Implementing Anchor-Based Object Detection with PyTorch

Detection Pipeline Overview

Anchor-driven detection frameworks operate through a standardized sequence. First, a dense grid of reference regions is synthesized across the input tensor. Second, a classifier evaluates each reference region for target presence. Third, a regressor adjusts the coordinates of positive regions to align with ground-truth boundaries.

Spatial Overlap and IoU

Spatial overlap between two rectengular regions is quantified using the Intersection over Union (IoU) metric. This ratio divides the overlapping area by the total combined area, providing a normalized similarity score between 0 and 1. It serves as the primary criterion for matching predictions to actual objects and filtering redundant detections.

Ancher Labeling Strategy

Every generated region functions as an independent training instance. Labels are assigned by mapping each region to either a background class or a specific ground-truth rectangle. Due to the dense generation strategy, the dataset natural skews heavily toward negative samples. During training, each anchor requires two distinct targets: a categorical label and a normalized coordinate offset relative to the matched ground truth.

Filtering Predictions with NMS

During inference, overlapping predictions for the same object are filtered using Non-Maximum Suppression (NMS). This greedy algorithm retains the highest-confidence prediction and discards neighboring boxes that exceed a predefined overlap threshold, ensuring a single output per detected object.

Core Implementation

The following modules provide a complete, standalone implementation of the anchor generation, matching, encoding, and inference pipeline using PyTorch.

1. Anchor Grid Generation

import torch
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle

def generate_anchor_grid(feature_map, scales, aspect_ratios):
    """Create a dense set of reference boxes centered on each pixel."""
    h, w = feature_map.shape[-2:]
    device = feature_map.device
    n_scales, n_ratios = len(scales), len(aspect_ratios)
    anchors_per_pixel = n_scales + n_ratios - 1

    scale_t = torch.tensor(scales, device=device)
    ratio_t = torch.tensor(aspect_ratios, device=device)

    # Normalize coordinates to [0, 1]
    step_y, step_x = 1.0 / h, 1.0 / w
    cy = (torch.arange(h, device=device) + 0.5) * step_y
    cx = (torch.arange(w, device=device) + 0.5) * step_x
    grid_y, grid_x = torch.meshgrid(cy, cx, indexing='ij')
    grid_y, grid_x = grid_y.reshape(-1), grid_x.reshape(-1)

    # Compute normalized widths and heights
    norm_w = torch.cat([scale_t * torch.sqrt(ratio_t[0]),
                        scales[0] * torch.sqrt(ratio_t[1:])]) * h / w
    norm_h = torch.cat([scale_t / torch.sqrt(ratio_t[0]),
                        scales[0] / torch.sqrt(ratio_t[1:])])

    # Half dimensions for corner calculation
    half_dims = torch.stack([-norm_w, -norm_h, norm_w, norm_h], dim=1).repeat(h * w, 1) / 2.0

    # Broadcast center coordinates
    centers = torch.stack([grid_x, grid_y, grid_x, grid_y], dim=1).repeat_interleave(anchors_per_pixel, dim=0)
    return (centers + half_dims).unsqueeze(0)

2. Bounding Box Visualization

def render_bounding_boxes(ax, box_coords, cls_names=None, palette=None):
    """Overlay rectangular regions on a matplotlib axis."""
    def ensure_list(val, fallback=None):
        if val is None: return fallback or []
        return val if isinstance(val, (list, tuple)) else [val]

    cls_names = ensure_list(cls_names)
    palette = ensure_list(palette, ['#1f77b4', '#2ca02c', '#d62728', '#9467bd', '#17becf'])

    for idx, coords in enumerate(box_coords):
        color = palette[idx % len(palette)]
        x_min, y_min, x_max, y_max = coords.detach().cpu().numpy()
        rect = Rectangle((x_min, y_min), x_max - x_min, y_max - y_min,
                         fill=False, edgecolor=color, linewidth=2)
        ax.add_patch(rect)

        if cls_names and idx < len(cls_names):
            txt_color = 'white' if color == '#000000' else 'black'
            ax.text(x_min, y_min, cls_names[idx], fontsize=8, color=txt_color,
                    bbox=dict(facecolor=color, edgecolor='none', pad=2))

