mayan_decode.ts

23.9 KB · raw

1/**2 * Mayan v2 decoder — anchor-finder + homography pipeline.3 *4 * Pipeline:5 *   binarize → find 3 corner anchors via 1:1:3:1:1 run-length pattern →6 *   classify TL/TR/BL by quadrant → compute DLT homography (pixel↔module)7 *   from 4 control points (TL, TR, BL, extrapolated BR) → sample grid →8 *   read 16-bit size marker → re-sample at exact size if different →9 *   probe `numBlocks` from the interleaved header → RS-decode each block →10 *   strip header, return encrypted payload.11 */1213import { rsDecodeBlock } from "./rs";14import {15  MAYAN_MAGIC,16  MAYAN_V2,17  RS_DATA,18  RS_PARITY,19  RS_BLOCK,20  HEADER_BYTES,21  QUIET_ZONE,22  ANCHOR_FOOTPRINT,23  MIN_SYMBOL_SIZE,24  MAX_SYMBOL_SIZE,25  isReserved,26} from "./mayan";2728// ── PUBLIC ENTRY POINTS ──────────────────────────────────────────────────────2930/** Decode a Mayan code (v1 or v2) from raw image data. Returns the31 *  encrypted-and-compressed payload byte stream — caller is responsible32 *  for AES-GCM decrypt and zstd decompress (or, in Peach, parsePayload).33 *34 *  Returns null when no Mayan code is found, magic byte mismatches, or35 *  Reed-Solomon correction fails. */36export function decodeMayan(imageData: ImageData): Uint8Array | null {37  const binary = adaptiveBinarize(imageData);38  const W = imageData.width;39  const H = imageData.height;4041  const anchors = findAnchors(binary, W, H);42  if (anchors.length < 3) return null;4344  const orient = classifyAnchors(anchors);45  if (!orient) return null;46  const { TL, TR, BL } = orient;47  const modulePx = orient.modulePx;48  if (modulePx < 1) return null;4950  // Estimate symbol size from anchor spacing. Anchor inner-3×3 centers are51  // at module (q+4, q+4) for TL — so the distance TL→TR in modules is52  // (size - 2q - 9) and we recover size = round(distance/modulePx) + 2q + 9.53  const distH = Math.hypot(TR.x - TL.x, TR.y - TL.y);54  const distV = Math.hypot(BL.x - TL.x, BL.y - TL.y);55  const span = (distH + distV) / 2 / modulePx;56  let estSize = Math.round(span) + 2 * QUIET_ZONE + ANCHOR_FOOTPRINT;57  estSize = clamp(estSize, MIN_SYMBOL_SIZE, MAX_SYMBOL_SIZE);5859  const matrix = sampleAndReadExact(binary, TL, TR, BL, estSize);60  if (!matrix) return null;6162  return decodeMatrix(matrix.matrix, matrix.size);63}6465/** Decode a Mayan v2 module matrix directly. Used by tests that bypass66 *  binarization and homography. */67export function decodeMatrix(68  matrix: boolean[][],69  symbolSize: number,70): Uint8Array | null {71  // Read every available data bit. The number of blocks is unknown until72  // we parse the header, but the header is itself interleaved across73  // blocks — so we read everything, probe `numBlocks` against the header,74  // then RS-decode at the right blocking.75  const allBits = readDataBits(matrix, symbolSize, Infinity);76  const interleaved = new Uint8Array(bitsToBytes(allBits));7778  if (interleaved.length === 0) return null;79  const maxK = Math.floor(interleaved.length / RS_BLOCK);80  // Magic byte sits at interleaved[0] regardless of numBlocks (block 0,81  // byte 0 → position 0). Skip the fast probe if it's already wrong; the82  // slow probe still gets a chance to RS-correct a flipped magic byte.83  // Fast probe first: for each candidate k, the header bytes live at84  // positions [0, k, 2k, 3k, 4k, 5k]. Check version + length consistency.85  for (let k = 1; interleaved[0] === MAYAN_MAGIC && k <= maxK; k++) {86    if (5 * k >= interleaved.length) break;87    const version = interleaved[k];88    if (version !== MAYAN_V2) continue;89    const length = interleaved[2 * k]90      | (interleaved[3 * k] << 8)91      | (interleaved[4 * k] << 16)92      | (interleaved[5 * k] << 24);93    if (length < 0 || length > 100_000_000) continue;94    if (Math.ceil((length + HEADER_BYTES) / RS_DATA) !== k) continue;95    const decoded = rsDecodeAtBlocks(interleaved, k);96    if (!decoded) continue;97    // Re-validate header from the corrected stream (in case the fast98    // probe accepted on un-corrected bytes that happened to be valid).99    if (decoded[0] !== MAYAN_MAGIC || decoded[1] !== MAYAN_V2) continue;100    const len2 = decoded[2] | (decoded[3] << 8) | (decoded[4] << 16) | (decoded[5] << 24);101    if (len2 < 0 || len2 + HEADER_BYTES > decoded.length) continue;102    return decoded.slice(HEADER_BYTES, HEADER_BYTES + len2);103  }104105  // Slow probe: header may be corrupted. RS-decode block 0 at every106  // candidate k and check the corrected header.107  for (let k = 1; k <= maxK; k++) {108    const decoded = rsDecodeAtBlocks(interleaved, k);109    if (!decoded) continue;110    if (decoded[0] !== MAYAN_MAGIC) continue;111    if (decoded[1] !== MAYAN_V2) continue;112    const len = decoded[2] | (decoded[3] << 8) | (decoded[4] << 16) | (decoded[5] << 24);113    if (len < 0 || len + HEADER_BYTES > decoded.length) continue;114    if (Math.ceil((len + HEADER_BYTES) / RS_DATA) !== k) continue;115    return decoded.slice(HEADER_BYTES, HEADER_BYTES + len);116  }117118  return null;119}120121/** Deinterleave the first `numBlocks * 255` bytes and RS-decode each block.122 *  Returns the concatenated `numBlocks * 204`-byte data payload (with123 *  header still prepended), or null if any block is uncorrectable. */124function rsDecodeAtBlocks(125  interleaved: Uint8Array,126  numBlocks: number,127): Uint8Array | null {128  if (interleaved.length < numBlocks * RS_BLOCK) return null;129  const out = new Uint8Array(numBlocks * RS_DATA);130  const block = new Uint8Array(RS_BLOCK);131  for (let i = 0; i < numBlocks; i++) {132    for (let j = 0; j < RS_BLOCK; j++) {133      block[j] = interleaved[i + j * numBlocks];134    }135    const corrected = rsDecodeBlock(block, RS_PARITY);136    if (!corrected) return null;137    out.set(corrected, i * RS_DATA);138  }139  return out;140}141142// ── BINARIZATION ─────────────────────────────────────────────────────────────143144function adaptiveBinarize(imageData: ImageData): boolean[][] {145  const { data, width, height } = imageData;146  const out: boolean[][] = Array.from(147    { length: height },148    () => new Array<boolean>(width).fill(false),149  );150  const R = 9;151  const integral = new Float64Array((width + 1) * (height + 1));152  for (let y = 0; y < height; y++) {153    let rowSum = 0;154    for (let x = 0; x < width; x++) {155      const i = (y * width + x) * 4;156      const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];157      rowSum += gray;158      integral[(y + 1) * (width + 1) + (x + 1)] =159        integral[y * (width + 1) + (x + 1)] + rowSum;160    }161  }162  for (let y = 0; y < height; y++) {163    for (let x = 0; x < width; x++) {164      const i = (y * width + x) * 4;165      const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];166      const x0 = Math.max(0, x - R);167      const y0 = Math.max(0, y - R);168      const x1 = Math.min(width - 1, x + R);169      const y1 = Math.min(height - 1, y + R);170      const sum =171        integral[(y1 + 1) * (width + 1) + (x1 + 1)] -172        integral[(y0) * (width + 1) + (x1 + 1)] -173        integral[(y1 + 1) * (width + 1) + (x0)] +174        integral[(y0) * (width + 1) + (x0)];175      const count = (x1 - x0 + 1) * (y1 - y0 + 1);176      out[y][x] = gray < sum / count - 8;177    }178  }179  return out;180}181182// ── ANCHOR DETECTION ─────────────────────────────────────────────────────────183184interface AnchorMatch {185  cx: number;186  cy: number;187  modulePx: number;188}189190interface RawCandidate {191  cx: number;192  cy: number;193  modulePx: number;194}195196/** Scan rows for the 1:1:3:1:1 light-dark-light-dark-light pattern that197 *  characterises the corner anchor (outer dark frame, light gap, inner198 *  3-module dark block, light gap, outer dark frame). Confirm vertically199 *  at the same column, then verify the candidate's center 3×3-module block200 *  is solidly dark. Returns clustered, deduplicated anchor centers. */201function findAnchors(binary: boolean[][], W: number, H: number): AnchorMatch[] {202  const candidates: RawCandidate[] = [];203204  for (let y = 0; y < H; y += 2) {205    scanLineForAnchor(206      (i) => binary[y]?.[i] ?? false,207      W,208      (cxRel, modulePx) => candidates.push({ cx: cxRel, cy: y, modulePx }),209    );210  }211212  const confirmed: AnchorMatch[] = [];213  for (const c of candidates) {214    if (c.cx < 0 || c.cx >= W) continue;215    const hits: { cy: number; modulePx: number }[] = [];216    scanLineForAnchor(217      (i) => binary[i]?.[c.cx] ?? false,218      H,219      (cyRel, modulePx) => hits.push({ cy: cyRel, modulePx }),220    );221    let best: { cy: number; modulePx: number } | null = null;222    for (const h of hits) {223      if (Math.abs(h.cy - c.cy) > c.modulePx * 5) continue;224      if (!best || Math.abs(h.cy - c.cy) < Math.abs(best.cy - c.cy)) best = h;225    }226    if (!best) continue;227    const cy = Math.round((c.cy + best.cy) / 2);228    const modulePx = (c.modulePx + best.modulePx) / 2;229    if (!verifyAnchorCenter(binary, c.cx, cy, modulePx, W, H)) continue;230    confirmed.push({ cx: c.cx, cy, modulePx });231  }232233  return clusterAnchors(confirmed);234}235236/** Verify that the candidate's central 3×3-module block is solidly dark237 *  (≥80% of sampled pixels). The horizontal and vertical 1:1:3:1:1 scans238 *  alone match many false positives in random data — but the real anchor's239 *  central 3×3 must be a uniform dark block. This kills the false positives240 *  cheaply. */241function verifyAnchorCenter(242  binary: boolean[][],243  cx: number, cy: number,244  modulePx: number,245  W: number, H: number,246): boolean {247  // Sample a 3×3 grid of points spanning the central 3 modules248  const step = modulePx;249  let dark = 0, total = 0;250  for (let dr = -1; dr <= 1; dr++) {251    for (let dc = -1; dc <= 1; dc++) {252      const x = Math.round(cx + dc * step);253      const y = Math.round(cy + dr * step);254      if (x < 0 || x >= W || y < 0 || y >= H) return false;255      total++;256      if (binary[y][x]) dark++;257    }258  }259  if (dark < total * 0.8) return false;260261  // And verify the immediate ring outside (at ±2 modules) has at least262  // some light points — the real anchor has a light gap there.263  let light = 0, ringTotal = 0;264  for (let dr = -2; dr <= 2; dr++) {265    for (let dc = -2; dc <= 2; dc++) {266      if (Math.abs(dr) < 2 && Math.abs(dc) < 2) continue; // only the ring267      const x = Math.round(cx + dc * step);268      const y = Math.round(cy + dr * step);269      if (x < 0 || x >= W || y < 0 || y >= H) continue;270      ringTotal++;271      if (!binary[y][x]) light++;272    }273  }274  return ringTotal > 0 && light >= ringTotal * 0.5;275}276277/** Scan a single 1-D line via a `pixel(i)` accessor for the 1:1:3:1:1278 *  pattern. Calls `onMatch(centerPosition, modulePx)` for every match. */279function scanLineForAnchor(280  pixel: (i: number) => boolean,281  N: number,282  onMatch: (center: number, modulePx: number) => void,283): void {284  // Sliding-window run-length scan: maintain a rolling history of the last285  // 5 alternating runs (light, dark, light, dark, light). On each new run,286  // evaluate the trailing 5-run window for the 1:1:3:1:1 ratio.287  const runs: { len: number; dark: boolean; start: number }[] = [];288  let i = 0;289  let cur = pixel(0);290  let runStart = 0;291  while (i <= N) {292    const px = i < N ? pixel(i) : !cur;293    if (px !== cur || i === N) {294      runs.push({ len: i - runStart, dark: cur, start: runStart });295      runStart = i;296      cur = px;297      // Keep only last 5298      if (runs.length > 5) runs.shift();299      if (runs.length === 5) {300        const [r0, r1, r2, r3, r4] = runs;301        // We need light, dark, light, dark, light302        if (!r0.dark && r1.dark && !r2.dark && r3.dark && !r4.dark) {303          const total = r0.len + r1.len + r2.len + r3.len + r4.len;304          const unit = total / 7;305          if (unit >= 1 &&306              within(r0.len, unit, 0.5) &&307              within(r1.len, unit, 0.5) &&308              within(r2.len, unit * 3, 0.8) &&309              within(r3.len, unit, 0.5) &&310              within(r4.len, unit, 0.5)) {311            const center = r2.start + r2.len / 2;312            onMatch(Math.round(center), unit);313          }314        }315        // Also accept the case where the line starts inside a dark region:316        // dark, light, dark, light, dark — with the middle dark being the317        // 3-unit center block.318        if (r0.dark && !r1.dark && r2.dark && !r3.dark && r4.dark) {319          const total = r0.len + r1.len + r2.len + r3.len + r4.len;320          const unit = total / 7;321          if (unit >= 1 &&322              within(r0.len, unit, 0.5) &&323              within(r1.len, unit, 0.5) &&324              within(r2.len, unit * 3, 0.8) &&325              within(r3.len, unit, 0.5) &&326              within(r4.len, unit, 0.5)) {327            const center = r2.start + r2.len / 2;328            onMatch(Math.round(center), unit);329          }330        }331      }332    }333    i++;334  }335}336337function within(value: number, target: number, tolerance: number): boolean {338  return Math.abs(value - target) <= target * tolerance;339}340341/** Cluster nearby anchor matches into single representatives. Two matches342 *  are the same anchor if they are within ~2 modules of each other. */343function clusterAnchors(matches: AnchorMatch[]): AnchorMatch[] {344  const clusters: AnchorMatch[][] = [];345  for (const m of matches) {346    let placed = false;347    for (const cluster of clusters) {348      const ref = cluster[0];349      const distSq = (m.cx - ref.cx) ** 2 + (m.cy - ref.cy) ** 2;350      if (distSq <= (ref.modulePx * 3) ** 2) {351        cluster.push(m);352        placed = true;353        break;354      }355    }356    if (!placed) clusters.push([m]);357  }358  return clusters.map((cluster) => {359    let sx = 0, sy = 0, sm = 0;360    for (const c of cluster) { sx += c.cx; sy += c.cy; sm += c.modulePx; }361    return {362      cx: sx / cluster.length,363      cy: sy / cluster.length,364      modulePx: sm / cluster.length,365    };366  });367}368369// ── ORIENTATION CLASSIFICATION ───────────────────────────────────────────────370371interface Orientation {372  TL: Point;373  TR: Point;374  BL: Point;375  modulePx: number;376}377378interface Point { x: number; y: number }379380/** Pick the three anchors corresponding to the genuine corner squares.381 *382 *  Strategy: the real anchors share the same modulePx (they're the same383 *  physical pattern photographed in the same image). False positives — the384 *  1:1:3:1:1 run-length artefacts that random data sometimes produces —385 *  scatter across modulePx values. Cluster candidates by modulePx, keep386 *  the dominant bucket, then among that bucket pick the triplet that best387 *  matches a right-angle isoceles triangle with equal legs (TL→TR ≈ TL→BL,388 *  angle at TL ≈ 90°). */389function classifyAnchors(anchors: AnchorMatch[]): Orientation | null {390  if (anchors.length < 3) return null;391392  let best: {393    TL: AnchorMatch; TR: AnchorMatch; BL: AnchorMatch; score: number;394  } | null = null;395396  const N = anchors.length;397  for (let i = 0; i < N; i++) {398    for (let j = 0; j < N; j++) {399      if (j === i) continue;400      for (let k = 0; k < N; k++) {401        if (k === i || k === j) continue;402        const tl = anchors[i];403        const a = anchors[j];404        const b = anchors[k];405        const va = { x: a.cx - tl.cx, y: a.cy - tl.cy };406        const vb = { x: b.cx - tl.cx, y: b.cy - tl.cy };407        const lenA = Math.hypot(va.x, va.y);408        const lenB = Math.hypot(vb.x, vb.y);409        if (lenA < 1 || lenB < 1) continue;410        // Order: TR has positive cross product (rightward then downward in411        // y-down image coords).412        const cross = va.x * vb.y - va.y * vb.x;413        if (cross <= 0) continue;414        // Right-angle at TL415        const cos = (va.x * vb.x + va.y * vb.y) / (lenA * lenB);416        const angularScore = -Math.abs(cos);417        // Equal legs418        const legRatio = Math.min(lenA, lenB) / Math.max(lenA, lenB);419        const lengthScore = -Math.abs(1 - legRatio);420        // Module size consistency — real anchors share one modulePx.421        const meanM = (tl.modulePx + a.modulePx + b.modulePx) / 3;422        const varM = (423          (tl.modulePx - meanM) ** 2 +424          (a.modulePx - meanM) ** 2 +425          (b.modulePx - meanM) ** 2426        ) / 3;427        const stdM = Math.sqrt(varM);428        const moduleScore = -stdM / Math.max(meanM, 1);429        // Triangle leg ≈ symbol pixel span. Real anchors are far apart.430        const sizeScore = Math.log(lenA + lenB) * 0.1;431        const score =432          angularScore * 5 +433          lengthScore * 5 +434          moduleScore * 8 +435          sizeScore;436        if (!best || score > best.score) {437          best = { TL: tl, TR: a, BL: b, score };438        }439      }440    }441  }442  if (!best) return null;443  const modulePx = (best.TL.modulePx + best.TR.modulePx + best.BL.modulePx) / 3;444  return {445    TL: { x: best.TL.cx, y: best.TL.cy },446    TR: { x: best.TR.cx, y: best.TR.cy },447    BL: { x: best.BL.cx, y: best.BL.cy },448    modulePx,449  };450}451452// ── HOMOGRAPHY (DLT, 4 point pairs) ──────────────────────────────────────────453454/** Solve a 3×3 homography H mapping src points (sx, sy) to dst points455 *  (dx, dy) such that `dst = H * src` in homogeneous coords. Uses DLT with456 *  h22 fixed at 1 — sufficient for non-degenerate quadrilaterals.457 *  Returns the flat 9-element matrix [h00..h22] or null on singular input. */458function computeHomography(src: Point[], dst: Point[]): number[] | null {459  if (src.length !== 4 || dst.length !== 4) return null;460  // 8 unknowns: h00,h01,h02,h10,h11,h12,h20,h21461  // Per pair: dx = h00*sx + h01*sy + h02 - h20*sx*dx - h21*sy*dx462  //           dy = h10*sx + h11*sy + h12 - h20*sx*dy - h21*sy*dy463  const A: number[][] = [];464  const b: number[] = [];465  for (let i = 0; i < 4; i++) {466    const sx = src[i].x, sy = src[i].y;467    const dx = dst[i].x, dy = dst[i].y;468    A.push([sx, sy, 1, 0, 0, 0, -sx * dx, -sy * dx]);469    b.push(dx);470    A.push([0, 0, 0, sx, sy, 1, -sx * dy, -sy * dy]);471    b.push(dy);472  }473  const x = solve8(A, b);474  if (!x) return null;475  return [x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], 1];476}477478/** Solve an 8×8 linear system via partial-pivot Gaussian elimination. */479function solve8(A: number[][], b: number[]): number[] | null {480  const n = 8;481  const M: number[][] = A.map((row, i) => [...row, b[i]]);482  for (let col = 0; col < n; col++) {483    let pivot = col;484    for (let r = col + 1; r < n; r++) {485      if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r;486    }487    if (Math.abs(M[pivot][col]) < 1e-12) return null;488    [M[col], M[pivot]] = [M[pivot], M[col]];489    for (let r = 0; r < n; r++) {490      if (r === col) continue;491      const factor = M[r][col] / M[col][col];492      if (factor === 0) continue;493      for (let c = col; c <= n; c++) M[r][c] -= factor * M[col][c];494    }495  }496  const x: number[] = new Array(n);497  for (let i = 0; i < n; i++) x[i] = M[i][n] / M[i][i];498  return x;499}500501function applyHomography(H: number[], px: number, py: number): Point {502  const w = H[6] * px + H[7] * py + H[8];503  return {504    x: (H[0] * px + H[1] * py + H[2]) / w,505    y: (H[3] * px + H[4] * py + H[5]) / w,506  };507}508509function invertHomography(H: number[]): number[] | null {510  const a = H[0], b = H[1], c = H[2];511  const d = H[3], e = H[4], f = H[5];512  const g = H[6], h = H[7], i = H[8];513  const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);514  if (Math.abs(det) < 1e-12) return null;515  const inv = 1 / det;516  return [517    (e * i - f * h) * inv,518    (c * h - b * i) * inv,519    (b * f - c * e) * inv,520    (f * g - d * i) * inv,521    (a * i - c * g) * inv,522    (c * d - a * f) * inv,523    (d * h - e * g) * inv,524    (b * g - a * h) * inv,525    (a * e - b * d) * inv,526  ];527}528529// ── GRID SAMPLING ────────────────────────────────────────────────────────────530531/** Anchor inner-3×3-block module coordinates for a given symbol size.532 *  These are the *center* coordinates (module index + 0.5) used as the533 *  destination side of the homography. */534function anchorModuleCenter(size: number, corner: "TL" | "TR" | "BL" | "BR"): Point {535  const q = QUIET_ZONE;536  // Inner 3×3 dark block centers at module (q+4, q+4) for TL etc. Add 0.5537  // for module-center coordinates.538  const tlx = q + 4 + 0.5;539  const tly = q + 4 + 0.5;540  const farx = size - q - 5 + 0.5; // size - q - ANCHOR_FOOTPRINT + 4 + 0.5541  const fary = size - q - 5 + 0.5;542  switch (corner) {543    case "TL": return { x: tlx, y: tly };544    case "TR": return { x: farx, y: tly };545    case "BL": return { x: tlx, y: fary };546    case "BR": return { x: farx, y: fary };547  }548}549550/** Sample the binary image at the ideal module grid for a given symbol551 *  size, using a homography fitted from the three known anchors plus an552 *  extrapolated BR. */553function sampleGrid(554  binary: boolean[][],555  TL: Point, TR: Point, BL: Point,556  size: number,557): boolean[][] | null {558  // BR ≈ TL + (TR - TL) + (BL - TL)559  const BR: Point = {560    x: TL.x + (TR.x - TL.x) + (BL.x - TL.x),561    y: TL.y + (TR.y - TL.y) + (BL.y - TL.y),562  };563  // src = pixel coords of anchors; dst = module-center coords.564  const src = [TL, TR, BL, BR];565  const dst = [566    anchorModuleCenter(size, "TL"),567    anchorModuleCenter(size, "TR"),568    anchorModuleCenter(size, "BL"),569    anchorModuleCenter(size, "BR"),570  ];571  // We need module → pixel sampling; build H_inv directly by swapping src/dst.572  const Hinv = computeHomography(dst, src);573  if (!Hinv) return null;574575  const matrix: boolean[][] = Array.from(576    { length: size },577    () => new Array<boolean>(size).fill(false),578  );579  const H = binary.length;580  const W = binary[0]?.length ?? 0;581  for (let r = 0; r < size; r++) {582    for (let c = 0; c < size; c++) {583      const p = applyHomography(Hinv, c + 0.5, r + 0.5);584      const ix = Math.round(p.x);585      const iy = Math.round(p.y);586      if (iy >= 0 && iy < H && ix >= 0 && ix < W) {587        matrix[r][c] = binary[iy][ix];588      }589    }590  }591  return matrix;592}593594/** Two-pass sampling: read the size bits at the estimated size, then595 *  re-sample at the exact size if they differ. */596function sampleAndReadExact(597  binary: boolean[][],598  TL: Point, TR: Point, BL: Point,599  estSize: number,600): { matrix: boolean[][]; size: number } | null {601  const rough = sampleGrid(binary, TL, TR, BL, estSize);602  if (!rough) return null;603  const exact = readSizeBits(rough);604  if (exact >= MIN_SYMBOL_SIZE && exact <= MAX_SYMBOL_SIZE && exact !== estSize) {605    const final = sampleGrid(binary, TL, TR, BL, exact);606    if (!final) return null;607    return { matrix: final, size: exact };608  }609  if (exact < MIN_SYMBOL_SIZE || exact > MAX_SYMBOL_SIZE) {610    // Fall back to the estimated size — RS may still recover.611    return { matrix: rough, size: estSize };612  }613  return { matrix: rough, size: exact };614}615616function readSizeBits(matrix: boolean[][]): number {617  const row = QUIET_ZONE;618  const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;619  let val = 0;620  for (let i = 0; i < 16; i++) {621    if (matrix[row]?.[colStart + i]) val |= 1 << (15 - i);622  }623  return val;624}625626// ── BIT / BYTE HELPERS ───────────────────────────────────────────────────────627628function readDataBits(629  matrix: boolean[][],630  size: number,631  count: number,632): boolean[] {633  const bits: boolean[] = [];634  for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bits.length < count; r++) {635    const row = matrix[r];636    if (!row) continue;637    for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bits.length < count; c++) {638      if (isReserved(r, c, size)) continue;639      bits.push(row[c] ?? false);640    }641  }642  return bits;643}644645function bitsToBytes(bits: boolean[]): number[] {646  const bytes: number[] = [];647  for (let i = 0; i < bits.length; i += 8) {648    let byte = 0;649    for (let j = 0; j < 8 && i + j < bits.length; j++) {650      if (bits[i + j]) byte |= 1 << (7 - j);651    }652    bytes.push(byte);653  }654  return bytes;655}656657function clamp(v: number, lo: number, hi: number): number {658  return Math.max(lo, Math.min(hi, v));659}