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.
416 lines
16 KiB
Plaintext
416 lines
16 KiB
Plaintext
// 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,
|
||
}
|
||
|
||
/// 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;
|
||
|
||
callback selected();
|
||
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);
|
||
|
||
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 => { root.selected(); }
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
/// Draw the false-coloured region map over the photograph.
|
||
in property <bool> overlay: false;
|
||
/// Clicking the photograph selects a region rather than panning.
|
||
in property <bool> picking: false;
|
||
/// A layer is selected, so the adjust panel below is scoped to it.
|
||
///
|
||
/// Supplied rather than derived: Slint has no `any` over a model, and the
|
||
/// core already knows the answer.
|
||
in property <bool> editing-mask: false;
|
||
|
||
callback segment();
|
||
callback overlay-toggled(bool);
|
||
callback picking-toggled(bool);
|
||
|
||
callback mask-selected(string);
|
||
callback mask-removed(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: "LOCAL"; }
|
||
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(); }
|
||
}
|
||
|
||
// The overlay the whole map is judged by, and the picking mode that
|
||
// makes a click mean "select" instead of "pan".
|
||
if root.enabled && root.segmented: HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
|
||
Button {
|
||
text: "Overlay";
|
||
active: root.overlay;
|
||
clicked => { root.overlay-toggled(!root.overlay); }
|
||
}
|
||
|
||
Button {
|
||
text: "Select";
|
||
active: root.picking;
|
||
clicked => { root.picking-toggled(!root.picking); }
|
||
}
|
||
}
|
||
|
||
if root.enabled && root.segmented && root.picking: 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.
|
||
// --- 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;
|
||
selected => { root.mask-selected(mask.id); }
|
||
removed => { root.mask-removed(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); }
|
||
}
|
||
|
||
// The one thing this panel has to say about the adjust panel below it,
|
||
// because otherwise selecting a layer silently changes what those
|
||
// sliders mean — the most confusing thing a scoped panel can do.
|
||
if root.enabled && root.masks.length > 0: Caption {
|
||
text: root.editing-mask
|
||
? "The controls below adjust the selected mask."
|
||
: "The controls below adjust the whole photograph.";
|
||
wrap: word-wrap;
|
||
}
|
||
}
|
||
}
|