3. IoU Matrix Computation

def compute_iou_matrix(set_a, set_b):
    """Calculate pairwise Intersection over Union between two box collections."""
    def area(boxes):
        return (boxes[:, 2] - boxes[:, 0]).clamp(min=0) * (boxes[:, 3] - boxes[:, 1]).clamp(min=0)

    area_a = area(set_a)
    area_b = area(set_b)

    # Intersection coordinates via broadcasting: (N, 1, 2) vs (1, M, 2)
    tl = torch.max(set_a[:, None, :2], set_b[:, :2])
    br = torch.min(set_a[:, None, 2:], set_b[:, 2:])
    inter_wh = (br - tl).clamp(min=0)
    inter_area = inter_wh[:, :, 0] * inter_wh[:, :, 1]

    union_area = area_a[:, None] + area_b[None, :] - inter_area
    return inter_area / union_area.clamp(min=1e-9)

4. Ground Truth Assignment

def match_anchors_to_ground_truth(gt_boxes, prior_boxes, device, match_thresh=0.5):
    """Assign each prior box to a ground-truth rectangle based on overlap."""
    n_priors, n_gt = prior_boxes.shape[0], gt_boxes.shape[0]
    iou_mat = compute_iou_matrix(prior_boxes, gt_boxes)

    assignment = torch.full((n_priors,), -1, dtype=torch.long, device=device)

    # 1. Assign priors that exceed the threshold
    max_iou, max_idx = torch.max(iou_mat, dim=1)
    valid_mask = max_iou >= match_thresh
    assignment[valid_mask] = max_idx[valid_mask]

    # 2. Ensure every GT box gets at least one prior (greedy max overlap)
    col_mask = torch.full((n_priors,), -1, device=device)
    row_mask = torch.full((n_gt,), -1, device=device)

    for _ in range(n_gt):
        flat_idx = torch.argmax(iou_mat)
        gt_idx = flat_idx % n_gt
        prior_idx = flat_idx // n_gt
        assignment[prior_idx] = gt_idx
        iou_mat[:, gt_idx] = col_mask
        iou_mat[prior_idx, :] = row_mask

    return assignment

5. Target Encoding and Preparation

def encode_box_offsets(priors, matched_gt, epsilon=1e-6):
    """Transform coordinate differences into normalized regression targets."""
    priors_cwh = torch.cat([(priors[:, :2] + priors[:, 2:]) / 2, priors[:, 2:] - priors[:, :2]], dim=1)
    gt_cwh = torch.cat([(matched_gt[:, :2] + matched_gt[:, 2:]) / 2, matched_gt[:, 2:] - matched_gt[:, :2]], dim=1)

    delta_xy = 10.0 * (gt_cwh[:, :2] - priors_cwh[:, :2]) / priors_cwh[:, 2:]
    delta_wh = 5.0 * torch.log(epsilon + gt_cwh[:, 2:] / priors_cwh[:, 2:])
    return torch.cat([delta_xy, delta_wh], dim=1)

def prepare_detection_targets(base_priors, batch_gt):
    """Generate classification labels and regression masks for a batch."""
    batch_sz = batch_gt.shape[0]
    priors = base_priors.squeeze(0)
    device, n_priors = priors.device, priors.shape[0]

    batch_deltas, batch_masks, batch_labels = [], [], []

    for b in range(batch_sz):
        gt = batch_gt[b]
        mapping = match_anchors_to_ground_truth(gt[:, 1:], priors, device)

        pos_mask = (mapping >= 0).float().unsqueeze(-1).repeat(1, 4)
        cls_tgt = torch.zeros(n_priors, dtype=torch.long, device=device)
        coord_tgt = torch.zeros((n_priors, 4), dtype=torch.float32, device=device)

        pos_indices = torch.nonzero(mapping >= 0).squeeze(-1)
        gt_indices = mapping[pos_indices]

        cls_tgt[pos_indices] = gt[gt_indices, 0].long() + 1
        coord_tgt[pos_indices] = gt[gt_indices, 1:]

        deltas = encode_box_offsets(priors, coord_tgt) * pos_mask
        batch_deltas.append(deltas.reshape(-1))
        batch_masks.append(pos_mask.reshape(-1))
        batch_labels.append(cls_tgt)

    return torch.stack(batch_deltas), torch.stack(batch_masks), torch.stack(batch_labels)

