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/

status license listed upstream

Demo: live camera detection of multiple AprilTag families with bounding boxes and labels

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. localhost over 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 touch getUserMedia.

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, and decode_sharpening, the same parameters the underlying C apriltag_detector_t exposes.
  • 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

FamilyNotes
tag36h11Default. Most widely used family; good balance of size and robustness.
tag25h9
tag16h5Smallest code space — fastest to decode, most prone to false positives at long range.
tagCircle21h7Hamming is fixed at 0 — see Known limitations for why.
tagCircle49h12Hamming is fixed at 1 — see Known limitations for why.
tagCustom48h12Hamming distance is forced to at least 1 — see below.
tagStandard41h12
tagStandard52h13Hamming 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:

  1. 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; for tagCircle49h12 specifically, 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 + tagCircle49h12 together 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.
  2. 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.

  1. Clone with submodules (git submodule update --init if you already cloned without --recursive) to get vendor/apriltag at the pinned AprilTag v3.4.3 commit the shipped binary was built from.

  2. Install Emscripten and activate it (emsdk activate latest, then source/run its env script).

  3. From wasm-build/, the build is a single emcc invocation. On Windows, run compile.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.js
    

    This is the exact command compile.bat runs, translated to a POSIX shell — there is no separate compile.sh in this repo. An earlier version of that script existed upstream but only compiled 2 of the 8 tag families and omitted _add_family from 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: ENVIRONMENT must include worker, not just web — the module is loaded from inside a dedicated Worker (js/detector-worker.js), and a web-only build aborts at runtime with an environment-mismatch assertion the moment it's loaded there. EXPORTED_RUNTIME_METHODS must include HEAPU8js/apriltag-wasm-wrapper.js writes image bytes directly into wasm memory via module.HEAPU8, which newer Emscripten versions no longer expose on the module object unless explicitly requested.

  4. This overwrites js/apriltag_wasm.js and js/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.