A linear DNG in and out: the writer, and a three-sample RawImage
dr-export gains write_linear_dng — LinearRaw, DNG 1.4, u16 samples at the sensor's scale, the body's matrices with their illuminants, the as-shot neutral, the EXIF block an export writes — streamed strip by strip through a closure so the composite is never held (FR-MRG-11). The tiff crate's directory is a map, so PhotometricInterpretation is written over what new_image set, which is the trick the S15.1 spike thought it had to hand-roll around. The test reads the file back through rawler. dr-decode's RawImage carries samples_per_pixel (a linear DNG is 3), the body's profile with its calibrations mapped back to EXIF illuminant codes, and the cleaned make and model. The GPU uploads a three-sample image as it is, normalised by black and white like a photosite, through a full f16 conversion — subnormals kept, because a 14-bit LSB sits at f16's smallest normal and rounding it to zero would crush exactly the shadows the file was written to keep.
This commit is contained in:
@@ -250,6 +250,135 @@ impl DemosaicedImage {
|
||||
}
|
||||
}
|
||||
|
||||
impl DemosaicedImage {
|
||||
/// TRACES: FR-MRG-3
|
||||
/// A source that is already RGB in camera space: a linear DNG, which is
|
||||
/// what a merge writes. No demosaic; the samples are normalised by the
|
||||
/// file's black and white levels exactly as the demosaic kernel would
|
||||
/// normalise a photosite, and everything else — the matrix, the
|
||||
/// balance, the body's base curve — is carried through as for a CFA
|
||||
/// file, because the composite is developed as one photograph from the
|
||||
/// body that took its sources.
|
||||
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> {
|
||||
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||
let limits = ctx.device.limits();
|
||||
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
|
||||
return Err(GpuError::TooLarge(format!(
|
||||
"{width}×{height} exceeds the device limit of {}",
|
||||
limits.max_texture_dimension_2d
|
||||
)));
|
||||
}
|
||||
let stride = raw.width as usize * 3;
|
||||
let expected = raw.height as usize * stride;
|
||||
if raw.data.len() < expected {
|
||||
return Err(GpuError::TooLarge(format!(
|
||||
"{} samples is short of the {expected} a {}×{} RGB image needs",
|
||||
raw.data.len(),
|
||||
raw.width,
|
||||
raw.height
|
||||
)));
|
||||
}
|
||||
let black = black_per_cell(raw);
|
||||
let inv = inv_range_per_cell(raw);
|
||||
// Per channel rather than per CFA cell: R, G, B are the first three.
|
||||
let mut half: Vec<u16> = Vec::with_capacity((width * height * 4) as usize);
|
||||
for y in 0..height as usize {
|
||||
let row = (raw.crop.y as usize + y) * stride + raw.crop.x as usize * 3;
|
||||
for x in 0..width as usize {
|
||||
let p = &raw.data[row + x * 3..row + x * 3 + 3];
|
||||
for c in 0..3 {
|
||||
let v = (f32::from(p[c]) - black[c]) * inv[c];
|
||||
half.push(f32_to_f16_bits_unclamped(v));
|
||||
}
|
||||
half.push(f32_to_f16_bits(1.0));
|
||||
}
|
||||
}
|
||||
let texture = ctx.device.create_texture_with_data(
|
||||
&ctx.queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("linear-rgb-source"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Self::FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
bytemuck::cast_slice(&half),
|
||||
);
|
||||
let view = texture.create_view(&Default::default());
|
||||
Ok(Self {
|
||||
texture,
|
||||
view,
|
||||
width,
|
||||
height,
|
||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||
base_curve: raw.base_curve,
|
||||
non_linear: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an f32 to half-precision bits, the general case: sign,
|
||||
/// subnormals, round-to-nearest-even, saturation at the largest finite.
|
||||
///
|
||||
/// `f32_to_f16_bits` below is the 8-bit special case and says why it can
|
||||
/// be; this one exists because a linear DNG is not that case. A 14-bit
|
||||
/// sensor's least significant step, normalised, is 6.1e-5 — right at f16's
|
||||
/// smallest normal (6.1e-5) — so the deepest shadows of a composite land
|
||||
/// in the subnormal range, and rounding them to zero would crush the
|
||||
/// shadows of exactly the file that was written to keep them. Values below
|
||||
/// zero (black subtraction on a noisy photosite) and above one (a highlight
|
||||
/// past the white level) are legitimate and kept.
|
||||
fn f32_to_f16_bits_unclamped(v: f32) -> u16 {
|
||||
let bits = v.to_bits();
|
||||
let sign = ((bits >> 16) & 0x8000) as u16;
|
||||
let exp = ((bits >> 23) & 0xFF) as i32;
|
||||
let mant = bits & 0x7F_FFFF;
|
||||
if exp == 0xFF {
|
||||
// Infinity or NaN: a NaN sample is a decode fault; store the largest
|
||||
// finite rather than propagate it through a blend.
|
||||
return sign | 0x7BFF;
|
||||
}
|
||||
let e = exp - 127 + 15;
|
||||
if e >= 0x1F {
|
||||
return sign | 0x7BFF;
|
||||
}
|
||||
if e <= 0 {
|
||||
// Subnormal in f16 (or underflow). Shift the full mantissa with its
|
||||
// implicit bit right by the deficit, rounding to nearest even.
|
||||
if e < -10 {
|
||||
return sign;
|
||||
}
|
||||
let m = (mant | 0x80_0000) >> (1 - e);
|
||||
let shift = 13;
|
||||
let rounded = round_shift(m, shift);
|
||||
return sign | rounded as u16;
|
||||
}
|
||||
let rounded = round_shift(mant, 13);
|
||||
// Rounding can carry into the exponent; that is correct.
|
||||
sign | (((e as u32) << 10) + rounded) as u16
|
||||
}
|
||||
|
||||
/// `v >> shift`, rounded to nearest with ties to even.
|
||||
fn round_shift(v: u32, shift: u32) -> u32 {
|
||||
let half = 1u32 << (shift - 1);
|
||||
let mask = (1u32 << shift) - 1;
|
||||
let low = v & mask;
|
||||
let mut out = v >> shift;
|
||||
if low > half || (low == half && (out & 1) == 1) {
|
||||
out += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert an f32 to IEEE 754 half-precision bits.
|
||||
///
|
||||
/// Written out rather than pulled in as a dependency: the inputs here are
|
||||
@@ -389,6 +518,9 @@ impl Demosaicer {
|
||||
/// `RawImage`; which of the two CFA families it came off is this
|
||||
/// function's problem, not theirs.
|
||||
pub fn run(&self, raw: &RawImage) -> Result<DemosaicedImage, GpuError> {
|
||||
if raw.samples_per_pixel == 3 {
|
||||
return DemosaicedImage::from_linear_rgb16(&self.ctx, raw);
|
||||
}
|
||||
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||
|
||||
let limits = self.ctx.device.limits();
|
||||
@@ -827,6 +959,43 @@ mod tests {
|
||||
use super::*;
|
||||
use dr_decode::CropRect;
|
||||
|
||||
fn f16_to_f32(bits: u16) -> f32 {
|
||||
let sign = if bits & 0x8000 != 0 { -1.0 } else { 1.0 };
|
||||
let e = ((bits >> 10) & 0x1F) as i32;
|
||||
let m = (bits & 0x3FF) as f32;
|
||||
if e == 0 {
|
||||
sign * m * 2f32.powi(-24)
|
||||
} else {
|
||||
sign * (1.0 + m / 1024.0) * 2f32.powi(e - 15)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclamped_half_keeps_shadows_signs_and_highlights() {
|
||||
// A 14-bit LSB, normalised: subnormal in f16, and must not be zero.
|
||||
let lsb = 1.0 / 16383.0;
|
||||
let back = f16_to_f32(f32_to_f16_bits_unclamped(lsb));
|
||||
assert!((back - lsb).abs() / lsb < 0.01, "{back} vs {lsb}");
|
||||
// A quarter of that, still representable.
|
||||
let tiny = lsb / 4.0;
|
||||
let back = f16_to_f32(f32_to_f16_bits_unclamped(tiny));
|
||||
assert!((back - tiny).abs() / tiny < 0.05, "{back} vs {tiny}");
|
||||
// Below zero and above one survive.
|
||||
assert!((f16_to_f32(f32_to_f16_bits_unclamped(-0.01)) + 0.01).abs() < 1e-5);
|
||||
assert!((f16_to_f32(f32_to_f16_bits_unclamped(1.75)) - 1.75).abs() < 1e-3);
|
||||
// Exact values are exact.
|
||||
assert_eq!(f32_to_f16_bits_unclamped(1.0), 0x3C00);
|
||||
assert_eq!(f32_to_f16_bits_unclamped(0.5), 0x3800);
|
||||
assert_eq!(f32_to_f16_bits_unclamped(0.0), 0);
|
||||
// Within one ULP of the clamped one on its domain: that one
|
||||
// truncates the mantissa, this one rounds it.
|
||||
for i in 0..=255 {
|
||||
let v = i as f32 / 255.0;
|
||||
let (a, b) = (f32_to_f16_bits_unclamped(v), f32_to_f16_bits(v));
|
||||
assert!(a.abs_diff(b) <= 1, "{v}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_for(black: [u16; 4], white: u16) -> RawImage {
|
||||
RawImage {
|
||||
width: 4,
|
||||
@@ -838,6 +1007,10 @@ mod tests {
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -950,6 +1123,10 @@ mod tests {
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1196,6 +1373,10 @@ mod tests {
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1277,6 +1458,10 @@ mod tests {
|
||||
],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
@@ -40,6 +40,10 @@ fn flat_raw(level: u16, curve: BaseCurve) -> RawImage {
|
||||
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: curve,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
@@ -46,6 +46,10 @@ fn flat_raw(level: u16) -> RawImage {
|
||||
// leaving a curve here would test the suppression rather than the
|
||||
// film. `dr-pipeline` asserts the suppression on the generated source.
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
Reference in New Issue
Block a user