splat-js-demo / engine /src /io /frames.js
Mike0021's picture
Splat.js demo — in-browser Gaussian splat training
83d921d
Raw History Blame Contribute Delete
11 kB
// io/frames.js — decode & downscale input photographs into pipeline Frames.
//
// A Frame carries two resolutions of the same photograph:
// feature scale — grayscale for SfM (corner/SIFT detection)
// training scale — RGB float for the 3DGS photometric loss
//
// No DOM requirement: uses OffscreenCanvas when available (workers, headless)
// and falls back to document canvases in a page.
/**
* @typedef {object} Frame
* @property {string} name source file name
* @property {number} fw feature-scale width
* @property {number} fh feature-scale height
* @property {Float32Array} gray feature-scale grayscale, fw*fh, 0..1
* @property {number} tw training-scale width
* @property {number} th training-scale height
* @property {Float32Array} rgb training-scale RGB, tw*th*3, 0..1
* @property {number} sharpness Laplacian variance (motion-blur indicator)
* @property {HTMLCanvasElement|OffscreenCanvas} thumb small preview
* @property {(x:number, y:number) => number[]} sampleColor training-res RGB at
* feature-scale pixel coords
*/
/**
* @typedef {object} FrameOptions
* @property {number} [featMaxDim=960] feature-scale max dimension. 960 (was
* 640): SfM feature localization is the pose-precision ceiling — measured on
* camping vs the server-COLMAP reference: 0.44% -> 0.27% ATE, tail halved.
* @property {number} [trainMaxDim] training-scale max dimension override.
* Default: native resolution up to 1600, shrunk only if the whole set would
* blow the GPU target budget (see adaptiveTrainCap).
* @property {number} [targetBudgetBytes=700e6] GPU budget for the training
* target buffer (all images, RGB float32).
* @property {(msg: string) => void} [log]
*/
import { probeImageSize } from './pano.js';
export const FEAT_MAX_DIM = 960;
export const TRAIN_MAX_DIM = 1600; // hard ceiling; actual res = native, memory permitting
const TARGET_BUDGET_BYTES = 700e6;
/** iOS Safari frees canvas/bitmap backing stores lazily — 50 twelve-MP
* photos' worth of "already garbage" transients outrun the collector and
* jetsam kills the tab. Zeroing the dimensions releases the store NOW. */
const releaseCanvas = (cv) => {
if (cv && cv.width) { cv.width = 0; cv.height = 0; }
};
/** Train at the PROVIDED resolution up to trainMaxDim, shrunk only if the
* whole image set would blow the GPU target budget. Call once per dataset
* (needs the image count and one representative size); pass the result to
* processSource. An explicit opts.trainMaxDim wins unconditionally. */
export function adaptiveTrainCap(nImages, w, h, opts = {}) {
const nat = Math.max(w, h);
const scale = opts.trainScale || 1;
// trainMaxDim CAPS the input scale (never upscales); trainScale then
// multiplies the working buffer on top (may upscale — the photos are only
// re-gridded; supervising above input res suppresses super-resolution
// ringing at the cost of native-res PSNR and speed)
if (opts.trainMaxDim) return Math.round(Math.min(opts.trainMaxDim, nat) * scale);
if (scale !== 1) return Math.round(nat * scale);
const native = Math.max(w, h);
const full = Math.min(native, TRAIN_MAX_DIM);
const [fw, fh] = fitDims(w, h, full);
const bytes = nImages * fw * fh * 12; // 3 channels x float32
const budget = opts.targetBudgetBytes || TARGET_BUDGET_BYTES;
if (bytes <= budget) return full;
const s = Math.sqrt(budget / bytes);
return Math.max(320, Math.floor(full * s));
}
function fitDims(w, h, maxDim, allowUp = false) {
const s0 = maxDim / Math.max(w, h);
const s = allowUp ? s0 : Math.min(1, s0);
return [Math.max(2, Math.round(w * s)), Math.max(2, Math.round(h * s))];
}
function mkCanvas(w, h) {
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
const cv = document.createElement('canvas');
cv.width = w; cv.height = h;
return cv;
}
/** High-quality downscale: iterative halving until within 2x of the target,
* then a final filtered draw. A single drawImage at large factors decimates
* (effectively point-samples), leaving aliasing in the training targets and
* the SfM grayscale. */
function drawScaled(src, srcW, srcH, w, h) {
let cur = src, cw = srcW, ch = srcH;
while (cw >= 2 * w && ch >= 2 * h) {
const nw = Math.max(w, Math.round(cw / 2));
const nh = Math.max(h, Math.round(ch / 2));
const cv = mkCanvas(nw, nh);
const cctx = cv.getContext('2d');
cctx.imageSmoothingEnabled = true;
cctx.imageSmoothingQuality = 'high';
cctx.drawImage(cur, 0, 0, cw, ch, 0, 0, nw, nh);
if (cur !== src) releaseCanvas(cur); // intermediate halving step, done with it
cur = cv; cw = nw; ch = nh;
}
const cv = mkCanvas(w, h);
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(cur, 0, 0, cw, ch, 0, 0, w, h);
if (cur !== src) releaseCanvas(cur);
return ctx;
}
/** Single-step downscale (default bilinear). Deliberately used for the SfM
* grayscale: the residual high-frequency detail (incl. mild aliasing) is
* discriminative texture for corner detection — the smooth variant registers
* measurably fewer cameras. */
function drawScaledFast(src, w, h, preResized = false) {
const cv = mkCanvas(w, h);
const ctx = cv.getContext('2d', { willReadFrequently: true });
// preResized sources are within 2x of the gray target by construction
// (decode-to-target uses 2x featMaxDim), so this single bilinear step is
// the halving chain's own terminal condition. Do NOT be tempted to switch
// smoothing off to fake the native path's alias texture: nearest at a
// non-integer stride moirés content-locked patterns that collapse
// cross-view matching (measured: truck-42 registration 42 -> 3).
ctx.drawImage(src, 0, 0, w, h);
return ctx;
}
/** Process one drawable source (ImageBitmap or canvas) into a Frame.
* trainCap: per-dataset training resolution from adaptiveTrainCap(). */
export function processSource(src, srcW, srcH, name, trainCap, opts = {}, preResized = false) {
const featCap = opts.featMaxDim || FEAT_MAX_DIM;
const [fw, fh] = fitDims(srcW, srcH, featCap);
// only an explicit trainScale may push the working buffer past native
const [tw, th] = fitDims(srcW, srcH, trainCap || TRAIN_MAX_DIM, !!opts.trainScale);
const fctx = drawScaledFast(src, fw, fh, preResized);
const fdata = fctx.getImageData(0, 0, fw, fh).data;
releaseCanvas(fctx.canvas);
const gray = new Float32Array(fw * fh);
for (let i = 0; i < fw * fh; i++) {
gray[i] = (0.299 * fdata[i * 4] + 0.587 * fdata[i * 4 + 1] + 0.114 * fdata[i * 4 + 2]) / 255;
}
// sharpness = Laplacian variance (motion-blur indicator; used to exclude
// the blurriest frames from the training loss)
let lapSum = 0, lapSq = 0;
const nl = (fw - 2) * (fh - 2);
for (let y = 1; y < fh - 1; y++) {
for (let x = 1; x < fw - 1; x++) {
const i = y * fw + x;
const lap = 4 * gray[i] - gray[i - 1] - gray[i + 1] - gray[i - fw] - gray[i + fw];
lapSum += lap; lapSq += lap * lap;
}
}
const lapMean = lapSum / nl;
const sharpness = lapSq / nl - lapMean * lapMean;
const tctx = drawScaled(src, srcW, srcH, tw, th);
const tdata = tctx.getImageData(0, 0, tw, th).data;
releaseCanvas(tctx.canvas);
const rgb = new Float32Array(tw * th * 3);
for (let i = 0; i < tw * th; i++) {
rgb[i * 3] = tdata[i * 4] / 255;
rgb[i * 3 + 1] = tdata[i * 4 + 1] / 255;
rgb[i * 3 + 2] = tdata[i * 4 + 2] / 255;
}
// small thumbnail for UIs
const [thw, thh] = fitDims(srcW, srcH, 96);
const thumbCtx = drawScaled(src, srcW, srcH, thw, thh);
return {
name, fw, fh, gray, tw, th, rgb, sharpness,
thumb: thumbCtx.canvas,
/** sample training-res RGB at feature-scale pixel coords */
sampleColor(x, y) {
const sx = Math.min(tw - 1, Math.max(0, Math.round(x * tw / fw)));
const sy = Math.min(th - 1, Math.max(0, Math.round(y * th / fh)));
const i = (sy * tw + sx) * 3;
return [rgb[i], rgb[i + 1], rgb[i + 2]];
},
};
}
/** Decode image Files/Blobs (or {source, name} pairs of anything
* createImageBitmap accepts) into Frames.
* @param {Array<File|Blob|{source:*, name:string}>} files
* @param {FrameOptions} [opts]
* @returns {Promise<Frame[]>} */
export async function decodeFrames(files, opts = {}) {
const log = opts.log || (() => {});
const out = [];
let trainCap = 0;
let saidResize = false;
for (const file of files) {
const source = file.source || file;
const name = file.name || 'frame';
try {
// Decode-to-target: a 12MP phone photo decoded at native res is ~48MB
// of bitmap that exists only to be thrown away — 50 of those is what
// OOMs iPhones. When the header tells us the native size (JPEG/PNG),
// ask the decoder for at most 2x the largest scale we keep (the final
// filtered draw still gets a >=2x supersampled source, so quality is
// the same as the full halving chain). resizeWidth ALONE keeps the
// scale uniform whatever EXIF orientation does to the axes.
let bmp = null;
let resized = false;
const dims = source instanceof Blob ? await probeImageSize(source) : null;
if (!trainCap && dims) {
trainCap = adaptiveTrainCap(files.length, dims.w, dims.h, opts);
log(`training resolution: ${trainCap}px max dim (${files.length} images)`);
}
if (dims && trainCap && !opts.trainScale && !opts.decodeNative) {
const need = 2 * Math.max(opts.featMaxDim || FEAT_MAX_DIM, trainCap);
const nat = Math.max(dims.w, dims.h);
if (nat > need * 1.05) {
const rw = Math.round(dims.w * (need / nat));
try {
bmp = await createImageBitmap(source, { resizeWidth: rw, resizeQuality: 'high' });
resized = true;
if (!saidResize) { saidResize = true; log(`decoding at ${need}px (native ${nat}px)`); }
} catch { bmp = null; /* resize options unsupported: decode native */ }
}
}
if (!bmp) bmp = await createImageBitmap(source);
if (!trainCap) {
trainCap = adaptiveTrainCap(files.length, bmp.width, bmp.height, opts);
log(`training resolution: ${trainCap}px max dim (${files.length} images)`);
}
out.push(processSource(bmp, bmp.width, bmp.height, name, trainCap, opts, resized));
bmp.close();
} catch (e) {
log(`skipped ${name}: ${e.message}`);
}
}
return out;
}
/** @deprecated use decodeFrames(files, { log }) */
export async function loadImageFiles(files, log) {
return decodeFrames(files, { log });
}