Brighten her face without touching the sky behind her

A mask layer is an ordinary develop chain plus a rule about where it
applies. Nothing in the chain knows it is being masked, so every operation
that works globally now works locally and a newly declared op in `ops/`
arrives with local support already done.

The composer emits each layer after the global chain and before the
conversion out of camera space, which is what a photographer means by "and
*then* lift the shadows on her face". Op fragments write to a `c` they
expect to own, so a layer block shadows it and copies the result back out
through a carrier — assigning the outer one from inside is impossible
precisely because it is shadowed. The fused dispatch survives: three global
adjustments and two masked ones remain one shader, one read, one write.

Masks rasterise on the GPU and never exist in CPU memory (ARCH §5.4). That
is the whole reason darktable's brush masks lag, and it is architectural
rather than tuning, so it is not a thing to inherit and fix later.

The rasteriser is a render pass rather than the compute shader it obviously
wants to be, and the format is why: R8Unorm is not a core storage format,
so a compute path has to widen masks to four bytes per pixel — 768 MB
across eight layers of a 24 MP export, against 192 MB at one byte. A colour
attachment takes R8Unorm happily. The array slice comes from the attached
view, so no slot uniform exists to disagree with where the pass writes.

Region masks index a compacted label field rather than the watershed's raw
basin roots, because a root is a sparse index into pixel space and
indexing a per-region array by one would need a table the size of the
image. Changing a selection then costs a few kilobytes, not a re-upload.

Stored as region ids, not as pixels: diffable, mergeable per-field under
FR-NC-9, and cheap in a sidecar. The ids only mean anything alongside the
segmentation that produced them, so each layer carries that signature and
is treated as stale rather than applied when it does not match — a
confidently wrong mask being much worse than an absent one.

