//! TRACES: FR-DEV-3 | FR-DSP-1 //! Capture sharpening — an unsharp mask against the sensor. //! //! Every raw file arrives softer than the scene was. The anti-aliasing filter //! spreads a point over more than one photosite on purpose, the lens's circle //! of confusion spreads it further, and the demosaic interpolates two of every //! three colour samples at each site from its neighbours. None of that is a //! mistake to be corrected in the developed *picture*; it is a property of how //! the frame was recorded, and capture sharpening is the step that undoes as //! much of it as the data supports before anything else is built on top. //! //! That distinction is the whole reason this operation exists separately from //! the output sharpening in `dr-export` (FR-EXP-4). Output sharpening is aimed //! at a size and a medium — a 900-pixel web image and a matte A2 print want //! different treatment of the same edit. Capture sharpening is aimed at the //! sensor, and its answer does not change because the file is going somewhere //! else. //! //! # Unsharp mask, and why the plainest one //! //! Blur a copy, subtract it from the original, and add back some multiple of //! the difference. The difference is everything the blur threw away — the //! high-frequency content — so adding it back steepens exactly the transitions //! that the capture chain flattened, and leaves flat areas alone because the //! blur of a flat area is the area itself. //! //! Deconvolution would in principle do better, since the thing being undone //! really is a convolution with a roughly known kernel. It is also iterative, //! needs a per-body point-spread estimate this codebase does not have, and //! amplifies noise in a way that needs its own regularisation. An unsharp mask //! is the baseline every editor ships and the one a photographer's hands //! already know; a deconvolution mode can be added later behind the same three //! parameters without changing what those parameters mean. //! //! # Why two passes and not one kernel //! //! A Gaussian is separable: blurring along x and then along y gives exactly //! the same result as a single two-dimensional kernel, at 2(2R+1) taps per //! pixel instead of (2R+1)². At the radii this operation reaches when zoomed //! in — a 3-source-pixel radius at 400% is a kernel extent of 36 render pixels //! — that is 146 taps against 5 329, and it is the difference between a //! sharpening slider that tracks the mouse and one that does not. //! //! [`crate::detail::DetailStage::passes`] returns a *list* precisely so this //! is expressible: the stage ping-pongs between intermediates, so the second //! pass is handed what the first one wrote with no plumbing here. //! //! ## What the chain can and cannot do, exactly //! //! A detail pass reads exactly one texture — whatever ran before it. So the //! textbook arrangement, "blur in two passes and then subtract the result from //! the original", is not available: by the time the blur is finished the //! original is two dispatches behind and nothing is holding it. That is not a //! gap to be worked around with a third pass either, and it is worth writing //! down why, because it looks like it should be. //! //! Write `Gx`, `Gy` for the two one-dimensional blurs and `Hx = I - Gx`, //! `Hy = I - Gy` for the high-passes they define. A second pass can form only //! `α·t + β·Gy(t)` from what the first pass left it in `t`. The result wanted //! is `(1+a)c - a·GyGx(c)`, whose only occurrence of `c` is under two blurs; //! matching the `GyGx` term needs `β ≠ 0`, and then the stray `Gy(c)` term //! that comes with it can only be cancelled by `α·t` if `t` contains `c` //! unblurred, which the `Gx` in the same expression rules out. No number of //! extra passes changes this: each one only adds another blur in front. //! //! So the two passes each apply a *one-dimensional* unsharp mask, and the //! composite is the product of the two one-dimensional kernels: //! //! ```text //! (I + a·Hy)(I + a·Hx) = I + a·(Hx + Hy) + a²·HxHy //! true unsharp = I + a·(Hx + Hy) - a ·HxHy //! ``` //! //! They differ in one term, and that term is worth understanding rather than //! apologising for. `HxHy` responds only to structure that curves in both //! directions at once: on any locally one-dimensional feature — which is what //! an edge is — one of the two factors is zero and **the two agree exactly**. //! Run this on a vertical edge and it produces the textbook unsharp mask to //! the last bit. They part company only at corners and at fine two-dimensional //! texture, where this arrangement sharpens slightly harder, by `a(1+a)` times //! a quantity that is itself second-order small. //! //! Both preserve a flat field exactly: each one-dimensional kernel sums to //! `(1+a) - a = 1`, so their product does too, and no amount of sharpening //! shifts the brightness of a sky. //! //! # The radius is in source pixels, and that is the decision to check //! //! [`crate::detail::RenderScale`] offers two units and the choice between them //! is the one thing about a neighbourhood operation that is easy to get wrong //! and invisible when it is. `frame_fraction` is for lengths that are a //! property of the *composition* — clarity, texture, dehaze, a mask feather — //! where "one percent of the frame" is what the photographer meant. This //! radius is not one of those. It stands for the spread of a point across //! *photosites*, and a body with a stronger anti-aliasing filter needs a //! larger one at the same framing, so it is stated in source pixels and //! converted with [`RenderScale::source_pixels`] once per render. //! //! Read as render pixels instead, the slider would mean a different photograph //! at every size: the develop view renders at whatever the viewport needs //! (FR-DSP-1), so a 60 MP frame in a 2 000 px panel would be sharpened with a //! kernel nine times too wide relative to the picture, and the export — the //! only render that is ever kept — would be the one that looked nothing like //! what was tuned. `the_radius_is_a_sensor_length_not_a_viewport_one` below //! and `a_proxy_and_an_export_sharpen_the_same_photograph` in `dr-gpu` are the //! two halves of the proof that it does not. //! //! # Where the honest answer is "not at this size" //! //! Converting into render pixels does not conjure detail back. On a proxy at //! one-third scale a one-source-pixel radius is a third of a render pixel, and //! the frequencies it would act on were destroyed by the downscale before this //! stage ran. [`RenderScale::resolves`] is the predicate for that condition and //! this operation obeys it: below one render pixel it stops, rather than //! drawing a plausible-looking sharpening that the exported file will not //! contain. That is why every editor tells the photographer to judge //! sharpening at 1:1 — and zooming to 1:1 is enough, because the framing's //! view rect shrinks while the render target keeps its size and the ratio //! climbs back to one. //! //! A softer roll-off, fading the amount out as the kernel approaches a pixel //! rather than stopping at it, would look better while zooming. It is not done //! because it needs a second threshold that no requirement supplies and that //! would be a guess dressed as a number; `resolves` is the line the stage //! already draws, and drawing it in two places differently is worse than a //! visible step. use crate::descriptor::{ Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit, }; use crate::detail::{DetailPass, DetailStage, RenderScale}; use crate::operation::{Affects, Helper, Operation, Uniform}; use crate::ops::helpers; pub const ID: OpId = OpId("capture_sharpen"); pub const AMOUNT: ParamId = ParamId("amount"); pub const RADIUS: ParamId = ParamId("radius"); pub const THRESHOLD: ParamId = ParamId("threshold"); /// How far out the Gaussian is walked, in standard deviations. /// /// Three: beyond that a Gaussian carries under 1.2% of its weight, and the /// taps cost more than they change. The kernel is normalised by the weights /// actually summed rather than by an analytic integral, so truncating here /// costs a slightly narrower effective blur and *not* a brightness shift. const KERNEL_SIGMAS: f32 = 3.0; /// The largest kernel extent, in render pixels, that will be dispatched. /// /// Only reachable by zooming past about 16:1, where the ratio climbs above one /// and a source-pixel radius becomes many render pixels. The cap exists so /// that a magnification nobody judges sharpening at cannot quietly turn a /// slider drag into a 200-tap convolution per pass; the price is a Gaussian /// truncated inside three sigma at those magnifications, which is a slightly /// tighter blur and nothing else. const MAX_KERNEL: f32 = 48.0; /// The default radius, in source pixels. /// /// One photosite. It is what an anti-aliasing filter and a demosaic between /// them spread a point over on a conventional Bayer sensor, and it is where /// every editor's capture sharpening starts. const DEFAULT_RADIUS: f32 = 1.0; static DESCRIPTOR: OpDescriptor = OpDescriptor { id: ID, label: LocalizedKey("op.capture_sharpen"), params: &[ // Amount carries the neutral, which is why it is first: the operation // is off when this is zero regardless of the other two, so a reset is // one control and the panel's ordering matches the way it is used. ParamDescriptor::amount("amount", "param.amount"), // In **source pixels** — see the module documentation. Half a photosite // is the smallest radius that means anything on a Bayer sensor, and // three is already past the point where an unsharp mask is sharpening // rather than adding local contrast; a photographer wanting the latter // wants clarity, which is a different operation with a different unit. ParamDescriptor::scalar( "radius", "param.radius", 0.5, 3.0, DEFAULT_RADIUS, Unit::None, Scale::Linear, 2, ), // A fraction, but declared as a scalar rather than through // `ParamDescriptor::fraction` for its precision alone: four decimal // places on a control whose whole useful travel is a dozen steps // reads as noise, and invites fiddling with digits that do nothing. ParamDescriptor::scalar( "threshold", "param.threshold", 0.0, 1.0, 0.0, Unit::None, Scale::Linear, 2, ), ], attributes: &[Attribute::Detail], }; /// TRACES: FR-DEV-3 /// Capture sharpening: a separable unsharp mask with a contrast threshold. #[derive(Debug, Clone, Copy, PartialEq)] pub struct CaptureSharpen { /// −100…100. Negative softens, which is a real request: a lens that /// out-resolves the sensor, or a frame with moiré, is better served by /// backing off the capture chain's acutance than by sharpening it. amount: f32, /// The Gaussian's standard deviation, **in source pixels**. radius: f32, /// Local contrast below which detail is left alone, 0…1. threshold: f32, } impl Default for CaptureSharpen { /// Neutral, and a radius already set to something usable. /// /// The radius does not start at its minimum, and this is not the usual /// "neutral means every parameter at zero" rule being broken: neutrality /// here is `amount == 0`, and a radius has no neutral value at all — a /// blur of zero width is not the identity, it is a kernel that does not /// exist. Starting it at one photosite means dragging the amount up gives /// a sensible result immediately rather than whatever the low end of the /// slider happens to be. fn default() -> Self { Self { amount: 0.0, radius: DEFAULT_RADIUS, threshold: 0.0, } } } impl CaptureSharpen { pub fn new() -> Self { Self::default() } /// The Gaussian's standard deviation at `scale`, in **render** pixels. /// /// The single place the unit conversion happens, and the reason it is a /// method rather than a line inside [`Self::passes`]: the tests assert /// against it, and a test that recomputed the conversion would agree with /// a bug in it. pub fn sigma(&self, scale: RenderScale) -> f32 { scale.source_pixels(self.radius) } /// The kernel extent at `scale`, in render pixels — the halo each pass /// reads, and what [`DetailPass::radius`] has to state. /// /// At least one whenever the pass runs at all: a kernel of extent zero /// reads one tap, its "blur" is the pixel itself, and the high-pass it /// produces is identically zero. That would be a dispatch that copies the /// image, which is not what "sharpen a little" should mean. pub fn kernel(&self, scale: RenderScale) -> u32 { let extent = (self.sigma(scale) * KERNEL_SIGMAS).ceil(); extent.clamp(1.0, MAX_KERNEL) as u32 } /// Whether this render is fine enough to show the radius that was chosen. /// /// Delegates to [`RenderScale::resolves`] rather than restating the /// comparison, so that the line between "sharpened" and "not at this size" /// is drawn in exactly one place in the codebase. pub fn resolves(&self, scale: RenderScale) -> bool { scale.resolves(self.radius) } } impl Operation for CaptureSharpen { fn descriptor(&self) -> &'static OpDescriptor { &DESCRIPTOR } fn set_param(&mut self, id: ParamId, value: f32) { if id == AMOUNT { self.amount = value; } else if id == RADIUS { self.radius = value; } else if id == THRESHOLD { self.threshold = value; } else { log::warn!("capture_sharpen: unknown parameter {id}"); } } fn param(&self, id: ParamId) -> f32 { if id == AMOUNT { self.amount } else if id == RADIUS { self.radius } else if id == THRESHOLD { self.threshold } else { 0.0 } } /// Neutral is `amount == 0`, not "every parameter at its default". /// /// A radius and a threshold describe *how* to sharpen and say nothing /// about whether to; moving either one with the amount at zero must leave /// the photograph untouched and must not make the edit non-neutral, or an /// unedited file would open reporting itself modified as soon as anyone /// brushed the radius slider. fn is_active(&self) -> bool { self.amount != 0.0 } /// Never called: a detail operation contributes no fused fragment, and /// [`crate::operation::compose_full`] filters it out before asking. fn wgsl_body(&self) -> String { String::new() } fn uniforms(&self) -> Vec { Vec::new() } fn affects(&self) -> Affects { Affects::Detail } fn detail(&self) -> Option<&dyn DetailStage> { Some(self) } /// The shared Rec. 709 luminance, which in this stage is not the /// approximation its own documentation warns about: `_helpers.yaml` notes /// that the weights are only approximate on camera-space values, and the /// detail stage runs *after* the camera matrix, in linear sRGB, where they /// are the definition. fn helpers(&self) -> &'static [Helper] { &[helpers::LUMINANCE] } } impl DetailStage for CaptureSharpen { fn passes(&self, scale: RenderScale) -> Vec { if !self.resolves(scale) { return vec![nothing_to_sharpen()]; } let extent = self.kernel(scale); // Both halves are the same body and the same uniforms, differing only // in the axis they walk — and the axis is read from the pass index the // composer already writes into the base uniform block, so there is one // kernel here rather than two that can drift apart. ["horizontal", "vertical"] .into_iter() .map(|label| DetailPass { label, radius: extent, uniforms: vec![ Uniform { // −100…100 as a gain around zero. A hundred percent is // a strong capture sharpen and not the ceiling of what // is useful, which is why the control is the familiar // photographic amount rather than a 0…1 fraction. name: "amount", value: self.amount / 100.0, }, Uniform { name: "sigma", value: self.sigma(scale), }, Uniform { name: "taps", value: extent as f32, }, Uniform { // The threshold as a local-contrast fraction. A quarter // at the top of the slider: past about 25% modulation // the gate has stopped rejecting noise and started // rejecting the edges the operation exists to sharpen. name: "gate", value: self.threshold * 0.25, }, ], wgsl: BODY.to_string(), }) .collect() } } /// The pass emitted when the radius is finer than a render pixel. /// /// One dispatch that changes nothing, rather than an empty chain, and the /// difference is not stylistic. [`crate::operation::compose_full`] decides /// from the *operations* — before any resolution is known — that an active /// detail operation means the fused pass hands on unclipped linear values /// instead of encoding its own output. If this returned no passes at all, /// that decision would still stand and nothing downstream would ever perform /// the output transform: `dr-gpu` would be handed a linear-working shader /// with an empty chain and refuse it. /// /// So the honest "nothing survives at this scale" still has to carry the /// encode, and one pass that does only that is exactly the resolve step the /// stage would otherwise need. It costs a single copy of a proxy-sized /// texture, which is a rounding error against the dispatches around it. fn nothing_to_sharpen() -> DetailPass { DetailPass { label: "unresolved", // Reads only the pixel it writes, so a tile needs no halo at all. radius: 0, uniforms: Vec::new(), wgsl: "// The chosen radius is finer than one pixel of this render, so the detail // it would act on is not in this texture — it was lost to the downscale // before this stage ran (FR-DSP-1). Guessing at it would put sharpening on // screen that the exported file will not contain, so this pass passes the // colour through unchanged and the interface is free to say `zoom to 1:1`. // // `c` already holds this pixel; leaving it alone is the whole body." .to_string(), } } /// One axis of the separable unsharp mask. /// /// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the /// axis is read from a uniform the composer already writes rather than from a /// second copy of this kernel. const BODY: &str = r#"// One axis of a separable unsharp mask, applied to luminance. // // The composer writes this pass's index into the base uniform block's fourth // lane precisely so that a two-pass operation need not carry a uniform of its // own to say which half it is in. Pass 0 walks x, pass 1 walks y. let axis = select(vec2(0, 1), vec2(1, 0), u.detail_base.w < 0.5); // The Gaussian is evaluated here rather than uploaded as a weight table. The // kernel changes size with the zoom — the radius is in source pixels and the // ratio is not fixed — so a table would have to be a fixed-length array padded // to the widest kernel the slider can reach, uploaded per frame, to save an // `exp` that the hardware does in one instruction. let extent = i32(taps); let falloff = 1.0 / (2.0 * sigma * sigma); // Sharpening acts on luminance alone. Adding the high-pass to the three // channels independently sharpens chroma noise into coloured speckle at every // edge, which is the classic way an unsharp mask ruins a high-ISO frame; and // the demosaic's interpolation error — the thing being corrected — is a // luminance error, because that is the channel the CFA samples most densely. let centre = luminance(c); var weighted = 0.0; var total = 0.0; for (var i = -extent; i <= extent; i = i + 1) { let d = f32(i); let w = exp(-d * d * falloff); weighted = weighted + w * luminance(tap(coord, axis * i)); total = total + w; } // Normalised by the weights actually summed, never by an analytic integral. // The kernel is truncated at three sigma and `tap` clamps at the border, so // the two disagree — by a fraction of a percent in the middle of the frame and // by far more along its edge. Dividing by the wrong one would put a bright or // dark band around the whole photograph, which is invisible on a test pattern // and perfectly visible on a sky. let blurred = weighted / total; // Everything this axis's blur threw away. Zero on a flat field, so a sky comes // through untouched at any amount, and the kernel as a whole still sums to one. let high = centre - blurred; // The threshold, as *local contrast* rather than as an absolute difference. // // A photographer setting this is saying "modulation this shallow is noise, not // detail", and that judgement is about the ratio between the detail and the // tone it sits on: the same sensor noise is a hundred times smaller in linear // units in a shadow than in a highlight, so an absolute gate calibrated on a // midtone would leave shadow noise fully sharpened and flatten highlight // texture. A ratio also makes the control survive the exposure slider, which // an absolute one would not. // // The floor keeps the ratio finite as the local level approaches black. Below // roughly nine stops down there is nothing but read noise anyway, and without // it a noise-sized difference divided by a noise-sized level would read as a // hard edge and be sharpened hardest exactly where it is least wanted. let level = max(blurred, 0.005); let contrast = abs(high) / level; // A soft knee rather than a step: gating on a comparison would sharpen one // pixel fully and its neighbour not at all, and the boundary between them is // itself an edge — visible as a crawling outline around every gently graded // region. Full suppression below half the gate, full effect above it. let knee = max(gate, 1e-5); let keep = select(1.0, smoothstep(knee * 0.5, knee, contrast), gate > 0.0); // `sharpened` rather than the obvious `target`: `target` is a WGSL reserved // keyword, and a fragment that declares one fails to compile against // *generated* source, so the error names a file nobody wrote. The pipeline // has been bitten by exactly this once already — see // `no_fragment_declares_a_wgsl_reserved_keyword` in `lib.rs`, which was added // the day `let target` broke the contrast fragment. let sharpened = centre + amount * high * keep; // Applied as a gain on all three channels rather than as an offset, so that // steepening an edge does not drag its colour towards grey: the channel ratios // are the hue and the saturation, and multiplying leaves them exactly where // they were. Negative luminance is not a colour, so undershoot stops at black // — the only clamp in this stage, and it is on the scalar, not on the channels, // which stay unclipped above one for the output transform to deal with. // // Below a nearly-black luminance the ratio stops carrying information — the // three channels are all noise there and the divisor is meaningless — so the // pixel is handed on untouched rather than multiplied by whatever fell out. let scaled = c * (max(sharpened, 0.0) / max(centre, 1e-5)); c = select(c, scaled, centre > 1e-5);"#; #[cfg(test)] mod tests { use super::*; use crate::EditGraph; use dr_types::ColourSpace; /// The develop chain with the sharpener turned up. /// /// Built through [`EditGraph`] rather than by reaching into `ops::chain()` /// directly, so that every value these tests use passes the same clamp a /// slider's would. A test asserting an exact kernel for a radius the graph /// would have clipped on the way in is a test of a configuration the /// photographer cannot reach — the mistake `build.rs` refuses outright for /// declared nodes, and which nothing catches for a hand-written one. fn sharpening(amount: f32, radius: f32) -> EditGraph { let mut graph = EditGraph::default_chain(); graph.set_param(ID, AMOUNT, amount); graph.set_param(ID, RADIUS, radius); graph } fn chain_at(graph: &EditGraph, scale: RenderScale) -> crate::detail::ComposedDetail { graph.compose_detail_for(scale, ColourSpace::Srgb) } #[test] fn a_radius_alone_is_not_an_edit() { // The neutral rule this operation states differently from most: two of // its three parameters describe *how* to sharpen, and moving them with // the amount at zero must leave the file unmodified. Otherwise opening // an image and brushing the radius slider would mark it edited and // write a sidecar for a photograph nobody changed. let mut op = CaptureSharpen::new(); assert!(!op.is_active()); op.set_param(RADIUS, 3.0); op.set_param(THRESHOLD, 1.0); assert!(!op.is_active(), "a radius is not a decision to sharpen"); op.set_param(AMOUNT, 25.0); assert!(op.is_active()); } #[test] fn a_neutral_sharpener_composes_no_passes_at_all() { // The rule the whole pipeline rests on: an operation at its defaults // costs nothing. Almost every photograph in a library is unsharpened, // and none of them should pay a dispatch for it. let composed = chain_at(&EditGraph::default_chain(), RenderScale::full((512, 512))); assert!(composed.is_empty()); assert_eq!(composed.radius(), 0); } #[test] fn sharpening_is_two_passes_and_only_the_last_one_encodes() { // Separability, as it reaches the GPU. The first pass writes a linear // intermediate and the second writes the display texture, so the // output transform happens exactly once (FR-DEV-2) at the end of the // chain rather than in the middle of a convolution. let composed = chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))); assert_eq!(composed.len(), 2); let (first, last) = (&composed.passes[0], &composed.passes[1]); assert_eq!(first.label, "capture_sharpen/horizontal"); assert_eq!(last.label, "capture_sharpen/vertical"); assert!(!first.writes_output); assert!(first.source.contains("texture_storage_2d f32 { let scale = RenderScale::new((render, render), full); chain_at(&graph, scale).radius() as f32 / scale.ratio() }; let export = in_source_pixels(4000); let half = in_source_pixels(2000); let fit = in_source_pixels(1600); assert!((export - 9.0).abs() < 0.01, "three sigma of three pixels"); // Within the rounding of one render pixel back through the ratio, // which is the whole of the permitted error: the kernel is an integer // count of render pixels, and one of those is two source pixels on the // half proxy and two and a half on the fit view. assert!( (half - export).abs() <= 2.0, "the same edit covers {half} source pixels on a half proxy and \ {export} at export" ); assert!( (fit - export).abs() <= 2.5, "the same edit covers {fit} source pixels on a 40% view and \ {export} at export" ); // The other half of the statement, and the one that fails if the unit // is misread: the kernel in *render* pixels must shrink with the // render, because that is what keeps it the same size on the picture. let render_pixels = |render: u32| chain_at(&graph, RenderScale::new((render, render), full)).radius(); assert_eq!(render_pixels(4000), 9); assert_eq!(render_pixels(2000), 5, "ceil(3 sigma of 1.5)"); assert_eq!(render_pixels(1600), 4, "ceil(3 sigma of 1.2)"); } #[test] fn a_render_too_coarse_for_the_radius_stops_rather_than_guesses() { // A one-pixel radius on a quarter-scale proxy is a quarter of a render // pixel, and no kernel represents that — the frequencies it would act // on went out with the downscale. `RenderScale::resolves` reports the // condition and this obeys it, because a preview that shows sharpening // the exported file will not contain is worse than one that shows none. let graph = sharpening(100.0, 1.0); let proxy = RenderScale::new((1000, 1000), (4000, 4000)); assert!(!proxy.resolves(1.0)); let composed = chain_at(&graph, proxy); // Not empty, though. See `nothing_to_sharpen`: the fused pass has // already been composed to hand on linear values, so *something* must // still perform the output transform. assert_eq!(composed.len(), 1); assert_eq!(composed.radius(), 0, "it reads no neighbours"); let pass = &composed.passes[0]; assert_eq!(pass.label, "capture_sharpen/unresolved"); assert!(pass.writes_output); assert!(pass.source.contains("fn encode_output")); assert!( !pass.source.contains("for (var i ="), "the pass-through must not walk a kernel it has decided not to run" ); // Zooming to 1:1 is what brings it back — the view rect shrinks while // the render target keeps its size — so there is no separate // full-resolution preview path for a photographer to wait on. let one_to_one = RenderScale::new((1000, 1000), (1000, 1000)); assert_eq!(chain_at(&graph, one_to_one).len(), 2); } #[test] fn the_amount_reaches_the_shader_as_a_gain_and_keeps_its_sign() { // Negative is not a mistake to be clamped away: a lens that // out-resolves the sensor, or a frame with moiré, wants the capture // chain's acutance backed off rather than lifted, and the same kernel // with a negative gain is exactly that. let amount_of = |value: f32| -> f32 { let composed = chain_at(&sharpening(value, 1.0), RenderScale::full((512, 512))); // Base block first and fixed, then this pass's own, in the order // `passes` declared them. composed.passes[0].uniforms[crate::detail::DETAIL_BASE_UNIFORM_FIELDS] }; assert!((amount_of(100.0) - 1.0).abs() < 1e-6); assert!((amount_of(50.0) - 0.5).abs() < 1e-6); assert!((amount_of(-40.0) + 0.4).abs() < 1e-6); } #[test] fn the_threshold_is_off_when_it_is_at_zero() { // The gate is a `smoothstep`, and a `smoothstep` whose two edges meet // is undefined. The body guards it with a `select` on this uniform // being positive, so a photographer who never touches the threshold // gets the plain unsharp mask and not a NaN. let gate_of = |threshold: f32| -> f32 { let mut graph = sharpening(50.0, 1.0); graph.set_param(ID, THRESHOLD, threshold); let composed = chain_at(&graph, RenderScale::full((512, 512))); *composed.passes[0].uniforms.last().expect("a gate") }; assert_eq!(gate_of(0.0), 0.0); assert!((gate_of(1.0) - 0.25).abs() < 1e-6); assert!( chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0] .source .contains("gate > 0.0") ); } #[test] fn every_pass_declares_a_uniform_block_the_gpu_will_accept() { // A uniform struct whose size is not a multiple of sixteen is rejected // outright by the WGSL uniform address space rules, and the failure // arrives as a compilation error against source nobody wrote. Both // shapes this operation emits have to satisfy it — the sharpening pair // and the pass-through, which carries no uniforms of its own at all. let scales = [ RenderScale::full((512, 512)), RenderScale::new((256, 256), (4096, 4096)), ]; for scale in scales { for pass in chain_at(&sharpening(75.0, 1.0), scale).passes { assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label); assert!( pass.uniforms.iter().all(|v| v.is_finite()), "{}", pass.label ); } } } #[test] fn the_kernel_declares_no_wgsl_reserved_keyword() { // `lib.rs` runs this check over the fused fragments and cannot reach // here: a detail pass is a separate shader, composed at a resolution // that `compose()` never sees. It is worth repeating rather than // skipping, because the failure it catches is the least legible one in // this crate — `let target = ...` in the contrast fragment once failed // with "name `target` is a reserved keyword", pointing at generated // source rather than at the operation that wrote it, and this body // reaches for exactly that word. // // Not the full reserved list; the words a kernel would plausibly pick // for a local. The same list `no_fragment_declares_a_wgsl_reserved_keyword` uses, // kept identical on purpose: two lists that drift apart would let a // word be safe in one stage and not in the other, which is the sort of // difference nobody discovers until a shader fails to compile. const RESERVED: &[&str] = &[ "target", "sample", "filter", "texture", "buffer", "binding", "const", "enum", "mat", "vec", "ptr", "ref", "shared", "static", "typedef", "union", "unless", "handle", "layout", "packed", "premerge", "regardless", "active", "do", "input", "output", "private", "resource", "restrict", "self", "std", "where", ]; for pass in chain_at(&sharpening(100.0, 2.0), RenderScale::full((512, 512))).passes { // Only what this operation wrote — the block the composer wraps // the body in. Its preamble declares `var output` and its tail // writes through it, and neither is this test's business; scanning // the whole file would fail on generated code nobody here can fix. let block = pass .source .rsplit_once(" {\n") .expect("the operation's block opens") .1; let body = block .split_once("\n }\n") .map_or(block, |(inside, _)| inside); // Comments are not declarations, and the kernel deliberately names // `target` in one to say why it does not use it. Stripping them // keeps this on the code, so that editing the prose can never fail // a test about what compiles. let code: Vec<&str> = body .lines() .filter(|l| !l.trim_start().starts_with("//")) .collect(); let code = code.join("\n"); for keyword in RESERVED { for form in [format!("let {keyword} "), format!("var {keyword} ")] { assert!( !code.contains(&form), "{} declares `{keyword}`, which is a WGSL reserved keyword", pass.label ); } } } } #[test] fn a_deep_zoom_cannot_turn_a_slider_into_an_unbounded_convolution() { // Zooming past about 16:1 pushes the ratio above one, and a radius in // source pixels becomes many render pixels. The cap keeps the cost of // a magnification nobody judges sharpening at from growing without // limit; what it costs is a Gaussian truncated inside three sigma, // which is a slightly tighter blur and no other artefact. let mut op = CaptureSharpen::new(); op.set_param(AMOUNT, 100.0); op.set_param(RADIUS, 3.0); let deep = RenderScale::new((2000, 2000), (25, 25)); assert!(deep.ratio() > 16.0); assert_eq!(op.kernel(deep), MAX_KERNEL as u32); } }