← case studies

SignalVision: Browser ML via ONNX int8 Quantization

2026.07.05ONNX · quantization · PyTorch · computer vision · edge AI · MediaPipe

SignalVision takes an existing PyTorch sign-language recognition model and adds deployment engineering: ONNX export, int8 quantization, and in-browser inference via onnxruntime-web. The pitch is not "I trained a model" — it's "I take existing models and make them run efficiently in constrained environments."

The first version shipped that story cleanly. Then I ran the live demo on a real webcam and it fell apart.

At a glance

  • Problem: A trained CNN is not a deployable product. Browser inference demands small models, predictable latency, and no server round-trip — but the benchmark numbers were measured on Sign Language MNIST crops, not webcam footage.
  • Approach: Export to ONNX fp32, compare dynamic vs static int8 quantization, ship static int8 with calibrated activation ranges, serve via onnxruntime-web. When live inference collapsed to noisy P/Q predictions, add MediaPipe Hand Landmarker for landmark-guided crop + upright rotation, plus an A–D eval harness to measure webcam accuracy honestly.
  • Stack: PyTorch · ONNX · onnxruntime-web · MediaPipe Hand Landmarker · Next.js 14 · TypeScript.
  • Headline result (benchmark): Static int8 model is 3.9× smaller with 3.4× faster p50 latency vs PyTorch baseline, at −0.34 pp accuracy cost on the held-out SL-MNIST test split.
  • Headline result (live, in progress): A–D letters reach ~100% on SL-MNIST after quantization; webcam accuracy is measured per-letter via the eval panel — confidence still swings frame-to-frame even when the sign is correct.

Phase 1 — making the model deployable

The deployment path matters more than the architecture of the CNN itself — a small conv network trained on sign-language frames. The engineering work is the export graph, quantization strategy, and browser runtime.

Why ONNX: Single graph representation that onnxruntime can optimize across CPU, WASM, and (eventually) WebGPU backends without tying deployment to PyTorch's Python runtime.

Why static int8 over dynamic: Dynamic int8 only quantizes MatMul/Gemm ops; Conv layers stay fp32 with quant/dequant overhead at every inference. On this conv-heavy model, dynamic int8 was slower than fp32 ONNX. Static int8 (QDQ, calibrated on 512 training samples) lets Conv layers run in int8 — where both speed and size wins come from — at the cost of a real accuracy drop.

Benchmarks (SL-MNIST test split, 7,172 samples)

All numbers measured on CPU (x86_64, 4 cores), batch size 1, 200 timed runs after warmup:

| Model | Size | Latency p50 | Accuracy | | --- | ---: | ---: | ---: | | PyTorch baseline (fp32) | 1.51 MB | 0.287 ms | 95.59% | | ONNX export (fp32) | 1.51 MB | 0.105 ms | 95.59% | | ONNX dynamic int8 | 0.39 MB | 0.490 ms | 95.64% | | ONNX static int8 (shipped) | 0.39 MB | 0.085 ms | 95.25% |

The accuracy cost is not zero, and that's expected: 95.59% → 95.25% (−0.34 pp, 25 additional misclassifications out of 7,172). A quantized model showing zero drop would be a signal to re-check the eval, not a win.

Per-class spot-check on A–D after quantization: A/B/C at 100%, D at 98.78% (3 confusions with N). The classifier was not the bottleneck for the first four letters.

Phase 2 — the webcam broke the demo

I pointed a laptop camera at my hand and the model latched onto P and Q regardless of the sign I was making.

That was not a quantization regression. It was a distribution mismatch:

| Sign Language MNIST (training) | Live webcam (demo) | | --- | --- | | Hand glyph spans ~27/28 pixels | Full frame squashed to 28×28 — hand becomes a tiny blur | | Tightly cropped, controlled lighting | Face, chair, room clutter in frame | | Grayscale glyph on neutral background | Color feed, skin-tone segmentation unreliable |

The 95.25% benchmark number was real — on the dataset the model was trained on. It did not transfer to arbitrary webcam scenes.

First fix: better preprocessing (still heuristic)

I tightened the crop with skin-tone bounding boxes, mirrored the capture to match the preview, added a 28×28 "model sees" preview, and smoothed predictions over a short window. That helped users see the failure mode (tiny hand blob in the preview) but was still fragile across skin tones and lighting.

Phase 3 — MediaPipe hand tracking

The missing piece was reliable hand localization. Google's MediaPipe Hand Landmarker runs in the browser (WASM, self-hosted under /public/mediapipe/) and returns 21 landmarks per hand.

