import sharp from 'sharp'; import { join } from 'node:path'; import { generate_id } from './ids.mjs'; // --------------------------------------------------------------------------- // Homography math // --------------------------------------------------------------------------- // Solve 8x8 linear system Ax=b via Gaussian elimination with partial pivoting function gaussian_solve(A, b) { const n = A.length; const M = A.map((row, i) => [...row, b[i]]); for (let col = 0; col < n; col++) { let max_row = col; for (let row = col + 1; row < n; row++) { if (Math.abs(M[row][col]) > Math.abs(M[max_row][col])) max_row = row; } [M[col], M[max_row]] = [M[max_row], M[col]]; for (let row = col + 1; row < n; row++) { const f = M[row][col] / M[col][col]; for (let j = col; j <= n; j++) M[row][j] -= f * M[col][j]; } } const x = new Array(n).fill(0); for (let i = n - 1; i >= 0; i--) { x[i] = M[i][n]; for (let j = i + 1; j < n; j++) x[i] -= M[i][j] * x[j]; x[i] /= M[i][i]; } return x; } // Compute 3x3 homography H such that src_pts[i] -> dst_pts[i] // Points: [{x, y}] x4, order TL TR BR BL function compute_homography(src_pts, dst_pts) { const A = []; for (let i = 0; i < 4; i++) { const { x: sx, y: sy } = src_pts[i]; const { x: dx, y: dy } = dst_pts[i]; A.push([-sx, -sy, -1, 0, 0, 0, dx * sx, dx * sy, dx]); A.push([ 0, 0, 0, -sx, -sy, -1, dy * sx, dy * sy, dy]); } const M8 = A.map(row => row.slice(0, 8)); const b = A.map(row => -row[8]); const h = gaussian_solve(M8, b); return [ [h[0], h[1], h[2]], [h[3], h[4], h[5]], [h[6], h[7], 1.0 ], ]; } // Invert a 3x3 matrix function invert_3x3(m) { const [[a, b, c], [d, e, f], [g, h, k]] = m; const det = a*(e*k - f*h) - b*(d*k - f*g) + c*(d*h - e*g); if (Math.abs(det) < 1e-10) throw new Error('Singular homography matrix'); const s = 1 / det; return [ [(e*k - f*h)*s, (c*h - b*k)*s, (b*f - c*e)*s], [(f*g - d*k)*s, (a*k - c*g)*s, (c*d - a*f)*s], [(d*h - e*g)*s, (b*g - a*h)*s, (a*e - b*d)*s], ]; } // Bilinear sample from RGBA pixel buffer (Uint8Array, row-major) function bilinear_sample(pixels, width, height, x, y, out, out_idx) { const x0 = x | 0, y0 = y | 0; const tx = x - x0, ty = y - y0; function px(xi, yi, ch) { if (xi < 0 || xi >= width || yi < 0 || yi >= height) return 0; return pixels[(yi * width + xi) * 4 + ch]; } for (let ch = 0; ch < 4; ch++) { const v00 = px(x0, y0, ch); const v10 = px(x0+1, y0, ch); const v01 = px(x0, y0+1, ch); const v11 = px(x0+1, y0+1, ch); out[out_idx + ch] = (v00 + (v10 - v00)*tx + (v01 - v00)*ty + (v11 - v10 - v01 + v00)*tx*ty) + 0.5 | 0; } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- // Compute natural cell size from corner quadrilateral + grid dimensions export function compute_cell_size(corners, rows, cols) { const [tl, tr, br, bl] = corners; const top_w = Math.hypot(tr.x - tl.x, tr.y - tl.y); const bot_w = Math.hypot(br.x - bl.x, br.y - bl.y); const left_h = Math.hypot(bl.x - tl.x, bl.y - tl.y); const rgt_h = Math.hypot(br.x - tr.x, br.y - tr.y); const raw_cw = Math.max(top_w, bot_w) / cols; const raw_ch = Math.max(left_h, rgt_h) / rows; return { cell_w: Math.round(Math.min(480, Math.max(48, raw_cw))), cell_h: Math.round(Math.min(480, Math.max(48, raw_ch))), }; } // Process a source image: apply perspective warp, slice into cells, save to images_dir. // Returns 2D array cells[row][col] = filename. export async function process_grid_image(source_path, corners, rows, cols, cell_w, cell_h, images_dir) { const out_w = cols * cell_w; const out_h = rows * cell_h; // H maps source corners -> output rectangle const dst_pts = [ { x: 0, y: 0 }, { x: out_w, y: 0 }, { x: out_w, y: out_h }, { x: 0, y: out_h }, ]; // Optionally downscale source to at most 2x the output resolution for speed const meta = await sharp(source_path).metadata(); const max_src = Math.max(out_w, out_h) * 2; const src_scale = Math.min(1.0, max_src / Math.max(meta.width, meta.height)); const scaled_src_w = Math.round(meta.width * src_scale); const scaled_src_h = Math.round(meta.height * src_scale); const pipeline = src_scale < 0.99 ? sharp(source_path).resize(scaled_src_w, scaled_src_h) : sharp(source_path); const { data: src_px } = await pipeline.ensureAlpha().raw().toBuffer({ resolveWithObject: true }); // Scale corner coords if we downscaled the source const sc = src_scale; const sc_corners = corners.map(c => ({ x: c.x * sc, y: c.y * sc })); const H_inv2 = invert_3x3(compute_homography(sc_corners, dst_pts)); // Warp full output grid const out_px = new Uint8Array(out_w * out_h * 4); const [hi0, hi1, hi2] = H_inv2; for (let oy = 0; oy < out_h; oy++) { for (let ox = 0; ox < out_w; ox++) { const denom = hi2[0]*ox + hi2[1]*oy + hi2[2]; const sx = (hi0[0]*ox + hi0[1]*oy + hi0[2]) / denom; const sy = (hi1[0]*ox + hi1[1]*oy + hi1[2]) / denom; bilinear_sample(src_px, scaled_src_w, scaled_src_h, sx, sy, out_px, (oy*out_w + ox)*4); } } // Slice into cells and save as JPEG const cells = []; for (let row = 0; row < rows; row++) { const row_arr = []; for (let col = 0; col < cols; col++) { const filename = generate_id() + '.jpg'; const cell_buf = Buffer.alloc(cell_w * cell_h * 4); const ox0 = col * cell_w, oy0 = row * cell_h; for (let cy = 0; cy < cell_h; cy++) { const src_row_off = ((oy0 + cy) * out_w + ox0) * 4; const dst_row_off = cy * cell_w * 4; cell_buf.set(out_px.subarray(src_row_off, src_row_off + cell_w * 4), dst_row_off); } await sharp(cell_buf, { raw: { width: cell_w, height: cell_h, channels: 4 } }) .jpeg({ quality: 88 }) .toFile(join(images_dir, filename)); row_arr.push(filename); } cells.push(row_arr); } return cells; }