Repair hot and dead photosites before the demosaic

A hot photosite went into the demosaic as it was read, and came out as a
coloured cross three pixels wide that nothing later could take back
out. Night and long exposures showed them; the defect-map reader added
for FR-RAW-3 was never wired in, and a CR2 carries no map anyway.

A pass over the mosaic now runs ahead of the demosaic, into a second
buffer. A photosite is hot when it reads more than twice every
same-colour photosite in its 5x5 window plus 2% of the range, and more
than twice each of its eight immediate neighbours of any colour. The
second half keeps stars and glints: real light reaches the sensor
through a lens and an anti-aliasing filter and lights a patch, so the
photosites beside it are lit too, where a hot photosite's are dark. It
is replaced by its brightest same-colour neighbour, which invents
nothing. Dead photosites are the mirror case, judged only where the
neighbourhood is above 5%, so shadow noise clipped at black is left
alone.

The colour of each photosite comes from a 6x6 sensor-anchored tile, so
Bayer and X-Trans share the pass. Export and every other path that
demosaics get it too, and there is no setting: the repair only fires
where a single photosite disagrees with everything around it.

Cost, warm, on a Canon 6D frame (RTX 3050): 91-99 ms to demosaic
before, 94-98 ms after; the extra pass is inside the run-to-run noise.

Tests render a frame with and without the defect and compare the
finished pixels. Without the repair a hot photosite showed by 230 and a
dead one by 168; with it neither shows, and a 3x3 highlight at white
survives.
This commit is contained in:
2026-09-27 06:20:23 -04:00
parent 6b99f67f47
commit c50d96e949
4 changed files with 588 additions and 7 deletions
+142
View File
@@ -0,0 +1,142 @@
//! TRACES: FR-RAW-3
//! Hot and dead photosite repair, end to end on a device.
//!
//! Each test renders a frame twice — once with a defect, once without — and
//! compares the finished pixels. That is the only comparison that means
//! anything: the repair happens on the mosaic, and what a photographer would
//! see of a defect it missed is the coloured cross the demosaic makes of it.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::EditGraph;
const SIZE: u32 = 36;
const WHITE: u16 = 4095;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat frame at `level`, with `set` applied to its photosites.
fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
let mut data = vec![level; (SIZE * SIZE) as usize];
for &(x, y, v) in set {
data[(y * SIZE + x) as usize] = v;
}
RawImage {
width: SIZE,
height: SIZE,
data,
cfa_pattern: pattern,
black_level: [0; 4],
white_level: WHITE,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
fn render(ctx: &GpuContext, raw: &RawImage) -> Vec<u8> {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(raw)
.expect("demosaic");
let shader = EditGraph::default_chain().compose();
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
adjust.export_pixels().expect("readback").0
}
/// The largest channel difference between two renders.
fn worst(a: &[u8], b: &[u8]) -> u8 {
a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap()
}
const MIDDLE: u32 = SIZE / 2;
/// **The feature.** A photosite at white in a dark frame — a hot pixel in a
/// night sky — leaves no trace in the rendered picture.
#[test]
fn a_hot_photosite_in_a_dark_frame_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[]));
for (x, y) in [
(MIDDLE, MIDDLE),
(MIDDLE + 1, MIDDLE),
(MIDDLE + 1, MIDDLE + 1),
] {
let hot = render(&ctx, &frame(CfaPattern::Rggb, 40, &[(x, y, WHITE)]));
let diff = worst(&clean, &hot);
assert!(
diff <= 1,
"a hot photosite at ({x}, {y}) still shows, by {diff}"
);
}
}
/// The same for one stuck dark in a lit area.
#[test]
fn a_dead_photosite_in_a_lit_frame_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[]));
let dead = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[(MIDDLE, MIDDLE, 0)]));
let diff = worst(&clean, &dead);
assert!(diff <= 1, "a dead photosite still shows, by {diff}");
}
/// **What it must not eat.** A point of real light lands on a patch of
/// photosites, not one — so a 3×3 highlight survives, even at its brightest.
#[test]
fn a_small_real_highlight_survives() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut star = Vec::new();
for dy in 0..3 {
for dx in 0..3 {
star.push((MIDDLE - 1 + dx, MIDDLE - 1 + dy, WHITE));
}
}
let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[]));
let lit = render(&ctx, &frame(CfaPattern::Rggb, 40, &star));
let at = ((MIDDLE * SIZE + MIDDLE) * 4 + 1) as usize;
assert!(
lit[at] > clean[at] + 100,
"the highlight was repaired away: {} against a background of {}",
lit[at],
clean[at]
);
}
/// The Fujifilm path goes through the same repair, with its own tile.
#[test]
fn a_hot_photosite_on_x_trans_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::XTrans, 40, &[]));
let hot = render(
&ctx,
&frame(CfaPattern::XTrans, 40, &[(MIDDLE, MIDDLE, WHITE)]),
);
let diff = worst(&clean, &hot);
assert!(diff <= 1, "a hot X-Trans photosite still shows, by {diff}");
}