Fast Multi-Target Circular Barcode Pose Tracking

edit
56112229374651
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.

edit
Earlier installments
I 2012-05 Optical localization to 0.1mm, no problemo Where it started. Cheap high-speed optical positioning for a robot's end effector, on a webcam.
II 2023-06 Simplifying Pose Estimation with Circular Barcodes 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.
III 2025-08 Fast 1D Circular Barcode Matching 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.
IV 2026-08 Realtime Multi-Barcode Tracking 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
not labellable: no plane
edit
edit
edit
liveStream = null
edit
edit
edit
hexRigOpts = Object {stride: 4, edgeThreshold: 12, minRows: 3, minVotes: 2, voteRatio: 2, gapFrac: 0.3, bothAxes: true, layout: Object}
edit
hexRigLoo = ƒ(res, target)
edit
hexRigScore = ƒ(…)
edit
viewof hexRigCases = EventTarget {value: Array(0)}
edit
lumaCapture = ƒ()
edit
hexRig = null
edit
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.
edit

§1 The barcode

edit
edit
radius →  centre to rim
id 45  =  bits 101101
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.

edit

§2 Multiple barcodes

close
inputs: sec
sec("multi")

If the layout of the barcodes is known, their combination gives a homography estimate. There is no matching ambiguity because the barcodes label themselves.

edit
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.
edit
hexTargetSvg = ƒ(…)
edit
makeHexTarget = ƒ(…)
edit
hexTarget = Object {ids: Array(7), marks: Array(7), diameterMm: 48, radiusMm: 24, pitchMm: 69.6, pitchFactor: 1.45, rollDeg: 30, mmPerUnit: 0.8421052631578947, layout: Object, byId: Map(7), minRowGapMm: 21.6, rowGapInDiscs: 2.14, widthMm: 187.2, heightMm: 168.55}
edit
hexPrintCheck = Array(3) [Object, Object, Object]
edit

§3 Evaluation

close
inputs: sec
sec("eval")

§3.1 The label set

close
inputs: sec
sec("labels")

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.

edit
hexOverlay = Object {COL: Object, parts: ƒ(pose, res)}
edit
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
56112229374651
hexcase-159 · 960×720 · 24ms
7/7 read
56112229374651
56112229374651
hexcase-5ivq-04 · 960×720 · 32ms
345mm · 66.1° · 6/7 read
56112229374651
56112229374651
hexcase-02 · 960×720 · 16ms
279mm · 15.4° · 6/7 read
56112229374651
56112229374651
hexcase-5ivq-08 · 960×720 · 22ms
161mm · 44.2° · 5/7 read
56112229374651
56: predicted 5px from its label, but a cluster of 19 rows sits 40px from it
56112229374651
hexcase-5ivq-03 · 960×720 · 31ms
979mm · 16.3° · 5/7 read
56112229374651
46: predicted 1px from its label, but a cluster of 10 rows sits 20px from it
56112229374651
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
56112229374651
hexcase-5iap-12 · 960×720 · 14ms
501mm · 13.5° · 6/7 read
56112229374651
56112229374651
hexcase-5iap-04 · 960×720 · 19ms
272mm · 27° · 4/7 read
56112229374651
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
56112229374651
hexcase-04-pre · 960×720 · 15ms
417mm · 18.1° · 5/7 read
56112229374651
56112229374651
hexcase-5ivq-07 · 960×720 · 18ms
536mm · 31.4° · 7/7 read
56112229374651
56112229374651
phone-hexcase-08 · 960×1280 · 27ms
219mm · 58.7° · 4/7 read
56112229374651
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
56112229374651
phone-hexcase-01 · 960×1280 · 23ms
7/7 read
56112229374651
56112229374651
phone-hexcase-07 · 960×1280 · 21ms
205mm · 58.7° · 5/7 read
56112229374651
51: predicted 4px from its label, and nothing is at the label either
56112229374651
phone-hexcase-09 · 960×1280 · 28ms
387mm · 56.3° · 7/7 read
56112229374651
56112229374651
phone-hexcase-04 · 960×1280 · 23ms
318mm · 42.9° · 6/7 read
56112229374651
46: predicted 5px from its label, but a cluster of 8 rows sits 11px from it
56112229374651
phone-hexcase-06 · 960×1280 · 32ms
354mm · 73.9° · 6/7 read
56112229374651
edit

§3.3 Score at the current settings

edit

Every frame above, replayed at the rig's current controls, scored against §3.1's labels.

