#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>
This commit is contained in:
Nathaniel Ledford
2026-10-08 13:53:08 -04:00
parent 4b83f8bb5e
commit 05618b2ad4
9 changed files with 168 additions and 26 deletions
+1 -2
View File
@@ -298,8 +298,7 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
let effective = s.effective();
let s: &DevelopSettings = &effective;
// the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU
// (a windowed render runs on the CPU until the kernels know the window: vignette and airlight)
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() || req.window.is_some() {
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() {
return None;
}
let gpu = device()?;
+3
View File
@@ -57,6 +57,8 @@ const FIELDS: &[(&str, usize)] = &[
("VIG_POWER", 1),
("VIG_HL", 1),
("VIG_STYLE", 1),
// the window's offset in the whole output and the whole output's size (pixels)
("VIG_VIEW", 4),
("GRAIN", 1),
("GRAIN_AMT", 1),
("GRAIN_SC", 1),
@@ -211,6 +213,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
p.f("VIG_ASPECT_MIX", v.aspect_mix);
p.f("VIG_POWER", v.power);
p.f("VIG_HL", v.highlights);
p.fs("VIG_VIEW", &fp.view);
p.u(
"VIG_STYLE",
match v.style {
+14 -3
View File
@@ -579,7 +579,11 @@ pub fn render(
Some(p) if p.key == plan.lin_key => p,
_ => Planes { key: plan.lin_key, ..Default::default() },
};
let prep = prepare(&mut cx, &lin, &plan, req, &mut planes);
let mut prep = prepare(&mut cx, &lin, &plan, req, &mut planes);
// a window dehazes with the whole frame's airlight
if let Some(a) = plan.fixed_air {
prep.air = a;
}
lap("planes", &mut t, &mut cx);
if let Some(c) = stages {
c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes });
@@ -650,7 +654,10 @@ pub fn render(
fail(FailKind::Fatal, format!("the GPU returned an incomplete image ({unwritten} of {n} pixels unwritten)"));
return None;
}
let histogram = Histogram::of_srgb8(&image);
let histogram = match plan.keep {
Some(k) => Histogram::of_srgb8(&image.crop(k.x, k.y, k.w, k.h)),
None => Histogram::of_srgb8(&image),
};
lap("histogram", &mut t, &mut cx);
// An entirely black result (upstream work that did not run would give that too): redo it on
// the CPU, which costs time only for the rare photo that really is black.
@@ -669,6 +676,10 @@ pub fn render(
}
});
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
// spots grew the window the render worked on: cut it back to the request
if let Some(k) = plan.keep {
image = image.crop(k.x, k.y, k.w, k.h);
}
Some(Rendered { image, histogram, deep: None })
}
@@ -713,7 +724,7 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho
fn denoise(cx: &mut Cx<'_>, img: Buf, plan: &Plan<'_>) -> Buf {
let (w, h) = (plan.w, plan.h);
let n = w * h;
let (lum, col) = local::nr_params(&plan.settings, plan.src_long, w.max(h));
let (lum, col) = local::nr_params(&plan.settings, plan.src_long, plan.frame.output_long(w, h));
let mut img = img;
if let Some(nr) = lum {
let l = cx.gpu.buffer(n);
+5 -4
View File
@@ -432,14 +432,15 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
// --- vignette (display linear, post-crop)
if (pu(F_VIG) != 0u) {
let fw = f32(w);
let fh = f32(h);
// the vignette belongs to the whole output frame, not to the window being drawn
let fw = pf(F_VIG_VIEW + 2u);
let fh = pf(F_VIG_VIEW + 3u);
let aspect = fw / fh;
let amount = pf(F_VIG_AMOUNT);
let mixa = pf(F_VIG_ASPECT_MIX);
let power = pf(F_VIG_POWER);
let u = (f32(x) + 0.5) / fw * 2.0 - 1.0;
let vv = (f32(y) + 0.5) / fh * 2.0 - 1.0;
let u = (pf(F_VIG_VIEW) + f32(x) + 0.5) / fw * 2.0 - 1.0;
let vv = (pf(F_VIG_VIEW + 1u) + f32(y) + 0.5) / fh * 2.0 - 1.0;
let sx = 1.0 + (aspect - 1.0) * mixa;
let sy = 1.0 + (1.0 / aspect - 1.0) * mixa;
let ax = abs(u * max(sx, 1.0) / max(sx, sy));
+41
View File
@@ -660,3 +660,44 @@ fn tint_directions_match_cpu_for_raw_and_rendered_sources() {
}
}
}
/// Issue #323: windows of a zoomed frame. The GPU renders the window like the CPU does: the
/// vignette and airlight belong to the whole frame, spots grow the window they work on.
#[test]
fn windows_match() {
if !gpu() {
return;
}
use lightcraft_pipeline::PixelWindow;
let src = scene(1, 960, 640);
let info = SourceInfo { raw: true, ..Default::default() };
let win = PixelWindow { x: 1200, y: 800, w: 640, h: 480 };
let req = RenderRequest { window: Some(win), ..RenderRequest::fit(3000, 2000) };
let mut cases = cases();
cases.push(("vignette at the corner", |s| {
s.vignette.amount = -80.0;
s.vignette.midpoint = 10.0;
}));
cases.push(("spot reading from outside", |s| {
s.spots.push(Spot {
points: vec![Point::new(0.45, 0.45)],
size: 0.03,
feather: 30.0,
opacity: 100.0,
source_offset: Some(Point::new(0.15, 0.0)),
..Default::default()
});
}));
for (name, edit) in cases {
let mut s = DevelopSettings::default();
edit(&mut s);
let name = format!("window: {name}");
check(&name, &src, &info, &s, &req);
}
// the corner window (where the vignette is strong)
let mut s = DevelopSettings::default();
s.vignette.amount = -80.0;
s.vignette.midpoint = 10.0;
let corner = RenderRequest { window: Some(PixelWindow { x: 2360, y: 1520, w: 640, h: 480 }), ..RenderRequest::fit(3000, 2000) };
check("window: vignette corner", &src, &info, &s, &corner);
}
+2 -2
View File
@@ -329,7 +329,7 @@ pub struct Plan<'a> {
/// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame).
pub fixed_air: Option<f32>,
/// A windowed render works on a larger window when spots reach into it (see
/// [`spots::window_for_spots`]); this is the requested window inside the rendered one, which
/// [`spots::window_for_reads`]); this is the requested window inside the rendered one, which
/// the result is cut to.
pub keep: Option<PixelWindow>,
}
@@ -369,7 +369,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph));
}
// (spots grow the rendered window; only 8-bit renders are cut back to the request)
let work = if req.depth == OutputDepth::U8 { spots::window_for_spots(&s.spots, &frame, full_w, full_h, px_per_long, win) } else { win };
let work = if req.depth == OutputDepth::U8 { spots::window_for_reads(s, &frame, full_w, full_h, px_per_long, win) } else { win };
if work != win {
keep = Some(PixelWindow { x: win.x - work.x, y: win.y - work.y, ..win });
}
+5 -3
View File
@@ -336,11 +336,13 @@ pub fn auto_similarity(l: f32, ch: [f32; 3], rl: f32, rch: [f32; 3]) -> f32 {
1.0 - smooth(0.5, 1.0, (dl * dl + dc * dc).sqrt())
}
/// The output pixel a dab at `d` samples its reference colour from.
/// The output pixel a dab at `d` samples its reference colour from. The position is taken at f32
/// precision (like the GPU kernel): a dab on a pixel boundary must not fall on either side of it
/// by an f64 rounding error, which depends on where the window of a zoomed view starts.
#[inline]
pub fn dab_pixel(d: Point, w: usize, h: usize) -> usize {
let x = (d.x.floor().max(0.0) as usize).min(w - 1);
let y = (d.y.floor().max(0.0) as usize).min(h - 1);
let x = ((d.x as f32).floor().max(0.0) as usize).min(w - 1);
let y = ((d.y as f32).floor().max(0.0) as usize).min(h - 1);
y * w + x
}
+71 -12
View File
@@ -109,19 +109,38 @@ pub fn pick_source(
/// The most a window grows to take in the spots that touch it.
const MAX_SPOT_SPAN: usize = 8192;
/// `win` (a window of the `full_w × full_h` output of `frame`) grown to hold, for every spot that
/// reaches into it, the whole spot and the patch it reads from: [`apply`] works on the pixels it
/// `win` (a window of the `full_w × full_h` output of `frame`) grown to hold, for every spot (or
/// Auto Mask stroke) that reaches into it, the whole spot and the patch it reads from: [`apply`] works on the pixels it
/// is given, so a spot cut by the window's edge, or whose source lies outside it, would come out
/// different from the same spot in the whole render. Spots with an automatic source (`None`)
/// search up to 4.2 radii around themselves. Growth stops at [`MAX_SPOT_SPAN`] pixels.
pub fn window_for_spots(spots: &[Spot], frame: &Frame, full_w: usize, full_h: usize, ppl: f64, win: crate::PixelWindow) -> crate::PixelWindow {
if spots.is_empty() {
pub fn window_for_reads(
s: &lightcraft_develop::DevelopSettings,
frame: &Frame,
full_w: usize,
full_h: usize,
ppl: f64,
win: crate::PixelWindow,
) -> crate::PixelWindow {
let spots = &s.spots;
let autos: Vec<&lightcraft_develop::BrushStroke> = s
.masks
.iter()
.flat_map(|m| &m.components)
.filter_map(|c| match &c.shape {
lightcraft_develop::MaskShape::Brush { strokes } => Some(strokes),
_ => None,
})
.flatten()
.filter(|st| st.auto_mask)
.collect();
if spots.is_empty() && autos.is_empty() {
return win;
}
let to_out = frame.norm_to_out(full_w, full_h);
let origin = to_out.apply(Point::new(0.0, 0.0));
// (target box, source box) of each spot, in output pixels: [x0, y0, x1, y1]
let boxes: Vec<([f64; 4], [f64; 4])> = spots
let mut boxes: Vec<([f64; 4], [f64; 4])> = spots
.iter()
.filter_map(|spot| {
let r = (spot.size * ppl).max(1.0);
@@ -144,6 +163,21 @@ pub fn window_for_spots(spots: &[Spot], frame: &Frame, full_w: usize, full_h: us
Some((t, s)).filter(|(t, s)| t.iter().chain(s).all(|v| v.is_finite()))
})
.collect();
// Auto Mask strokes weigh each dab by its similarity to the pixel under the dab's centre, and
// refine the stroke over a few radii: a stroke reaching into the window needs all of that
for st in autos {
let r = (st.size * ppl).max(0.5);
let pts: Vec<_> = st.points.iter().map(|p| to_out.apply(*p)).collect();
let Some(first) = pts.first() else { continue };
let (mut x0, mut y0, mut x1, mut y1) = (first.x, first.y, first.x, first.y);
for p in &pts {
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
}
let b = [x0 - 2.0 * r, y0 - 2.0 * r, x1 + 2.0 * r, y1 + 2.0 * r];
if b.iter().all(|v| v.is_finite()) {
boxes.push((b, b));
}
}
let (fw, fh) = (full_w as f64, full_h as f64);
let mut r = [win.x as f64, win.y as f64, (win.x + win.w) as f64, (win.y + win.h) as f64];
let touches = |r: &[f64; 4], b: &[f64; 4]| b[0] < r[2] && b[2] > r[0] && b[1] < r[3] && b[3] > r[1];
@@ -310,6 +344,10 @@ mod tests {
assert!(img.get(5, 50)[0] < 0.2);
}
fn with_spots(spots: Vec<Spot>) -> DevelopSettings {
DevelopSettings { spots, ..Default::default() }
}
fn spot_at(x: f64, y: f64, size: f64, offset: Option<Point>) -> Spot {
Spot { points: vec![Point::new(x, y)], size, source_offset: offset, ..Default::default() }
}
@@ -321,17 +359,17 @@ mod tests {
let ppl = frame.px_per_long(4000);
let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 };
// none: the window is left alone
assert_eq!(window_for_spots(&[], &frame, 4000, 3000, ppl, win), win);
assert_eq!(window_for_reads(&with_spots(vec![]), &frame, 4000, 3000, ppl, win), win);
// a spot far away is no business of this window
let far = spot_at(0.9, 0.9, 0.01, Some(Point::new(0.02, 0.0)));
assert_eq!(window_for_spots(&[far], &frame, 4000, 3000, ppl, win), win);
assert_eq!(window_for_reads(&with_spots(vec![far]), &frame, 4000, 3000, ppl, win), win);
// a spot inside it, reading from 300 px to the right, widens it to the source
let near = spot_at(0.30, 0.38, 0.01, Some(Point::new(0.075, 0.0)));
let g = window_for_spots(&[near], &frame, 4000, 3000, ppl, win);
let g = window_for_reads(&with_spots(vec![near]), &frame, 4000, 3000, ppl, win);
assert!(g.x <= win.x && g.x + g.w >= 1200 + 300 + 80 && g.y <= win.y && g.y + g.h >= win.y + win.h, "{g:?}");
// a spot that only reaches in with its edge counts too
let edge = spot_at(0.24, 0.38, 0.01, Some(Point::new(-0.05, 0.0)));
assert!(window_for_spots(&[edge], &frame, 4000, 3000, ppl, win).x < win.x);
assert!(window_for_reads(&with_spots(vec![edge]), &frame, 4000, 3000, ppl, win).x < win.x);
}
#[test]
@@ -342,14 +380,35 @@ mod tests {
let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 };
// a spot whose source is a long way off would need an enormous window: not granted
let wide = spot_at(0.03, 0.04, 0.01, Some(Point::new(0.6, 0.0)));
let g = window_for_spots(&[wide], &frame, 40_000, 30_000, ppl, win);
let g = window_for_reads(&with_spots(vec![wide]), &frame, 40_000, 30_000, ppl, win);
assert!(g.w <= MAX_SPOT_SPAN && g.h <= MAX_SPOT_SPAN, "{g:?}");
for bad in [f64::NAN, f64::INFINITY, 1e300, -1e300] {
let s = spot_at(bad, bad, bad, Some(Point::new(bad, bad)));
let g = window_for_spots(&[s], &frame, 40_000, 30_000, ppl, win);
let g = window_for_reads(&with_spots(vec![s]), &frame, 40_000, 30_000, ppl, win);
assert_eq!(g, win);
}
let empty = Spot { points: vec![], ..Default::default() };
assert_eq!(window_for_spots(&[empty], &frame, 40_000, 30_000, ppl, win), win);
assert_eq!(window_for_reads(&with_spots(vec![empty]), &frame, 40_000, 30_000, ppl, win), win);
}
// Given an Auto Mask stroke reaching into the window, the window grows to hold all of it
#[test]
fn a_window_grows_to_hold_an_auto_mask_stroke() {
use crate::PixelWindow;
use lightcraft_develop::{BrushStroke, Mask, MaskComponent, MaskOp, MaskShape};
let frame = Frame::new(4000, 3000, &DevelopSettings::default(), true);
let ppl = frame.px_per_long(4000);
let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 };
let stroke =
|auto| BrushStroke { points: vec![Point::new(0.30, 0.38), Point::new(0.5, 0.38)], size: 0.01, auto_mask: auto, ..Default::default() };
let mask = |auto| Mask {
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke(auto)] } }],
..Default::default()
};
let plain = DevelopSettings { masks: vec![mask(false)], ..Default::default() };
assert_eq!(window_for_reads(&plain, &frame, 4000, 3000, ppl, win), win, "a plain stroke reads nothing around it");
let auto = DevelopSettings { masks: vec![mask(true)], ..Default::default() };
let g = window_for_reads(&auto, &frame, 4000, 3000, ppl, win);
assert!(g.x + g.w >= 2000 && g.x <= 1200, "{g:?}");
}
}
+26
View File
@@ -315,3 +315,29 @@ fn an_automatic_spot_source_does_not_depend_on_the_window() {
}
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);
}