- Grid images: photograph component boxes in sub-sections, assemble into one logical grid via perspective warp (homography) - Source image gallery: bulk upload photos separately from grid setup - Grid drafts: persist partial work, resume across sessions - Multi-panel grids: define rows/cols per photo, system computes panel layout; process partially configured grids, edit individual panels - Pan/zoom canvas editor (HiDPI-aware, touch support) for corner alignment - SPA routing with canonical URLs (history.pushState, server catch-all) - Express error visibility: uncaughtException/unhandledRejection handlers and 4-arg error middleware - Original filename stored on source image upload - Various null-safety fixes and CSS [hidden] override fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
170 lines
5.8 KiB
JavaScript
170 lines
5.8 KiB
JavaScript
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;
|
|
}
|