//! TRACES: FR-EXP-4 //! Output sharpening, scaled by how far the image was resized. //! //! # Why an export needs this at all //! //! Downsampling averages neighbouring pixels, and averaging is a low-pass //! filter: a 24 MP frame reduced to 2048px comes out measurably softer than //! the same scene shot at 2048px would be. Output sharpening puts back the //! acuity the resample removed. It is not creative sharpening — that belongs //! in the develop pipeline, acts on the full-resolution image, and is a //! different control entirely. //! //! # Why the strength depends on the medium //! //! The three settings are not intensities dressed up as names. A screen shows //! a pixel as a pixel, so it needs the least. Ink spreads into paper — dot //! gain — and matte stock spreads it further than glossy, so a print needs //! more compensation to arrive looking the same. That is why the paper //! options are stronger, and why "more" is not simply a slider. use dr_types::OutputSharpening; /// Radius of the unsharp mask, in pixels. /// /// Fixed at a small value rather than scaled with the image: output /// sharpening compensates for the *resample*, which softens over a pixel or /// two whatever the size of the frame. A radius that grew with the image /// would produce haloes on a large export. const RADIUS: i32 = 1; /// Per-setting strength. Applied on top of the resize-derived scaling below. fn strength(setting: OutputSharpening) -> f32 { match setting { OutputSharpening::None => 0.0, OutputSharpening::Screen => 0.55, // Ink spread. Matte stock absorbs more than glossy, so it needs the // heavier hand of the two. OutputSharpening::GlossyPaper => 0.85, OutputSharpening::MattePaper => 1.15, } } /// Sharpen in place-ish: takes the resized buffer and returns it, sharpened. /// /// `scale` is the resize factor — destination width over source width. Below /// 1 the image was reduced and needs compensation; at or above 1 nothing was /// averaged away and the sharpening is skipped, because sharpening an image /// that was not softened only adds haloes. pub fn apply( mut rgba: Vec, width: u32, height: u32, setting: OutputSharpening, scale: f32, ) -> Vec { let base = strength(setting); if base == 0.0 || scale >= 1.0 || width < 3 || height < 3 { return rgba; } // A frame reduced to a tenth lost far more than one reduced to nine // tenths, so the compensation follows the reduction. Capped at the base // strength: past a point more sharpening is just edge artefacts, and a // thumbnail is the case where that shows most. let amount = base * (1.0 - scale).clamp(0.0, 1.0); let src = rgba.clone(); let (w, h) = (width as i32, height as i32); for y in 0..h { for x in 0..w { for c in 0..3 { // A 3×3 box blur is the mask. Gaussian would be more correct // and, at radius 1, indistinguishable — the kernel is nine // pixels either way. let mut sum = 0.0f32; let mut n = 0.0f32; for dy in -RADIUS..=RADIUS { for dx in -RADIUS..=RADIUS { let sx = (x + dx).clamp(0, w - 1); let sy = (y + dy).clamp(0, h - 1); sum += f32::from(src[((sy * w + sx) * 4 + c) as usize]); n += 1.0; } } let blurred = sum / n; let p = ((y * w + x) * 4 + c) as usize; let original = f32::from(src[p]); // Unsharp mask: the original plus its difference from a // blurred copy, which is the high-frequency detail. let sharpened = original + (original - blurred) * amount; rgba[p] = sharpened.round().clamp(0.0, 255.0) as u8; } } } rgba } #[cfg(test)] mod tests { use super::*; /// A frame split down the middle: dark left, light right. One vertical /// edge, which is what sharpening acts on. fn edge(w: u32, h: u32) -> Vec { let mut v = Vec::new(); for _ in 0..h { for x in 0..w { let level = if x < w / 2 { 60 } else { 190 }; v.extend_from_slice(&[level, level, level, 255]); } } v } fn at(buf: &[u8], w: u32, x: u32, y: u32) -> u8 { buf[((y * w + x) * 4) as usize] } #[test] fn none_leaves_the_image_exactly_as_it_was() { let src = edge(16, 8); let out = apply(src.clone(), 16, 8, OutputSharpening::None, 0.5); assert_eq!(out, src); } #[test] fn an_unresized_export_is_not_sharpened() { // Nothing was averaged away, so there is nothing to compensate for // and sharpening would only add haloes. let src = edge(16, 8); assert_eq!( apply(src.clone(), 16, 8, OutputSharpening::MattePaper, 1.0), src ); } #[test] fn sharpening_increases_contrast_across_an_edge() { // The property, stated directly: the dark side of the edge gets // darker and the light side lighter. let src = edge(16, 8); let out = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.4); let (before_dark, before_light) = (at(&src, 16, 7, 4), at(&src, 16, 8, 4)); let (after_dark, after_light) = (at(&out, 16, 7, 4), at(&out, 16, 8, 4)); assert!(after_dark < before_dark, "the dark side should deepen"); assert!(after_light > before_light, "the light side should lift"); } #[test] fn paper_sharpens_harder_than_screen() { // Ink spreads; the settings are about the medium, not taste. let src = edge(16, 8); let screen = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.4); let matte = apply(src.clone(), 16, 8, OutputSharpening::MattePaper, 0.4); assert!(at(&matte, 16, 8, 4) > at(&screen, 16, 8, 4)); assert!(strength(OutputSharpening::MattePaper) > strength(OutputSharpening::GlossyPaper)); } #[test] fn a_bigger_reduction_sharpens_more() { let src = edge(16, 8); let mild = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.9); let severe = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.1); assert!(at(&severe, 16, 8, 4) >= at(&mild, 16, 8, 4)); } #[test] fn a_flat_field_is_untouched() { // No detail means no high frequencies to amplify. If this drifts, the // mask is not centred and every sky gains a gradient. let flat = vec![128u8; 16 * 16 * 4]; assert_eq!( apply(flat.clone(), 16, 16, OutputSharpening::MattePaper, 0.3), flat ); } #[test] fn alpha_is_never_touched() { // The loop runs over three channels for a reason: sharpening alpha // would put a halo in the transparency of an image that has none. let out = apply(edge(16, 8), 16, 8, OutputSharpening::MattePaper, 0.2); for px in out.chunks_exact(4) { assert_eq!(px[3], 255); } } #[test] fn a_frame_too_small_to_have_neighbours_is_left_alone() { let tiny = vec![10u8; 2 * 2 * 4]; assert_eq!( apply(tiny.clone(), 2, 2, OutputSharpening::Screen, 0.5), tiny ); } }