Merge upstream/main into pr/overlay-windows (#1027 landed)

GPU handles(): only output-device proofs stay on the CPU; Show All Masks now renders zoom windows (upstream); proof-boundary tolerance in the GPU equivalence test; device-profile draft test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrew Frahn
2026-10-20 10:57:07 +11:00
co-authored by Claude Sonnet 5.5
521 changed files with 85675 additions and 4257 deletions
+29 -7
View File
@@ -92,6 +92,11 @@ pub struct SourceInfo {
/// The raw's own local tone mapping (DNG `ProfileGainTableMap`), rendered only when the
/// photo's Profile option asks for it ([`local_tone`]).
pub local_tone: Option<Arc<local_tone::LocalTone>>,
/// Long edge (px) of the original photo, when the source handed to the renderer is a smaller
/// copy of it (the app's thumbnail and preview levels); `None`: the source is the original.
/// Noise reduction sizes its radii in the original's pixels ([`Plan::detail_scale`]), so a
/// preview looks like the full-size render scaled down (issue #960).
pub native_long: Option<usize>,
}
impl Default for SourceInfo {
@@ -107,6 +112,7 @@ impl Default for SourceInfo {
camera_tone: None,
mattes: None,
local_tone: None,
native_long: None,
}
}
}
@@ -133,7 +139,7 @@ pub struct RenderRequest {
pub space: OutputSpace,
/// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports).
pub depth: OutputDepth,
/// Soft proofing (CPU only; see [`Proof`]).
/// Soft proofing (a proof against an output-device profile renders on the CPU; 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`].
@@ -360,6 +366,9 @@ pub struct Plan<'a> {
pub px_per_long: f64,
/// Long edge of the source buffer (px).
pub src_long: usize,
/// Output pixels per pixel of the original photo ([`SourceInfo::native_long`], else the source
/// buffer), at most 1: the scale of noise reduction, whose radii are in the original's pixels.
pub detail_scale: f32,
/// Key of the resampled source (together with the source buffer's identity).
pub geo: u64,
/// Key of the white-balanced, retouched, denoised image (and of the planes computed from it).
@@ -423,6 +432,9 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
None => (frame, full_w, full_h),
};
let src_long = src.width.max(src.height);
// (an original reported smaller than the source is the source)
let native_long = info.native_long.unwrap_or(0).max(src_long).max(1);
let detail_scale = local::detail_scale(px_per_long, native_long);
let geo = hash_of((format!("{frame:?}"), w, h));
let (wb_t, wb_tint) = local::effective_wb(info, s);
let eyes = redeye::resolve(src, &s.red_eye, s.orientation, &frame, w, h, px_per_long);
@@ -438,8 +450,12 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
local_tone::enabled(info, s),
src_long,
));
let lin_key = hash_of((pre_nr_key, [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits)));
Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, pre_nr_key, eyes, fixed_air, keep, mattes: info.mattes.clone() }
let lin_key = hash_of((
pre_nr_key,
[d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits),
detail_scale.to_bits(),
));
Plan { settings, frame, w, h, px_per_long, src_long, detail_scale, geo, lin_key, pre_nr_key, eyes, fixed_air, keep, mattes: info.mattes.clone() }
}
/// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal).
@@ -538,7 +554,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
Src::Shared(a) => a,
};
let plan = plan(src_img, info, s, req);
let Plan { ref frame, w, h, px_per_long, src_long, geo, lin_key, .. } = plan;
let Plan { ref frame, w, h, px_per_long, detail_scale, geo, lin_key, .. } = plan;
let s = &*plan.settings;
let shared = match (&src, cache) {
@@ -558,7 +574,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
// Without a cache the resampled buffer is ours: work on it in place.
let mut img = if shared.is_some() { (*sampled).clone() } else { Arc::unwrap_or_clone(sampled.clone()) };
lin_cpu(&mut img, info, &plan);
local::denoise(&mut img, s, src_long, frame.output_long(w, h));
local::denoise(&mut img, s, detail_scale);
Arc::new(img)
}
};
@@ -618,7 +634,11 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
/// exposed scene colours, as [`MaskShape::ColorRange`](lightcraft_develop::MaskShape) compares
/// them), averaged over 3 × 3 pixels of a render fitting `req`. `None` outside the image.
pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, p: lightcraft_geom::Point) -> Option<[f64; 3]> {
let plan = plan(src, info, s, req);
// Pick from the same settings as the render: disabled panels contribute nothing, and an
// SDR preview of an HDR edit includes the SDR rendition's exposure offset.
let effective = s.effective();
let rendition = settings_for(&effective, req);
let plan = plan(src, info, &rendition, req);
let mut img = plan.frame.sample(src, plan.w, plan.h);
lin_cpu(&mut img, info, &plan);
let o = plan.frame.norm_to_out(plan.w, plan.h).apply(p);
@@ -626,7 +646,7 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings,
if cx < 0 || cy < 0 || cx >= plan.w as i64 || cy >= plan.h as i64 {
return None;
}
let gain = (s.light.exposure as f32).exp2();
let gain = (plan.settings.light.exposure as f32).exp2();
let mut acc = [0f64; 3];
let mut n = 0.0;
for y in (cy - 1).max(0)..=(cy + 1).min(plan.h as i64 - 1) {
@@ -713,6 +733,8 @@ mod tests_hdr;
#[cfg(test)]
mod tests_local;
#[cfg(test)]
mod tests_nr_scale;
#[cfg(test)]
mod tests_tone;
#[cfg(test)]
mod tests_window;
+49 -13
View File
@@ -147,7 +147,7 @@ pub fn legacy_to_kelvin(temp: f64, tint: f64, as_shot: (f64, f64)) -> (f64, f64)
/// edited on a rendered photo or a raw without matrices never pass for Kelvin when pasted onto one that has them.
pub fn normalize_wb(d: &mut DevelopSettings, info: &SourceInfo) {
use lightcraft_develop::{WbMode, WbScale};
if !(info.raw && !info.relative_wb) {
if !info.raw || info.relative_wb {
d.wb.scale = WbScale::Legacy;
return;
}
@@ -306,28 +306,64 @@ pub struct NrColor {
pub t: f32,
}
/// Noise-reduction parameters at an output long edge of `out_long` px (see [`denoise`]).
pub fn nr_params(s: &DevelopSettings, src_long: usize, out_long: usize) -> (Option<NrLum>, Option<NrColor>) {
/// Output pixels per pixel of the original photo for an output of `px_per_long` pixels per unit
/// of the long edge of an original `native_long` pixels long, at most 1 ([`crate::Plan::detail_scale`]).
pub fn detail_scale(px_per_long: f64, native_long: usize) -> f32 {
let k = (px_per_long / native_long.max(1) as f64) as f32;
if k.is_finite() { k.clamp(0.0, 1.0) } else { 1.0 }
}
/// Smallest radius (px) noise reduction filters a preview with: [`gaussian`] makes no blur below
/// about 0.58 px and one 3-px box from there ([`lightcraft_raster::blur::box_sigma`]).
const NR_MIN_SIGMA: f32 = 1.0;
/// Below this fade a preview's noise reduction is not drawn at all (less than 1 % of a 1-px blur).
const NR_MIN_BLEND: f32 = 0.01;
/// Radius and blend weight that stand in for a blur of `sigma` px: below [`NR_MIN_SIGMA`] the
/// smallest blur the filter makes, blended in by the ratio of their variances, which keeps the
/// smoothing's second moment (how much it softens the finest detail) that of `sigma`.
fn nr_radius(sigma: f32) -> (f32, f32) {
if sigma >= NR_MIN_SIGMA {
return (sigma, 1.0);
}
let made = lightcraft_raster::blur::box_sigma(NR_MIN_SIGMA);
(NR_MIN_SIGMA, (sigma / made).powi(2).min(1.0))
}
/// Noise-reduction parameters at `scale` output pixels per pixel of the original
/// ([`crate::Plan::detail_scale`]; see [`denoise`]). Noise lives at the original's pixels, so the
/// radii are the full-size radii times `scale`, and the guided filter's `eps` (the variance it
/// treats as noise) follows the noise variance, which averaging `1/scale × 1/scale` pixels into one
/// multiplies by `scale²`. A radius below [`NR_MIN_SIGMA`] fades instead ([`nr_radius`]): previews look like
/// the full-size render scaled down (issue #960). At `scale` 1 these are the full-size parameters.
pub fn nr_params(s: &DevelopSettings, scale: f32) -> (Option<NrLum>, Option<NrColor>) {
let lum = (s.detail.nr_luminance / 100.0) as f32;
let col = (s.detail.nr_color / 100.0) as f32;
let scale = (out_long as f32 / src_long.max(1) as f32).clamp(0.05, 1.0);
let l = (lum > 0.0).then(|| {
let scale = if scale.is_finite() { scale.clamp(0.0, 1.0) } else { 1.0 };
let l = (lum > 0.0).then(|| nr_radius((1.0 + 2.5 * lum) * scale)).filter(|&(_, w)| w >= NR_MIN_BLEND).map(|(sigma, w)| {
let detail = (s.detail.nr_detail / 100.0) as f32;
NrLum { sigma: (1.0 + 2.5 * lum) * scale.max(0.4), eps: 0.002 + lum * lum * 0.25 * (1.0 - 0.7 * detail), k: lum.sqrt() }
let eps = ((0.002 + lum * lum * 0.25 * (1.0 - 0.7 * detail)) * scale * scale).max(1e-7);
NrLum { sigma, eps, k: lum.sqrt() * w }
});
let c = (col > 0.0).then(|| {
let sigma = (1.5 + 6.0 * col) * scale.max(0.35) * (1.0 + (s.detail.nr_color_smoothness / 100.0) as f32);
let smooth = 1.0 + (s.detail.nr_color_smoothness / 100.0) as f32;
let c = (col > 0.0).then(|| nr_radius((1.5 + 6.0 * col) * scale * smooth)).filter(|&(_, w)| w >= NR_MIN_BLEND).map(|(sigma, w)| {
let keep = (s.detail.nr_color_detail / 100.0) as f32 * 0.5;
NrColor { sigma, t: col * (1.0 - keep) }
NrColor { sigma, t: col * (1.0 - keep) * w }
});
(l, c)
}
/// Noise reduction at output resolution: luminance via an edge-aware self-guided filter on
/// log-luminance, colour by blurring chromaticity (rgb / Y) and re-applying the original luminance.
/// Radii scale with how much the source was downsampled (preview noise is already averaged out).
pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, src_long: usize, out_long: usize) {
let (lum, col) = nr_params(s, src_long, out_long);
/// `scale`: output pixels per pixel of the original ([`nr_params`]).
pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, scale: f32) {
let (lum, col) = nr_params(s, scale);
apply_nr(img, lum, col);
}
/// Noise reduction with explicit parameters (see [`denoise`]).
pub fn apply_nr(img: &mut Rgb32f, lum: Option<NrLum>, col: Option<NrColor>) {
let w = img.width;
if let Some(nr) = lum {
let _t = crate::profiling().then(std::time::Instant::now);
@@ -694,7 +730,7 @@ mod nr_tests {
let (v0, m0) = var(&img);
let mut s = DevelopSettings::default();
s.detail.nr_luminance = 80.0;
denoise(&mut img, &s, 64, 64);
denoise(&mut img, &s, 1.0);
let (v1, m1) = var(&img);
assert!(v1 < v0 * 0.5, "{v0} -> {v1}");
assert!((m1 - m0).abs() < 0.01);
+89 -4
View File
@@ -440,6 +440,44 @@ pub struct BrushDabs {
pub erase: bool,
/// Auto Mask: paint only pixels like the one under each dab, then snap to edges.
pub auto: bool,
/// Pen pressure (0..1) of each dab, parallel to `dabs`; empty when the stroke has no pressure data (full pressure).
pub pressure: Vec<f32>,
}
impl BrushDabs {
/// Radius, hard core and flow of dab `i`, pen pressure applied ([`pressure_radius`], [`pressure_flow`]).
pub fn dab(&self, i: usize) -> (f64, f64, f32) {
match self.pressure.get(i) {
Some(&p) => {
let r = pressure_radius(self.r, p);
(r, if self.r > 0.0 { self.hard * r / self.r } else { self.hard }, pressure_flow(self.flow, p))
}
None => (self.r, self.hard, self.flow),
}
}
}
/// The radius of a dab of a brush of radius `r` (pixels) painted at pen pressure `p` (0..1): from a tenth of the
/// brush at the lightest touch to the full brush at full pressure, never below half a pixel.
#[inline]
pub fn pressure_radius(r: f64, p: f32) -> f64 {
let p = if p.is_nan() { 1.0 } else { f64::from(p.clamp(0.0, 1.0)) };
(r * (0.1 + 0.9 * p)).max(0.5).min(r.max(0.5))
}
/// The flow (0..1) of a dab painted at pen pressure `p` (0..1): proportional to the pressure.
#[inline]
pub fn pressure_flow(flow: f32, p: f32) -> f32 {
let p = if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) };
flow * p
}
/// Pressures for the `added` dabs [`push_dabs`] just laid from a sample at pressure `from` to one at `to`: evenly
/// interpolated, the last one at `to`, as the dabs themselves are placed.
pub fn push_pressures(pressure: &mut Vec<f32>, added: usize, from: f32, to: f32) {
for k in 1..=added {
pressure.push(from + (to - from) * k as f32 / added as f32);
}
}
/// Output pixels a brush sample must move from the last dab to count as movement: below this the
@@ -456,11 +494,22 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa
// long the button is held: a sample where the pointer hasn't moved (a stationary hold records
// one per frame) lays no dab, so waiting in place adds nothing (#518).
let mut dabs: Vec<Point> = Vec::new();
let mut pressure: Vec<f32> = Vec::new();
let with_pressure = !s.pressure.is_empty();
let mut prev = None;
for p in &s.points {
let mut prev_p = 1.0;
for (i, p) in s.points.iter().enumerate() {
let q = to_out.apply(*p);
let before = dabs.len();
if push_dabs(&mut dabs, prev, q, r) {
let pq = s.pressure_at(i);
if with_pressure {
// the first dab of the stroke has nothing to interpolate from
let from = if prev.is_some() { prev_p } else { pq };
push_pressures(&mut pressure, dabs.len() - before, from, pq);
}
prev = Some(q);
prev_p = pq;
}
}
BrushDabs {
@@ -471,6 +520,7 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa
density: (s.density / 100.0).clamp(0.0, 1.0) as f32,
erase: s.erase,
auto: s.auto_mask,
pressure,
}
}
@@ -596,9 +646,11 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i
return;
}
for s in strokes {
let BrushDabs { dabs, r, hard, flow, density: dens, erase, auto } = brush_dabs(s, frame, w, h);
let bd = brush_dabs(s, frame, w, h);
let (dens, erase, auto) = (bd.density, bd.erase, bd.auto);
let mut stroke_alpha = Plane::new(w, h);
for d in &dabs {
for (k, d) in bd.dabs.iter().enumerate() {
let (r, hard, flow) = bd.dab(k);
let refc = auto.then(|| {
let j = dab_pixel(*d, w, h);
(log_l.data[j], chromaticity(img.data[j]))
@@ -623,7 +675,7 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i
}
}
if auto {
let (sigma, eps) = auto_refine(r);
let (sigma, eps) = auto_refine(bd.r);
let q = guided_cross(log_l, &stroke_alpha, sigma, eps);
// the refinement fills gaps along edges; it never takes paint away
stroke_alpha = stroke_alpha.zip_map(&q, f32::max);
@@ -794,6 +846,39 @@ mod tests {
assert!(v.len() <= 1_000_002, "{}", v.len());
}
/// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile
/// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure.
#[test]
fn pen_pressure_scales_dab_size_and_flow() {
let (w, h) = (400, 100);
let f = frame(w, h);
let img = Rgb32f::new(w, h);
let l = Plane::new(w, h);
let line = vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)];
let alpha = |pressure: Vec<f32>| {
let s = BrushStroke { points: line.clone(), size: 0.05, feather: 0.0, flow: 100.0, pressure, ..Default::default() };
shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None)
};
let full = alpha(vec![]);
assert_eq!(full.data, alpha(vec![1.0, 1.0]).data, "full pressure is no pressure");
// light at the start, full at the end: thin and faint on the left, as wide as the brush on the right
let ramp = alpha(vec![0.1, 1.0]);
let width = |a: &Plane, x: usize| (0..h).filter(|y| a.get(x, *y) > 0.01).count();
assert!(width(&ramp, 60) < width(&full, 60) / 2, "{} vs {}", width(&ramp, 60), width(&full, 60));
assert!(width(&ramp, 350) + 2 >= width(&full, 350));
assert!(ramp.get(60, 50) < full.get(60, 50) + 1e-6);
let faint = alpha(vec![0.2]);
assert!(faint.data.iter().zip(&full.data).all(|(a, b)| *a <= *b + 1e-6), "never more than full pressure");
assert!(faint.get(200, 50) > 0.0 && faint.get(200, 50) < full.get(200, 50));
for hostile in [vec![f32::NAN, f32::INFINITY], vec![-3.0], vec![0.5; 10_000]] {
let a = alpha(hostile);
assert!(a.data.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)));
}
assert_eq!(pressure_radius(10.0, f32::NAN), 10.0);
assert_eq!(pressure_radius(10.0, 0.0), 1.0);
assert_eq!(pressure_flow(0.8, 2.0), 0.8);
}
#[test]
fn brush_and_subtract() {
let f = frame(200, 100);
+55
View File
@@ -272,6 +272,61 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() {
assert!(d <= 2, "max difference {d}");
}
#[test]
fn color_range_sampling_respects_disabled_sections() {
use crate::{MaskView, Overlay, color_range_sample};
let src = Rgb32f::from_fn(32, 24, |_, _| [0.15, 0.25, 0.08]);
let info = SourceInfo::default();
let req = RenderRequest::fit(32, 24);
let point = lightcraft_geom::Point::new(0.5, 0.5);
let plain = color_range_sample(&src, &info, &DevelopSettings::default(), &req, point).unwrap();
for section in ["light", "color"] {
let mut s = DevelopSettings::default();
if section == "light" {
s.light.exposure = 3.0;
} else {
s.wb.mode = lightcraft_develop::WbMode::Custom;
s.wb.temp = 12000.0;
s.wb.tint = 100.0;
}
assert_ne!(color_range_sample(&src, &info, &s, &req, point).unwrap(), plain, "enabled {section}");
s.set_section_enabled(section, false);
let sample = color_range_sample(&src, &info, &s, &req, point).unwrap();
assert_eq!(sample, plain, "disabled {section} must not change the picked colour");
s.masks.push(lightcraft_develop::Mask {
id: 1,
components: vec![lightcraft_develop::MaskComponent {
name: None,
op: lightcraft_develop::MaskOp::Add,
invert: false,
shape: lightcraft_develop::MaskShape::ColorRange { samples: vec![sample], refine: 0.0 },
}],
..Default::default()
});
let overlay = RenderRequest { overlay: Overlay::Mask { id: 1, view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }, ..req };
let img = render(&src, &info, &s, &overlay).image;
assert_eq!(img.data[12 * 32 + 16], [255; 4], "the sampled colour is selected with {section} off");
}
}
#[test]
fn color_range_sampling_uses_the_requested_hdr_rendition() {
use crate::{OutputDepth, color_range_sample};
let src = Rgb32f::from_fn(32, 24, |_, _| [0.15, 0.25, 0.08]);
let info = SourceInfo::default();
let point = lightcraft_geom::Point::new(0.5, 0.5);
let mut s = DevelopSettings::default();
s.hdr.enabled = true;
s.hdr.sdr_brightness = 100.0;
let req = RenderRequest::fit(32, 24);
let expected = color_range_sample(&src, &info, &s.sdr_rendition(), &req, point).unwrap();
assert_eq!(color_range_sample(&src, &info, &s, &req, point).unwrap(), expected, "SDR preview brightness affects the picked colour");
let hdr = RenderRequest { depth: OutputDepth::F32Hdr, ..req };
let plain = color_range_sample(&src, &info, &DevelopSettings::default(), &hdr, point).unwrap();
assert_eq!(color_range_sample(&src, &info, &s, &hdr, point).unwrap(), plain, "HDR does not use the SDR offset");
assert_ne!(plain, expected);
}
/// A section whose eye is off renders as if it were at its defaults (issue #316): Light, Color
/// and Detail, which the pipeline doesn't switch off where it applies them.
#[test]
+211
View File
@@ -0,0 +1,211 @@
//! Noise reduction is resolution independent (issue #960): a preview looks like the full-size
//! render scaled down, whatever the preview's size and whichever source level it is drawn from.
use crate::local::{denoise, log_lum, nr_params};
use crate::{RenderRequest, SourceInfo, plan, render};
use lightcraft_develop::DevelopSettings;
use lightcraft_raster::resample::{Filter, resize};
use lightcraft_raster::{Rgb32f, Rgba8};
const W: usize = 1536;
const H: usize = 1024;
/// Deterministic standard normal samples (xorshift + Box-Muller).
struct Rng(u64);
impl Rng {
fn uniform(&mut self) -> f32 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
((self.0 >> 40) as f32 + 0.5) / (1u64 << 24) as f32
}
fn normal(&mut self) -> f32 {
let (u, v) = (self.uniform(), self.uniform());
(-2.0 * u.ln()).sqrt() * (std::f32::consts::TAU * v).cos()
}
}
/// A `W × H` linear scene: a gradient, an edge and texture from 3 to 750 px wavelength, with white
/// luminance noise of `noise` stops and colour noise.
fn noisy_scene(noise: f32) -> Rgb32f {
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
let n: Vec<[f32; 4]> = (0..W * H).map(|_| [rng.normal(), rng.normal(), rng.normal(), rng.normal()]).collect();
Rgb32f::from_fn(W, H, |x, y| {
let r = n[y * W + x];
let (xf, yf) = (x as f32, y as f32);
let tex = 0.25 * (xf / 120.0).sin() * (yf / 90.0).cos()
+ 0.12 * ((xf + yf) / 16.0).sin()
+ 0.08 * (xf / 6.0).sin() * (yf / 7.0).sin()
+ 0.05 * ((xf - 2.0 * yf) / 3.0).sin();
let edge = if x > 900 { 1.2 } else { 0.0 };
let y0 = 0.18 * (-1.5 + 1.5 * yf / H as f32 + tex + edge + noise * r[0]).exp2();
let c = |v: f32| y0 * (1.0 + 0.3 * noise * v).max(0.05);
[c(r[1]), c(r[2]), c(r[3])]
})
}
fn scaled(img: &Rgb32f, s: f32) -> Rgb32f {
resize(img, (W as f32 * s).round() as usize, (H as f32 * s).round() as usize, Filter::Mitchell)
}
/// RMS difference of log2 luminance (stops).
fn rms_log(a: &Rgb32f, b: &Rgb32f) -> f32 {
let n = a.data.len() as f32;
(a.data.iter().zip(&b.data).map(|(p, q)| (log_lum(*p) - log_lum(*q)).powi(2)).sum::<f32>() / n).sqrt()
}
/// RMS difference of chromaticity (r and b over r + g + b).
fn rms_chroma(a: &Rgb32f, b: &Rgb32f) -> f32 {
let ch = |p: [f32; 3]| {
let s = (p[0] + p[1] + p[2]).max(1e-6);
[p[0] / s, p[2] / s]
};
let d = |p: [f32; 3], q: [f32; 3]| (ch(p)[0] - ch(q)[0]).powi(2) + (ch(p)[1] - ch(q)[1]).powi(2);
(a.data.iter().zip(&b.data).map(|(p, q)| d(*p, *q)).sum::<f32>() / a.data.len() as f32).sqrt()
}
/// For each preview scale `k`: (k, how far the preview is from the full-size render scaled down,
/// how far it would be with noise reduction left off).
fn preview_error(s: &DevelopSettings, scales: &[f32], metric: fn(&Rgb32f, &Rgb32f) -> f32) -> Vec<(f32, f32, f32)> {
let src = noisy_scene(0.15);
let mut full = src.clone();
denoise(&mut full, s, 1.0);
scales
.iter()
.map(|&k| {
let reference = scaled(&full, k);
let plain = scaled(&src, k);
let mut preview = plain.clone();
denoise(&mut preview, s, k);
(k, metric(&preview, &reference), metric(&plain, &reference))
})
.collect()
}
const SCALES: [f32; 6] = [0.5, 0.33, 0.25, 0.167, 0.1, 0.05];
#[test]
fn luminance_nr_preview_matches_full_size_scaled_down() {
let mut s = DevelopSettings::default();
s.detail.nr_luminance = 50.0;
s.detail.nr_detail = 50.0;
s.detail.nr_color = 0.0;
for (k, err, off) in preview_error(&s, &SCALES, rms_log) {
let ratio = err / off;
eprintln!("luminance NR, preview at {k}: {err:.4} stops from the full-size render scaled down ({ratio:.2} of NR off)");
// Before #960 the radius stopped shrinking at 0.4×: 2.3× (at 0.167) to 6× (at 0.05) further
// off than no noise reduction at all, and 0.53 / 0.70 of it at 0.5 / 0.33.
assert!(ratio <= 1.0, "preview at {k} is further off than no noise reduction: {err} vs {off}");
assert!(err <= 0.01, "preview at {k} is {err} stops off");
if k >= 0.33 {
assert!(ratio <= 0.4, "preview at {k}: {ratio}");
}
}
}
#[test]
fn colour_nr_preview_matches_full_size_scaled_down() {
let mut s = DevelopSettings::default();
s.detail.nr_luminance = 0.0;
s.detail.nr_color = 50.0;
s.detail.nr_color_detail = 50.0;
s.detail.nr_color_smoothness = 50.0;
for (k, err, off) in preview_error(&s, &SCALES, rms_chroma) {
let ratio = err / off;
eprintln!("colour NR, preview at {k}: {err:.6} from the full-size render scaled down ({ratio:.2} of NR off)");
// before #960: 0.00020 (0.31 of NR off) at 0.167, 0.00022 (0.75) at 0.1
assert!(err <= 1.2e-4, "preview at {k} is {err} off");
if k >= 0.1 {
assert!(ratio <= 0.4, "preview at {k}: {ratio}");
}
}
}
#[test]
fn full_size_parameters_unchanged() {
// full-size renders and exports keep their look: these are the parameters from before #960
let mut s = DevelopSettings::default();
s.detail.nr_luminance = 50.0;
s.detail.nr_detail = 50.0;
s.detail.nr_color = 25.0;
s.detail.nr_color_detail = 50.0;
s.detail.nr_color_smoothness = 50.0;
let (l, c) = nr_params(&s, 1.0);
let (l, c) = (l.unwrap(), c.unwrap());
assert_eq!(l.sigma, 1.0 + 2.5 * 0.5);
assert_eq!(l.eps, 0.002 + 0.5 * 0.5 * 0.25 * (1.0 - 0.7 * 0.5));
assert_eq!(l.k, 0.5f32.sqrt());
assert_eq!(c.sigma, (1.5 + 6.0 * 0.25) * 1.5);
assert_eq!(c.t, 0.25 * (1.0 - 0.25));
}
#[test]
fn tiny_previews_skip_noise_reduction_and_hostile_scales_are_safe() {
let mut s = DevelopSettings::default();
s.detail.nr_luminance = 100.0;
s.detail.nr_color = 100.0;
// a 128-px thumbnail of a 6000-px photo: the luminance radius is a few hundredths of a pixel,
// the colour radius (wider) a quarter of one, so that fades to a tenth
let (l, c) = nr_params(&s, 128.0 / 6000.0);
assert_eq!(l, None);
let (c, full) = (c.unwrap(), nr_params(&s, 1.0).1.unwrap());
assert!(c.t < full.t * 0.1 && c.t > 0.0, "{c:?}");
assert_eq!(nr_params(&s, 0.0), (None, None));
for k in [f32::NAN, f32::INFINITY, -1.0, 7.0] {
let (l, c) = nr_params(&s, k);
let vals = [l.map(|l| [l.sigma, l.eps, l.k]), c.map(|c| [c.sigma, c.t, 1.0])];
for v in vals.into_iter().flatten().flatten() {
assert!(v.is_finite() && v >= 0.0, "{k}: {v}");
}
}
}
fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f32 {
let px = |p: &[u8; 4], q: &[u8; 4]| (0..3).map(|i| (f32::from(p[i]) - f32::from(q[i])).abs()).sum::<f32>() / 3.0;
a.data.iter().zip(&b.data).map(|(p, q)| px(p, q)).sum::<f32>() / a.data.len() as f32
}
#[test]
fn preview_from_a_smaller_source_level_matches_one_from_the_original() {
// The app draws a grid cell from the 512-px thumbnail level and the loupe from the 2560-px
// preview level. Told the original's size, a render from the smaller copy denoises like one from
// the original at the same output size; before #960 its radii were in the copy's pixels.
let mut s = DevelopSettings::default();
s.detail.nr_luminance = 60.0;
s.detail.nr_color = 40.0;
let original = noisy_scene(0.15);
let thumb_level = scaled(&original, 512.0 / W as f32);
let req = RenderRequest::fit(256, 256);
let raw = SourceInfo { raw: true, ..Default::default() };
let told = SourceInfo { native_long: Some(W), ..raw.clone() };
assert!((plan(&thumb_level, &told, &s, &req).detail_scale - 256.0 / W as f32).abs() < 1e-4);
assert!((plan(&original, &raw, &s, &req).detail_scale - 256.0 / W as f32).abs() < 1e-4);
assert!((plan(&thumb_level, &raw, &s, &req).detail_scale - 0.5).abs() < 1e-4, "original unknown: the source's pixels");
let mut plain = s.clone();
plain.detail.nr_luminance = 0.0;
plain.detail.nr_color = 0.0;
let from_original = render(&original, &raw, &s, &req).image;
let told_diff = mean_abs_diff(&render(&thumb_level, &told, &s, &req).image, &from_original);
let untold_diff = mean_abs_diff(&render(&thumb_level, &raw, &s, &req).image, &from_original);
// (the two sources differ a little anyway: the thumbnail level is resampled twice)
let resampling = mean_abs_diff(&render(&thumb_level, &told, &plain, &req).image, &render(&original, &raw, &plain, &req).image);
eprintln!("mean |Δ| (8-bit) from the original's render: told {told_diff:.3}, not told {untold_diff:.3}, NR off {resampling:.3}");
assert!(told_diff <= resampling + 0.1, "{told_diff} vs {resampling}");
assert!(untold_diff > told_diff + 0.3, "the copy's own pixels change the result: {untold_diff} vs {told_diff}");
}
#[test]
fn crop_does_not_change_the_noise_reduction_scale() {
// a 1:1 render of a crop is at the original's pixels, like a 1:1 render of the whole photo
let s = DevelopSettings::default();
let src = Rgb32f::filled(400, 200, [0.2; 3]);
let info = SourceInfo::default();
assert!((plan(&src, &info, &s, &RenderRequest::fit(400, 200)).detail_scale - 1.0).abs() < 1e-6);
let mut cropped = s.clone();
cropped.crop.geometry.rect = lightcraft_geom::Rect::new(0.25, 0.25, 0.75, 0.75);
let p = plan(&src, &info, &cropped, &RenderRequest::fit(200, 100));
assert_eq!((p.w, p.h), (200, 100));
assert!((p.detail_scale - 1.0).abs() < 1e-6, "{}", p.detail_scale);
}
+32
View File
@@ -238,6 +238,38 @@ fn a_mask_overlay_window_matches_the_whole_render() {
}
}
// Given the Show All Masks view (issue #1712), a window of it matches the same pixels of the
// whole render with that overlay: the union is composed from the same mask alphas, so the zoomed
// loupe can show it as a window like a single mask's overlay (issue #547)
#[test]
fn an_all_masks_overlay_window_matches_the_whole_render() {
use crate::{MaskView, Overlay};
use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let shapes = [
(3, MaskShape::Radial { center: Point::new(0.45, 0.5), rx: 0.2, ry: 0.15, angle: 20.0, feather: 50.0, invert: false }, 0.0),
(4, MaskShape::Linear { start: Point::new(0.4, 0.55), end: Point::new(0.6, 0.5) }, 100.0),
];
let mut s = DevelopSettings::default();
for (id, shape, refine) in shapes {
s.masks.push(Mask {
id,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust: LocalAdjustments { exposure: 1.0, ..Default::default() },
refine,
..Default::default()
});
}
for view in [MaskView::Color, MaskView::WhiteOnBlack, MaskView::ImageOnBlack] {
let overlay = Overlay::AllMasks { view, color: [255, 0, 0], opacity: 70 };
let f = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, ..RenderRequest::fit(W, H) }).image;
let w = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, window: Some(MID), ..RenderRequest::fit(W, H) }).image;
let (max, mean) = compare(&f, &w, MID);
// (refine edges differs by a rounding step, as in `mask_edge_refinement_does_not_depend_on_the_window`)
assert!(max <= 3 && mean <= 0.25, "{view:?}: max {max} (≤ 3), mean {mean:.3} (≤ 0.25)");
}
}
// ---- Deep zoom: the wide stages' kernels are far wider than any margin -------------------------
/// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware