diff --git a/core/dr-segment/examples/scene.rs b/core/dr-segment/examples/scene.rs index d350fd0..40f0e62 100644 --- a/core/dr-segment/examples/scene.rs +++ b/core/dr-segment/examples/scene.rs @@ -24,6 +24,14 @@ //! came from* rather than as an abstract grey field. PPM for the same reason //! the other examples use it: no encoder dependency, and every viewer reads it. //! +//! And `--refined.ppm` beside it, which is the same category +//! after [`dr_segment::refine_category`] has cut it back to the pixels whose +//! colour agrees with it. Both, never one: whether that refinement is an +//! improvement is a comparative judgement — did the flag come out of the sky, +//! and is the sky still there afterwards — and a single image cannot answer +//! it. The percentage printed beside each is how much weight came off, which +//! is the number to be suspicious of when it is large. +//! //! Timings are reported as a median over the requested run count, with the //! first run excluded. That first pass pays for tract's lazy allocation and is //! not representative of the second image a session decodes. @@ -108,6 +116,40 @@ fn main() { .rasterise(k, width, height) .expect("category index came from the same Scene"); write_overlay(&format!("{prefix}-{name}.ppm"), &rgb, &mask, width, height); + + // The same category cut back to the pixels whose colour agrees with + // it, written *beside* the coarse one rather than instead of it. The + // judgement this example exists to support is comparative — is the + // flag out, and is the sky still there — and it cannot be made from + // one image. + let start = Instant::now(); + let (refined, what) = dr_segment::refine_category( + &mask, + &rgb, + width, + height, + scene.cell_pixels(), + &dr_segment::RefineOptions::default(), + ); + let took = start.elapsed(); + match what { + dr_segment::Refined::Applied { removed } => { + println!( + " refined in {took:?}: {:.1}% of the weight cut", + removed * 100.0 + ); + write_overlay( + &format!("{prefix}-{name}-refined.ppm"), + &rgb, + &refined, + width, + height, + ); + } + dr_segment::Refined::Skipped(why) => { + println!(" left coarse: {why:?}"); + } + } } } diff --git a/core/dr-segment/src/lib.rs b/core/dr-segment/src/lib.rs index e9a44c1..e2f3577 100644 --- a/core/dr-segment/src/lib.rs +++ b/core/dr-segment/src/lib.rs @@ -37,10 +37,25 @@ //! water — and reads a second, ADE20K-trained model to do it. It shares this //! crate because it shares the runtime and the letterbox, not because it is //! another way of doing the same thing. +//! +//! [`refine`] is what the scene model needs and the instance model does not. +//! A category's weights come off an 80×80 grid, so one cell is twenty pixels +//! of a 1600px proxy and anything smaller than that — a flag in the sky, a +//! chimney, a bare branch — is averaged into whatever surrounds it. There is +//! no tiling answer here the way there is for an instance, because a category +//! has no bounding box to tile over. So the fix is the same one arm C makes: +//! the model says *what*, and the photograph's own pixels say *which* of them +//! belong to it. +//! +//! It shares no code with the arms and it is not a fourth one — it sharpens a +//! mask that already exists rather than proposing regions — but it is built on +//! [`distance`] for the same reason arm C is built on the watershed, which is +//! that the useful question is always "how far inside am I". pub mod distance; pub mod hierarchy; pub mod prior; +pub mod refine; #[cfg(feature = "semantic")] pub mod scene; #[cfg(feature = "semantic")] @@ -49,6 +64,7 @@ pub mod semantic; pub use distance::{signed_distance, Falloff, Morphology, Shaped}; pub use hierarchy::{Edge, Merge, MergeTree, RegionField}; pub use prior::{Membership, PriorOptions}; +pub use refine::{refine_category, RefineOptions, Refined, SkipReason}; #[cfg(feature = "semantic")] pub use scene::{Category, Scene, SceneModel}; #[cfg(feature = "semantic")] diff --git a/core/dr-segment/src/refine.rs b/core/dr-segment/src/refine.rs new file mode 100644 index 0000000..a7d4ead --- /dev/null +++ b/core/dr-segment/src/refine.rs @@ -0,0 +1,870 @@ +//! Sharpening a coarse category mask against the photograph's own colours. +//! +//! # The problem, as a number +//! +//! The `scene` module keeps the model's native `[1, 150, 80, 80]` logit grid, so +//! one cell is eight of the graph's input pixels across. At the 1600px proxy +//! the application segments at, the letterbox scale is `0.4` and **one cell is +//! 20 proxy pixels** — which the bilinear in `Scene::rasterise` then spreads +//! across one more either side. +//! +//! So a flag in the sky, or a chimney, or a bare branch, sits inside a handful +//! of cells whose softmax is dominated by the sky around it, and comes out +//! weighted as sky. No feather setting recovers it, because the information +//! was never in the grid. +//! +//! # What this does about it +//! +//! The photograph is at full proxy resolution even though the weights are not, +//! and it knows exactly where the flag is. The move is to let the *model* +//! decide what the category is and the *pixels* decide which of them belong to +//! it — the same division of labour [`crate::prior`] already draws between the +//! instance model and the watershed, applied to categories. +//! +//! 1. **Seeds.** Threshold the weights high, then erode by +//! [`RefineOptions::margin_cells`] logit cells using +//! [`crate::distance::signed_distance`]. What survives is confidently +//! inside; the mirror of it is confidently outside. The erosion radius is +//! derived from the grid rather than picked, because one cell *is* the +//! model's resolution and anything within a cell of the boundary is +//! precisely what there is no evidence about. +//! +//! 2. **A distribution per side, with several modes.** A single Gaussian over +//! sky is wrong: sky is blue at the zenith, white where the cloud is, and +//! pale at the horizon, and one blob over all three rejects two of them. So +//! k-means, which is GrabCut's mixture without the EM. +//! +//! 3. **Small modes are dropped** ([`RefineOptions::min_cluster`]). This is +//! the step that makes the whole thing work on the case it was built for. +//! A *small* flag deep in the sky has both a high weight and a large +//! distance from the mask's boundary, so it lands in the interior sample +//! and would teach the model its own colour. It cannot be excluded +//! geometrically — but it is a few percent of the sky, and a mode holding a +//! few percent of the confident interior is far likelier to be an intruder +//! than a real appearance of the category. +//! +//! 4. **Two tests, and a pixel must pass both.** An absolute one — is this +//! colour plausible under the category at all, as a chi-square on the +//! Mahalanobis distance — and a comparative one, is it likelier under the +//! interior than the exterior. The absolute test is what catches the flag, +//! whose colour is far from *both* sides and which the comparative test +//! alone would leave at even odds. The comparative test is what stops the +//! absolute one from needing a per-category constant. +//! +//! # Two properties that make it safe to apply blind +//! +//! **It is subtractive.** The output is the input times a factor in `0..=1`, +//! never more. So the worst failure available to it is losing part of a real +//! sky; it can never *gain* a region, and a blue car below the horizon that +//! was never in the mask cannot be pulled into it. +//! +//! **The partition survives.** `scene.rs` rests entirely on the +//! categories summing to at most one at every pixel — that is what lets two +//! adjacent grades be feathered without painting both into the overlap. +//! Multiplying by a factor in `0..=1` cannot raise a sum, so refining every +//! category independently still leaves a partition. The weight taken off the +//! flag lands in the unlisted remainder, which is exactly where a flag +//! belongs: ADE20K has no class for one. +//! +//! # What it cannot do, stated rather than discovered +//! +//! **An intruder large enough to hold its own mode is kept.** Step 3 separates +//! intruder from category by *share*, and by that measure a flag covering a +//! fifth of the sky and a bank of cloud covering a fifth of the sky are the +//! same object. Colour does not separate them either — a white cloud is as far +//! from blue sky in chrominance as many intruders are. +//! +//! So [`RefineOptions::min_cluster`] is not a threshold with a correct value +//! waiting to be found. It is the trade-off itself, and it is set where a +//! *photographic* intruder falls: a flag, a chimney or a bird is a fraction of +//! a percent of the sky it sits in, where the cloud that must survive is +//! usually tens of percent. Both ends of that are pinned by tests +//! (`a_flag_in_the_sky_is_removed` and +//! `an_intruder_larger_than_min_cluster_survives`) so that moving the number +//! reads as moving the trade-off rather than as fixing a bug. +//! +//! The case this leaves open is a *large* unrecognised object in an otherwise +//! clean category — a building filling half the sky. That wants the boundary +//! snapped to the watershed's basins, which is a different mechanism and not +//! this one. +//! +//! # And when it cannot tell +//! +//! Every path that lacks the evidence to judge returns the weights untouched +//! and says so in [`Refined::Skipped`], rather than returning a plausible +//! answer. An empty seed set fitted to a distribution would reject *every* +//! pixel, and a sky that silently vanished from a mask is the kind of failure +//! nobody attributes to the right place. + +use crate::distance::signed_distance; + +/// How aggressively a coarse category mask is cut back to the pixels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RefineOptions { + /// Weight at or above which a pixel may seed the interior distribution. + /// + /// High on purpose. This is not the mask's threshold — it is the standard + /// for "the model was sure here", and everything downstream is a + /// consequence of what these pixels look like. + pub seed_weight: f32, + + /// How far inside the boundary a seed must lie, in logit cells. + /// + /// One cell is the model's own resolution and `rasterise`'s bilinear + /// spreads a cell's influence across one more, so the first cell and a + /// half either side of the boundary is smear rather than evidence. + pub margin_cells: f32, + + /// Modes fitted per side. + /// + /// Four covers blue, cloud and horizon haze with one spare. More would fit + /// the intruder its own mode reliably enough to keep it, which is the + /// opposite of the point. + pub clusters: usize, + + /// Share of a side's samples below which a mode is discarded as an + /// intruder rather than kept as part of the category. + /// + /// The one number here with a real trade-off in it: too high and a genuine + /// wisp of cloud is cut out of the sky, too low and a small flag teaches + /// the model to keep it. + pub min_cluster: f32, + + /// How much luminance counts against chrominance in judging a colour. + /// + /// Well under one, and that is the difference between this working and not + /// on sky. Sky's variance is *dominated* by luminance — the zenith is + /// several stops off the horizon and a cloud is brighter than either — so + /// at equal weight the distribution is a long bright streak that a + /// mid-grey flag sits comfortably inside. A flag is separated by + /// chrominance; a cloud is separated by luminance alone. Down-weighting + /// keeps the cloud and catches the flag. + /// + /// Not zero, or a dark bird against a bright sky is kept. + /// + /// The same reasoning `dr_gpu::SegmentOptions` applies to the watershed's + /// gradient, for the same reason. + pub luma_weight: f32, + + /// Squared Mahalanobis distance beyond which a colour is implausible under + /// the category. + /// + /// Chi-square with three degrees of freedom: `11.34` is the 99th + /// percentile, so under the fitted model one confident pixel in a hundred + /// is expected to fail this on its own. + pub outlier: f32, + + /// Width of the band, in nats, over which the gate falls from keep to cut. + /// + /// A hard cut would put an aliased edge through the photograph at exactly + /// the place a person is looking. + pub soften: f32, + + /// Radius, in pixels, the gate is blurred by before it is applied. + /// + /// A per-pixel colour test on a real sky speckles: sensor noise puts + /// individual pixels over the line in both directions. Two pixels of blur + /// removes the speckle and costs nothing at the boundary, which is already + /// soft by [`RefineOptions::soften`]. + pub smooth_px: f32, + + /// Samples below which a side is too small to fit anything to. + pub min_samples: usize, +} + +impl Default for RefineOptions { + fn default() -> Self { + Self { + seed_weight: 0.9, + margin_cells: 1.5, + clusters: 4, + min_cluster: 0.03, + luma_weight: 0.25, + outlier: 11.34, + soften: 1.5, + smooth_px: 2.0, + min_samples: 500, + } + } +} + +/// Why a refinement declined to do anything. +/// +/// Carried out rather than logged in here, because the caller knows which +/// category it asked about and this module does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkipReason { + /// Nothing survived the erosion — the category is present but everywhere + /// thinner than the model's own resolution, so there is no pixel it is + /// sure about. + NoInterior, + /// The frame is essentially all this category, leaving nothing to + /// contrast it against. + NoExterior, + /// The buffers handed in do not describe one image. + Mismatched, +} + +/// What a refinement did. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Refined { + /// Applied. `removed` is the fraction of the category's original total + /// weight the gate took away — small for a clean sky, a few percent for + /// one with a flag in it, and large enough to be worth a look in the log + /// if the colour model has gone wrong. + Applied { + removed: f32, + }, + Skipped(SkipReason), +} + +/// Number of features a pixel is judged on: one luminance, two chrominance. +const FEATURES: usize = 3; + +/// Floor on a fitted variance. +/// +/// A mode over a flat patch of sky has a variance of nearly nothing, and +/// nothing in the denominator makes every other pixel infinitely improbable. +/// This is a standard deviation of one part in a hundred, comfortably below +/// anything a photograph resolves and comfortably above zero. +const VARIANCE_FLOOR: f32 = 1e-4; + +/// Cap on the samples fitted per side. +/// +/// k-means over a million pixels answers the same as k-means over twenty +/// thousand of them, and costs fifty times as much. The subsample is a fixed +/// stride rather than a random draw so the result is reproducible. +const MAX_SAMPLES: usize = 20_000; + +/// Lloyd iterations. +/// +/// Fixed rather than run to convergence: a stopping rule that depends on +/// floating-point comparison is a stopping rule that can differ between +/// machines, and a mask that differs between machines reaches the sidecar as +/// indices meaning one thing on the desktop and another on the phone +/// (docs/segmentation.md §6). +const ITERATIONS: usize = 12; + +/// TRACES: FR-DEV-3 +/// Cut a coarse category mask back to the pixels that belong to it. +/// +/// `weights` is one category's coverage at `width * height`, as +/// `Scene::rasterise` produces it. `rgb` is the same picture, tightly packed +/// `f32` RGB — the proxy the model itself read, so the two describe one frame. +/// +/// `cell_pixels` is how many pixels of *this* buffer one logit cell spans; see +/// `Scene::cell_pixels`, which is where the caller should get it rather than +/// re-deriving a letterbox inverse. +/// +/// Returns the refined weights and what was done to them. On any +/// [`Refined::Skipped`] the weights come back identical to the input. +pub fn refine_category( + weights: &[f32], + rgb: &[f32], + width: usize, + height: usize, + cell_pixels: f32, + options: &RefineOptions, +) -> (Vec, Refined) { + let pixels = width * height; + if weights.len() != pixels || rgb.len() != pixels * 3 || pixels == 0 { + return (weights.to_vec(), Refined::Skipped(SkipReason::Mismatched)); + } + + // The distance field is over the *thresholded* weights, so the boundary it + // measures from is where the model stopped being sure — not where the mask + // will eventually be cut, which is what this function is deciding. + let coverage: Vec = weights + .iter() + .map(|&w| (w.clamp(0.0, 1.0) * 255.0).round() as u8) + .collect(); + let threshold = (options.seed_weight.clamp(0.0, 1.0) * 255.0).round() as u8; + let distance = signed_distance(&coverage, width, height, threshold); + + let margin = options.margin_cells * cell_pixels.max(1.0); + + let interior = sample(&distance, rgb, options.luma_weight, |d| d >= margin); + if interior.len() < options.min_samples { + return (weights.to_vec(), Refined::Skipped(SkipReason::NoInterior)); + } + let exterior = sample(&distance, rgb, options.luma_weight, |d| d <= -margin); + if exterior.len() < options.min_samples { + return (weights.to_vec(), Refined::Skipped(SkipReason::NoExterior)); + } + + let inside = fit(&interior, options.clusters, options.min_cluster); + // The exterior keeps every mode it finds. Pruning there would be the wrong + // sign: a small mode outside is a small *object*, and forgetting it only + // makes the comparative test more willing to keep its pixels. + let outside = fit(&exterior, options.clusters, 0.0); + + let mut gate = vec![0.0f32; pixels]; + for (p, cell) in gate.iter_mut().enumerate() { + let x = feature(rgb, p, options.luma_weight); + + // Squared Mahalanobis for the absolute test, because that is what is + // chi-square distributed; the density — the same distance with the + // mode's own spread folded in — for the comparative one, so that a + // tight mode and a loose one are compared fairly. + let (maha_in, nll_in) = nearest(&inside, &x); + let (_, nll_out) = nearest(&outside, &x); + + let plausible = 0.5 * (options.outlier - maha_in); + let likelier = nll_out - nll_in; + let verdict = plausible.min(likelier); + + *cell = smoothstep(-options.soften, options.soften, verdict); + } + + if options.smooth_px >= 1.0 { + gate = blur(&gate, width, height, options.smooth_px.round() as usize); + } + + let before: f32 = weights.iter().sum(); + let refined: Vec = weights.iter().zip(&gate).map(|(&w, &g)| w * g).collect(); + let after: f32 = refined.iter().sum(); + let removed = if before > 0.0 { + ((before - after) / before).clamp(0.0, 1.0) + } else { + 0.0 + }; + + (refined, Refined::Applied { removed }) +} + +/// One pixel's colour, as the three numbers the distributions are fitted over. +/// +/// Opponent axes rather than raw RGB, and no division anywhere: normalising +/// chrominance by luminance would be exposure-invariant and would also blow up +/// in the shadows, which on a landscape is the whole foreground. +fn feature(rgb: &[f32], p: usize, luma_weight: f32) -> [f32; FEATURES] { + let (r, g, b) = (rgb[p * 3], rgb[p * 3 + 1], rgb[p * 3 + 2]); + let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + [luma_weight * luma, r - g, b - 0.5 * (r + g)] +} + +/// Every pixel whose distance passes `want`, subsampled to [`MAX_SAMPLES`]. +/// +/// Two passes — count, then take a fixed stride — rather than one pass +/// collecting everything and thinning afterwards, so a sky filling a 22 MP +/// proxy never allocates twenty million features to throw most of them away. +fn sample( + distance: &[f32], + rgb: &[f32], + luma_weight: f32, + want: impl Fn(f32) -> bool, +) -> Vec<[f32; FEATURES]> { + let total = distance.iter().filter(|&&d| want(d)).count(); + if total == 0 { + return Vec::new(); + } + let stride = total.div_ceil(MAX_SAMPLES).max(1); + + let mut out = Vec::with_capacity(total.div_ceil(stride)); + let mut seen = 0usize; + for (p, &d) in distance.iter().enumerate() { + if !want(d) { + continue; + } + if seen.is_multiple_of(stride) { + out.push(feature(rgb, p, luma_weight)); + } + seen += 1; + } + out +} + +/// One mode of a side's colour distribution. +#[derive(Debug, Clone, Copy)] +struct Mode { + mean: [f32; FEATURES], + /// Per-feature, not a full covariance. The opponent axes are close enough + /// to decorrelated by construction that the off-diagonal terms buy little, + /// and a diagonal fit stays well-conditioned on the few hundred samples a + /// small mode gets. + variance: [f32; FEATURES], + /// `0.5 * ln|Σ|`, precomputed because it is constant per mode and the + /// inner loop runs once per pixel. + half_log_det: f32, +} + +/// Fit `k` modes to one side, dropping any that hold less than `min_share`. +fn fit(samples: &[[f32; FEATURES]], k: usize, min_share: f32) -> Vec { + let k = k.max(1).min(samples.len()); + let mut centres = seed_centres(samples, k); + let k = centres.len(); + + let mut assignment = vec![0usize; samples.len()]; + for _ in 0..ITERATIONS { + let mut moved = false; + for (s, sample) in samples.iter().enumerate() { + let mut best = 0usize; + let mut best_d = f32::INFINITY; + for (c, centre) in centres.iter().enumerate() { + let d = squared(sample, centre); + if d < best_d { + best_d = d; + best = c; + } + } + if assignment[s] != best { + assignment[s] = best; + moved = true; + } + } + if !moved { + break; + } + + let mut sums = vec![[0.0f32; FEATURES]; k]; + let mut counts = vec![0usize; k]; + for (s, sample) in samples.iter().enumerate() { + let c = assignment[s]; + for f in 0..FEATURES { + sums[c][f] += sample[f]; + } + counts[c] += 1; + } + for c in 0..k { + // An emptied centre is left where it was rather than re-seeded. + // Re-seeding is the usual advice, but it needs a random draw + // inside the loop and the determinism is worth more here than the + // empty cluster costs. + if counts[c] == 0 { + continue; + } + for f in 0..FEATURES { + centres[c][f] = sums[c][f] / counts[c] as f32; + } + } + } + + // Mean and spread from the final assignment. + let mut sums = vec![[0.0f32; FEATURES]; k]; + let mut squares = vec![[0.0f32; FEATURES]; k]; + let mut counts = vec![0usize; k]; + for (s, sample) in samples.iter().enumerate() { + let c = assignment[s]; + for f in 0..FEATURES { + sums[c][f] += sample[f]; + squares[c][f] += sample[f] * sample[f]; + } + counts[c] += 1; + } + + let floor = (min_share * samples.len() as f32) as usize; + let mut modes = Vec::new(); + let mut largest: Option<(usize, Mode)> = None; + for c in 0..k { + if counts[c] == 0 { + continue; + } + let n = counts[c] as f32; + let mut mean = [0.0f32; FEATURES]; + let mut variance = [0.0f32; FEATURES]; + let mut half_log_det = 0.0f32; + for f in 0..FEATURES { + mean[f] = sums[c][f] / n; + variance[f] = (squares[c][f] / n - mean[f] * mean[f]).max(VARIANCE_FLOOR); + half_log_det += 0.5 * variance[f].ln(); + } + let mode = Mode { + mean, + variance, + half_log_det, + }; + + if largest.is_none_or(|(best, _)| counts[c] > best) { + largest = Some((counts[c], mode)); + } + if counts[c] >= floor { + modes.push(mode); + } + } + + // Pruning everything is possible when `min_cluster` is set high and the + // samples split evenly. Keeping the largest is better than returning + // nothing, which would make every pixel infinitely improbable and cut the + // whole category away. + if modes.is_empty() { + modes.extend(largest.map(|(_, m)| m)); + } + modes +} + +/// k-means++ seeding, with a fixed sequence. +/// +/// The usual seeding is random, and random would make a mask depend on which +/// run produced it. The draw is a fixed-seed LCG instead: still spread out, +/// still far better than taking the first `k` samples, and the same every time +/// on every machine. +fn seed_centres(samples: &[[f32; FEATURES]], k: usize) -> Vec<[f32; FEATURES]> { + let mut rng = Lcg(0x2545_F491_4F6C_DD1D); + let mut centres = vec![samples[0]]; + let mut nearest = vec![f32::INFINITY; samples.len()]; + + while centres.len() < k { + let last = *centres.last().expect("seeded with one"); + let mut total = 0.0f64; + for (s, sample) in samples.iter().enumerate() { + nearest[s] = nearest[s].min(squared(sample, &last)); + total += nearest[s] as f64; + } + if total <= 0.0 { + // Every sample coincides with a centre — a perfectly flat side. + // More modes would all be the same mode. + break; + } + + let mut target = rng.unit() as f64 * total; + let mut pick = samples.len() - 1; + for (s, &d) in nearest.iter().enumerate() { + target -= d as f64; + if target <= 0.0 { + pick = s; + break; + } + } + centres.push(samples[pick]); + } + centres +} + +/// The closest mode, as `(squared Mahalanobis, negative log density)`. +/// +/// Nearest rather than a proper mixture sum: the largest term dominates a +/// well-separated mixture, and taking the max is what makes the two numbers +/// this returns describe *the same* mode. +fn nearest(modes: &[Mode], x: &[f32; FEATURES]) -> (f32, f32) { + let mut best = (f32::INFINITY, f32::INFINITY); + for mode in modes { + let mut maha = 0.0f32; + for ((v, mean), variance) in x.iter().zip(&mode.mean).zip(&mode.variance) { + let d = v - mean; + maha += d * d / variance; + } + let nll = 0.5 * maha + mode.half_log_det; + if nll < best.1 { + best = (maha, nll); + } + } + best +} + +fn squared(a: &[f32; FEATURES], b: &[f32; FEATURES]) -> f32 { + (0..FEATURES).map(|f| (a[f] - b[f]).powi(2)).sum() +} + +fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 { + if edge1 <= edge0 { + return if x >= edge1 { 1.0 } else { 0.0 }; + } + let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Separable box blur, clamped at the edges. +/// +/// A box rather than a gaussian because it is being applied to a gate that is +/// already soft: what is wanted is the removal of single-pixel speckle, and the +/// shape of the kernel that does it does not matter. +fn blur(src: &[f32], width: usize, height: usize, radius: usize) -> Vec { + if radius == 0 || width == 0 || height == 0 { + return src.to_vec(); + } + let span = (2 * radius + 1) as f32; + let mut mid = vec![0.0f32; src.len()]; + for y in 0..height { + for x in 0..width { + let mut sum = 0.0f32; + for k in 0..=2 * radius { + let sx = (x + k).saturating_sub(radius).min(width - 1); + sum += src[y * width + sx]; + } + mid[y * width + x] = sum / span; + } + } + let mut out = vec![0.0f32; src.len()]; + for y in 0..height { + for x in 0..width { + let mut sum = 0.0f32; + for k in 0..=2 * radius { + let sy = (y + k).saturating_sub(radius).min(height - 1); + sum += mid[sy * width + x]; + } + out[y * width + x] = sum / span; + } + } + out +} + +/// A fixed-sequence generator, for the one place a draw is needed. +struct Lcg(u64); + +impl Lcg { + fn unit(&mut self) -> f32 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + // The high bits are the well-mixed ones in an LCG; the low bits cycle + // with a short period and would make the draw far from uniform. + ((self.0 >> 40) as f32) / ((1u64 << 24) as f32) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One logit cell, in the pixels of the synthetic frames below. + const CELL: f32 = 8.0; + + /// Edge of every synthetic frame. + /// + /// Large enough that an intruder can be a realistic *fraction* of the + /// category rather than a realistic number of pixels — which is what + /// [`RefineOptions::min_cluster`] is measured in, and getting that + /// proportion wrong is the difference between this method working and not. + const EDGE: usize = 192; + + /// A frame with a blue upper half and a green lower half, and a category + /// mask that claims the whole upper half plus a smear over the boundary — + /// which is what the coarse grid actually produces. + fn landscape(w: usize, h: usize) -> (Vec, Vec) { + let mut rgb = vec![0.0f32; w * h * 3]; + let mut weights = vec![0.0f32; w * h]; + for y in 0..h { + for x in 0..w { + let p = y * w + x; + // A vertical gradient in the sky, because a real one has one + // and a single Gaussian over luma is exactly what it breaks. + let t = y as f32 / h as f32; + if y < h / 2 { + rgb[p * 3] = 0.25 + 0.4 * t; + rgb[p * 3 + 1] = 0.45 + 0.35 * t; + rgb[p * 3 + 2] = 0.85; + } else { + rgb[p * 3] = 0.20; + rgb[p * 3 + 1] = 0.45; + rgb[p * 3 + 2] = 0.15; + } + weights[p] = if y < h / 2 { 1.0 } else { 0.0 }; + } + } + (rgb, weights) + } + + /// Paint a rectangle into the frame, and let the coarse mask claim it — + /// the flag in the sky. + fn intrude( + rgb: &mut [f32], + weights: &mut [f32], + w: usize, + rect: (usize, usize, usize, usize), + colour: [f32; 3], + ) { + let (x0, y0, x1, y1) = rect; + for y in y0..y1 { + for x in x0..x1 { + let p = y * w + x; + rgb[p * 3..p * 3 + 3].copy_from_slice(&colour); + weights[p] = 1.0; + } + } + } + + fn mean(v: &[f32], w: usize, rect: (usize, usize, usize, usize)) -> f32 { + let (x0, y0, x1, y1) = rect; + let mut sum = 0.0; + for y in y0..y1 { + for x in x0..x1 { + sum += v[y * w + x]; + } + } + sum / ((x1 - x0) * (y1 - y0)) as f32 + } + + /// The case the module exists for: a red flag inside the sky, claimed by + /// the coarse mask, must come back out — while the sky around it stays. + /// + /// The flag is 16×16 against an eroded sky of ~16k pixels, so it is 1.6% + /// of the category. That proportion is the test, as much as the colour is: + /// see [`an_intruder_larger_than_min_cluster_survives`] for the same + /// picture with a bigger flag and the opposite result. + #[test] + fn a_flag_in_the_sky_is_removed() { + let (w, h) = (EDGE, EDGE); + let (mut rgb, mut weights) = landscape(w, h); + intrude( + &mut rgb, + &mut weights, + w, + (88, 40, 104, 56), + [0.75, 0.10, 0.12], + ); + + let (out, what) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); + assert!( + matches!(what, Refined::Applied { .. }), + "should have had the evidence to judge: {what:?}" + ); + + // Inside the flag, clear of its own blurred edge. + let inside = mean(&out, w, (92, 44, 100, 52)); + assert!(inside < 0.2, "the flag should be cut out, got {inside}"); + + // Sky far from the flag and far from the horizon. + let sky = mean(&out, w, (8, 8, 60, 60)); + assert!(sky > 0.8, "the sky around it should survive, got {sky}"); + } + + /// The method's stated limit, pinned so that it is a decision rather than + /// a surprise. + /// + /// An intruder big enough to hold its own k-means mode above + /// [`RefineOptions::min_cluster`] is indistinguishable, by this test, from + /// a legitimate second appearance of the category — a bank of cloud is + /// exactly that, and the two differ only in what a person calls them. So a + /// 22% flag is kept, and the knob that would cut it is the same knob that + /// would cut the cloud in + /// [`a_cloud_is_not_mistaken_for_an_intruder`]. + /// + /// This is not a defect to be fixed by raising `min_cluster`; it is the + /// trade-off that setting *is*. Recorded here so that a later change which + /// makes this test fail is recognised as having moved the trade-off rather + /// than as having fixed a bug. + #[test] + fn an_intruder_larger_than_min_cluster_survives() { + let (w, h) = (EDGE, EDGE); + let (mut rgb, mut weights) = landscape(w, h); + intrude( + &mut rgb, + &mut weights, + w, + (60, 20, 120, 80), + [0.75, 0.10, 0.12], + ); + + let (out, _) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); + let inside = mean(&out, w, (75, 35, 105, 65)); + assert!( + inside > 0.7, + "a 22% intruder is kept — see this test's comment: {inside}" + ); + } + + /// The refinement may only ever take weight away. + /// + /// This is what bounds its damage and what keeps `scene.rs`'s partition + /// true when several categories are refined independently, so it is + /// asserted rather than left as a property of the arithmetic. + #[test] + fn refinement_is_subtractive() { + let (w, h) = (EDGE, EDGE); + let (mut rgb, mut weights) = landscape(w, h); + intrude( + &mut rgb, + &mut weights, + w, + (88, 40, 104, 56), + [0.75, 0.1, 0.12], + ); + + let (out, _) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); + for (p, (&before, &after)) in weights.iter().zip(&out).enumerate() { + assert!( + after <= before + 1e-6, + "pixel {p} gained weight: {before} -> {after}" + ); + assert!( + (0.0..=1.0).contains(&after), + "pixel {p} out of range: {after}" + ); + } + } + + /// A cloud is a legitimate part of the sky and is separated from the blue + /// by luminance alone, which is exactly what `luma_weight` is for. + /// + /// Without the down-weighting this test fails and the flag test passes, + /// which is why both are here. + #[test] + fn a_cloud_is_not_mistaken_for_an_intruder() { + let (w, h) = (EDGE, EDGE); + let (mut rgb, mut weights) = landscape(w, h); + // Big and pale — near-neutral, much brighter than the blue. + intrude( + &mut rgb, + &mut weights, + w, + (30, 12, 150, 60), + [0.92, 0.94, 0.96], + ); + + let (out, _) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); + let cloud = mean(&out, w, (50, 24, 130, 48)); + assert!(cloud > 0.7, "the cloud should stay sky, got {cloud}"); + } + + /// No confident interior means no distribution, and no distribution must + /// mean "leave it alone" rather than "reject everything". + #[test] + fn a_mask_thinner_than_the_grid_is_left_alone() { + let (w, h) = (EDGE, EDGE); + let (rgb, _) = landscape(w, h); + // A three-pixel stripe: narrower than one cell, so the erosion at + // 1.5 cells empties it. + let mut weights = vec![0.0f32; w * h]; + for y in 0..h { + for x in 60..63 { + weights[y * w + x] = 1.0; + } + } + + let (out, what) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); + assert_eq!(what, Refined::Skipped(SkipReason::NoInterior)); + assert_eq!(out, weights, "a skip must return the input untouched"); + } + + /// A frame that is entirely one category has nothing to contrast against. + #[test] + fn a_frame_of_nothing_but_sky_is_left_alone() { + let (w, h) = (EDGE, EDGE); + let rgb = vec![0.6f32; w * h * 3]; + let weights = vec![1.0f32; w * h]; + + let (out, what) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); + assert_eq!(what, Refined::Skipped(SkipReason::NoExterior)); + assert_eq!(out, weights); + } + + /// Masks reach the sidecar as indices, so the same input must give the + /// same mask on every run and every machine — which is why the k-means + /// seeding uses a fixed sequence and the iteration count is fixed. + #[test] + fn the_same_input_gives_the_same_answer() { + let (w, h) = (EDGE, EDGE); + let (mut rgb, mut weights) = landscape(w, h); + intrude( + &mut rgb, + &mut weights, + w, + (88, 40, 104, 56), + [0.8, 0.2, 0.2], + ); + + let opts = RefineOptions::default(); + let (a, _) = refine_category(&weights, &rgb, w, h, CELL, &opts); + let (b, _) = refine_category(&weights, &rgb, w, h, CELL, &opts); + assert_eq!(a, b); + } + + #[test] + fn buffers_that_disagree_are_refused() { + let (out, what) = + refine_category(&[1.0; 4], &[0.5; 6], 2, 2, CELL, &RefineOptions::default()); + assert_eq!(what, Refined::Skipped(SkipReason::Mismatched)); + assert_eq!(out, vec![1.0; 4]); + } +} diff --git a/core/dr-segment/src/scene.rs b/core/dr-segment/src/scene.rs index 1dcb5f1..e469d87 100644 --- a/core/dr-segment/src/scene.rs +++ b/core/dr-segment/src/scene.rs @@ -292,6 +292,23 @@ impl Scene { (self.grid_width, self.grid_height) } + /// How many pixels of the analysed image one logit cell spans. + /// + /// This is the module header's "resolution, stated plainly" as a number a + /// caller can act on: [`Self::rasterise`] will happily hand back a + /// full-resolution buffer, and this says how much of that resolution is + /// the model's and how much is the bilinear's. At the 1600px proxy the + /// application segments at, it is 20. + /// + /// [`crate::refine`] is the one caller, and it needs this rather than a + /// pixel count of its own because the erosion that separates evidence from + /// smear is *defined* as a multiple of the model's own resolution. Derived + /// from the fitted letterbox rather than recomputed from the image size, + /// so the two cannot drift. + pub fn cell_pixels(&self) -> f32 { + GRID_STRIDE as f32 / self.letterbox.scale() + } + /// One category's weights over the logit grid. pub fn weight(&self, category: usize) -> Option<&[f32]> { let cells = self.grid_width * self.grid_height; diff --git a/core/dr-segment/src/semantic.rs b/core/dr-segment/src/semantic.rs index c47179c..1beb291 100644 --- a/core/dr-segment/src/semantic.rs +++ b/core/dr-segment/src/semantic.rs @@ -472,6 +472,16 @@ impl Letterbox { } } + /// Input pixels per source pixel. + /// + /// An accessor rather than a public field so that the one place that + /// converts a model-space size into a source-space one — `Scene::cell_pixels` + /// — reads the same number this struct fitted, instead of recomputing a + /// letterbox inverse and drifting from it. + pub(crate) fn scale(&self) -> f32 { + self.scale + } + /// Resample a source window into the graph's `[1, 3, 640, 640]` input. /// /// Bilinear, and grey (`0.5`) in the padding — the value the network sees diff --git a/docs/traceability.md b/docs/traceability.md index 7c43bed..028d222 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 328 | -| TRACES tags found | 1026 | +| Source files scanned | 329 | +| TRACES tags found | 1028 | | Requirements defined | 179 | | Requirements covered | 125 | | **Coverage** | **69.8%** (125/179) | @@ -59,14 +59,14 @@ _None._ | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:283`](../ui/dr-ui/ui/identity.slint#L283) | | FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../core/dr-pipeline/src/sidecar.rs#L1) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:170`](../core/dr-pipeline/src/graph.rs#L170), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:1652`](../core/dr-pipeline/src/sidecar.rs#L1652), [`core/dr-pipeline/src/sidecar.rs:1712`](../core/dr-pipeline/src/sidecar.rs#L1712), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:102`](../ui/dr-ui/src/develop.rs#L102), [`ui/dr-ui/src/develop.rs:1472`](../ui/dr-ui/src/develop.rs#L1472), [`ui/dr-ui/src/develop.rs:164`](../ui/dr-ui/src/develop.rs#L164), [`ui/dr-ui/src/develop.rs:1986`](../ui/dr-ui/src/develop.rs#L1986), [`ui/dr-ui/src/develop.rs:2004`](../ui/dr-ui/src/develop.rs#L2004), [`ui/dr-ui/src/develop.rs:2018`](../ui/dr-ui/src/develop.rs#L2018), [`ui/dr-ui/src/develop.rs:2040`](../ui/dr-ui/src/develop.rs#L2040), [`ui/dr-ui/src/develop.rs:2186`](../ui/dr-ui/src/develop.rs#L2186), [`ui/dr-ui/src/develop.rs:2284`](../ui/dr-ui/src/develop.rs#L2284), [`ui/dr-ui/src/develop.rs:327`](../ui/dr-ui/src/develop.rs#L327), [`ui/dr-ui/src/develop.rs:3584`](../ui/dr-ui/src/develop.rs#L3584), [`ui/dr-ui/src/develop.rs:3614`](../ui/dr-ui/src/develop.rs#L3614), [`ui/dr-ui/src/develop.rs:364`](../ui/dr-ui/src/develop.rs#L364), [`ui/dr-ui/src/develop.rs:3680`](../ui/dr-ui/src/develop.rs#L3680), [`ui/dr-ui/src/develop.rs:3694`](../ui/dr-ui/src/develop.rs#L3694), [`ui/dr-ui/src/develop.rs:3886`](../ui/dr-ui/src/develop.rs#L3886), [`ui/dr-ui/src/develop.rs:4546`](../ui/dr-ui/src/develop.rs#L4546), [`ui/dr-ui/src/develop.rs:4600`](../ui/dr-ui/src/develop.rs#L4600), [`ui/dr-ui/src/develop.rs:4644`](../ui/dr-ui/src/develop.rs#L4644), [`ui/dr-ui/src/develop.rs:4694`](../ui/dr-ui/src/develop.rs#L4694), [`ui/dr-ui/src/develop.rs:587`](../ui/dr-ui/src/develop.rs#L587), [`ui/dr-ui/src/develop.rs:648`](../ui/dr-ui/src/develop.rs#L648), [`ui/dr-ui/src/develop.rs:766`](../ui/dr-ui/src/develop.rs#L766), [`ui/dr-ui/src/develop.rs:813`](../ui/dr-ui/src/develop.rs#L813), [`ui/dr-ui/src/develop.rs:842`](../ui/dr-ui/src/develop.rs#L842), [`ui/dr-ui/src/lib.rs:1608`](../ui/dr-ui/src/lib.rs#L1608), [`ui/dr-ui/src/lib.rs:2410`](../ui/dr-ui/src/lib.rs#L2410), [`ui/dr-ui/src/lib.rs:2606`](../ui/dr-ui/src/lib.rs#L2606), [`ui/dr-ui/src/lib.rs:2778`](../ui/dr-ui/src/lib.rs#L2778), [`ui/dr-ui/src/lib.rs:2842`](../ui/dr-ui/src/lib.rs#L2842), [`ui/dr-ui/src/lib.rs:2896`](../ui/dr-ui/src/lib.rs#L2896), [`ui/dr-ui/src/lib.rs:355`](../ui/dr-ui/src/lib.rs#L355), [`ui/dr-ui/src/lib.rs:398`](../ui/dr-ui/src/lib.rs#L398), [`ui/dr-ui/src/lib.rs:421`](../ui/dr-ui/src/lib.rs#L421), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/masks_ui.rs:247`](../ui/dr-ui/src/masks_ui.rs#L247), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:862`](../ui/dr-ui/src/masks_ui.rs#L862), [`ui/dr-ui/src/masks_ui.rs:976`](../ui/dr-ui/src/masks_ui.rs#L976), [`ui/dr-ui/src/segmentation.rs:259`](../ui/dr-ui/src/segmentation.rs#L259), [`ui/dr-ui/src/segmentation.rs:375`](../ui/dr-ui/src/segmentation.rs#L375), [`ui/dr-ui/src/segmentation.rs:403`](../ui/dr-ui/src/segmentation.rs#L403), [`ui/dr-ui/ui/adjust.slint:547`](../ui/dr-ui/ui/adjust.slint#L547), [`ui/dr-ui/ui/adjust.slint:666`](../ui/dr-ui/ui/adjust.slint#L666), [`ui/dr-ui/ui/app.slint:150`](../ui/dr-ui/ui/app.slint#L150), [`ui/dr-ui/ui/app.slint:196`](../ui/dr-ui/ui/app.slint#L196), [`ui/dr-ui/ui/app.slint:2011`](../ui/dr-ui/ui/app.slint#L2011), [`ui/dr-ui/ui/app.slint:951`](../ui/dr-ui/ui/app.slint#L951), [`ui/dr-ui/ui/masks.slint:579`](../ui/dr-ui/ui/masks.slint#L579) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:170`](../core/dr-pipeline/src/graph.rs#L170), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:1652`](../core/dr-pipeline/src/sidecar.rs#L1652), [`core/dr-pipeline/src/sidecar.rs:1712`](../core/dr-pipeline/src/sidecar.rs#L1712), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`core/dr-segment/src/refine.rs:248`](../core/dr-segment/src/refine.rs#L248), [`ui/dr-ui/src/develop.rs:102`](../ui/dr-ui/src/develop.rs#L102), [`ui/dr-ui/src/develop.rs:1472`](../ui/dr-ui/src/develop.rs#L1472), [`ui/dr-ui/src/develop.rs:164`](../ui/dr-ui/src/develop.rs#L164), [`ui/dr-ui/src/develop.rs:1986`](../ui/dr-ui/src/develop.rs#L1986), [`ui/dr-ui/src/develop.rs:2004`](../ui/dr-ui/src/develop.rs#L2004), [`ui/dr-ui/src/develop.rs:2018`](../ui/dr-ui/src/develop.rs#L2018), [`ui/dr-ui/src/develop.rs:2040`](../ui/dr-ui/src/develop.rs#L2040), [`ui/dr-ui/src/develop.rs:2186`](../ui/dr-ui/src/develop.rs#L2186), [`ui/dr-ui/src/develop.rs:2284`](../ui/dr-ui/src/develop.rs#L2284), [`ui/dr-ui/src/develop.rs:327`](../ui/dr-ui/src/develop.rs#L327), [`ui/dr-ui/src/develop.rs:3584`](../ui/dr-ui/src/develop.rs#L3584), [`ui/dr-ui/src/develop.rs:3614`](../ui/dr-ui/src/develop.rs#L3614), [`ui/dr-ui/src/develop.rs:364`](../ui/dr-ui/src/develop.rs#L364), [`ui/dr-ui/src/develop.rs:3680`](../ui/dr-ui/src/develop.rs#L3680), [`ui/dr-ui/src/develop.rs:3694`](../ui/dr-ui/src/develop.rs#L3694), [`ui/dr-ui/src/develop.rs:3886`](../ui/dr-ui/src/develop.rs#L3886), [`ui/dr-ui/src/develop.rs:4546`](../ui/dr-ui/src/develop.rs#L4546), [`ui/dr-ui/src/develop.rs:4600`](../ui/dr-ui/src/develop.rs#L4600), [`ui/dr-ui/src/develop.rs:4644`](../ui/dr-ui/src/develop.rs#L4644), [`ui/dr-ui/src/develop.rs:4694`](../ui/dr-ui/src/develop.rs#L4694), [`ui/dr-ui/src/develop.rs:587`](../ui/dr-ui/src/develop.rs#L587), [`ui/dr-ui/src/develop.rs:648`](../ui/dr-ui/src/develop.rs#L648), [`ui/dr-ui/src/develop.rs:766`](../ui/dr-ui/src/develop.rs#L766), [`ui/dr-ui/src/develop.rs:813`](../ui/dr-ui/src/develop.rs#L813), [`ui/dr-ui/src/develop.rs:842`](../ui/dr-ui/src/develop.rs#L842), [`ui/dr-ui/src/lib.rs:1608`](../ui/dr-ui/src/lib.rs#L1608), [`ui/dr-ui/src/lib.rs:2410`](../ui/dr-ui/src/lib.rs#L2410), [`ui/dr-ui/src/lib.rs:2606`](../ui/dr-ui/src/lib.rs#L2606), [`ui/dr-ui/src/lib.rs:2778`](../ui/dr-ui/src/lib.rs#L2778), [`ui/dr-ui/src/lib.rs:2842`](../ui/dr-ui/src/lib.rs#L2842), [`ui/dr-ui/src/lib.rs:2896`](../ui/dr-ui/src/lib.rs#L2896), [`ui/dr-ui/src/lib.rs:355`](../ui/dr-ui/src/lib.rs#L355), [`ui/dr-ui/src/lib.rs:398`](../ui/dr-ui/src/lib.rs#L398), [`ui/dr-ui/src/lib.rs:421`](../ui/dr-ui/src/lib.rs#L421), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/masks_ui.rs:247`](../ui/dr-ui/src/masks_ui.rs#L247), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:862`](../ui/dr-ui/src/masks_ui.rs#L862), [`ui/dr-ui/src/masks_ui.rs:976`](../ui/dr-ui/src/masks_ui.rs#L976), [`ui/dr-ui/src/segmentation.rs:259`](../ui/dr-ui/src/segmentation.rs#L259), [`ui/dr-ui/src/segmentation.rs:275`](../ui/dr-ui/src/segmentation.rs#L275), [`ui/dr-ui/src/segmentation.rs:392`](../ui/dr-ui/src/segmentation.rs#L392), [`ui/dr-ui/src/segmentation.rs:420`](../ui/dr-ui/src/segmentation.rs#L420), [`ui/dr-ui/ui/adjust.slint:547`](../ui/dr-ui/ui/adjust.slint#L547), [`ui/dr-ui/ui/adjust.slint:666`](../ui/dr-ui/ui/adjust.slint#L666), [`ui/dr-ui/ui/app.slint:150`](../ui/dr-ui/ui/app.slint#L150), [`ui/dr-ui/ui/app.slint:196`](../ui/dr-ui/ui/app.slint#L196), [`ui/dr-ui/ui/app.slint:2011`](../ui/dr-ui/ui/app.slint#L2011), [`ui/dr-ui/ui/app.slint:951`](../ui/dr-ui/ui/app.slint#L951), [`ui/dr-ui/ui/masks.slint:579`](../ui/dr-ui/ui/masks.slint#L579) | | FR-DEV-3a | [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:24`](../core/dr-pipeline/src/graph.rs#L24), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/mask.rs:988`](../core/dr-pipeline/src/mask.rs#L988), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1369`](../ui/dr-ui/src/develop.rs#L1369), [`ui/dr-ui/src/lib.rs:704`](../ui/dr-ui/src/lib.rs#L704), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | | FR-DEV-3c | [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/mask.rs:988`](../core/dr-pipeline/src/mask.rs#L988), [`ui/dr-ui/src/develop.rs:5273`](../ui/dr-ui/src/develop.rs#L5273) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:1079`](../core/dr-gpu/src/adjust.rs#L1079), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:558`](../core/dr-gpu/tests/local_contrast.rs#L558), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/graph.rs:617`](../core/dr-pipeline/src/graph.rs#L617), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) | | FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:102`](../core/dr-pipeline/src/graph.rs#L102), [`core/dr-pipeline/src/graph.rs:125`](../core/dr-pipeline/src/graph.rs#L125), [`core/dr-pipeline/src/graph.rs:325`](../core/dr-pipeline/src/graph.rs#L325), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:112`](../core/dr-pipeline/src/sidecar.rs#L112), [`core/dr-pipeline/src/sidecar.rs:168`](../core/dr-pipeline/src/sidecar.rs#L168), [`core/dr-pipeline/src/sidecar.rs:1998`](../core/dr-pipeline/src/sidecar.rs#L1998), [`core/dr-pipeline/src/sidecar.rs:2074`](../core/dr-pipeline/src/sidecar.rs#L2074), [`core/dr-pipeline/src/sidecar.rs:534`](../core/dr-pipeline/src/sidecar.rs#L534), [`core/dr-pipeline/src/sidecar.rs:661`](../core/dr-pipeline/src/sidecar.rs#L661), [`core/dr-pipeline/src/sidecar.rs:793`](../core/dr-pipeline/src/sidecar.rs#L793), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:3328`](../ui/dr-ui/src/develop.rs#L3328), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:3357`](../ui/dr-ui/src/develop.rs#L3357), [`ui/dr-ui/src/develop.rs:3395`](../ui/dr-ui/src/develop.rs#L3395), [`ui/dr-ui/src/develop.rs:3404`](../ui/dr-ui/src/develop.rs#L3404), [`ui/dr-ui/src/develop.rs:3507`](../ui/dr-ui/src/develop.rs#L3507), [`ui/dr-ui/src/develop.rs:3923`](../ui/dr-ui/src/develop.rs#L3923), [`ui/dr-ui/src/develop.rs:3938`](../ui/dr-ui/src/develop.rs#L3938), [`ui/dr-ui/src/lib.rs:2384`](../ui/dr-ui/src/lib.rs#L2384), [`ui/dr-ui/src/lib.rs:637`](../ui/dr-ui/src/lib.rs#L637), [`ui/dr-ui/src/lib.rs:695`](../ui/dr-ui/src/lib.rs#L695), [`ui/dr-ui/src/library.rs:530`](../ui/dr-ui/src/library.rs#L530), [`ui/dr-ui/src/library.rs:778`](../ui/dr-ui/src/library.rs#L778), [`ui/dr-ui/src/presets.rs:395`](../ui/dr-ui/src/presets.rs#L395), [`ui/dr-ui/ui/adjust.slint:1072`](../ui/dr-ui/ui/adjust.slint#L1072), [`ui/dr-ui/ui/adjust.slint:996`](../ui/dr-ui/ui/adjust.slint#L996), [`ui/dr-ui/ui/app.slint:2581`](../ui/dr-ui/ui/app.slint#L2581), [`ui/dr-ui/ui/app.slint:679`](../ui/dr-ui/ui/app.slint#L679) | -| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1050`](../core/dr-pipeline/src/framing.rs#L1050), [`core/dr-pipeline/src/framing.rs:328`](../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-types/src/lib.rs:337`](../core/dr-types/src/lib.rs#L337), [`core/dr-types/src/lib.rs:445`](../core/dr-types/src/lib.rs#L445), [`core/dr-types/src/lib.rs:457`](../core/dr-types/src/lib.rs#L457), [`core/dr-types/src/lib.rs:473`](../core/dr-types/src/lib.rs#L473), [`ui/dr-ui/src/develop.rs:139`](../ui/dr-ui/src/develop.rs#L139), [`ui/dr-ui/src/develop.rs:2203`](../ui/dr-ui/src/develop.rs#L2203), [`ui/dr-ui/src/segmentation.rs:375`](../ui/dr-ui/src/segmentation.rs#L375) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1050`](../core/dr-pipeline/src/framing.rs#L1050), [`core/dr-pipeline/src/framing.rs:328`](../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-types/src/lib.rs:337`](../core/dr-types/src/lib.rs#L337), [`core/dr-types/src/lib.rs:445`](../core/dr-types/src/lib.rs#L445), [`core/dr-types/src/lib.rs:457`](../core/dr-types/src/lib.rs#L457), [`core/dr-types/src/lib.rs:473`](../core/dr-types/src/lib.rs#L473), [`ui/dr-ui/src/develop.rs:139`](../ui/dr-ui/src/develop.rs#L139), [`ui/dr-ui/src/develop.rs:2203`](../ui/dr-ui/src/develop.rs#L2203), [`ui/dr-ui/src/segmentation.rs:392`](../ui/dr-ui/src/segmentation.rs#L392) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:351`](../core/dr-gpu/src/lib.rs#L351), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | | FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:3357`](../ui/dr-ui/src/develop.rs#L3357), [`ui/dr-ui/src/develop.rs:3938`](../ui/dr-ui/src/develop.rs#L3938), [`ui/dr-ui/src/develop.rs:3968`](../ui/dr-ui/src/develop.rs#L3968), [`ui/dr-ui/src/develop.rs:3981`](../ui/dr-ui/src/develop.rs#L3981), [`ui/dr-ui/src/develop.rs:3993`](../ui/dr-ui/src/develop.rs#L3993), [`ui/dr-ui/src/develop.rs:4009`](../ui/dr-ui/src/develop.rs#L4009), [`ui/dr-ui/src/develop.rs:4041`](../ui/dr-ui/src/develop.rs#L4041), [`ui/dr-ui/src/develop.rs:4045`](../ui/dr-ui/src/develop.rs#L4045), [`ui/dr-ui/src/develop.rs:4064`](../ui/dr-ui/src/develop.rs#L4064), [`ui/dr-ui/src/develop.rs:4080`](../ui/dr-ui/src/develop.rs#L4080), [`ui/dr-ui/src/develop.rs:709`](../ui/dr-ui/src/develop.rs#L709), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1543`](../ui/dr-ui/src/lib.rs#L1543), [`ui/dr-ui/src/lib.rs:1586`](../ui/dr-ui/src/lib.rs#L1586), [`ui/dr-ui/src/lib.rs:1594`](../ui/dr-ui/src/lib.rs#L1594), [`ui/dr-ui/src/lib.rs:2580`](../ui/dr-ui/src/lib.rs#L2580), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | | FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:381`](../core/dr-pipeline/src/preset.rs#L381), [`core/dr-pipeline/src/preset.rs:414`](../core/dr-pipeline/src/preset.rs#L414), [`core/dr-pipeline/src/starter.rs:1`](../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:272`](../core/dr-types/src/settings.rs#L272), [`ui/dr-ui/src/develop.rs:3880`](../ui/dr-ui/src/develop.rs#L3880), [`ui/dr-ui/src/develop.rs:3901`](../ui/dr-ui/src/develop.rs#L3901), [`ui/dr-ui/src/lib.rs:1510`](../ui/dr-ui/src/lib.rs#L1510), [`ui/dr-ui/src/lib.rs:2195`](../ui/dr-ui/src/lib.rs#L2195), [`ui/dr-ui/src/lib.rs:2224`](../ui/dr-ui/src/lib.rs#L2224), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:520`](../ui/dr-ui/src/library.rs#L520), [`ui/dr-ui/src/library_ui.rs:2747`](../ui/dr-ui/src/library_ui.rs#L2747), [`ui/dr-ui/src/library_ui.rs:3147`](../ui/dr-ui/src/library_ui.rs#L3147), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:657`](../ui/dr-ui/src/presets.rs#L657), [`ui/dr-ui/src/presets.rs:691`](../ui/dr-ui/src/presets.rs#L691), [`ui/dr-ui/ui/adjust.slint:740`](../ui/dr-ui/ui/adjust.slint#L740), [`ui/dr-ui/ui/adjust.slint:767`](../ui/dr-ui/ui/adjust.slint#L767), [`ui/dr-ui/ui/adjust.slint:801`](../ui/dr-ui/ui/adjust.slint#L801), [`ui/dr-ui/ui/app.slint:1564`](../ui/dr-ui/ui/app.slint#L1564), [`ui/dr-ui/ui/app.slint:2670`](../ui/dr-ui/ui/app.slint#L2670), [`ui/dr-ui/ui/app.slint:751`](../ui/dr-ui/ui/app.slint#L751), [`ui/dr-ui/ui/app.slint:762`](../ui/dr-ui/ui/app.slint#L762), [`ui/dr-ui/ui/library.slint:1456`](../ui/dr-ui/ui/library.slint#L1456), [`ui/dr-ui/ui/library.slint:1462`](../ui/dr-ui/ui/library.slint#L1462), [`ui/dr-ui/ui/library.slint:3964`](../ui/dr-ui/ui/library.slint#L3964), [`ui/dr-ui/ui/library.slint:3979`](../ui/dr-ui/ui/library.slint#L3979), [`ui/dr-ui/ui/presets.slint:105`](../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:101`](../ui/dr-ui/ui/settings.slint#L101), [`ui/dr-ui/ui/settings.slint:557`](../ui/dr-ui/ui/settings.slint#L557) | diff --git a/ui/dr-ui/src/segmentation.rs b/ui/dr-ui/src/segmentation.rs index 5266cc2..464de93 100644 --- a/ui/dr-ui/src/segmentation.rs +++ b/ui/dr-ui/src/segmentation.rs @@ -271,6 +271,22 @@ pub struct Options { /// answer. It is the right answer for a bird against sky, so it is offered /// per-image rather than chosen once for all of them. pub fine: bool, + + /// TRACES: FR-DEV-3 + /// Cut each scene category back to the pixels whose colour agrees with it. + /// + /// The scene model's counterpart to [`Options::fine`], and it exists + /// because tiling is not available here: a category has no bounding box to + /// tile over — sky is wherever the sky is — so the only route to a sharper + /// category edge is the photograph itself. See [`dr_segment::refine`]. + /// + /// On by default where `fine` is off, because the two have opposite costs. + /// Tiling is six inferences and a five-second wait; this is a distance + /// transform and a k-means over a subsample, tens of milliseconds against + /// a precompute already measured in hundreds. And it is subtractive, so + /// the worst it can do is take too much of a category rather than invent + /// one. + pub refine: bool, } impl Default for Options { @@ -278,6 +294,7 @@ impl Default for Options { Self { confidence: 0.30, fine: false, + refine: true, } } } @@ -353,7 +370,7 @@ pub fn compute( // way. Failures here are logged and dropped rather than propagated: no // scene model is an ordinary state, and a photograph that can be masked by // subject should not become unopenable because the categories are absent. - let categories = match scene_categories(&stood_up, uw, uh, orientation) { + let categories = match scene_categories(&stood_up, uw, uh, orientation, options.refine) { Ok(c) => c, Err(e) => { log::info!("no scene categories for this frame: {e}"); @@ -539,6 +556,7 @@ fn scene_categories( width: usize, height: usize, orientation: Orientation, + refine: bool, ) -> Result, String> { let mut model = load_scene_model()?; let scene = model @@ -558,6 +576,42 @@ fn scene_categories( let Some(weights) = scene.rasterise(index, width, height) else { continue; }; + + // Cut the category back to the pixels whose colour agrees with it. + // + // Here rather than after `lay_down` because this reads the photograph, + // and the photograph is upright at this point — refining on the far + // side of the permutation would mean carrying a second, rotated copy + // of the proxy across for it to read. + let weights = if refine { + let (refined, what) = dr_segment::refine_category( + &weights, + rgb, + width, + height, + scene.cell_pixels(), + &dr_segment::RefineOptions::default(), + ); + match what { + dr_segment::Refined::Applied { removed } => { + log::debug!( + "refined '{name}': cut {:.1}% of its weight", + removed * 100.0 + ); + } + // An ordinary state, not a failure — see `dr_segment::refine`. + // Logged all the same, because a refinement that silently did + // nothing is indistinguishable from the feature being off, and + // "the sky looks the way it did before" is what both look like. + dr_segment::Refined::Skipped(why) => { + log::debug!("left '{name}' coarse: {why:?}"); + } + } + refined + } else { + weights + }; + // Back into sensor space, exactly as an instance mask is: the grid a // stored layer indexes into has to be the sensor's whatever the model // was shown. `lay_down` wants a box too, so it gets the whole frame —