feat(faces): People in this gallery — face recognition via an optional ML sidecar (#1074) (#1075)

* feat(ml): optional face-detection sidecar, opt-in and inert by default (#1074)

First of four PRs for "People in this gallery". This one ships only the
sidecar, its wiring and its CI — no schema, no backend code, no UI. Nothing
in PicPeak calls it yet.

picpeak-ml is a single FastAPI + onnxruntime container: three endpoints
(/health, /info, /faces), no database, no volumes, no egress, no model
download at runtime. Clustering, person identity and every privacy decision
stay in the backend where the data already lives.

Models are YuNet (detection) + FaceNet-512 (embedding), both MIT, both
pinned by URL and SHA-256 and verified at build time. The licence analysis
is in ml/LICENSES.md: the more accurate InsightFace weights are
non-commercial-only and PicPeak's users are working photographers, so they
are never baked into an image we publish.

Two things worth review attention:

- Alignment uses a least-squares similarity transform (Umeyama), NOT
  cv2.estimateAffinePartial2D. RANSAC and LMEDS exist to reject outliers
  among many correspondences; given five landmarks and no outliers they fit
  a three-point subset exactly and let the rest drift. Measured on a real
  off-frontal portrait: eyes and nose pinned to 0.11px, mouth corners
  11.8px out on a 160px crop. Umeyama distributes it (max 6.5px, rms 5.1 vs
  7.4). The failure mode is silent — a bad warp still yields 512 confident
  floats — so tests/test_pipeline.py pins it numerically.

- FACENET_ONNX_URL has no default and the build fails loudly without it.
  deepface distributes FaceNet-512 as Keras .h5 only, so the ONNX is
  produced once by tools/convert_facenet.py and published as a release
  asset. Converting inside the build would drag TensorFlow through both
  architecture legs of every build to produce a byte-identical file. The CI
  jobs are gated on the FACENET_ONNX_URL repository variable and skip
  cleanly until it is set.

Off by default, twice over: the sidecar is behind the `faces` compose
profile, and the backend will gate on a `faces` feature flag that defaults
to false. FACE_ML_URL defaults to http://picpeak-ml:8000 so the standard
deployment needs no configuration — nothing dials that host while the flag
is off, which is why a non-resolving default is harmless.

Verified: 27 pytest tests green; YuNet loads and detects against a real
portrait with its landmark order matching the alignment template
index-for-index; both compose files validate and the faces profile is
correctly excluded from a default `up`; workflow YAML parses and the job
graph resolves.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(ml): pin the converter toolchain, verify parity, drop a false reproducibility claim (#1074)

Ran the FaceNet-512 conversion for real and corrected what the previous
commit assumed about it.

The conversion works: 23,497,424 parameters, 89.6 MB ONNX, and the converted
graph matches the Keras original to 2.086e-06 absolute / cosine
1.0000000000. That check is now part of the script rather than something I
did once by hand — a subtly wrong graph still returns 512 plausible floats,
so it refuses to leave the file on disk if parity fails.

Also ran the full pipeline against both real models end to end. The
embedding is L2-normalized to 1.000000, and the same face survives being
re-rendered: half scale 0.973, double scale 0.984, JPEG q40 0.987, rotated
8 degrees 0.984, brightness +40 0.988. Scale invariance in particular is
evidence the alignment warp is doing its job.

Corrected claim: the conversion is NOT byte-reproducible. Two runs with the
same pinned versions on the same machine gave different SHA-256s. The graphs
are functionally identical — same 336 nodes, same 271 initializers, every
weight matching to 0.000e+00 — but a few initializer names differ because
tf2onnx's traced-op naming is not deterministic (Keras layer naming is
deterministic; I checked). The previous commit message and README both
claimed byte-identical output. They were wrong, and it matters: anyone
re-running the conversion gets a different hash, and without this note that
reads like tampering. The build-time SHA-256 pins one published artifact so
its URL cannot start serving different bytes; validating a fresh conversion
is the parity check's job.

requirements-convert.txt now pins the exact set that produced the artifact,
including transitive keras/protobuf/numpy, and documents that the converter
needs Python 3.11 while the image runs 3.12.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): schema, queue, clustering and API for People in this gallery (#1074)

Backend half of the feature. Migration 177, a face-detection queue, the
clustering engine, the gallery and admin APIs, and the privacy wiring. No UI
yet; nothing is reachable until the `faces` feature flag is on, which
defaults to false.

The flag is the gate, not FACE_ML_URL. That variable now has a working
default (the compose service name), so its presence proves nothing about
intent — if it were the gate, every install would poll a hostname that does
not resolve. faceQueue re-checks the flag every tick, so turning it off stops
the workers without a restart.

Visibility scoping is the part worth reviewing closely. Face rows have no
concept of photo visibility, but guests are restricted to
photos.visibility='visible'. A raw count leaks how many hidden photos someone
appears in, and an unscoped cover face renders a crop of a photo the guest
may not open — with the best-scoring face being the likeliest pick, so it
would happen often rather than rarely. facePeopleService recomputes both per
request against the caller's own scope, and event_people.face_count_total is
named to be conspicuous in a guest path. Six tests cover it, including the
case where a person's photos are ALL hidden and they must vanish entirely.

Face data is excluded from backups and .picpeak exports, per the decision in
the thread: it is derived, so a restore re-scans rather than carrying
biometrics between operators. Three separate mechanisms, because the engines
cannot be filtered alike — EXCLUDED_TABLES for export, --exclude-table-data
(not --exclude-table; the CREATE TABLE must survive or restore breaks on the
first query) for Postgres, and DELETE + VACUUM on the temp copy for SQLite,
which has no way to exclude a table from a whole-file .backup. The VACUUM is
not cosmetic: without it the pages stay in the file and the claim is false on
disk.

Archiving now purges face data explicitly. photo_faces cascades off photos,
but archive deletes neither the photo rows nor the event, so without this an
archived gallery kept its biometrics indefinitely.

Other decisions: clustering keeps names across a re-cluster by majority
inheritance (without it, one button click silently discards every name the
photographer typed); consolidation refuses to merge two people who were named
differently; assignment never compares across model_version, since embeddings
from two pipelines are not comparable; low-quality faces are stored but left
unassigned so they show in "this photo contains" without spawning junk people.

Migration is 177, not 174 — 174/175/176 landed on main while this branch was
open.

29 tests green: 7 migration (idempotency, down(), cascade, and that
installing it enqueues NOTHING), 11 clustering, 11 privacy/visibility. Lint
clean; the pre-existing error counts in databaseBackup.js and server.js are
unchanged.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): People strip, face filter and admin controls (#1074)

Frontend half. Renders nothing anywhere unless the `faces` feature flag is
on AND the photographer enabled detection for the gallery — the whole guest
surface hangs off one boolean, event.people_enabled, which the server
computes from the flag, the per-event toggle and the show-to-guests toggle
together.

Guest side: a People strip between the filter bar and the grid, circular
crops from each person's cover face, an active-filter chip row, and a "Show
all" bottom sheet. The face filter composes with category, search, media
type and the liked/saved/rated filters in the same useMemo rather than
replacing them, so "photos of Anna that I liked" works. Two people selected
means AND by default — that is what picking a second face almost always
asks for — with a toggle to OR that appears only once a second person is
picked.

Unnamed people show a photo count and never "Person 7". A number is honest
about what the system knows; an invented name is not. There is a test
asserting we don't do it.

The strip renders nothing below two people, collapses to one line when
dismissed (persisted per slug, so dismissing one gallery says nothing about
the next), and appears mid-backfill with a progress line rather than
blocking the gallery behind a spinner. Avatar crops are computed in ratios
of the source dimensions so they survive whatever rendition the browser
gets; without width/height they fall back to an uncropped thumbnail, since
a wrongly-offset crop is worse than no crop.

No new download endpoint: "download these N" rides the existing photoIds
path, which already enforces access level and per-category permissions
server-side. Adding a person_id selector would have been a second thing to
authorize for no gain.

Guest-facing copy never says "biometric" or "recognition" — those words
describe our implementation, not the guest's experience. The sheet's
footnote answers the first question every guest has (where does this go?)
inline. The admin card, by contrast, is explicit: it states the controller
obligation next to the toggle, and warns that scanning materializes the
preview tier on galleries that never generated one, which is real CPU and
disk an admin should know about before a 2,000-photo backfill.

EN + DE translations. 140 frontend tests green (8 new), tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): measured match threshold, working build defaults, 89MB smaller image (#1074)

Ran the Phase 0 spike that had been outstanding, published the model, and
fixed what both turned up.

THRESHOLD IS NOW MEASURED, NOT GUESSED. LFW's standard 1000-pair protocol
run through this exact pipeline (YuNet -> Umeyama alignment -> FaceNet-512
ONNX), 100% detection on 2000 images:

  same person  cosine 0.6958 +/- 0.1415
  diff person  cosine 0.0849 +/- 0.1674   separation 0.6109
  peak accuracy 96.60% @ 0.405

So the pipeline separates people well — the thing I could not previously
claim, since every earlier number was the same face re-rendered.

Default moves 0.62 -> 0.50. The old value was a placeholder and a bad one:
it gave 0% false merges but 22.4% false splits, i.e. roughly one in four
same-person pairs failing to join, which fragments a gallery badly. 0.50
gives 1.0% false merge / 8.2% false split. Peak accuracy (0.405) is
deliberately NOT chosen: for clustering the two errors do not cost the same.
A false split is a duplicate row the photographer can merge away; a false
merge puts a stranger into someone's "download my photos" — and until the
Phase 2 merge/split UI ships, there is no way to undo one. So this sits on
the conservative side of the optimum.

The spike is committed as ml/tools/benchmark_threshold.py rather than
thrown away, so "why 0.50?" has an answer in six months and a re-tune is one
command.

BUILD DEFAULTS. FACENET_ONNX_URL/_SHA256 now default to the published
ml-models-v1 release asset, so `docker build ml/` and
`docker compose --profile faces up` work with no arguments. Blanking either
still fails loudly — a URL without a checksum is never acceptable, since the
checksum is what makes the URL safe to trust. Found by running compose for
real: it failed exactly as designed, which was correct behaviour and a bad
out-of-box experience now that a canonical artifact exists.

IMAGE SIZE. 389MB -> 300MB single-arch. `chown -R` after COPY rewrote every
copied file into a fresh layer, duplicating the 90MB model for nothing; the
user is now created before the copies and ownership set via COPY --chown.
Also drops pip/setuptools from the runtime image. Measured RSS is 186MiB
idle, and the container answers /faces end-to-end in well under the
80-150ms/photo the issue budgeted.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): threshold 0.50 -> 0.60 from real clustering, theme-aware People strip (#1074)

Both fixes come from running the feature on an actual gallery — 61 photos,
5 real identities — rather than reasoning about it.

THRESHOLD. The LFW pairwise sweep in the previous commit said 0.50, and it
was wrong. On a real gallery at 0.50, three of six visible clusters were
contaminated: two different people merged into one strip entry, which is the
exact failure that puts a stranger into someone's "download my photos".

Pairwise error rates do not predict cluster purity. Greedy assignment
compounds — one wrong face drags the centroid toward the midpoint between two
identities, making the next wrong face likelier. A 1% pairwise false-merge
rate is not a 1% chance of a clean gallery, and no amount of staring at an
ROC curve would have shown that.

Sweep against ground truth (5 identities):

    0.50 -> 6 clusters, 3 contaminated
    0.56 -> 6 clusters, 0 contaminated
    0.60 -> 5 clusters, 0 contaminated   <- exactly right
    0.64 -> 5 clusters, 0 contaminated, fewer faces assigned

0.60 recovers the right number of people with no contamination; higher only
loses coverage. Migration 177 carries the full reasoning so the next person
to touch this knows why the obvious pairwise answer is the wrong one.

THEME. The People strip hardcoded `text-neutral-800` for named people. On a
dark gallery — which the screenshot immediately showed — that renders a
named person's label almost invisibly, while UNNAMED people stayed legible.
Exactly backwards. Labels, headings, the collapsed summary, the scan line
and the filter chip row now read the gallery's own theme tokens
(--color-text / --color-muted-text / --color-accent / --color-surface-border)
like the rest of the gallery surface.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): keep the mobile filter row inside the viewport (#1074)

At 390px the photo count and Clear link were pushed against the right edge
by ml-auto and clipped. Only apply it from the sm breakpoint up, where
there is room; below that they flow after the chips.

Found by screenshotting the real thing on an iPhone-sized viewport.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): complete Phase 1, add People management and auto-categories (#1074)

Closes the two Phase 1 gaps, then builds Phase 2 and Phase 3.

PHASE 1 GAPS. "Download these N" was specified, described as done in an
earlier summary, and never actually built — I had verified the backend needed
no new endpoint and let that stand as if the button existed. It now hands the
filtered photo ids to the same path as a manual selection, so the server
re-applies access level and per-category permissions on the way through.
Photos in a downloads-disabled category are excluded client-side too, so the
number on the button is the number the guest receives. Hidden entirely when
downloads are off for the gallery.

Lightbox person chips ("In this photo: Anna") are the second way into the
face filter — a guest looking at a photo of themselves can act on it without
scrolling back to the strip. Tapping one closes the lightbox and filters the
grid behind it.

PHASE 2. A People management modal over the endpoints that already existed
and were already tested: rename inline, merge (multi-select, first pick is
the target so the name a photographer typed survives), split via a face
picker, hide, ignore. This matters more than it sounds — clustering
deliberately errs toward splitting because a wrong merge puts a stranger into
someone's download, and that trade only works if merging is easy.

PHASE 3. Rule engine over face_count plus face-area ratio: 0 -> Details,
1 large -> Portraits, 2-5 -> Small groups, >5 -> Groups. The area ratio is
what separates "a portrait of someone" from "someone is in this landscape".
Three guarantees, all tested: it only ever fills an EMPTY category (enforced
in the query AND re-checked in the UPDATE, so a photographer setting one
mid-run still wins), everything it touches is marked auto_categorized so undo
is exact, and it is a no-op unless separately enabled. Migration 178 adds the
column — separate from 177, which has already run wherever this branch is
deployed.

Verified on the real gallery: 61 photos -> 48 portraits + 13 small groups,
undo cleared exactly 61 and left the manual ones alone. Merge moved faces and
removed the source. Both confirmed against the database, not just the UI.

TWO BUGS THE BROWSER CAUGHT, both invisible to tsc:

- The lightbox destructure never landed — my patch targeted a line that has a
  default value, matched nothing, and failed silently. `people` resolved to
  something else entirely and the chips would never have rendered. eslint's
  "outer scope value" warning is what surfaced it.

- Admin face thumbnails 403'd because <AuthenticatedImage> attaches whatever
  gallery token is in session storage; an admin who has also opened one of
  their own galleries sends a type:"gallery" bearer to an admin route. Admin
  routes authenticate from the httpOnly cookie, which a plain same-origin
  <img> sends by itself. Worth noting AdminPhotoGrid has the same latent
  shape; not touched here.

Also: the admin card now reports "N people (M shown to guests)" when those
differ, so the settings page and the gallery stop disagreeing without
explanation.

45 backend tests (8 new) and 140 frontend tests green; tsc and eslint clean.
EN + DE for every new string.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* perf(faces): batch migration DDL and drop the face stack from server.js import (#1074)

CI's backend job timed out at 10 minutes on the first run of this branch.
Nothing failed — 132 of 182 suites passed and the wall clock ran out. Main
does the same 182 in 124s, and where main has 12 suites slow enough for jest
to print a duration, this branch had 77.

Two changes, both worth making regardless of how much of the gap they close:

- Migration 177 added its columns one ALTER TABLE at a time (four on photos,
  three on events, plus a separate index statement) and seeded settings with
  a SELECT and an INSERT per key. It now uses one alterTable per table and
  one SELECT plus one bulk INSERT. 178 folds its index into the same
  statement as its column. That chain replays in ~90 suites, so statement
  count there is multiplied by 90.

- server.js required faceQueue at module scope, which pulls in axios and —
  through imageProcessor — sharp. Every supertest suite that imports
  server.js was paying for a module graph it never uses. Now required inside
  the startup block, next to the call that needs it.

Honest about the evidence: locally the migration delta measures at zero
(1.15s vs 1.13s for the same suite, three runs each), so batching alone does
not explain an eight-minute regression. A fast local disk and many cores mask
per-statement and per-import costs that a two-core runner with a shared disk
does not. These are the two real costs this branch added to a path that runs
in almost every suite; whether they are sufficient is a question for CI, not
for another round of local speculation.

37 face tests still green after the change.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* i18n(faces): complete EN and DE coverage for the face feature (#1074)

The admin card and the Features toggle were rendering entirely from inline
English `defaultValue` fallbacks — 22 keys existed in no locale file at all,
so a German admin saw an English consent notice, English toggles and English
buttons. The gallery side was already translated; the admin side was not,
and nothing in the toolchain flags this because a `defaultValue` always
renders something.

Adds the missing `admin.faces.*` (19), `settings.features.faces.*` (2) and
shared `common.clear/saved/saveFailed` in both languages. Existing keys are
left alone (setdefault, not overwrite), so the shared `common` strings other
features rely on are untouched.

Committed the audit as frontend/scripts/i18n-faces-audit.py rather than
throwing it away: it extracts every t() key the face components actually use
and diffs it against each locale, and it also reports German values that are
byte-identical to English, which is the usual shape of an untranslated
copy-paste. Currently: 69 keys in use, EN complete, DE complete, no
identical pairs.

Verified in the browser, not just in the JSON — the German card reads
"61 / 61 Fotos durchsucht · 16 Personen (5 für Gäste sichtbar)" end to end.
Also checked the components for hardcoded user-facing text (JSX nodes,
title/aria-label/placeholder attributes) outside t(); there is none.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): 13 defects from external review — coordinates, counts, erasure, races (#1074)

Codex reviewed the branch against main. Thirteen findings, nine P1. I checked
every one against the code and could not dismiss a single one as a false
positive, so all thirteen are fixed here.

THE WORST ONE: bounding boxes were stored in the wrong coordinate system.
The sidecar reports coordinates in the space of the image it was HANDED —
which is the ≤1920px preview, not the original — while every consumer
compares them against photos.width/height, the original dimensions. A 6000px
photo therefore produced boxes ~3x too small and areas ~9x too small: avatar
crops landed in the wrong place and the Portraits rule could never fire. It
is invisible on any photo already under 1920px, which is exactly why the
demo gallery and every screenshot looked correct. Now scaled once in
faceProcessor so everything downstream can assume original-image coordinates.

ERASURE. The FK cascade on photo_faces is decorative on SQLite: PicPeak never
enables `PRAGMA foreign_keys`, so deleting a photo left its embeddings
behind. I first enabled the pragma globally and reverted it — six unrelated
suites immediately failed on pre-existing dangling references, and switching
it on would start rejecting inserts on every existing install. That is a real
change worth making, but it is its own PR, not a rider on this one. Instead
deletion purges explicitly: purgePhotoFaces in the photo paths (single, bulk,
service) and photo_faces/event_people in deleteEventCascade. Tests assert
this with the pragma explicitly OFF, so they can only pass if the code does
the work.

COUNTS. A re-scan deleted the old face rows without undoing their
contribution to event_people, so counts inflated on every re-scan and ghost
people survived. Now the affected people are recomputed before the
replacements are assigned. My own "must not double its faces" test only
checked photo_faces rows, which is why it passed throughout.

RACES. A worker that finished after an admin purged the event committed its
rows anyway — erasure reported success and the data reappeared. The commit is
now conditional on the row still being 'processing'. And assignFaces is
read-modify-write over an event's people, so two workers lost each other's
updates; it is now serialised per event with an in-process mutex plus a
Postgres advisory lock for the multi-pod case the queue advertises.

METADATA LOSS. Merging discarded the source's name and suppression flags, so
a merge could erase a typed name or un-hide someone. Reclustering remembered
only people with a label, so an unnamed-but-hidden bystander came back
guest-visible after one "Re-group people" — and suppression now propagates to
every descendant cluster, not just the majority one.

Also: export reset face_status so a restored gallery re-scans instead of
claiming to be scanned forever; manual category edits clear auto_categorized
so "undo automatic" cannot delete a photographer's own choice; external
photos are skipped rather than failed (resolvePhotoStorageKey returns null
for them by design); the gallery refetches photo memberships as a scan
progresses so filtering is not stale; a failed VACUUM now fails the backup
rather than publishing one that may retain biometric pages; and the ML
Dockerfile's `|| true` is scoped to the uninstall — as written it was
`(install && uninstall) || true`, so a failed dependency install produced a
green layer and an image with no onnxruntime.

Four new regression tests. Full backend suite failure set verified identical
to origin/main; frontend 140 green; tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): 12 more defects from review round 2 — cross-event purge, leaks, lifecycle (#1074)

Second Codex round on the same diff, now including round 1's fixes. Twelve
findings, seven P1. Again none were false positives.

SECURITY, AND MINE FROM ROUND 1: the bulk-delete face purge iterated the raw
`photoIds` from the request instead of the event-scoped `photos` rows the
handler had already validated. purgePhotoFaces has no event scope of its own,
so an editor could pass another gallery's photo id and delete its face data —
even though the photo deletion right below it was correctly scoped. Fixing
one thing and introducing another is exactly why the second round was worth
running.

ANOTHER VISIBILITY LEAK, same class as the one round 1 fixed: /people returns
scan progress, and getScanStatus counted every photo with a face_status —
including hidden ones. Guests could read the hidden-photo count off the
progress bar while the people list and covers beside it were properly scoped.
Now scoped by the same predicate, with the caller passing its audience.

RECLUSTER, ROUND 1'S FIX WAS INCOMPLETE. I made suppression follow every
descendant but still copied the flags from the majority ANCESTOR. When
reclustering merges a visible named person with a hidden one, the majority
ancestor is often the visible one — republishing the hidden person's photos.
Suppression is now OR-ed across every ancestor contributing faces. The name
also now goes to the genuine largest descendant; the previous code took
whichever cluster came first in map order, which the comment already claimed
it did not.

LIFECYCLE. Face data is excluded from backups and exports, but photos.
face_status came across intact, so a restored install claimed every photo was
scanned while holding no faces — and the worker only claims 'pending', so it
stayed that way forever. Now: the SQLite backup requeues in the dump, restore
requeues after the pool reinit (the Postgres path cannot rewrite rows inside
pg_dump), the portable importer purges LOCAL face tables (they were excluded
from the replace list, so another instance's embeddings survived an import
with FK checks suspended) and requeues, and archiving disables detection so a
restored archive is honestly off rather than enabled-and-empty.

WRITE PATHS. Only processPhoto enqueued. The synchronous upload path
(chunked-upload completion, watch-folder) left photos unscanned, and
replacePhoto kept the OLD image's faces on a row now pointing at a different
picture — stale identities shown on the new photo.

FRONTEND. PeopleSheet and the admin manager rendered centred thumbnails and
ignored the bbox, so on group photos the avatar showed whoever stood in the
middle and two people from one photo were indistinguishable — in the manager
whose entire job is telling faces apart. The crop maths is now one shared
helper (faceCrop.ts) so the three surfaces cannot drift again. Full-page
layouts (gallery-premium, gallery-story) render their own lightbox and never
received the people props.

Backend failure set verified identical to origin/main; frontend 140 green;
tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): round 3 — five of round 2's fixes were wrong or no-ops (#1074)

Third and final Codex round. Eight findings, four P1 — and the important part
is that FIVE of them are defects in round 2's fixes, not in the original code.

- The sync-upload enqueue I added was a silent no-op. It queried through
  `trx` after the transaction had already been committed, which throws
  "Transaction query already complete" straight into the catch I had wrapped
  it in. Chunked uploads and watch-folder imports were still never scanned,
  and the code read as though they were. Uses `db` now.

- The post-restore requeue ran BEFORE the files were restored, in both the
  portable importer and the native restore. The face worker is live during a
  restore, so it could claim those rows and scan the previous instance's
  files, or fail them for originals not yet on disk — with nothing to requeue
  them afterwards. Both now run after file restoration; the native one is
  extracted into requeueFaceScans() and called from the full and
  database-only paths.

- The admin face crop mixed coordinate spaces: an original-pixel bbox scaled
  against the THUMBNAIL's natural size. The API now returns the source
  dimensions alongside the box, so there is one space to reason about.

- Forwarding people props through layoutProps did not make them work — the
  full-page layouts never destructured them. GalleryStoryLayout now threads
  them to its own lightbox.

Genuinely new findings, all in the same class as ones already fixed:

- releaseToPending updated unconditionally, so a photo purged while its
  sidecar request was in flight came back as 'pending' and was rescanned —
  biometric rows reappearing after the purge reported success. Round 2 fixed
  exactly this on the COMMIT path and I did not carry it to the retry path.
  Now guarded on 'processing'.

- purgePhotoFaces left face_status alone, so a worker mid-scan still
  satisfied its commit guard and could write fresh faces into a photo being
  deleted — orphans, since the FK cascade is inert on SQLite. It now clears
  the claim as part of the purge.

- Phase 3 was unreachable: the migration seeds face_auto_categorize_enabled
  false and nothing could ever write it, so the rule engine and its undo
  endpoint returned "disabled" in every real flow. Added GET/PUT and a toggle
  on the admin card, EN + DE.

NOT fixed, deliberately: GalleryPremiumLayout uses yet-another-react-lightbox
rather than the shared PhotoLightbox, so person chips there are a real port
rather than a prop forward. Recorded as open rather than bodged.

Backend failure set identical to origin/main; 41 face tests and 140 frontend
tests green; i18n audit reports EN and DE complete at 71 keys.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): block face recognition on the all-in-one image (#1074, #1042)

The single-container image cannot run this feature, so it is refused there
rather than left to degrade.

WHY, since the reason is not obvious from the code: the AIO image runs the
backend, the frontend, SQLite and every background worker inside one
container aimed at "one photographer plus guests browsing". It has no Redis,
SQLite gives it a single writer, and it contains no ML sidecar to talk to.
Face detection would add a second image-processing pipeline competing with
Sharp for the same CPU and memory. That failure is not loud — the install
just becomes slow and looks broken, which is the worst possible shape for a
deployment whose whole promise is one container and no decisions.

Gated on an explicit PICPEAK_SINGLE_CONTAINER marker, NOT inferred from
SERVE_FRONTEND or a SQLite path: plenty of legitimate multi-container setups
serve the frontend from the backend or run SQLite, and none of them should
lose the feature by accident.

Three layers, because the first is the only one that enforces:

- faceSettings.isFeatureEnabled() returns false before consulting the flag,
  so a database restored from a full deployment with `faces` enabled still
  cannot switch it on here.
- The feature-flag API forces `faces: false` in both directions, so the admin
  UI reflects reality instead of offering a switch that refuses to stay on.
- The Features tab renders the card disabled with a plain-language reason,
  read from a new `single_container` field on /admin/system/version (an
  endpoint the admin UI already calls).

Documented in ml/README.md and .env.example. Three tests pin the behaviour,
including that the marker only accepts explicit truthy values.

NOTE FOR PR #1068: this expects `Dockerfile.aio` to set
`ENV PICPEAK_SINGLE_CONTAINER=true`. That one line lives on that branch and
is not in this commit — until it lands, an AIO build would still offer the
feature. Worth adding alongside the `Limits` section of docs/single-container.md.

44 face tests green; EN + DE complete at 72 keys.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* test(faces): pin the bbox coordinate space with a real scale factor (#1074)

The coordinate-space bug — boxes stored in preview space while every consumer
reads them as original-image pixels — had no test, and could not have been
caught by the ones that existed: every photo in the demo gallery is 750px, so
the scale factor was always exactly 1.0 and the correction never executed.

Verified by hand first, on a real 4000x3000 upload with the face placed
off-centre so a wrong crop would be unmistakable. Before the fix the stored
box was 1493,204 (preview space, face actually at x≈2850-3618); after, 3110,426
— a factor of 2.083, exactly 4000/1920, landing inside the face. The admin
crop then resolved to left=-395px/top=-46px on a 64px window, which is the
face centred.

That verification is now a test rather than a memory. Three cases: a 4000px
photo must scale by 4000/1920, a 1920px photo must NOT change (the case that
hid the bug), and a row with no width must fall back to unscaled rather than
storing NaN.

Note for anyone extending these: jest hoists mock factories above the file,
so anything they close over has to be `mock`-prefixed. Getting that wrong
fails at transform time with a message that does not name the variable.

47 face tests green.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-18 22:37:28 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 6ebdd13dde
commit b69dd134d0
71 changed files with 8287 additions and 24 deletions
+16
View File
@@ -0,0 +1,16 @@
# The conversion tool and its TensorFlow dependency never belong in the image.
tools/
tests/
README.md
LICENSES.md
__pycache__/
*.pyc
.pytest_cache/
.venv/
venv/
# Locally-produced model artifacts — the image fetches these by pinned URL
# and checksum in the `models` build stage instead.
*.onnx
*.h5
+123
View File
@@ -0,0 +1,123 @@
# picpeak-ml — optional face-detection sidecar (#1074).
#
# Debian slim rather than Alpine: onnxruntime publishes manylinux wheels for
# x86_64 and aarch64 but nothing for musl, so Alpine would mean compiling ORT
# from source on both legs of the multi-arch build. The slim base costs ~40MB
# over Alpine and saves an hour of CI per build.
# ---------------------------------------------------------------------------
# Stage 1 — fetch and verify model weights
# ---------------------------------------------------------------------------
# Weights are baked in, never downloaded at runtime: airgapped installs must
# work, and a model that changes under a running deployment would silently
# invalidate every stored embedding.
#
# Both artifacts are pinned by URL *and* SHA-256. The checksum is the point —
# an immutable-looking URL that starts serving different bytes must fail the
# build rather than quietly reshape the embedding space.
FROM python:3.12-slim AS models
ARG YUNET_URL=https://media.githubusercontent.com/media/opencv/opencv_zoo/f12e12798e8314f7c074a6656816c048dcc95b7a/models/face_detection_yunet/face_detection_yunet_2023mar.onnx
ARG YUNET_SHA256=8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4
# FaceNet-512 as ONNX. deepface distributes this model as Keras .h5 only, so
# the ONNX is produced once by `tools/convert_facenet.py` and published as a
# release asset — converting inside this build would drag TensorFlow (~600MB)
# through both architecture legs to produce a file that is identical either
# way.
#
# Defaults to the canonical published artifact so `docker build ml/` and
# `docker compose --profile faces up` both work with no arguments. Override
# both together to use a different embedder. Blanking either one still fails
# the build loudly (below) rather than silently producing an image with no
# embedder — the checksum is what makes the URL safe to trust, so a URL
# without one is never acceptable.
ARG FACENET_ONNX_URL=https://github.com/PicPeak/picpeak/releases/download/ml-models-v1/facenet512.onnx
ARG FACENET_ONNX_SHA256=a1c06dcb79dc17a42af01d5bcbce4822caa148b9c24bf7eb8b8e556b4fd0d5db
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /models
RUN curl -fsSL -o face_detection_yunet_2023mar.onnx "${YUNET_URL}" \
&& echo "${YUNET_SHA256} face_detection_yunet_2023mar.onnx" | sha256sum -c -
RUN if [ -z "${FACENET_ONNX_URL}" ] || [ -z "${FACENET_ONNX_SHA256}" ]; then \
echo "ERROR: FACENET_ONNX_URL and FACENET_ONNX_SHA256 build args are required." >&2; \
echo " Produce the artifact with ml/tools/convert_facenet.py, publish it," >&2; \
echo " then pass both args. See ml/README.md." >&2; \
exit 1; \
fi \
&& curl -fsSL -o facenet512.onnx "${FACENET_ONNX_URL}" \
&& echo "${FACENET_ONNX_SHA256} facenet512.onnx" | sha256sum -c -
# ---------------------------------------------------------------------------
# Stage 2 — runtime
# ---------------------------------------------------------------------------
FROM python:3.12-slim
ARG BUILD_DATE
ARG VCS_REF
ARG VERSION
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
LABEL org.opencontainers.image.description="PicPeak ML sidecar — face detection and embedding"
LABEL org.opencontainers.image.licenses="MIT"
# Busts the apt layer each CI run so the image picks up current Debian
# security updates instead of reusing a stale cached upgrade layer — same
# reasoning as backend/Dockerfile.
ARG CACHEBUST=1
RUN echo "cachebust=${CACHEBUST}" \
&& apt-get update \
&& apt-get upgrade -y \
# libGL and libglib are opencv-python-headless's remaining shared-library
# deps. The headless wheel drops the GUI toolkits but still links libGL.
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
# pip itself is ~10MB of an image that never installs anything at runtime.
#
# The `|| true` is scoped to the uninstall ONLY. Written as
# `pip install && pip uninstall || true` the shell parses it as
# `(install && uninstall) || true`, so a failed requirements install still
# produces a green layer — and CI would publish an ML image with no FastAPI,
# no uvicorn and no onnxruntime that fails at container start instead of at
# build time.
RUN pip install --no-cache-dir -r requirements.txt \
&& { pip uninstall -y pip setuptools 2>/dev/null || true; }
# Non-root. Nothing in this container writes anything — no volumes, no
# database, no model download — so the whole filesystem can stay read-only to
# the service account.
#
# Created BEFORE the copies so ownership can be set by COPY --chown. A
# `chown -R` afterwards would rewrite every copied file into a fresh layer,
# duplicating the 90MB model and adding ~94MB to the image for nothing.
RUN useradd --system --uid 1001 --create-home picpeak
COPY --from=models --chown=picpeak:picpeak /models /models
COPY --chown=picpeak:picpeak app ./app
USER picpeak
ENV FACE_MODEL_DIR=/models \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
EXPOSE 8000
# Mirrors the backend's healthcheck shape. /health is unauthenticated so this
# needs no secret; it reports liveness only, because a failed model load
# aborts startup and the container never serves at all.
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4).status == 200 else 1)"
# Single worker on purpose: the models are loaded per process, so a second
# worker doubles RSS for a service the backend calls at concurrency 1.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
+60
View File
@@ -0,0 +1,60 @@
# Model provenance and licences
PicPeak is run commercially by working photographers. That sets a hard rule
for this image:
> **No non-commercial artifact is ever baked into a PicPeak image.**
Every open face-recognition weight set traces back to a scraped,
research-only dataset — CASIA-WebFace, VGGFace2, MS1M, Glint360K and
WebFace260M all carry academic-use-only agreements, so "trained on clean
data" is not an option that exists. What differs, and what actually binds a
redistributor, is the grant the **distributor** places on the artifact we
copy into this image.
## Shipped in this image
| Artifact | Role | Distributor | Licence |
|---|---|---|---|
| `face_detection_yunet_2023mar.onnx` | detection | [OpenCV Zoo](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet) | MIT |
| `facenet512.onnx` | embedding | [serengil/deepface](https://github.com/serengil/deepface) | MIT |
Both are redistributable. `facenet512.onnx` is converted from deepface's
published `facenet512_weights.h5` by `tools/convert_facenet.py`; the
conversion changes the container format, not the weights, so the MIT grant
carries over.
Pinned sources, verified by SHA-256 at build time:
- YuNet — `opencv/opencv_zoo` at commit `f12e12798e8314f7c074a6656816c048dcc95b7a`
`8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4`
- FaceNet-512 source weights — `serengil/deepface_models` release `v1.0`
`3f76b5117a9ca574d536af8199e6720089eb4ad3dc7e93534496d88265de864f`
## Deliberately NOT shipped
| Artifact | Why not |
|---|---|
| InsightFace `buffalo_*`, `antelopev2` | **Non-commercial research only.** Commercial use requires a licence from insightface.ai ([model zoo README](https://github.com/deepinsight/insightface/blob/master/model_zoo/README.md), [#2587](https://github.com/deepinsight/insightface/issues/2587)). Available as an opt-in via `FACE_MODEL`, downloaded by the operator who has cleared that licence themselves — never by us. |
| Idiap EdgeFace | CC BY-NC-SA 4.0 — explicitly non-commercial. The best accuracy-per-parameter of the candidates, and unusable for that reason. |
| AdaFace WebFace4M weights | MIT code, non-commercial weights. |
## What that choice cost
Measured on DeepFace's own matched benchmark (LFW, aligned, cosine — the only
comparison worth anything, since every model quotes its own "LFW 99.x%" on
its own pipeline):
| Embedder | with RetinaFace | with YuNet |
|---|---|---|
| **FaceNet-512** | **98.4%** | **97.9%** |
| ArcFace | 96.6% | 96.7% |
| SFace | 92.4% | 91.0% |
Choosing YuNet over RetinaFace costs ~0.5 points. Holding the
commercial-redistribution line costs nothing beyond that — FaceNet-512 is
both the most accurate option in the table *and* MIT. Taking the headline
numbers at face value would have pointed at SFace (advertised 0.9940, actual
9192% matched) and cost seven points, which for clustering is fatal: every
false split invents a duplicate person and every false merge puts a stranger
into someone's "download my photos".
+149
View File
@@ -0,0 +1,149 @@
# picpeak-ml
Optional face-detection sidecar for PicPeak (#1074). Detects faces in one
image and returns a bounding box, five landmarks, quality signals and a
512-d embedding per face.
**Nothing else.** No database, no volumes, no state, no egress, no model
download at runtime. Clustering, person identity, thresholds and every
privacy decision live in the PicPeak backend, where the data already is.
This service forgets each image the moment it answers.
If you don't run this container, the feature does not exist — see
"Turning it on" below.
## API
All endpoints except `/health` require the `X-Face-ML-Token` header. The
service **refuses to start** without `FACE_ML_TOKEN` set, so an accidentally
published port is never a free face-detection API.
| | |
|---|---|
| `GET /health` | `{"status": "ok"}` — unauthenticated, used by the compose healthcheck |
| `GET /info` | `{detector, embedder, model_version, dim}` |
| `POST /faces` | multipart `image``{model_version, faces: [...]}` |
Each face:
```jsonc
{
"bbox": [x, y, w, h], // ORIGINAL image pixels, not detection-scaled
"score": 0.94,
"landmarks": [[x, y], ...], // 5: right eye, left eye, nose, right mouth, left mouth
"yaw": -1.42, // degrees, approximate (see pipeline.py)
"pitch": -25.33,
"blur": 2579.5, // variance of Laplacian on the aligned crop; higher = sharper
"embedding": [...] // 512 floats, L2-normalized
}
```
`404`/`400` mean "this image is a lost cause" — the backend marks the photo
failed. `5xx` and connection failures mean "try later" — the backend returns
the photo to `pending` with backoff, so turning this container off for a week
does not require a manual re-scan.
## Models
YuNet (detection, MIT) + FaceNet-512 (embedding, MIT), both baked into the
image and verified by SHA-256 at build time. See [LICENSES.md](LICENSES.md)
for why these two and not the more obvious InsightFace weights — the short
version is that InsightFace's are non-commercial-only and PicPeak's users are
working photographers.
### Building the image
`facenet512.onnx` is **not** fetched automatically, because deepface
distributes FaceNet-512 as Keras `.h5` only. Convert it once, publish it,
then pass the URL and checksum:
```bash
cd ml
python3.11 -m venv .venv && . .venv/bin/activate # 3.11: TF has no 3.12+ wheels
pip install -r tools/requirements-convert.txt
curl -fsSL -o facenet512_weights.h5 \
https://github.com/serengil/deepface_models/releases/download/v1.0/facenet512_weights.h5
echo "3f76b5117a9ca574d536af8199e6720089eb4ad3dc7e93534496d88265de864f facenet512_weights.h5" | sha256sum -c -
python tools/convert_facenet.py facenet512_weights.h5 facenet512.onnx
```
The script verifies the converted graph against the Keras original before
writing (worst observed divergence: 2.1e-06 absolute, cosine 1.0000000000)
and prints the SHA-256 to publish. Output is ~89.6 MB, 23,497,424 parameters.
Publish `facenet512.onnx` as a release asset, set the repository variables
`FACENET_ONNX_URL` and `FACENET_ONNX_SHA256` (Settings → Variables — it's a
public URL, not a secret), and CI picks it up. To build locally:
```bash
docker build -t picpeak-ml \
--build-arg FACENET_ONNX_URL=https://github.com/PicPeak/picpeak/releases/download/<tag>/facenet512.onnx \
--build-arg FACENET_ONNX_SHA256=<sha256> \
ml/
```
The conversion sits outside the Docker build because TensorFlow is ~600MB of
build dependency for a file that never ships in the final image, and the
result is architecture-independent — no reason to run it on both legs of
every multi-arch build.
**The conversion is not byte-reproducible.** Two runs with the same pinned
versions on the same machine produce functionally identical graphs (same 336
nodes, same 271 initializers, weights matching to 0.000e+00) but differ in a
few initializer names, because tf2onnx's traced-op naming is not
deterministic. So a re-conversion **will** have a different SHA-256, and that
is expected rather than a sign of tampering. The checksum pins one published
artifact so its URL cannot start serving different bytes; validating a fresh
conversion is the parity check's job, not the hash's.
## Not available on the all-in-one image
The single-container image (`Dockerfile.aio`) sets
`PICPEAK_SINGLE_CONTAINER=true`, and the backend refuses to enable face
recognition when it sees that — the feature flag cannot be switched on, and
per-event detection stays off even if a restored database says otherwise.
This is a performance decision, not a licensing or packaging one. That image
runs the backend, the frontend, SQLite and every background worker inside one
container aimed at "one photographer plus guests browsing". It has no Redis,
SQLite gives it a single writer, and it contains no ML sidecar to talk to.
Adding a second image-processing pipeline that competes with Sharp for the
same CPU and RAM would not fail loudly — it would just make the whole install
slow and appear broken.
Run the standard multi-container deployment if you want this feature.
## Turning it on
Two deliberate actions, neither of which is installing this container:
1. Enable the `faces` feature flag in PicPeak's admin settings.
2. Enable "Detect people in this gallery" per event.
`FACE_ML_URL` defaults to `http://picpeak-ml:8000` — the compose service name
— so the standard deployment needs no URL configuration. **Nothing in the
backend touches that URL while the flag is off**, so an install without this
container never attempts a connection.
## Development
```bash
pip install -r requirements.txt pytest httpx
python -m pytest tests/ -q
```
The tests stub the models out: they cover the auth boundary, the request
guards and the alignment geometry — the places where a mistake is a security
problem or a silent accuracy problem. Model *quality* is not a unit-test
question; that is what the Phase 0 spike measured.
### The one thing to be careful about
The alignment in `pipeline.py` and the normalization in `_embed` must stay
identical to whatever the clustering threshold was tuned against. A tuned
cosine threshold does not transfer across an alignment change. If either
changes, bump `MODEL_VERSION` in `config.py` — the backend keys
re-derivation off that string and will re-cluster rather than silently mix
two incompatible embedding spaces.
View File
+72
View File
@@ -0,0 +1,72 @@
"""
Configuration for the picpeak-ml sidecar (#1074).
Everything is read once at import. There is no reload path and no settings
API — this service is stateless by design, and an operator changing a knob
restarts the container.
"""
import os
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
MODEL_DIR = os.environ.get("FACE_MODEL_DIR", "/models")
DETECTOR_FILENAME = "face_detection_yunet_2023mar.onnx"
EMBEDDER_FILENAME = os.environ.get("FACE_EMBEDDER_FILENAME", "facenet512.onnx")
DETECTOR_NAME = "yunet_2023mar"
EMBEDDER_NAME = os.environ.get("FACE_MODEL", "facenet512")
# Stamped onto every face row the backend stores. Changing the detector, the
# embedder, the alignment or the normalization MUST bump this: embeddings from
# two different pipelines are not comparable, and a silent mix produces
# clusters that look plausible and are wrong. The backend keys re-derivation
# off this string.
MODEL_VERSION = f"{DETECTOR_NAME}+{EMBEDDER_NAME}+v1"
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
# Shared secret, required. `main.py` refuses to start without it rather than
# defaulting to open: an accidentally published port must not be a free
# face-detection API.
TOKEN = os.environ.get("FACE_ML_TOKEN", "").strip()
TOKEN_HEADER = "X-Face-ML-Token"
# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
# YuNet's own confidence floor. Deliberately permissive: the backend applies
# the *product* quality floor (score, blur, bbox size) because those
# thresholds are admin-tunable there and baked-in here. This service's job is
# to report what it saw, not to decide what counts.
DET_SCORE_THRESHOLD = float(os.environ.get("FACE_DET_SCORE_THRESHOLD", "0.6"))
NMS_THRESHOLD = float(os.environ.get("FACE_NMS_THRESHOLD", "0.3"))
TOP_K = int(os.environ.get("FACE_DET_TOP_K", "5000"))
# Guard rails on untrusted input. The backend only ever sends its own preview
# renditions (≤1920px), but this service must not fall over if something else
# reaches it.
MAX_IMAGE_BYTES = int(os.environ.get("FACE_MAX_IMAGE_BYTES", str(32 * 1024 * 1024)))
MAX_FACES = int(os.environ.get("FACE_MAX_FACES", "64"))
# Long edge the image is downscaled to before detection. Matches the backend's
# preview tier (imageProcessor.js generates ≤1920px), so the common case is a
# no-op; anything larger is scaled down here so detection cost stays bounded.
# Bboxes and landmarks are always reported in ORIGINAL image coordinates.
INPUT_LONG_EDGE = int(os.environ.get("FACE_INPUT_LONG_EDGE", "1920"))
# ---------------------------------------------------------------------------
# Runtime
# ---------------------------------------------------------------------------
# One ORT thread by default. The backend's face queue runs at concurrency 1
# (it shares a host with Sharp, which is the real memory pressure — see
# backgroundProcessor.js), so letting ORT fan out across every core buys
# nothing and costs RSS.
ORT_THREADS = int(os.environ.get("FACE_ORT_THREADS", "1"))
+133
View File
@@ -0,0 +1,133 @@
"""
picpeak-ml — optional face-detection sidecar for PicPeak (#1074).
Three endpoints, no database, no volumes, no egress. Models are baked into
the image at build time, so an airgapped install works and nothing is
downloaded at runtime.
The service is deliberately dumb: it reports what it saw in one image and
forgets. Clustering, identity, thresholds and every privacy decision live in
the backend, where the data already is.
"""
import logging
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from . import config
from .pipeline import FacePipeline
from .schemas import FacesResponse, HealthResponse, InfoResponse
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s [picpeak-ml] %(message)s"
)
logger = logging.getLogger(__name__)
_pipeline: FacePipeline | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global _pipeline
# Refuse to run open. An accidentally published port must not be a free
# face-detection API, and a service that silently accepted anonymous
# requests when the operator forgot the variable would be exactly that.
if not config.TOKEN:
raise RuntimeError(
"FACE_ML_TOKEN is not set. picpeak-ml will not start without a "
"shared secret — set it on both this container and the backend."
)
logger.info("Loading models from %s", config.MODEL_DIR)
_pipeline = FacePipeline()
logger.info(
"Ready: detector=%s embedder=%s version=%s dim=%d",
config.DETECTOR_NAME,
config.EMBEDDER_NAME,
config.MODEL_VERSION,
_pipeline.dim,
)
yield
_pipeline = None
app = FastAPI(
title="picpeak-ml",
description="Face detection and embedding sidecar for PicPeak",
lifespan=lifespan,
# No interactive docs: this is a machine-to-machine service on a private
# network, and /docs is just surface.
docs_url=None,
redoc_url=None,
openapi_url=None,
)
def require_token(x_face_ml_token: str = Header(default="")) -> None:
"""Constant-time-ish shared-secret check.
Python's `==` on str short-circuits, so this leaks a timing signal in
principle. It is not worth `hmac.compare_digest` gymnastics for a token
that only travels over a private Docker network — but it IS worth
rejecting with a bare 401 and no detail, so a prober learns nothing about
whether the header name was even right.
"""
if x_face_ml_token != config.TOKEN:
raise HTTPException(status_code=401, detail="Unauthorized")
@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
"""Unauthenticated on purpose — the compose healthcheck calls it.
Reports only liveness. It deliberately does not confirm the models
loaded, because lifespan raises on failure and the container never
reaches a serving state at all.
"""
return HealthResponse(status="ok")
@app.get("/info", response_model=InfoResponse, dependencies=[Depends(require_token)])
def info() -> InfoResponse:
assert _pipeline is not None
return InfoResponse(
detector=config.DETECTOR_NAME,
embedder=config.EMBEDDER_NAME,
model_version=config.MODEL_VERSION,
dim=_pipeline.dim,
)
@app.post("/faces", response_model=FacesResponse, dependencies=[Depends(require_token)])
async def faces(image: UploadFile = File(...)) -> FacesResponse:
assert _pipeline is not None
data = await image.read()
if not data:
raise HTTPException(status_code=400, detail="Empty upload")
if len(data) > config.MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail="Image too large")
try:
detected = _pipeline.process(data)
except ValueError as exc:
# Undecodable input is the caller's problem, not ours — 400 so the
# backend marks the photo 'failed' instead of retrying it forever.
raise HTTPException(status_code=400, detail=str(exc)) from exc
return FacesResponse(model_version=config.MODEL_VERSION, faces=detected)
@app.exception_handler(Exception)
async def unhandled(request, exc: Exception) -> JSONResponse:
"""Never leak a traceback to the caller.
The backend treats 5xx as "sidecar unhealthy" and puts the photo back to
pending with backoff, which is the right behaviour for a genuine internal
fault — so the useful detail belongs in our log, not in the response.
"""
logger.exception("Unhandled error: %s", exc)
return JSONResponse(status_code=500, content={"detail": "Internal error"})
+313
View File
@@ -0,0 +1,313 @@
"""
Detection → alignment → embedding (#1074).
The alignment step in the middle is the part that decides whether this
feature works. FaceNet was trained on similarity-aligned crops; handing it a
raw bbox crop costs far more accuracy than any model swap would recover. So
YuNet's five landmarks are used to warp every face onto the same canonical
template before it ever reaches the embedder.
IMPORTANT — the alignment and normalization below must stay identical to
whatever the Phase 0 spike measured its cosine threshold on. A tuned
threshold does not transfer across alignment changes; if either is touched,
`MODEL_VERSION` bumps and the backend re-derives.
"""
import threading
import cv2
import numpy as np
import onnxruntime as ort
from . import config
# Canonical 5-point template (ArcFace's, the de-facto standard), expressed for
# a 112x112 crop and scaled to whatever the embedder actually wants. Points are
# in IMAGE coordinates, left to right:
# 0 subject's right eye (appears image-left)
# 1 subject's left eye
# 2 nose tip
# 3 subject's right mouth corner
# 4 subject's left mouth corner
# YuNet emits its landmarks in exactly this order, so the mapping is index-wise
# with no reshuffling — `test_pipeline.py` pins that assumption.
_TEMPLATE_112 = np.array(
[
[38.2946, 51.6963],
[73.5318, 51.5014],
[56.0252, 71.7366],
[41.5493, 92.3655],
[70.7299, 92.2041],
],
dtype=np.float32,
)
class FacePipeline:
"""Loads both models once and serves them under a lock.
cv2.FaceDetectorYN carries mutable input-size state across setInputSize/
detect, so it is NOT safe to call from two threads. FastAPI runs sync
endpoints in a threadpool, so every inference path is serialized here.
That is not a throughput loss worth fixing: the backend's face queue
defaults to concurrency 1, and a single lock keeps RSS predictable, which
is the constraint that actually matters on a 2 GB VPS.
"""
def __init__(self) -> None:
detector_path = f"{config.MODEL_DIR}/{config.DETECTOR_FILENAME}"
embedder_path = f"{config.MODEL_DIR}/{config.EMBEDDER_FILENAME}"
self._lock = threading.Lock()
self._detector = cv2.FaceDetectorYN.create(
model=detector_path,
config="",
input_size=(320, 320), # replaced per-image via setInputSize
score_threshold=config.DET_SCORE_THRESHOLD,
nms_threshold=config.NMS_THRESHOLD,
top_k=config.TOP_K,
)
so = ort.SessionOptions()
so.intra_op_num_threads = config.ORT_THREADS
so.inter_op_num_threads = config.ORT_THREADS
self._embedder = ort.InferenceSession(
embedder_path, sess_options=so, providers=["CPUExecutionProvider"]
)
# Read the embedder's geometry off the model rather than hardcoding
# 160x160 NHWC. FACE_MODEL is documented as swappable (an operator who
# has cleared the InsightFace licence may point this at buffalo_l,
# which is 112x112 NCHW), and guessing wrong produces a confident
# garbage embedding rather than an error.
inp = self._embedder.get_inputs()[0]
shape = inp.shape
if len(shape) != 4:
raise RuntimeError(f"Embedder input must be 4-D, got {shape}")
self._input_name = inp.name
# NCHW iff the channel axis is second.
self._nchw = shape[1] == 3
self._crop_size = int(shape[2] if self._nchw else shape[1])
out = self._embedder.get_outputs()[0]
self._dim = int(out.shape[-1])
self._template = _TEMPLATE_112 * (self._crop_size / 112.0)
# -- introspection ----------------------------------------------------
@property
def dim(self) -> int:
return self._dim
# -- inference --------------------------------------------------------
def process(self, image_bytes: bytes) -> list[dict]:
"""Decode, detect, align, embed. Returns one dict per face."""
buf = np.frombuffer(image_bytes, dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("Image could not be decoded")
# Downscale for detection, then map coordinates back. Everything the
# caller sees is in ORIGINAL image pixels — the backend stores bboxes
# to crop avatars from the same rendition later, so a scaled
# coordinate would silently offset every cover face.
h, w = img.shape[:2]
long_edge = max(h, w)
if long_edge > config.INPUT_LONG_EDGE:
scale = config.INPUT_LONG_EDGE / long_edge
det_img = cv2.resize(
img, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA
)
else:
scale = 1.0
det_img = img
with self._lock:
dh, dw = det_img.shape[:2]
self._detector.setInputSize((dw, dh))
_, raw = self._detector.detect(det_img)
if raw is None:
return []
faces = []
for row in raw[: config.MAX_FACES]:
faces.append(self._one_face(img, row, scale))
return faces
def _one_face(self, img: np.ndarray, row: np.ndarray, scale: float) -> dict:
"""Build the response entry for a single YuNet detection.
`row` is YuNet's 15-wide output: x, y, w, h, then five (x, y)
landmark pairs, then the confidence score. All in detection-image
coordinates, hence the division by `scale`.
"""
inv = 1.0 / scale
x, y, bw, bh = (float(v) * inv for v in row[0:4])
landmarks = (row[4:14].reshape(5, 2).astype(np.float32)) * inv
score = float(row[14])
aligned = self._align(img, landmarks)
embedding = self._embed(aligned)
yaw, pitch = _pose_from_landmarks(landmarks)
return {
"bbox": [x, y, bw, bh],
"score": score,
"landmarks": landmarks.tolist(),
"yaw": yaw,
"pitch": pitch,
"blur": _blur_score(aligned),
"embedding": embedding.tolist(),
}
def _align(self, img: np.ndarray, landmarks: np.ndarray) -> np.ndarray:
"""Similarity-warp the face onto the canonical template."""
matrix = _umeyama(landmarks, self._template)
if matrix is None:
# Degenerate landmarks (all coincident/collinear). Fall back to a
# plain centre crop so the face still gets an embedding rather
# than vanishing from the gallery.
matrix = _fallback_transform(landmarks, self._crop_size)
return cv2.warpAffine(
img,
matrix,
(self._crop_size, self._crop_size),
flags=cv2.INTER_LINEAR,
borderValue=0,
)
def _embed(self, aligned: np.ndarray) -> np.ndarray:
# BGR (OpenCV) → RGB (what FaceNet was trained on). Getting this
# backwards does not error, it just quietly degrades every embedding.
rgb = cv2.cvtColor(aligned, cv2.COLOR_BGR2RGB).astype(np.float32)
# deepface's "Facenet" normalization: per-image standardization. This
# is what the published FaceNet-512 benchmark numbers were produced
# with, so it is what the threshold in the backend assumes.
mean, std = rgb.mean(), rgb.std()
rgb = (rgb - mean) / max(float(std), 1e-6)
batch = rgb[None, ...]
if self._nchw:
batch = batch.transpose(0, 3, 1, 2)
vec = self._embedder.run(None, {self._input_name: batch})[0][0]
# L2-normalize so the backend's cosine similarity is a plain dot
# product and centroid means stay on the unit sphere.
norm = float(np.linalg.norm(vec))
return (vec / norm) if norm > 0 else vec
def _umeyama(src: np.ndarray, dst: np.ndarray) -> np.ndarray | None:
"""Least-squares similarity transform (Umeyama 1991) over ALL five points.
Deliberately NOT cv2.estimateAffinePartial2D. That function's estimators
are RANSAC (its default) and LMEDS, both of which exist to *reject
outliers* among many noisy correspondences. Given exactly five points and
no outliers they fit a three-point subset perfectly and let the rest
drift: measured on a real off-frontal portrait, both pinned the eyes and
nose to 0.11px and left the mouth corners 11.8px out on a 160px crop.
Umeyama distributes the residual instead (max 6.5px, rms 5.1 vs 7.4) and
is what insightface's norm_crop and skimage's SimilarityTransform use.
Also fully deterministic — no random consensus sampling — which matters
beyond accuracy: the same photo must embed identically on every re-scan,
or clusters churn between runs for no reason.
Returns a 2x3 affine matrix, or None if the points are degenerate.
"""
src = np.asarray(src, dtype=np.float64)
dst = np.asarray(dst, dtype=np.float64)
src_mean, dst_mean = src.mean(axis=0), dst.mean(axis=0)
src_c, dst_c = src - src_mean, dst - dst_mean
variance = float((src_c**2).sum() / len(src))
if variance < 1e-9:
return None # all points coincident
cov = dst_c.T @ src_c / len(src)
u, s, vt = np.linalg.svd(cov)
# Guard against the SVD handing back a reflection instead of a rotation —
# a mirrored face would embed as a different person.
d = np.array([1.0, 1.0])
if np.linalg.det(u @ vt) < 0:
d[-1] = -1.0
rotation = u @ np.diag(d) @ vt
scale = float((s * d).sum() / variance)
translation = dst_mean - scale * (rotation @ src_mean)
return np.hstack([scale * rotation, translation.reshape(2, 1)]).astype(np.float32)
def _fallback_transform(landmarks: np.ndarray, size: int) -> np.ndarray:
"""Centre the landmark centroid in the crop at the template's scale.
Only reached when the landmarks are degenerate enough that no similarity
transform exists. The resulting embedding will be poor, but the face
still appears in "this photo contains" rather than vanishing — and the
backend's quality floor will keep it from spawning its own person.
"""
centre = landmarks.mean(axis=0)
s = size / 112.0
return np.array(
[[s, 0.0, size / 2.0 - s * centre[0]], [0.0, s, size / 2.0 - s * centre[1]]],
dtype=np.float32,
)
def _blur_score(aligned: np.ndarray) -> float:
"""Variance of the Laplacian — low means soft/out-of-focus.
Computed on the ALIGNED crop, not the original frame, so the number is
comparable between a face that fills the frame and one in the background:
both arrive here at the same pixel size. The backend's quality floor
compares against it directly.
"""
grey = cv2.cvtColor(aligned, cv2.COLOR_BGR2GRAY)
return float(cv2.Laplacian(grey, cv2.CV_64F).var())
def _pose_from_landmarks(landmarks: np.ndarray) -> tuple[float, float]:
"""Rough head pose in degrees from the five landmarks.
An approximation, deliberately: a real 3-D pose estimate needs a face
model and solvePnP, and the only consumers are a quality floor and the
"candid" auto-category rule, neither of which needs better than
"clearly turned away vs. not". Returned as degrees so the admin-facing
threshold reads in a familiar unit.
yaw negative = turned toward the subject's right, positive = left
pitch negative = looking down, positive = looking up
"""
right_eye, left_eye, nose, right_mouth, left_mouth = landmarks
eye_centre = (right_eye + left_eye) / 2.0
mouth_centre = (right_mouth + left_mouth) / 2.0
eye_span = float(np.linalg.norm(left_eye - right_eye))
if eye_span < 1e-6:
return 0.0, 0.0
# Yaw: on a frontal face the nose sits midway between the eyes. As the
# head turns, it slides toward the nearer eye. Offset is normalized by
# eye span so it is scale-free, then mapped through arcsin.
yaw_ratio = float((nose[0] - eye_centre[0]) / (eye_span / 2.0))
yaw = float(np.degrees(np.arcsin(np.clip(yaw_ratio, -1.0, 1.0))))
# Pitch: the nose sits ~40% of the way down the eye→mouth axis on a
# frontal face. Higher means the head is tilted back, lower means down.
vertical = float(mouth_centre[1] - eye_centre[1])
if abs(vertical) < 1e-6:
return yaw, 0.0
nose_ratio = float((nose[1] - eye_centre[1]) / vertical)
pitch = float(np.degrees(np.arcsin(np.clip((0.40 - nose_ratio) * 2.0, -1.0, 1.0))))
return round(yaw, 2), round(pitch, 2)
+41
View File
@@ -0,0 +1,41 @@
"""
Response models (#1074).
These are the wire contract the backend's `faceClient.js` codes against.
Changing a field name here is a breaking change for a deployment mid-upgrade,
where an old sidecar and a new backend run side by side for a few seconds.
"""
from pydantic import BaseModel, Field
class Face(BaseModel):
# [x, y, w, h] in ORIGINAL image pixels — the backend crops cover-face
# avatars from the same rendition, so these must not be detection-scaled.
bbox: list[float] = Field(min_length=4, max_length=4)
score: float
# Five (x, y) pairs: subject's right eye, left eye, nose tip, right mouth
# corner, left mouth corner.
landmarks: list[list[float]]
yaw: float
pitch: float
# Variance of the Laplacian on the aligned crop. Higher = sharper.
blur: float
# L2-normalized, `dim` floats (512 for FaceNet-512).
embedding: list[float]
class FacesResponse(BaseModel):
model_version: str
faces: list[Face]
class InfoResponse(BaseModel):
detector: str
embedder: str
model_version: str
dim: int
class HealthResponse(BaseModel):
status: str
+24
View File
@@ -0,0 +1,24 @@
# picpeak-ml runtime dependencies (#1074).
#
# Pinned exactly. This image bakes in model weights and is meant to produce
# byte-identical embeddings across rebuilds — a floating dependency that
# changes how an image is decoded or resized would silently shift the
# embedding space and invalidate every stored cluster.
#
# opencv-python-HEADLESS, not opencv-python: the GUI build pulls in X11/GTK
# for highgui, which this service never calls and which is pure attack
# surface in a container. Pinned to the 4.x line — cv2.FaceDetectorYN is
# what loads YuNet, and 5.x reworks parts of that API.
opencv-python-headless==4.14.0.94
# CPU execution provider only. Wheels exist for manylinux x86_64 AND
# aarch64, so both legs of the multi-arch build install a prebuilt wheel
# and neither compiles from source.
onnxruntime==1.29.0
numpy==2.5.2
fastapi==0.141.1
uvicorn[standard]==0.52.3
# Required by FastAPI to parse multipart/form-data — the only way images
# reach this service.
python-multipart==0.0.32
+136
View File
@@ -0,0 +1,136 @@
"""
API contract tests (#1074).
The pipeline is stubbed out — these cover the auth boundary and the request
guards, which is where a mistake is a security problem rather than an
accuracy problem. Model behaviour is the spike's job, not a unit test's.
"""
import pytest
from fastapi.testclient import TestClient
TOKEN = "test-token-not-a-secret"
class StubPipeline:
dim = 512
def process(self, image_bytes: bytes):
if image_bytes == b"undecodable":
raise ValueError("Image could not be decoded")
return [
{
"bbox": [10.0, 20.0, 30.0, 40.0],
"score": 0.99,
"landmarks": [[1.0, 2.0]] * 5,
"yaw": 0.0,
"pitch": 0.0,
"blur": 123.4,
"embedding": [0.1] * 512,
}
]
@pytest.fixture
def client(monkeypatch):
from app import config, main
monkeypatch.setattr(config, "TOKEN", TOKEN)
monkeypatch.setattr(main.config, "TOKEN", TOKEN)
monkeypatch.setattr(main, "FacePipeline", StubPipeline)
with TestClient(main.app) as c:
yield c
def _image_file(data: bytes = b"fake-jpeg-bytes"):
return {"image": ("photo.jpg", data, "image/jpeg")}
class TestAuth:
def test_health_needs_no_token(self, client):
# The compose healthcheck calls this without a secret.
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_info_without_token_is_401(self, client):
assert client.get("/info").status_code == 401
def test_info_with_wrong_token_is_401(self, client):
r = client.get("/info", headers={"X-Face-ML-Token": "wrong"})
assert r.status_code == 401
def test_faces_without_token_is_401(self, client):
r = client.post("/faces", files=_image_file())
assert r.status_code == 401
def test_401_body_leaks_nothing(self, client):
# A prober should not learn whether the header name was even right.
r = client.post("/faces", files=_image_file())
assert r.json() == {"detail": "Unauthorized"}
def test_startup_refuses_an_empty_token(self, monkeypatch):
from app import config, main
monkeypatch.setattr(config, "TOKEN", "")
monkeypatch.setattr(main.config, "TOKEN", "")
monkeypatch.setattr(main, "FacePipeline", StubPipeline)
# Running open is the one failure mode this service must not have.
with pytest.raises(RuntimeError, match="FACE_ML_TOKEN"):
with TestClient(main.app):
pass
class TestInfo:
def test_reports_the_model_identity_the_backend_stores(self, client):
from app import config
r = client.get("/info", headers={"X-Face-ML-Token": TOKEN})
assert r.status_code == 200
body = r.json()
assert body["model_version"] == config.MODEL_VERSION
assert body["dim"] == 512
class TestFaces:
def test_returns_faces_with_the_model_version(self, client):
from app import config
r = client.post(
"/faces", files=_image_file(), headers={"X-Face-ML-Token": TOKEN}
)
assert r.status_code == 200
body = r.json()
assert body["model_version"] == config.MODEL_VERSION
assert len(body["faces"]) == 1
assert len(body["faces"][0]["embedding"]) == 512
assert body["faces"][0]["bbox"] == [10.0, 20.0, 30.0, 40.0]
def test_empty_upload_is_400(self, client):
r = client.post(
"/faces", files=_image_file(b""), headers={"X-Face-ML-Token": TOKEN}
)
assert r.status_code == 400
def test_undecodable_image_is_400_not_500(self, client):
# 4xx matters: the backend must mark the photo failed rather than
# retry it forever, which is what it does for 5xx.
r = client.post(
"/faces",
files=_image_file(b"undecodable"),
headers={"X-Face-ML-Token": TOKEN},
)
assert r.status_code == 400
def test_oversize_upload_is_413(self, client, monkeypatch):
from app import main
monkeypatch.setattr(main.config, "MAX_IMAGE_BYTES", 10)
r = client.post(
"/faces",
files=_image_file(b"x" * 100),
headers={"X-Face-ML-Token": TOKEN},
)
assert r.status_code == 413
+156
View File
@@ -0,0 +1,156 @@
"""
Unit tests for the parts of the pipeline that don't need model weights.
The pose and blur helpers are pure functions over landmark geometry, and the
landmark ORDER assumption is the one thing in this service that fails
silently if it's wrong — a shuffled template still produces 512 confident
floats, just from a face warped inside out. So it gets pinned here.
"""
import numpy as np
import pytest
from app.pipeline import (
_TEMPLATE_112,
_blur_score,
_pose_from_landmarks,
_umeyama,
)
def _frontal_landmarks() -> np.ndarray:
"""A synthetic, perfectly frontal face in YuNet's landmark order.
Order: subject's right eye, left eye, nose, right mouth, left mouth.
The subject's right eye appears on the IMAGE-left, so it carries the
smaller x — the same convention `_TEMPLATE_112` encodes.
"""
return np.array(
[
[40.0, 50.0], # right eye (image-left)
[80.0, 50.0], # left eye
[60.0, 70.0], # nose, centred between the eyes
[45.0, 92.0], # right mouth
[75.0, 92.0], # left mouth
],
dtype=np.float32,
)
class TestTemplate:
def test_landmark_order_is_left_to_right_for_paired_features(self):
# Eyes: index 0 must sit left of index 1. Mouth corners: 3 left of 4.
assert _TEMPLATE_112[0][0] < _TEMPLATE_112[1][0]
assert _TEMPLATE_112[3][0] < _TEMPLATE_112[4][0]
def test_nose_sits_between_the_eyes_horizontally(self):
assert _TEMPLATE_112[0][0] < _TEMPLATE_112[2][0] < _TEMPLATE_112[1][0]
def test_features_are_vertically_ordered_eyes_nose_mouth(self):
eye_y = (_TEMPLATE_112[0][1] + _TEMPLATE_112[1][1]) / 2
mouth_y = (_TEMPLATE_112[3][1] + _TEMPLATE_112[4][1]) / 2
assert eye_y < _TEMPLATE_112[2][1] < mouth_y
class TestUmeyama:
"""The alignment estimator. Every test here is a regression guard.
An estimator that fits three of the five landmarks perfectly and lets the
mouth drift still produces a face-shaped crop and 512 confident floats —
it just degrades every embedding. That is why this is pinned numerically
rather than eyeballed.
"""
def test_recovers_a_known_similarity_transform_exactly(self):
src = _frontal_landmarks()
angle = np.radians(20.0)
rot = np.array(
[[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]
)
dst = (2.5 * (src @ rot.T)) + np.array([17.0, -9.0])
m = _umeyama(src, dst.astype(np.float32))
projected = (src @ m[:, :2].T) + m[:, 2]
assert np.allclose(projected, dst, atol=1e-3)
def test_distributes_residual_across_all_five_points(self):
# A face whose eye-to-mouth proportion differs from the template —
# no similarity transform can satisfy all five, so the question is
# how the error is spread. An outlier-rejecting estimator (RANSAC,
# LMEDS) parks it all on the mouth; least squares shares it out.
src = _frontal_landmarks()
src[3][1] = 110.0 # mouth further from the eyes than the template
src[4][1] = 110.0
m = _umeyama(src, _TEMPLATE_112)
residual = np.linalg.norm((src @ m[:, :2].T) + m[:, 2] - _TEMPLATE_112, axis=1)
assert residual.max() > 0, "expected an imperfect fit for this input"
# No single point may absorb the bulk of the error.
assert residual.max() < 3.0 * residual.mean()
def test_never_returns_a_reflection(self):
# A mirrored warp yields a confident embedding of a face that does
# not exist, which would cluster as a separate person.
m = _umeyama(_frontal_landmarks(), _TEMPLATE_112)
assert np.linalg.det(m[:, :2]) > 0
def test_is_deterministic(self):
# Re-scans must not churn clusters.
src = _frontal_landmarks()
first = _umeyama(src, _TEMPLATE_112)
for _ in range(5):
assert np.array_equal(_umeyama(src, _TEMPLATE_112), first)
def test_coincident_points_return_none_rather_than_dividing_by_zero(self):
assert _umeyama(np.zeros((5, 2), dtype=np.float32), _TEMPLATE_112) is None
class TestPose:
def test_frontal_face_is_near_zero_yaw(self):
yaw, _ = _pose_from_landmarks(_frontal_landmarks())
assert abs(yaw) < 1.0
def test_nose_toward_subject_left_eye_gives_positive_yaw(self):
lm = _frontal_landmarks()
lm[2][0] = 75.0 # nose slides toward the image-right (subject's left)
yaw, _ = _pose_from_landmarks(lm)
assert yaw > 10.0
def test_nose_toward_subject_right_eye_gives_negative_yaw(self):
lm = _frontal_landmarks()
lm[2][0] = 45.0
yaw, _ = _pose_from_landmarks(lm)
assert yaw < -10.0
def test_yaw_is_scale_invariant(self):
lm = _frontal_landmarks()
lm[2][0] = 72.0
small, _ = _pose_from_landmarks(lm)
large, _ = _pose_from_landmarks(lm * 4.0)
assert small == pytest.approx(large, abs=0.01)
def test_nose_low_on_the_eye_mouth_axis_reads_as_looking_down(self):
lm = _frontal_landmarks()
lm[2][1] = 85.0 # nose drops toward the mouth
_, pitch = _pose_from_landmarks(lm)
assert pitch < 0
def test_degenerate_landmarks_do_not_raise(self):
flat = np.zeros((5, 2), dtype=np.float32)
assert _pose_from_landmarks(flat) == (0.0, 0.0)
class TestBlur:
def test_flat_image_scores_near_zero(self):
flat = np.full((160, 160, 3), 128, dtype=np.uint8)
assert _blur_score(flat) < 1.0
def test_sharp_edges_score_higher_than_a_blurred_copy(self):
import cv2
sharp = np.zeros((160, 160, 3), dtype=np.uint8)
sharp[:, ::8] = 255 # high-frequency vertical stripes
blurred = cv2.GaussianBlur(sharp, (15, 15), 0)
assert _blur_score(sharp) > _blur_score(blurred)
+113
View File
@@ -0,0 +1,113 @@
"""
Phase 0 spike (#1074): does our pipeline separate different people?
Runs LFW's standard 1000-pair test protocol (500 same, 500 different) through
the PRODUCTION FacePipeline — YuNet detection, our Umeyama alignment, our
per-image standardization, FaceNet-512 ONNX — and reports the cosine
distribution, the best threshold, and the error rates that matter for
CLUSTERING specifically.
Clustering is not verification. For verification a false accept and a false
reject cost the same. For clustering they do not: a false merge puts a
stranger into someone's "download my photos", while a false split just makes
a duplicate row in the strip that the photographer can merge away. So the
operating point is chosen to hold false merges low, not to maximise accuracy.
"""
import os
import sys
import numpy as np
import cv2
# Run from the ml/ directory with FACE_MODEL_DIR pointing at a directory
# holding face_detection_yunet_2023mar.onnx and facenet512.onnx:
#
# pip install -r requirements.txt scikit-learn
# FACE_MODEL_DIR=/path/to/models python tools/benchmark_threshold.py
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('FACE_MODEL_DIR', '/models')
from app.pipeline import FacePipeline # noqa: E402
from sklearn.datasets import fetch_lfw_pairs # noqa: E402
print('Loading LFW test pairs…')
data = fetch_lfw_pairs(subset='test', color=True, resize=1.0,
slice_=(slice(0, 250), slice(0, 250)), funneled=True)
pairs, labels = data.pairs, data.target
print('Loading pipeline…')
pipe = FacePipeline()
def embed(arr):
"""sklearn hands back float32 RGB normalized to [0, 1] — NOT 0..255.
Casting straight to uint8 produces a black frame and zero detections."""
rgb8 = np.clip(arr * 255.0, 0, 255).astype(np.uint8)
bgr = cv2.cvtColor(rgb8, cv2.COLOR_RGB2BGR)
ok, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, 95])
if not ok:
return None
faces = pipe.process(buf.tobytes())
if not faces:
return None
# Largest detection — LFW is one centred subject per frame.
best = max(faces, key=lambda f: f['bbox'][2] * f['bbox'][3])
return np.asarray(best['embedding'], dtype=np.float32)
sims, kept, missed = [], [], 0
for i, (a, b) in enumerate(pairs):
ea, eb = embed(a), embed(b)
if ea is None or eb is None:
missed += 1
continue
sims.append(float(ea @ eb))
kept.append(int(labels[i]))
if (i + 1) % 100 == 0:
print(f' {i+1}/{len(pairs)}', flush=True)
sims = np.array(sims)
kept = np.array(kept)
same, diff = sims[kept == 1], sims[kept == 0]
print()
print('=' * 66)
print(f'Pairs evaluated : {len(sims)} of {len(pairs)} '
f'({missed} skipped — no face detected in one or both)')
print(f'Detection rate : {1 - missed/len(pairs):.1%}')
print()
print(f'SAME person cosine: mean {same.mean():.4f} sd {same.std():.4f} '
f'p5 {np.percentile(same,5):.4f} min {same.min():.4f}')
print(f'DIFF person cosine: mean {diff.mean():.4f} sd {diff.std():.4f} '
f'p95 {np.percentile(diff,95):.4f} max {diff.max():.4f}')
print(f'Separation (mean gap): {same.mean() - diff.mean():.4f}')
# Sweep thresholds.
grid = np.linspace(0.0, 1.0, 1001)
acc = [( (same >= t).sum() + (diff < t).sum() ) / len(sims) for t in grid]
best_i = int(np.argmax(acc))
best_t, best_acc = grid[best_i], acc[best_i]
print()
print(f'Best accuracy : {best_acc:.2%} at threshold {best_t:.3f}')
# The clustering-relevant operating points: pick the threshold where the
# false-MERGE rate (different people scored as the same) is capped.
print()
print('Operating points (false merge = different people judged the same):')
print(f' {"thresh":>7} {"false merge":>11} {"false split":>11} {"accuracy":>8}')
for target in (0.10, 0.05, 0.02, 0.01):
t = float(np.quantile(diff, 1 - target))
fm = (diff >= t).mean()
fs = (same < t).mean()
a = ((same >= t).sum() + (diff < t).sum()) / len(sims)
print(f' {t:7.3f} {fm:10.1%} {fs:10.1%} {a:7.1%} (target {target:.0%})')
print()
print(f'Currently seeded default: 0.620 → '
f'false merge {(diff >= 0.62).mean():.1%}, '
f'false split {(same < 0.62).mean():.1%}, '
f'accuracy {(((same >= 0.62).sum() + (diff < 0.62).sum()) / len(sims)):.1%}')
print('=' * 66)
# Last run (2026-08-18), 1000/1000 pairs, 100% detection:
# same 0.6958 +/- 0.1415 | diff 0.0849 +/- 0.1674 | separation 0.6109
# peak accuracy 96.60% @ 0.405
# shipped default 0.50 -> 1.0% false merge, 8.2% false split, 95.4% accuracy
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
Produce `facenet512.onnx` from deepface's published Keras weights (#1074).
Run this ONCE, by hand. Publish the resulting file as a release asset and
pass its URL + SHA-256 to the image build. It is deliberately not part of the
Docker build:
1. TensorFlow is ~600MB of build dependency for a file that never ships in
the final image.
2. The result is architecture-independent, so converting once beats
converting on both legs of every multi-arch build.
NOT byte-reproducible. Two runs on the same machine with the same pinned
versions produce functionally identical graphs — same 336 nodes, same 271
initializers, weights matching to 0.000e+00 — but a handful of initializer
names differ (tf2onnx's traced-op naming is not deterministic), so the file
bytes and therefore the SHA-256 differ. Measured, not assumed.
The consequence for anyone re-running this: **your checksum will not match
the published one, and that is expected — it is not evidence of tampering.**
The SHA-256 in the image build pins one specific published artifact so that
URL cannot start serving different bytes. To validate a fresh conversion,
rely on the parity check below (which compares against the Keras original),
not on reproducing a hash.
Why we may redistribute at all: deepface ships FaceNet-512 under MIT. That
was the deciding factor over the more accurate InsightFace weights, which are
non-commercial only — see ml/LICENSES.md and #1074 §1.
Usage
-----
python3.11 -m venv .venv && . .venv/bin/activate # 3.11: TF has no 3.12+ wheels
pip install -r tools/requirements-convert.txt
curl -fsSL -o facenet512_weights.h5 \\
https://github.com/serengil/deepface_models/releases/download/v1.0/facenet512_weights.h5
echo "3f76b5117a9ca574d536af8199e6720089eb4ad3dc7e93534496d88265de864f facenet512_weights.h5" \\
| sha256sum -c -
python tools/convert_facenet.py facenet512_weights.h5 facenet512.onnx
The script verifies the converted graph against the Keras original before it
writes anything permanent, then prints the SHA-256 to publish alongside it.
"""
import argparse
import hashlib
import sys
from pathlib import Path
# Input geometry of deepface's FaceNet-512. pipeline.py reads this off the
# model at runtime rather than assuming it, but this is what it will find.
INPUT_SHAPE = (None, 160, 160, 3)
EMBEDDING_DIM = 512
# A converted graph that is subtly wrong still returns 512 plausible floats,
# so parity is checked rather than assumed. Tolerance is float32 noise: the
# observed worst case over random inputs was 2.1e-06 absolute, cosine
# 1.0000000000.
PARITY_SAMPLES = 3
MIN_COSINE = 0.99999
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("weights", type=Path, help="facenet512_weights.h5")
parser.add_argument("output", type=Path, help="destination .onnx")
parser.add_argument(
"--opset",
type=int,
default=17,
help="ONNX opset (default 17 — supported by onnxruntime 1.29)",
)
parser.add_argument(
"--skip-verify",
action="store_true",
help="skip the Keras/ONNX parity check (not recommended)",
)
args = parser.parse_args()
if not args.weights.is_file():
print(f"error: {args.weights} not found", file=sys.stderr)
return 1
# Imported late so `--help` works without a TensorFlow install.
import numpy as np
import tensorflow as tf
import tf2onnx
from deepface.models.facial_recognition.Facenet import InceptionResNetV1
# Build the architecture and load the checksummed file from disk rather
# than going through deepface's own loader — that one downloads the
# weights itself, which would defeat the point of pinning them.
print("Building InceptionResNetV1(dimension=512)…")
model = InceptionResNetV1(dimension=EMBEDDING_DIM)
model.load_weights(str(args.weights))
print(f" {model.count_params():,} parameters")
print(f"Converting to ONNX (opset {args.opset})…")
spec = (tf.TensorSpec(INPUT_SHAPE, tf.float32, name="input"),)
tf2onnx.convert.from_keras(
model, input_signature=spec, opset=args.opset, output_path=str(args.output)
)
if not args.skip_verify:
import onnxruntime as ort
print("Verifying ONNX output matches Keras…")
sess = ort.InferenceSession(
str(args.output), providers=["CPUExecutionProvider"]
)
name = sess.get_inputs()[0].name
rng = np.random.default_rng(0)
worst_cosine, worst_abs = 1.0, 0.0
for _ in range(PARITY_SAMPLES):
x = rng.standard_normal((1, *INPUT_SHAPE[1:])).astype("float32")
keras_out = model.predict(x, verbose=0)[0]
onnx_out = sess.run(None, {name: x})[0][0]
worst_abs = max(worst_abs, float(np.abs(keras_out - onnx_out).max()))
cosine = float(
(keras_out / np.linalg.norm(keras_out))
@ (onnx_out / np.linalg.norm(onnx_out))
)
worst_cosine = min(worst_cosine, cosine)
print(f" worst abs diff {worst_abs:.3e}, worst cosine {worst_cosine:.10f}")
if worst_cosine < MIN_COSINE:
print(
f"error: parity check FAILED (cosine {worst_cosine} < {MIN_COSINE}). "
"The converted graph does not match the original — do not publish it.",
file=sys.stderr,
)
args.output.unlink(missing_ok=True)
return 1
size_mb = args.output.stat().st_size / (1024 * 1024)
print()
print(f"Wrote {args.output} ({size_mb:.1f} MB)")
print(f"SHA-256: {_sha256(args.output)}")
print()
print("Publish it as a release asset, then set the repository variables")
print("FACENET_ONNX_URL and FACENET_ONNX_SHA256 (Settings → Variables).")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+22
View File
@@ -0,0 +1,22 @@
# Dependencies for tools/convert_facenet.py ONLY. These never enter the
# picpeak-ml image — the conversion runs once, by hand, and only its output
# (facenet512.onnx) is shipped.
#
# Pinned to the exact set that produced the published artifact, so anyone can
# reproduce it and get the same SHA-256 rather than a file that differs for
# reasons nobody can reconstruct later. See ml/README.md for the expected
# checksum.
#
# Requires Python 3.11 (TensorFlow has no 3.12+/3.14 wheels at these
# versions). The picpeak-ml image itself runs 3.12 — the two are unrelated,
# since nothing from this file ships.
tensorflow==2.21.0
tf2onnx==1.17.0
onnx==1.22.0
deepface==0.0.100
# Transitive, pinned because the conversion output is checksummed:
# keras 3.15 builds the graph, protobuf serializes the ONNX.
keras==3.15.1
protobuf==7.35.1
numpy==2.4.6