Hacker News
July 21, 202610 min read
This is a decoder for Sony's ARW6 lossy raw format, used by (at least) ILCE‑7RM6 and ILCE‑7M5 when shooting lossy compressed raw (lossless compressed ARW is a different, LJPEG-based format), along with a format description as far as I could figure it out.
It successfully decodes APS-C and full-frame images from rawdb, along with the ones I took with my 7RM6.
Most of the research and the initial implementation was done with Claude (Fable 5 and Opus 4.8).
Unfortunately, I don't remember much about image compression, and this format gave me lots of trouble to understand (the canvas was a pain!), so I can't promise I haven't missed or misexplained something.
Any errors made here are mine to blame on!
nix develop python -m arw6_proto FILE.ARW OUT.dng The data The image is split into one or several tiles with specified offsets, which can be decoded separately; their layout is described in the bitstream section.
The pixels from the Bayer grid are rearranged into (we call these components ):
The green channel is split once by a single horizontal 5/3 DWT into green_lo and green_hi . This split is a transform in its own right and is not the 2‑D wavelet described below. The halves are then coded very differently:
A red or blue pixel is stored as a residue from the two greens of its own 2×2 Bayer cell: one beside it, one above/below. After the image is split by channels, they are the two adjacent columns (left and right). R and B are stored as:
chroma_r = (R − mean(g1, g2)) / 2 chroma_b = (B − mean(g1, g2)) / 2 Green is stored as signed integers, offset by 2048. When we restore, we clip all values to 12 bit.
The Bayer mosaic is then assembled ( decode.assemble_tile ):
R = clip( (g1 + g2)//2 + 2·chroma_r , 0, 4095 ) B = clip( (g1 + g2)//2 + 2·chroma_b , 0, 4095 ) Linearisation The bit depth seems to be a codec constant, 12 bits, and the component values are compressed. A fixed curve maps each code back to a linear sensor value. The curve is logarithmic (Cineon / S‑Log family) with a linear toe below a knee K :
V(c) = c for c ≤ K V(c) = (K − A) + A·exp((c − K)/A) for c > K with two design inputs — the log rate A ≈ 658.4 and the white level VMAX = 39003 reached at the top code. The body above already meets the toe with slope 1 at the knee; K then follows from V(4095) = VMAX .
These two constants were fit against a code-value table generated by running samples through Adobe DNG Converter ( scripts/delin_fit_table.csv ; the fit is scripts/fit_delin_curve.py ). Adobe's actual curve is still a bit different and I couldn't figure out how exactly it is constructed.
Delinearisation is the last decoding step, applied to the assembled mosaic.
While green_hi is stored as-is, green_lo and the two chromas are each put through a 3‑level 2‑D 5/3 DWT.
A 2‑D wavelet level filters both axes into Low/High, producing four sub‑bands named by (horizontal, vertical):
The three high‑frequency sub‑bands (HL, LH, HH) are the orientations stored at each detail level; the LL is fed to the next level. After the coarsest level you are left with a single small LL . Numbering the detail levels finest‑first:
component = D1 (finest) + D2 + D3 (coarsest) + LL └── detail levels, 3 orientations each ──┘ └ 1 orient Every one of D1 , D2 , D3 , LL is stored on its own; they differ only in how many orientations they hold — each detail level three, LL one. We call these regions ; a region holds its level's data for all the components that go through the DWT.
The final LL is additionally stored as horizontal differences; an inverse DPCM undoes it ( pixelops.predict_horizontal ).
Decoding combines the levels coarsest→finest ( idwt.idwt ). There is a possible parity flip, see below.
The region-component rows are split into chunks which are encoded separately. Also, some of the detail levels are merged at flipped parity during the IDWT (the flips below). Both are derived from the number of detail levels and the tile height. Because none of this is stored on disk, we need to derive it too to decode properly.
Each orientation uses a lattice — a strided subset of this canvas's rows. The chunks are sized so that the coarsest lattice has one row per chunk: with three detail levels the coarsest step is 2³, so chunks are 8 canvas rows tall. Lattices can be odd or even; we know which orientations use which. Lattices correspond to the detail levels after the DWT; each level halves the resolution, so its lattice is twice as sparse. A lattice can be reused for different data (then exactly the same layout is used for it).
The coordinate axis is shared among regions. Two parameters are set:
chunk_origin is set to the first row of the coarsest level's detail (odd) lattice — half the coarsest step. comp_top is set such that given a known tile height, the first and the last chunks are symmetrical in amount of data they have.
To be clear, these coordinates never appear in the bitstream; the canvas only tells the decoder how many rows of each region to decode from each chunk.
For instance, given the diagram above, the first chunk contains everything at canvas rows 10 and 11:
Chunks are encoded separately by regions and components.
An orientation only owns the lattice points that fall inside the real image. So, for a given lattice we calculate:
For 3 orientations, the lattices used are:
Finally, the flips . DWT splits the rows into high and low; the order may be reversed. During IDWT, we must merge in the same order. The level's rows alternate between its two lattices; the merge is flipped when the topmost row inside the image falls on the odd lattice rather than the even one. We calculate the flip bit per detail level.
The horizontal passes never flip. The one exception is recombining the green halves ( decode.combine_green ), which is always flipped, putting green_hi on the even columns.
As to why this canvas exists and why Sony doesn't just encode every region and component by chunks starting from row 0, I have no idea. Maybe this is somehow tied to how the encoder works.
The encoder quantises the wavelet coefficients; this is the only lossy step. Each chunk carries its own per‑orientation quantiser q .
Decoding dequantises with a mid‑rise reconstruction of each magnitude to its bin centre, (mag + 0.5)·2^q ; magnitude 0 stays 0, q == 0 means "no quantisation", signs are preserved.
The TIFF container An .ARW is a TIFF file containing a compression tag value for ARW6 (32766) in one of its IFDs. It can also contain a JPEG preview in a separate IFD.
The standard TIFF geometry tags are used:
The raw image is stored as a single strip ( StripOffsets[0] / StripByteCounts[0] ).
The black and white levels are stored halved (I'm not sure why).
The image (strip in TIFF terminology) is split into independent, rectangular tiles, not to be confused with the TIFF tiles. The strip begins with a little-endian header describing them:
strip: u32 count number of tiles u32 _unknown unknown count × TileRecord: u64 offset byte offset of this tile's payload within the strip u32 x, y tile's top-left corner in the Bayer mosaic u32 w, h tile's size, in mosaic pixels Tiles do not necessarily have the same size — e.g. a 10016‑wide image may split 5024 + 4992 . Full‑frame files that I've observed have 4 tiles; APS‑C crop files have 1. Each tile's offset marks the start of its self‑contained compressed blob.
Each tile starts with this header. Some sizes here and below are stored in blocks : units of 16 bytes. Everything is bit‑packed MSB‑first.
tile blob: 0x00 sub-header block (0x10 bytes): u32 magic ASCII "0000" (0x30303030) u32 _unknown0 unknown (increments across a frame's tiles) u16 width tile width, in mosaic pixels u16 comp_height component height = tile height / 2 u32 _unknown1 unknown (0x3d006e00 in every sample) 0x10 region-totals area (0x20 bytes = 2 blocks): 5 × u24 total block count of each coding region 0x30 5 × record block (0x10 bytes each), one per coding region: u8 component count (number of components in this region) count × u24 block count of each component's data in this region 0x80 [component data begins here] The number of regions is not in the file and is always five, in this order:
Within the shared regions the components are stored in the order green_lo , chroma_r , chroma_b .
The region totals and per‑component sizes are plain sizes; the byte offset of any one component's data is the sum of everything before it. A region's total may be larger than the sum of its components' sizes — the next region then starts past the padding.
Each region‑component starts with a 16‑byte header followed by a chunk table, then the entropy‑coded chunk data:
component: 0x00 header block (0x10 bytes): u16 table_blocks chunk-table size in blocks (header + table = table_blocks+1 blocks) u24 chunk_blocks entropy-chunk data size in blocks u8 _unknown0 unknown (constant format byte; 0x40 in every sample) u2 orient_count orientation count (high bits of its byte): 1 = LL/green_hi, 3 = detail u6 _unknown1 unknown (rest of the orientation-count byte — 0 in every sample, not assumed) u16 chunk_count number of chunk-table entries u8 _unknown2 unknown (constant format byte; 0x10 in every sample) u48 (padding) 0x10 chunk table (table_blocks blocks): chunk_count × chunk entry: u16 length byte length of chunk i (0 ⇒ empty chunk) orient_count × u4 q per-orientation quantiser, applied to that chunk's coefficients (table_blocks+1)·0x10 [entropy chunks begin here] Chunk i is the data for the i ‑th 8‑row cut of the canvas (component rows — 16 mosaic rows), anchored at chunk_origin . A chunk length of 0 marks a chunk that has none of this component's rows (e.g. the topmost chunks of the coarse regions, which fall in the margin "air" above the image).
Each chunk is an independent bitstream holding its rows of each orientation in turn, in the HL, LH, HH order (also the order of the q values in the chunk table). Each row is a line of ceil(width / 4) groups of four coefficients (the last group's excess coefficients are discarded), read with an adaptive Golomb‑Rice code ( bitstream.py ):
The Rice parameter k is updated with a variable-length code (skipped after the line's last group): 0 keeps the current k ; 10 followed by a unary number (zeroes ending with one) raises it by number + 1; 11 followed by a unary number lowers it by number + 1, the run capped so k never drops below zero (at the cap the terminating one is omitted).
Each line starts by reading an update code for the Rice parameter k , initially 0. While k = 0 , the line is coded as runs of all‑zero groups (an Elias‑gamma‑style exponent + mantissa), each run followed — unless it ends the line — by an unary code that raises k by at least 1.
Otherwise, each group reads four k ‑bit values, then an update code for k , then one sign bit per non‑zero value is stored.
The values are then dequantised with the chunk's own quantiser values from the chunk table.
Read what's here, then head to the original whenever you're ready - never required.
Continue Reading on Hacker NewsMeasuring What Matters with Jules
Google Developers July 21, 2026