#323: pipeline: render a window of the output (RenderRequest::window)

A zoomed view needs the pixels it shows at the zoom scale, not a whole-frame
texture stretched over them. RenderRequest gains an optional PixelWindow of the
virtual full output that max_w x max_h describes; only the window is
materialized. It is Frame:🪟 the crop rectangle narrows to the window
(flips as drawn), so sampling, masks, spots, red eye and the lens warp need no
change. The post-crop vignette keeps the whole frame's geometry (Frame::view)
and dehaze uses the whole frame's airlight (estimated on a 384 px render), so a
window matches the same pixels of the full render. The GPU renderer declines
windowed requests (CPU for now).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford 2026-10-08 12:55:39 -04:00
parent ca5c212d03
commit b358022f14
No known key found for this signature in database
7 changed files with 256 additions and 8 deletions

View File

@ -108,6 +108,7 @@ impl WireJob {
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
depth: lightcraft_engine::pipeline::OutputDepth::U8,
proof: None,
window: None,
}
}

View File

@ -298,7 +298,8 @@ 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
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() {
// (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() {
return None;
}
let gpu = device()?;

View File

@ -190,6 +190,8 @@ pub struct FinishParams {
pub w: usize,
pub h: usize,
pub px_per_long: f64,
/// The window's offset and the whole output's size (pixels): `(0, 0, w, h)` unless windowed.
pub view: [f32; 4],
}
impl FinishParams {
@ -255,6 +257,7 @@ impl FinishParams {
w,
h,
px_per_long,
view: frame.view.map_or([0.0, 0.0, w as f32, h as f32], |v| [v.x as f32, v.y as f32, v.full_w as f32, v.full_h as f32]),
}
}
}
@ -344,7 +347,9 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect();
let out_to_norm = fp.out_to_norm;
let long = fp.ow.max(fp.oh);
let aspect = w as f32 / h as f32;
let [vx, vy, vw, vh] = fp.view;
// the vignette belongs to the whole output frame, not to the window being drawn
let aspect = vw / vh;
let srgb = srgb_lut();
let mut out = vec![T::default(); w * h];
@ -502,8 +507,8 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
// --- vignette (display linear, post-crop)
if let Some(v) = vig {
let u = (x as f32 + 0.5) / w as f32 * 2.0 - 1.0;
let vv = (y as f32 + 0.5) / h as f32 * 2.0 - 1.0;
let u = (vx + x as f32 + 0.5) / vw * 2.0 - 1.0;
let vv = (vy + y as f32 + 0.5) / vh * 2.0 - 1.0;
let sx = 1.0 + (aspect - 1.0) * v.aspect_mix;
let sy = 1.0 + (1.0 / aspect - 1.0) * v.aspect_mix;
let (ax, ay) = ((u * sx.max(1.0) / sx.max(sy)).abs(), (vv * sy.max(1.0) / sx.max(sy)).abs());

View File

@ -31,6 +31,18 @@ pub struct Frame {
pub flip_v: bool,
/// Lens corrections / perspective (None = identity).
pub warp: Option<Warp>,
/// Set when this frame draws only a window of a larger output (see [`Frame::window`]).
pub view: Option<ViewWindow>,
}
/// Where a windowed [`Frame`]'s pixels sit in the full output: its offset and the full output's
/// size, for effects defined by the whole frame (post-crop vignette).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewWindow {
pub x: f64,
pub y: f64,
pub full_w: f64,
pub full_h: f64,
}
impl Frame {
@ -62,6 +74,7 @@ impl Frame {
flip_h: apply_crop && s.crop.flip_h,
flip_v: apply_crop && s.crop.flip_v,
warp: (!warp.is_identity()).then_some(warp),
view: None,
};
if apply_crop && s.geometry.constrain_crop {
f.constrain_to_image();
@ -97,6 +110,23 @@ impl Frame {
self.crop.rect = Rect::from_center(r.center(), r.width() * t, r.height() * t);
}
/// This frame narrowed to `win` of its `full_w × full_h` output: the same mapping, but the
/// output is only that window (the crop rectangle shrinks to it; flips are as drawn).
pub fn window(&self, full_w: usize, full_h: usize, win: crate::PixelWindow) -> Frame {
let (fw, fh) = (full_w.max(1) as f64, full_h.max(1) as f64);
let (x0, x1) = (win.x as f64 / fw, (win.x + win.w) as f64 / fw);
let (y0, y1) = (win.y as f64 / fh, (win.y + win.h) as f64 / fh);
// flips happen on the output before it maps onto the crop: undo them for the range
let (x0, x1) = if self.flip_h { (1.0 - x1, 1.0 - x0) } else { (x0, x1) };
let (y0, y1) = if self.flip_v { (1.0 - y1, 1.0 - y0) } else { (y0, y1) };
let r = self.crop.rect;
let (rw, rh) = (r.width(), r.height());
let mut f = self.clone();
f.crop.rect = Rect { x0: r.x0 + x0 * rw, y0: r.y0 + y0 * rh, x1: r.x0 + x1 * rw, y1: r.y0 + y1 * rh };
f.view = Some(ViewWindow { x: win.x as f64, y: win.y as f64, full_w: fw, full_h: fh });
f
}
/// Add automatically estimated lateral CA (`[α_R, α_B]`, see [`crate::optics::estimate_lateral_ca`]).
pub fn add_lateral_ca(&mut self, ca: [f64; 2]) {
if ca == [0.0; 2] {

View File

@ -99,6 +99,31 @@ pub struct RenderRequest {
pub depth: OutputDepth,
/// Soft proofing (CPU only; see [`Proof`]).
pub proof: Option<Proof>,
/// Render only this window of the output that `max_w × max_h` describes (a zoomed view):
/// the result is that window's pixels, as in the whole render. See [`PixelWindow`].
pub window: Option<PixelWindow>,
}
/// A window of the (virtual) full output, in its pixels: what a zoomed view needs, rendered
/// without materializing the whole frame. The output frame is the one `max_w × max_h` fits, after
/// crop, straighten and flips, as drawn. Out-of-range windows are clamped into it. Spatial stages
/// (clarity, dehaze, sharpening…) read their neighbours, so ask for a margin around what is kept.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PixelWindow {
pub x: usize,
pub y: usize,
pub w: usize,
pub h: usize,
}
impl PixelWindow {
/// This window inside a `full_w × full_h` frame: at least one pixel, wholly inside.
pub fn clamped(self, full_w: usize, full_h: usize) -> PixelWindow {
let (fw, fh) = (full_w.max(1), full_h.max(1));
let x = self.x.min(fw - 1);
let y = self.y.min(fh - 1);
PixelWindow { x, y, w: self.w.clamp(1, fw - x), h: self.h.clamp(1, fh - y) }
}
}
impl RenderRequest {
@ -112,6 +137,7 @@ impl RenderRequest {
space: OutputSpace::Srgb,
depth: OutputDepth::U8,
proof: None,
window: None,
}
}
}
@ -300,8 +326,13 @@ pub struct Plan<'a> {
pub lin_key: u64,
/// Red eye / pet eye corrections with their detected pupils, in output pixels.
pub eyes: Vec<redeye::EyeK>,
/// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame).
pub fixed_air: Option<f32>,
}
/// Long edge of the small render a windowed render estimates the whole frame's airlight from.
const AIRLIGHT_PROXY_EDGE: usize = 384;
/// Resolve `s` against `src` for `req` (see [`Plan`]).
pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &RenderRequest) -> Plan<'a> {
let mut settings: Cow<'a, DevelopSettings> = match profiles::effective(s) {
@ -311,8 +342,22 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
visualize::adjust_settings(req.overlay, &mut settings);
let s = &*settings;
let frame = frame_for(src, info, s, req.apply_crop);
let (w, h) = frame.fit(req.max_w, req.max_h);
let px_per_long = frame.px_per_long(w);
let (full_w, full_h) = frame.fit(req.max_w, req.max_h);
// sizes and scale are those of the whole output; a window only narrows what is drawn
let px_per_long = frame.px_per_long(full_w);
let mut fixed_air = None;
let (frame, w, h) = match req.window {
Some(win) => {
let win = win.clamped(full_w, full_h);
if s.section_enabled("effects") && (s.effects.dehaze != 0.0 || s.masks.iter().any(|m| m.adjust.dehaze != 0.0)) {
let k = (AIRLIGHT_PROXY_EDGE as f64 / full_w.max(full_h) as f64).min(1.0);
let (pw, ph) = (((full_w as f64 * k).round() as usize).max(1), ((full_h as f64 * k).round() as usize).max(1));
fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph));
}
(frame.window(full_w, full_h, win), win.w, win.h)
}
None => (frame, full_w, full_h),
};
let src_long = src.width.max(src.height);
let geo = hash_of((format!("{frame:?}"), w, h));
let (wb_t, wb_tint) = local::effective_wb(info, s);
@ -328,7 +373,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
[d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits),
src_long,
));
Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes }
Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air }
}
/// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal).
@ -410,7 +455,10 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
Some(p) if p.key == lin_key => p,
_ => local::Planes { key: lin_key, ..Default::default() },
};
let prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes);
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes);
if let Some(a) = plan.fixed_air {
prep.air = a;
}
lap("prepare", &mut t);
if let Some((a, c)) = shared {
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes });
@ -510,3 +558,5 @@ mod tests;
mod tests_geometry;
#[cfg(test)]
mod tests_local;
#[cfg(test)]
mod tests_window;

View File

@ -328,6 +328,15 @@ pub(crate) fn prepare(img: Arc<Rgb32f>, s: &DevelopSettings, frame: &Frame, px_p
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long }
}
/// The airlight of the whole output frame, estimated on a small render of it (`proxy_w` px wide):
/// a windowed render must dehaze with the frame's airlight, not that of the pixels it holds.
pub(crate) fn frame_airlight(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, frame: &Frame, proxy_w: usize, proxy_h: usize) -> f32 {
let mut img = frame.sample(src, proxy_w, proxy_h);
white_balance(&mut img, info, s);
let sigma = (0.02 * frame.px_per_long(proxy_w)).max(1.0) as f32;
airlight(&gaussian(&img.map(dark_of), sigma))
}
/// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel.
pub const AIRLIGHT_STEP: usize = 7;

View File

@ -0,0 +1,152 @@
//! 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) {
let (mut max, mut sum, mut n) = (0u8, 0f64, 0f64);
for y in HALO..at.h - HALO {
for x in HALO..at.w - HALO {
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);
}
}