Hasselblad 3FR / FFF: uncompressed RGB embedded previews

These files carry no JPEG: their preview is an uncompressed 8-bit RGB
IFD (1217 x 913 in the H4D-40 FFF, 218 x 218 in the CFV 3FR). The
largest such image is handed on by embedded_preview as a small baseline
TIFF, which lightcraft_codecs::decode reads, so 3FR / FFF show a preview
in the grid and loupe before they are developed. No Orientation tag is
written: the raw file's applies, as for JPEG previews without one. The
FFF's 16-bit copy (with a TransferFunction of unknown meaning) is not
used.

decode_raw_preview applied a container-declared colour space by forcing
the JPEG decoder on any owned preview; it now checks for JPEG bytes, so
a wrapped TIFF (or a JPEG XL preview) goes to its own decoder.

Checked on the CC0 H4D-40 FFF and CFV 3FR through
embedded_preview_srgb: right colours, the 3FR upright through its
Rotate90. Tests cover the wrap, the choice among RGB images, a short
strip 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:
Claude
2026-10-15 18:58:05 +00:00
parent 0f67225e83
commit b5dc500c54
5 changed files with 134 additions and 10 deletions
+3 -2
View File
@@ -707,8 +707,9 @@ pub(crate) fn decode_raw_preview(
covering: Option<usize>,
) -> Option<lightcraft_codecs::Decoded> {
let jpeg = stand_in_image(bytes, covering)?;
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
// the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview
// wrapped as a TIFF) and a JPEG XL preview are read by their own decoders
let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]);
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
Some(space) => {
let space = match space {
+4 -2
View File
@@ -148,8 +148,8 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
}
}
/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with
/// `lightcraft_codecs::decode`.
/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file, or for a Hasselblad 3FR / FFF, which
/// has no JPEG, its uncompressed RGB preview wrapped as a baseline TIFF — all decode with `lightcraft_codecs::decode`.
pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
preview_covering(bytes, None)
}
@@ -220,6 +220,8 @@ fn preview_covering(bytes: &[u8], min_edge: Option<u32>) -> Option<Vec<u8>> {
.map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() });
// a TIFF raw whose preview no tag points to: one we can't decode (Leaf MOS), or Epson ERF (in its maker note)
best.or_else(|| format.filter(|f| !f.is_supported() || *f == crate::RawFormat::Erf).and_then(|_| scan_for_jpeg(bytes)))
// Hasselblad 3FR / FFF carry no JPEG, only uncompressed RGB images: one wrapped as a baseline TIFF
.or_else(|| (format == Some(crate::RawFormat::CfaTiff)).then(|| crate::vendor::hasselblad::rgb_preview(&tiff, bytes)).flatten())
}
/// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count,
+121 -2
View File
@@ -19,9 +19,14 @@
//! `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.
//! - No JPEG preview. IFD0 is an uncompressed 8-bit RGB image (1217 x 913 in the H4D-40 FFF, IFD1 pointing at the
//! same strip; 218 x 218 in the CFV 3FR, under the file's own `Orientation`); the FFF also has a 16-bit RGB
//! copy in a `SubIFDs` child, with a `TransferFunction` whose meaning is not known, so it is not used.
//! [`rgb_preview`] hands the largest 8-bit one on as a small baseline TIFF.
use crate::dng;
use crate::{ColorData, Mode, RawFormat, RawImage, Result};
use lightcraft_tiff::{ParseOptions, Tiff};
use lightcraft_tiff::image::{Layout, chunk_bytes};
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, ParseOptions, Tiff, TiffWriter, Value, tags as t};
/// Private IFD0 tag holding the original 3FR header (a complete TIFF) inside an FFF.
pub(crate) const ORIGINAL_HEADER: u16 = 0xc51b;
@@ -55,6 +60,58 @@ fn original_header_color(bytes: &[u8]) -> Option<ColorData> {
crate::color::has_matrix(&color).then_some(color)
}
/// Upper bound on a preview's pixel bytes (8-bit RGB): 64 MP, far above the 1.1 MP of the largest one seen.
const MAX_PREVIEW_BYTES: usize = (64 << 20) * 3;
/// The file's largest uncompressed 8-bit RGB image (chunky, in strips), as a little-endian baseline TIFF holding
/// just those pixels, which `lightcraft_codecs::decode` reads. No `Orientation` tag is written: the viewer
/// applies the raw file's, which is the one that stands over this image in IFD0.
pub(crate) fn rgb_preview(tiff: &Tiff, bytes: &[u8]) -> Option<Vec<u8>> {
let info = tiff
.all_ifds()
.into_iter()
.filter_map(|i| i.image().ok())
.filter(|i| {
i.photometric == t::photometric::RGB
&& i.compression == t::compression::NONE
&& i.samples_per_pixel == 3
&& i.bits_per_sample.iter().all(|&b| b == 8)
&& i.planar == 1
&& i.sample_format == 1
&& matches!(i.layout, Layout::Strips { .. })
&& i.width > 0
&& i.height > 0
})
.max_by_key(|i| u64::from(i.width) * u64::from(i.height))?;
let (w, h) = (info.width as usize, info.height as usize);
let size = w.checked_mul(h)?.checked_mul(3)?;
if size > MAX_PREVIEW_BYTES {
return None;
}
let mut pixels = Vec::with_capacity(size);
for chunk in info.chunks(bytes.len() as u64) {
pixels.extend_from_slice(chunk_bytes(bytes, &chunk)?);
if pixels.len() >= size {
break;
}
}
// a short last strip is a truncated file: no preview rather than a picture with a black band
if pixels.len() < size {
return None;
}
pixels.truncate(size);
let mut ifd = IfdBuilder::new();
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![info.width]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![info.height]));
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![t::photometric::RGB]));
ifd.set(t::COMPRESSION, Value::Short(vec![t::compression::NONE]));
ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![1]));
ifd.set_image(ImageData::Strips { rows_per_strip: info.height, strips: vec![pixels] });
TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).ok()
}
/// 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;
@@ -81,7 +138,7 @@ mod tests {
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.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![(0..36).collect()] });
ifd
}
@@ -100,6 +157,11 @@ mod tests {
/// 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> {
fff_with(header, own_matrix, vec![])
}
/// [`fff`] with more `SubIFDs` children of IFD0 (other preview images).
fn fff_with(header: Option<Vec<u8>>, own_matrix: bool, extra: Vec<IfdBuilder>) -> 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]));
@@ -122,6 +184,9 @@ mod tests {
ifd0.set(ORIGINAL_HEADER, Value::Undefined(h));
}
ifd0.add_sub_ifd(raw);
for e in extra {
ifd0.add_sub_ifd(e);
}
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
}
@@ -164,6 +229,7 @@ mod tests {
for n in (0..bytes.len()).step_by(7) {
let _ = decode_file(&bytes[..n]);
let _ = probe_info(&bytes[..n]);
let _ = crate::embedded_preview(&bytes[..n]);
}
let mut header = original_header(true);
for i in (8..header.len()).step_by(3) {
@@ -173,4 +239,57 @@ mod tests {
}
}
}
/// `(width, height, pixels, has an Orientation tag)` of a wrapped preview.
fn wrapped(tiff: &[u8]) -> (u32, u32, Vec<u8>, bool) {
let t = Tiff::parse(tiff).unwrap();
let ifd = &t.ifds[0];
let info = ifd.image().unwrap();
let chunks = info.chunks(tiff.len() as u64);
let pixels = chunks.iter().flat_map(|c| chunk_bytes(tiff, c).unwrap().to_vec()).collect();
(info.width, info.height, pixels, ifd.contains(t::ORIENTATION))
}
/// No JPEG in the file: the 8-bit RGB IFD0 comes out as a baseline TIFF of the same pixels, without an
/// orientation of its own (the raw file's applies).
#[test]
fn rgb_ifd0_is_the_embedded_preview() {
let bytes = fff(Some(original_header(true)), false);
let preview = crate::embedded_preview(&bytes).expect("a preview");
assert_eq!(wrapped(&preview), (4, 3, (0..36).collect(), false));
assert_eq!(crate::embedded_preview_covering(&bytes, 2000), Some(preview));
}
/// The largest 8-bit RGB image wins; a bigger 16-bit one (the FFF's copy with a `TransferFunction`) is not
/// used, and nor is a planar or compressed one.
#[test]
fn only_8_bit_chunky_uncompressed_rgb_is_taken() {
let image = |w: u32, h: u32, bits: u16, planar: u16, compression: u16| {
let mut ifd = IfdBuilder::new();
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3]));
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![compression]));
ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![planar]));
let n = (w * h * 3 * u32::from(bits / 8)) as usize;
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![7; n]] });
ifd
};
let bytes = fff_with(None, false, vec![image(16, 12, 16, 1, 1), image(12, 9, 8, 2, 1), image(12, 9, 8, 1, 8)]);
assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()).0, 4, "IFD0's 4 x 3");
let bytes = fff_with(None, false, vec![image(6, 6, 8, 1, 1)]);
assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()), (6, 6, vec![7; 108], false));
}
/// A preview strip cut short by a truncated file gives no preview, not a partial picture.
#[test]
fn short_preview_strip_gives_none() {
let mut ifd0 = rgb_ifd();
ifd0.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![1; 30]] });
let tiff = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap();
assert_eq!(rgb_preview(&Tiff::parse(&tiff).unwrap(), &tiff), None);
}
}
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,6 +1,6 @@
# RAW parity: cameras, formats, demosaic, colour and lens profiles
> **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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Hasselblad FFF colour matrix from the embedded 3FR header; 3FR / FFF RGB previews) · **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; 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 | 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; the embedded preview is the uncompressed 8-bit RGB image (no JPEG in these files), handed on as a baseline TIFF |
| 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 embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged |
| 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) |