1use std::collections::{HashMap, HashSet};
44use std::sync::{Arc, OnceLock};
45
46use serde::{Deserialize, Serialize};
47
48use crate::error::EvalError;
49use crate::parity::ParityMode;
50use crate::segmentation::{Segmentation, SegmentationRleCounts};
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(transparent)]
57pub struct ImageId(pub i64);
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(transparent)]
62pub struct CategoryId(pub i64);
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
66#[serde(transparent)]
67pub struct AnnId(pub i64);
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct ImageMeta {
74 pub id: ImageId,
76 pub width: u32,
78 pub height: u32,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub file_name: Option<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct CategoryMeta {
89 pub id: CategoryId,
91 pub name: String,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub supercategory: Option<String>,
96}
97
98#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
102#[serde(from = "[f64; 4]", into = "[f64; 4]")]
103pub struct Bbox {
104 pub x: f64,
106 pub y: f64,
108 pub w: f64,
110 pub h: f64,
112}
113
114impl From<[f64; 4]> for Bbox {
115 fn from([x, y, w, h]: [f64; 4]) -> Self {
116 Self { x, y, w, h }
117 }
118}
119
120impl From<Bbox> for [f64; 4] {
121 fn from(b: Bbox) -> Self {
122 [b.x, b.y, b.w, b.h]
123 }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
133pub struct CocoAnnotation {
134 pub id: AnnId,
136 pub image_id: ImageId,
138 pub category_id: CategoryId,
140 pub area: f64,
143 #[serde(rename = "iscrowd", default, deserialize_with = "deserialize_bool_int")]
146 pub is_crowd: bool,
147 #[serde(
154 rename = "ignore",
155 default,
156 deserialize_with = "deserialize_opt_bool_int"
157 )]
158 pub ignore_flag: Option<bool>,
159 pub bbox: Bbox,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub segmentation: Option<Segmentation>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub keypoints: Option<Vec<f64>>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub num_keypoints: Option<u32>,
183}
184
185impl CocoAnnotation {
186 pub fn effective_ignore(&self, mode: ParityMode) -> bool {
194 match mode {
195 ParityMode::Strict => self.is_crowd,
196 ParityMode::Corrected => self.ignore_flag.unwrap_or(self.is_crowd),
197 }
198 }
199}
200
201pub trait Annotation {
207 fn image_id(&self) -> ImageId;
209 fn category_id(&self) -> CategoryId;
211 fn area(&self) -> f64;
213 fn is_crowd(&self) -> bool;
215 fn effective_ignore(&self, mode: ParityMode) -> bool;
217}
218
219impl Annotation for CocoAnnotation {
220 fn image_id(&self) -> ImageId {
221 self.image_id
222 }
223 fn category_id(&self) -> CategoryId {
224 self.category_id
225 }
226 fn area(&self) -> f64 {
227 self.area
228 }
229 fn is_crowd(&self) -> bool {
230 self.is_crowd
231 }
232 fn effective_ignore(&self, mode: ParityMode) -> bool {
233 Self::effective_ignore(self, mode)
234 }
235}
236
237pub trait EvalDataset: Send + Sync {
243 type Annotation: Annotation;
247
248 fn images(&self) -> &[ImageMeta];
250
251 fn categories(&self) -> &[CategoryMeta];
253
254 fn annotations(&self) -> &[Self::Annotation];
256
257 fn ann_indices_for_image(&self, image_id: ImageId) -> &[usize];
260
261 fn ann_indices_for_category(&self, cat_id: CategoryId) -> &[usize];
264
265 fn ann_iter_for_image(&self, image_id: ImageId) -> AnnotationIter<'_, Self::Annotation> {
267 AnnotationIter {
268 anns: self.annotations(),
269 indices: self.ann_indices_for_image(image_id).iter(),
270 }
271 }
272
273 fn ann_iter_for_category(&self, cat_id: CategoryId) -> AnnotationIter<'_, Self::Annotation> {
275 AnnotationIter {
276 anns: self.annotations(),
277 indices: self.ann_indices_for_category(cat_id).iter(),
278 }
279 }
280}
281
282pub struct AnnotationIter<'a, A> {
286 anns: &'a [A],
287 indices: std::slice::Iter<'a, usize>,
288}
289
290impl<'a, A> Iterator for AnnotationIter<'a, A> {
291 type Item = &'a A;
292
293 fn next(&mut self) -> Option<Self::Item> {
294 let idx = *self.indices.next()?;
295 self.anns.get(idx)
296 }
297
298 fn size_hint(&self) -> (usize, Option<usize>) {
299 self.indices.size_hint()
300 }
301}
302
303impl<'a, A> ExactSizeIterator for AnnotationIter<'a, A> {}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct CocoJson {
314 pub images: Vec<ImageMeta>,
316 pub annotations: Vec<CocoAnnotation>,
318 pub categories: Vec<CategoryMeta>,
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
342pub enum Frequency {
343 #[serde(rename = "r")]
345 Rare,
346 #[serde(rename = "c")]
348 Common,
349 #[serde(rename = "f")]
351 Frequent,
352}
353
354impl Frequency {
355 pub const fn as_letter(self) -> &'static str {
360 match self {
361 Self::Rare => "r",
362 Self::Common => "c",
363 Self::Frequent => "f",
364 }
365 }
366}
367
368#[derive(Debug, Clone, Deserialize)]
373struct LvisImageRaw {
374 id: ImageId,
375 width: u32,
376 height: u32,
377 #[serde(default)]
378 file_name: Option<String>,
379 #[serde(default)]
383 neg_category_ids: Option<Vec<CategoryId>>,
384 #[serde(default)]
389 not_exhaustive_category_ids: Option<Vec<CategoryId>>,
390}
391
392#[derive(Debug, Clone, Deserialize)]
397struct LvisCategoryRaw {
398 id: CategoryId,
399 name: String,
400 #[serde(default)]
401 supercategory: Option<String>,
402 #[serde(default)]
406 frequency: Option<Frequency>,
407}
408
409#[derive(Debug, Clone, Deserialize)]
414struct LvisJson {
415 images: Vec<LvisImageRaw>,
416 annotations: Vec<CocoAnnotation>,
417 categories: Vec<LvisCategoryRaw>,
418}
419
420#[derive(Debug, Clone)]
428pub struct FederatedMetadata {
429 pub pos_category_ids: HashMap<ImageId, HashSet<CategoryId>>,
432 pub neg_category_ids: HashMap<ImageId, HashSet<CategoryId>>,
435 pub not_exhaustive_category_ids: HashMap<ImageId, HashSet<CategoryId>>,
438 pub category_frequency: HashMap<CategoryId, Frequency>,
443}
444
445#[derive(Debug, Clone)]
461pub struct CocoDataset {
462 images: Arc<Vec<ImageMeta>>,
463 categories: Arc<Vec<CategoryMeta>>,
464 annotations: Arc<Vec<CocoAnnotation>>,
465 by_image: HashMap<ImageId, Vec<usize>>,
466 by_category: HashMap<CategoryId, Vec<usize>>,
467 by_image_cat: HashMap<(ImageId, CategoryId), Vec<usize>>,
468 federated: Option<FederatedMetadata>,
469 cached_hash: Arc<OnceLock<[u8; 32]>>,
476}
477
478impl CocoDataset {
479 pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, EvalError> {
485 let raw: CocoJson = serde_json::from_slice(bytes)?;
486 Self::from_parts(raw.images, raw.annotations, raw.categories)
487 }
488
489 pub fn from_parts(
491 images: Vec<ImageMeta>,
492 annotations: Vec<CocoAnnotation>,
493 categories: Vec<CategoryMeta>,
494 ) -> Result<Self, EvalError> {
495 let known_images: HashSet<ImageId> = images.iter().map(|i| i.id).collect();
496 let known_categories: HashSet<CategoryId> = categories.iter().map(|c| c.id).collect();
497
498 let mut by_image: HashMap<ImageId, Vec<usize>> = HashMap::with_capacity(images.len());
499 let mut by_category: HashMap<CategoryId, Vec<usize>> =
500 HashMap::with_capacity(categories.len());
501 let mut by_image_cat: HashMap<(ImageId, CategoryId), Vec<usize>> = HashMap::new();
502
503 for (idx, ann) in annotations.iter().enumerate() {
504 if !known_images.contains(&ann.image_id) {
505 return Err(EvalError::InvalidAnnotation {
506 detail: format!(
507 "annotation id={} references unknown image_id={}",
508 ann.id.0, ann.image_id.0
509 ),
510 });
511 }
512 if !known_categories.contains(&ann.category_id) {
513 return Err(EvalError::InvalidAnnotation {
514 detail: format!(
515 "annotation id={} references unknown category_id={}",
516 ann.id.0, ann.category_id.0
517 ),
518 });
519 }
520 by_image.entry(ann.image_id).or_default().push(idx);
521 by_category.entry(ann.category_id).or_default().push(idx);
522 by_image_cat
523 .entry((ann.image_id, ann.category_id))
524 .or_default()
525 .push(idx);
526 }
527
528 Ok(Self {
529 images: Arc::new(images),
530 categories: Arc::new(categories),
531 annotations: Arc::new(annotations),
532 by_image,
533 by_category,
534 by_image_cat,
535 federated: None,
536 cached_hash: Arc::new(OnceLock::new()),
537 })
538 }
539
540 pub fn from_lvis_json_bytes(bytes: &[u8]) -> Result<Self, EvalError> {
579 let raw: LvisJson = serde_json::from_slice(bytes)?;
580
581 let images: Vec<ImageMeta> = raw
582 .images
583 .iter()
584 .map(|im| ImageMeta {
585 id: im.id,
586 width: im.width,
587 height: im.height,
588 file_name: im.file_name.clone(),
589 })
590 .collect();
591 let categories: Vec<CategoryMeta> = raw
592 .categories
593 .iter()
594 .map(|c| CategoryMeta {
595 id: c.id,
596 name: c.name.clone(),
597 supercategory: c.supercategory.clone(),
598 })
599 .collect();
600
601 let mut missing_freq: Vec<i64> = raw
605 .categories
606 .iter()
607 .filter(|c| c.frequency.is_none())
608 .map(|c| c.id.0)
609 .collect();
610 if !missing_freq.is_empty() {
611 missing_freq.sort_unstable();
612 return Err(EvalError::MissingFrequency {
613 category_ids: missing_freq,
614 });
615 }
616 let category_frequency: HashMap<CategoryId, Frequency> = raw
617 .categories
618 .iter()
619 .filter_map(|c| c.frequency.map(|f| (c.id, f)))
620 .collect();
621
622 let mut dataset = Self::from_parts(images, raw.annotations, categories)?;
625
626 let mut pos: HashMap<ImageId, HashSet<CategoryId>> =
629 HashMap::with_capacity(raw.images.len());
630 for im in &raw.images {
631 pos.entry(im.id).or_default();
632 }
633 for ann in dataset.annotations.iter() {
634 pos.entry(ann.image_id).or_default().insert(ann.category_id);
635 }
636
637 let mut neg: HashMap<ImageId, HashSet<CategoryId>> =
640 HashMap::with_capacity(raw.images.len());
641 let mut nel: HashMap<ImageId, HashSet<CategoryId>> =
642 HashMap::with_capacity(raw.images.len());
643 for im in &raw.images {
644 let neg_set: HashSet<CategoryId> = im
645 .neg_category_ids
646 .as_deref()
647 .unwrap_or(&[])
648 .iter()
649 .copied()
650 .collect();
651 let nel_set: HashSet<CategoryId> = im
652 .not_exhaustive_category_ids
653 .as_deref()
654 .unwrap_or(&[])
655 .iter()
656 .copied()
657 .collect();
658 neg.insert(im.id, neg_set);
659 nel.insert(im.id, nel_set);
660 }
661
662 for im in &raw.images {
664 let image_id = im.id;
665 let pos_i = pos.get(&image_id).map_or_else(HashSet::new, Clone::clone);
666 let neg_i = &neg[&image_id];
667 let nel_i = &nel[&image_id];
668
669 if let Some(c) = pos_i.intersection(neg_i).next().copied() {
672 return Err(EvalError::LvisFederatedConflict {
673 image_id: image_id.0,
674 category_id: c.0,
675 detail: "category has GT on image but is also in neg_category_ids",
676 });
677 }
678 if let Some(c) = nel_i.difference(&pos_i).next().copied() {
680 return Err(EvalError::LvisFederatedConflict {
681 image_id: image_id.0,
682 category_id: c.0,
683 detail:
684 "category in not_exhaustive_category_ids but not in pos (no GT on image)",
685 });
686 }
687 if let Some(c) = nel_i.intersection(neg_i).next().copied() {
691 return Err(EvalError::LvisFederatedConflict {
692 image_id: image_id.0,
693 category_id: c.0,
694 detail: "category in both not_exhaustive_category_ids and neg_category_ids",
695 });
696 }
697 }
698
699 dataset.federated = Some(FederatedMetadata {
700 pos_category_ids: pos,
701 neg_category_ids: neg,
702 not_exhaustive_category_ids: nel,
703 category_frequency,
704 });
705 Ok(dataset)
706 }
707
708 pub fn federated(&self) -> Option<&FederatedMetadata> {
712 self.federated.as_ref()
713 }
714
715 pub fn pos_category_ids(&self) -> Option<&HashMap<ImageId, HashSet<CategoryId>>> {
718 self.federated.as_ref().map(|f| &f.pos_category_ids)
719 }
720
721 pub fn neg_category_ids(&self) -> Option<&HashMap<ImageId, HashSet<CategoryId>>> {
724 self.federated.as_ref().map(|f| &f.neg_category_ids)
725 }
726
727 pub fn not_exhaustive_category_ids(&self) -> Option<&HashMap<ImageId, HashSet<CategoryId>>> {
731 self.federated
732 .as_ref()
733 .map(|f| &f.not_exhaustive_category_ids)
734 }
735
736 pub fn category_frequency(&self) -> Option<&HashMap<CategoryId, Frequency>> {
741 self.federated.as_ref().map(|f| &f.category_frequency)
742 }
743
744 pub fn is_federated(&self) -> bool {
748 self.federated.is_some()
749 }
750
751 pub fn to_json_value(&self) -> CocoJson {
759 CocoJson {
760 images: (*self.images).clone(),
761 annotations: (*self.annotations).clone(),
762 categories: (*self.categories).clone(),
763 }
764 }
765}
766
767impl EvalDataset for CocoDataset {
768 type Annotation = CocoAnnotation;
769
770 fn images(&self) -> &[ImageMeta] {
771 &self.images
772 }
773
774 fn categories(&self) -> &[CategoryMeta] {
775 &self.categories
776 }
777
778 fn annotations(&self) -> &[CocoAnnotation] {
779 &self.annotations
780 }
781
782 fn ann_indices_for_image(&self, image_id: ImageId) -> &[usize] {
783 self.by_image.get(&image_id).map_or(&[][..], Vec::as_slice)
784 }
785
786 fn ann_indices_for_category(&self, cat_id: CategoryId) -> &[usize] {
787 self.by_category.get(&cat_id).map_or(&[][..], Vec::as_slice)
788 }
789}
790
791impl CocoDataset {
792 pub fn ann_indices_for(&self, image: ImageId, cat: CategoryId) -> &[usize] {
795 self.by_image_cat
796 .get(&(image, cat))
797 .map_or(&[][..], Vec::as_slice)
798 }
799}
800
801const HASH_TAG_DATASET: &[u8; 4] = b"DSET";
819const HASH_TAG_IMAGES: &[u8; 4] = b"IMGS";
820const HASH_TAG_CATEGORIES: &[u8; 4] = b"CATS";
821const HASH_TAG_ANNOTATIONS: &[u8; 4] = b"ANNS";
822const HASH_TAG_FEDERATED: &[u8; 4] = b"FEDM";
823
824const HASH_CANONICAL_VERSION: u8 = 1;
828
829#[inline]
830fn hash_u8(h: &mut blake3::Hasher, v: u8) {
831 h.update(&[v]);
832}
833#[inline]
834fn hash_u32(h: &mut blake3::Hasher, v: u32) {
835 h.update(&v.to_le_bytes());
836}
837#[inline]
838fn hash_i64(h: &mut blake3::Hasher, v: i64) {
839 h.update(&v.to_le_bytes());
840}
841#[inline]
842fn hash_u64(h: &mut blake3::Hasher, v: u64) {
843 h.update(&v.to_le_bytes());
844}
845#[inline]
846fn hash_f64(h: &mut blake3::Hasher, v: f64) {
847 h.update(&v.to_bits().to_le_bytes());
852}
853#[inline]
854fn hash_bool(h: &mut blake3::Hasher, v: bool) {
855 hash_u8(h, u8::from(v));
856}
857#[inline]
858fn hash_bytes(h: &mut blake3::Hasher, bytes: &[u8]) {
859 hash_u64(h, bytes.len() as u64);
860 h.update(bytes);
861}
862#[inline]
863fn hash_string(h: &mut blake3::Hasher, s: &str) {
864 hash_bytes(h, s.as_bytes());
865}
866#[inline]
867fn hash_option<T>(
868 h: &mut blake3::Hasher,
869 opt: Option<T>,
870 write: impl FnOnce(&mut blake3::Hasher, T),
871) {
872 match opt {
873 None => hash_u8(h, 0),
874 Some(v) => {
875 hash_u8(h, 1);
876 write(h, v);
877 }
878 }
879}
880
881fn hash_bbox(h: &mut blake3::Hasher, b: &Bbox) {
882 hash_f64(h, b.x);
883 hash_f64(h, b.y);
884 hash_f64(h, b.w);
885 hash_f64(h, b.h);
886}
887
888fn hash_segmentation(h: &mut blake3::Hasher, seg: Option<&Segmentation>) {
889 match seg {
890 None => hash_u8(h, 0),
891 Some(Segmentation::Polygons(polys)) => {
892 hash_u8(h, 1);
893 hash_u64(h, polys.len() as u64);
894 for poly in polys {
895 hash_u64(h, poly.len() as u64);
896 for &v in poly {
897 hash_f64(h, v);
898 }
899 }
900 }
901 Some(Segmentation::Rle(rle)) => {
902 let [rh, rw] = rle.size;
903 match &rle.counts {
904 SegmentationRleCounts::Compressed(s) => {
905 hash_u8(h, 2);
906 hash_u32(h, rh);
907 hash_u32(h, rw);
908 hash_string(h, s);
909 }
910 SegmentationRleCounts::Uncompressed(counts) => {
911 hash_u8(h, 3);
912 hash_u32(h, rh);
913 hash_u32(h, rw);
914 hash_u64(h, counts.len() as u64);
915 for &c in counts.iter() {
916 hash_u32(h, c);
917 }
918 }
919 }
920 }
921 }
922}
923
924fn hash_id_sorted<T>(
930 h: &mut blake3::Hasher,
931 tag: &[u8; 4],
932 items: &[T],
933 key: impl Fn(&T) -> i64,
934 write: impl Fn(&mut blake3::Hasher, &T),
935) {
936 h.update(tag);
937 let mut order: Vec<usize> = (0..items.len()).collect();
938 order.sort_unstable_by_key(|&i| key(&items[i]));
939 hash_u64(h, order.len() as u64);
940 for &i in &order {
941 write(h, &items[i]);
942 }
943}
944
945fn hash_image_meta(h: &mut blake3::Hasher, im: &ImageMeta) {
946 let ImageMeta {
947 id,
948 width,
949 height,
950 file_name,
951 } = im;
952 hash_i64(h, id.0);
953 hash_u32(h, *width);
954 hash_u32(h, *height);
955 hash_option(h, file_name.as_deref(), hash_string);
956}
957
958fn hash_category_meta(h: &mut blake3::Hasher, c: &CategoryMeta) {
959 let CategoryMeta {
960 id,
961 name,
962 supercategory,
963 } = c;
964 hash_i64(h, id.0);
965 hash_string(h, name);
966 hash_option(h, supercategory.as_deref(), hash_string);
967}
968
969fn hash_coco_annotation(h: &mut blake3::Hasher, a: &CocoAnnotation) {
970 let CocoAnnotation {
973 id,
974 image_id,
975 category_id,
976 area,
977 is_crowd,
978 ignore_flag,
979 bbox,
980 segmentation,
981 keypoints,
982 num_keypoints,
983 } = a;
984 hash_i64(h, id.0);
985 hash_i64(h, image_id.0);
986 hash_i64(h, category_id.0);
987 hash_f64(h, *area);
988 hash_bool(h, *is_crowd);
989 hash_option(h, *ignore_flag, hash_bool);
990 hash_bbox(h, bbox);
991 hash_segmentation(h, segmentation.as_ref());
992 hash_option(h, keypoints.as_deref(), |h, kps| {
993 hash_u64(h, kps.len() as u64);
994 for &v in kps {
995 hash_f64(h, v);
996 }
997 });
998 hash_option(h, *num_keypoints, hash_u32);
999}
1000
1001fn hash_federated(h: &mut blake3::Hasher, fed: &FederatedMetadata) {
1002 h.update(HASH_TAG_FEDERATED);
1003
1004 let mut freq_pairs: Vec<(i64, &Frequency)> = fed
1006 .category_frequency
1007 .iter()
1008 .map(|(k, v)| (k.0, v))
1009 .collect();
1010 freq_pairs.sort_unstable_by_key(|(k, _)| *k);
1011 hash_u64(h, freq_pairs.len() as u64);
1012 for (cid, freq) in freq_pairs {
1013 hash_i64(h, cid);
1014 hash_u8(h, freq.as_letter().as_bytes()[0]);
1016 }
1017
1018 type FedSection<'a> = (&'a [u8; 3], &'a HashMap<ImageId, HashSet<CategoryId>>);
1022 let sections: [FedSection<'_>; 3] = [
1023 (b"POS", &fed.pos_category_ids),
1024 (b"NEG", &fed.neg_category_ids),
1025 (b"NEX", &fed.not_exhaustive_category_ids),
1026 ];
1027 for (tag, map) in sections {
1028 h.update(tag);
1029 let mut entries: Vec<(i64, Vec<i64>)> = map
1030 .iter()
1031 .map(|(image_id, cats)| {
1032 let mut cat_ids: Vec<i64> = cats.iter().map(|c| c.0).collect();
1033 cat_ids.sort_unstable();
1034 (image_id.0, cat_ids)
1035 })
1036 .collect();
1037 entries.sort_unstable_by_key(|(image_id, _)| *image_id);
1038 hash_u64(h, entries.len() as u64);
1039 for (image_id, cat_ids) in entries {
1040 hash_i64(h, image_id);
1041 hash_u64(h, cat_ids.len() as u64);
1042 for cid in cat_ids {
1043 hash_i64(h, cid);
1044 }
1045 }
1046 }
1047}
1048
1049impl CocoDataset {
1050 pub fn dataset_hash(&self) -> [u8; 32] {
1059 *self.cached_hash.get_or_init(|| self.compute_dataset_hash())
1060 }
1061
1062 fn compute_dataset_hash(&self) -> [u8; 32] {
1063 let mut h = blake3::Hasher::new();
1064 h.update(HASH_TAG_DATASET);
1065 hash_u8(&mut h, HASH_CANONICAL_VERSION);
1066
1067 hash_id_sorted(
1068 &mut h,
1069 HASH_TAG_IMAGES,
1070 &self.images,
1071 |im| im.id.0,
1072 hash_image_meta,
1073 );
1074 hash_id_sorted(
1075 &mut h,
1076 HASH_TAG_CATEGORIES,
1077 &self.categories,
1078 |c| c.id.0,
1079 hash_category_meta,
1080 );
1081 hash_id_sorted(
1082 &mut h,
1083 HASH_TAG_ANNOTATIONS,
1084 &self.annotations,
1085 |a| a.id.0,
1086 hash_coco_annotation,
1087 );
1088
1089 match self.federated.as_ref() {
1091 None => hash_u8(&mut h, 0),
1092 Some(fed) => {
1093 hash_u8(&mut h, 1);
1094 hash_federated(&mut h, fed);
1095 }
1096 }
1097
1098 *h.finalize().as_bytes()
1099 }
1100}
1101
1102#[derive(Debug, Clone, PartialEq)]
1117pub struct CocoDetection {
1118 pub id: AnnId,
1121 pub image_id: ImageId,
1123 pub category_id: CategoryId,
1125 pub score: f64,
1127 pub bbox: Bbox,
1129 pub area: f64,
1131 pub segmentation: Option<Segmentation>,
1135 pub keypoints: Option<Vec<f64>>,
1140 pub num_keypoints: Option<u32>,
1145}
1146
1147impl Annotation for CocoDetection {
1148 fn image_id(&self) -> ImageId {
1149 self.image_id
1150 }
1151 fn category_id(&self) -> CategoryId {
1152 self.category_id
1153 }
1154 fn area(&self) -> f64 {
1155 self.area
1156 }
1157 fn is_crowd(&self) -> bool {
1158 false
1159 }
1160 fn effective_ignore(&self, _: ParityMode) -> bool {
1161 false
1162 }
1163}
1164
1165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1168pub struct DetectionInput {
1169 #[serde(default)]
1171 pub id: Option<AnnId>,
1172 pub image_id: ImageId,
1174 pub category_id: CategoryId,
1176 pub score: f64,
1178 pub bbox: Bbox,
1180 #[serde(default, skip_serializing_if = "Option::is_none")]
1184 pub segmentation: Option<Segmentation>,
1185 #[serde(default, skip_serializing_if = "Option::is_none")]
1188 pub keypoints: Option<Vec<f64>>,
1189 #[serde(default, skip_serializing_if = "Option::is_none")]
1192 pub num_keypoints: Option<u32>,
1193}
1194
1195#[derive(Debug, Clone)]
1198pub struct CocoDetections {
1199 detections: Arc<Vec<CocoDetection>>,
1200 by_image_cat: HashMap<(ImageId, CategoryId), Vec<usize>>,
1201 by_image: HashMap<ImageId, Vec<usize>>,
1202}
1203
1204impl CocoDetections {
1205 pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, EvalError> {
1213 let raw: Vec<DetectionInput> = serde_json::from_slice(bytes)?;
1214 Self::from_inputs(raw)
1215 }
1216
1217 pub fn from_inputs(inputs: Vec<DetectionInput>) -> Result<Self, EvalError> {
1221 let mut detections = Vec::with_capacity(inputs.len());
1222 let mut next_auto = 1i64;
1223 for input in inputs {
1224 if !input.score.is_finite() {
1225 return Err(EvalError::NonFinite {
1226 context: "detection score",
1227 });
1228 }
1229 let id = match input.id {
1230 Some(id) => id,
1231 None => {
1232 let id = AnnId(next_auto);
1233 next_auto += 1;
1234 id
1235 }
1236 };
1237 detections.push(CocoDetection {
1238 id,
1239 image_id: input.image_id,
1240 category_id: input.category_id,
1241 score: input.score,
1242 bbox: input.bbox,
1243 area: input.bbox.w * input.bbox.h,
1244 segmentation: input.segmentation,
1245 keypoints: input.keypoints,
1246 num_keypoints: input.num_keypoints,
1247 });
1248 }
1249
1250 let mut by_image_cat: HashMap<(ImageId, CategoryId), Vec<usize>> = HashMap::new();
1251 let mut by_image: HashMap<ImageId, Vec<usize>> = HashMap::new();
1252 for (idx, dt) in detections.iter().enumerate() {
1253 by_image_cat
1254 .entry((dt.image_id, dt.category_id))
1255 .or_default()
1256 .push(idx);
1257 by_image.entry(dt.image_id).or_default().push(idx);
1258 }
1259
1260 Ok(Self {
1261 detections: Arc::new(detections),
1262 by_image_cat,
1263 by_image,
1264 })
1265 }
1266
1267 pub fn from_records(records: Vec<CocoDetection>) -> Self {
1273 let mut by_image_cat: HashMap<(ImageId, CategoryId), Vec<usize>> = HashMap::new();
1274 let mut by_image: HashMap<ImageId, Vec<usize>> = HashMap::new();
1275 for (idx, dt) in records.iter().enumerate() {
1276 by_image_cat
1277 .entry((dt.image_id, dt.category_id))
1278 .or_default()
1279 .push(idx);
1280 by_image.entry(dt.image_id).or_default().push(idx);
1281 }
1282 Self {
1283 detections: Arc::new(records),
1284 by_image_cat,
1285 by_image,
1286 }
1287 }
1288
1289 pub fn detections(&self) -> &[CocoDetection] {
1291 &self.detections
1292 }
1293
1294 pub fn indices_for(&self, image: ImageId, cat: CategoryId) -> &[usize] {
1298 self.by_image_cat
1299 .get(&(image, cat))
1300 .map_or(&[][..], Vec::as_slice)
1301 }
1302
1303 pub fn indices_for_image(&self, image: ImageId) -> &[usize] {
1307 self.by_image.get(&image).map_or(&[][..], Vec::as_slice)
1308 }
1309
1310 pub fn lvis_trim(&self, max_dets: i64) -> CocoDetections {
1339 if max_dets < 0 {
1340 return self.clone();
1343 }
1344 let cap = max_dets as usize;
1345 let mut by_image_groups: HashMap<ImageId, Vec<usize>> = HashMap::new();
1346 for (idx, dt) in self.detections.iter().enumerate() {
1347 by_image_groups.entry(dt.image_id).or_default().push(idx);
1348 }
1349 let mut image_ids: Vec<ImageId> = by_image_groups.keys().copied().collect();
1357 image_ids.sort_unstable_by_key(|i| i.0);
1358
1359 let upper_bound = self
1366 .detections
1367 .len()
1368 .min(cap.saturating_mul(image_ids.len()));
1369 let mut out: Vec<CocoDetection> = Vec::with_capacity(upper_bound);
1370 for image_id in image_ids {
1371 let mut group = by_image_groups.remove(&image_id).unwrap_or_default();
1372 group.sort_by(|&a, &b| {
1377 self.detections[b]
1378 .score
1379 .partial_cmp(&self.detections[a].score)
1380 .unwrap_or(std::cmp::Ordering::Equal)
1381 });
1382 for &idx in group.iter().take(cap) {
1383 out.push(self.detections[idx].clone());
1384 }
1385 }
1386 CocoDetections::from_records(out)
1387 }
1388}
1389
1390#[derive(Deserialize)]
1398#[serde(untagged)]
1399enum BoolOrInt {
1400 Bool(bool),
1401 Int(i64),
1402}
1403
1404impl BoolOrInt {
1405 fn into_bool<E: serde::de::Error>(self) -> Result<bool, E> {
1406 match self {
1407 Self::Bool(b) => Ok(b),
1408 Self::Int(0) => Ok(false),
1409 Self::Int(1) => Ok(true),
1410 Self::Int(other) => Err(E::custom(format!(
1411 "expected 0 or 1 for COCO bool field, got {other}"
1412 ))),
1413 }
1414 }
1415}
1416
1417fn deserialize_bool_int<'de, D>(de: D) -> Result<bool, D::Error>
1418where
1419 D: serde::Deserializer<'de>,
1420{
1421 BoolOrInt::deserialize(de)?.into_bool()
1422}
1423
1424fn deserialize_opt_bool_int<'de, D>(de: D) -> Result<Option<bool>, D::Error>
1425where
1426 D: serde::Deserializer<'de>,
1427{
1428 Option::<BoolOrInt>::deserialize(de)?
1429 .map(BoolOrInt::into_bool)
1430 .transpose()
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435 use super::*;
1436 use proptest::prelude::*;
1437
1438 const CROWD_REGION_GT: &str = r#"{
1439 "images": [
1440 {"id": 1, "width": 200, "height": 200, "file_name": "img1.png"}
1441 ],
1442 "annotations": [
1443 {"id": 1, "image_id": 1, "category_id": 1,
1444 "bbox": [100, 100, 50, 50], "area": 2500, "iscrowd": 0},
1445 {"id": 2, "image_id": 1, "category_id": 1,
1446 "bbox": [0, 0, 200, 200], "area": 40000, "iscrowd": 1}
1447 ],
1448 "categories": [
1449 {"id": 1, "name": "widget", "supercategory": "thing"}
1450 ]
1451 }"#;
1452
1453 fn load_crowd_region() -> CocoDataset {
1454 CocoDataset::from_json_bytes(CROWD_REGION_GT.as_bytes()).unwrap()
1455 }
1456
1457 #[test]
1458 fn loads_crowd_region_fixture() {
1459 let ds = load_crowd_region();
1460 assert_eq!(ds.images().len(), 1);
1461 assert_eq!(ds.categories().len(), 1);
1462 assert_eq!(ds.annotations().len(), 2);
1463 assert_eq!(ds.images()[0].file_name.as_deref(), Some("img1.png"));
1464 assert_eq!(ds.categories()[0].name, "widget");
1465 }
1466
1467 #[test]
1468 fn by_image_index_returns_both_anns() {
1469 let ds = load_crowd_region();
1470 let idxs = ds.ann_indices_for_image(ImageId(1));
1471 assert_eq!(idxs.len(), 2);
1472 let anns: Vec<_> = ds.ann_iter_for_image(ImageId(1)).collect();
1473 assert_eq!(anns.len(), 2);
1474 assert_eq!(anns[0].id, AnnId(1));
1475 assert_eq!(anns[1].id, AnnId(2));
1476 }
1477
1478 #[test]
1479 fn by_category_index_returns_both_anns() {
1480 let ds = load_crowd_region();
1481 let idxs = ds.ann_indices_for_category(CategoryId(1));
1482 assert_eq!(idxs.len(), 2);
1483 }
1484
1485 #[test]
1486 fn unknown_image_returns_empty_slice() {
1487 let ds = load_crowd_region();
1488 assert!(ds.ann_indices_for_image(ImageId(999)).is_empty());
1489 assert!(ds.ann_indices_for_category(CategoryId(999)).is_empty());
1490 }
1491
1492 #[test]
1493 fn empty_image_or_category_returns_empty_slice_not_missing() {
1494 const ONLY_EMPTY_IMG: &str = r#"{
1498 "images": [{"id": 7, "width": 1, "height": 1}],
1499 "annotations": [],
1500 "categories": [{"id": 3, "name": "thing"}]
1501 }"#;
1502 let ds = CocoDataset::from_json_bytes(ONLY_EMPTY_IMG.as_bytes()).unwrap();
1503 assert!(ds.ann_indices_for_image(ImageId(7)).is_empty());
1504 assert!(ds.ann_indices_for_category(CategoryId(3)).is_empty());
1505 }
1506
1507 #[test]
1508 fn rejects_annotation_referencing_unknown_image() {
1509 const BAD: &str = r#"{
1510 "images": [{"id": 1, "width": 10, "height": 10}],
1511 "annotations": [
1512 {"id": 1, "image_id": 99, "category_id": 1,
1513 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0}
1514 ],
1515 "categories": [{"id": 1, "name": "thing"}]
1516 }"#;
1517 let err = CocoDataset::from_json_bytes(BAD.as_bytes()).unwrap_err();
1518 match err {
1519 EvalError::InvalidAnnotation { detail } => {
1520 assert!(detail.contains("image_id=99"), "msg: {detail}");
1521 }
1522 other => panic!("expected InvalidAnnotation, got {other:?}"),
1523 }
1524 }
1525
1526 #[test]
1527 fn rejects_annotation_referencing_unknown_category() {
1528 const BAD: &str = r#"{
1529 "images": [{"id": 1, "width": 10, "height": 10}],
1530 "annotations": [
1531 {"id": 1, "image_id": 1, "category_id": 42,
1532 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0}
1533 ],
1534 "categories": [{"id": 1, "name": "thing"}]
1535 }"#;
1536 let err = CocoDataset::from_json_bytes(BAD.as_bytes()).unwrap_err();
1537 match err {
1538 EvalError::InvalidAnnotation { detail } => {
1539 assert!(detail.contains("category_id=42"), "msg: {detail}");
1540 }
1541 other => panic!("expected InvalidAnnotation, got {other:?}"),
1542 }
1543 }
1544
1545 #[test]
1546 fn round_trips_through_json() {
1547 let ds = load_crowd_region();
1548 let json = serde_json::to_string(&ds.to_json_value()).unwrap();
1549 let again = CocoDataset::from_json_bytes(json.as_bytes()).unwrap();
1550 assert_eq!(ds.images(), again.images());
1551 assert_eq!(ds.categories(), again.categories());
1552 assert_eq!(ds.annotations(), again.annotations());
1553 }
1554
1555 #[test]
1558 fn d1_strict_mode_drops_explicit_ignore_field() {
1559 const ANN_JSON: &str = r#"{
1563 "images": [{"id": 1, "width": 10, "height": 10}],
1564 "annotations": [
1565 {"id": 1, "image_id": 1, "category_id": 1,
1566 "bbox": [0, 0, 1, 1], "area": 1,
1567 "iscrowd": 0, "ignore": 1}
1568 ],
1569 "categories": [{"id": 1, "name": "thing"}]
1570 }"#;
1571 let ds = CocoDataset::from_json_bytes(ANN_JSON.as_bytes()).unwrap();
1572 let ann = &ds.annotations()[0];
1573 assert!(!ann.effective_ignore(ParityMode::Strict));
1574 assert!(ann.effective_ignore(ParityMode::Corrected));
1575 }
1576
1577 #[test]
1578 fn d1_strict_mode_uses_iscrowd_when_ignore_absent() {
1579 const ANN_JSON: &str = r#"{
1582 "images": [{"id": 1, "width": 10, "height": 10}],
1583 "annotations": [
1584 {"id": 1, "image_id": 1, "category_id": 1,
1585 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 1}
1586 ],
1587 "categories": [{"id": 1, "name": "thing"}]
1588 }"#;
1589 let ds = CocoDataset::from_json_bytes(ANN_JSON.as_bytes()).unwrap();
1590 let ann = &ds.annotations()[0];
1591 assert!(ann.effective_ignore(ParityMode::Strict));
1592 assert!(ann.effective_ignore(ParityMode::Corrected));
1593 }
1594
1595 #[test]
1598 fn ann_indices_for_image_cat_returns_correct_subset() {
1599 const TWO_CATS: &str = r#"{
1600 "images": [{"id": 1, "width": 10, "height": 10}],
1601 "annotations": [
1602 {"id": 1, "image_id": 1, "category_id": 1,
1603 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0},
1604 {"id": 2, "image_id": 1, "category_id": 2,
1605 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0},
1606 {"id": 3, "image_id": 1, "category_id": 1,
1607 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0}
1608 ],
1609 "categories": [
1610 {"id": 1, "name": "a"}, {"id": 2, "name": "b"}
1611 ]
1612 }"#;
1613 let ds = CocoDataset::from_json_bytes(TWO_CATS.as_bytes()).unwrap();
1614 let cat1: Vec<AnnId> = ds
1615 .ann_indices_for(ImageId(1), CategoryId(1))
1616 .iter()
1617 .map(|&i| ds.annotations()[i].id)
1618 .collect();
1619 assert_eq!(cat1, vec![AnnId(1), AnnId(3)]);
1620 let cat2: Vec<AnnId> = ds
1621 .ann_indices_for(ImageId(1), CategoryId(2))
1622 .iter()
1623 .map(|&i| ds.annotations()[i].id)
1624 .collect();
1625 assert_eq!(cat2, vec![AnnId(2)]);
1626 assert!(ds.ann_indices_for(ImageId(1), CategoryId(99)).is_empty());
1627 assert!(ds.ann_indices_for(ImageId(99), CategoryId(1)).is_empty());
1628 }
1629
1630 fn dt_input(image: i64, cat: i64, score: f64, bbox: (f64, f64, f64, f64)) -> DetectionInput {
1633 DetectionInput {
1634 id: None,
1635 image_id: ImageId(image),
1636 category_id: CategoryId(cat),
1637 score,
1638 bbox: Bbox {
1639 x: bbox.0,
1640 y: bbox.1,
1641 w: bbox.2,
1642 h: bbox.3,
1643 },
1644 segmentation: None,
1645 keypoints: None,
1646 num_keypoints: None,
1647 }
1648 }
1649
1650 #[test]
1651 fn j1_auto_assigns_ids_when_absent() {
1652 let dts = CocoDetections::from_inputs(vec![
1653 dt_input(1, 1, 0.9, (0.0, 0.0, 1.0, 1.0)),
1654 dt_input(1, 1, 0.8, (0.0, 0.0, 1.0, 1.0)),
1655 ])
1656 .unwrap();
1657 let ids: Vec<AnnId> = dts.detections().iter().map(|d| d.id).collect();
1658 assert_eq!(ids, vec![AnnId(1), AnnId(2)]);
1659 }
1660
1661 #[test]
1662 fn j1_preserves_user_supplied_ids() {
1663 let mut a = dt_input(1, 1, 0.9, (0.0, 0.0, 1.0, 1.0));
1664 a.id = Some(AnnId(42));
1665 let mut b = dt_input(1, 1, 0.8, (0.0, 0.0, 1.0, 1.0));
1666 b.id = Some(AnnId(7));
1667 let dts = CocoDetections::from_inputs(vec![a, b]).unwrap();
1668 let ids: Vec<AnnId> = dts.detections().iter().map(|d| d.id).collect();
1669 assert_eq!(ids, vec![AnnId(42), AnnId(7)]);
1670 }
1671
1672 #[test]
1673 fn j3_derives_area_from_bbox() {
1674 let dts =
1675 CocoDetections::from_inputs(vec![dt_input(1, 1, 0.5, (10.0, 10.0, 4.0, 5.0))]).unwrap();
1676 assert_eq!(dts.detections()[0].area, 20.0);
1677 }
1678
1679 #[test]
1680 fn rejects_non_finite_score() {
1681 let err = CocoDetections::from_inputs(vec![dt_input(1, 1, f64::NAN, (0.0, 0.0, 1.0, 1.0))])
1682 .unwrap_err();
1683 assert!(matches!(
1684 err,
1685 EvalError::NonFinite {
1686 context: "detection score"
1687 }
1688 ));
1689 }
1690
1691 #[test]
1692 fn detections_indices_per_image_cat() {
1693 let dts = CocoDetections::from_inputs(vec![
1694 dt_input(1, 1, 0.9, (0.0, 0.0, 1.0, 1.0)),
1695 dt_input(1, 2, 0.8, (0.0, 0.0, 1.0, 1.0)),
1696 dt_input(2, 1, 0.7, (0.0, 0.0, 1.0, 1.0)),
1697 ])
1698 .unwrap();
1699 assert_eq!(dts.indices_for(ImageId(1), CategoryId(1)), &[0]);
1700 assert_eq!(dts.indices_for(ImageId(1), CategoryId(2)), &[1]);
1701 assert_eq!(dts.indices_for(ImageId(2), CategoryId(1)), &[2]);
1702 assert!(dts.indices_for(ImageId(99), CategoryId(1)).is_empty());
1703 let img1: Vec<usize> = dts.indices_for_image(ImageId(1)).to_vec();
1705 assert_eq!(img1, vec![0, 1]);
1706 }
1707
1708 #[test]
1709 fn loads_detections_from_json_array() {
1710 const JSON: &str = r#"[
1711 {"image_id": 1, "category_id": 1, "score": 0.9,
1712 "bbox": [0, 0, 2, 3]},
1713 {"id": 7, "image_id": 1, "category_id": 1, "score": 0.5,
1714 "bbox": [1, 1, 1, 1]}
1715 ]"#;
1716 let dts = CocoDetections::from_json_bytes(JSON.as_bytes()).unwrap();
1717 let ds = dts.detections();
1718 assert_eq!(ds[0].id, AnnId(1)); assert_eq!(ds[0].area, 6.0); assert_eq!(ds[1].id, AnnId(7)); assert!(!ds[0].is_crowd()); assert!(ds[0].segmentation.is_none());
1723 }
1724
1725 #[test]
1728 fn gt_loads_polygon_segmentation() {
1729 const JSON: &str = r#"{
1730 "images": [{"id": 1, "width": 10, "height": 10}],
1731 "annotations": [
1732 {"id": 1, "image_id": 1, "category_id": 1,
1733 "bbox": [0, 0, 4, 4], "area": 16, "iscrowd": 0,
1734 "segmentation": [[0, 0, 4, 0, 4, 4, 0, 4]]}
1735 ],
1736 "categories": [{"id": 1, "name": "thing"}]
1737 }"#;
1738 let ds = CocoDataset::from_json_bytes(JSON.as_bytes()).unwrap();
1739 let seg = ds.annotations()[0].segmentation.as_ref().unwrap();
1740 let rle = seg.to_rle(10, 10).unwrap();
1741 assert_eq!(rle.area(), 16);
1742 }
1743
1744 #[test]
1745 fn gt_loads_compressed_rle_segmentation() {
1746 let counts_str = String::from_utf8(vernier_mask::encode_counts(&[0, 16])).unwrap();
1747 let json = format!(
1748 r#"{{
1749 "images": [{{"id": 1, "width": 4, "height": 4}}],
1750 "annotations": [
1751 {{"id": 1, "image_id": 1, "category_id": 1,
1752 "bbox": [0, 0, 4, 4], "area": 16, "iscrowd": 1,
1753 "segmentation": {{"size": [4, 4], "counts": "{counts_str}"}}}}
1754 ],
1755 "categories": [{{"id": 1, "name": "thing"}}]
1756 }}"#
1757 );
1758 let ds = CocoDataset::from_json_bytes(json.as_bytes()).unwrap();
1759 let seg = ds.annotations()[0].segmentation.as_ref().unwrap();
1760 let rle = seg.to_rle(4, 4).unwrap();
1761 assert_eq!((rle.h, rle.w), (4, 4));
1762 assert_eq!(rle.area(), 16);
1763 }
1764
1765 #[test]
1766 fn gt_segmentation_round_trips_through_to_json_value() {
1767 const JSON: &str = r#"{
1768 "images": [{"id": 1, "width": 10, "height": 10}],
1769 "annotations": [
1770 {"id": 1, "image_id": 1, "category_id": 1,
1771 "bbox": [0, 0, 4, 4], "area": 16, "iscrowd": 0,
1772 "segmentation": [[0, 0, 4, 0, 4, 4, 0, 4]]}
1773 ],
1774 "categories": [{"id": 1, "name": "thing"}]
1775 }"#;
1776 let ds = CocoDataset::from_json_bytes(JSON.as_bytes()).unwrap();
1777 let serialized = serde_json::to_string(&ds.to_json_value()).unwrap();
1778 let again = CocoDataset::from_json_bytes(serialized.as_bytes()).unwrap();
1779 assert_eq!(ds.annotations(), again.annotations());
1780 }
1781
1782 #[test]
1783 fn gt_without_segmentation_field_loads_as_none() {
1784 let ds = load_crowd_region();
1785 assert!(ds.annotations().iter().all(|a| a.segmentation.is_none()));
1786 }
1787
1788 #[test]
1789 fn dt_loads_compressed_rle_segmentation() {
1790 const JSON: &str = r#"[
1791 {"image_id": 1, "category_id": 1, "score": 0.9,
1792 "bbox": [0, 0, 4, 4],
1793 "segmentation": {"size": [4, 4], "counts": "04L4"}}
1794 ]"#;
1795 let dts = CocoDetections::from_json_bytes(JSON.as_bytes()).unwrap();
1796 assert!(dts.detections()[0].segmentation.is_some());
1797 }
1798
1799 #[test]
1800 fn dt_without_segmentation_loads_as_none() {
1801 const JSON: &str = r#"[
1802 {"image_id": 1, "category_id": 1, "score": 0.9, "bbox": [0, 0, 1, 1]}
1803 ]"#;
1804 let dts = CocoDetections::from_json_bytes(JSON.as_bytes()).unwrap();
1805 assert!(dts.detections()[0].segmentation.is_none());
1806 }
1807
1808 fn arb_image() -> impl Strategy<Value = ImageMeta> {
1811 (1i64..1000, 1u32..2048, 1u32..2048).prop_map(|(id, w, h)| ImageMeta {
1812 id: ImageId(id),
1813 width: w,
1814 height: h,
1815 file_name: None,
1816 })
1817 }
1818
1819 fn arb_category() -> impl Strategy<Value = CategoryMeta> {
1820 (1i64..100, "[a-z]{1,8}").prop_map(|(id, name)| CategoryMeta {
1821 id: CategoryId(id),
1822 name,
1823 supercategory: None,
1824 })
1825 }
1826
1827 fn make_min_annotation(
1831 id: AnnId,
1832 image_id: ImageId,
1833 category_id: CategoryId,
1834 ) -> CocoAnnotation {
1835 CocoAnnotation {
1836 id,
1837 image_id,
1838 category_id,
1839 area: 25.0,
1840 is_crowd: false,
1841 ignore_flag: None,
1842 bbox: Bbox {
1843 x: 0.0,
1844 y: 0.0,
1845 w: 5.0,
1846 h: 5.0,
1847 },
1848 segmentation: None,
1849 keypoints: None,
1850 num_keypoints: None,
1851 }
1852 }
1853
1854 proptest! {
1855 #![proptest_config(ProptestConfig::with_cases(64))]
1856
1857 #[test]
1858 fn index_invariants_hold(
1859 images in proptest::collection::vec(arb_image(), 1..6),
1866 categories in proptest::collection::vec(arb_category(), 1..6),
1867 n_anns in 0usize..40,
1868 ann_seed in any::<u64>(),
1869 ) {
1870 let mut images = images;
1874 images.sort_by_key(|i| i.id);
1875 images.dedup_by_key(|i| i.id);
1876 let mut categories = categories;
1877 categories.sort_by_key(|c| c.id);
1878 categories.dedup_by_key(|c| c.id);
1879
1880 let mut state = ann_seed.wrapping_add(1);
1883 let mut next = || {
1884 state = state.wrapping_mul(6364136223846793005)
1885 .wrapping_add(1442695040888963407);
1886 state
1887 };
1888
1889 let mut annotations = Vec::with_capacity(n_anns);
1890 for ann_idx in 0..n_anns {
1891 let img = &images[(next() as usize) % images.len()];
1892 let cat = &categories[(next() as usize) % categories.len()];
1893 annotations.push(CocoAnnotation {
1894 id: AnnId(ann_idx as i64 + 1),
1895 image_id: img.id,
1896 category_id: cat.id,
1897 area: 1.0,
1898 is_crowd: false,
1899 ignore_flag: None,
1900 bbox: Bbox { x: 0.0, y: 0.0, w: 1.0, h: 1.0 },
1901 segmentation: None,
1902 keypoints: None,
1903 num_keypoints: None,
1904 });
1905 }
1906
1907 let ds = CocoDataset::from_parts(
1908 images.clone(), annotations.clone(), categories.clone()
1909 ).unwrap();
1910
1911 let mut seen_img: Vec<usize> = images.iter()
1915 .flat_map(|i| ds.ann_indices_for_image(i.id).iter().copied())
1916 .collect();
1917 seen_img.sort_unstable();
1918 let expected: Vec<usize> = (0..annotations.len()).collect();
1919 prop_assert_eq!(&seen_img, &expected);
1920
1921 let mut seen_cat: Vec<usize> = categories.iter()
1922 .flat_map(|c| ds.ann_indices_for_category(c.id).iter().copied())
1923 .collect();
1924 seen_cat.sort_unstable();
1925 prop_assert_eq!(&seen_cat, &expected);
1926
1927 for img in &images {
1929 for &idx in ds.ann_indices_for_image(img.id) {
1930 prop_assert_eq!(ds.annotations()[idx].image_id, img.id);
1931 }
1932 }
1933 for cat in &categories {
1934 for &idx in ds.ann_indices_for_category(cat.id) {
1935 prop_assert_eq!(ds.annotations()[idx].category_id, cat.id);
1936 }
1937 }
1938 }
1939 }
1940
1941 const LVIS_MIN_VALID: &str = r#"{
1949 "images": [
1950 {"id": 1, "width": 100, "height": 100,
1951 "neg_category_ids": [2], "not_exhaustive_category_ids": []},
1952 {"id": 2, "width": 100, "height": 100,
1953 "neg_category_ids": [], "not_exhaustive_category_ids": [2]}
1954 ],
1955 "annotations": [
1956 {"id": 1, "image_id": 1, "category_id": 1,
1957 "bbox": [0, 0, 10, 10], "area": 100, "iscrowd": 0},
1958 {"id": 2, "image_id": 2, "category_id": 2,
1959 "bbox": [0, 0, 20, 20], "area": 400, "iscrowd": 0}
1960 ],
1961 "categories": [
1962 {"id": 1, "name": "a", "frequency": "f"},
1963 {"id": 2, "name": "b", "frequency": "r"}
1964 ]
1965 }"#;
1966
1967 #[test]
1968 fn lvis_loads_minimal_valid_dataset() {
1969 let ds = CocoDataset::from_lvis_json_bytes(LVIS_MIN_VALID.as_bytes()).unwrap();
1970 assert_eq!(ds.images().len(), 2);
1972 assert_eq!(ds.categories().len(), 2);
1973 assert_eq!(ds.annotations().len(), 2);
1974 assert!(ds.is_federated());
1976 let pos = ds.pos_category_ids().unwrap();
1977 let neg = ds.neg_category_ids().unwrap();
1978 let nel = ds.not_exhaustive_category_ids().unwrap();
1979 let freq = ds.category_frequency().unwrap();
1980 assert_eq!(pos[&ImageId(1)], HashSet::from([CategoryId(1)]));
1982 assert_eq!(pos[&ImageId(2)], HashSet::from([CategoryId(2)]));
1983 assert_eq!(neg[&ImageId(1)], HashSet::from([CategoryId(2)]));
1985 assert_eq!(neg[&ImageId(2)], HashSet::new());
1986 assert_eq!(nel[&ImageId(1)], HashSet::new());
1988 assert_eq!(nel[&ImageId(2)], HashSet::from([CategoryId(2)]));
1989 assert_eq!(freq[&CategoryId(1)], Frequency::Frequent);
1991 assert_eq!(freq[&CategoryId(2)], Frequency::Rare);
1992 }
1993
1994 #[test]
1995 fn aa1_pos_derived_from_gts_does_not_include_zero_ann_categories() {
1996 let ds = CocoDataset::from_lvis_json_bytes(LVIS_MIN_VALID.as_bytes()).unwrap();
1999 let pos = ds.pos_category_ids().unwrap();
2000 assert!(!pos[&ImageId(1)].contains(&CategoryId(2)));
2001 assert!(!pos[&ImageId(2)].contains(&CategoryId(1)));
2002 }
2003
2004 #[test]
2005 fn from_json_bytes_leaves_federated_metadata_none() {
2006 let ds = CocoDataset::from_json_bytes(LVIS_MIN_VALID.as_bytes()).unwrap();
2010 assert!(!ds.is_federated());
2011 assert!(ds.pos_category_ids().is_none());
2012 assert!(ds.neg_category_ids().is_none());
2013 assert!(ds.not_exhaustive_category_ids().is_none());
2014 assert!(ds.category_frequency().is_none());
2015 }
2016
2017 #[test]
2018 fn aa7_pos_intersect_neg_rejected() {
2019 const BAD: &str = r#"{
2022 "images": [
2023 {"id": 1, "width": 10, "height": 10,
2024 "neg_category_ids": [1], "not_exhaustive_category_ids": []}
2025 ],
2026 "annotations": [
2027 {"id": 1, "image_id": 1, "category_id": 1,
2028 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0}
2029 ],
2030 "categories": [{"id": 1, "name": "a", "frequency": "f"}]
2031 }"#;
2032 let err = CocoDataset::from_lvis_json_bytes(BAD.as_bytes()).unwrap_err();
2033 match err {
2034 EvalError::LvisFederatedConflict {
2035 image_id,
2036 category_id,
2037 detail,
2038 } => {
2039 assert_eq!(image_id, 1);
2040 assert_eq!(category_id, 1);
2041 assert!(detail.contains("GT"));
2042 }
2043 other => panic!("expected LvisFederatedConflict, got {other:?}"),
2044 }
2045 }
2046
2047 #[test]
2048 fn aa7_not_exhaustive_outside_pos_rejected() {
2049 const BAD: &str = r#"{
2052 "images": [
2053 {"id": 1, "width": 10, "height": 10,
2054 "neg_category_ids": [], "not_exhaustive_category_ids": [2]}
2055 ],
2056 "annotations": [
2057 {"id": 1, "image_id": 1, "category_id": 1,
2058 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0}
2059 ],
2060 "categories": [
2061 {"id": 1, "name": "a", "frequency": "f"},
2062 {"id": 2, "name": "b", "frequency": "r"}
2063 ]
2064 }"#;
2065 let err = CocoDataset::from_lvis_json_bytes(BAD.as_bytes()).unwrap_err();
2066 match err {
2067 EvalError::LvisFederatedConflict {
2068 image_id,
2069 category_id,
2070 detail,
2071 } => {
2072 assert_eq!(image_id, 1);
2073 assert_eq!(category_id, 2);
2074 assert!(detail.contains("not_exhaustive"));
2075 }
2076 other => panic!("expected LvisFederatedConflict, got {other:?}"),
2077 }
2078 }
2079
2080 #[test]
2081 fn ab6_missing_frequency_collects_all_offenders() {
2082 const BAD: &str = r#"{
2085 "images": [
2086 {"id": 1, "width": 10, "height": 10,
2087 "neg_category_ids": [], "not_exhaustive_category_ids": []}
2088 ],
2089 "annotations": [],
2090 "categories": [
2091 {"id": 7, "name": "g"},
2092 {"id": 3, "name": "c"}
2093 ]
2094 }"#;
2095 let err = CocoDataset::from_lvis_json_bytes(BAD.as_bytes()).unwrap_err();
2096 match err {
2097 EvalError::MissingFrequency { category_ids } => {
2098 assert_eq!(category_ids, vec![3, 7]);
2099 }
2100 other => panic!("expected MissingFrequency, got {other:?}"),
2101 }
2102 }
2103
2104 #[test]
2105 fn lvis_loader_treats_absent_neg_field_as_empty() {
2106 const TOLERANT: &str = r#"{
2110 "images": [{"id": 1, "width": 10, "height": 10}],
2111 "annotations": [],
2112 "categories": [{"id": 1, "name": "a", "frequency": "c"}]
2113 }"#;
2114 let ds = CocoDataset::from_lvis_json_bytes(TOLERANT.as_bytes()).unwrap();
2115 let neg = ds.neg_category_ids().unwrap();
2116 let nel = ds.not_exhaustive_category_ids().unwrap();
2117 assert!(neg[&ImageId(1)].is_empty());
2118 assert!(nel[&ImageId(1)].is_empty());
2119 }
2120
2121 #[test]
2122 fn frequency_round_trips_serde() {
2123 for f in [Frequency::Rare, Frequency::Common, Frequency::Frequent] {
2124 let s = serde_json::to_string(&f).unwrap();
2125 let back: Frequency = serde_json::from_str(&s).unwrap();
2126 assert_eq!(f, back);
2127 }
2128 assert_eq!(serde_json::to_string(&Frequency::Rare).unwrap(), "\"r\"");
2130 assert_eq!(serde_json::to_string(&Frequency::Common).unwrap(), "\"c\"");
2131 assert_eq!(
2132 serde_json::to_string(&Frequency::Frequent).unwrap(),
2133 "\"f\""
2134 );
2135 }
2136
2137 #[test]
2140 fn ac2_q1_trims_500_single_category_to_300() {
2141 let dts = CocoDetections::from_inputs(
2145 (0..500)
2146 .map(|i| {
2147 let score = 1.0 - (i as f64) / 1000.0; dt_input(1, 1, score, (0.0, 0.0, 1.0, 1.0))
2149 })
2150 .collect(),
2151 )
2152 .unwrap();
2153 let trimmed = dts.lvis_trim(300);
2154 assert_eq!(trimmed.detections().len(), 300);
2155 let scores: Vec<f64> = trimmed.detections().iter().map(|d| d.score).collect();
2157 for w in scores.windows(2) {
2158 assert!(
2159 w[0] >= w[1],
2160 "lvis_trim must preserve score-descending order"
2161 );
2162 }
2163 assert!((scores[0] - 1.0).abs() < 1e-12);
2164 assert!((scores[299] - 0.701).abs() < 1e-12);
2167 }
2168
2169 #[test]
2170 fn ac3_q2_cross_class_crowding_keeps_300_total_across_classes() {
2171 let mut inputs = Vec::with_capacity(600);
2178 for i in 0..250 {
2179 let score = 0.5 - (i as f64) * 0.002;
2181 inputs.push(dt_input(1, 1, score, (0.0, 0.0, 1.0, 1.0)));
2182 }
2183 for i in 0..350 {
2184 let score = 1.0 - (i as f64) * 0.002;
2191 inputs.push(dt_input(1, 2, score, (0.0, 0.0, 1.0, 1.0)));
2192 }
2193 let dts = CocoDetections::from_inputs(inputs).unwrap();
2194 let trimmed = dts.lvis_trim(300);
2195 assert_eq!(trimmed.detections().len(), 300);
2197 let n_cat1 = trimmed
2201 .detections()
2202 .iter()
2203 .filter(|d| d.category_id == CategoryId(1))
2204 .count();
2205 let n_cat2 = trimmed
2206 .detections()
2207 .iter()
2208 .filter(|d| d.category_id == CategoryId(2))
2209 .count();
2210 assert_eq!(n_cat1 + n_cat2, 300);
2214 assert!(n_cat1 > 0, "cat 1 must keep at least its top-score entries");
2217 assert!(n_cat2 > 0, "cat 2 must keep its high-score entries");
2218 }
2219
2220 #[test]
2221 fn ac5_negative_max_dets_disables_trim() {
2222 let dts = CocoDetections::from_inputs(
2226 (0..50)
2227 .map(|i| dt_input(1, 1, i as f64 / 100.0, (0.0, 0.0, 1.0, 1.0)))
2228 .collect(),
2229 )
2230 .unwrap();
2231 let trimmed = dts.lvis_trim(-1);
2232 assert_eq!(trimmed.detections().len(), 50);
2233 for (i, dt) in trimmed.detections().iter().enumerate() {
2235 assert!((dt.score - (i as f64 / 100.0)).abs() < 1e-12);
2236 }
2237 }
2238
2239 #[test]
2240 fn ac5_max_dets_at_capacity_is_no_op() {
2241 let dts = CocoDetections::from_inputs(
2246 (0..10)
2247 .map(|i| dt_input(1, 1, i as f64 / 10.0, (0.0, 0.0, 1.0, 1.0)))
2248 .collect(),
2249 )
2250 .unwrap();
2251 let trimmed = dts.lvis_trim(100);
2252 assert_eq!(trimmed.detections().len(), 10);
2253 }
2254
2255 #[test]
2256 fn ac4_stable_sort_preserves_input_order_for_score_ties() {
2257 let mut a = dt_input(1, 1, 0.5, (0.0, 0.0, 1.0, 1.0));
2263 a.id = Some(AnnId(100));
2264 let mut b = dt_input(1, 1, 0.5, (1.0, 0.0, 1.0, 1.0));
2265 b.id = Some(AnnId(200));
2266 let dts = CocoDetections::from_inputs(vec![a, b]).unwrap();
2267 let trimmed = dts.lvis_trim(2);
2268 let ids: Vec<AnnId> = trimmed.detections().iter().map(|d| d.id).collect();
2269 assert_eq!(
2270 ids,
2271 vec![AnnId(100), AnnId(200)],
2272 "AC4: stable sort must preserve input order on score ties"
2273 );
2274 }
2275
2276 #[test]
2277 fn lvis_trim_groups_by_image_id() {
2278 let mut inputs = Vec::with_capacity(15);
2283 for img in 1..=3i64 {
2284 for i in 0..5 {
2285 let score = 1.0 - (img as f64) * 0.01 - (i as f64) * 0.001;
2286 inputs.push(dt_input(img, img, score, (0.0, 0.0, 1.0, 1.0)));
2287 }
2288 }
2289 let dts = CocoDetections::from_inputs(inputs).unwrap();
2290 let trimmed = dts.lvis_trim(2);
2291 assert_eq!(trimmed.detections().len(), 6);
2292 for img in 1..=3i64 {
2294 let n = trimmed
2295 .detections()
2296 .iter()
2297 .filter(|d| d.image_id == ImageId(img))
2298 .count();
2299 assert_eq!(n, 2, "image {img} must trim to 2");
2300 }
2301 }
2302
2303 #[test]
2304 fn lvis_trim_zero_max_dets_keeps_nothing() {
2305 let dts = CocoDetections::from_inputs(vec![
2306 dt_input(1, 1, 0.9, (0.0, 0.0, 1.0, 1.0)),
2307 dt_input(1, 1, 0.5, (0.0, 0.0, 1.0, 1.0)),
2308 ])
2309 .unwrap();
2310 let trimmed = dts.lvis_trim(0);
2311 assert!(trimmed.detections().is_empty());
2312 }
2313
2314 #[test]
2315 fn lvis_loader_inherits_invalid_annotation_validation() {
2316 const BAD: &str = r#"{
2319 "images": [
2320 {"id": 1, "width": 10, "height": 10,
2321 "neg_category_ids": [], "not_exhaustive_category_ids": []}
2322 ],
2323 "annotations": [
2324 {"id": 1, "image_id": 99, "category_id": 1,
2325 "bbox": [0, 0, 1, 1], "area": 1, "iscrowd": 0}
2326 ],
2327 "categories": [{"id": 1, "name": "a", "frequency": "f"}]
2328 }"#;
2329 let err = CocoDataset::from_lvis_json_bytes(BAD.as_bytes()).unwrap_err();
2330 assert!(matches!(err, EvalError::InvalidAnnotation { .. }));
2331 }
2332
2333 #[test]
2338 fn dataset_hash_is_stable_for_equal_inputs() {
2339 let a = load_crowd_region();
2340 let b = load_crowd_region();
2341 assert_eq!(a.dataset_hash(), b.dataset_hash());
2342 }
2343
2344 #[test]
2345 fn dataset_hash_caches_via_arc_clone() {
2346 let a = load_crowd_region();
2350 let b = a.clone();
2351 let h1 = a.dataset_hash();
2352 let h2 = b.dataset_hash();
2353 assert_eq!(h1, h2);
2354 }
2355
2356 #[test]
2357 fn dataset_hash_invariant_to_image_order() {
2358 let order_a = r#"{
2361 "images": [
2362 {"id": 1, "width": 10, "height": 10},
2363 {"id": 2, "width": 20, "height": 20}
2364 ],
2365 "annotations": [
2366 {"id": 1, "image_id": 1, "category_id": 1,
2367 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0}
2368 ],
2369 "categories": [{"id": 1, "name": "x"}]
2370 }"#;
2371 let order_b = r#"{
2372 "images": [
2373 {"id": 2, "width": 20, "height": 20},
2374 {"id": 1, "width": 10, "height": 10}
2375 ],
2376 "annotations": [
2377 {"id": 1, "image_id": 1, "category_id": 1,
2378 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0}
2379 ],
2380 "categories": [{"id": 1, "name": "x"}]
2381 }"#;
2382 let a = CocoDataset::from_json_bytes(order_a.as_bytes()).unwrap();
2383 let b = CocoDataset::from_json_bytes(order_b.as_bytes()).unwrap();
2384 assert_eq!(a.dataset_hash(), b.dataset_hash());
2385 }
2386
2387 #[test]
2388 fn dataset_hash_invariant_to_annotation_order() {
2389 let order_a = r#"{
2390 "images": [{"id": 1, "width": 200, "height": 200}],
2391 "annotations": [
2392 {"id": 1, "image_id": 1, "category_id": 1,
2393 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0},
2394 {"id": 2, "image_id": 1, "category_id": 1,
2395 "bbox": [10, 10, 5, 5], "area": 25, "iscrowd": 0}
2396 ],
2397 "categories": [{"id": 1, "name": "x"}]
2398 }"#;
2399 let order_b = r#"{
2400 "images": [{"id": 1, "width": 200, "height": 200}],
2401 "annotations": [
2402 {"id": 2, "image_id": 1, "category_id": 1,
2403 "bbox": [10, 10, 5, 5], "area": 25, "iscrowd": 0},
2404 {"id": 1, "image_id": 1, "category_id": 1,
2405 "bbox": [0, 0, 5, 5], "area": 25, "iscrowd": 0}
2406 ],
2407 "categories": [{"id": 1, "name": "x"}]
2408 }"#;
2409 let a = CocoDataset::from_json_bytes(order_a.as_bytes()).unwrap();
2410 let b = CocoDataset::from_json_bytes(order_b.as_bytes()).unwrap();
2411 assert_eq!(a.dataset_hash(), b.dataset_hash());
2412 }
2413
2414 #[test]
2415 fn dataset_hash_changes_when_bbox_changes_by_one_pixel() {
2416 let base = r#"{
2417 "images": [{"id": 1, "width": 200, "height": 200}],
2418 "annotations": [
2419 {"id": 1, "image_id": 1, "category_id": 1,
2420 "bbox": [10, 10, 5, 5], "area": 25, "iscrowd": 0}
2421 ],
2422 "categories": [{"id": 1, "name": "x"}]
2423 }"#;
2424 let shifted = r#"{
2425 "images": [{"id": 1, "width": 200, "height": 200}],
2426 "annotations": [
2427 {"id": 1, "image_id": 1, "category_id": 1,
2428 "bbox": [11, 10, 5, 5], "area": 25, "iscrowd": 0}
2429 ],
2430 "categories": [{"id": 1, "name": "x"}]
2431 }"#;
2432 let a = CocoDataset::from_json_bytes(base.as_bytes()).unwrap();
2433 let b = CocoDataset::from_json_bytes(shifted.as_bytes()).unwrap();
2434 assert_ne!(a.dataset_hash(), b.dataset_hash());
2435 }
2436
2437 proptest! {
2438 #[test]
2439 fn dataset_hash_invariant_under_id_shuffle(
2440 mut images in proptest::collection::vec(arb_image(), 1..16),
2441 categories in proptest::collection::vec(arb_category(), 1..4),
2442 ) {
2443 images.sort_by_key(|im| im.id.0);
2447 images.dedup_by_key(|im| im.id.0);
2448 let mut unique_categories = categories;
2449 unique_categories.sort_by_key(|c| c.id.0);
2450 unique_categories.dedup_by_key(|c| c.id.0);
2451 prop_assume!(!images.is_empty());
2452 prop_assume!(!unique_categories.is_empty());
2453
2454 let cat_id = unique_categories[0].id;
2458 let annotations: Vec<CocoAnnotation> = images
2459 .iter()
2460 .enumerate()
2461 .map(|(i, im)| make_min_annotation(AnnId((i as i64) + 1), im.id, cat_id))
2462 .collect();
2463 let mut shuffled = images.clone();
2464 shuffled.reverse();
2465
2466 let a = CocoDataset::from_parts(
2467 images,
2468 annotations.clone(),
2469 unique_categories.clone(),
2470 ).unwrap();
2471 let b = CocoDataset::from_parts(
2472 shuffled,
2473 annotations,
2474 unique_categories,
2475 ).unwrap();
2476 prop_assert_eq!(a.dataset_hash(), b.dataset_hash());
2477 }
2478 }
2479
2480 #[test]
2485 fn params_hash_is_stable_for_equal_inputs() {
2486 use crate::evaluate::OwnedEvaluateParams;
2487 let a = OwnedEvaluateParams {
2488 iou_thresholds: vec![0.5, 0.55, 0.6],
2489 area_ranges: vec![],
2490 max_dets_per_image: 100,
2491 use_cats: true,
2492 retain_iou: false,
2493 };
2494 let b = a.clone();
2495 assert_eq!(a.params_hash().unwrap(), b.params_hash().unwrap());
2496 }
2497
2498 #[test]
2499 fn params_hash_changes_when_thresholds_change() {
2500 use crate::evaluate::OwnedEvaluateParams;
2501 let a = OwnedEvaluateParams {
2502 iou_thresholds: vec![0.5, 0.55, 0.6],
2503 area_ranges: vec![],
2504 max_dets_per_image: 100,
2505 use_cats: true,
2506 retain_iou: false,
2507 };
2508 let mut b = a.clone();
2509 b.iou_thresholds.push(0.65);
2510 assert_ne!(a.params_hash().unwrap(), b.params_hash().unwrap());
2511 }
2512
2513 #[test]
2514 fn params_hash_changes_when_use_cats_toggles() {
2515 use crate::evaluate::OwnedEvaluateParams;
2516 let a = OwnedEvaluateParams {
2517 iou_thresholds: vec![0.5],
2518 area_ranges: vec![],
2519 max_dets_per_image: 100,
2520 use_cats: true,
2521 retain_iou: false,
2522 };
2523 let mut b = a.clone();
2524 b.use_cats = false;
2525 assert_ne!(a.params_hash().unwrap(), b.params_hash().unwrap());
2526 }
2527}