`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>
141 lines
5.1 KiB
Rust
141 lines
5.1 KiB
Rust
//! TRACES: FR-DEV-3
|
|
//! The tone curve's four curves, on a device.
|
|
//!
|
|
//! `dr-pipeline` asserts that the right WGSL is generated and `dr-gpu`'s other
|
|
//! tests assert that a shader runs; neither notices a fragment that says
|
|
//! exactly what it should and does not compile, or one that compiles and puts
|
|
//! the red curve's uniforms into the blue slot. So this renders flat grey
|
|
//! through each curve and looks at what came out.
|
|
//!
|
|
//! Flat grey because it makes every assertion a comparison between the three
|
|
//! components of one pixel: a curve that is meant to be chromatic must move
|
|
//! them apart, and one that is meant to be tonal must not.
|
|
|
|
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
|
use dr_pipeline::ops::curve::{self, Axis, Channel};
|
|
use dr_pipeline::EditGraph;
|
|
|
|
const SIZE: u32 = 8;
|
|
|
|
fn ctx() -> Option<GpuContext> {
|
|
pollster::block_on(GpuContext::new_headless()).ok()
|
|
}
|
|
|
|
/// The centre pixel's red, green and blue, after `graph` has run over flat
|
|
/// mid-grey.
|
|
fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) {
|
|
let data: Vec<u8> = (0..SIZE * SIZE)
|
|
.flat_map(|_| [128, 128, 128, 255])
|
|
.collect();
|
|
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
|
|
|
|
// Composed the way the display path composes it. A curve that generates
|
|
// invalid WGSL fails at `render` below, which is the point of running this
|
|
// on a device at all.
|
|
let shader = graph.compose();
|
|
|
|
let mut adjust = AdjustPass::new(ctx);
|
|
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
|
let pixels = adjust.export_pixels().expect("readback").0;
|
|
|
|
let at = ((SIZE / 2 * SIZE + SIZE / 2) * 4) as usize;
|
|
(pixels[at], pixels[at + 1], pixels[at + 2])
|
|
}
|
|
|
|
/// A curve with its mid-point lifted — the simplest edit that is unmistakably
|
|
/// an edit.
|
|
fn lifted(channel: Channel) -> EditGraph {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), 0.75);
|
|
graph
|
|
}
|
|
|
|
#[test]
|
|
fn the_master_curve_lifts_every_component_together() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
|
|
let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain());
|
|
let (r, g, b) = rendered(&ctx, &lifted(Channel::Master));
|
|
|
|
assert!(
|
|
r > r0,
|
|
"the master curve did not lift the image: {r} vs {r0}"
|
|
);
|
|
// Grey in, grey out: the master curve is applied as a ratio over
|
|
// luminance, so it changes tone and not hue. A tolerance of one code
|
|
// value, because the components travel through the ratio separately and
|
|
// the result is quantised to eight bits.
|
|
assert!(
|
|
r.abs_diff(g) <= 1 && g.abs_diff(b) <= 1,
|
|
"the master curve tinted a neutral pixel: {r},{g},{b}"
|
|
);
|
|
assert_eq!((g0, b0), (r0, r0), "the unedited image is neutral");
|
|
}
|
|
|
|
#[test]
|
|
fn a_channel_curve_lifts_only_its_own_component() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
|
|
let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain());
|
|
for (channel, name) in [
|
|
(Channel::Red, "red"),
|
|
(Channel::Green, "green"),
|
|
(Channel::Blue, "blue"),
|
|
] {
|
|
let (r, g, b) = rendered(&ctx, &lifted(channel));
|
|
// The component the curve names moves; the other two stay exactly
|
|
// where they were. This is what catches a fragment whose uniforms are
|
|
// wired to the wrong curve — it would still lift *something*.
|
|
let (moved, still) = match channel {
|
|
Channel::Red => (r > r0, g == g0 && b == b0),
|
|
Channel::Green => (g > g0, r == r0 && b == b0),
|
|
_ => (b > b0, r == r0 && g == g0),
|
|
};
|
|
assert!(moved, "the {name} curve changed nothing: {r},{g},{b}");
|
|
assert!(
|
|
still,
|
|
"the {name} curve moved a component that was not its own: \
|
|
{r},{g},{b} from {r0},{g0},{b0}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_master_and_the_channels_compose_in_one_pass() {
|
|
// All four curves at once: the case where the generated fragment is
|
|
// longest, every helper is present, and forty uniforms are in the block.
|
|
// Mostly a compile check, which is why the assertion is only that the
|
|
// result is a colour and not the one we started with.
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.set_param(curve::ID, curve::P1_Y, 0.15);
|
|
graph.set_param(curve::ID, curve::P3_Y, 0.85);
|
|
for (channel, y) in [
|
|
(Channel::Red, 0.55),
|
|
(Channel::Green, 0.5),
|
|
(Channel::Blue, 0.62),
|
|
] {
|
|
graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), y);
|
|
}
|
|
|
|
let (r0, _, _) = rendered(&ctx, &EditGraph::default_chain());
|
|
let (r, g, b) = rendered(&ctx, &graph);
|
|
assert!(
|
|
(r, g, b) != (r0, r0, r0),
|
|
"four active curves left the image untouched"
|
|
);
|
|
// Red and blue were pushed apart from green, which is the chromatic half
|
|
// doing its work on top of the tonal one.
|
|
assert!(b > g, "blue was lifted above green: {r},{g},{b}");
|
|
}
|