6. Prediction Decoding

def decode_box_predictions(priors, reg_outputs):
    """Revert normalized offsets back to absolute corner coordinates."""
    priors_cwh = torch.cat([(priors[:, :2] + priors[:, 2:]) / 2, priors[:, 2:] - priors[:, :2]], dim=1)

    pred_xy = (reg_outputs[:, :2] * priors_cwh[:, 2:] / 10.0) + priors_cwh[:, :2]
    pred_wh = torch.exp(reg_outputs[:, 2:] / 5.0) * priors_cwh[:, 2:]

    pred_cwh = torch.cat([pred_xy, pred_wh], dim=1)
    half_wh = pred_cwh[:, 2:] / 2
    return torch.cat([pred_cwh[:, :2] - half_wh, pred_cwh[:, :2] + half_wh], dim=1)

7. Non-Maximum Suppression

def apply_non_max_suppression(coords, confidences, overlap_limit):
    """Filter overlapping detections using a greedy confidence-based approach."""
    sorted_idx = torch.argsort(confidences, descending=True)
    retained = []

    while sorted_idx.numel() > 0:
        top = sorted_idx[0]
        retained.append(top)
        if sorted_idx.numel() == 1:
            break

        current_box = coords[top].unsqueeze(0)
        remaining_boxes = coords[sorted_idx[1:]]
        iou_vals = compute_iou_matrix(current_box, remaining_boxes).squeeze(0)

        keep_mask = iou_vals <= overlap_limit
        sorted_idx = sorted_idx[1:][keep_mask]

    return torch.tensor(retained, device=coords.device, dtype=torch.long)

8. Inference Pipeline

def run_detection_pipeline(class_probs, box_deltas, base_priors, nms_thresh=0.5, conf_thresh=0.01):
    """Execute full inference: decode, suppress overlaps, and threshold predictions."""
    device, batch_sz = class_probs.device, class_probs.shape[0]
    priors = base_priors.squeeze(0)
    n_anchors = class_probs.shape[2]
    results = []

    for b in range(batch_sz):
        probs = class_probs[b]
        deltas = box_deltas[b].reshape(-1, 4)

        max_conf, pred_cls = torch.max(probs[1:], dim=0)
        decoded_boxes = decode_box_predictions(priors, deltas)

        kept_idx = apply_non_max_suppression(decoded_boxes, max_conf, nms_thresh)

        all_idx = torch.arange(n_anchors, device=device)
        combined = torch.cat([kept_idx, all_idx])
        _, counts = torch.unique(combined, return_counts=True)
        suppressed_idx = combined[counts == 1]

        final_order = torch.cat([kept_idx, suppressed_idx])
        pred_cls[suppressed_idx] = -1
        pred_cls = pred_cls[final_order]
        max_conf = max_conf[final_order]
        decoded_boxes = decoded_boxes[final_order]

        low_conf_mask = max_conf < conf_thresh
        pred_cls[low_conf_mask] = -1
        max_conf[low_conf_mask] = 1.0 - max_conf[low_conf_mask]

        batch_out = torch.cat([pred_cls.unsqueeze(1), max_conf.unsqueeze(1), decoded_boxes], dim=1)
        results.append(batch_out)

    return torch.stack(results)

Tags: object-detection pytorch anchor-boxes computer-vision non-maximum-suppression

Posted on Wed, 23 Sep 2026 16:09:03 +0000 by Thikho