The watershed hierarchy does not survive a photograph, so local masking stops depending on it. A layer can now be one recognised object, and the object's own coverage is the mask. `Options::watershed` defaults off. It costs ~80 ms plus a full-resolution readback to produce a ladder that collapses, and paying that on every photograph buys a control that misleads. Kept switchable rather than deleted: the passes and the hierarchy are correct in themselves and it is the merge criterion that fails, which is a change to one function. Masks now rasterise in **source** space at proxy resolution and are sampled by the composed shader after the framing map. That fixes a real bug: they were rasterised in output space, so zooming slid the photograph underneath a mask that stayed pinned to the viewport, and cropping moved every adjustment to a different part of the picture. Doing it this way also leaves the framing map in exactly one place — a second copy in the mask shader would have been a second thing to keep in step, failing only when straightened. A subject is stored as identity, not pixels: the mask is megabytes and is reproducible by running the same model over the same image, so the sidecar carries the index, the class and the score, and the session carries the pixels. The class is there to be checked — if instance 3 comes back a "car" where it was a "dog", something changed and the layer is stale rather than silently masking the wrong thing. The overlay now draws instances and is transparent everywhere else. The region version covered every pixel and so hid the photograph it was drawn over; the question it exists to answer is whether an outline follows the subject, which you can only answer by seeing both. `examples/local.rs` is the worked example: subject in colour with the rest monochrome, and the subject lifted out of its background. Run on a 5472x3648 CR2 it finds two people and two cars, and the colour-pop keeps her hat and hair while the wall and grass behind go grey.
980 lines
37 KiB
Rust
980 lines
37 KiB
Rust
//! Local adjustments — a stack of masked edits over the global chain.
|
||
//!
|
||
//! FR-DEV-3's last line: "linear gradient, radial gradient, and brush masks".
|
||
//! A mask layer is an ordinary develop chain plus a rule saying *where* it
|
||
//! applies, and the two halves are deliberately independent — every operation
|
||
//! that works globally works locally, with no per-operation support needed and
|
||
//! nothing to add when a new one is declared in `ops/`.
|
||
//!
|
||
//! # Where a mask actually exists
|
||
//!
|
||
//! **Not here, and not on the CPU at all.** A layer stores the *rule* — some
|
||
//! region ids, or a gradient's geometry — and a compute pass rasterises it
|
||
//! into a texture (ARCH §5.4). This module's job is to describe the rule and
|
||
//! to emit the WGSL that blends by the result.
|
||
//!
|
||
//! That split is the direct response to darktable, where CPU-rasterised brush
|
||
//! masks make painting lag badly enough that users call it unworkable. The
|
||
//! problem there is architectural rather than a tuning failure, and the only
|
||
//! way not to inherit it is to never put a mask in CPU memory.
|
||
//!
|
||
//! # Why region ids rather than a raster
|
||
//!
|
||
//! [`MaskSource::Regions`] stores integers naming regions in the segmentation
|
||
//! hierarchy (`dr-segment`). That choice is what makes a mask diffable, cheap
|
||
//! in a sidecar, and mergeable per-field under FR-NC-9 — three properties a
|
||
//! stored raster has none of (docs/segmentation.md §1). Two devices that
|
||
//! select the same subject produce the same small sorted list, and a sync
|
||
//! conflict between them is resolvable rather than a binary blob fight.
|
||
//!
|
||
//! The cost is that the ids only mean anything alongside the segmentation that
|
||
//! produced them, so [`MaskSource::Regions::signature`] records which one —
|
||
//! see there for what happens when it does not match.
|
||
|
||
use std::fmt::Write as _;
|
||
|
||
use crate::descriptor::{OpDescriptor, ParamId};
|
||
use crate::operation::Operation;
|
||
use crate::ops;
|
||
|
||
/// The feather a new layer starts with, as a fraction of the shorter edge.
|
||
///
|
||
/// Named because two places have to agree about it: the constructor sets it,
|
||
/// and the sidecar omits it when unchanged. A literal in both would eventually
|
||
/// be a literal in one.
|
||
pub const DEFAULT_FEATHER: f32 = 0.004;
|
||
|
||
/// Bilinear sampling of one slice of the mask array, in **source** space.
|
||
///
|
||
/// Hand-rolled rather than done with a sampler, matching how the source
|
||
/// texture is read: adding a sampler would change a bind group layout every
|
||
/// pass shares.
|
||
///
|
||
/// Bilinear rather than a straight load because the array is at proxy
|
||
/// resolution and the view may be zoomed well past it. A nearest-neighbour
|
||
/// mask shows as visible stair-stepping along the edge of the adjustment at
|
||
/// 100%, which is exactly where a mask is judged.
|
||
pub(crate) const MASK_SAMPLER: crate::operation::Helper = crate::operation::Helper {
|
||
name: "sample_mask",
|
||
source: "fn sample_mask(uv: vec2<f32>, layer: i32) -> f32 {
|
||
let dims = vec2<f32>(textureDimensions(masks));
|
||
let last = vec2<i32>(dims) - vec2<i32>(1);
|
||
|
||
// Sample positions are texel centres, so the half-texel offset is what
|
||
// keeps the interpolated edge where the rasteriser drew it.
|
||
let t = uv * dims - vec2<f32>(0.5);
|
||
let base = vec2<i32>(floor(t));
|
||
let f = fract(t);
|
||
|
||
let p0 = clamp(base, vec2<i32>(0), last);
|
||
let p1 = clamp(base + vec2<i32>(1), vec2<i32>(0), last);
|
||
|
||
let a = textureLoad(masks, vec2<i32>(p0.x, p0.y), layer, 0).r;
|
||
let b = textureLoad(masks, vec2<i32>(p1.x, p0.y), layer, 0).r;
|
||
let c = textureLoad(masks, vec2<i32>(p0.x, p1.y), layer, 0).r;
|
||
let d = textureLoad(masks, vec2<i32>(p1.x, p1.y), layer, 0).r;
|
||
|
||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||
}",
|
||
};
|
||
|
||
/// Per-layer uniforms the generated shader reads: `invert`, then `opacity`.
|
||
pub const LAYER_UNIFORM_FIELDS: usize = 2;
|
||
|
||
/// The most layers one image may carry.
|
||
///
|
||
/// A limit exists because the masks are bound as one texture array and every
|
||
/// layer costs a full-resolution channel of VRAM — at 24 MP that is ~24 MB
|
||
/// each, so an unbounded stack is an out-of-memory waiting for a patient user.
|
||
/// Eight is comfortably past what an edit uses in practice and still bounded.
|
||
pub const MAX_LAYERS: usize = 8;
|
||
|
||
/// How a mask's coverage falls away from its edge.
|
||
///
|
||
/// Applied to the **signed distance** from the mask boundary, which is what
|
||
/// makes an arbitrary curve possible: the rasteriser computes one exact
|
||
/// Euclidean distance field and the choice below is a function of it, so a
|
||
/// new shape costs a line rather than a pass.
|
||
///
|
||
/// A watershed boundary is pixel-exact, which is correct and also harsher
|
||
/// than any edit wants at a subject's edge — an exposure change that stops
|
||
/// dead at a hairline reads as a cut-out. So the useful default is a soft one.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum Falloff {
|
||
/// No transition. The boundary as the segmentation drew it.
|
||
///
|
||
/// Worth keeping rather than approximating with a tiny feather: it is what
|
||
/// you want when checking *where* a boundary actually fell, and a feather
|
||
/// hides exactly that.
|
||
Hard,
|
||
/// Straight ramp. Predictable, and visibly banded on a gradient.
|
||
Linear,
|
||
/// Smoothstep — zero derivative at both ends.
|
||
///
|
||
/// The default. The ends are where a ramp shows: a linear falloff leaves a
|
||
/// visible crease where the effect starts and where it stops, because the
|
||
/// eye finds discontinuities in the *slope*, not in the value.
|
||
#[default]
|
||
Smooth,
|
||
/// Gaussian-shaped. Softest, and reaches further than its radius suggests.
|
||
Gaussian,
|
||
/// Sharp near the edge, long tail. For blending an adjustment out over a
|
||
/// large area without moving the boundary itself.
|
||
Exponential,
|
||
}
|
||
|
||
impl Falloff {
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
Self::Hard => "hard",
|
||
Self::Linear => "linear",
|
||
Self::Smooth => "smooth",
|
||
Self::Gaussian => "gaussian",
|
||
Self::Exponential => "exponential",
|
||
}
|
||
}
|
||
|
||
pub fn from_name(name: &str) -> Option<Self> {
|
||
Some(match name {
|
||
"hard" => Self::Hard,
|
||
"linear" => Self::Linear,
|
||
"smooth" => Self::Smooth,
|
||
"gaussian" => Self::Gaussian,
|
||
"exponential" => Self::Exponential,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Every variant, for a UI building a choice control.
|
||
pub const ALL: [Falloff; 5] = [
|
||
Falloff::Hard,
|
||
Falloff::Linear,
|
||
Falloff::Smooth,
|
||
Falloff::Gaussian,
|
||
Falloff::Exponential,
|
||
];
|
||
}
|
||
|
||
/// Growing, shrinking and tidying a mask's extent.
|
||
///
|
||
/// All four are thresholds of the same distance field, which is why they
|
||
/// arrive together rather than one at a time: dilation is "distance ≥ −r",
|
||
/// erosion is "distance ≥ +r", and the two compound operations are one of
|
||
/// those followed by the other.
|
||
///
|
||
/// The compound pair costs a **second** distance field, because after the
|
||
/// first threshold the shape has changed and the old distances no longer
|
||
/// describe it. That is a real cost and the reason they are named separately
|
||
/// rather than presented as a radius that happens to be signed.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum Morphology {
|
||
#[default]
|
||
None,
|
||
/// Grow. The everyday fix for a selection that stops just inside a
|
||
/// subject's edge, which is what an under-segmented boundary produces.
|
||
Dilate,
|
||
/// Shrink. Pulls a selection back off a halo it caught.
|
||
Erode,
|
||
/// Dilate then erode: fills pinholes and closes narrow gaps without
|
||
/// growing the outline. What to reach for when a mask is speckled with
|
||
/// missed pixels inside an area that is plainly one thing.
|
||
Close,
|
||
/// Erode then dilate: removes specks and thin spurs without shrinking the
|
||
/// outline. The complement, for a selection that leaked along an edge.
|
||
Open,
|
||
}
|
||
|
||
impl Morphology {
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
Self::None => "none",
|
||
Self::Dilate => "dilate",
|
||
Self::Erode => "erode",
|
||
Self::Close => "close",
|
||
Self::Open => "open",
|
||
}
|
||
}
|
||
|
||
pub fn from_name(name: &str) -> Option<Self> {
|
||
Some(match name {
|
||
"none" => Self::None,
|
||
"dilate" => Self::Dilate,
|
||
"erode" => Self::Erode,
|
||
"close" => Self::Close,
|
||
"open" => Self::Open,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Whether this needs a second distance field.
|
||
pub fn is_compound(self) -> bool {
|
||
matches!(self, Self::Close | Self::Open)
|
||
}
|
||
|
||
pub const ALL: [Morphology; 5] = [
|
||
Morphology::None,
|
||
Morphology::Dilate,
|
||
Morphology::Erode,
|
||
Morphology::Close,
|
||
Morphology::Open,
|
||
];
|
||
}
|
||
|
||
/// Where a mask layer applies.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum MaskSource {
|
||
/// A set of segmentation regions — the click-to-select mask.
|
||
///
|
||
/// This is what the watershed and the semantic model exist to produce.
|
||
/// Selecting a subject means "the regions the model's instance covers",
|
||
/// and the resulting edge is the watershed's, which is to say the image's
|
||
/// own (docs/segmentation.md §5).
|
||
Regions {
|
||
/// Which segmentation these ids index into.
|
||
///
|
||
/// Region numbering is a property of one particular segmentation of
|
||
/// one particular image at one particular proxy size. Store ids
|
||
/// without recording that, and a later build with a retuned watershed
|
||
/// silently reinterprets the mask as a different shape — the failure
|
||
/// mode being *a wrong mask*, which is far worse than *no mask*,
|
||
/// because nothing announces it.
|
||
///
|
||
/// When this does not match the current segmentation the layer is
|
||
/// treated as stale rather than applied: see [`MaskLayer::is_stale`].
|
||
signature: u64,
|
||
/// Granularity: how far up the merge hierarchy the ids were taken.
|
||
level: u32,
|
||
/// Sorted and deduplicated, so the same selection is byte-identical
|
||
/// however it was arrived at — which is what lets it be a cache key.
|
||
ids: Vec<u32>,
|
||
},
|
||
|
||
/// One object the model recognised, used as the mask directly.
|
||
///
|
||
/// **The primary way a local adjustment is made.** The watershed hierarchy
|
||
/// this crate was first built around does not survive a photograph: its
|
||
/// saddles are near zero almost everywhere, so a global cut collapses the
|
||
/// frame into one region plus noise (docs/segmentation.md §15). A model
|
||
/// instance is a whole object, found as one thing, and needs no ladder.
|
||
///
|
||
/// The trade is that the boundary is the model's — a quarter-resolution
|
||
/// sigmoid — rather than the image's own gradient. That is what the edge
|
||
/// treatment on [`MaskLayer`] is for: the mask arrives approximately
|
||
/// right and soft, and dilation, erosion and a chosen falloff are how it
|
||
/// is made to fit.
|
||
///
|
||
/// Stored as *identity*, not as pixels. The mask itself is several
|
||
/// megabytes and is reproducible by running the same model over the same
|
||
/// image, so the sidecar carries what is needed to find it again and the
|
||
/// session carries the pixels.
|
||
Subject {
|
||
/// Which segmentation run produced it, so a layer can tell whether
|
||
/// the index below still means what it meant.
|
||
signature: u64,
|
||
/// Position in that run's detection list, strongest first.
|
||
index: u32,
|
||
/// The class name, for display and as a sanity check on re-detection:
|
||
/// if instance 3 is a "car" where it was a "dog", the model or the
|
||
/// image changed and the layer should be treated as stale rather than
|
||
/// silently masking something else.
|
||
class: String,
|
||
score: f32,
|
||
},
|
||
|
||
/// A linear gradient — the graduated-filter mask.
|
||
///
|
||
/// Geometry is in **normalised output coordinates**, so it survives a crop
|
||
/// or an export at another size. Storing pixels would make a mask that
|
||
/// silently moves when the frame changes.
|
||
Linear {
|
||
/// Midpoint of the ramp, `0.0..=1.0` in each axis.
|
||
centre: (f32, f32),
|
||
/// Radians, measured from the +x axis.
|
||
angle: f32,
|
||
/// Distance from full effect to none, in normalised units. Zero is a
|
||
/// hard edge.
|
||
width: f32,
|
||
},
|
||
|
||
/// A radial gradient — the classic vignette-shaped local adjustment.
|
||
Radial {
|
||
centre: (f32, f32),
|
||
/// Semi-axes, normalised. Two of them, because a face is an ellipse
|
||
/// and forcing a circle makes the user compensate with a crop.
|
||
radii: (f32, f32),
|
||
angle: f32,
|
||
/// Fraction of the radius over which the edge falls off.
|
||
feather: f32,
|
||
},
|
||
}
|
||
|
||
impl MaskSource {
|
||
/// A short stable name for the UI and for debugging.
|
||
pub fn kind(&self) -> &'static str {
|
||
match self {
|
||
Self::Regions { .. } => "regions",
|
||
Self::Subject { .. } => "subject",
|
||
Self::Linear { .. } => "linear",
|
||
Self::Radial { .. } => "radial",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One local adjustment: a rule about *where*, plus a chain saying *what*.
|
||
pub struct MaskLayer {
|
||
/// Stable identity, for the sidecar and for merge (FR-NC-9).
|
||
pub id: String,
|
||
/// What the user called it. Empty means "name me after my source".
|
||
pub name: String,
|
||
pub source: MaskSource,
|
||
/// Swap inside for outside.
|
||
pub invert: bool,
|
||
/// Global strength of the layer, `0.0..=1.0`.
|
||
pub opacity: f32,
|
||
/// Off without being deleted — the A/B a local edit is always wanting.
|
||
pub enabled: bool,
|
||
|
||
/// Half-width of the edge transition, as a fraction of the frame's
|
||
/// **shorter edge**.
|
||
///
|
||
/// Normalised rather than in pixels for the same reason the gradient
|
||
/// geometry is: the same edit renders to a viewport and to a 24 MP export,
|
||
/// and a feather measured in pixels would be a different edge in each.
|
||
///
|
||
/// Zero means no transition regardless of [`Self::falloff`].
|
||
pub feather: f32,
|
||
pub falloff: Falloff,
|
||
|
||
/// Grow, shrink or tidy the mask before the feather is applied.
|
||
///
|
||
/// Before, and it matters: dilating a *feathered* mask would push the
|
||
/// half-way point outward and soften it further, so the two controls would
|
||
/// not be independent. Morphology moves the boundary; feather describes
|
||
/// how the boundary is crossed.
|
||
pub morphology: Morphology,
|
||
/// How far, in the same units as [`Self::feather`].
|
||
pub morph_radius: f32,
|
||
/// This layer's adjustments.
|
||
///
|
||
/// A full chain, the same one [`crate::EditGraph`] holds. That is the
|
||
/// whole reason local adjustments need no per-operation support: the
|
||
/// composer already knows how to turn a chain into WGSL, and a mask layer
|
||
/// is a chain that happens to be multiplied by a mask afterwards.
|
||
pub ops: Vec<Box<dyn Operation>>,
|
||
}
|
||
|
||
impl Clone for MaskLayer {
|
||
/// Cloned by *value*, not by handle: the ops are trait objects, so this
|
||
/// rebuilds a fresh chain and copies the parameters across. Needed because
|
||
/// the UI edits a layer speculatively and the history stores snapshots.
|
||
fn clone(&self) -> Self {
|
||
let mut ops = ops::chain();
|
||
for (dst, src) in ops.iter_mut().zip(&self.ops) {
|
||
for p in src.descriptor().params {
|
||
dst.set_param(p.id, src.param(p.id));
|
||
}
|
||
}
|
||
Self {
|
||
id: self.id.clone(),
|
||
name: self.name.clone(),
|
||
source: self.source.clone(),
|
||
invert: self.invert,
|
||
opacity: self.opacity,
|
||
enabled: self.enabled,
|
||
feather: self.feather,
|
||
falloff: self.falloff,
|
||
morphology: self.morphology,
|
||
morph_radius: self.morph_radius,
|
||
ops,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Debug for MaskLayer {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.debug_struct("MaskLayer")
|
||
.field("id", &self.id)
|
||
.field("name", &self.name)
|
||
.field("source", &self.source)
|
||
.field("invert", &self.invert)
|
||
.field("opacity", &self.opacity)
|
||
.field("enabled", &self.enabled)
|
||
.field("feather", &self.feather)
|
||
.field("falloff", &self.falloff)
|
||
.field("morphology", &self.morphology)
|
||
.field("active_ops", &self.active_ops().count())
|
||
.finish()
|
||
}
|
||
}
|
||
|
||
impl PartialEq for MaskLayer {
|
||
fn eq(&self, other: &Self) -> bool {
|
||
self.id == other.id
|
||
&& self.name == other.name
|
||
&& self.source == other.source
|
||
&& self.invert == other.invert
|
||
&& self.opacity == other.opacity
|
||
&& self.enabled == other.enabled
|
||
&& self.feather == other.feather
|
||
&& self.falloff == other.falloff
|
||
&& self.morphology == other.morphology
|
||
&& self.morph_radius == other.morph_radius
|
||
&& self.params().eq(other.params())
|
||
}
|
||
}
|
||
|
||
impl MaskLayer {
|
||
/// A new layer over `source`, with every adjustment at neutral.
|
||
pub fn new(id: impl Into<String>, source: MaskSource) -> Self {
|
||
Self {
|
||
id: id.into(),
|
||
name: String::new(),
|
||
source,
|
||
invert: false,
|
||
opacity: 1.0,
|
||
enabled: true,
|
||
// A small default rather than zero. A watershed boundary is exact
|
||
// to the pixel, and an adjustment that stops dead on one looks
|
||
// pasted on — the first thing anyone would reach for, so it is
|
||
// where the control starts.
|
||
feather: DEFAULT_FEATHER,
|
||
falloff: Falloff::default(),
|
||
morphology: Morphology::default(),
|
||
morph_radius: 0.0,
|
||
ops: ops::chain(),
|
||
}
|
||
}
|
||
|
||
/// The name to show, falling back to the source kind.
|
||
pub fn display_name(&self) -> &str {
|
||
if self.name.is_empty() {
|
||
self.source.kind()
|
||
} else {
|
||
&self.name
|
||
}
|
||
}
|
||
|
||
/// Whether this layer would change any pixel.
|
||
///
|
||
/// A layer with a mask but no adjustment is not inactive in the UI — it is
|
||
/// a selection the user is still working on — but it contributes nothing
|
||
/// to the shader and is omitted from it.
|
||
pub fn is_active(&self) -> bool {
|
||
self.enabled && self.opacity > 0.0 && self.active_ops().next().is_some()
|
||
}
|
||
|
||
pub fn active_ops(&self) -> impl Iterator<Item = &dyn Operation> {
|
||
self.ops.iter().map(|o| o.as_ref()).filter(|o| o.is_active())
|
||
}
|
||
|
||
/// Whether this layer's region ids belong to a different segmentation.
|
||
///
|
||
/// Applying it anyway would produce a confidently wrong mask, so callers
|
||
/// should offer to recompute rather than render it.
|
||
pub fn is_stale(&self, current: u64) -> bool {
|
||
match self.source {
|
||
MaskSource::Regions { signature, .. } | MaskSource::Subject { signature, .. } => {
|
||
signature != current
|
||
}
|
||
// A gradient is geometry in normalised coordinates. It means the
|
||
// same thing whatever was or was not detected, so nothing about a
|
||
// new run can invalidate it.
|
||
MaskSource::Linear { .. } | MaskSource::Radial { .. } => false,
|
||
}
|
||
}
|
||
|
||
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
|
||
self.ops.iter().map(|o| o.descriptor()).collect()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3a | FR-DEV-3c
|
||
/// The controls for this layer's adjustments.
|
||
///
|
||
/// The same shape [`crate::EditGraph::capabilities`] returns, so a panel
|
||
/// that can render the global chain renders a mask layer with no new code
|
||
/// — which is the practical payoff of a layer holding a real chain rather
|
||
/// than a handful of special-cased sliders.
|
||
///
|
||
/// Framing is absent, and that is the one real difference: a crop changes
|
||
/// the output's dimensions, so it is a property of the photograph and not
|
||
/// of a region within it. There is no such thing as cropping part of an
|
||
/// image.
|
||
pub fn capabilities(&self) -> Vec<crate::graph::OpCapability> {
|
||
self.ops
|
||
.iter()
|
||
.map(|op| {
|
||
let desc = op.descriptor();
|
||
crate::graph::OpCapability {
|
||
id: desc.id,
|
||
label: desc.label,
|
||
active: op.is_active(),
|
||
params: desc
|
||
.params
|
||
.iter()
|
||
.map(|p| crate::graph::ParamCapability {
|
||
id: p.id,
|
||
label: p.label,
|
||
kind: p.kind.clone(),
|
||
default: p.default,
|
||
value: op.param(p.id),
|
||
facet: p.facet,
|
||
})
|
||
.collect(),
|
||
presentation: op.presentation(),
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Reset every adjustment, keeping the selection.
|
||
///
|
||
/// The selection is the expensive half — it took a click and a scroll to
|
||
/// arrive at — so "start this layer's edit again" must not throw it away.
|
||
pub fn reset_adjustments(&mut self) {
|
||
for op in &mut self.ops {
|
||
for p in op.descriptor().params {
|
||
op.set_param(p.id, p.default);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_param(&mut self, op: &str, param: ParamId, value: f32) {
|
||
if let Some(o) = self.ops.iter_mut().find(|o| o.descriptor().id.0 == op) {
|
||
let clamped = o
|
||
.descriptor()
|
||
.param(param)
|
||
.map_or(value, |d| d.clamp(value));
|
||
o.set_param(param, clamped);
|
||
}
|
||
}
|
||
|
||
/// Every non-default parameter, for the sidecar.
|
||
pub fn params(&self) -> impl Iterator<Item = (&'static str, &'static str, f32)> + '_ {
|
||
self.ops.iter().flat_map(|o| {
|
||
let id = o.descriptor().id.0;
|
||
o.descriptor().params.iter().filter_map(move |p| {
|
||
let v = o.param(p.id);
|
||
(v != p.default).then_some((id, p.id.0, v))
|
||
})
|
||
})
|
||
}
|
||
|
||
/// The two uniforms the generated shader reads for this layer.
|
||
fn uniforms(&self) -> [f32; LAYER_UNIFORM_FIELDS] {
|
||
[if self.invert { 1.0 } else { 0.0 }, self.opacity]
|
||
}
|
||
}
|
||
|
||
/// The ordered stack of local adjustments.
|
||
#[derive(Debug, Clone, Default, PartialEq)]
|
||
pub struct MaskStack {
|
||
layers: Vec<MaskLayer>,
|
||
}
|
||
|
||
impl MaskStack {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
pub fn layers(&self) -> &[MaskLayer] {
|
||
&self.layers
|
||
}
|
||
|
||
pub fn layers_mut(&mut self) -> &mut [MaskLayer] {
|
||
&mut self.layers
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.layers.is_empty()
|
||
}
|
||
|
||
pub fn len(&self) -> usize {
|
||
self.layers.len()
|
||
}
|
||
|
||
pub fn get(&self, id: &str) -> Option<&MaskLayer> {
|
||
self.layers.iter().find(|l| l.id == id)
|
||
}
|
||
|
||
pub fn get_mut(&mut self, id: &str) -> Option<&mut MaskLayer> {
|
||
self.layers.iter_mut().find(|l| l.id == id)
|
||
}
|
||
|
||
/// Add a layer, returning whether there was room for it.
|
||
///
|
||
/// Refuses past [`MAX_LAYERS`] rather than dropping the oldest: a stack at
|
||
/// its limit is a thing to tell the user about, and silently discarding
|
||
/// work they can see on screen is the wrong way to handle it.
|
||
pub fn push(&mut self, layer: MaskLayer) -> bool {
|
||
if self.layers.len() >= MAX_LAYERS {
|
||
log::warn!("mask stack is full ({MAX_LAYERS} layers); refusing to add another");
|
||
return false;
|
||
}
|
||
self.layers.push(layer);
|
||
true
|
||
}
|
||
|
||
pub fn remove(&mut self, id: &str) -> Option<MaskLayer> {
|
||
let i = self.layers.iter().position(|l| l.id == id)?;
|
||
Some(self.layers.remove(i))
|
||
}
|
||
|
||
/// Reorder, since later layers composite over earlier ones.
|
||
pub fn move_to(&mut self, id: &str, index: usize) {
|
||
let Some(from) = self.layers.iter().position(|l| l.id == id) else {
|
||
return;
|
||
};
|
||
let layer = self.layers.remove(from);
|
||
self.layers.insert(index.min(self.layers.len()), layer);
|
||
}
|
||
|
||
/// The layers that will appear in the shader, in composite order.
|
||
///
|
||
/// The index within *this* sequence is the texture-array layer the
|
||
/// rasteriser must write, which is why both sides call this rather than
|
||
/// indexing `layers` — an inactive layer occupies no mask slot, and the
|
||
/// two halves disagreeing about that shows as an edit applied through the
|
||
/// wrong mask.
|
||
pub fn active(&self) -> impl Iterator<Item = &MaskLayer> {
|
||
self.layers.iter().filter(|l| l.is_active())
|
||
}
|
||
|
||
pub fn active_count(&self) -> usize {
|
||
self.active().count()
|
||
}
|
||
|
||
/// Whether any layer changes any pixel.
|
||
pub fn is_neutral(&self) -> bool {
|
||
self.active_count() == 0
|
||
}
|
||
|
||
/// Generate a fresh layer id that does not collide with an existing one.
|
||
pub fn next_id(&self) -> String {
|
||
(1..).map(|n| format!("m{n}")).find(|id| self.get(id).is_none()).expect("infinite range")
|
||
}
|
||
}
|
||
|
||
/// One layer's contribution to the generated shader.
|
||
pub(crate) struct LayerShader {
|
||
pub uniform_fields: String,
|
||
pub uniform_values: Vec<f32>,
|
||
pub body: String,
|
||
pub helpers: Vec<crate::operation::Helper>,
|
||
}
|
||
|
||
/// Emit the WGSL for every active layer.
|
||
///
|
||
/// `slot` is the layer's index in the mask texture array, matching
|
||
/// [`MaskStack::active`].
|
||
pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader {
|
||
let mut out = LayerShader {
|
||
uniform_fields: String::new(),
|
||
uniform_values: Vec::new(),
|
||
body: String::new(),
|
||
helpers: Vec::new(),
|
||
};
|
||
|
||
if stack.active().next().is_some() {
|
||
out.helpers.push(MASK_SAMPLER);
|
||
}
|
||
|
||
for (slot, layer) in stack.active().enumerate() {
|
||
let prefix = format!("mask{slot}");
|
||
|
||
let _ = writeln!(
|
||
out.uniform_fields,
|
||
" // mask {slot}: {}\n {prefix}_invert: f32,\n {prefix}_opacity: f32,",
|
||
layer.display_name()
|
||
);
|
||
out.uniform_values.extend_from_slice(&layer.uniforms());
|
||
|
||
let _ = writeln!(
|
||
out.body,
|
||
"\n // ======== mask {slot}: {} ({}) ========",
|
||
layer.display_name(),
|
||
layer.source.kind()
|
||
);
|
||
let _ = writeln!(out.body, " {{");
|
||
// **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source*
|
||
// space, and `uv_src` is the source position this output pixel came
|
||
// from — after the crop, the zoom, the pan, the straightening and the
|
||
// flips. Sampling by output pixel instead, as this once did, pins the
|
||
// mask to the viewport: zooming in slides the photograph under a mask
|
||
// that stays where it was, and cropping moves the adjustment to a
|
||
// different part of the picture.
|
||
//
|
||
// Doing it this way also means the framing map exists in exactly one
|
||
// place. A second copy here would be a second thing to keep in step
|
||
// with `Framing::wgsl_prologue`, and the failure would be a mask that
|
||
// is subtly wrong only when straightened.
|
||
let _ = writeln!(
|
||
out.body,
|
||
" var m = sample_mask(uv_src, {slot});"
|
||
);
|
||
let _ = writeln!(
|
||
out.body,
|
||
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
|
||
);
|
||
let _ = writeln!(
|
||
out.body,
|
||
" m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);"
|
||
);
|
||
// Skipping the work where the mask is empty is most of the point of a
|
||
// local adjustment: a mask covering a tenth of the frame should cost
|
||
// about a tenth of the shader. Safe as non-uniform control flow —
|
||
// nothing inside samples with derivatives or synchronises.
|
||
let _ = writeln!(out.body, " if (m > 0.0) {{");
|
||
// `masked` is the outer-scope carrier: op fragments write to a `c`
|
||
// they expect to own, so the inner block shadows `c` and copies the
|
||
// result back out. Assigning the outer `c` from inside is not possible
|
||
// precisely because it is shadowed.
|
||
let _ = writeln!(out.body, " var masked = c;");
|
||
let _ = writeln!(out.body, " {{");
|
||
let _ = writeln!(out.body, " var c = masked;");
|
||
|
||
for op in layer.active_ops() {
|
||
let id = op.descriptor().id.0;
|
||
let op_prefix = format!("{prefix}_{}", crate::operation::sanitise(id));
|
||
|
||
let op_uniforms = op.uniforms();
|
||
if !op_uniforms.is_empty() {
|
||
let _ = writeln!(out.uniform_fields, " // mask {slot}: {id}");
|
||
}
|
||
for u in &op_uniforms {
|
||
let _ = writeln!(out.uniform_fields, " {op_prefix}_{}: f32,", u.name);
|
||
out.uniform_values.push(u.value);
|
||
}
|
||
|
||
for h in op.helpers() {
|
||
if !out.helpers.iter().any(|e| e.name == h.name) {
|
||
out.helpers.push(*h);
|
||
}
|
||
}
|
||
|
||
let mut fragment = op.wgsl_body();
|
||
for u in &op_uniforms {
|
||
fragment = crate::operation::rewrite_uniform(
|
||
&fragment,
|
||
u.name,
|
||
&format!("u.{op_prefix}_{}", u.name),
|
||
);
|
||
}
|
||
|
||
let _ = writeln!(out.body, " // ---- {id} ----");
|
||
let _ = writeln!(out.body, " {{");
|
||
for line in fragment.lines() {
|
||
let _ = writeln!(out.body, " {line}");
|
||
}
|
||
let _ = writeln!(out.body, " }}");
|
||
}
|
||
|
||
let _ = writeln!(out.body, " masked = c;");
|
||
let _ = writeln!(out.body, " }}");
|
||
let _ = writeln!(out.body, " c = mix(c, masked, m);");
|
||
let _ = writeln!(out.body, " }}");
|
||
let _ = writeln!(out.body, " }}");
|
||
}
|
||
|
||
out
|
||
}
|
||
|
||
/// A stable fingerprint of a segmentation, for [`MaskSource::Regions`].
|
||
///
|
||
/// Built from the things that change what a region id *means* — the proxy
|
||
/// size, the region count, and the options the watershed ran with. Deliberately
|
||
/// **not** a hash of the label field: that would be a readback on a path that
|
||
/// must not have one (ARCH §6.1), and would also make the signature depend on
|
||
/// float arithmetic whose cross-vendor determinism is exactly the open
|
||
/// question (docs/segmentation.md §6, M5).
|
||
pub fn segmentation_signature(width: u32, height: u32, regions: u32, tuning: u64) -> u64 {
|
||
// FNV-1a over the four fields. Small, dependency-free, and adequate: this
|
||
// guards against accidental mismatch, not against a forged sidecar.
|
||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||
for word in [width as u64, height as u64, regions as u64, tuning] {
|
||
for byte in word.to_le_bytes() {
|
||
h ^= byte as u64;
|
||
h = h.wrapping_mul(0x1000_0000_01b3);
|
||
}
|
||
}
|
||
h
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::descriptor::ParamId;
|
||
|
||
fn regions(ids: &[u32]) -> MaskSource {
|
||
MaskSource::Regions {
|
||
signature: 7,
|
||
level: 300,
|
||
ids: ids.to_vec(),
|
||
}
|
||
}
|
||
|
||
fn lit_layer(id: &str, ev: f32) -> MaskLayer {
|
||
let mut layer = MaskLayer::new(id, regions(&[1, 2]));
|
||
layer.set_param("exposure", ParamId("exposure"), ev);
|
||
layer
|
||
}
|
||
|
||
#[test]
|
||
fn a_layer_with_no_adjustment_is_not_in_the_shader() {
|
||
let layer = MaskLayer::new("m1", regions(&[1]));
|
||
assert!(!layer.is_active(), "a bare selection changes no pixel");
|
||
|
||
let mut stack = MaskStack::new();
|
||
stack.push(layer);
|
||
assert!(stack.is_neutral());
|
||
assert_eq!(compose_layers(&stack).body, "");
|
||
}
|
||
|
||
#[test]
|
||
fn a_disabled_layer_is_omitted_but_kept() {
|
||
let mut stack = MaskStack::new();
|
||
let mut layer = lit_layer("m1", 1.0);
|
||
layer.enabled = false;
|
||
stack.push(layer);
|
||
|
||
assert_eq!(stack.active_count(), 0, "disabled layers do not render");
|
||
assert_eq!(stack.len(), 1, "but they are not deleted");
|
||
}
|
||
|
||
#[test]
|
||
fn zero_opacity_is_inactive() {
|
||
let mut layer = lit_layer("m1", 1.0);
|
||
layer.opacity = 0.0;
|
||
assert!(!layer.is_active());
|
||
}
|
||
|
||
#[test]
|
||
fn the_generated_block_reads_its_own_mask_slot() {
|
||
let mut stack = MaskStack::new();
|
||
stack.push(lit_layer("m1", 1.0));
|
||
stack.push(lit_layer("m2", -1.0));
|
||
|
||
let shader = compose_layers(&stack);
|
||
assert!(shader.body.contains("sample_mask(uv_src, 0)"));
|
||
assert!(shader.body.contains("sample_mask(uv_src, 1)"));
|
||
assert!(shader.body.contains("u.mask0_opacity"));
|
||
assert!(shader.body.contains("u.mask1_opacity"));
|
||
}
|
||
|
||
/// The slot a layer renders through must follow `active()`, not the raw
|
||
/// index — otherwise disabling layer 0 silently shifts every mask.
|
||
#[test]
|
||
fn slots_follow_active_order_not_stack_order() {
|
||
let mut stack = MaskStack::new();
|
||
let mut off = lit_layer("m1", 1.0);
|
||
off.enabled = false;
|
||
stack.push(off);
|
||
stack.push(lit_layer("m2", -1.0));
|
||
|
||
let shader = compose_layers(&stack);
|
||
assert!(
|
||
shader.body.contains("sample_mask(uv_src, 0)"),
|
||
"the one active layer must use slot 0, not slot 1"
|
||
);
|
||
assert!(!shader.body.contains("sample_mask(uv_src, 1)"));
|
||
}
|
||
|
||
#[test]
|
||
fn each_layer_gets_its_own_uniforms() {
|
||
let mut stack = MaskStack::new();
|
||
stack.push(lit_layer("m1", 1.0));
|
||
stack.push(lit_layer("m2", -1.0));
|
||
|
||
let shader = compose_layers(&stack);
|
||
assert!(shader.uniform_fields.contains("mask0_exposure_"));
|
||
assert!(shader.uniform_fields.contains("mask1_exposure_"));
|
||
assert_eq!(
|
||
shader.uniform_values.len(),
|
||
shader.uniform_fields.lines().filter(|l| l.trim_start().starts_with("mask")).count(),
|
||
"one value per emitted field"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_inner_block_shadows_c_and_copies_back() {
|
||
let mut stack = MaskStack::new();
|
||
stack.push(lit_layer("m1", 1.0));
|
||
let body = compose_layers(&stack).body;
|
||
|
||
assert!(body.contains("var masked = c;"));
|
||
assert!(body.contains("var c = masked;"));
|
||
assert!(body.contains("masked = c;"));
|
||
assert!(body.contains("c = mix(c, masked, m);"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_full_stack_refuses_rather_than_dropping_work() {
|
||
let mut stack = MaskStack::new();
|
||
for i in 0..MAX_LAYERS {
|
||
assert!(stack.push(lit_layer(&format!("m{i}"), 1.0)));
|
||
}
|
||
assert!(!stack.push(lit_layer("overflow", 1.0)));
|
||
assert_eq!(stack.len(), MAX_LAYERS);
|
||
assert!(stack.get("overflow").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn ids_do_not_collide() {
|
||
let mut stack = MaskStack::new();
|
||
assert_eq!(stack.next_id(), "m1");
|
||
stack.push(MaskLayer::new("m1", regions(&[1])));
|
||
assert_eq!(stack.next_id(), "m2");
|
||
}
|
||
|
||
#[test]
|
||
fn a_layer_from_another_segmentation_is_stale() {
|
||
let layer = MaskLayer::new("m1", regions(&[1]));
|
||
assert!(!layer.is_stale(7), "same signature is fine");
|
||
assert!(layer.is_stale(8), "a retuned segmentation invalidates ids");
|
||
|
||
// A gradient has no region ids, so nothing can go stale about it.
|
||
let grad = MaskLayer::new(
|
||
"m2",
|
||
MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.2 },
|
||
);
|
||
assert!(!grad.is_stale(999));
|
||
}
|
||
|
||
#[test]
|
||
fn signatures_separate_what_changes_a_region_id() {
|
||
let base = segmentation_signature(1600, 1067, 6730, 2);
|
||
assert_eq!(base, segmentation_signature(1600, 1067, 6730, 2));
|
||
assert_ne!(base, segmentation_signature(1600, 1067, 6730, 5), "tuning");
|
||
assert_ne!(base, segmentation_signature(800, 1067, 6730, 2), "proxy size");
|
||
assert_ne!(base, segmentation_signature(1600, 1067, 42, 2), "region count");
|
||
}
|
||
|
||
#[test]
|
||
fn cloning_copies_parameters_not_handles() {
|
||
let layer = lit_layer("m1", 1.5);
|
||
let mut copy = layer.clone();
|
||
assert_eq!(copy, layer);
|
||
|
||
copy.set_param("exposure", ParamId("exposure"), -1.0);
|
||
assert_ne!(copy, layer, "the clone edits independently");
|
||
}
|
||
|
||
#[test]
|
||
fn params_reports_only_what_moved() {
|
||
let layer = lit_layer("m1", 1.25);
|
||
let moved: Vec<_> = layer.params().collect();
|
||
assert_eq!(moved, vec![("exposure", "exposure", 1.25)]);
|
||
}
|
||
|
||
#[test]
|
||
fn reordering_moves_a_layer_within_the_stack() {
|
||
let mut stack = MaskStack::new();
|
||
stack.push(lit_layer("m1", 1.0));
|
||
stack.push(lit_layer("m2", 1.0));
|
||
stack.push(lit_layer("m3", 1.0));
|
||
|
||
stack.move_to("m3", 0);
|
||
let order: Vec<&str> = stack.layers().iter().map(|l| l.id.as_str()).collect();
|
||
assert_eq!(order, ["m3", "m1", "m2"]);
|
||
}
|
||
}
|