c38df01bf7280e39108c818c534432ddaa575891
420
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
eaafacc3fb |
Give the phone the model it had no way to obtain
Face indexing was compiled into the APK all along — dr-ui takes dr-face with `inference` on every target, so SCRFD, alignment, MBF, calibration and clustering were all in there. What was missing was the weights, and on Android there was no way to supply them. Route C (docs/faces.md §2.2) says the user obtains the model and the app loads it. On a desktop that is a real gesture: drop two files in ~/.local/share/darkroom/models/ and indexing starts working. On Android it is not a gesture at all. `internal_data_path` is app-private, `run-as` needs a debuggable build, and the in-app fetch route C specifies was never built — so the settings page reported "no face model is installed" on every launch with nothing behind the message. Not "off until you supply weights"; off. So the shape-fixed pair goes into LFS under the APK's assets, assemble-apk.sh copies it into the package, and `android_main` unpacks it to the shared models directory before anything asks whether a model is present. Three things that are not incidental: The models directory is now shared across accounts rather than per-account. Weights are identified by `faces.model_id`, not by who is signed in, so two accounts had no reason to hold two copies — and the unpack runs before any session exists to key a per-account path off. `face_models` still prefers a per-account directory when one is populated, so anyone mid-migration keeps the ability to pin one library to its own pair. The unpack writes under a temporary name and renames. `face_models` decides availability on `is_file()` alone, so a copy truncated by the process being killed would leave a file that passes that test and fails inside tract — reported to the user as a broken model rather than a missing one. assemble-apk.sh refuses an LFS pointer. At ~130 bytes it looks exactly like a model to `cp`, and unchecked it reaches the device and fails in the graph loader instead of telling someone to run `git lfs pull` — the same guard dr-segment's build script applies to yolo26n-seg.onnx. The licensing half is unchanged and recorded in §2.2a: the InsightFace grant is research-only, this is a private repository and a self-installed build, and these files come back out before anything is published. The weights are still not a cargo build input — dr-face has no `models/` directory and no `embedded-model` feature, and nothing in the build reads them. The APK assembly step copies two files and is the only thing in the tree that knows they exist. Verified on device: both models unpack on first launch (2524817 and 13616095 bytes) and the APK carries them at assets/models/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b846b312b8 |
Run the formatter over the face branch before it reaches CI
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h21m32s
Build and test / Layer separation (push) Successful in 37s
Traceability / Requirement traces (push) Successful in 25s
Build and test / Android (aarch64) (push) Failing after 33m58s
The merge of the SCRFD/MobileFaceNet work brought 69 rustfmt diffs across
dr-catalog, dr-face and dr-ui with it, so `cargo fmt --all -- --check` fails
on master and the Desktop job stops at its Format step — before clippy, the
tests or the release build have run at all. That makes the whole desktop
half of CI blind: a real compile error behind this would look exactly the
same from the outside. There was nothing behind it, as it turns out — with
the formatting fixed, clippy, the test suite and the release build all pass.
Every .rs hunk is `cargo fmt --all` on the pinned 1.92.0 toolchain, not a
hand edit, but it is worth being precise about what that moved, because it
is more than whitespace. Besides reflowing signatures and call chains,
rustfmt reordered the `pub mod` and `pub use` items in dr-face/src/lib.rs so
the `#[cfg(feature = "inference")]` entries sort in place, added the trailing
semicolon inside `let ... else { return }` bodies in identity_ui.rs, wrapped
a bare closure body in braces in cluster.rs, adjusted trailing commas, and
dropped a stray blank line at the end of identity_ui.rs. All of it is
semantically inert; none of it changes behaviour.
docs/traceability.md rides along because it has to. The matrix records each
TRACES tag by line number, and reflowing develop.rs, lib.rs, faces.rs,
identity.rs and identity_ui.rs moved them — FR-CAT-8, FR-CAT-9, FR-CULL-10,
FR-DEV-3, FR-DEV-3a and FR-DEV-3c all shift by a line or two. The matrix was
verified up to date on
|
||
|
|
9997623df4 |
Compile the mount-table reader only where there is a mount table
`platform_volumes` has been gated to Linux since it was written, but the eight items it is built from were not, so every Android build compiled a `/proc/mounts` parser it could never call and then printed eight dead-code warnings about it. Real warnings hide in that kind of noise. Gated per item rather than moved into a module, because the file already draws the line that way one function above and two patterns for one idea is worse than a repeated attribute. The tests go with them. They parse a mount table and assert on names like `mmcblk0p1`, so they are as Linux-bound as the code they exercise, and `Path` turns out to be too -- only the reader borrows one, where `Volume` owns its own. Android now builds dr-plat with no warnings at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
721efbc371 |
Drop three trait imports Android does not need
The Linux store is built on `keyring::Entry`, whose set_password, get_password and delete_credential are inherent methods. The Android one is built on `keyring_core::Entry`, where they are inherent too -- so the `CredentialApi` import that each of its three methods opened with was doing nothing, and said so on every Android build. `CredentialStoreApi` a few lines above is a different matter and stays: `build` really does come from that trait. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d777f7f44d |
Merge branch 'master' into worktree-faces-scrfd-mbf
Build and test / Desktop (Linux) (push) Failing after 25s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Successful in 58s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 33m26s
# Conflicts: # docs/traceability.md # ui/dr-ui/src/develop.rs # ui/dr-ui/src/segmentation.rs |
||
|
|
7e1c33ebed |
Draw the subjects on the photograph, not on the sensor
Build and test / Desktop (Linux) (push) Successful in 19m42s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Build and test / Android (aarch64) (push) Failing after 33m12s
"Find subjects" would recognise a person on a portrait frame and then paint the outline into the hillside behind them. The detection was right and the mask was right; what was wrong was the picture drawn to show them. Instance masks live in sensor space, and correctly so — the generated shader samples them at `uv_src`, after the framing map, which is what keeps a mask on its subject through a zoom, a pan and a crop. The overlay is the one consumer that is *not* sampled by that shader. It is a flat image handed to the compositor to lay over a photograph that has already been through the framing map, so it has to arrive in the same space that photograph is in, and it did not. On a frame from a camera held sideways the outlines were drawn a quarter turn away from the subjects they described. `overlay_clip` had the same fault one layer down, and it is the more insidious of the two because it looks right. The crop and the viewport are fractions of the photograph as the user sees it — the prologue maps an output pixel through `crop_rect` *before* it unturns the frame — and they were being measured against the sensor's width and height. Two numbers, correct type, wrong axis. Both now go through `Orientation::into_shown`, so the overlay and its clip are in the photograph's space and the turn is the same one the render and the thumbnails make. Neither was noticeable until this week, and the reason is worth writing down: before the detector was given an upright frame it found almost nothing on a portrait photograph, so there was rarely an outline to be in the wrong place. Fixing the detector is what made this visible. Landscape frames were never affected, which is most of them, and is why an overlay that ignored orientation entirely survived this long. Verified on `_MG_9080.CR2`, a portrait frame of two people and a dog: the overlay was a 1599x1066 image drawn onto a 1066x1599 canvas, with the colour sitting in the mountainside above the subjects. It is now 1066x1599, and each outline is on the thing it names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ce201c7dd6 |
Name the two spaces a photograph lives in, so a turn cannot go the wrong way
Every orientation bug this codebase has had has been the same bug: a turn of the right size applied in the wrong direction. That failure is worth naming precisely, because it does not look like one — a quarter turn applied backwards lands 180 degrees from right, so the result is a plausible transform of the picture rather than anything obviously broken, and on landscape frames it is not wrong at all. It was the straighten shear, and it was the segmentation overlay, and each time it was found by eye rather than by a test. The reason it keeps happening is that "rotate 90 degrees clockwise" cannot be checked by reading it. The reader has to hold in their head which of the two images is being rotated and which way the y axis runs, and there were four hand-written copies of the permutation to hold it for: the shader prologue, its CPU twin, the thumbnail path, and the segmentation. So nothing added here says clockwise, anticlockwise, horizontal or vertical. The functions say *which space they take and which space they return* — `into_shown` and `into_stored`, `source_pixel` and `shown_pixel`, `into_shown_rect` and `into_stored_rect` — and each takes the dimensions of the space it reads from, so no caller has to work out which pair it is holding. `StoredRect` and `ShownRect` are separate types because they are the same four numbers meaning different things, which is exactly the case where a mistake is silent: a shown rect measured against stored dimensions produces a rectangle in the wrong place, not an error. Underneath there is one permutation. `source_pixel` was already shared by the prologue and the thumbnails; `source_point` is its normalised twin, written beside it so the two cannot drift, and everything else is those two read forwards or backwards. `Orientation::inverse` is the group inverse rather than `4 - turns`: mirrors apply after the turn, so undoing means undoing them first, and a mirror seen from the far side of an odd turn is about the other axis. That is the diagonal-mirror case, tags 5 and 7, and getting it wrong renders as — again — 180 degrees. Three call sites lose their own copy: the thumbnail path, `dr-ui`'s segmentation, and `dr-gpu`'s `local` example. "Upright" now means one thing across the application rather than one thing per caller. The gate that matters most is `the_render_and_the_orientation_map_agree`. The shader prologue and `Orientation` answer the same question by different routes, and until now nothing checked that they answered it the same way. It now checks every EXIF tag against every user rotation and mirror on top of it, because the composition is where the two could agree singly and disagree together. The rest earn their place by having caught something. Writing these found two real errors in this commit's own new code before it ran anywhere: `shown_pixel` was handed the dimensions of the wrong space and overflowed, and the rect map turned the wrong way for the diagonal mirrors. A round trip that returns what went in is the only check worth having here, since every wrong answer is still a picture. No behaviour changes. The permutations are the ones that were already being applied; they are simply applied from one place now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
25c88d9dbd |
Start face indexing from Settings
Beside the thumbnail sweep, because it is the same kind of thing: a job that runs for an hour, is asked for once, and reports into the activity list above it. It is also downstream of that sweep -- detection reads the proxies it builds -- so the two belong in that order, and the coverage line says how many images are waiting on a proxy rather than only how many are left to index. The button drives the Identity Manager's own state rather than a second copy, so it cannot disagree with that screen about whether a pass is running, and either place can start or stop it. The pass now opens an activity row. The caption promises progress will appear in the list above, and without a row it would not: the button would be the only sign anything was happening, invisible from every other screen. Coverage is read when the Settings page opens. The figures live in the catalog and this page deliberately holds no session, so they arrive through a closure rather than being kept current -- they are only ever looked at while the page is on screen, and the check is two counts and an indexed scan. Also adds DARKROOM_NO_SYNC. Redirecting XDG_DATA_HOME isolates a test launch's catalog and thumbnails but not its server, and I found that out by pushing a test catalog over the live one. The guard sits in start_derived_sync rather than at its three call sites, because the sweep firing a sync is correct and a flag checked in three places is one that gets missed in a fourth. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f00b92a0e6 |
Turn face boxes back into sensor space before matching regions
Faces are found on the thumbnail, which is cached the right way up -- the grid would lie on its side otherwise. Segmentation runs on a proxy rendered through a neutral edit graph, which carries no orientation and is therefore in sensor order. For anything shot in portrait the two differ by a quarter turn, so a face and the person containing it were being compared in spaces 90 degrees apart: no match, or worse, a match against somebody else's region. The transform goes on the face rather than on the proxy. Instance masks are defined in the proxy's space and sampled long afterwards, so turning that space would be a far larger change than naming a region warrants. Also two things the first screenshot of the running app showed that no test would have: 110 of 23,528 displayed as "0%", which reads as the feature having done nothing. One decimal below ten percent, and a floor so real progress never shows as none. The rail picked some near-black covers, because the largest face in a group is often the nearest one in a badly lit frame and a black square beside a name identifies nobody. It now cuts the best few and takes the first legible one, falling back to the largest when a person's every photograph is dark -- which happens, and showing it beats showing nothing. 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> |
||
|
|
b55812812a |
Name segmented people from the faces already recognised in them
The segmenter knows it found a person; the face index knows which person. Joining them turns "person" in the mask list into "Anna", which is the difference between a vocabulary of eighty COCO classes and one that includes the user's family. Selecting a subject in a group photograph stops being a guessing game between three identical rows. Containment, not IoU. A face is a small part of the person it belongs to, so a correct pairing has an IoU near zero and anything IoU-based would reject every true match. Confirmed names only. A suggestion is the system's guess, and printing a guessed name onto a mask region would launder it into a fact. Writing the tests corrected the design once: a tight head-and-shoulders portrait, where the face fills most of the person box, is the case where naming is most certain, not least. An earlier guard rejected exactly that and has been removed, with the reasoning left as a test because it is easy to get backwards a second time. The names hang on the develop session, set when the image opens because that is the one moment the catalog and the image id are both in reach. Every segmentation run afterwards picks them up for free, and a library with no face indexing behaves exactly as it did before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d7a81375ee |
List the steps, and let a photographer step straight to one
Build and test / Desktop (Linux) (push) Successful in 19m30s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m5s
Undo answers "take back the last thing", which is the question asked about a mistake just noticed. It is the wrong instrument for one noticed six adjustments later: eight presses, each changing the picture, with no way to see how far back the mistake is without passing through it. A step is a whole state, so arriving from six away costs what arriving from one does — which is what makes a row worth making clickable rather than decorative. `Edit::Discrete` had to go for the list to be worth drawing. Seventeen call sites recorded the same anonymous step, which is fine for deciding whether two changes are one gesture and useless for a panel: seventeen rows reading "Discrete" is not a history. Every variant now carries enough to name itself, and the compiler enumerated the sites that had to start saying so. A step that moved a parameter is still named out of the descriptor, so an operation added as a YAML declaration appears in the history correctly named with nothing written for it (FR-DEV-3c). Choosing a film stock was not undoable at all. The pick went straight to `choose_film`, which nothing on the history's path ever sees. `pick_film` records it, and is separate because the same call is also how a *restored* edit gets its tables back — recording that would push a step for the undo the photographer had just asked for. The list is rebuilt off a revision rather than off every redraw. A drag ends in a redraw per frame while folding into one step, so the unconditional version would tear down and recreate every row sixty times a second to arrive back at the list already on screen. The counter is process-wide: a per-instance one starts every photograph at the same number, so a frontend holding "the revision I last drew" would keep the previous image's steps on screen — invisible while every image opens with one identical row, and a wrong-photograph bug the moment persisted history means it does not. The step names that no descriptor can supply are constants with a roll, and a test walks the roll rather than a second copy of it. `resolve` splits so that "is this catalogued?" can be asked: `derive` turns `history.mask_toggled` into "Mask Toggled", which names a field rather than an act and, being perfectly readable, is a mistake nobody would look at twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b89f1cfece |
Snapshot the whole edit in the history, so a drawn mask can be taken back
The undo stack snapshotted a `Preset` — the parameter map — and a mask layer is deliberately not a parameter. So drawing one changed nothing the history could see: `record` returned `false`, no step opened, and the layer the photographer had just painted had no way back. The interface went on calling `record` in good faith, including from the mask controls, and nothing failed. A film stock went missing the same way. The snapshot is an `EditState` now, so the history is complete by construction rather than by anyone keeping a list in their head. `undo` and `redo` return a `Step` rather than a `bool`. Stepping is not the only outcome a caller has to act on — a step across a change of film leaves the graph without its tables, and only the caller can bake them — and a `bool` would let that be dropped by writing nothing at all, which is the shape of mistake this module had already made once. `DevelopSession` settles the debt either way; a step that found nowhere to go is left alone, since clearing the film because undo hit the floor would take the stock off the picture. Five tests, all of which fail against the old snapshot: a drawn layer is undoable and redoable, a layer's own settings are a step of their own, a change of stock is a step and names what it needs baked back, clearing the film is undoable, and an exposure move does not deep-copy the mask stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
93efdf27a6 |
Keep the film stock when an edit is saved
`Version::update` is the write path an automatic save goes through. It copied the parameters and the masks and said nothing about the film, so a photograph developed on a stock was written back without it and opened the next time without its emulsion. Nothing reported a failure — the line was simply not there. It is the third of the three routines that captured "the edit" and the only one that got it wrong, which is the argument for not having three. All of them now destructure one `EditState`, so `from_graph`, `update` and `apply` cannot disagree about what an edit consists of, and the next part of one cannot be lost by anybody writing a line too few. Two tests, both of which fail without the fix: the field survives `update`, and the stock survives the round trip through the file. `apply` returns the `FilmRebake` it always implicitly owed, so `apply_version` now reads the debt off the call rather than off `version.film` — and pays it in both directions, since a version with no film has to clear the adjust pass too or it keeps textures bound that nothing will sample. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d562ceaaf4 |
Show a face beside each name in the people rail
The rail was drawing an empty square for every person: cover was hardcoded to a default image. That is the one place a portrait matters most, because the rail is how the user decides which unnamed group to open first, and a list of "Unnamed (24 faces)" rows tells them nothing. The portrait is the person's confirmed face with the largest crop_px -- the most source pixels the face actually occupied, so the one they have the best chance of recognising -- falling back to a suggestion so a freshly clustered group still has a face beside it. Cached in the controller, because every mutating action reloads the whole screen and cutting a portrait costs a JPEG decode per person. Without the cache, confirming one face would re-decode a proxy for every person in the library, and the rail does not change when a suggestion is accepted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5622a58ce3 |
Give an edit one complete state, and make omitting part of it a compile error
An edit used to be a bag of scalars. That stopped being true when the mask stack and the film stock arrived, both deliberately held apart from `ops` because a layer is not a scalar and a stock is not a scalar — and nothing announced the change. What happened instead is that three routines each captured "the edit" and each captured a different subset of it. `EditState` is all of it: the parameter map, the masks, the stock. What keeps it complete is not a comment. `EditGraph::state` destructures the graph exhaustively, `EditGraph::set_state` destructures the state exhaustively, and the fields are public so every construction site is a struct literal naming all of them. Adding a fifth kind of graph state — FR-DEV-8's spot removal is the one already asked for — fails to compile until somebody has decided whether an undo has to put it back. Verified both ways round by adding a field to each type and watching five call sites refuse to build. A compiler error rather than a runtime check, because the failure being prevented is silence: the missing halves produced no panic, no warning and no failing test. The stack is now shared rather than owned, and that is about the drag path rather than memory: `state` runs on every parameter change, which during a drag is once a frame, and deep-copying a painted brush sixty times a second to record an exposure move would be a cost paid for nothing. `masks_mut` is the one door a stack is modified through, so it clones on write. `FilmRebake` is the one thing a caller is still owed. Restoring a stock always needed the profile database this crate does not link (ARCH §6.5a); it was a comment before, and it is a `#[must_use]` return value now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2944c1b704 |
Document the run marker and cross-device face sync
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
96d07da15f |
Sync face data as sealed shards, so a second device does not re-index
Indexing 23,500 images is about two hours of CPU, and the result is byte-identical on every device: the same model over the same proxy produces the same embedding. Paying for it once per account rather than once per device is the point. Shards rather than the catalog snapshot, because the snapshot goes up whole on every sync and a fully indexed library carries roughly 30 MB of embeddings. That is exactly the cost the thumbnail store's 25 MB cap exists to bound, so face shards use the same cap -- imported from dr_thumbs rather than restated, since the number is a statement about sync cost and the two must not drift apart. The split follows the one already there: bulk immutable data in sealed shards, small mutable data in the catalog snapshot. Faces, landmarks, embeddings and run markers shard; people, names and assignments ride the catalog and merge by uuid. Keyed on oc:fileid throughout, never on image_id, because a row id means nothing on another device. The run marker travels with the faces it describes. Without it a receiving device cannot tell an image with no faces from one never examined, and would re-detect every landscape it had just adopted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4a82753d22 |
Show the detector the photograph, not the sensor's scanlines
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 19m7s
Build and test / Layer separation (push) Successful in 25s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m10s
"Find subjects" was handed the proxy in the sensor's own orientation, so every frame shot on a body held sideways reached the model lying on its side — and a model trained on upright photographs is very bad at those. Measured end to end on a 22 MP frame of two people and a dog: `person 0.36` and nothing else, against `dog 0.82, person 0.61, person 0.49` for the same pixels stood up. Nothing failed; the panel simply offered one poor subject where there were three good ones. The orientation was never dropped on purpose. The proxy is deliberately rendered through a *neutral* graph — the detection has to survive an exposure change, or every slider would invalidate the masks built on it — and neutral took the file's orientation with it along with everything else. Landscape frames were unaffected, which is why it stood for as long as it did. The turn is `Orientation::source_pixel`, the same function the grid's thumbnails already go through, so the detector and the thumbnailer now agree about which way is up rather than holding two opinions. What it is turned by is `Framing::effective_orientation` — the file's EXIF tag and the photographer's own rotations composed into one permutation, by the group law rather than by adding the turns, which is a distinction `Framing` already had to make and had already tested. Rotating the picture and pressing the button again therefore does what it looks like it does. The proxy stays in sensor space and the masks come back into it. That is not a detail to be tidied later: the generated shader samples the mask array at `uv_src`, *after* the framing map, so a mask stored upright would sit a quarter turn off the subject it was drawn around. That is a wrong mask rather than a weak one, and nothing announces it. So the picture is stood up for the model and laid back down for everything else, and `upright`/`lay_down` are returned as a pair because calling one and forgetting the other is silent. Both directions are the one function: `upright` gathers through `source_pixel` and `lay_down` scatters through it. A quarter turn is a bijection of the pixel grid, so the round trip is exact — no filter, no resampling, and no hole to fill — and an inverse written out by hand would be a second thing to keep in step, whose way of being wrong is a mask mirrored about the wrong axis, which still looks like a mask. The orientation joins the confidence and the tiling flag in the segmentation signature, and for the same reason: turning the photograph changes what the model recognises, so two runs either side of a rotation are different instance lists. Two that happened to come out the same length would otherwise share a signature and a stored layer would be silently re-indexed from one into the other. The refine pass had it too — it re-runs the model over a crop rendered in the same sensor space — so it makes the same turn, and would otherwise have handed back a worse mask than the one it was asked to improve, on the subject the photographer had just pointed at. `dr-gpu`'s `local` example is fixed with it. It exists to be the shipping path with pictures attached, and a diagnostic that reproduces the bug it is meant to catch is a trap for whoever reads it next. Seven tests. The round trip is the identity over all eight EXIF tags on a non-square asymmetric grid; a turn carries whole pixels rather than shearing the channels apart; a sideways frame reaches the model upright; a box comes back in sensor pixels, worked out by hand for the one turn a portrait frame actually writes; a restored box still reads low-to-high for every tag, since the rest of the pipeline takes `x1 - x0` without checking the sign; and the eight tags cannot collapse into one signature key. The existing composition test now runs against `effective_orientation` itself, over all 8 x 16 baseline-and-user pairs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
336a1fd296 |
Write down what the Android renderer change cost, and what else is owed
Three compromises were taken deliberately over the last few days and none of
them was written anywhere a future reader would look. So `docs/technical-debt.md`,
and two corrections to the architecture document that the Android change made
untrue the moment it landed.
**TD-1, the Android readback.** §12/6.1 says GPU results never round-trip
through the CPU, and the develop view on Android now does exactly that. That
is worth recording as a breach with reasons rather than quietly leaving a
constraint the code no longer honours — the next person to read §6.1 and then
`DevelopSession::render` would otherwise conclude one of them is a mistake.
The entry carries the device measurements that forced it, why setting
`preTransform` is not a fix available to us, and the three separate things any
one of which would remove it.
**TD-2 and TD-3**, the serial thumbnail fetch and the unbounded drain, were
found while chasing the tearing and are still outstanding. Both have a known
shape for the fix; neither is a bug, and neither should be discovered again
from scratch.
The architecture document said the app renders "through wgpu to Vulkan on both
Linux and Android", which stopped being true at
|
||
|
|
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> |
||
|
|
e0cb968e04 |
Merge remote-tracking branch 'origin/master' into worktree-spot-removal
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 20m9s
Build and test / Layer separation (push) Successful in 39s
Traceability / Requirement traces (push) Successful in 26s
Build and test / Android (aarch64) (push) Failing after 33m10s
# Conflicts: # docs/traceability.md |
||
|
|
bc07c32611 |
Merge branch 'master' into worktree-spot-removal
# Conflicts: # docs/traceability.md |
||
|
|
e14bc34a9e |
Ask which frame this was taken on, because grain is enlargement
Build and test / Desktop (Linux) (push) Successful in 19m6s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 26s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 33m7s
A crystal is a fixed size in micrometres. How grainy a photograph looks is therefore not a property of the emulsion alone -- it is film size against output size, and the frame is the half a digital file cannot supply. This assumed 35 mm for everything. The same emulsion on 4x5 averages about 3,800 crystals into the pixel that holds 300 on 35 mm, so it renders roughly 3.5 times smoother at the same print; every large-format photograph was being rendered as grainy as a half-frame. `Format` now carries the real image widths -- the gate, not the nominal inches, since a "4x5" exposes about 121 mm -- and the film node asks for it. It is a genuinely fixed list, unlike the stocks, so it is a declared `enum` parameter and gets its control, its sidecar entry and its undo step for nothing. It is also the first enum in the develop chain, and it broke two tests by being one. A row has to compare equal to itself across two builds or `sync_rows` replaces it on every parameter event -- destroying the elements built from it, including whichever TouchArea holds the current gesture, so the format picker would have fought every slider drag in the panel. `ModelRc` compares by identity and the row built a fresh choices model each call. `no_choices` already shares one empty model for exactly this reason, and the build site already said "see no_choices for why the identity matters". The fix follows it: memoise the model per variant list. Curve rows solve the same problem the other way, writing values through the existing model, which is not needed here -- a variant list is fixed at compile time, so one model can serve forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
626780276d |
Draw with OpenGL on Android, where the driver owns the display rotation
The grid tore while scrolling on the tablet, in portrait only, and was
flawless in landscape. It was not vsync and it was not the grid.
Measured on the device, same build, only the tablet rotated:
landscape bufferTransform=ROT_180 composition=DEVICE (2) clean
portrait bufferTransform=ROT_270 composition=CLIENT (1) torn
The panel is mounted landscape — 1920x3000 at installOrientation 3 — so a
portrait window needs a 90 degree rotation before scanout. wgpu-hal hardcodes
the swapchain's `preTransform` to `IDENTITY` and says so in a comment beside
the line:
// On Android 10+, libvulkan's `vkQueuePresentKHR` returns
// `VK_SUBOPTIMAL_KHR` if not doing pre-rotation ... This is always the
// case when the device orientation is anything other than the identity
// one, as we unconditionally use `VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR`.
That is gfx-rs/wgpu#3345, and it cannot be fixed by setting the field:
`preTransform` is a *promise* that the content is already rotated, so keeping
it needs the renderer to rotate what it draws, which wgpu cannot do on Skia's
behalf.
We do not have to be on that swapchain. `AndroidWindowAdapter` chooses
`SkiaRenderer::default_wgpu_29` only because this crate enables
`unstable-wgpu-29`; without it `SkiaRenderer::default` resolves — through
i-slint-renderer-skia's build script, which selects OpenGL on anything that is
not Apple, Windows or wasm — to Skia over OpenGL, where the driver owns the
rotation and there is no transform to get wrong. So both renderer features
move to the desktop-only dependency, and desktop is untouched.
# The cost, stated rather than hidden
Skia over OpenGL cannot sample a `wgpu::Texture`, so the develop view's frame
comes back through memory: `AdjustPass::export_pixels`, already ungated and
already used by the export path, into a `SharedPixelBuffer`. That is the
round-trip ARCH §6.1 and AC-8 exist to forbid, and it is the right trade only
because of what the alternative actually is — not a faster develop view, but a
grid that tears in the orientation a tablet is mostly held in.
Two things keep it small. The device is still opened on Android, so demosaic
and the adjust pass are untouched on the GPU; only the last hop changes. And
`render` fits the pass to the canvas before it runs, so the readback is at
viewport resolution, a fraction of the ~7 ms at 4K the original measurement
was taken against.
Four other explanations died on the way here, each by measurement rather than
argument: the present mode (a patch confirmed in the installed binary reached
`AutoVsync`, and the rows still duplicated), our shared wgpu device (Slint
opened its own, unchanged), Skia's partial rendering (off for GPU surfaces),
and client composition itself (unavoidable in portrait on this panel, so it
cannot be what distinguishes a torn frame from a clean one).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3e607222c6 |
Record the Identity screen in the face spec
Also notes what building it taught the design: a split has to reject before it confirms, or the next clustering pass undoes it. 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> |
||
|
|
8ab9440190 |
Put the repair tool on the photograph
A third chip beside Crop and Local, and the mode strip's own comment predicted the shape: a mode that arms a gesture on the canvas and scopes the column. Click a mark to cover it, drag the disc to move the repair, drag the source circle to say where the patch comes from, Delete to remove it. The source starts two and a half radii towards the middle of the frame, which is FR-DEV-8's automatic placement in its cheap form — dust sits on skies and skies are smooth, so it is usually right and always one drag from fixed. Two things are drawn deliberately. The circles are the size the repairs actually are, because whether a disc covers a speck is the whole judgement being made and a fixed-size dot would say nothing about it; the reach around them is padded to a touch target so a spot on a dust mark can still be picked up on a phone. And only the selected repair shows its source: a dusty sky carries a dozen, and two dozen circles with nothing saying which belongs to which is less information rather than more. The panel edits what is stored while the canvas draws what is mapped, and the two are pushed separately for that reason — a slider deriving its value from the drawn radius would move differently at different zoom levels. It is also the one panel built from SliderRow rather than a live track: a repair has no OpId to coalesce a drag under, so a row that fires once per gesture is what keeps undo one step per decision. Verified as far as this environment allows: the strip renders and the column re-scopes, photographed under XWayland. Synthetic clicks do not reach this application, so the gestures are as-written rather than as-felt, and docs/spot-removal.md says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2ac069a6b3 |
Index the library's faces, and group them into people
Wires dr-face to dr-catalog: a background sweep that reads the proxy the grid already built, detects, aligns, embeds and stores, then a clustering pass that turns those embeddings into suggested people. Detection runs on the Large thumbnail tier and nowhere else. That is what makes the feature affordable -- a browsed library has already paid for its proxies, so face indexing adds no RAW decode that was not already happening -- and it is why an image whose proxy is missing is skipped rather than fetched: requesting one here would put face indexing on the network path FR-CULL-8 keeps it off. The sweep keeps no cursor. It asks the catalog what is missing, so it resumes after process death with no repeated work beyond the in-flight image, and cancelling is dropping the receiver. recluster writes only the suggested half. Confirmed faces go in as anchors and come back untouched, and a cluster of one stays nameless -- naming every stray face would fill the People view with noise the user then has to dismiss. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
871a0eac28 |
Check the image has the commands before CI finds out it does not
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 19m21s
Build and test / Layer separation (push) Successful in 29s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m3s
Two failures in a row were the same shape: a command the workflow calls was not in the image. git-lfs, then file(1). Each cost a full run to learn, and the android job is expensive to be wrong in -- the step that fails is at the end, so every attempt paid twenty-eight minutes of cross-compile first to reach the line that could not work. Both were visible in ten seconds from here. `docker run <image> command -v file` is the whole diagnosis; it just never occurred to anybody to ask before pushing. So the question gets asked automatically. This reads the `run:` blocks out of the workflows, pulls the commands worth doubting -- the ones a minimal Debian plausibly lacks, not `cd` -- and checks each against the image its job declares. It does not run the workflow and is not a replacement for one. It answers exactly the question that was expensive to answer. `git lfs` is handled specially and the comment says why: it is a subcommand, so the first word of the line is `git`, which is always there. Taking first words alone would have missed the original bug -- and did, in the first version of this script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d06b24c485 |
Regenerate the traceability matrix after the 0.7.0 work
Build and test / Desktop (Linux) (push) Successful in 19m20s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Successful in 1m2s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 33m15s
Seven more tags -- 621 against 614 -- from the print path, the film table rebuild and the projector lamp. Coverage is unchanged at 51.4% (91/177): the new tags land on requirements that already had one. Nine rows move. As before, the matrix links to line numbers, so a commit that inserts anything above a tag rewrites its row without changing what the row says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2958444835 |
Let undo reach the repairs before the tool can make one
A history state was a Preset — a map of scalars — which was right while every edit in the graph was a parameter. A spot is not one, and undo is the first thing anyone does with a repair: place it, dislike it, take it back. Left as it was, that press would have stepped some unrelated slider and left the spot on the photograph, which reads as undo being broken rather than as undo being absent. So a state is now the pair, params and spot set. The same door is the one the mask stack will come through: mask edits are outside undo today for exactly this reason, and FR-DEV-5 is not finished until they are not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f00b3ae924 |
Take the tone from the hole and the texture from beside it
A clone gets the texture right and the level wrong. Dust on a gradient sky is copied from a patch a little lighter than the hole it fills, and the repair reads as a disc even though every grain in it is correct — which is why FR-DEV-8 asks for heal and not only for clone. Heal adds the membrane: the difference between the two neighbourhoods, sampled at twenty-four points around the rim and interpolated across the disc by inverse square distance. Solving the Poisson problem properly is tens of Jacobi iterations, and an iteration here is a dispatch — sixty dispatches to remove a dust spot is not a frame budget. The closed form costs one loop over the rim, no state, and no second pass. The spec called for mean-value weights; inverse squares are two transcendentals per sample cheaper and agree wherever the boundary difference varies smoothly, which is every repair anyone makes. What decides whether that trade holds is the measurement, so the measurement is the test: on a ramp steep enough to leave a clone wrong by 38 levels out of 255, the heal is wrong by 0. docs/spot-removal.md §6.1 records what shipped and what it would take to go back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5323608051 |
Draw the repairs, before anything sharpens what they removed
A spot set now composes detail passes of its own, one per round, and they go ahead of every operation's kernel. That placement is the decision worth recording: a sharpening pass reads a neighbourhood, so sharpening a dust mark before removing it smears its edge into pixels the repair's disc does not cover, and what survives is a faint over-sharpened ring around an otherwise perfect patch. It also disagrees with ARCH §5.2, which draws spot removal after clarity — docs/spot-removal.md §5.1 is where that is argued out. Every length reaching the shader is in render pixels, converted here where the framing is in scope. Both the centre and the source go through `Framing::output_at` — the same map the fused pass applies to every pixel — so a rotated photograph rotates the offset with no trigonometry, and the radius is found by mapping a point one radius above the centre and measuring, rather than by multiplying by a ratio this function has no business knowing about. The tests turn and crop the frame and expect the mark to stay gone, which is the property that arrangement buys. compose_full now takes the spot set, because a photograph with a repair and no sharpening still has a detail stage: a fused pass that encoded its own output there would quantise twice and bind to a texture of the wrong format. compose_detail_for takes the source size for the same kind of reason — a RenderScale describes the region on screen, and a spot is stored against the photograph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
00e78dc2ac |
Cluster faces into people, and calibrate what a similarity means
FR-CULL-9 forbids thresholding a bare cosine anywhere in the subsystem, so calibrate fits P(same person) per library and reports whether the fit is trustworthy. Two details carry most of the weight. The fit runs against a 200-bin histogram rather than a pair list: a 25,000-face library has ~3e8 pairs and no gradient descent is running over that. And a fresh library has no valid calibration, because the positives have to come from user confirmations or burst siblings -- bootstrapping them from high cosine would fit the calibration to the belief it was supposed to test. Clustering defends against the over-merging FR-CULL-10 warns about with constraints rather than a better threshold: two faces in one photograph never merge, and two groups confirmed as different people never merge. Average link rather than single link, so one strong edge cannot weld two families together. Calibration is defined once, in dr-face, and dr-catalog re-exports it. Two implementations of one probability model is exactly how a number comes to mean the wrong thing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
aac3136407 |
Store faces and the people they belong to
Schema v8: people, faces, face_person, face_person_rejected, and the per-library calibration. Follows catalog.md 10.1 with two additions the spec work turned up. crop_px, because at the 1024px proxy tier a group shot reaches the embedder at ~50 source pixels upsampled to 112 and a portrait at 340. FR-CULL-9 names face size as an axis along which an uncalibrated similarity misbehaves, so it is a stored feature rather than a UI hint. face_person_rejected, because rejection is not the absence of an assignment. Without it the next clustering pass re-suggests exactly the face the user just pushed away, and the tool feels broken. record_detections replaces rather than appends, since DetectFaces is coalesced per image -- and carries confirmations across the replacement by box overlap, so re-indexing with a better model cannot discard the user's own labelling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6997c0f7ac |
Let a detail pass carry a list, not only a kernel
Every neighbourhood pass so far has been a convolution, whose whole description fits in the uniform block because its structure fixes how many numbers it needs. Spot removal is not that shape: sixty-four repairs and one repair are the same shader with a different buffer behind it. So a pass may declare `storage`, which arrives at binding 3 as `array<vec4<f32>>` with `arrayLength` in scope. The alternative — packing the list into uniforms — needs a fixed maximum paid for on every frame, a composer that can emit vec4 fields because a uniform array's stride is 16 whatever it holds, and it gives the next operation that wants a table nothing to build on. The property worth having is what stays out of the generated source: the count is in the buffer, so placing the tenth spot uploads 512 bytes and reuses the compiled pipeline, exactly as moving a slider does for the fused pass. `changing_the_list_does_not_recompile` is that, asserted. One bind group entry rather than two more layouts, and one placeholder buffer allocated in `new` rather than sixteen bytes per pass per frame — a zero-length storage buffer cannot be bound, and per-frame allocation is what this module's documentation exists to refuse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
19981c1033 |
Detect, align and embed faces with SCRFD and MobileFaceNet
Ports the pipeline from the C++ reference in ../scene-actor-extraction (MIT, same author). End to end on real portraits it separates identities the way the reference's fitted calibration says it should: 0.596 between distinct photographs of one person, 0.05 between different people, either side of MBF's 0.267 boundary. Three things are structural rather than incidental: Aligned112 can only be built by align::warp, so Embedder::embed cannot be handed an unaligned bounding-box crop. That mistake yields 512 plausible unit-norm numbers and no error, so the type system refuses it instead. Embedding carries its ModelId and cosine() returns None across models, because a cross-model similarity is the one mistake that produces plausible garbage rather than a failure. The model-free half -- alignment, embedding arithmetic, f16 storage -- sits outside the inference feature and is covered by 11 tests that need no weights on the machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
44444f7768 |
Write the repairs down, one line each, and merge them by id
A spot is eight numbers, so it goes in the version block as a line rather than in a block of its own the way a mask does — sixty-four blocks would bury the rest of the file. A line per spot rather than one line for the set, because the line is what a diff shows and what a merge resolves. The parse arm sits ahead of the op.param arm deliberately: without it, `spot.abc123 = 0.4 0.6 …` reads as an operation called `spot` whose value will not parse, and the repair is dropped with a warning about a corrupt number. The prefix is safe precisely because a spot is not an operation, so no ops/ declaration can claim the name. Merging is merge_masks by id, one level down, and it is where the derived ids earn their keep: two devices that removed different marks hold different ids and both survive, while two that removed the same piece of dust hold the same id and the merge sees the one repair it is. A spot both sides dragged resolves whole to the higher revision — eight numbers describe one disc, and half of each is a repair neither photographer made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
72410f39c6 |
Answer M1: tract loads both face graphs once their dims are pinned
Neither InsightFace export parses as shipped -- SCRFD fails at its input node, ArcFace at the first Conv -- which is the same wall dr-segment hit on YOLO's dynamic export. Both load cleanly with the input dims frozen, so the pure-Rust runtime holds for the face pipeline too. tools/fix-face-model-shapes.sh does the freezing, and exists so the artefact is reproducible rather than a binary someone once produced. It takes two forms because the two graphs need different ones: ArcFace's batch is a named dim_param, SCRFD's H and W are dynamic but unnamed. Also notes YuNet loading with no intervention, which matters for the licence question in faces.md 2.3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
97479a0512 |
Hold the repairs a photographer makes, and say which may share a pass
A spot is a disc, a source offset and four numbers, and it lives beside `ops` for the reason `masks` and `film` do: the operation trait is ParamId -> f32, and a list of repairs is neither scalar nor fixed. Two decisions here are not obvious. The id is derived from the position rather than counted, because two devices editing offline would each mint `spot3` for different marks and the sidecar merge would then treat two repairs as one — from the position, two devices that removed the same piece of dust agree, and two that removed different ones do not. And every length is in the frame's isotropic units, not a mixture of those and shorter-edge fractions: one unit for the radius, the feather and the offset agrees on a landscape frame and on a portrait one, where a mixture only agrees on the first. `rounds` is the arithmetic that keeps a source from reading a destination. Every spot in one pass reads the photograph as it stood before that pass, so a spot sourcing from an earlier spot's destination would copy the mark that spot was removing. Grouping is not a pass per spot — that is sixty-four dispatches for a case that almost never arises — it is a new round only when the sources actually collide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ec740115b6 |
Spec the face pipeline on SCRFD and MobileFaceNet
FR-CULL-8..12 specify the subsystem in terms of "a 512-dimension embedding from a stated model" and stop there, because D13 was open. This names the models, and grounds them in the measurements and the working C++ pipeline in ../scene-actor-extraction rather than in a literature reading. The licensing half of D13 stays open, but with a route through it: the InsightFace weights are non-commercial and cannot be committed, so the app ships the code and the user fetches the model. faces.model_id already makes that a survivable choice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8d8d6491ad |
Say how spot removal is going to work before writing it
FR-DEV-8 is the last develop requirement with nothing behind it. The pipeline it lands on already has most of the parts — the neighbourhood stage, the mask crate's normalised coordinates, the gradient handles' canvas drags, the merge-by-id rule — so the spec is mostly about the four things that are genuinely new, and about the two places where the obvious implementation is the wrong one: a Poisson solve is sixty dispatches per spot, and a per-frame readback to find out what colour a sky is would undo ARCH §6.1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f82c69bc6b |
Say which version this is: 0.7.0
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 1h20m35s
Build and test / Layer separation (push) Successful in 31s
Traceability / Requirement traces (push) Failing after 1m0s
Build and test / Android (aarch64) (push) Failing after 33m5s
Film simulation is a feature, not a fix: five Ilford stocks, a grain model that counts silver rather than adding noise, and the pipeline and UI to drive them. 0.6.0 was tagged thirty-one commits ago and does not describe any of that. The Android versionCode follows from this without being restated -- package.sh packs MAJOR*10000 + MINOR*100 + PATCH, so 0.7.0 is 700, above the 600 already installed on devices and therefore an upgrade rather than a refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>v0.7.0 |
||
|
|
f14176de29 |
Give the projector lamp a name instead of a warning
Every bake of a Vision3 stock logged:
unknown illuminant "K75P", falling back to D55
K75P is a cinema xenon short-arc lamp, and Kodak 2383 and 2393 -- the
projection print films those stocks print onto -- name it as the light their
result is looked at under. It was never implemented, so it fell through to
the unknown branch.
The fallback was the right family: a xenon arc sits near 6000 K, close to
daylight and nothing like the tungsten enlarger above it. So the pixels do not
move. What changes is that D55 is now a documented choice rather than the
consolation prize for an unrecognised string, with the approximation stated --
an arc has line structure a Planckian curve cannot express, and the residue of
that is small here because the viewing step adapts the white point out either
way.
The test is the point of the commit. A profile naming a light nobody
implemented should fail the suite, not whisper into a log that only gets read
when somebody happens to be looking for something else -- which is how this
was found.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4b50648870 |
Rebuild the film tables when the film's own sliders move
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Failing after 37s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 41m29s
Build and test / Android (aarch64) (push) Failing after 33m5s
Push and print exposure did nothing at all. The parameter was set and the
tables were never rebuilt, so the shader went on running the stock as it had
been baked.
The check asked the wrong list:
self.rows() // one entry per *parameter*, tab-filtered
.get(op_index as usize) // indexed by an *operation* index
.map(|_| ())
.and( ...the real check... )
`op_index` counts over the scoped capabilities -- it is what `lookup` resolves
a slider through -- so capabilities is the only list to ask. Indexing `rows()`
served no purpose, and past its end the `and` short-circuited to None and the
rebake silently never happened.
Both halves of that are worth saying. It was wrong, and it was convoluted, and
the convolution is what hid the wrongness: a one-line check would have been
obviously right or obviously broken.
This is a class of bug the suite cannot reach. The tables are rebuilt in the
interface layer, in response to a control, and every test either side of it
passed throughout -- dr-film computed the pushed curves correctly and the
shader rendered whatever it was handed. Only moving the slider showed it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ff1a3e0e80 |
Print the black-and-white negatives instead of showing the scan
Choosing Ilford HP5 Plus showed an inverted grey frame. So did Double-X. They are negatives, and upstream leaves `target_print` null on every monochrome stock, so nothing was ever printed and the scan was all there was. A colour negative at least announces itself -- the orange mask says plainly that you are looking at a negative. A monochrome one just looks broken. They print on Kodak 2302 now, which is a monochrome print film and is what such a negative is actually printed onto; Double-X onto 2302 is the standard cine chain. For the Ilford stocks it stands in for an Ilford paper, which nobody has measured, and is at least the right kind of material. The scan is still reachable through the Scanned/Printed toggle. It is a thing to choose now rather than the only thing on offer. `every_shipped_stock_bakes` did not catch this, and could not: it derives "should this be inverted?" from the stock's kind *and whether it names a paper*, so it looked at an inverted HP5, concluded that was right for an unprinted negative, and passed. The assertion was self-consistent and the situation was still wrong. The new test asserts the thing that actually matters -- a camera negative must name a paper, that paper must be a printing stock, and it must be the same kind of material, so a monochrome negative cannot end up on colour paper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ce6458547a |
Develop longer, from the measurements rather than from a contrast slider
Pushing was not a thing to simulate. It was measured data being thrown away: Double-X and 2302 each ship five characteristic curves, one per development time, and this shipped the 6.5-minute column and discarded four. All five now ship and interpolate. The axis is real. Double-X runs 4 to 12 minutes, and across it the average gradient goes 0.472 to 1.034 while Dmax goes 1.19 to 2.56. The control is in stops, because that is what a photographer means, and one stop is a factor of about 1.41 in time. That mapping is checked rather than assumed: against Double-X's own axis it lands within 2% of the 9-minute column for +1, and near 12 minutes for +2, which are the times the datasheet gives for exactly that. There is a test. **Pushing must not recover shadow detail, and this does not.** Across the whole measured range the speed point moves about a third of a stop while the gradient doubles; three stops under mid-grey, density goes from 0.008 to 0.035, which is still nothing. Developing longer multiplies what was already recorded and cannot record what never hit the film. A push built as added exposure or global contrast brightens those shadows instead and looks convincing until someone who shoots film sees it, so that property has a test of its own. Interpolated in *log* time, because development is multiplicative: 4 to 5 minutes is the same amount of push as 9 to 12, and interpolating linearly would bunch the control at one end. Clamped at both ends, because past the published range there is no data and extrapolating a contrast curve invents an emulsion nobody tested. A stock measured at one process ignores the control entirely rather than inventing a curve for it -- Portra 800's pushes are separate *measured* profiles, which is the honest way to offer those. Costs nothing per pixel and changes no shader. The curves are a per-stock table, so the interpolation happens on the CPU at bake time, where choosing a stock and moving its sliders already rebakes. The Vulkan shader is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8f863ec56f |
Put file(1) in the Android image too
Build and test / Desktop (Linux) (push) Successful in 1h22m29s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 10m19s
Build and test / android-image (push) Successful in 10m19s
Build and test / Android (aarch64) (push) Failing after 33m30s
The android job now fetches the model and cross-compiles the whole app --
28 minutes of it -- and then dies on
file: not found (exit 127)
`Verify minimum API level` reads the linked API out of the .so's ELF
notes with file(1), and the image has never had it. Like git-lfs, the
absence could not show until something got that far: every previous run
panicked in dr-segment's build script long before this line, so the step
that was going to fail never ran.
Audited the rest of what the remaining steps invoke against the image
rather than find the next one the same expensive way -- zip, keytool,
base64, mktemp, shred, find, sed, awk, and aapt2/zipalign/apksigner/d8
from build-tools are all present. file was the only gap left.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b9ee29f3c7 |
Regenerate the traceability matrix for the Ilford stocks
Build and test / Desktop (Linux) (push) Successful in 1h21m23s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 33m28s
Same drift as before and for the same reason: the matrix links to line numbers, and the grain work moved lines in files that carry tags. The stocks bring six new files and ten new tags -- 227 scanned against 221, 614 found against 604 -- all on requirements that were already covered, so coverage is 51.4% (91/177) either side. Twelve rows move and nothing else changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |