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,175 @@
|
||||
//! The develop pipeline — operations, descriptors, and shader composition.
|
||||
//!
|
||||
//! # What this crate is
|
||||
//!
|
||||
//! An operation here is three things: a **descriptor** saying what its
|
||||
//! parameters are, a **WGSL fragment** saying what it does to a colour, and
|
||||
//! the **state** of its current settings. It is not a shader, not a pipeline,
|
||||
//! and not a control — those belong to `dr-gpu` and `dr-ui` respectively.
|
||||
//!
|
||||
//! That split is what lets this crate have no GPU dependency at all: the
|
||||
//! generated WGSL is a string, and everything about it can be tested without
|
||||
//! a device (ARCH §6.5a).
|
||||
//!
|
||||
//! # Composable shaders
|
||||
//!
|
||||
//! The interesting property. Operations are separate in Rust but *fused* on
|
||||
//! the GPU: [`operation::compose`] concatenates the enabled operations'
|
||||
//! fragments into one compute shader, so an edit with three active
|
||||
//! adjustments runs as one dispatch with one texture read and one write.
|
||||
//!
|
||||
//! An operation at neutral settings contributes nothing — no code, no
|
||||
//! uniform, no branch. The shader for a given op-set compiles once and is
|
||||
//! cached by [`operation::ComposedShader::structure_hash`], which covers the
|
||||
//! operations and their order but not their values; moving a slider uploads
|
||||
//! uniforms and reuses the pipeline.
|
||||
//!
|
||||
//! # Where this sits
|
||||
//!
|
||||
//! Input is the demosaiced texture from `dr-gpu`: linear, scene-referred,
|
||||
//! camera colour space. Working in linear light is what makes exposure a
|
||||
//! single multiply and white balance a per-channel scale; on gamma-encoded
|
||||
//! data neither would be physically meaningful (ARCH §5.2).
|
||||
|
||||
pub mod descriptor;
|
||||
pub mod graph;
|
||||
pub mod operation;
|
||||
pub mod ops;
|
||||
|
||||
pub use descriptor::{
|
||||
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit,
|
||||
};
|
||||
pub use graph::{EditGraph, OpCapability, ParamCapability};
|
||||
pub use operation::{compose, Affects, ComposedShader, Helper, Operation, Uniform};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every operation in the default chain, activated.
|
||||
fn fully_active() -> EditGraph {
|
||||
let mut g = EditGraph::default_chain();
|
||||
for desc in g.descriptors() {
|
||||
for p in desc.params {
|
||||
// A value away from the default, within range.
|
||||
let v = match p.kind {
|
||||
ParamKind::Scalar { max, .. } => max * 0.5,
|
||||
ParamKind::Bool => 1.0,
|
||||
};
|
||||
g.set_param(desc.id, p.id, v);
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_operation_can_be_activated_together() {
|
||||
let g = fully_active();
|
||||
assert!(!g.is_neutral());
|
||||
let shader = g.compose();
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
7,
|
||||
"all seven operations should appear"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_full_chain_generates_a_well_formed_uniform_block() {
|
||||
let shader = fully_active().compose();
|
||||
assert_eq!(
|
||||
shader.uniforms.len() % 4,
|
||||
0,
|
||||
"the uniform block must be 16-byte aligned"
|
||||
);
|
||||
assert!(shader.uniforms.iter().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_operation_declares_a_duplicate_id() {
|
||||
// Two operations sharing an id would collide in the generated
|
||||
// uniform struct and produce a shader that does not compile.
|
||||
let g = EditGraph::default_chain();
|
||||
let mut ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
||||
let before = ids.len();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(before, ids.len(), "operation ids must be unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_operation_declares_a_duplicate_parameter() {
|
||||
for desc in EditGraph::default_chain().descriptors() {
|
||||
let mut ids: Vec<&str> = desc.params.iter().map(|p| p.id.0).collect();
|
||||
let before = ids.len();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(before, ids.len(), "{} has a duplicate parameter", desc.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_is_within_its_declared_range() {
|
||||
// A default outside its own range would mean a fresh image opens
|
||||
// with a value the UI cannot represent.
|
||||
for desc in EditGraph::default_chain().descriptors() {
|
||||
for p in desc.params {
|
||||
assert_eq!(
|
||||
p.clamp(p.default),
|
||||
p.default,
|
||||
"{}.{} default {} is outside its range",
|
||||
desc.id,
|
||||
p.id,
|
||||
p.default
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_leave_every_operation_inactive() {
|
||||
// The invariant behind "opening an image shows the image": every
|
||||
// operation must read its own default as neutral.
|
||||
let g = EditGraph::default_chain();
|
||||
for desc in g.descriptors() {
|
||||
for p in desc.params {
|
||||
assert_eq!(
|
||||
g.param(desc.id, p.id),
|
||||
Some(p.default),
|
||||
"{}.{} does not start at its default",
|
||||
desc.id,
|
||||
p.id
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(g.is_neutral());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_uniform_names_are_valid_wgsl_identifiers() {
|
||||
let shader = fully_active().compose();
|
||||
for line in shader.source.lines() {
|
||||
let trimmed = line.trim();
|
||||
let Some((name, _)) = trimmed.split_once(": f32,") else {
|
||||
continue;
|
||||
};
|
||||
assert!(
|
||||
name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
&& !name.starts_with(|c: char| c.is_ascii_digit()),
|
||||
"{name} is not a valid WGSL identifier"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_chain_declares_each_helper_once() {
|
||||
// Six of the seven operations want `luminance`. A duplicate function
|
||||
// definition fails to compile, so this is the property that keeps
|
||||
// helper sharing safe as operations are added.
|
||||
let source = fully_active().compose().source;
|
||||
for helper in ["luminance", "tone_position", "colour_saturation"] {
|
||||
let count = source.matches(&format!("fn {helper}(")).count();
|
||||
assert!(count <= 1, "{helper} declared {count} times");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user