From 07c3f0261c2fb2a3f34b19caae8deb4c371cfc18 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 02:34:40 -0400 Subject: [PATCH] M5.2d: geometry resample on the GPU The source is uploaded once per view (kept with the GPU stages) and resampled on the device: orientation as an integer pixel map, the Mitchell area prefilter with the CPU's exact taps, then one bilinear resample through the crop/straighten/flip affine or through the full warp (inverse perspective, manual distortion, embedded DNG WarpRectilinear per plane, lateral CA, manual and embedded vignetting gain, blank outside the source). `Frame::sample_plan` (new) decides prefilter size and mode for both renderers; sources larger than the device's buffer limit are still resampled on the CPU. Equivalence: all 8 orientations with and without crop/straighten/flip, embedded lens + perspective: max 1 LSB. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/ctx.rs | 7 ++ crates/gpu/src/render.rs | 187 ++++++++++++++++++++++++++++-- crates/gpu/src/wgsl/geom.wgsl | 195 ++++++++++++++++++++++++++++++++ crates/gpu/tests/equivalence.rs | 39 +++++++ crates/pipeline/src/geometry.rs | 83 ++++++++++---- 5 files changed, 476 insertions(+), 35 deletions(-) create mode 100644 crates/gpu/src/wgsl/geom.wgsl diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 4e359144..f8f36632 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -44,6 +44,11 @@ const MODULES: &[Module] = &[ bindings: &[("img", false, "f32"), ("log_l", false, "f32"), ("aux", false, "f32"), ("c", true, "f32"), ("alpha", true, "f32")], entries: &["shape", "combine", "finalize"], }, + Module { + src: include_str!("wgsl/geom.wgsl"), + bindings: &[("src", false, "f32"), ("dst", true, "f32")], + entries: &["orient", "sample_affine", "sample_warp"], + }, ]; struct Kernel { @@ -81,6 +86,8 @@ fn constants() -> String { } s += &format!("const TONE_MIN_EV: f32 = {LUT_MIN_EV:?};\nconst TONE_MAX_EV: f32 = {LUT_MAX_EV:?};\nconst TONE_N: u32 = {LUT_N}u;\n"); s += &format!("const TONE_MIN_GAIN: f32 = {:?};\n", 2f32.powf(LUT_MIN_EV)); + let b = lightcraft_pipeline::geometry::BLANK; + s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]); s += &format!("const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\n", crate::params::CURVE_N); for (i, h) in GRAIN_HASH.iter().enumerate() { s += &format!("const GRAIN_H{i}: u32 = {h}u;\n"); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 8be7fa2e..a7948d9a 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -5,7 +5,9 @@ use std::sync::{Arc, Mutex}; use lightcraft_develop::DevelopSettings; +use lightcraft_geom::Orientation; use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms}; +use lightcraft_pipeline::geometry::SampleMode; use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local}; use lightcraft_raster::resample::Filter; use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8}; @@ -18,6 +20,8 @@ use crate::params::{Present, finish_block}; #[derive(Default)] pub struct GpuStages { entries: Mutex>, + /// The uploaded source (a view renders one photo at several sizes). + source: Mutex, Arc)>>, } const CAPACITY: usize = 2; @@ -56,6 +60,19 @@ impl GpuStages { } } + fn source(&self, src: &Arc, upload: impl FnOnce() -> Buf) -> Arc { + let mut g = self.source.lock().unwrap_or_else(|e| e.into_inner()); + match &*g { + Some((s, b)) if Arc::ptr_eq(s, src) => b.clone(), + _ => { + *g = None; // free the previous photo's buffer first + let b = Arc::new(upload()); + *g = Some((src.clone(), b.clone())); + b + } + } + } + fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { self.entries.lock().unwrap_or_else(|e| e.into_inner()) } @@ -213,6 +230,127 @@ fn guided_fast(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, sigma: f32, eps: f3 q } +// --------------------------------------------------------------------------------------------- +// Geometry (`Frame::sample`) + +/// Integer pixel map `(x, y) → (m0·x + m1·y + m2, m3·x + m4·y + m5)` from an oriented image back +/// to the source of `w × h` (`Image::oriented`: optional horizontal flip, then quarter turns). +pub(crate) fn orient_map(o: Orientation, w: usize, h: usize) -> [i32; 6] { + let (flip, turns) = o.to_parts(); + let (w, h) = (w as i32, h as i32); + let mut ops: Vec [i32; 6]> = Vec::new(); + if flip { + ops.push(|w, _| [-1, 0, w - 1, 0, 1, 0]); + } + let cw: fn(i32, i32) -> [i32; 6] = |_, h| [0, 1, 0, -1, 0, h - 1]; + let half: fn(i32, i32) -> [i32; 6] = |w, h| [-1, 0, w - 1, 0, -1, h - 1]; + match turns { + 1 => ops.push(cw), + 2 => ops.push(half), + 3 => { + ops.push(half); + ops.push(cw); + } + _ => {} + } + // compose: T ← T ∘ op, tracking each op's input size + let mut t = [1, 0, 0, 0, 1, 0]; + let (mut cw_, mut ch_) = (w, h); + for op in ops { + let o = op(cw_, ch_); + t = [ + t[0] * o[0] + t[1] * o[3], + t[0] * o[1] + t[1] * o[4], + t[0] * o[2] + t[1] * o[5] + t[2], + t[3] * o[0] + t[4] * o[3], + t[3] * o[1] + t[4] * o[4], + t[3] * o[2] + t[4] * o[5] + t[5], + ]; + if o[0] == 0 { + (cw_, ch_) = (ch_, cw_); + } + } + t +} + +fn affine_bits(a: &lightcraft_geom::Affine) -> [u32; 6] { + a.0.map(|v| (v as f32).to_bits()) +} + +/// The `sample_warp` kernel's parameters (layout documented in `geom.wgsl`). +fn warp_params( + wp: &lightcraft_pipeline::optics::Warp, + base: (usize, usize), + out: (usize, usize), + o2t: &lightcraft_geom::Affine, + sx: f64, + sy: f64, +) -> Vec { + let f = |v: f64| (v as f32).to_bits(); + let mut p = vec![base.0 as u32, base.1 as u32, out.0 as u32, out.1 as u32]; + p.extend(affine_bits(o2t)); + p.extend([f(sx), f(sy), f(wp.w), f(wp.h)]); + let persp = wp.persp != lightcraft_geom::Homography::IDENTITY; + p.push(persp as u32); + p.extend(wp.persp_inv.0.map(f)); + p.push(f(wp.k1)); + p.extend(wp.ca.map(f)); + p.push(f(wp.lens_dist)); + let warp = wp.lens.and_then(|l| l.warp); + p.push(warp.is_some() as u32); + let w = warp.unwrap_or_default(); + p.extend(w.planes.iter().flatten().map(|v| f(*v))); + p.extend([f(w.center.x), f(w.center.y), f(w.radius)]); + p.extend([f(wp.vig_stops), f(wp.vig_power), f(wp.lens_vig)]); + let vig = wp.lens.and_then(|l| l.vignette); + p.push(vig.is_some() as u32); + let v = vig.unwrap_or_default(); + p.extend(v.k.map(f)); + p.extend([f(v.center.x), f(v.center.y), f(v.radius)]); + p.push(wp.per_channel() as u32); + p.push(wp.has_gain() as u32); + debug_assert_eq!(p.len(), 65); + p +} + +/// Resample the source into the output frame (`Frame::sample`). May return the source buffer +/// itself when the frame is the identity. +fn sample(cx: &mut Cx<'_>, src: &Arc, src_buf: Arc, plan: &Plan<'_>) -> Arc { + let (w, h) = (plan.w, plan.h); + let fr = &plan.frame; + let sp = fr.sample_plan(src.width, src.height, w, h); + let oriented = if fr.orient == Orientation::Normal { + src_buf + } else { + let out = cx.gpu.buffer(sp.ow * sp.oh * 3); + let mut p = vec![src.width as u32, src.height as u32, sp.ow as u32, sp.oh as u32]; + p.extend(orient_map(fr.orient, src.width, src.height).map(|v| v as u32)); + cx.run("orient", &p, &[Some(&src_buf), Some(&out)], groups2(sp.ow, sp.oh, [16, 16])); + Arc::new(out) + }; + let (base, (bw, bh)) = match sp.prefilter { + Some(d) => (Arc::new(resize(cx, &oriented, (sp.ow, sp.oh), d, 3, Filter::Mitchell)), d), + None => (oriented, (sp.ow, sp.oh)), + }; + match &sp.mode { + SampleMode::Copy => base, + SampleMode::Affine(xf) => { + let out = cx.gpu.buffer(w * h * 3); + let mut p = vec![bw as u32, bh as u32, w as u32, h as u32]; + p.extend(affine_bits(xf)); + cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16])); + Arc::new(out) + } + SampleMode::Warp(o2t) => { + let wp = fr.warp.as_ref().expect("warp mode has a warp"); + let out = cx.gpu.buffer(w * h * 3); + let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy); + cx.run("sample_warp", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16])); + Arc::new(out) + } + } +} + // --------------------------------------------------------------------------------------------- // The render @@ -248,7 +386,7 @@ pub fn render( let plan = lightcraft_pipeline::plan(src, info, s, req); let (w, h) = (plan.w, plan.h); let n = w * h; - if !gpu.fits(n * 3) || !gpu.fits(src.data.len() * 3) { + if !gpu.fits(n * 3) { return None; } let s = &*plan.settings; @@ -256,10 +394,23 @@ pub fn render( let mut host = Host::default(); let mut cx = Cx::new(gpu); - // 1. geometry + // 1. geometry (on the CPU when the source exceeds the device's buffer limit) let sampled = match &cached { Some(e) => e.sampled.clone(), - None => Arc::new(sample(&mut cx, src, &plan, &mut host)), + None if gpu.fits(src.data.len() * 3) => { + let upload = || gpu.upload(rgb_words(src)); + let src_buf = match stages { + Some(c) => c.source(src, upload), + None => Arc::new(upload()), + }; + sample(&mut cx, src, src_buf, &plan) + } + None => { + let img = plan.frame.sample(src, w, h); + let b = Arc::new(gpu.upload(rgb_words(&img))); + host.sampled = Some(img); + b + } }; lap("sample", &mut t); @@ -314,14 +465,6 @@ pub fn render( Some(Rendered { image, histogram }) } -/// Resample the source into the output frame. -fn sample(cx: &mut Cx<'_>, src: &Arc, plan: &Plan<'_>, host: &mut Host) -> Buf { - let img = plan.frame.sample(src, plan.w, plan.h); - let b = cx.gpu.upload(rgb_words(&img)); - host.sampled = Some(img); - b -} - /// The white-balanced, retouched, denoised image. fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, host: &mut Host) -> Buf { let (w, h) = (plan.w, plan.h); @@ -553,3 +696,25 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho let terms = list.iter().map(|m| mask_terms(&m.adjust)).collect(); (Some(alpha), terms) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn orient_map_matches_image_oriented() { + use Orientation::*; + let img = Rgb32f::from_fn(5, 3, |x, y| [x as f32, y as f32, 0.0]); + for o in [Normal, Rotate90, Rotate180, Rotate270, FlipH, Transverse, FlipV, Transpose] { + let r = img.oriented(o); + let m = orient_map(o, 5, 3); + for y in 0..r.height { + for x in 0..r.width { + let (x, y) = (x as i32, y as i32); + let (sx, sy) = (m[0] * x + m[1] * y + m[2], m[3] * x + m[4] * y + m[5]); + assert_eq!(r.get(x as usize, y as usize), img.get(sx as usize, sy as usize), "{o:?} at {x},{y}"); + } + } + } + } +} diff --git a/crates/gpu/src/wgsl/geom.wgsl b/crates/gpu/src/wgsl/geom.wgsl new file mode 100644 index 00000000..973f386d --- /dev/null +++ b/crates/gpu/src/wgsl/geom.wgsl @@ -0,0 +1,195 @@ +// Geometry (`lightcraft_pipeline::geometry::Frame::sample`): orientation, and the single bilinear +// resample through the crop/straighten/flip affine or the full lens + perspective warp. +// Bindings: src (rgb), dst (rgb). + +fn src_px(w: u32, h: u32, x: i32, y: i32) -> vec3 { + let cx = u32(clamp(x, 0, i32(w) - 1)); + let cy = u32(clamp(y, 0, i32(h) - 1)); + let j = 3u * (cy * w + cx); + return vec3(src[j], src[j + 1u], src[j + 2u]); +} + +// `Rgb32f::sample_bilinear` (pixel centres at +0.5, clamped edges). +fn bilinear(w: u32, h: u32, x: f32, y: f32) -> vec3 { + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = floor(fx); + let y0 = floor(fy); + let tx = fx - x0; + let ty = fy - y0; + let ix = i32(x0); + let iy = i32(y0); + let a = src_px(w, h, ix, iy); + let b = src_px(w, h, ix + 1, iy); + let c = src_px(w, h, ix, iy + 1); + let d = src_px(w, h, ix + 1, iy + 1); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; +} + +fn put(i: u32, v: vec3) { + dst[3u * i] = v.x; + dst[3u * i + 1u] = v.y; + dst[3u * i + 2u] = v.z; +} + +// Orientation: dst (ow × oh) pixel (x, y) = src pixel (P[4]·x + P[5]·y + P[6], P[7]·x + P[8]·y + P[9]) +// (integers). P: sw, sh, ow, oh, map[6]. +@compute @workgroup_size(16, 16) +fn orient(@builtin(global_invocation_id) g: vec3) { + let sw = pu(0u); + let ow = pu(2u); + let oh = pu(3u); + if (g.x >= ow || g.y >= oh) { + return; + } + let x = i32(g.x); + let y = i32(g.y); + let m = array(bitcast(pu(4u)), bitcast(pu(5u)), bitcast(pu(6u)), bitcast(pu(7u)), bitcast(pu(8u)), bitcast(pu(9u))); + let sx = u32(m[0] * x + m[1] * y + m[2]); + let sy = u32(m[3] * x + m[4] * y + m[5]); + let j = 3u * (sy * sw + sx); + put(g.y * ow + g.x, vec3(src[j], src[j + 1u], src[j + 2u])); +} + +// Bilinear resample through an affine (output px → base px). P: bw, bh, w, h, affine[6]. +@compute @workgroup_size(16, 16) +fn sample_affine(@builtin(global_invocation_id) g: vec3) { + let w = pu(2u); + let h = pu(3u); + if (g.x >= w || g.y >= h) { + return; + } + let px = f32(g.x) + 0.5; + let py = f32(g.y) + 0.5; + let x = pf(4u) * px + pf(6u) * py + pf(8u); + let y = pf(5u) * px + pf(7u) * py + pf(9u); + put(g.y * w + g.x, bilinear(pu(0u), pu(1u), x, y)); +} + +// --- the lens / perspective warp (`lightcraft_pipeline::optics::Warp`), in oriented-source px. +// P layout (see `geom_warp_params`): 0 bw, 1 bh, 2 w, 3 h, 4..10 output → transformed affine, +// 10 sx, 11 sy, 12 W, 13 H, 14 persp?, 15..24 persp_inv, 24 k1, 25..28 ca, 28 lens_dist, +// 29 warp?, 30..48 warp planes (3 × 6), 48 warp centre x, 49 y, 50 warp radius, 51 vig_stops, +// 52 vig_power, 53 lens_vig, 54 vignette?, 55..60 vignette k, 60 vignette centre x, 61 y, +// 62 vignette radius, 63 per-channel?, 64 gain? +const WP_N: u32 = 65u; + +fn centre() -> vec2 { + return vec2(pf(12u) / 2.0, pf(13u) / 2.0); +} + +fn to_corrected(p: vec2) -> vec2 { + if (pu(14u) == 0u) { + return p; + } + let c = centre(); + let l = max(pf(12u), pf(13u)) / 2.0; + let q = (p - c) / l; + var wq = pf(21u) * q.x + pf(22u) * q.y + pf(23u); + if (abs(wq) < 1e-30) { + wq = 1e-30; + } + let x = (pf(15u) * q.x + pf(16u) * q.y + pf(17u)) / wq; + let y = (pf(18u) * q.x + pf(19u) * q.y + pf(20u)) / wq; + return c + vec2(x, y) * l; +} + +fn embedded_warp(p: vec2, ch: u32) -> vec2 { + let w = pf(12u); + let h = pf(13u); + let cc = vec2(pf(48u) * w, pf(49u) * h); + let m = max(pf(50u) * max(w, h), 1e-9); + let o = 30u + 6u * min(ch, 2u); + let d = (p - cc) / m; + let r2 = d.x * d.x + d.y * d.y; + let f = pf(o) + r2 * (pf(o + 1u) + r2 * (pf(o + 2u) + r2 * pf(o + 3u))); + let kt0 = pf(o + 4u); + let kt1 = pf(o + 5u); + let sx = d.x * f + kt0 * 2.0 * d.x * d.y + kt1 * (r2 + 2.0 * d.x * d.x); + let sy = d.y * f + kt1 * 2.0 * d.x * d.y + kt0 * (r2 + 2.0 * d.y * d.y); + return cc + m * vec2(sx, sy); +} + +fn corrected_to_source(p: vec2, ch: u32) -> vec2 { + let c = centre(); + let hd = max(sqrt(pf(12u) * pf(12u) + pf(13u) * pf(13u)) / 2.0, 1e-9); + var q = p; + let k1 = pf(24u); + if (k1 != 0.0) { + let d = q - c; + let r2 = (d.x * d.x + d.y * d.y) / (hd * hd); + q = c + d * (1.0 + k1 * r2); + } + let ld = pf(28u); + if (ld != 0.0 && pu(29u) != 0u) { + let s = embedded_warp(q, ch); + q = q + (s - q) * ld; + } + let k = pf(25u + ch); + if (k != 0.0) { + q = c + (q - c) * (1.0 + k); + } + return q; +} + +fn warp_gain(s: vec2) -> f32 { + var g = 1.0; + let stops = pf(51u); + let w = pf(12u); + let h = pf(13u); + if (stops != 0.0) { + let hd = max(sqrt(w * w + h * h) / 2.0, 1e-9); + let r = min(length(s - centre()) / hd, 1.5); + g *= exp2(stops * pow(r, pf(52u))); + } + let lv = pf(53u); + if (lv != 0.0 && pu(54u) != 0u) { + let cc = vec2(pf(60u) * w, pf(61u) * h); + let m = max(pf(62u) * max(w, h), 1e-9); + let d = s - cc; + let r2 = (d.x * d.x + d.y * d.y) / (m * m); + var vg = 1.0; + var rp = r2; + for (var k = 0u; k < 5u; k++) { + vg += pf(55u + k) * rp; + rp *= r2; + } + g *= 1.0 + (vg - 1.0) * lv; + } + return g; +} + +@compute @workgroup_size(16, 16) +fn sample_warp(@builtin(global_invocation_id) g: vec3) { + let w = pu(2u); + let h = pu(3u); + if (g.x >= w || g.y >= h) { + return; + } + let bw = pu(0u); + let bh = pu(1u); + let px = f32(g.x) + 0.5; + let py = f32(g.y) + 0.5; + let t = vec2(pf(4u) * px + pf(6u) * py + pf(8u), pf(5u) * px + pf(7u) * py + pf(9u)); + let c = to_corrected(t); + let s = corrected_to_source(c, 1u); + let sx = pf(10u); + let sy = pf(11u); + if (!(s.x >= -0.5 && s.y >= -0.5 && s.x <= pf(12u) + 0.5 && s.y <= pf(13u) + 0.5)) { + put(g.y * w + g.x, vec3(BLANK_R, BLANK_G, BLANK_B)); + return; + } + var v = bilinear(bw, bh, s.x * sx, s.y * sy); + if (pu(63u) != 0u) { + let qr = corrected_to_source(c, 0u); + v.x = bilinear(bw, bh, qr.x * sx, qr.y * sy).x; + let qb = corrected_to_source(c, 2u); + v.z = bilinear(bw, bh, qb.x * sx, qb.y * sy).z; + } + if (pu(64u) != 0u) { + v = v * warp_gain(s); + } + put(g.y * w + g.x, v); +} diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 80c032c9..474d3404 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -297,6 +297,45 @@ fn rendered_sources_and_other_scenes() { } } +#[test] +fn geometry_variants() { + if !gpu() { + return; + } + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; + let src = scene(2, 900, 600); + let raw = SourceInfo { raw: true, ..Default::default() }; + use Orientation::*; + for o in [Normal, Rotate90, Rotate180, Rotate270, FlipH, Transverse, FlipV, Transpose] { + for (crop, req) in [(false, RenderRequest::fit(900, 900)), (true, RenderRequest::fit(500, 500))] { + let mut s = DevelopSettings { orientation: o, ..Default::default() }; + if crop { + s.crop.geometry.rect = Rect::new(0.2, 0.1, 0.9, 0.8); + s.crop.geometry.angle = -4.0; + s.crop.flip_v = true; + } + check(&format!("{o:?} crop={crop}"), &src, &raw, &s, &req); + } + } + // embedded DNG lens corrections (per-plane warp + vignette) with manual CA + let lens = EmbeddedLens { + warp: Some(EmbeddedWarp { + planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002], [1.0, -0.028, 0.01, 0.0, 0.001, -0.002], [1.0, -0.026, 0.01, 0.0, 0.001, -0.002]], + center: Point::new(0.52, 0.48), + radius: 0.6, + }), + vignette: Some(EmbeddedVignette { k: [0.4, -0.1, 0.02, 0.0, 0.0], center: Point::new(0.5, 0.5), radius: 0.6 }), + }; + let info = SourceInfo { lens: Some(lens), ..raw }; + let mut s = DevelopSettings::default(); + s.optics.lens_profile = true; + s.optics.ca_red = 30.0; + s.geometry.horizontal = -15.0; + check("embedded lens + perspective", &src, &info, &s, &RenderRequest::fit(700, 700)); + s.orientation = Rotate270; + check("embedded lens rotated", &src, &info, &s, &RenderRequest::fit(700, 700)); +} + #[test] fn cached_renders_match_uncached() { if !gpu() { diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index 5742c8ab..a8ed0cd2 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -177,37 +177,48 @@ impl Frame { Point::new(p.x * self.ow / l, p.y * self.oh / l) } + /// How [`Frame::sample`] resamples a (EXIF-oriented) source of `src_w × src_h` into `w × h`. + pub fn sample_plan(&self, src_w: usize, src_h: usize, w: usize, h: usize) -> SamplePlan { + let (ow, oh) = if self.orient.swaps_axes() { (src_h, src_w) } else { (src_w, src_h) }; + let crop_px = self.crop.rect_px(self.ow, self.oh); + let k = crop_px.width() / w as f64; + let prefilter = (k > 1.25).then(|| (((ow as f64 / k).round() as usize).max(1), ((oh as f64 / k).round() as usize).max(1))); + let (bw, bh) = prefilter.unwrap_or((ow, oh)); + let (sx, sy) = (bw as f64 / ow as f64, bh as f64 / oh as f64); + let mode = if self.warp.is_some() { + SampleMode::Warp(self.out_to_oriented(w, h)) + } else if self.crop.is_identity() && !self.flip_h && !self.flip_v && bw == w && bh == h { + SampleMode::Copy + } else { + SampleMode::Affine(Affine::scale(sx, sy) * self.out_to_oriented(w, h)) + }; + SamplePlan { ow, oh, prefilter, sx, sy, mode } + } + /// Sample the source into a `w × h` output buffer: orientation, crop, rotation, flips — one /// bilinear resample from a pre-filtered (area-downscaled) copy, so minification never aliases. pub fn sample(&self, src: &Rgb32f, w: usize, h: usize) -> Rgb32f { let oriented = if self.orient == Orientation::Normal { None } else { Some(src.oriented(self.orient)) }; let o = oriented.as_ref().unwrap_or(src); - let crop_px = self.crop.rect_px(self.ow, self.oh); - let k = crop_px.width() / w as f64; - let (base, s) = if k > 1.25 { - let nw = ((o.width as f64 / k).round() as usize).max(1); - let nh = ((o.height as f64 / k).round() as usize).max(1); - (resize(o, nw, nh, Filter::Mitchell), nw as f64 / o.width as f64) - } else { - (o.clone(), 1.0) + let plan = self.sample_plan(src.width, src.height, w, h); + let base = match plan.prefilter { + Some((nw, nh)) => std::borrow::Cow::Owned(resize(o, nw, nh, Filter::Mitchell)), + None => std::borrow::Cow::Borrowed(o), }; - let (sx, sy) = (s, base.height as f64 / o.height as f64); - if let Some(wp) = &self.warp { - return sample_warped(&base, sx, sy, wp, self.out_to_oriented(w, h), w, h); - } - let xf = Affine::scale(sx, sy) * self.out_to_oriented(w, h); - let identity_like = self.crop.is_identity() && !self.flip_h && !self.flip_v && base.width == w && base.height == h; - if identity_like { - return base; - } - let mut out = Rgb32f::new(w, h); - par_rows(&mut out.data, w, |y, row| { - for (x, px) in row.iter_mut().enumerate() { - let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - *px = base.sample_bilinear(p.x as f32, p.y as f32); + match plan.mode { + SampleMode::Copy => base.into_owned(), + SampleMode::Warp(o2t) => sample_warped(&base, plan.sx, plan.sy, self.warp.as_ref().expect("warp"), o2t, w, h), + SampleMode::Affine(xf) => { + let mut out = Rgb32f::new(w, h); + par_rows(&mut out.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + *px = base.sample_bilinear(p.x as f32, p.y as f32); + } + }); + out } - }); - out + } } /// The crop rectangle as a quad in normalized oriented coordinates (for overlays). @@ -221,6 +232,30 @@ impl Frame { } } +/// How a render resamples its source (see [`Frame::sample_plan`]). +#[derive(Clone, Debug)] +pub struct SamplePlan { + /// Oriented source size. + pub ow: usize, + pub oh: usize, + /// Size of the Mitchell prefilter (area downscale before the bilinear resample), if any. + pub prefilter: Option<(usize, usize)>, + /// Oriented-source px → prefiltered px. + pub sx: f64, + pub sy: f64, + pub mode: SampleMode, +} + +#[derive(Clone, Debug)] +pub enum SampleMode { + /// The (prefiltered) oriented source is the output. + Copy, + /// Bilinear through this affine (output px → prefiltered px). + Affine(Affine), + /// Through the warp; the affine maps output px → transformed px. + Warp(Affine), +} + /// The perspective homography of the settings (Upright, then the manual Transform sliders), lens-corrected → /// transformed, in centred coordinates. pub fn perspective(s: &DevelopSettings, ow: f64, oh: f64) -> Homography {