mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-12 17:00:37 -06:00
* 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:
co-authored by
Claude Opus 5.5
FredApps
parent
a270765816
commit
d977d3e1a4
@@ -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,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];
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user