/** * Mayan v2 decoder — anchor-finder + homography pipeline. * * Pipeline: * binarize → find 3 corner anchors via 1:1:3:1:1 run-length pattern → * classify TL/TR/BL by quadrant → compute DLT homography (pixel↔module) * from 4 control points (TL, TR, BL, extrapolated BR) → sample grid → * read 16-bit size marker → re-sample at exact size if different → * probe `numBlocks` from the interleaved header → RS-decode each block → * strip header, return encrypted payload. */ import { rsDecodeBlock } from "./rs"; import { MAYAN_MAGIC, MAYAN_V2, RS_DATA, RS_PARITY, RS_BLOCK, HEADER_BYTES, QUIET_ZONE, ANCHOR_FOOTPRINT, MIN_SYMBOL_SIZE, MAX_SYMBOL_SIZE, isReserved, } from "./mayan"; // ── PUBLIC ENTRY POINTS ────────────────────────────────────────────────────── /** Decode a Mayan code (v1 or v2) from raw image data. Returns the * encrypted-and-compressed payload byte stream — caller is responsible * for AES-GCM decrypt and zstd decompress (or, in Peach, parsePayload). * * Returns null when no Mayan code is found, magic byte mismatches, or * Reed-Solomon correction fails. */ export function decodeMayan(imageData: ImageData): Uint8Array | null { const binary = adaptiveBinarize(imageData); const W = imageData.width; const H = imageData.height; const anchors = findAnchors(binary, W, H); if (anchors.length < 3) return null; const orient = classifyAnchors(anchors); if (!orient) return null; const { TL, TR, BL } = orient; const modulePx = orient.modulePx; if (modulePx < 1) return null; // Estimate symbol size from anchor spacing. Anchor inner-3×3 centers are // at module (q+4, q+4) for TL — so the distance TL→TR in modules is // (size - 2q - 9) and we recover size = round(distance/modulePx) + 2q + 9. const distH = Math.hypot(TR.x - TL.x, TR.y - TL.y); const distV = Math.hypot(BL.x - TL.x, BL.y - TL.y); const span = (distH + distV) / 2 / modulePx; let estSize = Math.round(span) + 2 * QUIET_ZONE + ANCHOR_FOOTPRINT; estSize = clamp(estSize, MIN_SYMBOL_SIZE, MAX_SYMBOL_SIZE); const matrix = sampleAndReadExact(binary, TL, TR, BL, estSize); if (!matrix) return null; return decodeMatrix(matrix.matrix, matrix.size); } /** Decode a Mayan v2 module matrix directly. Used by tests that bypass * binarization and homography. */ export function decodeMatrix( matrix: boolean[][], symbolSize: number, ): Uint8Array | null { // Read every available data bit. The number of blocks is unknown until // we parse the header, but the header is itself interleaved across // blocks — so we read everything, probe `numBlocks` against the header, // then RS-decode at the right blocking. const allBits = readDataBits(matrix, symbolSize, Infinity); const interleaved = new Uint8Array(bitsToBytes(allBits)); if (interleaved.length === 0) return null; const maxK = Math.floor(interleaved.length / RS_BLOCK); // Magic byte sits at interleaved[0] regardless of numBlocks (block 0, // byte 0 → position 0). Skip the fast probe if it's already wrong; the // slow probe still gets a chance to RS-correct a flipped magic byte. // Fast probe first: for each candidate k, the header bytes live at // positions [0, k, 2k, 3k, 4k, 5k]. Check version + length consistency. for (let k = 1; interleaved[0] === MAYAN_MAGIC && k <= maxK; k++) { if (5 * k >= interleaved.length) break; const version = interleaved[k]; if (version !== MAYAN_V2) continue; const length = interleaved[2 * k] | (interleaved[3 * k] << 8) | (interleaved[4 * k] << 16) | (interleaved[5 * k] << 24); if (length < 0 || length > 100_000_000) continue; if (Math.ceil((length + HEADER_BYTES) / RS_DATA) !== k) continue; const decoded = rsDecodeAtBlocks(interleaved, k); if (!decoded) continue; // Re-validate header from the corrected stream (in case the fast // probe accepted on un-corrected bytes that happened to be valid). if (decoded[0] !== MAYAN_MAGIC || decoded[1] !== MAYAN_V2) continue; const len2 = decoded[2] | (decoded[3] << 8) | (decoded[4] << 16) | (decoded[5] << 24); if (len2 < 0 || len2 + HEADER_BYTES > decoded.length) continue; return decoded.slice(HEADER_BYTES, HEADER_BYTES + len2); } // Slow probe: header may be corrupted. RS-decode block 0 at every // candidate k and check the corrected header. for (let k = 1; k <= maxK; k++) { const decoded = rsDecodeAtBlocks(interleaved, k); if (!decoded) continue; if (decoded[0] !== MAYAN_MAGIC) continue; if (decoded[1] !== MAYAN_V2) continue; const len = decoded[2] | (decoded[3] << 8) | (decoded[4] << 16) | (decoded[5] << 24); if (len < 0 || len + HEADER_BYTES > decoded.length) continue; if (Math.ceil((len + HEADER_BYTES) / RS_DATA) !== k) continue; return decoded.slice(HEADER_BYTES, HEADER_BYTES + len); } return null; } /** Deinterleave the first `numBlocks * 255` bytes and RS-decode each block. * Returns the concatenated `numBlocks * 204`-byte data payload (with * header still prepended), or null if any block is uncorrectable. */ function rsDecodeAtBlocks( interleaved: Uint8Array, numBlocks: number, ): Uint8Array | null { if (interleaved.length < numBlocks * RS_BLOCK) return null; const out = new Uint8Array(numBlocks * RS_DATA); const block = new Uint8Array(RS_BLOCK); for (let i = 0; i < numBlocks; i++) { for (let j = 0; j < RS_BLOCK; j++) { block[j] = interleaved[i + j * numBlocks]; } const corrected = rsDecodeBlock(block, RS_PARITY); if (!corrected) return null; out.set(corrected, i * RS_DATA); } return out; } // ── BINARIZATION ───────────────────────────────────────────────────────────── function adaptiveBinarize(imageData: ImageData): boolean[][] { const { data, width, height } = imageData; const out: boolean[][] = Array.from( { length: height }, () => new Array(width).fill(false), ); const R = 9; const integral = new Float64Array((width + 1) * (height + 1)); for (let y = 0; y < height; y++) { let rowSum = 0; for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; rowSum += gray; integral[(y + 1) * (width + 1) + (x + 1)] = integral[y * (width + 1) + (x + 1)] + rowSum; } } for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; const x0 = Math.max(0, x - R); const y0 = Math.max(0, y - R); const x1 = Math.min(width - 1, x + R); const y1 = Math.min(height - 1, y + R); const sum = integral[(y1 + 1) * (width + 1) + (x1 + 1)] - integral[(y0) * (width + 1) + (x1 + 1)] - integral[(y1 + 1) * (width + 1) + (x0)] + integral[(y0) * (width + 1) + (x0)]; const count = (x1 - x0 + 1) * (y1 - y0 + 1); out[y][x] = gray < sum / count - 8; } } return out; } // ── ANCHOR DETECTION ───────────────────────────────────────────────────────── interface AnchorMatch { cx: number; cy: number; modulePx: number; } interface RawCandidate { cx: number; cy: number; modulePx: number; } /** Scan rows for the 1:1:3:1:1 light-dark-light-dark-light pattern that * characterises the corner anchor (outer dark frame, light gap, inner * 3-module dark block, light gap, outer dark frame). Confirm vertically * at the same column, then verify the candidate's center 3×3-module block * is solidly dark. Returns clustered, deduplicated anchor centers. */ function findAnchors(binary: boolean[][], W: number, H: number): AnchorMatch[] { const candidates: RawCandidate[] = []; for (let y = 0; y < H; y += 2) { scanLineForAnchor( (i) => binary[y]?.[i] ?? false, W, (cxRel, modulePx) => candidates.push({ cx: cxRel, cy: y, modulePx }), ); } const confirmed: AnchorMatch[] = []; for (const c of candidates) { if (c.cx < 0 || c.cx >= W) continue; const hits: { cy: number; modulePx: number }[] = []; scanLineForAnchor( (i) => binary[i]?.[c.cx] ?? false, H, (cyRel, modulePx) => hits.push({ cy: cyRel, modulePx }), ); let best: { cy: number; modulePx: number } | null = null; for (const h of hits) { if (Math.abs(h.cy - c.cy) > c.modulePx * 5) continue; if (!best || Math.abs(h.cy - c.cy) < Math.abs(best.cy - c.cy)) best = h; } if (!best) continue; const cy = Math.round((c.cy + best.cy) / 2); const modulePx = (c.modulePx + best.modulePx) / 2; if (!verifyAnchorCenter(binary, c.cx, cy, modulePx, W, H)) continue; confirmed.push({ cx: c.cx, cy, modulePx }); } return clusterAnchors(confirmed); } /** Verify that the candidate's central 3×3-module block is solidly dark * (≥80% of sampled pixels). The horizontal and vertical 1:1:3:1:1 scans * alone match many false positives in random data — but the real anchor's * central 3×3 must be a uniform dark block. This kills the false positives * cheaply. */ function verifyAnchorCenter( binary: boolean[][], cx: number, cy: number, modulePx: number, W: number, H: number, ): boolean { // Sample a 3×3 grid of points spanning the central 3 modules const step = modulePx; let dark = 0, total = 0; for (let dr = -1; dr <= 1; dr++) { for (let dc = -1; dc <= 1; dc++) { const x = Math.round(cx + dc * step); const y = Math.round(cy + dr * step); if (x < 0 || x >= W || y < 0 || y >= H) return false; total++; if (binary[y][x]) dark++; } } if (dark < total * 0.8) return false; // And verify the immediate ring outside (at ±2 modules) has at least // some light points — the real anchor has a light gap there. let light = 0, ringTotal = 0; for (let dr = -2; dr <= 2; dr++) { for (let dc = -2; dc <= 2; dc++) { if (Math.abs(dr) < 2 && Math.abs(dc) < 2) continue; // only the ring const x = Math.round(cx + dc * step); const y = Math.round(cy + dr * step); if (x < 0 || x >= W || y < 0 || y >= H) continue; ringTotal++; if (!binary[y][x]) light++; } } return ringTotal > 0 && light >= ringTotal * 0.5; } /** Scan a single 1-D line via a `pixel(i)` accessor for the 1:1:3:1:1 * pattern. Calls `onMatch(centerPosition, modulePx)` for every match. */ function scanLineForAnchor( pixel: (i: number) => boolean, N: number, onMatch: (center: number, modulePx: number) => void, ): void { // Sliding-window run-length scan: maintain a rolling history of the last // 5 alternating runs (light, dark, light, dark, light). On each new run, // evaluate the trailing 5-run window for the 1:1:3:1:1 ratio. const runs: { len: number; dark: boolean; start: number }[] = []; let i = 0; let cur = pixel(0); let runStart = 0; while (i <= N) { const px = i < N ? pixel(i) : !cur; if (px !== cur || i === N) { runs.push({ len: i - runStart, dark: cur, start: runStart }); runStart = i; cur = px; // Keep only last 5 if (runs.length > 5) runs.shift(); if (runs.length === 5) { const [r0, r1, r2, r3, r4] = runs; // We need light, dark, light, dark, light if (!r0.dark && r1.dark && !r2.dark && r3.dark && !r4.dark) { const total = r0.len + r1.len + r2.len + r3.len + r4.len; const unit = total / 7; if (unit >= 1 && within(r0.len, unit, 0.5) && within(r1.len, unit, 0.5) && within(r2.len, unit * 3, 0.8) && within(r3.len, unit, 0.5) && within(r4.len, unit, 0.5)) { const center = r2.start + r2.len / 2; onMatch(Math.round(center), unit); } } // Also accept the case where the line starts inside a dark region: // dark, light, dark, light, dark — with the middle dark being the // 3-unit center block. if (r0.dark && !r1.dark && r2.dark && !r3.dark && r4.dark) { const total = r0.len + r1.len + r2.len + r3.len + r4.len; const unit = total / 7; if (unit >= 1 && within(r0.len, unit, 0.5) && within(r1.len, unit, 0.5) && within(r2.len, unit * 3, 0.8) && within(r3.len, unit, 0.5) && within(r4.len, unit, 0.5)) { const center = r2.start + r2.len / 2; onMatch(Math.round(center), unit); } } } } i++; } } function within(value: number, target: number, tolerance: number): boolean { return Math.abs(value - target) <= target * tolerance; } /** Cluster nearby anchor matches into single representatives. Two matches * are the same anchor if they are within ~2 modules of each other. */ function clusterAnchors(matches: AnchorMatch[]): AnchorMatch[] { const clusters: AnchorMatch[][] = []; for (const m of matches) { let placed = false; for (const cluster of clusters) { const ref = cluster[0]; const distSq = (m.cx - ref.cx) ** 2 + (m.cy - ref.cy) ** 2; if (distSq <= (ref.modulePx * 3) ** 2) { cluster.push(m); placed = true; break; } } if (!placed) clusters.push([m]); } return clusters.map((cluster) => { let sx = 0, sy = 0, sm = 0; for (const c of cluster) { sx += c.cx; sy += c.cy; sm += c.modulePx; } return { cx: sx / cluster.length, cy: sy / cluster.length, modulePx: sm / cluster.length, }; }); } // ── ORIENTATION CLASSIFICATION ─────────────────────────────────────────────── interface Orientation { TL: Point; TR: Point; BL: Point; modulePx: number; } interface Point { x: number; y: number } /** Pick the three anchors corresponding to the genuine corner squares. * * Strategy: the real anchors share the same modulePx (they're the same * physical pattern photographed in the same image). False positives — the * 1:1:3:1:1 run-length artefacts that random data sometimes produces — * scatter across modulePx values. Cluster candidates by modulePx, keep * the dominant bucket, then among that bucket pick the triplet that best * matches a right-angle isoceles triangle with equal legs (TL→TR ≈ TL→BL, * angle at TL ≈ 90°). */ function classifyAnchors(anchors: AnchorMatch[]): Orientation | null { if (anchors.length < 3) return null; let best: { TL: AnchorMatch; TR: AnchorMatch; BL: AnchorMatch; score: number; } | null = null; const N = anchors.length; for (let i = 0; i < N; i++) { for (let j = 0; j < N; j++) { if (j === i) continue; for (let k = 0; k < N; k++) { if (k === i || k === j) continue; const tl = anchors[i]; const a = anchors[j]; const b = anchors[k]; const va = { x: a.cx - tl.cx, y: a.cy - tl.cy }; const vb = { x: b.cx - tl.cx, y: b.cy - tl.cy }; const lenA = Math.hypot(va.x, va.y); const lenB = Math.hypot(vb.x, vb.y); if (lenA < 1 || lenB < 1) continue; // Order: TR has positive cross product (rightward then downward in // y-down image coords). const cross = va.x * vb.y - va.y * vb.x; if (cross <= 0) continue; // Right-angle at TL const cos = (va.x * vb.x + va.y * vb.y) / (lenA * lenB); const angularScore = -Math.abs(cos); // Equal legs const legRatio = Math.min(lenA, lenB) / Math.max(lenA, lenB); const lengthScore = -Math.abs(1 - legRatio); // Module size consistency — real anchors share one modulePx. const meanM = (tl.modulePx + a.modulePx + b.modulePx) / 3; const varM = ( (tl.modulePx - meanM) ** 2 + (a.modulePx - meanM) ** 2 + (b.modulePx - meanM) ** 2 ) / 3; const stdM = Math.sqrt(varM); const moduleScore = -stdM / Math.max(meanM, 1); // Triangle leg ≈ symbol pixel span. Real anchors are far apart. const sizeScore = Math.log(lenA + lenB) * 0.1; const score = angularScore * 5 + lengthScore * 5 + moduleScore * 8 + sizeScore; if (!best || score > best.score) { best = { TL: tl, TR: a, BL: b, score }; } } } } if (!best) return null; const modulePx = (best.TL.modulePx + best.TR.modulePx + best.BL.modulePx) / 3; return { TL: { x: best.TL.cx, y: best.TL.cy }, TR: { x: best.TR.cx, y: best.TR.cy }, BL: { x: best.BL.cx, y: best.BL.cy }, modulePx, }; } // ── HOMOGRAPHY (DLT, 4 point pairs) ────────────────────────────────────────── /** Solve a 3×3 homography H mapping src points (sx, sy) to dst points * (dx, dy) such that `dst = H * src` in homogeneous coords. Uses DLT with * h22 fixed at 1 — sufficient for non-degenerate quadrilaterals. * Returns the flat 9-element matrix [h00..h22] or null on singular input. */ function computeHomography(src: Point[], dst: Point[]): number[] | null { if (src.length !== 4 || dst.length !== 4) return null; // 8 unknowns: h00,h01,h02,h10,h11,h12,h20,h21 // Per pair: dx = h00*sx + h01*sy + h02 - h20*sx*dx - h21*sy*dx // dy = h10*sx + h11*sy + h12 - h20*sx*dy - h21*sy*dy const A: number[][] = []; const b: number[] = []; for (let i = 0; i < 4; i++) { const sx = src[i].x, sy = src[i].y; const dx = dst[i].x, dy = dst[i].y; A.push([sx, sy, 1, 0, 0, 0, -sx * dx, -sy * dx]); b.push(dx); A.push([0, 0, 0, sx, sy, 1, -sx * dy, -sy * dy]); b.push(dy); } const x = solve8(A, b); if (!x) return null; return [x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], 1]; } /** Solve an 8×8 linear system via partial-pivot Gaussian elimination. */ function solve8(A: number[][], b: number[]): number[] | null { const n = 8; const M: number[][] = A.map((row, i) => [...row, b[i]]); for (let col = 0; col < n; col++) { let pivot = col; for (let r = col + 1; r < n; r++) { if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r; } if (Math.abs(M[pivot][col]) < 1e-12) return null; [M[col], M[pivot]] = [M[pivot], M[col]]; for (let r = 0; r < n; r++) { if (r === col) continue; const factor = M[r][col] / M[col][col]; if (factor === 0) continue; for (let c = col; c <= n; c++) M[r][c] -= factor * M[col][c]; } } const x: number[] = new Array(n); for (let i = 0; i < n; i++) x[i] = M[i][n] / M[i][i]; return x; } function applyHomography(H: number[], px: number, py: number): Point { const w = H[6] * px + H[7] * py + H[8]; return { x: (H[0] * px + H[1] * py + H[2]) / w, y: (H[3] * px + H[4] * py + H[5]) / w, }; } function invertHomography(H: number[]): number[] | null { const a = H[0], b = H[1], c = H[2]; const d = H[3], e = H[4], f = H[5]; const g = H[6], h = H[7], i = H[8]; const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); if (Math.abs(det) < 1e-12) return null; const inv = 1 / det; return [ (e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv, (f * g - d * i) * inv, (a * i - c * g) * inv, (c * d - a * f) * inv, (d * h - e * g) * inv, (b * g - a * h) * inv, (a * e - b * d) * inv, ]; } // ── GRID SAMPLING ──────────────────────────────────────────────────────────── /** Anchor inner-3×3-block module coordinates for a given symbol size. * These are the *center* coordinates (module index + 0.5) used as the * destination side of the homography. */ function anchorModuleCenter(size: number, corner: "TL" | "TR" | "BL" | "BR"): Point { const q = QUIET_ZONE; // Inner 3×3 dark block centers at module (q+4, q+4) for TL etc. Add 0.5 // for module-center coordinates. const tlx = q + 4 + 0.5; const tly = q + 4 + 0.5; const farx = size - q - 5 + 0.5; // size - q - ANCHOR_FOOTPRINT + 4 + 0.5 const fary = size - q - 5 + 0.5; switch (corner) { case "TL": return { x: tlx, y: tly }; case "TR": return { x: farx, y: tly }; case "BL": return { x: tlx, y: fary }; case "BR": return { x: farx, y: fary }; } } /** Sample the binary image at the ideal module grid for a given symbol * size, using a homography fitted from the three known anchors plus an * extrapolated BR. */ function sampleGrid( binary: boolean[][], TL: Point, TR: Point, BL: Point, size: number, ): boolean[][] | null { // BR ≈ TL + (TR - TL) + (BL - TL) const BR: Point = { x: TL.x + (TR.x - TL.x) + (BL.x - TL.x), y: TL.y + (TR.y - TL.y) + (BL.y - TL.y), }; // src = pixel coords of anchors; dst = module-center coords. const src = [TL, TR, BL, BR]; const dst = [ anchorModuleCenter(size, "TL"), anchorModuleCenter(size, "TR"), anchorModuleCenter(size, "BL"), anchorModuleCenter(size, "BR"), ]; // We need module → pixel sampling; build H_inv directly by swapping src/dst. const Hinv = computeHomography(dst, src); if (!Hinv) return null; const matrix: boolean[][] = Array.from( { length: size }, () => new Array(size).fill(false), ); const H = binary.length; const W = binary[0]?.length ?? 0; for (let r = 0; r < size; r++) { for (let c = 0; c < size; c++) { const p = applyHomography(Hinv, c + 0.5, r + 0.5); const ix = Math.round(p.x); const iy = Math.round(p.y); if (iy >= 0 && iy < H && ix >= 0 && ix < W) { matrix[r][c] = binary[iy][ix]; } } } return matrix; } /** Two-pass sampling: read the size bits at the estimated size, then * re-sample at the exact size if they differ. */ function sampleAndReadExact( binary: boolean[][], TL: Point, TR: Point, BL: Point, estSize: number, ): { matrix: boolean[][]; size: number } | null { const rough = sampleGrid(binary, TL, TR, BL, estSize); if (!rough) return null; const exact = readSizeBits(rough); if (exact >= MIN_SYMBOL_SIZE && exact <= MAX_SYMBOL_SIZE && exact !== estSize) { const final = sampleGrid(binary, TL, TR, BL, exact); if (!final) return null; return { matrix: final, size: exact }; } if (exact < MIN_SYMBOL_SIZE || exact > MAX_SYMBOL_SIZE) { // Fall back to the estimated size — RS may still recover. return { matrix: rough, size: estSize }; } return { matrix: rough, size: exact }; } function readSizeBits(matrix: boolean[][]): number { const row = QUIET_ZONE; const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1; let val = 0; for (let i = 0; i < 16; i++) { if (matrix[row]?.[colStart + i]) val |= 1 << (15 - i); } return val; } // ── BIT / BYTE HELPERS ─────────────────────────────────────────────────────── function readDataBits( matrix: boolean[][], size: number, count: number, ): boolean[] { const bits: boolean[] = []; for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bits.length < count; r++) { const row = matrix[r]; if (!row) continue; for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bits.length < count; c++) { if (isReserved(r, c, size)) continue; bits.push(row[c] ?? false); } } return bits; } function bitsToBytes(bits: boolean[]): number[] { const bytes: number[] = []; for (let i = 0; i < bits.length; i += 8) { let byte = 0; for (let j = 0; j < 8 && i + j < bits.length; j++) { if (bits[i + j]) byte |= 1 << (7 - j); } bytes.push(byte); } return bytes; } function clamp(v: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, v)); }