Agents and perception · Medium · 20 min

Detect objects: IoU and NMS

A detector proposes 81 boxes for a desk with six things on it. Write the IoU and the non-maximum suppression under every YOLO and keep the ones that count.

DETECTIONS.taller and DETECTIONS.calle hold the raw boxes YOLOS-tiny proposed for two photos of ours: { label, score, box }, with box = [x1, y1, x2, y2] between 0 and 1. Many repeat. Write four functions:

iou(a, b): intersection over union of two boxes. 0 when they do not touch.

filterByScore(boxes, min): the boxes with score >= min, in the same order.

nms(boxes, limit): sort by score, highest first, and walk through: a box stays if no already accepted box **with the same label** overlaps it with iou > limit. Return the accepted ones in that order.

countByLabel(boxes): { label: how many } with the keys in alphabetical order. The preview draws the boxes over the photo and lets you move both thresholds.

Challenges 0/6

  • IoU: half overlapping, identical and apart
  • The confidence threshold keeps the order
  • NMS: three boxes of one person and a dog on top become two
  • The workshop desk at confidence 0.5 and IoU 0.5
  • The street: two people, a dog, a car, a bike
  • With the model's boxes (your photo) or the workshop desk: in order and without duplicates

function iou(a, b) {
  // intersection area / (areaA + areaB - intersection); 0 when they do not touch
  return 0;
}

function filterByScore(boxes, min) {
  // score >= min, same order
  return boxes;
}

function nms(boxes, limit) {
  // highest score first; keep a box unless an accepted box with the same label overlaps it with iou > limit
  return boxes;
}

function countByLabel(boxes) {
  // { label: count }, keys sorted
  return {};
}

console.log(countByLabel(nms(filterByScore(DETECTIONS.taller.boxes, 0.5), 0.5)));
Console output appears here (console.log).

The idea

A detector does not return "a laptop". It returns hundreds of proposals, each with a label, a confidence and a box, and many point at the same object. Two steps sort that out: a confidence threshold, which drops the doubtful ones, and non-maximum suppression (NMS), which keeps the best of each group of overlapping boxes. IoU (intersection over union) is the rule for "overlapping".

What we measured: quantised YOLOS-tiny (9.7 MB) took 711 ms per 1024 × 768 photo on the DGX Spark CPU on 2026-10-18, with transformers.js 3.8.1. On the workshop desk it proposed 81 boxes with confidence ≥ 0.05; at threshold 0.5 and IoU 0.5 what remains is what the preview shows. It also gets things wrong: it calls the spanners on the wall "knife", counts ten "book" among notebooks and folders, and sees four "cup" where there is one mug, a pen holder and a speaker. We generated the photos with FLUX.1-schnell.

You can run it yourself on this page: the "A model in your browser" box loads YOLOS-tiny with ONNX Runtime Web (24 MB, all from this site) and in the browser on the DGX Spark it took about 730 ms per photo on 2026-10-19. Its raw boxes, for a sample or for your own photo, enter the exercise as a third scene.

The IoU threshold is a business decision, not a magic number. At 0.3 you lose objects that touch (two chairs side by side); at 0.7 you keep duplicates. Look at the preview with each value before you fix it.

All of this runs with no GPU and no cloud. A 10 MB detector in the browser or on a mini PC is enough to count parts on a shelf or spot a gap on a line, and the image never leaves the place where it was taken.

Run it on your machine

With Python and NumPy, on this exercise's raw boxes (CC BY 4.0):

curl -O https://vorluxai.com/data/labs/detecciones-taller.json
python3 -c "
import json, numpy as np
boxes = [b for b in json.load(open('detecciones-taller.json'))['boxes'] if b['score'] >= 0.5]
def iou(a, b):
    ix = max(0, min(a[2], b[2]) - max(a[0], b[0])); iy = max(0, min(a[3], b[3]) - max(a[1], b[1]))
    inter = ix * iy; return inter / ((a[2]-a[0])*(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - inter)
kept = []
for b in sorted(boxes, key=lambda b: -b['score']):
    if not any(k['label'] == b['label'] and iou(k['box'], b['box']) > 0.5 for k in kept): kept.append(b)
print(len(boxes), 'cajas ->', len(kept), 'tras NMS:', sorted(k['label'] for k in kept))
"

Check what you learned

1. Why does a detector propose several boxes for one object?
2. Two boxes with different labels overlap by 90 %. What does this exercise's NMS do?
3. What happens when the IoU threshold goes from 0.5 to 0.8?
4. The detector calls the spanners on the wall "knife". What do you do in production?

Go deeper: the Hugging Face reference →

This in production, with your data? Let's talk for 15 minutes →