@@ -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 = [ ( 400 u32 , 300 u32 ) , ( 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 ) ;
}
}