File size: 10,982 Bytes
83d921d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
// 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 });
}