From 37f63edbf98ff1cab79b7f38cd18d02ba52b78d0 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 08:39:00 +0200 Subject: [PATCH] Take the watershed out of the product path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It does not work on a photograph, so nothing should offer it. `Segmentation` is now one model pass and what it recognised: no region field, no merge tree, no label upload, no granularity slider, and no readback of the whole proxy to build a graph that collapses. A click means "the object under the cursor". The region-selection path went with the hierarchy it indexed — including the shift-click add/subtract, which has no meaning for a whole object and would have been a modifier that silently did nothing. The passes, the hierarchy and the semantic prior stay in `dr-gpu` and `dr-segment`, tested and documented. It is the *merge criterion* that fails — the saddle is the minimum gradient along a boundary, so one weak pixel merges two regions and real gradient noise puts a weak pixel on every boundary. That is one function to replace, and the evidence for replacing it is worth keeping. What is gone is the wiring, the option, and the control that offered a user a choice with no outcome. `MaskSource::Regions` remains in the pipeline: it is tested, it round-trips through the sidecar, and a stored layer that names regions must still load and be reported stale rather than failing to parse. --- ui/dr-ui/src/develop.rs | 111 +--- ui/dr-ui/src/masks_ui.rs | 21 +- ui/dr-ui/src/segmentation.rs | 1023 +++++++--------------------------- ui/dr-ui/ui/app.slint | 8 - ui/dr-ui/ui/masks.slint | 40 +- 5 files changed, 228 insertions(+), 975 deletions(-) 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.