Files
DarkRoom/core/dr-segment/src/distance.rs
T
dtourolle 84fade99ec Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 21:16:03 +02:00

577 lines
20 KiB
Rust

//! Exact Euclidean distance from a mask's boundary, and the shaping built on
//! it.
//!
//! # One measurement, four controls
//!
//! Feathering, growing, shrinking, closing and opening are the same number
//! read differently. Given the **signed** distance from the boundary —
//! positive inside, negative outside — dilation by `r` is the set where
//! `d >= -r`, erosion is `d >= +r`, and a feather of any shape is a function
//! of `d`. So the field is computed once and the controls are arithmetic on
//! it.
//!
//! That is also why the *field* is what gets uploaded to the GPU rather than a
//! finished alpha: growing a mask or changing its falloff then costs a uniform
//! upload and no recomputation at all, which is what makes those live
//! controls rather than ones that stall on every drag.
//!
//! Closing and opening are the exception. After the first threshold the shape
//! has changed, so the old distances describe the old one and a second field
//! is needed — [`Shaped::needs_recompute`] says so, and it is the only edge
//! control that is not free.
//!
//! # Why this is on the CPU
//!
//! ARCH §5.4 says masks rasterise on the GPU and never exist in CPU memory,
//! and the reason it says so is brush lag: a stroke rasterised per-frame on
//! the CPU is what makes darktable's drawn masks unusable. This is a different
//! operation with different economics.
//!
//! - It runs **once per mask edit**, not once per frame.
//! - Its input is already CPU-side — the model's coverage was produced here.
//! - Its output is a field the GPU then samples for free, forever after.
//!
//! And doing it here buys two things a shader could not. It is **exactly
//! deterministic**, which matters because masks reach the sidecar as indices
//! and a field that varied by vendor would mean a mask meaning one thing on
//! the desktop and another on the phone (docs/dev/segmentation.md §6, M5). And it
//! is testable against hand-computed distances with no adapter present.
//!
//! # The transform
//!
//! Felzenszwalb and Huttenlocher's separable exact transform: the lower
//! envelope of parabolas along every row, then along every column. Linear in
//! the number of pixels, exactly Euclidean — not the chamfer approximation
//! that leaves a mask visibly octagonal when grown by more than a few pixels.
/// Larger than any squared distance in an image anyone will render.
const FAR: f32 = 1e20;
/// Signed Euclidean distance from the mask's boundary, in pixels.
///
/// Positive inside, negative outside. `coverage` is one byte per pixel;
/// `threshold` is the value at or above which a pixel counts as inside.
///
/// The sign convention is the one that makes the controls read naturally: a
/// positive offset grows the mask, matching "dilate by 3 pixels".
///
/// # The half-pixel, which is not a detail
///
/// The transform measures to the nearest pixel *centre* of the other class, so
/// the closest an inside pixel can be to an outside one is exactly 1. Reported
/// raw, that puts the boundary nowhere — no pixel is at zero, the smallest
/// magnitude either side is 1, and **eroding by anything under a pixel removes
/// nothing at all**. A control whose first notch does nothing is a broken
/// control.
///
/// So half a pixel comes off each side, which puts the boundary where it
/// physically is: between the last inside pixel and the first outside one.
/// A pixel against the edge then reads `+0.5` inside and `-0.5` outside,
/// symmetric, and eroding by 1 takes exactly the outermost ring.
pub fn signed_distance(coverage: &[u8], width: usize, height: usize, threshold: u8) -> Vec<f32> {
assert_eq!(
coverage.len(),
width * height,
"coverage must cover every pixel"
);
let inside: Vec<bool> = coverage.iter().map(|&c| c >= threshold).collect();
// Two transforms, because a pixel's distance is to the nearest pixel of
// the *opposite* class and that is a different seed set on each side.
let to_outside = euclidean(&inside, width, height, false);
let to_inside = euclidean(&inside, width, height, true);
inside
.iter()
.enumerate()
.map(|(i, &is_in)| {
if is_in {
to_outside[i] - 0.5
} else {
-(to_inside[i] - 0.5)
}
})
.collect()
}
/// Distance from every pixel to the nearest pixel whose class is `seed`.
fn euclidean(inside: &[bool], width: usize, height: usize, seed: bool) -> Vec<f32> {
let mut f: Vec<f32> = inside
.iter()
.map(|&v| if v == seed { 0.0 } else { FAR })
.collect();
// Scratch, allocated once and reused by every line: the parabola vertices
// still on the lower envelope, and the crossings between consecutive ones.
let longest = width.max(height);
let mut v = vec![0usize; longest];
let mut z = vec![0.0f32; longest + 1];
let mut out = vec![0.0f32; longest];
for y in 0..height {
let row: Vec<f32> = f[y * width..(y + 1) * width].to_vec();
transform(&row, &mut out[..width], &mut v, &mut z);
f[y * width..(y + 1) * width].copy_from_slice(&out[..width]);
}
let mut column = vec![0.0f32; height];
for x in 0..width {
for y in 0..height {
column[y] = f[y * width + x];
}
transform(&column, &mut out[..height], &mut v, &mut z);
for y in 0..height {
f[y * width + x] = out[y];
}
}
// Squared until here — the transform works in squares because that is what
// makes the parabolas parabolas.
f.iter().map(|d| d.max(0.0).sqrt()).collect()
}
/// One-dimensional squared distance transform.
///
/// `out[q] = min over p of ( f[p] + (q - p)^2 )`, computed by walking the
/// lower envelope of those parabolas.
fn transform(f: &[f32], out: &mut [f32], v: &mut [usize], z: &mut [f32]) {
let n = f.len();
if n == 0 {
return;
}
let mut k = 0usize;
v[0] = 0;
z[0] = -FAR;
z[1] = FAR;
for q in 1..n {
if f[q] >= FAR {
// An infinite parabola is never the lowest anywhere, and
// including it would divide one infinity by another.
continue;
}
loop {
let p = v[k];
// Where this parabola crosses the one currently on top.
let s = ((f[q] + sq(q)) - (f[p] + sq(p))) / (2.0 * q as f32 - 2.0 * p as f32);
if s > z[k] {
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = FAR;
break;
}
if k == 0 {
// It dominates everything before it: start the envelope again
// from this vertex.
v[0] = q;
z[0] = -FAR;
z[1] = FAR;
break;
}
k -= 1;
}
}
// Every parabola was infinite, so every distance is.
if f.iter().all(|&x| x >= FAR) {
out[..n].fill(FAR);
return;
}
k = 0;
// `q` is both the position and the value the parabola is evaluated at, so
// it stays; what changes is that the write goes through the iterator
// rather than re-indexing `out` on every step.
for (q, slot) in out.iter_mut().enumerate().take(n) {
while z[k + 1] < q as f32 {
k += 1;
}
let p = v[k];
*slot = (q as f32 - p as f32).powi(2) + f[p];
}
}
fn sq(x: usize) -> f32 {
(x as f32) * (x as f32)
}
/// How coverage falls away from the boundary.
///
/// Mirrors `dr_pipeline::mask::Falloff`, which this crate cannot see — the
/// pipeline depends on nothing here and inverting that to share one enum would
/// be a dependency edge for five variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Falloff {
Hard,
Linear,
#[default]
Smooth,
Gaussian,
Exponential,
}
impl Falloff {
/// Coverage at a signed distance, given a feather half-width.
///
/// `t` is the distance normalised to the feather: `-1` is a feather-width
/// outside, `+1` a feather-width inside. Every curve returns `0.5` at the
/// boundary, which is what keeps the *edge* where the mask says it is
/// whichever curve is chosen — changing the falloff should change how the
/// transition looks, never where it sits.
pub fn coverage(self, t: f32) -> f32 {
match self {
Self::Hard => {
if t >= 0.0 {
1.0
} else {
0.0
}
}
Self::Linear => (t * 0.5 + 0.5).clamp(0.0, 1.0),
Self::Smooth => {
let x = (t * 0.5 + 0.5).clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
// A logistic curve rather than a true Gaussian integral: it is the
// same shape to the eye, has a closed form, and is exactly 0.5 at
// the boundary by construction.
Self::Gaussian => 1.0 / (1.0 + (-3.0 * t).exp()),
// Reaches full coverage quickly inside and trails off slowly
// outside, for blending an adjustment away without moving its edge.
Self::Exponential => {
if t >= 0.0 {
1.0 - 0.5 * (-3.0 * t).exp()
} else {
0.5 * (3.0 * t).exp()
}
}
}
}
}
/// Growing, shrinking and tidying. Mirrors `dr_pipeline::mask::Morphology`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Morphology {
#[default]
None,
Dilate,
Erode,
Close,
Open,
}
impl Morphology {
/// Whether this needs the distance field rebuilt after a threshold.
pub fn needs_recompute(self) -> bool {
matches!(self, Self::Close | Self::Open)
}
/// The offset applied to the distance before the falloff, in pixels.
///
/// Zero for the compound pair: they are applied by [`apply_morphology`]
/// rather than by shifting the field, because their second half operates
/// on a shape the field does not describe.
pub fn offset(self, radius: f32) -> f32 {
match self {
Self::Dilate => radius,
Self::Erode => -radius,
Self::None | Self::Close | Self::Open => 0.0,
}
}
}
/// Apply a compound morphology, returning a new distance field.
///
/// Closing is a dilation followed by an erosion, opening the reverse. Each
/// half is a threshold of a field, and the second half needs a field of the
/// *thresholded* shape — so this recomputes once in the middle and is the one
/// edge control that is not free.
///
/// Returns `None` for the operations that need no rebuild, so a caller can use
/// the field it already has.
pub fn apply_morphology(
distance: &[f32],
width: usize,
height: usize,
morphology: Morphology,
radius: f32,
) -> Option<Vec<f32>> {
if !morphology.needs_recompute() || radius <= 0.0 {
return None;
}
// First half: threshold the existing field.
let first = match morphology {
Morphology::Close => -radius, // dilate
Morphology::Open => radius, // erode
_ => return None,
};
let intermediate: Vec<u8> = distance
.iter()
.map(|&d| if d >= first { 255 } else { 0 })
.collect();
// Second half: measure the new shape, and shift so the caller's threshold
// at zero performs the opposite operation.
let rebuilt = signed_distance(&intermediate, width, height, 128);
let second = match morphology {
Morphology::Close => radius, // erode
Morphology::Open => -radius, // dilate
_ => unreachable!("guarded above"),
};
Some(rebuilt.iter().map(|&d| d - second).collect())
}
/// A distance field ready to be sampled, with the offset already folded in.
#[derive(Debug, Clone, PartialEq)]
pub struct Shaped {
pub distance: Vec<f32>,
pub width: usize,
pub height: usize,
}
impl Shaped {
/// Build from coverage, applying whatever morphology needs a rebuild.
///
/// The simple operations are *not* folded in here: they are an offset the
/// shader adds when it samples, so changing "grow by 4px" to "grow by 6px"
/// costs a uniform rather than a transform.
pub fn build(
coverage: &[u8],
width: usize,
height: usize,
threshold: u8,
morphology: Morphology,
radius_px: f32,
) -> Self {
let distance = signed_distance(coverage, width, height, threshold);
let distance =
apply_morphology(&distance, width, height, morphology, radius_px).unwrap_or(distance);
Self {
distance,
width,
height,
}
}
/// Coverage at one pixel, for tests and for the CPU export path.
pub fn coverage_at(&self, index: usize, offset: f32, feather: f32, falloff: Falloff) -> f32 {
let d = self.distance[index] + offset;
if feather <= 0.0 {
return if d >= 0.0 { 1.0 } else { 0.0 };
}
falloff.coverage(d / feather)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A 2px-wide vertical bar down the middle of a 9x1 strip.
fn bar() -> (Vec<u8>, usize, usize) {
let mut m = vec![0u8; 9];
m[4] = 255;
(m, 9, 1)
}
#[test]
fn distance_is_exact_along_a_line() {
let (m, w, h) = bar();
let d = signed_distance(&m, w, h, 128);
// The lone inside pixel sits half a pixel from the boundary on each
// side, and the pixels either side of it are half a pixel out.
assert_eq!(d[4], 0.5);
assert_eq!(d[3], -0.5);
assert_eq!(d[5], -0.5);
// Then one per pixel from there.
assert_eq!(d[2], -1.5);
assert_eq!(d[0], -3.5);
assert_eq!(d[8], -3.5);
}
/// The property that separates an exact transform from a chamfer one: a
/// diagonal neighbour is √2 away, not 1 and not 2.
#[test]
fn diagonals_are_euclidean_not_chamfer() {
let mut m = vec![0u8; 25];
m[12] = 255; // centre of 5x5
let d = signed_distance(&m, 5, 5, 128);
// Distance from the boundary, so the half-pixel comes back off to
// compare against the centre-to-centre figures.
let at = |x: usize, y: usize| -d[y * 5 + x] + 0.5;
assert!(
(at(1, 1) - std::f32::consts::SQRT_2).abs() < 1e-4,
"{}",
at(1, 1)
);
assert!((at(0, 0) - (8.0f32).sqrt()).abs() < 1e-4, "{}", at(0, 0));
assert_eq!(at(2, 0), 2.0, "straight up is exactly two");
}
#[test]
fn an_empty_mask_is_everywhere_outside() {
let d = signed_distance(&[0u8; 16], 4, 4, 128);
assert!(d.iter().all(|&v| v < 0.0), "no pixel can be inside");
}
#[test]
fn a_full_mask_is_everywhere_inside() {
let d = signed_distance(&[255u8; 16], 4, 4, 128);
assert!(d.iter().all(|&v| v > 0.0), "no pixel can be outside");
}
/// Every curve must cross at the boundary, or changing the falloff would
/// move the edge rather than soften it.
#[test]
fn every_falloff_is_half_at_the_boundary() {
for f in [
Falloff::Linear,
Falloff::Smooth,
Falloff::Gaussian,
Falloff::Exponential,
] {
let v = f.coverage(0.0);
assert!((v - 0.5).abs() < 1e-5, "{f:?} gave {v} at the boundary");
}
}
#[test]
fn every_falloff_is_monotone_and_bounded() {
for f in Falloff::ALL_FOR_TEST {
let mut previous = -1.0;
for i in -20..=20 {
let v = f.coverage(i as f32 / 10.0);
assert!((0.0..=1.0).contains(&v), "{f:?} left the range: {v}");
assert!(v >= previous - 1e-6, "{f:?} went backwards at {i}");
previous = v;
}
}
}
#[test]
fn a_hard_falloff_has_no_transition() {
assert_eq!(Falloff::Hard.coverage(-0.01), 0.0);
assert_eq!(Falloff::Hard.coverage(0.0), 1.0);
}
#[test]
fn dilating_grows_and_eroding_shrinks() {
let mut m = vec![0u8; 81];
for y in 3..6 {
for x in 3..6 {
m[y * 9 + x] = 255;
}
}
let d = signed_distance(&m, 9, 9, 128);
let area = |offset: f32| d.iter().filter(|&&v| v + offset >= 0.0).count();
let plain = area(0.0);
assert_eq!(plain, 9, "the 3x3 block itself");
assert!(area(Morphology::Dilate.offset(1.0)) > plain, "dilate grows");
assert!(area(Morphology::Erode.offset(1.0)) < plain, "erode shrinks");
}
/// Closing fills a hole without growing the outline — the thing it is for.
#[test]
fn closing_fills_a_pinhole() {
// Generous margin on purpose. Outside the image is not "outside the
// mask", so a dilation that reaches the border cannot erode back from
// it, and a tight frame measures that artefact instead of the
// operation.
let (w, h) = (21, 21);
let mut m = vec![0u8; w * h];
for y in 7..14 {
for x in 7..14 {
m[y * w + x] = 255;
}
}
// One missing pixel inside, as a soft mask commonly has.
m[10 * w + 10] = 0;
let before = signed_distance(&m, w, h, 128);
assert!(
before[10 * w + 10] < 0.0,
"the hole starts outside the mask"
);
let after = apply_morphology(&before, w, h, Morphology::Close, 2.0).expect("recomputed");
assert!(after[10 * w + 10] >= 0.0, "closing should have filled it");
// The outline may round at a corner — closing does that, and it is the
// price of filling — but it must not march outward across the frame.
let grew = after
.iter()
.zip(&before)
.filter(|(a, b)| **a >= 0.0 && **b < 0.0)
.count();
assert!(grew <= 5, "closing should fill the hole, not grow: {grew}");
}
/// Opening removes a speck without shrinking the body.
#[test]
fn opening_removes_a_speck() {
let (w, h) = (13, 13);
let mut m = vec![0u8; w * h];
for y in 3..10 {
for x in 3..10 {
m[y * w + x] = 255;
}
}
m[0] = 255; // an isolated speck in the corner
let before = signed_distance(&m, w, h, 128);
assert!(before[0] >= 0.0, "the speck starts inside the mask");
let after = apply_morphology(&before, w, h, Morphology::Open, 1.5).expect("recomputed");
assert!(after[0] < 0.0, "opening should have removed it");
assert!(
after[6 * w + 6] >= 0.0,
"and left the body of the mask alone"
);
}
#[test]
fn the_simple_operations_need_no_rebuild() {
assert!(!Morphology::None.needs_recompute());
assert!(!Morphology::Dilate.needs_recompute());
assert!(!Morphology::Erode.needs_recompute());
assert!(Morphology::Close.needs_recompute());
assert!(Morphology::Open.needs_recompute());
}
#[test]
fn the_same_mask_gives_the_same_field_twice() {
// M5: masks reach the sidecar as indices, so the field they are shaped
// by has to be reproducible.
let mut m = vec![0u8; 64];
m[20..30].fill(255);
let a = signed_distance(&m, 8, 8, 128);
let b = signed_distance(&m, 8, 8, 128);
assert_eq!(a, b);
}
#[test]
fn zero_feather_is_a_step() {
let shaped = Shaped::build(&[0, 255, 255, 0], 4, 1, 128, Morphology::None, 0.0);
assert_eq!(shaped.coverage_at(1, 0.0, 0.0, Falloff::Smooth), 1.0);
assert_eq!(shaped.coverage_at(3, 0.0, 0.0, Falloff::Smooth), 0.0);
}
impl Falloff {
const ALL_FOR_TEST: [Falloff; 5] = [
Falloff::Hard,
Falloff::Linear,
Falloff::Smooth,
Falloff::Gaussian,
Falloff::Exponential,
];
}
}