Commit Graph
230 Commits
Author SHA1 Message Date
dtourolle 45214d3ca8 Regenerate the traceability matrix
Four branches merged, each of which had regenerated this file against its own
tree. Those versions all disagreed and none was right for the union, which is
why the merges took whichever side was to hand and deferred to this: the matrix
is generated, so the only correct version is the one produced once, here, from
the merged source.
2026-08-22 19:01:39 +02:00
dtourolle cd8750462f WIP: EXIF metadata on export
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle 97d4bd9061 WIP: clarity and texture
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle b4e55b47c1 WIP: noise reduction
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle 8ea427de3c WIP: capture sharpening
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00
dtourolle 0d9910efc6 Merge branch 'worktree-agent-a89309a856c8f4947' into integration 2026-08-22 19:00:56 +02:00
dtourolle 21500b45be Merge branch 'worktree-agent-abfe489c84c337e7c' into integration 2026-08-22 19:00:56 +02:00
dtourolleandClaude Opus 5 13d003b89d Let the curve widget plot whichever curve is asked for
An operation with four curves and a panel that draws one plot needs a way to
say which. The panel finds out the way it finds out everything else: the
points are faceted with the subject they act on, consecutive parameters
sharing a subject are one curve, and a widget spanning several of them gets
a selector over their names. Nothing in ui/ contains the word "red", and an
operation that grows a fifth curve arrives with a fifth chip.

The names ride on the panel rather than on the curve's row, because a Slint
model is compared by identity: a fresh list built on every parameter event
would make the row look changed every time, and rewriting a row rebuilds the
element holding the drag in progress. That is the hazard the in-place point
update already exists to avoid.

Which curve is on show is interface state, not an edit. It changes no pixel,
so it takes no history step, reaches no sidecar, and redraws nothing — the
photograph on screen is already right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:06:21 +02:00
dtourolleandClaude Opus 5 d64a61d677 Give the tone curve a curve for each colour channel
The declaration in ops/tone_curve.yaml has claimed per-channel curves since
it was written — it is the justification for the operation carrying both
`tone` and `colour`. Only the master curve existed. This is the other three.

The master runs first and the channels grade its result. Both orders are
real images and they differ visibly, so the choice is made and written down
rather than left to the loop: a point placed on the blue curve should act on
the tone the photographer can see, which is what the master has already
produced. The other order anchors the grade to tones the master is about to
move, so adjusting contrast slides a warm shadow up into the midtones.

Every id that existed before today is spelled exactly as it was. The master
curve keeps `p2_y` and the new curves take `r_`, `g_` and `b_` prefixes, so
a sidecar written when there was one curve loads, means what it meant, and
renders the same shader — asserted on the generated source, not on the
parameter values. Nothing needed a version check because nothing was
renamed.

Each curve reaches the shader only when it has been moved off the diagonal,
so an S-curve and no colour work generates what it generated when this
operation held ten parameters instead of forty, down to the uniform names.
The monotonicity guarantee is enforced per curve: a coincident pair on blue
divides by zero exactly as thoroughly as one on the master.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:06:13 +02:00
dtourolleandClaude Opus 5 f4c21c4e2c Regenerate the traceability matrix after the merge
Both sides added tags. 49.7%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:53:25 +02:00
dtourolleandClaude Opus 5 49be1fe354 Give the library somewhere to type a keyword
A sheet over the grid, opened from the header beside "Add to collection" —
deliberately the same card, scrim and dismissal as the filing sheet, because
they are the same gesture applied to two kinds of label: pick the photographs,
then say what they are. A user who has filed a selection already knows how this
works.

A word the whole selection carries, a word only some of it carries, and a word
none of it carries are three visibly different marks. Half-applied shown as
applied would be a lie about photographs the user cannot see from here, so a
partial keyword draws a dash and says "3 of 12" beside it. Tapping a dash
completes the keyword rather than removing it, which is what it means nine times
in ten, and the tenth is one more tap away.

The vocabulary is answered against the selection in Rust and pulled when the
sheet opens rather than pushed on every selection change — the selection moves
on each arrow key and the sheet is shut for almost all of them.

Assign and unassign travel by name, so a word typed into the field and a word
tapped in the list are one path rather than two, and the sheet never has to
invent an identity for a keyword that does not exist yet.

One gap, commented at the call site: unlike a star or a flag, a keyword is not
queued to the image's sidecar, because the sidecar format has no field for one.
So it reaches the user's other devices through the catalog merge, and a deleted
catalog loses keywords where it would keep ratings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:51:11 +02:00
dtourolleandClaude Opus 5 62188ec740 Keep both devices' keywords when the catalogs meet
Keywords are catalog state, and the catalog syncs. Without this, two devices
keywording the same library would resolve to whichever synced last, and an
afternoon of work would vanish with no sign it had ever happened.

The vocabulary merges per row on the rule collections already use: revision
first, timestamp only to break a tie, so a device with a skewed clock cannot win
by having the wrong idea of the time. Assignments merge as a set union, which is
FR-NC-9's principle applied to metadata instead of edit nodes — disjoint work
survives on both sides.

Three things needed care and are commented where they happen:

A deletion travels *by name*, not by identity. Both devices may have minted
their own uuid for one word before they ever synced, so deleting by uuid would
tombstone a row nothing was assigned to and leave every photograph still
carrying the word. The union then refuses to readmit a word a winning tombstone
has just removed — without that filter the remote's live assignments would
resurrect it on the very same pass.

Images are resolved by the server's file id first and the content hash second.
Membership has always used the hash alone, but the hash is computed only when
import dedup or a reconnect asks for it, which for most libraries is never — so
a hash-only union would have quietly done nothing for the ordinary photograph.

A word lands on the local default version. Version uuids do not reconcile in the
catalog at all: ensure_default_versions mints a fresh one per device, so a
uuid-keyed join would have unioned nothing.

Removal still does not propagate. That is the trade collection membership
already makes, for the same reason — an unwanted keyword is removed again in a
second, and a silently lost afternoon is not recoverable at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:51:00 +02:00
dtourolleandClaude Opus 5 2147eaa6a5 Put a keyword on a photograph, not only search for one
The catalog has been able to *find* by keyword since v1 — query.rs joins the
keywords table, matches it exactly, and substring-matches it for free text — and
nothing anywhere could ever put a word there. A user could filter to a keyword
they had no way to apply.

This is the missing half: create, rename, delete, list, assign, unassign, and
the two reads a panel needs. Bulk-only for assignment, because keywording a
selection is the common case rather than the exception — the photographer picks
out the frames with the puffin in them and applies "puffin" once, in one
transaction.

