collective

Browse / github.com/storytold/lightcraft / patch

LR-IMP-FORMATS: decode compressed Samsung SRW (32770, 32772, 32773)

LR-IMP-FORMATS: decode compressed Samsung SRW (32770, 32772, 32773) ## Problem Samsung NX bodies that write compressed raws (NX30, NX300, NX2000, NX3000, NX mini, NX1, NX500) only showed the embedded preview: the raw data could not be decoded, so no real development was possible. ## What changed - New `crates/raw/src/vendor/srwc.rs`: decoders for SRW compressions 32770 (row blocks), 32772 (Huffman) and 32773 (blocks), hooked into `srw.rs` and `vendor/mod.rs`. - Several bodies write a wrong CFAPattern, so for compressed files the Bayer layout is chosen by colour agreement with the embedded camera JPEG, falling back to the tag when the result isn't clear-cut. -

Status
open
Score
0.00
Adopters
0 other operators
Author
emmertex (imported from pull request #3004, open)
Base
387bed199fca44d76af5aa1c24dd7cc3de4c8db2
Size
+628 −13 in 3 files
Review
pending
Shared
2026-10-28
Id
c9829ba69a118a1a575d0c1a

How it went

No other operator has reported using it yet.

Diff

raw · crates/raw/src/vendor/mod.rs crates/raw/src/vendor/srw.rs crates/raw/src/vendor/srwc.rs

diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs--- a/crates/raw/src/vendor/mod.rs+++ b/crates/raw/src/vendor/mod.rs@@ -27,6 +27,7 @@ mod rafc; pub mod rw2; mod sr2; pub mod srw;+mod srwc;  use crate::{BlackLevel, Cfa, Rect}; use lightcraft_tiff::Tiff;diff --git a/crates/raw/src/vendor/srw.rs b/crates/raw/src/vendor/srw.rs--- a/crates/raw/src/vendor/srw.rs+++ b/crates/raw/src/vendor/srw.rs@@ -15,8 +15,8 @@ //!   NX1100): 12-bit samples, two in three bytes, rows without padding. The NX5/NX10/NX11 files read them MSB-first, //!   the others LSB-first; the file doesn't say which, the image does: in the wrong order neighbouring same-colour //!   samples differ 5 to 11 times more (10 files, every one decided by that margin), see [`lsb_is_smoother`].-//! - Every other shape of those and of the other private values (32770 with fewer bytes: NX30, NX300, NX2000,-//!   EK-GN120; 32772: NX mini, NX3000, NX3300; 32773: NX1, NX500) is a compressed coding: reported as unsupported.+//! - The compressed codings (32770 with a row table: NX30, NX300, NX2000, EK-GN120; 32772: NX mini, NX3000, NX3300;+//!   32773: NX1, NX500) are decoded by [`super::srwc`]; their `CFAPattern` is checked against the embedded JPEG. //! - The maker note is a plain IFD whose value offsets count from the start of the note, not of the TIFF. Several //!   vectors are stored offset by `EncryptionKey`, 11 integers in every file (element `i` of a vector uses key //!   `i mod 11`): `WB_RGGBLevelsUncorrected` and `WB_RGGBLevelsBlack` hold `value + key`, `RawData` holds@@ -46,6 +46,8 @@ const ENCRYPTION_KEY: u16 = 0xa020; const WB_UNCORRECTED: u16 = 0xa021; const WB_BLACK: u16 = 0xa028; const RAW_DATA: u16 = 0xa048;+/// In the raw IFD of compression 32770 files: the file offset of the row-offset table.+const ROW_TABLE: u16 = 0xa010;  /// The private `Compression` values of the raw image IFD. const COMPRESSIONS: std::ops::RangeInclusive<u16> = 32769..=32773;@@ -78,13 +80,22 @@ enum Coding {     /// 12-bit samples, two in three bytes: read MSB-first (the NX5, NX10 and NX11 files) or LSB-first (the later     /// bodies' files); the file does not say which, the smoother image does ([`lsb_is_smoother`]).     Packed12,+    /// Compression 32770 with a row-offset table (tag `0xa010` of the raw IFD): [`super::srwc::decode_v0`].+    RowBlocks(u64),+    /// Compression 32772: [`super::srwc::decode_v1`].+    Huffman,+    /// Compression 32773: [`super::srwc::decode_v2`].+    Blocks16, } -fn coding(compression: u16, w: usize, h: usize, strip: u64) -> Option<Coding> {+fn coding(compression: u16, w: usize, h: usize, strip: u64, row_table: Option<u64>) -> Option<Coding> {     let n = (w as u64).checked_mul(h as u64)?;     match compression {         32769 if strip == n.checked_mul(2)? => Some(Coding::Words),         32770 if w.is_multiple_of(2) && strip == n.checked_mul(12)? / 8 => Some(Coding::Packed12),+        32770 => row_table.map(Coding::RowBlocks),+        32772 => Some(Coding::Huffman),+        32773 => Some(Coding::Blocks16),         _ => None,     } }@@ -98,17 +109,29 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {     let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;     let chunks = info.chunks(bytes.len() as u64);     let strip: u64 = chunks.iter().map(|c| c.len).sum();-    let Some(coding) = coding(info.compression, w, h, strip).filter(|_| chunks.len() == 1) else {+    let row_table = raw.u64s(ROW_TABLE).and_then(|v| v.first().copied());+    let Some(coding) = coding(info.compression, w, h, strip, row_table).filter(|_| chunks.len() == 1) else {         return Err(RawError::Unsupported(format!(             "Samsung compressed raw (compression {}, {:.2} stored bits per sample)",             info.compression,             strip as f64 * 8.0 / n as f64         )));     };+    let mut depth = None;     let data = if mode == Mode::Full {         let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("SRW strip outside file".into()))?;         let mut d = vec![0u16; n];         match coding {+            Coding::RowBlocks(table) => {+                let table = usize::try_from(table).map_err(|_| RawError::Corrupt("SRW row table offset".into()))?;+                let strip = usize::try_from(chunks[0].offset).map_err(|_| RawError::Corrupt("SRW strip offset".into()))?;+                d = super::srwc::decode_v0(bytes, strip, table, w, h)?;+            }+            Coding::Huffman => d = super::srwc::decode_v1(src, w, h, u32::from(info.bits()))?,+            Coding::Blocks16 => {+                let (v, b) = super::srwc::decode_v2(src, w, h)?;+                (d, depth) = (v, Some(b));+            }             Coding::Words => read_u16s(src, ByteOrder::Little, &mut d),             Coding::Packed12 => {                 let stride = w / 2 * 3;@@ -134,12 +157,19 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {         (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },         _ => return Err(RawError::Unsupported("Samsung raw without a 2x2 CFAPattern".into())),     };+    // the compressed codings' CFAPattern is not reliable: the camera JPEG decides when it can+    let cfa = match (&data, coding) {+        (RawData::U16(d), Coding::RowBlocks(_) | Coding::Huffman | Coding::Blocks16) if !d.is_empty() => crate::preview::embedded_preview(bytes)+            .and_then(|j| super::srwc::layout_from_preview(&j, d, w, active, 0.0))+            .map_or(cfa, |pattern| Cfa { width: 2, height: 2, pattern }),+        _ => cfa,+    };     // saturation levels and black levels (R, G1, G2, B), in the file's own words     let levels = mn.as_ref().and_then(|m| keyed(m, RAW_DATA, 1)).filter(|v| v.len() == 12);     let RawData::U16(samples) = &data else { return Err(RawError::Unsupported("float SRW".into())) };     // saturation: the file's level, or lower where the samples pile up (EX1 clips at 15882 of its stated 16383)     let stated = levels.as_ref().filter(|v| v[4..].iter().all(|&x| x == v[4]) && (255..=65535).contains(&v[4])).map(|v| v[4] as f32);-    let bits = stated.map_or((info.bits() as u32).clamp(8, 16), |s| 32 - (s as u32).leading_zeros());+    let bits = stated.or(depth.map(|b| ((1u32 << b) - 1) as f32)).map_or((info.bits() as u32).clamp(8, 16), |s| 32 - (s as u32).leading_zeros());     let plateau = (!samples.is_empty()).then(|| white_from_data(&active_rows(samples, w, active), bits));     let white = match (stated, plateau) {         (Some(s), Some(p)) => s.min(p),@@ -471,24 +501,24 @@ mod tests {         assert_eq!(r.cfa.unwrap().name(), "RGGB");     } -    /// Files whose strip is not exactly the uncompressed size (or that use another private value) are compressed-    /// codings: reported as such, still recognised as SRW.+    /// Files whose strip is not exactly the uncompressed size and that carry no coding we read (32770 without its+    /// row table, 32769 short) are reported as unsupported, still recognised as SRW; garbage in the compressed codings+    /// is an error or a decoded image, never a panic.     #[test]     fn compressed_codings_are_reported_not_misread() {         let plain = |compression, strip: Vec<u8>| {             srw(Spec { w: 64, h: 32, compression, strip, pattern: Some([2, 1, 1, 0]), note: Some(full_note([0, 0, 64, 32], 4095, 0)), picture: None })         };-        for bytes in [-            plain(32770, vec![7; 64 * 32 * 12 / 8 - 3]),-            plain(32772, vec![7; 4000]),-            plain(32773, pack12(&ramp(64, 32, 4096))),-            plain(32769, vec![0; 64 * 32]),-        ] {+        for bytes in [plain(32770, vec![7; 64 * 32 * 12 / 8 - 3]), plain(32769, vec![0; 64 * 32])] {             assert_eq!(probe(&bytes), Some(RawFormat::Srw));             let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") };             assert!(why.contains("compressed"), "{why}");             assert!(probe_info(&bytes).is_err());         }+        for bytes in [plain(32772, vec![7; 4000]), plain(32773, pack12(&ramp(64, 32, 4096))), plain(32773, vec![0; 10])] {+            assert_eq!(probe(&bytes), Some(RawFormat::Srw));+            let _ = decode(&bytes);+        }     }      /// Without a maker note the samples still decode: whole array, no white balance, depth from the TIFF tags.diff --git a/crates/raw/src/vendor/srwc.rs b/crates/raw/src/vendor/srwc.rsnew file mode 100644--- /dev/null+++ b/crates/raw/src/vendor/srwc.rs@@ -0,0 +1,584 @@+//! Samsung SRW: the three compressed codings of the raw image.+//!+//! Sources: prose descriptions of the formats (the open decoders' documentation and our reading of how they work,+//! no code copied), checked against CC0 samples from raw.pixls.us (NX300 for compression 32770, NX3000 for 32772,+//! NX1 and NX500 for 32773: their decoded mosaics follow the camera's own JPEG, `corpus_samsung_srw`).+//!+//! All three read bits MSB-first; 32770 and 32773 take them from little-endian 32-bit words, 32772 from bytes.+//!+//! - **32770 with a row table** (NX30, NX300, NX2000, EK-GN120): tag `0xa010` of the raw IFD points at one+//!   little-endian 32-bit offset per row, counted from the strip. Each row is blocks of 16 samples: a direction bit,+//!   four 2-bit length updates (keep, +1, -1, or a new 4-bit length; lengths start at 7 in the first two rows, 4+//!   after), then 8 even- and 8 odd-position signed differences. The prediction is the sample one row (even+//!   positions) or two rows (odd) above, or, going left, the last even / odd sample of the previous block (128 at+//!   the row start). Finally the samples of each 2x2 cell swap their top-right and bottom-left values.+//! - **32772** (NX mini, NX3000, NX3300): one bit stream of canonical-Huffman-coded difference lengths (the+//!   lossless-JPEG difference coding) with a fixed 14-entry table; each sample adds its difference to the previous+//!   same-colour sample of the row, the first two of a row to those two rows of the same parity above.+//! - **32773** (NX1, NX500): a 16-byte header (bit depth, option flags, initial value), then rows starting at+//!   16-byte boundaries, each blocks of 16 samples (8 of one colour column parity, interleaved) with an optional+//!   quantizer step, a prediction mode (left, or the mean of two samples above at one of six offsets) and four+//!   difference lengths updated like 32770's.+use crate::{RawError, Result};++/// MSB-first bits from little-endian 32-bit words, loaded only when needed (so a row's end position is a word+/// boundary after its last used bit).+struct Words<'a> {+    src: &'a [u8],+    pos: usize,+    buf: u64,+    have: u32,+}++impl<'a> Words<'a> {+    fn new(src: &'a [u8], pos: usize) -> Self {+        Self { src, pos, buf: 0, have: 0 }+    }++    fn bits(&mut self, n: u32) -> Result<u32> {+        if n == 0 {+            return Ok(0);+        }+        if self.have < n {+            let w = self+                .src+                .get(self.pos..self.pos.saturating_add(4))+                .and_then(|b| b.try_into().ok())+                .map(u32::from_le_bytes)+                .ok_or_else(|| RawError::Corrupt("SRW compressed data truncated".into()))?;+            self.pos += 4;+            self.buf = (self.buf << 32) | u64::from(w);+            self.have += 32;+        }+        self.have -= n;+        Ok(((self.buf >> self.have) & ((1u64 << n) - 1)) as u32)+    }+}++/// A signed difference of `n` bits (two's complement).+fn signed(v: u32, n: u32) -> i32 {+    if n == 0 {+        0+    } else if v >> (n - 1) != 0 {+        v as i32 - (1i32 << n)+    } else {+        v as i32+    }+}++fn store(v: i32) -> u16 {+    v.clamp(0, u16::MAX as i32) as u16+}++/// Compression 32770 with a row-offset table at `table` (absolute file offset); `strip` is the strip's offset.+pub(crate) fn decode_v0(bytes: &[u8], strip: usize, table: usize, width: usize, h: usize) -> Result<Vec<u16>> {+    if h < 2 || width < 2 {+        return Err(RawError::Corrupt(format!("Samsung compressed raw {width}x{h}")));+    }+    // rows are whole 16-sample blocks: decode into rows padded to that, keep the stored width+    let w = width.next_multiple_of(16);+    let mut d = vec![0u16; w * h];+    for row in 0..h {+        let off = table+            .checked_add(row.saturating_mul(4))+            .and_then(|o| bytes.get(o..o.checked_add(4)?))+            .and_then(|b| b.try_into().ok())+            .map(u32::from_le_bytes)+            .ok_or_else(|| RawError::Corrupt("SRW row table truncated".into()))?;+        let mut bits = Words::new(bytes, strip.saturating_add(off as usize));+        let mut len = [if row < 2 { 7u32 } else { 4 }; 4];+        for col in (0..w).step_by(16) {+            let up = bits.bits(1)? == 1;+            let mut op = [0u32; 4];+            for o in &mut op {+                *o = bits.bits(2)?;+            }+            for (l, o) in len.iter_mut().zip(op) {+                *l = match o {+                    3 => bits.bits(4)?,+                    2 => l.saturating_sub(1),+                    1 => (*l + 1).min(16),+                    _ => *l,+                };+            }+            for parity in 0..2 {+                // the left prediction of this parity: the previous block's last sample of it+                let left = if col == 0 { 128 } else { i32::from(d[row * w + col - 2 + parity]) };+                for c in (parity..16).step_by(2) {+                    let n = len[(parity << 1) | (c >> 3)];+                    let diff = signed(bits.bits(n)?, n);+                    let pred = if up {+                        let r = row.checked_sub(1 + parity).ok_or_else(|| RawError::Corrupt("SRW block predicts from above the first row".into()))?;+                        i32::from(d[r * w + col + c])+                    } else {+                        left+                    };+                    d[row * w + col + c] = store(pred + diff);+                }+            }+        }+    }+    // each 2x2 cell is stored with its top-right and bottom-left samples exchanged+    for row in (0..h - 1).step_by(2) {+        for col in (0..w - 1).step_by(2) {+            d.swap(row * w + col + 1, (row + 1) * w + col);+        }+    }+    Ok(d.chunks_exact(w).flat_map(|r| &r[..width]).copied().collect())+}++/// The 32772 code table: (code length, difference length) in code order.+const V1_TABLE: [(u32, u32); 14] =+    [(3, 4), (3, 7), (2, 6), (2, 5), (4, 3), (6, 0), (7, 9), (8, 10), (9, 11), (10, 12), (10, 13), (5, 1), (4, 8), (4, 2)];++/// Compression 32772: the strip at `src`.+pub(crate) fn decode_v1(src: &[u8], w: usize, h: usize, bits_per_sample: u32) -> Result<Vec<u16>> {+    // canonical codes in table order: entry i takes the next 1024 >> len slots of a 10-bit lookup+    let mut lookup = [(0u32, 0u32); 1024];+    let mut n = 0usize;+    for &(cl, dl) in &V1_TABLE {+        for _ in 0..(1024 >> cl) {+            if let Some(s) = lookup.get_mut(n) {+                *s = (cl, dl);+            }+            n += 1;+        }+    }+    let mut d = vec![0u16; w * h];+    let (mut pos, mut buf, mut have) = (0usize, 0u64, 0u32);+    let mut vpred = [[0i32; 2]; 2];+    let limit = 1i64 << bits_per_sample.clamp(8, 16);+    for row in 0..h {+        let mut hpred = [0i32; 2];+        for col in 0..w {+            while have < 32 {+                // past the end: zero bits, caught below if a code needs them+                buf = (buf << 8) | u64::from(src.get(pos).copied().unwrap_or(0));+                pos += 1;+                have += 8;+            }+            let (cl, dl) = lookup[((buf >> (have - 10)) & 1023) as usize];+            have -= cl;+            let diff = if dl == 0 {+                0+            } else {+                let v = ((buf >> (have - dl)) & ((1u64 << dl) - 1)) as i32;+                have -= dl;+                if v & (1 << (dl - 1)) == 0 { v - ((1 << dl) - 1) } else { v }+            };+            if pos > src.len().saturating_add(8) {+                return Err(RawError::Corrupt("SRW compressed data truncated".into()));+            }+            let p = col & 1;+            if col < 2 {+                vpred[row & 1][col] += diff;+                hpred[col] = vpred[row & 1][col];+            } else {+                hpred[p] += diff;+            }+            if !(0..limit).contains(&i64::from(hpred[p])) {+                return Err(RawError::Corrupt(format!("SRW sample out of range at row {row}")));+            }+            d[row * w + col] = hpred[p] as u16;+        }+    }+    Ok(d)+}++/// Compression 32773: the strip at `src`. Returns the samples and the header's bit depth.+pub(crate) fn decode_v2(src: &[u8], w: usize, h: usize) -> Result<(Vec<u16>, u32)> {+    let header = src.get(..16).ok_or_else(|| RawError::Corrupt("SRW compressed header truncated".into()))?;+    let depth = u32::from(header[1] & 15) + 1;+    let opt = u32::from(header[9] & 15);+    let init = i32::from(u16::from_le_bytes([header[12], header[13]]) & 0x3fff);+    if !(8..=16).contains(&depth) {+        return Err(RawError::Unsupported(format!("Samsung compressed raw with {depth}-bit samples")));+    }+    let max = (1i32 << depth) - 1;+    let mut d = vec![0u16; w * h];+    let mut pos = 16usize;+    for row in 0..h {+        pos = pos.next_multiple_of(16);+        let mut bits = Words::new(src, pos);+        let (mut mag, mut pmode) = (0i32, 7u32);+        let mut lent = [[if row < 2 { 7u32 } else { 4 }; 2]; 3];+        let mut len = [0u32; 4];+        let odd = row & 1;+        for tab in (0..w.saturating_sub(15)).step_by(16) {+            if opt & 4 == 0 && tab % 64 == 0 {+                mag = match bits.bits(2)? {+                    0 => mag,+                    1 => mag.saturating_sub(2),+                    2 => mag.saturating_add(2),+                    _ => bits.bits(12)? as i32,+                };+            }+            if opt & 2 != 0 {+                pmode = 7 - 4 * bits.bits(1)?;+            } else if bits.bits(1)? == 0 {+                pmode = bits.bits(3)?;+            }+            if opt & 1 != 0 || bits.bits(1)? == 0 {+                for l in &mut len {+                    *l = bits.bits(2)?;+                }+                for (c, l) in len.iter_mut().enumerate() {+                    let i = ((odd << 1) | (c & 1)) % 3;+                    *l = match *l {+                        0 => lent[i][0],+                        1 => (lent[i][0] + 1).min(16),+                        2 => lent[i][0].saturating_sub(1),+                        _ => bits.bits(4)?,+                    };+                    lent[i] = [lent[i][1], *l];+                }+            }+            for c in 0..16 {+                let col = tab + ((((c & 7) << 1) ^ (c >> 3)) ^ odd);+                let pred = if pmode == 7 || row < 2 {+                    if tab == 0 { init } else { i32::from(d[row * w + tab - 2 + (col & 1)]) }+                } else {+                    // samples of this colour above: the row above (diagonal neighbours) or two rows above+                    let diagonal = (col & 1) == odd;+                    let (r, shift): (usize, isize) = if diagonal { (row - 1, if odd == 0 { 1 } else { -1 }) } else { (row - 2, 0) };+                    let a = [-4isize, -2, -2, 0, 0, 2, 4][pmode as usize];+                    let b = [-4isize, -2, 0, 0, 2, 2, 4][pmode as usize];+                    let at = |o: isize| -> Result<i32> {+                        let x = (col as isize).checked_add(shift + o).filter(|x| (0..w as isize).contains(x));+                        let x = x.ok_or_else(|| RawError::Corrupt("SRW prediction outside the row".into()))?;+                        Ok(i32::from(d[r * w + x as usize]))+                    };+                    (at(a)? + at(b)? + 1) >> 1+                };+                let n = len[c >> 2];+                let diff = signed(bits.bits(n)?, n);+                let (m, p, dl) = (i64::from(mag), i64::from(pred), i64::from(diff));+                let v = p.saturating_add(dl.saturating_mul(m.saturating_mul(2).saturating_add(1))).saturating_add(m);+                d[row * w + col] = v.clamp(0, i64::from(max)) as u16;+            }+        }+        pos = bits.pos;+    }+    Ok((d, depth))+}++/// The four Bayer layouts (colour of each 2x2 site, row-major from the stored array's first sample).+const LAYOUTS: [[u8; 4]; 4] = [[0, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]];++/// The Bayer layout whose colours follow the camera's own embedded JPEG: the compressed files' `CFAPattern` is not+/// reliable (the NX300 and NX2000 state RGGB and the NX30 GRBG for the same sensor read-out; the NX3000 states GBRG+/// for a GRBG mosaic). Per cell of a coarse grid over `active`, the log red/green and blue/green ratios of the raw+/// sites are correlated with the JPEG's; the layout must win clearly, else `None` (keep the tag).+pub(crate) fn layout_from_preview(jpeg: &[u8], d: &[u16], w: usize, active: crate::Rect, black: f64) -> Option<Vec<u8>> {+    use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};+    const GW: usize = 24;+    const GH: usize = 18;+    let opts = DecoderOptions::default().set_strict_mode(false).jpeg_set_out_colorspace(ColorSpace::RGB);+    let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts);+    let px = dec.decode().ok()?;+    let info = dec.info()?;+    let (jw, jh) = (info.width as usize, info.height as usize);+    if jw < GW || jh < GH || px.len() < jw * jh * 3 {+        return None;+    }+    let lin = |v: u8| {+        let v = f64::from(v) / 255.0;+        if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }+    };+    let mut jc = vec![([0f64; 3], 0f64); GW * GH];+    for y in 0..jh {+        for x in 0..jw {+            let cell = &mut jc[(y * GH / jh) * GW + x * GW / jw];+            for c in 0..3 {+                cell.0[c] += lin(px[(y * jw + x) * 3 + c]);+            }+            cell.1 += 1.0;+        }+    }+    let (x0, y0) = ((active.x + 1) & !1, (active.y + 1) & !1);+    let (cw, ch) = ((active.x + active.width).saturating_sub(x0) / 2, (active.y + active.height).saturating_sub(y0) / 2);+    if cw < GW || ch < GH {+        return None;+    }+    let mut rc = vec![([0f64; 4], 0f64); GW * GH];+    for cy in 0..ch {+        for cx in 0..cw {+            let cell = &mut rc[(cy * GH / ch) * GW + cx * GW / cw];+            for site in 0..4 {+                let v = d.get((y0 + 2 * cy + site / 2) * w + x0 + 2 * cx + site % 2).copied().unwrap_or(0);+                cell.0[site] += f64::from(v) - black;+            }+            cell.1 += 1.0;+        }+    }+    let score = |layout: &[u8; 4]| {+        let (mut xs, mut ys) = ([vec![], vec![]], [vec![], vec![]]);+        for ((s, sn), (j, jn)) in rc.iter().zip(&jc) {+            let mut rgb = [0f64; 3];+            for (site, &c) in layout.iter().enumerate() {+                rgb[c as usize] += s[site] / sn / if c == 1 { 2.0 } else { 1.0 };+            }+            let j = j.map(|v| v / jn);+            if rgb.iter().all(|v| *v > 1.0) && j.iter().all(|v| (0.002..0.9).contains(v)) {+                for (k, c) in [0, 2].into_iter().enumerate() {+                    xs[k].push((rgb[c] / rgb[1]).ln());+                    ys[k].push((j[c] / j[1]).ln());+                }+            }+        }+        (correlation(&xs[0], &ys[0]) + correlation(&xs[1], &ys[1])) / 2.0+    };+    let mut scored: Vec<(f64, [u8; 4])> = LAYOUTS.iter().map(|l| (score(l), *l)).collect();+    scored.sort_by(|a, b| b.0.total_cmp(&a.0));+    let (best, runner_up) = (scored.first()?, scored.get(1)?);+    (best.0 > 0.5 && best.0 - runner_up.0 > 0.3).then(|| best.1.to_vec())+}++/// Pearson correlation; 0 for fewer than 16 points or no spread.+fn correlation(a: &[f64], b: &[f64]) -> f64 {+    let n = a.len().min(b.len());+    if n < 16 {+        return 0.0;+    }+    let (ma, mb) = (a.iter().sum::<f64>() / n as f64, b.iter().sum::<f64>() / n as f64);+    let (mut sab, mut saa, mut sbb) = (0.0, 0.0, 0.0);+    for (x, y) in a.iter().zip(b) {+        sab += (x - ma) * (y - mb);+        saa += (x - ma) * (x - ma);+        sbb += (y - mb) * (y - mb);+    }+    let den = (saa * sbb).sqrt();+    if den > 0.0 { sab / den } else { 0.0 }+}++#[cfg(test)]+mod tests {+    use super::*;++    /// MSB-first bits packed into little-endian 32-bit words.+    #[derive(Default)]+    struct Writer {+        out: Vec<u8>,+        acc: u64,+        n: u32,+    }++    impl Writer {+        fn put(&mut self, v: u32, bits: u32) {+            if bits == 0 {+                return;+            }+            self.acc = (self.acc << bits) | u64::from(v & ((1u32 << bits) - 1));+            self.n += bits;+            while self.n >= 32 {+                self.n -= 32;+                self.out.extend_from_slice(&((self.acc >> self.n) as u32).to_le_bytes());+            }+        }+        /// Flush to a word boundary.+        fn finish(&mut self) -> Vec<u8> {+            if self.n > 0 {+                let pad = 32 - self.n;+                self.put(0, pad);+            }+            std::mem::take(&mut self.out)+        }+    }++    fn image(w: usize, h: usize) -> Vec<u16> {+        (0..w * h).map(|i| ((i % w) * 7 + (i / w) * 13 + (i % 3) * 50) as u16 % 4000).collect()+    }++    /// 32770: every block predicts left, with an explicit 13-bit length.+    fn encode_v0(img: &[u16], w: usize, h: usize) -> Vec<u8> {+        let wp = w.next_multiple_of(16);+        let mut s = vec![0u16; wp * h];+        for r in 0..h {+            s[r * wp..r * wp + w].copy_from_slice(&img[r * w..r * w + w]);+        }+        for r in (0..h - 1).step_by(2) {+            for c in (0..wp - 1).step_by(2) {+                s.swap(r * wp + c + 1, (r + 1) * wp + c);+            }+        }+        let (mut table, mut data) = (vec![], vec![]);+        for r in 0..h {+            table.extend_from_slice(&(data.len() as u32).to_le_bytes());+            let mut b = Writer::default();+            for col in (0..wp).step_by(16) {+                b.put(0, 1);+                for _ in 0..4 {+                    b.put(3, 2);+                }+                for _ in 0..4 {+                    b.put(13, 4);+                }+                for parity in 0..2 {+                    let left = if col == 0 { 128 } else { i32::from(s[r * wp + col - 2 + parity]) };+                    for c in (parity..16).step_by(2) {+                        b.put((i32::from(s[r * wp + col + c]) - left) as u32, 13);+                    }+                }+            }+            data.extend(b.finish());+        }+        // file: row table at 0, strip after it+        let mut file = table;+        file.extend(data);+        file+    }++    #[test]+    fn row_block_coding_round_trips() {+        let (w, h) = (40, 6); // not a whole number of blocks: rows are padded to 48+        let img = image(w, h);+        let file = encode_v0(&img, w, h);+        assert_eq!(decode_v0(&file, h * 4, 0, w, h).unwrap(), img);+    }++    fn encode_v1(img: &[u16], w: usize, h: usize) -> Vec<u8> {+        let mut starts = [0u32; 17];+        let mut slot = 0u32;+        for &(cl, dl) in &V1_TABLE {+            starts[dl as usize] = (slot >> (10 - cl)) | (cl << 16);+            slot += 1024 >> cl;+        }+        let (mut bits, mut acc, mut n) = (vec![], 0u64, 0u32);+        let mut put = |v: u32, k: u32, bits: &mut Vec<u8>| {+            acc = (acc << k) | u64::from(v);+            n += k;+            while n >= 8 {+                n -= 8;+                bits.push((acc >> n) as u8);+            }+        };+        let mut vpred = [[0i32; 2]; 2];+        for r in 0..h {+            let mut hpred = [0i32; 2];+            for c in 0..w {+                let v = i32::from(img[r * w + c]);+                let p = if c < 2 { vpred[r & 1][c] } else { hpred[c & 1] };+                let diff = v - p;+                let dl = 32 - diff.unsigned_abs().leading_zeros();+                let code = starts[dl as usize];+                put(code & 0xffff, code >> 16, &mut bits);+                let stored = if diff >= 0 { diff } else { diff + (1 << dl) - 1 };+                put(stored as u32, dl, &mut bits);+                if c < 2 {+                    vpred[r & 1][c] = v;+                }+                hpred[c & 1] = v;+            }+        }+        put(0, 7, &mut bits);+        bits+    }++    #[test]+    fn huffman_coding_round_trips() {+        let (w, h) = (34, 5);+        let img = image(w, h);+        assert_eq!(decode_v1(&encode_v1(&img, w, h), w, h, 12).unwrap(), img);+    }++    /// 32773 with options 7 (no quantizer, left prediction, explicit lengths of 12 bits) and initial value 100.+    fn encode_v2(img: &[u16], w: usize, h: usize) -> Vec<u8> {+        let mut out = vec![0u8; 16];+        out[1] = 13; // 14-bit samples+        out[9] = 7;+        out[12..14].copy_from_slice(&100u16.to_le_bytes());+        for r in 0..h {+            out.resize(out.len().next_multiple_of(16), 0);+            let mut b = Writer::default();+            for tab in (0..w.saturating_sub(15)).step_by(16) {+                b.put(0, 1); // pmode 7+                for _ in 0..4 {+                    b.put(3, 2);+                }+                for _ in 0..4 {+                    b.put(14, 4);+                }+                for c in 0..16 {+                    let col = tab + ((((c & 7) << 1) ^ (c >> 3)) ^ (r & 1));+                    let pred = if tab == 0 { 100 } else { i32::from(img[r * w + tab - 2 + (col & 1)]) };+                    b.put((i32::from(img[r * w + col]) - pred) as u32, 14);+                }+            }+            out.extend(b.finish());+        }+        out+    }++    #[test]+    fn block_coding_round_trips() {+        let (w, h) = (48, 4);+        let img = image(w, h);+        let (d, depth) = decode_v2(&encode_v2(&img, w, h), w, h).unwrap();+        assert_eq!((d, depth), (img, 14));+    }++    /// 32773: a very wide row whose quantizer step keeps growing (`+2` every 64 columns) until+    /// `diff * (2 * mag + 1)` no longer fits an `i32`, then a full-range difference.+    #[test]+    fn growing_quantizer_step_does_not_overflow() {+        let groups = 33_000usize; // 4095 + 2 * 33000 > 65536+        let (w, h) = (groups * 64, 1);+        let mut out = vec![0u8; 16];+        out[1] = 15; // 16-bit samples+        let mut b = Writer::default();+        for tab in (0..w).step_by(16) {+            if tab == 0 {+                b.put(3, 2);+                b.put(4095, 12);+            } else if tab % 64 == 0 {+                b.put(2, 2); // mag += 2+            }+            b.put(1, 1); // keep pmode 7+            if tab + 16 < w {+                b.put(1, 1); // keep the zero lengths+            } else {+                b.put(0, 1);+                for _ in 0..4 {+                    b.put(3, 2);+                }+                for _ in 0..4 {+                    b.put(15, 4);+                }+                for _ in 0..16 {+                    b.put(0x4000, 15); // -16384+                }+            }+        }+        out.extend(b.finish());+        assert!(decode_v2(&out, w, h).is_ok());+    }++    #[test]+    fn hostile_streams_do_not_panic() {+        let (w, h) = (40, 6);+        let img = image(w, h);+        let v0 = encode_v0(&img, w, h);+        let v1 = encode_v1(&img, w, h);+        let v2 = encode_v2(&img, 48, 4);+        let noise: Vec<u8> = (0..4000u32).map(|i| (i.wrapping_mul(2654435761) >> 13) as u8).collect();+        for cut in [0, 1, 7, 16, 23, 64, 100] {+            let _ = decode_v0(v0.get(..cut).unwrap_or(&v0), h * 4, 0, w, h);+            let _ = decode_v1(v1.get(..cut).unwrap_or(&v1), w, h, 12);+            let _ = decode_v2(v2.get(..cut).unwrap_or(&v2), 48, 4);+        }+        for (ww, hh) in [(0, 0), (1, 1), (16, 1), (17, 3), (64, 64)] {+            let _ = decode_v0(&noise, 7, 3, ww, hh);+            let _ = decode_v0(&noise, usize::MAX - 2, usize::MAX - 2, ww, hh);+            let _ = decode_v1(&noise, ww, hh, 12);+            let _ = decode_v2(&noise, ww, hh);+        }+        // a stream that asks for predictions from above in the first rows+        let mut up = vec![0u8; 8];+        up.extend([0xff; 64]);+        assert!(decode_v0(&up, 8, 0, 16, 2).is_err());+        // a bit depth outside 8..=16 is refused, not decoded+        let mut bad = v2.clone();+        bad[1] = 3;+        assert!(matches!(decode_v2(&bad, 48, 4), Err(RawError::Unsupported(_))));+    }+}