Files
RecipesCam/docker/frontend
3dtours 12113088c8 web: hand the upscaler the photo's own pixels
An export larger than the photo came back flat: the model was never shown the
finest detail the photo held. Before it ran, the source was drawn down to
`scale / 4` of its size — floored at half — and only then handed over, on the
reasoning that a four-for-one model reading `target / 4` invents exactly the
destination and a whole photo would waste three quarters of its output. That
holds for a perfect resampler; it is not what this one is. A 2400px photo going
to 4K was fed at 1200px, and detail finer than the feed's own pixel — 1px
stripes, skin, foliage, fabric — was averaged into flat grey before the model
ever saw it. The draw then had that grey to enlarge, and no model can put back
what it was never given.

The feed is now the bitmap itself, read once at its own size: `drawImage(bitmap,
0, 0)`, no intermediate scale, no floor. The model's four-for-one is spent in
the destination draw instead, which reduces to `scale` and keeps what the photo
actually held. That draw also stops defaulting to `low` — it is usually a
reduction by up to four, and `low` would keep one sample in four of what the
model has just drawn.

Measured against the same running stack, a 2400x1800 source exported at 4K with
bands of 1/2/4/8/16px stripes and a patch of per-pixel grain, each band scored
by how it correlates with the pattern the source held at the source's own pixel
pitch (`r` / on-minus-off swing), plus the grain's high-frequency energy:

              p1            p2            grain sd   secs
  before   -0.01 / -0.0   0.94 / 204.0      13.9     51.0
  after     0.89 / 179.3  0.95 / 214.7      25.3    188.4
  hqresize  0.96 /  83.2  0.99 / 125.9      16.1        —

The 1px band went from uncorrelated and flat to 0.89 — the finest detail the
photo has now reaches the file. Grain lands above the plain-resize reference
rather than below it, which is the model enlarging texture instead of a filter
smearing it.

ponytail: the whole photo per tile means 80 tiles for a 2400px source where 20
were enough, so the wasm path (no WebGPU in the test chromium) grew from 51s to
188s for that export. It is the price of the detail and it is paid once per
export, off the critical path; a device with WebGPU, or a smaller source, does
not pay it this way.

Verified on the rebuilt container (BASE=http://localhost:8090):
- sr-detail-probe.cjs, midtone source so the app's tone pipeline cannot clip
  the very detail being measured (an earlier all-contrast version of it reported
  "grain 0.00" for the model AND for a plain resize — it was measuring the clip)
- superres-test.cjs 32 PASS / 0 FAIL (export sizes, 4K tile seams clean)
- sr-crop-export.cjs 0 FAIL
- npx tsc --noEmit clean.
2026-09-25 18:48:13 +07:00
..