Files
DarkRoom/core/dr-export/src/sharpen.rs
T
dtourolleandClaude Opus 5 151dcc3c02 Make a file out of a photograph
Export existed as a settings page and nothing else: format, quality, colour
space, five sizing modes, a filename template and a metadata switch, all
configurable in detail, and no way to produce a single file. dr-export is the
other half.

**It returns bytes and a name, and writes nothing.** An export has three
destinations with nothing in common — a path on Linux, a SAF document on
Android where there is no path at all (ARCH §6.9), and a PUT to a Nextcloud
folder — so a crate that opened the file itself would serve one of them and be
rewritten for the other two. The caller places the bytes.

Resize, then sharpen, then encode, in that order and for a reason: output
sharpening compensates for the softening the resample introduced, so its
strength scales with how much scaling actually happened, and sharpening before
shrinking would throw the result away. Lanczos-3, separable, with weights
computed once per output row — FR-EXP-4 asks for Lanczos or better because a
box filter turns a distant fence into moiré.

Collision handling takes the "is this name taken" test as a closure rather
than looking at a directory, because there is no directory it could look at
that works everywhere. That shape is not politeness toward Linux: Android's
createDocument renames on collision by itself and cannot overwrite at all, so
all three CollisionPolicy settings need the answer *before* anything is
created. Overwrite, Skip and Increment are each tested, and Increment gives up
after ten thousand rather than spinning against a destination that reports
everything as taken.

Three things are honest rather than done:

  - **Colour space.** sRGB only. The shader encodes and clips to sRGB before
    this crate sees a pixel, so tagging a file Display P3 would claim a gamut
    it does not contain. Refused with a typed error instead of mislabelled;
    honouring it is a pipeline change (FR-EXP-2).
  - **AVIF and JPEG XL.** No encoder. libaom and libjxl are C, ravif is slow
    enough to change what a batch feels like, and the settings page offers
    both because FR-EXP-1 lists them — so asking for one says so rather than
    writing a JPEG under a .avif name.
  - **16-bit TIFF** is a real 16-bit file carrying eight bits of information,
    because AdjustPass renders to Rgba8Unorm. Widened by *257, not <<8, so
    white lands on 65535 rather than a quarter-percent grey. Making it mean
    what it says needs the composer told what format to write.

Metadata is not written at all, which satisfies the half of FR-EXP-8 that
matters most: strip_location defaults to on, and a file with no EXIF block has
no GPS tag. Retaining camera and copyright when asked is not implemented and
cannot be faked by omission.

Also here:

  - `AdjustPass::export_pixels`, ungated where `read_output` is behind a
    feature. The two are the same transfer and opposites in intent: reading
    pixels back to *display* them is what ARCH §6.1 forbids and AC-8 asserts
    against, while reading them back to encode a JPEG is the only way a file
    has ever been made. Separate methods so the instrumentation can count one
    without counting the other.
  - `ExportTarget`, so a destination can be a folder on the server. On Android
    that is the only destination needing no platform work whatsoever — a PUT
    against create_dir, already on the RemoteBackend trait, behaving
    identically on both platforms. Switching target clears the destination,
    since a path is not a remote folder and carrying one across would offer to
    create a folder called `home` at the library root.

Verified end to end rather than by unit test alone: `cargo run -p dr-export
--example export` decodes a frame, runs the develop chain on the GPU at full
resolution, reads it back, and writes all five formats — 27 ms for a
full-size JPEG, 165 ms with a Lanczos reduction to 1200px. ImageMagick agrees
the 16-bit TIFF is 16-bit. dr-export cross-compiles clean for
aarch64-linux-android; all three encoders are pure Rust, which is why they
were chosen. 944 tests pass, clippy and fmt clean.

Not yet wired to a button. The develop view has no export action, so nothing
in the running app can reach any of this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:26:37 +02:00

198 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<u8>,
width: u32,
height: u32,
setting: OutputSharpening,
scale: f32,
) -> Vec<u8> {
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<u8> {
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
);
}
}