Read applied Sony RAW metadata and crop ILCE-6000 images

This commit is contained in:
optimalbytee
2026-10-11 04:06:16 +01:00
parent 7926161595
commit e2ed234e65
9 changed files with 405 additions and 22 deletions
+4 -2
View File
@@ -27,7 +27,7 @@ Implemented only from public specifications, papers and observation of files:
* Publicly documented maker-note / private tags (ExifTool's tag tables): Canon ModelID (0x0010),
SensorInfo (0x00E0) and ColorData (0x4001), Nikon WB_RBLevels (0x000C) and BlackLevel (0x003D), Sony
BlackLevel (0x7310), WB_RGGBLevels (0x7313), SonyRawFileType (0x7000) and SonyToneCurve
(0x7010), the PanasonicRaw IFD0 tags, Olympus ImageProcessing (0x2040) and CameraSettings
(0x7010), SR2Private Offset / Length / Key (0x7200 / 0x7201 / 0x7221), the PanasonicRaw IFD0 tags, Olympus ImageProcessing (0x2040) and CameraSettings
(0x2020) preview tags, the Fujifilm RAF header and metadata records (RawImageCropTopLeft /
RawImageCroppedSize, FujiLayout, XTransLayout, WB_GRGBLevels, RawExposureBias) and FujiIFD
(RawImageFullWidth / Height, BitsPerSample, StripOffsets / ByteCounts, BlackLevel,
@@ -41,6 +41,8 @@ Implemented only from public specifications, papers and observation of files:
* Sony cRAW (ARW 2): H. Dietz, "Sony ARW2 Compression: Artifacts And Credible Repair"
(Electronic Imaging 2016) and the RawDigger / diglloyd write-ups of the 11 + 7-bit scheme;
the exact bit layout and tone-curve scale were established by observation of sample files.
Encrypted SR2SubIFD metadata with key bytes `11 22 33 44` was measured independently against
ExifTool's metadata dumps; see [Sony metadata evidence](../../docs/sony-arw-metadata.md).
* Panasonic RW2 RawFormat 5, uncompressed Olympus ORF and uncompressed Fujifilm RAF: established
by observation of sample files (bit packing, page layout, sample justification; for RAF the
three sample storages and the reversed order of the X-Trans / Bayer layout records, each
@@ -64,7 +66,7 @@ camera colour tables were copied.
| CR2 sRAW / mRAW | Unsupported |
| NEF / NRW, ARW, PEF and other TIFF/EP raws | Uncompressed and lossless-JPEG (incl. Sony lossless ARW) CFA data |
| Nikon compressed NEF (compression 34713): lossless and lossy type 1 / 2, 12 and 14 bit | Decoded: fixed Huffman tables + maker-note `0x0096` seeds and curve (see `src/nefc.rs`). BlackLevel `0x003d` is read in 14-bit units. "Lossy after split" files are unsupported (preview fallback) |
| Sony compressed ARW ("cRAW", SonyRawFileType 2) | Decoded: 11-bit min/max + 7-bit delta blocks, SonyToneCurve to 14 bits, the curve's 512 black level when no BlackLevel tag is written (the first-generation bodies, e.g. the ILCE-7) |
| Sony compressed ARW ("cRAW", SonyRawFileType 2) | Decoded: 11-bit min/max + 7-bit delta blocks, SonyToneCurve to 14 bits; applied WB and black from plain tags or encrypted SR2SubIFD with the measured common key. If metadata is unreadable, black falls back to 512 and WB to grey-world. Measured ILCE-7 / ILCE-6000 image area; camera colour calibration remains incomplete |
| Panasonic / Leica RW2, RawFormat 5 (12- and 14-bit packed) | Decoded, with PanasonicRaw black / white / WB / sensor borders |
| Olympus ORF, uncompressed 16-bit (E-1, E-400…) | Decoded, with ImageProcessing black / WB / ValidBits / crop |
| Nikon "lossy after split" NEF, Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 4 and older, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead |
+18 -1
View File
@@ -95,6 +95,9 @@ const PROFILES: &[Profile] = &[Profile {
/// right and bottom), the same corner as the D4.
/// * **ILCE-7** (2 files): 12 x 12 pixels in from the left and top of the
/// 6048 x 4024 cRAW data (36 and 12 pixels of margin on the right and bottom).
/// * **ILCE-6000** (3 files): the same 12 x 12 origin and 6000 x 4000 image;
/// confirmed by the file's EXIF size and SR2SubIFD's area [12, 12, 6012, 4012],
/// and checked against the matching camera JPEGs. No colour profile is inferred.
struct ImageArea {
make: &'static str,
model: &'static str,
@@ -107,6 +110,7 @@ struct ImageArea {
const IMAGE_AREAS: &[ImageArea] = &[
ImageArea { make: "NIKON CORPORATION", model: "NIKON D800E", size: (7424, 4924), crop: (8, 6, 7360, 4912) },
ImageArea { make: "SONY", model: "ILCE-7", size: (6048, 4024), crop: (12, 12, 6000, 4000) },
ImageArea { make: "SONY", model: "ILCE-6000", size: (6048, 4024), crop: (12, 12, 6000, 4000) },
];
/// Applies the measured profile of the camera that made `s`, when there is one,
@@ -114,7 +118,7 @@ const IMAGE_AREAS: &[ImageArea] = &[
pub(crate) fn apply(s: &mut Sensor) {
let (Some(make), Some(model)) = (s.make.as_deref(), s.model.as_deref()) else { return };
let (make, model) = (make.trim(), model.trim());
if let Some(a) = IMAGE_AREAS.iter().find(|a| a.make == make && a.model == model && a.size == (s.width, s.height)) {
if let Some(a) = IMAGE_AREAS.iter().find(|a| a.make == make && a.model == model && a.size == (s.width, s.height) && s.crop == s.active) {
let (x, y, w, h) = a.crop;
s.crop = Rect::new(x, y, w, h);
}
@@ -196,6 +200,19 @@ mod tests {
apply(&mut s);
assert_eq!(s.crop, Rect::new(12, 12, 6000, 4000));
let mut s = sensor("SONY", "ILCE-6000", 6048, 4024);
apply(&mut s);
assert_eq!(s.crop, Rect::new(12, 12, 6000, 4000));
assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty());
// A file's explicit crop wins, including a different in-camera aspect ratio.
s.crop = Rect::new(12, 324, 6000, 3376);
apply(&mut s);
assert_eq!(s.crop, Rect::new(12, 324, 6000, 3376));
let mut s = sensor("SONY", "ILCE-6000", 3024, 2012);
apply(&mut s);
assert_eq!(s.crop, Rect::new(0, 0, 3024, 2012));
// Another size or model of the same maker is left alone.
let mut s = nef("NIKON D800E", 2496, 1646);
apply(&mut s);
+1
View File
@@ -56,6 +56,7 @@ mod raf;
mod rw2;
mod sensor;
mod sony;
mod sony_metadata;
mod tiff;
mod tiffep;
+262
View File
@@ -0,0 +1,262 @@
//! Sony's encrypted SR2SubIFD: black levels and the white balance actually applied by the camera.
//!
//! Directory/tag meanings are from ExifTool's public Sony tag documentation. The keystream was
//! measured independently by XOR-ing file bytes with ExifTool 13.59's black-box `-v5` metadata
//! dumps of three ILCE-6000 ARWs (56958-byte blocks, key bytes 11 22 33 44). All 14239 whole
//! words in each block obey k[n] = k[n-63] XOR k[n-127]; the first 127 words agree across the
//! files. This is metadata cipher data, not camera colour calibration or image data. LightCraft's
//! documented investigation suggested the experiment; no sibling or third-party decoder code
//! is used. Other keys remain unreadable rather than being guessed.
use crate::Limits;
use crate::tiff::{Ifd, Tiff};
const PRIVATE_DATA: u16 = 50740;
const OFFSET: u16 = 0x7200;
const LENGTH: u16 = 0x7201;
const KEY: u16 = 0x7221;
const MAX_METADATA: usize = 1 << 20;
// Measured from metadata only. The key's first 127 words, in byte-stream order (big endian).
const INITIAL: [u32; 127] = [
0x54c2c4d6, 0x49c478bc, 0x34ae2f8e, 0x25d80504, 0xc0d9d6b0, 0xd838fb71, 0xe8eff27d, 0xfbc1fcea, 0x506c499a, 0x47f20f37, 0x710777cf, 0x7867e7ba, 0x42d67caa,
0x7f2bd11a, 0x67a216ca, 0x0e986d40, 0x4ae8d4c0, 0xe36778b4, 0x5a958415, 0xdbfe2be8, 0x20faa1aa, 0x7132a6b8, 0xf4de4b7f, 0x55991aa1, 0xa849d5aa, 0x49577832,
0xb92f3daa, 0x399cc526, 0x22cdd000, 0xe1977a29, 0x37c5db55, 0xb0177e1e, 0x2a1016aa, 0xa300086e, 0x3bab9bfe, 0x262eece0, 0x23771aa9, 0x0a5dc91c, 0x31b902ae,
0x58e64bf8, 0x259c300e, 0xa57705c8, 0x284a6541, 0xfb229c60, 0x1bacaa9e, 0xbcab3350, 0x67cd9fbe, 0x8f135e60, 0xf8c26a40, 0x6770da61, 0x3e1febfd, 0xd0c70803,
0x8dbb037b, 0x6f6fa4c5, 0x6749d10d, 0x7f51598d, 0xd5e5a4ec, 0x207dfa91, 0x6558ebc2, 0xbe594639, 0x617a9e5d, 0x3c497950, 0x0844eb3f, 0x04207ed2, 0xd27ceac4,
0x70d20f05, 0xb47003f6, 0xe9e4e3ae, 0xcc19d265, 0x326dd956, 0xf0d3a327, 0xb71275f0, 0x7994e285, 0x0aff594d, 0x128e8345, 0x7bda597a, 0xd634c380, 0xe24a006f,
0x8974818b, 0x3320b22a, 0xbe808417, 0xa2d5648a, 0x6fe80b39, 0x23ebad41, 0xa2d11e5d, 0x027d9397, 0x9a722ac8, 0x432c7dac, 0x7146692a, 0x82a3dc77, 0xd66887c5,
0x831f43b7, 0x4e5dddde, 0x03793f81, 0x306ab437, 0x00ccf86c, 0xfc6ed3d2, 0x076b8fdb, 0x9808cfca, 0x0f4eef6e, 0xc8cc3830, 0x104ac16a, 0xa189eff4, 0x3e085c08,
0xd28baf88, 0x5c853ac4, 0xe60480f8, 0xc51acd98, 0x691e5ee1, 0x333feeb9, 0x1e35bc33, 0xec4a4642, 0xee57c5a5, 0xbeeb51f7, 0xe0c4f32c, 0xa5422f6a, 0x1d266d12,
0x3752fd3b, 0xfbc53c7d, 0x2421a4a3, 0xcdc6a2de, 0x26e6b330, 0x6c073d46, 0x058e2f27, 0x43833f30, 0x46d1382e, 0x5f0804ec,
];
fn decipher(data: &mut [u8]) {
let mut history = INITIAL;
for (n, chunk) in data.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let slot = n % 127;
let word = if n < 127 {
INITIAL.get(n).copied().unwrap_or(0)
} else {
history.get(slot).copied().unwrap_or(0) ^ history.get((n - 63) % 127).copied().unwrap_or(0)
};
if let Some(previous) = history.get_mut(slot) {
*previous = word;
}
for (byte, key) in chunk.iter_mut().zip(word.to_be_bytes()) {
*byte ^= key;
}
}
// Sony leaves an incomplete final word in the clear.
}
#[derive(Default)]
pub(crate) struct Levels {
pub black: Option<[f64; 4]>,
pub wb: Option<[f64; 4]>,
}
/// Reads only four SHORT/SSHORT levels from a complete directory. Overflow value offsets are
/// absolute file offsets, but must point inside this decrypted block, never into other file data.
fn four(t: &Tiff, ifd: &Ifd, start: usize, tag: u16) -> Option<[f64; 4]> {
let mut e = *ifd.get(tag)?;
if !matches!(e.typ, 3 | 8) || e.count != 4 {
return None;
}
e.at = e.at.checked_sub(start)?;
t.raw(&e)?;
let v = [t.float(&e, 0)?, t.float(&e, 1)?, t.float(&e, 2)?, t.float(&e, 3)?];
v.iter().all(|x| (0.0..=16383.0).contains(x)).then_some(v)
}
fn parse(plain: &[u8], start: usize, le: bool) -> Option<Levels> {
let t = Tiff { data: plain, le, first_ifd: 0 };
let count = usize::from(t.u16_at(0)?);
let end = count.checked_mul(12)?.checked_add(6)?;
plain.get(..end)?;
let ifd = t.ifd_at(0, 0)?;
Some(Levels { black: four(&t, &ifd, start, 0x7310), wb: four(&t, &ifd, start, 0x7313) })
}
pub(crate) fn read(t: &Tiff, limits: &Limits) -> Option<Levels> {
let ifd0 = t.ifd_at(t.first_ifd, 0)?;
let pointer = ifd0.get(PRIVATE_DATA)?;
// BYTE[4]/UNDEFINED[4] containing a file-order offset (not a DNG private payload).
if !matches!(pointer.typ, 1 | 7) || pointer.count != 4 {
return None;
}
let private = t.ifd_at(t.u32_at(pointer.at)? as usize, 0)?;
if t.raw(private.get(KEY)?)? != [0x11, 0x22, 0x33, 0x44] {
return None;
}
let start = t.tag_uint(&private, OFFSET)? as usize;
let len = t.tag_uint(&private, LENGTH)? as usize;
if !(6..=MAX_METADATA).contains(&len) || len as u64 > limits.max_alloc {
return None;
}
let mut plain = t.bytes(start, len)?.to_vec();
decipher(&mut plain);
parse(&plain, start, t.le)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testgen::{TiffBuilder, Val};
use crate::{DevelopOptions, RawFormat, WhiteBalance, decode, develop_sensor};
/// Procedural CFA TIFF with plain and encrypted levels that deliberately disagree.
/// No camera file, scene pixels, metadata dump or private fixture is included.
fn fixture(be: bool, plain: bool, black: [u16; 4], wb: [u16; 4]) -> Vec<u8> {
let u16_bytes = |v: u16| if be { v.to_be_bytes() } else { v.to_le_bytes() };
let u32_bytes = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() };
let mut block = Vec::new();
block.extend_from_slice(&u16_bytes(2));
for (tag, typ, offset) in [(0x7310, 3, 2048), (0x7313, 8, 2056)] {
block.extend_from_slice(&u16_bytes(tag));
block.extend_from_slice(&u16_bytes(typ));
block.extend_from_slice(&u32_bytes(4));
block.extend_from_slice(&u32_bytes(offset)); // replaced with absolute offsets below
}
block.extend_from_slice(&[0; 4]);
block.resize(2048, 0); // crosses multiple 127-word recurrence periods
for v in black.into_iter().chain(wb) {
block.extend_from_slice(&u16_bytes(v));
}
block.extend_from_slice(&[0xAA, 0xBB]); // tail stored in the clear
let mut b = TiffBuilder { big_endian: be, ..Default::default() };
let len = block.len();
let data = b.blob(block);
let pixels = b.blob(
(0..32 * 16)
.flat_map(|i| {
u16_bytes(match (i % 32 % 2, i / 32 % 2) {
(0, 0) => 1512,
(1, 1) => 2512,
_ => 3512,
})
})
.collect(),
);
let mut entries = vec![
(256, Val::Long(vec![32])),
(257, Val::Long(vec![16])),
(258, Val::Short(vec![16])),
(259, Val::Short(vec![1])),
(262, Val::Short(vec![32803])),
(273, Val::Blobs(vec![pixels])),
(277, Val::Short(vec![1])),
(278, Val::Long(vec![16])),
(279, Val::Long(vec![32 * 16 * 2])),
(33421, Val::Short(vec![2, 2])),
(33422, Val::Byte(vec![0, 1, 1, 2])),
(50717, Val::Short(vec![16383])),
];
if plain {
entries.extend([(0x7310, Val::Short(vec![600; 4])), (0x7313, Val::Short(vec![2048, 1024, 1024, 1536]))]);
}
let raw = b.ifd(entries);
let private = b.ifd(vec![(OFFSET, Val::Blobs(vec![data])), (LENGTH, Val::Long(vec![len as u32])), (KEY, Val::Byte(vec![0x11, 0x22, 0x33, 0x44]))]);
let root = b.ifd(vec![
(271, Val::Ascii("SONY".into())),
(272, Val::Ascii("ILCE-Synthetic".into())),
(330, Val::Ifds(vec![raw])),
(PRIVATE_DATA, Val::Ifds(vec![private])),
]);
b.chain = vec![root];
let mut bytes = b.build();
let t = Tiff::new(&bytes).unwrap();
let root = t.ifd_at(t.first_ifd, 0).unwrap();
let pointer = *root.get(PRIVATE_DATA).unwrap();
let private = t.ifd_at(t.u32_at(pointer.at).unwrap() as usize, 0).unwrap();
let start = t.tag_uint(&private, OFFSET).unwrap() as usize;
// TIFF BYTE[4] offset rather than the generator's LONG[1].
bytes[pointer.at - 6..pointer.at - 4].copy_from_slice(&u16_bytes(1));
bytes[pointer.at - 4..pointer.at].copy_from_slice(&u32_bytes(4));
for (entry, offset) in [(10, 2048), (22, 2056)] {
bytes[start + entry..start + entry + 4].copy_from_slice(&u32_bytes((start + offset) as u32));
}
decipher(&mut bytes[start..start + len]);
bytes
}
#[test]
fn encrypted_applied_white_balance_and_black_reach_the_developer() {
for be in [false, true] {
let bytes = fixture(be, false, [512, 513, 514, 515], [3072, 1024, 1024, 2048]);
let s = decode(&bytes, &Limits::default()).unwrap();
assert_eq!(s.format, RawFormat::Arw);
assert_eq!(s.camera_wb, Some([3.0, 1.0, 2.0]));
assert_eq!(s.black.values, vec![512.0, 513.0, 514.0, 515.0]);
let dev = develop_sensor(&s, &DevelopOptions::default()).unwrap();
assert_eq!(dev.info.wb_multipliers, [3.0, 1.0, 2.0]);
assert!(!dev.warnings.iter().any(|w| w.contains("grey world") || w.contains("assumed 0")));
let explicit = develop_sensor(&s, &DevelopOptions { white_balance: WhiteBalance::Multipliers([3.0, 1.0, 2.0]), ..Default::default() }).unwrap();
assert_eq!(dev.rgb, explicit.rgb);
}
}
#[test]
fn plain_levels_win_and_invalid_encrypted_levels_are_ignored() {
let s = decode(&fixture(false, true, [800; 4], [3072, 1024, 1024, 2048]), &Limits::default()).unwrap();
assert_eq!(s.camera_wb, Some([2.0, 1.0, 1.5]));
assert_eq!(s.black.values, vec![600.0; 4]);
let mut broken_plain = fixture(false, true, [800; 4], [3072, 1024, 1024, 2048]);
let t = Tiff::new(&broken_plain).unwrap();
let raw = t.all_ifds().into_iter().find(|i| i.has(0x7313)).unwrap();
let at = raw.get(0x7313).unwrap().at;
broken_plain[at..at + 8].fill(0);
let s = decode(&broken_plain, &Limits::default()).unwrap();
assert_eq!(s.camera_wb, Some([3.0, 1.0, 2.0]));
assert_eq!(s.black.values, vec![600.0; 4]);
for wb in [[0, 0, 0, 0], [65535, 1024, 1024, 2048], [30000, 1024, 1024, 2048], [3072, 0, 0, 2048]] {
let s = decode(&fixture(false, false, [65535; 4], wb), &Limits::default()).unwrap();
assert_eq!(s.camera_wb, None);
assert_eq!(s.black.values, vec![0.0]);
}
}
#[test]
fn hostile_private_metadata_is_bounded_and_optional() {
let bytes = fixture(false, false, [512; 4], [3072, 1024, 1024, 2048]);
for n in 0..bytes.len() {
if let Some(t) = Tiff::new(&bytes[..n]) {
let _ = read(&t, &Limits::default());
}
}
let t = Tiff::new(&bytes).unwrap();
assert!(read(&t, &Limits { max_alloc: 2000, ..Default::default() }).is_none());
let root = t.ifd_at(t.first_ifd, 0).unwrap();
let private = t.ifd_at(t.u32_at(root.get(PRIVATE_DATA).unwrap().at).unwrap() as usize, 0).unwrap();
for (at, value) in [(private.get(KEY).unwrap().at, 0), (private.get(LENGTH).unwrap().at, u32::MAX), (private.get(OFFSET).unwrap().at, u32::MAX)] {
let mut corrupt = bytes.clone();
corrupt[at..at + 4].copy_from_slice(&value.to_le_bytes());
assert!(read(&Tiff::new(&corrupt).unwrap(), &Limits::default()).is_none());
let s = decode(&corrupt, &Limits::default()).unwrap();
assert_eq!(s.camera_wb, None);
}
let start = t.tag_uint(&private, OFFSET).unwrap() as usize;
let len = t.tag_uint(&private, LENGTH).unwrap() as usize;
let mut corrupt = bytes.clone();
decipher(&mut corrupt[start..start + len]);
// Values before the encrypted block cannot be interpreted as metadata.
corrupt[start + 22..start + 26].copy_from_slice(&8u32.to_le_bytes());
decipher(&mut corrupt[start..start + len]);
assert!(read(&Tiff::new(&corrupt).unwrap(), &Limits::default()).unwrap().wb.is_none());
}
#[test]
fn measured_keystream_recurrence_and_unencrypted_tail() {
let mut zero = vec![0; 14239 * 4 + 2];
decipher(&mut zero);
assert_eq!(&zero[..4], &[0x54, 0xc2, 0xc4, 0xd6]);
// Words recovered independently from all three oracle dumps, including ring wrap
// and the final complete word of a real metadata block.
assert_eq!(&zero[127 * 4..128 * 4], &[0x86, 0xbe, 0x2e, 0x12]);
assert_eq!(&zero[255 * 4..256 * 4], &[0xc9, 0x1a, 0x54, 0xc4]);
assert_eq!(&zero[14238 * 4..14239 * 4], &[0xb9, 0xd3, 0x6e, 0xab]);
assert_eq!(&zero[14239 * 4..], &[0, 0]);
decipher(&mut zero);
assert!(zero.iter().all(|&b| b == 0));
}
}
+26 -10
View File
@@ -149,25 +149,34 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result<Sen
let make = ifds.iter().find_map(|i| t.tag_ascii(i, tag::MAKE));
let model = ifds.iter().find_map(|i| t.tag_ascii(i, tag::MODEL));
// Older Sony bodies put the applied white balance and black levels only in SR2SubIFD.
// Plain tags win when usable; a malformed plain value must not hide valid private metadata.
let bl = t.tag_floats(&raw, tag::BLACK_LEVEL);
let sony_black = t.tag_floats(&raw, SONY_BLACK_LEVEL);
let sony_plain_wb = sony_wb(&t.tag_floats(&raw, SONY_WB_RGGB_LEVELS));
let needs_sony_levels = (bl.is_empty() && rggb_by_position(&cfa, &sony_black).is_none()) || sony_plain_wb.is_none();
let sony_levels = if format == RawFormat::Arw && needs_sony_levels { crate::sony_metadata::read(t, limits) } else { None };
let vendor_black = match &nikon {
// Nikon's BlackLevel is in 14-bit units whatever the sample depth:
// 12-bit D750 / D850 / Z 50 files store 600 / 400 / 1008 while their
// darkest samples are about a quarter of that (measured on CC0
// raw.pixls.us samples, documented in LightCraft's NEF decoder).
Some((n, m)) => n.tag_floats(m, NIKON_BLACK_LEVEL).into_iter().map(|v| if plane.bits == 12 { v / 4.0 } else { v }).collect(),
None => t.tag_floats(&raw, SONY_BLACK_LEVEL),
None => {
if rggb_by_position(&cfa, &sony_black).is_some() {
sony_black
} else {
sony_levels.as_ref().and_then(|s| s.black).map(|v| v.to_vec()).unwrap_or_default()
}
}
};
let black = if let Some(b) = bl.first() {
BlackLevels::uniform(*b as f32)
} else if let Some(v) = rggb_by_position(&cfa, &vendor_black) {
BlackLevels { rows: 2, cols: 2, values: v.to_vec(), delta_h: Vec::new(), delta_v: Vec::new() }
} else if craw {
// cRAW files of the first generation (an ILCE-7 writes none) carry no
// BlackLevel tag; the tone curve maps the black code to 512, so their
// decoded data has a 512 black level (see `sony.rs`). Other tag-less
// bodies sit higher (a DSC-RX0's darkest samples are near 800), so 512
// is a floor, still far closer than 0.
// When neither plain nor encrypted metadata is readable, the common cRAW
// black code maps to 512. Some bodies sit higher, so this remains a fallback.
BlackLevels::uniform(512.0)
} else if nikon.is_some() {
// Nikon bodies that write no BlackLevel tag subtract the black level in camera: a D3200
@@ -187,10 +196,7 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result<Sen
[r, b, ..] => Some([*r, 1.0, *b]),
_ => None,
},
None => match t.tag_floats(&raw, SONY_WB_RGGB_LEVELS).as_slice() {
[r, g1, g2, b] if *g1 + *g2 > 0.0 => Some([2.0 * r / (g1 + g2), 1.0, 2.0 * b / (g1 + g2)]),
_ => None,
},
None => sony_plain_wb.or_else(|| sony_levels.as_ref().and_then(|s| s.wb).and_then(|v| sony_wb(&v))),
}
.filter(|m| m.iter().all(|v| (0.25..8.0).contains(v)));
// DNG-style DefaultCropOrigin / DefaultCropSize (some vendors write them in the raw IFD).
@@ -229,6 +235,16 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result<Sen
Ok(sensor)
}
fn sony_wb(levels: &[f64]) -> Option<[f64; 3]> {
let [r, g1, g2, b] = <[f64; 4]>::try_from(levels).ok()?;
if [r, g1, g2, b].iter().any(|v| !v.is_finite() || *v <= 0.0) {
return None;
}
let g = (g1 + g2) / 2.0;
let m = [r / g, 1.0, b / g];
m.iter().all(|v| (0.25..8.0).contains(v)).then_some(m)
}
#[cfg(test)]
mod padding_tests {
use super::padded_width;
+8 -6
View File
@@ -68,9 +68,8 @@ const WANTS: &[Want] = &[
model: "DSC-RX0",
size: (4832, 3224),
cfa: [0, 1, 1, 2],
// No readable BlackLevel tag: the cRAW fallback of 512 (#1902). The file's darkest
// samples sit near 800, so this body's real level is still to be read.
black: &[512.0],
// SR2SubIFD black levels, verified with the metadata oracle (not the 512 fallback).
black: &[800.0; 4],
white: [16301.0; 3],
},
// The G9's high-resolution mode: a 10480 × 7794 sensor cropped to 10368 × 7776.
@@ -109,6 +108,9 @@ fn decode_matches_the_measured_oracles() {
let cfa = s.cfa.as_ref().unwrap_or_else(|| panic!("{}: no CFA", w.file));
assert_eq!(cfa.colors, w.cfa, "{}", w.file);
assert_eq!(s.black.values, w.black, "{}", w.file);
if w.file == "DSC00009.ARW" {
assert_eq!(s.camera_wb, Some([2056.0 / 1024.0, 1.0, 1908.0 / 1024.0]));
}
for ch in 0..3 {
assert!((s.white[ch] - w.white[ch]).abs() < 0.5, "{} white[{ch}] = {}", w.file, s.white[ch]);
}
@@ -130,11 +132,11 @@ fn developing_produces_the_crop_in_16bit_rgb() {
hi = (*v).max(hi);
}
// A real exposure covers a sane share of the range and never leaves it. The PowerShot
// CR2 and the RX0 ARW are the documented exceptions: neither carries an as-shot white
// CR2 is the documented exception: it carries no as-shot white
// balance we read, so the grey-world estimate brightens these scenes (the PowerShot's
// own DNG, with its file WB, develops blacks to ~0 — reading the maker-note WB of
// Canon and Sony is the follow-up).
let estimated_wb = matches!(w.file, "IMG_4059.CR2" | "DSC00009.ARW");
// Canon is the follow-up). Sony's SR2SubIFD now supplies the applied gains and black.
let estimated_wb = w.file == "IMG_4059.CR2";
let black_cap: u16 = if estimated_wb { 16384 } else { 4096 };
assert!(lo < black_cap, "{}: developed blacks at {lo}", w.file);
assert!(hi > u16::MAX / 4, "{}: developed highlights at {hi}", w.file);
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: every Photoshop 2026 format from its bundle against our readers and writers; Affinity notes moved here from `roadmap.md`) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Sony encrypted applied WB/black and measured ILCE-6000 image area; colour calibration remains partial) · **Target:** Adobe Photoshop 2026
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
@@ -66,7 +66,7 @@ supports many more, including CR3).
| Canon CR2 | yes | |
| Canon CR3 | detected, unsupported | `crates/raw/src/lib.rs:219` |
| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50 |
| Sony ARW/SR2/SRF (incl. compressed) | yes | |
| Sony ARW/SR2/SRF (incl. compressed) | yes; camera calibration partial | 2026-10-11: encrypted SR2SubIFD applied WB/black (measured common key), ILCE-6000 image area; synthetic and public RX0 corpus tests. [Evidence and limits](sony-arw-metadata.md) |
| Panasonic RW2, Pentax PEF | yes | |
| Olympus ORF | uncompressed only | |
| Fujifilm RAF (Bayer and X-Trans) | uncompressed only; no Fuji colour calibration | |
@@ -133,4 +133,5 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Sony encrypted applied WB/black and measured ILCE-6000 crop; camera calibration remains partial |
| 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` |
+5 -1
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: every known shortfall ranked, from the 2026-10-10 re-measure) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G14: Sony applied WB/black corrected; camera colour and tone gap remains open) · **Target:** Adobe Photoshop 2026
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
@@ -97,6 +97,9 @@ what an agent can't finish alone.
sidecars, masking and calibration in the Camera Raw dialog, per-camera colour for non-DNG
cameras, lens profiles.
- **Evidence:** `crates/raw/src/lib.rs:219` (CR3), scorecard FILE-215-11/12, #50.
- **Progress, 2026-10-11:** encrypted Sony applied WB/black and the measured ILCE-6000 image area
now read correctly; public RX0 black is 800 rather than the 512 fallback. Camera calibration
and JPEG tone parity remain open; see [measurement and limits](sony-arw-metadata.md).
- **Estimate:** 120–200 h.
### G15. AI and generative features
@@ -211,5 +214,6 @@ what an agent can't finish alone.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | G14 Sony metadata/crop progress recorded without closing camera colour or tone gaps |
| 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed |
| 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues |
+78
View File
@@ -0,0 +1,78 @@
# Sony ARW metadata evidence
2026-10-11: PhotoCraft now reads applied white balance and black levels from Sony's encrypted
SR2SubIFD when usable plain tags are absent. It also applies the measured ILCE-6000 image area.
This corrects source metadata; it does not add a camera colour profile or a JPEG tone match.
## Research and scope
PhotoCraft already contains the cRAW sensor decoder from [#83](https://github.com/storytold/photocraft/pull/83)
and the 512 black fallback from [#1902](https://github.com/storytold/photocraft/pull/1902).
The open [#2135](https://github.com/storytold/photocraft/pull/2135) proposes fitting camera-JPEG
colour and tone; [#2230](https://github.com/storytold/photocraft/pull/2230) adds camera profiles.
Their actual bodies and diffs were reviewed before this change; neither is merged at the
inspection date. This change addresses metadata missing from the existing decoder, without
duplicating those colour-development proposals.
LightCraft's [#115](https://github.com/storytold/lightcraft/pull/115),
[#535](https://github.com/storytold/lightcraft/issues/535) and its current camera-preview-colour
documentation distinguish applied WB, camera calibration and JPEG tone/DRO. Its SR2 investigation
suggested testing encrypted metadata before estimating WB from scene pixels. PhotoCraft's
implementation is independent: no sibling source, decoder dependency or camera colour table is
included. The first keystream words were recovered again from local metadata observations.
## Measurement
Three read-only local ILCE-6000 ARW/JPEG pairs were inspected with ExifTool 13.59 and PhotoCraft's
`rawinfo` example. No private files, scene pixels or metadata dumps are committed.
| Source fact | All three ARWs |
|---|---|
| Sensor / output image | 6048 × 4024 / 6000 × 4000 |
| cRAW code range after SonyToneCurve | 14-bit; white 16383 |
| SR2SubIFD BlackLevel (`0x7310`) | 512, 512, 512, 512 |
| Applied WB_RGGBLevels (`0x7313`) | 2764, 1024, 1024, 1784 |
| Applied R/G/B multipliers | 2.69921875, 1, 1.7421875 |
| Image area observation | [12, 12, 6012, 4012], corroborated by EXIF dimensions and JPEG framing |
Previously WB was estimated independently from each scene, yielding R/G/B gains of roughly
2.920/1/2.214, 2.661/1/1.823 and 3.063/1/1.645. All three now use the camera's applied gains.
The measured crop is specific to ILCE-6000 at this sensor size; an explicit existing crop wins.
Other models and sensor sizes are unaffected by that entry.
The pinned public-domain DSC-RX0 corpus sample independently verifies black 800 in all four
positions, rather than the former 512 fallback, and WB 2056/1024, 1, 1908/1024.
## Container and safety
Tag meanings come from [ExifTool's public Sony tag documentation](https://exiftool.org/TagNames/Sony.html).
IFD0's `50740` BYTE/UNDEFINED[4] points to SR2Private, whose `0x7200`, `0x7201` and `0x7221`
provide the block offset, byte length and key bytes. Only the observed `11 22 33 44` key is supported.
XOR of ciphertext with black-box decrypted metadata yielded the same first 127 big-endian
keystream words for all three files. Every subsequent whole word in each 56958-byte block
obeyed `k[n] = k[n-63] XOR k[n-127]`; the incomplete final word is stored in the clear.
Only four SHORT/SSHORT values for `0x7310` and `0x7313` are read. Their absolute value offsets
must resolve inside the decrypted block. Allocation is capped at 1 MiB and `Limits.max_alloc`;
the whole directory and source bytes must exist. Negative or excessive levels, unusable gains,
other keys and malformed metadata are ignored, retaining the existing decode and fallback.
Usable plain tags take precedence; invalid plain WB does not conceal valid encrypted WB.
Auto-WB fields and unverified Tag2010 layouts are not substituted for applied WB.
Synthetic tests cover both byte orders, offsets beyond the first recurrence period, fixed
keystream observations, an incomplete final word, precedence, invalid levels and gains, every
truncation of a procedural fixture, out-of-block offsets, absurd sizes and allocation limits.
The public RAW corpus pins the RX0 metadata and checks deterministic 16-bit development.
## Rendering limits
The full sensor buffer still passes through black subtraction, applied WB, demosaicing and
16-bit ProPhoto-compatible development. The embedded JPEG remains a comparison image; it is
not substituted for RAW pixels. All three files import and export at 6000 × 4000 in 16-bit RGB.
Local comparisons with the paired camera JPEGs and installed LightCraft 0.6.0 show reduced
WB casts, but PhotoCraft remains flatter, darker and less saturated. These ARWs have no DNG
colour matrix, so the existing neutral camera-to-sRGB fallback and scene-referred tone remain.
Camera JPEGs also have DRO Auto and lens/tone processing; they are visual references, not
linear sensor oracles. The remaining camera colour and tone work belongs to G14 and the open
colour/profile proposals above. Three uncontrolled scenes do not establish a camera calibration.