LR-EDIT-DETAIL-SHARPEN: Radius and Detail work, and sharpening acts at pixel scale (#757) (#762)

* LR-EDIT-DETAIL-SHARPEN: Radius and Detail work, and sharpening acts at pixel scale (#757)

* LR-EDIT-DETAIL-SHARPEN: an enlarging export keeps every Radius step (#757)

The sharpening blur's sigma was capped at 4 px, so a 2x export rendered
Radius 2 and 3 alike (both scaled past 4 px). The cap is now the largest
sigma the Gaussian taps support, GAUSS_SIGMA_MAX (64), which the CPU and
GPU blurs share. Test: enlarged_export_keeps_radius_steps_apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM

* LR-EDIT-DETAIL-SHARPEN: preserve process-aware previews after rebase (#757)

Use the V3 pixel-scale blur and Masking weight for the Radius, Detail and
Masking previews on both renderers. Preserve the legacy texture-band previews
for V1/V2 and compute the same Gaussian when sharpening is disabled.

Cover process selection, radius changes, and prepared/fresh plane equivalence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
Phil Hart
2026-11-05 05:21:13 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 FredApps
parent a270765816
commit d977d3e1a4
18 changed files with 728 additions and 57 deletions
+5 -2
View File
@@ -84,17 +84,20 @@ pub enum Process {
/// Highlights and Shadows keep the local contrast of the tones they move: their edge-aware base
/// is taken of a fine median of log luminance (issue #632).
V2,
/// Pixel-scale sharpening with working Radius and Detail controls.
V3,
}
impl Process {
/// Every process, oldest first.
pub const ALL: [Process; 2] = [Process::V1, Process::V2];
pub const LATEST: Process = Process::V2;
pub const ALL: [Process; 3] = [Process::V1, Process::V2, Process::V3];
pub const LATEST: Process = Process::V3;
pub const fn version(self) -> ProcessVersion {
match self {
Process::V1 => ProcessVersion(1),
Process::V2 => ProcessVersion(2),
Process::V3 => ProcessVersion(3),
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 74;
pub const RENDER_CACHE_VERSION: u64 = 75;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
+5
View File
@@ -32,6 +32,11 @@ const MODULES: &[Module] = &[
bindings: &[("src", false, "f32"), ("dst", true, "f32")],
entries: &["box_h", "box_v", "minmax", "median_h", "median_v"],
},
Module {
src: include_str!("wgsl/gauss.wgsl"),
bindings: &[("src", false, "f32"), ("taps", false, "f32"), ("dst", true, "f32")],
entries: &["gauss_h", "gauss_v"],
},
Module {
src: include_str!("wgsl/resize.wgsl"),
bindings: &[("src", false, "f32"), ("table", false, "u32"), ("dst", true, "f32")],
+12
View File
@@ -28,6 +28,11 @@ const FIELDS: &[(&str, usize)] = &[
("DEHAZE", 1),
("SHARPEN", 1),
("SHARPEN_MASK", 1),
("SHARPEN_LIMIT", 1),
("SHARPEN_GAIN", 1),
("SHARPEN_V3", 1),
("SHP_OFF", 1),
("HAS_SHP", 1),
("HAS_CLAR", 1),
("HAS_TEX", 1),
("HAS_DARK", 1),
@@ -147,6 +152,8 @@ impl Block {
pub struct Present {
pub clarity: bool,
pub texture: bool,
/// The sharpening blur plane (after the texture plane in `tex` when both are present).
pub sharpen: bool,
pub dark: bool,
/// The blurred chromaticity follows the mask planes in the `masks` buffer.
pub chroma: bool,
@@ -195,6 +202,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr
p.f("DEHAZE", fp.dehaze);
p.f("SHARPEN", fp.sharpen);
p.f("SHARPEN_MASK", fp.sharpen_mask);
p.f("SHARPEN_LIMIT", fp.sharpen_limit);
p.f("SHARPEN_GAIN", fp.sharpen_gain);
p.b("SHARPEN_V3", fp.pixel_sharpen);
p.u("SHP_OFF", if present.texture { (fp.w * fp.h) as u32 } else { 0 });
p.b("HAS_SHP", present.sharpen);
p.b("HAS_CLAR", present.clarity);
p.b("HAS_TEX", present.texture);
p.b("HAS_DARK", present.dark);
+35 -5
View File
@@ -44,6 +44,7 @@ struct Planes {
base: Option<(local::BaseKey, Arc<Buf>)>,
clarity: Option<(u32, Arc<Buf>)>,
texture: Option<(u32, Arc<Buf>)>,
sharpen: Option<(local::SharpenKey, Arc<Buf>)>,
dark: Option<(u32, Arc<Buf>, f32)>,
chroma: Option<(u32, Arc<Buf>)>,
}
@@ -105,6 +106,7 @@ impl GpuStages {
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.sharpen.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()) {
@@ -277,6 +279,18 @@ pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize
if last == 0 { a } else { b }
}
/// Exact Gaussian (`lightcraft_raster::blur::gaussian_taps`) of a one-channel plane.
pub(crate) fn gaussian_fir(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, sigma: f32) -> Buf {
let taps = lightcraft_raster::blur::gauss_taps(sigma);
let taps_buf = cx.gpu.upload(&taps);
let r = (taps.len() / 2) as u32;
let (tmp, out) = (cx.gpu.buffer(w * h), cx.gpu.buffer(w * h));
let p = [w as u32, h as u32, r];
cx.run("gauss_h", &p, &[Some(src), Some(&taps_buf), Some(&tmp)], groups2(w, h, [16, 16]));
cx.run("gauss_v", &p, &[Some(&tmp), Some(&taps_buf), Some(&out)], groups2(w, h, [16, 16]));
out
}
/// 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);
@@ -733,9 +747,22 @@ pub fn render(
let present = Present {
clarity: prep.clarity.is_some(),
texture: prep.texture.is_some(),
sharpen: prep.sharpen.is_some(),
dark: prep.dark.is_some(),
chroma: masks.is_some() && prep.chroma.is_some(),
};
// the finish kernel's `tex` binding: the texture plane, then the sharpening plane (one binding
// for both keeps the kernel within the storage-buffer count every supported device has)
let tex_buf: Option<Arc<Buf>> = match (&prep.texture, &prep.sharpen) {
(Some(t), Some(sh)) => {
let both = cx.gpu.buffer(2 * n);
cx.copy_into(t, &both, 0);
cx.copy_into(sh, &both, n);
Some(Arc::new(both))
}
(Some(b), None) | (None, Some(b)) => Some(b.clone()),
(None, None) => None,
};
let (p, aux) = finish_block(&fp, &terms, &present);
let aux = gpu.upload(&aux);
// cleared, so that work which never ran cannot pass for an image (see the check below)
@@ -758,7 +785,7 @@ pub fn render(
Some(&prep.log_l),
Some(&prep.base),
prep.clarity.as_deref(),
prep.texture.as_deref(),
tex_buf.as_deref(),
prep.dark.as_deref(),
masks.as_ref(),
Some(&aux),
@@ -836,8 +863,9 @@ pub fn render(
let overlay_mask = overlay_mask.or_else(|| {
req.overlay.reads_detail().then(|| {
let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone();
let tex = prep.texture.as_deref().map(|b| cx.read_plane(b, w, h));
lightcraft_pipeline::visualize::sharpen_plane(req.overlay, &plan.settings, &l, tex.as_ref(), plan.px_per_long)
let band = if local::pixel_sharpen(&plan.settings) { &prep.sharpen } else { &prep.texture };
let blur = band.as_deref().map(|b| cx.read_plane(b, w, h));
lightcraft_pipeline::visualize::sharpen_plane(req.overlay, &plan.settings, &l, blur.as_ref(), plan.px_per_long, plan.native_long as f64)
})?
});
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref());
@@ -1056,6 +1084,7 @@ struct Prep {
base: Arc<Buf>,
clarity: Option<Arc<Buf>>,
texture: Option<Arc<Buf>>,
sharpen: Option<Arc<Buf>>,
dark: Option<Arc<Buf>>,
/// Blurred chromaticity (local Moiré / Noise).
chroma: Option<Arc<Buf>>,
@@ -1066,7 +1095,7 @@ struct Prep {
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 sig = local::plane_sigmas(&plan.settings, plan.px_per_long, plan.native_long as f64, req.quality);
let log_l = match &planes.log_l {
Some(b) => b.clone(),
None => {
@@ -1086,6 +1115,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla
};
let clarity = sig.clarity.map(|sg| plane_at(&mut planes.clarity, sg.to_bits(), || guided_fast(cx, &log_l, w, h, sg, local::CLARITY_EPS)));
let texture = sig.texture.map(|sg| plane_at(&mut planes.texture, sg.to_bits(), || gaussian(cx, &log_l, w, h, 1, sg)));
let sharpen = sig.sharpen.map(|sg| plane_at(&mut planes.sharpen, local::sharpen_key(&plan.settings, sg), || gaussian_fir(cx, &log_l, w, h, sg)));
let (dark, air) = match sig.dark {
None => (None, 1.0),
Some(sg) => match &planes.dark {
@@ -1113,7 +1143,7 @@ fn prepare(cx: &mut Cx<'_>, lin: &Buf, plan: &Plan<'_>, req: &RenderRequest, pla
gaussian(cx, &ch, w, h, 3, sg)
})
});
Prep { log_l, base, clarity, texture, dark, chroma, air }
Prep { log_l, base, clarity, texture, sharpen, dark, chroma, air }
}
/// Most brush dabs the mask kernel evaluates per pixel (more: the CPU rasterizes the brush).
+18 -9
View File
@@ -453,19 +453,28 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
delta += cl * 0.85 * det * (0.35 + 0.65 * mid);
}
let tx = pf(F_TEX) + lt[8];
let sp = lt[13] * 0.6 + pf(F_SHARPEN);
if ((tx != 0.0 || sp != 0.0) && pu(F_HAS_TEX) != 0u) {
if (tx != 0.0 && pu(F_HAS_TEX) != 0u) {
let det = l_pre - tex[i];
let tame = 1.0 - 0.6 * sstep(0.4, 1.6, abs(det));
delta += tx * 1.1 * clamp(det, -1.0, 1.0) * tame;
if (sp != 0.0) {
let sm = pf(F_SHARPEN_MASK);
var mk = 1.0;
if (sm > 0.0) {
mk = sstep(sm * 0.25, sm * 0.25 + 0.15, abs(det));
}
delta += sp * 1.3 * clamp(det, -0.8, 0.8) * mk;
}
let sp = lt[13] * 0.6 + pf(F_SHARPEN);
if (pu(F_SHARPEN_V3) != 0u && sp != 0.0 && pu(F_HAS_SHP) != 0u) {
let det = l_pre - tex[pu(F_SHP_OFF) + i];
let sm = pf(F_SHARPEN_MASK);
var mk = 1.0;
if (sm > 0.0) {
mk = sstep(sm * 0.1, sm * 0.1 + 0.06, abs(det));
}
delta += sp * pf(F_SHARPEN_GAIN) * 7.0 * mk * det / (1.0 + abs(det) / pf(F_SHARPEN_LIMIT));
} else if (pu(F_SHARPEN_V3) == 0u && sp != 0.0 && pu(F_HAS_TEX) != 0u) {
let det = l_pre - tex[i];
let sm = pf(F_SHARPEN_MASK);
var mk = 1.0;
if (sm > 0.0) {
mk = sstep(sm * 0.25, sm * 0.25 + 0.15, abs(det));
}
delta += sp * 1.3 * clamp(det, -0.8, 0.8) * mk;
}
// local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges
if (l_noise != 0.0 && pu(F_HAS_TEX) != 0u) {
+36
View File
@@ -0,0 +1,36 @@
// Exact separable Gaussian with explicit taps over a one-channel plane (clamped edges); mirrors
// `lightcraft_raster::blur::gaussian_taps` (same taps, same accumulation order).
// P: w, h, r (taps has 2r+1 entries). Bindings: src, taps, dst.
@compute @workgroup_size(16, 16)
fn gauss_h(@builtin(global_invocation_id) g: vec3<u32>) {
let w = pu(0u);
let h = pu(1u);
let r = i32(pu(2u));
if (g.x >= w || g.y >= h) {
return;
}
let last = i32(w) - 1;
let row = g.y * w;
var acc = 0.0;
for (var k = 0; k <= 2 * r; k++) {
acc += src[row + u32(clamp(i32(g.x) + k - r, 0, last))] * taps[u32(k)];
}
dst[row + g.x] = acc;
}
@compute @workgroup_size(16, 16)
fn gauss_v(@builtin(global_invocation_id) g: vec3<u32>) {
let w = pu(0u);
let h = pu(1u);
let r = i32(pu(2u));
if (g.x >= w || g.y >= h) {
return;
}
let last = i32(h) - 1;
var acc = 0.0;
for (var k = 0; k <= 2 * r; k++) {
acc += src[u32(clamp(i32(g.y) + k - r, 0, last)) * w + g.x] * taps[u32(k)];
}
dst[g.y * w + g.x] = acc;
}
+51
View File
@@ -242,6 +242,33 @@ fn cases() -> Vec<(&'static str, Edit)> {
s.effects.clarity = -60.0;
s.effects.texture = -40.0;
}),
("sharpen radius 2.5 detail 100", |s| {
s.detail.sharpen_amount = 100.0;
s.detail.sharpen_radius = 2.5;
s.detail.sharpen_detail = 100.0;
}),
("sharpen radius 0.6 detail 0", |s| {
s.detail.sharpen_amount = 80.0;
s.detail.sharpen_radius = 0.6;
s.detail.sharpen_detail = 0.0;
}),
("sharpen + texture + masking", |s| {
s.detail.sharpen_amount = 70.0;
s.detail.sharpen_masking = 30.0;
s.effects.texture = 40.0;
}),
("local sharpness", |s| {
s.masks = vec![Mask {
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) },
}],
adjust: lightcraft_develop::LocalAdjustments { sharpness: 80.0, ..Default::default() },
..Default::default()
}];
}),
("sharpen masking", |s| {
s.detail.sharpen_amount = 90.0;
s.detail.sharpen_masking = 60.0;
@@ -664,6 +691,30 @@ fn highlights_shadows_base_matches_on_every_process() {
}
}
#[test]
fn sharpening_matches_each_process_and_reduced_source() {
if !gpu() {
return;
}
let src = scene(0, 256, 192);
let info = SourceInfo { raw: true, native_long: Some(1024), ..Default::default() };
let req = RenderRequest::fit(128, 128);
let cache = StageCache::default();
for process in [Process::V1, Process::V2, Process::V3, Process::V2, Process::V3] {
let mut s = DevelopSettings { process: process.version(), ..Default::default() };
s.detail.sharpen_amount = 80.0;
s.detail.sharpen_radius = 2.0;
s.detail.sharpen_detail = 75.0;
for nr in [0.0, 50.0, 20.0] {
s.detail.nr_luminance = nr;
check(&format!("{process:?} sharpening NR {nr}"), &src, &info, &s, &req);
let warm = lightcraft_gpu::render(&src, &info, &s, &req, Some(&cache)).expect("gpu");
let fresh = lightcraft_gpu::render(&src, &info, &s, &req, None).expect("gpu");
assert_eq!(warm.image, fresh.image, "{process:?} NR {nr}");
}
}
}
#[test]
fn overlays_match() {
if !gpu() {
+53 -6
View File
@@ -29,6 +29,31 @@ pub fn sharpen_mask_weight(masking: f32, det: f32) -> f32 {
if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 }
}
/// Masking weight for the V3 pixel-scale sharpening band, also used by its Alt-drag preview.
#[inline]
pub fn pixel_sharpen_mask_weight(masking: f32, det: f32) -> f32 {
if masking > 0.0 { smooth(masking * 0.1, masking * 0.1 + 0.06, det.abs()) } else { 1.0 }
}
/// Gain of the sharpening detail term (per unit of `Amount / 150`), in stops of luminance per stop of detail.
pub const SHARPEN_GAIN: f32 = 7.0;
/// Soft-clip limit (stops) of the sharpening detail term for Detail `0..=100`: 0.03 stops (large
/// edge detail is flattened so halos stay small) up to 1.5 stops (practically linear).
pub fn sharpen_limit(detail: f64) -> f32 {
let d = (detail / 100.0).clamp(0.0, 1.0) as f32;
0.03 * 50f32.powf(d)
}
/// The sharpening boost (stops) for pixel-scale detail `det` (log luminance minus its blur at the
/// sharpen radius): linear for small detail, soft-clipped at `limit`, and gated by the Masking edge
/// mask (`mask` = Masking / 100) so flat areas and noise can be left alone.
#[inline]
pub fn sharpen_term(det: f32, limit: f32, mask: f32) -> f32 {
let m = pixel_sharpen_mask_weight(mask, det);
SHARPEN_GAIN * m * det / (1.0 + det.abs() / limit)
}
/// Parametric region curve (encoded domain) composed with the master point curve.
fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> {
let parametric = c.highlights != 0.0 || c.lights != 0.0 || c.darks != 0.0 || c.shadows != 0.0;
@@ -178,6 +203,12 @@ pub struct FinishParams {
pub dehaze: f32,
pub sharpen: f32,
pub sharpen_mask: f32,
/// V1/V2 retain their texture-band sharpening; V3 uses the pixel-scale plane.
pub pixel_sharpen: bool,
/// Soft-clip limit (stops) of the sharpening detail term: small damps halos (Detail 0), large keeps all (100).
pub sharpen_limit: f32,
/// Response compensation for a sharpening radius below one output pixel.
pub sharpen_gain: f32,
/// Airlight after and before exposure, exposure gain and EV (see [`crate::Prepared`]).
pub air: f32,
pub air_pre: f32,
@@ -256,6 +287,13 @@ impl FinishParams {
dehaze,
sharpen: (s.detail.sharpen_amount / 150.0) as f32,
sharpen_mask: (s.detail.sharpen_masking / 100.0) as f32,
pixel_sharpen: crate::local::pixel_sharpen(s),
sharpen_limit: sharpen_limit(s.detail.sharpen_detail),
sharpen_gain: crate::local::sharpen_gain(crate::local::sharpen_sigma_raw(
s,
px_per_long,
info.native_long.unwrap_or(0).max(frame.ow.max(frame.oh) as usize).max(1) as f64,
)),
air: air_pre * gain,
air_pre,
gain,
@@ -292,7 +330,7 @@ pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap {
let l = &s.light;
match s.process.process() {
// V2 changes the Highlights/Shadows base (`local::tone_base`), not the tone map
Process::V1 | Process::V2 => match info.camera_tone.as_ref().filter(|_| info.raw) {
Process::V1 | Process::V2 | Process::V3 => match info.camera_tone.as_ref().filter(|_| info.raw) {
Some(curve) => ToneMap::camera_hdr(curve, l.contrast, l.whites, l.blacks, peak),
None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak),
None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak),
@@ -662,17 +700,26 @@ fn local_tone_delta(p: &Prepared, fp: &FinishParams, index: usize, base: f32, l1
delta += clarity * 0.85 * detail * (0.35 + 0.65 * midtone);
}
let texture = fp.tex + l_tex;
let sharpen = l_sharp * 0.6 + fp.sharpen;
if (texture != 0.0 || sharpen != 0.0)
if texture != 0.0
&& let Some(blur) = &p.texture_blur
{
let detail = p.log_l.data[index] - blur.data[index];
let tame = 1.0 - 0.6 * smooth(0.4, 1.6, detail.abs());
delta += texture * 1.1 * detail.clamp(-1.0, 1.0) * tame;
if sharpen != 0.0 {
let mask = sharpen_mask_weight(fp.sharpen_mask, detail);
delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask;
}
let sharpen = l_sharp * 0.6 + fp.sharpen;
if fp.pixel_sharpen {
if sharpen != 0.0
&& let Some(blur) = &p.sharpen_blur
{
delta += sharpen * fp.sharpen_gain * sharpen_term(p.log_l.data[index] - blur.data[index], fp.sharpen_limit, fp.sharpen_mask);
}
} else if sharpen != 0.0
&& let Some(blur) = &p.texture_blur
{
let detail = p.log_l.data[index] - blur.data[index];
let mask = sharpen_mask_weight(fp.sharpen_mask, detail);
delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask;
}
if l_noise != 0.0
&& let Some(blur) = &p.texture_blur
+27 -4
View File
@@ -227,6 +227,8 @@ pub(crate) struct Prepared {
pub base: Arc<Plane>,
pub clarity_blur: Option<Arc<Plane>>,
pub texture_blur: Option<Arc<Plane>>,
/// Log luminance blurred at the sharpen radius (the pixel-scale band sharpening boosts).
pub sharpen_blur: Option<Arc<Plane>>,
pub dark: Option<Arc<Plane>>,
/// Blurred chromaticity (`rgb / Y`) for local Moiré / Noise.
pub chroma_blur: Option<Arc<Rgb32f>>,
@@ -329,7 +331,7 @@ impl StageCache {
}
let pl = &e.planes;
let planes = pl.log_l.iter().chain(pl.base.iter().map(|x| &x.1)).chain(pl.clarity.iter().map(|x| &x.1));
for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)) {
for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.sharpen.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)) {
add(Arc::as_ptr(p) as usize, size(p));
}
}
@@ -389,6 +391,8 @@ pub struct Plan<'a> {
pub px_per_long: f64,
/// Long edge of the source buffer (px).
pub src_long: usize,
/// Long edge of the original photo, shared by sharpening and noise reduction.
pub native_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,
@@ -478,7 +482,23 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
[d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits),
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() }
Plan {
settings,
frame,
w,
h,
px_per_long,
src_long,
native_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).
@@ -611,7 +631,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
Some(p) if req.quality == Quality::Draft && p.pre_nr == plan.pre_nr_key => p.for_other_nr(),
_ => local::Planes { key: lin_key, pre_nr: plan.pre_nr_key, ..Default::default() },
};
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_deref());
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, plan.native_long as f64, req.quality, &mut planes, plan.mattes.as_deref());
if let Some(a) = plan.fixed_air {
prep.air = a;
}
@@ -689,7 +709,8 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings,
/// (the one the render evaluated, or for a hidden mask a fresh evaluation).
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
if o.reads_detail() {
return visualize::sharpen_plane(o, &plan.settings, &prep.log_l, prep.texture_blur.as_deref(), plan.px_per_long);
let band = if local::pixel_sharpen(&plan.settings) { &prep.sharpen_blur } else { &prep.texture_blur };
return visualize::sharpen_plane(o, &plan.settings, &prep.log_l, band.as_deref(), plan.px_per_long, plan.native_long as f64);
}
let m = o.mask(&plan.settings)?;
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
@@ -763,6 +784,8 @@ mod tests_mask_geometry;
#[cfg(test)]
mod tests_nr_scale;
#[cfg(test)]
mod tests_sharpen;
#[cfg(test)]
mod tests_tone;
#[cfg(test)]
mod tests_window;
+87 -16
View File
@@ -4,7 +4,7 @@
use lightcraft_color::cct::{temp_tint_to_xy, wb_matrix, xy_to_temp_tint};
use lightcraft_color::{REC2020, luminance_2020};
use lightcraft_develop::{DevelopSettings, Process};
use lightcraft_raster::blur::gaussian;
use lightcraft_raster::blur::{gauss_taps, gaussian, gaussian_taps};
use std::sync::Arc;
use lightcraft_raster::{Plane, Rgb32f, par_join};
@@ -375,7 +375,7 @@ pub fn base_detail_radius(px_per_long: f64) -> usize {
pub fn tone_base_detail(s: &DevelopSettings, px_per_long: f64) -> usize {
match s.process.process() {
Process::V1 => 0,
Process::V2 => base_detail_radius(px_per_long),
Process::V2 | Process::V3 => base_detail_radius(px_per_long),
}
}
@@ -521,6 +521,8 @@ pub(crate) struct Planes {
pub base: Option<(BaseKey, Arc<Plane>)>,
pub clarity: Option<(u32, Arc<Plane>)>,
pub texture: Option<(u32, Arc<Plane>)>,
/// Sharpening blur (exact Gaussian at the sharpen radius).
pub sharpen: Option<(SharpenKey, Arc<Plane>)>,
/// Dark channel and its airlight.
pub dark: Option<(u32, Arc<Plane>, f32)>,
/// Blurred chromaticity (local Moiré / Noise).
@@ -532,7 +534,7 @@ impl Planes {
/// enough for a draft: the wide blurs (base, clarity, dehaze, chromaticity). Its `key` matches
/// no image, so only another draft takes them.
pub fn for_other_nr(self) -> Planes {
Planes { key: 0, pre_nr: self.pre_nr, log_l: None, texture: None, ..self }
Planes { key: 0, pre_nr: self.pre_nr, log_l: None, texture: None, sharpen: None, ..self }
}
}
@@ -541,6 +543,16 @@ impl Planes {
/// processes, so a photo moved to another one (Update to Current Process, undo) recomputes it.
pub type BaseKey = (u32, Process, usize);
pub type SharpenKey = (u32, Process);
pub fn sharpen_key(s: &DevelopSettings, sigma: f32) -> SharpenKey {
(sigma.to_bits(), s.process.process())
}
pub fn pixel_sharpen(s: &DevelopSettings) -> bool {
s.process.process() >= Process::V3
}
/// The [`BaseKey`] of a base at `sigma` for `s` (`detail`: [`PlaneSigmas::base_detail`]).
pub fn base_key(s: &DevelopSettings, sigma: f32, detail: usize) -> BaseKey {
(sigma.to_bits(), s.process.process(), detail)
@@ -584,6 +596,8 @@ pub struct PlaneSigmas {
pub clarity: Option<f32>,
/// Texture / sharpening band (Gaussian).
pub texture: Option<f32>,
/// Sharpening blur (exact Gaussian, see [`sharpen_sigma`]).
pub sharpen: Option<f32>,
/// Dehaze dark channel (Gaussian).
pub dark: Option<f32>,
/// Chromaticity blur for local Moiré / colour noise (Gaussian).
@@ -595,7 +609,45 @@ pub fn texture_sigma(px_per_long: f64) -> f32 {
(0.0018 * px_per_long as f32).max(0.6)
}
pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas {
/// Sharpening blur radius in output pixels before clamping: Radius (source pixels) times the
/// output pixels per original pixel (`native_long` = the original photo's long edge), so a preview and
/// an export show the same detail at the same displayed scale.
pub fn sharpen_sigma_raw(s: &DevelopSettings, px_per_long: f64, native_long: f64) -> f32 {
let r = s.detail.sharpen_radius;
let radius = if r.is_finite() { r.clamp(0.1, 10.0) } else { 1.0 };
(radius * px_per_long / native_long.max(1.0)) as f32
}
/// Smallest and largest sigma (px) of the sharpening blur. The largest is the most the Gaussian taps
/// support (CPU and GPU share them), so an enlarging export keeps every Radius step distinct.
pub const SHARPEN_SIGMA_MIN: f32 = 0.5;
pub const SHARPEN_SIGMA_MAX: f32 = lightcraft_raster::blur::GAUSS_SIGMA_MAX;
/// The sharpening blur's sigma for a raw value of [`sharpen_sigma_raw`].
pub fn sharpen_sigma(raw: f32) -> f32 {
raw.clamp(SHARPEN_SIGMA_MIN, SHARPEN_SIGMA_MAX)
}
/// Match the continuous Gaussian's Nyquist response below half a pixel, using a representable
/// FIR blur. Blend the correction out between half and one pixel: no jump at the floor, and
/// full-pixel sharpening (including Radius 1 at native size) is unchanged.
pub fn sharpen_gain(raw: f32) -> f32 {
if !raw.is_finite() || raw <= 0.0 {
return 0.0;
}
if raw >= 1.0 {
return 1.0;
}
let taps = gauss_taps(sharpen_sigma(raw));
let radius = taps.len() / 2;
let response = taps.iter().enumerate().map(|(i, &t)| if (i + radius).is_multiple_of(2) { t } else { -t }).sum::<f32>();
let desired = -(-0.5 * std::f32::consts::PI.powi(2) * raw * raw).exp_m1();
let ratio = desired / (1.0 - response).max(1e-6);
let blend = ((1.0 - raw) / 0.5).clamp(0.0, 1.0);
1.0 + (ratio - 1.0) * blend
}
pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, native_long: f64, q: Quality) -> PlaneSigmas {
let ppl = px_per_long as f32;
// A hidden mask, or one with no components, does not change the picture. Its sliders must
// not pick the tone base Clarity reads (#1214).
@@ -614,16 +666,19 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
let clarity = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| (0.012 * ppl).max(1.0));
// local Noise and Defringe read the fine detail band too
let texture = (s.effects.texture != 0.0
|| s.detail.sharpen_amount != 0.0
|| local_any(|a| a.texture)
|| local_any(|a| a.sharpness)
|| local_any(|a| a.noise)
|| (!pixel_sharpen(s) && (s.detail.sharpen_amount != 0.0 || local_any(|a| a.sharpness)))
|| s.masks.iter().filter(live).any(|m| m.adjust.defringe > 0.0))
.then(|| texture_sigma(px_per_long));
let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0));
let chroma = (local_any(|a| a.moire) || s.masks.iter().filter(live).any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0));
let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 };
PlaneSigmas { base, base_detail, clarity, texture, dark, chroma }
// V1/V2 keep their texture-band sharpening; only V3 requests the pixel-scale plane.
let sharpen_raw = sharpen_sigma_raw(s, px_per_long, native_long);
let sharpen = (pixel_sharpen(s) && sharpen_gain(sharpen_raw) > 0.0 && (s.detail.sharpen_amount != 0.0 || local_any(|a| a.sharpness)))
.then(|| sharpen_sigma(sharpen_raw));
PlaneSigmas { base, base_detail, clarity, texture, sharpen, dark, chroma }
}
/// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure),
@@ -635,15 +690,23 @@ pub(crate) fn prepare(
s: &DevelopSettings,
frame: &Frame,
px_per_long: f64,
native_long: f64,
q: Quality,
planes: &mut Planes,
mattes: Option<&masks::Mattes>,
) -> Prepared {
let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone();
let PlaneSigmas { base: base_sigma, base_detail, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } =
plane_sigmas(s, px_per_long, q);
let PlaneSigmas {
base: base_sigma,
base_detail,
clarity: clarity_sigma,
texture: texture_sigma,
sharpen: sharpen_sigma_px,
dark: dark_sigma,
chroma: chroma_sigma,
} = plane_sigmas(s, px_per_long, native_long, q);
let Planes { base: sb, clarity: sc, texture: st, dark: sd, chroma: sch, .. } = planes;
let Planes { base: sb, clarity: sc, texture: st, sharpen: ss, dark: sd, chroma: sch, .. } = planes;
let chroma_blur = chroma_sigma.map(|sg| match sch {
Some((k, c)) if *k == sg.to_bits() => c.clone(),
_ => {
@@ -653,14 +716,22 @@ pub(crate) fn prepare(
}
});
let l = &log_l;
let (base, (clarity_blur, (texture_blur, dark))) = par_join(
let (base, (clarity_blur, ((texture_blur, sharpen_blur), dark))) = par_join(
|| base_sigma.map(|sg| plane_at(sb, base_key(s, sg, base_detail), || timed("base", || tone_base(l, sg, base_detail)))),
|| {
par_join(
|| clarity_sigma.map(|sg| plane_at(sc, sg.to_bits(), || timed("clarity", || guided_fast(l, sg, CLARITY_EPS)))),
|| {
par_join(
|| texture_sigma.map(|sg| plane_at(st, sg.to_bits(), || timed("texture", || gaussian(l, sg)))),
|| {
par_join(
|| texture_sigma.map(|sg| plane_at(st, sg.to_bits(), || timed("texture", || gaussian(l, sg)))),
|| {
sharpen_sigma_px
.map(|sg| plane_at(ss, sharpen_key(s, sg), || timed("sharpen", || gaussian_taps(l, &gauss_taps(sg)))))
},
)
},
|| {
dark_sigma.map(|sg| match sd {
Some((k, d, air)) if *k == sg.to_bits() => (d.clone(), *air),
@@ -684,7 +755,7 @@ pub(crate) fn prepare(
};
let ev = s.light.exposure as f32;
let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev, mattes));
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long }
Prepared { img, log_l, base, clarity_blur, texture_blur, sharpen_blur, dark, chroma_blur, air, masks, px_per_long }
}
/// The airlight of the whole output frame, estimated on a small render of it (`proxy_w` px wide):
@@ -814,17 +885,17 @@ mod tests {
adjust: lightcraft_develop::LocalAdjustments { highlights: 40.0, ..Default::default() },
..Default::default()
});
assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_none());
assert!(plane_sigmas(&s, 2000.0, 2000.0, Quality::Full).base.is_none());
s.masks[0].visible = true;
s.masks[0].components.clear();
assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_none());
assert!(plane_sigmas(&s, 2000.0, 2000.0, Quality::Full).base.is_none());
s.masks[0].components.push(MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) },
});
assert!(plane_sigmas(&s, 2000.0, Quality::Full).base.is_some());
assert!(plane_sigmas(&s, 2000.0, 2000.0, Quality::Full).base.is_some());
}
#[test]
+4
View File
@@ -287,12 +287,16 @@ fn drafts_keep_wide_planes_across_a_noise_reduction_change_and_full_renders_do_n
let full = RenderRequest { quality: Quality::Full, ..draft };
let mut s = typical_edits();
s.effects.clarity = 60.0;
s.detail.sharpen_amount = 80.0;
render_cached(&src, &info, &s, &draft, &cache);
let clarity_plane = |c: &StageCache| c.entries.lock().unwrap().first().and_then(|e| e.planes.clarity.clone()).unwrap().1;
let sharpen_plane = |c: &StageCache| c.entries.lock().unwrap().first().and_then(|e| e.planes.sharpen.clone()).unwrap().1;
let first = clarity_plane(&cache);
let first_sharpen = sharpen_plane(&cache);
s.detail.nr_luminance = 50.0;
render_cached(&src, &info, &s, &draft, &cache);
assert!(Arc::ptr_eq(&first, &clarity_plane(&cache)), "a draft redid the clarity plane for a noise-reduction change");
assert!(!Arc::ptr_eq(&first_sharpen, &sharpen_plane(&cache)), "a draft reused the old sharpening plane after noise reduction changed");
for v in [10.0, 45.0, 80.0] {
s.detail.nr_luminance = v;
s.detail.nr_color = v / 2.0;
+266
View File
@@ -0,0 +1,266 @@
//! Sharpening: an unsharp mask at the pixel scale, with Radius (source pixels), Detail (halo
//! damping) and Masking (edge mask), the same at every preview and export size.
use lightcraft_develop::DevelopSettings;
use lightcraft_raster::{Rgb32f, Rgba8};
use crate::{RenderRequest, SourceInfo, render};
fn info() -> SourceInfo {
SourceInfo { raw: true, ..Default::default() }
}
fn render_at(src: &Rgb32f, s: &DevelopSettings, w: usize) -> Rgba8 {
render(src, &info(), s, &RenderRequest::fit(w, w)).image
}
/// RMS difference of the green channel.
fn rms(a: &Rgba8, b: &Rgba8) -> f64 {
assert_eq!((a.width, a.height), (b.width, b.height));
let sum: f64 = a.data.iter().zip(&b.data).map(|(p, q)| (p[1] as f64 - q[1] as f64).powi(2)).sum();
(sum / a.data.len() as f64).sqrt()
}
/// A grating with the given period (px at 512 wide), in scene-linear light.
fn grating(w: usize, period: f32) -> Rgb32f {
let h = w / 8;
Rgb32f::from_fn(w, h, |x, _| {
let k = w as f32 / 512.0;
let v = 0.18 * (1.0 + 0.4 * (std::f32::consts::TAU * x as f32 / (period * k)).sin());
[v; 3]
})
}
/// A scene with a hard edge, fine texture and a gradient, defined in normalized coordinates so it
/// can be sampled at any size.
fn scene(w: usize) -> Rgb32f {
let h = w * 3 / 4;
Rgb32f::from_fn(w, h, |x, y| {
let (u, v) = (x as f32 / w as f32, y as f32 / w as f32);
let edge = if u > 0.5 { 0.3 } else { 0.08 };
let tex = 0.03 * (u * 150.0).sin() * (v * 130.0).cos();
let g = 0.1 * v;
let l = (edge + tex + g).max(0.001);
[l, l * 0.9, l * 0.8]
})
}
fn sharp(amount: f64, radius: f64, detail: f64, masking: f64) -> DevelopSettings {
let mut s = DevelopSettings::default();
s.detail.sharpen_amount = amount;
s.detail.sharpen_radius = radius;
s.detail.sharpen_detail = detail;
s.detail.sharpen_masking = masking;
s
}
#[test]
fn amount_zero_is_the_identity_whatever_radius_and_detail() {
let src = scene(256);
let plain = render_at(&src, &DevelopSettings::default(), 256);
for (r, d) in [(0.5, 0.0), (1.0, 25.0), (3.0, 100.0)] {
assert_eq!(render_at(&src, &sharp(0.0, r, d, 0.0), 256).data, plain.data, "radius {r} detail {d}");
}
}
#[test]
fn radius_changes_the_output() {
let src = scene(256);
let a = render_at(&src, &sharp(60.0, 0.8, 25.0, 0.0), 256);
let b = render_at(&src, &sharp(60.0, 2.5, 25.0, 0.0), 256);
assert_ne!(a.data, b.data);
assert!(rms(&a, &b) > 0.3, "{}", rms(&a, &b));
}
#[test]
fn larger_radius_boosts_coarser_detail() {
// gain on a fine (4 px) and a coarse (24 px) grating, for a small and a large radius
let gain = |period: f32, radius: f64| {
let src = grating(512, period);
rms(&render_at(&src, &sharp(80.0, radius, 100.0, 0.0), 512), &render_at(&src, &sharp(0.0, radius, 100.0, 0.0), 512))
};
let (fine_small, fine_large) = (gain(4.0, 0.6), gain(4.0, 3.0));
let (coarse_small, coarse_large) = (gain(24.0, 0.6), gain(24.0, 3.0));
eprintln!("fine {fine_small} {fine_large}, coarse {coarse_small} {coarse_large}");
assert!(coarse_large > 2.0 * coarse_small, "coarse: small {coarse_small}, large {coarse_large}");
assert!(fine_small > coarse_small, "a small radius favours fine detail: {fine_small} vs {coarse_small}");
// the large radius moves its emphasis to coarser detail
assert!(coarse_large / fine_large > 2.0 * coarse_small / fine_small, "{coarse_large}/{fine_large} vs {coarse_small}/{fine_small}");
}
#[test]
fn detail_changes_the_output_and_damps_halos() {
let src = scene(256);
let lo = render_at(&src, &sharp(80.0, 1.5, 0.0, 0.0), 256);
let hi = render_at(&src, &sharp(80.0, 1.5, 100.0, 0.0), 256);
let plain = render_at(&src, &sharp(0.0, 1.5, 0.0, 0.0), 256);
assert_ne!(lo.data, hi.data);
// the overshoot next to the hard edge (u = 0.5) shrinks with Detail 0
let row = 96 * 256;
let at = |img: &Rgba8, x: usize| img.data[row + x][1] as i32;
let over = |img: &Rgba8| (118..128).map(|x| (at(img, x) - at(&plain, x)).abs()).max().unwrap_or(0);
assert!(over(&lo) < over(&hi), "halo {} (detail 0) vs {} (detail 100)", over(&lo), over(&hi));
}
#[test]
fn masking_leaves_flat_areas_alone() {
// gentle noise on a flat field: unmasked sharpening changes it, a high Masking does not
let src = Rgb32f::from_fn(128, 96, |x, y| {
let n = ((x * 7 + y * 13) % 5) as f32 * 0.0015;
[0.18 + n; 3]
});
let plain = render_at(&src, &sharp(0.0, 1.0, 25.0, 0.0), 128);
let open = render_at(&src, &sharp(100.0, 1.0, 25.0, 0.0), 128);
let masked = render_at(&src, &sharp(100.0, 1.0, 25.0, 100.0), 128);
assert!(rms(&open, &plain) > rms(&masked, &plain), "{} vs {}", rms(&open, &plain), rms(&masked, &plain));
}
#[test]
fn sharpening_is_the_same_at_every_size() {
// the same photo previewed at half size and rendered in full: what sharpening adds (sharpened
// minus plain) agrees between the preview and the shrunk full render far better than with zero
let src = scene(512);
let shrink = |img: &Rgba8| -> Vec<f64> {
(0..256 * 192)
.map(|i| {
let (x, y) = (i % 256 * 2, i / 256 * 2);
let p = |dx: usize, dy: usize| img.data[(y + dy) * 512 + x + dx][1] as f64;
(p(0, 0) + p(1, 0) + p(0, 1) + p(1, 1)) / 4.0
})
.collect()
};
let added = |a: &[f64], b: &[f64]| -> Vec<f64> { a.iter().zip(b).map(|(p, q)| p - q).collect() };
let norm = |v: &[f64]| (v.iter().map(|x| x * x).sum::<f64>() / v.len() as f64).sqrt();
let (s, off) = (sharp(60.0, 2.0, 25.0, 0.0), sharp(0.0, 2.0, 25.0, 0.0));
let full = added(&shrink(&render_at(&src, &s, 512)), &shrink(&render_at(&src, &off, 512)));
let half_on = render_at(&src, &s, 256);
let half_off = render_at(&src, &off, 256);
let half: Vec<f64> = half_on.data.iter().zip(&half_off.data).map(|(p, q)| p[1] as f64 - q[1] as f64).collect();
let miss = norm(&added(&full, &half));
eprintln!("size invariance: effect {} (half) {} (full, shrunk), disagreement {miss}", norm(&half), norm(&full));
assert!(norm(&half) > 0.5, "the sharpening shows at half size");
assert!(miss < 0.6 * norm(&half), "preview and export disagree: {miss} vs an effect of {}", norm(&half));
}
#[test]
fn subpixel_blur_response_is_continuous_and_does_not_vanish() {
use crate::local::{sharpen_gain, sharpen_sigma};
use lightcraft_raster::{
Plane,
blur::{gauss_taps, gaussian_taps},
};
// An alternating grating measures the actual FIR response at Nyquist, away from edges.
let src = Plane::from_fn(256, 16, |x, _| if x % 2 == 0 { 1.0 } else { -1.0 });
let response = |sigma| {
let blur = gaussian_taps(&src, &gauss_taps(sharpen_sigma(sigma)));
(16..240).map(|x| (src.data[x] - blur.data[x]) * src.data[x]).sum::<f32>() / 224.0 * sharpen_gain(sigma)
};
for sigma in [0.1, 0.25, 0.34, 0.4, 0.4999, 0.5001] {
let desired = -(-0.5 * std::f32::consts::PI.powi(2) * sigma * sigma).exp_m1();
assert!((response(sigma) - desired).abs() < 0.001, "sigma {sigma}: {} vs {desired}", response(sigma));
}
assert!((response(0.4999) - response(0.5001)).abs() < 0.001);
assert!(response(0.75) > response(0.5));
assert_eq!(sharpen_gain(1.0), 1.0);
assert_eq!(sharpen_gain(2.0), 1.0);
}
#[test]
fn reduced_source_uses_the_original_pixel_radius() {
let original = scene(512);
let told = SourceInfo { native_long: Some(512), ..info() };
let req = RenderRequest::fit(128, 128);
let s = sharp(100.0, 2.0, 100.0, 0.0);
// Use the pipeline's own resampling for the smaller source level. Mixing resampling
// kernels adds edge ringing unrelated to whether the original pixel radius is retained.
let p = crate::plan(&original, &info(), &s, &req);
let reduced = p.frame.sample(&original, p.w, p.h);
let mut off = s.clone();
off.detail.sharpen_amount = 0.0;
let from_original = render(&original, &info(), &s, &req).image;
let from_reduced = render(&reduced, &told, &s, &req).image;
let unknown = render(&reduced, &info(), &s, &req).image;
let resampling = rms(&render(&original, &info(), &off, &req).image, &render(&reduced, &told, &off, &req).image);
let (a, b) = (rms(&from_original, &from_reduced), rms(&from_original, &unknown));
assert!(a < resampling + 0.5, "original-size radius: {a}, resampling {resampling}");
assert!(b > a + 0.3, "copy-size radius: {b}, original-size radius: {a}");
}
#[test]
fn old_processes_keep_their_sharpening_and_cache_switches_are_exact() {
use crate::{StageCache, render_cached};
use lightcraft_develop::Process;
use std::sync::Arc;
let src = Arc::new(scene(256));
let req = RenderRequest::fit(256, 256);
let cache = StageCache::default();
for process in [Process::V1, Process::V2, Process::V3, Process::V2, Process::V3] {
let mut s = sharp(80.0, 0.6, 0.0, 30.0);
s.process = process.version();
let plain = render(&src, &info(), &s, &req).image;
assert_eq!(render_cached(&src, &info(), &s, &req, &cache).image, plain);
s.detail.sharpen_radius = 3.0;
s.detail.sharpen_detail = 100.0;
let changed = render(&src, &info(), &s, &req).image;
if process < Process::V3 {
assert_eq!(changed, plain, "{process:?}");
} else {
assert_ne!(changed, plain);
}
for nr in [60.0, 20.0] {
s.detail.nr_luminance = nr;
assert_eq!(render_cached(&src, &info(), &s, &req, &cache).image, render(&src, &info(), &s, &req).image, "{process:?}, NR {nr}");
}
}
}
#[test]
fn texture_does_not_depend_on_sharpening_settings() {
let src = scene(256);
let mut a = DevelopSettings::default();
a.effects.texture = 50.0;
let mut b = a.clone();
b.detail.sharpen_radius = 3.0;
b.detail.sharpen_detail = 100.0;
assert_eq!(render_at(&src, &a, 256).data, render_at(&src, &b, 256).data);
}
#[test]
fn local_sharpness_uses_the_sharpen_band() {
use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let src = scene(256);
let mut s = DevelopSettings {
masks: vec![Mask {
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.01) },
}],
adjust: LocalAdjustments { sharpness: 100.0, ..Default::default() },
..Default::default()
}],
..Default::default()
};
let plain = render_at(&src, &DevelopSettings::default(), 256);
let with = render_at(&src, &s, 256);
assert!(rms(&with, &plain) > 0.05, "{}", rms(&with, &plain));
s.detail.sharpen_radius = 3.0;
assert_ne!(render_at(&src, &s, 256).data, with.data, "Radius reaches the local sharpness band");
}
/// An enlarging export (2× the original) scales Radius 2 and 3 to 4 and 6 px: they stay distinct
/// rather than meeting at a 4 px cap.
#[test]
fn enlarged_export_keeps_radius_steps_apart() {
use crate::Quality;
use crate::local::plane_sigmas;
let sigma = |r: f64| plane_sigmas(&sharp(100.0, r, 0.0, 0.0), 1024.0, 512.0, Quality::Full).sharpen.unwrap();
assert_eq!((sigma(2.0), sigma(3.0)), (4.0, 6.0));
let src = scene(128);
let enlarged = SourceInfo { native_long: Some(64), ..info() };
let at = |r: f64| render(&src, &enlarged, &sharp(100.0, r, 0.0, 0.0), &RenderRequest::fit(128, 128)).image;
let d = rms(&at(2.0), &at(3.0));
assert!(d > 0.1, "Radius 2 and 3 render alike at 2x: {d}");
}
+6 -6
View File
@@ -152,8 +152,8 @@ fn zero_highlights_and_shadows_render_without_the_base() {
controls::set(&mut s, "effects.clarity", 40.0);
// no base plane is computed or read (the per-pixel stage sees log luminance): the render is
// the one it always was
assert_eq!(local::plane_sigmas(&s, 1200.0, Quality::Full).base, None);
assert!(local::plane_sigmas(&with("light.shadows", 1.0), 1200.0, Quality::Full).base.is_some());
assert_eq!(local::plane_sigmas(&s, 1200.0, 1200.0, Quality::Full).base, None);
assert!(local::plane_sigmas(&with("light.shadows", 1.0), 1200.0, 1200.0, Quality::Full).base.is_some());
}
#[test]
@@ -216,7 +216,7 @@ fn v1_keeps_the_base_it_always_had() {
assert_eq!(ProcessVersion::legacy().process(), Process::V1);
for ppl in [300.0, 1200.0, 5568.0, 1e6] {
for q in [Quality::Full, Quality::Draft] {
let sig = local::plane_sigmas(&v1, ppl, q);
let sig = local::plane_sigmas(&v1, ppl, ppl, q);
assert!(sig.base.is_some());
assert_eq!(sig.base_detail, 0, "V1 at {ppl} px");
assert_eq!(local::tone_base_detail(&v1, ppl), 0);
@@ -229,8 +229,8 @@ fn v1_keeps_the_base_it_always_had() {
}
// V2 takes the median wherever the image is big enough for one
let v2 = with("light.highlights", -100.0);
assert_eq!(local::plane_sigmas(&v2, 5568.0, Quality::Full).base_detail, local::base_detail_radius(5568.0));
assert_eq!(local::plane_sigmas(&v2, 300.0, Quality::Full).base_detail, 0);
assert_eq!(local::plane_sigmas(&v2, 5568.0, 5568.0, Quality::Full).base_detail, local::base_detail_radius(5568.0));
assert_eq!(local::plane_sigmas(&v2, 300.0, 300.0, Quality::Full).base_detail, 0);
}
#[test]
@@ -259,7 +259,7 @@ fn the_base_plane_is_cached_per_process() {
let warm = render_cached(&src, &info, s, &req, &cache).image;
assert!(warm == render(&src, &info, s, &req).image, "process {:?}", s.process);
}
let sig = local::plane_sigmas(&s2, W as f64, Quality::Full);
let sig = local::plane_sigmas(&s2, W as f64, W as f64, Quality::Full);
let sg = sig.base.unwrap();
assert_ne!(local::base_key(&s1, sg, 0), local::base_key(&s2, sg, 0), "the process is part of the key");
assert_ne!(local::base_key(&s2, sg, 0), local::base_key(&s2, sg, sig.base_detail));
+60 -6
View File
@@ -244,19 +244,25 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) {
}
/// The plane a sharpening view draws ([`Overlay::reads_detail`]), from the render's log luminance
/// `log_l` and its texture blur (`None` when the render didn't need it: blurred here with the same
/// sigma). [`Overlay::SharpenDetail`]: the detail band (log luminance minus its blur, in EV);
/// `log_l` and its process-specific sharpening blur (`None` when the render didn't need it:
/// blurred here with the same kernel and sigma). [`Overlay::SharpenDetail`]: the detail band (log luminance minus its blur, in EV);
/// [`Overlay::SharpenMask`]: the sharpening weight under the photo's Masking. Both renderers call
/// this with their own planes.
pub fn sharpen_plane(o: Overlay, s: &DevelopSettings, log_l: &Plane, texture: Option<&Plane>, px_per_long: f64) -> Option<Plane> {
pub fn sharpen_plane(o: Overlay, s: &DevelopSettings, log_l: &Plane, band: Option<&Plane>, px_per_long: f64, native_long: f64) -> Option<Plane> {
if !o.reads_detail() {
return None;
}
let own;
let blur = match texture.filter(|b| (b.width, b.height) == (log_l.width, log_l.height)) {
let pixel = crate::local::pixel_sharpen(s);
let blur = match band.filter(|b| (b.width, b.height) == (log_l.width, log_l.height)) {
Some(b) => b,
None => {
own = lightcraft_raster::blur::gaussian(log_l, crate::local::texture_sigma(px_per_long));
own = if pixel {
let sigma = crate::local::sharpen_sigma(crate::local::sharpen_sigma_raw(s, px_per_long, native_long));
lightcraft_raster::blur::gaussian_taps(log_l, &lightcraft_raster::blur::gauss_taps(sigma))
} else {
lightcraft_raster::blur::gaussian(log_l, crate::local::texture_sigma(px_per_long))
};
&own
}
};
@@ -268,7 +274,13 @@ pub fn sharpen_plane(o: Overlay, s: &DevelopSettings, log_l: &Plane, texture: Op
.zip(&blur.data)
.map(|(l, b)| {
let det = l - b;
if mask { crate::finish::sharpen_mask_weight(masking, det) } else { det }
if !mask {
det
} else if pixel {
crate::finish::pixel_sharpen_mask_weight(masking, det)
} else {
crate::finish::sharpen_mask_weight(masking, det)
}
})
.collect();
Some(Plane { width: log_l.width, height: log_l.height, data })
@@ -596,6 +608,48 @@ mod tests {
assert_eq!(render(&src, &info, &s, &req(Overlay::None)).image, plain.image);
}
#[test]
fn sharpening_preview_follows_the_process_radius_and_render_planes() {
use lightcraft_develop::Process;
use lightcraft_raster::blur::{gaussian, gaussian_taps};
let l = Plane { width: 80, height: 16, data: (0..1280).map(|i| ((i % 80) as f32 * 0.73).sin() * 0.3).collect() };
let mut s = DevelopSettings::default();
let mut bands = Vec::new();
for process in Process::ALL {
s.process = process.version();
s.detail.sharpen_radius = 3.0;
s.detail.sharpen_masking = 70.0;
let pixel = crate::local::pixel_sharpen(&s);
let blur = if pixel {
let sigma = crate::local::sharpen_sigma(crate::local::sharpen_sigma_raw(&s, 80.0, 160.0));
gaussian_taps(&l, &lightcraft_raster::blur::gauss_taps(sigma))
} else {
gaussian(&l, crate::local::texture_sigma(80.0))
};
for o in [Overlay::SharpenDetail, Overlay::SharpenMask] {
let cached = sharpen_plane(o, &s, &l, Some(&blur), 80.0, 160.0).unwrap();
let fresh = sharpen_plane(o, &s, &l, None, 80.0, 160.0).unwrap();
assert_eq!(cached.data, fresh.data, "{process:?} {o:?}");
for ((&actual, &log), &base) in fresh.data.iter().zip(&l.data).zip(&blur.data) {
let det = log - base;
let expected = if o == Overlay::SharpenDetail {
det
} else if pixel {
crate::finish::pixel_sharpen_mask_weight(0.7, det)
} else {
crate::finish::sharpen_mask_weight(0.7, det)
};
assert_eq!(actual, expected);
}
}
bands.push(sharpen_plane(Overlay::SharpenDetail, &s, &l, None, 80.0, 160.0).unwrap().data);
}
assert_eq!(bands[0], bands[1], "the legacy previews stay unchanged");
assert_ne!(bands[1], bands[2], "V3 previews use the pixel-scale band");
s.detail.sharpen_radius = 1.0;
assert_ne!(bands[2], sharpen_plane(Overlay::SharpenDetail, &s, &l, None, 80.0, 160.0).unwrap().data);
}
#[test]
fn sharpening_previews_show_the_mask_and_detail() {
// a smooth ramp (left) and a hard edge (at x = 60)
+59
View File
@@ -126,8 +126,67 @@ pub fn gaussian<T: Pixel>(img: &Image<T>, sigma: f32) -> Image<T> {
a
}
/// Largest sigma [`gauss_taps`] makes taps for (larger ones are clamped to it). The GPU blur reads
/// the same taps, so it is the limit of both paths.
pub const GAUSS_SIGMA_MAX: f32 = 64.0;
/// Normalized Gaussian taps `[w(-r) .. w(r)]` for `sigma`, `r = ceil(3 sigma)`.
///
/// An exact kernel, unlike the three-box approximation of [`gaussian`], which cannot represent
/// a sigma under about 0.8 px (its boxes collapse to the identity): sharpening needs those.
pub fn gauss_taps(sigma: f32) -> Vec<f32> {
let sigma = if sigma.is_nan() { 0.05 } else { sigma.clamp(0.05, GAUSS_SIGMA_MAX) };
let r = (3.0 * sigma).ceil() as i32;
let k = -0.5 / (sigma * sigma);
let mut t: Vec<f32> = (-r..=r).map(|i| ((i * i) as f32 * k).exp()).collect();
let sum: f32 = t.iter().sum();
t.iter_mut().for_each(|v| *v /= sum);
t
}
/// Separable blur with odd-length symmetric `taps` (clamped edges), see [`gauss_taps`].
pub fn gaussian_taps<T: Pixel>(img: &Image<T>, taps: &[f32]) -> Image<T> {
let (w, h) = (img.width, img.height);
if w == 0 || h == 0 || taps.len() < 3 {
return img.clone();
}
let r = (taps.len() / 2) as isize;
let mut a = Image::<T>::new(w, h);
par_rows(&mut a.data, w, |y, row| {
let src = &img.data[y * w..(y + 1) * w];
for (x, o) in row.iter_mut().enumerate() {
let mut acc = T::zero();
for (k, t) in taps.iter().enumerate() {
let xi = (x as isize + k as isize - r).clamp(0, w as isize - 1) as usize;
acc = acc.madd(src[xi], *t);
}
*o = acc;
}
});
let mut b = Image::<T>::new(w, h);
par_rows(&mut b.data, w, |y, row| {
for (k, t) in taps.iter().enumerate() {
let yi = (y as isize + k as isize - r).clamp(0, h as isize - 1) as usize;
for (o, v) in row.iter_mut().zip(&a.data[yi * w..(yi + 1) * w]) {
*o = o.madd(*v, *t);
}
}
});
b
}
#[cfg(test)]
mod tests {
#[test]
fn fir_gaussian_has_bounded_finite_taps_for_hostile_sigmas() {
for sigma in [f32::NAN, f32::NEG_INFINITY, f32::INFINITY, f32::MAX, -5.0, 0.0, 0.1, 4.0] {
let taps = super::gauss_taps(sigma);
assert!(taps.len() <= 385 && taps.len() % 2 == 1);
assert!(taps.iter().all(|v| v.is_finite() && *v >= 0.0));
assert!((taps.iter().sum::<f32>() - 1.0).abs() < 1e-5);
}
}
/// The box radii of the formula as it was, for the check below.
fn box_radii_before(sigma: f32) -> [usize; 3] {
let n = 3.0f32;
+1 -1
View File
@@ -204,7 +204,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | |
| LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section |
| LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | |
| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | 🟡 | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking`, `crates/pipeline/src/visualize.rs` | Amount and Masking work; Radius and Detail are stored (XMP round trip) but do not change the render yet (the band is a fixed 0.18 % of the long edge). Alt/Option-drag previews: Amount greyscale, Radius / Detail the detail band sharpening amplifies, Masking the mask (white = sharpened) |
| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking`, `crates/pipeline/src/visualize.rs` | Process V3: pixel-scale sharpening, working Radius and Detail, original-photo scale in previews and exports; V1/V2 preserve the previous rendering (`crates/pipeline/src/tests_sharpen.rs`). Alt/Option-drag previews: Amount greyscale, Radius / Detail the detail band sharpening amplifies, Masking the mask (white = sharpened) |
| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960). Alt/Option-drag on Luminance, Detail and Contrast shows the photo in greyscale |
| LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | preview-size independent like luminance NR (#960) |
| LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (`lightcraft-cli denoise-serve`, `crates/denoise/src/remote.rs`); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) |
+2 -1
View File
@@ -8,7 +8,8 @@ stronger Light sliders, issue #196) without changing a single photo someone has
| Process | What it changes |
|---|---|
| **V1** | LightCraft's rendering from before process versions existed. Frozen. |
| **V2** (the latest) | Highlights and Shadows keep fine detail (issue #632): their base layer (`local::tone_base`) is the edge-aware filter of a separable median of log luminance (radius 0.15 % of the long edge, at most 24 px, `local::BASE_DETAIL`) instead of log luminance itself, so twigs, lines and texture take their surroundings' shift and keep their contrast; region edges stay sharp. CPU (`local::prepare`) and GPU prepare branch on `local::tone_base_detail` (0 = no median under V1); the base plane is cached per process (`local::BaseKey`). Renders without Highlights or Shadows (global or local) are the same as V1's. Tone map unchanged. |
| **V2** | Highlights and Shadows keep fine detail (issue #632): their base layer (`local::tone_base`) is the edge-aware filter of a separable median of log luminance (radius 0.15 % of the long edge, at most 24 px, `local::BASE_DETAIL`) instead of log luminance itself, so twigs, lines and texture take their surroundings' shift and keep their contrast; region edges stay sharp. CPU (`local::prepare`) and GPU prepare branch on `local::tone_base_detail` (0 = no median under V1); the base plane is cached per process (`local::BaseKey`). Renders without Highlights or Shadows (global or local) are the same as V1's. Tone map unchanged. |
| **V3** (the latest) | Pixel-scale sharpening (issue #757): Radius uses the original photo’s pixels, Detail controls halo damping, and downsized sources retain a response-matched sharpening band. V1 and V2 keep texture-band sharpening exactly. The sharpen plane is keyed by radius and process and invalidated after noise-reduction changes. The Highlights/Shadows base and tone map are unchanged from V2. |
## The rule