1use 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
30pub const SCHEMA_VERSION: &str = "2026.04";
32
33type PolygonRings = Vec<Vec<(f32, f32)>>;
35
36#[derive(Debug, Clone)]
43pub struct CocoToArrowOptions {
44 pub include_masks: bool,
46 pub group: Option<String>,
48 pub max_workers: usize,
50 pub images_dir: Option<PathBuf>,
53 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#[derive(Debug, Clone)]
71pub struct ArrowToCocoOptions {
72 pub groups: Vec<String>,
74 pub include_masks: bool,
76 pub info: Option<CocoInfo>,
78 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
93fn 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
106pub 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 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 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 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 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 let mut df = crate::samples_dataframe(&all_samples)?;
229
230 let mut metadata: BTreeMap<PlSmallStr, PlSmallStr> = BTreeMap::new();
232 metadata.insert(
233 PlSmallStr::from("schema_version"),
234 PlSmallStr::from(SCHEMA_VERSION),
235 );
236
237 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 (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 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
358fn 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
427fn 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 .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
449struct StageReport {
452 staged: usize,
454 missing: Vec<String>,
456 failed: Vec<String>,
460 collisions: usize,
465}
466
467fn 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 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 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 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#[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
611pub 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
651fn 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 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 let box2d = coco_bbox_to_box2d(&ann.bbox, image.width, image.height);
681
682 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 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 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
764fn 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
773pub 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 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 let is_legacy = schema_version.is_none();
806
807 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 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 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 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 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 let sizes = df
910 .column("size")
911 .ok()
912 .and_then(|c| extract_all_sizes(c).ok());
913
914 let iscrowds: Vec<u8> = df
916 .column("iscrowd")
917 .ok()
918 .map(|c| {
919 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 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 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 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 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 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 let mut builder = CocoDatasetBuilder::new();
995
996 if let Some(info) = &options.info {
997 builder = builder.info(info.clone());
998 }
999
1000 let skip_row = |i: usize| -> bool {
1002 !groups_to_filter.is_empty() && !groups_to_filter.contains(&groups[i])
1003 };
1004
1005 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 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 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 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 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 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 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 let segmentation = if options.include_masks {
1076 if is_legacy {
1077 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 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 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 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 if let Some(score_val) = score {
1150 builder.set_annotation_score(ann_id, score_val);
1151 }
1152 }
1153
1154 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 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 {
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 {
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 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 !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 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 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 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
1304fn 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
1333fn 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
1354fn 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
1383fn 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
1402fn 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 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
1440fn 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
1452fn 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
1461fn 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 #[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 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 let coords = vec![
1565 0.1,
1566 0.1,
1567 0.2,
1568 0.1,
1569 0.15,
1570 0.2, f32::NAN, 0.5,
1573 0.5,
1574 0.6,
1575 0.5,
1576 0.55,
1577 0.6, ];
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 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 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 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 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); }
1628
1629 #[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 let samples_with_mask = convert_image_annotations(&image, &index, true, None);
1717 assert!(samples_with_mask[0].annotations[0].polygon().is_some());
1718
1719 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 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 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 #[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 #[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 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 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 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 let coco_path = temp_dir.path().join("original.json");
1927 let writer = CocoWriter::new();
1928 writer.write_json(&original, &coco_path).unwrap();
1929
1930 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 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 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 assert_eq!(restored.categories[0].name, "person");
1962 }
1963
1964 #[tokio::test]
1965 async fn test_arrow_to_coco_roundtrip_preserves_annotation_id() {
1966 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 #[tokio::test]
2057 async fn test_coco_to_arrow_schema_version_metadata() {
2058 let temp_dir = TempDir::new().unwrap();
2059
2060 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 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 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 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 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 assert_eq!(
2150 meta.get(&PlSmallStr::from("schema_version")),
2151 Some(&PlSmallStr::from(SCHEMA_VERSION)),
2152 );
2153
2154 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 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 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 #[tokio::test]
2193 async fn test_coco_arrow_roundtrip_lvis_supercategory() {
2194 let temp_dir = TempDir::new().unwrap();
2195
2196 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 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 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 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 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 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 assert_eq!(
2273 sample_count, 1,
2274 "sentinel row should be emitted for image with neg data"
2275 );
2276
2277 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 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 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 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 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 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 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 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 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 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 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 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}