Demosaic a Fujifilm sensor instead of refusing it

Every RAF stopped at the embedded preview, because the demosaicer had
one kernel and it was a Bayer kernel. D11 makes Fujifilm first-class and
FR-RAW-5 asks for it by name, so a hard error there was a promise we had
not kept.

X-Trans is a 6x6 tile, and nothing in the Bayer path survives that: the
missing channels sit at different offsets at all 36 positions, so there
is no fixed kernel to write. The new shader fits a weighted plane
through each channel's samples in a 5x5 window and carries the other two
channels across as the difference between those planes, keeping the
pixel's own measured value untouched. A plane rather than a mean because
the three channels are sampled at different places in the tile: a mean
compares a red taken slightly left of the pixel with a green taken
slightly right of it, and that offset is a colour cast that follows
every gradient in the frame. The fit is done in white-balanced space,
where the constant-colour-difference model it rests on is actually true
of a neutral subject; that alone halves the error at a luminance edge.

Two compromises, both deliberate.

It is not Markesteijn. There are no directional hypotheses and no
homogeneity map, so it does not resolve detail finer than the CFA period
and a hard edge arrives about two pixels wide. It cannot ring — the
output is bounded by the local sample range — so it does not produce the
worms FR-RAW-5 exists to avoid, but the quality that requirement asks
for is still owed.

The tile's phase is guessed rather than known. rawler has each body's
pattern exactly, as a 36-character string, but CfaPattern::XTrans throws
it away before dr-gpu sees the file, and it is not a constant to
hard-code: the bodies in that database start the tile at four different
origins. So the phase is read back out of the pixels, by grouping the 36
per-position means and taking the grouping with the least spread. That
part needs nothing from the scene. Telling red from blue does — shifting
the tile by half a tile turns it into itself with red and blue swapped,
so no geometry can decide it — and the as-shot white balance is what
breaks the tie. A frame that is almost entirely one colour can defeat
that; widening dr-decode to carry the pattern string would retire the
guess altogether.

