Skip to content

chore(photo-exif): tag people in the photo library (face pass) #217

Description

@MSIH

Now actionable (2026-07-25). Both things that blocked this are resolved: the face-detection rewrite landed (#268 / PR #273), and the owner decided this pass runs first, ahead of photo descriptions. The description job was split out into #275.

⛔ Blocked by #280 — do not run this pass yet

Added 2026-07-25 after review. This pass cannot see HEIC photos, and it fails silently: detectFaces throws on every HEIC and face-worker.js:178-181 logs the error and continues. The job would finish, report a face count, and never tell you it skipped 62,614 photos — 47% of the library.

Nothing on this machine can decode a HEIC today (measured 2026-07-25): sharp's bundled libheif fails on all sampled files, and there is no ffmpeg, ImageMagick, or Windows HEIF codec. Ingestion worked only because exifr reads EXIF metadata without decoding pixels — this pass is the first code that needs the pixels.

The gap is concentrated in exactly the years with the most people in frame:

year HEIC other HEIC share
2019 0 8,738 0%
2021 10,338 10,481 50%
2022 12,765 3,602 78%
2023 16,314 6,001 73%
2025 6,654 4,909 58%
2026 3,202 1,374 70%

Running before #280 lands means person search works well for 2015-2019 and goes progressively blind through the years you have the most photos of people — with no error to tell you so.

One piece of good news: the continue fires before the photo is written to the state file, so skipped photos are never marked done. Land #280 and re-run — everything is picked up, with no state to clear. But it is cheaper to land #280 first than to run this pass twice.

#280 also fixes the same blocker for #275, which needs it too.

Problem

The photo library — about 129,000 photos — can currently be searched only by when and where a photo was taken, plus the folder it came from. You can't ask for photos of a particular person.

There's an already-built job (connectors/photo-exif/face-worker.js) that fixes this. It finds faces in each photo, groups the ones that look like the same person into anonymous piles, and then — once you tell it "that pile is Amy" — tags every photo in that pile with her name. Those tags are what make "show me photos of Amy" work.

It has never run on this machine — verified 2026-07-25: zero photos have any face information.

Why it's runnable now

Two things were in the way, both now cleared:

  1. The face-detection software wouldn't install. The old setup needed a component with no prebuilt version for this machine's Node version, and building it from scratch needed a Visual Studio C++ toolchain that isn't installed. feat(photo-exif): swap face stack from tfjs-node to onnxruntime-node #268 replaced that whole stack with one that ships ready-to-run binaries. No build step anymore.
  2. It was waiting on the description job. That job turns out to be a months-long grind (see chore(photo-exif): write AI descriptions for the photo library (timing test first) #275). Rather than keep the fast, valuable half hostage to the slow half, the owner decided to run this pass first.

Ordering: this runs first, descriptions run second

Both jobs replace a photo's stored text and small metadata record rather than adding to them — so whichever runs second overwrites some of what the first one wrote. Understanding that tradeoff is the whole reason ordering matters.

Chosen order — faces first:

Week 1 This pass runs (hours) → person search works
Week 1+ You name the piles you recognize
Months after Descriptions (#275) fill in gradually overnight
Months after Nothing to redo — #276 made the two passes preserve each other

No longer overwritten (#276, merged 2026-07-25). The description job used to wipe this pass's bookkeeping — the detected-face count and the "Pictured: …" sentence. Both jobs now build their photo record through one shared component, so each carries the other's work through. Nothing is lost in either direction and no re-run is needed.

One rule remains: run the two passes one at a time, never overlapping. Each reads the other's state once when it starts, so naming a face while a description run is in flight would still be missed by that run.

(Faces-first was chosen on 2026-07-25 to avoid delaying person search by months. Since #276 the order no longer costs anything either way.)

Current state (verified 2026-07-25)

Thing State
Photos in scope 129,143
— of those, with face info 0
— of those, from Google Photos (so they have Google's own people tags) 129,127 (99.99%)
Photos not in scope (came from Messages, outside the photo folder) 5,395
Face models downloaded? noFACE_MODELS_PATH is unset
Job's progress files absent
Photo import finished? yes — nothing new since 2026-07-17

How naming the piles works — two paths, cheap one first

Because 99.99% of the library came from Google Photos, most photos carry Google's own people tags. That makes the cheap path the primary one:

  1. From Google's tags (feat(photo-exif): suggest cluster labels from single-name Google Photos sidecars #272 — free, no AI, no network). Where a photo has exactly one face and Google tagged exactly one person, that's a vote for "this pile is that person." Piles with a clear enough majority get a suggested name printed. This should name most piles for nothing.
  2. From your contact photos (feat: suggest cluster labels from contact photos in photo-exif face-worker #84 — fallback). For piles the first path can't name, compare against the photos on your contacts.

Both paths only ever print suggestions. Neither ever applies a name by itself — you confirm each one. A wrong automatic name would pollute the contact graph worse than leaving the pile anonymous.

Implementation Plan

  1. Land fix(photo-exif): shared HEIC-capable image decode for both AI passes #280 first (HEIC decode). Without it this pass silently skips 47% of the library — see the blocked-by note at the top. Everything below assumes fix(photo-exif): shared HEIC-capable image decode for both AI passes #280 has merged.

  2. Download the two face models into a folder and point FACE_MODELS_PATH at it (currently unset in connectors/photo-exif/.env): det_10g.onnx (finds faces) and w600k_r50.onnx (recognizes them), the buffalo_l pair from the InsightFace model zoo. One-time download; fully offline afterward.

  3. Install the connector's dependencies: npm install in connectors/photo-exif/. No compiler needed — both packages ship prebuilt binaries (this is what feat(photo-exif): swap face stack from tfjs-node to onnxruntime-node #268 fixed).

  4. Run the pass: node face-worker.js from connectors/photo-exif/. No pause between photos by default, so it runs at full speed. Saves progress after every photo and resumes, so killing it mid-run is safe. Watch the log for face detection failed for ... lines — a run producing tens of thousands of them means fix(photo-exif): shared HEIC-capable image decode for both AI passes #280 didn't take, and the job will still exit reporting success.

  5. Get name suggestions, cheap path first: node face-worker.js suggest-from-sidecars, then node face-worker.js export-thumbnails ./faces and suggest-labels for whatever's left unnamed.

  6. Confirm the names you recognize: node face-worker.js label <id> "<name>" for each. This is the step that actually creates the person tags.

  7. No follow-up re-run needed — fix(photo-exif): caption pass must preserve face enrichment #276 made the description job preserve this pass's output. Just don't run the two passes at the same time.

Acceptance Criteria

  • The face pass completes over the library; the count of photos with face information climbs from 0 and is reported.
  • HEIC photos were actually examined — roughly 47% of the photos with face information are .heic, and the run did not emit a large volume of face detection failed lines. Without this, a run that skipped half the library still passes the criterion above.
  • suggest-from-sidecars runs and its suggestion count is reported (how many piles Google's tags named for free).
  • At least one pile is confirmed with a real name, and that person's photos then surface when searching for them.
  • No photo gets duplicated — face info attaches to the existing photo record.
  • The date, location, place name, and original-file pointer on each photo are unchanged after the job touches it.
  • Store→recall smoke test still green (server boots, 0 errors).

Out of Scope

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3choreworth-it:DOworth-it verdict: build now (evidenced pain / activation / correctness)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions