WIP: clarity and texture
Checkpoint committed by the coordinator, not by the authoring agent: the session hit its API limit mid-task and left this work uncommitted. Committed so it survives, NOT because it is finished - expect failing tests and half-applied changes. The agent resumes from here.
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
//! Clarity and texture, end to end on a real device.
|
||||
//!
|
||||
//! `dr-pipeline`'s tests assert what the composer *generates* — the kernel
|
||||
//! width, the uniforms, which lines of WGSL each node emits. None of that can
|
||||
//! tell whether the two passes compose into an unsharp mask, whether the
|
||||
//! original colour really survives the hand-off from the blur pass to the
|
||||
//! combining one, or whether the halo the soft limit is supposed to bound is
|
||||
//! actually bounded in pixels. Those are questions only a GPU answers.
|
||||
//!
|
||||
//! # Why every measurement is in stops
|
||||
//!
|
||||
//! The controls work on log luminance, and their guarantees are stated in
|
||||
//! stops: an overshoot of at most `gain * threshold`, an effect that is
|
||||
//! symmetric about neutral, a strength that does not depend on how bright the
|
||||
//! subject is. Asserting on 8-bit code values would restate all of that in a
|
||||
//! unit where none of it is true, and would need a fresh magic number for
|
||||
//! every brightness tested. So the pixels are decoded back to linear and
|
||||
//! compared as ratios.
|
||||
//!
|
||||
//! # The test image
|
||||
//!
|
||||
//! A vertical step between two **midtones** rather than between black and
|
||||
//! white. Clarity is tapered to nothing at both ends of the range on purpose
|
||||
//! (see `midtone_weight`), so a 0–255 step is the one edge in the world it is
|
||||
//! designed to leave alone, and a test built on it would measure the taper
|
||||
//! working and call it the feature not working.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::{OpId, ParamId};
|
||||
use dr_pipeline::ops::local_contrast::{Clarity, Texture};
|
||||
use dr_pipeline::{Affects, EditGraph, OutputMode};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
const CLARITY: OpId = OpId("clarity");
|
||||
const TEXTURE: OpId = OpId("texture");
|
||||
const AMOUNT: ParamId = ParamId("amount");
|
||||
|
||||
/// Large enough that texture's kernel — a tenth of clarity's — is still more
|
||||
/// than one pixel wide. At 1024 its sigma is 1.2 px; at 256 it would round to
|
||||
/// a delta and the control would honestly do nothing, which is the behaviour
|
||||
/// `texture_stops_rather_than_lying_when_the_render_is_too_small` covers and
|
||||
/// not the behaviour under test here.
|
||||
const SIZE: u32 = 1024;
|
||||
|
||||
/// The two sides of the step, as sRGB code values.
|
||||
///
|
||||
/// Both well inside the range, and roughly two stops apart — a real edge, of
|
||||
/// the kind that produces the halo this file exists to bound.
|
||||
const DARK: u8 = 90;
|
||||
const BRIGHT: u8 = 175;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
// CI runners and headless machines may have no usable adapter. Skip rather
|
||||
// than fail, exactly as the rest of this crate's device tests do.
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_decode(v: u8) -> f32 {
|
||||
let e = v as f32 / 255.0;
|
||||
if e <= 0.040_45 {
|
||||
e / 12.92
|
||||
} else {
|
||||
((e + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// A vertical step from `DARK` to `BRIGHT` at the half-way column.
|
||||
fn step_edge(ctx: &GpuContext, size: u32, tint: [f32; 3]) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..size * size)
|
||||
.flat_map(|i| {
|
||||
let x = i % size;
|
||||
let v = if x < size / 2 { DARK } else { BRIGHT } as f32;
|
||||
[
|
||||
(v * tint[0]).round() as u8,
|
||||
(v * tint[1]).round() as u8,
|
||||
(v * tint[2]).round() as u8,
|
||||
255,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
||||
}
|
||||
|
||||
/// One row of the rendered image, as linear luminance-ish red values.
|
||||
fn row(pixels: &[u8], size: u32, y: u32) -> Vec<u8> {
|
||||
(0..size)
|
||||
.map(|x| pixels[((y * size + x) * 4) as usize])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One row as full RGB triples.
|
||||
fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
|
||||
(0..size)
|
||||
.map(|x| {
|
||||
let i = ((y * size + x) * 4) as usize;
|
||||
[pixels[i], pixels[i + 1], pixels[i + 2]]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render one graph with its detail stage and read the pixels back.
|
||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
pass.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
/// A graph with one of the two controls set and everything else neutral.
|
||||
fn graph_with(op: OpId, amount: f32) -> EditGraph {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(op, AMOUNT, amount);
|
||||
g
|
||||
}
|
||||
|
||||
/// How far a pixel moved, in stops, against the same pixel unedited.
|
||||
fn stops(edited: u8, plain: u8) -> f32 {
|
||||
(srgb_decode(edited).max(1e-6) / srgb_decode(plain).max(1e-6)).log2()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() {
|
||||
// The definition of a local contrast control, as pixels: it must do
|
||||
// something at the edge and *nothing* a long way from it. An operation
|
||||
// that brightened the whole bright plateau would be an exposure slider
|
||||
// with extra steps, and it is the failure a sign error in the base
|
||||
// produces.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row(
|
||||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let edge = (SIZE / 2) as usize;
|
||||
let reach = Clarity::with_amount(100.0)
|
||||
.kernel(EditGraph::default_chain().render_scale((SIZE, SIZE), (SIZE, SIZE)))
|
||||
as usize;
|
||||
|
||||
// Far outside the kernel's reach the base equals the pixel, the detail
|
||||
// signal is zero, and the output must be the input to the last code value.
|
||||
for x in [0, reach / 2, SIZE as usize - 1 - reach / 2, SIZE as usize - 1] {
|
||||
assert!(
|
||||
edited[x].abs_diff(plain[x]) <= 1,
|
||||
"column {x} moved by {} away from any edge",
|
||||
edited[x].abs_diff(plain[x])
|
||||
);
|
||||
}
|
||||
|
||||
// And at the edge it must do the thing it is for: the bright side lifts,
|
||||
// the dark side drops, which is what "more local contrast" means.
|
||||
assert!(
|
||||
edited[edge] > plain[edge] + 4,
|
||||
"the bright side of the edge did not lift: {} vs {}",
|
||||
edited[edge],
|
||||
plain[edge]
|
||||
);
|
||||
assert!(
|
||||
edited[edge - 1] + 4 < plain[edge - 1],
|
||||
"the dark side of the edge did not drop: {} vs {}",
|
||||
edited[edge - 1],
|
||||
plain[edge - 1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_soft_limit_bounds_the_halo_at_a_hard_edge() {
|
||||
// The single most common way clarity is got wrong, held to a number.
|
||||
//
|
||||
// `t * tanh(d / t)` saturates at `t`, so no pixel may move further than
|
||||
// `gain * threshold` stops however violent the edge — a bound that holds
|
||||
// by construction rather than by tuning, and one this test takes from the
|
||||
// operation itself rather than restating.
|
||||
//
|
||||
// The comparison that gives it meaning is the second assertion: an
|
||||
// unlimited unsharp mask over this edge would move the bright side by
|
||||
// about half the step, which is more than twice as far. That is the
|
||||
// difference between a control and a white glow along the skyline.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row(
|
||||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let worst = (0..SIZE as usize)
|
||||
.map(|x| stops(edited[x], plain[x]).abs())
|
||||
.fold(0.0f32, f32::max);
|
||||
let bound = Clarity::with_amount(100.0).overshoot_bound();
|
||||
|
||||
// A code value's worth of slack: the readback is 8-bit, and a pixel
|
||||
// sitting exactly on the bound quantises either side of it.
|
||||
assert!(
|
||||
worst <= bound + 0.02,
|
||||
"a pixel moved {worst:.3} stops, past the {bound:.3} the soft limit \
|
||||
promises"
|
||||
);
|
||||
|
||||
// Half the step is what an unlimited mask would have produced at the very
|
||||
// edge, since the base there is the mean of the two plateaus.
|
||||
let unlimited = (srgb_decode(BRIGHT) / srgb_decode(DARK)).log2() / 2.0;
|
||||
assert!(
|
||||
worst < unlimited * 0.6,
|
||||
"the limit is not biting: {worst:.3} stops against the {unlimited:.3} \
|
||||
an unlimited unsharp mask would give"
|
||||
);
|
||||
// But it is still a real effect, not a control that does nothing.
|
||||
assert!(worst > 0.1, "clarity moved almost nothing: {worst:.3} stops");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_and_an_export_agree_about_the_effect() {
|
||||
// TRACES: FR-DSP-1 — the decision the radius unit rests on, proved in
|
||||
// pixels rather than in kernel widths.
|
||||
//
|
||||
// Clarity's radius is a fraction of the frame because the control is
|
||||
// compositional: "separate the subject from its background" is a statement
|
||||
// about how much of the picture the subject occupies. If that is right,
|
||||
// the *same edit* rendered at two resolutions must produce an effect of
|
||||
// the same strength covering the same proportion of the frame — which is
|
||||
// exactly what a photographer tuning on screen and exporting at full size
|
||||
// is relying on.
|
||||
//
|
||||
// Had the radius been stated in source pixels, the proxy here would show
|
||||
// half the reach and the export would be a different photograph.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
// Peak excursion in stops, and how far the effect reaches, as a fraction
|
||||
// of the frame.
|
||||
let measure = |out: u32| -> (f32, f32) {
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, out),
|
||||
out,
|
||||
out / 2,
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row(
|
||||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, out),
|
||||
out,
|
||||
out / 2,
|
||||
);
|
||||
|
||||
let moved: Vec<f32> = (0..out as usize)
|
||||
.map(|x| stops(edited[x], plain[x]).abs())
|
||||
.collect();
|
||||
let peak = moved.iter().cloned().fold(0.0f32, f32::max);
|
||||
// The width of the band that moved by more than a tenth of the peak —
|
||||
// a threshold relative to the effect, so it means the same thing at
|
||||
// both sizes.
|
||||
let touched = moved.iter().filter(|m| **m > peak * 0.1).count();
|
||||
(peak, touched as f32 / out as f32)
|
||||
};
|
||||
|
||||
let (proxy_peak, proxy_reach) = measure(SIZE / 2);
|
||||
let (export_peak, export_reach) = measure(SIZE);
|
||||
|
||||
assert!(
|
||||
(proxy_peak - export_peak).abs() < 0.03,
|
||||
"the same edit is {proxy_peak:.3} stops on the proxy and \
|
||||
{export_peak:.3} in the export"
|
||||
);
|
||||
assert!(
|
||||
(proxy_reach - export_reach).abs() < 0.02,
|
||||
"the effect covers {proxy_reach:.3} of the proxy and {export_reach:.3} \
|
||||
of the export; a radius tuned on screen must land in the file"
|
||||
);
|
||||
// And it is a real effect at both sizes, not two flat images agreeing.
|
||||
assert!(
|
||||
proxy_peak > 0.1 && proxy_reach > 0.02,
|
||||
"{proxy_peak:.3} stops over {proxy_reach:.3} of the proxy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_acts_at_a_finer_scale_than_clarity() {
|
||||
// The whole reason there are two nodes. If the two controls ever reach the
|
||||
// same distance from an edge, the second slider has become a duplicate of
|
||||
// the first and a photographer setting both is setting one thing twice.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let reach = |op: OpId| -> usize {
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row(
|
||||
&render(&mut pass, &graph_with(op, 100.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
// How many columns moved by more than a code value — the honest
|
||||
// measure of "how far from the edge does this control reach".
|
||||
(0..SIZE as usize)
|
||||
.filter(|&x| edited[x].abs_diff(plain[x]) > 1)
|
||||
.count()
|
||||
};
|
||||
|
||||
let coarse = reach(CLARITY);
|
||||
let fine = reach(TEXTURE);
|
||||
assert!(fine > 0, "texture did nothing at all");
|
||||
assert!(
|
||||
coarse > fine * 4,
|
||||
"clarity reaches {coarse} columns and texture {fine}; these are not \
|
||||
separable scales"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clarity_moves_luminance_without_moving_hue() {
|
||||
// The third halo decision, in pixels. The gain is applied as a scale on
|
||||
// the whole triple, so chromaticity is untouched; boosting the channels
|
||||
// independently would put a *coloured* fringe along every edge, arriving
|
||||
// from a control the photographer reads as contrast.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
// A strongly tinted step, so a per-channel mask would show plainly.
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 0.55, 0.25]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row_rgb(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row_rgb(
|
||||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
// Compare in linear light, where a scale is a scale. The two channel
|
||||
// ratios together fix the chromaticity, so holding both fixes the colour.
|
||||
let edge = (SIZE / 2) as usize;
|
||||
for x in [edge, edge + 1, edge + 4, edge - 1, edge - 4] {
|
||||
let ratio = |p: [u8; 3], i: usize| srgb_decode(p[i]) / srgb_decode(p[0]).max(1e-6);
|
||||
for channel in [1, 2] {
|
||||
let before = ratio(plain[x], channel);
|
||||
let after = ratio(edited[x], channel);
|
||||
assert!(
|
||||
(after - before).abs() < 0.02,
|
||||
"column {x} channel {channel}: chromaticity moved from \
|
||||
{before:.4} to {after:.4} — that is a coloured fringe"
|
||||
);
|
||||
}
|
||||
}
|
||||
// And the effect was actually applied here, or the assertion above is
|
||||
// vacuous.
|
||||
assert!(edited[edge][0].abs_diff(plain[edge][0]) > 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_clarity_softens_the_surface_without_dissolving_the_edge() {
|
||||
// The soft limit earns its keep in both directions. An unlimited mask at
|
||||
// −100 subtracts the whole detail signal and turns every edge to mud;
|
||||
// limited, it removes at most the threshold, so modelling softens and real
|
||||
// edges stand.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let softened = row(
|
||||
&render(&mut pass, &graph_with(CLARITY, -100.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let edge = (SIZE / 2) as usize;
|
||||
// The sign is the other way round from the positive case: the bright side
|
||||
// of the edge comes down and the dark side comes up.
|
||||
assert!(
|
||||
softened[edge] + 3 < plain[edge],
|
||||
"negative clarity did not soften: {} vs {}",
|
||||
softened[edge],
|
||||
plain[edge]
|
||||
);
|
||||
|
||||
// But the step itself survives. Measured in stops across the edge, so the
|
||||
// claim is about contrast and not about code values.
|
||||
let step_of = |r: &[u8]| (srgb_decode(r[edge]) / srgb_decode(r[edge - 1])).log2();
|
||||
let before = step_of(&plain);
|
||||
let after = step_of(&softened);
|
||||
assert!(
|
||||
after > before * 0.55,
|
||||
"the edge dissolved: {after:.3} stops left of {before:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_controls_cost_the_edit_nothing() {
|
||||
// Both nodes are in the default chain, and both are the widest kernels in
|
||||
// the pipeline. An unedited photograph must render through the single
|
||||
// fused dispatch it always did — no detail pass, no intermediate texture,
|
||||
// and byte-identical pixels.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, 128, [1.0, 1.0, 1.0]);
|
||||
let graph = EditGraph::default_chain();
|
||||
|
||||
assert_eq!(
|
||||
graph.compose_for(ColourSpace::Srgb).output_mode,
|
||||
OutputMode::Encoded,
|
||||
"a neutral detail operation must not change how the fused pass ends"
|
||||
);
|
||||
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
render(&mut pass, &graph, &source, 128);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
assert_eq!(pass.detail_dispatches(), 0);
|
||||
assert_eq!(pass.detail_allocations(), 0, "nothing was allocated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
|
||||
// TRACES: FR-DEV-3d. Clarity is `Affects::Detail`, so the fused colour
|
||||
// pass's result is still valid while the slider moves — which for a
|
||||
// hundred-tap kernel is the difference between an interactive control and
|
||||
// a slideshow. Invisible in the output by construction, so a dispatch
|
||||
// counter is the only thing that can see it.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, 256, [1.0, 1.0, 1.0]);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = graph_with(CLARITY, 40.0);
|
||||
render(&mut pass, &graph, &source, 256);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
assert_eq!(pass.detail_dispatches(), 2, "a separable mask is two passes");
|
||||
let pipelines = pass.cached_detail_pipelines();
|
||||
|
||||
for amount in [50.0, 60.0, 70.0] {
|
||||
graph.set_param(CLARITY, AMOUNT, amount);
|
||||
render(&mut pass, &graph, &source, 256);
|
||||
}
|
||||
assert_eq!(
|
||||
pass.colour_dispatches(),
|
||||
1,
|
||||
"the fused colour pass re-ran for a change it does not depend on"
|
||||
);
|
||||
assert_eq!(pass.detail_dispatches(), 8);
|
||||
assert_eq!(
|
||||
pass.cached_detail_pipelines(),
|
||||
pipelines,
|
||||
"an amount is a uniform, not a shader"
|
||||
);
|
||||
|
||||
// Turning on the other control adds its own pair, and only its own pair.
|
||||
graph.set_param(TEXTURE, AMOUNT, 40.0);
|
||||
render(&mut pass, &graph, &source, 256);
|
||||
assert_eq!(pass.detail_dispatches(), 12);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_controls_stack_without_overwriting_each_other() {
|
||||
// Four passes through one ping-pong, with the scratch lane changing hands
|
||||
// half way. If clarity's combining pass left the colour where its blur
|
||||
// pass had put it — or if texture's blur overwrote the colour rather than
|
||||
// the lane — the result would be a blurred image rather than a sharpened
|
||||
// one, which is loud rather than subtle.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let mut graph = graph_with(CLARITY, 80.0);
|
||||
graph.set_param(TEXTURE, AMOUNT, 80.0);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let both = row(&render(&mut pass, &graph, &source, SIZE), SIZE, SIZE / 2);
|
||||
assert_eq!(pass.detail_dispatches(), 4);
|
||||
|
||||
let edge = (SIZE / 2) as usize;
|
||||
// Both sides of the edge move the way local contrast moves them...
|
||||
assert!(both[edge] > plain[edge] + 4);
|
||||
assert!(both[edge - 1] + 4 < plain[edge - 1]);
|
||||
// ...and the plateaus are untouched, which a stray blur would not leave.
|
||||
assert!(both[0].abs_diff(plain[0]) <= 1);
|
||||
assert!(both[SIZE as usize - 1].abs_diff(plain[SIZE as usize - 1]) <= 1);
|
||||
|
||||
// Stacked, they must reach further than either alone — the coarse control
|
||||
// still working at its own scale rather than being overwritten by the fine
|
||||
// one running after it.
|
||||
let mut clarity_only = AdjustPass::new(&ctx);
|
||||
let coarse = row(
|
||||
&render(&mut clarity_only, &graph_with(CLARITY, 80.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
assert!(
|
||||
both[edge] >= coarse[edge],
|
||||
"adding texture undid clarity: {} against {}",
|
||||
both[edge],
|
||||
coarse[edge]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_contributes_nothing_where_its_scale_does_not_exist() {
|
||||
// Unlike the acutance family this is not an approximation being hidden. A
|
||||
// two-pixel surface structure is not present in a 128-pixel rendering of
|
||||
// the frame, so the honest answer is no pass at all — and clarity, a
|
||||
// hundred times wider, still runs, which is what a thumbnail should show.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, 512, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut graph = graph_with(TEXTURE, 100.0);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
render(&mut pass, &graph, &source, 128);
|
||||
assert_eq!(
|
||||
pass.detail_dispatches(),
|
||||
0,
|
||||
"texture claimed a kernel it cannot draw"
|
||||
);
|
||||
|
||||
graph.set_param(CLARITY, AMOUNT, 100.0);
|
||||
render(&mut pass, &graph, &source, 128);
|
||||
assert_eq!(pass.detail_dispatches(), 2, "clarity survives a thumbnail");
|
||||
|
||||
// And texture comes back, exactly, as soon as the view is large enough to
|
||||
// hold it — no separate path, no fade, just the kernel resolving again.
|
||||
let mut zoomed = AdjustPass::new(&ctx);
|
||||
render(&mut zoomed, &graph_with(TEXTURE, 100.0), &source, 1024);
|
||||
assert_eq!(zoomed.detail_dispatches(), 2);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# A hand-written node. `rust:` names the type in `crate::ops` that implements
|
||||
# `Operation`; its descriptor, its parameters and its passes come from that
|
||||
# type rather than from this file. It appears here anyway because `ops/` is
|
||||
# where the pipeline's order is written down, and an order kept half in YAML
|
||||
# and half in Rust would be worse than either alone.
|
||||
id: clarity
|
||||
order: 120
|
||||
|
||||
attributes: [detail]
|
||||
rust: Clarity
|
||||
|
||||
why_rust: |
|
||||
A neighbourhood operation. Clarity is defined by what the pixels around a
|
||||
pixel are doing, and the schema above describes a function of one colour —
|
||||
`wgsl:` is handed `c` and no coordinate, which is the wall the detail stage
|
||||
exists on the other side of. It declares `Affects::Detail` and returns two
|
||||
`DetailPass`es: a Gaussian of log luminance along each axis, the second of
|
||||
which also applies the mask.
|
||||
|
||||
A kernel is also not four facts. Stretching this schema to express a
|
||||
truncation rule, a soft limit and a midtone taper would produce a worse
|
||||
language than Rust, aimed at one caller.
|
||||
|
||||
placement: |
|
||||
In the detail group, after noise reduction and before sharpening.
|
||||
|
||||
Order inside the group is not arbitrary. Clarity multiplies local contrast,
|
||||
so it multiplies noise with it — running it before noise reduction would ask
|
||||
the denoiser to remove grain that clarity had already amplified into
|
||||
structure. And capture sharpening belongs last, on the picture as it will
|
||||
finally be, so that the acutance a photographer judges at 1:1 is the acutance
|
||||
in the file.
|
||||
|
||||
Before texture, which is a decade finer: coarse before fine, so the fine
|
||||
control's base is computed on the modelling the coarse one has already
|
||||
settled.
|
||||
@@ -0,0 +1,23 @@
|
||||
# A hand-written node — see `clarity.yaml`, whose implementation this shares.
|
||||
id: texture
|
||||
order: 130
|
||||
|
||||
attributes: [detail]
|
||||
rust: Texture
|
||||
|
||||
why_rust: |
|
||||
The same neighbourhood operation as clarity, at a tenth of the scale: one
|
||||
implementation in `src/ops/local_contrast.rs`, parameterised by the band it
|
||||
acts on. Two nodes rather than one node with two sliders because the radius
|
||||
is the *definition* of each control rather than a setting of it, and because
|
||||
a texture at zero must then cost nothing at all — which `is_active()` gives
|
||||
for free and a merged node would have had to hand-write.
|
||||
|
||||
placement: |
|
||||
Immediately after clarity, and for the same reasons: after noise reduction,
|
||||
which must not be handed amplified grain, and before capture sharpening,
|
||||
which belongs last.
|
||||
|
||||
After clarity specifically, so that the fine base is computed on the
|
||||
modelling the coarse control has already settled rather than the other way
|
||||
round.
|
||||
@@ -315,6 +315,37 @@ pub struct DetailPass {
|
||||
/// them in the shader. Prefer computing lengths on the CPU in
|
||||
/// [`DetailStage::passes`], where the units are named methods rather
|
||||
/// than an untyped float.
|
||||
/// - `aux: f32` and `tap_aux(coord, offset) -> f32` — **one scalar per
|
||||
/// pixel that survives to the next pass**, pre-loaded with what the
|
||||
/// previous pass left there and written back out unless the body
|
||||
/// assigns it.
|
||||
///
|
||||
/// # Why `aux` exists
|
||||
///
|
||||
/// The ping-pong hands each pass exactly one texture: what the pass before
|
||||
/// it wrote. That is enough for a chain of filters — a separable blur is
|
||||
/// two of them — and it is *not* enough for an unsharp mask, which is the
|
||||
/// shape of sharpening, clarity, texture and dehaze alike. An unsharp mask
|
||||
/// needs the blur **and** the original in the same place at the same time,
|
||||
/// and once the first pass has written its blur the original is gone.
|
||||
///
|
||||
/// Three channels cannot carry both. Even restricted to the case where the
|
||||
/// operation only moves luminance — so the colour is a luminance and two
|
||||
/// chromaticity degrees of freedom — the combining pass needs four
|
||||
/// numbers: the original luminance, two of chromaticity, and the blurred
|
||||
/// luminance. Four does not fit in three, and no encoding makes it fit.
|
||||
///
|
||||
/// The intermediate is `rgba16float` and its alpha was being written as a
|
||||
/// constant `1.0` and read by nobody, so the fourth number goes there. A
|
||||
/// blur pass leaves `c` alone and puts its result in `aux`; the pass after
|
||||
/// it therefore receives the untouched original *and* the blur, and can
|
||||
/// subtract one from the other. An operation with no use for the lane says
|
||||
/// nothing and hands on what it was given.
|
||||
///
|
||||
/// The last pass in the chain writes the display texture, whose alpha is
|
||||
/// opacity rather than scratch space, so `aux` is readable there and not
|
||||
/// written. That is exactly the right way round: the combining pass is the
|
||||
/// one that reads it.
|
||||
///
|
||||
/// Uniforms are addressed by the bare names declared in [`Self::uniforms`],
|
||||
/// exactly as a fused fragment addresses its own; the composer rewrites
|
||||
@@ -536,7 +567,11 @@ fn compose_one(
|
||||
"rgba16float",
|
||||
" // Another linear intermediate: no clip and no encode, because\n\
|
||||
\x20 // the pass after this one still has to read real values.\n\
|
||||
\x20 textureStore(output, coord, vec4<f32>(c, 1.0));"
|
||||
\x20 //\n\
|
||||
\x20 // `aux` rides in alpha. A pass that never touches it hands on\n\
|
||||
\x20 // whatever it was given, so the lane costs an operation that\n\
|
||||
\x20 // does not want it exactly one copy of a value it already read.\n\
|
||||
\x20 textureStore(output, coord, vec4<f32>(c, aux));"
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
@@ -582,6 +617,12 @@ fn tap(coord: vec2<i32>, offset: vec2<i32>) -> vec3<f32> {{
|
||||
return textureLoad(source, clamp(coord + offset, vec2<i32>(0), last), 0).rgb;
|
||||
}}
|
||||
|
||||
// The same neighbour's scratch lane — see `aux` in the body below.
|
||||
fn tap_aux(coord: vec2<i32>, offset: vec2<i32>) -> f32 {{
|
||||
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(1);
|
||||
return textureLoad(source, clamp(coord + offset, vec2<i32>(0), last), 0).a;
|
||||
}}
|
||||
|
||||
{helper_src}{encode_fn}
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
@@ -596,6 +637,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
let render_scale = u.detail_base.z;
|
||||
|
||||
var c = tap(coord, vec2<i32>(0));
|
||||
// One scalar per pixel that survives the hand-off from one pass to the
|
||||
// next, alongside the colour. See `DetailPass::wgsl` for what it is for
|
||||
// and why three channels were not enough.
|
||||
var aux = tap_aux(coord, vec2<i32>(0));
|
||||
|
||||
{{
|
||||
{indented}
|
||||
@@ -880,6 +925,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pass_can_hand_a_scalar_to_the_next_one_alongside_the_colour() {
|
||||
// What makes an unsharp mask — sharpening, clarity, texture, dehaze —
|
||||
// expressible at all in a chain that hands each pass exactly one
|
||||
// texture. The blur goes in `aux` and the colour rides through
|
||||
// untouched, so the pass that combines them receives both; without the
|
||||
// lane, four numbers would have to fit in three channels and the
|
||||
// operation could only ever be a blur.
|
||||
//
|
||||
// A pass that says nothing about `aux` hands on what it was given,
|
||||
// which is why the box blur below needs no knowledge of it.
|
||||
let ops = with_blur(0.05);
|
||||
let composed = compose_detail(
|
||||
&ops,
|
||||
RenderScale::full((512, 512)),
|
||||
dr_types::ColourSpace::Srgb,
|
||||
);
|
||||
|
||||
for pass in &composed.passes {
|
||||
assert!(
|
||||
pass.source.contains("fn tap_aux(") && pass.source.contains("var aux = tap_aux("),
|
||||
"{} cannot read the scratch lane",
|
||||
pass.label
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
composed.passes[0]
|
||||
.source
|
||||
.contains("textureStore(output, coord, vec4<f32>(c, aux));"),
|
||||
"an intermediate must carry the lane to the pass after it"
|
||||
);
|
||||
// The last pass writes the display texture, whose alpha is opacity and
|
||||
// not scratch space. Readable there, not written — which is the right
|
||||
// way round, because the combining pass is the one that reads it.
|
||||
assert!(composed.passes[1].writes_output);
|
||||
assert!(!composed.passes[1].source.contains("vec4<f32>(c, aux)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_with_no_detail_operation_composes_no_passes() {
|
||||
// The property that keeps the cost of this stage at zero for the
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
//! TRACES: FR-DEV-3 | FR-DSP-1
|
||||
//! Clarity and texture — local contrast at two scales.
|
||||
//!
|
||||
//! Both are unsharp masks. Both build a blurred *base*, subtract it from the
|
||||
//! pixel to get a local contrast signal, and add a multiple of that signal
|
||||
//! back. The only thing that separates them is the width of the blur, and
|
||||
//! that single difference is the whole of what a photographer means by the two
|
||||
//! words:
|
||||
//!
|
||||
//! - **Clarity** works at roughly a hundredth of the frame. At that scale the
|
||||
//! base is a picture of where the *subject* is, so the difference is the
|
||||
//! subject's modelling — the sense of a face standing away from its
|
||||
//! background, of cloud having volume. It is the "punch" control, and it is
|
||||
//! also the one that produces visible halos when it is got wrong, because a
|
||||
//! forty-pixel overshoot along a skyline is not a subtlety.
|
||||
//!
|
||||
//! - **Texture** works a decade finer, at a few pixels. At that scale the base
|
||||
//! is a picture of the *surface*, so the difference is skin, fabric, bark,
|
||||
//! foliage. Its overshoot is a band two or three pixels wide, which the eye
|
||||
//! reads as acutance rather than as a halo — which is exactly why it can be
|
||||
//! pushed much harder than clarity without looking artificial.
|
||||
//!
|
||||
//! # Why two nodes and not one node with two parameters
|
||||
//!
|
||||
//! The tempting shape is a single `local_contrast` node with a `clarity` and a
|
||||
//! `texture` slider, since they share every line of machinery. It is the wrong
|
||||
//! one, for four reasons that all point the same way.
|
||||
//!
|
||||
//! **The scale is not a parameter, it is the definition.** Neither control
|
||||
//! exposes a radius, and neither should: a texture slider with a large radius
|
||||
//! *is* clarity, and offering the photographer that knob would ask them to
|
||||
//! re-derive the distinction the two names already make. So the radius is a
|
||||
//! constant of the node — and a node whose defining constant differs is a
|
||||
//! different node, not a different setting.
|
||||
//!
|
||||
//! **Neutrality would have to be re-implemented by hand.** The rule the whole
|
||||
//! pipeline rests on is that an operation at its defaults contributes nothing:
|
||||
//! no code, no uniform, no dispatch. `is_active()` gives each of these that for
|
||||
//! free. Merged, the node would be active whenever *either* slider had moved,
|
||||
//! and would then need an internal guard per half to avoid dispatching a
|
||||
//! forty-pixel blur for a control sitting at zero — hand-writing, in one
|
||||
//! place, the thing the pipeline already does everywhere.
|
||||
//!
|
||||
//! **There is no dispatch to save.** The usual reason to merge two operations
|
||||
//! is to fuse their work. Here there is nothing to fuse: the two blurs are
|
||||
//! different blurs, by definition, so a merged node costs the same four passes
|
||||
//! that two nodes cost, and costs them in the same order.
|
||||
//!
|
||||
//! **The sidecar, the history and the reset all read better.** `clarity.amount`
|
||||
//! and `texture.amount` say what they are; `local_contrast.clarity` names a
|
||||
//! concept no photographer asked for in order to reach one that they did.
|
||||
//! Undo says "clarity", and double-tapping clarity to reset it leaves texture
|
||||
//! alone — which is what a photographer who has just tuned texture expects.
|
||||
//!
|
||||
//! Against all that, the cost of two nodes is one shared implementation
|
||||
//! parameterised by a [`Band`], below. The `attributes:` grouping is
|
||||
//! `[detail]` either way, so it offers no argument in either direction.
|
||||
//!
|
||||
//! # Halos, and what is done about them
|
||||
//!
|
||||
//! A naive unsharp mask — `c + amount * (c - blur(c))` in linear light — is
|
||||
//! the single most common way this feature is got wrong, and it fails in four
|
||||
//! separate ways at once. Each is addressed by a specific decision here.
|
||||
//!
|
||||
//! **1. Work in stops, not in levels.** The base is a Gaussian mean of *log*
|
||||
//! luminance, so the detail signal is a ratio: "this pixel is 0.4 stops
|
||||
//! brighter than its surroundings". In linear light the same edge produces an
|
||||
//! overshoot proportional to absolute brightness, so an edge against a bright
|
||||
//! sky blows out while the identical edge in shadow does nothing — and the
|
||||
//! amount that looked right stops looking right the moment exposure moves.
|
||||
//! Stops also make the negative direction symmetric: −50 removes exactly the
|
||||
//! proportion of local contrast that +50 adds.
|
||||
//!
|
||||
//! **2. Soft-limit the detail signal — this is the main halo control.** The
|
||||
//! signal is passed through `t * tanh(d / t)` before it is used. Below the
|
||||
//! threshold the function is the identity to within a percent, so structure
|
||||
//! and surface detail pass through at full strength; far above it the output
|
||||
//! saturates at `t` whatever the input, so a four-stop skyline transition
|
||||
//! contributes no more overshoot than a one-stop one. That is the distinction
|
||||
//! between *structure* and an *edge*, drawn on amplitude rather than by an
|
||||
//! edge detector — a guided or bilateral base would draw it more precisely and
|
||||
//! would cost several more full-frame passes to do it. `tanh` costs one
|
||||
//! instruction and has no threshold artefact, because it is smooth everywhere;
|
||||
//! a hard clamp would put a visible contour along the locus where the detail
|
||||
//! signal crosses `t`.
|
||||
//!
|
||||
//! It is also the right thing on the negative side. At amount −100 an
|
||||
//! unlimited unsharp mask subtracts the whole detail signal and dissolves
|
||||
//! edges into mud; limited, it removes at most `t` stops, so negative clarity
|
||||
//! softens surface and modelling while leaving real edges standing.
|
||||
//!
|
||||
//! **3. Move luminance only, and scale the triple.** The gain is applied as
|
||||
//! `c * 2^stops`, which leaves chromaticity exactly where it was. Boosting the
|
||||
//! three channels independently shifts hue and saturation wherever the detail
|
||||
//! signal is large — that is a *coloured* fringe along every edge, arriving
|
||||
//! from a control the photographer thinks of as contrast, and it is the hardest
|
||||
//! kind of halo to attribute to its cause. `apply_tone_gain` already takes this
|
||||
//! position for the tonal controls, for the same reason.
|
||||
//!
|
||||
//! **4. Taper clarity to nothing at both ends of the range.** Clarity is
|
||||
//! midtone structure by definition, and its two worst halos are at the
|
||||
//! extremes: a bright sky beside a dark subject blooms, and deep shadow goes
|
||||
//! to mud. A weight of `1 - (2p - 1)^2` over the perceptual tone position
|
||||
//! removes exactly those, and stops a *contrast* slider from creating a blown
|
||||
//! highlight by pushing a recovered value back over one.
|
||||
//!
|
||||
//! Texture deliberately does **not** get this taper. Skin in a highlight and
|
||||
//! fabric in a shadow are precisely what the control is for, and a fine-scale
|
||||
//! overshoot at either end is a two-pixel band, not a bloom.
|
||||
//!
|
||||
//! # Why the radius is a fraction of the frame
|
||||
//!
|
||||
//! [`RenderScale`] names two units, and picking the wrong one produces an
|
||||
//! effect that is a different photograph on screen and in the file. These two
|
||||
//! controls take [`RenderScale::frame_fraction`] — the unit a mask feather is
|
||||
//! already stored in — and not [`RenderScale::source_pixels`].
|
||||
//!
|
||||
//! The test is whose property the length is. Capture sharpening's radius
|
||||
//! belongs to the *sensor*: it is about the lens's circle of confusion and the
|
||||
//! demosaic's interpolation, both of which are facts about the file and
|
||||
//! neither of which changes if the photograph is cropped. Clarity's radius
|
||||
//! belongs to the *composition*: "separate the subject from its background" is
|
||||
//! a statement about how much of the frame the subject occupies, and it stays
|
||||
//! true when the same frame is printed large or viewed small. Crop into a
|
||||
//! quarter of the frame and the subject now fills it, so the scale that models
|
||||
//! it really has grown — which `frame_fraction` gives, because
|
||||
//! [`crate::EditGraph::render_scale`] folds the crop in before this code runs.
|
||||
//!
|
||||
//! The practical consequence is that these two controls preview honestly at
|
||||
//! every zoom level, which the acutance family cannot. There is no
|
||||
//! [`RenderScale::resolves`] check here and no reason for one: at a small
|
||||
//! render the kernel shrinks with the frame and keeps its proportions, and the
|
||||
//! effect is the effect.
|
||||
//!
|
||||
//! Texture does eventually round to a zero-pixel kernel on a thumbnail, and
|
||||
//! then contributes no pass at all. That is not the acutance family's problem
|
||||
//! restated — it is the honest answer. A two-pixel surface structure is not
|
||||
//! present in a 300-pixel rendering of the frame in the first place, and it
|
||||
//! reappears, exactly, as soon as the view is zoomed.
|
||||
//!
|
||||
//! # What this costs
|
||||
//!
|
||||
//! Clarity's kernel is large — of the order of a hundred taps per pass at
|
||||
//! preview resolution — and the two passes are the honest, exact separable
|
||||
//! Gaussian rather than a sparse approximation of one. A strided kernel would
|
||||
//! be several times cheaper and is deliberately not taken: undersampling an
|
||||
//! image that is not band-limited aliases high-frequency content down into the
|
||||
//! base, the base is then subtracted, and the aliasing arrives in the output as
|
||||
//! low-frequency mottling across smooth gradients. Mottled skies are precisely
|
||||
//! the artefact this control must not have. The right optimisation is a base
|
||||
//! computed at reduced resolution, which needs a detail stage that can write a
|
||||
//! smaller target than it reads; that is a change to [`crate::detail`], not to
|
||||
//! this file.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::detail::{DetailPass, DetailStage, RenderScale};
|
||||
use crate::operation::{Affects, Helper, Operation, Uniform};
|
||||
use crate::ops::helpers;
|
||||
|
||||
pub const CLARITY: OpId = OpId("clarity");
|
||||
pub const TEXTURE: OpId = OpId("texture");
|
||||
|
||||
/// The one parameter each control has. Both are called `amount`, so the
|
||||
/// sidecar keys read `clarity.amount` and `texture.amount`.
|
||||
pub const AMOUNT: ParamId = ParamId("amount");
|
||||
|
||||
/// How far the kernel runs, in standard deviations.
|
||||
///
|
||||
/// Two, not three. A Gaussian truncated at 2σ and renormalised keeps 95.4% of
|
||||
/// its mass and is still a perfectly monotone low-pass; the missing tail
|
||||
/// changes the base by less than the difference between two adjacent settings
|
||||
/// of the slider, and it halves the tap count of the widest pass in the
|
||||
/// pipeline.
|
||||
const TRUNCATION: f32 = 2.0;
|
||||
|
||||
/// Everything that makes one of these two controls the control it is.
|
||||
///
|
||||
/// A struct rather than four associated constants so that the differences
|
||||
/// between clarity and texture can be read side by side, which is the one
|
||||
/// thing a reader comes to this file to do.
|
||||
pub struct Recipe {
|
||||
descriptor: &'static OpDescriptor,
|
||||
helpers: &'static [Helper],
|
||||
/// The Gaussian's σ, as a fraction of the frame's shorter edge.
|
||||
sigma: f32,
|
||||
/// Where the soft limit starts to bite, in stops. See the module
|
||||
/// documentation, halo control (2).
|
||||
threshold: f32,
|
||||
/// Stops of local contrast added at full slider travel.
|
||||
gain: f32,
|
||||
/// Whether the effect is tapered away from the midtones. See halo control
|
||||
/// (4) — true for clarity, false for texture, and that asymmetry is
|
||||
/// deliberate.
|
||||
midtone_taper: bool,
|
||||
}
|
||||
|
||||
/// The band of spatial frequencies a control acts on.
|
||||
///
|
||||
/// The type parameter of [`LocalContrast`], because the scale is the *only*
|
||||
/// thing that differs between clarity and texture and it differs at compile
|
||||
/// time. One implementation, two nodes, and no branch anywhere that could
|
||||
/// drift.
|
||||
pub trait Band: Send + Sync + 'static {
|
||||
const RECIPE: Recipe;
|
||||
}
|
||||
|
||||
/// Clarity's band: roughly a hundredth of the frame.
|
||||
pub struct Coarse;
|
||||
|
||||
/// Texture's band: a decade finer, a few pixels at any size.
|
||||
pub struct Fine;
|
||||
|
||||
impl Band for Coarse {
|
||||
const RECIPE: Recipe = Recipe {
|
||||
descriptor: &CLARITY_DESCRIPTOR,
|
||||
helpers: CLARITY_HELPERS,
|
||||
// 1.2% of the shorter edge — about 48 px on a 4000 px frame. Wide
|
||||
// enough that the base is the subject rather than the surface, narrow
|
||||
// enough that the result is still local contrast and not a second
|
||||
// exposure slider.
|
||||
sigma: 0.012,
|
||||
// A third of a stop. Clarity's whole difficulty is that a wide kernel
|
||||
// sees an enormous detail signal at every real edge, so the limit has
|
||||
// to bite early: at full travel the largest overshoot any edge can
|
||||
// produce is a third of a stop, about 26%, before the midtone taper
|
||||
// reduces it further.
|
||||
threshold: 0.35,
|
||||
gain: 1.0,
|
||||
midtone_taper: true,
|
||||
};
|
||||
}
|
||||
|
||||
impl Band for Fine {
|
||||
const RECIPE: Recipe = Recipe {
|
||||
descriptor: &TEXTURE_DESCRIPTOR,
|
||||
helpers: TEXTURE_HELPERS,
|
||||
// Exactly a decade below clarity, which is what makes the two controls
|
||||
// separable in use: at a ten-to-one ratio of scales, neither can
|
||||
// substantially do the other's job, so a photographer setting both is
|
||||
// setting two things and not the same thing twice.
|
||||
sigma: 0.0012,
|
||||
// Three times clarity's, because at this scale the overshoot is a band
|
||||
// two or three pixels wide and the eye reads that as acutance. Limiting
|
||||
// it as hard as clarity would take the crispness out of the one control
|
||||
// that exists to provide it.
|
||||
threshold: 1.0,
|
||||
// A narrow overshoot carries less visual weight than a wide one, so
|
||||
// equal numbers on the two sliders should land at comparable strength.
|
||||
gain: 1.25,
|
||||
midtone_taper: false,
|
||||
};
|
||||
}
|
||||
|
||||
static CLARITY_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: CLARITY,
|
||||
label: LocalizedKey("op.clarity"),
|
||||
params: &[ParamDescriptor::amount("amount", "param.clarity.amount")],
|
||||
attributes: &[Attribute::Detail],
|
||||
};
|
||||
|
||||
static TEXTURE_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: TEXTURE,
|
||||
label: LocalizedKey("op.texture"),
|
||||
params: &[ParamDescriptor::amount("amount", "param.texture.amount")],
|
||||
attributes: &[Attribute::Detail],
|
||||
};
|
||||
|
||||
/// Luminance as a position on a logarithmic scale, floored.
|
||||
///
|
||||
/// Declared here rather than in `_helpers.yaml` because it is not a shared
|
||||
/// idea: it exists so that the base can be a mean of *log* luminance, which is
|
||||
/// the first of this file's four halo decisions and means nothing outside it.
|
||||
const LOG_LUMA: Helper = Helper {
|
||||
name: "log_luma",
|
||||
source: "\
|
||||
// Luminance in stops, floored fourteen stops below white.
|
||||
//
|
||||
// The floor is what makes the logarithm safe on the values this stage
|
||||
// actually receives: the intermediate is unclipped and scene-referred, so a
|
||||
// pixel can be exactly zero and an out-of-gamut colour can be negative. It
|
||||
// sits far enough down that no real signal is affected — a fourteen-stop
|
||||
// range is more than any sensor delivers — and it turns both of those into a
|
||||
// very dark pixel rather than an infinity that would propagate through the
|
||||
// blur into every pixel within the kernel's reach.
|
||||
fn log_luma(c: vec3<f32>) -> f32 {
|
||||
return log2(max(luminance(c), 0.00006103515625));
|
||||
}",
|
||||
};
|
||||
|
||||
/// How much of clarity applies at a given luminance.
|
||||
const MIDTONE_WEIGHT: Helper = Helper {
|
||||
name: "midtone_weight",
|
||||
source: "\
|
||||
// A parabola over the perceptual tone position: one in the midtones, zero at
|
||||
// both black and white.
|
||||
//
|
||||
// Clarity is midtone structure by definition, and this is also where two of
|
||||
// its three worst halos live — a bright sky beside a dark subject blooms, and
|
||||
// deep shadow turns to mud. Tapering to nothing at both ends removes them, and
|
||||
// stops a control the photographer reads as `contrast` from pushing a
|
||||
// recovered highlight back over one and clipping it.
|
||||
//
|
||||
// `tone_position` rather than the raw value, so the taper is even to the eye
|
||||
// rather than crowded into the bottom of the range the way a linear weight
|
||||
// would be.
|
||||
fn midtone_weight(luma: f32) -> f32 {
|
||||
let p = 2.0 * tone_position(luma) - 1.0;
|
||||
return 1.0 - p * p;
|
||||
}",
|
||||
};
|
||||
|
||||
static CLARITY_HELPERS: &[Helper] = &[
|
||||
helpers::LUMINANCE,
|
||||
LOG_LUMA,
|
||||
helpers::TONE_POSITION,
|
||||
MIDTONE_WEIGHT,
|
||||
];
|
||||
|
||||
static TEXTURE_HELPERS: &[Helper] = &[helpers::LUMINANCE, LOG_LUMA];
|
||||
|
||||
/// An unsharp mask at one fixed scale.
|
||||
///
|
||||
/// See the module documentation for why the scale is a type parameter rather
|
||||
/// than a parameter, and why there are two nodes rather than one.
|
||||
pub struct LocalContrast<B: Band> {
|
||||
/// −100…100, exactly as the slider reports it.
|
||||
amount: f32,
|
||||
band: PhantomData<B>,
|
||||
}
|
||||
|
||||
/// Clarity: local contrast at roughly a hundredth of the frame.
|
||||
pub type Clarity = LocalContrast<Coarse>;
|
||||
|
||||
/// Texture: local contrast a decade finer than clarity.
|
||||
pub type Texture = LocalContrast<Fine>;
|
||||
|
||||
impl<B: Band> Default for LocalContrast<B> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
amount: 0.0,
|
||||
band: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: Band> LocalContrast<B> {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Start from a slider position, for tests and presets.
|
||||
pub fn with_amount(amount: f32) -> Self {
|
||||
Self {
|
||||
amount,
|
||||
band: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Gaussian's σ at this render, in **render pixels**.
|
||||
///
|
||||
/// The one conversion this operation performs, and the reason it happens
|
||||
/// here rather than in WGSL: `frame_fraction` is named after its unit,
|
||||
/// where a bare `f32` in a shader would not be.
|
||||
pub fn sigma(&self, scale: RenderScale) -> f32 {
|
||||
scale.frame_fraction(B::RECIPE.sigma)
|
||||
}
|
||||
|
||||
/// The kernel radius at this render, in render pixels.
|
||||
///
|
||||
/// Exposed so a test can state what it expects without repeating the
|
||||
/// rounding rule — a test that recomputed it would agree with a bug.
|
||||
pub fn kernel(&self, scale: RenderScale) -> u32 {
|
||||
(self.sigma(scale) * TRUNCATION).round().max(0.0) as u32
|
||||
}
|
||||
|
||||
/// Stops of local contrast at this slider position.
|
||||
fn gain(&self) -> f32 {
|
||||
self.amount / 100.0 * B::RECIPE.gain
|
||||
}
|
||||
|
||||
/// The largest excursion this control can produce at its current setting,
|
||||
/// in stops — the bound the soft limit guarantees.
|
||||
///
|
||||
/// `gain * threshold`, because `t * tanh(d / t)` saturates at `t` however
|
||||
/// violent the edge. Public because it is the one number a test can hold
|
||||
/// the halo to without re-deriving the shader: whatever the picture, no
|
||||
/// pixel may move further than this. See the module documentation, halo
|
||||
/// control (2).
|
||||
pub fn overshoot_bound(&self) -> f32 {
|
||||
self.gain().abs() * B::RECIPE.threshold
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: Band> Operation for LocalContrast<B> {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
B::RECIPE.descriptor
|
||||
}
|
||||
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
self.amount = value;
|
||||
}
|
||||
|
||||
fn param(&self, _id: ParamId) -> f32 {
|
||||
self.amount
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.amount != 0.0
|
||||
}
|
||||
|
||||
/// Never called: a neighbourhood operation contributes no fused fragment,
|
||||
/// and `compose_full` filters it out before asking.
|
||||
fn wgsl_body(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn affects(&self) -> Affects {
|
||||
Affects::Detail
|
||||
}
|
||||
|
||||
fn detail(&self) -> Option<&dyn DetailStage> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
B::RECIPE.helpers
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: Band> DetailStage for LocalContrast<B> {
|
||||
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
|
||||
let sigma = self.sigma(scale);
|
||||
let radius = self.kernel(scale);
|
||||
|
||||
// A kernel that rounded to nothing is not "blur by zero" — it is a
|
||||
// scale this render is too small to show. Texture reaches this on a
|
||||
// thumbnail and the honest answer is to contribute no pass, which is
|
||||
// also what stops a degenerate one-tap Gaussian from burning two
|
||||
// dispatches to copy the image.
|
||||
if radius == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// 1/σ², so the shader's inner loop is a multiply rather than a
|
||||
// division per tap.
|
||||
let inv_variance = 1.0 / (sigma * sigma);
|
||||
let shape = vec![
|
||||
Uniform {
|
||||
name: "radius",
|
||||
value: radius as f32,
|
||||
},
|
||||
Uniform {
|
||||
name: "inv_variance",
|
||||
value: inv_variance,
|
||||
},
|
||||
];
|
||||
|
||||
let mut combine = shape.clone();
|
||||
combine.push(Uniform {
|
||||
name: "threshold",
|
||||
value: B::RECIPE.threshold,
|
||||
});
|
||||
combine.push(Uniform {
|
||||
name: "gain",
|
||||
value: self.gain(),
|
||||
});
|
||||
|
||||
vec![
|
||||
DetailPass {
|
||||
label: "base",
|
||||
radius,
|
||||
uniforms: shape,
|
||||
wgsl: BASE_X.to_string(),
|
||||
},
|
||||
DetailPass {
|
||||
label: "combine",
|
||||
radius,
|
||||
uniforms: combine,
|
||||
wgsl: combine_body(B::RECIPE.midtone_taper),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Half of the base, along x.
|
||||
///
|
||||
/// Deliberately does not touch `c`: the pass after this one needs the
|
||||
/// *original* colour as well as the blur, which is what an unsharp mask is and
|
||||
/// why the `aux` lane exists at all.
|
||||
const BASE_X: &str = "\
|
||||
// Half of a separable Gaussian, over log luminance, along x.
|
||||
//
|
||||
// The colour is left exactly as it arrived. An unsharp mask needs the blur and
|
||||
// the original in the same place at the same time, and the ping-pong hands
|
||||
// each pass only what the pass before it wrote — so the blur travels in `aux`
|
||||
// and the colour rides through untouched. See `DetailPass::wgsl`.
|
||||
//
|
||||
// Weights are evaluated rather than tabulated: a table would need a uniform
|
||||
// array sized for the largest kernel any resolution could ask for, and `exp`
|
||||
// is cheaper than the bandwidth that array would cost.
|
||||
let r = i32(radius);
|
||||
var sum = 0.0;
|
||||
var weight = 0.0;
|
||||
for (var i = -r; i <= r; i = i + 1) {
|
||||
let f = f32(i);
|
||||
let w = exp(-0.5 * f * f * inv_variance);
|
||||
sum = sum + w * log_luma(tap(coord, vec2<i32>(i, 0)));
|
||||
weight = weight + w;
|
||||
}
|
||||
// Normalised by the weights actually summed, not by an analytic constant, so
|
||||
// truncating the Gaussian at 2σ leaves a true mean rather than a slightly dark
|
||||
// one — and so a kernel clamped at the image border averages the pixels that
|
||||
// exist.
|
||||
aux = sum / weight;";
|
||||
|
||||
/// The second pass: finish the base along y, then apply the mask.
|
||||
///
|
||||
/// Generated rather than constant because the midtone taper is present for
|
||||
/// clarity and absent for texture. Emitting the line only where it applies
|
||||
/// keeps texture's shader honest about not having one, and saves it a uniform
|
||||
/// and two helper functions it would never call.
|
||||
fn combine_body(midtone_taper: bool) -> String {
|
||||
let weight = if midtone_taper {
|
||||
"\n\
|
||||
// Clarity only: tapered to nothing at both ends of the range. See\n\
|
||||
// `midtone_weight` for what that is worth against a halo.\n\
|
||||
let stops = gain * shaped * midtone_weight(luminance(c));"
|
||||
} else {
|
||||
"\n\
|
||||
// Texture is deliberately *not* tapered towards black and white.\n\
|
||||
// Skin in a highlight and fabric in a shadow are what the control is\n\
|
||||
// for, and at this scale an overshoot is two pixels wide — acutance,\n\
|
||||
// not a bloom.\n\
|
||||
let stops = gain * shaped;"
|
||||
};
|
||||
|
||||
format!(
|
||||
"\
|
||||
// The other half of the base, then the unsharp mask itself.
|
||||
//
|
||||
// `tap_aux` reads the previous pass's log-luminance blur, while `c` is still
|
||||
// the colour the colour pass produced — which is the arrangement that makes an
|
||||
// unsharp mask expressible in a chain that hands on one texture per pass.
|
||||
let r = i32(radius);
|
||||
var sum = 0.0;
|
||||
var weight = 0.0;
|
||||
for (var i = -r; i <= r; i = i + 1) {{
|
||||
let f = f32(i);
|
||||
let w = exp(-0.5 * f * f * inv_variance);
|
||||
sum = sum + w * tap_aux(coord, vec2<i32>(0, i));
|
||||
weight = weight + w;
|
||||
}}
|
||||
let base = sum / weight;
|
||||
|
||||
// Local contrast, in **stops**. Both terms are logarithms, so this is a ratio:
|
||||
// `detail` says how much brighter this pixel is than its surroundings, and
|
||||
// says it in a unit that means the same thing in a highlight and in a shadow.
|
||||
// The linear-light difference an unsharp mask usually takes does not, which is
|
||||
// why it blows out bright edges and does nothing to dark ones.
|
||||
let detail = log_luma(c) - base;
|
||||
|
||||
// The halo control. Below the threshold `tanh` is the identity to within a
|
||||
// percent, so structure and surface pass through at full strength; far above
|
||||
// it the output saturates at the threshold, so a four-stop edge contributes no
|
||||
// more overshoot than a one-stop one. Smooth everywhere, so unlike a clamp it
|
||||
// leaves no contour along the locus where the detail signal crosses it.
|
||||
let shaped = threshold * tanh(detail / threshold);
|
||||
{weight}
|
||||
|
||||
// Applied as a scale on the whole triple, which leaves chromaticity exactly
|
||||
// where it was. Boosting the channels independently would put a *coloured*
|
||||
// fringe along every edge, arriving from a control the photographer reads as
|
||||
// contrast — the hardest kind of halo to attribute to its cause.
|
||||
c = c * exp2(stops);"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::detail::compose_detail;
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
/// The two controls, as the graph would hold them.
|
||||
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> {
|
||||
vec![
|
||||
Box::new(Clarity::with_amount(clarity)),
|
||||
Box::new(Texture::with_amount(texture)),
|
||||
]
|
||||
}
|
||||
|
||||
fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail {
|
||||
compose_detail(&ops(clarity, texture), scale, ColourSpace::Srgb)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_controls_start_neutral_and_cost_nothing() {
|
||||
// The rule the whole pipeline rests on. An unedited photograph must
|
||||
// not pay for a clarity slider nobody has touched — and, because these
|
||||
// are the widest kernels in the pipeline, "nothing" here is a large
|
||||
// amount of nothing.
|
||||
assert!(!Clarity::new().is_active());
|
||||
assert!(!Texture::new().is_active());
|
||||
assert!(composed(0.0, 0.0, RenderScale::full((2000, 1500))).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_control_moving_does_not_dispatch_the_other() {
|
||||
// The concrete reason these are two nodes rather than one with two
|
||||
// sliders. Merged, the node would be active whenever either had moved
|
||||
// and would need a hand-written guard per half to avoid running a
|
||||
// forty-pixel blur for a control sitting at zero.
|
||||
let scale = RenderScale::full((2000, 1500));
|
||||
let only_clarity = composed(50.0, 0.0, scale);
|
||||
assert_eq!(only_clarity.len(), 2, "one operation, two passes");
|
||||
assert!(only_clarity.passes.iter().all(|p| p.label.starts_with("clarity/")));
|
||||
|
||||
let both = composed(50.0, 50.0, scale);
|
||||
assert_eq!(both.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_controls_differ_by_a_decade_of_scale() {
|
||||
// The whole point of there being two of them. If these ever converge,
|
||||
// one of the controls has stopped doing its job and the second slider
|
||||
// has become a duplicate of the first.
|
||||
let scale = RenderScale::full((4000, 3000));
|
||||
let clarity = Clarity::with_amount(100.0).kernel(scale);
|
||||
let texture = Texture::with_amount(100.0).kernel(scale);
|
||||
// 1.2% of the *shorter* edge — 3000, not 4000 — and two sigmas wide.
|
||||
assert_eq!(clarity, 72);
|
||||
assert_eq!(texture, 7, "a decade finer");
|
||||
assert!(
|
||||
clarity >= texture * 8,
|
||||
"clarity {clarity} and texture {texture} are not separable scales"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radius_is_a_fraction_of_the_frame_and_not_a_count_of_source_pixels() {
|
||||
// TRACES: FR-DSP-1 — the decision this whole file's units rest on.
|
||||
//
|
||||
// Clarity is compositional: "separate the subject from its background"
|
||||
// is a statement about how much of the frame the subject occupies, and
|
||||
// it stays true at every size the frame is rendered at. So the kernel
|
||||
// must cover the same *proportion* of the picture on a proxy as in the
|
||||
// export, which is what `frame_fraction` gives and what
|
||||
// `source_pixels` would not.
|
||||
let sizes = [(400u32, 300u32), (2000, 1500), (6000, 4500)];
|
||||
let proportions: Vec<f32> = sizes
|
||||
.iter()
|
||||
.map(|&(w, h)| {
|
||||
let scale = RenderScale::full((w, h));
|
||||
Clarity::with_amount(60.0).kernel(scale) as f32 / w.min(h) as f32
|
||||
})
|
||||
.collect();
|
||||
for p in &proportions {
|
||||
assert!(
|
||||
(p - 0.024).abs() < 0.002,
|
||||
"the kernel drifted from its declared fraction: {proportions:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// And the contrast with the other unit, stated rather than implied: on
|
||||
// a one-third proxy a source-pixel radius *shrinks* to a third of the
|
||||
// proportion it had, which is the bug this choice avoids.
|
||||
let proxy = RenderScale::new((2000, 1500), (6000, 4500));
|
||||
let clarity = Clarity::with_amount(60.0);
|
||||
assert_eq!(clarity.kernel(proxy), clarity.kernel(RenderScale::full((2000, 1500))));
|
||||
assert!(
|
||||
proxy.source_pixels(96.0) < 40.0,
|
||||
"the same length in the other unit would have collapsed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_stops_rather_than_lying_when_the_render_is_too_small() {
|
||||
// A two-pixel surface structure is not present in a 300-pixel
|
||||
// rendering of the frame, so the honest thing is to contribute no
|
||||
// pass. Unlike the acutance family this is not an approximation being
|
||||
// hidden: zoom in and the kernel comes back, exactly.
|
||||
let thumbnail = RenderScale::full((160, 120));
|
||||
assert_eq!(Texture::with_amount(100.0).kernel(thumbnail), 0);
|
||||
assert!(Texture::with_amount(100.0).passes(thumbnail).is_empty());
|
||||
|
||||
// Clarity is a hundred times wider and survives, which is what a
|
||||
// thumbnail should show: the modelling, not the surface.
|
||||
assert!(Clarity::with_amount(100.0).kernel(thumbnail) > 0);
|
||||
assert_eq!(Clarity::with_amount(100.0).passes(thumbnail).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_pass_hands_the_original_colour_to_the_second() {
|
||||
// The property that makes an unsharp mask expressible in a chain that
|
||||
// passes on one texture per pass. If the blur pass ever writes `c`,
|
||||
// the combining pass has nothing to subtract the base *from* and the
|
||||
// operation silently becomes a blur.
|
||||
let passes = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500)));
|
||||
let base = &passes[0];
|
||||
let combine = &passes[1];
|
||||
|
||||
assert!(base.wgsl.contains("aux = sum / weight;"));
|
||||
assert!(
|
||||
!base.wgsl.contains("c = "),
|
||||
"the blur pass must leave the colour alone: {}",
|
||||
base.wgsl
|
||||
);
|
||||
assert!(
|
||||
combine.wgsl.contains("tap_aux("),
|
||||
"the combining pass must read the blur out of the scratch lane"
|
||||
);
|
||||
assert!(combine.wgsl.contains("log_luma(c) - base"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_declared_radius_is_the_halo_a_tile_would_need() {
|
||||
// ARCH §5.3 grows a tile by the widest reach of the pass computing it,
|
||||
// and nothing can infer that from the WGSL because the offsets come
|
||||
// from a uniform. An understated radius shows as a seam at every tile
|
||||
// boundary — an artefact that reads as a driver bug.
|
||||
let scale = RenderScale::full((2000, 1500));
|
||||
let composed = composed(50.0, 50.0, scale);
|
||||
let clarity = Clarity::with_amount(50.0).kernel(scale);
|
||||
assert_eq!(composed.radius(), clarity, "the widest pass sets the halo");
|
||||
for pass in &composed.passes {
|
||||
assert!(pass.radius > 0, "{} declared no reach", pass.label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_halo_limit_is_in_the_shader_and_bounds_the_overshoot() {
|
||||
// The single most common way this feature is got wrong. `tanh`
|
||||
// saturates at the threshold, so the largest overshoot a full-travel
|
||||
// slider can produce is `gain * threshold` stops however violent the
|
||||
// edge — a bound that holds by construction rather than by tuning.
|
||||
let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1];
|
||||
assert!(combine.wgsl.contains("threshold * tanh(detail / threshold)"));
|
||||
|
||||
let bound = |u: &[Uniform]| {
|
||||
let get = |n| u.iter().find(|x| x.name == n).unwrap().value;
|
||||
get("gain").abs() * get("threshold")
|
||||
};
|
||||
// A third of a stop for clarity, before the midtone taper takes more
|
||||
// off; a little over a stop for texture, whose overshoot is two pixels
|
||||
// wide and reads as acutance.
|
||||
assert!((bound(&combine.uniforms) - 0.35).abs() < 1e-6);
|
||||
let texture = &Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1];
|
||||
assert!((bound(&texture.uniforms) - 1.25).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_clarity_tapers_towards_black_and_white() {
|
||||
// The asymmetry is the deliberate part: texture must work on skin in a
|
||||
// highlight and fabric in a shadow, which is exactly where clarity
|
||||
// must not.
|
||||
let scale = RenderScale::full((2000, 1500));
|
||||
let clarity = &Clarity::with_amount(50.0).passes(scale)[1];
|
||||
let texture = &Texture::with_amount(50.0).passes(scale)[1];
|
||||
assert!(clarity.wgsl.contains("midtone_weight(luminance(c))"));
|
||||
assert!(!texture.wgsl.contains("midtone_weight"));
|
||||
|
||||
// And texture does not carry the helpers it would need for one, so its
|
||||
// shader says what it does rather than merely not calling it.
|
||||
assert!(Texture::new().helpers().iter().any(|h| h.name == "log_luma"));
|
||||
assert!(!Texture::new()
|
||||
.helpers()
|
||||
.iter()
|
||||
.any(|h| h.name == "midtone_weight"));
|
||||
assert!(Clarity::new()
|
||||
.helpers()
|
||||
.iter()
|
||||
.any(|h| h.name == "midtone_weight"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_amount_is_symmetric_about_neutral() {
|
||||
// Working in stops is what buys this: −50 removes exactly the
|
||||
// proportion of local contrast that +50 adds, at every brightness.
|
||||
// In linear light it would not, and the pair of settings that looked
|
||||
// balanced would depend on exposure.
|
||||
let scale = RenderScale::full((2000, 1500));
|
||||
let up = Clarity::with_amount(50.0).passes(scale);
|
||||
let down = Clarity::with_amount(-50.0).passes(scale);
|
||||
let gain = |p: &[DetailPass]| {
|
||||
p[1].uniforms
|
||||
.iter()
|
||||
.find(|u| u.name == "gain")
|
||||
.unwrap()
|
||||
.value
|
||||
};
|
||||
assert!((gain(&up) + gain(&down)).abs() < 1e-6);
|
||||
// Same kernel either way — the direction is a sign, not a scale.
|
||||
assert_eq!(up[0].radius, down[0].radius);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_pass_declares_a_uniform_block_the_gpu_will_accept() {
|
||||
// A uniform struct whose size is not a multiple of 16 is rejected
|
||||
// outright by the WGSL uniform address space rules, and arrives as a
|
||||
// compilation failure against generated source.
|
||||
for pass in composed(70.0, -40.0, RenderScale::full((1600, 1200))).passes {
|
||||
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
|
||||
assert!(
|
||||
pass.uniforms.iter().all(|v| v.is_finite()),
|
||||
"{} uploaded a non-finite uniform",
|
||||
pass.label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_parameter_round_trips_under_its_own_id() {
|
||||
// Mechanical, and exactly why it is worth checking: a `set_param` that
|
||||
// read the wrong field would look perfect and silently break the
|
||||
// sidecar.
|
||||
let mut op = Clarity::new();
|
||||
op.set_param(AMOUNT, -37.0);
|
||||
assert_eq!(op.param(AMOUNT), -37.0);
|
||||
assert_eq!(op.descriptor().id, CLARITY);
|
||||
assert_eq!(Texture::new().descriptor().id, TEXTURE);
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,16 @@ pub mod aberration;
|
||||
pub mod colour_mixer;
|
||||
pub mod curve;
|
||||
pub mod distortion;
|
||||
pub mod local_contrast;
|
||||
pub mod vignetting;
|
||||
|
||||
pub use aberration::Aberration;
|
||||
pub use colour_mixer::ColourMixer;
|
||||
pub use curve::ToneCurve;
|
||||
pub use distortion::Distortion;
|
||||
// Clarity and texture are one implementation at two scales; see the module's
|
||||
// documentation for why that is two nodes and not one.
|
||||
pub use local_contrast::{Clarity, Texture};
|
||||
pub use vignetting::Vignetting;
|
||||
|
||||
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
|
||||
|
||||
Reference in New Issue
Block a user