d706c12d77507ca927579b653b4fc79e4ffa5cb3
17
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
4f31123b0c |
Let the user choose which SCRFD finds their faces
faces.md §12.3 measured what the cheapest detector costs: the small faces in every group shot, and a dog embedded a dozen times. Which trade is right depends on the machine doing the sweep — a desktop left overnight and a tablet on a battery want different answers — so the detector is now a per-device setting, Fast / Balanced / Thorough on the settings page beside the indexing button, persisted with the rest of the settings file. A detector is half of a model id. Every face, marker, shard and calibration is keyed on faces.model_id precisely so that a model change is a new id and a re-index rather than a silent change under existing data, and a detector change is a model change: it decides which faces exist and where the landmarks that align them land. So each choice names its own pipeline. 500M keeps the bare "w600k_mbf" every existing library was written under, so an upgrade disturbs nothing; the others are qualified. Choosing one restarts coverage from zero under the new id, the sweep re-detects, confirmed names carry across by box overlap, and the sync shards are keyed by the same id so a peer on another setting neither adopts nor pollutes them. The library controller carries the id into the sync the same way it carries the cache budget, because the sync starts from places that have no settings in reach. All three shape-fixed exports ship — APK, Arch, Flatpak — since a tablet has no other way to obtain the one it was not installed with; the APK grows by twenty megabytes for the choice. |
||
|
|
8b3abdb787 |
Keep each face's quality, and never compare against a poor one
The embedder's raw output has a length, and the length is a reading of how recognisable the crop was: a blur, an occlusion or a hard profile comes out short. Normalising threw it away. A short vector sits near the middle of the sphere and matches a little of everyone, which is how one bad crop bridges two people in a grouping pass. So the length is kept — the store now holds the raw vector, re-normalised on load, with the length beside it as `faces.quality` — and a face under MIN_GALLERY_QUALITY (14) is a probe: measured against the gallery and placed where it fits, but never what another face is measured against. Two probes are never paired, and a probe is nobody's evidence for a confidence. The People screen shows the number as "Quality 17.3", dimmed below the floor. Faces indexed before this stored unit vectors and have no reading; they are admitted to the gallery, and schema V14 forgets the run marker of every image holding one so the next indexing pass measures them. A peer's unmeasured shard faces are not adopted, or a sync would write that marker back. |
||
|
|
d7b851f622 |
Build the People rail rows you can see, not the ones you cannot
`Flickable { VerticalLayout { for ... } }` instantiates every row. On the
reference library that is 14,268 row subtrees, which measures at 4.2
seconds before a pixel is drawn — and it was paid again on every action,
because every action reloads the model. Roughly half the wait between
clicking "Identity" and the screen appearing was this.
Slint's compiler has a virtualising path for a `for`; it is what makes
`std-widgets`' `ListView` cheap. It keys on the parent element's base
being *named* `ListView` and exposing the five lengths its layouting code
writes back, and a custom base is explicitly allowed. So `widgets.slint`
grows one, and `std-widgets` stays out of the file that establishes our
style. Measured on the real screen: 200,000 rail rows now render in 62ms.
Three things had to be true together, and each was silent on its own.
**The row height must be constant.** The rail hid a set-aside person with
`height: cond ? 52px : 0px`, a height that reads the model — so the
layout cannot place row N without building rows 0..N, and Slint builds
them all. The filtering moves to Rust, where the toggle was already
reloading anyway.
**The list must not be wrapped.** It carries its own stretch and preferred
size, having no natural height to offer; a Rectangle in between hands the
layout that Rectangle's constraints, which are taken from the list and
are therefore nothing.
**The panel must let it fill.** `Panel` lays its children out with
`alignment: start`, which gives each its preferred height — right for a
column of sliders, wrong for anything that scrolls. Hence `Panel.fill`.
Get any of them wrong and the rail renders empty, with no error and a
model full of people. All three were, in turn, before a headless render
of the real screen showed a blank rail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8e4befa727 |
Say what each control is, so a screen reader can use one
The whole interface carried five accessible-* declarations in 14,482 lines of markup, all of them on a single row of the colour mixer, and nothing said so. Every other control — every button, every tick-box, every chip, and the slider the develop panel builds thirty-six of for the mixer alone — reached AT-SPI and TalkBack as an unnamed rectangle. NFR-A11Y-2 is not a polish item for a user in that position; it is whether the application can be used at all. The annotations go on the shared components rather than on the screens, which is the same argument widgets.slint was written to make one layer down: a control named where it is used is a control unnamed everywhere it is used next. Twelve components now declare a role, a name and — where the control does something — the action assistive technology invokes to do it. Three screens were touched, and only where the component could not know the answer. SliderTrack is the one that mattered most and the one that could not be fixed from inside itself. It is handed four numbers and knows nothing about what they mean, so it takes a `label` and a formatted `readout` and every wrapper passes down what it was already drawing. A test asserts that every instantiation does, because a track added without one announces "slider, 0.35" and looks perfectly correct in a screenshot. It also gains increment, decrement and set-value. A slider that can only be dragged is a slider a pointer is a modifier for, which is the objection ui-navigation D-N2 makes about hover-only affordances with the argument run one step further; these three are what a screen reader drives a slider with, and they commit as well as change — one nudge is a whole gesture, so a caller that persists on `committed` must hear about it. Two decisions worth recording because the obvious alternative is wrong: `active` on Button and IconButton is deliberately not announced from the component. It says a toggle is on and says nothing about whether a control that is *off* is a toggle at all, so announcing it would report every button in the application as an unpressed toggle. The four call sites that mean a toggle say so themselves, which Slint permits because the role is inherited. SwatchSlider's row-level role is removed rather than kept. It was the one control that had a label, and now that the track underneath it has one too the two would nest — a slider inside a slider, the outer holding the value and the inner holding the actions that can change it. The row stands down and hands the same strings to the control that owns the gesture. The test reads the markup the way darkroom-android's manifest test reads its XML: there is no accessibility tree without a window, so what it defends is the failure that actually happens — a role or a name lost in a refactor, which compiles, renders identically, and is invisible to everyone not using a screen reader. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
31a3580f9d |
Extract the gesture vocabulary from the code that implements it
Every gesture the application has was documented in the comment beside the `TouchArea` that implements it. Excellent comments, and unreachable by anyone not reading the source — which is the FR-UI-4 failure in a different costume: a gesture nobody can find is a feature only its author knows about. Writing them out again in a hand-kept help page is the failure this avoids. Two descriptions of one gesture drift, and it is always the prose that drifts: the code is exercised every time somebody uses the application and the page is exercised never. A help screen confidently describing a double tap the grid stopped honouring last week is worse than no help screen — and the grid did stop honouring one, in the commit before this. So the comment beside the implementation stays the only copy, and a `GESTURE:` block beside it is scanned into two artefacts: `docs/gestures.md` for a reader, and a Rust table for the application to draw a help sheet from. Both committed, both gated, so neither can quietly stop describing the code. It lives in the traceability crate because it is the same operation on the same input — walk the tree, pull structured tags out of comments, render, fail if the committed artefact has moved. Only the vocabulary is new. It scans `ui` and `apps` alone: a gesture needs an interface to be performed on, and excluding `tools` is also what stops the scanner extracting its own worked examples as broken gestures. Fifteen gestures so far, across the library grid and the People screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d0b671d4db |
Put the grouping dials where the regrouping is
The merge probability was `dr_face`'s constant and the smallest group was a bare `< 2` in the clustering pass. Both were tuned on one library — 1,813 faces of one photographer's family — and the quantity they optimise is a property of the population, not of the model. A household at close family resemblance and two thousand strangers at a wedding want different answers, and neither of them is the reference library. The doc comment already conceded the point and pointed at `face_index --tune`; a photographer does not have a terminal. So they are `FaceSettings` now, saved per device beside the cache budgets and edited from the People screen — beside the Regroup button that applies them and the rail that shows what they did, because a value changed three screens away from its effect is one nobody can tune. Moving them is safe by construction, which is why nothing asks for confirmation: a regroup writes only the suggested half, and confirmations, names and ignores enter as anchors and come back unchanged. The smallest-group rule is applied only to groups the system invented — a group the user named or set aside survives it whatever its size, because a display preference does not overrule a judgement. **Withdrawal, without which the setting does nothing visible.** Raising the smallest group stops the pass creating small groups; it does not remove the ones a previous pass made, because those still hold their suggestions, so they are not empty, so the prune leaves them. The pass now releases every unanchored face it did not place before pruning. And a dial you cannot see the effect of is not a dial. "What would this do?" runs the same population through the clusterer without opening a transaction and reports groups, faces grouped and largest group — one row of `--tune`'s table, on the user's own library, on a worker thread. The line leads with the group count because that is the number that says which side of the right setting you are on: it climbs as fragments are gathered into people and falls as separate people start being welded, while the grouped-face count rises straight through both. The preview parks its poll timer in a slot of its own. A preview and a regroup are allowed to be in flight together, and sharing the sweep's single slot would have the second to start drop the first's timer — visible as a Regroup that finished on its worker and never said so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e40f2bfee9 |
Let go of the keyboard when a name is finished
Pressing Enter on a person's name committed it and then kept the field focused, so on a tablet the on-screen keyboard stayed up over the faces the user had pressed Enter to get back to. The name looked accepted and the screen looked stuck. `Field` grows `release-focus()`, the other half of the `take-focus()` it already had, and the Identity screen calls it from `accepted`. A function rather than a property for the reason the existing one gives: focus is an event, and bound to a property it would fight anything else that took it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c8e05831f4 |
Show the confidence, and say which curve it came from
FR-CULL-9 was read as "no fit, no number", so every library without 200 confirmed positive pairs showed "Confidence unavailable" on every suggestion — which is every library, until enough confirmations exist to fit one. The confirmations are made on this screen, ranked by the number it was withholding, so the degraded state was also the permanent one. There has always been a curve: Calibration::default is the reference implementation's fitted MBF sigmoid, which is what clustering already operates at. It is a published operating point, not an invention, and what the requirement forbids is presenting it *as though it were measured on this library*. So the percentage is shown, and the screen says once, above the grid, where the curve came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c81d6865a7 |
Stop the name field running through the buttons under it
The Identity header was two rows pinned to 32px and 36px. Neither number was big enough for what the row held: a `Field` is `Theme.touch-target` — 44px — and a `Button` is `Theme.control-height`. Slint honours a child's own height and lets it overflow the box the layout gave it, so the name field drew 44px from the top of a 32px row while the button strip began at 38px. The overlap was 6px of text box sitting on top of "Confirm all". Neither row states a height any more. The first takes the height of what is in it, and the strip takes the height of a control — read from the theme rather than from the row inside it, since `actions` sizes itself from the Flickable's viewport and measuring it back would be a binding loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
af5a13b3f7 |
Narrow the grid to several people at once, either way
"Show photos" could only ever mean one person. The two questions a photographer actually asks are "every picture of Anna or Bob" and "the pictures they are both in", and the second is not reachable by any sequence of single-person filters — no amount of switching between one person and another finds the frame they share. So the filter holds a *set* of people and a mode. `RatingFilter` was already the right home, as its own doc says: every query path threads it, so the count in the header and the cells in the grid are narrowed by the same thing, and this composes with stars, flags and the date range for free. The union is `EXISTS ... person_id IN (...)`. The intersection counts **distinct** people per image and compares against the size of the selection — one subquery rather than one per person, and it does not grow the statement with the selection. `DISTINCT` is what makes it correct: three faces of Anna in one frame must not satisfy a filter asking for Anna and Bob, and there is a test that says so. Any rather than All is the default. With one person the modes are the same filter, and adding a second to a union can only ever show more — so a user who has not noticed the toggle never ends up staring at an empty grid wondering what they broke. The toggle only appears at two people, because a control that demonstrably does nothing is a control that teaches the user to ignore it. Building the set needs no picker of its own: the Identity screen gains "And also…" beside "Show photos", offered only once the grid is already narrowed to somebody. Each person is a chip on the filter bar and each chip removes just that person, so a selection of three can be taken apart one at a time rather than only cleared wholesale. `RatingFilter` stops being `Copy`, since it now holds a `Vec`. Every query path already took it by reference; the casualties were two struct updates and one `Cell` that becomes a `RefCell`. 484 dr-ui tests pass, including the union, the intersection, that one person reads the same in both modes, and the repeated-faces trap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d6380fecc8 |
Make the People screen a place work can be done
Five faults, all on one screen, and the Slint and Rust halves of each have to land together. **Regroup froze the window.** It ran inside the Slint callback, on the UI thread. It is much faster now, but fast is not bounded — the work grows with the library, and the one thing that must not grow with the library is how long the window stops answering. It runs on a worker thread with an mpsc channel and a 250ms poll, like every other long pass in this module, and the button says what it is doing instead of the window going quiet. Cancellation is dropping the receiver. Reclustering also prunes the empty groups the previous pass left, so pressing the button twice no longer fills the rail with "Unnamed (0 faces)". **The faces were a single row running off the screen.** The comment on the layout claimed to be a wrapping row; Slint has no flow layout and a HorizontalLayout does not wrap, so a person with forty faces was a person whose faces could not be reviewed past the fifth. It is now laid out the way the library grid lays out thumbnails, with the same arithmetic: choose how many columns of roughly the requested size fit, then divide the width between them so the cells fill the row exactly and nothing overhangs. **The header did not fit a phone.** A 240px name field beside five buttons is wider than an Android screen — and worse than not fitting, a layout cannot be narrower than its children's minimums, so the row reported that oversized minimum upwards and inflated the whole screen. The faces grid is its sibling, so it would have been measured against a width that was never on the display. The header is now two rows, the actions sit in a Flickable that scrolls rather than overflowing, and the rail narrows to 132px on the compact class. **Strangers crowded out the people who matter.** Most clusters in a real library are passers-by and other people's guests. "Not interested" sets a group aside; the rail hides it and says how many are hidden, with one button to bring them back. Reversible, and never a deletion — see the catalog commit for why. **A face was a dead end.** Identifying someone and then having no way to see their photographs is a filing cabinet with no drawer handles. "Show photos" narrows the library grid to that person and leaves a chip on the filter bar saying so, which is also how it is cleared. It is a term on `RatingFilter` rather than a grid scope of its own, exactly as that struct's own doc says new narrowing terms should be — so the count and the cells are narrowed by the same thing, and it composes with the others for free. Suggested faces count, not only confirmed ones, or a freshly grouped person would show an empty grid. Crops are read from where they are now stored, falling back to cutting one out of the proxy for faces indexed before that existed. 480 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
57c0cc0d35 |
Keep the name typed into a cluster when the next one is opened
Naming a cluster and moving straight to the next is the gesture this screen
exists for, and it discarded the name every time. Two causes, both in the same
four lines.
`Field.text` is two-way bound to its `TextInput`. Binding it to `selected-name`
therefore works exactly once: the first keystroke writes through the `<=>` and
**replaces** the declarative binding, after which the field follows nothing.
Switching clusters left the previous cluster's half-typed text on screen,
attached to the new person.
And `Field` only reported `accepted`, which is Enter. A name typed and then
abandoned by clicking the next face never reached Rust at all.
So `Field` gains an `edited` callback, the screen keeps the draft with the
person it was typed for, and the draft is written when the selection moves or
the screen closes. The field is then reset from a revision counter the screen
watches.
A counter rather than `changed selected-name`, because the name is not a key:
naming six clusters "Anna" in a row never changes `selected-name`, and the field
would keep the half-typed text from the cluster before. Nor `changed
selected-person`, since accepting a namesake merge lands the user back on a
person they may already have been on.
The draft carries its `PersonId`. A reload can move the selection out from under
a half-typed name — a merge arriving through a sync, a deletion — and applying
it to whoever is selected now would rename a stranger. If the person is gone
when the draft lands, it is dropped rather than resurrecting a row the rail no
longer shows.
`None` and `Some("")` are kept distinct. A user who cleared the field means to
clear the name; a user who never touched it means to leave it alone. Collapsing
those two erases names by walking past them.
An implicit commit does not raise the namesake merge offer. That question is
about a screen the user has already left, and answering it on their behalf while
they look at the next cluster is not a question at all — the offer stays on the
explicit submit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
725f7bf77f |
Offer the merge when two people turn out to share a name
Over-clustering is the normal state of a freshly indexed library — FR-CULL-10 says so — which means one person arrives as several groups and the user names each of them the same thing. Until now that produced several people called Anna and no way to join them: `identity::merge` existed, `faces::merge_people` existed with its redirect tombstone, and `merge-into(int)` sat in identity.slint declared, never emitted and never wired. The screen had a split button and no merge. So a rename that collides now offers one. Type a name another person already carries and a strip appears under the field: "Someone else is already called Anna (14 faces). Merge them?" Offered, not performed. `people.uuid` is the identity and the name is not — the schema comment on that column is explicit that two devices naming the same cluster independently is the case it was built for — so two people sharing a name is legal, and folding them together on a keystroke would be the screen making an identity decision on the user's behalf. That is the thing this screen spends a whole button avoiding. The rename always lands first, and declining leaves it exactly as typed. There is nothing to undo because nothing was done. Details that are not arbitrary: The comparison is trimmed and case-insensitive. "anna" on a phone keyboard and "Anna" on a desktop are one intention, and an offer that appeared only when the capitalisation matched would read as a bug. An empty name collides with nothing. Every unnamed cluster renders as "Unnamed (n faces)"; if that counted as a collision the offer would appear on every cluster in a fresh library, and accepting it would fold the library into one person. The newly-named person folds into the one that already held the name, not the reverse. The older person is the one other devices have seen and the one whose confirmations are more likely to be real. Selection follows the merge, because landing on an empty screen after a successful action reads as a failure. The offer is retired when the person changes, and when a refresh finds its target gone — merged from the other side of a sync, or deleted. An offer left standing would fold whoever happens to be selected now. A merged-away person is not a namesake: `faces::people` already excludes redirects, so the offer does not reappear the instant it is accepted. Four tests over the collision rules, and the existing 467 still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
61c4547b9c |
Make the Identity Manager a peer of the library and develop
It was reachable only from the library header, which made it a side trip rather than a mode. It is now reachable from develop's header too, beside the way back, because that is the same kind of move -- leaving this photograph for somewhere else in the library -- and a screen you can only reach from one of the other two is not a peer of them. Leaving returns to whichever screen opened it, and the button says which. A back button that read "Library" while returning to develop would be lying about the one thing a back button has to be right about. The develop session is only hidden, never torn down, so returning to it costs nothing and keeps the photographer's place. The header now matches the other two screens rather than using a close cross: three screens whose headers disagree read as three applications. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
26a1eb7e28 |
Record that face detection has run, not just what it found
An image with no faces in it was indistinguishable from one that had never been looked at, so every landscape, still life and document scan in the library was re-detected on every pass, for ever. In a real library that is most of it: on the 23,527-image test library, 64 of the first 110 images indexed contain no face at all. Schema v9 adds face_index, a run marker per (image, model) carrying the face count and the proxy edge it read. Keyed on the model, so a model change puts every image back in the queue by itself. That makes a coverage figure possible, which is the thing a user actually wants to see. The audit also splits the outstanding set by whether a proxy exists, because 23,417 awaiting a proxy and 110 ready to index are different problems, and telling the user to run indexing again would not fix the first. The Identity screen gains Index faces, Stop, and the coverage line. examples/face_index.rs is the same check and sweep without a window, which is the right shape for an overnight pass. Measured on the real library in release: 3.5 images/second, 110 images and 125 faces in 30 seconds, and a second run correctly finds nothing left to do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
10b10569e2 |
Add the Identity screen
A third top-level screen beside the library and develop, because naming a cluster and pulling a stranger out of it are tasks with their own rhythm and need the whole window. The screen is designed around the clustering being wrong, which is FR-CULL-10 rather than pessimism: grouping over-merges on siblings, on parents and children, and on the same person a decade apart. So Split off sits next to Confirm all rather than behind a menu, the confirm/reject pair is on the face itself, and a group the system found is drawn differently from a person the user has vouched for. Splitting rejects before it confirms. Without that the next clustering pass suggests the face straight back and the user's correction becomes an argument they keep having. Face crops come from the proxies the grid already built, one decode per image rather than per face -- a group photograph holding six faces of one family is one JPEG. Where the calibration is not fitted the screen says confidence is unavailable instead of printing a percentage that looks measured, which is FR-CULL-9's rule at the point it becomes visible. The verdict controls use drawn icons, not tick and cross characters: ui/icons.slint exists because those render as tofu on Android. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |