Files
lightcraft/crates/pipeline/src/tests_window.rs
T
Nathaniel LedfordandClaude Sonnet 5.5 05618b2ad4 #323: windows on the GPU; Auto Mask strokes grow the window; dab references at f32
The GPU renderer renders windows (no more CPU fallback): the vignette gets the
window's offset and the whole output's size (VIG_VIEW), the airlight and noise
reduction scale come from the whole frame, and a window grown for spots or
strokes is cut back after the readback. equivalence.rs renders every settings
case as a window plus a corner vignette and a spot reading from outside, within
the existing 1 LSB. Auto Mask strokes join spots in growing the window (they read
the pixel under each dab). An Auto Mask dab's reference pixel is chosen from its
position at f32 precision, like the GPU kernel: an f64 rounding error that
depended on the window's start could put a boundary dab on either pixel.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:28:45 -04:00

344 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Region rendering (issue #323): a window of the virtual full-size output is rendered on its own
//! and must match the same pixels of the full render, so zoomed views can be sharp at any zoom
//! without materializing the whole frame.
use lightcraft_develop::{DevelopSettings, controls};
use lightcraft_geom::{Orientation, Rect};
use lightcraft_raster::{Rgb32f, Rgba8};
use crate::{PixelWindow, RenderRequest, SourceInfo, render};
const W: usize = 960;
const H: usize = 640;
/// Context rendered around the pixels a caller keeps (spatial filters read their neighbours).
const HALO: usize = 64;
fn scene() -> Rgb32f {
lightcraft_scenes::demo_library()[0].render(480, 320)
}
fn full(s: &DevelopSettings) -> Rgba8 {
render(&scene(), &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image
}
fn window(s: &DevelopSettings, win: PixelWindow) -> Rgba8 {
let req = RenderRequest { window: Some(win), ..RenderRequest::fit(W, H) };
render(&scene(), &SourceInfo::default(), s, &req).image
}
/// Largest per-channel difference and mean absolute difference between `win` (rendered for
/// `at`, kept `HALO` px in from its edges) and the same pixels of `full`.
fn compare(full: &Rgba8, win: &Rgba8, at: PixelWindow) -> (u8, f64) {
compare_inset(full, win, at, HALO)
}
/// [`compare`] keeping `inset` px in from the window's edges.
fn compare_inset(full: &Rgba8, win: &Rgba8, at: PixelWindow, inset: usize) -> (u8, f64) {
let (mut max, mut sum, mut n) = (0u8, 0f64, 0f64);
for y in inset..at.h - inset {
for x in inset..at.w - inset {
let (a, b) = (full.get(at.x + x, at.y + y), win.get(x, y));
for k in 0..3 {
let d = a[k].abs_diff(b[k]);
max = max.max(d);
sum += d as f64;
n += 1.0;
}
}
}
(max, sum / n)
}
fn check(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) {
let (f, w) = (full(s), window(s, win));
assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size");
let (max, mean) = compare(&f, &w, win);
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
}
const MID: PixelWindow = PixelWindow { x: 300, y: 180, w: 400, h: 300 };
// Given the window is the whole output, then it is the full render, byte for byte
#[test]
fn the_whole_frame_as_a_window_is_the_full_render() {
let s = DevelopSettings::default();
let f = full(&s);
let w = window(&s, PixelWindow { x: 0, y: 0, w: f.width, h: f.height });
assert_eq!(f.data, w.data);
}
// Given default settings, a window in the middle matches the full render
#[test]
fn a_window_matches_the_full_render() {
check("default", &DevelopSettings::default(), MID, 1, 0.1);
}
// Given a window at each corner, it matches too (edges of the frame are not special)
#[test]
fn windows_at_the_corners_match() {
let s = DevelopSettings::default();
for (x, y) in [(0, 0), (W - 400, 0), (0, H - 300), (W - 400, H - 300)] {
check("corner", &s, PixelWindow { x, y, w: 400, h: 300 }, 1, 0.1);
}
}
// Given crop, straighten and flip, the window still lands on the same pixels
#[test]
fn a_cropped_straightened_flipped_view_matches() {
let mut s = DevelopSettings::default();
s.crop.geometry.rect = Rect { x0: 0.1, y0: 0.15, x1: 0.85, y1: 0.9 };
s.crop.geometry.angle = 4.0;
s.crop.flip_h = true;
check("crop+angle+flipH", &s, MID, 3, 0.3);
s.crop.flip_h = false;
s.crop.flip_v = true;
check("crop+angle+flipV", &s, MID, 3, 0.3);
}
// Given a rotated photo, the same
#[test]
fn rotated_and_mirrored_orientations_match() {
for o in [Orientation::Rotate90, Orientation::Rotate270, Orientation::FlipH, Orientation::Transpose] {
let s = DevelopSettings { orientation: o, ..Default::default() };
let f = full(&s);
let win = PixelWindow { x: f.width / 4, y: f.height / 4, w: f.width / 2, h: f.height / 2 };
check(&format!("{o:?}"), &s, win, 3, 0.3);
}
}
// Given a post-crop vignette, the window shows the vignette of the whole frame, not its own
#[test]
fn the_vignette_belongs_to_the_frame_not_the_window() {
let mut s = DevelopSettings::default();
controls::set(&mut s, "vignette.amount", -90.0);
controls::set(&mut s, "vignette.midpoint", 10.0);
// the vignette is really there: the corner is much darker than without it
let (with, without) = (full(&s), full(&DevelopSettings::default()));
let corner = |i: &Rgba8| i.get(W - 20, H - 20)[1] as i32;
assert!(corner(&without) - corner(&with) > 30, "{} vs {}", corner(&without), corner(&with));
// …and a window over that corner shows it, at the same place
check("vignette", &s, PixelWindow { x: W - 400, y: H - 300, w: 400, h: 300 }, 2, 0.2);
check("vignette middle", &s, MID, 2, 0.2);
}
// Given spatial adjustments, the window agrees away from its halo
#[test]
fn spatial_adjustments_agree_inside_the_halo() {
for (id, v) in
[("effects.clarity", 70.0), ("effects.dehaze", 40.0), ("detail.sharpenAmount", 80.0), ("light.shadows", 60.0), ("effects.texture", 60.0)]
{
let mut s = DevelopSettings::default();
controls::set(&mut s, id, v);
check(id, &s, MID, 6, 0.8);
}
}
// Given a huge virtual frame (48000 px: an 800 % zoom), only the window is rendered
#[test]
fn a_window_of_a_huge_frame_is_cheap() {
let req = RenderRequest { window: Some(PixelWindow { x: 20_000, y: 12_000, w: 256, h: 192 }), ..RenderRequest::fit(48_000, 32_000) };
let r = render(&scene(), &SourceInfo::default(), &DevelopSettings::default(), &req);
assert_eq!((r.image.width, r.image.height), (256, 192));
}
// Hostile windows (from UI state) are clamped into the frame, never a panic or a huge buffer
#[test]
fn windows_outside_or_degenerate_are_clamped() {
let s = DevelopSettings::default();
for win in [
PixelWindow { x: W + 500, y: 5, w: 100, h: 100 },
PixelWindow { x: 0, y: 0, w: 0, h: 0 },
PixelWindow { x: usize::MAX, y: usize::MAX, w: usize::MAX, h: usize::MAX },
PixelWindow { x: 900, y: 600, w: 5000, h: 5000 },
] {
let r = window(&s, win);
assert!(r.width >= 1 && r.height >= 1 && r.width <= W && r.height <= H, "{win:?}: {}×{}", r.width, r.height);
}
}
/// A textured, noisy source: what noise reduction and edge refinement act on.
fn noisy_scene() -> Rgb32f {
let base = scene();
Rgb32f::from_fn(base.width, base.height, |x, y| {
let mut v = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841);
v ^= v >> 13;
v = v.wrapping_mul(0x5bd1_e995);
v ^= v >> 15;
let n = 1.0 + 0.25 * ((v & 0xffff) as f32 / 32768.0 - 1.0);
base.data[y * base.width + x].map(|c| c * n)
})
}
fn check_noisy(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) {
let src = noisy_scene();
let f = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image;
let w = render(&src, &SourceInfo::default(), s, &RenderRequest { window: Some(win), ..RenderRequest::fit(W, H) }).image;
let (max, mean) = compare(&f, &w, win);
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
}
// Given noise reduction, its strength follows the whole frame's size, not the window's
#[test]
fn noise_reduction_does_not_depend_on_the_window() {
for (id, v) in [("detail.nrLuminance", 80.0), ("detail.nrColor", 80.0)] {
let mut s = DevelopSettings::default();
controls::set(&mut s, id, v);
check_noisy(id, &s, MID, 3, 0.15);
}
}
// Given a mask with Refine Edges, the refinement width follows the whole frame's size
#[test]
fn mask_edge_refinement_does_not_depend_on_the_window() {
use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) };
let mut s = DevelopSettings::default();
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust: LocalAdjustments { exposure: 1.5, ..Default::default() },
refine: 100.0,
..Default::default()
});
check_noisy("refine", &s, MID, 3, 0.2);
}
// ---- Deep zoom: the wide stages' kernels are far wider than any margin -------------------------
/// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware
/// filters and the dark channel have something to be wrong about.
fn blocks() -> Rgb32f {
Rgb32f::from_fn(600, 400, |x, y| {
let cell = |size: usize, k: usize| ((x / size * 7 + y / size * 13 + k) % 5) as f32 / 4.0;
let l = 0.02 + 0.5 * cell(97, 1) + 0.25 * cell(31, 2) + 0.1 * cell(11, 3);
[l * 1.1, l, l * 0.8]
})
}
const BIG_W: usize = 7680;
const BIG_H: usize = 5120;
/// A window of a 7680 × 5120 frame against the same pixels of the whole render. The wide stages
/// (dehaze, clarity, highlights/shadows base) have XX/// can't contain them, so they come from the whole frame at reduced size.
fn check_big(name: &str, s: &DevelopSettings, max_ok: u8, mean_ok: f64) {
let src = blocks();
let f = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(BIG_W, BIG_H)).image;
let win = PixelWindow { x: 3000, y: 1800, w: 512, h: 384 };
let w = render(&src, &SourceInfo::default(), s, &RenderRequest { window: Some(win), ..RenderRequest::fit(BIG_W, BIG_H) }).image;
let (max, mean) = compare(&f, &w, win);
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
}
#[test]
fn dehaze_at_depth_matches_the_whole_render() {
let mut s = DevelopSettings::default();
controls::set(&mut s, "effects.dehaze", 60.0);
check_big("dehaze", &s, 6, 0.6);
}
#[test]
fn clarity_at_depth_matches_the_whole_render() {
let mut s = DevelopSettings::default();
controls::set(&mut s, "effects.clarity", 80.0);
check_big("clarity", &s, 6, 0.6);
}
#[test]
fn highlights_and_shadows_at_depth_match_the_whole_render() {
let mut s = DevelopSettings::default();
controls::set(&mut s, "light.shadows", 80.0);
controls::set(&mut s, "light.highlights", -80.0);
check_big("shadows+highlights", &s, 6, 0.6);
}
// ---- Spot removal reads outside the spot -------------------------------------------------------
/// Like [`check`], over every pixel of the window (a spot at its edge must be right too).
fn check_whole(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) {
let (f, w) = (full(s), window(s, win));
let (max, mean) = compare_inset(&f, &w, win, 0);
assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size");
assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})");
}
fn with_spot(mode: lightcraft_develop::SpotMode, at: (f64, f64), offset: Option<(f64, f64)>) -> DevelopSettings {
use lightcraft_develop::Spot;
use lightcraft_geom::Point;
let mut s = DevelopSettings::default();
s.spots.push(Spot {
mode,
points: vec![Point::new(at.0, at.1)],
size: 0.04,
feather: 30.0,
opacity: 100.0,
source_offset: offset.map(|(x, y)| Point::new(x, y)),
});
s
}
// Given a spot whose source patch lies outside the window, the window still shows it healed
#[test]
fn a_spot_with_its_source_outside_the_window_matches_the_whole_render() {
use lightcraft_develop::SpotMode;
// the target is at x ≈ 0.36 of the frame; its source is 0.12 of the width to the right
for mode in [SpotMode::Clone, SpotMode::Heal] {
let s = with_spot(mode, (0.40, 0.5), Some((0.12, 0.05)));
// a window that holds the target (x ≈ 384) but not the source (x ≈ 499..)
check_whole(&format!("{mode:?}"), &s, PixelWindow { x: 250, y: 200, w: 200, h: 200 }, 1, 0.1);
}
}
// Given a spot just outside the window whose feathered edge reaches in, the window shows it
#[test]
fn a_spot_overlapping_the_window_edge_matches_the_whole_render() {
let s = with_spot(lightcraft_develop::SpotMode::Heal, (0.30, 0.5), Some((-0.1, 0.0)));
check_whole("edge", &s, PixelWindow { x: 300, y: 200, w: 240, h: 240 }, 1, 0.1);
}
// Given a spot with an automatic source, two windows over the same spot choose the same source
#[test]
fn an_automatic_spot_source_does_not_depend_on_the_window() {
let s = with_spot(lightcraft_develop::SpotMode::Heal, (0.5, 0.5), None);
// windows so tight that the candidate sources can't all be inside them
let (a, b) = (PixelWindow { x: 430, y: 280, w: 110, h: 90 }, PixelWindow { x: 450, y: 290, w: 110, h: 90 });
let (ia, ib) = (window(&s, a), window(&s, b));
let (mut max, mut n) = (0u8, 0);
for y in 0..80 {
for x in 0..90 {
let (p, q) = (ia.get(x + 20, y + 10), ib.get(x, y));
for k in 0..3 {
max = max.max(p[k].abs_diff(q[k]));
n += 1;
}
}
}
assert!(n > 0 && max <= 2, "overlapping windows disagree by up to {max}");
}
// Given Auto Mask strokes that run in and out of the window, the window shows the whole render's mask
#[test]
fn auto_mask_strokes_match_the_whole_render() {
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let st = |pts: &[(f64, f64)], auto_mask, erase| BrushStroke {
points: pts.iter().map(|p| Point::new(p.0, p.1)).collect(),
size: 0.06,
feather: 40.0,
flow: 80.0,
auto_mask,
erase,
..Default::default()
};
let strokes = vec![st(&[(0.2, 0.55), (0.5, 0.6), (0.8, 0.5)], true, false), st(&[(0.5, 0.58)], true, true)];
let s = DevelopSettings {
masks: vec![Mask {
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }],
adjust: LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() },
..Default::default()
}],
..Default::default()
};
check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1);
}