Skip to content

kurok/pyimgtag

pyimgtag

CI CodeQL PyPI version Python versions License: MIT codecov

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.

Demo

Tagging a folder of photos with pyimgtag and a local model, then querying the results as a table, filtering by tag, and listing cleanup candidates

Recorded against the bundled mock backend with asciinema + agg — regenerate with docs/record-demo.sh.

Install

pip install pyimgtag

Minimal example

pip install pyimgtag
ollama pull gemma4:e4b              # one-time: pull the local vision model
pyimgtag run --input-dir ~/Photos   # tag a folder, fully on-device

Add --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.

Overview

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 (judge subcommand)
  • Dry-run mode, date/limit filters, JSON/CSV export
  • SQLite progress DB with schema versioning for incremental re-runs

Requirements

  • Python 3.12+
  • Ollama installed and running
  • Gemma 4 model pulled: ollama pull gemma4:e4b

macOS-specific:

  • Apple Silicon or Intel Mac
  • Optional: exiftool for reliable HEIC EXIF (falls back to Pillow)
  • Optional: pillow-heif for 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

Quick Start

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

Installation

# 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

Platform Support

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).

macOS Setup

# 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 library

Note: Apple Photos library access requires Full Disk Access permission for your terminal app — grant it in System Settings > Privacy & Security > Full Disk Access.

Face features: model files are downloaded automatically

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 command

To 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.

Linux Setup

# 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.json

Note: --write-back (Apple Photos) is skipped on Linux with a warning. Use --write-exif instead.

Windows Setup

# 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 status

Note: On Windows, use \ path separators or quote paths with spaces: "C:\My Photos".

Platform Troubleshooting

macOS:

  • "Operation not permitted" on Photos library → grant Full Disk Access to Terminal in System Settings > Privacy & Security > Full Disk Access
  • exiftool not found → brew install exiftool
  • HEIC files not loading → pip install pillow-heif
  • Ollama not running → brew services start ollama or run ollama serve

Linux:

  • exiftool not found → install via package manager (see setup above)
  • HEIC files not loading → pip install pillow-heif
  • Ollama not running → ollama serve in a separate terminal
  • Permission denied on image folder → check directory permissions with ls -la

Windows:

  • exiftool not 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

Usage

Subcommands

pyimgtag uses subcommands. Run pyimgtag --help for the full list.

pyimgtag run — tag images

# 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.json
Choosing a vision backend

By 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 gemini

Per-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

pyimgtag status — check progress

# Show processing stats
pyimgtag status

# Output:
# Progress: 140 / 200 (70%)
#   ok:      140
#   error:   2
#   pending: 58

pyimgtag reprocess — reset for re-tagging

# Reset everything (e.g. after prompt improvements) — requires --yes
pyimgtag reprocess --yes

# Reset only failed entries
pyimgtag reprocess --status error

A full reset (no --status) wipes all tagging progress, so it refuses to run and exits 1 unless you confirm with --yes.

pyimgtag cleanup — find photos to delete

# 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, text

pyimgtag cleanup-drift — prune dead DB rows

Finds 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

pyimgtag query — search tagged images

# 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 50

pyimgtag faces — face detection, clustering, naming

The 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:8766

The 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 0 auto-picks the core count; the default -j 1 is serial and unchanged. Already-scanned images are skipped, so re-runs resume. Same dashboard flags as run / 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.

--live needs Screen Recording permission. Programmatic window capture requires it — grant it to your terminal under System Settings → Privacy & Security → Screen Recording, then retry. (--live captures 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).

pyimgtag review — launch the local review UI

# Browse the progress DB, edit tags, change cleanup class
pyimgtag review                      # serves on http://127.0.0.1:8765
pyimgtag review --port 9000 --no-browser

This 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]').

pyimgtag tags — manage tags

# 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

pyimgtag preflight — check prerequisites

# Verify Ollama, model, and source path
pyimgtag preflight --input-dir ~/Pictures/exported

pyimgtag judge — score photo quality

Score 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.json

Sample 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)

Sample verbose output

[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

Output schema

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)

Judge output schema

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

Architecture

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`

Development

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-files

Pre-PR smoke (tests/e2e/)

The 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.sh

The 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).

Resume and Enrichment

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-threaded

A 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-existing

Use 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.

Local webapp

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 marked cleanup_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 review on http://127.0.0.1:8765 (review at /review).
  • pyimgtag faces ui on http://127.0.0.1:8766 (faces at /faces).
  • python -m pyimgtag.webapp for a bare uvicorn launch — reads HOST / PORT / PYIMGTAG_DB / PYIMGTAG_LOG_LEVEL from the environment. This is the same launch surface used by scripts/test-smoke-local.sh and the pr-tests GitHub Actions workflow.

Flags (apply to run, judge, faces scan):

  • --no-web — terminal-only mode, no server started.
  • --web — force-enable (overrides PYIMGTAG_NO_WEB=1).
  • --web-host HOST — bind host (default 127.0.0.1).
  • --web-port PORT — bind port (default 8770).
  • --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.

Environment variables

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-key argument is visible to other users in ps aux process listings on shared machines. | PYIMGTAG_NO_WEB | All commands that start the dashboard | 1 / true / yes disables the dashboard by default (same as --no-web). | | PYIMGTAG_NO_UPDATE_CHECK | All pyimgtag invocations | Skip the PyPI update check on startup. | | PYIMGTAG_USE_PHOTOSCRIPT | --write-back / faces import | 1 / true / yes opts into the in-process photoscript path instead of the default osascript subprocess. | | 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 (default info). | | HOST / PORT | python -m pyimgtag.webapp | Bind host / port for the standalone launcher (default 127.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. |

Contributing

See CONTRIBUTING.md.

Security

Found a vulnerability? Please follow the disclosure flow in SECURITY.md -- do not file a public issue.

License

MIT -- see LICENSE.

About

Tag images with AI using a local Gemma model (via Ollama) — EXIF GPS reverse geocoding, Apple Photos write-back on macOS, no cloud, fully on-device

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages