Skip to main content

CLI tool to describe photos and add keywords using AI

Project description

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.

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
  • Supports Ollama and LM Studio compatible OpenAI APIs
  • 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)
  • libraw support for rawpy (install via Homebrew on macOS: brew install libraw)

Installation

For end-users, the recommended installation method is via uv:

uv tool install photo-tagger

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 (default http://localhost:11434/v1)
  • OLLAMA_API_KEY – optional API key passed to Ollama requests
  • LM_STUDIO_BASE_URL – override the LM Studio endpoint (default http://localhost:1234/v1)
  • LM_STUDIO_API_KEY / OPENAI_API_KEY – API key for LM Studio’s OpenAI-compatible server
  • MODEL_NAME – default model name (default qwen/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:

  1. $PHOTO_TAGGER_CONFIG environment variable (explicit path)
  2. .photo-tagger.toml in the current working directory (project-local)
  3. ~/.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 (default cr3,jpg)
  • -r/--recursive – recurse into subdirectories while scanning inputs
  • -m/--model – model identifier understood by your provider
  • --providerollama or lmstudio (defaults to lmstudio)
  • --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 *_original snapshot 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 of PATH
  • --summary-file PATH – write a JSON run summary (token usage, success/failure counts) to PATH on completion
  • --cache-file PATH – persistent SQLite cache of model outputs keyed by image content hash and model+prompt+settings. Reruns skip the model call when nothing relevant has changed
  • --lock-file PATH – acquire an exclusive file lock on PATH before running and refuse to start if another photo-tagger already 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 into jq or your own tools
  • --newer-than DATE / --older-than DATE – filter the input batch by file mtime. Accepts ISO 8601 like 2024-01-01 or 2024-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 in FILE (one per line; # lines are comments).
  • --append-to-skip-file FILE – append each successfully tagged filename to FILE as 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

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

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

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:

pytest

If you plan to contribute, also run ruff check for linting before opening a PR.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

photo_tagger-0.2.1.tar.gz (45.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

photo_tagger-0.2.1-py3-none-any.whl (52.5 kB view details)

Uploaded Python 3

File details

Details for the file photo_tagger-0.2.1.tar.gz.

File metadata

  • Download URL: photo_tagger-0.2.1.tar.gz
  • Upload date:
  • Size: 45.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for photo_tagger-0.2.1.tar.gz
Algorithm Hash digest
SHA256 1c17b4197565f525202b4ce991cd7a14d9a10fc0b2c86b0f67e526322a0fb47a
MD5 45d802da19ca4f952d82d67c922f87ff
BLAKE2b-256 e47be94752907d570a2c075d5f12d694d75f4b08fb7d7b608171d1772f18e404

See more details on using hashes here.

Provenance

The following attestation bundles were made for photo_tagger-0.2.1.tar.gz:

Publisher: publish.yml on jbsilva/photo-tagger

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file photo_tagger-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: photo_tagger-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 52.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for photo_tagger-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 5a288ec32b8e095ea24f796ec164d1b23a608769474a39af6937b2e82df5bc92
MD5 e347fa148de6f31879d54fffa235f510
BLAKE2b-256 e5a08df357cfc0ba2bdbf308a299e740d911cb349efacdc460d51357d8c1440c

See more details on using hashes here.

Provenance

The following attestation bundles were made for photo_tagger-0.2.1-py3-none-any.whl:

Publisher: publish.yml on jbsilva/photo-tagger

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page