docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken.
1264 lines
56 KiB
Rust
1264 lines
56 KiB
Rust
//! 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 each half is 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.
|
||
//!
|
||
//! Run at the render size, that measured **34 ms at 4K** — seven times the
|
||
//! entire fused point chain, for one slider — which is `docs/dev/technical-debt.md`
|
||
//! TD-4 and is what [`Recipe::base_scale`] now answers. The base is computed on
|
||
//! a grid a quarter the size on each axis: a sixteenth of the pixels at a
|
||
//! quarter of the radius.
|
||
//!
|
||
//! **This is not the strided kernel wearing a hat**, and the difference is
|
||
//! exactly the paragraph above. A stride samples an image that is not band-
|
||
//! limited and aliases; the reduction *band-limits first* — that is what the
|
||
//! `reduce` pass is for and why it is a separate dispatch — and only then
|
||
//! samples. What is thrown away is content the base could not represent at any
|
||
//! resolution, because a Gaussian at σ = 26 px has nothing above one cycle per
|
||
//! 26 px in it and the quarter-scale grid carries one cycle per 8 px. So the
|
||
//! reduced base is not an approximation of the full-resolution base; it is the
|
||
//! same band-limited function, sampled where it is still fully determined.
|
||
//!
|
||
//! Which is also why [`LocalContrast::reduction`] steps down and why texture
|
||
//! never reduces at all. The argument holds only while the reduced grid can
|
||
//! still carry the Gaussian, and the moment it cannot, the honest answer is
|
||
//! the full-resolution one — which is the cheap case anyway, because the
|
||
//! viewport that produced it is small.
|
||
|
||
use std::marker::PhantomData;
|
||
use std::sync::{Arc, LazyLock};
|
||
|
||
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;
|
||
|
||
/// The smallest σ, in reduced pixels, worth running a Gaussian over.
|
||
///
|
||
/// One pixel, which with [`TRUNCATION`] is a five-tap kernel — the narrowest
|
||
/// that still has a shape. Below it the weights collapse towards a single tap
|
||
/// and the blur that survives is the reduce pass's box, which is a different
|
||
/// filter with a different edge response. See [`LocalContrast::reduction`].
|
||
const MIN_REDUCED_SIGMA: f32 = 1.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 {
|
||
/// The static this operation's descriptor is built in. A `LazyLock`
|
||
/// rather than a reference to a descriptor, because a descriptor is an
|
||
/// owned value handed out as an `Arc` now (FR-PLG-2), and a `const`
|
||
/// recipe cannot hold an `Arc` — only a reference to the static that
|
||
/// makes one.
|
||
descriptor: &'static LazyLock<Arc<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 most this band's base may be shrunk before it is blurred.
|
||
///
|
||
/// A ceiling, not the answer — [`LocalContrast::reduction`] steps it down
|
||
/// on a viewport too small to carry it. `1` refuses the optimisation
|
||
/// outright, which is the only correct value for a band whose σ is already
|
||
/// a few pixels.
|
||
///
|
||
/// Must be a power of two: the step-down halves.
|
||
base_scale: u32,
|
||
}
|
||
|
||
/// 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,
|
||
// A quarter, which is what `docs/dev/technical-debt.md` TD-4 bought back.
|
||
//
|
||
// σ is 1.2% of the shorter edge — 26 px at 4K — so the base holds no
|
||
// spatial frequency anywhere near the quarter-scale Nyquist of one
|
||
// cycle per 8 px. Computing it there is not an approximation of the
|
||
// full-resolution base; it is the same band-limited function sampled
|
||
// where it is still fully determined. What it costs is a sixteenth of
|
||
// the pixels at a quarter of the radius, about a sixty-fourth of the
|
||
// work, against the 34 ms this control measured at 4K.
|
||
//
|
||
// Not an eighth. σ/8 is 3.2 px at 4K and under two on a 1080p
|
||
// viewport, which is where the reduce pass's own box filter starts
|
||
// doing more of the blurring than the Gaussian does — and the halo
|
||
// behaviour this operation is careful about is a property of the
|
||
// Gaussian.
|
||
base_scale: 4,
|
||
};
|
||
}
|
||
|
||
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,
|
||
// Never reduced, and this is the reason the scale belongs to the band
|
||
// rather than to the stage. Texture's σ is a decade finer — 2.6 px at
|
||
// 4K — so a quarter-scale grid would not hold its base at all: the
|
||
// reduce pass's 4x4 box is already wider than the Gaussian it would be
|
||
// prefiltering, and what came back would be a blur of the wrong width
|
||
// rather than a cheaper blur of the right one.
|
||
base_scale: 1,
|
||
};
|
||
}
|
||
|
||
static CLARITY_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||
Arc::new(OpDescriptor {
|
||
id: CLARITY,
|
||
label: LocalizedKey("op.clarity"),
|
||
params: vec![ParamDescriptor::amount("amount", "param.clarity.amount")],
|
||
attributes: vec![Attribute::Detail],
|
||
})
|
||
});
|
||
|
||
static TEXTURE_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||
Arc::new(OpDescriptor {
|
||
id: TEXTURE,
|
||
label: LocalizedKey("op.texture"),
|
||
params: vec![ParamDescriptor::amount("amount", "param.texture.amount")],
|
||
attributes: vec![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
|
||
}
|
||
|
||
/// TRACES: FR-DSP-3
|
||
/// The factor this render's base is computed at — 1 meaning "the render
|
||
/// size", as everything did before TD-4.
|
||
///
|
||
/// [`Recipe::base_scale`] is a ceiling rather than the answer, because a
|
||
/// reduced grid still has to hold a Gaussian. At a quarter of a small
|
||
/// viewport clarity's σ falls under a pixel, and a kernel of one or two
|
||
/// taps is not a Gaussian — it is the reduce pass's own box filter with a
|
||
/// rounding error on top, which would make the control change character on
|
||
/// a window resize rather than merely get cheaper.
|
||
///
|
||
/// So the reduction steps down by halves until the reduced σ is worth
|
||
/// convolving: a quarter on a desktop viewport, a half on a small one,
|
||
/// none on a thumbnail. Stepping down rather than switching off keeps most
|
||
/// of the saving in the middle of the range, and the case it gives up on
|
||
/// is the one that was already cheap — the cost is `radius x pixels` and a
|
||
/// small viewport is small in both.
|
||
pub fn reduction(&self, scale: RenderScale) -> u32 {
|
||
let sigma = self.sigma(scale);
|
||
let mut reduction = B::RECIPE.base_scale.max(1);
|
||
while reduction > 1 && sigma / (reduction as f32) < MIN_REDUCED_SIGMA {
|
||
reduction /= 2;
|
||
}
|
||
reduction
|
||
}
|
||
|
||
/// 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) -> Arc<OpDescriptor> {
|
||
Arc::clone(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(),
|
||
});
|
||
|
||
let reduction = self.reduction(scale);
|
||
if reduction == 1 {
|
||
// The full-resolution form, unchanged: blur x into the scratch
|
||
// lane, then finish along y and apply the mask in one pass.
|
||
return vec![
|
||
DetailPass {
|
||
output_scale: 1,
|
||
label: "base",
|
||
radius,
|
||
// A convolution, not a list: nothing to bind at binding 3.
|
||
storage: Vec::new(),
|
||
uniforms: shape,
|
||
wgsl: BASE_X.to_string(),
|
||
},
|
||
DetailPass {
|
||
output_scale: 1,
|
||
label: "combine",
|
||
radius,
|
||
// A convolution, not a list: nothing to bind at binding 3.
|
||
storage: Vec::new(),
|
||
uniforms: combine,
|
||
wgsl: combine_body(B::RECIPE.midtone_taper, Base::Convolved),
|
||
},
|
||
];
|
||
}
|
||
|
||
// The reduced form. Four passes rather than two, and cheaper than the
|
||
// two by a factor of about `reduction²`: three of them run on a grid
|
||
// that many times smaller on each axis, and the one that does not is a
|
||
// single bilinear read.
|
||
//
|
||
// The σ and the radius are the band's own, divided — not recomputed
|
||
// from `RenderScale`, which knows nothing about this grid. Deriving
|
||
// them from the numbers the full-resolution path uses is what keeps
|
||
// the two forms the same filter, so that crossing the threshold in
|
||
// `reduction` does not change the picture.
|
||
let reduced_sigma = sigma / reduction as f32;
|
||
// At least one tap either side. `reduction` has already guaranteed
|
||
// σ >= MIN_REDUCED_SIGMA, so this floor is a belt on top of a brace.
|
||
let reduced_radius = ((reduced_sigma * TRUNCATION).round() as u32).max(1);
|
||
let reduced_shape = vec![
|
||
Uniform {
|
||
name: "radius",
|
||
value: reduced_radius as f32,
|
||
},
|
||
Uniform {
|
||
name: "inv_variance",
|
||
value: 1.0 / (reduced_sigma * reduced_sigma),
|
||
},
|
||
];
|
||
|
||
// The combining pass no longer convolves anything, so it needs neither
|
||
// the radius nor the variance — only the two numbers the unsharp mask
|
||
// itself is made of.
|
||
let mask = vec![
|
||
Uniform {
|
||
name: "threshold",
|
||
value: B::RECIPE.threshold,
|
||
},
|
||
Uniform {
|
||
name: "gain",
|
||
value: self.gain(),
|
||
},
|
||
];
|
||
|
||
vec![
|
||
DetailPass {
|
||
output_scale: reduction,
|
||
label: "reduce",
|
||
// Reads only the block it writes, so it reaches no further
|
||
// than the pixel it is producing and a tile needs no halo for
|
||
// it. The halo the *chain* needs comes from the blurs below.
|
||
radius: 0,
|
||
storage: Vec::new(),
|
||
uniforms: vec![Uniform {
|
||
name: "reduction",
|
||
value: reduction as f32,
|
||
}],
|
||
wgsl: REDUCE.to_string(),
|
||
},
|
||
DetailPass {
|
||
output_scale: reduction,
|
||
label: "base-x",
|
||
radius: reduced_radius,
|
||
storage: Vec::new(),
|
||
uniforms: reduced_shape.clone(),
|
||
wgsl: reduced_blur(Axis::X),
|
||
},
|
||
DetailPass {
|
||
output_scale: reduction,
|
||
label: "base-y",
|
||
radius: reduced_radius,
|
||
storage: Vec::new(),
|
||
uniforms: reduced_shape,
|
||
wgsl: reduced_blur(Axis::Y),
|
||
},
|
||
DetailPass {
|
||
output_scale: 1,
|
||
label: "combine",
|
||
// One bilinear read of the reduced chain, which reaches one
|
||
// reduced pixel — `reduction` render pixels — around itself.
|
||
// Stated rather than left at zero because an understated
|
||
// radius is a tile seam, and a seam is worth more than the
|
||
// three lines it costs to be accurate here.
|
||
radius: reduction,
|
||
storage: Vec::new(),
|
||
uniforms: mask,
|
||
wgsl: combine_body(B::RECIPE.midtone_taper, Base::Reduced),
|
||
},
|
||
]
|
||
}
|
||
}
|
||
|
||
/// Which way a separable half runs.
|
||
enum Axis {
|
||
X,
|
||
Y,
|
||
}
|
||
|
||
/// Where the combining pass finds the base it subtracts.
|
||
enum Base {
|
||
/// Convolved along y by the combining pass itself, out of the scratch
|
||
/// lane the previous pass wrote. The full-resolution form.
|
||
Convolved,
|
||
/// Already finished, on the reduced chain, and read back up.
|
||
Reduced,
|
||
}
|
||
|
||
/// 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;";
|
||
|
||
/// Band-limit the image onto the reduced grid, in log luminance.
|
||
///
|
||
/// A pass of its own rather than something the first blur half does on the
|
||
/// way past, because it is a different filter doing a different job: this one
|
||
/// exists so that the Gaussian's *input* is representable on the coarse grid.
|
||
/// Sampling every fourth pixel instead would alias — a shimmer that changes
|
||
/// when the viewport is resized, which is the classic way a mip-based blur
|
||
/// goes wrong and is very hard to attribute to a clarity slider.
|
||
///
|
||
/// A box over exactly the block the output pixel covers. Not a wider or
|
||
/// prettier prefilter: the Gaussian that follows is 8σ wide on this grid, so
|
||
/// what a better prefilter would buy is a correction of a fraction of a
|
||
/// reduced pixel to a curve four pixels across, and it would cost taps on the
|
||
/// only pass here that reads the full-resolution image.
|
||
///
|
||
/// **In log luminance, not linear.** The base is a mean of logarithms — that
|
||
/// is what makes `detail` a ratio and the whole operation exposure-invariant
|
||
/// (see the module documentation, halo control 1). Averaging linear values
|
||
/// here and taking the logarithm later is a different number, and the
|
||
/// difference is precisely the local contrast this operation exists to
|
||
/// measure: it would be quietly subtracted out of every block.
|
||
const REDUCE: &str = "\
|
||
// The colour rides through untouched, as it does in every pass of this
|
||
// operation — though here it is untouched and also unused: the reduced chain
|
||
// carries a scalar, and the colour the combining pass subtracts from is the
|
||
// full-resolution one it reads from the other chain.
|
||
let s = i32(reduction);
|
||
var sum = 0.0;
|
||
for (var y = 0; y < s; y = y + 1) {
|
||
for (var x = 0; x < s; x = x + 1) {
|
||
sum = sum + log_luma(tap(coord, vec2<i32>(x, y)));
|
||
}
|
||
}
|
||
aux = sum / f32(s * s);";
|
||
|
||
/// One half of the reduced separable Gaussian.
|
||
///
|
||
/// Reads the scratch lane rather than the colour, which is the one line that
|
||
/// differs from [`BASE_X`]: by this point the log-luminance conversion has
|
||
/// already been done, once, by the reduce pass. Doing it again per tap would
|
||
/// be a logarithm inside the inner loop for a value that cannot have changed.
|
||
fn reduced_blur(axis: Axis) -> String {
|
||
let offset = match axis {
|
||
Axis::X => "vec2<i32>(i, 0)",
|
||
Axis::Y => "vec2<i32>(0, i)",
|
||
};
|
||
format!(
|
||
"\
|
||
// Half of a separable Gaussian over the reduced base, in log luminance.
|
||
//
|
||
// Weights are evaluated rather than tabulated, as in `BASE_X` and for the same
|
||
// reason — and here the loop is a quarter as long, which is the whole point.
|
||
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, {offset});
|
||
weight = weight + w;
|
||
}}
|
||
// Normalised by the weights actually summed, so a kernel clamped at the
|
||
// border averages the pixels that exist rather than fading towards zero.
|
||
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, base: Base) -> String {
|
||
// Where the base comes from — the one thing that differs between the two
|
||
// forms. Everything below this line is the unsharp mask itself, written
|
||
// once, so the reduced form cannot drift into being a different operation
|
||
// from the full-resolution one it replaces.
|
||
let base = match base {
|
||
Base::Convolved => "\
|
||
// 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;"
|
||
.to_string(),
|
||
Base::Reduced => "\
|
||
// The base, finished on the reduced chain and read back up bilinearly. `c` is
|
||
// the full-resolution colour, straight off the other chain — which is why the
|
||
// reduced passes had to leave that chain alone, and why this pass reads two
|
||
// textures rather than one.
|
||
//
|
||
// One read where the full-resolution form runs a 105-tap convolution. That
|
||
// difference *is* TD-4.
|
||
let base = reduced_at(coord);"
|
||
.to_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!(
|
||
"\
|
||
{base}
|
||
|
||
// 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);
|
||
// Four at this viewport, because clarity's base is computed reduced
|
||
// here — reduce, two blur halves, combine. The number is the band's
|
||
// and the viewport's, not a constant; what this test is about is that
|
||
// *all* of them belong to clarity.
|
||
assert_eq!(only_clarity.len(), 4, "one operation, its own passes");
|
||
assert!(only_clarity
|
||
.passes
|
||
.iter()
|
||
.all(|p| p.label.starts_with("clarity/")));
|
||
|
||
// Both on: clarity's four plus texture's two. Texture stays at the
|
||
// render size whatever the viewport — its band is a decade finer, so
|
||
// a reduced grid could not hold its base — and that asymmetry is the
|
||
// reason the scale belongs to the band rather than to the stage.
|
||
let both = composed(50.0, 50.0, scale);
|
||
assert_eq!(both.len(), 6);
|
||
assert_eq!(
|
||
both.passes
|
||
.iter()
|
||
.filter(|p| p.label.starts_with("texture/"))
|
||
.count(),
|
||
2
|
||
);
|
||
assert!(both
|
||
.passes
|
||
.iter()
|
||
.filter(|p| p.label.starts_with("texture/"))
|
||
.all(|p| p.output_scale == 1));
|
||
}
|
||
|
||
#[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);
|
||
// Both derived rather than observed, because a number copied out of a
|
||
// test run agrees with whatever the code did on the day.
|
||
//
|
||
// `frame_fraction` takes the *shorter* edge: min(4000, 3000) = 3000.
|
||
// clarity σ = 0.012 × 3000 = 36.0 → round(36.0 × 2) = 72
|
||
// texture σ = 0.0012 × 3000 = 3.6 → round( 3.6 × 2) = 7
|
||
//
|
||
// (7.2 rounds down, which is why texture is 7 and not 8 — the
|
||
// truncation is two sigmas, and two sigmas of 3.6 px is 7.2 px.)
|
||
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.
|
||
// The declared proportion is σ × 2 = 0.012 × 2 = 0.024 of the shorter
|
||
// edge, and the tolerance is what rounding to a whole pixel costs:
|
||
//
|
||
// 300 → round(0.024 × 300) = 7 → 7/300 = 0.02333 (−0.00067)
|
||
// 1500 → round(0.024 × 1500) = 36 → 36/1500 = 0.02400 ( 0.00000)
|
||
// 4500 → round(0.024 × 4500) = 108 → 108/4500 = 0.02400 ( 0.00000)
|
||
//
|
||
// Half a pixel over the smallest frame here is 0.5/300 = 0.0017, so
|
||
// 0.002 is the tolerance a rounded kernel can actually hold — and it
|
||
// is a bound, not a fitted number.
|
||
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.
|
||
//
|
||
// ratio = (2000/6000 + 1500/4500) / 2 = 1/3
|
||
// frame_fraction(0.012) → 0.012 × 1500 = 18 px, the same 18 px the
|
||
// export of that framing gets, because the
|
||
// unit is a proportion of what is rendered;
|
||
// source_pixels(96) → 96 × 1/3 = 32 px, a third of the reach
|
||
// the same edit had at full size.
|
||
//
|
||
// 96 is clarity's kernel at 4000 px of shorter edge, so the second line
|
||
// is what this control would have done had it been written in the
|
||
// acutance family's unit.
|
||
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.
|
||
//
|
||
// shorter edge = 120
|
||
// texture σ = 0.0012 × 120 = 0.144 → round(0.288) = 0 — no pass
|
||
// clarity σ = 0.012 × 120 = 1.44 → round(2.88) = 3 — two passes
|
||
//
|
||
// Texture's kernel crosses back above zero at round(0.0024 × e) ≥ 1,
|
||
// i.e. a shorter edge of about 209 px, which is a little larger than a
|
||
// contact sheet thumbnail and a great deal smaller than any view a
|
||
// photographer judges surface detail in.
|
||
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.
|
||
// Both forms, because they are two chains and the property has to
|
||
// hold in each. A thumbnail is too small to carry a reduced base and
|
||
// takes the two-pass path; a desktop viewport takes the four-pass one.
|
||
for scale in [
|
||
RenderScale::full((160, 120)),
|
||
RenderScale::full((2000, 1500)),
|
||
] {
|
||
let passes = Clarity::with_amount(50.0).passes(scale);
|
||
let (combine, blurs) = passes.split_last().expect("clarity is active");
|
||
|
||
for blur in blurs {
|
||
assert!(
|
||
!blur.wgsl.contains("c = "),
|
||
"{} must leave the colour alone: {}",
|
||
blur.label,
|
||
blur.wgsl
|
||
);
|
||
assert!(
|
||
blur.wgsl.contains("aux ="),
|
||
"{} must put its result in the scratch lane",
|
||
blur.label
|
||
);
|
||
}
|
||
|
||
// However the base was arrived at, the mask subtracts it from the
|
||
// colour the *colour* pass produced. That is the whole property:
|
||
// an unsharp mask needs the blur and the original together, and
|
||
// neither chain may have overwritten the original on the way.
|
||
assert!(combine.wgsl.contains("log_luma(c) - base"));
|
||
}
|
||
|
||
// And the two forms differ in exactly one place — where `base` came
|
||
// from. A reduced combine does no convolution at all.
|
||
let reduced = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500)));
|
||
let combine = reduced.last().expect("clarity is active");
|
||
assert!(combine.wgsl.contains("let base = reduced_at(coord);"));
|
||
assert!(
|
||
!combine.wgsl.contains("tap_aux("),
|
||
"a reduced combine has nothing left to convolve"
|
||
);
|
||
}
|
||
|
||
#[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.
|
||
//
|
||
// shorter edge = 1500
|
||
// clarity → round(0.024 × 1500) = 36 px
|
||
// texture → round(0.0024 × 1500) = 4 px
|
||
//
|
||
// so the halo the four passes together need is clarity's 36, taken as
|
||
// the maximum rather than the sum: the passes are separate dispatches,
|
||
// and a tile is grown for whichever of them reaches furthest.
|
||
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 {
|
||
// The reduce pass is the one honest exception: it reads exactly
|
||
// the block it writes and no further, so a tile computing it needs
|
||
// no halo at all. Every other pass reaches somewhere and must say
|
||
// so.
|
||
if pass.label.ends_with("/reduce") {
|
||
assert_eq!(pass.radius, 0, "the reduce pass reads only its own block");
|
||
continue;
|
||
}
|
||
assert!(pass.radius > 0, "{} declared no reach", pass.label);
|
||
}
|
||
|
||
// The equality above is the claim worth restating: a reduced base
|
||
// reaches exactly as far across the photograph as the full-resolution
|
||
// one it replaces. `radius x output_scale`, 9 x 4 against 36, which is
|
||
// what makes the reduction invisible to a tile scheduler.
|
||
let reduced = Clarity::with_amount(50.0);
|
||
assert_eq!(reduced.reduction(scale), 4);
|
||
let base_x = composed
|
||
.passes
|
||
.iter()
|
||
.find(|p| p.label.ends_with("/base-x"))
|
||
.expect("a reduced chain has an x half");
|
||
assert_eq!(base_x.radius * base_x.output_scale, clarity);
|
||
}
|
||
|
||
#[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 passes = Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)));
|
||
let combine = passes.last().expect("clarity is active");
|
||
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.
|
||
//
|
||
// clarity gain = 100/100 × 1.00 = 1.00 ; × 0.35 = 0.35 stops (26%)
|
||
// texture gain = 100/100 × 1.25 = 1.25 ; × 1.00 = 1.25 stops
|
||
//
|
||
// Both at full travel, which is what makes them a bound and not a
|
||
// measurement: no picture, and no edge in any picture, can produce more.
|
||
assert!((bound(&combine.uniforms) - 0.35).abs() < 1e-6);
|
||
let texture_passes = Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)));
|
||
let texture = texture_passes.last().expect("texture is active");
|
||
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_passes = Clarity::with_amount(50.0).passes(scale);
|
||
let texture_passes = Texture::with_amount(50.0).passes(scale);
|
||
let clarity = clarity_passes.last().expect("clarity is active");
|
||
let texture = texture_passes.last().expect("texture is active");
|
||
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.last()
|
||
.expect("clarity is active")
|
||
.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. Read
|
||
// off the blur halves rather than the first pass, which since the
|
||
// reduced base exists is a downscale carrying no radius at all.
|
||
let radii = |p: &[DetailPass]| {
|
||
p.iter()
|
||
.map(|d| (d.radius, d.output_scale))
|
||
.collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(radii(&up), radii(&down));
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|