Skip to main content

vernier_core/
evaluate_parallel.rs

1//! Parallel sibling of [`crate::evaluate::evaluate_with`] (ADR-0047).
2//!
3//! The outer `(k, i)` cell loop is dispatched via `par_iter` against a
4//! per-worker `CellScratch`; the calling thread folds the results
5//! into pre-sized output `Vec`s at deterministic flat indices.
6//!
7//! Strict-mode bit-equality across thread counts holds by construction:
8//! output writes go to deterministic `k*n_a*n_i + a*n_i + i` slots,
9//! the matching kernel is pure on owned data, `accumulate()` runs
10//! serially on the dense output, and the optional retained-IoU map is
11//! built in canonical `(k, i)` order. The parity harness asserts this
12//! property along the `num_threads` axis.
13//!
14//! The caller must `install` a `rayon::ThreadPool` around the call;
15//! this module uses `par_iter` against the ambient pool.
16
17use std::collections::{HashMap, HashSet};
18
19use ndarray::{Array2, ArrayView2, ArrayViewMut2};
20use rayon::prelude::*;
21
22use crate::accumulate::PerImageEval;
23use crate::dataset::{CategoryId, CocoDataset, CocoDetections, EvalDataset, ImageMeta};
24use crate::error::EvalError;
25use crate::evaluate::{
26    dt_top_indices_for_cell_into, evaluate_cell, gt_indices_for_cell, raw_dt_indices_for_cell,
27    CellBuffers, CellScratch, EvalGrid, EvalImageMeta, EvalKernel, EvaluateParams, KernelScratch,
28    COLLAPSED_CATEGORY_SENTINEL,
29};
30use crate::parity::ParityMode;
31
32/// Wall-time split for [`evaluate_with_parallel`], gated on `bench-timings`.
33#[cfg(feature = "bench-timings")]
34pub(crate) mod timings {
35    use crate::bench_counters::BenchCounterSet;
36
37    pub(super) const PAR_ITER_NS: usize = 0;
38    pub(super) const SERIAL_POST_NS: usize = 1;
39    pub(super) const N_CALLS: usize = 2;
40
41    pub(super) static COUNTERS: BenchCounterSet<3> = BenchCounterSet::new();
42
43    pub(crate) fn read_and_reset() -> (u64, u64, u64) {
44        let [p, s, n] = COUNTERS.read_and_reset();
45        (p, s, n)
46    }
47}
48
49/// Parallel sibling of [`crate::evaluate::evaluate_with`]. Caller
50/// `install`s a `rayon::ThreadPool` around the call.
51///
52/// # Errors
53///
54/// Propagates the first [`EvalError`] from any per-cell call.
55pub fn evaluate_with_parallel<K: EvalKernel>(
56    gt: &CocoDataset,
57    dt: &CocoDetections,
58    params: EvaluateParams<'_>,
59    parity_mode: ParityMode,
60    kernel: &K,
61) -> Result<EvalGrid, EvalError> {
62    let mut images: Vec<&ImageMeta> = gt.images().iter().collect();
63    images.sort_unstable_by_key(|im| im.id.0);
64    let n_i = images.len();
65    let n_a = params.area_ranges.len();
66
67    let category_buckets: Vec<Option<CategoryId>> = if params.use_cats {
68        let mut cats: Vec<_> = gt.categories().iter().map(|c| c.id).collect();
69        cats.sort_unstable_by_key(|id| id.0);
70        cats.into_iter().map(Some).collect()
71    } else {
72        vec![None]
73    };
74    let n_k = category_buckets.len();
75
76    let federated_per_image: Vec<Option<(&HashSet<CategoryId>, &HashSet<CategoryId>)>> =
77        match (params.use_cats, gt.federated()) {
78            (true, Some(fed)) => images
79                .iter()
80                .map(|im| {
81                    let neg = fed.neg_category_ids.get(&im.id)?;
82                    let nel = fed.not_exhaustive_category_ids.get(&im.id)?;
83                    Some((neg, nel))
84                })
85                .collect(),
86            _ => Vec::new(),
87        };
88
89    let strict_lvis_zero_area_filter =
90        matches!(parity_mode, ParityMode::Strict) && gt.federated().is_some();
91
92    let gt_anns = gt.annotations();
93    let dt_anns = dt.detections();
94
95    // Per-image rayon tasks write into a single output pair in
96    // image-major `(i, k, a)` layout via `par_chunks_mut`, then we
97    // transpose in place to the canonical `(k, a, i)` contract that
98    // downstream consumers (`accumulate`, `tables`, rkyv archive)
99    // expect. Single allocation per output Vec — saves the working +
100    // canonical double-buffer (~26 MB peak on val2017).
101    #[cfg(feature = "bench-timings")]
102    let t_par = std::time::Instant::now();
103
104    let total_slots = n_k * n_a * n_i;
105    let chunk_len = n_k * n_a;
106    let mut eval_imgs: Vec<Option<Box<PerImageEval>>> = vec![None; total_slots];
107    let mut eval_imgs_meta: Vec<Option<Box<EvalImageMeta>>> = vec![None; total_slots];
108
109    let retained_per_image: Vec<Vec<(usize, Array2<f64>)>> = eval_imgs
110        .par_chunks_mut(chunk_len)
111        .zip(eval_imgs_meta.par_chunks_mut(chunk_len))
112        .enumerate()
113        .map_init(
114            || {
115                (
116                    CellScratch::new(),
117                    KernelScratch::<K::Annotation>::default(),
118                )
119            },
120            |(scratch, kernel_scratch), (i, (eval_chunk, meta_chunk))| {
121                let mut per_image_retained: Vec<(usize, Array2<f64>)> = Vec::new();
122                for k in 0..n_k {
123                    let eval_slice = &mut eval_chunk[k * n_a..(k + 1) * n_a];
124                    let meta_slice = &mut meta_chunk[k * n_a..(k + 1) * n_a];
125                    process_one_cell_into(
126                        scratch,
127                        kernel_scratch,
128                        k,
129                        i,
130                        &images,
131                        &category_buckets,
132                        gt,
133                        gt_anns,
134                        dt,
135                        dt_anns,
136                        params,
137                        parity_mode,
138                        kernel,
139                        &federated_per_image,
140                        strict_lvis_zero_area_filter,
141                        eval_slice,
142                        meta_slice,
143                        &mut per_image_retained,
144                    )?;
145                }
146                Ok::<Vec<(usize, Array2<f64>)>, EvalError>(per_image_retained)
147            },
148        )
149        .collect::<Result<Vec<_>, _>>()?;
150
151    #[cfg(feature = "bench-timings")]
152    let par_ns = u64::try_from(t_par.elapsed().as_nanos()).unwrap_or(u64::MAX);
153
154    #[cfg(feature = "bench-timings")]
155    let t_post = std::time::Instant::now();
156
157    transpose_pair_image_major_to_canonical(
158        &mut eval_imgs,
159        &mut eval_imgs_meta,
160        CellLayout { n_k, n_a, n_i },
161    );
162
163    let mut retained_pairs: Vec<((usize, usize), Array2<f64>)> = Vec::new();
164    for (i, per_image) in retained_per_image.into_iter().enumerate() {
165        for (k, iou) in per_image {
166            retained_pairs.push(((k, i), iou));
167        }
168    }
169
170    // Sort by `(k, i)` before HashMap insertion so the final map is
171    // built in the same order the sequential path would have used —
172    // independent of par_iter completion order.
173    let retained_ious_map: Option<HashMap<(usize, usize), Array2<f64>>> = if params.retain_iou {
174        retained_pairs.sort_by_key(|&(key, _)| key);
175        Some(retained_pairs.into_iter().collect())
176    } else {
177        None
178    };
179
180    let grid = EvalGrid {
181        eval_imgs,
182        eval_imgs_meta,
183        n_categories: n_k,
184        n_area_ranges: n_a,
185        n_images: n_i,
186        retained_ious: retained_ious_map.map(crate::tables::RetainedIous::from_map),
187    };
188    #[cfg(feature = "bench-timings")]
189    {
190        let post_ns = u64::try_from(t_post.elapsed().as_nanos()).unwrap_or(u64::MAX);
191        timings::COUNTERS.add(timings::PAR_ITER_NS, par_ns);
192        timings::COUNTERS.add(timings::SERIAL_POST_NS, post_ns);
193        timings::COUNTERS.bump(timings::N_CALLS);
194    }
195    Ok(grid)
196}
197
198/// Per-cell body. Writes per-area-range outputs directly into
199/// `eval_slice` / `meta_slice` (length = `params.area_ranges.len()`);
200/// pushes retained IoU into `retained_out` when enabled.
201#[allow(clippy::too_many_arguments)]
202fn process_one_cell_into<K: EvalKernel>(
203    scratch: &mut CellScratch,
204    kernel_scratch: &mut KernelScratch<K::Annotation>,
205    k: usize,
206    i: usize,
207    images: &[&ImageMeta],
208    category_buckets: &[Option<CategoryId>],
209    gt: &CocoDataset,
210    gt_anns: &[crate::dataset::CocoAnnotation],
211    dt: &CocoDetections,
212    dt_anns: &[crate::dataset::CocoDetection],
213    params: EvaluateParams<'_>,
214    parity_mode: ParityMode,
215    kernel: &K,
216    federated_per_image: &[Option<(&HashSet<CategoryId>, &HashSet<CategoryId>)>],
217    strict_lvis_zero_area_filter: bool,
218    eval_slice: &mut [Option<Box<PerImageEval>>],
219    meta_slice: &mut [Option<Box<EvalImageMeta>>],
220    retained_out: &mut Vec<(usize, Array2<f64>)>,
221) -> Result<(), EvalError> {
222    let cat = category_buckets[k];
223    let category_id = cat.map_or(COLLAPSED_CATEGORY_SENTINEL, |c| c.0);
224    let image = images[i];
225    let image_id = image.id;
226
227    let gt_indices_raw = gt_indices_for_cell(gt, image_id, cat);
228    let gt_indices_buf: Vec<usize>;
229    let gt_indices: &[usize] =
230        if strict_lvis_zero_area_filter && gt_indices_raw.iter().any(|&j| gt_anns[j].area <= 0.0) {
231            gt_indices_buf = gt_indices_raw
232                .iter()
233                .copied()
234                .filter(|&j| gt_anns[j].area > 0.0)
235                .collect();
236            &gt_indices_buf
237        } else {
238            gt_indices_raw
239        };
240    let raw_dt_indices = raw_dt_indices_for_cell(dt, image_id, cat);
241    if gt_indices.is_empty() && raw_dt_indices.is_empty() {
242        return Ok(());
243    }
244
245    // AA4 cell-skip + AA3 `not_exhaustive` flag; `federated_per_image`
246    // is `Some` exactly when federated semantics apply.
247    let mut not_exhaustive_for_cell = false;
248    if let (Some(c), Some(Some((neg_set, nel_set)))) = (cat, federated_per_image.get(i)) {
249        if gt_indices.is_empty() && !neg_set.contains(&c) {
250            return Ok(());
251        }
252        not_exhaustive_for_cell = nel_set.contains(&c);
253    }
254
255    dt_top_indices_for_cell_into(
256        &mut scratch.dt_indices,
257        &mut scratch.dt_score_buf,
258        &mut scratch.dt_perm_buf,
259        dt_anns,
260        raw_dt_indices,
261        params.max_dets_per_image,
262    );
263
264    scratch.gt_areas.clear();
265    scratch
266        .gt_areas
267        .extend(gt_indices.iter().map(|&j| gt_anns[j].area));
268    scratch.gt_iscrowd.clear();
269    scratch
270        .gt_iscrowd
271        .extend(gt_indices.iter().map(|&j| gt_anns[j].is_crowd));
272    scratch.gt_base_ignore.clear();
273    scratch.gt_base_ignore.extend(
274        gt_indices.iter().map(|&j| {
275            gt_anns[j].effective_ignore(parity_mode) || kernel.extra_gt_ignore(&gt_anns[j])
276        }),
277    );
278    scratch.gt_ids.clear();
279    scratch
280        .gt_ids
281        .extend(gt_indices.iter().map(|&j| gt_anns[j].id.0));
282    scratch.dt_areas.clear();
283    scratch
284        .dt_areas
285        .extend(scratch.dt_indices.iter().map(|&j| dt_anns[j].area));
286    scratch.dt_scores.clear();
287    scratch
288        .dt_scores
289        .extend(scratch.dt_indices.iter().map(|&j| dt_anns[j].score));
290    scratch.dt_ids.clear();
291    scratch
292        .dt_ids
293        .extend(scratch.dt_indices.iter().map(|&j| dt_anns[j].id.0));
294
295    #[cfg(feature = "bench-timings")]
296    {
297        crate::evaluate::build_anns_count::bump();
298        crate::evaluate::build_anns_count::bump();
299    }
300    kernel.build_gt_anns_into(&mut kernel_scratch.gt, gt_anns, gt_indices, image)?;
301    kernel.build_dt_anns_into(
302        &mut kernel_scratch.dt,
303        dt_anns,
304        &scratch.dt_indices,
305        image,
306        parity_mode,
307    )?;
308
309    let g = kernel_scratch.gt.len();
310    let d = kernel_scratch.dt.len();
311    scratch.iou_buf.clear();
312    scratch.iou_buf.resize(g * d, 0.0);
313    if g > 0 && d > 0 {
314        let mut iou_view =
315            ArrayViewMut2::from_shape((g, d), &mut scratch.iou_buf[..]).map_err(|e| {
316                EvalError::DimensionMismatch {
317                    detail: format!("iou scratch view: {e}"),
318                }
319            })?;
320        kernel.compute(&kernel_scratch.gt, &kernel_scratch.dt, &mut iou_view)?;
321    }
322
323    let iou_view = ArrayView2::from_shape((g, d), &scratch.iou_buf[..]).map_err(|e| {
324        EvalError::DimensionMismatch {
325            detail: format!("iou scratch view: {e}"),
326        }
327    })?;
328    let buffers = CellBuffers {
329        image_id: image_id.0,
330        category_id,
331        max_det: params.max_dets_per_image,
332        gt_areas: &scratch.gt_areas,
333        gt_iscrowd: &scratch.gt_iscrowd,
334        gt_base_ignore: &scratch.gt_base_ignore,
335        gt_ids: &scratch.gt_ids,
336        dt_areas: &scratch.dt_areas,
337        dt_scores: &scratch.dt_scores,
338        dt_ids: &scratch.dt_ids,
339        iou: iou_view,
340        not_exhaustive: not_exhaustive_for_cell,
341    };
342
343    for (a, area) in params.area_ranges.iter().enumerate() {
344        let (cell, meta) = evaluate_cell(
345            &mut scratch.gt_ignore_buf,
346            &buffers,
347            area,
348            params.iou_thresholds,
349            parity_mode,
350        )?;
351        eval_slice[a] = Some(Box::new(cell));
352        meta_slice[a] = Some(Box::new(meta));
353    }
354
355    if params.retain_iou {
356        let cloned = Array2::from_shape_vec((g, d), scratch.iou_buf.clone()).map_err(|e| {
357            EvalError::DimensionMismatch {
358                detail: format!("retained iou clone: {e}"),
359            }
360        })?;
361        retained_out.push((k, cloned));
362    }
363
364    Ok(())
365}
366
367/// Shape of the cell-output tensor. Bundled into one struct so
368/// callers can't silently swap the three `usize` axis lengths.
369#[derive(Clone, Copy)]
370struct CellLayout {
371    n_k: usize,
372    n_a: usize,
373    n_i: usize,
374}
375
376/// In-place tensor transpose of two parallel buffers from image-major
377/// `(i, k, a)` to the canonical `(k, a, i)` layout `EvalGrid` expects.
378/// Cycle-following permutation: one shared visited bitset, one walk
379/// of the cycle structure swaps both buffers per step. Fused over
380/// `eval_imgs` + `eval_imgs_meta` because they share the permutation.
381fn transpose_pair_image_major_to_canonical<A, B>(
382    buf_a: &mut [Option<A>],
383    buf_b: &mut [Option<B>],
384    layout: CellLayout,
385) {
386    let total = layout.n_k * layout.n_a * layout.n_i;
387    debug_assert_eq!(buf_a.len(), total);
388    debug_assert_eq!(buf_b.len(), total);
389    if total <= 1 {
390        return;
391    }
392    let mut visited = vec![false; total];
393    for start in 0..total {
394        if visited[start] {
395            continue;
396        }
397        visited[start] = true;
398        let mut next = image_major_to_canonical(start, layout);
399        if next == start {
400            continue;
401        }
402        let mut held_a = buf_a[start].take();
403        let mut held_b = buf_b[start].take();
404        while next != start {
405            visited[next] = true;
406            std::mem::swap(&mut held_a, &mut buf_a[next]);
407            std::mem::swap(&mut held_b, &mut buf_b[next]);
408            next = image_major_to_canonical(next, layout);
409        }
410        buf_a[start] = held_a;
411        buf_b[start] = held_b;
412    }
413}
414
415/// Decompose `pos` as `(i, k, a)` in image-major layout and recompose
416/// into the canonical `(k, a, i)` linear index.
417#[inline]
418fn image_major_to_canonical(pos: usize, layout: CellLayout) -> usize {
419    let chunk = layout.n_k * layout.n_a;
420    let i = pos / chunk;
421    let r = pos % chunk;
422    let k = r / layout.n_a;
423    let a = r % layout.n_a;
424    k * layout.n_a * layout.n_i + a * layout.n_i + i
425}
426
427// ----- Per-paradigm parallel sibling wrappers -------------------------
428//
429// One `pub fn` per `crate::evaluate::evaluate_*` entry; the FFI routes
430// between sequential and parallel without holding the kernel type.
431
432use crate::evaluate::{boundary_kernel, segm_kernel};
433use crate::similarity::{BboxIou, BoundaryGtCache, OksSimilarity, SegmGtCache};
434
435/// Parallel sibling of [`crate::evaluate::evaluate_bbox`].
436///
437/// # Errors
438/// Propagates [`EvalError`] from the underlying kernel and matching
439/// calls.
440pub fn evaluate_bbox_parallel(
441    gt: &CocoDataset,
442    dt: &CocoDetections,
443    params: EvaluateParams<'_>,
444    parity_mode: ParityMode,
445) -> Result<EvalGrid, EvalError> {
446    evaluate_with_parallel(gt, dt, params, parity_mode, &BboxIou)
447}
448
449/// Parallel sibling of [`crate::evaluate::evaluate_segm`].
450///
451/// # Errors
452/// Propagates [`EvalError`] from the underlying kernel and matching
453/// calls.
454pub fn evaluate_segm_parallel(
455    gt: &CocoDataset,
456    dt: &CocoDetections,
457    params: EvaluateParams<'_>,
458    parity_mode: ParityMode,
459) -> Result<EvalGrid, EvalError> {
460    evaluate_with_parallel(gt, dt, params, parity_mode, &segm_kernel(None))
461}
462
463/// Parallel sibling of [`crate::evaluate::evaluate_segm_cached`].
464///
465/// # Errors
466/// Propagates [`EvalError`] from the underlying kernel and matching
467/// calls.
468pub fn evaluate_segm_cached_parallel(
469    gt: &CocoDataset,
470    dt: &CocoDetections,
471    params: EvaluateParams<'_>,
472    parity_mode: ParityMode,
473    cache: &SegmGtCache,
474) -> Result<EvalGrid, EvalError> {
475    evaluate_with_parallel(gt, dt, params, parity_mode, &segm_kernel(Some(cache)))
476}
477
478/// Parallel sibling of [`crate::evaluate::evaluate_boundary`].
479///
480/// Uncached path: the cell loop derives GT bands inline. Callers that
481/// run repeated evaluations against the same GT should construct a
482/// [`BoundaryGtCache`] and use [`evaluate_boundary_cached_parallel`]
483/// instead (per ADR-0020); the first call populates the cache, every
484/// subsequent call reuses the bands.
485///
486/// # Errors
487/// Propagates [`EvalError`] from the underlying kernel and matching
488/// calls.
489pub fn evaluate_boundary_parallel(
490    gt: &CocoDataset,
491    dt: &CocoDetections,
492    params: EvaluateParams<'_>,
493    parity_mode: ParityMode,
494    dilation_ratio: f64,
495) -> Result<EvalGrid, EvalError> {
496    evaluate_with_parallel(
497        gt,
498        dt,
499        params,
500        parity_mode,
501        &boundary_kernel(dilation_ratio, None),
502    )
503}
504
505/// Parallel sibling of [`crate::evaluate::evaluate_boundary_cached`].
506///
507/// # Errors
508/// Propagates [`EvalError`] from the underlying kernel and matching
509/// calls.
510pub fn evaluate_boundary_cached_parallel(
511    gt: &CocoDataset,
512    dt: &CocoDetections,
513    params: EvaluateParams<'_>,
514    parity_mode: ParityMode,
515    dilation_ratio: f64,
516    cache: &BoundaryGtCache,
517) -> Result<EvalGrid, EvalError> {
518    cache.align_ratio(dilation_ratio);
519    evaluate_with_parallel(
520        gt,
521        dt,
522        params,
523        parity_mode,
524        &boundary_kernel(dilation_ratio, Some(cache)),
525    )
526}
527
528/// Parallel sibling of [`crate::evaluate::evaluate_keypoints`].
529///
530/// # Errors
531/// Propagates [`EvalError`] from the underlying kernel and matching
532/// calls.
533pub fn evaluate_keypoints_parallel(
534    gt: &CocoDataset,
535    dt: &CocoDetections,
536    params: EvaluateParams<'_>,
537    parity_mode: ParityMode,
538    sigmas: HashMap<i64, Vec<f64>>,
539) -> Result<EvalGrid, EvalError> {
540    evaluate_with_parallel(gt, dt, params, parity_mode, &OksSimilarity::new(sigmas))
541}
542
543#[cfg(test)]
544mod tests {
545    //! Rust-side smoke: bit-equal output to `evaluate_with` on a
546    //! small synthetic bbox fixture. The load-bearing parity assertion
547    //! lives in `tests/python/parity/` (cross-thread bit-equality
548    //! across every paradigm and real COCO/LVIS fixtures).
549    use super::*;
550    use crate::accumulate::{accumulate, AccumulateParams};
551    use crate::dataset::{
552        AnnId, Bbox, CategoryId, CategoryMeta, CocoAnnotation, DetectionInput, ImageId, ImageMeta,
553    };
554    use crate::evaluate::{evaluate_with, AreaRange};
555    use crate::parity::{iou_thresholds, recall_thresholds};
556    use crate::similarity::BboxIou;
557
558    fn img(id: i64, w: u32, h: u32) -> ImageMeta {
559        ImageMeta {
560            id: ImageId(id),
561            width: w,
562            height: h,
563            file_name: None,
564        }
565    }
566
567    /// Reference (out-of-place) transpose to compare against.
568    fn transpose_reference<T: Clone>(
569        image_major: &[Option<T>],
570        n_k: usize,
571        n_a: usize,
572        n_i: usize,
573    ) -> Vec<Option<T>> {
574        let mut out: Vec<Option<T>> = (0..n_k * n_a * n_i).map(|_| None).collect();
575        for i in 0..n_i {
576            for k in 0..n_k {
577                for a in 0..n_a {
578                    let src = i * n_k * n_a + k * n_a + a;
579                    let dst = k * n_a * n_i + a * n_i + i;
580                    out[dst] = image_major[src].clone();
581                }
582            }
583        }
584        out
585    }
586
587    /// Build a paired (`u32` indices, `i64` indices) image-major
588    /// fixture so the fused transpose exercises both buffers. Using
589    /// distinct types catches any swap-the-buffers bug.
590    fn paired_fixture(
591        n_k: usize,
592        n_a: usize,
593        n_i: usize,
594        keep: impl Fn(usize) -> bool,
595    ) -> (Vec<Option<u32>>, Vec<Option<i64>>) {
596        let n = n_k * n_a * n_i;
597        let a: Vec<Option<u32>> = (0..n)
598            .map(|p| if keep(p) { Some(p as u32) } else { None })
599            .collect();
600        let b: Vec<Option<i64>> = (0..n)
601            .map(|p| if keep(p) { Some(-(p as i64)) } else { None })
602            .collect();
603        (a, b)
604    }
605
606    #[test]
607    fn inplace_transpose_matches_reference_dense() {
608        // n_k=3, n_a=2, n_i=4: 24 slots, mostly non-trivial cycles.
609        let layout = CellLayout {
610            n_k: 3,
611            n_a: 2,
612            n_i: 4,
613        };
614        let (mut a, mut b) = paired_fixture(layout.n_k, layout.n_a, layout.n_i, |_| true);
615        let expected_a = transpose_reference(&a, layout.n_k, layout.n_a, layout.n_i);
616        let expected_b = transpose_reference(&b, layout.n_k, layout.n_a, layout.n_i);
617        transpose_pair_image_major_to_canonical(&mut a, &mut b, layout);
618        assert_eq!(a, expected_a);
619        assert_eq!(b, expected_b);
620    }
621
622    #[test]
623    fn inplace_transpose_matches_reference_sparse() {
624        // Mix of Some and None — covers the val2017-shape case where
625        // most cells are empty.
626        let layout = CellLayout {
627            n_k: 4,
628            n_a: 4,
629            n_i: 5,
630        };
631        let (mut a, mut b) = paired_fixture(layout.n_k, layout.n_a, layout.n_i, |p| p % 3 == 0);
632        let expected_a = transpose_reference(&a, layout.n_k, layout.n_a, layout.n_i);
633        let expected_b = transpose_reference(&b, layout.n_k, layout.n_a, layout.n_i);
634        transpose_pair_image_major_to_canonical(&mut a, &mut b, layout);
635        assert_eq!(a, expected_a);
636        assert_eq!(b, expected_b);
637    }
638
639    #[test]
640    fn inplace_transpose_handles_degenerate_shapes() {
641        // n_k=1: every position is a fixed point (image-major and
642        // canonical layouts coincide). n_i=1 likewise.
643        for (n_k, n_a, n_i) in [(1, 4, 5), (3, 4, 1), (1, 1, 1)] {
644            let layout = CellLayout { n_k, n_a, n_i };
645            let (mut a, mut b) = paired_fixture(n_k, n_a, n_i, |_| true);
646            let expected_a = transpose_reference(&a, n_k, n_a, n_i);
647            let expected_b = transpose_reference(&b, n_k, n_a, n_i);
648            transpose_pair_image_major_to_canonical(&mut a, &mut b, layout);
649            assert_eq!(a, expected_a, "shape ({n_k}, {n_a}, {n_i})");
650            assert_eq!(b, expected_b, "shape ({n_k}, {n_a}, {n_i})");
651        }
652    }
653
654    fn cat(id: i64, name: &str) -> CategoryMeta {
655        CategoryMeta {
656            id: CategoryId(id),
657            name: name.into(),
658            supercategory: None,
659        }
660    }
661
662    fn ann(id: i64, image: i64, cat_id: i64, bbox: (f64, f64, f64, f64)) -> CocoAnnotation {
663        CocoAnnotation {
664            id: AnnId(id),
665            image_id: ImageId(image),
666            category_id: CategoryId(cat_id),
667            area: bbox.2 * bbox.3,
668            is_crowd: false,
669            ignore_flag: None,
670            bbox: Bbox {
671                x: bbox.0,
672                y: bbox.1,
673                w: bbox.2,
674                h: bbox.3,
675            },
676            segmentation: None,
677            keypoints: None,
678            num_keypoints: None,
679        }
680    }
681
682    fn dt_input(image: i64, cat_id: i64, score: f64, bbox: (f64, f64, f64, f64)) -> DetectionInput {
683        DetectionInput {
684            id: None,
685            image_id: ImageId(image),
686            category_id: CategoryId(cat_id),
687            score,
688            bbox: Bbox {
689                x: bbox.0,
690                y: bbox.1,
691                w: bbox.2,
692                h: bbox.3,
693            },
694            segmentation: None,
695            keypoints: None,
696            num_keypoints: None,
697        }
698    }
699
700    fn tiny_grid_inputs() -> (CocoDataset, CocoDetections) {
701        let images = vec![img(1, 100, 100), img(2, 100, 100), img(3, 100, 100)];
702        let cats = vec![cat(1, "a"), cat(2, "b")];
703        let mut anns = Vec::new();
704        let mut next_id = 1_i64;
705        for &im_id in &[1_i64, 2, 3] {
706            for &cat_id in &[1_i64, 2] {
707                anns.push(ann(next_id, im_id, cat_id, (10.0, 10.0, 20.0, 20.0)));
708                next_id += 1;
709            }
710        }
711        let gt = CocoDataset::from_parts(images, anns, cats).expect("dataset");
712        let dts = CocoDetections::from_inputs(vec![
713            dt_input(1, 1, 0.9, (12.0, 12.0, 18.0, 18.0)),
714            dt_input(2, 1, 0.8, (11.0, 11.0, 20.0, 20.0)),
715            dt_input(3, 2, 0.7, (15.0, 15.0, 10.0, 10.0)),
716        ])
717        .expect("detections");
718        (gt, dts)
719    }
720
721    #[test]
722    fn parallel_eval_imgs_bit_equals_sequential_bbox() {
723        let (gt, dt) = tiny_grid_inputs();
724        let area = AreaRange::coco_default();
725        let params = EvaluateParams {
726            iou_thresholds: iou_thresholds(),
727            area_ranges: &area,
728            max_dets_per_image: 100,
729            use_cats: true,
730            retain_iou: false,
731        };
732        let kernel = BboxIou;
733
734        let seq = evaluate_with(&gt, &dt, params, ParityMode::Strict, &kernel).expect("seq");
735
736        // Run under a pool of 4 threads to force the par_iter to fan
737        // out; the result must be byte-equal to the sequential one.
738        let pool = rayon::ThreadPoolBuilder::new()
739            .num_threads(4)
740            .build()
741            .expect("pool");
742        let par = pool
743            .install(|| evaluate_with_parallel(&gt, &dt, params, ParityMode::Strict, &kernel))
744            .expect("par");
745
746        assert_eq!(seq.n_categories, par.n_categories);
747        assert_eq!(seq.n_area_ranges, par.n_area_ranges);
748        assert_eq!(seq.n_images, par.n_images);
749        assert_eq!(seq.eval_imgs.len(), par.eval_imgs.len());
750        for (idx, (s, p)) in seq.eval_imgs.iter().zip(par.eval_imgs.iter()).enumerate() {
751            match (s, p) {
752                (None, None) => {}
753                (Some(s), Some(p)) => {
754                    assert_eq!(s.dt_scores, p.dt_scores, "dt_scores mismatch at flat={idx}");
755                    assert_eq!(s.gt_ignore, p.gt_ignore, "gt_ignore mismatch at flat={idx}");
756                    assert_eq!(
757                        s.dt_matched, p.dt_matched,
758                        "dt_matched mismatch at flat={idx}"
759                    );
760                    assert_eq!(s.dt_ignore, p.dt_ignore, "dt_ignore mismatch at flat={idx}");
761                }
762                _ => panic!("Some/None mismatch at flat={idx}"),
763            }
764        }
765    }
766
767    #[test]
768    fn parallel_summary_bit_equals_sequential_across_thread_counts() {
769        let (gt, dt) = tiny_grid_inputs();
770        let area = AreaRange::coco_default();
771        let params = EvaluateParams {
772            iou_thresholds: iou_thresholds(),
773            area_ranges: &area,
774            max_dets_per_image: 100,
775            use_cats: true,
776            retain_iou: false,
777        };
778        let kernel = BboxIou;
779
780        let seq = evaluate_with(&gt, &dt, params, ParityMode::Strict, &kernel).expect("seq");
781
782        let acc_params = AccumulateParams {
783            iou_thresholds: iou_thresholds(),
784            recall_thresholds: recall_thresholds(),
785            max_dets: &[1, 10, 100],
786            n_categories: seq.n_categories,
787            n_area_ranges: seq.n_area_ranges,
788            n_images: seq.n_images,
789        };
790        let acc_seq = accumulate(&seq.eval_imgs, acc_params, ParityMode::Strict).expect("acc seq");
791
792        // Sweep across a few thread counts to catch any race or order-
793        // dependence the par_iter scheduler might introduce.
794        for n_threads in [2usize, 3, 4, 8] {
795            let pool = rayon::ThreadPoolBuilder::new()
796                .num_threads(n_threads)
797                .build()
798                .expect("pool");
799            let par = pool
800                .install(|| evaluate_with_parallel(&gt, &dt, params, ParityMode::Strict, &kernel))
801                .expect("par");
802            let acc_par =
803                accumulate(&par.eval_imgs, acc_params, ParityMode::Strict).expect("acc par");
804            assert_eq!(
805                acc_seq.precision, acc_par.precision,
806                "precision mismatch at n_threads={n_threads}"
807            );
808            assert_eq!(
809                acc_seq.recall, acc_par.recall,
810                "recall mismatch at n_threads={n_threads}"
811            );
812            assert_eq!(
813                acc_seq.scores, acc_par.scores,
814                "scores mismatch at n_threads={n_threads}"
815            );
816        }
817    }
818}