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
+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.