Files
DarkRoom/core/dr-gpu/tests/masked_outputs.rs
T
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:16:34 +02:00

326 lines
11 KiB
Rust

//! Every path that produces pixels must apply the masks.
//!
//! The display, the export and the thumbnail run the same generated shader,
//! and that shader always emits a block per active mask layer. Bind the empty
//! placeholder instead of the real array and every one of those blocks
//! multiplies by zero: the local adjustments are simply absent, with no error
//! and no warning.
//!
//! That is what happened — exports and thumbnails both took the unmasked
//! path — and it is invisible from inside either one. The only way to see it
//! is to render the same edit twice and compare, which is what this does.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass, SubjectMasks};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::{ops, Framing};
use dr_segment::Shaped;
use dr_types::ColourSpace;
const PROXY: u32 = 24;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
fn grey(ctx: &GpuContext, size: u32) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|_| [128, 128, 128, 255])
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
/// Coverage over the left half, as a detection would produce.
fn left_half() -> Vec<u8> {
(0..PROXY * PROXY)
.map(|i| if i % PROXY < PROXY / 2 { 255 } else { 0 })
.collect()
}
fn brightening_stack() -> MaskStack {
let mut stack = MaskStack::new();
let mut layer = MaskLayer::new(
"m1",
MaskSource::Subject {
signature: 1,
index: 0,
class: "person".into(),
score: 0.9,
},
);
layer.set_param("exposure", ParamId("exposure"), 2.0);
layer.feather = 0.0;
stack.push(layer);
stack
}
/// Render `stack` at `out` pixels, exactly as the session's shared helper
/// does: fields, array, then a masked render.
fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
let source = grey(ctx, 64);
let coverage = left_half();
let fields: Vec<Vec<f32>> = stack
.active()
.map(|_| {
Shaped::build(
&coverage,
PROXY as usize,
PROXY as usize,
128,
dr_segment::Morphology::None,
0.0,
)
.distance
})
.collect();
let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect();
let subjects = SubjectMasks::upload(ctx, &refs, PROXY, PROXY).expect("upload");
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render(stack, None, Some(&subjects), PROXY, PROXY)
.expect("rasterise");
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, out, out, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
/// The same edit rendered *without* the array bound — what export and
/// thumbnail were doing.
fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
let source = grey(ctx, 64);
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, out, out).expect("render");
adjust.export_pixels().expect("readback").0
}
fn luma(pixels: &[u8], size: u32, x: u32, y: u32) -> u8 {
pixels[((y * size + x) * 4) as usize]
}
/// The fault itself, stated as a test: the two paths must not agree.
///
/// If binding the array made no difference, the masks would not be reaching
/// the shader at all — which is precisely the bug this file exists for.
#[test]
fn binding_the_masks_changes_the_result() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = brightening_stack();
const OUT: u32 = 64;
let masked = render_at(&ctx, &stack, OUT);
let unmasked = render_unmasked(&ctx, &stack, OUT);
let left_masked = luma(&masked, OUT, 8, 32);
let left_unmasked = luma(&unmasked, OUT, 8, 32);
assert!(
left_masked > left_unmasked + 40,
"with the array bound the masked half must brighten: {left_masked} \
against {left_unmasked}"
);
assert!(
(120..=136).contains(&left_unmasked),
"and without it the layer contributes nothing at all, silently: \
{left_unmasked}"
);
}
/// A thumbnail is the same edit at a small size, so it must carry the same
/// local adjustments. This is the reported bug.
#[test]
fn a_thumbnail_sized_render_still_carries_its_masks() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = brightening_stack();
for out in [16u32, 32, 64, 128] {
let pixels = render_at(&ctx, &stack, out);
let inside = luma(&pixels, out, out / 8, out / 2);
let outside = luma(&pixels, out, out - out / 8 - 1, out / 2);
assert!(
inside > outside + 40,
"at {out}px the masked half should be brighter: {inside} against \
{outside}"
);
}
}
/// One mask array, every output size. The array is rasterised in source space
/// and sampled through the framing map, so a thumbnail and a full-size export
/// must reach the same *proportion* of the frame — not merely both be
/// non-empty.
#[test]
fn the_mask_covers_the_same_fraction_at_every_size() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = brightening_stack();
let fraction = |out: u32| {
let pixels = render_at(&ctx, &stack, out);
let lit = (0..out * out)
.filter(|i| pixels[(*i as usize) * 4] > 150)
.count();
lit as f32 / (out * out) as f32
};
let small = fraction(32);
let large = fraction(128);
assert!(
(small - large).abs() < 0.05,
"the same edit should mask the same share of the frame at any size: \
{small:.3} at 32px against {large:.3} at 128px"
);
assert!(
(0.4..0.6).contains(&small),
"and that share is the left half: {small:.3}"
);
}
// ---------------------------------------------------------------------------
// Gradient geometry
//
// The mask is rasterised over the source frame, whose two axes are not the
// same length. A gradient measured in raw 0..1 fractions therefore means a
// different distance horizontally than vertically — so a circle comes out an
// ellipse and an angle is not the angle asked for. Neither shows in the stored
// numbers, and both are what a photographer is looking straight at while
// dragging a handle.
// ---------------------------------------------------------------------------
/// A flat grey frame at an arbitrary shape, brightened wherever `stack` covers.
fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u8> {
let data: Vec<u8> = (0..w * h).flat_map(|_| [128u8, 128, 128, 255]).collect();
let source = DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload");
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, None, None, w, h).expect("rasterise");
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, w, h, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
fn brightened(pixels: &[u8], w: u32, x: u32, y: u32) -> bool {
pixels[((y * w + x) * 4) as usize] > 160
}
fn gradient(source: MaskSource) -> MaskStack {
let mut stack = MaskStack::new();
let mut layer = MaskLayer::new("g1", source);
layer.set_param("exposure", ParamId("exposure"), 2.0);
stack.push(layer);
stack
}
/// A radial with equal radii must be round on the screen, not on the numbers.
#[test]
fn a_radial_with_equal_radii_is_a_circle_on_a_wide_frame() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
// 3:2. On a square frame this test cannot fail, which is why it is not one.
const W: u32 = 96;
const H: u32 = 64;
// 0.3 of the frame's height. In pixels that is 19 either way — the x axis
// spans 0..1.5 in the same units, so the fraction is smaller and the
// distance is the same.
let pixels = render_gradient(
&ctx,
&gradient(MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.3, 0.3),
angle: 0.0,
// Hard, so "covered" is a question with an answer rather than a
// ramp to pick a threshold out of.
feather: 0.0,
}),
W,
H,
);
let (cx, cy) = (W / 2, H / 2);
assert!(brightened(&pixels, W, cx, cy), "the centre must be covered");
for (dx, dy, what) in [(15u32, 0u32, "right"), (0, 15, "down")] {
assert!(
brightened(&pixels, W, cx + dx, cy + dy),
"15px {what} of centre is inside a 19px radius"
);
}
for (dx, dy, what) in [(24u32, 0u32, "right"), (0, 24, "down")] {
assert!(
!brightened(&pixels, W, cx + dx, cy + dy),
"24px {what} of centre is outside it — before the aspect \
correction the horizontal reach was 28px and this passed only \
downwards"
);
}
}
/// And a ramp at 45° must be at 45° where the photographer sees it.
#[test]
fn a_diagonal_ramp_runs_at_the_angle_it_was_given() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
const W: u32 = 96;
const H: u32 = 64;
let pixels = render_gradient(
&ctx,
&gradient(MaskSource::Linear {
centre: (0.5, 0.5),
angle: std::f32::consts::FRAC_PI_4,
width: 0.0,
}),
W,
H,
);
// Coverage increases along (cos 45°, sin 45°), so the half-way line runs
// from lower-left to upper-right through the centre: equal steps right and
// down stay on the covered side, and the two sides of it disagree.
let (cx, cy) = (W / 2, H / 2);
assert!(
brightened(&pixels, W, cx + 16, cy + 16),
"down and to the right of the line is inside"
);
assert!(
!brightened(&pixels, W, cx - 16, cy - 16),
"up and to the left of it is outside"
);
// The diagonal itself, sampled a few pixels either side. Without the
// aspect correction the line comes out at 34 degrees and both of these
// land on the same side of it.
assert!(
brightened(&pixels, W, cx + 18, cy - 14),
"just below the 45 degree line is inside"
);
assert!(
!brightened(&pixels, W, cx + 14, cy - 18),
"just above it is not"
);
}