Schema v6 adds `keyword_terms`, and deliberately does *not* touch the v1 join.
The assignment keeps the word as text because the catalog is a rebuildable index
and the durable copies of that fact — the sidecar, XMP dc:subject — both carry a
string; a foreign key would mean a catalog rebuilt from sidecars had to invent
identity rows before it could record anything, and would break the query path
that already works. So the text is the fact, and the new table is only the
identity a rename and a deletion can be keyed on.

`keyword_terms.name` carries no unique index, which looks like an oversight and
is not: two devices that each type "Iceland" are both right until they meet, and
a constraint would abort the merge at that moment. Uniqueness is converged upon
instead — create resolves an existing name, fuse_duplicates collapses a
cross-device pair onto the smaller uuid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:50:49 +02:00
dtourolleandClaude Opus 5 b1e56877aa Merge integration into wip/ingest
Brings in the lens-profile and neighbourhood-operation work so the card
import is verified against what it will actually be merged into, rather
than against the tree it was written on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:49:51 +02:00
dtourolleandClaude Opus 5 939f33a3a3 Say on the import page where the photographs end up
Three lint findings and the gap the third one was pointing at.

`Context::library_label` was dead: the page named the folder on this machine
and said nothing at all about the server, which is half of what the Import
button commits to and the half that takes minutes rather than seconds. It now
names both, and states the order — copied and verified here first, then
uploaded (FR-NC-7b) — beside the destinations rather than beside the button,
because someone watching a slow upload needs to already know the photographs
are safe on disk.

The label is cached when the page opens rather than read in `render`: reaching
it goes through the context closure to the account, and `render` runs on every
keystroke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:49:02 +02:00
dtourolle c963dafd09 Merge branch 'worktree-agent-afd449f5e7a01e341' into integration
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	core/dr-pipeline/ops/README.md
#	core/dr-pipeline/src/lib.rs
#	docs/traceability.md
2026-08-22 15:35:29 +02:00
dtourolle 1526c957cf wip: ingest 2026-08-22 15:34:43 +02:00
dtourolle 7f60a2547c Merge branch 'worktree-agent-a75dc051d9bf691de' into integration
# Conflicts:
#	docs/traceability.md
2026-08-22 15:34:29 +02:00
dtourolleandClaude Opus 5 60d5504fb4 Prove on a device that the profile reaches the screen
The unit tests either side of the base curve check halves — that the shipped
database parses and lifts its midtones, and that the generated WGSL evaluates
a curve in the right place. Neither would notice if the two agreed with each
other and both were wrong: a curve packed into the wrong uniform slots, or a
flag read from the wrong component, satisfies both and renders nothing.

So render real pixels. A flat frame through a neutral edit, once with the
Canon EOS 6D's curve looked up by name from the YAML and once with the
identity, asserting what a base curve is actually for — midtones lifted,
black still black, white still white, monotone the whole way — plus the
number an unprofiled body must still produce, so "never worse than today"
is a value rather than a promise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:40:51 +02:00
dtourolleandClaude Opus 5 7407a82aa7 Let an operation read the pixel next to it, and settle where sharpening belongs
The fused pass hands a fragment a colour and no coordinate. That is what buys
one dispatch for a whole edit, and it is also a wall: sharpening, noise
reduction, clarity, texture, dehaze and spot removal are each defined by what
the neighbours are doing, and FR-DEV-3 and FR-DEV-8 ask for all six. None of
them could be written at any price.

So there is now a detail stage. An operation implements `Operation` for its
parameters exactly as before — the panel, the sidecar, the history and the
presets all work unchanged — and additionally returns `Affects::Detail` and a
`DetailStage` yielding one pass per dispatch. `Affects` grows the third variant
`docs/requirements.md:250` designed and nothing had cut.

Where the stage sits is a colour-science decision, not an arrangement of
convenience. It runs after every point operation and every mask layer, so an
amount chosen against a tone curve survives the curve moving; in linear sRGB
after the camera matrix, because camera RGB has no luminance to sharpen
against; and before the output transform and the clip, because FR-DEV-2 allows
one quantisation and a highlight clipped before a convolution grows a dark
ring. The fused pass therefore ends one of two ways, and when a detail stage
follows it hands on unclipped f16 and the last detail pass encodes.

At render resolution rather than on the source, which is the whole of FR-DSP-1:
a pass before the framing prologue would cost 24 MP to draw a 2 MP preview.
`RenderScale` is what makes that survivable — a radius is stored as a fraction
of the frame's shorter edge, exactly as a mask feather already is, or as a
count of source pixels, and converted per render. It also reports when a radius
is smaller than a proxy pixel rather than drawing a plausible lie; zooming to
1:1 makes the preview exact with no second path.

`Invalidation` gives FR-DEV-3d something to mean. Moving a detail parameter
leaves the colour key alone, so `AdjustPass` keeps the linear intermediate and
skips the fused dispatch: dragging a sharpening slider costs a convolution.
Moving exposure does re-run the detail passes, because they read what the
colour pass wrote, and there is no arrangement of keys that avoids it while
keeping sharpening after tone.

Validated by a separable box blur that is not a develop operation, behind the
`detail-probe` feature and absent from a shipping build. An abstraction with no
consumer is a guess; a box blur's answer is known in closed form, so the tests
assert every byte of the ramp rather than that the edge got softer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:39:35 +02:00
dtourolleandClaude Opus 5 743fefe7f1 Render each body through the profile its own files describe
Colour came from whichever matrix rawler happened to key `D65`, the second
one was discarded, and the rendering was left linear. That is the dcraw
default, and FR-DEV-3e names it as the reason people abandon a converter in
the first hour: correct in the abstract, flat and poor on skin in practice.

The decoder now builds a camera profile.

- `ColorMatrix1/2` and `CalibrationIlluminant1/2`. rawler surfaces these as
  an illuminant-keyed map — for DNGs from the tags, and for native formats
  from its own camera database — so a Canon CR2 arrives with a tungsten
  matrix and a daylight matrix exactly as an Adobe DNG of the same frame
  would. Dual-illuminant support is therefore not a DNG feature here.

- `ForwardMatrix1/2`, read straight from the root IFD, because rawler parses
  them and never surfaces them. Where a file carries both, they replace the
  inverted colour matrix: the same relationship measured in the direction
  rendering actually wants, rather than an inversion that amplifies the
  measurement error exactly where skin lives.