Seven device tests render actual frames and read them back. The unit tests
either side check halves that would both pass if the two agreed with each
other and were both wrong; a mask sampled with x and y swapped satisfies
them and fails these.
This commit is contained in:
2026-08-22 08:39:16 +02:00
parent 0da8271836
commit c6a846a1f9
9 changed files with 1853 additions and 1 deletions
+269
View File
@@ -0,0 +1,269 @@
//! Local adjustments, end to end on a device.
//!
//! The unit tests either side of this one check halves: `dr-pipeline` asserts
//! the generated WGSL says the right thing, and `dr-gpu`'s mask tests assert
//! an array of the right shape comes out. Neither would notice if the two
//! agreed with each other and both were wrong — a mask sampled with x and y
//! swapped satisfies both.
//!
//! So this renders a real frame and reads the pixels back: the masked region
//! must change, the rest must not, and the boundary must fall where the label
//! field says it does.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::{ops, EditGraph, Framing};
use dr_types::ColourSpace;
const SIZE: u32 = 32;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat mid-grey JPEG-path image, so any change is the adjustment's.
fn grey(ctx: &GpuContext) -> DemosaicedImage {
let data: Vec<u8> = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect();
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
}
/// Two regions: 0 is the left half, 1 the right.
fn split_field(ctx: &GpuContext) -> LabelField {
let labels: Vec<u32> = (0..SIZE * SIZE)
.map(|i| u32::from(i % SIZE >= SIZE / 2))
.collect();
LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload")
}
/// A layer brightening whatever it covers, by a lot, so it cannot be missed.
fn brighten(source: MaskSource) -> MaskLayer {
let mut layer = MaskLayer::new("m1", source);
layer.set_param("exposure", ParamId("exposure"), 2.0);
layer
}
fn luma_at(pixels: &[u8], x: u32, y: u32) -> u8 {
pixels[((y * SIZE + x) * 4) as usize]
}
/// Render `stack` over flat grey and hand back the RGBA8 result.
fn render(ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>) -> Vec<u8> {
let source = grey(ctx);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, field, SIZE, SIZE).expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
#[test]
fn a_region_mask_changes_only_the_regions_it_names() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let mut stack = MaskStack::new();
stack.push(brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
}));
let pixels = render(&ctx, &stack, Some(&field));
// Sampled well inside each half, clear of the feathered boundary.
let inside = luma_at(&pixels, 4, SIZE / 2);
let outside = luma_at(&pixels, SIZE - 5, SIZE / 2);
assert!(
inside > outside + 40,
"the masked half should be much brighter: {inside} vs {outside}"
);
assert!(
(120..=136).contains(&outside),
"the unmasked half must be untouched mid-grey, got {outside}"
);
}
/// The failure a swapped axis or an inverted comparison would produce, and
/// which the "inside is brighter" assertion alone would not catch.
#[test]
fn inverting_a_region_mask_swaps_which_half_moves() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let mut layer = brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
});
layer.invert = true;
let mut stack = MaskStack::new();
stack.push(layer);
let pixels = render(&ctx, &stack, Some(&field));
let left = luma_at(&pixels, 4, SIZE / 2);
let right = luma_at(&pixels, SIZE - 5, SIZE / 2);
assert!(
right > left + 40,
"inverted, the *other* half should brighten: left {left}, right {right}"
);
}
#[test]
fn opacity_scales_the_effect() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let source = MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
};
let mut full = MaskStack::new();
full.push(brighten(source.clone()));
let mut half = MaskStack::new();
let mut layer = brighten(source);
layer.opacity = 0.5;
half.push(layer);
let at_full = luma_at(&render(&ctx, &full, Some(&field)), 4, SIZE / 2);
let at_half = luma_at(&render(&ctx, &half, Some(&field)), 4, SIZE / 2);
let untouched = 128;
assert!(
at_half > untouched && at_half < at_full,
"half opacity should land between neutral and full: {untouched} < {at_half} < {at_full}"
);
}
#[test]
fn a_linear_gradient_ramps_across_the_frame() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(brighten(MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 1.0,
}));
let pixels = render(&ctx, &stack, None);
let left = luma_at(&pixels, 1, SIZE / 2);
let middle = luma_at(&pixels, SIZE / 2, SIZE / 2);
let right = luma_at(&pixels, SIZE - 2, SIZE / 2);
assert!(
left < middle && middle < right,
"a horizontal ramp should increase left to right: {left}, {middle}, {right}"
);
}
#[test]
fn a_radial_mask_is_strongest_at_its_centre() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(brighten(MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.3, 0.3),
angle: 0.0,
feather: 0.5,
}));
let pixels = render(&ctx, &stack, None);
let centre = luma_at(&pixels, SIZE / 2, SIZE / 2);
let corner = luma_at(&pixels, 1, 1);
assert!(
centre > corner + 40,
"the centre should carry the effect: {centre} vs corner {corner}"
);
assert!(
(120..=136).contains(&corner),
"outside the radius must be untouched, got {corner}"
);
}
/// Two layers must not read each other's slice.
#[test]
fn stacked_layers_use_their_own_masks() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let mut stack = MaskStack::new();
// Left half up.
stack.push(brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
}));
// Right half down.
let mut darken = MaskLayer::new("m2", MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![1],
});
darken.set_param("exposure", ParamId("exposure"), -2.0);
stack.push(darken);
let pixels = render(&ctx, &stack, Some(&field));
let left = luma_at(&pixels, 4, SIZE / 2);
let right = luma_at(&pixels, SIZE - 5, SIZE / 2);
assert!(left > 150, "left should have brightened, got {left}");
assert!(right < 100, "right should have darkened, got {right}");
}
/// A neutral edit must render identically whether or not masks are bound —
/// otherwise merely *having* the feature would alter every unedited image.
#[test]
fn an_empty_stack_renders_exactly_as_the_unmasked_path() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let plain = {
let source = grey(&ctx);
let mut adjust = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
adjust.export_pixels().expect("readback").0
};
let masked = render(&ctx, &MaskStack::new(), None);
assert_eq!(plain, masked, "an empty mask stack must be a no-op");
}