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:
Generated
+3
@@ -1465,6 +1465,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"png",
|
"png",
|
||||||
"pollster",
|
"pollster",
|
||||||
|
"rawler",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tiff",
|
"tiff",
|
||||||
"zune-jpeg 0.4.21",
|
"zune-jpeg 0.4.21",
|
||||||
@@ -1499,6 +1500,7 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-film",
|
"dr-film",
|
||||||
|
"dr-pano",
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"dr-segment",
|
"dr-segment",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1674,6 +1676,7 @@ dependencies = [
|
|||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
"dr-ingest",
|
"dr-ingest",
|
||||||
"dr-lens",
|
"dr-lens",
|
||||||
|
"dr-pano",
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-preset-xmp",
|
"dr-preset-xmp",
|
||||||
|
|||||||
@@ -134,6 +134,22 @@ pub struct RawImage {
|
|||||||
pub base_curve: BaseCurve,
|
pub base_curve: BaseCurve,
|
||||||
/// The usable region of `data`, excluding masked and border photosites.
|
/// The usable region of `data`, excluding masked and border photosites.
|
||||||
pub crop: CropRect,
|
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<profile::CameraProfile>,
|
||||||
|
/// 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
|
/// TRACES: FR-RAW-3
|
||||||
@@ -559,6 +575,21 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
image.camera.clean_model.as_str(),
|
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 {
|
let data = match image.data {
|
||||||
rawler::RawImageData::Integer(v) => v,
|
rawler::RawImageData::Integer(v) => v,
|
||||||
rawler::RawImageData::Float(v) => {
|
rawler::RawImageData::Float(v) => {
|
||||||
@@ -627,6 +658,10 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
wb_coeffs,
|
wb_coeffs,
|
||||||
color_matrix,
|
color_matrix,
|
||||||
base_curve,
|
base_curve,
|
||||||
|
samples_per_pixel,
|
||||||
|
profile,
|
||||||
|
make: image.camera.clean_make.clone(),
|
||||||
|
model: image.camera.clean_model.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -392,6 +392,21 @@ impl CameraProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The calibrations this profile was built from, coolest first.
|
/// 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] {
|
pub fn calibrations(&self) -> &[Calibration] {
|
||||||
&self.calibrations
|
&self.calibrations
|
||||||
}
|
}
|
||||||
@@ -528,6 +543,35 @@ fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Compose a forward matrix into camera RGB → linear sRGB.
|
||||||
///
|
///
|
||||||
/// `forward` takes white-balanced camera RGB to XYZ under D50, which is the
|
/// `forward` takes white-balanced camera RGB to XYZ under D50, which is the
|
||||||
|
|||||||
@@ -38,4 +38,7 @@ dr-gpu.workspace = true
|
|||||||
dr-pipeline.workspace = true
|
dr-pipeline.workspace = true
|
||||||
env_logger.workspace = true
|
env_logger.workspace = true
|
||||||
pollster.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
|
zune-jpeg.workspace = true
|
||||||
|
|||||||
@@ -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<W, F>(
|
||||||
|
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<u16>) -> 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::<colortype::RGB16>(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<u16> = 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<W, K>(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<SRational> = 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<u8> {
|
||||||
|
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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -213,7 +213,7 @@ impl tiff::encoder::TiffValue for Undefined<'_> {
|
|||||||
/// specification says, `dr-decode` reads them back with `from_utf8_lossy`, and
|
/// 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
|
/// a mangled accent is a far better outcome than a refusal. So the bytes go
|
||||||
/// through verbatim with the terminating NUL the type requires.
|
/// 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<'_> {
|
impl tiff::encoder::TiffValue for Ascii<'_> {
|
||||||
const BYTE_LEN: u8 = 1;
|
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
|
/// 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
|
/// big-endian machine. `exif.rs` builds its own header and so chooses its own
|
||||||
/// order; here the container has already chosen.
|
/// 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<'_> {
|
impl tiff::encoder::TiffValue for Rationals<'_> {
|
||||||
const BYTE_LEN: u8 = 8;
|
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
|
/// and then no pointer is written either, so the file has no trace of the
|
||||||
/// directory rather than a pointer to an empty one.
|
/// directory rather than a pointer to an empty one.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct SubDirectories {
|
pub(crate) struct SubDirectories {
|
||||||
exif: Option<u32>,
|
exif: Option<u32>,
|
||||||
gps: Option<u32>,
|
gps: Option<u32>,
|
||||||
}
|
}
|
||||||
@@ -335,7 +335,7 @@ struct SubDirectories {
|
|||||||
/// A TIFF gets no separate EXIF *block* — no APP1, no `eXIf` chunk. Its own
|
/// 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
|
/// directory is the EXIF structure, and adding a second copy inside it would
|
||||||
/// give a reader two answers to every question.
|
/// give a reader two answers to every question.
|
||||||
fn sub_directories<W>(
|
pub(crate) fn sub_directories<W>(
|
||||||
encoder: &mut tiff::encoder::TiffEncoder<W>,
|
encoder: &mut tiff::encoder::TiffEncoder<W>,
|
||||||
source: Option<&SourceMetadata>,
|
source: Option<&SourceMetadata>,
|
||||||
width: u32,
|
width: u32,
|
||||||
@@ -465,7 +465,7 @@ where
|
|||||||
///
|
///
|
||||||
/// No `Orientation`, for the reason `exif.rs` gives at length: the pixels
|
/// No `Orientation`, for the reason `exif.rs` gives at length: the pixels
|
||||||
/// arriving here are already upright.
|
/// arriving here are already upright.
|
||||||
fn tag_metadata<W, K>(
|
pub(crate) fn tag_metadata<W, K>(
|
||||||
dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>,
|
dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>,
|
||||||
source: Option<&SourceMetadata>,
|
source: Option<&SourceMetadata>,
|
||||||
sub: &SubDirectories,
|
sub: &SubDirectories,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
use dr_types::{ColourSpace, ExportFormat, ExportSettings};
|
use dr_types::{ColourSpace, ExportFormat, ExportSettings};
|
||||||
|
|
||||||
|
mod dng;
|
||||||
mod encode;
|
mod encode;
|
||||||
mod error;
|
mod error;
|
||||||
mod exif;
|
mod exif;
|
||||||
@@ -33,6 +34,7 @@ mod name;
|
|||||||
mod sharpen;
|
mod sharpen;
|
||||||
mod size;
|
mod size;
|
||||||
|
|
||||||
|
pub use dng::{write_linear_dng, DngProfile};
|
||||||
pub use error::ExportError;
|
pub use error::ExportError;
|
||||||
pub use metadata::SourceMetadata;
|
pub use metadata::SourceMetadata;
|
||||||
pub use name::{resolve_name, NameContext};
|
pub use name::{resolve_name, NameContext};
|
||||||
|
|||||||
@@ -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.
|
/// Convert an f32 to IEEE 754 half-precision bits.
|
||||||
///
|
///
|
||||||
/// Written out rather than pulled in as a dependency: the inputs here are
|
/// 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
|
/// `RawImage`; which of the two CFA families it came off is this
|
||||||
/// function's problem, not theirs.
|
/// function's problem, not theirs.
|
||||||
pub fn run(&self, raw: &RawImage) -> Result<DemosaicedImage, GpuError> {
|
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 (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||||
|
|
||||||
let limits = self.ctx.device.limits();
|
let limits = self.ctx.device.limits();
|
||||||
@@ -827,6 +959,43 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use dr_decode::CropRect;
|
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 {
|
fn raw_for(black: [u16; 4], white: u16) -> RawImage {
|
||||||
RawImage {
|
RawImage {
|
||||||
width: 4,
|
width: 4,
|
||||||
@@ -838,6 +1007,10 @@ mod tests {
|
|||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
base_curve: BaseCurve::IDENTITY,
|
||||||
|
samples_per_pixel: 1,
|
||||||
|
profile: None,
|
||||||
|
make: String::new(),
|
||||||
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -950,6 +1123,10 @@ mod tests {
|
|||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
base_curve: BaseCurve::IDENTITY,
|
||||||
|
samples_per_pixel: 1,
|
||||||
|
profile: None,
|
||||||
|
make: String::new(),
|
||||||
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -1196,6 +1373,10 @@ mod tests {
|
|||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
base_curve: BaseCurve::IDENTITY,
|
||||||
|
samples_per_pixel: 1,
|
||||||
|
profile: None,
|
||||||
|
make: String::new(),
|
||||||
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -1277,6 +1458,10 @@ mod tests {
|
|||||||
],
|
],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
base_curve: BaseCurve::IDENTITY,
|
||||||
|
samples_per_pixel: 1,
|
||||||
|
profile: None,
|
||||||
|
make: String::new(),
|
||||||
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 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],
|
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]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
base_curve: curve,
|
base_curve: curve,
|
||||||
|
samples_per_pixel: 1,
|
||||||
|
profile: None,
|
||||||
|
make: String::new(),
|
||||||
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ fn flat_raw(level: u16) -> RawImage {
|
|||||||
// leaving a curve here would test the suppression rather than the
|
// leaving a curve here would test the suppression rather than the
|
||||||
// film. `dr-pipeline` asserts the suppression on the generated source.
|
// film. `dr-pipeline` asserts the suppression on the generated source.
|
||||||
base_curve: BaseCurve::IDENTITY,
|
base_curve: BaseCurve::IDENTITY,
|
||||||
|
samples_per_pixel: 1,
|
||||||
|
profile: None,
|
||||||
|
make: String::new(),
|
||||||
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user