2026-07-20
How we picked and verified an AI photo-upscale model that runs entirely in your browser
NearIMG's AI photo-upscale tool needed a real super-resolution model that runs entirely client-side — no upload, same as every other NearIMG tool. "Find a model and run it in the browser" sounds like the easy part next to that constraint, but it hides a real trap: a lot of ready-to-use ONNX exports of these models quietly bake in a fixed input resolution, which works fine in a demo and then breaks the moment a real user drops in a photo of their own size. Here's the actual model-selection process, the trap we caught before it caught us, how we verified we weren't shipping a tampered or subtly-wrong model, and the real numbers we measured — in a real browser — before deciding this was ready to ship.
The candidates we actually looked at
We weren't picking in a vacuum — the bar was "small enough to self-host, clearly licensed for commercial redistribution, and good enough to actually look better than a canvas resize."
| Candidate | Outcome |
|---|---|
| Real-ESRGAN x4plus (full-size) | Rejected — the official release is roughly 64 MB, far past what's reasonable to self-host for a single feature. |
| Qualcomm AI Hub's pre-converted ONNX export of the same weights | Rejected — hard- coded 128×128 input, no dynamic axes. See below. |
| FSRCNN / ESPCN (classic small super-resolution models) | Investigated only, not pursued — we didn't find a clearly-licensed, deployable ONNX export of either in the time we spent looking, and the model we did land on already cleared the size/license/quality bar, so there was no pressing reason to keep digging. |
| realesr-general-x4v3 (Real-ESRGAN "compact" variant, SRVGGNetCompact architecture) | Chosen. BSD-3-Clause, ~4.9 MB weights, dynamic input size once exported correctly (see below). |
The trap: a "ready to use" ONNX export with a hard-coded input size
The most convenient-looking option wasn't the weights themselves — it was
qualcomm/Real-ESRGAN-General-x4v3, a pre-converted ONNX export of the exact same
model on Qualcomm AI Hub. Pre-converted means no PyTorch, no export step, no chance to get the
conversion wrong. It also turned out to have its input shape hard-coded to 128×128 pixels,
with no dynamic axes declared in the graph. That's fine for a fixed benchmark; it's useless for a
tool whose entire premise is "upload whatever small photo you have" — real user photos aren't
128×128, and there's no way to resize the ONNX graph's expectations after the fact.
This wasn't a novel surprise, either. It's the same failure shape NearBG's team had already hit exporting BiRefNet for background removal — a different model, same converter-shaped trap: a fixed-size ONNX export that looks done until you feed it something the graph wasn't literally carved for. Knowing that precedent existed is exactly why this got checked instead of assumed; the Qualcomm export was rejected on this specific evidence, not on the general principle that "pre-converted models might be risky."
Verifying the weights weren't tampered with
Having ruled out the pre-converted export, the plan became: get the official
.pth weights and convert them ourselves, correctly. Before trusting any downloaded
binary, we pulled realesr-general-x4v3.pth from two independent HuggingFace mirrors
— clavidk/realesr-general-x4v3 and rippertnt/upscale — and hashed both:
sha256sum realesr-general-x4v3.pth
8dc7edb9ac80ccdc30c3a5dca6616509367f05fbc184ad95b731f05bece96292 (4.89 MB)
Identical hash from two unrelated re-uploads is real (if modest) evidence that this is the
unmodified official checkpoint from xinntao/Real-ESRGAN (BSD-3-Clause) — not a
re-trained, quantized, or otherwise altered copy circulating under the same filename.
Verifying the architecture actually matches
A correct hash confirms the file wasn't tampered with; it says nothing about whether our own
model code — the thing we'd actually run inference through — reproduces the right architecture.
So we ported the original SRVGGNetCompact definition from the official repo's
realesrgan/archs/srvgg_arch.py (same BSD-3-Clause source) into our own
srvgg_arch.py, configured it exactly as the checkpoint expects
(num_feat=64, num_conv=32, upscale=4, act_type='prelu'), and loaded the official
state dict with strict=True:
model.load_state_dict(sd, strict=True)
# missing_keys=[], unexpected_keys=[]
Zero missing keys and zero unexpected keys is a real assertion, not an assumption — PyTorch's strict loader fails loudly the moment a layer name, shape, or count doesn't line up. This is what let us trust that the network graph we were about to export to ONNX is architecturally identical to the one the weights were actually trained in, not a plausible-looking approximation.
The fix: exporting with dynamic axes ourselves
With weights and architecture both verified, the export step that avoids the Qualcomm trap is
one extra argument to torch.onnx.export:
torch.onnx.export(
model, dummy, 'realesr-general-x4v3.onnx',
input_names=['input'], output_names=['output'],
dynamic_axes={'input': {2: 'height', 3: 'width'}, 'output': {2: 'height', 3: 'width'}},
opset_version=13, dynamo=False,
)
Marking dimensions 2 and 3 (height and width) as dynamic axes means the resulting graph accepts any input resolution, not just whatever size the dummy tensor happened to be during export — the exact thing the Qualcomm build didn't do. The result: a 4.87 MB ONNX file with no fixed-input trap.
Getting there wasn't entirely friction-free, either — a small, honest bug along the way:
the first export attempt used PyTorch's defaults and failed, because current PyTorch (2.9+)
defaults to its newer "dynamo" ONNX exporter, which requires the separate onnxscript
package that wasn't installed in this environment. The fix was dynamo=False, falling
back to the older, still fully-supported TorchScript-based exporter — a one-line fix once the
actual error message (not a guess) pointed at the missing package.
Measuring it for real, not guessing
A model that loads and exports cleanly still might be slow, or might not actually look better
than a plain resize. So this wasn't graded on paper — it was run. The test harness drove real
Playwright Chromium (not a Node-side onnxruntime-web import standing in for a browser) with the
WASM execution backend, decoding, resizing and PNG-encoding through actual
<canvas> calls — the same code path that ships. Every run's network traffic was
captured via Playwright's request interception; the count of requests to any host other than the
local test server was zero, across every run.
| Case | Input → target | Model load | Inference | Canvas baseline | Sharpness (baseline / AI / true original) | AI vs. baseline |
|---|---|---|---|---|---|---|
fruits.jpg (OpenCV sample) | 128×120 → 512×480 | 1814 ms (cold) | 1706 ms | 0.9 ms | 14.16 / 40.79 / 122.56 | 2.88× |
| Real photo (picsum.photos id 237, a dog) | 75×50 → 300×200 | 20 ms (warm) | 451 ms | 0.2 ms | 61.86 / 300.33 / 730.76 | 4.85× |
fruits.jpg, no pre-downscale (worst case) |
512×480 → 2048×1920 | — | 27,757 ms (~27.8 s) | — | — | — |
"Sharpness" here is a high-frequency energy metric — the mean squared luminance difference
between adjacent pixels, a real (if simple) approximation of "how much fine detail is actually
present," measured on the literal rendered PNG from each run, not estimated. The absolute numbers
matter less than the relative comparison: across two very different real images, the AI output
carried 2.9–4.9× the high-frequency detail of a plain imageSmoothingQuality:
'high' canvas resize to the identical target size — a consistent, measured gap, not a
cherry-picked one. The third row is the worst-case number that shaped a real product decision,
covered next.
What this can't do — the honest tradeoffs
Being upfront about what shipping this actually costs, rather than only the parts that flatter it:
- It's slow on large inputs, and that shaped the UI. Small inputs (75–
130px on a side) upscale in well under two seconds, which is fine to run inline. But feeding a
~500px-wide image straight into the model at full size, with no pre-downscale, took roughly
28 seconds in this single-threaded WASM, cold-sandbox measurement. That number is what
drove the actual product decisions: inference runs in a worker so the main thread never blocks,
a "this may take a while" notice appears past roughly the 4-second mark, and the feature itself
is deliberately framed — in its title and description, not just internally — as a
tool for upscaling small, low-resolution images, rather than "instantly enlarge anything huge."
The shipped engine (
packages/near-upscale-engine/upscale.js) also caps the actual side length fed into the model at 768px regardless of the source photo's size, specifically to keep worst-case inference time bounded instead of scaling unboundedly with whatever a user happens to drop in. - The checkpoint is fixed at 4× — there is no separate 2× model.
This architecture's PixelShuffle upsampler bakes its output-channel count into the trained
weights, and no 2×-native sibling checkpoint of this one exists. So the UI's "200%" option
isn't a different, smaller model — it's the same 4× model, run at its one native
scale, with the result then precisely downscaled by half via canvas. That still produces
visibly more real detail than asking a plain canvas resize to invent 2× worth of pixels
from nothing, since it starts from the AI's 4× output rather than the original. The same
principle covers arbitrary pixel targets too: run the model at its one supported factor, then
finish with an exact canvas crop/fit to the requested size — documented in
planUpscale()'s comments in the engine source, not something bolted on separately per mode. - It doesn't recover the true original. Compared against the actual full-resolution source photo (not the deliberately-shrunk input fed to the model), the AI output never fully closes the gap — which is expected, not a bug: super-resolution generates plausible detail, it doesn't recover information that was never captured in the low-resolution input in the first place. That's an inherent limit of the technique, not something a better model config would fix. What it does deliver, measurably, is 2.9– 4.9× more high-frequency detail than a naive resize at the same target size — a real, different result, not a "blurry enlarge" with extra steps.
The honest summary
The pre-converted, ready-to-use ONNX export was the path of least resistance, and it was wrong
for this product for a concrete, checkable reason — a hard-coded 128×128 input on a
tool whose whole point is arbitrary user photos, the same converter-shaped trap another team here
had already hit with a different model. Avoiding it cost one extra step (exporting the official
weights ourselves, with dynamic axes) and one small, quickly-diagnosed bug along the way (the
newer PyTorch exporter needing a package we didn't have). What it bought was a model whose
provenance is hash-verified, whose architecture match is strict=True-verified, and
whose real-world performance and quality gap over a plain resize were measured in an actual
browser instead of assumed from a benchmark table — including the worst-case number that
honestly shaped how the feature got framed and throttled before it shipped.