LAB16: Computer Vision with YOLO

Non-Maximum Suppression Step-by-Step

Understand how NMS filters overlapping bounding boxes in object detection.

Concept from LAB16

See Section 16.2: YOLO Detection Pipeline in the PDF book.

Code
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as patches

def iou(box1, box2):
    """Calculate Intersection over Union"""
    x1 = max(box1[0], box2[0])
    y1 = max(box1[1], box2[1])
    x2 = min(box1[0] + box1[2], box2[0] + box2[2])
    y2 = min(box1[1] + box1[3], box2[1] + box2[3])

    intersection = max(0, x2 - x1) * max(0, y2 - y1)
    area1 = box1[2] * box1[3]
    area2 = box2[2] * box2[3]
    union = area1 + area2 - intersection

    return intersection / union if union > 0 else 0

def nms(boxes, scores, iou_threshold=0.5):
    """Non-Maximum Suppression"""
    indices = np.argsort(scores)[::-1]  # Sort by confidence
    keep = []

    while len(indices) > 0:
        current = indices[0]
        keep.append(current)

        remaining = []
        for idx in indices[1:]:
            if iou(boxes[current], boxes[idx]) < iou_threshold:
                remaining.append(idx)
        indices = remaining

    return keep

# Sample detections (x, y, w, h)
boxes = np.array([
    [50, 50, 100, 120],   # High confidence
    [55, 48, 95, 115],    # Overlapping
    [60, 52, 90, 110],    # Overlapping
    [200, 100, 80, 100],  # Separate detection
    [205, 105, 75, 95],   # Overlapping with above
])
scores = np.array([0.95, 0.88, 0.82, 0.90, 0.75])
labels = ['person', 'person', 'person', 'dog', 'dog']

# Apply NMS
kept_indices = nms(boxes, scores, iou_threshold=0.5)

# Visualization
fig, axes = plt.subplots(1, 3, figsize=(15, 5))

colors = plt.cm.Set1(np.linspace(0, 1, len(boxes)))

# Before NMS
ax = axes[0]
ax.set_xlim(0, 350)
ax.set_ylim(0, 300)
ax.set_aspect('equal')
ax.set_title(f'Before NMS\n({len(boxes)} boxes)')

for i, (box, score, label) in enumerate(zip(boxes, scores, labels)):
    rect = patches.Rectangle((box[0], box[1]), box[2], box[3],
                             linewidth=2, edgecolor=colors[i],
                             facecolor='none', linestyle='--')
    ax.add_patch(rect)
    ax.text(box[0], box[1] - 5, f'{label}: {score:.2f}',
           fontsize=8, color=colors[i])
ax.invert_yaxis()

# IoU Calculation
ax = axes[1]
ax.set_xlim(0, 350)
ax.set_ylim(0, 300)
ax.set_aspect('equal')
ax.set_title('IoU Calculation')

# Show IoU between first two boxes
box1, box2 = boxes[0], boxes[1]
rect1 = patches.Rectangle((box1[0], box1[1]), box1[2], box1[3],
                          linewidth=2, edgecolor='blue', facecolor='blue', alpha=0.3)
rect2 = patches.Rectangle((box2[0], box2[1]), box2[2], box2[3],
                          linewidth=2, edgecolor='red', facecolor='red', alpha=0.3)
ax.add_patch(rect1)
ax.add_patch(rect2)

# Intersection
x1 = max(box1[0], box2[0])
y1 = max(box1[1], box2[1])
x2 = min(box1[0] + box1[2], box2[0] + box2[2])
y2 = min(box1[1] + box1[3], box2[1] + box2[3])
if x2 > x1 and y2 > y1:
    intersection = patches.Rectangle((x1, y1), x2-x1, y2-y1,
                                     linewidth=2, edgecolor='green',
                                     facecolor='green', alpha=0.5)
    ax.add_patch(intersection)

iou_val = iou(box1, box2)
ax.text(150, 250, f'IoU = {iou_val:.2f}', fontsize=12, ha='center')
ax.text(150, 270, 'Suppressed (IoU > 0.5)', fontsize=10, ha='center', color='red')
ax.invert_yaxis()

# After NMS
ax = axes[2]
ax.set_xlim(0, 350)
ax.set_ylim(0, 300)
ax.set_aspect('equal')
ax.set_title(f'After NMS\n({len(kept_indices)} boxes)')

for i in kept_indices:
    box = boxes[i]
    rect = patches.Rectangle((box[0], box[1]), box[2], box[3],
                             linewidth=3, edgecolor=colors[i],
                             facecolor=colors[i], alpha=0.3)
    ax.add_patch(rect)
    ax.text(box[0], box[1] - 5, f'{labels[i]}: {scores[i]:.2f}',
           fontsize=10, color=colors[i], fontweight='bold')
ax.invert_yaxis()

plt.tight_layout()
plt.show()
Figure 25.1: Non-Maximum Suppression visualization

NMS Algorithm

def nms(boxes, scores, iou_threshold):
    # 1. Sort boxes by confidence (descending)
    indices = argsort(scores, descending=True)
    keep = []

    while indices not empty:
        # 2. Keep highest confidence box
        best = indices[0]
        keep.append(best)

        # 3. Remove boxes with high IoU overlap
        remaining = []
        for idx in indices[1:]:
            if IoU(boxes[best], boxes[idx]) < threshold:
                remaining.append(idx)

        indices = remaining

    return keep

IoU Threshold Effect

Code
thresholds = [0.3, 0.5, 0.7, 0.9]
fig, axes = plt.subplots(1, 4, figsize=(16, 4))

for ax, thresh in zip(axes, thresholds):
    kept = nms(boxes, scores, iou_threshold=thresh)

    ax.set_xlim(0, 350)
    ax.set_ylim(0, 300)
    ax.set_aspect('equal')
    ax.set_title(f'IoU threshold = {thresh}\n{len(kept)} boxes kept')

    for i in kept:
        box = boxes[i]
        rect = patches.Rectangle((box[0], box[1]), box[2], box[3],
                                 linewidth=2, edgecolor=colors[i],
                                 facecolor=colors[i], alpha=0.3)
        ax.add_patch(rect)
    ax.invert_yaxis()

plt.tight_layout()
plt.show()
Figure 25.2: Effect of IoU threshold on NMS results
IoU Threshold Effect
Low (0.3) Aggressive suppression, may remove valid boxes
Medium (0.5) Standard choice, good balance
High (0.7) More boxes kept, may have duplicates