Tag your whole photo library with a local AI model — your images never leave your machine. pyimgtag runs a vision model (Gemma via Ollama) over a folder or your Apple Photos library and writes 1–5 searchable tags, a scene category, location (from EXIF GPS, never guessed), and cleanup hints for every photo — on-device by default.
Then query, filter, score, and prune your library straight from the CLI. Optional cloud backends (Claude, OpenAI, Gemini) when you want them. Runs on macOS, Linux, and Windows.
Recorded against the bundled mock backend with asciinema + agg — regenerate with docs/record-demo.sh.
pip install pyimgtagpip install pyimgtag
ollama pull gemma4:e4b # one-time: pull the local vision model
pyimgtag run --input-dir ~/Photos # tag a folder, fully on-deviceAdd --dry-run to preview without writing, --limit N to sample, or --output-json results.json to export — see Quick Start below.
See also: Awesome-Ollama — a curated list of apps and tools built on local Ollama models, where pyimgtag fits in.
pyimgtag uses a vision model to analyse images and generate 1-5 descriptive
tags per photo. By default it calls a locally-running Gemma model via
Ollama, so image analysis and tagging stay on-device.
You can also point pyimgtag at a remote Ollama server or one of three
hosted vision APIs — Anthropic Claude,
OpenAI,
or Google Gemini — by passing
--backend. When a cloud backend is selected, the JPEG bytes leave the
machine; otherwise they don't.
If EXIF GPS is present, only the latitude/longitude is sent to OpenStreetMap Nominatim for reverse geocoding to a city/place; results are cached locally so repeat lookups stay offline.
Works on macOS, Linux, and Windows. Apple Photos integration (write-back) is macOS-only.
Key features:
- One model call per image, compact prompt, low token usage
- Pluggable vision backends: local Ollama (default), remote Ollama via
--ollama-url, Anthropic Claude, OpenAI, or Google Gemini via--backend - Rich AI metadata: scene category, emotional tone, cleanup classification, text detection, event hints
- EXIF GPS as source of truth for location (never guessed from image content)
- Open reverse geocoding via Nominatim (sends GPS coords to OpenStreetMap; cached locally)
- Supports exported folders and Apple Photos library originals (macOS only)
- Apple Photos write-back: push AI tags and descriptions back as keywords/captions (macOS only)
- Subcommands:
run,judge,status,reprocess,cleanup,cleanup-drift,preflight,query,tags,faces,review - Photo quality scoring: a single 1–10 score plus a model-written reason (
judgesubcommand) - Dry-run mode, date/limit filters, JSON/CSV export
- SQLite progress DB with schema versioning for incremental re-runs
- Python 3.12+
- Ollama installed and running
- Gemma 4 model pulled:
ollama pull gemma4:e4b
macOS-specific:
- Apple Silicon or Intel Mac
- Optional:
exiftoolfor reliable HEIC EXIF (falls back to Pillow) - Optional:
pillow-heiffor HEIC image loading
All platforms:
- Works on macOS, Linux, and Windows
- EXIF writing via
exiftool(if installed) works across platforms - Apple Photos write-back requires macOS
pip install -e ".[dev]"
# Pull the model
ollama pull gemma4:e4b
# Dry-run on an exported folder, first 20 images
pyimgtag run --input-dir ~/Pictures/exported --limit 20 --dry-run
# Single date
pyimgtag run --input-dir ~/Pictures/exported --date 2026-04-01 --dry-run
# Date range with JSON output
pyimgtag run --input-dir ~/Pictures/exported \
--date-from 2026-03-01 --date-to 2026-03-31 \
--output-json results.json
# Photos library
pyimgtag run --photos-library ~/Pictures/Photos\ Library.photoslibrary \
--limit 50 --dry-run
# Check processing progress
pyimgtag status
# Re-tag all photos (e.g. after prompt improvements)
pyimgtag reprocess --yes
# List photos flagged for deletion
pyimgtag cleanup
# Score photos by quality (judge)
pyimgtag judge --input-dir ~/Pictures/exported --limit 20 --verbose
# Filter to only strong photos, save ranking to JSON
pyimgtag judge --input-dir ~/Pictures/exported \
--min-score 7 --output-json ranking.json# From source
git clone https://github.com/kurok/pyimgtag.git
cd pyimgtag
pip install -e ".[dev]"
# Optional HEIC support
pip install pillow-heif
# Optional exiftool (better EXIF for HEIC)
brew install exiftool| Feature | macOS | Linux | Windows |
|---|---|---|---|
| Image tagging via Ollama | ✅ | ✅ | ✅ |
| EXIF reading (GPS, dates) | ✅ | ✅ | ✅ |
| Reverse geocoding (Nominatim) | ✅ | ✅ | ✅ |
EXIF writing via exiftool |
✅ | ✅ | ✅ |
| HEIC conversion (sips / pillow-heif) | ✅ sips + pillow-heif | ✅ pillow-heif | ✅ pillow-heif |
| RAW image support (rawpy) | ✅ | ✅ | ✅ |
| Apple Photos library scanning | ✅ | ❌ | ❌ |
| Apple Photos write-back | ✅ | ❌ | ❌ |
| Face management (Apple Photos) | ✅ | ❌ | ❌ |
Face naming via screen OCR (capture-names) |
✅ Vision OCR | ❌ | ❌ |
Note: Most features work cross-platform. Apple Photos integration and face management are macOS-only — they require AppleScript via osascript. faces capture-names additionally uses Apple's Vision framework for OCR (the [ocr] extra).
# Prerequisites
brew install ollama exiftool
ollama pull gemma4:e4b
# Install
pip install "pyimgtag[all]" # pillow-heif, photoscript, osxphotos, rawpy, face-recognition, fastapi, uvicorn, Vision OCR
# face naming add-ons: [photos-db] (osxphotos), [ocr] (Vision screen OCR, macOS)
# or from source
git clone https://github.com/kurok/pyimgtag.git
cd pyimgtag
pip install -e ".[all,dev]"Features available: everything including Apple Photos integration, HEIC, face management, and photo review workflows.
Typical macOS workflow:
# Tag your Photos library directly
pyimgtag run --photos-library ~/Pictures/Photos\ Library.photoslibrary --write-back --limit 50
# Score photo quality
pyimgtag judge --photos-library ~/Pictures/Photos\ Library.photoslibrary --min-score 8
# Import named faces from Apple Photos
pyimgtag faces import-photos # reads system default Photos libraryNote: Apple Photos library access requires Full Disk Access permission for your terminal app — grant it in System Settings > Privacy & Security > Full Disk Access.
pip install 'pyimgtag[face]' is sufficient. The face_recognition
library depends on face_recognition_models (dlib .dat files) which
was never published to PyPI. pyimgtag downloads those files automatically
on first use to ~/.cache/pyimgtag/face_models/ (~130 MB, one-time).
pip install 'pyimgtag[face]' # models download on first faces commandTo use pre-downloaded files (air-gapped installs, shared CI caches):
export PYIMGTAG_FACE_MODEL_DIR=/path/to/models
pyimgtag faces scan ...The four required files are the standard dlib model files from
ageitgey/face_recognition_models. If the automatic download fails,
download them manually and point PYIMGTAG_FACE_MODEL_DIR at the
directory containing the .dat files.
# Ubuntu/Debian
sudo apt-get install exiftool python3.12 python3-pip
# or install exiftool from https://exiftool.org
# Fedora/RHEL
sudo dnf install perl-Image-ExifTool python3.12
# Arch
sudo pacman -S perl-image-exiftool python
# Install Ollama
curl -fsSL https://ollama.com/install.sh | sh
ollama pull gemma4:e4b
# Install pyimgtag
pip install "pyimgtag[heic]" # includes pillow-heif for HEIC
# or from source
git clone https://github.com/kurok/pyimgtag.git
cd pyimgtag
pip install -e ".[heic,dev]"Features available: image tagging, EXIF reading/writing, geocoding, judge, dedup, JSON/CSV export. No Apple Photos integration.
Typical Linux workflow:
# Tag an exported photo directory
pyimgtag run --input-dir ~/Pictures/exported --output-json results.json
# With EXIF write-back (requires exiftool)
pyimgtag run --input-dir ~/Pictures/exported --write-exif
# Score photo quality
pyimgtag judge --input-dir ~/Pictures/exported --min-score 7 --output-json ranking.jsonNote: --write-back (Apple Photos) is skipped on Linux with a warning. Use --write-exif instead.
# Install Python 3.12+ from https://python.org
# Install Ollama from https://ollama.com
# Install exiftool — download from https://exiftool.org/
# Or via Chocolatey:
choco install exiftool
# Or via winget:
winget install OliverBetz.ExifTool
ollama pull gemma4:e4b
# Install pyimgtag
pip install "pyimgtag[heic]"
# or from source
git clone https://github.com/kurok/pyimgtag.git
cd pyimgtag
pip install -e ".[heic,dev]"Features available: same as Linux — tagging, EXIF, geocoding, judge, dedup, export. No Apple Photos integration.
Typical Windows workflow (PowerShell):
# Tag photos in a folder
pyimgtag run --input-dir C:\Users\Me\Pictures\exported --output-json results.json
# Score photo quality
pyimgtag judge --input-dir C:\Users\Me\Pictures\exported --min-score 7
# Check what was processed
pyimgtag statusNote: On Windows, use \ path separators or quote paths with spaces: "C:\My Photos".
macOS:
- "Operation not permitted" on Photos library → grant Full Disk Access to Terminal in System Settings > Privacy & Security > Full Disk Access
exiftoolnot found →brew install exiftool- HEIC files not loading →
pip install pillow-heif - Ollama not running →
brew services start ollamaor runollama serve
Linux:
exiftoolnot found → install via package manager (see setup above)- HEIC files not loading →
pip install pillow-heif - Ollama not running →
ollama servein a separate terminal - Permission denied on image folder → check directory permissions with
ls -la
Windows:
exiftoolnot found → add exiftool directory to PATH, or install via Chocolatey/winget- Python not found → ensure Python 3.12+ is installed and added to PATH during install
- HEIC files not loading →
pip install pillow-heif - Ollama not running → start Ollama from system tray or run
ollama serve - Long paths issue → enable long path support:
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1
pyimgtag uses subcommands. Run pyimgtag --help for the full list.
# Exported image folder
pyimgtag run --input-dir /path/to/photos
# Apple Photos library
pyimgtag run --photos-library ~/Pictures/Photos\ Library.photoslibrary
# With filters
pyimgtag run --input-dir /path/to/photos \
--limit 100 --date-from 2026-03-01 --date-to 2026-03-31
# Write tags back to Apple Photos as keywords
pyimgtag run --photos-library ~/Pictures/Photos\ Library.photoslibrary \
--write-back --limit 10
# Deduplicate by perceptual hash
pyimgtag run --input-dir /path/to/photos --dedup
# Export to JSON
pyimgtag run --input-dir /path/to/photos --output-json results.jsonBy default pyimgtag calls a local Ollama server. Use --backend to pick a
different provider; the same prompt and result schema apply across backends.
# Default: local Ollama
pyimgtag run --input-dir /path/to/photos
# Remote Ollama server (e.g. on another machine in your LAN)
pyimgtag run --input-dir /path/to/photos --ollama-url http://gpu-host:11434
# Anthropic Claude
ANTHROPIC_API_KEY=sk-ant-... pyimgtag run --input-dir /path/to/photos \
--backend anthropic
# OpenAI (override the default model if needed)
OPENAI_API_KEY=sk-... pyimgtag run --input-dir /path/to/photos \
--backend openai --model gpt-4o
# Google Gemini
GOOGLE_API_KEY=... pyimgtag run --input-dir /path/to/photos \
--backend geminiPer-backend defaults:
| Backend | Default model | Auth env var |
|---|---|---|
ollama |
gemma4:e4b |
none (uses --ollama-url) |
anthropic |
claude-sonnet-4-6 |
ANTHROPIC_API_KEY |
openai |
gpt-4o-mini |
OPENAI_API_KEY |
gemini |
gemini-1.5-flash |
GOOGLE_API_KEY (or GEMINI_API_KEY) |
Cloud backends send the JPEG bytes for each image to the provider. Use
--api-base to override the base URL (for self-hosted gateways or
proxies) and --api-key if you want to pass the secret on the command
line instead of via an environment variable. The --backend flag works
identically for pyimgtag judge.
Run flags:
| Flag | Description |
|---|---|
--input-dir PATH |
Exported image folder |
--photos-library PATH |
Apple Photos library package (macOS only) |
--limit N |
Max images to process |
--date YYYY-MM-DD |
Single date filter |
--date-from / --date-to |
Date range filter |
--extensions jpg,png |
File types (default: jpg,jpeg,heic,png) |
--skip-no-gps |
Skip images without GPS data |
--dry-run |
Verbose output, no DB writes |
--verbose / -v |
Detailed per-file output |
--output-json FILE |
Write results to JSON |
--output-csv FILE |
Write results to CSV |
--jsonl-stdout |
JSONL output to stdout |
--no-recursive |
Only scan the top-level directory (no subdirectories) |
--newest-first |
Process newest files first (by modification time) |
--write-back |
Write tags/description back to Apple Photos (macOS only; uses osascript by default — set PYIMGTAG_USE_PHOTOSCRIPT=1 to opt into the faster in-process photoscript path on stable hosts) |
--write-back-mode overwrite|append |
Write-back strategy: replace all keywords (overwrite, default) or merge with existing (append) |
--skip-if-tagged |
Skip the model for photos that already have keywords in Apple Photos (--photos-library only) |
--write-exif |
Write description and keywords to image EXIF via exiftool |
--sidecar-only |
Write metadata to an XMP sidecar (.xmp) instead of modifying the original |
--metadata-format auto|xmp|iptc|exif |
Which metadata fields to write with --write-exif (default: auto, all fields) |
--dedup |
Skip duplicates via perceptual hash |
--dedup-threshold N |
Hamming distance threshold (default: 5) |
--backend ollama|anthropic|openai|gemini |
Vision-model backend (default: ollama) |
--model NAME |
Model name (backend-specific default; ollama: gemma4:e4b) |
--ollama-url URL |
Ollama API URL (used when --backend=ollama; supports remote Ollama) |
--api-base URL / --api-key KEY |
Cloud-API base URL / key (anthropic / openai / gemini) |
--max-dim N |
Max image dimension (default: 1280) |
--timeout N |
Model request timeout in seconds |
--db PATH |
Progress database path |
--no-cache |
Skip progress DB, reprocess all |
--skip-existing |
Fully skip unchanged photos already complete in the DB (fastest resume; takes precedence over --resume-from-db) |
--resume-from-db |
Reuse cached model results for unchanged files; only re-run local enrichment (EXIF, geocoding) |
--resume-threaded |
With --resume-from-db: enrich cached items in a background thread |
# Show processing stats
pyimgtag status
# Output:
# Progress: 140 / 200 (70%)
# ok: 140
# error: 2
# pending: 58# Reset everything (e.g. after prompt improvements) — requires --yes
pyimgtag reprocess --yes
# Reset only failed entries
pyimgtag reprocess --status errorA full reset (no --status) wipes all tagging progress, so it refuses to run
and exits 1 unless you confirm with --yes.
# List photos the AI flagged as "delete"
pyimgtag cleanup
# Also include "review" (uncertain) candidates
pyimgtag cleanup --include-review
# Output:
# Cleanup candidates (delete): 12
#
# [delete] /path/to/blurry_photo.jpg | 2026-03-15 | tags: blurry, dark
# [delete] /path/to/screenshot.png | 2026-04-01 | tags: screenshot, textFinds progress-DB rows whose backing file is gone (and, on macOS, photos that
Apple Photos no longer indexes). Lists the dead rows by default; pass --prune
to delete them from the database.
# Dry run: list the dead rows (default behaviour)
pyimgtag cleanup-drift
# Actually delete the dead rows from the DB
pyimgtag cleanup-drift --prune# Search by tag
pyimgtag query --tag sunset
# Search by city / country
pyimgtag query --city "San Francisco"
pyimgtag query --country Italy
# Filter by scene / cleanup / status
pyimgtag query --scene-category outdoor_travel --status ok
pyimgtag query --cleanup delete
# Filter by text-detection state
pyimgtag query --has-text
pyimgtag query --no-text
# Output as JSON or just paths (e.g. for shell pipelines)
pyimgtag query --tag beach --format json
pyimgtag query --tag beach --format paths --limit 50The face workflow chains a handful of sub-actions. Detection, clustering,
review, apply, and the UI work cross-platform on --input-dir folders; the
Apple Photos sources (--photos-library, import-photos, and
capture-names --live) require macOS.
# 1. Detect faces and compute embeddings (also accepts --input-dir on any platform)
pyimgtag faces scan --photos-library ~/Pictures/Photos\ Library.photoslibrary
# Faster on multi-core machines — fan detection+embedding across processes:
pyimgtag faces scan --photos-library ~/Pictures/Photos\ Library.photoslibrary \
--quality accurate -j 8 # -j 0 = one worker per CPU core
# 2. Cluster embeddings into person groups (DBSCAN)
pyimgtag faces cluster --eps 0.5 --min-samples 2
# 3. Inspect the clusters from the CLI
pyimgtag faces review
# 4. Name the auto clusters — pick whichever fits your library (see table below):
pyimgtag faces import-photos # read names from the Photos DB (osxphotos)
pyimgtag faces match-references ~/faces/refs # match clusters to labeled images
pyimgtag faces capture-names --live --apply # OCR names off the People-view screenshot
# 5. Write person keywords to image metadata (EXIF or XMP sidecar)
pyimgtag faces apply --write-exif
pyimgtag faces apply --sidecar-only --dry-run
# 6. Manage clusters via the web UI (rename, merge, delete)
pyimgtag faces ui # serves the unified webapp on http://127.0.0.1:8766The faces ui person grid can sort by face count (most / fewest faces)
or name, and supports multi-select to confirm or delete several people
at once. Selecting unassigned faces lets you create a new person or assign
them to an existing one.
Scan quality & speed. scan takes a --quality {fast,balanced,accurate}
preset (default balanced) plus granular overrides --detection-model {hog,cnn},
--max-dim, --upsample, --num-jitters, --min-face-size, --extensions,
and --limit. accurate re-samples each face 10× when encoding — the slow
part — so on a large library combine it with --jobs/-j N to run detection
- embedding across CPU cores (workers do the CPU work; the main process does all
DB writes).
-j 0auto-picks the core count; the default-j 1is serial and unchanged. Already-scanned images are skipped, so re-runs resume. Same dashboard flags asrun/judge(--web/--no-web/--web-host/--web-port/--no-browser).
Naming the clusters. Three ways to turn "Person 7" into real names; all are conservative (they leave trusted/named people untouched) and reuse the same embedding matcher:
| Command | Gets names from | Needs | Best when |
|---|---|---|---|
import-photos |
the Apple Photos library DB via osxphotos (AppleScript/photoscript fallback) | [photos-db], macOS |
Photos already has your people named |
match-references <dir> |
a folder of labeled images (Alice.jpg or Alice/01.jpg) |
[face] |
you have a few clean reference shots per person |
capture-names |
OCR of a People-grid screenshot (--screenshot FILE, or --live to grab it now) |
[ocr] + [face], macOS |
AppleScript enumeration fails (the -2741 error) and you'd rather screenshot than curate files |
match-references and capture-names are dry-run by default — add --apply
to write the names, --threshold to tune match strictness. capture-names also
accepts --languages ru-RU,en-US to steer Vision OCR for non-Latin names, and
--save-screenshot PATH to keep a --live capture.
--liveneeds Screen Recording permission. Programmatic window capture requires it — grant it to your terminal under System Settings → Privacy & Security → Screen Recording, then retry. (--livecaptures the Photos window by its window id, so it works even on secondary displays.) If you'd rather not grant it, take the screenshot yourself (Cmd-Shift-4, then Space, click the Photos window) and pass--screenshot PATH— that path needs no permission.
The [face] extra is required for all detection/embedding; model files
are downloaded automatically on first use (see
Face features: model files are downloaded automatically). import-photos additionally needs [photos-db] (pip install 'pyimgtag[photos-db]'); capture-names additionally needs [ocr] (pip install 'pyimgtag[ocr]', macOS only).
# Browse the progress DB, edit tags, change cleanup class
pyimgtag review # serves on http://127.0.0.1:8765
pyimgtag review --port 9000 --no-browserThis serves the same unified webapp as run --web, just bound to a
different default port. See Local webapp below for the
full page list. Requires the [review] extra (pip install 'pyimgtag[review]').
# List all tags with image counts
pyimgtag tags list
# Rename a tag across all images
pyimgtag tags rename old-name new-name
# Delete a tag from all images
pyimgtag tags delete unwanted-tag --dry-run
# Merge one tag into another
pyimgtag tags merge source-tag target-tag# Verify Ollama, model, and source path
pyimgtag preflight --input-dir ~/Pictures/exportedScore each image with a single overall quality score — an integer on a 1–10 scale — plus a short natural-language reason written by the model. The prompt asks the model to weigh impact/creativity/storytelling, technical quality, and composition, but only the one score and the reason are returned. Outputs a ranked list. Uses the same pluggable vision backends as run — local Ollama by default, or --backend anthropic / openai / gemini.
# Score all images in a folder
pyimgtag judge --input-dir ~/Pictures/exported
# Only show photos scoring 7 or above
pyimgtag judge --input-dir ~/Pictures/exported --min-score 7
# Verbose output (score plus the model's reason)
pyimgtag judge --input-dir ~/Pictures/exported --limit 20 --verbose
# Sort by filename instead of score
pyimgtag judge --input-dir ~/Pictures/exported --sort-by name
# Score Photos library
pyimgtag judge --photos-library ~/Pictures/Photos\ Library.photoslibrary \
--limit 50 --min-score 8
# Save full ranking to JSON
pyimgtag judge --input-dir ~/Pictures/exported \
--output-json ranking.jsonSample output (brief mode):
[1/5] golden_hour.jpg → 9/10 outstanding
[2/5] portrait.jpg → 7/10 solid
Sample output (--verbose):
[1/5] golden_hour.jpg
Score: 9/10
Reason: Golden light over the cityscape; strong composition but slight haloing on edges.
Judge flags:
| Flag | Default | Description |
|---|---|---|
--input-dir PATH |
— | Exported image folder |
--photos-library PATH |
— | Apple Photos library (macOS only) |
--limit N |
unlimited | Max images to score |
--extensions EXT,... |
jpg,jpeg,heic,png,tiff,webp |
File types |
--min-score SCORE |
— | Only show images scoring ≥ SCORE |
--sort-by score|name |
score |
Final sort order |
--output-json FILE |
— | Write ranked results to JSON |
--verbose |
false | Show the model's reason for each score |
--no-recursive |
false | Do not scan subdirectories |
--backend ollama|anthropic|openai|gemini |
ollama |
Vision-model backend (same as pyimgtag run) |
--model NAME |
backend-specific | Model name; defaults gemma4:e4b / claude-sonnet-4-6 / gpt-4o-mini / gemini-1.5-flash |
--ollama-url URL |
http://localhost:11434 |
Ollama API URL (used when --backend=ollama) |
--api-base URL |
provider default | Override the cloud-API base URL (anthropic / openai / gemini) |
--api-key KEY |
env var | Cloud-API key; defaults to the provider's conventional env var |
--max-dim N |
1280 |
Max image dimension before resize |
--timeout N |
120 |
Request timeout (seconds) |
--db PATH |
~/.cache/pyimgtag/progress.db |
Progress DB path; judge scores share the same DB as run |
--skip-judged |
false | Skip images that already have a row in judge_scores |
--write-back |
false | Write the score keyword back to Apple Photos (macOS + --photos-library only) |
--write-back-mode overwrite|append |
overwrite |
Whether write-back replaces or merges keywords |
--web / --no-web / --web-host / --web-port / --no-browser |
— | Same dashboard flags as pyimgtag run (default port 8770) |
[1/50] sunset_beach.jpg
Path: /Users/me/Pictures/exported/sunset_beach.jpg
Date: 2026-04-01 14:30:00
Tags: sunset, beach, ocean, waves, sand
Summary: golden hour sunset over the Pacific
Scene: outdoor_leisure
Tone: positive
Cleanup: keep
Event: outing
Signif.: high
GPS: 37.7749, -122.4194
Location: San Francisco, California, United States
Status: ok
--- Summary ---
Scanned: 200
Processed: 50
Skipped (date): 0
Skipped (no GPS): 0
Skipped (no file):0
Model failures: 2
Geocode failures: 0
Each result (JSON/CSV) includes:
| Field | Description |
|---|---|
file_path |
Full path to image |
file_name |
Filename |
source_type |
directory or photos_library |
image_date |
EXIF or file date |
tags |
1-5 vision model tags |
scene_summary |
Short scene description |
scene_category |
indoor_home, indoor_work, outdoor_leisure, outdoor_travel, transport, other |
emotional_tone |
positive, neutral, negative, mixed |
cleanup_class |
keep, review, delete |
has_text |
Whether image contains readable text |
text_summary |
Extracted text summary (if has_text) |
event_hint |
outing, gathering, work, travel, daily, other |
significance |
high, medium, low |
gps_lat / gps_lon |
EXIF GPS coordinates |
nearest_place |
Village/town/suburb |
nearest_city |
City |
nearest_region |
State/region |
nearest_country |
Country |
processing_status |
ok or error |
error_message |
Error details if any |
phash |
Perceptual hash (when --dedup used) |
Results from pyimgtag judge --output-json use a different structure:
| Field | Description |
|---|---|
file_path |
Full path to image |
file_name |
Filename |
weighted_score |
Overall score (integer 1–10) |
reason |
The model's natural-language justification for the score (2–4 sentences) |
verdict |
Legacy field — empty string for new runs; populated only for rows produced by the old 13-criterion prompt |
core_score / visible_score |
Legacy compatibility values — for new runs both equal weighted_score |
scores.* |
Legacy 13-criterion fields (impact, story_subject, composition_center, lighting, creativity_style, color_mood, presentation_crop, technical_excellence, focus_sharpness, exposure_tonal, noise_cleanliness, subject_separation, edit_integrity) — for new runs each one mirrors weighted_score; they carry distinct per-criterion values only for legacy DB rows |
src/pyimgtag/
main.py CLI entry point and subcommand dispatch (thin)
models.py Data classes (ExifData, TagResult, GeoResult, ImageResult)
scanner.py Directory and Photos library scanning
exif_reader.py EXIF GPS + date extraction (exiftool + Pillow)
ollama_client.py Ollama vision API client (rich structured response)
cloud_clients.py Anthropic / OpenAI / Gemini vision-API adapters
geocoder.py Nominatim reverse geocoder with disk cache
filters.py Date filter logic
output_writer.py JSON/CSV/JSONL output
progress_db.py SQLite progress DB with versioned migrations
applescript_writer.py Apple Photos keyword/description write-back
_face_dep_check.py Preflight for face_recognition; auto-downloads model files via _face_model_cache
face_ocr.py Screen-OCR naming: Vision OCR of a People-view screenshot → cluster names
dedup.py Perceptual hash duplicate detection
heic_converter.py HEIC to JPEG conversion (macOS sips)
cache.py Simple JSON disk cache
judge_scorer.py Score aggregation (legacy 13-criterion weighting; a no-op for new single-score runs)
preflight.py Shared preflight helpers
commands/
run.py `pyimgtag run` handler
judge.py `pyimgtag judge` handler
db.py `pyimgtag status/reprocess/cleanup` handlers
query.py `pyimgtag query` handler
tags.py `pyimgtag tags` handler
faces.py `pyimgtag faces` (scan [--jobs] / cluster / review / apply / import-photos / match-references / capture-names / ui)
preflight_cmd.py `pyimgtag preflight` handler
review_cmd.py `pyimgtag review` handler
webapp/
__main__.py `python -m pyimgtag.webapp` standalone uvicorn launcher
unified_app.py FastAPI app composition + `/health` endpoint
nav.py Shared nav shell + design system
routes_review.py `/review` router (browse / edit / lightbox)
routes_faces.py `/faces` router (cluster management UI)
routes_tags.py `/tags` router
routes_query.py `/query` router
routes_judge.py `/judge` router (rating grid + filter/sort)
routes_edit.py `/edit` router (bulk-delete from Photos)
routes_about.py `/about` router (version / update check / wiki)
dashboard_server.py / server_thread.py / bootstrap.py
In-process dashboard for `run` / `judge` / `faces scan`
pip install -e ".[dev,lint,security]"
pytest tests/ -v
ruff format src/ tests/ && ruff check src/ tests/ --fix
python -m mypy src/pyimgtag/ --ignore-missing-imports --disable-error-code import-untyped
python -m bandit -r src/pyimgtag/ -c pyproject.toml
pre-commit install && pre-commit run --all-filesThe pr-tests GitHub Actions workflow runs unit tests and a
Playwright Chromium smoke that boots the dashboard on every PR. To run
the same checks locally before pushing:
# Installs deps + Chromium, starts the app on :8000, waits for /health,
# runs unit tests, runs the Playwright smoke, then stops the app cleanly.
scripts/test-smoke-local.sh
# Custom port / visible browser:
PORT=8765 scripts/test-smoke-local.sh
PYIMGTAG_E2E_HEADLESS=0 scripts/test-smoke-local.shThe smoke test auto-discovers every link in the top nav, clicks each
one, and fails the run on any of: HTTP 5xx, an uncaught JS error,
a console.error, a blank page, or a heading-less page.
Inspecting failures. When a smoke test fails — locally or in CI —
artefacts land under tests/e2e/artifacts/<test-id>/:
| File | What it shows |
|---|---|
screenshot.png |
full-page PNG of the page when the assertion fired |
trace.zip |
Playwright trace — open with playwright show-trace trace.zip for DOM, network log, and per-step screenshots |
app.log (parent dir) |
uvicorn access log + tracebacks from the dashboard process |
CI uploads the same tests/e2e/artifacts/ directory as a workflow
artifact named pr-tests-artifacts. Download it from the failed
run's "Artifacts" panel on GitHub.
Required checks. A PR can merge once the Unit + E2E smoke job in
the pr-tests workflow passes (alongside the existing Python package matrix and CodeQL).
Rerunning pyimgtag run on an already-processed library normally skips unchanged files.
With --resume-from-db, those files are re-hydrated from the database instead of being
silently skipped, so their results still appear in output files and the --write-back path
runs again.
Only local enrichment is repeated (EXIF, reverse geocoding). The AI model is not called again.
# Normal run — first pass, all files sent to Ollama
pyimgtag run --photos-library ~/Pictures/Photos\ Library.photoslibrary \
--db ~/my-progress.db --write-back
# Resume after interruption — unchanged files load from DB, only new files hit Ollama
pyimgtag run --photos-library ~/Pictures/Photos\ Library.photoslibrary \
--db ~/my-progress.db --write-back --resume-from-db
# Threaded resume — cached-item enrichment runs in a background thread
# while the main thread continues sending new files to Ollama
pyimgtag run --photos-library ~/Pictures/Photos\ Library.photoslibrary \
--db ~/my-progress.db --write-back --resume-from-db --resume-threadedA file is eligible for DB resume if:
- Its size and modification time have not changed since the last run.
- The cached entry has at least one tag.
For the fastest possible resume over a large, mostly-tagged library, use
--skip-existing instead. It fully skips any unchanged photo that already has
a usable result in the DB (status ok + non-empty tags) — no EXIF re-read,
geocoding, write-back, or DB rewrite. Skipped photos are not re-written
even with --write-back / --write-exif. --skip-existing takes precedence
over --resume-from-db and is ignored when --no-cache is set.
# Fastest resume — fully skip photos already complete in the DB
pyimgtag run --photos-library ~/Pictures/Photos\ Library.photoslibrary \
--db ~/my-progress.db --skip-existingUse pyimgtag reprocess --db ~/my-progress.db --yes to force a full re-run for all files,
or pyimgtag reprocess --db ~/my-progress.db --status error to retry only failed files.
pyimgtag run, pyimgtag judge, and pyimgtag faces scan auto-start a
local webapp at http://127.0.0.1:8770 by default. The same unified app
hosts a single top-nav with these pages:
/— Dashboard (live progress, status, quick links)./review— browse DB entries, edit tags, change cleanup class./faces— manage person clusters, rename, merge, delete./tags— list, rename, merge, delete tags across the DB./query— full-text/tag/scene/judge filters with hover thumbnails./judge— judge-score grid with rating filter / sort / pager./edit— bulk-delete files markedcleanup_class='delete'from Apple Photos (macOS only; gated behind an explicit confirm)./about— installed version, latest PyPI release, update check, wiki links./health— plain JSON liveness probe ({ok, version, db}); used by the pre-PR + CI smoke runners.
The standalone commands continue to work and serve the same unified app:
pyimgtag reviewon http://127.0.0.1:8765 (review at/review).pyimgtag faces uion http://127.0.0.1:8766 (faces at/faces).python -m pyimgtag.webappfor a bare uvicorn launch — readsHOST/PORT/PYIMGTAG_DB/PYIMGTAG_LOG_LEVELfrom the environment. This is the same launch surface used byscripts/test-smoke-local.shand thepr-testsGitHub Actions workflow.
Flags (apply to run, judge, faces scan):
--no-web— terminal-only mode, no server started.--web— force-enable (overridesPYIMGTAG_NO_WEB=1).--web-host HOST— bind host (default127.0.0.1).--web-port PORT— bind port (default8770).--no-browser— do not auto-open the browser.
Pause semantics are cooperative: the gate is checked before each file so in-flight Ollama / face-detection requests are never interrupted mid-call.
Migration note: the pyimgtag review and pyimgtag faces ui commands
now serve the unified app, so the URL paths have shifted. Bookmarks that
used http://localhost:8765/api/stats should be updated to
http://localhost:8765/review/api/stats.
Security note: The review server has no authentication. It is designed for single-user, localhost use only. The default --host 127.0.0.1 / --web-host 127.0.0.1 binding is safe. Do not change the host to 0.0.0.0 on a shared or networked machine — anyone who can reach the port can delete your photos. There is no CSRF protection. The /edit page performs irreversible operations on your Photos library.
| Variable | Used by | Effect |
|---|---|---|
PYIMGTAG_BACKEND |
pyimgtag run / pyimgtag judge |
Default vision backend (ollama / anthropic / openai / gemini). Overridden by --backend. |
OLLAMA_URL |
pyimgtag run / judge / preflight |
Default Ollama base URL (default http://localhost:11434). Overridden by --ollama-url. |
ANTHROPIC_API_KEY |
--backend anthropic |
Auth for Claude. Overridden by --api-key. |
OPENAI_API_KEY |
--backend openai |
Auth for OpenAI. Overridden by --api-key. |
GOOGLE_API_KEY / GEMINI_API_KEY |
--backend gemini |
Auth for Gemini (either name accepted). Overridden by --api-key. |
API key security: Prefer env vars over
--api-key. The--api-keyargument is visible to other users inps auxprocess listings on shared machines. |PYIMGTAG_NO_WEB| All commands that start the dashboard |1/true/yesdisables the dashboard by default (same as--no-web). | |PYIMGTAG_NO_UPDATE_CHECK| Allpyimgtaginvocations | Skip the PyPI update check on startup. | |PYIMGTAG_USE_PHOTOSCRIPT|--write-back/ faces import |1/true/yesopts into the in-process photoscript path instead of the defaultosascriptsubprocess. | |PYIMGTAG_PARSE_ERROR_LOG|pyimgtag run| Path to write JSON-parse error log (opt-in; disabled by default). May contain photo descriptions — do not store in a synced or shared directory. | |PYIMGTAG_DB|python -m pyimgtag.webapp| Override the progress-DB path the standalone webapp opens. | |PYIMGTAG_LOG_LEVEL|python -m pyimgtag.webapp| uvicorn log level (defaultinfo). | |HOST/PORT|python -m pyimgtag.webapp| Bind host / port for the standalone launcher (default127.0.0.1:8000). | |PYIMGTAG_SCREENSHOT_DB|tests/local/test_webapp_screenshots.py| Walk the screenshot suite against an existing DB instead of a sandboxed one. | |BASE_URL/PYIMGTAG_E2E_HEADLESS|tests/e2e/Playwright suite | Override smoke-test target URL / run with a visible browser. |
See CONTRIBUTING.md.
Found a vulnerability? Please follow the disclosure flow in SECURITY.md -- do not file a public issue.
MIT -- see LICENSE.
