Commit Graph
1111 Commits
Author SHA1 Message Date
dtourolle 2fd7690b6f Mark a pixel the lens correction pushed off the sensor with alpha 0 in the camera-space tap
The fused shader stored black with alpha 1 for a pixel whose source
coordinate left the frame, and the merge's warp averaged it in like any
other: a dark, badly interpolated fringe along every frame's edge, visible
as a seam wherever a frame ended and, later, as the edge the border fill
continued. The display keeps its opaque black; CameraLinear stores alpha 0
and the warp weights each sample by the alpha it interpolated, dropping a
sample that has none.
2026-09-19 20:41:20 +02:00
dtourolle c5f07f9ced Give the engine an Inpainter role for the panorama border filler
MI-GAN is plain convolutions, so every rung serves it and none needs a
special form; the role exists so resolve_model and the probe's fingerprint
know the model, and so the merge job can open it through the engine rather
than tract, which takes 7.4 s a tile for it.
2026-09-19 20:41:20 +02:00
dtourolle 5c00942b84 One completeness job over a registry of repairs, and a re-index button
A library's records are never all complete at once. A face found before
its quality was kept has no quality; one found before the eye models
existed has no reading; one adopted from a peer's shard has no crop; an
image the fast detector examined on a 1024 px proxy has boxes the current
detector would not have drawn; an image the scan stat'ed has no capture
date. On the reference library that is 17,762 faces under the bare
w600k_mbf id with no quality, no reading and no dense landmarks, 4,144 of
them without a crop, beside 12,217 images the fast detector examined and
found nothing in. Every one of those gaps was its own pass — V14's
measuring pass, §17.5's eye pass, the sweep's proxy repair, the sweep's
detector upgrade — with its own work list, its own count and its own idea
of done, and adding a per-face field meant adding a pass. There was no
pass at all for the case the library is actually in: boxes and landmarks
drawn by a weaker detector on a proxy, which every later per-face pass
would have read from.

dr_ui::repairs replaces them with one job over a registry. A Repair names
one thing a record can lack — the predicate that says which images still
owe it, the input its handler needs (a header, the original, or a native
render), the handler, and what to record for an image that can never be
done. The job unions the predicates into one work list, fetches each
image once at the most any claimant asks for, renders it at most once,
and runs every handler whose predicate that image still matches, checked
again before each because a detection writes every field a per-face
handler would fill. The registry today: face-proxy, face-quality,
face-eyes, face-crop, face-detection, face-upgrade, metadata — the last
there to say that this is not a face job. Adding a field is one entry.

A repair's predicate is the only definition of its work: the count the
settings page shows, the list the job fetches and the check before its
handler run are one predicate, so the job converges. That is why the
registry is cut to what the device can do rather than listing what it
skips — an entry is a count and a set of originals to fetch — and why an
eye reading that cannot be cut is not a criterion.

The catalog side is generic to match: record_updates writes whichever
fields a FaceUpdate carries and re-marks the image so the shards export
it; faces_needing and count_needing answer a predicate the caller
supplies, replacing the measuring pass's three special cases.

