AprilTag JS
August 10, 2026 · View on GitHub
Real-time AprilTag detection
in the browser, powered by WebAssembly. Clone it, open index.html, and
detect tags from a live camera or an uploaded image — no build step, no
server-side component, no native dependencies.
Live demo: https://alialimohamad.github.io/apriltag-js/

Credit
This is a WebAssembly port of AprilRobotics/apriltag, the reference AprilTag 3 implementation built and maintained by the APRIL Robotics Laboratory at the University of Michigan. The detector itself — quad extraction, decoding, every tag family — is their unmodified C code, compiled to WebAssembly with Emscripten. All credit for the detection algorithm belongs to them. This repository only adds the WebAssembly/JavaScript integration layer: worker plumbing, a browser-facing API, and the playground UI in this README.
If you use AprilTag in academic work, please cite:
E. Olson, "AprilTag: A robust and flexible visual fiducial system," IEEE International Conference on Robotics and Automation (ICRA), 2011.
J. Wang and E. Olson, "AprilTag 2: Efficient and robust fiducial detection," IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS), 2016.
See NOTICE for the full attribution and LICENSE for terms — everything here, including the bundled WebAssembly binary, is BSD-2-Clause.
This project is listed as the official third-party JavaScript/WebAssembly binding in the upstream README (AprilRobotics/apriltag#446), alongside the existing Python, Matlab, and Julia bindings.
Why this exists
There isn't a good public, ready-to-run AprilTag detector for the web. This
repository exists to fill that gap: a single index.html that works from a
plain clone, with every tag family the C library supports, real-time camera
detection, single-image detection, and a data panel that shows exactly what
the detector returns.
Quick start
git clone --recursive https://github.com/AliAlimohamad/apriltag-js.git
cd apriltag-js
python -m http.server 8080
# open http://localhost:8080
--recursive pulls in the pinned AprilTag C source under vendor/apriltag/
via a git submodule — needed only if you plan to rebuild the WebAssembly
module (see Rebuilding the WebAssembly module
below). The prebuilt js/apriltag_wasm.wasm already ships in the repo, so a
plain git clone without --recursive is enough to just run the playground.
Any static file server works. A couple of things to know:
getUserMedia(camera access) requires a secure context.localhostover plain HTTP counts as secure, so the command above works for local development. It does not work when you open the page from another device via your machine's LAN IP (http://192.168.x.x:8080) — that's plain HTTP from the browser's point of view and the camera will be refused. To test from a phone, either serve over HTTPS locally, or just push to GitHub Pages, which serves HTTPS for free.- Image upload works over any origin, including
file://, since it doesn't touchgetUserMedia.
No printed tags on hand? Switch to Upload Image and drop in
test-images/apriltags_test.png — a reference sheet with one tag from each
of tag36h11, tagStandard41h12, tagStandard52h13, tagCustom48h12,
tagCircle49h12, and tagCircle21h7, useful for a quick sanity check that
multi-family detection is working — all six should be detected.
Features
- All 8 tag families the reference library ships with:
tag36h11,tag25h9,tag16h5,tagCircle21h7,tagCircle49h12,tagCustom48h12,tagStandard41h12,tagStandard52h13— select any combination, or all at once. - Live camera detection, with a device picker and resolution selector.
- Image upload (file picker or drag-and-drop), for single-shot detection without a camera.
- Real-time overlay — bounding box, corner markers, center crosshair, and an ID/family label drawn directly on the video or image, all independently toggleable.
- Detector tuning — live sliders for
quad_decimate,quad_sigma,refine_edges, anddecode_sharpening, the same parameters the underlying Capriltag_detector_texposes. - Per-tag data table — every field the detector returns for every tag in the current frame (ID, family, center, all 4 corners, decision margin, hamming distance, rotation), with a Copy as JSON button.
- Performance HUD — FPS, per-frame detection time, and tag count.
- Runs entirely client-side. No external requests, no analytics, no CDN dependency.
Supported tag families
| Family | Notes |
|---|---|
tag36h11 | Default. Most widely used family; good balance of size and robustness. |
tag25h9 | |
tag16h5 | Smallest code space — fastest to decode, most prone to false positives at long range. |
tagCircle21h7 | Hamming is fixed at 0 — see Known limitations for why. |
tagCircle49h12 | Hamming is fixed at 1 — see Known limitations for why. |
tagCustom48h12 | Hamming distance is forced to at least 1 — see below. |
tagStandard41h12 | |
tagStandard52h13 | Hamming distance is forced to at least 1 — see below. |
tagCustom48h12 and tagStandard52h13 have a dense enough code space that
hamming distance 0 produces an unacceptable false-positive rate, so both the
compiled detector and this UI enforce a minimum of 1 regardless of what you
select.
Enabling more than two or three families at once will noticeably drop your frame rate — each additional family is a full extra decode pass over every detected quad, every frame.
Known limitations
Historical note: two related bugs in the compiled detector, both fixed as
of the current js/apriltag_wasm.wasm build. tagCircle49h12 has 65,535
codes — by far the largest code space of the families this build exposes
(the next largest, tagStandard41h12, has 2,115) — and that size is what
originally exposed two bugs in the compiled detector:
- Requesting hamming distance 0 for any family internally called
AprilTag's
apriltag_detector_add_family()convenience wrapper, which silently substituted hamming distance 2 regardless of what was asked for. For most families that was "just" a wastefully oversized table; fortagCircle49h12specifically, a hamming-2 decode table needs several gigabytes and the allocation failed outright (safely: AprilTag's own decode path already null-checks this, so it degraded to "detects nothing" rather than crashing). Stacking that waste across several simultaneously selected families was, on its own, enough to exceed the compiled detector's 512MB heap even when no single family's table was individually too large — e.g.tag36h11+tagCustom48h12+tagStandard41h12+tagStandard52h13+tagCircle49h12together needed ~560MB unfixed (over budget) vs. ~368MB fixed (comfortable). Whichever family happened to be added last in that situation would silently fail to activate — which could look like an earlier, already-working family had stopped detecting, since reconfiguring the detector rebuilds it from scratch each time. - Whichever family was set up first always went through
initialize_detector(), which hardcoded hamming distance to 0 — so a family's own hamming choice was silently ignored whenever it happened to be first, funneling it straight into bug 1.
Both are fixed at the source in wasm-build/apriltag_wasm.c: add_family()
now always calls apriltag_detector_add_family_bits() with the exact
requested hamming (never the hardcoded-2 wrapper), and initialize_detector()
now takes and honors an explicit hamming argument instead of hardcoding 0 for
the first family. Hamming 0 means hamming 0, for every family, including
whichever one is added first — no JS-side workaround needed.
tagCircle21h7 is available, locked to hamming 0. It shares the code
space above with tagCircle49h12 but is smaller and denser (21 bits vs. 49),
which made it more exposed to bug 1 than any other family: even a
correctly-sized hamming-1 table (not just the buggy hamming-2 one) widens its
match tolerance enough to misdetect other active families' tags as corrupted
tagCircle21h7 codes, reproducible in testing. Since bug 1 is fixed and
hamming 0 (exact match only) doesn't have that problem, tagCircle21h7 ships
pinned to hamming 0 in js/tag-families.js (maxHamming: 0, same mechanism
used to pin tagCircle49h12 to hamming 1) rather than left user-selectable.
Detection result shape
Every detected tag has this shape, matching detection_info_t in the C shim
(wasm-build/apriltag_wasm.c) exactly as it's decoded in
js/apriltag-wasm-wrapper.js:
{
"id": 4,
"family": "tag36h11",
"familyId": 0,
"center": { "x": 321.4, "y": 178.9 },
"corners": [
{ "x": 290.1, "y": 150.2 },
{ "x": 352.6, "y": 151.8 },
{ "x": 354.0, "y": 210.5 },
{ "x": 291.3, "y": 209.0 }
],
"decisionMargin": 74.3,
"hamming": 0,
"rotation": 0.02
}
Corners are ordered starting from the tag's own top-left in its canonical orientation, so their order rotates with the tag — useful if you need to recover orientation, not just position.
Architecture
index.html + js/playground.js camera/upload UI, overlay, tuning controls
│
js/detector-manager.js main-thread async API, backpressure
│ (postMessage)
js/detector-worker.js runs off the main thread
│
js/apriltag-wasm-wrapper.js memory management, struct decoding
│
js/apriltag_wasm.js + .wasm compiled AprilTag C library (Emscripten)
Detection runs in a Web Worker so a busy frame never blocks the UI thread.
The manager (AprilTagDetectorManager) drops a detection request outright if
the worker is still processing the previous one, rather than queuing it —
that backpressure is what keeps the camera preview smooth under load. The
playground renders the video feed and the last completed detection result on
independent requestAnimationFrame loops for the same reason: the display
should never stall waiting on the detector.
js/tag-families.js is the single source of truth for the family
name/index table shared by the wrapper and the UI — the index values there
are load-bearing, since they're what actually crosses the WebAssembly
boundary and must match the C enum in wasm-build/apriltag_wasm.c.
Rebuilding the WebAssembly module
You only need this if you're modifying the C shim or the family set — the
prebuilt binary in js/ already contains all 8 families and works as-is.
-
Clone with submodules (
git submodule update --initif you already cloned without--recursive) to getvendor/apriltagat the pinned AprilTag v3.4.3 commit the shipped binary was built from. -
Install Emscripten and activate it (
emsdk activate latest, then source/run its env script). -
From
wasm-build/, the build is a singleemccinvocation. On Windows, runcompile.bat. On Linux/macOS, run the equivalent command directly — this repo intentionally does not ship a shell wrapper for it (see below), so translate the batch file's line continuations (^) to\and run:emcc -O3 \ apriltag_wasm.c \ ../vendor/apriltag/apriltag.c \ ../vendor/apriltag/apriltag_quad_thresh.c \ ../vendor/apriltag/apriltag_pose.c \ ../vendor/apriltag/tag36h11.c \ ../vendor/apriltag/tag25h9.c \ ../vendor/apriltag/tag16h5.c \ ../vendor/apriltag/tagCircle21h7.c \ ../vendor/apriltag/tagCircle49h12.c \ ../vendor/apriltag/tagCustom48h12.c \ ../vendor/apriltag/tagStandard41h12.c \ ../vendor/apriltag/tagStandard52h13.c \ ../vendor/apriltag/common/g2d.c \ ../vendor/apriltag/common/getopt.c \ ../vendor/apriltag/common/homography.c \ ../vendor/apriltag/common/image_u8.c \ ../vendor/apriltag/common/image_u8x3.c \ ../vendor/apriltag/common/matd.c \ ../vendor/apriltag/common/pam.c \ ../vendor/apriltag/common/pjpeg.c \ ../vendor/apriltag/common/pjpeg-idct.c \ ../vendor/apriltag/common/pnm.c \ ../vendor/apriltag/common/string_util.c \ ../vendor/apriltag/common/svd22.c \ ../vendor/apriltag/common/time_util.c \ ../vendor/apriltag/common/unionfind.c \ ../vendor/apriltag/common/workerpool.c \ ../vendor/apriltag/common/zarray.c \ ../vendor/apriltag/common/zhash.c \ ../vendor/apriltag/common/zmaxheap.c \ ../vendor/apriltag/common/pthreads_cross.c \ -I../vendor/apriltag \ -s WASM=1 \ -s ALLOW_MEMORY_GROWTH=1 \ -s INITIAL_MEMORY=335544320 \ -s MAXIMUM_MEMORY=536870912 \ -s MODULARIZE=1 \ -s EXPORT_NAME="AprilTagWasm" \ -s EXPORTED_FUNCTIONS="['_malloc','_free','_initialize_detector','_set_detector_parameters','_detect_tags','_cleanup','_add_family']" \ -s EXPORTED_RUNTIME_METHODS="['cwrap','setValue','getValue','UTF8ToString','HEAPU8']" \ -s ASSERTIONS=1 \ -s ENVIRONMENT='web,worker' \ -o ../js/apriltag_wasm.jsThis is the exact command
compile.batruns, translated to a POSIX shell — there is no separatecompile.shin this repo. An earlier version of that script existed upstream but only compiled 2 of the 8 tag families and omitted_add_familyfrom the exported functions, so it could not actually produce a working build; rather than ship something known to be broken, the command lives here in the README where both platforms can use the one verified-correct version.Two flags are load-bearing and easy to get wrong if you're adapting this command:
ENVIRONMENTmust includeworker, not justweb— the module is loaded from inside a dedicated Worker (js/detector-worker.js), and aweb-only build aborts at runtime with an environment-mismatch assertion the moment it's loaded there.EXPORTED_RUNTIME_METHODSmust includeHEAPU8—js/apriltag-wasm-wrapper.jswrites image bytes directly into wasm memory viamodule.HEAPU8, which newer Emscripten versions no longer expose on the module object unless explicitly requested. -
This overwrites
js/apriltag_wasm.jsandjs/apriltag_wasm.wasm. Reload the page to pick up the new build.
Browser support
Requires WebAssembly, Web Workers, and OffscreenCanvas-free 2D canvas —
which is to say, any browser from the last several years. Camera features
need getUserMedia in a secure context (see Quick start
above); image upload works everywhere the page loads.
License
BSD-2-Clause, matching the upstream AprilTag license exactly — see LICENSE. The bundled WebAssembly binary is a compiled derivative of AprilTag's C source and carries the same terms; see NOTICE for full attribution.