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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 02:34:40 -04:00
parent 2dfd65b39a
commit 07c3f0261c
5 changed files with 476 additions and 35 deletions

View File

@ -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");

View File

@ -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<Vec<Entry>>,
/// The uploaded source (a view renders one photo at several sizes).
source: Mutex<Option<(Arc<Rgb32f>, Arc<Buf>)>>,
}
const CAPACITY: usize = 2;
@ -56,6 +60,19 @@ impl GpuStages {
}
}
fn source(&self, src: &Arc<Rgb32f>, upload: impl FnOnce() -> Buf) -> Arc<Buf> {
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<Entry>> {
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<fn(i32, i32) -> [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<u32> {
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<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>) -> Arc<Buf> {
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<Rgb32f>, 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}");
}
}
}
}
}

View File

@ -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<f32> {
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<f32>(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<f32> {
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<f32>) {
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<u32>) {
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<i32, 6>(bitcast<i32>(pu(4u)), bitcast<i32>(pu(5u)), bitcast<i32>(pu(6u)), bitcast<i32>(pu(7u)), bitcast<i32>(pu(8u)), bitcast<i32>(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<f32>(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<u32>) {
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<f32> {
return vec2<f32>(pf(12u) / 2.0, pf(13u) / 2.0);
}
fn to_corrected(p: vec2<f32>) -> vec2<f32> {
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<f32>(x, y) * l;
}
fn embedded_warp(p: vec2<f32>, ch: u32) -> vec2<f32> {
let w = pf(12u);
let h = pf(13u);
let cc = vec2<f32>(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<f32>(sx, sy);
}
fn corrected_to_source(p: vec2<f32>, ch: u32) -> vec2<f32> {
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>) -> 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<f32>(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<u32>) {
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<f32>(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<f32>(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);
}

View File

@ -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() {

View File

@ -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 {