Repository navigation
feat(cull): eye focus, eye state, smile and gaze per face - #471
Merged
Merged
Conversation
Culling could not tell a frame focused on the eyes from one focused on the nose. Face sharpness was a Laplacian variance over the whole face box on the ≤2048 px working copy, 35% of the composite face score and never a defect of its own, and eye focus was not measured at all — the commercial culling tools (Lightroom's Assisted Culling, Excire, Narrative) all treat both as separate criteria. This was outstanding item 3 of the culling plan. Measure. `lrg_imaging::focus` ports scikit-image's blur effect (Crété-Roffet 2007): re-blur a patch and see how much gradient energy survives. Being a ratio of the patch's own energies it barely moves with contrast, which is what comparing frames of a burst needs. Pinned against skimage (testdata/focus_goldens.json, make_focus_goldens.py). Where. Faces are still detected on the working copy, so YuNet, stored boxes and identities do not change. The face step then decodes the original once more, only for a photo with faces (`convert::decode_oriented`, sharing its decode and orientation code with normalisation so boxes map by one scale), and re-measures there (`faces::refine_focus`): the sharper eye in a square of half the interocular distance, and the face box. By-path the file is read again; an upload is kept until the face step. At most two such decodes at once. About 0.24 s more per photo with faces for a 24 MP JPEG in release; nothing for a photo without. Rank. Per photo `cull_eye_focus`/`cull_face_focus` are the least sharp prominent face's, absent when too small to judge, with `cull_focus_version` telling that apart from "indexed before this". Eye focus takes a share of the face term against the group; `eyes_out_of_focus` (or `face_out_of_focus` where the eyes are too small) is a label and, clearly behind a sharper sibling, a clear defect. Never for a single. All or nothing per group. Portrait weights it most, sports least, street not at all; python_parity switches it off. The explanation's "eye focus N/10 within the group" is relative to the sharpest frame: per-pixel sharpness depends on camera, ISO and magnification as much as on focus (a 6x enlargement of a sharp portrait read 0.39 on its sharp eye in the live test), while frames of one burst share all of that. Migration: /index/unprocessed re-queues cull-prepared photos with faces and no focus version, the cull pass re-runs the face step for them without writing identities, and /cull/grade warns about photos still without it. The plugin's prepare prompt no longer says such photos have no culling data. Verified: cargo fmt/clippy/test (workspace), face goldens with the real models (LRG_REQUIRE_GOLDENS=face), cull_eval (portrait fixture gains a nose-vs-eyes group: winner top-1 85.7% -> 100% overall with eye focus), and live against the running binary on by-path and multipart uploads. Docs-Reviewed: Help-Find-Similar.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HaMMvqLxjmxBq24sQiYQmn
Culling used to guess blinks from an eye-openness gradient proxy. It now
reads each prominent face with MediaPipe Face Mesh V2 (478 landmarks) and
Blendshape V2 (52 expression coefficients), the signals commercial cullers
rank on: eyes open / half closed / closed / can't tell, smile, gaze and
head turn.
- export_face_state.py converts Google's face_landmarker.task (pinned by
SHA-256, both model cards Apache-2.0) to ONNX with tf2onnx and checks
every output against TFLite; model-assets-face-state.yml publishes the
pair (~7 MB) to its own face-state-assets-v1 tag.
- lrg-ml face_state.rs: rotated 1.5x ROI along the eye line, two passes
(detector box, then landmarks), blendshapes from the 146-landmark subset,
eye state with a near-eye rule for turned heads and "unknown" when the
face is too small, too turned or not a face. Goldens pin it to MediaPipe
(landmarks within ~1 % of the eye distance, closed eyelids exact).
- Indexing analyses up to 12 prominent faces on the full-size original
(the same decode the eye focus uses), stores the readings per face and
per photo, and marks them with cull_face_state_version so photos
prepared before the model was downloaded are prepared again.
- Ranking: the model reading replaces the proxy, all or nothing per group;
new reasons eyes_closed, eyes_half_closed, looking_away and best_smile;
a laugh with closed eyes is not a blink; smile weight in the portrait
and event presets.
- The model is its own family in /v1/models/assets and /health; the plugin
asks for it before face and cull runs. Indexing and grading warn with the
fix ("Download AI models" or "Prepare now") when it is missing.
Docs-Reviewed: Help-Find-Similar.md
Docs-Reviewed: Help-Choosing-AI-Model.md
Docs-Reviewed: Help-Other-AI-Server.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvqLxjmxBq24sQiYQmn
Rust 1.99's clippy adds chunks_exact_to_as_chunks, which the format-lint-rust job (stable toolchain) turns into an error. Same pattern the BioCLIP and Chroma readers already use; no change in behaviour, the mesh output is always a whole number of points. Docs-Reviewed: Help-Cull-Photos.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HaMMvqLxjmxBq24sQiYQmn
bmachek
pushed a commit
that referenced
this pull request
Nov 4, 2026
…wnload #471 added the `face_state` family ("Eye and expression analysis", `face-state-assets-v1`) as one more `need_*` flag in the combined download, which this branch replaced with per-family `Family` entries. It becomes `Family::FaceState` here, with #471's size estimate, release tag and assets, so a family that is not published yet (the case that started this branch) is skipped and named while the others still download. - routes/assets.rs: `Family::FaceState` with id `face_state`, name "Eye and expression analysis", `FACE_STATE_APPROX_BYTES`, `state.face_state.is_cached()` and `face_state_assets`. - tests/contract.rs: the /v1/models/assets id list includes `face_state`. - Getting-Started.md: main's text (about 4.2 GB, with the eye and expression model). - Troubleshooting.md: the §3 intro names the fifth model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HaMMvtwbAwMWB9jcpdCc3T
Merged
5 of 7 tasks
bmachek
added a commit
that referenced
this pull request
Nov 4, 2026
…e, search and AI Edit (#474) * feat(cull): read eyes and gaze the way real photographs show them The face state thresholds were pinned on one portrait and on lids moved shut on the mesh. Measured on photographs (examples/face_state_probe.rs, 285 faces from Wikimedia Commons to choose by, 292 from a disjoint sample to check by, not committed), neither reading meant what the ranking took it to mean. Eyes. eyeBlink tops out low: eyes shut read 0.54-0.78 on the more closed eye and 0.41-0.72 as a mean, so the mean at 0.6 caught 6 of 22 shut or lowered pairs. The more closed eye now speaks, with thresholds where the readings are: 19 of 22, about 17 of 24 on the second sample (was about 8), and 1 of 178 open-eyed portraits over the label. Gaze. eyeLook* is the eye inside the head, so the label had it backwards: people turned 25-30 degrees looking straight at the camera read 0.85-0.99, a head turned 39 degrees with the eyes following it 0.14. It is now degrees off the lens, head and eyes together, from a head pose read off a frame of bone landmarks (within 1 degree of a full Procrustes fit on yaw and roll, 2 on pitch, at no linear algebra dependency). looking_away in portraits drops from 25-31% to 10-12% and rises from 53% to 67% in People looking away. Shut eyes abstain; their eyeLook* is noise. Squint is read at last, only on a face that is not smiling. All 52 blendshapes are stored per face, so a later signal need not decode every original again. The version check read the key's presence, so a bump would have changed nothing; its value counts now. #471 is in no release, so only development databases are analysed again. Also from the live test: shut eyes were reported as the best open eyes of their group, a single always being its own best. Limits, in the wiki: a slit overlaps eyes shut, sunglasses read closed (the relative rules hold inside a burst), a head on its side reads open. The sports and street thresholds are scaled by judgement, not measured. A hand-culled shoot still has to confirm all of it. Verified: cargo fmt/clippy/test (workspace) with face and facestate goldens on the real models, check-docs, and live against the release binary by path. Docs-Reviewed: Help-Analyze-and-Index.md Docs-Reviewed: Help-Find-Similar.md Docs-Reviewed: Credits.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HaMMvsyz1bjNzJoq8hSGXy * feat(people,search,cull,edit): ask the face state what people ask of a photo People and search can now ask what a face shows, culling names the mouth and the light on the face, and AI Edit reads the faces it is about to edit. All of it measured on photographs from Wikimedia Commons first (not committed), the same way as the eyes and gaze before it. People and search. lrg_analysis::face_expression answers eyes open, smiling (mouthSmile from 0.5) and looking at the camera (within 20 degrees), per person on GET /v1/faces/persons/<id>/photos and for everyone in the frame on /v1/search, both pages and the MCP tools. A reading the model did not make is unknown, not a no: it is counted and the fix named. A person's card shows their best portrait, no longer the sharpest blink, and "best N" takes each person's best photos. A catalog indexed before the model was on disk has rows with no readings, and a cull pass never touched them. It now brings its readings to the rows a full pass wrote, matched by box, leaving identities alone, and stamps the photo current only once they are saved. /index/unprocessed queues a face pass for readings that are out of date or rows the cull pass could not match (face_rows_behind). Checked live: indexed without the model, a person named, cull pass with it: the person keeps the name and the filters answer. Culling gains labels, which move no score until a hand-culled shoot says what they are worth: mouth_open (portrait, event), face_darker, face_hard_light, face_blown. What the measuring changed: - mouthPucker reads up to 0.99 on full lips at rest and mouthFunnel barely moves, so the mouth is jawOpen and mouthLowerDown (from 0.2: 10% of people speaking, 0.6% of portraits). - Blown is the middle channel, not the top: warm light clips red on skin that holds its detail, and that flagged 16% of faces. It is now 2.6% over 0.05. - Luminance is only ever compared with the same people in a sibling frame, since skin ranges over several stops. AI Edit: face_hard_light takes clarity and most contrast, face_blown holds the white point, face_below_surroundings holds contrast. That last reading is lowest on people with dark skin in front of something bright, correctly exposed, so it never calls a face underexposed or backlit: it can only hold contrast back, and says what it measured. A failed read reaches the user; missing models skip the rules, as the comment there explains. Left out on the evidence: keeping turned heads out of cluster formation scores the same at every limit from 60 to 30 degrees on 370 faces of 18 public figures, since the occlusion gate already holds them back, and raising that gate lowers the score. Clustering is unchanged; examples/face_cluster_eval.rs is what measured it. Verified: cargo fmt/clippy/test (workspace) with face and facestate goldens on the real models, check-docs, live against the release binary. busted has one failure, aesthetic_refine_spec.lua:76, which fails the same without this change. Not yet run inside Lightroom. Docs-Reviewed: Credits.md Docs-Reviewed: Dev-Agent-Bridge.md Docs-Reviewed: Help-Find-Similar.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HaMMvsyz1bjNzJoq8hSGXy * fix(cull): a gaze going down is not a blink The first run on a real shoot, a test catalog of 197 photos, showed what the Commons samples did not: 36 faces read closed, nearly all a child looking down at candles with the lids over the eyes. That made a false "eyes closed" and could make a reject candidate of a frame a photographer may well want. Lids down on a head lowered 10 degrees or more now read `down`. Such a face is left out of the blink reading and the frame says "looking down", never as a defect. Eyes going down count as neither open nor on the camera in the People and search filters, and cost a portrait what half-closed eyes do. Measured: 30 of 39 faces reading closed in that catalog become `down`, and all 18 of the highest, checked by eye, really do look down. 10 of 34 faces with the eyes really shut, mostly people asleep, get the note too, which costs a warning where a reject would have been, the right way round. eyeLookDown does not separate them. Faces reading closed fall from 36 to 7. The rest are mostly gazes on a barely lowered head and lids on a raised head behind glasses, which the relative rules hold back inside a burst. FACE_STATE_VERSION goes to 3, so those photos are analysed again. Also says what a guardrail did: "no clarity was added", not "left alone", for a value it had set to 0. Verified: cargo fmt/clippy/test (workspace) with face and facestate goldens on models downloaded from the published release, check-docs, and in Lightroom Classic 15.6 on the test catalog: 123 photos with faces queued again by the version, names kept. Docs-Reviewed: Help-Analyze-and-Index.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HaMMvsyz1bjNzJoq8hSGXy * fix(cull): sharp eyes overrule a soft frame; a lowered mouth abstains Found in Lightroom, on the test catalog. Ten frames of a child behind birthday candles all read `blurred`, on a frame-wide sharpness of 0.03-0.05, while the eyes read 0.54-0.60 and are visibly sharp. With every frame a defect the group had no pick and all ten were reject candidates, so deleting the candidates would have lost the whole series, next to explanations reading "noticeably blurred" and "eye focus 10/10". A frame whose eyes reach reason_eye_focus_threshold is no longer a blur defect, on the top frame's own check as well. In portrait every group now has its pick, and candidates fall from 74 to 69, the one really smeared frame still among them. Older than this PR, fixed here because these signals are what make it possible. It is one catalog, and the reading depends on magnification, so a slightly soft frame shot close can pass. Still false: frames with no measurable eyes, a sharp cat or a boy's shirt at 0.09, which default and event still reject as a whole group. The eyes cannot help there; the frame-wide threshold needs a hand-culled shoot. Also: a lowered head foreshortens the lips, so a closed mouth read open and got mouth_open. Such a face now abstains. Verified: cargo fmt/clippy/test (workspace) with goldens, cull_eval unchanged (winner top-1 7 of 7), check-docs, and re-graded live on the test catalog. Docs-Reviewed: Help-Analyze-and-Index.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HaMMvsyz1bjNzJoq8hSGXy --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Culling now judges faces by the criteria the commercial culling tools use. Lightroom's own Assisted Culling has Eye focus and Eyes open, including "can't tell". Narrative has per-face focus and eye traffic lights, Aftershoot key faces and expression, Excire eye sharpness and smile. Before this PR, face sharpness was a Laplacian variance over the whole face box on the ≤2048 px working copy, and blinks were guessed from an eye-openness gradient proxy.
This PR holds the first two of five planned packages (see Follow-ups):
Dev-Image-Culling-Implementation-Plan.md.1. Eye and face focus at full resolution
lrg-imaging/src/focus.rs): a port of scikit-image'sblur_effect(Crété-Roffet 2007). It compares a patch's gradient energy before and after a re-blur, so contrast barely moves it; that is what comparing the frames of one burst needs. A golden test pins it to scikit-image (testdata/focus_goldens.json, made bymake_focus_goldens.py).convert::decode_orientedshares its code with normalisation, so boxes map by one scale factor;faces::refine_focus). By-path, the file is read again, a raw through its embedded preview. Uploads are kept until the face step. At most two such decodes run at once.cull_focus_versiontells "too small" apart from "indexed before this existed".eyes_out_of_focus(orface_out_of_focuswhen the eyes are too small) becomes a defect only when the frame is clearly behind a sharper sibling; a single frame is never rejected for focus. All or nothing per group. Weights: portrait 0.55, default and event 0.35, sports 0.20, street off.eye focus N/10 within the group, relative to the sharpest frame, because per-pixel sharpness depends on camera, ISO and magnification as much as on focus./v1/index/unprocessedre-queues cull-prepared photos with faces but no focus version. The cull pass re-runs the face step for them, writing no identities./v1/cull/gradewarns about photos still without it. A failed full-size decode shows up in the indexwarnings.2. Eye state, smile and gaze (MediaPipe)
face_landmarker.task, pinned by URL and SHA-256. Both model cards state Apache-2.0. The uses they exclude, life-critical decisions and identifying people, do not include culling.server-rs/scripts/export_face_state.py(standalone, PEP 723) converts them with tf2onnx and checks every output against TFLite before anything is written: at most 4·10⁻⁴ px off on landmarks, 1.3·10⁻⁶ on blendshapes. Credited inCredits.md.lrg-ml/src/face_state.rs):FACE_TABLEgetsface_eye_state,face_eye_closed,face_smile,face_gaze_awayandface_yaw;cull_eye_closed(worst prominent face),cull_eye_state,cull_smile(least smiling) andcull_gaze_away, pluscull_face_state_versionandcull_face_state_model.eyes_closedreplacespossible_blinkwhen measured and is a defect from 0.6;eyes_half_closed(a label, not a reject),looking_away(portrait) andbest_smile, with smile weights portrait 0.15 and event 0.10;python_parityturns all of it off.face_statein/v1/models/assetsand in/v1/server/health(face_state_model,face_state_error); Download AI models fetches it (~7 MB);/v1/index/unprocessedre-queues cull-prepared photos with faces but no face-state version, only once the model is on disk;/v1/cull/gradewarns with the fix that applies: Prepare now when photos lack the reading, Download AI models when the model is missing;Util.requiredModelFamilies).Cost
Measured in release on a 4-core container:
examples/bench_cull_metrics.rs).Merging publishes a model release
The push to
mainrunsmodel-assets-face-state.yml, since its paths include the new export script. It publishesface-state-assets-v1:face_mesh_v2.onnxandface_blendshapes_v2.onnx, about 7 MB, Apache-2.0. Nothing was dispatched from this branch.Please check that run after merging. Until the release exists, Download AI models fails on every installation that is missing the eye and expression model.
download_release_assetsresolves every missing file before it fetches any, so the other families are blocked too. Culling itself keeps working on the old proxy and says so in its warning.PR CI does not require the
facestategoldens yet, since there is no release to fetch them from. The nightlygolden-tests-full.ymlfetches them from that release.Test plan
cargo fmt --check,cargo clippy --workspace --all-targets(no warnings),cargo test --workspaceLRG_REQUIRE_GOLDENS=face,auraface,facestate, face-state models exported locally):focus_goldensagainst scikit-image: synthetic cases exact to 1e-9; the astronaut's eyes and face to 1e-2 across JPEG decoders./index/unprocessedre-queue rules, for focus and for face state only with the model on disk;face_stateis its own downloadable family;/cull/gradenames a real blink, and its metrics, reasons, explanation and warnings.cull_eval: winner top-1 100 %, NDCG 1.000, reject recall 100 %, reject precision 85.7 %. The one false reject predates this PR, in the event fixture, where focus is not active.curland the access token:eyes_out_of_focusand a reject, the blurred oneblurred. No false blinks, andbest_smilewent to the right frame.busted614 successes / 0 failures,luacheck0 warnings, StyLua clean.cull_eval.Docs
Help-Cull-Photos.md: new sections Focus on the eyes and Eyes open, smile and gaze; reason codes and fields.Help-People-Faces.md,Help-Analyze-and-Index.md,Troubleshooting.md.Getting-Started.md: the download size is now 4.2 GB. The old 3.3 GB had not counted the quality model since AestheticSigLIP score + genre at index time, and a trimmed set of catalog fields (part 2 of 4) #425.Dev-Backend-API.md: health, model families,/index/unprocessed, the index face fields,/cull/grademetrics and warnings, ranking.Credits.md(MediaPipe).server-rs/README.md(new Face state models section) andplugin/README.md, with the regenerated wiki copies.Dev-Image-Culling-Implementation-Plan.md(items 1 and 3 done) andDev-Image-Culling-Signal-Analysis.md.CLAUDE.md,AGENTS.mdandCONTRIBUTING.md(golden familyfacestate, thelrg-mlline), anddoc-sources.toml.Reviewed and still accurate (
Docs-Reviewedtrailers):Help-Find-Similar.md,Help-Choosing-AI-Model.md,Help-Other-AI-Server.md.Follow-ups (planned packages)
🤖 Generated with Claude Code
https://claude.ai/code/session_01HaMMvqLxjmxBq24sQiYQmn