From acab0d7abbb324d1bbad38a8142a1885204e88c1 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 19 Sep 2026 13:54:22 +0200 Subject: [PATCH] A linear DNG in and out: the writer, and a three-sample RawImage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 3 + core/dr-decode/src/lib.rs | 35 +++++ core/dr-decode/src/profile.rs | 44 ++++++ core/dr-export/Cargo.toml | 3 + core/dr-export/src/dng.rs | 257 ++++++++++++++++++++++++++++++++ core/dr-export/src/encode.rs | 10 +- core/dr-export/src/lib.rs | 2 + core/dr-gpu/src/demosaic.rs | 185 +++++++++++++++++++++++ core/dr-gpu/tests/base_curve.rs | 4 + core/dr-gpu/tests/film_sim.rs | 4 + 10 files changed, 542 insertions(+), 5 deletions(-) create mode 100644 core/dr-export/src/dng.rs diff --git a/Cargo.lock b/Cargo.lock index dac2564..490835d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,6 +1465,7 @@ dependencies = [ "log", "png", "pollster", + "rawler", "thiserror 2.0.20", "tiff", "zune-jpeg 0.4.21", @@ -1499,6 +1500,7 @@ dependencies = [ "bytemuck", "dr-decode", "dr-film", + "dr-pano", "dr-pipeline", "dr-segment", "dr-types", @@ -1674,6 +1676,7 @@ dependencies = [ "dr-gpu", "dr-ingest", "dr-lens", + "dr-pano", "dr-pipeline", "dr-plat", "dr-preset-xmp", diff --git a/core/dr-decode/src/lib.rs b/core/dr-decode/src/lib.rs index 64411eb..69e5311 100644 --- a/core/dr-decode/src/lib.rs +++ b/core/dr-decode/src/lib.rs @@ -134,6 +134,22 @@ pub struct RawImage { pub base_curve: BaseCurve, /// The usable region of `data`, excluding masked and border photosites. pub crop: CropRect, + /// TRACES: FR-MRG-3 + /// Samples per photosite in `data`: 1 for a colour-filter-array capture, + /// 3 for a *linear* DNG — demosaiced RGB, still camera-space, which is + /// what a merge writes. With 3, `cfa_pattern` means nothing, `data` is + /// `width × height × 3` interleaved, and the GPU uploads it as it is + /// rather than demosaicing. + pub samples_per_pixel: u8, + /// TRACES: FR-MRG-3 + /// The body's colour profile as the file carried it, for a composite to + /// carry on: calibrations and the as-shot neutral. `None` for a body the + /// decoder has no matrix for. + pub profile: Option, + /// The body, as rawler cleans the names: what `Make`/`Model` say and what + /// the base-curve database matches on. + pub make: String, + pub model: String, } /// TRACES: FR-RAW-3 @@ -559,6 +575,21 @@ fn decode_unguarded(bytes: &[u8]) -> Result { image.camera.clean_model.as_str(), ); + // TRACES: FR-MRG-3 + // A linear DNG — three samples per pixel, no colour filter array — is a + // composite this application wrote (or any other demosaiced DNG). It + // carries the same scale, matrices and neutral as a CFA file and goes + // through the same profile; only the demosaic is skipped. + let samples_per_pixel = match image.cpp { + 1 => 1u8, + 3 => 3, + other => { + return Err(DecodeError::Unsupported(format!( + "{other} samples per pixel; only CFA (1) and linear RGB (3) are handled" + ))) + } + }; + let data = match image.data { rawler::RawImageData::Integer(v) => v, rawler::RawImageData::Float(v) => { @@ -627,6 +658,10 @@ fn decode_unguarded(bytes: &[u8]) -> Result { wb_coeffs, color_matrix, base_curve, + samples_per_pixel, + profile, + make: image.camera.clean_make.clone(), + model: image.camera.clean_model.clone(), }) } diff --git a/core/dr-decode/src/profile.rs b/core/dr-decode/src/profile.rs index e8fcb18..2235c04 100644 --- a/core/dr-decode/src/profile.rs +++ b/core/dr-decode/src/profile.rs @@ -392,6 +392,21 @@ impl CameraProfile { } /// The calibrations this profile was built from, coolest first. + /// TRACES: FR-MRG-3 + /// The calibrations as a DNG carries them: `(CalibrationIlluminant, + /// ColorMatrix)` with the EXIF light-source code, for a composite to + /// write the profile of the body that took its sources. + /// + /// The code is recovered from the temperature, which is lossy only for + /// illuminants this profile never kept: `extract` drops calibrations + /// whose illuminant has no temperature, so every one here maps back. + pub fn dng_calibrations(&self) -> Vec<(u16, [[f32; 3]; 3])> { + self.calibrations + .iter() + .map(|c| (illuminant_code(c.temperature), c.xyz_to_cam)) + .collect() + } + pub fn calibrations(&self) -> &[Calibration] { &self.calibrations } @@ -528,6 +543,35 @@ fn illuminant_temperature(illuminant: Illuminant) -> Option { }) } +/// The EXIF `LightSource` code for a calibration temperature — the inverse +/// of [`illuminant_temperature`], on the temperatures it produces. +fn illuminant_code(temperature: f32) -> u16 { + // Nearest of the table, so a temperature that came through a float + // round-trip still lands on its illuminant. Where two illuminants share + // a temperature (D55 and Daylight, D65 and Cloudy, D75 and Shade) the + // CIE standard one is written: it is what every profile database means. + const TABLE: &[(f32, u16)] = &[ + (2856.0, 17), // A + (3200.0, 24), // ISO studio tungsten + (3500.0, 15), // white fluorescent + (4150.0, 14), // cool white fluorescent + (4230.0, 2), // fluorescent + (4874.0, 18), // B + (5000.0, 13), // daylight white fluorescent + (5003.0, 23), // D50 + (5503.0, 20), // D55 + (6430.0, 12), // daylight fluorescent + (6504.0, 21), // D65 + (6774.0, 19), // C + (7504.0, 22), // D75 + ]; + TABLE + .iter() + .min_by(|a, b| (a.0 - temperature).abs().total_cmp(&(b.0 - temperature).abs())) + .map(|(_, code)| *code) + .unwrap_or(255) +} + /// Compose a forward matrix into camera RGB → linear sRGB. /// /// `forward` takes white-balanced camera RGB to XYZ under D50, which is the diff --git a/core/dr-export/Cargo.toml b/core/dr-export/Cargo.toml index bcadf91..a438248 100644 --- a/core/dr-export/Cargo.toml +++ b/core/dr-export/Cargo.toml @@ -38,4 +38,7 @@ dr-gpu.workspace = true dr-pipeline.workspace = true env_logger.workspace = true pollster.workspace = true +# The DNG writer's test reads its output back through the decoder the +# library uses, which is the whole claim the writer makes (S15.1). +rawler.workspace = true zune-jpeg.workspace = true diff --git a/core/dr-export/src/dng.rs b/core/dr-export/src/dng.rs new file mode 100644 index 0000000..2951c55 --- /dev/null +++ b/core/dr-export/src/dng.rs @@ -0,0 +1,257 @@ +//! TRACES: FR-MRG-3 +//! A linear DNG: the container a merge writes its composite into. +//! +//! Decided by S15.1 (2026-09-19): rawler reads back a `LinearRaw` DNG the +//! application writes, so a composite re-enters the library as +//! `Format::Dng` through the decoder every camera DNG uses. What is written +//! is a RAW in every sense a warp can preserve — camera-linear `u16` +//! samples at the first source's own scale, its matrices, illuminants, +//! as-shot neutral and body name — so the panorama is developed afterwards +//! as one photograph, from the sensor's numbers. +//! +//! # Streamed, not buffered +//! +//! The composite is larger than any single photograph the pipeline renders +//! and larger than the tablet's memory (FR-MRG-11), so the writer never +//! holds it. Strips are pulled from the caller one at a time through a +//! closure, in order, and written as they arrive; the caller renders a band +//! of chunks, hands over its rows, and moves on. +//! +//! # Why the `tiff` crate after all +//! +//! S15.1's spike hand-rolled its IFD because the crate's encoder fixes +//! `PhotometricInterpretation` to RGB when the image is opened. It does — but +//! a directory is a map and a later `write_tag` on the same tag replaces the +//! earlier, so `LinearRaw` goes in over the top and everything else the +//! crate does (strips, offsets, sub-IFDs, the EXIF block `encode.rs` already +//! knows how to write) is kept. + +use std::io::{Seek, Write}; + +use tiff::encoder::{colortype, DirectoryEncoder, SRational, TiffEncoder, TiffKind, TiffValue}; +use tiff::tags::Tag; + +use crate::encode::{sub_directories, tag_metadata, Ascii, Rationals}; +use crate::{ExportError, SourceMetadata}; + +/// What the DNG says about the camera that "took" the composite: the first +/// source's profile, carried across so the composite develops through it. +#[derive(Debug, Clone, PartialEq)] +pub struct DngProfile { + /// `UniqueCameraModel`, the name the profile database matches on. + pub unique_model: String, + /// `(CalibrationIlluminant, ColorMatrix)`: the EXIF light-source code and + /// the XYZ → camera matrix measured under it. One or two. + pub calibrations: Vec<(u16, [[f32; 3]; 3])>, + /// `AsShotNeutral`, camera RGB of the scene's white. + pub as_shot_neutral: [f32; 3], + /// `WhiteLevel`: the sample value that is clipping. The first source's + /// white minus its black, since the samples are black-subtracted. + pub white_level: u32, +} + +/// Write a linear DNG, pulling `rows_per_strip`-row strips from `strips`. +/// +/// Each call to `strips` receives the strip index and a buffer to fill with +/// `width × rows × 3` interleaved RGB `u16` samples (the last strip may be +/// shorter). `source` supplies the `Make`, `Model`, dates and EXIF block +/// exactly as an export does (FR-EXP-8 sanitising already applied by the +/// caller). +/// +/// `PhotometricInterpretation = LinearRaw`, `DNGVersion 1.4`, uncompressed, +/// `Orientation = 1` — the composite is written upright (panorama.md §8). +pub fn write_linear_dng( + out: W, + width: u32, + height: u32, + rows_per_strip: u32, + profile: &DngProfile, + source: Option<&SourceMetadata>, + mut strips: F, +) -> Result<(), ExportError> +where + W: Write + Seek, + F: FnMut(usize, &mut Vec) -> Result<(), ExportError>, +{ + let enc = |e: tiff::TiffError| ExportError::Encode(e.to_string()); + let mut encoder = TiffEncoder::new(out).map_err(enc)?; + let sub = sub_directories(&mut encoder, source, width, height)?; + let mut image = encoder + .new_image::(width, height) + .map_err(enc)?; + image.rows_per_strip(rows_per_strip.max(1)).map_err(enc)?; + tag_metadata(image.encoder(), source, &sub)?; + tag_dng(image.encoder(), profile).map_err(enc)?; + + let rows = rows_per_strip.max(1); + let strip_count = height.div_ceil(rows) as usize; + let mut buf: Vec = Vec::with_capacity((width * rows * 3) as usize); + for k in 0..strip_count { + buf.clear(); + strips(k, &mut buf)?; + let expected_rows = rows.min(height - k as u32 * rows); + let expected = (width * expected_rows * 3) as usize; + if buf.len() != expected { + return Err(ExportError::Encode(format!( + "strip {k} has {} samples, expected {expected}", + buf.len() + ))); + } + image.write_strip(&buf).map_err(enc)?; + } + image.finish().map_err(enc) +} + +/// The tags that make a TIFF a DNG, and a linear one. +fn tag_dng(dir: &mut DirectoryEncoder<'_, W, K>, profile: &DngProfile) -> tiff::TiffResult<()> +where + W: Write + Seek, + K: TiffKind, +{ + // Over the top of what `new_image` wrote: this is the whole trick. + dir.write_tag(Tag::PhotometricInterpretation, LINEAR_RAW)?; + dir.write_tag(Tag::Orientation, 1u16)?; + dir.write_tag(Tag::Unknown(tag::DNG_VERSION), &[1u8, 4, 0, 0][..])?; + dir.write_tag(Tag::Unknown(tag::DNG_BACKWARD_VERSION), &[1u8, 4, 0, 0][..])?; + dir.write_tag(Tag::Unknown(tag::UNIQUE_CAMERA_MODEL), Ascii(&profile.unique_model))?; + dir.write_tag(Tag::Unknown(tag::WHITE_LEVEL), &[profile.white_level; 3][..])?; + dir.write_tag(Tag::Unknown(tag::BLACK_LEVEL), &[0u32; 3][..])?; + + for (slot, (illuminant, matrix)) in profile.calibrations.iter().take(2).enumerate() { + let (ill_tag, mat_tag) = if slot == 0 { + (tag::CALIBRATION_ILLUMINANT_1, tag::COLOR_MATRIX_1) + } else { + (tag::CALIBRATION_ILLUMINANT_2, tag::COLOR_MATRIX_2) + }; + dir.write_tag(Tag::Unknown(ill_tag), *illuminant)?; + let flat: Vec = matrix + .iter() + .flatten() + .map(|&v| SRational { + n: (v * 10_000.0).round() as i32, + d: 10_000, + }) + .collect(); + dir.write_tag(Tag::Unknown(mat_tag), SRationals(&flat))?; + } + + let neutral: Vec<(u32, u32)> = profile + .as_shot_neutral + .iter() + .map(|&v| ((v.max(0.0) * 1_000_000.0).round() as u32, 1_000_000)) + .collect(); + dir.write_tag(Tag::Unknown(tag::AS_SHOT_NEUTRAL), Rationals(&neutral))?; + Ok(()) +} + +/// `PhotometricInterpretation` for demosaiced, un-rendered sensor data. +const LINEAR_RAW: u16 = 34892; + +/// DNG tag numbers the `tiff` crate has no names for. +mod tag { + pub const DNG_VERSION: u16 = 50706; + pub const DNG_BACKWARD_VERSION: u16 = 50707; + pub const UNIQUE_CAMERA_MODEL: u16 = 50708; + pub const BLACK_LEVEL: u16 = 50714; + pub const WHITE_LEVEL: u16 = 50717; + pub const COLOR_MATRIX_1: u16 = 50721; + pub const COLOR_MATRIX_2: u16 = 50722; + pub const AS_SHOT_NEUTRAL: u16 = 50728; + pub const CALIBRATION_ILLUMINANT_1: u16 = 50778; + pub const CALIBRATION_ILLUMINANT_2: u16 = 50779; +} + +/// A run of `SRATIONAL`s, as `encode::Rationals` is for `RATIONAL`. +struct SRationals<'a>(&'a [SRational]); + +impl TiffValue for SRationals<'_> { + const BYTE_LEN: u8 = 8; + const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::SRATIONAL; + + fn count(&self) -> usize { + self.0.len() + } + + fn data(&self) -> std::borrow::Cow<'_, [u8]> { + let mut out = Vec::with_capacity(self.0.len() * 8); + for r in self.0 { + out.extend_from_slice(&r.n.to_ne_bytes()); + out.extend_from_slice(&r.d.to_ne_bytes()); + } + std::borrow::Cow::Owned(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn profile() -> DngProfile { + DngProfile { + unique_model: "Canon EOS 6D".into(), + calibrations: vec![ + (17, [[0.8, -0.2, 0.1], [-0.3, 1.1, 0.2], [0.0, -0.1, 0.9]]), + (21, [[0.7, -0.1, 0.0], [-0.2, 1.0, 0.1], [0.0, -0.2, 0.8]]), + ], + as_shot_neutral: [0.5, 1.0, 0.6], + white_level: 13_023, + } + } + + fn write(width: u32, height: u32, rows: u32) -> Vec { + let mut bytes = std::io::Cursor::new(Vec::new()); + let source = SourceMetadata { + make: Some("Canon".into()), + model: Some("Canon EOS 6D".into()), + ..Default::default() + }; + write_linear_dng(&mut bytes, width, height, rows, &profile(), Some(&source), |k, buf| { + let first = k as u32 * rows; + let n = rows.min(height - first); + for y in first..first + n { + for x in 0..width { + buf.extend([(x + y * width) as u16, 1000, 2000]); + } + } + Ok(()) + }) + .expect("written"); + bytes.into_inner() + } + + #[test] + fn rawler_reads_it_back_as_linear_raw() { + let bytes = write(20, 13, 4); + let source = rawler::rawsource::RawSource::new_from_slice(&bytes); + let decoder = rawler::get_decoder(&source).expect("a DNG"); + let image = decoder + .raw_image(&source, &Default::default(), false) + .expect("decodes"); + assert_eq!((image.width, image.height, image.cpp), (20, 13, 3)); + assert_eq!(image.whitelevel.0[0], 13_023); + // Pixel (3, 2) is (3 + 2·20, 1000, 2000) — samples in order, strips + // joined without a seam. + let rawler::RawImageData::Integer(data) = &image.data else { + panic!("integer samples") + }; + let i = (2 * 20 + 3) * 3; + assert_eq!(&data[i..i + 3], &[43, 1000, 2000]); + // Last row, from the short final strip. + let i = (12 * 20 + 19) * 3; + assert_eq!(data[i], (19 + 12 * 20) as u16); + // The profile came through as the camera's. + assert!(image.camera.color_matrix.len() >= 1); + assert_eq!(image.model, "Canon EOS 6D"); + } + + #[test] + fn a_strip_of_the_wrong_length_is_refused() { + let mut bytes = std::io::Cursor::new(Vec::new()); + let err = write_linear_dng(&mut bytes, 8, 8, 8, &profile(), None, |_, buf| { + buf.extend([0u16; 10]); + Ok(()) + }) + .unwrap_err(); + assert!(matches!(err, ExportError::Encode(_))); + } +} diff --git a/core/dr-export/src/encode.rs b/core/dr-export/src/encode.rs index ac0a31a..61a27f4 100644 --- a/core/dr-export/src/encode.rs +++ b/core/dr-export/src/encode.rs @@ -213,7 +213,7 @@ impl tiff::encoder::TiffValue for Undefined<'_> { /// specification says, `dr-decode` reads them back with `from_utf8_lossy`, and /// a mangled accent is a far better outcome than a refusal. So the bytes go /// through verbatim with the terminating NUL the type requires. -struct Ascii<'a>(&'a str); +pub(crate) struct Ascii<'a>(pub(crate) &'a str); impl tiff::encoder::TiffValue for Ascii<'_> { const BYTE_LEN: u8 = 1; @@ -241,7 +241,7 @@ impl tiff::encoder::TiffValue for Ascii<'_> { /// a value that forced little-endian would be read back byte-swapped on a /// big-endian machine. `exif.rs` builds its own header and so chooses its own /// order; here the container has already chosen. -struct Rationals<'a>(&'a [(u32, u32)]); +pub(crate) struct Rationals<'a>(pub(crate) &'a [(u32, u32)]); impl tiff::encoder::TiffValue for Rationals<'_> { const BYTE_LEN: u8 = 8; @@ -317,7 +317,7 @@ where /// and then no pointer is written either, so the file has no trace of the /// directory rather than a pointer to an empty one. #[derive(Default)] -struct SubDirectories { +pub(crate) struct SubDirectories { exif: Option, gps: Option, } @@ -335,7 +335,7 @@ struct SubDirectories { /// A TIFF gets no separate EXIF *block* — no APP1, no `eXIf` chunk. Its own /// directory is the EXIF structure, and adding a second copy inside it would /// give a reader two answers to every question. -fn sub_directories( +pub(crate) fn sub_directories( encoder: &mut tiff::encoder::TiffEncoder, source: Option<&SourceMetadata>, width: u32, @@ -465,7 +465,7 @@ where /// /// No `Orientation`, for the reason `exif.rs` gives at length: the pixels /// arriving here are already upright. -fn tag_metadata( +pub(crate) fn tag_metadata( dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>, source: Option<&SourceMetadata>, sub: &SubDirectories, diff --git a/core/dr-export/src/lib.rs b/core/dr-export/src/lib.rs index a406101..6a21c08 100644 --- a/core/dr-export/src/lib.rs +++ b/core/dr-export/src/lib.rs @@ -24,6 +24,7 @@ use dr_types::{ColourSpace, ExportFormat, ExportSettings}; +mod dng; mod encode; mod error; mod exif; @@ -33,6 +34,7 @@ mod name; mod sharpen; mod size; +pub use dng::{write_linear_dng, DngProfile}; pub use error::ExportError; pub use metadata::SourceMetadata; pub use name::{resolve_name, NameContext}; diff --git a/core/dr-gpu/src/demosaic.rs b/core/dr-gpu/src/demosaic.rs index 265424f..cc36dfe 100644 --- a/core/dr-gpu/src/demosaic.rs +++ b/core/dr-gpu/src/demosaic.rs @@ -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 { + 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 = 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 { + 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, diff --git a/core/dr-gpu/tests/base_curve.rs b/core/dr-gpu/tests/base_curve.rs index e0349e1..a77379b 100644 --- a/core/dr-gpu/tests/base_curve.rs +++ b/core/dr-gpu/tests/base_curve.rs @@ -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, diff --git a/core/dr-gpu/tests/film_sim.rs b/core/dr-gpu/tests/film_sim.rs index 49fda1a..2c2349f 100644 --- a/core/dr-gpu/tests/film_sim.rs +++ b/core/dr-gpu/tests/film_sim.rs @@ -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,