NearIMG

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."

CandidateOutcome
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 weightsRejected — 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.

CaseInput → targetModel loadInference Canvas baselineSharpness (baseline / AI / true original)AI vs. baseline
fruits.jpg (OpenCV sample)128×120 → 512×480 1814 ms (cold)1706 ms0.9 ms 14.16 / 40.79 / 122.562.88×
Real photo (picsum.photos id 237, a dog)75×50 → 300×200 20 ms (warm)451 ms0.2 ms 61.86 / 300.33 / 730.764.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.

Sponsored
← NearIMG

This page shows ads only if you consent.