// Image preprocessing identical to the Python pipeline (preprocess.py), so the browser feeds // the model the same numbers that training, evaluation and the API use: // resize the shorter side to 256 with Pillow's bilinear filter (reproduced exactly: // two passes, 22-bit fixed-point coefficients, the same rounding), centre-crop 224x224 // (Python's round-half-to-even), scale to [0, 1], normalise with the ImageNet mean/std. // Input and intermediate images are RGB Uint8Arrays, row-major, 3 bytes per pixel. // Verified against the Python code on all 758 validation photos (web/check_preprocess.mjs) // and, for tall and unusual images, through the page (web/check_page.cjs). export const RESIZE = 256; export const CROP = 224; export const MEAN = [0.485, 0.456, 0.406]; export const STD = [0.229, 0.224, 0.225]; // Same limits as preprocess.py: bigger or more elongated images are refused. export const MAX_PIXELS = 25_000_000; export const MAX_ASPECT = 10; export function checkSize(w, h) { if (w < 1 || h < 1) throw new Error("The image is empty."); if (w * h > MAX_PIXELS) throw new Error(`That image is too large: ${w}x${h} pixels.`); if (Math.max(w, h) / Math.min(w, h) > MAX_ASPECT) { throw new Error(`That image is too elongated: ${w}x${h}.`); } } const PRECISION_BITS = 32 - 8 - 2; // Pillow's Resample.c function bilinear(x) { x = Math.abs(x); return x < 1 ? 1 - x : 0; } // Pillow precompute_coeffs + normalize_coeffs_8bpc for one axis. function coefficients(inSize, outSize) { const scale = inSize / outSize; const filterscale = Math.max(scale, 1); const support = 1 * filterscale; // bilinear support is 1 const ksize = Math.ceil(support) * 2 + 1; const bounds = new Int32Array(outSize * 2); const kk = new Int32Array(outSize * ksize); const k = new Float64Array(ksize); for (let xx = 0; xx < outSize; xx++) { const center = (xx + 0.5) * scale; const ss = 1 / filterscale; let xmin = Math.trunc(center - support + 0.5); if (xmin < 0) xmin = 0; let xmax = Math.trunc(center + support + 0.5); if (xmax > inSize) xmax = inSize; xmax -= xmin; let ww = 0; for (let x = 0; x < xmax; x++) { const w = bilinear((x + xmin - center + 0.5) * ss); k[x] = w; ww += w; } for (let x = 0; x < ksize; x++) { const v = x < xmax && ww !== 0 ? k[x] / ww : 0; kk[xx * ksize + x] = Math.trunc(v < 0 ? -0.5 + v * (1 << PRECISION_BITS) : 0.5 + v * (1 << PRECISION_BITS)); } bounds[xx * 2] = xmin; bounds[xx * 2 + 1] = xmax; } return { bounds, kk, ksize }; } function clip8(ss) { const v = ss >> PRECISION_BITS; return v < 0 ? 0 : v > 255 ? 255 : v; } function resizeHorizontal(src, w, h, outW) { const { bounds, kk, ksize } = coefficients(w, outW); const out = new Uint8Array(outW * h * 3); const half = 1 << (PRECISION_BITS - 1); for (let y = 0; y < h; y++) { for (let xx = 0; xx < outW; xx++) { const xmin = bounds[xx * 2], xmax = bounds[xx * 2 + 1]; let s0 = half, s1 = half, s2 = half; for (let x = 0; x < xmax; x++) { const c = kk[xx * ksize + x], i = (y * w + x + xmin) * 3; s0 += src[i] * c; s1 += src[i + 1] * c; s2 += src[i + 2] * c; } const o = (y * outW + xx) * 3; out[o] = clip8(s0); out[o + 1] = clip8(s1); out[o + 2] = clip8(s2); } } return out; } function resizeVertical(src, w, h, outH) { const { bounds, kk, ksize } = coefficients(h, outH); const out = new Uint8Array(w * outH * 3); const half = 1 << (PRECISION_BITS - 1); for (let yy = 0; yy < outH; yy++) { const ymin = bounds[yy * 2], ymax = bounds[yy * 2 + 1]; for (let x = 0; x < w; x++) { let s0 = half, s1 = half, s2 = half; for (let y = 0; y < ymax; y++) { const c = kk[yy * ksize + y], i = ((y + ymin) * w + x) * 3; s0 += src[i] * c; s1 += src[i + 1] * c; s2 += src[i + 2] * c; } const o = (yy * w + x) * 3; out[o] = clip8(s0); out[o + 1] = clip8(s1); out[o + 2] = clip8(s2); } } return out; } // Pillow 13 (Resample.c) resizes horizontally first, then vertically, unless the height // shrinks by more than twice as much as the width (tall photos): then vertically first. A // pass that changes nothing is skipped. The order changes the rounding between passes. export function resize(src, w, h, outW, outH) { let data = src; if (h - outH > 0 && h - outH > (w - outW) * 2) { if (outH !== h) data = resizeVertical(data, w, h, outH); if (outW !== w) data = resizeHorizontal(data, w, outH, outW); } else { if (outW !== w) data = resizeHorizontal(data, w, h, outW); if (outH !== h) data = resizeVertical(data, outW, h, outH); } return data; } export function shorterSideSize(w, h, size = RESIZE) { // torchvision: the long side is truncated, int(size * long / short) return w <= h ? [size, Math.trunc((size * h) / w)] : [Math.trunc((size * w) / h), size]; } // Python's round(): halves go to the nearest even integer. export function roundHalfEven(x) { const r = Math.round(x); return Math.abs(x % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r; } export function preprocess(rgb, w, h) { checkSize(w, h); const [rw, rh] = shorterSideSize(w, h); const resized = resize(rgb, w, h, rw, rh); const top = roundHalfEven((rh - CROP) / 2); const left = roundHalfEven((rw - CROP) / 2); const out = new Float32Array(3 * CROP * CROP); const plane = CROP * CROP; for (let y = 0; y < CROP; y++) { for (let x = 0; x < CROP; x++) { const i = ((y + top) * rw + x + left) * 3; for (let c = 0; c < 3; c++) { // float32 arithmetic in the same order as NumPy: (v / 255 - mean) / std const v = Math.fround(Math.fround(resized[i + c] / 255) - Math.fround(MEAN[c])); out[c * plane + y * CROP + x] = Math.fround(v / Math.fround(STD[c])); } } } return out; }