1use std::{
62 collections::{BTreeMap, HashMap},
63 fs::File,
64 path::{Path, PathBuf},
65};
66
67use walkdir::WalkDir;
68
69use crate::Error;
70
71pub const IMAGE_EXTENSIONS: &[&str] = &[
73 "jpg",
74 "jpeg",
75 "png",
76 "camera.jpeg",
77 "camera.png",
78 "camera.jpg",
79];
80
81fn is_parquet_dataset(path: &Path) -> bool {
86 path.extension().and_then(std::ffi::OsStr::to_str) == Some("parquet")
87}
88
89#[cfg(feature = "polars")]
104pub fn read_dataset_metadata(path: &Path) -> Result<BTreeMap<String, String>, Error> {
105 use polars::prelude::*;
106
107 let file = File::open(path).map_err(|e| {
108 Error::InvalidParameters(format!("Cannot open dataset file {:?}: {}", path, e))
109 })?;
110
111 if is_parquet_dataset(path) {
112 let mut reader = ParquetReader::new(file);
113 let parquet_meta = reader.get_metadata().map_err(|e| {
114 Error::InvalidParameters(format!("Failed to read Parquet metadata {:?}: {}", path, e))
115 })?;
116 Ok(parquet_meta
117 .key_value_metadata
118 .as_ref()
119 .map(|kv| {
120 kv.iter()
121 .filter_map(|e| e.value.clone().map(|v| (e.key.to_string(), v)))
122 .collect()
123 })
124 .unwrap_or_default())
125 } else {
126 let mut file = file;
127 let custom_meta = IpcReader::new(&mut file).custom_metadata().ok().flatten();
128 Ok(custom_meta
129 .map(|m| {
130 m.iter()
131 .map(|(k, v)| (k.to_string(), v.to_string()))
132 .collect()
133 })
134 .unwrap_or_default())
135 }
136}
137
138#[cfg(feature = "polars")]
153pub fn read_dataset_dataframe(
154 path: &Path,
155) -> Result<(polars::prelude::DataFrame, BTreeMap<String, String>), Error> {
156 use polars::prelude::*;
157
158 let file = File::open(path).map_err(|e| {
159 Error::InvalidParameters(format!("Cannot open dataset file {:?}: {}", path, e))
160 })?;
161
162 if is_parquet_dataset(path) {
163 let mut reader = ParquetReader::new(file);
164 let parquet_meta = reader
165 .get_metadata()
166 .map_err(|e| {
167 Error::InvalidParameters(format!(
168 "Failed to read Parquet metadata {:?}: {}",
169 path, e
170 ))
171 })?
172 .clone();
173 let metadata: BTreeMap<String, String> = parquet_meta
174 .key_value_metadata
175 .as_ref()
176 .map(|kv| {
177 kv.iter()
178 .filter_map(|e| e.value.clone().map(|v| (e.key.to_string(), v)))
179 .collect()
180 })
181 .unwrap_or_default();
182 let df = reader.finish().map_err(|e| {
183 Error::InvalidParameters(format!("Failed to read Parquet file {:?}: {}", path, e))
184 })?;
185 Ok((df, metadata))
186 } else {
187 let mut file = file;
188 let mut reader = IpcReader::new(&mut file);
189 let custom_meta = reader.custom_metadata().ok().flatten();
190 let metadata: BTreeMap<String, String> = custom_meta
191 .map(|m| {
192 m.iter()
193 .map(|(k, v)| (k.to_string(), v.to_string()))
194 .collect()
195 })
196 .unwrap_or_default();
197 let df = reader.finish().map_err(|e| {
198 Error::InvalidParameters(format!("Failed to read Arrow file {:?}: {}", path, e))
199 })?;
200 Ok((df, metadata))
201 }
202}
203
204#[cfg(feature = "polars")]
241pub fn resolve_arrow_files(arrow_path: &Path) -> Result<HashMap<String, PathBuf>, Error> {
242 let (df, _metadata) = read_dataset_dataframe(arrow_path)?;
243
244 let names = df
246 .column("name")
247 .map_err(|e| Error::InvalidParameters(format!("Missing 'name' column: {}", e)))?
248 .str()
249 .map_err(|e| Error::InvalidParameters(format!("Invalid 'name' column type: {}", e)))?;
250
251 let frames = df.column("frame").ok();
253
254 let mut result = HashMap::new();
255
256 for idx in 0..df.height() {
257 let name = match names.get(idx) {
259 Some(n) => n.to_string(),
260 None => continue, };
262
263 if result.contains_key(&name) {
265 continue;
266 }
267
268 let frame = frames.and_then(|col| {
270 col.u64()
272 .ok()
273 .and_then(|s| s.get(idx))
274 .or_else(|| col.u32().ok().and_then(|s| s.get(idx).map(|v| v as u64)))
275 });
276
277 let relative_path = if let Some(frame_num) = frame {
279 PathBuf::from(&name).join(format!("{}_{:03}.camera.jpeg", name, frame_num))
282 } else {
283 PathBuf::from(format!("{}.camera.jpeg", name))
285 };
286
287 result.insert(name, relative_path);
288 }
289
290 Ok(result)
291}
292
293#[derive(Debug, Clone)]
295pub struct ResolvedFile {
296 pub name: String,
298 pub frame: Option<u64>,
300 pub path: Option<PathBuf>,
302 pub expected_path: PathBuf,
304}
305
306#[cfg(feature = "polars")]
340pub fn resolve_files_with_container(
341 arrow_path: &Path,
342 sensor_container: &Path,
343) -> Result<Vec<ResolvedFile>, Error> {
344 let (df, _metadata) = read_dataset_dataframe(arrow_path)?;
345
346 let file_index = build_file_index(sensor_container)?;
348
349 let names = df
351 .column("name")
352 .map_err(|e| Error::InvalidParameters(format!("Missing 'name' column: {}", e)))?
353 .str()
354 .map_err(|e| Error::InvalidParameters(format!("Invalid 'name' column type: {}", e)))?;
355
356 let frames = df.column("frame").ok();
358
359 let mut result = Vec::new();
360 let mut seen_samples: HashMap<String, bool> = HashMap::new();
361
362 for idx in 0..df.height() {
363 let name = match names.get(idx) {
364 Some(n) => n.to_string(),
365 None => continue,
366 };
367
368 let frame = frames.and_then(|col| {
370 col.u64()
371 .ok()
372 .and_then(|s| s.get(idx))
373 .or_else(|| col.u32().ok().and_then(|s| s.get(idx).map(|v| v as u64)))
374 });
375
376 let sample_key = match frame {
377 Some(f) => format!("{}_{}", name, f),
378 None => name.clone(),
379 };
380
381 if seen_samples.contains_key(&sample_key) {
383 continue;
384 }
385 seen_samples.insert(sample_key.clone(), true);
386
387 let expected_path = if let Some(frame_num) = frame {
389 PathBuf::from(&name).join(format!("{}_{:03}.camera.jpeg", name, frame_num))
390 } else {
391 PathBuf::from(format!("{}.camera.jpeg", name))
392 };
393
394 let actual_path = find_matching_file(&file_index, &name, frame);
396
397 result.push(ResolvedFile {
398 name,
399 frame,
400 path: actual_path,
401 expected_path,
402 });
403 }
404
405 Ok(result)
406}
407
408fn build_file_index(root: &Path) -> Result<HashMap<String, PathBuf>, Error> {
410 let mut index = HashMap::new();
411
412 if !root.exists() {
413 return Ok(index);
414 }
415
416 for entry in WalkDir::new(root)
417 .into_iter()
418 .filter_map(|e| e.ok())
419 .filter(|e| e.file_type().is_file() || (e.file_type().is_symlink() && e.path().is_file()))
420 {
421 let path = entry.path().to_path_buf();
422 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
423 index.insert(filename.to_lowercase(), path.clone());
425
426 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
428 let clean_stem = stem.strip_suffix(".camera").unwrap_or(stem).to_lowercase();
430 index.entry(clean_stem).or_insert_with(|| path.clone());
431 }
432 }
433 }
434
435 Ok(index)
436}
437
438fn find_matching_file(
440 index: &HashMap<String, PathBuf>,
441 name: &str,
442 frame: Option<u64>,
443) -> Option<PathBuf> {
444 let search_key = match frame {
445 Some(f) => format!("{}_{:03}", name, f).to_lowercase(),
446 None => name.to_lowercase(),
447 };
448
449 for ext in IMAGE_EXTENSIONS {
451 let key = format!("{}.{}", search_key, ext);
452 if let Some(path) = index.get(&key) {
453 return Some(path.clone());
454 }
455 }
456
457 if let Some(path) = index.get(&search_key) {
459 return Some(path.clone());
460 }
461
462 None
463}
464
465#[derive(Debug, Clone, PartialEq, Eq)]
467pub enum ValidationIssue {
468 MissingArrowFile { expected: PathBuf },
470 MissingSensorContainer { expected: PathBuf },
472 MissingFile { name: String, expected: PathBuf },
474 UnreferencedFile { path: PathBuf },
476 InvalidStructure { message: String },
478}
479
480impl std::fmt::Display for ValidationIssue {
481 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482 match self {
483 ValidationIssue::MissingArrowFile { expected } => {
484 write!(
485 f,
486 "Missing dataset annotation file: expected {:?} or the same path with a .parquet extension",
487 expected
488 )
489 }
490 ValidationIssue::MissingSensorContainer { expected } => {
491 write!(f, "Missing sensor container directory: {:?}", expected)
492 }
493 ValidationIssue::MissingFile { name, expected } => {
494 write!(f, "Missing file for sample '{}': {:?}", name, expected)
495 }
496 ValidationIssue::UnreferencedFile { path } => {
497 write!(f, "Unreferenced file in container: {:?}", path)
498 }
499 ValidationIssue::InvalidStructure { message } => {
500 write!(f, "Invalid structure: {}", message)
501 }
502 }
503 }
504}
505
506#[cfg(feature = "polars")]
539pub fn validate_dataset_structure(dataset_dir: &Path) -> Result<Vec<ValidationIssue>, Error> {
540 let mut issues = Vec::new();
541
542 let dataset_name = dataset_dir
544 .file_name()
545 .and_then(|n| n.to_str())
546 .ok_or_else(|| Error::InvalidParameters("Invalid dataset directory path".to_owned()))?;
547
548 let arrow_path = dataset_dir.join(format!("{}.arrow", dataset_name));
552 let parquet_path = dataset_dir.join(format!("{}.parquet", dataset_name));
553 let dataset_path = match (arrow_path.exists(), parquet_path.exists()) {
554 (true, false) => arrow_path,
555 (false, true) => parquet_path,
556 (true, true) => {
557 issues.push(ValidationIssue::InvalidStructure {
558 message: format!(
559 "Both {:?} and {:?} exist; keep exactly one dataset annotation file",
560 arrow_path, parquet_path
561 ),
562 });
563 return Ok(issues);
564 }
565 (false, false) => {
566 issues.push(ValidationIssue::MissingArrowFile {
567 expected: arrow_path.clone(),
568 });
569 return Ok(issues);
570 }
571 };
572
573 let container_path = dataset_dir.join(dataset_name);
575 if !container_path.exists() {
576 issues.push(ValidationIssue::MissingSensorContainer {
577 expected: container_path.clone(),
578 });
579 return Ok(issues);
581 }
582
583 let resolved = resolve_files_with_container(&dataset_path, &container_path)?;
585
586 let mut referenced_files: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
588
589 for file in &resolved {
590 match &file.path {
591 Some(path) => {
592 referenced_files.insert(path.clone());
593 }
594 None => {
595 issues.push(ValidationIssue::MissingFile {
596 name: file.name.clone(),
597 expected: file.expected_path.clone(),
598 });
599 }
600 }
601 }
602
603 for entry in WalkDir::new(&container_path)
605 .into_iter()
606 .filter_map(|e| e.ok())
607 .filter(|e| e.file_type().is_file())
608 {
609 let path = entry.path().to_path_buf();
610
611 let is_image = path
613 .extension()
614 .and_then(|e| e.to_str())
615 .map(|e| {
616 matches!(
617 e.to_lowercase().as_str(),
618 "jpg" | "jpeg" | "png" | "pcd" | "bin"
619 )
620 })
621 .unwrap_or(false);
622
623 if is_image && !referenced_files.contains(&path) {
624 issues.push(ValidationIssue::UnreferencedFile { path });
625 }
626 }
627
628 Ok(issues)
629}
630
631#[cfg(feature = "polars")]
670pub fn generate_arrow_from_folder(
671 folder: &Path,
672 output: &Path,
673 detect_sequences: bool,
674) -> Result<usize, Error> {
675 use polars::prelude::*;
676 use std::io::BufWriter;
677
678 let image_files: Vec<PathBuf> = WalkDir::new(folder)
680 .into_iter()
681 .filter_map(|e| e.ok())
682 .filter(|e| e.file_type().is_file())
683 .filter(|e| {
684 e.path()
685 .extension()
686 .and_then(|ext| ext.to_str())
687 .map(|ext| {
688 matches!(
689 ext.to_lowercase().as_str(),
690 "jpg" | "jpeg" | "png" | "pcd" | "bin"
691 )
692 })
693 .unwrap_or(false)
694 })
695 .map(|e| e.path().to_path_buf())
696 .collect();
697
698 if image_files.is_empty() {
699 return Err(Error::InvalidParameters(
700 "No image files found in folder".to_owned(),
701 ));
702 }
703
704 let mut names: Vec<String> = Vec::new();
706 let mut frames: Vec<Option<u64>> = Vec::new();
707
708 for path in &image_files {
709 let (name, frame) = parse_image_filename(path, folder, detect_sequences);
710 names.push(name);
711 frames.push(frame);
712 }
713
714 let name_series = Series::new("name".into(), &names);
718 let frame_series = Series::new("frame".into(), &frames);
719
720 let mut df = DataFrame::new_infer_height(vec![name_series.into(), frame_series.into()])?;
721
722 if let Some(parent) = output.parent() {
724 std::fs::create_dir_all(parent)?;
725 }
726
727 let file = File::create(output)?;
729 let writer = BufWriter::new(file);
730 IpcWriter::new(writer)
731 .finish(&mut df)
732 .map_err(|e| Error::InvalidParameters(format!("Failed to write Arrow file: {}", e)))?;
733
734 Ok(image_files.len())
735}
736
737fn parse_image_filename(path: &Path, root: &Path, detect_sequences: bool) -> (String, Option<u64>) {
739 let stem = path
740 .file_stem()
741 .and_then(|s| s.to_str())
742 .unwrap_or("unknown");
743
744 let clean_stem = stem.strip_suffix(".camera").unwrap_or(stem);
746
747 if !detect_sequences {
748 return (clean_stem.to_string(), None);
749 }
750
751 if let Some(idx) = clean_stem.rfind('_') {
754 let (name_part, frame_part) = clean_stem.split_at(idx);
755 let frame_str = &frame_part[1..]; if let Ok(frame) = frame_str.parse::<u64>() {
758 let relative = path.strip_prefix(root).unwrap_or(path);
760 if relative.components().count() > 1 {
761 return (name_part.to_string(), Some(frame));
763 }
764
765 return (name_part.to_string(), Some(frame));
768 }
769 }
770
771 (clean_stem.to_string(), None)
773}
774
775pub fn get_sensor_container_path(dataset_dir: &Path) -> Option<PathBuf> {
785 let dataset_name = dataset_dir.file_name()?.to_str()?;
786 Some(dataset_dir.join(dataset_name))
787}
788
789pub fn get_arrow_path(dataset_dir: &Path) -> Option<PathBuf> {
799 let dataset_name = dataset_dir.file_name()?.to_str()?;
800 Some(dataset_dir.join(format!("{}.arrow", dataset_name)))
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806 use std::io::Write;
807 use tempfile::TempDir;
808
809 fn create_test_image(path: &Path) {
811 let jpeg_data: &[u8] = &[
813 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00,
814 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06,
815 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D,
816 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D,
817 0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28,
818 0x37, 0x29, 0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32,
819 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01,
820 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01,
821 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02,
822 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10,
823 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00,
824 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
825 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42,
826 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16,
827 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
828 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55,
829 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73,
830 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
831 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5,
832 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA,
833 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6,
834 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA,
835 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08,
836 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0xFB, 0xD5, 0xDB, 0x20, 0xA8, 0xF1, 0x4D, 0x9E,
837 0xBA, 0x79, 0xC5, 0x14, 0x51, 0x40, 0xFF, 0xD9,
838 ];
839
840 if let Some(parent) = path.parent() {
841 std::fs::create_dir_all(parent).unwrap();
842 }
843 let mut file = File::create(path).unwrap();
844 file.write_all(jpeg_data).unwrap();
845 }
846
847 #[test]
848 fn test_get_arrow_path() {
849 let dir = Path::new("/data/my_dataset");
850 let arrow = get_arrow_path(dir).unwrap();
851 assert_eq!(arrow, PathBuf::from("/data/my_dataset/my_dataset.arrow"));
852 }
853
854 #[test]
855 fn test_get_sensor_container_path() {
856 let dir = Path::new("/data/my_dataset");
857 let container = get_sensor_container_path(dir).unwrap();
858 assert_eq!(container, PathBuf::from("/data/my_dataset/my_dataset"));
859 }
860
861 #[test]
862 fn test_parse_image_filename_standalone() {
863 let root = Path::new("/data");
864 let path = Path::new("/data/image.jpg");
865
866 let (name, frame) = parse_image_filename(path, root, true);
867 assert_eq!(name, "image");
868 assert_eq!(frame, None);
869 }
870
871 #[test]
872 fn test_parse_image_filename_camera_extension() {
873 let root = Path::new("/data");
874 let path = Path::new("/data/sample.camera.jpeg");
875
876 let (name, frame) = parse_image_filename(path, root, true);
877 assert_eq!(name, "sample");
878 assert_eq!(frame, None);
879 }
880
881 #[test]
882 fn test_parse_image_filename_sequence() {
883 let root = Path::new("/data");
884 let path = Path::new("/data/seq/seq_001.camera.jpeg");
885
886 let (name, frame) = parse_image_filename(path, root, true);
887 assert_eq!(name, "seq");
888 assert_eq!(frame, Some(1));
889 }
890
891 #[test]
892 fn test_parse_image_filename_no_sequence_detection() {
893 let root = Path::new("/data");
894 let path = Path::new("/data/seq/seq_001.camera.jpeg");
895
896 let (name, frame) = parse_image_filename(path, root, false);
897 assert_eq!(name, "seq_001");
898 assert_eq!(frame, None);
899 }
900
901 #[test]
902 fn test_build_file_index() {
903 let temp_dir = TempDir::new().unwrap();
904 let root = temp_dir.path();
905
906 create_test_image(&root.join("image1.jpg"));
908 create_test_image(&root.join("sub/image2.camera.jpeg"));
909
910 let index = build_file_index(root).unwrap();
911
912 assert!(index.contains_key("image1.jpg"));
914 assert!(index.contains_key("image2.camera.jpeg"));
915
916 assert!(index.contains_key("image1"));
918 assert!(index.contains_key("image2"));
919 }
920
921 #[test]
922 fn test_find_matching_file() {
923 let temp_dir = TempDir::new().unwrap();
924 let root = temp_dir.path();
925
926 create_test_image(&root.join("sample.camera.jpeg"));
928 create_test_image(&root.join("seq/seq_001.camera.jpeg"));
929
930 let index = build_file_index(root).unwrap();
931
932 let found = find_matching_file(&index, "sample", None);
934 assert!(found.is_some());
935
936 let found = find_matching_file(&index, "seq", Some(1));
938 assert!(found.is_some());
939
940 let found = find_matching_file(&index, "nonexistent", None);
942 assert!(found.is_none());
943 }
944
945 #[cfg(feature = "polars")]
946 #[test]
947 fn test_generate_arrow_from_folder() {
948 use polars::prelude::*;
949
950 let temp_dir = TempDir::new().unwrap();
951 let root = temp_dir.path();
952
953 let images_dir = root.join("images");
955 create_test_image(&images_dir.join("photo1.jpg"));
956 create_test_image(&images_dir.join("photo2.png"));
957 create_test_image(&images_dir.join("seq/seq_001.camera.jpeg"));
958 create_test_image(&images_dir.join("seq/seq_002.camera.jpeg"));
959
960 let arrow_path = root.join("output.arrow");
962 let count = generate_arrow_from_folder(&images_dir, &arrow_path, true).unwrap();
963
964 assert_eq!(count, 4);
965 assert!(arrow_path.exists());
966
967 let mut file = File::open(&arrow_path).unwrap();
969 let df = IpcReader::new(&mut file).finish().unwrap();
970
971 assert_eq!(df.height(), 4);
972 assert_eq!(df.width(), 2); assert!(df.column("name").is_ok());
974 assert!(df.column("frame").is_ok());
975 }
976
977 #[cfg(feature = "polars")]
978 #[test]
979 fn test_resolve_arrow_files() {
980 use polars::prelude::*;
981 use std::io::BufWriter;
982
983 let temp_dir = TempDir::new().unwrap();
984 let root = temp_dir.path();
985
986 let names = Series::new("name".into(), &["sample1", "sample2", "seq"]);
988 let frames: Vec<Option<u64>> = vec![None, None, Some(1)];
989 let frame_series = Series::new("frame".into(), &frames);
990
991 let mut df = DataFrame::new_infer_height(vec![names.into(), frame_series.into()]).unwrap();
992
993 let arrow_path = root.join("test.arrow");
994 let file = File::create(&arrow_path).unwrap();
995 let writer = BufWriter::new(file);
996 IpcWriter::new(writer).finish(&mut df).unwrap();
997
998 let resolved = resolve_arrow_files(&arrow_path).unwrap();
1000
1001 assert_eq!(resolved.len(), 3);
1002 assert!(resolved.contains_key("sample1"));
1003 assert!(resolved.contains_key("sample2"));
1004 assert!(resolved.contains_key("seq"));
1005 }
1006
1007 #[cfg(feature = "polars")]
1008 #[test]
1009 fn test_validate_dataset_structure_valid() {
1010 use polars::prelude::*;
1011 use std::io::BufWriter;
1012
1013 let temp_dir = TempDir::new().unwrap();
1014 let dataset_dir = temp_dir.path().join("my_dataset");
1015 std::fs::create_dir_all(&dataset_dir).unwrap();
1016
1017 let names = Series::new("name".into(), &["image1"]);
1019 let frames: Vec<Option<u64>> = vec![None];
1020 let frame_series = Series::new("frame".into(), &frames);
1021
1022 let mut df = DataFrame::new_infer_height(vec![names.into(), frame_series.into()]).unwrap();
1023
1024 let arrow_path = dataset_dir.join("my_dataset.arrow");
1025 let file = File::create(&arrow_path).unwrap();
1026 let writer = BufWriter::new(file);
1027 IpcWriter::new(writer).finish(&mut df).unwrap();
1028
1029 let container = dataset_dir.join("my_dataset");
1031 create_test_image(&container.join("image1.camera.jpeg"));
1032
1033 let issues = validate_dataset_structure(&dataset_dir).unwrap();
1035
1036 let missing_files: Vec<_> = issues
1038 .iter()
1039 .filter(|i| matches!(i, ValidationIssue::MissingFile { .. }))
1040 .collect();
1041 assert!(
1042 missing_files.is_empty(),
1043 "Unexpected missing files: {:?}",
1044 missing_files
1045 );
1046 }
1047
1048 #[cfg(feature = "polars")]
1049 #[test]
1050 fn test_validate_dataset_structure_parquet() {
1051 use polars::prelude::*;
1052
1053 let temp_dir = TempDir::new().unwrap();
1054 let dataset_dir = temp_dir.path().join("my_dataset");
1055 std::fs::create_dir_all(&dataset_dir).unwrap();
1056
1057 let names = Series::new("name".into(), &["image1"]);
1058 let frames: Vec<Option<u64>> = vec![None];
1059 let frame_series = Series::new("frame".into(), &frames);
1060 let mut df = DataFrame::new_infer_height(vec![names.into(), frame_series.into()]).unwrap();
1061 let parquet_path = dataset_dir.join("my_dataset.parquet");
1062 ParquetWriter::new(File::create(&parquet_path).unwrap())
1063 .finish(&mut df)
1064 .unwrap();
1065
1066 let container = dataset_dir.join("my_dataset");
1067 create_test_image(&container.join("image1.camera.jpeg"));
1068
1069 let issues = validate_dataset_structure(&dataset_dir).unwrap();
1070 assert!(
1071 issues
1072 .iter()
1073 .all(|issue| !matches!(issue, ValidationIssue::MissingFile { .. })),
1074 "Parquet dataset should resolve its staged image: {issues:?}"
1075 );
1076 assert!(
1077 issues
1078 .iter()
1079 .all(|issue| !matches!(issue, ValidationIssue::MissingArrowFile { .. })),
1080 "Parquet must satisfy dataset annotation discovery: {issues:?}"
1081 );
1082 }
1083
1084 #[cfg(feature = "polars")]
1085 #[test]
1086 fn test_validate_dataset_structure_rejects_ambiguous_annotation_files() {
1087 let temp_dir = TempDir::new().unwrap();
1088 let dataset_dir = temp_dir.path().join("my_dataset");
1089 std::fs::create_dir_all(&dataset_dir).unwrap();
1090 std::fs::write(dataset_dir.join("my_dataset.arrow"), b"arrow").unwrap();
1091 std::fs::write(dataset_dir.join("my_dataset.parquet"), b"parquet").unwrap();
1092
1093 let issues = validate_dataset_structure(&dataset_dir).unwrap();
1094 assert_eq!(issues.len(), 1);
1095 assert!(matches!(
1096 &issues[0],
1097 ValidationIssue::InvalidStructure { message }
1098 if message.contains("Both") && message.contains("keep exactly one")
1099 ));
1100 }
1101
1102 #[cfg(feature = "polars")]
1103 #[test]
1104 fn test_validate_dataset_structure_missing_arrow() {
1105 let temp_dir = TempDir::new().unwrap();
1106 let dataset_dir = temp_dir.path().join("my_dataset");
1107 std::fs::create_dir_all(&dataset_dir).unwrap();
1108
1109 let issues = validate_dataset_structure(&dataset_dir).unwrap();
1110
1111 assert_eq!(issues.len(), 1);
1112 assert!(matches!(
1113 &issues[0],
1114 ValidationIssue::MissingArrowFile { .. }
1115 ));
1116 }
1117
1118 #[test]
1119 fn test_image_extensions() {
1120 assert!(IMAGE_EXTENSIONS.contains(&"jpg"));
1121 assert!(IMAGE_EXTENSIONS.contains(&"jpeg"));
1122 assert!(IMAGE_EXTENSIONS.contains(&"png"));
1123 assert!(IMAGE_EXTENSIONS.contains(&"camera.jpeg"));
1124 }
1125
1126 #[test]
1127 fn test_validation_issue_display() {
1128 let issue = ValidationIssue::MissingFile {
1129 name: "test".to_string(),
1130 expected: PathBuf::from("test.jpg"),
1131 };
1132 let display = format!("{}", issue);
1133 assert!(display.contains("test"));
1134 assert!(display.contains("test.jpg"));
1135 }
1136
1137 #[cfg(feature = "polars")]
1142 #[test]
1143 fn test_read_dataset_metadata_arrow_already_current_short_circuits() {
1144 use polars::prelude::*;
1145 use std::{io::BufWriter, sync::Arc};
1146
1147 let temp_dir = TempDir::new().unwrap();
1148 let arrow_path = temp_dir.path().join("current.arrow");
1149
1150 let names = Series::new("name".into(), &["sample1"]);
1151 let mut df = DataFrame::new_infer_height(vec![names.into()]).unwrap();
1152
1153 let file = File::create(&arrow_path).unwrap();
1154 let writer = BufWriter::new(file);
1155 let mut ipc_writer = IpcWriter::new(writer);
1156 let mut metadata = BTreeMap::new();
1157 metadata.insert(
1158 PlSmallStr::from("schema_version"),
1159 PlSmallStr::from("2026.04"),
1160 );
1161 ipc_writer.set_custom_schema_metadata(Arc::new(metadata));
1162 ipc_writer.finish(&mut df).unwrap();
1163
1164 let meta = read_dataset_metadata(&arrow_path).unwrap();
1168 assert_eq!(
1169 meta.get("schema_version").map(|s| s.as_str()),
1170 Some("2026.04")
1171 );
1172 }
1173
1174 #[cfg(feature = "polars")]
1175 #[test]
1176 fn test_read_dataset_metadata_parquet_already_current_short_circuits() {
1177 use polars::prelude::*;
1178
1179 let temp_dir = TempDir::new().unwrap();
1180 let parquet_path = temp_dir.path().join("current.parquet");
1181
1182 let names = Series::new("name".into(), &["sample1"]);
1183 let mut df = DataFrame::new_infer_height(vec![names.into()]).unwrap();
1184
1185 let file = File::create(&parquet_path).unwrap();
1186 let kv = vec![("schema_version".to_string(), "2026.04".to_string())];
1187 ParquetWriter::new(file)
1188 .with_key_value_metadata(Some(KeyValueMetadata::from_static(kv)))
1189 .finish(&mut df)
1190 .unwrap();
1191
1192 let meta = read_dataset_metadata(&parquet_path).unwrap();
1193 assert_eq!(
1194 meta.get("schema_version").map(|s| s.as_str()),
1195 Some("2026.04")
1196 );
1197 }
1198}