Small Target Label Assignment (STAL)

Posted by Rico's Nerd Cluster on April 16, 2026

STAL

STAL = Small-Target-Aware Label Assignment. It is a training-time matching rule for object detectors: it decides which predicted boxes / anchors should be treated as positive samples for each ground-truth object.

The problem it solves: standard label assignment can drop very small objects because their IoU with candidate anchors is often low, even when the anchor is visually near the object. Ultralytics describes STAL in YOLO26 as a modification of TAL that keeps tiny objects from being ignored during training. (Ultralytics)

In short, STAL aims to increase positive supervision for tiny objects during training, which improves small-object recall and training stability.

STAL does not change inference directly. It changes how positives are assigned during training.

\[\operatorname{STAL} \approx \operatorname{TAL} + \operatorname{scale\text{-}adaptive\ IoU\ thresholding}\]

STAL intuition


Why dynamic IoU thresholding

Fixed IoU thresholds are harsh on small objects. A shift of 1-2 pixels changes IoU much more for a $6 \times 6$ box than for a $100 \times 100$ box.

STAL addresses this by lowering the IoU acceptance threshold for smaller objects. (arXiv)

\[τ_j = τ_{\min} + (τ_{\max} - τ_{\min}) \sqrt{\frac{A_j}{A_{\mathrm{img}}}}\]

where:

\[A_j = \operatorname{area\ of\ GT\ object}\ j, \qquad A_{\mathrm{img}} = \operatorname{image\ area}\]

For small objects, $\tau_j$ becomes lower than the base threshold. This allows spatially reasonable low-IoU matches to remain trainable positives.

Example intuition:

1
2
3
large object: tau ~ 0.50
medium object: tau ~ 0.35
tiny object: tau ~ 0.10

STAL pseudocode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def STAL_assign(
    pred_scores,       # [N, C]
    pred_boxes,        # [N, 4]
    anchor_points,     # [N, 2]
    gt_boxes,          # [M, 4]
    gt_labels,         # [M]
    image_w,
    image_h,
    top_k=10,
    alpha=1.0,
    beta=6.0,
    tau_min=0.10,
    tau_max=0.50,
    small_size_thr=8,
    min_small_pos=4,
):
    """
    STAL = Task-Aligned Label Assignment
           + dynamic IoU threshold for small targets
           + optional minimum-positive guarantee for tiny objects.
    """

    N = len(pred_boxes)
    M = len(gt_boxes)
    C = pred_scores.shape[1]

    assigned_gt_idx = [-1 for _ in range(N)]
    assigned_metric = [0.0 for _ in range(N)]

    target_labels = ["background" for _ in range(N)]
    target_boxes = [[0, 0, 0, 0] for _ in range(N)]
    target_scores = [[0.0 for _ in range(C)] for _ in range(N)]

    image_area = image_w * image_h

    if M == 0:
        return assigned_gt_idx, target_labels, target_boxes, target_scores

    # IoU between every GT and prediction
    ious = compute_iou_matrix(gt_boxes, pred_boxes)  # [M, N]

    positive_pairs = []

    for j in range(M):
        gt = gt_boxes[j]
        cls = gt_labels[j]

        x1, y1, x2, y2 = gt
        gt_w = x2 - x1
        gt_h = y2 - y1
        gt_area = gt_w * gt_h

        # --------------------------------------------------------
        # 1. Dynamic IoU threshold
        # --------------------------------------------------------
        area_ratio = gt_area / image_area

        # One possible scale-aware threshold.
        # Small area_ratio -> threshold close to tau_min.
        # Large area_ratio -> threshold closer to tau_max.
        tau_j = tau_min + (tau_max - tau_min) * sqrt(area_ratio)

        # Optional clamp
        tau_j = max(tau_min, min(tau_j, tau_max))

        is_small = min(gt_w, gt_h) < small_size_thr

        candidates = []

        for i in range(N):
            ax, ay = anchor_points[i]

            # Candidate center condition
            inside_gt = (x1 <= ax <= x2) and (y1 <= ay <= y2)

            if not inside_gt:
                continue

            iou = ious[j][i]

            # ----------------------------------------------------
            # 2. STAL thresholding:
            #    use dynamic threshold instead of fixed IoU cutoff
            # ----------------------------------------------------
            if iou < tau_j:
                continue

            s = pred_scores[i][cls]

            # TAL metric
            task_metric = (s ** alpha) * (iou ** beta)

            candidates.append((i, task_metric, iou))

        # --------------------------------------------------------
        # 3. Select top-k using TAL metric
        # --------------------------------------------------------
        candidates.sort(key=lambda x: x[1], reverse=True)
        selected = candidates[:top_k]

        # --------------------------------------------------------
        # 4. Small-object fallback:
        #    if dynamic threshold still gives too few positives,
        #    force at least min_small_pos.
        # --------------------------------------------------------
        if is_small and len(selected) < min_small_pos:
            selected = add_small_object_fallback_candidates(
                selected=selected,
                gt_box=gt,
                gt_label=cls,
                pred_scores=pred_scores,
                pred_boxes=pred_boxes,
                anchor_points=anchor_points,
                ious=ious[j],
                alpha=alpha,
                beta=beta,
                min_pos=min_small_pos,
            )

        for i, task_metric, iou in selected:
            positive_pairs.append((j, i, task_metric, iou))

    # ------------------------------------------------------------
    # 5. Resolve conflicts:
    #    if one anchor is assigned to multiple GTs, keep best IoU
    # ------------------------------------------------------------
    for j, i, task_metric, iou in positive_pairs:
        old_j = assigned_gt_idx[i]

        if old_j == -1:
            assigned_gt_idx[i] = j
            assigned_metric[i] = task_metric
        else:
            old_iou = ious[old_j][i]

            if iou > old_iou:
                assigned_gt_idx[i] = j
                assigned_metric[i] = task_metric

    # ------------------------------------------------------------
    # 6. Build targets
    # ------------------------------------------------------------
    for i in range(N):
        j = assigned_gt_idx[i]

        if j == -1:
            continue

        cls = gt_labels[j]

        target_labels[i] = cls
        target_boxes[i] = gt_boxes[j]
        target_scores[i][cls] = assigned_metric[i]

    return assigned_gt_idx, target_labels, target_boxes, target_scores

