Show what a control does to a photograph, one parameter at a time
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
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
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.
This commit is contained in:
@@ -0,0 +1,159 @@
|
|||||||
|
//! 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! Sweep one operation's parameters and write a frame per step.
|
||||||
|
//!
|
||||||
|
//! A diagnostic, not part of the product: it exists to show what a control
|
||||||
|
//! actually does to a photograph, one parameter at a time, so a new node can
|
||||||
|
//! be looked at rather than reasoned about.
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run -p dr-gpu --release --example sweep -- IMG.CR2 out/ colour_grading 21
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! It names no operation. The op id arrives as a string, the parameters and
|
||||||
|
//! their ranges come from the graph's own capabilities, and a node declared
|
||||||
|
//! yesterday sweeps on the same terms as one that shipped a year ago — which
|
||||||
|
//! is the property `ops/README.md` promises and the reason this is one example
|
||||||
|
//! rather than one per node.
|
||||||
|
//!
|
||||||
|
//! PPM out, like `develop.rs`, so it needs no encoder dependency; the caller
|
||||||
|
//! turns them into whatever it wants.
|
||||||
|
|
||||||
|
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
||||||
|
use dr_pipeline::{Affects, EditGraph, OpId, ParamId, ParamKind};
|
||||||
|
use dr_types::ColourSpace;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let (Some(input), Some(out_dir), Some(op)) = (args.next(), args.next(), args.next()) else {
|
||||||
|
eprintln!("usage: sweep <file.cr2> <out_dir> <op_id> [steps]");
|
||||||
|
eprintln!(" sweep <file.cr2> <out_dir> --list");
|
||||||
|
std::process::exit(2);
|
||||||
|
};
|
||||||
|
let steps: usize = args
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.filter(|n| *n >= 2)
|
||||||
|
.unwrap_or(21);
|
||||||
|
// Sweep one named parameter rather than all of them.
|
||||||
|
let only: Option<String> = args.next();
|
||||||
|
|
||||||
|
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
|
||||||
|
let image = load(&ctx, &input);
|
||||||
|
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
|
||||||
|
// `--list` prints every op and parameter with its range, which is how the
|
||||||
|
// caller learns what there is to sweep without this file holding a list
|
||||||
|
// that would go stale.
|
||||||
|
if op == "--list" {
|
||||||
|
for cap in graph.capabilities() {
|
||||||
|
println!("{}", cap.id.0);
|
||||||
|
for p in &cap.params {
|
||||||
|
match p.kind {
|
||||||
|
ParamKind::Scalar { min, max, .. } => {
|
||||||
|
println!(" {:<20} {min} .. {max} default {}", p.id.0, p.default)
|
||||||
|
}
|
||||||
|
ParamKind::Bool => println!(" {:<20} bool", p.id.0),
|
||||||
|
ParamKind::Enum { ref variants } => {
|
||||||
|
println!(" {:<20} enum, {} variants", p.id.0, variants.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&out_dir).expect("create out dir");
|
||||||
|
|
||||||
|
// `OpId`/`ParamId` hold `&'static str`, and an argument is not static.
|
||||||
|
// Leaking is right rather than expedient here: the ids live as long as the
|
||||||
|
// graph does, and this process exits immediately after.
|
||||||
|
let op_id = OpId(Box::leak(op.clone().into_boxed_str()));
|
||||||
|
|
||||||
|
let cap = graph
|
||||||
|
.capabilities()
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.id == op_id)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
eprintln!("no operation `{op}` — try --list");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bounded output rather than full sensor resolution. This is the proxy
|
||||||
|
// path FR-DSP-1 already renders through, so it is the same code the
|
||||||
|
// develop view uses — and a 25 MP frame would be a 75 MB PPM, times
|
||||||
|
// several hundred frames in one sweep.
|
||||||
|
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(1100);
|
||||||
|
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} (from {full_w} x {full_h})");
|
||||||
|
|
||||||
|
let mut adjust = AdjustPass::new(&ctx);
|
||||||
|
|
||||||
|
// The reference frame: every parameter at its default. Written once so a
|
||||||
|
// viewer can see what the sweep is departing from.
|
||||||
|
render_to(&mut adjust, &image, &graph, w, h, &out_dir, "neutral", 0, 0.0);
|
||||||
|
|
||||||
|
// Parameters held away from their default for the duration, as
|
||||||
|
// `SWEEP_HOLD=shadow_strength=70,midtone_hue=210`.
|
||||||
|
//
|
||||||
|
// Needed because a parameter is not always meaningful alone. Where a
|
||||||
|
// `presentation:` block groups several into one conceptual control — a
|
||||||
|
// hue and the strength behind it — sweeping one with the other at its
|
||||||
|
// default renders the same frame every time, which looks like a broken
|
||||||
|
// node rather than a correctly declared neutral.
|
||||||
|
let hold = std::env::var("SWEEP_HOLD").unwrap_or_default();
|
||||||
|
for clause in hold.split(',').filter(|c| !c.trim().is_empty()) {
|
||||||
|
let Some((name, value)) = clause.split_once('=') else {
|
||||||
|
eprintln!("SWEEP_HOLD wants name=value, got `{clause}`");
|
||||||
|
std::process::exit(2);
|
||||||
|
};
|
||||||
|
let value: f32 = value.trim().parse().expect("hold value");
|
||||||
|
let name = Box::leak(name.trim().to_string().into_boxed_str());
|
||||||
|
graph.set_param(op_id, ParamId(name), value);
|
||||||
|
println!("holding {name} = {value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
for p in &cap.params {
|
||||||
|
if only.as_deref().is_some_and(|o| o != p.id.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ParamKind::Scalar { min, max, .. } = p.kind else {
|
||||||
|
eprintln!("skipping {} — only scalars sweep meaningfully", p.id.0);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let param_id = ParamId(Box::leak(p.id.0.to_string().into_boxed_str()));
|
||||||
|
|
||||||
|
for i in 0..steps {
|
||||||
|
let t = i as f32 / (steps - 1) as f32;
|
||||||
|
let value = min + (max - min) * t;
|
||||||
|
graph.set_param(op_id, param_id, value);
|
||||||
|
render_to(
|
||||||
|
&mut adjust,
|
||||||
|
&image,
|
||||||
|
&graph,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
&out_dir,
|
||||||
|
p.id.0,
|
||||||
|
i,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Back to default before the next parameter, so each sweep is of one
|
||||||
|
// control rather than of everything tried so far.
|
||||||
|
graph.set_param(op_id, param_id, p.default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn render_to(
|
||||||
|
adjust: &mut AdjustPass,
|
||||||
|
image: &dr_gpu::DemosaicedImage,
|
||||||
|
graph: &EditGraph,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
dir: &str,
|
||||||
|
name: &str,
|
||||||
|
index: usize,
|
||||||
|
value: f32,
|
||||||
|
) {
|
||||||
|
// The detail path, always. A neighbourhood node — dehaze, clarity,
|
||||||
|
// sharpening — runs as its own dispatch after the fused pass, and the
|
||||||
|
// fused path refuses a shader composed with one rather than rendering it
|
||||||
|
// wrongly. `render_detailed` falls through to the plain path when the
|
||||||
|
// chain has no detail stage, so this one call serves both kinds of node
|
||||||
|
// and the example never has to know which it was handed.
|
||||||
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
|
let scale = graph.render_scale(image.size(), (w, h));
|
||||||
|
let detail = graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||||
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
|
adjust
|
||||||
|
.render_detailed(image, &shader, w, h, None, &detail, key)
|
||||||
|
.expect("adjust");
|
||||||
|
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
|
||||||
|
|
||||||
|
let path = format!("{dir}/{name}_{index:03}.ppm");
|
||||||
|
write_ppm(&path, &pixels, pw, ph);
|
||||||
|
|
||||||
|
// The value goes beside the frame rather than into the filename: a caption
|
||||||
|
// wants "-37.5", and a filename that carried it would need escaping and
|
||||||
|
// would sort wrongly.
|
||||||
|
let meta = format!("{dir}/{name}_{index:03}.txt");
|
||||||
|
std::fs::write(meta, format!("{name} {value:.4}\n")).expect("write value");
|
||||||
|
println!("{name}[{index}] = {value:.4}");
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-60
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user