Merge PR #1919: Hasselblad FFF: colour matrix from the embedded 3FR header

# Conflicts:
#	docs/parity-checklist.md
#	docs/raw-parity.md
This commit is contained in:
Brandon Thomas
2026-10-18 09:22:57 -04:00
7 changed files with 311 additions and 8 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 {
+2 -2
View File
@@ -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),
+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,
+295
View File
@@ -0,0 +1,295 @@
//! 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.
//! - 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::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;
/// 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)
}
/// 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;
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![(0..36).collect()] });
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> {
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]));
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);
for e in extra {
ifd0.add_sub_ifd(e);
}
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 _ = crate::embedded_preview(&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) => {}
}
}
}
/// `(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);
}
}
+1
View File
@@ -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 -1
View File
@@ -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; 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) |
@@ -288,6 +288,8 @@ Beyond decoding:
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback |
| 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) |
| 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% |