hexcase-5ivq-04, 960×720, read here in 5.9ms at stride 4 on 6 workers,
median of 5 after a warm-up:
6 of 7 barcodes decoded.
Solid rings carry an id, dashed ones located but did not decode. The whole bank, and what the
settings above cost it, is §3.
⠿➕edit
⠿➕edit
About
Part V of a super long [younger me, 2012] and infrequently worked on project for fast optical localization. In this installment I added
Detect multiple circular barcodes in the scene
Decode them so each has an unambiguous code for matching
Compile into WASM via AssemblyScript for fast performance
Use Web Workers for parallelism
Fuse multiple barcodes to get a pose
Less than 2ms per frame on MacBook, 16ms on phone 🤙 which is complete overkill for the browser as both max out at 30 f.p.s. for webcams, however, the long term aim is for hardware.
There are lots of optical tracking systems, including circular barcodes. The unique thing about this one is the design around single scan lines, so barcodes can be recognized with appropriate hardware as pixels leave the camera's MIPI. This is potentially hundreds of times faster than the frames per second rating of a camera.
The geometry, in a three.js camera simulator. A circular barcode reads the same along any chord through its centre, so a 2D pose search becomes a 1D pattern match on one row of pixels.
Centre and tilt of one barcode from one scan row of a simulated frame, by recovering the Möbius transform — the exact 1D projective map — from an anchor search, a DP alignment and a least-squares refit.
Several barcodes at independent poses, on live frames. Strided rows so each barcode is near-centred on one of them, and a cross-ratio test in place of the anchor SVD.
V
2026-08
This notebook
Printed barcodes that carry their own position, decoded from a live camera, fused into a plane pose.
⠿➕edit
The Scanner
⠿➕edit
This lets you run the barcode matcher a few ways
wave your mobile displaying the pattern §2 in front of your webcam on a laptop running this notebook
print out the pattern §2 on paper and track with this notebook on your mobile
connect Claude Code to the notebook live on your laptop and let it drive and take measurements (also /remote-control from Claude Code from mobile)
download the notebook as a single file and let Claude Code choose how to edit and run it
⠿➕edit
⠿➕edit
1440x1080 4ms frame (cap 1.0 gray 0.0 scan 2.9 fit 0.0 draw 0.0) 6w luma NV12 no plane — 0 read, need 4 target marks cases 0/12
No cases yet. Aim the camera at the printed sheet with the rig running:
frames that read 5+ barcodes and still fail on at least one are collected automatically.
8 mid edges — one per cell, always
3 cell boundaries
11 edges to position against on this scan line
⠿➕edit
A circular barcode is a set of concentric rings. With Manchester Encoding of 6 bits, every bit has at least one intensity change (an edge). These edges can help position the barcode on a scan line and also double as a digital label.
If the layout of the barcodes is known, their combination gives a homography estimate. There is no matching ambiguity because the barcodes label themselves.
7 barcodes, ids 56, 11, 22, 29, 37, 46, 51 — the seven non-zero codewords of a [6,3] code,
so no single misread cell can turn one member into another.
48mm barcodes, 69.6mm pitch, cluster rotated 30°,
sheet 187.2 × 168.55mm on A4. Tightest clearance between barcodes that can share a
scan row: 21.6mm (2.14× the dark disc it has to beat).
The rotation is not decoration — it is worth more than the pitch is.
Over three poses each, rotated reads 7/7/7 barcodes against 3/3/3 upright at this pitch.
Why is not established: the obvious explanations (collinear triples, along-row clearance) are both
contradicted by the same table, so this is an empirical setting, not a derived one.
Working distance: a barcode needs roughly 4 pixels per tooth, so keep the
whole cluster in frame with each barcode above ~60px. On a 1280-wide camera that is
roughly 0.4–1.2 m from the sheet.
Print at 100%, not "fit to page" — the millimetre geometry is the
calibration. Measure the 100mm bar to check.
Each banked frame is labelled with the correct barcode centres, and §3.3 scores the fast detector against them. The correct centres were determined with a much more expensive algorithm, concentric ring matching under perspective, and eyeballed for correctness of fit, see §4.7.
⠿➕edit
§3.2 Near misses
⠿➕edit
The banked frame was captured by the rig's auto capture, looking for hard cases where some of the barcodes were correct but not all. Images were taken in a few different cluttered environments, some including barcode-like features such as venetian blinds and railings.
90 of 112 marks read now, 86 when captured, no off-target detections.
solid = read · dashed = located, payload did not decode ·
dotted = missing · ✕ = fitted an involution somewhere the plane does not go
46: predicted 1px from its label, but a cluster of 10 rows sits 20px from it
hexcase-5ivq-06 · 960×720 · 26ms
4/7 read
56112229374651
22: predicted 1px from its label, but a cluster of 8 rows sits 30px from it · 46: predicted 1px from its label, but a cluster of 4 rows sits 17px from it
56: predicted 1px from its label, and nothing is at the label either · 46: predicted 1px from its label, and nothing is at the label either · 51: predicted 1px from its label, and nothing is at the label either
56: predicted 6px from its label, and nothing is at the label either · 11: predicted 5px from its label, and nothing is at the label either · 51: predicted 6px from its label, and nothing is at the label either
The barcode is designed so it is easy to recognize and localize from a single scan line of pixels. The encoding gives it a label and edges for sharp positioning.
d is the offset — centre
of the barcode to the scan row, measured on the barcode, in the same units as the tooth radii.
It is the one pose quantity the row gives back on its own, as
√(-B/A).
A tooth only shows an edge where
the two cells either side of it carry the same bit; the rest are drawn
faint and left out of the fit. Each solid marker is
half green / half orange —
the ring's two crossings, at opposite ends of the row, landing on one u.
Move the sliders: the row changes completely, the line does not.
⠿➕edit
A scan row goes through each ring twice. Once on the way into the centre, and once out. The order is symmetric. So there is a match for each edge crossing across a mirror down the middle: an involution (a function that is its own inverse).
Once you adjust for the perspective (Möbius transform, Part III), those pairs are on a straight line defining the tilt angle of the scanline relative to the camera.
hexcase-5ivq-04 · 960×720 · row y = 554 ·
97 of 120 scanned rows lock at least one barcode,
36 of them decode an id ·
this row locks 3, 2 of them with an id.
Click or drag on the frame to move the scan row.
Green carries an id, amber is located but undecoded — for the circles and for the
strip alike. Magenta dashed rims are the frame's recorded labels, drawn as the ellipse the
sheet's own homography puts them on: ground truth,
not something the detector produced.
⠿➕edit
89 edges out of 960 pixels.
Blue rises, amber falls — the sign is
not decoration, it is what carries the bit at a mid tooth. Sub-pixel positions are worth
the trouble: integer ones cost about 0.03 of cross ratio at small barcode scales, which is past
what the gate downstream tolerates.
⠿➕edit
After edge detection, adjacent edges are grouped. Barcode geometry bounds the problem space.
⠿➕edit
On the row above: 89 edges →
7 candidate groups →
3 locked.
segmentation gate
value
groups if relaxed
why the geometry says so
minEdges
6
+1
an involution needs three pairs, so five edges cannot lock whatever they are
minSpan
14px
+1
a barcode narrower than this has its rings inside one pixel of each other
gapFrac
0.2
-2
the widest gap INSIDE one barcode is the dark disc, 2·6 of a 2·28.5 span — at most 0.21 of it, so a wider gap separates barcodes rather than rings
maxEdges
36
no change
one barcode presents at most 2·(nT+1) = 30 edges; more than that plus slack is more than one barcode, so split
groupCap
33
+4
the same bound as a refusal to OFFER: an over-cap run cannot be one barcode, and fitting it is the expensive case
per-group gate
killed here
reported as
opposite signs, P inside both pairs, Q outside the span ±2%, tolPx 1.1, ≥6 inliers
1
no involution fits
tooth index within [0, 14], positive slope, ≥3 lattice inliers and within 2 of the pairs offered
—
no lattice assignment
lattice support ≥ 5
3
too little support
one lock per foot: |Δfoot| < 0.6 · max(wHalf)
—
duplicate or rival
Relaxing a gate can ADD groups it was refusing or REMOVE ones it was forcing a
split into, which is why the column is signed. Values are recomputed from the
layout, not copied. The stages themselves are drawn in §4.2.
⠿➕edit
7 candidate groups, 3 of them lock.
The split rule is the widest gap: inside one barcode the widest gap is the dark disc, at most
0.21 of the barcode's own span, so anything wider separates barcodes rather than rings.
The threshold sits below that bound on purpose, which sometimes cuts a real barcode in
half — so the unsplit run is offered as well, and brackets overlap.
A wrong split loses a barcode; a wrong merge just fails to lock and is discarded.
⠿➕edit
Surviving groups have a clear involution around a reflection point P, a very distinctive pattern stemming from the properties of an orientated set of concentric rings, that is preserved even if the scanline is off-centre to the barcode.
⠿➕edit
no id emitted ·
support 6 · d = 19.55 · 2 of 6 bits read directly,
0 violations, 3 checks ·
A = 9.293e-6, B = -3.551e-3
id 29011101 ·
support 10 · d = 2.27 · 6 of 6 bits read directly,
0 violations, 7 checks ·
A = 2.267e-6, B = -1.165e-5
id 37100101 ·
support 10 · d = 4.94 · 6 of 6 bits read directly,
0 violations, 7 checks ·
A = 4.869e-6, B = -1.187e-4
⠿➕edit
Once P is cracked the slope is a fit and the barcode can be fully read if all its edges are observable. It is probably true that some of the bits of the barcode can be decoded even if the scan is off-centre, but not tried here.
⠿➕edit
edges1Dsub = ƒ(…)
⠿➕edit
findInvolution = ƒ(…)
⠿➕edit
solveMan = ƒ(…)
⠿➕edit
manRowGroups = ƒ(…)
⠿➕edit
detectRowMan = ƒ(…)
⠿➕edit
detectFrameMan = ƒ(…)
⠿➕edit
§4.3 Combine scanlines
⠿➕edit
Adjacent parallel scanlines can be joined if their P are close
A lock joins the nearest open cluster whose last row is within 4 · stride above it and whose last P is within max(10px, 0.35 · wHalf) — the window drawn on the figure below — and otherwise opens its own. The match is on P and never on the decoded id, because rows that locate but fail to decode are exactly what the ellipse fit is made of.
A cluster becomes a barcode on three independent things: at least 3 rows, an id with at least 2 votes that beats the runner-up 2:1, and a pose fitManPose calls plausible. Clutter reliably produces one of the three and rarely two. If two clusters claim the same id the better-evidenced one keeps it, on rows, then vote margin, then coverage, so one id stays one piece of paper.
⠿➕edit
Stage 1 returned 55 locks over 90 rows at
stride 6. Stage 2 joined 52 of them into
3 barcodes and
1 that
located but did not decode; 3 never reached three
rows and are dropped. The polyline is not part of the algorithm — it just shows which locks
ended up in the same group. Right: the largest barcode, one blue line per scanned row, its P as
a dot, and the join window ± 16px drawn on the last
row to join. The ellipse is what the group becomes — fitted from the rows' own chord offsets,
axis-aligned because a row scan recovers two half-axes and not a rotation.
⠿➕edit
manScanRows = ƒ(…)
⠿➕edit
scanRowsMan = ƒ(…)
⠿➕edit
clusterManRows = ƒ(…)
⠿➕edit
analyzeFrameMan = ƒ(…)
⠿➕edit
fitManPose = ƒ(…)
⠿➕edit
manSceneTest = `stride 6: 3/4 correct, 0 spurious, 1 located-unread, 11ms
#45 W55 yaw0 -> tilt 9 b 54/55 cov 1.10 asp 1.01 err 0.4px
#9 W40 yaw30 -> tilt 51 b 54/40 cov 1.11 asp 0.63 err 2.1px
#62 W70 yaw50 -> tilt 50 b 70/70 cov 1.29 asp 0.64 err 1.2px
#21 W33 yaw15 -> MISSED
stride 4: 4/4 correct, 0 spurious, 0 located-unread, 21ms
#45 W55 yaw0 -> tilt 4 b 55/55 cov 1.39 asp 1.00 err 1.1px
#9 W40 yaw30 -> tilt 22 b 38/40 cov 1.58 asp 0.93 err 1.3px
#62 W70 yaw50 -> tilt 50 b 70/70 cov 1.26 asp 0.65 err 0.7px
#21 W33 yaw15 -> tilt 57 b 56/33 cov 0.86 asp 0.54 err 2.1px
stride 4 bothAxes: 4/4 correct, 0 spurious, 0 located-unread, 37ms
#45 W55 yaw0 -> tilt 4 b 55/55 cov 1.39 asp 1.00 err 0.8px
#9 W40 yaw30 -> tilt 22 b 41/40 cov 1.58 asp 0.93 err 1.0px
#62 W70 yaw50 -> tilt 50 b 70/70 cov 1.26 asp 0.65 err 0.7px
#21 W33 yaw15 -> tilt 57 b 34/33 cov 0.86 asp 0.54 err 1.0px`
A row scan measures a barcode's x and extrapolates its y, and a column scan of the same frame inverts that. Both cost two scans, both run on the worker pool, and they often disagree on the centre. They have to decode the same id to be paired at all, so all the merge has to do is choose: it takes the column pass's y unless the row pass decoded with a strictly larger vote margin.
Summed absolute y error over the 37 barcodes the bank fuses from both passes, against an oracle that reads the frozen label and always picks the closer pass:
oracle
14.2px
by vote margin
18.3px
by scanline count
25.5px
by coverage
34.6px
always the column pass
45.1px
always the row pass
49.9px
Blue reads the answer key, so it is a floor and not a rule. Green is what ships, and it closes 87% of the distance to that floor from the better fixed pass. The two greys below it are rules that were tried.
⠿➕edit
rotateFrame = ƒ(frame, turns)
⠿➕edit
unrotatePoint = ƒ(x, y, turns, w, h)
⠿➕edit
mergeManAxes = ƒ(…)
⠿➕edit
manAxesTest = `rows only (default) 4/4 barcodes x 0.66 mean / 0.87 worst y 1.00 mean / 2.00 worst 21ms
columns only 4/4 barcodes x 1.25 mean / 1.92 worst y 0.54 mean / 0.69 worst
bothAxes: true 4/4 barcodes x 0.66 mean / 0.87 worst y 0.43 mean / 0.69 worst 37ms — it is two passes, and it costs two
4/4 detections seen by both passes; 0 id conflict(s); worst cross-pass disagreement 2.81px
(that disagreement is the same size as the row scan's actual worst y error, 2.00px — so it is a usable error bar and not just a number)
ok row scan is better in x than in y
ok column scan measures y better than the row scan does
ok both-axes y beats row-only y
ok both-axes x is no worse than row-only x
ok both-axes finds no fewer barcodes
ok no id conflicts on a clean scene
ok no duplicate ids
ok default call is single-pass
PASS`
⠿➕edit
⠿➕edit
§4.4 From barcodes to a pose
⠿➕edit
As the barcodes have labels the hexagonal grid of 7 barcodes can be trivially fitted allowing more spatial fusion for pose estimation in 6 DOF space.
As each barcode fit also carries the local scale a and b, only two barcodes are needed to determine a plane, and the live pose fits with them.
⠿➕edit
fitHomography = ƒ(pairs)
⠿➕edit
fitHomographyScaled = ƒ(…)
⠿➕edit
fitHexPose = ƒ(…)
⠿➕edit
§4.5 Making it fast
⠿➕edit
Multiple scan lines can be processed in parallel. In this notebook we farm out the work to web workers to make use of multi-core computation available on laptops and mobiles.
⠿➕edit
⠿➕edit
detectKernelSource = `var window = self;
const manLayout = ({"nBits":6,"half":1.6071428571428572,"nT":14,"R":28.5,"teeth":[6,7.607142857142858,9.214285714285715,10.821428571428571,12.428571428571429,14.035714285714286,15.642857142857142,17.25,18.857142857142858,20.464285714285715,22.071428571428573,23.67857142857143,25.285714285714285,26.892857142857142,28.5],"teeth2":[36,57.8686224489796,84.90306122448982,117.1033163265306,154.46938775510205,197.0012755102041,244.69897959183672,297.5625,355.5918367346939,418.78698979591843,487.14795918367355,560.6747448979593,639.3673469387754,723.2257653061224,812.25],"guaranteed":[0,14,2,4,6,8,10,12]});
const edges1Dsub = function edges1Dsub(sig, thr = 6) {
// Part II's edges1D with parabolic sub-pixel refinement of each gradient peak.
// Integer edge positions cost ~0.03 of cross ratio at 2px-per-template-unit
// mark scales — past the CR gate's tolerance — so the quarter-pixel accuracy
// here is what lets small on-screen marks through detection at all.
//
// d is a BACKWARD difference, so d[i] is the gradient at i - 0.5 and the
// parabola through |d[i-1..i+1]| has its vertex there too. Reporting the peak
// at i put every edge half a pixel late along the scan. Because the 12 scan
// directions of §4.7 span 180° and not 360°, that bias does not cancel: it
// lands as (0, 4b/π) ≈ 0.64px on an intersected centre. The correction is a
// translation of a whole row, so involutions, cross ratios and decoded bits
// are untouched — the bank reads 85/112 either way — only positions move.
const n = sig.length;
const d = new Float32Array(n);
for (let i = 1; i < n; i++) d[i] = sig[i] - sig[i - 1];
const idx = [];
for (let i = 2; i < n - 2; i++) {Show 529 truncated lines
AI rewrote the web worker algorithms in AssemblyScript and shipped an in-browser AssemblyScript Compiler. This makes it faster, and avoids the initial slow down you get with unoptimized Javascript when first run on a page.
The AssemblyScript compiler is included in this notebook as @tomlarkworthy/assembly-script, which owns asc, assemblyscript, long and binaryen.
Warm-up, pooled, two independent runs of each implementation.
One pass is the 16-frame bank; a fresh pool is built at frame 0 and then
left alone. JavaScript starts around 13 ms/frame and descends a
staircase — the plateaus are V8 promoting the kernel through its
compiler tiers — taking several hundred frames to approach its floor, and
the two runs disagree about where that floor is (2.9 vs 3.7 ms). At 30fps
the flat part of the red curve is ten to twenty seconds away.
WebAssembly is at its floor by the third pass in both runs and stays there.
2026-08-04 · headless Chromium 136 · 14 cores, 6 workers · stride 4 · 16 bank frames.
wasmSourceText = `// An AssemblyScript port of @tomlarkworthy/coded-landmark-tracking's
// detectRowMan and everything it calls: manRowGroups -> findInvolution ->
// solveMan, over the manLayout constants.
//
// This is 83% of the row scan (solveMan 46%, findInvolution 37%) and the
// boundary is one call per row, so the JS<->WASM crossing is paid ~120 times a
// frame rather than once per candidate group.
//
// Written to be BIT-IDENTICAL, not idiomatic. The notebook holds its worker
// pool to "identical to 4dp on every bank frame" and a WASM arm has to clear
// the same bar, so every guard, every strict-vs-non-strict comparison and
// every tie-break follows the JS exactly. Where the JS leans on IEEE behaviour
// that reads like a bug (a division by zero yielding an infinity that a later
// isFinite catches) this leans on it too.
//
// The data structures are the one place it departs, because AssemblyScript has
// no cheap Map/Set and this must not allocate per row:
// JS \`claimed\` Set over tooth index -> claimed[] bool, nT+1 entries
// JS \`byTooth\` Map<t, {...}> -> btHas[]/btU[]/btSR[]/btErr[], nT+1
// JS \`bits\` Array with nulls -> bits[] i32 with -1 for nullShow 513 truncated lines
Byte for byte identical to the binary saved in this file — 6883 bytes.
⠿➕edit
§4.7 Relabelling
⠿➕edit
The bank's reference labels (§3.1) are computed from a slower, more accurate offline procedure: row stride 1 instead of 4, a sweep over the edge threshold, and a sub-pixel refinement stage.
The edge threshold is chosen per frame.
Every centre is refined on its own ring lattice.
The plane is fitted by exhaustive RANSAC over the 4-subsets.
The plane may flag a measurement, but not replace one.
⠿➕edit
resampleAlong = ƒ(frame, deg)
⠿➕edit
denseRotations = ƒ(…)
⠿➕edit
intersectLines = ƒ(lines)
⠿➕edit
denseLabel = ƒ(…)
⠿➕edit
§4.7.1 The ring lattice refinement
⠿➕edit
For lattice refinement, all edges of the barcode, through every scan line, in 12 directions are used to fit a perspective distorted concentric ring model, with an ink bleed parameter.
Fit the whole set at once:
|A (p − c)| = teeth[t] + δ · polarity
c is the centre in the image, A the inverse of the local plane-to-image map, and δ a single ink-bleed offset. Seven parameters against thousands of observations.
δ is identifiable. Thickening the ink moves a dark→light boundary outward and a light→dark boundary inward. The involution pairs require ss[f] === -ss[e], so both edges of a pair take the same signed shift, and consecutive teeth alternate polarity. Across the bank, ink bleed fits 0.06mm.
An affine A is not enough. Under a homography concentric circles map to ellipses that are not concentric: the imaged ellipse of a ring drifts toward the vanishing point as the ring grows. An affine fit has no term for that, so on a tilted sheet it carries a bias. Two additional perspective parameters remove the bias, solved as a second stage after the converged affine fit.
Measured against a synthetic image, adding perspective correction takes the relabelling from a median error of 0.118px to 0.010px, and its worst case from 0.721px to 0.027px.
There is more we can do to improve accuracy in realtime applications e.g. adaptive tuning of the edge detector parameters, or adjustment of scanlines focus across frames. We do not use frame similarity information at all!
My primary next goal however is running faster than 30 frames per second. This does not appear to be easily achieved with browser, so next work will attempt to apply this algorithm to real hardware like the Arducam 100fps Mono Global Shutter USB Camera, 720P OV9281 UVC Webcam Module which has hardware support for changing scan line settings and capture rate.
ok every section has a heading cell
ok headings appear in declared order
ok no section has two heading cells
ok every sec() key is declared
ok every ref() key is declared
ok no hand-written section number in prose
19 sections
PASS
cc_find_module resolves a module name (e.g. "@tomlarkworthy/exporter-3") to a runtime Module object by polling viewof currentModules.value at call time (not reactively). cc_serialize_value safely converts any runtime value to a truncated string for transport.
⠿➕edit
cc_find_module = ƒ(runtime, moduleName)
⠿➕edit
cc_serialize_value = ƒ(value, maxLen)
⠿➕edit
Variable watcher
Subscribes to Observable variables via observe(), debounces updates (1s), and forwards changes to Claude Code over the WebSocket. Also maintains the cc_watches table UI.
One handler per MCP tool. Each receives a cmd object and the Observable runtime, returns {ok, result} or {ok:false, error}. Handlers: get-variable, define-variable, define-cell, delete-variable, list-variables, list-cells, run-tests, create-module, delete-module, eval, fork, watch, unwatch.
⠿➕edit
cc_handle_get_variable = ƒ(cmd, runtime)
⠿➕edit
cc_handle_define_variable = ƒ(cmd, runtime)
⠿➕edit
cc_handle_define_cell = ƒ(cmd, runtime)
⠿➕edit
cc_handle_delete_variable = ƒ(cmd, runtime)
⠿➕edit
cc_handle_list_variables = ƒ(cmd, runtime)
⠿➕edit
cc_handle_list_cells = ƒ(cmd, runtime)
⠿➕edit
cc_handle_run_tests = ƒ(cmd, runtime)
⠿➕edit
cc_handle_create_module = ƒ(cmd, runtime)
⠿➕edit
cc_handle_delete_module = ƒ(cmd, runtime)
⠿➕edit
cc_handle_eval = ƒ(cmd, runtime)
⠿➕edit
cc_handle_fork = ƒ(cmd, runtimeArg)
⠿➕edit
cc_handle_watch = ƒ(cmd, runtime)
⠿➕edit
cc_handle_unwatch = ƒ(cmd, runtime)
⠿➕edit
Command dispatch & WebSocket
cc_command_handlers maps action strings to handler functions. cc_ws manages the WebSocket lifecycle: connect, pair with token, dispatch incoming messages (commands, replies, tool-activity), and auto-reconnect from the URL hash cc= param or sessionStorage.
cc_change_forwarder polls the local-change-history and streams new cell edits to Claude Code. cc_voice provides optional speech recognition input and TTS output for hands-free pairing.