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:
2026-08-09 11:37:58 +02:00
co-authored by Claude Opus 5
parent cc1c5c892d
commit 78e3e6b846
29 changed files with 5865 additions and 68 deletions
+61
View File
@@ -0,0 +1,61 @@
//! Print what `decode` extracts from a RAW file.
//!
//! A sanity check on the pipeline's inputs: black and white levels, the CFA
//! pattern after re-phasing, as-shot white balance, and the camera→sRGB
//! matrix. Wrong values here produce a wrong image no shader can fix, so it
//! is worth being able to see them directly.
//!
//! ```sh
//! cargo run -p dr-decode --example rawinfo -- IMG.CR2
//! ```
fn main() {
let Some(path) = std::env::args().nth(1) else {
eprintln!("usage: rawinfo <file.cr2>");
std::process::exit(2);
};
let bytes = std::fs::read(&path).expect("read file");
let raw = dr_decode::decode(&bytes).expect("decode");
println!("file {path}");
println!("readout {} × {}", raw.width, raw.height);
println!(
"crop {} × {} at ({}, {})",
raw.crop.width, raw.crop.height, raw.crop.x, raw.crop.y
);
let (dx, dy) = raw.crop.shifts_cfa_phase();
println!(
"cfa {:?} (rephased: {dx}, {dy})",
raw.cfa_pattern
);
println!("black {:?}", raw.black_level);
println!("white {}", raw.white_level);
println!("wb_coeffs {:?}", raw.wb_coeffs);
match raw.color_matrix {
Some(m) => {
println!("cam→srgb");
for row in m.chunks(3) {
println!(
" [{:>8.4} {:>8.4} {:>8.4}]",
row[0], row[1], row[2]
);
}
// Each row should sum to roughly 1: a neutral camera-space colour
// must stay neutral in sRGB. Far from 1 means the normalisation
// or the matrix composition is wrong.
let sums: Vec<f32> = m.chunks(3).map(|r| r.iter().sum()).collect();
println!("row sums {sums:.4?} (≈1.0 each if correct)");
}
None => println!("cam→srgb none — uncalibrated body"),
}
// Sample the actual data range, which reveals a black-level or bit-depth
// mistake faster than any amount of staring at metadata.
let (min, max) = raw
.data
.iter()
.fold((u16::MAX, 0u16), |(lo, hi), &v| (lo.min(v), hi.max(v)));
println!("sample range {min} … {max}");
}
+457 -4
View File
@@ -46,6 +46,8 @@ pub struct Metadata {
/// them.
#[derive(Debug, Clone)]
pub struct RawImage {
/// Width of `data` in samples — the *full* sensor row stride, including
/// any masked border. Not the width the user sees; see [`Self::crop`].
pub width: u32,
pub height: u32,
/// One sample per photosite, in sensor order.
@@ -55,8 +57,42 @@ pub struct RawImage {
pub white_level: u16,
/// As-shot white balance, as per-channel multipliers.
pub wb_coeffs: [f32; 4],
/// Camera-to-XYZ colour matrix (FR-DEV-3e).
/// Camera RGB to linear sRGB (D65), row-major 3×3 (FR-DEV-3e).
///
/// `None` where the body is unknown to the decoder, in which case the
/// pipeline falls back to identity and the result is uncalibrated rather
/// than wrong-by-a-guess.
pub color_matrix: Option<[f32; 9]>,
/// The usable region of `data`, excluding masked and border photosites.
pub crop: CropRect,
}
/// TRACES: FR-RAW-3
/// The usable region of a sensor readout.
///
/// RAW files carry photosites the image does not include: optically black
/// columns used to measure the black level, and a few border rows most
/// demosaics need as context but no viewer should display. Cropping is
/// therefore not an edit — it is part of reading the file correctly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CropRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl CropRect {
/// Whether the crop origin shifts the CFA phase.
///
/// A Bayer pattern repeats every 2×2, so a crop starting at an odd
/// coordinate makes the top-left photosite of the *visible* image a
/// different colour than the pattern names. Demosaicing without
/// accounting for it swaps red and blue — the classic symptom being a
/// correctly-exposed image with wildly wrong colour.
pub fn shifts_cfa_phase(&self) -> (bool, bool) {
(self.x % 2 == 1, self.y % 2 == 1)
}
}
/// TRACES: FR-RAW-5
@@ -78,6 +114,61 @@ impl CfaPattern {
pub fn is_xtrans(self) -> bool {
matches!(self, CfaPattern::XTrans)
}
/// The pattern as seen from an origin shifted by `(dx, dy)` photosites.
///
/// Used to re-phase the pattern after cropping to the active area
/// ([`CropRect::shifts_cfa_phase`]). X-Trans is returned unchanged: its
/// 6×6 cell does not re-phase under a 2×2 shift, so the X-Trans demosaic
/// handles the offset itself.
pub fn shifted(self, dx: bool, dy: bool) -> Self {
use CfaPattern::*;
if matches!(self, XTrans | Unknown) {
return self;
}
// Shifting one column swaps the pair horizontally; one row swaps
// vertically. Both together is the diagonal opposite.
let after_x = if dx {
match self {
Rggb => Grbg,
Grbg => Rggb,
Bggr => Gbrg,
Gbrg => Bggr,
other => other,
}
} else {
self
};
if dy {
match after_x {
Rggb => Gbrg,
Gbrg => Rggb,
Grbg => Bggr,
Bggr => Grbg,
other => other,
}
} else {
after_x
}
}
/// The colour of the photosite at `(x, y)` within the pattern.
///
/// Channel indices are 0=R, 1=G, 2=B, matching the shader's convention.
pub fn colour_at(self, x: u32, y: u32) -> u8 {
use CfaPattern::*;
// Each 2×2 cell listed row-major from its own origin.
let cell: [u8; 4] = match self {
Rggb => [0, 1, 1, 2],
Bggr => [2, 1, 1, 0],
Grbg => [1, 0, 2, 1],
Gbrg => [1, 2, 0, 1],
// Not meaningful for a 6×6 pattern or an unknown one; the caller
// must not be on the Bayer path at all.
XTrans | Unknown => [1, 1, 1, 1],
};
cell[((y % 2) * 2 + (x % 2)) as usize]
}
}
/// TRACES: FR-RAW-1 | M-9
@@ -164,6 +255,9 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
.raw_image(&source, &Default::default(), false)
.map_err(|e| DecodeError::Decode(e.to_string()))?;
// Derived before the match below moves `image.data`.
let color_matrix = cam_to_srgb(&image);
let data = match image.data {
rawler::RawImageData::Integer(v) => v,
rawler::RawImageData::Float(v) => {
@@ -175,7 +269,6 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
}
};
let cfa = cfa_from_rawler(&image.camera.cfa, image.camera.model.as_str());
// Black levels are rationals; the pipeline wants plain u16 samples.
let bl = &image.blacklevel.levels;
let level_at = |i: usize| -> u16 {
@@ -185,9 +278,36 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
};
let black_level = [level_at(0), level_at(1), level_at(2), level_at(3)];
// Prefer the recommended crop, falling back to the active area, then to
// the whole readout. `crop_area` is what the camera itself would show;
// `active_area` merely excludes the masked border.
let rect = image.crop_area.or(image.active_area);
let crop = match rect {
Some(r) => CropRect {
x: r.p.x as u32,
y: r.p.y as u32,
width: r.d.w as u32,
height: r.d.h as u32,
},
None => CropRect {
x: 0,
y: 0,
width: image.width as u32,
height: image.height as u32,
},
};
// Re-phase the CFA to the crop origin, or the demosaic swaps R and B on
// any body whose active area starts at an odd coordinate. Applied exactly
// once — shifting twice returns the original pattern and reintroduces the
// very bug it exists to prevent.
let (dx, dy) = crop.shifts_cfa_phase();
let cfa = cfa_from_rawler(&image.camera.cfa, image.camera.model.as_str()).shifted(dx, dy);
Ok(RawImage {
width: image.width as u32,
height: image.height as u32,
crop,
data,
cfa_pattern: cfa,
black_level,
@@ -197,11 +317,177 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
.first()
.map(|v| *v as u16)
.unwrap_or(u16::MAX),
wb_coeffs: image.wb_coeffs,
color_matrix: None,
wb_coeffs: sane_wb(image.wb_coeffs),
color_matrix,
})
}
/// TRACES: FR-DEV-3e
/// Compose the camera→sRGB-linear matrix from rawler's XYZ→camera.
///
/// **Two rawler traps this avoids**, both measured on a Canon 6D CR2
/// (2026-08-09):
///
/// 1. `RawImage::xyz_to_cam` is **all zeros** — it carries an upstream
/// deprecation note and 0.7.2 no longer fills it. The live data is
/// `color_matrix`, keyed by illuminant. Reading the old field silently
/// yields no colour transform at all.
/// 2. `cam_to_xyz_normalized()` divides each of four rows by its own sum, and
/// the fourth row (emerald/white, unused on any Bayer body) sums to zero.
/// Every element came back `NaN`. Inverting the 3×3 ourselves avoids the
/// fourth channel entirely.
fn cam_to_srgb(image: &rawler::RawImage) -> Option<[f32; 9]> {
use rawler::imgop::xyz::Illuminant;
// Prefer D65 — it matches sRGB's white point, so no chromatic adaptation
// is needed. Illuminant A (tungsten) is a distant fallback for bodies
// that ship only one matrix; adapting it properly is a v0.2 colour-
// management concern (ARCH §5.2), not something to fake here.
let flat = image
.color_matrix
.get(&Illuminant::D65)
.or_else(|| image.color_matrix.get(&Illuminant::A))?;
if flat.len() < 9 {
return None;
}
let xyz_to_cam: [[f32; 3]; 3] = [
[flat[0], flat[1], flat[2]],
[flat[3], flat[4], flat[5]],
[flat[6], flat[7], flat[8]],
];
cam_to_srgb_from(&xyz_to_cam)
}
/// The matrix maths, split out so it can be tested without a RAW file.
//
// The constants below are quoted at their published precision rather than
// trimmed to what f32 can represent. Truncating a standard matrix to satisfy
// a linter makes it harder to check against the specification, and the
// rounding happens identically either way.
#[allow(clippy::excessive_precision)]
fn cam_to_srgb_from(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 9]> {
// XYZ (D65) → linear sRGB, the standard primaries.
const XYZ_TO_SRGB: [[f32; 3]; 3] = [
[3.2404542, -1.5371385, -0.4985314],
[-0.9692660, 1.8760108, 0.0415560],
[0.0556434, -0.2040259, 1.0572252],
];
// sRGB (D65) → XYZ, for finding the camera response to white.
const SRGB_TO_XYZ: [[f32; 3]; 3] = [
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
];
// An absent or unpopulated matrix is all zeros. Using it would render
// black, so report absence and let the caller fall back to identity.
if xyz_to_cam.iter().flatten().all(|v| v.abs() < f32::EPSILON) {
return None;
}
if xyz_to_cam.iter().flatten().any(|v| !v.is_finite()) {
return None;
}
// White balance to D65: find what the camera reports for sRGB white, so
// the composed matrix maps neutral to neutral. Without this the image
// carries a strong cast even with correct primaries.
let mut cam_white = [0.0f32; 3];
for (i, row) in xyz_to_cam.iter().enumerate() {
// xyz_to_cam · (XYZ of sRGB white) — the row sums of SRGB_TO_XYZ.
for k in 0..3 {
let white_k: f32 = SRGB_TO_XYZ[k].iter().sum();
cam_white[i] += row[k] * white_k;
}
}
if cam_white.iter().any(|v| v.abs() < 1e-6 || !v.is_finite()) {
return None;
}
// Scale each row so the camera's own white becomes unity, then invert.
let balanced = [
[
xyz_to_cam[0][0] / cam_white[0],
xyz_to_cam[0][1] / cam_white[0],
xyz_to_cam[0][2] / cam_white[0],
],
[
xyz_to_cam[1][0] / cam_white[1],
xyz_to_cam[1][1] / cam_white[1],
xyz_to_cam[1][2] / cam_white[1],
],
[
xyz_to_cam[2][0] / cam_white[2],
xyz_to_cam[2][1] / cam_white[2],
xyz_to_cam[2][2] / cam_white[2],
],
];
let cam_to_xyz = invert3(&balanced)?;
let mut out = [0.0f32; 9];
for i in 0..3 {
for j in 0..3 {
let mut sum = 0.0;
for k in 0..3 {
sum += XYZ_TO_SRGB[i][k] * cam_to_xyz[k][j];
}
out[i * 3 + j] = sum;
}
}
if out.iter().any(|v| !v.is_finite()) {
return None;
}
Some(out)
}
/// TRACES: FR-DEV-3e
/// Normalise as-shot white balance into usable multipliers.
///
/// rawler reports coefficients in RGBE order, and the fourth is `NaN` on
/// every three-colour sensor — *measured on a Canon 6D CR2 (2026-08-09):
/// `[1.893, 1.0, 1.797, NaN]`*. Uploaded to the GPU unchecked, that NaN
/// contaminates the shader's uniform block. It is normalised to 1.0 here,
/// where the reason can be written down, rather than being defended against
/// at every use site.
///
/// Coefficients are also divided through by green, so green is the reference
/// channel and exposure does not shift when white balance changes.
fn sane_wb(raw: [f32; 4]) -> [f32; 4] {
let usable = |v: f32| if v.is_finite() && v > 0.0 { v } else { 1.0 };
let (r, g, b) = (usable(raw[0]), usable(raw[1]), usable(raw[2]));
[r / g, 1.0, b / g, 1.0]
}
/// Invert a 3×3 matrix, or `None` if it is singular.
fn invert3(m: &[[f32; 3]; 3]) -> Option<[[f32; 3]; 3]> {
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
if det.abs() < 1e-12 || !det.is_finite() {
return None;
}
let inv = 1.0 / det;
Some([
[
(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv,
(m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv,
(m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv,
],
[
(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv,
(m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv,
(m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv,
],
[
(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv,
(m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv,
(m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv,
],
])
}
fn cfa_from_rawler(cfa: &rawler::CFA, model: &str) -> CfaPattern {
// rawler exposes the pattern as a string; X-Trans is 6x6 rather than 2x2.
let name = cfa.name.to_ascii_uppercase();
@@ -263,4 +549,171 @@ mod tests {
assert!(CfaPattern::XTrans.is_xtrans());
assert!(!CfaPattern::Rggb.is_xtrans());
}
#[test]
fn cfa_colours_follow_the_named_pattern() {
// RGGB: red at the origin, blue diagonally opposite.
let p = CfaPattern::Rggb;
assert_eq!(p.colour_at(0, 0), 0, "top-left is red");
assert_eq!(p.colour_at(1, 0), 1, "top-right is green");
assert_eq!(p.colour_at(0, 1), 1, "bottom-left is green");
assert_eq!(p.colour_at(1, 1), 2, "bottom-right is blue");
}
#[test]
fn cfa_pattern_repeats_every_two_photosites() {
let p = CfaPattern::Bggr;
for (x, y) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)] {
assert_eq!(p.colour_at(x, y), p.colour_at(x + 2, y + 2));
assert_eq!(p.colour_at(x, y), p.colour_at(x + 100, y + 64));
}
}
#[test]
fn an_odd_crop_origin_rephases_the_pattern() {
// The bug this prevents: a body whose active area starts at an odd
// column renders with red and blue swapped, because the visible
// top-left photosite is not the one the pattern names.
let shifted = CfaPattern::Rggb.shifted(true, false);
assert_eq!(shifted, CfaPattern::Grbg);
// Reading the shifted pattern at the origin must agree with reading
// the original one column across.
assert_eq!(shifted.colour_at(0, 0), CfaPattern::Rggb.colour_at(1, 0));
assert_eq!(shifted.colour_at(1, 0), CfaPattern::Rggb.colour_at(2, 0));
}
#[test]
fn shifting_both_axes_gives_the_diagonal_opposite() {
let s = CfaPattern::Rggb.shifted(true, true);
assert_eq!(s, CfaPattern::Bggr);
assert_eq!(s.colour_at(0, 0), CfaPattern::Rggb.colour_at(1, 1));
}
#[test]
fn shifting_is_its_own_inverse() {
for p in [
CfaPattern::Rggb,
CfaPattern::Bggr,
CfaPattern::Grbg,
CfaPattern::Gbrg,
] {
assert_eq!(p.shifted(true, false).shifted(true, false), p);
assert_eq!(p.shifted(false, true).shifted(false, true), p);
assert_eq!(p.shifted(true, true).shifted(true, true), p);
}
}
#[test]
fn an_even_crop_origin_leaves_the_pattern_alone() {
let c = CropRect {
x: 0,
y: 0,
width: 100,
height: 100,
};
assert_eq!(c.shifts_cfa_phase(), (false, false));
assert_eq!(CfaPattern::Rggb.shifted(false, false), CfaPattern::Rggb);
let even = CropRect {
x: 84,
y: 50,
width: 100,
height: 100,
};
assert_eq!(even.shifts_cfa_phase(), (false, false));
}
#[test]
fn neutral_stays_neutral_through_the_colour_matrix() {
// The property that makes a camera matrix correct: a neutral camera
// colour must land on a neutral sRGB colour, so every row sums to 1.
// A matrix that fails this renders a strong global cast.
//
// Values are the D65 matrix rawler reports for a Canon EOS 6D.
let xyz_to_cam = [
[0.7034, -0.0804, -0.1014],
[-0.4420, 1.2564, 0.2058],
[-0.0851, 0.1994, 0.5758],
];
let m = cam_to_srgb_from(&xyz_to_cam).expect("a well-formed matrix inverts");
for (i, row) in m.chunks(3).enumerate() {
let sum: f32 = row.iter().sum();
assert!(
(sum - 1.0).abs() < 1e-4,
"row {i} sums to {sum}, not 1.0 — neutral would not stay neutral"
);
}
}
#[test]
fn an_all_zero_matrix_is_absent_rather_than_black() {
// rawler's deprecated `xyz_to_cam` is all zeros in 0.7.2. Treating it
// as a real matrix renders a black image; the pipeline needs to know
// to fall back to identity instead.
assert_eq!(cam_to_srgb_from(&[[0.0; 3]; 3]), None);
}
#[test]
fn a_singular_matrix_is_rejected() {
// Two identical rows cannot be inverted; returning garbage here would
// surface as an unexplained colour failure much later.
let singular = [[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
assert_eq!(cam_to_srgb_from(&singular), None);
}
#[test]
// Indexing by i/j is how the matrix identity is written down; iterators
// would obscure what is being asserted.
#[allow(clippy::needless_range_loop)]
fn inversion_round_trips() {
let m = [[2.0, 0.0, 1.0], [1.0, 3.0, 0.0], [0.0, 1.0, 4.0]];
let inv = invert3(&m).expect("invertible");
// m · inv should be the identity.
for i in 0..3 {
for j in 0..3 {
let mut sum = 0.0;
for k in 0..3 {
sum += m[i][k] * inv[k][j];
}
let expected = if i == j { 1.0 } else { 0.0 };
assert!((sum - expected).abs() < 1e-5, "element ({i},{j}) = {sum}");
}
}
}
#[test]
fn white_balance_drops_the_nan_fourth_channel() {
// rawler reports RGBE, and E is NaN on every three-colour sensor.
// Measured on a Canon 6D: [1.893, 1.0, 1.797, NaN]. Uploaded raw,
// that NaN poisons the shader's uniform block.
let wb = sane_wb([1.8925781, 1.0, 1.796875, f32::NAN]);
assert!(wb.iter().all(|v| v.is_finite()), "no NaN may survive");
assert_eq!(wb[3], 1.0);
}
#[test]
fn white_balance_is_normalised_to_green() {
// Green is the reference channel, so overall exposure does not shift
// when white balance changes.
let wb = sane_wb([3.0, 2.0, 4.0, f32::NAN]);
assert_eq!(wb[1], 1.0);
assert!((wb[0] - 1.5).abs() < 1e-6);
assert!((wb[2] - 2.0).abs() < 1e-6);
}
#[test]
fn absent_white_balance_falls_back_to_neutral() {
// A body reporting nothing must render neutral, not black or
// infinite.
let wb = sane_wb([0.0, 0.0, 0.0, 0.0]);
assert_eq!(wb, [1.0, 1.0, 1.0, 1.0]);
}
#[test]
fn xtrans_does_not_rephase() {
// A 6×6 cell does not re-phase under a 2×2 shift; claiming otherwise
// would corrupt the X-Trans path rather than fix it.
assert_eq!(CfaPattern::XTrans.shifted(true, true), CfaPattern::XTrans);
}
}