Local-first, AI-assisted photo keyword tagging for macOS.
CameraVision analyzes your photos with a local vision model and writes clean, reviewable keyword metadata to XMP sidecar files that Lightroom Classic, Capture One, and other XMP-aware tools read directly. Nothing leaves your machine: images and their derivatives are sent only to a local Ollama model you run yourself.
It ships as two front-ends over one shared engine, so both produce identical, auditable results:
| What it is | Best for | |
|---|---|---|
| CupricAspect | A native macOS SwiftUI app with a guided, five-step workflow | Reviewing and correcting tags visually before you write anything |
| aisidecar | A single command-line tool with subcommands | Scripting, batch runs, and automation |
Status: CupricAspect is in beta (
0.1.0-beta.1). TheaisidecarCLI is feature-complete for its analyze → export → normalize workflows. Everything in this README reflects the tools as they ship today.
- Analyzes images locally. Runs each photo (whole-frame and/or subject-isolated) through a local Ollama vision model to propose subject and scene keywords.
- Keeps an audit trail. Writes a raw
.ai.jsonsidecar per image recording the model, prompts, schema, candidate keywords, confidence, evidence, and provenance. - Writes safe XMP sidecars. Exports the keywords you accept to
.xmpsidecars through a project-owned XMP engine — with backups, semantic merge, and post-write validation. Source image files are never modified. - Normalizes keywords across a batch. Optionally reconciles a folder's keywords against a controlled vocabulary, propagating confident tags and explaining every decision.
- Replays decisions. Saves a durable normalization session you can re-apply later without re-running the model.
Supported image formats: nef, nrw, cr3, cr2, arw, raf, orf, rw2,
dng, jpg, jpeg, tif, tiff, heic, png.
Private by design: photos, derivatives, metadata, sidecars, and model output are never uploaded anywhere. The only network call is to your local Ollama endpoint.
- macOS 15 or later.
- Ollama installed and running locally.
- A vision-capable Ollama model (see Setup).
- To build from source: Xcode with Swift 6 / Swift Package Manager support.
Install Ollama from the macOS download page, then confirm it is available:
ollama --version
ollama listThe Ollama desktop app starts the local service automatically. From a terminal you can also start it manually:
ollama serveCameraVision needs a model that accepts image input. To pull a current public Gemma 4 workstation model:
ollama pull gemma4:26bThe built-in default tag is gemma4:26b-a4b-it-qat (render profile
gemma4-26b-default). If that exact tag is available to your Ollama install, pull
it; otherwise pull any current vision-capable tag and select it with --model (CLI)
or in Settings (app).
ollama pull gemma4:26b-a4b-it-qatTwo ways to get CupricAspect: download a prebuilt app, or build from source.
Download (recommended for the beta). Grab the latest build from the Releases page:
CupricAspect-<version>.dmg— open it and dragCupricAspect.appto Applications.SHA256SUMS— checksums for verifying your download.
Beta builds are not notarized by Apple, so on first launch macOS warns that it "could not verify" the app. This is expected. Clear it once, either way:
- open System Settings → Privacy & Security, scroll to the note that CupricAspect was blocked, and click Open Anyway; or
- run
xattr -dr com.apple.quarantine /Applications/CupricAspect.app.
After that the app launches normally. (Once a signed, notarized release channel exists, that channel will not show this prompt at all.)
Verify your download (optional). From the folder holding the downloaded files:
shasum -a 256 -c SHA256SUMSEvery release is also built by GitHub Actions with a provenance attestation. With the GitHub CLI you can confirm an artifact came from this repo's CI:
gh attestation verify CupricAspect-<version>.dmg --repo ronbuening/CameraVisionBuild from source. Prefer to run from source? Clone the repo and run the app directly:
swift run CupricAspectOr produce your own packaged .app + DMG in dist/ (a local, untracked folder):
Scripts/build-release.shSee Building From Source for the full command set.
CupricAspect walks you through tagging a folder of photos in five guided steps. Each step maps onto the same engine the CLI uses, so what you review is exactly what gets written — and nothing touches XMP until you approve it.
Choose one root folder; nested folders are included via the Include subfolders toggle (on by default). Detected images appear in a thumbnail grid, and you can send output to a separate folder instead of writing beside the originals.
Pick the path: Analyze only (write auditable .ai.json, no XMP — the safest
first pass), Analyze & write XMP (export accepted keywords straight to .xmp for
Lightroom Classic and Capture One), or Analyze, Normalize, and Write XMP (the full
pipeline — reconcile keywords batch-wide under your vocabulary and consensus rules,
then write normalized XMP). A previously saved normalization session can also be
applied from here.
Confirm the run: render mode (Whole Image / Subject Only / Both) and the vision model, which shows a live connectivity indicator and acts as a per-run override that does not change your saved default. An Advanced flags disclosure covers GPS context, existing-sidecar handling, and concurrency.
Progress runs locally with a live count, elapsed time, and per-image rate; the copper aperture "breathes" while the job runs. You can Cancel at any point — analyzed photos stay done, so starting again resumes where you left off.
Approve, reject, edit, or defer each candidate keyword — per image or in bulk — with confidence bands and provenance visible. Save session only preserves your review for later; Write XMP shows the change plan first and can optionally clean up intermediate files after a successful write.
- Normalization Inspector. For batch normalization, a read-only inspector explains every keyword decision — outcome, originating stage, governing rule, support, and skip reasons — with a "needs attention" filter for conflicts and requires-review tags. A session-context panel lets you provide Subject / Habitat / Event hints before a run.
- Nothing is written until you say so. Analysis produces only
.ai.json; XMP is written only when you export, and a dry-run change plan is always available first. - Settings persist to the shared
config.json(so the CLI sees the same defaults), let you pick the vision model and endpoint, tune model timeout and retry limits, show a connectivity indicator, and choose Light / Dark / Auto themes with copper, brass, or patina accents. - Studio shell (nonlinear, sidebar navigation) is planned for a later release; its toggle is visible but disabled ("coming soon") during the beta.
Run the CLI with swift run aisidecar <command> from the repo, or invoke the built
binary directly. Every command has --help, and aisidecar --version prints the
product version.
| Command | Purpose |
|---|---|
analyze |
Scan images, render model inputs, call Ollama, and write raw .ai.json sidecars. |
write-xmp |
Export accepted candidates to XMP sidecars — or analyze-and-write in one command. |
normalize |
Build normalized batch decisions, sessions, reports, dry-run plans, or normalized XMP writes. |
apply-session |
Re-apply stored normalization decisions without rerunning analysis. |
explain-session |
Read-only explanation of a saved session's keyword decisions. |
benchmark |
Run the benchmark harness. |
purge |
Remove derivative-cache artifacts. |
cleanup |
Remove owned raw sidecars and run/report artifacts from a folder. |
Tip: While experimenting, always pass
--output-dirso generated sidecars and reports land in a staging folder instead of beside your original photos.
Inspect what the scanner sees, without running the model:
swift run aisidecar analyze /path/to/photos --recursive --dry-scanAnalyze and write raw .ai.json sidecars:
swift run aisidecar analyze /path/to/photos \
--recursive --mode both --model gemma4:26b \
--output-dir /tmp/aisidecar-aiPreview the XMP export as a change plan (writes nothing):
swift run aisidecar write-xmp \
--from-json /tmp/aisidecar-ai --recursive \
--source-root /path/to/photos --dry-run \
--output-dir /tmp/aisidecar-xmp-previewWrite XMP sidecars after reviewing the plan:
swift run aisidecar write-xmp \
--from-json /tmp/aisidecar-ai --recursive \
--source-root /path/to/photos \
--output-dir /tmp/aisidecar-xmpAnalyze only — auditable AI output, no XMP:
swift run aisidecar analyze /path/to/photos --mode both --recursive \
--model gemma4:26b --output-dir /tmp/aisidecar-aiAnalyze and write XMP in one command:
swift run aisidecar write-xmp /path/to/photos --recursive --mode both \
--model gemma4:26b --output-dir /tmp/aisidecar-xmpAnalyze and normalize in one command (batch-aware keyword decisions):
swift run aisidecar normalize /path/to/photos --recursive --mode both \
--model gemma4:26b --output-dir /tmp/aisidecar-normalized-xmpBuild a normalization session without writing XMP:
swift run aisidecar normalize --from-json /tmp/aisidecar-ai --recursive \
--source-root /path/to/photos --session-only \
--output-dir /tmp/aisidecar-normalizationApply a saved session later, no model runs:
swift run aisidecar apply-session \
/tmp/aisidecar-normalization/normalization-session-2026-07-07T180000Z-a3f2.json \
--dry-run --output-dir /tmp/aisidecar-apply-previewInspect the exact model inputs before a real run:
swift run aisidecar analyze /path/to/photos --mode both \
--export-model-inputs /tmp/aisidecar-model-inputsBoth tools share one config file. Precedence, highest to lowest:
CLI flag > AISIDECAR_* environment variable > JSON config file > built-in default
Default config path (the app's Settings sheet writes through to this same file):
~/Library/Application Support/aisidecar/config.json
Point at a different file with --config <path> or AISIDECAR_CONFIG.
The commented reference template is aisidecar.config.example.jsonc. It is JSONC for readability; the loader accepts strict JSON only, so strip comments and unused keys before saving it as a config. Unknown keys are rejected, so a typo can't silently change future runs.
A minimal config:
{
"model": "gemma4:26b-a4b-it-qat",
"model_endpoint": "http://localhost:11434",
"model_timeout_seconds": 180,
"model_retry_limit": 2,
"profile": "gemma4-26b-default",
"gps_context": "coarse",
"stage_concurrency": 1
}Frequently used knobs:
--model <tag>/"model"— choose the installed Ollama vision model.--model-endpoint <url>/"model_endpoint"— point at a non-default Ollama endpoint.--model-timeout <seconds>/"model_timeout_seconds"— allow slower model requests or cold starts.--model-retry-limit <n>/"model_retry_limit"— set additional attempts for retryable failures.--mode whole|subject|both/"mode"— analysis input role.--stage-concurrency 1/"stage_concurrency"— lower memory pressure by preparing renders serially.--gps-context off|coarse|exact/"gps_context"— prompt-only GPS context. Coordinates and GPS-derived location metadata are never exported as keywords.--existing skip|overwrite|fail/"existing"— how raw.ai.jsoncollisions are handled.--pair-scope union|raw-only|jpeg-only/"pair_scope"— RAW/JPEG same-base-name grouping.--normalization-mode off|single-image|batch-conservative/"normalization_mode"—offis the Phase 2 baseline: no vocabulary mapping, affinity, batch propagation, or session-context application.
| File / location | What it is |
|---|---|
<image>.<ext>.ai.json |
Raw AI sidecar: source identity, model provenance, prompts, candidates, run records, plus an xmp_export stamp after a successful export. |
<image>.xmp |
Metadata sidecar written by the owned XMP engine. |
*-progress-*.jsonl |
Progress log for folder/batch operations. |
*-report-*.json |
Machine-readable run report. |
*-summary-*.md |
Human-readable run summary. |
normalization-session-*.json |
Durable session that apply-session can reuse. |
~/Library/Caches/aisidecar/derivatives |
Regenerable render/derivative cache (default location); CLI and GUI coordinate access, and purge skips artifacts in active use. |
~/Library/Application Support/CupricAspect/ |
App state (recovery session, per-run artifacts) and a size-capped diagnostic log (path shown in Settings → About). |
New run artifacts use a filesystem-portable yyyy-MM-dd'T'HHmmssZ-<4hex> token, for example
batch-progress-2026-07-07T180000Z-a3f2.jsonl. The shared suffix keeps a run's progress, report, and summary files
visually paired and prevents same-second collisions. Existing colon-bearing artifact names remain readable and
cleanup-compatible.
After a write, import or synchronize metadata in Lightroom Classic or Capture One using each app's normal XMP workflow to pick up the new keywords.
- Analysis never touches XMP.
analyzeand all raw-sidecar paths write only.ai.json. XMP is created or modified only bywrite-xmpand normalized export. - Source images are never modified — including RAW, JPEG, TIFF, HEIC, PNG, and DNG.
- Dry-run is real.
--dry-runon XMP and normalization paths writes plans and reports but never modifies sidecars. - XMP writes are careful: owned parser/writer, deterministic backups, source-hash checks, and post-write semantic validation.
- GPS location data stays out of keywords. GPS context can influence prompts and provenance, but coordinate syntax, GPS-derived location terms, and GPS-only evidence are guarded at extraction, session context, review, and final planning. A visible object such as a GPS receiver may still be tagged as an object.
cleanupis conservative. It never removes source images,.xmpsidecars, XMP backups, model-input exports, debug derivatives, the derivative cache, or normalization session JSON. It removes UUID-shaped atomic-writer temp files only after they are more than 24 hours old; fresh and unrelated hidden files stay protected.
Batch exit statuses — Batch commands return 0 only when all items succeed,
1 when one or more items fail, and 130 when interrupted. This lets shell chains
such as aisidecar analyze ... && aisidecar write-xmp ... stop after an incomplete
run. Dry-run modes follow the same policy: write-xmp --dry-run and
normalize --dry-run exit 1 when the printed change plan contains failed inputs
or target plans. Symbolic links in a scanned folder are skipped with a recorded
scan error and count as failed items. analyze --dry-scan remains a diagnostic
exception: it reports scan errors in its JSON output and exits 0 when the scan
itself completes.
E_MODEL_TAG_NOT_FOUND — The configured model isn't installed, is named
differently, or isn't reported by Ollama as vision-capable. Check and pass a known tag:
ollama list
swift run aisidecar analyze /path/to/photo.jpg --model gemma4:26b --output-dir /tmp/aisidecar-aiCannot connect to Ollama — Start the desktop app or run ollama serve, then
confirm the endpoint. CameraVision defaults to http://localhost:11434.
Memory pressure during analysis — Use a smaller vision model, set
--stage-concurrency 1, avoid --mode both on the first pass, and write to a staging
--output-dir.
Running two copies at once — Not supported. Two CupricAspect instances, or the app
and the CLI, working the same folders share the review recovery file, diagnostic log,
and config.json; the last writer wins and can silently overwrite the other's autosave
or settings. Quit one before working in the other.
XCTest unavailable (Command Line Tools only) — Point SwiftPM at a full Xcode install:
env DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift test# Build and test
swift test
# Run the CLI
swift run aisidecar --help
# Run the app
swift run CupricAspect
# Build the packaged CupricAspect.app + DMG
Scripts/build-release.shSources/AISidecarCore/ Shared scan, render, model, sidecar, XMP, normalization, and pipeline logic.
Sources/AISidecarCLI/ Argument parsing, command wiring, and CLI presentation.
Sources/CupricAspectApp/ SwiftUI GUI: presentation and state orchestration; all processing stays in Core.
Tests/AISidecarCoreTests/ Offline tests and synthetic fixtures for Core/CLI.
Tests/CupricAspectAppTests/ Offline GUI model tests.
Scripts/ Release build, packaging assets, and fixture generator.
dist/ Assembled CupricAspect.app and DMG output (local, git-ignored).
Reusable behavior lives in AISidecarCore; the CLI and GUI targets are thin
front-ends over it, which is why both produce identical results. Tests are
deterministic and offline (no Ollama, network, or real images).