edit
frame size read loc miss wrong off p50 px worst px ms score
hexcase-159 960×720 5/720 00 0.81.9295
phone-hexcase-08 960×1280 5/711 00 1.710.8325
hexcase-5ivq-04 960×720 6/710 00 1.67.4396
phone-hexcase-01 960×1280 7/700 00 0.810.6357
hexcase-02 960×720 6/710 00 0.51236
hexcase-5ivq-08 960×720 4/712 00 3.43.8234
phone-hexcase-07 960×1280 5/711 00 3.511.7345
hexcase-5ivq-03 960×720 5/711 00 0.52.2465
phone-hexcase-09 960×1280 5/710 10 2.345.5432
hexcase-5ivq-06 960×720 4/712 00 1.27.6304
phone-hexcase-04 960×1280 6/710 00 0.72.8356
hexcase-5iap-12 960×720 6/710 00 0.40.5196
hexcase-5iap-04 960×720 5/711 00 0.40.6215
phone-hexcase-06 960×1280 6/710 00 1.430.9376
hexcase-04-pre 960×720 5/720 00 0.735.9215
hexcase-5ivq-07 960×720 7/700 00 0.61227
16 frames 87/112 1681048984
stride 4 · edge threshold 12 · min rows 3 · min votes 2 · both axes
edit

§4 Detection

edit
§4.1 The circular encoding
§4.2 One scanline
§4.3 Frame fusion
§4.4 The plane

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.

edit

§4.1 The circular barcode pattern

close
inputs: sec
sec("pattern")
edit
dPyaw 38° · tilt 24° · d = 11.80
Peach arc joins one ring's two crossingsQ at x = -687one image row — the lattice is gone
d² = -B/Ar² — the tooth lattice, known in advanceu = t² measured
rings crossed 11 of 15
of those, edges 8
A 7.3414e-6
B -1.0223e-3
d recovered 11.800669 (true 11.800669)
max residual / span 2.7e-16
max |u_left - u_right| 2.2e-18
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.

edit
manLayout = Object {nBits: 6, half: 1.6071428571428572, nT: 14, R: 28.5, teeth: Array(15), teeth2: Array(15), guaranteed: Array(8)}
edit
manPageLevel = 255
edit
manColor = ƒ(…)
edit
manMarkSvgSource = ƒ(…)
edit
renderManFrame = ƒ(…)
edit
manScene = Object {gray: Uint8Array(518400), w: 960, h: 540, truth: Array(4)}
edit

§4.2 One scanline, 3 degrees-of-freedom

edit

One scanline is enough to determine 3 of the 6 position/orientation degrees of freedom for the barcode in 3D space.

barcode on line geometric
centre position along line x offset
width on line z offset
tilt rotation around y

The first step towards pose retrieval is edge detection

