ORF: black level from the main maker note for bodies without ImageProcessing; gate and bound the 0x1017/0x1018 gains (part of #1273)

This commit is contained in:
Marco Arnulfo 2026-10-12 22:48:34 +02:00
parent 73a10f5b6d
commit 5eee65edac
4 changed files with 114 additions and 25 deletions

View File

@ -19,8 +19,7 @@
//! six CC0 files (C5060WZ, C7070WZ, SP-510UZ, SP-550UZ, SP-565UZ, SP-570UZ). Their Exif `CFAPattern` states the
//! layout (it matches the embedded thumbnails), the maker note's entries `0x1017`/`0x1018` hold the red and
//! blue gain (first value, 256 = 1.0; checked against neutral areas of the thumbnail), and nothing states a
//! black level or an active area. The E-10 (16-bit words) has no ImageProcessing sub-directory either and keeps
//! its gains in the same two entries.
//! black level or an active area.
//! - 12-bit blocks (E-300, E-500, E-330; the E-M5 Mark II and PEN-F high-resolution files): one strip of 12.8 bits
//! per pixel, `width * 8 / 5` bytes per row, made of 16-byte blocks. A block is 15 sample bytes followed by one
//! pad byte that is always zero; the 15 bytes are a little-endian 120-bit string holding ten 12-bit samples, sample
@ -55,6 +54,8 @@ const WB_RB: u16 = 0x0100;
const BLACK: u16 = 0x0600;
const MN_WB_RED: u16 = 0x1017;
const MN_WB_BLUE: u16 = 0x1018;
const MN_BLACK: u16 = 0x1012;
const MN_VALID_BITS: u16 = 0x102c;
const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615];
/// The Olympus maker note.
@ -76,6 +77,17 @@ pub(crate) fn sub_ifd(bytes: &[u8], mn: &MakerNote, tag: u16) -> Option<Ifd> {
lightcraft_tiff::parse_ifd_at(bytes, at, mn.order, mn.base, false, &opts).ok().map(|(i, _)| i)
}
/// Black level of a body without ImageProcessing: the main note's `0x1012` BlackLevel (the mean of its four values)
/// in `0x102c` ValidBits units, scaled to the decoded `bits`. The E-10 states 32 at 10 bits, and its 12-bit samples
/// bottom out at 124 to 132; the two-field compacts state 0 at 12 bits.
fn maker_note_black(m: &MakerNote, bits: u32) -> Option<f32> {
let bits = bits.min(16);
let v = m.ifd.f64s(MN_BLACK).filter(|v| v.len() == 4)?;
let valid = m.ifd.u64(MN_VALID_BITS).and_then(|b| u32::try_from(b).ok()).filter(|b| (8..=bits).contains(b))?;
let level = v.iter().sum::<f64>() / 4.0 * f64::from(1u32 << (bits - valid));
(level >= 0.0 && level < f64::from(1u32 << bits) / 4.0).then_some(level as f32)
}
/// Unpack one row of 12-bit samples stored as little-endian 32-bit words read MSB-first.
pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) {
let swapped: Vec<u8> = src
@ -196,6 +208,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let chunks = info.chunks(bytes.len() as u64);
let total: u64 = chunks.iter().map(|c| c.len).sum();
let stated = cfa_from_exif(&tiff);
let mut two_field = false;
let (mut data, bits) = if info.compression != 1 {
return Err(RawError::Unsupported(format!("ORF compression {}", info.compression)));
} else if total >= (n as u64) * 2 {
@ -232,6 +245,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
.then(|| field_rows(&info.offsets.iter().copied().zip(info.byte_counts.iter().copied()).collect::<Vec<_>>(), w, h, bytes.len()))
.flatten()
{
two_field = true;
let d = if mode == Mode::Full || stated.is_none() {
let (rb, h0) = (w * 3 / 2, h.div_ceil(2));
let mut d = vec![0u16; n];
@ -281,7 +295,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let values = a.pattern.iter().map(|&c| [v[0], (v[1] + v[2]) / 2.0, v[3]][c as usize] as f32).collect();
BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() }
}
_ => BlackLevel::uniform(0.0),
_ => BlackLevel::uniform(mn.as_ref().filter(|_| ip.is_none()).and_then(|m| maker_note_black(m, bits)).unwrap_or(0.0)),
};
let wb = ip
.as_ref()
@ -289,9 +303,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
.filter(|v| v.len() >= 2 && v[0] > 0.0 && v[1] > 0.0)
.map(|v| [(v[0] / 256.0) as f32, 1.0, (v[1] / 256.0) as f32])
.or_else(|| {
// two-field bodies and the E-10: the red and blue gains lead the entries of the main maker note
let m = mn.as_ref()?;
let g = |tag| m.ifd.f64s(tag).and_then(|v| v.first().copied()).filter(|g| *g > 0.0);
// the red and blue gains lead the main note's entries 0x1017/0x1018 on the two-field compacts and on
// the E-10 (16-bit words, no ImageProcessing; 384 and 406 on its CC0 file 5424)
let m = mn.as_ref().filter(|_| two_field || ip.is_none())?;
let g = |tag| m.ifd.f64s(tag).and_then(|v| v.first().copied()).filter(|g| *g > 25.6 && *g < 2560.0);
Some([(g(MN_WB_RED)? / 256.0) as f32, 1.0, (g(MN_WB_BLUE)? / 256.0) as f32])
});
let white = white_from_data(samples, bits);
@ -372,36 +387,102 @@ mod tests {
b
}
/// An old-style little-endian Olympus maker note (`OLYMP\0`, version 1, then the IFD) holding two-short
/// entries, whose values fit inline.
fn olymp_note(entries: &[(u16, [u16; 2])]) -> Vec<u8> {
let mut b = b"OLYMP\0\x01\0".to_vec();
/// An old-style little-endian Olympus maker note (`OLYMP\0`, version 1, then the IFD) of SHORT (type 3) or
/// UNDEFINED (type 7) entries. Values longer than four bytes follow the IFD; their offsets count from the TIFF
/// header, so `at` is where the note sits in the file.
fn olymp_note(at: usize, entries: &[(u16, u16, Vec<u8>)]) -> Vec<u8> {
let ifd_end = 8 + 2 + 12 * entries.len() + 4;
let (mut b, mut extra) = (b"OLYMP\0\x01\0".to_vec(), Vec::new());
b.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, [v0, v1]) in entries {
for (tag, typ, value) in entries {
b.extend_from_slice(&tag.to_le_bytes());
b.extend_from_slice(&3u16.to_le_bytes());
b.extend_from_slice(&2u32.to_le_bytes());
b.extend_from_slice(&v0.to_le_bytes());
b.extend_from_slice(&v1.to_le_bytes());
b.extend_from_slice(&typ.to_le_bytes());
b.extend_from_slice(&(if *typ == 3 { value.len() / 2 } else { value.len() } as u32).to_le_bytes());
if value.len() <= 4 {
let mut v = value.clone();
v.resize(4, 0);
b.extend_from_slice(&v);
} else {
b.extend_from_slice(&((at + ifd_end + extra.len()) as u32).to_le_bytes());
extra.extend_from_slice(value);
}
}
b.extend_from_slice(&0u32.to_le_bytes());
b.extend_from_slice(&extra);
b
}
/// SHORT values, little-endian.
fn shorts(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
/// A 16 × 4 ORF of 12-bit samples in 16-bit words with an [`olymp_note`] of `entries`, decoded.
fn decode_noted(entries: &[(u16, u16, Vec<u8>)]) -> RawImage {
let (w, h) = (16u32, 4u32);
let words: Vec<u8> = (0..w * h).flat_map(|i| (((200 + i * 37) % 4096) as u16).to_le_bytes()).collect();
let file = |at| orf_with_note(w, h, 16, words.clone(), Some(RGGB), Some(olymp_note(at, entries)));
let at = file(0).windows(6).position(|b| b == b"OLYMP\0").unwrap();
let bytes = file(at);
assert_eq!(bytes.windows(6).position(|b| b == b"OLYMP\0"), Some(at));
crate::decode(&bytes).unwrap()
}
/// An ImageProcessing sub-directory stored inline (old-style note) with one unrelated entry and no levels.
fn bare_image_processing() -> (u16, u16, Vec<u8>) {
let mut ifd = 1u16.to_le_bytes().to_vec();
ifd.extend_from_slice(&[0, 0, 3, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
ifd.extend_from_slice(&0u32.to_le_bytes());
(IMAGE_PROCESSING, 7, ifd)
}
/// Issue #1273: the E-10 (16-bit words, no ImageProcessing sub-directory) keeps its red and blue gains in
/// maker-note entries `0x1017`/`0x1018`, as the two-field compacts do.
#[test]
fn as_shot_white_balance_from_the_main_maker_note() {
let (w, h) = (16usize, 4usize);
let words: Vec<u8> = (0..w * h).flat_map(|i| (((100 + i * 37) % 4096) as u16).to_le_bytes()).collect();
let wb = |entries: &[(u16, [u16; 2])]| {
crate::decode(&orf_with_note(w as u32, h as u32, 16, words.clone(), Some(RGGB), Some(olymp_note(entries)))).unwrap().wb_multipliers
let wb = |red: u16, blue: Option<u16>| {
let mut e = vec![(MN_WB_RED, 3, shorts(&[red, 64]))];
e.extend(blue.map(|b| (MN_WB_BLUE, 3, shorts(&[b, 64]))));
decode_noted(&e).wb_multipliers
};
assert_eq!(wb(&[(MN_WB_RED, [384, 64]), (MN_WB_BLUE, [406, 64])]), Some([1.5, 1.0, 406.0 / 256.0]));
// both gains are needed, and a zero gain is none
assert_eq!(wb(&[(MN_WB_RED, [384, 64])]), None);
assert_eq!(wb(&[(MN_WB_RED, [0, 64]), (MN_WB_BLUE, [406, 64])]), None);
assert_eq!(wb(&[]), None);
assert_eq!(wb(384, Some(406)), Some([1.5, 1.0, 406.0 / 256.0]));
// both gains are needed, within 0.1 to 10 (25.6 to 2560 in 256 units)
assert_eq!(wb(384, None), None);
assert_eq!(wb(0, Some(406)), None);
assert_eq!(wb(25, Some(406)), None);
assert_eq!(wb(384, Some(2560)), None);
assert_eq!(wb(27, Some(2559)), Some([27.0 / 256.0, 1.0, 2559.0 / 256.0]));
assert_eq!(decode_noted(&[]).wb_multipliers, None);
// a body with ImageProcessing but without its levels doesn't fall back to these entries
let e = [bare_image_processing(), (MN_WB_RED, 3, shorts(&[384, 64])), (MN_WB_BLUE, 3, shorts(&[406, 64]))];
assert_eq!(decode_noted(&e).wb_multipliers, None);
}
/// Issue #1273: a body without ImageProcessing states its black level in the main note, `0x1012` (four
/// values) in `0x102c` ValidBits units: the E-10's 32 at 10 bits is 128 on its 12-bit samples. The two-field
/// compacts state 0 at 12 bits.
#[test]
fn black_level_from_the_main_maker_note() {
let black = |level: u16, valid: Option<u16>| {
let mut e = vec![(MN_BLACK, 3, shorts(&[level; 4]))];
e.extend(valid.map(|v| (MN_VALID_BITS, 3, shorts(&[v, 0]))));
let r = decode_noted(&e);
assert_eq!(r.bits, 12);
r.black.values
};
assert_eq!(black(32, Some(10)), vec![128.0]);
assert_eq!(black(0, Some(12)), vec![0.0]);
assert_eq!(black(32, Some(12)), vec![32.0]);
// the four values are averaged
let e = [(MN_BLACK, 3, shorts(&[30, 32, 32, 34])), (MN_VALID_BITS, 3, shorts(&[10, 0]))];
assert_eq!(decode_noted(&e).black.values, vec![128.0]);
// no ValidBits, ValidBits above the samples' depth, a level of a quarter of the range or more: no black
assert_eq!(black(32, None), vec![0.0]);
assert_eq!(black(32, Some(14)), vec![0.0]);
assert_eq!(black(256, Some(10)), vec![0.0]);
// ImageProcessing present (without BlackLevel2): the main note isn't read
let e = [bare_image_processing(), (MN_BLACK, 3, shorts(&[32; 4])), (MN_VALID_BITS, 3, shorts(&[10, 0]))];
assert_eq!(decode_noted(&e).black.values, vec![0.0]);
}
/// A 12-bit mosaic stored as 16-bit words: `site(cx, cy)` gives the four values of the 2×2 cell at (cx, cy).

File diff suppressed because one or more lines are too long

View File

@ -266,6 +266,7 @@ Beyond decoding:
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Olympus E-10: as-shot white balance (`0x1017`/`0x1018`) and black level (`0x1012` in `0x102c` ValidBits units) from the main maker note, issue #1273; ΔE76 against its thumbnail 18.5 → 11.5, camera-look fit now accepted; counts unchanged |
| 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point |
| 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% |
| 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts |

View File

@ -1,5 +1,11 @@
# What's new in LightCraft
## 2026-10-12
### Older raw cameras
- Olympus E-10 raws open with the camera's white balance (White Balance ▸ As Shot) and without the grey haze in
the shadows: the black level recorded in the file is now subtracted.
## 2026-10-11
### JPEG XL export