The base curve was a five-point spline on the unit square, flat past its last point: every value above 1.0 left it as the same number, per channel. Exposure and highlight recovery put values up there, and the curve threw them away, then handed the result on as though it were still scene-linear. The six per-body curves were also, by their own file's account, hand-tuned shapes rather than measurements, and not enough is known about where they came from to keep them (D19). In their place, one view transform for every body (FR-DEV-3j): a log-logistic sigmoid per channel, with the middle channel put back between the other two so a hue survives the shoulder. Its two free constants are solved from two conditions rather than set: scene grey 0.13, where the retired default curve put it, lands on display 0.18, and the scene white four stops above grey lands on 1.0. So a highlight a stop past sensor saturation still rolls into white, and the midtones stay within 0.26 EV of the retired default between scene 0.03 and 1.0. `dr_pipeline::view` holds the CPU reference and the WGSL, and the tests there are FR-DEV-3j's acceptance criteria. It is still fixed and still in the fused pass's tail, so a detail stage still sees rendered values; the next commits make it an operation and move it after the detail stage. It is skipped for a JPEG, as the base curve was, and absent from the camera-space tap. The base curve's database, its lookup and its twelve uniform slots go. `RawImage` and `DemosaicedImage` lose the field, and the GPU test that proved a curve reached the shader is replaced by one that renders the view transform against the CPU reference and shows two highlights above 1.0 still render apart. The JPEG-and-sensor test now asserts the two differ by exactly the view transform, where before an identity fixture curve had made them match.
142 lines
4.5 KiB
Rust
142 lines
4.5 KiB
Rust
//! 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::{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]),
|
||
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}");
|
||
}
|