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:
@@ -770,6 +770,35 @@ impl DevelopSession {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What kind of mask a layer is — "regions", "linear", "radial".
|
||||||
|
pub fn mask_kind(&self, id: &str) -> &'static str {
|
||||||
|
self.graph
|
||||||
|
.masks()
|
||||||
|
.get(id)
|
||||||
|
.map_or("", |l| l.source.kind())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mask_inverted(&self, id: &str) -> bool {
|
||||||
|
self.graph.masks().get(id).is_some_and(|l| l.invert)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mask_opacity(&self, id: &str) -> f32 {
|
||||||
|
self.graph.masks().get(id).map_or(1.0, |l| l.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a layer has any adjustment on it yet.
|
||||||
|
///
|
||||||
|
/// Distinct from `is_active`, which also asks whether the layer is enabled
|
||||||
|
/// and visible. The panel wants specifically "you have made a selection
|
||||||
|
/// and not yet done anything with it", because that state looks identical
|
||||||
|
/// to a broken mask and is the most likely thing a first-time user hits.
|
||||||
|
pub fn mask_is_adjusted(&self, id: &str) -> bool {
|
||||||
|
self.graph
|
||||||
|
.masks()
|
||||||
|
.get(id)
|
||||||
|
.is_some_and(|l| l.active_ops().next().is_some())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn active_mask(&self) -> Option<&str> {
|
pub fn active_mask(&self) -> Option<&str> {
|
||||||
self.active_mask.as_deref()
|
self.active_mask.as_deref()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ mod library_ui;
|
|||||||
mod live_style;
|
mod live_style;
|
||||||
mod net_runtime;
|
mod net_runtime;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod masks_ui;
|
||||||
mod segmentation;
|
mod segmentation;
|
||||||
mod settings_store;
|
mod settings_store;
|
||||||
mod settings_ui;
|
mod settings_ui;
|
||||||
@@ -282,6 +283,11 @@ fn reset_view_state(window: &AppWindow) {
|
|||||||
// beside the next one's filename is a confident, precise lie, and the gap
|
// beside the next one's filename is a confident, precise lie, and the gap
|
||||||
// before the new frame settles is exactly long enough to read it.
|
// before the new frame settles is exactly long enough to read it.
|
||||||
window.set_histogram(histogram::empty());
|
window.set_histogram(histogram::empty());
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// The region map belongs to one photograph. Carrying the stack, the
|
||||||
|
// overlay or the crosshair to the next one would offer a selection of
|
||||||
|
// regions that are not in the picture on screen.
|
||||||
|
masks_ui::reset(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push the framing back to the geometry panel.
|
/// Push the framing back to the geometry panel.
|
||||||
@@ -1723,6 +1729,12 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
redraw(&w);
|
redraw(&w);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// The local-adjustment panel. Wired as a block rather than inline because
|
||||||
|
// it is a dozen callbacks that all say the same three things, and they
|
||||||
|
// read better beside each other than scattered through this function.
|
||||||
|
masks_ui::wire(&window, &session, &rows, &redraw, gpu.clone());
|
||||||
|
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
//! Wiring the local-adjustment panel to the develop session.
|
||||||
|
//!
|
||||||
|
//! Everything here is translation: Slint rows in one direction, callbacks in
|
||||||
|
//! the other. The decisions all live in [`crate::develop::DevelopSession`] and
|
||||||
|
//! [`crate::segmentation`], which is the same split every other `*_ui` module
|
||||||
|
//! in this crate draws.
|
||||||
|
//!
|
||||||
|
//! # The one thing this module does decide
|
||||||
|
//!
|
||||||
|
//! Selecting a mask layer re-scopes the adjust panel to that layer's chain.
|
||||||
|
//! That happens because [`crate::develop::DevelopSession::rows`] answers
|
||||||
|
//! differently once a layer is active, so the sync below only has to call
|
||||||
|
//! `sync_rows` afterwards — there is no second panel and no duplicated
|
||||||
|
//! control-building code. It is worth stating plainly because the absence of
|
||||||
|
//! code is easy to mistake for an omission.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use slint::{ComponentHandle as _, ModelRc, VecModel};
|
||||||
|
|
||||||
|
use crate::develop::DevelopSession;
|
||||||
|
use crate::segmentation;
|
||||||
|
use crate::{sync_rows, AppWindow, MaskRow, ParamRow, SubjectRow};
|
||||||
|
|
||||||
|
/// Push every mask-related property from the session into the window.
|
||||||
|
pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
|
||||||
|
let slot = session.borrow();
|
||||||
|
let Some(s) = slot.as_ref() else {
|
||||||
|
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
||||||
|
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
||||||
|
window.set_segmented(false);
|
||||||
|
window.set_segmentation_regions(0);
|
||||||
|
window.set_editing_mask(false);
|
||||||
|
window.set_overlay_on(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let active = s.active_mask().map(|id| id.to_string());
|
||||||
|
let rows: Vec<MaskRow> = s
|
||||||
|
.mask_layers()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, label, enabled, selected)| MaskRow {
|
||||||
|
stale: s.mask_is_stale(&id),
|
||||||
|
adjusted: s.mask_is_adjusted(&id),
|
||||||
|
kind: s.mask_kind(&id).into(),
|
||||||
|
inverted: s.mask_inverted(&id),
|
||||||
|
opacity: s.mask_opacity(&id),
|
||||||
|
id: id.into(),
|
||||||
|
label: label.into(),
|
||||||
|
enabled,
|
||||||
|
selected,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
window.set_mask_rows(ModelRc::new(VecModel::from(rows)));
|
||||||
|
|
||||||
|
let subjects: Vec<SubjectRow> = s
|
||||||
|
.detected_subjects()
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (label, score))| SubjectRow {
|
||||||
|
index: i as i32,
|
||||||
|
label: label.into(),
|
||||||
|
score,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
window.set_subject_rows(ModelRc::new(VecModel::from(subjects)));
|
||||||
|
|
||||||
|
window.set_segmented(s.has_segmentation());
|
||||||
|
window.set_segmentation_level(s.segmentation_level() as i32);
|
||||||
|
window.set_segmentation_regions(s.segmentation_region_count() as i32);
|
||||||
|
window.set_editing_mask(active.is_some());
|
||||||
|
|
||||||
|
// The overlay is regenerated only when there is one to draw. It is a
|
||||||
|
// proxy-sized RGBA buffer — a megabyte or so — and rebuilding it on every
|
||||||
|
// slider event would be a memcpy per frame for a picture that changes only
|
||||||
|
// when the level does.
|
||||||
|
match s.overlay_image() {
|
||||||
|
Some(image) => {
|
||||||
|
window.set_region_overlay(image);
|
||||||
|
window.set_overlay_on(true);
|
||||||
|
}
|
||||||
|
None => window.set_overlay_on(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the panel's callbacks.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn wire(
|
||||||
|
window: &AppWindow,
|
||||||
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||||
|
rows: &Rc<VecModel<ParamRow>>,
|
||||||
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
||||||
|
gpu: Option<dr_gpu::GpuContext>,
|
||||||
|
) {
|
||||||
|
// --- computing the region map -----------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
window.on_segment_image(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(ctx) = gpu.clone() else {
|
||||||
|
log::warn!("no GPU context; cannot segment");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// **Blocking, on the UI thread, and flagged rather than hidden.**
|
||||||
|
//
|
||||||
|
// Half a second of watershed and inference. Moving it to a worker
|
||||||
|
// needs the session — which owns GPU resources and is behind a
|
||||||
|
// `RefCell` shared with every callback here — to be reachable from
|
||||||
|
// another thread, and that is a restructuring of the develop
|
||||||
|
// session rather than a change to this call.
|
||||||
|
//
|
||||||
|
// So it blocks, and the button says "Finding regions…" first: a
|
||||||
|
// frozen window with a stale label is the version of this that
|
||||||
|
// reads as a crash. See the note in `segmentation.rs` — this is
|
||||||
|
// the largest rough edge in the feature.
|
||||||
|
w.set_segmenting(true);
|
||||||
|
// Let the label reach the screen before the stall begins.
|
||||||
|
slint::Timer::single_shot(std::time::Duration::from_millis(16), {
|
||||||
|
let weak = w.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let options = segmentation::Options::default();
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
if let Err(e) = s.segment(&ctx, &options) {
|
||||||
|
log::warn!("segmentation failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.set_segmenting(false);
|
||||||
|
sync(&w, &session);
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
redraw(&w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the overlay and the picking mode ---------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
window.on_overlay_toggled(move |on| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.set_overlay(on);
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.on_region_picking_toggled(move |on| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
w.set_region_picking(on);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
window.on_segmentation_level_changed(move |level| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.set_segmentation_level(level.max(2) as u32);
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- selecting on the photograph --------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
window.on_region_picked(move |x, y, add| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let picked = session
|
||||||
|
.borrow_mut()
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|s| s.select_region_at(x, y, add));
|
||||||
|
if picked.is_none() {
|
||||||
|
// A click that hit no region is not an error and must not
|
||||||
|
// clear the selection: the most likely cause is the letterbox
|
||||||
|
// margin, and losing a selection to a near-miss is the kind of
|
||||||
|
// thing that makes a tool feel hostile.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the stack ---------------------------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
window.on_mask_selected(move |id| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
{
|
||||||
|
let mut slot = session.borrow_mut();
|
||||||
|
let Some(s) = slot.as_mut() else { return };
|
||||||
|
// Clicking the selected layer again deselects it, which is how
|
||||||
|
// the panel gets back to the whole photograph without a
|
||||||
|
// separate "edit globally" control.
|
||||||
|
if s.active_mask() == Some(id.as_str()) {
|
||||||
|
s.set_active_mask(None);
|
||||||
|
} else {
|
||||||
|
s.set_active_mask(Some(&id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
// The scope changed, so the adjust panel below is now describing a
|
||||||
|
// different chain.
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
window.on_mask_removed(move |id| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.remove_mask(&id);
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
window.on_mask_toggled(move |id, on| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.set_mask_enabled(&id, on);
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
window.on_mask_invert_toggled(move |id, on| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.set_mask_invert(&id, on);
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
window.on_mask_opacity_changed(move |id, value| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.set_mask_opacity(&id, value);
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- adding layers ------------------------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
window.on_add_gradient_mask(move |radial| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.add_gradient_mask(radial);
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
window.on_add_subject_mask(move |index| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.add_subject_mask(index.max(0) as usize);
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the panel when the open image changes.
|
||||||
|
///
|
||||||
|
/// Its own function rather than a call to [`sync`] with an empty session,
|
||||||
|
/// because the *window* state has to be reset too: picking mode and the
|
||||||
|
/// overlay are properties of looking at one photograph, and carrying them to
|
||||||
|
/// the next one leaves a crosshair over an image with no region map behind it.
|
||||||
|
pub(crate) fn reset(window: &AppWindow) {
|
||||||
|
window.set_region_picking(false);
|
||||||
|
window.set_overlay_on(false);
|
||||||
|
window.set_segmenting(false);
|
||||||
|
window.set_segmented(false);
|
||||||
|
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
||||||
|
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
||||||
|
window.set_editing_mask(false);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slint";
|
import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slint";
|
||||||
|
import { MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||||
import { LaunchScreen } from "launch.slint";
|
import { LaunchScreen } from "launch.slint";
|
||||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint";
|
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.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 <bool> expanded: true;
|
||||||
in property <string> layout-class: "expanded";
|
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.
|
/// Whether the develop column — image info, geometry, adjust — is shown.
|
||||||
///
|
///
|
||||||
/// Distinct from `expanded`, which is the *layout class* and only supplies
|
/// 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 == "";
|
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.
|
// Where the photograph actually sits inside this box.
|
||||||
//
|
//
|
||||||
// `image-fit: contain` letterboxes, and Slint does not report
|
// `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; }
|
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 {
|
if root.total > 0 && root.load-error == "": TouchArea {
|
||||||
x: 0; y: 0;
|
x: 0; y: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1595,7 +1689,18 @@ in property <bool> panel-visible: true;
|
|||||||
FocusScope {
|
FocusScope {
|
||||||
init => { self.focus(); }
|
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) => {
|
key-pressed(event) => {
|
||||||
|
root.shift-held = event.modifiers.shift;
|
||||||
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
|
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
|
||||||
// of the letter, because the logical key that
|
// of the letter, because the logical key that
|
||||||
// reaches us carries the shift: holding it for
|
// reaches us carries the shift: holding it for
|
||||||
@@ -1786,6 +1891,44 @@ in property <bool> panel-visible: true;
|
|||||||
background: Theme.rule;
|
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 {
|
adjust := AdjustPanel {
|
||||||
rows: root.adjust-rows;
|
rows: root.adjust-rows;
|
||||||
enabled: root.adjust-enabled;
|
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