15 KiB
Image Categorizer — Design (M1: Foundation + Trip-Cluster)
Date: 2026-06-27
Status: Approved design, pending implementation plan
Scope of this spec: Milestone M1 only (shared foundation + the trip-cluster app). Later milestones are documented as a roadmap, each to get its own spec→plan→build cycle.
Purpose
A monorepo of small, independent tools that clean up and structure a ~40k-image, 15-year Immich library so it can become travel "memories." This is the upstream stage of an existing pipeline:
[NEW categorizer: trips + tags] → image-rater (pick best → 4+ album) → travel-memories (album → Grav blog)
Every handoff is through Immich itself (tags, albums, ratings) — no app talks to another directly. The categorizer's job is to leave clean trip tags (and explicit non-trip marks) in Immich for image-rater to consume by tag.
Division of labor (the core principle)
AI/algorithms do what they do well; the human does QA and (later, downstream) content creation.
- The machine proposes groupings with a confidence score.
- The human reviews at the cluster level (a few dozen candidate trips), not photo-by-photo, mostly in bulk, with attention concentrated where confidence is low.
- Nothing is silently auto-applied to the library. Trust matters with 15 years of memories.
What M1 explicitly does NOT do
- It does not decide which photos within a trip are keepers — that is
image-rater's job downstream. - It does not fully categorize everyday/non-trip photos beyond marking them as non-trip (deferred to a future milestone).
- It does not draft any narrative text (the user owns content creation).
Context & constraints (from the existing ecosystem)
The two existing apps (claude-image-rater, claude-travel-memories, both under /home/mischa/Projects/) establish strong, consistent conventions this project follows and builds upon:
- Stack: Python 3.12 + Flask (factory
create_app()) + an argparse CLI sharing one package. - No-build frontend: Jinja templates extending
base.html, DaisyUI 4 + Tailwind + Alpine.js (CDN), HTMX for navigation, onestatic/app.js. - Single-responsibility modules: one module is the only thing that talks to Immich; one is the only thing that talks to Anthropic.
- Testing: pytest +
pytest-httpserver(mock Immich) + Playwright UI tests, TDD ("failing test first"). - Docker: one container per app, distinct port,
user: ${UID}:${GID}, state on a mounted volume. - Proven UI: travel-memories' triage screen (grid + ring-selection + keyboard navigation + fullscreen lightbox) was reused by image-rater. This becomes the canonical shared component (see §UI).
Both apps currently duplicate near-identical code (immich.py, config.py, slugify, base.html, thumbnail proxy route, JSON-state pattern). That duplication is the evidence that extracting a shared foundation is high-value, and that migrating those apps onto it later (M4/M5) is largely mechanical.
Decisions locked during brainstorming
- State store: SQLite (not the JSON-file pattern of the existing apps). A single DB makes the cross-trip filtering this project needs ("all assets in a trip window but missing the trip tag", "everything still unreviewed") far easier.
shared/coreis designed cleanly enough that the other apps could migrate onto it later. - Immich is the source of truth. SQLite is the working/review layer; all durable results are written back to Immich as tags.
- Monorepo with shared packages + per-app containers (Approach A on a shared foundation). Build the categorizer first as the POC that proves the foundation, then migrate the other apps onto it in later milestones (Option 2 end-state), in milestones — not all at once.
- GPS is a minority signal. Most old camera photos lack GPS, so trip detection is timestamp-first, anchored by existing tags, refined by GPS where present.
- Everyday/non-trip photos: assign trips AND positively mark non-trip so they stop resurfacing as "unreviewed." Full non-trip categorization is deferred.
Roadmap (sequenced; each milestone independently shippable)
End state: one monorepo — shared/{immich, core, ai, ui} + apps/{trip-cluster, tag-verify, enrich, image-rater, travel-memories}, each its own container.
- M1 — Foundation + trip-cluster (THIS SPEC). Stand up the monorepo, shared packages, SQLite store, Immich ingest, and the
trip-clusterapp end-to-end. Validate on the last trip's photos. This is the POC that proves the foundation. - M2 — tag-verify app on the proven foundation (verify/normalize existing tags, dedupe vocabulary, find outliers).
- M3 — enrich app (geocode location tags like "Kiev" → coordinates and optionally backfill GPS into Immich to improve future trip detection; AI captioning; content tags; noise detection).
- M4 — migrate image-rater onto shared packages (+ optional SQLite).
- M5 — migrate travel-memories onto shared packages (+ optional SQLite).
- M6 (future/optional) — non-trip categorization (the deferred full-library taxonomy).
M1 Design
Monorepo layout
claude-image-categorizer/
pyproject.toml # workspace; shared/* as editable path deps
docker-compose.yml # one service per app (just trip-cluster for M1)
.env.example # IMMICH_URL, IMMICH_API_KEY, ANTHROPIC_API_KEY, UID, GID
README.md
CLAUDE.md
shared/
immich/ # the one true Immich client
core/ # SQLite store + domain models + persistence
ai/ # Anthropic batch wrapper (scaffolded; light/unused in M1)
ui/ # base.html, Tailwind/DaisyUI/Alpine/HTMX, Jinja macros, app.js
apps/
trip-cluster/
Dockerfile
categorize.py # CLI entry point -> app.cli.main()
app/ # Flask factory, routes, app-specific logic
tests/
docs/superpowers/specs/ ...
shared/*are local, editable Python packages so appsimportthem directly. Packaging via a workspace (pyproject.tomlpath deps;uvworkspace optional). This stays close to the existing pip+venv practice while enabling the monorepo.- Each app owns its Dockerfile and a distinct port. trip-cluster serves on 8084 (8082 = travel-memories, 8083 = image-rater).
- The SQLite DB and downloaded thumbnails live on a mounted volume so state persists across container restarts and is reachable from the host.
user: ${UID}:${GID}.
Shared packages
shared/immich
The single Immich REST client (consolidates the duplicated immich.py from both existing apps). requests.Session with x-api-key. Capabilities needed for M1:
resolve_tag_id(name), list tags / tag inventory.- Search assets (paginated
POST /api/search/metadata) by date range, by tag, and broadly — returning at least:id,originalFileName,localDateTime, GPS lat/lon (when present), city/country (when present),type, existing tags, rating. download_thumbnail(asset_id)(…/thumbnail?size=preview).upsert_tag(name),tag_assets(tag_id, asset_ids)(write-back of trip / non-trip tags).
shared/core
SQLite store + domain dataclasses + persistence (connection, schema/migrations, data-access helpers). Owns the on-disk schema. Designed as a clean data-access layer so other apps can adopt it later.
Domain model (central unit = Cluster / candidate trip):
- Asset — mirror of an Immich asset:
immich_id,taken_at,gps_lat/gps_lon(nullable),place_city/place_country(nullable),type,has_gps,thumb_path,ingested_at. - Tag — inventory of Immich tags:
name,immich_tag_id, usagecount(ingested now; used heavily in M2). - AssetTag — existing Immich tags per asset (denormalized for filtering).
- Cluster — a candidate trip:
id,start_at,end_at,count,suggested_name,confidence,kind_guess(trip|everyday),status(pending|approved|non_trip|merged|split|skipped),decided_name,reviewed_at,notes. - ClusterMember — asset↔cluster link with
member_confidenceandis_outlier. - WritebackLog — every change pushed to Immich (
asset_id,action,tag,result,applied_at) for idempotency. - Meta — last-ingest timestamp, run params, schema version.
SQLite tables: assets, asset_tags, tags, clusters, cluster_members, writeback_log, meta. Single DB file on the mounted volume.
shared/ai
Anthropic batch Messages API wrapper using the official anthropic SDK, carrying over image-rater's proven conventions (per-criterion judgments, confidence, score floors, chunking ≤50/batch, default model claude-haiku-4-5). Scaffolded but barely used in M1 — trip-cluster is algorithm-first (near-zero AI cost). It earns its keep in M3 (enrich).
shared/ui
The shared visual foundation, extracted from the proven travel-memories/image-rater templates:
base.html(DaisyUI 4 + Tailwind + Alpine.js + HTMX via CDN, navbar, content block).- The canonical grid + fullscreen lightbox component: thumbnail grid with ring-selection, arrow-key navigation, full-screen view (arrows = prev/next, Esc = close). (First-class requirement.)
- Reusable Jinja macros (image grid, lightbox, confidence/status badges, approve/reject controls) and shared
app.jsAlpine components.
The trip-cluster app (M1 deliverable)
Workflow
categorize ingest [--from DATE --to DATE | --tag NAME | --subset N] # 1. pull → SQLite + thumbs
categorize cluster [--gap-threshold ...] # 2. build candidate trips
categorize serve # 3. review UI on :8084
# (approve / non-trip / split / merge)
categorize apply # 4. write approved tags back to Immich
(serve may also trigger apply per-cluster via a UI button, plus a batch "apply all approved" action; CLI apply is the headless equivalent.)
1. Ingest (automatic, scopeable)
Pull assets from Immich via shared/immich, upsert metadata into SQLite, download thumbnails. Incremental & idempotent (fetch only changed-since-last via Immich updatedAt; upsert by immich_id).
Scopeable from day one — the mechanism for the vet-first plan. Instead of forcing a full 40k pull, ingest accepts a bounded scope:
--from / --to(date window) → e.g. the last trip plus surrounding everyday photos;--tag NAME→ a single already-tagged trip;--subset N→ a cap.
This lets the whole tool be validated on the last trip for near-zero cost before widening to the backlog over time.
2. Cluster (automatic) — signal hierarchy
Pure, algorithmic, no API calls (keeps the POC nearly free). Produces candidate trips, each with a suggested_name, confidence, and kind_guess:
- Existing trip tag → authoritative seed; its assets form a confirmed cluster (still shown, to verify completeness). Respects the user's existing trip-tag convention.
- Timestamp gap clustering → primary structure: sort by
taken_at, split where the inter-photo gap exceeds a tunable threshold. - Location anchors (existing location tags like "Kiev" + GPS when present) → refine boundaries, propose names.
- Coverage detection → assets inside a confirmed trip's time window but missing its tag are flagged "likely belongs here" (the completeness gap); assets carrying a trip tag but outside their cluster are flagged as outliers.
- Visual similarity (Immich CLIP / local pHash) → optional tie-breaker for ambiguous boundaries; marked optional so it can never block the slice.
Confidence & kind_guess (echoing image-rater's confidence/floor approach): tight time window + existing trip tag + consistent location → high confidence; sparse, untagged, no GPS → low ("needs your eye"). Low-volume scattered clusters → kind_guess = everyday (suggested non-trip).
3. Review (human, cluster-level)
A review screen lists candidate trips sorted by "needs attention" (low confidence first). Per cluster: thumbnail grid (with the shared grid+lightbox / arrow-key / full-screen component), editable suggested name, and actions:
- Approve trip (confirm + tweak name)
- Mark non-trip
- Split (break one cluster into two)
- Merge adjacent (combine with a neighbor)
- Skip
To keep it fast: high-confidence clusters arrive pre-filled, and an "approve all high-confidence" bulk action lets the user rubber-stamp the obvious cases, concentrating attention on the fuzzy ones. Every decision persists immediately to SQLite (resumable: closing and reopening resumes exactly where the user left off).
4. Write-back (to Immich, idempotent)
On approval (per-cluster or batch apply):
- Approve → write the trip tag (via
upsert_tag+tag_assets) to all member assets, respecting the existing trip-tag convention. - Mark non-trip → apply a
non-triptag (named to fit the user's existing scheme) so those assets are filtered out and never resurface as unreviewed. - Idempotent:
writeback_logrecords applied changes; re-runs skip what's already done. Explicit confirmation before any write (mirrors image-rater's export safety).
All durable state lands in Immich; SQLite remains the working/review layer.
Tag-scheme reconciliation (important)
Before introducing any tag convention (trip names, the non-trip mark, or any future ai-* scheme), inspect the live Immich instance and reconcile with what already exists rather than inventing a parallel scheme. The user's trips already follow a convention — trip-cluster must read and respect it.
App shape & conventions
categorizeCLI (argparse subcommands:ingest·cluster·serve·apply) + Flask factorycreate_app().- Jinja + DaisyUI/Tailwind/Alpine/HTMX via
shared/ui, no build step, Immich thumbnail proxy route, serves on 8084. - Env vars (same names as existing apps):
IMMICH_URL,IMMICH_API_KEY,ANTHROPIC_API_KEY(Anthropic optional for M1), loaded from.env.
Testing strategy
TDD throughout, mirroring the existing apps:
- Unit: clustering algorithm as a pure function (timestamps/tags/GPS → clusters) — deterministic and easy to assert; SQLite store; config; Immich client (mocked via
pytest-httpserver). - Route/UI: the review flow (approve / non-trip / split / merge, immediate persistence, idempotent write-back) with Immich mocked.
- Playwright UI tests: grid + arrow-key navigation + full-screen lightbox behavior, mirroring travel-memories/image-rater.
- Shared packages tested independently of the apps.
Cost notes
M1 is algorithm-first, so trip-cluster makes essentially no Anthropic calls — clustering is local timestamp/tag/GPS math. AI cost becomes relevant only in M3 (enrich), where image-input tokens dominate and the batch API (~50% cheaper, chunked ≤50) plus the Haiku default keep it modest (image-rater reference: ~700 images on Haiku ≈ $1).
Out of scope for M1
- Choosing keepers within a trip (image-rater).
- Full non-trip/everyday categorization (M6).
- Migrating the existing apps onto the shared foundation (M4/M5).
- Geocoding / GPS backfill / AI captioning (M3).
- Narrative text drafting (human-owned; downstream).