webcam frame (mirrored)


  MediaPipe Hand Landmarker     21 landmarks + handedness


  landmark bbox + padding       square crop where hand fills frame


  wrist → middle-MCP rotation   upright the hand before resize


  28×28 grayscale tensor        unchanged CNN input contract


  int8 ONNX via onnxruntime-web letter + confidence

Inference is gated: if MediaPipe does not detect a hand, the CNN does not run. That alone eliminated a class of garbage predictions on empty frames.

Tradeoffs accepted:

  • +7.8 MB for hand_landmarker.task on top of the 0.39 MB CNN
  • Two WASM runtimes (MediaPipe + onnxruntime-web) on the main thread — Web Worker offload is the next step
  • MediaPipe finds the hand; it does not classify letters — the CNN is still required

Phase 4 — measuring live accuracy honestly (A–D eval)

Benchmark tables on a static test split are not enough when the product surface is a webcam. I added an A–D eval panel on /vision:

  1. Select holding A / B / C / D for the sign you are performing
  2. Hold steady ~1s — a sample records when the prediction stabilizes
  3. Per-letter and overall accuracy update in the UI

This follows the same philosophy as LeadSignal's /validate page: show your evaluation, not just your demo.

Live demo: A–D eval recording letter C, MediaPipe hand crop, 28×28 model-input preview, and inference log showing frame-to-frame confidence swings on a correct sign.

What the clip shows (and what it means)

The eval panel is recording C. MediaPipe's green crop box is tracking the hand. The CNN agrees — prediction C — but confidence oscillates between ~0.37 and ~0.94 across consecutive frames in the inference log, even while the sign is held steady.

That is the current struggle in one clip:

  • Localization is largely solved — the 28×28 preview shows a hand-shaped glyph filling the frame, not a full-scene blur
  • Classification is directionally correct but not stable enough to claim 100% live A–D yet
  • Smoothing and eval cooldowns help the UX, but they mask variance rather than removing it

The gap is no longer "find the hand." It is close the domain gap between SL-MNIST glyphs and real webcam appearance (contrast, finger edges, background bleed, signer variation).

Can browser inference train the model further?

Short answer: use the browser to collect and label data and measure failure modes; run actual weight updates offline in Python. Full fine-tuning inside the tab is possible in theory but a poor fit for this project today.

| Approach | Viable? | Role | | --- | --- | --- | | Eval panel → export labeled crops | Yes — best next step | When you select "holding C" and a sample records, save the 28×28 tensor + user label to IndexedDB; export CSV/JSON for train_model.py fine-tuning | | Active learning loop | Yes | Browser flags low-confidence frames; you confirm the label; retrain on disagreements | | MediaPipe geometry checks | Partial | Rules on finger extension could gate or boost A/B/D; C is harder from landmarks alone | | TF.js / ONNX Runtime Training in-browser | Technically yes, practically no | Backprop on a CNN in WASM is slow, brittle, and fights SignalVision's "ship int8 ONNX" story | | Quantization-aware retrain in browser | No | Calibration and QDQ export belong in the Python pipeline |

The principle that does transfer: the browser is an excellent local lab. Privacy-by-construction (frames never leave the device), instant feedback on preprocessing changes, and per-device eval — then bundle improved weights as a new versioned .onnx artifact. That keeps deployment engineering as the spine of the project while using live inference to drive the next training set, not to replace PyTorch.

Planned iteration:

  1. Export eval-panel samples (landmark crop + label + confidence) from the client
  2. Fine-tune the existing CNN on webcam-labeled A–D crops — small data, last layers only
  3. Re-run the ONNX → int8 export pipeline and A/B against the current artifact
  4. Publish per-letter webcam accuracy next to SL-MNIST benchmark accuracy — both numbers, no cherry-picking

What I'd do differently (updated)

WebGPU backend. WASM is the portable default for both MediaPipe and onnxruntime-web; WebGPU would be the next step for devices that support it.

Web Worker for MediaPipe. detect() blocks the main thread; move landmark inference off-thread before the CNN tick.

Versioned model artifacts. Cache-busted ONNX URLs, semver on the int8 file, graceful fallback when a new model fails to load.

Webcam-labeled fine-tune set. The eval panel is already the labeling UI — wire it to export training rows instead of only counting accuracy.

  • PyTorch · ONNX · onnxruntime-web · MediaPipe Hand Landmarker · Next.js 14
  • Live demo: signalvision/ app — /vision (webcam + A–D eval) · /vision/benchmarks (quantization dashboard)
  • GitHub: SignalVision source

Related portfolio work: LeadSignal (rules + Claude enrichment for RevOps), DocSignal (hybrid RAG retrieval).