Offer the categories in the masks panel
Everything before this was reachable only from an example that writes PPMs. This is the part a photographer can touch: a list under the subjects, click one, get a mask layer for every pixel of that category. ## Under the subjects, and the order is the argument Clicking the photograph is how a local adjustment usually starts, so the things the model *found* come first. A category is the move you reach for deliberately — grade the sky, not this one bird — and putting it second says so without a word of explanation. Coverage is shown for the same reason a subject's score is: it tells the photographer whether a category is worth a click before they spend one finding out. ## Not a new tab, which is what was asked for A develop tab is derived from `Attribute`, not declared — the tabs exist because operations claim an attribute, and no amount of Slint adds one. A seventh attribute would have meant duplicating every adjustment once per category, and the combinatorics get silly by the third. Reached through masks instead, a category composes with every adjustment that already exists, and inherits feather and falloff rather than needing its own. What was described as "per-category sliders with feather and decay" is exactly what this is; only the door is different. ## Verified as far as it can be here Compiles, populates, round-trips, 560 dr-ui tests green. **Not clicked** — synthetic input is blocked on this setup, so how it looks and feels is unverified and wants a human at the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,7 @@ use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
|
|||||||
|
|
||||||
use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId};
|
use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId};
|
||||||
use crate::segmentation;
|
use crate::segmentation;
|
||||||
use crate::{sync_rows, AppWindow, GradientHandle, MaskRow, ParamRow, SubjectRow};
|
use crate::{sync_rows, AppWindow, CategoryRow, GradientHandle, MaskRow, ParamRow, SubjectRow};
|
||||||
|
|
||||||
/// What the adjust panel's heading says when the controls are global.
|
/// What the adjust panel's heading says when the controls are global.
|
||||||
///
|
///
|
||||||
@@ -146,12 +146,28 @@ impl Running {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A descriptor name as a list label.
|
||||||
|
///
|
||||||
|
/// Only the first letter, because the names in `models/scene/categories.txt`
|
||||||
|
/// are already the words a photographer would use — "sky", "vegetation" — and
|
||||||
|
/// a lookup table would be a second place to edit every time one is added.
|
||||||
|
/// Title case on a multi-word name would be wrong anyway: "Swimming pool", not
|
||||||
|
/// "Swimming Pool".
|
||||||
|
fn category_label(name: &str) -> String {
|
||||||
|
let mut chars = name.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Push every mask-related property from the session into the window.
|
/// Push every mask-related property from the session into the window.
|
||||||
pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
|
pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
|
||||||
let slot = session.borrow();
|
let slot = session.borrow();
|
||||||
let Some(s) = slot.as_ref() else {
|
let Some(s) = slot.as_ref() else {
|
||||||
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
||||||
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
||||||
|
window.set_category_rows(ModelRc::new(VecModel::<CategoryRow>::default()));
|
||||||
window.set_segmented(false);
|
window.set_segmented(false);
|
||||||
window.set_adjust_scope(GLOBAL_SCOPE.into());
|
window.set_adjust_scope(GLOBAL_SCOPE.into());
|
||||||
clear_handles(window);
|
clear_handles(window);
|
||||||
@@ -193,6 +209,19 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
|
|||||||
.collect();
|
.collect();
|
||||||
window.set_subject_rows(ModelRc::new(VecModel::from(subjects)));
|
window.set_subject_rows(ModelRc::new(VecModel::from(subjects)));
|
||||||
|
|
||||||
|
// Already largest-first from the precompute, and already filtered to the
|
||||||
|
// ones with enough coverage to be worth a control.
|
||||||
|
let categories: Vec<CategoryRow> = s
|
||||||
|
.categories()
|
||||||
|
.iter()
|
||||||
|
.map(|c| CategoryRow {
|
||||||
|
name: c.name.as_ref().into(),
|
||||||
|
label: category_label(&c.name).into(),
|
||||||
|
coverage: c.coverage,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
window.set_category_rows(ModelRc::new(VecModel::from(categories)));
|
||||||
|
|
||||||
window.set_segmented(s.has_segmentation());
|
window.set_segmented(s.has_segmentation());
|
||||||
window.set_adjust_scope(scope_label(s).into());
|
window.set_adjust_scope(scope_label(s).into());
|
||||||
sync_handles(window, s);
|
sync_handles(window, s);
|
||||||
@@ -636,6 +665,22 @@ pub(crate) fn wire(
|
|||||||
redraw(&w);
|
redraw(&w);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
window.on_add_category_mask(move |name| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.add_category_mask(&name);
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
@@ -796,6 +841,7 @@ pub(crate) fn reset(window: &AppWindow) {
|
|||||||
window.set_segmented(false);
|
window.set_segmented(false);
|
||||||
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
||||||
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
||||||
|
window.set_category_rows(ModelRc::new(VecModel::<CategoryRow>::default()));
|
||||||
window.set_adjust_scope(GLOBAL_SCOPE.into());
|
window.set_adjust_scope(GLOBAL_SCOPE.into());
|
||||||
clear_handles(window);
|
clear_handles(window);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { AdjustPanel, GeometryPanel, GroupStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
|
import { AdjustPanel, GeometryPanel, GroupStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
|
||||||
import { GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||||
import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
|
import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
|
||||||
import { CropOverlay } from "crop.slint";
|
import { CropOverlay } from "crop.slint";
|
||||||
import { HistoryPanel, HistoryRow } from "history.slint";
|
import { HistoryPanel, HistoryRow } from "history.slint";
|
||||||
@@ -962,6 +962,7 @@ export component AppWindow inherits Window {
|
|||||||
|
|
||||||
in property <[MaskRow]> mask-rows;
|
in property <[MaskRow]> mask-rows;
|
||||||
in property <[SubjectRow]> subject-rows;
|
in property <[SubjectRow]> subject-rows;
|
||||||
|
in property <[CategoryRow]> category-rows;
|
||||||
in property <bool> segmented: false;
|
in property <bool> segmented: false;
|
||||||
in property <bool> segmenting: false;
|
in property <bool> segmenting: false;
|
||||||
in property <bool> refining: false;
|
in property <bool> refining: false;
|
||||||
@@ -1045,6 +1046,7 @@ export component AppWindow inherits Window {
|
|||||||
callback mask-morph-radius-changed(string, float);
|
callback mask-morph-radius-changed(string, float);
|
||||||
callback add-gradient-mask(bool);
|
callback add-gradient-mask(bool);
|
||||||
callback add-subject-mask(int);
|
callback add-subject-mask(int);
|
||||||
|
callback add-category-mask(string);
|
||||||
|
|
||||||
/// Whether the develop column — image info, geometry, adjust — is shown.
|
/// Whether the develop column — image info, geometry, adjust — is shown.
|
||||||
///
|
///
|
||||||
@@ -2472,6 +2474,7 @@ in property <bool> panel-visible: true;
|
|||||||
enabled: root.adjust-enabled;
|
enabled: root.adjust-enabled;
|
||||||
masks: root.mask-rows;
|
masks: root.mask-rows;
|
||||||
subjects: root.subject-rows;
|
subjects: root.subject-rows;
|
||||||
|
categories: root.category-rows;
|
||||||
segmented: root.segmented;
|
segmented: root.segmented;
|
||||||
segmenting: root.segmenting;
|
segmenting: root.segmenting;
|
||||||
refining: root.refining;
|
refining: root.refining;
|
||||||
@@ -2505,6 +2508,7 @@ in property <bool> panel-visible: true;
|
|||||||
}
|
}
|
||||||
add-gradient(radial) => { root.add-gradient-mask(radial); }
|
add-gradient(radial) => { root.add-gradient-mask(radial); }
|
||||||
add-subject(i) => { root.add-subject-mask(i); }
|
add-subject(i) => { root.add-subject-mask(i); }
|
||||||
|
add-category(n) => { root.add-category-mask(n); }
|
||||||
}
|
}
|
||||||
|
|
||||||
if root.local-mode: Rectangle {
|
if root.local-mode: Rectangle {
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ export struct SubjectRow {
|
|||||||
score: float,
|
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 {
|
component MaskEntry inherits Rectangle {
|
||||||
in property <MaskRow> data;
|
in property <MaskRow> data;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
@@ -270,6 +282,7 @@ export component MaskPanel inherits Rectangle {
|
|||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
in property <[MaskRow]> masks;
|
in property <[MaskRow]> masks;
|
||||||
in property <[SubjectRow]> subjects;
|
in property <[SubjectRow]> subjects;
|
||||||
|
in property <[CategoryRow]> categories;
|
||||||
|
|
||||||
/// A region map has been computed for this image.
|
/// A region map has been computed for this image.
|
||||||
in property <bool> segmented: false;
|
in property <bool> segmented: false;
|
||||||
@@ -302,6 +315,7 @@ export component MaskPanel inherits Rectangle {
|
|||||||
|
|
||||||
callback add-gradient(bool);
|
callback add-gradient(bool);
|
||||||
callback add-subject(int);
|
callback add-subject(int);
|
||||||
|
callback add-category(string);
|
||||||
|
|
||||||
background: Theme.surface;
|
background: Theme.surface;
|
||||||
|
|
||||||
@@ -450,6 +464,55 @@ export component MaskPanel inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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 {
|
if root.enabled && root.segmented && root.subjects.length == 0: Caption {
|
||||||
text: "Nothing recognised. The model knows people, animals and vehicles — "
|
text: "Nothing recognised. The model knows people, animals and vehicles — "
|
||||||
+ "a landscape has no subject for it to find. Add a gradient instead.";
|
+ "a landscape has no subject for it to find. Add a gradient instead.";
|
||||||
|
|||||||
Reference in New Issue
Block a user