Two buttons on the settings page run the job and differ in one
predicate. "Index faces" converges on coverage: has anything examined
this image. "Re-index every face" converges on provenance: face-detection
claims every image with no marker under the chosen detector, in either
of its forms (FaceDetector::model_ids, so a desktop in f32 and a tablet
on the Hexagon do not re-index each other's work), and a marker saying a
weaker one looked is not that. An original over the fetch budget is left
exactly as it was under the re-index, where the sweep marks it examined:
a re-detection with nothing found would delete the faces, and "cannot
fetch" is not "no faces".
2026-09-19 18:52:13 +02:00
dtourolle 2a4ac0ed3d Carry every identity across a re-detection, by box and by embedding
record_detections replaces an image's faces and carried only the user's
confirmations onto the new ones, by box overlap above 0.5 IoU. Everything
else on the old faces was dropped: the suggestions the last grouping pass
made, and the people the user had said a face was not. On the reference
library that is 13,011 suggestions and 77 rejections beside 3,778
confirmations — a re-detection of it would have been correct by
FR-CULL-12's letter, since suggestions are derived data, and would have
handed back a People screen of strangers.

Now every old face is read before the delete — box, vector, assignment,
rejections — and matched to the new faces one-to-one, best pair first. A
pair qualifies when the boxes overlap at all and either the overlap alone
says so (IoU above 0.5, the old rule) or the embeddings do (cosine above
SAME_FACE_COSINE, 0.45, the reference library's P≈0.95 line). The
embedding route claims the box a low-resolution pass drew badly enough
that overlap alone would not; the vector is also what breaks the tie in a
group photograph, where two neighbouring faces overlap both new boxes.
Overlap is required on both routes, because the same vector elsewhere in
the frame — a mirror, a print on the wall — is not the same face and must
not take its name. Onto the matched face go the assignment as it was,
confirmed or suggested with its probability, and every rejection.

The merge's match_faces still matches by overlap alone across devices; it
is the same question and is not changed here.
2026-09-19 18:34:31 +02:00
dtourolle 46af2a0a46 Stop naming an optimisation level: on tract it means into_optimized, which aborts on yolo26n-seg
ONNX Runtime's default is already its fullest level. ort-tract maps any
level but disabled to tract's optimiser, whose slice pass divides by
zero inside the segmenter's graph — a panic across the C API and so an
abort, which is what stopped dr-ui's develop test. The app never asked
tract for that and does not start now.
2026-09-19 16:35:22 +02:00
dtourolle 95c9cffc0d Keep the embedder off the Hexagon, and let the probe example ask for a runtime
On the tablet the engine compiled arcface for the NPU: the routing
compared the form a rung wants with the form on offer, and for the
embedder both are f32, so nothing said no. A rung now says which roles
it serves at all, and the Hexagon does not serve the embedder (§7 —
its vectors must compare across devices). Tested at the routing seam.

dr-segment's onnx_probe example still named ort-tract, which is what
stopped the workspace test build.
2026-09-19 16:21:53 +02:00
dtourolle cbbe67fbd7 Let the probe's clock be its proof, not disable_cpu_ep_fallback
The strict flag refused the Hexagon over the ten quantise/dequantise
nodes at the graph's edges that QNN declines by policy, which cost
microseconds. A provider that hands real work to the CPU is slower than
the CPU floor and the timing already rejects it; the tablet measured
2.3 ms on the NPU against a 29.7 ms floor.
2026-09-19 16:13:19 +02:00
dtourolle 691af96e3e Keep the readable half of a provider's error for the settings row
ONNX Runtime's errors open with a source path and a template signature;
the first 160 characters of a CUDA failure were all signature. The
reason now starts at the first word a person can act on.
2026-09-19 16:07:19 +02:00
dtourolle 7a436e2549 Move the panorama keypoint detector onto the engine, and probe with a detector
XFeat's two exports are a Keypoints role now; the crate no longer names
tract, and the app compiles TensorRT engines for both ahead of the
first merge. The probe picks the smallest *detector* rather than the
smallest file: the tablet's first run chose the 112 KB eye classifier,
which has no int8 form, and reported the Hexagon as failed for want of
one.
2026-09-19 16:05:08 +02:00
dtourolle 76bc5652d7 Calibrate the int8 detectors on library proxies, in chunks, and measure them
The first int8 files found no faces at all, and for two reasons the
tool now guards against. The calibration set was landscape photographs
with no faces in them, so the score head's ranges had never seen the
face regime; the set is now proxies from the library itself. And ONNX
Runtime's strided and moving-average calibration modes both degrade
these graphs measurably (a quarter of the faces at eight images, none
at ninety-six), while driving the calibrator in chunks by hand gives
ranges identical to a single pass — so the tool does that, four images
at a time, and feeds quantize_static through its range cache.

Measured against f32 over 400 proxies (docs/inference.md §10.1): the
10g form finds every face above 32 px the f32 form finds; 500m and
2.5g find 96%, and what they lose sits at a median confidence of 0.52
against the 0.50 threshold. Shipped with the number on record.

The Android unpack list gains the three int8 files; without that the
tablet never saw them. D13's runtime half records the reopening.
2026-09-19 16:02:44 +02:00
dtourolle 4ed29b9d81 Add the int8 detectors for the Hexagon, calibrated on real photographs
tools/quantise-models.sh writes the QDQ form QNN's HTP backend takes
whole: opset 17, per-channel int8 weights, uint8 activations, ranges
from running the f32 graph over photographs fed exactly as the app
feeds them. The calibration is strided, four images at a time, because
every ONNX Runtime calibrator holds each image's whole set of
activations until it folds them — a gigabyte an image on the 10g
detector, and an OOM kill with no message when folded once at the end.

Release-time, never on the device (docs/inference.md §5): it needs
real photographs and a person reading the recall measurement that
gates whether each file is offered.
2026-09-19 16:02:37 +02:00
dtourolle 05508741af Start the inference engine from both apps and show its choice in Settings
The desktop names where a package may have put libonnxruntime — an
override variable, beside the executable, the package's own library
directory, the Flatpak prefix, the system library directory — and
Android points at the APK's native library directory, which is also
what Qualcomm's DSP loader must be told for the Hexagon skel. Android
starts the engine at the end of the model unpack rather than at launch,
because the probe fingerprints the model files and a first launch has
none until then.

The About panel gains an Inference row beside Graphics, re-read every
two seconds while the probe runs and engines land, and faces.model_id
carries the detector's form: an int8 detector finds a different set of
faces and is a different population (docs/inference.md §7). A
low-memory signal drops every idle session with the GPU caches.

The APK assembly bundles ONNX Runtime and the Qualcomm HTP libraries
from Maven, fetched by tools/fetch-android-runtime.sh with their
published checksums; RUNTIME_DIR=none builds the tract-only APK, which
is a slower app and not a broken one. The desktop packages carry no
runtime yet.

Two probe fixes from the first desktop run: the floor must not be
built with CPU fallback disabled, and a versioned libonnxruntime.so is
a runtime too. On the reference desktop the probe now loads ONNX
Runtime 1.30, measures 30 ms on the CPU provider, and selects TensorRT
at 1.5 ms.
2026-09-19 16:02:37 +02:00
dtourolle d15c41e699 Add dr-inference-engine and route every model session through it
One crate names the runtime, the providers and the devices; dr-face and
dr-segment ask it for a session by role. It hands ort an API table once
per process — from a libonnxruntime it dlopens when the app names a
directory holding one, otherwise from tract — so the Rust build stays
free of C on every target and a package can install the runtime as a
file (docs/inference.md §3).

Sessions live in a registry behind a Model handle that holds the bytes,
not the session: every use refreshes a timestamp and a reaper unloads
whatever sat idle past the decay. A scan that runs the detector on each
image never lets it go idle; a click in the develop view lets the
segmenter go after thirty seconds; a handle used after that reloads,
and reloads on a higher rung if a compiled engine has landed meanwhile.

The probe walks the platform's ladder by building strict sessions and
timing them against the CPU provider, caches the choice against a
fingerprint of the runtime, driver, hardware and models, and compiles
engines for the selected rung in the background, smallest model first.
Nothing in this commit turns the native path on: the apps still run on
tract until they call init with a runtime directory.
2026-09-19 16:02:37 +02:00
dtourolle caf21bea64 Name the crate dr-inference-engine 2026-09-19 16:02:37 +02:00
dtourolle 6739fdf908 Specify per-device inference backends, with the 2026-09-19 measurements
tract runs every model on one core on every platform. Measured against
ONNX Runtime's providers on the MagicPad 2 and the reference desktop:
ORT CPU alone is 3-10x, the Hexagon at int8 runs the detectors in
1-3 ms, TensorRT is ~2x the CUDA provider. NNAPI, XNNPACK, WebGPU and
CUDA int8 were tried and excluded with the numbers that excluded them.

The spec keeps the build C-free: ort::set_api takes a table from a
dlopened runtime or from ort-tract, chosen once per process. Rungs
are chosen by building a real session, cached until an input changes,
and compiled engines are built in the background after the first
frame. The embedder stays f32 everywhere; int8 detectors are a
distinct model_id and are gated on a recall measurement.
2026-09-19 16:02:37 +02:00
dtourolle 42d11d919b cargo fmt and clippy across the panorama work, and one lint master carried
The dr-face comparison is master's: a negated partial-order test on the
eye box's width, rewritten as the two conditions it meant.
2026-09-19 15:53:06 +02:00
dtourolle 67f225beba panorama.md: MI-GAN as the border filler — MIT, six operators, 7.4 s a tile
Read and measured, not built. The bare 512 generator exports at a fixed
shape and loads under tract with nothing unsupported; at f32 on the
desktop CPU it takes 7.4 s per 512×512 tile, which puts a full-resolution
fill of the fixture's border at ten minutes. The three routes that would
make it viable are recorded, with the quarter-resolution fill the cheapest
and Hexagon int8 the one the model was designed for.
2026-09-19 15:30:49 +02:00
dtourolle 39adfd4b75 Regenerate the traceability matrix and gesture book after the rebase 2026-09-19 15:24:44 +02:00
dtourolle 57ed51c1c5 Projection chips redraw the preview; auto-crop as the DNG default crop
Picking a chip stored the choice for the merge and changed nothing on
screen — the chip did not even highlight, since the selected property
was never written back. Now the pick is reflected, and the job, waiting
for its decision, takes a Preview request, draws the alignment on the
chosen surface at proxy cost and reports again; the drain puts the new
picture and its size up. Auto is the surface the field of view suggests.

Also:
The largest rectangle inside the frames' coverage is found a row at a
time — a histogram of consecutive covered rows and a stack pass per row —
so the composite is never held to be measured (FR-MRG-11). It is written
as DefaultCropOrigin/DefaultCropSize (FR-MRG-4): the file opens on the
picture, the border is still in it, and resetting the crop shows it.
rawler reports the crop as the picture, which the test checks.

FR-MRG-4 records the question raised the same day — fill the border
rather than crop it — as open: a non-generative fill through the heal,
or a generative inpainter with its licence and weights. Neither decided.
2026-09-19 15:24:20 +02:00
dtourolle 30bd276d0b Merge page: outline every frame on the preview, and let it be tall
A sweep whose frames overlap by more than half reads as one photograph,
and the page's job is to show frames. Each footprint is walked along its
border and drawn in amber where it lands, so twelve frames look like
twelve and a misplaced one is visible as such. The preview may take most
of the page's height rather than 320 px.
2026-09-19 15:24:20 +02:00
dtourolle 75d2ceb23c Provenance in the sidecar, a launch hook for the page, and where it stands
derived_from and merge are top-level sidecar fields (FR-MRG-6): one line
per source in order, and how the composite was made. A build that
predates them keeps the lines as unknown and writes them back. The job
writes the sidecar beside the composite and stages it with its own
record when the composite goes through the outbox.

DARKROOM_START_MERGE=a.CR2,b.CR2 lands on the merge page at startup with
the job running on local files, on the model of DARKROOM_START_IDENTITY,
for looking at the page where synthetic clicks do not reach it. The fetch
and the start are shared with the grid's button.

panorama.md §11 records what exists, the fixture's figures, and the six
things still open, auto-crop first.
2026-09-19 15:24:20 +02:00
dtourolle 2e9a1eb0f0 The merge job and its page: a selection to a panorama DNG, confirmed first
dr_ui::merge is the orchestration with no interface in it: decode each
frame to sensor data and build its graph as a session would (orientation,
lens profile); render each through the camera-space tap at proxy size and
detect keypoints there, so the alignment is measured in the undistorted
frame the tiles are rendered in; align; solve one gain per frame from the
proxies' overlaps; draw the aligned set in colour for the page; then wait.
Nothing is written until a Decision arrives (FR-MRG-1). The merge writes
a linear DNG through the outbox with a destination record, so the drain
puts it beside its sources on a folder library and a server alike, and
the library rescans (FR-MRG-3).

merge.slint is the page, on the import page's model: the alignment
table with a failed frame named on its row and the button held off
(FR-MRG-5), the preview, the projection choice, Stop and Back. A
"Merge to panorama" button joins the grid's selection bar at two frames.

Headless, the example produces the fixture's 22 993 x 5 980 DNG in 45 s
on the reference desktop, exposures balanced across the stop of drift.
2026-09-19 15:24:20 +02:00
dtourolle 44ea763c61 dr-gpu: the merge pass — warp, accumulate, resolve, chunk by chunk
merge.wgsl warps one camera-space tile into one output chunk — output
pixel to direction (the projection maths of dr_pano::projection, verbatim),
direction to the frame's camera, camera to source pixel, bilinear by hand
from four textureLoads because rgba32float is not filterable — and adds it
into a storage-buffer accumulator weighted by its distance from the
frame's edge. A resolve pass divides by the weights and packs sixteen-bit
samples at the sensor's scale with a coverage bit.

MergePass::merge drives it: bands of rows, chunks across a band, and for
each chunk only the frames whose footprint meets it, each rendered as the
source rectangle the chunk needs and nothing more. The working set is one
chunk, one tile and one band (FR-MRG-11); the frame textures are the
caller's to cache. Feathered, not seamed; gain a scalar per frame — the
blend quality is panorama.md §10's step 5, after the path writes a file.
2026-09-19 15:24:12 +02:00
dtourolle acab0d7abb A linear DNG in and out: the writer, and a three-sample RawImage
dr-export gains write_linear_dng — LinearRaw, DNG 1.4, u16 samples at
the sensor's scale, the body's matrices with their illuminants, the
as-shot neutral, the EXIF block an export writes — streamed strip by
strip through a closure so the composite is never held (FR-MRG-11). The
tiff crate's directory is a map, so PhotometricInterpretation is written
over what new_image set, which is the trick the S15.1 spike thought it
had to hand-roll around. The test reads the file back through rawler.

dr-decode's RawImage carries samples_per_pixel (a linear DNG is 3), the
body's profile with its calibrations mapped back to EXIF illuminant
codes, and the cleaned make and model. The GPU uploads a three-sample
image as it is, normalised by black and white like a photosite, through
a full f16 conversion — subnormals kept, because a 14-bit LSB sits at
f16's smallest normal and rounding it to zero would crush exactly the
shadows the file was written to keep.
2026-09-19 15:24:12 +02:00
dtourolle 9b6b4942cf The camera-space tap: OutputMode::CameraLinear, composed with no operations
compose_camera_linear composes the fused pass with an empty operation
list, the file's orientation as the baseline, a view rect for the tile,
and a store of rgba32float. On the GPU, render_camera_linear is the only
entry that accepts it: it fills the profile uniforms neutral — unit white
balance, identity matrix, curve off — so what lands in the texture is the
sensor's numbers after the lens warp and nothing else (FR-MRG-2). A third
bind-group layout carries the format, as the linear one does, and the
readback is generalised to any pixel width for the f32 copy.

Thirty-two bits because the composite is written back at the sensor's
scale: a 14-bit sensor has 16 384 steps to white and f16 keeps 2 048 of
them in the top octave.
2026-09-19 15:24:12 +02:00
dtourolle 54290b9540 dr-pano: a second XFeat shape for portrait frames, and a matcher that takes seconds
Twelve real frames from the fixture set now align in 4.5 s — 4.4 s of
matching, 118 ms of bundle adjustment — where the first run took 51 s and
left the first two frames out.

The matcher computes each pair's similarity matrix once, across the
cores, with a dot product written to vectorise; both nearest-neighbour
directions read it. The frames that failed were portrait: fitted into the
landscape input they used 512 of 1024 px, and their thin overlap did not
survive at half resolution. The same weights are now exported at 768×1024
as well and the detector picks the shape by aspect. The example aligns
from embedded previews and draws the set on a cylinder; on the fixture the
sweep is 152° at a fitted 47.9 mm against the EXIF's 50, RMS 1.5 px, and
the overlaps show no ghosting.
2026-09-19 15:24:12 +02:00
dtourolle 231b4a54ab dr-pano: the geometry, from features to cameras
A new crate holding the CPU half of a merge (FR-MRG-10): the grayscale
proxy with orientation, the XFeat decoder ported step for step from the
reference detectAndCompute, mutual-nearest-neighbour matching, a robust
pairwise homography with the focal length read off it, a hand-rolled
Levenberg–Marquardt bundle adjustment over every rotation and the focal,
the three output projections, and align(), which chains it all and names
the frames it could not place rather than guessing (FR-MRG-5).

Dependency-free without the xfeat feature — linalg.rs says why the dense
algebra is hand-rolled — and tested on synthetic sweeps whose answer is
known exactly. The noise test records the single-row degeneracy: one
pixel of noise is a tenth of a percent of focal, which is a uniform
stretch of the sweep, not a misalignment.
2026-09-19 15:24:12 +02:00
dtourolle 2bf0ec8dba S15.4, CPU half: XFeat runs in ~400 ms per frame on the tablet
tools/onnx-probe-on-device.sh cross-builds dr-segment's onnx_probe
without the embedded segmentation model, pushes it with a model to the
attached device and times two runs. The 768×1024 XFeat export takes
~400 ms on the reference tablet's NEON cores against ~300 ms on the
desktop, with identical output ranges — inside NFR-MRG-1's 1 s per frame.
The blend half of S15.4 waits for a chunked blend to exist.
2026-09-19 15:24:12 +02:00
dtourolle 5bf06c5030 Fixture README: the frames carry Orientation 8, not 6 2026-09-19 15:24:12 +02:00
dtourolle 44fdcbc6f7 Add the twelve-frame 6D panorama set as an LFS fixture
fixtures/pano/2025-08-05: _MG_8320 … 8331, one portrait hand-held sweep
at 50 mm with a stop of shutter drift and sky in every frame — the set
§3.11 is built against, with each of those facts named as the test it
is. fixtures/** is tracked in LFS like the models but with the opposite
default: CI's pulls exclude it, so a build never fetches 325 MB it does
not use.
2026-09-19 15:24:11 +02:00
dtourolle f9510405c3 FR-MRG-3: the composite is a RAW at the source's native scale
Camera-linear u16 samples on the first source's black-subtracted scale
with its white level, never rescaled to fill 16 bits, with its body,
matrices, illuminants and as-shot neutral carried — so the panorama is
developed afterwards as one photograph from the sensor's own numbers.
The only thing a warp cannot preserve is the colour filter array, and
the clause says so.
2026-09-19 15:24:10 +02:00
dtourolle 7e6b25b21b S15.3: the camera-space tap is uniforms, not structure — and FR-MRG-2 moves below the profile
The fused chain, as operation.rs's tests fix it, is warp → as-shot white
balance → operations → base curve → camera matrix → store. LinearWorking
stores after the matrix, so the existing linear tap carries the body's
base curve, and a composite stitched from it and developed as an
unprofiled body would render that curve twice.

FR-MRG-2 therefore stitches camera-linear RGB — after the warp, before
white balance, curve and matrix — and the composite carries the first
source's body, matrices and as-shot neutral so its own develop applies
the profile once. The composer already makes this a uniform question:
white balance, matrix and the curve flag are reserved uniforms, so the
tap is a compose entry with no operations and a render entry that fills
them neutral. panorama.md §5.1 states the shape and asks for f32 buffers.
2026-09-19 15:24:10 +02:00
dtourolle e4b6b6c935 S15.2: XFeat exports at a fixed shape and loads under tract
tools/export-xfeat.sh exports the convolutional network alone at 768×1024
grayscale, on the pattern of export-seg-model.sh: thirteen standard
operator types, no dynamic axes, the keypoint decoding left to Rust.
examples/onnx_probe loads it through the ort-over-tract backend the app
ships with nothing unsupported and runs it in ~300 ms on the desktop CPU.

The weights are Apache-2.0, read from the repository's LICENSE, with no
grant on the checkpoint — recorded in models/LICENCE.md before they land,
as FR-MRG-8 asks. The probe stays: the next model will need the same
check.
2026-09-19 15:24:10 +02:00
dtourolle 1ded5afbaa S15.1: rawler reads back a linear DNG, so that is the container
A hand-rolled 64×48 LinearRaw DNG — one IFD, 16-bit RGB, DNGVersion,
ColorMatrix1, AsShotNeutral — comes back through rawler 0.7 with cpp 3,
the samples in the order written and the matrix parsed into the camera
definition; CameraProfile::extract builds a profile from it. ImageMagick
reads the same bytes.

dr_decode::decode currently accepts the file as CFA and passes three
times the samples on, so the cpp == 3 branch is the decode work FR-MRG-3
needs, and the only decode work. panorama.md §8 records the result.
2026-09-19 15:24:10 +02:00
dtourolle c901fc1a0a Specify panorama merging: §3.11, D18, S15, and the design in panorama.md
A merge writes a new source file beside its sources (D18) rather than a
multi-source Version, which answers the schema question §7 had been holding
open for panorama, HDR merge and focus stacking together. The panorama is
undeferred as FR-MRG-1 … 11; the other two stay in §7 with their data model
decided.

FR-MRG-10 and 11 fix where the work runs — every per-pixel stage on the GPU,
the composite never held as one texture — because the output exceeds
max_texture_dimension_2d before it exceeds memory. panorama.md carries the
stage table, the chunked output driver, the model licences and the porting
sources. S15 gates all of it.

Coverage falls from 83.0% to 77.2%: thirteen requirements entered with no
code, and outstanding.md §11 says so.
2026-09-19 15:24:10 +02:00
dtourolle f79a76f2d5 Name the eye pass on the People screen
Once every image has been through the detector and only readings are
left — the state an already-indexed library is in the day the eye models
arrive — the button reads "Read eye state" rather than promising to
index, and the coverage line says what the faces are waiting for.
2026-09-19 14:24:16 +02:00
dtourolle facb44cb55 Keep the dense landmarks behind each eye reading, packed
The 106 points the eye boxes were cut from, stored beside the reading as
16-bit fixed point over the frame: 424 bytes a face, a seventh of a pixel
on a 6000-pixel frame, where f16 at the same size would have been six.
Derived data like the embedding, kept for the same reason — it cost a
fetch and a model run, and the next per-face pass should run from the
catalog. Shards carry it; a peer's shard from before it is still read.
2026-09-19 14:24:15 +02:00
dtourolle 85cc2b1dcc Trace the eye reading to FR-CULL-8a and the chip to FR-CULL-13
The register grew both clauses the same day this was built: FR-CULL-8a is
the per-face state the reading is, and FR-CULL-13 is the rule that a
signal is shown and filtered and never writes a judgement. The tags,
faces.md §17 and catalog.md now say which is which; FR-CULL-8a records
what of it is built, and that its third model is under the InsightFace
grant by the same decision as the pair.
2026-09-19 14:06:59 +02:00
dtourolle d706c12d77 Cover the eyes-open subquery with an index
The people filter was served from faces_image without touching a row;
reading the eye columns in the same subquery touched every one, and
ALTER TABLE had put those seven floats after the embedding and the crop
blob. One count took 24 seconds on the reference library, thirteen of
them system time. faces_eyes covers the subquery again: five
milliseconds.
2026-09-19 14:05:52 +02:00
dtourolle cd0ca6785f Specify eye state as a filter term, and record what was measured
FR-CULL-13, with §3.9.1's exclusion of blink detection re-read as the
exclusion of blink selection it always was: the stored fact and the chip
are built, a pass that picks the frame where everyone's eyes are open is
not. faces.md §17 has the models, the crop measurements, the four-state
rule and its floors, the native and proxy sheets read face by face, and
what remains to measure.
2026-09-19 14:05:50 +02:00
dtourolle 83f4253b6a Filter the grid to a person with their eyes open
An "Eyes open" chip beside the people chips, offered only while someone
is chosen and dropped when the last person goes, so no term narrows the
grid with nothing on the bar to say so. It compiles the rule in
dr_face::eyes into the person's face subquery — Anna, eyes open, whoever
else is blinking beside her — and drops a frame only on a closed eye that
could be read: sunglasses, eyes too small or soft to read, and faces never
read all pass, so an old library shows everything under the chip until
the measuring pass has run. A test drives the same readings through the
SQL and through the rule and requires them to agree.

The People screen badges a face "Eyes closed", "Sunglasses" or "Eyes
unclear" so the reason a frame is or is not in the grid can be read off
the face; the sweep loads the three models when they are beside the pair
and reads eyes on the indexing and measuring passes from the native
render; the coverage line counts unread faces as work to measure so an
already-indexed library keeps its Index button. The term travels with the
place.
2026-09-19 14:04:35 +02:00
dtourolle 6aae4c3eb0 Ship the three eye-state models beside the face pair
2d106det for the eye contours, OCEC for open or closed, SGC for
sunglasses — all three pinned to a batch of one by the same script as
the pair, and installed by every packager so the eyes-open filter works
out of the box. The two classifiers are MIT, code and weights; the
README records their provenance, SGC's undocumented training set, and
the hashes as fetched and as shipped.
2026-09-19 14:04:08 +02:00
dtourolle 54b543fb77 Store seven eye numbers per face rather than three
Per eye P(open), the pixels across its box and the sharpness of the
patch; and P(sunglasses). The verdict — open, closed, sunglasses,
unclear — stays a rule in dr_face::eyes so the floors can move without
re-measuring twenty thousand faces. Shards carry the same seven, and a
peer's shard from before any of them is still read.
2026-09-19 14:04:08 +02:00
dtourolle f5956707e7 Cut the eye box from a landmark contour, and refuse eyes that cannot be read
SCRFD's eye point places a face, not an eye: on turned and smiling heads
the classifier's window had the eye in a corner, and two model-free ways
of re-centring it — the darkest blob, the most contrasty window — both
lost open eyes (19 → 15 and 19 → 9 of 25). Three landmark models were
then run over the same faces; Face Mesh V2 and InsightFace's 2d106det
tied at 22 of 25 and 2d106det ships, being the cheapest by far and under
the grant the detector and embedder already carry. The eye box is the
tight bounding box of its ten lid points, cut upright from the native
render, which is what the classifier was trained on.

The larger change is that the reading now carries, per eye, the source
pixels across the box and the sharpness of the patch — because the
commonest wrong answer on the reference library was a soft eye read as
closed, and a classifier shown a smear will always say something. An eye
under either floor, or narrower than six tenths of its partner (the far
eye of a turned head, whose contour collapses), is not asked; a face with
no readable eye is a fourth state, Unreadable, that no filter drops. On
twenty native renders the one real blink is caught, the laughing faces
are closed, the profiles are judged on the near eye, and the one thing
left beyond any floor is a face with a pot held over it.
2026-09-19 14:04:08 +02:00
dtourolle b908d861e0 Keep each face's eye reading in the catalog and in its shard
Three nullable columns beside quality — P(open) for each eye and
P(sunglasses) — because the verdict is a rule with thresholds in it and a
rule belongs in code, not in rows that would have to be re-measured. NULL
is "never read": a face from before the models, or from a device without
them, and every reader treats it as unknown rather than as closed.

The measuring pass V14 built for the embedding's length is what fills
them, so the sweep's work list now also names faces with no eye reading
— but only on a device that has the models, or it would fetch every
original to do nothing to it. A peer's shard without the reading is still
adopted, unlike one without the quality: the pass finds this work by the
NULL rather than by the run marker, so adoption costs it nothing.
2026-09-19 14:04:06 +02:00
dtourolle 6b51726322 Read each face's eyes, and whether sunglasses hide them
Two MIT classifiers from the same author as the reference pipeline's
whole-body detector: OCEC answers P(open) for one 40×24 eye, SGC
P(sunglasses) for a 48×48 head. Both load in tract once their batch
dimension is pinned by tools/fix-face-model-shapes.sh, like the embedder.

The crops come through the same fitted similarity the aligned face does,
so an eye window is a constant in template units rather than a second
warp, and a tilted head yields an upright eye. Measured on 60 proxies
from the reference library: the eye window plateaus at 22×11, the S
variant beats M and L (which overfit their own domain), and for
sunglasses the aligned face beats a head framing but the higher of the
two catches 11 of 12 pairs against 9 for either alone.

The reading keeps both eyes and the sunglasses number apart, because a
wink averages to the least informative value and a lens of dark glass
draws a confident answer from the eye classifier — over a woman in
sunglasses it read the right eye 0.97 open. Sunglasses take precedence,
and a face behind them is neither open nor a blink.
2026-09-19 14:03:31 +02:00
dtourolle 2481904016 Bring outstanding.md up to the decisions of 2026-09-19
Its plugin section still asked for the contradiction to be resolved, its
render-path section still asked whether FR-DSP-2 was a requirement and
said NFR-RES-2 had no answer, and its closing section still called D12
open. Each now records what was decided and keeps the argument that was
weighed, so the document reads as the history it says it is rather than
as a plan the register has moved past.
2026-09-19 12:25:04 +02:00
dtourolle a92ae4576f Repair the references that point at sections that moved
Eight citations named §5.1, §5.2 and a §5 selector language that
requirements.md's §5 has not contained since it became a pointer at
architecture.md; two named §9 for the golden images and the benchmark
suite, which are §8; and the three pointers into architecture.md were
each one section off. All now name the section that holds the thing.

architecture.md §12's subsections are numbered 6.1–6.13, colliding with
its real §6. That numbering is what every ARCH §6.n citation in the tree
uses, so it stays, and a note at the head of §12 says so instead of
leaving the next reader to work it out.

FR-DEV-3f's open question about persisting the film stock was answered
in sidecar.rs; the clause now says so.
2026-09-19 12:25:04 +02:00
dtourolle ed4460cb9c Tag three requirements the code already meets
R5 says in its own note that zoom_resolution.rs establishes it as a
pixel equality; that file was tagged FR-DSP-5 alone. FR-DEV-19's three
sub-clauses carry eighty-three tags between them while the parent had
none; MaskLayer, which is the thing they edit, now carries it. And
NFR-R3 — a crash in decode does not take down the application, the
image is marked failed — is exactly what the decoder's panic guard and
the face sweep's unreadable mark do, tagged FR-RAW-4 and NFR-SEC-1 and
not the clause that asked for them.
2026-09-19 12:25:04 +02:00
dtourolle 7596cf9bcc State the compatibility baseline and the channels
NFR-COMPAT-1 and NFR-COMPAT-2 were instructions to write a requirement,
not requirements: "state the API level", "state the channels". Both
are now stated from what the build enforces and what exists.

The baseline is minSdk 28 / targetSdk 36 from the Android Dockerfile,
a Vulkan adapter at wgpu's default limits because compute needs storage
textures — device_from already called that the floor and is tagged for
it — with no optional feature required, since the f16 in FR-DEV-2 is a
texture format and not shader arithmetic. The reference device is the
HONOR ROD2-W09 the figures are taken on, and the second-vendor clause is
recorded as unmet rather than quietly dropped: there is no Mali or
PowerVR device, so an Android figure here is an Adreno figure.

The channels are all self-distribution — Arch package, local Flatpak,
sideloaded APK, NSIS installer — because D13's face weights rule out
every store, and the two consequences are written down: SAF stays
although a sideloaded build need not have it, and S11 becomes a
pre-publication step.
2026-09-19 12:25:04 +02:00