edit
edit
rowWalkScan = Object {rows: Array(120), locked: 97, total: 120}
edit
rowWalkRow = Object {y: 554, w: 960, h: 720, edges: Array(89), groups: Array(7), kept: Array(3), thr: 12}
edit
rowWalkBox = Object {STRIP: 30, w: 960, boxW: 990, style: "margin:12px 0;max-width:990px"}
edit
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
intensity along the row (0-255)first difference · dashed = ±12 threshold
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 gatevaluegroups if relaxedwhy the geometry says so
minEdges6+1an involution needs three pairs, so five edges cannot lock whatever they are
minSpan14px+1a barcode narrower than this has its rings inside one pixel of each other
gapFrac0.2-2the 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
maxEdges36no changeone barcode presents at most 2·(nT+1) = 30 edges; more than that plus slack is more than one barcode, so split
groupCap33+4the same bound as a refusal to OFFER: an over-cap run cannot be one barcode, and fitting it is the expensive case
per-group gatekilled herereported as
opposite signs, P inside both pairs, Q outside the span ±2%, tolPx 1.1, ≥6 inliers1no involution fits
tooth index within [0, 14], positive slope, ≥3 lattice inliers and within 2 of the pairs offeredno lattice assignment
lattice support ≥ 53too 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
6 edges · dropped: no involution fits7 edges · dropped: lattice support 3 < 528 edges · located, no id · support 6 · d = 19.510 edges · dropped: lattice support 4 < 512 edges · dropped: lattice support 4 < 522 edges · id 29 · support 10 · d = 2.320 edges · id 37 · support 10 · d = 4.9
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
P 6 mirror pairs · 28 edges · Q at -2214 u against the tooth lattice · ticks are the 15 teeth · worst miss 0.23 of a half-cell
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
P 10 mirror pairs · 22 edges · Q at -187 u against the tooth lattice · ticks are the 15 teeth · worst miss 0.19 of a half-cell
id 29 011101 · support 10 · d = 2.27 · 6 of 6 bits read directly, 0 violations, 7 checks · A = 2.267e-6, B = -1.165e-5
P 10 mirror pairs · 20 edges · Q at -31 u against the tooth lattice · ticks are the 15 teeth · worst miss 0.17 of a half-cell
id 37 100101 · 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
id 9id 45id 62no id
± 16px
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`
edit

§4.3.1 Scanning orthogonally

close
inputs: sec
sec("ortho")

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
edit
detectPool = Object {size: 6, lastWorkerMs: Array(0), lastWorkerChunks: Array(0), runRows: async ƒ(frame, ys, opts)}
edit
analyzeFrameManAsync = async ƒ(…)
edit
edit
edit
poolBenchmark = null
edit
edit

§4.6 Making it even faster, and fast to start

edit

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.

edit
warmupCurve = Object {measured: "2026-08-04 · headless Chromium 136 · 14 cores, 6 workers · stride 4 · 16 bank frames", script: "scratch/rmbt/js-pool-curve.ts", runs: Array(4)}
edit
JavaScriptWebAssembly
02468101214↑ ms per frame100200300400500600frames scanned since the pool was built →3.0 ms1.6 ms3.7 ms1.6 mscold
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.
edit
wasmShippedBytes = Uint8Array(6883) [0, 97, 115, 109, 1, 0, 0, 0, 1, 68, 10, 96, 0, 1, 127, 96, 1, 127, 1, 127, …]
edit
wasmKernelBytes = Uint8Array(6883) [0, 97, 115, 109, 1, 0, 0, 0, 1, 68, 10, 96, 0, 1, 127, 96, 1, 127, 1, 127, …]
edit
makeWasmDetectRow = ƒ(mod, jsDetectRow)
edit
wasmDetectRowMan = ƒ(…)
edit
edit
edit
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
edit
edit
wasmBuild = Object {bytes: Uint8Array(6883), error: null, stderr: "", version: "0.28.20", compileMs: 166, builtBytes: 6883, shippedBytes: 6883, identical: true, firstDiff: -1}
edit

asc 0.28.20 · compiled in 166ms

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.

  1. The edge threshold is chosen per frame.
  2. Every centre is refined on its own ring lattice.
  3. The plane is fitted by exhaustive RANSAC over the 4-subsets.
  4. 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.

edit
edit
ringObservations = ƒ(…)
edit
fitRingLattice = ƒ(…)
edit
fitPlaneMetric = ƒ(…)
edit
fitPlaneRansac = ƒ(…)
edit
markContrast = ƒ(frame, cx, cy, R)
edit
relabelCase = async ƒ(…)
edit
edit
edit
edit
relabelStatus = "idle"
edit
relabelRun = null
edit
choose a bank frame, then press the button
edit

Some helpers for development

edit
edit
renderHexScene = ƒ(…)
edit
edit
edit
edit
edit
edit

§5 Next steps

close
inputs: sec
sec("next")

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.

edit
whenVisible = ƒ(cellName, invalidation)
edit
edit
edit
edit
sections = Array(19) [Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object]
edit
sectionIndex = Map(19) {"about" => Object, "scanner" => Object, "mark" => Object, "multi" => Object, "eval" => Object, "labels" => Object, "nearmiss" => Object, "score" => Object, "detect" => Object, "pattern" => Object, "scanline" => Object, "combine" => Object, "ortho" => Object, "pose" => Object, "fast" => Object, "faster" => Object, "relabel" => Object, "lattice" => Object, "next" => Object}
edit
sec = ƒ(key)
edit
externalLink = ƒ(…)
edit
ref = ƒ(key, label)
edit
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
edit
edit
annotation_a2kyvwghbb = Object {box: Object, state: "open", createdAt: "2026-08-11T20:23:39.808Z", anchor: Object}
edit
edit
annotation_a2ais7vd8x = Object {box: Object, state: "open", createdAt: "2026-08-12T04:27:56.079Z", anchor: Object}
edit
encodingDiagram · quote (changed)

the axis should have fixed domains so when the tilt range is change the angle change is clear.

wasm_md · quote (changed)

should be an aside not external link