Files
DarkRoom/ui/dr-ui/src/develop/mask_ops.rs
T
dtourolle 050c2c9d16 Split develop.rs into develop/ by area of behaviour
develop.rs had grown to 9,327 lines covering everything the develop
session does: opening a photograph, the parameter-row and curve-widget
panel model, mask viewing and editing, mask creation and the rasteriser
that turns a mask stack into GPU arrays, spot repairs, scene
segmentation, framing and zoom, white-balance sampling, rendering and
film choice, and the undo/snapshot history. docs/dev/code-health.md
CH-1 names dr-ui's lack of a view layer as the reason every feature
kept landing in a handful of files; this is the first of the two pure
splits it recommends as easy, no-behaviour-change wins independent of
that larger rework.

The boundaries follow the file's own sections (several were already
marked off with comment headers) and the seams a full read turned up
underneath them -- mask storage/rasterisation turned out to be a
distinct concern from mask viewing and editing, and rows/tabs/curves
from each other, so those split further than the headers alone
suggested. Each module stays under about 1,500 lines. Struct fields
and the handful of helper methods now called from a sibling module
became `pub(super)`, which is strictly narrower than the whole-crate
reachability a single file gave them; nothing gained visibility outside
`develop`. Tests moved with the code they test, including the few
cases where a helper one file's tests needed was itself only defined
in another's -- those became shared fixtures in `mod.rs` alongside the
`headless`/`read_back`/`grey_session` helpers that already worked that
way. `mod.rs` re-exports every item `develop::` callers outside this
module used before, so lib.rs, masks_ui.rs and the rest needed no
changes.
2026-09-20 18:21:26 +02:00

1378 lines
58 KiB
Rust

//! 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<Cow<'a, [u8]>> {
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<f32>> = 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<MaskSource> {
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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
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<u8> = (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<u8> = (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<u8> = (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<u8> = (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"
);
}
}