edgefirst_client/
dataset.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4use std::{collections::HashMap, fmt::Display};
5
6use crate::{
7    Client, Error,
8    api::{AnnotationSetID, DatasetID, ProjectID, SampleID},
9};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13#[cfg(feature = "polars")]
14use polars::prelude::*;
15
16/// File types supported in EdgeFirst Studio datasets.
17///
18/// Represents the different types of sensor data files that can be stored
19/// and processed in a dataset. EdgeFirst Studio supports various modalities
20/// including visual images and different forms of LiDAR and radar data.
21///
22/// # Examples
23///
24/// ```rust
25/// use edgefirst_client::FileType;
26///
27/// // Create file types from strings
28/// let image_type: FileType = "image".try_into().unwrap();
29/// let lidar_type: FileType = "lidar.pcd".try_into().unwrap();
30///
31/// // Display file types
32/// println!("Processing {} files", image_type); // "Processing image files"
33///
34/// // Use in dataset operations - example usage
35/// let file_type = FileType::Image;
36/// match file_type {
37///     FileType::Image => println!("Processing image files"),
38///     FileType::LidarPcd => println!("Processing LiDAR point cloud files"),
39///     _ => println!("Processing other sensor data"),
40/// }
41/// ```
42#[derive(Clone, Eq, PartialEq, Debug)]
43pub enum FileType {
44    /// Standard image files (JPEG, PNG, etc.)
45    Image,
46    /// LiDAR point cloud data files (.pcd format)
47    LidarPcd,
48    /// LiDAR depth images (.png format)
49    LidarDepth,
50    /// LiDAR reflectance images (.jpg format)
51    LidarReflect,
52    /// Radar point cloud data files (.pcd format)
53    RadarPcd,
54    /// Radar cube data files (.png format)
55    RadarCube,
56}
57
58impl std::fmt::Display for FileType {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        let value = match self {
61            FileType::Image => "image",
62            FileType::LidarPcd => "lidar.pcd",
63            FileType::LidarDepth => "lidar.png",
64            FileType::LidarReflect => "lidar.jpg",
65            FileType::RadarPcd => "radar.pcd",
66            FileType::RadarCube => "radar.png",
67        };
68        write!(f, "{}", value)
69    }
70}
71
72impl TryFrom<&str> for FileType {
73    type Error = crate::Error;
74
75    fn try_from(s: &str) -> Result<Self, Self::Error> {
76        match s {
77            "image" => Ok(FileType::Image),
78            "lidar.pcd" => Ok(FileType::LidarPcd),
79            "lidar.png" => Ok(FileType::LidarDepth),
80            "lidar.jpg" => Ok(FileType::LidarReflect),
81            "radar.pcd" => Ok(FileType::RadarPcd),
82            "radar.png" => Ok(FileType::RadarCube),
83            _ => Err(crate::Error::InvalidFileType(s.to_string())),
84        }
85    }
86}
87
88impl std::str::FromStr for FileType {
89    type Err = crate::Error;
90
91    fn from_str(s: &str) -> Result<Self, Self::Err> {
92        s.try_into()
93    }
94}
95
96/// Annotation types supported for labeling data in EdgeFirst Studio.
97///
98/// Represents the different types of annotations that can be applied to
99/// sensor data for machine learning tasks. Each type corresponds to a
100/// different annotation geometry and use case.
101///
102/// # Examples
103///
104/// ```rust
105/// use edgefirst_client::AnnotationType;
106///
107/// // Create annotation types from strings (using TryFrom)
108/// let box_2d: AnnotationType = "box2d".try_into().unwrap();
109/// let segmentation: AnnotationType = "mask".try_into().unwrap();
110///
111/// // Or use From with String
112/// let box_2d = AnnotationType::from("box2d".to_string());
113/// let segmentation = AnnotationType::from("mask".to_string());
114///
115/// // Display annotation types
116/// println!("Annotation type: {}", box_2d); // "Annotation type: box2d"
117///
118/// // Use in matching and processing
119/// let annotation_type = AnnotationType::Box2d;
120/// match annotation_type {
121///     AnnotationType::Box2d => println!("Processing 2D bounding boxes"),
122///     AnnotationType::Box3d => println!("Processing 3D bounding boxes"),
123///     AnnotationType::Mask => println!("Processing segmentation masks"),
124/// }
125/// ```
126#[derive(Clone, Eq, PartialEq, Debug)]
127pub enum AnnotationType {
128    /// 2D bounding boxes for object detection in images
129    Box2d,
130    /// 3D bounding boxes for object detection in 3D space (LiDAR, etc.)
131    Box3d,
132    /// Pixel-level segmentation masks for semantic/instance segmentation
133    Mask,
134}
135
136impl TryFrom<&str> for AnnotationType {
137    type Error = crate::Error;
138
139    fn try_from(s: &str) -> Result<Self, Self::Error> {
140        match s {
141            "box2d" => Ok(AnnotationType::Box2d),
142            "box3d" => Ok(AnnotationType::Box3d),
143            "mask" => Ok(AnnotationType::Mask),
144            _ => Err(crate::Error::InvalidAnnotationType(s.to_string())),
145        }
146    }
147}
148
149impl From<String> for AnnotationType {
150    fn from(s: String) -> Self {
151        // For backward compatibility, default to Box2d if invalid
152        s.as_str().try_into().unwrap_or(AnnotationType::Box2d)
153    }
154}
155
156impl From<&String> for AnnotationType {
157    fn from(s: &String) -> Self {
158        // For backward compatibility, default to Box2d if invalid
159        s.as_str().try_into().unwrap_or(AnnotationType::Box2d)
160    }
161}
162
163impl std::fmt::Display for AnnotationType {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        let value = match self {
166            AnnotationType::Box2d => "box2d",
167            AnnotationType::Box3d => "box3d",
168            AnnotationType::Mask => "mask",
169        };
170        write!(f, "{}", value)
171    }
172}
173
174/// A dataset in EdgeFirst Studio containing sensor data and annotations.
175///
176/// Datasets are collections of multi-modal sensor data (images, LiDAR, radar)
177/// along with their corresponding annotations (bounding boxes, segmentation
178/// masks, 3D annotations). Datasets belong to projects and can be used for
179/// training and validation of machine learning models.
180///
181/// # Features
182///
183/// - **Multi-modal Data**: Support for images, LiDAR point clouds, radar data
184/// - **Rich Annotations**: 2D/3D bounding boxes, segmentation masks
185/// - **Metadata**: Timestamps, sensor configurations, calibration data
186/// - **Version Control**: Track changes and maintain data lineage
187/// - **Format Conversion**: Export to popular ML frameworks
188///
189/// # Examples
190///
191/// ```no_run
192/// use edgefirst_client::{Client, Dataset, DatasetID};
193/// use std::str::FromStr;
194///
195/// # async fn example() -> Result<(), edgefirst_client::Error> {
196/// # let client = Client::new()?;
197/// // Get dataset information
198/// let dataset_id = DatasetID::from_str("ds-abc123")?;
199/// let dataset = client.dataset(dataset_id).await?;
200/// println!("Dataset: {}", dataset.name());
201///
202/// // Access dataset metadata
203/// println!("Dataset ID: {}", dataset.id());
204/// println!("Description: {}", dataset.description());
205/// println!("Created: {}", dataset.created());
206///
207/// // Work with dataset data would require additional methods
208/// // that are implemented in the full API
209/// # Ok(())
210/// # }
211/// ```
212#[derive(Deserialize, Clone, Debug)]
213pub struct Dataset {
214    id: DatasetID,
215    project_id: ProjectID,
216    name: String,
217    description: String,
218    cloud_key: String,
219    #[serde(rename = "createdAt")]
220    created: DateTime<Utc>,
221}
222
223impl Display for Dataset {
224    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
225        write!(f, "{} {}", self.id, self.name)
226    }
227}
228
229impl Dataset {
230    pub fn id(&self) -> DatasetID {
231        self.id
232    }
233
234    pub fn project_id(&self) -> ProjectID {
235        self.project_id
236    }
237
238    pub fn name(&self) -> &str {
239        &self.name
240    }
241
242    pub fn description(&self) -> &str {
243        &self.description
244    }
245
246    pub fn cloud_key(&self) -> &str {
247        &self.cloud_key
248    }
249
250    pub fn created(&self) -> &DateTime<Utc> {
251        &self.created
252    }
253
254    pub async fn project(&self, client: &Client) -> Result<crate::api::Project, Error> {
255        client.project(self.project_id).await
256    }
257
258    pub async fn annotation_sets(&self, client: &Client) -> Result<Vec<AnnotationSet>, Error> {
259        client.annotation_sets(self.id).await
260    }
261
262    pub async fn labels(&self, client: &Client) -> Result<Vec<Label>, Error> {
263        client.labels(self.id).await
264    }
265
266    pub async fn add_label(&self, client: &Client, name: &str) -> Result<(), Error> {
267        client.add_label(self.id, name).await
268    }
269
270    pub async fn remove_label(&self, client: &Client, name: &str) -> Result<(), Error> {
271        let labels = self.labels(client).await?;
272        let label = labels
273            .iter()
274            .find(|l| l.name() == name)
275            .ok_or_else(|| Error::MissingLabel(name.to_string()))?;
276        client.remove_label(label.id()).await
277    }
278}
279
280/// The AnnotationSet class represents a collection of annotations in a dataset.
281/// A dataset can have multiple annotation sets, each containing annotations for
282/// different tasks or purposes.
283#[derive(Deserialize)]
284pub struct AnnotationSet {
285    id: AnnotationSetID,
286    dataset_id: DatasetID,
287    name: String,
288    description: String,
289    #[serde(rename = "date")]
290    created: DateTime<Utc>,
291}
292
293impl Display for AnnotationSet {
294    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
295        write!(f, "{} {}", self.id, self.name)
296    }
297}
298
299impl AnnotationSet {
300    pub fn id(&self) -> AnnotationSetID {
301        self.id
302    }
303
304    pub fn dataset_id(&self) -> DatasetID {
305        self.dataset_id
306    }
307
308    pub fn name(&self) -> &str {
309        &self.name
310    }
311
312    pub fn description(&self) -> &str {
313        &self.description
314    }
315
316    pub fn created(&self) -> DateTime<Utc> {
317        self.created
318    }
319
320    pub async fn dataset(&self, client: &Client) -> Result<Dataset, Error> {
321        client.dataset(self.dataset_id).await
322    }
323}
324
325/// A sample in a dataset, typically representing a single image with metadata
326/// and optional sensor data.
327///
328/// Each sample has a unique ID, image reference, and can include additional
329/// sensor data like LiDAR, radar, or depth maps. Samples can also have
330/// associated annotations.
331#[derive(Serialize, Deserialize, Clone, Debug)]
332pub struct Sample {
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub id: Option<SampleID>,
335    /// Dataset split (train, val, test) - stored in Arrow metadata, not used
336    /// for directory structure.
337    /// API field name discrepancy: samples.populate2 expects "group", but
338    /// samples.list returns "group_name".
339    #[serde(
340        alias = "group_name",
341        rename(serialize = "group", deserialize = "group_name"),
342        skip_serializing_if = "Option::is_none"
343    )]
344    pub group: Option<String>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub sequence_name: Option<String>,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub sequence_uuid: Option<String>,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub sequence_description: Option<String>,
351    #[serde(
352        default,
353        skip_serializing_if = "Option::is_none",
354        deserialize_with = "deserialize_frame_number"
355    )]
356    pub frame_number: Option<u32>,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub uuid: Option<String>,
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub image_name: Option<String>,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub image_url: Option<String>,
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub width: Option<u32>,
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub height: Option<u32>,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub date: Option<DateTime<Utc>>,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub source: Option<String>,
371    /// Camera location and pose (GPS + IMU data).
372    /// Serialized as "sensors" for API compatibility with populate endpoint.
373    #[serde(rename = "sensors", skip_serializing_if = "Option::is_none")]
374    pub location: Option<Location>,
375    /// Image degradation type (blur, occlusion, weather, etc.).
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub degradation: Option<String>,
378    /// Additional sensor files (LiDAR, radar, depth maps, etc.).
379    /// When deserializing from samples.list: Vec<SampleFile>
380    /// When serializing for samples.populate2: HashMap<String, String>
381    /// (file_type -> filename)
382    #[serde(
383        default,
384        skip_serializing_if = "Vec::is_empty",
385        serialize_with = "serialize_files",
386        deserialize_with = "deserialize_files"
387    )]
388    pub files: Vec<SampleFile>,
389    #[serde(
390        default,
391        skip_serializing_if = "Vec::is_empty",
392        serialize_with = "serialize_annotations",
393        deserialize_with = "deserialize_annotations"
394    )]
395    pub annotations: Vec<Annotation>,
396}
397
398// Custom deserializer for frame_number - converts -1 to None
399// Server returns -1 for non-sequence samples, but clients should see None
400fn deserialize_frame_number<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
401where
402    D: serde::Deserializer<'de>,
403{
404    use serde::Deserialize;
405
406    let value = Option::<i32>::deserialize(deserializer)?;
407    Ok(value.and_then(|v| if v < 0 { None } else { Some(v as u32) }))
408}
409
410// Custom serializer for files field - converts Vec<SampleFile> to
411// HashMap<String, String>
412fn serialize_files<S>(files: &[SampleFile], serializer: S) -> Result<S::Ok, S::Error>
413where
414    S: serde::Serializer,
415{
416    use serde::Serialize;
417    let map: HashMap<String, String> = files
418        .iter()
419        .filter_map(|f| {
420            f.filename()
421                .map(|filename| (f.file_type().to_string(), filename.to_string()))
422        })
423        .collect();
424    map.serialize(serializer)
425}
426
427// Custom deserializer for files field - converts HashMap or Vec to
428// Vec<SampleFile>
429fn deserialize_files<'de, D>(deserializer: D) -> Result<Vec<SampleFile>, D::Error>
430where
431    D: serde::Deserializer<'de>,
432{
433    use serde::Deserialize;
434
435    #[derive(Deserialize)]
436    #[serde(untagged)]
437    enum FilesFormat {
438        Vec(Vec<SampleFile>),
439        Map(HashMap<String, String>),
440    }
441
442    let value = Option::<FilesFormat>::deserialize(deserializer)?;
443    Ok(value
444        .map(|v| match v {
445            FilesFormat::Vec(files) => files,
446            FilesFormat::Map(map) => convert_files_map_to_vec(map),
447        })
448        .unwrap_or_default())
449}
450
451// Custom serializer for annotations field - serializes to a flat
452// Vec<Annotation> to match the updated samples.populate2 contract (annotations
453// array)
454fn serialize_annotations<S>(annotations: &Vec<Annotation>, serializer: S) -> Result<S::Ok, S::Error>
455where
456    S: serde::Serializer,
457{
458    serde::Serialize::serialize(annotations, serializer)
459}
460
461// Custom deserializer for annotations field - converts server format back to
462// Vec<Annotation>
463fn deserialize_annotations<'de, D>(deserializer: D) -> Result<Vec<Annotation>, D::Error>
464where
465    D: serde::Deserializer<'de>,
466{
467    use serde::Deserialize;
468
469    #[derive(Deserialize)]
470    #[serde(untagged)]
471    enum AnnotationsFormat {
472        Vec(Vec<Annotation>),
473        Map(HashMap<String, Vec<Annotation>>),
474    }
475
476    let value = Option::<AnnotationsFormat>::deserialize(deserializer)?;
477    Ok(value
478        .map(|v| match v {
479            AnnotationsFormat::Vec(annotations) => annotations,
480            AnnotationsFormat::Map(map) => convert_annotations_map_to_vec(map),
481        })
482        .unwrap_or_default())
483}
484
485impl Display for Sample {
486    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
487        write!(
488            f,
489            "{} {}",
490            self.id
491                .map(|id| id.to_string())
492                .unwrap_or_else(|| "unknown".to_string()),
493            self.image_name().unwrap_or("unknown")
494        )
495    }
496}
497
498impl Default for Sample {
499    fn default() -> Self {
500        Self::new()
501    }
502}
503
504impl Sample {
505    /// Creates a new empty sample.
506    pub fn new() -> Self {
507        Self {
508            id: None,
509            group: None,
510            sequence_name: None,
511            sequence_uuid: None,
512            sequence_description: None,
513            frame_number: None,
514            uuid: None,
515            image_name: None,
516            image_url: None,
517            width: None,
518            height: None,
519            date: None,
520            source: None,
521            location: None,
522            degradation: None,
523            files: vec![],
524            annotations: vec![],
525        }
526    }
527
528    pub fn id(&self) -> Option<SampleID> {
529        self.id
530    }
531
532    pub fn name(&self) -> Option<String> {
533        self.image_name.as_ref().map(|n| extract_sample_name(n))
534    }
535
536    pub fn group(&self) -> Option<&String> {
537        self.group.as_ref()
538    }
539
540    pub fn sequence_name(&self) -> Option<&String> {
541        self.sequence_name.as_ref()
542    }
543
544    pub fn sequence_uuid(&self) -> Option<&String> {
545        self.sequence_uuid.as_ref()
546    }
547
548    pub fn sequence_description(&self) -> Option<&String> {
549        self.sequence_description.as_ref()
550    }
551
552    pub fn frame_number(&self) -> Option<u32> {
553        self.frame_number
554    }
555
556    pub fn uuid(&self) -> Option<&String> {
557        self.uuid.as_ref()
558    }
559
560    pub fn image_name(&self) -> Option<&str> {
561        self.image_name.as_deref()
562    }
563
564    pub fn image_url(&self) -> Option<&str> {
565        self.image_url.as_deref()
566    }
567
568    pub fn width(&self) -> Option<u32> {
569        self.width
570    }
571
572    pub fn height(&self) -> Option<u32> {
573        self.height
574    }
575
576    pub fn date(&self) -> Option<DateTime<Utc>> {
577        self.date
578    }
579
580    pub fn source(&self) -> Option<&String> {
581        self.source.as_ref()
582    }
583
584    pub fn location(&self) -> Option<&Location> {
585        self.location.as_ref()
586    }
587
588    pub fn files(&self) -> &[SampleFile] {
589        &self.files
590    }
591
592    pub fn annotations(&self) -> &[Annotation] {
593        &self.annotations
594    }
595
596    pub fn with_annotations(mut self, annotations: Vec<Annotation>) -> Self {
597        self.annotations = annotations;
598        self
599    }
600
601    pub fn with_frame_number(mut self, frame_number: Option<u32>) -> Self {
602        self.frame_number = frame_number;
603        self
604    }
605
606    pub async fn download(
607        &self,
608        client: &Client,
609        file_type: FileType,
610    ) -> Result<Option<Vec<u8>>, Error> {
611        let url = resolve_file_url(&file_type, self.image_url.as_deref(), &self.files);
612
613        Ok(match url {
614            Some(url) => Some(client.download(url).await?),
615            None => None,
616        })
617    }
618}
619
620/// A file associated with a sample (e.g., LiDAR point cloud, radar data).
621///
622/// For samples retrieved from the server, this contains the file type and URL.
623/// For samples being populated to the server, this can be a type and filename.
624#[derive(Serialize, Deserialize, Clone, Debug)]
625pub struct SampleFile {
626    r#type: String,
627    #[serde(skip_serializing_if = "Option::is_none")]
628    url: Option<String>,
629    #[serde(skip_serializing_if = "Option::is_none")]
630    filename: Option<String>,
631}
632
633impl SampleFile {
634    /// Creates a new sample file with type and URL (for downloaded samples).
635    pub fn with_url(file_type: String, url: String) -> Self {
636        Self {
637            r#type: file_type,
638            url: Some(url),
639            filename: None,
640        }
641    }
642
643    /// Creates a new sample file with type and filename (for populate API).
644    pub fn with_filename(file_type: String, filename: String) -> Self {
645        Self {
646            r#type: file_type,
647            url: None,
648            filename: Some(filename),
649        }
650    }
651
652    pub fn file_type(&self) -> &str {
653        &self.r#type
654    }
655
656    pub fn url(&self) -> Option<&str> {
657        self.url.as_deref()
658    }
659
660    pub fn filename(&self) -> Option<&str> {
661        self.filename.as_deref()
662    }
663}
664
665/// Location and pose information for a sample.
666///
667/// Contains GPS coordinates and IMU orientation data describing where and how
668/// the camera was positioned when capturing the sample.
669#[derive(Serialize, Deserialize, Clone, Debug)]
670pub struct Location {
671    #[serde(skip_serializing_if = "Option::is_none")]
672    pub gps: Option<GpsData>,
673    #[serde(skip_serializing_if = "Option::is_none")]
674    pub imu: Option<ImuData>,
675}
676
677/// GPS location data (latitude and longitude).
678#[derive(Serialize, Deserialize, Clone, Debug)]
679pub struct GpsData {
680    pub lat: f64,
681    pub lon: f64,
682}
683
684impl GpsData {
685    /// Validate GPS coordinates are within valid ranges.
686    ///
687    /// Checks if latitude and longitude values are within valid geographic
688    /// ranges. Helps catch data corruption or API issues early.
689    ///
690    /// # Returns
691    /// `Ok(())` if valid, `Err(String)` with descriptive error message
692    /// otherwise
693    ///
694    /// # Valid Ranges
695    /// - Latitude: -90.0 to +90.0 degrees
696    /// - Longitude: -180.0 to +180.0 degrees
697    ///
698    /// # Examples
699    /// ```
700    /// use edgefirst_client::GpsData;
701    ///
702    /// let gps = GpsData {
703    ///     lat: 37.7749,
704    ///     lon: -122.4194,
705    /// };
706    /// assert!(gps.validate().is_ok());
707    ///
708    /// let bad_gps = GpsData {
709    ///     lat: 100.0,
710    ///     lon: 0.0,
711    /// };
712    /// assert!(bad_gps.validate().is_err());
713    /// ```
714    pub fn validate(&self) -> Result<(), String> {
715        validate_gps_coordinates(self.lat, self.lon)
716    }
717}
718
719/// IMU orientation data (roll, pitch, yaw in degrees).
720#[derive(Serialize, Deserialize, Clone, Debug)]
721pub struct ImuData {
722    pub roll: f64,
723    pub pitch: f64,
724    pub yaw: f64,
725}
726
727impl ImuData {
728    /// Validate IMU orientation angles are within valid ranges.
729    ///
730    /// Checks if roll, pitch, and yaw values are finite and within reasonable
731    /// ranges. Helps catch data corruption or sensor errors early.
732    ///
733    /// # Returns
734    /// `Ok(())` if valid, `Err(String)` with descriptive error message
735    /// otherwise
736    ///
737    /// # Valid Ranges
738    /// - Roll: -180.0 to +180.0 degrees
739    /// - Pitch: -90.0 to +90.0 degrees (typical gimbal lock range)
740    /// - Yaw: -180.0 to +180.0 degrees (or 0 to 360, normalized)
741    ///
742    /// # Examples
743    /// ```
744    /// use edgefirst_client::ImuData;
745    ///
746    /// let imu = ImuData {
747    ///     roll: 10.0,
748    ///     pitch: 5.0,
749    ///     yaw: 90.0,
750    /// };
751    /// assert!(imu.validate().is_ok());
752    ///
753    /// let bad_imu = ImuData {
754    ///     roll: 200.0,
755    ///     pitch: 0.0,
756    ///     yaw: 0.0,
757    /// };
758    /// assert!(bad_imu.validate().is_err());
759    /// ```
760    pub fn validate(&self) -> Result<(), String> {
761        validate_imu_orientation(self.roll, self.pitch, self.yaw)
762    }
763}
764
765#[allow(dead_code)]
766pub trait TypeName {
767    fn type_name() -> String;
768}
769
770#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
771pub struct Box3d {
772    x: f32,
773    y: f32,
774    z: f32,
775    w: f32,
776    h: f32,
777    l: f32,
778}
779
780impl TypeName for Box3d {
781    fn type_name() -> String {
782        "box3d".to_owned()
783    }
784}
785
786impl Box3d {
787    pub fn new(cx: f32, cy: f32, cz: f32, width: f32, height: f32, length: f32) -> Self {
788        Self {
789            x: cx,
790            y: cy,
791            z: cz,
792            w: width,
793            h: height,
794            l: length,
795        }
796    }
797
798    pub fn width(&self) -> f32 {
799        self.w
800    }
801
802    pub fn height(&self) -> f32 {
803        self.h
804    }
805
806    pub fn length(&self) -> f32 {
807        self.l
808    }
809
810    pub fn cx(&self) -> f32 {
811        self.x
812    }
813
814    pub fn cy(&self) -> f32 {
815        self.y
816    }
817
818    pub fn cz(&self) -> f32 {
819        self.z
820    }
821
822    pub fn left(&self) -> f32 {
823        self.x - self.w / 2.0
824    }
825
826    pub fn top(&self) -> f32 {
827        self.y - self.h / 2.0
828    }
829
830    pub fn front(&self) -> f32 {
831        self.z - self.l / 2.0
832    }
833}
834
835#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
836pub struct Box2d {
837    h: f32,
838    w: f32,
839    x: f32,
840    y: f32,
841}
842
843impl TypeName for Box2d {
844    fn type_name() -> String {
845        "box2d".to_owned()
846    }
847}
848
849impl Box2d {
850    pub fn new(left: f32, top: f32, width: f32, height: f32) -> Self {
851        Self {
852            x: left,
853            y: top,
854            w: width,
855            h: height,
856        }
857    }
858
859    pub fn width(&self) -> f32 {
860        self.w
861    }
862
863    pub fn height(&self) -> f32 {
864        self.h
865    }
866
867    pub fn left(&self) -> f32 {
868        self.x
869    }
870
871    pub fn top(&self) -> f32 {
872        self.y
873    }
874
875    pub fn cx(&self) -> f32 {
876        self.x + self.w / 2.0
877    }
878
879    pub fn cy(&self) -> f32 {
880        self.y + self.h / 2.0
881    }
882}
883
884#[derive(Clone, Debug, PartialEq)]
885pub struct Mask {
886    pub polygon: Vec<Vec<(f32, f32)>>,
887}
888
889impl TypeName for Mask {
890    fn type_name() -> String {
891        "mask".to_owned()
892    }
893}
894
895impl Mask {
896    pub fn new(polygon: Vec<Vec<(f32, f32)>>) -> Self {
897        Self { polygon }
898    }
899}
900
901impl serde::Serialize for Mask {
902    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
903    where
904        S: serde::Serializer,
905    {
906        serde::Serialize::serialize(&self.polygon, serializer)
907    }
908}
909
910impl<'de> serde::Deserialize<'de> for Mask {
911    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
912    where
913        D: serde::Deserializer<'de>,
914    {
915        use serde::Deserialize;
916
917        #[derive(Deserialize)]
918        #[serde(untagged)]
919        enum MaskFormat {
920            Polygon { polygon: Vec<Vec<(f32, f32)>> },
921            Direct(Vec<Vec<(f32, f32)>>),
922        }
923
924        match MaskFormat::deserialize(deserializer)? {
925            MaskFormat::Polygon { polygon } => Ok(Self { polygon }),
926            MaskFormat::Direct(polygon) => Ok(Self { polygon }),
927        }
928    }
929}
930
931#[derive(Serialize, Deserialize, Clone, Debug)]
932pub struct Annotation {
933    #[serde(skip_serializing_if = "Option::is_none")]
934    sample_id: Option<SampleID>,
935    #[serde(skip_serializing_if = "Option::is_none")]
936    name: Option<String>,
937    #[serde(skip_serializing_if = "Option::is_none")]
938    sequence_name: Option<String>,
939    #[serde(skip_serializing_if = "Option::is_none")]
940    frame_number: Option<u32>,
941    /// Dataset split (train, val, test) - matches `Sample.group`.
942    /// JSON field name: "group_name" (Studio API uses this name for both upload
943    /// and download).
944    #[serde(rename = "group_name", skip_serializing_if = "Option::is_none")]
945    group: Option<String>,
946    /// Object tracking identifier across frames.
947    /// JSON field name: "object_reference" for upload (populate), "object_id"
948    /// for download (list).
949    #[serde(
950        rename = "object_reference",
951        alias = "object_id",
952        skip_serializing_if = "Option::is_none"
953    )]
954    object_id: Option<String>,
955    #[serde(skip_serializing_if = "Option::is_none")]
956    label_name: Option<String>,
957    #[serde(skip_serializing_if = "Option::is_none")]
958    label_index: Option<u64>,
959    #[serde(skip_serializing_if = "Option::is_none")]
960    box2d: Option<Box2d>,
961    #[serde(skip_serializing_if = "Option::is_none")]
962    box3d: Option<Box3d>,
963    #[serde(skip_serializing_if = "Option::is_none")]
964    mask: Option<Mask>,
965}
966
967impl Default for Annotation {
968    fn default() -> Self {
969        Self::new()
970    }
971}
972
973impl Annotation {
974    pub fn new() -> Self {
975        Self {
976            sample_id: None,
977            name: None,
978            sequence_name: None,
979            frame_number: None,
980            group: None,
981            object_id: None,
982            label_name: None,
983            label_index: None,
984            box2d: None,
985            box3d: None,
986            mask: None,
987        }
988    }
989
990    pub fn set_sample_id(&mut self, sample_id: Option<SampleID>) {
991        self.sample_id = sample_id;
992    }
993
994    pub fn sample_id(&self) -> Option<SampleID> {
995        self.sample_id
996    }
997
998    pub fn set_name(&mut self, name: Option<String>) {
999        self.name = name;
1000    }
1001
1002    pub fn name(&self) -> Option<&String> {
1003        self.name.as_ref()
1004    }
1005
1006    pub fn set_sequence_name(&mut self, sequence_name: Option<String>) {
1007        self.sequence_name = sequence_name;
1008    }
1009
1010    pub fn sequence_name(&self) -> Option<&String> {
1011        self.sequence_name.as_ref()
1012    }
1013
1014    pub fn set_frame_number(&mut self, frame_number: Option<u32>) {
1015        self.frame_number = frame_number;
1016    }
1017
1018    pub fn frame_number(&self) -> Option<u32> {
1019        self.frame_number
1020    }
1021
1022    pub fn set_group(&mut self, group: Option<String>) {
1023        self.group = group;
1024    }
1025
1026    pub fn group(&self) -> Option<&String> {
1027        self.group.as_ref()
1028    }
1029
1030    pub fn object_id(&self) -> Option<&String> {
1031        self.object_id.as_ref()
1032    }
1033
1034    pub fn set_object_id(&mut self, object_id: Option<String>) {
1035        self.object_id = object_id;
1036    }
1037
1038    #[deprecated(note = "renamed to object_id")]
1039    pub fn object_reference(&self) -> Option<&String> {
1040        self.object_id()
1041    }
1042
1043    #[deprecated(note = "renamed to set_object_id")]
1044    pub fn set_object_reference(&mut self, object_reference: Option<String>) {
1045        self.set_object_id(object_reference);
1046    }
1047
1048    pub fn label(&self) -> Option<&String> {
1049        self.label_name.as_ref()
1050    }
1051
1052    pub fn set_label(&mut self, label_name: Option<String>) {
1053        self.label_name = label_name;
1054    }
1055
1056    pub fn label_index(&self) -> Option<u64> {
1057        self.label_index
1058    }
1059
1060    pub fn set_label_index(&mut self, label_index: Option<u64>) {
1061        self.label_index = label_index;
1062    }
1063
1064    pub fn box2d(&self) -> Option<&Box2d> {
1065        self.box2d.as_ref()
1066    }
1067
1068    pub fn set_box2d(&mut self, box2d: Option<Box2d>) {
1069        self.box2d = box2d;
1070    }
1071
1072    pub fn box3d(&self) -> Option<&Box3d> {
1073        self.box3d.as_ref()
1074    }
1075
1076    pub fn set_box3d(&mut self, box3d: Option<Box3d>) {
1077        self.box3d = box3d;
1078    }
1079
1080    pub fn mask(&self) -> Option<&Mask> {
1081        self.mask.as_ref()
1082    }
1083
1084    pub fn set_mask(&mut self, mask: Option<Mask>) {
1085        self.mask = mask;
1086    }
1087}
1088
1089#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
1090pub struct Label {
1091    id: u64,
1092    dataset_id: DatasetID,
1093    index: u64,
1094    name: String,
1095}
1096
1097impl Label {
1098    pub fn id(&self) -> u64 {
1099        self.id
1100    }
1101
1102    pub fn dataset_id(&self) -> DatasetID {
1103        self.dataset_id
1104    }
1105
1106    pub fn index(&self) -> u64 {
1107        self.index
1108    }
1109
1110    pub fn name(&self) -> &str {
1111        &self.name
1112    }
1113
1114    pub async fn remove(&self, client: &Client) -> Result<(), Error> {
1115        client.remove_label(self.id()).await
1116    }
1117
1118    pub async fn set_name(&mut self, client: &Client, name: &str) -> Result<(), Error> {
1119        self.name = name.to_string();
1120        client.update_label(self).await
1121    }
1122
1123    pub async fn set_index(&mut self, client: &Client, index: u64) -> Result<(), Error> {
1124        self.index = index;
1125        client.update_label(self).await
1126    }
1127}
1128
1129impl Display for Label {
1130    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1131        write!(f, "{}", self.name())
1132    }
1133}
1134
1135#[derive(Serialize, Clone, Debug)]
1136pub struct NewLabelObject {
1137    pub name: String,
1138}
1139
1140#[derive(Serialize, Clone, Debug)]
1141pub struct NewLabel {
1142    pub dataset_id: DatasetID,
1143    pub labels: Vec<NewLabelObject>,
1144}
1145
1146#[derive(Deserialize, Clone, Debug)]
1147#[allow(dead_code)]
1148pub struct Group {
1149    pub id: u64, // Groups seem to use raw u64, not a specific ID type
1150    pub name: String,
1151}
1152
1153#[cfg(feature = "polars")]
1154fn extract_annotation_name(ann: &Annotation) -> Option<(String, Option<u32>)> {
1155    use std::path::Path;
1156
1157    let name = ann.name.as_ref()?;
1158    let name = Path::new(name).file_stem()?.to_str()?;
1159
1160    // For sequences, return base name and frame number
1161    // For non-sequences, return name and None
1162    match &ann.sequence_name {
1163        Some(sequence) => Some((sequence.clone(), ann.frame_number)),
1164        None => Some((name.to_string(), None)),
1165    }
1166}
1167
1168#[cfg(feature = "polars")]
1169fn convert_mask_to_series(mask: &Mask) -> Series {
1170    use polars::series::Series;
1171
1172    let list = flatten_polygon_coordinates(&mask.polygon);
1173    Series::new("mask".into(), list)
1174}
1175
1176/// Create a DataFrame from a slice of annotations (2025.01 schema).
1177///
1178/// **DEPRECATED**: Use [`samples_dataframe()`] instead for full 2025.10 schema
1179/// support including optional metadata columns (size, location, pose,
1180/// degradation).
1181///
1182/// This function generates a DataFrame with the original 9-column schema.
1183/// It remains functional for backward compatibility but does not include
1184/// new optional columns added in version 2025.10.
1185///
1186/// # Schema (2025.01)
1187///
1188/// - `name`: Sample name (String)
1189/// - `frame`: Frame number (UInt64)
1190/// - `object_id`: Object tracking ID (String)
1191/// - `label`: Object label (Categorical)
1192/// - `label_index`: Label index (UInt64)
1193/// - `group`: Dataset group (Categorical)
1194/// - `mask`: Segmentation mask (List<Float32>)
1195/// - `box2d`: 2D bounding box [cx, cy, w, h] (Array<Float32, 4>)
1196/// - `box3d`: 3D bounding box [x, y, z, w, h, l] (Array<Float32, 6>)
1197///
1198/// # Migration
1199///
1200/// ```rust,no_run
1201/// use edgefirst_client::{Client, samples_dataframe};
1202///
1203/// # async fn example() -> Result<(), edgefirst_client::Error> {
1204/// # let client = Client::new()?;
1205/// # let dataset_id = 1.into();
1206/// # let annotation_set_id = 1.into();
1207/// # let groups = vec![];
1208/// # let types = vec![];
1209/// // OLD (deprecated):
1210/// let annotations = client
1211///     .annotations(annotation_set_id, &groups, &types, None)
1212///     .await?;
1213/// let df = edgefirst_client::annotations_dataframe(&annotations)?;
1214///
1215/// // NEW (recommended):
1216/// let samples = client
1217///     .samples(
1218///         dataset_id,
1219///         Some(annotation_set_id),
1220///         &types,
1221///         &groups,
1222///         &[],
1223///         None,
1224///     )
1225///     .await?;
1226/// let df = samples_dataframe(&samples)?;
1227/// # Ok(())
1228/// # }
1229/// ```
1230#[deprecated(
1231    since = "0.8.0",
1232    note = "Use `samples_dataframe()` for complete 2025.10 schema support"
1233)]
1234#[cfg(feature = "polars")]
1235pub fn annotations_dataframe(annotations: &[Annotation]) -> Result<DataFrame, Error> {
1236    use itertools::Itertools;
1237
1238    let (names, frames, objects, labels, label_indices, groups, masks, boxes2d, boxes3d) =
1239        annotations
1240            .iter()
1241            .filter_map(|ann| {
1242                let (name, frame) = extract_annotation_name(ann)?;
1243
1244                let masks = ann.mask.as_ref().map(convert_mask_to_series);
1245
1246                let box2d = ann.box2d.as_ref().map(|box2d| {
1247                    Series::new(
1248                        "box2d".into(),
1249                        [box2d.cx(), box2d.cy(), box2d.width(), box2d.height()],
1250                    )
1251                });
1252
1253                let box3d = ann.box3d.as_ref().map(|box3d| {
1254                    Series::new(
1255                        "box3d".into(),
1256                        [box3d.x, box3d.y, box3d.z, box3d.w, box3d.h, box3d.l],
1257                    )
1258                });
1259
1260                Some((
1261                    name,
1262                    frame,
1263                    ann.object_id().cloned(),
1264                    ann.label_name.clone(),
1265                    ann.label_index,
1266                    ann.group.clone(),
1267                    masks,
1268                    box2d,
1269                    box3d,
1270                ))
1271            })
1272            .multiunzip::<(
1273                Vec<_>, // names
1274                Vec<_>, // frames
1275                Vec<_>, // objects
1276                Vec<_>, // labels
1277                Vec<_>, // label_indices
1278                Vec<_>, // groups
1279                Vec<_>, // masks
1280                Vec<_>, // boxes2d
1281                Vec<_>, // boxes3d
1282            )>();
1283    let names = Series::new("name".into(), names).into();
1284    let frames = Series::new("frame".into(), frames).into();
1285    let objects = Series::new("object_id".into(), objects).into();
1286    let labels = Series::new("label".into(), labels)
1287        .cast(&DataType::Categorical(
1288            Categories::new("labels".into(), "labels".into(), CategoricalPhysical::U8),
1289            Arc::new(CategoricalMapping::new(u8::MAX as usize)),
1290        ))?
1291        .into();
1292    let label_indices = Series::new("label_index".into(), label_indices).into();
1293    let groups = Series::new("group".into(), groups)
1294        .cast(&DataType::Categorical(
1295            Categories::new("groups".into(), "groups".into(), CategoricalPhysical::U8),
1296            Arc::new(CategoricalMapping::new(u8::MAX as usize)),
1297        ))?
1298        .into();
1299    let masks = Series::new("mask".into(), masks)
1300        .cast(&DataType::List(Box::new(DataType::Float32)))?
1301        .into();
1302    let boxes2d = Series::new("box2d".into(), boxes2d)
1303        .cast(&DataType::Array(Box::new(DataType::Float32), 4))?
1304        .into();
1305    let boxes3d = Series::new("box3d".into(), boxes3d)
1306        .cast(&DataType::Array(Box::new(DataType::Float32), 6))?
1307        .into();
1308
1309    Ok(DataFrame::new(vec![
1310        names,
1311        frames,
1312        objects,
1313        labels,
1314        label_indices,
1315        groups,
1316        masks,
1317        boxes2d,
1318        boxes3d,
1319    ])?)
1320}
1321
1322/// Create a DataFrame from a slice of samples with complete 2025.10 schema.
1323///
1324/// This function generates a DataFrame with all 13 columns including optional
1325/// sample metadata (size, location, pose, degradation). Each annotation in
1326/// each sample becomes one row in the DataFrame.
1327///
1328/// # Schema (2025.10)
1329///
1330/// - `name`: Sample name (String)
1331/// - `frame`: Frame number (UInt64)
1332/// - `object_id`: Object tracking ID (String)
1333/// - `label`: Object label (Categorical)
1334/// - `label_index`: Label index (UInt64)
1335/// - `group`: Dataset group (Categorical)
1336/// - `mask`: Segmentation mask (List<Float32>)
1337/// - `box2d`: 2D bounding box [cx, cy, w, h] (Array<Float32, 4>)
1338/// - `box3d`: 3D bounding box [x, y, z, w, h, l] (Array<Float32, 6>)
1339/// - `size`: Image size [width, height] (Array<UInt32, 2>) - OPTIONAL
1340/// - `location`: GPS [lat, lon] (Array<Float32, 2>) - OPTIONAL
1341/// - `pose`: IMU [yaw, pitch, roll] (Array<Float32, 3>) - OPTIONAL
1342/// - `degradation`: Image degradation (String) - OPTIONAL
1343///
1344/// # Example
1345///
1346/// ```rust,no_run
1347/// use edgefirst_client::{Client, samples_dataframe};
1348///
1349/// # async fn example() -> Result<(), edgefirst_client::Error> {
1350/// # let client = Client::new()?;
1351/// # let dataset_id = 1.into();
1352/// # let annotation_set_id = 1.into();
1353/// let samples = client
1354///     .samples(dataset_id, Some(annotation_set_id), &[], &[], &[], None)
1355///     .await?;
1356/// let df = samples_dataframe(&samples)?;
1357/// println!("DataFrame shape: {:?}", df.shape());
1358/// # Ok(())
1359/// # }
1360/// ```
1361#[cfg(feature = "polars")]
1362pub fn samples_dataframe(samples: &[Sample]) -> Result<DataFrame, Error> {
1363    // Flatten samples into annotation rows with sample metadata
1364    let rows: Vec<_> = samples
1365        .iter()
1366        .flat_map(|sample| {
1367            // Extract sample metadata once per sample
1368            let size = match (sample.width, sample.height) {
1369                (Some(w), Some(h)) => Some(vec![w, h]),
1370                _ => None,
1371            };
1372
1373            let location = sample.location.as_ref().and_then(|loc| {
1374                loc.gps
1375                    .as_ref()
1376                    .map(|gps| vec![gps.lat as f32, gps.lon as f32])
1377            });
1378
1379            let pose = sample.location.as_ref().and_then(|loc| {
1380                loc.imu
1381                    .as_ref()
1382                    .map(|imu| vec![imu.yaw as f32, imu.pitch as f32, imu.roll as f32])
1383            });
1384
1385            let degradation = sample.degradation.clone();
1386
1387            // If no annotations, create one row for the sample (null annotations)
1388            if sample.annotations.is_empty() {
1389                let (name, frame) = match extract_annotation_name_from_sample(sample) {
1390                    Some(nf) => nf,
1391                    None => return vec![],
1392                };
1393
1394                return vec![(
1395                    name,
1396                    frame,
1397                    None,                 // object_id placeholder for now
1398                    None,                 // label
1399                    None,                 // label_index
1400                    sample.group.clone(), // group
1401                    None,                 // mask
1402                    None,                 // box2d
1403                    None,                 // box3d
1404                    size.clone(),
1405                    location.clone(),
1406                    pose.clone(),
1407                    degradation.clone(),
1408                )];
1409            }
1410
1411            // Create one row per annotation
1412            sample
1413                .annotations
1414                .iter()
1415                .filter_map(|ann| {
1416                    let (name, frame) = extract_annotation_name(ann)?;
1417
1418                    let mask = ann.mask.as_ref().map(convert_mask_to_series);
1419
1420                    let box2d = ann.box2d.as_ref().map(|box2d| {
1421                        Series::new(
1422                            "box2d".into(),
1423                            [box2d.cx(), box2d.cy(), box2d.width(), box2d.height()],
1424                        )
1425                    });
1426
1427                    let box3d = ann.box3d.as_ref().map(|box3d| {
1428                        Series::new(
1429                            "box3d".into(),
1430                            [box3d.x, box3d.y, box3d.z, box3d.w, box3d.h, box3d.l],
1431                        )
1432                    });
1433
1434                    Some((
1435                        name,
1436                        frame,
1437                        ann.object_id().cloned(),
1438                        ann.label_name.clone(),
1439                        ann.label_index,
1440                        sample.group.clone(), // Group is on Sample, not Annotation
1441                        mask,
1442                        box2d,
1443                        box3d,
1444                        size.clone(),
1445                        location.clone(),
1446                        pose.clone(),
1447                        degradation.clone(),
1448                    ))
1449                })
1450                .collect::<Vec<_>>()
1451        })
1452        .collect();
1453
1454    // Manually unzip into separate vectors
1455    let mut names = Vec::new();
1456    let mut frames = Vec::new();
1457    let mut objects = Vec::new();
1458    let mut labels = Vec::new();
1459    let mut label_indices = Vec::new();
1460    let mut groups = Vec::new();
1461    let mut masks = Vec::new();
1462    let mut boxes2d = Vec::new();
1463    let mut boxes3d = Vec::new();
1464    let mut sizes = Vec::new();
1465    let mut locations = Vec::new();
1466    let mut poses = Vec::new();
1467    let mut degradations = Vec::new();
1468
1469    for (
1470        name,
1471        frame,
1472        object,
1473        label,
1474        label_index,
1475        group,
1476        mask,
1477        box2d,
1478        box3d,
1479        size,
1480        location,
1481        pose,
1482        degradation,
1483    ) in rows
1484    {
1485        names.push(name);
1486        frames.push(frame);
1487        objects.push(object);
1488        labels.push(label);
1489        label_indices.push(label_index);
1490        groups.push(group);
1491        masks.push(mask);
1492        boxes2d.push(box2d);
1493        boxes3d.push(box3d);
1494        sizes.push(size);
1495        locations.push(location);
1496        poses.push(pose);
1497        degradations.push(degradation);
1498    }
1499
1500    // Build DataFrame columns
1501    let names = Series::new("name".into(), names).into();
1502    let frames = Series::new("frame".into(), frames).into();
1503    let objects = Series::new("object_id".into(), objects).into();
1504
1505    // Column name: "label" (NOT "label_name")
1506    let labels = Series::new("label".into(), labels)
1507        .cast(&DataType::Categorical(
1508            Categories::new("labels".into(), "labels".into(), CategoricalPhysical::U8),
1509            Arc::new(CategoricalMapping::new(u8::MAX as usize)),
1510        ))?
1511        .into();
1512
1513    let label_indices = Series::new("label_index".into(), label_indices).into();
1514
1515    // Column name: "group" (NOT "group_name")
1516    let groups = Series::new("group".into(), groups)
1517        .cast(&DataType::Categorical(
1518            Categories::new("groups".into(), "groups".into(), CategoricalPhysical::U8),
1519            Arc::new(CategoricalMapping::new(u8::MAX as usize)),
1520        ))?
1521        .into();
1522
1523    let masks = Series::new("mask".into(), masks)
1524        .cast(&DataType::List(Box::new(DataType::Float32)))?
1525        .into();
1526    let boxes2d = Series::new("box2d".into(), boxes2d)
1527        .cast(&DataType::Array(Box::new(DataType::Float32), 4))?
1528        .into();
1529    let boxes3d = Series::new("box3d".into(), boxes3d)
1530        .cast(&DataType::Array(Box::new(DataType::Float32), 6))?
1531        .into();
1532
1533    // NEW: Optional columns (2025.10)
1534    // Convert Vec<Option<Vec<T>>> to Vec<Option<Series>> for array columns
1535    let size_series: Vec<Option<Series>> = sizes
1536        .into_iter()
1537        .map(|opt_vec| opt_vec.map(|vec| Series::new("size".into(), vec)))
1538        .collect();
1539    let sizes = Series::new("size".into(), size_series)
1540        .cast(&DataType::Array(Box::new(DataType::UInt32), 2))?
1541        .into();
1542
1543    let location_series: Vec<Option<Series>> = locations
1544        .into_iter()
1545        .map(|opt_vec| opt_vec.map(|vec| Series::new("location".into(), vec)))
1546        .collect();
1547    let locations = Series::new("location".into(), location_series)
1548        .cast(&DataType::Array(Box::new(DataType::Float32), 2))?
1549        .into();
1550
1551    let pose_series: Vec<Option<Series>> = poses
1552        .into_iter()
1553        .map(|opt_vec| opt_vec.map(|vec| Series::new("pose".into(), vec)))
1554        .collect();
1555    let poses = Series::new("pose".into(), pose_series)
1556        .cast(&DataType::Array(Box::new(DataType::Float32), 3))?
1557        .into();
1558
1559    let degradations = Series::new("degradation".into(), degradations).into();
1560
1561    Ok(DataFrame::new(vec![
1562        names,
1563        frames,
1564        objects,
1565        labels,
1566        label_indices,
1567        groups,
1568        masks,
1569        boxes2d,
1570        boxes3d,
1571        sizes,
1572        locations,
1573        poses,
1574        degradations,
1575    ])?)
1576}
1577
1578// Helper: Extract name/frame from Sample (for samples with no annotations)
1579#[cfg(feature = "polars")]
1580fn extract_annotation_name_from_sample(sample: &Sample) -> Option<(String, Option<u32>)> {
1581    use std::path::Path;
1582
1583    let name = sample.image_name.as_ref()?;
1584    let name = Path::new(name).file_stem()?.to_str()?;
1585
1586    // For sequences, return base name and frame number
1587    // For non-sequences, return name and None
1588    match &sample.sequence_name {
1589        Some(sequence) => Some((sequence.clone(), sample.frame_number)),
1590        None => Some((name.to_string(), None)),
1591    }
1592}
1593
1594// ============================================================================
1595// PURE FUNCTIONS FOR TESTABLE CORE LOGIC
1596// ============================================================================
1597
1598/// Extract sample name from image filename by:
1599/// 1. Removing file extension (everything after last dot)
1600/// 2. Removing .camera suffix if present
1601///
1602/// # Examples
1603/// - "scene_001.camera.jpg" → "scene_001"
1604/// - "image.jpg" → "image"
1605/// - ".jpg" → ".jpg" (preserves filenames starting with dot)
1606fn extract_sample_name(image_name: &str) -> String {
1607    // Step 1: Remove file extension (but preserve filenames starting with dot)
1608    let name = image_name
1609        .rsplit_once('.')
1610        .and_then(|(name, _)| {
1611            // Only remove extension if the name part is non-empty (handles ".jpg" case)
1612            if name.is_empty() {
1613                None
1614            } else {
1615                Some(name.to_string())
1616            }
1617        })
1618        .unwrap_or_else(|| image_name.to_string());
1619
1620    // Step 2: Remove .camera suffix if present
1621    name.rsplit_once(".camera")
1622        .and_then(|(name, _)| {
1623            // Only remove .camera if the name part is non-empty
1624            if name.is_empty() {
1625                None
1626            } else {
1627                Some(name.to_string())
1628            }
1629        })
1630        .unwrap_or_else(|| name.clone())
1631}
1632
1633/// Resolve file URL for a given file type from sample data.
1634///
1635/// Pure function that extracts the URL resolution logic from
1636/// `Sample::download()`. Returns `Some(url)` if the file exists, `None`
1637/// otherwise.
1638///
1639/// # Examples
1640/// - Image: Uses `image_url` field
1641/// - Other files: Searches `files` array by type matching
1642///
1643/// # Arguments
1644/// * `file_type` - The type of file to resolve (e.g., "image", "lidar.pcd")
1645/// * `image_url` - The sample's image URL (for FileType::Image)
1646/// * `files` - The sample's file list (for other file types)
1647fn resolve_file_url<'a>(
1648    file_type: &FileType,
1649    image_url: Option<&'a str>,
1650    files: &'a [SampleFile],
1651) -> Option<&'a str> {
1652    match file_type {
1653        FileType::Image => image_url,
1654        file => files
1655            .iter()
1656            .find(|f| f.r#type == file.to_string())
1657            .and_then(|f| f.url.as_deref()),
1658    }
1659}
1660
1661// ============================================================================
1662// DESERIALIZATION FORMAT CONVERSION HELPERS
1663// ============================================================================
1664
1665/// Convert files HashMap format to Vec<SampleFile>.
1666///
1667/// Pure function that handles the conversion from the server's populate API
1668/// format (HashMap<String, String>) to the internal Vec<SampleFile>
1669/// representation.
1670///
1671/// # Arguments
1672/// * `map` - HashMap where key is file type (e.g., "lidar.pcd") and value is
1673///   filename
1674fn convert_files_map_to_vec(map: HashMap<String, String>) -> Vec<SampleFile> {
1675    map.into_iter()
1676        .map(|(file_type, filename)| SampleFile::with_filename(file_type, filename))
1677        .collect()
1678}
1679
1680/// Convert annotations grouped format to flat Vec<Annotation>.
1681///
1682/// Pure function that handles the conversion from the server's legacy format
1683/// (HashMap<String, Vec<Annotation>>) to the flat Vec<Annotation>
1684/// representation.
1685///
1686/// # Arguments
1687/// * `map` - HashMap where keys are annotation types ("bbox", "box3d", "mask")
1688fn convert_annotations_map_to_vec(map: HashMap<String, Vec<Annotation>>) -> Vec<Annotation> {
1689    let mut all_annotations = Vec::new();
1690    if let Some(bbox_anns) = map.get("bbox") {
1691        all_annotations.extend(bbox_anns.clone());
1692    }
1693    if let Some(box3d_anns) = map.get("box3d") {
1694        all_annotations.extend(box3d_anns.clone());
1695    }
1696    if let Some(mask_anns) = map.get("mask") {
1697        all_annotations.extend(mask_anns.clone());
1698    }
1699    all_annotations
1700}
1701
1702// ============================================================================
1703// GPS/IMU VALIDATION HELPERS
1704// ============================================================================
1705
1706/// Validate GPS coordinates are within valid ranges.
1707///
1708/// Pure function that checks if latitude and longitude values are within valid
1709/// geographic ranges. Helps catch data corruption or API issues early.
1710///
1711/// # Arguments
1712/// * `lat` - Latitude in degrees
1713/// * `lon` - Longitude in degrees
1714///
1715/// # Returns
1716/// `Ok(())` if valid, `Err(String)` with descriptive error message otherwise
1717///
1718/// # Valid Ranges
1719/// - Latitude: -90.0 to +90.0 degrees
1720/// - Longitude: -180.0 to +180.0 degrees
1721fn validate_gps_coordinates(lat: f64, lon: f64) -> Result<(), String> {
1722    if !lat.is_finite() {
1723        return Err(format!("GPS latitude is not finite: {}", lat));
1724    }
1725    if !lon.is_finite() {
1726        return Err(format!("GPS longitude is not finite: {}", lon));
1727    }
1728    if !(-90.0..=90.0).contains(&lat) {
1729        return Err(format!("GPS latitude out of range [-90, 90]: {}", lat));
1730    }
1731    if !(-180.0..=180.0).contains(&lon) {
1732        return Err(format!("GPS longitude out of range [-180, 180]: {}", lon));
1733    }
1734    Ok(())
1735}
1736
1737/// Validate IMU orientation angles are within valid ranges.
1738///
1739/// Pure function that checks if roll, pitch, and yaw values are finite and
1740/// within reasonable ranges. Helps catch data corruption or sensor errors
1741/// early.
1742///
1743/// # Arguments
1744/// * `roll` - Roll angle in degrees
1745/// * `pitch` - Pitch angle in degrees
1746/// * `yaw` - Yaw angle in degrees
1747///
1748/// # Returns
1749/// `Ok(())` if valid, `Err(String)` with descriptive error message otherwise
1750///
1751/// # Valid Ranges
1752/// - Roll: -180.0 to +180.0 degrees
1753/// - Pitch: -90.0 to +90.0 degrees (typical gimbal lock range)
1754/// - Yaw: -180.0 to +180.0 degrees (or 0 to 360, normalized)
1755fn validate_imu_orientation(roll: f64, pitch: f64, yaw: f64) -> Result<(), String> {
1756    if !roll.is_finite() {
1757        return Err(format!("IMU roll is not finite: {}", roll));
1758    }
1759    if !pitch.is_finite() {
1760        return Err(format!("IMU pitch is not finite: {}", pitch));
1761    }
1762    if !yaw.is_finite() {
1763        return Err(format!("IMU yaw is not finite: {}", yaw));
1764    }
1765    if !(-180.0..=180.0).contains(&roll) {
1766        return Err(format!("IMU roll out of range [-180, 180]: {}", roll));
1767    }
1768    if !(-90.0..=90.0).contains(&pitch) {
1769        return Err(format!("IMU pitch out of range [-90, 90]: {}", pitch));
1770    }
1771    if !(-180.0..=180.0).contains(&yaw) {
1772        return Err(format!("IMU yaw out of range [-180, 180]: {}", yaw));
1773    }
1774    Ok(())
1775}
1776
1777// ============================================================================
1778// MASK POLYGON CONVERSION HELPERS
1779// ============================================================================
1780
1781/// Flatten polygon coordinates into a flat vector of f32 values for Polars
1782/// Series.
1783///
1784/// Converts nested polygon structure into a flat list of coordinates with
1785/// NaN separators between polygons:
1786/// - Input: [[(x1, y1), (x2, y2)], [(x3, y3)]]
1787/// - Output: [x1, y1, x2, y2, NaN, x3, y3]
1788#[cfg(feature = "polars")]
1789fn flatten_polygon_coordinates(polygons: &[Vec<(f32, f32)>]) -> Vec<f32> {
1790    let mut list = Vec::new();
1791
1792    for polygon in polygons {
1793        for &(x, y) in polygon {
1794            list.push(x);
1795            list.push(y);
1796        }
1797        // Separate polygons with NaN
1798        if !polygons.is_empty() {
1799            list.push(f32::NAN);
1800        }
1801    }
1802
1803    // Remove the last NaN if it exists (trailing separator not needed)
1804    if !list.is_empty() && list[list.len() - 1].is_nan() {
1805        list.pop();
1806    }
1807
1808    list
1809}
1810
1811/// Unflatten coordinates with NaN separators back to nested polygon
1812/// structure.
1813///
1814/// Converts flat list of coordinates with NaN separators back to nested
1815/// polygon structure (inverse of flatten_polygon_coordinates):
1816/// - Input: [x1, y1, x2, y2, NaN, x3, y3]
1817/// - Output: [[(x1, y1), (x2, y2)], [(x3, y3)]]
1818///
1819/// This function is used when parsing Arrow files to reconstruct the nested
1820/// polygon format required by the EdgeFirst Studio API.
1821///
1822/// # Examples
1823///
1824/// ```rust
1825/// use edgefirst_client::unflatten_polygon_coordinates;
1826///
1827/// let coords = vec![1.0, 2.0, 3.0, 4.0, f32::NAN, 5.0, 6.0];
1828/// let polygons = unflatten_polygon_coordinates(&coords);
1829///
1830/// assert_eq!(polygons.len(), 2);
1831/// assert_eq!(polygons[0], vec![(1.0, 2.0), (3.0, 4.0)]);
1832/// assert_eq!(polygons[1], vec![(5.0, 6.0)]);
1833/// ```
1834#[cfg(feature = "polars")]
1835pub fn unflatten_polygon_coordinates(coords: &[f32]) -> Vec<Vec<(f32, f32)>> {
1836    let mut polygons = Vec::new();
1837    let mut current_polygon = Vec::new();
1838    let mut i = 0;
1839
1840    while i < coords.len() {
1841        if coords[i].is_nan() {
1842            // NaN separator - save current polygon and start new one
1843            if !current_polygon.is_empty() {
1844                polygons.push(current_polygon.clone());
1845                current_polygon.clear();
1846            }
1847            i += 1;
1848        } else if i + 1 < coords.len() {
1849            // Have both x and y coordinates
1850            current_polygon.push((coords[i], coords[i + 1]));
1851            i += 2;
1852        } else {
1853            // Odd number of coordinates (malformed data) - skip last value
1854            i += 1;
1855        }
1856    }
1857
1858    // Save the last polygon if not empty
1859    if !current_polygon.is_empty() {
1860        polygons.push(current_polygon);
1861    }
1862
1863    polygons
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868    use super::*;
1869    use std::str::FromStr;
1870
1871    // ============================================================================
1872    // TEST HELPER FUNCTIONS (Pure Logic for Testing)
1873    // ============================================================================
1874
1875    /// Flatten legacy grouped annotation format to a single vector.
1876    ///
1877    /// Converts HashMap<String, Vec<Annotation>> (with bbox/box3d/mask keys)
1878    /// into a flat Vec<Annotation> in deterministic order.
1879    fn flatten_annotation_map(
1880        map: std::collections::HashMap<String, Vec<Annotation>>,
1881    ) -> Vec<Annotation> {
1882        let mut all_annotations = Vec::new();
1883
1884        // Process in fixed order for deterministic results
1885        for key in ["bbox", "box3d", "mask"] {
1886            if let Some(mut anns) = map.get(key).cloned() {
1887                all_annotations.append(&mut anns);
1888            }
1889        }
1890
1891        all_annotations
1892    }
1893
1894    /// Get the JSON field name for the Annotation group field (for tests).
1895    fn annotation_group_field_name() -> &'static str {
1896        "group_name"
1897    }
1898
1899    /// Get the JSON field name for the Annotation object_id field (for tests).
1900    fn annotation_object_id_field_name() -> &'static str {
1901        "object_reference"
1902    }
1903
1904    /// Get the accepted alias for the Annotation object_id field (for tests).
1905    fn annotation_object_id_alias() -> &'static str {
1906        "object_id"
1907    }
1908
1909    /// Validate that annotation field names match expected values in JSON (for
1910    /// tests).
1911    fn validate_annotation_field_names(
1912        json_str: &str,
1913        expected_group: bool,
1914        expected_object_ref: bool,
1915    ) -> Result<(), String> {
1916        if expected_group && !json_str.contains("\"group_name\"") {
1917            return Err("Missing expected field: group_name".to_string());
1918        }
1919        if expected_object_ref && !json_str.contains("\"object_reference\"") {
1920            return Err("Missing expected field: object_reference".to_string());
1921        }
1922        Ok(())
1923    }
1924
1925    // ==== FileType Conversion Tests ====
1926    #[test]
1927    fn test_file_type_conversions() {
1928        let cases = vec![
1929            (FileType::Image, "image"),
1930            (FileType::LidarPcd, "lidar.pcd"),
1931            (FileType::LidarDepth, "lidar.png"),
1932            (FileType::LidarReflect, "lidar.jpg"),
1933            (FileType::RadarPcd, "radar.pcd"),
1934            (FileType::RadarCube, "radar.png"),
1935        ];
1936
1937        // Test: Display → to_string()
1938        for (file_type, expected_str) in &cases {
1939            assert_eq!(file_type.to_string(), *expected_str);
1940        }
1941
1942        // Test: try_from() string parsing
1943        for (file_type, type_str) in &cases {
1944            assert_eq!(FileType::try_from(*type_str).unwrap(), *file_type);
1945        }
1946
1947        // Test: FromStr trait
1948        for (file_type, type_str) in &cases {
1949            assert_eq!(FileType::from_str(type_str).unwrap(), *file_type);
1950        }
1951
1952        // Test: Invalid input
1953        assert!(FileType::try_from("invalid").is_err());
1954
1955        // Test: Round-trip (Display → try_from)
1956        for (file_type, _) in &cases {
1957            let s = file_type.to_string();
1958            let parsed = FileType::try_from(s.as_str()).unwrap();
1959            assert_eq!(parsed, *file_type);
1960        }
1961    }
1962
1963    // ==== AnnotationType Conversion Tests ====
1964    #[test]
1965    fn test_annotation_type_conversions() {
1966        let cases = vec![
1967            (AnnotationType::Box2d, "box2d"),
1968            (AnnotationType::Box3d, "box3d"),
1969            (AnnotationType::Mask, "mask"),
1970        ];
1971
1972        // Test: Display → to_string()
1973        for (ann_type, expected_str) in &cases {
1974            assert_eq!(ann_type.to_string(), *expected_str);
1975        }
1976
1977        // Test: try_from() string parsing
1978        for (ann_type, type_str) in &cases {
1979            assert_eq!(AnnotationType::try_from(*type_str).unwrap(), *ann_type);
1980        }
1981
1982        // Test: From<String> (backward compatibility)
1983        assert_eq!(
1984            AnnotationType::from("box2d".to_string()),
1985            AnnotationType::Box2d
1986        );
1987        assert_eq!(
1988            AnnotationType::from("box3d".to_string()),
1989            AnnotationType::Box3d
1990        );
1991        assert_eq!(
1992            AnnotationType::from("mask".to_string()),
1993            AnnotationType::Mask
1994        );
1995
1996        // Invalid defaults to Box2d for backward compatibility
1997        assert_eq!(
1998            AnnotationType::from("invalid".to_string()),
1999            AnnotationType::Box2d
2000        );
2001
2002        // Test: Invalid input
2003        assert!(AnnotationType::try_from("invalid").is_err());
2004
2005        // Test: Round-trip (Display → try_from)
2006        for (ann_type, _) in &cases {
2007            let s = ann_type.to_string();
2008            let parsed = AnnotationType::try_from(s.as_str()).unwrap();
2009            assert_eq!(parsed, *ann_type);
2010        }
2011    }
2012
2013    // ==== Pure Function: extract_sample_name Tests ====
2014    #[test]
2015    fn test_extract_sample_name_with_extension_and_camera() {
2016        assert_eq!(extract_sample_name("scene_001.camera.jpg"), "scene_001");
2017    }
2018
2019    #[test]
2020    fn test_extract_sample_name_multiple_dots() {
2021        assert_eq!(extract_sample_name("image.v2.camera.png"), "image.v2");
2022    }
2023
2024    #[test]
2025    fn test_extract_sample_name_extension_only() {
2026        assert_eq!(extract_sample_name("test.jpg"), "test");
2027    }
2028
2029    #[test]
2030    fn test_extract_sample_name_no_extension() {
2031        assert_eq!(extract_sample_name("test"), "test");
2032    }
2033
2034    #[test]
2035    fn test_extract_sample_name_edge_case_dot_prefix() {
2036        assert_eq!(extract_sample_name(".jpg"), ".jpg");
2037    }
2038
2039    // ==== File URL Resolution Tests ====
2040    #[test]
2041    fn test_resolve_file_url_image_type() {
2042        let image_url = Some("https://example.com/image.jpg");
2043        let files = vec![];
2044        let result = resolve_file_url(&FileType::Image, image_url, &files);
2045        assert_eq!(result, Some("https://example.com/image.jpg"));
2046    }
2047
2048    #[test]
2049    fn test_resolve_file_url_lidar_pcd() {
2050        let image_url = Some("https://example.com/image.jpg");
2051        let files = vec![
2052            SampleFile::with_url(
2053                "lidar.pcd".to_string(),
2054                "https://example.com/file.pcd".to_string(),
2055            ),
2056            SampleFile::with_url(
2057                "radar.pcd".to_string(),
2058                "https://example.com/radar.pcd".to_string(),
2059            ),
2060        ];
2061        let result = resolve_file_url(&FileType::LidarPcd, image_url, &files);
2062        assert_eq!(result, Some("https://example.com/file.pcd"));
2063    }
2064
2065    #[test]
2066    fn test_resolve_file_url_not_found() {
2067        let image_url = Some("https://example.com/image.jpg");
2068        let files = vec![SampleFile::with_url(
2069            "lidar.pcd".to_string(),
2070            "https://example.com/file.pcd".to_string(),
2071        )];
2072        // Requesting radar.pcd which doesn't exist in files
2073        let result = resolve_file_url(&FileType::RadarPcd, image_url, &files);
2074        assert_eq!(result, None);
2075    }
2076
2077    #[test]
2078    fn test_resolve_file_url_no_image_url() {
2079        let image_url = None;
2080        let files = vec![];
2081        let result = resolve_file_url(&FileType::Image, image_url, &files);
2082        assert_eq!(result, None);
2083    }
2084
2085    // ==== Format Conversion Tests ====
2086    #[test]
2087    fn test_convert_files_map_to_vec_single_file() {
2088        let mut map = HashMap::new();
2089        map.insert("lidar.pcd".to_string(), "scan001.pcd".to_string());
2090
2091        let files = convert_files_map_to_vec(map);
2092        assert_eq!(files.len(), 1);
2093        assert_eq!(files[0].file_type(), "lidar.pcd");
2094        assert_eq!(files[0].filename(), Some("scan001.pcd"));
2095    }
2096
2097    #[test]
2098    fn test_convert_files_map_to_vec_multiple_files() {
2099        let mut map = HashMap::new();
2100        map.insert("lidar.pcd".to_string(), "scan.pcd".to_string());
2101        map.insert("radar.pcd".to_string(), "radar.pcd".to_string());
2102
2103        let files = convert_files_map_to_vec(map);
2104        assert_eq!(files.len(), 2);
2105    }
2106
2107    #[test]
2108    fn test_convert_files_map_to_vec_empty() {
2109        let map = HashMap::new();
2110        let files = convert_files_map_to_vec(map);
2111        assert_eq!(files.len(), 0);
2112    }
2113
2114    #[test]
2115    fn test_convert_annotations_map_to_vec_with_bbox() {
2116        let mut map = HashMap::new();
2117        let bbox_ann = Annotation::new();
2118        map.insert("bbox".to_string(), vec![bbox_ann.clone()]);
2119
2120        let annotations = convert_annotations_map_to_vec(map);
2121        assert_eq!(annotations.len(), 1);
2122    }
2123
2124    #[test]
2125    fn test_convert_annotations_map_to_vec_all_types() {
2126        let mut map = HashMap::new();
2127        map.insert("bbox".to_string(), vec![Annotation::new()]);
2128        map.insert("box3d".to_string(), vec![Annotation::new()]);
2129        map.insert("mask".to_string(), vec![Annotation::new()]);
2130
2131        let annotations = convert_annotations_map_to_vec(map);
2132        assert_eq!(annotations.len(), 3);
2133    }
2134
2135    #[test]
2136    fn test_convert_annotations_map_to_vec_empty() {
2137        let map = HashMap::new();
2138        let annotations = convert_annotations_map_to_vec(map);
2139        assert_eq!(annotations.len(), 0);
2140    }
2141
2142    #[test]
2143    fn test_convert_annotations_map_to_vec_unknown_type_ignored() {
2144        let mut map = HashMap::new();
2145        map.insert("unknown".to_string(), vec![Annotation::new()]);
2146
2147        let annotations = convert_annotations_map_to_vec(map);
2148        // Unknown types are ignored
2149        assert_eq!(annotations.len(), 0);
2150    }
2151
2152    // ==== Annotation Field Mapping Tests ====
2153    #[test]
2154    fn test_annotation_group_field_name() {
2155        assert_eq!(annotation_group_field_name(), "group_name");
2156    }
2157
2158    #[test]
2159    fn test_annotation_object_id_field_name() {
2160        assert_eq!(annotation_object_id_field_name(), "object_reference");
2161    }
2162
2163    #[test]
2164    fn test_annotation_object_id_alias() {
2165        assert_eq!(annotation_object_id_alias(), "object_id");
2166    }
2167
2168    #[test]
2169    fn test_validate_annotation_field_names_success() {
2170        let json = r#"{"group_name":"train","object_reference":"obj1"}"#;
2171        assert!(validate_annotation_field_names(json, true, true).is_ok());
2172    }
2173
2174    #[test]
2175    fn test_validate_annotation_field_names_missing_group() {
2176        let json = r#"{"object_reference":"obj1"}"#;
2177        let result = validate_annotation_field_names(json, true, false);
2178        assert!(result.is_err());
2179        assert!(result.unwrap_err().contains("group_name"));
2180    }
2181
2182    #[test]
2183    fn test_validate_annotation_field_names_missing_object_ref() {
2184        let json = r#"{"group_name":"train"}"#;
2185        let result = validate_annotation_field_names(json, false, true);
2186        assert!(result.is_err());
2187        assert!(result.unwrap_err().contains("object_reference"));
2188    }
2189
2190    #[test]
2191    fn test_annotation_serialization_field_names() {
2192        // Test that Annotation serializes with correct field names
2193        let mut ann = Annotation::new();
2194        ann.set_group(Some("train".to_string()));
2195        ann.set_object_id(Some("obj1".to_string()));
2196
2197        let json = serde_json::to_string(&ann).unwrap();
2198        // Verify JSON contains correct field names
2199        assert!(validate_annotation_field_names(&json, true, true).is_ok());
2200    }
2201
2202    // ==== GPS/IMU Validation Tests ====
2203    #[test]
2204    fn test_validate_gps_coordinates_valid() {
2205        assert!(validate_gps_coordinates(37.7749, -122.4194).is_ok()); // San Francisco
2206        assert!(validate_gps_coordinates(0.0, 0.0).is_ok()); // Null Island
2207        assert!(validate_gps_coordinates(90.0, 180.0).is_ok()); // Edge cases
2208        assert!(validate_gps_coordinates(-90.0, -180.0).is_ok()); // Edge cases
2209    }
2210
2211    #[test]
2212    fn test_validate_gps_coordinates_invalid_latitude() {
2213        let result = validate_gps_coordinates(91.0, 0.0);
2214        assert!(result.is_err());
2215        assert!(result.unwrap_err().contains("latitude out of range"));
2216
2217        let result = validate_gps_coordinates(-91.0, 0.0);
2218        assert!(result.is_err());
2219        assert!(result.unwrap_err().contains("latitude out of range"));
2220    }
2221
2222    #[test]
2223    fn test_validate_gps_coordinates_invalid_longitude() {
2224        let result = validate_gps_coordinates(0.0, 181.0);
2225        assert!(result.is_err());
2226        assert!(result.unwrap_err().contains("longitude out of range"));
2227
2228        let result = validate_gps_coordinates(0.0, -181.0);
2229        assert!(result.is_err());
2230        assert!(result.unwrap_err().contains("longitude out of range"));
2231    }
2232
2233    #[test]
2234    fn test_validate_gps_coordinates_non_finite() {
2235        let result = validate_gps_coordinates(f64::NAN, 0.0);
2236        assert!(result.is_err());
2237        assert!(result.unwrap_err().contains("not finite"));
2238
2239        let result = validate_gps_coordinates(0.0, f64::INFINITY);
2240        assert!(result.is_err());
2241        assert!(result.unwrap_err().contains("not finite"));
2242    }
2243
2244    #[test]
2245    fn test_validate_imu_orientation_valid() {
2246        assert!(validate_imu_orientation(0.0, 0.0, 0.0).is_ok());
2247        assert!(validate_imu_orientation(45.0, 30.0, 90.0).is_ok());
2248        assert!(validate_imu_orientation(180.0, 90.0, -180.0).is_ok()); // Edge cases
2249        assert!(validate_imu_orientation(-180.0, -90.0, 180.0).is_ok()); // Edge cases
2250    }
2251
2252    #[test]
2253    fn test_validate_imu_orientation_invalid_roll() {
2254        let result = validate_imu_orientation(181.0, 0.0, 0.0);
2255        assert!(result.is_err());
2256        assert!(result.unwrap_err().contains("roll out of range"));
2257
2258        let result = validate_imu_orientation(-181.0, 0.0, 0.0);
2259        assert!(result.is_err());
2260    }
2261
2262    #[test]
2263    fn test_validate_imu_orientation_invalid_pitch() {
2264        let result = validate_imu_orientation(0.0, 91.0, 0.0);
2265        assert!(result.is_err());
2266        assert!(result.unwrap_err().contains("pitch out of range"));
2267
2268        let result = validate_imu_orientation(0.0, -91.0, 0.0);
2269        assert!(result.is_err());
2270    }
2271
2272    #[test]
2273    fn test_validate_imu_orientation_non_finite() {
2274        let result = validate_imu_orientation(f64::NAN, 0.0, 0.0);
2275        assert!(result.is_err());
2276        assert!(result.unwrap_err().contains("not finite"));
2277
2278        let result = validate_imu_orientation(0.0, f64::INFINITY, 0.0);
2279        assert!(result.is_err());
2280
2281        let result = validate_imu_orientation(0.0, 0.0, f64::NEG_INFINITY);
2282        assert!(result.is_err());
2283    }
2284
2285    // ==== Polygon Flattening Tests ====
2286    #[test]
2287    #[cfg(feature = "polars")]
2288    fn test_flatten_polygon_coordinates_single_polygon() {
2289        let polygons = vec![vec![(1.0, 2.0), (3.0, 4.0)]];
2290        let result = flatten_polygon_coordinates(&polygons);
2291
2292        // Should have x1, y1, x2, y2 (no trailing NaN)
2293        assert_eq!(result.len(), 4);
2294        assert_eq!(&result[..4], &[1.0, 2.0, 3.0, 4.0]);
2295    }
2296
2297    #[test]
2298    #[cfg(feature = "polars")]
2299    fn test_flatten_polygon_coordinates_multiple_polygons() {
2300        let polygons = vec![vec![(1.0, 2.0), (3.0, 4.0)], vec![(5.0, 6.0), (7.0, 8.0)]];
2301        let result = flatten_polygon_coordinates(&polygons);
2302
2303        // x1, y1, x2, y2, NaN, x3, y3, x4, y4 (no trailing NaN)
2304        assert_eq!(result.len(), 9);
2305        assert_eq!(&result[..4], &[1.0, 2.0, 3.0, 4.0]);
2306        assert!(result[4].is_nan()); // NaN separator
2307        assert_eq!(&result[5..9], &[5.0, 6.0, 7.0, 8.0]);
2308    }
2309
2310    #[test]
2311    #[cfg(feature = "polars")]
2312    fn test_flatten_polygon_coordinates_empty() {
2313        let polygons: Vec<Vec<(f32, f32)>> = vec![];
2314        let result = flatten_polygon_coordinates(&polygons);
2315
2316        assert_eq!(result.len(), 0);
2317    }
2318
2319    // ==== Polygon Unflattening Tests ====
2320    #[test]
2321    #[cfg(feature = "polars")]
2322    fn test_unflatten_polygon_coordinates_single_polygon() {
2323        let coords = vec![1.0, 2.0, 3.0, 4.0];
2324        let result = unflatten_polygon_coordinates(&coords);
2325
2326        assert_eq!(result.len(), 1);
2327        assert_eq!(result[0].len(), 2);
2328        assert_eq!(result[0][0], (1.0, 2.0));
2329        assert_eq!(result[0][1], (3.0, 4.0));
2330    }
2331
2332    #[test]
2333    #[cfg(feature = "polars")]
2334    fn test_unflatten_polygon_coordinates_multiple_polygons() {
2335        let coords = vec![1.0, 2.0, 3.0, 4.0, f32::NAN, 5.0, 6.0, 7.0, 8.0];
2336        let result = unflatten_polygon_coordinates(&coords);
2337
2338        assert_eq!(result.len(), 2);
2339        assert_eq!(result[0].len(), 2);
2340        assert_eq!(result[0][0], (1.0, 2.0));
2341        assert_eq!(result[0][1], (3.0, 4.0));
2342        assert_eq!(result[1].len(), 2);
2343        assert_eq!(result[1][0], (5.0, 6.0));
2344        assert_eq!(result[1][1], (7.0, 8.0));
2345    }
2346
2347    #[test]
2348    #[cfg(feature = "polars")]
2349    fn test_unflatten_polygon_coordinates_roundtrip() {
2350        // Test that flatten -> unflatten produces the same result
2351        let original = vec![vec![(1.0, 2.0), (3.0, 4.0)], vec![(5.0, 6.0), (7.0, 8.0)]];
2352        let flattened = flatten_polygon_coordinates(&original);
2353        let result = unflatten_polygon_coordinates(&flattened);
2354
2355        assert_eq!(result, original);
2356    }
2357
2358    // ==== Annotation Format Flattening Tests ====
2359    #[test]
2360    fn test_flatten_annotation_map_all_types() {
2361        use std::collections::HashMap;
2362
2363        let mut map = HashMap::new();
2364
2365        // Create test annotations
2366        let mut bbox_ann = Annotation::new();
2367        bbox_ann.set_label(Some("bbox_label".to_string()));
2368
2369        let mut box3d_ann = Annotation::new();
2370        box3d_ann.set_label(Some("box3d_label".to_string()));
2371
2372        let mut mask_ann = Annotation::new();
2373        mask_ann.set_label(Some("mask_label".to_string()));
2374
2375        map.insert("bbox".to_string(), vec![bbox_ann.clone()]);
2376        map.insert("box3d".to_string(), vec![box3d_ann.clone()]);
2377        map.insert("mask".to_string(), vec![mask_ann.clone()]);
2378
2379        let result = flatten_annotation_map(map);
2380
2381        assert_eq!(result.len(), 3);
2382        // Check ordering: bbox, box3d, mask
2383        assert_eq!(result[0].label(), Some(&"bbox_label".to_string()));
2384        assert_eq!(result[1].label(), Some(&"box3d_label".to_string()));
2385        assert_eq!(result[2].label(), Some(&"mask_label".to_string()));
2386    }
2387
2388    #[test]
2389    fn test_flatten_annotation_map_single_type() {
2390        use std::collections::HashMap;
2391
2392        let mut map = HashMap::new();
2393        let mut bbox_ann = Annotation::new();
2394        bbox_ann.set_label(Some("test".to_string()));
2395        map.insert("bbox".to_string(), vec![bbox_ann]);
2396
2397        let result = flatten_annotation_map(map);
2398
2399        assert_eq!(result.len(), 1);
2400        assert_eq!(result[0].label(), Some(&"test".to_string()));
2401    }
2402
2403    #[test]
2404    fn test_flatten_annotation_map_empty() {
2405        use std::collections::HashMap;
2406
2407        let map = HashMap::new();
2408        let result = flatten_annotation_map(map);
2409
2410        assert_eq!(result.len(), 0);
2411    }
2412
2413    #[test]
2414    fn test_flatten_annotation_map_deterministic_order() {
2415        use std::collections::HashMap;
2416
2417        let mut map = HashMap::new();
2418
2419        let mut bbox_ann = Annotation::new();
2420        bbox_ann.set_label(Some("bbox".to_string()));
2421
2422        let mut box3d_ann = Annotation::new();
2423        box3d_ann.set_label(Some("box3d".to_string()));
2424
2425        let mut mask_ann = Annotation::new();
2426        mask_ann.set_label(Some("mask".to_string()));
2427
2428        // Insert in reverse order to test deterministic ordering
2429        map.insert("mask".to_string(), vec![mask_ann]);
2430        map.insert("box3d".to_string(), vec![box3d_ann]);
2431        map.insert("bbox".to_string(), vec![bbox_ann]);
2432
2433        let result = flatten_annotation_map(map);
2434
2435        // Should be bbox, box3d, mask regardless of insertion order
2436        assert_eq!(result.len(), 3);
2437        assert_eq!(result[0].label(), Some(&"bbox".to_string()));
2438        assert_eq!(result[1].label(), Some(&"box3d".to_string()));
2439        assert_eq!(result[2].label(), Some(&"mask".to_string()));
2440    }
2441
2442    // ==== Box2d Tests ====
2443    #[test]
2444    fn test_box2d_construction_and_accessors() {
2445        // Test case 1: Basic construction with positive coordinates
2446        let bbox = Box2d::new(10.0, 20.0, 100.0, 50.0);
2447        assert_eq!(
2448            (bbox.left(), bbox.top(), bbox.width(), bbox.height()),
2449            (10.0, 20.0, 100.0, 50.0)
2450        );
2451
2452        // Test case 2: Center calculations
2453        assert_eq!((bbox.cx(), bbox.cy()), (60.0, 45.0)); // 10+50, 20+25
2454
2455        // Test case 3: Zero origin
2456        let bbox = Box2d::new(0.0, 0.0, 640.0, 480.0);
2457        assert_eq!(
2458            (bbox.left(), bbox.top(), bbox.width(), bbox.height()),
2459            (0.0, 0.0, 640.0, 480.0)
2460        );
2461        assert_eq!((bbox.cx(), bbox.cy()), (320.0, 240.0));
2462    }
2463
2464    #[test]
2465    fn test_box2d_center_calculation() {
2466        let bbox = Box2d::new(10.0, 20.0, 100.0, 50.0);
2467
2468        // Center = position + size/2
2469        assert_eq!(bbox.cx(), 60.0); // 10 + 100/2
2470        assert_eq!(bbox.cy(), 45.0); // 20 + 50/2
2471    }
2472
2473    #[test]
2474    fn test_box2d_zero_dimensions() {
2475        let bbox = Box2d::new(10.0, 20.0, 0.0, 0.0);
2476
2477        // When width/height are zero, center = position
2478        assert_eq!(bbox.cx(), 10.0);
2479        assert_eq!(bbox.cy(), 20.0);
2480    }
2481
2482    #[test]
2483    fn test_box2d_negative_dimensions() {
2484        let bbox = Box2d::new(100.0, 100.0, -50.0, -50.0);
2485
2486        // Negative dimensions create inverted boxes (valid edge case)
2487        assert_eq!(bbox.width(), -50.0);
2488        assert_eq!(bbox.height(), -50.0);
2489        assert_eq!(bbox.cx(), 75.0); // 100 + (-50)/2
2490        assert_eq!(bbox.cy(), 75.0); // 100 + (-50)/2
2491    }
2492
2493    // ==== Box3d Tests ====
2494    #[test]
2495    fn test_box3d_construction_and_accessors() {
2496        // Test case 1: Basic 3D construction
2497        let bbox = Box3d::new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
2498        assert_eq!((bbox.cx(), bbox.cy(), bbox.cz()), (1.0, 2.0, 3.0));
2499        assert_eq!(
2500            (bbox.width(), bbox.height(), bbox.length()),
2501            (4.0, 5.0, 6.0)
2502        );
2503
2504        // Test case 2: Corners calculation with offset center
2505        let bbox = Box3d::new(10.0, 20.0, 30.0, 4.0, 6.0, 8.0);
2506        assert_eq!((bbox.left(), bbox.top(), bbox.front()), (8.0, 17.0, 26.0)); // 10-2, 20-3, 30-4
2507
2508        // Test case 3: Center at origin with negative corners
2509        let bbox = Box3d::new(0.0, 0.0, 0.0, 2.0, 3.0, 4.0);
2510        assert_eq!((bbox.cx(), bbox.cy(), bbox.cz()), (0.0, 0.0, 0.0));
2511        assert_eq!(
2512            (bbox.width(), bbox.height(), bbox.length()),
2513            (2.0, 3.0, 4.0)
2514        );
2515        assert_eq!((bbox.left(), bbox.top(), bbox.front()), (-1.0, -1.5, -2.0));
2516    }
2517
2518    #[test]
2519    fn test_box3d_center_calculation() {
2520        let bbox = Box3d::new(10.0, 20.0, 30.0, 100.0, 50.0, 40.0);
2521
2522        // Center values as specified in constructor
2523        assert_eq!(bbox.cx(), 10.0);
2524        assert_eq!(bbox.cy(), 20.0);
2525        assert_eq!(bbox.cz(), 30.0);
2526    }
2527
2528    #[test]
2529    fn test_box3d_zero_dimensions() {
2530        let bbox = Box3d::new(5.0, 10.0, 15.0, 0.0, 0.0, 0.0);
2531
2532        // When all dimensions are zero, corners = center
2533        assert_eq!(bbox.cx(), 5.0);
2534        assert_eq!(bbox.cy(), 10.0);
2535        assert_eq!(bbox.cz(), 15.0);
2536        assert_eq!((bbox.left(), bbox.top(), bbox.front()), (5.0, 10.0, 15.0));
2537    }
2538
2539    #[test]
2540    fn test_box3d_negative_dimensions() {
2541        let bbox = Box3d::new(100.0, 100.0, 100.0, -50.0, -50.0, -50.0);
2542
2543        // Negative dimensions create inverted boxes
2544        assert_eq!(bbox.width(), -50.0);
2545        assert_eq!(bbox.height(), -50.0);
2546        assert_eq!(bbox.length(), -50.0);
2547        assert_eq!(
2548            (bbox.left(), bbox.top(), bbox.front()),
2549            (125.0, 125.0, 125.0)
2550        );
2551    }
2552
2553    // ==== Mask Tests ====
2554    #[test]
2555    fn test_mask_creation_and_deserialization() {
2556        // Test case 1: Direct construction
2557        let polygon = vec![vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]];
2558        let mask = Mask::new(polygon.clone());
2559        assert_eq!(mask.polygon, polygon);
2560
2561        // Test case 2: Deserialization from legacy format
2562        let legacy = serde_json::json!({
2563            "mask": {
2564                "polygon": [[
2565                    [0.0_f32, 0.0_f32],
2566                    [1.0_f32, 0.0_f32],
2567                    [1.0_f32, 1.0_f32]
2568                ]]
2569            }
2570        });
2571
2572        #[derive(serde::Deserialize)]
2573        struct Wrapper {
2574            mask: Mask,
2575        }
2576
2577        let parsed: Wrapper = serde_json::from_value(legacy).unwrap();
2578        assert_eq!(parsed.mask.polygon.len(), 1);
2579        assert_eq!(parsed.mask.polygon[0].len(), 3);
2580    }
2581
2582    // ==== Sample Tests ====
2583    #[test]
2584    fn test_sample_construction_and_accessors() {
2585        // Test case 1: New sample is empty
2586        let sample = Sample::new();
2587        assert_eq!(sample.id(), None);
2588        assert_eq!(sample.image_name(), None);
2589        assert_eq!(sample.width(), None);
2590        assert_eq!(sample.height(), None);
2591
2592        // Test case 2: Sample with populated fields
2593        let mut sample = Sample::new();
2594        sample.image_name = Some("test.jpg".to_string());
2595        sample.width = Some(1920);
2596        sample.height = Some(1080);
2597        sample.group = Some("group1".to_string());
2598
2599        assert_eq!(sample.image_name(), Some("test.jpg"));
2600        assert_eq!(sample.width(), Some(1920));
2601        assert_eq!(sample.height(), Some(1080));
2602        assert_eq!(sample.group(), Some(&"group1".to_string()));
2603    }
2604
2605    #[test]
2606    fn test_sample_name_extraction_from_image_name() {
2607        let mut sample = Sample::new();
2608
2609        // Test case 1: Basic image name with extension
2610        sample.image_name = Some("test_image.jpg".to_string());
2611        assert_eq!(sample.name(), Some("test_image".to_string()));
2612
2613        // Test case 2: Image name with .camera suffix
2614        sample.image_name = Some("test_image.camera.jpg".to_string());
2615        assert_eq!(sample.name(), Some("test_image".to_string()));
2616
2617        // Test case 3: Image name without extension
2618        sample.image_name = Some("test_image".to_string());
2619        assert_eq!(sample.name(), Some("test_image".to_string()));
2620    }
2621
2622    // ==== Annotation Tests ====
2623    #[test]
2624    fn test_annotation_construction_and_setters() {
2625        // Test case 1: New annotation is empty
2626        let ann = Annotation::new();
2627        assert_eq!(ann.sample_id(), None);
2628        assert_eq!(ann.label(), None);
2629        assert_eq!(ann.box2d(), None);
2630        assert_eq!(ann.box3d(), None);
2631        assert_eq!(ann.mask(), None);
2632
2633        // Test case 2: Setting annotation fields
2634        let mut ann = Annotation::new();
2635        ann.set_label(Some("car".to_string()));
2636        assert_eq!(ann.label(), Some(&"car".to_string()));
2637
2638        ann.set_label_index(Some(42));
2639        assert_eq!(ann.label_index(), Some(42));
2640
2641        // Test case 3: Setting bounding box
2642        let bbox = Box2d::new(10.0, 20.0, 100.0, 50.0);
2643        ann.set_box2d(Some(bbox.clone()));
2644        assert!(ann.box2d().is_some());
2645        assert_eq!(ann.box2d().unwrap().left(), 10.0);
2646    }
2647
2648    // ==== SampleFile Tests ====
2649    #[test]
2650    fn test_sample_file_with_url_and_filename() {
2651        // Test case 1: SampleFile with URL
2652        let file = SampleFile::with_url(
2653            "lidar.pcd".to_string(),
2654            "https://example.com/file.pcd".to_string(),
2655        );
2656        assert_eq!(file.file_type(), "lidar.pcd");
2657        assert_eq!(file.url(), Some("https://example.com/file.pcd"));
2658        assert_eq!(file.filename(), None);
2659
2660        // Test case 2: SampleFile with local filename
2661        let file = SampleFile::with_filename("image".to_string(), "test.jpg".to_string());
2662        assert_eq!(file.file_type(), "image");
2663        assert_eq!(file.filename(), Some("test.jpg"));
2664        assert_eq!(file.url(), None);
2665    }
2666
2667    // ==== Label Tests ====
2668    #[test]
2669    fn test_label_deserialization_and_accessors() {
2670        use serde_json::json;
2671
2672        // Test case 1: Label deserialization and accessors
2673        let label_json = json!({
2674            "id": 123,
2675            "dataset_id": 456,
2676            "index": 5,
2677            "name": "car"
2678        });
2679
2680        let label: Label = serde_json::from_value(label_json).unwrap();
2681        assert_eq!(label.id(), 123);
2682        assert_eq!(label.index(), 5);
2683        assert_eq!(label.name(), "car");
2684        assert_eq!(label.to_string(), "car");
2685        assert_eq!(format!("{}", label), "car");
2686
2687        // Test case 2: Different label
2688        let label_json = json!({
2689            "id": 1,
2690            "dataset_id": 100,
2691            "index": 0,
2692            "name": "person"
2693        });
2694
2695        let label: Label = serde_json::from_value(label_json).unwrap();
2696        assert_eq!(format!("{}", label), "person");
2697    }
2698
2699    // ==== Annotation Serialization Tests ====
2700    #[test]
2701    fn test_annotation_serialization_with_mask_and_box() {
2702        let polygon = vec![vec![
2703            (0.0_f32, 0.0_f32),
2704            (1.0_f32, 0.0_f32),
2705            (1.0_f32, 1.0_f32),
2706        ]];
2707
2708        let mut annotation = Annotation::new();
2709        annotation.set_label(Some("test".to_string()));
2710        annotation.set_box2d(Some(Box2d::new(10.0, 20.0, 30.0, 40.0)));
2711        annotation.set_mask(Some(Mask::new(polygon)));
2712
2713        let mut sample = Sample::new();
2714        sample.annotations.push(annotation);
2715
2716        let json = serde_json::to_value(&sample).unwrap();
2717        let annotations = json
2718            .get("annotations")
2719            .and_then(|value| value.as_array())
2720            .expect("annotations serialized as array");
2721        assert_eq!(annotations.len(), 1);
2722
2723        let annotation_json = annotations[0].as_object().expect("annotation object");
2724        assert!(annotation_json.contains_key("box2d"));
2725        assert!(annotation_json.contains_key("mask"));
2726        assert!(!annotation_json.contains_key("x"));
2727        assert!(
2728            annotation_json
2729                .get("mask")
2730                .and_then(|value| value.as_array())
2731                .is_some()
2732        );
2733    }
2734
2735    #[test]
2736    fn test_frame_number_negative_one_deserializes_as_none() {
2737        // Server returns frame_number: -1 for non-sequence samples
2738        // This should deserialize as None for the client
2739        let json = r#"{
2740            "uuid": "test-uuid",
2741            "frame_number": -1
2742        }"#;
2743
2744        let sample: Sample = serde_json::from_str(json).unwrap();
2745        assert_eq!(sample.frame_number, None);
2746    }
2747
2748    #[test]
2749    fn test_frame_number_positive_value_deserializes_correctly() {
2750        // Valid frame numbers should deserialize normally
2751        let json = r#"{
2752            "uuid": "test-uuid",
2753            "frame_number": 5
2754        }"#;
2755
2756        let sample: Sample = serde_json::from_str(json).unwrap();
2757        assert_eq!(sample.frame_number, Some(5));
2758    }
2759
2760    #[test]
2761    fn test_frame_number_null_deserializes_as_none() {
2762        // Explicit null should also be None
2763        let json = r#"{
2764            "uuid": "test-uuid",
2765            "frame_number": null
2766        }"#;
2767
2768        let sample: Sample = serde_json::from_str(json).unwrap();
2769        assert_eq!(sample.frame_number, None);
2770    }
2771
2772    #[test]
2773    fn test_frame_number_missing_deserializes_as_none() {
2774        // Missing field should be None
2775        let json = r#"{
2776            "uuid": "test-uuid"
2777        }"#;
2778
2779        let sample: Sample = serde_json::from_str(json).unwrap();
2780        assert_eq!(sample.frame_number, None);
2781    }
2782}