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 <radial@gmail.com>
Co-authored-by: wnanbei <wnanbei@gmail.com>
Co-authored-by: lownlyrider <lownlyrider@gmail.com>
This commit is contained in:
Dylan M. Taylor
2026-11-11 21:42:48 -05:00
committed by GitHub
co-authored by radialmonster wnanbei lownlyrider
parent 5d1135eaaf
commit 2eddd3073e
38 changed files with 7850 additions and 12 deletions
Generated
+1
View File
@@ -1788,6 +1788,7 @@ dependencies = [
"proptest",
"rayon",
"serde",
"sha2 0.10.9",
"thiserror 2.0.21",
"zune-core",
"zune-jpeg",
+6 -2
View File
@@ -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.
+10 -1
View File
@@ -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.
+192
View File
@@ -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, <https://rawdb.dnglab.org/api/sets/Sony/ILCE-7RM6> (the data behind <https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6>, 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/<file> https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/<source path>`, 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", <https://pub.smpte.org/latest/rdd34/rdd34-2015.pdf> (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 |
@@ -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).
+388
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+995
View File
@@ -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<usize> {
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<usize> {
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<usize> {
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<Vec<TileEntry>> {
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<TileEntry> = 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<StreamRef>,
}
/// `n` bits at bit position `pos` (MSB-first) of a tile's word 0.
fn field(w: &[u8], pos: usize, n: usize) -> Option<u32> {
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<TileHeader> {
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<u32> {
(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<Vec<Half>> {
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<usize> {
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<u8> {
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<usize>,
l1: Vec<usize>,
h1: Vec<usize>,
l2: Vec<usize>,
h2: Vec<usize>,
l3: Vec<usize>,
h3: Vec<usize>,
}
/// 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<usize>, Vec<usize>) {
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<usize> = 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<Vec<i32>>);
/// 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<Vec<HalfLines>> {
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<i32>]) -> 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<HalfLines>], layouts: &[HalfRows], vs: usize, w2: usize, ph: [u8; 3]) -> Result<Plane> {
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<TilePlanes> {
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<HalfRows> = (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::<Vec<_>>().into_iter().collect::<Result<Vec<_>>>()?;
let comps =
(0..3).into_par_iter().map(|c| component(c, &streams, &layouts, vs, w2, ph)).collect::<Vec<_>>().into_iter().collect::<Result<Vec<_>>>()?;
// 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<Vec<u16>> {
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<Vec<u16>> {
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<Vec<u16>> {
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<Vec<u16>> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect::<Result<_>>()?;
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<u8>, TileSpec) {
seeded_tile(w, h, s, qi, 7)
}
fn seeded_tile(w: usize, h: usize, s: u8, qi: Qis, seed: u64) -> (Vec<u8>, 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::<Vec<_>>(),
[(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<i32> = 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<i32> = (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::<usize>(), 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
}
}
+55
View File
@@ -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));
}
}
+484
View File
@@ -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<i32>, Vec<i32>) {
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<i32> = (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<i32> = (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<u8>,
}
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<u8> {
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<u32> = 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<usize>, Vec<usize>) {
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<Vec<i32>> {
rows.iter().map(|&r| plane.row(r).unwrap().iter().map(|&v| quantize(v, qi)).collect()).collect()
}
fn encode_lines(lines: &[Vec<i32>]) -> Vec<u8> {
let mut w = BitWriter::default();
for l in lines {
vld_encode_line(l, l.len(), &mut w);
}
w.finish()
}
fn pad16(v: &mut Vec<u8>) {
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<u8>, Vec<u32>)]) -> Vec<u8> {
let (mut idx, mut data) = (Vec::new(), Vec::new());
for pair in halves.chunks(2) {
let mut nib: Vec<u32> = 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<u8> {
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<u8>, Vec<u32>)>>> =
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<usize> = (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<Vec<i32>>; 3]| parts.into_iter().flatten().collect::<Vec<_>>();
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<i32>>, Vec<u32>); 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<Vec<Vec<u8>>> = 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<usize> = 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<u8>)], width: usize, height: usize) -> Vec<u8> {
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<u8>)], width: usize, height: usize, bits: u16) -> Vec<u8> {
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<u8>)], width: usize, height: usize) -> Vec<u8> {
arw6_file_with(tiles, width, height, false, 14)
}
fn arw6_file_with(tiles: &[(usize, usize, usize, usize, Vec<u8>)], width: usize, height: usize, white: bool, bits: u16) -> Vec<u8> {
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<u8>, 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)
}
File diff suppressed because it is too large Load Diff
+671
View File
@@ -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<f64>) -> 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<Vec<f64>>,
pub vignetting: Option<Vec<f64>>,
pub ca: Option<(Vec<f64>, Vec<f64>)>,
/// In-camera on/off of distortion, vignetting and CA correction, as recorded.
pub flags: [Option<i64>; 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<usize>) -> Option<Vec<f64>> {
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<f64> {
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<f64> {
let (near, far) = (x.min(y), x.hypot(y));
let overshoots = |z: f64| -> Option<bool> {
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<Opcode> {
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<Opcode> {
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<f64>, active: Rect, crop: Rect) -> Vec<Opcode> {
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<Entry> = 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<f64> {
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<Opcode>, 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<f64> = 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::<Vec<_>>());
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::<Vec<_>>());
assert_eq!(ops.len(), 1);
}
}
+1
View File
@@ -296,6 +296,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
opcodes: OpcodeLists::default(),
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -392,6 +392,7 @@ pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode, limits: &
opcodes: OpcodeLists::default(),
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -63,6 +63,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, caller: &Limits) -> Result<RawIma
opcodes: OpcodeLists::default(),
camera,
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -441,6 +441,7 @@ fn decode_ifd(bytes: &[u8], tiff: &Tiff, pixels: &Ifd, main: &Ifd, mode: Mode, f
opcodes,
camera: Camera::from_ifd0(ifd0),
cinema: cinema_tags(ifd0, raw),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+5 -2
View File
@@ -153,6 +153,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
ifd.set(t::BLACK_LEVEL_DELTA_V, Value::SRational(b.delta_v.iter().map(|&v| srational(v as f64)).collect()));
}
let white: Vec<u32> = (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::<Vec<_>>()));
} else {
@@ -216,8 +217,9 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
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");
+1
View File
@@ -123,6 +123,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
opcodes: OpcodeLists::default(),
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
pixel_aspect: 1.0,
};
img.validate_for(mode)?;
+1
View File
@@ -117,6 +117,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
opcodes: OpcodeLists::default(),
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+189
View File
@@ -0,0 +1,189 @@
//! Lens-correction data a raw file records, kept with where it came from and separately from what LightCraft
//! applies.
//!
//! A readable table is not a correction. Each component carries a [`SonyLensStatus`]: only
//! [`SonyLensStatus::Applied`] components were converted into the image's `OpcodeList3`, and that is decided by
//! independent validation per model, coding and component (see `docs/sony-lens-corrections.md`), never by the mere
//! presence of a table. Everything else here is metadata for diagnostics and later validation work.
use crate::Rect;
use serde::{Deserialize, Serialize};
/// Vendor lens-correction metadata of a raw file (see the module docs). Empty for formats that record none, or
/// whose tables are read straight into opcodes.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LensMetadata {
/// Sony ARW: the raw image IFD's shading, lateral chromatic aberration and distortion records, with their
/// encrypted `SR2SubIFD` copies. `None` when neither directory holds any of them.
#[serde(default)]
pub sony: Option<SonyLensCorrections>,
}
/// 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<T> {
pub setting: SonyField<SonySetting>,
/// The table's samples after the leading sample count, which must equal their number.
pub table: SonyField<T>,
pub status: SonyLensStatus,
}
/// One field read from the raw IFD and from the `SR2SubIFD`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SonyField<T> {
/// 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<T>,
pub source: Option<SonySource>,
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<bool>,
/// 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<Rect>,
/// The raw IFD's `DefaultCropOrigin` / `DefaultCropSize` as recorded.
pub default_crop: Option<Rect>,
/// 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<T> SonyField<T> {
/// 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
}
}
+34
View File
@@ -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<RawImage> {
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,
}
}
+536
View File
@@ -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<u32> {
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<u32> {
// 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<Vec<i32>> {
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<i32> = 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<i32>,
}
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<Vec<i32>> {
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<i32>, 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<Plane> {
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<Plane> {
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<Plane> {
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<Plane> {
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<Plane> {
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<u8> {
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<i32>, 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);
}
}
}
}
+1
View File
@@ -124,6 +124,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
opcodes: OpcodeLists::default(),
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
pixel_aspect: 1.0,
};
img.validate_for(mode)?;
+1
View File
@@ -168,6 +168,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
opcodes: OpcodeLists::default(),
camera: tiff.as_ref().and_then(|t| t.ifds.first()).map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
pixel_aspect: 1.0,
};
img.validate_for(mode)?;
+2
View File
@@ -326,6 +326,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
},
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
@@ -657,6 +658,7 @@ fn small_raw(bytes: &[u8], tiff: &Tiff, info: &ImageInfo, strip: craft_tiff::ima
opcodes: OpcodeLists::default(),
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -344,6 +344,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
opcodes: OpcodeLists::default(),
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -236,6 +236,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
opcodes: OpcodeLists::default(),
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+16
View File
@@ -66,6 +66,22 @@ fn nikon_color_space(bytes: &[u8], tiff: &Tiff, exif: &Ifd, make: &str) -> 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<bool> {
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 })
+1
View File
@@ -253,6 +253,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
.map(Camera::from_ifd0)
.unwrap_or_else(|| Camera { model: Some(camera_name(bytes)).filter(|n| !n.is_empty()), ..Default::default() }),
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -661,6 +661,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() },
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+6 -6
View File
@@ -87,11 +87,11 @@ pub(crate) fn white_from_data(data: &[u16], bits: u32) -> 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();
+637
View File
@@ -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<u8>,
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<Self> {
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<Vec<f64>> {
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<usize>,
length: Option<usize>,
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<usize> {
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<u8> {
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<u8> {
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<i16> = 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<u8> {
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<usize>, Option<usize>, 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<u8>)> {
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<u8> = (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]));
}
}
+1
View File
@@ -188,6 +188,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawIma
opcodes: OpcodeLists::default(),
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
cinema: Default::default(),
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -47,6 +47,7 @@ fn an_active_area_that_wraps_past_the_image_is_corrupt() {
opcodes: OpcodeLists::default(),
camera: Camera::default(),
cinema: Default::default(),
lens_metadata: Default::default(),
pixel_aspect: 1.0,
};
assert!(raw.validate().is_err(), "`x + width` wrapped: the area is outside the image");
+43
View File
@@ -236,6 +236,29 @@ fn chunk_samples(c: &Chunk, ccpp: usize) -> 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<std::ops::Range<usize>> {
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]);
}
}
+1
View File
@@ -62,6 +62,7 @@ fn synthetic(w: usize, h: usize, cfa: Option<Cfa>, cpp: usize) -> RawImage {
opcodes: OpcodeLists::default(),
camera,
cinema: Default::default(),
lens_metadata: Default::default(),
}
}
+1
View File
@@ -26,6 +26,7 @@ fn sample(comp: DngCompression) -> Vec<u8> {
opcodes: OpcodeLists::default(),
camera: Default::default(),
cinema: Default::default(),
lens_metadata: Default::default(),
};
craft_raw::write_dng(&raw, &DngWriteOptions { compression: comp, ..Default::default() }).unwrap()
}
+6 -1
View File
@@ -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<u8> {
let px = (W * H) as usize;
let strip: Vec<u8> = (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<u8> = (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<u8> {
fn samples() -> Vec<Vec<u8>> {
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(),