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:
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user