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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user