Every local adjustment began from a shape: painted, drawn with a handle, or found by a model. So the only way to hold back a sky was to draw a line near where it ended, and the only way to warm skin was to paint round it — both of which put the edit's edge where the photographer put a gesture rather than where the picture changes. A gradient across a treeline halos, and an adjustment traced round a face stops on the outline of a hand. MaskSource grows two variants that select by what a pixel *is*. Luminance carries two bounds on the perceptual tone scale plus a softness; Colour carries an arc of hue, a range of chroma, and one softness for every edge of both. Five floats and three, so they diff, sync and merge per field under FR-NC-9 exactly as a gradient's geometry does — the property a stored raster has none of, and the reason the model's coverage had to sit beside its source rather than inside it. The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the demosaiced source as a sixth binding and two new modes read it: decode, balance, pull a clipped photosite back to neutral, apply the camera matrix, then weigh the band. Nothing crosses to the CPU but the numbers and the matrix, and each mask texel averages its own footprint in the source, so a band lands on the tone an area is rather than on whichever texel a proxy grid happened to land on. The photograph it measures is the one the camera recorded, before this edit. A band over the edited result would slide out from under the edit as the edit was made — raising the highlights would change which pixels counted as highlights, and the slider would chase its own mask. Feather, falloff and morphology stay off a range layer, which is what `shapeable` already meant. All three are functions of the signed distance from a boundary, and a range has no boundary to be at a distance from; its edge is the softness of its own band, in the band's units. Offering them would be four controls that move and change nothing.
201 lines
7.5 KiB
Rust
201 lines
7.5 KiB
Rust
// TRACES: FR-DEV-10
|
|
//! A range mask must select by the photograph's values, and by nothing else.
|
|
//!
|
|
//! Two properties, and both are silent when they break. A range mask that
|
|
//! reads the wrong pixels still produces a plausible-looking selection, and one
|
|
//! that depends on the size it was rasterised at looks right in the develop
|
|
//! view and wrong only in the export — the one place nobody is watching.
|
|
//!
|
|
//! Everything here goes through the real pass. There is no CPU rasterisation of
|
|
//! a mask to test against and there must not be (ARCH §5.4), so the mask is
|
|
//! observed the only way it exists: through the adjustment it weights.
|
|
|
|
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass};
|
|
use dr_pipeline::descriptor::ParamId;
|
|
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
|
use dr_pipeline::operation::compose_full;
|
|
use dr_pipeline::spot::SpotSet;
|
|
use dr_pipeline::{ops, Framing};
|
|
use dr_types::ColourSpace;
|
|
|
|
/// The source and the output are the same size, so a difference between two
|
|
/// runs can only have come from the mask.
|
|
const SIZE: u32 = 64;
|
|
|
|
fn ctx() -> Option<GpuContext> {
|
|
pollster::block_on(GpuContext::new_headless()).ok()
|
|
}
|
|
|
|
/// A frame whose left half is one colour and right half another.
|
|
///
|
|
/// Deliberately not a ramp. A range mask's whole job is to divide the picture
|
|
/// by value, so a source that is already divided by value makes the assertion
|
|
/// "the mask found the half it was aimed at" rather than "the mask is roughly
|
|
/// where it should be".
|
|
fn split(ctx: &GpuContext, left: [u8; 3], right: [u8; 3]) -> DemosaicedImage {
|
|
let data: Vec<u8> = (0..SIZE * SIZE)
|
|
.flat_map(|i| {
|
|
let c = if i % SIZE < SIZE / 2 { left } else { right };
|
|
[c[0], c[1], c[2], 255]
|
|
})
|
|
.collect();
|
|
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
|
}
|
|
|
|
fn brightened(source: MaskSource) -> MaskStack {
|
|
let mut stack = MaskStack::new();
|
|
let mut layer = MaskLayer::new("m1", source);
|
|
// Two stops, so "selected" and "not selected" are not a judgement call.
|
|
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
|
stack.push(layer);
|
|
stack
|
|
}
|
|
|
|
/// Render `stack` over `source`, with the mask rasterised at `raster`.
|
|
///
|
|
/// `raster` is a parameter because it is the thing that must not matter: the
|
|
/// develop view and an export ask for the same mask at different sizes, and a
|
|
/// range that answered differently at each would be a mask that changes when
|
|
/// the photograph is exported.
|
|
fn render(ctx: &GpuContext, source: &DemosaicedImage, stack: &MaskStack, raster: u32) -> Vec<u8> {
|
|
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
|
let array = masks
|
|
.render(stack, None, None, Some(source), raster, raster)
|
|
.expect("rasterise");
|
|
|
|
let shader = compose_full(
|
|
&ops::chain(),
|
|
&Framing::new(),
|
|
ColourSpace::Srgb,
|
|
stack,
|
|
&SpotSet::new(),
|
|
&[],
|
|
);
|
|
let mut adjust = AdjustPass::new(ctx);
|
|
adjust
|
|
.render_masked(source, &shader, SIZE, SIZE, Some(array))
|
|
.expect("render");
|
|
adjust.export_pixels().expect("readback").0
|
|
}
|
|
|
|
fn red_at(pixels: &[u8], x: u32, y: u32) -> u8 {
|
|
pixels[((y * SIZE + x) * 4) as usize]
|
|
}
|
|
|
|
/// The centre of each half, away from the seam the mask's own softness
|
|
/// straddles.
|
|
fn halves(pixels: &[u8]) -> (u8, u8) {
|
|
(
|
|
red_at(pixels, SIZE / 4, SIZE / 2),
|
|
red_at(pixels, SIZE * 3 / 4, SIZE / 2),
|
|
)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-10
|
|
#[test]
|
|
fn a_tone_band_brightens_only_the_half_inside_it() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
// 200 lands near 0.83 on the perceptual scale and 30 near 0.24, so a band
|
|
// over the upper half contains one and not the other with room to spare.
|
|
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
|
let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15));
|
|
|
|
let pixels = render(&ctx, &source, &stack, SIZE);
|
|
let (bright, dark) = halves(&pixels);
|
|
|
|
assert!(
|
|
bright > 230,
|
|
"the bright half is inside the band and should have been lifted, got {bright}"
|
|
);
|
|
assert!(
|
|
dark < 45,
|
|
"the dark half is outside the band and must be untouched, got {dark}"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-10
|
|
/// The property the develop view and the export path share. The mask array is
|
|
/// rasterised at a proxy size in both, but nothing in this pass may *depend*
|
|
/// on that size — a range is a function of the photograph's values, and those
|
|
/// do not change when somebody asks for a bigger picture.
|
|
#[test]
|
|
fn a_tone_band_is_the_same_mask_at_any_raster_size() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
|
let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15));
|
|
|
|
// A quarter of the source and twice it: one mask texel averaging sixteen
|
|
// source texels, and one source texel spread over four mask texels.
|
|
let small = halves(&render(&ctx, &source, &stack, SIZE / 4));
|
|
let large = halves(&render(&ctx, &source, &stack, SIZE * 2));
|
|
|
|
// A tolerance rather than equality: the two rasters land their edges on
|
|
// different grids, and the assertion is that the *selection* is the same,
|
|
// not that two resamplings of it are bit-identical.
|
|
assert!(
|
|
small.0.abs_diff(large.0) <= 4 && small.1.abs_diff(large.1) <= 4,
|
|
"the same band selected differently at two raster sizes: {small:?} vs {large:?}"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-10
|
|
#[test]
|
|
fn a_colour_band_follows_hue_rather_than_brightness() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
// Short of saturation on purpose. A fully clipped channel carries no
|
|
// colour at all, and the pass pulls such a pixel back to neutral before
|
|
// the band ever sees it — which is correct, and would make this test about
|
|
// that instead.
|
|
let source = split(&ctx, [200, 40, 40], [40, 40, 200]);
|
|
// A narrow arc at red, above the chroma floor that keeps the greys out.
|
|
let stack = brightened(MaskSource::colour_range(0.0, 0.05, 0.15, 1.0, 0.05));
|
|
|
|
let pixels = render(&ctx, &source, &stack, SIZE);
|
|
let (red, blue) = halves(&pixels);
|
|
|
|
assert!(
|
|
red > 230,
|
|
"the red half is inside the arc and should have been lifted, got {red}"
|
|
);
|
|
assert!(
|
|
blue < 60,
|
|
"the blue half is a third of the circle away and must be untouched, got {blue}"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-10
|
|
/// The greys a hue arc would otherwise sweep up.
|
|
///
|
|
/// A nearly-neutral pixel still has a hue — three almost-equal channels round
|
|
/// to one — so an arc without a chroma floor selects a haze of noise across
|
|
/// every desaturated part of the picture. It is the failure that looks like the
|
|
/// mask working badly rather than like a control that is missing.
|
|
#[test]
|
|
fn a_colour_band_ignores_the_greys() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
// Both halves neutral, at the two brightnesses the tone test uses, so the
|
|
// only reason either could be selected is the hue arc reaching them.
|
|
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
|
let stack = brightened(MaskSource::colour_range(0.0, 0.5, 0.15, 1.0, 0.05));
|
|
|
|
let pixels = render(&ctx, &source, &stack, SIZE);
|
|
let (light, dark) = halves(&pixels);
|
|
|
|
assert!(
|
|
light < 215 && dark < 45,
|
|
"a grey frame was selected by a colour mask: {light}, {dark}"
|
|
);
|
|
}
|