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
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+737
-62
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user