From 2eddd3073eda0882699e0ebcc4a34308dde889cd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 21:42:48 -0500 Subject: [PATCH] craft-raw: Sony ARW, ARW6, SR2 and lens corrections (#1051) * craft-raw: the file-format layer of camera raw files, with DNG and CRW A new crate for #587 and #778: bytes in, the sensor data out as a neutral RawImage (samples, CFA, levels, active area, crop, orientation, colour tags, opcode lists, profile tables), and back for DNG. One Cargo feature per format: dng (read and write), jxl (JPEG XL DNG), crw (through craft-crw). The base is LightCraft's crates/raw at 5ff567a4: dng, dngwrite, tiffraw, unpack, ljpeg, jxl, semantic, preview, the parsing half of opcodes, profile and gaintable, the model and probe. Demosaicing and everything after it stay with the develop pipeline. Ported from PhotoCraft's and FilmCraft's DNG readers, each with a test: the default crop's per-tag defaults, an unusable crop falling back to the active area, non-finite white levels, the white default of a linearized image, the AsShotWhiteXY range, 8-bit-only lossy JPEG, lossy tiles that must cover their part of the image, and the CinemaDNG frame tags. * craft-raw: Hasselblad 3FR and FFF (feature hasselblad) RawFormat::CfaTiff decodes: the DNG-layout reader plus what these files add. Uncompressed data or Hasselblad's lossless JPEG (selection value 8), RGGB when no CFAPattern is stated, an FFF's colour matrix from the 3FR header it embeds, and the 8-bit RGB preview handed on as a baseline TIFF. Moved from LightCraft's vendor/hasselblad.rs at 5ff567a4 with its tests. Ported from PhotoCraft's reader: four BlackLevel values without a BlackLevelRepeatDim are one per CFA site in a file that is not a DNG. * craft-raw: Minolta MRW, Epson ERF, Mamiya MEF and Kodak DCS520C Four formats only LightCraft decodes, one feature each (mrw, erf, mef, kodak), moved from its vendor modules at 5ff567a4 with their tests; the MRW block container comes from lightcraft-meta. src/shared.rs holds the level helpers the maker decoders share. ERF and MEF state no white balance: the estimate LightCraft made from the preview is develop-side work, so wb_multipliers is None for them here. * craft-raw: shared scan budget for tagged previews, tile-sized lossy JPEG budget, bound on Deflate tile padding Ported from storytold/lightcraft#3695. Candidate preview ranges are deduplicated and charged to one byte budget and one header-walk budget; a lossy JPEG decoded for a tile may hold no more samples than the tile rounded up to whole MCUs (and never more than Limits::max_lossy_tile_samples); a Deflate chunk whose nominal size is mostly padding outside the image is refused before it is inflated. * craft-raw: lossless JPEG sample bound per referenced byte; previews larger than the file are passed over Ported from storytold/lightcraft#3910 (the parts whose code is in craft-raw today). check_image refuses a lossless JPEG image that declares more than 32 samples per referenced byte, counted once per chunk that points at the bytes so identical tiles stored once still decode. The Hasselblad RGB preview search passes over an image whose pixel bytes exceed the file and still takes a smaller complete one. * craft-raw: test for the Deflate padding bound * craft-raw: Pentax PEF and Samsung SRW Moved from LightCraft crates/raw (vendor/pef.rs, vendor/srw.rs) at 5ff567a4. The Huffman difference decoder and bit reader that PEF shares with the Nikon, Panasonic and Sony codings become the huffman module. * craft-raw: Canon CR2, with sRAW and mRAW Moved from LightCraft crates/raw (vendor/cr2.rs, vendor/cr2/sraw.rs) at 5ff567a4. The white-balance presets a CR2 lists come back as data in ColorData::kelvin_presets (the ordering from LightCraft's kelvin_presets.rs; the temperature and tint arithmetic stays with the developer). * craft-raw: an sRAW preview larger than the file is refused before its buffer is reserved Ported from storytold/lightcraft#3910 (cr2::sraw::read_preview). * WIP craft-raw: Nikon NEF/NRW (does not compile yet: limits threading, develop() calls in tests) * craft-raw: Nikon NEF/NRW, with PhotoCraft's maker-note compression routing and 12-bit D4 white level * craft-raw: Panasonic RW2 / RWL / RAW, every raw format * craft-raw: RW2 format 8 refuses strips stating fewer bits than samples Ported from storytold/lightcraft#3910. * craft-raw: RW2 format 8 refuses samples above the declared bit depth Ported from storytold/photocraft#11838. * craft-raw: the RW2 format 8 robustness seed carries a valid stream Its random payload decoded only while out-of-depth samples were clamped; mutations now start from a file that decodes. * craft-raw: Fujifilm RAF, uncompressed and compressed, with lens corrections * craft-raw: compressed RAF stripes cap their capacity hint at the stripe's bits Ported from storytold/lightcraft#3910. * craft-raw: test RAF orientation and camera from the embedded JPEG's Exif * WIP craft-raw: Olympus ORF * craft-raw: RAF embedded preview, and its range from the header alone (bounded_preview) * craft-raw: Olympus ORF, the uncompressed layouts, with its maker-note preview and WB_GLevel * WIP craft-raw: Sony ARW * craft-raw: tiled lossless ARW decodes hold one group of tiles at a time Ported from storytold/lightcraft#3910. * craft-raw: dark 12-bit uncompressed ARWs keep Sony's 14-bit levels, and the DNG writer's depth holds the white level Ported from storytold/lightcraft#3429. * craft-raw: Sony ARW family, docs, seeds and the derivation records --------- Co-authored-by: radialmonster Co-authored-by: wnanbei Co-authored-by: lownlyrider --- Cargo.lock | 1 + crates/raw/Cargo.toml | 8 +- crates/raw/README.md | 11 +- crates/raw/docs/arw6-compression.md | 192 ++ .../docs/sony-ilce7rm6-lens-corrections.md | 379 ++++ crates/raw/docs/sony-lens-corrections.md | 388 ++++ crates/raw/src/arw.rs | 1653 +++++++++++++++++ crates/raw/src/arw6.rs | 995 ++++++++++ crates/raw/src/arw6_curve.rs | 55 + crates/raw/src/arw6_testenc.rs | 484 +++++ crates/raw/src/arw_lens.rs | 1526 +++++++++++++++ crates/raw/src/arw_lens_rm6.rs | 671 +++++++ crates/raw/src/cr2.rs | 1 + crates/raw/src/cr2/sraw.rs | 1 + crates/raw/src/crw.rs | 1 + crates/raw/src/dng.rs | 1 + crates/raw/src/dngwrite.rs | 7 +- crates/raw/src/erf.rs | 1 + crates/raw/src/kodak.rs | 1 + crates/raw/src/lens_metadata.rs | 189 ++ crates/raw/src/lib.rs | 34 + crates/raw/src/llvc.rs | 536 ++++++ crates/raw/src/mef.rs | 1 + crates/raw/src/mrw.rs | 1 + crates/raw/src/nef.rs | 2 + crates/raw/src/orf.rs | 1 + crates/raw/src/pef.rs | 1 + crates/raw/src/preview.rs | 16 + crates/raw/src/raf.rs | 1 + crates/raw/src/rw2.rs | 1 + crates/raw/src/shared.rs | 12 +- crates/raw/src/sr2.rs | 637 +++++++ crates/raw/src/srw.rs | 1 + crates/raw/src/tests.rs | 1 + crates/raw/src/tiffraw.rs | 43 + crates/raw/tests/it/dng.rs | 1 + crates/raw/tests/it/robust.rs | 1 + crates/raw/tests/it/vendor_robust.rs | 7 +- 38 files changed, 7850 insertions(+), 12 deletions(-) create mode 100644 crates/raw/docs/arw6-compression.md create mode 100644 crates/raw/docs/sony-ilce7rm6-lens-corrections.md create mode 100644 crates/raw/docs/sony-lens-corrections.md create mode 100644 crates/raw/src/arw.rs create mode 100644 crates/raw/src/arw6.rs create mode 100644 crates/raw/src/arw6_curve.rs create mode 100644 crates/raw/src/arw6_testenc.rs create mode 100644 crates/raw/src/arw_lens.rs create mode 100644 crates/raw/src/arw_lens_rm6.rs create mode 100644 crates/raw/src/lens_metadata.rs create mode 100644 crates/raw/src/llvc.rs create mode 100644 crates/raw/src/sr2.rs diff --git a/Cargo.lock b/Cargo.lock index 1aef35fe0..0d365b9ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1788,6 +1788,7 @@ dependencies = [ "proptest", "rayon", "serde", + "sha2 0.10.9", "thiserror 2.0.21", "zune-core", "zune-jpeg", diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 541cc3b6e..f812b2eb6 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "craft-raw" -description = "Camera raw files, the file-format layer: container parsing, decompression and the sensor data out as a neutral raw image with its levels, crop, orientation, colour tags and embedded previews. DNG (read and write), Hasselblad 3FR/FFF, MRW, ERF, MEF, Kodak DCS, Pentax PEF, Samsung SRW, Canon CR2, Nikon NEF/NRW, Panasonic RW2, Fujifilm RAF, Olympus ORF (uncompressed) and Canon CRW so far." +description = "Camera raw files, the file-format layer: container parsing, decompression and the sensor data out as a neutral raw image with its levels, crop, orientation, colour tags and embedded previews. DNG (read and write), Hasselblad 3FR/FFF, MRW, ERF, MEF, Kodak DCS, Pentax PEF, Samsung SRW, Canon CR2, Nikon NEF/NRW, Panasonic RW2, Fujifilm RAF, Olympus ORF (uncompressed), Sony ARW and Canon CRW so far." version.workspace = true edition.workspace = true license.workspace = true @@ -11,7 +11,7 @@ repository.workspace = true workspace = true [features] -default = ["dng", "jxl", "crw", "hasselblad", "mrw", "erf", "mef", "kodak", "pef", "srw", "cr2", "nef", "rw2", "raf", "orf"] +default = ["dng", "jxl", "crw", "hasselblad", "mrw", "erf", "mef", "kodak", "pef", "srw", "cr2", "nef", "rw2", "raf", "orf", "arw"] ## Adobe DNG, reading and writing: uncompressed, lossless JPEG, lossy JPEG, Deflate. dng = ["dep:miniz_oxide", "dep:zune-jpeg", "dep:zune-core"] ## JPEG XL compressed DNGs (DNG 1.7, compression 52546) via jxl-oxide (pure Rust, MIT OR Apache-2.0). @@ -40,6 +40,8 @@ rw2 = [] raf = [] ## Olympus / OM System ORF: the uncompressed layouts. Compressed ORF is refused (its embedded preview still reads). orf = [] +## Sony ARW, SR2 and SRF: uncompressed, ARW2 (cRAW), lossless JPEG tiles, ARW6 (Compressed RAW 2, RDD 34 LLVC), the encrypted SR2SubIFD, in-file lens corrections. +arw = [] ## Canon CRW (CIFF) through craft-crw, the one CRW decoder. crw = ["dep:craft-crw", "dep:chrono"] @@ -61,6 +63,8 @@ jxl-oxide = { version = "0.12", default-features = false, optional = true } [dev-dependencies] proptest = { workspace = true } +# The SHA-256 of the SR2 keystream known answer. +sha2 = { version = "0.10", default-features = false, features = ["std"] } # Encodes the lossy-JPEG tiles of the tests. jpeg-encoder = { version = "0.7", default-features = false, features = ["std"] } # Lossless JPEG XL encoder for the JPEG XL DNG tests. diff --git a/crates/raw/README.md b/crates/raw/README.md index 2a1e270d2..98bf1a221 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -56,9 +56,9 @@ One module and one Cargo feature per maker or container family. All are on by de | `cr2` | Canon CR2 | The lossless-JPEG frame of IFD 3 de-sliced (`0xc640`); the layout from `CR2CFAPattern` (`0xc5e0`); image area from `SensorInfo`; black measured on the masked columns; white from the saturation plateau; as-shot white balance from `ColorData` or `WBInfo`; the white-balance presets at their temperatures (`ColorData::kelvin_presets`). sRAW / mRAW (subsampled YCbCr, 4:2:2 and 4:2:0) come out as linear white-balanced RGB with three samples per pixel, the chroma gains measured against the file's own 16-bit RGB image in IFD 2 | | `nef` | Nikon NEF / NRW | Uncompressed (16-bit words, 12-bit packed, Coolpix word packing), the packed layouts labelled compression 34713, Nikon's Huffman coding (lossless and lossy types, 12 and 14 bits, "lossy after split") with the `0x0096` linearisation table, small raw (YCbCr 4:2:2) as linear white-balanced RGB; black from `0x003d` or the NRW block, as-shot white balance from `0x000c`, `CropArea` or the measured image area of bodies that record none, masked trailing columns left out of the active area, the D1X pixel aspect, and the in-file distortion correction as a `WarpRectilinear` opcode ([derivation](docs/nikon-lens-corrections.md)). High Efficiency (HE / HE★) is refused by name; the previews still read | | `rw2` | Panasonic RW2, Leica RWL, older Panasonic / Leica RAW | Packed blocks (formats 2, 5, 7), formats 4 and 6, format 8 prefix-coded strips and tiles (a sample outside the bit depth makes the strip corrupt), 16-bit words; black per colour from `0x001c`–`0x001e` (plus the 15 counts formats 4 and 6 store above it, 15 when no black is recorded); white from the saturation plateau; sensor borders as the active area and the in-camera aspect crop; mapped-out defect pixels filled; as-shot white balance from `0x0024`–`0x0026`; the in-camera distortion correction (`0x0119`) as a `WarpRectilinear` opcode. Raw formats 1 and 3 and compression 34826 are refused (no samples of them) | -| `mrw` | Minolta / Konica Minolta MRW | The `\0MRM` block container (`PRD`, `WBG`, `TTW`), 12-bit packed and 16-bit word mosaics, as-shot white balance from `WBG`, white from the saturation plateau; `mrw::parse` reads the block container alone | | `raf` | Fujifilm RAF | The RAF header, records and raw IFD; uncompressed Bayer and X-Trans (16-bit words, 12-bit LSB-first, 32-bit word packing); Fujifilm's lossless and lossy striped compression ([derivation](docs/raf-compression.md), [corpus](docs/raf-corpus.sha256)); black, as-shot white balance and, for bodies without colour matrices, the camera's neutral multipliers for Illuminant A and D65 (`ColorData::kelvin_presets`, a neutral pair); orientation, make and model from the embedded JPEG's Exif; the in-file distortion and vignetting corrections as opcodes; the embedded preview, also by range from the header alone (`bounded_preview`, `embedded_preview_from_range`). The pre-2010 FinePix layout without a raw IFD is refused | | `orf` | Olympus / OM System ORF | Uncompressed: 16-bit words (12-bit values in the top bits shifted down), 12-bit packed in 32-bit words (XZ-2), 12-bit two-field (older compacts), 16-byte blocks of ten 12-bit samples (E-300, E-330, E-500, high-resolution modes); CFA from Exif `CFAPattern`; black, as-shot white balance (relative to `WB_GLevel`) and crop from the maker note's ImageProcessing, or from the main note on bodies without it; the CameraSettings preview. Compressed ORF is refused ([why](docs/orf-compression.md)); its preview still reads | +| `arw` | Sony ARW (and the SR2 / SRF containers) | Uncompressed 16-bit words and packed 12-bit, ARW2 (cRAW, compression 32767), lossless JPEG tiles of 2 × 2 cells (compression 7) and the linear YCbCr of downsized lossless files, ARW6 "Compressed RAW 2" (compression 32766, SMPTE RDD 34 LLVC; [derivation](docs/arw6-compression.md)); the encrypted SR2SubIFD (every key) for black, white balance, crop and the white-balance presets at their temperatures (`ColorData::kelvin_presets`); crop from Sony's crop tags, the DNG-style default crop or the recorded image size; the in-file distortion, shading and lateral CA tables decoded with their provenance (`RawImage::lens_metadata`), and the validated distortion as a `WarpRectilinear` opcode ([7RM4A and others](docs/sony-lens-corrections.md), [7RM6](docs/sony-ilce7rm6-lens-corrections.md)); the Dynamic Range Optimizer flag of the embedded preview (`embedded_preview_dynamic_range_optimized`) | | `mrw` | Minolta / Konica Minolta MRW | The `\0MRM` block container (`PRD`, `WBG`, `TTW`), 12-bit packed and 16-bit word mosaics, as-shot white balance from `WBG`, white from the saturation plateau | | `erf` | Epson ERF (R-D1, R-D1s, R-D1x) | 12-bit samples in 16-byte blocks, black levels from the maker note, the measured picture origin. No white balance (the file states none) | | `mef` | Mamiya MEF (ZD) | Uncompressed 12-bit packed; default crop from the RGB preview's measured window. No white balance (the file states none) | @@ -163,6 +163,15 @@ Left as LightCraft has it, with the reason: red and blue levels are divided by ImageProcessing `WB_GLevel` (`0x011f`) when it is recorded, not always by 256. PhotoCraft's `ValidBits` (`0x0611`) and crop handling are covered by LightCraft's own (the main note's `0x102c`, and the top-bit test on the samples). Compressed ORF is not decoded: see `docs/orf-compression.md`. +- Sony ARW is LightCraft's reader. PhotoCraft's (uncompressed, ARW2, lossless JPEG; it refuses ARW6) adds + nothing it lacks: its SR2SubIFD reader knows one measured key (`11 22 33 44`), LightCraft's generator reads every + key. One difference was kept as LightCraft has it and needs the maintainer's eye: PhotoCraft frames the ILCE-7 + and ILCE-6000 at (12, 12, 6000, 4000) of the 6048 × 4024 data, measured on camera JPEGs and matching the file's + SR2SubIFD area; LightCraft anchors the recorded image size at the left, (0, 0, 6000, 4000). Both windows hold + picture (the data's last 26 columns are padding). Changing it would move every saved crop on those bodies in + LightCraft. +- Exif-only inputs a decoder needs are read from the Exif IFD directly: the 7RM6 lens tables are chosen by + `LensModel` and `FocalLength`. - Lossy JPEG is accepted for CFA data (one grey component) as in PhotoCraft; FilmCraft refuses it because DNG 1.4 allows the coding for LinearRaw only. diff --git a/crates/raw/docs/arw6-compression.md b/crates/raw/docs/arw6-compression.md new file mode 100644 index 000000000..76218afb6 --- /dev/null +++ b/crates/raw/docs/arw6-compression.md @@ -0,0 +1,192 @@ +# Sony ARW6 (Compressed RAW 2) + +The derivation record of this crate's ARW6 decoder, moved from LightCraft (`docs/arw6-compression.md`) with the code. It decodes the raw sensor data of Sony's *Compressed* and *Compressed (HQ)* ARW files (TIFF Compression 32766, SonyRawFileType 6, "ARW 6.0") written by the A7R VI (ILCE-7RM6), in pure Rust. The ILCE-7M5 (A7 V) writes the same codec; no sample of it was available, so it is unverified. + +Decode timings: see the end of this document (measured in the performance pass). + +## Format and implementation + +The codec is Sony's use of SMPTE RDD 34 (LLVC, Low Latency Video Codec). `src/llvc.rs` is the codec core (bit reader, variable-length decoder, dequantiser, inverse 5/3 wavelet); `src/arw6.rs` is the Sony container, geometry and colour reconstruction; `src/arw6_curve.rs` is the companding curve; `arw.rs` dispatches Compression 32766 to it. A test-only encoder (`arw6_testenc.rs`) builds synthetic tiles for round-trip tests. + +### Container + +- Raw SubIFD: Compression 32766, 14 bits per sample, BlackLevel 512 x4 (`0x7310`), WB `0x7313`, crop `0x74c7/0x74c8` (full frame: origin 12,8, size 9984x6656; APS-C: origin 16,10, size 6528x4352), CFA RGGB. The Sony tag-based white level (15360) is not read; the DNG `WhiteLevel` is (16383). +- The single strip starts with a tile table: `u32` count, `u32` 0, then per tile a `u64` offset (relative to the strip) and `u32` x, y, width, height. Seen: full frame 4 tiles of 5008x3336 (HQ) or 5024 / 4992 wide (Compressed); APS-C (6592x4372) one tile. Bytes up to offset 512 follow the table (480 on APS-C, 408 on full frame; not interpreted, not needed). +- A tile is a sequence of 16-byte words. Word 0 is RDD 34 `Picture_info` (HS = tile width, VS = tile height / 2, BBD = 16, NC = 3, NW = 3, NP = `1000000000b`; other values are refused as unsupported). Word 1 holds five `u24` group byte-sums, word 2 is zero, words 3-7 describe the groups (a count byte, then `u24` stream sizes in words). Groups: g0 = LL3 (3 streams, one per component), g1 / g2 / g3 = level-3 / 2 / 1 bands (3 streams each), g4 = green residual (1 stream). Streams follow in that order from word 8. +- A stream is a header word (a `u16` count of index words, a `u24` count of data words, then 11 bytes that are not interpreted), an index table and data, each padded to a word. The index has one entry per transform unit (TU): `A(16) X B(16) X`, where A and B are the byte lengths of the TU's two halves and X are quantiser-index (QI) nibbles (one for g0 and g4, three for g1-g3: HL, LH, HH). Halves per tile = ceil((VS - 2 - s) / 8) + 1 with s the first-half shift (below), TUs = ceil(halves / 2): 105 and 137 for the sample sizes. Halves are numbered n = 2 * TU + h; each is byte-aligned and consecutive halves are contiguous. +- QI values seen, identical in Compressed and HQ files: level 1 component 0 (1,1,2), components 1-2 (2,2,3); level 2 (0,0,1); level 3 (0,0,0); LL3 0; residual 2. Compressed and HQ differ only in the coefficient data. + +### Entropy coding and dequantisation + +- Each line of a band is an RDD 34 section 6.3 coefficient-set stream: ceil(width / 4) sets of four coefficients driven by the DPT / ABS / SGN state machine, with zero runs and the "0^lim" end code. Values past the width are discarded. Lines of a half follow one another bit-contiguously; the state resets per line. +- Two deviations from the RDD 34 text were established by black-box comparison: in state DPTs = 0 with DPT = 0, a "0" code is a zero set that also enters DPTs = 1; and a run code of value V means V - 1 further zero sets before the post-run "0^n 1" depth code. +- Dequantisation is `recon(q) = sign(q) * (((2|q| + 1) << (QI - 1)) - (|q| & 1))`, `recon(0) = 0`, and QI = 0 leaves q unchanged. This replaces RDD 34's plain shift. +- LL3 lines are DPCM: a running sum along the line. The 2048 base (codes are unsigned 12-bit) applies to component 0 (the green mean) only; the chroma planes start at 0. +- Check: every half's lines end inside its last byte. The Phase 0 measurement found budget - 8 < consumed <= budget on every half of all 8 files; the decoder rejects a half that does not. + +### Frame geometry + +- Each tile holds three W/2 x H/2 planes (W, H = tile size): component 0 = M, the green mean; components 1-2 = chroma c1, c2; plus a W/2 x H/2 residual plane from g4. A three-level 5/3 wavelet is inverted (vertical, then horizontal) with whole-sample symmetric extension at every edge and integer arithmetic. Tiles are independent. +- The TU grid and the lifting phases are anchored to the full sensor, not to the tile. Let s = (lines in g4's first half) - 2, read from the stream (residual lines are decoded while at least 8 bits of the half remain, at most 8): 0 on full frame, 3 on the APS-C crop (the crop's sensor-row offset 573 is 5 mod 8). Half n then covers plane rows 8n - 6 + s .. 8n + 1 + s, clipped to the frame. The lifting phases follow from s: full frame (0,1,1), APS-C (1,0,0). Each half holds, in clipped natural order, its rows of HL1, LH1, HH1, HL2, LH2, HH2, LL3, HL3, LH3, HH3 and, in g4, 8 residual rows; edge halves are shorter. With the right s every line of every file is used. + +### Colour reconstruction + +With plane row j, column x (x +- 1 clamped at the edges), in 12-bit code units: + +- `G1[j][x] = M[j][x] - ((res[j-1][x] + res[j-1][x+1] + res[j][x] + res[j][x+1] + 4) >> 3)`, with `res[-1] := res[0]`. +- `G2[j][x] = ((G1[j][x-1] + G1[j][x] + G1[j+1][x-1] + G1[j+1][x]) >> 2) + res[j][x]` (unclipped G1; the last row uses G1[j] for G1[j+1]). +- `R = 2*c1 + ((clamp(G1, 0, 4095) + clamp(G2, 0, 4095)) >> 1)`, `B = 2*c2 + (same)` (a negative green, as in deep high-ISO shadows, counts as 0). +- Every code is clipped to 0..4095. Per 2x2 cell: R (0,0), G1 (0,1), G2 (1,0), B (1,1). + +### Output range (companding curve) + +Codes map through one monotone curve, the same for every file and both modes. It is not stored in the file (below); it is our own measurement. Output values are in 2 x 14-bit DN: black 2 x 512 = 1024, the 14-bit white 16383 maps to 32766. + +- The curve is piecewise linear through knots (c_k, u_k), k = 0..306: u_k = 128 k (one knot per 64 DN of the 14-bit scale); c_k = u_k for k <= 11 (the identity through code 1408); c_k = 356.89 + 655.646 ln(k - 6) for k >= 12 (that is, c = -2824.32 + 655.646 ln(u - 768)). F(code) is the linear interpolation of u over c, rounded half up (floor(x + 0.5)). Values: F(1408) = 1408, F(1536) = 1542, F(2048) = 2457, F(3000) = 7979, F(3500) = 16227, F(3977) = 32767, F(4095) = 39077. +- `arw6_curve.rs` builds the 4096-entry table from the formula at first use; a unit test pins the whole table (its sum and position-weighted sum), so a platform whose `ln` rounds differently fails it. +- The decoder reports black 1024 and white = 2 x the file's DNG `WhiteLevel` (16383), i.e. 32766; a file without a usable white level gets 32766 too (2 x 16383), not a data-derived value. Codes above 3977 sit above white and clip as usual. + +#### How it was measured + +Each of the four scenes of the CC0 set (APS-C and full frame, landscape and portrait) has a lossless (LJ92) file and a Compressed and an HQ file, shot handheld one after another. Seven Compressed / HQ files share shutter, aperture and ISO with their lossless sibling. The eighth, APSC_land RAW-C (1/500 s against 1/1000 s), was left out: its brightness ratio to the lossless file is 1.37 rather than 2 and drifts with level, so the light changed between the shots. + +- Data: the ARW6 12-bit codes from LightCraft's decoder with the curve replaced by the identity, and the lossless 14-bit DN from LightCraft's LJ92 path. Both modes share the sensor origin (their crop tags are equal); the lossless frame is the ARW6 frame padded with zeros at the right and bottom. +- Registration: the handheld shifts are large (median 3 to 102 green cells). Patches were registered locally on the green planes with a curve-independent rank transform, normalised cross-correlation and a neighbour-consistency filter. +- Two estimators per pair and CFA channel, each capture with its own gain and offset (the gains' geometric mean fixed to 1, the offsets' mean to 0): + - quantile (CDF) matching over all overlapping pixels: for each code, the mean lossless value over the slice of the distribution the code occupies (precise, slightly smoothed by compression noise); + - block pairs: smooth 8 x 8-cell blocks (16 x 16 sensor pixels) away from clipping, a model linear in F, fitted robustly (unbiased). +- Fitting c = C + A ln(u - B) through the knots gives A 655.88, B 766.9, C -2826.5 on the blocks and A 653.47, B 770.1 on the quantile curves. Holding B at 768 changes the curve by at most 0.08 %; the formula above (A 655.646, C -2824.32) uses it. + +Uncertainty: 95 % intervals of a cluster bootstrap over the 7 captures (40 replicates), as a share of F - 1024 and in codes, and the largest deviation of any subset or method fit (Compressed only, HQ only, greens, red + blue, APS-C, full frame, quantile method): + +| Codes | Bootstrap 95 % | In codes | Largest subset / method deviation | +|---|---|---|---| +| 1408-1536 | -0.42..+0.35 % | -1.6..+2.0 | 0.75 % | +| 1536-2048 | -0.65..+0.58 % | -3.0..+2.8 | 0.80 % | +| 2048-2560 | -0.55..+0.57 % | -3.1..+3.3 | 0.78 % | +| 2560-3072 | -0.59..+0.50 % | -3.1..+3.7 | 0.64 % | +| 3072-3584 | -0.76..+0.54 % | -3.5..+4.9 | 0.77 % (quantile) | +| 3584-3840 | -0.84..+0.57 % | -3.7..+5.5 | 0.90 % (quantile) | +| 3840-3977 | -0.88..+0.59 % | -3.8..+5.8 | 0.97 % (quantile) | +| 3977-4095 | -0.91..+0.62 % | -4.0..+5.9 | 1.03 %, extrapolated | + +The lossless files clip at the 14-bit white, so codes above about 3977 rest on the formula alone. The camera's exact integer table can't be recovered: integer knot codes, the encoder's rounding and a few codes around the knee are below the measurement's resolution. + +#### Evidence for the structure + +- Knots every 128 u: the measured slope dF/dc is flat within segments and jumps at codes 1531, 1632, 1719, 1796, 1865, 1928, 1984, 2037, 2085…, where u = 1536, 1664, 1792…. On codes 1380-2200 a spacing of 128 leaves 0.021 RMS relative slope error; the next best, 126, 64 and a smooth log, leave 0.037, 0.041 and 0.068. Above code 2200 the segments are too short to resolve, but there the piecewise-linear and smooth forms differ by less than 1. +- Log spacing: the measured knot codes follow c = C + A ln(u - B) with chi-squared per degree of freedom 0.90 (bootstrap 95 %: A 654.3-658.2, B 762.8-771.1). +- The knee: the log branch at u = 1408 gives code 1413 (95 % 1410-1415), so the identity runs through knot 11 and the log knots start at k = 12 (segment slope 1.035 between them). Starting them at k = 11 fits as well but would need a slope below 1 right after the identity; a smooth log leaving the identity near code 1340 fits the blocks clearly worse (cost 25.4 against 22.9). +- One curve for everything: Compressed and HQ, all four CFA channels, APS-C and full frame agree within 0.8 %, and the ratio is flat with frame radius to +-0.5 % (no gain map). + +#### Scale + +The x2 unit is measured, not assumed: + +- Below the knee F(c) = c: the measured slope is 1.000 +- 0.01 for codes 1150-1408, with offsets within +-2. +- The overall level is fixed by the captures' average exposure to +-0.4 % (per-capture gains 0.99-1.02). Independently, flat clipped areas of the Compressed files sit at code 3977 +- 1 in all four scenes, and the formula, not fitted to that, puts the 14-bit white (32766) at code 3977.0 (bootstrap 95 % 3973-3983). +- HQ files carry data above the 14-bit clip: their clipped highlights sit at codes 3996-4095 depending on the scene (about 16.8k-19.5k DN), so the curve keeps its values above 32766. The reported white level is unaffected. + +#### Not stored in the file + +Every IFD of all 12 files was dumped and compared across modes: + +- Raw SubIFD: `0x7010` (SonyToneCurve) and `0x7011` are zero. The monotone arrays `0x7032`, `0x7035` and `0x7037` are lens corrections, the same in every mode. The only mode-dependent tag is `0x7000` SonyRawFileType (4 against 6). +- Maker note (`0x9050`, `0x94xx`, deciphered): the Compressed and HQ bytes differ from the lossless ones only in 1-2 bytes (the quality setting), and hold no curve-shaped monotone run. +- SR2SubIFD (encrypted, 42478 bytes): every file uses the same keystream, so XOR-ing two files compares their plaintexts. The lossless and HQ files of a scene differ in only 34 bytes, so there is no ARW6-only region. A curve stored identically in every mode can't be ruled out, but none is known there. `0x7250` likewise has no mode-specific region. +- The bytes after the tile table (480 on APS-C, 408 on full frame; the last 120 high-entropy) are identical in the Compressed and HQ files of a scene, even at different exposure, and differ between scenes: not a curve. + +#### Verification against the oracle + +After the derivation was frozen, the derived table was compared with the black-box oracle's output curve (below), as a check only, with no tuning. They are identical through code 1422 (both are the identity through 1408), then within -1.0 % to +0.25 % of (value - black); the largest difference is 75 (0.2 %) at code 4093. Ours minus the oracle's: -5..+1 for codes 1408-1536, -13..-2 for 1536-2048, -33..-12 for 2048-3072, -34..+40 for 3072-3977, +1..+75 for 3977-4095. + +## Verification + +Method: LibRaw master (commit `d1f0dd95`, 2026-10-08, run as `unprocessed_raw`, 16-bit output before any processing) was used only as a black-box oracle: it was built and run, and its source was never read. A Python reference model of the format (the Phase 0 reference model, local, not in the repo) was written from RDD 34 and from the oracle's behaviour on real and synthetic input, then reproduced the oracle exactly on every tile of the 8 Compressed / HQ files below. A test-only encoder, validated byte for byte against real tiles, probed the oracle with synthetic coefficients (dequantiser, phases, edge mapping). The Rust decoder reproduced the Phase 0 reference sums on all 8 files. Its output then went through a table read off the oracle's output; that table was strictly increasing, so these exact matches prove the 12-bit codes before the curve identical to the oracle's. The table has since been replaced by our own measured curve (*Output range*), so the output values now differ from the oracle's by the amounts in *Verification against the oracle*. + +Geometry was checked against each file's embedded full-resolution JPEG at the DefaultCropOrigin: our output aligns at offset 0 on full frame and APS-C. + +### LibRaw's APS-C bug + +LibRaw assumes the full-frame grid (s = 0) for every tile. On the APS-C crop that drops the first three plane rows, leaves the last three unfed, and shifts its whole output up by 6 sensor rows; its first rows and last rows are also wrong. Our decoder differs from LibRaw there on purpose. To compare against LibRaw on APS-C files, our row y+6 equals LibRaw's row y for 12 <= y < h - 24 (LibRaw's rows 0-11 and the last 18 rows of the shifted overlap differ); on full frame compare directly: the 12-bit codes match over the whole frame. + +### Corpus + +| File | Source path (RawDB, ILCE-7RM6) | Size (bytes) | SHA-256 | Fetched by | +|---|---|---|---|---| +| `arw6-sony-ilce7rm6-apsc-land-hq.arw` | `arw6/ILCE-7RM6_APSC_land_RAW-C-HD.ARW` | 29 720 576 | `b60a7e752d2a8102ccbe6eebcfdbf533c6b0f2545739090873e9e8eb32cc0637` | `cargo xtask corpus --download` | +| `arw6-sony-ilce7rm6-apsc-land-c.arw` | `arw6/ILCE-7RM6_APSC_land_RAW-C.ARW` | 30 900 224 | `1c803304e63ec508b2b5f9d59d4a7e30ed279315b35fa4c5d75f959bed62198b` | `curl -L` | +| `arw6-sony-ilce7rm6-apsc-port-hq.arw` | `arw6/ILCE-7RM6_APSC_port_RAW-C-HD.ARW` | 28 758 016 | `0b6d782571e86868670f613de9fa1f1364623b24fbfd21b704b2c516d7a1c1cb` | `curl -L` | +| `arw6-sony-ilce7rm6-apsc-port-c.arw` | `arw6/ILCE-7RM6_APSC_port_RAW-C.ARW` | 29 044 736 | `c41674a485ffcf6d873e20e545ae62936192d94e95be3d53548e93bc543ecfd4` | `curl -L` | +| `arw6-sony-ilce7rm6-ff-land-hq.arw` | `arw6/ILCE-7RM6_FF_land_RAW-C-HD.ARW` | 63 451 136 | `559fa26501d7813e56afecde2aea3d9965a99228162b194752d986193abb172c` | `curl -L` | +| `arw6-sony-ilce7rm6-ff-land-c.arw` | `arw6/ILCE-7RM6_FF_land_RAW-C.ARW` | 63 844 352 | `d900d6cbe838be864269ad54de4a33fcac7eb3a3d4f91009739f770b8f85a23b` | `cargo xtask corpus --download` | +| `arw6-sony-ilce7rm6-ff-port-hq.arw` | `arw6/ILCE-7RM6_FF_port_RAW-C-HD.ARW` | 62 640 128 | `81c709633cb8f6340e612c3f53bb6ec6b0cccfe5b713c67efb1e977ece9e266d` | `curl -L` | +| `arw6-sony-ilce7rm6-ff-port-c.arw` | `arw6/ILCE-7RM6_FF_port_RAW-C.ARW` | 63 168 512 | `f4448485629c3f82ff1955fd046404350e9a1f09b346038574b89c074d7c50fd` | `curl -L` | +| `arw-sony-ilce7rm6-apsc-land-lossless.arw` | `raw_modes/ILCE-7RM6_APSC_land_RAW.ARW` | 44 167 168 | `7aaf0b3941509d7364a6f03844783155ebc912d246d1c456e5cdb4693ec09fbc` | `curl -L` | +| `arw-sony-ilce7rm6-apsc-port-lossless.arw` | `raw_modes/ILCE-7RM6_APSC_port_RAW.ARW` | 43 692 032 | `0f011933ee96525a39710daa50b59d39702b4ec5934fa0206742e2128c55f366` | `curl -L` | +| `arw-sony-ilce7rm6-ff-land-lossless.arw` | `raw_modes/ILCE-7RM6_FF_land_RAW.ARW` | 100 851 712 | `5cb3c870bc961cf6b079d56c860d0cdf11acd1afd04e5fb8dbe041bcfd2d6201` | `curl -L` | +| `arw-sony-ilce7rm6-ff-port-lossless.arw` | `raw_modes/ILCE-7RM6_FF_port_RAW.ARW` | 100 884 480 | `b1a4026aa6adbaa27973b8b3426d8b4aa0c1cae1845cf5102359327d3b069691` | `curl -L` | + +These 12 files are CC0 1.0. RawDB's record of the set, (the data behind , accessed 2026-10-10), says `"license":"CC0 1.0"` for the set (uploaded by abbradar, 2026-08-29) and again for each of the 12 files, next to the SHA-256 values in the table. + +They live in the gitignored corpus. `cargo xtask corpus --download` fetches two of them, one per geometry and compression mode (full frame Compressed, APS-C HQ), and checks each against its SHA-256, pinned in `RAW_SAMPLES` (`xtask/src/main.rs`). Fetch the other ten by hand: the server answers 303 to a signed object URL, so `curl -L -o corpus/raw/ https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/`, then compare with the SHA-256 above. `cargo test -p lightcraft-raw corpus_sony -- --nocapture` checks the decoder's sensor sum and position-weighted sum of the two pinned files and skips absent files. The sums are regression values from LightCraft's decoder with the derived curve; the 12-bit codes behind them were verified against the oracle in Phase 0. + +Nine further full-frame HQ photos from the maintainer's A7R VI were compared with the oracle locally; they are private and never committed or published. On all 9 (ISO 100-12800, incl. two near-black night shots) the 12-bit codes matched the oracle over the full frame (0 mismatching pixels of 66.8 MP each). + +Unconditional synthetic tests (encode -> decode round trips for both geometries, the RDD 34 worked example, the dequantiser, hostile and truncated input) run without the corpus. + +## Coverage + +| Coverage | Status | +|---|---| +| ILCE-7RM6 Compressed and HQ, full frame, landscape and portrait | 4 files, 12-bit codes exact against the oracle; output through our measured curve | +| ILCE-7RM6 Compressed and HQ, APS-C crop, landscape and portrait | 4 files, 12-bit codes exact against the Phase 0 model (interior-exact against LibRaw after the 6-row shift; LibRaw's own edge rows differ) | +| ILCE-7RM6 lossless (existing LJ92 path) | 4 files decode; not in the corpus test | +| Private A7R VI HQ photos | 9 files, 12-bit codes exact over the full frame against the oracle (local) | + +## Not covered + +- The 12-bit Compressed mode (electronic shutter with continuous drive): no sample exists, so it returns `Unsupported` unless its headers prove identical. +- The ILCE-7M5: same codec, no sample; unverified. +- Tiles whose plane width is not a multiple of 8. +- First-half shifts other than 0 and 3 (other crops / bodies): the geometry is generic and round-trip tested, but no real sample exists. +- Memory: one FF decode peaks at about 896 MB RSS (about 6.7 x the 134 MB output; the lossless path peaks at 249 MB). Follow-ups: dequantise straight into bands, place tiles as they arrive. +- Colour: the ILCE-7RM6 bundled colour profile and Sony's embedded lens corrections are separate work. + +## Sources + +- SMPTE RDD 34:2015 "LLVC (Low Latency Video Codec) for Network Transfer", (accessed 2026-10-09): picture / TU structure, VLD, inverse quantisation, 5/3 lifting with symmetric extension. +- LibRaw master `d1f0dd95`, run only as a black-box oracle (not a product, test or build dependency) for the 12-bit codes; the companding curve was measured without it and compared with it only afterwards. +- The embedded JPEG of each file, for the geometry check. +- Not read, by licence: LibRaw, rawspeed, dnglab, RawTherapee and other ARW6 decoder sources. Exact byte layouts, deviations and geometry were established through sample analysis and black-box comparison; the curve was measured from the CC0 lossless / ARW6 pairs. + +## Timings + +Raw decode only, 4 CPUs, release build, best of 3 wall-clock (2026-10-10). Method: a small throwaway Rust program linking `lightcraft-raw` reads the file into memory, then times `lightcraft_raw::decode` alone with `std::time::Instant` (no render, no JPEG encode), three decodes in one process; the best is reported. Files were already in the page cache. + +| File | Size | Sensor | Decode | +|---|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 63.5 MB | 10016x6672 | 0.45 s | +| ILCE-7RM6 FF, Compressed | 63.8 MB | 10016x6672 | 0.51 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 29.7 MB | 6592x4372 | 0.35 s | +| ILCE-7RM6 APS-C, Compressed | 30.9 MB | 6592x4372 | 0.34 s | + +The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the tiles, over the 13 streams of each tile and over its 3 components, and the colour reconstruction over rows. + +2026-10-12 performance pass (output unchanged, bit for bit): the vertical wavelet pass lifts whole rows instead of gathering each column, `0^n 1` codes are counted with one `leading_zeros`, and the colour reconstruction reads rows as slices, in parallel. Same method, best of 5, the previous and the new decoder run alternately on the same machine (4 CPUs, which was busier than on 2026-10-10), best of two rounds: + +| File | Before | After | +|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 0.56 s | 0.46 s | +| ILCE-7RM6 FF, Compressed | 0.54 s | 0.38 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | +| ILCE-7RM6 APS-C, Compressed | 0.35 s | 0.24 s | + +2026-10-13 (output unchanged, bit for bit): the two halves of each stream, which are independent bit streams, decode in parallel; the vertical wavelet pass runs its rows in parallel; the horizontal pass lifts each row in place (scratch space reused per thread), rows in parallel, instead of allocating two vectors per row. Same method, best of 3, the previous and the new decoder run alternately, on another machine (Ryzen 7 9800X3D, a 4-CPU cgroup quota shared with other builds); CPU is the process CPU time of the decode (all threads): + +| File | Before | After | CPU before | CPU after | +|---|---|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 0.60 s | 0.48 s | 1.14 s | 0.87 s | +| ILCE-7RM6 FF, Compressed | 0.62 s | 0.48 s | 1.14 s | 0.89 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | 0.50 s | 0.40 s | +| ILCE-7RM6 APS-C, Compressed | 0.31 s | 0.24 s | 0.51 s | 0.42 s | diff --git a/crates/raw/docs/sony-ilce7rm6-lens-corrections.md b/crates/raw/docs/sony-ilce7rm6-lens-corrections.md new file mode 100644 index 000000000..216fc4b63 --- /dev/null +++ b/crates/raw/docs/sony-ilce7rm6-lens-corrections.md @@ -0,0 +1,379 @@ +# Sony ARW lens corrections + +> Moved from LightCraft (`docs/sony-ilce7rm6-lens-corrections.md`) with the decoder (`src/arw_lens.rs`, `src/arw_lens_rm6.rs`). It records how the tables were derived and verified; mentions of LightCraft's settings and UI describe that app. + +Sony cameras record three lens-correction tables in the raw IFD of every ARW: distortion, vignetting and lateral +chromatic aberration (CA). LightCraft reads them and turns distortion into a DNG `WarpRectilinear` opcode, so it +reaches the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp and Panasonic's +RW2 distortion data. Lateral CA is parsed and converts to the warp's red and blue planes, but it is **off by default**: +applied after demosaic it made the render's CA worse (Verification). Vignetting is read but not applied (below). The +conversion is verified on the **ILCE-7RM6** and, for distortion, the **ILCE-6700** ([ILCE-6700](#ilce-6700)); the +ILCE-7RM4A has its own conversion, with other conventions +([Two conventions](#two-conventions)), and other Sony bodies' tables give no correction until they are verified. + +The correction is on by default for new imports, as for any file with embedded lens data (`Photo::camera_defaults`). +Photos imported before this change keep their settings: **Photo → Reload from Disk** adds the lens data and, when the +photo is still at its import settings, turns the correction on as one undoable step; the automatic reload after an +external editor saves the file does the same. Edited photos keep their settings. The Profile Distortion slider +scales the warp as for DNG and RW2. + +## The tables + +ExifTool's names (EXIF table, `SubIFD` group); each table is a count-prefixed array of signed 16-bit values +(`SSHORT`, 17 / 17 / 33 values), and each has an in-camera on/off tag: + +| Table | Tag | Flag tag | Count (full frame / APS-C crop mode) | Flag values seen | +|---|---|---|---|---| +| Distortion (`DistortionCorrParams`) | `0x7037` | `0x7036` | 16 / 11 | 0 (off) or 1 (on) | +| Vignetting (`VignettingCorrParams`) | `0x7032` | `0x7031` | 16 / 11 | 2 on all 54 photos | +| Lateral CA (`ChromaticAberrationCorrParams`) | `0x7035` | `0x7034` | 32 / 22 (red, then blue) | 1 on all 54 photos | + +The first value is the knot count; only the first `count` values are used. Full-frame files carry 16 / 16 / 32 knots; +APS-C crop-mode files 11 / 11 / 22, padded to the same array length (distortion with its last value repeated, +vignetting and CA with zeros). The CA table holds red's `count / 2` knots, then blue's. The corrections are built +whatever the flags say: the flag only records what the camera did to its own JPEG. Tables with a count outside +2–16 (CA: an odd count or outside 4–32), a count that doesn't fit the array, or non-integer values are ignored. + +## Measured conventions + +None of this is documented; it was measured against the camera's own JPEGs (method below). Residuals are in px at a +1440 px long edge. + +- **Knots and centre.** Knot `i` sits at sensor radius `i · s` from the default-crop centre, with + `s = hypot(9984, 6656) / 30` = 399.98 px: the full-frame default crop's half diagonal divided by 15, so knot 15 is + the full-frame crop corner. The spacing is fixed on the sensor: APS-C files use the same pixel spacing (verified on + CA, rms 0.004–0.010 px against 0.011–0.014 px for `i/15` of the APS-C half diagonal; `i/10` of it, 392 px, fits as + well). Values are interpolated linearly between + knots; beyond the last knot the last segment is extended (it matters only in the outermost ~0.4 knot spacing at + the corners of a zoomed-out barrel correction). The centre is the default-crop centre: the measured translation + between our render and the camera JPEG has median 0.013 px, max 0.216 px. +- **Distortion** is relative and indexed at the corrected radius, in units of 1/15000: a corrected radius `r` + reads the source radius `S(r) = r · (1 + v(r) / 15000)` (positive values: pincushion; negative: barrel). The camera + then zooms so the output fills the default crop: output radius = `z` · corrected radius, with `z` chosen so that + the largest `S(ρ / z) / ρ` over the crop's border radii `ρ` is 1 (binding at the corner for pincushion, at the + short-edge midpoint for barrel). `z` was 0.971–1.035 on these photos. Without the zoom every hypothesis fails + (24.6 px); with it, the worst residual over the 34 measurable corrected photos was 0.596 px (rms 0.143; corners and + edge midpoints 0.570 px), within the 0.75 px budget for a unit between about 14700 and 15350. A global + least-squares fit gives 15000.7. +- **Lateral CA** is relative to the green source radius, at that radius: red's source radius is + `g · (1 + c_R(g) / 3.36·10⁶)`, with `g` the green source radius (after the distortion correction above) and `c_R` + the red half on the same knots; blue likewise with the second half. Positive values: that colour's image is larger + than green's. **The unit is provisional.** The measurement is borderline: on the 15 photos with CA above 0.15 px + that a single unit explains, the best (minimax) unit 2.26·10⁶ leaves 0.305 px against a 0.3 px budget; per-photo + best units scatter from 1.5·10⁶ to 7.2·10⁶ (about ±30 % scene bias: the RawDB landscape and portrait shots of one + scene, with nearly identical tables, measure 3.4–3.5·10⁶ and 4.5·10⁶). The chosen 3.36·10⁶ is the median of the 20 + cleanest photos: worst 0.399 px on the 15, and the rms raw CA over all 46 measured photos falls from 0.079 to + 0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. **Not applied by default** + (Verification). +- **Vignetting: not applied.** The camera JPEGs show no vignetting compensation relative to our uncorrected render: + over the 39 photos whose measurement reaches the corners, the corner/centre ratio JPEG / render has median 0.996 + (p10 0.907, p90 1.087), while the tables would predict a corner gain with median +32 % for `1 + v / 2¹⁴` (+16 % + for `v / 2¹⁵`, +63 % for `v / 2¹³`). Regressing the measured corner EV on the predicted one gives slope 0.009 ± + 0.063. No model (`1 + v/U`, `U/(U − v)`, `2^(v/U)`) at any unit from 2¹³ to 2¹⁸ reaches 3 %; the identity is the + best of all. The raw data isn't pre-compensated either: the read noise of three dark high-ISO frames is flat from + centre to corner, where a digital gain from these tables would raise it by 60–80 %. So the table probably describes + the lens's natural falloff, but this camera's JPEGs don't apply it, and nothing shows its unit or model. Options: + (a) skip it, so renders match the camera JPEG (the current ruling); (b) apply it with an unverified model for a + Lightroom-like look; (c) shoot flat fields (a white wall at several apertures, one lens) and measure the model. + +## Conversion to `WarpRectilinear` + +`src/arw_lens_rm6.rs` builds one `WarpRectilinear` in `OpcodeList3`, centred on the default-crop centre +(DNG pixel-index units relative to the active area; radius 1 = the farthest active-area corner, `m`): + +1. `S` from the distortion table and the zoom `z` (bisection on [0.5, 2], 4097 border radii); the green map is + `G(r) = S(r / z)`. Red and blue, when the CA planes are on: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`. +2. Each map is sampled at `u = i / 64`, i = 0…64 (65 samples of `G(u · m) / m`) and fitted by least squares with + DNG's 4-term odd polynomial `u · (k0 + k1 u² + k2 u⁴ + k3 u⁶)` (`vendor::fit_radial_warp`, shared with RW2). +3. **Fit gate:** a plane is refused when its fit is worse than 0.25 px at a 1440 px long edge at a sample the crop + shows: the samples up to the first one at or beyond the crop corner. The lossless files' padded active area + (10240 × 7168 for a 9984 × 6656 crop) reaches radii that are never shown (`u` up to 0.925 is shown there). + Over all 54 photos the fit's worst error at all 65 samples was 0.220 px (a lossless file; ARW6 files at most + 0.175 px). Between samples the error is somewhat larger: 0.071 against 0.049 px for the full-frame 16–35 mm + example in the tests. +4. **By default one plane, G**, applied to all three channels: `lens_opcodes` calls `warp_opcode(…, lateral_ca: + false)`. With `lateral_ca: true` (a code change) the planes are `[R, G, B]`, unless the CA table is absent or + malformed, a CA value is implausible (|c| above 2·10⁻³ of the radius, i.e. 6720; the largest value in the 54 + photos' tables is 1724) or a colour plane fails the gate: then G alone again. The G plane is the same either way. + No opcode when the distortion table is absent or all zero (CA alone is never applied: it is relative to green), + the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo uncorrected rather than + wrongly warped. + +The opcode then goes through the same pipeline stage as a DNG's (`crates/pipeline/src/optics.rs`): it rotates with +the photo (portrait files are covered) and is carried by DNG export, as RW2's is. + +## Which bodies get it + +Only bodies on one of two allowlists in `arw_lens_rm6.rs` get an opcode: `FULL_FRAME_CROP` (today `ILCE-7RM6`, +9984 × 6656) and `APSC_BODIES` (today `ILCE-6700`, below). The unit and the knot spacing are verified per body; a body +with another sensor size or another unit would be warped wrongly. Other bodies get no correction from this conversion; +the ILCE-7RM4A gets its own, with other conventions (below). To add a body: collect photos of it shot with distortion +correction on (several lenses and focal lengths), repeat the camera-JPEG registration below against the uncorrected +render, confirm the unit, the knot spacing and the zoom rule (or measure the body's own). Then add a full-frame body's +default crop to `FULL_FRAME_CROP`, or an APS-C body whose knots span its own crop (as measured for the ILCE-6700) to +`APSC_BODIES`, with a test; an APS-C body also needs its fit gate checked against the camera JPEG (see *ILCE-6700*). + +For bodies in `APSC_BODIES` (today `ILCE-6700`), the knots span the file's own default crop: spacing = its half +diagonal / (knot count − 1). This crop-based rule applies only to that list. The ILCE-7RM6 keeps its sensor-fixed +400 px in both modes; in full frame (16 knots) the crop-based rule gives the same 400 px, and in its APS-C mode +(11 knots) it would give 392 px, which was only an alternative measurement there (it fits as well, above). + +## ILCE-6700 + +The ILCE-6700 (APS-C, 6192 × 4128 default crop) writes 11-knot tables like the ILCE-7RM6's APS-C mode. Measured as +above on 30 photos shot with distortion correction Auto: Sigma 10–18 mm F2.8 DC DN (9 photos, 10–18 mm), Tamron +17–70 mm F2.8 B070 (13, 17–70 mm) and Sony E 70–350 mm G (8, 70–350 mm), all private, only the summary numbers here. +The convention is the ILCE-7RM6's (corrected-radius index, unit 15000, frame-filling zoom), with the knot spacing +the APS-C half diagonal / 10 = 372.09 px: + +| | p95 residual at 1440 px | +|---|---| +| Uncorrected | 3.8–5.6 px (19 photos; on the other 11 the shift exceeds the method's ±20 px search) | +| Sensor-fixed 400 px (the ILCE-7RM6's pixel spacing) | 0.56–3.71 px (passes only at 10 mm) | +| **Half diagonal / 10 = 372 px, unit 15000** (exact table) | **0.23–0.68 px on 27 photos (median 0.43); 1.34–1.42 px on the three 10 mm photos** | +| The same as LightCraft renders it (`lightcraft-cli render`, Enable Lens Corrections on, fitted polynomial) | **0.22–0.73 px on 27 photos (median 0.44); 1.36–1.38 px at 10 mm** | + +Units 14000–17000 and a reading that drops the first table value as a non-knot (10 knots over the half diagonal, +413 px) were also scanned; none keeps all 30 within 0.75 px, and 15000 gives the lowest worst case at 372 px. The +residual is radial (tangential median ≤ 0.02 px, so the centre is right) and has the same shape on every lens, +positive at mid radii and negative at the corners, growing with the table's magnitude (10 mm: last knot −1333, 1.3 px +at the corners). Fitted per photo, the best spacing and unit trade off against each other and drift with the lens +(10 mm about 400 px, 18 mm about 365 px): the same lens-dependent effect as the per-lens units above. The 10 mm end +stays within the 0.5–1.5 px accepted for Nikon Z lenses (`docs/nikon-lens-corrections.md`) and removes about 75 % of +the uncorrected 5.6 px. + +**Fit gate.** The 11 knots over the smaller crop bend more sharply than `WarpRectilinear`'s polynomial follows: on the +decoded geometry (active area 6656 × 4608) it fits the 30 tables within 0.08–0.44 px, and 13 of them exceed the +full-frame 0.25 px gate. Measured against the camera JPEG, the fitted polynomial is as good as the exact table (p95 +0.22–0.73 px against 0.23–0.68 px on the 27 photos, 1.35–1.38 px at 10 mm), so APS-C bodies use a 0.5 px gate +(`APSC_FIT_ERROR_PX_AT_1440`). + +**Lens exception: Sigma 10–18 mm F2.8 DC DN at 10–10.5 mm** (`LENS_EXCEPTIONS`, matched on Exif `LensModel` +`10-18mm F2.8 DC DN | Contemporary 023` and `FocalLength` ≤ 10.5 mm). Its wide end keeps 1.36–1.38 px under the +body's rule. On 115 further private photos of this lens (97 at 10 mm, 2 at 10.4–10.5 mm, 16 at 11.7–12.5 mm), a grid +over spacing 372–426 px and unit 14000–16500 was measured on the exact table. The photos at 10 mm were split by +shooting location: 63 to choose the values, 34 to check them. A larger spacing with a smaller unit fits about as well +(a ridge in the grid): 420 px / 14000 was best on the 63 choice photos (0.445 px worst) but put 2 of the 34 check photos +above 0.75 px, so the pick is **416 px / 14250**, within 0.51 px on both sets. +As rendered (`lightcraft-cli render`, Enable Lens Corrections on): + +| Sigma 10–18 mm | p95 at 1440 px | +|---|---| +| 10 mm, 97 photos (one night photo has too little detail to measure) | 0.38–0.55 px, median 0.44 (body's rule: about 1.4 px) | +| 10.4–10.5 mm, 2 photos | 0.36 px | +| 11.7–12.5 mm, 16 photos (body's rule, the exception does not apply) | 0.48–0.72 px, median 0.57 | + +At 11.7 mm and longer an exception of this kind fits worse than the body's rule (412 px / 14500 on the grid: 0.73–1.19 px against 0.48–0.69 px), so it stops at +10.5 mm, the longest measured focal length where it helps; 10.6–11.6 mm is unmeasured and takes the body's rule. + +## Two conventions + +A second Sony decoder, `src/arw_lens.rs` +([Sony embedded distortion corrections](sony-lens-corrections.md), PR #498), reads the same `0x7037` table on the +**ILCE-7RM4A**, validated against Sony Imaging Edge exports (distortion On/Off pairs at 200 and 600 mm with the +FE 200–600 mm; corrected TIFFs at 24 mm with the FE 24–105 mm). Its conventions differ from the ones above +(`r_o` = output radius about the crop centre, `v` = the table, linear between knots): + +| | ILCE-7RM6 (this page, `arw_lens_rm6.rs`) | ILCE-7RM4A (`arw_lens.rs`) | +|---|---|---| +| Knots | `i · 399.98` px, fixed on the sensor; last segment extended | `i / 15` of the crop's half diagonal; radius clamped to 1 | +| Table indexed at | the corrected radius `r_o / z` | the output radius `r_o` | +| Unit | 15000 | 16384 | +| Zoom | fill: the largest `S(ρ / z) / ρ` over the border is 1 | `1 / max f` over the border radii | +| Aspect crops | applied; the zoom for the recorded crop is unverified | refused (native 3:2 only) | +| Tables | distortion (16 knots; 11 in APS-C); lateral CA parsed, off by default; vignetting read, not applied | distortion, 16 entries only | + +`arw::decode` passes both the same active area and crop (the DNG-style default crop, on the ILCE-7RM6 the same as +Sony's crop tags); their model lists don't overlap, so a photo gets at most one of them: an ILCE-7RM6 this page's +conversion, an ILCE-7RM4A the other, every other body neither. + +**Cross-check.** The ILCE-7RM4A formula, scored on the 34 measurable corrected ILCE-7RM6 photos exactly as in +Measured conventions (radial bins, px at 1440, max / rms; "c" = the worst corner or edge-midpoint point): + +| Variant | Unit | All photos | Corners max / median | 24–70 GM II (23) | 100 mm Macro (8) | 200–600 G (3) | +|---|---|---|---|---|---|---| +| **A**: this page | 15000 | **0.596 / 0.143** | 0.570 / 0.099 | 0.596 / 0.100, c 0.474 | 0.361 / 0.135, c 0.336 | 0.588 / 0.325, c 0.570 | +| **B1**: the ILCE-7RM4A formula | 16384 | **1.429 / 0.319** | 1.913 / 0.204 | 1.429 / 0.385, c 1.913 | 0.289 / 0.049, c 0.234 | 0.264 / 0.115, c 0.320 | +| **B2**: B1 at its best single unit | 15680 | 0.940 / 0.223 | 1.202 / 0.098 | 0.940 / 0.215, c 1.202 | 0.327 / 0.100, c 0.278 | 0.683 / 0.428, c 0.637 | +| **B3**: A's indexing and zoom at 16384 | 16384 | 1.450 / 0.455 | 1.845 / 0.350 | 1.450 / 0.539, c 1.845 | 0.284 / 0.062, c 0.238 | 0.654 / 0.329, c 0.586 | + +- B1 misses the 0.75 px budget on these photos, but only on the 24–70 GM II: at 24–28 mm it under-corrects the barrel + in the outer half (up to 1.43 px at 0.7–0.85 of the half diagonal), at 45–70 mm the pincushion by a nearly uniform + 0.2–0.4 px. On the 100 mm and the 200–600 it fits better than A, and on the 200–600, the one lens both validations + share, its unit agrees with this body's (best unit under its indexing 16524–16602). So the data does not show the + two bodies differing. +- The zoom rules agree (within 0.0035 on every photo); the unit and the table index make the difference. Under A's + indexing the 24–70 GM II's barrel and pincushion ends want the same unit (14749 / 14860, 0.7 % apart); under B's + they need 14792 / 15705 (6 % apart). +- APS-C: B refuses 11-entry tables. Its knots read literally on the APS-C crop (`i / 15` of its half diagonal, + 261.5 px) fit the APS-C RawDB files' lateral CA 4 × worse on 3 of 6 photos than the sensor-fixed 400 px; `i / 10` + (392 px) can't be told from 400 px. + +**Open question: no single unit fits every lens under either convention.** The 200–600 wants a 5–7 % larger unit (a +weaker correction) than the 24–70 GM II's pincushion end with similar table values (+409 … +561 against +406 … +454 +at the corner), under both indexings and every knot spacing tried (0.94–1.12 ×: the ratio of the two lenses' best +units stays 1.053–1.073). A's 15000 is the compromise that keeps every photo within 0.6 px; 16384 is the 200–600's +value. That rests on 3 measurable 200–600 photos (594–600 mm). The ILCE-7RM4A's own wide-angle evidence is its +weakest: its On/Off pairs are all 200–600, and its 24 mm check (LightCraft export against Sony's TIFF) is median 0.35 / +95th percentile 1.04 px at 2376 px. On its `WIDE` unit-test table the two formulas differ by up to 2.49 px at 1440 at +the corner (computed from the formulas, not measured). + +**What would settle it:** Sony Imaging Edge On/Off exports of the 24–70 GM II (or any wide zoom's wide end) on either +body, which would show whether the wide-end disagreement is the body or the formula; and more lenses, and more +200–600 photos, on the ILCE-7RM6, to tell a per-lens unit from a convention. Until then each body keeps the +conventions it was validated with. + +## Method + +Photos: 54 ILCE-7RM6 ARWs: 42 private, and the 12 CC0 samples of the +[dnglab RawDB ILCE-7RM6 set](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6) (`ILCE-7RM6_{FF,APSC}_{land,port}_RAW{,-C,-C-HD}.ARW`: +full frame and APS-C, landscape and portrait, lossless, Compressed and Compressed HQ). `cargo xtask corpus --download` +fetches three of them: the APS-C lossless file as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, and the ARW 6.0 +decoder's full-frame Compressed and APS-C Compressed HQ files (`arw6-sony-ilce7rm6-ff-land-c.arw`, +`arw6-sony-ilce7rm6-apsc-land-hq.arw`). The corpus test `corpus_sony_lens_tables_become_opcodes` checks all three, and +the full-frame lossless file where it is present. +Four lenses: FE 24–70 mm F2.8 GM II (27 photos), FE 16–35 mm F2.8 GM II (12, all RawDB), FE 100 mm F2.8 Macro GM OSS +(9), FE 200–600 mm F5.6–6.3 G OSS (6). 41 photos have in-camera distortion correction on (all private); 13 have it +off (the 12 RawDB files and one private photo). Only aggregate numbers per lens are reported. The scripts are local +(the gitignored `plan/arw6/lens/`), not in the repository; the steps are described so they can be redone. + +- **Oracle:** the camera JPEG inside each ARW (`JpgFromRaw`, 9984 × 6656 / 6528 × 4352, stored unrotated, turned to + the photo's orientation). Tag values were read with ExifTool used as a tool. Clean room: no lens-correction code of + any other project and no Adobe data was read. +- **Distortion (camera-JPEG block registration).** Our render at half the JPEG size and the JPEG (2 × 2 box-reduced) + are both reduced to a quarter of the JPEG size; on the green channel, every 40 px a 64 px JPEG block is located in + the render by normalised cross-correlation with parabolic sub-pixel refinement. Pass 1 searches coarse-to-fine + (±28 px at 1/16 size, then ±6 px); pass 2 (used for every number) searches ±6 px around pass 1's smooth radial fit + (data-derived, needed for a macro of a periodic screen). Blocks with low contrast (std < 2), NCC < 0.8 or a + tangential displacement above 4 px are dropped. A robust fit of `p_render = g(r) · p_jpeg + t` (`g` an even + 4-term polynomial) gives the translation `t`; the measured radius ratio is the median of 32 radial bins (≥ 10 + blocks each). A photo is measurable with ≥ 300 blocks and ≥ 15 bins reaching 0.7 of the half diagonal: 34 of the + 41 corrected photos (two black frames, a near-black night frame, two low-detail frames and two whose blocks reach + only 0.54 of the half diagonal are not). 108 hypotheses (radius norm, knot placement, relative or absolute, table + indexed at the corrected or the source radius, zoom rule), each with its best unit, were compared. +- **Lateral CA (raw CFA).** The demosaiced render shows only 25–60 % of the lens's CA and the JPEG's 4:2:2 chroma + and noise reduction hide it, so CA was measured on the raw: from LightCraft's uncompressed CFA DNG export, the + red (blue) plane against green interpolated at the same sites (4-neighbour mean), square-root encoded, 2 × 2 + binned; 40 px blocks every 24 px, ±3 px NCC search, radial component, median per 16 radial bins. +- **Vignetting (exposure brackets).** The JPEG is resampled into the render's geometry; per pixel and channel, the + exposure at which eight renders bracketed −0.3 … +1.8 EV equal the JPEG value is interpolated (smooth areas, JPEG + values 12–240, monotone brackets). That cancels the fitted tone curve, since exposure is a scene-linear gain before + it. Medians in 16 annuli give the JPEG's falloff relative to our uncorrected render. + +## Verification + +Every photo rendered with Enable Lens Corrections on and off at half the JPEG size (50 of the 54 are Compressed or +Compressed HQ ARW 6.0 files, so the renders used a local build of this code merged with the ARW6 decoder), then the same block +registration (passes 1 and 2) against the camera JPEG, and R and B against G inside each render (80 px blocks every +128 full-resolution px, ±4 px search; per corner the median radial shift of the 12 nearest blocks). px at a 1440 px +long edge; aggregates only. + +**Distortion, photos corrected in camera** (our render with the correction on vs the camera JPEG; budget 0.75 px). +"Corners + midpoints" is the 2-D displacement at the four corners and four edge midpoints, global translation +included; "bins" the radial residual over 32 radial bins. A photo counts as measurable as in the method above. + +| Lens | Photos (measurable) | Focal lengths | Corners + midpoints max / rms | Bins max / rms | Uncorrected, corners (per-photo max) | +|---|---|---|---|---|---| +| FE 24–70 mm F2.8 GM II | 26 (23) | 24–70 mm | 0.553 / 0.125 | 0.503 / 0.066 | 1.1–17.3 | +| FE 100 mm F2.8 Macro GM OSS | 9 (7) | 100 mm | 0.409 / 0.111 | 0.317 / 0.097 | 0.6–4.8 | +| FE 200–600 mm F5.6–6.3 G OSS | 6 (3) | 594–600 mm | 0.679 / 0.321 | 0.498 / 0.274 | 7.5–10.8 | +| **All** | **41 (33)** | | **0.679** / 0.150 (206 points) | 0.503 / 0.109 | 0.6–17.3 | + +No photo is over budget; the global translation is at most 0.056 px. The 8 not measurable are black, near-black or +low-detail frames, or frames whose blocks don't reach 0.7 of the half diagonal. That is one more than in the +measurement (33 measurable against 34): one 100 mm photo keeps only 255 of the 300 blocks needed when it is matched on +the corrected render, as block coverage differs slightly between the two renders. The 200–600 mm keeps the systematic +residual the measurement found for it (its own best unit is 15659, see Limits). + +**Controls, correction off in camera** (13 photos: the 12 RawDB files, full frame and APS-C, and one private 24–70 mm +at 58 mm). Their JPEG keeps the lens's distortion, so our corrected render must differ from it by the model's own shift: +it does, by up to 10.5 px (full frame) and 11.4 px (APS-C), and matches that shift within 0.639 px at the corners and +midpoints (bins 0.259 px; full-frame RawDB files ≤ 0.218 px, APS-C ≤ 0.639 px). This checks that the pipeline renders +the model (also for APS-C, barrel, zoom 0.984), not that the APS-C model is right. + +**Portrait photos:** 8 corrected (orientation 6 and 8) within 0.232 px, 6 controls within 0.369 px of the model's +shift: the warp turns with the photo. + +**Lateral CA in the render** (radial R-vs-G and B-vs-G shift, median of the 12 blocks nearest each corner; 43 photos +with enough detail at all four corners; budget 0.3 px): + +| Lens | Photos | Off: max R / B | Off: rms | On: max R / B | On: rms | Camera JPEG: max / rms | +|---|---|---|---|---|---|---| +| FE 24–70 mm F2.8 GM II | 23 | 0.089 / 0.071 | 0.028 | 0.219 / 0.148 | 0.069 | 0.150 / 0.036 | +| FE 16–35 mm F2.8 GM II | 12 | 0.125 / 0.054 | 0.041 | 0.030 / 0.051 | 0.020 | 0.058 / 0.016 | +| FE 100 mm F2.8 Macro GM OSS | 6 | 0.020 / 0.050 | 0.013 | 0.061 / 0.157 | 0.044 | 0.021 / 0.004 | +| FE 200–600 mm F5.6–6.3 G OSS | 2 | 0.033 / 0.101 | 0.032 | 0.146 / 0.075 | 0.070 | 0.113 / 0.039 | +| **All** | **43** | 0.125 / 0.101 | 0.032 | **0.219** / 0.157 | 0.052 | 0.150 / 0.028 | + +Within budget everywhere, but **the correction makes the render's own CA larger on average** (rms 0.032 → 0.052 px; +smaller only for the 16–35 mm). The reason is where it runs: over the radial bins beyond 0.3 of the half diagonal +(418 bins), our demosaiced render shows only 0.29 × the lens CA measured on the raw CFA (correlation 0.85), while the +warp after demosaic moves R and B by 0.84 × the table (correlation 0.96). On the raw data the table halves the CA (rms +0.062 → 0.035 px); applied after a demosaic that already hid most of it, it overshoots (rms 0.021 → 0.035 px over the +same bins). All of this is far below one pixel at 1440 px. + +**Ruling: the lateral CA planes are off by default.** `lens_opcodes` emits the G plane alone; the conversion stays in +the code and its unit tests (`warp_opcode(…, lateral_ca: true)`), so it can be turned on once CA is corrected where +the table applies. **Follow-up:** warp the raw CFA's red and blue sites before demosaic (the table halves the raw CA, +rms 0.062 → 0.035 px), then turn the planes on and repeat this check. Optics ▸ Remove Chromatic Aberration +(`optics.remove_ca`, an automatic estimate from the image) remains available for visible fringes. The distortion +numbers above were measured with the three-plane warp, whose G plane is the one the default emits. + +**Fit gate margins** (worst of R, G and B at the gated samples; gate 0.25 px): the 4 lossless files 0.160 px (margin +0.090; 0.220 px at all 65 samples, the padded radii never shown), the 46 full-frame ARW6 files 0.175 px (margin 0.075), +the 4 APS-C ARW6 files 0.035 px. The largest CA value in the 54 tables is 1724 (FE 24–70 mm), a quarter of the 6720 +bound. So every photo passes: with the CA planes on (the verification renders) each got the three-plane warp, and the +corrected and uncorrected renders differ on all 54; by default each gets the one-plane distortion warp. + +## Performance + +`LIGHTCRAFT_PROFILE=1` renders of the full-frame lossless file and the full-frame Compressed HQ file (corpus +`arw-sony-ilce7rm6-ff-land-lossless.arw`, `arw6-sony-ilce7rm6-ff-land-hq.arw`), on a 4-core laptop shared with other +builds (so absolute times are high); the fastest of 3–5 runs. The warp runs in the `sample` stage: + +| Render | Off | One plane (the default; measured with manual Distortion 1) | Three planes (CA planes on) | +|---|---|---|---| +| Full size 9984 × 6656, lossless | 267 ms | — | 3098 ms | +| Full size 9984 × 6656, Compressed HQ | 403 ms | 1187 ms | 4014 ms | +| 1920 px long edge (2496 × 1664 source), Compressed HQ | 23.7 ms | 45.4 ms | 112.0 ms | + +The default one-plane warp takes the sample stage from 24 to 45 ms at 1920 px (inferred from the manual Distortion +run: the same single-plane path, one plane for all three channels). The three colour planes cost about 2.5–3× that, +as for an RW2 or DNG with three planes (the same path): the price of turning the CA planes on. The stage cache keys +the resampled source by the source, framing and output size, so tone, colour and exposure drags don't re-run it. + +## Limits + +- **ILCE-7RM6 and ILCE-6700 only.** Other Sony bodies record the same tags; their knot spacing (defined here by the + full-frame crop, or an APS-C body's own crop), unit and zoom rule are unverified, so they get no correction from this conversion (the ILCE-7RM4A's distortion has + its own: [Two conventions](#two-conventions)). +- **ILCE-6700 at strong barrel distortion:** the body's rule leaves 1.36–1.38 px at the Sigma 10–18 mm's 10 mm end; + that lens has a measured exception (above). The two public raw.pixls.us ILCE-6700 files (one scene, E 16–55 mm + F2.8 G at 16 mm, not used for fitting) both carry the same table (−1829 at the last knot). On the compressed file + the correction leaves p95 3.1 px (uncorrected 5.1 px, exact table 3.4 px); with the same spacing this lens fits at + unit 16000 (0.43 px), another lens-dependent unit (below). On the lossless file the table, on its padded + 6656 × 4608 frame, misses the 0.5 px fit gate, so that photo stays uncorrected (5.3 px). Other wide lenses with + similarly strong tables are unmeasured. Its vignetting and lateral CA tables are read, not applied: measured in + #2801 (the camera JPEG doesn't compensate vignetting, as on the ILCE-7RM6). +- **APS-C distortion is unverified in camera on the ILCE-7RM6:** no APS-C photo with distortion correction on was available; the APS-C + knot spacing is verified on CA only, and the APS-C zoom follows the full-frame rule. +- **Aspect-ratio crops** (in-camera 4:3, 16:9, 1:1) were not measured; the correction uses the crop the file records. +- **One distortion unit for every lens.** Fitted per lens the unit is 14826 (24–70 mm, 23 photos, very consistent), + 15659 (200–600 mm, 3 photos) and 16017 (100 mm, tiny tables); with the single 15000 the 200–600 mm keeps a systematic + residual of up to 0.59 px (within budget, above the method's 0.03 px noise floor). Something lens-dependent is not + captured; per-lens units would need per-lens verification. +- **Lateral CA off by default.** Its unit is provisional (above), and a warp after demosaic, as for RW2 and DNG, + overshoots: our demosaic already hides about 70 % of the lens CA, and with the planes on the rms corner CA rose + from 0.032 to 0.052 px (Verification). Correcting CA before demosaic is the follow-up. +- **Vignetting not applied** (above). +- **DNG export** carries the warp in `OpcodeList3`, as for RW2: a DNG reader applies it like any embedded lens + correction. +- **Existing imports** need Photo → Reload from Disk to pick up the lens data. +- The camera-JPEG colour fit still matches its pairs without the correction (docs/camera-preview-colour.md). + +## Sources + +- [ExifTool EXIF tag names](https://exiftool.org/TagNames/EXIF.html): the names and types of tags `0x7031`–`0x7037` + (ExifTool 13.59 was used to read the tag values). Their meaning, units and conventions were measured as above. +- [dnglab RawDB, Sony ILCE-7RM6](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6): the 12 CC0 samples (data only; + dnglab's code was not consulted). diff --git a/crates/raw/docs/sony-lens-corrections.md b/crates/raw/docs/sony-lens-corrections.md new file mode 100644 index 000000000..b48269b2c --- /dev/null +++ b/crates/raw/docs/sony-lens-corrections.md @@ -0,0 +1,388 @@ +# Sony embedded lens corrections + +> Moved from LightCraft (`docs/sony-lens-corrections.md`) with the decoder (`src/arw_lens.rs`, `src/arw_lens_rm6.rs`). It records how the tables were derived and verified; mentions of LightCraft's settings and UI describe that app. + +The **ILCE-7RM6** (A7R VI) has its own conversion, with other conventions measured against its camera JPEGs: +[Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md). On ILCE-7RM6 photos the formula below misses the 24–70 GM II by +up to 1.43 px at 1440 px while fitting the 100 mm and 200–600 better, and no single unit fits every lens under either +convention; that page's *Two conventions* section has the comparison and what would settle it. + +Sony ARWs record three lens corrections per shot in the raw image IFD: shading (vignetting), lateral chromatic +aberration (CA) and distortion, each as a camera setting and a radial table, with copies in the encrypted +`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and applies distortion here on three validated +bodies: **ILCE-7RM4A (A7R IVA)**, **ILCE-7M3 (A7 III)** and **ILCE-7CR (A7CR)**. The A7R IVA keeps its existing Bayer 3:2 conversion, +validated with the FE 24-105mm F4 G OSS and FE 200-600mm F5.6-6.3 G OSS; the A7 III uses the same conversion, validated +with the FE 16-35mm F4 ZA OSS, FE 28-70mm F3.5-5.6 OSS and FE 50mm F1.8 ([ILCE-7M3 validation](#ilce-7m3-validation)). The A7CR uses a separate conversion, +limited to full-frame Bayer 3:2 files with distortion Auto or forced Auto. Apart from the separate ILCE-7RM6 converter, other models and shading and CA on every model are +decoded only: no warp, vignetting or CA correction is added for them. The decoded record stays with the image +(`RawInfo::lens_metadata` / `RawImage::lens_metadata`, types in `src/lens_metadata.rs`, decoder in +`src/arw_lens.rs`) for diagnostics and for the validation work described below. + +The applied distortion is converted into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction +control, CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. Linear YCbCr ARWs +and non-3:2 camera crops are excluded pending validation. An aspect crop may retain the full-frame radial +normalization; the decoder does not guess that relationship. Tables of other lengths and rejected tables remain +uncorrected, and a table found only in the encrypted `SR2SubIFD` is never applied. The camera's distortion Off +setting does not erase its table. A7CR Off, missing, unavailable and unknown settings produce no warp. The A7R IVA's +existing behavior is unchanged. Newly imported photos use LightCraft's existing embedded-profile default +(enabled); the profile correction control can disable or adjust it. + +## What is decoded + +| Component | Raw-IFD setting / table | `SR2SubIFD` setting / table | Accepted layout | +|---|---|---|---| +| Shading | `0x7031` / `0x7032` | `0x797c` / `0x797d` | SHORT\[1\]; SSHORT\[17\]: 16, then 16 samples | +| Lateral CA | `0x7034` / `0x7035` | `0x797f` / `0x7980` | SHORT\[1\]; SSHORT\[33\]: 32, then two 16-sample curves | +| Distortion | `0x7036` / `0x7037` | `0x7981` / `0x7982` | SHORT\[1\]; SSHORT\[17\]: 16, then 16 samples | + +- **Bounded, exact reading.** A raw-IFD field is the one the TIFF reader parsed, the copy the image itself uses: + its first occurrence, within the reader's limits (8,192 entries per directory, a total value budget). The directory + is also scanned, in either byte order and in BigTIFF, for diagnostics only: a tag that occurs more than once is + flagged (`raw_ifd_repeated`, `sr2_repeated`), and an entry the reader left out is reported with its reason (values + outside the file, an unknown type, or past the reader's limits) rather than treated as absent. `SR2SubIFD` fields + are read straight from their first entry. A setting may also be a nonnegative SSHORT (the documented editor type + differs from the type the cameras store). Anything else is refused with its reason (`SonyFieldError`: field type, + count, leading sample count, values outside the file or block, past the reader's limits, a negative setting). + Nothing is allocated from a count in the file. +- **Raw IFD first, `SR2SubIFD` as a checked fallback.** The raw IFD's copy wins. The `SR2SubIFD`'s copy is used only + when the raw IFD lacks the tag; a malformed raw-IFD copy is reported, never silently replaced. When both copies are + well-formed their agreement is recorded (`copies_agree`), and on disagreement the raw IFD's copy is used. The block + is decrypted (by the existing `vendor/sr2.rs`) for every ARW that has one, so the check always runs; a block that + can't be found or decrypted gives no copies. Locating it selects the `SR2Private` directory's offset, length and + key exactly as the TIFF reader would (the reader's own directory layout, 8,192-entry limit, readable-entry rules + and value budget, then the first readable occurrence of each), but decodes only each field's first value, so the + directory's other entries cost no memory however large they claim to be; the block itself is limited to 1 MiB. The + plain tag numbers are not looked up inside the `SR2SubIFD`. +- **Settings keep their number and a documented meaning:** shading 256 Off, 257 and 272 Auto, 511 unavailable; CA + 0 Off, 1 Auto, 255 unavailable; distortion 0 Off, 1 Auto, 17 Auto fixed by the lens, 255 unavailable. Any other value + is `Unknown(value)`, never Off (the A7CR's Low shading setting has no documented number). Values are not read as + bit masks. Off does not mean absent: the table is still recorded. +- **Only the 16-sample (CA: 2 × 16) layout is interpreted.** Many bodies store 11 (CA: 22) samples padded into the + same 17 (33) values: older compacts and bodies such as the DSC-RX100 III and ILCE-3500, and the ILCE-7M4's APS-C + captures (its full-frame captures carry 16). Those tables are refused as `CountPrefix(11)` and stay + uninterpreted. An all-zero table is kept: it is a valid no-op, not a sign of an unsupported lens. +- **Framing evidence travels with the tables:** the stored frame (padding included), Sony's crop tags and the DNG + default crop as recorded, the image crop, the reference rectangle the distortion conversion is centred on and + normalized to (the default crop when present, else the image crop), compression, whether the data is linear + YCbCr (lossless M/S), and whether both crops are native 3:2. +- **Per-component status.** `Applied` (converted into an opcode), `NoTable`, `NotValidatedComponent` (shading and CA + on every body), `NotValidatedModel`, `NotValidatedMode` (linear YCbCr, non-3:2 framing, or a distortion table found + only in the `SR2SubIFD`), `Neutral` (all-zero table in the validated scope) and `Rejected` (the validated + conversion refused the table). A readable table never implies a usable correction; only `Applied` components + become opcodes. + +For the A7CR, `CameraCorrectionDisabled` records an Off, missing, unavailable or unknown setting. APS-C, +aspect crops and lossless M/S remain `NotValidatedMode`. The raw IFD's correction table is required. + +The A7R IVA's applied path is unchanged: its conversion still receives the raw IFD's `0x7037` table exactly as the +TIFF reader parsed it, with the same model, framing and fit checks, and produces the same warp. A repeated tag keeps +the reader's choice and is only flagged, and a table the reader never reads (past its entry limit) is reported but +never applied, so already-catalogued photos, their previews and their stored lens data stay consistent with a fresh +decode. + +## What each component still needs + +The following summarizes an investigation on the ILCE-7CR (A7CR): an owner's collection of 131 raws with four +lenses (Voigtländer APO-LANTHAR 28mm F2 and 50mm F2, both manual focus; Sony FE 24–50mm F2.8 G; Tamron 35–150mm +F2–2.8), plus the three public raw.pixls.us A7CR samples. No private photographs, names or derived images are +part of this repository. + +- **Inventory.** All 131 raws carry all three tables in both the raw IFD and the `SR2SubIFD`, and the copies agree + in every file; all four lenses provide tables, the manual-focus ones included. Shading is 272 (Auto) and CA 1 + (Auto) everywhere; distortion is 17 (Auto, fixed by the lens) on the nine FE 24–50 files and 0 (Off) on the rest. + Every image crop is 9504 × 6336 at (32, 20), in uncompressed, compressed and lossless-L (padded to 9728 × 6656) + storage. The decoder reproduces this inventory exactly (see *Local inventory check*). +- **Distortion.** The A7R IVA interpretation misses the A7CR camera JPEG at the wide end. The A7CR now uses the + separately measured corrected-radius convention below. It uses the raw IFD table, not the MakerNote array. +- **Shading.** The layout decodes, but no public specification gives the sample-to-gain function, its radius + convention on the A7CR, or whether the raw samples already contain part of the correction. Brightness ratios on six + natural scenes show no consistent missing peripheral brightening. Needed: evenly lit flat fields with shading + Off / Auto / Low at several apertures and focal lengths, raw CFA ratios between them, and Imaging Edge On/Off + exports, to fix the gain function and processing stage and rule out double correction. +- **Lateral CA.** A public analysis reads the table as a red, then a blue curve relative to green, at a 2⁻²¹ + relative radial scale. On three distortion-Off files that candidate reduced red and blue feature residuals + modestly (for example 0.35 → 0.29 px median for red with the 28 mm), which supports investigating it but certifies + neither scale, interpolation nor processing stage. Two product changes come before it: embedded CA must be + independent of the distortion amount (today `pipeline::optics` scales the whole embedded per-plane warp with the + profile distortion control), and the red and blue planes must be resampled in camera RGB, before the camera colour + model mixes them (`engine::files::load_bytes_now` applies the colour model before the optics stage). Then + validation on full-resolution achromatic edges. +- **Other modes.** APS-C, aspect crops and lossless M/S (linear YCbCr) captures were not measured and stay + excluded. + +## A7CR camera-JPEG validation + +The A7CR conversion substantially reduces the corrected camera JPEG's geometry mismatch. Its empirical table unit is +`15559.300457825264`, fixed by the earlier four-file FE 24-50mm fit before checking other files and lenses. +This is a measured approximation for the tested scope, not a Sony specification or a universal Sony constant. + +For output radius `rho` in native crop pixels: + +``` +spacing = hypot(9504, 6336) / 30 +S(r) = r * (1 + interpolate(table, r / spacing) / 15559.300457825264) +source_radius(rho) = S(rho / zoom) +``` + +Interpolation is linear. The final segment extends beyond the last knot. Positive samples increase the source +radius before the fill zoom; negative samples reduce it. + +The fill zoom makes the maximum of +`S(rho / zoom) / rho` on the rectangular crop border equal to one. The optical center is the default crop's +pixel-center midpoint. The measured crop is 9504 by 6336 at (32, 20). + +Storage padding affects DNG normalization +only, so 9600 by 6376 and 9728 by 6656 storage produce the same physical warp. + +A four-term odd polynomial represents the map as `WarpRectilinear`. A dense 32769-point check rejects a polynomial +that folds or differs from the table by more than 0.25 px at a 1440 px long edge. This approximation check is +separate from agreement with the camera JPEG. The converter applies no shading or lateral CA. + +Validation uses all 131 owner files numerically, nine public raw.pixls.us codings, one independent public FE 50mm +file and the public issue #1300 FE 16-25mm file. Private pixels stay in memory. No private images, names or source +identities are saved in the new measurement artifacts. + +The nine private correction-on files cover the FE 24-50mm +at 24, 49 and 50 mm. The other 122 private files have correction Off and cover the Tamron 35-150mm and the +Voigtlander APO-LANTHAR 28mm and 50mm. Off references validate preservation, not correction-on optics for those lenses. + +Green-channel SIFT (Scale-Invariant Feature Transform) matches at 2376 px are filtered by an independent radial fit. Residuals remove median translation +only. They fit no scale, center or lens coefficient to the candidate. + +High-pass correlation uses held-out pixels +at 1200 px. File checks hold out the second file and then every fifth file in each lens/focal/setting group, and +both public correction-on files. The private checks were also examined in the earlier study, so they are file +checks rather than a fresh independent capture campaign. + +The public second-lens checks do not tune the unit. + +The A7R VI study uses a 0.75 px camera validation budget at 1440 px. The reporter has support to 0.933 half +diagonals. Its direct table's largest radial-bin residual is 0.590 px at 1200 px (0.708 px at 1440 px). + +The implemented polynomial reaches 0.638 px in a radial bin and 0.692 px in a supported edge region at 1200 px +in source coordinates. Actual corrected-raster matches give 0.173/0.605 px median/p95, a 0.763 px radial-bin +maximum and a 0.969 px edge-region maximum at 1200 px. That edge region is 1.162 px at 1440 px, so this does not +pass a strict 0.75 px budget everywhere measured. + +The unit test against the independently fitted smooth feature curve uses the combined 1.0 px table and +polynomial approximation budget; its observed maximum is 0.895 px at 1440 px. These are different metrics. + +The held-out private 24 mm file has weak outer-radius support. No unsupported ideal corner is counted as measured. + +APS-C, aspect crops and lossless M/S are excluded. Portrait captures use the same sensor-coordinate map and the +existing EXIF orientation conversion. Other lenses and the 16-25mm's tele end need more correction-on references. + +No Imaging Edge On/Off pairs were measured for this body. Camera JPEGs are the reference for this change. + +This branch supplies the warp for camera-look pairing in PR #1586. It does not duplicate that pairing code. + +`LOOK_VERSION` 12 refreshes smart previews that stored no A7CR lens data; `RENDER_CACHE_VERSION` 69 redraws cached +renders. + +Existing edits are preserved. Reload from Disk reads the new lens data for old imports. Enable the +profile correction control when an existing edit has it disabled. + +## Local inventory check + +`crates/raw/tests/it/sony_lens_inventory.rs` is opt-in and does nothing in CI. With `LIGHTCRAFT_SONY_LENS_DIR` naming a +directory of ARWs it decodes each from its headers and prints its record (settings, sources, copies' agreement, +sample ranges, statuses); `LIGHTCRAFT_SONY_LENS_FULL=1` also compares header and full decode. With +`LIGHTCRAFT_SONY_LENS_INVENTORY` naming a CSV inventory it checks every file's table counts, sizes, sample counts, +minima and maxima, raw-IFD versus `SR2SubIFD` agreement, settings (numbers and labels), stored size, compression and +crop tags. A file with a row must decode, and every file must have a row, whether it decodes or not. Any decode +error fails the check except an unsupported variant, which is counted and must equal `LIGHTCRAFT_SONY_LENS_UNSUPPORTED` +(default 0). On the 131 A7CR raws above, all 131 inventory rows reproduce and every file decodes. On 133 public Sony +ARWs (127 raw.pixls.us samples from 100 bodies, four corpus copies and two files attached to a public issue), 110 carry +a record, 14 decode without one, and 9 are unsupported variants (eight packed ARWs of older bodies and one ARW +without a CFA image); header and full decode agree on the 124 that decode, and no record has disagreeing copies. + +## Clean-room evidence + +The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) names the raw-IFD tags +`0x7031` to `0x7037` (VignettingCorrection / VignettingCorrParams, ChromaticAberrationCorrection / +ChromaticAberrationCorrParams, DistortionCorrection / DistortionCorrParams) with their setting values, and gives the +tables as 17 or 33 signed 16-bit integers whose first value is the count. The +[Sony SR2SubIFD documentation](https://exiftool.org/TagNames/Sony.html#SR2SubIFD) names the encrypted copies +`0x797d`, `0x7980` and `0x7982`; the setting tags next to them (`0x797c`, `0x797f`, `0x7981`) are unnamed there and +were identified by inspecting files, where they equal the raw-IFD settings. The corpus test's expected tables are the +values ExifTool, run as a black-box tool, prints for the public samples. No external photo decoder implementation, +lens database or Adobe lens profile was consulted. Sony Imaging Edge Edit 4.1 was used only as a black-box reference +on local scratch copies. + +The observed inverse mapping, with radius `r` normalized to the default crop's half diagonal, is: + +``` +f(r) = 1 + interpolate(table, r) / 16384 +source = center + (output - center) * zoom * f(r) +zoom = 1 / max(f(r)) over radii on the rectangular output boundary +``` + +The border radii range from the shorter half edge / half diagonal to 1. Table knots span 0 through 1. +Linear interpolation approximates the observed map closely; we do not claim to reproduce Sony's +internal interpolator. A fixed-size weighted least-squares fit expresses source radius as +`r * (k0 + k1*r² + k2*r⁴ + k3*r⁶)`. The fit is rejected if radial error exceeds 0.0005 of the +half diagonal (about 0.43 px at a 1440 px 3:2 preview), or the mapping folds. Table values are bounded +and the input must have precisely the supported signed-short layout. Crop offset and normalization +are converted into DNG's active-area coordinate system before creating the opcode. The warp's centre +and normalization come from the standard `DefaultCropOrigin` / `DefaultCropSize` when the file has them, +not from Sony's crop tags: on the tested A7R IVA files the former start at raw x=32 and the latter at +x=0, and the standard origin is the one that aligns with Sony's exports. The image itself keeps the +decoder's crop precedence for every Sony body (Sony's crop tags, then the default crop, then the +recorded image size), so the framing is unchanged; the opcode's centre is in active-area coordinates, +so the warp stays on the optical centre either way. On the A7R IVA that framing still sits 32 raw +pixels left of Sony's exports (eight pixels in a 2376-pixel-wide export). + +The main raw-IFD table is important: the similarly named MakerNote table has different values and +must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files, and the +`SR2SubIFD` copy is never converted. + +## Validation + +At 2376×1584, independent SIFT correspondences between Sony distortion On and Off TIFF exports gave: + +| Lens / focal length | Median residual | 95th percentile | Maximum matched radius | +|---|---:|---:|---:| +| FE 200–600 / 200 mm | 0.043 px | 0.191 px | 0.97 half diagonals | +| FE 200–600 / 600 mm | 0.055 px | 0.231 px | 0.95 half diagonals | + +These use the fixed 16384 scale and automatic border framing, not fitted per-image scale or zoom. +A median translation below 0.004 px was removed from the residuals. Correspondences do not cover +all pixels; these are geometric feature residuals, not photometric equality or a worst-case guarantee. +Across 20 sampled RAWs (16 at 24–105 mm, four at 200–600 mm), the polynomial's maximum deviation +from the interpolated file table was below 0.12 px at 1440 px. That measures approximation error, +not agreement with an independent renderer. Camera JPEG and Apple Core Image comparisons support +the wide-lens interpretation but are weaker evidence than same-renderer On/Off pairs. + +Historical validation before restoring main's image crop precedence: LightCraft exports with the +standard default crop framing were compared directly with Sony's corrected +TIFFs. At 2376 px wide, median / 95th-percentile feature residuals were 0.35 / 1.04 px at 24 mm, +0.15 / 0.40 px at 200 mm, and 0.13 / 0.56 px at 600 mm (median translation below 0.024 px). +The uncorrected medians at 200 and 600 mm were 8.63 and 10.82 px respectively. Different colour, +sharpening, and demosaicing affect feature localization; these are geometry checks, not colour parity. +An isolated desktop run on Apple M1 Metal detected the profile, rendered without GPU fallback or +warnings, and exposed the existing Optics controls. Existing library edits are not reset: once the +source has loaded, enable lens corrections if they were previously disabled. + +After restoring main's crop precedence, fresh 2376×1584 exports on 2026-10-10 +(main `b70ae174` plus the aspect-framing guard) measured: + +| Focal length | Corrected median / p95 residual | Uncorrected median | Removed translation x / y | Matches / maximum radius | +|---|---|---|---|---| +| 24 mm | 0.36 / 1.12 px | 5.62 px | 7.98 / −0.01 px | 1,442 / 0.95 | +| 200 mm | 0.13 / 0.35 px | 8.65 px | 8.01 / 0.02 px | 11,606 / 0.98 | +| 600 mm | 0.10 / 0.37 px | 10.33 px | 8.02 / 0.00 px | 12,268 / 0.97 | + +Residuals subtract only the median feature translation; no scale or lens coefficients are fitted to +these reference pairs. The roughly eight-pixel horizontal translation is the retained crop-origin +difference described above, not a claim of exact Sony framing. Maximum radius is in half diagonals. +The distortion geometry remains aligned while every body's image framing follows main's crop order. + +Tests cover signedness/length rejection, zero and malformed tables, independent 600 mm reference +geometry, barrel framing, offset crop normalization, both TIFF byte orders, header/full consistency, +and preservation through DNG export. The decoder's synthetic tests (`src/arw_lens.rs`) add both +byte orders and BigTIFF, exact SHORT / SSHORT types, negative samples, missing, short, long, huge and zero counts, +wrong leading counts, values outside the file, a table past the TIFF reader's entry limit (not applied), repeated +tags (the reader's copy kept, as before), unknown and negative settings, raw-IFD versus +`SR2SubIFD` precedence and agreement, the correct `SR2SubIFD` tag numbers, a wrong decryption key, hostile entries +inside the decrypted block, linear YCbCr, framing evidence (Sony's crop at x = 0 against the default crop, agreeing +crops, padded storage, aspect crops, crop tags outside the frame), all eight EXIF orientations leaving the record +unchanged, and neutral, out-of-range and folding tables. On a small classic-TIFF fixture in both byte orders, every +truncation and four mutations of each byte outside the pixel data (set to 0x00 and 0xff, top and low bit flipped) +decode or fail without a panic. `tests/sr2_heap.rs` measures the peak heap of decoding a 16 MiB file whose +`SR2Private` directory holds eight fields aliasing the whole file: 37 kB, against 134 MB when those fields were +decoded. No private photographs or reference TIFFs are committed. + +## ILCE-7M3 validation + +Checked on 2026-10-12 against the camera's own JPEG of the same exposure, which is an independent renderer +that applied the correction (RAW+JPEG, Lens Comp. ▸ Distortion Comp.: Auto). RAW+JPEG pairs from one +ILCE-7M3 (26 in the table below, plus two left out as explained under it): a checkerboard filling the frame on a TV (handheld) and on a desktop monitor (tripod), compressed and +uncompressed raws, apertures from wide open to f/8, plus one textured wall. Tables covered strong barrel (16 mm, +−690 at the corner), strong pincushion (70 mm, +1161) and the sign change between them. + +Method (`tools/sony_distortion_check.py`): LightCraft renders each ARW at full size with the profile correction +on and off; sub-pixel board corners (Hessian saddle points refined with `cornerSubPix`, kept only where the four +quadrants alternate, labelled into rows and columns) are matched with the JPEG's by grid position, with the +match fixed by the central corners before any fit, because a board is periodic. A similarity (zoom, rotation, +shift) is fitted, as the camera fills its frame with its own zoom; no lens coefficient is fitted. The textured wall +uses SIFT matches instead. The script was checked on synthetic boards with a known radial distortion. + +| Lens / focal length | Shots | Corrected vs JPEG: median / p95 (px at 6000) | Uncorrected median | Corner (r > 0.9) | Camera / LightCraft correction | +|---|---:|---|---:|---:|---:| +| FE 16–35 F4 ZA / 16 mm | 10 | 0.33–0.38 / 1.01–1.15 | 16.7–17.0 | 1.40–1.56 px | 1.02 | +| FE 16–35 F4 ZA / 20 mm | 4 | 0.16–0.24 / 0.34–0.50 | 3.9–4.4 | 0.15–0.24 px | 0.98 | +| FE 16–35 F4 ZA / 35 mm | 2 | 0.18–0.21 / 0.39–0.44 | 11.0–11.1 | 0.18–0.26 px | 1.01–1.02 | +| FE 28–70 / 28 mm | 2 | 0.24–0.25 / 0.59–0.64 | 13.2–13.4 | 0.92 px | 1.02 | +| FE 28–70 / 33 mm | 1 | 0.07 / 0.16 | 0.8 | 0.07 px | 1.00 | +| FE 28–70 / 50 mm | 1 | 0.30 / 0.69 | 20.8 | 1.24 px | 1.01 | +| FE 28–70 / 70 mm | 1 | 0.44 / 0.93 | 26.4 | 1.49 px | 1.01 | +| FE 50 F1.8 / 50 mm | 4 | 0.06–0.12 / 0.15–0.31 | 2.8–3.0 | 0.08–0.14 px | 1.00–1.01 | +| Wall, 16 mm (SIFT) | 1 | 0.37 / 1.06 | 5.6 | 1.22 px | — | + +Residuals are in pixels of the 6000×4000 JPEG; at the 2376 px exports used above for the ILCE-7RM4A, the +board medians are 0.02–0.17 px and the p95 at most 0.46 px. The last column fits JPEG ≈ uncorrected + +α·(corrected − uncorrected) radially: LightCraft applies the camera's amount of correction within 2 %, slightly +less for strong barrel and slightly more at 20 mm, which is what remains at the 16 mm corners (about 1.5 px of a +35 px correction). Rows and columns of corrected boards are as straight as in the JPEG in every shot (largest +bow 0.8–7.8 px in both, from the screens), against 7–55 px uncorrected. Compressed and uncompressed raws, +the TV and the monitor, and wide open and f/8 agree. Two 16 mm board shots whose board stopped short of the +corners (reach 0.8 of the half diagonal) gave 0.85 px medians and are left out of the table. + +On this body Sony's crop tags and the default crop agree (origin 12, 12, 6000×4000 of 6048×4024), so the +warp sits on the image centre; fitted against the camera, the centre is within 0.3 px. Unit tests keep the warp from +three of these tables within 0.0008 of the half diagonal (2.9 px at 6000 px) of the camera's fitted geometry. +The photographs stay with their owner; none is committed. + +## Camera coverage and regression boundary + +The model check is exact: `ILCE-7RM4` (A7R IV without the A suffix), unknown/missing +model names, and every other model remain unsupported by this distortion decoder. Matching tag +numbers and table lengths do not establish the scale, interpolation or crop normalization on another +body. Support can expand after independent geometry validation; do not add model-name prefix matching. + +The current `cargo xtask corpus --download` manifest contains 25 Sony CC0 samples across 18 bodies, +including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm), each with a pinned SHA-256 that the +download checks. `sony_embedded_distortion_is_limited_to_validated_models` checks 23 of them (the two ILCE-7CM2 +codings have their own test), and `sony_lens_records_of_the_public_samples` verifies all 25 against their pins +(`SONY_SAMPLES` in `crates/raw/tests/it/corpus.rs`, itself anchored by a pinned digest and compared with the manifest) +before decoding. **No CI job fetches the corpus**: these fixture tests run only where it has been downloaded, and +otherwise skip each absent file. `LIGHTCRAFT_CORPUS_REQUIRED=1` makes `sony_lens_records_of_the_public_samples` +fail on any absent pinned file instead. + +| Camera | Corpus samples | Decoded record: tables, settings (shading / CA / distortion) | Applied | +|---|---|---|---| +| ILCE-7RM4A | Compressed (4822) | All three tables; 257 / 1 / 17 | One distortion warp | +| ILCE-9M2 | Compressed (3989) | All three; 257 / 1 / 0 | None | +| ILCE-7M3 | Compressed, uncompressed | All three; 257 / 1 / 0 | One distortion warp | +| ILCE-7M4 | 14-bit, lossless L/M/S | L/M/S: all three, 272 / 1 / 17; the 14-bit file (APS-C framing, 4608 × 3072): settings only, its 11-sample tables are not interpreted | None | +| ILCE-7RM2 | 12-bit uncompressed | All three; 256 / 0 / 1 | None | +| DSC-RX100 | One | Shading, from the `SR2SubIFD` (the raw IFD has no tables); CA and distortion in the 11-sample layout | None | +| DSC-RX100M3 | One | Shading; CA and distortion in the 11-sample layout | None | +| ILCE-7CR | Compressed, lossless L/M | All three; 272 / 1 / 0 | None | +| ILCE-7RM4 | 14-bit compressed | All three; 257 / 1 / 17 | None | +| ILCE-3500 | 5600 K | Settings only (unavailable) | None | +| ILCE-7S | Shade | An all-zero CA table; settings unavailable | None | +| ILCE-7SM2 | 16:9 framing | All-zero shading and CA tables; settings unavailable | None | +| DSLR-A500, DSLR-A700, SLT-A33, DSLR-A580, DSLR-A900 | One each | No record | None | + +These are **model-boundary regression checks, not new geometric validation of those models**. +`sony_lens_records_of_the_public_samples` also checks the exact tables, settings, provenance and framing evidence of +the three A7CR codings and the A7R IVA sample, and that across the Sony corpus only the A7R IVA's distortion is ever +applied, besides the A7 III's. The A7 III tables were refused at first because their corner warp was unverified; +the [ILCE-7M3 validation](#ilce-7m3-validation) measured it against the camera's own JPEGs and enabled the body. The lossless M/S +files are linear YCbCr and remain excluded independently of the model check. Header and full decode +must agree for every sample. Synthetic tests also verify both TIFF byte orders, exact model matching, +that the warp is centred on the standard default crop while the image keeps Sony's crop, and that DNG export preserves an accepted warp without adding one to rejected +models. A non-3:2 Sony image crop is also rejected when the standard default crop still describes +3:2 geometry; the regression fails without the image-framing guard. The existing independent Sony +geometry tests continue to cover the enabled A7R IVA path. +Before restoring main's crop precedence, re-exporting the 24, 200 and 600 mm reference files after +the model restriction produced byte-identical corrected PNGs to the earlier validated exports. Isolated headless app checks showed the A7R IVA profile +controls present and the A7 III profile controls absent, with no notices and completed RAW renders. + +Before enabling another body, compare corrected and uncorrected renders against independent references +across multiple lenses/focal lengths and photographs with features near the edges and corners. Record +residuals and spatial coverage, and check crop/aspect and compression variants. Embedded JPEGs are +useful when the camera actually applied distortion correction; an Off JPEG cannot certify an On warp. +Prefer same-renderer Sony On/Off exports when the camera setting or geometry is ambiguous. Table-fit +error alone only checks our polynomial approximation, not whether the table's interpretation is correct. diff --git a/crates/raw/src/arw.rs b/crates/raw/src/arw.rs new file mode 100644 index 000000000..0843c7f93 --- /dev/null +++ b/crates/raw/src/arw.rs @@ -0,0 +1,1653 @@ +//! Sony ARW. +//! +//! Sources: H. Dietz, "Sony ARW2 Compression: Artifacts And Credible Repair" (IS&T Electronic Imaging 2016) for +//! the ARW2 ("cRAW") scheme, and the ExifTool Sony tag-name documentation for the meaning of the raw-IFD tags +//! (`0x7010` tone-curve thresholds, `0x7310` black levels, `0x7313` WB levels, `0x74c7/0x74c8` crop, `0x7031`–`0x7037` +//! lens-correction tables: see [`crate::arw_lens`] for the ILCE-7RM4A and [`crate::arw_lens_rm6`] for the ILCE-7RM6). +//! +//! ARW2, as described in the paper: +//! 1. Sensor values are tone-mapped to 11-bit codes by a five-segment piecewise-linear curve whose step doubles at +//! each threshold; the thresholds are recorded in the file (`0x7010`). We invert it: code `c` indexes the curve at +//! `2c` in a 12-bit domain whose breakpoints are the recorded thresholds / 4, with slopes 1, 2, 4, 8, 16; the +//! result is in 14-bit sensor units (black 512, white 16383 per the file's own tags, which we verified against +//! the decoded data). +//! 2. Each row is coded in 32-pixel groups split into two interleaved 16-pixel sets (even columns, then odd), each +//! a 128-bit little-endian block: 11-bit max, 11-bit min, 4-bit index of the max, 4-bit index of the min and 14 +//! seven-bit deltas above the min, scaled by the smallest shift that fits `max − min` into 7 bits. +//! +//! White balance: `WB_RGGBLevels` (`0x7313`), the gains the camera applied. Bodies from about 2017 on write them in +//! plain form in the raw IFD; every ARW from the DSLR-A200 on has them in the encrypted `SR2SubIFD` (see +//! [`crate::sr2`]), and the two agree on every file that has both. Only when neither can be read do we fall back on +//! the `WB_RGBLevels` of the maker note's enciphered `Tag2010` block (see [`DECIPHER`] and [`TAG2010_WB`]), which +//! can differ widely from what the camera applied (storytold/lightcraft#535). Without any, an ARW opened with unit multipliers, +//! i.e. a strong green cast (storytold/lightcraft#148). +//! +//! Black level: the raw IFD's `0x7310`, else the level stored in the encrypted `SR2SubIFD` (see [`sr2_black`]; 800 +//! rather than the default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit). +//! Downsized lossless (YCbCr) data sit [`YCBCR_OFFSET`] above that. +//! +//! The Sony DSC-R1 (.SR2, 2005) stores uncompressed 16-bit words big-endian inside a little-endian TIFF +//! ([`word16_order`]). Measured on the CC0 DSC-R1 (raw.pixls.us 3221): StripByteCounts 20780544 = 3984×2608×2; read +//! big-endian the maximum is 16368 and the image is smooth (neighbour roughness 0.012 against 0.249), read +//! little-endian the maximum is 65340 (noise). Black stays the default 512 (the masked columns read 511.4) and white +//! comes from the data. The file has no maker-note `FullImageSize` and no plain WB or crop tag; they live in its +//! `SR2SubIFD` (key `63 be 01 3e`, see [`crate::sr2`]): black `0x7300`, as-shot WB `0x7303` (G, R, B, G) and the crop +//! `0x74c3` (x0, y0, x1, y1), each read only when no other source exists. +//! +//! Packed 12-bit ARW (DSLR-A900; Compression 32767 but BitsPerSample 12 and a strip of exactly width × height × 1.5 +//! bytes): plain 12-bit samples, two per three bytes, least-significant-bit first ([`unpack_row12`]); linear, black 128 +//! (512 on the 14-bit scale), CFA from the file's own pattern tag, crop centred on the frame (see [`default_crop`]). +//! +//! ARW6 (Compressed RAW 2, compression 32766): `arw6.rs`, output in 2 × 14-bit units. +//! +//! Also: uncompressed 16-bit ARW, and lossless-compressed ARW (Compression 7, ILCE-7M4 and later): LJ92 tiles whose +//! frames hold one 2×2 CFA cell per four-component sample ([`read_quad_tiles`]). Other lossless-JPEG layouts go +//! through the generic TIFF path. + +use crate::arw6; +use crate::lens_metadata::SonyLensGeometry; +use crate::tiffraw::{Packing, check_image, decode_groups, read_image_in}; +use crate::{ + BlackLevel, Camera, Cfa, ColorData, LensMetadata, Limits, Mode, OpcodeLists, Orientation, RawData, RawError, RawFormat, RawImage, Rect, Result, + ljpeg, +}; +use craft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes}; +use craft_tiff::tags::{self as t, photometric}; +use craft_tiff::{Ifd, Tiff, makernote}; +use rayon::prelude::*; + +const TONE_CURVE: u16 = 0x7010; +const BLACK_LEVEL: u16 = 0x7310; +const WB_RGGB: u16 = 0x7313; +const CROP_TOP_LEFT: u16 = 0x74c7; +const CROP_SIZE: u16 = 0x74c8; +/// `SR2SubIFD` tags of the oldest layout (DSC-R1): per-channel black, as-shot WB in G, R, B, G order, and the crop +/// `(x0, y0, x1, y1)`. Used only when nothing else gives the value. +const SR2_OLD_BLACK: u16 = 0x7300; +const SR2_OLD_WB: u16 = 0x7303; +const SR2_OLD_CROP: u16 = 0x74c3; +/// `SR2SubIFD` white-balance presets at a stated temperature (`WB_RGBLevels2500K` … `8500K`: R, G, B), which give +/// the Kelvin scale of a camera without colour matrices (`crate::kelvin_presets`). +const SR2_KELVIN_PRESETS: [(f32, u16); 5] = [(2500.0, 0x782d), (3200.0, 0x782c), (4500.0, 0x7824), (6000.0, 0x782b), (8500.0, 0x782a)]; +const YCBCR_COEFFICIENTS: u16 = 529; +const REFERENCE_BLACK_WHITE: u16 = 532; +/// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width). +const MN_TAG2010: u16 = 0x2010; +const MN_FULL_IMAGE_SIZE: u16 = 0xb02b; +/// Maker-note `DynamicRangeOptimizer` (ExifTool Sony tag documentation, int32u): 0 Off, 1 Standard, 2 Advanced +/// Auto, 3 Auto, 8–12 Advanced Lv1–Lv5, 16–23 Lv1–Lv8. Sony writes a second tag of that name, `0xb04f` (0 Off, +/// 1 Standard, 2 Plus), but it adds nothing: on the 241 Sony raws (75 bodies) of 264 checked that carry both, it +/// reads 1 whenever `0xb025` is on, whatever the level, and 0 when off; older bodies (DSLR-A100 to A900, +/// NEX-3/5/C3, SLT-A33/A35/A55) leave it out. `0xb025` was present on all 264 files (98 bodies). +const MN_DYNAMIC_RANGE_OPTIMIZER: u16 = 0xb025; + +/// Whether the camera's Dynamic Range Optimizer (DRO) was on for this shot. DRO is a local tone operator that +/// brightens darker regions of the camera's JPEG (and the preview embedded in the ARW) but leaves the raw data +/// alone; the factory default is Auto. `None` when the maker note doesn't record it or holds an undocumented value. +pub(crate) fn dynamic_range_optimizer(note: &makernote::MakerNote) -> Option { + match note.ifd.u64(MN_DYNAMIC_RANGE_OPTIMIZER)? { + 0 => Some(false), + 1..=3 | 8..=12 | 16..=23 => Some(true), + _ => None, + } +} + +/// Inverse of Sony's maker-note byte substitution. ExifTool's Sony tag documentation states that the data of +/// tags `0x2010`, `0x9050` and `0x94xx` "is encrypted by a simple substitution cipher" (no decoder source was +/// consulted). The substitution, checked black-box on CC0 raw.pixls.us samples: every byte `p < 249` is stored as +/// `p³ mod 249` (a bijection on 0..249, as 3 is coprime to φ(249) = 164) and bytes 249–255 are stored unchanged. +/// Verified on 15 files from 13 bodies covering seven `Tag2010` layouts: the deciphered `SonyISO` field encodes +/// the Exif ISO (`100 · 2^(16 − v/256)`), the green level is 255–256 throughout, and on the ILCE-7M3 the deciphered +/// `WB_RGBLevels` equal the plain raw-IFD `0x7313` levels. +const DECIPHER: [u8; 256] = { + let mut inv = [0u8; 256]; + let mut p = 0usize; + while p < 256 { + let c = if p < 249 { p * p % 249 * p % 249 } else { p }; + inv[c] = p as u8; + p += 1; + } + inv +}; + +/// `Tag2010` layouts (ExifTool "Sony Tag2010a" … "Tag2010i" tables): the models each is documented for and the +/// byte offset of `WB_RGBLevels` (three `u16`, R, G, B gains; Tag2010g and h share the offset). Only a fallback +/// for files whose `WB_RGGBLevels` (`0x7313`, plain or in the `SR2SubIFD`) cannot be read. On the 45 CC0 +/// raw.pixls.us ARWs that have both but no plain `0x7313`, the two agree within 5 % on 35 and differ by 8–100 % +/// on the other 10: five of the eight shot with a preset, colour temperature or custom white balance (ILCE-3500 at +/// 5600 K: R −34 %, B +100 %) and five in Auto (SLT-A37: R −32 %, B +53 %). Where the camera JPEG has enough +/// neutral pixels to tell, the gains the camera applied are `WB_RGGBLevels`, not these. +const TAG2010_WB: &[(&[&str], usize)] = &[ + (&["NEX-5N"], 4476), + (&["SLT-A65", "SLT-A77", "NEX-7", "NEX-VG20E"], 4480), + (&["SLT-A37", "SLT-A57", "NEX-F3"], 4444), + (&["DSC-HX10V", "DSC-HX20V", "DSC-HX200V", "DSC-TX66", "DSC-TX200V", "DSC-TX300V", "DSC-WX50", "DSC-WX100", "DSC-WX150"], 4568), + ( + &[ + "SLT-A58", + "SLT-A99", + "ILCE-3000", + "ILCE-3500", + "NEX-3N", + "NEX-5R", + "NEX-5T", + "NEX-6", + "NEX-VG30E", + "NEX-VG900", + "DSC-RX100", + "DSC-RX1", + "DSC-RX1R", + "DSC-HX300", + "DSC-HX50V", + "DSC-TX30", + "DSC-WX60", + "DSC-WX200", + "DSC-WX300", + ], + 4532, + ), + (&["DSC-RX100M2", "DSC-QX10", "DSC-QX100"], 4204), + ( + &[ + "DSC-HX60V", + "DSC-HX350", + "DSC-HX400V", + "DSC-QX30", + "DSC-RX10", + "DSC-RX100M3", + "DSC-WX220", + "DSC-WX350", + "ILCE-7", + "ILCE-7R", + "ILCE-7S", + "ILCE-7M2", + "ILCE-5000", + "ILCE-5100", + "ILCE-6000", + "ILCE-QX1", + "ILCA-68", + "ILCA-77M2", + "DSC-HX80", + "DSC-HX90V", + "DSC-RX0", + "DSC-RX1RM2", + "DSC-RX10M2", + "DSC-RX10M3", + "DSC-RX100M4", + "DSC-RX100M5", + "DSC-WX500", + "ILCE-6300", + "ILCE-6500", + "ILCE-7RM2", + "ILCE-7SM2", + "ILCA-99M2", + ], + 612, + ), + ( + &[ + "ILCE-6100", + "ILCE-6400", + "ILCE-6600", + "ILCE-7C", + "ILCE-7M3", + "ILCE-7RM3", + "ILCE-7RM4", + "ILCE-9", + "ILCE-9M2", + "DSC-RX0M2", + "DSC-RX10M4", + "DSC-RX100M6", + "DSC-RX100M5A", + "DSC-RX100M7", + "DSC-HX99", + ], + 594, + ), +]; + +/// Maker-note `SonyModelID` (`0xb001`, int16u, ExifTool Sony tag documentation). +const MN_SONY_MODEL_ID: u16 = 0xb001; + +/// `SonyModelID` values (ExifTool Sony tag documentation) of the Sony bodies Hasselblad sold under its own Make and +/// model (the Lunar, a NEX-7; the Stellar and Stellar II, an RX100 and RX100 II; the HV, an SLT-A99), so that +/// [`TAG2010_WB`] finds their layout. Measured on the CC0 Lunar (raw.pixls.us 4051): Make HASSELBLAD, Model Lunar, +/// SonyModelID 289 (storytold/lightcraft#1275). +const SONY_MODEL_IDS: &[(u64, &str)] = &[(289, "NEX-7"), (294, "SLT-A99"), (297, "DSC-RX100"), (308, "DSC-RX100M2")]; + +/// The Sony body for the model-keyed tables: the Exif `model` of a Sony-branded file, else the one the maker note's +/// `SonyModelID` names (a Lunar says "Lunar"), else `model` as it is. +fn sony_body<'a>(make: &str, model: &'a str, mn: Option<&makernote::MakerNote>) -> &'a str { + if make.trim().to_ascii_uppercase().starts_with("SONY") { + return model; + } + let id = mn.and_then(|m| m.ifd.u64(MN_SONY_MODEL_ID)); + SONY_MODEL_IDS.iter().find(|(i, _)| Some(*i) == id).map_or(model, |(_, name)| name) +} + +/// White balance from the maker note's enciphered `Tag2010` block, the fallback described at [`TAG2010_WB`]. +/// `model` is the Sony body ([`sony_body`]); Sony appends a regional "V" to some names (SLT-A77V), which the +/// documented lists omit. +fn tag2010_wb(model: &str, block: &[u8], order: craft_tiff::ByteOrder) -> Option<[f32; 3]> { + let model = model.trim(); + let &(_, offset) = TAG2010_WB.iter().find(|(models, _)| models.iter().any(|m| model == *m || model.strip_suffix('V') == Some(*m)))?; + let bytes: Vec = block.get(offset..offset + 6)?.iter().map(|&b| DECIPHER[b as usize]).collect(); + let level = |i: usize| order.read_u16(&bytes, 2 * i).map(f32::from); + let (r, g, b) = (level(0)?, level(1)?, level(2)?); + // levels are fixed-point gains (green 255–256 on every sample seen); reject implausible values from an unknown layout + if !(16.0..=16384.0).contains(&g) { + return None; + } + let (r, b) = (r / g, b / g); + ((0.2..=8.0).contains(&r) && (0.2..=8.0).contains(&b)).then_some([r, 1.0, b]) +} + +/// Whether a 12-bit, single-strip, Sony-compressed (32767) image is stored as plain packed 12-bit samples: the strip +/// is exactly 1.5 bytes per pixel (width even), so every row is `1.5 · width` bytes with no padding. On the one +/// DSLR-A900 sample this is the whole strip (36 917 760 = 6080 × 4048 × 1.5) and it ends at the end of the file. +fn is_packed12(info: &ImageInfo, strip_count: usize, strip_len: u64) -> bool { + let (w, h) = (info.width as u64, info.height as u64); + // checked_mul chain: a wrapping `w · h · 3` must refuse the strip, not compare against a wrapped length + let packed_len = w.checked_mul(h).and_then(|n| n.checked_mul(3)).map(|n| n / 2); + info.bits() == 12 && strip_count == 1 && w % 2 == 0 && w > 0 && h > 0 && packed_len == Some(strip_len) +} + +/// Unpack plain 12-bit samples stored least-significant-bit first: two pixels per three bytes, the first being +/// `b0 | (b1 & 0xf) << 8` and the second `b1 >> 4 | b2 << 4` (measured: same-colour neighbours differ by ~16 codes +/// in this order against ~200 in the most-significant-first order). `row` is `1.5 · out.len()` bytes. +pub(crate) fn unpack_row12(row: &[u8], out: &mut [u16]) { + for (i, b) in row.windows(3).step_by(3).enumerate() { + let Some(px) = out.get_mut(2 * i..2 * i + 2) else { break }; + px[0] = u16::from(b[0]) | (u16::from(b[1] & 0xf) << 8); + px[1] = u16::from(b[1] >> 4) | (u16::from(b[2]) << 4); + } +} + +/// White-balance gains `[R/G, 1, B/G]` from `WB_RGGBLevels` (R, G, G, B); `None` unless there are four levels +/// with a positive green and plausible ratios. +fn rggb_gains(levels: &[f64]) -> Option<[f32; 3]> { + let &[r, g1, g2, b] = levels else { return None }; + let g = (g1 + g2) / 2.0; + if !g.is_finite() || g <= 0.0 { + return None; + } + let (r, b) = (r / g, b / g); + ((0.2..=8.0).contains(&r) && (0.2..=8.0).contains(&b)).then_some([r as f32, 1.0, b as f32]) +} + +/// The inverse tone curve: 11-bit code → 14-bit sensor value. +pub(crate) fn code_curve(thresholds: &[u64]) -> Vec { + let mut bp = [0usize, 4095, 4095, 4095, 4095, 4095]; + for (i, &v) in thresholds.iter().take(4).enumerate() { + bp[i + 1] = ((v >> 2) as usize).min(4095); + } + // keep breakpoints monotonic + for i in 1..6 { + bp[i] = bp[i].max(bp[i - 1]); + } + let mut lut = vec![0u32; 4096]; + let mut seg = 0; + for i in 1..4096 { + while seg < 4 && i > bp[seg + 1] { + seg += 1; + } + lut[i] = lut[i - 1] + (1 << seg); + } + (0..2048).map(|c| lut[(2 * c).min(4095)].min(16383) as u16).collect() +} + +/// Decode one row of ARW2 data (`row.len() == width` bytes) into codes. +pub(crate) fn decode_row(row: &[u8], out: &mut [u16]) { + let w = out.len(); + let mut x0 = 0; + while x0 + 32 <= w && x0 + 32 <= row.len() { + for half in 0..2 { + let off = x0 + half * 16; + let Some(bytes) = row.get(off..off + 16).and_then(|b| <[u8; 16]>::try_from(b).ok()) else { return }; + let block = u128::from_le_bytes(bytes); + let max = (block & 0x7ff) as u16; + let min = ((block >> 11) & 0x7ff) as u16; + let imax = ((block >> 22) & 0xf) as usize; + let imin = ((block >> 26) & 0xf) as usize; + let range = max.saturating_sub(min); + let mut sh = 0; + while sh < 4 && (range >> sh) > 127 { + sh += 1; + } + let mut bit = 30; + for i in 0..16 { + let v = if i == imax { + max + } else if i == imin { + min + } else { + // a corrupt block with imax == imin would read a 15th delta past bit 127 + let d = if bit + 7 <= 128 { ((block >> bit) & 0x7f) as u16 } else { 0 }; + bit += 7; + (min + (d << sh)).min(0x7ff) + }; + out[x0 + half + 2 * i] = v; + } + } + x0 += 32; + } +} + +/// Whether `info` holds Sony's lossless-compressed layout: LJ92 tiles whose frames are half the tile in each +/// direction with four components (checked on the first tile). +fn is_quad_tiled(bytes: &[u8], info: &ImageInfo) -> bool { + let Layout::Tiles { tile_width, tile_height } = info.layout else { return false }; + info.samples_per_pixel == 1 + && info + .chunks(bytes.len() as u64) + .first() + .and_then(|c| chunk_bytes(bytes, c)) + .and_then(|src| ljpeg::frame_info(src).ok()) + .is_some_and(|(fw, fh, nc, _)| nc == 4 && fw * 2 == tile_width as usize && fh * 2 == tile_height as usize) +} + +/// Decode Sony's lossless-compressed raw data. Each tile is one LJ92 frame of half the tile's width and height whose +/// four components are the tile's 2×2 CFA cells in raster order (top-left, top-right, bottom-left, bottom-right), so +/// all four colour planes are predicted separately. Observed in the files' own frame headers (512×512 tiles holding +/// 256×256×4 frames) and checked on decoded images; the generic TIFF path reads a frame as a row-major sample stream +/// (DNG's convention), which would interleave each tile's left and right halves row by row. +fn read_quad_tiles(bytes: &[u8], info: &ImageInfo, limits: &Limits) -> Result> { + let (w, h) = (info.width as usize, info.height as usize); + let total = check_image(bytes, info, limits)?; + let chunks = info.chunks(bytes.len() as u64); + let mut out = vec![0u16; total]; + let mut ok = 0usize; + let mut first_err = None; + // decoded a group at a time: all tiles at once would hold every frame (up to four times the image, the tile grid + // check_image allows) beside the output (storytold/lightcraft#2554) + for group in decode_groups(&chunks, 1, total) { + let decoded: Vec> = chunks[group] + .par_iter() + .map(|c| { + let src = chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("tile offset past end of file".into()))?; + let f = ljpeg::decode(src, (c.width as usize * c.height as usize).max(1 << 16))?; + // a tile's frame is half its nominal width and height: a larger one (which the sample cap above would + // still let through when the tile is small) is no Sony tile + if f.components != 4 || f.data.len() < f.width * f.height * 4 || f.width * 2 > c.width as usize || f.height * 2 > c.height as usize { + return Err(RawError::Corrupt(format!( + "lossless ARW tile: {} samples in a {}×{}×{} frame", + f.data.len(), + f.width, + f.height, + f.components + ))); + } + Ok((*c, f)) + }) + .collect(); + for r in decoded { + let (c, f) = match r { + Ok(v) => v, + Err(e) => { + first_err.get_or_insert(e); + continue; + } + }; + ok += 1; + let (x0, y0) = (c.x as usize, c.y as usize); + for fy in 0..f.height { + for k in 0..4 { + let y = y0 + 2 * fy + (k >> 1); + if y >= h { + continue; + } + for fx in 0..f.width { + let x = x0 + 2 * fx + (k & 1); + if x < w { + out[y * w + x] = f.data[(fy * f.width + fx) * 4 + k]; + } + } + } + } + } + } + if ok == 0 { + return Err(first_err.unwrap_or_else(|| RawError::Corrupt("no decodable tiles".into()))); + } + Ok(out) +} + +/// Sony's downsized lossless ARWs (M and S sizes: linear YCbCr, already white-balanced) are stored this far above the +/// sensor black level the raw IFD records (`0x7310`, 512 in every file seen); their reference levels say Y black 0. +/// Measured against a full-size coding of the same scene shot seconds apart, on the pixels where both camera JPEGs +/// agree: YCbCr = k · (full-size − black) · WB + b, with b 1015–1052 and green k 0.98–1.02, on 11 of 12 files from +/// four bodies (ILCE-7M4 M, S and APS-C S; ILCE-7RM5 M, S and S35 S; ILCE-7CR M, S and S35 S; ILCE-9M3 M, S and +/// APS-C S; the twelfth's reference was shot 24 s later, 7 % brighter). Their darkest 0.1 % lies at 1047–1127. So +/// black and white both move up by 512: the scale stays the full-size raw's, and an M/S file renders like its +/// full-size twin. Subtracting only 512 left a pedestal of 3 % of white, lifting the shadows (storytold/lightcraft#535). +const YCBCR_OFFSET: f32 = 512.0; +/// White of the YCbCr data before [`YCBCR_OFFSET`]: the reference levels' Y white (required by [`read_ycbcr_tiles`]). +const YCBCR_WHITE: f32 = 16383.0; + +/// Downsized M/S ARWs are linear YCbCr 4:2:0 / 4:2:2, not CFA, despite retaining dummy CFA tags. +/// T.81 supplies the lossless coding; TIFF 6.0 supplies the YCbCr coefficients and reference +/// levels. Sony's reference chroma black and white are identical (the neutral offset). +fn read_ycbcr_tiles(bytes: &[u8], info: &ImageInfo, raw: &Ifd, mode: Mode, limits: &Limits) -> Result> { + let total = check_image(bytes, info, limits)?; + if info.samples_per_pixel != 3 || info.planar != 1 || !matches!(info.layout, Layout::Tiles { .. }) { + return Err(RawError::Unsupported("Sony linear YCbCr tile layout".into())); + } + let chunks = info.chunks(bytes.len() as u64); + let first = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("missing YCbCr tile".into()))?; + ljpeg::frame_info_subsampled(first)?; + let coefficients = raw.f64s(YCBCR_COEFFICIENTS).unwrap_or_else(|| vec![0.299, 0.587, 0.114]); + let [kr, kg, kb] = coefficients.as_slice() else { return Err(RawError::Corrupt("YCbCr coefficients".into())) }; + if !coefficients.iter().all(|v| v.is_finite() && *v > 0.0 && *v < 1.0) || (kr + kg + kb - 1.0).abs() > 0.001 { + return Err(RawError::Corrupt("invalid YCbCr coefficients".into())); + } + let references = raw.f64s(REFERENCE_BLACK_WHITE).unwrap_or_else(|| vec![0.0, 16383.0, 16384.0, 16384.0, 16384.0, 16384.0]); + let [yblack, ywhite, cbzero, cbwhite, crzero, crwhite] = references.as_slice() else { + return Err(RawError::Corrupt("YCbCr reference levels".into())); + }; + if !references.iter().all(|v| v.is_finite() && (0.0..=65535.0).contains(v)) + || *yblack != 0.0 + || *ywhite != 16383.0 + || cbzero != cbwhite + || crzero != crwhite + { + return Err(RawError::Unsupported("Sony YCbCr reference levels".into())); + } + if mode == Mode::Header { + return Ok(Vec::new()); + } + let (w, h) = (info.width as usize, info.height as usize); + let mut out = vec![0u16; total]; + // decoded a group at a time, as read_quad_tiles does (storytold/lightcraft#2554) + for group in decode_groups(&chunks, 3, total) { + let decoded: Vec<(Chunk, ljpeg::FrameSubsampled)> = chunks[group] + .par_iter() + .map(|c| { + let src = chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("YCbCr tile outside file".into()))?; + let limit = + (c.width as usize).checked_mul(c.height as usize).and_then(|n| n.checked_mul(3)).ok_or(RawError::Limit("tile too large"))?; + let f = ljpeg::decode_subsampled(src, limit, ljpeg::Prediction::Geometric)?; + if f.width != c.width as usize || f.height != c.height as usize { + return Err(RawError::Corrupt("Sony YCbCr tile dimensions".into())); + } + Ok((*c, f)) + }) + .collect::>()?; + for (c, f) in decoded { + for y in 0..f.height.min(h.saturating_sub(c.y as usize)) { + for x in 0..f.width.min(w.saturating_sub(c.x as usize)) { + let luma = f.planes[0][y * f.width + x] as f64; + let ci = (y / f.vertical_subsampling) * (f.width / 2) + x / 2; + let cb = f.planes[1][ci] as f64 - cbzero; + let cr = f.planes[2][ci] as f64 - crzero; + let r = luma + (2.0 - 2.0 * kr) * cr; + let b = luma + (2.0 - 2.0 * kb) * cb; + let g = (luma - kr * r - kb * b) / kg; + let dst = (((c.y as usize + y) * w) + c.x as usize + x) * 3; + for (s, value) in [r, g, b].into_iter().enumerate() { + out[dst + s] = value.round().clamp(0.0, 65535.0) as u16; + } + } + } + } + } + Ok(out) +} + +/// The black level stored in the encrypted `SR2SubIFD` (`0x7310`; bodies that do not write it in the raw IFD keep +/// it only there), in 14-bit units; `None` unless it is SHORT, count 4, and the four levels are equal and plausible. +/// +/// Its value sits at a layout-dependent position (1638 … 2786 bytes into the block), which the directory entry +/// gives. Reading a fixed position instead (2510, right for the 29252, 33210 and 56958-byte layouts) read other data +/// on the DSLR-A450/A500/A550 (27152 bytes: 354–365) and the DSLR-A700 (62112 bytes: 975), whose black is 512 by +/// ExifTool and by the data floor (storytold/lightcraft#535). +fn sr2_black(sr2: &crate::sr2::SubIfd) -> Option { + match sr2.short_bytes(BLACK_LEVEL).or_else(|| sr2.short_bytes(SR2_OLD_BLACK))? { + (3, plain) if plain.len() == 8 => sr2_black_levels(plain, sr2.order()), + _ => None, + } +} + +/// The DSC-R1's as-shot gains `[R/G, 1, B/G]` from `0x7303` (G, R, B, G), when plausible. +fn sr2_old_wb(sr2: &crate::sr2::SubIfd) -> Option<[f32; 3]> { + let v = sr2.shorts(SR2_OLD_WB).filter(|v| v.len() == 4 && v.iter().all(|&x| x > 0.0))?; + let g = (v[0] + v[3]) / 2.0; + let (r, b) = ((v[1] / g) as f32, (v[2] / g) as f32); + ((0.25..=16.0).contains(&r) && (0.25..=16.0).contains(&b)).then_some([r, 1.0, b]) +} + +/// The DSC-R1's crop from `0x74c3` (x0, y0, x1, y1): an even origin (CFA phase) and a window inside the `w × h` frame. +/// The five presets of [`SR2_KELVIN_PRESETS`], when the block has them all and they make a curve. +fn sr2_kelvin_presets(sr2: &crate::sr2::SubIfd) -> Option { + let mut points = [(0.0f32, [0.0f32; 3]); 5]; + for (p, (k, tag)) in points.iter_mut().zip(SR2_KELVIN_PRESETS) { + let v = sr2.shorts(tag).filter(|v| v.len() == 3)?; + *p = (k, [v[0] as f32, v[1] as f32, v[2] as f32]); + } + crate::kelvin::KelvinPresets::new(points) +} + +fn sr2_old_crop(sr2: &crate::sr2::SubIfd, w: usize, h: usize) -> Option { + let v = sr2.shorts(SR2_OLD_CROP).filter(|v| v.len() == 4)?; + let [x0, y0, x1, y1] = [v[0] as usize, v[1] as usize, v[2] as usize, v[3] as usize]; + (x0 % 2 == 0 && y0 % 2 == 0 && x1 > x0 && y1 > y0 && x1 <= w && y1 <= h).then(|| Rect::new(x0, y0, x1 - x0, y1 - y0)) +} + +/// One black level from the four deciphered per-channel levels (their mean), when they are near-equal and plausible. +fn sr2_black_levels(plain: &[u8], order: craft_tiff::ByteOrder) -> Option { + let levels: Vec = (0..4).filter_map(|i| order.read_u16(plain, 2 * i)).collect(); + let (&lo, &hi) = (levels.iter().min()?, levels.iter().max()?); + (levels.len() == 4 && (64..=4096).contains(&lo) && hi - lo <= 64).then(|| levels.iter().map(|&v| f32::from(v)).sum::() / 4.0) +} + +/// Byte order of 16-bit-word samples: the file's own order unless the words only fit the sample width in the other +/// (see the DSC-R1 note at the top). Sampled across the strip; the order is swapped only when the file order leaves +/// more than 1 % of the sampled words at or above `1 << max(bits, 14)` while the other order leaves none. +fn word16_order(strip: &[u8], bits: u32, file: craft_tiff::ByteOrder) -> craft_tiff::ByteOrder { + use craft_tiff::ByteOrder::{Big, Little}; + let other = if file == Little { Big } else { Little }; + let limit = 1u32 << bits.clamp(14, 16); + let (mut n, mut over_file, mut over_other) = (0usize, 0usize, 0usize); + let words = strip.len() / 2; + let step = (words / 65536).max(1); + for &b in strip.as_chunks::<2>().0.iter().step_by(step) { + n += 1; + over_file += usize::from(u32::from(file.u16(b)) >= limit); + over_other += usize::from(u32::from(other.u16(b)) >= limit); + } + if n > 0 && over_other == 0 && over_file * 100 > n { other } else { file } +} + +/// The raw IFD's DNG-style `DefaultCropOrigin` / `DefaultCropSize`, clipped to the `w × h` frame. +fn dng_default_crop(raw: &Ifd, w: usize, h: usize) -> Option { + if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.u64s(t::DEFAULT_CROP_SIZE).as_deref()) + && *cw > 0 + && *ch > 0 + { + return Some(Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h)); + } + None +} + +/// A rectangle exactly as an origin tag and a size tag record it (two values each), when both exist and fit `usize`. +fn recorded_rect(raw: &Ifd, origin: u16, size: u16) -> Option { + let (Some(origin), Some(size)) = (raw.u64s(origin), raw.u64s(size)) else { return None }; + let (&[x, y], &[w, h]) = (origin.as_slice(), size.as_slice()) else { return None }; + let n = |v: u64| usize::try_from(v).ok(); + Some(Rect::new(n(x)?, n(y)?, n(w)?, n(h)?)) +} + +/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style +/// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]). +/// +/// `centred` places that recorded-size window in the middle of the frame (on even offsets, keeping the CFA phase) +/// instead of at the left: the packed 12-bit frame (DSLR-A900) has no padding at either edge, and its camera JPEG +/// is centred on the frame. +fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, exif_size: Option<(u64, u64)>, w: usize, h: usize, centred: bool) -> Rect { + let full = Rect::new(0, 0, w, h); + if let Some(crop) = dng_default_crop(raw, w, h) { + return crop; + } + match mn.and_then(|m| m.ifd.u64s(MN_FULL_IMAGE_SIZE)).as_deref() { + Some([fh, fw]) => recorded_size_crop((*fw, *fh), exif_size, w, h, centred).unwrap_or(full), + _ => full, + } +} + +/// The crop for the maker note's `FullImageSize` `full` (width, height: the camera JPEG's size) and the Exif image +/// size `exif` (`PixelXDimension` × `PixelYDimension`) in a `w × h` frame; `None` when they don't describe a crop of +/// it. Older bodies store a few columns of padding at the right edge of the raw frame (constant values, 8–32 columns +/// on the samples we checked) inside a frame 16–48 pixels wider than `FullImageSize`, so the crop is anchored at +/// the left, which removes them while keeping the CFA phase. In the camera's 3:2 mode it is also anchored at the +/// top. An in-camera aspect ratio narrower than the frame (16:9) is centred vertically, as measured by registering +/// each camera JPEG on its raw (the ILCE-7SM2's 4240 × 2384 at y 228–232 of 2848; the DSLR-A580's 4912 × 2760 at +/// y 258–262 of 3280; storytold/lightcraft#535): its height comes from `FullImageSize` (ILCE-7SM2) or, when that still says +/// 3:2, from an Exif image size of the same width (DSLR-A580). `centred` centres the window in both directions +/// instead (see [`default_crop`]). +fn recorded_size_crop(full: (u64, u64), exif: Option<(u64, u64)>, w: usize, h: usize, centred: bool) -> Option { + let (fw, fh) = (usize::try_from(full.0).ok()?, usize::try_from(full.1).ok()?); + // only a plausible trim of the width: never more than 64 pixels, never an enlargement + if fw == 0 || fh == 0 || fw > w || fw.saturating_add(64) < w || fh > h { + return None; + } + let height = match exif.and_then(|(ew, eh)| Some((usize::try_from(ew).ok()?, usize::try_from(eh).ok()?))) { + Some((ew, eh)) if ew == fw && eh > 0 && eh < fh => eh, + _ => fh, + }; + // even offsets keep the CFA phase + let x = if centred { ((w - fw) / 2) & !1 } else { 0 }; + if height.saturating_add(64) >= h { + return Some(Rect::new(x, if centred { ((h - height) / 2) & !1 } else { 0 }, fw, height)); + } + // an in-camera aspect ratio: at least half the frame's height, centred + (height * 2 >= h).then(|| Rect::new(x, ((h - height) / 2) & !1, fw, height)) +} + +fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { + tiff.all_ifds() + .into_iter() + .filter(|i| i.u16(t::PHOTOMETRIC) == Some(photometric::CFA) || i.contains(TONE_CURVE)) + .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0))) +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result { + let tiff = Tiff::parse(bytes)?; + let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("TIFF without IFDs".into()))?; + let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("ARW without a CFA image IFD (old ARW or SR2)".into()))?; + let info = raw.image()?; + let (w, h) = (info.width as usize, info.height as usize); + // the cap (≤ 2^30) keeps every downstream usize product (w·h·2, w·h·3/2, y·w) inside range on 32-bit targets + let samples = crate::checked_pixel_count(w as u64, h as u64).ok_or(RawError::Limit("image too large"))?; + let bits = info.bits() as u32; + let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR; + let chunks = info.chunks(bytes.len() as u64); + let strip_len = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len)); + // The row-per-w-bytes (ARW2) layout stores exactly one byte per pixel in a single strip; all 108 such files in the test + // archive measure exactly width x height. A200/A230/A350 files carry a strip 4-23 % larger than that (and A290/A390 one + // smaller), which is a different packing, so they are refused rather than read with the wrong layout. + let one_byte_per_sample = chunks.len() == 1 && strip_len == samples; + let packed12 = info.compression == 32767 && is_packed12(&info, chunks.len(), strip_len); + let (data, out_bits) = match info.compression { + 7 if linear_rgb => (RawData::U16(read_ycbcr_tiles(bytes, &info, raw, mode, limits)?), 14), + 32767 if one_byte_per_sample && mode == Mode::Header => { + chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + (RawData::U16(Vec::new()), 14) + } + 32767 if one_byte_per_sample => { + let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + if src.len() as u64 != strip_len { + return Err(RawError::Corrupt("raw strip shorter than its rows".into())); + } + let curve = code_curve(&raw.u64s(TONE_CURVE).unwrap_or_else(|| vec![8000, 10400, 12900, 14100])); + let mut data = vec![0u16; samples as usize]; + data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + let row = src.get(y * w..((y + 1) * w).min(src.len())).unwrap_or(&[]); + if row.len() == w { + decode_row(row, out); + out.iter_mut().for_each(|v| *v = curve[*v as usize]); + } + }); + (RawData::U16(data), 14) + } + 32767 if packed12 && mode == Mode::Header => { + chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + (RawData::U16(Vec::new()), 12) + } + 32767 if packed12 => { + let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + if src.len() as u64 != strip_len { + return Err(RawError::Corrupt("raw strip shorter than its rows".into())); + } + let row_bytes = w / 2 * 3; + let mut data = vec![0u16; samples as usize]; + data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + if let Some(row) = src.get(y * row_bytes..(y + 1) * row_bytes) { + unpack_row12(row, out); + } + }); + (RawData::U16(data), 12) + } + 32767 => return Err(RawError::Unsupported("Sony ARW version 1 / packed compressed variant (raw strip is not one byte per pixel)".into())), + 7 if is_quad_tiled(bytes, &info) => match mode { + Mode::Full => (RawData::U16(read_quad_tiles(bytes, &info, limits)?), bits), + Mode::Header => { + check_image(bytes, &info, limits)?; + (RawData::U16(Vec::new()), bits) + } + }, + 32766 if chunks.len() == 1 => { + if bits != 14 { + return Err(RawError::Unsupported("ARW6 12-bit mode (no sample to verify)".into())); + } + let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + (RawData::U16(arw6::decode(src, w, h, mode)?), 16) + } + 32766 => return Err(RawError::Unsupported("ARW6 without exactly one strip".into())), + 1 => { + let word16 = strip_len >= (w * h * 2) as u64; + let packing = if word16 { Packing::Word16 } else { Packing::Msb }; + let order = match chunks.first().and_then(|c| chunk_bytes(bytes, c)) { + Some(strip) if word16 => word16_order(strip, bits, tiff.order), + _ => tiff.order, + }; + (read_image_in(mode, bytes, &info, order, packing, limits)?, bits) + } + _ => (read_image_in(mode, bytes, &info, tiff.order, Packing::Msb, limits)?, bits), + }; + let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float ARW".into())) }; + // Sony's "12-bit uncompressed" mode stores 16-bit words on the 14-bit scale, + // including dark frames with no sample above 4095. The layout, not the scene's + // brightest sample, determines black/white units. Header and full decodes must + // agree too. Genuinely packed 12-bit strips retain their 12-bit scale. + let scale_bits = if info.compression == 1 && out_bits < 14 && strip_len >= (w * h * 2) as u64 { 14 } else { out_bits }; + + let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + _ => Cfa::bayer_static("RGGB"), + }; + let default_black = if scale_bits >= 14 { 512.0 } else { 128.0 }; + // the decrypted SR2SubIFD, read once and only when a plain black level or white balance is missing + let sr2_cell = std::cell::OnceCell::new(); + let sr2 = || sr2_cell.get_or_init(|| crate::sr2::SubIfd::read(bytes, ifd0, tiff.order)).as_ref(); + let black = match raw.f64s(BLACK_LEVEL).as_deref() { + Some(v) if linear_rgb && !v.is_empty() => BlackLevel::uniform((v.iter().sum::() / v.len() as f64) as f32 + YCBCR_OFFSET), + Some([a, b, c, d]) => { + BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() } + } + _ if linear_rgb => BlackLevel::uniform(default_black + YCBCR_OFFSET), + _ => BlackLevel::uniform((scale_bits >= 14).then(|| sr2().and_then(sr2_black)).flatten().unwrap_or(default_black)), + }; + // ARW6 samples are in 2 x 14-bit units, so the file's 14-bit levels double; without the tag the white is the + // 14-bit white in those units, 2 x 16383 + let unit = if info.compression == 32766 { 2.0 } else { 1.0 }; + let white = if linear_rgb { + YCBCR_WHITE + YCBCR_OFFSET + } else { + let tag = raw.f64(t::WHITE_LEVEL).map(|v| v as f32 * unit).filter(|v| *v > 0.0); + match (tag, info.compression) { + (Some(v), _) => v, + (None, 32766) => 32766.0, + (None, _) => crate::shared::white_from_data(samples, scale_bits), + } + }; + let black = BlackLevel { values: black.values.iter().map(|v| v * unit).collect(), ..black }; + let model = ifd0.string(t::MODEL).unwrap_or_default(); + let mn = tiff + .exif() + .and_then(|e| e.get(t::MAKER_NOTE)) + .and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &ifd0.string(t::MAKE).unwrap_or_default())); + let body = sony_body(&ifd0.string(t::MAKE).unwrap_or_default(), &model, mn.as_ref()); + let wb = raw + .f64s(WB_RGGB) + .and_then(|v| rggb_gains(&v)) + .or_else(|| rggb_gains(&sr2()?.shorts(WB_RGGB)?)) + .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(body, m.ifd.bytes(MN_TAG2010)?, m.order))) + .or_else(|| sr2_old_wb(sr2()?)); + let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) { + (Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h), + _ => { + let exif_size = tiff.exif().and_then(|e| Some((e.u64(t::PIXEL_X_DIMENSION)?, e.u64(t::PIXEL_Y_DIMENSION)?))); + let crop = default_crop(raw, mn.as_ref(), exif_size, w, h, packed12); + // no crop from any other source: the old SR2SubIFD's + if crop == Rect::new(0, 0, w, h) { sr2().and_then(|s| sr2_old_crop(s, w, h)).unwrap_or(crop) } else { crop } + } + }; + // The lens-correction record. The distortion table is centred on the DNG-style default crop when the file has + // one: on the ILCE-7RM4A it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and + // the warp was validated against Sony's exports with that centre. The opcode's centre is in active-area + // coordinates, so it stays on the optical centre whichever crop frames the image. + let reference = dng_default_crop(raw, w, h).unwrap_or(crop); + let geometry = SonyLensGeometry { + storage_width: w, + storage_height: h, + sony_crop: recorded_rect(raw, CROP_TOP_LEFT, CROP_SIZE), + default_crop: recorded_rect(raw, t::DEFAULT_CROP_ORIGIN, t::DEFAULT_CROP_SIZE), + image_crop: crop, + reference, + compression: info.compression, + linear_ycbcr: linear_rgb, + native_3_2: crate::arw_lens::is_3_2(crop) && crate::arw_lens::is_3_2(reference), + }; + // decrypted for the copies' agreement check too, not only when a plain field is missing: a few dozen kB, found by + // reading only three `SR2Private` entries (see `sr2::SubIfd::read`) + let (mut sony_lens, warp) = crate::arw_lens::corrections(bytes, tiff.order, (raw, tiff.bigtiff), sr2(), &model, geometry); + // The ILCE-7RM6's tables follow other conventions, measured against its camera JPEGs ([`crate::arw_lens_rm6`]); + // its allowlist and `arw_lens`'s are disjoint, so at most one of them produces an opcode. Both the image framing + // and the table geometry must stay within the validated 3:2 scope. + let rm6: Vec = if linear_rgb || !crate::arw_lens::is_3_2(crop) { + Vec::new() + } else { + // the lens and focal length as Exif states them (LensModel, FocalLength in the Exif IFD or IFD0) + let exif = tiff.exif(); + let lens = exif.and_then(|e| e.string(t::LENS_MODEL)).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); + let focal = exif.and_then(|e| e.f64(t::FOCAL_LENGTH)).or_else(|| ifd0.f64(t::FOCAL_LENGTH)).filter(|f| *f > 0.0 && f.is_finite()); + crate::arw_lens_rm6::lens_opcodes(raw, &model, lens.as_deref(), focal, Rect::new(0, 0, w, h), reference) + }; + if !rm6.is_empty() + && let Some(record) = sony_lens.as_mut() + { + // distortion only: its lateral CA planes are off (see `arw_lens_rm6::lens_opcodes`) + record.distortion.status = crate::lens_metadata::SonyLensStatus::Applied; + } + let img = RawImage { + format: RawFormat::Arw, + width: w, + height: h, + cpp: if linear_rgb { 3 } else { 1 }, + data, + cfa: if linear_rgb { None } else { Some(cfa) }, + bits: out_bits, + black, + white: vec![white], + active_area: Rect::new(0, 0, w, h), + crop, + orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), + // (linear YCbCr comes white-balanced: its unit multipliers have no place on the presets' curve) + color: ColorData { kelvin_presets: if linear_rgb { None } else { sr2().and_then(sr2_kelvin_presets) }, ..Default::default() }, + // Sony's linear YCbCr already carries as-shot WB. Applying the CFA gains again + // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. + wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, + linearized: false, + pixel_aspect: 1.0, + // only validated components become opcodes (see `arw_lens`, and `arw_lens_rm6` for the ILCE-7RM6) + opcodes: OpcodeLists { list3: warp.into_iter().chain(rm6).collect(), ..Default::default() }, + camera: Camera::from_ifd0(ifd0), + cinema: Default::default(), + lens_metadata: LensMetadata { sony: sony_lens }, + }; + img.validate_for(mode)?; + Ok(img) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn downsized_lossless_is_linear_rgb_not_cfa() { + use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7M4".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![15, 15, 15])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::YCBCR])); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(TONE_CURVE, Value::Short(vec![0; 4])); + raw.set(BLACK_LEVEL, Value::Short(vec![512; 4])); + raw.set(WB_RGGB, Value::Short(vec![2048, 1024, 1024, 2048])); + raw.set_image(ImageData::Tiles { tile_width: 4, tile_height: 4, tiles: vec![ljpeg::tests::fixture_420()] }); + let file = TiffWriter::default().write(&[raw]).unwrap(); + let header = decode(&file, Mode::Header, &Limits::default()).unwrap(); + let full = decode(&file, Mode::Full, &Limits::default()).unwrap(); + assert_eq!(header.info(), full.info()); + assert_eq!((full.width, full.height, full.cpp), (4, 4, 3)); + assert!(full.cfa.is_none()); + assert_eq!(full.data.len(), 48); + assert_eq!(full.wb_multipliers, Some([1.0; 3])); + // the YCbCr data sit 512 above the recorded sensor black level; white moves with it + assert_eq!((full.black.mean(), full.white_at(0)), (1024.0, 16895.0)); + assert_eq!((header.black.mean(), header.white_at(0)), (1024.0, 16895.0)); + let RawData::U16(ref pixels) = full.data else { + panic!("integer ARW"); + }; + assert_eq!(&pixels[..3], &[1140, 929, 1000]); + assert_eq!(full.info().developed_size(), (4, 4)); + } + + #[test] + fn recorded_size_crops_in_camera_aspect_ratios() { + // 3:2: anchored at the top-left, as before (ILCE-7S, DSLR-A700 whose Exif size is the whole frame) + assert_eq!(recorded_size_crop((4240, 2832), Some((4240, 2832)), 4288, 2848, false), Some(Rect::new(0, 0, 4240, 2832))); + assert_eq!(recorded_size_crop((4272, 2848), Some((4288, 2856)), 4288, 2856, false), Some(Rect::new(0, 0, 4272, 2848))); + assert_eq!(recorded_size_crop((4240, 2832), None, 4288, 2848, false), Some(Rect::new(0, 0, 4240, 2832))); + // 16:9 from FullImageSize (ILCE-7SM2) and from the Exif size when FullImageSize says 3:2 (DSLR-A580): centred + assert_eq!(recorded_size_crop((4240, 2384), Some((4240, 2384)), 4288, 2848, false), Some(Rect::new(0, 232, 4240, 2384))); + assert_eq!(recorded_size_crop((4912, 3264), Some((4912, 2760)), 4928, 3280, false), Some(Rect::new(0, 260, 4912, 2760))); + // offsets stay even (CFA phase) + assert_eq!(recorded_size_crop((4240, 2386), None, 4288, 2848, false).map(|r| r.y), Some(230)); + // not a crop of this frame: wider than it, far narrower, under half its height, empty, hostile values + assert_eq!(recorded_size_crop((4300, 2832), None, 4288, 2848, false), None); + assert_eq!(recorded_size_crop((4000, 2832), None, 4288, 2848, false), None); + assert_eq!(recorded_size_crop((4240, 1000), None, 4288, 2848, false), None); + assert_eq!(recorded_size_crop((0, 2832), None, 4288, 2848, false), None); + assert_eq!(recorded_size_crop((u64::MAX, u64::MAX), Some((u64::MAX, 1)), 4288, 2848, false), None); + assert_eq!(recorded_size_crop((4240, 2832), Some((4240, 0)), 4288, 2848, false), Some(Rect::new(0, 0, 4240, 2832))); + // centred (packed 12-bit DSLR-A900: 6048 x 4032 in the middle of 6080 x 4048) + assert_eq!(recorded_size_crop((6048, 4032), Some((6048, 4032)), 6080, 4048, true), Some(Rect::new(16, 8, 6048, 4032))); + assert_eq!(recorded_size_crop((6046, 4030), None, 6080, 4048, true), Some(Rect::new(16, 8, 6046, 4030))); + } + + /// A 32767-compressed ARW whose single strip is `strip` bytes for a 32 x 4 image. + fn arw_with_strip(strip: usize) -> Vec { + use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("DSLR-A200".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0u8; strip]] }); + TiffWriter::default().write(&[raw]).unwrap() + } + + /// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow. + #[test] + fn strip_byte_counts_past_u64_max_do_not_overflow() { + let file = crate::shared::tests::huge_strip_counts(arw_with_strip(32 * 4)); + for mode in [Mode::Header, Mode::Full] { + let _ = super::decode(&file, mode, &Limits::default()); + } + } + + /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode, as + /// one plane, also with a CA table (lateral CA is off by default); another body's tables don't (see + /// [`crate::arw_lens_rm6`]). + #[test] + fn lens_tables_reach_opcode_list3() { + use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let file = |model: &str, ca: bool| { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(model.into())); + // 3:2, the aspect the opcode is limited to + raw.set(t::IMAGE_WIDTH, Value::Long(vec![96])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![64])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + let table = vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]; + raw.set(crate::arw_lens_rm6::DISTORTION_PARAMS, Value::SShort(table)); + if ca { + let table = vec![ + 32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, + -6, 20, 42, 56, 60, 54, 32, + ]; + raw.set(crate::arw_lens_rm6::CA_PARAMS, Value::SShort(table)); + } + raw.set_image(ImageData::Strips { rows_per_strip: 64, strips: vec![vec![0u8; 96 * 64]] }); + TiffWriter::default().write(&[raw]).unwrap() + }; + for mode in [Mode::Header, Mode::Full] { + for ca in [false, true] { + let list3 = |model| decode(&file(model, ca), mode, &Limits::default()).unwrap().opcodes.list3; + let ops = list3("ILCE-7RM6"); + assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{mode:?} {ca}: {ops:?}"); + assert!(list3("ILCE-7M4").is_empty(), "{mode:?} {ca}"); + } + } + } + + #[test] + fn strip_larger_or_smaller_than_one_byte_per_pixel_is_the_packed_variant() { + for strip in [32 * 4 + 8, 32 * 4 * 5 / 4 - 1, 32 * 4 - 1] { + for mode in [Mode::Header, Mode::Full] { + let err = decode(&arw_with_strip(strip), mode, &Limits::default()).unwrap_err(); + assert!(matches!(err, RawError::Unsupported(ref m) if m.contains("packed compressed variant")), "{strip} {mode:?}: {err:?}"); + } + } + for mode in [Mode::Header, Mode::Full] { + let r = decode(&arw_with_strip(32 * 4), mode, &Limits::default()); + assert!(!matches!(r, Err(RawError::Unsupported(ref m)) if m.contains("packed compressed variant")), "{r:?}"); + } + } + + /// A 32767-compressed, 12-bit ARW of `w` x `h` whose single strip is `strip`. + fn packed_arw(w: u32, h: u32, strip: Vec) -> Vec { + use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("DSLR-A900".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] }); + TiffWriter::default().write(&[raw]).unwrap() + } + + /// Pack 12-bit samples as the file does: `b0 = a & 0xff`, `b1 = a >> 8 | b << 4 & 0xf0`, `b2 = b >> 4`. + fn pack12(samples: &[u16]) -> Vec { + samples.chunks(2).flat_map(|p| [p[0] as u8, (p[0] >> 8) as u8 | ((p[1] & 0xf) << 4) as u8, (p[1] >> 4) as u8]).collect() + } + + #[test] + fn packed_12_bit_strip_of_one_and_a_half_bytes_per_pixel_decodes() { + let (w, h) = (8usize, 4usize); + let samples: Vec = (0..w * h).map(|i| (i as u16 * 129 + 130) & 0xfff).collect(); + let file = packed_arw(w as u32, h as u32, pack12(&samples)); + let full = decode(&file, Mode::Full, &Limits::default()).unwrap(); + let RawData::U16(ref got) = full.data else { panic!("integer ARW") }; + assert_eq!(got, &samples); + assert_eq!((full.width, full.height, full.bits), (w, h, 12)); + assert_eq!(full.cfa.as_ref().map(|c| c.pattern.clone()), Some(vec![0, 1, 1, 2])); + assert_eq!(full.black.values.first().copied().unwrap_or(0.0), 128.0); + let header = decode(&file, Mode::Header, &Limits::default()).unwrap(); + assert_eq!(header.info(), full.info()); + // the first three bytes 0x12 0xA3 0x45 are the samples 0x312 and 0x45A (least-significant-bit first) + let mut row = vec![0u16; 2]; + unpack_row12(&[0x12, 0xa3, 0x45], &mut row); + assert_eq!(row, [0x312, 0x45a]); + } + + #[test] + fn packed_12_bit_needs_the_exact_strip_size() { + let (w, h) = (8usize, 4usize); + let exact = w * h * 3 / 2; + for strip in [exact - 1, exact + 1, exact + 3 * h] { + for mode in [Mode::Header, Mode::Full] { + let err = decode(&packed_arw(w as u32, h as u32, vec![0u8; strip]), mode, &Limits::default()).unwrap_err(); + assert!(matches!(err, RawError::Unsupported(ref m) if m.contains("packed compressed variant")), "{strip} {mode:?}: {err:?}"); + } + } + // an odd width cannot be packed in pairs + let err = decode(&packed_arw(7, 4, vec![0u8; 7 * 4 * 3 / 2]), Mode::Full, &Limits::default()).unwrap_err(); + assert!(matches!(err, RawError::Unsupported(_)), "{err:?}"); + } + + /// Encode one 16-value set as an ARW2 block (the paper's scheme, used here to test the decoder). + pub(crate) fn encode_block(v: &[u16; 16]) -> [u8; 16] { + let (imax, &max) = v.iter().enumerate().max_by_key(|(i, x)| (**x, usize::MAX - *i)).unwrap(); + let (imin, &min) = v.iter().enumerate().filter(|(i, _)| *i != imax).min_by_key(|(_, x)| **x).unwrap(); + let range = max - min; + let mut sh = 0; + while sh < 4 && (range >> sh) > 127 { + sh += 1; + } + let mut b: u128 = max as u128 | (min as u128) << 11 | (imax as u128) << 22 | (imin as u128) << 26; + let mut bit = 30; + for (i, &x) in v.iter().enumerate() { + if i == imax || i == imin { + continue; + } + b |= (((x - min) >> sh) as u128 & 0x7f) << bit; + bit += 7; + } + b.to_le_bytes() + } + + #[test] + fn block_roundtrip_exact_when_range_small() { + let mut row = vec![0u8; 64]; + let vals: Vec = (0..64).map(|i| 300 + (i * 37 % 100) as u16).collect(); + for g in 0..2 { + for half in 0..2 { + let set: [u16; 16] = std::array::from_fn(|i| vals[g * 32 + half + 2 * i]); + row[g * 32 + half * 16..g * 32 + half * 16 + 16].copy_from_slice(&encode_block(&set)); + } + } + let mut out = vec![0u16; 64]; + decode_row(&row, &mut out); + assert_eq!(out, vals); + } + + #[test] + fn block_quantises_large_ranges() { + let set: [u16; 16] = std::array::from_fn(|i| (i as u16) * 130); + let mut row = vec![0u8; 32]; + row[..16].copy_from_slice(&encode_block(&set)); + let mut out = vec![0u16; 32]; + decode_row(&row, &mut out); + for i in 0..16 { + let got = out[2 * i]; + assert!(got <= set[i] && set[i] - got < 16, "{i}: {got} vs {}", set[i]); + } + assert_eq!(out[0], 0); + assert_eq!(out[30], 1950); + } + + #[test] + fn curve_is_monotonic_and_matches_tags() { + let c = code_curve(&[8000, 10400, 12900, 14100]); + assert!(c.windows(2).all(|w| w[1] >= w[0])); + assert_eq!(c[256], 512); // black + assert_eq!(c[1000], 2000); + assert_eq!(*c.last().unwrap(), 16383); + // degenerate thresholds do not panic + let _ = code_curve(&[]); + let _ = code_curve(&[60000, 1, 0, 0]); + } + + #[test] + fn decipher_inverts_the_cube_substitution() { + let mut seen = [false; 256]; + for p in 0..=255u8 { + let c = if p < 249 { (p as u32).pow(3) % 249 } else { p as u32 } as u8; + assert_eq!(DECIPHER[c as usize], p); + seen[DECIPHER[p as usize] as usize] = true; + } + assert!(seen.iter().all(|&s| s), "not a bijection"); + assert_eq!((DECIPHER[8], DECIPHER[27], DECIPHER[94]), (2, 3, 7)); // 7³ = 343 ≡ 94 + } + + fn encipher(plain: &[u8]) -> Vec { + plain.iter().map(|&p| if p < 249 { ((p as u32).pow(3) % 249) as u8 } else { p }).collect() + } + + #[test] + fn tag2010_white_balance_by_model() { + use craft_tiff::ByteOrder::Little; + let mut plain = vec![0u8; 700]; + for (i, v) in [669u16, 256, 441].iter().enumerate() { + plain[612 + 2 * i..614 + 2 * i].copy_from_slice(&v.to_le_bytes()); + } + let block = encipher(&plain); + let wb = tag2010_wb("DSC-RX100M3", &block, Little).unwrap(); + assert!((wb[0] - 669.0 / 256.0).abs() < 1e-6 && wb[1] == 1.0 && (wb[2] - 441.0 / 256.0).abs() < 1e-6, "{wb:?}"); + // same layout (Tag2010h), regional "V" suffix, other layouts, unknown models, truncated or implausible data + assert!(tag2010_wb("ILCE-7RM2", &block, Little).is_some()); + assert!(tag2010_wb("DSC-RX100M3V", &block, Little).is_some()); + assert!(tag2010_wb("SLT-A77V", &block, Little).is_none()); // Tag2010b: offset 4480 is past the block + assert!(tag2010_wb("DSC-RX100M3X", &block, Little).is_none()); + assert!(tag2010_wb("ILCE-1", &block, Little).is_none()); + assert!(tag2010_wb("DSC-RX100M3", &block[..615], Little).is_none()); + assert!(tag2010_wb("DSC-RX100M3", &encipher(&[0u8; 700]), Little).is_none()); + let mut odd = plain.clone(); + odd[612..614].copy_from_slice(&9000u16.to_le_bytes()); // R/G ≈ 35 + assert!(tag2010_wb("DSC-RX100M3", &encipher(&odd), Little).is_none()); + } + + /// A Sony-style file: IFD0 (`make`) → Exif → maker note `SONY DSC \0\0\0` + an IFD of `entries` (tag, type, + /// count, inline value bytes), the layout of the camera's own notes; `count` overrides the entry count. + fn sony_file(order: craft_tiff::ByteOrder, make: &str, entries: &[(u16, u16, u32, [u8; 4])], count: Option) -> Vec { + use craft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + let (u16b, u32b) = match order { + ByteOrder::Little => (u16::to_le_bytes as fn(u16) -> [u8; 2], u32::to_le_bytes as fn(u32) -> [u8; 4]), + ByteOrder::Big => (u16::to_be_bytes as fn(u16) -> [u8; 2], u32::to_be_bytes as fn(u32) -> [u8; 4]), + }; + let mut note = b"SONY DSC \0\0\0".to_vec(); + note.extend(u16b(count.unwrap_or(entries.len() as u16))); + for &(tag, kind, n, value) in entries { + note.extend(u16b(tag)); + note.extend(u16b(kind)); + note.extend(u32b(n)); + note.extend(value); + } + note.extend(u32b(0)); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note)); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii("ILCE-7CR".into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(order, false).write(&[ifd0]).unwrap() + } + + #[test] + fn dynamic_range_optimizer_is_read_from_the_sony_note() { + use craft_tiff::ByteOrder::{Big, Little}; + let long = |order, v: u32| match order { + Little => v.to_le_bytes(), + Big => v.to_be_bytes(), + }; + let short = |order, v: u16| match order { + Little => [v.to_le_bytes()[0], v.to_le_bytes()[1], 0, 0], + Big => [v.to_be_bytes()[0], v.to_be_bytes()[1], 0, 0], + }; + let dro = |bytes: &[u8]| crate::embedded_preview_dynamic_range_optimized(bytes); + for order in [Little, Big] { + // every documented value: Off; Standard, Advanced Auto, Auto (the factory default); Advanced Lv1–5; Lv1–8 + let documented: Vec = [0, 1, 2, 3].into_iter().chain(8..=12).chain(16..=23).collect(); + for &value in &documented { + let file = sony_file(order, "SONY", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 1, long(order, value))], None); + assert_eq!(dro(&file), Some(value != 0), "{order:?} {value}"); + } + // undocumented values say nothing + for value in (0..=64).filter(|v| !documented.contains(v)).chain([255, 65535, u32::MAX]) { + let file = sony_file(order, "SONY", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 1, long(order, value))], None); + assert_eq!(dro(&file), None, "{order:?} {value}"); + } + // stored as a SHORT: same reading + assert_eq!(dro(&sony_file(order, "SONY", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 3, 1, short(order, 18))], None)), Some(true)); + // the coarse 0xb04f alone is not used, nor is an ASCII or empty value + assert_eq!(dro(&sony_file(order, "SONY", &[(0xb04f, 3, 1, short(order, 1))], None)), None); + assert_eq!(dro(&sony_file(order, "SONY", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 2, 2, *b"3\0\0\0")], None)), None); + assert_eq!(dro(&sony_file(order, "SONY", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 0, [0; 4])], None)), None); + // other makers' notes are not read as Sony's + assert_eq!(dro(&sony_file(order, "NIKON CORPORATION", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 1, long(order, 3))], None)), None); + } + } + + #[test] + fn hostile_sony_notes_are_read_safely() { + use craft_tiff::ByteOrder::Little; + let entry = [(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 1, 3u32.to_le_bytes())]; + let good = sony_file(Little, "SONY", &entry, None); + assert_eq!(crate::embedded_preview_dynamic_range_optimized(&good), Some(true)); + // an entry count far beyond the note: at most the entries actually there are read + assert_ne!(crate::embedded_preview_dynamic_range_optimized(&sony_file(Little, "SONY", &entry, Some(u16::MAX))), Some(false)); + // an entry pointing outside the file + let outside = [(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 2, 0xffff_fff0u32.to_le_bytes())]; + assert_eq!(crate::embedded_preview_dynamic_range_optimized(&sony_file(Little, "SONY", &outside, None)), None); + // every truncation of a good file, and no file at all + for len in 0..good.len() { + let _ = crate::embedded_preview_dynamic_range_optimized(&good[..len]); + } + assert_eq!(crate::embedded_preview_dynamic_range_optimized(b"not a TIFF"), None); + assert_eq!(crate::embedded_preview_dynamic_range_optimized(&[]), None); + // a Sony file without a maker note + let mut ifd0 = craft_tiff::IfdBuilder::new(); + ifd0.set(t::MAKE, craft_tiff::Value::Ascii("SONY".into())); + assert_eq!(crate::embedded_preview_dynamic_range_optimized(&craft_tiff::TiffWriter::new(Little, false).write(&[ifd0]).unwrap()), None); + } + + #[test] + fn rggb_gains_need_four_plausible_levels() { + assert_eq!(rggb_gains(&[2048.0, 1024.0, 1024.0, 1536.0]), Some([2.0, 1.0, 1.5])); + assert_eq!(rggb_gains(&[2048.0, 1000.0, 1048.0, 1536.0]), Some([2.0, 1.0, 1.5])); + for bad in [ + &[2048.0, 1024.0, 1024.0][..], + &[2048.0, 1024.0, 1024.0, 1536.0, 1.0], + &[2048.0, 1024.0, -1024.0, 1536.0], + &[2048.0, 0.0, 0.0, 1536.0], + &[2048.0, f64::NAN, 1024.0, 1536.0], + &[-2048.0, 1024.0, 1024.0, 1536.0], + &[9000.0, 1024.0, 1024.0, 1536.0], + &[2048.0, 1024.0, 1024.0, f64::INFINITY], + ] { + assert_eq!(rggb_gains(bad), None, "{bad:?}"); + } + } + + /// A 32 × 4 ARW2 file whose white balance can come from the raw IFD's `0x7313`, an encrypted `SR2SubIFD` + /// (reached through `DNGPrivateData` and the `SR2Private` IFD, as Sony stores it, with `key`; black level 800) + /// and a maker note whose `Tag2010` block (RX100 III layout) holds `WB_RGBLevels`. + fn arw_with_wb(plain: Option<[u16; 4]>, sr2: Option<[u16; 4]>, tag2010: Option<[u16; 3]>, key: [u8; 4]) -> Vec { + use craft_tiff::{ByteOrder::Little, IfdBuilder, ImageData, TiffWriter, Value}; + let mut tag2010_block = vec![0u8; 700]; + for (i, v) in tag2010.unwrap_or([0; 3]).iter().enumerate() { + tag2010_block[612 + 2 * i..614 + 2 * i].copy_from_slice(&v.to_le_bytes()); + } + let tag2010_block = encipher(&tag2010_block); + // the maker note: "SONY DSC " header, then an IFD whose offsets are file offsets + let note = |at: usize| -> Vec { + let mut n = b"SONY DSC \0\0\0".to_vec(); + n.extend([1, 0]); + n.extend(MN_TAG2010.to_le_bytes()); + n.extend(7u16.to_le_bytes()); + n.extend((tag2010_block.len() as u32).to_le_bytes()); + n.extend(((at + n.len() + 8) as u32).to_le_bytes()); + n.extend([0; 4]); + n.extend(&tag2010_block); + n + }; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("DSC-RX100M3".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + if let Some(levels) = plain { + raw.set(WB_RGGB, Value::Short(levels.to_vec())); + } + if tag2010.is_some() { + raw.set_child(t::EXIF_IFD, IfdBuilder::new().with(t::MAKER_NOTE, Value::Undefined(note(0)))); + } + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0u8; 32 * 4]] }); + let mut file = TiffWriter::default().write(&[raw]).unwrap(); + let parsed = Tiff::parse(&file).unwrap(); + if let Some(e) = parsed.exif().and_then(|e| e.get(t::MAKER_NOTE)) { + let at = e.offset as usize; + file[at..at + e.count()].copy_from_slice(¬e(at)); + } + if let Some(levels) = sr2 { + // SR2Private IFD (three entries), then the encrypted SR2SubIFD + let private = file.len(); + let start = private + 2 + 3 * 12 + 4; + let block = crate::sr2::tests::block(start, &[(BLACK_LEVEL, 3, &[800; 4]), (WB_RGGB, 8, &levels)], Little); + file.extend([3, 0]); + for (tag, kind, value) in + [(0x7200u16, 4u16, (start as u32).to_le_bytes()), (0x7201, 4, (block.len() as u32).to_le_bytes()), (0x7221, 7, key)] + { + file.extend(tag.to_le_bytes()); + file.extend(kind.to_le_bytes()); + file.extend(if kind == 7 { 4u32 } else { 1 }.to_le_bytes()); + file.extend(value); + } + file.extend([0; 4]); + file.extend(block); + let at = parsed.ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + file[at..at + 4].copy_from_slice(&(private as u32).to_le_bytes()); + } + file + } + + /// storytold/lightcraft#1275: a Hasselblad Lunar (Make HASSELBLAD, Model Lunar) is a NEX-7. Its maker note (header-less, as the + /// Lunar's is) names the body by `SonyModelID`, which picks the NEX-7's `Tag2010` layout for the fallback white + /// balance; without the ID, or with one that isn't listed, there is no layout to read. + #[test] + fn rebadged_body_takes_its_tag2010_layout_from_the_sony_model_id() { + use craft_tiff::{ByteOrder::Little, IfdBuilder, ImageData, TiffWriter, Value}; + let file = |make: &str, model: &str, id: Option| { + let mut block = vec![0u8; 4600]; + for (i, v) in [639u16, 256, 368].iter().enumerate() { + block[4480 + 2 * i..4482 + 2 * i].copy_from_slice(&v.to_le_bytes()); + } + let block = encipher(&block); + let note = |at: usize| -> Vec { + let entries = 1 + u16::from(id.is_some()); + let mut n = entries.to_le_bytes().to_vec(); + n.extend(MN_TAG2010.to_le_bytes()); + n.extend(7u16.to_le_bytes()); + n.extend((block.len() as u32).to_le_bytes()); + n.extend(((at + 2 + 12 * entries as usize + 4) as u32).to_le_bytes()); + if let Some(id) = id { + n.extend(MN_SONY_MODEL_ID.to_le_bytes()); + n.extend(3u16.to_le_bytes()); + n.extend(1u32.to_le_bytes()); + n.extend(id.to_le_bytes()); + n.extend([0, 0]); + } + n.extend([0; 4]); + n.extend(&block); + n + }; + let mut raw = IfdBuilder::new(); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0u8; 32 * 4]] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.set_child(t::EXIF_IFD, IfdBuilder::new().with(t::MAKER_NOTE, Value::Undefined(note(0)))); + ifd0.add_sub_ifd(raw); + let mut file = TiffWriter::new(Little, false).write(&[ifd0]).unwrap(); + let parsed = Tiff::parse(&file).unwrap(); + let e = parsed.exif().unwrap().get(t::MAKER_NOTE).unwrap(); + let at = e.offset as usize; + file[at..at + e.count()].copy_from_slice(¬e(at)); + file + }; + let wb = |make: &str, model: &str, id: Option| { + let bytes = file(make, model, id); + assert_eq!(crate::probe(&bytes), Some(RawFormat::Arw), "{make} {model}"); + let full = crate::decode(&bytes).unwrap(); + assert_eq!(crate::probe_info(&bytes).unwrap().wb_multipliers, full.wb_multipliers); + full.wb_multipliers + }; + let nex7 = Some([639.0 / 256.0, 1.0, 368.0 / 256.0]); + assert_eq!(wb("HASSELBLAD", "Lunar", Some(289)), nex7); + assert_eq!(wb("SONY", "NEX-7", None), nex7, "the Sony body itself"); + assert_eq!(wb("HASSELBLAD", "Lunar", None), None, "no SonyModelID"); + assert_eq!(wb("HASSELBLAD", "Lunar", Some(288)), None, "an ID that is not listed (NEX-5N)"); + // a Sony file keeps its Exif model whatever the ID says + assert_eq!(wb("SONY", "ILCE-1", Some(289)), None); + assert_eq!(sony_body("HASSELBLAD", "HV", None), "HV"); + } + + #[test] + fn dsc_r1_sr2_subifd_gives_black_wb_and_crop() { + use craft_tiff::ByteOrder::Little; + let key = [0x63, 0xbe, 0x01, 0x3e]; + let enc = |crop: [u16; 4], wb: [u16; 4]| { + let b = crate::sr2::tests::block_keyed( + 230812, + &[(SR2_OLD_BLACK, 3, &[514, 511, 512, 513]), (SR2_OLD_WB, 3, &wb), (SR2_OLD_CROP, 3, &crop)], + Little, + key, + ); + crate::sr2::SubIfd::decrypt_keyed(&b, 230812, Little, key) + }; + let sub = enc([16, 8, 3904, 2600], [256, 500, 385, 256]); + assert_eq!(sr2_black(&sub), Some(512.5)); + assert_eq!(sr2_old_wb(&sub), Some([500.0 / 256.0, 1.0, 385.0 / 256.0])); + assert_eq!(sr2_old_crop(&sub, 3984, 2608), Some(Rect::new(16, 8, 3888, 2592))); + // implausible values are dropped, not guessed + assert_eq!(sr2_old_crop(&enc([15, 8, 3904, 2600], [0, 1, 1, 0]), 3984, 2608), None); + assert_eq!(sr2_old_crop(&sub, 3900, 2608), None); + assert_eq!(sr2_old_wb(&enc([16, 8, 3904, 2600], [0, 1, 1, 0])), None); + } + + #[test] + fn white_balance_prefers_the_applied_levels() { + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + let (plain, sr2, tag2010) = ([2048, 1024, 1024, 1536], [2932, 1024, 1024, 1576], [484, 256, 788]); + let wb = |file: Vec| { + let header = decode(&file, Mode::Header, &Limits::default()).unwrap(); + let full = decode(&file, Mode::Full, &Limits::default()).unwrap(); + assert_eq!(header.wb_multipliers, full.wb_multipliers); + assert_eq!(header.black, full.black); + full.wb_multipliers + }; + // the same decrypted directory gives the black level, with the known key only + let black = |file: Vec| decode(&file, Mode::Full, &Limits::default()).unwrap().black.mean(); + assert_eq!(black(arw_with_wb(None, Some(sr2), None, KEY)), 800.0); + assert_eq!(black(arw_with_wb(None, Some(sr2), None, [1, 2, 3, 4])), 512.0); + assert_eq!(black(arw_with_wb(None, None, None, KEY)), 512.0); + let sr2_gains = Some([2932.0 / 1024.0, 1.0, 1576.0 / 1024.0]); + // the encrypted SR2SubIFD's WB_RGGBLevels over Tag2010 (storytold/lightcraft#535: ILCE-3500 at 5600 K) and when alone + assert_eq!(wb(arw_with_wb(None, Some(sr2), Some(tag2010), KEY)), sr2_gains); + assert_eq!(wb(arw_with_wb(None, Some(sr2), None, KEY)), sr2_gains); + // the plain levels over both + assert_eq!(wb(arw_with_wb(Some(plain), Some(sr2), Some(tag2010), KEY)), Some([2.0, 1.0, 1.5])); + // Tag2010 when the SR2SubIFD has another key, implausible levels or none at all + let fallback = Some([484.0 / 256.0, 1.0, 788.0 / 256.0]); + assert_eq!(wb(arw_with_wb(None, Some(sr2), Some(tag2010), [1, 2, 3, 4])), fallback); + assert_eq!(wb(arw_with_wb(None, Some([9000, 1024, 1024, 1576]), Some(tag2010), KEY)), fallback); + assert_eq!(wb(arw_with_wb(None, None, Some(tag2010), KEY)), fallback); + assert_eq!(wb(arw_with_wb(None, None, None, KEY)), None); + } + + #[test] + fn broken_sr2_private_data_is_ignored() { + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + let file = arw_with_wb(None, Some([2932, 1024, 1024, 1576]), None, KEY); + let at = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + let private = u32::from_le_bytes(file[at..at + 4].try_into().unwrap()) as usize; + let patched = |pos: usize, bytes: &[u8]| { + let mut f = file.clone(); + f[pos..pos + bytes.len()].copy_from_slice(bytes); + decode(&f, Mode::Full, &Limits::default()).map(|img| img.wb_multipliers) + }; + // DNGPrivateData pointing nowhere or into the image; SR2SubIFD offset or length past the end, or enormous + for (pos, bytes) in [ + (at, u32::MAX.to_le_bytes()), + (at, 8u32.to_le_bytes()), + (private + 2 + 8, u32::MAX.to_le_bytes()), + (private + 2 + 12 + 8, u32::MAX.to_le_bytes()), + (private + 2 + 12 + 8, (file.len() as u32).to_le_bytes()), + (private + 2 + 12 + 8, (1u32 << 21).to_le_bytes()), + ] { + assert_eq!(patched(pos, &bytes).unwrap(), None, "{pos} {bytes:?}"); + } + // the file cut anywhere inside the SR2 data still decodes (the image comes first), without white balance + for len in [private, private + 20, file.len() - 30, file.len() - 1] { + let img = decode(&file[..len], Mode::Full, &Limits::default()).unwrap(); + assert_eq!(img.wb_multipliers, None, "{len}"); + } + } + + #[test] + fn sr2_black_level_decodes_and_rejects_garbage() { + use craft_tiff::ByteOrder::Little; + let plain = |levels: [u16; 4]| -> Vec { levels.iter().flat_map(|v| v.to_le_bytes()).collect() }; + assert_eq!(sr2_black_levels(&plain([800; 4]), Little), Some(800.0)); + assert_eq!(sr2_black_levels(&plain([512, 512, 514, 512]), Little), Some(512.5)); + assert_eq!(sr2_black_levels(&plain([65320, 64928, 65260, 488]), Little), None); + assert_eq!(sr2_black_levels(&plain([0; 4]), Little), None); + assert_eq!(sr2_black_levels(&plain([800; 4])[..5], Little), None); + assert_eq!(sr2_black_levels(&[], Little), None); + } + + /// An encrypted `SR2SubIFD` of `len` bytes at file offset `start` whose first entry is a black level stored at + /// `value_pos` (relative), holding `levels`; `other` puts four more levels at position 2510 (where the old + /// fixed read looked). + fn sr2_block(start: usize, len: usize, tag: u16, value_pos: usize, levels: [u16; 4], other: Option<[u16; 4]>) -> Vec { + let mut plain = vec![0u8; len]; + plain[0..2].copy_from_slice(&180u16.to_le_bytes()); + plain[2..4].copy_from_slice(&tag.to_le_bytes()); + plain[4..6].copy_from_slice(&3u16.to_le_bytes()); + plain[6..10].copy_from_slice(&4u32.to_le_bytes()); + plain[10..14].copy_from_slice(&((start + value_pos) as u32).to_le_bytes()); + for (i, v) in levels.iter().enumerate() { + plain[value_pos + 2 * i..value_pos + 2 * i + 2].copy_from_slice(&v.to_le_bytes()); + } + if let Some(o) = other { + for (i, v) in o.iter().enumerate() { + plain[2510 + 2 * i..2510 + 2 * i + 2].copy_from_slice(&v.to_le_bytes()); + } + } + crate::sr2::tests::encrypt(&mut plain); + plain + } + + /// [`sr2_black`] on the encrypted `SR2SubIFD` bytes `block`, which start at file offset `start`. + fn sr2_black_in(block: &[u8], start: usize, order: craft_tiff::ByteOrder) -> Option { + sr2_black(&crate::sr2::SubIfd::decrypt(block, start, order)) + } + + #[test] + fn sr2_black_level_follows_the_first_entry_to_its_layout() { + use craft_tiff::ByteOrder::Little; + let start = 37584; + // the value position of every layout seen, e.g. the DSLR-A700's (62112 bytes, value at 1638) and the + // DSLR-A450/A500/A550's (27152 bytes, value at 2166); the first four are those of bodies without a plain 0x7310 + for pos in [1638, 2166, 2418, 2510, 2030, 2102, 2186, 2558, 2786] { + for level in [512, 800] { + let block = sr2_block(start, 27152, BLACK_LEVEL, pos, [level; 4], None); + assert_eq!(sr2_black_in(&block, start, Little), Some(f32::from(level)), "value at {pos}"); + } + } + // the A700's layout holds four other equal values at 2510 (975 each); the old fixed read took those + let a700 = sr2_block(start, 62112, BLACK_LEVEL, 1638, [512; 4], Some([975; 4])); + assert_eq!(sr2_black_in(&a700, start, Little), Some(512.0)); + // not a black-level entry, another type, a value outside the block, before it + assert_eq!(sr2_black_in(&sr2_block(start, 27152, 0x7311, 2166, [512; 4], None), start, Little), None); + let mut long = sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None); + long[4] ^= 3 ^ 4; // SHORT -> LONG (type field of the first entry) + assert_eq!(sr2_black_in(&long, start, Little), None); + assert_eq!(sr2_black_in(&sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None)[..2170], start, Little), None); + let block = sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None); + assert_eq!(sr2_black_in(&block, start + 4000, Little), None, "value offset before the block"); + assert_eq!(sr2_black_in(&block[..10], start, Little), None, "truncated directory"); + assert_eq!(sr2_black_in(&[], start, Little), None); + // implausible levels at a known position + assert_eq!(sr2_black_in(&sr2_block(start, 27152, BLACK_LEVEL, 2166, [4; 4], None), start, Little), None); + } + + /// Verify Sony word storage uses the same levels in dark and bright frames and survives DNG export. + #[test] + fn uncompressed_12_bit_word_levels_do_not_depend_on_scene_brightness() { + use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + for peak in [1024u16, 3144, 12000] { + let samples: Vec = (0..64).map(|i| if i == 0 { peak } else { 512 }).collect(); + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCA-99M2".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![8])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![samples.iter().flat_map(|v| v.to_le_bytes()).collect()] }); + let bytes = TiffWriter::default().write(&[raw]).unwrap(); + for mode in [Mode::Header, Mode::Full] { + let decoded = decode(&bytes, mode, &Limits::default()).unwrap(); + assert_eq!(decoded.bits, 12, "keep the recorded bit depth"); + assert_eq!(decoded.black.mean(), 512.0, "peak {peak}, {mode:?}"); + assert_eq!(decoded.white, vec![16383.0], "peak {peak}, {mode:?}"); + #[cfg(feature = "dng")] + if matches!(mode, Mode::Full) { + let dng = crate::write_dng(&decoded, &crate::DngWriteOptions::default()).unwrap(); + let tiff = Tiff::parse(&dng).unwrap(); + let bits = tiff.ifds[0].u32(t::BITS_PER_SAMPLE).unwrap(); + let white = tiff.ifds[0].u32(t::WHITE_LEVEL).unwrap(); + assert!((2..=16).contains(&bits)); + assert!(white < (1u32 << bits), "peak {peak}: {bits} bits cannot represent white {white}"); + let back = crate::decode(&dng).unwrap(); + assert_eq!(back.white, decoded.white); + assert_eq!(back.black.mean(), decoded.black.mean()); + let RawData::U16(back_samples) = back.data else { panic!("integer DNG samples") }; + assert_eq!(back_samples, samples); + } + } + } + } + + #[test] + fn word16_byte_order_follows_the_sample_range() { + use craft_tiff::ByteOrder::{Big, Little}; + let be: Vec = (0..4096u16).flat_map(|i| (512 + i * 3).to_be_bytes()).collect(); // 512..12800 big-endian words + assert_eq!(word16_order(&be, 14, Little), Big); + let le: Vec = (0..4096u16).flat_map(|i| (512 + i * 3).to_le_bytes()).collect(); + assert_eq!(word16_order(&le, 14, Little), Little); + // values whose bytes are all below 0x40 fit both ways: keep the file order + let amb: Vec = (0..4096usize).flat_map(|i| [(i % 60) as u8, (i % 50) as u8]).collect(); + assert_eq!(word16_order(&amb, 14, Little), Little); + assert_eq!(word16_order(&[], 14, Big), Big); + // any `bits` value (even a corrupt one) clamps to 14..=16 instead of overflowing the shift + for bits in [0, 1, 12, 14, 16, 17, 31, 32, 33, u32::MAX] { + let _ = word16_order(&be, bits, Little); + } + } + + #[test] + fn quad_tiles_place_cfa_cells() { + // 14×12 mosaic in 8×8 tiles (the right and bottom tiles overhang), each tile a 4×4 frame of 2×2 cells, + // stored column by column in the file as Sony does while TileOffsets stay in raster order + let (w, h, tw) = (14usize, 12usize, 8usize); + let mosaic: Vec = (0..w * h).map(|i| 100 + (i * 37 % 4000) as u16).collect(); + let at = |x: usize, y: usize| if x < w && y < h { mosaic[y * w + x] } else { 0 }; + let mut file = vec![0u8; 16]; + let mut offsets = vec![0u64; 4]; + let mut counts = vec![0u64; 4]; + for tx in 0..2 { + for ty in 0..2 { + let mut frame = Vec::new(); + for fy in 0..tw / 2 { + for fx in 0..tw / 2 { + let (x, y) = (tx * tw + 2 * fx, ty * tw + 2 * fy); + frame.extend([at(x, y), at(x + 1, y), at(x, y + 1), at(x + 1, y + 1)]); + } + } + let enc = ljpeg::encode(&frame, tw / 2, tw / 2, 4, 14, 1, 0); + offsets[ty * 2 + tx] = file.len() as u64; + counts[ty * 2 + tx] = enc.len() as u64; + file.extend(enc); + } + } + let info = ImageInfo { + width: w as u32, + height: h as u32, + bits_per_sample: vec![14], + samples_per_pixel: 1, + compression: 7, + photometric: photometric::CFA, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Tiles { tile_width: tw as u32, tile_height: tw as u32 }, + offsets, + byte_counts: counts, + }; + assert!(is_quad_tiled(&file, &info)); + assert_eq!(read_quad_tiles(&file, &info, &Limits::default()).unwrap(), mosaic); + } + + /// storytold/lightcraft#2561: a small file whose one-byte or packed 12-bit strip declares a large frame is refused before the full + /// decode allocates the frame. + #[test] + fn a_strip_cut_short_is_refused_before_allocating() { + use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let (w, h) = (3000u32, 2000u32); + for declared in [w * h, w * h / 2 * 3] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("DSLR-A100".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![0u8; 12]] }); + let mut f = TiffWriter::default().write(&[raw]).unwrap(); + crate::shared::declare_strip_len(&mut f, 12, declared); + assert!(matches!(decode(&f, Mode::Full, &Limits::default()), Err(RawError::Corrupt(_))), "{declared} bytes declared"); + } + } +} diff --git a/crates/raw/src/arw6.rs b/crates/raw/src/arw6.rs new file mode 100644 index 000000000..bab809302 --- /dev/null +++ b/crates/raw/src/arw6.rs @@ -0,0 +1,995 @@ +//! Sony ARW6 ("Compressed RAW 2" / HQ, TIFF Compression 32766): container parsing, geometry, colour and decode. +//! +//! Clean-room. The layout comes from SMPTE RDD 34 (Picture_info in word 0) and from Phase 0 black-box +//! inspection of real ILCE-7RM6 files (tile table, group size words, stream headers and index entries), each rule +//! checked by re-serialising parsed tiles byte-for-byte. The format is described in `docs/arw6-compression.md`; section +//! names in italics below refer to it. No other raw decoder's source was consulted. + +use crate::arw6_curve::curve; +use crate::huffman::Bits; +use crate::llvc::{Bands3, Plane, band_rows, dequant, reconstruct3, vld_decode_line}; +use crate::{RawError, Result}; +use rayon::prelude::*; +use std::ops::Range; + +fn corrupt(why: &str) -> RawError { + RawError::Corrupt(format!("ARW6: {why}")) +} + +fn unsupported(why: &str) -> RawError { + RawError::Unsupported(format!("ARW6 {why}")) +} + +fn u16_be(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?; + Some(usize::from(u16::from_be_bytes([*s.first()?, *s.get(1)?]))) +} + +fn u24_be(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(3)?)?; + Some(s.iter().fold(0usize, |a, &v| (a << 8) | usize::from(v))) +} + +fn u32_le(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(s.try_into().ok()?) as usize) +} + +/// One tile of the strip: its bytes run from `offset` to the next tile's offset (the last: the strip end). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TileEntry { + pub offset: usize, + pub x: usize, + pub y: usize, + pub width: usize, + pub height: usize, +} + +/// Most tiles a strip may hold (real files have 1 or 4). +const MAX_TILES: usize = 16; + +/// Parse the tile table at the start of the strip (file structure, *Container*): `u32` count, `u32` 0, then +/// 24-byte entries `u64` offset (relative to the strip), `u32` x, y, width, height, all little-endian. +pub(crate) fn parse_tile_table(strip: &[u8], width: usize, height: usize) -> Result> { + let count = u32_le(strip, 0).ok_or_else(|| corrupt("truncated tile table"))?; + if count == 0 || count > MAX_TILES { + return Err(unsupported("tile count outside 1..=16")); + } + let mut tiles: Vec = Vec::with_capacity(count); + let mut area = 0usize; + for i in 0..count { + let at = 8 + 24 * i; + let e = strip.get(at..at + 24).ok_or_else(|| corrupt("truncated tile table"))?; + let offset = e.get(..8).and_then(|b| <[u8; 8]>::try_from(b).ok()).map(u64::from_le_bytes).and_then(|o| usize::try_from(o).ok()); + let offset = offset.filter(|&o| o < strip.len()).ok_or_else(|| corrupt("tile offset outside the strip"))?; + if tiles.last().is_some_and(|p| offset <= p.offset) { + return Err(corrupt("tile offsets do not increase")); + } + let (x, y, w, h) = (u32_le(e, 8), u32_le(e, 12), u32_le(e, 16), u32_le(e, 20)); + let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(corrupt("truncated tile table")) }; + // Even geometry; the level-3 band width w / 16 must be whole (w/2 a multiple of 8). + if w == 0 || h == 0 || x % 2 != 0 || y % 2 != 0 || w % 2 != 0 || h % 2 != 0 || (w / 2) % 8 != 0 { + return Err(unsupported("tile geometry")); + } + let fits = x.checked_add(w).is_some_and(|r| r <= width) && y.checked_add(h).is_some_and(|b| b <= height); + if !fits { + return Err(corrupt("tile outside the image")); + } + let a = w.checked_mul(h).filter(|&a| a <= crate::MAX_SAMPLES).ok_or(RawError::Limit("ARW6 tile too large"))?; + area = area.checked_add(a).ok_or(RawError::Limit("ARW6 tiles too large"))?; + tiles.push(TileEntry { offset, x, y, width: w, height: h }); + } + for (i, a) in tiles.iter().enumerate() { + for b in tiles.iter().skip(i + 1) { + if a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height { + return Err(corrupt("tiles overlap")); + } + } + } + // In-image, disjoint tiles cover the image exactly when their areas sum to it. + if width.checked_mul(height) != Some(area) { + return Err(corrupt("tiles do not cover the image")); + } + Ok(tiles) +} + +/// Tile `i`'s bytes: from its offset to the next tile's offset (the last: the strip end). +pub(crate) fn tile_bytes<'a>(strip: &'a [u8], tiles: &[TileEntry], i: usize) -> Option<&'a [u8]> { + let start = tiles.get(i)?.offset; + let end = match tiles.get(i.checked_add(1)?) { + Some(next) => next.offset, + None => strip.len(), + }; + strip.get(start..end) +} + +/// One of a tile's 13 streams (positions in 16-byte words from the tile start). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StreamRef { + pub group: u8, + pub comp: u8, + pub word: usize, + pub size_words: usize, + pub index_words: usize, + pub data_words: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TileHeader { + pub hs: usize, + pub vs: usize, + /// File order: g0 c0..2, g1 c0..2, g2 c0..2, g3 c0..2, g4 c0. + pub streams: Vec, +} + +/// `n` bits at bit position `pos` (MSB-first) of a tile's word 0. +fn field(w: &[u8], pos: usize, n: usize) -> Option { + let mut v = 0u32; + for i in pos..pos.checked_add(n)? { + let byte = *w.get(i / 8)?; + v = (v << 1) | u32::from((byte >> (7 - i % 8)) & 1); + } + Some(v) +} + +/// Streams per group: g0..g3 one per colour component, g4 the green residual. +const GROUP_STREAMS: [usize; 5] = [3, 3, 3, 3, 1]; + +/// Parse word 0 (RDD 34 `Picture_info`; every real file has HS = tile width, VS = tile height / 2, BBD 16, NC 3, NW 3, +/// NP 1000000000b), the group size words 3..=7 and each stream's header word (*Container*, Phase 0). +pub(crate) fn parse_tile_header(tile: &[u8], tw: usize, th: usize) -> Result { + let w0 = tile.get(..16).ok_or_else(|| corrupt("tile shorter than its picture header"))?; + let f = |pos, n| field(w0, pos, n).map(|v| v as usize); + let (hs, vs, bbd, nc, nw, np) = (f(64, 16), f(80, 16), f(102, 6), f(112, 3), f(115, 3), f(118, 10)); + let (Some(hs), Some(vs), Some(bbd), Some(nc), Some(nw), Some(np)) = (hs, vs, bbd, nc, nw, np) else { + return Err(corrupt("picture header")); + }; + if hs != tw || vs * 2 != th || bbd != 16 || nc != 3 || nw != 3 || np != 0b10_0000_0000 { + return Err(unsupported(&format!("picture header variant (HS {hs}, VS {vs}, BBD {bbd}, NC {nc}, NW {nw}, NP {np:#b})"))); + } + let mut streams = Vec::with_capacity(13); + let mut word = 8usize; + for (group, &n) in GROUP_STREAMS.iter().enumerate() { + let table = tile.get((3 + group) * 16..(4 + group) * 16).ok_or_else(|| corrupt("truncated group table"))?; + if usize::from(table.first().copied().unwrap_or(0)) != n { + return Err(unsupported("group stream count")); + } + for comp in 0..n { + let size_words = u24_be(table, 1 + 3 * comp).ok_or_else(|| corrupt("group table"))?; + let end = word.checked_add(size_words).filter(|e| e.checked_mul(16).is_some_and(|b| b <= tile.len())); + let end = end.ok_or_else(|| corrupt("stream outside the tile"))?; + let hdr = tile.get(word * 16..word * 16 + 16).ok_or_else(|| corrupt("stream outside the tile"))?; + let (index_words, data_words) = + (u16_be(hdr, 0).ok_or_else(|| corrupt("stream header"))?, u24_be(hdr, 2).ok_or_else(|| corrupt("stream header"))?); + if index_words.checked_add(data_words).and_then(|s| s.checked_add(1)).is_none_or(|s| s > size_words) { + return Err(corrupt("stream header sizes exceed the stream")); + } + streams.push(StreamRef { group: group as u8, comp: comp as u8, word, size_words, index_words, data_words }); + word = end; + } + } + Ok(TileHeader { hs, vs, streams }) +} + +/// One half-TU of a stream: `len` bytes at byte `start` of the tile, quantiser index per band +/// (g0/g4: the single nibble repeated). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Half { + pub start: usize, + pub len: usize, + pub qi: [u32; 3], +} + +fn nibbles(b: &[u8], from: usize, n: usize) -> Option { + (from..from + n).try_fold(0u32, |a, i| Some((a << 4) | u32::from((*b.get(i / 2)? >> (4 * (1 - i % 2))) & 15))) +} + +/// The `2 * ntu` halves of a stream. Index entry per TU (file structure): `A:16 X B:16 X` with A / B the byte +/// lengths of half 0 / 1 and X the QI nibble (g0, g4: 1 nibble) or three nibbles HL, LH, HH (g1..g3); the data area +/// holds each TU's A bytes then B bytes, consecutive TUs contiguous. +pub(crate) fn stream_halves(tile: &[u8], s: &StreamRef, ntu: usize) -> Result> { + let nx = if s.group == 0 || s.group == 4 { 1 } else { 3 }; + let esz = 4 + nx; // bytes: (16 + 4nx + 16 + 4nx) / 8 + let idx_start = s.word.checked_add(1).and_then(|w| w.checked_mul(16)).ok_or_else(|| corrupt("stream position"))?; + let idx_len = s.index_words.checked_mul(16).ok_or_else(|| corrupt("stream position"))?; + if ntu.checked_mul(esz).is_none_or(|n| n > idx_len) { + return Err(corrupt("index table too small for the tile's TUs")); + } + let data_start = idx_start.checked_add(idx_len).ok_or_else(|| corrupt("stream position"))?; + let index = tile.get(idx_start..data_start).ok_or_else(|| corrupt("index table outside the tile"))?; + let data_end = s.data_words.checked_mul(16).and_then(|d| d.checked_add(data_start)).ok_or_else(|| corrupt("stream position"))?; + if data_end > tile.len() { + return Err(corrupt("stream data outside the tile")); + } + let mut halves = Vec::with_capacity(2 * ntu); + let mut pos = data_start; + for k in 0..ntu { + let e = index.get(k * esz..(k + 1) * esz).ok_or_else(|| corrupt("index table"))?; + let bad = || corrupt("index entry"); + let (a, x1) = (nibbles(e, 0, 4).ok_or_else(bad)? as usize, nibbles(e, 4, nx).ok_or_else(bad)?); + let (b, x2) = (nibbles(e, 4 + nx, 4).ok_or_else(bad)? as usize, nibbles(e, 8 + nx, nx).ok_or_else(bad)?); + for (len, x) in [(a, x1), (b, x2)] { + let end = pos.checked_add(len).filter(|&e| e <= data_end).ok_or_else(|| corrupt("half outside its stream's data"))?; + let qi = if nx == 1 { [x; 3] } else { [(x >> 8) & 15, (x >> 4) & 15, x & 15] }; + halves.push(Half { start: pos, len, qi }); + pos = end; + } + } + Ok(halves) +} + +// ---- sensor-anchored frame geometry and tile reconstruction (*Frame geometry*) ---- + +/// Halves of `vs` plane rows are anchored to the sensor: half `n` covers plane rows `8n - 6 + s .. 8n + 1 + s`, so +/// `ceil((vs - 2 - s) / 8) + 1` halves are needed, two per TU (*Container*). +pub(crate) fn tu_count(vs: usize, s: u8) -> usize { + let halves = vs.saturating_sub(2usize.saturating_add(usize::from(s))).div_ceil(8).saturating_add(1); + halves.div_ceil(2) +} + +/// Lifting phases of levels 1..=3 for anchor offset `s` (the Phase 0 reference model, local and not in the repo; see `docs/arw6-compression.md`): +/// FF (s = 0) gives [0, 1, 1], the APS-C crop (s = 3) [1, 0, 0]. +pub(crate) fn phases(s: u8) -> [u8; 3] { + let s = i32::from(s); + let p1 = s % 2; + let p2 = ((2 + s - p1) / 2) % 2; + let k3 = ((6 + s) % 8 - p1).div_euclid(2); + let p3 = (k3 - p2).div_euclid(2).rem_euclid(2); + [p1 as u8, p2 as u8, p3 as u8] +} + +/// Plane rows of half `n`: `8n - 6 + s .. 8n + 2 + s`, clipped to `0..rows` (empty when disjoint). +pub(crate) fn half_rows(n: usize, s: u8, rows: usize) -> Range { + let base = n.saturating_mul(8).saturating_add(usize::from(s)); + base.saturating_sub(6).min(rows)..base.saturating_add(2).min(rows) +} + +/// First half's frame rows `lines - 2`, read before the TU count is known from g4's first index entry alone +/// (index table at word `g4.word + 1`, entry 0's `A` = big-endian u16; its bytes start after the index table). +/// Residual lines are decoded while at least 8 bits remain and fewer than 8 lines were read (*Frame geometry*). +pub(crate) fn first_half_shift(tile: &[u8], g4: &StreamRef, width2: usize) -> Result { + let idx = g4.word.checked_add(1).and_then(|w| w.checked_mul(16)).ok_or_else(|| corrupt("stream position"))?; + let data = g4.index_words.checked_mul(16).and_then(|l| l.checked_add(idx)).ok_or_else(|| corrupt("stream position"))?; + let a = u16_be(tile, idx).ok_or_else(|| corrupt("index table outside the tile"))?; + let bytes = tile.get(data..data.checked_add(a).ok_or_else(|| corrupt("stream position"))?).ok_or_else(|| corrupt("half outside the tile"))?; + let total = bytes.len() * 8; + let mut bits = Bits::new(bytes); + let mut lines = 0usize; + while lines < 8 && total.saturating_sub(bits.consumed_bits()) >= 8 { + vld_decode_line(&mut bits, total, width2)?; + lines += 1; + } + // The stop rule is sound because a residual line costs at least 8 bits: an all-zero line of width w costs + // 1 + ceil(log2(ceil(w/4) + 1)) bits, >= 8 for w >= 253, and real plane widths are >= 2496. + match lines.checked_sub(2) { + Some(s @ 0..=5) => Ok(s as u8), + _ => Err(unsupported("first-half row count outside 2..=7")), + } +} + +/// Band rows of one half: plane rows `t` and the rows they land on at each level (`split`). +struct HalfRows { + t: Range, + l1: Vec, + h1: Vec, + l2: Vec, + h2: Vec, + l3: Vec, + h3: Vec, +} + +/// Rows with `t % 2 == p` go to the low band at `(t - p) / 2`, the others to the high band at `(t - (1 - p)) / 2`. +fn split(rows: &[usize], p: u8) -> (Vec, Vec) { + let p = usize::from(p); + let (mut lo, mut hi) = (Vec::new(), Vec::new()); + for &t in rows { + if t % 2 == p { + lo.push(t.saturating_sub(p) / 2); + } else { + hi.push(t.saturating_sub(1 - p) / 2); + } + } + (lo, hi) +} + +fn half_layout(n: usize, s: u8, vs: usize, ph: [u8; 3]) -> HalfRows { + let t = half_rows(n, s, vs); + let rows: Vec = t.clone().collect(); + let (l1, h1) = split(&rows, ph[0]); + let (l2, h2) = split(&l1, ph[1]); + let (l3, h3) = split(&l2, ph[2]); + HalfRows { t, l1, h1, l2, h2, l3, h3 } +} + +/// Lines a half holds in group `g`: g0 LL3 rows; g1 HL3 + LH3 + HH3; g2 HL2 + LH2 + HH2; g3 HL1 + LH1 + HH1; g4 residual. +fn line_count(g: u8, r: &HalfRows) -> usize { + match g { + 0 => r.l3.len(), + 1 => r.l3.len() + 2 * r.h3.len(), + 2 => r.l2.len() + 2 * r.h2.len(), + 3 => r.l1.len() + 2 * r.h1.len(), + _ => r.t.len(), + } +} + +/// Line width of group `g`: level-`l` bands are `(tw / 2) >> l` wide (g0, g1: level 3; g2: 2; g3: 1; g4: the plane). +fn line_width(g: u8, w2: usize) -> usize { + match g { + 0 | 1 => w2 >> 3, + 2 => w2 >> 2, + 3 => w2 >> 1, + _ => w2, + } +} + +/// A half with its decoded (still quantised) lines. +type HalfLines = (Half, Vec>); + +/// Index the stream's halves and VLD-decode exactly the lines each holds; a line running past its half is `Corrupt`. +fn decode_stream(tile: &[u8], st: &StreamRef, ntu: usize, w2: usize, layouts: &[HalfRows]) -> Result> { + let halves = stream_halves(tile, st, ntu)?; + let width = line_width(st.group, w2); + // halves are independent bit streams: decode them in parallel + halves + .into_par_iter() + .zip(layouts.par_iter()) + .map(|(half, rows)| { + let end = half.start.checked_add(half.len).ok_or_else(|| corrupt("half position"))?; + let bytes = tile.get(half.start..end).ok_or_else(|| corrupt("half outside the tile"))?; + let total = bytes.len() * 8; + let mut bits = Bits::new(bytes); + let mut lines = Vec::with_capacity(line_count(st.group, rows)); + let n = line_count(st.group, rows); + for _ in 0..n { + lines.push(vld_decode_line(&mut bits, total, width)?); + if bits.consumed_bits() > total { + return Err(corrupt("line runs past its half")); + } + } + // Phase 0 measurement: in all 3562 halves of the 8 real files the lines end within the half's last + // byte (`len*8 - 8 < consumed <= len*8`); garbage that decodes to fewer bits is corruption. + if n > 0 && bits.consumed_bits() <= total.saturating_sub(8) { + return Err(corrupt("half ends early")); + } + Ok((half, lines)) + }) + .collect() +} + +/// Dequantise `line` into band row `row`. +fn put(plane: &mut Plane, row: usize, line: &[i32], mut f: impl FnMut(i32) -> i32) -> Result<()> { + let start = row.checked_mul(plane.width).ok_or_else(|| corrupt("band row"))?; + let dst = plane.data.get_mut(start..start.saturating_add(plane.width)).ok_or_else(|| corrupt("band row outside its band"))?; + for (d, &v) in dst.iter_mut().zip(line) { + *d = f(v); + } + Ok(()) +} + +/// Lay a half's lines into (band, rows, QI) in order: the natural clipped assignment of *Frame geometry*. +fn fill(set: [(&mut Plane, &[usize], u32); 3], lines: &[Vec]) -> Result<()> { + let mut it = lines.iter(); + for (plane, rows, qi) in set { + for &r in rows { + put(plane, r, it.next().ok_or_else(|| corrupt("half holds fewer lines than rows"))?, |q| dequant(q, qi))?; + } + } + Ok(()) +} + +/// One colour component: its ten bands from streams g0..g3 (component `c`), then the 3-level inverse. +fn component(c: usize, streams: &[Vec], layouts: &[HalfRows], vs: usize, w2: usize, ph: [u8; 3]) -> Result { + let (n1l, n1h) = band_rows(vs, ph[0]); + let (n2l, n2h) = band_rows(n1l, ph[1]); + let (n3l, n3h) = band_rows(n2l, ph[2]); + let (w1, w2b, w3) = (w2 >> 1, w2 >> 2, w2 >> 3); + let z = Plane::zeros; + let mut b = Bands3 { + ll3: z(w3, n3l), + hl3: z(w3, n3l), + lh3: z(w3, n3h), + hh3: z(w3, n3h), + hl2: z(w2b, n2l), + lh2: z(w2b, n2h), + hh2: z(w2b, n2h), + hl1: z(w1, n1l), + lh1: z(w1, n1h), + hh1: z(w1, n1h), + }; + let grp = |g: usize, n: usize| streams.get(3 * g + c).and_then(|s| s.get(n)).ok_or_else(|| corrupt("missing half")); + for (n, r) in layouts.iter().enumerate() { + let (h0, l0) = grp(0, n)?; + // LL3: the nibble must be 0 (Phase 0: never seen otherwise); lines are DPCM from 2048 for the green mean (c = 0) and + // from 0 for the base-free chroma planes (*Entropy coding and dequantisation*). + if h0.qi[0] != 0 { + return Err(unsupported("LL3 quantiser index")); + } + for (&row, line) in r.l3.iter().zip(l0) { + let mut acc = if c == 0 { 2048i32 } else { 0 }; + put(&mut b.ll3, row, line, |d| { + acc = acc.saturating_add(d); + acc + })?; + } + let (h1, l1) = grp(1, n)?; + fill([(&mut b.hl3, &r.l3, h1.qi[0]), (&mut b.lh3, &r.h3, h1.qi[1]), (&mut b.hh3, &r.h3, h1.qi[2])], l1)?; + let (h2, l2) = grp(2, n)?; + fill([(&mut b.hl2, &r.l2, h2.qi[0]), (&mut b.lh2, &r.h2, h2.qi[1]), (&mut b.hh2, &r.h2, h2.qi[2])], l2)?; + let (h3, l3) = grp(3, n)?; + fill([(&mut b.hl1, &r.l1, h3.qi[0]), (&mut b.lh1, &r.h1, h3.qi[1]), (&mut b.hh1, &r.h1, h3.qi[2])], l3)?; + } + reconstruct3(&b, ph) +} + +/// A decoded tile: the three wavelet planes (green mean, two chroma) and the green residual, each `tw/2 x th/2`. +pub(crate) struct TilePlanes { + pub m: Plane, + pub c1: Plane, + pub c2: Plane, + pub res: Plane, +} + +/// Decode a tile to its planes: header, `s` from g4's first half, TU count, all 13 streams (in parallel), band +/// assembly and the 3-level inverse per component (in parallel). +pub(crate) fn decode_tile_planes(tile: &[u8], tw: usize, th: usize) -> Result { + let h = parse_tile_header(tile, tw, th)?; + let (w2, vs) = (tw / 2, h.vs); + if vs == 0 || !tw.is_multiple_of(2) || !w2.is_multiple_of(8) || w2.checked_mul(vs).is_none_or(|a| a > crate::MAX_SAMPLES) { + return Err(unsupported("tile geometry")); + } + let g4 = h.streams.get(12).ok_or_else(|| corrupt("missing residual stream"))?; + let s = first_half_shift(tile, g4, w2)?; + let (ntu, ph) = (tu_count(vs, s), phases(s)); + let layouts: Vec = (0..2 * ntu).map(|n| half_layout(n, s, vs, ph)).collect(); + let streams = + h.streams.par_iter().map(|st| decode_stream(tile, st, ntu, w2, &layouts)).collect::>().into_iter().collect::>>()?; + let comps = + (0..3).into_par_iter().map(|c| component(c, &streams, &layouts, vs, w2, ph)).collect::>().into_iter().collect::>>()?; + // Residual: g4's lines are the half's plane rows, dequantised with the half's nibble. + let mut res = Plane::zeros(w2, vs); + for ((half, lines), r) in streams.get(12).ok_or_else(|| corrupt("missing residual stream"))?.iter().zip(&layouts) { + for (row, line) in r.t.clone().zip(lines) { + put(&mut res, row, line, |q| dequant(q, half.qi[0]))?; + } + } + let mut it = comps.into_iter(); + let (Some(m), Some(c1), Some(c2)) = (it.next(), it.next(), it.next()) else { return Err(corrupt("missing component")) }; + Ok(TilePlanes { m, c1, c2, res }) +} + +/// Colour reconstruction (*Colour reconstruction*, checked against Phase 0 oracle output): the planes give a +/// `(2 W2) x (2 H2)` mosaic with R at (0,0), G1 (0,1), G2 (1,0), B (1,1) of every 2x2 cell, each value passed through +/// [`curve`]. G1 comes from the green mean minus a residual average, G2 is predicted from the (unclipped) G1 plus the +/// residual, and R/B are chroma plus the mean of the greens clamped to 0..=4095. +pub(crate) fn colour(p: &TilePlanes) -> Result> { + let (w2, h2) = (p.m.width, p.m.height); + let len = w2.checked_mul(h2).ok_or_else(|| corrupt("tile size"))?; + if [&p.c1, &p.c2, &p.res].iter().any(|q| q.width != w2 || q.height != h2 || q.data.len() != len) || p.m.data.len() != len { + return Err(corrupt("plane size mismatch")); + } + let mw = w2.checked_mul(2).ok_or_else(|| corrupt("tile size"))?; + let total = len.checked_mul(4).ok_or_else(|| corrupt("tile size"))?; + // (rows as slices: `at` is 0 past a row's end, as never happens once the sizes above match) + let at = |r: &[i32], x: usize| i64::from(r.get(x).copied().unwrap_or(0)); + let mut g1 = vec![0i64; len]; + g1.par_chunks_mut(w2.max(1)).enumerate().for_each(|(j, row)| { + let (Some(m), Some(rp), Some(rj)) = (p.m.row(j), p.res.row(j.saturating_sub(1)), p.res.row(j)) else { return }; + for (x, g) in row.iter_mut().enumerate() { + let xn = (x + 1).min(w2 - 1); + let r = at(rp, x) + at(rp, xn) + at(rj, x) + at(rj, xn); + *g = at(m, x) - ((r + 4) >> 3); + } + }); + let g1_row = |j: usize| g1.get(j * w2..(j + 1) * w2).unwrap_or(&[]); + let g = |r: &[i64], x: usize| r.get(x).copied().unwrap_or(0); + let mut out = vec![0u16; total]; + // each plane row gives two mosaic rows + out.par_chunks_mut((2 * mw).max(1)).enumerate().for_each(|(j, rows)| { + let (gj, gn) = (g1_row(j), g1_row((j + 1).min(h2 - 1))); + let (Some(res), Some(c1), Some(c2), Some((top, bottom))) = (p.res.row(j), p.c1.row(j), p.c2.row(j), rows.split_at_mut_checked(mw)) else { + return; + }; + for (x, (t, b)) in top.as_chunks_mut::<2>().0.iter_mut().zip(bottom.as_chunks_mut::<2>().0.iter_mut()).enumerate() { + let xp = x.saturating_sub(1); + let g2 = ((g(gj, xp) + g(gj, x) + g(gn, xp) + g(gn, x)) >> 2) + at(res, x); + let mean = (g(gj, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1; + let cells = [2 * at(c1, x) + mean, g(gj, x), g2, 2 * at(c2, x) + mean]; + for (o, v) in t.iter_mut().chain(b.iter_mut()).zip(cells) { + *o = curve(v.clamp(0, 4095) as i32); + } + } + }); + Ok(out) +} + +/// One tile to its `tw x th` mosaic in output units. +pub(crate) fn decode_tile(tile: &[u8], tw: usize, th: usize) -> Result> { + colour(&decode_tile_planes(tile, tw, th)?) +} + +/// Decode a whole ARW6 strip: [`Mode::Header`](crate::Mode) validates the tile table, tile headers and stream +/// tables and returns no samples; `Full` decodes the tiles in parallel into a `width x height` mosaic. +pub(crate) fn decode(strip: &[u8], width: usize, height: usize, mode: crate::Mode) -> Result> { + let tiles = parse_tile_table(strip, width, height)?; + let bytes = |i: usize| tile_bytes(strip, &tiles, i).ok_or_else(|| corrupt("tile outside strip")); + if mode == crate::Mode::Header { + for (i, t) in tiles.iter().enumerate() { + let tile = bytes(i)?; + let h = parse_tile_header(tile, t.width, t.height)?; + let g4 = h.streams.get(12).ok_or_else(|| corrupt("missing residual stream"))?; + let s = first_half_shift(tile, g4, t.width / 2)?; + let ntu = tu_count(h.vs, s); + for st in &h.streams { + stream_halves(tile, st, ntu)?; + } + } + return Ok(Vec::new()); + } + // a tile that fails is reported before the mosaic is allocated (storytold/lightcraft#1512) + let decoded: Vec> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect::>()?; + let mut out = vec![0u16; width.checked_mul(height).ok_or_else(|| corrupt("image size"))?]; + for (t, d) in tiles.iter().zip(decoded) { + for (r, row) in d.chunks_exact(t.width.max(1)).enumerate() { + let start = (t.y + r).checked_mul(width).and_then(|a| a.checked_add(t.x)).ok_or_else(|| corrupt("tile placement"))?; + let dst = start.checked_add(row.len()).and_then(|end| out.get_mut(start..end)).ok_or_else(|| corrupt("tile outside image"))?; + dst.copy_from_slice(row); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::arw6_testenc::*; + use crate::huffman::Bits; + use craft_tiff::Tiff; + use craft_tiff::image::chunk_bytes; + use craft_tiff::tags as t; + + fn planes(w2: usize, h2: usize, seed: u64) -> (Plane, Plane, Plane, Plane) { + let mut x = seed.max(1); + let mut next = |span: i32| -> i32 { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x % (2 * span as u64 + 1)) as i32 - span + }; + let mut mk = |centre: i32, span: i32| Plane { width: w2, height: h2, data: (0..w2 * h2).map(|_| centre + next(span)).collect() }; + (mk(2048, 200), mk(0, 60), mk(0, 60), mk(0, 24)) + } + + fn one_tile(w: usize, h: usize, s: u8, qi: Qis) -> (Vec, TileSpec) { + seeded_tile(w, h, s, qi, 7) + } + + fn seeded_tile(w: usize, h: usize, s: u8, qi: Qis, seed: u64) -> (Vec, TileSpec) { + let (m, c1, c2, res) = planes(w / 2, h / 2, seed); + let t = TileSpec { s, qi, m, c1, c2, res }; + (encode_tile(&t), t) + } + + /// The raw strip bytes of the compression-32766 IFD, as `arw.rs` locates them. + fn strip_of(file: &[u8]) -> &[u8] { + let tiff = Tiff::parse(file).unwrap(); + let raw = tiff.all_ifds().into_iter().find(|i| i.u16(t::COMPRESSION) == Some(32766)).unwrap(); + let info = raw.image().unwrap(); + let chunks = info.chunks(file.len() as u64); + chunk_bytes(file, &chunks[0]).unwrap() + } + + #[test] + fn tile_table_round_trips() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let file = arw6_file(&[(0, 0, 64, 48, tile.clone())], 64, 48); + let strip = strip_of(&file); + let t = parse_tile_table(strip, 64, 48).unwrap(); + assert_eq!(t.len(), 1); + assert_eq!((t[0].x, t[0].y, t[0].width, t[0].height, t[0].offset), (0, 0, 64, 48, 512)); + } + + #[test] + fn tile_table_rejects_bad_layouts() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let strip = strip_of(&arw6_file(&[(0, 0, 64, 48, tile.clone())], 64, 48)).to_vec(); + let mut zero = strip.clone(); + zero[0] = 0; + assert!(parse_tile_table(&zero, 64, 48).is_err()); + let mut far = strip.clone(); + far[8..16].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(parse_tile_table(&far, 64, 48).is_err()); + let mut odd = strip.clone(); + odd[20..24].copy_from_slice(&63u32.to_le_bytes()); // the first tile's y: odd, and no longer covering + assert!(parse_tile_table(&odd, 64, 48).is_err()); + assert!(parse_tile_table(&strip, 64, 64).is_err()); // does not cover the image + let two = arw6_file(&[(0, 0, 64, 48, tile.clone()), (0, 0, 64, 48, tile.clone())], 128, 48); + assert!(parse_tile_table(strip_of(&two), 128, 48).is_err()); // overlapping + assert!(parse_tile_table(&strip[..7], 64, 48).is_err()); // truncated table + } + + #[test] + fn tile_header_and_streams() { + let (tile, _) = one_tile(64, 48, 3, Qis::REAL); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + assert_eq!((h.hs, h.vs, h.streams.len()), (64, 24, 13)); + let ntu = tu_count(24, 3); + assert_eq!(ntu, 2); + assert_eq!( + h.streams.iter().map(|s| (s.group, s.comp)).collect::>(), + [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (2, 0), (2, 1), (2, 2), (3, 0), (3, 1), (3, 2), (4, 0)] + ); + let g3 = stream_halves(&tile, &h.streams[9], ntu).unwrap(); + assert_eq!(g3.len(), 4); + assert_eq!(g3[0].qi, [1, 1, 2]); + let g4 = stream_halves(&tile, &h.streams[12], ntu).unwrap(); + assert_eq!(g4[1].qi, [2, 2, 2]); + assert!(g4[3].start + g4[3].len <= tile.len()); + let mut bad = tile.clone(); + bad[13] = 0xff; // corrupts word 0's BBD bits: any picture-header deviation is Unsupported + assert!(matches!(parse_tile_header(&bad, 64, 48), Err(RawError::Unsupported(_)))); + assert!(parse_tile_header(&tile, 64, 50).is_err()); // VS != th / 2 + } + + fn put32(b: &mut [u8], at: usize, v: u32) { + b[at..at + 4].copy_from_slice(&v.to_le_bytes()); + } + + #[test] + fn tile_table_validation_cases() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let many = |n: usize| { + let tiles: Vec<_> = (0..n).map(|i| (64 * i, 0, 64, 48, tile.clone())).collect(); + strip_of(&arw6_file(&tiles, 64 * n, 48)).to_vec() + }; + let sixteen = many(16); + assert_eq!(parse_tile_table(&sixteen, 1024, 48).unwrap().len(), 16); + let mut seventeen = sixteen.clone(); + seventeen[0] = 17; + assert!(parse_tile_table(&seventeen, 1088, 48).is_err()); + + let two = strip_of(&arw6_file(&[(0, 0, 64, 48, tile.clone()), (64, 0, 64, 48, tile.clone())], 128, 48)).to_vec(); + assert_eq!(parse_tile_table(&two, 128, 48).unwrap().len(), 2); + let mut swapped = two.clone(); + let (a, b) = (two[8..16].to_vec(), two[32..40].to_vec()); + swapped[8..16].copy_from_slice(&b); + swapped[32..40].copy_from_slice(&a); + assert!(parse_tile_table(&swapped, 128, 48).is_err()); + let mut at_end = two.clone(); + at_end[32..40].copy_from_slice(&(two.len() as u64).to_le_bytes()); + assert!(parse_tile_table(&at_end, 128, 48).is_err()); + + let one = many(1); + let mut wide = one.clone(); + put32(&mut wide, 24, 72); // w / 2 = 36 is not a multiple of 8 + assert!(parse_tile_table(&wide, 72, 48).is_err()); + let mut huge = one.clone(); + put32(&mut huge, 24, 1 << 20); + put32(&mut huge, 28, 1 << 20); + assert!(matches!(parse_tile_table(&huge, 1 << 20, 1 << 20), Err(RawError::Limit(_)))); + } + + #[test] + fn tile_bytes_span_to_the_next_offset() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let file = arw6_file(&[(0, 0, 64, 48, tile.clone()), (64, 0, 64, 48, tile.clone())], 128, 48); + let strip = strip_of(&file); + let tiles = parse_tile_table(strip, 128, 48).unwrap(); + let (t0, t1) = (tile_bytes(strip, &tiles, 0).unwrap(), tile_bytes(strip, &tiles, 1).unwrap()); + assert_eq!(t0.len(), tiles[1].offset - tiles[0].offset); + assert_eq!(t0, &tile[..]); + assert_eq!(t1.as_ptr() as usize + t1.len(), strip.as_ptr() as usize + strip.len()); + assert!(tile_bytes(strip, &tiles, 2).is_none()); + } + + #[test] + fn tile_header_and_stream_validation_cases() { + let (tile, _) = one_tile(64, 48, 0, Qis::REAL); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + let mut two_streams = tile.clone(); + two_streams[3 * 16] = 2; + assert!(parse_tile_header(&two_streams, 64, 48).is_err()); + let mut big = tile.clone(); + big[3 * 16 + 1..3 * 16 + 4].copy_from_slice(&[0xff; 3]); // stream 0 larger than the tile + assert!(parse_tile_header(&big, 64, 48).is_err()); + let mut idx = tile.clone(); + idx[8 * 16..8 * 16 + 2].copy_from_slice(&[0xff, 0xff]); // index words beyond the stream + assert!(parse_tile_header(&idx, 64, 48).is_err()); + + let s = &h.streams[9]; + let mut bad_half = tile.clone(); + let idx_start = (s.word + 1) * 16; + bad_half[idx_start..idx_start + 2].copy_from_slice(&[0xff, 0xff]); // A past the data area + assert!(stream_halves(&bad_half, s, tu_count(24, 0)).is_err()); + let outside = StreamRef { index_words: 0xffff, ..s.clone() }; + assert!(stream_halves(&tile, &outside, tu_count(24, 0)).is_err()); + } + + #[test] + fn every_stream_of_an_encoded_tile_parses() { + for s in [0u8, 3] { + let (tile, spec) = one_tile(64, 48, s, Qis::REAL); + assert_eq!(tile.len() % 4096, 0); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + let ntu = tu_count(24, s); + for st in &h.streams { + assert_eq!(stream_halves(&tile, st, ntu).unwrap().len(), 2 * ntu); + } + // the residual stream's halves decode to the clipped, quantised rows 8n-6+s ..= 8n+1+s + let g4 = stream_halves(&tile, &h.streams[12], ntu).unwrap(); + for (n, half) in g4.iter().enumerate() { + let bytes = &tile[half.start..half.start + half.len]; + let mut bits = Bits::new(bytes); + for r in (8 * n as i32 - 6 + s as i32..8 * n as i32 + 2 + s as i32).filter(|&r| (0..24).contains(&r)) { + let line = crate::llvc::vld_decode_line(&mut bits, bytes.len() * 8, 32).unwrap(); + let want: Vec = spec.res.row(r as usize).unwrap().iter().map(|&v| quantize(v, 2)).collect(); + assert_eq!(line, want, "s {s} half {n} row {r}"); + } + } + // a stream that claims more halves than its index holds is rejected + assert!(stream_halves(&tile, &h.streams[0], 1000).is_err()); + } + } + + #[test] + fn encoder_decodes_its_own_lines() { + let vals: Vec = (0..100).map(|i| (i * 37) % 23 - 11).collect(); + let mut w = BitWriter::default(); + vld_encode_line(&vals, 100, &mut w); + let bytes = w.finish(); + let mut b = Bits::new(&bytes); + assert_eq!(crate::llvc::vld_decode_line(&mut b, bytes.len() * 8, 100).unwrap(), vals); + } + + #[test] + fn phases_and_half_rows_match_the_spec() { + assert_eq!(phases(0), [0, 1, 1]); + assert_eq!(phases(3), [1, 0, 0]); + assert_eq!(half_rows(0, 0, 1668), 0..2); + assert_eq!(half_rows(209, 0, 1668), 1666..1668); + assert_eq!(half_rows(1, 0, 1668), 2..10); + assert_eq!(half_rows(0, 3, 2186), 0..5); + assert_eq!(half_rows(273, 3, 2186), 2181..2186); + assert_eq!(half_rows(300, 3, 2186), 2186..2186); + } + + #[test] + fn tu_count_covers_every_row() { + assert_eq!((tu_count(1668, 0), tu_count(2186, 3), tu_count(24, 3), tu_count(48, 0)), (105, 137, 2, 4)); + for s in 0u8..=5 { + for vs in 1usize..200 { + let last = half_rows(2 * tu_count(vs, s) - 1, s, vs); + assert!(last.end == vs || (2 * tu_count(vs, s) - 1) * 8 + usize::from(s) + 2 >= vs, "s {s} vs {vs}"); + assert_eq!((0..2 * tu_count(vs, s)).map(|n| half_rows(n, s, vs).len()).sum::(), vs, "s {s} vs {vs}"); + } + } + } + + #[test] + fn first_half_shift_is_read_from_the_stream() { + for s in [0u8, 3, 5] { + let (tile, _) = one_tile(64, 48, s, Qis::REAL); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + assert_eq!(first_half_shift(&tile, &h.streams[12], 32).unwrap(), s); + } + } + + #[test] + fn round_trip_every_shift() { + for s in 0u8..=5 { + for (w, h) in [(64usize, 48usize), (80, 96), (64, 36)] { + let (tile, spec) = one_tile(w, h, s, Qis::ZERO); // QI 0: lossless, planes must come back exactly + let p = decode_tile_planes(&tile, w, h).unwrap_or_else(|e| panic!("s={s} {w}x{h}: {e}")); + assert_eq!(p.m, spec.m, "s={s} {w}x{h} m"); + assert_eq!(p.c1, spec.c1); + assert_eq!(p.c2, spec.c2); + assert_eq!(p.res, spec.res); + } + } + } + + #[test] + fn round_trip_with_real_quantisers() { + // bands drawn as quantised integers q, planes = reconstruct3(dequant(q)); the encoder must reproduce them bit for bit + for s in [0u8, 3] { + let (tile, spec) = one_tile_quantised(96, 64, s, Qis::REAL); + let p = decode_tile_planes(&tile, 96, 64).unwrap(); + assert_eq!(p.m, spec.m); + assert_eq!(p.c1, spec.c1); + assert_eq!(p.c2, spec.c2); + assert_eq!(p.res, spec.res); + } + } + + #[test] + fn half_too_short_is_corrupt() { + let (mut tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + let w = h.streams[9].word * 16 + 16; // first index entry of g3 c0: shrink A by 1 byte + let a = u16::from_be_bytes([tile[w], tile[w + 1]]) - 1; + tile[w..w + 2].copy_from_slice(&a.to_be_bytes()); + assert!(matches!(decode_tile_planes(&tile, 64, 48), Err(RawError::Corrupt(_)))); + } + + proptest::proptest! { + #[test] + fn mutated_tiles_never_panic(flips in proptest::collection::vec((proptest::num::usize::ANY, proptest::num::u8::ANY), 1..12), cut in proptest::num::usize::ANY) { + let (mut tile, _) = one_tile(64, 48, 3, Qis::REAL); + for (i, b) in flips { let i = i % tile.len(); tile[i] ^= b; } + let n = cut % (tile.len() + 1); + let _ = decode_tile(&tile[..n], 64, 48); + let _ = decode_tile(&tile, 64, 48); + } + } + + fn plane(w: usize, v: &[i32]) -> Plane { + Plane { width: w, height: v.len() / w, data: v.to_vec() } + } + + fn example() -> TilePlanes { + TilePlanes { + m: plane(3, &[2048, 2052, 2060, 2050, 2049, 2070]), + res: plane(3, &[4, -8, 2, 0, 2, -6]), + c1: plane(3, &[10, -10, 0, 0, 3, -2]), + c2: plane(3, &[-5, 7, 1, 2, 0, 4]), + } + } + + #[test] + fn colour_matches_the_phase0_example() { + let out = colour(&example()).unwrap(); // 6 wide x 4 rows, curve units + let code = |r: usize, c: usize| out[r * 6 + c] as i32; + assert_eq!( + [code(0, 1), code(0, 3), code(0, 5), code(2, 1), code(2, 3), code(2, 5)], + [2049, 2053, 2059, 2050, 2050, 2071].map(|c| curve(c) as i32) + ); // G1 + assert_eq!( + [code(1, 0), code(1, 2), code(1, 4), code(3, 0), code(3, 2), code(3, 4)], + [2053, 2042, 2060, 2050, 2052, 2054].map(|c| curve(c) as i32) + ); // G2 + assert_eq!([code(0, 0), code(0, 2), code(0, 4)], [2071, 2027, 2059].map(|c| curve(c) as i32)); // R + assert_eq!([code(1, 1), code(1, 3), code(1, 5)], [2041, 2061, 2061].map(|c| curve(c) as i32)); // B + } + + #[test] + fn colour_clips_like_the_oracle() { + let mut p = example(); + p.m.data[1] = 4100; + let out = colour(&p).unwrap(); + assert_eq!(out[3], curve(4095)); // G1 = 4101 -> clipped output + assert_eq!(out[6 + 2], curve(2554)); // G2 predicted from the unclipped G1 + assert_eq!(out[2], curve(3304)); // R = 2*(-10) + ((4095 + 2554) >> 1): chroma uses the clipped greens + } + + #[test] + fn colour_clamps_negative_greens() { + let p = TilePlanes { + m: plane(2, &[10, 2048, 2048, 2048]), + res: plane(2, &[100, 100, 0, 0]), + c1: plane(2, &[7, 0, 0, 0]), + c2: plane(2, &[-3, 0, 0, 0]), + }; + let g1_00 = 10 - ((400 + 4) >> 3); // -40 + let g1_10 = 2048 - ((200 + 4) >> 3); + let g2_00 = ((2 * g1_00 + 2 * g1_10) >> 2) + 100; + assert!(g1_00 < 0); + let mean = g2_00.clamp(0, 4095) >> 1; // G1 contributes 0 + let out = colour(&p).unwrap(); + assert_eq!(out[0], curve(14 + mean)); + assert_eq!(out[1], curve(0)); // G1 itself clamps + assert_eq!(out[2 * 2 + 1], curve(-6 + mean)); // B at (0,0): row 1, col 1 of a 4-wide mosaic + } + + #[test] + fn decode_header_and_full_and_truncations() { + let (tile, _) = one_tile(64, 48, 3, Qis::REAL); + let file = arw6_file(&[(0, 0, 64, 48, tile)], 64, 48); + let strip = strip_of(&file); + assert!(decode(strip, 64, 48, crate::Mode::Header).unwrap().is_empty()); + assert_eq!(decode(strip, 64, 48, crate::Mode::Full).unwrap().len(), 64 * 48); + for n in 0..strip.len() { + let _ = decode(&strip[..n], 64, 48, crate::Mode::Full); + let _ = decode(&strip[..n], 64, 48, crate::Mode::Header); + } + } + + #[test] + fn garbage_streams_import_then_fail() { + let (mut tile, _) = one_tile(64, 48, 0, Qis::REAL); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + let st = &h.streams[9]; // g3 c0 + let start = (st.word + 1 + st.index_words) * 16; + for (i, b) in tile[start..start + st.data_words * 16].iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(97) ^ 0xa5; + } + let strip = strip_of(&arw6_file(&[(0, 0, 64, 48, tile)], 64, 48)).to_vec(); + assert!(decode(&strip, 64, 48, crate::Mode::Header).is_ok()); + assert!(matches!(decode(&strip, 64, 48, crate::Mode::Full), Err(RawError::Corrupt(_)))); + } + + #[test] + fn chroma_planes_are_base_free() { + let (w2, h2) = (32, 24); + let flat = |v: i32| Plane { width: w2, height: h2, data: vec![v; w2 * h2] }; + let (m, _, _, res) = planes(w2, h2, 7); + for (c1, c2) in [(0, 0), (5, -3)] { + let t = TileSpec { s: 0, qi: Qis::ZERO, m: m.clone(), c1: flat(c1), c2: flat(c2), res: res.clone() }; + let tile = encode_tile(&t); + let p = decode_tile_planes(&tile, 64, 48).unwrap(); + assert!(p.c1.data.iter().all(|&v| v == c1) && p.c2.data.iter().all(|&v| v == c2)); + if c1 == 0 { + // zero chroma: R = B = (G1 + G2) >> 1 at every cell (no 2048 base, no saturation) + let out = decode_tile(&tile, 64, 48).unwrap(); + for j in 0..h2 { + for x in 0..w2 { + let at = |dy: usize, dx: usize| out[(2 * j + dy) * 64 + 2 * x + dx]; + assert_eq!(at(0, 0), at(1, 1), "R == B at ({x},{j})"); + assert!(at(0, 0) < curve(4095)); + } + } + } + } + } + + #[test] + fn arw6_white_without_tag_is_32766() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let file = arw6_file_no_white(&[(0, 0, 64, 48, tile)], 64, 48); + let img = crate::decode(&file).unwrap(); + assert_eq!(img.white, [32766.0]); + } + + #[test] + fn twelve_bit_mode_is_unsupported() { + let (tile, _) = one_tile(64, 48, 0, Qis::ZERO); + let file = arw6_file_bits(&[(0, 0, 64, 48, tile)], 64, 48, 12); + assert!(matches!(crate::decode(&file), Err(RawError::Unsupported(_)))); + } + + #[test] + fn shift_out_of_range_is_unsupported() { + let (w2, h2) = (32, 24); + let (m, c1, c2, res) = planes(w2, h2, 7); + let tile = encode_tile(&TileSpec { s: 6, qi: Qis::ZERO, m, c1, c2, res }); + assert!(matches!(decode_tile_planes(&tile, 64, 48), Err(RawError::Unsupported(_)))); + } + + #[test] + fn corrupt_streams_import_then_fail() { + // Garbage VLD data is self-delimiting and decodes to *something*; what is detectable is a half whose index + // length is too short for the lines it must hold. + let (mut tile, _) = one_tile(64, 48, 0, Qis::REAL); + let h = parse_tile_header(&tile, 64, 48).unwrap(); + let at = (h.streams[9].word + 1) * 16; // g3 c0 index table, first entry: half 0's byte length (u16 BE) + tile[at] = 0; + tile[at + 1] = 1; + let strip = strip_of(&arw6_file(&[(0, 0, 64, 48, tile)], 64, 48)).to_vec(); + assert!(decode(&strip, 64, 48, crate::Mode::Header).is_ok()); + assert!(matches!(decode(&strip, 64, 48, crate::Mode::Full), Err(RawError::Corrupt(_)))); + } + + #[test] + fn four_tiles_place_correctly_and_arw_reports_doubled_levels() { + let tiles: Vec<_> = [(0, 0), (64, 0), (0, 48), (64, 48)] + .iter() + .enumerate() + .map(|(i, &(x, y))| { + let (t, _) = seeded_tile(64, 48, 0, Qis::ZERO, 11 + i as u64 * 17); // a different image per tile + (x, y, 64, 48, t) + }) + .collect(); + let file = arw6_file(&tiles, 128, 96); + let img = crate::decode(&file).unwrap(); + assert_eq!((img.width, img.height, img.bits), (128, 96, 16)); + assert_eq!(img.black.values, vec![1024.0; 4]); + assert_eq!(img.white, vec![30720.0]); + assert_eq!(img.cfa.as_ref().map(|c| c.pattern.clone()), Some(vec![0, 1, 1, 2])); + let crate::RawData::U16(d) = img.data else { panic!() }; + for (x, y, w, h, bytes) in &tiles { + let want = decode_tile(bytes, *w, *h).unwrap(); + for r in 0..*h { + assert_eq!(&d[(y + r) * 128 + x..(y + r) * 128 + x + w], &want[r * w..(r + 1) * w], "tile at ({x},{y}) row {r}"); + } + } + assert_eq!(crate::probe_info(&file).unwrap().width, 128); // header mode: tile tables and headers only + } +} diff --git a/crates/raw/src/arw6_curve.rs b/crates/raw/src/arw6_curve.rs new file mode 100644 index 000000000..f7c748a15 --- /dev/null +++ b/crates/raw/src/arw6_curve.rs @@ -0,0 +1,55 @@ +//! The ARW6 companding curve: 12-bit tile code to output value in 2 × 14-bit DN (black 1024, white 32766). +//! +//! Piecewise linear through knots (c_k, 128 k): c_k = 128 k for k <= 11, else 356.89 + 655.646 ln(k - 6); each code's +//! value is the interpolation rounded half up. Our own measurement from the CC0 lossless / ARW6 pairs of the ILCE-7RM6, +//! not stored in the file; see docs/arw6-compression.md (*Output range*). + +use std::sync::OnceLock; + +/// Code of knot `k`, whose output value is 128 k. +fn knot(k: u32) -> f64 { + if k <= 11 { f64::from(128 * k) } else { 356.89 + 655.646 * f64::from(k - 6).ln() } +} + +fn table() -> &'static [u16; 4096] { + static TABLE: OnceLock<[u16; 4096]> = OnceLock::new(); + TABLE.get_or_init(|| { + let mut t = [0u16; 4096]; + let mut k = 0u32; + for (code, v) in t.iter_mut().enumerate() { + let c = code as f64; + while knot(k + 1) < c { + k += 1; + } + let (c0, c1, u0) = (knot(k), knot(k + 1), f64::from(128 * k)); + *v = (u0 + 128.0 * (c - c0) / (c1 - c0) + 0.5).floor() as u16; + } + t + }) +} + +/// Companding curve: 12-bit code (clamped to 0..=4095) to output value. +pub(crate) fn curve(code12: i32) -> u16 { + table().get(code12.clamp(0, 4095) as usize).copied().unwrap_or(u16::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Pins the whole generated table (sum and position-weighted sum of all 4096 values, from the derivation's own + /// table), so a platform whose `ln` rounds differently fails here. + #[test] + fn table_is_pinned() { + let t = table(); + let sum: u64 = t.iter().map(|&v| u64::from(v)).sum(); + let weighted: u64 = t.iter().enumerate().map(|(i, &v)| (i as u64 + 1) * u64::from(v)).sum(); + assert_eq!((sum, weighted), (27776336, 92792980288)); + for (c, v) in [(0, 0), (1408, 1408), (1536, 1542), (2048, 2457), (3000, 7979), (3500, 16227), (3977, 32767), (4095, 39077)] { + assert_eq!(curve(c), v, "code {c}"); + } + assert!((0..=1408).all(|c| curve(c) == c as u16), "identity through 1408"); + assert!(t.windows(2).all(|w| w[0] < w[1]), "strictly increasing"); + assert_eq!((curve(-5), curve(5000)), (0, 39077)); + } +} diff --git a/crates/raw/src/arw6_testenc.rs b/crates/raw/src/arw6_testenc.rs new file mode 100644 index 000000000..ede114213 --- /dev/null +++ b/crates/raw/src/arw6_testenc.rs @@ -0,0 +1,484 @@ +//! Test-only ARW6 synthetic encoder pieces: forward 5/3 transforms (exact inverse of `llvc::inverse_*`) and `quantize`. +//! Section names in italics refer to `docs/arw6-compression.md`. +#![allow(clippy::unwrap_used, clippy::type_complexity)] // a test-only encoder (the module is `cfg(test)`) + +use crate::llvc::{Bands3, Plane, band_rows}; +use craft_tiff::tags as t; +use craft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + +/// Exact inverse of `dequant` on representable values: `sign(v) * (|v| >> qi)`. +pub(crate) fn quantize(v: i32, qi: u32) -> i32 { + v.signum() * (v.abs() >> qi) +} + +fn at(v: &[i32], i: isize) -> i32 { + if v.is_empty() { 0 } else { v[i.clamp(0, v.len() as isize - 1) as usize] } +} + +/// Forward 1-D 5/3 with whole-sample symmetric extension (port of the Phase 0 reference model, local and not in the repo). +fn forward_1d(x: &[i32], phase: u8) -> (Vec, Vec) { + let n = x.len() as isize; + let p = isize::from(phase); + let xs = |i: isize| -> i32 { + let r = if i < 0 { + -i + } else if i >= n { + 2 * (n - 1) - i + } else { + i + }; + if (0..n).contains(&r) { x[r as usize] } else { 0 } + }; + let (nl, nh) = band_rows(x.len(), phase); + let h: Vec = (0..nh as isize) + .map(|k| { + let pos = 2 * k + 1 - p; + xs(pos) - ((xs(pos - 1) + xs(pos + 1)) >> 1) + }) + .collect(); + let l = (0..nl as isize).map(|j| xs(2 * j + p) + ((at(&h, j - 1 + p) + at(&h, j + p) + 2) >> 2)).collect(); + (l, h) +} + +fn split(x: &Plane, phase: u8, vertical: bool) -> (Plane, Plane) { + let (across, n) = if vertical { (x.width, x.height) } else { (x.height, x.width) }; + let (nl, nh) = band_rows(n, phase); + let dims = |m| if vertical { (across, m) } else { (m, across) }; + let (mut lo, mut hi) = (Plane::zeros(dims(nl).0, dims(nl).1), Plane::zeros(dims(nh).0, dims(nh).1)); + for i in 0..across { + let line: Vec = (0..n).map(|k| if vertical { x.data[k * x.width + i] } else { x.data[i * x.width + k] }).collect(); + let (l, h) = forward_1d(&line, phase); + for (p, v) in [(&mut lo, l), (&mut hi, h)] { + for (k, v) in v.into_iter().enumerate() { + let idx = if vertical { k * p.width + i } else { i * p.width + k }; + p.data[idx] = v; + } + } + } + (lo, hi) +} + +pub(crate) fn forward_53_1d_rows(x: &Plane, phase: u8) -> (Plane, Plane) { + split(x, phase, true) +} + +pub(crate) fn forward_53_1d_cols(x: &Plane, phase: u8) -> (Plane, Plane) { + split(x, phase, false) +} + +/// Horizontal first (phase 0), then vertical; returns `(ll, lh, hl, hh)`, the exact inverse of `inverse_53_2d`. +pub(crate) fn forward_53_2d(x: &Plane, vphase: u8) -> (Plane, Plane, Plane, Plane) { + let (lo, hi) = forward_53_1d_cols(x, 0); + let (ll, lh) = forward_53_1d_rows(&lo, vphase); + let (hl, hh) = forward_53_1d_rows(&hi, vphase); + (ll, lh, hl, hh) +} + +/// 3-level forward, the exact inverse of `reconstruct3` with the same `phases`. +pub(crate) fn forward3(x: &Plane, phases: [u8; 3]) -> Bands3 { + let (ll1, lh1, hl1, hh1) = forward_53_2d(x, phases[0]); + let (ll2, lh2, hl2, hh2) = forward_53_2d(&ll1, phases[1]); + let (ll3, lh3, hl3, hh3) = forward_53_2d(&ll2, phases[2]); + Bands3 { ll3, hl3, lh3, hh3, hl2, lh2, hh2, hl1, lh1, hh1 } +} + +// ---- RDD 34 line encoder (mirror of `llvc::vld_decode_line`; port of the Phase 0 reference model's encoder) ---- + +/// MSB-first bit sink; one `0`/`1` per element until `finish`. +#[derive(Default)] +pub(crate) struct BitWriter { + bits: Vec, +} + +impl BitWriter { + pub(crate) fn put(&mut self, bit: bool) { + self.bits.push(u8::from(bit)); + } + pub(crate) fn put_n(&mut self, v: u32, n: u32) { + for i in (0..n).rev() { + self.put((v >> i) & 1 == 1); + } + } + fn zeros(&mut self, n: u32) { + for _ in 0..n { + self.put(false); + } + } + /// Zero-padded to a whole byte. + pub(crate) fn finish(self) -> Vec { + self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (b << (7 - i)))).collect() + } +} + +/// Encode one line (`width` coefficients, padded with zeros to whole sets of 4), with Sony's two deviations. +pub(crate) fn vld_encode_line(values: &[i32], width: usize, out: &mut BitWriter) { + let sets_n = width.div_ceil(4); + let mut vals = values[..width.min(values.len())].to_vec(); + vals.resize(4 * sets_n, 0); + let sets: Vec<&[i32]> = vals.chunks(4).collect(); + let dpts: Vec = sets.iter().map(|s| 32 - s.iter().map(|v| v.unsigned_abs()).max().unwrap_or(0).leading_zeros()).collect(); + let (mut dpt, mut dpts_state, mut cnt, mut zr) = (0u32, 0u8, sets_n, 0usize); + let mut prev: Option<&[i32]> = None; + let mut k = 0usize; + loop { + let dpt_v = cnt != 0; + cnt = cnt.saturating_sub(1); + zr = zr.saturating_sub(1); + if dpt_v { + let d = dpts[k]; + match dpts_state { + 0 if d == dpt => { + out.put(false); + if dpt == 0 { + dpts_state = 1; + } + } + 0 if d > dpt => { + out.put(true); + out.put(false); + out.zeros(d - dpt - 1); + out.put(true); + dpt = d; + } + 0 => { + out.put(true); + out.put(true); + if d >= 1 { + out.zeros(dpt - d - 1); + out.put(true); + dpt = d; + } else { + out.zeros(dpt - 1); + dpt = 0; + dpts_state = 1; + } + } + 1 if d > 0 => { + out.put(true); + out.zeros(d - 1); + out.put(true); + dpt = d; + dpts_state = 0; + } + 1 => { + let r = dpts[k..].iter().take_while(|&&x| x == 0).count(); + let lim = (cnt as u64 + 2).next_power_of_two().trailing_zeros(); + if k + r >= sets_n { + out.zeros(lim); // the rest of the line is zero + zr = cnt + 2; + } else { + let v = (r + 1) as u32; + let m = 31 - v.leading_zeros(); + assert!(m < lim); + out.zeros(m); + out.put(true); + out.put_n(v - (1 << m), m); + zr = r + 1; + } + dpts_state = 2; + } + _ if zr == 1 => { + out.zeros(d - 1); + out.put(true); + dpt = d; + dpts_state = 0; + } + _ => assert_eq!(d, 0), + } + } + if let Some(p) = prev { + for &v in p { + if v != 0 { + out.put(v < 0); + } + } + } + if k >= sets_n { + return; + } + if dpt_v { + for &v in sets[k] { + out.put_n(v.unsigned_abs(), dpt); + } + prev = Some(sets[k]); + } + k += 1; + } +} + +// ---- tile / file serialiser (`tilewrite.py::TileModel.serialize`, `arw6dump.py`) ---- + +/// Per-band quantiser indices: `l1`/`l2`/`l3` per component, bands in HL, LH, HH order. +#[derive(Clone)] +pub(crate) struct Qis { + pub l1: [[u32; 3]; 3], + pub l2: [[u32; 3]; 3], + pub l3: [[u32; 3]; 3], + pub res: u32, +} + +impl Qis { + pub(crate) const ZERO: Qis = Qis { l1: [[0; 3]; 3], l2: [[0; 3]; 3], l3: [[0; 3]; 3], res: 0 }; + /// The values seen in every real file (*Container*). + pub(crate) const REAL: Qis = Qis { l1: [[1, 1, 2], [2, 2, 3], [2, 2, 3]], l2: [[0, 0, 1]; 3], l3: [[0; 3]; 3], res: 2 }; +} + +/// One tile to encode: `s` is the anchor offset (*Frame geometry*), planes are W/2 x H/2. +pub(crate) struct TileSpec { + pub s: u8, + pub qi: Qis, + pub m: Plane, + pub c1: Plane, + pub c2: Plane, + pub res: Plane, +} + +/// Band row indices of the plane rows `rows` (low: `t % 2 == p`, row `(t-p)/2`; high: row `(t-(1-p))/2`). +fn split_rows(rows: &[usize], p: usize) -> (Vec, Vec) { + let (mut lo, mut hi) = (Vec::new(), Vec::new()); + for &t in rows { + if t % 2 == p { + lo.push((t - p) / 2); + } else { + hi.push((t - (1 - p)) / 2); + } + } + (lo, hi) +} + +fn quantised_rows(plane: &Plane, rows: &[usize], qi: u32) -> Vec> { + rows.iter().map(|&r| plane.row(r).unwrap().iter().map(|&v| quantize(v, qi)).collect()).collect() +} + +fn encode_lines(lines: &[Vec]) -> Vec { + let mut w = BitWriter::default(); + for l in lines { + vld_encode_line(l, l.len(), &mut w); + } + w.finish() +} + +fn pad16(v: &mut Vec) { + v.resize(v.len().next_multiple_of(16), 0); +} + +fn u24(v: usize) -> [u8; 3] { + [(v >> 16) as u8, (v >> 8) as u8, v as u8] +} + +/// One stream: header word, index table, data area. `halves[i]` = (bytes, qi nibbles of the TU). +fn build_stream(halves: &[(Vec, Vec)]) -> Vec { + let (mut idx, mut data) = (Vec::new(), Vec::new()); + for pair in halves.chunks(2) { + let mut nib: Vec = Vec::new(); + for (bytes, qi) in pair { + nib.extend((0..4).rev().map(|i| (bytes.len() as u32 >> (4 * i)) & 15)); + nib.extend(qi); + data.extend_from_slice(bytes); + } + idx.extend(nib.chunks(2).map(|c| (c[0] << 4 | c[1]) as u8)); + } + pad16(&mut idx); + pad16(&mut data); + let mut out = (idx.len() as u16 / 16).to_be_bytes().to_vec(); + out.extend(u24(data.len() / 16)); + out.extend([0x40, 0x40, 0x01, 0x12, 0x10, 0, 0, 0, 0, 0, 0]); + out.extend(idx); + out.extend(data); + out +} + +/// Encode one tile: forward 3-level 5/3 per component with the phases of `s`, quantise, pack every half in natural +/// clipped order (*Frame geometry*), then add index tables, stream headers and the tile header. +pub(crate) fn encode_tile(t: &TileSpec) -> Vec { + let (vs, s) = (t.m.height, i32::from(t.s)); + let ntu = crate::arw6::tu_count(vs, t.s); + let p1 = s % 2; + let p2 = ((2 + s - p1) / 2) % 2; + let k3 = ((6 + s) % 8 - p1) / 2; + let p3 = ((k3 - p2) / 2).rem_euclid(2); + let (up1, up2, up3) = (p1 as usize, p2 as usize, p3 as usize); + let comps = [&t.m, &t.c1, &t.c2]; + let bands: Vec<_> = comps.iter().map(|p| forward3(p, [p1 as u8, p2 as u8, p3 as u8])).collect(); + let qi = &t.qi; + // streams[group][comp] -> halves + let mut streams: Vec, Vec)>>> = + vec![vec![Vec::new(); 3], vec![Vec::new(); 3], vec![Vec::new(); 3], vec![Vec::new(); 3], vec![Vec::new()]]; + for n in 0..2 * ntu as i32 { + let rows: Vec = (8 * n - 6 + s..8 * n + 2 + s).filter(|&r| r >= 0 && (r as usize) < vs).map(|r| r as usize).collect(); + let (low1, high1) = split_rows(&rows, up1); + let (low2, high2) = split_rows(&low1, up2); + let (low3, high3) = split_rows(&low2, up3); + for (c, b) in bands.iter().enumerate() { + let cat = |parts: [Vec>; 3]| parts.into_iter().flatten().collect::>(); + let dpcm = { + let mut ll = quantised_rows(&b.ll3, &low3, 0); + for l in &mut ll { + let mut prev = if c == 0 { 2048 } else { 0 }; + for v in l.iter_mut() { + (*v, prev) = (*v - prev, *v); + } + } + ll + }; + let sets: [(usize, Vec>, Vec); 4] = [ + (0, dpcm, vec![0]), + ( + 1, + cat([ + quantised_rows(&b.hl3, &low3, qi.l3[c][0]), + quantised_rows(&b.lh3, &high3, qi.l3[c][1]), + quantised_rows(&b.hh3, &high3, qi.l3[c][2]), + ]), + qi.l3[c].to_vec(), + ), + ( + 2, + cat([ + quantised_rows(&b.hl2, &low2, qi.l2[c][0]), + quantised_rows(&b.lh2, &high2, qi.l2[c][1]), + quantised_rows(&b.hh2, &high2, qi.l2[c][2]), + ]), + qi.l2[c].to_vec(), + ), + ( + 3, + cat([ + quantised_rows(&b.hl1, &low1, qi.l1[c][0]), + quantised_rows(&b.lh1, &high1, qi.l1[c][1]), + quantised_rows(&b.hh1, &high1, qi.l1[c][2]), + ]), + qi.l1[c].to_vec(), + ), + ]; + for (g, lines, q) in sets { + streams[g][c].push((encode_lines(&lines), q)); + } + } + streams[4][0].push((encode_lines(&quantised_rows(&t.res, &rows, qi.res)), vec![qi.res])); + } + let blobs: Vec>> = streams.iter().map(|g| g.iter().map(|s| build_stream(s)).collect()).collect(); + let mut head = vec![0u8; 8 * 16]; + head[..4].copy_from_slice(b"0000"); // bytes 4..8: tile index, 0 + let tw = 2 * t.m.width; + let w0: u64 = (tw as u64) << 48 | (vs as u64) << 32 | 16 << 20 | 3 << 13 | 3 << 10 | 0b10_0000_0000; + head[8..16].copy_from_slice(&w0.to_be_bytes()); + for (g, group) in blobs.iter().enumerate() { + let words: Vec = group.iter().map(|b| b.len() / 16).collect(); + head[(3 + g) * 16] = words.len() as u8; + for (i, w) in words.iter().enumerate() { + head[(3 + g) * 16 + 1 + 3 * i..][..3].copy_from_slice(&u24(*w)); + } + head[16 + 3 * g..][..3].copy_from_slice(&u24(words.iter().sum())); + } + let mut out = head; + out.extend(blobs.into_iter().flatten().flatten()); + out.resize(out.len().next_multiple_of(4096), 0); + out +} + +/// A little-endian ARW6 TIFF: IFD0 (Sony, ILCE-7RM6) + a SubIFD with the strip = tile table, zero padding to 512, +/// then the tiles `(x, y, w, h, bytes)` back to back. +pub(crate) fn arw6_file(tiles: &[(usize, usize, usize, usize, Vec)], width: usize, height: usize) -> Vec { + arw6_file_with(tiles, width, height, true, 14) +} + +/// [`arw6_file`] with another BitsPerSample. +pub(crate) fn arw6_file_bits(tiles: &[(usize, usize, usize, usize, Vec)], width: usize, height: usize, bits: u16) -> Vec { + arw6_file_with(tiles, width, height, true, bits) +} + +/// [`arw6_file`] without the WhiteLevel tag. +pub(crate) fn arw6_file_no_white(tiles: &[(usize, usize, usize, usize, Vec)], width: usize, height: usize) -> Vec { + arw6_file_with(tiles, width, height, false, 14) +} + +fn arw6_file_with(tiles: &[(usize, usize, usize, usize, Vec)], width: usize, height: usize, white: bool, bits: u16) -> Vec { + let mut strip = (tiles.len() as u32).to_le_bytes().to_vec(); + strip.extend([0; 4]); + let mut offset = 512u64; + for (x, y, w, h, bytes) in tiles { + strip.extend(offset.to_le_bytes()); + for v in [x, y, w, h] { + strip.extend((*v as u32).to_le_bytes()); + } + offset += bytes.len() as u64; + } + strip.resize(512, 0); + for (.., bytes) in tiles { + strip.extend_from_slice(bytes); + } + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![width as u32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![height as u32])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits])); + raw.set(t::COMPRESSION, Value::Short(vec![32766])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(0x7310, Value::Short(vec![512; 4])); + if white { + raw.set(t::WHITE_LEVEL, Value::Long(vec![15360])); + } + raw.set_image(ImageData::Strips { rows_per_strip: height as u32, strips: vec![strip] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SONY".into())); + ifd0.set(t::MODEL, Value::Ascii("ILCE-7RM6".into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![1])); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() +} + +/// A tile whose bands are random quantised integers `q` (level 1 wider), `v = dequant(q, qi)`, planes by +/// `reconstruct3` (so `quantize` in the encoder is exact on them); LL3 is random around 2048 (green mean) or 0 (chroma), the residual +/// `dequant(q, qi.res)`. +pub(crate) fn one_tile_quantised(w: usize, h: usize, s: u8, qi: Qis) -> (Vec, TileSpec) { + use crate::llvc::{dequant, reconstruct3}; + let (w2, vs) = (w / 2, h / 2); + let ph = crate::arw6::phases(s); + let mut x = 0x9e37_79b9_7f4a_7c15u64; + let mut rnd = |span: i32, centre: i32, q: u32| -> i32 { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + let v = (x % (2 * span as u64 + 1)) as i32 - span; + if centre == i32::MIN { + v + } else if centre == 0 { + dequant(v, q) + } else { + centre + v + } + }; + let mut plane = |wd: usize, ht: usize, span: i32, centre: i32, q: u32| Plane { + width: wd, + height: ht, + data: (0..wd * ht).map(|_| rnd(span, centre, q)).collect(), + }; + let mut comps = Vec::new(); + for c in 0..3 { + let (n1l, n1h) = band_rows(vs, ph[0]); + let (n2l, n2h) = band_rows(n1l, ph[1]); + let (n3l, n3h) = band_rows(n2l, ph[2]); + let (w1, w2b, w3) = (w2 / 2, w2 / 4, w2 / 8); + let (a, b, d) = (qi.l1[c], qi.l2[c], qi.l3[c]); + let bands = Bands3 { + ll3: plane(w3, n3l, 60, if c == 0 { 2048 } else { i32::MIN }, 0), + hl3: plane(w3, n3l, 7, 0, d[0]), + lh3: plane(w3, n3h, 7, 0, d[1]), + hh3: plane(w3, n3h, 7, 0, d[2]), + hl2: plane(w2b, n2l, 7, 0, b[0]), + lh2: plane(w2b, n2h, 7, 0, b[1]), + hh2: plane(w2b, n2h, 7, 0, b[2]), + hl1: plane(w1, n1l, 15, 0, a[0]), + lh1: plane(w1, n1h, 15, 0, a[1]), + hh1: plane(w1, n1h, 15, 0, a[2]), + }; + comps.push(reconstruct3(&bands, ph).unwrap()); + } + let res = plane(w2, vs, 7, 0, qi.res); + let (m, c1, c2) = (comps.remove(0), comps.remove(0), comps.remove(0)); + let t = TileSpec { s, qi, m, c1, c2, res }; + (encode_tile(&t), t) +} diff --git a/crates/raw/src/arw_lens.rs b/crates/raw/src/arw_lens.rs new file mode 100644 index 000000000..d1ae6acd6 --- /dev/null +++ b/crates/raw/src/arw_lens.rs @@ -0,0 +1,1526 @@ +//! Sony's in-file lens-correction records (shading, lateral chromatic aberration, distortion): bounded decoding of +//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and validated +//! ILCE-7RM4A, ILCE-7M3 and ILCE-7CR distortion tables into DNG warps for the shared optics pipeline. +//! +//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7031` to `0x7037`, signed shorts) and +//! https://exiftool.org/TagNames/Sony.html#SR2SubIFD (`0x797d`, `0x7980`, `0x7982`). The `SR2SubIFD` setting tags +//! `0x797c`, `0x797f` and `0x7981` sit next to those tables, unnamed there; they equal the raw IFD's settings in every +//! file inspected. Geometry inferred independently from files and Sony Imaging Edge exports; see +//! docs/sony-lens-corrections.md. No lens database and no external decoder code. The MakerNote's similarly named +//! tables have another scale and layout and are never read here. +//! +//! A raw-IFD field is the one the TIFF reader parsed, the copy the image itself uses (its first occurrence, within +//! the reader's limits). The directory is also scanned, for diagnostics only: whether a tag is repeated, and why an +//! entry the reader left out was dropped (values outside the file, an unknown type, or past the reader's limits). +//! `SR2SubIFD` fields are read straight from their first entry. Only the exact layouts are accepted: SHORT\[1\] (or +//! nonnegative SSHORT\[1\]) settings, SSHORT\[17\] headed by 16 for shading and distortion, SSHORT\[33\] headed by +//! 32 for CA. Nothing is allocated from a count. The raw IFD's copy wins; the `SR2SubIFD`'s is used only when the raw +//! IFD lacks the tag, and a malformed raw-IFD copy is reported, not replaced. Decoding grants nothing: only components +//! whose interpretation was independently validated for the model and mode are converted ([`SonyLensStatus`]), and +//! the conversion receives exactly the table the decoder applied before this record existed. + +use crate::lens_metadata::{ + SonyComponent, SonyCopy, SonyField, SonyFieldError, SonyLensCorrections, SonyLensGeometry, SonyLensStatus, SonySetting, SonySettingMeaning, + SonySource, +}; +use crate::sr2::SubIfd; +use crate::{Opcode, Rect}; +use craft_tiff::{ByteOrder, FieldType, Ifd, Value}; + +/// Each component's setting and table tags, `(raw IFD, SR2SubIFD)`. +const SHADING: Tags = Tags { setting: (0x7031, 0x797c), table: (0x7032, 0x797d) }; +const CA: Tags = Tags { setting: (0x7034, 0x797f), table: (0x7035, 0x7980) }; +const DISTORTION: Tags = Tags { setting: (0x7036, 0x7981), table: (0x7037, 0x7982) }; +/// TIFF field types. +const SHORT: u16 = 3; +const SSHORT: u16 = 8; +/// Models using the validated A7R IVA conversion (ILCE-7RM4A, ILCE-7M3). The A7CR uses a separate conversion. +/// ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases. +const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A", "ILCE-7M3"]; +/// Most directory entries scanned: a classic TIFF directory's largest count, also bounding a BigTIFF one. +const MAX_ENTRIES: usize = 1 << 16; +const SAMPLES: usize = 1024; +const MAX_ERROR: f64 = 0.0005; +/// Empirical A7CR unit, fixed on four FE 24-50mm files before checking the other files and lenses. +/// The camera-JPEG measurements and remaining coverage limits are in docs/sony-lens-corrections.md. +const A7CR_UNIT: f64 = 15559.300457825264; +const A7CR_FRAME: (usize, usize) = (9504, 6336); +const A7CR_APPROXIMATION_PX: f64 = 0.25; + +#[derive(Clone, Copy)] +struct Tags { + setting: (u16, u16), + table: (u16, u16), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Shading, + Ca, + Distortion, +} + +/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation), compared whole. +fn meaning(kind: Kind, value: u16) -> SonySettingMeaning { + use Kind::{Ca, Distortion, Shading}; + use SonySettingMeaning::*; + match (kind, value) { + (Shading, 256) | (Ca | Distortion, 0) => Off, + (Shading, 257 | 272) | (Ca | Distortion, 1) => Auto, + (Distortion, 17) => ForcedAuto, + (Shading, 511) | (Ca | Distortion, 255) => Unavailable, + _ => Unknown(value), + } +} + +/// One copy of a field. +#[derive(Clone, Debug, PartialEq)] +enum Read { + Absent, + Valid(T), + Rejected(SonyFieldError), +} + +impl Read { + fn map(self, f: impl FnOnce(T) -> U) -> Read { + match self { + Read::Absent => Read::Absent, + Read::Valid(v) => Read::Valid(f(v)), + Read::Rejected(e) => Read::Rejected(e), + } + } + fn copy(&self) -> SonyCopy { + match self { + Read::Absent => SonyCopy::Absent, + Read::Valid(_) => SonyCopy::Valid, + Read::Rejected(e) => SonyCopy::Rejected(*e), + } + } +} + +/// One copy of a field, and whether its directory holds the tag more than once. +struct Found { + read: Read, + repeated: bool, +} + +impl Found { + fn map(self, f: impl FnOnce(T) -> U) -> Found { + Found { read: self.read.map(f), repeated: self.repeated } + } +} + +/// A TIFF directory whose out-of-line values sit at `offset - base` in `data`: the raw IFD in the file (`base` 0), or +/// the decrypted `SR2SubIFD`, whose offsets are file offsets into the block itself. +struct Directory<'a> { + /// From the entry count on. + entries: &'a [u8], + data: &'a [u8], + base: usize, + order: ByteOrder, + /// BigTIFF layout: 8-byte entry count, 20-byte entries with an 8-byte count and value field. + big: bool, +} + +impl<'a> Directory<'a> { + /// The first entry of `tag`, and whether another follows. Only the first counts, as in the TIFF reader and + /// `sr2.rs`. Reads at most the entries the data holds, and at most [`MAX_ENTRIES`]. + fn entry(&self, tag: u16) -> (Option<&'a [u8]>, bool) { + let o = self.order; + let (n, head, size) = if self.big { (o.read_u64(self.entries, 0), 8, 20) } else { (o.read_u16(self.entries, 0).map(u64::from), 2, 12) }; + let Some(n) = n else { return (None, false) }; + let mut found = None; + for i in 0..usize::try_from(n).unwrap_or(usize::MAX).min(MAX_ENTRIES) { + let at = i.checked_mul(size).and_then(|at| at.checked_add(head)); + let Some(e) = at.and_then(|at| self.entries.get(at..at.checked_add(size)?)) else { break }; + if o.read_u16(e, 0) == Some(tag) { + if found.is_some() { + return (found, true); + } + found = Some(e); + } + } + (found, false) + } + + /// Field type and count of entry `e`. + fn field(&self, e: &[u8]) -> (u16, u64) { + let o = self.order; + let count = if self.big { o.read_u64(e, 4) } else { o.read_u32(e, 4).map(u64::from) }; + (o.read_u16(e, 2).unwrap_or(0), count.unwrap_or(u64::MAX)) + } + + /// `len` value bytes of entry `e`: inline when they fit the value field, else at the entry's offset. + fn values(&self, e: &'a [u8], len: usize) -> Option<&'a [u8]> { + let (field, width) = if self.big { (12, 8) } else { (8, 4) }; + if len <= width { + return e.get(field..field + len); + } + let offset = if self.big { self.order.read_u64(e, field)? } else { u64::from(self.order.read_u32(e, field)?) }; + let at = usize::try_from(offset).ok()?.checked_sub(self.base)?; + self.data.get(at..at.checked_add(len)?) + } + + /// Why the TIFF reader left out entry `e`, which the directory holds: a field type it doesn't know, values outside + /// the data, or else its own limits (the entries it reads per directory, its value budget). + fn unread(&self, e: &'a [u8]) -> SonyFieldError { + let (kind, count) = self.field(e); + let Some(size) = FieldType::from_u16(kind).map(FieldType::size) else { return SonyFieldError::FieldType(kind) }; + match count.checked_mul(size as u64).and_then(|len| usize::try_from(len).ok()).and_then(|len| self.values(e, len)) { + Some(_) => SonyFieldError::PastReaderLimits, + None => SonyFieldError::OutsideData, + } + } + + /// A radial table of exactly `N` samples in entry `e`: SSHORT\[N + 1\], the first value `N`. + fn table(&self, e: &'a [u8]) -> Read<[i16; N]> { + let (kind, count) = self.field(e); + if kind != SSHORT { + return Read::Rejected(SonyFieldError::FieldType(kind)); + } + if count != (N + 1) as u64 { + return Read::Rejected(SonyFieldError::Count(count)); + } + let Some(bytes) = self.values(e, 2 * (N + 1)) else { return Read::Rejected(SonyFieldError::OutsideData) }; + let at = |i: usize| self.order.read_u16(bytes, 2 * i).map_or(0, |v| v as i16); + let prefix = at(0); + if usize::try_from(prefix).ok() != Some(N) { + return Read::Rejected(SonyFieldError::CountPrefix(prefix)); + } + Read::Valid(std::array::from_fn(|i| at(i + 1))) + } + + /// A setting in entry `e`: SHORT\[1\], or SSHORT\[1\] when not negative. + fn setting(&self, e: &'a [u8], kind: Kind) -> Read { + let (field_type, count) = self.field(e); + if field_type != SHORT && field_type != SSHORT { + return Read::Rejected(SonyFieldError::FieldType(field_type)); + } + if count != 1 { + return Read::Rejected(SonyFieldError::Count(count)); + } + let Some(value) = self.values(e, 2).and_then(|b| self.order.read_u16(b, 0)) else { + return Read::Rejected(SonyFieldError::OutsideData); + }; + if field_type == SSHORT && (value as i16) < 0 { + return Read::Rejected(SonyFieldError::NegativeSetting(value as i16)); + } + Read::Valid(SonySetting { value, meaning: meaning(kind, value) }) + } +} + +/// A table as the TIFF reader parsed it: SSHORT\[N + 1\], the first value `N`. +fn parsed_table(value: &Value) -> Read<[i16; N]> { + let Value::SShort(v) = value else { return Read::Rejected(SonyFieldError::FieldType(value.field_type() as u16)) }; + if v.len() != N + 1 { + return Read::Rejected(SonyFieldError::Count(v.len() as u64)); + } + let at = |i: usize| v.get(i).copied().unwrap_or(0); + if usize::try_from(at(0)).ok() != Some(N) { + return Read::Rejected(SonyFieldError::CountPrefix(at(0))); + } + Read::Valid(std::array::from_fn(|i| at(i + 1))) +} + +/// A setting as the TIFF reader parsed it: SHORT\[1\], or SSHORT\[1\] when not negative (the documented and the stored +/// type differ). +fn parsed_setting(value: &Value, kind: Kind) -> Read { + let number = match value { + Value::Short(v) if v.len() != 1 => return Read::Rejected(SonyFieldError::Count(v.len() as u64)), + Value::SShort(v) if v.len() != 1 => return Read::Rejected(SonyFieldError::Count(v.len() as u64)), + Value::Short(v) => v.first().copied(), + Value::SShort(v) => match v.first().copied() { + Some(n) if n < 0 => return Read::Rejected(SonyFieldError::NegativeSetting(n)), + n => n.map(|n| n as u16), + }, + other => return Read::Rejected(SonyFieldError::FieldType(other.field_type() as u16)), + }; + number.map_or(Read::Rejected(SonyFieldError::Count(0)), |value| Read::Valid(SonySetting { value, meaning: meaning(kind, value) })) +} + +/// The raw IFD's copy of `tag`: the one the TIFF reader parsed, which is what the image itself uses. The directory +/// itself only adds diagnostics: whether the tag is repeated, and why an entry the reader left out was dropped. +fn raw_copy(ifd: &Ifd, dir: Option<&Directory<'_>>, tag: u16, read: impl Fn(&Value) -> Read) -> Found { + let (entry, repeated) = dir.map_or((None, false), |d| d.entry(tag)); + let read = match (ifd.get(tag), entry.zip(dir)) { + (Some(parsed), _) => read(&parsed.value), + (None, Some((e, d))) => Read::Rejected(d.unread(e)), + (None, None) => Read::Absent, + }; + Found { read, repeated } +} + +/// The `SR2SubIFD`'s copy of `tag`, read straight from its first entry. +fn sr2_copy<'a, T>(dir: Option<&Directory<'a>>, tag: u16, read: impl Fn(&Directory<'a>, &'a [u8]) -> Read) -> Found { + let Some(d) = dir else { return Found { read: Read::Absent, repeated: false } }; + let (entry, repeated) = d.entry(tag); + Found { read: entry.map_or(Read::Absent, |e| read(d, e)), repeated } +} + +/// One field from its two copies: the raw IFD's when well-formed, else the `SR2SubIFD`'s when the raw IFD lacks the +/// tag. +fn combine(raw: Found, sr2: Found) -> SonyField { + let (raw_ifd, sr2_copy) = (raw.read.copy(), sr2.read.copy()); + let copies_agree = match (&raw.read, &sr2.read) { + (Read::Valid(a), Read::Valid(b)) => Some(a == b), + _ => None, + }; + let (value, source) = match (raw.read, sr2.read) { + (Read::Valid(v), _) => (Some(v), Some(SonySource::RawIfd)), + (Read::Absent, Read::Valid(v)) => (Some(v), Some(SonySource::Sr2SubIfd)), + _ => (None, None), + }; + SonyField { value, source, raw_ifd, sr2: sr2_copy, copies_agree, raw_ifd_repeated: raw.repeated, sr2_repeated: sr2.repeated } +} + +/// Whether `r` has the native 3:2 framing, within 0.5 %. +pub(super) fn is_3_2(r: Rect) -> bool { + r.height > 0 && (r.width as f64 / r.height as f64 - 1.5).abs() <= 0.005 +} + +/// Decode the file's record and the validated distortion warp. `raw` is the raw image IFD as the TIFF reader parsed +/// it (in a BigTIFF file when `big`), `sr2` the decrypted `SR2SubIFD`. The record is `None` when neither directory +/// holds any of the six fields. +pub(super) fn corrections( + bytes: &[u8], + order: ByteOrder, + (raw, big): (&Ifd, bool), + sr2: Option<&SubIfd>, + model: &str, + geometry: SonyLensGeometry, +) -> (Option, Option) { + let plain = usize::try_from(raw.offset).ok().and_then(|at| Some(Directory { entries: bytes.get(at..)?, data: bytes, base: 0, order, big })); + let encrypted = sr2.map(|s| { + let (block, start) = s.block(); + Directory { entries: block, data: block, base: start, order: s.order(), big: false } + }); + let (p, s) = (plain.as_ref(), encrypted.as_ref()); + // CA: two contiguous 16-sample curves after the count + let split = |v: [i16; 32]| -> [[i16; 16]; 2] { std::array::from_fn(|c| std::array::from_fn(|i| v[16 * c + i])) }; + let table16 = |tags: (u16, u16)| combine(raw_copy(raw, p, tags.0, parsed_table::<16>), sr2_copy(s, tags.1, |d, e| d.table::<16>(e))); + let shading_table = table16(SHADING.table); + let distortion_table = table16(DISTORTION.table); + let ca_table = combine(raw_copy(raw, p, CA.table.0, parsed_table::<32>).map(split), sr2_copy(s, CA.table.1, |d, e| d.table::<32>(e)).map(split)); + let setting = |tags: Tags, kind: Kind| { + combine(raw_copy(raw, p, tags.setting.0, |v| parsed_setting(v, kind)), sr2_copy(s, tags.setting.1, |d, e| d.setting(e, kind))) + }; + let settings = [setting(SHADING, Kind::Shading), setting(CA, Kind::Ca), setting(DISTORTION, Kind::Distortion)]; + if !shading_table.is_present() && !ca_table.is_present() && !distortion_table.is_present() && !settings.iter().any(SonyField::is_present) { + return (None, None); + } + let [shading_setting, ca_setting, distortion_setting] = settings; + // Only the validated scope reaches the conversion: the raw IFD's table, as the TIFF reader parsed it, of a Bayer + // image whose image crop and reference are native 3:2 (DefaultCrop can keep native geometry while Sony's tags + // select an aspect crop). + let a7cr = model == "ILCE-7CR"; + let validated_model = DISTORTION_MODELS.contains(&model) || a7cr; + let in_mode = !geometry.linear_ycbcr + && geometry.native_3_2 + && distortion_table.source == Some(SonySource::RawIfd) + && (!a7cr || ((geometry.reference.width, geometry.reference.height) == A7CR_FRAME && geometry.image_crop == geometry.reference)); + let a7cr_auto = distortion_setting.value.is_some_and(|s| matches!(s.meaning, SonySettingMeaning::Auto | SonySettingMeaning::ForcedAuto)); + let (w, h) = (geometry.storage_width, geometry.storage_height); + let warp = match &distortion_table.value { + Some(table) if in_mode && a7cr && a7cr_auto => a7cr_distortion(table, Rect::new(0, 0, w, h), geometry.reference), + Some(table) if in_mode && !a7cr => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference), + _ => None, + }; + let distortion_status = match &distortion_table.value { + None => SonyLensStatus::NoTable, + Some(_) if warp.is_some() => SonyLensStatus::Applied, + Some(_) if !validated_model => SonyLensStatus::NotValidatedModel, + Some(_) if !in_mode => SonyLensStatus::NotValidatedMode, + Some(_) if a7cr && !a7cr_auto => SonyLensStatus::CameraCorrectionDisabled, + Some(table) if table.iter().all(|v| *v == 0) => SonyLensStatus::Neutral, + Some(_) => SonyLensStatus::Rejected, + }; + // No independent validation of shading or CA semantics exists on any body: decoded, never applied. + let unvalidated = |present: bool| if present { SonyLensStatus::NotValidatedComponent } else { SonyLensStatus::NoTable }; + let record = SonyLensCorrections { + shading: SonyComponent { status: unvalidated(shading_table.value.is_some()), setting: shading_setting, table: shading_table }, + chromatic_aberration: SonyComponent { status: unvalidated(ca_table.value.is_some()), setting: ca_setting, table: ca_table }, + distortion: SonyComponent { setting: distortion_setting, table: distortion_table, status: distortion_status }, + sr2_read: sr2.is_some(), + geometry, + }; + (Some(record), warp) +} + +/// A7CR camera-JPEG convention, measured independently of the ILCE-7RM4A conversion below. +/// For output radius rho, the corrected radius is rho / zoom. The table gives source radius at that +/// corrected radius. Its knots use the full-frame diagonal, not storage padding. Off/unknown settings +/// and unvalidated capture modes are excluded by the caller. +fn a7cr_distortion(table: &[i16; 16], active: Rect, crop: Rect) -> Option { + if (crop.width, crop.height) != A7CR_FRAME + || crop != crop.clipped(active.width, active.height) + || table.iter().all(|v| *v == 0) + || table.iter().any(|v| !(-4096..=4096).contains(v)) + { + return None; + } + let (cw, ch) = (crop.width as f64, crop.height as f64); + let spacing = cw.hypot(ch) / 30.0; + let source = |r: f64| { + let at = r / spacing; + let i = (at.floor() as usize).min(14); + let (a, b) = (f64::from(table[i]), f64::from(table[i + 1])); + r * (1.0 + (a + (b - a) * (at - i as f64)) / A7CR_UNIT) + }; + let border_min = cw.min(ch) / 2.0; + let border_max = cw.hypot(ch) / 2.0; + let border_ratio = |zoom: f64| { + (0..=4096) + .map(|j| { + let r = border_min + (border_max - border_min) * j as f64 / 4096.0; + source(r / zoom) / r + }) + .fold(0.0, f64::max) + }; + let (mut lo, mut hi) = (0.5, 2.0); + if border_ratio(lo) <= 1.0 || border_ratio(hi) > 1.0 { + return None; + } + for _ in 0..60 { + let mid = (lo + hi) / 2.0; + if border_ratio(mid) > 1.0 { + lo = mid; + } else { + hi = mid; + } + } + let zoom = (lo + hi) / 2.0; + let radius = (cw - 1.0).hypot(ch - 1.0) / 2.0; + let basis = |r: f64| { + let r2 = r * r; + [r, r * r2, r * r2 * r2, r * r2 * r2 * r2] + }; + let mut matrix = [[0.0; 4]; 4]; + let mut rhs = [0.0; 4]; + let mut previous = -1.0; + for j in 0..=SAMPLES { + let r = j as f64 / SAMPLES as f64; + let mapped = source(r * radius / zoom) / radius; + if !mapped.is_finite() || mapped <= previous { + return None; + } + previous = mapped; + let terms = basis(r); + for (i, &a) in terms.iter().enumerate() { + rhs[i] += a * mapped; + for (k, &b) in terms.iter().enumerate() { + matrix[i][k] += a * b; + } + } + } + let mut k = solve4(matrix, rhs)?; + // Check every displayed radius, separately from the empirical camera-JPEG validation. + for j in 0..=32768 { + let r = j as f64 / 32768.0; + let fit = basis(r).iter().zip(k).map(|(a, b)| a * b).sum::(); + let r2 = r * r; + let derivative = k[0] + r2 * (3.0 * k[1] + r2 * (5.0 * k[2] + r2 * 7.0 * k[3])); + let error_px = (fit * radius - source(r * radius / zoom)).abs() * 1440.0 / cw; + if !error_px.is_finite() || error_px > A7CR_APPROXIMATION_PX || derivative <= 0.0 { + return None; + } + } + let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0); + let (cx, cy) = (crop.x as f64 + (cw - 1.0) / 2.0, crop.y as f64 + (ch - 1.0) / 2.0); + let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy)); + let ratio2 = (farthest / radius).powi(2); + for (i, coefficient) in k.iter_mut().enumerate() { + *coefficient *= ratio2.powi(i as i32); + } + Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [cx / ax, cy / ay] }) +} + +/// The validated conversion of a raw-IFD distortion table into a `WarpRectilinear`. Only independently validated +/// camera models and the 16-entry layout; unknown models and implausible or non-smooth maps are left uncorrected. +/// See docs/sony-lens-corrections.md. A camera setting of Off does not remove the lens data: the user can enable it +/// in the developer. +fn distortion(model: &str, table: &[i16; 16], active: Rect, crop: Rect) -> Option { + // A matching table shape does not establish its scale or radial normalization on another body. + if !DISTORTION_MODELS.contains(&model) { + return None; + } + if table.iter().all(|v| *v == 0) || table.iter().any(|v| !(-4096..=4096).contains(v)) { + return None; + } + let crop = crop.clipped(active.width, active.height); + if active.width < 2 || active.height < 2 || crop.width < 2 || crop.height < 2 { + return None; + } + // Only native 3:2 framing has been validated. An in-camera aspect crop may retain a table + // normalized to the full sensor frame; do not silently reinterpret it as the smaller crop. + if (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 { + return None; + } + // Interpolate fractional radial scale at evenly spaced radii from centre to cropped corner. + let scale = |r: f64| { + let at = r.clamp(0.0, 1.0) * 15.0; + let i = (at.floor() as usize).min(14); + let a = f64::from(table[i]); + let b = f64::from(table[i + 1]); + 1.0 + (a + (b - a) * (at - i as f64)) / 16384.0 + }; + let (cw, ch) = (crop.width as f64 - 1.0, crop.height as f64 - 1.0); + let diagonal = cw.hypot(ch); + // Zoom until the entire rectangular output boundary is inside the source. For a piecewise-linear + // scale its maximum on that boundary occurs at its nearest radius or at one of the remaining knots. + let nearest = cw.min(ch) / diagonal; + let max_scale = (0..16).map(|i| i as f64 / 15.0).filter(|&r| r >= nearest).map(scale).fold(scale(nearest), f64::max); + let zoom = 1.0 / max_scale; + let basis = |r: f64| { + let r2 = r * r; + [r, r * r2, r * r2 * r2, r * r2 * r2 * r2] + }; + let mut matrix = [[0.0; 4]; 4]; + let mut rhs = [0.0; 4]; + let mut previous = -1.0; + for j in 0..=SAMPLES { + let r = j as f64 / SAMPLES as f64; + let source = r * scale(r) * zoom; + if source <= previous { + return None; + } + previous = source; + let terms = basis(r); + for (i, &a) in terms.iter().enumerate() { + rhs[i] += a * source; + for (k, &b) in terms.iter().enumerate() { + matrix[i][k] += a * b; + } + } + } + let mut k = solve4(matrix, rhs)?; + // Keep the polynomial within a fraction of a preview pixel of the file's table, and forbid folds. + for j in 0..=SAMPLES { + let r = j as f64 / SAMPLES as f64; + let fit = basis(r).iter().zip(k).map(|(a, b)| a * b).sum::(); + let r2 = r * r; + let derivative = k[0] + r2 * (3.0 * k[1] + r2 * (5.0 * k[2] + r2 * 7.0 * k[3])); + if (fit - r * scale(r) * zoom).abs() > MAX_ERROR || derivative <= 0.0 { + return None; + } + } + // DNG uses the farthest *active-area* corner, not the cropped diagonal. Preserve the same mapping + // after the engine translates the opcode into default-cropped and EXIF-oriented coordinates. + let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0); + let (cx, cy) = (crop.x as f64 + cw / 2.0, crop.y as f64 + ch / 2.0); + let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy)); + let ratio2 = (2.0 * farthest / diagonal).powi(2); + for (i, coefficient) in k.iter_mut().enumerate() { + *coefficient *= ratio2.powi(i as i32); + } + Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [cx / ax, cy / ay] }) +} + +/// Small fixed-size least-squares system; partial pivoting, no input-sized allocations. +fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { + for col in 0..4 { + let pivot = (col..4).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?; + if !a[pivot][col].is_finite() || a[pivot][col].abs() < 1e-12 { + return None; + } + a.swap(col, pivot); + b.swap(col, pivot); + for row in col + 1..4 { + let factor = a[row][col] / a[col][col]; + let (top, bottom) = a.split_at_mut(row); + for (x, p) in bottom[0][col..].iter_mut().zip(&top[col][col..]) { + *x -= factor * p; + } + b[row] -= factor * b[col]; + } + } + let mut x = [0.0; 4]; + for i in (0..4).rev() { + x[i] = (b[i] - (i + 1..4).map(|j| a[i][j] * x[j]).sum::()) / a[i][i]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lens_metadata::SonyCopy::{Absent, Rejected, Valid}; + use crate::lens_metadata::SonyFieldError::*; + use crate::{LensMetadata, Limits, Mode, RawImage}; + use craft_tiff::ByteOrder::{Big, Little}; + use craft_tiff::{IfdBuilder, ImageData, Tiff, TiffWriter, Value, tags as t}; + + const TELE: [i16; 16] = [-2, 1, 7, 17, 31, 48, 68, 93, 120, 152, 187, 225, 265, 309, 356, 403]; + const WIDE: [i16; 16] = [14, 0, -27, -59, -106, -158, -223, -291, -367, -440, -519, -593, -667, -731, -792, -839]; + const AREA: Rect = Rect { x: 0, y: 0, width: 9504, height: 6336 }; + /// The shading samples of the public ILCE-7CR samples (raw.pixls.us DSC00795/6/8). + const SHADING_T: [i16; 16] = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + /// Public storytold/lightcraft#1300, FE 16-25mm F2.8 G at 16 mm, raw-IFD samples. + const A7CR_WIDE: [i16; 16] = [0, -14, -59, -130, -226, -341, -472, -615, -767, -924, -1084, -1242, -1397, -1549, -1696, -1839]; + + /// A CA table with negative and positive samples and two different curves. + fn ca() -> [i16; 32] { + std::array::from_fn(|i| (i as i16 - 16) * 128) + } + fn ca_curves() -> [[i16; 16]; 2] { + let v = ca(); + std::array::from_fn(|c| std::array::from_fn(|i| v[16 * c + i])) + } + fn sshort(prefix: i16, samples: &[i16]) -> Value { + Value::SShort(std::iter::once(prefix).chain(samples.iter().copied()).collect()) + } + /// The six raw-IFD fields: shading 272 (Auto), CA 1 (Auto), and the distortion `setting` and `table`. + fn plain(table: &[i16; 16], setting: u16) -> Vec<(u16, Value)> { + vec![ + (SHADING.setting.0, Value::Short(vec![272])), + (SHADING.table.0, sshort(16, &SHADING_T)), + (CA.setting.0, Value::Short(vec![1])), + (CA.table.0, sshort(32, &ca())), + (DISTORTION.setting.0, Value::Short(vec![setting])), + (DISTORTION.table.0, sshort(16, table)), + ] + } + /// The same fields as `SR2SubIFD` entries `(tag, type, values)`. + fn encrypted(table: &[i16; 16], setting: u16) -> Vec<(u16, u16, Vec)> { + let t = |prefix: i16, v: &[i16]| std::iter::once(prefix).chain(v.iter().copied()).map(|x| x as u16).collect::>(); + vec![ + (SHADING.setting.1, SHORT, vec![272]), + (SHADING.table.1, SSHORT, t(16, &SHADING_T)), + (CA.setting.1, SHORT, vec![1]), + (CA.table.1, SSHORT, t(32, &ca())), + (DISTORTION.setting.1, SHORT, vec![setting]), + (DISTORTION.table.1, SSHORT, t(16, table)), + ] + } + fn set(fields: &mut Vec<(u16, Value)>, tag: u16, value: Value) { + fields.retain(|(t, _)| *t != tag); + fields.push((tag, value)); + } + fn word(order: ByteOrder, v: u32) -> Vec { + let mut out = Vec::new(); + order.put_u32(&mut out, v); + out + } + + /// A small uncompressed Sony CFA file (the raw IFD is IFD0) with the lens fields `plain` in its raw IFD and, when + /// `sr2` is set, an encrypted `SR2SubIFD` holding those entries, reached as Sony stores it: `DNGPrivateData` -> + /// `SR2Private` IFD -> block. + struct Arw { + model: &'static str, + order: ByteOrder, + bigtiff: bool, + size: (u32, u32), + sony_crop: Option<[u32; 4]>, + default_crop: Option<[u32; 4]>, + plain: Vec<(u16, Value)>, + sr2: Option)>>, + /// The key stored in `SR2SubIFDKey`; the block is always encrypted with the usual one. + stored_key: [u8; 4], + /// An edit to the decrypted `SR2SubIFD` before it is encrypted. + #[allow(clippy::type_complexity)] + sr2_patch: Option>, + } + + impl Arw { + fn new(model: &'static str, order: ByteOrder) -> Arw { + Arw { + model, + order, + bigtiff: false, + size: (32, 24), + sony_crop: Some([2, 2, 30, 20]), + default_crop: Some([2, 2, 30, 20]), + plain: plain(&TELE, 0), + sr2: Some(encrypted(&TELE, 0)), + stored_key: KEY, + sr2_patch: None, + } + } + + fn build(&self) -> Vec { + let (w, h) = self.size; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(self.model.into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + 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::WHITE_LEVEL, Value::Long(vec![16383])); + if let Some([x, y, cw, ch]) = self.sony_crop { + raw.set(0x74c7, Value::Long(vec![x, y])); + raw.set(0x74c8, Value::Long(vec![cw, ch])); + } + if let Some([x, y, cw, ch]) = self.default_crop { + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![x, y])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![cw, ch])); + } + if self.sr2.is_some() { + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + } + for (tag, value) in &self.plain { + raw.set(*tag, value.clone()); + } + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![0; (w * h * 2) as usize]] }); + let mut file = TiffWriter::new(self.order, self.bigtiff).write(&[raw]).unwrap(); + let Some(entries) = &self.sr2 else { return file }; + let o = self.order; + let at = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + let private = file.len(); + let start = private + 2 + 3 * 12 + 4; + let refs: Vec<(u16, u16, &[u16])> = entries.iter().map(|(tag, kind, v)| (*tag, *kind, v.as_slice())).collect(); + let mut block = crate::sr2::tests::block(start, &refs, o); + if let Some(patch) = &self.sr2_patch { + crate::sr2::tests::encrypt(&mut block); + patch(&mut block); + crate::sr2::tests::encrypt(&mut block); + } + o.put_u16(&mut file, 3); + for (tag, value) in [(0x7200u16, start as u32), (0x7201, block.len() as u32)] { + o.put_u16(&mut file, tag); + o.put_u16(&mut file, 4); + o.put_u32(&mut file, 1); + o.put_u32(&mut file, value); + } + o.put_u16(&mut file, 0x7221); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, 4); + file.extend(self.stored_key); + o.put_u32(&mut file, 0); + file.extend(block); + file[at..at + 4].copy_from_slice(&word(o, private as u32)); + file + } + } + + /// Byte position of IFD0's entry for `tag` in a classic TIFF `file`. + fn entry_at(file: &[u8], order: ByteOrder, tag: u16) -> usize { + let ifd = order.read_u32(file, 4).unwrap() as usize; + let n = usize::from(order.read_u16(file, ifd).unwrap()); + (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| order.read_u16(file, e) == Some(tag)).unwrap() + } + + /// Header and full decode of `file`, which must describe the same image; the full one. + fn decode(file: &[u8]) -> RawImage { + let header = crate::arw::decode(file, Mode::Header, &Limits::default()).unwrap(); + let full = crate::arw::decode(file, Mode::Full, &Limits::default()).unwrap(); + assert_eq!(header.info(), full.info()); + full + } + fn sony(img: &RawImage) -> &SonyLensCorrections { + img.lens_metadata.sony.as_ref().expect("a Sony lens record") + } + /// (has a value, source, raw-IFD copy, SR2SubIFD copy, agreement) of one field. + fn prov(f: &SonyField) -> (bool, Option, SonyCopy, SonyCopy, Option) { + (f.value.is_some(), f.source, f.raw_ifd, f.sr2, f.copies_agree) + } + /// [`prov`] of the three settings, then the three tables. + #[allow(clippy::type_complexity)] + fn provenance(s: &SonyLensCorrections) -> [(bool, Option, SonyCopy, SonyCopy, Option); 6] { + [ + prov(&s.shading.setting), + prov(&s.chromatic_aberration.setting), + prov(&s.distortion.setting), + prov(&s.shading.table), + prov(&s.chromatic_aberration.table), + prov(&s.distortion.table), + ] + } + + #[test] + fn decodes_all_three_components_with_settings_and_provenance() { + let raw = Some(SonySource::RawIfd); + for order in [Little, Big] { + for bigtiff in [false, true] { + for (model, warps, distortion_status) in + [("ILCE-7CR", 0, SonyLensStatus::NotValidatedMode), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)] + { + let case = format!("{model} {order:?} bigtiff={bigtiff}"); + let mut arw = Arw::new(model, order); + arw.bigtiff = bigtiff; + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(img.opcodes.list3.len(), warps, "{case}"); + assert!(s.sr2_read, "{case}"); + assert_eq!(provenance(s), [(true, raw, Valid, Valid, Some(true)); 6], "{case}"); + assert_eq!(s.shading.table.value, Some(SHADING_T), "{case}"); + assert_eq!(s.chromatic_aberration.table.value, Some(ca_curves()), "{case}"); + assert_eq!(s.distortion.table.value, Some(TELE), "{case}"); + assert_eq!(s.shading.setting.value, Some(SonySetting { value: 272, meaning: SonySettingMeaning::Auto })); + assert_eq!(s.chromatic_aberration.setting.value, Some(SonySetting { value: 1, meaning: SonySettingMeaning::Auto })); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 0, meaning: SonySettingMeaning::Off })); + // readable shading and CA tables are not corrections + assert_eq!(s.shading.status, SonyLensStatus::NotValidatedComponent, "{case}"); + assert_eq!(s.chromatic_aberration.status, SonyLensStatus::NotValidatedComponent, "{case}"); + assert_eq!(s.distortion.status, distortion_status, "{case}"); + let crop = Rect::new(2, 2, 30, 20); + let geometry = SonyLensGeometry { + storage_width: 32, + storage_height: 24, + sony_crop: Some(crop), + default_crop: Some(crop), + image_crop: crop, + reference: crop, + compression: 1, + linear_ycbcr: false, + native_3_2: true, + }; + assert_eq!(s.geometry, geometry, "{case}"); + // the applied warp is the validated conversion of the raw IFD's table, as before + let reference: Vec = distortion(model, &TELE, Rect::new(0, 0, 32, 24), crop).into_iter().collect(); + assert_eq!(img.opcodes.list3, reference, "{case}"); + // DNG export keeps the warp and invents no Sony record + #[cfg(feature = "dng")] + { + let dng = crate::write_dng(&img, &Default::default()).unwrap(); + let back = crate::decode(&dng).unwrap(); + assert_eq!(back.opcodes.list3, img.opcodes.list3, "{case}"); + assert_eq!(back.lens_metadata, LensMetadata::default(), "{case}"); + } + } + } + } + } + + #[test] + fn settings_keep_their_number_and_unknown_values_are_never_off() { + use Kind::{Ca, Distortion, Shading}; + use SonySettingMeaning::*; + for (kind, value, expected) in [ + (Shading, 256, Off), + (Shading, 257, Auto), + (Shading, 272, Auto), + (Shading, 511, Unavailable), + (Shading, 0, Unknown(0)), + (Shading, 1, Unknown(1)), + (Shading, 255, Unknown(255)), + (Shading, 258, Unknown(258)), + (Shading, 273, Unknown(273)), + (Ca, 0, Off), + (Ca, 1, Auto), + (Ca, 255, Unavailable), + (Ca, 2, Unknown(2)), + (Ca, 17, Unknown(17)), + (Ca, 256, Unknown(256)), + (Distortion, 0, Off), + (Distortion, 1, Auto), + (Distortion, 17, ForcedAuto), + (Distortion, 255, Unavailable), + (Distortion, 2, Unknown(2)), + (Distortion, 16, Unknown(16)), + (Distortion, 256, Unknown(256)), + (Distortion, u16::MAX, Unknown(u16::MAX)), + ] { + assert_eq!(meaning(kind, value), expected, "{kind:?} {value}"); + } + for order in [Little, Big] { + let read = |value: Value| { + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2 = None; + set(&mut arw.plain, DISTORTION.setting.0, value); + sony(&decode(&arw.build())).distortion.setting.clone() + }; + assert_eq!(read(Value::Short(vec![17])).value, Some(SonySetting { value: 17, meaning: ForcedAuto })); + assert_eq!(read(Value::Short(vec![9])).value, Some(SonySetting { value: 9, meaning: Unknown(9) })); + // the documented editor type (SSHORT) is accepted when not negative + assert_eq!(read(Value::SShort(vec![1])).value, Some(SonySetting { value: 1, meaning: Auto })); + for (value, error) in [ + (Value::SShort(vec![-1]), NegativeSetting(-1)), + (Value::SShort(vec![i16::MIN]), NegativeSetting(i16::MIN)), + (Value::Long(vec![1]), FieldType(4)), + (Value::Ascii("1".into()), FieldType(2)), + (Value::Short(vec![1, 1]), Count(2)), + (Value::Short(vec![0; 40]), Count(40)), + ] { + let f = read(value.clone()); + assert_eq!(prov(&f), (false, None, Rejected(error), Absent, None), "{value:?} {order:?}"); + } + } + } + + #[test] + fn malformed_raw_ifd_tables_are_refused_and_not_replaced_by_the_sr2_copy() { + let d = DISTORTION.table.0; + let cases = [ + (d, sshort(16, &[]), Count(1)), + (d, Value::SShort(vec![16, 0]), Count(2)), + (d, sshort(16, &TELE[..15]), Count(16)), + (d, Value::SShort(std::iter::once(16).chain(TELE).chain([0]).collect()), Count(18)), + (d, Value::SShort(vec![15; 17]), CountPrefix(15)), + (d, sshort(-16, &TELE), CountPrefix(-16)), + (d, sshort(0, &TELE), CountPrefix(0)), + (d, Value::Short(vec![16; 17]), FieldType(3)), + (d, Value::Long(vec![16; 17]), FieldType(4)), + (d, Value::Double(vec![f64::NAN; 17]), FieldType(12)), + (d, Value::Undefined(vec![16; 34]), FieldType(7)), + (SHADING.table.0, sshort(32, &[0; 32]), Count(33)), + (SHADING.table.0, Value::Short(std::iter::once(16).chain([0; 16]).collect()), FieldType(3)), + (CA.table.0, sshort(16, &ca()[..16]), Count(17)), + (CA.table.0, sshort(16, &ca()), CountPrefix(16)), + (CA.table.0, sshort(33, &ca()), CountPrefix(33)), + ]; + for order in [Little, Big] { + for (tag, value, error) in &cases { + let mut arw = Arw::new("ILCE-7RM4A", order); + set(&mut arw.plain, *tag, value.clone()); + let img = decode(&arw.build()); + let s = sony(&img); + let field = if *tag == SHADING.table.0 { + prov(&s.shading.table) + } else if *tag == CA.table.0 { + prov(&s.chromatic_aberration.table) + } else { + prov(&s.distortion.table) + }; + // the SR2SubIFD's good copy does not hide the corruption + assert_eq!(field, (false, None, Rejected(*error), Valid, None), "{tag:#x} {value:?} {order:?}"); + // the validated distortion needs its own raw-IFD table; a bad unrelated table doesn't remove it + assert_eq!(img.opcodes.list3.len(), usize::from(*tag != d), "{tag:#x} {value:?}"); + assert_eq!(s.distortion.status == SonyLensStatus::NoTable, *tag == d); + } + } + } + + #[test] + fn counts_and_offsets_beyond_the_data_are_reported_without_reading_them() { + for order in [Little, Big] { + let good = Arw::new("ILCE-7RM4A", order).build(); + let e = entry_at(&good, order, DISTORTION.table.0); + let len = good.len() as u32; + for (field, value, error) in [ + // counts: the TIFF reader drops an entry whose values would pass the end of the file + (4, 0x7fff_ffff, OutsideData), + (4, u32::MAX, OutsideData), + (4, 0, Count(0)), + (4, 16, Count(16)), + // value offsets past the end, straddling it, at it + (8, u32::MAX, OutsideData), + (8, len - 10, OutsideData), + (8, len, OutsideData), + ] { + let mut file = good.clone(); + file[e + field..e + field + 4].copy_from_slice(&word(order, value)); + let img = decode(&file); + let s = sony(&img); + assert_eq!(prov(&s.distortion.table), (false, None, Rejected(error), Valid, None), "{field} {value} {order:?}"); + assert!(img.opcodes.list3.is_empty()); + assert_eq!(s.distortion.status, SonyLensStatus::NoTable); + assert_eq!(s.shading.table.value, Some(SHADING_T)); + } + } + } + + #[test] + fn a_table_past_the_tiff_readers_entry_limit_is_not_applied() { + // The TIFF reader that decodes the image reads 8,192 entries of a directory; with 8,200 filler entries ahead + // of them the image never sees the lens fields, so they must not become a correction (main applies none). + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.size, arw.sony_crop, arw.default_crop, arw.sr2) = ((30, 20), None, None, None); + let reference = decode(&arw.build()); + assert_eq!(reference.opcodes.list3.len(), 1, "control: the same file without filler is corrected"); + arw.plain = (0..8200u16).map(|i| (0x5000 + i, Value::Byte(vec![0]))).chain(plain(&TELE, 0)).collect(); + let img = decode(&arw.build()); + assert!(img.opcodes.list3.is_empty(), "{order:?}: a table the TIFF reader never read was applied"); + // the directory scan still finds and reports it + let s = sony(&img); + assert_eq!(prov(&s.distortion.table), (false, None, Rejected(PastReaderLimits), Absent, None)); + assert_eq!(s.distortion.status, SonyLensStatus::NoTable); + } + } + + #[test] + fn a_repeated_tag_keeps_the_copy_the_tiff_reader_keeps() { + let crop = Rect::new(2, 2, 30, 20); + let warp = |table: &[i16; 16]| -> Vec { distortion("ILCE-7RM4A", table, Rect::new(0, 0, 32, 24), crop).into_iter().collect() }; + for order in [Little, Big] { + // a second distortion table (WIDE) right after the first (TELE), under a placeholder tag renamed to 0x7037 + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.push((0x7040, sshort(16, &WIDE))); + let good = arw.build(); + let mut tag = Vec::new(); + order.put_u16(&mut tag, DISTORTION.table.0); + let mut twice = good.clone(); + let e = entry_at(&twice, order, 0x7040); + twice[e..e + 2].copy_from_slice(&tag); + // both valid: the first one, as the TIFF reader keeps it and main applies it + let img = decode(&twice); + assert_eq!(img.opcodes.list3, warp(&TELE), "{order:?}: both copies valid"); + assert_eq!(sony(&img).distortion.table.value, Some(TELE)); + // reported as a diagnostic, which changes nothing else + let f = &sony(&img).distortion.table; + assert_eq!((f.raw_ifd_repeated, f.sr2_repeated, f.raw_ifd, f.copies_agree), (true, false, Valid, Some(true))); + assert_eq!(sony(&img).distortion.status, SonyLensStatus::Applied); + assert!(!sony(&decode(&good)).distortion.table.raw_ifd_repeated); + // the first one's values outside the file: the TIFF reader drops it and keeps the second + let mut dropped = twice.clone(); + let first = entry_at(&dropped, order, DISTORTION.table.0); + dropped[first + 8..first + 12].copy_from_slice(&word(order, u32::MAX)); + let img = decode(&dropped); + assert_eq!(img.opcodes.list3, warp(&WIDE), "{order:?}: first copy unreadable"); + assert_eq!(sony(&img).distortion.table.value, Some(WIDE)); + assert!(sony(&img).distortion.table.raw_ifd_repeated); + // the first one malformed (the setting's SHORT entry renamed): the TIFF reader keeps it, nothing applies + let mut short = good.clone(); + let e = entry_at(&short, order, DISTORTION.setting.0); + short[e..e + 2].copy_from_slice(&tag); + let img = decode(&short); + assert!(img.opcodes.list3.is_empty(), "{order:?}: first copy malformed"); + assert_eq!(sony(&img).distortion.table.raw_ifd, Rejected(FieldType(SHORT))); + } + } + + #[test] + fn sr2_copies_fill_in_only_missing_raw_ifd_fields_and_are_never_applied() { + let (raw, sr2) = (Some(SonySource::RawIfd), Some(SonySource::Sr2SubIfd)); + for order in [Little, Big] { + // nothing in the raw IFD: every field from the SR2SubIFD, decoded, not applied even on the validated + // model (only the raw IFD's table was validated) + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.clear(); + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(provenance(s), [(true, sr2, Absent, Valid, None); 6]); + assert_eq!( + (s.shading.table.value, s.chromatic_aberration.table.value, s.distortion.table.value), + (Some(SHADING_T), Some(ca_curves()), Some(TELE)) + ); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 0, meaning: SonySettingMeaning::Off })); + assert_eq!(s.distortion.status, SonyLensStatus::NotValidatedMode); + assert!(img.opcodes.list3.is_empty()); + // only the distortion table in the raw IFD: that one is used and applied, the rest comes from the SR2SubIFD + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.retain(|(tag, _)| *tag == DISTORTION.table.0); + let img = decode(&arw.build()); + let s = sony(&img); + let p = provenance(s); + assert_eq!(p[5], (true, raw, Valid, Valid, Some(true))); + assert!(p[..5].iter().all(|f| *f == (true, sr2, Absent, Valid, None)), "{p:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (SonyLensStatus::Applied, 1)); + // the raw IFD's tag numbers inside the SR2SubIFD are not its tables + let mut arw = Arw::new("ILCE-7CR", order); + arw.plain.clear(); + let plain_tags = [SHADING.setting.0, SHADING.table.0, CA.setting.0, CA.table.0, DISTORTION.setting.0, DISTORTION.table.0]; + arw.sr2 = Some(encrypted(&TELE, 0).into_iter().zip(plain_tags).map(|((_, kind, v), tag)| (tag, kind, v)).collect()); + assert_eq!(decode(&arw.build()).lens_metadata, LensMetadata::default()); + } + } + + #[test] + fn disagreeing_copies_are_reported_and_the_raw_ifd_wins() { + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.sr2 = Some(encrypted(&WIDE, 17)); + let img = decode(&arw.build()); + let s = sony(&img); + let raw = Some(SonySource::RawIfd); + assert_eq!(prov(&s.distortion.table), (true, raw, Valid, Valid, Some(false))); + assert_eq!(prov(&s.distortion.setting), (true, raw, Valid, Valid, Some(false))); + assert_eq!(prov(&s.shading.table), (true, raw, Valid, Valid, Some(true))); + assert_eq!(s.distortion.table.value, Some(TELE)); + assert_eq!(s.distortion.setting.value.map(|v| v.value), Some(0)); + // the validated conversion is unchanged: the raw IFD's table + let reference: Vec = distortion("ILCE-7RM4A", &TELE, Rect::new(0, 0, 32, 24), Rect::new(2, 2, 30, 20)).into_iter().collect(); + assert_eq!(img.opcodes.list3, reference); + } + } + + #[test] + fn unreadable_or_hostile_sr2_subifds_give_no_copies() { + let raw = Some(SonySource::RawIfd); + for order in [Little, Big] { + // stored with another key: the block decrypts to noise, no copy is found, nothing falls back on it + let mut arw = Arw::new("ILCE-7CR", order); + arw.stored_key = [1, 2, 3, 4]; + let img = decode(&arw.build()); + let s = sony(&img); + assert!(provenance(s).iter().all(|&(value, source, plain, sr2, agree)| value + && source == raw + && plain == Valid + && sr2 != Valid + && agree.is_none())); + arw.plain.clear(); + let img = decode(&arw.build()); + assert!(img.lens_metadata.sony.as_ref().is_none_or(|s| provenance(s).iter().all(|f| !f.0 && f.3 != Valid))); + // no SR2SubIFD; DNGPrivateData pointing past the file or into the image + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2 = None; + let s = sony(&decode(&arw.build())).clone(); + assert!(!s.sr2_read); + assert_eq!(provenance(&s), [(true, raw, Valid, Absent, None); 6]); + let good = Arw::new("ILCE-7CR", order).build(); + let at = Tiff::parse(&good).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + for pointer in [u32::MAX, good.len() as u32, 8] { + let mut file = good.clone(); + file[at..at + 4].copy_from_slice(&word(order, pointer)); + let img = decode(&file); + assert!(sony(&img).distortion.table.sr2 != Valid, "{pointer}"); + assert_eq!(sony(&img).distortion.table.value, Some(TELE)); + } + // hostile entries in a well-encrypted block (the distortion table is its sixth entry): another type, huge + // count, a value offset before the block or past it + let sixth = 2 + 12 * 5; + let put = move |at: usize, bytes: Vec| move |b: &mut [u8]| b[at..at + bytes.len()].copy_from_slice(&bytes); + let mut short = Vec::new(); + order.put_u16(&mut short, SHORT); + for (patch, error) in [ + (Box::new(put(sixth + 2, short.clone())) as Box, FieldType(SHORT)), + (Box::new(put(sixth + 4, word(order, u32::MAX))), Count(u64::from(u32::MAX))), + (Box::new(put(sixth + 4, word(order, 1))), Count(1)), + (Box::new(put(sixth + 8, word(order, 0))), OutsideData), + (Box::new(put(sixth + 8, word(order, u32::MAX))), OutsideData), + ] { + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2_patch = Some(patch); + let s = sony(&decode(&arw.build())).clone(); + assert_eq!(prov(&s.distortion.table), (true, raw, Valid, Rejected(error), None), "{error:?}"); + assert_eq!(prov(&s.shading.table), (true, raw, Valid, Valid, Some(true))); + } + // an entry count past the end of the block: the scan stops where the block does + let mut count = Vec::new(); + order.put_u16(&mut count, u16::MAX); + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2_patch = Some(Box::new(put(0, count))); + assert_eq!(provenance(sony(&decode(&arw.build()))), [(true, raw, Valid, Valid, Some(true)); 6]); + } + } + + #[test] + fn framing_and_coding_evidence_gate_the_validated_distortion() { + use SonyLensStatus::*; + let r = Rect::new; + for order in [Little, Big] { + let run = |arw: &Arw| { + let img = decode(&arw.build()); + let s = sony(&img).clone(); + (s, img) + }; + // Sony's crop at x = 0 and the default crop at x = 2 (the A7R IVA's 0 versus 32): the image keeps Sony's + // crop, the table is centred on and normalised to the default crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.sony_crop = Some([0, 2, 30, 20]); + let (s, img) = run(&arw); + let g = &s.geometry; + assert_eq!( + (g.sony_crop, g.default_crop, g.image_crop, g.reference), + (Some(r(0, 2, 30, 20)), Some(r(2, 2, 30, 20)), r(0, 2, 30, 20), r(2, 2, 30, 20)) + ); + assert_eq!((img.crop, s.distortion.status), (r(0, 2, 30, 20), Applied)); + let Some(Opcode::WarpRectilinear { center, .. }) = img.opcodes.list3.first() else { panic!("warp") }; + assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + // agreeing crops (the A7CR's case), in a frame padded beyond them (lossless L): the storage is recorded, + // the reference stays the crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.size = (40, 28); + let (s, _) = run(&arw); + assert_eq!((s.geometry.storage_width, s.geometry.storage_height, s.geometry.reference), (40, 28, r(2, 2, 30, 20))); + assert_eq!(s.distortion.status, Applied); + // without a default crop the reference is the image crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.default_crop = None; + let (s, _) = run(&arw); + assert_eq!((s.geometry.default_crop, s.geometry.reference, s.distortion.status), (None, r(2, 2, 30, 20), Applied)); + // non-3:2 framing: an aspect crop in Sony's tags, in the default crop, or a 4:3 frame without crop tags + for (sony_crop, default_crop) in + [(Some([2, 2, 30, 16]), Some([2, 2, 30, 20])), (Some([2, 2, 30, 20]), Some([2, 2, 30, 16])), (None, None)] + { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.sony_crop, arw.default_crop) = (sony_crop, default_crop); + let (s, img) = run(&arw); + assert!(!s.geometry.native_3_2, "{sony_crop:?} {default_crop:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (NotValidatedMode, 0), "{sony_crop:?} {default_crop:?}"); + } + // neutral, out-of-range and folding tables in the validated scope; models outside it + for (model, table, status) in [ + ("ILCE-7RM4A", [0; 16], Neutral), + ("ILCE-7RM4A", [i16::MIN; 16], Rejected), + ("ILCE-7RM4A", [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], Rejected), + ("ILCE-7RM4", TELE, NotValidatedModel), + ("ILCE-7CR", [0; 16], NotValidatedMode), + ("", TELE, NotValidatedModel), + ] { + let mut arw = Arw::new(model, order); + arw.plain = plain(&table, 1); + let (s, img) = run(&arw); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (status, 0), "{model} {table:?}"); + assert_eq!(s.distortion.table.value, Some(table)); + } + } + } + + #[test] + fn out_of_range_crop_tags_are_recorded_and_never_applied() { + for order in [Little, Big] { + // past the frame, enormous, empty, partly outside + for crop in [[99999, 0, 30, 20], [u32::MAX; 4], [2, 2, 0, 0], [30, 2, 30, 20]] { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.sony_crop, arw.default_crop) = (Some(crop), Some(crop)); + let img = decode(&arw.build()); + let s = sony(&img); + let [x, y, w, h] = crop.map(|v| v as usize); + assert_eq!((s.geometry.sony_crop, s.geometry.default_crop), (Some(Rect::new(x, y, w, h)), Some(Rect::new(x, y, w, h))), "{crop:?}"); + assert!(!s.geometry.native_3_2, "{crop:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (SonyLensStatus::NotValidatedMode, 0), "{crop:?}"); + assert_eq!(s.distortion.table.value, Some(TELE)); + } + } + } + + #[test] + fn exif_orientation_does_not_change_the_record() { + // the tables and the warp are in sensor coordinates; the engine orients the warp (`engine::files::embedded_lens`) + let reference = decode(&Arw::new("ILCE-7RM4A", Little).build()); + for orientation in 1..=8u16 { + let mut arw = Arw::new("ILCE-7RM4A", Little); + arw.plain.push((t::ORIENTATION, Value::Short(vec![orientation]))); + let img = decode(&arw.build()); + assert_eq!(img.orientation, crate::Orientation::from_exif(orientation)); + assert_eq!((&img.lens_metadata, &img.opcodes), (&reference.lens_metadata, &reference.opcodes), "{orientation}"); + } + } + + fn a7cr_geometry(storage: (usize, usize)) -> SonyLensGeometry { + let crop = Rect::new(32, 20, 9504, 6336); + SonyLensGeometry { + storage_width: storage.0, + storage_height: storage.1, + sony_crop: Some(crop), + default_crop: Some(crop), + image_crop: crop, + reference: crop, + compression: 7, + linear_ycbcr: false, + native_3_2: true, + } + } + + #[test] + fn a7cr_requires_an_enabled_setting_and_validated_full_frame_mode() { + use SonyLensStatus::*; + for order in [Little, Big] { + for (setting, status) in + [(0, CameraCorrectionDisabled), (1, Applied), (17, Applied), (255, CameraCorrectionDisabled), (2, CameraCorrectionDisabled)] + { + let mut arw = Arw::new("ILCE-7CR", order); + arw.plain = plain(&A7CR_WIDE, setting); + arw.sr2 = None; + let bytes = arw.build(); + let tiff = Tiff::parse(&bytes).unwrap(); + let run = |model: &str, geometry: SonyLensGeometry| { + let (record, warp) = corrections(&bytes, order, (&tiff.ifds[0], false), None, model, geometry); + (record.unwrap().distortion.status, warp.is_some()) + }; + let geometry = a7cr_geometry((9728, 6656)); + assert_eq!(run("ILCE-7CR", geometry.clone()), (status, status == Applied)); + for model in ["ILCE-7CR ", "ILCE-7CR unknown", "ILCE-7C", "ILCE-7CM2", "ILCE-7RM4", "ILCE-7RM6"] { + assert_eq!(run(model, geometry.clone()), (NotValidatedModel, false), "{model}"); + } + let mut linear = geometry.clone(); + linear.linear_ycbcr = true; + let mut apsc = geometry.clone(); + apsc.reference = Rect::new(32, 20, 6240, 4160); + apsc.image_crop = apsc.reference; + let mut aspect = geometry.clone(); + aspect.image_crop.height = 5346; + aspect.native_3_2 = false; + let mut offset = geometry.clone(); + offset.image_crop.x = 0; + for mode in [linear, apsc, aspect, offset] { + assert_eq!(run("ILCE-7CR", mode), (NotValidatedMode, false)); + } + // Missing or malformed settings cannot enable the correction. + for value in [None, Some(Value::SShort(vec![-1])), Some(Value::Long(vec![17]))] { + arw.plain.retain(|(tag, _)| *tag != DISTORTION.setting.0); + if let Some(value) = value { + arw.plain.push((DISTORTION.setting.0, value)); + } + let bytes = arw.build(); + let tiff = Tiff::parse(&bytes).unwrap(); + let (record, warp) = corrections(&bytes, order, (&tiff.ifds[0], false), None, "ILCE-7CR", geometry.clone()); + assert_eq!(record.unwrap().distortion.status, CameraCorrectionDisabled); + assert!(warp.is_none()); + } + } + } + } + + #[test] + fn a7cr_storage_padding_does_not_change_the_optical_mapping() { + let crop = Rect::new(32, 20, 9504, 6336); + let mut maps = Vec::new(); + for (w, h) in [(9600, 6376), (9728, 6656)] { + let Opcode::WarpRectilinear { planes, center } = a7cr_distortion(&A7CR_WIDE, Rect::new(0, 0, w, h), crop).unwrap() else { + panic!("warp") + }; + let (cx, cy) = (center[0] * (w - 1) as f64, center[1] * (h - 1) as f64); + assert!((cx - 4783.5).abs() < 1e-10 && (cy - 3187.5).abs() < 1e-10); + let radius = cx.max((w - 1) as f64 - cx).hypot(cy.max((h - 1) as f64 - cy)); + let k = planes[0]; + maps.push( + (0..=100) + .map(|j| { + let r = j as f64 / 100.0 * 5700.0; + let r2 = (r / radius).powi(2); + r * (k[0] + r2 * (k[1] + r2 * (k[2] + r2 * k[3]))) + }) + .collect::>(), + ); + } + for (a, b) in maps[0].iter().zip(&maps[1]) { + assert!((a - b).abs() < 1e-9); + } + for bad in [[0; 16], [i16::MIN; 16], [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] { + assert!(a7cr_distortion(&bad, Rect::new(0, 0, 9728, 6656), crop).is_none()); + } + assert!(a7cr_distortion(&A7CR_WIDE, AREA, crop).is_none(), "crop outside storage"); + assert!(a7cr_distortion(&A7CR_WIDE, AREA, Rect::new(0, 0, 6240, 4160)).is_none(), "APS-C"); + } + + #[test] + fn a7cr_matches_independently_measured_public_jpeg_geometry() { + // Green-channel feature fits, independent of the table, on the public FE 16-25mm (storytold/lightcraft#1300) + // and FE 50mm files. Check only the supported radii, not extrapolated corners. + let prime = [-4, -1, 3, 6, 12, 17, 23, 30, 37, 43, 50, 58, 65, 71, 76, 84]; + let cases = [ + (A7CR_WIDE, [1.0606515126386147, -0.24282593993968085, 0.1667903749262344, -0.059882313562606454], 0.93), + (prime, [0.9951622479344596, 0.00741813858793482, -0.002474290073462703, -0.000235147000260114], 0.96), + ]; + let crop = Rect::new(32, 20, 9504, 6336); + let radius = 9503f64.hypot(6335.0) / 2.0; + for (table, reference, supported) in cases { + let Opcode::WarpRectilinear { planes, center } = a7cr_distortion(&table, Rect::new(0, 0, 9728, 6656), crop).unwrap() else { + panic!("warp") + }; + let (cx, cy) = (center[0] * 9727.0, center[1] * 6655.0); + let normalization = cx.max(9727.0 - cx).hypot(cy.max(6655.0 - cy)); + let k = planes[0]; + let mut worst = 0.0f64; + for j in 0..=1000 { + let r = supported * j as f64 / 1000.0; + let rho = r * radius; + let r2 = (rho / normalization).powi(2); + let mapped = rho * (k[0] + r2 * (k[1] + r2 * (k[2] + r2 * k[3]))); + let expected = rho * (reference[0] + r * r * (reference[1] + r * r * (reference[2] + r * r * reference[3]))); + worst = worst.max((mapped - expected).abs() * 1440.0 / 9504.0); + } + // This smooth feature fit has localization noise. The combined budget allows the + // 0.75 px camera-table residual plus the separately bounded 0.25 px polynomial error. + assert!(worst < 1.0, "camera-JPEG shape residual {worst} px at 1440"); + } + } + + #[test] + fn linear_ycbcr_files_are_decoded_but_not_corrected() { + // a downsized lossless (M/S) coding, as in arw.rs's test, from the validated model with 3:2 crop tags + for order in [Little, Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7RM4A".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![15, 15, 15])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::YCBCR])); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(0x7010, Value::Short(vec![0; 4])); + raw.set(0x74c7, Value::Long(vec![0, 0])); + raw.set(0x74c8, Value::Long(vec![3, 2])); + for (tag, value) in plain(&TELE, 17) { + raw.set(tag, value); + } + raw.set_image(ImageData::Tiles { tile_width: 4, tile_height: 4, tiles: vec![crate::ljpeg::tests::fixture_420()] }); + let img = decode(&TiffWriter::new(order, false).write(&[raw]).unwrap()); + let s = sony(&img); + assert!(s.geometry.linear_ycbcr && s.geometry.native_3_2); + assert_eq!((s.geometry.compression, s.distortion.status, img.opcodes.list3.len()), (7, SonyLensStatus::NotValidatedMode, 0)); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 17, meaning: SonySettingMeaning::ForcedAuto })); + assert_eq!(s.distortion.table.value, Some(TELE)); + } + } + + #[test] + fn files_without_lens_fields_have_no_record() { + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.clear(); + arw.sr2 = None; + let img = decode(&arw.build()); + assert_eq!((img.lens_metadata.clone(), img.opcodes.list3.len()), (LensMetadata::default(), 0)); + // an SR2SubIFD holding other tags only + arw.sr2 = Some(vec![(0x7310, SHORT, vec![512; 4])]); + assert_eq!(decode(&arw.build()).lens_metadata, LensMetadata::default()); + // one field anywhere is a record + arw.sr2 = Some(vec![(DISTORTION.setting.1, SHORT, vec![255])]); + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 255, meaning: SonySettingMeaning::Unavailable })); + assert_eq!( + (s.distortion.status, s.shading.status, s.chromatic_aberration.status), + (SonyLensStatus::NoTable, SonyLensStatus::NoTable, SonyLensStatus::NoTable) + ); + } + } + + #[test] + fn corrupt_files_never_panic() { + for order in [Little, Big] { + let good = Arw::new("ILCE-7RM4A", order).build(); + let tiff = Tiff::parse(&good).unwrap(); + let strip = tiff.ifds[0].image().unwrap().chunks(good.len() as u64)[0]; + let image = strip.offset as usize..(strip.offset + strip.len) as usize; + let check = |file: &[u8]| { + let header = crate::arw::decode(file, Mode::Header, &Limits::default()); + let full = crate::arw::decode(file, Mode::Full, &Limits::default()); + if let (Ok(h), Ok(f)) = (header, full) { + assert_eq!((h.lens_metadata, h.opcodes), (f.lens_metadata, f.opcodes)); + } + }; + for len in 0..good.len() { + check(&good[..len]); + } + for at in (0..good.len()).filter(|at| !image.contains(at)) { + for v in [0, 0xff, good[at] ^ 0x80, good[at] ^ 0x01] { + let mut file = good.clone(); + file[at] = v; + check(&file); + } + } + } + } + + #[test] + fn matches_independent_sony_reference_geometry() { + // On/off feature correspondences from Imaging Edge 4.1 at 600 mm, independently fitted. + let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &TELE, AREA, AREA).unwrap() else { panic!("warp") }; + assert_eq!(center, [0.5, 0.5]); + let reference = [0.9760159044, 0.0254891644, -0.0005984764, -0.0010120638]; + for i in 0..=100 { + let r = i as f64 / 100.0; + let evaluate = |k: &[f64]| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))); + assert!((evaluate(&planes[0]) - evaluate(&reference)).abs() < 0.00015); + } + } + + // ILCE-7M3 raw-IFD tables (FE 16-35mm F4 ZA OSS at 16 and 35 mm, FE 28-70mm F3.5-5.6 OSS at 70 mm) and the + // geometry of the camera's own JPEG of the same exposure (Lens Comp. Distortion: Auto): checkerboard corners of + // that JPEG matched with the uncorrected raw and fitted with the warp's polynomial over the 6000×4000 frame (rms + // 0.11–0.25 px). See docs/sony-lens-corrections.md. + const A7M3: [([i16; 16], [f64; 4]); 3] = [ + ( + [32, 2, -38, -83, -138, -199, -270, -336, -402, -462, -519, -568, -611, -643, -670, -690], + [1.0247000859, -0.1092847508, 0.1061422243, -0.0397989678], + ), + ([2, 2, 4, 9, 18, 29, 45, 65, 92, 124, 164, 210, 264, 329, 403, 489], [0.9708150714, 0.0157256288, 0.0175613359, -0.004107544]), + ([4, 7, 18, 40, 68, 108, 155, 212, 280, 359, 453, 557, 679, 819, 978, 1161], [0.9342002611, 0.0560486347, 0.0076184603, 0.0021497933]), + ]; + + #[test] + fn ilce_7m3_matches_the_cameras_own_jpeg_geometry() { + let frame = Rect::new(0, 0, 6000, 4000); + let evaluate = |k: &[f64], r: f64| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))); + for (values, reference) in A7M3 { + let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7M3", &values, frame, frame).unwrap() else { panic!("warp") }; + assert_eq!(center, [0.5, 0.5]); + // The camera fills its frame with its own zoom; compare the shape over the measured radii (to 0.97). + let radii: Vec = (1..=97).map(|i| i as f64 / 100.0).collect(); + let (ours, camera): (Vec, Vec) = radii.iter().map(|&r| (evaluate(&planes[0], r), evaluate(&reference, r))).unzip(); + let worst = |ours: &[f64]| { + let zoom = ours.iter().zip(&camera).map(|(a, b)| a * b).sum::() / ours.iter().map(|a| a * a).sum::(); + ours.iter().zip(&camera).map(|(a, b)| (a * zoom - b).abs()).fold(0.0, f64::max) + }; + // 0.0008 of the half diagonal is 2.9 px on the 6000 px frame (measured: 0.4–2.4 px); uncorrected is 10×+ off. + let (corrected, uncorrected) = (worst(&ours), worst(&radii)); + assert!(corrected < 0.0008 && uncorrected > 10.0 * corrected, "{values:?}: {corrected} vs {uncorrected}"); + } + // Sony's crop and the default crop agree on this body (12, 12, 6000×4000 of 6048×4024), so the warp sits on + // the image centre. + let Opcode::WarpRectilinear { center, .. } = + distortion("ILCE-7M3", &A7M3[0].0, Rect::new(0, 0, 6048, 4024), Rect::new(12, 12, 6000, 4000)).unwrap() + else { + panic!("warp") + }; + assert!((center[0] * 6047.0 - 3011.5).abs() < 1e-9 && (center[1] * 4023.0 - 2011.5).abs() < 1e-9, "{center:?}"); + } + + #[test] + fn barrel_framing_and_offset_crop_keep_the_same_geometry() { + let crop = Rect::new(32, 20, 9504, 6336); + let active = Rect::new(0, 0, 9600, 6376); + let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &WIDE, AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &WIDE, active, crop).unwrap() else { panic!("warp") }; + assert!(a[0][0] > 1.02 && a[0][0] < 1.03, "barrel correction expands the middle"); + assert!((center[0] * 9599.0 - 4783.5).abs() < 1e-10); + assert!((center[1] * 6375.0 - 3187.5).abs() < 1e-10); + let ratio = (4815.5f64.hypot(3187.5)) / (4751.5f64.hypot(3167.5)); + for i in 0..4 { + assert!((a[0][i] - b[0][i] / ratio.powi(2 * i as i32)).abs() < 1e-10); + } + } + + /// The conversion's own refusals; missing and malformed layouts are refused by the decoder before it (see + /// `malformed_raw_ifd_tables_are_refused_and_not_replaced_by_the_sr2_copy`). + #[test] + fn refuses_unusable_tables() { + for bad in [[0; 16], [i16::MIN; 16], [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] { + assert!(distortion("ILCE-7RM4A", &bad, AREA, AREA).is_none()); + } + assert!(distortion("ILCE-7RM4A", &TELE, Rect::new(0, 0, 1, 1), AREA).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, Rect::new(0, 0, 9504, 5346)).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, Rect::new(99999, 0, 10, 10)).is_none()); + } + + #[test] + fn refuses_unvalidated_models_even_with_a_valid_table() { + assert!(distortion("ILCE-7RM4A", &TELE, AREA, AREA).is_some()); + assert!(distortion("ILCE-7M3", &TELE, AREA, AREA).is_some()); + for model in ["", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7CR", "ILCE-7M3 ", "ILCE-7SM3"] + { + assert!(distortion(model, &TELE, AREA, AREA).is_none(), "{model}"); + } + } + + #[test] + fn header_and_full_decode_preserve_correction_through_dng() { + for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 1), ("ILCE-7M4", 0), ("", 0)] { + for (height, expected) in [(20, expected), (16, 0)] { + for order in [ByteOrder::Little, ByteOrder::Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(model.into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); + 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::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set(0x74c7, Value::Long(vec![0, 2])); + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 2])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); + // An aspect crop must stay excluded even when DefaultCrop still describes 3:2. + raw.set(0x74c8, Value::Long(vec![30, height])); + raw.set(DISTORTION.table.0, Value::SShort(std::iter::once(16).chain(TELE).collect())); + // Zero pixels have the same byte representation in both orders. + raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); + let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); + let header = crate::arw::decode(&file, crate::Mode::Header, &Limits::default()).unwrap(); + let full = crate::arw::decode(&file, crate::Mode::Full, &Limits::default()).unwrap(); + assert_eq!(header.info(), full.info()); + assert_eq!(full.opcodes.list3.len(), expected, "{model} {order:?} height={height}"); + // the image keeps Sony's crop tags; the warp is centred on the DNG default crop (x 2..32 of 32) + assert_eq!(full.crop, Rect::new(0, 2, 30, height as usize)); + if let Some(Opcode::WarpRectilinear { center, .. }) = full.opcodes.list3.first() { + assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + } + #[cfg(feature = "dng")] + { + let dng = crate::write_dng(&full, &Default::default()).unwrap(); + assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3); + } + } + } + } + } +} diff --git a/crates/raw/src/arw_lens_rm6.rs b/crates/raw/src/arw_lens_rm6.rs new file mode 100644 index 000000000..d3e3b7275 --- /dev/null +++ b/crates/raw/src/arw_lens_rm6.rs @@ -0,0 +1,671 @@ +//! Sony ARW lens corrections from the tables in the raw IFD, as the equivalent DNG `OpcodeList3` opcodes, so they +//! reach the same "profile corrections" (`optics.lens_profile`) as a DNG's embedded warp. +//! +//! These are the ILCE-7RM6 conventions, measured against the camera JPEG. [`crate::arw_lens`] holds the ILCE-7RM4A +//! conventions, measured against Sony's Imaging Edge exports (unit 16384, the table indexed at the output radius). +//! Each is selected by camera model; no single unit fits every lens under either. See both docs: +//! docs/sony-ilce7rm6-lens-corrections.md (→ Two conventions) and docs/sony-lens-corrections.md. +//! +//! Sony records three count-prefixed signed 16-bit tables in the raw IFD, each with an in-camera on/off tag (ExifTool +//! Sony tag names): distortion [`DISTORTION_PARAMS`] (flag [`DISTORTION_FLAG`]), vignetting [`VIGNETTING_PARAMS`] +//! ([`VIGNETTING_FLAG`]) and lateral chromatic aberration [`CA_PARAMS`] ([`CA_FLAG`]). The first value is the knot +//! count: 16 / 16 / 32 in full-frame files of the ILCE-7RM6, 11 / 11 / 22 in its APS-C crop mode, the rest padding; +//! the CA table holds red's knots, then blue's. +//! +//! Their meaning was measured by registering blocks of the camera JPEG in each file on LightCraft's uncorrected render +//! (and raw data, for CA) over 54 ILCE-7RM6 photos with four lenses, 41 of them shot with in-camera distortion +//! correction on; nothing else (clean room). The conventions, with their worst residuals at a 1440 px long edge: +//! - knot `i` sits at sensor radius `i · s` from the default-crop centre, `s` = the body's full-frame default-crop +//! half diagonal / 15 (the same pixel spacing in APS-C mode: the knots are fixed on the sensor); linear in between, +//! the last segment extended beyond the last knot. +//! - distortion: a corrected radius `r` (px) reads the source radius `S(r) = r · (1 + v(r) / 15000)` (positive: +//! pincushion), and the camera then zooms (output radius = `z` · corrected radius) so the output fills the default +//! crop and reads only inside it: the largest `S(ρ/z)/ρ` over the crop border radii `ρ` is 1. 0.596 px over the 34 +//! measurable corrected photos (budget 0.75 px; corners and edge midpoints 0.570 px). +//! - `WarpRectilinear`'s 4-term odd polynomial fits that map within 2.35·10⁻⁴ of the DNG radius (0.220 px) on every +//! photo, at its 65 fit samples. A table it fits worse than 0.25 px (0.5 px on an APS-C body) at the samples the crop shows is refused (the +//! photo stays uncorrected rather than wrongly warped). Between samples the error is somewhat larger: 0.071 against +//! 0.049 px for the full-frame 16–35 mm example in the tests. +//! - vignetting: read but never applied. The camera JPEGs keep the uncorrected render's radial falloff (median +//! corner/centre ratio 0.996 over 39 photos) and no radial gain model fits the tables. +//! - lateral CA: red's (blue's) source radius is `g · (1 + c(g) / 3.36·10⁶)` with `g` the green source radius and `c` +//! the red (blue) half on the same knots. The unit is **provisional**: the measurement is borderline (0.399 px +//! worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ to 7.2·10⁶, about ±30 % scene bias), +//! but it cuts the rms raw-CFA CA over 46 photos from 0.079 to 0.052 px. The table is parsed and [`warp_opcode`] +//! converts it to the warp's R and B planes, but they are **not emitted by default**: [`lens_opcodes`] passes +//! `lateral_ca: false`, because applied after demosaic they made the render's CA worse (numbers there). To turn +//! them on in code, pass `true` there. When on, a CA value beyond [`CA_MAX_RELATIVE`] or a colour plane that fails +//! the fit gate above drops both colour planes, and G alone is applied to all three channels. +//! +//! The correction is produced whatever the in-camera flag says. Only bodies listed in [`FULL_FRAME_CROP`] or +//! [`APSC_BODIES`] get it: the unit and the knot spacing are verified on the ILCE-7RM6 and, for distortion, on the +//! ILCE-6700 (APS-C, 11 knots spanning its own crop, the same unit 15000: 0.73 px worst over 27 photos with three +//! lenses as rendered, 1.38 px at the 10–18 mm's 10 mm end). Another body is added by repeating the measurement on its photos shot +//! with distortion correction on and listing its full-frame default crop (or, for an APS-C body, its model). The method +//! and the numbers: docs/sony-ilce7rm6-lens-corrections.md. + +use crate::opcodes::max_radius; +use crate::shared::fit_radial_warp; +use crate::{Opcode, Rect}; +use craft_tiff::Ifd; +use std::ops::RangeInclusive; + +pub(crate) const DISTORTION_PARAMS: u16 = 0x7037; +pub(crate) const VIGNETTING_PARAMS: u16 = 0x7032; +pub(crate) const CA_PARAMS: u16 = 0x7035; +pub(crate) const DISTORTION_FLAG: u16 = 0x7036; +pub(crate) const VIGNETTING_FLAG: u16 = 0x7031; +pub(crate) const CA_FLAG: u16 = 0x7034; + +/// The bodies whose tables are verified (Exif `Model`), with their full-frame default crop (width, height) in px. +const FULL_FRAME_CROP: &[(&str, (f64, f64))] = &[("ILCE-7RM6", (9984.0, 6656.0))]; +/// The verified APS-C bodies (Exif `Model`): their knots span the file's own default crop, the last knot at its corner. +/// Measured on the ILCE-6700 (docs/sony-ilce7rm6-lens-corrections.md → ILCE-6700). +const APSC_BODIES: &[&str] = &["ILCE-6700"]; + +/// A lens whose tables follow its body's convention with another knot spacing and unit, at the focal lengths up to +/// `max_focal_mm`; measured like the body, against the camera JPEG. +struct LensException { + /// Exif `Model` of the body. + body: &'static str, + /// Exif `LensModel`, as the body records it. + lens: &'static str, + max_focal_mm: f64, + knot_spacing: f64, + unit: f64, +} + +/// The measured lens exceptions (docs/sony-ilce7rm6-lens-corrections.md → ILCE-6700). +const LENS_EXCEPTIONS: &[LensException] = &[ + // Sigma 10-18 mm F2.8 DC DN at its wide end: 97 photos at 10 mm and 2 at 10.4-10.5 mm. The body's rule leaves + // 1.36-1.38 px there; from 11.7 mm on the body's rule fits better. `lens` is the only LensModel in 336 files of + // this lens (native E-mount, no adapter); the 116 measured copies carry body firmware "ILCE-6700 v2.00" + LensException { body: "ILCE-6700", lens: "10-18mm F2.8 DC DN | Contemporary 023", max_focal_mm: 10.5, knot_spacing: 416.0, unit: 14250.0 }, +]; + +/// The exception for the body `model` with the lens `lens` at `focal_mm`, if one is measured. +fn lens_exception(model: &str, lens: Option<&str>, focal_mm: Option) -> Option<&'static LensException> { + let (lens, focal) = (lens?.trim(), focal_mm.filter(|f| f.is_finite() && *f > 0.0)?); + LENS_EXCEPTIONS.iter().find(|e| e.body == model.trim() && e.lens == lens && focal <= e.max_focal_mm) +} +/// The distortion table's value of 1 (relative radius); a global least-squares fit over the 34 photos gives 15000.7. +const DISTORTION_UNIT: f64 = 15000.0; +/// The CA table's value of 1 (relative to the green source radius). Provisional: the median best unit of the 20 +/// cleanest photos; per-photo units scatter from 1.5·10⁶ to 7.2·10⁶ (the measurement's scene bias is about ±30 %). +const CA_UNIT: f64 = 3.36e6; +/// Largest plausible CA value relative to the green source radius (|c| / [`CA_UNIT`]): about 12 px at the full-frame +/// corner; the largest value in the 54 measured photos' tables is 1724 (FE 24–70 mm), 1724 / 3.36·10⁶ ≈ 5.1·10⁻⁴. +/// A table beyond it is refused. +const CA_MAX_RELATIVE: f64 = 2e-3; +/// Crop border radii scanned by the frame-filling zoom. +const ZOOM_SCAN: usize = 4096; +/// Largest polynomial fit error accepted at the fit samples the crop shows, px at a 1440 px long edge. +const FIT_ERROR_PX_AT_1440: f64 = 0.25; +/// The fit gate of [`APSC_BODIES`]: their 11 knots over a smaller crop bend more sharply than the polynomial follows +/// (worst 0.44 px over the 30 measured ILCE-6700 tables; 13 of them exceed 0.25 px), while the fitted polynomial +/// matches the camera JPEG as well as the exact table (p95 0.22–0.73 px against 0.23–0.68 px, 10 mm aside). +const APSC_FIT_ERROR_PX_AT_1440: f64 = 0.5; + +/// A file's lens tables: the first `count` values of each (CA: red, blue); `None` when absent or malformed. +/// `vignetting` and `flags` are parsed for the tests and the docs only: don't build an opcode from them (the camera +/// applies no vignetting correction to its JPEGs, and the flags don't decide anything). +#[derive(Debug)] +// vignetting and the flags never become an opcode +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) struct LensTables { + pub distortion: Option>, + pub vignetting: Option>, + pub ca: Option<(Vec, Vec)>, + /// In-camera on/off of distortion, vignetting and CA correction, as recorded. + pub flags: [Option; 3], +} + +/// The values of a count-prefixed integer table whose count is in `counts` and fits the array. +fn table(raw: &Ifd, tag: u16, counts: RangeInclusive) -> Option> { + let v = raw.value(tag)?; + let count = usize::try_from(v.get_i64(0)?).ok().filter(|n| counts.contains(n) && *n < v.count())?; + (1..=count).map(|i| v.get_i64(i).map(|x| x as f64)).collect() +} + +/// The lens tables of the raw IFD `raw`. +pub(crate) fn read_tables(raw: &Ifd) -> LensTables { + let ca = table(raw, CA_PARAMS, 4..=32).filter(|v| v.len().is_multiple_of(2)).map(|v| { + let (red, blue) = v.split_at(v.len() / 2); + (red.to_vec(), blue.to_vec()) + }); + LensTables { + distortion: table(raw, DISTORTION_PARAMS, 2..=16), + vignetting: table(raw, VIGNETTING_PARAMS, 2..=16), + ca, + flags: [raw.i64(DISTORTION_FLAG), raw.i64(VIGNETTING_FLAG), raw.i64(CA_FLAG)], + } +} + +/// The knot spacing (px) of a verified body: a full-frame body's full-frame default-crop half diagonal / 15 (fixed on +/// the sensor, so in its APS-C crop mode too); an APS-C body's default-crop `crop` half diagonal / (`knots` − 1). Both +/// put the last knot at the corner of the crop the table was written for (the ILCE-7RM6's APS-C mode, 11 knots, gives +/// 392 px that way, which fits its files as well as the sensor-fixed 400 px). `None` for an unverified body, or an +/// APS-C body with fewer than two knots or a crop that isn't 3:2 (the only shape measured; empty crops included). +fn knot_spacing(model: &str, knots: usize, crop: Rect) -> Option { + let model = model.trim(); + if let Some(&(_, (w, h))) = FULL_FRAME_CROP.iter().find(|(m, _)| *m == model) { + return Some(w.hypot(h) / 30.0); + } + let gaps = knots.checked_sub(1).filter(|&n| n > 0)?; + let half_diagonal = (crop.width as f64).hypot(crop.height as f64) / 2.0; + (APSC_BODIES.contains(&model) && crate::arw_lens::is_3_2(crop)).then(|| half_diagonal / gaps as f64) +} + +/// The knot table at radius `r` px: knot `i` at `i · spacing`, linear in between, the last segment extended beyond +/// the last knot. NaN with fewer than two knots. +fn interpolate(knots: &[f64], spacing: f64, r: f64) -> f64 { + let t = r / spacing; + // `as` saturates, so a huge `t` lands on the last segment; NaN fails `t > 0` + let i = if t > 0.0 { (t as usize).min(knots.len().saturating_sub(2)) } else { 0 }; + match (knots.get(i), knots.get(i + 1)) { + (Some(a), Some(b)) => a + (b - a) * (t - i as f64), + _ => f64::NAN, + } +} + +/// The camera's frame-filling zoom `z` for the source radius map `source` and a crop of half size `x × y`: the +/// largest `source(ρ/z)/ρ` over the border radii `ρ` in [min(x, y), hypot(x, y)] is 1, so the output crop reads +/// only inside the source crop and touches it (at the corner for pincushion, the short-edge midpoint for barrel). +/// Bisection over [0.5, 2]; `None` when that range holds no solution or a ratio is not finite. +fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option { + let (near, far) = (x.min(y), x.hypot(y)); + let overshoots = |z: f64| -> Option { + let mut worst = f64::NEG_INFINITY; + for k in 0..=ZOOM_SCAN { + let rb = near + k as f64 * (far - near) / ZOOM_SCAN as f64; + let ratio = source(rb / z) / rb; + if !ratio.is_finite() { + return None; + } + worst = worst.max(ratio); + } + Some(worst > 1.0) + }; + let (mut lo, mut hi) = (0.5, 2.0); + if !overshoots(lo)? || overshoots(hi)? { + return None; + } + for _ in 0..100 { + let z = 0.5 * (lo + hi); + if overshoots(z)? { lo = z } else { hi = z } + } + Some(0.5 * (lo + hi)) +} + +/// The distortion and CA tables as a `WarpRectilinear` relative to the active area `active` (centred on the default +/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B with `lateral_ca`, or G +/// alone (applied to all three) without it, or when the CA table is absent, implausible or a colour plane's fit +/// fails. `None` when the distortion table is absent or +/// all zero (CA alone is not applied: it is relative to green), the zoom or the G fit fails. A fit fails when it is +/// worse than 0.25 px at a 1440 px long edge at one of the fit samples within the crop (a padded active area, as in +/// lossless files, reaches radii that are never shown). +pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect, lateral_ca: bool) -> Option { + gated_warp_opcode(t, knot_spacing, DISTORTION_UNIT, active, crop, lateral_ca, FIT_ERROR_PX_AT_1440) +} + +/// [`warp_opcode`] with the distortion unit `unit` instead of 15000 and the fit gate `fit_error_px` (px at a 1440 px +/// long edge) instead of 0.25 px. +fn gated_warp_opcode(t: &LensTables, knot_spacing: f64, unit: f64, active: Rect, crop: Rect, lateral_ca: bool, fit_error_px: f64) -> Option { + let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?; + let (aw, ah) = (active.width as f64, active.height as f64); + if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 { + return None; + } + let (cw, ch) = (crop.width as f64, crop.height as f64); + let source = |r: f64| r * (1.0 + interpolate(v, knot_spacing, r) / unit); + let z = fill_zoom(&source, cw / 2.0, ch / 2.0)?; + // DNG's normalisation: pixel-index units, radius 1 = the farthest active-area corner + let (cx, cy) = (crop.x as f64 + (cw - 1.0) / 2.0, crop.y as f64 + (ch - 1.0) / 2.0); + let m = max_radius(cx, cy, aw - 1.0, ah - 1.0); + let max_fit_error = fit_error_px * (cw.max(ch) / 1440.0) / m; + let shown = (cw / 2.0).hypot(ch / 2.0) / m; + // output radius (px) → green source radius + let green = |r: f64| source(r / z); + let plane = |map: &dyn Fn(f64) -> f64| { + let [k0, k1, k2, k3] = fit_radial_warp(&|u| map(u * m) / m, max_fit_error, shown)?; + Some([k0, k1, k2, k3, 0.0, 0.0]) + }; + let g = plane(&green)?; + // red and blue: relative to the green source radius, at that radius + let colour = |c: &[f64]| { + plane(&|r| { + let s = green(r); + s * (1.0 + interpolate(c, knot_spacing, s) / CA_UNIT) + }) + }; + // a NaN fails the comparison: refused too + let plausible = |c: &[f64]| c.iter().all(|v| v.abs() <= CA_MAX_RELATIVE * CA_UNIT); + let rgb = t.ca.as_ref().filter(|(red, blue)| lateral_ca && plausible(red) && plausible(blue)); + let rgb = rgb.and_then(|(red, blue)| Some(vec![colour(red)?, g, colour(blue)?])); + Some(Opcode::WarpRectilinear { planes: rgb.unwrap_or_else(|| vec![g]), center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) +} + +/// The `OpcodeList3` lens corrections of a Sony raw IFD for the body `model` (Exif `Model`); empty for a body whose +/// tables are not verified. +/// +/// Distortion only: the lateral CA planes are off (`lateral_ca: false`). Our demosaic already hides about 70 % of the +/// lens CA (the render shows 0.29 × the CA measured on the raw CFA), while the warp, which runs after demosaic, moves +/// R and B by 0.84 × the table. Over the 43 verified photos with enough corner detail, the render's corner R/G and B/G +/// CA went from rms 0.032 / max 0.125 px (off) to 0.052 / 0.219 px (on) at a 1440 px long edge; only the 16–35 mm +/// improved. Follow-up: warp the CFA colour planes before demosaic, then turn the planes on here. +/// +/// `lens` (Exif `LensModel`) and `focal_mm` (Exif `FocalLength`) select a measured lens exception's spacing and unit. +pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, lens: Option<&str>, focal_mm: Option, active: Rect, crop: Rect) -> Vec { + let tables = read_tables(raw); + let knots = tables.distortion.as_ref().map_or(0, Vec::len); + let Some(spacing) = knot_spacing(model, knots, crop) else { return Vec::new() }; + let op = if let Some(e) = lens_exception(model, lens, focal_mm) { + gated_warp_opcode(&tables, e.knot_spacing, e.unit, active, crop, false, APSC_FIT_ERROR_PX_AT_1440) + } else if APSC_BODIES.contains(&model.trim()) { + gated_warp_opcode(&tables, spacing, DISTORTION_UNIT, active, crop, false, APSC_FIT_ERROR_PX_AT_1440) + } else { + warp_opcode(&tables, spacing, active, crop, false) + }; + op.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use craft_tiff::{Entry, Value}; + + /// The FF 16–35 mm example (corpus `arw-sony-ilce7rm6-ff-land-lossless.arw`, docs/sony-ilce7rm6-lens-corrections.md). + const FF_DISTORTION: [i16; 17] = [16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]; + const FF_VIGNETTING: [i16; 17] = [16, 0, 80, 336, 672, 1120, 1648, 2240, 2880, 3600, 4336, 5104, 5872, 6640, 7392, 8160, 8976]; + const FF_CA: [i16; 33] = [ + 32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, -6, 20, 42, + 56, 60, 54, 32, + ]; + /// The CA table of corpus `arw6-sony-ilce7rm6-ff-land-hq.arw` (the same scene; same distortion table). + const FF_HQ_CA: [i16; 33] = [ + 32, 242, 234, 222, 208, 192, 172, 152, 128, 106, 82, 62, 42, 26, 12, 4, 2, -270, -254, -234, -208, -180, -146, -112, -72, -38, -4, 22, 44, + 58, 64, 56, 36, + ]; + /// The APS-C example (corpus `arw6-sony-ilce7rm6-apsc-land-hq.arw`): counts 11 / 11 / 22, then padding. + const APSC_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -512, -583, -583, -583, -583, -583, -583]; + const APSC_VIGNETTING: [i16; 17] = [11, 0, 48, 208, 496, 880, 1360, 1952, 2624, 3568, 4832, 6176, 0, 0, 0, 0, 0]; + const APSC_CA: [i16; 33] = [ + 22, 790, 764, 734, 702, 666, 624, 582, 544, 510, 484, 470, -432, -404, -372, -330, -286, -230, -174, -114, -58, -6, 34, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; + /// The APS-C lossless corpus file `arw-sony-ilce7rm6-apsc-land-lossless.arw` (same scene; its tables differ slightly). + const APSC_LOSSLESS_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -511, -583, -583, -583, -583, -583, -583]; + const APSC_LOSSLESS_CA: [i16; 33] = [ + 22, 792, 766, 738, 706, 670, 630, 588, 548, 514, 488, 476, -436, -408, -376, -334, -290, -234, -178, -118, -62, -12, 30, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; + /// ARW6 geometry (the decoder's raw image size and default crop). + const FF_ACTIVE: Rect = Rect { x: 0, y: 0, width: 10016, height: 6672 }; + const FF_CROP: Rect = Rect { x: 12, y: 8, width: 9984, height: 6656 }; + const APSC_ACTIVE: Rect = Rect { x: 0, y: 0, width: 6592, height: 4372 }; + const APSC_CROP: Rect = Rect { x: 16, y: 10, width: 6528, height: 4352 }; + /// The G planes of the examples on their ARW6 geometry, and the R and B planes with each file's CA table + /// (docs/sony-ilce7rm6-lens-corrections.md). + const FF_G: [f64; 4] = [0.9714495823, 0.022508032, 0.0006090085237, 0.005683284548]; + const FF_HQ_R: [f64; 4] = [0.9715153129, 0.02236266369, 0.0007060224765, 0.005666341563]; + const FF_HQ_B: [f64; 4] = [0.97137946, 0.02277016389, 0.0003830082563, 0.005727820598]; + const APSC_G: [f64; 4] = [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468]; + const APSC_R: [f64; 4] = [1.016503991, -0.06206460153, 0.0278447625, -0.005644118171]; + const APSC_B: [f64; 4] = [1.016155702, -0.06162477627, 0.02753315326, -0.005554915901]; + + fn knots() -> f64 { + knot_spacing("ILCE-7RM6", 16, FF_CROP).unwrap() + } + + fn ifd(tags: Vec<(u16, Value)>) -> Ifd { + let mut entries: Vec = tags.into_iter().map(|(tag, value)| Entry { tag, value, offset: 0 }).collect(); + entries.sort_by_key(|e| e.tag); + Ifd { entries, ..Default::default() } + } + + fn sony_lens_ifd(distortion: &[i16], vignetting: &[i16], ca: &[i16], flags: [u16; 3]) -> Ifd { + ifd(vec![ + (DISTORTION_PARAMS, Value::SShort(distortion.to_vec())), + (VIGNETTING_PARAMS, Value::SShort(vignetting.to_vec())), + (CA_PARAMS, Value::SShort(ca.to_vec())), + (DISTORTION_FLAG, Value::Short(vec![flags[0]])), + (VIGNETTING_FLAG, Value::Short(vec![flags[1]])), + (CA_FLAG, Value::Short(vec![flags[2]])), + ]) + } + + fn tables_with_distortion(v: &[f64]) -> LensTables { + LensTables { distortion: Some(v.to_vec()), vignetting: None, ca: None, flags: [None; 3] } + } + + fn values(table: &[i16]) -> Vec { + table[1..=table[0] as usize].iter().map(|&v| v as f64).collect() + } + + /// The warp's planes and centre against the recipe's numbers (docs/sony-ilce7rm6-lens-corrections.md); returns the planes. + fn assert_warp(op: Option, center: [f64; 2], k: &[[f64; 4]]) -> Vec<[f64; 6]> { + let Some(Opcode::WarpRectilinear { planes, center: c }) = op else { panic!("no warp: {op:?}") }; + assert_eq!(planes.len(), k.len()); + assert!((c[0] - center[0]).abs() < 1e-9 && (c[1] - center[1]).abs() < 1e-9, "centre {c:?}, want {center:?}"); + for (p, k) in planes.iter().zip(k) { + for (i, want) in k.iter().enumerate() { + assert!((p[i] - want).abs() < 1e-6, "k{i} {}, want {want} ({p:?})", p[i]); + } + assert_eq!((p[4], p[5]), (0.0, 0.0), "radial only"); + } + planes + } + + #[test] + fn reads_the_three_tables_and_flags() { + let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1])); + let d = t.distortion.unwrap(); + assert_eq!((d.len(), d[0], d[15]), (16, 0.0, 479.0)); + let v = t.vignetting.unwrap(); + assert_eq!((v.len(), v[1], v[15]), (16, 80.0, 8976.0)); + let (red, blue) = t.ca.unwrap(); + assert_eq!((red.len(), red[0], red[15]), (16, 242.0, 4.0)); + assert_eq!((blue.len(), blue[0], blue[15]), (16, -276.0, 32.0)); + assert_eq!(t.flags, [Some(0), Some(2), Some(1)]); + } + + #[test] + fn apsc_tables_use_only_the_first_count_values() { + let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); + let d = t.distortion.unwrap(); + assert_eq!((d.len(), d[10]), (11, -583.0)); + assert_eq!(t.vignetting.unwrap().len(), 11); + let (red, blue) = t.ca.unwrap(); + assert_eq!((red.len(), red[10]), (11, 470.0)); + assert_eq!((blue.len(), blue[0], blue[10]), (11, -432.0, 34.0)); + } + + #[test] + fn missing_or_malformed_tables_yield_no_opcode() { + let mut short = FF_DISTORTION.to_vec(); + short.pop(); + let mut count_17 = FF_DISTORTION.to_vec(); + count_17[0] = 17; + count_17.push(500); + let mut too_large = FF_DISTORTION.to_vec(); + too_large[0] = 600; + for (what, table) in [ + ("count 15 with 3 values", vec![15, 0, 2, 7]), + ("count 17", count_17), + ("empty", vec![]), + ("count 16 with 15 values", short), + ("count 1", vec![1, 5]), + ("count 0", vec![0, 5, 6]), + ("negative count", vec![-16, 0, 2, 7]), + ("count larger than the array", too_large), + ] { + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(table))]); + let t = read_tables(&ifd); + assert!(t.distortion.is_none(), "{what}"); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}"); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", None, None, FF_ACTIVE, FF_CROP).is_empty(), "{what}"); + } + // not an integer array, or absent + for value in [Value::Ascii("16 0 2 7".into()), Value::Double(vec![2.0, 0.0, 1.0])] { + assert!(read_tables(&ifd(vec![(DISTORTION_PARAMS, value)])).distortion.is_none()); + } + let none = read_tables(&ifd(vec![])); + assert!(none.distortion.is_none() && none.vignetting.is_none() && none.ca.is_none() && none.flags == [None; 3]); + // the CA table needs an even count of 4..=32 + for ca in [vec![3, 1, 2, 3], vec![34; 35], vec![2, 1, 2]] { + assert!(read_tables(&ifd(vec![(CA_PARAMS, Value::SShort(ca))])).ca.is_none()); + } + } + + #[test] + fn hostile_tables_are_rejected() { + let folded = [0.0, 2000.0, 1000.0, 3000.0, 2000.0, 4000.0, 3000.0, 5000.0, 4000.0, 6000.0, 5000.0, 7000.0, 6000.0, 8000.0, 7000.0, 9000.0]; + for (what, table) in [ + ("huge", vec![(i64::MAX / 4) as f64; 16]), + ("most negative", vec![i64::MIN as f64; 16]), + ("zig-zag", folded.to_vec()), + ("all zero", vec![0.0; 16]), + ("one knot", vec![100.0]), + ("no knots", vec![]), + ("not finite", vec![0.0, f64::NAN, 20.0]), + ("infinite", vec![0.0, f64::INFINITY, 20.0]), + ] { + assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}"); + } + // the same through the IFD, as 64-bit values + for v in [i64::MAX / 4, i64::MIN, i64::MAX] { + let mut table = vec![v; 17]; + table[0] = 16; + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SLong8(table))]); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", None, None, FF_ACTIVE, FF_CROP).is_empty(), "{v}"); + } + // degenerate geometry + let ff = tables_with_distortion(&values(&FF_DISTORTION)); + for (what, knots, active, crop) in [ + ("empty crop", knots(), FF_ACTIVE, Rect::new(12, 8, 0, 6656)), + ("1 × 1 active area", knots(), Rect::new(0, 0, 1, 1), Rect::new(0, 0, 1, 1)), + ("knot spacing NaN", f64::NAN, FF_ACTIVE, FF_CROP), + ("knot spacing 0", 0.0, FF_ACTIVE, FF_CROP), + ] { + assert!(warp_opcode(&ff, knots, active, crop, false).is_none(), "{what}"); + } + } + + #[test] + fn ff_example_table_becomes_the_measured_warp() { + let t = tables_with_distortion(&values(&FF_DISTORTION)); + assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false), [0.4996005991, 0.5], &[FF_G]); + } + + #[test] + fn apsc_example_table_becomes_the_measured_warp() { + let t = tables_with_distortion(&values(&APSC_DISTORTION)); + let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, false); + assert_warp(op, [0.4975724473, 0.5], &[APSC_G]); + } + + #[test] + fn ca_table_shifts_red_and_blue_planes() { + let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [0, 2, 1])); + let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]); + assert_ne!(planes[0][0], planes[1][0]); + // G is the distortion plane alone + let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false); + assert!(matches!(g, Some(Opcode::WarpRectilinear { planes: ref p, .. }) if p == &[planes[1]]), "{g:?}"); + } + + #[test] + fn apsc_ca_planes_match_the_measured_warp() { + let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); + assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, true), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]); + } + + #[test] + fn ca_without_distortion_is_not_applied() { + let ifd = ifd(vec![(CA_PARAMS, Value::SShort(FF_HQ_CA.to_vec()))]); + let t = read_tables(&ifd); + assert!(t.ca.is_some() && t.distortion.is_none()); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true).is_none()); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", None, None, FF_ACTIVE, FF_CROP).is_empty()); + // an all-zero distortion table counts as absent + let zero = LensTables { ca: t.ca, ..tables_with_distortion(&[0.0; 16]) }; + assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP, true).is_none()); + } + + #[test] + fn rejected_ca_keeps_the_single_green_plane() { + let green = assert_warp( + warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false), + [0.4996005991, 0.5], + &[FF_G], + ); + let ok = values(&FF_HQ_CA); + let huge = vec![(i64::MAX / 4) as f64; 16]; + for (what, red, blue) in [ + ("huge red", huge.clone(), ok[16..].to_vec()), + ("huge blue", ok[..16].to_vec(), huge), + ("most negative blue", ok[..16].to_vec(), vec![i64::MIN as f64; 16]), + ("not finite red", vec![0.0, f64::NAN, 20.0], ok[16..].to_vec()), + ("one knot", vec![100.0], vec![100.0]), + ] { + let t = LensTables { ca: Some((red, blue)), ..tables_with_distortion(&values(&FF_DISTORTION)) }; + let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true) else { panic!("{what}: no warp") }; + assert_eq!(planes, green, "{what}"); + } + // through the IFD: an odd count, or values that overflow the fit + let mut odd = FF_HQ_CA.to_vec(); + odd[0] = 31; + let mut huge_ifd = vec![i64::MAX / 4; 33]; + huge_ifd[0] = 32; + // a constant 32767 (the SShort maximum) fits as well as G does and would shift red ~59 px at the full-res + // corner: refused as implausible (bound 2·10⁻³ relative = 6720) + let mut max = vec![32767; 33]; + max[0] = 32; + for (what, ca) in [("odd count", Value::SShort(odd)), ("huge", Value::SLong8(huge_ifd)), ("constant 32767", Value::SShort(max))] { + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, ca)]); + let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes == &green), "{what}: {op:?}"); + } + // values up to the bound are kept + let mut at_bound = vec![6720; 33]; + at_bound[0] = 32; + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, Value::SShort(at_bound))]); + let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{op:?}"); + } + + /// The default: a valid CA table is read and converts (above), but `lens_opcodes` emits the G plane alone. + #[test] + fn lens_opcodes_emit_the_green_plane_only() { + let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [1, 2, 1]); + let with_ca = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(with_ca, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{with_ca:?}"); + let ops = lens_opcodes(&ifd, "ILCE-7RM6", None, None, FF_ACTIVE, FF_CROP); + let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() else { panic!("{ops:?}") }; + assert_eq!(planes.len(), 1); + let green = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false); + assert_eq!(ops.first(), green.as_ref()); + } + + /// The corpus lossless files' own tables on their decoded geometry (padded active areas; the corpus test checks + /// the G plane from the files): R, G, B with the CA planes enabled. + #[test] + fn lossless_examples_ca_planes_match_the_measured_warp() { + let ff = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1])); + let ff_active = Rect { x: 0, y: 0, width: 10240, height: 7168 }; + assert_warp( + warp_opcode(&ff, knots(), ff_active, FF_CROP, true), + [0.488670768629749, 0.4653969582810102], + &[ + [0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + [0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947], + ], + ); + let apsc = read_tables(&sony_lens_ifd(&APSC_LOSSLESS_DISTORTION, &APSC_VIGNETTING, &APSC_LOSSLESS_CA, [0, 2, 1])); + let apsc_active = Rect { x: 0, y: 0, width: 6656, height: 4608 }; + assert_warp( + warp_opcode(&apsc, knots(), apsc_active, APSC_CROP, true), + [0.49278737791134486, 0.4743868026915563], + &[ + [1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + [1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897], + ], + ); + } + + /// The lossless files' padded active area (10240 × 7168) puts the farthest active-area corner (DNG radius 1) well + /// beyond the crop's (u 0.925): the gate looks only at the radii the crop shows. The FF example × 1.5 fits within + /// 0.12 px at 1440 at the samples there, 0.27 px at all 65. + #[test] + fn padded_active_area_gates_only_the_displayed_radii() { + let strong: Vec = values(&FF_DISTORTION).iter().map(|v| v * 1.5).collect(); + let padded = Rect { x: 0, y: 0, width: 10240, height: 7168 }; + let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP, false); + assert!(matches!(op, Some(Opcode::WarpRectilinear { .. })), "{op:?}"); + } + + /// An ILCE-6700 file (Sigma 10–18 mm at 10 mm): 11 knots, the strongest table of the measured set. + const A6700_DISTORTION: [i16; 17] = [11, 90, 11, -90, -205, -345, -500, -676, -852, -1031, -1190, -1333, -1333, -1333, -1333, -1333, -1333]; + const A6700_CROP: Rect = Rect { x: 0, y: 0, width: 6192, height: 4128 }; + /// An ILCE-6700 file (Tamron 17–70 mm at 32 mm), on the decoded geometry: the polynomial fits it within 0.44 px. + const A6700_32MM_DISTORTION: [i16; 17] = [11, 7, 0, 7, 29, 58, 101, 158, 230, 323, 446, 611, 611, 611, 611, 611, 611]; + const A6700_ACTIVE: Rect = Rect { x: 0, y: 0, width: 6656, height: 4608 }; + const A6700_DECODED_CROP: Rect = Rect { x: 26, y: 20, width: 6192, height: 4128 }; + + #[test] + fn apsc_body_knots_span_its_own_crop() { + // half diagonal 3720.9 px / 10 + let s = knot_spacing("ILCE-6700", 11, A6700_CROP).unwrap(); + assert!((s - 372.0929).abs() < 1e-3, "{s}"); + // the full-frame body keeps its sensor-fixed spacing whatever the crop and count + assert_eq!(knot_spacing("ILCE-7RM6", 11, APSC_CROP), knot_spacing("ILCE-7RM6", 16, FF_CROP)); + // degenerate counts and crops give no spacing instead of a division by zero + assert_eq!(knot_spacing("ILCE-6700", 1, A6700_CROP), None); + assert_eq!(knot_spacing("ILCE-6700", 0, A6700_CROP), None); + assert_eq!(knot_spacing("ILCE-6700", 11, Rect { x: 0, y: 0, width: 0, height: 0 }), None); + // the spacing comes from the crop, so a crop of another shape than the measured 3:2 gets no correction + let square = Rect { width: A6700_CROP.height, ..A6700_CROP }; + assert_eq!(knot_spacing("ILCE-6700", 11, square), None); + let ifd = sony_lens_ifd(&A6700_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [1, 2, 1]); + let ops = lens_opcodes(&ifd, "ILCE-6700", None, None, A6700_CROP, A6700_CROP); + let gated = gated_warp_opcode(&read_tables(&ifd), s, DISTORTION_UNIT, A6700_CROP, A6700_CROP, false, APSC_FIT_ERROR_PX_AT_1440); + assert_eq!(ops, gated.into_iter().collect::>()); + assert_eq!(ops.len(), 1); + // other APS-C bodies stay unverified + assert!(lens_opcodes(&ifd, "ILCE-6600", None, None, A6700_CROP, A6700_CROP).is_empty()); + } + + /// The APS-C fit gate: a table the polynomial fits within 0.44 px is applied on the ILCE-6700, while the + /// full-frame 0.25 px gate refuses it. + #[test] + fn apsc_body_uses_its_wider_fit_gate() { + let ifd = sony_lens_ifd(&A6700_32MM_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [1, 2, 1]); + let t = read_tables(&ifd); + let s = knot_spacing("ILCE-6700", 11, A6700_DECODED_CROP).unwrap(); + assert_eq!(warp_opcode(&t, s, A6700_ACTIVE, A6700_DECODED_CROP, false), None); + let ops = lens_opcodes(&ifd, "ILCE-6700", None, None, A6700_ACTIVE, A6700_DECODED_CROP); + assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{ops:?}"); + } + + const SIGMA_10_18: &str = "10-18mm F2.8 DC DN | Contemporary 023"; + + /// The Sigma 10-18 mm exception: its spacing and unit at 10-10.5 mm, the body's rule from 11.7 mm (and without a + /// lens or focal length, on another body or another lens). + #[test] + fn lens_exception_applies_only_to_its_lens_body_and_focal_range() { + let ifd = sony_lens_ifd(&A6700_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [1, 2, 1]); + let t = read_tables(&ifd); + let geometry = (A6700_ACTIVE, A6700_DECODED_CROP); + let exception = gated_warp_opcode(&t, 416.0, 14250.0, geometry.0, geometry.1, false, APSC_FIT_ERROR_PX_AT_1440); + let body = lens_opcodes(&ifd, "ILCE-6700", None, None, geometry.0, geometry.1); + assert!(exception.is_some() && body.len() == 1); + assert_ne!(body.first(), exception.as_ref()); + for focal in [10.0, 10.5] { + let ops = lens_opcodes(&ifd, "ILCE-6700", Some(SIGMA_10_18), Some(focal), geometry.0, geometry.1); + assert_eq!(ops.first(), exception.as_ref(), "{focal} mm"); + } + for (lens, focal) in [(Some(SIGMA_10_18), Some(11.7)), (Some(SIGMA_10_18), None), (None, Some(10.0)), (Some("E 10-20mm F4 PZ G"), Some(10.0))] + { + let ops = lens_opcodes(&ifd, "ILCE-6700", lens, focal, geometry.0, geometry.1); + assert_eq!(ops, body, "{lens:?} {focal:?}"); + } + // hostile focal lengths fall back to the body's rule + for focal in [f64::NAN, f64::INFINITY, -10.0, 0.0] { + assert_eq!(lens_opcodes(&ifd, "ILCE-6700", Some(SIGMA_10_18), Some(focal), geometry.0, geometry.1), body); + } + assert_eq!(lens_exception("ILCE-6600", Some(SIGMA_10_18), Some(10.0)).map(|e| e.unit), None); + } + + #[test] + fn unknown_model_gets_no_opcodes() { + let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1]); + assert!(lens_opcodes(&ifd, "ILCE-7M4", None, None, FF_ACTIVE, FF_CROP).is_empty()); + assert!(lens_opcodes(&ifd, "", None, None, FF_ACTIVE, FF_CROP).is_empty()); + // the camera's in-camera flag (0 = off here) doesn't matter: the table is applied + let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", None, None, FF_ACTIVE, FF_CROP); + assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, false).into_iter().collect::>()); + assert_eq!(ops.len(), 1); + } +} diff --git a/crates/raw/src/cr2.rs b/crates/raw/src/cr2.rs index 14cd190c9..79f38ae6b 100644 --- a/crates/raw/src/cr2.rs +++ b/crates/raw/src/cr2.rs @@ -296,6 +296,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result Result Result> { ifd.set(t::BLACK_LEVEL_DELTA_V, Value::SRational(b.delta_v.iter().map(|&v| srational(v as f64)).collect())); } let white: Vec = (0..cpp).map(|s| raw.white_at(s).round().clamp(0.0, u32::MAX as f32) as u32).collect(); + let max_white = white.iter().copied().max().unwrap_or(0); if matches!(raw.data, RawData::F32(_)) { ifd.set(t::WHITE_LEVEL, rat_vec(&(0..cpp).map(|s| raw.white_at(s) as f64).collect::>())); } else { @@ -216,8 +217,9 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![bytes] }); } (RawData::U16(v), _) => { - let maxv = v.iter().copied().max().unwrap_or(0); - let bits = (16 - maxv.leading_zeros()).max(raw.bits.min(16)).max(2) as u8; + // Dark frames may never reach the sensor's white level, which must still fit. + let maxv = u32::from(v.iter().copied().max().unwrap_or(0)).max(max_white); + let bits = (32 - maxv.leading_zeros()).max(raw.bits).clamp(2, 16) as u8; ifd.set( t::BITS_PER_SAMPLE, Value::Short(vec![if matches!(opts.compression, DngCompression::Uncompressed) { 16 } else { bits as u16 }; cpp]), @@ -458,6 +460,7 @@ mod tests { opcodes: OpcodeLists::default(), camera: Default::default(), cinema: Default::default(), + lens_metadata: Default::default(), }; let opts = |tile: u32| DngWriteOptions { compression: DngCompression::Deflate { tile, half: false }, ..DngWriteOptions::default() }; let hostile = write_dng(&raw, &opts(u32::MAX)).expect("the clamped tile writes"); diff --git a/crates/raw/src/erf.rs b/crates/raw/src/erf.rs index ff06dabf0..9828e0cbf 100644 --- a/crates/raw/src/erf.rs +++ b/crates/raw/src/erf.rs @@ -123,6 +123,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result Result, +} + +/// Sony's in-file lens-correction record: three independent components, each a camera setting and a radial table. +/// +/// Tag identities and storage types from the ExifTool EXIF / Sony tag-name documentation: raw-IFD `0x7031` / `0x7032` +/// (shading, "VignettingCorrection" / "VignettingCorrParams"), `0x7034` / `0x7035` (lateral chromatic aberration), +/// `0x7036` / `0x7037` (distortion), and the `SR2SubIFD` tables `0x797d`, `0x7980`, `0x7982`. The `SR2SubIFD` setting +/// tags `0x797c`, `0x797f`, `0x7981` are unnamed there; they sit next to the tables and equal the raw-IFD settings +/// in every file inspected. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyLensCorrections { + /// Shading (vignetting): 16 radial samples whose gain function, radius convention and processing stage are not + /// established. Opaque signed data. + pub shading: SonyComponent<[i16; 16]>, + /// Lateral chromatic aberration: two 16-sample radial curves. A public analysis reads them as red, then blue, + /// relative to green; that is not validated here. + pub chromatic_aberration: SonyComponent<[[i16; 16]; 2]>, + /// Distortion: 16 radial samples (see `docs/sony-lens-corrections.md` for the validated interpretation). + pub distortion: SonyComponent<[i16; 16]>, + /// Whether the encrypted `SR2SubIFD` was found and decrypted. `false`: no copies to fall back on or compare. + pub sr2_read: bool, + /// The framing and coding the tables have to be interpreted against. + pub geometry: SonyLensGeometry, +} + +/// One correction component: the camera's setting, its table, and whether LightCraft applies it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyComponent { + pub setting: SonyField, + /// The table's samples after the leading sample count, which must equal their number. + pub table: SonyField, + pub status: SonyLensStatus, +} + +/// One field read from the raw IFD and from the `SR2SubIFD`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyField { + /// The value in use: the raw IFD's when it holds a well-formed one, else the `SR2SubIFD`'s when the raw IFD + /// lacks the tag. `None` when no copy is usable, and when the raw IFD's copy is malformed: a corrupt field is + /// reported, not silently replaced by the other copy. The raw IFD's copy is the one the TIFF reader that decodes + /// the image parsed (its first occurrence, within the reader's limits). + pub value: Option, + pub source: Option, + pub raw_ifd: SonyCopy, + pub sr2: SonyCopy, + /// Whether the two copies are equal, when both are well-formed. The raw IFD's wins when they differ. + pub copies_agree: Option, + /// Diagnostics: the tag occurs more than once in the raw IFD / the `SR2SubIFD`. Only the first occurrence is used, + /// as the TIFF reader does (the raw IFD's: the first one whose values it could read). + #[serde(default)] + pub raw_ifd_repeated: bool, + #[serde(default)] + pub sr2_repeated: bool, +} + +/// Where a value was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonySource { + /// The plain raw image IFD. + RawIfd, + /// The encrypted `SR2SubIFD`. + Sr2SubIfd, +} + +/// How one copy of a field was found. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyCopy { + /// Not in that directory (or the `SR2SubIFD` could not be read). + Absent, + /// Present with exactly the supported type and layout. + Valid, + /// Present, but refused. + Rejected(SonyFieldError), +} + +/// Why a present field was refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyFieldError { + /// Another TIFF field type than SSHORT (8) for a table, or SHORT (3) / SSHORT for a setting. + FieldType(u16), + /// Another number of values than the supported layout's: 17 (shading, distortion), 33 (CA) or 1 (setting). + Count(u64), + /// The leading sample count is not the layout's 16 (32 for CA). Older bodies store 11 (22) samples padded to the + /// same length, a layout that is not interpreted; a count of 0 goes with an "unavailable" setting. + CountPrefix(i16), + /// The values lie outside the file (raw IFD) or the decrypted block (`SR2SubIFD`). + OutsideData, + /// In the raw IFD but past what the TIFF reader that decodes the image reads (8,192 entries per directory, a + /// total value budget), so the image doesn't use it either. + PastReaderLimits, + /// A setting stored as a negative SSHORT. + NegativeSetting(i16), +} + +/// A camera correction setting: the stored number and its documented meaning. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SonySetting { + pub value: u16, + pub meaning: SonySettingMeaning, +} + +/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation). Values are compared whole; +/// they are not read as bit masks. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonySettingMeaning { + /// Shading 256, CA 0, distortion 0. The table is still recorded: Off does not mean absent. + Off, + /// Shading 257 or 272, CA 1, distortion 1. + Auto, + /// Distortion 17: Auto, fixed by the lens. + ForcedAuto, + /// "No correction params available": shading 511, CA 255, distortion 255. + Unavailable, + /// Any other value (e.g. a shading "Low" setting, whose number is not documented). Never treated as Off. + Unknown(u16), +} + +/// Whether a component is applied, and if not, why. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyLensStatus { + /// Converted into the image's `OpcodeList3` (ILCE-7RM4A, ILCE-7RM6 or eligible full-frame ILCE-7CR Bayer distortion). + Applied, + /// No usable table. + NoTable, + /// Decoded only: no independent validation covers this component's rendering semantics on any body (shading, + /// lateral CA). + NotValidatedComponent, + /// Decoded only: the component is validated on other camera models, not on this one. + NotValidatedModel, + /// Decoded only: the model is validated, but not this coding or framing (linear YCbCr, a non-3:2 crop) or a + /// table found only in the `SR2SubIFD`. + NotValidatedMode, + /// A7CR distortion is Off, unavailable, missing or unknown. Its uncorrected JPEG and render keep the same geometry. + CameraCorrectionDisabled, + /// Validated scope, but the table is all zeros: a valid no-op, nothing to apply. + Neutral, + /// Validated scope, but the conversion refused this table (values outside the validated range, a degenerate + /// crop, or a fitted map that misses the table or folds). + Rejected, +} + +/// The framing and coding evidence for interpreting the tables, all in raw-frame pixels. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyLensGeometry { + /// The stored raw frame, including any padding (lossless L files are padded). + pub storage_width: usize, + pub storage_height: usize, + /// Sony's crop tags (`0x74c7` / `0x74c8`) as recorded. + pub sony_crop: Option, + /// The raw IFD's `DefaultCropOrigin` / `DefaultCropSize` as recorded. + pub default_crop: Option, + /// The image's crop (the decoder's precedence: Sony's tags, then the default crop, then the recorded size). + pub image_crop: Rect, + /// The rectangle the validated distortion conversion is centred on and normalised to: the default crop when + /// the file has one, else the image crop. Clipped to the frame. + pub reference: Rect, + /// TIFF compression of the raw image (1 uncompressed, 7 lossless, 32767 ARW2, 32766 ARW6). + pub compression: u16, + /// Downsized lossless M/S data (linear YCbCr, not sensor CFA). + pub linear_ycbcr: bool, + /// Both the image crop and the reference are 3:2 within 0.5 %, the only framing validated. + pub native_3_2: bool, +} + +impl SonyField { + /// Whether either directory holds the tag (well-formed or not). + pub fn is_present(&self) -> bool { + self.raw_ifd != SonyCopy::Absent || self.sr2 != SonyCopy::Absent + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index c90e2e5b6..444241c59 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -34,6 +34,7 @@ //! | `rw2` | Panasonic RW2, Leica RWL and the older Panasonic / Leica RAW: packed, formats 4 and 6, format 8 prefix-coded strips, 16-bit words | //! | `raf` | Fujifilm RAF: uncompressed Bayer and X-Trans, Fujifilm's lossless and lossy compression, lens corrections | //! | `orf` | Olympus / OM System ORF: uncompressed 16-bit, 12-bit packed and two-field layouts, 16-byte blocks; compressed refused | +//! | `arw` | Sony ARW (and SR2 / SRF containers): uncompressed, ARW2, lossless JPEG tiles, ARW6, the encrypted SR2SubIFD, lens corrections | //! | `pef` | Pentax PEF: uncompressed (packed or 16-bit words) and Pentax's Huffman coding (compression 65535) | //! | `srw` | Samsung SRW: the uncompressed variants (compression 32769 words, 32770 at 12 bits packed) | //! | `hasselblad` | Hasselblad 3FR and FFF (and other CFA TIFFs in the DNG layout without a DNG version): uncompressed and Hasselblad's lossless JPEG; implies `dng` | @@ -68,6 +69,18 @@ pub mod gaintable; #[cfg(feature = "hasselblad")] mod hasselblad; // (the Huffman difference decoder Pentax, Nikon, Panasonic and Sony codings share) +#[cfg(feature = "arw")] +mod arw; +#[cfg(feature = "arw")] +mod arw6; +#[cfg(feature = "arw")] +mod arw6_curve; +#[cfg(all(test, feature = "arw"))] +mod arw6_testenc; +#[cfg(feature = "arw")] +mod arw_lens; +#[cfg(feature = "arw")] +mod arw_lens_rm6; #[allow(dead_code)] mod huffman; #[cfg(feature = "jxl")] @@ -75,7 +88,10 @@ mod jxl; pub mod kelvin; #[cfg(feature = "kodak")] mod kodak; +pub mod lens_metadata; pub mod ljpeg; +#[cfg(feature = "arw")] +mod llvc; #[cfg(feature = "mef")] mod mef; #[cfg(feature = "mrw")] @@ -103,6 +119,8 @@ mod rafc; mod rw2; #[cfg(feature = "dng")] pub mod semantic; +#[cfg(feature = "arw")] +mod sr2; // (the TIFF pixel plumbing every TIFF-based maker shares; DNG is its only user so far) #[allow(dead_code)] // each maker uses some of these helpers; which are live depends on the features mod shared; @@ -115,7 +133,10 @@ mod unpack; #[cfg(feature = "dng")] pub use dngwrite::{DngCompression, DngWriteOptions, write_dng}; +pub use lens_metadata::LensMetadata; pub use opcodes::{Opcode, OpcodeLists}; +#[cfg(feature = "arw")] +pub use preview::embedded_preview_dynamic_range_optimized; pub use preview::{ PREVIEW_PREFIX, PreviewColorSpace, PreviewRange, bounded_preview, embedded_preview, embedded_preview_color_space, embedded_preview_covering, embedded_preview_from_range, @@ -355,6 +376,7 @@ impl RawFormat { || (self == RawFormat::Rw2 && cfg!(feature = "rw2")) || (self == RawFormat::Raf && cfg!(feature = "raf")) || (self == RawFormat::Orf && cfg!(feature = "orf")) + || (self == RawFormat::Arw && cfg!(feature = "arw")) || (self == RawFormat::Pef && cfg!(feature = "pef")) || (self == RawFormat::Srw && cfg!(feature = "srw")) } @@ -647,6 +669,8 @@ fn decode_in(bytes: &[u8], mode: Mode, limits: &Limits) -> Result { RawFormat::Raf => raf::decode(bytes, mode, limits), #[cfg(feature = "orf")] RawFormat::Orf => orf::decode(bytes, mode, limits), + #[cfg(feature = "arw")] + RawFormat::Arw => arw::decode(bytes, mode, limits), #[cfg(feature = "pef")] RawFormat::Pef => pef::decode(bytes, mode, limits), #[cfg(feature = "srw")] @@ -677,6 +701,10 @@ pub struct RawInfo { /// CinemaDNG frame tags (empty for a still). #[serde(default)] pub cinema: CinemaTags, + /// Lens-correction tables the file records, decoded but not necessarily applied (see [`lens_metadata`]); only + /// validated components are in `opcodes`. + #[serde(default)] + pub lens_metadata: LensMetadata, /// See [`RawImage::pixel_aspect`]. #[serde(default = "square_pixels")] pub pixel_aspect: f32, @@ -932,6 +960,10 @@ pub struct RawImage { /// CinemaDNG frame tags (empty for a still). #[serde(default)] pub cinema: CinemaTags, + /// Lens-correction tables the file records, decoded but not necessarily applied (see [`lens_metadata`]); only + /// validated components are in `opcodes`. + #[serde(default)] + pub lens_metadata: LensMetadata, } impl RawImage { @@ -952,6 +984,7 @@ impl RawImage { opcodes: self.opcodes.clone(), camera: self.camera.clone(), cinema: self.cinema.clone(), + lens_metadata: self.lens_metadata.clone(), pixel_aspect: self.pixel_aspect, } } @@ -973,6 +1006,7 @@ impl RawImage { opcodes: self.opcodes, camera: self.camera, cinema: self.cinema, + lens_metadata: self.lens_metadata, pixel_aspect: self.pixel_aspect, } } diff --git a/crates/raw/src/llvc.rs b/crates/raw/src/llvc.rs new file mode 100644 index 000000000..efdf0818a --- /dev/null +++ b/crates/raw/src/llvc.rs @@ -0,0 +1,536 @@ +//! RDD 34:2015 LLVC codec core as Sony's ARW6 uses it; see docs/arw6-compression.md. +//! +//! Clean-room: written from SMPTE RDD 34:2015 §6.3 (Figures 6.5-6.8) and checked against its worked +//! example (Figure 6.9) and Phase 0 oracle vectors. No other raw decoder's source was consulted. + +use crate::huffman::Bits; +use crate::{RawError, Result}; + +/// Largest coefficient bit depth (DPT) a stream may select; keeps `Bits::get` in range. +pub(crate) const MAX_DPT: u32 = 24; + +fn corrupt(why: &str) -> RawError { + RawError::Corrupt(format!("ARW6 VLD: {why}")) +} + +/// One bit, failing when the reader has run past the `source_bits` of its half. +#[inline(always)] +fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result { + let b = bits.get(1); + if bits.consumed_bits() > source_bits { + return Err(corrupt("past the end of its half")); + } + Ok(b) +} + +/// Count zero bits up to and including the terminating 1 (the `0^n 1` codes), bounded by `MAX_DPT`. +fn zeros_then_one(bits: &mut Bits<'_>, source_bits: usize) -> Result { + // the next MAX_DPT + 1 bits at once (more zeros than that is corrupt either way) + let n = bits.peek(MAX_DPT + 1).leading_zeros() - (32 - (MAX_DPT + 1)); + bits.skip((n + 1).min(MAX_DPT + 1)); + if bits.consumed_bits() > source_bits { + return Err(corrupt("past the end of its half")); + } + if n > MAX_DPT { + return Err(corrupt("DPT code exceeds MAX_DPT")); + } + Ok(n) +} + +/// Decode one coefficient line of `width.div_ceil(4)` sets (RDD 34 §6.3.6) and return exactly `width` values. +pub(crate) fn vld_decode_line(bits: &mut Bits<'_>, source_bits: usize, width: usize) -> Result> { + if width == 0 { + return Ok(Vec::new()); // no sets: the state machine below would never terminate + } + let sets = width.div_ceil(4); + let (mut abs, mut dpt, mut cnt, mut dpts) = ([0u32; 4], 0u32, sets, 0u8); + let (mut abs_v, mut abs_e, mut dpt_v, mut dpt_e, mut zr) = (false, false, true, false, 0u64); + let mut out: Vec = Vec::with_capacity(sets * 4); + loop { + // DPT value decoding (Figure 6.6). + if cnt == 0 { + dpt_v = false; + } + if cnt == 1 { + dpt_e = true; + } + cnt = cnt.saturating_sub(1); + zr = zr.saturating_sub(1); + if dpt_v { + match dpts { + 0 => { + if bit(bits, source_bits)? == 0 { + // Sony deviation 1, oracle-verified in Phase 0: a zero code at DPT 0 is a zero set entering run mode. + if dpt == 0 { + dpts = 1; + } + } else if bit(bits, source_bits)? == 0 { + // "1 0 0^n 1": DPT up by n+1. + dpt = dpt.saturating_add(zeros_then_one(bits, source_bits)? + 1); + } else { + // "1 1 0^n 1" (DPT down by n+1) or "1 1 0^(DPT-1)" (DPT to 0). + let mut n = 0; + while n + 1 < dpt && bit(bits, source_bits)? == 0 { + n += 1; + } + if n + 1 >= dpt { + dpt = 0; + dpts = 1; + } else { + dpt -= n + 1; + } + } + } + 1 => { + if bit(bits, source_bits)? == 1 { + // "1 0^n 1": leave run mode at DPT n+1. + dpt = zeros_then_one(bits, source_bits)? + 1; + dpts = 0; + } else { + // Run code of ceil(log2(Cnt+2)) bits (Figure 6.6). + let lim = (cnt as u64 + 2).next_power_of_two().trailing_zeros(); + if lim > 32 { + return Err(corrupt("line too wide")); + } + let mut m = 1; + while m < lim && bit(bits, source_bits)? == 0 { + m += 1; + } + if m == lim { + // "0^lim": the rest of the line is zero. + zr = cnt as u64 + 2; + } else { + zr = 1; + for _ in 0..m { + zr = (zr << 1) | bit(bits, source_bits)? as u64; + } + } + dpts = 2; + } + } + _ => { + // Sony deviation 2, oracle-verified in Phase 0: the run ends at ZR == 1 (RDD 34 says 0). + if zr == 1 { + dpt = zeros_then_one(bits, source_bits)? + 1; + dpts = 0; + } + } + } + if dpt > MAX_DPT { + return Err(corrupt("DPT exceeds MAX_DPT")); + } + } + // SGN value decoding (Figure 6.7): signs of the previous set. + if abs_v { + for v in abs { + let v = v as i32; + out.push(if v != 0 && bit(bits, source_bits)? == 1 { -v } else { v }); + } + } + if abs_e { + out.truncate(width); + return Ok(out); + } + abs_v = dpt_v; + abs_e = dpt_e; + // ABS value decoding (Figure 6.8). + if dpt_v { + for a in abs.iter_mut() { + *a = bits.get(dpt); + } + if bits.consumed_bits() > source_bits { + return Err(corrupt("past the end of its half")); + } + } + } +} + +/// Parity dequantiser, Phase 0 (oracle-verified): 0 -> 0, qi = 0 -> q, else +/// `sign(q) * (((2|q| + 1) << (qi - 1)) - (|q| & 1))`. Saturates instead of overflowing on hostile input. +pub(crate) fn dequant(q: i32, qi: u32) -> i32 { + if q == 0 || qi == 0 { + return q; + } + let a = i64::from(q).abs(); + let m = (((2 * a + 1) << (qi - 1).min(30)) - (a & 1)).min(i64::from(i32::MAX)); + (if q < 0 { -m } else { m }) as i32 +} + +/// A row-major plane of integer samples. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Plane { + pub width: usize, + pub height: usize, + pub data: Vec, +} + +impl Plane { + pub fn zeros(width: usize, height: usize) -> Plane { + Plane { width, height, data: vec![0; width.saturating_mul(height)] } + } + pub fn row(&self, y: usize) -> Option<&[i32]> { + let start = y.checked_mul(self.width)?; + self.data.get(start..start.checked_add(self.width)?) + } + fn ok(&self) -> bool { + self.width.checked_mul(self.height) == Some(self.data.len()) + } +} + +/// `(low, high)` sample counts of an `n`-sample signal whose low-pass samples sit at indices = `phase` (mod 2). +pub(crate) fn band_rows(n: usize, phase: u8) -> (usize, usize) { + let low = (n + 1).saturating_sub(usize::from(phase)) / 2; + (low, n - low) +} + +/// Saturating narrowing: hostile coefficients clamp instead of overflowing. +fn sat(v: i64) -> i32 { + v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 +} + +/// Sample `i` (`w` values wide) of a band of `n` samples, clamped into range (the whole-sample symmetric extension); +/// `zeros` for an empty band. +#[inline] +fn band_sample<'a>(v: &'a [i32], w: usize, n: usize, i: isize, zeros: &'a [i32]) -> &'a [i32] { + if n == 0 { + return zeros; + } + let i = i.clamp(0, n as isize - 1) as usize; + v.get(i * w..(i + 1) * w).unwrap_or(zeros) +} + +/// Inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes), of a +/// signal whose samples are `w` values wide: a band's width for all its columns at once (row by row, as each column +/// alone would give; rows in parallel). One line (`w = 1`) goes through [`lift_row`]. +fn inverse_lift(l: &[i32], h: &[i32], w: usize, phase: u8) -> Result> { + use rayon::prelude::*; + let w = w.max(1); + let (nl, nh) = (l.len() / w, h.len() / w); + let (n, p) = (nl + nh, isize::from(phase)); + if band_rows(n, phase) != (nl, nh) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + let zeros = vec![0; w]; + let mut even = vec![0; nl * w]; + even.par_chunks_exact_mut(w).zip(l.par_chunks_exact(w)).enumerate().for_each(|(j, (e, lv))| { + let (a, b) = (band_sample(h, w, nh, j as isize - 1 + p, &zeros), band_sample(h, w, nh, j as isize + p, &zeros)); + for (((e, &lv), &a), &b) in e.iter_mut().zip(lv).zip(a).zip(b) { + *e = sat(i64::from(lv) - ((i64::from(a) + i64::from(b) + 2) >> 2)); + } + }); + let mut out = vec![0; n * w]; + let lo = usize::from(phase); + out.par_chunks_exact_mut(w).enumerate().for_each(|(t, o)| { + if t % 2 == lo { + if let Some(e) = even.get((t - lo) / 2 * w..((t - lo) / 2 + 1) * w) { + o.copy_from_slice(e); + } + return; + } + let k = (t - (1 - lo)) / 2; + let Some(hv) = h.get(k * w..(k + 1) * w) else { return }; + let (a, b) = (band_sample(&even, w, nl, k as isize - p, &zeros), band_sample(&even, w, nl, k as isize + 1 - p, &zeros)); + for (((o, &hv), &a), &b) in o.iter_mut().zip(hv).zip(a).zip(b) { + *o = sat(i64::from(hv) + ((i64::from(a) + i64::from(b)) >> 1)); + } + }); + Ok(out) +} + +/// [`inverse_lift`] of one line (`w = 1`) into `out` (`l.len() + h.len()` values), without allocating: `even` is +/// scratch space. The caller has checked the band sizes. +fn lift_row(l: &[i32], h: &[i32], phase: u8, even: &mut Vec, out: &mut [i32]) { + let (nl, nh, p) = (l.len(), h.len(), isize::from(phase)); + let hs = |i: isize| if nh == 0 { 0 } else { h.get(i.clamp(0, nh as isize - 1) as usize).map_or(0, |&v| i64::from(v)) }; + even.clear(); + even.extend(l.iter().enumerate().map(|(j, &lv)| sat(i64::from(lv) - ((hs(j as isize - 1 + p) + hs(j as isize + p) + 2) >> 2)))); + let even = &*even; + let es = |i: isize| if nl == 0 { 0 } else { even.get(i.clamp(0, nl as isize - 1) as usize).map_or(0, |&v| i64::from(v)) }; + let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); + for (o, &e) in out.iter_mut().skip(lo).step_by(2).zip(even.iter()) { + *o = e; + } + for (k, (o, &hv)) in out.iter_mut().skip(hi).step_by(2).zip(h).enumerate() { + *o = sat(i64::from(hv) + ((es(k as isize - p) + es(k as isize + 1 - p)) >> 1)); + } +} + +/// Inverse transform of the `low`/`high` pair along one axis: down the columns when `vertical` (all at once, row by +/// row), else along each row. +fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result { + if !low.ok() || !high.ok() { + return Err(RawError::Corrupt("ARW6 wavelet: plane size mismatch".into())); + } + let (across_l, across_h) = if vertical { (low.width, high.width) } else { (low.height, high.height) }; + if across_l != across_h { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + let (nl, nh) = if vertical { (low.height, high.height) } else { (low.width, high.width) }; + let n = nl + nh; + let (w, h) = if vertical { (across_l, n) } else { (n, across_l) }; + if across_l == 0 { + return Ok(Plane::zeros(w, h)); + } + if vertical { + return Ok(Plane { width: w, height: h, data: inverse_lift(&low.data, &high.data, w, phase)? }); + } + if band_rows(n, phase) != (nl, nh) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + use rayon::prelude::*; + let mut out = Plane::zeros(w, h); + out.data.par_chunks_exact_mut(w.max(1)).enumerate().for_each_init(Vec::new, |even, (i, r)| { + if let (Some(l), Some(hi)) = (low.row(i), high.row(i)) { + lift_row(l, hi, phase, even, r); + } + }); + Ok(out) +} + +/// Vertical inverse 5/3 (along rows of samples): `low`/`high` are stacked bands of equal width. +pub(crate) fn inverse_53_1d_rows(low: &Plane, high: &Plane, phase: u8) -> Result { + lines(low, high, phase, true) +} + +/// Horizontal inverse 5/3: `low`/`high` are side-by-side bands of equal height. +pub(crate) fn inverse_53_1d_cols(low: &Plane, high: &Plane, phase: u8) -> Result { + lines(low, high, phase, false) +} + +/// 2-D inverse, RDD 34 §6.5 order: vertical IWT (phase `vphase`) on (ll, lh) and (hl, hh), then horizontal (phase 0). +pub(crate) fn inverse_53_2d(ll: &Plane, lh: &Plane, hl: &Plane, hh: &Plane, vphase: u8) -> Result { + let lo = inverse_53_1d_rows(ll, lh, vphase)?; + let hi = inverse_53_1d_rows(hl, hh, vphase)?; + inverse_53_1d_cols(&lo, &hi, 0) +} + +/// The ten bands of a 3-level decomposition (LL only at the deepest level). +pub(crate) struct Bands3 { + pub ll3: Plane, + pub hl3: Plane, + pub lh3: Plane, + pub hh3: Plane, + pub hl2: Plane, + pub lh2: Plane, + pub hh2: Plane, + pub hl1: Plane, + pub lh1: Plane, + pub hh1: Plane, +} + +/// Full inverse: level 3 uses `phases[2]`, level 2 `phases[1]`, level 1 `phases[0]`. +pub(crate) fn reconstruct3(b: &Bands3, phases: [u8; 3]) -> Result { + let ll2 = inverse_53_2d(&b.ll3, &b.lh3, &b.hl3, &b.hh3, phases[2])?; + let ll1 = inverse_53_2d(&ll2, &b.lh2, &b.hl2, &b.hh2, phases[1])?; + inverse_53_2d(&ll1, &b.lh1, &b.hl1, &b.hh1, phases[0]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::huffman::Bits; + + fn bits_of(s: &str) -> Vec { + let mut out = vec![0u8; s.len().div_ceil(8)]; + for (i, c) in s.bytes().enumerate() { + if c == b'1' { + out[i / 8] |= 0x80 >> (i % 8); + } + } + out + } + fn line(s: &str, width: usize) -> (Vec, usize) { + let src = bits_of(s); + let mut b = Bits::new(&src); + let v = vld_decode_line(&mut b, src.len() * 8, width).unwrap(); + (v, b.consumed_bits()) + } + + #[test] + fn rdd34_figure_6_9_worked_example() { + // RDD 34:2015 §6.3.6, Figure 6.9: 8 sets, 91 bits + let s = "1000010110000010000100110000101000010100110100000000000011000010010001001010011100110000000"; + let (v, used) = line(s, 32); + assert_eq!(v, vec![6, 0, -8, 4, 0, 0, 0, 0, -9, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(used, 91); + } + #[test] + fn sony_zero_at_dpt0_enters_run_state() { + // Phase 0 vector "dev1": a "0" at DPT 0 is a zero set that moves to DPTs = 1; the next set uses the "1 0^n 1" code + let (v, used) = line("01001101000011001010", 8); + assert_eq!(v, vec![0, 0, 0, 0, 5, 0, -3, 1]); + assert_eq!(used, 20); + } + #[test] + fn sony_run_value_means_minus_one_zero_sets() { + // Phase 0 vector "dev2": run code value 3 = two more zero sets, then "0^n 1" post-run DPT + let (v, used) = line("001101100000000", 16); + assert_eq!(v, vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0]); + assert_eq!(used, 15); + } + #[test] + fn end_of_line_zero_code() { + let (v, used) = line("1001110100101100100", 12); + assert_eq!(v, vec![3, -1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(used, 19); + } + #[test] + fn dpt_moves_up_down_and_to_zero() { + let (v, used) = line("1011000100101111111110011101001011111100000", 20); + assert_eq!(v, vec![1, 0, 0, 0, 7, 7, -7, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(used, 43); + } + #[test] + fn all_zero_line_is_two_bits_and_width_truncates() { + let (v, used) = line("00", 6); // L = 2 sets, width 6 keeps 6 of 8 values + assert_eq!(v, vec![0; 6]); + assert_eq!(used, 2); + } + #[test] + fn width_zero_returns_empty_line() { + let (v, used) = line("1111", 0); + assert!(v.is_empty()); + assert_eq!(used, 0); + } + #[test] + fn hostile_input_errors_instead_of_panicking() { + let mut climb = vec![0u8; 64]; + climb[0] = 0x80; // "1 0 0^n 1" with n > MAX_DPT + let mut b = Bits::new(&climb); + assert!(matches!(vld_decode_line(&mut b, 512, 16), Err(RawError::Corrupt(_)))); + let short = bits_of("10"); // needs more bits than it has + let mut b = Bits::new(&short); + assert!(matches!(vld_decode_line(&mut b, 2, 16), Err(RawError::Corrupt(_)))); + } + proptest::proptest! { + #[test] + fn random_bits_never_panic(src in proptest::collection::vec(proptest::num::u8::ANY, 0..256), width in 0usize..300) { + let mut b = Bits::new(&src); + let _ = vld_decode_line(&mut b, src.len() * 8, width); + } + } + + fn col(v: &[i32]) -> Plane { + Plane { width: 1, height: v.len(), data: v.to_vec() } + } + #[test] + fn dequant_parity_rule() { + for (qi, table) in + [(0u32, [-3, -2, -1, 0, 1, 2, 3]), (1, [-6, -5, -2, 0, 2, 5, 6]), (2, [-13, -10, -5, 0, 5, 10, 13]), (3, [-27, -20, -11, 0, 11, 20, 27])] + { + for (q, v) in (-3..=3).zip(table) { + assert_eq!(dequant(q, qi), v, "q={q} qi={qi}"); + } + } + } + #[test] + fn quantize_inverts_dequant() { + for qi in 0..6 { + for q in -50..=50 { + assert_eq!(crate::arw6_testenc::quantize(dequant(q, qi), qi), q, "q={q} qi={qi}"); + } + } + } + #[test] + fn dequant_does_not_overflow() { + assert_eq!(dequant(i32::MAX, 31), i32::MAX); + assert_eq!(dequant(i32::MIN, 40), -i32::MAX); + } + #[test] + fn extreme_coefficients_do_not_overflow() { + for v in [i32::MAX, i32::MIN] { + for (w, h) in [(7usize, 9usize), (16, 16), (9, 16)] { + let p = |w, h| Plane { width: w, height: h, data: vec![v; w * h] }; + for ph in 0..2u8 { + let (lh, lw) = (band_rows(h, ph), band_rows(w, 0)); + assert!(inverse_53_2d(&p(lw.0, lh.0), &p(lw.0, lh.1), &p(lw.1, lh.0), &p(lw.1, lh.1), ph).is_ok()); + } + let (w3, h3) = (w * 8, h * 8); + let mut sizes = vec![]; + let (mut cw, mut ch) = (w3, h3); + for ph in [0u8; 3] { + let (a, b) = (band_rows(cw, 0), band_rows(ch, ph)); + sizes.push((a, b)); + (cw, ch) = (a.0, b.0); + } + let q = |l: usize, xh: bool, yh: bool| { + let ((a, b), (c, d)) = sizes[l]; + p(if xh { b } else { a }, if yh { d } else { c }) + }; + let b = Bands3 { + ll3: q(2, false, false), + hl3: q(2, true, false), + lh3: q(2, false, true), + hh3: q(2, true, true), + hl2: q(1, true, false), + lh2: q(1, false, true), + hh2: q(1, true, true), + hl1: q(0, true, false), + lh1: q(0, false, true), + hh1: q(0, true, true), + }; + assert!(reconstruct3(&b, [0, 1, 0]).is_ok()); + } + } + } + #[test] + fn band_rows_counts() { + assert_eq!(band_rows(1093, 0), (547, 546)); + assert_eq!(band_rows(1093, 1), (546, 547)); + assert_eq!(band_rows(1668, 0), (834, 834)); + assert_eq!(band_rows(5, 1), (2, 3)); + assert_eq!(band_rows(1, 1), (0, 1)); + } + #[test] + fn inverse_53_hand_vectors() { + // computed by hand from RDD 34 §6.5 with X[-1] := X[1], X[n] := X[n-2]; checked against the Phase 0 model + assert_eq!(inverse_53_1d_rows(&col(&[10, 20]), &col(&[3, -4]), 0).unwrap().data, vec![8, 17, 20, 16]); + assert_eq!(inverse_53_1d_rows(&col(&[10, 20]), &col(&[3, -4]), 1).unwrap().data, vec![13, 10, 12, 22]); + assert_eq!(inverse_53_1d_rows(&col(&[10, 20, 30]), &col(&[3, -4]), 0).unwrap().data, vec![8, 17, 20, 22, 32]); + assert_eq!(inverse_53_1d_rows(&col(&[3, -4]), &col(&[10, 20, 30]), 1).unwrap().data, vec![5, -5, 9, -17, 13]); + } + #[test] + fn horizontal_pass_gives_the_vertical_passs_samples() { + // two implementations of one lifting: `lift_row` along rows, `inverse_lift` down columns + let mut s = 0x9e37_79b9_7f4a_7c15u64; + let mut next = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s % 8191) as i32 - 4095 + }; + for n in 1..12usize { + for phase in 0..2u8 { + let ((nl, nh), rows) = (band_rows(n, phase), 3); + let low = Plane { width: nl, height: rows, data: (0..nl * rows).map(|_| next()).collect() }; + let high = Plane { width: nh, height: rows, data: (0..nh * rows).map(|_| next()).collect() }; + let across = inverse_53_1d_cols(&low, &high, phase).unwrap(); + for r in 0..rows { + let down = inverse_53_1d_rows(&col(low.row(r).unwrap()), &col(high.row(r).unwrap()), phase).unwrap(); + assert_eq!(across.row(r).unwrap(), &down.data[..], "n {n}, phase {phase}, row {r}"); + } + } + } + } + #[test] + fn inverse_53_rejects_mismatched_bands() { + assert!(inverse_53_1d_rows(&col(&[1, 2, 3]), &col(&[1]), 0).is_err()); + assert!(inverse_53_1d_rows(&col(&[1, 2]), &Plane::zeros(2, 2), 0).is_err()); + assert!(inverse_53_1d_cols(&col(&[1, 2]), &col(&[1, 2, 3]), 0).is_err()); + } + proptest::proptest! { + #[test] + fn forward_then_inverse_is_identity(w in 1usize..24, h in 1usize..40, p1 in 0u8..2, p2 in 0u8..2, p3 in 0u8..2, seed in proptest::num::u64::ANY) { + use crate::arw6_testenc::{forward3, forward_53_2d}; + use proptest::prop_assert_eq; + let mut s = seed | 1; + let mut next = || { s ^= s << 13; s ^= s >> 7; s ^= s << 17; (s % 8191) as i32 - 4095 }; + let x = Plane { width: w, height: h, data: (0..w * h).map(|_| next()).collect() }; + let (ll, lh, hl, hh) = forward_53_2d(&x, p1); + prop_assert_eq!(inverse_53_2d(&ll, &lh, &hl, &hh, p1).unwrap(), x.clone()); + if w >= 8 && h >= 8 { + prop_assert_eq!(reconstruct3(&forward3(&x, [p1, p2, p3]), [p1, p2, p3]).unwrap(), x); + } + } + } +} diff --git a/crates/raw/src/mef.rs b/crates/raw/src/mef.rs index a0856ff47..bd551d94c 100644 --- a/crates/raw/src/mef.rs +++ b/crates/raw/src/mef.rs @@ -124,6 +124,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result Result Result Result Result Optio } } +/// Whether the camera optimised its embedded JPEG's dynamic range with local tone mapping that the raw data doesn't +/// carry: Sony's Dynamic Range Optimizer (maker note `0xb025`). Such a JPEG is brighter in its darker regions than +/// the raw developed with the camera's global tone curve. `Some(false)` when the file says it was off, `None` when +/// it doesn't say (other makers, notes without the tag, undocumented values). +#[cfg(feature = "arw")] +pub fn embedded_preview_dynamic_range_optimized(bytes: &[u8]) -> Option { + let tiff = Tiff::parse(bytes).ok()?; + let make = tiff.ifds.first()?.string(t::MAKE)?; + if !make.trim().to_ascii_uppercase().starts_with("SONY") { + return None; + } + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?; + crate::arw::dynamic_range_optimizer(&mn) +} + /// The first frame (SOF) marker of a JPEG and its position, from the marker segments before the first scan. fn first_frame(b: &[u8]) -> Option<(u8, usize)> { first_frame_within(b, &mut { usize::MAX }) diff --git a/crates/raw/src/raf.rs b/crates/raw/src/raf.rs index 21d8b6222..3bc8bc219 100644 --- a/crates/raw/src/raf.rs +++ b/crates/raw/src/raf.rs @@ -253,6 +253,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result Result f32 { } } -#[cfg(feature = "rw2")] +#[cfg(any(feature = "rw2", feature = "arw"))] /// Output radii sampled to fit a radial warp. const DISTORTION_SAMPLES: usize = 64; -#[cfg(feature = "rw2")] +#[cfg(any(feature = "rw2", feature = "arw"))] /// Least-squares radial warp from a camera's forward distortion map. /// /// `corrected` maps a source radius to the output radius it lands at; it must rise monotonically up to `t_max`, the @@ -133,7 +133,7 @@ pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, fit_radial_warp(&|u| source(u * t_max), max_fit_error, 1.0) } -#[cfg(feature = "rw2")] +#[cfg(any(feature = "rw2", feature = "arw"))] /// Least-squares `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` through `source_of_u` sampled at `u = i/64`, i = 0..=64 /// (`u` = output radius, `WarpRectilinear` units, so the result is directly a plane's `[k0, k1, k2, k3]`). `None` when /// a sample is not finite, the normal equations are singular, or the worst fit error at the samples up to the first @@ -173,7 +173,7 @@ pub(crate) fn fit_radial_warp(source_of_u: &dyn Fn(f64) -> f64, max_fit_error: f (worst <= max_fit_error).then_some(k) } -#[cfg(feature = "rw2")] +#[cfg(any(feature = "rw2", feature = "arw"))] /// Solve the 4 × 4 system `a·x = b` (Gaussian elimination with partial pivoting); `None` when singular or not finite. fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { for c in 0..4 { @@ -300,7 +300,7 @@ pub(crate) mod tests { assert!((13500.0..=13583.0).contains(&w), "{w}"); } - #[cfg(feature = "rw2")] + #[cfg(any(feature = "rw2", feature = "arw"))] #[test] fn warp_from_forward_map_recovers_identity_and_rejects_folds() { let k = warp_from_forward_map(&|r| r, 1.0, 1e-9).unwrap(); @@ -329,7 +329,7 @@ pub(crate) mod tests { assert!(warp_from_forward_map(&|_| f64::NAN, 1.0, 1e-3).is_none()); } - #[cfg(feature = "rw2")] + #[cfg(any(feature = "rw2", feature = "arw"))] #[test] fn fit_radial_warp_identity_and_gate() { let k = fit_radial_warp(&|u| u, 1e-9, 1.0).unwrap(); diff --git a/crates/raw/src/sr2.rs b/crates/raw/src/sr2.rs new file mode 100644 index 000000000..3847516c5 --- /dev/null +++ b/crates/raw/src/sr2.rs @@ -0,0 +1,637 @@ +//! Sony's encrypted `SR2SubIFD`. +//! +//! Sony ARWs from the DSLR-A200 on carry a private directory, the `SR2SubIFD`, reached from IFD0's +//! `DNGPrivateData` through the `SR2Private` IFD (`SR2SubIFDOffset` `0x7200`, `SR2SubIFDLength` `0x7201`, +//! `SR2SubIFDKey` `0x7221`; tag names from ExifTool's Sony tag documentation). The block is a TIFF directory in the +//! file's byte order whose value offsets are file offsets into the block itself, and the whole block is encrypted. +//! Among its entries are the black level (`0x7310`) and the white balance the camera applied, `WB_RGGBLevels` +//! (`0x7313`: R, G, G, B); bodies from about 2017 on also write both in plain form in the raw IFD. +//! +//! This module is the only place the block is decrypted: the black level and the white balance are both read through +//! [`SubIfd`] (storytold/lightcraft#535; it replaces positional keystream tables recovered earlier for the black level alone). +//! +//! **Clean-room provenance.** The decryption was derived only by black-box known-plaintext analysis of CC0 files. No +//! raw-decoder source (dcraw, LibRaw, rawspeed, rawloader/rawler, darktable, RawTherapee, ExifTool's Perl code, …) +//! and no description of Sony's cipher or keystream generator was read or consulted. The steps: +//! +//! 1. **Files.** The 126 ARWs with an `SR2SubIFD` among the CC0 Sony samples of raw.pixls.us collected for issue +//! storytold/lightcraft#535: 97 bodies from the DSLR-A200 to the ILCE-9M3 and ZV-E1, every Sony body on the site that has one, with +//! every coding of the bodies named in open issues. +//! 2. **Plaintext.** `exiftool -v5 FILE`, run as a black-box tool, prints the decrypted `SR2SubIFD` as a hex dump +//! with file offsets. XOR-ing each dumped byte with the byte at the same offset in the file gives the keystream +//! byte at that position of the block. +//! 3. **Comparison.** All 126 files store the key `11 22 33 44`, and their keystreams agree byte for byte at every +//! position they share: 15 layouts from 25150 to 62112 bytes, 5.2 MB of keystream in all. Bytes after the last +//! whole 4-byte word (two, in every block whose length is not a multiple of four) are stored in the clear. +//! 4. **Structure.** Read as 32-bit words, the longest keystream (the DSLR-A700 sample `DSC07249.ARW`, SHA-256 +//! `3159e288…`, 15528 words) was tested for every recurrence `k[n] = k[n − a] ⊕ k[n − b]` with +//! `1 ≤ a < b < 300`: an exhaustive search, not a guided one. Exactly two pairs hold on every word: `(63, 127)` +//! and `(126, 254)`, the second a consequence of the first. With `(63, 127)`, any 127 consecutive words fix all +//! later ones, so [`SEED`] is simply the first 127 words of that file's keystream. +//! 5. **Checks.** Generated from [`SEED`], the keystream equals the one recovered from each of the 126 files at every +//! position. It also reproduces bytes recovered independently from plain tag values before the generator existed +//! (8 bytes at position 2510, storytold/lightcraft#148, from the plain black levels of the DSC-RX100M6 and ILCE-7M3) and the +//! positional tables the black level was read with until storytold/lightcraft#535; the unit tests pin all of them. +//! +//! +//! **Keys other than `11 22 33 44`: the seed derivation is a recalled scheme (generic memory, no code copied), +//! confirmed because it reproduces [`SEED`] — the keystream storytold/lightcraft#575 measured from 126 CC0 files — exactly, and because +//! it turns the DSC-R1's block (another key) into a valid directory.** [`seed_from_key`] takes the key as the +//! little-endian `u32` of tag `0x7221`, fills 128 state words from the linear congruential generator +//! `k ← k·48828125 + 1` (four words, the fourth then shifted left one bit with the top bit of `w0 ^ w2` shifted in), +//! extends them with `w[i] = (w[i−4] ⊕ w[i−2]) << 1 | (w[i−3] ⊕ w[i−1]) >> 31` to word 126, and the first 127 words of +//! `w[i & 127] = w[(i+1) & 127] ⊕ w[(i+65) & 127]` from `i = 127` are the seed of the recurrence above. For key +//! `11 22 33 44` that is [`SEED`], word for word (unit test `seed_derivation_reproduces_the_measured_seed`; chance +//! of an accident 2⁻⁴⁰⁶⁴). For the DSC-R1's key `63 be 01 3e` the 11932-byte block becomes a well-formed 101-entry +//! directory (black `0x7300`, white balance `0x7303`, crop `0x74c3`). + +use craft_tiff::{ByteOrder, EntrySpan, Ifd, ParseOptions, decode_value, directory_entries, entry_span, tags as t}; + +/// `SR2Private` tags. +const SUBIFD_OFFSET: u16 = 0x7200; +const SUBIFD_LENGTH: u16 = 0x7201; +const SUBIFD_KEY: u16 = 0x7221; +/// The key stored by every ARW examined; [`SEED`] is its keystream. +const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; +/// Upper bound on the block length we decrypt. The largest seen is 62112 bytes (DSLR-A700). +const MAX_LEN: usize = 1 << 20; +/// TIFF field types of the levels: SHORT and SSHORT. +const SHORT: u16 = 3; +const SSHORT: u16 = 8; + +/// The first 127 keystream words for [`KEY`] (each XOR-ed with four block bytes, most significant byte first). +/// Later words follow `k[n] = k[n − 63] ⊕ k[n − 127]`; see the module docs. +const SEED: [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, +]; + +/// The seed (first 127 keystream words) for `key`, the little-endian `u32` of tag `0x7221`; see the module docs. +fn seed_from_key(key: u32) -> [u32; 127] { + let mut p = [0u32; 128]; + let mut k = key; + for w in p.iter_mut().take(4) { + k = k.wrapping_mul(48_828_125).wrapping_add(1); + *w = k; + } + p[3] = (p[3] << 1) | ((p[0] ^ p[2]) >> 31); + for i in 4..127 { + p[i] = ((p[i - 4] ^ p[i - 2]) << 1) | ((p[i - 3] ^ p[i - 1]) >> 31); + } + let mut seed = [0u32; 127]; + for (j, out) in seed.iter_mut().enumerate() { + let i = 127 + j; + p[i & 127] = p[(i + 1) & 127] ^ p[(i + 65) & 127]; + *out = p[i & 127]; + } + seed +} + +/// The seed for the four key bytes stored in the file: the measured [`SEED`] for [`KEY`], else the derived one. +fn seed_for(key: [u8; 4]) -> [u32; 127] { + if key == KEY { SEED } else { seed_from_key(u32::from_le_bytes(key)) } +} + +/// XOR `block` with the keystream of [`KEY`] from its first byte on (whole 4-byte words; a shorter tail stays as +/// it is). Encrypts and decrypts alike. +#[cfg(test)] +fn apply_keystream(block: &mut [u8]) { + apply_keystream_with(block, SEED); +} + +/// [`apply_keystream`] for the keystream starting with `seed`. +fn apply_keystream_with(block: &mut [u8], seed: [u32; 127]) { + let mut ring = seed; + for (n, word) in block.as_chunks_mut::<4>().0.iter_mut().enumerate() { + // `ring[i]` holds k[n − 127] until it is replaced by k[n]; k[n − 63] sits at (n − 63) mod 127 + let i = n % SEED.len(); + if n >= SEED.len() { + ring[i] ^= ring[(i + 64) % SEED.len()]; + } + for (b, k) in word.iter_mut().zip(ring[i].to_be_bytes()) { + *b ^= k; + } + } +} + +/// A decrypted `SR2SubIFD`. +pub(crate) struct SubIfd { + /// File offset of the block: the directory's value offsets are file offsets. + start: usize, + plain: Vec, + order: ByteOrder, +} + +impl SubIfd { + /// Find and decrypt the file's `SR2SubIFD`; `None` when it has none, it lies outside the file, is larger than + /// [`MAX_LEN`]. + pub(crate) fn read(bytes: &[u8], ifd0: &Ifd, order: ByteOrder) -> Option { + let at = order.read_u32(ifd0.bytes(t::DNG_PRIVATE_DATA)?, 0)?; + let fields = private_fields(bytes, u64::from(at), order); + let (start, len, key) = (fields.offset?, fields.length?, fields.key?); + if len > MAX_LEN { + return None; + } + Some(Self::decrypt_keyed(bytes.get(start..start.checked_add(len)?)?, start, order, key)) + } + + /// Decrypt `block`, the encrypted directory found at file offset `start`. + #[cfg(test)] + pub(crate) fn decrypt(block: &[u8], start: usize, order: ByteOrder) -> Self { + Self::decrypt_keyed(block, start, order, KEY) + } + + /// [`SubIfd::decrypt`] for a block stored with `key` (tag `0x7221`). + pub(crate) fn decrypt_keyed(block: &[u8], start: usize, order: ByteOrder, key: [u8; 4]) -> Self { + let mut plain = block.to_vec(); + apply_keystream_with(&mut plain, seed_for(key)); + Self { start, plain, order } + } + + /// The byte order of the directory's values (the file's). + pub(crate) fn order(&self) -> ByteOrder { + self.order + } + + /// The decrypted block (the directory from its entry count on) and the file offset it was stored at, which its + /// value offsets count from: for readers that check each entry's exact type and count themselves + /// (`arw_lens`). + pub(crate) fn block(&self) -> (&[u8], usize) { + (&self.plain, self.start) + } + + /// The field type and value bytes of a SHORT or SSHORT entry; `None` when the tag is absent, has another type + /// or its values lie outside the block. + pub(crate) fn short_bytes(&self, tag: u16) -> Option<(u16, &[u8])> { + let o = self.order; + let entries = usize::from(o.read_u16(&self.plain, 0)?); + let entry = (0..entries).map_while(|i| self.plain.get(2 + 12 * i..14 + 12 * i)).find(|e| o.read_u16(e, 0) == Some(tag))?; + let kind = o.read_u16(entry, 2)?; + if kind != SHORT && kind != SSHORT { + return None; + } + let size = usize::try_from(o.read_u32(entry, 4)?).ok()?.checked_mul(2)?; + let values = if size <= 4 { + entry.get(8..8 + size)? + } else { + let at = usize::try_from(o.read_u32(entry, 8)?).ok()?.checked_sub(self.start)?; + self.plain.get(at..at.checked_add(size)?)? + }; + Some((kind, values)) + } + + /// The values of a SHORT or SSHORT entry (see [`SubIfd::short_bytes`]). + pub(crate) fn shorts(&self, tag: u16) -> Option> { + let (kind, values) = self.short_bytes(tag)?; + let value = |v: u16| if kind == SSHORT { f64::from(v as i16) } else { f64::from(v) }; + Some(values.as_chunks::<2>().0.iter().map(|&c| value(self.order.u16(c))).collect()) + } +} + +/// The `SR2Private` fields that locate the block. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct PrivateFields { + offset: Option, + length: Option, + key: Option<[u8; 4]>, +} + +/// The block's file offset, length and key from the `SR2Private` directory at `at`, exactly as the TIFF reader would +/// give them (`parse_ifd_at` with its default limits, then its `u64` and `bytes` lookups), without decoding the +/// directory. The selection uses the reader's own rules: its directory layout and entry limit +/// ([`directory_entries`]), which entries it can read ([`entry_span`]) and its value budget; each field is its first +/// readable occurrence. Of that entry only the first value is decoded (the key's first five bytes, which settle +/// whether it is four bytes), so a hostile directory can't make this allocate memory sized by its other entries, +/// which could each alias the whole file, before the block's own [`MAX_LEN`] is checked. +fn private_fields(bytes: &[u8], at: u64, order: ByteOrder) -> PrivateFields { + let opts = ParseOptions::default(); + let Ok(dir) = directory_entries(bytes, at, order, false, opts.max_entries) else { return PrivateFields::default() }; + let mut budget = opts.budget_for(bytes.len()); + let (mut offset, mut length, mut key) = (None, None, None); + for p in dir.positions() { + let Some(span) = entry_span(bytes, p, order, false, 0) else { continue }; + if span.len > budget { + continue; + } + budget -= span.len; + match span.tag { + SUBIFD_OFFSET if offset.is_none() => offset = Some(first_number(bytes, &span, order)), + SUBIFD_LENGTH if length.is_none() => length = Some(first_number(bytes, &span, order)), + SUBIFD_KEY if key.is_none() => key = Some(four_bytes(bytes, &span, order)), + _ => {} + } + } + PrivateFields { offset: offset.flatten(), length: length.flatten(), key: key.flatten() } +} + +/// The entry's first value as the reader's `u64` lookup reads it (an unsigned or non-negative integer), as a `usize`. +fn first_number(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option { + if span.count == 0 { + return None; + } + let start = usize::try_from(span.start).ok()?; + let first = bytes.get(start..start.checked_add(span.field_type.size())?)?; + usize::try_from(decode_value(order, span.field_type, first).get_u64(0)?).ok() +} + +/// The entry's bytes as the reader's `bytes` lookup gives them, when they are exactly four. Decoding the first five +/// value bytes settles it: BYTE and UNDEFINED give all their bytes, ASCII its text before the first NUL, which +/// decoding never makes shorter. +fn four_bytes(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option<[u8; 4]> { + let start = usize::try_from(span.start).ok()?; + let head = bytes.get(start..start.checked_add(usize::try_from(span.len).ok()?.min(5))?)?; + <[u8; 4]>::try_from(decode_value(order, span.field_type, head).as_bytes()?).ok() +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + /// TIFF field types. + const BYTE: u16 = 1; + const LONG: u16 = 4; + const UNDEFINED: u16 = 7; + const IFD: u16 = 13; + use craft_tiff::ByteOrder::{Big, Little}; + + /// An `SR2SubIFD` block for file offset `start`, encrypted: SHORT/SSHORT entries `(tag, type, values)`, values + /// of more than two shorts stored after the table at file offsets, as Sony does. + pub(crate) fn block(start: usize, entries: &[(u16, u16, &[u16])], order: ByteOrder) -> Vec { + let mut plain = Vec::new(); + order.put_u16(&mut plain, entries.len() as u16); + let mut data = Vec::new(); + let data_at = start + 2 + 12 * entries.len() + 4; + for &(tag, kind, values) in entries { + order.put_u16(&mut plain, tag); + order.put_u16(&mut plain, kind); + order.put_u32(&mut plain, values.len() as u32); + let mut field = Vec::new(); + values.iter().for_each(|&v| order.put_u16(&mut field, v)); + if field.len() <= 4 { + field.resize(4, 0); + plain.extend(field); + } else { + order.put_u32(&mut plain, (data_at + data.len()) as u32); + data.extend(field); + } + } + plain.extend([0; 4]); + plain.extend(data); + plain.extend([0xa5; 2]); // a length that is not a multiple of four, like most real blocks + apply_keystream(&mut plain); + plain + } + + /// [`block`] encrypted with another `key`. + pub(crate) fn block_keyed(start: usize, entries: &[(u16, u16, &[u16])], order: ByteOrder, key: [u8; 4]) -> Vec { + let mut b = block(start, entries, order); + apply_keystream(&mut b); // undo the default key + apply_keystream_with(&mut b, seed_for(key)); + b + } + + /// Encrypt (or decrypt) `block` in place, as a file stores an `SR2SubIFD` at its first byte. + pub(crate) fn encrypt(block: &mut [u8]) { + apply_keystream(block); + } + + /// Keystream bytes recovered earlier, each from plain tag values or ExifTool's decrypted dump at one position, + /// before the generator existed: the black level's 8 bytes at 2510 (storytold/lightcraft#148), and the positional tables the black + /// level was read with until storytold/lightcraft#535 (storytold/lightcraft#542: the directory's count and first entry, and the black-level value in the + /// four layouts of bodies without a plain `0x7310`). The generator must reproduce every one of them, so the black + /// levels it decrypts are those the tables gave. + #[test] + fn keystream_matches_bytes_recovered_at_single_positions() { + let mut ks = vec![0u8; 62114]; + apply_keystream(&mut ks); + let recovered: [(usize, &[u8]); 5] = [ + (0, &[0x54, 0xc2, 0xc4, 0xd6, 0x49, 0xc4, 0x78, 0xbc, 0x34, 0xae, 0x2f, 0x8e, 0x25, 0xd8]), + (1638, &[0x18, 0x95, 0x65, 0xf0, 0x18, 0x8f, 0x61, 0xc6]), + (2166, &[0xc6, 0x17, 0x02, 0xcb, 0x89, 0x21, 0xbc, 0x43]), + (2418, &[0xd1, 0xef, 0xda, 0xfe, 0x50, 0xf3, 0xd0, 0xbe]), + (2510, &[0x43, 0xcb, 0x86, 0xb6, 0x11, 0xd2, 0x1a, 0x73]), + ]; + for (at, bytes) in recovered { + assert_eq!(&ks[at..at + bytes.len()], bytes, "keystream at {at}"); + } + } + + /// Known answers recorded from CC0 files without the generator, so that a wrong seed word or recurrence fails + /// here even without the corpus: the SHA-256 of the whole keystream recovered from the DSLR-A700 sample + /// (`DSC07249.ARW`: the file's bytes XOR ExifTool's decrypted dump; 62112 bytes, the longest block seen, so it + /// covers every black-level and white-balance position of every layout), and the encrypted `WB_RGGBLevels` of + /// one file per layout of bodies without a plain `0x7313`, with the levels ExifTool decrypts from them. + #[test] + fn keystream_matches_recorded_known_answers() { + use sha2::{Digest, Sha256}; + let mut ks = vec![0u8; 62112]; + apply_keystream(&mut ks); + let digest: String = Sha256::digest(&ks).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(digest, "d55e96d7bb69cb823d9dfa038dd9f3cfb6b080be4df8c8aa2bc56f71a4a3f183"); + // (file, position in the block, bytes stored there, WB_RGGBLevels) + let cases: [(&str, usize, [u8; 8], [i16; 4]); 4] = [ + ("DSLR-A700 DSC07249", 1654, [0xc9, 0x2a, 0xb6, 0xa4, 0x1f, 0xc1, 0x72, 0x30], [2128, 1024, 1024, 1564]), + ("DSLR-A500 DSC02421", 2182, [0xb9, 0x6a, 0xed, 0xd3, 0x0d, 0xa8, 0xbb, 0xda], [2212, 1024, 1024, 1416]), + ("SLT-A33 DSC01867", 2434, [0x3c, 0x43, 0x05, 0x3f, 0x0d, 0xe2, 0x05, 0xcc], [2344, 1024, 1024, 1508]), + ("ILCE-3500 DSC06923", 2526, [0x37, 0x2e, 0x71, 0x5b, 0x88, 0xb7, 0x21, 0x73], [2932, 1024, 1024, 1576]), + ]; + for (file, at, stored, levels) in cases { + let mut block = vec![0u8; (at + 8).next_multiple_of(4)]; + block[at..at + 8].copy_from_slice(&stored); + apply_keystream(&mut block); + let got: Vec = block[at..at + 8].chunks(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect(); + assert_eq!(got, levels, "{file}"); + } + } + + /// The proof of the recalled scheme: derived from key `11 22 33 44`, the seed is exactly the keystream storytold/lightcraft#575 + /// measured from the CC0 files. + #[test] + fn seed_derivation_reproduces_the_measured_seed() { + assert_eq!(seed_from_key(u32::from_le_bytes(KEY)), SEED); + assert_ne!(seed_from_key(u32::from_le_bytes(KEY)), seed_from_key(u32::from_le_bytes(KEY) ^ 1)); + assert_eq!(seed_for(KEY), SEED); + } + + #[test] + fn another_key_decrypts_a_block() { + // the DSC-R1's key and layout: black 0x7300, WB 0x7303 (G, R, B, G), crop 0x74c3 + let key = [0x63, 0xbe, 0x01, 0x3e]; + let entries: [(u16, u16, &[u16]); 3] = + [(0x7300, SHORT, &[514, 511, 512, 513]), (0x7303, SHORT, &[256, 500, 385, 256]), (0x74c3, SHORT, &[16, 8, 3904, 2600])]; + for order in [Little, Big] { + let enc = block_keyed(230812, &entries, order, key); + let sub = SubIfd::decrypt_keyed(&enc, 230812, order, key); + assert_eq!(sub.shorts(0x7300), Some(vec![514.0, 511.0, 512.0, 513.0]), "{order:?}"); + assert_eq!(sub.shorts(0x7303), Some(vec![256.0, 500.0, 385.0, 256.0])); + assert_eq!(sub.shorts(0x74c3), Some(vec![16.0, 8.0, 3904.0, 2600.0])); + // the wrong key (or the default one) gives noise + assert_eq!(SubIfd::decrypt(&enc, 230812, order).shorts(0x7300), None); + } + } + + #[test] + fn keystream_follows_its_recurrence() { + let mut zeros = vec![0u8; 62114]; + apply_keystream(&mut zeros); + // the recurrence, past the seed, and the clear tail + let word = |n: usize| u32::from_be_bytes(zeros[4 * n..4 * n + 4].try_into().unwrap()); + assert_eq!(word(127), SEED[64] ^ SEED[0]); + assert!((200..15528).all(|n| word(n) == word(n - 63) ^ word(n - 127))); + assert_eq!(zeros[62112..], [0, 0]); + // applying it twice restores the input + let mut again = zeros.clone(); + apply_keystream(&mut again); + assert!(again[..62112].iter().all(|&b| b == 0)); + } + + #[test] + fn reads_entries_inline_and_at_file_offsets() { + for order in [Little, Big] { + let start = 75964; + let enc = block(start, &[(0x7310, SHORT, &[512; 4]), (0x7311, SHORT, &[256]), (0x7313, SSHORT, &[2212, 1024, 1024, 1416])], order); + let sub = SubIfd::decrypt(&enc, start, order); + assert_eq!(sub.shorts(0x7313), Some(vec![2212.0, 1024.0, 1024.0, 1416.0]), "{order:?}"); + assert_eq!(sub.shorts(0x7310), Some(vec![512.0; 4])); + assert_eq!(sub.shorts(0x7311), Some(vec![256.0])); + assert_eq!(sub.shorts(0x7312), None); + // SSHORT is signed + let enc = block(start, &[(0x7313, SSHORT, &[0xffff, 1024])], order); + assert_eq!(SubIfd::decrypt(&enc, start, order).shorts(0x7313), Some(vec![-1.0, 1024.0])); + } + } + + /// An `SR2Private` directory of `entries` `(tag, type, count, value field)` at file offset 0. + fn private(entries: &[(u16, u16, u32, [u8; 4])], order: ByteOrder) -> Vec { + let mut out = Vec::new(); + order.put_u16(&mut out, entries.len() as u16); + for &(tag, kind, count, value) in entries { + order.put_u16(&mut out, tag); + order.put_u16(&mut out, kind); + order.put_u32(&mut out, count); + out.extend(value); + } + order.put_u32(&mut out, 0); + out + } + + /// `(offset, length, key)` as one of the readers selects them. + type Selected = (Option, Option, Option<[u8; 4]>); + + /// What `SubIfd::read` on main selects: the TIFF reader's parse of the directory with main's options, then the + /// directory's `u64` and `bytes` lookups. + fn reader_selects(bytes: &[u8], at: usize, order: ByteOrder) -> Selected { + let opts = craft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; + let Ok((private, _)) = craft_tiff::parse_ifd_at(bytes, at as u64, order, 0, false, &opts) else { return (None, None, None) }; + let n = |tag: u16| private.u64(tag).and_then(|v| usize::try_from(v).ok()); + (n(SUBIFD_OFFSET), n(SUBIFD_LENGTH), private.bytes(SUBIFD_KEY).and_then(|b| <[u8; 4]>::try_from(b).ok())) + } + + /// Synthetic `SR2Private` directories at offset 0, with what each tests: filler entries ahead of the fields, and + /// unreadable, unusual or valid first occurrences of each field ahead of a valid second one. + fn differential_cases(order: ByteOrder) -> Vec<(String, Vec)> { + let w = |v: u32| { + let mut b = Vec::new(); + order.put_u32(&mut b, v); + [b[0], b[1], b[2], b[3]] + }; + let h = |a: u16, b: u16| { + let mut out = Vec::new(); + order.put_u16(&mut out, a); + order.put_u16(&mut out, b); + [out[0], out[1], out[2], out[3]] + }; + let valid = [(SUBIFD_OFFSET, LONG, 1, w(4096)), (SUBIFD_LENGTH, LONG, 1, w(64)), (SUBIFD_KEY, UNDEFINED, 4, KEY)]; + let mut cases = Vec::new(); + for fillers in [0usize, 1, 255, 256, 257, 8189, 8190, 8191, 8192, 8193] { + let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers]; + entries.extend(valid); + cases.push((format!("{fillers} fillers"), private(&entries, order))); + } + // a first occurrence ahead of the valid one; pointer values point into a 32-byte tail after the directory + let tail_at = 2 + 12 * 4 + 4; + let mut tail: Vec = (1..=32).collect(); + tail[4..8].copy_from_slice(b"\x11\x22\x33\x44"); + tail[8] = 0; + let numeric: Vec<(&str, u16, u32, [u8; 4])> = vec![ + ("unknown type 0", 0, 1, w(8)), + ("unknown type 14", 14, 1, w(8)), + ("unknown type 0xffff", 0xffff, 1, w(8)), + ("LONG[2] past the file", LONG, 2, w(u32::MAX)), + ("LONG[2] straddling the end", LONG, 2, w(tail_at as u32 + 28)), + ("LONG[u32::MAX]", LONG, u32::MAX, w(tail_at as u32)), + ("DOUBLE[u32::MAX]", 12, u32::MAX, w(tail_at as u32)), + ("LONG[0]", LONG, 0, w(0)), + ("LONG[2] in the file", LONG, 2, w(tail_at as u32)), + ("SHORT[1]", SHORT, 1, h(9, 0)), + ("SHORT[2] inline", SHORT, 2, h(9, 10)), + ("BYTE[3]", BYTE, 3, [5, 6, 7, 0]), + ("SSHORT -1", SSHORT, 1, h(0xffff, 0)), + ("SSHORT 5", SSHORT, 1, h(5, 0)), + ("SLONG -1", 9, 1, w(u32::MAX)), + ("LONG8 at a pointer", 16, 1, w(tail_at as u32)), + ("RATIONAL", 5, 1, w(tail_at as u32)), + ("ASCII", 2, 4, *b"abc\0"), + ("IFD", IFD, 1, w(4096)), + ]; + for (tag, other) in [(SUBIFD_OFFSET, [valid[1], valid[2]]), (SUBIFD_LENGTH, [valid[0], valid[2]])] { + for (what, kind, count, field) in &numeric { + let valid_one = if tag == SUBIFD_OFFSET { valid[0] } else { valid[1] }; + let mut d = private(&[(tag, *kind, *count, *field), valid_one, other[0], other[1]], order); + d.extend(&tail); + cases.push((format!("{tag:#x}: first {what}"), d)); + } + } + let keys: Vec<(&str, u16, u32, [u8; 4])> = vec![ + ("unknown type", 0, 4, KEY), + ("UNDEFINED[8] past the file", UNDEFINED, 8, w(u32::MAX)), + ("UNDEFINED[5] in the file", UNDEFINED, 5, w(tail_at as u32)), + ("UNDEFINED[3]", UNDEFINED, 3, KEY), + ("UNDEFINED[0]", UNDEFINED, 0, KEY), + ("BYTE[4]", BYTE, 4, [1, 2, 3, 4]), + ("SBYTE[4]", 6, 4, KEY), + ("ASCII[4]", 2, 4, KEY), + ("ASCII[4] with NUL", 2, 4, [0x41, 0x42, 0, 0x43]), + ("ASCII[4] invalid UTF-8", 2, 4, [0xff, 0x41, 0, 0]), + ("ASCII[4] two invalid bytes", 2, 4, [0xc3, 0x28, 0x41, 0]), + ("ASCII[9] at a pointer, NUL at 4", 2, 9, w(tail_at as u32 + 4)), + ("ASCII[8] at a pointer, no NUL", 2, 8, w(tail_at as u32 + 12)), + ("LONG[1]", LONG, 1, KEY), + ]; + for (what, kind, count, field) in &keys { + let mut d = private(&[(SUBIFD_KEY, *kind, *count, *field), valid[0], valid[1], valid[2]], order); + d.extend(&tail); + cases.push((format!("key: first {what}"), d)); + } + // the reader's value budget (8 × the data's length + 1 MiB), used up by entries aliasing the data + for aliases in [23u32, 24] { + let mut entries = vec![(0x7300, UNDEFINED, 1 << 16, w(0)); aliases as usize]; + entries.extend(valid); + let mut d = private(&entries, order); + d.resize(1 << 16, 0); + cases.push((format!("{aliases} entries using the value budget"), d)); + } + // no entries; more entries declared than the data holds + cases.push(("empty directory".into(), private(&[], order))); + let mut long = private(&valid, order); + long[..2].copy_from_slice(&if order == Little { 10u16.to_le_bytes() } else { 10u16.to_be_bytes() }); + long.truncate(2 + 12 * 3); + cases.push(("10 entries declared, 3 present".into(), long)); + cases + } + + #[test] + fn private_fields_match_the_tiff_readers_selection() { + let mut differ = Vec::new(); + for order in [Little, Big] { + for (case, d) in differential_cases(order) { + let f = private_fields(&d, 0, order); + let (ours, reader) = ((f.offset, f.length, f.key), reader_selects(&d, 0, order)); + if ours != reader { + differ.push(format!("{order:?} {case}: {ours:?}, reader {reader:?}")); + } + } + assert_eq!(private_fields(&[], 0, order), PrivateFields::default()); + assert_eq!(private_fields(&private(&[], order), u64::MAX, order), PrivateFields::default()); + } + assert!(differ.is_empty(), "{} cases differ from the TIFF reader:\n{}", differ.len(), differ.join("\n")); + // what the reader (and so this) gives in the cases the review named: the limit is 8,192 entries, a later + // copy stands in for an unreadable first one, and a used-up value budget hides the fields + let found = |d: &[u8]| private_fields(d, 0, Little) == PrivateFields { offset: Some(4096), length: Some(64), key: Some(KEY) }; + let cases = differential_cases(Little); + let case = |name: &str| cases.iter().find(|(c, _)| c == name).map(|(_, d)| d.clone()).unwrap(); + for name in [ + "0 fillers", + "256 fillers", + "8189 fillers", + "0x7200: first LONG[2] past the file", + "0x7201: first unknown type 14", + "key: first UNDEFINED[8] past the file", + "23 entries using the value budget", + ] { + assert!(found(&case(name)), "{name}"); + } + for name in ["8190 fillers", "8192 fillers", "24 entries using the value budget", "0x7200: first ASCII", "key: first UNDEFINED[3]"] { + assert!(!found(&case(name)), "{name}"); + } + } + + /// The case that broke on review: valid entries ahead of the three fields, and an unreadable first offset ahead of + /// a valid one. Main's reader finds the block; so must this one (else the black level, white balance and crop it + /// holds are lost). + #[test] + fn the_block_is_found_behind_filler_and_unreadable_entries() { + for order in [Little, Big] { + for (fillers, unreadable_first) in [(256usize, false), (300, false), (0, true), (256, true)] { + let w = |v: u32| { + let mut b = Vec::new(); + order.put_u32(&mut b, v); + [b[0], b[1], b[2], b[3]] + }; + let n = fillers + 3 + usize::from(unreadable_first); + let start = 2 + 12 * n + 4; + let block = block(start, &[(0x7310, SHORT, &[800; 4])], order); + let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers]; + if unreadable_first { + entries.push((SUBIFD_OFFSET, LONG, 2, w(u32::MAX))); + } + entries.extend([ + (SUBIFD_OFFSET, LONG, 1, w(start as u32)), + (SUBIFD_LENGTH, LONG, 1, w(block.len() as u32)), + (SUBIFD_KEY, UNDEFINED, 4, KEY), + ]); + let mut file = private(&entries, order); + assert_eq!(file.len(), start); + file.extend(&block); + let mut pointer = Vec::new(); + order.put_u32(&mut pointer, 0); + let ifd0 = Ifd { + entries: vec![craft_tiff::Entry { tag: t::DNG_PRIVATE_DATA, value: craft_tiff::Value::Byte(pointer), offset: 0 }], + ..Default::default() + }; + let black = SubIfd::read(&file, &ifd0, order).and_then(|s| s.shorts(0x7310)); + assert_eq!(black, Some(vec![800.0; 4]), "{order:?}: {fillers} fillers, unreadable first offset {unreadable_first}"); + } + } + } + + #[test] + fn hostile_directories_give_none() { + let start = 4096; + let good = block(start, &[(0x7313, SSHORT, &[2212, 1024, 1024, 1416])], Little); + let read = |b: &[u8], start: usize| SubIfd::decrypt(b, start, Little).shorts(0x7313); + assert!(read(&good, start).is_some()); + // decrypted at another file offset, the value offset points before or past the block + assert_eq!(read(&good, start + 64), None); + assert_eq!(read(&good, start - 64), None); + // every truncation, down to empty + for n in 0..good.len() - 2 { + assert_eq!(read(&good[..n], start), None, "{n} bytes"); + } + // garbage: still encrypted; 65535 entries of tag 0xffff; a huge count, another type, a wild offset + assert_eq!(SubIfd { start, plain: good.clone(), order: Little }.shorts(0x7313), None); + assert_eq!(SubIfd { start, plain: vec![0xff; 1000], order: Little }.shorts(0x7313), None); + let mut plain = good.clone(); + apply_keystream(&mut plain); + let patched = |at: usize, bytes: &[u8]| { + let mut p = plain.clone(); + p[at..at + bytes.len()].copy_from_slice(bytes); + apply_keystream(&mut p); + read(&p, start) + }; + assert_eq!(patched(6, &u32::MAX.to_le_bytes()), None); // count + assert_eq!(patched(4, &4u16.to_le_bytes()), None); // LONG + assert_eq!(patched(10, &u32::MAX.to_le_bytes()), None); // value offset + // an entry count past the end of the block: the scan stops where the block ends, so an entry before that + // point is still found + assert_eq!(patched(0, &u16::MAX.to_le_bytes()), Some(vec![2212.0, 1024.0, 1024.0, 1416.0])); + } +} diff --git a/crates/raw/src/srw.rs b/crates/raw/src/srw.rs index 690459832..d747acfe5 100644 --- a/crates/raw/src/srw.rs +++ b/crates/raw/src/srw.rs @@ -188,6 +188,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result usize { (c.width as usize).saturating_mul(c.height as usize).saturating_mul(ccpp) } +/// Consecutive index ranges of `chunks` whose combined nominal samples (`ccpp` per pixel) stay within the group size +/// [`read_image`] uses for an image of `total` samples (at least one chunk per range). A decoder that holds its +/// decoded tiles until it copies them decodes one range at a time, so it holds a group's frames beside the output, +/// not the whole grid's (storytold/lightcraft#1517, storytold/lightcraft#2554). +#[cfg(feature = "arw")] +pub(crate) fn decode_groups(chunks: &[Chunk], ccpp: usize, total: usize) -> Vec> { + let budget = (total / 4).max(1 << 20); + let mut groups = Vec::new(); + let (mut from, mut taken) = (0usize, 0usize); + for (i, c) in chunks.iter().enumerate() { + let n = chunk_samples(c, ccpp); + if i > from && taken.saturating_add(n) > budget { + groups.push(from..i); + (from, taken) = (i, 0); + } + taken = taken.saturating_add(n); + } + if from < chunks.len() { + groups.push(from..chunks.len()); + } + groups +} + #[allow(clippy::too_many_arguments)] fn decode_chunk( data: &[u8], @@ -927,4 +950,24 @@ mod budget_tests { assert!(matches!(&err, RawError::Corrupt(m) if m.contains("8000 bytes of image data")), "{err:?}"); } } + + /// storytold/lightcraft#2554: tiled decoders decode the grid in groups of at most a quarter of the image (1 Mi samples at + /// least), each group at least one chunk, covering every chunk once and in order. + #[cfg(feature = "arw")] + #[test] + fn decode_groups_split_the_grid() { + let info = aliased(4096, 4096, 512, 64); + let chunks = info.chunks(64); + assert_eq!(chunks.len(), 64); + let groups = decode_groups(&chunks, 1, 4096 * 4096); + // a quarter of 16 Mi samples is 4 Mi: 16 tiles of 256 Ki samples per group + assert_eq!(groups.len(), 4); + assert!(groups.iter().all(|g| g.len() == 16)); + assert_eq!(groups.first().map(|g| g.start), Some(0)); + assert!(groups.windows(2).all(|p| p[0].end == p[1].start)); + assert_eq!(groups.last().map(|g| g.end), Some(64)); + // a tile larger than the budget is still a group of its own + let big = aliased(8192, 8192, 8192, 64); + assert_eq!(decode_groups(&big.chunks(64), 1, 1 << 20), vec![0..1]); + } } diff --git a/crates/raw/tests/it/dng.rs b/crates/raw/tests/it/dng.rs index 695591a4c..d4c5f702c 100644 --- a/crates/raw/tests/it/dng.rs +++ b/crates/raw/tests/it/dng.rs @@ -62,6 +62,7 @@ fn synthetic(w: usize, h: usize, cfa: Option, cpp: usize) -> RawImage { opcodes: OpcodeLists::default(), camera, cinema: Default::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/tests/it/robust.rs b/crates/raw/tests/it/robust.rs index 99a32e852..4f59e7355 100644 --- a/crates/raw/tests/it/robust.rs +++ b/crates/raw/tests/it/robust.rs @@ -26,6 +26,7 @@ fn sample(comp: DngCompression) -> Vec { opcodes: OpcodeLists::default(), camera: Default::default(), cinema: Default::default(), + lens_metadata: Default::default(), }; craft_raw::write_dng(&raw, &DngWriteOptions { compression: comp, ..Default::default() }).unwrap() } diff --git a/crates/raw/tests/it/vendor_robust.rs b/crates/raw/tests/it/vendor_robust.rs index 521e7bafe..148fdb498 100644 --- a/crates/raw/tests/it/vendor_robust.rs +++ b/crates/raw/tests/it/vendor_robust.rs @@ -11,7 +11,9 @@ const H: u32 = 8; /// A CFA TIFF the way most makers lay it out: IFD0 (make) + a SubIFD holding the raw strip. fn cfa_tiff(make: &str, compression: u16, bits: u16, order: ByteOrder) -> Vec { let px = (W * H) as usize; - let strip: Vec = (0..px * 2).map(|i| (i * 37 % 251) as u8).collect(); + // Sony's ARW2 (32767) packs a sample per byte + let len = if compression == 32767 { px } else { px * 2 }; + let strip: Vec = (0..len).map(|i| (i * 37 % 251) as u8).collect(); let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![W])); @@ -256,6 +258,9 @@ fn magic_tiff(magic: &[u8; 4], make: &str) -> Vec { fn samples() -> Vec> { vec![ + cfa_tiff("SONY", 32767, 12, ByteOrder::Little), + cfa_tiff("SONY", 1, 14, ByteOrder::Little), + cfa_tiff("SONY", 32766, 14, ByteOrder::Little), magic_tiff(b"IIRO", "OLYMPUS IMAGING CORP."), raf(), rw2(),