//! Creating and adjusting mask layers, and the machinery that turns a mask //! stack into the rasterised arrays a render pass samples -- including what //! gets written back to the sidecar as stored coverage. use std::borrow::Cow; use std::sync::Arc; use dr_gpu::GpuContext; use dr_pipeline::mask::{MaskLayer, MaskSource}; use dr_pipeline::Edit; #[cfg(test)] use dr_pipeline::{EditGraph, ParamId}; use crate::labels; use crate::segmentation; use super::session::DevelopSession; use super::SEGMENT_PROXY_EDGE; /// `dr_pipeline`'s morphology, as `dr_segment` names it. /// /// Two enums for one idea, and deliberately: `dr-pipeline` describes the /// *edit* and `dr-segment` implements the *transform*, and neither depends on /// the other. The crossing is this function, which the compiler makes /// exhaustive on both sides. fn morphology_for(m: dr_pipeline::mask::Morphology) -> dr_segment::Morphology { use dr_pipeline::mask::Morphology as Edit; use dr_segment::Morphology as Transform; match m { Edit::None => Transform::None, Edit::Dilate => Transform::Dilate, Edit::Erode => Transform::Erode, Edit::Close => Transform::Close, Edit::Open => Transform::Open, } } impl DevelopSession { /// Returns whether the array is now valid for the current stack. /// /// Split from reading the array back because the render below needs /// `self.adjust` mutably while holding `self.masks` immutably. Those are /// disjoint fields and the borrow checker will allow it — but only when /// each is reached directly rather than through a method taking `self`. /// What the uploaded distance fields depend on. /// /// The instance each layer names, and the morphology that *rebuilds* a /// field rather than offsetting it. Nothing else: see `subject_key`. pub(super) fn subject_signature(&self) -> u64 { use dr_pipeline::mask::{MaskSource, Morphology}; let mut h: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |v: u64| { for byte in v.to_le_bytes() { h ^= byte as u64; h = h.wrapping_mul(0x1000_0000_01b3); } }; // TRACES: FR-DEV-19c // The reveal is in the key because it is in the *sequence*: revealing // a layer with no adjustment on it gives that layer a slot, which // renumbers every field after it. Leaving it out is the bug where // clicking a subject shows the mask of whichever layer happened to be // beneath it. let reveal = self.reveal(); mix(reveal.as_ref().map_or(0, |r| { // Every shown id, not only which are shown: a layer joining the // shown set renumbers every slot after it, exactly as one joining // the active set does. Colours are left out — they change what // the shader draws, not which field it draws through. let mut h: u64 = 1; for l in &r.layers { for b in l.layer.as_bytes() { h = h.wrapping_mul(31).wrapping_add(*b as u64); } h = h.wrapping_mul(31).wrapping_add(0x1f); } h })); for layer in self.graph.masks().rendered(reveal.as_ref()) { match &layer.base().source { MaskSource::Subject { index, .. } => { mix(1); mix(*index as u64); if layer.base().morphology.is_compound() { mix(match layer.base().morphology { Morphology::Close => 2, Morphology::Open => 3, _ => 0, }); mix(layer.base().morph_radius.to_bits() as u64); } } // Hashed by *name*, and `4` rather than `1` so a category // named the same as an instance index could never collide with // it. The field has to be rebuilt when either changes. MaskSource::Category { name, .. } => { mix(4); for b in name.as_bytes() { mix(*b as u64); } // Only the compound operations change the field itself. if layer.base().morphology.is_compound() { mix(match layer.base().morphology { Morphology::Close => 2, Morphology::Open => 3, _ => 0, }); mix(layer.base().morph_radius.to_bits() as u64); } // The refine control changes which pixels are in the mask // at all, so it changes the coverage the field is measured // from — unlike a feather, which is read off a field that // is already correct. Omitting it here is the bug where // the slider moves and nothing happens until some other // control happens to invalidate the cache. mix(layer.base().refine.to_bits() as u64); } _ => mix(0), } } h } /// One layer's coverage: what the model says now, or what the sidecar /// remembered it saying. /// /// **The model first, always.** It is the live answer, it is the only one /// that can respond to the refine control, and a run in this sitting is by /// definition newer than anything a file was holding. /// /// The fallback is the point of the stored raster. A photograph reopened, /// and a batch export from the grid — which opens a session, applies a /// version and never runs a model at all — have no segmentation to ask, so /// before this they resolved every subject and category layer to nothing /// and wrote out a file missing the local adjustments, with a line in the /// log as the only sign. See [`dr_pipeline::coverage`]. /// /// `None` for every other source: a gradient and a brush are rasterised /// from their own geometry and have no coverage to fetch, and the caller /// gives them a placeholder field so the slot indices still line up. pub(super) fn layer_coverage<'a>( &'a self, layer: &'a dr_pipeline::mask::MaskLayer, width: usize, height: usize, ) -> Option> { use dr_pipeline::mask::MaskSource; let live = self .segmentation .as_ref() .and_then(|seg| match &layer.base().source { MaskSource::Category { name, .. } => { seg.category_mask_at(name, layer.base().refine) } MaskSource::Subject { index, .. } => { seg.instance_mask(*index as usize).map(Cow::Borrowed) } _ => None, }); if live.is_some() { return live; } match layer.base().source { // Resampled where it was written against a different proxy edge; // in the ordinary case the sizes match and this is the decode. MaskSource::Subject { .. } | MaskSource::Category { .. } => layer .base() .coverage .as_ref() .map(|stored| Cow::Owned(stored.decode_at(width, height))), _ => None, } } /// TRACES: FR-CAT-8 | FR-DEV-3 /// The mask stack as it should be written to a sidecar. /// /// The stack the graph holds, with each model layer's coverage brought up /// to what the segmentation now says it is. That raster is what lets the /// *next* opening of this photograph render the layer without a model run /// — the whole of [`dr_pipeline::coverage`]'s reason to exist. /// /// # Why here, and not where the field is built /// /// `ensure_subject_fields` is the tempting place: it is the one funnel /// every coverage passes through, so recording it there would catch every /// route automatically. But it runs on a *drag* — dilating a mask with a /// compound morphology rebuilds the field every frame — and encoding a /// megapixel raster per frame is exactly the kind of work NFR-P5 is about. /// /// Saving happens when the photograph stops being the open one, once, and /// already costs a network round trip. So the encoding is done here, where /// nothing is waiting on it, and the session's own rendering goes on /// reading the model directly. /// /// Returns the stack by value rather than mutating: the caller is /// serialising, not editing, and a graph that quietly gained a field on /// the way past would be a mutation nobody asked for and undo would not /// know about. pub fn masks_for_storage(&self) -> dr_pipeline::mask::MaskStack { use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS}; use dr_pipeline::mask::MaskSource; let mut stack = self.graph.masks().clone(); let Some(seg) = self.segmentation.as_ref() else { // No model has run this sitting, so whatever the layers arrived // holding is still the best answer anyone has. Handing the stack // back untouched is what stops a photograph that was opened, // glanced at and closed from losing the coverage its own sidecar // gave it. return stack; }; let (pw, ph) = seg.proxy_size(); for layer in stack.layers_mut() { let values = match &layer.base().source { MaskSource::Category { name, .. } => { seg.category_mask_at(name, layer.base().refine) } MaskSource::Subject { index, .. } => { seg.instance_mask(*index as usize).map(Cow::Borrowed) } // Nothing else has a model behind it. Left alone rather than // cleared, so a hand-written file's key survives a round trip // even though nothing samples it. _ => continue, }; // The layer names something this run does not contain — a stale // index, a category the scene model no longer offers. Keeping what // was stored is right: it is a mask that was once correct, and the // panel is already telling the user the layer is stale. let Some(values) = values else { continue }; // `None` from the encoder means "will not fit in a sidecar", and // the stored raster is then cleared rather than left standing. It // would describe the layer at some earlier refine, and a mask of // the wrong shape presented as authoritative is worse than the // honest state, which is that this one needs the model run. layer.base_mut().coverage = Coverage::encode(&values, pw, ph, RENDERED_LEVELS).map(Arc::new); } stack } /// Rebuild the distance fields if anything they depend on moved. pub(super) fn ensure_subject_fields(&mut self, ctx: &GpuContext) { let key = self.subject_signature(); if key == self.subject_key && self.subjects.is_some() { return; } // The proxy the fields are measured in: the segmentation's own where // one has been run, and otherwise the size the mask array is // rasterised at. `mask_raster_size` derives that from the photograph // rather than from a segmentation for exactly this case, and the two // are the same number by construction — see there. let (pw, ph) = match self.segmentation.as_ref() { Some(seg) => seg.proxy_size(), None => { let (w, h) = self.mask_raster_size(); (w as usize, h as usize) } }; // In `rendered()` order, because that is the order the rasteriser // walks and the order it indexes these by — the revealed layer // included, which is why the reveal is asked for here and folded into // the key above. let reveal = self.reveal(); let mut fields: Vec> = Vec::new(); for layer in self.graph.masks().rendered(reveal.as_ref()) { let field = match self.layer_coverage(layer, pw, ph) { Some(coverage) => { dr_segment::Shaped::build( &coverage, pw, ph, 128, morphology_for(layer.base().morphology), // Radii are fractions of the shorter edge; the field // is in proxy pixels. layer.base().morph_radius * pw.min(ph) as f32, ) .distance } // Full size, never `unwrap_or_default`: an empty vec is a // wrong-sized field, `SubjectMasks::upload` rejects the whole // batch on one, and every other layer in the stack then loses // its mask too. One stale name should cost one layer, not all // of them. // // It is also the placeholder a gradient or a brush gets, so // the slot indices line up with `active()` whatever mix of // sources the stack holds. None => vec![-1.0; pw * ph], }; fields.push(field); } if fields.is_empty() { self.subjects = None; self.subject_key = key; return; } let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect(); self.subjects = dr_gpu::SubjectMasks::upload(ctx, &refs, pw as u32, ph as u32) .inspect_err(|e| log::warn!("could not upload the subject fields: {e}")) .ok(); self.subject_key = key; } pub(super) fn rasterise_masks(&mut self) -> bool { // TRACES: FR-DEV-19c // A stack that changes no pixel is normally not worth a pass — except // when one of its layers is being looked at, which is exactly the // state a fresh selection is in. Asking `rendered_count` rather than // `is_neutral` is what makes "click a category, see its mask" work at // all: before it, the array was never rasterised, the slice the reveal // samples held whatever was last in it, and the answer was a blank // photograph. let reveal = self.reveal(); if self.graph.masks().rendered_count(reveal.as_ref()) == 0 { return false; } // **Source space, at the segmentation's proxy size** — not the // viewport's. The generated shader samples this after the framing map, // so a mask drawn here stays on the photograph through a zoom, a pan // and a crop. Rasterising at viewport size, as this first did, pinned // the mask to the screen instead: zooming slid the picture underneath // one that stayed put. // // It also means the array does not reallocate when the window // resizes, and does not need redrawing when the view moves. let (pw, ph) = self.mask_raster_size(); let subjects = self.subjects.as_ref(); // TRACES: FR-DEV-10 // A refcount, taken before the rasteriser is borrowed mutably. A range // layer measures the photograph itself, so the pass needs the source // as well as the stack — and it is the same texture every other pass // reads, not a copy made for masking. let source = self.demosaiced.clone(); // **Built here, not in `segment`.** A gradient needs no segmentation — // a graduated filter over a sky never had to know what a sky is — but // the rasteriser was only ever constructed on the way out of one, so // adding a gradient to a photograph nobody had segmented produced an // array that was never rasterised and a layer that drew nothing at // all. Silently: the generated shader still emits the layer's block // and the empty placeholder multiplies it by zero, which is the same // failure exports and thumbnails had. // // The shader compile this costs is paid once, on the first frame after // the first mask is added — a button press, not a frame anyone is // dragging through. `is_neutral` above is what keeps it off the path // of every photograph that has no local adjustment at all. if self.masks.is_none() { let ctx = self.ctx.clone(); self.masks = dr_gpu::MaskPass::new(&ctx) .inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}")) .ok(); } 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_revealing( self.graph.masks(), None, subjects, Some(source.as_ref()), pw, ph, reveal.as_ref(), ) .inspect_err(|e| log::warn!("mask rasterisation failed: {e}")) .is_ok() } /// The size the mask array is rasterised at, in source space. /// /// **A property of the photograph, not of the segmentation.** The two come /// out the same because both are the source scaled to `SEGMENT_PROXY_EDGE`, /// and they have to: a subject layer's distance field is built at the /// segmentation's proxy and sampled against this array, so the two sizes /// agreeing is a requirement rather than a coincidence. Reading the size /// *off* the segmentation is what made it look like a dependency, and made /// a gradient — which indexes nothing — wait for a model to run. /// /// The aspect must be the source's either way. A gradient's geometry is /// measured against the frame's own proportions, so a square array over a /// 3:2 photograph would stretch every circle it drew. pub(super) fn mask_raster_size(&self) -> (u32, u32) { if let Some(seg) = self.segmentation.as_ref() { let (pw, ph) = seg.proxy_size(); return (pw as u32, ph as u32); } let (sw, sh) = self.demosaiced.size(); let scale = (SEGMENT_PROXY_EDGE as f32 / sw.max(sh).max(1) as f32).min(1.0); ( ((sw as f32 * scale) as u32).max(1), ((sh as f32 * scale) as u32).max(1), ) } /// The selected layer's mask rule, for a caller that has to remember what /// a gesture started from. pub fn active_mask_source(&self) -> Option { self.active_layer().map(|l| l.base().source.clone()) } /// Select exactly one layer for editing, or `None` to return the panel to /// the global chain. Replaces whatever was selected before, including a /// multi-selection — the ordinary, unmodified click. /// Selecting a layer points the tools at its base again. /// /// Without this, selecting a two-part mask after a three-part one would /// leave the brush aimed at a part that is not there — or, worse, at a /// different part than the one highlighted in the panel. pub fn set_active_mask(&mut self, id: Option<&str>) { self.active_part = 0; self.active_masks = id .filter(|id| self.graph.masks().get(id).is_some()) .map(|id| vec![id.to_string()]) .unwrap_or_default(); } /// Add or remove one layer from the selection, keeping the rest — the /// modifier-click that builds a multi-selection. /// /// A layer id the graph no longer has is dropped rather than toggled in: /// the row that offered it is stale by the time the click lands, and /// selecting a ghost would make every subsequent batched edit silently /// skip it (`active_layers_mut` filters by membership, not existence). pub fn toggle_active_mask(&mut self, id: &str) { if self.graph.masks().get(id).is_none() { return; } match self.active_masks.iter().position(|a| a == id) { Some(i) => { self.active_masks.remove(i); } None => self.active_masks.push(id.to_string()), } } /// Mask the object under a normalised image point. /// /// 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) -> Option { let index = self.segmentation.as_ref()?.instance_at(x, y)?; self.add_subject_mask(index) } /// Add a layer covering one photographic category. /// /// The counterpart to [`Self::add_subject_mask`], and it takes a *name* /// rather than an index for the reason `MaskSource::Category` stores one: /// the descriptor grouping ADE20K's classes is editable, so an index would /// silently repoint every stored layer the first time a category was /// added to it. /// /// The layer is named after the category, because "sky" is a better name /// for a layer than "Mask 3" and the user can rename it anyway. pub fn add_category_mask(&mut self, name: &str) -> Option { use dr_pipeline::mask::MaskSource; let seg = self.segmentation.as_ref()?; let signature = seg.signature(); // Refuse a category this run did not produce rather than creating a // layer that renders empty: an empty mask looks like a broken // adjustment, where a button that does nothing at least says so. seg.category_mask(name)?; let id = self.graph.masks().next_id(); let mut layer = MaskLayer::new( id.clone(), MaskSource::Category { signature, name: name.to_string(), }, ); layer.name = name.to_string(); // Refined from the start, by as much as this photograph will bear. // // A category's edges are twenty proxy pixels wide before this runs, so // the unrefined mask is the wrong default for the common case — a // photographer adding a sky mask wants the sky, not the sky plus every // chimney in it. Zero is still one drag away, and it is exactly the // model's own weighting when they get there. // // **Asked of the frame rather than taken from a constant**, and that // is the correction rather than a refinement of the idea. The constant // was `STRICTNESS_DEFAULT`, fitted on a synthetic sky; on real // photographs it removes three quarters to all of `architecture`, // `ground` and `vegetation`, so clicking a category produced an empty // mask — the failure that reads as the feature not working at all, // because the adjustment moves and no pixel changes. The measurements // are on `dr_segment::Refinement::gentle`, which is what picks the // number now. // // Set here rather than in `MaskLayer::new` because the number belongs // to `dr_segment` and `dr-pipeline` does not depend on it — see // `dr_pipeline::mask::MAX_REFINE`. layer.base_mut().refine = self .segmentation .as_ref() .map_or(dr_segment::STRICTNESS_OFF, |seg| { seg.category_default_refine(name) }); if !self.graph.masks_mut().push(layer) { return None; } self.active_masks = vec![id.clone()]; self.show_new_mask(&id); self.history .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } /// What the scene model found in this frame, largest category first. /// /// Empty when no scene model was available, which is an ordinary state — /// see `segmentation::scene_categories`. pub fn categories(&self) -> &[segmentation::CategorySummary] { self.segmentation.as_ref().map_or(&[], |s| s.categories()) } /// Add a layer selecting one detected subject. /// /// The instance's own coverage is the mask, rather than the watershed /// regions it overlaps. Snapping to regions was the original design and /// it is not currently worth doing: the hierarchy those ids index into /// collapses on a photograph (docs/dev/segmentation.md §15), so snapping /// would trade the model's approximately-right outline for a /// confidently-wrong one. pub fn add_subject_mask(&mut self, index: usize) -> Option { let seg = self.segmentation.as_ref()?; let instance = seg.instances().get(index)?; let signature = seg.signature(); let name = instance.class_name.to_string(); let score = instance.score; let id = self.graph.masks().next_id(); let mut layer = MaskLayer::new( id.clone(), MaskSource::Subject { signature, index: index as u32, class: name.clone(), score, }, ); layer.name = name; if !self.graph.masks_mut().push(layer) { return None; } self.active_masks = vec![id.clone()]; self.show_new_mask(&id); self.history .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } /// Add a gradient layer, which needs no segmentation. pub fn add_gradient_mask(&mut self, radial: bool) -> Option { let id = self.graph.masks().next_id(); let source = if radial { MaskSource::Radial { centre: (0.5, 0.5), radii: (0.35, 0.35), angle: 0.0, feather: 0.5, } } else { MaskSource::Linear { centre: (0.5, 0.5), angle: std::f32::consts::FRAC_PI_2, width: 0.3, } }; if !self .graph .masks_mut() .push(MaskLayer::new(id.clone(), source)) { return None; } self.active_masks = vec![id.clone()]; self.show_new_mask(&id); self.history .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } /// TRACES: FR-DEV-19b /// Add a mask that is nothing but hand-painted, and select it. /// /// **The one route to a brush that starts from nothing.** Everything else /// in this file makes a layer out of a selection — a gradient, a band, a /// subject, a category — and painting was reachable only by making one of /// those first and then joining a painted part to it. So the answer to /// "brush a correction onto this corner of the sky" was "add a radial /// gradient you do not want, then paint into it", which is not an answer. /// /// The layer covers nothing until a stroke lands in it, which is exactly /// what [`dr_pipeline::mask::MaskPart::covers`] is about: it is not active, /// it costs no slice, and an invert on it would not take the adjustment /// global. What the panel shows meanwhile is a row with the tools armed /// over it — see `masks_ui`, which arms them. pub fn add_brush_mask(&mut self) -> Option { let id = self.graph.masks().next_id(); if !self .graph .masks_mut() .push(MaskLayer::new(id.clone(), MaskSource::brush())) { return None; } self.active_masks = vec![id.clone()]; self.active_part = 0; self.show_new_mask(&id); self.history .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } /// TRACES: FR-DEV-10 /// Add a mask that selects by a range of the photograph's own values. /// /// Needs no segmentation, like a gradient, and for a stronger reason: a /// band is a question about the picture rather than about what is in it. /// `chromatic` picks the colour range over the brightness one. pub fn add_range_mask(&mut self, chromatic: bool) -> Option { let id = self.graph.masks().next_id(); let source = if chromatic { MaskSource::skin_tones() } else { MaskSource::highlights() }; if !self .graph .masks_mut() .push(MaskLayer::new(id.clone(), source)) { return None; } self.active_masks = vec![id.clone()]; self.show_new_mask(&id); self.history .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } /// TRACES: FR-DEV-10 /// Move a range layer's band — its two bounds and the fade at each edge. /// /// All three at once, because they are one control: dragging the lower /// bound past the upper swaps them (see `MaskSource::luminance_range`), /// and a setter per field would have to decide that question three times /// with only a third of the answer each time. /// /// A no-op on a layer that is not a range, rather than a panic: the panel /// asks first, and a callback that arrives against a layer the user has /// since replaced is ordinary rather than a fault. pub fn set_mask_band(&mut self, id: &str, lo: f32, hi: f32, softness: f32) { let Some(layer) = self.part_of_mut(id) else { return; }; // Built into a temporary first: the arc is read out of the layer and // the whole source is written back over it, and the two cannot be the // same statement. let rebuilt = match &layer.source { MaskSource::Luminance { .. } => MaskSource::luminance_range(lo, hi, softness), MaskSource::Colour { hue, hue_width, .. } => { MaskSource::colour_range(*hue, *hue_width, lo, hi, softness) } _ => return, }; layer.source = rebuilt; // `Control`, not `Action`: a band is dragged, and a drag is one // decision however many values it passes through. self.history .record(&self.graph, Edit::Control(labels::step::MASK_RANGE)); } /// TRACES: FR-DEV-10 /// Move a colour range's arc — its centre hue and its half-width. pub fn set_mask_hue(&mut self, id: &str, hue: f32, width: f32) { let Some(layer) = self.part_of_mut(id) else { return; }; // A temporary, for the reason `set_mask_band` gives. let rebuilt = match &layer.source { MaskSource::Colour { chroma_lo, chroma_hi, softness, .. } => MaskSource::colour_range(hue, width, *chroma_lo, *chroma_hi, *softness), // Only a colour range has an arc. A brightness one reaching here // is a stale callback against a layer the user has replaced, // which is ordinary rather than a fault. _ => return, }; layer.source = rebuilt; self.history .record(&self.graph, Edit::Control(labels::step::MASK_RANGE)); } /// TRACES: FR-DEV-10 /// Whether the band controls apply to this layer. pub fn mask_is_ranged(&self, id: &str) -> bool { self.part_of(id).is_some_and(|p| p.source.is_range()) } /// TRACES: FR-DEV-10 /// Whether this layer's band is over colour rather than over brightness. pub fn mask_is_chromatic(&self, id: &str) -> bool { self.part_of(id) .is_some_and(|p| matches!(p.source, MaskSource::Colour { .. })) } /// TRACES: FR-DEV-10 /// This layer's band: lower bound, upper bound, softness. /// /// Tone positions for a luminance layer and chroma for a colour one — /// the same two questions asked of different measurements, which is why /// one panel control drives both. Zeroes for a layer that has no band, /// which the panel never draws. pub fn mask_band(&self, id: &str) -> (f32, f32, f32) { match self.part_of(id).map(|p| &p.source) { Some(MaskSource::Luminance { lo, hi, softness }) => (*lo, *hi, *softness), Some(MaskSource::Colour { chroma_lo, chroma_hi, softness, .. }) => (*chroma_lo, *chroma_hi, *softness), _ => (0.0, 0.0, 0.0), } } /// TRACES: FR-DEV-10 /// A colour range's arc: centre hue and half-width, both in turns. pub fn mask_hue(&self, id: &str) -> (f32, f32) { match self.part_of(id).map(|p| &p.source) { Some(MaskSource::Colour { hue, hue_width, .. }) => (*hue, *hue_width), _ => (0.0, 0.0), } } pub fn remove_mask(&mut self, id: &str) { if self.graph.masks_mut().remove(id).is_some() { self.active_masks.retain(|a| a != id); self.history .record(&self.graph, Edit::Action(labels::step::MASK_REMOVED)); } } pub fn set_mask_enabled(&mut self, id: &str, enabled: bool) { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.enabled = enabled; self.history .record(&self.graph, Edit::Action(labels::step::MASK_TOGGLED)); } } pub fn set_mask_invert(&mut self, id: &str, invert: bool) { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.invert = invert; self.history .record(&self.graph, Edit::Action(labels::step::MASK_INVERTED)); } } /// Edge transition half-width, in fractions of the shorter edge. pub fn set_mask_feather(&mut self, id: &str, feather: f32) { if let Some(part) = self.part_of_mut(id) { part.feather = feather.clamp(0.0, 1.0); self.history .record(&self.graph, Edit::Control(labels::step::MASK_FEATHER)); } } /// How strictly this layer's category is cut back to the pixels whose /// colour agrees with it. /// /// Unlike the feather, this changes the mask's *shape*, so the distance /// field has to be rebuilt — the same class of cost as a close or an open /// (`dr_segment::Morphology::needs_recompute`), and the reason it is in /// `subject_signature`. It still runs no model: the evidence was fitted /// during segmentation and this is a smoothstep over it. pub fn set_mask_refine(&mut self, id: &str, refine: f32) { if let Some(part) = self.part_of_mut(id) { part.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE); self.history .record(&self.graph, Edit::Control(labels::step::MASK_REFINE)); } } pub fn set_mask_falloff(&mut self, id: &str, index: usize) { use dr_pipeline::mask::Falloff; let Some(&falloff) = Falloff::ALL.get(index) else { return; }; if let Some(part) = self.part_of_mut(id) { part.falloff = falloff; self.history .record(&self.graph, Edit::Action(labels::step::MASK_FALLOFF)); } } pub fn set_mask_morphology(&mut self, id: &str, index: usize) { use dr_pipeline::mask::Morphology; let Some(&morphology) = Morphology::ALL.get(index) else { return; }; if let Some(part) = self.part_of_mut(id) { part.morphology = morphology; // Picking an operation with no amount set would appear to do // nothing, and the user would reasonably conclude it is broken. if morphology != Morphology::None && part.morph_radius <= 0.0 { part.morph_radius = 0.006; } self.history .record(&self.graph, Edit::Action(labels::step::MASK_MORPHOLOGY)); } } pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) { if let Some(part) = self.part_of_mut(id) { part.morph_radius = radius.clamp(0.0, 1.0); self.history .record(&self.graph, Edit::Control(labels::step::MASK_MORPH)); } } /// Which falloff a layer uses, as an index into `Falloff::ALL`. pub fn mask_falloff(&self, id: &str) -> usize { use dr_pipeline::mask::Falloff; self.part_of(id).map_or(0, |p| { Falloff::ALL .iter() .position(|&f| f == p.falloff) .unwrap_or(0) }) } pub fn mask_morphology(&self, id: &str) -> usize { use dr_pipeline::mask::Morphology; self.part_of(id).map_or(0, |p| { Morphology::ALL .iter() .position(|&m| m == p.morphology) .unwrap_or(0) }) } pub fn mask_feather(&self, id: &str) -> f32 { self.part_of(id).map_or(0.0, |p| p.feather) } pub fn mask_morph_radius(&self, id: &str) -> f32 { self.part_of(id).map_or(0.0, |p| p.morph_radius) } pub fn mask_refine(&self, id: &str) -> f32 { self.part_of(id).map_or(0.0, |p| p.refine) } /// Whether this layer has a refinement to act on. /// /// Two conditions, and both are needed. The source must be a category — /// nothing else has a colour model fitted for it — and the segmentation /// must actually have fitted one, which it cannot for a category that is /// everywhere thinner than the model's own resolution or that fills the /// whole frame. /// /// Asked by the panel before it draws the slider, because a control that /// moves and does nothing is worse than an absent one. pub fn mask_is_refinable(&self, id: &str) -> bool { use dr_pipeline::mask::MaskSource; let Some(layer) = self.graph.masks().get(id) else { return false; }; let index = self.shaped_part(id); let Some(part) = layer.part(index) else { return false; }; let MaskSource::Category { name, .. } = &part.source else { return false; }; self.segmentation .as_ref() .is_some_and(|seg| seg.category_is_refinable(name)) } /// Whether the edge controls apply to this layer. /// /// Only sources that go through the distance field. A gradient carries its /// own falloff in its geometry, so offering a second one would be two /// controls fighting over the same edge. /// /// A range (FR-DEV-10) is excluded on stronger grounds than the gradient. /// Feather, falloff and morphology are every one of them a function of the /// *signed distance from a boundary*, and a range mask has no boundary: it /// is a weighting over the photograph's values, soft everywhere the values /// are, sharp everywhere they are. There is no outline to grow, shrink or /// cross. Its edge is the softness of its own band, which is in the band's /// units rather than in pixels — see `MaskSource::Luminance`. pub fn mask_is_shapeable(&self, id: &str) -> bool { use dr_pipeline::mask::MaskSource; self.part_of(id).is_some_and(|p| { matches!( p.source, MaskSource::Subject { .. } | MaskSource::Category { .. } | MaskSource::Regions { .. } ) }) } pub fn set_mask_opacity(&mut self, id: &str, opacity: f32) { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.opacity = opacity.clamp(0.0, 1.0); // `Op` rather than `Discrete`: opacity is dragged, and a drag is // one decision however many values it passes through. `Discrete` // would put every intermediate position on the undo stack. self.history .record(&self.graph, Edit::Control(labels::step::MASK_OPACITY)); } } /// Whether a layer's region ids belong to a segmentation other than the /// one currently loaded — a mask restored from a sidecar written under /// different tuning. pub fn mask_is_stale(&self, id: &str) -> bool { let Some(layer) = self.graph.masks().get(id) else { return false; }; match self.segmentation.as_ref() { Some(seg) => layer.is_stale(seg.signature()), // Nothing loaded to compare against, and nothing to report: the // layer renders from the raster its sidecar stored (see // `layer_coverage`). It was already not *stale* before that — the // word invites the user to recompute a selection that is fine — // and now it is not unrenderable either. None => false, } } } #[cfg(test)] mod tests { use super::*; use crate::develop::test_support::*; /// TRACES: FR-DEV-3 /// A gradient needs no segmentation, and until now it silently got no mask. /// /// The rasteriser was built on the way out of `segment`, so a gradient /// added to a photograph nobody had segmented had nothing to draw it — and /// the failure was invisible from every side. The generated shader still /// emits the layer's block, the empty placeholder multiplies it by zero, /// and the result is a well-formed frame with the local adjustment simply /// absent. No error, no warning, and nothing on screen to tell it apart /// from a mask the user had placed badly. /// /// A graduated filter over a sky never had to know what a sky is, so the /// dependency was wrong as well as silent. #[test] fn a_gradient_renders_on_a_photograph_nobody_has_segmented() { let Some(ctx) = headless() else { return }; // Mid grey, so a brightening layer is unambiguous either way. let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); assert!( !session.has_segmentation(), "the point of the test is that there is none" ); let before = read_back(&ctx, &session.render(64, 64).expect("render")); // A radial over the middle, brightened hard. Addressed by index, so // this names no operation (FR-DEV-3a). session.add_gradient_mask(true).expect("a radial"); let row = session.rows()[0].clone(); session.set_param(row.op_index, row.param_index, row.maximum); let after = read_back(&ctx, &session.render(64, 64).expect("render")); let centre = |px: &[u8]| px[((32 * 64 + 32) * 4) as usize]; assert!( centre(&after) > centre(&before) + 20, "the middle of the frame must brighten: {} against {}", centre(&after), centre(&before) ); // And only the middle: a mask that failed to rasterise the other way — // covering everything — would pass the assertion above. let corner = |px: &[u8]| px[0]; assert_eq!( corner(&after), corner(&before), "the corner is outside the radial and must not move" ); } // ---------------------------------------------------------------------- // Stored coverage (dr_pipeline::coverage) // ---------------------------------------------------------------------- // // A subject or category layer is stored in the sidecar as identity — which // run, which instance, which category — and identity resolves to pixels // only while that run is in memory. So reopening an edited photograph // dropped every model-backed local adjustment, and a batch export, which // never runs a model at all, could not have them at any point. Both // failures were silent: the shader still emitted the layer's block, the // placeholder multiplied it by zero, and the result was a well-formed // frame with the adjustment simply absent. /// A stored edit holding one subject layer over the left half of the /// frame, as a session that *had* run the model would have written it. /// /// `stored` is the whole variable: with the raster, this is a sidecar /// written by a build that persists coverage; without it, one written /// before that existed. Everything else about the two is identical, which /// is what makes the pair of tests below a measurement rather than an /// assertion about two different edits. fn version_with_a_subject(proxy: (u32, u32), stored: bool) -> dr_pipeline::Version { use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS}; let (pw, ph) = (proxy.0 as usize, proxy.1 as usize); let mut values = vec![0u8; pw * ph]; for y in 0..ph { for x in 0..pw / 2 { values[y * pw + x] = 255; } } let mut layer = MaskLayer::new( "m1", MaskSource::Subject { signature: 0xfeed, index: 0, class: "dog".into(), score: 0.9, }, ); layer.set_param("exposure", ParamId("exposure"), 2.0); if stored { layer.base_mut().coverage = Some(Arc::new( Coverage::encode(&values, pw, ph, RENDERED_LEVELS).expect("a half frame encodes"), )); } let mut graph = EditGraph::default_chain(); graph.masks_mut().push(layer); dr_pipeline::Version::from_graph("default", "Default", &graph) } /// TRACES: FR-DEV-3 | FR-CAT-8 /// Reopening an edited photograph renders its subject mask, with no model. /// /// This is the failure the stored raster exists for. `apply_version` is /// exactly what opening a photograph from the library does with the /// sidecar it fetched, and until the coverage went into the file the layer /// resolved to nothing every time. #[test] fn a_stored_subject_mask_renders_with_no_model_run() { let Some(ctx) = headless() else { return }; let (mut session, before) = grey_session(&ctx); let proxy = session.mask_raster_size(); session.apply_version(&version_with_a_subject(proxy, true)); let after = read_back(&ctx, &session.render(64, 64).expect("render")); let at = |px: &[u8], x: usize, y: usize| px[(y * 64 + x) * 4]; assert!( at(&after, 16, 32) > at(&before, 16, 32) + 20, "the covered half must brighten: {} against {}", at(&after, 16, 32), at(&before, 16, 32) ); // And only that half. A mask that failed the other way — covering // everything — would pass the assertion above and is the louder bug. assert_eq!( at(&after, 56, 32), at(&before, 56, 32), "the uncovered half must not move" ); } /// The control for the test above: the same edit with the raster left out /// is the behaviour every build had before this, which is no mask at all. /// /// Worth pinning down in both directions. It is what a sidecar written by /// an older build looks like, and reading one must go on being harmless — /// the layer needs the model run, exactly as it always did, rather than /// rendering as an empty mask or as the whole frame. #[test] fn a_subject_mask_with_no_stored_coverage_still_needs_the_model() { let Some(ctx) = headless() else { return }; let (mut session, before) = grey_session(&ctx); let proxy = session.mask_raster_size(); session.apply_version(&version_with_a_subject(proxy, false)); let after = read_back(&ctx, &session.render(64, 64).expect("render")); assert_eq!( after, before, "with no coverage the layer contributes nothing" ); } /// TRACES: FR-EXP-9 | FR-CAT-8 /// A batch export renders the stored mask too. /// /// `export::render_from_library` fetches the RAW and the sidecar, opens a /// session, applies the version and calls `render_for_export` — and it /// runs no model on the way, so before the coverage was stored a batch /// wrote out files with the photographer's local adjustments missing, over /// a log warning nobody was reading. These two lines are that path with /// the fetch taken out. #[test] fn an_export_renders_a_stored_subject_mask() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); let plain = session .render_for_export(dr_types::ColourSpace::Srgb) .expect("export"); let proxy = session.mask_raster_size(); session.apply_version(&version_with_a_subject(proxy, true)); let masked = session .render_for_export(dr_types::ColourSpace::Srgb) .expect("export"); let at = |f: &dr_export::Frame, x: usize, y: usize| f.rgba[(y * 64 + x) * 4]; assert!( at(&masked, 16, 32) > at(&plain, 16, 32) + 20, "the exported file must carry the local adjustment: {} against {}", at(&masked, 16, 32), at(&plain, 16, 32) ); assert_eq!( at(&masked, 56, 32), at(&plain, 56, 32), "and only where the mask covers" ); } /// What a session hands the sidecar writer when no model has run. /// /// Untouched, and that is the whole of it: a photograph opened, looked at /// and closed must not have the coverage its own sidecar gave it stripped /// out on the way past. Saving is automatic, so a stack that lost the /// raster here would lose it on disk within seconds of being opened. #[test] fn saving_without_a_model_keeps_the_coverage_that_was_loaded() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); let proxy = session.mask_raster_size(); session.apply_version(&version_with_a_subject(proxy, true)); let stored = session.masks_for_storage(); assert_eq!(stored.len(), 1); assert!( stored.layers()[0].base().coverage.is_some(), "the raster the sidecar gave us must go back to the sidecar" ); } #[test] fn a_stored_mask_renders_exactly_what_the_model_rendered() { let Some(ctx) = headless() else { return }; let mut live = session_with_a_left_half_subject(&ctx); let id = live.add_subject_mask(0).expect("a subject layer"); // TRACES: FR-DEV-19c // Making a mask opens its eye (`show_new_mask`), and this test is // about the pixels the *edit* produces. Closed here rather than left // open, and the asymmetry is the point rather than an inconvenience: // the reveal is how somebody is looking at a photograph, so a session // that has just made a layer legitimately draws a frame that a session // which read the same layer out of a file does not. Both of those are // correct, and only one of them is what a stored raster has to // reproduce. live.set_mask_shown(&id, false); live.graph .masks_mut() .get_mut(&id) .expect("the layer") .set_param("exposure", ParamId("exposure"), 2.0); let with_model = read_back(&ctx, &live.render(64, 64).expect("render")); // Through the sidecar, the way `presets::save_open_edit` does it: the // graph's own parameters, with the stack that carries the coverage // substituted for the one the graph is holding. let mut version = dr_pipeline::Version::from_graph("default", "Default", &live.graph); version.masks = live.masks_for_storage(); let mut sidecar = dr_pipeline::Sidecar::new(); sidecar.put(version); let text = sidecar.to_text(); let parsed = dr_pipeline::Sidecar::parse(&text).expect("reparse"); let rgba: Vec = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut reopened = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); assert!( !reopened.has_segmentation(), "the point: nothing has run a model in this session" ); reopened.apply_version(parsed.default_version().expect("a version")); // TRACES: FR-DEV-19c // And a sidecar carries no viewing state: a photograph reopened is not // reopened with its masks tinted red. Checked here because this is the // one test that puts an edit through a file and renders both ends, so // it is where the property would first go wrong. assert!( !reopened.any_mask_shown(), "restoring an edit must not open an eye" ); let from_the_file = read_back(&ctx, &reopened.render(64, 64).expect("render")); assert_eq!( from_the_file, with_model, "the reopened frame must be the frame the model produced" ); // And that both are actually a mask rather than both being nothing. let at = |px: &[u8], x: usize| px[(32 * 64 + x) * 4]; assert!( at(&with_model, 16) > at(&with_model, 56) + 20, "the premise: the live render really is masked ({} against {})", at(&with_model, 16), at(&with_model, 56) ); } /// And what a session hands over when a model *has* run: the coverage /// folded in, at the proxy the distance field is measured in. /// /// The encoding happens here rather than where the field is built because /// that runs on a drag — see `masks_for_storage`. #[test] fn saving_after_a_model_run_folds_the_coverage_in() { let Some(ctx) = headless() else { return }; let mut session = session_with_a_left_half_subject(&ctx); let id = session.add_subject_mask(0).expect("a subject layer"); let stored = session.masks_for_storage(); let coverage = stored .get(&id) .expect("the layer is in the stack") .base() .coverage .as_ref() .expect("a subject the model found has coverage to store"); let (pw, ph) = session.mask_raster_size(); assert_eq!( (coverage.width(), coverage.height()), (pw as usize, ph as usize) ); let decoded = coverage.decode(); assert_eq!(decoded[32 * pw as usize + 8], 255, "inside the subject"); assert_eq!(decoded[32 * pw as usize + 56], 0, "outside it"); assert!( coverage.encoded_len() < 512, "a half-frame mask is a handful of runs, not {} bytes", coverage.encoded_len() ); } /// A layer whose coverage came from the file must still take the model's /// once a model runs — the live answer is the only one that responds to /// the refine control, and a run in this sitting is newer than a file. #[test] fn a_model_run_takes_precedence_over_what_was_stored() { let Some(ctx) = headless() else { return }; let mut session = session_with_a_left_half_subject(&ctx); // Stored coverage saying the *right* half, against a segmentation // saying the left. let (pw, ph) = session.mask_raster_size(); let (pw, ph) = (pw as usize, ph as usize); let mut mirrored = vec![0u8; pw * ph]; for y in 0..ph { for x in pw / 2..pw { mirrored[y * pw + x] = 255; } } let id = session.add_subject_mask(0).expect("a subject layer"); { let layer = session.graph.masks_mut().get_mut(&id).expect("the layer"); layer.set_param("exposure", ParamId("exposure"), 2.0); layer.base_mut().coverage = Some(Arc::new( dr_pipeline::Coverage::encode( &mirrored, pw, ph, dr_pipeline::coverage::RENDERED_LEVELS, ) .expect("encode"), )); } let frame = read_back(&ctx, &session.render(64, 64).expect("render")); let at = |x: usize| frame[(32 * 64 + x) * 4]; assert!( at(16) > at(56) + 20, "the model's left half must win over the file's right half: \ {} against {}", at(16), at(56) ); } /// TRACES: FR-DEV-3 /// The mask array and the segmentation proxy are the same size on purpose. /// /// They have to be: a subject layer's distance field is built at the /// segmentation's proxy resolution and sampled against the array, so if the /// two ever diverged a subject mask would be drawn at the wrong scale — /// a mask that is confidently in the wrong place, which is worse than none. /// /// It used to hold because the array's size was *read off* the /// segmentation, which also made a gradient wait for a model it does not /// use. Deriving both from the photograph keeps the agreement and drops the /// dependency, and this is what stops the agreement being an accident. #[test] fn the_mask_array_is_the_size_the_segmentation_will_use() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..100 * 100) .flat_map(|_| [128u8, 128, 128, 255]) .collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) .expect("session"); let before = session.mask_raster_size(); if session .segment(&crate::segmentation::Options::default()) .is_err() { eprintln!("no model; skipping"); return; } assert_eq!( before, session.mask_raster_size(), "a mask rasterised before the model ran must not move when it does" ); } }