Files
DarkRoom/core/dr-gpu/examples/rangesweep.rs
T
dtourolle 4c217c9be6
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m52s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 56s
Build and test / Layer separation (push) Successful in 37s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 54s
Build and test / Android (aarch64) (push) Successful in 26m29s
Show what a control does to a photograph, one parameter at a time
A node that has just been declared can be read, reasoned about and tested, and
none of that answers the question a photographer asks first: what does moving
this do to the picture. Colour grading, dehaze and the range masks were all
argued into the tree on their behaviour and none of them had been *looked* at.

So two diagnostics. `sweep` walks one operation from its minimum to its maximum
and writes a frame per step; `rangesweep` does the same for a mask band, which
is not an ordinary parameter — it lives on a layer, is rasterised by its own
pass, and only becomes visible through whatever adjustment the layer carries,
so it gets two stops down to make the selection legible.

`sweep` names no operation. The id arrives as a string and the parameters and
their ranges come from the graph's own capabilities, so a node declared
yesterday sweeps on the same terms as one that shipped a year ago — the
property `ops/README.md` promises, used rather than asserted.

Three things it learned the hard way and now records. It renders through
`render_detailed` unconditionally, because the fused path refuses a shader
composed with a detail stage rather than rendering it wrongly, and that call
falls through when there is no such stage. It takes `SWEEP_HOLD`, because a
parameter grouped under one widget is not meaningful alone: a hue with no
strength behind it renders the same frame every time, which reads as a broken
node rather than a correctly declared neutral. And it bounds the output, since
a 25 MP frame is a 75 MB PPM and a sweep is hundreds of them.

Both read a rendered file as readily as a raw one, so a JPEG can stand in where
no raw is to hand — on the terms `from_rgba8` documents, with the controls
still working and their neutral being what the camera left rather than what the
sensor recorded.
2026-09-07 20:01:08 +02:00

160 lines
6.3 KiB
Rust

//! Sweep a range mask's band and show what it selects.
//!
//! A diagnostic for FR-DEV-10. The other sweep example drives global
//! parameters through `EditGraph`; a band is not one of those — it lives on a
//! `MaskLayer`, is rasterised by its own pass, and only becomes visible
//! through whatever adjustment the layer carries.
//!
//! So the layer here is given a deliberately blunt adjustment — two stops down
//! — because the question this answers is *what does the band select*, not
//! *what would a photographer do with it*. A subtle edit would show a subtle
//! selection and prove nothing.
//!
//! ```sh
//! cargo run -p dr-gpu --release --example rangesweep -- IMG.CR2 out/ luminance 21
//! cargo run -p dr-gpu --release --example rangesweep -- IMG.CR2 out/ hue 21
//! ```
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass};
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::ops;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{Framing, ParamId};
use dr_types::ColourSpace;
fn main() {
env_logger::init();
let mut args = std::env::args().skip(1);
let (Some(input), Some(out_dir), Some(mode)) = (args.next(), args.next(), args.next()) else {
eprintln!("usage: rangesweep <file.cr2> <out_dir> <luminance|hue|width> [steps]");
std::process::exit(2);
};
let steps: usize = args
.next()
.and_then(|s| s.parse().ok())
.filter(|n| *n >= 2)
.unwrap_or(21);
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
let image = load(&ctx, &input);
std::fs::create_dir_all(&out_dir).expect("create out dir");
let (full_w, full_h) = image.size();
let longest = std::env::var("SWEEP_MAX_PX")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1000);
let scale = (longest as f32 / full_w.max(full_h) as f32).min(1.0);
let w = ((full_w as f32 * scale) as u32).max(1);
let h = ((full_h as f32 * scale) as u32).max(1);
println!("rendering {w} x {h}");
let mut masks = MaskPass::new(&ctx).expect("mask pass");
let mut adjust = AdjustPass::new(&ctx);
for i in 0..steps {
let t = i as f32 / (steps - 1) as f32;
// What moves, and what the caption should say about it.
let (source, label) = match mode.as_str() {
// A half-wide band walking from black to white, so the selection
// sweeps across the tonal scale rather than merely widening.
"luminance" => {
let centre = t;
let half = 0.15;
(
MaskSource::luminance_range(centre - half, centre + half, 0.10),
format!("luminance band centred {:.2}", centre),
)
}
// The hue circle, at a fixed arc and a chroma floor that keeps the
// near-neutral parts of the picture out of it.
"hue" => (
MaskSource::colour_range(t, 0.08, 0.05, 1.0, 0.10),
format!("hue {:.0} deg", t * 360.0),
),
// The arc opening from nothing to everything, at a fixed hue.
"width" => (
MaskSource::colour_range(0.08, t * 0.5, 0.05, 1.0, 0.10),
format!("hue width {:.0} deg", t * 0.5 * 360.0),
),
other => {
eprintln!("unknown mode `{other}`");
std::process::exit(2);
}
};
let mut layer = MaskLayer::new("sweep", source);
// Two stops down: blunt on purpose. See the module docs.
layer.set_param("exposure", ParamId("exposure"), -2.0);
let mut stack = MaskStack::new();
stack.push(layer);
// No label field and no subject masks: a range needs neither. It is a
// weighting over the picture's own values, so the only input it wants
// is the picture, which is the `Some(&image)` below.
let array = masks
.render(&stack, None, None, Some(&image), w, h)
.expect("rasterise");
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&stack,
&SpotSet::new(),
&[],
);
adjust
.render_masked(&image, &shader, w, h, Some(array))
.expect("render");
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
let path = format!("{out_dir}/{mode}_{i:03}.ppm");
write_ppm(&path, &pixels, pw, ph);
std::fs::write(format!("{out_dir}/{mode}_{i:03}.txt"), format!("{label}\n"))
.expect("write label");
println!("{mode}[{i}] {label}");
}
}
fn write_ppm(path: &str, rgba: &[u8], w: u32, h: u32) {
use std::io::Write;
let mut out = Vec::with_capacity((w * h * 3) as usize + 32);
out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
for px in rgba.chunks_exact(4) {
out.extend_from_slice(&px[..3]);
}
std::fs::File::create(path)
.expect("create output")
.write_all(&out)
.expect("write output");
}
/// Load either a RAW or an already-rendered image.
///
/// A JPEG takes the path `DemosaicedImage::from_rgba8` documents: no CFA to
/// interpolate, identity colour matrix, neutral white balance, and the shader
/// linearises the gamma-encoded pixels. The controls all still work; their
/// neutral is "as the camera left it" rather than "as the sensor recorded it",
/// which is worth knowing when reading a sweep made from one.
fn load(ctx: &GpuContext, path: &str) -> DemosaicedImage {
let bytes = std::fs::read(path).expect("read file");
match dr_decode::probe(&bytes) {
Some(dr_types::Format::Jpeg) => {
let p = dr_decode::decode_jpeg(&bytes).expect("decode jpeg");
println!("loaded {} x {} (rendered, not raw)", p.width, p.height);
DemosaicedImage::from_rgba8(ctx, &p.rgba, p.width, p.height).expect("upload")
}
_ => {
let raw = dr_decode::decode(&bytes).expect("decode raw");
println!("loaded {} x {} (raw)", raw.crop.width, raw.crop.height);
let demosaic = Demosaicer::new(ctx).expect("demosaicer");
demosaic.run(&raw).expect("demosaic")
}
}
}