{"id":48994740,"url":"https://github.com/kurok/pyimgtag","last_synced_at":"2026-05-30T22:01:18.213Z","repository":{"id":351602583,"uuid":"1211629161","full_name":"kurok/pyimgtag","owner":"kurok","description":"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","archived":false,"fork":false,"pushed_at":"2026-05-28T21:13:21.000Z","size":1237,"stargazers_count":3,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-28T23:13:33.025Z","etag":null,"topics":["exif","gemma","image-tagging","local-ai","macos","ollama","photos","python","reverse-geocoding"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/kurok.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":["kurok"]}},"created_at":"2026-04-15T15:28:42.000Z","updated_at":"2026-05-28T21:13:26.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/kurok/pyimgtag","commit_stats":null,"previous_names":["kurok/pyimgtag"],"tags_count":34,"template":false,"template_full_name":null,"purl":"pkg:github/kurok/pyimgtag","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kurok%2Fpyimgtag","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kurok%2Fpyimgtag/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kurok%2Fpyimgtag/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kurok%2Fpyimgtag/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/kurok","download_url":"https://codeload.github.com/kurok/pyimgtag/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kurok%2Fpyimgtag/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33711018,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-30T02:00:06.278Z","response_time":92,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["exif","gemma","image-tagging","local-ai","macos","ollama","photos","python","reverse-geocoding"],"created_at":"2026-04-18T16:10:38.173Z","updated_at":"2026-05-30T22:01:18.205Z","avatar_url":"https://github.com/kurok.png","language":"Python","funding_links":["https://github.com/sponsors/kurok"],"categories":[],"sub_categories":[],"readme":"# pyimgtag\n\n[![CI](https://github.com/kurok/pyimgtag/actions/workflows/python-package.yml/badge.svg)](https://github.com/kurok/pyimgtag/actions/workflows/python-package.yml)\n[![CodeQL](https://github.com/kurok/pyimgtag/actions/workflows/codeql.yml/badge.svg)](https://github.com/kurok/pyimgtag/actions/workflows/codeql.yml)\n[![PyPI version](https://img.shields.io/pypi/v/pyimgtag)](https://pypi.org/project/pyimgtag/)\n[![Python versions](https://img.shields.io/pypi/pyversions/pyimgtag)](https://pypi.org/project/pyimgtag/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![codecov](https://codecov.io/gh/kurok/pyimgtag/graph/badge.svg)](https://codecov.io/gh/kurok/pyimgtag)\n\nTag images using a local Gemma model for searchable tags, with optional Apple Photos integration on macOS.\n\n## Overview\n\npyimgtag uses a vision model to analyse images and generate 1-5 descriptive\ntags per photo. By default it calls a locally-running Gemma model via\n[Ollama](https://ollama.ai), so image analysis and tagging stay on-device.\nYou can also point pyimgtag at a **remote Ollama server** or one of three\nhosted vision APIs — [Anthropic Claude](https://docs.anthropic.com/en/api/messages),\n[OpenAI](https://platform.openai.com/docs/api-reference/chat),\nor [Google Gemini](https://ai.google.dev/api/generate-content) — by passing\n`--backend`. When a cloud backend is selected, the JPEG bytes leave the\nmachine; otherwise they don't.\n\nIf EXIF GPS is present, only the latitude/longitude is sent to [OpenStreetMap\nNominatim](https://nominatim.openstreetmap.org/) for reverse geocoding to a\ncity/place; results are cached locally so repeat lookups stay offline.\n\nWorks on **macOS, Linux, and Windows**. Apple Photos integration (write-back) is macOS-only.\n\n**Key features:**\n\n- One model call per image, compact prompt, low token usage\n- **Pluggable vision backends**: local Ollama (default), remote Ollama via `--ollama-url`, Anthropic Claude, OpenAI, or Google Gemini via `--backend`\n- Rich AI metadata: scene category, emotional tone, cleanup classification, text detection, event hints\n- EXIF GPS as source of truth for location (never guessed from image content)\n- Open reverse geocoding via Nominatim (sends GPS coords to OpenStreetMap; cached locally)\n- Supports exported folders and Apple Photos library originals (macOS only)\n- Apple Photos write-back: push AI tags and descriptions back as keywords/captions (macOS only)\n- Subcommands: `run`, `judge`, `status`, `reprocess`, `cleanup`, `preflight`, `query`, `tags`, `faces`, `review`\n- Photo quality scoring with professional 13-criterion rubric (new: `judge` subcommand)\n- Dry-run mode, date/limit filters, JSON/CSV export\n- SQLite progress DB with schema versioning for incremental re-runs\n\n## Requirements\n\n- Python 3.11–3.14\n- [Ollama](https://ollama.ai) installed and running\n- Gemma 4 model pulled: `ollama pull gemma4:e4b`\n\n**macOS-specific:**\n- Apple Silicon or Intel Mac\n- Optional: `exiftool` for reliable HEIC EXIF (falls back to Pillow)\n- Optional: `pillow-heif` for HEIC image loading\n\n**All platforms:**\n- Works on macOS, Linux, and Windows\n- EXIF writing via `exiftool` (if installed) works across platforms\n- Apple Photos write-back requires macOS\n\n## Quick Start\n\n```bash\npip install -e \".[dev]\"\n\n# Pull the model\nollama pull gemma4:e4b\n\n# Dry-run on an exported folder, first 20 images\npyimgtag run --input-dir ~/Pictures/exported --limit 20 --dry-run\n\n# Single date\npyimgtag run --input-dir ~/Pictures/exported --date 2026-04-01 --dry-run\n\n# Date range with JSON output\npyimgtag run --input-dir ~/Pictures/exported \\\n  --date-from 2026-03-01 --date-to 2026-03-31 \\\n  --output-json results.json\n\n# Photos library\npyimgtag run --photos-library ~/Pictures/Photos\\ Library.photoslibrary \\\n  --limit 50 --dry-run\n\n# Check processing progress\npyimgtag status\n\n# Re-tag all photos (e.g. after prompt improvements)\npyimgtag reprocess\n\n# List photos flagged for deletion\npyimgtag cleanup\n\n# Score photos by quality (judge)\npyimgtag judge --input-dir ~/Pictures/exported --limit 20 --verbose\n\n# Filter to only strong photos, save ranking to JSON\npyimgtag judge --input-dir ~/Pictures/exported \\\n  --min-score 7 --output-json ranking.json\n```\n\n## Installation\n\n```bash\n# From source\ngit clone https://github.com/kurok/pyimgtag.git\ncd pyimgtag\npip install -e \".[dev]\"\n\n# Optional HEIC support\npip install pillow-heif\n\n# Optional exiftool (better EXIF for HEIC)\nbrew install exiftool\n```\n\n## Platform Support\n\n| Feature | macOS | Linux | Windows |\n|---------|-------|-------|---------|\n| Image tagging via Ollama | ✅ | ✅ | ✅ |\n| EXIF reading (GPS, dates) | ✅ | ✅ | ✅ |\n| Reverse geocoding (Nominatim) | ✅ | ✅ | ✅ |\n| EXIF writing via `exiftool` | ✅ | ✅ | ✅ |\n| HEIC conversion (sips / pillow-heif) | ✅ sips + pillow-heif | ✅ pillow-heif | ✅ pillow-heif |\n| RAW image support (rawpy) | ✅ | ✅ | ✅ |\n| Apple Photos library scanning | ✅ | ❌ | ❌ |\n| Apple Photos write-back | ✅ | ❌ | ❌ |\n| Face management (Apple Photos) | ✅ | ❌ | ❌ |\n\n**Note:** Most features work cross-platform. Apple Photos integration and face management are macOS-only — they require AppleScript via `osascript`.\n\n### macOS Setup\n\n```bash\n# Prerequisites\nbrew install ollama exiftool\nollama pull gemma4:e4b\n\n# Install\npip install \"pyimgtag[all]\"   # includes pillow-heif, photoscript, rawpy, face-recognition, fastapi, uvicorn\n# or from source\ngit clone https://github.com/kurok/pyimgtag.git\ncd pyimgtag\npip install -e \".[all,dev]\"\n```\n\nFeatures available: everything including Apple Photos integration, HEIC, face management, and photo review workflows.\n\nTypical macOS workflow:\n```bash\n# Tag your Photos library directly\npyimgtag run --photos-library ~/Pictures/Photos\\ Library.photoslibrary --write-back --limit 50\n\n# Score photo quality\npyimgtag judge --photos-library ~/Pictures/Photos\\ Library.photoslibrary --min-score 8\n\n# Import named faces from Apple Photos\npyimgtag faces import-photos  # reads system default Photos library\n```\n\n**Note:** Apple Photos library access requires Full Disk Access permission for your terminal app — grant it in System Settings \u003e Privacy \u0026 Security \u003e Full Disk Access.\n\n#### Face features: `face_recognition_models` is git-only\n\n`face_recognition` needs a companion package, `face_recognition_models`,\nwhich only lives on git — it was never published to PyPI. **You always\nneed to install it as a separate step**, regardless of whether you got\npyimgtag from PyPI or source: PyPI rejects packages whose metadata\ndeclares direct-URL dependencies, so `[face]` / `[all]` extras can't\nlist it for you.\n\n```bash\npip install 'pyimgtag[face]'      # or .[all]; models package NOT included\npython -m pip install \\\n    \"face_recognition_models @ git+https://github.com/ageitgey/face_recognition_models\"\n```\n\nIf `pyimgtag faces scan` exits with a \"Please install\n`face_recognition_models`\" message and no traceback, you skipped that\nsecond command. Verify the install landed in the right venv with:\n\n```bash\npython -m pip show face_recognition_models\n```\n\nIf `pip show` says it's installed but pyimgtag still complains, the\nlikely culprit is a missing `pkg_resources`. There are two ways this\nshows up:\n\n1. Python 3.12+ no longer bundles setuptools by default, so\n   `pkg_resources` is just absent.\n2. **setuptools 81 removed `pkg_resources` from the package**, so you\n   can have setuptools 81+ installed and `pip show` happy, yet\n   `import pkg_resources` raises `ModuleNotFoundError`.\n\nPinning setuptools below 81 fixes both — the `[face]` and `[all]`\nextras pin `setuptools\u003e=68.0,\u003c81` automatically. If you installed\nwithout the extra (or your env already had setuptools 81+), run:\n\n```bash\npython -m pip install 'setuptools\u003c81'\n```\n\n### Linux Setup\n\n```bash\n# Ubuntu/Debian\nsudo apt-get install exiftool python3.11 python3-pip\n# or install exiftool from https://exiftool.org\n\n# Fedora/RHEL\nsudo dnf install perl-Image-ExifTool python3.11\n\n# Arch\nsudo pacman -S perl-image-exiftool python\n\n# Install Ollama\ncurl -fsSL https://ollama.com/install.sh | sh\nollama pull gemma4:e4b\n\n# Install pyimgtag\npip install \"pyimgtag[heic]\"   # includes pillow-heif for HEIC\n# or from source\ngit clone https://github.com/kurok/pyimgtag.git\ncd pyimgtag\npip install -e \".[heic,dev]\"\n```\n\nFeatures available: image tagging, EXIF reading/writing, geocoding, judge, dedup, JSON/CSV export. No Apple Photos integration.\n\nTypical Linux workflow:\n```bash\n# Tag an exported photo directory\npyimgtag run --input-dir ~/Pictures/exported --output-json results.json\n\n# With EXIF write-back (requires exiftool)\npyimgtag run --input-dir ~/Pictures/exported --write-exif\n\n# Score photo quality\npyimgtag judge --input-dir ~/Pictures/exported --min-score 7 --output-json ranking.json\n```\n\n**Note:** `--write-back` (Apple Photos) is silently skipped on Linux with a warning. Use `--write-exif` instead.\n\n### Windows Setup\n\n```powershell\n# Install Python 3.11+ from https://python.org\n# Install Ollama from https://ollama.com\n\n# Install exiftool — download from https://exiftool.org/\n# Or via Chocolatey:\nchoco install exiftool\n\n# Or via winget:\nwinget install OliverBetz.ExifTool\n\nollama pull gemma4:e4b\n\n# Install pyimgtag\npip install \"pyimgtag[heic]\"\n# or from source\ngit clone https://github.com/kurok/pyimgtag.git\ncd pyimgtag\npip install -e \".[heic,dev]\"\n```\n\nFeatures available: same as Linux — tagging, EXIF, geocoding, judge, dedup, export. No Apple Photos integration.\n\nTypical Windows workflow (PowerShell):\n```powershell\n# Tag photos in a folder\npyimgtag run --input-dir C:\\Users\\Me\\Pictures\\exported --output-json results.json\n\n# Score photo quality\npyimgtag judge --input-dir C:\\Users\\Me\\Pictures\\exported --min-score 7\n\n# Check what was processed\npyimgtag status\n```\n\n**Note:** On Windows, use `\\` path separators or quote paths with spaces: `\"C:\\My Photos\"`.\n\n### Platform Troubleshooting\n\n**macOS:**\n- \"Operation not permitted\" on Photos library → grant Full Disk Access to Terminal in System Settings \u003e Privacy \u0026 Security \u003e Full Disk Access\n- `exiftool` not found → `brew install exiftool`\n- HEIC files not loading → `pip install pillow-heif`\n- Ollama not running → `brew services start ollama` or run `ollama serve`\n\n**Linux:**\n- `exiftool` not found → install via package manager (see setup above)\n- HEIC files not loading → `pip install pillow-heif`\n- Ollama not running → `ollama serve` in a separate terminal\n- Permission denied on image folder → check directory permissions with `ls -la`\n\n**Windows:**\n- `exiftool` not found → add exiftool directory to PATH, or install via Chocolatey/winget\n- Python not found → ensure Python 3.11+ is installed and added to PATH during install\n- HEIC files not loading → `pip install pillow-heif`\n- Ollama not running → start Ollama from system tray or run `ollama serve`\n- Long paths issue → enable long path support: `Set-ItemProperty -Path \"HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\" -Name \"LongPathsEnabled\" -Value 1`\n\n## Usage\n\n### Subcommands\n\npyimgtag uses subcommands. Run `pyimgtag --help` for the full list.\n\n#### `pyimgtag run` — tag images\n\n```bash\n# Exported image folder\npyimgtag run --input-dir /path/to/photos\n\n# Apple Photos library\npyimgtag run --photos-library ~/Pictures/Photos\\ Library.photoslibrary\n\n# With filters\npyimgtag run --input-dir /path/to/photos \\\n  --limit 100 --date-from 2026-03-01 --date-to 2026-03-31\n\n# Write tags back to Apple Photos as keywords\npyimgtag run --photos-library ~/Pictures/Photos\\ Library.photoslibrary \\\n  --write-back --limit 10\n\n# Deduplicate by perceptual hash\npyimgtag run --input-dir /path/to/photos --dedup\n\n# Export to JSON\npyimgtag run --input-dir /path/to/photos --output-json results.json\n```\n\n##### Choosing a vision backend\n\nBy default pyimgtag calls a local Ollama server. Use `--backend` to pick a\ndifferent provider; the same prompt and result schema apply across backends.\n\n```bash\n# Default: local Ollama\npyimgtag run --input-dir /path/to/photos\n\n# Remote Ollama server (e.g. on another machine in your LAN)\npyimgtag run --input-dir /path/to/photos --ollama-url http://gpu-host:11434\n\n# Anthropic Claude\nANTHROPIC_API_KEY=sk-ant-... pyimgtag run --input-dir /path/to/photos \\\n  --backend anthropic\n\n# OpenAI (override the default model if needed)\nOPENAI_API_KEY=sk-... pyimgtag run --input-dir /path/to/photos \\\n  --backend openai --model gpt-4o\n\n# Google Gemini\nGOOGLE_API_KEY=... pyimgtag run --input-dir /path/to/photos \\\n  --backend gemini\n```\n\nPer-backend defaults:\n\n| Backend     | Default model         | Auth env var                          |\n|-------------|-----------------------|---------------------------------------|\n| `ollama`    | `gemma4:e4b`          | none (uses `--ollama-url`)            |\n| `anthropic` | `claude-sonnet-4-6`   | `ANTHROPIC_API_KEY`                   |\n| `openai`    | `gpt-4o-mini`         | `OPENAI_API_KEY`                      |\n| `gemini`    | `gemini-1.5-flash`    | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`)|\n\nCloud backends send the JPEG bytes for each image to the provider. Use\n`--api-base` to override the base URL (for self-hosted gateways or\nproxies) and `--api-key` if you want to pass the secret on the command\nline instead of via an environment variable. The `--backend` flag works\nidentically for `pyimgtag judge`.\n\n**Run flags:**\n\n| Flag | Description |\n|---|---|\n| `--input-dir PATH` | Exported image folder |\n| `--photos-library PATH` | Apple Photos library package *(macOS only)* |\n| `--limit N` | Max images to process |\n| `--date YYYY-MM-DD` | Single date filter |\n| `--date-from` / `--date-to` | Date range filter |\n| `--extensions jpg,png` | File types (default: jpg,jpeg,heic,png) |\n| `--skip-no-gps` | Skip images without GPS data |\n| `--dry-run` | Verbose output, no DB writes |\n| `--verbose` / `-v` | Detailed per-file output |\n| `--output-json FILE` | Write results to JSON |\n| `--output-csv FILE` | Write results to CSV |\n| `--jsonl-stdout` | JSONL output to stdout |\n| `--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)* |\n| `--write-exif` | Write description and keywords to image EXIF |\n| `--dedup` | Skip duplicates via perceptual hash |\n| `--dedup-threshold N` | Hamming distance threshold (default: 5) |\n| `--model NAME` | Ollama model (default: gemma4:e4b) |\n| `--ollama-url URL` | Ollama API URL |\n| `--max-dim N` | Max image dimension (default: 1280) |\n| `--timeout N` | Model request timeout in seconds |\n| `--db PATH` | Progress database path |\n| `--no-cache` | Skip progress DB, reprocess all |\n\n#### `pyimgtag status` — check progress\n\n```bash\n# Show processing stats\npyimgtag status\n\n# Output:\n# Progress: 142 / 200 (71%)\n#   ok:      140\n#   error:   2\n#   pending: 58\n```\n\n#### `pyimgtag reprocess` — reset for re-tagging\n\n```bash\n# Reset everything (e.g. after prompt improvements)\npyimgtag reprocess\n\n# Reset only failed entries\npyimgtag reprocess --status error\n```\n\n#### `pyimgtag cleanup` — find photos to delete\n\n```bash\n# List photos the AI flagged as \"delete\"\npyimgtag cleanup\n\n# Also include \"review\" (uncertain) candidates\npyimgtag cleanup --include-review\n\n# Output:\n# Cleanup candidates (delete): 12\n#\n#   [delete]  /path/to/blurry_photo.jpg  | 2026-03-15  | tags: blurry, dark\n#   [delete]  /path/to/screenshot.png    | 2026-04-01  | tags: screenshot, text\n```\n\n#### `pyimgtag query` — search tagged images\n\n```bash\n# Search by tag\npyimgtag query --tag sunset\n\n# Search by city / country\npyimgtag query --city \"San Francisco\"\npyimgtag query --country Italy\n\n# Filter by scene / cleanup / status\npyimgtag query --scene-category outdoor_travel --status ok\npyimgtag query --cleanup delete\n\n# Filter by text-detection state\npyimgtag query --has-text\npyimgtag query --no-text\n\n# Output as JSON or just paths (e.g. for shell pipelines)\npyimgtag query --tag beach --format json\npyimgtag query --tag beach --format paths --limit 50\n```\n\n#### `pyimgtag faces` — face detection, clustering, naming *(macOS)*\n\nSix sub-subcommands chain into a typical face workflow:\n\n```bash\n# 1. Detect faces and compute embeddings\npyimgtag faces scan --photos-library ~/Pictures/Photos\\ Library.photoslibrary\n\n# 2. Cluster embeddings into person groups (DBSCAN)\npyimgtag faces cluster --eps 0.5 --min-samples 2\n\n# 3. Inspect the clusters from the CLI\npyimgtag faces review\n\n# 4. Import named persons from Apple Photos (uses bulk AppleScript)\npyimgtag faces import-photos\n\n# 5. Write person keywords to image metadata (EXIF or XMP sidecar)\npyimgtag faces apply --write-exif\npyimgtag faces apply --sidecar-only --dry-run\n\n# 6. Manage clusters via the web UI (rename, merge, delete)\npyimgtag faces ui  # serves the unified webapp on http://127.0.0.1:8766\n```\n\n`scan` accepts `--detection-model {hog,cnn}` (hog = fast CPU, cnn = accurate\nGPU), `--max-dim`, `--extensions`, and `--limit`, plus the same dashboard\nflags as `run` / `judge` (`--web` / `--no-web` / `--web-host` / `--web-port`\n/ `--no-browser`).\n\nThe `[face]` extra is required; see the\n[`face_recognition_models` install note](#face-features-face_recognition_models-is-git-only)\nabove.\n\n#### `pyimgtag review` — launch the local review UI\n\n```bash\n# Browse the progress DB, edit tags, change cleanup class\npyimgtag review                      # serves on http://127.0.0.1:8765\npyimgtag review --port 9000 --no-browser\n```\n\nThis serves the **same** unified webapp as `run --web`, just bound to a\ndifferent default port. See [Local webapp](#local-webapp) below for the\nfull page list. Requires the `[review]` extra (`pip install\n'pyimgtag[review]'`).\n\n#### `pyimgtag tags` — manage tags\n\n```bash\n# List all tags with image counts\npyimgtag tags list\n\n# Rename a tag across all images\npyimgtag tags rename old-name new-name\n\n# Delete a tag from all images\npyimgtag tags delete unwanted-tag --dry-run\n\n# Merge one tag into another\npyimgtag tags merge source-tag target-tag\n```\n\n#### `pyimgtag preflight` — check prerequisites\n\n```bash\n# Verify Ollama, model, and source path\npyimgtag preflight --input-dir ~/Pictures/exported\n```\n\n#### `pyimgtag judge` — score photo quality\n\nScore each image against a 13-criterion professional rubric. Outputs a ranked list with weighted scores as **integers on a 1–10 scale** (no decimal component). Requires Ollama.\n\n```bash\n# Score all images in a folder\npyimgtag judge --input-dir ~/Pictures/exported\n\n# Only show photos scoring 3.5 or above\npyimgtag judge --input-dir ~/Pictures/exported --min-score 7\n\n# Verbose breakdown (per-criterion scores)\npyimgtag judge --input-dir ~/Pictures/exported --limit 20 --verbose\n\n# Sort by filename instead of score\npyimgtag judge --input-dir ~/Pictures/exported --sort-by name\n\n# Score Photos library\npyimgtag judge --photos-library ~/Pictures/Photos\\ Library.photoslibrary \\\n  --limit 50 --min-score 8\n\n# Save full ranking to JSON\npyimgtag judge --input-dir ~/Pictures/exported \\\n  --output-json ranking.json\n```\n\n**Sample output (brief mode):**\n```\n[1/5] golden_hour.jpg → 9/10 outstanding | + impact, composition_center | - edit_integrity, noise_cleanliness\n  Golden light over the cityscape; strong composition but slight haloing on edges.\n[2/5] portrait.jpg → 7/10 solid | + focus_sharpness, lighting | - creativity_style, color_mood\n  Well-lit portrait; technically solid but conventional treatment.\n```\n\n**Sample output (--verbose):**\n```\n[1/5] golden_hour.jpg\n  Score:   9/10  (core: 9, visible: 8)\n  Best:    impact=10, composition_center=10, lighting=8\n  Weakest: edit_integrity=6, noise_cleanliness=6, subject_separation=6\n  Verdict: Golden light over the cityscape; strong composition but slight haloing on edges.\n```\n\n**Judge flags:**\n\n| Flag | Default | Description |\n|---|---|---|\n| `--input-dir PATH` | — | Exported image folder |\n| `--photos-library PATH` | — | Apple Photos library *(macOS only)* |\n| `--limit N` | unlimited | Max images to score |\n| `--extensions EXT,...` | `jpg,jpeg,heic,png,tiff,webp` | File types |\n| `--min-score SCORE` | — | Only show images scoring ≥ SCORE |\n| `--sort-by score\\|name` | `score` | Final sort order |\n| `--output-json FILE` | — | Write ranked results to JSON |\n| `--verbose` | false | Per-criterion breakdown |\n| `--no-recursive` | false | Do not scan subdirectories |\n| `--backend ollama\\|anthropic\\|openai\\|gemini` | `ollama` | Vision-model backend (same as `pyimgtag run`) |\n| `--model NAME` | backend-specific | Model name; defaults `gemma4:e4b` / `claude-sonnet-4-6` / `gpt-4o-mini` / `gemini-1.5-flash` |\n| `--ollama-url URL` | `http://localhost:11434` | Ollama API URL (used when `--backend=ollama`) |\n| `--api-base URL` | provider default | Override the cloud-API base URL (anthropic / openai / gemini) |\n| `--api-key KEY` | env var | Cloud-API key; defaults to the provider's conventional env var |\n| `--max-dim N` | `1280` | Max image dimension before resize |\n| `--timeout N` | `120` | Request timeout (seconds) |\n| `--db PATH` | `~/.cache/pyimgtag/progress.db` | Progress DB path; judge scores share the same DB as `run` |\n| `--skip-judged` | false | Skip images that already have a row in `judge_scores` |\n| `--write-back` | false | Write the score keyword back to Apple Photos *(macOS + `--photos-library` only)* |\n| `--write-back-mode overwrite\\|append` | `overwrite` | Whether write-back replaces or merges keywords |\n| `--web` / `--no-web` / `--web-host` / `--web-port` / `--no-browser` | — | Same dashboard flags as `pyimgtag run` (default port `8770`) |\n\n### Sample verbose output\n\n```\n[1/50] sunset_beach.jpg\n  Path:     /Users/me/Pictures/exported/sunset_beach.jpg\n  Date:     2026-04-01 14:30:00\n  Tags:     sunset, beach, ocean, waves, sand\n  Summary:  golden hour sunset over the Pacific\n  Scene:    outdoor_leisure\n  Tone:     positive\n  Cleanup:  keep\n  Event:    outing\n  Signif.:  high\n  GPS:      37.7749, -122.4194\n  Location: San Francisco, California, United States\n  Status:   ok\n\n--- Summary ---\n  Scanned:          200\n  Processed:        50\n  Skipped (date):   0\n  Skipped (no GPS): 0\n  Skipped (no file):0\n  Model failures:   2\n  Geocode failures: 0\n```\n\n### Output schema\n\nEach result (JSON/CSV) includes:\n\n| Field | Description |\n|---|---|\n| `file_path` | Full path to image |\n| `file_name` | Filename |\n| `source_type` | `directory` or `photos_library` |\n| `image_date` | EXIF or file date |\n| `tags` | 1-5 vision model tags |\n| `scene_summary` | Short scene description |\n| `scene_category` | `indoor_home`, `indoor_work`, `outdoor_leisure`, `outdoor_travel`, `transport`, `other` |\n| `emotional_tone` | `positive`, `neutral`, `negative`, `mixed` |\n| `cleanup_class` | `keep`, `review`, `delete` |\n| `has_text` | Whether image contains readable text |\n| `text_summary` | Extracted text summary (if `has_text`) |\n| `event_hint` | `outing`, `gathering`, `work`, `travel`, `daily`, `other` |\n| `significance` | `high`, `medium`, `low` |\n| `gps_lat` / `gps_lon` | EXIF GPS coordinates |\n| `nearest_place` | Village/town/suburb |\n| `nearest_city` | City |\n| `nearest_region` | State/region |\n| `nearest_country` | Country |\n| `processing_status` | `ok` or `error` |\n| `error_message` | Error details if any |\n| `phash` | Perceptual hash (when `--dedup` used) |\n\n### Judge output schema\n\nResults from `pyimgtag judge --output-json` use a different structure:\n\n| Field | Description |\n|---|---|\n| `file_path` | Full path to image |\n| `file_name` | Filename |\n| `weighted_score` | Overall weighted score (integer 1–10) |\n| `core_score` | Artistic criteria average (integer 1–10) |\n| `visible_score` | Technical criteria average (integer 1–10) |\n| `verdict` | One-sentence summary of key strength and weakness |\n| `scores.impact` | Emotional pull and memorability (integer 1–10) |\n| `scores.story_subject` | Clear subject and meaning (integer 1–10) |\n| `scores.composition_center` | Visual flow, balance, center of interest (integer 1–10) |\n| `scores.lighting` | Quality, control, mood support (integer 1–10) |\n| `scores.creativity_style` | Originality of treatment (integer 1–10) |\n| `scores.color_mood` | Color balance and mood fit (integer 1–10) |\n| `scores.presentation_crop` | Crop, framing, aspect ratio (integer 1–10) |\n| `scores.technical_excellence` | Exposure, retouching, overall finish (integer 1–10) |\n| `scores.focus_sharpness` | Critical detail is sharp (integer 1–10) |\n| `scores.exposure_tonal` | Highlights and shadows under control (integer 1–10) |\n| `scores.noise_cleanliness` | Clean detail, no distracting grain (integer 1–10) |\n| `scores.subject_separation` | Subject stands out from background (integer 1–10) |\n| `scores.edit_integrity` | No halos, overprocessing, or clone artefacts (integer 1–10) |\n\n## Architecture\n\n```\nsrc/pyimgtag/\n  main.py              CLI entry point and subcommand dispatch (thin)\n  models.py            Data classes (ExifData, TagResult, GeoResult, ImageResult)\n  scanner.py           Directory and Photos library scanning\n  exif_reader.py       EXIF GPS + date extraction (exiftool + Pillow)\n  ollama_client.py     Ollama vision API client (rich structured response)\n  cloud_clients.py     Anthropic / OpenAI / Gemini vision-API adapters\n  geocoder.py          Nominatim reverse geocoder with disk cache\n  filters.py           Date/GPS filter logic\n  output_writer.py     JSON/CSV/JSONL output\n  progress_db.py       SQLite progress DB with versioned migrations\n  applescript_writer.py  Apple Photos keyword/description write-back\n  _face_dep_check.py   Friendly preflight for face_recognition_models\n  dedup.py             Perceptual hash duplicate detection\n  heic_converter.py    HEIC to JPEG conversion (macOS sips)\n  cache.py             Simple JSON disk cache\n  judge_scorer.py      Weighted rubric score computation (13-criterion)\n  preflight.py         Shared preflight helpers\n  commands/\n    run.py             `pyimgtag run` handler\n    judge.py           `pyimgtag judge` handler\n    db.py              `pyimgtag status/reprocess/cleanup` handlers\n    query.py           `pyimgtag query` handler\n    tags.py            `pyimgtag tags` handler\n    faces.py           `pyimgtag faces` (scan / cluster / review / apply / import-photos / ui)\n    preflight_cmd.py   `pyimgtag preflight` handler\n    review_cmd.py      `pyimgtag review` handler\n  webapp/\n    __main__.py        `python -m pyimgtag.webapp` standalone uvicorn launcher\n    unified_app.py     FastAPI app composition + `/health` endpoint\n    nav.py             Shared nav shell + design system\n    routes_review.py   `/review` router (browse / edit / lightbox)\n    routes_faces.py    `/faces` router (cluster management UI)\n    routes_tags.py     `/tags` router\n    routes_query.py    `/query` router\n    routes_judge.py    `/judge` router (rating grid + filter/sort)\n    routes_edit.py     `/edit` router (bulk-delete from Photos)\n    routes_about.py    `/about` router (version / update check / wiki)\n    dashboard_server.py / server_thread.py / bootstrap.py\n                       In-process dashboard for `run` / `judge` / `faces scan`\n```\n\n## Development\n\n```bash\npip install -e \".[dev,lint,security]\"\n\npytest tests/ -v\nruff format src/ tests/ \u0026\u0026 ruff check src/ tests/ --fix\npython -m mypy src/pyimgtag/ --ignore-missing-imports --disable-error-code import-untyped\npython -m bandit -r src/pyimgtag/ -c pyproject.toml\npre-commit install \u0026\u0026 pre-commit run --all-files\n```\n\n### Pre-PR smoke (`tests/e2e/`)\n\nThe `pr-tests` GitHub Actions workflow runs unit tests **and** a\nPlaywright Chromium smoke that boots the dashboard on every PR. To run\nthe same checks locally before pushing:\n\n```bash\n# Installs deps + Chromium, starts the app on :8000, waits for /health,\n# runs unit tests, runs the Playwright smoke, then stops the app cleanly.\nscripts/test-smoke-local.sh\n\n# Custom port / real DB / visible browser:\nPORT=8765 scripts/test-smoke-local.sh\nPYIMGTAG_DB=~/.cache/pyimgtag/progress.db scripts/test-smoke-local.sh\nPYIMGTAG_E2E_HEADLESS=0 scripts/test-smoke-local.sh\n```\n\nThe smoke test auto-discovers every link in the top nav, clicks each\none, and fails the run on **any** of: HTTP 5xx, an uncaught JS error,\na `console.error`, a blank page, or a heading-less page.\n\n**Inspecting failures.** When a smoke test fails — locally or in CI —\nartefacts land under `tests/e2e/artifacts/\u003ctest-id\u003e/`:\n\n| File | What it shows |\n| --- | --- |\n| `screenshot.png` | full-page PNG of the page when the assertion fired |\n| `trace.zip` | Playwright trace — open with `playwright show-trace trace.zip` for DOM, network log, and per-step screenshots |\n| `app.log` (parent dir) | uvicorn access log + tracebacks from the dashboard process |\n\nCI uploads the same `tests/e2e/artifacts/` directory as a workflow\nartifact named `pr-tests-artifacts`. Download it from the failed\nrun's \"Artifacts\" panel on GitHub.\n\n**Required checks.** A PR can merge once the `Unit + E2E smoke` job in\nthe `pr-tests` workflow passes (alongside the existing `Python\npackage` matrix and CodeQL).\n\n## Resume and Enrichment\n\nRerunning `pyimgtag run` on an already-processed library normally skips unchanged files.\nWith `--resume-from-db`, those files are re-hydrated from the database instead of being\nsilently skipped, so their results still appear in output files and the `--write-back` path\nruns again.\n\nOnly **local enrichment** is repeated (EXIF, reverse geocoding). The AI model is not called again.\n\n```bash\n# Normal run — first pass, all files sent to Ollama\npyimgtag run --photos-library ~/Pictures/Photos\\ Library.photoslibrary \\\n             --db ~/my-progress.db --write-back\n\n# Resume after interruption — unchanged files load from DB, only new files hit Ollama\npyimgtag run --photos-library ~/Pictures/Photos\\ Library.photoslibrary \\\n             --db ~/my-progress.db --write-back --resume-from-db\n\n# Threaded resume — cached-item enrichment runs in a background thread\n# while the main thread continues sending new files to Ollama\npyimgtag run --photos-library ~/Pictures/Photos\\ Library.photoslibrary \\\n             --db ~/my-progress.db --write-back --resume-from-db --resume-threaded\n```\n\nA file is eligible for DB resume if:\n- Its size and modification time have not changed since the last run.\n- The cached entry has at least one tag.\n\nUse `pyimgtag reprocess --db ~/my-progress.db` to force a full re-run for all files,\nor `pyimgtag reprocess --db ~/my-progress.db --status error` to retry only failed files.\n\n## Local webapp\n\n`pyimgtag run`, `pyimgtag judge`, and `pyimgtag faces scan` auto-start a\nlocal webapp at http://127.0.0.1:8770 by default. The same unified app\nhosts a single top-nav with these pages:\n\n- `/` — Dashboard (live progress, status, quick links).\n- `/review` — browse DB entries, edit tags, change cleanup class.\n- `/faces` — manage person clusters, rename, merge, delete.\n- `/tags` — list, rename, merge, delete tags across the DB.\n- `/query` — full-text/tag/scene/judge filters with hover thumbnails.\n- `/judge` — judge-score grid with rating filter / sort / pager.\n- `/edit` — bulk-delete files marked `cleanup_class='delete'` from\n  Apple Photos (macOS only; gated behind an explicit confirm).\n- `/about` — installed version, latest PyPI release, update check, wiki links.\n- `/health` — plain JSON liveness probe (`{ok, version, db}`); used by\n  the pre-PR + CI smoke runners.\n\nThe standalone commands continue to work and serve the **same** unified app:\n\n- `pyimgtag review` on http://127.0.0.1:8765 (review at `/review`).\n- `pyimgtag faces ui` on http://127.0.0.1:8766 (faces at `/faces`).\n- `python -m pyimgtag.webapp` for a bare uvicorn launch — reads `HOST`\n  / `PORT` / `PYIMGTAG_DB` / `PYIMGTAG_LOG_LEVEL` from the environment.\n  This is the same launch surface used by `scripts/test-smoke-local.sh`\n  and the `pr-tests` GitHub Actions workflow.\n\nFlags (apply to `run`, `judge`, `faces scan`):\n\n- `--no-web` — terminal-only mode, no server started.\n- `--web` — force-enable (overrides `PYIMGTAG_NO_WEB=1`).\n- `--web-host HOST` — bind host (default `127.0.0.1`).\n- `--web-port PORT` — bind port (default `8770`).\n- `--no-browser` — do not auto-open the browser.\n\nPause semantics are cooperative: the gate is checked before each file so\nin-flight Ollama / face-detection requests are never interrupted mid-call.\n\n**Migration note:** the `pyimgtag review` and `pyimgtag faces ui` commands\nnow serve the unified app, so the URL paths have shifted. Bookmarks that\nused `http://localhost:8765/api/stats` should be updated to\n`http://localhost:8765/review/api/stats`.\n\n## Environment variables\n\n| Variable | Used by | Effect |\n|---|---|---|\n| `PYIMGTAG_BACKEND` | `pyimgtag run` / `pyimgtag judge` | Default vision backend (`ollama` / `anthropic` / `openai` / `gemini`). Overridden by `--backend`. |\n| `OLLAMA_URL` | `pyimgtag run` / `judge` / `preflight` | Default Ollama base URL (default `http://localhost:11434`). Overridden by `--ollama-url`. |\n| `ANTHROPIC_API_KEY` | `--backend anthropic` | Auth for Claude. Overridden by `--api-key`. |\n| `OPENAI_API_KEY` | `--backend openai` | Auth for OpenAI. Overridden by `--api-key`. |\n| `GOOGLE_API_KEY` / `GEMINI_API_KEY` | `--backend gemini` | Auth for Gemini (either name accepted). Overridden by `--api-key`. |\n| `PYIMGTAG_NO_WEB` | All commands that start the dashboard | `1` / `true` / `yes` disables the dashboard by default (same as `--no-web`). |\n| `PYIMGTAG_NO_UPDATE_CHECK` | All `pyimgtag` invocations | Skip the PyPI update check on startup. |\n| `PYIMGTAG_USE_PHOTOSCRIPT` | `--write-back` / faces import | `1` / `true` / `yes` opts into the in-process [photoscript](https://pypi.org/project/photoscript/) path instead of the default `osascript` subprocess. |\n| `PYIMGTAG_PARSE_ERROR_LOG` | `pyimgtag run` | Path to the Ollama JSON-parse error log (default `pyimgtag-parse-errors.log` in the cwd). |\n| `PYIMGTAG_DB` | `python -m pyimgtag.webapp` | Override the progress-DB path the standalone webapp opens. |\n| `PYIMGTAG_LOG_LEVEL` | `python -m pyimgtag.webapp` | uvicorn log level (default `info`). |\n| `HOST` / `PORT` | `python -m pyimgtag.webapp` | Bind host / port for the standalone launcher (default `127.0.0.1:8000`). |\n| `PYIMGTAG_SCREENSHOT_DB` | `tests/local/test_webapp_screenshots.py` | Walk the screenshot suite against an existing DB instead of a sandboxed one. |\n| `BASE_URL` / `PYIMGTAG_E2E_HEADLESS` | `tests/e2e/` Playwright suite | Override smoke-test target URL / run with a visible browser. |\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Security\n\nFound a vulnerability? Please follow the disclosure flow in [SECURITY.md](SECURITY.md) -- do not file a public issue.\n\n## License\n\nMIT -- see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkurok%2Fpyimgtag","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkurok%2Fpyimgtag","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkurok%2Fpyimgtag/lists"}