Skip to main content

edgefirst_client/coco/
studio.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4//! COCO import/export for EdgeFirst Studio.
5//!
6//! Provides high-level workflows for importing COCO datasets into Studio
7//! and exporting Studio datasets to COCO format.
8//!
9//! **Note:** COCO datasets must be extracted before import. ZIP archives
10//! are not supported directly - extract images to `train2017/`, `val2017/`,
11//! etc. subdirectories first.
12
13use 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/// Result of a COCO import operation.
32#[derive(Debug, Clone)]
33pub struct CocoImportResult {
34    /// Total number of images in the COCO dataset.
35    pub total_images: usize,
36    /// Number of images that were already imported (skipped).
37    pub skipped: usize,
38    /// Number of images newly imported.
39    pub imported: usize,
40}
41
42/// Options for importing COCO to Studio.
43#[derive(Debug, Clone)]
44pub struct CocoImportOptions {
45    /// Include segmentation masks.
46    pub include_masks: bool,
47    /// Include images (upload them to Studio).
48    pub include_images: bool,
49    /// Group name for all samples (e.g., "train", "val").
50    pub group: Option<String>,
51    /// Batch size for API calls.
52    pub batch_size: usize,
53    /// Maximum concurrent uploads (default: 64).
54    pub concurrency: usize,
55    /// Resume import by skipping already-imported samples.
56    /// When true (default), checks existing samples and skips duplicates.
57    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/// Options for exporting Studio to COCO.
74#[derive(Debug, Clone)]
75pub struct CocoExportOptions {
76    /// Filter by group names (empty = all).
77    pub groups: Vec<String>,
78    /// Include segmentation masks in output.
79    pub include_masks: bool,
80    /// Include images in output (download and add to ZIP).
81    pub include_images: bool,
82    /// Output as ZIP archive (if false, output JSON only).
83    pub output_zip: bool,
84    /// Pretty-print JSON.
85    pub pretty_json: bool,
86    /// COCO info section.
87    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
103/// Import COCO dataset into EdgeFirst Studio.
104///
105/// Reads COCO annotations and images from an extracted directory,
106/// converts to EdgeFirst format, and uploads to Studio using the bulk API.
107///
108/// # Arguments
109/// * `client` - Authenticated Studio client
110/// * `coco_path` - Path to COCO annotation JSON file (images must be extracted
111///   in sibling directories like `train2017/`, `val2017/`)
112/// * `dataset_id` - Target dataset in Studio
113/// * `annotation_set_id` - Target annotation set
114/// * `options` - Import options
115/// * `progress` - Optional progress channel
116///
117/// # Returns
118/// Import result with counts of total, skipped, and imported samples
119///
120/// # Errors
121/// Returns an error if:
122/// - No annotation file is found
123/// - Images are not extracted (ZIP archives not supported)
124/// - Upload to Studio fails
125///
126/// # Resume Behavior
127/// When `options.resume` is true (default), the function checks which samples
128/// already exist in the target dataset and skips them. This allows resuming
129/// interrupted imports without re-uploading data.
130///
131/// # Example
132/// ```bash
133/// # First extract COCO dataset:
134/// cd ~/Datasets/COCO
135/// unzip annotations_trainval2017.zip
136/// unzip val2017.zip
137///
138/// # Then import:
139/// edgefirst import-coco annotations/instances_val2017.json DS_ID AS_ID --group val
140///
141/// # If interrupted, simply run again - it will resume from where it left off
142/// ```
143pub 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    // Read COCO dataset
154    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    // Validate that images are extracted
164    if options.include_images {
165        validate_images_extracted(&dataset, &images_dir)?;
166    }
167
168    // Check for existing samples if resume is enabled
169    let existing_names = fetch_existing_sample_names(client, &dataset_id, options.resume).await?;
170
171    // Filter images for import
172    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 filtering info
177    log_import_filter_info(group_filter, filtered_by_group, total_images);
178
179    let to_import = images_to_import.len();
180
181    // If nothing to import, return early
182    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    // Build index and upload
201    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
221/// Fetch existing sample names from the server if resume is enabled.
222async 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
243/// Log filtering information.
244fn 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
255/// Log when there's nothing to import.
256fn 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
267/// Send progress update.
268async 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
280/// Context for batch upload operations.
281struct 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
289/// Upload images in batches with high concurrency.
290async 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
319/// Convert a batch of COCO images to EdgeFirst samples.
320fn 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
344/// Validate that images are extracted and accessible.
345fn validate_images_extracted(dataset: &CocoDataset, images_dir: &Path) -> Result<(), Error> {
346    // Sample a few images to verify they exist
347    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
374/// Find an image file in standard COCO directory locations.
375fn 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
388/// Infer group name from COCO annotation filename.
389///
390/// Examples:
391/// - `instances_train2017.json` -> Some("train")
392/// - `instances_val2017.json` -> Some("val")
393/// - `instances_test2017.json` -> Some("test")
394/// - `custom.json` -> None
395fn infer_group_from_filename(path: &Path) -> Option<String> {
396    let stem = path.file_stem()?.to_str()?;
397
398    // Look for patterns like "instances_train2017" or "instances_val2014"
399    if let Some(rest) = stem.strip_prefix("instances_") {
400        // Remove trailing year digits: "train2017" -> "train"
401        let group = rest.trim_end_matches(char::is_numeric);
402        if !group.is_empty() {
403            return Some(group.to_string());
404        }
405    }
406
407    // Also handle patterns like "train_instances" or just "train"
408    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
417/// Read COCO dataset from a path (directory or JSON file).
418///
419/// Returns the dataset and the base directory for images.
420fn read_coco_from_path(coco_path: &Path) -> Result<(CocoDataset, PathBuf), Error> {
421    if coco_path.is_dir() {
422        // Read all annotation files and merge into one dataset
423        let datasets = read_coco_directory(coco_path, &CocoReadOptions::default())?;
424        log::info!("Found {} annotation files in directory", datasets.len());
425
426        // Merge all datasets, preserving group info by prefixing file_name
427        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            // Prefix file_name with group folder so infer_group_from_folder can extract it
436            // e.g., "000000123.jpg" -> "train2017/000000123.jpg"
437            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        // JSON file directly
447        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()) // Go up from annotations/ to COCO root
452            .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
463/// Filter images for import based on group filter and existing samples.
464///
465/// Returns a vector of images to import, the count of skipped images,
466/// and the count of images filtered by group.
467fn 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    // Filter images that match group filter and aren't already imported
475    let images_to_import: Vec<_> = images
476        .iter()
477        .filter(|img| {
478            // Check group filter
479            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            // Check if already imported
486            let sample_name = extract_sample_name(&img.file_name);
487            !existing_names.contains(&sample_name)
488        })
489        .collect();
490
491    // Count images filtered by group
492    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
508/// Extract sample name from a file name.
509fn 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
517/// Merge two COCO datasets, avoiding duplicates.
518///
519/// Images and categories are deduplicated by ID. Annotations are always
520/// appended (assuming globally unique IDs across annotation files).
521fn merge_coco_datasets(target: &mut CocoDataset, source: CocoDataset) {
522    // Merge images (deduplicate by id)
523    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    // Merge categories (deduplicate by id)
531    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    // Merge annotations (always append - IDs should be globally unique)
539    target.annotations.extend(source.annotations);
540
541    // Merge licenses (deduplicate by id)
542    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    // Take info from source if target has none
550    if target.info.description.is_none() && source.info.description.is_some() {
551        target.info = source.info;
552    }
553}
554
555/// Convert a COCO image and its annotations to an EdgeFirst Sample for Studio upload.
556///
557/// LVIS extension fields (`iscrowd`, `category_frequency`, `neg_label_indices`,
558/// `not_exhaustive_label_indices`) are set on the returned Sample/Annotations when
559/// present in the source data. However, Studio's backend currently drops these fields
560/// during import because it re-marshals annotations through typed Go structs (see
561/// DE-2509). These fields will round-trip correctly once the backend adds JSONB
562/// pass-through support. Until then, they are harmlessly omitted from the stored data
563/// via `skip_serializing_if = "Option::is_none"`.
564fn 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    // Create annotations
579    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    // Translate LVIS image-level fields to label_index lists
612    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    // Create sample files
624    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
645/// Export Studio dataset to COCO format.
646///
647/// Downloads samples and annotations from Studio and converts to COCO format.
648///
649/// # Arguments
650/// * `client` - Authenticated Studio client
651/// * `dataset_id` - Source dataset in Studio
652/// * `annotation_set_id` - Source annotation set
653/// * `output_path` - Output file path (JSON or ZIP)
654/// * `options` - Export options
655/// * `progress` - Optional progress channel
656///
657/// # Returns
658/// Number of annotations exported
659pub 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    // Fetch samples from Studio with annotations
670    let groups: Vec<String> = options.groups.clone();
671    let annotation_types = [crate::AnnotationType::Box2d, crate::AnnotationType::Polygon];
672
673    // Fetch all samples
674    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    // Convert to COCO format
687    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        // Use the image_name directly if it has an extension, otherwise add .jpg
699        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            // Get bbox from box2d if present, otherwise compute from polygon
708            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                // Prefer Studio label_index (source-faithful COCO category_id when
718                // present) so export does not renumber categories to sequential 1..N.
719                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    // Write output
739    let writer = CocoWriter::with_options(CocoWriteOptions {
740        compress: true,
741        pretty: options.pretty_json,
742    });
743
744    if options.output_zip {
745        // Download images and create ZIP
746        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
760/// Download images for samples from their presigned URLs.
761///
762/// Returns a vector of (archive_path, image_data) pairs suitable for ZIP
763/// creation.
764async 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        // Find image file URL
774        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            // Download the image
784            match client.download(url).await {
785                Ok(data) => {
786                    // Build archive path from sample name
787                    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 warning but continue with other images
797                    log::warn!(
798                        "Failed to download image for sample {:?}: {}",
799                        sample.image_name,
800                        e
801                    );
802                }
803            }
804        }
805
806        // Update progress
807        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/// Options for verifying a COCO import.
822#[derive(Debug, Clone)]
823pub struct CocoVerifyOptions {
824    /// Include segmentation mask verification.
825    pub verify_masks: bool,
826    /// Group to verify (None = all groups).
827    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/// Result of a COCO annotation update operation.
840#[derive(Debug, Clone)]
841pub struct CocoUpdateResult {
842    /// Total number of images in the COCO dataset.
843    pub total_images: usize,
844    /// Number of samples that were updated with new annotations.
845    pub updated: usize,
846    /// Number of COCO images not found in Studio (not updated).
847    pub not_found: usize,
848}
849
850/// Options for updating annotations on existing samples.
851#[derive(Debug, Clone)]
852pub struct CocoUpdateOptions {
853    /// Include segmentation masks in the update.
854    pub include_masks: bool,
855    /// Group name filter (None = match any group).
856    pub group: Option<String>,
857    /// Batch size for API calls.
858    pub batch_size: usize,
859    /// Maximum concurrent operations.
860    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
874/// Read COCO dataset from a path, handling both files and directories.
875fn read_coco_dataset_for_update(coco_path: &Path) -> Result<CocoDataset, Error> {
876    if coco_path.is_dir() {
877        // Read all annotation files and merge into one dataset
878        let datasets = read_coco_directory(coco_path, &CocoReadOptions::default())?;
879        log::info!("Found {} annotation files in directory", datasets.len());
880
881        // Merge all datasets, preserving group info by prefixing file_name
882        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            // Prefix file_name with group folder so infer_group_from_folder can extract it
891            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
909/// Build a map of sample name -> (sample_id, width, height, group) from
910/// samples.
911fn 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
926/// Convert a Studio `label_index` into a COCO `category_id` (`u32`).
927///
928/// Fails fast if the index cannot be represented as `u32` so we never silently
929/// truncate sparse/source-faithful IDs during export or verify.
930fn 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
938/// Resolve the COCO category id for an annotation, preferring Studio
939/// `label_index` (source-faithful) when present.
940fn 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
954/// Build parallel name/index arrays that preserve COCO `category_id` as
955/// Studio `label_index` (source-faithful; IDs are not rebased to 0..N).
956fn 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
962/// Ensure all COCO category labels exist in Studio with source-faithful indices.
963///
964/// Uses [`Client::add_labels_with_indices`] so each category is created (or
965/// reassigned) at its original COCO `category_id`. Name-only `add_label` would
966/// let the server assign sequential 0..N indices and break COCO round-trips.
967async 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    // Re-query labels to get their IDs after create/reassign
988    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
1003/// Convert a single COCO annotation to a ServerAnnotation.
1004///
1005/// This is a pure transformation function that can be easily tested.
1006fn 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    // Get category name and label_id
1018    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    // Convert bounding box to server format
1028    let box2d = coco_bbox_to_box2d(&coco_ann.bbox, width, height);
1029
1030    // Convert polygon to string if enabled
1031    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
1063/// Process a single COCO image for update, returning annotations and group
1064/// update info.
1065fn 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    // Check if group needs updating
1086    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    // Convert all annotations for this image
1095    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
1117/// Update sample groups in bulk.
1118async 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    // Collect unique group names and get/create their IDs
1135    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    // Update each sample's group
1153    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
1186/// Update annotations on existing samples without re-uploading images.
1187///
1188/// This function reads COCO annotations and updates the annotations on samples
1189/// that already exist in Studio. It's useful for:
1190/// - Adding masks to samples that were imported without them
1191/// - Syncing updated annotations to Studio
1192///
1193/// **Note:** This does NOT upload images. Samples must already exist in Studio.
1194///
1195/// # Arguments
1196/// * `client` - Authenticated Studio client
1197/// * `coco_path` - Path to COCO annotation JSON file
1198/// * `dataset_id` - Target dataset in Studio
1199/// * `annotation_set_id` - Target annotation set
1200/// * `options` - Update options
1201/// * `progress` - Optional progress channel
1202///
1203/// # Returns
1204/// Update result with counts of updated and not-found samples.
1205pub 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    // Read COCO annotations
1218    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    // Query ALL existing samples from Studio
1235    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    // Build COCO index for efficient annotation lookup
1255    let coco_index = CocoIndex::from_dataset(&dataset);
1256
1257    // Ensure all labels exist
1258    let label_map = ensure_labels_exist(client, &dataset_id, &dataset.categories).await?;
1259
1260    // Process all images and collect results
1261    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    // Send initial progress
1319    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    // Step 1: Delete existing annotations for these samples
1330    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    // Delete in batches to avoid overwhelming the server
1341    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    // Send progress after delete
1348    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    // Step 2: Add new annotations in batches
1359    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    // Final progress update
1370    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    // Step 3: Update sample groups if needed
1381    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
1399/// Convert a Polygon to a polygon string for the server API.
1400///
1401/// The server expects a 3D array format: `[[[x1,y1],[x2,y2],...], ...]`
1402/// where each point is an `[x, y]` pair. This matches how the server
1403/// parses polygons in `annotations_handler.go`:
1404/// ```go
1405/// var polygons [][][]float64
1406/// json.Unmarshal([]byte(ann.Polygon), &polygons)
1407/// ```
1408///
1409/// **Note:** This function filters out NaN and Infinity values which would
1410/// serialize as `null` in JSON and cause parsing failures on the server.
1411fn polygon_to_polygon_string(polygon: &crate::Polygon) -> String {
1412    // Convert Vec<Vec<(f32, f32)>> to Vec<Vec<[f32; 2]>> for proper JSON
1413    // serialization Filter out any NaN or Infinity values which would become
1414    // "null" in JSON
1415    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) // Need at least 3 points for a valid polygon
1425        .collect();
1426
1427    serde_json::to_string(&rings).unwrap_or_default()
1428}
1429
1430/// Compute COCO bounding box from polygon contours.
1431///
1432/// When the server doesn't return bounding box coordinates for segmentation
1433/// annotations, we compute them from the polygon bounds.
1434fn 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    // Convert normalized coordinates to COCO pixel coordinates [x, y, w, h]
1464    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
1476/// Verify a COCO dataset import against Studio data.
1477///
1478/// Compares the local COCO dataset against what's stored in Studio to verify:
1479/// - All images are present (no missing, no extras)
1480/// - All annotations are correct (using Hungarian matching)
1481/// - Bounding boxes match within tolerance
1482/// - Segmentation masks match (if enabled)
1483///
1484/// This does NOT download images - it only compares metadata and annotations.
1485///
1486/// # Arguments
1487/// * `client` - Authenticated Studio client
1488/// * `coco_path` - Path to local COCO annotation JSON file
1489/// * `dataset_id` - Dataset in Studio to verify against
1490/// * `annotation_set_id` - Annotation set in Studio to verify against
1491/// * `options` - Verification options
1492/// * `progress` - Optional progress channel
1493///
1494/// # Returns
1495/// Verification result with detailed comparison metrics.
1496pub 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    // Read local COCO dataset
1515    log::info!("Reading local COCO dataset from {:?}", coco_path);
1516    let (coco_dataset, inferred_group) = if coco_path.is_dir() {
1517        // Read all annotation files and merge into one dataset
1518        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        // When verifying entire directory, don't filter by group
1532        (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    // Determine group filter (only when verifying a single JSON file)
1545    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    // Fetch samples from Studio with annotations
1558    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    // Convert Studio samples to COCO format for comparison
1581    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        // Use the image_name directly if it has an extension, otherwise add .jpg
1589        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            // Get bbox from box2d if present, otherwise compute from polygon
1598            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 bounds
1602                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    // Build sample name sets for comparison
1627    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    // Validate bounding boxes
1645    log::info!("Validating bounding boxes...");
1646    let bbox_validation = validate_bboxes(&coco_dataset, &studio_dataset);
1647
1648    // Validate masks if enabled
1649    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    // Validate categories
1657    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    // =========================================================================
1678    // Options default tests
1679    // =========================================================================
1680
1681    #[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    // =========================================================================
1720    // find_image_file tests
1721    // =========================================================================
1722
1723    #[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        // Tests that file_name like "train2017/000001.jpg" is handled
1732        let result = find_image_file(Path::new("/nonexistent"), "train2017/image.jpg");
1733        assert!(result.is_none()); // Non-existent, but exercises the path logic
1734    }
1735
1736    // =========================================================================
1737    // infer_group_from_filename tests
1738    // =========================================================================
1739
1740    #[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        // The function checks "val" before "validation", so "validation_set" matches
1773        // "val"
1774        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    // =========================================================================
1791    // coco_label_specs tests
1792    // =========================================================================
1793
1794    #[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        // Must preserve sparse COCO IDs — not rebase to 0..N
1819        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    // =========================================================================
1845    // merge_coco_datasets tests
1846    // =========================================================================
1847
1848    #[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, // Duplicate
1924                    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); // Only 2, not 3
1939        assert_eq!(target.images[0].file_name, "img1.jpg"); // Original preserved
1940    }
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, // Duplicate
1958                    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"); // Original preserved
1976    }
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    // =========================================================================
1996    // convert_coco_image_to_sample tests
1997    // =========================================================================
1998
1999    #[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, // Don't try to include images
2038            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        // With masks
2113        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        // Without masks
2119        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    // =========================================================================
2126    // compute_bbox_from_polygon tests
2127    // =========================================================================
2128
2129    #[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        // Two disjoint regions
2160        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        // Should encompass both regions: 0.1 to 0.9
2170        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    // =========================================================================
2177    // polygon_to_polygon_string tests
2178    // =========================================================================
2179
2180    #[test]
2181    fn test_polygon_to_polygon_string() {
2182        // Create a simple triangle mask
2183        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        // Server expects 3D array format: [[[x1,y1],[x2,y2],...]]
2188        // NOT COCO format: [[x1,y1,x2,y2,...]]
2189        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        // Create a mask with two polygons (e.g., disjoint regions)
2195        // Each polygon needs at least 3 points to be valid
2196        let mask = crate::Polygon::new(vec![
2197            vec![(0.1, 0.1), (0.2, 0.1), (0.15, 0.2)], // Triangle 1
2198            vec![(0.5, 0.5), (0.6, 0.5), (0.55, 0.6)], // Triangle 2
2199        ]);
2200
2201        let result = polygon_to_polygon_string(&mask);
2202
2203        // Should produce two separate polygon rings
2204        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        // Test that NaN values are filtered out
2213        let mask = crate::Polygon::new(vec![vec![
2214            (0.1, 0.2),
2215            (f32::NAN, 0.4), // NaN value - should be filtered
2216            (0.3, 0.4),
2217            (0.5, 0.6),
2218        ]]);
2219
2220        let result = polygon_to_polygon_string(&mask);
2221
2222        // NaN values should be filtered out, not serialized as "null"
2223        assert!(
2224            !result.contains("null"),
2225            "NaN values should be filtered out, got: {}",
2226            result
2227        );
2228        // Should have 3 valid points remaining
2229        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        // Test that Infinity values are filtered out
2235        let mask = crate::Polygon::new(vec![vec![
2236            (0.1, 0.2),
2237            (f32::INFINITY, 0.4), // Infinity - should be filtered
2238            (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        // If filtering leaves fewer than 3 points, the ring should be dropped
2254        let mask = crate::Polygon::new(vec![vec![
2255            (0.1, 0.2),
2256            (f32::NAN, 0.4),      // filtered
2257            (f32::NAN, f32::NAN), // filtered
2258        ]]);
2259
2260        let result = polygon_to_polygon_string(&mask);
2261
2262        // Only 1 point remains, so the ring should be dropped
2263        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), // -Infinity - should be filtered
2271            (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    // =========================================================================
2280    // CocoImportResult tests
2281    // =========================================================================
2282
2283    #[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    // =========================================================================
2297    // CocoUpdateResult tests
2298    // =========================================================================
2299
2300    #[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    // =========================================================================
2314    // read_coco_dataset_for_update tests
2315    // =========================================================================
2316
2317    #[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        // Directory doesn't exist, so is_dir() returns false, and it's not .json
2338        assert!(result.is_err());
2339    }
2340
2341    // =========================================================================
2342    // build_sample_info_map tests
2343    // =========================================================================
2344
2345    #[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        // Sample missing id
2399        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        // Sample missing name
2407        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        // Sample missing dimensions
2415        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        // All samples should be skipped because they're incomplete
2425        assert!(map.is_empty());
2426    }
2427
2428    // =========================================================================
2429    // UploadContext tests
2430    // =========================================================================
2431
2432    #[test]
2433    fn test_coco_import_options_clone() {
2434        // Test that options can be cloned (used in async contexts)
2435        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    // =========================================================================
2444    // CocoImportOptions custom values tests
2445    // =========================================================================
2446
2447    #[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    // =========================================================================
2482    // extract_sample_name tests
2483    // =========================================================================
2484
2485    #[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}