Merge branch 'master' into worktree-faces-scrfd-mbf
Build and test / Desktop (Linux) (push) Failing after 25s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Successful in 58s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 33m26s

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/src/develop.rs
#	ui/dr-ui/src/segmentation.rs
This commit is contained in:
2026-08-27 11:57:38 +02:00
51 changed files with 7732 additions and 586 deletions
+7 -20
View File
@@ -39,27 +39,14 @@ impl Preview {
/// not: a quarter turn is a permutation, so it costs the same either way,
/// and doing it on the smaller buffer moves a fraction of the bytes.
///
/// Allocates a second buffer rather than rotating in place. An in-place
/// quarter turn on a non-square image is a cycle-following permutation
/// that is both slower per pixel and far harder to get right, for a saving
/// that a thumbnail-sized buffer does not need.
/// The turn itself is [`dr_types::Orientation::into_shown`], which every
/// other consumer of an orientation in this codebase also goes through.
/// That is deliberate: a hand-written permutation per caller is how two of
/// them come to disagree, and a disagreement here shows as a thumbnail
/// facing the other way from the develop view.
pub fn apply_orientation(&mut self, orientation: dr_types::Orientation) {
if orientation.is_normal() || self.width == 0 || self.height == 0 {
return;
}
let (dw, dh) = orientation.oriented_size(self.width, self.height);
let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4];
for y in 0..dh {
for x in 0..dw {
let (sx, sy) = orientation.source_pixel(x, y, dw, dh);
let s = ((sy * self.width + sx) * 4) as usize;
let d = ((y * dw + x) * 4) as usize;
out[d..d + 4].copy_from_slice(&self.rgba[s..s + 4]);
}
}
self.rgba = out;
let (rgba, dw, dh) = orientation.into_shown(&self.rgba, self.width, self.height, 4);
self.rgba = rgba;
self.width = dw;
self.height = dh;
}
-156
View File
@@ -283,159 +283,3 @@ mod tests {
}
}
/// Map a box from the **displayed** (upright) image back into the **stored**
/// (sensor) one.
///
/// # Why this is needed at all
///
/// Faces are found on the thumbnail, which is cached the right way up — the
/// grid would lie on its side otherwise. Segmentation runs on a proxy rendered
/// through a *neutral* edit graph, which carries no orientation, so it is in
/// sensor order. For any photograph shot in portrait the two spaces differ by a
/// quarter turn, and matching a face against an instance without undoing that
/// finds nothing — or worse, finds the wrong person, since a rotated box can
/// still land inside some other instance.
///
/// The transform is applied to the face rather than to the segmentation proxy
/// on purpose. Instance masks are defined in the proxy's space and sampled long
/// afterwards; turning that space would be a far larger change than naming a
/// region warrants.
///
/// `displayed` is the size of the upright image in the same units as `bbox`.
/// Orientation is `(quarter_turns clockwise, flip_h, flip_v)`, applied by the
/// renderer in that order — so undoing it means undoing the flips first.
pub fn to_sensor_space(
bbox: (f32, f32, f32, f32),
displayed: (f32, f32),
quarter_turns: u8,
flip_h: bool,
flip_v: bool,
) -> (f32, f32, f32, f32) {
let (dw, dh) = displayed;
let (mut x0, mut y0, mut x1, mut y1) = bbox;
// Undo the mirrors, which the renderer applied last.
if flip_h {
let (a, b) = (dw - x1, dw - x0);
x0 = a;
x1 = b;
}
if flip_v {
let (a, b) = (dh - y1, dh - y0);
y0 = a;
y1 = b;
}
// Undo the turn. Each step rotates the box a quarter turn anticlockwise
// within the frame it currently occupies, swapping the frame's extents as
// it goes — which is why `w` and `h` are tracked rather than assumed.
let (mut w, mut h) = (dw, dh);
for _ in 0..(quarter_turns % 4) {
// Clockwise forward is (x, y) -> (h_before - y, x); anticlockwise back
// is (x, y) -> (y, w - x).
let (nx0, ny0) = (y0, w - x1);
let (nx1, ny1) = (y1, w - x0);
x0 = nx0;
y0 = ny0;
x1 = nx1;
y1 = ny1;
std::mem::swap(&mut w, &mut h);
}
(x0, y0, x1, y1)
}
#[cfg(test)]
mod orientation_tests {
use super::*;
/// A landscape frame with a face near the top left.
const DISPLAYED: (f32, f32) = (1000.0, 600.0);
const FACE: (f32, f32, f32, f32) = (100.0, 50.0, 200.0, 150.0);
#[test]
fn an_upright_image_needs_no_transform() {
assert_eq!(to_sensor_space(FACE, DISPLAYED, 0, false, false), FACE);
}
/// The case that motivated this: a portrait photograph, stored sideways
/// and displayed with one clockwise quarter turn.
#[test]
fn a_quarter_turn_round_trips() {
let sensor = to_sensor_space(FACE, DISPLAYED, 1, false, false);
// Sensor frame is 600 × 1000 — the displayed extents swapped.
assert!(sensor.0 >= 0.0 && sensor.2 <= 600.0, "{sensor:?}");
assert!(sensor.1 >= 0.0 && sensor.3 <= 1000.0, "{sensor:?}");
// And the box keeps its size, only turned.
let (w, h) = (sensor.2 - sensor.0, sensor.3 - sensor.1);
assert!((w - 100.0).abs() < 1e-3, "width {w}");
assert!((h - 100.0).abs() < 1e-3, "height {h}");
}
/// Four quarter turns is the identity, which is the cheapest possible
/// check that the rotation step is self-consistent.
#[test]
fn four_quarter_turns_return_the_original() {
let mut b = FACE;
let mut frame = DISPLAYED;
for _ in 0..4 {
b = to_sensor_space(b, frame, 1, false, false);
frame = (frame.1, frame.0);
}
assert!((b.0 - FACE.0).abs() < 1e-3, "{b:?}");
assert!((b.1 - FACE.1).abs() < 1e-3, "{b:?}");
assert!((b.2 - FACE.2).abs() < 1e-3, "{b:?}");
assert!((b.3 - FACE.3).abs() < 1e-3, "{b:?}");
}
#[test]
fn a_horizontal_mirror_reflects_across_the_width() {
let s = to_sensor_space(FACE, DISPLAYED, 0, true, false);
assert_eq!(s, (800.0, 50.0, 900.0, 150.0));
}
#[test]
fn a_vertical_mirror_reflects_across_the_height() {
let s = to_sensor_space(FACE, DISPLAYED, 0, false, true);
assert_eq!(s, (100.0, 450.0, 200.0, 550.0));
}
#[test]
fn a_half_turn_maps_a_corner_to_the_opposite_corner() {
let corner = (0.0, 0.0, 100.0, 100.0);
let s = to_sensor_space(corner, DISPLAYED, 2, false, false);
assert!((s.0 - 900.0).abs() < 1e-3, "{s:?}");
assert!((s.1 - 500.0).abs() < 1e-3, "{s:?}");
}
/// The boxes must stay well-formed whatever the transform: `x0 <= x1` and
/// `y0 <= y1`, or every containment test downstream silently returns zero.
#[test]
fn every_orientation_produces_a_well_formed_box() {
for turns in 0..4u8 {
for &fh in &[false, true] {
for &fv in &[false, true] {
let s = to_sensor_space(FACE, DISPLAYED, turns, fh, fv);
assert!(s.0 <= s.2, "turns={turns} fh={fh} fv={fv}: {s:?}");
assert!(s.1 <= s.3, "turns={turns} fh={fh} fv={fv}: {s:?}");
}
}
}
}
/// A face that was inside the frame must stay inside it, whichever way the
/// frame is turned.
#[test]
fn a_face_inside_the_frame_stays_inside_it() {
for turns in 0..4u8 {
let s = to_sensor_space(FACE, DISPLAYED, turns, false, false);
let (fw, fh) = if turns % 2 == 1 {
(DISPLAYED.1, DISPLAYED.0)
} else {
DISPLAYED
};
assert!(s.0 >= -1e-3 && s.2 <= fw + 1e-3, "turns={turns}: {s:?}");
assert!(s.1 >= -1e-3 && s.3 <= fh + 1e-3, "turns={turns}: {s:?}");
}
}
}
+294
View File
@@ -0,0 +1,294 @@
//! TRACES: FR-DEV-3f
//! Grain as a Boolean model — grains that overlap, rather than noise that does
//! not.
//!
//! # Why the counting model was not enough
//!
//! [`crate::grain`] gets the *variance* of a developed density right: a count
//! of independent yes/no events, `D(Dmax − uD)/N`, calibrated from published
//! granularity. What it cannot get right is the **structure**, because it
//! treats every pixel as an independent draw.
//!
//! Measured at 35 mm, a pixel of a 5472-wide frame covers about 6.6 µm and
//! holds some 300 crystals of ~370 nm. Three hundred independent events per
//! pixel average almost flat, and the little that survives has no spatial
//! extent — which is exactly why it reads as sensor noise rather than as film.
//!
//! Real grain is visible because it *clumps*. A crystal is far smaller than a
//! pixel, but crystals overlap into structures that are not, and those survive
//! the filtering that averages independent noise away.
//!
//! # The model
//!
//! Newson, Delon & Galerne, *A Stochastic Film Grain Model for
//! Resolution-Independent Rendering* (Computer Graphics Forum, 2017).
//!
//! Grain centres are a Poisson process of local intensity `λ(y)`; each centre
//! carries a disc. The developed film is the **union** of those discs, and a
//! point is opaque exactly when some disc covers it. Because a disc covers a
//! whole neighbourhood, nearby points are *correlated* — and that correlation
//! is the clumping, which arrives for free rather than being added.
//!
//! # Why it couples to density and not to a grey level
//!
//! The paper drives `λ` from an image's grey level, because it renders grain
//! onto a finished picture. We are not doing that: we have a *density* per
//! layer, from a measured characteristic curve, and a dye that absorbs through
//! it.
//!
//! The two meet exactly. In a Boolean model the chance a point is left
//! uncovered is
//!
//! ```text
//! P(uncovered) = exp(−λ · E[A])
//! ```
//!
//! which is Beer–Lambert. So the model's coverage *is* optical density, and
//!
//! ```text
//! λ = D · ln(10) / E[A]
//! ```
//!
//! puts our measured densities straight into it — with `E[A]`, the mean grain
//! area, already computed from published RMS granularity in
//! [`crate::grain`]. Nothing here is tuned by eye.
//!
//! # What it costs
//!
//! Monte Carlo per pixel, against the counting model's two hashes and a square
//! root. The shader form stores no grains: space is cut into cells, a
//! generator is seeded from each cell's index, and only the cells a sample
//! could reach are visited. That keeps it inside the fused pass — no
//! neighbouring *pixel* is read — but it is emphatically not free, and
//! [`BooleanGrain::samples_for`] is where that trade is made explicit.
use crate::grain::Grain;
/// Mean grain area, in µm², recovered from the counting model's calibration.
///
/// The two models are the same emulsion seen two ways, so they must not
/// disagree about how big a crystal is: `Grain` already inverts published RMS
/// granularity for exactly this number, and taking it from there is what stops
/// a Boolean render and a counting render describing different films.
pub fn mean_grain_area_um2(grain: &Grain, pixel_size_um: f32) -> [f32; 3] {
let pixel_area = (pixel_size_um * pixel_size_um).max(1e-6);
grain.particles.map(|n| pixel_area / n.max(1e-6))
}
/// TRACES: FR-DEV-3f
/// What the shader needs to render the Boolean model.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BooleanGrain {
/// Grain radius per layer, in *pixels* at the current sampling scale.
///
/// In pixels rather than micrometres because that is the unit the shader
/// works in, and converting once here keeps the conversion out of the
/// inner loop.
pub radius_px: [f32; 3],
/// `ln(10) / E[A]`, per layer: the factor taking a density to a Poisson
/// intensity. Precomputed because it is constant per bake and the shader
/// would otherwise recompute a logarithm per pixel per layer.
pub lambda_per_density: [f32; 3],
/// The density each layer saturates at, as in the counting model.
pub density_max: [f32; 3],
/// Monte Carlo samples per pixel.
pub samples: u32,
/// Standard deviation of the sampling kernel, in pixels.
///
/// The pixel's own footprint: what a scanner or an eye integrates over.
/// Too small and the render is binary salt and pepper; too large and the
/// grain is blurred out of existence.
pub sigma_px: f32,
}
impl BooleanGrain {
/// Derive the parameters for a stock at a given sampling scale.
pub fn new(grain: &Grain, pixel_size_um: f32, samples: u32) -> Self {
let area = mean_grain_area_um2(grain, pixel_size_um);
let pixel_area = (pixel_size_um * pixel_size_um).max(1e-6);
let mut radius_px = [0.0f32; 3];
let mut lambda_per_density = [0.0f32; 3];
for l in 0..3 {
// A disc of this area, expressed as a fraction of a pixel.
let area_px = area[l] / pixel_area;
radius_px[l] = (area_px / std::f32::consts::PI).sqrt();
// Beer-Lambert, read backwards: coverage exp(-lambda*E[A]) is
// transmittance 10^-D, so lambda = D * ln(10) / E[A].
lambda_per_density[l] = std::f32::consts::LN_10 / area_px.max(1e-9);
}
Self {
radius_px,
lambda_per_density,
density_max: grain.density_max,
samples: samples.max(1),
// Half a pixel: the footprint of one sample of a sensor whose
// pixels abut. Wider would be a soft scanner, narrower a sharper
// one than exists.
sigma_px: 0.5,
}
}
/// How many Monte Carlo samples a given quality asks for.
///
/// The estimator's own noise falls as `1/sqrt(N)`, so this trades one kind
/// of grain against another: too few samples and the *sampling* shows as a
/// second, wrong texture on top of the film's.
pub fn samples_for(quality: Quality) -> u32 {
match quality {
Quality::Preview => 16,
Quality::Export => 64,
}
}
/// Expected coverage at a density — what the render must average to.
///
/// The Boolean model's mean is analytic even though its texture is not,
/// which is what makes it testable without rendering anything: whatever
/// the grain does locally, across a flat patch it has to come back to the
/// density the characteristic curve asked for.
pub fn expected_coverage(&self, layer: usize, density: f32) -> f32 {
let d = density.clamp(0.0, self.density_max[layer]);
1.0 - 10f32.powf(-d)
}
}
/// How hard to work at the Monte Carlo.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Quality {
/// Interactive. Some sampling noise, which at preview scale is hidden
/// under the grain it is sampling.
Preview,
/// Final render, where the sampling noise must be well below the grain.
Export,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::Profile;
fn portra() -> Profile {
Profile::parse(include_str!("../profiles/kodak_portra_400.yaml")).unwrap()
}
fn model(pixel_size_um: f32) -> BooleanGrain {
let g = Grain::for_pixel_size(&portra(), pixel_size_um);
BooleanGrain::new(&g, pixel_size_um, 16)
}
#[test]
fn coverage_is_beer_lambert() {
// The identity the whole coupling rests on: a Boolean model's uncovered
// fraction is exp(-lambda E[A]), and transmittance is 10^-D, so the
// model's coverage *is* the film's opacity. If this drifts, the grain
// is no longer rendering the density the curve asked for.
let m = model(6.6);
// Inside the layer's own range. Past its Dmax the coverage clamps —
// correctly, since a film cannot develop denser than its maximum — and
// an earlier version of this test probed 2.0 against a layer that
// reaches 1.798, then blamed the model for the clamp.
for d in [0.0f32, 0.3, 1.0, 1.7] {
let coverage = m.expected_coverage(1, d);
let transmittance = 1.0 - coverage;
assert!(
(transmittance - 10f32.powf(-d)).abs() < 1e-5,
"at density {d}: transmittance {transmittance}, expected {}",
10f32.powf(-d)
);
}
}
#[test]
fn clear_film_has_no_grains_and_fully_developed_film_is_nearly_solid() {
let m = model(6.6);
assert!(m.expected_coverage(1, 0.0) < 1e-6);
// At its own maximum, not at some density it never reaches: Portra's
// green layer tops out near 1.8, which transmits about 1.6% — dense,
// and not opaque. A film that went fully black would be one whose
// shadows carried no detail at all.
let dmax = m.density_max[1];
assert!(
m.expected_coverage(1, dmax) > 0.98,
"{}",
m.expected_coverage(1, dmax)
);
}
#[test]
fn coverage_clamps_at_the_layers_own_maximum() {
// The property the two tests above tripped over, asserted directly:
// asking for more density than the emulsion has gives the emulsion's
// own ceiling rather than extrapolating one.
let m = model(6.6);
let dmax = m.density_max[1];
assert_eq!(
m.expected_coverage(1, dmax),
m.expected_coverage(1, dmax + 5.0)
);
}
#[test]
fn the_two_models_describe_the_same_crystal() {
// The counting model and this one are one emulsion seen two ways. If
// they disagreed about grain size they would render as different
// films, and the difference would look like a modelling choice rather
// than the bug it is.
let px = 6.6;
let g = Grain::for_pixel_size(&portra(), px);
let area = mean_grain_area_um2(&g, px);
// Portra's green layer: ~0.14 um^2, about 370 nm across.
assert!(
(0.10..0.20).contains(&area[1]),
"grain area {} um^2 is not what the counting model calibrated",
area[1]
);
let m = BooleanGrain::new(&g, px, 16);
// And the radius in pixels must match that area at this scale.
let area_px = area[1] / (px * px);
let expect_r = (area_px / std::f32::consts::PI).sqrt();
assert!((m.radius_px[1] - expect_r).abs() < 1e-6);
}
#[test]
fn zooming_in_makes_the_grains_bigger_in_pixels() {
// Resolution independence, which is the paper's headline claim and the
// thing the counting model can only approximate: a grain is a fixed
// size *on the film*, so looking closer must resolve it, not merely
// reduce the variance.
let close = model(2.0);
let far = model(12.0);
assert!(
close.radius_px[1] > far.radius_px[1] * 3.0,
"close {} far {}",
close.radius_px[1],
far.radius_px[1]
);
}
#[test]
fn a_finer_stock_has_smaller_grains() {
let mut fine = portra();
let mut coarse = portra();
fine.rms_granularity = [4.0; 3];
coarse.rms_granularity = [16.0; 3];
let f = BooleanGrain::new(&Grain::for_pixel_size(&fine, 6.6), 6.6, 16);
let c = BooleanGrain::new(&Grain::for_pixel_size(&coarse, 6.6), 6.6, 16);
assert!(
c.radius_px[1] > f.radius_px[1],
"coarse {} is not larger than fine {}",
c.radius_px[1],
f.radius_px[1]
);
}
#[test]
fn export_samples_more_than_preview() {
assert!(
BooleanGrain::samples_for(Quality::Export)
> BooleanGrain::samples_for(Quality::Preview)
);
}
}
+93 -6
View File
@@ -76,13 +76,65 @@ const RMS_APERTURE_AREA_UM2: f32 = std::f32::consts::PI * 24.0 * 24.0;
/// The net density the granularity figure is quoted at.
const RMS_REFERENCE_NET_DENSITY: f32 = 1.0;
/// A 35 mm frame's width, in micrometres.
/// TRACES: FR-DEV-3f
/// The frame a photograph is being simulated on.
///
/// What turns a pixel count into a grain size. A photograph has no inherent
/// film format, so simulating one means choosing what the frame *would have
/// been*; 35 mm is the choice that makes the numbers mean what a photographer
/// expects, since published granularity and every intuition about how grainy a
/// stock looks come from 35 mm.
/// **Grain is a function of enlargement, and this is the half of it the
/// photograph cannot supply.** A crystal is a fixed size in micrometres, so
/// how grainy a picture looks depends entirely on how much the frame was
/// magnified to make it — and that is film size against output size.
///
/// The same emulsion on 4x5 packs about 3,800 crystals into the pixel that
/// holds 300 on 35 mm, so it renders roughly 3.5 times smoother at the same
/// output size. Treating everything as 35 mm, as this did, made every
/// photograph as grainy as the smallest common format.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Mm35,
Format645,
Format6x6,
Format6x7,
Sheet4x5,
Sheet8x10,
}
impl Format {
/// The formats, in the order the picker offers them.
///
/// Smallest first, so index zero is 35 mm — the commonest frame, and the
/// one whose grain every published figure and every photographer's
/// intuition is calibrated against.
pub const ALL: [Format; 6] = [
Format::Mm35,
Format::Format645,
Format::Format6x6,
Format::Format6x7,
Format::Sheet4x5,
Format::Sheet8x10,
];
/// The frame's width in micrometres — the *image* area, not the sheet.
pub fn width_um(self) -> f32 {
match self {
Format::Mm35 => 36_000.0,
// 6x4.5 and 6x6 share a 56 mm gate; only the other axis differs,
// and grain scales with the linear magnification of the axis being
// enlarged.
Format::Format645 | Format::Format6x6 => 56_000.0,
Format::Format6x7 => 70_000.0,
// The image area of a sheet, which is smaller than the nominal
// inches: a "4x5" exposes about 121 x 97 mm.
Format::Sheet4x5 => 121_000.0,
Format::Sheet8x10 => 248_000.0,
}
}
pub fn from_index(i: usize) -> Self {
Self::ALL.get(i).copied().unwrap_or(Format::Mm35)
}
}
/// A 35 mm frame's width, in micrometres. The default format.
pub const FRAME_WIDTH_UM: f32 = 36_000.0;
/// TRACES: FR-DEV-3f
@@ -245,6 +297,41 @@ mod tests {
);
}
#[test]
fn a_larger_format_is_less_grainy_at_the_same_output_size() {
// TRACES: FR-DEV-3f
// The point of the whole control, and a fact about photography rather
// than about this code: enlarge 35 mm and 4x5 to the same print and the
// sheet is visibly smoother, because each of its pixels averages far
// more crystals. Treating every frame as 35 mm made a large-format
// photograph as grainy as a small one.
let p = portra();
let out_px = 5472.0;
let small = Grain::for_pixel_size(&p, Format::Mm35.width_um() / out_px);
let large = Grain::for_pixel_size(&p, Format::Sheet4x5.width_um() / out_px);
let d = small.density_max[1] * 0.5;
let ratio = small.sigma(1, d) / large.sigma(1, d);
// Linear magnification is 121/36, so the crystal count per pixel goes
// as its square and sigma as its reciprocal: about 3.4x.
assert!(
(2.5..4.5).contains(&ratio),
"35mm is {ratio:.2}x grainier than 4x5, which is not the enlargement"
);
}
#[test]
fn the_formats_are_ordered_smallest_first() {
// Index zero has to be the neutral choice — 35 mm, which is what every
// published granularity figure is calibrated against.
let widths: Vec<f32> = Format::ALL.iter().map(|f| f.width_um()).collect();
assert_eq!(widths[0], FRAME_WIDTH_UM);
assert!(
widths.windows(2).all(|w| w[0] <= w[1]),
"formats are not ordered by size: {widths:?}"
);
}
#[test]
fn a_pixel_never_holds_less_than_one_grain() {
// Past this the model describes a pixel smaller than a crystal, where
+3 -1
View File
@@ -38,6 +38,7 @@
//! auditable. The sRGB reflectance basis is Mallett & Yuksel (2019).
pub mod bake;
pub mod boolean_grain;
mod built_in;
pub mod grain;
pub mod profile;
@@ -45,7 +46,8 @@ pub mod spectrum;
pub mod tables;
pub use bake::{bake, Baked, Recipe};
pub use grain::Grain;
pub use boolean_grain::BooleanGrain;
pub use grain::{Format, Grain};
pub use profile::{Kind, Profile, Stage, Support};
use built_in::BUILT_IN;
+50 -6
View File
@@ -30,6 +30,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, Subj
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, EditGraph, Framing};
use dr_segment::{SemanticModel, SemanticOptions, Shaped};
use dr_types::ColourSpace;
@@ -55,7 +56,13 @@ fn main() {
// ---- the photograph ---------------------------------------------------
let bytes = std::fs::read(&path).expect("read file");
let raw = dr_decode::decode(&bytes).expect("decode");
// The tag, because the model reads photographs and the sensor stores
// scanlines. See `stand_up` below: this example exists to be the shipping
// path with pictures attached, so it has to make the same turn the
// develop session makes.
let orientation = dr_decode::orientation(&bytes).unwrap_or_default();
println!("source {} × {}", raw.crop.width, raw.crop.height);
println!("turns {}", orientation.quarter_turns);
let source = Demosaicer::new(&ctx)
.expect("demosaicer")
.run(&raw)
@@ -93,10 +100,16 @@ fn main() {
.collect();
// ---- find the subject -------------------------------------------------
//
// Stood up first. A model trained on upright photographs is very bad at
// sideways ones, and the proxy above is in the sensor's own orientation
// — see `stand_up`.
let (upright, uw, uh) = stand_up(&rgb, pw as usize, ph as usize, orientation);
let t = std::time::Instant::now();
let mut model = SemanticModel::embedded().expect("model");
let instances = model
.detect(&rgb, pw as usize, ph as usize, &SemanticOptions::default())
.detect(&upright, uw, uh, &SemanticOptions::default())
.expect("detect");
println!(
"detect {} found in {:.0} ms",
@@ -118,10 +131,11 @@ fn main() {
subject.class_name, subject.score
);
// Quantised exactly as the develop session does, so this example exercises
// the shipping path rather than a shortcut around it.
let alpha: Vec<u8> = subject
.mask
// Laid back down, then quantised exactly as the develop session does, so
// this example exercises the shipping path rather than a shortcut around
// it. The mask has to end up in *source* space: the composed shader
// samples the mask array after the framing map.
let alpha: Vec<u8> = lay_down(&subject.mask, uw, uh, orientation)
.iter()
.map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
.collect();
@@ -315,12 +329,42 @@ fn render_stack(
.render(stack, None, Some(&subjects), pw, ph)
.expect("rasterise masks");
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
);
adjust
.render_masked(source, &shader, ow, oh, Some(array))
.expect("render");
}
/// Turn the proxy the way the photographer is looking at it, so the model
/// reads a photograph rather than a scanline order — and turn the mask it
/// answers with back again, because the composed shader samples masks in
/// source space, after the framing map.
///
/// Both are `dr_types::Orientation`, which is the one place the permutation
/// is written: the grid's thumbnails, the develop session's segmentation and
/// this example all go through it, so "upright" means one thing across the
/// application. A local copy here would be a fourth opinion, and this example
/// exists to be the shipping path rather than an imitation of it.
fn stand_up(
rgb: &[f32],
width: usize,
height: usize,
o: dr_types::Orientation,
) -> (Vec<f32>, usize, usize) {
let (out, w, h) = o.into_shown(rgb, width as u32, height as u32, 3);
(out, w as usize, h as usize)
}
fn lay_down(mask: &[f32], dw: usize, dh: usize, o: dr_types::Orientation) -> Vec<f32> {
o.into_stored(mask, dw as u32, dh as u32, 1).0
}
fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) {
let s = (longest as f32 / w.max(h) as f32).min(1.0);
(
+2 -4
View File
@@ -1367,8 +1367,7 @@ mod tests {
// instead of the kernel under test. The chain still
// carries the resolve pass that finishes the render, and
// that generated source is worth compiling too.
let scale = g.render_scale(img.size(), (16, 16));
let detail = g.compose_detail(scale);
let detail = g.compose_detail(img.size(), (16, 16));
let key = g.invalidation().through(dr_pipeline::Affects::Colour);
pass.render_detailed(&img, &shader, 16, 16, None, &detail, key)
.unwrap_or_else(|e| {
@@ -1774,8 +1773,7 @@ mod tests {
// find those operations in neither stage and fail for a reason that is
// not a defect. Shadows the smaller size deliberately.
let (w, h) = g.output_size(512, 512);
let scale = g.render_scale((512, 512), (w, h));
let detail = g.compose_detail_for(scale, dr_types::ColourSpace::Srgb);
let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb);
assert!(
!detail.is_empty(),
"the detail half composed nothing, so nothing of it was compiled"
+63
View File
@@ -149,6 +149,8 @@ pub(crate) struct DetailRunner {
/// Compiled pipelines by pass structure hash.
cache: HashMap<u64, wgpu::ComputePipeline>,
pool: Intermediates,
/// See [`placeholder_instances`].
no_instances: wgpu::Buffer,
}
struct Layout {
@@ -156,6 +158,22 @@ struct Layout {
pipeline: wgpu::PipelineLayout,
}
/// What binding 3 holds for a pass that declared no instance list.
///
/// One zeroed element, allocated once. Zero-length storage buffers cannot be
/// bound, and the passes that read this binding are exactly the ones that
/// uploaded something of their own, so nothing ever reads the placeholder's
/// contents — it exists to keep one bind group layout serving both kinds of
/// pass.
fn placeholder_instances(ctx: &GpuContext) -> wgpu::Buffer {
ctx.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("detail-instances-placeholder"),
contents: bytemuck::cast_slice(&[[0.0f32; 4]]),
usage: wgpu::BufferUsages::STORAGE,
})
}
impl DetailRunner {
pub(crate) fn new(ctx: &GpuContext) -> Self {
Self {
@@ -164,6 +182,7 @@ impl DetailRunner {
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
cache: HashMap::new(),
pool: Intermediates::new(),
no_instances: placeholder_instances(ctx),
}
}
@@ -230,6 +249,24 @@ impl DetailRunner {
usage: wgpu::BufferUsages::UNIFORM,
});
// TRACES: FR-DEV-8
// The instance list, uploaded only by the passes that have one. A
// kernel pass — which is every pass that is a convolution — is
// handed the placeholder allocated once in `new`, because a storage
// buffer of length zero is not bindable and allocating a fresh
// sixteen bytes per pass per frame is the per-frame allocation this
// module's documentation exists to refuse.
let instances = (!pass.storage.is_empty()).then(|| {
self.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("detail-instances"),
contents: bytemuck::cast_slice(pass.storage.as_slice()),
usage: wgpu::BufferUsages::STORAGE,
})
});
let instances = instances.as_ref().unwrap_or(&self.no_instances);
let bind_group = self
.ctx
.device
@@ -249,6 +286,10 @@ impl DetailRunner {
binding: 2,
resource: wgpu::BindingResource::TextureView(destination),
},
wgpu::BindGroupEntry {
binding: 3,
resource: instances.as_entire_binding(),
},
],
});
@@ -338,6 +379,20 @@ impl DetailRunner {
}
}
/// A read-only storage buffer entry, as `mask.rs` declares its strokes.
fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
impl Layout {
fn new(ctx: &GpuContext, format: wgpu::TextureFormat, label: &str) -> Self {
let bind_group = ctx
@@ -376,6 +431,14 @@ impl Layout {
},
count: None,
},
// TRACES: FR-DEV-8
// The instance list, for a pass whose work is a list rather
// than a kernel (`DetailPass::storage`). Every other pass
// gets `Intermediates`' placeholder here — one entry on both
// layouts rather than two more layouts, since a convolution
// that never reads the buffer costs nothing for it being
// bound.
storage_entry(3),
],
});
+2 -1
View File
@@ -87,7 +87,8 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
+169
View File
@@ -0,0 +1,169 @@
//! TRACES: FR-DEV-8
//! The instance binding: a detail pass whose work is a list, not a kernel.
//!
//! Spot removal needs a detail pass to read a variable number of records —
//! sixty-four repairs and one repair are the same shader with a different
//! buffer behind it. That is binding 3, and this file proves the three things
//! about it that a picture would not tell you clearly:
//!
//! - the data uploaded is the data the shader reads, in order;
//! - a pass that declares no list still runs, bound to the placeholder;
//! - the same shader with a *different* list does not recompile, which is what
//! keeps placing a spot as cheap as moving a slider.
//!
//! The passes here are synthetic on purpose. `spot_removal.rs` asserts the
//! repair; this asserts the plumbing, so a failure in one does not have to be
//! read to work out which of the two broke.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::detail::{ComposedDetail, ComposedDetailPass};
use dr_pipeline::{Affects, EditGraph};
use dr_types::ColourSpace;
const SIZE: u32 = 8;
fn ctx() -> Option<GpuContext> {
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// A flat mid-grey frame, so anything the pass adds is the whole answer.
fn grey(ctx: &GpuContext) -> DemosaicedImage {
let data: Vec<u8> = (0..SIZE * SIZE).flat_map(|_| [0u8, 0, 0, 255]).collect();
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
}
/// A pass that sums the instance list into the red channel and writes the
/// output. Deliberately trivial: the value on screen is then a direct readout
/// of what arrived in the buffer.
fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass {
let source = "
@group(0) @binding(0) var source: texture_2d<f32>;
struct Params { detail_base: vec4<f32> }
@group(0) @binding(1) var<uniform> u: Params;
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = textureDimensions(output);
if (gid.x >= dims.x || gid.y >= dims.y) { return; }
// Weighted by index, so a buffer read back to front fails this rather
// than passing by symmetry.
var total = 0.0;
let n = arrayLength(&instances);
for (var i = 0u; i < n; i = i + 1u) {
total = total + instances[i].x * f32(i + 1u);
}
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) / 255.0, 0.0, 1.0));
}
"
.to_string();
ComposedDetailPass {
label: "test/instances".to_string(),
source,
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
storage,
radius: 0,
writes_output: true,
// Any distinct number: the hash is a cache key, and these tests are
// what decide whether two chains share a pipeline.
structure_hash: structure,
}
}
fn render(pass: &mut AdjustPass, source: &DemosaicedImage, chain: &ComposedDetail) -> Vec<u8> {
// The fused half has to be composed knowing a detail stage follows it, or
// it encodes its own output and the chain would quantise twice — a mismatch
// `render_detailed` refuses outright. The probe is the graph that says so;
// its own passes are not used, since the chain here is hand-built.
let mut graph = EditGraph::with_detail_probe();
graph.set_param(
dr_pipeline::descriptor::OpId("detail_probe"),
dr_pipeline::descriptor::ParamId("radius"),
0.05,
);
let shader = graph.compose_for(ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, SIZE, SIZE, None, chain, key)
.expect("render");
pass.export_pixels().expect("readback").0
}
/// The list arrives whole, in order, and the shader can tell how long it is.
#[test]
fn a_pass_reads_the_list_it_was_given() {
let Some(ctx) = ctx() else { return };
let source = grey(&ctx);
let mut pass = AdjustPass::new(&ctx);
// 0.1·1 + 0.2·2 + 0.3·3 = 1.4, which clips to 1.0 — so instead: values
// chosen to land at a quarter, unambiguously distinguishable from both the
// "read nothing" answer of 0 and the "read them unweighted" answer of 0.15.
let chain = ComposedDetail {
passes: vec![summing_pass(
vec![[0.05, 0.0, 0.0, 0.0], [0.1, 0.0, 0.0, 0.0]],
1,
)],
};
let pixels = render(&mut pass, &source, &chain);
let (red, green) = (pixels[0], pixels[1]);
// 0.05·1 + 0.1·2 = 0.25, written straight to an rgba8 target.
assert!(
red.abs_diff((0.25 * 255.0) as u8) <= 1,
"the shader summed {red}, not the list it was handed"
);
assert_eq!(green, 2, "arrayLength saw both entries");
}
/// A convolution declares no list and must still run: it is bound to the
/// placeholder rather than to nothing, because a zero-length storage buffer
/// cannot be bound at all and a second bind group layout for the difference
/// would be two layouts to keep in step.
#[test]
fn a_pass_with_no_list_still_runs() {
let Some(ctx) = ctx() else { return };
let source = grey(&ctx);
let mut pass = AdjustPass::new(&ctx);
let chain = ComposedDetail {
passes: vec![summing_pass(Vec::new(), 2)],
};
let pixels = render(&mut pass, &source, &chain);
assert_eq!(pixels[0], 0, "the placeholder is zeroed");
assert_eq!(pixels[1], 1, "and is exactly one element long");
}
/// The property that makes placing the tenth spot as cheap as moving a slider:
/// the list is in the buffer, not in the source, so the pipeline is compiled
/// once however many entries arrive.
#[test]
fn changing_the_list_does_not_recompile() {
let Some(ctx) = ctx() else { return };
let source = grey(&ctx);
let mut pass = AdjustPass::new(&ctx);
for count in 1..=6 {
let list = (0..count).map(|_| [0.01, 0.0, 0.0, 0.0]).collect();
let chain = ComposedDetail {
passes: vec![summing_pass(list, 3)],
};
render(&mut pass, &source, &chain);
}
assert_eq!(
pass.cached_detail_pipelines(),
1,
"six different lists, one compiled pipeline"
);
}
+4 -2
View File
@@ -101,7 +101,8 @@ fn render(
let _ = ctx;
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
@@ -402,7 +403,8 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
assert!(detail.is_empty());
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
+8 -1
View File
@@ -14,6 +14,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, EditGraph, Framing};
use dr_types::ColourSpace;
@@ -65,7 +66,13 @@ fn render_at(
h: u32,
) -> Vec<u8> {
let source = grey_at(ctx, w, h);
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, field, None, w, h).expect("rasterise");
+4 -2
View File
@@ -108,7 +108,8 @@ fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
@@ -596,7 +597,8 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() {
// description of "a two-pixel surface structure is not present in a
// 128-pixel rendering".
let scale = graph.render_scale(source.size(), (128, 128));
let composed = graph.compose_detail_for(scale, ColourSpace::Srgb);
let composed =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
assert_eq!(
composed.len(),
1,
+22 -3
View File
@@ -14,6 +14,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass, SubjectMasks};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, Framing};
use dr_segment::Shaped;
use dr_types::ColourSpace;
@@ -83,7 +84,13 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
.render(stack, None, Some(&subjects), PROXY, PROXY)
.expect("rasterise");
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
);
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, out, out, Some(array))
@@ -95,7 +102,13 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
/// thumbnail were doing.
fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
let source = grey(ctx, 64);
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
);
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, out, out).expect("render");
adjust.export_pixels().expect("readback").0
@@ -211,7 +224,13 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, None, None, w, h).expect("rasterise");
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
);
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, w, h, Some(array))
+2 -1
View File
@@ -73,7 +73,8 @@ fn render_at(
scale: RenderScale,
) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
.expect("render");
+389
View File
@@ -0,0 +1,389 @@
//! TRACES: FR-DEV-8
//! Repairs, drawn on a real device.
//!
//! `dr-pipeline`'s tests assert the model and the record packing; nothing there
//! can say whether the disc lands where the photographer put it. That is what
//! this file is for, and the cases it covers are the ones where a repair goes
//! wrong *quietly*:
//!
//! - the mark is still there, because the disc landed beside it;
//! - the repair works on screen and not in the export, because a length was
//! converted in the wrong units;
//! - the repair works until the photograph is cropped or rotated, because the
//! framing was applied to the pixels and not to the spot.
//!
//! The frame is a flat grey field with one black mark on it, which makes every
//! assertion here countable: a repair either leaves dark pixels or it does not.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::spot::{Spot, SpotMode};
use dr_pipeline::{Affects, EditGraph};
use dr_types::ColourSpace;
const SIZE: u32 = 128;
const GREY: u8 = 128;
/// Where the mark is, in normalised coordinates, and how big it is in frame
/// units. Off-centre on both axes so that a repair landing on a mirrored or
/// transposed position fails rather than passing by symmetry.
const MARK: (f32, f32) = (0.3, 0.65);
const MARK_RADIUS: f32 = 0.03;
fn ctx() -> Option<GpuContext> {
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// A flat grey frame with one black mark on it — a dust spot, idealised.
fn marked_frame(ctx: &GpuContext) -> DemosaicedImage {
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
let (x, y) = ((i % SIZE) as f32, (i / SIZE) as f32);
let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32);
let r = MARK_RADIUS * SIZE as f32;
let v = if (x - cx).hypot(y - cy) <= r {
0u8
} else {
GREY
};
[v, v, v, 255]
})
.collect();
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
}
/// A repair covering the mark, reading from clean grey to its right.
///
/// The disc is twice the mark, so the mark sits entirely inside the solid core
/// and none of it falls in the feathered rim — otherwise this file would be
/// asserting a blend rather than a repair.
fn repair(mode: SpotMode) -> Spot {
let mut spot = Spot::new(MARK, (0.3, 0.0), MARK_RADIUS * 2.0);
spot.mode = mode;
spot
}
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let (w, h) = graph.output_size(source.size().0, source.size().1);
let (w, h) = (w.min(out), h.min(out));
let detail = graph.compose_detail_for(source.size(), (w, h), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, w, h, None, &detail, key)
.expect("render");
pass.export_pixels().expect("readback").0
}
/// How many pixels are darker than anything a grey field contains.
///
/// The mark is the only dark thing in the frame, so this counts what is left of
/// it — and counts it wherever it ended up, which is what makes the same
/// assertion work after a crop or a rotation.
fn dark_pixels(pixels: &[u8]) -> usize {
pixels.chunks_exact(4).filter(|p| p[0] < GREY - 24).count()
}
/// The whole feature in one assertion: the mark is there, and then it is not.
#[test]
fn a_clone_removes_the_mark() {
let Some(ctx) = ctx() else { return };
let source = marked_frame(&ctx);
let mut pass = AdjustPass::new(&ctx);
let before = dark_pixels(&render(
&mut pass,
&EditGraph::default_chain(),
&source,
SIZE,
));
assert!(before > 20, "the frame is supposed to have a mark on it");
let mut graph = EditGraph::default_chain();
graph.spots_mut().place(repair(SpotMode::Clone));
let after = dark_pixels(&render(&mut pass, &graph, &source, SIZE));
assert_eq!(after, 0, "{before} dark pixels before, {after} after");
}
/// The grey the repair lays down has to be the *photograph's* grey. A repair
/// that removes the mark by darkening or brightening the disc passes the count
/// above and is still visibly a disc.
#[test]
fn the_patch_is_the_photograph_and_not_an_approximation_of_it() {
let Some(ctx) = ctx() else { return };
let source = marked_frame(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::default_chain();
graph.spots_mut().place(repair(SpotMode::Clone));
let pixels = render(&mut pass, &graph, &source, SIZE);
let centre =
((MARK.1 * SIZE as f32) as u32 * SIZE + (MARK.0 * SIZE as f32) as u32) as usize * 4;
assert!(
pixels[centre].abs_diff(GREY) <= 2,
"the repaired centre reads {}, the field is {GREY}",
pixels[centre]
);
}
/// A repair is stored as a fraction of the frame, so it must land in the same
/// *place* whatever size the frame is drawn at — which is the difference
/// between a preview that tells the truth and an export that does not
/// (FR-DSP-1).
#[test]
fn a_proxy_and_an_export_repair_the_same_thing() {
let Some(ctx) = ctx() else { return };
let source = marked_frame(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::default_chain();
graph.spots_mut().place(repair(SpotMode::Clone));
assert_eq!(dark_pixels(&render(&mut pass, &graph, &source, SIZE)), 0);
assert_eq!(
dark_pixels(&render(&mut pass, &graph, &source, SIZE / 4)),
0,
"the repair missed the mark at a quarter size"
);
}
/// The framing is applied to the spot, not to the pixels afterwards. If the
/// centre were mapped and the offset were not, this is the test that fails: the
/// disc would land on the mark and read from the wrong side of the frame.
#[test]
fn a_rotated_photograph_carries_its_repairs_round_with_it() {
let Some(ctx) = ctx() else { return };
let source = marked_frame(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::default_chain();
graph.spots_mut().place(repair(SpotMode::Clone));
graph.rotate_quarters(1);
assert_eq!(
dark_pixels(&render(&mut pass, &graph, &source, SIZE)),
0,
"the mark came back when the frame was turned"
);
}
/// And a crop, which moves the origin and the scale at once.
#[test]
fn a_crop_carries_its_repairs_with_it() {
let Some(ctx) = ctx() else { return };
let source = marked_frame(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::default_chain();
graph.spots_mut().place(repair(SpotMode::Clone));
graph.set_crop(dr_pipeline::CropRect {
x: 0.1,
y: 0.4,
width: 0.5,
height: 0.5,
});
assert_eq!(
dark_pixels(&render(&mut pass, &graph, &source, SIZE)),
0,
"the mark is inside this crop and the repair no longer covers it"
);
}
/// Opacity is a real control: at zero the repair is off, and the mark is
/// exactly as it was.
#[test]
fn a_transparent_repair_draws_nothing() {
let Some(ctx) = ctx() else { return };
let source = marked_frame(&ctx);
let mut pass = AdjustPass::new(&ctx);
let bare = dark_pixels(&render(
&mut pass,
&EditGraph::default_chain(),
&source,
SIZE,
));
let mut graph = EditGraph::default_chain();
let mut spot = repair(SpotMode::Clone);
spot.set_opacity(0.0);
graph.spots_mut().place(spot);
assert_eq!(dark_pixels(&render(&mut pass, &graph, &source, SIZE)), bare);
}
/// Placing repairs must not recompile: the list is in a storage buffer and the
/// shader never learns how long it is, so a photographer working through a
/// dusty sky pays one compilation.
#[test]
fn placing_repairs_compiles_one_pipeline() {
let Some(ctx) = ctx() else { return };
let source = marked_frame(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::default_chain();
for i in 0..6 {
let y = 0.1 + 0.1 * i as f32;
graph
.spots_mut()
.place(Spot::new((0.5, y), (0.1, 0.0), 0.02));
render(&mut pass, &graph, &source, SIZE);
}
assert_eq!(
pass.cached_detail_pipelines(),
1,
"six repairs, one compiled pipeline"
);
}
// ---------------------------------------------------------------------------
// Heal (FR-DEV-8)
// ---------------------------------------------------------------------------
/// A frame whose brightness ramps across it, with one black mark on it.
///
/// This is the case that separates the two modes. A clone copies a patch from
/// somewhere else on the ramp, so it arrives at the wrong level and leaves a
/// disc of the right texture and the wrong tone; a heal carries the difference
/// across from the boundary and leaves nothing.
fn ramped_frame(ctx: &GpuContext) -> DemosaicedImage {
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
let (x, y) = ((i % SIZE) as f32, (i / SIZE) as f32);
let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32);
let r = MARK_RADIUS * SIZE as f32;
// 64 at the left edge to 192 at the right: a ramp steep enough that
// a clone from a third of a frame away is unmistakably wrong, and
// shallow enough to stay well inside the range.
let ramp = 64.0 + 128.0 * x / SIZE as f32;
let v = if (x - cx).hypot(y - cy) <= r {
0u8
} else {
ramp as u8
};
[v, v, v, 255]
})
.collect();
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
}
/// What the ramp says at a column, as the byte the renderer should produce.
fn ramp_at(x: u32) -> u8 {
(64.0 + 128.0 * x as f32 / SIZE as f32) as u8
}
/// The worst a repair is wrong by, over the disc it covers.
///
/// Measured against the ramp the photograph would have had if the mark had
/// never been there, which is the only definition of "repaired" worth
/// asserting: a repair that removes the mark and leaves the wrong tone has not
/// repaired anything, it has drawn a different mark.
fn worst_error(pixels: &[u8]) -> u8 {
let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32);
let r = MARK_RADIUS * SIZE as f32;
let mut worst = 0u8;
for y in 0..SIZE {
for x in 0..SIZE {
if (x as f32 - cx).hypot(y as f32 - cy) > r {
continue;
}
let got = pixels[((y * SIZE + x) * 4) as usize];
worst = worst.max(got.abs_diff(ramp_at(x)));
}
}
worst
}
/// The measurement the mode exists for, and the one that decides
/// `RIM_SAMPLES`: on a gradient, a heal is right and a clone is not.
#[test]
fn a_heal_takes_the_tone_from_the_hole_it_fills() {
let Some(ctx) = ctx() else { return };
let source = ramped_frame(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut cloned = EditGraph::default_chain();
cloned.spots_mut().place(repair(SpotMode::Clone));
let clone_error = worst_error(&render(&mut pass, &cloned, &source, SIZE));
let mut healed = EditGraph::default_chain();
healed.spots_mut().place(repair(SpotMode::Heal));
let heal_error = worst_error(&render(&mut pass, &healed, &source, SIZE));
// The clone is wrong by roughly the ramp across the source offset — about
// 38 levels here — and it is wrong across the whole disc.
assert!(
clone_error > 20,
"the clone was supposed to be visibly wrong, and is off by {clone_error}"
);
// Measured at 0 on the reference device — the ramp is linear, so the
// boundary difference is constant all the way round and the membrane
// reproduces it exactly. The tolerance is for the rounding a different
// driver may do, not for the method being approximate here.
assert!(
heal_error <= 1,
"the heal is off by {heal_error} levels; the clone it has to beat is off by {clone_error}"
);
}
/// And the repair still has to remove the mark: a membrane that matched the
/// boundary while leaving the black disc underneath would pass the measurement
/// above by averaging its way past it.
#[test]
fn a_heal_removes_the_mark_as_well_as_matching_the_tone() {
let Some(ctx) = ctx() else { return };
let source = ramped_frame(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::default_chain();
graph.spots_mut().place(repair(SpotMode::Heal));
let pixels = render(&mut pass, &graph, &source, SIZE);
let mark = (MARK.0 * SIZE as f32) as u32;
for x in mark - 2..=mark + 2 {
let got = pixels[(((MARK.1 * SIZE as f32) as u32 * SIZE + x) * 4) as usize];
assert!(
got.abs_diff(ramp_at(x)) <= 3,
"column {x} reads {got}, the ramp says {}",
ramp_at(x)
);
}
}
/// A heal on a flat field is a clone: the boundary difference is zero all the
/// way round, so the membrane is zero and neither mode has anything to add.
/// Worth pinning, because a membrane that quietly tinted a flat repair would
/// be invisible in the gradient test above.
#[test]
fn a_heal_on_a_flat_field_changes_nothing_a_clone_would_not() {
let Some(ctx) = ctx() else { return };
let source = marked_frame(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut cloned = EditGraph::default_chain();
cloned.spots_mut().place(repair(SpotMode::Clone));
let a = render(&mut pass, &cloned, &source, SIZE);
let mut healed = EditGraph::default_chain();
healed.spots_mut().place(repair(SpotMode::Heal));
let b = render(&mut pass, &healed, &source, SIZE);
let worst = a
.iter()
.zip(&b)
.map(|(x, y)| x.abs_diff(*y))
.max()
.unwrap_or(0);
assert!(
worst <= 1,
"heal and clone differ by {worst} on a flat field"
);
}
+72
View File
@@ -359,6 +359,29 @@ pub struct DetailPass {
/// Uniform values this pass's body reads.
pub uniforms: Vec<Uniform>,
/// TRACES: FR-DEV-8
/// Per-instance data, for a pass whose work is a *list* rather than a
/// kernel.
///
/// Reaches the body as `instances: array<vec4<f32>>`, with
/// `instance_count` in scope as a `u32`. Empty for every pass that is a
/// convolution, which is every pass that existed before spot removal.
///
/// # Why not the uniform block
///
/// Because the uniform block is fixed by the pass's *structure*, and this
/// is not: sixty-four repairs and one repair are the same shader with a
/// different buffer behind it. Packing the list into uniforms would need a
/// fixed maximum, paid for on every frame whether the photograph carries
/// one spot or none, and it would need the composer to emit `vec4` fields —
/// a WGSL uniform array has a stride of 16 whatever it holds.
///
/// The property that matters more: with the list in storage the generated
/// source does not mention how many there are, so placing the tenth spot
/// uploads 512 bytes and reuses the compiled pipeline, exactly as moving a
/// slider does for the fused pass.
pub storage: Vec<[f32; 4]>,
}
/// TRACES: FR-DEV-3 | FR-DEV-8
@@ -396,6 +419,9 @@ pub struct ComposedDetailPass {
pub source: String,
/// Uniform values in the order the generated struct declares them.
pub uniforms: Vec<f32>,
/// TRACES: FR-DEV-8
/// The instance list, if this pass declared one. See [`DetailPass::storage`].
pub storage: Vec<[f32; 4]>,
/// See [`DetailPass::radius`].
pub radius: u32,
/// Whether this pass writes the display/export texture rather than another
@@ -463,10 +489,45 @@ pub fn compose_detail(
ops: &[Box<dyn Operation>],
scale: RenderScale,
output: ColourSpace,
) -> ComposedDetail {
compose_detail_with(ops, &[], scale, output)
}
/// TRACES: FR-DEV-8
/// The detail stage with a set of repairs ahead of the operations.
///
/// `spots` are already-built passes, from [`crate::SpotSet::passes`], and they
/// go **first** — before sharpening, before noise reduction, before every
/// kernel in `ops`.
///
/// That placement is a decision rather than an ordering convenience. A
/// sharpening kernel reads a neighbourhood, so sharpening a dust mark before
/// removing it smears the mark's edge into pixels the repair's own disc does
/// not cover: what is left afterwards is a faint over-sharpened ring around an
/// otherwise perfect patch, which is exactly the artefact that reads as broken
/// software. Removing the mark first means every later pass sees the
/// photograph the photographer thinks they are sharpening.
///
/// It also means ARCH §5.2's stage list, which draws spot removal after
/// texture and clarity, is not what this does — see `docs/spot-removal.md`
/// §5.1, which is where the disagreement is written down.
pub fn compose_detail_with(
ops: &[Box<dyn Operation>],
spots: &[DetailPass],
scale: RenderScale,
output: ColourSpace,
) -> ComposedDetail {
// Every pass of every active detail operation, flattened, carrying the
// operation it came from for the uniform prefix and the helper set.
let mut planned: Vec<(&'static str, &'static [Helper], DetailPass, usize)> = Vec::new();
for (index, pass) in spots.iter().enumerate() {
planned.push((
crate::spot::SPOT_ID,
crate::spot::SPOT_HELPERS,
pass.clone(),
index,
));
}
for op in ops {
if !op.is_active() {
continue;
@@ -509,6 +570,7 @@ pub fn compose_detail(
radius: 0,
wgsl: String::new(),
uniforms: Vec::new(),
storage: Vec::new(),
},
0,
scale,
@@ -651,6 +713,10 @@ struct Params {{
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var<uniform> u: Params;
@group(0) @binding(2) var output: texture_storage_2d<{store_format}, write>;
// A pass whose work is a list rather than a kernel reads it here; every other
// pass leaves this bound to a single empty element and never looks at it. See
// `DetailPass::storage` for why the list is not in the uniform block.
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
// A neighbour, clamped to the edge of the image.
//
@@ -681,6 +747,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
// What this render is, relative to the export it has to match.
let render_dims = u.detail_base.xy;
let render_scale = u.detail_base.z;
// How many entries `instances` actually holds, read from the buffer itself
// rather than from a uniform so the two cannot disagree. A pass that
// declared no list is bound to a one-element placeholder and never asks.
let instance_count = arrayLength(&instances);
var c = tap(coord, vec2<i32>(0));
// One scalar per pixel that survives the hand-off from one pass to the
@@ -708,6 +778,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
label,
source,
uniforms: uniform_values,
storage: pass.storage.clone(),
radius: pass.radius,
writes_output,
structure_hash,
@@ -793,6 +864,7 @@ mod tests {
&crate::Framing::new(),
dr_types::ColourSpace::Srgb,
&crate::mask::MaskStack::new(),
&crate::spot::SpotSet::new(),
)
}
+2
View File
@@ -141,6 +141,8 @@ impl DetailStage for BoxBlur {
.map(|(axis, _)| DetailPass {
label: if axis == 0 { "horizontal" } else { "vertical" },
radius: r,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![
Uniform {
name: "radius",
+80 -12
View File
@@ -359,6 +359,7 @@ impl Framing {
self.baseline = orientation;
}
/// TRACES: FR-DEV-3 | FR-DEV-3h
/// The baseline and the user's turns and mirrors, collapsed into one.
///
/// Everything that renders or measures the frame goes through here; only
@@ -370,7 +371,16 @@ impl Framing {
/// axes when that turn is odd — which is exactly the case that a naive
/// "add the turns, or the flags" gets wrong, and gets wrong silently,
/// since the result is still a valid-looking orientation.
fn effective(&self) -> (u8, bool, bool) {
///
/// Returned as an [`dr_types::Orientation`] because that is what it *is*
/// — a quarter turn and two mirrors — and because saying so lets a
/// caller outside the render reuse
/// [`dr_types::Orientation::source_pixel`] rather than write the
/// permutation out a second time. `dr-ui`'s segmentation is that caller:
/// the detector has to read the photograph the way the photographer does,
/// and the thumbnail path already turns its pixels with the same
/// function.
pub fn effective_orientation(&self) -> dr_types::Orientation {
let b = self.baseline;
// The user's mirrors, seen from the far side of the baseline's turn.
let (ux, uy) = if b.swaps_axes() {
@@ -378,11 +388,16 @@ impl Framing {
} else {
(self.flip_h, self.flip_v)
};
(
(b.quarter_turns + self.quarter_turns) % 4,
b.flip_h != ux,
b.flip_v != uy,
)
dr_types::Orientation {
quarter_turns: (b.quarter_turns + self.quarter_turns) % 4,
flip_h: b.flip_h != ux,
flip_v: b.flip_v != uy,
}
}
fn effective(&self) -> (u8, bool, bool) {
let o = self.effective_orientation();
(o.quarter_turns, o.flip_h, o.flip_v)
}
/// Whether this stage currently changes the image.
@@ -906,6 +921,61 @@ mod tests {
/// turn combined with a user flip — a phone portrait that the user then
/// mirrors. Naive flag-ORing renders it mirrored about the wrong axis,
/// which still looks like a photograph.
/// TRACES: FR-DEV-3h
/// The render's map and `dr_types::Orientation`'s must be the same map.
///
/// There are two ways to ask "where does this output pixel come from":
/// the shader prologue (via [`Framing::source_at`], its CPU twin), and
/// [`Orientation::source_pixel`], which the grid's thumbnails and the
/// segmentation both go through. They are written independently and they
/// have to agree, or the photograph and everything drawn over it turn
/// different ways.
///
/// A wrong direction is what this catches, and it is worth stating what
/// that looks like: a quarter turn applied backwards is 180° from right,
/// which reads as a deliberate transform rather than as a mistake. Checked
/// over every EXIF tag and every user rotation on top of it, because the
/// composition is where the two could agree singly and disagree together.
#[test]
fn the_render_and_the_orientation_map_agree() {
const SW: u32 = 8;
const SH: u32 = 5;
for tag in 1..=8u16 {
for user_turns in 0..4u8 {
for user_flip_h in [false, true] {
let mut f = Framing::new();
f.set_baseline(Orientation::from_exif(tag));
f.rotate_quarters(i32::from(user_turns));
f.set_param(FLIP_H, f32::from(u8::from(user_flip_h)));
let effective = f.effective_orientation();
let (dw, dh) = effective.oriented_size(SW, SH);
assert_eq!(f.output_size(SW, SH), (dw, dh), "tag {tag}/{user_turns}");
for y in 0..dh {
for x in 0..dw {
let want = effective.source_pixel(x, y, dw, dh);
let out = ((x as f32 + 0.5) / dw as f32, (y as f32 + 0.5) / dh as f32);
let (u, v) = f.source_at(out, SW, SH);
let got = (
(u * SW as f32).floor().clamp(0.0, (SW - 1) as f32) as u32,
(v * SH as f32).floor().clamp(0.0, (SH - 1) as f32) as u32,
);
assert_eq!(
want, got,
"tag {tag}, user {user_turns} turn(s), flip_h {user_flip_h}: \
output ({x},{y}) — orientation says {want:?}, the render says {got:?}"
);
}
}
}
}
}
}
#[test]
fn a_baseline_and_a_user_rotation_compose_into_one_permutation() {
// Non-square and coprime, so no accidental symmetry hides an error.
@@ -931,12 +1001,10 @@ mod tests {
f.set_param(FLIP_H, f32::from(u8::from(user_flip_h)));
f.set_param(FLIP_V, f32::from(u8::from(user_flip_v)));
let (t, fh, fv) = f.effective();
let combined = Orientation {
quarter_turns: t,
flip_h: fh,
flip_v: fv,
};
// The public accessor, not the private tuple: this is
// the permutation `dr-ui` turns the detector's input
// by, so it is the one that has to be right.
let combined = f.effective_orientation();
// The output size the composed transform produces must
// be the one the two stages produce in sequence.
+143 -9
View File
@@ -7,6 +7,8 @@
//! Order is data, not code: operations run in the sequence this holds them,
//! so reordering the pipeline needs no code change.
use std::sync::Arc;
use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
};
@@ -14,6 +16,9 @@ use crate::framing::{CropRect, Framing};
use crate::mask::MaskStack;
use crate::operation::{compose_full, ComposedShader, Operation};
use crate::ops;
use crate::preset::{Preset, Scope};
use crate::spot::SpotSet;
use crate::state::{EditState, FilmRebake, FilmRef};
/// TRACES: FR-DEV-3a
/// What one operation offers, as plain data.
@@ -92,7 +97,7 @@ pub struct EditGraph {
/// layer *contains* a chain of its own. Folding the stack into the global
/// list would make the list recursive and every consumer that walks it
/// have to know that some entries are really sub-graphs.
masks: MaskStack,
masks: Arc<MaskStack>,
/// TRACES: FR-DEV-3f
/// The film stock this edit renders through, if any.
///
@@ -105,6 +110,15 @@ pub struct EditGraph {
/// that render — because they are one fact, and holding them apart is how
/// a sidecar comes to name one stock while the shader draws another.
film: Option<Film>,
/// TRACES: FR-DEV-8
/// The repairs (`docs/spot-removal.md`).
///
/// Apart from `ops` for the third time and the same reason: a spot is not
/// a scalar, and a list of them is not a slider. It sits beside the masks
/// rather than among them because it is not a mask either — a mask says
/// *where* an adjustment applies, and a spot says where a piece of the
/// photograph comes from.
spots: SpotSet,
}
/// TRACES: FR-DEV-3f
@@ -146,8 +160,9 @@ impl EditGraph {
Self {
ops: ops::chain(),
framing: Framing::new(),
masks: MaskStack::new(),
masks: Arc::new(MaskStack::new()),
film: None,
spots: SpotSet::new(),
}
}
@@ -173,13 +188,30 @@ impl EditGraph {
graph
}
/// TRACES: FR-DEV-8
/// The repairs.
pub fn spots(&self) -> &SpotSet {
&self.spots
}
pub fn spots_mut(&mut self) -> &mut SpotSet {
&mut self.spots
}
/// The local adjustment stack.
pub fn masks(&self) -> &MaskStack {
&self.masks
}
/// The stack, to modify.
///
/// Clones on write. The stack is shared with every [`EditState`] snapshot
/// taken since it last changed — the undo stack holds a run of them — so
/// this is where a shared stack becomes this graph's own again. Callers
/// see no difference; what it buys is that recording a slider drag does
/// not deep-copy a painted mask once a frame.
pub fn masks_mut(&mut self) -> &mut MaskStack {
&mut self.masks
Arc::make_mut(&mut self.masks)
}
/// The framing — crop, straighten, rotation and flips.
@@ -310,6 +342,89 @@ impl EditGraph {
self.film.as_ref()
}
/// TRACES: FR-DEV-5 | FR-CAT-8
/// The whole edit, as data — what an undo step and a sidecar are both
/// made of.
///
/// **The pattern below is exhaustive on purpose.** It is the only thing
/// standing between a new kind of graph state and an undo that quietly
/// ignores it, which is exactly how the mask stack came to be missing
/// from the history for as long as it was. Never add `..` to it: a field
/// added to this struct should fail to compile here until somebody has
/// decided whether stepping backwards has to put it back. See
/// [`crate::state`].
pub fn state(&self) -> EditState {
let Self {
// Both reached through `capabilities`, which is the one walk the
// develop panel, the clipboard and the sidecar already make — so
// an operation is undoable by virtue of being in the chain, with
// nothing to register (FR-DEV-3c).
ops: _,
framing: _,
masks,
film,
spots,
} = self;
EditState {
params: Preset::capture(self),
// A refcount bump. See `EditState::masks` for why that matters on
// a path called once a frame.
masks: Arc::clone(masks),
// The names travel; the tables do not. They are derived, and this
// crate cannot rebuild them — hence `FilmRebake`.
film: film.as_ref().map(|f| FilmRef {
stock: f.stock.clone(),
print: f.print.clone(),
}),
spots: spots.clone(),
}
}
/// TRACES: FR-DEV-5 | FR-CAT-8
/// Put `state` back, replacing whatever this graph held.
///
/// A *replacement*, not an overlay: a parameter absent from the state
/// means default, and a state with no mask blocks means an edit with no
/// local adjustments rather than an edit that keeps whatever was on
/// screen. That is the same rule [`Preset::apply`] and
/// [`crate::Version::apply`] keep, and for the same reason — "the
/// photograph is now as it was" is the whole claim the call makes.
///
/// The **viewport survives**, because [`Preset::apply`] preserves it. Zoom
/// says where the user is looking rather than what the picture is, and an
/// undo that refitted the frame would read as having navigated somewhere.
///
/// The pattern below is exhaustive for the reason [`Self::state`]'s is.
pub fn set_state(&mut self, state: &EditState) -> FilmRebake {
let EditState {
params,
masks,
film,
spots,
} = state;
// At full scope. `Scope` is a question about what a paste carries
// *between* photographs; this is one photograph's own edit being put
// back, so there is nothing to leave behind.
params.apply(self, Scope::Everything);
self.masks = Arc::clone(masks);
self.spots = spots.clone();
// Cleared either way, and when a stock is named the caller bakes it.
// Left standing, the tables now in the graph would be the ones baked
// from the film node's *previous* exposure sliders — and those sliders
// were just replaced, so the restored state would render through the
// film of the state it replaced. Clearing is the conservative half of
// that; `Wanted` is the half that gets it back.
self.set_film(None);
match film {
None => FilmRebake::NotNeeded,
Some(want) => FilmRebake::Wanted(want.clone()),
}
}
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
if op == crate::framing::ID {
let Some(desc) = self.framing.descriptor().param(param) else {
@@ -362,7 +477,7 @@ impl EditGraph {
// Masks go too, and this is why `apply` can be a replacement rather
// than an overlay: a sidecar with no mask blocks means an edit with no
// local adjustments, not an edit that keeps whatever was on screen.
self.masks = MaskStack::new();
self.masks = Arc::new(MaskStack::new());
// The film goes too, for the reason the masks do. Restoring it is the
// *caller's* job rather than `Version::apply`'s: a sidecar names a
// stock, and turning a name into tables needs the profile database,
@@ -402,6 +517,7 @@ impl EditGraph {
!self.ops.iter().any(|o| o.is_active())
&& !self.framing.edits_image()
&& self.masks.is_neutral()
&& self.spots.is_neutral()
}
/// Generate the fused shader for the current state, encoded to sRGB.
@@ -421,7 +537,7 @@ impl EditGraph {
/// graph renders to the screen and to a file in the same breath, and the
/// two want different answers.
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
compose_full(&self.ops, &self.framing, output, &self.masks)
compose_full(&self.ops, &self.framing, output, &self.masks, &self.spots)
}
/// TRACES: FR-DSP-1
@@ -462,9 +578,10 @@ impl EditGraph {
/// single encoded dispatch it always has.
pub fn compose_detail(
&self,
scale: crate::detail::RenderScale,
source: (u32, u32),
render: (u32, u32),
) -> crate::detail::ComposedDetail {
self.compose_detail_for(scale, dr_types::ColourSpace::Srgb)
self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb)
}
/// TRACES: FR-EXP-2
@@ -475,12 +592,21 @@ impl EditGraph {
/// transform — the fused pass stops at linear working values. Composing
/// the two halves for different spaces would encode the edit twice, or
/// not at all.
/// `source` is the demosaiced image's size and `render` the size being
/// drawn. The scale is worked out here rather than handed in, because the
/// repairs need the *source* size as well — a spot is stored in normalised
/// source coordinates and has to be put through the framing to find out
/// where it lands on this render, and a [`crate::detail::RenderScale`]
/// describes the region on screen rather than the photograph.
pub fn compose_detail_for(
&self,
scale: crate::detail::RenderScale,
source: (u32, u32),
render: (u32, u32),
output: dr_types::ColourSpace,
) -> crate::detail::ComposedDetail {
crate::detail::compose_detail(&self.ops, scale, output)
let scale = self.render_scale(source, render);
let spots = self.spots.passes(&self.framing, source, scale);
crate::detail::compose_detail_with(&self.ops, &spots, scale, output)
}
/// TRACES: FR-DEV-3d
@@ -552,6 +678,14 @@ impl EditGraph {
}
}
// TRACES: FR-DEV-8
// The repairs belong to the detail stage, because that is where they
// run. Moving a spot therefore re-runs the neighbourhood passes and
// leaves the fused colour dispatch and the demosaic alone, which is
// the difference between a spot that follows the finger and one that
// stutters (FR-DEV-3d).
detail = self.spots.hash(detail);
crate::Invalidation::new(geometry, colour, detail)
}
}
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -42,6 +42,8 @@ pub mod operation;
pub mod ops;
pub mod preset;
pub mod sidecar;
pub mod spot;
pub mod state;
pub use descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
@@ -52,7 +54,7 @@ pub use detail::{
};
pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability};
pub use history::{Edit, History};
pub use history::{Edit, Entry as HistoryEntry, History, Step};
pub use lens::{compose_warps, ComposedWarp, Warp};
pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
@@ -60,6 +62,8 @@ pub use operation::{
};
pub use preset::{Preset, Scope};
pub use sidecar::{Sidecar, Version};
pub use spot::{Spot, SpotMode, SpotSet};
pub use state::{EditState, FilmRebake, FilmRef};
#[cfg(test)]
mod tests {
@@ -150,8 +154,7 @@ mod tests {
// source pixels, so on a proxy it may honestly decline to draw at all
// (`RenderScale::resolves`) — which would put it in neither half and
// make the assertion fail for a reason that is not a defect.
let scale = g.render_scale((4000, 3000), (4000, 3000));
let detail = g.compose_detail(scale);
let detail = g.compose_detail((4000, 3000), (4000, 3000));
let mut fused_blocks = 0;
for desc in g.descriptors() {
let id = desc.id.0;
+20 -6
View File
@@ -467,7 +467,13 @@ pub fn compose_with_framing(
framing: &Framing,
output: ColourSpace,
) -> ComposedShader {
compose_full(ops, framing, output, &MaskStack::new())
compose_full(
ops,
framing,
output,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
)
}
/// TRACES: FR-DEV-3
@@ -487,6 +493,7 @@ pub fn compose_full(
framing: &Framing,
output: ColourSpace,
masks: &MaskStack,
spots: &crate::spot::SpotSet,
) -> ComposedShader {
// Active *point* operations. A neighbourhood operation is filtered out
// here rather than asked for a fragment it cannot write: it reads pixels
@@ -506,11 +513,18 @@ pub fn compose_full(
// than from a flag the caller sets, because a caller that got the flag
// wrong would produce a shader whose storage format does not match the
// texture bound to it.
let output_mode = if ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
OutputMode::LinearWorking
} else {
OutputMode::Encoded
};
//
// TRACES: FR-DEV-8
// The repairs count too, and they are the reason this takes a spot set at
// all: a photograph with a spot on it and no sharpening still has a detail
// stage, and a fused pass that encoded its own output there would quantise
// twice and be bound to a texture of the wrong format.
let output_mode =
if ops.iter().any(|o| o.is_active() && o.detail().is_some()) || !spots.is_neutral() {
OutputMode::LinearWorking
} else {
OutputMode::Encoded
};
// Whether an operation has taken over the rendering. Decided from the
// operations for the same reason `output_mode` is: a caller that got it
+8 -1
View File
@@ -358,6 +358,8 @@ impl DetailStage for CaptureSharpen {
.map(|label| DetailPass {
label,
radius: extent,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![
Uniform {
// −100…100 as a gain around zero. A hundred percent is
@@ -410,6 +412,8 @@ fn nothing_to_sharpen() -> DetailPass {
label: "unresolved",
// Reads only the pixel it writes, so a tile needs no halo at all.
radius: 0,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: Vec::new(),
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail
// it would act on is not in this texture — it was lost to the downscale
@@ -537,7 +541,10 @@ mod tests {
}
fn chain_at(graph: &EditGraph, scale: RenderScale) -> crate::detail::ComposedDetail {
graph.compose_detail_for(scale, ColourSpace::Srgb)
// The scale is what these tests vary, so it is rebuilt into the two
// sizes it stands for rather than handed over: a render of the full
// frame at `render_size`, from a source of `full_size`.
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb)
}
#[test]
+28
View File
@@ -37,6 +37,23 @@ pub const ID: OpId = OpId("film_sim");
pub const EXPOSURE: ParamId = ParamId("exposure");
pub const PRINT_EXPOSURE: ParamId = ParamId("print_exposure");
pub const PUSH: ParamId = ParamId("push");
pub const FORMAT: ParamId = ParamId("format");
/// TRACES: FR-DEV-3f
/// The frames a photograph can be simulated on, smallest first.
///
/// A genuinely fixed list, unlike the stocks: nobody invents a film format, so
/// this is a declared `enum` parameter and gets its control, its place in the
/// sidecar and its undo step for free. The *sizes* live in `dr_film::Format`;
/// this crate carries only the names, in the same order.
static FORMATS: [LocalizedKey; 6] = [
LocalizedKey("param.film_sim.format.35mm"),
LocalizedKey("param.film_sim.format.645"),
LocalizedKey("param.film_sim.format.6x6"),
LocalizedKey("param.film_sim.format.6x7"),
LocalizedKey("param.film_sim.format.4x5"),
LocalizedKey("param.film_sim.format.8x10"),
];
/// How many samples a characteristic curve carries.
///
@@ -70,6 +87,13 @@ static DESCRIPTOR: OpDescriptor = OpDescriptor {
// actually published: Double-X's measured axis spans about -1 to +2,
// and beyond a range like that a curve would have to be invented.
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0),
// TRACES: FR-DEV-3f
// Which frame this was taken on — the half of the enlargement a
// photograph cannot supply. A crystal is a fixed size in micrometres,
// so how grainy a picture looks is film size against output size, and
// the same emulsion on 4x5 renders about three times smoother than on
// 35mm at the same print.
ParamDescriptor::choice("format", "param.film_sim.format", &FORMATS),
],
};
@@ -129,6 +153,8 @@ pub struct FilmSim {
exposure: f32,
print_exposure: f32,
push: f32,
/// Index into `FORMATS`. Zero is 35 mm, which is the neutral choice.
format: f32,
tables: Option<FilmTables>,
}
@@ -173,6 +199,7 @@ impl Operation for FilmSim {
EXPOSURE => self.exposure = value,
PRINT_EXPOSURE => self.print_exposure = value,
PUSH => self.push = value,
FORMAT => self.format = value,
_ => log::warn!("film_sim: unknown parameter {id}"),
}
}
@@ -182,6 +209,7 @@ impl Operation for FilmSim {
EXPOSURE => self.exposure,
PRINT_EXPOSURE => self.print_exposure,
PUSH => self.push,
FORMAT => self.format,
_ => 0.0,
}
}
@@ -475,12 +475,16 @@ impl<B: Band> DetailStage for LocalContrast<B> {
DetailPass {
label: "base",
radius,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: shape,
wgsl: BASE_X.to_string(),
},
DetailPass {
label: "combine",
radius,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: combine,
wgsl: combine_body(B::RECIPE.midtone_taper),
},
@@ -436,6 +436,8 @@ impl DetailStage for NoiseReduction {
passes.push(DetailPass {
label: "luminance",
radius: luma,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![
Uniform {
name: "radius",
@@ -474,6 +476,8 @@ impl DetailStage for NoiseReduction {
"chroma-vertical"
},
radius: chroma,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![
Uniform {
name: "radius",
+367 -66
View File
@@ -68,7 +68,9 @@ use std::fmt::Write as _;
use crate::graph::EditGraph;
use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER};
use crate::preset::{resolve, Preset};
use crate::preset::Preset;
use crate::spot::{Spot, SpotMode, SpotSet};
use crate::state::{EditState, FilmRebake};
/// Format version of the document itself.
///
@@ -108,14 +110,10 @@ pub struct Sidecar {
/// TRACES: FR-DEV-3f
/// The stock and paper a version names, without the tables they bake to.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FilmRef {
pub stock: String,
/// The paper, if the negative is printed. Absent means the film is viewed
/// as it comes — which for a colour negative is the scan, orange and
/// inverted, and is a legitimate thing to ask for.
pub print: Option<String>,
}
///
/// Re-exported rather than defined here: a sidecar is one of the things an
/// edit is written to, not where an edit is defined. See [`crate::state`].
pub use crate::state::FilmRef;
/// TRACES: FR-CAT-12 | FR-NC-8
/// One named edit variant.
@@ -182,6 +180,15 @@ pub struct Version {
/// database, which this crate does not link, so [`Self::apply`] leaves the
/// graph's film cleared and the caller re-bakes — see `EditGraph::set_film`.
pub film: Option<FilmRef>,
/// TRACES: FR-DEV-8 | FR-NC-9
/// The repairs (`docs/spot-removal.md`).
///
/// A line per spot, keyed `spot.<id>`, rather than a block per spot as a
/// mask gets: a spot is eight numbers, and sixty-four blocks would bury the
/// rest of the file. A line *per spot* rather than one line for the set,
/// because the line is the unit of merge and of a readable diff — the same
/// reasoning [`write_strokes`] gives for a line per stroke.
pub spots: SpotSet,
/// Keys this build did not recognise, kept verbatim.
///
/// An operation this build lacks would otherwise be deleted the moment an
@@ -191,8 +198,22 @@ pub struct Version {
}
impl Version {
/// A new version holding the non-default parameters of `graph`.
/// A new version holding everything `graph`'s edit consists of.
///
/// The destructuring is exhaustive on purpose — see [`crate::state`]. A
/// new part of an edit must not reach the file only by somebody
/// remembering to add a line here, which is how [`Self::update`] came to
/// write the masks and forget the film.
pub fn from_graph(uuid: impl Into<String>, name: impl Into<String>, graph: &EditGraph) -> Self {
let EditState {
params,
masks,
film,
spots,
} = graph.state();
let params = params.into_params();
let masks = (*masks).clone();
Self {
uuid: uuid.into(),
name: name.into(),
@@ -206,40 +227,40 @@ impl Version {
// it in the "not yet looked at" state a cull resumes from.
rating: 0,
flag: 0,
params: capture(graph),
masks: graph.masks().clone(),
film: graph.film().map(|f| FilmRef {
stock: f.stock.clone(),
print: f.print.clone(),
}),
params,
masks,
film,
spots,
unknown: BTreeMap::new(),
}
}
/// Apply this version's parameters to a graph.
/// Apply this version's edit to a graph, returning the film it still owes.
///
/// The graph is reset first, so loading is a *replacement* rather than an
/// overlay: a parameter absent from the file means default, and would
/// otherwise silently inherit whatever the graph happened to hold.
/// Loading is a *replacement* rather than an overlay: a parameter absent
/// from the file means default, and would otherwise silently inherit
/// whatever the graph happened to hold.
///
/// Unknown operations and parameters are skipped with a warning by
/// [`EditGraph::set_param`], and values are clamped there, so a corrupt
/// or newer file cannot reach a shader.
pub fn apply(&self, graph: &mut EditGraph) {
///
/// The [`FilmRebake`] is not a new obligation — restoring a stock always
/// needed the profile database this crate does not link (ARCH §6.5a), and
/// callers were already doing it from a comment. It is the same debt made
/// impossible to walk past.
pub fn apply(&self, graph: &mut EditGraph) -> FilmRebake {
// Reset first for the *viewport's* sake, and only that: `set_state`
// deliberately preserves the view so an undo does not read as
// navigation, whereas opening a photograph should show it fitted
// rather than at the zoom the previous one was inspected at.
graph.reset();
for ((op, param), value) in &self.params {
// `OpId` and `ParamId` hold `&'static str` because descriptors
// are statics, and a sidecar's strings are not. `resolve` matches
// the file's names against the descriptors and hands back the
// static ids, so no string read from disk is ever leaked to get
// a lifetime it did not earn.
let Some((op, param)) = resolve(graph, op, param) else {
log::warn!("sidecar: unknown parameter {op}.{param}; ignoring");
continue;
};
graph.set_param(op, param, *value);
}
*graph.masks_mut() = self.masks.clone();
graph.set_state(&EditState {
params: Preset::from_params(self.params.clone()),
masks: std::sync::Arc::new(self.masks.clone()),
film: self.film.clone(),
spots: self.spots.clone(),
})
}
/// Record `graph` into this version, bumping the revision.
@@ -248,8 +269,22 @@ impl Version {
/// setter: FR-NC-9 resolves conflicts by revision, so a local edit that
/// did not bump it is a local edit a remote one will silently win.
pub fn update(&mut self, graph: &EditGraph, device: &str, now: i64) {
self.params = capture(graph);
self.masks = graph.masks().clone();
// Exhaustive, and this is the call site that proves why it has to be:
// this method wrote the parameters, the masks and the repairs and
// silently dropped the film, so saving an edit developed on a stock
// lost the stock. Nothing here can be forgotten now without failing to
// compile.
let EditState {
params,
masks,
film,
spots,
} = graph.state();
self.params = params.into_params();
self.masks = (*masks).clone();
self.film = film;
self.spots = spots;
self.revision = self.revision.saturating_add(1);
self.device = device.to_string();
self.modified = now;
@@ -314,6 +349,89 @@ impl Version {
conflicts
}
/// TRACES: FR-DEV-8 | FR-NC-9
/// Merge the spot sets, returning the repairs that genuinely conflicted.
///
/// By id, exactly as [`Self::merge_masks`] does, and for the same reason
/// one level down: a repair made on the phone and a repair made on the
/// desktop are different ids, so both survive and neither is a conflict.
/// That is most of why [`crate::spot::Spot::derive_id`] hashes the position
/// rather than counting — with counted ids the two would collide here and
/// one would be lost.
///
/// A spot *both* sides moved resolves wholesale to the higher revision.
/// Half of one device's offset with the other's radius is a repair neither
/// photographer made, and unlike a mask there is not even a case for
/// interleaving: eight numbers describe one disc.
fn merge_spots(
&mut self,
remote: &Version,
base: Option<&Version>,
remote_wins: bool,
) -> Vec<(String, String)> {
let empty = SpotSet::new();
let base_spots = base.map(|b| &b.spots).unwrap_or(&empty);
let mut conflicts = Vec::new();
let ids: Vec<String> = self
.spots
.spots()
.iter()
.chain(remote.spots.spots())
.map(|s| s.id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
for id in ids {
let ours = self.spots.get(&id);
let theirs = remote.spots.get(&id);
let was = base_spots.get(&id);
let we_changed = ours != was;
let they_changed = theirs != was;
match (we_changed, they_changed) {
// Only they touched it: take theirs, a deletion included.
(false, true) => match theirs {
Some(spot) => self.put_spot(spot.clone()),
None => {
self.spots.remove(&id);
}
},
(true, true) if ours != theirs => {
conflicts.push(("spot".to_string(), id.clone()));
if remote_wins {
match theirs {
Some(spot) => self.put_spot(spot.clone()),
None => {
self.spots.remove(&id);
}
}
}
}
_ => {}
}
}
conflicts
}
/// Replace a repair of the same id, or append it.
///
/// Position is not merged, for the reason [`Self::put_mask`] gives and one
/// more: the order only decides which of two *overlapping* repairs lands on
/// top, and repairs that overlap are already a case the photographer will
/// look at.
fn put_spot(&mut self, spot: Spot) {
match self.spots.get_mut(&spot.id) {
Some(existing) => *existing = spot,
None => {
self.spots.place(spot);
}
}
}
/// Replace a layer of the same id, or append it.
///
/// Position is not merged. Two devices that reordered the same stack have
@@ -438,6 +556,7 @@ impl Version {
// plus half of another's opacity is a layer neither of them made —
// so the layer is the unit, exactly as the value is for a parameter.
conflicts.extend(self.merge_masks(remote, base, remote_wins));
conflicts.extend(self.merge_spots(remote, base, remote_wins));
// Unknown keys follow the same rule, so an operation neither side
// understands is not dropped by the merge either.
@@ -490,20 +609,6 @@ fn merge_judgement(ours: u8, theirs: u8, remote_wins: bool) -> u8 {
}
}
/// Every non-default parameter in the graph, keyed by `(op, param)`.
///
/// Reads [`EditGraph::capabilities`] — the same list the UI builds controls
/// from — so an operation is persisted by virtue of being in the chain, with
/// nothing to register and nothing to forget.
///
/// Delegated to [`Preset::capture`] rather than reimplemented: a version's
/// parameters and a copied preset are the same values taken from the same
/// list, and two routines building the same map would be two places for the
/// non-default rule to drift.
fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> {
Preset::capture(graph).into_params()
}
impl Sidecar {
pub fn new() -> Self {
Self::default()
@@ -564,6 +669,15 @@ impl Sidecar {
for ((op, param), value) in &v.params {
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
}
// TRACES: FR-DEV-8
// In the order they were made, which is the order they are drawn
// in and the order that decides which repairs share a pass
// (`SpotSet::rounds`). A `BTreeMap` here — sorting them by id —
// would look tidier in the file and would silently reorder the
// photograph.
for spot in v.spots.spots() {
write_spot(&mut out, spot);
}
for (k, raw) in &v.unknown {
let _ = writeln!(out, "{k} = {raw}");
}
@@ -691,6 +805,21 @@ impl Sidecar {
Some(value.to_string());
}
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG),
// TRACES: FR-DEV-8
// Ahead of the `op.param` arm below, which would otherwise try
// to read eight numbers as one float and drop the repair with a
// warning about a corrupt value. The prefix is safe because a
// spot is deliberately *not* an operation (see `crate::spot`),
// so no `ops/` declaration can ever claim the name.
k if k.starts_with("spot.") => {
let id = &k["spot.".len()..];
match parse_spot(id, value) {
Some(spot) => {
version.spots.place(spot);
}
None => log::warn!("sidecar: unreadable spot {id}; ignoring it"),
}
}
_ => match key.split_once('.') {
// An `op.param` line whose value does not parse is a
// corrupt number, not an unknown key; dropping it lets
@@ -730,6 +859,78 @@ impl Sidecar {
}
}
/// TRACES: FR-DEV-8
/// Write one repair as one line.
///
/// Fields in order: centre, radius, feather, source offset, opacity, mode —
/// and the word `disabled` when it is switched off, which is rare enough that
/// it is a trailing marker rather than a ninth number every line carries.
///
/// Values are written at the precision they are held at (see `crate::spot`'s
/// grid), so a round trip is exact and two devices that placed the same repair
/// produce the same line rather than a diff of noise in the sixth decimal.
fn write_spot(out: &mut String, spot: &Spot) {
let _ = write!(
out,
"spot.{} = {} {} {} {} {} {} {} {}",
spot.id,
format_value(spot.centre.0),
format_value(spot.centre.1),
format_value(spot.radius),
format_value(spot.feather),
format_value(spot.offset.0),
format_value(spot.offset.1),
format_value(spot.opacity),
spot.mode.name(),
);
if !spot.enabled {
let _ = write!(out, " disabled");
}
let _ = writeln!(out);
}
/// TRACES: FR-DEV-8
/// Read one `spot.<id> = …` line, or nothing if it cannot be trusted.
///
/// A malformed line costs that repair and not the file, which is the rule
/// [`parse_stroke`] follows and for the sharper version of its reason: a spot
/// read half-way is a patch of one part of the photograph copied over another
/// part at random. A missing repair is noticed and re-made in a second; a
/// repair in the wrong place looks like the file is damaged.
fn parse_spot(id: &str, value: &str) -> Option<Spot> {
if id.is_empty() {
return None;
}
let mut tokens = value.split_whitespace();
let mut number = || {
tokens
.next()
.and_then(|t| t.parse::<f32>().ok())
.filter(|v| v.is_finite())
};
let centre = (number()?, number()?);
let radius = number()?;
let feather = number()?;
let offset = (number()?, number()?);
let opacity = number()?;
let mode = SpotMode::from_name(tokens.next()?)?;
// Anything after the mode that is not the one marker this format defines
// is a newer build's business. Ignored rather than refused: the repair is
// complete without it, and refusing would drop work over a field this
// build simply does not know about yet.
let enabled = !tokens.any(|t| t == "disabled");
let mut spot = Spot::new(centre, offset, radius);
spot.id = id.to_string();
spot.set_feather(feather);
spot.set_opacity(opacity);
spot.mode = mode;
spot.enabled = enabled;
Some(spot)
}
/// Write one mask layer as its own block.
///
/// The version uuid is repeated in the header rather than relying on the
@@ -1168,6 +1369,81 @@ mod tests {
Version::from_graph("uuid-1", "Default", graph)
}
/// A graph developing on a stock, with tables well-formed enough for the
/// film node to keep them. The emulsion is invented; what is under test
/// is whether the *choice* reaches the file.
fn on_film(stock: &str) -> EditGraph {
let mut g = edited();
g.set_film(Some(crate::graph::Film {
stock: stock.to_string(),
print: None,
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
lut_size: 2,
grain_particles: [0.0; 3],
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}));
g
}
#[test]
fn saving_an_edit_keeps_the_film_it_was_developed_on() {
// `update` is the write path — the one an automatic save goes through
// — and it used to copy the parameters and the masks and say nothing
// about the film. A photograph developed on a stock was written back
// without it, so the next time it opened, the emulsion was gone and
// nothing had reported a failure.
//
// It reads as an oversight because it was one, and that is the point:
// three routines captured "the edit" and each captured a different
// subset. All three now destructure one `EditState`, so the next part
// of an edit cannot be forgotten by anybody writing a line too few.
let mut v = version_of(&on_film("kodak_portra_400"));
v.film = None;
v.update(&on_film("kodak_portra_400"), "device-a", 1000);
assert_eq!(
v.film,
Some(FilmRef {
stock: "kodak_portra_400".into(),
print: None,
}),
"the stock did not survive the save"
);
}
#[test]
fn a_film_written_by_update_comes_back_off_the_disk() {
// End to end, because the field being set is only half of it: the
// stock has to reach the text and parse back out of it.
let mut v = version_of(&EditGraph::default_chain());
v.update(&on_film("ilford_hp5"), "device-a", 1000);
let mut sidecar = Sidecar::new();
sidecar.put(v);
let parsed = Sidecar::parse(&sidecar.to_text()).expect("re-read");
let mut graph = EditGraph::default_chain();
let rebake = parsed
.default_version()
.expect("a version")
.apply(&mut graph);
assert_eq!(
rebake.wanted().map(|f| f.stock.as_str()),
Some("ilford_hp5"),
"reopening the photograph has to ask for its stock back"
);
}
#[test]
fn only_non_default_values_are_written() {
// The property the whole format rests on: a neutral operation is
@@ -1197,7 +1473,8 @@ mod tests {
parsed
.default_version()
.expect("a version")
.apply(&mut restored);
.apply(&mut restored)
.expect_no_film();
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
assert_eq!(
@@ -1231,7 +1508,8 @@ mod tests {
parsed
.default_version()
.expect("a version")
.apply(&mut restored);
.apply(&mut restored)
.expect_no_film();
for cap in g.capabilities() {
for p in &cap.params {
@@ -1268,7 +1546,8 @@ mod tests {
parsed
.default_version()
.expect("a version")
.apply(&mut restored);
.apply(&mut restored)
.expect_no_film();
assert_eq!(restored.crop(), g.crop());
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
@@ -1308,7 +1587,8 @@ mod tests {
.expect("valid")
.default_version()
.expect("a version")
.apply(&mut sideways);
.apply(&mut sideways)
.expect_no_film();
assert_eq!(
sideways.framing().baseline(),
@@ -1326,7 +1606,11 @@ mod tests {
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let mut g = edited();
parsed.default_version().expect("a version").apply(&mut g);
parsed
.default_version()
.expect("a version")
.apply(&mut g)
.expect_no_film();
assert!(g.is_neutral(), "a neutral version must clear the graph");
}
@@ -1353,7 +1637,11 @@ mod tests {
tone_curve.p1_y = 0.15\ntone_curve.p3_y = 0.85\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed.default_version().expect("a version").apply(&mut g);
parsed
.default_version()
.expect("a version")
.apply(&mut g)
.expect_no_film();
// The S-curve the file describes, on the master curve and nowhere
// else.
@@ -1420,7 +1708,8 @@ mod tests {
parsed
.default_version()
.expect("a version")
.apply(&mut restored);
.apply(&mut restored)
.expect_no_film();
assert_eq!(restored.param(curve::ID, blue), Some(0.08));
assert_eq!(restored.param(curve::ID, red), Some(0.92));
@@ -1449,7 +1738,11 @@ mod tests {
time_machine.year = 1994\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed.default_version().expect("a version").apply(&mut g);
parsed
.default_version()
.expect("a version")
.apply(&mut g)
.expect_no_film();
assert!(g.is_neutral());
}
@@ -1459,7 +1752,11 @@ mod tests {
exposure.exposure = NaN\nwhite_balance.temperature = 20\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed.default_version().expect("a version").apply(&mut g);
parsed
.default_version()
.expect("a version")
.apply(&mut g)
.expect_no_film();
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert_eq!(
@@ -1476,7 +1773,11 @@ mod tests {
exposure.exposure = 99\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed.default_version().expect("a version").apply(&mut g);
parsed
.default_version()
.expect("a version")
.apply(&mut g)
.expect_no_film();
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
}
@@ -1510,7 +1811,7 @@ mod tests {
assert_eq!(parsed.default_version().expect("default").name, "Colour");
let mut g = EditGraph::default_chain();
parsed.versions["u2"].apply(&mut g);
parsed.versions["u2"].apply(&mut g).expect_no_film();
assert_eq!(
g.param(saturation::ID, saturation::SATURATION),
Some(-100.0)
@@ -1555,7 +1856,7 @@ mod tests {
assert!(conflicts.is_empty(), "disjoint edits must not conflict");
let mut g = EditGraph::default_chain();
local.apply(&mut g);
local.apply(&mut g).expect_no_film();
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0));
assert_eq!(g.crop().width, 0.5);
}
@@ -1579,7 +1880,7 @@ mod tests {
assert_eq!(conflicts.len(), 1, "the same parameter, two values");
let mut g = EditGraph::default_chain();
local.apply(&mut g);
local.apply(&mut g).expect_no_film();
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(2.0));
}
@@ -1604,7 +1905,7 @@ mod tests {
local.merge(&remote, Some(&base));
let mut g = EditGraph::default_chain();
local.apply(&mut g);
local.apply(&mut g).expect_no_film();
assert_eq!(
g.param(exposure::ID, exposure::EXPOSURE),
Some(1.0),
@@ -1626,7 +1927,7 @@ mod tests {
local.merge(&remote, Some(&base));
let mut g = EditGraph::default_chain();
local.apply(&mut g);
local.apply(&mut g).expect_no_film();
assert!(g.is_neutral(), "the remote reset must survive the merge");
}
@@ -1935,7 +2236,7 @@ mod tests {
assert_eq!(local.rating, 4, "the tablet's cull arrived");
let mut g = EditGraph::default_chain();
local.apply(&mut g);
local.apply(&mut g).expect_no_film();
assert_eq!(
g.param(exposure::ID, exposure::EXPOSURE),
Some(1.5),
+796
View File
@@ -0,0 +1,796 @@
//! TRACES: FR-DEV-8
//! Spot removal — the marks a photographer paints out, as parameters.
//!
//! A spot is a disc over something unwanted, a source offset saying where the
//! replacement comes from, and the handful of numbers that decide how the two
//! are blended. No pixels are stored, here or anywhere: the shader draws the
//! repair from these numbers every time the photograph is rendered, which is
//! what makes it non-destructive, cheap to sync, and undoable
//! (`docs/spot-removal.md`).
//!
//! # Why this is not an operation
//!
//! [`crate::Operation`] is `ParamId -> f32`, and the generic machinery built on
//! that — the develop panel, the sidecar, the presets — works precisely because
//! it is true. A spot list is neither scalar nor of fixed length, so it lives
//! beside `ops` in [`crate::EditGraph`], as `framing`, `masks` and `film`
//! already do for the same reason. The trait says as much where it refuses a
//! downcast for film tables: a thing that is not a slider should not pretend to
//! be one.
//!
//! # Units, once, for all of a spot's lengths
//!
//! `centre` is in **normalised source coordinates**, the space every mask uses,
//! so a spot survives a crop, a straighten, a zoom and an export at another
//! size with no arithmetic to keep it where the dust was.
//!
//! Every *length* — the radius, the feather, the source offset — is in the
//! frame's **isotropic units**, where y spans `0..1` and x spans `0..aspect`.
//! That is [`crate::mask::MaskSource::Radial`]'s convention and it is chosen
//! here for the same reason: only in those units is a disc a disc. Normalised
//! coordinates would make a spot on a 3:2 frame an ellipse half again wider
//! than it is tall, and the source offset would point somewhere other than
//! where the photographer dragged it.
//!
//! It is deliberately *one* unit for all three. A radius in shorter-edge
//! fractions beside an offset in frame units agrees on a landscape frame and
//! silently disagrees on a portrait one, which is the kind of bug that stays
//! invisible until somebody rotates a photograph.
//!
//! # What is not decided here
//!
//! How a spot is drawn. That is `dr-gpu`, from the passes [`crate::detail`]
//! composes — this module holds the state and the two pieces of arithmetic
//! nobody downstream should have to repeat: where a spot's source is
//! ([`Spot::source`]), and which spots may share a pass ([`SpotSet::rounds`]).
use crate::operation::{canonical_bits, hash_bytes, mix, Helper, FNV_OFFSET};
/// The most spots one edit holds.
///
/// Past a few dozen marks the answer is to clean the sensor, and a bound is
/// what keeps a sidecar a file a human can still read. Pushing past it refuses
/// rather than dropping the oldest — the rule [`crate::mask::MaskStack::push`]
/// follows, for the reason it gives: work the user can see on screen must not
/// vanish without being told.
pub const MAX_SPOTS: usize = 64;
/// The furthest a source may be dragged from what it repairs, in frame units.
///
/// Half the frame's height is well past any repair a photographer makes, and it
/// bounds something that is otherwise unbounded: a detail pass declares how far
/// it reads from the pixel it writes, and for a spot that is the offset plus
/// the radius. An unbounded offset is an unbounded halo, which is a pass the
/// tile scheduler cannot plan (ARCH §5.3, `docs/spot-removal.md` §5.3).
pub const MAX_SOURCE_DISTANCE: f32 = 0.5;
/// The radius a new spot starts at, in frame units.
///
/// About 25 px on the short edge of a 24 MP frame — a dust mark. Small enough
/// that the first click on a speck usually covers it, large enough to be worth
/// clicking at all.
pub const DEFAULT_RADIUS: f32 = 0.012;
/// The smallest radius a spot may be dragged to, in frame units.
///
/// Not zero: a spot with no radius repairs nothing and reads as the tool being
/// broken rather than as a spot being small.
pub const MIN_RADIUS: f32 = 0.001;
/// The largest radius a spot may be dragged to, in frame units.
///
/// A repair wider than half the frame is not a repair, and the same bound on
/// the radius as on the offset keeps the halo arithmetic honest.
pub const MAX_RADIUS: f32 = 0.5;
/// The fraction of the radius over which a new spot's edge falls away.
///
/// Soft by default because the common repair is dust on a gradient sky, where
/// a hard edge shows as a disc even when the colour underneath it is right.
pub const DEFAULT_FEATHER: f32 = 0.35;
/// How far a new repair's source starts from what it repairs, in radii.
///
/// Clear of the disc it is replacing — a source overlapping its own
/// destination would copy the mark it is removing — and close enough that on a
/// smoothly varying background it is the same background. Two and a half puts
/// a full radius of untouched photograph between the two edges.
const SOURCE_ARM: f32 = 2.5;
/// The grid every stored length and coordinate is rounded to, as a divisor.
///
/// The same value and the same reasoning as [`crate::mask::Stroke`]'s grid:
/// values are snapped on the way in *and* written at that precision, so a
/// sidecar round trip is exact rather than nearly exact, and two devices that
/// placed the same spot produce the same line instead of a diff of noise in the
/// sixth decimal — which under per-field merge (FR-NC-9) is a conflict over
/// nothing.
const SPOT_GRID: f32 = 10_000.0;
/// Round to the stored grid. See [`SPOT_GRID`].
fn snap(v: f32) -> f32 {
(v * SPOT_GRID).round() / SPOT_GRID
}
/// TRACES: FR-DEV-8
/// How a spot's patch meets what is already there.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SpotMode {
/// Copy the source's texture and take the destination's colour and
/// brightness from the boundary. The right answer for dust on a sky, and
/// the default because that is the overwhelming majority of spots.
#[default]
Heal,
/// Copy the source, unaltered.
///
/// Kept because heal is wrong on an edge: a spot straddling a horizon
/// healed by interpolating its boundary smears the horizon's contrast
/// across the disc, and the honest tool then is a straight copy from a
/// matching part of the frame. FR-DEV-8 asks for both for this reason.
Clone,
}
impl SpotMode {
/// The name this mode is stored under. Stable: it is in every sidecar.
pub fn name(self) -> &'static str {
match self {
Self::Heal => "heal",
Self::Clone => "clone",
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"heal" => Some(Self::Heal),
"clone" => Some(Self::Clone),
_ => None,
}
}
}
/// TRACES: FR-DEV-8
/// One repair: what is covered, what covers it, and how the two meet.
#[derive(Debug, Clone, PartialEq)]
pub struct Spot {
/// Stable across devices — see [`Spot::derive_id`].
pub id: String,
/// What is being covered, in normalised source coordinates.
pub centre: (f32, f32),
/// Where the replacement comes from, as a displacement from `centre` in
/// frame units.
///
/// A vector rather than a second point, so that nudging a spot half a pixel
/// carries its source along instead of asking the photographer to place it
/// again. Moving the source alone is an edit to this.
pub offset: (f32, f32),
/// The radius of the disc, in frame units.
pub radius: f32,
/// The fraction of the radius over which the edge falls away, `0.0..=1.0`.
/// Zero is a hard disc.
pub feather: f32,
/// How much of the patch is laid down, `0.0..=1.0`.
///
/// Below one the repair is partial, which is how a mark is *reduced* rather
/// than removed — worth having for a blemish that is part of the subject
/// rather than dirt on the sensor.
pub opacity: f32,
pub mode: SpotMode,
/// Whether this spot draws.
///
/// Kept rather than deleted so a photographer can see what a repair was
/// doing without losing it, exactly as [`crate::mask::MaskLayer::enabled`]
/// does for a layer.
pub enabled: bool,
}
impl Spot {
/// A spot covering `centre`, sourced `offset` away, clamped to what the
/// renderer can express.
///
/// The id is derived from the position — see [`Spot::derive_id`]. A caller
/// adding to a set should go through [`SpotSet::place`], which is what
/// resolves the case of two spots landing on the same point.
pub fn new(centre: (f32, f32), offset: (f32, f32), radius: f32) -> Self {
let centre = (snap(centre.0), snap(centre.1));
Self {
id: Self::derive_id(centre),
centre,
offset: clamp_offset(offset),
radius: snap(radius.clamp(MIN_RADIUS, MAX_RADIUS)),
feather: DEFAULT_FEATHER,
opacity: 1.0,
mode: SpotMode::default(),
enabled: true,
}
}
/// TRACES: FR-DEV-8
/// Where a new repair reads from, before anybody has looked at it.
///
/// **FR-DEV-8 asks for automatic source placement, and this is the cheap
/// half of it.** The good half searches the photograph for a patch whose
/// surroundings match — a compute dispatch scoring candidate offsets, and
/// one small readback when the spot is created (`docs/spot-removal.md`
/// §8). This is what stands in for it, and it is worth having on its own
/// terms rather than as a placeholder: dust sits on skies, skies are
/// smooth, and a patch two and a half radii away is nearly always the same
/// sky.
///
/// Towards the centre of the frame, because that is the direction with the
/// most photograph in it: a mark near an edge sourced outwards reads from
/// the border, or from outside it, where `spot_tap` clamps and the repair
/// smears. A mark *at* the centre has no such direction and is sent right,
/// which is as good as any other bearing and is at least predictable.
///
/// The result is what gets stored, and it is never recomputed: a repair
/// whose source moved on its own when the file was reopened would be an
/// edit changing itself, and non-destructive editing means the sidecar
/// decides what the picture is.
pub fn default_offset(centre: (f32, f32), radius: f32, aspect: f32) -> (f32, f32) {
let aspect = if aspect > 0.0 { aspect } else { 1.0 };
// In frame units, where a direction is a direction: normalised
// coordinates would bend the bearing by the aspect ratio and send a
// source off at an angle nobody chose.
let to_centre = ((0.5 - centre.0) * aspect, 0.5 - centre.1);
let length = to_centre.0.hypot(to_centre.1);
let direction = if length > 1e-4 {
(to_centre.0 / length, to_centre.1 / length)
} else {
(1.0, 0.0)
};
let distance = radius * SOURCE_ARM;
(direction.0 * distance, direction.1 * distance)
}
/// TRACES: FR-NC-9
/// The id a spot at `centre` is given: a short base-36 hash of the position
/// it was placed at.
///
/// **Derived rather than counted**, which is the opposite of what
/// [`crate::mask::MaskStack::next_id`] does, and the difference is worth
/// stating. A layer is a thing a user names and reorders, so a sequence is
/// natural. A spot is not named, and two devices editing the same
/// photograph offline would each mint `spot3` for different marks — after
/// which the merge in [`crate::sidecar`] would treat two repairs as one and
/// quietly keep whichever revision was higher.
///
/// From the position, two devices that removed *the same piece of dust*
/// agree on the id and the merge resolves them as one spot — which is
/// exactly right, because it is one spot. Two devices that removed
/// different marks disagree, and both survive.
///
/// The id is minted once, at placement, and never re-derived: dragging a
/// spot moves the repair, it does not make a different one.
pub fn derive_id(centre: (f32, f32)) -> String {
let mut h = FNV_OFFSET;
h = mix(h, u64::from(canonical_bits(snap(centre.0))));
h = mix(h, u64::from(canonical_bits(snap(centre.1))));
base36(h)
}
/// Where this spot reads from, in normalised source coordinates.
///
/// `aspect` is the source's width over its height. The offset is in frame
/// units and the answer is in normalised ones, and this is the only place
/// that conversion happens on this side — a caller doing it itself would be
/// the second place, and the two would eventually disagree about which axis
/// carries the aspect.
pub fn source(&self, aspect: f32) -> (f32, f32) {
let aspect = if aspect > 0.0 { aspect } else { 1.0 };
(
self.centre.0 + self.offset.0 / aspect,
self.centre.1 + self.offset.1,
)
}
/// This spot's centre in frame units, where a disc is a disc.
pub fn frame_centre(&self, aspect: f32) -> (f32, f32) {
(self.centre.0 * aspect, self.centre.1)
}
/// How far the source is from what it repairs, in frame units.
pub fn distance(&self) -> f32 {
self.offset.0.hypot(self.offset.1)
}
/// Whether this spot changes the photograph.
///
/// A spot with no offset reads the pixel it is writing: a clone copies a
/// pixel onto itself and a heal interpolates a boundary difference that is
/// zero everywhere, so both are the identity and both would cost a pass. A
/// spot just placed and not yet given a source is in exactly that state,
/// which is why this is asked per spot rather than per set.
pub fn is_active(&self) -> bool {
self.enabled && self.radius > 0.0 && self.opacity > 0.0 && self.distance() > f32::EPSILON
}
/// Move the whole repair, source and all, to a new centre.
///
/// The id does not move with it — see [`Spot::derive_id`].
pub fn set_centre(&mut self, centre: (f32, f32)) {
self.centre = (snap(centre.0), snap(centre.1));
}
/// Move the source, leaving what is being repaired where it is.
pub fn set_offset(&mut self, offset: (f32, f32)) {
self.offset = clamp_offset(offset);
}
pub fn set_radius(&mut self, radius: f32) {
self.radius = snap(radius.clamp(MIN_RADIUS, MAX_RADIUS));
}
pub fn set_feather(&mut self, feather: f32) {
self.feather = snap(feather.clamp(0.0, 1.0));
}
pub fn set_opacity(&mut self, opacity: f32) {
self.opacity = snap(opacity.clamp(0.0, 1.0));
}
/// Fold this spot into a running hash, for the detail stage's cache key.
///
/// Every value is a parameter — a position a finger left, a number from a
/// sidecar — never a float that came back from the GPU, which is what makes
/// hashing the bit patterns sound rather than reckless (ARCH §6.13). The id
/// is in it too: two spots that swapped ids are a different edit to sync
/// even though they draw the same picture.
pub(crate) fn hash(&self, h: u64) -> u64 {
let mut h = hash_bytes(h, self.id.as_bytes());
for v in [
self.centre.0,
self.centre.1,
self.offset.0,
self.offset.1,
self.radius,
self.feather,
self.opacity,
] {
h = mix(h, u64::from(canonical_bits(v)));
}
h = hash_bytes(h, self.mode.name().as_bytes());
mix(h, u64::from(self.enabled))
}
}
/// An offset clamped to what the halo bound allows, snapped to the grid.
///
/// Clamped along its own direction rather than per axis, so a drag towards a
/// corner stops at the bound instead of sliding along it — a per-axis clamp
/// would turn a diagonal drag into an L-shaped one under the finger.
fn clamp_offset(offset: (f32, f32)) -> (f32, f32) {
let distance = offset.0.hypot(offset.1);
if distance > MAX_SOURCE_DISTANCE {
let scale = MAX_SOURCE_DISTANCE / distance;
(snap(offset.0 * scale), snap(offset.1 * scale))
} else {
(snap(offset.0), snap(offset.1))
}
}
/// A hash as base-36 digits.
///
/// Short because it becomes a sidecar key that a human reads while debugging an
/// edit that went wrong. Six digits is two thousand million ids against the
/// sixty-four an edit may hold, so a collision is not a thing that happens by
/// accident — and [`SpotSet::place`] resolves it anyway when it does.
fn base36(mut h: u64) -> String {
const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
let mut out = String::with_capacity(6);
for _ in 0..6 {
out.push(DIGITS[(h % 36) as usize] as char);
h /= 36;
}
out
}
/// TRACES: FR-DEV-8
/// Every repair on one photograph, in the order they were made.
///
/// The order is not decoration: it decides which spots may share a pass
/// ([`Self::rounds`]) and which repair sits on top where two overlap.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SpotSet {
spots: Vec<Spot>,
}
impl SpotSet {
pub fn new() -> Self {
Self::default()
}
pub fn spots(&self) -> &[Spot] {
&self.spots
}
pub fn len(&self) -> usize {
self.spots.len()
}
pub fn is_empty(&self) -> bool {
self.spots.is_empty()
}
/// Whether this set draws nothing, and the detail stage may skip it
/// entirely.
pub fn is_neutral(&self) -> bool {
!self.spots.iter().any(Spot::is_active)
}
pub fn get(&self, id: &str) -> Option<&Spot> {
self.spots.iter().find(|s| s.id == id)
}
pub fn get_mut(&mut self, id: &str) -> Option<&mut Spot> {
self.spots.iter_mut().find(|s| s.id == id)
}
/// The spots that draw, in order.
pub fn active(&self) -> impl Iterator<Item = &Spot> {
self.spots.iter().filter(|s| s.is_active())
}
/// Add a spot, returning its id, or `None` if the set is full.
///
/// Full **refuses** rather than dropping the oldest: sixty-four repairs are
/// sixty-four decisions, and silently discarding the first to make room for
/// the sixty-fifth would undo work the photographer can see on screen.
///
/// A spot placed on top of an existing one is given a distinct id by
/// salting the hash, so the set never holds two spots under one name. This
/// is rare by construction — the same point to a ten-thousandth of the
/// frame — and it is the one case [`Spot::derive_id`]'s determinism cannot
/// resolve on its own.
pub fn place(&mut self, mut spot: Spot) -> Option<String> {
if self.spots.len() >= MAX_SPOTS {
log::warn!("spots: {MAX_SPOTS} is the limit; refusing to place another");
return None;
}
let mut salt: u64 = 0;
while self.spots.iter().any(|s| s.id == spot.id) {
salt += 1;
let mut h = FNV_OFFSET;
h = mix(h, u64::from(canonical_bits(spot.centre.0)));
h = mix(h, u64::from(canonical_bits(spot.centre.1)));
spot.id = base36(mix(h, salt));
}
let id = spot.id.clone();
self.spots.push(spot);
Some(id)
}
/// Remove one repair.
pub fn remove(&mut self, id: &str) -> Option<Spot> {
let index = self.spots.iter().position(|s| s.id == id)?;
Some(self.spots.remove(index))
}
pub fn clear(&mut self) {
self.spots.clear();
}
/// TRACES: FR-DEV-8
/// The active spots grouped into passes, as indices into the order
/// [`Self::active`] yields.
///
/// # Why grouping is needed at all
///
/// A detail pass reads one texture and writes another, so every spot in one
/// pass reads the photograph as it stood *before* that pass. A spot whose
/// source sits on an earlier spot's destination therefore copies the very
/// mark the earlier spot was removing, and the mark reappears a few hundred
/// pixels away — which reads as the tool being broken rather than as two
/// repairs that disagree.
///
/// The fix is not a pass per spot: sixty-four dispatches for a frame that
/// needs one is a frame budget spent on a case that almost never arises.
/// Instead a spot joins the round being built unless its source disc
/// intersects the destination disc of a spot already in that round, in
/// which case it opens a new one. Spots scattered over a sky with their
/// sources beside them — the overwhelming majority — come out as a single
/// round.
///
/// Only the round being built is consulted. Earlier rounds have already
/// been applied by the time a later one runs, so reading their destinations
/// is not a hazard: it is the repaired photograph, which is exactly what a
/// source should see.
///
/// Destinations overlapping destinations is not a hazard either — both
/// write the same output and the later spot lands on top, which is the
/// order the photographer made them in.
pub fn rounds(&self, aspect: f32) -> Vec<Vec<usize>> {
let active: Vec<&Spot> = self.active().collect();
let mut rounds: Vec<Vec<usize>> = Vec::new();
let mut current: Vec<usize> = Vec::new();
for (index, spot) in active.iter().enumerate() {
let source = spot.source(aspect);
let source_frame = (source.0 * aspect, source.1);
let conflicts = current.iter().any(|&earlier| {
let other = active[earlier];
let dest = other.frame_centre(aspect);
let reach = spot.radius + other.radius;
let dx = source_frame.0 - dest.0;
let dy = source_frame.1 - dest.1;
dx * dx + dy * dy < reach * reach
});
if conflicts {
rounds.push(std::mem::take(&mut current));
}
current.push(index);
}
if !current.is_empty() {
rounds.push(current);
}
rounds
}
/// Fold the set into a running hash, for the detail stage's cache key.
///
/// The order is in it: two spots swapped is a different grouping in
/// [`Self::rounds`] and a different picture where they overlap.
pub(crate) fn hash(&self, mut h: u64) -> u64 {
for spot in &self.spots {
h = spot.hash(h);
}
mix(h, self.spots.len() as u64)
}
}
/// The id the generated passes are labelled and prefixed with.
///
/// Not an operation id — no `ops/*.yaml` declares it and nothing in the chain
/// answers to it — for the same reason [`crate::detail`]'s resolve pass has one
/// of its own: a label reading `spot/round0` sends a reader to this module
/// rather than to whichever operation happened to lend its name.
pub const SPOT_ID: &str = "spot";
/// Bilinear sampling, which the generated preamble does not offer.
///
/// `tap` takes an integer offset from the pixel being written, and a repair
/// reads from wherever its source is — a fractional position in render space,
/// because the offset was stored as a fraction of the frame and multiplied up.
/// Sampling it nearest-neighbour would make a repair jitter by a pixel as the
/// view is zoomed, which on a face is the difference between a repair and a
/// smudge.
pub const SPOT_HELPERS: &[Helper] = &[Helper {
name: "spot_tap",
source: "\
// A bilinear sample at an arbitrary position, clamped to the edge.
//
// Clamped rather than zero-filled, exactly as `tap` is: a source dragged partly
// off the frame must read the pixels that exist rather than fade into black,
// which would draw a dark crescent inside the repair.
fn spot_tap(p: vec2<f32>) -> vec3<f32> {
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(1);
// Pixel centres sit at half-integers, so the texel below and left of a
// position is `floor(p - 0.5)`. Getting this wrong shifts every repair by
// half a pixel — invisible in a test that checks a mean, obvious on a face.
let q = p - vec2<f32>(0.5);
let base = floor(q);
let f = q - base;
let i0 = clamp(vec2<i32>(base), vec2<i32>(0), last);
let i1 = clamp(i0 + vec2<i32>(1), vec2<i32>(0), last);
let s00 = textureLoad(source, vec2<i32>(i0.x, i0.y), 0).rgb;
let s10 = textureLoad(source, vec2<i32>(i1.x, i0.y), 0).rgb;
let s01 = textureLoad(source, vec2<i32>(i0.x, i1.y), 0).rgb;
let s11 = textureLoad(source, vec2<i32>(i1.x, i1.y), 0).rgb;
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
}",
}];
/// TRACES: FR-DEV-8
/// How many points around a disc's rim a heal samples.
///
/// The membrane in [`SPOT_BODY`] is an interpolation of the boundary
/// difference, so this is the resolution of the boundary it sees. Twenty-four
/// puts a sample every fifteen degrees, which on a disc of any size a
/// photographer draws is finer than the tone it is interpolating.
///
/// A uniform rather than a constant in the source, so tuning it uploads a
/// buffer instead of recompiling — and so a future control could trade it for
/// speed on a large repair without a second shader.
pub const RIM_SAMPLES: f32 = 24.0;
/// The WGSL every spot pass runs. See [`SpotSet::passes`] for the record layout
/// it reads, which is where the meaning of each lane is written down.
const SPOT_BODY: &str = "\
// Each repair is two records: the disc it covers, and where it reads from.
let repairs = instance_count / 2u;
// The centre of this pixel. Half-integer, because a disc of radius 1.5 centred
// on a pixel should cover that pixel whole rather than half of it.
let here = vec2<f32>(f32(coord.x) + 0.5, f32(coord.y) + 0.5);
for (var i = 0u; i < repairs; i = i + 1u) {
let disc = instances[i * 2u];
let src = instances[i * 2u + 1u];
// The rejection test. It is what every pixel outside every repair pays,
// and a repair covers a few thousand pixels of a few million.
let delta = here - disc.xy;
let dist = length(delta);
if (dist >= disc.z) {
continue;
}
// One inside the solid core, falling to zero at the rim. `disc.w` is where
// the fall begins, worked out on the CPU so the shader never divides by a
// feather that might be zero.
let cover = (1.0 - smoothstep(disc.w, disc.z, dist)) * src.z;
if (cover <= 0.0) {
continue;
}
// The same displacement within the disc, read from beside it: the patch is
// a translation of the photograph, so its texture arrives unrotated and
// unscaled.
//
// `replacement`, not `patch`: WGSL reserves that word, and a reserved
// keyword in generated code is a compile error a long way from its cause.
var replacement = spot_tap(src.xy + delta);
// Heal: carry the source's texture, but the destination's tone.
//
// What a clone gets wrong is not the texture, it is the level. Dust on a
// gradient sky is cloned from a patch a little lighter or darker than the
// hole it fills, and the repair reads as a disc even though every grain in
// it is right. The fix is the difference between the two neighbourhoods,
// interpolated across the disc — a membrane, in the sense the Poisson
// literature means, approximated here in closed form rather than solved.
//
// Solving it properly is tens of Jacobi iterations, and an iteration in
// this architecture is a dispatch: sixty dispatches to remove a dust spot
// is not a frame budget. Interpolating the boundary difference by inverse
// square distance costs one loop over the rim and no state at all, and on
// the case that actually matters — a smooth background, where the
// difference around the rim is near enough constant — it lands on the same
// answer the solve would.
if (src.w > 0.5) {
var weighted = vec3<f32>(0.0);
var total = 0.0;
let samples = i32(rim_samples);
for (var k = 0; k < samples; k = k + 1) {
// Offset by half a step so no sample sits exactly on an axis,
// where a rim that crosses a hard edge would align with it.
let angle = (f32(k) + 0.5) * 6.283185307 / rim_samples;
let arm = vec2<f32>(cos(angle), sin(angle)) * disc.z;
// What the photograph says here, minus what the source says at the
// matching point of its own rim.
let boundary = spot_tap(disc.xy + arm) - spot_tap(src.xy + arm);
// Inverse square distance, floored so a pixel that lands on a
// sample is a large weight rather than an infinite one.
let w = 1.0 / max(dot(here - (disc.xy + arm), here - (disc.xy + arm)), 1.0);
weighted = weighted + boundary * w;
total = total + w;
}
replacement = replacement + weighted / max(total, 1e-6);
}
c = mix(c, replacement, cover);
}";
impl SpotSet {
/// TRACES: FR-DEV-8 | FR-DSP-1
/// The passes that draw these repairs at this size.
///
/// Shaped like [`crate::detail::DetailStage::passes`] and called in the
/// same place for the same reason, but deliberately not an implementation
/// of it: that trait belongs to operations, and a spot set is not one.
///
/// # Everything the shader sees is in render pixels
///
/// The conversion happens here, where the [`crate::Framing`] is in scope,
/// and never in WGSL. That is what keeps the shader ignorant of crops,
/// zooms, rotations and flips: a repair's centre and its source both go
/// through [`crate::Framing::output_at`] — the same map the fused pass
/// applies to every pixel — so a rotated photograph rotates the offset with
/// no trigonometry here at all, and a repair panned off screen lands
/// outside the target and draws nothing.
///
/// The radius goes through the same map rather than being multiplied by a
/// ratio: a point one radius above the centre is mapped too, and the
/// distance between the two answers *is* the radius in render pixels.
/// Anything cheaper would need this function to know that the framing is a
/// similarity, which is not its business to know.
///
/// # The record layout
///
/// Two `vec4`s per repair, because a repair does not fit in one:
///
/// | | x | y | z | w |
/// |---|---|---|---|---|
/// | 0 | centre x | centre y | radius | where the edge starts falling |
/// | 1 | source x | source y | opacity | 1 for heal, 0 for clone |
pub fn passes(
&self,
framing: &crate::Framing,
source: (u32, u32),
scale: crate::detail::RenderScale,
) -> Vec<crate::detail::DetailPass> {
use crate::detail::DetailPass;
let (sw, sh) = (source.0.max(1), source.1.max(1));
let aspect = sw as f32 / sh as f32;
let (rw, rh) = scale.render_size();
let (rw, rh) = (rw as f32, rh as f32);
let to_px = |uv: (f32, f32)| (uv.0 * rw, uv.1 * rh);
let active: Vec<&Spot> = self.active().collect();
let mut passes = Vec::new();
for (round, group) in self.rounds(aspect).into_iter().enumerate() {
let mut storage: Vec<[f32; 4]> = Vec::with_capacity(group.len() * 2);
let mut reach: f32 = 0.0;
for index in group {
let spot = active[index];
let centre = to_px(framing.output_at(spot.centre, sw, sh));
let from = to_px(framing.output_at(spot.source(aspect), sw, sh));
// One radius along y in frame units is one radius along y in
// normalised coordinates, which is why the probe point is built
// this way rather than from the offset.
let rim = (spot.centre.0, spot.centre.1 + spot.radius);
let rim_px = to_px(framing.output_at(rim, sw, sh));
let radius = (rim_px.0 - centre.0).hypot(rim_px.1 - centre.1);
// Where the edge begins to fall away. Always at least half a
// pixel inside the rim: a disc with a genuinely hard edge
// aliases into a visible polygon, and half a pixel of ramp is
// finer than any feather control can ask for anyway.
let inner = (radius * (1.0 - spot.feather)).min(radius - 0.5).max(0.0);
storage.push([centre.0, centre.1, radius, inner]);
storage.push([
from.0,
from.1,
spot.opacity,
match spot.mode {
SpotMode::Heal => 1.0,
SpotMode::Clone => 0.0,
},
]);
// How far this pass reads from a pixel it writes: across to the
// source, plus the disc it reads there. Stated honestly even
// though it is large — an understated radius shows as a seam at
// every tile boundary, which reads as a driver bug (ARCH §5.3).
let across = (from.0 - centre.0).hypot(from.1 - centre.1);
reach = reach.max(across + radius);
}
if storage.is_empty() {
continue;
}
passes.push(DetailPass {
label: round_label(round),
radius: reach.ceil() as u32,
wgsl: SPOT_BODY.to_string(),
uniforms: vec![crate::operation::Uniform {
name: "rim_samples",
value: RIM_SAMPLES,
}],
storage,
});
}
passes
}
}
/// A static label for round `n`.
///
/// Static because a pass label is a `&'static str`, and rounds past the few
/// named here are rare enough — each one needs a source deliberately placed
/// over an earlier repair — that sharing a label between them costs nothing but
/// a slightly vaguer line in a profiler.
fn round_label(round: usize) -> &'static str {
match round {
0 => "round0",
1 => "round1",
2 => "round2",
3 => "round3",
_ => "round",
}
}
+313
View File
@@ -0,0 +1,313 @@
//! TRACES: FR-DEV-5 | FR-CAT-8
//! The whole of an edit, gathered so that nothing can be left out of it.
//!
//! # Why this exists
//!
//! An edit used to be a bag of scalars, and [`Preset`] is that bag. It was a
//! true description while the graph held only operations and a framing, and it
//! stopped being true the moment a mask stack and a film stock arrived —
//! because a layer is not a scalar and a stock is not a scalar, which is
//! precisely why [`EditGraph`] holds both of them apart from `ops`.
//!
//! Nothing announced the change. What happened instead is that three places
//! each captured "the edit" and each captured a different subset of it:
//! [`Preset::capture`] took the parameters, `Version::from_graph` took the
//! parameters and the masks and the film, and `Version::update` took the
//! parameters and the masks and dropped the film on the floor. The visible
//! symptom was quieter still — the history snapshots a `Preset`, so drawing a
//! mask opened no undo step at all, and `record` returned `false` while the
//! interface went on calling it in good faith.
//!
//! # What keeps it complete
//!
//! Not vigilance, and not a checklist in a comment. [`EditGraph::state`]
//! destructures the graph **exhaustively** and [`EditGraph::set_state`]
//! destructures this type exhaustively — no `..` in either pattern, and this
//! type's fields are public so that every construction site is a struct
//! literal naming all of them. A fifth kind of state added to the graph does
//! not compile until somebody has decided whether an undo has to put it back.
//!
//! FR-DEV-8's spot removal was named here as the one already asked for, and it
//! arrived. It did not compile, which is the whole of what this was for: the
//! repairs are in [`Self::spots`] because the build refused to proceed without
//! an answer about them, rather than because anyone remembered to look.
//!
//! That is the whole mechanism. It is deliberately a compiler error rather
//! than a runtime check, because the failure being prevented is *silence*: the
//! mask bug produced no panic, no warning and no failing test, and only a
//! diagnostic that fires before the code runs at all could have caught it.
//!
//! # What is deliberately not in here
//!
//! - **The viewport.** Zoom and pan say where the photographer is looking, not
//! what the photograph becomes. An undo that also moved the view would read
//! as navigation — the rule [`Preset::apply`] already keeps for a paste.
//! - **The orientation baseline.** How the camera stored its rows is part of
//! reading the file, not a decision anyone made (see [`crate::framing`]).
//! - **The film tables.** Derived from the stock, the paper and the film
//! node's own exposure sliders, and re-baked in milliseconds. Turning a name
//! back into tables needs the profile database this crate does not link
//! (ARCH §6.5a), which is why [`FilmRebake`] exists.
//! - **The rating and the flag.** Judgements about the photograph rather than
//! edits to it; they change no pixel, and `sidecar::Version` keeps them for
//! that reason.
use crate::graph::EditGraph;
use crate::mask::MaskStack;
use crate::preset::Preset;
use crate::spot::SpotSet;
/// TRACES: FR-DEV-3f
/// The stock and paper an edit names, without the tables they bake to.
///
/// The **id, not an index**. Stocks are files that users add
/// (`core/dr-film/profiles`), so an index would mean installing a profile
/// silently changed which film every existing photograph was developed on.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FilmRef {
pub stock: String,
/// The paper, if the negative is printed. Absent means the film is viewed
/// as it comes — which for a colour negative is the scan, orange and
/// inverted, and is a legitimate thing to ask for.
pub print: Option<String>,
}
/// TRACES: FR-DEV-5 | FR-CAT-8
/// Everything about a photograph that an edit decides.
///
/// Fields are public on purpose: a struct literal is exhaustive, so every
/// place that builds one has to account for every part of an edit. See the
/// module note — that is the entire safety mechanism, and hiding these behind
/// a constructor with positional arguments would trade it for two `String`s
/// that can be passed in the wrong order.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct EditState {
/// Every non-default parameter — the operations' and the framing's alike,
/// since the framing is an entry in [`EditGraph::capabilities`] like any
/// other.
pub params: Preset,
/// The local adjustments (FR-DEV-3).
///
/// Shared rather than owned, and that is a decision about the *drag path*
/// rather than about memory. `state` is called on every parameter change,
/// which during a slider drag is once a frame; a mask stack holding a
/// painted brush is thousands of stroke points, and deep-copying it sixty
/// times a second to record an exposure move that did not touch it would
/// be a cost paid for nothing. [`EditGraph::masks_mut`] is the single door
/// through which a stack is modified, and it clones on write, so snapshots
/// share until one of them is actually edited.
pub masks: std::sync::Arc<MaskStack>,
/// TRACES: FR-DEV-3f
/// The stock this edit develops on, named.
pub film: Option<FilmRef>,
/// TRACES: FR-DEV-8
/// The repairs (see [`crate::spot`]).
///
/// Owned rather than shared, where [`Self::masks`] is shared, and the
/// difference is a fact about the two rather than an inconsistency: a
/// repair is a handful of numbers and a photographer places tens of them,
/// while a painted mask is an unbounded path of stroke points. Copying the
/// first once a frame is nothing; copying the second is the reason
/// `masks_mut` clones on write.
pub spots: SpotSet,
}
/// TRACES: FR-DEV-3f
/// What a caller still owes the graph after its state was replaced.
///
/// `dr-pipeline` can hold a film but cannot bake one: the tables come from the
/// profile database, and this crate does not link it (ARCH §6.5a). So
/// restoring an edit that names a stock is necessarily two steps, and this is
/// the second one made impossible to forget rather than left in a comment.
#[derive(Debug, Clone, PartialEq)]
#[must_use = "an ignored rebake leaves the photograph rendering without its film"]
pub enum FilmRebake {
/// The graph's film is already right — either the state named none, in
/// which case it has been cleared, or there was nothing to restore.
NotNeeded,
/// Bake this stock and hand the result back through
/// [`EditGraph::set_film`].
Wanted(FilmRef),
}
impl FilmRebake {
/// The stock to bake, if one is owed.
pub fn wanted(&self) -> Option<&FilmRef> {
match self {
Self::NotNeeded => None,
Self::Wanted(film) => Some(film),
}
}
/// Assert that nothing is owed, for a caller that knows this edit names
/// no film — a test fixture, in practice.
///
/// It *checks* rather than discards, and that is the point of it existing
/// at all. `let _ = …` would make the same claim silently and go on making
/// it the day the fixture grows a stock, at which point the test would
/// pass while rendering the wrong picture — which is precisely the failure
/// this type was introduced to stop.
#[track_caller]
pub fn expect_no_film(self) {
if let Self::Wanted(film) = self {
panic!(
"this edit develops on {:?}, which has to be baked back before \
the photograph can be rendered",
film.stock
);
}
}
}
impl EditState {
/// The state `graph` is in. The same call as [`EditGraph::state`], for a
/// caller that reads better this way round.
pub fn capture(graph: &EditGraph) -> Self {
graph.state()
}
/// Whether this edit does anything to the photograph at all.
///
/// A neutral state is a *decision* rather than a missing one — applying it
/// returns the photograph to its defaults — so this answers "is there
/// anything to show", not "is there anything to store".
pub fn is_neutral(&self) -> bool {
self.params.is_empty()
&& self.masks.is_empty()
&& self.film.is_none()
&& self.spots.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::framing;
use crate::graph::Film;
use crate::mask::{MaskLayer, MaskSource};
use crate::ops::{curve, exposure, saturation};
use crate::spot::Spot;
use crate::CropRect;
/// A graph with one of *every* kind of state moved off its default: an
/// operation's parameter, a framing, a mask layer, a film, a repair. One
/// of each is the point — a round trip that only exercises the scalars is
/// the test that was already passing while the masks went missing.
fn thoroughly_edited() -> EditGraph {
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
g.set_param(saturation::ID, saturation::SATURATION, -30.0);
g.set_param(curve::ID, curve::P1_X, 0.3);
g.set_param(framing::ID, framing::ANGLE, 2.5);
g.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.6,
height: 0.6,
});
let mut layer = MaskLayer::new(
"l1",
MaskSource::Radial {
centre: (0.4, 0.6),
radii: (0.25, 0.3),
angle: 0.2,
feather: 0.15,
},
);
layer.opacity = 0.6;
layer.invert = true;
g.masks_mut().push(layer);
g.spots_mut()
.place(Spot::new((0.3, 0.7), (0.05, 0.0), 0.02))
.expect("the repair was placed");
g.set_film(Some(Film {
stock: "kodak_portra_400".into(),
print: Some("kodak_endura".into()),
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
lut_size: 2,
grain_particles: [0.0; 3],
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}));
g
}
#[test]
fn an_edit_survives_being_taken_off_a_graph_and_put_back() {
// The property every undo rests on. Checked over the whole state
// rather than the values that were set, because the interesting
// failure is not a value coming back wrong — it is a *kind* of value
// that was never carried at all, and a narrower assertion is exactly
// what missed the masks.
let edited = thoroughly_edited();
let state = edited.state();
let mut fresh = EditGraph::default_chain();
let rebake = fresh.set_state(&state);
assert_eq!(
rebake,
FilmRebake::Wanted(FilmRef {
stock: "kodak_portra_400".into(),
print: Some("kodak_endura".into()),
}),
"the stock has to be asked for, since this crate cannot bake it"
);
// The film is the one part `set_state` deliberately does not restore,
// so it is put back the way a caller would before comparing.
fresh.set_film(edited.film().cloned());
assert_eq!(
fresh.state(),
state,
"something about the edit did not survive the round trip"
);
}
#[test]
fn putting_a_state_back_replaces_rather_than_overlays() {
// A part absent from the state means *default*, not "leave whatever
// was there". Otherwise undoing to the floor would leave the last
// mask standing, which is the shape of the bug this type exists for.
let mut g = EditGraph::default_chain();
let floor = g.state();
let edited = thoroughly_edited();
assert!(g.set_state(&edited.state()).wanted().is_some());
// A caller bakes at this point. Standing in for it here is what makes
// the assertion below mean anything: without a film on the graph,
// "the film was cleared" would be true of a graph that never had one.
g.set_film(edited.film().cloned());
assert!(g.film().is_some() && !g.masks().is_empty() && !g.spots().is_empty());
g.set_state(&floor).expect_no_film();
assert!(
g.masks().is_empty(),
"a layer outlived the state holding it"
);
assert!(g.film().is_none(), "the film outlived the state holding it");
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert!(g.crop().is_full(), "the crop outlived it: {:?}", g.crop());
assert_eq!(g.state(), floor);
}
#[test]
fn a_neutral_state_is_a_decision_rather_than_a_missing_one() {
assert!(EditGraph::default_chain().state().is_neutral());
assert!(!thoroughly_edited().state().is_neutral());
}
}
+3 -2
View File
@@ -39,7 +39,8 @@ fn round_trip(graph: &EditGraph) -> EditGraph {
.versions
.get("default")
.expect("version survived")
.apply(&mut restored);
.apply(&mut restored)
.expect_no_film();
restored
}
@@ -239,7 +240,7 @@ fn applying_a_maskless_version_clears_existing_masks() {
assert_eq!(graph.masks().len(), 1);
let plain = Version::from_graph("clean", "Clean", &EditGraph::default_chain());
plain.apply(&mut graph);
plain.apply(&mut graph).expect_no_film();
assert!(graph.masks().is_empty());
}
+298
View File
@@ -0,0 +1,298 @@
//! TRACES: FR-DEV-8 | FR-NC-9
//! Repairs must survive the sidecar, and survive two devices.
//!
//! The sidecar is the authoritative store (ARCH §6.12), so a spot that does not
//! round-trip is not a persistence bug but lost work — and a spot that
//! round-trips *nearly* is worse than one that fails, because a disc copied
//! from slightly the wrong place looks like a damaged file rather than like a
//! feature that did not run.
use dr_pipeline::spot::{Spot, SpotMode, DEFAULT_RADIUS};
use dr_pipeline::{EditGraph, Sidecar, Version};
fn spot_at(centre: (f32, f32), offset: (f32, f32)) -> Spot {
Spot::new(centre, offset, DEFAULT_RADIUS)
}
/// A graph carrying three repairs: the default kind, a clone, and one switched
/// off — which between them cover every field the line format carries.
fn graph_with_spots() -> EditGraph {
let mut graph = EditGraph::default_chain();
graph
.spots_mut()
.place(spot_at((0.25, 0.75), (0.08, -0.02)));
let mut cloned = spot_at((0.6, 0.4), (-0.12, 0.05));
cloned.mode = SpotMode::Clone;
cloned.set_feather(0.0);
cloned.set_opacity(0.5);
graph.spots_mut().place(cloned);
let mut off = spot_at((0.1, 0.1), (0.2, 0.2));
off.enabled = false;
graph.spots_mut().place(off);
graph
}
fn round_trip(graph: &EditGraph) -> EditGraph {
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", graph));
let text = sidecar.to_text();
let parsed = Sidecar::parse(&text).expect("reparse");
let mut restored = EditGraph::default_chain();
parsed
.versions
.get("default")
.expect("version survived")
.apply(&mut restored)
.expect_no_film();
restored
}
#[test]
fn every_field_of_every_repair_comes_back() {
let graph = graph_with_spots();
let restored = round_trip(&graph);
assert_eq!(
restored.spots().spots(),
graph.spots().spots(),
"a repair is eight numbers and every one of them matters"
);
}
/// The order is the order they were made in, which decides which repair lands
/// on top where two overlap and which of them may share a pass. Sorting by id
/// on the way out would look tidier and would reorder the photograph.
#[test]
fn the_order_they_were_made_in_survives() {
let graph = graph_with_spots();
let ids: Vec<&str> = graph
.spots()
.spots()
.iter()
.map(|s| s.id.as_str())
.collect();
let restored = round_trip(&graph);
let back: Vec<&str> = restored
.spots()
.spots()
.iter()
.map(|s| s.id.as_str())
.collect();
assert_eq!(ids, back);
}
/// What lets a caller skip an upload by comparing content: the same edit must
/// produce the same bytes, or every save looks like a change to sync.
#[test]
fn writing_the_same_repairs_twice_is_byte_identical() {
let graph = graph_with_spots();
let mut a = Sidecar::new();
a.put(Version::from_graph("default", "Default", &graph));
let mut b = Sidecar::new();
b.put(Version::from_graph("default", "Default", &graph));
assert_eq!(a.to_text(), b.to_text());
}
/// A frame nobody has repaired must not grow a line, in the same way an
/// operation at neutral contributes nothing.
#[test]
fn an_unrepaired_frame_writes_no_spot_lines() {
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph(
"default",
"Default",
&EditGraph::default_chain(),
));
assert!(!sidecar.to_text().contains("spot."));
}
/// The file is meant to be readable by a human debugging an edit that went
/// wrong, and hand-editable by one who knows what they are doing.
#[test]
fn a_hand_written_line_loads() {
let text = "drsc 1\n\
\n\
[version default]\n\
name = Default\n\
default = 1\n\
revision = 1\n\
spot.abc123 = 0.4 0.6 0.02 0.5 0.1 -0.05 1 heal\n";
let sidecar = Sidecar::parse(text).expect("parse");
let spots = &sidecar.versions["default"].spots;
assert_eq!(spots.len(), 1);
let spot = &spots.spots()[0];
assert_eq!(spot.id, "abc123");
assert_eq!(spot.centre, (0.4, 0.6));
assert_eq!(spot.radius, 0.02);
assert_eq!(spot.feather, 0.5);
assert_eq!(spot.offset, (0.1, -0.05));
assert_eq!(spot.mode, SpotMode::Heal);
assert!(spot.enabled);
}
/// A truncated or hand-mangled line costs that repair and not the file. The
/// alternative — refusing the version — throws away every other repair, the
/// crop and the exposure over one bad line.
#[test]
fn a_malformed_line_costs_one_repair() {
let text = "drsc 1\n\
\n\
[version default]\n\
revision = 1\n\
exposure.exposure = 0.75\n\
spot.good11 = 0.4 0.6 0.02 0.5 0.1 -0.05 1 heal\n\
spot.short1 = 0.4 0.6 0.02\n\
spot.nomode = 0.4 0.6 0.02 0.5 0.1 -0.05 1 smudge\n\
spot.notnum = x y 0.02 0.5 0.1 -0.05 1 heal\n";
let sidecar = Sidecar::parse(text).expect("parse");
let version = &sidecar.versions["default"];
assert_eq!(version.spots.len(), 1);
assert_eq!(version.spots.spots()[0].id, "good11");
assert_eq!(
version.params.get(&("exposure".into(), "exposure".into())),
Some(&0.75),
"the rest of the edit still loaded"
);
}
/// A field a newer build added must not cost the repair. The disc is complete
/// without it, and refusing would be a device running behind deleting work it
/// merely does not understand.
#[test]
fn a_trailing_field_from_a_newer_build_is_ignored_not_refused() {
let text = "drsc 1\n\
\n\
[version default]\n\
revision = 1\n\
spot.abc123 = 0.4 0.6 0.02 0.5 0.1 -0.05 1 heal rotation=0.5\n";
let sidecar = Sidecar::parse(text).expect("parse");
assert_eq!(sidecar.versions["default"].spots.len(), 1);
}
/// A spot switched off is state the photographer set, not an absence.
#[test]
fn a_disabled_repair_stays_disabled() {
let graph = graph_with_spots();
let restored = round_trip(&graph);
let off = restored.spots().spots().last().expect("three repairs");
assert!(!off.enabled);
}
// ---------------------------------------------------------------------------
// Sync merge (FR-NC-9)
// ---------------------------------------------------------------------------
fn version_with(uuid: &str, revision: u64, build: impl FnOnce(&mut EditGraph)) -> Version {
let mut graph = EditGraph::default_chain();
build(&mut graph);
let mut v = Version::from_graph(uuid, "Default", &graph);
v.revision = revision;
v
}
/// The case the id derivation exists for. Two devices, offline, each removing a
/// different mark: with counted ids both would be `spot3` and one would be
/// lost here without a word.
#[test]
fn repairs_from_two_devices_both_survive() {
let base = version_with("default", 1, |_| {});
let mut ours = version_with("default", 2, |g| {
g.spots_mut().place(spot_at((0.2, 0.2), (0.05, 0.0)));
});
let theirs = version_with("default", 2, |g| {
g.spots_mut().place(spot_at((0.8, 0.8), (-0.05, 0.0)));
});
let conflicts = ours.merge(&theirs, Some(&base));
assert!(conflicts.is_empty(), "different marks are not a conflict");
assert_eq!(ours.spots.len(), 2);
}
/// And the other half of that: two devices that removed *the same* piece of
/// dust agree on the id, so the merge sees one repair — which is right, because
/// it is one repair, and the alternative is the same disc drawn twice.
#[test]
fn the_same_mark_removed_on_both_devices_stays_one_repair() {
let base = version_with("default", 1, |_| {});
let mut ours = version_with("default", 2, |g| {
g.spots_mut().place(spot_at((0.5, 0.5), (0.06, 0.0)));
});
let theirs = version_with("default", 3, |g| {
g.spots_mut().place(spot_at((0.5, 0.5), (0.06, 0.0)));
});
let conflicts = ours.merge(&theirs, Some(&base));
assert!(
conflicts.is_empty(),
"the same repair is not a disagreement"
);
assert_eq!(ours.spots.len(), 1);
}
/// Both devices dragging one repair's source is genuinely ambiguous: the two
/// offsets cannot be averaged into a third that either photographer wanted, so
/// the higher revision takes the spot whole.
#[test]
fn both_moving_one_repair_is_a_conflict_resolved_by_revision() {
let placed = spot_at((0.5, 0.5), (0.06, 0.0));
let id = placed.id.clone();
let base = version_with("default", 1, |g| {
g.spots_mut().place(placed.clone());
});
let mut ours = version_with("default", 2, |g| {
g.spots_mut().place(placed.clone());
g.spots_mut().get_mut(&id).unwrap().set_offset((0.2, 0.0));
});
let theirs = version_with("default", 5, |g| {
g.spots_mut().place(placed.clone());
g.spots_mut().get_mut(&id).unwrap().set_offset((0.0, -0.2));
});
let conflicts = ours.merge(&theirs, Some(&base));
assert_eq!(conflicts, vec![("spot".to_string(), id.clone())]);
assert_eq!(
ours.spots.get(&id).map(|s| s.offset),
Some((0.0, -0.2)),
"the higher revision wins the repair whole"
);
}
/// A repair deleted on one device and untouched on the other stays deleted —
/// the disjoint case again, in the direction that is easy to get backwards.
#[test]
fn a_deletion_propagates() {
let placed = spot_at((0.5, 0.5), (0.06, 0.0));
let id = placed.id.clone();
let base = version_with("default", 1, |g| {
g.spots_mut().place(placed.clone());
});
let mut ours = version_with("default", 2, |g| {
g.spots_mut().place(placed.clone());
});
let theirs = version_with("default", 3, |_| {});
assert!(ours.merge(&theirs, Some(&base)).is_empty());
assert!(ours.spots.get(&id).is_none());
}
+303
View File
@@ -0,0 +1,303 @@
//! TRACES: FR-DEV-8
//! The spot model: identity, bounds, and which repairs may share a pass.
//!
//! Everything here is arithmetic and bookkeeping, which is exactly why it is
//! tested without a device: the three ways this model can be wrong — an id that
//! is not stable, a bound that drops work silently, a grouping that lets a
//! source read a destination — all produce a *picture* that is subtly wrong and
//! no error anywhere.
use dr_pipeline::spot::{
Spot, SpotMode, SpotSet, DEFAULT_RADIUS, MAX_SOURCE_DISTANCE, MAX_SPOTS, MIN_RADIUS,
};
use dr_pipeline::EditGraph;
/// A 3:2 frame, which is the shape that catches a unit confusion. On a square
/// one every wrong answer happens to be right.
const ASPECT: f32 = 1.5;
fn spot_at(centre: (f32, f32), offset: (f32, f32)) -> Spot {
Spot::new(centre, offset, DEFAULT_RADIUS)
}
/// Two devices that remove the same piece of dust must agree on its id, or the
/// sidecar merge treats one repair as two and both survive — a spot drawn
/// twice, which is visible.
#[test]
fn the_same_placement_mints_the_same_id() {
let a = spot_at((0.25, 0.75), (0.05, 0.0));
let b = spot_at((0.25, 0.75), (-0.02, 0.03));
assert_eq!(a.id, b.id, "the id is the position, not the whole spot");
let elsewhere = spot_at((0.26, 0.75), (0.05, 0.0));
assert_ne!(a.id, elsewhere.id);
}
/// And the id must not move when the repair does: dragging a spot is an edit to
/// a spot, not the deletion of one and the creation of another. If the id
/// followed the centre, a drag on one device and a radius change on the other
/// would merge as two unrelated spots.
#[test]
fn dragging_a_spot_keeps_its_id() {
let mut spot = spot_at((0.25, 0.75), (0.05, 0.0));
let id = spot.id.clone();
spot.set_centre((0.9, 0.1));
spot.set_offset((0.1, 0.1));
spot.set_radius(0.2);
assert_eq!(spot.id, id);
}
/// Two spots placed on the same point are still two repairs, and a set that
/// held them both under one id would lose one of them at the next save.
#[test]
fn a_second_spot_on_the_same_point_gets_its_own_id() {
let mut set = SpotSet::new();
let first = set.place(spot_at((0.5, 0.5), (0.05, 0.0))).unwrap();
let second = set.place(spot_at((0.5, 0.5), (0.0, 0.05))).unwrap();
assert_ne!(first, second);
assert_eq!(set.len(), 2);
assert!(set.get(&first).is_some() && set.get(&second).is_some());
}
/// The bound refuses rather than dropping. A set that quietly discarded the
/// oldest repair would remove work already on screen, with nothing said.
#[test]
fn the_limit_refuses_and_keeps_what_is_there() {
let mut set = SpotSet::new();
for i in 0..MAX_SPOTS {
let y = i as f32 / MAX_SPOTS as f32;
assert!(set.place(spot_at((0.5, y), (0.05, 0.0))).is_some());
}
let first = set.spots()[0].clone();
assert!(set.place(spot_at((0.1, 0.1), (0.05, 0.0))).is_none());
assert_eq!(set.len(), MAX_SPOTS);
assert_eq!(
set.spots()[0],
first,
"the oldest repair survives the refusal"
);
}
/// The offset bound is what keeps the detail pass's halo finite, so it has to
/// hold along the diagonal and not merely per axis — and it must keep the
/// direction the photographer dragged in.
#[test]
fn a_source_dragged_too_far_stops_in_the_direction_it_was_going() {
let spot = spot_at((0.5, 0.5), (3.0, 4.0));
let distance = spot.distance();
assert!(
(distance - MAX_SOURCE_DISTANCE).abs() < 1e-3,
"clamped to the bound, got {distance}"
);
// 3:4 in, 3:4 out.
assert!((spot.offset.0 / spot.offset.1 - 0.75).abs() < 1e-3);
}
#[test]
fn a_radius_cannot_be_dragged_to_nothing() {
let mut spot = spot_at((0.5, 0.5), (0.05, 0.0));
spot.set_radius(0.0);
assert!(spot.radius >= MIN_RADIUS);
}
/// The offset is in frame units and the source is in normalised ones, and the
/// aspect goes on exactly one of the two axes. Getting this backwards puts the
/// source somewhere the photographer did not drag it, by a third of the frame
/// on a 3:2 — visible, and easy to write.
#[test]
fn the_source_converts_frame_units_to_normalised_ones() {
let spot = spot_at((0.5, 0.5), (0.15, 0.15));
let (sx, sy) = spot.source(ASPECT);
assert!((sx - (0.5 + 0.15 / ASPECT)).abs() < 1e-4);
assert!((sy - 0.65).abs() < 1e-4);
// The displacement is equal on both axes in frame units, so it must be
// *unequal* in normalised ones on a frame that is not square.
assert!((sx - 0.5) < (sy - 0.5));
}
/// A spot with no source reads the pixel it writes: the identity, at the cost
/// of a dispatch. A freshly placed spot is in that state until a source is
/// found for it, which is why the question is asked per spot.
#[test]
fn a_spot_with_no_offset_draws_nothing() {
let mut set = SpotSet::new();
let id = set.place(spot_at((0.5, 0.5), (0.0, 0.0))).unwrap();
assert!(set.is_neutral());
assert_eq!(set.rounds(ASPECT).len(), 0);
set.get_mut(&id).unwrap().set_offset((0.08, 0.0));
assert!(!set.is_neutral());
assert_eq!(set.rounds(ASPECT), vec![vec![0]]);
}
#[test]
fn a_disabled_spot_draws_nothing_but_is_kept() {
let mut set = SpotSet::new();
let id = set.place(spot_at((0.5, 0.5), (0.08, 0.0))).unwrap();
set.get_mut(&id).unwrap().enabled = false;
assert!(set.is_neutral());
assert_eq!(set.len(), 1, "disabling is not deleting");
}
/// Spots scattered over a sky with their sources beside them are the
/// overwhelming majority, and they must cost one dispatch.
#[test]
fn repairs_that_do_not_interfere_share_one_pass() {
let mut set = SpotSet::new();
for i in 0..8 {
let y = 0.1 + 0.1 * i as f32;
set.place(spot_at((0.5, y), (0.04, 0.0)));
}
assert_eq!(set.rounds(ASPECT), vec![(0..8).collect::<Vec<_>>()]);
}
/// The case the grouping exists for: the second spot reads from where the first
/// one is repairing. In one pass it would copy the mark the first spot is
/// removing, and the mark would reappear somewhere else in the frame.
#[test]
fn a_source_over_an_earlier_repair_opens_a_new_pass() {
let mut set = SpotSet::new();
// Repairs (0.30, 0.50) from (0.40, 0.50) — both in frame units on x.
set.place(spot_at((0.2, 0.5), (0.1, 0.0)));
// Repairs (0.60, 0.50) by reading (0.30, 0.50): exactly the first
// destination.
set.place(spot_at((0.4, 0.5), (-0.3, 0.0)));
assert_eq!(set.rounds(ASPECT), vec![vec![0], vec![1]]);
}
/// Two repairs landing on top of each other is not a hazard — they write the
/// same output and the later one lands on top, which is the order they were
/// made in. Splitting a pass for it would cost a dispatch for nothing.
#[test]
fn overlapping_destinations_stay_in_one_pass() {
let mut set = SpotSet::new();
set.place(spot_at((0.5, 0.5), (0.2, 0.0)));
set.place(spot_at((0.505, 0.5), (0.2, 0.05)));
assert_eq!(set.rounds(ASPECT).len(), 1);
}
/// A spot is an edit like any other, so a graph holding one is not clean — and
/// a graph whose only spot draws nothing is.
#[test]
fn a_placed_repair_makes_the_graph_dirty() {
let mut graph = EditGraph::default_chain();
assert!(graph.is_neutral());
graph.spots_mut().place(spot_at((0.5, 0.5), (0.0, 0.0)));
assert!(graph.is_neutral(), "a spot with no source is not an edit");
graph.spots_mut().place(spot_at((0.2, 0.2), (0.08, 0.0)));
assert!(!graph.is_neutral());
}
/// Moving a spot must re-run the neighbourhood passes and nothing before them.
/// If it moved the colour key, dragging a spot would re-run the fused dispatch
/// and every mask on the frame with it (FR-DEV-3d).
#[test]
fn a_repair_moves_the_detail_key_alone() {
use dr_pipeline::Affects;
let mut graph = EditGraph::default_chain();
let before = graph.invalidation();
let id = graph
.spots_mut()
.place(spot_at((0.5, 0.5), (0.08, 0.0)))
.unwrap();
let after = graph.invalidation();
assert_eq!(
before.through(Affects::Colour),
after.through(Affects::Colour),
"a repair is not a colour change"
);
assert_ne!(
before.through(Affects::Detail),
after.through(Affects::Detail)
);
// And a change *to* a spot moves it again.
let placed = graph.invalidation();
graph.spots_mut().get_mut(&id).unwrap().set_radius(0.05);
assert_ne!(
placed.through(Affects::Detail),
graph.invalidation().through(Affects::Detail)
);
}
#[test]
fn modes_survive_their_own_names() {
for mode in [SpotMode::Heal, SpotMode::Clone] {
assert_eq!(SpotMode::from_name(mode.name()), Some(mode));
}
assert_eq!(SpotMode::from_name("smudge"), None);
}
// ---------------------------------------------------------------------------
// Undo (FR-DEV-5)
// ---------------------------------------------------------------------------
/// The press a photographer reaches for first: place a repair, dislike it,
/// take it back. Before the history carried the spot set this stepped some
/// unrelated slider and left the repair on the photograph, which reads as undo
/// being broken rather than absent.
#[test]
fn undo_takes_a_repair_back() {
use dr_pipeline::history::{Edit, History};
let mut graph = EditGraph::default_chain();
let mut history = History::new(&graph);
graph.spots_mut().place(spot_at((0.5, 0.5), (0.08, 0.0)));
assert!(history.record(
&graph,
Edit::Action(dr_pipeline::LocalizedKey("history.spot"))
));
assert_eq!(graph.spots().len(), 1);
assert!(history.undo(&mut graph).moved());
assert_eq!(graph.spots().len(), 0, "the repair is still on the frame");
assert!(history.redo(&mut graph).moved());
assert_eq!(graph.spots().len(), 1, "and redo could not put it back");
}
/// Moving a repair is undoable too, and separately from placing it: the two
/// are different decisions and a photographer who nudges a source expects one
/// press to return it, not to lose the spot entirely.
#[test]
fn undo_steps_back_through_a_moved_source() {
use dr_pipeline::history::{Edit, History};
let mut graph = EditGraph::default_chain();
let id = graph
.spots_mut()
.place(spot_at((0.5, 0.5), (0.08, 0.0)))
.unwrap();
let mut history = History::new(&graph);
graph
.spots_mut()
.get_mut(&id)
.unwrap()
.set_offset((0.2, 0.1));
history.record(
&graph,
Edit::Action(dr_pipeline::LocalizedKey("history.spot")),
);
assert!(history.undo(&mut graph).moved());
assert_eq!(
graph.spots().get(&id).map(|s| s.offset),
Some((0.08, 0.0)),
"the source did not go back where it was"
);
assert_eq!(graph.spots().len(), 1, "and the repair itself survived");
}
+2 -1
View File
@@ -26,7 +26,8 @@ fn apply(text: &str) -> EditGraph {
sidecar
.default_version()
.expect("a default version")
.apply(&mut graph);
.apply(&mut graph)
.expect_no_film();
graph
}
+341
View File
@@ -441,6 +441,214 @@ impl Orientation {
}
}
/// TRACES: FR-DEV-3h
/// A rectangle in the image **as stored** — the sensor's own scanline order.
///
/// Normalised: fractions of the stored image, origin top-left.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StoredRect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
/// TRACES: FR-DEV-3h
/// A rectangle in the image **as shown** — the photograph the right way up.
///
/// Normalised, like [`StoredRect`], and deliberately a different type. The two
/// are the same four numbers and mean different things, which is precisely why
/// mixing them up is silent: a stored rect used against shown dimensions
/// produces a plausible rectangle in the wrong place. See [`Orientation`]'s
/// module note on why nothing here is named "clockwise".
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShownRect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
/// TRACES: FR-DEV-3h
/// Moving pixels, rectangles and points between the stored image and the shown
/// one.
///
/// # Why these are named after spaces and not after rotations
///
/// Every orientation bug this codebase has had was somebody applying a turn
/// the correct size in the wrong direction — and a quarter turn applied
/// backwards lands 180° from right, which looks like a deliberate transform
/// rather than a mistake. "Rotate 90° clockwise" cannot be checked by reading
/// it, because the reader has to hold in their head which image is being
/// rotated and which way the y axis points.
///
/// So no function below says clockwise, anticlockwise, horizontal or vertical.
/// They say **which space they take and which space they return**, the two
/// spaces are different Rust types where a mistake would otherwise be silent,
/// and the direction is then something the compiler checks rather than
/// something the author remembers.
///
/// The one primitive underneath all of it is
/// [`Orientation::source_pixel`] — the backward map the shader prologue and
/// the thumbnail path already share. Everything here is that map read forwards
/// or backwards, so there is one permutation in the codebase and no second
/// opinion to drift.
impl Orientation {
/// The transform that puts a shown image back into stored order.
///
/// Not simply `4 - turns`: the mirrors are applied *after* the turn, so
/// undoing means undoing them first, and a mirror seen from the far side
/// of a turn may be about the other axis. Getting this wrong is the
/// diagonal-mirror case (tags 5 and 7), which renders as a 180° error.
pub fn inverse(self) -> Self {
// Mirrors are involutions, so the inverse's mirrors are the same two;
// what changes is the turn they are read against.
let (flip_h, flip_v) = if self.quarter_turns % 2 == 1 {
(self.flip_v, self.flip_h)
} else {
(self.flip_h, self.flip_v)
};
Self {
quarter_turns: (4 - self.quarter_turns) % 4,
flip_h,
flip_v,
}
}
/// Where a **stored** pixel lands in the **shown** image.
///
/// [`Self::source_pixel`] run the other way, and stated the same way:
/// each takes the dimensions of the space it *reads from*. So this one is
/// handed the stored size and `source_pixel` is handed the shown size,
/// and neither caller has to work out which pair it is holding.
pub fn shown_pixel(self, sx: u32, sy: u32, width: u32, height: u32) -> (u32, u32) {
self.inverse().source_pixel(sx, sy, width, height)
}
/// Read a **stored** buffer into **shown** order.
///
/// `channels` values per pixel, tightly packed. Generic because the same
/// permutation serves an RGB proxy of floats, an RGBA overlay of bytes and
/// a single-channel mask, and three hand-written copies of one loop is how
/// two of them come to disagree.
///
/// Exact. A quarter turn and its mirrors are a permutation of the pixel
/// grid, so nothing is filtered, nothing is resampled, and the round trip
/// through [`Self::into_stored`] returns what went in.
pub fn into_shown<T: Copy + Default>(
self,
stored: &[T],
width: u32,
height: u32,
channels: usize,
) -> (Vec<T>, u32, u32) {
if self.is_normal() || width == 0 || height == 0 {
return (stored.to_vec(), width, height);
}
let (dw, dh) = self.oriented_size(width, height);
let mut out = vec![T::default(); (dw as usize) * (dh as usize) * channels];
for y in 0..dh {
for x in 0..dw {
let (sx, sy) = self.source_pixel(x, y, dw, dh);
let s = (sy as usize * width as usize + sx as usize) * channels;
let d = (y as usize * dw as usize + x as usize) * channels;
out[d..d + channels].copy_from_slice(&stored[s..s + channels]);
}
}
(out, dw, dh)
}
/// Read a **shown** buffer back into **stored** order.
///
/// `(dw, dh)` are the shown dimensions. The exact inverse of
/// [`Self::into_shown`], and written as the same map rather than as a
/// second one: a bijection read as a scatter fills every stored pixel once
/// and leaves no hole.
pub fn into_stored<T: Copy + Default>(
self,
shown: &[T],
dw: u32,
dh: u32,
channels: usize,
) -> (Vec<T>, u32, u32) {
if self.is_normal() || dw == 0 || dh == 0 {
return (shown.to_vec(), dw, dh);
}
let (sw, sh) = self.oriented_size(dw, dh);
let mut out = vec![T::default(); (sw as usize) * (sh as usize) * channels];
for y in 0..dh {
for x in 0..dw {
let (sx, sy) = self.source_pixel(x, y, dw, dh);
let s = (y as usize * dw as usize + x as usize) * channels;
let d = (sy as usize * sw as usize + sx as usize) * channels;
out[d..d + channels].copy_from_slice(&shown[s..s + channels]);
}
}
(out, sw, sh)
}
/// [`Self::source_pixel`] in normalised continuous coordinates.
///
/// The pixel map says `dw - 1 - x` where this says `1 - x`, because a
/// pixel *centre* at `x + 0.5` has to land at `dw - x - 0.5`. Everything
/// else is the same transform in the same order — turn first, mirrors
/// after, in the stored image's own axes — and it is written next to its
/// integer twin so the two cannot drift apart unnoticed.
fn source_point(self, x: f32, y: f32) -> (f32, f32) {
let (mut sx, mut sy) = match self.quarter_turns {
1 => (y, 1.0 - x),
2 => (1.0 - x, 1.0 - y),
3 => (1.0 - y, x),
_ => (x, y),
};
if self.flip_h {
sx = 1.0 - sx;
}
if self.flip_v {
sy = 1.0 - sy;
}
(sx, sy)
}
/// A normalised rectangle, **stored** to **shown**.
///
/// Through the *inverse* map, because [`Self::source_point`] runs shown to
/// stored — and that asymmetry is written once, here, rather than being
/// rediscovered at each call site. The corners are mapped and the extremes
/// taken afterwards, since a turn exchanges which corner is which and a
/// rectangle has to keep its width positive.
pub fn into_shown_rect(self, r: StoredRect) -> ShownRect {
let [x, y, x1, y1] = self
.inverse()
.corners([r.x, r.y, r.x + r.width, r.y + r.height]);
ShownRect {
x,
y,
width: x1 - x,
height: y1 - y,
}
}
/// A normalised rectangle, **shown** to **stored**.
pub fn into_stored_rect(self, r: ShownRect) -> StoredRect {
let [x, y, x1, y1] = self.corners([r.x, r.y, r.x + r.width, r.y + r.height]);
StoredRect {
x,
y,
width: x1 - x,
height: y1 - y,
}
}
/// Both corners of a normalised rect through [`Self::source_point`], left
/// in `(x0, y0, x1, y1)` order.
fn corners(self, [ax, ay, bx, by]: [f32; 4]) -> [f32; 4] {
let (p0x, p0y) = self.source_point(ax, ay);
let (p1x, p1y) = self.source_point(bx, by);
[p0x.min(p1x), p0y.min(p1y), p0x.max(p1x), p0y.max(p1y)]
}
}
/// TRACES: FR-EXP-8
/// Where a photograph was taken.
///
@@ -778,4 +986,137 @@ mod tests {
assert!(Availability::Original > Availability::Preview);
assert!(Availability::Preview > Availability::MetadataOnly);
}
// ---- the orientation space maps (FR-DEV-3h) --------------------------
/// Every pixel its own value, non-square and coprime, so no symmetry can
/// hide a permutation that is not the intended one.
fn ramp(w: u32, h: u32, channels: usize) -> Vec<u32> {
(0..(w * h) as usize)
.flat_map(|i| (0..channels).map(move |c| (i * 10 + c) as u32))
.collect()
}
/// The property everything else rests on. A quarter turn applied backwards
/// lands 180° from right and still looks like a transform, so "it came
/// back" is the only check worth having.
#[test]
fn a_buffer_survives_the_round_trip_through_shown_space() {
for tag in 1..=8u16 {
let o = Orientation::from_exif(tag);
for channels in [1usize, 3, 4] {
let src = ramp(5, 3, channels);
let (shown, dw, dh) = o.into_shown(&src, 5, 3, channels);
assert_eq!((dw, dh), o.oriented_size(5, 3), "tag {tag}");
let (back, bw, bh) = o.into_stored(&shown, dw, dh, channels);
assert_eq!((bw, bh), (5, 3), "tag {tag}: stored size");
assert_eq!(back, src, "tag {tag}, {channels}ch: did not come back");
}
}
}
/// `shown_pixel` must be the true inverse of `source_pixel`, not merely
/// something that looks like it. This is where a wrong direction shows up
/// as a fixed point that should have moved.
#[test]
fn the_two_pixel_maps_invert_each_other() {
const W: u32 = 7;
const H: u32 = 4;
for tag in 1..=8u16 {
let o = Orientation::from_exif(tag);
let (dw, dh) = o.oriented_size(W, H);
for y in 0..dh {
for x in 0..dw {
let (sx, sy) = o.source_pixel(x, y, dw, dh);
assert!(sx < W && sy < H, "tag {tag}: ({x},{y}) left the image");
assert_eq!(
o.shown_pixel(sx, sy, W, H),
(x, y),
"tag {tag}: ({x},{y}) did not survive the return trip"
);
}
}
}
}
/// An inverse that is not a group inverse is the diagonal-mirror bug: tags
/// 5 and 7 differ only in which axis the mirror is about, and getting it
/// wrong renders as a 180° error rather than as anything obviously broken.
#[test]
fn the_inverse_undoes_the_transform() {
for tag in 1..=8u16 {
let o = Orientation::from_exif(tag);
assert_eq!(o.inverse().inverse(), o, "tag {tag}: not an involution");
// Composed on the grid, the pair must be the identity.
let (dw, dh) = o.oriented_size(6, 4);
let src = ramp(6, 4, 1);
let (shown, _, _) = o.into_shown(&src, 6, 4, 1);
let (back, _, _) = o.inverse().into_shown(&shown, dw, dh, 1);
assert_eq!(back, src, "tag {tag}: inverse did not undo it");
}
}
/// The rect maps have to agree with the pixel map, or a clip lands
/// somewhere the pixels did not — which is exactly the fault that put the
/// segmentation overlay on its side over an upright photograph.
#[test]
fn a_rect_lands_where_its_pixels_land() {
const W: u32 = 8;
const H: u32 = 4;
for tag in 1..=8u16 {
let o = Orientation::from_exif(tag);
let (dw, dh) = o.oriented_size(W, H);
// A stored rect covering a known block of pixels.
let r = StoredRect {
x: 0.25,
y: 0.0,
width: 0.5,
height: 0.5,
};
let shown = o.into_shown_rect(r);
// Every stored pixel inside `r` must map into `shown`, and the
// rect must not have grown: a permutation moves a block, it does
// not resize one.
assert!(
(shown.width * shown.height - r.width * r.height).abs() < 1e-5,
"tag {tag}: the rect changed size"
);
for sy in 0..H {
for sx in 0..W {
let inside = (sx as f32 + 0.5) / W as f32 >= r.x
&& (sx as f32 + 0.5) / W as f32 <= r.x + r.width
&& (sy as f32 + 0.5) / H as f32 >= r.y
&& (sy as f32 + 0.5) / H as f32 <= r.y + r.height;
if !inside {
continue;
}
let (x, y) = o.shown_pixel(sx, sy, W, H);
let (u, v) = ((x as f32 + 0.5) / dw as f32, (y as f32 + 0.5) / dh as f32);
assert!(
u >= shown.x - 1e-4
&& u <= shown.x + shown.width + 1e-4
&& v >= shown.y - 1e-4
&& v <= shown.y + shown.height + 1e-4,
"tag {tag}: stored pixel ({sx},{sy}) -> shown ({x},{y}) fell outside {shown:?}"
);
}
}
// And back again.
let round = o.into_stored_rect(shown);
assert!(
(round.x - r.x).abs() < 1e-5,
"tag {tag}: x {round:?} vs {r:?}"
);
assert!(
(round.y - r.y).abs() < 1e-5,
"tag {tag}: y {round:?} vs {r:?}"
);
assert!((round.width - r.width).abs() < 1e-5, "tag {tag}: w");
assert!((round.height - r.height).abs() < 1e-5, "tag {tag}: h");
}
}
}