Photo Tagger
Photo Tagger is a command-line helper that asks a vision-language model to analyze your photos and writes Lightroom-compatible metadata.
By default it keeps your originals untouched by creating XMP sidecars, but you can embed the updates
directly into each photo with --embed-in-photo.
Full documentation: https://jbsilva.github.io/photo-tagger/
Highlights
- Works with RAW and standard image formats (CR3, CR2, NEF, JPG, PNG, and more)
- Generates a title, a concise description, and hierarchical keywords
- Merges with existing metadata unless you opt-in to overwrite
- Works with Ollama, LM Studio, and any hosted OpenAI-compatible API
- Ships a
doctorcommand that pre-flights ExifTool and your model provider - Optional desktop GUI (
photo-tagger gui) for a point-and-click workflow - Converts images to compact JPEG bytes to minimize token usage
- Generates detailed log files for easy debugging and auditing
- Highly configurable via CLI flags and environment variables
Requirements
- Python 3.14+
- ExifTool available on
PATH - A running Ollama or LM Studio server exposing a vision-language model (for example Qwen-VL)
librawsupport forrawpy(install via Homebrew on macOS:brew install libraw)
Installation
For end-users, the recommended installation method is via uv:
uv tool install photo-tagger
To include the optional desktop GUI, install the gui extra:
uv tool install 'photo-tagger[gui]'
Or install from conda-forge (works with conda, mamba, or pixi). The GUI is a separate package that bundles PySide6:
conda install -c conda-forge photo-tagger # CLI only
conda install -c conda-forge photo-tagger-gui # CLI + desktop GUI
For development (tests, linting):
uv sync --group dev --group test
Configuration
Environment variables provide defaults so you can keep the CLI concise:
OLLAMA_BASE_URL– override the Ollama HTTP endpoint (defaulthttp://localhost:11434/v1)OLLAMA_API_KEY– optional API key passed to Ollama requestsLM_STUDIO_BASE_URL– override the LM Studio endpoint (defaulthttp://localhost:1234/v1)LM_STUDIO_API_KEY/OPENAI_API_KEY– API key for LM Studio’s OpenAI-compatible serverOPENAI_BASE_URL– endpoint for theopenaiprovider (defaulthttps://api.openai.com/v1)OPENAI_API_KEY– API key for theopenaiprovider (required for that provider)MODEL_NAME– default model name (defaultqwen/qwen3-vl-30b)JPEG_DIMENSIONS,JPEG_QUALITY,TEMPERATURE,MAX_TOKENS,RETRIES– fine-tune runtime
Any CLI flag takes precedence over the environment.
Config file
You can persist CLI defaults in a TOML file so they apply automatically. Search order:
$PHOTO_TAGGER_CONFIGenvironment variable (explicit path).photo-tagger.tomlin the current working directory (project-local)~/.config/photo-tagger/config.toml(user-wide)
CLI flags override config file values, and the config file overrides built-in defaults.
Example .photo-tagger.toml:
extensions = "cr3,jpg,dng"
recursive = true
workers = 2
[provider]
model_name = "qwen/qwen3-vl-30b"
provider_name = "lmstudio"
api_base_url = "http://localhost:1234/v1"
[inference]
temperature = 0.2
max_tokens = 32768
[output]
preserve_keywords = true
max_keywords = 15
[artifacts]
cache_file = ".photo-tagger-cache.db"
The section names match the internal option groups: provider, inference, output, log,
display, filter, and artifacts. Top-level keys cover extensions, recursive, and workers.
Unknown keys are silently ignored, so the file stays forward-compatible.
Usage
The CLI is exposed as photo-tagger once installed, or you can invoke it directly:
photo-tagger -i ./photos --ext cr3,jpg -r
Key options:
-i/--input PATH– repeatable; mix files and directories--ext– comma-separated extension list used when scanning directories (defaultcr3,jpg)-r/--recursive– recurse into subdirectories while scanning inputs-m/--model– model identifier understood by your provider--provider–ollama,lmstudio, oropenai(defaults tolmstudio)--url/--api-key– override provider endpoint and credentials--overwrite-keywords– replace instead of merge existing keyword metadata--no-write-title/--no-write-description– skip writing those fields--no-backup-xmp– avoid creating*_originalsnapshot before writing--embed-in-photo– write metadata directly into the image instead of creating an XMP sidecar--dry-run– run the model and log the proposed metadata without writing XMP-w/--workers N– process N photos concurrently using a thread pool (default 1)--no-progress– hide the live rich progress bar (auto-disabled on non-interactive stdouts)--max-keywords N– cap how many AI-generated keywords are kept per photo before merging--prompt-file PATH– override the default user prompt with the contents ofPATH--summary-file PATH– write a JSON run summary (token usage, success/failure counts) toPATHon completion--cache-file PATH– persistent SQLite cache of model outputs keyed by an image-data hash (ExifTool'sImageDataHash, which ignores metadata so it survives--embed-in-photo) plus model+prompt+settings. Reruns skip the model call when nothing relevant has changed--lock-file PATH– acquire an exclusive file lock onPATHbefore running and refuse to start if anotherphoto-taggeralready holds it (prevents two runs racing on the same folder). Works on Linux, macOS, and Windows--json– emit one NDJSON line per processed photo to stdout (file, status, title, description, keywords, token usage, cache flag); logs and progress stay on stderr so you can pipe straight intojqor your own tools--newer-than DATE/--older-than DATE– filter the input batch by file mtime. Accepts ISO 8601 like2024-01-01or2024-01-01T14:30; naive timestamps use local time--jpeg-dimensions,--jpeg-quality,--temperature,--max-tokens,--retries– control inference behavior
Skipping and resuming
Three flags work together so you can re-run on a folder without redoing finished work:
--skip-from FILE– skip filenames listed inFILE(one per line;#lines are comments).--append-to-skip-file FILE– append each successfully tagged filename toFILEas the run progresses. The file is created if missing, so the same path can be passed to both flags from the very first run.--skip-tagged– skip files that already have keywords, a description, or a title in either the image or its XMP sidecar. Catches photos tagged in Lightroom or by hand without needing a skip list at all.
Resume-on-failure pattern: pass the same path to both flags so a killed run can be restarted with a single command.
photo-tagger -i ~/Pictures/Shoot -r \
--skip-from processed.txt \
--append-to-skip-file processed.txt
To process a folder mixing already-tagged and untagged photos:
photo-tagger -i ~/Pictures/Mixed --skip-tagged
A successful run creates or updates an .xmp sidecar for every processed image (unless you embed
the metadata). Existing metadata is merged so Lightroom keeps hierarchical keywords such as
Animal|Bird|Osprey intact.
Examples
Process a folder of RAW and JPEG files recursively:
photo-tagger -i ~/Pictures/Portfolio --ext cr3,jpg -r
Tag a few explicit files and overwrite existing keywords:
photo-tagger \
-i IMG_0001.CR3 \
-i IMG_0002.CR3 \
--overwrite-keywords
Embed metadata directly into a set of JPEGs:
photo-tagger -i ./exports --ext jpg --embed-in-photo
Check your setup before a big run (verifies ExifTool and that the provider serves the model):
photo-tagger doctor --provider lmstudio --model qwen/qwen3-vl-30b
Send requests to a remote Ollama host with a custom model:
photo-tagger -i ./shoot --provider ollama --model llava:34b --url http://ollama-box:11434/v1
Use a hosted OpenAI-compatible API (key read from OPENAI_API_KEY):
photo-tagger -i ./shoot --provider openai --model gpt-4o-mini
Preview proposed metadata without writing anything (useful when iterating on prompts):
photo-tagger -i ./sample --dry-run
Process a large folder concurrently with a live progress bar and a JSON summary:
photo-tagger -i ~/Pictures/Trip -r --workers 4 --summary-file ~/Pictures/Trip/run.json
Use a custom prompt tuned for wildlife photography:
photo-tagger -i ./shoot --prompt-file prompts/wildlife.txt --max-keywords 12
Cache model outputs so reruns on the same folder skip the inference cost:
photo-tagger -i ~/Pictures/Shoot -r --cache-file ~/.cache/photo-tagger.db
Tag only photos from a specific trip and stream NDJSON for downstream tools:
photo-tagger -i ~/Pictures/Camera -r \
--newer-than 2026-04-01 --older-than 2026-05-01 \
--json --no-progress | jq -c 'select(.status == "ok") | {file, title}'
Refuse to start if another run is already in flight on this folder:
photo-tagger -i ~/Pictures/Camera --lock-file /tmp/photo-tagger.lock
Desktop GUI
Prefer a point-and-click workflow? Install the optional gui extra (or the photo-tagger-gui
conda-forge package) and launch the desktop app:
uv tool install 'photo-tagger[gui]' # or: conda install -c conda-forge photo-tagger-gui
photo-tagger gui
The GUI is a review-before-write frontend over the same building blocks as the CLI. Drag in photos
or folders, pick what to process from a checkable tree, choose a provider and model (hit Test
connection to run the same checks as doctor), then Generate selected to run the model on a
background thread. Each photo's proposed title, description, and keywords appear next to the
existing values in a side-by-side detail pane, where you can edit any field before you Save. A
photo that fails shows why and can be retried, and Open logs opens the run log folder. It reads
the same config file and environment variables as the CLI. PySide6 is only pulled in by the gui
extra, so the plain CLI install stays lightweight.
Logging
Logs are written to stderr and to a timestamped file (for example 20260101...-photo_tagger.log).
Adjust levels with --console-log-level and --file-log-level, or disable either by setting the
value to OFF.
Testing
Run the unit tests with:
uv run pytest
The GUI tests are skipped automatically unless the gui extra is installed. To exercise them, sync
the extra and run headless via Qt's offscreen platform:
uv sync --extra gui --group test
QT_QPA_PLATFORM=offscreen uv run pytest tests/test_gui.py
If you plan to contribute, also run uv run ruff check for linting before opening a PR.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file photo_tagger-0.4.0.tar.gz.
File metadata
- Download URL: photo_tagger-0.4.0.tar.gz
- Upload date:
- Size: 84.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9dd71df88775a6f484b9cd5f31bebaa68173402b3522a1a9356e06455b0ded25
|
|
| MD5 |
a1aac121f3a961ab067fa852c9463833
|
|
| BLAKE2b-256 |
8690980bfc9e3d520c915efa7a122c86324f45483bda830c881ec00683aefd0e
|
Provenance
The following attestation bundles were made for photo_tagger-0.4.0.tar.gz:
Publisher:
publish.yml on jbsilva/photo-tagger
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
photo_tagger-0.4.0.tar.gz -
Subject digest:
9dd71df88775a6f484b9cd5f31bebaa68173402b3522a1a9356e06455b0ded25 - Sigstore transparency entry: 1972157231
- Sigstore integration time:
-
Permalink:
jbsilva/photo-tagger@79e79fc780d551b4ffbcfa1c0d5afdf1a9a93a2d -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/jbsilva
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@79e79fc780d551b4ffbcfa1c0d5afdf1a9a93a2d -
Trigger Event:
release
-
Statement type:
File details
Details for the file photo_tagger-0.4.0-py3-none-any.whl.
File metadata
- Download URL: photo_tagger-0.4.0-py3-none-any.whl
- Upload date:
- Size: 94.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
73c2c970c865ecc1c41034be72efcd1b015d8b7d5eed2cd56f5cabbeb63d6779
|
|
| MD5 |
d80a53413895215d3b1773456d7e7bc8
|
|
| BLAKE2b-256 |
9040489d6e99020928ecf8cadf01d6b60fa2bd16796df06ee24ad19e00b2f6db
|
Provenance
The following attestation bundles were made for photo_tagger-0.4.0-py3-none-any.whl:
Publisher:
publish.yml on jbsilva/photo-tagger
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
photo_tagger-0.4.0-py3-none-any.whl -
Subject digest:
73c2c970c865ecc1c41034be72efcd1b015d8b7d5eed2cd56f5cabbeb63d6779 - Sigstore transparency entry: 1972157363
- Sigstore integration time:
-
Permalink:
jbsilva/photo-tagger@79e79fc780d551b4ffbcfa1c0d5afdf1a9a93a2d -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/jbsilva
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@79e79fc780d551b4ffbcfa1c0d5afdf1a9a93a2d -
Trigger Event:
release
-
Statement type: