cd /news/developer-tools/sony-arw6-llvc-v3-reverse-engineered… · home topics developer-tools article
[ARTICLE · art-66615] src=github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Sony ARW6 (LLVC v3) reverse-engineered decoder

A developer has reverse-engineered and released an open-source decoder for Sony's ARW6 lossy raw format, used by the ILCE‑7RM6 and ILCE‑7M5 cameras, with the initial implementation done using Claude (Fable 5 and Opus 4.8). The decoder successfully processes APS-C and full-frame images and outputs DNG files, though the author notes potential errors in the format description.

read12 min views3 publishedJul 21, 2026
Sony ARW6 (LLVC v3) reverse-engineered decoder
Image: source

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 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):

component what it is
green_lo
the low‑pass half of the green channel
green_hi
the high‑pass half of the green channel
chroma_r
red pixels as a residual against the neighbouring greens
chroma_b
blue pixels as a residual against the neighbouring greens

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:

green_lo

: processed like chromas;green_hi

: stored as‑is.

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 )

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):

LL— low both ways: the blurry core, split again and stored at the next level;** HL**— vertical edges,** LH**— horizontal edges,** HH**— diagonal detail.

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.

All regions use a shared canvas:

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

— where does the first chunk start on the coordinates;comp_top

— where does thefirst rowstart on the coordinates.

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:

  • 2 green_hi

rows; - for D1: 1 HL row (canvas row 10) and 1 LH and HH row each (row 11), for the 3 components ( green_lo

,chroma_r

,chroma_b

); - for D2: 1 LH and HH row each, again per component — canvas row 10 is also on D2's odd lattice (rows 2, 6, 10, …);

  • no D3 or LL rows.

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:

top

/bottom

— the first and one‑past‑lastlattice‑row indicesthat are inside the image;row_base

— the first lattice row at/after the chunk origin: chunk 0's window starts there.

For 3 orientations, the lattices used are:

  • HL: evenlattices; - LH, HH: oddlattices.

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.

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:

tag meaning
ImageWidth / ImageLength (0x100 / 0x101)
dimensions
StripOffsets / StripByteCounts (0x111 / 0x117)
location + size of the compressed strip
BlackLevel (0x7310, Sony‑private)
black level per Bayer channel, R G1 G2 B (expected uniform)
WhiteLevel (0xc61d)
white (saturation) level
WB_RGGBLevels (0x7313, Sony‑private)
white-balance level per Bayer channel, R G1 G2 B

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).

See also: container.read_tiff

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.

See also: container.parse_tiles

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:

region contents components
0 LL
3
1 D3 — coarsest detail
3
2 D2
3
3 D1 — finest detail
3
4 green_hi
1

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.

module role
decode.py
top‑level decoder: decode_tile (one tile → mosaic) and decode (full mosaic)
container.py
byte structure: TIFF/strip/tile parsing, the tile sub‑header, per‑component headers
geometry.py
the canvas coordinate frame and every region's derived geometry + component count
bitstream.py
Golomb‑Rice entropy decoding of the chunks
idwt.py
the inverse 5/3 wavelet (IDWT)
pixelops.py
dequant, LL predictor, delinearisation curve
utils.py
MSB bit readers, integer helpers
dngwriter.py
wrap a decoded mosaic into a mosaiced DNG
convert.py
the CLI (python -m arw6_proto )
── more in #developer-tools 4 stories · sorted by recency
── more on @sony 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/sony-arw6-llvc-v3-re…] indexed:0 read:12min 2026-07-21 ·