1use super::{
14 convert::{
15 box2d_to_coco_bbox, coco_bbox_to_box2d, coco_segmentation_to_polygon,
16 polygon_to_coco_polygon,
17 },
18 reader::{CocoReadOptions, CocoReader, read_coco_directory},
19 types::{CocoDataset, CocoImage, CocoIndex, CocoInfo, CocoSegmentation},
20 writer::{CocoDatasetBuilder, CocoWriteOptions, CocoWriter},
21};
22use crate::{
23 Annotation, AnnotationSetID, Client, DatasetID, Error, FileType, Progress, Sample, SampleFile,
24};
25use std::{
26 collections::HashSet,
27 path::{Path, PathBuf},
28};
29use tokio::sync::mpsc::Sender;
30
31#[derive(Debug, Clone)]
33pub struct CocoImportResult {
34 pub total_images: usize,
36 pub skipped: usize,
38 pub imported: usize,
40}
41
42#[derive(Debug, Clone)]
44pub struct CocoImportOptions {
45 pub include_masks: bool,
47 pub include_images: bool,
49 pub group: Option<String>,
51 pub batch_size: usize,
53 pub concurrency: usize,
55 pub resume: bool,
58}
59
60impl Default for CocoImportOptions {
61 fn default() -> Self {
62 Self {
63 include_masks: true,
64 include_images: true,
65 group: None,
66 batch_size: 100,
67 concurrency: 64,
68 resume: true,
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
75pub struct CocoExportOptions {
76 pub groups: Vec<String>,
78 pub include_masks: bool,
80 pub include_images: bool,
82 pub output_zip: bool,
84 pub pretty_json: bool,
86 pub info: Option<CocoInfo>,
88}
89
90impl Default for CocoExportOptions {
91 fn default() -> Self {
92 Self {
93 groups: vec![],
94 include_masks: true,
95 include_images: false,
96 output_zip: false,
97 pretty_json: false,
98 info: None,
99 }
100 }
101}
102
103pub async fn import_coco_to_studio(
144 client: &Client,
145 coco_path: impl AsRef<Path>,
146 dataset_id: DatasetID,
147 annotation_set_id: AnnotationSetID,
148 options: &CocoImportOptions,
149 progress: Option<Sender<Progress>>,
150) -> Result<CocoImportResult, Error> {
151 let coco_path = coco_path.as_ref();
152
153 let (dataset, images_dir) = read_coco_from_path(coco_path)?;
155
156 let total_images = dataset.images.len();
157 if total_images == 0 {
158 return Err(Error::MissingAnnotations(
159 "No images found in COCO dataset".to_string(),
160 ));
161 }
162
163 if options.include_images {
165 validate_images_extracted(&dataset, &images_dir)?;
166 }
167
168 let existing_names = fetch_existing_sample_names(client, &dataset_id, options.resume).await?;
170
171 let group_filter = options.group.as_deref();
173 let (images_to_import, skipped, filtered_by_group) =
174 filter_images_for_import(&dataset.images, group_filter, &existing_names);
175
176 log_import_filter_info(group_filter, filtered_by_group, total_images);
178
179 let to_import = images_to_import.len();
180
181 if to_import == 0 {
183 log_nothing_to_import(skipped);
184 return Ok(CocoImportResult {
185 total_images,
186 skipped,
187 imported: 0,
188 });
189 }
190
191 if skipped > 0 {
192 log::info!(
193 "Resuming import: {} of {} images already imported, {} remaining",
194 skipped,
195 total_images,
196 to_import
197 );
198 }
199
200 let index = CocoIndex::from_dataset(&dataset);
202 send_progress(&progress, 0, to_import).await;
203
204 let upload_ctx = UploadContext {
205 client,
206 dataset_id: &dataset_id,
207 annotation_set_id: &annotation_set_id,
208 options,
209 progress: &progress,
210 };
211 let imported =
212 upload_images_in_batches(&upload_ctx, &images_to_import, &index, &images_dir).await?;
213
214 Ok(CocoImportResult {
215 total_images,
216 skipped,
217 imported,
218 })
219}
220
221async fn fetch_existing_sample_names(
223 client: &Client,
224 dataset_id: &DatasetID,
225 resume: bool,
226) -> Result<HashSet<String>, Error> {
227 if !resume {
228 return Ok(HashSet::new());
229 }
230
231 log::info!("Checking for existing samples in dataset {}...", dataset_id);
232 let names = client.sample_names(*dataset_id, &[], None, None).await?;
233 log::info!("Found {} existing samples in dataset", names.len());
234
235 if !names.is_empty() {
236 let samples: Vec<_> = names.iter().take(3).collect();
237 log::debug!("Sample names from server: {:?}", samples);
238 }
239
240 Ok(names)
241}
242
243fn log_import_filter_info(group_filter: Option<&str>, filtered_by_group: usize, total: usize) {
245 if filtered_by_group > 0 {
246 log::info!(
247 "Group filter '{}': {} images excluded, {} matching",
248 group_filter.unwrap_or(""),
249 filtered_by_group,
250 total - filtered_by_group
251 );
252 }
253}
254
255fn log_nothing_to_import(skipped: usize) {
257 if skipped > 0 {
258 log::info!(
259 "All {} matching images already imported, nothing to do",
260 skipped
261 );
262 } else {
263 log::info!("No images to import");
264 }
265}
266
267async fn send_progress(progress: &Option<Sender<Progress>>, current: usize, total: usize) {
269 if let Some(p) = progress {
270 let _ = p
271 .send(Progress {
272 current,
273 total,
274 status: None,
275 })
276 .await;
277 }
278}
279
280struct UploadContext<'a> {
282 client: &'a Client,
283 dataset_id: &'a DatasetID,
284 annotation_set_id: &'a AnnotationSetID,
285 options: &'a CocoImportOptions,
286 progress: &'a Option<Sender<Progress>>,
287}
288
289async fn upload_images_in_batches<'a>(
291 ctx: &UploadContext<'a>,
292 images: &[&CocoImage],
293 index: &CocoIndex,
294 images_dir: &Path,
295) -> Result<usize, Error> {
296 let mut imported = 0;
297 let to_import = images.len();
298
299 for batch in images.chunks(ctx.options.batch_size) {
300 let samples = convert_batch_to_samples(batch, index, images_dir, ctx.options)?;
301
302 ctx.client
303 .populate_samples_with_concurrency(
304 *ctx.dataset_id,
305 Some(*ctx.annotation_set_id),
306 samples,
307 None,
308 Some(ctx.options.concurrency),
309 )
310 .await?;
311
312 imported += batch.len();
313 send_progress(ctx.progress, imported, to_import).await;
314 }
315
316 Ok(imported)
317}
318
319fn convert_batch_to_samples(
321 batch: &[&CocoImage],
322 index: &CocoIndex,
323 images_dir: &Path,
324 options: &CocoImportOptions,
325) -> Result<Vec<Sample>, Error> {
326 let mut samples = Vec::with_capacity(batch.len());
327
328 for image in batch {
329 let image_group = super::reader::infer_group_from_folder(&image.file_name);
330 let sample = convert_coco_image_to_sample(
331 image,
332 index,
333 images_dir,
334 options.include_masks,
335 options.include_images,
336 image_group.as_deref(),
337 )?;
338 samples.push(sample);
339 }
340
341 Ok(samples)
342}
343
344fn validate_images_extracted(dataset: &CocoDataset, images_dir: &Path) -> Result<(), Error> {
346 let sample_size = std::cmp::min(5, dataset.images.len());
348 let mut missing = Vec::new();
349
350 for image in dataset.images.iter().take(sample_size) {
351 if find_image_file(images_dir, &image.file_name).is_none() {
352 missing.push(image.file_name.clone());
353 }
354 }
355
356 if !missing.is_empty() {
357 let examples: Vec<_> = missing.iter().take(3).cloned().collect();
358 return Err(Error::MissingImages(format!(
359 "Images must be extracted before import.\n\
360 Cannot find: {}\n\n\
361 Searched in: {}\n\
362 Expected subdirectories: train2017/, val2017/, images/\n\n\
363 Please extract your COCO image archives first:\n\
364 $ cd {} && unzip train2017.zip && unzip val2017.zip",
365 examples.join(", "),
366 images_dir.display(),
367 images_dir.display()
368 )));
369 }
370
371 Ok(())
372}
373
374fn find_image_file(base_dir: &Path, file_name: &str) -> Option<PathBuf> {
376 let candidates = [
377 base_dir.join(file_name),
378 base_dir.join("images").join(file_name),
379 base_dir.join("train2017").join(file_name),
380 base_dir.join("val2017").join(file_name),
381 base_dir.join("test2017").join(file_name),
382 base_dir.join("train2014").join(file_name),
383 base_dir.join("val2014").join(file_name),
384 ];
385 candidates.into_iter().find(|p| p.exists())
386}
387
388fn infer_group_from_filename(path: &Path) -> Option<String> {
396 let stem = path.file_stem()?.to_str()?;
397
398 if let Some(rest) = stem.strip_prefix("instances_") {
400 let group = rest.trim_end_matches(char::is_numeric);
402 if !group.is_empty() {
403 return Some(group.to_string());
404 }
405 }
406
407 for prefix in ["train", "val", "test", "validation"] {
409 if stem.starts_with(prefix) {
410 return Some(prefix.to_string());
411 }
412 }
413
414 None
415}
416
417fn read_coco_from_path(coco_path: &Path) -> Result<(CocoDataset, PathBuf), Error> {
421 if coco_path.is_dir() {
422 let datasets = read_coco_directory(coco_path, &CocoReadOptions::default())?;
424 log::info!("Found {} annotation files in directory", datasets.len());
425
426 let mut merged = CocoDataset::default();
428 for (mut ds, group) in datasets {
429 log::info!(
430 " - {} group: {} images, {} annotations",
431 group,
432 ds.images.len(),
433 ds.annotations.len()
434 );
435 for image in &mut ds.images {
438 if !image.file_name.contains('/') {
439 image.file_name = format!("{}2017/{}", group, image.file_name);
440 }
441 }
442 merge_coco_datasets(&mut merged, ds);
443 }
444 Ok((merged, coco_path.to_path_buf()))
445 } else if coco_path.extension().is_some_and(|e| e == "json") {
446 let reader = CocoReader::new();
448 let dataset = reader.read_json(coco_path)?;
449 let parent = coco_path
450 .parent()
451 .and_then(|p| p.parent()) .unwrap_or(Path::new("."));
453 Ok((dataset, parent.to_path_buf()))
454 } else {
455 Err(Error::InvalidParameters(
456 "COCO import requires a JSON annotation file or directory. \
457 ZIP archives must be extracted first."
458 .to_string(),
459 ))
460 }
461}
462
463fn filter_images_for_import<'a>(
468 images: &'a [CocoImage],
469 group_filter: Option<&str>,
470 existing_names: &HashSet<String>,
471) -> (Vec<&'a CocoImage>, usize, usize) {
472 let total = images.len();
473
474 let images_to_import: Vec<_> = images
476 .iter()
477 .filter(|img| {
478 if let Some(filter) = group_filter {
480 let inferred = super::reader::infer_group_from_folder(&img.file_name);
481 if inferred.as_deref() != Some(filter) {
482 return false;
483 }
484 }
485 let sample_name = extract_sample_name(&img.file_name);
487 !existing_names.contains(&sample_name)
488 })
489 .collect();
490
491 let filtered_by_group = if group_filter.is_some() {
493 images
494 .iter()
495 .filter(|img| {
496 let inferred = super::reader::infer_group_from_folder(&img.file_name);
497 inferred.as_deref() != group_filter
498 })
499 .count()
500 } else {
501 0
502 };
503
504 let skipped = total - filtered_by_group - images_to_import.len();
505 (images_to_import, skipped, filtered_by_group)
506}
507
508fn extract_sample_name(file_name: &str) -> String {
510 Path::new(file_name)
511 .file_stem()
512 .and_then(|s| s.to_str())
513 .map(String::from)
514 .unwrap_or_else(|| file_name.to_string())
515}
516
517fn merge_coco_datasets(target: &mut CocoDataset, source: CocoDataset) {
522 let existing_image_ids: HashSet<_> = target.images.iter().map(|i| i.id).collect();
524 for image in source.images {
525 if !existing_image_ids.contains(&image.id) {
526 target.images.push(image);
527 }
528 }
529
530 let existing_cat_ids: HashSet<_> = target.categories.iter().map(|c| c.id).collect();
532 for cat in source.categories {
533 if !existing_cat_ids.contains(&cat.id) {
534 target.categories.push(cat);
535 }
536 }
537
538 target.annotations.extend(source.annotations);
540
541 let existing_license_ids: HashSet<_> = target.licenses.iter().map(|l| l.id).collect();
543 for license in source.licenses {
544 if !existing_license_ids.contains(&license.id) {
545 target.licenses.push(license);
546 }
547 }
548
549 if target.info.description.is_none() && source.info.description.is_some() {
551 target.info = source.info;
552 }
553}
554
555fn convert_coco_image_to_sample(
565 image: &CocoImage,
566 index: &CocoIndex,
567 images_dir: &Path,
568 include_masks: bool,
569 include_images: bool,
570 group: Option<&str>,
571) -> Result<Sample, Error> {
572 let sample_name = Path::new(&image.file_name)
573 .file_stem()
574 .and_then(|s| s.to_str())
575 .map(String::from)
576 .unwrap_or_else(|| image.file_name.clone());
577
578 let annotations = index
580 .annotations_for_image(image.id)
581 .iter()
582 .filter_map(|coco_ann| {
583 let label = index.label_name(coco_ann.category_id)?;
584 let label_index = index.label_index(coco_ann.category_id);
585
586 let box2d = coco_bbox_to_box2d(&coco_ann.bbox, image.width, image.height);
587
588 let polygon = if include_masks {
589 coco_ann.segmentation.as_ref().and_then(|seg| {
590 coco_segmentation_to_polygon(seg, image.width, image.height).ok()
591 })
592 } else {
593 None
594 };
595
596 {
597 let mut ann = Annotation::new();
598 ann.set_name(Some(sample_name.clone()));
599 ann.set_label(Some(label.to_string()));
600 ann.set_label_index(label_index);
601 ann.set_box2d(Some(box2d));
602 ann.set_polygon(polygon);
603 ann.set_group(group.map(String::from));
604 ann.set_iscrowd(Some(coco_ann.iscrowd != 0));
605 ann.set_category_frequency(index.frequency(coco_ann.category_id).map(String::from));
606 Some(ann)
607 }
608 })
609 .collect();
610
611 let neg_label_indices = image.neg_category_ids.as_ref().map(|ids| {
613 ids.iter()
614 .filter_map(|&id| index.label_index(id).map(|idx| idx as u32))
615 .collect::<Vec<u32>>()
616 });
617 let not_exhaustive_label_indices = image.not_exhaustive_category_ids.as_ref().map(|ids| {
618 ids.iter()
619 .filter_map(|&id| index.label_index(id).map(|idx| idx as u32))
620 .collect::<Vec<u32>>()
621 });
622
623 let mut files = Vec::new();
625 if include_images && let Some(image_path) = find_image_file(images_dir, &image.file_name) {
626 files.push(SampleFile::with_filename(
627 FileType::Image.to_string(),
628 image_path.to_string_lossy().to_string(),
629 ));
630 }
631
632 Ok(Sample {
633 image_name: Some(sample_name),
634 width: Some(image.width),
635 height: Some(image.height),
636 group: group.map(String::from),
637 neg_label_indices,
638 not_exhaustive_label_indices,
639 files,
640 annotations,
641 ..Default::default()
642 })
643}
644
645pub async fn export_studio_to_coco(
660 client: &Client,
661 dataset_id: DatasetID,
662 annotation_set_id: AnnotationSetID,
663 output_path: impl AsRef<Path>,
664 options: &CocoExportOptions,
665 progress: Option<Sender<Progress>>,
666) -> Result<usize, Error> {
667 let output_path = output_path.as_ref();
668
669 let groups: Vec<String> = options.groups.clone();
671 let annotation_types = [crate::AnnotationType::Box2d, crate::AnnotationType::Polygon];
672
673 let all_samples = client
675 .samples(
676 dataset_id,
677 Some(annotation_set_id),
678 &annotation_types,
679 &groups,
680 &[],
681 progress.clone(),
682 None,
683 )
684 .await?;
685
686 let mut builder = CocoDatasetBuilder::new();
688
689 if let Some(info) = &options.info {
690 builder = builder.info(info.clone());
691 }
692
693 for sample in &all_samples {
694 let image_name = sample.image_name.as_deref().unwrap_or("unknown");
695 let width = sample.width.unwrap_or(0);
696 let height = sample.height.unwrap_or(0);
697
698 let file_name = if image_name.contains('.') {
700 image_name.to_string()
701 } else {
702 format!("{}.jpg", image_name)
703 };
704 let image_id = builder.add_image(&file_name, width, height);
705
706 for ann in &sample.annotations {
707 let bbox = if let Some(box2d) = ann.box2d() {
709 Some(box2d_to_coco_bbox(box2d, width, height))
710 } else if let Some(polygon) = ann.polygon() {
711 compute_bbox_from_polygon(polygon, width, height)
712 } else {
713 None
714 };
715
716 if let Some(bbox) = bbox {
717 let category_id = category_id_for_annotation(&mut builder, ann)?;
720
721 let segmentation = if options.include_masks {
722 ann.polygon().map(|polygon| {
723 let coco_poly = polygon_to_coco_polygon(polygon, width, height);
724 CocoSegmentation::Polygon(coco_poly)
725 })
726 } else {
727 None
728 };
729
730 builder.add_annotation(image_id, category_id, bbox, segmentation);
731 }
732 }
733 }
734
735 let dataset = builder.build();
736 let annotation_count = dataset.annotations.len();
737
738 let writer = CocoWriter::with_options(CocoWriteOptions {
740 compress: true,
741 pretty: options.pretty_json,
742 });
743
744 if options.output_zip {
745 let images = if options.include_images {
747 download_images(client, &all_samples, progress.clone()).await?
748 } else {
749 vec![]
750 };
751
752 writer.write_zip(&dataset, images.into_iter(), output_path)?;
753 } else {
754 writer.write_json(&dataset, output_path)?;
755 }
756
757 Ok(annotation_count)
758}
759
760async fn download_images(
765 client: &Client,
766 samples: &[Sample],
767 progress: Option<Sender<Progress>>,
768) -> Result<Vec<(String, Vec<u8>)>, Error> {
769 let mut result = Vec::with_capacity(samples.len());
770 let total = samples.len();
771
772 for (i, sample) in samples.iter().enumerate() {
773 let image_url = sample.files.iter().find_map(|f| {
775 if f.file_type() == "image" {
776 f.url()
777 } else {
778 None
779 }
780 });
781
782 if let Some(url) = image_url {
783 match client.download(url).await {
785 Ok(data) => {
786 let name = sample.image_name.as_deref().unwrap_or("unknown");
788 let filename = if name.contains('.') {
789 format!("images/{}", name)
790 } else {
791 format!("images/{}.jpg", name)
792 };
793 result.push((filename, data));
794 }
795 Err(e) => {
796 log::warn!(
798 "Failed to download image for sample {:?}: {}",
799 sample.image_name,
800 e
801 );
802 }
803 }
804 }
805
806 if let Some(ref p) = progress {
808 let _ = p
809 .send(Progress {
810 current: i + 1,
811 total,
812 status: None,
813 })
814 .await;
815 }
816 }
817
818 Ok(result)
819}
820
821#[derive(Debug, Clone)]
823pub struct CocoVerifyOptions {
824 pub verify_masks: bool,
826 pub group: Option<String>,
828}
829
830impl Default for CocoVerifyOptions {
831 fn default() -> Self {
832 Self {
833 verify_masks: true,
834 group: None,
835 }
836 }
837}
838
839#[derive(Debug, Clone)]
841pub struct CocoUpdateResult {
842 pub total_images: usize,
844 pub updated: usize,
846 pub not_found: usize,
848}
849
850#[derive(Debug, Clone)]
852pub struct CocoUpdateOptions {
853 pub include_masks: bool,
855 pub group: Option<String>,
857 pub batch_size: usize,
859 pub concurrency: usize,
861}
862
863impl Default for CocoUpdateOptions {
864 fn default() -> Self {
865 Self {
866 include_masks: true,
867 group: None,
868 batch_size: 100,
869 concurrency: 64,
870 }
871 }
872}
873
874fn read_coco_dataset_for_update(coco_path: &Path) -> Result<CocoDataset, Error> {
876 if coco_path.is_dir() {
877 let datasets = read_coco_directory(coco_path, &CocoReadOptions::default())?;
879 log::info!("Found {} annotation files in directory", datasets.len());
880
881 let mut merged = CocoDataset::default();
883 for (mut ds, group) in datasets {
884 log::info!(
885 " - {} group: {} images, {} annotations",
886 group,
887 ds.images.len(),
888 ds.annotations.len()
889 );
890 for image in &mut ds.images {
892 if !image.file_name.contains('/') {
893 image.file_name = format!("{}2017/{}", group, image.file_name);
894 }
895 }
896 merge_coco_datasets(&mut merged, ds);
897 }
898 Ok(merged)
899 } else if coco_path.extension().is_some_and(|e| e == "json") {
900 let reader = CocoReader::new();
901 reader.read_json(coco_path)
902 } else {
903 Err(Error::InvalidParameters(
904 "COCO update requires a JSON annotation file or directory.".to_string(),
905 ))
906 }
907}
908
909fn build_sample_info_map(
912 samples: &[Sample],
913) -> std::collections::HashMap<String, (crate::SampleID, u32, u32, Option<String>)> {
914 use std::collections::HashMap;
915 let mut sample_info = HashMap::new();
916 for sample in samples {
917 if let (Some(name), Some(id), Some(w), Some(h)) =
918 (sample.name(), sample.id(), sample.width, sample.height)
919 {
920 sample_info.insert(name, (id, w, h, sample.group.clone()));
921 }
922 }
923 sample_info
924}
925
926fn coco_category_id_from_label_index(label_index: u64) -> Result<u32, Error> {
931 u32::try_from(label_index).map_err(|_| {
932 Error::InvalidParameters(format!(
933 "label_index {label_index} exceeds u32::MAX and cannot be used as a COCO category_id"
934 ))
935 })
936}
937
938fn category_id_for_annotation(
941 builder: &mut CocoDatasetBuilder,
942 ann: &Annotation,
943) -> Result<u32, Error> {
944 let label = ann.label().map(|s| s.as_str()).unwrap_or("unknown");
945 Ok(match ann.label_index() {
946 Some(idx) => {
947 let category_id = coco_category_id_from_label_index(idx)?;
948 builder.add_category_with_id(category_id, label, None)
949 }
950 None => builder.add_category(label, None),
951 })
952}
953
954fn coco_label_specs(categories: &[crate::coco::CocoCategory]) -> (Vec<String>, Vec<Option<u64>>) {
957 let names: Vec<String> = categories.iter().map(|c| c.name.clone()).collect();
958 let indices: Vec<Option<u64>> = categories.iter().map(|c| Some(u64::from(c.id))).collect();
959 (names, indices)
960}
961
962async fn ensure_labels_exist(
968 client: &Client,
969 dataset_id: &DatasetID,
970 categories: &[crate::coco::CocoCategory],
971) -> Result<std::collections::HashMap<String, u64>, Error> {
972 use std::collections::HashMap;
973
974 if categories.is_empty() {
975 return Ok(HashMap::new());
976 }
977
978 let (names, indices) = coco_label_specs(categories);
979 log::info!(
980 "Ensuring {} COCO labels exist in Studio with source-faithful indices...",
981 names.len()
982 );
983 client
984 .add_labels_with_indices(*dataset_id, &names, &indices)
985 .await?;
986
987 let labels = client.labels(*dataset_id, None).await?;
989 let label_map: HashMap<String, u64> = labels
990 .iter()
991 .map(|l| (l.name().to_string(), l.id()))
992 .collect();
993
994 log::info!(
995 "Label map has {} entries for {} COCO categories",
996 label_map.len(),
997 categories.len()
998 );
999
1000 Ok(label_map)
1001}
1002
1003fn convert_coco_annotation_to_server(
1007 coco_ann: &super::types::CocoAnnotation,
1008 coco_index: &CocoIndex,
1009 label_map: &std::collections::HashMap<String, u64>,
1010 image_id: u64,
1011 annotation_set_id: u64,
1012 dims: (u32, u32),
1013 include_masks: bool,
1014) -> (crate::api::ServerAnnotation, bool) {
1015 let (width, height) = dims;
1016
1017 let category_name = coco_index
1019 .categories
1020 .get(&coco_ann.category_id)
1021 .map(|c| c.name.as_str())
1022 .unwrap_or("unknown");
1023
1024 let label_id = label_map.get(category_name).copied();
1025 let missing_label = label_id.is_none();
1026
1027 let box2d = coco_bbox_to_box2d(&coco_ann.bbox, width, height);
1029
1030 let polygon = if include_masks {
1032 coco_ann
1033 .segmentation
1034 .as_ref()
1035 .and_then(|seg| coco_segmentation_to_polygon(seg, width, height).ok())
1036 .map(|p| polygon_to_polygon_string(&p))
1037 .unwrap_or_default()
1038 } else {
1039 String::new()
1040 };
1041
1042 let annotation_type = if polygon.is_empty() { "box" } else { "seg" }.to_string();
1043
1044 let server_ann = crate::api::ServerAnnotation {
1045 label_id,
1046 label_index: None,
1047 label_name: Some(category_name.to_string()),
1048 annotation_type,
1049 x: box2d.left() as f64,
1050 y: box2d.top() as f64,
1051 w: box2d.width() as f64,
1052 h: box2d.height() as f64,
1053 score: 1.0,
1054 polygon,
1055 image_id,
1056 annotation_set_id,
1057 object_reference: None,
1058 };
1059
1060 (server_ann, missing_label)
1061}
1062
1063fn process_image_for_update(
1066 coco_image: &CocoImage,
1067 sample_info: &std::collections::HashMap<String, (crate::SampleID, u32, u32, Option<String>)>,
1068 coco_index: &CocoIndex,
1069 label_map: &std::collections::HashMap<String, u64>,
1070 annotation_set_id: u64,
1071 include_masks: bool,
1072) -> Option<(
1073 crate::SampleID,
1074 Vec<crate::api::ServerAnnotation>,
1075 Option<String>,
1076 usize,
1077)> {
1078 let sample_name = extract_sample_name(&coco_image.file_name);
1079 let expected_group = super::reader::infer_group_from_folder(&coco_image.file_name);
1080
1081 let (sample_id, width, height, current_group) = sample_info.get(&sample_name)?;
1082 let (sample_id, width, height) = (*sample_id, *width, *height);
1083 let image_id: u64 = sample_id.into();
1084
1085 let group_update = expected_group.as_ref().and_then(|expected| {
1087 if Some(expected) != current_group.as_ref() {
1088 Some(expected.clone())
1089 } else {
1090 None
1091 }
1092 });
1093
1094 let mut annotations = Vec::new();
1096 let mut missing_label_count = 0;
1097
1098 for coco_ann in coco_index.annotations_for_image(coco_image.id) {
1099 let (server_ann, missing) = convert_coco_annotation_to_server(
1100 coco_ann,
1101 coco_index,
1102 label_map,
1103 image_id,
1104 annotation_set_id,
1105 (width, height),
1106 include_masks,
1107 );
1108 if missing {
1109 missing_label_count += 1;
1110 }
1111 annotations.push(server_ann);
1112 }
1113
1114 Some((sample_id, annotations, group_update, missing_label_count))
1115}
1116
1117async fn update_sample_groups(
1119 client: &Client,
1120 dataset_id: &DatasetID,
1121 samples_needing_group_update: &[(crate::SampleID, String)],
1122) -> usize {
1123 use std::collections::{HashMap, HashSet};
1124
1125 if samples_needing_group_update.is_empty() {
1126 return 0;
1127 }
1128
1129 log::info!(
1130 "Updating groups for {} samples...",
1131 samples_needing_group_update.len()
1132 );
1133
1134 let unique_groups: HashSet<String> = samples_needing_group_update
1136 .iter()
1137 .map(|(_, group)| group.clone())
1138 .collect();
1139
1140 let mut group_id_map: HashMap<String, u64> = HashMap::new();
1141 for group_name in unique_groups {
1142 match client.get_or_create_group(*dataset_id, &group_name).await {
1143 Ok(group_id) => {
1144 group_id_map.insert(group_name, group_id);
1145 }
1146 Err(e) => {
1147 log::warn!("Failed to get/create group '{}': {}", group_name, e);
1148 }
1149 }
1150 }
1151
1152 let mut updated_count = 0;
1154 let mut failed_count = 0;
1155 for (sample_id, group_name) in samples_needing_group_update {
1156 if let Some(&group_id) = group_id_map.get(group_name) {
1157 match client.set_sample_group_id(*sample_id, group_id).await {
1158 Ok(_) => {
1159 updated_count += 1;
1160 if updated_count % 1000 == 0 {
1161 log::debug!("Updated groups for {} samples so far", updated_count);
1162 }
1163 }
1164 Err(e) => {
1165 failed_count += 1;
1166 if failed_count <= 5 {
1167 log::warn!("Failed to update group for sample {:?}: {}", sample_id, e);
1168 }
1169 }
1170 }
1171 }
1172 }
1173
1174 if failed_count > 5 {
1175 log::warn!("... and {} more group update failures", failed_count - 5);
1176 }
1177 log::info!(
1178 "Updated groups for {} samples ({} failed)",
1179 updated_count,
1180 failed_count
1181 );
1182
1183 updated_count
1184}
1185
1186pub async fn update_coco_annotations(
1206 client: &Client,
1207 coco_path: impl AsRef<Path>,
1208 dataset_id: DatasetID,
1209 annotation_set_id: AnnotationSetID,
1210 options: &CocoUpdateOptions,
1211 progress: Option<Sender<Progress>>,
1212) -> Result<CocoUpdateResult, Error> {
1213 use crate::{SampleID, api::ServerAnnotation};
1214
1215 let coco_path = coco_path.as_ref();
1216
1217 let dataset = read_coco_dataset_for_update(coco_path)?;
1219 let total_images = dataset.images.len();
1220
1221 if total_images == 0 {
1222 return Err(Error::MissingAnnotations(
1223 "No images found in COCO dataset".to_string(),
1224 ));
1225 }
1226
1227 log::info!(
1228 "COCO dataset: {} images, {} annotations, {} categories",
1229 total_images,
1230 dataset.annotations.len(),
1231 dataset.categories.len()
1232 );
1233
1234 log::info!("Fetching existing samples from Studio...");
1236 let existing_samples = client
1237 .samples(
1238 dataset_id,
1239 Some(annotation_set_id),
1240 &[],
1241 &[],
1242 &[],
1243 progress.clone(),
1244 None,
1245 )
1246 .await?;
1247
1248 let sample_info = build_sample_info_map(&existing_samples);
1249 log::info!(
1250 "Found {} existing samples in Studio with IDs and dimensions",
1251 sample_info.len()
1252 );
1253
1254 let coco_index = CocoIndex::from_dataset(&dataset);
1256
1257 let label_map = ensure_labels_exist(client, &dataset_id, &dataset.categories).await?;
1259
1260 let annotation_set_id_u64: u64 = annotation_set_id.into();
1262 let mut sample_ids_to_update: Vec<SampleID> = Vec::new();
1263 let mut server_annotations: Vec<ServerAnnotation> = Vec::new();
1264 let mut samples_needing_group_update: Vec<(SampleID, String)> = Vec::new();
1265 let mut not_found = 0;
1266 let mut missing_label_count = 0;
1267
1268 for coco_image in &dataset.images {
1269 match process_image_for_update(
1270 coco_image,
1271 &sample_info,
1272 &coco_index,
1273 &label_map,
1274 annotation_set_id_u64,
1275 options.include_masks,
1276 ) {
1277 Some((sample_id, annotations, group_update, missing_labels)) => {
1278 sample_ids_to_update.push(sample_id);
1279 server_annotations.extend(annotations);
1280 missing_label_count += missing_labels;
1281 if let Some(group) = group_update {
1282 samples_needing_group_update.push((sample_id, group));
1283 }
1284 }
1285 None => {
1286 not_found += 1;
1287 log::debug!(
1288 "Sample not found in Studio: {}",
1289 extract_sample_name(&coco_image.file_name)
1290 );
1291 }
1292 }
1293 }
1294
1295 let to_update = sample_ids_to_update.len();
1296 log::info!(
1297 "Updating {} samples ({} not found in Studio), {} annotations",
1298 to_update,
1299 not_found,
1300 server_annotations.len()
1301 );
1302
1303 if missing_label_count > 0 {
1304 log::warn!(
1305 "{} annotations have missing label_id (category not found in label map)",
1306 missing_label_count
1307 );
1308 }
1309
1310 if to_update == 0 {
1311 return Ok(CocoUpdateResult {
1312 total_images,
1313 updated: 0,
1314 not_found,
1315 });
1316 }
1317
1318 if let Some(ref tx) = progress {
1320 let _ = tx
1321 .send(Progress {
1322 current: 0,
1323 total: to_update,
1324 status: None,
1325 })
1326 .await;
1327 }
1328
1329 log::info!(
1331 "Deleting existing annotations for {} samples...",
1332 sample_ids_to_update.len()
1333 );
1334 let annotation_types = if options.include_masks {
1335 vec!["box".to_string(), "seg".to_string()]
1336 } else {
1337 vec!["box".to_string()]
1338 };
1339
1340 for batch in sample_ids_to_update.chunks(options.batch_size) {
1342 client
1343 .delete_annotations_bulk(annotation_set_id, &annotation_types, batch)
1344 .await?;
1345 }
1346
1347 if let Some(ref tx) = progress {
1349 let _ = tx
1350 .send(Progress {
1351 current: to_update / 2,
1352 total: to_update,
1353 status: None,
1354 })
1355 .await;
1356 }
1357
1358 log::info!("Adding {} new annotations...", server_annotations.len());
1360 let mut added = 0;
1361 for batch in server_annotations.chunks(options.batch_size) {
1362 client
1363 .add_annotations_bulk(annotation_set_id, batch.to_vec())
1364 .await?;
1365 added += batch.len();
1366 log::debug!("Added {} annotations so far", added);
1367 }
1368
1369 if let Some(ref tx) = progress {
1371 let _ = tx
1372 .send(Progress {
1373 current: to_update,
1374 total: to_update,
1375 status: None,
1376 })
1377 .await;
1378 }
1379
1380 let groups_updated =
1382 update_sample_groups(client, &dataset_id, &samples_needing_group_update).await;
1383
1384 log::info!(
1385 "Update complete: {} samples updated, {} not found, {} annotations added, {} groups updated",
1386 to_update,
1387 not_found,
1388 added,
1389 groups_updated
1390 );
1391
1392 Ok(CocoUpdateResult {
1393 total_images,
1394 updated: to_update,
1395 not_found,
1396 })
1397}
1398
1399fn polygon_to_polygon_string(polygon: &crate::Polygon) -> String {
1412 let rings: Vec<Vec<[f32; 2]>> = polygon
1416 .rings
1417 .iter()
1418 .map(|ring| {
1419 ring.iter()
1420 .filter(|(x, y)| x.is_finite() && y.is_finite())
1421 .map(|&(x, y)| [x, y])
1422 .collect()
1423 })
1424 .filter(|ring: &Vec<[f32; 2]>| ring.len() >= 3) .collect();
1426
1427 serde_json::to_string(&rings).unwrap_or_default()
1428}
1429
1430fn compute_bbox_from_polygon(
1435 polygon: &crate::Polygon,
1436 width: u32,
1437 height: u32,
1438) -> Option<[f64; 4]> {
1439 if polygon.rings.is_empty() {
1440 return None;
1441 }
1442
1443 let mut min_x = f32::MAX;
1444 let mut min_y = f32::MAX;
1445 let mut max_x = f32::MIN;
1446 let mut max_y = f32::MIN;
1447
1448 for ring in &polygon.rings {
1449 for &(x, y) in ring {
1450 if x.is_finite() && y.is_finite() {
1451 min_x = min_x.min(x);
1452 min_y = min_y.min(y);
1453 max_x = max_x.max(x);
1454 max_y = max_y.max(y);
1455 }
1456 }
1457 }
1458
1459 if min_x == f32::MAX || min_y == f32::MAX {
1460 return None;
1461 }
1462
1463 let x = (min_x * width as f32) as f64;
1465 let y = (min_y * height as f32) as f64;
1466 let w = ((max_x - min_x) * width as f32) as f64;
1467 let h = ((max_y - min_y) * height as f32) as f64;
1468
1469 if w > 0.0 && h > 0.0 {
1470 Some([x, y, w, h])
1471 } else {
1472 None
1473 }
1474}
1475
1476pub async fn verify_coco_import(
1497 client: &Client,
1498 coco_path: impl AsRef<Path>,
1499 dataset_id: DatasetID,
1500 annotation_set_id: AnnotationSetID,
1501 options: &CocoVerifyOptions,
1502 progress: Option<Sender<Progress>>,
1503) -> Result<super::verify::VerificationResult, Error> {
1504 use super::{
1505 verify::{
1506 MaskValidationResult, VerificationResult, validate_bboxes, validate_categories,
1507 validate_masks,
1508 },
1509 writer::CocoDatasetBuilder,
1510 };
1511
1512 let coco_path = coco_path.as_ref();
1513
1514 log::info!("Reading local COCO dataset from {:?}", coco_path);
1516 let (coco_dataset, inferred_group) = if coco_path.is_dir() {
1517 let datasets = read_coco_directory(coco_path, &CocoReadOptions::default())?;
1519 log::info!("Found {} annotation files in directory", datasets.len());
1520
1521 let mut merged = CocoDataset::default();
1522 for (ds, group) in datasets {
1523 log::info!(
1524 " - {} group: {} images, {} annotations",
1525 group,
1526 ds.images.len(),
1527 ds.annotations.len()
1528 );
1529 merge_coco_datasets(&mut merged, ds);
1530 }
1531 (merged, None)
1533 } else if coco_path.extension().is_some_and(|e| e == "json") {
1534 let reader = CocoReader::new();
1535 let dataset = reader.read_json(coco_path)?;
1536 let group = infer_group_from_filename(coco_path);
1537 (dataset, group)
1538 } else {
1539 return Err(Error::InvalidParameters(
1540 "COCO verification requires a JSON annotation file or directory.".to_string(),
1541 ));
1542 };
1543
1544 let effective_group = options.group.clone().or(inferred_group);
1546 let groups: Vec<String> = effective_group
1547 .as_ref()
1548 .map(|g| vec![g.clone()])
1549 .unwrap_or_default();
1550
1551 log::info!(
1552 "Local COCO: {} images, {} annotations",
1553 coco_dataset.images.len(),
1554 coco_dataset.annotations.len()
1555 );
1556
1557 log::info!("Fetching samples from Studio dataset {}...", dataset_id);
1559 let annotation_types = [crate::AnnotationType::Box2d, crate::AnnotationType::Polygon];
1560
1561 let studio_samples = client
1562 .samples(
1563 dataset_id,
1564 Some(annotation_set_id),
1565 &annotation_types,
1566 &groups,
1567 &[],
1568 progress.clone(),
1569 None,
1570 )
1571 .await?;
1572
1573 let total_annotations: usize = studio_samples.iter().map(|s| s.annotations.len()).sum();
1574 log::info!(
1575 "Studio: {} samples, {} total annotations",
1576 studio_samples.len(),
1577 total_annotations
1578 );
1579
1580 let mut builder = CocoDatasetBuilder::new();
1582
1583 for sample in &studio_samples {
1584 let image_name = sample.image_name.as_deref().unwrap_or("unknown");
1585 let width = sample.width.unwrap_or(0);
1586 let height = sample.height.unwrap_or(0);
1587
1588 let file_name = if image_name.contains('.') {
1590 image_name.to_string()
1591 } else {
1592 format!("{}.jpg", image_name)
1593 };
1594 let image_id = builder.add_image(&file_name, width, height);
1595
1596 for ann in &sample.annotations {
1597 let bbox = if let Some(box2d) = ann.box2d() {
1599 Some(box2d_to_coco_bbox(box2d, width, height))
1600 } else if let Some(polygon) = ann.polygon() {
1601 compute_bbox_from_polygon(polygon, width, height)
1603 } else {
1604 None
1605 };
1606
1607 if let Some(bbox) = bbox {
1608 let category_id = category_id_for_annotation(&mut builder, ann)?;
1609
1610 let segmentation = if options.verify_masks {
1611 ann.polygon().map(|polygon| {
1612 let coco_poly = polygon_to_coco_polygon(polygon, width, height);
1613 CocoSegmentation::Polygon(coco_poly)
1614 })
1615 } else {
1616 None
1617 };
1618
1619 builder.add_annotation(image_id, category_id, bbox, segmentation);
1620 }
1621 }
1622 }
1623
1624 let studio_dataset = builder.build();
1625
1626 let coco_names: HashSet<String> = coco_dataset
1628 .images
1629 .iter()
1630 .map(|img| {
1631 Path::new(&img.file_name)
1632 .file_stem()
1633 .and_then(|s| s.to_str())
1634 .map(String::from)
1635 .unwrap_or_else(|| img.file_name.clone())
1636 })
1637 .collect();
1638
1639 let studio_names: HashSet<String> = studio_samples.iter().filter_map(|s| s.name()).collect();
1640
1641 let missing_images: Vec<String> = coco_names.difference(&studio_names).cloned().collect();
1642 let extra_images: Vec<String> = studio_names.difference(&coco_names).cloned().collect();
1643
1644 log::info!("Validating bounding boxes...");
1646 let bbox_validation = validate_bboxes(&coco_dataset, &studio_dataset);
1647
1648 log::info!("Validating segmentation masks...");
1650 let mask_validation = if options.verify_masks {
1651 validate_masks(&coco_dataset, &studio_dataset)
1652 } else {
1653 MaskValidationResult::new()
1654 };
1655
1656 let category_validation = validate_categories(&coco_dataset, &studio_dataset);
1658
1659 Ok(VerificationResult {
1660 coco_image_count: coco_dataset.images.len(),
1661 studio_image_count: studio_samples.len(),
1662 missing_images,
1663 extra_images,
1664 coco_annotation_count: coco_dataset.annotations.len(),
1665 studio_annotation_count: studio_dataset.annotations.len(),
1666 bbox_validation,
1667 mask_validation,
1668 category_validation,
1669 })
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674 use super::*;
1675 use crate::coco::{CocoAnnotation, CocoCategory};
1676
1677 #[test]
1682 fn test_coco_import_options_default() {
1683 let options = CocoImportOptions::default();
1684 assert!(options.include_masks);
1685 assert!(options.include_images);
1686 assert!(options.group.is_none());
1687 assert_eq!(options.batch_size, 100);
1688 assert_eq!(options.concurrency, 64);
1689 assert!(options.resume);
1690 }
1691
1692 #[test]
1693 fn test_coco_export_options_default() {
1694 let options = CocoExportOptions::default();
1695 assert!(options.groups.is_empty());
1696 assert!(options.include_masks);
1697 assert!(!options.include_images);
1698 assert!(!options.output_zip);
1699 assert!(!options.pretty_json);
1700 assert!(options.info.is_none());
1701 }
1702
1703 #[test]
1704 fn test_coco_update_options_default() {
1705 let options = CocoUpdateOptions::default();
1706 assert!(options.include_masks);
1707 assert!(options.group.is_none());
1708 assert_eq!(options.batch_size, 100);
1709 assert_eq!(options.concurrency, 64);
1710 }
1711
1712 #[test]
1713 fn test_coco_verify_options_default() {
1714 let options = CocoVerifyOptions::default();
1715 assert!(options.verify_masks);
1716 assert!(options.group.is_none());
1717 }
1718
1719 #[test]
1724 fn test_find_image_file_nonexistent() {
1725 let result = find_image_file(Path::new("/nonexistent"), "test.jpg");
1726 assert!(result.is_none());
1727 }
1728
1729 #[test]
1730 fn test_find_image_file_with_subdirectory_in_name() {
1731 let result = find_image_file(Path::new("/nonexistent"), "train2017/image.jpg");
1733 assert!(result.is_none()); }
1735
1736 #[test]
1741 fn test_infer_group_from_filename_instances_train() {
1742 let path = Path::new("annotations/instances_train2017.json");
1743 assert_eq!(infer_group_from_filename(path), Some("train".to_string()));
1744 }
1745
1746 #[test]
1747 fn test_infer_group_from_filename_instances_val() {
1748 let path = Path::new("annotations/instances_val2017.json");
1749 assert_eq!(infer_group_from_filename(path), Some("val".to_string()));
1750 }
1751
1752 #[test]
1753 fn test_infer_group_from_filename_instances_test() {
1754 let path = Path::new("instances_test2017.json");
1755 assert_eq!(infer_group_from_filename(path), Some("test".to_string()));
1756 }
1757
1758 #[test]
1759 fn test_infer_group_from_filename_train_prefix() {
1760 let path = Path::new("train_annotations.json");
1761 assert_eq!(infer_group_from_filename(path), Some("train".to_string()));
1762 }
1763
1764 #[test]
1765 fn test_infer_group_from_filename_val_prefix() {
1766 let path = Path::new("val_data.json");
1767 assert_eq!(infer_group_from_filename(path), Some("val".to_string()));
1768 }
1769
1770 #[test]
1771 fn test_infer_group_from_filename_validation_prefix() {
1772 let path = Path::new("validation_set.json");
1775 assert_eq!(infer_group_from_filename(path), Some("val".to_string()));
1776 }
1777
1778 #[test]
1779 fn test_infer_group_from_filename_custom() {
1780 let path = Path::new("my_custom_annotations.json");
1781 assert_eq!(infer_group_from_filename(path), None);
1782 }
1783
1784 #[test]
1785 fn test_infer_group_from_filename_instances_2014() {
1786 let path = Path::new("instances_val2014.json");
1787 assert_eq!(infer_group_from_filename(path), Some("val".to_string()));
1788 }
1789
1790 #[test]
1795 fn test_coco_label_specs_preserves_category_ids() {
1796 let categories = vec![
1797 CocoCategory {
1798 id: 1,
1799 name: "person".to_string(),
1800 supercategory: None,
1801 ..Default::default()
1802 },
1803 CocoCategory {
1804 id: 3,
1805 name: "car".to_string(),
1806 supercategory: None,
1807 ..Default::default()
1808 },
1809 CocoCategory {
1810 id: 90,
1811 name: "toothbrush".to_string(),
1812 supercategory: None,
1813 ..Default::default()
1814 },
1815 ];
1816 let (names, indices) = coco_label_specs(&categories);
1817 assert_eq!(names, vec!["person", "car", "toothbrush"]);
1818 assert_eq!(indices, vec![Some(1), Some(3), Some(90)]);
1820 }
1821
1822 #[test]
1823 fn test_coco_label_specs_empty() {
1824 let (names, indices) = coco_label_specs(&[]);
1825 assert!(names.is_empty());
1826 assert!(indices.is_empty());
1827 }
1828
1829 #[test]
1830 fn test_coco_category_id_from_label_index_ok() {
1831 assert_eq!(coco_category_id_from_label_index(90).unwrap(), 90);
1832 assert_eq!(
1833 coco_category_id_from_label_index(u32::MAX as u64).unwrap(),
1834 u32::MAX
1835 );
1836 }
1837
1838 #[test]
1839 fn test_coco_category_id_from_label_index_overflow() {
1840 let err = coco_category_id_from_label_index(u64::from(u32::MAX) + 1).unwrap_err();
1841 assert!(matches!(err, crate::Error::InvalidParameters(_)));
1842 }
1843
1844 #[test]
1849 fn test_merge_coco_datasets_empty() {
1850 let mut target = CocoDataset::default();
1851 let source = CocoDataset::default();
1852 merge_coco_datasets(&mut target, source);
1853 assert!(target.images.is_empty());
1854 assert!(target.annotations.is_empty());
1855 assert!(target.categories.is_empty());
1856 }
1857
1858 #[test]
1859 fn test_merge_coco_datasets_basic() {
1860 let mut target = CocoDataset {
1861 images: vec![CocoImage {
1862 id: 1,
1863 file_name: "img1.jpg".to_string(),
1864 ..Default::default()
1865 }],
1866 categories: vec![CocoCategory {
1867 id: 1,
1868 name: "cat".to_string(),
1869 supercategory: None,
1870 ..Default::default()
1871 }],
1872 annotations: vec![CocoAnnotation {
1873 id: 1,
1874 image_id: 1,
1875 category_id: 1,
1876 ..Default::default()
1877 }],
1878 ..Default::default()
1879 };
1880
1881 let source = CocoDataset {
1882 images: vec![CocoImage {
1883 id: 2,
1884 file_name: "img2.jpg".to_string(),
1885 ..Default::default()
1886 }],
1887 categories: vec![CocoCategory {
1888 id: 2,
1889 name: "dog".to_string(),
1890 supercategory: None,
1891 ..Default::default()
1892 }],
1893 annotations: vec![CocoAnnotation {
1894 id: 2,
1895 image_id: 2,
1896 category_id: 2,
1897 ..Default::default()
1898 }],
1899 ..Default::default()
1900 };
1901
1902 merge_coco_datasets(&mut target, source);
1903
1904 assert_eq!(target.images.len(), 2);
1905 assert_eq!(target.categories.len(), 2);
1906 assert_eq!(target.annotations.len(), 2);
1907 }
1908
1909 #[test]
1910 fn test_merge_coco_datasets_deduplicates_images() {
1911 let mut target = CocoDataset {
1912 images: vec![CocoImage {
1913 id: 1,
1914 file_name: "img1.jpg".to_string(),
1915 ..Default::default()
1916 }],
1917 ..Default::default()
1918 };
1919
1920 let source = CocoDataset {
1921 images: vec![
1922 CocoImage {
1923 id: 1, file_name: "img1_dup.jpg".to_string(),
1925 ..Default::default()
1926 },
1927 CocoImage {
1928 id: 2,
1929 file_name: "img2.jpg".to_string(),
1930 ..Default::default()
1931 },
1932 ],
1933 ..Default::default()
1934 };
1935
1936 merge_coco_datasets(&mut target, source);
1937
1938 assert_eq!(target.images.len(), 2); assert_eq!(target.images[0].file_name, "img1.jpg"); }
1941
1942 #[test]
1943 fn test_merge_coco_datasets_deduplicates_categories() {
1944 let mut target = CocoDataset {
1945 categories: vec![CocoCategory {
1946 id: 1,
1947 name: "person".to_string(),
1948 supercategory: None,
1949 ..Default::default()
1950 }],
1951 ..Default::default()
1952 };
1953
1954 let source = CocoDataset {
1955 categories: vec![
1956 CocoCategory {
1957 id: 1, name: "person_dup".to_string(),
1959 supercategory: None,
1960 ..Default::default()
1961 },
1962 CocoCategory {
1963 id: 2,
1964 name: "car".to_string(),
1965 supercategory: None,
1966 ..Default::default()
1967 },
1968 ],
1969 ..Default::default()
1970 };
1971
1972 merge_coco_datasets(&mut target, source);
1973
1974 assert_eq!(target.categories.len(), 2);
1975 assert_eq!(target.categories[0].name, "person"); }
1977
1978 #[test]
1979 fn test_merge_coco_datasets_info_preserved() {
1980 let mut target = CocoDataset::default();
1981
1982 let source = CocoDataset {
1983 info: CocoInfo {
1984 description: Some("Test dataset".to_string()),
1985 ..Default::default()
1986 },
1987 ..Default::default()
1988 };
1989
1990 merge_coco_datasets(&mut target, source);
1991
1992 assert_eq!(target.info.description, Some("Test dataset".to_string()));
1993 }
1994
1995 #[test]
2000 fn test_convert_coco_image_to_sample() {
2001 let image = CocoImage {
2002 id: 1,
2003 width: 640,
2004 height: 480,
2005 file_name: "test.jpg".to_string(),
2006 ..Default::default()
2007 };
2008
2009 let dataset = CocoDataset {
2010 images: vec![image.clone()],
2011 categories: vec![CocoCategory {
2012 id: 1,
2013 name: "person".to_string(),
2014 supercategory: None,
2015 ..Default::default()
2016 }],
2017 annotations: vec![CocoAnnotation {
2018 id: 1,
2019 image_id: 1,
2020 category_id: 1,
2021 bbox: [100.0, 50.0, 200.0, 150.0],
2022 area: 30000.0,
2023 iscrowd: 0,
2024 segmentation: None,
2025 score: None,
2026 }],
2027 ..Default::default()
2028 };
2029
2030 let index = CocoIndex::from_dataset(&dataset);
2031
2032 let sample = convert_coco_image_to_sample(
2033 &image,
2034 &index,
2035 Path::new("/tmp"),
2036 true,
2037 false, Some("train"),
2039 )
2040 .unwrap();
2041
2042 assert_eq!(sample.image_name, Some("test".to_string()));
2043 assert_eq!(sample.width, Some(640));
2044 assert_eq!(sample.height, Some(480));
2045 assert_eq!(sample.group, Some("train".to_string()));
2046 assert_eq!(sample.annotations.len(), 1);
2047 assert_eq!(sample.annotations[0].label(), Some(&"person".to_string()));
2048 }
2049
2050 #[test]
2051 fn test_convert_coco_image_to_sample_no_annotations() {
2052 let image = CocoImage {
2053 id: 1,
2054 width: 640,
2055 height: 480,
2056 file_name: "empty.jpg".to_string(),
2057 ..Default::default()
2058 };
2059
2060 let dataset = CocoDataset {
2061 images: vec![image.clone()],
2062 categories: vec![],
2063 annotations: vec![],
2064 ..Default::default()
2065 };
2066
2067 let index = CocoIndex::from_dataset(&dataset);
2068
2069 let sample =
2070 convert_coco_image_to_sample(&image, &index, Path::new("/tmp"), true, false, None)
2071 .unwrap();
2072
2073 assert_eq!(sample.image_name, Some("empty".to_string()));
2074 assert!(sample.annotations.is_empty());
2075 }
2076
2077 #[test]
2078 fn test_convert_coco_image_to_sample_with_mask() {
2079 let image = CocoImage {
2080 id: 1,
2081 width: 100,
2082 height: 100,
2083 file_name: "masked.jpg".to_string(),
2084 ..Default::default()
2085 };
2086
2087 let dataset = CocoDataset {
2088 images: vec![image.clone()],
2089 categories: vec![CocoCategory {
2090 id: 1,
2091 name: "object".to_string(),
2092 supercategory: None,
2093 ..Default::default()
2094 }],
2095 annotations: vec![CocoAnnotation {
2096 id: 1,
2097 image_id: 1,
2098 category_id: 1,
2099 bbox: [10.0, 10.0, 50.0, 50.0],
2100 area: 2500.0,
2101 iscrowd: 0,
2102 segmentation: Some(CocoSegmentation::Polygon(vec![vec![
2103 10.0, 10.0, 60.0, 10.0, 60.0, 60.0, 10.0, 60.0,
2104 ]])),
2105 score: None,
2106 }],
2107 ..Default::default()
2108 };
2109
2110 let index = CocoIndex::from_dataset(&dataset);
2111
2112 let sample_with =
2114 convert_coco_image_to_sample(&image, &index, Path::new("/tmp"), true, false, None)
2115 .unwrap();
2116 assert!(sample_with.annotations[0].polygon().is_some());
2117
2118 let sample_without =
2120 convert_coco_image_to_sample(&image, &index, Path::new("/tmp"), false, false, None)
2121 .unwrap();
2122 assert!(sample_without.annotations[0].polygon().is_none());
2123 }
2124
2125 #[test]
2130 fn test_compute_bbox_from_polygon_simple() {
2131 let mask = crate::Polygon::new(vec![vec![(0.1, 0.1), (0.5, 0.1), (0.5, 0.5), (0.1, 0.5)]]);
2132
2133 let bbox = compute_bbox_from_polygon(&mask, 100, 100);
2134
2135 assert!(bbox.is_some());
2136 let [x, y, w, h] = bbox.unwrap();
2137 assert!((x - 10.0).abs() < 1.0);
2138 assert!((y - 10.0).abs() < 1.0);
2139 assert!((w - 40.0).abs() < 1.0);
2140 assert!((h - 40.0).abs() < 1.0);
2141 }
2142
2143 #[test]
2144 fn test_compute_bbox_from_polygon_empty() {
2145 let mask = crate::Polygon::new(vec![]);
2146 let bbox = compute_bbox_from_polygon(&mask, 100, 100);
2147 assert!(bbox.is_none());
2148 }
2149
2150 #[test]
2151 fn test_compute_bbox_from_polygon_with_nan() {
2152 let mask = crate::Polygon::new(vec![vec![(f32::NAN, f32::NAN), (f32::NAN, f32::NAN)]]);
2153 let bbox = compute_bbox_from_polygon(&mask, 100, 100);
2154 assert!(bbox.is_none());
2155 }
2156
2157 #[test]
2158 fn test_compute_bbox_from_polygon_multiple_rings() {
2159 let mask = crate::Polygon::new(vec![
2161 vec![(0.1, 0.1), (0.2, 0.1), (0.2, 0.2), (0.1, 0.2)],
2162 vec![(0.8, 0.8), (0.9, 0.8), (0.9, 0.9), (0.8, 0.9)],
2163 ]);
2164
2165 let bbox = compute_bbox_from_polygon(&mask, 100, 100);
2166
2167 assert!(bbox.is_some());
2168 let [x, y, w, h] = bbox.unwrap();
2169 assert!((x - 10.0).abs() < 1.0);
2171 assert!((y - 10.0).abs() < 1.0);
2172 assert!((w - 80.0).abs() < 1.0);
2173 assert!((h - 80.0).abs() < 1.0);
2174 }
2175
2176 #[test]
2181 fn test_polygon_to_polygon_string() {
2182 let mask = crate::Polygon::new(vec![vec![(0.1, 0.2), (0.3, 0.4), (0.5, 0.6)]]);
2184
2185 let result = polygon_to_polygon_string(&mask);
2186
2187 assert_eq!(result, "[[[0.1,0.2],[0.3,0.4],[0.5,0.6]]]");
2190 }
2191
2192 #[test]
2193 fn test_polygon_to_polygon_string_multiple_rings() {
2194 let mask = crate::Polygon::new(vec![
2197 vec![(0.1, 0.1), (0.2, 0.1), (0.15, 0.2)], vec![(0.5, 0.5), (0.6, 0.5), (0.55, 0.6)], ]);
2200
2201 let result = polygon_to_polygon_string(&mask);
2202
2203 assert_eq!(
2205 result,
2206 "[[[0.1,0.1],[0.2,0.1],[0.15,0.2]],[[0.5,0.5],[0.6,0.5],[0.55,0.6]]]"
2207 );
2208 }
2209
2210 #[test]
2211 fn test_polygon_to_polygon_string_filters_nan_values() {
2212 let mask = crate::Polygon::new(vec![vec![
2214 (0.1, 0.2),
2215 (f32::NAN, 0.4), (0.3, 0.4),
2217 (0.5, 0.6),
2218 ]]);
2219
2220 let result = polygon_to_polygon_string(&mask);
2221
2222 assert!(
2224 !result.contains("null"),
2225 "NaN values should be filtered out, got: {}",
2226 result
2227 );
2228 assert_eq!(result, "[[[0.1,0.2],[0.3,0.4],[0.5,0.6]]]");
2230 }
2231
2232 #[test]
2233 fn test_polygon_to_polygon_string_filters_infinity() {
2234 let mask = crate::Polygon::new(vec![vec![
2236 (0.1, 0.2),
2237 (f32::INFINITY, 0.4), (0.3, 0.4),
2239 (0.5, 0.6),
2240 ]]);
2241
2242 let result = polygon_to_polygon_string(&mask);
2243
2244 assert!(
2245 !result.contains("null"),
2246 "Infinity values should be filtered out"
2247 );
2248 assert_eq!(result, "[[[0.1,0.2],[0.3,0.4],[0.5,0.6]]]");
2249 }
2250
2251 #[test]
2252 fn test_polygon_to_polygon_string_too_few_points_after_filter() {
2253 let mask = crate::Polygon::new(vec![vec![
2255 (0.1, 0.2),
2256 (f32::NAN, 0.4), (f32::NAN, f32::NAN), ]]);
2259
2260 let result = polygon_to_polygon_string(&mask);
2261
2262 assert_eq!(result, "[]");
2264 }
2265
2266 #[test]
2267 fn test_polygon_to_polygon_string_negative_infinity() {
2268 let mask = crate::Polygon::new(vec![vec![
2269 (0.1, 0.2),
2270 (f32::NEG_INFINITY, 0.4), (0.3, 0.4),
2272 (0.5, 0.6),
2273 ]]);
2274
2275 let result = polygon_to_polygon_string(&mask);
2276 assert_eq!(result, "[[[0.1,0.2],[0.3,0.4],[0.5,0.6]]]");
2277 }
2278
2279 #[test]
2284 fn test_coco_import_result() {
2285 let result = CocoImportResult {
2286 total_images: 100,
2287 skipped: 30,
2288 imported: 70,
2289 };
2290
2291 assert_eq!(result.total_images, 100);
2292 assert_eq!(result.skipped, 30);
2293 assert_eq!(result.imported, 70);
2294 }
2295
2296 #[test]
2301 fn test_coco_update_result() {
2302 let result = CocoUpdateResult {
2303 total_images: 500,
2304 updated: 450,
2305 not_found: 50,
2306 };
2307
2308 assert_eq!(result.total_images, 500);
2309 assert_eq!(result.updated, 450);
2310 assert_eq!(result.not_found, 50);
2311 }
2312
2313 #[test]
2318 fn test_read_coco_dataset_for_update_invalid_extension() {
2319 let result = read_coco_dataset_for_update(Path::new("/tmp/file.txt"));
2320 assert!(result.is_err());
2321 let err = result.unwrap_err();
2322 assert!(
2323 err.to_string()
2324 .contains("COCO update requires a JSON annotation file")
2325 );
2326 }
2327
2328 #[test]
2329 fn test_read_coco_dataset_for_update_nonexistent_json() {
2330 let result = read_coco_dataset_for_update(Path::new("/nonexistent/file.json"));
2331 assert!(result.is_err());
2332 }
2333
2334 #[test]
2335 fn test_read_coco_dataset_for_update_nonexistent_directory() {
2336 let result = read_coco_dataset_for_update(Path::new("/nonexistent_dir"));
2337 assert!(result.is_err());
2339 }
2340
2341 #[test]
2346 fn test_build_sample_info_map_empty() {
2347 let samples: Vec<crate::Sample> = vec![];
2348 let map = build_sample_info_map(&samples);
2349 assert!(map.is_empty());
2350 }
2351
2352 #[test]
2353 fn test_build_sample_info_map_with_samples() {
2354 use crate::{Sample, SampleID};
2355
2356 let sample1 = Sample {
2357 image_name: Some("sample1".to_string()),
2358 id: Some(SampleID::from(1)),
2359 width: Some(640),
2360 height: Some(480),
2361 group: Some("train".to_string()),
2362 ..Default::default()
2363 };
2364
2365 let sample2 = Sample {
2366 image_name: Some("sample2".to_string()),
2367 id: Some(SampleID::from(2)),
2368 width: Some(1280),
2369 height: Some(720),
2370 group: None,
2371 ..Default::default()
2372 };
2373
2374 let samples = vec![sample1, sample2];
2375 let map = build_sample_info_map(&samples);
2376
2377 assert_eq!(map.len(), 2);
2378 assert!(map.contains_key("sample1"));
2379 assert!(map.contains_key("sample2"));
2380
2381 let (id1, w1, h1, g1) = map.get("sample1").unwrap();
2382 assert_eq!(*id1, SampleID::from(1));
2383 assert_eq!(*w1, 640);
2384 assert_eq!(*h1, 480);
2385 assert_eq!(g1.as_deref(), Some("train"));
2386
2387 let (id2, w2, h2, g2) = map.get("sample2").unwrap();
2388 assert_eq!(*id2, SampleID::from(2));
2389 assert_eq!(*w2, 1280);
2390 assert_eq!(*h2, 720);
2391 assert!(g2.is_none());
2392 }
2393
2394 #[test]
2395 fn test_build_sample_info_map_skips_incomplete_samples() {
2396 use crate::Sample;
2397
2398 let sample_no_id = Sample {
2400 image_name: Some("no_id".to_string()),
2401 width: Some(640),
2402 height: Some(480),
2403 ..Default::default()
2404 };
2405
2406 let sample_no_name = Sample {
2408 id: Some(crate::SampleID::from(1)),
2409 width: Some(640),
2410 height: Some(480),
2411 ..Default::default()
2412 };
2413
2414 let sample_no_dims = Sample {
2416 image_name: Some("no_dims".to_string()),
2417 id: Some(crate::SampleID::from(2)),
2418 ..Default::default()
2419 };
2420
2421 let samples = vec![sample_no_id, sample_no_name, sample_no_dims];
2422 let map = build_sample_info_map(&samples);
2423
2424 assert!(map.is_empty());
2426 }
2427
2428 #[test]
2433 fn test_coco_import_options_clone() {
2434 let options = CocoImportOptions::default();
2436 let cloned = options.clone();
2437
2438 assert_eq!(options.batch_size, cloned.batch_size);
2439 assert_eq!(options.concurrency, cloned.concurrency);
2440 assert_eq!(options.include_masks, cloned.include_masks);
2441 }
2442
2443 #[test]
2448 fn test_coco_import_options_custom() {
2449 let options = CocoImportOptions {
2450 include_masks: false,
2451 include_images: false,
2452 group: Some("test".to_string()),
2453 batch_size: 50,
2454 concurrency: 32,
2455 resume: false,
2456 };
2457
2458 assert!(!options.include_masks);
2459 assert!(!options.include_images);
2460 assert_eq!(options.group.as_deref(), Some("test"));
2461 assert_eq!(options.batch_size, 50);
2462 assert_eq!(options.concurrency, 32);
2463 assert!(!options.resume);
2464 }
2465
2466 #[test]
2467 fn test_coco_update_options_custom() {
2468 let options = CocoUpdateOptions {
2469 include_masks: false,
2470 group: Some("val".to_string()),
2471 batch_size: 25,
2472 concurrency: 16,
2473 };
2474
2475 assert!(!options.include_masks);
2476 assert_eq!(options.group.as_deref(), Some("val"));
2477 assert_eq!(options.batch_size, 25);
2478 assert_eq!(options.concurrency, 16);
2479 }
2480
2481 #[test]
2486 fn test_extract_sample_name_simple() {
2487 assert_eq!(extract_sample_name("image.jpg"), "image");
2488 }
2489
2490 #[test]
2491 fn test_extract_sample_name_with_path() {
2492 assert_eq!(extract_sample_name("train2017/000001.jpg"), "000001");
2493 }
2494
2495 #[test]
2496 fn test_extract_sample_name_no_extension() {
2497 assert_eq!(extract_sample_name("image"), "image");
2498 }
2499
2500 #[test]
2501 fn test_extract_sample_name_multiple_dots() {
2502 assert_eq!(extract_sample_name("image.v2.final.jpg"), "image.v2.final");
2503 }
2504}