Benchmarks / CPU and I/O (per commit) (push) Successful in 3m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h18m38s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 50s
Build and test / Android (aarch64) (push) Failing after 30s
The colour gate decided *what* was in a category and had no way to decide *where* its edge fell. A colour test has no notion of an edge. So a refined sky lost its flag and kept the model's twenty-pixel-blocky outline, and no setting of the control could move that outline onto the horizon. This adds the second half: a **marker-based watershed**. The mask is eroded to give two markers, and the flood runs in the ribbon left between them, meeting along the most expensive line it can find. The cost is a sum of terms exactly as docs/segmentation.md §2 specifies — the photograph's own edges, and the colour model's disagreement. ## Why this is not the watershed §15 threw away That path failed because the merge *ladder* collapsed: 45,808 basins reduced to one region plus specks. There is no ladder here. Markers prevent over-segmentation by seeding rather than by merging afterwards, so the one component that broke is the one component this does not have. The markers are also better than the textbook's. scikit-image derives them by thresholding the gradient — guessing where objects are — where these come from a model that knows what sky is. Marker selection is what normally goes wrong with this method, and it was already solved. ## The gate still runs, and it runs first A flood cannot replace the colour gate. It only refines contours that already exist, and there is no contour around a flag precisely because the model never noticed one — the flag in the tests sits forty-five pixels from the boundary against a ribbon of six. The tests caught this; the first version of this commit had the flood standing in for the gate and the flag stayed. So the gate goes first and *creates* the contour, and the flood then puts every contour — the horizon and the new hole alike — onto a real edge. ## Erosion that does not delete flagpoles Eroding by a cell and a half destroys anything thinner than three cells: a mast, a bare branch, and equally a strip of sky between two of them. Those would be left unseeded and the flood would fill them from whichever side surrounds them, so a flagpole would come back — and come back *confident*. Erosion therefore stops at the ridge of the distance transform. Whatever would otherwise vanish keeps a one-pixel seed down its centre, floored at `min_thickness` so a hot pixel does not qualify. That floor also moves the signal-versus-noise decision out of colour space, where it was a share of a fitted distribution nobody can picture, and into image space, where it is a width in pixels a photographer can see. ## Two modelling errors the outward test found Both were invisible while the refinement could only subtract, because the gate was multiplied by weights that were already zero outside the mask. The moment the boundary could move outward they decided the answer. **A diagonal covariance is wrong along a gradient.** Sky moves along all three opponent features together — luminance up, red-green drifting, blue-yellow down — so treating them as independent charges a colour two deviations along that gradient three times over. Measured: sky fifteen rows past the sample scored 11.6 against a threshold of 11.34, so the model refused the very thing it was refining. The fit now carries a full 3x3 covariance, inverted by cofactors rather than by a dependency (D13, the NDK). **Eroded seeds understate the spread, always, in a known direction.** The sample is drawn from the middle of a category and never from its edge, so for anything with a gradient the colours nearest the boundary are exactly the ones left out. The broad mode is therefore fitted wider than its sample by `SHOULDER`. Same pixel: Mahalanobis 5.9 uncorrected, 1.5 corrected — the difference between refusing the horizon and reaching it. Only the broad mode is widened; the tight ones are what discriminate. ## What was given up Strict subtractivity. It bounded the damage and kept `scene.rs`'s partition true for free, and it had to go: a mask that may only shrink can sharpen a horizon inward but never outward, so wherever the coarse contour sat inside the true edge, the error survived every setting of the control. The travel bound replaces it. Everything beyond the ribbon is already a marker, so the flood never reaches it — not "can only remove" but "can only move this far", and the distance is the model's own uncertainty. That single bound also retires the connectivity test, the reachability radius and the separate additive path that an outward-growing rule would have needed. A blue car below the horizon cannot be gained, not because a rule forbids it, but because the flood is never there. `the_colour_gate_only_removes` keeps the older property where it still holds; `the_flood_cannot_travel_further_than_the_ribbon` holds the new one across the whole travel of the control. ## Cost The flood visits only unlabelled pixels, so confining it to the ribbon is not an optimisation added on top — it is what a seeded flood does. A ribbon of a few tens of pixels around one contour is a small part of a proxy. The distance transform is no longer cached, because it has to be measured from the mask as the gate leaves it and the gate moves with the control. That is one transform plus one flood per change of the control, against a precompute that runs the model once. Verified: fmt clean, clippy --workspace -D warnings clean, 63 dr-segment tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
715 lines
31 KiB
Plaintext
715 lines
31 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,
|
||
/// How strictly a category is cut back to the pixels whose colour agrees
|
||
/// with it, in nats. Zero is the model's own weighting.
|
||
refine: float,
|
||
/// Whether this layer has a colour model fitted to refine against. Only a
|
||
/// category has one, and only where the frame gave enough to fit it.
|
||
refinable: bool,
|
||
/// 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,
|
||
}
|
||
|
||
/// One photographic category the scene model weighed.
|
||
export struct CategoryRow {
|
||
/// The descriptor's name — "sky", "vegetation". Carried rather than an
|
||
/// index because the descriptor is editable and an index would repoint.
|
||
name: string,
|
||
label: string,
|
||
/// 0..1 of the frame. Shown for the same reason a subject's score is: it
|
||
/// tells the photographer whether the category is worth reaching for
|
||
/// before they spend a click finding out.
|
||
coverage: 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 refine-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;
|
||
|
||
// Both lines below are bounded for the reason the subject
|
||
// row is: a mask's label and kind come from the model, and an
|
||
// unbounded `Text` asks for its whole string at layout time
|
||
// even when `elide` means it will never draw it. A mask *is* a
|
||
// segmentation result, so without this the column moved when a
|
||
// subject was clicked as well as when one was found.
|
||
Label {
|
||
text: root.data.label;
|
||
emphasised: root.data.selected || touch.has-hover;
|
||
overflow: elide;
|
||
min-width: 0px;
|
||
max-width: 160px;
|
||
}
|
||
|
||
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;
|
||
min-width: 0px;
|
||
max-width: 160px;
|
||
// 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(); }
|
||
}
|
||
|
||
// Which pixels are in the mask at all. Above the edge controls
|
||
// because it decides the outline they then treat, and unlike them
|
||
// it rebuilds the distance field — the same cost as a close or an
|
||
// open, and the reason it commits on release rather than on every
|
||
// sample of a drag.
|
||
if root.data.refinable: SliderRow {
|
||
label: "Refine";
|
||
hint: "Drop pixels whose colour does not belong — a flag in "
|
||
+ "the sky, a chimney, a bare branch — and pull the "
|
||
+ "outline onto the edge the photograph actually has. "
|
||
+ "Zero is the model's own outline.";
|
||
value: root.data.refine;
|
||
default-value: 4;
|
||
minimum: 0;
|
||
maximum: 8;
|
||
// Tenths, not whole nats. The interval between a flag going
|
||
// and a cloud going is under three nats wide, so whole steps
|
||
// would give the control about three useful positions inside
|
||
// the range it exists to explore.
|
||
precision: 1;
|
||
changed(v) => { root.refine-changed(v); }
|
||
reset => { root.refine-changed(4); }
|
||
}
|
||
|
||
// 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;
|
||
in property <[CategoryRow]> categories;
|
||
|
||
/// 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-refine-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);
|
||
callback add-category(string);
|
||
|
||
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.
|
||
layout := 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;
|
||
// **`elide` is a paint-time behaviour, and this is a
|
||
// layout-time problem.** A `Text` asks for the width of
|
||
// its whole string whether or not it will draw all of
|
||
// it, so without a stated maximum this row asked for
|
||
// whatever the model happened to return, that became
|
||
// `layout.preferred-width`, the panel publishes that as
|
||
// its `min-width`, and the develop column takes the
|
||
// widest minimum any panel declares. The column
|
||
// therefore moved the instant segmentation finished —
|
||
// a photograph the user was looking at, jumping
|
||
// sideways because a label said "traffic light".
|
||
//
|
||
// Stated as a maximum for the reason `ChipGrid`
|
||
// declares its width from its column count rather than
|
||
// from its options: what a panel asks for must follow
|
||
// from its structure, never from its data. Past this
|
||
// the row elides, which is what `elide` was for.
|
||
//
|
||
// 160px is the same judgement as `ChipGrid`'s 88px
|
||
// chip — comfortable for the class names this model
|
||
// returns, and narrow enough that a subject list
|
||
// cannot be what sets the column.
|
||
min-width: 0px;
|
||
max-width: 160px;
|
||
}
|
||
Value { text: round(subject.score * 100) + "%"; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- whole categories ---------------------------------------------
|
||
//
|
||
// Below the subjects rather than above, and the order is the argument:
|
||
// clicking the photograph is how a local adjustment usually starts, so
|
||
// the things that were *found* come first. Categories are the move you
|
||
// reach for deliberately — grade the sky, not this one bird.
|
||
if root.enabled && root.segmented && root.categories.length > 0: Caption {
|
||
text: "Or grade a whole category. One adjustment covers every pixel of it.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
if root.enabled && root.segmented: VerticalLayout {
|
||
spacing: 0px;
|
||
for category in root.categories: category-row := TouchArea {
|
||
height: Theme.touch-target;
|
||
mouse-cursor: pointer;
|
||
clicked => { root.add-category(category.name); }
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
|
||
Label {
|
||
text: category.label;
|
||
emphasised: category-row.has-hover;
|
||
horizontal-stretch: 1;
|
||
overflow: elide;
|
||
// Same layout-time reason as the subject row above:
|
||
// a Text asks for its whole string's width whether or
|
||
// not it elides, and the develop column takes the
|
||
// widest minimum any panel declares.
|
||
max-width: 160px;
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
Label {
|
||
// Coverage as a percentage, which is the unit the
|
||
// number means something in. A category under half a
|
||
// percent never reaches this list at all.
|
||
// A plain Label is already `ink-dim`; only
|
||
// `emphasised` lifts it, and a coverage readout is
|
||
// exactly the thing that should not compete with the
|
||
// name beside it.
|
||
text: round(category.coverage * 100) + "%";
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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); }
|
||
refine-changed(v) => { root.mask-refine-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.
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-UI-3
|
||
/// The mask's handles, drawn on the photograph.
|
||
///
|
||
/// A linear or radial mask could be made and then never moved: there was
|
||
/// nothing to grab, so a radial sat at the centre of the frame at its default
|
||
/// size for ever.
|
||
///
|
||
/// **Placed over the fitted image, not the canvas box.** The photograph is
|
||
/// letterboxed inside the canvas and Slint does not report the fitted rect, so
|
||
/// the develop view derives it and gives this component exactly that rect —
|
||
/// which is why everything in here is a plain fraction of `root.width` and
|
||
/// `root.height` rather than an offset from someone else's origin. Rust
|
||
/// supplies the positions already mapped through the framing — the same map
|
||
/// the shader applies — so a handle follows the mask through a zoom, a pan, a
|
||
/// crop and a straightening rather than sitting where the mask used to be.
|
||
///
|
||
/// **Drawn small, grabbed large.** The visible dot is 14px because a bigger
|
||
/// one would hide the edge it is placed on, and the `TouchArea` is a full
|
||
/// touch target, centred on it — `Button` establishes the same split. These
|
||
/// are the first controls in the application meant to be dragged on the
|
||
/// photograph, and a 12-inch tablet has no hover to reveal them with and no
|
||
/// modifier to qualify them by, so what is drawn is all there is (FR-UI-7).
|
||
export component GradientHandles inherits Rectangle {
|
||
in property <[GradientHandle]> handles;
|
||
|
||
/// role, from-x, from-y, to-x, to-y — all fractions of this rect.
|
||
callback dragged(HandleRole, float, float, float, float);
|
||
/// One history step per gesture, not per frame.
|
||
callback released();
|
||
|
||
background: transparent;
|
||
|
||
for handle in root.handles: Rectangle {
|
||
x: handle.x * root.width - self.width / 2;
|
||
y: handle.y * root.height - self.height / 2;
|
||
width: Theme.touch-target;
|
||
height: Theme.touch-target;
|
||
|
||
// The centre moves the whole mask, so it is filled; the others shape
|
||
// it and are rings. Shape rather than colour, because the handles sit
|
||
// on a photograph and any colour they carried would be read as part
|
||
// of it.
|
||
property <bool> solid: handle.role == HandleRole.centre;
|
||
|
||
Rectangle {
|
||
width: handle.role == HandleRole.rotate ? 12px : 14px;
|
||
height: self.width;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
border-radius: self.width / 2;
|
||
border-width: 2px;
|
||
// White with a dark ring, so the handle is visible against a blown
|
||
// sky and against a black frame — one treatment, both extremes, no
|
||
// theme token because this is drawn over the image and not over
|
||
// the interface.
|
||
border-color: #00000099;
|
||
background: parent.solid ? #ffffff : #ffffff44;
|
||
}
|
||
|
||
drag := TouchArea {
|
||
width: 100%;
|
||
height: 100%;
|
||
mouse-cursor: parent.solid
|
||
? MouseCursor.move
|
||
: MouseCursor.crosshair;
|
||
|
||
// Where the press landed, in the same fractions the callback
|
||
// reports in. Captured on the way down so the whole gesture is
|
||
// measured from one origin.
|
||
property <float> from-x;
|
||
property <float> from-y;
|
||
|
||
function fraction-x(local-x: length) -> float {
|
||
return (parent.x + local-x) / max(root.width, 1px);
|
||
}
|
||
function fraction-y(local-y: length) -> float {
|
||
return (parent.y + local-y) / max(root.height, 1px);
|
||
}
|
||
|
||
pointer-event(ev) => {
|
||
if (ev.kind == PointerEventKind.down) {
|
||
self.from-x = self.fraction-x(self.pressed-x);
|
||
self.from-y = self.fraction-y(self.pressed-y);
|
||
}
|
||
if (ev.kind == PointerEventKind.up) {
|
||
root.released();
|
||
}
|
||
}
|
||
|
||
moved => {
|
||
if (self.pressed) {
|
||
root.dragged(
|
||
handle.role,
|
||
self.from-x,
|
||
self.from-y,
|
||
self.fraction-x(self.mouse-x),
|
||
self.fraction-y(self.mouse-y),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|