#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:
parent
ca5c212d03
commit
b358022f14
|
|
@ -108,6 +108,7 @@ impl WireJob {
|
|||
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
|
||||
depth: lightcraft_engine::pipeline::OutputDepth::U8,
|
||||
proof: None,
|
||||
window: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue