Files
DarkRoom/ui/dr-ui/ui/masks.slint
T
dtourolleandClaude Sonnet 5 b3641b5307 Let a subject's mask be refined, and let several masks be edited at once
Two related gaps in the mask panel, from the same conversation: a subject's
outline is only ever as sharp as the whole-frame pass that found it, and a
change meant for several layers had to be dragged once per layer.

## Refine mask

A "Refine mask" button on a subject layer re-runs detection on a padded crop
around that instance's own box instead of the whole frame — the subject
reaches the model at its own size rather than squeezed into the model's fixed
640x640 window alongside everything else in the photograph. `RefineJob`
mirrors `SegmentationJob`'s split (built on the session, run off it, adopted
back), and the crop itself is rendered through `Framing::set_view` — the same
ephemeral viewport the interactive zoom already uses to render a region above
proxy resolution, so no new render path and no change to the model's own
input size was needed. `dr-segment` is untouched: `Tiling::Whole` already
treats whatever buffer it is handed as the one window.

The result is still downsampled onto the shared proxy grid every instance's
mask lives on, but from a sharper source than the whole-frame pass ever saw
for that subject, which is what the edge actually reads out of.

## Multi-select

`active_mask: Option<String>` is now `active_masks: Vec<String>`. A plain
click still replaces the selection; a control- or command-click toggles one
layer in or out of it. `set_param` and `reset_op` fan out to every selected
layer, each set to the exact value the slider now shows rather than offset by
however far it already was — one slider, one reading, applied everywhere
selected. Dragging a gradient's on-canvas handle is deliberately not
extended to multi-select: several gradients have no single geometry a shared
handle could move, so `gradient_handles` stays empty unless exactly one
layer is selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:17:07 +02:00

