Skip to main content

vision_rs/models/yolo/loss/
assign.rs

1/*
2 * Copyright 2026 Teenygrad
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17
18//! Simplified TaskAlignedAssigner (CPU-side).
19//!
20//! For each GT box, alignment scores are:
21//!   score = cls_score ^ alpha * iou ^ beta
22//!
23//! The top-k anchors per GT are selected as positives.  Conflicts (multiple GTs
24//! assigning the same anchor) are broken by highest alignment score.
25
26/// Result of assigning GT targets to anchors for a single image.
27pub struct AssignResult {
28    /// Whether each anchor is a positive. Shape: `[A]`.
29    pub is_positive: Vec<bool>,
30    /// GT box (cx, cy, w, h) assigned to each anchor (only valid for positives).
31    /// Shape: `[4, A]` channels-first.
32    pub target_boxes: Vec<f32>,
33    /// GT class index assigned to each anchor (only valid for positives).
34    /// Shape: `[A]`.
35    pub target_cls: Vec<usize>,
36    /// Soft target per anchor: `(align / max_align_for_gt) * max_iou_for_gt`.
37    ///
38    /// Zero for negatives.  Used as the soft class label AND box loss weight,
39    /// matching the ultralytics E2ELoss normalisation.  Shape: `[A]`.
40    pub soft_target: Vec<f32>,
41}
42
43/// Compute the IoU between a predicted XYWH box and a GT XYWH box.
44fn iou_xywh(p: [f32; 4], g: [f32; 4]) -> f32 {
45    let (px1, py1) = (p[0] - p[2] * 0.5, p[1] - p[3] * 0.5);
46    let (px2, py2) = (p[0] + p[2] * 0.5, p[1] + p[3] * 0.5);
47    let (gx1, gy1) = (g[0] - g[2] * 0.5, g[1] - g[3] * 0.5);
48    let (gx2, gy2) = (g[0] + g[2] * 0.5, g[1] + g[3] * 0.5);
49
50    let ix1 = px1.max(gx1);
51    let iy1 = py1.max(gy1);
52    let ix2 = px2.min(gx2);
53    let iy2 = py2.min(gy2);
54    let inter = (ix2 - ix1).max(0.0) * (iy2 - iy1).max(0.0);
55    let union = p[2] * p[3] + g[2] * g[3] - inter;
56    inter / (union + 1e-7)
57}
58
59/// TaskAlignedAssigner parameters.
60#[derive(Clone)]
61pub struct TaskAlignedAssigner {
62    /// Top-k anchors selected per GT.
63    pub top_k: usize,
64    /// Exponent on the predicted class confidence.
65    pub alpha: f32,
66    /// Exponent on the predicted IoU.
67    pub beta: f32,
68}
69
70impl Default for TaskAlignedAssigner {
71    fn default() -> Self {
72        Self { top_k: 8, alpha: 0.5, beta: 6.0 }
73    }
74}
75
76impl TaskAlignedAssigner {
77    /// Assign GT boxes to anchors for a **single image**.
78    ///
79    /// # Arguments
80    /// * `pred_xywh`   – decoded anchor predictions `[4, A]` channels-first
81    /// * `pred_scores` – per-anchor class logits `[nc, A]` channels-first (sigmoid applied internally)
82    /// * `anchor_cx`   – anchor centre x `[A]`
83    /// * `anchor_cy`   – anchor centre y `[A]`
84    /// * `gt_boxes`    – GT boxes `[[cx, cy, w, h]; M]`
85    /// * `gt_cls`      – GT class indices `[M]`
86    pub fn assign(
87        &self,
88        pred_xywh: &[f32],
89        pred_scores: &[f32],
90        anchor_cx: &[f32],
91        anchor_cy: &[f32],
92        gt_boxes: &[[f32; 4]],
93        gt_cls: &[usize],
94    ) -> AssignResult {
95        let a = anchor_cx.len();
96        let m = gt_boxes.len();
97        let nc = pred_scores.len() / a;
98
99        let mut target_boxes    = vec![0.0f32; 4 * a];
100        let mut target_cls      = vec![0usize; a];
101        let mut soft_target     = vec![0.0f32; a];
102        let mut assigned_score  = vec![-1.0f32; a]; // -1 = unassigned
103        let mut assigned_gt     = vec![0usize; a];
104        let mut is_positive     = vec![false; a];
105
106        if m == 0 {
107            return AssignResult { is_positive, target_boxes, target_cls, soft_target };
108        }
109
110        // Pre-compute sigmoid of predicted class scores.
111        let sig: Vec<f32> = pred_scores.iter().map(|&x| 1.0 / (1.0 + (-x).exp())).collect();
112
113        // Per-GT tracking for soft-target normalisation.
114        let mut gt_max_align = vec![0.0f32; m]; // max alignment score among top-k
115        let mut gt_max_iou   = vec![0.0f32; m]; // max IoU among top-k
116
117        // Flat list of (gt_idx, anchor_idx, align_score, iou) from top-k per GT.
118        let mut candidates: Vec<(usize, usize, f32, f32)> = Vec::new();
119
120        // Phase 1: per-GT top-k selection — collect candidates and track per-GT maxes.
121        for (gi, &gt_box) in gt_boxes.iter().enumerate() {
122            let gc = gt_cls[gi];
123            let [gx, gy, gw, gh] = gt_box;
124
125            let mut scores: Vec<(usize, f32, f32)> = (0..a).filter_map(|ai| {
126                let cx = anchor_cx[ai];
127                let cy = anchor_cy[ai];
128                if cx < gx - gw * 0.5 || cx > gx + gw * 0.5
129                    || cy < gy - gh * 0.5 || cy > gy + gh * 0.5
130                {
131                    return None;
132                }
133
134                let p = [pred_xywh[ai], pred_xywh[a+ai], pred_xywh[2*a+ai], pred_xywh[3*a+ai]];
135                let iou = iou_xywh(p, gt_box).max(0.0);
136                let cls_score = if gc < nc { sig[gc * a + ai] } else { 0.0 };
137                let align = cls_score.powf(self.alpha) * iou.powf(self.beta);
138                Some((ai, align, iou))
139            }).collect();
140
141            if scores.is_empty() { continue; }
142
143            scores.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
144            scores.truncate(self.top_k);
145
146            for &(ai, align, iou) in &scores {
147                if align > gt_max_align[gi] { gt_max_align[gi] = align; }
148                if iou   > gt_max_iou[gi]   { gt_max_iou[gi]   = iou; }
149                candidates.push((gi, ai, align, iou));
150            }
151        }
152
153        // Phase 2: conflict resolution — highest alignment score wins — and box assignment.
154        for (gi, ai, align, _iou) in &candidates {
155            if *align > assigned_score[*ai] {
156                assigned_score[*ai] = *align;
157                assigned_gt[*ai]    = *gi;
158                is_positive[*ai]    = true;
159                target_cls[*ai]     = gt_cls[*gi];
160                let gt_box = gt_boxes[*gi];
161                target_boxes[*ai]           = gt_box[0];
162                target_boxes[a  + *ai]      = gt_box[1];
163                target_boxes[2*a + *ai]     = gt_box[2];
164                target_boxes[3*a + *ai]     = gt_box[3];
165            }
166        }
167
168        // Phase 3: soft target = (align / max_align_for_gt) * max_iou_for_gt.
169        const EPS: f32 = 1e-8;
170        for ai in 0..a {
171            if is_positive[ai] {
172                let gi    = assigned_gt[ai];
173                let align = assigned_score[ai];
174                soft_target[ai] = (align / (gt_max_align[gi] + EPS)) * gt_max_iou[gi];
175            }
176        }
177
178        AssignResult { is_positive, target_boxes, target_cls, soft_target }
179    }
180}