Flatten contrast toward grey instead of scaling shadows by a ratio
Reducing contrast turned every black in a night photograph pink. The fragment lifted each pixel's luminance to its target by multiplying the colour by target/luma. For a pixel at 0.001 on the way to 0.09 that is a gain of ninety, and in the deepest shadows the channels are sensor noise: after white balance the red and blue noise sits above the green, their multipliers being nearly twice its, so ninety times that noise is magenta. Flattening now mixes the colour toward middle grey, which gives the same luminance and adds the lift as a neutral. A black goes to grey and its noise stays the size it was. The same fragment clamped luma/0.36 into the curve's 0..1 domain, which scaled every tone above twice middle grey down to 0.36 in either direction: contrast +10 took a 230 grey to 162. Those tones are now left where they are, which is continuous with the curve's top (value 1, slope 0).
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
//! Contrast, on a device, at the two ends of the tonal range.
|
||||
//!
|
||||
//! The descriptor tests check that the fragment says the right words; these
|
||||
//! check what those words do to a pixel. Both failures here passed every
|
||||
//! descriptor test for months, because each is a property of the arithmetic
|
||||
//! at the extremes rather than of the shape of the code.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::operation::compose;
|
||||
use dr_pipeline::ops;
|
||||
|
||||
const SIZE: u32 = 8;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A flat frame of one sRGB colour, contrast set to `amount`, rendered and
|
||||
/// read back as the colour of one pixel.
|
||||
fn render(ctx: &GpuContext, rgb: [u8; 3], amount: f32) -> [u8; 3] {
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|_| [rgb[0], rgb[1], rgb[2], 255])
|
||||
.collect();
|
||||
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
|
||||
|
||||
let mut chain = ops::chain();
|
||||
let op = chain
|
||||
.iter_mut()
|
||||
.find(|o| o.descriptor().id.0 == "contrast")
|
||||
.expect("contrast is in the chain");
|
||||
op.set_param(ParamId("contrast"), amount);
|
||||
let shader = compose(&chain);
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||
let pixels = adjust.export_pixels().expect("readback").0;
|
||||
[pixels[0], pixels[1], pixels[2]]
|
||||
}
|
||||
|
||||
/// **The pink-blacks bug.** A near-black pixel whose red and blue sit a count
|
||||
/// above its green — what white-balanced sensor noise in a night shadow looks
|
||||
/// like — must come out grey when contrast is reduced, not magenta.
|
||||
///
|
||||
/// The ratio form lifted it by a gain of well over a hundred, and a hundred
|
||||
/// times a one-count cast is a saturated colour.
|
||||
#[test]
|
||||
fn reducing_contrast_lifts_a_black_to_grey_not_to_magenta() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let [r, g, b] = render(&ctx, [4, 1, 4], -50.0);
|
||||
let spread = r.max(g).max(b) - r.min(g).min(b);
|
||||
assert!(
|
||||
g > 40,
|
||||
"a black at half contrast should be lifted toward grey, got ({r}, {g}, {b})"
|
||||
);
|
||||
assert!(
|
||||
spread <= 6,
|
||||
"the lift must be neutral: ({r}, {g}, {b}) has a cast of {spread}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The pinned highlights.** A light tone, above twice middle grey, must not
|
||||
/// be pulled down to the top of the curve by the smallest positive contrast.
|
||||
#[test]
|
||||
fn a_little_contrast_leaves_a_highlight_where_it_was() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let before = render(&ctx, [230, 230, 230], 0.0)[1];
|
||||
let after = render(&ctx, [230, 230, 230], 10.0)[1];
|
||||
assert!(
|
||||
after >= before.saturating_sub(2),
|
||||
"contrast +10 took a highlight from {before} to {after}"
|
||||
);
|
||||
}
|
||||
@@ -35,42 +35,63 @@ helpers: [luminance, apply_tone_gain]
|
||||
|
||||
define:
|
||||
contrast_curve: |
|
||||
// A symmetric S-curve on a 0..1 perceptual position.
|
||||
// The steepening S, on a 0..1 perceptual position.
|
||||
//
|
||||
// `amount` above zero steepens, below zero flattens. The smoothstep form is
|
||||
// used for the steepening direction because it has zero gradient at both
|
||||
// ends, so the curve cannot invert however hard it is pushed — the failure
|
||||
// that makes naive gain-about-a-pivot unusable past moderate settings.
|
||||
// Blends toward a smoothstep, which has zero gradient at both ends, so the
|
||||
// curve cannot invert however hard it is pushed — the failure that makes
|
||||
// naive gain-about-a-pivot unusable past moderate settings. Only the
|
||||
// positive direction comes here: flattening is not a curve at all (see
|
||||
// the fragment).
|
||||
fn contrast_curve(x: f32, amount: f32) -> f32 {
|
||||
let clamped = clamp(x, 0.0, 1.0);
|
||||
if (amount >= 0.0) {
|
||||
// Blend toward a smoothstep, which is the S.
|
||||
let s = clamped * clamped * (3.0 - 2.0 * clamped);
|
||||
return mix(clamped, s, amount);
|
||||
}
|
||||
// Flattening: pull toward the mid-point. At amount = -1 every tone
|
||||
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
|
||||
return mix(clamped, 0.5, -amount);
|
||||
}
|
||||
|
||||
wgsl: |
|
||||
if (amount < 0.0) {
|
||||
// **Flattening mixes toward middle grey; it does not scale.**
|
||||
//
|
||||
// Every tone moves the same fraction of the way to 0.18, which at -1
|
||||
// collapses the picture to grey — the meaningful limit of 'no contrast'.
|
||||
// In luminance this is exactly what the ratio form below would compute,
|
||||
// but the ratio form reaches it by multiplying: a pixel at 0.001 has to
|
||||
// be lifted to 0.09, a gain of ninety, and in the deepest shadows the
|
||||
// channels are sensor noise, not a colour. After white balance the red
|
||||
// and blue noise sits above the green (their multipliers are nearly
|
||||
// twice its), so ninety times that noise is magenta — every black in the
|
||||
// frame turned pink. Mixing adds the lift as a neutral, so a black goes
|
||||
// to grey and its noise stays the size it was.
|
||||
//
|
||||
// The grey is (1, 1, 1) scaled, because this runs after white balance
|
||||
// in the camera's space, where that is what neutral is.
|
||||
c = mix(c, vec3<f32>(0.18), -amount);
|
||||
} else {
|
||||
let luma = luminance(c);
|
||||
if (luma > 0.0001) {
|
||||
// Only up to twice middle grey, which is the curve's whole domain. Above
|
||||
// it the curve's value is 1 and its slope 0, so leaving those tones
|
||||
// alone is the continuous continuation — where scaling them to the
|
||||
// curve's top, as this once did through a clamp, pinned every highlight
|
||||
// in the photograph to 0.36 at the smallest touch of the slider.
|
||||
if (luma > 0.0001 && luma < 0.36) {
|
||||
// Work on luminance and rescale the colour by the ratio, rather than
|
||||
// curving each channel independently. Per-channel contrast shifts hue
|
||||
// wherever the channels differ — the classic symptom being skies going
|
||||
// cyan as contrast rises.
|
||||
// cyan as contrast rises. Safe here where it was not for flattening:
|
||||
// the S only ever pulls a shadow down, so the gain is at most one
|
||||
// below the pivot and noise is never amplified.
|
||||
//
|
||||
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The
|
||||
// curve operates on luma/(2*0.18) so that middle grey lands at the
|
||||
// curve's own 0.5 pivot.
|
||||
let pos = clamp(luma / 0.36, 0.0, 1.0);
|
||||
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone.
|
||||
// The curve operates on luma/(2*0.18) so that middle grey lands at
|
||||
// the curve's own 0.5 pivot.
|
||||
let pos = luma / 0.36;
|
||||
let curved = contrast_curve(pos, amount);
|
||||
// Not `target`: that is a WGSL reserved keyword, and using it produces a
|
||||
// parse error in generated code rather than anywhere a reader would look.
|
||||
let curved_luma = curved * 0.36;
|
||||
c = apply_tone_gain(c, curved_luma / luma);
|
||||
}
|
||||
}
|
||||
c = max(c, vec3<f32>(0.0));
|
||||
|
||||
tests:
|
||||
@@ -104,6 +125,19 @@ tests:
|
||||
propagate through everything downstream.
|
||||
expect_wgsl: ["luma > 0.0001"]
|
||||
|
||||
- name: flattening_mixes_toward_grey_rather_than_scaling
|
||||
why: |
|
||||
Lifting a shadow by a luminance ratio multiplies its noise by the same
|
||||
ratio — ninety at the bottom of a night photograph — and after white
|
||||
balance that noise is magenta. A mix adds the lift as a neutral.
|
||||
expect_wgsl: ["mix(c, vec3<f32>(0.18), -amount)"]
|
||||
|
||||
- name: highlights_are_not_pinned_to_the_top_of_the_curve
|
||||
why: |
|
||||
The curve covers 0..0.36. A clamp into that range scaled every brighter
|
||||
pixel down to 0.36; tones above it are left as they are.
|
||||
expect_wgsl: ["luma < 0.36"]
|
||||
|
||||
- name: the_curve_cannot_invert
|
||||
why: |
|
||||
A gain-about-a-pivot form produces a non-monotonic curve past moderate
|
||||
|
||||
+12
-12
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user