krites cull¶
Description¶
Judge a shoot — analyse every frame and assign a keep / maybe / reject verdict with reasons. For each frame krites measures model-free quality signals — focus (variance of the Laplacian) and exposure (blown-highlight / crushed-shadow clipping) — and resolves a verdict through a cull profile. Hard gates reject, a soft focus floor demotes to maybe, and a clean frame is a keep. Near-duplicate bursts are then grouped (perceptual hash): the sharpest frame is kept and the rest demoted to maybe, tagged with the burst they belong to.
krites proposes; you dispose — a verdict is a label written to
.krites/verdicts.yaml, never a file operation. Originals are
never touched.
Each cull also banks an analysis cache (.krites/analysis.yaml) — the raw
per-frame signals — and, on every verdict, records the machine's proposed
ruling alongside your final one. That captured history is what a learning loop
would train on; it also makes verdicts explainable and re-culls fast.
Re-culling reuses that cache, so tuning a threshold and re-culling is near-instant on a big shoot. The exact conditions for reuse — and the ones that force a fresh pass — are in The analysis cache.
The shoot must already be registered with krites ingest.
Usage¶
The shoot is taken from --shoot, or the current working directory if
omitted.
Flags¶
| Flag | Description | Default |
|---|---|---|
--shoot |
Shoot directory to cull | the current directory |
--profile |
Assert which profile this shoot uses | (unset — no assertion) |
--reanalyze |
Recompute analysis, ignoring the cache | false |
Plus the global flags on every command, including
--output text|json.
--profile does not select a profile¶
There is only one profile, wedding-default, and --profile cannot switch to
another. It is an assertion: krites resolves the shoot's profile from the
config cascade, then checks the name you passed matches, and fails if it does
not.
Left unset — the default — no assertion is made and the resolved profile is used.
Its thresholds are the cull.profile.* keys, which cascade global-to-shoot; see
Tune a cull profile.
Examples¶
# Cull the shoot in the current directory
cd ~/shoots/smith-wedding && krites cull
# Or target it by path, with JSON output
krites cull --shoot ~/shoots/smith-wedding --output json
Text output:
Shoot "smith-wedding" — 4 frames
keep 1 maybe 0 reject 3
keep 01-sharp.jpg
reject 02-flat.jpg — out of focus (sharpness 0 below 50)
reject 03-dark.jpg — out of focus (sharpness 0 below 50); crushed shadows (100% over 30%)
reject 04-bright.jpg — out of focus (sharpness 0 below 50); blown highlights (100% over 10%)
Eye / blink signal¶
The cull engine also judges eyes open vs. closed: the
wedding-default profile carries an eye gate, and when a face provider is
configured the worst-face eye-open probability drives the verdict —
- a likely blink (below
eye_open_soft, seed0.50) demotes a frame to maybe with asubject blinking …reason; eye_open_hardis disabled by default (0), so a closed-eye frame is never auto-rejected — it may be the only record of a moment, so you dispose. Raise it to opt into auto-reject;- only faces clearing
min_face_box(seed0.10of the frame's shorter side) count, so a blinking guest in the far background doesn't demote a portrait; - within a near-duplicate burst, an open-eyed frame is kept over a sharper blink.
The face detection that feeds the eye signal is a local ONNX model (UltraFace detector + InsightFace landmarks → eye-aspect-ratio), run with no CGO. It is off by default (local-first, opt-in); enable it in config:
face:
enabled: true
library_path: /path/to/libonnxruntime.so # or .dylib on macOS
# detector_model / landmark_model are optional — when omitted, krites fetches
# the pinned models into its cache once and verifies their checksums.
# optional — the EAR→eye-open calibration (defaults are tuned to real frames):
ear:
closed: 0.12 # EAR at/below which eyes read fully closed
open: 0.26 # EAR at/above which eyes read fully open
The ONNX models are fetched + checksum-verified automatically on first use; only the ONNX Runtime library path is supplied by hand (it's platform-specific).
With it on, the cull demotes blinks to maybe — subject blinking (N% eye-open,
below 50%) — and banks min_eye_open per frame. Off, the cull runs model-free
(no faces ⇒ no eye penalty), exactly as before. The deterministic gate, signal
projection and burst ranking are pure / WASM-safe; only the native adapter is
gated behind config.
Expression signal (smile / looking at camera)¶
When the face provider is on, the same landmark pass that judges eyes also
reads two expression attributes per face — for free, no extra model:
smile (mouth curvature) and facing-camera (a head-pose proxy
from landmark symmetry — head orientation, not iris gaze). Both are
probabilities in [0,1], worst-face across the frame like eyes.
Their default value is best-of-burst ranking and capture, not rejection. The
wedding-default profile ships them verdict-neutral (smile_soft = 0,
facing_soft = 0): a solemn ceremony face or a candid glance away is normal at
a wedding, never a defect. What they do by default:
- within a near-duplicate burst, among open-eyed frames the better-smiling
frame is preferred, then the one more facing the camera — the ranking chain
is eyes-open ▸ expression ▸ aesthetic ▸ sharpness; a demotion
names the tier that decided it (
kept the smiling frame); - every frame banks
min_smile/min_facingin the analysis cache, feeding the Phase-4 learner alongside sharpness and eyes.
Opt into demote-to-maybe by raising the soft floors in the profile
(smile_soft / facing_soft), or tune the geometry anchors in config:
face:
enabled: true
smile: { closed: 0.00, open: 0.10 } # mouth-curvature → smile-probability ramp
facing: { frontal: 0.10, turned: 0.50 } # yaw-asymmetry → facing-probability ramp
The mouth/pose landmark indices and these anchors are provisional — seeded, not yet calibrated against a real-frame distribution the way the EAR anchors were (a calibration harness does that before expression is trusted). That is exactly why expression ships verdict-neutral: an uncalibrated estimator can inform ranking but cannot move a verdict.
Aesthetic signal (deferred)¶
The cull carries an aesthetic ranking tier and an aesthetic cache slot,
but no scorer backend ships yet: the whole-frame
"is this a strong photograph" model is deferred to Phase 4, where it is built as
a CLIP+aesthetic-head so the learning loop gets a reusable per-frame embedding in
the same pass. Until then the aesthetic tier is inert — the ranking chain runs
eyes ▸ expression ▸ sharpness — and aesthetic_floor stays 0 (never a hard
gate by design).
Notes & current limits¶
- The
wedding-defaultprofile is the seed ruleset; its focus/exposure/eye thresholds are tunable starting points. A configurable profile catalog (krites profile …) lands later; onlywedding-defaultis available today. - Re-culls reuse the analysis cache — tuning a threshold and re-culling
re-resolves verdicts from the cached signals, with no re-decode and no model
pass.
--reanalyzeforces a full recompute; the cache is also invalidated automatically when a signal-affecting setting changes (the face-size floor, or turning eye detection on or off). It is not invalidated by a file's modification time, so replacing an original in place after a cull needs--reanalyze. - Eye detection runs on the CPU execution provider today; the Apple-Silicon CoreML acceleration and automatic model fetch (so you don't supply the model paths by hand) are the remaining face-provider follow-ups. RAW frames decode via JPEG/PNG only for now (cull on embedded previews is later).