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);
|
||||
}
|
||||
Reference in New Issue
Block a user