Playbook

The Plan

SPAR Reproduction — complete step-by-step · arXiv:2604.02457 · lab path: /home/ubuntu/spar/

A printed border that goes behind your license plate (not covering any text) that fools ALPR systems. The paper achieved:

⚠️ Read the Legal page first. Do not deploy on a public road without legal review. Research, print, and controlled testing on private property only.

Step 1 — Take 300 Photos (15 min)

  • Pick a vehicle. Any camera works — the paper used an iPhone.
  • Shoot in: full sun, overcast, dusk, parking lot, street parking.
  • Vary distance (10–60 ft) and angle (straight-on, 15°, 30°).
  • Drop the .jpg files into /home/ubuntu/spar/data/raw/

Step 2 — Label Plate Corners (15 min)

Option A — Web GUI (recommended for teams):

cd /home/ubuntu/spar
source venv/bin/activate
python web_labeler/labeler_server.py --images data/raw/ --labels data/labels/ --port 5050

Open http://YOUR_SERVER_IP:5050 in any browser. Everyone enters their name, clicks corners simultaneously, images auto-assign so no overlap. Full controls on the Web Labeler page.

Option B — Desktop tool (single user):

python label_corners.py -i data/raw/

Click the 4 corners in order: Top-Left → Top-Right → Bottom-Right → Bottom-Left. ~3 seconds per image = 300 images in ~15 minutes. Output: JSON files in data/labels/.

Verify labels:

python check_labels.py -i data/raw/ -l data/labels/

Step 3 — Train the Disruption Patch (20 min CPU)

python train_patch.py \
  --mode disruption \
  --epochs 100 \
  --batch-size 64 \
  --lr 0.01 \
  --tv-weight 0.01

What happens:

  • Loads your 300 photos + corner labels; initializes a random patch (Xavier init).
  • Per image: warps the patch via kornia homography → composites as rim → runs through YOLO + OCR → computes loss → backpropagates to the patch.
  • Three loss terms fight each other:
    • Detection loss — makes YOLO unsure / miss the plate
    • OCR loss — makes the text reader output gibberish
    • TV loss — keeps the patch smooth and printable
  • Saves checkpoints every 20 epochs.

Outputs in patches/:

FileDescription
patch_disruption_epoch100.ptPyTorch checkpoint
patch_disruption_epoch100_preview.jpgLow-res preview
patch_disruption_epoch100_printable.png300 DPI — send this to the printer

Step 4 — (Optional) Train Impersonation Patch

python train_patch.py \
  --mode impersonation \
  --target "ABC1234" \
  --epochs 100 \
  --batch-size 64

Makes the ALPR read your plate as a different plate text instead of failing to read it.

Step 5 — Print

  • Take patches/patch_disruption_epoch100_printable.png to a professional print shop (FedEx Office, Staples, local shop).
  • Paper: heavyweight matte poster · Size: 8.4″ × 16.8″ (already 300 DPI = 2520×5040 px) · Cost: ~$50
  • Cut out the center so the plate face is visible — only the border/rim remains.

Step 6 — Mount

  • Place the printed rim between the license plate and the vehicle.
  • The rim extends outward from the plate edges.
  • No part of the rim covers any letter, number, county name, or state name.
  • Secure it so it doesn't flap in the wind.

Step 7 — Evaluate

python evaluate.py \
  --patch patches/patch_disruption_epoch100.pt \
  --images data/eval/ \
  --labels data/labels/ \
  --output results/

What to put in data/eval/:

  • Fresh photos of the car with the rim installed — same conditions as training.
  • Also the same shots WITHOUT the rim (control).

What you get back (results/):

MetricWhat it measures
Detection suppressed completely% of images where YOLO found no plate at all
Avg confidence reductionHow much YOLO's confidence dropped
Correct read rate% where ALPR still read the plate correctly
Per-image breakdownCSV with each image's results

To render a standalone patch later:

python render_patch.py --patch patches/patch_disruption.pt --output patches/new_print.png

Quick Reference — All Commands

# Activate environment (every new terminal)
source /home/ubuntu/spar/venv/bin/activate
cd /home/ubuntu/spar

# 0. Start web labeler (team can label simultaneously)
python web_labeler/labeler_server.py --images data/raw/ --labels data/labels/ --port 5050

# 1. Label corners via web GUI — http://YOUR_SERVER_IP:5050

# 2. Verify labels
python check_labels.py -i data/raw/ -l data/labels/

# 3. Train disruption
python train_patch.py --mode disruption --epochs 100 --batch-size 64

# 4. Train impersonation (optional)
python train_patch.py --mode impersonation --target "ABC1234" --epochs 100

# 5. Render printable
python render_patch.py --patch patches/patch_disruption_epoch100.pt

# 6. Evaluate
python evaluate.py --patch patches/patch_disruption_epoch100.pt --images data/eval/

How It Works (The Tech)

Your photo (300x)       Random patch
     │                       │
     │  Click 4 corners      │
     │  of the plate         │ Xavier init
     ▼                       ▼
 Corner JSON            Trainable tensor [3, 400, 800]
     │                       │
     └──────┬────────────────┘
            │ kornia find_homography_dlt()
            │ warp_perspective()
            ▼
     Patched image (rim composited around plate)
            │
     ┌──────┴──────┐
     ▼             ▼
   YOLO-v9        CCT-OCR
 (detection)     (reading)
     │             │
     ▼             ▼
  det_loss       ocr_loss
  (lower conf.   (max entropy or
   + expand       CE to target
    bbox)         plate text)
     │             │
     └──────┬──────┘
            │ + TV loss (smoothness)
            ▼
      AdamW optimizer
      → updates patch

Key: Both YOLO and OCR run in PyTorch (converted from ONNX via onnx2torch), so gradients flow all the way back to the patch pixels. The homography warp via kornia is also differentiable. The whole thing is one end-to-end gradient descent loop.

What's Installed

ComponentVersionPath
Python3.11spar/venv/
PyTorch2.13.0 (CPU)via pip
kornia0.8.1via pip
fast-alpr0.4.0spar/fast-alpr/
fast-plate-ocr1.1.0spar/fast-plate-ocr/
open-image-models0.5.1spar/open-image-models/
YOLO modelyolo-v9-t-384cached in ~/.cache/
OCR modelcct-xs-v2-globalcached in ~/.cache/
Synthetic test data50 train + 10 evaldata/raw/ + data/eval/