Show the regions, click one, and adjust it
The local panel sits above the adjust panel because it decides what those sliders act on; below it, a photographer would set an exposure and only then discover which scope it landed in. Selecting a layer re-scopes the existing controls to that layer's chain — there is no second set of sliders, and there must not be, or every operation added to `ops/` would need a local twin. The overlay is drawn over the canvas rather than blended into the render, because it is a diagnostic and not an edit: it must not reach the histogram, an export, or the texture handed to the compositor. Nearest- neighbour always — the map's values are *names*, so smoothing between region 4 and region 9 invents a colour belonging to neither and softens exactly the edge the overlay exists to show. Picking gets its own touch area above the pan handler. Panning wants press-drag-release and picking wants a click; interleaving them in one handler is how a drag ends up selecting a region the user was scrolling past. Shift is tracked as window state because a TouchArea's click carries no modifiers. Three states a layer can be in are worth distinguishing, and each has a different remedy: stale needs re-segmenting, "no adjustment yet" needs a slider moved, and the ordinary case needs nothing said. A bare selection renders nothing and looks identical to a broken mask, which is the first thing a new user will hit. Known rough edge, commented where it happens: segmentation blocks the UI thread for about half a second. Moving it to a worker needs the develop session — GPU resources behind a RefCell shared with every callback — to be reachable from another thread, which is a restructuring rather than a change to the call. The button says "Finding regions…" first so the stall is announced rather than looking like a hang.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slint";
|
||||
import { MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||
@@ -792,6 +793,51 @@ export component AppWindow inherits Window {
|
||||
in property <bool> expanded: true;
|
||||
in property <string> layout-class: "expanded";
|
||||
|
||||
// --- local adjustments (FR-DEV-3) ---------------------------------------
|
||||
|
||||
/// The false-coloured region map, drawn over the photograph.
|
||||
///
|
||||
/// An empty image when there is nothing to draw, which is also how the
|
||||
/// overlay is hidden — one source of truth rather than a flag that can
|
||||
/// disagree with the picture.
|
||||
in property <image> region-overlay;
|
||||
/// How strongly the overlay is composited. Not 1.0: the point of looking
|
||||
/// at it is to judge whether a boundary follows the *photograph*, and an
|
||||
/// opaque map hides the thing being judged.
|
||||
in property <float> overlay-strength: 0.55;
|
||||
in property <bool> overlay-on: false;
|
||||
/// Clicking the canvas selects a region instead of panning.
|
||||
in property <bool> region-picking: false;
|
||||
/// Whether shift is down, tracked by the develop key scope below.
|
||||
///
|
||||
/// A `TouchArea`'s `clicked` carries no modifiers, so the state has to be
|
||||
/// held between the key event and the click. Private to the window because
|
||||
/// nothing outside it has any business setting it.
|
||||
property <bool> shift-held: false;
|
||||
|
||||
in property <[MaskRow]> mask-rows;
|
||||
in property <[SubjectRow]> subject-rows;
|
||||
in property <bool> segmented: false;
|
||||
in property <bool> segmenting: false;
|
||||
in property <int> segmentation-level: 300;
|
||||
in property <int> segmentation-regions: 0;
|
||||
in property <bool> editing-mask: false;
|
||||
|
||||
callback segment-image();
|
||||
callback overlay-toggled(bool);
|
||||
callback region-picking-toggled(bool);
|
||||
callback segmentation-level-changed(int);
|
||||
/// A click on the photograph, in fractions of the shown image, plus
|
||||
/// whether it should extend the selection rather than replace it.
|
||||
callback region-picked(float, float, 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 add-gradient-mask(bool);
|
||||
callback add-subject-mask(int);
|
||||
|
||||
/// Whether the develop column — image info, geometry, adjust — is shown.
|
||||
///
|
||||
/// Distinct from `expanded`, which is the *layout class* and only supplies
|
||||
@@ -1276,6 +1322,29 @@ in property <bool> panel-visible: true;
|
||||
visible: root.total > 0 && root.load-error == "";
|
||||
}
|
||||
|
||||
// The region map, composited over the photograph at the
|
||||
// same fitted rect.
|
||||
//
|
||||
// Drawn here rather than blended into the render because
|
||||
// it is a diagnostic and not an edit: it must not reach
|
||||
// the histogram, an export, or the texture the develop
|
||||
// pass hands the compositor.
|
||||
if root.overlay-on && root.total > 0: Image {
|
||||
x: parent.shown-x;
|
||||
y: parent.shown-y;
|
||||
width: parent.shown-w;
|
||||
height: parent.shown-h;
|
||||
source: root.region-overlay;
|
||||
image-fit: fill;
|
||||
opacity: root.overlay-strength;
|
||||
// Nearest-neighbour, always. The map is proxy-sized
|
||||
// and its values are *names*: smoothing between region
|
||||
// 4 and region 9 invents a colour belonging to
|
||||
// neither, and draws a soft edge exactly where the
|
||||
// point is to see a hard one.
|
||||
image-rendering: ImageRendering.pixelated;
|
||||
}
|
||||
|
||||
// Where the photograph actually sits inside this box.
|
||||
//
|
||||
// `image-fit: contain` letterboxes, and Slint does not report
|
||||
@@ -1344,6 +1413,31 @@ in property <bool> panel-visible: true;
|
||||
cancelled => { self.last-scale = 1.0; }
|
||||
}
|
||||
|
||||
// Region picking, above the pan/zoom area so a click
|
||||
// reaches it first. A separate area rather than a branch
|
||||
// inside the one below: panning wants press-drag-release
|
||||
// and picking wants a click, and interleaving the two in
|
||||
// one handler is how a drag ends up selecting a region the
|
||||
// user was only scrolling past.
|
||||
if root.region-picking && root.total > 0 && root.load-error == "": pick := TouchArea {
|
||||
x: parent.shown-x;
|
||||
y: parent.shown-y;
|
||||
width: parent.shown-w;
|
||||
height: parent.shown-h;
|
||||
mouse-cursor: MouseCursor.crosshair;
|
||||
enabled: !root.crop-mode;
|
||||
|
||||
clicked => {
|
||||
root.region-picked(
|
||||
self.mouse-x / max(self.width, 1px),
|
||||
self.mouse-y / max(self.height, 1px),
|
||||
// Shift extends or removes, the modifier every
|
||||
// selection tool uses for the same thing.
|
||||
root.shift-held,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if root.total > 0 && root.load-error == "": TouchArea {
|
||||
x: 0; y: 0;
|
||||
width: 100%;
|
||||
@@ -1595,7 +1689,18 @@ in property <bool> panel-visible: true;
|
||||
FocusScope {
|
||||
init => { self.focus(); }
|
||||
|
||||
// Shift is state, not an event: the click that needs
|
||||
// to know about it arrives through a `TouchArea`,
|
||||
// which reports no modifiers of its own. Tracked on
|
||||
// both edges so releasing it while the pointer is over
|
||||
// the canvas is noticed.
|
||||
key-released(event) => {
|
||||
root.shift-held = event.modifiers.shift;
|
||||
return reject;
|
||||
}
|
||||
|
||||
key-pressed(event) => {
|
||||
root.shift-held = event.modifiers.shift;
|
||||
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
|
||||
// of the letter, because the logical key that
|
||||
// reaches us carries the shift: holding it for
|
||||
@@ -1786,6 +1891,44 @@ in property <bool> panel-visible: true;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// Above the adjust panel, because it decides what
|
||||
// those sliders act on. Below it, the photographer
|
||||
// would set an exposure and only then discover which
|
||||
// scope it landed in.
|
||||
MaskPanel {
|
||||
enabled: root.adjust-enabled;
|
||||
masks: root.mask-rows;
|
||||
subjects: root.subject-rows;
|
||||
segmented: root.segmented;
|
||||
segmenting: root.segmenting;
|
||||
level: root.segmentation-level;
|
||||
region-count: root.segmentation-regions;
|
||||
overlay: root.overlay-on;
|
||||
picking: root.region-picking;
|
||||
editing-mask: root.editing-mask;
|
||||
|
||||
segment => { root.segment-image(); }
|
||||
overlay-toggled(on) => { root.overlay-toggled(on); }
|
||||
picking-toggled(on) => { root.region-picking-toggled(on); }
|
||||
level-changed(v) => { root.segmentation-level-changed(v); }
|
||||
mask-selected(id) => { root.mask-selected(id); }
|
||||
mask-removed(id) => { root.mask-removed(id); }
|
||||
mask-toggled(id, on) => { root.mask-toggled(id, on); }
|
||||
mask-invert-toggled(id, on) => {
|
||||
root.mask-invert-toggled(id, on);
|
||||
}
|
||||
mask-opacity-changed(id, v) => {
|
||||
root.mask-opacity-changed(id, v);
|
||||
}
|
||||
add-gradient(radial) => { root.add-gradient-mask(radial); }
|
||||
add-subject(i) => { root.add-subject-mask(i); }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
adjust := AdjustPanel {
|
||||
rows: root.adjust-rows;
|
||||
enabled: root.adjust-enabled;
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// 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 { 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,
|
||||
/// 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);
|
||||
|
||||
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 {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
/// How many regions the ladder is currently cut to.
|
||||
in property <int> level: 300;
|
||||
in property <int> region-count: 0;
|
||||
|
||||
/// 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 level-changed(int);
|
||||
|
||||
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 add-gradient(bool);
|
||||
callback add-subject(int);
|
||||
|
||||
background: Theme.surface;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Theme.gap;
|
||||
spacing: Theme.gap-sm;
|
||||
alignment: start;
|
||||
|
||||
HorizontalLayout {
|
||||
PanelHeading { text: "LOCAL"; }
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
if root.segmented: Value { text: root.region-count + " regions"; }
|
||||
}
|
||||
|
||||
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: VerticalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Caption {
|
||||
text: "Find the regions in this photograph, so a mask can snap to them.";
|
||||
}
|
||||
|
||||
Button {
|
||||
text: root.segmenting ? "Finding regions…" : "Find regions";
|
||||
enabled: !root.segmenting;
|
||||
primary: true;
|
||||
clicked => { root.segment(); }
|
||||
}
|
||||
}
|
||||
|
||||
if root.enabled && root.segmented: VerticalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
// The overlay the whole map is judged by. Without it the
|
||||
// granularity control is a number with no visible meaning.
|
||||
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.picking: Caption {
|
||||
text: "Click the photograph to select a region. Shift-click to add or remove.";
|
||||
}
|
||||
|
||||
// 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.
|
||||
SliderRow {
|
||||
label: "Detail";
|
||||
value: root.level;
|
||||
default-value: 300;
|
||||
minimum: 8;
|
||||
maximum: 2000;
|
||||
changed(v) => { root.level-changed(v); }
|
||||
reset => { root.level-changed(300); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- what the model found -------------------------------------------
|
||||
if root.enabled && root.segmented && root.subjects.length > 0: VerticalLayout {
|
||||
spacing: 2px;
|
||||
padding-top: Theme.gap-sm;
|
||||
|
||||
Caption { text: "Recognised"; }
|
||||
|
||||
for subject[i] 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;
|
||||
}
|
||||
Value { text: round(subject.score * 100) + "%"; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// --- the stack -------------------------------------------------------
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Caption { text: "Masks"; horizontal-stretch: 1; }
|
||||
|
||||
// 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.
|
||||
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.";
|
||||
}
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
// The one thing the panel has to say about the adjust panel below
|
||||
// it, because otherwise selecting a layer silently changes what those
|
||||
// sliders mean — the single 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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user