Find hot photosites without repairing them, and measure a sensor's aging

The hot-pixel pass could only repair: it returned how many photosites it
changed and threw away which. find_hot_pixels runs the same pass and
returns them as sensor coordinates, leaving the frame alone, so a sensor's
defects can be tracked across frames.

sensor_scan prints each frame's candidates, and with --probe reads a list
of coordinates back out of every frame. Run over 53 6D raws from 2015 to
2026, it found 32 persistent defects, 2 in 2015 and 32 by 2026, and showed
what a defect map has to account for: a frame that does not flag a
photosite proves nothing unless its neighbourhood is dark, and the 6D
hides some of its defects itself above ISO 5000. docs/dev/sensor-health.md
records the findings and the design they argue for.
This commit is contained in:
2026-10-04 02:38:48 -04:00
parent 83f0461ce7
commit 8f9e59b9fa
6 changed files with 305 additions and 14 deletions
+117
View File
@@ -0,0 +1,117 @@
//! List each RAW file's hot and dead photosite candidates (docs/dev/sensor-health.md).
//!
//! Reads paths on stdin and prints one JSON line per file: its capture
//! conditions and every photosite [`Demosaicer::find_hot_pixels`] flags, as
//! `[x, y, value, hot]` in sensor coordinates. Which candidates are defects
//! is a question across frames, so this answers nothing on its own.
//!
//! ```sh
//! find ~/Pictures -name '*.CR2' | cargo run --release -p dr-gpu --example sensor_scan
//! ```
use std::io::{BufRead, Write};
use dr_gpu::{Demosaicer, GpuContext};
fn main() {
if let Some(list) = std::env::args().skip_while(|a| a != "--probe").nth(1) {
return probe(&list);
}
let ctx = pollster::block_on(GpuContext::new_headless()).expect("a GPU for the hot-pixel pass");
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
for line in std::io::stdin().lock().lines() {
let path = line.expect("stdin");
match scan(&path, &demosaicer) {
Ok(json) => println!("{json}"),
Err(e) => eprintln!("fail\t{path}\t{e}"),
}
std::io::stdout().flush().ok();
}
}
fn scan(path: &str, demosaicer: &Demosaicer) -> Result<String, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let raw = dr_decode::decode(&bytes).map_err(|e| e.to_string())?;
let meta = dr_decode::metadata(&bytes).map_err(|e| e.to_string())?;
let sites = demosaicer
.find_hot_pixels(&raw)
.map_err(|e| e.to_string())?;
let opt = |v: Option<f64>| v.map_or("null".to_string(), |v| v.to_string());
let list: Vec<String> = sites
.iter()
.map(|s| {
let v = raw.data[(s.y * raw.width + s.x) as usize];
format!("[{},{},{},{}]", s.x, s.y, v, u8::from(s.hot))
})
.collect();
Ok(format!(
"{{\"path\":{:?},\"model\":{:?},\"captured\":{},\"iso\":{},\"shutter\":{},\"white\":{},\"black\":{:?},\"crop\":[{},{},{},{}],\"sites\":[{}]}}",
path,
format!("{} {}", raw.make, raw.model),
meta.captured_at.map_or("null".to_string(), |t| t.to_string()),
opt(meta.iso.map(f64::from)),
opt(meta.shutter.map(f64::from)),
raw.white_level,
raw.black_level,
raw.crop.x,
raw.crop.y,
raw.crop.width,
raw.crop.height,
list.join(","),
))
}
/// `--probe COORDS`: for each path on stdin, each `x y` line of COORDS as
/// `[value, same-colour neighbour max, median]` over black, on the CPU. A
/// probe asks whether a photosite stood out in a frame where it would have
/// been visible, which the scan's verdict cannot say: a frame that does not
/// flag a defect may only have been too bright around it.
fn probe(list: &str) {
let coords: Vec<(u32, u32)> = std::fs::read_to_string(list)
.expect("coords")
.lines()
.filter_map(|l| {
let mut it = l.split_whitespace().map(|v| v.parse().ok());
Some((it.next()??, it.next()??))
})
.collect();
for line in std::io::stdin().lock().lines() {
let path = line.expect("stdin");
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let (Ok(raw), Ok(meta)) = (dr_decode::decode(&bytes), dr_decode::metadata(&bytes)) else {
continue;
};
let w = raw.width as i64;
let at = |x: i64, y: i64| {
let cell = (((y - raw.crop.y as i64) & 1) * 2 + ((x - raw.crop.x as i64) & 1)) as usize;
raw.data[(y * w + x) as usize].saturating_sub(raw.black_level[cell])
};
let rows: Vec<String> = coords
.iter()
.map(|&(x, y)| {
let (x, y) = (x as i64, y as i64);
let mut n: Vec<u16> = Vec::new();
for dy in [-2i64, 0, 2] {
for dx in [-2i64, 0, 2] {
if (dx, dy) != (0, 0) {
n.push(at(x + dx, y + dy));
}
}
}
n.sort_unstable();
format!("[{},{},{}]", at(x, y), n[n.len() - 1], n[n.len() / 2])
})
.collect();
println!(
"{{\"path\":{:?},\"captured\":{},\"iso\":{},\"shutter\":{},\"range\":{},\"p\":[{}]}}",
path,
meta.captured_at.unwrap_or(0),
meta.iso.unwrap_or(0),
meta.shutter.unwrap_or(0.0),
raw.white_level - raw.black_level[0],
rows.join(","),
);
}
}
+61 -9
View File
@@ -1071,6 +1071,46 @@ impl Demosaicer {
if raw.samples_per_pixel != 1 {
return Ok(0);
}
let words = self.hot_pixel_words(raw)?;
let mut changed = 0;
for (i, v) in raw.data.iter_mut().enumerate() {
let new = unpack_sample(&words, i);
changed += usize::from(new != *v);
*v = new;
}
Ok(changed)
}
/// The photosites [`Self::repair_hot_pixels`] would replace, in sensor
/// coordinates, without replacing them.
///
/// For the sensor health record (docs/dev/sensor-health.md): one frame's
/// verdict is a candidate list, not a defect map — a single photosite of a
/// star that passes both tests reads the same as a hot one. Which of them
/// is the sensor is decided across frames, by who keeps coming back.
pub fn find_hot_pixels(&self, raw: &RawImage) -> Result<Vec<Photosite>, GpuError> {
if raw.samples_per_pixel != 1 {
return Ok(Vec::new());
}
let words = self.hot_pixel_words(raw)?;
let stride = raw.width.max(1);
Ok(raw
.data
.iter()
.enumerate()
.filter_map(|(i, &v)| {
let new = unpack_sample(&words, i);
(new != v).then(|| Photosite {
x: i as u32 % stride,
y: i as u32 / stride,
hot: new < v,
})
})
.collect())
}
/// The hot-pixel pass over `raw`, read back as packed words.
fn hot_pixel_words(&self, raw: &RawImage) -> Result<Vec<u32>, GpuError> {
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
let xtrans_tile = raw
.cfa_pattern
@@ -1122,18 +1162,30 @@ impl Demosaicer {
.map_err(|e| GpuError::Readback(e.to_string()))?;
let words: Vec<u32> = bytemuck::cast_slice(&slice.get_mapped_range()).to_vec();
readback.unmap();
let mut changed = 0;
for (i, v) in raw.data.iter_mut().enumerate() {
let w = words[i / 2];
let new = if i % 2 == 0 { w & 0xFFFF } else { w >> 16 } as u16;
changed += usize::from(new != *v);
*v = new;
}
Ok(changed)
Ok(words)
}
}
/// One photosite the hot-pixel pass judged defective.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct Photosite {
/// Sensor coordinates: the full readout, masked border included.
pub x: u32,
pub y: u32,
/// Read far above its neighbourhood; otherwise far below (dead).
pub hot: bool,
}
/// Sample `i` of a readout packed by [`pack_samples`].
fn unpack_sample(words: &[u32], i: usize) -> u16 {
let w = words[i / 2];
(if i.is_multiple_of(2) {
w & 0xFFFF
} else {
w >> 16
}) as u16
}
const IDENTITY_3X3: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
/// Pack u16 samples two per u32, little-endian within the word.
+1 -1
View File
@@ -38,7 +38,7 @@ pub use adjust::AdjustPass;
// rather than an implementation detail: a detail pass is guaranteed linear,
// unclipped, full internal precision (FR-DEV-2), and anyone reasoning about
// VRAM at 24 MP needs to know what an intermediate costs.
pub use demosaic::{DemosaicedImage, Demosaicer};
pub use demosaic::{DemosaicedImage, Demosaicer, Photosite};
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
pub use error::GpuError;
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
+41 -1
View File
@@ -7,7 +7,7 @@
//! see of a defect it missed is the coloured cross the demosaic makes of it.
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, Photosite};
use dr_pipeline::EditGraph;
const SIZE: u32 = 36;
@@ -171,3 +171,43 @@ fn the_repaired_mosaic_reads_back_with_only_the_defect_changed() {
let others = (0..raw.data.len()).filter(|&i| i != at);
assert!(others.into_iter().all(|i| raw.data[i] == before.data[i]));
}
/// Finding without repairing (docs/dev/sensor-health.md): the same verdict as
/// the repair, as sensor coordinates, with the frame left as it was. The
/// sensor health record builds on this, so it must name exactly the
/// photosites the repair would change — the hot one and the dead one, and
/// not the star.
#[test]
fn finding_names_what_the_repair_would_change_and_changes_nothing() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let d = Demosaicer::new(&ctx).expect("demosaicer");
let mut set = vec![(MIDDLE, MIDDLE, WHITE), (9, 25, 0)];
for dy in 0..3 {
for dx in 0..3 {
set.push((4 + dx, 4 + dy, WHITE));
}
}
let raw = frame(CfaPattern::Rggb, 1600, &set);
let mut found = d.find_hot_pixels(&raw).expect("find");
found.sort_by_key(|p| (p.y, p.x));
assert_eq!(
found,
vec![
Photosite {
x: MIDDLE,
y: MIDDLE,
hot: true
},
Photosite {
x: 9,
y: 25,
hot: false
},
]
);
let mut repaired = raw.clone();
assert_eq!(d.repair_hot_pixels(&mut repaired).expect("repair"), 2);
}