481 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Local adjustments: the mask stack, the region overlay, and the subjects the
// model recognised (FR-DEV-3).
//
// The panel is deliberately thin. A mask layer holds an ordinary develop
// chain, so selecting one hands the *existing* adjust panel that chain — there
// are no per-layer sliders here and there must not be, or every operation
// added to `ops/` would need a local twin.
import { Theme } from "theme.slint";
import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint";
import { Segmented, SliderRow } from "controls.slint";
/// One layer in the stack.
export struct MaskRow {
// Identity, opaque to this file — the callbacks hand it straight back.
id: string,
label: string,
/// What kind of mask it is, for the row's second line.
kind: string,
enabled: bool,
/// The layer the develop panel is currently editing.
selected: bool,
inverted: bool,
opacity: float,
/// Edge transition half-width, as a fraction of the frame's shorter edge.
feather: float,
/// Index into `Falloff::ALL`.
falloff: int,
/// Index into `Morphology::ALL`.
morphology: int,
morph-radius: float,
/// Whether the edge controls apply. A gradient has its own geometry and
/// does not go through the distance field, so showing them would offer
/// controls that do nothing.
shapeable: bool,
/// Its region ids came from a different segmentation than the one loaded,
/// so applying it would produce a confidently wrong shape.
stale: bool,
/// Whether it currently changes any pixel. A bare selection does not, and
/// saying so is what stops it reading as broken.
adjusted: bool,
}
/// Which part of a gradient a canvas handle drags.
///
/// Named by the job rather than by the field, because the two gradients keep
/// different things in the same place: `edge` is a linear ramp's width and a
/// radial's major semi-axis, and `cross` is the radial's minor one.
export enum HandleRole { centre, edge, cross, rotate }
/// One draggable point on the photograph.
///
/// Positioned in fractions of the image **as it is currently shown** — after
/// the crop, the zoom and the pan — because that is the only space this file
/// can draw in. Rust maps the mask's own source-space geometry into it on
/// every frame, so the handle sits on the mask rather than beside it.
export struct GradientHandle {
role: HandleRole,
x: float,
y: float,
}
/// Something the model found.
export struct SubjectRow {
index: int,
label: string,
/// 0..1. Shown rather than hidden: the detector is a shortcut, not an
/// authority, and a weak guess presented like a strong one makes the tool
/// look wrong when it was merely unsure.
score: float,
}
component MaskEntry inherits Rectangle {
in property <MaskRow> data;
in property <bool> enabled: true;
/// Whether a refine pass is running (for any row — only one runs at a
/// time), so this row's button can say so and refuse a second press.
in property <bool> refining: false;
/// `true` when the click should add this row to the selection rather
/// than replace it with just this row.
callback selected(bool);
callback removed();
callback toggled(bool);
callback invert-toggled(bool);
callback opacity-changed(float);
callback feather-changed(float);
callback falloff-picked(int);
callback morphology-picked(int);
callback morph-radius-changed(float);
callback refined();
height: layout.preferred-height;
background: root.data.selected ? Theme.surface-raised : transparent;
border-radius: Theme.radius;
touch := TouchArea {
mouse-cursor: pointer;
enabled: root.enabled;
// `clicked` carries no modifiers, so a plain click and a
// multi-select click need the pointer event itself: released, over
// the row, with control or command held is "add to the selection"
// rather than "replace it".
pointer-event(event) => {
if event.kind == PointerEventKind.up && event.button == PointerEventButton.left {
root.selected(event.modifiers.control || event.modifiers.meta);
}
}
}
layout := VerticalLayout {
padding: Theme.gap-sm;
spacing: 2px;
HorizontalLayout {
spacing: Theme.gap-sm;
// The eye. Disabling a layer is the A/B a local edit constantly
// wants, so it is one press away rather than inside the row.
visibility := TouchArea {
width: Theme.touch-target;
height: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.toggled(!root.data.enabled); }
Label {
text: root.data.enabled ? "◉" : "○";
emphasised: visibility.has-hover || root.data.enabled;
horizontal-alignment: center;
vertical-alignment: center;
}
}
VerticalLayout {
horizontal-stretch: 1;
alignment: center;
spacing: 0px;
Label {
text: root.data.label;
emphasised: root.data.selected || touch.has-hover;
overflow: elide;
}
Caption {
// Elided rather than wrapped: this sits beside two fixed
// targets in a row, and wrapping would give the rows of a
// stack different heights for no gain.
overflow: elide;
// Three states worth distinguishing, and each has a
// different remedy: stale needs the segmentation re-run,
// unadjusted needs a slider moved, and the ordinary case
// needs nothing said about it beyond what it is.
text: root.data.stale
? "needs re-segmenting"
: (root.data.adjusted
? root.data.kind
: root.data.kind + " · no adjustment yet");
}
}
remove := TouchArea {
width: Theme.touch-target;
height: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.removed(); }
Label {
text: "×";
emphasised: remove.has-hover;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// Opacity and invert appear only on the selected layer. On every row
// they would triple the height of a stack of eight and put seven sets
// of controls in front of the one being worked on.
if root.data.selected: VerticalLayout {
spacing: Theme.gap-sm;
padding-top: Theme.gap-sm;
SliderRow {
label: "Opacity";
value: root.data.opacity;
default-value: 1.0;
minimum: 0.0;
maximum: 1.0;
changed(v) => { root.opacity-changed(v); }
reset => { root.opacity-changed(1.0); }
}
invert := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.invert-toggled(!root.data.inverted); }
Label {
text: root.data.inverted ? "Inverted — edit applies outside" : "Invert";
emphasised: invert.has-hover || root.data.inverted;
}
}
// Only a subject has a detection behind it worth re-running. A
// gradient's edge is geometry the photographer placed, and there
// is nothing for the model to find twice.
if root.data.kind == "subject": Button {
text: root.refining ? "Refining…" : "Refine mask";
enabled: !root.refining;
clicked => { root.refined(); }
}
// The edge. All of it reads one signed distance field, so these
// are free to move — none of them recomputes anything except a
// compound morphology.
if root.data.shapeable: SliderRow {
label: "Feather";
hint: "How far the edit fades across the edge.";
value: root.data.feather * 1000;
default-value: 4;
minimum: 0;
maximum: 60;
changed(v) => { root.feather-changed(v / 1000); }
reset => { root.feather-changed(0.004); }
}
if root.data.shapeable: Segmented {
label: "Falloff";
options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"];
selected: root.data.falloff;
picked(i) => { root.falloff-picked(i); }
}
if root.data.shapeable: Segmented {
label: "Edge";
hint: "Grow or shrink the outline the model drew. Close fills "
+ "pinholes; open removes specks.";
options: ["None", "Grow", "Shrink", "Close", "Open"];
selected: root.data.morphology;
picked(i) => { root.morphology-picked(i); }
}
if root.data.shapeable && root.data.morphology != 0: SliderRow {
label: "Amount";
value: root.data.morph-radius * 1000;
default-value: 0;
minimum: 0;
maximum: 40;
changed(v) => { root.morph-radius-changed(v / 1000); }
reset => { root.morph-radius-changed(0); }
}
}
}
}
export component MaskPanel inherits Rectangle {
in property <bool> enabled: true;
in property <[MaskRow]> masks;
in property <[SubjectRow]> subjects;
/// A region map has been computed for this image.
in property <bool> segmented: false;
/// One is being computed now.
in property <bool> segmenting: false;
/// A refine pass is running for one subject layer.
in property <bool> refining: false;
/// Whether the photographer has hidden the overlay to see the photograph.
/// Two-way bound to the window's own, so the canvas and this control never
/// disagree about it.
in-out property <bool> overlay-hidden: false;
/// `true` asks for the slower, tiled pass.
callback segment(bool);
/// `true` when the click should add the layer to the selection rather
/// than replace it.
callback mask-selected(string, bool);
callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool);
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float);
callback mask-falloff-picked(string, int);
callback mask-morphology-picked(string, int);
callback mask-morph-radius-changed(string, float);
callback add-gradient(bool);
callback add-subject(int);
background: Theme.surface;
// **Flat, deliberately.** An earlier version grouped each state into its
// own `if ...: VerticalLayout { ... }`. The nested layout under-reported
// its height, so the divider and the mask row below it were placed over
// the "Find regions" button — the sort of fault that is invisible in the
// source and obvious the moment anyone looks at the panel. Conditioning
// each child on its own keeps every element a direct child of one layout,
// which is the layout that measures them.
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
HorizontalLayout {
PanelHeading { text: "MASKS"; }
Rectangle { horizontal-stretch: 1; }
if root.segmented: Value { text: root.subjects.length + (root.subjects.length == 1 ? " subject" : " subjects");
}
}
if !root.enabled: Caption { text: "No image"; }
// --- the region map ---------------------------------------------
//
// Segmentation is a thing the user starts. Half a second of watershed
// 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 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.
wrap: word-wrap;
}
if root.enabled && !root.segmented: Button {
text: root.segmenting ? "Looking…" : "Find subjects";
enabled: !root.segmenting;
primary: true;
clicked => { root.segment(false); }
}
// No "Overlay" button and no "Select" button. Both switched on things
// that are now simply what local mode *is*: entering it draws the
// region map and makes a click on the photograph mean "select". A mode
// whose behaviour has to be armed separately is a mode that can be
// entered and still do nothing, which is what these two allowed.
if root.enabled && root.segmented: Caption {
text: "Click a subject in the photograph to mask it.";
wrap: word-wrap;
}
// Hiding the overlay is not the "Overlay" button this panel used to
// have, and the distinction is the reason it is back. That one *armed*
// the overlay: local mode could be entered and still show nothing,
// which is the fault the comment below records. This one only takes an
// overlay that is already there out of the way for a moment, which is
// the one thing a photographer needs constantly and had no way to do —
// a mask is judged against the photograph under it, and you cannot see
// that photograph through the thing describing it.
//
// Reads as its own action rather than its state: "Show the mask" is
// what pressing it will do, not what is currently true.
if root.enabled && root.segmented: Button {
text: root.overlay-hidden ? "Show the mask" : "Hide the mask";
clicked => { root.overlay-hidden = !root.overlay-hidden; }
}
// The second pass, offered rather than taken automatically.
//
// The model's input is a fixed 640x640 square and every frame is
// letterboxed into it, so a subject that is small in the frame reaches
// the model small — a bird at 200px in a 1600px proxy arrives at 80px,
// and an outline traced at 80px is what a coarse mask is. Tiling runs
// the model over overlapping windows instead, so that bird arrives at
// its own size.
//
// It costs one inference per tile: about 2.8s for a 3x2 grid against
// 470ms for the whole frame. That is the wrong trade for the usual
// photograph, where the subject fills much of the frame and the first
// pass is already the best answer available — so it is a button and
// not a default, and it says what it costs.
if root.enabled && root.segmented: Button {
text: root.segmenting ? "Looking closer…" : "Look closer (slower)";
enabled: !root.segmenting;
clicked => { root.segment(true); }
}
// 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.
// --- what the model found ----------------------------------------
if root.enabled && root.segmented && root.subjects.length > 0: Caption {
// Says where these came from and how they differ from a region.
// 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: "Click one to mask it. The outline is the model's, so soften or grow it below.";
wrap: word-wrap;
}
if root.enabled && root.segmented: VerticalLayout {
spacing: 0px;
for subject in root.subjects: subject-row := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.add-subject(subject.index); }
HorizontalLayout {
spacing: Theme.gap-sm;
Label {
text: subject.label;
emphasised: subject-row.has-hover;
horizontal-stretch: 1;
overflow: elide;
}
Value { text: round(subject.score * 100) + "%"; }
}
}
}
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;
}
// --- the stack -----------------------------------------------------
Caption { text: "Masks"; }
// Gradients need no segmentation, so they are offered whether or not
// one has been computed — a graduated filter over a sky is a local
// adjustment that never needed to know what a sky is.
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Linear";
enabled: root.enabled;
clicked => { root.add-gradient(false); }
}
Button {
text: "Radial";
enabled: root.enabled;
clicked => { root.add-gradient(true); }
}
}
if root.enabled && root.masks.length == 0: Caption {
text: "No local adjustments. Select a region, or add a gradient.";
wrap: word-wrap;
}
for mask in root.masks: MaskEntry {
data: mask;
enabled: root.enabled;
refining: root.refining;
selected(extend) => { root.mask-selected(mask.id, extend); }
removed => { root.mask-removed(mask.id); }
refined => { root.mask-refined(mask.id); }
toggled(on) => { root.mask-toggled(mask.id, on); }
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
feather-changed(v) => { root.mask-feather-changed(mask.id, v); }
falloff-picked(i) => { root.mask-falloff-picked(mask.id, i); }
morphology-picked(i) => { root.mask-morphology-picked(mask.id, i); }
morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); }
}
// No caption saying which chain the sliders below are pointed at.
//
// That sentence used to be the *only* indication that selecting a
// layer had silently re-scoped thirty controls, and describing a
// hazard in a caption is not the same as removing it. The adjust
// panel's own heading names the layer it is editing now, which puts
// the answer on the thing that changed rather than above it.
}
}