- `AsShotNeutral`, used to estimate what the scene was lit by and to
  interpolate between the two calibrations in mireds. The estimate is
  circular — the temperature needs a matrix and the matrix needs the
  temperature — so it is a fixed point, three rounds, as Adobe's SDK does it.

Bodies calibrated at neither D65 nor A stopped rendering uncalibrated as a
side effect: a Phase One IQ3 carries D55 and D75 and used to get no matrix
at all.

And a base curve, applied per channel in camera RGB between the last
adjustment and the conversion out of camera space — a toe, a steep midtone
and a shoulder, which is the difference between a photograph and a scan of
one. It is not an edit: no slider, nothing in the sidecar, because it
belongs to the body rather than to anything anyone decided, and a sidecar is
shared between bodies. It is not a develop node either, and `ops/README.md`
now records why. It evaluates on the tone curve's own spline rather than a
second copy, so a profile author placing a control point and a photographer
dragging one mean the same thing by it.

The curves are data. `core/dr-decode/profiles/base_curves.yaml` ships inside
the binary as a floor and is superseded by any copy on disk carrying a
higher `version:`, so a body can be added and distributed without a release
— and, under the GPL, contributed. The comparison runs both ways: a stale
pack cannot hold an upgraded binary back at last year's rendering.

Canon EOS 6D and R6, Nikon Z 6 and D750, Sony A7 III and Fujifilm X-T3 ship
with their own curves. Every other body gets a conservative default, which
is much closer to right than the identity is for any of them. A JPEG gets
none — it has already been rendered once, by the camera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:37:33 +02:00
dtourolle 735683b849 wip: ingest 2026-08-22 14:12:40 +02:00
dtourolleandClaude Opus 5 98e0ad0537 Regenerate the traceability matrix
FR-CAT-10, FR-NC-7a and FR-NC-7b gained implementations; the two new
requirements also raise the denominator. 46.9% to 48.6%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:01:01 +02:00
dtourolleandClaude Opus 5 125fccbb46 Put an uploaded original in its dated folder
The transport was built and had nowhere to aim: put_chunked has existed since
the connector landed, and the only things pushed through it are thumbnail
shards and the catalog snapshot.

The folder segments arrive already expanded, from dr_ingest::layout, rather
than being re-derived here. That split is what makes FR-NC-7a's first
consequence true — if this module worked out the folders from whatever the
local library happened to look like, two machines with differently organised
libraries would file the same photograph in two different places on one
server.

A name is resolved against one listing rather than a probe per candidate: a
day folder is one PROPFIND and the answer covers every collision in it, where
probing costs a round trip per attempt over a link that may be mobile data.
Two cameras both produce IMG_0001.CR3, and the second must not overwrite the
first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:01:01 +02:00
dtourolleandClaude Opus 5 7d1e6f724e Import photographs from a card
Distinct from a scan, and the distinction is the whole reason the crate
exists: a scan catalogues files where they already are, where an import moves
them from a card into the library. A scan that fails halfway has read
nothing; an import that fails halfway has written something.

