| out | ||
| .gitignore | ||
| cluster.py | ||
| contact_sheet.py | ||
| lessons.md | ||
| LICENSE | ||
| Makefile | ||
| pipeline.py | ||
| plan.md | ||
| pyproject.toml | ||
| README.md | ||
| segment.py | ||
| term-instructions.pdf | ||
| uv.lock | ||
sprues
A local pipeline that takes a photo of a plastic sprue and segments it into individual bit crops, then clusters visually-identical bits so a single human label propagates to every member.
The pipeline runs entirely on classical CV (OpenCV) for segmentation and a local DINOv2 image-embedding model for clustering. No cloud APIs, no LM Studio dependency, sub-minute end-to-end on Apple Silicon.
Usage
Add a new sprue:
uv run python pipeline.py /path/to/sprue-photo.jpg out
# → out/<short-hash>/orig.jpg + outputs
open out/<short-hash>/clusters.html
# Edit out/<short-hash>/labels.json to attach names to clusters; reload.
Re-process every sprue already in out/:
make regen # iterates out/*/orig.* and re-runs the pipeline on each
Each out/<short-hash>/ contains:
orig.jpg— copy of the input photo (committed)00_feeders.png01_plastic.png02_runner.png03_masks.png— debug overlays at each pipeline stage (committed)crops/masks/— per-bit images (gitignored, regenerate via pipeline)clusters.htmlclusters.jsonsegments.jsonlabels.json— contact sheet, manifests, user-editable label scaffold (gitignored)
Example
Termagants sprue, original photo and segmented bits (one colour per bit, runner overlay in red):
| Input | Segmented |
|---|---|
![]() |
![]() |
Test sprues currently in tree (click the dir for full debug overlays):
| sprue | dir | bits | clusters |
|---|---|---|---|
| Termagants (Tyranid, organic bits) | out/70a05231fe/ |
14 | 12 |
| Intercessors v1 (Space Marines, dense grid) | out/f174fe6dbb/ |
59 | 36 |
| Intercessors v2 (Space Marines, similar layout) | out/1ee24d83cd/ |
63 | 39 |
| Rhino (vehicle, large flat panels) | out/404866c10c/ |
17 | 13 |
How it works
segment.py — turn a sprue photo into per-bit crops + masks:
- Otsu-binarise the photo into a plastic-vs-background mask.
- Detect ejector-tab/feeder discs by Hough circles. Filter by sprue-symmetry (centred on a vertical or horizontal axis), filled-disc-with-bright-centre pattern, and at least four radial runner spokes. Zero those discs in the plastic mask.
- Erode the plastic; identify the runner network as the eroded connected component whose bbox spans the most of the image (not the largest by area — vehicle bits dominate that). Dilate it back, close pinhole gaps, thicken the walls so adjacent grid-cell bits don't bleed together.
bits_mask = plastic AND NOT runner_full. Each connected component is one bit, with sprue gates trimmed.- Filter out residual runner fragments by combined edge-density and thickness checks; verify each bit has at least some background-facing edge (real bits sit in cells, runner fragments are sandwiched in runner).
The script tries several erosion-kernel sizes and picks the trial with the most accepted bits.
cluster.py — DINOv2 image embeddings + agglomerative clustering on cosine
similarity (threshold 0.80). Visually-identical bits land in the same
cluster.
contact_sheet.py — render clusters.html showing one row per cluster with
all member crops side-by-side, plus an editable labels.json scaffold.
pipeline.py — orchestrator: segment → cluster → contact sheet.
What's NOT in the pipeline
Bit names ("fleshborer", "body") aren't recovered automatically.
Names come from the human via labels.json; the
clustering ensures one label propagates across every copy of that bit on the
sprue.
Files
segment.pycluster.pycontact_sheet.pypipeline.py— pipelineplan.md— the original architectural plan (kept for context; pipeline has since deviated in places, seelessons.md)lessons.md— design lessons captured during development (which approaches worked, which didn't, why)term-*.jpgterm-instructions.pdfintercessors-*.jpgrhino-*.jpg— test inputsout/— generated outputs (segmentation, clusters, contact sheet), gitignored

