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.
This commit is contained in:
2026-08-22 08:39:17 +02:00
parent b1433ad4a9
commit ee10097435
13 changed files with 963 additions and 101 deletions
+2
View File
@@ -821,6 +821,7 @@ export component AppWindow inherits Window {
in property <bool> segmenting: false;
in property <int> segmentation-level: 300;
in property <int> segmentation-regions: 0;
in property <bool> has-regions: false;
in property <bool> editing-mask: false;
callback segment-image();
@@ -1903,6 +1904,7 @@ in property <bool> panel-visible: true;
segmenting: root.segmenting;
level: root.segmentation-level;
region-count: root.segmentation-regions;
has-regions: root.has-regions;
overlay: root.overlay-on;
picking: root.region-picking;
editing-mask: root.editing-mask;
+29 -6
View File
@@ -171,6 +171,13 @@ export component MaskPanel inherits Rectangle {
/// How many regions the ladder is currently cut to.
in property <int> level: 300;
in property <int> region-count: 0;
/// Whether a region hierarchy exists to click into.
///
/// Off in the ordinary case. The watershed's ladder collapses on a
/// photograph, so the controls that drive it are hidden rather than shown
/// doing nothing — a slider that changes no outcome is worse than an
/// absent one, because it invites the user to blame themselves.
in property <bool> has-regions: false;
/// Draw the false-coloured region map over the photograph.
in property <bool> overlay: false;
@@ -213,7 +220,12 @@ export component MaskPanel inherits Rectangle {
HorizontalLayout {
PanelHeading { text: "LOCAL"; }
Rectangle { horizontal-stretch: 1; }
if root.segmented: Value { text: root.level + " / " + root.region-count; }
if root.segmented && root.has-regions: Value {
text: root.level + " / " + root.region-count;
}
if root.segmented && !root.has-regions: Value {
text: root.subjects.length + (root.subjects.length == 1 ? " subject" : " subjects");
}
}
if !root.enabled: Caption { text: "No image"; }
@@ -224,7 +236,7 @@ export component MaskPanel inherits Rectangle {
// and inference is too long to spend on every photograph opened, and
// most edits never need one.
if root.enabled && !root.segmented: Caption {
text: "Find the regions in this photograph, so a mask can snap to them.";
text: "Find the subjects in this photograph, so a mask can follow one.";
// Wrapped, or this sentence's single-line width becomes the
// panel's minimum and levers the fixed 280px develop column open,
// taking every other panel's controls off the right edge with it.
@@ -232,7 +244,7 @@ export component MaskPanel inherits Rectangle {
}
if root.enabled && !root.segmented: Button {
text: root.segmenting ? "Finding regions…" : "Find regions";
text: root.segmenting ? "Looking…" : "Find subjects";
enabled: !root.segmenting;
primary: true;
clicked => { root.segment(); }
@@ -256,11 +268,16 @@ export component MaskPanel inherits Rectangle {
}
}
if root.enabled && root.segmented && root.picking: Caption {
if root.enabled && root.segmented && root.picking && root.has-regions: Caption {
text: "Click the photograph to select a region. Shift-click to add or remove.";
wrap: word-wrap;
}
if root.enabled && root.segmented && root.picking && !root.has-regions: Caption {
text: "Click a subject in the photograph to mask it.";
wrap: word-wrap;
}
// Granularity, labelled by what it does rather than by its number:
// "detail" is what a photographer is choosing between, where "300
// regions" is an implementation detail they would have to learn.
@@ -268,7 +285,7 @@ export component MaskPanel inherits Rectangle {
// and a photograph that segments into more than that had the finest
// part of its own ladder unreachable — the slider simply stopped
// before the regions did.
if root.enabled && root.segmented: SliderRow {
if root.enabled && root.segmented && root.has-regions: SliderRow {
label: "Detail";
hint: "How finely a click divides the picture, out of "
+ root.region-count + " regions the watershed found.";
@@ -286,7 +303,7 @@ export component MaskPanel inherits Rectangle {
// A list of four beside a count of three thousand invites exactly
// one question, and the panel should answer it rather than
// provoke it.
text: "Subjects the model recognised. Click one to select the whole thing.";
text: "Click one to mask it. The outline is the model's, so soften or grow it below.";
wrap: word-wrap;
}
@@ -311,6 +328,12 @@ export component MaskPanel inherits Rectangle {
}
}
if root.enabled && root.segmented && root.subjects.length == 0: Caption {
text: "Nothing recognised. The model knows people, animals and vehicles — "
+ "a landscape has no subject for it to find. Add a gradient instead.";
wrap: word-wrap;
}
Rectangle {
height: 1px;
background: Theme.rule;