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()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
self.active_mask.as_deref()
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ mod library_ui;
|
||||
mod live_style;
|
||||
mod net_runtime;
|
||||
mod presets;
|
||||
mod masks_ui;
|
||||
mod segmentation;
|
||||
mod settings_store;
|
||||
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
|
||||
// before the new frame settles is exactly long enough to read it.
|
||||
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.
|
||||
@@ -1723,6 +1729,12 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
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 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);
|
||||
}
|
||||
Reference in New Issue
Block a user