Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts: # core/dr-pipeline/tests/mask_sidecar.rs
This commit is contained in:
@@ -43,7 +43,9 @@ struct MaskParams {
|
||||
// `falloff_code` on the Rust side.
|
||||
falloff: u32,
|
||||
|
||||
// Geometry, in normalised output coordinates. Meaning depends on `mode`.
|
||||
// Geometry. Meaning depends on `mode`. The centre is in normalised 0..1
|
||||
// coordinates; every distance below it is in the isotropic frame units
|
||||
// `frame_delta` establishes.
|
||||
centre: vec2<f32>,
|
||||
// Linear: (cos, sin) of the ramp direction. Radial: semi-axes.
|
||||
axis: vec2<f32>,
|
||||
@@ -128,9 +130,23 @@ fn region_mask(px: vec2<i32>) -> f32 {
|
||||
return total / n;
|
||||
}
|
||||
|
||||
// Offset from a gradient's centre, in the frame's own **isotropic** units:
|
||||
// y spans 0..1 and x spans 0..aspect, so a step of the same length means the
|
||||
// same distance whichever way it points.
|
||||
//
|
||||
// Without this the geometry lives in raw 0..1, where one axis is compressed
|
||||
// against the other by the aspect ratio — so a 45° ramp is not at 45° on
|
||||
// anything but a square frame, and a radial with equal radii draws an ellipse.
|
||||
// Both faults are invisible in the stored numbers and obvious the moment a
|
||||
// handle is dragged on a photograph, which is what this exists for.
|
||||
fn frame_delta(uv: vec2<f32>) -> vec2<f32> {
|
||||
let aspect = vec2<f32>(f32(p.width) / f32(max(p.height, 1u)), 1.0);
|
||||
return (uv - p.centre) * aspect;
|
||||
}
|
||||
|
||||
fn linear_mask(uv: vec2<f32>) -> f32 {
|
||||
// Signed distance along the ramp direction, from the centre.
|
||||
let d = dot(uv - p.centre, p.axis);
|
||||
let d = dot(frame_delta(uv), p.axis);
|
||||
if (p.softness <= 0.0) {
|
||||
return select(0.0, 1.0, d >= 0.0);
|
||||
}
|
||||
@@ -140,7 +156,7 @@ fn linear_mask(uv: vec2<f32>) -> f32 {
|
||||
fn radial_mask(uv: vec2<f32>) -> f32 {
|
||||
let ca = cos(-p.angle);
|
||||
let sa = sin(-p.angle);
|
||||
let d = uv - p.centre;
|
||||
let d = frame_delta(uv);
|
||||
// Into the ellipse's own frame, then normalised by its semi-axes so the
|
||||
// problem becomes a unit circle.
|
||||
let local = vec2<f32>(d.x * ca - d.y * sa, d.x * sa + d.y * ca);
|
||||
|
||||
@@ -189,3 +189,135 @@ fn the_mask_covers_the_same_fraction_at_every_size() {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user