mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Read applied Sony RAW metadata and crop ILCE-6000 images
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -56,6 +56,7 @@ mod raf;
|
||||
mod rw2;
|
||||
mod sensor;
|
||||
mod sony;
|
||||
mod sony_metadata;
|
||||
mod tiff;
|
||||
mod tiffep;
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user