So the failure paths are the design. Bytes stream at 1 MiB and are hashed on
the way past, so an 80 MB RAW never sits in memory. The second destination
(FR-CAT-10's backup copy) is written from the same read rather than copied
from the primary afterwards — a backup made by re-reading the primary would
inherit a bad write rather than catch it, and re-reading the card doubles the
wear on the one copy that still exists. Verification re-reads the
destination, because hashing what is still in memory would pass on a full
disk, a dying card and a truncated write alike. Anything that fails past the
point of creating the file takes the file back, or the next scan catalogues a
truncated RAW as though it were fine.

Three things the crate refuses to know. It never deletes from the card: a
move-import records what is now redundant and a separate retire() does the
deleting, because on a syncing library "safe" means the upload was confirmed
(FR-NC-7b). It does not decode, so capture metadata arrives through a probe
and a card of unreadable files costs no demosaic. And it does not know what a
duplicate is, since that is a catalog query — FR-CAT-11's two tiers arrive as
one closure asked twice, once before any transfer and once with the digest.

Camera serial would be the stronger metadata key and is absent, because
nothing in the tree reads it yet; make and model plus capture time and the
original filename is what is available, and the digest tier covers the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:47 +02:00
dtourolleandClaude Opus 5 37d8744db8 Let storage write as well as read
Storage enumerates and reads, which is all a scan ever needed. An import
writes, and there was nothing to write through.

WritableStorage is separate from Storage rather than folded into it, because
the two are not granted together: a card mounted read-only, or a share the
user has view rights on, should fail to typecheck as a destination rather
than fail with EROFS halfway through a copy. Ingest takes a &dyn Storage
source and a &dyn WritableStorage destination, which is exactly the asymmetry
of copying off a card.

Shaped for SAF throughout, for the same reason the read side is: a document
id is not composable, so every call takes a parent reference plus one name
and hands back the reference the provider itself produced. create_dir is
idempotent because importing a second card on the same day must land in the
folder the first made, and the naive SAF call would produce "2026-08-22 (1)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:26 +02:00
dtourolleandClaude Opus 5 c36d4c80c8 Give the calendar one reading of a capture instant
The era-based conversion sat in library_ui.rs with two readers: the
timeline's month headings and, through format_date, the exporter's {date}
token. Import folder templates are a third, and three copies of a date
calculation that could drift apart is one too many — an image filed under a
date the timeline does not show it on is a file the user cannot find.

Moved to dr_types::time, which is where shared vocabulary lives, and given
the offset-aware reading an import needs: a shot taken at 23:30 in Tokyo
belongs in Tokyo's day, and filing by UTC would split one night's
photographs across two folders at whatever hour the offset happens to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:26 +02:00
dtourolleandClaude Opus 5 5945f7420a Say where an uploaded original lands
FR-NC-7 specifies how bytes travel and deliberately has no opinion about
where they go, and nothing else said either — so the one thing a photograph
needs on arrival, a folder, was unwritten.

FR-NC-7a gives it `{yyyy}/{yyyy}-{mm}-{dd}`, and two consequences the
transport mechanics do not supply on their own: expansion is a pure function
of capture metadata, so two devices computing a destination for the same
frame agree; and the template governs placement on upload only, because the
remote library is reachable by other clients and restructuring it under them
is not ours to do.

FR-NC-7b joins import to upload. A move-import must not erase the card until
the upload is confirmed — a card erased against an in-flight transfer is the
one failure in this application with no undo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:13 +02:00
dtourolle 610f679881 Reconcile the three branches
Two faults textual merge could not see. Both agents added a `mod tests` to
masks_ui.rs — the module boundary was an artefact of them being written
apart, and the tests do not overlap, so they fold into one. And `segment`
lost its context argument when the work moved to a worker, which a test
written on another branch still passed.
2026-08-22 13:33:55 +02:00
dtourolle 5bcd0e0269 Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts:
#	core/dr-pipeline/tests/mask_sidecar.rs
2026-08-22 13:23:34 +02:00
dtourolle ae32ed7974 Merge branch 'worktree-agent-afa2042c919d0d111' into integration 2026-08-22 13:23:09 +02:00
dtourolleandClaude Opus 5 96a7b405c2 Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's
chain. Same panel, same order, same sliders, different meaning — and the only
thing that said so was a sentence in the panel above, which a photographer
reaching for the exposure slider has no reason to read. An exposure change
lands on the whole frame when it was meant for a face, or the reverse; both are
silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the
dangerous one and it is: the others in that document cost time, this one costs
work.

The remedy is the classic one for a modal fault — make the mode visible — and
the application already had the pattern. Crop arms a canvas interaction, draws
an overlay, gives the column one job and is left by the control that entered
it. Local masking is the same animal built as a peer panel, and that is what
created the ambiguity. So `crop-mode` stops being a bare boolean and becomes
one value of a three-state mode, which is the point: two modes could both be on
before, and now that is not a state the interface can be in rather than one it
is tested against.

**One strip, not two.** The mode control was going to sit beside the group
strip that filters the adjustments, which is two controls above one column
answering the same question — what am I working on. They are one control now,
`Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's
bottom strip has for the same reason. The two halves are different kinds of
state and are drawn differently: a mode is a chip that fills with the accent
when it is on, a group is a word with a rule under it. That difference is what
lets both be read at once, which they routinely are — picking Light while a
mask is selected filters *that layer's* chain and does not leave the mode.
Dropping the scope on a group press would be the same fault coming back from
the other end, and would make Light mean two things depending on where it was
pressed.

The strip stays pinned above the develop column rather than moving to the top
of the canvas as the document proposed. The half that filters the column
belongs to the column, and the photograph is the subject. The canvas keeps one
button, which now names the mode it leaves rather than saying "Done" — that was
unambiguous with one mode and would not be with two — because the column can be
closed on a narrow window and no mode may be inescapable.

Entering a mode is a side effect, so Rust owns it rather than the strip writing
the property: crop drops the zoom, local turns the overlay on, and leaving
clears the selection. That last one is the fix. The "Overlay" and "Select"
toggles are gone because they armed things that are simply what the mode *is* —
a mode that has to be switched on separately is one you can enter and have do
nothing. Escape and the Android back gesture join `back_step` as one
`LeaveMode` rather than a second exit concept, and the mode is left before the
zoom is: it was entered later, and it is the bigger step back.

The heading is where the scope goes. Not a caption beside the panel, the
heading *of* the panel that changed — `ADJUST` becomes the layer's name, the
same string the selected row in the stack shows. That is the difference between
describing a hazard and removing it.

**Handles on the photograph.** A linear or radial mask could be created and
then not moved, so a radial sat at the centre of the frame at its default size
for ever. Three faults stood in the way of drawing one.

The first is that a gradient did not render at all until the model had run. The
rasteriser was built on the way out of `segment` and the array's size was read
*off* the segmentation, so a gradient added to an unsegmented photograph
produced nothing — silently, in the same way exports and thumbnails once did:
the shader still emits the layer's block and the empty placeholder multiplies it
by zero. The proxy size is a property of the photograph. Both are derived from
it now, and deliberately at the same size rather than by coincidence, because a
subject's distance field is sampled against that array.

The second is hit-testing. A handle is drawn in output coordinates and stored
in source ones, and between them lie the crop, the zoom, the pan, the
straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated
on the CPU, kept in that file beside it so that keeping the two in step is one
file's problem — a handle mapped through anything less drifts off the mask the
moment the view moves, which is exactly what masks are rasterised in source
space to avoid.

The third is that a drag is a displacement, not a destination. Each handle
answers to the movement of the pointer since the press, applied to where the
mask was when the press landed. Snapping the handle to the pointer instead
jerks it by up to half a touch target on the first press, and the target is
finger-sized because a tablet has no hover to reveal a control and no modifier
to qualify it.

A ramp gets three handles — centre, width, angle. An ellipse gets three too:
centre and one per semi-axis, the major one carrying the direction as well as
the length, because where an axis is put says both. It had a fourth, and it is
gone: standing off the shape by a fixed distance, the rotation arm began
outside the photograph at the size a new radial is created at, so the first
thing anyone saw was a control they could not reach without first shrinking the
mask.

Two faults here were found by looking at the screen rather than at the source,
both of the kind that cannot be found any other way. A `1px` rule with a size
and no position is *centred* by Slint, so the seam between the photograph and
the column was a hairline down the middle of the panel, through the histogram
and every slider under it — twice, once in `app.slint` and once in
`AdjustPanel`. And handing Slint a fresh model for the handles on every pointer
event made the repeater rebuild its items, taking the `TouchArea` holding the
gesture with them: the handle jumped once and then went dead under a finger
that was still down. `develop.rs` carries the same warning about the parameter
rows, where it broke slider drags; the model is rewritten in place now.

The tests worth having are the ones about ambiguity and about the map. That the
same row reads the frame's value, then the layer's, then the frame's again is
§1.1 in one assertion. That dragging a handle onto another gradient's matching
handle *produces* that gradient closes the loop between the two directions of
the framing map, through a view that is cropped, zoomed, panned, straightened
and quarter-turned at once — a one-legged map is invisible when the framing is
neutral, because then both legs are the identity.

Not done here: the histogram still reports the whole frame while the sliders
edit a layer. That disagreement is real and is N3's, which this unblocks. The
strip has room for a Brush entry beside Crop and Local when the painted masks
land in the core, and it needs nothing here but the canvas interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:20:41 +02:00
dtourolleandClaude Opus 5 c75863c93f Give a gradient the angle it was asked for
A linear mask at 45° was not at 45°, and a radial with equal radii was an
ellipse. Both on every photograph that is not square, which is all of them.

The geometry is stored in normalised coordinates so that a mask survives a
crop, a zoom and an export at another size — that part was right. What was
wrong is that a *distance* was being measured in those coordinates too, and a
fraction of the width is not the same length as a fraction of the height. So
`dot(uv - centre, axis)` measured the ramp in a space one of whose axes is
squashed against the other by the aspect ratio, and the iso-lines came out
sheared: on a 3:2 frame a ramp asked for at 45° arrives at about 34°.

Nothing announces it. The stored numbers are exactly what was written, the
shader is doing exactly what it says, and the only place the fault exists is
between the photographer's intent and the picture. It has been invisible so far
because there is no way yet to place a gradient by eye — the handles that make
it visible are what turned it up.

So distances and angles move into the frame's own isotropic units: y spans
`0..1` and x spans `0..aspect`, which makes a circle round and 45° a real
diagonal. The centre stays a plain fraction of each axis, because it is a point
and a point has no such problem — and because that is the space a click arrives
in. `frame_delta` is the one conversion and must stay the only one; the mask
array's own dimensions carry the aspect, so it costs no uniform.

The sidecar format does not change. What changes is what the numbers mean, and
the only geometry in the wild is a default that has never been movable.

The two tests are at 96×64 rather than square, which is the whole point: on a
square target this bug cannot be reproduced, and every existing mask test was
square. Both fail without the conversion — the radial reaching 28px sideways
where it reaches 19px down, and the diagonal landing on the wrong side of the
line it is supposed to lie along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:19:55 +02:00
dtourolleandClaude Opus 5 c396a22dfd Paint a mask without ever rasterising one on the CPU
The last line of FR-DEV-3, and the mask ARCH §5.4 was written for. darktable
rasterises drawn masks on the CPU and users call the result unworkable; the
architecture's answer is that a stroke arrives as *parameters* and the device
draws it. This is that, from the model through the sidecar to the pixels — but
not the finger: the canvas is somebody else's change, and this leaves it a
seam rather than reaching into it.

**A stroke is a swept disc along a polyline**, plus erase, radius, hardness and
flow. `MaskSource::Brush` holds an ordered list of them, and the order is the
mask: an erase after an add takes it away and the same pair reversed does not.
Nothing about it is pixels, which is what makes a mask that costs a line of
text, diffs by the gesture, and survives a crop, a straighten and an export at
any size — the properties a stored raster has none of, and the same argument
the region ids were chosen for.

Two things keep the point count honest. While the finger is down, a position
closer to the last than an eighth of the radius is dropped: a touch screen
reports 120 a second, so a finger held still for five seconds is six hundred
points in the same place, and simplification would only remove them once the
gesture had ended — after every frame in between had drawn all of them. When
it ends, Douglas–Peucker at an eighth of the radius removes what a disc that
wide cannot express: a swept circle moved by r/8 moves its own edge by r/8,
which is inside the soft part of any brush. Coordinates snap to a
ten-thousandth of the frame on the way in *and* are written at that precision,
so a round trip is exact rather than nearly exact — a file that drifts in the
sixth decimal every save is a per-field merge conflict a day, over nothing.

**Cost is why the strokes are not drawn by the full-screen triangle the other
masks use.** A swept disc is the minimum distance to any of its segments, so a
stroke over the whole frame costs `pixels × segments` and both terms grow
together — the quadratic that is darktable's problem moved onto the GPU rather
than solved. Each stroke is instead drawn over its own bounding box, grown by
the radius, so the rasteriser never invokes the shader for a pixel the stroke
cannot reach: `area(box) × segments`, which for a dab or a swipe is a small
fraction of the frame. A gesture past 256 points continues as a second stroke
for the same reason, since a shorter stroke has a smaller box.

Add and erase are `dst + a(1 - dst)` and `dst(1 - a)`, which are exactly a
source-over and a one-minus-source blend — so they are blend state, not
arithmetic, and no pass ever reads the slice it is writing. That is what
permits one draw per stroke at all. Within a stroke the coverage is the
*minimum* distance over its segments rather than a sum: a path that crosses
itself must not build up where it did, or every circle and every scribble
would be blotchy wherever consecutive dabs overlap, which is everywhere.

Not a distance field, deliberately. `dr-segment`'s transform documents the two
conditions that make CPU work right there — once per mask edit, over input
already CPU-side — and a stroke fails both: it changes while the finger moves,
and its input is a handful of coordinates that never needed to be pixels. It
also needs no transform, because the distance to a swept disc is closed form.
A stroke is the one mask whose distance field is known without computing one.

An unpainted brush layer is inactive rather than empty, which is not an
optimisation: `invert` turns empty into everything, so a layer created with
invert already set would apply its adjustment to the whole photograph before a
single stroke was made. That is the loud, confident kind of wrong this codebase
refuses everywhere else a mask can go missing, and there is a rendered test for
it.

The tests read pixels back off a device rather than checking that the two
halves agree with each other. What they pin down is what is silent when wrong:
the y flip between mask space and clip space, which a centred stroke would not
notice; a bounding box not grown by the radius, which makes a tap draw nothing
at all; an aspect ratio ignored, which makes a dab an ellipse on any frame that
is not square; a stroke doubling back and building up; and an erase that lost
its place in the order and put back paint the user had taken off.

Not done here: the interaction. The canvas needs to begin, extend and end a
stroke on the active layer, and `DevelopSession::rasterise_masks` still returns
early without a segmentation — it takes the proxy size from one, and a brush
needs no model to have run over the photograph first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:37:42 +02:00
dtourolleandClaude Opus 5 c0e1179936 Find the subjects without stopping the window
"Find subjects" took the UI thread for two thirds of a second on a 22 MP
frame — a proxy render, a readback and a YOLO pass through `ort` — and for
that time the interface was simply gone. The panel apologised for it rather
than hiding it: a "Looking…" label, and a 16 ms `single_shot` so the label
reached the screen before the freeze began, with a comment saying the obvious
fix needed the develop session restructured and was not being taken.

The obstacle was never `Send`. `DevelopSession` is `Send` — the device, the
source texture and the passes all are. What cannot go to a worker is the
`Rc<RefCell<Option<DevelopSession>>>` that every callback in the window
reaches through, and the window has to keep reaching through it while the work
runs. Handing the session over would freeze the interface exactly as
thoroughly as blocking on it did.

So the work takes a copy of what it needs instead. A `SegmentationJob` is the
device, the demosaiced source behind an `Arc`, and the name of the session
that asked. Taking one is two `Arc` bumps; running one is 495 ms on this
desktop; none of it touches the session, and there is deliberately no
`&mut DevelopSession` in scope for a caller to hold across it. The proxy
render travels with it rather than staying behind — a `GpuContext` and a
texture handle are both `Send`, and the model was never the only expensive
half. So does building the mask rasteriser, which is a shader compile:
adopting the result was costing 23 ms, a dropped frame on the one redraw the
user is waiting for, and the rasteriser is needed exactly when the subjects
arrive and never before. What is left on the UI thread is a microsecond.

The answer comes back through a channel a `slint::Timer` polls, which is the
shape `apply_when_ready` already uses for a sidecar fetch.

**A result can outlive the photograph it describes.** Two thirds of a second
is long enough to press the button, think better of it and swipe to the next
frame — and the result landing then would fill the panel with subjects that
are not in the picture, drawing outlines around a dog two photographs back.
Nothing downstream can tell: the masks rasterise and the overlay draws either
way. So every session is minted with an id, a job carries the id it was taken
from, and `delivery` compares the two before anything is applied. An id
rather than a counter beside the session slot, because that slot is written
from four places in `lib.rs` and the fifth would be the one that forgot.

A discard touches nothing on the way out. `segmenting` belongs to whichever
photograph is open now, which may well have a run of its own going, and
clearing it would re-enable a button that is correctly insensitive.

One run at a time, and abandonment is what stops that being a trap. A job
left over from a photograph the user has left is displaced rather than waited
for — otherwise the next frame's "Find subjects" would do nothing for the
length of a run nobody wants, which is the wait this exists to remove. `ort`
offers no way into the inference, so abandoning is checked at the seams there
are: before the job starts, and between the readback and the model. Abandoned
early it costs nothing, abandoned mid-inference it costs the run it was
already committed to, and either way the answer is dropped at the channel.

`DevelopSession::segment` survives as a test-only convenience. Left public it
is precisely the shape that put two thirds of a second on the UI thread in the
first place, and the next caller would reach for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:26:11 +02:00
dtourolle 586698db00 Give the strip a layout to sit in
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 1m2s
🐳 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 9m38s
The develop column's panel is a Rectangle, not a layout — a note four lines
above explains why it is not an `if`. Two children of one therefore both sit
at its origin, so pinning the strip beside the Flickable overlapped them and
collapsed the whole develop view to a sliver.

Wrapped in a VerticalLayout. The strip is pinned by being outside the
Flickable rather than by any coordinate, which is what keeps it working at
any column height.

Caught by screenshotting the device rather than by the build, which was
clean throughout — a Slint layout fault is invisible in the source and
obvious the moment anyone looks.
2026-08-22 10:57:08 +02:00
dtourolle 9a7b045df4 Pin the group strip where it can be found
It was inside AdjustPanel, which on a tablet put it below five other panels
and off the bottom of the screen: present, working, and unreachable without
scrolling past everything it exists to save you scrolling past. A control
that answers "where is everything else" cannot itself be somewhere else.

Now a GroupStrip above the scrolling column, so it never scrolls away. It
still names no group — the strings arrive resolved from whatever the
operations declared themselves to be about.
2026-08-22 10:46:35 +02:00
dtourolle 1d7106c94d Apply the masks to the thumbnail and the export, not only the screen
Reported as a thumbnail bug; the export had it too, which is the serious
half. You would have exported a photograph missing every local adjustment.

Both called the unmasked `render`, and the failure is silent by
construction: the generated shader always declares the mask binding and
always emits a block per active layer, so binding the empty placeholder
multiplies each of them by zero. No error, no warning, no missing texture —
the adjustments are simply not there. From inside either path there is
nothing to see.

Every path that produces pixels now goes through one helper that binds the
array, and that is the point of it being one helper rather than three
correct call sites. The array is rasterised in source space at proxy size
and sampled through the framing map, so one array serves every output size:
a 256px thumbnail and a 24 MP export bind the same texture.

Three tests, and the first is the fault stated directly — render the same
edit with and without the array and assert they *differ*. If binding it ever
stops mattering, the masks have stopped reaching the shader. The third
checks the masked share of the frame is the same at 32px and 128px, because
"both non-empty" would pass while a mask that scaled wrongly still ruined
every thumbnail.
2026-08-22 10:38:49 +02:00
dtourolle 924a837389 Group the panel by what operations say they are about
A strip of groups over the adjust panel — Light, Colour, Detail — derived
from the attributes the operations declare. `adjust.slint` names none of
them: the strings arrive resolved and the panel only draws them, so a new
operation joins the right group by saying what it is and this file does not
change (FR-DEV-3a).

A group nothing carries is not offered, so a tab never opens onto nothing.
Geometry is left out because its one operation prefers an on-canvas widget
and is skipped by the row builder — a Geometry tab would be empty while
`GeometryPanel` holds the real controls. The strip appears only when there
is more than one group to choose between; a single tab is a control with one
option.

The selected group is underlined rather than filled. The accent means
*modified* everywhere else in this interface, and spending it on "which tab"
would blunt the one signal the panel has.

**The trap, and it nearly bit again.** `op_index` on a row counts over every
capability, not over the ones a filter kept — it is how a row routes back to
the core. Renumbering it while filtering would make a slider drive a
different operation, which looks like a rendering fault rather than a
routing one. `rows_filtered` keeps `enumerate` over the full list and only
`group_head` is a position within the emitted rows; a test moves a value
through a filtered row and checks it lands where it was asked to.

Six tests, including that a nonsense index falls back to showing everything
rather than to showing nothing.
2026-08-22 10:22:47 +02:00
dtourolle 7421837c8a Let an operation say what it is about, so the panel can group without naming
Tool tabs need a taxonomy, and the taxonomy was the problem: a table in
`ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks
what `ui-refinement.md` condemned `starts-group` for — the core deciding
where the panel draws things.

`Attribute` threads the needle. It says what an operation *is* — tone,
colour, detail, optics, geometry, effect — which is the same category as
`ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is
drawn, where it sits and whether it is visible stay the frontend's. There is
no attribute for "the third tab", the enum's order is declaration order
rather than screen order, and a frontend may render these as tabs, as
headings, or ignore them.

The payoff is that a tab strip can be *derived*: the groups are the
attributes present in the capability list, so the interface names no
operation and needs no table to keep in step. An operation joins the right
group by declaring what it is, which is the one thing its author is well
placed to say.

Plural, because the tone curve is genuinely both — an RGB curve is tonal and
the per-channel curves are chromatic, and filing it under one would hide it
from half the people looking for it.

Required and non-empty, enforced in `build.rs`, and the failure was checked
by removing the line rather than assumed. An operation with no attribute is
invisible to a panel that groups by them; a build that stops costs ten
seconds, a control nobody can find costs more. The vocabulary is closed for
the same reason: a typo would otherwise invent a category holding exactly one
operation, which looks like a deliberate one until somebody counts.

Six tests over the real chain, including the hand-written operations that
`build.rs` never sees and so cannot check.
2026-08-22 10:10:00 +02:00
dtourolle acd694c2cf No phone, so stop designing for one
Targets are a 12-inch tablet and a desktop (D15). That removes most of the
navigation question rather than answering it.

`EXPANDED_MIN_WIDTH` is 820 logical pixels and a 12-inch tablet is ~1024
across in portrait, so both orientations of both targets are the expanded
class. The compact class now fires only when a desktop window is dragged
narrow — graceful degradation, not a second interface. The bottom tool strip
and the one-tool-at-a-time sheet were solving a phone, and there is no phone.

What survives is input, not size, and the architecture had already decided
it: `WidgetDemand::precise_pointing` exists for a television remote, and its
own documentation says touch is fine because hit regions grow to the
modality. Touch changes hit regions, not layout. The rules that fall out are
worth stating because they are easy to violate by accident — no hover-only
affordance and no modifier key may be the sole route to anything, since a
tablet has neither. Local masking already lost its shift-click extend for
exactly this reason.

The guaranteed-wide viewport also pays for a better answer to the extent
problem than hiding things. The complaint was never that the column is long;
it is that the histogram scrolls away from the sliders it reports on.
Collapsing shortens the scroll, pinning removes the problem, and ~260px of
fixed height is affordable on a viewport that is never under 820 wide.
2026-08-22 09:55:09 +02:00
dtourolle 85dafd78b7 Work out where things go, now that there are many of them
Local adjustments took the develop column from four panels to six, and the
operation set is meant to keep growing — FR-DEV-3 still lists texture,
clarity, sharpening and noise reduction as v1. But the count is the lesser
problem.

The real one is that local adjustments introduced a *mode* without
introducing a way to see it. Selecting a mask silently re-points thirty
sliders at that layer, and the histogram those sliders are judged against
goes on reporting the whole frame. I built that, and it is the fault that
loses work rather than merely slowing someone down.

Decided: local becomes a mode, in the sense `crop-mode` already is. The app
has the pattern, the user knows it, and it removes the ambiguity by
construction instead of describing it in a caption. It also inherits the
Escape/back stack that already leaves the innermost state first.

Recommended: diverge on **width**, not on platform. A tablet in landscape
wants what a desktop wants and a narrow desktop window wants what a phone
wants, so `cfg(target_os)` would give one physical situation two answers.
`apply_layout_class` already classifies on width and already remembers a
per-class override; this is a second consumer of a decision the app makes
anyway. What must not diverge is the controls — both layouts consume the
same generated capability model, so a new operation still needs no UI edit.

Left open, because it is taste: whether the wide layout eventually gains
tool tabs. Recorded rather than left to be rediscovered is the *legitimate*
route to them — a descriptor declaring an operation's nature, the same shape
as `Affects`, with the frontend free to render it as a tab or ignore it.
Deferred because ten operations do not need eight tabs and the field is easy
to add later and awkward to remove.

Surveys what Lightroom, Capture One, darktable and the phone editors
actually do, including the thing none of them do: make a mask a panel that
rewires a different panel.
2026-08-22 09:52:08 +02:00
dtourolle a881fa3693 Use transform-rotation, which is what Slint calls it now
rotation-angle is deprecated in 1.17 and was the only warning the build
still emitted.
2026-08-22 09:03:59 +02:00
dtourolle e7dbdeb21f Keep the overlay on the photograph when the view moves
The overlay is a source-space picture; the canvas beside it shows whatever
the crop, the zoom and the pan selected out of that same space. Drawn whole
it stayed frame-sized while the photograph moved underneath, so zooming in
left a map of the whole picture stretched over a detail of it.

It now reports the visible rectangle as a clip, which the compositor
applies for nothing. Resampling on the CPU instead would mean rebuilding a
megapixel image on every frame of a drag, and putting it on the GPU would
add a second texture to keep in step with the view.

Pushed from the render path rather than the panel's sync: a pan changes no
mask and no row, so nothing else needs to run, and rebuilding the row
models on every frame of a drag would be waste.

Straightening is handled by rotating the image. A quarter turn or a flip
permutes the axes and a clip rectangle cannot say that — noted where it
happens rather than left to be discovered. The proper fix is to run the
overlay through the same shader prologue the photograph goes through, which
is the right answer and a larger one than this.

Four tests, and the one that matters asserts the clip *narrows* when zoomed
— which is precisely what it failed to do.
2026-08-22 08:53:02 +02:00
dtourolle 37f63edbf9 Take the watershed out of the product path
It does not work on a photograph, so nothing should offer it. `Segmentation`
is now one model pass and what it recognised: no region field, no merge
tree, no label upload, no granularity slider, and no readback of the whole
proxy to build a graph that collapses.

A click means "the object under the cursor". The region-selection path went
with the hierarchy it indexed — including the shift-click add/subtract,
which has no meaning for a whole object and would have been a modifier that
silently did nothing.

The passes, the hierarchy and the semantic prior stay in `dr-gpu` and
`dr-segment`, tested and documented. It is the *merge criterion* that
fails — the saddle is the minimum gradient along a boundary, so one weak
pixel merges two regions and real gradient noise puts a weak pixel on every
boundary. That is one function to replace, and the evidence for replacing it
is worth keeping. What is gone is the wiring, the option, and the control
that offered a user a choice with no outcome.

`MaskSource::Regions` remains in the pipeline: it is tested, it round-trips
through the sidecar, and a stored layer that names regions must still load
and be reported stale rather than failing to parse.
2026-08-22 08:39:17 +02:00
dtourolle 6163b63895 Put the edge controls where the mask is
Feather, falloff, grow/shrink/close/open and their amount, on the selected
layer. All four read the one distance field, so all four are live — nothing
recomputes except a compound morphology, and the session keys on that
separately so a feather drag rebuilds nothing.

Shown only for sources that go through the distance field. A gradient
carries its own falloff in its geometry, and offering a second one would be
two controls fighting over the same edge.

Picking an operation seeds a small amount if none is set. Selecting "Grow"
and seeing nothing happen would read as a broken control rather than as a
radius of zero.
2026-08-22 08:39:17 +02:00
dtourolle ec713585a5 Measure the distance to the edge, and get four controls for one transform
Feathering, growing, shrinking, closing and opening are the same number
read differently. With the signed distance from the boundary in hand,
dilation is the set where d >= -r, erosion where d >= +r, and a feather of
any shape is a function of d. So the field is computed once and the
controls are arithmetic on it.

The **field** is what reaches the GPU, not a finished alpha, and that is
the point: growing a mask or changing its falloff then costs a uniform
upload and no recomputation, which is what makes them live controls rather
than ones that stall on every drag. Only closing and opening rebuild,
because after the first threshold the shape has changed and the old
distances describe the old one.

Exact Euclidean, via Felzenszwalb's separable transform — not a chamfer
approximation, which leaves a mask visibly octagonal once grown more than
a few pixels. A test asserts the diagonal is √2 rather than 1 or 2.

It runs on the CPU, which ARCH §5.4 forbids for masks. The rule is about
brush lag — a stroke rasterised per frame — and this is a different
operation: once per mask edit, on input the model already produced here,
producing a field the GPU then samples for free. What it buys is exact
determinism, which matters because masks reach the sidecar as indices and a
field that varied by vendor would mean a mask meaning one thing on the
desktop and another on the phone.

The half-pixel in `signed_distance` is not a detail, and a test caught it.
Measuring to the nearest opposite pixel *centre* puts the smallest
magnitude at 1 either side, so the boundary is nowhere and **eroding by
less than a pixel removes nothing**. A control whose first notch does
nothing is a broken control. Half a pixel off each side puts the boundary
where it physically is, and eroding by 1 takes exactly the outermost ring.

Every falloff curve is 0.5 at the boundary by construction, asserted for
all five: changing the curve should change how the transition looks and
never where it sits.
2026-08-22 08:39:17 +02:00
dtourolle ee10097435 Mask the subject the model found, not the regions underneath it
The watershed hierarchy does not survive a photograph, so local masking
stops depending on it. A layer can now be one recognised object, and the
object's own coverage is the mask.

`Options::watershed` defaults off. It costs ~80 ms plus a full-resolution
readback to produce a ladder that collapses, and paying that on every
photograph buys a control that misleads. Kept switchable rather than
deleted: the passes and the hierarchy are correct in themselves and it is
the merge criterion that fails, which is a change to one function.

Masks now rasterise in **source** space at proxy resolution and are sampled
by the composed shader after the framing map. That fixes a real bug: they
were rasterised in output space, so zooming slid the photograph underneath
a mask that stayed pinned to the viewport, and cropping moved every
adjustment to a different part of the picture. Doing it this way also
leaves the framing map in exactly one place — a second copy in the mask
shader would have been a second thing to keep in step, failing only when
straightened.

A subject is stored as identity, not pixels: the mask is megabytes and is
reproducible by running the same model over the same image, so the sidecar
carries the index, the class and the score, and the session carries the
pixels. The class is there to be checked — if instance 3 comes back a "car"
where it was a "dog", something changed and the layer is stale rather than
silently masking the wrong thing.

The overlay now draws instances and is transparent everywhere else. The
region version covered every pixel and so hid the photograph it was drawn
over; the question it exists to answer is whether an outline follows the
subject, which you can only answer by seeing both.

`examples/local.rs` is the worked example: subject in colour with the rest
monochrome, and the subject lifted out of its background. Run on a 5472x3648
CR2 it finds two people and two cars, and the colour-pop keeps her hat and
hair while the wall and grass behind go grey.
2026-08-22 08:39:17 +02:00
dtourolle b1433ad4a9 Give a mask an edge treatment, and find out the watershed has none worth having
Two things, and the second is why the first matters more than expected.

Mask layers gain a feather, a falloff curve and a morphology, all defined
against a signed distance from the boundary rather than as separate
features — one exact distance field answers "how soft" and "how far" at
once, so dilation is a threshold at -r, erosion one at +r, and closing and
opening are one of each in sequence. The compound pair costs a second
distance field, which is why they are named rather than presented as a
radius that happens to be signed. Types, defaults and sidecar round-trip
only; the field itself is next.

`edge-feather` and `edge-falloff`, not `feather` and `falloff`, because a
radial mask already writes `feather` for the fraction of its radius it
ramps over. Same word, different quantity, different units — sharing the
key would have made an existing file ambiguous.

The diagnostic that provoked this is committed as an ignored test, because
"does the ladder land on things a person means" is the question S15 exists
to answer and it should not depend on whoever still has the script. On
bus.jpg it answers badly: 35,075 regions at blur 2 over an 810x1080 frame,
and cutting that to 400 gives *one* region covering nearly the whole
picture plus 399 noise specks. Not over-segmentation — collapse. Almost
every saddle is near zero, so the merge order joins everything meaningful
before it joins anything spurious, and a global cut spends its entire
budget on grain.

So the granularity ladder does not currently work on a photograph, and the
region masks built on it inherit that. Recorded rather than worked around:
the next commits move local masking onto the model's instances, where the
edge treatment above is what makes a quarter-resolution mask usable.
2026-08-22 08:39:17 +02:00
dtourolle 12d320cf33 Record what the spec got wrong about the model that exists
docs/segmentation.md §4 priced arm B as costing a C dependency under the
NDK and treated that as most of the difference between the arms. It is not
a cost that has to be paid: `ort`'s `alternative-backend` disables its
linking entirely and `ort-tract` supplies the API from tract, which is pure
Rust. D13's "largest exception the policy would tolerate" turns out not to
be needed, and the answer generalises to the face pipeline — so D13's
runtime half is now answered and only its licensing half is open.

Three findings contradict §4 outright and are recorded as F4-F6 rather than
quietly designed around. There is no ADE20K-trained YOLO, so the shipped
vocabulary selects subjects and not stuff — "select the sky" comes from the
watershed or from nowhere. It is instance segmentation, so it partitions
nothing and two people come back as two instances. And tract cannot parse a
dynamic-shape export, which fixes the input at 640 square and makes tiling
the only route to more semantic resolution.

Arm C ships, but §8's criteria are not what decided it, and saying so
matters more than claiming the process worked. §8 asked for a two-
interaction margin over arm A on a traced corpus. That comparison was never
run: F4 and F5 changed what the arms are, and a model that recognises
subjects but has no word for sky cannot be a selection tool alone, while a
watershed cannot tell a person from the wall behind them. They stopped
being candidates and became complements.

What is *not* done is written down as plainly: the 24-image corpus is
untraced, so M1-M4 have no numbers and "this feels right" has not become
one. M5 is answered on one device only, and region ids now reach the
sidecar — so a cross-vendor divergence would mean a mask written on the
desktop meaning something else on Android. F3 stands.
2026-08-22 08:39:17 +02:00