mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Hasselblad FFF: colour matrix from the embedded 3FR header
An FFF (a 3FR converted on the desktop) has no colour matrix of its own: the camera's stays in the original 3FR header the FFF carries as a complete TIFF in private IFD0 tag 0xC51B. The new vendor::hasselblad reader decodes 3FR / FFF through dng::decode_as and, when the file has no matrix, takes the matrices from that header, so an FFF no longer develops with the "camera RGB = sRGB" fallback (H4D-40 as-shot white moves from xy 0.279, 0.356 to 0.325, 0.336). RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR (raw.pixls.us 931 and 709). Synthetic tests cover the embedded matrix, an own matrix winning, a missing or damaged header, and truncated files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J2NDVXA8jQQWeJb4YVTb6b
This commit is contained in:
@@ -110,7 +110,7 @@ pub enum RawFormat {
|
||||
X3f,
|
||||
/// A TIFF-based raw that has no DNG version tag but describes itself the way DNG does (a
|
||||
/// full-resolution CFA image with black/white levels, a default crop and `AsShotNeutral`):
|
||||
/// Hasselblad 3FR and FFF. Read by the DNG reader.
|
||||
/// Hasselblad 3FR and FFF. Read by `vendor::hasselblad` (the DNG reader plus what Hasselblad adds).
|
||||
CfaTiff,
|
||||
/// Kodak DCS520C TIFF: a two-component lossless JPEG CFA sub-IFD with a response curve (`vendor::kodak`).
|
||||
KodakDcs,
|
||||
@@ -397,7 +397,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
RawFormat::Rw2 => vendor::rw2::decode(bytes, mode),
|
||||
RawFormat::Pef => vendor::pef::decode(bytes, mode),
|
||||
RawFormat::Orf => vendor::orf::decode(bytes, mode),
|
||||
RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped),
|
||||
RawFormat::CfaTiff => vendor::hasselblad::decode(bytes, mode).map(lift_clipped),
|
||||
RawFormat::Srw => vendor::srw::decode(bytes, mode),
|
||||
RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode),
|
||||
RawFormat::Erf => vendor::erf::decode(bytes, mode),
|
||||
|
||||
Vendored
+176
@@ -0,0 +1,176 @@
|
||||
//! Hasselblad 3FR and FFF raws ([`crate::RawFormat::CfaTiff`]).
|
||||
//!
|
||||
//! Sources: TIFF 6.0 (container), the DNG Specification (the tags these files borrow) and our own black-box
|
||||
//! analysis of a CC0 H4D-40 FFF from raw.pixls.us (931, "Hasselblad - H4D-40 - 16bit (4:3).fff"); every number
|
||||
//! below was measured on it.
|
||||
//!
|
||||
//! - Both are TIFFs without a DNG version tag that describe their raw image with DNG's tags: a full-resolution
|
||||
//! CFA sub-IFD with `BlackLevel`, `WhiteLevel`, `DefaultCropOrigin` / `DefaultCropSize`, and `AsShotNeutral`.
|
||||
//! [`crate::dng::decode_as`] reads all of that; this module adds what that reader does not know about.
|
||||
//! - The raw image is uncompressed (X1D, X1D II, X2D, CFV 100C) or Hasselblad's lossless JPEG with predictor
|
||||
//! selection value 8 ([`crate::ljpeg::decode_hasselblad`]); the H4D-40 FFF's 7410 x 5586 strip is the latter.
|
||||
//! - No `CFAPattern`. The layout is RGGB from the sensor origin: averaged over the 6 x 6 sensor blocks behind each
|
||||
//! pixel of the file's 1217 x 913 RGB IFD0 image (the default crop is exactly six times its size), the sites at
|
||||
//! (0, 0) correlate 0.976 with its red and 0.726 with its blue, the sites at (1, 1) 0.896 with blue and 0.847
|
||||
//! with red, and the two green sites' diagonal differences are half those of the other pair.
|
||||
//! - An FFF is a 3FR converted on the desktop. It has no colour matrix of its own: the camera's matrix stays in
|
||||
//! the original 3FR header, which the FFF carries whole as a little-endian TIFF in the private IFD0 tag
|
||||
//! `0xC51B` (4096 bytes in the sample: an IFD0 with Make, Model, `ColorMatrix1` and `AsShotNeutral`, a
|
||||
//! `SubIFDs` pair for the raw and preview images, whose data offsets point into the old 3FR and are not read).
|
||||
//! [`decode`] takes the matrices from there when the outer file has none. The white balance stays the outer
|
||||
//! file's, which is the one the converter last saved.
|
||||
use crate::dng;
|
||||
use crate::{ColorData, Mode, RawFormat, RawImage, Result};
|
||||
use lightcraft_tiff::{ParseOptions, Tiff};
|
||||
|
||||
/// Private IFD0 tag holding the original 3FR header (a complete TIFF) inside an FFF.
|
||||
pub(crate) const ORIGINAL_HEADER: u16 = 0xc51b;
|
||||
|
||||
/// Upper bound on the embedded header's size: the sample's is 4 KiB; a larger block is not a header.
|
||||
const MAX_HEADER: usize = 1 << 20;
|
||||
|
||||
/// Decode a 3FR / FFF (or another TIFF raw in the DNG layout, see [`dng::is_plain_cfa_tiff`]).
|
||||
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let mut img = dng::decode_as(bytes, mode, RawFormat::CfaTiff)?;
|
||||
if !crate::color::has_matrix(&img.color)
|
||||
&& let Some(header) = original_header_color(bytes)
|
||||
{
|
||||
adopt_matrices(&mut img.color, &header);
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// The colour tags of the original 3FR header an FFF embeds in [`ORIGINAL_HEADER`], when it has a colour matrix.
|
||||
fn original_header_color(bytes: &[u8]) -> Option<ColorData> {
|
||||
let outer = Tiff::parse(bytes).ok()?;
|
||||
let block = outer.ifds.first()?.bytes(ORIGINAL_HEADER)?;
|
||||
if block.len() > MAX_HEADER {
|
||||
return None;
|
||||
}
|
||||
let opts = ParseOptions { max_ifds: 16, max_depth: 2, ..Default::default() };
|
||||
let header = Tiff::parse_with(block, &opts).ok()?;
|
||||
let ifd0 = header.ifds.first()?;
|
||||
let raw = dng::raw_ifd(&header).unwrap_or(ifd0);
|
||||
let color = dng::color_data(ifd0, raw, header.order);
|
||||
crate::color::has_matrix(&color).then_some(color)
|
||||
}
|
||||
|
||||
/// Take the camera matrices (and the illuminants they belong to) of `from`; the rest of `color` stays.
|
||||
fn adopt_matrices(color: &mut ColorData, from: &ColorData) {
|
||||
color.illuminant = from.illuminant;
|
||||
color.color_matrix = from.color_matrix;
|
||||
color.forward_matrix = from.forward_matrix;
|
||||
color.camera_calibration = from.camera_calibration;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{RawData, RawError, decode as decode_file, probe, probe_info};
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
|
||||
const MATRIX: [(i32, i32); 9] =
|
||||
[(686, 1000), (-196, 1000), (-32, 1000), (-437, 1000), (1228, 1000), (235, 1000), (-165, 1000), (317, 1000), (675, 1000)];
|
||||
|
||||
fn rgb_ifd() -> IfdBuilder {
|
||||
let mut ifd = IfdBuilder::new();
|
||||
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![4]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![3]));
|
||||
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
|
||||
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![2]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![0x80; 36]] });
|
||||
ifd
|
||||
}
|
||||
|
||||
/// The camera's 3FR header as an FFF embeds it: a little-endian TIFF whose IFD0 has the colour matrix.
|
||||
fn original_header(matrix: bool) -> Vec<u8> {
|
||||
let mut ifd0 = rgb_ifd();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D".into()));
|
||||
if matrix {
|
||||
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(MATRIX.to_vec()));
|
||||
}
|
||||
ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(43, 100), (1, 1), (79, 100)]));
|
||||
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
/// An FFF in miniature: big-endian, IFD0 an RGB image with `AsShotNeutral` and the embedded header `header`,
|
||||
/// a 16-bit uncompressed CFA sub-IFD of 8 x 6 samples, no colour matrix of its own unless `own_matrix`.
|
||||
fn fff(header: Option<Vec<u8>>, own_matrix: bool) -> Vec<u8> {
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![8]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![6]));
|
||||
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
|
||||
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)]));
|
||||
raw.set(t::WHITE_LEVEL, Value::Short(vec![65535]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![(0..96u8).collect()] });
|
||||
let mut ifd0 = rgb_ifd();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D-40".into()));
|
||||
ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (4, 5)]));
|
||||
if own_matrix {
|
||||
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)]));
|
||||
}
|
||||
if let Some(h) = header {
|
||||
ifd0.set(ORIGINAL_HEADER, Value::Undefined(h));
|
||||
}
|
||||
ifd0.add_sub_ifd(raw);
|
||||
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
fn matrix_row0(c: &ColorData) -> Option<[f64; 3]> {
|
||||
c.color_matrix[0].map(|m| m.0[0])
|
||||
}
|
||||
|
||||
/// The FFF's own tags give the samples and white balance; the colour matrix comes from the 3FR header it
|
||||
/// embeds, so the image is not developed with the "camera RGB ≈ sRGB" fallback.
|
||||
#[test]
|
||||
fn fff_takes_the_colour_matrix_from_the_embedded_3fr_header() {
|
||||
let bytes = fff(Some(original_header(true)), false);
|
||||
assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff));
|
||||
let r = decode_file(&bytes).unwrap();
|
||||
assert_eq!(r.data, RawData::U16((0..48u16).map(|i| u16::from_be_bytes([2 * i as u8, 2 * i as u8 + 1])).collect()));
|
||||
assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB");
|
||||
assert_eq!(matrix_row0(&r.color), Some([0.686, -0.196, -0.032]));
|
||||
assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.8]), "the outer file's white balance");
|
||||
assert!(!crate::color::camera_transform_of(&r.color, lightcraft_color::D65).matrix_is_fallback);
|
||||
// the import probe says the same
|
||||
assert_eq!(matrix_row0(&probe_info(&bytes).unwrap().color), Some([0.686, -0.196, -0.032]));
|
||||
}
|
||||
|
||||
/// A matrix of the file's own wins; without one anywhere (no header, or a header without a matrix) the
|
||||
/// file still decodes, with the fallback colour.
|
||||
#[test]
|
||||
fn own_matrix_wins_and_a_missing_one_is_not_an_error() {
|
||||
let own = decode_file(&fff(Some(original_header(true)), true)).unwrap();
|
||||
assert_eq!(matrix_row0(&own.color), Some([1.0, 0.0, 0.0]));
|
||||
for header in [None, Some(original_header(false)), Some(b"not a tiff".to_vec())] {
|
||||
let r = decode_file(&fff(header, false)).unwrap();
|
||||
assert!(!crate::color::has_matrix(&r.color));
|
||||
}
|
||||
}
|
||||
|
||||
/// A truncated or damaged file never panics: an error, or a decode.
|
||||
#[test]
|
||||
fn damaged_files_do_not_panic() {
|
||||
let bytes = fff(Some(original_header(true)), false);
|
||||
for n in (0..bytes.len()).step_by(7) {
|
||||
let _ = decode_file(&bytes[..n]);
|
||||
let _ = probe_info(&bytes[..n]);
|
||||
}
|
||||
let mut header = original_header(true);
|
||||
for i in (8..header.len()).step_by(3) {
|
||||
header[i] ^= 0x5a;
|
||||
match decode_file(&fff(Some(header.clone()), false)) {
|
||||
Ok(_) | Err(RawError::Corrupt(_) | RawError::Tiff(_) | RawError::Unsupported(_) | RawError::Limit(_) | RawError::NotRaw) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -12,6 +12,7 @@ pub mod cr3;
|
||||
pub mod crx;
|
||||
pub(crate) mod crx_wavelet;
|
||||
pub mod erf;
|
||||
pub(crate) mod hasselblad;
|
||||
pub mod kodak;
|
||||
pub mod mef;
|
||||
pub mod nef;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-2
@@ -1,6 +1,6 @@
|
||||
# RAW parity: cameras, formats, demosaic, colour and lens profiles
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Hasselblad FFF colour matrix from the embedded 3FR header) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
|
||||
|
||||
Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera
|
||||
does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by
|
||||
@@ -159,7 +159,7 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus
|
||||
| DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) |
|
||||
| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 |
|
||||
| DNG | GoPro GPR (VC-5) | ⬜ | refused |
|
||||
| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; FFF neutral-matrix colour remains unverified |
|
||||
| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR |
|
||||
| 3FR / FFF | Hasselblad compression | ⬜ | preview |
|
||||
| ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` |
|
||||
| IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) |
|
||||
@@ -287,6 +287,7 @@ Beyond decoding:
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged |
|
||||
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) |
|
||||
| 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) |
|
||||
| 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% |
|
||||
|
||||
Reference in New Issue
Block a user