Spaces:
Running
Running
| /** | |
| * 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, | |
| }; | |
| } | |