The tests assert reconstruction, not success: a flat patch comes back
exactly at all six phases tested, and a linear ramp comes back exactly
too, which is the property the plane fit exists for and the one a mean
would fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 08:46:28 +02:00
co-authored by Claude Opus 5
parent 2330ed25e9
commit 1c0994c807
3 changed files with 940 additions and 50 deletions
+687 -47
View File
@@ -1,4 +1,4 @@
//! Raw upload, black/white normalisation, and Bayer demosaic.
//! Raw upload, black/white normalisation, and demosaic — Bayer and X-Trans.
//!
//! The first real pipeline stage (ARCH §5.2). It takes CFA sensor data from
//! `dr-decode`, uploads it once, and produces a linear scene-referred
@@ -31,6 +31,32 @@ struct DemosaicParams {
inv_range: [f32; 4],
}
/// TRACES: FR-RAW-5
/// Uniform block for the X-Trans pass. Layout must match `xtrans.wgsl`.
///
/// Separate from [`DemosaicParams`] rather than a superset of it: the two
/// passes disagree about what a black level even is — four positional values
/// on a 2×2 cell, one sensor-wide value on a 6×6 tile — and a shared block
/// would have to carry both and let each shader pick.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct XTransParams {
width: u32,
height: u32,
crop_x: u32,
crop_y: u32,
stride: u32,
black: f32,
inv_range: f32,
_pad0: u32,
/// As-shot white balance gains, green-normalised. The demosaic
/// interpolates in balanced space and undoes them before writing.
wb: [f32; 4],
inv_wb: [f32; 4],
/// The 6×6 tile at this sensor's phase, two bits per photosite.
tile: [u32; 4],
}
/// A demosaiced image living on the GPU.
///
/// RGBA16Float, scene-referred, camera colour space. This is the input every
@@ -222,10 +248,17 @@ fn f32_to_f16_bits(v: f32) -> u16 {
((exp as u16) << 10) | mantissa as u16
}
/// Runs the demosaic pass. Holds the pipeline so repeated images reuse it.
/// Runs the demosaic pass. Holds the pipelines so repeated images reuse them.
///
/// Both CFA families are built up front rather than on first use. A Fujifilm
/// file arriving mid-session would otherwise pay a shader compilation inside
/// the interaction budget, and the compilation is the one part of this that
/// can fail on a driver — better to learn that when the pipeline is created
/// than when a photograph is opened.
pub struct Demosaicer {
ctx: GpuContext,
pipeline: wgpu::ComputePipeline,
xtrans_pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout,
}
@@ -237,7 +270,17 @@ impl Demosaicer {
label: Some("demosaic"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/demosaic.wgsl").into()),
});
let xtrans_shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("xtrans-demosaic"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/xtrans.wgsl").into()),
});
// One layout for both passes. They take the same three bindings — raw
// samples, a uniform block, the output texture — and only the contents
// of the uniform differ, so a second layout would be the same three
// entries written twice.
let bind_group_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -296,32 +339,31 @@ impl Demosaicer {
cache: None,
});
let xtrans_pipeline =
ctx.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("xtrans-demosaic-pipeline"),
layout: Some(&layout),
module: &xtrans_shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
Ok(Self {
ctx: ctx.clone(),
pipeline,
xtrans_pipeline,
bind_group_layout,
})
}
/// Upload and demosaic one image.
///
/// Two kernels behind one entry point. The caller hands over a
/// `RawImage`; which of the two CFA families it came off is this
/// function's problem, not theirs.
pub fn run(&self, raw: &RawImage) -> Result<DemosaicedImage, GpuError> {
if raw.cfa_pattern.is_xtrans() {
// Better to say so than to render a maze of artefacts that reads
// as a corrupt file (FR-RAW-5).
return Err(GpuError::UnsupportedCfa(
"X-Trans demosaic not yet implemented".into(),
));
}
let pattern = match raw.cfa_pattern {
CfaPattern::Rggb => 0u32,
CfaPattern::Bggr => 1,
CfaPattern::Grbg => 2,
CfaPattern::Gbrg => 3,
other => {
return Err(GpuError::UnsupportedCfa(format!("{other:?}")));
}
};
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
let limits = self.ctx.device.limits();
@@ -332,6 +374,38 @@ impl Demosaicer {
)));
}
// Declared here and filled in one branch each, so the bytes handed to
// the buffer outlive the `if` that chose them.
let bayer_params;
let xtrans_params;
let (pipeline, params_bytes) = if raw.cfa_pattern.is_xtrans() {
xtrans_params = xtrans_params_for(raw, width, height);
(&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params))
} else {
let pattern = match raw.cfa_pattern {
CfaPattern::Rggb => 0u32,
CfaPattern::Bggr => 1,
CfaPattern::Grbg => 2,
CfaPattern::Gbrg => 3,
other => {
return Err(GpuError::UnsupportedCfa(format!("{other:?}")));
}
};
bayer_params = DemosaicParams {
width,
height,
crop_x: raw.crop.x,
crop_y: raw.crop.y,
stride: raw.width,
pattern,
_pad0: 0,
_pad1: 0,
black: black_per_cell(raw),
inv_range: inv_range_per_cell(raw),
};
(&self.pipeline, bytemuck::bytes_of(&bayer_params))
};
// Pack the u16 samples two per u32. WGSL has no u16 storage type, so
// unpacking happens in the shader.
let packed = pack_samples(&raw.data);
@@ -344,24 +418,12 @@ impl Demosaicer {
usage: wgpu::BufferUsages::STORAGE,
});
let params = DemosaicParams {
width,
height,
crop_x: raw.crop.x,
crop_y: raw.crop.y,
stride: raw.width,
pattern,
_pad0: 0,
_pad1: 0,
black: black_per_cell(raw),
inv_range: inv_range_per_cell(raw),
};
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("demosaic-params"),
contents: bytemuck::bytes_of(&params),
contents: params_bytes,
usage: wgpu::BufferUsages::UNIFORM,
});
@@ -418,7 +480,7 @@ impl Demosaicer {
label: Some("demosaic-pass"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
@@ -478,25 +540,259 @@ fn black_per_cell(raw: &RawImage) -> [f32; 4] {
}
/// Reciprocal of the usable range per cell, so the shader avoids a division.
fn inv_range_per_cell(raw: &RawImage) -> [f32; 4] {
let white = f32::from(raw.white_level);
let mut out = [0.0f32; 4];
for (slot, black) in out.iter_mut().zip(black_per_cell(raw)) {
*slot = inv_range_above(white, black);
}
out
}
/// Reciprocal of the usable range above one black level.
///
/// A white level at or below black would divide by zero; such a file is
/// malformed, and falling back to full scale renders something inspectable
/// rather than a NaN texture.
fn inv_range_per_cell(raw: &RawImage) -> [f32; 4] {
let white = f32::from(raw.white_level);
let black = black_per_cell(raw);
let mut out = [0.0f32; 4];
for (i, &b) in black.iter().enumerate() {
let range = white - b;
out[i] = if range > 1.0 {
1.0 / range
} else {
1.0 / 65535.0
};
fn inv_range_above(white: f32, black: f32) -> f32 {
let range = white - black;
if range > 1.0 {
1.0 / range
} else {
1.0 / 65535.0
}
}
// ---- X-Trans ----------------------------------------------------------
//
// Fujifilm's 6×6 colour filter array (FR-RAW-5). Nothing in the Bayer path
// generalises to it: there is no 2×2 cell, the black levels are not
// positional, and the pattern's origin is a per-body fact rather than a
// constant.
/// TRACES: FR-RAW-5
/// The X-Trans tile, row-major from the sensor's own origin. 0=R, 1=G, 2=B.
///
/// Transcribed from rawler's camera database — `color_pattern` in
/// `data/cameras/fuji/x-t3.toml`, `"GGRGGBGGBGGRBRGRBGGGBGGRGGRGGBRBGBRG"` —
/// and not derived by eye. It is the same tile every Fujifilm body uses; what
/// differs between them is only where it starts, which is what
/// [`detect_xtrans_phase`] is for.
///
/// The structure worth knowing when reading the shader: twenty green, eight
/// red, eight blue, with a red *and* a blue in every row and every column.
/// That last property is the whole point of the design — no line of the
/// sensor is blind to a colour, so there is no orientation along which the
/// pattern aliases the way Bayer does.
const XTRANS_TILE: [[u8; 6]; 6] = [
[1, 1, 0, 1, 1, 2],
[1, 1, 2, 1, 1, 0],
[2, 0, 1, 0, 2, 1],
[1, 1, 2, 1, 1, 0],
[1, 1, 0, 1, 1, 2],
[0, 2, 1, 2, 0, 1],
];
/// Colour of the photosite at absolute sensor coordinates, for a given phase.
fn xtrans_colour_at(phase: (u32, u32), x: u32, y: u32) -> u8 {
XTRANS_TILE[((y + phase.1) % 6) as usize][((x + phase.0) % 6) as usize]
}
/// Pack the phase-rotated tile into the four words the shader indexes.
///
/// Word `k` holds row `2k` in its low twelve bits and row `2k+1` in the next
/// twelve, two bits per photosite; the fourth word is padding that keeps the
/// uniform block's 16-byte alignment. Rotating on the CPU means the shader
/// never has to know that a phase exists.
fn pack_xtrans_tile(phase: (u32, u32)) -> [u32; 4] {
let mut out = [0u32; 4];
for row in 0..6u32 {
for col in 0..6u32 {
let colour = u32::from(xtrans_colour_at(phase, col, row));
out[(row >> 1) as usize] |= colour << ((row & 1) * 12 + col * 2);
}
}
out
}
/// As-shot white balance gains, green-normalised and bounded.
///
/// Bounded because these reach a divisor in the shader: `dr-decode` already
/// turns rawler's `NaN` fourth coefficient into 1.0, but a coefficient of
/// 0.001 from a mis-parsed tag would survive that and turn one channel into
/// a thousandfold amplifier.
fn wb_gains(raw: &RawImage) -> [f32; 3] {
let bounded = |v: f32| {
if v.is_finite() {
v.clamp(0.05, 20.0)
} else {
1.0
}
};
[
bounded(raw.wb_coeffs[0]),
bounded(raw.wb_coeffs[1]),
bounded(raw.wb_coeffs[2]),
]
}
/// The single black level and range the X-Trans pass normalises against.
///
/// rawler reports Fujifilm black levels over the whole 6×6 tile, of which
/// `dr-decode`'s four-element field keeps the first four. On every body
/// examined those four are identical, so their mean *is* the level rather
/// than an estimate of it — and averaging is what puts a body that does
/// report distinct values in the middle rather than on whichever corner
/// happened to be first.
fn xtrans_levels(raw: &RawImage) -> (f32, f32) {
let black = black_per_cell(raw).iter().sum::<f32>() / 4.0;
(black, inv_range_above(f32::from(raw.white_level), black))
}
/// TRACES: FR-RAW-5
/// Recover the 6×6 phase of the tile from the sensor data itself.
///
/// **Why this is guesswork rather than a lookup.** rawler knows each body's
/// pattern exactly — it is a 36-character string in the camera database — but
/// `CfaPattern::XTrans` is a bare enum variant, so the phase is discarded
/// before `dr-gpu` ever sees the file. It is not a constant that could simply
/// be hard-coded: of the Fujifilm bodies in that database, the tile starts at
/// four different origins, and choosing the wrong one mislabels every
/// photosite on the sensor. Widening `dr-decode`'s type to carry the string
/// is the real fix; until then the phase is read back out of the pixels.
///
/// **How.** Average the sensor over each of the 36 positions in the tile.
/// Photosites sharing a filter share a mean, so the correct phase is the one
/// whose grouping of those 36 numbers into 20 green, 8 red and 8 blue has the
/// least spread within each group. That criterion needs nothing from the
/// scene and is decisive — except for one thing it cannot possibly see:
/// translating the tile by three columns turns it into itself with red and
/// blue exchanged, so the two labellings fit the data equally well. Red and
/// blue are told apart by the as-shot white balance, on the argument that the
/// camera's own gains should bring the three channel means towards each
/// other, and only bring them together for the right assignment.
///
/// That last step is an assumption about the scene, and a frame that is
/// almost entirely one colour can defeat it. The failure is a red/blue swap,
/// which is loud and obviously wrong rather than subtly wrong — and it goes
/// away entirely once the pattern is plumbed through from the decoder.
fn detect_xtrans_phase(raw: &RawImage) -> (u32, u32) {
let mut sums = [[0.0f64; 6]; 6];
let mut counts = [[0.0f64; 6]; 6];
// Only the cropped area. The masked border a sensor readout carries is at
// the black level in every position, and averaging it in flattens the very
// differences this reads.
let stride = raw.width as usize;
let x0 = raw.crop.x as usize;
let y0 = raw.crop.y as usize;
let x1 = (x0 + raw.crop.width as usize).min(stride);
let y1 = (y0 + raw.crop.height as usize).min(raw.height as usize);
// A couple of hundred rows already give tens of thousands of samples per
// tile position, and this runs over a 24 MP buffer. The step is kept
// coprime with six so that skipping rows still visits all six rows of the
// tile — a step of six would sample one row of it and nothing else.
let mut step = y1.saturating_sub(y0) / 256;
while step < 1 || step.is_multiple_of(2) || step.is_multiple_of(3) {
step += 1;
}
for y in (y0..y1).step_by(step) {
let row = y * stride;
for x in x0..x1 {
let Some(&value) = raw.data.get(row + x) else {
continue;
};
sums[y % 6][x % 6] += f64::from(value);
counts[y % 6][x % 6] += 1.0;
}
}
let mut means = [[0.0f64; 6]; 6];
let mut total = 0.0;
for ((mean_row, sum_row), count_row) in means.iter_mut().zip(&sums).zip(&counts) {
for ((mean, &sum), &count) in mean_row.iter_mut().zip(sum_row).zip(count_row) {
*mean = sum / count.max(1.0);
total += *mean;
}
}
// Normalised so the tie tolerance below means the same thing at every
// exposure.
let scale = if total > 0.0 { 36.0 / total } else { 1.0 };
let wb = wb_gains(raw);
// Two candidates always fit exactly as well as each other — the tile maps
// onto itself under a half-tile shift — so the comparison has to admit a
// tie rather than trust the last bit of a float sum.
const TIE: f64 = 1e-6;
let mut best_spread = f64::INFINITY;
let mut best_imbalance = f64::INFINITY;
let mut best = (0u32, 0u32);
for py in 0..6u32 {
for px in 0..6u32 {
let mut group_n = [0.0f64; 3];
let mut group_sum = [0.0f64; 3];
let mut group_sq = [0.0f64; 3];
for j in 0..6u32 {
for i in 0..6u32 {
let k = usize::from(xtrans_colour_at((px, py), i, j));
let m = means[j as usize][i as usize] * scale;
group_n[k] += 1.0;
group_sum[k] += m;
group_sq[k] += m * m;
}
}
let mut spread = 0.0;
let mut balanced = [0.0f64; 3];
for k in 0..3 {
let n = group_n[k].max(1.0);
let mean = group_sum[k] / n;
spread += group_sq[k] - n * mean * mean;
balanced[k] = f64::from(wb[k]) * mean;
}
let neutral = (0..3).map(|k| group_n[k] * balanced[k]).sum::<f64>() / 36.0;
let imbalance = (0..3)
.map(|k| group_n[k] * (balanced[k] - neutral).powi(2))
.sum::<f64>();
let decisive = spread < best_spread - TIE;
let tied_but_more_neutral = spread < best_spread + TIE && imbalance < best_imbalance;
if decisive || tied_but_more_neutral {
best_spread = best_spread.min(spread);
best_imbalance = imbalance;
best = (px, py);
}
}
}
log::debug!("X-Trans phase detected as {best:?} (spread {best_spread:.4})");
best
}
/// TRACES: FR-RAW-5
/// Everything the X-Trans shader needs about one image.
fn xtrans_params_for(raw: &RawImage, width: u32, height: u32) -> XTransParams {
let (black, inv_range) = xtrans_levels(raw);
let wb = wb_gains(raw);
XTransParams {
width,
height,
crop_x: raw.crop.x,
crop_y: raw.crop.y,
stride: raw.width,
black,
inv_range,
_pad0: 0,
wb: [wb[0], wb[1], wb[2], 1.0],
inv_wb: [1.0 / wb[0], 1.0 / wb[1], 1.0 / wb[2], 1.0],
tile: pack_xtrans_tile(detect_xtrans_phase(raw)),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -886,15 +1182,359 @@ mod tests {
}
#[test]
fn xtrans_is_refused_rather_than_rendered_wrong() {
fn an_unidentifiable_cfa_is_refused_rather_than_rendered_wrong() {
// Both demosaics need to know which filter each photosite carries. A
// file whose pattern dr-decode could not name has no answer to that,
// and guessing produces a maze of colour that reads as a corrupt
// image rather than as an unsupported one.
let Some(ctx) = ctx() else { return };
let d = Demosaicer::new(&ctx).expect("demosaicer");
let mut raw = flat_cfa(CfaPattern::Rggb, 8, [100, 100, 100], 0, 1000);
raw.cfa_pattern = CfaPattern::XTrans;
raw.cfa_pattern = CfaPattern::Unknown;
assert!(
matches!(d.run(&raw), Err(GpuError::UnsupportedCfa(_))),
"an X-Trans file must report the gap, not render artefacts"
"an unnamed pattern must report the gap, not render artefacts"
);
}
// ---- X-Trans -------------------------------------------------------
/// The colours the tile assigns across one period, for comparing two
/// phases.
///
/// Phases are compared this way rather than as coordinate pairs because
/// the tile maps onto itself under a half-tile shift: `(0, 0)` and
/// `(3, 3)` are different numbers describing the same sensor.
fn labelling(phase: (u32, u32)) -> Vec<u8> {
(0..6)
.flat_map(|y| (0..6).map(move |x| xtrans_colour_at(phase, x, y)))
.collect()
}
/// Build a synthetic X-Trans mosaic of a uniform colour at a known phase.
fn flat_xtrans(
phase: (u32, u32),
size: u32,
rgb: [u16; 3],
black: u16,
white: u16,
) -> RawImage {
let mut data = vec![0u16; (size * size) as usize];
for y in 0..size {
for x in 0..size {
let c = usize::from(xtrans_colour_at(phase, x, y));
data[(y * size + x) as usize] = black + rgb[c];
}
}
RawImage {
width: size,
height: size,
data,
cfa_pattern: CfaPattern::XTrans,
black_level: [black; 4],
white_level: white,
// The gains a camera looking at this colour would have recorded.
// Not decoration: the phase detector needs them to tell red from
// blue, and every real file carries them.
wb_coeffs: [
f32::from(rgb[1]) / f32::from(rgb[0]),
1.0,
f32::from(rgb[1]) / f32::from(rgb[2]),
1.0,
],
color_matrix: None,
crop: CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
}
}
#[test]
fn the_xtrans_tile_is_blind_to_no_colour_on_any_line() {
// The property that makes X-Trans what it is, and the cheapest check
// that the 36 transcribed entries are the right 36: every row and
// every column carries a red and a blue, which is why the pattern
// does not alias along an axis the way Bayer does. One mistyped entry
// breaks a line here, and would otherwise mislabel one photosite in
// thirty-six across the whole sensor.
let mut counts = [0usize; 3];
for row in XTRANS_TILE {
for colour in row {
counts[usize::from(colour)] += 1;
}
}
assert_eq!(counts, [8, 20, 8], "8 red, 20 green, 8 blue");
for (i, row) in XTRANS_TILE.iter().enumerate() {
let column: Vec<u8> = XTRANS_TILE.iter().map(|r| r[i]).collect();
for (line, what) in [(row.to_vec(), "row"), (column, "column")] {
assert!(
line.contains(&0) && line.contains(&2),
"{what} {i} carries no red or no blue: {line:?}"
);
}
}
}
#[test]
fn packing_the_tile_survives_every_phase() {
// The shader reads the tile back out of four packed words. If the
// packing and the unpacking disagree by so much as one shift, every
// photosite is assigned somebody else's filter — and the output is
// still a plausible-looking image, just the wrong colour.
for py in 0..6u32 {
for px in 0..6u32 {
let packed = pack_xtrans_tile((px, py));
for y in 0..6u32 {
for x in 0..6u32 {
let word = packed[(y >> 1) as usize];
let unpacked = (word >> ((y & 1) * 12 + x * 2)) & 3;
assert_eq!(
unpacked as u8,
xtrans_colour_at((px, py), x, y),
"phase ({px},{py}) at ({x},{y})"
);
}
}
}
}
}
#[test]
fn the_xtrans_phase_is_recovered_from_the_sensor_data() {
// dr-decode reports "X-Trans" and not where the tile starts, and the
// Fujifilm bodies do not agree on that — rawler's database gives four
// different origins. Reading the phase back out of the pixels is the
// only thing standing between a Fuji file and every photosite being
// assigned the wrong filter.
for py in 0..6u32 {
for px in 0..6u32 {
let raw = flat_xtrans((px, py), 36, [9000, 12000, 4000], 0, 16383);
let found = detect_xtrans_phase(&raw);
assert_eq!(
labelling(found),
labelling((px, py)),
"phase ({px},{py}) came back as {found:?}"
);
}
}
}
#[test]
fn white_balance_is_what_tells_the_red_sites_from_the_blue() {
// Shifting the tile by half a tile turns it into itself with red and
// blue exchanged, so nothing about the geometry can choose between the
// two — only the camera's own gains can. This pins that down from both
// sides: with the gains the answer is exact, and without them green is
// still placed correctly while red and blue become a coin toss. If the
// second half ever starts insisting on the right answer too, the
// tie-break has been replaced by something that only looks like it
// works.
let mut raw = flat_xtrans((0, 0), 36, [9000, 12000, 4000], 0, 16383);
assert_eq!(labelling(detect_xtrans_phase(&raw)), labelling((0, 0)));
raw.wb_coeffs = [1.0, 1.0, 1.0, 1.0];
let blind = detect_xtrans_phase(&raw);
for y in 0..6u32 {
for x in 0..6u32 {
assert_eq!(
xtrans_colour_at(blind, x, y) == 1,
xtrans_colour_at((0, 0), x, y) == 1,
"green at ({x},{y}) must not depend on the white balance"
);
}
}
}
#[test]
fn a_flat_xtrans_patch_demosaics_to_that_colour() {
// The same property the Bayer path is held to, and the one that
// catches a wrong tile, a wrong phase, or a colour-difference model
// that fails to cancel: a sensor looking at a uniform colour must
// reconstruct that colour, at every phase and right to the border.
let Some(ctx) = ctx() else { return };
let d = Demosaicer::new(&ctx).expect("demosaicer");
let (black, white) = (1024u16, 16383u16);
let range = f32::from(white - black);
let rgb = [8000u16, 12000, 3000];
let expected = [
f32::from(rgb[0]) / range,
f32::from(rgb[1]) / range,
f32::from(rgb[2]) / range,
];
for phase in [(0, 0), (1, 0), (0, 1), (3, 0), (2, 5), (4, 3)] {
let raw = flat_xtrans(phase, 36, rgb, black, white);
let img = d.run(&raw).expect("demosaic");
let px = read_rgba(&ctx, &img);
let (w, h) = img.size();
for y in 0..h {
for x in 0..w {
let p = px[(y * w + x) as usize];
for (ch, &want) in expected.iter().enumerate() {
assert!(
(p[ch] - want).abs() < 0.01,
"phase {phase:?} pixel ({x},{y}) channel {ch}: \
got {}, want {want}",
p[ch]
);
}
}
}
}
}
#[test]
fn an_xtrans_gradient_carries_no_colour_cast() {
// Why the shader fits a plane rather than averaging each channel's
// neighbours. The three channels are sampled at different places in
// the tile, so a mean compares a red taken slightly to one side of the
// pixel with a green taken slightly to the other. On a flat patch that
// cancels and the test above passes anyway; on a gradient it is a
// colour cast that follows the gradient across the whole frame. A
// plane has no such offset and reconstructs a ramp exactly.
let Some(ctx) = ctx() else { return };
let d = Demosaicer::new(&ctx).expect("demosaicer");
let (black, white) = (1024u16, 16383u16);
let range = f32::from(white - black);
let size = 36u32;
// A different slope per channel and per axis, so a cast in any
// direction shows.
let level = |c: usize, x: u32, y: u32| -> u16 {
match c {
0 => 3000 + 20 * x as u16 + 8 * y as u16,
1 => 6000 + 12 * x as u16 + 24 * y as u16,
_ => 2000 + 30 * x as u16 + 6 * y as u16,
}
};
let phase = (2, 1);
let mut data = vec![0u16; (size * size) as usize];
for y in 0..size {
for x in 0..size {
let c = usize::from(xtrans_colour_at(phase, x, y));
data[(y * size + x) as usize] = black + level(c, x, y);
}
}
let mut raw = flat_xtrans(phase, size, [3000, 6000, 2000], black, white);
raw.data = data;
let img = d.run(&raw).expect("demosaic");
let px = read_rgba(&ctx, &img);
let (w, _) = img.size();
// A two-pixel margin: at the border the window slides inward, and the
// clamp to the local sample range can bite where the pixel sits at the
// edge of its own window.
for y in 2..size - 2 {
for x in 2..size - 2 {
let p = px[(y * w + x) as usize];
for (c, &got) in p.iter().take(3).enumerate() {
let want = f32::from(level(c, x, y)) / range;
assert!(
(got - want).abs() < 0.005,
"pixel ({x},{y}) channel {c}: got {got}, want {want} — \
a ramp must survive the interpolation unbent"
);
}
}
}
}
#[test]
fn an_xtrans_crop_origin_does_not_move_the_tile() {
// The 6×6 tile is anchored to the sensor readout, not to the visible
// frame — which is why CfaPattern::shifted deliberately leaves X-Trans
// alone and says the demosaic handles the offset itself. This is that
// handling. Forget to add the crop origin in either the detector or
// the shader and an active area starting anywhere but a multiple of
// six re-colours the entire image.
let Some(ctx) = ctx() else { return };
let d = Demosaicer::new(&ctx).expect("demosaicer");
let (black, white) = (0u16, 16383u16);
let rgb = [9000u16, 13000, 4000];
let expected = [
f32::from(rgb[0]) / f32::from(white),
f32::from(rgb[1]) / f32::from(white),
f32::from(rgb[2]) / f32::from(white),
];
let mut raw = flat_xtrans((0, 0), 48, rgb, black, white);
// Neither offset is a multiple of six, so the visible top-left is a
// different filter than the sensor's own origin.
raw.crop = CropRect {
x: 5,
y: 7,
width: 34,
height: 34,
};
let img = d.run(&raw).expect("demosaic");
let px = read_rgba(&ctx, &img);
let (w, h) = img.size();
for y in 0..h {
for x in 0..w {
let p = px[(y * w + x) as usize];
for (ch, &want) in expected.iter().enumerate() {
assert!(
(p[ch] - want).abs() < 0.01,
"pixel ({x},{y}) channel {ch}: got {}, want {want}",
p[ch]
);
}
}
}
}
#[test]
fn xtrans_output_is_free_of_nan_and_negatives() {
// The same hazard as the Bayer path, reached by a different route: an
// f16 NaN propagates silently through every later stage, and a
// negative value breaks the ratio-based operations downstream. Here
// the risk is the plane solve — a near-singular window divides by a
// determinant close to zero — so the input is the noisiest thing a
// sensor can produce.
let Some(ctx) = ctx() else { return };
let d = Demosaicer::new(&ctx).expect("demosaicer");
let size = 36u32;
let mut data = vec![0u16; (size * size) as usize];
for y in 0..size {
for x in 0..size {
data[(y * size + x) as usize] = if (x / 2 + y / 2) % 2 == 0 { 16000 } else { 40 };
}
}
let mut raw = flat_xtrans((0, 0), size, [100, 100, 100], 32, 16383);
raw.data = data;
let img = d.run(&raw).expect("demosaic");
for (i, p) in read_rgba(&ctx, &img).iter().enumerate() {
for (ch, v) in p.iter().take(3).enumerate() {
assert!(v.is_finite(), "pixel {i} channel {ch} is {v}");
assert!(*v >= 0.0, "pixel {i} channel {ch} is negative: {v}");
}
}
}
#[test]
fn an_xtrans_black_level_is_a_single_sensor_wide_value() {
// The four-value black level is a 2×2 convention. Indexing it by
// position on a 6×6 tile would lift or crush a fifth of the
// photosites, so the X-Trans path averages instead — which for the
// broadcast case every Fujifilm body produces is exactly the level.
let mut raw = raw_for([1024, 0, 0, 0], 16383);
raw.cfa_pattern = CfaPattern::XTrans;
let (black, inv_range) = xtrans_levels(&raw);
assert_eq!(black, 1024.0);
assert!((f32::from(16383u16 - 1024) * inv_range - 1.0).abs() < 1e-5);
}
}