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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
use std::collections::{HashMap, HashSet};
use rayon::prelude::*;
use crate::params::Params;
use super::COCOeval;
use super::types::{AccumulatedEval, EvalImg, EvalShape};
/// Accumulate per-image eval results into precision/recall arrays.
///
/// When `img_filter` is `Some`, only eval_imgs whose `image_id` is in the set
/// are included. Pass `None` to include all images (standard behavior).
pub(super) fn accumulate_impl(
eval_imgs: &[Option<EvalImg>],
params: &Params,
img_filter: Option<&HashSet<u64>>,
) -> AccumulatedEval {
let t = params.iou_thrs.len();
let r = params.rec_thrs.len();
let k = if params.use_cats {
params.cat_ids.len()
} else {
1
};
let a = params.area_ranges.len();
let m = params.max_dets.len();
// Build category_id → k_idx mapping for grouping eval_imgs.
let cat_id_to_k_idx: HashMap<u64, usize> = if params.use_cats {
params
.cat_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect()
} else {
std::iter::once((u64::MAX, 0usize)).collect()
};
// Build area_range → index lookup using bit-exact f64 keys (avoids linear search).
// range values are copied verbatim from params, so bit-exact equality is safe.
let area_rng_to_idx: HashMap<[u64; 2], usize> = params
.area_ranges
.iter()
.enumerate()
.map(|(i, ar)| ([ar.range[0].to_bits(), ar.range[1].to_bits()], i))
.collect();
// Group eval_imgs by (k_idx, a_idx) — O(eval_imgs) once.
// Replaces the old dense index formula k_actual * (a * N) + a_idx * N + img_idx,
// which assumed a specific dense layout that no longer applies after the sparse refactor.
let mut grouped: Vec<Vec<&EvalImg>> = vec![Vec::new(); k * a];
for eval in eval_imgs.iter().flatten() {
if let Some(filter) = img_filter {
if !filter.contains(&eval.image_id) {
continue;
}
}
if let Some(&k_idx) = cat_id_to_k_idx.get(&eval.category_id) {
let a_key = [eval.area_rng[0].to_bits(), eval.area_rng[1].to_bits()];
let a_idx = match area_rng_to_idx.get(&a_key).copied() {
Some(idx) => idx,
None => continue, // skip eval results with area ranges not in current params
};
grouped[k_idx * a + a_idx].push(eval);
}
}
// Build flat list of (k_idx, a_idx, m_idx) work items
let work_items: Vec<(usize, usize, usize)> = (0..k)
.flat_map(|k_idx| {
(0..a).flat_map(move |a_idx| (0..m).map(move |m_idx| (k_idx, a_idx, m_idx)))
})
.collect();
// Each work item produces a set of (index, value) writes for precision, recall, scores
/// Intermediate results from a single (category, area_range, max_det) work item.
/// Each field is a list of (flat_index, value) pairs to write into the output arrays.
struct AccResult {
precision_writes: Vec<(usize, f64)>,
recall_writes: Vec<(usize, f64)>,
scores_writes: Vec<(usize, f64)>,
}
let shape = EvalShape { t, r, k, a, m };
let results: Vec<AccResult> = work_items
.par_iter()
.map(|&(k_idx, a_idx, m_idx)| {
let max_det = params.max_dets[m_idx];
let evals = &grouped[k_idx * a + a_idx];
let total_dts: usize = evals.iter().map(|e| e.dt_scores.len().min(max_det)).sum();
let mut all_dt_scores: Vec<f64> = Vec::with_capacity(total_dts);
let mut all_dt_matched: Vec<Vec<bool>> =
(0..t).map(|_| Vec::with_capacity(total_dts)).collect();
let mut all_dt_ignore: Vec<Vec<bool>> =
(0..t).map(|_| Vec::with_capacity(total_dts)).collect();
let mut num_gt = 0usize;
for eval_img in evals {
let nd = eval_img.dt_scores.len().min(max_det);
all_dt_scores.extend_from_slice(&eval_img.dt_scores[..nd]);
for t_idx in 0..t {
all_dt_matched[t_idx].extend_from_slice(&eval_img.dt_matched[t_idx][..nd]);
all_dt_ignore[t_idx].extend_from_slice(&eval_img.dt_ignore[t_idx][..nd]);
}
num_gt += eval_img.gt_ignore.iter().filter(|&&x| !x).count();
}
let mut precision_writes = Vec::new();
let mut recall_writes = Vec::new();
let mut scores_writes = Vec::new();
if num_gt == 0 {
return AccResult {
precision_writes,
recall_writes,
scores_writes,
};
}
// Initialize precision, recall, and scores to 0.0 (distinct from -1.0 which
// means "no data"). This ensures categories with GT but no matches show 0 AP,
// not "missing". Only recall thresholds reached by actual detections get
// overwritten — unreachable thresholds stay at 0.0.
for t_idx in 0..t {
for r_idx in 0..r {
let p_idx = shape.precision_idx(t_idx, r_idx, k_idx, a_idx, m_idx);
precision_writes.push((p_idx, 0.0));
scores_writes.push((p_idx, 0.0));
}
}
// Sort by score descending
let mut inds: Vec<usize> = (0..all_dt_scores.len()).collect();
inds.sort_by(|&a, &b| {
all_dt_scores[b]
.partial_cmp(&all_dt_scores[a])
.unwrap_or(std::cmp::Ordering::Equal)
});
let nd = inds.len();
if nd == 0 {
// GT exists but no detections — recall is 0.0 (not -1.0 "missing").
for t_idx in 0..t {
let recall_idx = shape.recall_idx(t_idx, k_idx, a_idx, m_idx);
recall_writes.push((recall_idx, 0.0));
}
return AccResult {
precision_writes,
recall_writes,
scores_writes,
};
}
// Hoist sorted_scores outside the threshold loop (identical across thresholds)
let sorted_scores: Vec<f64> = inds.iter().map(|&i| all_dt_scores[i]).collect();
// Pre-allocate TP/FP buffers reused across thresholds.
let mut tp = vec![0.0f64; nd];
let mut fp = vec![0.0f64; nd];
for t_idx in 0..t {
// Classify each detection (in score-sorted order) as TP, FP, or ignored.
// Ignored detections contribute neither TP nor FP.
for (out_idx, &src_idx) in inds.iter().enumerate() {
if all_dt_ignore[t_idx][src_idx] {
tp[out_idx] = 0.0;
fp[out_idx] = 0.0;
} else if all_dt_matched[t_idx][src_idx] {
tp[out_idx] = 1.0;
fp[out_idx] = 0.0;
} else {
tp[out_idx] = 0.0;
fp[out_idx] = 1.0;
}
}
// Cumulative sum: tp[d] = total TPs up to detection d.
for d in 1..nd {
tp[d] += tp[d - 1];
fp[d] += fp[d - 1];
}
let (final_recall, curve) = crate::primitives::counts::precision_recall_curve(
&tp,
&fp,
num_gt,
¶ms.rec_thrs,
);
let recall_idx = shape.recall_idx(t_idx, k_idx, a_idx, m_idx);
recall_writes.push((recall_idx, final_recall));
for (r_idx, pr_val, rc_ptr) in curve {
let p_idx = shape.precision_idx(t_idx, r_idx, k_idx, a_idx, m_idx);
precision_writes.push((p_idx, pr_val));
scores_writes.push((p_idx, sorted_scores[rc_ptr]));
}
}
AccResult {
precision_writes,
recall_writes,
scores_writes,
}
})
.collect();
// Merge results into output arrays
let total = t * r * k * a * m;
let mut precision = vec![-1.0f64; total];
let mut scores = vec![-1.0f64; total];
let total_recall = t * k * a * m;
let mut recall = vec![-1.0f64; total_recall];
for result in results {
for (idx, val) in result.precision_writes {
precision[idx] = val;
}
for (idx, val) in result.recall_writes {
recall[idx] = val;
}
for (idx, val) in result.scores_writes {
scores[idx] = val;
}
}
AccumulatedEval {
precision,
recall,
scores,
shape,
}
}
impl COCOeval {
/// Accumulate per-image results into precision/recall arrays.
pub fn accumulate(&mut self) {
self.eval = Some(accumulate_impl(&self.eval_imgs, &self.params, None));
}
}