01Live02Batch03About
/ Batch

The batch pipeline,
all in one pass.

One real target, scored end-to-end by the production pipeline. Seven rounds fired — one landed off paper, six scored. The fixture is regenerated by a script, never hand-edited.

Also builtsingle-shot live pipeline
group_1 · template_01
scout · 15484ms
SIFT + FLANN (Lowe 0.7, RANSAC)
Photo · Scoringdrag to compare
111122223333444455556666777788889999
photoscoring
real aligned photo6 shots scored
Verdict
36.6/ 60
group total
6 shots scored

a0ring 4 · (434.1, 538.1)4.8
a1ring 10 · (715.9, 718.2)10.0
a2ring 8 · (842.9, 846.1)8.8
a3ring 2 · (426, 1160.7)2.5
a4ring 4 · (384.1, 859.9)4.8
a5ring 5 · (678.3, 1066.6)5.7

Rounds fired7 · 1 off-paper
Scored6 / 6
Templatetemplate_01 · 1500×1500
AlignmentSIFT + FLANN (Lowe 0.7, RANSAC)
Ring width61.8px
Step 1: Center identified at the small circle inside ring 9. Step 2: Found 6 clear shot holes as dark perforations. Step 3: (A) in ring 7 outer half, (B) in ring 10 center, (C) in ring 8 inner half, (D) in ring 5 outer half, (E) in ring 3 outer half, (F) in ring 1 outer half. Step 4: Scored based on position within each ring.
claude vision · batch scout15484ms · 6 anchors
Under the hood · batch mode

Scored in five stages.
Conservative by default, global by design.

Batch mode splits work between Claude Vision (judgment — “how many holes, roughly where?”) and OpenCV (precision — with a single shared diff and a global matcher that prevents double-counting when anchors overlap). Scroll through the pipeline.

01
Align · OpenCV · no AI

Make the pixels match the template.

Same SIFT + FLANN + Lowe + RANSAC pipeline the live page uses — shared code, shared fixture format. The only difference here is what comes next: a single rectified canvas that every downstream anchor reuses, rather than a before/after pair feeding a temporal diff.

Produces
One rectified canvas, pixel-aligned with the template.
SIFT inlier keypoints between raw photo and reference template
sift keypoints · flann matcher · lowe 0.7 · ransac homography
02
Scout · Claude Vision

Where are the shots, roughly?

Claude receives the aligned photo and returns approximate positions for every perforation it can confidently identify. The hint (“about 7 rounds fired”) is deliberately framed as a hint, not a quota — Claude is free to return fewer shots when evidence is ambiguous, rather than inventing them from smudges or shadows. On this fixture, the hint was 7 and the scout returned 6. That gap is by design.

Produces
6 approximate anchors — fewer than the hint when Claude isn’t sure.
a0a1a2a3a4a5
hint was 7; one shot landed off paper, no perforation to find.
circles show scout’s confidence region, not the measured position — that comes in stage 03.
03
Diff · OpenCV · shared

One difference image, shared by every anchor.

The aligned photo is differenced against a brightness-normalised template — the template scan lives near saturation, handheld photos are usually dimmer, so we shift their median brightness to match before the diff. Printed ring and numeral edges are then zeroed out via a feature mask (≈11.1% of pixels), so the diff only contains real perforations. Crucially this runs once— the same diff image is reused inside every anchor’s ROI, not recomputed per shot.

Produces
One difference image, reused across every anchor.
Feature-masked difference image
diff (shared)
Template feature mask — printed edges zeroed
feature mask (11.1% zeroed)
gaussian blur · median-brightness match (Δ 24) · absdiff · laplacian mask
04
Group by ring · per-anchor scale

Search radius from physical geometry.

Each anchor is pre-assigned to its nearest ring group; that group’s ring_width drives its search radius and blob-area filters. The bounds come from the physical pellet/ring ratio (a 4.5mm pellet on an 8mm-ring target is always ~0.56 ring widths across) — so adding a new template at a different print DPI works without per-template recalibration. On this fixture every anchor gets a search radius of 339px (5.5 × ring_width) with a blob-area window of 22922915px².

Produces
Per-anchor search parameters, no per-template recalibration.
ring_width × 5.5 = 339px
ROIs overlap when shots group tightly — that’s what sets up stage 05.
05
Match globally · Hungarian

One-to-one, observation identity preserved.

Every anchor’s ROI surfaces 2–4 candidate observations, and with a 5.5× ring-width radius, ROIs overlap heavily — multiple anchors often see the same physical hole. Observations across all ROIs are clustered (complete-linkage, so adjacent pellets can’t merge through a bridge observation), and a Hungarian solver finds the globally minimum-cost anchor↔cluster assignment. Edge cost is the distance from the anchor to its own observation in that cluster — so the returned centroid is always one the anchor actually saw, never a neighbour’s. The heatmap below is the solver’s cost matrix: darker means cheaper, the ringed cells are the assignment Hungarian picked.

Produces
A one-to-one assignment — no greedy collisions, no phantom centroids.
a0a1a2a3a4a5
cost matrix · darker = cheaper · distance in px
c0c1c2c3c4c5
a036145227292··4.8r4
a1·15634365··10.0r10
a2·90239·349·8.8r8
a3·224332·743422.5r2
a4265···309604.8r4
a5·478··273·5.7r5
one figure, two views · 6 clusters, 6 anchors · crosshairs mark the final positions · the dashed line is the path Hungarian considered and passed over
Fallback · strict by design

No LLM-only path; alignment must succeed.

Batch has no before-frame to fall back on — without alignment, the sniper has no template to diff against, and there’s no temporal signal to isolate holes. If SIFT can’t find enough keypoints (too blurry, target cropped, extreme angle), the endpoint returns 422 with a hint to retry rather than guessing.