Add the develop pipeline: demosaic and seven raw adjustments
Decode through display, on the GPU: black/white normalisation, Bayer demosaic, camera colour transform, and the first seven adjustment operations — white balance, exposure, highlights/shadows, blacks/whites, brilliance, vibrance, saturation. Composable shaders. Each operation contributes a WGSL fragment rather than owning a pass, and dr-pipeline fuses the *active* ones into a single compute shader. One texture read and one write per frame regardless of how many adjustments are in play, while the operations stay independent in Rust — adding one is a new file, with no central shader to edit. An operation at neutral settings contributes no code, no uniform and no branch. Uniforms are prefixed per operation so two may both declare `amount`; helpers dedupe by name from a single source of truth. Pipelines cache on a structure hash covering the op-set and its order but not the values, so dragging a slider uploads uniforms and reuses the compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one pipeline compiled across ten slider positions. The UI is generated, not written. EditGraph::capabilities() reports parameters with their kinds, ranges, defaults and current values; the panel builds one control per entry chosen by ParamKind. No file in ui/ names an operation, and dr-pipeline has no wgpu dependency, so codegen is testable without a device (ARCH §6.5a). Three defects found against real files, each silent: - rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer populated. The live matrices are in `color_matrix`, keyed by illuminant. Reading the old field yields no colour transform at all. - `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it divides each of four rows by its own sum, and the unused fourth (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it. `wb_coeffs[3]` is NaN for the same reason and is normalised at decode. - As-shot white balance reached the uniform block but no shader read it, so the first render of a real CR2 came out violently green. Green photosites collect roughly twice the signal of red and blue. Now applied unconditionally before any operation, with tests on ordering. Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves visible zippering on any high-contrast edge at 1:1. Two of the four packed CFA constants were wrong on the first attempt, so all four layouts are asserted to reconstruct the same colour. Crop origins at odd coordinates re-phase the pattern; without that, red and blue swap. X-Trans reports GpuError::UnsupportedCfa rather than approximating with the Bayer path, which would look like a corrupt file. 206 tests, including GPU tests proving every operation and the full seven-operation chain generate compilable WGSL. Known gaps: the display path still reads back to the CPU each frame, which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the `readback` feature and waits on spike S1 wiring Slint's texture import. Curve shapes are a first draft and want tuning against real photographs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
//! The edit graph — an ordered set of operations (ARCH §3.4).
|
||||
//!
|
||||
//! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any
|
||||
//! moment on Android (ARCH §6.10), and recovery is only tractable because
|
||||
//! everything needed to re-render lives here rather than in GPU memory.
|
||||
//!
|
||||
//! Order is data, not code: operations run in the sequence this holds them,
|
||||
//! so reordering the pipeline needs no code change.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind};
|
||||
use crate::operation::{compose, ComposedShader, Operation};
|
||||
use crate::ops;
|
||||
|
||||
/// TRACES: FR-DEV-3a
|
||||
/// What one operation offers, as plain data.
|
||||
///
|
||||
/// Deliberately owned rather than borrowed, and free of any trait objects:
|
||||
/// the UI receives a snapshot it can hold across a frame without borrowing
|
||||
/// the graph, and nothing in it hints at how the operation is implemented.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OpCapability {
|
||||
pub id: OpId,
|
||||
/// A key for the UI's own catalogue. Never a display string — resolving
|
||||
/// it needs a localiser, which `core/` must not depend on.
|
||||
pub label: LocalizedKey,
|
||||
/// Whether this operation currently alters the image. A UI may use it to
|
||||
/// mark a section as modified, or to offer a per-operation reset.
|
||||
pub active: bool,
|
||||
pub params: Vec<ParamCapability>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a | FR-DEV-3b
|
||||
/// What one parameter offers.
|
||||
///
|
||||
/// [`Self::kind`] is what selects the control: the UI maps each `ParamKind`
|
||||
/// to a widget appropriate to the current input modality (ARCH §4.3), and
|
||||
/// never switches on the parameter's identity.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ParamCapability {
|
||||
pub id: ParamId,
|
||||
pub label: LocalizedKey,
|
||||
pub kind: ParamKind,
|
||||
pub default: f32,
|
||||
/// The current setting, so the control opens where the edit actually is.
|
||||
pub value: f32,
|
||||
}
|
||||
|
||||
impl ParamCapability {
|
||||
/// Whether this parameter is away from its default.
|
||||
pub fn is_modified(&self) -> bool {
|
||||
self.value != self.default
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordered pipeline of operations.
|
||||
pub struct EditGraph {
|
||||
ops: Vec<Box<dyn Operation>>,
|
||||
}
|
||||
|
||||
impl EditGraph {
|
||||
/// The default develop chain, in pipeline order (ARCH §5.2).
|
||||
///
|
||||
/// Order is not arbitrary. White balance and exposure come first because
|
||||
/// they are corrections to how the scene was captured, and the tonal
|
||||
/// operations that follow should act on a correctly exposed image.
|
||||
/// Colour comes last, so vibrance responds to the tones the user has
|
||||
/// actually settled on rather than the ones they started with.
|
||||
pub fn default_chain() -> Self {
|
||||
Self {
|
||||
ops: vec![
|
||||
Box::new(ops::WhiteBalance::new()),
|
||||
Box::new(ops::Exposure::new()),
|
||||
Box::new(ops::HighlightsShadows::new()),
|
||||
Box::new(ops::BlacksWhites::new()),
|
||||
Box::new(ops::Brilliance::new()),
|
||||
Box::new(ops::Vibrance::new()),
|
||||
Box::new(ops::Saturation::new()),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Descriptors for every operation, in order. Drives panel generation
|
||||
/// (FR-DEV-3a).
|
||||
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
|
||||
self.ops.iter().map(|o| o.descriptor()).collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a | FR-DEV-3c
|
||||
/// Everything a UI needs to build its controls.
|
||||
///
|
||||
/// **This is the only thing the UI should read.** It must not know that
|
||||
/// exposure exists, that saturation is implemented with a mix, or that
|
||||
/// any of this becomes a shader — it walks this list and instantiates a
|
||||
/// control per entry according to the [`ParamKind`]. A new operation
|
||||
/// therefore appears in the interface with no UI change at all
|
||||
/// (FR-DEV-3c), and an operation removed from the chain disappears from
|
||||
/// it just as automatically.
|
||||
///
|
||||
/// Current values are included so the UI has no separate initialisation
|
||||
/// step, and so reopening an edited image shows where the sliders
|
||||
/// actually are.
|
||||
pub fn capabilities(&self) -> Vec<OpCapability> {
|
||||
self.ops
|
||||
.iter()
|
||||
.map(|op| {
|
||||
let desc = op.descriptor();
|
||||
OpCapability {
|
||||
id: desc.id,
|
||||
label: desc.label,
|
||||
active: op.is_active(),
|
||||
params: desc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamCapability {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
kind: p.kind.clone(),
|
||||
default: p.default,
|
||||
value: op.param(p.id),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Set a parameter, clamping to the descriptor's declared range.
|
||||
///
|
||||
/// Clamping here rather than in each operation means an operation never
|
||||
/// has to defend against an out-of-range value, and a corrupt sidecar
|
||||
/// cannot reach a shader.
|
||||
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
||||
let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else {
|
||||
// A sidecar naming an operation this build does not have. The
|
||||
// rest of the edit must still apply.
|
||||
log::warn!("unknown operation {op}; ignoring");
|
||||
return;
|
||||
};
|
||||
let clamped = match operation.descriptor().param(param) {
|
||||
Some(d) => d.clamp(value),
|
||||
None => {
|
||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||
return;
|
||||
}
|
||||
};
|
||||
operation.set_param(param, clamped);
|
||||
}
|
||||
|
||||
/// Read a parameter back.
|
||||
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
|
||||
self.ops
|
||||
.iter()
|
||||
.find(|o| o.descriptor().id == op)
|
||||
.map(|o| o.param(param))
|
||||
}
|
||||
|
||||
/// Reset every parameter of every operation to its default.
|
||||
pub fn reset(&mut self) {
|
||||
for op in &mut self.ops {
|
||||
for p in op.descriptor().params {
|
||||
op.set_param(p.id, p.default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any operation currently changes the image.
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
!self.ops.iter().any(|o| o.is_active())
|
||||
}
|
||||
|
||||
/// Generate the fused shader for the current state.
|
||||
pub fn compose(&self) -> ComposedShader {
|
||||
compose(&self.ops)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditGraph {
|
||||
fn default() -> Self {
|
||||
Self::default_chain()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ops::{exposure, white_balance};
|
||||
|
||||
#[test]
|
||||
fn a_fresh_graph_is_neutral() {
|
||||
// Opening an unedited image must produce the image, not an
|
||||
// interpretation of it.
|
||||
let g = EditGraph::default_chain();
|
||||
assert!(g.is_neutral());
|
||||
assert_eq!(
|
||||
g.compose().source.matches("---- ").count(),
|
||||
0,
|
||||
"a neutral graph must generate no operation blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_chain_exposes_every_operation() {
|
||||
let g = EditGraph::default_chain();
|
||||
let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
||||
for expected in [
|
||||
"white_balance",
|
||||
"exposure",
|
||||
"highlights_shadows",
|
||||
"blacks_whites",
|
||||
"brilliance",
|
||||
"vibrance",
|
||||
"saturation",
|
||||
] {
|
||||
assert!(ids.contains(&expected), "{expected} missing from the chain");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_balance_and_exposure_precede_the_tonal_operations() {
|
||||
// Corrections to capture must come before interpretation of tone, or
|
||||
// the tonal controls act on a wrongly exposed image.
|
||||
let g = EditGraph::default_chain();
|
||||
let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
||||
let pos = |id: &str| ids.iter().position(|x| *x == id).expect(id);
|
||||
assert!(pos("white_balance") < pos("highlights_shadows"));
|
||||
assert!(pos("exposure") < pos("highlights_shadows"));
|
||||
assert!(pos("highlights_shadows") < pos("vibrance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_a_parameter_activates_its_operation() {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
|
||||
assert!(!g.is_neutral());
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.5));
|
||||
assert!(g.compose().source.contains("---- exposure ----"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_clamped_to_the_descriptor() {
|
||||
// The guarantee that lets each operation skip range checks.
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 99.0);
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_operation_is_ignored_rather_than_panicking() {
|
||||
// A sidecar from a newer version names operations this build lacks.
|
||||
// The rest of the edit must still load.
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(OpId("time_machine"), ParamId("year"), 1994.0);
|
||||
assert!(g.is_neutral());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_parameter_is_ignored() {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, ParamId("nonexistent"), 3.0);
|
||||
assert!(g.is_neutral());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_every_operation_to_neutral() {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
||||
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 50.0);
|
||||
assert!(!g.is_neutral());
|
||||
|
||||
g.reset();
|
||||
assert!(g.is_neutral(), "reset must clear every operation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_active_operations_reach_the_shader() {
|
||||
// The composition property, end to end: two adjustments out of seven
|
||||
// available must generate a shader doing exactly two things.
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
||||
|
||||
let shader = g.compose();
|
||||
assert_eq!(shader.source.matches("---- ").count(), 2);
|
||||
assert!(shader.source.contains("---- exposure ----"));
|
||||
assert!(shader.source.contains("---- white_balance ----"));
|
||||
assert!(!shader.source.contains("---- saturation ----"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_slider_does_not_change_the_shader_structure() {
|
||||
// What makes the pipeline cache worth having: dragging a slider must
|
||||
// reuse the compiled pipeline and upload uniforms only.
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
let first = g.compose();
|
||||
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
||||
let second = g.compose();
|
||||
|
||||
assert_eq!(first.structure_hash, second.structure_hash);
|
||||
assert_eq!(first.source, second.source);
|
||||
assert_ne!(first.uniforms, second.uniforms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_describe_every_operation_and_parameter() {
|
||||
// The UI builds its whole panel from this. Anything missing here is
|
||||
// something the UI would have to hardcode.
|
||||
let g = EditGraph::default_chain();
|
||||
let caps = g.capabilities();
|
||||
assert_eq!(caps.len(), g.descriptors().len());
|
||||
|
||||
for cap in &caps {
|
||||
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
|
||||
for p in &cap.params {
|
||||
// A control cannot be built without a range.
|
||||
match &p.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
assert!(min < max, "{}.{} has an empty range", cap.id, p.id);
|
||||
assert!(
|
||||
(*min..=*max).contains(&p.default),
|
||||
"{}.{} default is outside its range",
|
||||
cap.id,
|
||||
p.id
|
||||
);
|
||||
}
|
||||
ParamKind::Bool => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_report_current_values_not_just_defaults() {
|
||||
// So reopening an edited image shows the sliders where the edit left
|
||||
// them, with no separate initialisation path in the UI.
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
|
||||
|
||||
let cap = g
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == exposure::ID)
|
||||
.expect("exposure is in the chain");
|
||||
let p = &cap.params[0];
|
||||
assert_eq!(p.value, 1.25);
|
||||
assert_eq!(p.default, 0.0);
|
||||
assert!(p.is_modified());
|
||||
assert!(cap.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_graph_reports_nothing_modified() {
|
||||
for cap in EditGraph::default_chain().capabilities() {
|
||||
assert!(!cap.active, "{} should start inactive", cap.id);
|
||||
for p in &cap.params {
|
||||
assert!(
|
||||
!p.is_modified(),
|
||||
"{}.{} should start at default",
|
||||
cap.id,
|
||||
p.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_survive_a_round_trip_through_set_param() {
|
||||
// The UI reads a capability, writes the value back, and must get the
|
||||
// same thing out — no hidden scaling between the two.
|
||||
let mut g = EditGraph::default_chain();
|
||||
for cap in g.capabilities() {
|
||||
for p in &cap.params {
|
||||
if let ParamKind::Scalar { max, .. } = p.kind {
|
||||
let target = max * 0.5;
|
||||
g.set_param(cap.id, p.id, target);
|
||||
assert_eq!(g.param(cap.id, p.id), Some(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_an_operation_needs_no_ui_change() {
|
||||
// FR-DEV-3c, asserted structurally: everything a control needs is
|
||||
// reachable from the capability list, so a new operation appears
|
||||
// without the UI naming it. If this test needs editing to add an
|
||||
// operation, the abstraction has leaked.
|
||||
let g = EditGraph::default_chain();
|
||||
let rendered: Vec<String> = g
|
||||
.capabilities()
|
||||
.iter()
|
||||
.flat_map(|c| {
|
||||
c.params.iter().map(move |p| match p.kind {
|
||||
ParamKind::Scalar {
|
||||
min,
|
||||
max,
|
||||
precision,
|
||||
..
|
||||
} => format!(
|
||||
"{}/{}: slider {min}..{max} @{precision} = {}",
|
||||
c.label.0, p.label.0, p.value
|
||||
),
|
||||
ParamKind::Bool => format!("{}/{}: switch", c.label.0, p.label.0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(rendered.len(), 10, "seven operations, ten parameters");
|
||||
assert!(rendered.iter().all(|r| !r.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_another_operation_does_change_the_structure() {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
let before = g.compose().structure_hash;
|
||||
|
||||
g.set_param(
|
||||
crate::ops::colour::SATURATION_ID,
|
||||
crate::ops::colour::SATURATION,
|
||||
30.0,
|
||||
);
|
||||
assert_ne!(before, g.compose().structure_hash);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user