mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 13:07:43 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user