Optional fallback for tiny objects

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def add_small_object_fallback_candidates(
    selected,
    gt_box,
    gt_label,
    pred_scores,
    pred_boxes,
    anchor_points,
    ious,
    alpha,
    beta,
    min_pos,
):
    selected_ids = set(i for i, _, _ in selected)

    gx, gy = box_center(gt_box)

    fallback = []

    for i in range(len(pred_boxes)):
        if i in selected_ids:
            continue

        ax, ay = anchor_points[i]

        dist = squared_distance((ax, ay), (gx, gy))
        iou = ious[i]
        s = pred_scores[i][gt_label]

        task_metric = (s ** alpha) * (iou ** beta)

        # For tiny objects, center proximity can rescue candidates
        # whose IoU is low because of pixel discretization.
        fallback_score = task_metric - 0.01 * dist

        fallback.append((i, fallback_score, task_metric, iou))

    fallback.sort(key=lambda x: x[1], reverse=True)

    for i, _, task_metric, iou in fallback:
        if len(selected) >= min_pos:
            break

        selected.append((i, task_metric, iou))

    return selected

Tiny numeric example

Suppose:

\[\tau_{\max} = 0.50, \qquad \tau_{\min} = 0.10\]

and the image is $640 \times 640$.

Large object

1
2
3
GT size = 160 x 160
area ratio = 25600 / 409600 = 0.0625
sqrt(area ratio) = 0.25

Then:

\[τ = 0.10 + (0.50 - 0.10)(0.25) = 0.20\]

So a candidate needs IoU at least $0.20$.

Tiny object

1
2
3
GT size = 8 x 8
area ratio = 64 / 409600 = 0.000156
sqrt(area ratio) = 0.0125

Then:

\[τ = 0.10 + (0.50 - 0.10)(0.0125) = 0.105\]

So a candidate with IoU $0.15$ is accepted.

With a fixed threshold of $0.50$, that candidate would be rejected.

This is the key STAL difference:

\[\operatorname{fixed\ threshold:}\ \operatorname{IoU} > 0.5\] \[\operatorname{STAL:}\ \operatorname{IoU} > τ(\operatorname{object\ size})\]

For small objects:

\[τ(\operatorname{small\ object}) \ll 0.5\]

Summary

STAL keeps TAL’s task-aligned ranking but adds scale-adaptive IoU thresholding so tiny objects are not systematically filtered out. In practice, this yields stronger supervision for small targets and better recall in small-object regimes.