Real-time object detection for warehouse robots, on CPU.
TechTrack is the first full case study in EN.705.603 (Creating AI-Enabled Systems), which I am taking at Johns Hopkins in fall 2026 as part of my MS in Artificial Intelligence. The scenario is a fleet of warehouse robots that have to find and label twenty kinds of objects (people, forklifts, pallets, boxes, safety gear, vehicles, signs) in a 30 frames per second camera feed, on CPU only, with a human review loop that turns the model's mistakes into retraining data.
The robot's planner stops or reroutes on every box it receives. A missed person is the most expensive error the system can make, and I set every threshold in the analysis against person recall first.
I built the inference service in Python and OpenCV, then the evaluation and retraining pieces around it.
Both candidate models arrived trained, so the analysis tunes the system around them rather than the networks. I ran each model once over the 9,525-image logistics dataset, cached every candidate box above 0.01 objectness, and made each later experiment a re-read of that cache. An evaluation went from two minutes to about two seconds on my laptop.
Model 2 led model 1 on mAP (0.583 against 0.544), was ahead or level on all 20 classes, and kept the lead under every match IoU and score threshold I tried. I set the deployed objectness threshold at 0.25, where person recall stays within 0.02 of its best and the planner gets 3.5 boxes per frame instead of 8.6.
| class | boxes | AP model 1 | AP model 2 | delta |
|---|---|---|---|---|
| wood pallet | 9,330 | 0.273 | 0.307 | +0.034 |
| person | 6,368 | 0.511 | 0.588 | +0.077 |
| cardboard box | 4,995 | 0.723 | 0.782 | +0.059 |
| fire | 2,793 | 0.253 | 0.315 | +0.062 |
| helmet | 2,170 | 0.470 | 0.528 | +0.058 |
| smoke | 1,495 | 0.455 | 0.528 | +0.073 |
| car | 1,379 | 0.674 | 0.684 | +0.010 |
| safety vest | 1,260 | 0.562 | 0.586 | +0.024 |
| traffic light | 1,193 | 0.782 | 0.793 | +0.011 |
| forklift | 1,103 | 0.695 | 0.778 | +0.083 |
| truck | 782 | 0.765 | 0.768 | +0.003 |
| van | 765 | 0.784 | 0.790 | +0.006 |
| road sign | 720 | 0.213 | 0.219 | +0.006 |
| traffic cone | 506 | 0.681 | 0.691 | +0.010 |
| qr code | 369 | 0.760 | 0.759 | -0.001 |
| license plate | 359 | 0.353 | 0.414 | +0.061 |
| freight container | 318 | 0.236 | 0.305 | +0.069 |
| barcode | 283 | 0.676 | 0.754 | +0.078 |
| ladder | 277 | 0.385 | 0.436 | +0.051 |
| gloves | 256 | 0.630 | 0.636 | +0.006 |
| mAP | 36,721 | 0.544 | 0.583 | +0.039 |
| match IoU | score threshold | model 1 | model 2 |
|---|---|---|---|
| 0.3 | 0.05 | 0.585 | 0.612 |
| 0.3 | 0.25 | 0.580 | 0.611 |
| 0.3 | 0.50 | 0.485 | 0.541 |
| 0.5 | 0.05 | 0.544 | 0.583 |
| 0.5 | 0.25 | 0.549 | 0.571 |
| 0.5 | 0.50 | 0.462 | 0.524 |
| 0.7 | 0.05 | 0.385 | 0.427 |
| 0.7 | 0.25 | 0.393 | 0.423 |
| 0.7 | 0.50 | 0.364 | 0.402 |
| threshold | person P | person R | all P | all R | boxes per image |
|---|---|---|---|---|---|
| 0.05 | 0.637 | 0.638 | 0.591 | 0.586 | 8.58 |
| 0.10 | 0.637 | 0.638 | 0.591 | 0.586 | 5.78 |
| 0.25 | 0.673 | 0.620 | 0.634 | 0.564 | 3.46 |
| 0.50 | 0.808 | 0.489 | 0.786 | 0.453 | 2.22 |
| objectness threshold | boxes per image | unlabeled boxes per image | person recall |
|---|---|---|---|
| 0.05 | 8.58 | 4.76 | 0.638 |
| 0.10 | 5.78 | 1.95 | 0.638 |
| 0.20 | 3.93 | 0.11 | 0.638 |
| 0.25 | 3.46 | 0.03 | 0.620 |
| 0.30 | 3.11 | 0.02 | 0.595 |
| 0.50 | 2.22 | 0.00 | 0.489 |
I drew a 5,000-image working sample stratified by the rarest class in each image, using label counts only. It keeps every ladder, gloves, and freight container image. On the ten rarest classes its AP error against the full set is half that of a uniform sample for model 2.
| class | images (full) | images (stratified) | images (uniform) | share kept (stratified) | share kept (uniform) |
|---|---|---|---|---|---|
| ladder | 183 | 183 | 86 | 1.00 | 0.47 |
| freight container | 192 | 192 | 98 | 1.00 | 0.51 |
| gloves | 226 | 226 | 115 | 1.00 | 0.51 |
| barcode | 272 | 263 | 144 | 0.97 | 0.53 |
| traffic cone | 291 | 268 | 175 | 0.92 | 0.60 |
| license plate | 292 | 286 | 155 | 0.98 | 0.53 |
| qr code | 299 | 278 | 162 | 0.93 | 0.54 |
| road sign | 372 | 331 | 210 | 0.89 | 0.56 |
| cardboard box | 443 | 330 | 211 | 0.74 | 0.48 |
| safety vest | 479 | 297 | 254 | 0.62 | 0.53 |
| helmet | 631 | 485 | 329 | 0.77 | 0.52 |
| wood pallet | 689 | 292 | 370 | 0.42 | 0.54 |
| van | 695 | 326 | 360 | 0.47 | 0.52 |
| truck | 706 | 279 | 390 | 0.40 | 0.55 |
| traffic light | 866 | 370 | 429 | 0.43 | 0.50 |
| forklift | 949 | 303 | 487 | 0.32 | 0.51 |
| smoke | 975 | 263 | 538 | 0.27 | 0.55 |
| car | 1,000 | 456 | 534 | 0.46 | 0.53 |
| fire | 1,133 | 361 | 603 | 0.32 | 0.53 |
| person | 1,933 | 1,220 | 1,001 | 0.63 | 0.52 |
A sweep from 0.1 to 0.9 found a flat top between 0.4 and 0.6 that held on the 4,525 images outside the sample and at the score thresholds the service would run, so I kept 0.5 in the middle of the plateau.
| NMS IoU | mAP |
|---|---|
| 0.1 | 0.545 |
| 0.2 | 0.548 |
| 0.3 | 0.564 |
| 0.4 | 0.567 |
| 0.5 | 0.572 |
| 0.6 | 0.571 |
| 0.7 | 0.559 |
| 0.8 | 0.527 |
| 0.9 | 0.468 |
| score threshold | NMS 0.3 | NMS 0.4 | NMS 0.5 | NMS 0.6 | NMS 0.7 | boxes per image at 0.5 | at 0.6 |
|---|---|---|---|---|---|---|---|
| 0.05 | 0.564 | 0.567 | 0.572 | 0.571 | 0.559 | 8.95 | 10.46 |
| 0.25 | 0.555 | 0.562 | 0.565 | 0.563 | 0.551 | 3.77 | 4.02 |
| 0.50 | 0.506 | 0.510 | 0.520 | 0.518 | 0.512 | 2.50 | 2.57 |
| NMS IoU | wood pallet (13.5) | cardboard box (11.3) | person (3.3) | helmet (3.4) | car (1.4) | gloves (1.1) | ladder (1.5) |
|---|---|---|---|---|---|---|---|
| 0.1 | 0.274 | 0.730 | 0.545 | 0.449 | 0.601 | 0.638 | 0.332 |
| 0.3 | 0.278 | 0.734 | 0.599 | 0.524 | 0.599 | 0.637 | 0.394 |
| 0.5 | 0.269 | 0.729 | 0.598 | 0.524 | 0.598 | 0.637 | 0.442 |
| 0.7 | 0.281 | 0.721 | 0.580 | 0.516 | 0.594 | 0.637 | 0.376 |
| 0.9 | 0.252 | 0.693 | 0.499 | 0.488 | 0.351 | 0.547 | 0.328 |
| best candidate IoU | share of pallet ground truths | count |
|---|---|---|
| no overlap (below 0.1) | 0.220 | 870 |
| 0.1 to 0.3 | 0.175 | 692 |
| 0.3 to 0.5 (near miss) | 0.195 | 773 |
| 0.5 to 0.7 | 0.228 | 903 |
| 0.7 and up | 0.183 | 723 |
Heavy blur cost 0.17 mAP and darkening cost 0.11, while a horizontal flip changed nothing and a vertical flip cost 0.25 (an upside-down aisle is a view the robot never sees, so vertical flips stay out of retraining). Boxes per frame fell 26 to 55 percent under blur and darkness, which makes the box count a cheap online signal that the camera or the lighting has changed.
| variant | mAP | detections at score 0.5 | change |
|---|---|---|---|
| original | 0.572 | 12,504 | 0.000 |
| blur k=5 | 0.541 | 9,236 | -0.031 |
| blur k=9 | 0.468 | 7,178 | -0.104 |
| blur k=15 | 0.402 | 5,585 | -0.170 |
| horizontal flip | 0.581 | 12,448 | +0.009 |
| vertical flip | 0.318 | 6,444 | -0.254 |
| brightness -80 | 0.459 | 8,075 | -0.113 |
| brightness -40 | 0.554 | 11,380 | -0.018 |
| brightness +40 | 0.556 | 11,924 | -0.016 |
| brightness +80 | 0.534 | 10,790 | -0.038 |
| contrast x0.6 | 0.569 | 12,276 | -0.003 |
| contrast x1.4 | 0.562 | 12,090 | -0.010 |
| blur k=15 | change | vertical flip | change | brightness -80 | change |
|---|---|---|---|---|---|
| van | -0.35 | forklift | -0.68 | traffic light | -0.25 |
| safety vest | -0.31 | traffic cone | -0.52 | forklift | -0.19 |
| barcode | -0.26 | gloves | -0.51 | smoke | -0.19 |
| helmet | -0.25 | van | -0.48 | barcode | -0.17 |
| truck | -0.24 | car | -0.43 | cardboard box | -0.17 |
| person | -0.20 | smoke | -0.41 | license plate | -0.16 |
I scored all 5,000 images under seven sets of loss weights and compared the 500 hardest images each one selects, then repeated the ranking with the loss divided by the number of objects in the image.
| term | mean per image | median per image | mean per ground truth box | share of summed loss | factor to equal objectness |
|---|---|---|---|---|---|
| localization | 0.050 | 0.016 | 0.031 | 0.005 | 197 |
| objectness | 9.762 | 5.669 | 3.449 | 0.902 | 1 |
| no-object | 0.492 | 0.069 | 0.151 | 0.046 | 20 |
| classification | 0.515 | 0.307 | 0.192 | 0.048 | 19 |
| balanced | localization x5 | classification x5 | no-object x5 | no-object x0.1 | objectness x5 | |
|---|---|---|---|---|---|---|
| balanced | 1.00 | 0.98 | 0.98 | 0.81 | 0.95 | 0.97 |
| localization x5 | 0.98 | 1.00 | 0.96 | 0.81 | 0.94 | 0.95 |
| classification x5 | 0.98 | 0.96 | 1.00 | 0.79 | 0.96 | 0.97 |
| no-object x5 | 0.81 | 0.81 | 0.79 | 1.00 | 0.77 | 0.78 |
| no-object x0.1 | 0.95 | 0.94 | 0.96 | 0.77 | 1.00 | 0.98 |
| objectness x5 | 0.97 | 0.95 | 0.97 | 0.78 | 0.98 | 1.00 |
| setting | boxes per image (raw) | small boxes (raw) | top class (raw) | boxes per image (per object) | small boxes (per object) | top class (per object) | shared with raw top 500 |
|---|---|---|---|---|---|---|---|
| balanced | 19.7 | 0.15 | wood pallet (0.33) | 1.22 | 0.00 | fire (0.15) | 0.05 |
| localization x5 | 19.7 | 0.15 | wood pallet (0.33) | 1.23 | 0.00 | fire (0.15) | 0.06 |
| classification x5 | 19.7 | 0.14 | wood pallet (0.32) | 1.22 | 0.00 | fire (0.15) | 0.05 |
| no-object x5 | 19.7 | 0.16 | wood pallet (0.34) | 1.23 | 0.01 | fire (0.14) | 0.05 |
| no-object x0.1 | 19.6 | 0.15 | wood pallet (0.32) | 1.22 | 0.00 | fire (0.15) | 0.05 |
| objectness x5 | 19.7 | 0.15 | wood pallet (0.32) | 1.23 | 0.00 | fire (0.15) | 0.05 |
| whole sample | 4.1 | 0.06 | person (0.20) |
Wood pallets have the most labeled boxes in the dataset and one of the lowest APs (about 0.3), and no NMS setting moves them. When I measured the best candidate box for each pallet, only 41 percent had one at the 0.5 match threshold and 22 percent had nothing overlapping at all. That is a labeling and retraining problem, and the design routes it to the human review queue.
Setting the NMS threshold too low cost 0.03 mAP from merged neighbors. Setting it too high cost 0.10 from duplicate boxes on every object. Sparse classes like car and gloves ignore the setting until 0.8, while crowded ones like person and helmet need at least 0.3.
Ranked by total loss the hardest 500 images average 20 objects each against 4 for the dataset (mostly pallet stacks), and the loss weights I tried keep 90 to 99 percent of that set. Dividing the loss by the object count selects an almost entirely different set (25 images in common) led by fire and the other low-AP classes. I would make that switch the miner's first configuration option.
Every decision in the report, the measurement it rests on, the requirement it serves, and the alternative I rejected.
The dataset is built from mosaic tiles rather than real warehouse frames, so the crowding and augmentation results describe that construction as much as a live camera. The timing (about 13 ms per frame against a 33 ms budget) is one laptop CPU, not the robot's.
Course repositories stay private under the university's rules, so this page summarizes my results and does not link the code.