926 lines
37 KiB
Rust
926 lines
37 KiB
Rust
//! The GPU renderer: the CPU pipeline's stages, evaluated on the device where a kernel exists and
|
||
//! on the CPU otherwise (per-stage hybrid). Stage results stay on the device and are cached per
|
||
//! view exactly like [`lightcraft_pipeline::StageCache`] (same keys, see [`lightcraft_pipeline::Plan`]).
|
||
|
||
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};
|
||
|
||
use crate::ctx::{Buf, Gpu, groups1, groups2};
|
||
use crate::params::{Present, finish_block};
|
||
|
||
/// Device-resident stages of one view, kept next to the CPU [`lightcraft_pipeline::StageCache`]
|
||
/// (as its extension). Holds the last two output sizes, like the CPU cache.
|
||
#[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;
|
||
|
||
#[derive(Clone)]
|
||
struct Entry {
|
||
src: Arc<Rgb32f>,
|
||
geo: u64,
|
||
sampled: Arc<Buf>,
|
||
lin: Option<(u64, Arc<Buf>)>,
|
||
planes: Planes,
|
||
}
|
||
|
||
/// Spatial planes of one linear image (`key` = its `lin_key`), each tagged with its radius.
|
||
#[derive(Clone, Default)]
|
||
struct Planes {
|
||
key: u64,
|
||
log_l: Option<Arc<Buf>>,
|
||
base: Option<(u32, Arc<Buf>)>,
|
||
clarity: Option<(u32, Arc<Buf>)>,
|
||
texture: Option<(u32, Arc<Buf>)>,
|
||
dark: Option<(u32, Arc<Buf>, f32)>,
|
||
chroma: Option<(u32, Arc<Buf>)>,
|
||
}
|
||
|
||
impl GpuStages {
|
||
fn get(&self, src: &Arc<Rgb32f>, geo: u64) -> Option<Entry> {
|
||
self.lock().iter().find(|e| e.geo == geo && Arc::ptr_eq(&e.src, src)).cloned()
|
||
}
|
||
|
||
fn put(&self, e: Entry) {
|
||
let mut v = self.lock();
|
||
v.retain(|o| !(o.geo == e.geo && Arc::ptr_eq(&o.src, &e.src)));
|
||
v.push(e);
|
||
while v.len() > CAPACITY {
|
||
v.remove(0);
|
||
}
|
||
}
|
||
|
||
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())
|
||
}
|
||
|
||
/// Number of cached output sizes.
|
||
pub fn len(&self) -> usize {
|
||
self.lock().len()
|
||
}
|
||
|
||
/// Device bytes held (each buffer counted once).
|
||
pub fn bytes(&self) -> usize {
|
||
let mut seen: Vec<*const Buf> = Vec::new();
|
||
let mut total = 0;
|
||
let mut add = |b: &Arc<Buf>| {
|
||
let p = Arc::as_ptr(b);
|
||
if !seen.contains(&p) {
|
||
seen.push(p);
|
||
total += b.len * 4;
|
||
}
|
||
};
|
||
for e in self.lock().iter() {
|
||
add(&e.sampled);
|
||
if let Some((_, b)) = &e.lin {
|
||
add(b);
|
||
}
|
||
let p = &e.planes;
|
||
p.log_l.iter().for_each(&mut add);
|
||
p.base.iter().for_each(|(_, b)| add(b));
|
||
p.clarity.iter().for_each(|(_, b)| add(b));
|
||
p.texture.iter().for_each(|(_, b)| add(b));
|
||
p.dark.iter().for_each(|(_, b, _)| add(b));
|
||
}
|
||
if let Some((_, b)) = &*self.source.lock().unwrap_or_else(|e| e.into_inner()) {
|
||
add(b);
|
||
}
|
||
total
|
||
}
|
||
|
||
/// Drop everything (e.g. when memory is needed).
|
||
pub fn clear(&self) {
|
||
self.lock().clear();
|
||
*self.source.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.len() == 0
|
||
}
|
||
}
|
||
|
||
/// A render in progress: the device and the commands recorded so far. Reading a result back
|
||
/// submits everything recorded before it.
|
||
pub(crate) struct Cx<'a> {
|
||
pub gpu: &'a Gpu,
|
||
enc: Option<wgpu::CommandEncoder>,
|
||
}
|
||
|
||
impl<'a> Cx<'a> {
|
||
pub fn new(gpu: &'a Gpu) -> Cx<'a> {
|
||
Cx { gpu, enc: Some(gpu.encoder()) }
|
||
}
|
||
|
||
/// Record kernel `name` (see [`Gpu::run`]).
|
||
pub fn run(&mut self, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) {
|
||
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
|
||
self.gpu.run(enc, name, p, bufs, groups);
|
||
}
|
||
|
||
/// Submit what is recorded and read back `len` values of `b`.
|
||
pub fn read<T: bytemuck::Pod>(&mut self, b: &Buf, len: usize) -> Vec<T> {
|
||
let enc = self.enc.take().unwrap_or_else(|| self.gpu.encoder());
|
||
self.gpu.finish_and_read(enc, b, len)
|
||
}
|
||
|
||
/// Submit what is recorded and wait for it (profiling only: stage timings).
|
||
pub fn sync(&mut self) {
|
||
if let Some(enc) = self.enc.take() {
|
||
self.gpu.submit([enc.finish()]);
|
||
}
|
||
let _ = self.gpu.device.poll(wgpu::PollType::wait_indefinitely());
|
||
}
|
||
|
||
/// A zero-filled buffer (the clear is recorded).
|
||
pub fn zeroed(&mut self, len: usize) -> Buf {
|
||
let out = self.gpu.buffer(len);
|
||
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
|
||
enc.clear_buffer(out.raw(), 0, None);
|
||
out
|
||
}
|
||
|
||
/// A copy of `b` (recorded).
|
||
pub fn copy(&mut self, b: &Buf) -> Buf {
|
||
let out = self.gpu.buffer(b.len);
|
||
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
|
||
enc.copy_buffer_to_buffer(b.raw(), 0, out.raw(), 0, (b.len * 4) as u64);
|
||
out
|
||
}
|
||
|
||
/// `len` values of `b` from `offset` (both in 32-bit words), as a new buffer (recorded).
|
||
pub fn slice(&mut self, b: &Buf, offset: usize, len: usize) -> Buf {
|
||
let out = self.gpu.buffer(len);
|
||
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
|
||
enc.copy_buffer_to_buffer(b.raw(), (offset * 4) as u64, out.raw(), 0, (len * 4) as u64);
|
||
out
|
||
}
|
||
|
||
/// Copy all of `src` into `dst` at `offset` (32-bit words; recorded).
|
||
pub fn copy_into(&mut self, src: &Buf, dst: &Buf, offset: usize) {
|
||
let enc = self.enc.get_or_insert_with(|| self.gpu.encoder());
|
||
enc.copy_buffer_to_buffer(src.raw(), 0, dst.raw(), (offset * 4) as u64, (src.len * 4) as u64);
|
||
}
|
||
|
||
pub fn read_rgb(&mut self, b: &Buf, w: usize, h: usize) -> Rgb32f {
|
||
Rgb32f { width: w, height: h, data: self.read(b, w * h * 3) }
|
||
}
|
||
|
||
pub fn read_plane(&mut self, b: &Buf, w: usize, h: usize) -> Plane {
|
||
Plane { width: w, height: h, data: self.read(b, w * h) }
|
||
}
|
||
}
|
||
|
||
pub(crate) fn rgb_words(img: &Rgb32f) -> &[f32] {
|
||
bytemuck::cast_slice(&img.data)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Filters (twins of `lightcraft_raster::blur` / `resample` and `lightcraft_pipeline::local`)
|
||
|
||
/// Pixels each blur thread slides its running sum over (at least; longer for large radii, so the
|
||
/// priming sum stays a small share of the work).
|
||
const CHUNK: usize = 16;
|
||
|
||
/// Gaussian blur of a `w × h` image of `nc` interleaved channels: three box passes each way
|
||
/// (`lightcraft_raster::blur::gaussian`).
|
||
pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize, sigma: f32) -> Buf {
|
||
if sigma <= 0.3 || w * h == 0 {
|
||
return cx.copy(src);
|
||
}
|
||
let radii = lightcraft_raster::blur::box_radii(sigma);
|
||
let passes: Vec<(&str, usize)> = radii.iter().map(|r| ("box_h", *r)).chain(radii.iter().map(|r| ("box_v", *r))).filter(|p| p.1 > 0).collect();
|
||
if passes.is_empty() {
|
||
return cx.copy(src);
|
||
}
|
||
// ping-pong between two buffers
|
||
let mut bufs = [cx.gpu.buffer(w * h * nc), cx.gpu.buffer(w * h * nc)];
|
||
for (i, (k, r)) in passes.iter().enumerate() {
|
||
let chunk = (2 * r + 1).clamp(CHUNK, 128);
|
||
let groups = if *k == "box_h" { groups2(w.div_ceil(chunk), h, [64, 4]) } else { groups2(w, h.div_ceil(chunk), [64, 4]) };
|
||
let p = [w as u32, h as u32, nc as u32, *r as u32, chunk as u32];
|
||
let (out, prev) = (&bufs[i % 2], &bufs[(i + 1) % 2]);
|
||
cx.run(k, &p, &[Some(if i == 0 { src } else { prev }), Some(out)], groups);
|
||
}
|
||
let last = (passes.len() - 1) % 2;
|
||
let [a, b] = std::mem::replace(&mut bufs, [cx.gpu.buffer(0), cx.gpu.buffer(0)]);
|
||
if last == 0 { a } else { b }
|
||
}
|
||
|
||
/// Resample taps of `lightcraft_raster::resample::resize`, packed for the `resize_*` kernels.
|
||
fn taps(src: usize, dst: usize, filter: Filter) -> Vec<u32> {
|
||
let w = lightcraft_raster::resample::weights(src, dst, filter);
|
||
let mut t = Vec::with_capacity(dst * 3);
|
||
let mut weights = Vec::new();
|
||
let base = dst * 3;
|
||
for (lo, ws) in &w {
|
||
t.extend_from_slice(&[*lo as u32, ws.len() as u32, (base + weights.len()) as u32]);
|
||
weights.extend(ws.iter().map(|v| v.to_bits()));
|
||
}
|
||
t.extend(weights);
|
||
t
|
||
}
|
||
|
||
/// `lightcraft_raster::resample::resize` of an `nc`-channel image.
|
||
pub(crate) fn resize(cx: &mut Cx<'_>, src: &Buf, (sw, sh): (usize, usize), (dw, dh): (usize, usize), nc: usize, filter: Filter) -> Buf {
|
||
let (dw, dh) = (dw.max(1), dh.max(1));
|
||
if (sw, sh) == (dw, dh) {
|
||
return cx.copy(src);
|
||
}
|
||
let tx = cx.gpu.upload(&taps(sw, dw, filter));
|
||
let tmp = cx.gpu.buffer(dw * sh * nc);
|
||
cx.run("resize_h", &[sw as u32, sh as u32, dw as u32, nc as u32], &[Some(src), Some(&tx), Some(&tmp)], groups2(dw, sh, [16, 16]));
|
||
let ty = cx.gpu.upload(&taps(sh, dh, filter));
|
||
let out = cx.gpu.buffer(dw * dh * nc);
|
||
cx.run("resize_v", &[dw as u32, sh as u32, dh as u32, nc as u32], &[Some(&tmp), Some(&ty), Some(&out)], groups2(dw, dh, [16, 16]));
|
||
out
|
||
}
|
||
|
||
/// An element-wise kernel of the `map` module over `n` items.
|
||
pub(crate) fn map(cx: &mut Cx<'_>, k: &str, n: usize, extra: &[u32], ins: [Option<&Buf>; 3], out: &Buf) {
|
||
let mut p = vec![n as u32];
|
||
p.extend_from_slice(extra);
|
||
cx.run(k, &p, &[ins[0], ins[1], ins[2], Some(out)], groups1(n));
|
||
}
|
||
|
||
/// Self-guided filter of a plane (`local::guided`).
|
||
fn guided(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf {
|
||
let ab = guided_coeffs(cx, p, w, h, sigma, eps);
|
||
let q = cx.gpu.buffer(w * h);
|
||
map(cx, "guided_apply", w * h, &[], [Some(p), Some(&ab), None], &q);
|
||
q
|
||
}
|
||
|
||
/// Blurred (a, b) coefficients, interleaved.
|
||
fn guided_coeffs(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf {
|
||
let n = w * h;
|
||
let pp = cx.gpu.buffer(2 * n);
|
||
map(cx, "guided_pre", n, &[], [Some(p), None, None], &pp);
|
||
let m = gaussian(cx, &pp, w, h, 2, sigma);
|
||
let ab = cx.gpu.buffer(2 * n);
|
||
map(cx, "guided_ab", n, &[eps.to_bits()], [Some(&m), None, None], &ab);
|
||
gaussian(cx, &ab, w, h, 2, sigma)
|
||
}
|
||
|
||
/// Fast guided filter (`local::guided_fast`): coefficients on a subsampled grid.
|
||
fn guided_fast(cx: &mut Cx<'_>, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf {
|
||
let s = local::guided_fast_step(sigma);
|
||
if s <= 1 {
|
||
return guided(cx, p, w, h, sigma, eps);
|
||
}
|
||
let (lw, lh) = (w.div_ceil(s).max(1), h.div_ceil(s).max(1));
|
||
let lo = resize(cx, p, (w, h), (lw, lh), 1, Filter::Box);
|
||
let ab = guided_coeffs(cx, &lo, lw, lh, sigma / s as f32, eps);
|
||
let up = resize(cx, &ab, (lw, lh), (w, h), 2, Filter::Bilinear);
|
||
let q = cx.gpu.buffer(w * h);
|
||
map(cx, "guided_apply", w * h, &[], [Some(p), Some(&up), None], &q);
|
||
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)),
|
||
};
|
||
let mut affine = |xf: &lightcraft_geom::Affine| {
|
||
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)
|
||
};
|
||
match (&sp.mode, fr.warp.as_ref()) {
|
||
(SampleMode::Copy, _) => base,
|
||
(SampleMode::Affine(xf), _) => affine(xf),
|
||
// `sample_plan` plans a warp only when there is one; without it the same mapping is affine
|
||
(SampleMode::Warp(o2t), None) => affine(&(lightcraft_geom::Affine::scale(sp.sx, sp.sy) * *o2t)),
|
||
(SampleMode::Warp(o2t), Some(wp)) => {
|
||
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
|
||
|
||
/// CPU copies made along the way (for stages that still run on the CPU).
|
||
#[derive(Default)]
|
||
struct Host {
|
||
sampled: Option<Rgb32f>,
|
||
lin: Option<Arc<Rgb32f>>,
|
||
log_l: Option<Arc<Plane>>,
|
||
}
|
||
|
||
fn profiling() -> bool {
|
||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
|
||
}
|
||
|
||
/// Render on `gpu`, reusing `stages` (if given). `None` when the render does not fit the device.
|
||
pub fn render(
|
||
gpu: &Gpu,
|
||
src: &Arc<Rgb32f>,
|
||
info: &SourceInfo,
|
||
s: &DevelopSettings,
|
||
req: &RenderRequest,
|
||
stages: Option<&GpuStages>,
|
||
) -> Option<Rendered> {
|
||
let mut t = profiling().then(std::time::Instant::now);
|
||
// Under `LIGHTCRAFT_PROFILE` each stage is submitted and waited for, so the timings are real.
|
||
let lap = |what: &str, t: &mut Option<std::time::Instant>, cx: &mut Cx<'_>| {
|
||
if let Some(t) = t {
|
||
cx.sync();
|
||
eprintln!(" gpu {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||
*t = std::time::Instant::now();
|
||
}
|
||
};
|
||
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) {
|
||
return None;
|
||
}
|
||
let s = &*plan.settings;
|
||
let cached = stages.and_then(|c| c.get(src, plan.geo));
|
||
let mut host = Host::default();
|
||
let mut cx = Cx::new(gpu);
|
||
lap("plan", &mut t, &mut cx);
|
||
|
||
// 1. geometry (on the CPU when the source exceeds the device's buffer limit)
|
||
let sampled = match &cached {
|
||
Some(e) => e.sampled.clone(),
|
||
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, &mut cx);
|
||
|
||
// 2. white balance, defringe, spots, noise reduction
|
||
let lin = match cached.as_ref().and_then(|e| e.lin.clone()).filter(|(k, _)| *k == plan.lin_key) {
|
||
Some((_, b)) => b,
|
||
None => Arc::new(linear(&mut cx, &sampled, info, &plan, &mut host)),
|
||
};
|
||
lap("wb/nr", &mut t, &mut cx);
|
||
|
||
// 3. spatial planes
|
||
let mut planes = match cached.map(|e| e.planes) {
|
||
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);
|
||
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 });
|
||
}
|
||
|
||
// 4. masks
|
||
let (masks, terms) = masks(&mut cx, &lin, &prep, &plan, &mut host);
|
||
lap("masks", &mut t, &mut cx);
|
||
|
||
// 5. per-pixel stage
|
||
let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space);
|
||
let present = Present {
|
||
clarity: prep.clarity.is_some(),
|
||
texture: prep.texture.is_some(),
|
||
dark: prep.dark.is_some(),
|
||
chroma: masks.is_some() && prep.chroma.is_some(),
|
||
};
|
||
let (p, aux) = finish_block(&fp, &terms, &present);
|
||
let aux = gpu.upload(&aux);
|
||
let out = gpu.buffer(n);
|
||
cx.run(
|
||
"main",
|
||
&p,
|
||
&[
|
||
Some(&lin),
|
||
Some(&prep.log_l),
|
||
Some(&prep.base),
|
||
prep.clarity.as_deref(),
|
||
prep.texture.as_deref(),
|
||
prep.dark.as_deref(),
|
||
masks.as_ref(),
|
||
Some(&aux),
|
||
Some(&out),
|
||
],
|
||
groups2(w, h, [16, 16]),
|
||
);
|
||
// the alpha a mask overlay shows: copied out before the readback below submits
|
||
let overlay_mask = req.overlay.mask(s).map(|m| {
|
||
let list = s.masks.iter().filter(|m| m.visible && !m.components.is_empty());
|
||
match (list.map(|m| m.id).position(|id| id == m.id), masks.as_ref()) {
|
||
(Some(mi), Some(all)) => Ok(cx.slice(all, mi * n, n)),
|
||
_ => Err(m),
|
||
}
|
||
});
|
||
let data: Vec<[u8; 4]> = cx.read(&out, n);
|
||
let image = Rgba8 { width: w, height: h, data };
|
||
lap("finish + readback", &mut t, &mut cx);
|
||
let histogram = Histogram::of_srgb8(&image);
|
||
lap("histogram", &mut t, &mut cx);
|
||
let mut image = image;
|
||
let overlay_mask = overlay_mask.map(|r| match r {
|
||
Ok(b) => cx.read_plane(&b, w, h),
|
||
// a hidden mask: evaluate it on the CPU
|
||
Err(m) => {
|
||
let img = host.lin.get_or_insert_with(|| Arc::new(cx.read_rgb(&lin, w, h))).clone();
|
||
let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone();
|
||
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32)
|
||
}
|
||
});
|
||
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
|
||
Some(Rendered { image, histogram, deep: None })
|
||
}
|
||
|
||
/// 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);
|
||
let n = w * h;
|
||
let s = &*plan.settings;
|
||
let img = if lightcraft_pipeline::lin_needs_cpu(s) {
|
||
// defringe / spot removal: CPU
|
||
let mut img = match host.sampled.take() {
|
||
Some(i) => i,
|
||
None => cx.read_rgb(sampled, w, h),
|
||
};
|
||
lightcraft_pipeline::lin_cpu(&mut img, info, plan);
|
||
cx.gpu.upload(rgb_words(&img))
|
||
} else {
|
||
let m = local::wb_matrix_for(info, s);
|
||
let mut p = vec![m.is_some() as u32];
|
||
p.extend(m.unwrap_or_default().iter().flatten().map(|v| v.to_bits()));
|
||
let out = cx.gpu.buffer(n * 3);
|
||
map(cx, "wb_k", n, &p, [Some(sampled), None, None], &out);
|
||
if plan.eyes.is_empty() {
|
||
out
|
||
} else {
|
||
let mut p = vec![w as u32, plan.eyes.len() as u32];
|
||
p.extend(plan.eyes.iter().flat_map(|e| e.words()).map(f32::to_bits));
|
||
let eyed = cx.gpu.buffer(n * 3);
|
||
map(cx, "redeye_k", n, &p, [Some(&out), None, None], &eyed);
|
||
eyed
|
||
}
|
||
};
|
||
denoise(cx, img, plan)
|
||
}
|
||
|
||
/// Noise reduction (`local::denoise`).
|
||
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 mut img = img;
|
||
if let Some(nr) = lum {
|
||
let l = cx.gpu.buffer(n);
|
||
map(cx, "log_lum_k", n, &[], [Some(&img), None, None], &l);
|
||
let f = guided(cx, &l, w, h, nr.sigma, nr.eps);
|
||
let out = cx.gpu.buffer(n * 3);
|
||
map(cx, "nr_lum", n, &[nr.k.to_bits()], [Some(&img), Some(&l), Some(&f)], &out);
|
||
img = out;
|
||
}
|
||
if let Some(nr) = col {
|
||
let chroma = cx.gpu.buffer(n * 3);
|
||
map(cx, "chroma_k", n, &[], [Some(&img), None, None], &chroma);
|
||
let b = gaussian(cx, &chroma, w, h, 3, nr.sigma);
|
||
let out = cx.gpu.buffer(n * 3);
|
||
map(cx, "nr_col", n, &[nr.t.to_bits()], [Some(&img), Some(&chroma), Some(&b)], &out);
|
||
img = out;
|
||
}
|
||
img
|
||
}
|
||
|
||
/// The planes the per-pixel stage reads.
|
||
struct Prep {
|
||
log_l: Arc<Buf>,
|
||
base: Arc<Buf>,
|
||
clarity: Option<Arc<Buf>>,
|
||
texture: Option<Arc<Buf>>,
|
||
dark: Option<Arc<Buf>>,
|
||
/// Blurred chromaticity (local Moiré / Noise).
|
||
chroma: Option<Arc<Buf>>,
|
||
air: f32,
|
||
}
|
||
|
||
fn plane_at(slot: &mut Option<(u32, Arc<Buf>)>, sigma: f32, f: impl FnOnce() -> Buf) -> Arc<Buf> {
|
||
match slot {
|
||
Some((k, p)) if *k == sigma.to_bits() => p.clone(),
|
||
_ => {
|
||
let p = Arc::new(f());
|
||
*slot = Some((sigma.to_bits(), p.clone()));
|
||
p
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The spatial planes (`local::prepare`), reusing those in `planes`.
|
||
fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, planes: &mut Planes) -> Prep {
|
||
let (w, h) = (plan.w, plan.h);
|
||
let n = w * h;
|
||
let sig = local::plane_sigmas(&plan.settings, plan.px_per_long, req.quality);
|
||
let log_l = match &planes.log_l {
|
||
Some(b) => b.clone(),
|
||
None => {
|
||
let l = cx.gpu.buffer(n);
|
||
map(cx, "log_lum_k", n, &[], [Some(lin), None, None], &l);
|
||
let b = Arc::new(l);
|
||
planes.log_l = Some(b.clone());
|
||
b
|
||
}
|
||
};
|
||
let base = match sig.base {
|
||
Some(sg) => plane_at(&mut planes.base, sg, || guided_fast(cx, &log_l, w, h, sg, local::BASE_EPS)),
|
||
None => log_l.clone(),
|
||
};
|
||
let clarity = sig.clarity.map(|sg| plane_at(&mut planes.clarity, sg, || guided_fast(cx, &log_l, w, h, sg, local::CLARITY_EPS)));
|
||
let texture = sig.texture.map(|sg| plane_at(&mut planes.texture, sg, || gaussian(cx, &log_l, w, h, 1, sg)));
|
||
let (dark, air) = match sig.dark {
|
||
None => (None, 1.0),
|
||
Some(sg) => match &planes.dark {
|
||
Some((k, d, air)) if *k == sg.to_bits() => (Some(d.clone()), *air),
|
||
_ => {
|
||
let d0 = cx.gpu.buffer(n);
|
||
map(cx, "dark_k", n, &[], [Some(lin), None, None], &d0);
|
||
let d = gaussian(cx, &d0, w, h, 1, sg);
|
||
// airlight: a percentile of every 7th value, as on the CPU
|
||
let m = n.div_ceil(local::AIRLIGHT_STEP);
|
||
let sub = cx.gpu.buffer(m);
|
||
map(cx, "subsample", m, &[local::AIRLIGHT_STEP as u32], [Some(&d), None, None], &sub);
|
||
let air = local::airlight_of(cx.read(&sub, m));
|
||
let b = Arc::new(d);
|
||
planes.dark = Some((sg.to_bits(), b.clone(), air));
|
||
(Some(b), air)
|
||
}
|
||
},
|
||
};
|
||
let chroma = sig.chroma.map(|sg| {
|
||
plane_at(&mut planes.chroma, sg, || {
|
||
let ch = cx.gpu.buffer(n * 3);
|
||
map(cx, "chroma_k", n, &[], [Some(lin), None, None], &ch);
|
||
gaussian(cx, &ch, w, h, 3, sg)
|
||
})
|
||
});
|
||
Prep { log_l, base, clarity, texture, dark, chroma, air }
|
||
}
|
||
|
||
/// Most brush dabs the mask kernel evaluates per pixel (more: the CPU rasterizes the brush).
|
||
const MAX_DABS: usize = 4096;
|
||
|
||
/// The `shape` kernel's brush data for `dabs`: stroke records (12 words: dab offset, dab count,
|
||
/// r, hard, flow, density, erase, bbox x0 y0 x1 y1, auto) then the dab centres.
|
||
fn brush_aux(dabs: &[&lightcraft_pipeline::masks::BrushDabs], erase: bool) -> Vec<f32> {
|
||
let mut aux = Vec::new();
|
||
let mut off = 12 * dabs.len();
|
||
for d in dabs {
|
||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||
for q in &d.dabs {
|
||
(x0, y0, x1, y1) = (x0.min(q.x - d.r), y0.min(q.y - d.r), x1.max(q.x + d.r), y1.max(q.y + d.r));
|
||
}
|
||
aux.extend([off as f32, d.dabs.len() as f32, d.r as f32, d.hard as f32, d.flow, d.density, (erase && d.erase) as u8 as f32]);
|
||
aux.extend([x0, y0, x1, y1].map(|v| v as f32));
|
||
aux.push(d.auto as u8 as f32);
|
||
off += 2 * d.dabs.len();
|
||
}
|
||
for d in dabs {
|
||
aux.extend(d.dabs.iter().flat_map(|q| [q.x as f32, q.y as f32]));
|
||
}
|
||
aux
|
||
}
|
||
|
||
/// Cross guided filter (`masks::guided_cross`) of `p` steered by `guide`, max-combined with `p`.
|
||
fn guided_cross_max(cx: &mut Cx<'_>, guide: &Buf, p: &Buf, w: usize, h: usize, sigma: f32, eps: f32) -> Buf {
|
||
let n = w * h;
|
||
let (m0, m1) = (cx.gpu.buffer(2 * n), cx.gpu.buffer(2 * n));
|
||
map(cx, "xguided_pre", n, &[0], [Some(guide), Some(p), None], &m0);
|
||
map(cx, "xguided_pre", n, &[1], [Some(guide), Some(p), None], &m1);
|
||
let (b0, b1) = (gaussian(cx, &m0, w, h, 2, sigma), gaussian(cx, &m1, w, h, 2, sigma));
|
||
let ab = cx.gpu.buffer(2 * n);
|
||
map(cx, "xguided_ab", n, &[eps.to_bits()], [Some(&b0), Some(&b1), None], &ab);
|
||
let ab = gaussian(cx, &ab, w, h, 2, sigma);
|
||
let q = cx.gpu.buffer(n);
|
||
map(cx, "xguided_apply", n, &[], [Some(guide), Some(&ab), Some(p)], &q);
|
||
q
|
||
}
|
||
|
||
/// Evaluate the masks (`lightcraft_pipeline::masks::evaluate`): their alpha planes (concatenated,
|
||
/// followed by the blurred chromaticity when local Moiré / Noise need it) and their adjustment
|
||
/// terms. Shapes without a kernel (Sky, Subject, …) run on the CPU.
|
||
fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Host) -> (Option<Buf>, Vec<[f32; MASK_TERMS]>) {
|
||
use lightcraft_develop::{MaskOp, MaskShape};
|
||
let s = &*plan.settings;
|
||
let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect();
|
||
if list.is_empty() {
|
||
return (None, Vec::new());
|
||
}
|
||
let (w, h) = (plan.w, plan.h);
|
||
let n = w * h;
|
||
let frame = &plan.frame;
|
||
let ev = s.light.exposure as f32;
|
||
let long = frame.ow.max(frame.oh);
|
||
let [a, b, c, d, e, f] = frame.out_to_norm(w, h).0;
|
||
let (kx, ky) = (frame.ow / long, frame.oh / long);
|
||
let aff = [a * kx, b * ky, c * kx, d * ky, e * kx, f * ky].map(|v| (v as f32).to_bits());
|
||
let alpha = cx.zeroed(n * list.len() + if prep.chroma.is_some() { 3 * n } else { 0 });
|
||
if let Some(ch) = &prep.chroma {
|
||
cx.copy_into(ch, &alpha, n * list.len());
|
||
}
|
||
let comp_plane = cx.gpu.buffer(n);
|
||
for (mi, m) in list.iter().enumerate() {
|
||
let mut first = true;
|
||
for comp in &m.components {
|
||
let op = match comp.op {
|
||
_ if first && comp.op != MaskOp::Intersect => 0u32,
|
||
MaskOp::Add => 1,
|
||
MaskOp::Subtract => 2,
|
||
MaskOp::Intersect => 3,
|
||
};
|
||
first = false;
|
||
let mut p = vec![w as u32, h as u32, 0, comp.invert as u32];
|
||
p.extend_from_slice(&aff);
|
||
let f32s = |v: &[f64]| v.iter().map(|x| (*x as f32).to_bits()).collect::<Vec<u32>>();
|
||
let mut aux: Vec<f32> = Vec::new();
|
||
let mut own: Option<Buf> = None;
|
||
let kind = match &comp.shape {
|
||
MaskShape::Linear { start, end } => {
|
||
let (a, b) = (frame.norm_to_long(*start), frame.norm_to_long(*end));
|
||
let d = b - a;
|
||
p.extend(f32s(&[a.x, a.y, d.x, d.y, d.dot(d).max(1e-12)]));
|
||
Some(0)
|
||
}
|
||
MaskShape::Radial { center, rx, ry, angle, feather, invert } => {
|
||
let c = frame.norm_to_long(*center);
|
||
let (sn, co) = (-angle.to_radians()).sin_cos();
|
||
p.extend(f32s(&[c.x, c.y, co, sn, rx.max(1e-6), ry.max(1e-6), (*feather / 100.0).clamp(0.0, 1.0)]));
|
||
p.push(*invert as u32);
|
||
Some(1)
|
||
}
|
||
MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => {
|
||
p.extend(f32s(&[*lo, *hi, lo_feather.max(1e-3), hi_feather.max(1e-3)]));
|
||
p.push(ev.to_bits());
|
||
Some(2)
|
||
}
|
||
MaskShape::ColorRange { samples, refine } => {
|
||
let tol = 0.04 + 0.16 * (*refine as f32 / 100.0);
|
||
p.extend([tol.to_bits(), ev.exp2().to_bits(), samples.len() as u32]);
|
||
aux.extend(samples.iter().flat_map(|s| s.map(|v| v as f32)));
|
||
Some(3)
|
||
}
|
||
MaskShape::Brush { strokes } => {
|
||
let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect();
|
||
if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS {
|
||
None
|
||
} else if dabs.iter().any(|d| d.auto) {
|
||
// Auto Mask: stroke by stroke (each one refined before it's combined)
|
||
let brush = cx.zeroed(n);
|
||
let stroke = cx.gpu.buffer(n);
|
||
for d in &dabs {
|
||
let mut q = p.clone();
|
||
q[2] = 4;
|
||
q[3] = 0;
|
||
q.push(1);
|
||
let aux = cx.gpu.upload(&brush_aux(&[d], false));
|
||
cx.run("shape", &q, &[Some(lin), Some(&prep.log_l), Some(&aux), Some(&stroke), Some(&brush)], groups2(w, h, [16, 16]));
|
||
let refined = d.auto.then(|| {
|
||
let (sigma, eps) = lightcraft_pipeline::masks::auto_refine(d.r);
|
||
guided_cross_max(cx, &prep.log_l, &stroke, w, h, sigma, eps)
|
||
});
|
||
let op = if d.erase { 2 } else { 1 };
|
||
cx.run(
|
||
"combine",
|
||
&[n as u32, 0, 0, op],
|
||
&[None, None, None, Some(refined.as_ref().unwrap_or(&stroke)), Some(&brush)],
|
||
groups1(n),
|
||
);
|
||
}
|
||
if comp.invert {
|
||
cx.run("finalize", &[n as u32, 0, 0, 1, 1f32.to_bits()], &[None, None, None, Some(&stroke), Some(&brush)], groups1(n));
|
||
}
|
||
own = Some(brush);
|
||
None
|
||
} else {
|
||
p.push(dabs.len() as u32);
|
||
aux = brush_aux(&dabs.iter().collect::<Vec<_>>(), true);
|
||
Some(4)
|
||
}
|
||
}
|
||
_ => None,
|
||
};
|
||
let plane = match kind {
|
||
_ if own.is_some() => own,
|
||
Some(k) => {
|
||
p[2] = k;
|
||
let aux = (!aux.is_empty()).then(|| cx.gpu.upload(&aux));
|
||
cx.run("shape", &p, &[Some(lin), Some(&prep.log_l), aux.as_ref(), Some(&comp_plane), Some(&alpha)], groups2(w, h, [16, 16]));
|
||
None
|
||
}
|
||
None => {
|
||
// no kernel: evaluate on the CPU
|
||
let img = host.lin.get_or_insert_with(|| Arc::new(cx.read_rgb(lin, w, h))).clone();
|
||
let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone();
|
||
let mut v = lightcraft_pipeline::masks::shape_alpha(&comp.shape, frame, w, h, &img, &l, ev);
|
||
if comp.invert {
|
||
v.data.iter_mut().for_each(|x| *x = 1.0 - *x);
|
||
}
|
||
Some(cx.gpu.upload(&v.data))
|
||
}
|
||
};
|
||
let cbuf = plane.as_ref().unwrap_or(&comp_plane);
|
||
let k = [n as u32, 0, mi as u32, op];
|
||
cx.run("combine", &k, &[None, None, None, Some(cbuf), Some(&alpha)], groups1(n));
|
||
}
|
||
let amt = (m.adjust.amount / 100.0) as f32;
|
||
cx.run(
|
||
"finalize",
|
||
&[n as u32, 0, mi as u32, m.invert as u32, amt.to_bits()],
|
||
&[None, None, None, Some(&comp_plane), Some(&alpha)],
|
||
groups1(n),
|
||
);
|
||
}
|
||
let terms = list.iter().map(|m| mask_terms(&m.adjust)).collect();
|
||
(Some(alpha), terms)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Kernel timings on a 24 MP plane: `cargo test --release -p lightcraft-gpu -- --ignored --nocapture`.
|
||
#[test]
|
||
#[ignore]
|
||
fn bench_kernels() {
|
||
let Some(gpu) = crate::device() else { return };
|
||
let _scope = crate::ctx::RenderScope::new(gpu);
|
||
let (w, h) = (6000, 4000);
|
||
let src = gpu.upload(&vec![0.5f32; w * h * 3]);
|
||
let time = |name: &str, f: &mut dyn FnMut(&mut Cx<'_>)| {
|
||
let mut best = f64::MAX;
|
||
for _ in 0..5 {
|
||
let mut cx = Cx::new(gpu);
|
||
cx.sync();
|
||
let t = std::time::Instant::now();
|
||
f(&mut cx);
|
||
cx.sync();
|
||
best = best.min(t.elapsed().as_secs_f64() * 1e3);
|
||
}
|
||
eprintln!("{name:<40} {best:.1} ms");
|
||
};
|
||
for nc in [1, 2, 3] {
|
||
time(&format!("gaussian nc={nc} sigma=2"), &mut |cx| drop(gaussian(cx, &src, w, h, nc, 2.0)));
|
||
}
|
||
time("map log_lum", &mut |cx| {
|
||
let o = cx.gpu.buffer(w * h);
|
||
map(cx, "log_lum_k", w * h, &[], [Some(&src), None, None], &o);
|
||
});
|
||
time("guided sigma=1.75", &mut |cx| drop(guided(cx, &src, w, h, 1.75, 0.01)));
|
||
}
|
||
|
||
#[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}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|