diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index be8373d..7007883 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -26,6 +26,13 @@ use crate::labels; use crate::ParamRow; /// A loaded image plus its edit state. +/// Longest edge the model and the masks work at. +/// +/// ~1.3 MP at 3:2. Large enough that an outline is within a pixel or two of +/// where it belongs, small enough that a distance transform over it is a few +/// milliseconds and its field a few megabytes. +const SEGMENT_PROXY_EDGE: u32 = 1600; + pub struct DevelopSession { /// Kept so the session can build GPU resources after construction. /// @@ -727,14 +734,16 @@ impl DevelopSession { return false; }; let (pw, ph) = seg.proxy_size(); - let labels = seg.labels(); let subjects = self.subjects.as_ref(); let Some(pass) = self.masks.as_mut() else { return false; }; + // No label field: region masks were the watershed's, and nothing + // produces one any more. A stored layer that still names regions is + // skipped by the rasteriser rather than drawn wrong. pass.render( self.graph.masks(), - labels, + None, subjects, pw as u32, ph as u32, @@ -758,8 +767,8 @@ impl DevelopSession { // The model reads the photograph as captured, not as edited: the // segmentation must survive an exposure change, or every slider would // invalidate the masks that depend on it (docs/segmentation.md §3). - let (rgb, rw, rh) = self.neutral_proxy(ctx, options.segment.max_edge)?; - let seg = segmentation::compute(ctx, &self.demosaiced, &rgb, rw, rh, options)?; + let (rgb, rw, rh) = self.neutral_proxy(ctx, SEGMENT_PROXY_EDGE)?; + let seg = segmentation::compute(ctx, &rgb, rw, rh, options)?; if self.masks.is_none() { self.masks = MaskPass::new(ctx) @@ -819,26 +828,6 @@ impl DevelopSession { self.segmentation.is_some() } - pub fn segmentation_level(&self) -> u32 { - self.segmentation.as_ref().map_or(0, |s| s.level()) - } - - /// Whether a region hierarchy exists to click into. - pub fn has_regions(&self) -> bool { - self.segmentation.as_ref().is_some_and(|s| s.has_regions()) - } - - pub fn segmentation_region_count(&self) -> usize { - self.segmentation.as_ref().map_or(0, |s| s.region_count()) - } - - /// Move the granularity ladder — the scroll wheel over the canvas. - pub fn set_segmentation_level(&mut self, level: u32) { - if let Some(seg) = self.segmentation.as_mut() { - seg.set_level(level); - } - } - /// The subjects the model recognised, as `(label, confidence)`. /// /// Confidence is shown rather than hidden because the detector is offered @@ -951,76 +940,16 @@ impl DevelopSession { .map(|id| id.to_string()); } - /// Select the region under a normalised image point. + /// Mask the object under a normalised image point. /// - /// `add` extends the selected layer instead of replacing its selection, - /// which is the shift-click every selection tool has. With no layer - /// selected a new one is created, because clicking the photograph is how a - /// local adjustment begins and requiring "add layer" first would be a step - /// with no decision in it. + /// Clicking the photograph is how a local adjustment begins, so this + /// creates the layer — requiring "add layer" first would be a step with no + /// decision in it. /// /// Returns the layer that now holds the selection. - pub fn select_region_at(&mut self, x: f32, y: f32, add: bool) -> Option { - // With no region hierarchy — the default — a click means "the object - // under the cursor", which is the interaction the model can actually - // support. `add` has no meaning for a whole object, so it is ignored - // rather than quietly doing something else. - if self.segmentation.as_ref().is_some_and(|s| !s.has_regions()) { - let index = self.segmentation.as_ref()?.instance_at(x, y)?; - return self.add_subject_mask(index); - } - - let seg = self.segmentation.as_ref()?; - let picked = seg.regions_at(x, y); - if picked.is_empty() { - return None; - } - let (signature, level) = (seg.signature(), seg.level()); - - let id = match self.active_mask.clone() { - Some(id) => id, - None => { - let id = self.graph.masks().next_id(); - let layer = MaskLayer::new( - id.clone(), - MaskSource::Regions { - signature, - level, - ids: Vec::new(), - }, - ); - if !self.graph.masks_mut().push(layer) { - return None; - } - self.active_mask = Some(id.clone()); - id - } - }; - - let layer = self.graph.masks_mut().get_mut(&id)?; - let mut ids = match (&layer.source, add) { - (MaskSource::Regions { ids, .. }, true) => ids.clone(), - _ => Vec::new(), - }; - - // Clicking a region already in the selection removes it, so one - // gesture both adds and corrects — the alternative is a modifier for - // subtract that nobody remembers. - if add && picked.iter().all(|r| ids.contains(r)) { - ids.retain(|r| !picked.contains(r)); - } else { - ids.extend(picked); - } - ids.sort_unstable(); - ids.dedup(); - - layer.source = MaskSource::Regions { - signature, - level, - ids, - }; - self.history.record(&self.graph, Edit::Discrete); - Some(id) + pub fn select_region_at(&mut self, x: f32, y: f32) -> Option { + let index = self.segmentation.as_ref()?.instance_at(x, y)?; + self.add_subject_mask(index) } /// Add a layer selecting one detected subject. diff --git a/ui/dr-ui/src/masks_ui.rs b/ui/dr-ui/src/masks_ui.rs index 97810f6..30118a6 100644 --- a/ui/dr-ui/src/masks_ui.rs +++ b/ui/dr-ui/src/masks_ui.rs @@ -30,8 +30,6 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc::default())); window.set_subject_rows(ModelRc::new(VecModel::::default())); window.set_segmented(false); - window.set_segmentation_regions(0); - window.set_has_regions(false); window.set_editing_mask(false); window.set_overlay_on(false); return; @@ -73,9 +71,6 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc::default())); window.set_subject_rows(ModelRc::new(VecModel::::default())); window.set_editing_mask(false); diff --git a/ui/dr-ui/src/segmentation.rs b/ui/dr-ui/src/segmentation.rs index f7ff178..966f9b3 100644 --- a/ui/dr-ui/src/segmentation.rs +++ b/ui/dr-ui/src/segmentation.rs @@ -1,188 +1,100 @@ -//! Running the segmentation for the image on screen. +//! Finding what can be selected in the photograph on screen. //! -//! Joins the two arms into the one thing the develop view needs: a region map -//! whose boundaries came from the watershed and whose grouping came from the -//! model (docs/segmentation.md §5). +//! One model pass, and what it recognised. That is the whole subsystem now. +//! +//! # What used to be here +//! +//! A watershed over-segmented the image, a merge tree turned that into a +//! granularity ladder, and a click walked up it (docs/segmentation.md, arms A +//! and C). It is gone from this path, and the reason is measured rather than +//! aesthetic: on a real photograph the saddles are near zero almost +//! everywhere, so the merge order joins everything meaningful before it joins +//! anything spurious. Cutting a 45,808-basin field of a 22 MP frame to 2,000 +//! regions left **one** region covering nearly the whole picture plus specks +//! (§15). A ladder that collapses is not a ladder. +//! +//! The passes and the hierarchy still exist in `dr-gpu` and `dr-segment`, +//! tested and documented, because it is the *merge criterion* that fails and +//! that is one function. What does not exist any more is a product path +//! through them, or a control offering a choice that does nothing. //! //! # This is a precompute, and it is slow //! -//! Roughly 70 ms of watershed plus ~470 ms of inference plus a full-resolution -//! readback, so it runs **once per image on a worker** and never on the frame -//! path. Every interaction it enables — click a subject, widen a selection, -//! toggle the overlay — reads its cached output and costs a lookup. -//! -//! The consequence for the interface is that segmentation is a thing the user -//! *starts*, and the view must stay usable while it runs. There is no version -//! of this that is fast enough to do silently when a photograph opens. +//! ~700 ms on a 22 MP frame: a proxy render, a readback, and the model. It +//! runs **once per image, when the user asks**, and never on the frame path. +//! Every interaction it enables — click a subject, grow a mask, change a +//! falloff — reads its cached output. use std::sync::Arc; -use dr_gpu::{GpuContext, LabelField, SegmentOptions, SegmentPass}; +use dr_gpu::GpuContext; use dr_pipeline::mask::segmentation_signature; -use dr_segment::prior::{self, Membership, PriorOptions}; -use dr_segment::{MergeTree, RegionField}; -/// How many regions a click's selection starts from. -/// -/// Not the finest level: at the bottom of the ladder a smooth sky bands into -/// strips and a click lands on one of them, which reads as the tool being -/// broken rather than as precision. A few hundred regions over a proxy is -/// roughly "objects and their large parts", which is where a first click -/// should land — and the scroll wheel walks from there in both directions. -pub const DEFAULT_LEVEL: u32 = 300; - -/// One image's region map, ready to select from. -pub struct Segmentation { - /// The watershed's partition, with the semantic prior already applied to - /// its edge weights. - /// - /// **Optional, and off by default.** The hierarchy built on it does not - /// survive a photograph: saddles are near zero almost everywhere, so the - /// merge order joins everything meaningful before it joins anything - /// spurious and a global cut returns one region plus noise. Measured on - /// both a compressed JPEG and a 22 MB CR2 — see docs/segmentation.md §15. - /// Until the merge criterion is replaced, local masking runs on the - /// model's instances and this stays off. - field: Option, - /// The merge order over `field`, which is the granularity ladder. - tree: Option, - /// What the model found, for "select the subject" and for the layer names - /// a user actually recognises. - instances: Vec, - /// The labels on the GPU, for the mask rasteriser. - /// - /// Optional, and not merely for testing: the region map is complete and - /// every selection decision is made on the CPU half above, so an upload - /// that fails costs the *rendering* of a mask and not the ability to - /// compute one. It also lets the selection logic be asserted with no - /// adapter present, which is where the behaviour that matters lives. - labels: Option, - /// Identifies this segmentation, so a stored mask can tell whether its - /// region ids still mean what they meant. - signature: u64, - /// The grouping at the current level: region id -> group id. - grouping: Vec, - level: u32, - /// Size of the space instance masks and the label field are defined in. - /// - /// Everything a mask is built from lives here, in **source** proxy pixels, - /// which is what lets the render sample it after the framing map rather - /// than before — so a mask stays on the photograph through a zoom, a pan - /// and a crop. - proxy: (usize, usize), -} - -/// A detected object, reduced to what the interface needs. -/// -/// The full [`dr_segment::Instance`] carries a soft mask the size of the -/// image; keeping several of those alive per photograph is tens of megabytes -/// for data already folded into the region field. The regions it covers are -/// the durable part. +/// One recognised object. #[derive(Debug, Clone)] pub struct InstanceSummary { pub class_name: Arc, pub score: f32, - /// The regions this instance covers, snapped to watershed boundaries. - /// - /// Empty without a watershed, which is the default, and unread by anything - /// today. Kept — with the lint silenced rather than the field deleted — - /// because it is the mechanism that would give a model's outline the - /// image's own edge, and that is the point of the whole exercise if the - /// hierarchy underneath it is ever made to work. Deleting it would mean - /// rediscovering `regions_for_instance` from the git history. - #[allow(dead_code)] - pub regions: Vec, /// Coverage at proxy resolution, quantised to a byte. /// - /// **This is what the mask is now made of.** A byte rather than the `f32` - /// the model produces: 256 levels is far finer than an edge anyone can - /// see, and at four bytes a pixel a handful of instances would be most of - /// a hundred megabytes for a single photograph. + /// A byte rather than the `f32` the model produces: 256 levels is far + /// finer than an edge anyone can see, and at four bytes a pixel a handful + /// of objects would be most of a hundred megabytes for one photograph. + /// + /// This is the *source* a distance field is built from, not the mask + /// itself — `dr_segment::Shaped` turns it into one. pub mask: Vec, } +/// One image's recognised objects, ready to mask. +pub struct Segmentation { + instances: Vec, + /// Identifies this run, so a stored layer can tell whether the index it + /// holds still means what it meant. + signature: u64, + /// The space instance masks are defined in, in **source** proxy pixels. + /// + /// Everything a mask is built from lives here, which is what lets the + /// render sample it *after* the framing map rather than before — so a mask + /// stays on the photograph through a zoom, a pan and a crop. + proxy: (usize, usize), +} + impl Segmentation { pub fn signature(&self) -> u64 { self.signature } - pub fn labels(&self) -> Option<&LabelField> { - self.labels.as_ref() - } - - pub fn level(&self) -> u32 { - self.level - } - - pub fn region_count(&self) -> usize { - self.field.as_ref().map_or(0, |f| f.region_count) - } - - /// Whether a region hierarchy exists to click into. - pub fn has_regions(&self) -> bool { - self.field.is_some() - } - - /// The space instance masks and the label field are defined in. pub fn proxy_size(&self) -> (usize, usize) { self.proxy } + pub fn instances(&self) -> &[InstanceSummary] { + &self.instances + } + /// One instance's coverage, at [`Self::proxy_size`]. pub fn instance_mask(&self, index: usize) -> Option<&[u8]> { self.instances.get(index).map(|i| i.mask.as_slice()) } - pub fn instances(&self) -> &[InstanceSummary] { - &self.instances - } - - /// Move the granularity ladder, recomputing the grouping. + /// The strongest instance covering a point in normalised image + /// coordinates. /// - /// Cheap — a union-find over a few thousand nodes — which is what makes - /// the scroll wheel a live control rather than a re-segmentation. - pub fn set_level(&mut self, level: u32) { - let (Some(field), Some(tree)) = (self.field.as_ref(), self.tree.as_ref()) else { - return; - }; - let level = level.clamp(2, field.region_count.max(2) as u32); - if level == self.level { - return; - } - self.level = level; - self.grouping = tree.cut_to(level as usize); - } - - /// The group under a point in **normalised image coordinates**. - /// - /// Normalised because the caller has a click in a widget that is letter- - /// boxed, zoomed and possibly cropped; converting once at the call site is - /// clearer than passing three transforms in here. - pub fn group_at(&self, x: f32, y: f32) -> Option { - let field = self.field.as_ref()?; - if !(0.0..1.0).contains(&x) || !(0.0..1.0).contains(&y) { - return None; - } - let px = ((x * field.width as f32) as usize).min(field.width - 1); - let py = ((y * field.height as f32) as usize).min(field.height - 1); - let region = field.labels[py * field.width + px]; - self.grouping.get(region as usize).copied() - } - - /// The strongest instance covering a point, for click-to-select. - /// - /// Strongest rather than smallest: the detections are score-ordered and + /// Strongest rather than smallest: detections are score-ordered and /// overlapping ones are usually the same object found twice, so the more - /// confident is the better guess. A person standing in front of a bus - /// wins over the bus because the person's mask is the one under the - /// cursor at all. + /// confident is the better guess. A person in front of a bus wins over the + /// bus, because the person's mask is the one under the cursor at all. pub fn instance_at(&self, x: f32, y: f32) -> Option { if !(0.0..1.0).contains(&x) || !(0.0..1.0).contains(&y) { return None; } let (w, h) = self.proxy; - let px = ((x * w as f32) as usize).min(w.saturating_sub(1)); - let py = ((y * h as f32) as usize).min(h.saturating_sub(1)); + if w == 0 || h == 0 { + return None; + } + let px = ((x * w as f32) as usize).min(w - 1); + let py = ((y * h as f32) as usize).min(h - 1); let p = py * w + px; self.instances @@ -193,65 +105,24 @@ impl Segmentation { .map(|(i, _)| i) } - /// Every region belonging to a group — the ids a mask stores. - pub fn regions_in_group(&self, group: u32) -> Vec { - let Some(field) = self.field.as_ref() else { - return Vec::new(); - }; - (0..field.region_count as u32) - .filter(|&r| self.grouping.get(r as usize) == Some(&group)) - .collect() - } - - /// The regions under a click, at the current level. - pub fn regions_at(&self, x: f32, y: f32) -> Vec { - self.group_at(x, y) - .map(|g| self.regions_in_group(g)) - .unwrap_or_default() - } - - /// A false-coloured RGBA picture of what a click can select. + /// A false-coloured picture of what a click can select, in source space. /// - /// The diagnostic that makes the segmentation arguable rather than a - /// matter of faith — and the thing a user wants when deciding whether a - /// click will land where they mean. - /// - /// **Transparent where nothing is selectable**, which is the difference - /// between this and the region version it replaced. A region map covers - /// every pixel, so it hid the photograph it was drawn over; instances - /// cover the objects and nothing else, so the frame stays visible and the - /// question "does this outline follow the subject" can actually be - /// answered by looking. + /// **Transparent where nothing is selectable.** The region map this + /// replaced covered every pixel and so hid the photograph it was drawn + /// over; the question an overlay exists to answer is whether an outline + /// follows the subject, and that can only be answered by seeing both. pub fn overlay_rgba(&self) -> (Vec, u32, u32) { - match self.field.as_ref() { - Some(field) => self.region_overlay(field), - None => self.instance_overlay(), - } - } - - fn instance_overlay(&self) -> (Vec, u32, u32) { let (w, h) = self.proxy; let mut out = vec![0u8; w * h * 4]; - // Painted strongest-last, so where two detections overlap the more - // confident one is the colour on top — matching which of them a click - // would select. + // Weakest first, so where two detections overlap the more confident + // one is the colour on top — matching which a click would select. let mut order: Vec = (0..self.instances.len()).collect(); - order.sort_by(|&a, &b| { - self.instances[a] - .score - .total_cmp(&self.instances[b].score) - }); + order.sort_by(|&a, &b| self.instances[a].score.total_cmp(&self.instances[b].score)); - for (rank, &i) in order.iter().enumerate() { - let instance = &self.instances[i]; - // Keyed by the instance's own index rather than by paint order, so - // a colour belongs to an object and does not change when another - // detection appears beside it. - let [r, g, b] = group_colour(i as u32); - let _ = rank; - - for (p, &cov) in instance.mask.iter().enumerate() { + for &i in &order { + let [r, g, b] = instance_colour(i as u32); + for (p, &cov) in self.instances[i].mask.iter().enumerate() { if cov < 128 || p * 4 + 3 >= out.len() { continue; } @@ -262,9 +133,10 @@ impl Segmentation { } } - // The outline drawn opaque over the fill. It is the part being judged - // — a fill can look right while its edge sits several pixels off the - // subject — and it survives the low opacity the fill is composited at. + // The outline drawn opaque white over the fill. It is the part being + // judged — a fill can look right while its edge sits several pixels + // off the subject — and it survives the low opacity the fill is + // composited at. let solid = |p: usize| out.get(p * 4 + 3).is_some_and(|&a| a > 0); let mut edges = Vec::new(); for y in 0..h { @@ -288,51 +160,15 @@ impl Segmentation { (out, w as u32, h as u32) } - - fn region_overlay(&self, field: &RegionField) -> (Vec, u32, u32) { - let (w, h) = (field.width, field.height); - let mut out = vec![0u8; w * h * 4]; - - for (p, ®ion) in field.labels.iter().enumerate() { - let group = self.grouping.get(region as usize).copied().unwrap_or(0); - let [r, g, b] = group_colour(group); - out[p * 4] = r; - out[p * 4 + 1] = g; - out[p * 4 + 2] = b; - out[p * 4 + 3] = 255; - } - - // Boundaries drawn black over the fill. Without them two adjacent - // groups that happen to land on near hues read as one region, which is - // exactly the judgement the overlay exists to support. - for y in 0..h { - for x in 0..w { - let here = self.grouping[field.labels[y * w + x] as usize]; - let right = (x + 1 < w).then(|| self.grouping[field.labels[y * w + x + 1] as usize]); - let down = (y + 1 < h).then(|| self.grouping[field.labels[(y + 1) * w + x] as usize]); - if right.is_some_and(|g| g != here) || down.is_some_and(|g| g != here) { - let p = (y * w + x) * 4; - out[p..p + 3].copy_from_slice(&[0, 0, 0]); - } - } - } - - (out, w as u32, h as u32) - } } -/// A distinct colour per group. +/// A distinct colour per instance. /// -/// Golden-angle hue stepping: consecutive ids land far apart on the wheel, so -/// neighbouring regions — which usually *do* have consecutive ids, since the -/// field is compacted in raster order — are visually separated. Deterministic -/// rather than random, so the same region is the same colour every time the -/// overlay is drawn and the eye can track it across a level change. -fn group_colour(group: u32) -> [u8; 3] { - let h = (group as f32 * 137.508) % 360.0; - // Fixed saturation and value: a fully saturated palette is easier to - // separate than one that also varies in lightness, and the image beneath - // supplies all the tonal variation the eye needs. +/// Golden-angle hue stepping, deterministic rather than random: the same +/// object is the same colour every time the overlay is drawn, so the eye can +/// track it while a mask is being shaped. +fn instance_colour(index: u32) -> [u8; 3] { + let h = (index as f32 * 137.508) % 360.0; let c = 230.0; let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); let (r, g, b) = match (h / 60.0) as u32 { @@ -349,139 +185,65 @@ fn group_colour(group: u32) -> [u8; 3] { /// What to run. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Options { - pub segment: SegmentOptions, - pub prior: PriorOptions, - /// Whether to run the model at all. + /// Detections below this are dropped. /// - /// This is the one that matters now: with it off there is nothing to - /// select, because the watershed below is not currently usable. - pub semantic: bool, - /// Whether to run the watershed and build the region hierarchy. - /// - /// **Off by default.** It costs ~80 ms plus a full-resolution readback and - /// currently produces a ladder that collapses (docs/segmentation.md §15), - /// so paying for it on every photograph buys a control that misleads. - /// Kept switchable rather than deleted: the passes and the hierarchy are - /// correct in themselves and it is the *merge criterion* that fails, which - /// is a change to one function rather than to the subsystem. - pub watershed: bool, + /// Deliberately low. A weak detection costs a spurious entry in a list the + /// user is choosing from, and its score is shown beside it; a missed one + /// costs a subject that cannot be selected at all, which is the worse + /// failure for a selection tool. + pub confidence: f32, } impl Default for Options { fn default() -> Self { - Self { - segment: SegmentOptions::default(), - prior: PriorOptions::default(), - semantic: true, - watershed: false, - } + Self { confidence: 0.30 } } } /// Find what can be selected in this photograph. /// /// `rgb` is the proxy the model reads — tightly packed RGB floats at -/// `(rgb_width, rgb_height)`. Passed in rather than derived here because the -/// caller already has the decoded image, and re-deriving it would mean a -/// second readback of something the CPU is holding. -/// -/// The watershed is optional and off by default; see [`Options::watershed`]. +/// `(width, height)`. Passed in rather than derived here because the caller +/// already has the rendered proxy, and re-deriving it would mean a second +/// readback of something the CPU is holding. pub fn compute( - ctx: &GpuContext, - source: &dr_gpu::DemosaicedImage, + _ctx: &GpuContext, rgb: &[f32], - rgb_width: usize, - rgb_height: usize, + width: usize, + height: usize, options: &Options, ) -> Result { - let instances = if options.semantic { - detect(rgb, rgb_width, rgb_height)? - } else { - Vec::new() - }; + let found = detect(rgb, width, height)?; - let mut field = None; - let mut tree = None; - let mut labels = None; - let mut grouping = Vec::new(); - let mut summaries: Vec = Vec::new(); - - if options.watershed { - let pass = SegmentPass::new(ctx).map_err(|e| format!("watershed unavailable: {e}"))?; - let raw = pass - .run(source, options.segment) - .map_err(|e| format!("watershed failed: {e}"))?; - let raw_field = raw - .read_field() - .map_err(|e| format!("could not read the region field: {e}"))?; - - let (weighted, snapped) = apply_prior(raw_field, &instances, &options.prior); - let built = MergeTree::build(&weighted); - grouping = built.cut_to(DEFAULT_LEVEL as usize); - - let (w, h) = (weighted.width as u32, weighted.height as u32); - labels = LabelField::upload(ctx, &weighted.labels, w, h, weighted.region_count as u32) - .inspect_err(|e| log::warn!("could not upload the label field: {e}")) - .ok(); - - summaries = snapped; - field = Some(weighted); - tree = Some(built); - } - - // Whether or not the watershed ran, the instances themselves are the - // masks. `apply_prior` fills in region ids when there is a hierarchy to - // snap to; this fills in the pixels either way. - if summaries.is_empty() { - summaries = instances - .iter() - .filter(|i| i.score >= options.prior.confidence) - .map(|i| InstanceSummary { - class_name: i.class_name.clone(), - score: i.score, - regions: Vec::new(), - mask: quantise(&i.mask), - }) - .collect(); - } else { - for (summary, instance) in summaries.iter_mut().zip( - instances - .iter() - .filter(|i| i.score >= options.prior.confidence), - ) { - summary.mask = quantise(&instance.mask); - } - } - - let proxy = match field.as_ref() { - Some(f) => (f.width, f.height), - None => (rgb_width, rgb_height), - }; + let instances: Vec = found + .iter() + .filter(|i| i.score >= options.confidence) + .map(|i| InstanceSummary { + class_name: i.class_name.clone(), + score: i.score, + mask: quantise(&i.mask), + }) + .collect(); let signature = segmentation_signature( - proxy.0 as u32, - proxy.1 as u32, - summaries.len() as u32, - tuning_hash(options), + width as u32, + height as u32, + instances.len() as u32, + options.confidence.to_bits() as u64, ); Ok(Segmentation { - field, - tree, - instances: summaries, - labels, + instances, signature, - grouping, - level: DEFAULT_LEVEL, - proxy, + proxy: (width, height), }) } -/// Arm B. Loads the model each call rather than holding a session open. +/// Load the model and run it. /// -/// ~24 ms against the ~470 ms of inference that follows it, and it runs once -/// per image — so caching the session would keep 11 MB of weights resident for -/// the whole life of the app to save five percent of a background task. +/// Loading is ~24 ms against the ~470 ms of inference that follows, and this +/// runs once per image — so caching the session would keep 11 MB of weights +/// resident for the life of the app to save five percent of a background task. fn detect(rgb: &[f32], width: usize, height: usize) -> Result, String> { let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?; model @@ -499,517 +261,140 @@ fn quantise(mask: &[f32]) -> Vec { .collect() } -/// Arm C: fold the instances into the merge weights, and record what each one -/// selects. -fn apply_prior( - field: RegionField, - instances: &[dr_segment::Instance], - options: &PriorOptions, -) -> (RegionField, Vec) { - let strong: Vec<&dr_segment::Instance> = instances - .iter() - .filter(|i| i.score >= options.confidence) - .collect(); - - if strong.is_empty() { - return (field, Vec::new()); - } - - let masks: Vec<&[f32]> = strong.iter().map(|i| i.mask.as_slice()).collect(); - let Ok(membership) = Membership::compute(&field, &masks, options) else { - // Only reachable if a mask disagrees with the field's size, which - // would be a bug rather than a user-facing condition. Arm A alone is - // the right degradation. - log::warn!("instance masks do not match the region field; using watershed alone"); - return (field, Vec::new()); - }; - - let summaries = strong - .iter() - .map(|i| InstanceSummary { - class_name: i.class_name.clone(), - score: i.score, - regions: prior::regions_for_instance(&field, &i.mask, options), - // Filled in by `compute`, which quantises every instance the same - // way whether or not a hierarchy was built. - mask: Vec::new(), - }) - .collect(); - - ( - prior::apply_semantic_prior(&field, &membership, options), - summaries, - ) -} - -/// Everything about the run that changes what a region id means. -/// -/// Folded into the signature so a mask stored under one tuning is not silently -/// reinterpreted under another. Not a general-purpose hash of `Options` — the -/// prior's strength changes the *grouping* but not the underlying regions, and -/// including it would invalidate masks that are still perfectly valid. -fn tuning_hash(options: &Options) -> u64 { - let s = &options.segment; - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for word in [ - s.max_edge as u64, - s.blur_radius as u64, - s.w_luma.to_bits() as u64, - s.w_chroma.to_bits() as u64, - s.plateau_iterations as u64, - u64::from(options.semantic), - ] { - for byte in word.to_le_bytes() { - h ^= byte as u64; - h = h.wrapping_mul(0x1000_0000_01b3); - } - } - h -} - #[cfg(test)] mod tests { use super::*; - /// A 4x2 field split down the middle. - /// - /// Regions 0 (top-left) and 1 (bottom-left) make the left half; 2 and 3 - /// the right. The seam between the halves is the strong boundary and the - /// within-half seams are weak, so coarsening merges each half before it - /// ever joins the two — which is the structure a real picture of a subject - /// against a background has, and the one every assertion below depends on. - fn field() -> RegionField { - RegionField { - width: 4, - height: 2, - labels: vec![0, 0, 2, 2, 1, 1, 3, 3], - region_count: 4, - adjacency: vec![ - dr_segment::Edge { a: 0, b: 1, saddle: 0.1 }, - dr_segment::Edge { a: 2, b: 3, saddle: 0.1 }, - dr_segment::Edge { a: 0, b: 2, saddle: 1.0 }, - dr_segment::Edge { a: 1, b: 3, saddle: 1.0 }, - ], + /// Two objects: a big weak one on the left, a small strong one that + /// overlaps it. + fn overlapping() -> Segmentation { + let (w, h) = (8usize, 4usize); + let mut big = vec![0u8; w * h]; + let mut small = vec![0u8; w * h]; + for y in 0..h { + for x in 0..6 { + big[y * w + x] = 255; + } + for x in 4..8 { + small[y * w + x] = 255; + } } - } - /// A segmentation with no GPU field, which is all the selection logic - /// needs — every decision below is made on the CPU half. - fn segmentation(level: u32) -> Segmentation { - let field = field(); - let tree = MergeTree::build(&field); - let grouping = tree.cut_to(level as usize); Segmentation { - proxy: (field.width, field.height), - field: Some(field), - tree: Some(tree), - instances: Vec::new(), - labels: None, + instances: vec![ + InstanceSummary { + class_name: "bus".into(), + score: 0.5, + mask: big, + }, + InstanceSummary { + class_name: "person".into(), + score: 0.9, + mask: small, + }, + ], signature: 1, - grouping, - level, + proxy: (w, h), } } #[test] - fn a_click_lands_on_the_group_under_it() { - // Four regions cut to two groups: the field's equal saddles merge in - // (a, b) order, so 0-1 and 2-3 pair up — left half and right half. - let seg = segmentation(2); + fn a_click_finds_the_object_under_it() { + let seg = overlapping(); + assert_eq!(seg.instance_at(0.1, 0.5), Some(0), "only the bus here"); + assert_eq!(seg.instance_at(0.95, 0.5), Some(1), "only the person here"); + } - let left = seg.group_at(0.1, 0.5).expect("inside the frame"); - let right = seg.group_at(0.9, 0.5).expect("inside the frame"); - assert_ne!(left, right, "the two halves are different groups"); - - // The same group whichever row is clicked, which is what "region" - // means and what a per-pixel lookup would not give. - assert_eq!(seg.group_at(0.1, 0.1), Some(left)); - assert_eq!(seg.group_at(0.1, 0.9), Some(left)); + /// The overlap rule, and the one that decides what a click means where two + /// detections cover the same pixel. + #[test] + fn overlapping_objects_resolve_to_the_more_confident() { + let seg = overlapping(); + assert_eq!( + seg.instance_at(0.6, 0.5), + Some(1), + "the person at 0.9 beats the bus at 0.5" + ); } #[test] fn a_click_outside_the_frame_selects_nothing() { - let seg = segmentation(2); - assert_eq!(seg.group_at(-0.1, 0.5), None); - assert_eq!(seg.group_at(1.5, 0.5), None); - assert_eq!(seg.group_at(0.5, 1.0), None, "the far edge is exclusive"); + let seg = overlapping(); + assert_eq!(seg.instance_at(-0.1, 0.5), None); + assert_eq!(seg.instance_at(1.5, 0.5), None); + assert_eq!(seg.instance_at(0.5, 1.0), None, "the far edge is exclusive"); } #[test] - fn selecting_a_group_returns_every_region_in_it() { - let seg = segmentation(2); - let regions = seg.regions_at(0.1, 0.5); - assert_eq!(regions.len(), 2, "each half is two merged regions"); - assert!(regions.windows(2).all(|w| w[0] < w[1]), "sorted for the mask"); - } - - /// The scroll wheel. Coarsening must not re-segment, and must actually - /// change what a click selects. - #[test] - fn the_level_changes_what_a_click_selects() { - let mut seg = segmentation(4); - assert_eq!(seg.regions_at(0.1, 0.5).len(), 1, "finest: one region"); - - seg.set_level(2); - assert_eq!(seg.level(), 2); - assert_eq!(seg.regions_at(0.1, 0.5).len(), 2, "coarser: the pair"); - } - - #[test] - fn the_level_is_clamped_to_what_exists() { - let mut seg = segmentation(4); - seg.set_level(9999); - assert!(seg.level() <= seg.region_count() as u32); - - seg.set_level(0); - assert!(seg.level() >= 2, "one group would select the whole frame"); - } - - #[test] - fn the_overlay_covers_every_pixel_and_draws_boundaries() { - let seg = segmentation(2); - let (rgba, w, h) = seg.overlay_rgba(); - assert_eq!((w, h), (4, 2)); - assert_eq!(rgba.len(), 4 * 2 * 4); - assert!(rgba.chunks_exact(4).all(|p| p[3] == 255), "fully opaque"); - - // The vertical seam between the halves must be drawn. - let at = |x: usize, y: usize| { - let p = (y * 4 + x) * 4; - [rgba[p], rgba[p + 1], rgba[p + 2]] + fn a_click_on_nothing_selects_nothing() { + let seg = Segmentation { + instances: Vec::new(), + signature: 1, + proxy: (4, 4), }; - assert_eq!(at(1, 0), [0, 0, 0], "the boundary column is black"); - assert_ne!(at(0, 0), [0, 0, 0], "the interior is not"); - } - - #[test] - fn the_two_halves_get_different_colours() { - let seg = segmentation(2); - let (rgba, _, _) = seg.overlay_rgba(); - let left = [rgba[0], rgba[1], rgba[2]]; - let right = { - let p = 3 * 4; - [rgba[p], rgba[p + 1], rgba[p + 2]] - }; - assert_ne!(left, right); + assert_eq!(seg.instance_at(0.5, 0.5), None); } #[test] fn colours_are_stable_and_distinct() { - assert_eq!(group_colour(7), group_colour(7), "same id, same colour"); - assert_ne!(group_colour(0), group_colour(1)); - assert_ne!(group_colour(1), group_colour(2)); + assert_eq!(instance_colour(7), instance_colour(7)); + assert_ne!(instance_colour(0), instance_colour(1)); + assert_ne!(instance_colour(1), instance_colour(2)); } #[test] - fn every_colour_is_visible_against_the_image() { - // The overlay is composited over a photograph, so a colour that is - // nearly black is a region the user cannot see they have selected. - for id in 0..64u32 { - let [r, g, b] = group_colour(id); - let brightest = r.max(g).max(b); - assert!(brightest >= 200, "group {id} is too dark: {r},{g},{b}"); + fn every_colour_is_visible_against_a_photograph() { + for i in 0..64u32 { + let [r, g, b] = instance_colour(i); + assert!(r.max(g).max(b) >= 200, "instance {i} is too dark"); } } + /// The overlay must not cover the picture: that is the difference between + /// this and the region map it replaced. #[test] - fn tuning_changes_the_signature_but_the_prior_does_not() { - let base = Options::default(); - - let mut coarser = base; - coarser.segment.max_edge = 800; - assert_ne!(tuning_hash(&base), tuning_hash(&coarser), "proxy size"); - - let mut no_model = base; - no_model.semantic = false; - assert_ne!(tuning_hash(&base), tuning_hash(&no_model), "arm B on/off"); - - // The prior reweights the merge order over the *same* regions, so a - // mask's ids still mean what they meant. Invalidating them here would - // discard work for no reason. - let mut stronger = base; - stronger.prior.strength = 0.1; - assert_eq!(tuning_hash(&base), tuning_hash(&stronger)); - } - - // ------------------------------------------------------------------ - // On a device, end to end - // ------------------------------------------------------------------ - - /// A bright disc on a dark ground: one unambiguous boundary, which is the - /// backlit-silhouette control case from docs/segmentation.md §9. - fn disc(size: u32) -> Vec { - let r = size as f32 * 0.28; - let c = size as f32 / 2.0; - let mut out = Vec::with_capacity((size * size) as usize * 4); - for y in 0..size { - for x in 0..size { - let d = ((x as f32 + 0.5 - c).powi(2) + (y as f32 + 0.5 - c).powi(2)).sqrt(); - let v = if d < r { 230 } else { 30 }; - out.extend_from_slice(&[v, v, v, 255]); - } - } - out - } - - fn context() -> Option { - pollster::block_on(dr_gpu::GpuContext::new_headless()).ok() - } - - /// The whole arm-A chain on a real adapter: watershed, region graph, merge - /// tree, and a click landing on the disc rather than on the ground. - /// - /// The CPU tests above use hand-built fields, which cannot catch a - /// watershed that produces nothing, a proxy scaled the wrong way, or a - /// click transformed into the wrong pixel. - #[test] - fn a_real_segmentation_separates_the_disc_from_the_ground() { - let Some(ctx) = context() else { - eprintln!("no adapter; skipping"); - return; + fn the_overlay_is_transparent_where_nothing_was_found() { + let seg = Segmentation { + instances: Vec::new(), + signature: 1, + proxy: (4, 4), }; - - const SIZE: u32 = 256; - let rgba = disc(SIZE); - let source = dr_gpu::DemosaicedImage::from_rgba8(&ctx, &rgba, SIZE, SIZE).expect("upload"); - let rgb: Vec = rgba - .chunks_exact(4) - .flat_map(|p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]) - .collect(); - - // Arm A alone. The model has no COCO class for "grey disc", so running - // it here would cost half a second to contribute nothing — and the - // point of this test is the watershed half. - let options = Options { - semantic: false, - // Explicit: the watershed is off by default now, and these tests - // exist to exercise it. - watershed: true, - ..Options::default() - }; - - let mut seg = compute(&ctx, &source, &rgb, SIZE as usize, SIZE as usize, &options) - .expect("segmentation"); - assert!(seg.region_count() > 1, "a boundary should make regions"); - - // Coarse enough that the disc is one region rather than several. - seg.set_level(4); - - let centre = seg.group_at(0.5, 0.5).expect("centre is in frame"); - let corner = seg.group_at(0.04, 0.04).expect("corner is in frame"); - assert_ne!( - centre, corner, - "the disc and the ground must not be one group" - ); - - // And the selection must be the disc, not the whole frame: a mask that - // covers everything is the failure mode a "they differ" assertion on - // its own would not catch. - let selected = seg.regions_at(0.5, 0.5); - assert!(!selected.is_empty()); - assert!( - selected.len() < seg.region_count(), - "selecting the disc should not select every region" - ); + let (px, w, h) = seg.overlay_rgba(); + assert_eq!((w, h), (4, 4)); + assert!(px.chunks_exact(4).all(|p| p[3] == 0), "nothing to draw"); } #[test] - fn the_overlay_matches_the_segmentation_it_describes() { - let Some(ctx) = context() else { - eprintln!("no adapter; skipping"); - return; + fn the_overlay_outlines_what_it_fills() { + let seg = overlapping(); + let (px, w, _) = seg.overlay_rgba(); + let at = |x: usize, y: usize| { + let p = (y * w as usize + x) * 4; + [px[p], px[p + 1], px[p + 2], px[p + 3]] }; - - const SIZE: u32 = 128; - let rgba = disc(SIZE); - let source = dr_gpu::DemosaicedImage::from_rgba8(&ctx, &rgba, SIZE, SIZE).expect("upload"); - let rgb: Vec = rgba - .chunks_exact(4) - .flat_map(|p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]) - .collect(); - - let options = Options { - semantic: false, - // Explicit: the watershed is off by default now, and these tests - // exist to exercise it. - watershed: true, - ..Options::default() - }; - let mut seg = compute(&ctx, &source, &rgb, SIZE as usize, SIZE as usize, &options) - .expect("segmentation"); - seg.set_level(4); - - let (pixels, w, h) = seg.overlay_rgba(); - assert_eq!(pixels.len(), (w * h) as usize * 4); - - // The disc's centre and the ground must be drawn differently, or the - // overlay is not showing the thing it claims to show. - let at = |x: u32, y: u32| { - let p = ((y * w + x) * 4) as usize; - [pixels[p], pixels[p + 1], pixels[p + 2]] - }; - assert_ne!(at(w / 2, h / 2), at(2, 2)); + // The rightmost column of the person is against the frame edge, so it + // is an outline pixel. + assert_eq!(at(7, 1), [255, 255, 255, 255]); + // And an interior pixel keeps its fill. + assert_ne!(at(2, 1)[3], 0, "the bus is filled"); + assert_ne!(at(2, 1), [255, 255, 255, 255], "and not all outline"); } - /// The signature has to change when a mask's ids would mean something - /// different, and this is the case that actually happens: the same image - /// segmented at another proxy size. #[test] - fn re_segmenting_at_another_size_invalidates_stored_ids() { - let Some(ctx) = context() else { - eprintln!("no adapter; skipping"); - return; - }; - - const SIZE: u32 = 192; - let rgba = disc(SIZE); - let source = dr_gpu::DemosaicedImage::from_rgba8(&ctx, &rgba, SIZE, SIZE).expect("upload"); - let rgb: Vec = rgba - .chunks_exact(4) - .flat_map(|p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]) - .collect(); - - let mut coarse = Options { - semantic: false, - watershed: true, - ..Options::default() - }; - coarse.segment.max_edge = 96; - - let mut fine = coarse; - fine.segment.max_edge = 192; - - let a = compute(&ctx, &source, &rgb, SIZE as usize, SIZE as usize, &coarse).expect("a"); - let b = compute(&ctx, &source, &rgb, SIZE as usize, SIZE as usize, &fine).expect("b"); - assert_ne!( - a.signature(), - b.signature(), - "ids from one proxy must not be read as ids from another" - ); + fn quantising_rounds_rather_than_truncates() { + // Exactly half must reach the threshold the selection tests against. + assert_eq!(quantise(&[0.5]), vec![128]); + assert_eq!(quantise(&[0.0, 1.0]), vec![0, 255]); + // And values outside the range cannot wrap. + assert_eq!(quantise(&[-1.0, 2.0]), vec![0, 255]); } - /// Run-to-run stability on one device, which is what lets a selection be - /// stored and reopened at all (M5). #[test] - fn the_same_image_segments_identically_twice() { - let Some(ctx) = context() else { - eprintln!("no adapter; skipping"); - return; - }; - - const SIZE: u32 = 128; - let rgba = disc(SIZE); - let source = dr_gpu::DemosaicedImage::from_rgba8(&ctx, &rgba, SIZE, SIZE).expect("upload"); - let rgb: Vec = rgba - .chunks_exact(4) - .flat_map(|p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]) - .collect(); - let options = Options { - semantic: false, - // Explicit: the watershed is off by default now, and these tests - // exist to exercise it. - watershed: true, - ..Options::default() - }; - - let a = compute(&ctx, &source, &rgb, SIZE as usize, SIZE as usize, &options).expect("a"); - let b = compute(&ctx, &source, &rgb, SIZE as usize, SIZE as usize, &options).expect("b"); - - assert_eq!(a.signature(), b.signature()); - assert_eq!(a.region_count(), b.region_count()); - assert_eq!(a.regions_at(0.5, 0.5), b.regions_at(0.5, 0.5)); - } - - /// Dump the granularity ladder for a real photograph, to look at. - /// - /// Ignored, because it needs a file and writes several megabytes. It is - /// committed rather than kept in a scratch directory because "does the - /// ladder land on things a person means" is the question the whole spike - /// exists to answer (docs/segmentation.md §11 step 2), and it should be - /// answerable by anyone who picks this up rather than by whoever still has - /// the script. - /// - /// ```sh - /// # A P6 PPM, which needs no decoder here: - /// # magick photo.jpg -colorspace sRGB photo.ppm - /// DARKROOM_TEST_PPM=photo.ppm DARKROOM_OUT=/tmp/ws \ - /// cargo test -p dr-ui --release ladder_for_a_photograph -- --ignored --nocapture - /// ``` - #[test] - #[ignore = "needs a photograph; run by hand when judging the segmentation"] - fn ladder_for_a_photograph() { - let Ok(path) = std::env::var("DARKROOM_TEST_PPM") else { - eprintln!("set DARKROOM_TEST_PPM to a P6 .ppm"); - return; - }; - let prefix = std::env::var("DARKROOM_OUT").unwrap_or_else(|_| "ladder".into()); - let Some(ctx) = context() else { - eprintln!("no adapter"); - return; - }; - - let (rgb8, w, h) = read_ppm(&path); - println!("image {w} × {h}"); - - let rgba: Vec = rgb8 - .chunks_exact(3) - .flat_map(|p| [p[0], p[1], p[2], 255]) - .collect(); - let source = dr_gpu::DemosaicedImage::from_rgba8(&ctx, &rgba, w as u32, h as u32) - .expect("upload"); - let rgb: Vec = rgb8.iter().map(|&v| v as f32 / 255.0).collect(); - - for blur in [2, 5, 9] { - let mut options = Options { - semantic: false, - ..Options::default() - }; - options.segment.blur_radius = blur; - - let t = std::time::Instant::now(); - let mut seg = compute(&ctx, &source, &rgb, w, h, &options).expect("segmentation"); - println!( - "blur {blur:>2} {} regions in {:.0} ms", - seg.region_count(), - t.elapsed().as_secs_f32() * 1000.0 - ); - - for level in [1200, 400, 120, 40, 12] { - seg.set_level(level); - let (px, ow, oh) = seg.overlay_rgba(); - let rgb: Vec = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); - write_ppm(&format!("{prefix}-b{blur}-l{level}.ppm"), &rgb, ow, oh); - } - } - println!("wrote {prefix}-b*-l*.ppm"); - } - - fn read_ppm(path: &str) -> (Vec, usize, usize) { - let bytes = std::fs::read(path).expect("read ppm"); - // P6\n \n\n. Tokens are whitespace-separated, and - // comments are not handled because the writer above never emits them. - let mut fields = Vec::new(); - let mut i = 0; - while fields.len() < 4 { - while i < bytes.len() && bytes[i].is_ascii_whitespace() { - i += 1; - } - let start = i; - while i < bytes.len() && !bytes[i].is_ascii_whitespace() { - i += 1; - } - fields.push(String::from_utf8_lossy(&bytes[start..i]).to_string()); - } - assert_eq!(fields[0], "P6", "expected a binary PPM"); - let w: usize = fields[1].parse().expect("width"); - let h: usize = fields[2].parse().expect("height"); - (bytes[i + 1..].to_vec(), w, h) - } - - fn write_ppm(path: &str, rgb: &[u8], w: u32, h: u32) { - use std::io::Write as _; - let mut f = std::io::BufWriter::new(std::fs::File::create(path).expect("create")); - write!(f, "P6\n{w} {h}\n255\n").expect("header"); - f.write_all(rgb).expect("body"); + fn confidence_changes_the_signature() { + // A different threshold is a different instance list, so the indices a + // stored layer holds mean something else. + let a = segmentation_signature(100, 100, 3, Options::default().confidence.to_bits() as u64); + let b = segmentation_signature(100, 100, 3, 0.9f32.to_bits() as u64); + assert_ne!(a, b); } } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 7ec1ea4..c9565bc 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -819,15 +819,11 @@ export component AppWindow inherits Window { in property <[SubjectRow]> subject-rows; in property segmented: false; in property segmenting: false; - in property segmentation-level: 300; - in property segmentation-regions: 0; - in property has-regions: false; in property 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); @@ -1906,9 +1902,6 @@ in property panel-visible: true; subjects: root.subject-rows; segmented: root.segmented; segmenting: root.segmenting; - level: root.segmentation-level; - region-count: root.segmentation-regions; - has-regions: root.has-regions; overlay: root.overlay-on; picking: root.region-picking; editing-mask: root.editing-mask; @@ -1916,7 +1909,6 @@ in property panel-visible: true; 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); } diff --git a/ui/dr-ui/ui/masks.slint b/ui/dr-ui/ui/masks.slint index fed949c..715efd7 100644 --- a/ui/dr-ui/ui/masks.slint +++ b/ui/dr-ui/ui/masks.slint @@ -223,16 +223,6 @@ export component MaskPanel inherits Rectangle { in property segmented: false; /// One is being computed now. in property segmenting: false; - /// How many regions the ladder is currently cut to. - in property level: 300; - in property region-count: 0; - /// Whether a region hierarchy exists to click into. - /// - /// Off in the ordinary case. The watershed's ladder collapses on a - /// photograph, so the controls that drive it are hidden rather than shown - /// doing nothing — a slider that changes no outcome is worse than an - /// absent one, because it invites the user to blame themselves. - in property has-regions: false; /// Draw the false-coloured region map over the photograph. in property overlay: false; @@ -247,7 +237,6 @@ export component MaskPanel inherits Rectangle { callback segment(); callback overlay-toggled(bool); callback picking-toggled(bool); - callback level-changed(int); callback mask-selected(string); callback mask-removed(string); @@ -279,11 +268,7 @@ export component MaskPanel inherits Rectangle { HorizontalLayout { PanelHeading { text: "LOCAL"; } Rectangle { horizontal-stretch: 1; } - if root.segmented && root.has-regions: Value { - text: root.level + " / " + root.region-count; - } - if root.segmented && !root.has-regions: Value { - text: root.subjects.length + (root.subjects.length == 1 ? " subject" : " subjects"); + if root.segmented: Value { text: root.subjects.length + (root.subjects.length == 1 ? " subject" : " subjects"); } } @@ -327,12 +312,7 @@ export component MaskPanel inherits Rectangle { } } - if root.enabled && root.segmented && root.picking && root.has-regions: Caption { - text: "Click the photograph to select a region. Shift-click to add or remove."; - wrap: word-wrap; - } - - if root.enabled && root.segmented && root.picking && !root.has-regions: Caption { + if root.enabled && root.segmented && root.picking: Caption { text: "Click a subject in the photograph to mask it."; wrap: word-wrap; } @@ -340,22 +320,6 @@ export component MaskPanel inherits Rectangle { // 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. - // **The ceiling is the region count, not a constant.** It was 2000, - // and a photograph that segments into more than that had the finest - // part of its own ladder unreachable — the slider simply stopped - // before the regions did. - if root.enabled && root.segmented && root.has-regions: SliderRow { - label: "Detail"; - hint: "How finely a click divides the picture, out of " - + root.region-count + " regions the watershed found."; - value: root.level; - default-value: 300; - minimum: 8; - maximum: max(root.region-count, 8); - changed(v) => { root.level-changed(v); } - reset => { root.level-changed(300); } - } - // --- what the model found ---------------------------------------- if root.enabled && root.segmented && root.subjects.length > 0: Caption { // Says where these came from and how they differ from a region.