Skip to main content

edgefirst_client/coco/
arrow.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4//! COCO to EdgeFirst Arrow IPC or Parquet conversion.
5//!
6//! Provides high-performance conversion between COCO JSON and EdgeFirst Arrow
7//! format, supporting async operations and progress tracking.
8
9use super::{
10    convert::{
11        box2d_to_coco_bbox, coco_bbox_to_box2d, coco_segmentation_to_mask_data,
12        coco_segmentation_to_polygon, polygon_to_coco_polygon,
13    },
14    reader::{CocoReader, read_coco_directory},
15    types::{CocoCategory, CocoDataset, CocoImage, CocoIndex, CocoInfo, CocoSegmentation},
16    writer::{CocoDatasetBuilder, CocoWriteOptions, CocoWriter},
17};
18use crate::{Annotation, Box2d, Error, Polygon, Progress, Sample};
19use polars::prelude::*;
20use std::{
21    collections::{BTreeMap, HashMap},
22    path::{Path, PathBuf},
23    sync::{
24        Arc,
25        atomic::{AtomicUsize, Ordering},
26    },
27};
28use tokio::sync::{Semaphore, mpsc::Sender};
29
30/// Schema version written into Arrow IPC file metadata.
31pub const SCHEMA_VERSION: &str = "2026.04";
32
33/// Polygon rings for a single row: each ring is a vec of `(x, y)` coordinate pairs.
34type PolygonRings = Vec<Vec<(f32, f32)>>;
35
36/// Options for COCO to Arrow conversion.
37///
38/// Construct with `..Default::default()` (as every call site in this
39/// workspace does) rather than a fully-specified struct literal: new fields
40/// may be added here in a minor release, following this crate's existing
41/// convention for `*Options` structs.
42#[derive(Debug, Clone)]
43pub struct CocoToArrowOptions {
44    /// Include segmentation masks in output.
45    pub include_masks: bool,
46    /// Group name for all samples (e.g., "train", "val").
47    pub group: Option<String>,
48    /// Maximum number of parallel workers.
49    pub max_workers: usize,
50    /// Stage the images referenced by the COCO file into the EdgeFirst
51    /// on-disk layout next to the output annotation file.
52    pub images_dir: Option<PathBuf>,
53    /// Symlink instead of copy (large datasets).
54    pub link_images: bool,
55}
56
57impl Default for CocoToArrowOptions {
58    fn default() -> Self {
59        Self {
60            include_masks: true,
61            group: None,
62            max_workers: max_workers(),
63            images_dir: None,
64            link_images: false,
65        }
66    }
67}
68
69/// Options for Arrow to COCO conversion.
70#[derive(Debug, Clone)]
71pub struct ArrowToCocoOptions {
72    /// Filter by group names (empty = all).
73    pub groups: Vec<String>,
74    /// Include segmentation masks in output.
75    pub include_masks: bool,
76    /// COCO info section.
77    pub info: Option<CocoInfo>,
78    /// Pretty-print the output JSON.
79    pub pretty: bool,
80}
81
82impl Default for ArrowToCocoOptions {
83    fn default() -> Self {
84        Self {
85            groups: vec![],
86            include_masks: true,
87            info: None,
88            pretty: false,
89        }
90    }
91}
92
93/// Determine maximum number of parallel workers.
94fn max_workers() -> usize {
95    std::env::var("MAX_COCO_WORKERS")
96        .ok()
97        .and_then(|v| v.parse().ok())
98        .unwrap_or_else(|| {
99            let cpus = std::thread::available_parallelism()
100                .map(|n| n.get())
101                .unwrap_or(4);
102            (cpus / 2).clamp(2, 8)
103        })
104}
105
106/// Convert COCO annotations to the EdgeFirst Dataset Format.
107///
108/// This is a high-performance async conversion that uses parallel workers
109/// for parsing and transforming annotations.
110///
111/// # Arguments
112/// * `coco_path` - Path to COCO annotation JSON/ZIP or a standard extracted
113///   directory; directory inputs combine inferred train/val groups
114/// * `output_path` - Output Arrow IPC or Parquet path, selected by extension
115/// * `options` - Conversion options
116/// * `progress` - Optional progress channel
117///
118/// # Returns
119/// Number of samples converted
120pub async fn coco_to_arrow<P: AsRef<Path>>(
121    coco_path: P,
122    output_path: P,
123    options: &CocoToArrowOptions,
124    progress: Option<Sender<Progress>>,
125) -> Result<usize, Error> {
126    let coco_path = coco_path.as_ref();
127    let output_path = output_path.as_ref();
128
129    // A directory is the standard multi-split COCO layout. Process each
130    // annotation file independently so train/val image or annotation IDs may
131    // overlap without one split silently replacing rows from another.
132    let mut sources: Vec<(CocoDataset, Option<String>, Option<String>)> = if coco_path.is_dir() {
133        read_coco_directory(coco_path, &Default::default())?
134            .into_iter()
135            .map(|(dataset, inferred_group)| {
136                let group = options.group.clone().or(Some(inferred_group.clone()));
137                (dataset, group, Some(inferred_group))
138            })
139            .collect()
140    } else {
141        let reader = CocoReader::new();
142        let dataset = if coco_path.extension().is_some_and(|e| e == "zip") {
143            reader.read_annotations_zip(coco_path)?
144        } else {
145            reader.read_json(coco_path)?
146        };
147        vec![(dataset, options.group.clone(), None)]
148    };
149
150    let total_images: usize = sources
151        .iter()
152        .map(|(dataset, _, _)| dataset.images.len())
153        .sum();
154    let categories = collect_compatible_categories(&sources)?;
155    let mut image_file_names = Vec::with_capacity(total_images);
156
157    // Send initial progress
158    if let Some(ref p) = progress {
159        let _ = p
160            .send(Progress {
161                current: 0,
162                total: total_images,
163                status: None,
164            })
165            .await;
166    }
167
168    // Process images in parallel
169    let sem = Arc::new(Semaphore::new(options.max_workers));
170    let current = Arc::new(AtomicUsize::new(0));
171    let mut tasks = Vec::with_capacity(total_images);
172    for (dataset, group, inferred_group) in sources.drain(..) {
173        let split_dir = if coco_path.is_dir() {
174            inferred_group
175                .as_deref()
176                .and_then(|group| find_split_image_dir(coco_path, group))
177        } else {
178            None
179        };
180        image_file_names.extend(dataset.images.iter().map(|image| {
181            split_dir
182                .as_ref()
183                .map(|dir| dir.join(&image.file_name).to_string_lossy().into_owned())
184                .unwrap_or_else(|| image.file_name.clone())
185        }));
186
187        let index = Arc::new(CocoIndex::from_dataset(&dataset));
188        for image in dataset.images {
189            let sem = sem.clone();
190            let index = index.clone();
191            let current = current.clone();
192            let progress = progress.clone();
193            let group = group.clone();
194            let include_masks = options.include_masks;
195
196            let task = tokio::spawn(async move {
197                let _permit = sem.acquire().await.map_err(Error::SemaphoreError)?;
198
199                let samples =
200                    convert_image_annotations(&image, &index, include_masks, group.as_deref());
201
202                let c = current.fetch_add(1, Ordering::SeqCst) + 1;
203                if let Some(ref p) = progress {
204                    let _ = p
205                        .send(Progress {
206                            current: c,
207                            total: total_images,
208                            status: None,
209                        })
210                        .await;
211                }
212
213                Ok::<Vec<Sample>, Error>(samples)
214            });
215
216            tasks.push(task);
217        }
218    }
219
220    // Collect all samples
221    let mut all_samples = Vec::with_capacity(total_images);
222    for task in tasks {
223        let samples = task.await??;
224        all_samples.extend(samples);
225    }
226
227    // Convert to DataFrame
228    let mut df = crate::samples_dataframe(&all_samples)?;
229
230    // Build schema-level metadata
231    let mut metadata: BTreeMap<PlSmallStr, PlSmallStr> = BTreeMap::new();
232    metadata.insert(
233        PlSmallStr::from("schema_version"),
234        PlSmallStr::from(SCHEMA_VERSION),
235    );
236
237    // Build category_metadata JSON from all categories.
238    // Includes id, frequency, and any LVIS fields (synset, synonyms, def).
239    // All categories are stored so that categories without annotations
240    // (e.g., those only referenced in neg_category_ids) can be
241    // reconstructed during Arrow→COCO export.
242    if !categories.is_empty() {
243        let cat_meta: HashMap<String, serde_json::Value> = categories
244            .iter()
245            .map(|c| {
246                let mut entry = serde_json::Map::new();
247                entry.insert("id".to_string(), serde_json::json!(c.id));
248                if let Some(ref f) = c.frequency {
249                    entry.insert(
250                        "frequency".to_string(),
251                        serde_json::Value::String(f.clone()),
252                    );
253                }
254                if let Some(ref s) = c.synset {
255                    entry.insert("synset".to_string(), serde_json::Value::String(s.clone()));
256                }
257                if let Some(ref syns) = c.synonyms {
258                    entry.insert("synonyms".to_string(), serde_json::json!(syns));
259                }
260                if let Some(ref d) = c.def {
261                    entry.insert(
262                        "definition".to_string(),
263                        serde_json::Value::String(d.clone()),
264                    );
265                }
266                if let Some(ref sc) = c.supercategory {
267                    entry.insert(
268                        "supercategory".to_string(),
269                        serde_json::Value::String(sc.clone()),
270                    );
271                }
272                // Note: image_count and instance_count are intentionally not
273                // stored — they are recomputable statistics that can be derived
274                // from the annotations at any time.
275                (c.name.clone(), serde_json::Value::Object(entry))
276            })
277            .collect();
278
279        let json = serde_json::to_string(&cat_meta).unwrap_or_default();
280        metadata.insert(
281            PlSmallStr::from("category_metadata"),
282            PlSmallStr::from(json.as_str()),
283        );
284    }
285
286    // Write labels metadata: sorted list of category names by category_id.
287    if !categories.is_empty() {
288        let mut cats: Vec<_> = categories.iter().collect();
289        cats.sort_by_key(|c| c.id);
290        let labels: Vec<String> = cats.iter().map(|c| c.name.clone()).collect();
291        let labels_json = serde_json::to_string(&labels).unwrap_or_default();
292        metadata.insert(PlSmallStr::from("labels"), PlSmallStr::from(labels_json));
293    }
294
295    write_dataset(&mut df, output_path, metadata)?;
296
297    if let Some(images_dir) = &options.images_dir {
298        let report = stage_images(
299            output_path,
300            images_dir,
301            options.link_images,
302            &image_file_names,
303        );
304        log::info!("Staged {} images", report.staged);
305        if !report.missing.is_empty() || !report.failed.is_empty() || report.collisions > 0 {
306            const MAX_LISTED: usize = 5;
307            let mut msg = String::new();
308            if !report.missing.is_empty() {
309                let listed: Vec<&str> = report
310                    .missing
311                    .iter()
312                    .take(MAX_LISTED)
313                    .map(String::as_str)
314                    .collect();
315                msg.push_str(&format!(
316                    "{} images referenced by {} were not found in {}: {}",
317                    report.missing.len(),
318                    coco_path.display(),
319                    images_dir.display(),
320                    listed.join(", "),
321                ));
322                if report.missing.len() > MAX_LISTED {
323                    msg.push_str(&format!(" (+{} more)", report.missing.len() - MAX_LISTED));
324                }
325            }
326            if !report.failed.is_empty() {
327                let listed: Vec<&str> = report
328                    .failed
329                    .iter()
330                    .take(MAX_LISTED)
331                    .map(String::as_str)
332                    .collect();
333                if !msg.is_empty() {
334                    msg.push_str("; ");
335                }
336                msg.push_str(&format!(
337                    "{} image(s) failed to stage (copy/link I/O error, see warnings above): {}",
338                    report.failed.len(),
339                    listed.join(", "),
340                ));
341                if report.failed.len() > MAX_LISTED {
342                    msg.push_str(&format!(" (+{} more)", report.failed.len() - MAX_LISTED));
343                }
344            }
345            if report.collisions > 0 {
346                msg.push_str(&format!(
347                    "; {} destination-basename collision(s) skipped (see warnings above)",
348                    report.collisions
349                ));
350            }
351            log::warn!("{}", msg);
352        }
353    }
354
355    Ok(all_samples.len())
356}
357
358/// Collect the shared category vocabulary for a multi-split conversion.
359///
360/// EdgeFirst metadata is keyed by category name while rows retain the numeric
361/// COCO category ID in `label_index`. A disagreement between splits therefore
362/// cannot be represented faithfully and is rejected instead of silently
363/// selecting whichever split happened to be read last.
364fn collect_compatible_categories(
365    sources: &[(CocoDataset, Option<String>, Option<String>)],
366) -> Result<Vec<CocoCategory>, Error> {
367    let mut by_name: BTreeMap<String, CocoCategory> = BTreeMap::new();
368    let mut names_by_id: BTreeMap<u32, String> = BTreeMap::new();
369
370    for (dataset, _, _) in sources {
371        for category in &dataset.categories {
372            if let Some(existing_name) = names_by_id.get(&category.id)
373                && existing_name != &category.name
374            {
375                return Err(Error::CocoError(format!(
376                    "COCO splits disagree on category {}: '{}' versus '{}'",
377                    category.id, existing_name, category.name
378                )));
379            }
380            if let Some(existing) = by_name.get(&category.name) {
381                if existing.id != category.id {
382                    return Err(Error::CocoError(format!(
383                        "COCO splits disagree on category '{}': id {} versus {}",
384                        category.name, existing.id, category.id
385                    )));
386                }
387
388                let conflicting_field = [
389                    (
390                        "supercategory",
391                        existing.supercategory.as_ref() != category.supercategory.as_ref(),
392                    ),
393                    (
394                        "synset",
395                        existing.synset.as_ref() != category.synset.as_ref(),
396                    ),
397                    (
398                        "frequency",
399                        existing.frequency.as_ref() != category.frequency.as_ref(),
400                    ),
401                    (
402                        "synonyms",
403                        existing.synonyms.as_ref() != category.synonyms.as_ref(),
404                    ),
405                    ("definition", existing.def.as_ref() != category.def.as_ref()),
406                ]
407                .into_iter()
408                .find_map(|(field, differs)| differs.then_some(field));
409
410                if let Some(field) = conflicting_field {
411                    return Err(Error::CocoError(format!(
412                        "COCO splits disagree on category '{}' metadata field '{}'",
413                        category.name, field
414                    )));
415                }
416            }
417            names_by_id.insert(category.id, category.name.clone());
418            by_name
419                .entry(category.name.clone())
420                .or_insert_with(|| category.clone());
421        }
422    }
423
424    Ok(by_name.into_values().collect())
425}
426
427/// Find the immediate COCO image directory associated with an inferred group.
428///
429/// Standard layouts use `train2017`, `val2017`, and `test2017`. Matching after
430/// trimming the year also supports other year suffixes without hard-coding one
431/// COCO release.
432fn find_split_image_dir(coco_root: &Path, group: &str) -> Option<PathBuf> {
433    let mut candidates: Vec<PathBuf> = std::fs::read_dir(coco_root)
434        .ok()?
435        .filter_map(Result::ok)
436        // `Path::is_dir` follows symlinks, which is common for large local
437        // COCO image trees stored on another volume.
438        .filter(|entry| entry.path().is_dir())
439        .filter_map(|entry| {
440            let name = entry.file_name();
441            let name_str = name.to_str()?;
442            (name_str.trim_end_matches(char::is_numeric) == group).then(|| PathBuf::from(name))
443        })
444        .collect();
445    candidates.sort();
446    candidates.into_iter().next()
447}
448
449/// Outcome of staging images referenced by a COCO dataset into the output
450/// annotation file's sibling image directory.
451struct StageReport {
452    /// Number of images copied/linked (or already present from a prior run).
453    staged: usize,
454    /// COCO `file_name` values that could not be found under `images_dir`.
455    missing: Vec<String>,
456    /// COCO `file_name` values that were found under `images_dir` but failed
457    /// to copy/link into the staging directory (see warnings logged at the
458    /// point of failure for the underlying I/O error).
459    failed: Vec<String>,
460    /// Number of `file_name`s that mapped to a destination basename already
461    /// claimed *within this run* by a different source path, and were
462    /// therefore skipped rather than silently overwriting/aliasing another
463    /// image (e.g. `train/000001.jpg` and `val/000001.jpg`).
464    collisions: usize,
465}
466
467/// Stage images referenced by `file_names` into the EdgeFirst on-disk
468/// layout next to `output_path`.
469///
470/// For an output annotation path `<dir>/<stem>.<ext>`, images are staged
471/// into the sibling directory `<dir>/<stem>/`, which is where
472/// `resolve_files_with_container` expects to find them.
473///
474/// Source resolution per `file_name` tries `images_dir.join(file_name)`
475/// first (COCO `file_name` may carry subpaths), then falls back to
476/// `images_dir.join(basename)`. The destination always uses the basename.
477/// Destination files that already exist from a prior run are left
478/// untouched, making re-runs idempotent. Two `file_name`s that resolve to
479/// the same destination basename but different source paths within the
480/// same run (e.g. `train/000001.jpg` and `val/000001.jpg`) are a
481/// collision: only the first is staged, the rest are counted in
482/// `StageReport::collisions` and logged, never silently treated as staged.
483/// Missing sources and copy/link I/O failures are both reported
484/// non-fatally via `StageReport`, in its `missing` and `failed` fields
485/// respectively.
486fn stage_images(
487    output_path: &Path,
488    images_dir: &Path,
489    link: bool,
490    file_names: &[String],
491) -> StageReport {
492    let parent = output_path.parent().unwrap_or_else(|| Path::new("."));
493    let stem = output_path
494        .file_stem()
495        .and_then(std::ffi::OsStr::to_str)
496        .unwrap_or("dataset");
497    let dest_dir = parent.join(stem);
498
499    if let Err(e) = std::fs::create_dir_all(&dest_dir) {
500        log::warn!(
501            "Failed to create image staging directory {}: {}",
502            dest_dir.display(),
503            e
504        );
505        return StageReport {
506            staged: 0,
507            missing: file_names.to_vec(),
508            failed: Vec::new(),
509            collisions: 0,
510        };
511    }
512
513    let mut staged = 0;
514    let mut missing = Vec::new();
515    let mut failed = Vec::new();
516    let mut collisions = 0;
517
518    // Basenames claimed by a source path within this run, so a second
519    // `file_name` mapping to the same basename from a *different* source
520    // can be detected as a collision rather than counted as staged just
521    // because the destination now exists.
522    let mut claimed_this_run: HashMap<std::ffi::OsString, (PathBuf, String)> = HashMap::new();
523
524    for file_name in file_names {
525        let basename = match Path::new(file_name).file_name() {
526            Some(b) => b,
527            None => {
528                missing.push(file_name.clone());
529                continue;
530            }
531        };
532
533        let candidate = images_dir.join(file_name);
534        let src = if candidate.exists() {
535            candidate
536        } else {
537            images_dir.join(basename)
538        };
539
540        if let Some((claimed_src, claimed_file_name)) = claimed_this_run.get(basename) {
541            if claimed_src == &src {
542                // Same source referenced twice (e.g. by two annotations) — harmless.
543                staged += 1;
544            } else {
545                log::warn!(
546                    "Destination basename '{}' collision: '{}' was already staged from '{}' this run; skipping '{}' ({})",
547                    basename.to_string_lossy(),
548                    claimed_file_name,
549                    claimed_src.display(),
550                    file_name,
551                    src.display(),
552                );
553                collisions += 1;
554            }
555            continue;
556        }
557
558        let dest = dest_dir.join(basename);
559
560        // Skip files already staged from a prior run.
561        if dest.symlink_metadata().is_ok() {
562            staged += 1;
563            claimed_this_run.insert(basename.to_os_string(), (src, file_name.clone()));
564            continue;
565        }
566
567        if !src.exists() {
568            missing.push(file_name.clone());
569            continue;
570        }
571
572        let result = if link {
573            stage_via_link(&src, &dest)
574        } else {
575            std::fs::copy(&src, &dest).map(|_| ())
576        };
577
578        match result {
579            Ok(()) => {
580                staged += 1;
581                claimed_this_run.insert(basename.to_os_string(), (src, file_name.clone()));
582            }
583            Err(e) => {
584                log::warn!("Failed to stage image '{}': {}", file_name, e);
585                failed.push(file_name.clone());
586            }
587        }
588    }
589
590    StageReport {
591        staged,
592        missing,
593        failed,
594        collisions,
595    }
596}
597
598/// Symlink `dest` to the canonicalized `src` (Unix/macOS). Staging is a
599/// Linux/macOS workflow; other platforms fall back to copying.
600#[cfg(unix)]
601fn stage_via_link(src: &Path, dest: &Path) -> std::io::Result<()> {
602    let canonical = src.canonicalize()?;
603    std::os::unix::fs::symlink(canonical, dest)
604}
605
606#[cfg(not(unix))]
607fn stage_via_link(src: &Path, dest: &Path) -> std::io::Result<()> {
608    std::fs::copy(src, dest).map(|_| ())
609}
610
611/// Write a DataFrame plus file-level metadata to `output_path`.
612///
613/// Format is selected from the output path extension: `.parquet` writes
614/// Apache Parquet with the metadata stored as footer key-value pairs;
615/// anything else (including `.arrow`/`.ipc`) writes Arrow IPC with the
616/// metadata stored as custom schema metadata.
617pub fn write_dataset(
618    df: &mut DataFrame,
619    output_path: &Path,
620    metadata: BTreeMap<PlSmallStr, PlSmallStr>,
621) -> Result<(), Error> {
622    if let Some(parent) = output_path.parent()
623        && !parent.as_os_str().is_empty()
624    {
625        std::fs::create_dir_all(parent)?;
626    }
627    let ext = output_path
628        .extension()
629        .and_then(std::ffi::OsStr::to_str)
630        .unwrap_or("");
631    let mut file = std::fs::File::create(output_path)?;
632    match ext {
633        "parquet" => {
634            let kv = metadata
635                .into_iter()
636                .map(|(k, v)| (k.to_string(), v.to_string()))
637                .collect();
638            ParquetWriter::new(&mut file)
639                .with_key_value_metadata(Some(KeyValueMetadata::from_static(kv)))
640                .finish(df)?;
641        }
642        _ => {
643            let mut writer = IpcWriter::new(&mut file);
644            writer.set_custom_schema_metadata(Arc::new(metadata));
645            writer.finish(df)?;
646        }
647    }
648    Ok(())
649}
650
651/// Convert a single image's annotations to EdgeFirst samples.
652fn convert_image_annotations(
653    image: &CocoImage,
654    index: &CocoIndex,
655    include_masks: bool,
656    group: Option<&str>,
657) -> Vec<Sample> {
658    let annotations = index.annotations_for_image(image.id);
659    let sample_name = sample_name_from_filename(&image.file_name);
660
661    // Translate LVIS image-level fields to label_index lists
662    let neg_label_indices = image.neg_category_ids.as_ref().map(|ids| {
663        ids.iter()
664            .filter_map(|&id| index.label_index(id).map(|idx| idx as u32))
665            .collect::<Vec<u32>>()
666    });
667    let not_exhaustive_label_indices = image.not_exhaustive_category_ids.as_ref().map(|ids| {
668        ids.iter()
669            .filter_map(|&id| index.label_index(id).map(|idx| idx as u32))
670            .collect::<Vec<u32>>()
671    });
672
673    let mut samples: Vec<Sample> = annotations
674        .iter()
675        .filter_map(|ann| {
676            let label = index.label_name(ann.category_id)?;
677            let label_index = index.label_index(ann.category_id);
678
679            // Convert bbox
680            let box2d = coco_bbox_to_box2d(&ann.bbox, image.width, image.height);
681
682            // Convert segmentation based on type:
683            // - Polygon → annotation.polygon (normalized coords)
684            // - RLE/CompressedRle → annotation.mask (PNG-encoded MaskData)
685            let (polygon, mask) = if include_masks {
686                if let Some(seg) = &ann.segmentation {
687                    match seg {
688                        CocoSegmentation::Polygon(_) => {
689                            let poly =
690                                coco_segmentation_to_polygon(seg, image.width, image.height).ok();
691                            (poly, None)
692                        }
693                        CocoSegmentation::Rle(_) | CocoSegmentation::CompressedRle(_) => {
694                            let mask_data = coco_segmentation_to_mask_data(seg).ok().flatten();
695                            (None, mask_data)
696                        }
697                    }
698                } else {
699                    (None, None)
700                }
701            } else {
702                (None, None)
703            };
704
705            let mut annotation = Annotation::new();
706            annotation.set_name(Some(sample_name.clone()));
707            annotation.set_object_id(Some(ann.id.to_string()));
708            annotation.set_label(Some(label.to_string()));
709            annotation.set_label_index(label_index);
710            annotation.set_box2d(Some(box2d));
711            annotation.set_polygon(polygon);
712            annotation.set_mask(mask);
713            annotation.set_group(group.map(String::from));
714            annotation.set_iscrowd(Some(ann.iscrowd != 0));
715            annotation.set_category_frequency(index.frequency(ann.category_id).map(String::from));
716
717            // Map COCO score to appropriate geometry score field
718            if let Some(score) = ann.score {
719                let score_f32 = score as f32;
720                if annotation.mask().is_some() {
721                    annotation.set_mask_score(Some(score_f32));
722                } else if annotation.polygon().is_some() {
723                    annotation.set_polygon_score(Some(score_f32));
724                } else {
725                    annotation.set_box2d_score(Some(score_f32));
726                }
727            }
728
729            let mut sample = Sample {
730                image_name: Some(sample_name.clone()),
731                width: Some(image.width),
732                height: Some(image.height),
733                group: group.map(String::from),
734                annotations: vec![annotation],
735                ..Default::default()
736            };
737            sample.neg_label_indices = neg_label_indices.clone();
738            sample.not_exhaustive_label_indices = not_exhaustive_label_indices.clone();
739
740            Some(sample)
741        })
742        .collect();
743
744    // Emit a placeholder row for any image with no annotations so the image is
745    // never dropped from the dataset. This preserves the image's group (dataset
746    // split) for every image, and carries any LVIS neg/exhaustive category data
747    // for images that have verified-negative labels but no positive annotations.
748    if samples.is_empty() {
749        let mut sample = Sample {
750            image_name: Some(sample_name.clone()),
751            width: Some(image.width),
752            height: Some(image.height),
753            group: group.map(String::from),
754            ..Default::default()
755        };
756        sample.neg_label_indices = neg_label_indices;
757        sample.not_exhaustive_label_indices = not_exhaustive_label_indices;
758        samples.push(sample);
759    }
760
761    samples
762}
763
764/// Extract sample name from image filename.
765fn sample_name_from_filename(filename: &str) -> String {
766    Path::new(filename)
767        .file_stem()
768        .and_then(|s| s.to_str())
769        .map(String::from)
770        .unwrap_or_else(|| filename.to_string())
771}
772
773/// Convert EdgeFirst Arrow format to COCO annotations.
774///
775/// Reads an Arrow file and produces COCO JSON output. LVIS extension fields
776/// are preserved when present in the Arrow file: `neg_category_ids`,
777/// `not_exhaustive_category_ids`, category `frequency`, annotation `iscrowd`,
778/// `supercategory`, and category metadata (`synset`, `synonyms`, `def`).
779///
780/// # Arguments
781/// * `arrow_path` - Path to EdgeFirst Arrow file
782/// * `output_path` - Output COCO JSON file path
783/// * `options` - Conversion options
784/// * `progress` - Optional progress channel
785///
786/// # Returns
787/// Number of annotations converted
788pub async fn arrow_to_coco<P: AsRef<Path>>(
789    arrow_path: P,
790    output_path: P,
791    options: &ArrowToCocoOptions,
792    progress: Option<Sender<Progress>>,
793) -> Result<usize, Error> {
794    let arrow_path = arrow_path.as_ref();
795    let output_path = output_path.as_ref();
796
797    // Read the DataFrame and its file-level metadata (schema_version,
798    // category_metadata, labels). Accepts .arrow/.ipc/.parquet.
799    let (df, metadata) = crate::format::read_dataset_dataframe(arrow_path)?;
800    let schema_version = metadata.get("schema_version").cloned();
801    let category_metadata_json = metadata.get("category_metadata").cloned();
802    let labels_metadata_json = metadata.get("labels").cloned();
803
804    // Determine format version: absent → 2025.10, present → use value
805    let is_legacy = schema_version.is_none();
806
807    // Get group column for filtering
808    let groups_to_filter: std::collections::HashSet<_> = options.groups.iter().cloned().collect();
809
810    let total_rows = df.height();
811
812    if let Some(ref p) = progress {
813        let _ = p
814            .send(Progress {
815                current: 0,
816                total: total_rows,
817                status: None,
818            })
819            .await;
820    }
821
822    // Extract columns - all at once for O(n) instead of O(n²) per-row access
823    let names: Vec<String> = df
824        .column("name")?
825        .str()?
826        .iter()
827        .map(|s| s.unwrap_or_default().to_string())
828        .collect();
829
830    let labels: Vec<String> = df
831        .column("label")
832        .ok()
833        .and_then(|c| c.cast(&DataType::String).ok())
834        .map(|c| {
835            c.str()
836                .ok()
837                .map(|s| {
838                    s.iter()
839                        .map(|v| v.unwrap_or_default().to_string())
840                        .collect()
841                })
842                .unwrap_or_else(|| vec![String::new(); total_rows])
843        })
844        .unwrap_or_else(|| vec![String::new(); total_rows]);
845
846    let label_indices: Vec<Option<u64>> = df
847        .column("label_index")
848        .ok()
849        .map(|c| {
850            c.u64()
851                .ok()
852                .map(|s| s.iter().collect())
853                .unwrap_or_else(|| vec![None; total_rows])
854        })
855        .unwrap_or_else(|| vec![None; total_rows]);
856
857    // Get group column for filtering
858    let groups: Vec<String> = df
859        .column("group")
860        .ok()
861        .and_then(|c| c.cast(&DataType::String).ok())
862        .map(|c| {
863            c.str()
864                .ok()
865                .map(|s| {
866                    s.iter()
867                        .map(|v| v.unwrap_or_default().to_string())
868                        .collect()
869                })
870                .unwrap_or_default()
871        })
872        .unwrap_or_else(|| vec!["".to_string(); total_rows]);
873
874    // Extract all box2d values upfront (O(n) instead of O(n²))
875    let box2ds = df
876        .column("box2d")
877        .ok()
878        .map(extract_all_box2ds)
879        .transpose()?
880        .unwrap_or_else(|| vec![[0.0; 4]; total_rows]);
881
882    // Extract segmentation data based on schema version
883    //
884    // 2025.10 (legacy): mask column is List(Float32) with NaN-separated polygon coords
885    // 2026.04+:         polygon column is List(List(Float32)), mask column is Binary (PNG)
886    let legacy_masks: Option<Vec<Vec<f32>>> = if is_legacy && options.include_masks {
887        df.column("mask").ok().map(extract_all_masks).transpose()?
888    } else {
889        None
890    };
891
892    let polygons_2026: Option<Vec<Option<PolygonRings>>> = if !is_legacy && options.include_masks {
893        df.column("polygon")
894            .ok()
895            .map(|c| extract_all_polygons(c, total_rows))
896    } else {
897        None
898    };
899
900    let mask_binary_2026: Option<Vec<Option<Vec<u8>>>> = if !is_legacy && options.include_masks {
901        df.column("mask")
902            .ok()
903            .map(|c| extract_all_binary_masks(c, total_rows))
904    } else {
905        None
906    };
907
908    // Extract all sizes upfront if present
909    let sizes = df
910        .column("size")
911        .ok()
912        .and_then(|c| extract_all_sizes(c).ok());
913
914    // Extract iscrowd column (optional, Boolean in 2026.04, UInt32 in older schemas)
915    let iscrowds: Vec<u8> = df
916        .column("iscrowd")
917        .ok()
918        .map(|c| {
919            // Try Boolean first (2026.04 schema), then fall back to UInt32 (older schemas)
920            if let Ok(bool_ca) = c.bool() {
921                bool_ca
922                    .iter()
923                    .map(|v| if v.unwrap_or(false) { 1 } else { 0 })
924                    .collect()
925            } else {
926                c.u32()
927                    .ok()
928                    .map(|s| s.iter().map(|v| v.unwrap_or(0) as u8).collect())
929                    .unwrap_or_else(|| vec![0; total_rows])
930            }
931        })
932        .unwrap_or_else(|| vec![0; total_rows]);
933
934    // Extract category_frequency column (optional, Categorical/String)
935    let category_frequencies: Vec<Option<String>> = df
936        .column("category_frequency")
937        .ok()
938        .and_then(|c| c.cast(&DataType::String).ok())
939        .map(|c| {
940            c.str()
941                .ok()
942                .map(|s| s.iter().map(|v| v.map(String::from)).collect())
943                .unwrap_or_else(|| vec![None; total_rows])
944        })
945        .unwrap_or_else(|| vec![None; total_rows]);
946
947    // Extract neg_label_indices column (optional, List<UInt32>)
948    let neg_label_indices: Vec<Option<Vec<u32>>> = df
949        .column("neg_label_indices")
950        .ok()
951        .map(|c| extract_list_u32_column(c, total_rows))
952        .unwrap_or_else(|| vec![None; total_rows]);
953
954    // Extract not_exhaustive_label_indices column (optional, List<UInt32>)
955    let not_exhaustive_label_indices: Vec<Option<Vec<u32>>> = df
956        .column("not_exhaustive_label_indices")
957        .ok()
958        .map(|c| extract_list_u32_column(c, total_rows))
959        .unwrap_or_else(|| vec![None; total_rows]);
960
961    // Extract score columns (2026.04 schema)
962    let box2d_scores: Vec<Option<f32>> = extract_f32_column(&df, "box2d_score", total_rows);
963    let box3d_scores: Vec<Option<f32>> = extract_f32_column(&df, "box3d_score", total_rows);
964    let polygon_scores: Vec<Option<f32>> = extract_f32_column(&df, "polygon_score", total_rows);
965    let mask_scores: Vec<Option<f32>> = extract_f32_column(&df, "mask_score", total_rows);
966
967    // Extract object_id column (optional, String) and parse to u64 where
968    // possible. This preserves the source COCO/LVIS annotation `id` across
969    // the Arrow→COCO round-trip so downstream tools (e.g., prompted-
970    // segmentation workflows that key on ann.id) see the original IDs.
971    //
972    // Non-numeric object_ids — produced by datasets whose instances carry
973    // string UUIDs rather than COCO numeric IDs — parse to None and fall
974    // through to auto-generated IDs in the builder. This is intentional:
975    // COCO requires numeric annotation IDs, and a UUID has no meaningful
976    // numeric projection.
977    let object_id_u64s: Vec<Option<u64>> = df
978        .column("object_id")
979        .ok()
980        .and_then(|c| c.cast(&DataType::String).ok())
981        .map(|c| {
982            c.str()
983                .ok()
984                .map(|s| {
985                    s.iter()
986                        .map(|v| v.and_then(|s| s.parse::<u64>().ok()))
987                        .collect()
988                })
989                .unwrap_or_else(|| vec![None; total_rows])
990        })
991        .unwrap_or_else(|| vec![None; total_rows]);
992
993    // Build COCO dataset
994    let mut builder = CocoDatasetBuilder::new();
995
996    if let Some(info) = &options.info {
997        builder = builder.info(info.clone());
998    }
999
1000    // Group-filter predicate: returns true if this row should be skipped
1001    let skip_row = |i: usize| -> bool {
1002        !groups_to_filter.is_empty() && !groups_to_filter.contains(&groups[i])
1003    };
1004
1005    // Track unique images and categories
1006    let mut image_dimensions: HashMap<String, (u32, u32)> = HashMap::new();
1007    let mut image_ids: HashMap<String, u64> = HashMap::new();
1008    let mut category_ids: HashMap<String, u32> = HashMap::new();
1009
1010    // First pass: collect unique images and categories
1011    for i in 0..total_rows {
1012        if skip_row(i) {
1013            continue;
1014        }
1015
1016        let name = &names[i];
1017        let label = &labels[i];
1018
1019        // Get or estimate image dimensions
1020        if !image_ids.contains_key(name) {
1021            let (width, height) = sizes
1022                .as_ref()
1023                .and_then(|s| s.get(i).copied())
1024                .unwrap_or((0, 0));
1025
1026            let id = builder.add_image(name, width, height);
1027            image_ids.insert(name.clone(), id);
1028            image_dimensions.insert(name.clone(), (width, height));
1029        }
1030
1031        if !label.is_empty() && !category_ids.contains_key(label) {
1032            let id = if let Some(Some(idx)) = label_indices.get(i) {
1033                builder.add_category_with_id(*idx as u32, label, None)
1034            } else {
1035                builder.add_category(label, None)
1036            };
1037            category_ids.insert(label.clone(), id);
1038        }
1039    }
1040
1041    // Second pass: create annotations
1042    let mut last_progress_update = 0;
1043    for i in 0..total_rows {
1044        if skip_row(i) {
1045            continue;
1046        }
1047
1048        let name = &names[i];
1049        let label = &labels[i];
1050
1051        // Skip sentinel rows (empty label = image with neg/exhaustive data but no annotations)
1052        if label.is_empty() {
1053            continue;
1054        }
1055
1056        let image_id = *image_ids.get(name).unwrap_or(&0);
1057        let category_id = *category_ids.get(label).unwrap_or(&0);
1058        let (width, height) = *image_dimensions.get(name).unwrap_or(&(1, 1));
1059
1060        // Convert box2d from Arrow center-normalized [cx, cy, w, h] to COCO format
1061        // Arrow stores center-point, Box2d expects top-left
1062        let bbox = box2ds.get(i).map(|box2d| {
1063            let cx = box2d[0];
1064            let cy = box2d[1];
1065            let w = box2d[2];
1066            let h = box2d[3];
1067            // Convert from center-point to top-left format
1068            let left = cx - w / 2.0;
1069            let top = cy - h / 2.0;
1070            let ef_box2d = Box2d::new(left, top, w, h);
1071            box2d_to_coco_bbox(&ef_box2d, width, height)
1072        });
1073
1074        // Build segmentation based on schema version
1075        let segmentation = if options.include_masks {
1076            if is_legacy {
1077                // 2025.10: mask column contains NaN-separated flat polygon coords
1078                legacy_masks.as_ref().and_then(|m| {
1079                    m.get(i).and_then(|coords| {
1080                        if coords.is_empty() {
1081                            None
1082                        } else {
1083                            let rings = crate::unflatten_polygon_coordinates(coords);
1084                            let polygon = Polygon::new(rings);
1085                            let coco_poly = polygon_to_coco_polygon(&polygon, width, height);
1086                            if coco_poly.is_empty() {
1087                                None
1088                            } else {
1089                                Some(CocoSegmentation::Polygon(coco_poly))
1090                            }
1091                        }
1092                    })
1093                })
1094            } else {
1095                // 2026.04+: try mask (Binary/PNG → RLE) first, then polygon column
1096                let mask_seg = mask_binary_2026.as_ref().and_then(|masks| {
1097                    masks.get(i).and_then(|opt_bytes| {
1098                        opt_bytes
1099                            .as_ref()
1100                            .and_then(|png_bytes| png_to_rle_segmentation(png_bytes, i))
1101                    })
1102                });
1103
1104                if mask_seg.is_some() {
1105                    mask_seg
1106                } else {
1107                    // Fall back to polygon column
1108                    polygons_2026.as_ref().and_then(|polys| {
1109                        polys.get(i).and_then(|opt_rings| {
1110                            opt_rings.as_ref().and_then(|rings| {
1111                                if rings.is_empty() {
1112                                    return None;
1113                                }
1114                                let polygon = Polygon::new(rings.clone());
1115                                let coco_poly = polygon_to_coco_polygon(&polygon, width, height);
1116                                if coco_poly.is_empty() {
1117                                    None
1118                                } else {
1119                                    Some(CocoSegmentation::Polygon(coco_poly))
1120                                }
1121                            })
1122                        })
1123                    })
1124                }
1125            }
1126        } else {
1127            None
1128        };
1129
1130        // Determine the score: use first non-null from available score columns
1131        let score: Option<f64> = mask_scores[i]
1132            .or(polygon_scores[i])
1133            .or(box3d_scores[i])
1134            .or(box2d_scores[i])
1135            .map(|s| s as f64);
1136
1137        if let Some(bbox) = bbox {
1138            let iscrowd = iscrowds[i];
1139            let ann_id = builder.add_annotation_with_id(
1140                object_id_u64s[i],
1141                image_id,
1142                category_id,
1143                bbox,
1144                segmentation,
1145                iscrowd,
1146            );
1147
1148            // Set score on the annotation if present
1149            if let Some(score_val) = score {
1150                builder.set_annotation_score(ann_id, score_val);
1151            }
1152        }
1153
1154        // Update progress every 1000 rows to reduce overhead
1155        if let Some(ref p) = progress
1156            && (i - last_progress_update >= 1000 || i == total_rows - 1)
1157        {
1158            let _ = p
1159                .send(Progress {
1160                    current: i + 1,
1161                    total: total_rows,
1162                    status: None,
1163                })
1164                .await;
1165            last_progress_update = i;
1166        }
1167    }
1168
1169    // Send final progress event (may not have fired if last rows were filtered)
1170    if let Some(ref p) = progress
1171        && last_progress_update < total_rows.saturating_sub(1)
1172    {
1173        let _ = p
1174            .send(Progress {
1175                current: total_rows,
1176                total: total_rows,
1177                status: None,
1178            })
1179            .await;
1180    }
1181
1182    // Third pass: set LVIS image-level fields (neg/not-exhaustive category IDs)
1183    // Since label_index == category_id, we can use the values directly.
1184    {
1185        let mut processed_images: std::collections::HashSet<u64> = std::collections::HashSet::new();
1186        for i in 0..total_rows {
1187            if skip_row(i) {
1188                continue;
1189            }
1190            let name = &names[i];
1191            if let Some(&image_id) = image_ids.get(name) {
1192                if !processed_images.insert(image_id) {
1193                    continue;
1194                }
1195                let neg = neg_label_indices[i].clone();
1196                let not_exhaustive = not_exhaustive_label_indices[i].clone();
1197                if neg.is_some() || not_exhaustive.is_some() {
1198                    builder.set_image_neg_categories(image_id, neg, not_exhaustive);
1199                }
1200            }
1201        }
1202    }
1203
1204    // Set category frequency from the category_frequency column.
1205    // Build a map of category_name -> frequency from the first occurrence.
1206    {
1207        let mut freq_map: HashMap<String, String> = HashMap::new();
1208        for i in 0..total_rows {
1209            if skip_row(i) {
1210                continue;
1211            }
1212            let label = &labels[i];
1213            if !label.is_empty()
1214                && !freq_map.contains_key(label)
1215                && let Some(ref freq) = category_frequencies[i]
1216            {
1217                freq_map.insert(label.clone(), freq.clone());
1218            }
1219        }
1220        for (name, freq) in &freq_map {
1221            builder.set_category_metadata(name, None, Some(freq.clone()), None, None);
1222        }
1223    }
1224
1225    // Set category metadata from file-level metadata JSON
1226    // (id, frequency, synset, synonyms, def, supercategory).
1227    // Also creates categories that exist in metadata but have no annotations
1228    // (e.g., categories only referenced in neg_category_ids).
1229    // set_category_metadata only updates fields that are Some, so frequency
1230    // set from the column above is preserved for categories that had annotations.
1231    if let Some(ref json_str) = category_metadata_json
1232        && let Ok(meta) = serde_json::from_str::<HashMap<String, serde_json::Value>>(json_str)
1233    {
1234        for (cat_name, value) in &meta {
1235            let supercategory = value.get("supercategory").and_then(|v| v.as_str());
1236
1237            // If this category doesn't exist yet, create it with the stored id
1238            if !category_ids.contains_key(cat_name.as_str()) {
1239                let cat_id = value.get("id").and_then(|v| v.as_u64()).map(|id| id as u32);
1240                let id = if let Some(cat_id) = cat_id {
1241                    builder.add_category_with_id(cat_id, cat_name, supercategory)
1242                } else {
1243                    builder.add_category(cat_name, supercategory)
1244                };
1245                category_ids.insert(cat_name.clone(), id);
1246            } else {
1247                // Category already exists — set supercategory if present in metadata
1248                if let Some(sc) = supercategory {
1249                    builder.set_category_supercategory(cat_name, sc);
1250                }
1251            }
1252
1253            let synset = value
1254                .get("synset")
1255                .and_then(|v| v.as_str())
1256                .map(String::from);
1257            let frequency = value
1258                .get("frequency")
1259                .and_then(|v| v.as_str())
1260                .map(String::from);
1261            let synonyms = value.get("synonyms").and_then(|v| {
1262                v.as_array().map(|arr| {
1263                    arr.iter()
1264                        .filter_map(|s| s.as_str().map(String::from))
1265                        .collect()
1266                })
1267            });
1268            let def = value
1269                .get("definition")
1270                .and_then(|v| v.as_str())
1271                .map(String::from);
1272
1273            builder.set_category_metadata(cat_name, synset, frequency, synonyms, def);
1274        }
1275    }
1276
1277    // Populate category names from labels metadata if categories weren't set
1278    // from category_metadata (e.g., older files that only have labels list).
1279    if category_metadata_json.is_none()
1280        && let Some(ref labels_json) = labels_metadata_json
1281        && let Ok(label_names) = serde_json::from_str::<Vec<String>>(labels_json)
1282    {
1283        for label_name in &label_names {
1284            if !category_ids.contains_key(label_name) {
1285                let id = builder.add_category(label_name, None);
1286                category_ids.insert(label_name.clone(), id);
1287            }
1288        }
1289    }
1290
1291    let dataset = builder.build();
1292    let annotation_count = dataset.annotations.len();
1293
1294    // Write output
1295    let writer = CocoWriter::with_options(CocoWriteOptions {
1296        pretty: options.pretty,
1297        ..Default::default()
1298    });
1299    writer.write_json(&dataset, output_path)?;
1300
1301    Ok(annotation_count)
1302}
1303
1304/// Extract all box2d values from a column at once (O(n) instead of O(n²)).
1305fn extract_all_box2ds(col: &Column) -> Result<Vec<[f32; 4]>, Error> {
1306    let arr = col.array()?;
1307    let mut result = Vec::with_capacity(arr.len());
1308
1309    for inner in arr.amortized_iter() {
1310        let values = if let Some(inner) = inner {
1311            let series = inner.as_ref();
1312            let vals: Vec<f32> = series
1313                .f32()
1314                .map_err(|e| Error::CocoError(format!("box2d cast error: {}", e)))?
1315                .iter()
1316                .map(|v| v.unwrap_or(0.0))
1317                .collect();
1318
1319            if vals.len() == 4 {
1320                [vals[0], vals[1], vals[2], vals[3]]
1321            } else {
1322                [0.0, 0.0, 0.0, 0.0]
1323            }
1324        } else {
1325            [0.0, 0.0, 0.0, 0.0]
1326        };
1327        result.push(values);
1328    }
1329
1330    Ok(result)
1331}
1332
1333/// Extract all mask coordinates from a column at once (O(n) instead of O(n²)).
1334fn extract_all_masks(col: &Column) -> Result<Vec<Vec<f32>>, Error> {
1335    let list = col.list()?;
1336    let mut result = Vec::with_capacity(list.len());
1337
1338    for i in 0..list.len() {
1339        let coords = match list.get_as_series(i) {
1340            Some(series) => series
1341                .f32()
1342                .map_err(|e| Error::CocoError(format!("mask cast error: {}", e)))?
1343                .iter()
1344                .map(|v| v.unwrap_or(f32::NAN))
1345                .collect(),
1346            None => vec![],
1347        };
1348        result.push(coords);
1349    }
1350
1351    Ok(result)
1352}
1353
1354/// Extract all image sizes from a column at once.
1355fn extract_all_sizes(col: &Column) -> Result<Vec<(u32, u32)>, Error> {
1356    let arr = col.array()?;
1357    let mut result = Vec::with_capacity(arr.len());
1358
1359    for inner in arr.amortized_iter() {
1360        let size = if let Some(inner) = inner {
1361            let series = inner.as_ref();
1362            let values: Vec<u32> = series
1363                .u32()
1364                .map_err(|e| Error::CocoError(format!("size cast error: {}", e)))?
1365                .iter()
1366                .map(|v| v.unwrap_or(0))
1367                .collect();
1368
1369            if values.len() >= 2 {
1370                (values[0], values[1])
1371            } else {
1372                (0, 0)
1373            }
1374        } else {
1375            (0, 0)
1376        };
1377        result.push(size);
1378    }
1379
1380    Ok(result)
1381}
1382
1383/// Extract a List<UInt32> column into a vector of optional Vec<u32>.
1384fn extract_list_u32_column(col: &Column, total_rows: usize) -> Vec<Option<Vec<u32>>> {
1385    col.list()
1386        .ok()
1387        .map(|list| {
1388            (0..list.len())
1389                .map(|i| {
1390                    list.get_as_series(i).and_then(|series| {
1391                        series
1392                            .u32()
1393                            .ok()
1394                            .map(|ca| ca.iter().flatten().collect::<Vec<u32>>())
1395                    })
1396                })
1397                .collect()
1398        })
1399        .unwrap_or_else(|| vec![None; total_rows])
1400}
1401
1402/// Extract polygon rings from a `List(List(Float32))` column (2026.04 schema).
1403///
1404/// Each row is an optional list of rings; each ring is a list of flat `[x, y, x, y, ...]`
1405/// coordinate pairs.
1406fn extract_all_polygons(col: &Column, total_rows: usize) -> Vec<Option<PolygonRings>> {
1407    let outer_list = match col.list() {
1408        Ok(l) => l,
1409        Err(_) => return vec![None; total_rows],
1410    };
1411
1412    let mut result = Vec::with_capacity(total_rows);
1413    for i in 0..outer_list.len() {
1414        let rings = outer_list.get_as_series(i).and_then(|ring_series| {
1415            let inner_list = ring_series.list().ok()?;
1416            let mut rings = Vec::new();
1417            for j in 0..inner_list.len() {
1418                if let Some(coords_series) = inner_list.get_as_series(j)
1419                    && let Ok(f32_ca) = coords_series.f32()
1420                {
1421                    let coords: Vec<f32> = f32_ca.iter().map(|v| v.unwrap_or(0.0)).collect();
1422                    // Convert flat [x, y, x, y, ...] to Vec<(f32, f32)>
1423                    let points: Vec<(f32, f32)> = coords
1424                        .chunks(2)
1425                        .filter(|c| c.len() == 2)
1426                        .map(|c| (c[0], c[1]))
1427                        .collect();
1428                    if !points.is_empty() {
1429                        rings.push(points);
1430                    }
1431                }
1432            }
1433            if rings.is_empty() { None } else { Some(rings) }
1434        });
1435        result.push(rings);
1436    }
1437    result
1438}
1439
1440/// Extract binary mask data from a `Binary` column (2026.04 schema — PNG bytes).
1441fn extract_all_binary_masks(col: &Column, total_rows: usize) -> Vec<Option<Vec<u8>>> {
1442    let binary_ca = match col.binary() {
1443        Ok(b) => b,
1444        Err(_) => return vec![None; total_rows],
1445    };
1446
1447    (0..binary_ca.len())
1448        .map(|i| binary_ca.get(i).map(|bytes| bytes.to_vec()))
1449        .collect()
1450}
1451
1452/// Extract an optional Float32 column by name.
1453fn extract_f32_column(df: &DataFrame, name: &str, total_rows: usize) -> Vec<Option<f32>> {
1454    df.column(name)
1455        .ok()
1456        .and_then(|c| c.f32().ok())
1457        .map(|ca| ca.iter().collect())
1458        .unwrap_or_else(|| vec![None; total_rows])
1459}
1460
1461/// Decode a PNG mask (Binary column bytes) into COCO RLE segmentation.
1462///
1463/// Validates the PNG, decodes pixels, binarizes if needed (8-bit or 16-bit),
1464/// and encodes as COCO RLE. Returns `None` for empty or invalid data (with
1465/// a warning log for invalid cases).
1466fn png_to_rle_segmentation(png_bytes: &[u8], row_index: usize) -> Option<CocoSegmentation> {
1467    if png_bytes.is_empty() {
1468        return None;
1469    }
1470
1471    let mask_data = match crate::MaskData::from_png_checked(png_bytes.to_vec()) {
1472        Ok(m) => m,
1473        Err(e) => {
1474            log::warn!("Skipping invalid PNG mask at row {}: {}", row_index, e);
1475            return None;
1476        }
1477    };
1478
1479    let mw = mask_data.width();
1480    let mh = mask_data.height();
1481    let bit_depth = mask_data.bit_depth();
1482
1483    let decoded = match mask_data.decode() {
1484        Ok(d) => d,
1485        Err(e) => {
1486            log::warn!("Failed to decode PNG mask at row {}: {}", row_index, e);
1487            return None;
1488        }
1489    };
1490
1491    let binary_mask = match bit_depth {
1492        1 => decoded,
1493        8 => {
1494            log::warn!(
1495                "Binarizing 8-bit mask for row {} — score data is lost",
1496                row_index
1497            );
1498            decoded
1499                .iter()
1500                .map(|&v| if v >= 128 { 1 } else { 0 })
1501                .collect()
1502        }
1503        16 => {
1504            log::warn!(
1505                "Binarizing 16-bit mask for row {} — score data is lost",
1506                row_index
1507            );
1508            decoded
1509                .chunks(2)
1510                .map(|pair| {
1511                    let val = if pair.len() == 2 {
1512                        u16::from_be_bytes([pair[0], pair[1]])
1513                    } else {
1514                        0
1515                    };
1516                    if val >= 32768 { 1u8 } else { 0u8 }
1517                })
1518                .collect()
1519        }
1520        _ => decoded,
1521    };
1522
1523    match super::convert::encode_rle(&binary_mask, mw, mh) {
1524        Ok(rle) => Some(CocoSegmentation::Rle(rle)),
1525        Err(e) => {
1526            log::warn!("Failed to encode RLE for row {}: {}", row_index, e);
1527            None
1528        }
1529    }
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534    use super::*;
1535    use crate::coco::{CocoAnnotation, CocoCategory, CocoDataset};
1536    use tempfile::TempDir;
1537
1538    // =========================================================================
1539    // unflatten_polygon_coords tests
1540    // =========================================================================
1541
1542    #[test]
1543    fn test_unflatten_polygon_coords_empty() {
1544        let coords: Vec<f32> = vec![];
1545        let result = crate::unflatten_polygon_coordinates(&coords);
1546        assert!(result.is_empty());
1547    }
1548
1549    #[test]
1550    fn test_unflatten_polygon_coords_single_polygon() {
1551        // Simple rectangle: 4 points
1552        let coords = vec![0.1, 0.2, 0.3, 0.2, 0.3, 0.4, 0.1, 0.4];
1553        let result = crate::unflatten_polygon_coordinates(&coords);
1554
1555        assert_eq!(result.len(), 1);
1556        assert_eq!(result[0].len(), 4);
1557        assert_eq!(result[0][0], (0.1, 0.2));
1558        assert_eq!(result[0][3], (0.1, 0.4));
1559    }
1560
1561    #[test]
1562    fn test_unflatten_polygon_coords_multiple_polygons() {
1563        // Two triangles separated by NaN
1564        let coords = vec![
1565            0.1,
1566            0.1,
1567            0.2,
1568            0.1,
1569            0.15,
1570            0.2,      // First triangle
1571            f32::NAN, // Separator
1572            0.5,
1573            0.5,
1574            0.6,
1575            0.5,
1576            0.55,
1577            0.6, // Second triangle
1578        ];
1579        let result = crate::unflatten_polygon_coordinates(&coords);
1580
1581        assert_eq!(result.len(), 2);
1582        assert_eq!(result[0].len(), 3);
1583        assert_eq!(result[1].len(), 3);
1584        assert_eq!(result[0][0], (0.1, 0.1));
1585        assert_eq!(result[1][0], (0.5, 0.5));
1586    }
1587
1588    #[test]
1589    fn test_unflatten_polygon_coords_leading_nan() {
1590        // NaN at the start should be handled gracefully
1591        let coords = vec![f32::NAN, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6];
1592        let result = crate::unflatten_polygon_coordinates(&coords);
1593
1594        assert_eq!(result.len(), 1);
1595        assert_eq!(result[0].len(), 3);
1596    }
1597
1598    #[test]
1599    fn test_unflatten_polygon_coords_trailing_nan() {
1600        // NaN at the end
1601        let coords = vec![0.1, 0.2, 0.3, 0.4, f32::NAN];
1602        let result = crate::unflatten_polygon_coordinates(&coords);
1603
1604        assert_eq!(result.len(), 1);
1605        assert_eq!(result[0].len(), 2);
1606    }
1607
1608    #[test]
1609    fn test_unflatten_polygon_coords_consecutive_nans() {
1610        // Multiple NaNs in a row
1611        let coords = vec![0.1, 0.2, f32::NAN, f32::NAN, 0.3, 0.4];
1612        let result = crate::unflatten_polygon_coordinates(&coords);
1613
1614        assert_eq!(result.len(), 2);
1615        assert_eq!(result[0].len(), 1);
1616        assert_eq!(result[1].len(), 1);
1617    }
1618
1619    #[test]
1620    fn test_unflatten_polygon_coords_odd_values() {
1621        // Odd number of coordinates (trailing x without y)
1622        let coords = vec![0.1, 0.2, 0.3, 0.4, 0.5];
1623        let result = crate::unflatten_polygon_coordinates(&coords);
1624
1625        assert_eq!(result.len(), 1);
1626        assert_eq!(result[0].len(), 2); // Only complete pairs
1627    }
1628
1629    // =========================================================================
1630    // convert_image_annotations tests
1631    // =========================================================================
1632
1633    #[test]
1634    fn test_convert_image_annotations_basic() {
1635        let image = CocoImage {
1636            id: 1,
1637            width: 640,
1638            height: 480,
1639            file_name: "test_image.jpg".to_string(),
1640            ..Default::default()
1641        };
1642
1643        let dataset = CocoDataset {
1644            images: vec![image.clone()],
1645            categories: vec![CocoCategory {
1646                id: 1,
1647                name: "cat".to_string(),
1648                supercategory: Some("animal".to_string()),
1649                ..Default::default()
1650            }],
1651            annotations: vec![CocoAnnotation {
1652                id: 42,
1653                image_id: 1,
1654                category_id: 1,
1655                bbox: [100.0, 100.0, 200.0, 200.0],
1656                area: 40000.0,
1657                iscrowd: 0,
1658                segmentation: None,
1659                score: None,
1660            }],
1661            ..Default::default()
1662        };
1663
1664        let index = CocoIndex::from_dataset(&dataset);
1665        let samples = convert_image_annotations(&image, &index, true, Some("train"));
1666
1667        assert_eq!(samples.len(), 1);
1668        assert_eq!(samples[0].image_name, Some("test_image".to_string()));
1669        assert_eq!(samples[0].group, Some("train".to_string()));
1670        assert_eq!(samples[0].annotations.len(), 1);
1671        assert_eq!(samples[0].annotations[0].label(), Some(&"cat".to_string()));
1672        assert_eq!(
1673            samples[0].annotations[0].object_id(),
1674            Some(&"42".to_string()),
1675            "object_id must be populated from COCO annotation id to enable \
1676             prediction-to-prompt linking in prompted-segmentation workflows",
1677        );
1678    }
1679
1680    #[test]
1681    fn test_convert_image_annotations_with_mask() {
1682        let image = CocoImage {
1683            id: 1,
1684            width: 100,
1685            height: 100,
1686            file_name: "masked.jpg".to_string(),
1687            ..Default::default()
1688        };
1689
1690        let dataset = CocoDataset {
1691            images: vec![image.clone()],
1692            categories: vec![CocoCategory {
1693                id: 1,
1694                name: "object".to_string(),
1695                supercategory: None,
1696                ..Default::default()
1697            }],
1698            annotations: vec![CocoAnnotation {
1699                id: 1,
1700                image_id: 1,
1701                category_id: 1,
1702                bbox: [10.0, 10.0, 50.0, 50.0],
1703                area: 2500.0,
1704                iscrowd: 0,
1705                segmentation: Some(CocoSegmentation::Polygon(vec![vec![
1706                    10.0, 10.0, 60.0, 10.0, 60.0, 60.0, 10.0, 60.0,
1707                ]])),
1708                score: None,
1709            }],
1710            ..Default::default()
1711        };
1712
1713        let index = CocoIndex::from_dataset(&dataset);
1714
1715        // With masks enabled
1716        let samples_with_mask = convert_image_annotations(&image, &index, true, None);
1717        assert!(samples_with_mask[0].annotations[0].polygon().is_some());
1718
1719        // With masks disabled
1720        let samples_no_mask = convert_image_annotations(&image, &index, false, None);
1721        assert!(samples_no_mask[0].annotations[0].polygon().is_none());
1722    }
1723
1724    #[test]
1725    fn test_convert_image_annotations_object_id_from_lvis_large_id() {
1726        // LVIS v1.0 annotation IDs are u64 and routinely exceed 32-bit range
1727        // (the public release goes well past 2 billion). This test guards
1728        // against any future change that silently truncates on the path from
1729        // CocoAnnotation.id (u64) to Annotation.object_id (String).
1730        let image = CocoImage {
1731            id: 397133,
1732            width: 640,
1733            height: 480,
1734            file_name: "000000397133.jpg".to_string(),
1735            ..Default::default()
1736        };
1737
1738        let large_id: u64 = 9_876_543_210;
1739        let dataset = CocoDataset {
1740            images: vec![image.clone()],
1741            categories: vec![CocoCategory {
1742                id: 16,
1743                name: "dog".to_string(),
1744                synset: Some("dog.n.01".to_string()),
1745                frequency: Some("f".to_string()),
1746                ..Default::default()
1747            }],
1748            annotations: vec![CocoAnnotation {
1749                id: large_id,
1750                image_id: 397133,
1751                category_id: 16,
1752                bbox: [192.81, 224.8, 74.73, 33.43],
1753                area: 1035.7,
1754                iscrowd: 0,
1755                segmentation: None,
1756                score: None,
1757            }],
1758            ..Default::default()
1759        };
1760
1761        let index = CocoIndex::from_dataset(&dataset);
1762        let samples = convert_image_annotations(&image, &index, true, None);
1763
1764        assert_eq!(samples.len(), 1);
1765        assert_eq!(samples[0].annotations.len(), 1);
1766        assert_eq!(
1767            samples[0].annotations[0].object_id(),
1768            Some(&large_id.to_string()),
1769        );
1770    }
1771
1772    #[test]
1773    fn test_convert_image_annotations_no_annotations() {
1774        let image = CocoImage {
1775            id: 1,
1776            width: 640,
1777            height: 480,
1778            file_name: "empty.jpg".to_string(),
1779            ..Default::default()
1780        };
1781
1782        let dataset = CocoDataset {
1783            images: vec![image.clone()],
1784            categories: vec![],
1785            annotations: vec![],
1786            ..Default::default()
1787        };
1788
1789        let index = CocoIndex::from_dataset(&dataset);
1790        let samples = convert_image_annotations(&image, &index, true, None);
1791
1792        // An image with no annotations must still emit one placeholder row so
1793        // the image is never dropped from the dataset.
1794        assert_eq!(samples.len(), 1);
1795        assert_eq!(samples[0].image_name, Some("empty".to_string()));
1796        assert!(samples[0].annotations.is_empty());
1797        assert_eq!(samples[0].group, None);
1798    }
1799
1800    // =========================================================================
1801    // sample_name_from_filename tests
1802    // =========================================================================
1803
1804    #[test]
1805    fn test_sample_name_from_filename() {
1806        assert_eq!(
1807            sample_name_from_filename("000000397133.jpg"),
1808            "000000397133"
1809        );
1810        assert_eq!(sample_name_from_filename("train2017/image.jpg"), "image");
1811        assert_eq!(sample_name_from_filename("test"), "test");
1812    }
1813
1814    #[test]
1815    fn test_sample_name_from_filename_nested_path() {
1816        assert_eq!(
1817            sample_name_from_filename("a/b/c/deep_image.png"),
1818            "deep_image"
1819        );
1820    }
1821
1822    #[test]
1823    fn test_sample_name_from_filename_no_extension() {
1824        assert_eq!(sample_name_from_filename("no_extension"), "no_extension");
1825    }
1826
1827    // =========================================================================
1828    // Options tests
1829    // =========================================================================
1830
1831    #[test]
1832    fn test_coco_to_arrow_options_default() {
1833        let options = CocoToArrowOptions::default();
1834        assert!(options.include_masks);
1835        assert!(options.group.is_none());
1836        assert!(options.max_workers >= 2);
1837    }
1838
1839    #[test]
1840    fn test_arrow_to_coco_options_default() {
1841        let options = ArrowToCocoOptions::default();
1842        assert!(options.groups.is_empty());
1843        assert!(options.include_masks);
1844        assert!(options.info.is_none());
1845        assert!(!options.pretty);
1846    }
1847
1848    #[test]
1849    fn test_max_workers() {
1850        let workers = max_workers();
1851        assert!(workers >= 2);
1852        assert!(workers <= 8);
1853    }
1854
1855    #[tokio::test]
1856    async fn test_coco_to_arrow_minimal() {
1857        let temp_dir = TempDir::new().unwrap();
1858
1859        // Create minimal COCO JSON
1860        let coco_json = r#"{
1861            "images": [
1862                {"id": 1, "width": 640, "height": 480, "file_name": "test.jpg"}
1863            ],
1864            "annotations": [
1865                {"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 20, 100, 80], "area": 8000, "iscrowd": 0}
1866            ],
1867            "categories": [
1868                {"id": 1, "name": "person", "supercategory": "human"}
1869            ]
1870        }"#;
1871
1872        let coco_path = temp_dir.path().join("test.json");
1873        std::fs::write(&coco_path, coco_json).unwrap();
1874
1875        let arrow_path = temp_dir.path().join("output.arrow");
1876
1877        let options = CocoToArrowOptions::default();
1878        let count = coco_to_arrow(&coco_path, &arrow_path, &options, None)
1879            .await
1880            .unwrap();
1881
1882        assert_eq!(count, 1);
1883        assert!(arrow_path.exists());
1884
1885        // Verify Arrow contents
1886        let mut file = std::fs::File::open(&arrow_path).unwrap();
1887        let df = IpcReader::new(&mut file).finish().unwrap();
1888        assert_eq!(df.height(), 1);
1889    }
1890
1891    #[tokio::test]
1892    async fn test_arrow_to_coco_roundtrip() {
1893        let temp_dir = TempDir::new().unwrap();
1894
1895        // Create COCO JSON
1896        let original = CocoDataset {
1897            images: vec![CocoImage {
1898                id: 1,
1899                width: 640,
1900                height: 480,
1901                file_name: "test.jpg".to_string(),
1902                ..Default::default()
1903            }],
1904            annotations: vec![CocoAnnotation {
1905                id: 1,
1906                image_id: 1,
1907                category_id: 1,
1908                bbox: [100.0, 50.0, 200.0, 150.0],
1909                area: 30000.0,
1910                iscrowd: 0,
1911                segmentation: Some(CocoSegmentation::Polygon(vec![vec![
1912                    100.0, 50.0, 300.0, 50.0, 300.0, 200.0, 100.0, 200.0,
1913                ]])),
1914                score: None,
1915            }],
1916            categories: vec![CocoCategory {
1917                id: 1,
1918                name: "person".to_string(),
1919                supercategory: Some("human".to_string()),
1920                ..Default::default()
1921            }],
1922            ..Default::default()
1923        };
1924
1925        // Write original COCO
1926        let coco_path = temp_dir.path().join("original.json");
1927        let writer = CocoWriter::new();
1928        writer.write_json(&original, &coco_path).unwrap();
1929
1930        // Convert to Arrow
1931        let arrow_path = temp_dir.path().join("converted.arrow");
1932        let options = CocoToArrowOptions::default();
1933        coco_to_arrow(&coco_path, &arrow_path, &options, None)
1934            .await
1935            .unwrap();
1936
1937        // Convert back to COCO
1938        let restored_path = temp_dir.path().join("restored.json");
1939        let options = ArrowToCocoOptions {
1940            pretty: true,
1941            ..Default::default()
1942        };
1943        arrow_to_coco(&arrow_path, &restored_path, &options, None)
1944            .await
1945            .unwrap();
1946
1947        // Verify restored data
1948        let contents = std::fs::read_to_string(&restored_path).unwrap();
1949        assert!(
1950            contents.lines().count() > 1,
1951            "pretty output should span multiple lines"
1952        );
1953        let reader = CocoReader::new();
1954        let restored = reader.read_json(&restored_path).unwrap();
1955
1956        assert_eq!(restored.images.len(), 1);
1957        assert_eq!(restored.annotations.len(), 1);
1958        assert_eq!(restored.categories.len(), 1);
1959
1960        // Check category name preserved
1961        assert_eq!(restored.categories[0].name, "person");
1962    }
1963
1964    #[tokio::test]
1965    async fn test_arrow_to_coco_roundtrip_preserves_annotation_id() {
1966        // Asserts the COCO/LVIS annotation `id` survives the full
1967        // JSON → Arrow → JSON round-trip. The IDs deliberately mix a
1968        // small value (1) with a 33-bit value (9_876_543_210) to catch
1969        // any future regression that silently truncates to u32 along
1970        // the path.
1971        let temp_dir = TempDir::new().unwrap();
1972
1973        let large_id: u64 = 9_876_543_210;
1974        let original = CocoDataset {
1975            images: vec![CocoImage {
1976                id: 1,
1977                width: 640,
1978                height: 480,
1979                file_name: "test.jpg".to_string(),
1980                ..Default::default()
1981            }],
1982            annotations: vec![
1983                CocoAnnotation {
1984                    id: 1,
1985                    image_id: 1,
1986                    category_id: 1,
1987                    bbox: [10.0, 20.0, 100.0, 80.0],
1988                    area: 8000.0,
1989                    iscrowd: 0,
1990                    segmentation: None,
1991                    score: None,
1992                },
1993                CocoAnnotation {
1994                    id: large_id,
1995                    image_id: 1,
1996                    category_id: 1,
1997                    bbox: [200.0, 200.0, 100.0, 100.0],
1998                    area: 10000.0,
1999                    iscrowd: 0,
2000                    segmentation: None,
2001                    score: None,
2002                },
2003            ],
2004            categories: vec![CocoCategory {
2005                id: 1,
2006                name: "person".to_string(),
2007                supercategory: Some("human".to_string()),
2008                ..Default::default()
2009            }],
2010            ..Default::default()
2011        };
2012
2013        let coco_path = temp_dir.path().join("original.json");
2014        let writer = CocoWriter::new();
2015        writer.write_json(&original, &coco_path).unwrap();
2016
2017        let arrow_path = temp_dir.path().join("converted.arrow");
2018        coco_to_arrow(
2019            &coco_path,
2020            &arrow_path,
2021            &CocoToArrowOptions::default(),
2022            None,
2023        )
2024        .await
2025        .unwrap();
2026
2027        let restored_path = temp_dir.path().join("restored.json");
2028        arrow_to_coco(
2029            &arrow_path,
2030            &restored_path,
2031            &ArrowToCocoOptions::default(),
2032            None,
2033        )
2034        .await
2035        .unwrap();
2036
2037        let restored = CocoReader::new().read_json(&restored_path).unwrap();
2038        assert_eq!(restored.annotations.len(), 2);
2039
2040        let restored_ids: std::collections::HashSet<u64> =
2041            restored.annotations.iter().map(|a| a.id).collect();
2042        assert!(
2043            restored_ids.contains(&1),
2044            "small annotation id (1) must round-trip; got {restored_ids:?}"
2045        );
2046        assert!(
2047            restored_ids.contains(&large_id),
2048            "33-bit LVIS-scale annotation id ({large_id}) must round-trip; got {restored_ids:?}"
2049        );
2050    }
2051
2052    // =========================================================================
2053    // Arrow IPC file metadata tests
2054    // =========================================================================
2055
2056    #[tokio::test]
2057    async fn test_coco_to_arrow_schema_version_metadata() {
2058        let temp_dir = TempDir::new().unwrap();
2059
2060        // Create minimal COCO JSON (no LVIS fields)
2061        let coco_json = r#"{
2062            "images": [
2063                {"id": 1, "width": 640, "height": 480, "file_name": "test.jpg"}
2064            ],
2065            "annotations": [
2066                {"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 20, 100, 80], "area": 8000, "iscrowd": 0}
2067            ],
2068            "categories": [
2069                {"id": 1, "name": "person", "supercategory": "human"}
2070            ]
2071        }"#;
2072
2073        let coco_path = temp_dir.path().join("test.json");
2074        std::fs::write(&coco_path, coco_json).unwrap();
2075
2076        let arrow_path = temp_dir.path().join("output.arrow");
2077        let options = CocoToArrowOptions::default();
2078        coco_to_arrow(&coco_path, &arrow_path, &options, None)
2079            .await
2080            .unwrap();
2081
2082        // Read back and verify schema_version metadata
2083        let mut file = std::fs::File::open(&arrow_path).unwrap();
2084        let mut reader = IpcReader::new(&mut file);
2085        let custom_meta = reader.custom_metadata().unwrap();
2086        assert!(custom_meta.is_some(), "custom metadata should be present");
2087
2088        let meta = custom_meta.unwrap();
2089        assert_eq!(
2090            meta.get(&PlSmallStr::from("schema_version")),
2091            Some(&PlSmallStr::from(SCHEMA_VERSION)),
2092            "schema_version metadata should be '2026.04'"
2093        );
2094
2095        // category_metadata is always present when there are categories
2096        assert!(
2097            meta.contains_key(&PlSmallStr::from("category_metadata")),
2098            "category_metadata should be present even without LVIS fields"
2099        );
2100    }
2101
2102    #[tokio::test]
2103    async fn test_coco_to_arrow_category_metadata_lvis() {
2104        let temp_dir = TempDir::new().unwrap();
2105
2106        // Create COCO JSON with LVIS category fields
2107        let coco_json = r#"{
2108            "images": [
2109                {"id": 1, "width": 640, "height": 480, "file_name": "test.jpg"}
2110            ],
2111            "annotations": [
2112                {"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 20, 100, 80], "area": 8000, "iscrowd": 0},
2113                {"id": 2, "image_id": 1, "category_id": 2, "bbox": [50, 60, 80, 40], "area": 3200, "iscrowd": 0}
2114            ],
2115            "categories": [
2116                {
2117                    "id": 1,
2118                    "name": "aerosol_can",
2119                    "synset": "aerosol.n.02",
2120                    "synonyms": ["aerosol_can", "spray_can"],
2121                    "def": "a dispenser that holds a substance under pressure"
2122                },
2123                {
2124                    "id": 2,
2125                    "name": "person",
2126                    "supercategory": "human"
2127                }
2128            ]
2129        }"#;
2130
2131        let coco_path = temp_dir.path().join("lvis.json");
2132        std::fs::write(&coco_path, coco_json).unwrap();
2133
2134        let arrow_path = temp_dir.path().join("lvis_output.arrow");
2135        let options = CocoToArrowOptions::default();
2136        coco_to_arrow(&coco_path, &arrow_path, &options, None)
2137            .await
2138            .unwrap();
2139
2140        // Read back and verify metadata
2141        let mut file = std::fs::File::open(&arrow_path).unwrap();
2142        let mut reader = IpcReader::new(&mut file);
2143        let custom_meta = reader.custom_metadata().unwrap();
2144        assert!(custom_meta.is_some(), "custom metadata should be present");
2145
2146        let meta = custom_meta.unwrap();
2147
2148        // schema_version is always present
2149        assert_eq!(
2150            meta.get(&PlSmallStr::from("schema_version")),
2151            Some(&PlSmallStr::from(SCHEMA_VERSION)),
2152        );
2153
2154        // category_metadata should be present (aerosol_can has LVIS fields)
2155        let cat_meta_str = meta
2156            .get(&PlSmallStr::from("category_metadata"))
2157            .expect("category_metadata should be present for LVIS data");
2158
2159        let cat_meta: HashMap<String, serde_json::Value> =
2160            serde_json::from_str(cat_meta_str.as_str()).unwrap();
2161
2162        // Both categories should be present (all categories are now stored)
2163        assert!(
2164            cat_meta.contains_key("aerosol_can"),
2165            "aerosol_can should be in category_metadata"
2166        );
2167        assert!(
2168            cat_meta.contains_key("person"),
2169            "person should also be in category_metadata"
2170        );
2171
2172        // Verify aerosol_can entry contents
2173        let aerosol = cat_meta.get("aerosol_can").unwrap();
2174        assert_eq!(
2175            aerosol.get("synset").and_then(|v| v.as_str()),
2176            Some("aerosol.n.02")
2177        );
2178        assert_eq!(
2179            aerosol.get("definition").and_then(|v| v.as_str()),
2180            Some("a dispenser that holds a substance under pressure")
2181        );
2182        let synonyms = aerosol.get("synonyms").and_then(|v| v.as_array()).unwrap();
2183        assert_eq!(synonyms.len(), 2);
2184        assert_eq!(synonyms[0].as_str(), Some("aerosol_can"));
2185        assert_eq!(synonyms[1].as_str(), Some("spray_can"));
2186    }
2187
2188    // =========================================================================
2189    // LVIS round-trip tests
2190    // =========================================================================
2191
2192    #[tokio::test]
2193    async fn test_coco_arrow_roundtrip_lvis_supercategory() {
2194        let temp_dir = TempDir::new().unwrap();
2195
2196        // Create COCO JSON with supercategory
2197        let coco_json = r#"{
2198            "images": [
2199                {"id": 1, "width": 640, "height": 480, "file_name": "test.jpg"}
2200            ],
2201            "annotations": [
2202                {"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 20, 100, 80], "area": 8000, "iscrowd": 0}
2203            ],
2204            "categories": [
2205                {"id": 1, "name": "person", "supercategory": "human"}
2206            ]
2207        }"#;
2208
2209        let coco_path = temp_dir.path().join("original.json");
2210        std::fs::write(&coco_path, coco_json).unwrap();
2211
2212        // Convert to Arrow
2213        let arrow_path = temp_dir.path().join("converted.arrow");
2214        let options = CocoToArrowOptions::default();
2215        coco_to_arrow(&coco_path, &arrow_path, &options, None)
2216            .await
2217            .unwrap();
2218
2219        // Convert back to COCO
2220        let restored_path = temp_dir.path().join("restored.json");
2221        let options = ArrowToCocoOptions::default();
2222        arrow_to_coco(&arrow_path, &restored_path, &options, None)
2223            .await
2224            .unwrap();
2225
2226        // Verify supercategory is preserved
2227        let reader = CocoReader::new();
2228        let restored = reader.read_json(&restored_path).unwrap();
2229
2230        assert_eq!(restored.categories.len(), 1);
2231        assert_eq!(restored.categories[0].name, "person");
2232        assert_eq!(
2233            restored.categories[0].supercategory,
2234            Some("human".to_string()),
2235            "supercategory should survive COCO→Arrow→COCO round-trip"
2236        );
2237    }
2238
2239    #[tokio::test]
2240    async fn test_coco_arrow_roundtrip_neg_categories_no_annotations() {
2241        let temp_dir = TempDir::new().unwrap();
2242
2243        // Create COCO JSON: image has neg_category_ids but NO annotations
2244        let coco_json = r#"{
2245            "images": [
2246                {
2247                    "id": 1,
2248                    "width": 640,
2249                    "height": 480,
2250                    "file_name": "empty.jpg",
2251                    "neg_category_ids": [1, 2]
2252                }
2253            ],
2254            "annotations": [],
2255            "categories": [
2256                {"id": 1, "name": "cat", "supercategory": "animal"},
2257                {"id": 2, "name": "dog", "supercategory": "animal"}
2258            ]
2259        }"#;
2260
2261        let coco_path = temp_dir.path().join("original.json");
2262        std::fs::write(&coco_path, coco_json).unwrap();
2263
2264        // Convert to Arrow
2265        let arrow_path = temp_dir.path().join("converted.arrow");
2266        let options = CocoToArrowOptions::default();
2267        let sample_count = coco_to_arrow(&coco_path, &arrow_path, &options, None)
2268            .await
2269            .unwrap();
2270
2271        // Should have 1 sentinel sample (image with neg data but no annotations)
2272        assert_eq!(
2273            sample_count, 1,
2274            "sentinel row should be emitted for image with neg data"
2275        );
2276
2277        // Convert back to COCO
2278        let restored_path = temp_dir.path().join("restored.json");
2279        let options = ArrowToCocoOptions::default();
2280        arrow_to_coco(&arrow_path, &restored_path, &options, None)
2281            .await
2282            .unwrap();
2283
2284        // Verify neg_category_ids survived the round-trip
2285        let reader = CocoReader::new();
2286        let restored = reader.read_json(&restored_path).unwrap();
2287
2288        assert_eq!(restored.images.len(), 1);
2289        assert_eq!(restored.annotations.len(), 0, "no annotations expected");
2290        assert_eq!(restored.categories.len(), 2, "both categories should exist");
2291
2292        let neg = restored.images[0].neg_category_ids.as_ref();
2293        assert!(
2294            neg.is_some(),
2295            "neg_category_ids should survive round-trip for zero-annotation image"
2296        );
2297        let neg_ids = neg.unwrap();
2298        assert_eq!(neg_ids.len(), 2, "should have 2 neg categories");
2299        assert!(neg_ids.contains(&1), "neg_category_ids should contain 1");
2300        assert!(neg_ids.contains(&2), "neg_category_ids should contain 2");
2301
2302        // Verify supercategory survives for annotation-free categories
2303        for cat in &restored.categories {
2304            assert_eq!(
2305                cat.supercategory,
2306                Some("animal".to_string()),
2307                "supercategory should survive round-trip for annotation-free category '{}'",
2308                cat.name
2309            );
2310        }
2311    }
2312
2313    #[test]
2314    fn test_convert_image_annotations_neg_only_no_annotations() {
2315        let image = CocoImage {
2316            id: 1,
2317            width: 640,
2318            height: 480,
2319            file_name: "neg_only.jpg".to_string(),
2320            neg_category_ids: Some(vec![1, 2]),
2321            ..Default::default()
2322        };
2323
2324        let dataset = CocoDataset {
2325            images: vec![image.clone()],
2326            categories: vec![
2327                CocoCategory {
2328                    id: 1,
2329                    name: "cat".to_string(),
2330                    supercategory: Some("animal".to_string()),
2331                    ..Default::default()
2332                },
2333                CocoCategory {
2334                    id: 2,
2335                    name: "dog".to_string(),
2336                    supercategory: Some("animal".to_string()),
2337                    ..Default::default()
2338                },
2339            ],
2340            annotations: vec![],
2341            ..Default::default()
2342        };
2343
2344        let index = CocoIndex::from_dataset(&dataset);
2345        let samples = convert_image_annotations(&image, &index, true, None);
2346
2347        // Should emit 1 sentinel sample (no annotations but has neg data)
2348        assert_eq!(
2349            samples.len(),
2350            1,
2351            "sentinel row should be emitted for neg-only image"
2352        );
2353        assert_eq!(samples[0].image_name, Some("neg_only".to_string()));
2354        assert!(
2355            samples[0].annotations.is_empty(),
2356            "sentinel should have no annotations"
2357        );
2358        assert!(
2359            samples[0].neg_label_indices.is_some(),
2360            "sentinel should preserve neg_label_indices"
2361        );
2362        assert_eq!(samples[0].neg_label_indices.as_ref().unwrap().len(), 2);
2363    }
2364
2365    #[test]
2366    fn test_convert_image_annotations_no_annotations_emits_placeholder() {
2367        // A plain image with NO annotations and NO LVIS neg/exhaustive fields
2368        // must still emit one placeholder sample so the image is never dropped
2369        // and its dataset split (group) is preserved.
2370        let image = CocoImage {
2371            id: 1,
2372            width: 640,
2373            height: 480,
2374            file_name: "empty.jpg".to_string(),
2375            ..Default::default()
2376        };
2377
2378        let dataset = CocoDataset {
2379            images: vec![image.clone()],
2380            categories: vec![CocoCategory {
2381                id: 1,
2382                name: "person".to_string(),
2383                ..Default::default()
2384            }],
2385            annotations: vec![],
2386            ..Default::default()
2387        };
2388
2389        let index = CocoIndex::from_dataset(&dataset);
2390        let samples = convert_image_annotations(&image, &index, true, Some("train"));
2391
2392        assert_eq!(
2393            samples.len(),
2394            1,
2395            "placeholder row must be emitted for an unannotated image"
2396        );
2397        assert_eq!(samples[0].image_name, Some("empty".to_string()));
2398        assert!(
2399            samples[0].annotations.is_empty(),
2400            "placeholder should have no annotations"
2401        );
2402        assert_eq!(
2403            samples[0].group,
2404            Some("train".to_string()),
2405            "group must be preserved on the placeholder row"
2406        );
2407    }
2408
2409    #[tokio::test]
2410    async fn test_coco_to_arrow_includes_unannotated_images() {
2411        // coco-to-arrow must emit one row per image even when some images have
2412        // no annotations, so dataset splits (group) cover every image.
2413        let temp_dir = TempDir::new().unwrap();
2414
2415        let coco_json = r#"{
2416            "images": [
2417                {"id": 1, "width": 640, "height": 480, "file_name": "annotated.jpg"},
2418                {"id": 2, "width": 640, "height": 480, "file_name": "empty.jpg"}
2419            ],
2420            "annotations": [
2421                {"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 20, 100, 80], "area": 8000, "iscrowd": 0}
2422            ],
2423            "categories": [
2424                {"id": 1, "name": "person", "supercategory": "human"}
2425            ]
2426        }"#;
2427
2428        let coco_path = temp_dir.path().join("test.json");
2429        std::fs::write(&coco_path, coco_json).unwrap();
2430        let arrow_path = temp_dir.path().join("out.arrow");
2431
2432        let options = CocoToArrowOptions {
2433            group: Some("train".to_string()),
2434            ..Default::default()
2435        };
2436        let count = coco_to_arrow(&coco_path, &arrow_path, &options, None)
2437            .await
2438            .unwrap();
2439
2440        // 1 annotation row + 1 placeholder row for the unannotated image.
2441        assert_eq!(count, 2, "every image must produce at least one row");
2442
2443        let mut file = std::fs::File::open(&arrow_path).unwrap();
2444        let df = IpcReader::new(&mut file).finish().unwrap();
2445        assert_eq!(df.height(), 2);
2446
2447        // The unannotated image must appear with its group set and a null label.
2448        let names = df.column("name").unwrap().str().unwrap();
2449        let empty_row = (0..df.height()).find(|&i| names.get(i) == Some("empty"));
2450        assert!(
2451            empty_row.is_some(),
2452            "unannotated image 'empty' must appear in the Arrow output"
2453        );
2454        let i = empty_row.unwrap();
2455        let group_col = df.column("group").unwrap().cast(&DataType::String).unwrap();
2456        assert_eq!(
2457            group_col.str().unwrap().get(i),
2458            Some("train"),
2459            "group must be set on the unannotated image's row"
2460        );
2461        let label_col = df.column("label").unwrap().cast(&DataType::String).unwrap();
2462        assert_eq!(
2463            label_col.str().unwrap().get(i),
2464            None,
2465            "unannotated image row must have a null label"
2466        );
2467    }
2468
2469    // =========================================================================
2470    // Parquet output tests
2471    // =========================================================================
2472
2473    const MINIMAL_COCO_JSON: &str = r#"{
2474        "images": [
2475            {"id": 1, "width": 640, "height": 480, "file_name": "test1.jpg"},
2476            {"id": 2, "width": 320, "height": 240, "file_name": "test2.jpg"}
2477        ],
2478        "annotations": [
2479            {"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 20, 100, 80], "area": 8000, "iscrowd": 0},
2480            {"id": 2, "image_id": 2, "category_id": 2, "bbox": [5, 5, 50, 40], "area": 2000, "iscrowd": 0}
2481        ],
2482        "categories": [
2483            {"id": 1, "name": "person", "supercategory": "human"},
2484            {"id": 2, "name": "cat", "supercategory": "animal"}
2485        ]
2486    }"#;
2487
2488    #[tokio::test]
2489    async fn coco_to_parquet_roundtrip() {
2490        let dir = TempDir::new().unwrap();
2491        let coco = dir.path().join("instances.json");
2492        std::fs::write(&coco, MINIMAL_COCO_JSON).unwrap();
2493        let out = dir.path().join("out.parquet");
2494
2495        let n = coco_to_arrow(&coco, &out, &CocoToArrowOptions::default(), None)
2496            .await
2497            .unwrap();
2498        assert_eq!(n, 2);
2499
2500        let file = std::fs::File::open(&out).unwrap();
2501        let mut reader = ParquetReader::new(file);
2502
2503        // KV metadata: read the parquet footer key-value pairs.
2504        let metadata = reader.get_metadata().unwrap().clone();
2505        let kv = metadata
2506            .key_value_metadata
2507            .as_ref()
2508            .expect("parquet footer must carry key-value metadata");
2509        let get = |key: &str| {
2510            kv.iter()
2511                .find(|e| e.key == key)
2512                .and_then(|e| e.value.clone())
2513        };
2514        assert_eq!(
2515            get("schema_version").as_deref(),
2516            Some(SCHEMA_VERSION),
2517            "schema_version metadata should be '2026.04'"
2518        );
2519        assert!(get("labels").is_some(), "labels metadata should be present");
2520        assert!(
2521            get("category_metadata").is_some(),
2522            "category_metadata metadata should be present"
2523        );
2524
2525        let df = reader.finish().unwrap();
2526        assert_eq!(df.height(), 2);
2527        let cols = df.get_column_names();
2528        assert!(cols.iter().any(|c| c.as_str() == "name"));
2529        assert!(cols.iter().any(|c| c.as_str() == "label"));
2530        assert!(cols.iter().any(|c| c.as_str() == "box2d"));
2531    }
2532
2533    #[tokio::test]
2534    async fn parquet_to_coco_roundtrip() {
2535        let dir = TempDir::new().unwrap();
2536        let coco = dir.path().join("instances.json");
2537        std::fs::write(&coco, MINIMAL_COCO_JSON).unwrap();
2538        let parquet = dir.path().join("out.parquet");
2539
2540        let n = coco_to_arrow(&coco, &parquet, &CocoToArrowOptions::default(), None)
2541            .await
2542            .unwrap();
2543        assert_eq!(n, 2);
2544
2545        let roundtrip_json = dir.path().join("roundtrip.json");
2546        let converted = arrow_to_coco(
2547            &parquet,
2548            &roundtrip_json,
2549            &ArrowToCocoOptions::default(),
2550            None,
2551        )
2552        .await
2553        .unwrap();
2554        assert_eq!(converted, 2, "arrow_to_coco must accept a .parquet input");
2555
2556        let output: serde_json::Value =
2557            serde_json::from_str(&std::fs::read_to_string(&roundtrip_json).unwrap()).unwrap();
2558
2559        let categories = output["categories"].as_array().unwrap();
2560        assert_eq!(categories.len(), 2, "categories must round-trip");
2561        let category_names: std::collections::HashSet<_> = categories
2562            .iter()
2563            .map(|c| c["name"].as_str().unwrap().to_string())
2564            .collect();
2565        assert!(category_names.contains("person"));
2566        assert!(category_names.contains("cat"));
2567
2568        let annotations = output["annotations"].as_array().unwrap();
2569        assert_eq!(annotations.len(), 2, "annotation count must round-trip");
2570    }
2571
2572    #[tokio::test]
2573    async fn coco_directory_combines_splits_for_arrow_and_parquet() {
2574        let dir = TempDir::new().unwrap();
2575        let coco_root = dir.path().join("coco");
2576        let annotations = coco_root.join("annotations");
2577        std::fs::create_dir_all(&annotations).unwrap();
2578        std::fs::create_dir_all(coco_root.join("train2017")).unwrap();
2579        #[cfg(unix)]
2580        {
2581            std::fs::create_dir_all(coco_root.join("val-images")).unwrap();
2582            std::os::unix::fs::symlink(coco_root.join("val-images"), coco_root.join("val2017"))
2583                .unwrap();
2584        }
2585        #[cfg(not(unix))]
2586        std::fs::create_dir_all(coco_root.join("val2017")).unwrap();
2587
2588        // Deliberately reuse image and annotation IDs across splits. Processing
2589        // each source independently must retain both rather than deduplicating
2590        // one split by numeric ID.
2591        let split_json = |file_name: &str, annotated: bool| {
2592            let annotations = if annotated {
2593                r#"[{"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 20, 100, 80], "area": 8000, "iscrowd": 0}]"#
2594            } else {
2595                "[]"
2596            };
2597            format!(
2598                r#"{{
2599                    "images": [{{"id": 1, "width": 640, "height": 480, "file_name": "{file_name}"}}],
2600                    "annotations": {annotations},
2601                    "categories": [{{"id": 1, "name": "person", "supercategory": "human"}}]
2602                }}"#
2603            )
2604        };
2605        std::fs::write(
2606            annotations.join("instances_train2017.json"),
2607            split_json("train.jpg", true),
2608        )
2609        .unwrap();
2610        std::fs::write(
2611            annotations.join("instances_val2017.json"),
2612            split_json("val.jpg", false),
2613        )
2614        .unwrap();
2615        std::fs::write(coco_root.join("train2017/train.jpg"), b"train").unwrap();
2616        std::fs::write(coco_root.join("val2017/val.jpg"), b"val").unwrap();
2617
2618        for extension in ["arrow", "parquet"] {
2619            let dataset_name = format!("combined_{extension}");
2620            let output_dir = dir.path().join(&dataset_name);
2621            let output = output_dir.join(format!("{dataset_name}.{extension}"));
2622            let options = CocoToArrowOptions {
2623                images_dir: Some(coco_root.clone()),
2624                link_images: true,
2625                ..Default::default()
2626            };
2627            let count = coco_to_arrow(&coco_root, &output, &options, None)
2628                .await
2629                .unwrap();
2630            assert_eq!(count, 2, "annotated and empty images must both be retained");
2631
2632            let (df, metadata) = crate::format::read_dataset_dataframe(&output).unwrap();
2633            assert_eq!(df.height(), 2);
2634            assert_eq!(
2635                metadata.get("schema_version").map(String::as_str),
2636                Some(SCHEMA_VERSION)
2637            );
2638
2639            let groups = df.column("group").unwrap().cast(&DataType::String).unwrap();
2640            let groups: std::collections::HashSet<_> =
2641                groups.str().unwrap().iter().flatten().collect();
2642            assert_eq!(groups, std::collections::HashSet::from(["train", "val"]));
2643
2644            let names = df.column("name").unwrap().str().unwrap();
2645            assert!(names.iter().flatten().any(|name| name == "train"));
2646            assert!(names.iter().flatten().any(|name| name == "val"));
2647
2648            let staged = output_dir.join(&dataset_name);
2649            assert_eq!(std::fs::read(staged.join("train.jpg")).unwrap(), b"train");
2650            assert_eq!(std::fs::read(staged.join("val.jpg")).unwrap(), b"val");
2651            assert!(
2652                crate::format::validate_dataset_structure(&output_dir)
2653                    .unwrap()
2654                    .is_empty(),
2655                "linked image staging should pass offline validation"
2656            );
2657        }
2658    }
2659
2660    #[tokio::test]
2661    async fn coco_directory_rejects_incompatible_split_categories() {
2662        let dir = TempDir::new().unwrap();
2663        let annotations = dir.path().join("annotations");
2664        std::fs::create_dir_all(&annotations).unwrap();
2665        let make_json = |category: &str| {
2666            format!(
2667                r#"{{
2668                    "images": [{{"id": 1, "width": 1, "height": 1, "file_name": "image.jpg"}}],
2669                    "annotations": [],
2670                    "categories": [{{"id": 1, "name": "{category}"}}]
2671                }}"#
2672            )
2673        };
2674        std::fs::write(
2675            annotations.join("instances_train2017.json"),
2676            make_json("person"),
2677        )
2678        .unwrap();
2679        std::fs::write(
2680            annotations.join("instances_val2017.json"),
2681            make_json("vehicle"),
2682        )
2683        .unwrap();
2684
2685        let output = dir.path().join("output.arrow");
2686        let error = coco_to_arrow(dir.path(), &output, &CocoToArrowOptions::default(), None)
2687            .await
2688            .unwrap_err();
2689        assert!(
2690            error
2691                .to_string()
2692                .contains("COCO splits disagree on category 1")
2693        );
2694        assert!(!output.exists());
2695    }
2696
2697    #[test]
2698    fn compatible_categories_reject_conflicting_persisted_metadata() {
2699        let category = CocoCategory {
2700            id: 1,
2701            name: "person".to_string(),
2702            supercategory: Some("human".to_string()),
2703            synset: Some("person.n.01".to_string()),
2704            frequency: Some("f".to_string()),
2705            synonyms: Some(vec!["person".to_string(), "human".to_string()]),
2706            def: Some("a human being".to_string()),
2707            ..Default::default()
2708        };
2709        let conflicting = [
2710            (
2711                "supercategory",
2712                CocoCategory {
2713                    supercategory: Some("animal".to_string()),
2714                    ..category.clone()
2715                },
2716            ),
2717            (
2718                "synset",
2719                CocoCategory {
2720                    synset: Some("person.n.02".to_string()),
2721                    ..category.clone()
2722                },
2723            ),
2724            (
2725                "frequency",
2726                CocoCategory {
2727                    frequency: Some("c".to_string()),
2728                    ..category.clone()
2729                },
2730            ),
2731            (
2732                "synonyms",
2733                CocoCategory {
2734                    synonyms: Some(vec!["individual".to_string()]),
2735                    ..category.clone()
2736                },
2737            ),
2738            (
2739                "definition",
2740                CocoCategory {
2741                    def: Some("a different definition".to_string()),
2742                    ..category.clone()
2743                },
2744            ),
2745        ];
2746
2747        for (field, conflicting_category) in conflicting {
2748            let sources = vec![
2749                (
2750                    CocoDataset {
2751                        categories: vec![category.clone()],
2752                        ..Default::default()
2753                    },
2754                    Some("train".to_string()),
2755                    None,
2756                ),
2757                (
2758                    CocoDataset {
2759                        categories: vec![conflicting_category],
2760                        ..Default::default()
2761                    },
2762                    Some("val".to_string()),
2763                    None,
2764                ),
2765            ];
2766
2767            let error = collect_compatible_categories(&sources).unwrap_err();
2768            assert!(
2769                error
2770                    .to_string()
2771                    .contains(&format!("metadata field '{field}'")),
2772                "unexpected error for {field}: {error}"
2773            );
2774        }
2775    }
2776
2777    // =========================================================================
2778    // Image staging tests
2779    // =========================================================================
2780
2781    const STAGING_COCO_JSON: &str = r#"{
2782        "images": [
2783            {"id": 1, "width": 640, "height": 480, "file_name": "img1.jpg"},
2784            {"id": 2, "width": 320, "height": 240, "file_name": "img2.jpg"}
2785        ],
2786        "annotations": [],
2787        "categories": []
2788    }"#;
2789
2790    #[tokio::test]
2791    async fn staging_copies_referenced_images_and_reports_missing() {
2792        let dir = TempDir::new().unwrap();
2793        let src = dir.path().join("src");
2794        std::fs::create_dir(&src).unwrap();
2795        std::fs::write(src.join("img1.jpg"), b"jpegdata").unwrap();
2796
2797        let coco = dir.path().join("instances.json");
2798        std::fs::write(&coco, STAGING_COCO_JSON).unwrap();
2799
2800        let out = dir.path().join("ds/ds.arrow");
2801        let options = CocoToArrowOptions {
2802            images_dir: Some(src),
2803            ..Default::default()
2804        };
2805
2806        coco_to_arrow(&coco, &out, &options, None).await.unwrap();
2807
2808        assert!(dir.path().join("ds/ds/img1.jpg").exists());
2809        assert!(
2810            !dir.path().join("ds/ds/img2.jpg").exists(),
2811            "missing source images must be skipped non-fatally"
2812        );
2813    }
2814
2815    #[tokio::test]
2816    async fn staging_links_when_requested() {
2817        let dir = TempDir::new().unwrap();
2818        let src = dir.path().join("src");
2819        std::fs::create_dir(&src).unwrap();
2820        std::fs::write(src.join("img1.jpg"), b"jpegdata").unwrap();
2821
2822        let coco = dir.path().join("instances.json");
2823        std::fs::write(&coco, STAGING_COCO_JSON).unwrap();
2824
2825        let out = dir.path().join("ds/ds.arrow");
2826        let options = CocoToArrowOptions {
2827            images_dir: Some(src),
2828            link_images: true,
2829            ..Default::default()
2830        };
2831
2832        coco_to_arrow(&coco, &out, &options, None).await.unwrap();
2833
2834        let staged = dir.path().join("ds/ds/img1.jpg");
2835        #[cfg(unix)]
2836        assert!(
2837            std::fs::symlink_metadata(&staged)
2838                .unwrap()
2839                .file_type()
2840                .is_symlink(),
2841            "--link must produce a symlink, not a copy"
2842        );
2843        assert!(staged.exists());
2844    }
2845
2846    #[tokio::test]
2847    async fn staging_is_idempotent_on_rerun() {
2848        let dir = TempDir::new().unwrap();
2849        let src = dir.path().join("src");
2850        std::fs::create_dir(&src).unwrap();
2851        std::fs::write(src.join("img1.jpg"), b"jpegdata").unwrap();
2852
2853        let coco = dir.path().join("instances.json");
2854        std::fs::write(&coco, STAGING_COCO_JSON).unwrap();
2855
2856        let out = dir.path().join("ds/ds.arrow");
2857        let options = CocoToArrowOptions {
2858            images_dir: Some(src),
2859            ..Default::default()
2860        };
2861
2862        coco_to_arrow(&coco, &out, &options, None).await.unwrap();
2863        coco_to_arrow(&coco, &out, &options, None)
2864            .await
2865            .expect("re-running staging over an already-staged output must not error");
2866
2867        let staged = dir.path().join("ds/ds/img1.jpg");
2868        assert!(staged.exists());
2869        assert_eq!(
2870            std::fs::read(&staged).unwrap(),
2871            b"jpegdata",
2872            "re-run must leave exactly one, unmodified copy"
2873        );
2874    }
2875
2876    #[test]
2877    fn staging_reports_collision_on_duplicate_basename_from_different_sources() {
2878        let dir = TempDir::new().unwrap();
2879        let src = dir.path().join("src");
2880        std::fs::create_dir_all(src.join("train")).unwrap();
2881        std::fs::create_dir_all(src.join("val")).unwrap();
2882        std::fs::write(src.join("train/000001.jpg"), b"train-data").unwrap();
2883        std::fs::write(src.join("val/000001.jpg"), b"val-data").unwrap();
2884
2885        let out = dir.path().join("ds/ds.arrow");
2886        let file_names = vec!["train/000001.jpg".to_string(), "val/000001.jpg".to_string()];
2887
2888        let report = stage_images(&out, &src, false, &file_names);
2889
2890        assert_eq!(
2891            report.staged, 1,
2892            "only the first source claiming the basename must be staged"
2893        );
2894        assert_eq!(
2895            report.collisions, 1,
2896            "the second file_name must be counted as a collision, not staged"
2897        );
2898        assert!(report.missing.is_empty());
2899
2900        let dest = dir.path().join("ds/ds/000001.jpg");
2901        assert_eq!(
2902            std::fs::read(&dest).unwrap(),
2903            b"train-data",
2904            "the destination must retain the first-staged source's bytes, never the second's"
2905        );
2906    }
2907}