An automated pipeline that detects Lego brick shape (and, optionally, color) from a live video feed, as the first stage of an automated sorting system. Physical sorting hardware (Arduino/Raspberry Pi actuation) is a later phase -- this covers everything up through live detection.
The pipeline has four stages:
- Capture (
capture/) -- guided image capture from a VDO.Ninja stream or USB webcam, walking through each brick class and a checklist of orientation variations. - Labeling (
labeling/) -- a local, offline bounding-box annotation tool (no cloud account needed), plus a script that splits labeled images into a YOLO-ready train/val/test dataset. - Training (
training/) -- fine-tunes a pretrained YOLOv8 model on your dataset. Includes a grayscale-conversion step so the shape model can't use color as a classification shortcut (see Section 5). - Inference (
inference/) -- runs the trained model against a live feed, with an optional second stage (color_classification/) that classifies the color of each detected piece separately.
lego_sorter/
├── requirements.txt
├── setup.bat
├── README.md <- this file
├── capture/ # Phase 1: guided image capture
│ ├── config.py # BRICK_CLASSES, VARIATION_PROMPTS
│ ├── base.py, vdo_ninja_source.py, webcam_source.py
│ ├── session.py, main.py
├── labeling/ # Phase 1.5: local annotation
│ ├── annotate.py, split_dataset.py
├── training/ # Phase 2: model training
│ ├── make_grayscale_dataset.py
│ ├── train.py, evaluate.py
├── inference/ # Phase 3: live detection
│ └── live_infer.py
├── color_classification/ # Phase 3b: color stage (optional)
│ ├── config.py, capture_reference.py, classify.py
├── data/
│ ├── raw/ # captured + labeled images, by class
│ └── color_references/ # saved color reference swatches
├── dataset/ # generated split (color)
└── dataset_gray/ # generated split (grayscale, for training)
- Ensure Python is installed and on your PATH.
- From the project root, run:
This installs everything in
setup.batrequirements.txt, then runsplaywright install chromium(a required separate download for Playwright's browser binary -- the pip package alone isn't enough), then verifies the key imports. - (Optional, recommended as the project grows) Use a virtual environment:
Activate it manually each session afterward -- a batch script can't leave a venv active in the terminal window that launched it.
python -m venv venv venv\Scripts\activate setup.bat
python -m capture.main --source vdoninja --url "https://vdo.ninja/?view=AgWSsA9y"
python -m capture.main --source webcam --device 0
Walks through each class in capture/config.py's BRICK_CLASSES, then each
variation prompt, capturing IMAGES_PER_VARIATION shots per variation
(s = capture, r = redo, n = skip variation, q = quit).
python -m labeling.annotate
Draws one bounding box per image (class is inferred from the folder).
Controls: drag to draw, s save + next, u undo, x exclude a bad photo,
q quit (safe to resume later -- it skips anything already labeled).
python -m labeling.split_dataset
Produces dataset/ in YOLO format, split per-class so small datasets still
get proportional representation, with data.yaml included.
python -m training.make_grayscale_dataset
Produces dataset_gray/ -- same split and labels, grayscale (3-channel)
images. See Section 5 for why this matters.
python -m training.train --data dataset_gray/data.yaml
Fine-tunes YOLOv8n from COCO-pretrained weights. Best weights land at
runs/train/lego_shape_mvp/weights/best.pt.
python -m training.evaluate --weights runs/detect/runs/train/lego_shape_mvp/weights/best.pt
Prints overall + per-class mAP50 and mAP50-95 on the held-out test split.
python -m color_classification.capture_reference --source vdoninja --url "https://vdo.ninja/?view=AgWSsA9y"
One photo per color you want to detect (see Section 6).
python -m inference.live_infer --source vdoninja --url "https://vdo.ninja/?view=AgWSsA9y" --weights "runs\detect\runs\train\lego_shape_mvp\weights\best.pt"
Runs shape detection (on a grayscale copy of each frame) and, if color
references exist, classifies the color of each detection from the original
color frame. Press q to quit. Add --no-color to skip color entirely.
Say you're currently detecting 2x2, 2x3, 2x4 and want to add 2x5,
2x6, 2x7. The pipeline is designed so this doesn't require starting
over:
-
Append the new classes to the end of
BRICK_CLASSESincapture/config.py:BRICK_CLASSES = [ "2x2", "2x3", "2x4", # existing -- do not reorder or remove these "2x5", "2x6", "2x7", # new -- always add to the END ]
This ordering matters. Each class's position in this list becomes its numeric class ID in every YOLO label file already on disk. If you insert a new class in the middle or reorder existing ones, every already-labeled image's class ID silently points to the wrong class. Appending to the end keeps all your existing labels valid.
-
Capture data for the new classes only:
python -m capture.main --source vdoninja --url "..."The capture tool walks through every class currently in
BRICK_CLASSESeach run -- it doesn't know which ones you've already captured. The simplest way to capture only the new classes: temporarily comment out the existing class names inBRICK_CLASSESfor this capture session, then restore the full list (existing + new) afterward before labeling. Aim for the same volume you used for the original classes. -
Label the new images:
python -m labeling.annotateSince already-labeled images are automatically skipped, this will only prompt you for the new, unlabeled images.
-
Re-run the split (rebuilds
dataset/from all labeled images, old and new):python -m labeling.split_dataset -
Regenerate the grayscale dataset:
python -m training.make_grayscale_dataset -
Retrain. With datasets this size, a full retrain from the COCO-pretrained base (
yolov8n.pt) is fast (minutes) and safer than incrementally fine-tuning your previousbest.pt-- incremental fine-tuning on a tiny new batch risks the model drifting on the classes it already knew (catastrophic forgetting):python -m training.train --data dataset_gray/data.yaml -
Re-evaluate all classes, not just the new ones -- check the confusion matrix for cross-confusion between old and new classes (e.g.
2x4vs2x5is a similarly subtle difference to your earlier2x2vs2x3confusion, and is exactly the kind of thing to check for).
If a shape class is represented by only one or two colors during training, the model can learn to use color as a shortcut for classification instead of the actual geometric shape -- it's an easier signal to pick up on than subtle stud-count differences. That shortcut then breaks the moment you test with a color the model didn't associate with that class.
Converting training images to grayscale removes color information from the model's input entirely, so this shortcut is physically unavailable -- the model is forced to learn the actual silhouette (outline, stud pattern, aspect ratio).
This must be applied consistently: training/make_grayscale_dataset.py
converts the training data, and inference/live_infer.py converts each live
frame to grayscale the same way before running shape detection. Training and
inference need matching preprocessing, or you've recreated a different
version of the same domain-shift problem.
One upside: since color no longer matters for the shape model, you don't need every color of every shape in your training data -- orientation variety matters far more than color variety now.
Color is handled as a separate, much simpler problem from shape -- you don't need a deep model or images of every shape in every color:
-
Capture one reference photo per color you want to detect:
python -m color_classification.capture_reference --source vdoninja --url "..."Point the camera at a solid-color piece centered in frame for each color listed in
color_classification/config.py'sCOLOR_NAMES. The tool samples the center of the frame (avoiding background) and stores its average color in LAB space (a color space where Euclidean distance corresponds well to perceived color difference) todata/color_references/references.json. -
Classification at inference time: once the shape model detects a piece and its bounding box (on the grayscale frame),
live_infer.pycrops that same region from the original color frame and callsclassify_color(), which compares the crop's average color (background pixels excluded via a brightness threshold) against your saved references and returns the closest match. -
Limitation to keep in mind: this is a simple nearest-reference color match, not a trained model -- it's lighting-sensitive in the same way shape detection was before you fixed that with grayscale. Capture your color references under lighting similar to your deployment setup, and expect to recapture references if you change your rig's lighting meaningfully.
To add a new color later: add its name to COLOR_NAMES in
color_classification/config.py and re-run capture_reference.py -- it
will only prompt for colors not already in the references file.
- Color classification accuracy hasn't been validated yet the way shape detection has -- treat the first live test as diagnostic, same as the shape model's first run.
- Physical sorting mechanism (Arduino/Raspberry Pi actuation) is not yet
built --
live_infer.py's per-frame detection output (class + confidence- color) is the hook point for that once you're ready.
- For scaling to a much larger piece catalog later, see the discussion of embedding-based / few-shot retrieval recognition from earlier in this project -- worth revisiting once class count grows into the dozens.