/** * Tile layout and autocorrelation-based sync recovery * * The watermark is embedded as a periodic pattern of tiles. * Each tile contains one complete copy of the coded payload. * During detection, autocorrelation recovers the tile grid * even after cropping. */ import type { Buffer2D } from './types.js'; /** Tile grid description */ export interface TileGrid { /** Tile period in subband pixels */ tilePeriod: number; /** Phase offset X (for cropped frames) */ phaseX: number; /** Phase offset Y (for cropped frames) */ phaseY: number; /** Number of complete tiles in X direction */ tilesX: number; /** Number of complete tiles in Y direction */ tilesY: number; /** Total number of tiles */ totalTiles: number; } /** * Compute the tile grid for a given subband size and tile period * During embedding, phase is always (0, 0) */ export function computeTileGrid( subbandWidth: number, subbandHeight: number, tilePeriod: number ): TileGrid { // Snap tile period down to a multiple of 8 so that DCT blocks // perfectly tile each tile with no leftover strips (which would // show up as unembedded grid lines in the diff). const snapped = Math.floor(tilePeriod / 8) * 8; const effectivePeriod = Math.max(8, snapped); const tilesX = Math.floor(subbandWidth / effectivePeriod); const tilesY = Math.floor(subbandHeight / effectivePeriod); return { tilePeriod: effectivePeriod, phaseX: 0, phaseY: 0, tilesX, tilesY, totalTiles: tilesX * tilesY, }; } /** * Get the subband region for a specific tile * Returns [startX, startY] in subband coordinates */ export function getTileOrigin(grid: TileGrid, tileIdx: number): { x: number; y: number } { const tileCol = tileIdx % grid.tilesX; const tileRow = Math.floor(tileIdx / grid.tilesX); return { x: grid.phaseX + tileCol * grid.tilePeriod, y: grid.phaseY + tileRow * grid.tilePeriod, }; } /** * Compute 8x8 DCT block positions within a tile * Returns array of [blockRow, blockCol] in subband coordinates */ export function getTileBlocks( tileOriginX: number, tileOriginY: number, tilePeriod: number, subbandWidth: number, subbandHeight: number ): Array<{ row: number; col: number }> { const blocks: Array<{ row: number; col: number }> = []; const blocksPerTileSide = Math.floor(tilePeriod / 8); for (let br = 0; br < blocksPerTileSide; br++) { for (let bc = 0; bc < blocksPerTileSide; bc++) { const absRow = Math.floor(tileOriginY / 8) + br; const absCol = Math.floor(tileOriginX / 8) + bc; // Ensure the block fits within the subband if ((absRow + 1) * 8 <= subbandHeight && (absCol + 1) * 8 <= subbandWidth) { blocks.push({ row: absRow, col: absCol }); } } } return blocks; } /** * Autocorrelation-based tile period and phase recovery * * Computes 2D autocorrelation of the subband energy pattern * to find the periodic tile structure. */ export function recoverTileGrid( subband: Buffer2D, expectedTilePeriod: number, searchRange: number = 4 ): TileGrid { const { data, width, height } = subband; // Compute block energy map (8x8 blocks) const bw = Math.floor(width / 8); const bh = Math.floor(height / 8); const energy = new Float64Array(bw * bh); for (let by = 0; by < bh; by++) { for (let bx = 0; bx < bw; bx++) { let e = 0; for (let r = 0; r < 8; r++) { for (let c = 0; c < 8; c++) { const v = data[(by * 8 + r) * width + (bx * 8 + c)]; e += v * v; } } energy[by * bw + bx] = e; } } // Expected tile period in blocks const expectedBlockPeriod = Math.floor(expectedTilePeriod / 8); // Search for the best period near the expected value let bestPeriod = expectedBlockPeriod; let bestCorr = -Infinity; let bestPhaseX = 0; let bestPhaseY = 0; for (let p = expectedBlockPeriod - searchRange; p <= expectedBlockPeriod + searchRange; p++) { if (p < 2) continue; // For each candidate phase offset for (let py = 0; py < p; py++) { for (let px = 0; px < p; px++) { let corr = 0; let count = 0; // Compute autocorrelation at this period and phase for (let by = py; by + p < bh; by += p) { for (let bx = px; bx + p < bw; bx += p) { const e1 = energy[by * bw + bx]; const e2 = energy[(by + p) * bw + bx]; const e3 = energy[by * bw + (bx + p)]; corr += e1 * e2 + e1 * e3; count++; } } if (count > 0) { corr /= count; if (corr > bestCorr) { bestCorr = corr; bestPeriod = p; bestPhaseX = px; bestPhaseY = py; } } } } } // Convert from block coordinates back to subband pixels const tilePeriod = bestPeriod * 8; const phaseX = bestPhaseX * 8; const phaseY = bestPhaseY * 8; const tilesX = Math.floor((width - phaseX) / tilePeriod); const tilesY = Math.floor((height - phaseY) / tilePeriod); return { tilePeriod, phaseX, phaseY, tilesX, tilesY, totalTiles: tilesX * tilesY, }; }