Skip to main content

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    mask::MaskData,
10};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14#[cfg(feature = "polars")]
15use polars::prelude::*;
16
17/// File types supported in EdgeFirst Studio datasets.
18///
19/// Represents the different types of sensor data files that can be stored
20/// and processed in a dataset. EdgeFirst Studio supports various modalities
21/// including visual images and different forms of LiDAR and radar data.
22///
23/// # String Representations
24///
25/// This enum has two string representations:
26/// - **Display** (`fmt::Display`): Returns the server API type name (e.g.,
27///   `"lidar.depth"`) used when making API requests to EdgeFirst Studio.
28/// - **file_extension()**: Returns the file extension for saving (e.g.,
29///   `"lidar.png"`) which may differ from the API type name.
30///
31/// # Examples
32///
33/// ```rust
34/// use edgefirst_client::FileType;
35///
36/// // Create file types from strings
37/// let image_type: FileType = "image".try_into().unwrap();
38/// let lidar_type: FileType = "lidar.pcd".try_into().unwrap();
39///
40/// // Display file types
41/// println!("Processing {} files", image_type); // "Processing image files"
42///
43/// // Use in dataset operations - example usage
44/// let file_type = FileType::Image;
45/// match file_type {
46///     FileType::Image => println!("Processing image files"),
47///     FileType::LidarPcd => println!("Processing LiDAR point cloud files"),
48///     _ => println!("Processing other sensor data"),
49/// }
50/// ```
51#[derive(Clone, Eq, PartialEq, Debug)]
52pub enum FileType {
53    /// Standard image files (JPEG, PNG, etc.)
54    Image,
55    /// LiDAR point cloud data files (.pcd format)
56    LidarPcd,
57    /// LiDAR depth images (.png format)
58    LidarDepth,
59    /// LiDAR reflectance images (.jpg format)
60    LidarReflect,
61    /// Radar point cloud data files (.pcd format)
62    RadarPcd,
63    /// Radar cube data files (.png format)
64    RadarCube,
65    /// All sensor types - expands to all known file types
66    All,
67}
68
69impl std::fmt::Display for FileType {
70    /// Returns the server API type name for this file type.
71    /// Used when making API requests to the server.
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        let value = match self {
74            FileType::Image => "image",
75            FileType::LidarPcd => "lidar.pcd",
76            FileType::LidarDepth => "lidar.depth",
77            FileType::LidarReflect => "lidar.reflect",
78            FileType::RadarPcd => "radar.pcd",
79            FileType::RadarCube => "radar.png",
80            FileType::All => "all",
81        };
82        write!(f, "{}", value)
83    }
84}
85
86impl FileType {
87    /// Returns the file extension to use when saving downloaded files.
88    /// This may differ from the API type name (e.g., lidar.depth → lidar.png).
89    pub fn file_extension(&self) -> &'static str {
90        match self {
91            FileType::Image => "jpg", // Will be overridden by infer detection
92            FileType::LidarPcd => "lidar.pcd",
93            FileType::LidarDepth => "lidar.png",
94            FileType::LidarReflect => "lidar.jpg",
95            FileType::RadarPcd => "radar.pcd",
96            FileType::RadarCube => "radar.png",
97            FileType::All => "",
98        }
99    }
100}
101
102impl TryFrom<&str> for FileType {
103    type Error = crate::Error;
104
105    fn try_from(s: &str) -> Result<Self, Self::Error> {
106        // Source of truth for accepted file-type tokens. When changing these
107        // arms, also update the user-facing lists in `Error::InvalidFileType`
108        // (error.rs) and the CLI `--types` help text (edgefirst-cli main.rs).
109        match s {
110            "image" => Ok(FileType::Image),
111            "lidar.pcd" => Ok(FileType::LidarPcd),
112            // Accept CLI names (lidar.png), server names (lidar.depth), and aliases
113            "lidar.png" | "lidar.depth" | "depth.png" | "depthmap" => Ok(FileType::LidarDepth),
114            "lidar.jpg" | "lidar.jpeg" | "lidar.reflect" => Ok(FileType::LidarReflect),
115            "radar.pcd" | "pcd" => Ok(FileType::RadarPcd),
116            "radar.png" | "cube" => Ok(FileType::RadarCube),
117            "all" => Ok(FileType::All),
118            _ => Err(crate::Error::InvalidFileType(s.to_string())),
119        }
120    }
121}
122
123impl std::str::FromStr for FileType {
124    type Err = crate::Error;
125
126    fn from_str(s: &str) -> Result<Self, Self::Err> {
127        s.try_into()
128    }
129}
130
131impl FileType {
132    /// Returns all concrete sensor file types (excludes `All`).
133    ///
134    /// This is useful for expanding the `All` variant or listing available
135    /// types.
136    ///
137    /// # Example
138    ///
139    /// ```rust
140    /// use edgefirst_client::FileType;
141    ///
142    /// let all_types = FileType::all_sensor_types();
143    /// assert!(all_types.contains(&FileType::Image));
144    /// assert!(!all_types.contains(&FileType::All));
145    /// ```
146    pub fn all_sensor_types() -> Vec<FileType> {
147        vec![
148            FileType::Image,
149            FileType::LidarPcd,
150            FileType::LidarDepth,
151            FileType::LidarReflect,
152            FileType::RadarPcd,
153            FileType::RadarCube,
154        ]
155    }
156
157    /// Returns all valid type names as strings for help text.
158    ///
159    /// # Example
160    ///
161    /// ```rust
162    /// use edgefirst_client::FileType;
163    ///
164    /// let names = FileType::type_names();
165    /// assert!(names.contains(&"image"));
166    /// assert!(names.contains(&"all"));
167    /// ```
168    pub fn type_names() -> Vec<&'static str> {
169        vec![
170            "image",
171            "lidar.pcd",
172            "lidar.png",
173            "lidar.jpg",
174            "radar.pcd",
175            "radar.png",
176            "all",
177        ]
178    }
179
180    /// Expands a list of file types, replacing `All` with all concrete sensor
181    /// types.
182    ///
183    /// If the input contains `FileType::All`, returns all sensor types.
184    /// Otherwise, returns the input types unchanged.
185    ///
186    /// # Example
187    ///
188    /// ```rust
189    /// use edgefirst_client::FileType;
190    ///
191    /// let types = vec![FileType::All];
192    /// let expanded = FileType::expand_types(&types);
193    /// assert_eq!(expanded.len(), 6); // All concrete sensor types
194    ///
195    /// let types = vec![FileType::Image, FileType::LidarPcd];
196    /// let expanded = FileType::expand_types(&types);
197    /// assert_eq!(expanded.len(), 2); // Unchanged
198    /// ```
199    pub fn expand_types(types: &[FileType]) -> Vec<FileType> {
200        if types.contains(&FileType::All) {
201            FileType::all_sensor_types()
202        } else {
203            types.to_vec()
204        }
205    }
206}
207
208/// Annotation types supported for labeling data in EdgeFirst Studio.
209///
210/// Represents the different types of annotations that can be applied to
211/// sensor data for machine learning tasks. Each type corresponds to a
212/// different annotation geometry and use case.
213///
214/// # Examples
215///
216/// ```rust
217/// use edgefirst_client::AnnotationType;
218///
219/// // Create annotation types from strings (using TryFrom)
220/// let box_2d: AnnotationType = "box2d".try_into().unwrap();
221/// let segmentation: AnnotationType = "polygon".try_into().unwrap();
222///
223/// // Or use From with String
224/// let box_2d = AnnotationType::from("box2d".to_string());
225/// let segmentation = AnnotationType::from("polygon".to_string());
226///
227/// // Display annotation types
228/// println!("Annotation type: {}", box_2d); // "Annotation type: box2d"
229///
230/// // Use in matching and processing
231/// let annotation_type = AnnotationType::Box2d;
232/// match annotation_type {
233///     AnnotationType::Box2d => println!("Processing 2D bounding boxes"),
234///     AnnotationType::Box3d => println!("Processing 3D bounding boxes"),
235///     AnnotationType::Polygon => println!("Processing polygon contours"),
236///     AnnotationType::Mask => println!("Processing raster pixel masks"),
237/// }
238/// ```
239#[derive(Clone, Eq, PartialEq, Debug)]
240pub enum AnnotationType {
241    /// 2D bounding boxes for object detection in images
242    Box2d,
243    /// 3D bounding boxes for object detection in 3D space (LiDAR, etc.)
244    Box3d,
245    /// Vector polygon contours for instance segmentation
246    Polygon,
247    /// Raster pixel masks for semantic/instance segmentation
248    Mask,
249}
250
251impl TryFrom<&str> for AnnotationType {
252    type Error = crate::Error;
253
254    fn try_from(s: &str) -> Result<Self, Self::Error> {
255        match s {
256            "box2d" => Ok(AnnotationType::Box2d),
257            "box3d" => Ok(AnnotationType::Box3d),
258            "polygon" => Ok(AnnotationType::Polygon),
259            "seg" => Ok(AnnotationType::Polygon),
260            "mask" => Ok(AnnotationType::Polygon), // backward compat
261            "raster" => Ok(AnnotationType::Mask),
262            _ => Err(crate::Error::InvalidAnnotationType(s.to_string())),
263        }
264    }
265}
266
267impl From<String> for AnnotationType {
268    fn from(s: String) -> Self {
269        // For backward compatibility, default to Box2d if invalid
270        s.as_str().try_into().unwrap_or(AnnotationType::Box2d)
271    }
272}
273
274impl From<&String> for AnnotationType {
275    fn from(s: &String) -> Self {
276        // For backward compatibility, default to Box2d if invalid
277        s.as_str().try_into().unwrap_or(AnnotationType::Box2d)
278    }
279}
280
281impl AnnotationType {
282    /// Returns the annotation type name expected by the server's
283    /// samples/annotations RPC `types` filter.
284    ///
285    /// The bridge endpoint accepts these I/O names and maps them to its
286    /// internal DB types (`box`/`3dbox`/`seg`) itself; sending the DB names
287    /// directly does not match the filter and silently drops it (see
288    /// dve-database `api/bridge_handler.go` `TYPE_MAP`).
289    /// - `Box2d` → `"box2d"`
290    /// - `Box3d` → `"box3d"`
291    /// - `Polygon` / `Mask` → `"mask"`
292    pub fn as_server_type(&self) -> &'static str {
293        match self {
294            AnnotationType::Box2d => "box2d",
295            AnnotationType::Box3d => "box3d",
296            AnnotationType::Polygon => "mask",
297            AnnotationType::Mask => "mask",
298        }
299    }
300}
301
302impl std::fmt::Display for AnnotationType {
303    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        let value = match self {
305            AnnotationType::Box2d => "box2d",
306            AnnotationType::Box3d => "box3d",
307            AnnotationType::Polygon => "polygon",
308            AnnotationType::Mask => "mask",
309        };
310        write!(f, "{}", value)
311    }
312}
313
314/// A dataset in EdgeFirst Studio containing sensor data and annotations.
315///
316/// Datasets are collections of multi-modal sensor data (images, LiDAR, radar)
317/// along with their corresponding annotations (bounding boxes, segmentation
318/// masks, 3D annotations). Datasets belong to projects and can be used for
319/// training and validation of machine learning models.
320///
321/// # Features
322///
323/// - **Multi-modal Data**: Support for images, LiDAR point clouds, radar data
324/// - **Rich Annotations**: 2D/3D bounding boxes, segmentation masks
325/// - **Metadata**: Timestamps, sensor configurations, calibration data
326/// - **Version Control**: Track changes and maintain data lineage
327/// - **Format Conversion**: Export to popular ML frameworks
328///
329/// # Examples
330///
331/// ```no_run
332/// use edgefirst_client::{Client, Dataset, DatasetID};
333/// use std::str::FromStr;
334///
335/// # async fn example() -> Result<(), edgefirst_client::Error> {
336/// # let client = Client::new()?;
337/// // Get dataset information
338/// let dataset_id = DatasetID::from_str("ds-abc123")?;
339/// let dataset = client.dataset(dataset_id).await?;
340/// println!("Dataset: {}", dataset.name());
341///
342/// // Access dataset metadata
343/// println!("Dataset ID: {}", dataset.id());
344/// println!("Description: {}", dataset.description());
345/// println!("Created: {}", dataset.created());
346///
347/// // Work with dataset data would require additional methods
348/// // that are implemented in the full API
349/// # Ok(())
350/// # }
351/// ```
352#[derive(Deserialize, Clone, Debug)]
353pub struct Dataset {
354    id: DatasetID,
355    project_id: ProjectID,
356    name: String,
357    description: String,
358    cloud_key: String,
359    #[serde(rename = "createdAt")]
360    created: DateTime<Utc>,
361}
362
363impl Display for Dataset {
364    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
365        write!(f, "{} {}", self.id, self.name)
366    }
367}
368
369impl Dataset {
370    pub fn id(&self) -> DatasetID {
371        self.id
372    }
373
374    pub fn project_id(&self) -> ProjectID {
375        self.project_id
376    }
377
378    pub fn name(&self) -> &str {
379        &self.name
380    }
381
382    pub fn description(&self) -> &str {
383        &self.description
384    }
385
386    pub fn cloud_key(&self) -> &str {
387        &self.cloud_key
388    }
389
390    pub fn created(&self) -> &DateTime<Utc> {
391        &self.created
392    }
393
394    pub async fn project(&self, client: &Client) -> Result<crate::api::Project, Error> {
395        client.project(self.project_id).await
396    }
397
398    pub async fn annotation_sets(&self, client: &Client) -> Result<Vec<AnnotationSet>, Error> {
399        client.annotation_sets(self.id).await
400    }
401
402    pub async fn labels(&self, client: &Client) -> Result<Vec<Label>, Error> {
403        client.labels(self.id).await
404    }
405
406    pub async fn add_label(&self, client: &Client, name: &str) -> Result<(), Error> {
407        client.add_label(self.id, name).await
408    }
409
410    pub async fn add_label_with_index(
411        &self,
412        client: &Client,
413        name: &str,
414        index: u64,
415    ) -> Result<(), Error> {
416        client.add_label_with_index(self.id, name, index).await
417    }
418
419    pub async fn remove_label(&self, client: &Client, name: &str) -> Result<(), Error> {
420        let labels = self.labels(client).await?;
421        let label = labels
422            .iter()
423            .find(|l| l.name() == name)
424            .ok_or_else(|| Error::MissingLabel(name.to_string()))?;
425        client.remove_label(label.id()).await
426    }
427}
428
429/// The AnnotationSet class represents a collection of annotations in a dataset.
430/// A dataset can have multiple annotation sets, each containing annotations for
431/// different tasks or purposes.
432#[derive(Deserialize)]
433pub struct AnnotationSet {
434    id: AnnotationSetID,
435    dataset_id: DatasetID,
436    name: String,
437    description: String,
438    #[serde(rename = "date")]
439    created: DateTime<Utc>,
440}
441
442impl Display for AnnotationSet {
443    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
444        write!(f, "{} {}", self.id, self.name)
445    }
446}
447
448impl AnnotationSet {
449    pub fn id(&self) -> AnnotationSetID {
450        self.id
451    }
452
453    pub fn dataset_id(&self) -> DatasetID {
454        self.dataset_id
455    }
456
457    pub fn name(&self) -> &str {
458        &self.name
459    }
460
461    pub fn description(&self) -> &str {
462        &self.description
463    }
464
465    pub fn created(&self) -> DateTime<Utc> {
466        self.created
467    }
468
469    pub async fn dataset(&self, client: &Client) -> Result<Dataset, Error> {
470        client.dataset(self.dataset_id).await
471    }
472}
473
474/// Pipeline timing measurements for a sample, in nanoseconds.
475///
476/// Each field records the wall-clock duration of one pipeline stage.
477/// Populated from Arrow metadata; not part of the Studio JSON-RPC API.
478#[derive(Clone, Debug, Default, PartialEq)]
479pub struct Timing {
480    /// Duration of the data-loading stage (nanoseconds).
481    pub load: Option<i64>,
482    /// Duration of the preprocessing stage (nanoseconds).
483    pub preprocess: Option<i64>,
484    /// Duration of the inference stage (nanoseconds).
485    pub inference: Option<i64>,
486    /// Duration of the decoding / postprocessing stage (nanoseconds).
487    pub decode: Option<i64>,
488}
489
490/// A sample in a dataset, typically representing a single image with metadata
491/// and optional sensor data.
492///
493/// Each sample has a unique ID, image reference, and can include additional
494/// sensor data like LiDAR, radar, or depth maps. Samples can also have
495/// associated annotations.
496#[derive(Serialize, Clone, Debug)]
497pub struct Sample {
498    #[serde(skip_serializing_if = "Option::is_none")]
499    pub id: Option<SampleID>,
500    /// Dataset split (train, val, test) - stored in Arrow metadata, not used
501    /// for directory structure.
502    /// API field name discrepancy: samples.populate2 expects "group", but
503    /// samples.list returns "group_name".
504    #[serde(
505        alias = "group_name",
506        rename(serialize = "group", deserialize = "group_name"),
507        skip_serializing_if = "Option::is_none"
508    )]
509    pub group: Option<String>,
510    #[serde(skip_serializing_if = "Option::is_none")]
511    pub sequence_name: Option<String>,
512    #[serde(skip_serializing_if = "Option::is_none")]
513    pub sequence_uuid: Option<String>,
514    #[serde(skip_serializing_if = "Option::is_none")]
515    pub sequence_description: Option<String>,
516    #[serde(
517        default,
518        skip_serializing_if = "Option::is_none",
519        deserialize_with = "deserialize_frame_number"
520    )]
521    pub frame_number: Option<u32>,
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub uuid: Option<String>,
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub image_name: Option<String>,
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub image_url: Option<String>,
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub width: Option<u32>,
530    #[serde(skip_serializing_if = "Option::is_none")]
531    pub height: Option<u32>,
532    #[serde(skip_serializing_if = "Option::is_none")]
533    pub date: Option<DateTime<Utc>>,
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub source: Option<String>,
536    /// Camera location and pose (GPS + IMU data).
537    /// Location data is extracted from the "sensors" field during
538    /// deserialization. When uploading samples, this field is serialized
539    /// as "sensors" to match the samples.populate2 API format.
540    #[serde(skip_serializing_if = "Option::is_none", rename(serialize = "sensors"))]
541    pub location: Option<Location>,
542    /// Image degradation type (blur, occlusion, weather, etc.).
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub degradation: Option<String>,
545    /// LVIS: label_index values for categories verified absent from this image.
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub neg_label_indices: Option<Vec<u32>>,
548    /// LVIS: label_index values for categories with incomplete annotation.
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub not_exhaustive_label_indices: Option<Vec<u32>>,
551    /// Additional sensor files (LiDAR, radar, depth maps, etc.).
552    /// Deserialization is handled by custom Deserialize impl which extracts
553    /// files from the "sensors" field. Serialization converts to HashMap for
554    /// samples.populate2 API.
555    #[serde(
556        default,
557        skip_serializing_if = "Vec::is_empty",
558        serialize_with = "serialize_files"
559    )]
560    pub files: Vec<SampleFile>,
561    /// Annotations associated with this sample.
562    /// Deserialization is handled by custom Deserialize impl.
563    #[serde(
564        default,
565        skip_serializing_if = "Vec::is_empty",
566        serialize_with = "serialize_annotations"
567    )]
568    pub annotations: Vec<Annotation>,
569    /// Pipeline timing measurements (populated from Arrow, not from Studio
570    /// JSON-RPC).
571    #[serde(skip)]
572    pub timing: Option<Timing>,
573}
574
575// Custom deserializer for frame_number - converts -1 to None
576// Server returns -1 for non-sequence samples, but clients should see None
577fn deserialize_frame_number<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
578where
579    D: serde::Deserializer<'de>,
580{
581    use serde::Deserialize;
582
583    let value = Option::<i32>::deserialize(deserializer)?;
584    Ok(value.and_then(|v| if v < 0 { None } else { Some(v as u32) }))
585}
586
587/// Check if a string is a valid downloadable URL (http/https).
588/// Used to distinguish between pre-signed URLs and inline base64/JSON data.
589fn is_valid_url(s: &str) -> bool {
590    s.starts_with("http://") || s.starts_with("https://")
591}
592
593// Custom serializer for files field - converts Vec<SampleFile> to
594// HashMap<String, String>
595fn serialize_files<S>(files: &[SampleFile], serializer: S) -> Result<S::Ok, S::Error>
596where
597    S: serde::Serializer,
598{
599    use serde::Serialize;
600    let map: HashMap<String, String> = files
601        .iter()
602        .filter_map(|f| {
603            f.filename()
604                .map(|filename| (f.file_type().to_string(), filename.to_string()))
605        })
606        .collect();
607    map.serialize(serializer)
608}
609
610// Custom serializer for annotations field - serializes to a flat
611// Vec<Annotation> to match the updated samples.populate2 contract (annotations
612// array)
613fn serialize_annotations<S>(annotations: &Vec<Annotation>, serializer: S) -> Result<S::Ok, S::Error>
614where
615    S: serde::Serializer,
616{
617    serde::Serialize::serialize(annotations, serializer)
618}
619
620// Custom deserializer for annotations field - converts server format back to
621// Vec<Annotation>
622fn deserialize_annotations<'de, D>(deserializer: D) -> Result<Vec<Annotation>, D::Error>
623where
624    D: serde::Deserializer<'de>,
625{
626    use serde::Deserialize;
627
628    #[derive(Deserialize)]
629    #[serde(untagged)]
630    enum AnnotationsFormat {
631        Vec(Vec<Annotation>),
632        Map(HashMap<String, Vec<Annotation>>),
633    }
634
635    let value = Option::<AnnotationsFormat>::deserialize(deserializer)?;
636    Ok(value
637        .map(|v| match v {
638            AnnotationsFormat::Vec(annotations) => annotations,
639            AnnotationsFormat::Map(map) => convert_annotations_map_to_vec(map),
640        })
641        .unwrap_or_default())
642}
643
644/// Intermediate struct for deserializing sensors data that may contain both
645/// file references (URLs/data) and location data (GPS/IMU).
646#[derive(Debug, Default)]
647struct SensorsData {
648    files: Vec<SampleFile>,
649    location: Option<Location>,
650}
651
652/// Deserialize sensors field into both files and location data.
653fn deserialize_sensors_data(value: Option<serde_json::Value>) -> SensorsData {
654    use serde_json::Value;
655
656    /// Create a SampleFile from a string value, distinguishing URL vs inline
657    /// data.
658    fn create_sample_file(file_type: String, value: String) -> SampleFile {
659        if is_valid_url(&value) {
660            SampleFile::with_url(file_type, value)
661        } else {
662            SampleFile::with_data(file_type, value)
663        }
664    }
665
666    /// Create a SampleFile from any JSON value, converting non-strings to JSON.
667    fn create_sample_file_from_value(file_type: String, value: Value) -> Option<SampleFile> {
668        match value {
669            Value::String(s) => Some(create_sample_file(file_type, s)),
670            Value::Object(_) | Value::Array(_) => {
671                // Inline JSON data (legacy format) - serialize to string
672                serde_json::to_string(&value)
673                    .ok()
674                    .map(|data| SampleFile::with_data(file_type, data))
675            }
676            _ => None,
677        }
678    }
679
680    /// Try to extract Location from a JSON object containing gps/imu keys.
681    fn extract_location(map: &serde_json::Map<String, Value>) -> Option<Location> {
682        let gps = map
683            .get("gps")
684            .and_then(|v| serde_json::from_value::<GpsData>(v.clone()).ok());
685        let imu = map
686            .get("imu")
687            .and_then(|v| serde_json::from_value::<ImuData>(v.clone()).ok());
688
689        if gps.is_some() || imu.is_some() {
690            Some(Location { gps, imu })
691        } else {
692            None
693        }
694    }
695
696    let mut result = SensorsData::default();
697
698    match value {
699        None => result,
700        Some(Value::Array(arr)) => {
701            // Array of single-key objects: [{"radar.png": "url"}, {"gps": {...}}, ...]
702            for item in arr {
703                if let Value::Object(map) = item {
704                    // Check if this looks like a SampleFile object (has "type" key)
705                    if map.contains_key("type") {
706                        // Try to parse as SampleFile
707                        if let Ok(file) =
708                            serde_json::from_value::<SampleFile>(Value::Object(map.clone()))
709                        {
710                            result.files.push(file);
711                        }
712                    } else {
713                        // Check for location data (gps/imu)
714                        if let Some(loc) = extract_location(&map) {
715                            // Merge with existing location
716                            if let Some(ref mut existing) = result.location {
717                                if loc.gps.is_some() {
718                                    existing.gps = loc.gps;
719                                }
720                                if loc.imu.is_some() {
721                                    existing.imu = loc.imu;
722                                }
723                            } else {
724                                result.location = Some(loc);
725                            }
726                        } else {
727                            // Single-key object: {file_type: url_or_data}
728                            for (file_type, value) in map {
729                                if let Some(file) = create_sample_file_from_value(file_type, value)
730                                {
731                                    result.files.push(file);
732                                }
733                            }
734                        }
735                    }
736                }
737            }
738            result
739        }
740        Some(Value::Object(map)) => {
741            // Check if this contains location data (gps or imu keys with object values)
742            if let Some(loc) = extract_location(&map) {
743                result.location = Some(loc);
744            }
745
746            // Also extract any file references (non-location keys)
747            for (key, value) in map {
748                if key != "gps"
749                    && key != "imu"
750                    && let Some(file) = create_sample_file_from_value(key, value)
751                {
752                    result.files.push(file);
753                }
754            }
755            result
756        }
757        Some(_) => result,
758    }
759}
760
761/// Raw sample structure for deserialization.
762/// This mirrors Sample but deserializes sensors into a combined struct
763/// that captures both files and location data.
764#[derive(Deserialize)]
765struct SampleRaw {
766    #[serde(default)]
767    id: Option<SampleID>,
768    #[serde(alias = "group_name")]
769    group: Option<String>,
770    sequence_name: Option<String>,
771    sequence_uuid: Option<String>,
772    sequence_description: Option<String>,
773    #[serde(default, deserialize_with = "deserialize_frame_number")]
774    frame_number: Option<u32>,
775    uuid: Option<String>,
776    image_name: Option<String>,
777    image_url: Option<String>,
778    width: Option<u32>,
779    height: Option<u32>,
780    date: Option<DateTime<Utc>>,
781    source: Option<String>,
782    degradation: Option<String>,
783    #[serde(default)]
784    neg_label_indices: Option<Vec<u32>>,
785    #[serde(default)]
786    not_exhaustive_label_indices: Option<Vec<u32>>,
787    /// Raw sensors JSON - will be processed into files + location
788    #[serde(default, alias = "sensors")]
789    sensors: Option<serde_json::Value>,
790    #[serde(default, deserialize_with = "deserialize_annotations")]
791    annotations: Vec<Annotation>,
792}
793
794impl From<SampleRaw> for Sample {
795    fn from(raw: SampleRaw) -> Self {
796        let sensors_data = deserialize_sensors_data(raw.sensors);
797
798        Sample {
799            id: raw.id,
800            group: raw.group,
801            sequence_name: raw.sequence_name,
802            sequence_uuid: raw.sequence_uuid,
803            sequence_description: raw.sequence_description,
804            frame_number: raw.frame_number,
805            uuid: raw.uuid,
806            image_name: raw.image_name,
807            image_url: raw.image_url,
808            width: raw.width,
809            height: raw.height,
810            date: raw.date,
811            source: raw.source,
812            location: sensors_data.location,
813            degradation: raw.degradation,
814            neg_label_indices: raw.neg_label_indices,
815            not_exhaustive_label_indices: raw.not_exhaustive_label_indices,
816            files: sensors_data.files,
817            annotations: raw.annotations,
818            timing: None,
819        }
820    }
821}
822
823impl<'de> serde::Deserialize<'de> for Sample {
824    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
825    where
826        D: serde::Deserializer<'de>,
827    {
828        let raw = SampleRaw::deserialize(deserializer)?;
829        Ok(Sample::from(raw))
830    }
831}
832
833impl Display for Sample {
834    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
835        write!(
836            f,
837            "{} {}",
838            self.id
839                .map(|id| id.to_string())
840                .unwrap_or_else(|| "unknown".to_string()),
841            self.image_name().unwrap_or("unknown")
842        )
843    }
844}
845
846impl Default for Sample {
847    fn default() -> Self {
848        Self::new()
849    }
850}
851
852impl Sample {
853    /// Creates a new empty sample.
854    pub fn new() -> Self {
855        Self {
856            id: None,
857            group: None,
858            sequence_name: None,
859            sequence_uuid: None,
860            sequence_description: None,
861            frame_number: None,
862            uuid: None,
863            image_name: None,
864            image_url: None,
865            width: None,
866            height: None,
867            date: None,
868            source: None,
869            location: None,
870            degradation: None,
871            neg_label_indices: None,
872            not_exhaustive_label_indices: None,
873            files: vec![],
874            annotations: vec![],
875            timing: None,
876        }
877    }
878
879    pub fn id(&self) -> Option<SampleID> {
880        self.id
881    }
882
883    pub fn name(&self) -> Option<String> {
884        self.image_name.as_ref().map(|n| extract_sample_name(n))
885    }
886
887    pub fn group(&self) -> Option<&String> {
888        self.group.as_ref()
889    }
890
891    pub fn sequence_name(&self) -> Option<&String> {
892        self.sequence_name.as_ref()
893    }
894
895    pub fn sequence_uuid(&self) -> Option<&String> {
896        self.sequence_uuid.as_ref()
897    }
898
899    pub fn sequence_description(&self) -> Option<&String> {
900        self.sequence_description.as_ref()
901    }
902
903    pub fn frame_number(&self) -> Option<u32> {
904        self.frame_number
905    }
906
907    pub fn uuid(&self) -> Option<&String> {
908        self.uuid.as_ref()
909    }
910
911    pub fn image_name(&self) -> Option<&str> {
912        self.image_name.as_deref()
913    }
914
915    pub fn image_url(&self) -> Option<&str> {
916        self.image_url.as_deref()
917    }
918
919    pub fn width(&self) -> Option<u32> {
920        self.width
921    }
922
923    pub fn height(&self) -> Option<u32> {
924        self.height
925    }
926
927    pub fn date(&self) -> Option<DateTime<Utc>> {
928        self.date
929    }
930
931    pub fn source(&self) -> Option<&String> {
932        self.source.as_ref()
933    }
934
935    pub fn location(&self) -> Option<&Location> {
936        self.location.as_ref()
937    }
938
939    pub fn files(&self) -> &[SampleFile] {
940        &self.files
941    }
942
943    pub fn annotations(&self) -> &[Annotation] {
944        &self.annotations
945    }
946
947    pub fn with_annotations(mut self, annotations: Vec<Annotation>) -> Self {
948        self.annotations = annotations;
949        self
950    }
951
952    pub fn with_frame_number(mut self, frame_number: Option<u32>) -> Self {
953        self.frame_number = frame_number;
954        self
955    }
956
957    /// Downloads a file of the specified type for this sample.
958    ///
959    /// Supports both newer datasets (pre-signed URLs) and legacy datasets
960    /// (inline base64-encoded data):
961    /// 1. First tries to download from URL if available
962    /// 2. Falls back to decoding inline base64 data for legacy datasets
963    pub async fn download(
964        &self,
965        client: &Client,
966        file_type: FileType,
967    ) -> Result<Option<Vec<u8>>, Error> {
968        use base64::{Engine, engine::general_purpose::STANDARD};
969
970        // Handle image type separately (uses image_url field)
971        if file_type == FileType::Image {
972            if let Some(url) = self.image_url.as_deref()
973                && is_valid_url(url)
974            {
975                return Ok(Some(client.download(url).await?));
976            }
977            return Ok(None);
978        }
979
980        // Find the matching file for this type
981        let file = resolve_file(&file_type, &self.files);
982
983        match file {
984            Some(f) => {
985                // Prefer URL (newer datasets)
986                if let Some(url) = f.url() {
987                    return Ok(Some(client.download(url).await?));
988                }
989
990                // Fall back to inline data (legacy datasets)
991                if let Some(data) = f.data() {
992                    // Legacy data can be in several formats:
993                    // 1. Base64-encoded JSON: "eyJyYWRhci5wY2QiOi..." -> {"radar.pcd": "content"}
994                    // 2. Direct JSON wrapper: {"radar.pcd": "content"}
995                    // 3. Raw content (PCD text, etc.)
996
997                    // Try base64 decode first
998                    let decoded = if let Ok(bytes) = STANDARD.decode(data) {
999                        // Check if decoded bytes are UTF-8 JSON
1000                        if let Ok(text) = String::from_utf8(bytes.clone()) {
1001                            if text.starts_with('{') {
1002                                // It's JSON - use the text for further processing
1003                                text
1004                            } else {
1005                                // Non-JSON binary data - return as-is
1006                                return Ok(Some(bytes));
1007                            }
1008                        } else {
1009                            // Binary data - return as-is
1010                            return Ok(Some(bytes));
1011                        }
1012                    } else {
1013                        // Not base64 - use original data
1014                        data.to_string()
1015                    };
1016
1017                    // Try to unwrap JSON wrapper: {"type_name": "content"}
1018                    let content = if decoded.starts_with('{') {
1019                        if let Ok(json) = serde_json::from_str::<serde_json::Value>(&decoded) {
1020                            if let Some(obj) = json.as_object() {
1021                                obj.values()
1022                                    .next()
1023                                    .and_then(|v| v.as_str())
1024                                    .map(|s| s.to_string())
1025                                    .unwrap_or(decoded)
1026                            } else {
1027                                decoded
1028                            }
1029                        } else {
1030                            decoded
1031                        }
1032                    } else {
1033                        decoded
1034                    };
1035
1036                    return Ok(Some(content.as_bytes().to_vec()));
1037                }
1038
1039                Ok(None)
1040            }
1041            None => Ok(None),
1042        }
1043    }
1044}
1045
1046/// A file associated with a sample (e.g., LiDAR point cloud, radar data).
1047///
1048/// For samples retrieved from the server, this contains the file type and URL.
1049/// For samples being populated to the server, this can be a type and filename.
1050///
1051/// Legacy datasets may have inline base64-encoded data instead of URLs.
1052/// The `data` field stores this inline content for fallback when no URL exists.
1053#[derive(Serialize, Deserialize, Clone, Debug)]
1054pub struct SampleFile {
1055    r#type: String,
1056    #[serde(skip_serializing_if = "Option::is_none")]
1057    url: Option<String>,
1058    #[serde(skip_serializing_if = "Option::is_none")]
1059    filename: Option<String>,
1060    /// Inline base64-encoded data for legacy datasets without pre-signed URLs.
1061    #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
1062    data: Option<String>,
1063    /// Raw bytes for direct upload (e.g., from ZIP archives).
1064    /// This field is not serialized - it's only used during the upload process.
1065    #[serde(skip)]
1066    bytes: Option<Vec<u8>>,
1067}
1068
1069impl SampleFile {
1070    /// Creates a new sample file with type and URL (for newer datasets).
1071    pub fn with_url(file_type: String, url: String) -> Self {
1072        Self {
1073            r#type: file_type,
1074            url: Some(url),
1075            filename: None,
1076            data: None,
1077            bytes: None,
1078        }
1079    }
1080
1081    /// Creates a new sample file with type and filename (for populate API).
1082    pub fn with_filename(file_type: String, filename: String) -> Self {
1083        Self {
1084            r#type: file_type,
1085            url: None,
1086            filename: Some(filename),
1087            data: None,
1088            bytes: None,
1089        }
1090    }
1091
1092    /// Creates a new sample file with inline data (for legacy datasets).
1093    pub fn with_data(file_type: String, data: String) -> Self {
1094        Self {
1095            r#type: file_type,
1096            url: None,
1097            filename: None,
1098            data: Some(data),
1099            bytes: None,
1100        }
1101    }
1102
1103    /// Creates a new sample file with raw bytes for direct upload.
1104    ///
1105    /// This is useful for uploading files from ZIP archives without extracting
1106    /// to disk first. The bytes are uploaded directly to the presigned URL.
1107    ///
1108    /// # Arguments
1109    /// * `file_type` - The type of file (e.g., "image", "lidar.pcd")
1110    /// * `filename` - The filename to use for the upload
1111    /// * `bytes` - The raw file bytes
1112    pub fn with_bytes(file_type: String, filename: String, bytes: Vec<u8>) -> Self {
1113        Self {
1114            r#type: file_type,
1115            url: None,
1116            filename: Some(filename),
1117            data: None,
1118            bytes: Some(bytes),
1119        }
1120    }
1121
1122    pub fn file_type(&self) -> &str {
1123        &self.r#type
1124    }
1125
1126    pub fn url(&self) -> Option<&str> {
1127        self.url.as_deref()
1128    }
1129
1130    pub fn filename(&self) -> Option<&str> {
1131        self.filename.as_deref()
1132    }
1133
1134    /// Returns inline base64-encoded data (for legacy datasets).
1135    pub fn data(&self) -> Option<&str> {
1136        self.data.as_deref()
1137    }
1138
1139    /// Returns raw bytes for direct upload (from ZIP archives, etc.).
1140    pub fn bytes(&self) -> Option<&[u8]> {
1141        self.bytes.as_deref()
1142    }
1143}
1144
1145/// Location and pose information for a sample.
1146///
1147/// Contains GPS coordinates and IMU orientation data describing where and how
1148/// the camera was positioned when capturing the sample.
1149#[derive(Serialize, Deserialize, Clone, Debug)]
1150pub struct Location {
1151    #[serde(skip_serializing_if = "Option::is_none")]
1152    pub gps: Option<GpsData>,
1153    #[serde(skip_serializing_if = "Option::is_none")]
1154    pub imu: Option<ImuData>,
1155}
1156
1157/// GPS location data (latitude and longitude).
1158#[derive(Serialize, Deserialize, Clone, Debug)]
1159pub struct GpsData {
1160    pub lat: f64,
1161    pub lon: f64,
1162}
1163
1164impl GpsData {
1165    /// Validate GPS coordinates are within valid ranges.
1166    ///
1167    /// Checks if latitude and longitude values are within valid geographic
1168    /// ranges. Helps catch data corruption or API issues early.
1169    ///
1170    /// # Returns
1171    /// `Ok(())` if valid, `Err(String)` with descriptive error message
1172    /// otherwise
1173    ///
1174    /// # Valid Ranges
1175    /// - Latitude: -90.0 to +90.0 degrees
1176    /// - Longitude: -180.0 to +180.0 degrees
1177    ///
1178    /// # Examples
1179    /// ```
1180    /// use edgefirst_client::GpsData;
1181    ///
1182    /// let gps = GpsData {
1183    ///     lat: 37.7749,
1184    ///     lon: -122.4194,
1185    /// };
1186    /// assert!(gps.validate().is_ok());
1187    ///
1188    /// let bad_gps = GpsData {
1189    ///     lat: 100.0,
1190    ///     lon: 0.0,
1191    /// };
1192    /// assert!(bad_gps.validate().is_err());
1193    /// ```
1194    pub fn validate(&self) -> Result<(), String> {
1195        validate_gps_coordinates(self.lat, self.lon)
1196    }
1197}
1198
1199/// IMU orientation data (roll, pitch, yaw in degrees).
1200#[derive(Serialize, Deserialize, Clone, Debug)]
1201pub struct ImuData {
1202    pub roll: f64,
1203    pub pitch: f64,
1204    pub yaw: f64,
1205}
1206
1207impl ImuData {
1208    /// Validate IMU orientation angles are within valid ranges.
1209    ///
1210    /// Checks if roll, pitch, and yaw values are finite and within reasonable
1211    /// ranges. Helps catch data corruption or sensor errors early.
1212    ///
1213    /// # Returns
1214    /// `Ok(())` if valid, `Err(String)` with descriptive error message
1215    /// otherwise
1216    ///
1217    /// # Valid Ranges
1218    /// - Roll: -180.0 to +180.0 degrees
1219    /// - Pitch: -90.0 to +90.0 degrees (typical gimbal lock range)
1220    /// - Yaw: -180.0 to +180.0 degrees (or 0 to 360, normalized)
1221    ///
1222    /// # Examples
1223    /// ```
1224    /// use edgefirst_client::ImuData;
1225    ///
1226    /// let imu = ImuData {
1227    ///     roll: 10.0,
1228    ///     pitch: 5.0,
1229    ///     yaw: 90.0,
1230    /// };
1231    /// assert!(imu.validate().is_ok());
1232    ///
1233    /// let bad_imu = ImuData {
1234    ///     roll: 200.0,
1235    ///     pitch: 0.0,
1236    ///     yaw: 0.0,
1237    /// };
1238    /// assert!(bad_imu.validate().is_err());
1239    /// ```
1240    pub fn validate(&self) -> Result<(), String> {
1241        validate_imu_orientation(self.roll, self.pitch, self.yaw)
1242    }
1243}
1244
1245#[allow(dead_code)]
1246pub trait TypeName {
1247    fn type_name() -> String;
1248}
1249
1250#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
1251pub struct Box3d {
1252    x: f32,
1253    y: f32,
1254    z: f32,
1255    w: f32,
1256    h: f32,
1257    l: f32,
1258}
1259
1260impl TypeName for Box3d {
1261    fn type_name() -> String {
1262        "box3d".to_owned()
1263    }
1264}
1265
1266impl Box3d {
1267    pub fn new(cx: f32, cy: f32, cz: f32, width: f32, height: f32, length: f32) -> Self {
1268        Self {
1269            x: cx,
1270            y: cy,
1271            z: cz,
1272            w: width,
1273            h: height,
1274            l: length,
1275        }
1276    }
1277
1278    pub fn width(&self) -> f32 {
1279        self.w
1280    }
1281
1282    pub fn height(&self) -> f32 {
1283        self.h
1284    }
1285
1286    pub fn length(&self) -> f32 {
1287        self.l
1288    }
1289
1290    pub fn cx(&self) -> f32 {
1291        self.x
1292    }
1293
1294    pub fn cy(&self) -> f32 {
1295        self.y
1296    }
1297
1298    pub fn cz(&self) -> f32 {
1299        self.z
1300    }
1301
1302    pub fn left(&self) -> f32 {
1303        self.x - self.w / 2.0
1304    }
1305
1306    pub fn top(&self) -> f32 {
1307        self.y - self.h / 2.0
1308    }
1309
1310    pub fn front(&self) -> f32 {
1311        self.z - self.l / 2.0
1312    }
1313}
1314
1315#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
1316pub struct Box2d {
1317    h: f32,
1318    w: f32,
1319    x: f32,
1320    y: f32,
1321}
1322
1323impl TypeName for Box2d {
1324    fn type_name() -> String {
1325        "box2d".to_owned()
1326    }
1327}
1328
1329impl Box2d {
1330    pub fn new(left: f32, top: f32, width: f32, height: f32) -> Self {
1331        Self {
1332            x: left,
1333            y: top,
1334            w: width,
1335            h: height,
1336        }
1337    }
1338
1339    pub fn width(&self) -> f32 {
1340        self.w
1341    }
1342
1343    pub fn height(&self) -> f32 {
1344        self.h
1345    }
1346
1347    pub fn left(&self) -> f32 {
1348        self.x
1349    }
1350
1351    pub fn top(&self) -> f32 {
1352        self.y
1353    }
1354
1355    pub fn cx(&self) -> f32 {
1356        self.x + self.w / 2.0
1357    }
1358
1359    pub fn cy(&self) -> f32 {
1360        self.y + self.h / 2.0
1361    }
1362}
1363
1364#[derive(Clone, Debug, PartialEq)]
1365pub struct Polygon {
1366    pub rings: Vec<Vec<(f32, f32)>>,
1367}
1368
1369impl TypeName for Polygon {
1370    fn type_name() -> String {
1371        "polygon".to_owned()
1372    }
1373}
1374
1375impl Polygon {
1376    pub fn new(rings: Vec<Vec<(f32, f32)>>) -> Self {
1377        Self { rings }
1378    }
1379}
1380
1381impl serde::Serialize for Polygon {
1382    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1383    where
1384        S: serde::Serializer,
1385    {
1386        serde::Serialize::serialize(&self.rings, serializer)
1387    }
1388}
1389
1390impl<'de> serde::Deserialize<'de> for Polygon {
1391    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1392    where
1393        D: serde::Deserializer<'de>,
1394    {
1395        // First, deserialize to a raw JSON value to handle various formats
1396        let value = serde_json::Value::deserialize(deserializer)?;
1397
1398        // Try to extract polygon data from various formats
1399        let polygon_value = if let Some(obj) = value.as_object() {
1400            // Format: {"polygon": [...]} or {"rings": [...]}
1401            obj.get("rings")
1402                .or_else(|| obj.get("polygon"))
1403                .cloned()
1404                .unwrap_or(serde_json::Value::Null)
1405        } else {
1406            // Format: [[...]] (direct array)
1407            value
1408        };
1409
1410        // Parse the polygon array, filtering out null/invalid values
1411        let rings = parse_polygon_value(&polygon_value);
1412
1413        Ok(Self { rings })
1414    }
1415}
1416
1417/// Parse polygon value from JSON, handling malformed data gracefully.
1418///
1419/// Handles multiple formats:
1420/// - `[[[x,y],[x,y],...]]` - 3D array with point pairs (correct format)
1421/// - `[[x,y,x,y,...]]` - 2D array with flat coords (COCO format, legacy)
1422/// - `[[null,null,...]]` - corrupted data (returns empty)
1423/// - `null` - missing data (returns empty)
1424fn parse_polygon_value(value: &serde_json::Value) -> Vec<Vec<(f32, f32)>> {
1425    let Some(outer_array) = value.as_array() else {
1426        return vec![];
1427    };
1428
1429    let mut result = Vec::new();
1430
1431    for ring in outer_array {
1432        let Some(ring_array) = ring.as_array() else {
1433            continue;
1434        };
1435
1436        // Check if this is a 3D array (point pairs) or 2D array (flat coords)
1437        let is_3d = ring_array
1438            .first()
1439            .map(|first| first.is_array())
1440            .unwrap_or(false);
1441
1442        let points: Vec<(f32, f32)> = if is_3d {
1443            // 3D format: [[x1,y1], [x2,y2], ...]
1444            ring_array
1445                .iter()
1446                .filter_map(|point| {
1447                    let arr = point.as_array()?;
1448                    if arr.len() >= 2 {
1449                        let x = arr[0].as_f64()? as f32;
1450                        let y = arr[1].as_f64()? as f32;
1451                        if x.is_finite() && y.is_finite() {
1452                            Some((x, y))
1453                        } else {
1454                            None
1455                        }
1456                    } else {
1457                        None
1458                    }
1459                })
1460                .collect()
1461        } else {
1462            // 2D format (flat): [x1, y1, x2, y2, ...]
1463            ring_array
1464                .chunks(2)
1465                .filter_map(|chunk| {
1466                    if chunk.len() >= 2 {
1467                        let x = chunk[0].as_f64()? as f32;
1468                        let y = chunk[1].as_f64()? as f32;
1469                        if x.is_finite() && y.is_finite() {
1470                            Some((x, y))
1471                        } else {
1472                            None
1473                        }
1474                    } else {
1475                        None
1476                    }
1477                })
1478                .collect()
1479        };
1480
1481        // Only add rings with at least 3 valid points
1482        if points.len() >= 3 {
1483            result.push(points);
1484        }
1485    }
1486
1487    result
1488}
1489
1490/// Helper struct for deserializing annotations from the server.
1491///
1492/// The server sends bounding box coordinates as flat fields (x, y, w, h) at the
1493/// annotation level, but we want to store them as a nested Box2d struct.
1494#[derive(Deserialize)]
1495struct AnnotationRaw {
1496    #[serde(default)]
1497    sample_id: Option<SampleID>,
1498    #[serde(default)]
1499    name: Option<String>,
1500    #[serde(default)]
1501    sequence_name: Option<String>,
1502    #[serde(default)]
1503    frame_number: Option<u32>,
1504    #[serde(rename = "group_name", default)]
1505    group: Option<String>,
1506    #[serde(rename = "object_reference", alias = "object_id", default)]
1507    object_id: Option<String>,
1508    #[serde(default)]
1509    label_name: Option<String>,
1510    #[serde(default)]
1511    label_index: Option<u64>,
1512    #[serde(default)]
1513    iscrowd: Option<bool>,
1514    #[serde(default)]
1515    category_frequency: Option<String>,
1516    // Nested box2d format (if server sends it this way)
1517    #[serde(default)]
1518    box2d: Option<Box2d>,
1519    #[serde(default)]
1520    box3d: Option<Box3d>,
1521    #[serde(default, alias = "mask")]
1522    polygon: Option<Polygon>,
1523    // Flat box2d fields from server (x, y, w, h at annotation level)
1524    #[serde(default)]
1525    x: Option<f64>,
1526    #[serde(default)]
1527    y: Option<f64>,
1528    #[serde(default)]
1529    w: Option<f64>,
1530    #[serde(default)]
1531    h: Option<f64>,
1532}
1533
1534#[derive(Serialize, Clone, Debug)]
1535pub struct Annotation {
1536    #[serde(skip_serializing_if = "Option::is_none")]
1537    sample_id: Option<SampleID>,
1538    #[serde(skip_serializing_if = "Option::is_none")]
1539    name: Option<String>,
1540    #[serde(skip_serializing_if = "Option::is_none")]
1541    sequence_name: Option<String>,
1542    #[serde(skip_serializing_if = "Option::is_none")]
1543    frame_number: Option<u32>,
1544    /// Dataset split (train, val, test) - matches `Sample.group`.
1545    /// JSON field name: "group_name" (Studio API uses this name for both upload
1546    /// and download).
1547    #[serde(rename = "group_name", skip_serializing_if = "Option::is_none")]
1548    group: Option<String>,
1549    /// Object tracking identifier across frames.
1550    /// JSON field name: "object_reference" for upload (populate), "object_id"
1551    /// for download (list).
1552    #[serde(
1553        rename = "object_reference",
1554        alias = "object_id",
1555        skip_serializing_if = "Option::is_none"
1556    )]
1557    object_id: Option<String>,
1558    #[serde(skip_serializing_if = "Option::is_none")]
1559    label_name: Option<String>,
1560    #[serde(skip_serializing_if = "Option::is_none")]
1561    label_index: Option<u64>,
1562    /// COCO crowd flag: true = crowd region, false = single instance.
1563    #[serde(default, skip_serializing_if = "Option::is_none")]
1564    iscrowd: Option<bool>,
1565    /// LVIS frequency group: "f" (frequent), "c" (common), "r" (rare).
1566    #[serde(default, skip_serializing_if = "Option::is_none")]
1567    category_frequency: Option<String>,
1568    #[serde(skip_serializing_if = "Option::is_none")]
1569    box2d: Option<Box2d>,
1570    #[serde(skip_serializing_if = "Option::is_none")]
1571    box3d: Option<Box3d>,
1572    /// Polygon vertices for instance segmentation.
1573    ///
1574    /// Wire name is `mask` for historical reasons: the Rust field was
1575    /// renamed from `mask: Mask` to `polygon: Polygon` after the
1576    /// `samples.populate2` contract was already locked in, and the server
1577    /// still expects the key to be `mask`. Uploads that emit `polygon`
1578    /// here get silently dropped. Deserialisation accepts both names
1579    /// because `AnnotationRaw` carries `alias = "mask"`.
1580    #[serde(rename(serialize = "mask"), skip_serializing_if = "Option::is_none")]
1581    polygon: Option<Polygon>,
1582    /// PNG-encoded raster mask (populated from Arrow, not from Studio JSON-RPC).
1583    #[serde(skip)]
1584    mask: Option<MaskData>,
1585    /// Detection confidence score for box2d (0..1).
1586    #[serde(skip_serializing_if = "Option::is_none")]
1587    box2d_score: Option<f32>,
1588    /// Detection confidence score for box3d (0..1).
1589    #[serde(skip_serializing_if = "Option::is_none")]
1590    box3d_score: Option<f32>,
1591    /// Confidence score for polygon (0..1).
1592    #[serde(skip_serializing_if = "Option::is_none")]
1593    polygon_score: Option<f32>,
1594    /// Confidence score for mask (0..1).
1595    #[serde(skip_serializing_if = "Option::is_none")]
1596    mask_score: Option<f32>,
1597}
1598
1599impl<'de> serde::Deserialize<'de> for Annotation {
1600    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1601    where
1602        D: serde::Deserializer<'de>,
1603    {
1604        // Deserialize to AnnotationRaw first to handle server format differences
1605        let raw: AnnotationRaw = serde::Deserialize::deserialize(deserializer)?;
1606
1607        // Prefer nested box2d if present, otherwise construct from flat x/y/w/h
1608        let box2d = raw.box2d.or_else(|| match (raw.x, raw.y, raw.w, raw.h) {
1609            (Some(x), Some(y), Some(w), Some(h)) if w > 0.0 && h > 0.0 => {
1610                Some(Box2d::new(x as f32, y as f32, w as f32, h as f32))
1611            }
1612            _ => None,
1613        });
1614
1615        Ok(Annotation {
1616            sample_id: raw.sample_id,
1617            name: raw.name,
1618            sequence_name: raw.sequence_name,
1619            frame_number: raw.frame_number,
1620            group: raw.group,
1621            object_id: raw.object_id,
1622            label_name: raw.label_name,
1623            label_index: raw.label_index,
1624            iscrowd: raw.iscrowd,
1625            category_frequency: raw.category_frequency,
1626            box2d,
1627            box3d: raw.box3d,
1628            polygon: raw.polygon,
1629            mask: None,
1630            box2d_score: None,
1631            box3d_score: None,
1632            polygon_score: None,
1633            mask_score: None,
1634        })
1635    }
1636}
1637
1638impl Default for Annotation {
1639    fn default() -> Self {
1640        Self::new()
1641    }
1642}
1643
1644impl Annotation {
1645    pub fn new() -> Self {
1646        Self {
1647            sample_id: None,
1648            name: None,
1649            sequence_name: None,
1650            frame_number: None,
1651            group: None,
1652            object_id: None,
1653            label_name: None,
1654            label_index: None,
1655            iscrowd: None,
1656            category_frequency: None,
1657            box2d: None,
1658            box3d: None,
1659            polygon: None,
1660            mask: None,
1661            box2d_score: None,
1662            box3d_score: None,
1663            polygon_score: None,
1664            mask_score: None,
1665        }
1666    }
1667
1668    pub fn set_sample_id(&mut self, sample_id: Option<SampleID>) {
1669        self.sample_id = sample_id;
1670    }
1671
1672    pub fn sample_id(&self) -> Option<SampleID> {
1673        self.sample_id
1674    }
1675
1676    pub fn set_name(&mut self, name: Option<String>) {
1677        self.name = name;
1678    }
1679
1680    pub fn name(&self) -> Option<&String> {
1681        self.name.as_ref()
1682    }
1683
1684    pub fn set_sequence_name(&mut self, sequence_name: Option<String>) {
1685        self.sequence_name = sequence_name;
1686    }
1687
1688    pub fn sequence_name(&self) -> Option<&String> {
1689        self.sequence_name.as_ref()
1690    }
1691
1692    pub fn set_frame_number(&mut self, frame_number: Option<u32>) {
1693        self.frame_number = frame_number;
1694    }
1695
1696    pub fn frame_number(&self) -> Option<u32> {
1697        self.frame_number
1698    }
1699
1700    pub fn set_group(&mut self, group: Option<String>) {
1701        self.group = group;
1702    }
1703
1704    pub fn group(&self) -> Option<&String> {
1705        self.group.as_ref()
1706    }
1707
1708    pub fn object_id(&self) -> Option<&String> {
1709        self.object_id.as_ref()
1710    }
1711
1712    pub fn set_object_id(&mut self, object_id: Option<String>) {
1713        self.object_id = object_id;
1714    }
1715
1716    pub fn label(&self) -> Option<&String> {
1717        self.label_name.as_ref()
1718    }
1719
1720    pub fn set_label(&mut self, label_name: Option<String>) {
1721        self.label_name = label_name;
1722    }
1723
1724    pub fn label_index(&self) -> Option<u64> {
1725        self.label_index
1726    }
1727
1728    pub fn set_label_index(&mut self, label_index: Option<u64>) {
1729        self.label_index = label_index;
1730    }
1731
1732    pub fn iscrowd(&self) -> Option<bool> {
1733        self.iscrowd
1734    }
1735
1736    pub fn set_iscrowd(&mut self, iscrowd: Option<bool>) {
1737        self.iscrowd = iscrowd;
1738    }
1739
1740    pub fn category_frequency(&self) -> Option<&String> {
1741        self.category_frequency.as_ref()
1742    }
1743
1744    pub fn set_category_frequency(&mut self, category_frequency: Option<String>) {
1745        self.category_frequency = category_frequency;
1746    }
1747
1748    pub fn box2d(&self) -> Option<&Box2d> {
1749        self.box2d.as_ref()
1750    }
1751
1752    pub fn set_box2d(&mut self, box2d: Option<Box2d>) {
1753        self.box2d = box2d;
1754    }
1755
1756    pub fn box3d(&self) -> Option<&Box3d> {
1757        self.box3d.as_ref()
1758    }
1759
1760    pub fn set_box3d(&mut self, box3d: Option<Box3d>) {
1761        self.box3d = box3d;
1762    }
1763
1764    pub fn polygon(&self) -> Option<&Polygon> {
1765        self.polygon.as_ref()
1766    }
1767
1768    pub fn set_polygon(&mut self, polygon: Option<Polygon>) {
1769        self.polygon = polygon;
1770    }
1771
1772    pub fn mask(&self) -> Option<&MaskData> {
1773        self.mask.as_ref()
1774    }
1775
1776    pub fn set_mask(&mut self, mask: Option<MaskData>) {
1777        self.mask = mask;
1778    }
1779
1780    pub fn box2d_score(&self) -> Option<f32> {
1781        self.box2d_score
1782    }
1783
1784    pub fn set_box2d_score(&mut self, score: Option<f32>) {
1785        self.box2d_score = score;
1786    }
1787
1788    pub fn box3d_score(&self) -> Option<f32> {
1789        self.box3d_score
1790    }
1791
1792    pub fn set_box3d_score(&mut self, score: Option<f32>) {
1793        self.box3d_score = score;
1794    }
1795
1796    pub fn polygon_score(&self) -> Option<f32> {
1797        self.polygon_score
1798    }
1799
1800    pub fn set_polygon_score(&mut self, score: Option<f32>) {
1801        self.polygon_score = score;
1802    }
1803
1804    pub fn mask_score(&self) -> Option<f32> {
1805        self.mask_score
1806    }
1807
1808    pub fn set_mask_score(&mut self, score: Option<f32>) {
1809        self.mask_score = score;
1810    }
1811}
1812
1813#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
1814pub struct Label {
1815    id: u64,
1816    dataset_id: DatasetID,
1817    index: u64,
1818    name: String,
1819}
1820
1821impl Label {
1822    pub fn id(&self) -> u64 {
1823        self.id
1824    }
1825
1826    pub fn dataset_id(&self) -> DatasetID {
1827        self.dataset_id
1828    }
1829
1830    pub fn index(&self) -> u64 {
1831        self.index
1832    }
1833
1834    pub fn name(&self) -> &str {
1835        &self.name
1836    }
1837
1838    pub async fn remove(&self, client: &Client) -> Result<(), Error> {
1839        client.remove_label(self.id()).await
1840    }
1841
1842    pub async fn set_name(&mut self, client: &Client, name: &str) -> Result<(), Error> {
1843        self.name = name.to_string();
1844        client.update_label(self).await
1845    }
1846
1847    pub async fn set_index(&mut self, client: &Client, index: u64) -> Result<(), Error> {
1848        self.index = index;
1849        client.update_label(self).await
1850    }
1851}
1852
1853impl Display for Label {
1854    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1855        write!(f, "{}", self.name())
1856    }
1857}
1858
1859#[derive(Serialize, Clone, Debug)]
1860pub struct NewLabelObject {
1861    pub name: String,
1862}
1863
1864#[derive(Serialize, Clone, Debug)]
1865pub struct NewLabel {
1866    pub dataset_id: DatasetID,
1867    pub labels: Vec<NewLabelObject>,
1868}
1869
1870/// A dataset group for organizing samples into logical subsets.
1871///
1872/// Groups are used to partition samples within a dataset for different purposes
1873/// such as training, validation, and testing. Each sample can belong to at most
1874/// one group at a time.
1875///
1876/// # Common Group Names
1877///
1878/// - `"train"` - Training data for model fitting
1879/// - `"val"` - Validation data for hyperparameter tuning
1880/// - `"test"` - Test data for final evaluation
1881///
1882/// # Examples
1883///
1884/// ```rust,no_run
1885/// use edgefirst_client::{Client, DatasetID};
1886///
1887/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1888/// let client = Client::new()?.with_token_path(None)?;
1889/// let dataset_id: DatasetID = "ds-123".try_into()?;
1890///
1891/// // List all groups in the dataset
1892/// let groups = client.groups(dataset_id).await?;
1893/// for group in groups {
1894///     println!("Group [{}]: {}", group.id, group.name);
1895/// }
1896/// # Ok(())
1897/// # }
1898/// ```
1899#[derive(Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
1900pub struct Group {
1901    /// The unique numeric identifier for this group.
1902    ///
1903    /// Group IDs are assigned by the server and are unique within an
1904    /// organization.
1905    pub id: u64,
1906
1907    /// The human-readable name of the group.
1908    ///
1909    /// Common names include "train", "val", "test", but any string is valid.
1910    pub name: String,
1911}
1912
1913#[cfg(feature = "polars")]
1914fn extract_annotation_name(ann: &Annotation) -> Option<(String, Option<u32>)> {
1915    use std::path::Path;
1916
1917    let name = ann.name.as_ref()?;
1918    let name = Path::new(name).file_stem()?.to_str()?;
1919
1920    // For sequences, return base name and frame number
1921    // For non-sequences, return name and None
1922    match &ann.sequence_name {
1923        Some(sequence) => Some((sequence.clone(), ann.frame_number)),
1924        None => Some((name.to_string(), None)),
1925    }
1926}
1927
1928/// Convert a polygon into a nested `List(List(Float32))` Series for the
1929/// 2026.04 schema. Each ring becomes an inner list of interleaved
1930/// `[x1, y1, x2, y2, ...]` floats.
1931#[cfg(feature = "polars")]
1932fn convert_polygon_to_nested_series(polygon: &Polygon) -> Series {
1933    let ring_series: Vec<Option<Series>> = polygon
1934        .rings
1935        .iter()
1936        .map(|ring| {
1937            let coords: Vec<f32> = ring.iter().flat_map(|&(x, y)| [x, y]).collect();
1938            Some(Series::new("".into(), coords))
1939        })
1940        .collect();
1941    Series::new("".into(), ring_series)
1942}
1943
1944/// Create a DataFrame from a slice of samples with the 2026.04 schema.
1945///
1946/// Each annotation in each sample becomes one row. Columns where every value
1947/// is null are automatically dropped, so the result only contains columns
1948/// that carry data. The `name` column is always present.
1949///
1950/// # Schema (2026.04)
1951///
1952/// - `name`: Sample name (String) - ALWAYS PRESENT
1953/// - `frame`: Frame number (UInt32)
1954/// - `object_id`: Object tracking ID (String)
1955/// - `label`: Object label (Categorical)
1956/// - `label_index`: Label index (UInt64)
1957/// - `group`: Dataset group (Categorical)
1958/// - `polygon`: Segmentation polygon rings (List<List<Float32>>)
1959/// - `box2d`: 2D bounding box [cx, cy, w, h] (Array<Float32, 4>)
1960/// - `box3d`: 3D bounding box [x, y, z, w, h, l] (Array<Float32, 6>)
1961/// - `mask`: PNG-encoded raster mask (Binary)
1962/// - `box2d_score`: Box2d confidence (Float32)
1963/// - `box3d_score`: Box3d confidence (Float32)
1964/// - `polygon_score`: Polygon confidence (Float32)
1965/// - `mask_score`: Mask confidence (Float32)
1966/// - `size`: Image size [width, height] (Array<UInt32, 2>)
1967/// - `location`: GPS [lat, lon] (Array<Float32, 2>)
1968/// - `pose`: IMU [yaw, pitch, roll] (Array<Float32, 3>)
1969/// - `degradation`: Image degradation (String)
1970/// - `iscrowd`: COCO crowd flag (Boolean)
1971/// - `category_frequency`: LVIS frequency group (Categorical)
1972/// - `neg_label_indices`: Verified-absent label indices (List<UInt32>)
1973/// - `not_exhaustive_label_indices`: Incomplete label indices (List<UInt32>)
1974/// - `timing`: Pipeline timing (Struct{load, preprocess, inference, decode} of Int64)
1975///
1976/// # Example
1977///
1978/// ```rust,no_run
1979/// use edgefirst_client::{Client, samples_dataframe};
1980///
1981/// # async fn example() -> Result<(), edgefirst_client::Error> {
1982/// # let client = Client::new()?;
1983/// # let dataset_id = 1.into();
1984/// # let annotation_set_id = 1.into();
1985/// let samples = client
1986///     .samples(dataset_id, Some(annotation_set_id), &[], &[], &[], None)
1987///     .await?;
1988/// let df = samples_dataframe(&samples)?;
1989/// println!("DataFrame shape: {:?}", df.shape());
1990/// # Ok(())
1991/// # }
1992/// ```
1993#[cfg(feature = "polars")]
1994pub fn samples_dataframe(samples: &[Sample]) -> Result<DataFrame, Error> {
1995    // Collect per-row vectors directly while iterating samples
1996    let mut names: Vec<String> = Vec::new();
1997    let mut frames: Vec<Option<u32>> = Vec::new();
1998    let mut objects: Vec<Option<String>> = Vec::new();
1999    let mut labels: Vec<Option<String>> = Vec::new();
2000    let mut label_indices: Vec<Option<u64>> = Vec::new();
2001    let mut groups: Vec<Option<String>> = Vec::new();
2002    let mut polygons: Vec<Option<Series>> = Vec::new();
2003    let mut boxes2d: Vec<Option<Series>> = Vec::new();
2004    let mut boxes3d: Vec<Option<Series>> = Vec::new();
2005    let mut mask_bytes: Vec<Option<Vec<u8>>> = Vec::new();
2006    let mut box2d_scores: Vec<Option<f32>> = Vec::new();
2007    let mut box3d_scores: Vec<Option<f32>> = Vec::new();
2008    let mut polygon_scores: Vec<Option<f32>> = Vec::new();
2009    let mut mask_scores: Vec<Option<f32>> = Vec::new();
2010    let mut sizes: Vec<Option<Vec<u32>>> = Vec::new();
2011    let mut locations: Vec<Option<Vec<f32>>> = Vec::new();
2012    let mut poses: Vec<Option<Vec<f32>>> = Vec::new();
2013    let mut degradations: Vec<Option<String>> = Vec::new();
2014    let mut iscrowds: Vec<Option<bool>> = Vec::new();
2015    let mut category_frequencies: Vec<Option<String>> = Vec::new();
2016    let mut neg_label_indices_vec: Vec<Option<Vec<u32>>> = Vec::new();
2017    let mut not_exhaustive_label_indices_vec: Vec<Option<Vec<u32>>> = Vec::new();
2018    let mut timing_load: Vec<Option<i64>> = Vec::new();
2019    let mut timing_preprocess: Vec<Option<i64>> = Vec::new();
2020    let mut timing_inference: Vec<Option<i64>> = Vec::new();
2021    let mut timing_decode: Vec<Option<i64>> = Vec::new();
2022
2023    for sample in samples {
2024        // Extract sample metadata once per sample
2025        let size = match (sample.width, sample.height) {
2026            (Some(w), Some(h)) => Some(vec![w, h]),
2027            _ => None,
2028        };
2029
2030        let location = sample.location.as_ref().and_then(|loc| {
2031            loc.gps
2032                .as_ref()
2033                .map(|gps| vec![gps.lat as f32, gps.lon as f32])
2034        });
2035
2036        let pose = sample.location.as_ref().and_then(|loc| {
2037            loc.imu
2038                .as_ref()
2039                .map(|imu| vec![imu.yaw as f32, imu.pitch as f32, imu.roll as f32])
2040        });
2041
2042        let degradation = sample.degradation.clone();
2043
2044        // Timing from the sample (same for all rows of this sample)
2045        let t_load = sample.timing.as_ref().and_then(|t| t.load);
2046        let t_preprocess = sample.timing.as_ref().and_then(|t| t.preprocess);
2047        let t_inference = sample.timing.as_ref().and_then(|t| t.inference);
2048        let t_decode = sample.timing.as_ref().and_then(|t| t.decode);
2049
2050        // Helper to push shared sample-level fields
2051        macro_rules! push_sample_fields {
2052            () => {
2053                sizes.push(size.clone());
2054                locations.push(location.clone());
2055                poses.push(pose.clone());
2056                degradations.push(degradation.clone());
2057                neg_label_indices_vec.push(sample.neg_label_indices.clone());
2058                not_exhaustive_label_indices_vec.push(sample.not_exhaustive_label_indices.clone());
2059                timing_load.push(t_load);
2060                timing_preprocess.push(t_preprocess);
2061                timing_inference.push(t_inference);
2062                timing_decode.push(t_decode);
2063            };
2064        }
2065
2066        if sample.annotations.is_empty() {
2067            // One row for the sample with null annotation fields
2068            let (name, frame) = match extract_annotation_name_from_sample(sample) {
2069                Some(nf) => nf,
2070                None => continue,
2071            };
2072
2073            names.push(name);
2074            frames.push(frame);
2075            objects.push(None);
2076            labels.push(None);
2077            label_indices.push(None);
2078            groups.push(sample.group.clone());
2079            polygons.push(None);
2080            boxes2d.push(None);
2081            boxes3d.push(None);
2082            mask_bytes.push(None);
2083            box2d_scores.push(None);
2084            box3d_scores.push(None);
2085            polygon_scores.push(None);
2086            mask_scores.push(None);
2087            iscrowds.push(None);
2088            category_frequencies.push(None);
2089            push_sample_fields!();
2090        } else {
2091            // One row per annotation
2092            for ann in &sample.annotations {
2093                let (name, frame) = match extract_annotation_name(ann) {
2094                    Some(nf) => nf,
2095                    None => continue,
2096                };
2097
2098                let polygon = ann.polygon.as_ref().map(convert_polygon_to_nested_series);
2099
2100                let box2d = ann
2101                    .box2d
2102                    .as_ref()
2103                    .map(|b| Series::new("box2d".into(), [b.cx(), b.cy(), b.width(), b.height()]));
2104
2105                let box3d = ann
2106                    .box3d
2107                    .as_ref()
2108                    .map(|b| Series::new("box3d".into(), [b.x, b.y, b.z, b.w, b.h, b.l]));
2109
2110                names.push(name);
2111                frames.push(frame);
2112                objects.push(ann.object_id().cloned());
2113                labels.push(ann.label_name.clone());
2114                label_indices.push(ann.label_index);
2115                groups.push(sample.group.clone());
2116                polygons.push(polygon);
2117                boxes2d.push(box2d);
2118                boxes3d.push(box3d);
2119                mask_bytes.push(ann.mask.as_ref().map(|m| m.as_bytes().to_vec()));
2120                box2d_scores.push(ann.box2d_score());
2121                box3d_scores.push(ann.box3d_score());
2122                polygon_scores.push(ann.polygon_score());
2123                mask_scores.push(ann.mask_score());
2124                iscrowds.push(ann.iscrowd);
2125                category_frequencies.push(ann.category_frequency.clone());
2126                push_sample_fields!();
2127            }
2128        }
2129    }
2130
2131    // Build DataFrame columns
2132    let names_col: Column = Series::new("name".into(), names).into();
2133    let frames_col: Column = Series::new("frame".into(), frames).into();
2134    let objects_col: Column = Series::new("object_id".into(), objects).into();
2135
2136    // Column name: "label" (NOT "label_name")
2137    //
2138    // Physical is U16 so taxonomies larger than 255 labels fit (LVIS v1 has
2139    // 1,203 categories). U16 caps at 65,535 — comfortably above any realistic
2140    // object-detection taxonomy — and only costs one extra byte per row vs U8.
2141    let labels_col: Column = Series::new("label".into(), labels)
2142        .cast(&DataType::Categorical(
2143            Categories::new("labels".into(), "labels".into(), CategoricalPhysical::U16),
2144            Arc::new(CategoricalMapping::with_hasher(
2145                u16::MAX as usize,
2146                Default::default(),
2147            )),
2148        ))?
2149        .into();
2150
2151    let label_indices_col: Column = Series::new("label_index".into(), label_indices).into();
2152
2153    // Column name: "group" (NOT "group_name")
2154    let groups_col: Column = Series::new("group".into(), groups)
2155        .cast(&DataType::Categorical(
2156            Categories::new("groups".into(), "groups".into(), CategoricalPhysical::U8),
2157            Arc::new(CategoricalMapping::with_hasher(
2158                u8::MAX as usize,
2159                Default::default(),
2160            )),
2161        ))?
2162        .into();
2163
2164    // Polygon: List(List(Float32)) — nested rings
2165    // Build using ListChunked to avoid Polars dtype mismatch when mixing Some/None entries.
2166    // Series::new() with Vec<Option<Series>> panics when Some entries are list[f32] but None
2167    // entries infer as list[null].
2168    let polygons_col: Column = if polygons.iter().all(|p| p.is_none()) {
2169        // All null — create a null column that the drop rule will remove
2170        Series::new_null("polygon".into(), polygons.len()).into()
2171    } else {
2172        // Build properly typed column: convert each Option<Series> to Option<Series>,
2173        // ensuring None entries don't cause dtype inference issues
2174        let typed_polygons: Vec<Option<Series>> = polygons
2175            .into_iter()
2176            .map(|opt| {
2177                opt.map(|s| {
2178                    s.cast(&DataType::List(Box::new(DataType::Float32)))
2179                        .unwrap_or(s)
2180                })
2181            })
2182            .collect();
2183        Series::new("polygon".into(), &typed_polygons)
2184            .cast(&DataType::List(Box::new(DataType::List(Box::new(
2185                DataType::Float32,
2186            )))))?
2187            .into()
2188    };
2189
2190    let boxes2d_col: Column = Series::new("box2d".into(), boxes2d)
2191        .cast(&DataType::Array(Box::new(DataType::Float32), 4))?
2192        .into();
2193    let boxes3d_col: Column = Series::new("box3d".into(), boxes3d)
2194        .cast(&DataType::Array(Box::new(DataType::Float32), 6))?
2195        .into();
2196
2197    // Mask: Binary (raw PNG bytes)
2198    let mask_col: Column = Series::new("mask".into(), mask_bytes).into();
2199
2200    // Score columns: Float32
2201    let box2d_score_col: Column = Series::new("box2d_score".into(), box2d_scores).into();
2202    let box3d_score_col: Column = Series::new("box3d_score".into(), box3d_scores).into();
2203    let polygon_score_col: Column = Series::new("polygon_score".into(), polygon_scores).into();
2204    let mask_score_col: Column = Series::new("mask_score".into(), mask_scores).into();
2205
2206    // Optional metadata columns (2025.10)
2207    let size_series: Vec<Option<Series>> = sizes
2208        .into_iter()
2209        .map(|opt_vec| opt_vec.map(|vec| Series::new("size".into(), vec)))
2210        .collect();
2211    let sizes_col: Column = Series::new("size".into(), size_series)
2212        .cast(&DataType::Array(Box::new(DataType::UInt32), 2))?
2213        .into();
2214
2215    let location_series: Vec<Option<Series>> = locations
2216        .into_iter()
2217        .map(|opt_vec| opt_vec.map(|vec| Series::new("location".into(), vec)))
2218        .collect();
2219    let locations_col: Column = Series::new("location".into(), location_series)
2220        .cast(&DataType::Array(Box::new(DataType::Float32), 2))?
2221        .into();
2222
2223    let pose_series: Vec<Option<Series>> = poses
2224        .into_iter()
2225        .map(|opt_vec| opt_vec.map(|vec| Series::new("pose".into(), vec)))
2226        .collect();
2227    let poses_col: Column = Series::new("pose".into(), pose_series)
2228        .cast(&DataType::Array(Box::new(DataType::Float32), 3))?
2229        .into();
2230
2231    let degradations_col: Column = Series::new("degradation".into(), degradations).into();
2232
2233    // LVIS extension columns
2234    let iscrowds_col: Column = Series::new("iscrowd".into(), iscrowds).into();
2235
2236    let category_frequencies_col: Column =
2237        Series::new("category_frequency".into(), category_frequencies)
2238            .cast(&DataType::Categorical(
2239                Categories::new(
2240                    "cat_freq".into(),
2241                    "cat_freq".into(),
2242                    CategoricalPhysical::U8,
2243                ),
2244                Arc::new(CategoricalMapping::with_hasher(
2245                    u8::MAX as usize,
2246                    Default::default(),
2247                )),
2248            ))?
2249            .into();
2250
2251    let neg_label_indices_series: Vec<Option<Series>> = neg_label_indices_vec
2252        .into_iter()
2253        .map(|opt_vec| opt_vec.map(|vec| Series::new("neg_label_indices".into(), vec)))
2254        .collect();
2255    let neg_label_indices_col: Column =
2256        Series::new("neg_label_indices".into(), neg_label_indices_series)
2257            .cast(&DataType::List(Box::new(DataType::UInt32)))?
2258            .into();
2259
2260    let not_exhaustive_label_indices_series: Vec<Option<Series>> = not_exhaustive_label_indices_vec
2261        .into_iter()
2262        .map(|opt_vec| opt_vec.map(|vec| Series::new("not_exhaustive_label_indices".into(), vec)))
2263        .collect();
2264    let not_exhaustive_label_indices_col: Column = Series::new(
2265        "not_exhaustive_label_indices".into(),
2266        not_exhaustive_label_indices_series,
2267    )
2268    .cast(&DataType::List(Box::new(DataType::UInt32)))?
2269    .into();
2270
2271    // Timing: Struct{load, preprocess, inference, decode} of Int64
2272    let timing_col: Column = StructChunked::from_series(
2273        "timing".into(),
2274        frames_col.len(),
2275        [
2276            Series::new("load".into(), &timing_load),
2277            Series::new("preprocess".into(), &timing_preprocess),
2278            Series::new("inference".into(), &timing_inference),
2279            Series::new("decode".into(), &timing_decode),
2280        ]
2281        .iter(),
2282    )?
2283    .into_series()
2284    .into();
2285
2286    // Collect all columns, then drop any where ALL values are null (except "name")
2287    let all_columns: Vec<Column> = vec![
2288        names_col,
2289        frames_col,
2290        objects_col,
2291        labels_col,
2292        label_indices_col,
2293        groups_col,
2294        polygons_col,
2295        boxes2d_col,
2296        boxes3d_col,
2297        mask_col,
2298        box2d_score_col,
2299        box3d_score_col,
2300        polygon_score_col,
2301        mask_score_col,
2302        sizes_col,
2303        locations_col,
2304        poses_col,
2305        degradations_col,
2306        iscrowds_col,
2307        category_frequencies_col,
2308        neg_label_indices_col,
2309        not_exhaustive_label_indices_col,
2310        timing_col,
2311    ];
2312
2313    let height = all_columns.first().map(|c| c.len()).unwrap_or(0);
2314
2315    let non_empty_columns: Vec<Column> = all_columns
2316        .into_iter()
2317        .filter(|col| col.name() == "name" || !is_all_null_column(col))
2318        .collect();
2319
2320    Ok(DataFrame::new(height, non_empty_columns)?)
2321}
2322
2323/// Returns `true` when every value in the column is null. For `Struct`
2324/// columns the check recurses into inner fields — the struct is considered
2325/// all-null when **all** of its fields are individually all-null.
2326#[cfg(feature = "polars")]
2327fn is_all_null_column(col: &Column) -> bool {
2328    if col.is_empty() {
2329        return true;
2330    }
2331    if col.null_count() == col.len() {
2332        return true;
2333    }
2334    // Struct columns may have non-null outer rows but all-null inner fields
2335    if let DataType::Struct(..) = col.dtype()
2336        && let Ok(s) = col.as_materialized_series().struct_()
2337    {
2338        return s
2339            .fields_as_series()
2340            .iter()
2341            .all(|field| field.null_count() == field.len());
2342    }
2343    false
2344}
2345
2346// Helper: Extract name/frame from Sample (for samples with no annotations)
2347#[cfg(feature = "polars")]
2348fn extract_annotation_name_from_sample(sample: &Sample) -> Option<(String, Option<u32>)> {
2349    use std::path::Path;
2350
2351    let name = sample.image_name.as_ref()?;
2352    let name = Path::new(name).file_stem()?.to_str()?;
2353
2354    // For sequences, return base name and frame number
2355    // For non-sequences, return name and None
2356    match &sample.sequence_name {
2357        Some(sequence) => Some((sequence.clone(), sample.frame_number)),
2358        None => Some((name.to_string(), None)),
2359    }
2360}
2361
2362// ============================================================================
2363// PURE FUNCTIONS FOR TESTABLE CORE LOGIC
2364// ============================================================================
2365
2366/// Extract sample name from image filename by:
2367/// 1. Removing file extension (everything after last dot)
2368/// 2. Removing .camera suffix if present
2369///
2370/// # Examples
2371/// - "scene_001.camera.jpg" → "scene_001"
2372/// - "image.jpg" → "image"
2373/// - ".jpg" → ".jpg" (preserves filenames starting with dot)
2374fn extract_sample_name(image_name: &str) -> String {
2375    // Step 1: Remove file extension (but preserve filenames starting with dot)
2376    let name = image_name
2377        .rsplit_once('.')
2378        .and_then(|(name, _)| {
2379            // Only remove extension if the name part is non-empty (handles ".jpg" case)
2380            if name.is_empty() {
2381                None
2382            } else {
2383                Some(name.to_string())
2384            }
2385        })
2386        .unwrap_or_else(|| image_name.to_string());
2387
2388    // Step 2: Remove .camera suffix if present
2389    name.rsplit_once(".camera")
2390        .and_then(|(name, _)| {
2391            // Only remove .camera if the name part is non-empty
2392            if name.is_empty() {
2393                None
2394            } else {
2395                Some(name.to_string())
2396            }
2397        })
2398        .unwrap_or_else(|| name.clone())
2399}
2400
2401/// Resolve a file for a given file type from sample data.
2402///
2403/// Returns the matching `SampleFile` if found, which may contain either
2404/// a URL (newer datasets) or inline data (legacy datasets).
2405///
2406/// # Arguments
2407/// * `file_type` - The type of file to resolve (e.g., LidarPcd, RadarPcd)
2408/// * `files` - The sample's file list
2409fn resolve_file<'a>(file_type: &FileType, files: &'a [SampleFile]) -> Option<&'a SampleFile> {
2410    match file_type {
2411        FileType::Image => None, // Image uses image_url field, not files
2412        FileType::All => None,   // All should be expanded before calling this
2413        file => {
2414            // Get all possible names for this file type (primary + aliases)
2415            let type_names = file_type_names(file);
2416            files
2417                .iter()
2418                .find(|f| type_names.contains(&f.r#type.as_str()))
2419        }
2420    }
2421}
2422
2423/// Returns all possible server-side names for a file type.
2424/// The server uses specific naming conventions in the STUDIO_DB_TYPE_MAP.
2425fn file_type_names(file_type: &FileType) -> Vec<&'static str> {
2426    match file_type {
2427        FileType::Image => vec!["image"],
2428        FileType::LidarPcd => vec!["lidar.pcd"],
2429        FileType::LidarDepth => vec!["lidar.depth", "depth.png", "depthmap"],
2430        FileType::LidarReflect => vec!["lidar.reflect"],
2431        FileType::RadarPcd => vec!["radar.pcd", "pcd"],
2432        FileType::RadarCube => vec!["radar.png", "cube"],
2433        FileType::All => vec![],
2434    }
2435}
2436
2437// ============================================================================
2438// DESERIALIZATION FORMAT CONVERSION HELPERS
2439// ============================================================================
2440
2441/// Convert annotations grouped format to flat Vec<Annotation>.
2442///
2443/// Pure function that handles the conversion from the server's legacy format
2444/// (HashMap<String, Vec<Annotation>>) to the flat Vec<Annotation>
2445/// representation.
2446///
2447/// # Arguments
2448/// * `map` - HashMap where keys are annotation types ("bbox", "box3d", "mask")
2449fn convert_annotations_map_to_vec(map: HashMap<String, Vec<Annotation>>) -> Vec<Annotation> {
2450    let mut all_annotations = Vec::new();
2451    if let Some(bbox_anns) = map.get("bbox") {
2452        all_annotations.extend(bbox_anns.clone());
2453    }
2454    if let Some(box3d_anns) = map.get("box3d") {
2455        all_annotations.extend(box3d_anns.clone());
2456    }
2457    if let Some(mask_anns) = map.get("mask") {
2458        all_annotations.extend(mask_anns.clone());
2459    }
2460    all_annotations
2461}
2462
2463// ============================================================================
2464// GPS/IMU VALIDATION HELPERS
2465// ============================================================================
2466
2467/// Validate GPS coordinates are within valid ranges.
2468///
2469/// Pure function that checks if latitude and longitude values are within valid
2470/// geographic ranges. Helps catch data corruption or API issues early.
2471///
2472/// # Arguments
2473/// * `lat` - Latitude in degrees
2474/// * `lon` - Longitude in degrees
2475///
2476/// # Returns
2477/// `Ok(())` if valid, `Err(String)` with descriptive error message otherwise
2478///
2479/// # Valid Ranges
2480/// - Latitude: -90.0 to +90.0 degrees
2481/// - Longitude: -180.0 to +180.0 degrees
2482fn validate_gps_coordinates(lat: f64, lon: f64) -> Result<(), String> {
2483    if !lat.is_finite() {
2484        return Err(format!("GPS latitude is not finite: {}", lat));
2485    }
2486    if !lon.is_finite() {
2487        return Err(format!("GPS longitude is not finite: {}", lon));
2488    }
2489    if !(-90.0..=90.0).contains(&lat) {
2490        return Err(format!("GPS latitude out of range [-90, 90]: {}", lat));
2491    }
2492    if !(-180.0..=180.0).contains(&lon) {
2493        return Err(format!("GPS longitude out of range [-180, 180]: {}", lon));
2494    }
2495    Ok(())
2496}
2497
2498/// Validate IMU orientation angles are within valid ranges.
2499///
2500/// Pure function that checks if roll, pitch, and yaw values are finite and
2501/// within reasonable ranges. Helps catch data corruption or sensor errors
2502/// early.
2503///
2504/// # Arguments
2505/// * `roll` - Roll angle in degrees
2506/// * `pitch` - Pitch angle in degrees
2507/// * `yaw` - Yaw angle in degrees
2508///
2509/// # Returns
2510/// `Ok(())` if valid, `Err(String)` with descriptive error message otherwise
2511///
2512/// # Valid Ranges
2513/// - Roll: -180.0 to +180.0 degrees
2514/// - Pitch: -90.0 to +90.0 degrees (typical gimbal lock range)
2515/// - Yaw: -180.0 to +180.0 degrees (or 0 to 360, normalized)
2516fn validate_imu_orientation(roll: f64, pitch: f64, yaw: f64) -> Result<(), String> {
2517    if !roll.is_finite() {
2518        return Err(format!("IMU roll is not finite: {}", roll));
2519    }
2520    if !pitch.is_finite() {
2521        return Err(format!("IMU pitch is not finite: {}", pitch));
2522    }
2523    if !yaw.is_finite() {
2524        return Err(format!("IMU yaw is not finite: {}", yaw));
2525    }
2526    if !(-180.0..=180.0).contains(&roll) {
2527        return Err(format!("IMU roll out of range [-180, 180]: {}", roll));
2528    }
2529    if !(-90.0..=90.0).contains(&pitch) {
2530        return Err(format!("IMU pitch out of range [-90, 90]: {}", pitch));
2531    }
2532    if !(-180.0..=180.0).contains(&yaw) {
2533        return Err(format!("IMU yaw out of range [-180, 180]: {}", yaw));
2534    }
2535    Ok(())
2536}
2537
2538// ============================================================================
2539// MASK POLYGON CONVERSION HELPERS
2540// ============================================================================
2541
2542/// Unflatten coordinates with NaN separators back to nested polygon
2543/// structure.
2544///
2545/// Converts flat list of coordinates with NaN separators back to nested
2546/// polygon structure:
2547/// - Input: [x1, y1, x2, y2, NaN, x3, y3]
2548/// - Output: [[(x1, y1), (x2, y2)], [(x3, y3)]]
2549///
2550/// This function is used when parsing Arrow files to reconstruct the nested
2551/// polygon format required by the EdgeFirst Studio API.
2552///
2553/// # Examples
2554///
2555/// ```rust
2556/// use edgefirst_client::unflatten_polygon_coordinates;
2557///
2558/// let coords = vec![1.0, 2.0, 3.0, 4.0, f32::NAN, 5.0, 6.0];
2559/// let polygons = unflatten_polygon_coordinates(&coords);
2560///
2561/// assert_eq!(polygons.len(), 2);
2562/// assert_eq!(polygons[0], vec![(1.0, 2.0), (3.0, 4.0)]);
2563/// assert_eq!(polygons[1], vec![(5.0, 6.0)]);
2564/// ```
2565#[cfg(feature = "polars")]
2566pub fn unflatten_polygon_coordinates(coords: &[f32]) -> Vec<Vec<(f32, f32)>> {
2567    let mut polygons = Vec::new();
2568    let mut current_polygon = Vec::new();
2569    let mut i = 0;
2570
2571    while i < coords.len() {
2572        if coords[i].is_nan() {
2573            // NaN separator - save current polygon and start new one
2574            if !current_polygon.is_empty() {
2575                polygons.push(std::mem::take(&mut current_polygon));
2576            }
2577            i += 1;
2578        } else if i + 1 < coords.len() && !coords[i + 1].is_nan() {
2579            // Have both x and y coordinates (neither is NaN)
2580            current_polygon.push((coords[i], coords[i + 1]));
2581            i += 2;
2582        } else if i + 1 < coords.len() && coords[i + 1].is_nan() {
2583            // x is valid but y is NaN - malformed data; skip x, process NaN on
2584            // next iteration
2585            i += 1;
2586        } else {
2587            // Odd trailing value - skip
2588            i += 1;
2589        }
2590    }
2591
2592    // Save the last polygon if not empty
2593    if !current_polygon.is_empty() {
2594        polygons.push(current_polygon);
2595    }
2596
2597    polygons
2598}
2599
2600#[cfg(test)]
2601mod tests {
2602    use super::*;
2603
2604    // ============================================================================
2605    // TEST HELPER FUNCTIONS (Pure Logic for Testing)
2606    // ============================================================================
2607
2608    /// Flatten legacy grouped annotation format to a single vector.
2609    ///
2610    /// Converts HashMap<String, Vec<Annotation>> (with bbox/box3d/mask keys)
2611    /// into a flat Vec<Annotation> in deterministic order.
2612    fn flatten_annotation_map(
2613        map: std::collections::HashMap<String, Vec<Annotation>>,
2614    ) -> Vec<Annotation> {
2615        let mut all_annotations = Vec::new();
2616
2617        // Process in fixed order for deterministic results
2618        for key in ["bbox", "box3d", "mask"] {
2619            if let Some(mut anns) = map.get(key).cloned() {
2620                all_annotations.append(&mut anns);
2621            }
2622        }
2623
2624        all_annotations
2625    }
2626
2627    /// Get the JSON field name for the Annotation group field (for tests).
2628    fn annotation_group_field_name() -> &'static str {
2629        "group_name"
2630    }
2631
2632    /// Get the JSON field name for the Annotation object_id field (for tests).
2633    fn annotation_object_id_field_name() -> &'static str {
2634        "object_reference"
2635    }
2636
2637    /// Get the accepted alias for the Annotation object_id field (for tests).
2638    fn annotation_object_id_alias() -> &'static str {
2639        "object_id"
2640    }
2641
2642    /// Validate that annotation field names match expected values in JSON (for
2643    /// tests).
2644    fn validate_annotation_field_names(
2645        json_str: &str,
2646        expected_group: bool,
2647        expected_object_ref: bool,
2648    ) -> Result<(), String> {
2649        if expected_group && !json_str.contains("\"group_name\"") {
2650            return Err("Missing expected field: group_name".to_string());
2651        }
2652        if expected_object_ref && !json_str.contains("\"object_reference\"") {
2653            return Err("Missing expected field: object_reference".to_string());
2654        }
2655        Ok(())
2656    }
2657
2658    // ==== FileType Conversion Tests ====
2659    #[test]
2660    fn test_file_type_conversions() {
2661        // to_string() returns server API type names
2662        let api_cases = vec![
2663            (FileType::Image, "image"),
2664            (FileType::LidarPcd, "lidar.pcd"),
2665            (FileType::LidarDepth, "lidar.depth"),
2666            (FileType::LidarReflect, "lidar.reflect"),
2667            (FileType::RadarPcd, "radar.pcd"),
2668            (FileType::RadarCube, "radar.png"),
2669        ];
2670
2671        // file_extension() returns file extensions for saving
2672        let ext_cases = vec![
2673            (FileType::Image, "jpg"),
2674            (FileType::LidarPcd, "lidar.pcd"),
2675            (FileType::LidarDepth, "lidar.png"),
2676            (FileType::LidarReflect, "lidar.jpg"),
2677            (FileType::RadarPcd, "radar.pcd"),
2678            (FileType::RadarCube, "radar.png"),
2679        ];
2680
2681        // Test: Display → to_string() returns server API names
2682        for (file_type, expected_str) in &api_cases {
2683            assert_eq!(file_type.to_string(), *expected_str);
2684        }
2685
2686        // Test: file_extension() returns correct extensions
2687        for (file_type, expected_ext) in &ext_cases {
2688            assert_eq!(file_type.file_extension(), *expected_ext);
2689        }
2690
2691        // Test: try_from() string parsing (accepts multiple aliases)
2692        assert_eq!(
2693            FileType::try_from("lidar.depth").unwrap(),
2694            FileType::LidarDepth
2695        );
2696        assert_eq!(
2697            FileType::try_from("lidar.png").unwrap(),
2698            FileType::LidarDepth
2699        );
2700        assert_eq!(
2701            FileType::try_from("depth.png").unwrap(),
2702            FileType::LidarDepth
2703        );
2704        assert_eq!(
2705            FileType::try_from("lidar.reflect").unwrap(),
2706            FileType::LidarReflect
2707        );
2708        assert_eq!(
2709            FileType::try_from("lidar.jpg").unwrap(),
2710            FileType::LidarReflect
2711        );
2712        assert_eq!(
2713            FileType::try_from("lidar.jpeg").unwrap(),
2714            FileType::LidarReflect
2715        );
2716
2717        // Test: Invalid input
2718        assert!(FileType::try_from("invalid").is_err());
2719
2720        // Test: Round-trip (Display → try_from)
2721        for (file_type, _) in &api_cases {
2722            let s = file_type.to_string();
2723            let parsed = FileType::try_from(s.as_str()).unwrap();
2724            assert_eq!(parsed, *file_type);
2725        }
2726    }
2727
2728    // ==== AnnotationType Conversion Tests ====
2729    #[test]
2730    fn test_annotation_type_conversions() {
2731        let cases = vec![
2732            (AnnotationType::Box2d, "box2d"),
2733            (AnnotationType::Box3d, "box3d"),
2734            (AnnotationType::Polygon, "polygon"),
2735            (AnnotationType::Mask, "mask"),
2736        ];
2737
2738        // Test: Display → to_string()
2739        for (ann_type, expected_str) in &cases {
2740            assert_eq!(ann_type.to_string(), *expected_str);
2741        }
2742
2743        // Test: try_from() string parsing
2744        assert_eq!(
2745            AnnotationType::try_from("box2d").unwrap(),
2746            AnnotationType::Box2d
2747        );
2748        assert_eq!(
2749            AnnotationType::try_from("box3d").unwrap(),
2750            AnnotationType::Box3d
2751        );
2752        assert_eq!(
2753            AnnotationType::try_from("polygon").unwrap(),
2754            AnnotationType::Polygon
2755        );
2756        // "mask" maps to Polygon for backward compat
2757        assert_eq!(
2758            AnnotationType::try_from("mask").unwrap(),
2759            AnnotationType::Polygon
2760        );
2761        // "raster" maps to Mask
2762        assert_eq!(
2763            AnnotationType::try_from("raster").unwrap(),
2764            AnnotationType::Mask
2765        );
2766
2767        // Test: From<String> (backward compatibility)
2768        assert_eq!(
2769            AnnotationType::from("box2d".to_string()),
2770            AnnotationType::Box2d
2771        );
2772        assert_eq!(
2773            AnnotationType::from("box3d".to_string()),
2774            AnnotationType::Box3d
2775        );
2776        assert_eq!(
2777            AnnotationType::from("polygon".to_string()),
2778            AnnotationType::Polygon
2779        );
2780        // "mask" string maps to Polygon for backward compat
2781        assert_eq!(
2782            AnnotationType::from("mask".to_string()),
2783            AnnotationType::Polygon
2784        );
2785
2786        // Invalid defaults to Box2d for backward compatibility
2787        assert_eq!(
2788            AnnotationType::from("invalid".to_string()),
2789            AnnotationType::Box2d
2790        );
2791
2792        // Test: Invalid input
2793        assert!(AnnotationType::try_from("invalid").is_err());
2794
2795        // Test: Round-trip (Display → try_from)
2796        // Note: Polygon round-trips ("polygon" → Polygon), but Mask does not
2797        // because "mask" → Polygon (backward compat). Mask displays as "mask"
2798        // but parses to Polygon.
2799        assert_eq!(
2800            AnnotationType::try_from(AnnotationType::Box2d.to_string().as_str()).unwrap(),
2801            AnnotationType::Box2d
2802        );
2803        assert_eq!(
2804            AnnotationType::try_from(AnnotationType::Box3d.to_string().as_str()).unwrap(),
2805            AnnotationType::Box3d
2806        );
2807        assert_eq!(
2808            AnnotationType::try_from(AnnotationType::Polygon.to_string().as_str()).unwrap(),
2809            AnnotationType::Polygon
2810        );
2811    }
2812
2813    #[test]
2814    fn test_annotation_type_as_server_type() {
2815        // `as_server_type` returns the IO names the samples/annotations RPC
2816        // accepts for its `types` filter; the server maps these to DB types.
2817        // Note Polygon -> "mask" here (an accepted filter alias), which differs
2818        // from the Display/column name ("polygon").
2819        assert_eq!(AnnotationType::Box2d.as_server_type(), "box2d");
2820        assert_eq!(AnnotationType::Box3d.as_server_type(), "box3d");
2821        assert_eq!(AnnotationType::Polygon.as_server_type(), "mask");
2822        assert_eq!(AnnotationType::Mask.as_server_type(), "mask");
2823
2824        // The server type differs from the Display name only for Polygon — the
2825        // distinction the issue-#8 download-annotations fix depended on.
2826        assert_ne!(
2827            AnnotationType::Polygon.as_server_type(),
2828            AnnotationType::Polygon.to_string().as_str()
2829        );
2830        assert_eq!(
2831            AnnotationType::Box2d.as_server_type(),
2832            AnnotationType::Box2d.to_string().as_str()
2833        );
2834    }
2835
2836    // ==== Pure Function: extract_sample_name Tests ====
2837    #[test]
2838    fn test_extract_sample_name_with_extension_and_camera() {
2839        assert_eq!(extract_sample_name("scene_001.camera.jpg"), "scene_001");
2840    }
2841
2842    #[test]
2843    fn test_extract_sample_name_multiple_dots() {
2844        assert_eq!(extract_sample_name("image.v2.camera.png"), "image.v2");
2845    }
2846
2847    #[test]
2848    fn test_extract_sample_name_extension_only() {
2849        assert_eq!(extract_sample_name("test.jpg"), "test");
2850    }
2851
2852    #[test]
2853    fn test_extract_sample_name_no_extension() {
2854        assert_eq!(extract_sample_name("test"), "test");
2855    }
2856
2857    #[test]
2858    fn test_extract_sample_name_edge_case_dot_prefix() {
2859        assert_eq!(extract_sample_name(".jpg"), ".jpg");
2860    }
2861
2862    // ==== File Resolution Tests ====
2863    #[test]
2864    fn test_resolve_file_image_type_returns_none() {
2865        // Image type uses image_url field, not files array
2866        let files = vec![];
2867        let result = resolve_file(&FileType::Image, &files);
2868        assert!(result.is_none());
2869    }
2870
2871    #[test]
2872    fn test_resolve_file_lidar_pcd() {
2873        let files = vec![
2874            SampleFile::with_url(
2875                "lidar.pcd".to_string(),
2876                "https://example.com/file.pcd".to_string(),
2877            ),
2878            SampleFile::with_url(
2879                "radar.pcd".to_string(),
2880                "https://example.com/radar.pcd".to_string(),
2881            ),
2882        ];
2883        let result = resolve_file(&FileType::LidarPcd, &files);
2884        assert!(result.is_some());
2885        assert_eq!(result.unwrap().url(), Some("https://example.com/file.pcd"));
2886    }
2887
2888    #[test]
2889    fn test_resolve_file_not_found() {
2890        let files = vec![SampleFile::with_url(
2891            "lidar.pcd".to_string(),
2892            "https://example.com/file.pcd".to_string(),
2893        )];
2894        // Requesting radar.pcd which doesn't exist in files
2895        let result = resolve_file(&FileType::RadarPcd, &files);
2896        assert!(result.is_none());
2897    }
2898
2899    #[test]
2900    fn test_resolve_file_lidar_depth() {
2901        // Server returns "lidar.depth" for LiDAR depth data
2902        let files = vec![SampleFile::with_url(
2903            "lidar.depth".to_string(),
2904            "https://example.com/depth.png".to_string(),
2905        )];
2906        let result = resolve_file(&FileType::LidarDepth, &files);
2907        assert!(result.is_some());
2908        assert_eq!(result.unwrap().url(), Some("https://example.com/depth.png"));
2909    }
2910
2911    #[test]
2912    fn test_resolve_file_lidar_reflect() {
2913        // Server returns "lidar.reflect" for LiDAR reflectance data
2914        let files = vec![SampleFile::with_url(
2915            "lidar.reflect".to_string(),
2916            "https://example.com/reflect.png".to_string(),
2917        )];
2918        let result = resolve_file(&FileType::LidarReflect, &files);
2919        assert!(result.is_some());
2920        assert_eq!(
2921            result.unwrap().url(),
2922            Some("https://example.com/reflect.png")
2923        );
2924    }
2925
2926    #[test]
2927    fn test_resolve_file_radar_cube() {
2928        // Server returns "radar.png" or "cube" for radar cube data
2929        let files = vec![SampleFile::with_url(
2930            "radar.png".to_string(),
2931            "https://example.com/radar.png".to_string(),
2932        )];
2933        let result = resolve_file(&FileType::RadarCube, &files);
2934        assert!(result.is_some());
2935        assert_eq!(result.unwrap().url(), Some("https://example.com/radar.png"));
2936    }
2937
2938    #[test]
2939    fn test_resolve_file_with_inline_data() {
2940        // Legacy datasets may have inline data instead of URLs
2941        let files = vec![SampleFile::with_data(
2942            "radar.pcd".to_string(),
2943            "SGVsbG8gV29ybGQ=".to_string(), // base64 "Hello World"
2944        )];
2945        let result = resolve_file(&FileType::RadarPcd, &files);
2946        assert!(result.is_some());
2947        let file = result.unwrap();
2948        assert!(file.url().is_none());
2949        assert_eq!(file.data(), Some("SGVsbG8gV29ybGQ="));
2950    }
2951
2952    #[test]
2953    fn test_convert_annotations_map_to_vec_with_bbox() {
2954        let mut map = HashMap::new();
2955        let bbox_ann = Annotation::new();
2956        map.insert("bbox".to_string(), vec![bbox_ann.clone()]);
2957
2958        let annotations = convert_annotations_map_to_vec(map);
2959        assert_eq!(annotations.len(), 1);
2960    }
2961
2962    #[test]
2963    fn test_convert_annotations_map_to_vec_all_types() {
2964        let mut map = HashMap::new();
2965        map.insert("bbox".to_string(), vec![Annotation::new()]);
2966        map.insert("box3d".to_string(), vec![Annotation::new()]);
2967        map.insert("mask".to_string(), vec![Annotation::new()]);
2968
2969        let annotations = convert_annotations_map_to_vec(map);
2970        assert_eq!(annotations.len(), 3);
2971    }
2972
2973    #[test]
2974    fn test_convert_annotations_map_to_vec_empty() {
2975        let map = HashMap::new();
2976        let annotations = convert_annotations_map_to_vec(map);
2977        assert_eq!(annotations.len(), 0);
2978    }
2979
2980    #[test]
2981    fn test_convert_annotations_map_to_vec_unknown_type_ignored() {
2982        let mut map = HashMap::new();
2983        map.insert("unknown".to_string(), vec![Annotation::new()]);
2984
2985        let annotations = convert_annotations_map_to_vec(map);
2986        // Unknown types are ignored
2987        assert_eq!(annotations.len(), 0);
2988    }
2989
2990    // ==== Annotation Field Mapping Tests ====
2991    #[test]
2992    fn test_annotation_group_field_name() {
2993        assert_eq!(annotation_group_field_name(), "group_name");
2994    }
2995
2996    #[test]
2997    fn test_annotation_object_id_field_name() {
2998        assert_eq!(annotation_object_id_field_name(), "object_reference");
2999    }
3000
3001    #[test]
3002    fn test_annotation_object_id_alias() {
3003        assert_eq!(annotation_object_id_alias(), "object_id");
3004    }
3005
3006    #[test]
3007    fn test_validate_annotation_field_names_success() {
3008        let json = r#"{"group_name":"train","object_reference":"obj1"}"#;
3009        assert!(validate_annotation_field_names(json, true, true).is_ok());
3010    }
3011
3012    #[test]
3013    fn test_validate_annotation_field_names_missing_group() {
3014        let json = r#"{"object_reference":"obj1"}"#;
3015        let result = validate_annotation_field_names(json, true, false);
3016        assert!(result.is_err());
3017        assert!(result.unwrap_err().contains("group_name"));
3018    }
3019
3020    #[test]
3021    fn test_validate_annotation_field_names_missing_object_ref() {
3022        let json = r#"{"group_name":"train"}"#;
3023        let result = validate_annotation_field_names(json, false, true);
3024        assert!(result.is_err());
3025        assert!(result.unwrap_err().contains("object_reference"));
3026    }
3027
3028    #[test]
3029    fn test_annotation_serialization_field_names() {
3030        // Test that Annotation serializes with correct field names
3031        let mut ann = Annotation::new();
3032        ann.set_group(Some("train".to_string()));
3033        ann.set_object_id(Some("obj1".to_string()));
3034
3035        let json = serde_json::to_string(&ann).unwrap();
3036        // Verify JSON contains correct field names
3037        assert!(validate_annotation_field_names(&json, true, true).is_ok());
3038    }
3039
3040    // ==== GPS/IMU Validation Tests ====
3041    #[test]
3042    fn test_validate_gps_coordinates_valid() {
3043        assert!(validate_gps_coordinates(37.7749, -122.4194).is_ok()); // San Francisco
3044        assert!(validate_gps_coordinates(0.0, 0.0).is_ok()); // Null Island
3045        assert!(validate_gps_coordinates(90.0, 180.0).is_ok()); // Edge cases
3046        assert!(validate_gps_coordinates(-90.0, -180.0).is_ok()); // Edge cases
3047    }
3048
3049    #[test]
3050    fn test_validate_gps_coordinates_invalid_latitude() {
3051        let result = validate_gps_coordinates(91.0, 0.0);
3052        assert!(result.is_err());
3053        assert!(result.unwrap_err().contains("latitude out of range"));
3054
3055        let result = validate_gps_coordinates(-91.0, 0.0);
3056        assert!(result.is_err());
3057        assert!(result.unwrap_err().contains("latitude out of range"));
3058    }
3059
3060    #[test]
3061    fn test_validate_gps_coordinates_invalid_longitude() {
3062        let result = validate_gps_coordinates(0.0, 181.0);
3063        assert!(result.is_err());
3064        assert!(result.unwrap_err().contains("longitude out of range"));
3065
3066        let result = validate_gps_coordinates(0.0, -181.0);
3067        assert!(result.is_err());
3068        assert!(result.unwrap_err().contains("longitude out of range"));
3069    }
3070
3071    #[test]
3072    fn test_validate_gps_coordinates_non_finite() {
3073        let result = validate_gps_coordinates(f64::NAN, 0.0);
3074        assert!(result.is_err());
3075        assert!(result.unwrap_err().contains("not finite"));
3076
3077        let result = validate_gps_coordinates(0.0, f64::INFINITY);
3078        assert!(result.is_err());
3079        assert!(result.unwrap_err().contains("not finite"));
3080    }
3081
3082    #[test]
3083    fn test_validate_imu_orientation_valid() {
3084        assert!(validate_imu_orientation(0.0, 0.0, 0.0).is_ok());
3085        assert!(validate_imu_orientation(45.0, 30.0, 90.0).is_ok());
3086        assert!(validate_imu_orientation(180.0, 90.0, -180.0).is_ok()); // Edge cases
3087        assert!(validate_imu_orientation(-180.0, -90.0, 180.0).is_ok()); // Edge cases
3088    }
3089
3090    #[test]
3091    fn test_validate_imu_orientation_invalid_roll() {
3092        let result = validate_imu_orientation(181.0, 0.0, 0.0);
3093        assert!(result.is_err());
3094        assert!(result.unwrap_err().contains("roll out of range"));
3095
3096        let result = validate_imu_orientation(-181.0, 0.0, 0.0);
3097        assert!(result.is_err());
3098    }
3099
3100    #[test]
3101    fn test_validate_imu_orientation_invalid_pitch() {
3102        let result = validate_imu_orientation(0.0, 91.0, 0.0);
3103        assert!(result.is_err());
3104        assert!(result.unwrap_err().contains("pitch out of range"));
3105
3106        let result = validate_imu_orientation(0.0, -91.0, 0.0);
3107        assert!(result.is_err());
3108    }
3109
3110    #[test]
3111    fn test_validate_imu_orientation_non_finite() {
3112        let result = validate_imu_orientation(f64::NAN, 0.0, 0.0);
3113        assert!(result.is_err());
3114        assert!(result.unwrap_err().contains("not finite"));
3115
3116        let result = validate_imu_orientation(0.0, f64::INFINITY, 0.0);
3117        assert!(result.is_err());
3118
3119        let result = validate_imu_orientation(0.0, 0.0, f64::NEG_INFINITY);
3120        assert!(result.is_err());
3121    }
3122
3123    // ==== Polygon Unflattening Tests ====
3124    #[test]
3125    #[cfg(feature = "polars")]
3126    fn test_unflatten_polygon_coordinates_single_polygon() {
3127        let coords = vec![1.0, 2.0, 3.0, 4.0];
3128        let result = unflatten_polygon_coordinates(&coords);
3129
3130        assert_eq!(result.len(), 1);
3131        assert_eq!(result[0].len(), 2);
3132        assert_eq!(result[0][0], (1.0, 2.0));
3133        assert_eq!(result[0][1], (3.0, 4.0));
3134    }
3135
3136    #[test]
3137    #[cfg(feature = "polars")]
3138    fn test_unflatten_polygon_coordinates_multiple_polygons() {
3139        let coords = vec![1.0, 2.0, 3.0, 4.0, f32::NAN, 5.0, 6.0, 7.0, 8.0];
3140        let result = unflatten_polygon_coordinates(&coords);
3141
3142        assert_eq!(result.len(), 2);
3143        assert_eq!(result[0].len(), 2);
3144        assert_eq!(result[0][0], (1.0, 2.0));
3145        assert_eq!(result[0][1], (3.0, 4.0));
3146        assert_eq!(result[1].len(), 2);
3147        assert_eq!(result[1][0], (5.0, 6.0));
3148        assert_eq!(result[1][1], (7.0, 8.0));
3149    }
3150
3151    #[test]
3152    #[cfg(feature = "polars")]
3153    fn test_unflatten_polygon_coordinates_roundtrip() {
3154        // Test that unflatten correctly reconstructs from NaN-separated flat coords
3155        let flat = vec![1.0, 2.0, 3.0, 4.0, f32::NAN, 5.0, 6.0, 7.0, 8.0];
3156        let result = unflatten_polygon_coordinates(&flat);
3157
3158        let expected = vec![vec![(1.0, 2.0), (3.0, 4.0)], vec![(5.0, 6.0), (7.0, 8.0)]];
3159        assert_eq!(result, expected);
3160    }
3161
3162    // ==== Annotation Format Flattening Tests ====
3163    #[test]
3164    fn test_flatten_annotation_map_all_types() {
3165        use std::collections::HashMap;
3166
3167        let mut map = HashMap::new();
3168
3169        // Create test annotations
3170        let mut bbox_ann = Annotation::new();
3171        bbox_ann.set_label(Some("bbox_label".to_string()));
3172
3173        let mut box3d_ann = Annotation::new();
3174        box3d_ann.set_label(Some("box3d_label".to_string()));
3175
3176        let mut mask_ann = Annotation::new();
3177        mask_ann.set_label(Some("mask_label".to_string()));
3178
3179        map.insert("bbox".to_string(), vec![bbox_ann.clone()]);
3180        map.insert("box3d".to_string(), vec![box3d_ann.clone()]);
3181        map.insert("mask".to_string(), vec![mask_ann.clone()]);
3182
3183        let result = flatten_annotation_map(map);
3184
3185        assert_eq!(result.len(), 3);
3186        // Check ordering: bbox, box3d, mask
3187        assert_eq!(result[0].label(), Some(&"bbox_label".to_string()));
3188        assert_eq!(result[1].label(), Some(&"box3d_label".to_string()));
3189        assert_eq!(result[2].label(), Some(&"mask_label".to_string()));
3190    }
3191
3192    #[test]
3193    fn test_flatten_annotation_map_single_type() {
3194        use std::collections::HashMap;
3195
3196        let mut map = HashMap::new();
3197        let mut bbox_ann = Annotation::new();
3198        bbox_ann.set_label(Some("test".to_string()));
3199        map.insert("bbox".to_string(), vec![bbox_ann]);
3200
3201        let result = flatten_annotation_map(map);
3202
3203        assert_eq!(result.len(), 1);
3204        assert_eq!(result[0].label(), Some(&"test".to_string()));
3205    }
3206
3207    #[test]
3208    fn test_flatten_annotation_map_empty() {
3209        use std::collections::HashMap;
3210
3211        let map = HashMap::new();
3212        let result = flatten_annotation_map(map);
3213
3214        assert_eq!(result.len(), 0);
3215    }
3216
3217    #[test]
3218    fn test_flatten_annotation_map_deterministic_order() {
3219        use std::collections::HashMap;
3220
3221        let mut map = HashMap::new();
3222
3223        let mut bbox_ann = Annotation::new();
3224        bbox_ann.set_label(Some("bbox".to_string()));
3225
3226        let mut box3d_ann = Annotation::new();
3227        box3d_ann.set_label(Some("box3d".to_string()));
3228
3229        let mut mask_ann = Annotation::new();
3230        mask_ann.set_label(Some("mask".to_string()));
3231
3232        // Insert in reverse order to test deterministic ordering
3233        map.insert("mask".to_string(), vec![mask_ann]);
3234        map.insert("box3d".to_string(), vec![box3d_ann]);
3235        map.insert("bbox".to_string(), vec![bbox_ann]);
3236
3237        let result = flatten_annotation_map(map);
3238
3239        // Should be bbox, box3d, mask regardless of insertion order
3240        assert_eq!(result.len(), 3);
3241        assert_eq!(result[0].label(), Some(&"bbox".to_string()));
3242        assert_eq!(result[1].label(), Some(&"box3d".to_string()));
3243        assert_eq!(result[2].label(), Some(&"mask".to_string()));
3244    }
3245
3246    // ==== Box2d Tests ====
3247    #[test]
3248    fn test_box2d_construction_and_accessors() {
3249        // Test case 1: Basic construction with positive coordinates
3250        let bbox = Box2d::new(10.0, 20.0, 100.0, 50.0);
3251        assert_eq!(
3252            (bbox.left(), bbox.top(), bbox.width(), bbox.height()),
3253            (10.0, 20.0, 100.0, 50.0)
3254        );
3255
3256        // Test case 2: Center calculations
3257        assert_eq!((bbox.cx(), bbox.cy()), (60.0, 45.0)); // 10+50, 20+25
3258
3259        // Test case 3: Zero origin
3260        let bbox = Box2d::new(0.0, 0.0, 640.0, 480.0);
3261        assert_eq!(
3262            (bbox.left(), bbox.top(), bbox.width(), bbox.height()),
3263            (0.0, 0.0, 640.0, 480.0)
3264        );
3265        assert_eq!((bbox.cx(), bbox.cy()), (320.0, 240.0));
3266    }
3267
3268    #[test]
3269    fn test_box2d_center_calculation() {
3270        let bbox = Box2d::new(10.0, 20.0, 100.0, 50.0);
3271
3272        // Center = position + size/2
3273        assert_eq!(bbox.cx(), 60.0); // 10 + 100/2
3274        assert_eq!(bbox.cy(), 45.0); // 20 + 50/2
3275    }
3276
3277    #[test]
3278    fn test_box2d_zero_dimensions() {
3279        let bbox = Box2d::new(10.0, 20.0, 0.0, 0.0);
3280
3281        // When width/height are zero, center = position
3282        assert_eq!(bbox.cx(), 10.0);
3283        assert_eq!(bbox.cy(), 20.0);
3284    }
3285
3286    #[test]
3287    fn test_box2d_negative_dimensions() {
3288        let bbox = Box2d::new(100.0, 100.0, -50.0, -50.0);
3289
3290        // Negative dimensions create inverted boxes (valid edge case)
3291        assert_eq!(bbox.width(), -50.0);
3292        assert_eq!(bbox.height(), -50.0);
3293        assert_eq!(bbox.cx(), 75.0); // 100 + (-50)/2
3294        assert_eq!(bbox.cy(), 75.0); // 100 + (-50)/2
3295    }
3296
3297    // ==== Box3d Tests ====
3298    #[test]
3299    fn test_box3d_construction_and_accessors() {
3300        // Test case 1: Basic 3D construction
3301        let bbox = Box3d::new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
3302        assert_eq!((bbox.cx(), bbox.cy(), bbox.cz()), (1.0, 2.0, 3.0));
3303        assert_eq!(
3304            (bbox.width(), bbox.height(), bbox.length()),
3305            (4.0, 5.0, 6.0)
3306        );
3307
3308        // Test case 2: Corners calculation with offset center
3309        let bbox = Box3d::new(10.0, 20.0, 30.0, 4.0, 6.0, 8.0);
3310        assert_eq!((bbox.left(), bbox.top(), bbox.front()), (8.0, 17.0, 26.0)); // 10-2, 20-3, 30-4
3311
3312        // Test case 3: Center at origin with negative corners
3313        let bbox = Box3d::new(0.0, 0.0, 0.0, 2.0, 3.0, 4.0);
3314        assert_eq!((bbox.cx(), bbox.cy(), bbox.cz()), (0.0, 0.0, 0.0));
3315        assert_eq!(
3316            (bbox.width(), bbox.height(), bbox.length()),
3317            (2.0, 3.0, 4.0)
3318        );
3319        assert_eq!((bbox.left(), bbox.top(), bbox.front()), (-1.0, -1.5, -2.0));
3320    }
3321
3322    #[test]
3323    fn test_box3d_center_calculation() {
3324        let bbox = Box3d::new(10.0, 20.0, 30.0, 100.0, 50.0, 40.0);
3325
3326        // Center values as specified in constructor
3327        assert_eq!(bbox.cx(), 10.0);
3328        assert_eq!(bbox.cy(), 20.0);
3329        assert_eq!(bbox.cz(), 30.0);
3330    }
3331
3332    #[test]
3333    fn test_box3d_zero_dimensions() {
3334        let bbox = Box3d::new(5.0, 10.0, 15.0, 0.0, 0.0, 0.0);
3335
3336        // When all dimensions are zero, corners = center
3337        assert_eq!(bbox.cx(), 5.0);
3338        assert_eq!(bbox.cy(), 10.0);
3339        assert_eq!(bbox.cz(), 15.0);
3340        assert_eq!((bbox.left(), bbox.top(), bbox.front()), (5.0, 10.0, 15.0));
3341    }
3342
3343    #[test]
3344    fn test_box3d_negative_dimensions() {
3345        let bbox = Box3d::new(100.0, 100.0, 100.0, -50.0, -50.0, -50.0);
3346
3347        // Negative dimensions create inverted boxes
3348        assert_eq!(bbox.width(), -50.0);
3349        assert_eq!(bbox.height(), -50.0);
3350        assert_eq!(bbox.length(), -50.0);
3351        assert_eq!(
3352            (bbox.left(), bbox.top(), bbox.front()),
3353            (125.0, 125.0, 125.0)
3354        );
3355    }
3356
3357    // ==== Polygon Tests ====
3358    #[test]
3359    fn test_polygon_creation_and_deserialization() {
3360        // Test case 1: Direct construction
3361        let rings = vec![vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]];
3362        let polygon = Polygon::new(rings.clone());
3363        assert_eq!(polygon.rings, rings);
3364
3365        // Test case 2: Deserialization from legacy format (field name "polygon")
3366        let legacy = serde_json::json!({
3367            "polygon": {
3368                "polygon": [[
3369                    [0.0_f32, 0.0_f32],
3370                    [1.0_f32, 0.0_f32],
3371                    [1.0_f32, 1.0_f32]
3372                ]]
3373            }
3374        });
3375
3376        #[derive(serde::Deserialize)]
3377        struct Wrapper {
3378            polygon: Polygon,
3379        }
3380
3381        let parsed: Wrapper = serde_json::from_value(legacy).unwrap();
3382        assert_eq!(parsed.polygon.rings.len(), 1);
3383        assert_eq!(parsed.polygon.rings[0].len(), 3);
3384    }
3385
3386    // ==== Sample Tests ====
3387    #[test]
3388    fn test_sample_construction_and_accessors() {
3389        // Test case 1: New sample is empty
3390        let sample = Sample::new();
3391        assert_eq!(sample.id(), None);
3392        assert_eq!(sample.image_name(), None);
3393        assert_eq!(sample.width(), None);
3394        assert_eq!(sample.height(), None);
3395
3396        // Test case 2: Sample with populated fields
3397        let mut sample = Sample::new();
3398        sample.image_name = Some("test.jpg".to_string());
3399        sample.width = Some(1920);
3400        sample.height = Some(1080);
3401        sample.group = Some("group1".to_string());
3402
3403        assert_eq!(sample.image_name(), Some("test.jpg"));
3404        assert_eq!(sample.width(), Some(1920));
3405        assert_eq!(sample.height(), Some(1080));
3406        assert_eq!(sample.group(), Some(&"group1".to_string()));
3407    }
3408
3409    #[test]
3410    fn test_sample_name_extraction_from_image_name() {
3411        let mut sample = Sample::new();
3412
3413        // Test case 1: Basic image name with extension
3414        sample.image_name = Some("test_image.jpg".to_string());
3415        assert_eq!(sample.name(), Some("test_image".to_string()));
3416
3417        // Test case 2: Image name with .camera suffix
3418        sample.image_name = Some("test_image.camera.jpg".to_string());
3419        assert_eq!(sample.name(), Some("test_image".to_string()));
3420
3421        // Test case 3: Image name without extension
3422        sample.image_name = Some("test_image".to_string());
3423        assert_eq!(sample.name(), Some("test_image".to_string()));
3424    }
3425
3426    // ==== Annotation Tests ====
3427    #[test]
3428    fn test_annotation_construction_and_setters() {
3429        // Test case 1: New annotation is empty
3430        let ann = Annotation::new();
3431        assert_eq!(ann.sample_id(), None);
3432        assert_eq!(ann.label(), None);
3433        assert_eq!(ann.box2d(), None);
3434        assert_eq!(ann.box3d(), None);
3435        assert_eq!(ann.polygon(), None);
3436
3437        // Test case 2: Setting annotation fields
3438        let mut ann = Annotation::new();
3439        ann.set_label(Some("car".to_string()));
3440        assert_eq!(ann.label(), Some(&"car".to_string()));
3441
3442        ann.set_label_index(Some(42));
3443        assert_eq!(ann.label_index(), Some(42));
3444
3445        // Test case 3: Setting bounding box
3446        let bbox = Box2d::new(10.0, 20.0, 100.0, 50.0);
3447        ann.set_box2d(Some(bbox.clone()));
3448        assert!(ann.box2d().is_some());
3449        assert_eq!(ann.box2d().unwrap().left(), 10.0);
3450    }
3451
3452    // ==== SampleFile Tests ====
3453    #[test]
3454    fn test_sample_file_with_url_and_filename() {
3455        // Test case 1: SampleFile with URL
3456        let file = SampleFile::with_url(
3457            "lidar.pcd".to_string(),
3458            "https://example.com/file.pcd".to_string(),
3459        );
3460        assert_eq!(file.file_type(), "lidar.pcd");
3461        assert_eq!(file.url(), Some("https://example.com/file.pcd"));
3462        assert_eq!(file.filename(), None);
3463
3464        // Test case 2: SampleFile with local filename
3465        let file = SampleFile::with_filename("image".to_string(), "test.jpg".to_string());
3466        assert_eq!(file.file_type(), "image");
3467        assert_eq!(file.filename(), Some("test.jpg"));
3468        assert_eq!(file.url(), None);
3469    }
3470
3471    // ==== Sample GPS/IMU Deserialization Tests ====
3472    #[test]
3473    fn test_sample_deserializes_gps_imu_from_sensors() {
3474        use serde_json::json;
3475
3476        // Test: GPS and IMU data in sensors array is extracted to location field
3477        let sample_json = json!({
3478            "id": 123,
3479            "image_name": "test.jpg",
3480            "sensors": [
3481                {"gps": {"lat": 37.7749, "lon": -122.4194}},
3482                {"imu": {"roll": 1.5, "pitch": 2.5, "yaw": 3.5}},
3483                {"radar.pcd": "https://example.com/radar.pcd"}
3484            ]
3485        });
3486
3487        let sample: Sample = serde_json::from_value(sample_json).unwrap();
3488
3489        // Verify location was extracted
3490        assert!(sample.location.is_some());
3491        let location = sample.location.as_ref().unwrap();
3492
3493        // Verify GPS data
3494        assert!(location.gps.is_some());
3495        let gps = location.gps.as_ref().unwrap();
3496        assert!((gps.lat - 37.7749).abs() < 0.0001);
3497        assert!((gps.lon - (-122.4194)).abs() < 0.0001);
3498
3499        // Verify IMU data
3500        assert!(location.imu.is_some());
3501        let imu = location.imu.as_ref().unwrap();
3502        assert!((imu.roll - 1.5).abs() < 0.0001);
3503        assert!((imu.pitch - 2.5).abs() < 0.0001);
3504        assert!((imu.yaw - 3.5).abs() < 0.0001);
3505
3506        // Verify files were also extracted (non-GPS/IMU entries)
3507        assert_eq!(sample.files.len(), 1);
3508        assert_eq!(sample.files[0].file_type(), "radar.pcd");
3509        assert_eq!(sample.files[0].url(), Some("https://example.com/radar.pcd"));
3510    }
3511
3512    #[test]
3513    fn test_sample_deserializes_gps_only() {
3514        use serde_json::json;
3515
3516        // Test: Only GPS data in sensors
3517        let sample_json = json!({
3518            "id": 456,
3519            "sensors": [
3520                {"gps": {"lat": 40.7128, "lon": -74.0060}}
3521            ]
3522        });
3523
3524        let sample: Sample = serde_json::from_value(sample_json).unwrap();
3525
3526        assert!(sample.location.is_some());
3527        let location = sample.location.as_ref().unwrap();
3528
3529        assert!(location.gps.is_some());
3530        assert!(location.imu.is_none());
3531
3532        let gps = location.gps.as_ref().unwrap();
3533        assert!((gps.lat - 40.7128).abs() < 0.0001);
3534        assert!((gps.lon - (-74.0060)).abs() < 0.0001);
3535    }
3536
3537    #[test]
3538    fn test_sample_deserializes_without_location() {
3539        use serde_json::json;
3540
3541        // Test: Sample with only file sensors (no GPS/IMU)
3542        let sample_json = json!({
3543            "id": 789,
3544            "sensors": [
3545                {"radar.pcd": "https://example.com/radar.pcd"},
3546                {"lidar.pcd": "https://example.com/lidar.pcd"}
3547            ]
3548        });
3549
3550        let sample: Sample = serde_json::from_value(sample_json).unwrap();
3551
3552        // No location data
3553        assert!(sample.location.is_none());
3554
3555        // Both files extracted
3556        assert_eq!(sample.files.len(), 2);
3557    }
3558
3559    // ==== Label Tests ====
3560    #[test]
3561    fn test_label_deserialization_and_accessors() {
3562        use serde_json::json;
3563
3564        // Test case 1: Label deserialization and accessors
3565        let label_json = json!({
3566            "id": 123,
3567            "dataset_id": 456,
3568            "index": 5,
3569            "name": "car"
3570        });
3571
3572        let label: Label = serde_json::from_value(label_json).unwrap();
3573        assert_eq!(label.id(), 123);
3574        assert_eq!(label.index(), 5);
3575        assert_eq!(label.name(), "car");
3576        assert_eq!(label.to_string(), "car");
3577        assert_eq!(format!("{}", label), "car");
3578
3579        // Test case 2: Different label
3580        let label_json = json!({
3581            "id": 1,
3582            "dataset_id": 100,
3583            "index": 0,
3584            "name": "person"
3585        });
3586
3587        let label: Label = serde_json::from_value(label_json).unwrap();
3588        assert_eq!(format!("{}", label), "person");
3589    }
3590
3591    // ==== Annotation Serialization Tests ====
3592    #[test]
3593    fn test_annotation_serialization_with_mask_and_box() {
3594        let polygon = vec![vec![
3595            (0.0_f32, 0.0_f32),
3596            (1.0_f32, 0.0_f32),
3597            (1.0_f32, 1.0_f32),
3598        ]];
3599
3600        let mut annotation = Annotation::new();
3601        annotation.set_label(Some("test".to_string()));
3602        annotation.set_box2d(Some(Box2d::new(10.0, 20.0, 30.0, 40.0)));
3603        annotation.set_polygon(Some(Polygon::new(polygon)));
3604
3605        let mut sample = Sample::new();
3606        sample.annotations.push(annotation);
3607
3608        let json = serde_json::to_value(&sample).unwrap();
3609        let annotations = json
3610            .get("annotations")
3611            .and_then(|value| value.as_array())
3612            .expect("annotations serialized as array");
3613        assert_eq!(annotations.len(), 1);
3614
3615        let annotation_json = annotations[0].as_object().expect("annotation object");
3616        assert!(annotation_json.contains_key("box2d"));
3617        // samples.populate2 expects the polygon geometry under the "mask" key
3618        // (historical: struct was renamed Rust-side from Mask to Polygon but
3619        // the wire contract did not follow). Emitting "polygon" here is what
3620        // caused polygons to be silently dropped on upload.
3621        assert!(
3622            annotation_json.contains_key("mask"),
3623            "Annotation must serialise polygon under 'mask' key for samples.populate2; got keys: {:?}",
3624            annotation_json.keys().collect::<Vec<_>>()
3625        );
3626        assert!(!annotation_json.contains_key("polygon"));
3627        assert!(!annotation_json.contains_key("x"));
3628        assert!(
3629            annotation_json
3630                .get("mask")
3631                .and_then(|value| value.as_array())
3632                .is_some()
3633        );
3634    }
3635
3636    #[test]
3637    fn test_frame_number_negative_one_deserializes_as_none() {
3638        // Server returns frame_number: -1 for non-sequence samples
3639        // This should deserialize as None for the client
3640        let json = r#"{
3641            "uuid": "test-uuid",
3642            "frame_number": -1
3643        }"#;
3644
3645        let sample: Sample = serde_json::from_str(json).unwrap();
3646        assert_eq!(sample.frame_number, None);
3647    }
3648
3649    #[test]
3650    fn test_frame_number_positive_value_deserializes_correctly() {
3651        // Valid frame numbers should deserialize normally
3652        let json = r#"{
3653            "uuid": "test-uuid",
3654            "frame_number": 5
3655        }"#;
3656
3657        let sample: Sample = serde_json::from_str(json).unwrap();
3658        assert_eq!(sample.frame_number, Some(5));
3659    }
3660
3661    #[test]
3662    fn test_frame_number_null_deserializes_as_none() {
3663        // Explicit null should also be None
3664        let json = r#"{
3665            "uuid": "test-uuid",
3666            "frame_number": null
3667        }"#;
3668
3669        let sample: Sample = serde_json::from_str(json).unwrap();
3670        assert_eq!(sample.frame_number, None);
3671    }
3672
3673    #[test]
3674    fn test_frame_number_missing_deserializes_as_none() {
3675        // Missing field should be None
3676        let json = r#"{
3677            "uuid": "test-uuid"
3678        }"#;
3679
3680        let sample: Sample = serde_json::from_str(json).unwrap();
3681        assert_eq!(sample.frame_number, None);
3682    }
3683
3684    // =========================================================================
3685    // samples_dataframe tests - CRITICAL: Verify group preservation
3686    // =========================================================================
3687
3688    #[cfg(feature = "polars")]
3689    #[test]
3690    fn test_samples_dataframe_preserves_group_for_samples_without_annotations() {
3691        use polars::prelude::*;
3692
3693        // Create sample WITH annotations
3694        let mut sample_with_ann = Sample::new();
3695        sample_with_ann.image_name = Some("annotated.jpg".to_string());
3696        sample_with_ann.group = Some("train".to_string());
3697        let mut annotation = Annotation::new();
3698        annotation.set_label(Some("car".to_string()));
3699        annotation.set_box2d(Some(Box2d::new(0.1, 0.2, 0.3, 0.4)));
3700        annotation.set_name(Some("annotated".to_string()));
3701        sample_with_ann.annotations = vec![annotation];
3702
3703        // Create sample WITHOUT annotations (this is the critical case)
3704        let mut sample_no_ann = Sample::new();
3705        sample_no_ann.image_name = Some("unannotated.jpg".to_string());
3706        sample_no_ann.group = Some("val".to_string()); // Should be preserved!
3707        sample_no_ann.annotations = vec![]; // Empty annotations
3708
3709        let samples = vec![sample_with_ann, sample_no_ann];
3710
3711        // Convert to DataFrame
3712        let df = samples_dataframe(&samples).expect("Failed to create DataFrame");
3713
3714        // Verify we have 2 rows (one per sample)
3715        assert_eq!(df.height(), 2, "Expected 2 rows (one per sample)");
3716
3717        // Get the group column
3718        let groups_col = df.column("group").expect("group column should exist");
3719        let groups_cast = groups_col.cast(&DataType::String).expect("cast to string");
3720        let groups = groups_cast.str().expect("as str");
3721
3722        // Find the row for "unannotated" and verify it has group "val"
3723        let names_col = df.column("name").expect("name column should exist");
3724        let names_cast = names_col.cast(&DataType::String).expect("cast to string");
3725        let names = names_cast.str().expect("as str");
3726
3727        let mut found_unannotated = false;
3728        for idx in 0..df.height() {
3729            if let Some(name) = names.get(idx)
3730                && name == "unannotated"
3731            {
3732                found_unannotated = true;
3733                let group = groups.get(idx);
3734                assert_eq!(
3735                    group,
3736                    Some("val"),
3737                    "CRITICAL: Sample 'unannotated' without annotations must have group 'val'"
3738                );
3739            }
3740        }
3741
3742        assert!(
3743            found_unannotated,
3744            "Did not find 'unannotated' sample in DataFrame - \
3745             this means samples without annotations are not being included"
3746        );
3747    }
3748
3749    #[cfg(feature = "polars")]
3750    #[test]
3751    fn test_samples_dataframe_includes_all_samples_even_without_annotations() {
3752        // Verify that samples without annotations still appear in the DataFrame
3753        // with null annotation fields but WITH their group field populated
3754
3755        let mut sample1 = Sample::new();
3756        sample1.image_name = Some("with_ann.jpg".to_string());
3757        sample1.group = Some("train".to_string());
3758        let mut ann = Annotation::new();
3759        ann.set_label(Some("person".to_string()));
3760        ann.set_box2d(Some(Box2d::new(0.0, 0.0, 0.5, 0.5)));
3761        ann.set_name(Some("with_ann".to_string()));
3762        sample1.annotations = vec![ann];
3763
3764        let mut sample2 = Sample::new();
3765        sample2.image_name = Some("no_ann_train.jpg".to_string());
3766        sample2.group = Some("train".to_string());
3767        sample2.annotations = vec![];
3768
3769        let mut sample3 = Sample::new();
3770        sample3.image_name = Some("no_ann_val.jpg".to_string());
3771        sample3.group = Some("val".to_string());
3772        sample3.annotations = vec![];
3773
3774        let samples = vec![sample1, sample2, sample3];
3775
3776        let df = samples_dataframe(&samples).expect("Failed to create DataFrame");
3777
3778        // We should have exactly 3 rows - one per sample
3779        assert_eq!(
3780            df.height(),
3781            3,
3782            "Expected 3 rows (samples without annotations should create one row each)"
3783        );
3784
3785        // Check that all groups are present
3786        let groups_col = df.column("group").expect("group column");
3787        let groups_cast = groups_col.cast(&polars::prelude::DataType::String).unwrap();
3788        let groups = groups_cast.str().unwrap();
3789
3790        let mut train_count = 0;
3791        let mut val_count = 0;
3792
3793        for idx in 0..df.height() {
3794            match groups.get(idx) {
3795                Some("train") => train_count += 1,
3796                Some("val") => val_count += 1,
3797                other => panic!(
3798                    "Unexpected group value at row {}: {:?}. \
3799                     All samples should have their group preserved.",
3800                    idx, other
3801                ),
3802            }
3803        }
3804
3805        assert_eq!(train_count, 2, "Expected 2 samples in 'train' group");
3806        assert_eq!(val_count, 1, "Expected 1 sample in 'val' group");
3807    }
3808
3809    #[cfg(feature = "polars")]
3810    #[test]
3811    fn test_samples_dataframe_group_is_not_null_for_samples_with_group() {
3812        // CRITICAL: Even when a sample has no annotations, if it has a group,
3813        // that group must NOT be null in the DataFrame
3814
3815        let mut sample = Sample::new();
3816        sample.image_name = Some("test.jpg".to_string());
3817        sample.group = Some("test_group".to_string());
3818        sample.annotations = vec![];
3819
3820        let df = samples_dataframe(&[sample]).expect("Failed to create DataFrame");
3821
3822        let groups_col = df.column("group").expect("group column");
3823
3824        // The group column should have NO nulls because our sample has a group
3825        assert_eq!(
3826            groups_col.null_count(),
3827            0,
3828            "Sample with group='test_group' but no annotations has NULL group in DataFrame. \
3829             This is a bug in samples_dataframe - group must be preserved!"
3830        );
3831    }
3832
3833    #[cfg(feature = "polars")]
3834    #[test]
3835    fn test_samples_dataframe_group_consistent_across_all_rows_for_same_image() {
3836        use polars::prelude::*;
3837
3838        // Test that when a sample has multiple annotations, ALL rows have
3839        // the same group value (not just the first one)
3840
3841        let mut sample = Sample::new();
3842        sample.image_name = Some("multi_ann.jpg".to_string());
3843        sample.group = Some("train".to_string());
3844
3845        // Add multiple annotations
3846        let mut ann1 = Annotation::new();
3847        ann1.set_label(Some("car".to_string()));
3848        ann1.set_box2d(Some(Box2d::new(0.1, 0.2, 0.3, 0.4)));
3849        ann1.set_name(Some("multi_ann".to_string()));
3850
3851        let mut ann2 = Annotation::new();
3852        ann2.set_label(Some("truck".to_string()));
3853        ann2.set_box2d(Some(Box2d::new(0.5, 0.6, 0.2, 0.2)));
3854        ann2.set_name(Some("multi_ann".to_string()));
3855
3856        let mut ann3 = Annotation::new();
3857        ann3.set_label(Some("bus".to_string()));
3858        ann3.set_box2d(Some(Box2d::new(0.7, 0.8, 0.1, 0.1)));
3859        ann3.set_name(Some("multi_ann".to_string()));
3860
3861        sample.annotations = vec![ann1, ann2, ann3];
3862
3863        let df = samples_dataframe(&[sample]).expect("Failed to create DataFrame");
3864
3865        // Should have 3 rows (one per annotation)
3866        assert_eq!(df.height(), 3, "Expected 3 rows (one per annotation)");
3867
3868        // ALL rows should have the group "train" (not just the first one)
3869        let groups_col = df.column("group").expect("group column");
3870        let groups_cast = groups_col.cast(&DataType::String).expect("cast to string");
3871        let groups = groups_cast.str().expect("as str");
3872
3873        // No nulls allowed
3874        assert_eq!(groups_col.null_count(), 0, "No rows should have null group");
3875
3876        // All rows should have the same group
3877        for idx in 0..df.height() {
3878            let group = groups.get(idx);
3879            assert_eq!(
3880                group,
3881                Some("train"),
3882                "Row {} should have group 'train', got {:?}. \
3883                 All rows for the same image must have identical group values.",
3884                idx,
3885                group
3886            );
3887        }
3888    }
3889
3890    #[cfg(feature = "polars")]
3891    #[test]
3892    fn test_samples_dataframe_lvis_columns() {
3893        let mut ann = Annotation::new();
3894        ann.set_name(Some("test".to_string()));
3895        ann.set_label(Some("person".to_string()));
3896        ann.set_label_index(Some(1));
3897        ann.set_iscrowd(Some(false));
3898        ann.set_category_frequency(Some("f".to_string()));
3899
3900        let sample = Sample {
3901            image_name: Some("test.jpg".to_string()),
3902            width: Some(640),
3903            height: Some(480),
3904            annotations: vec![ann],
3905            neg_label_indices: Some(vec![5, 12]),
3906            not_exhaustive_label_indices: Some(vec![3]),
3907            ..Default::default()
3908        };
3909
3910        let df = samples_dataframe(&[sample]).unwrap();
3911
3912        // Verify LVIS columns are present (they have data)
3913        assert!(df.column("iscrowd").is_ok(), "iscrowd column missing");
3914        assert!(
3915            df.column("category_frequency").is_ok(),
3916            "category_frequency column missing"
3917        );
3918        assert!(
3919            df.column("neg_label_indices").is_ok(),
3920            "neg_label_indices column missing"
3921        );
3922        assert!(
3923            df.column("not_exhaustive_label_indices").is_ok(),
3924            "not_exhaustive_label_indices column missing"
3925        );
3926
3927        // All-null columns should be dropped (polygon, box2d, box3d, mask, scores, etc.)
3928        assert!(
3929            df.column("polygon").is_err(),
3930            "polygon column should be dropped (all null)"
3931        );
3932        assert!(
3933            df.column("box2d").is_err(),
3934            "box2d column should be dropped (all null)"
3935        );
3936    }
3937
3938    #[test]
3939    fn test_annotation_serialization_skips_lvis_fields() {
3940        let ann = Annotation::new();
3941        let json = serde_json::to_string(&ann).unwrap();
3942        assert!(
3943            !json.contains("iscrowd"),
3944            "iscrowd should be omitted when None"
3945        );
3946        assert!(
3947            !json.contains("category_frequency"),
3948            "category_frequency should be omitted when None"
3949        );
3950    }
3951
3952    #[test]
3953    fn test_sample_serialization_skips_lvis_fields() {
3954        let sample = Sample::new();
3955        let json = serde_json::to_string(&sample).unwrap();
3956        assert!(
3957            !json.contains("neg_label_indices"),
3958            "neg_label_indices should be omitted when None"
3959        );
3960        assert!(
3961            !json.contains("not_exhaustive_label_indices"),
3962            "not_exhaustive_label_indices should be omitted when None"
3963        );
3964    }
3965
3966    #[test]
3967    fn test_annotation_score_fields() {
3968        let mut ann = Annotation::default();
3969        assert!(ann.box2d_score.is_none());
3970        assert!(ann.polygon_score.is_none());
3971        assert!(ann.mask_score.is_none());
3972        ann.box2d_score = Some(0.95);
3973        ann.polygon_score = Some(0.87);
3974        ann.mask_score = Some(0.42);
3975        assert_eq!(ann.box2d_score, Some(0.95));
3976        assert_eq!(ann.polygon_score, Some(0.87));
3977        assert_eq!(ann.mask_score, Some(0.42));
3978    }
3979
3980    #[test]
3981    fn test_timing_struct() {
3982        let timing = Timing {
3983            load: Some(1_000_000),
3984            preprocess: Some(2_000_000),
3985            inference: Some(50_000_000),
3986            decode: Some(3_000_000),
3987        };
3988        assert_eq!(timing.inference, Some(50_000_000));
3989
3990        let default = Timing::default();
3991        assert!(default.load.is_none());
3992    }
3993
3994    #[test]
3995    fn test_sample_timing() {
3996        let mut sample = Sample::default();
3997        assert!(sample.timing.is_none());
3998        sample.timing = Some(Timing {
3999            load: Some(1_000_000),
4000            ..Default::default()
4001        });
4002        assert!(sample.timing.is_some());
4003    }
4004
4005    // =========================================================================
4006    // samples_dataframe 2026.04 schema tests
4007    // =========================================================================
4008
4009    #[cfg(feature = "polars")]
4010    #[test]
4011    fn test_samples_dataframe_polygon_column() {
4012        let mut ann = Annotation::new();
4013        ann.set_name(Some("test".to_string()));
4014        ann.set_polygon(Some(Polygon::new(vec![vec![
4015            (0.1, 0.2),
4016            (0.3, 0.4),
4017            (0.5, 0.6),
4018        ]])));
4019
4020        let sample = Sample {
4021            image_name: Some("test.jpg".to_string()),
4022            annotations: vec![ann],
4023            ..Default::default()
4024        };
4025
4026        let df = samples_dataframe(&[sample]).unwrap();
4027
4028        // 2026.04: polygon column exists with nested List(List(Float32))
4029        assert!(df.column("polygon").is_ok(), "Should have polygon column");
4030
4031        // The old "mask" column with float data should NOT exist (no MaskData set)
4032        // If mask column exists, it would be Binary type from MaskData, not floats
4033        if let Ok(mask_col) = df.column("mask") {
4034            // If it exists, it must be Binary type, not List(Float32)
4035            assert_eq!(
4036                mask_col.dtype(),
4037                &polars::prelude::DataType::Binary,
4038                "mask column must be Binary type (PNG bytes), not float list"
4039            );
4040        }
4041    }
4042
4043    #[cfg(feature = "polars")]
4044    #[test]
4045    fn test_samples_dataframe_column_presence_drops_all_null() {
4046        // Sample with only a name, no annotations
4047        let sample = Sample {
4048            image_name: Some("test.jpg".to_string()),
4049            ..Default::default()
4050        };
4051
4052        let df = samples_dataframe(&[sample]).unwrap();
4053
4054        // name is always present
4055        assert!(df.column("name").is_ok(), "name column must always exist");
4056
4057        // All-null columns should be dropped
4058        assert!(
4059            df.column("polygon").is_err(),
4060            "All-null polygon should be dropped"
4061        );
4062        assert!(
4063            df.column("box2d").is_err(),
4064            "All-null box2d should be dropped"
4065        );
4066        assert!(
4067            df.column("box3d").is_err(),
4068            "All-null box3d should be dropped"
4069        );
4070        assert!(
4071            df.column("mask").is_err(),
4072            "All-null mask should be dropped"
4073        );
4074        assert!(
4075            df.column("box2d_score").is_err(),
4076            "All-null score columns should be dropped"
4077        );
4078        assert!(
4079            df.column("timing").is_err(),
4080            "All-null timing should be dropped"
4081        );
4082    }
4083
4084    #[cfg(feature = "polars")]
4085    #[test]
4086    fn test_samples_dataframe_size_column() {
4087        // Samples with width/height should produce the size column
4088        let sample1 = Sample {
4089            image_name: Some("img1.jpg".to_string()),
4090            width: Some(1920),
4091            height: Some(1080),
4092            ..Default::default()
4093        };
4094        let sample2 = Sample {
4095            image_name: Some("img2.jpg".to_string()),
4096            width: Some(640),
4097            height: Some(480),
4098            ..Default::default()
4099        };
4100
4101        let df = samples_dataframe(&[sample1, sample2]).unwrap();
4102
4103        // Size column should be present (not dropped by all-null rule)
4104        let size_col = df
4105            .column("size")
4106            .expect("size column should be present when width/height are set");
4107        assert_eq!(size_col.len(), 2);
4108
4109        // Each row should be an Array(UInt32, 2) with [width, height]
4110        let arr = size_col.array().expect("size column should be Array dtype");
4111        let row0 = arr.get_as_series(0).unwrap();
4112        let row0_vals: Vec<u32> = row0.u32().unwrap().into_no_null_iter().collect();
4113        assert_eq!(row0_vals, vec![1920, 1080]);
4114
4115        let row1 = arr.get_as_series(1).unwrap();
4116        let row1_vals: Vec<u32> = row1.u32().unwrap().into_no_null_iter().collect();
4117        assert_eq!(row1_vals, vec![640, 480]);
4118    }
4119
4120    #[cfg(feature = "polars")]
4121    #[test]
4122    fn test_samples_dataframe_size_column_partial() {
4123        // When only some samples have dimensions, size column should still be present
4124        let sample1 = Sample {
4125            image_name: Some("img1.jpg".to_string()),
4126            width: Some(1920),
4127            height: Some(1080),
4128            ..Default::default()
4129        };
4130        let sample2 = Sample {
4131            image_name: Some("img2.jpg".to_string()),
4132            // No width/height
4133            ..Default::default()
4134        };
4135
4136        let df = samples_dataframe(&[sample1, sample2]).unwrap();
4137
4138        // Size column should be present (not all null)
4139        let size_col = df
4140            .column("size")
4141            .expect("size column should be present when at least one sample has dimensions");
4142        assert_eq!(size_col.len(), 2);
4143        assert_eq!(size_col.null_count(), 1, "one row should be null");
4144    }
4145
4146    #[cfg(feature = "polars")]
4147    #[test]
4148    fn test_samples_dataframe_score_columns() {
4149        let mut ann = Annotation::new();
4150        ann.set_name(Some("test".to_string()));
4151        ann.set_box2d(Some(Box2d::new(0.1, 0.2, 0.3, 0.4)));
4152        ann.set_box2d_score(Some(0.95));
4153        ann.set_polygon(Some(Polygon::new(vec![vec![
4154            (0.0, 0.0),
4155            (1.0, 0.0),
4156            (1.0, 1.0),
4157        ]])));
4158        ann.set_polygon_score(Some(0.87));
4159
4160        let sample = Sample {
4161            image_name: Some("test.jpg".to_string()),
4162            annotations: vec![ann],
4163            ..Default::default()
4164        };
4165
4166        let df = samples_dataframe(&[sample]).unwrap();
4167
4168        // Score columns with data should be present
4169        assert!(
4170            df.column("box2d_score").is_ok(),
4171            "box2d_score column missing"
4172        );
4173        assert!(
4174            df.column("polygon_score").is_ok(),
4175            "polygon_score column missing"
4176        );
4177
4178        // Score columns with no data should be dropped
4179        assert!(
4180            df.column("box3d_score").is_err(),
4181            "box3d_score should be dropped (all null)"
4182        );
4183        assert!(
4184            df.column("mask_score").is_err(),
4185            "mask_score should be dropped (all null)"
4186        );
4187
4188        // Verify score values
4189        let box2d_scores = df.column("box2d_score").unwrap();
4190        let val = box2d_scores.f32().unwrap().get(0);
4191        assert_eq!(val, Some(0.95));
4192    }
4193
4194    #[cfg(feature = "polars")]
4195    #[test]
4196    fn test_samples_dataframe_timing_column() {
4197        let mut ann = Annotation::new();
4198        ann.set_name(Some("test".to_string()));
4199        ann.set_label(Some("person".to_string()));
4200
4201        let sample = Sample {
4202            image_name: Some("test.jpg".to_string()),
4203            annotations: vec![ann],
4204            timing: Some(Timing {
4205                load: Some(1_000_000),
4206                preprocess: Some(2_000_000),
4207                inference: Some(50_000_000),
4208                decode: Some(3_000_000),
4209            }),
4210            ..Default::default()
4211        };
4212
4213        let df = samples_dataframe(&[sample]).unwrap();
4214
4215        // Timing column should exist (has data)
4216        assert!(df.column("timing").is_ok(), "timing column missing");
4217
4218        // Verify it is a struct type
4219        let timing_col = df.column("timing").unwrap();
4220        assert!(
4221            matches!(timing_col.dtype(), polars::prelude::DataType::Struct(..)),
4222            "timing column should be Struct type, got {:?}",
4223            timing_col.dtype()
4224        );
4225    }
4226
4227    #[cfg(feature = "polars")]
4228    #[test]
4229    fn test_samples_dataframe_mask_binary_column() {
4230        let mut ann = Annotation::new();
4231        ann.set_name(Some("test".to_string()));
4232        // Create a small valid PNG via MaskData::encode
4233        let pixels = vec![0u8, 255, 128, 64];
4234        let mask_data = MaskData::encode(&pixels, 2, 2, 8).unwrap();
4235        ann.set_mask(Some(mask_data));
4236
4237        let sample = Sample {
4238            image_name: Some("test.jpg".to_string()),
4239            annotations: vec![ann],
4240            ..Default::default()
4241        };
4242
4243        let df = samples_dataframe(&[sample]).unwrap();
4244
4245        // mask column should exist with Binary type
4246        let mask_col = df.column("mask").unwrap();
4247        assert_eq!(
4248            mask_col.dtype(),
4249            &polars::prelude::DataType::Binary,
4250            "mask column should be Binary"
4251        );
4252        assert_eq!(mask_col.null_count(), 0, "mask value should not be null");
4253    }
4254
4255    // =========================================================================
4256    // AnnotationType "seg" alias test
4257    // =========================================================================
4258
4259    #[test]
4260    fn test_annotation_type_seg_alias() {
4261        assert_eq!(
4262            AnnotationType::try_from("seg").unwrap(),
4263            AnnotationType::Polygon,
4264            "\"seg\" should map to Polygon for server round-trip"
4265        );
4266    }
4267
4268    // =========================================================================
4269    // Timing edge case tests
4270    // =========================================================================
4271
4272    #[cfg(feature = "polars")]
4273    #[test]
4274    fn test_samples_dataframe_timing_partial() {
4275        // Timing with only load set; other fields None
4276        let mut ann = Annotation::new();
4277        ann.set_name(Some("test".to_string()));
4278        ann.set_label(Some("person".to_string()));
4279
4280        let sample = Sample {
4281            image_name: Some("test.jpg".to_string()),
4282            annotations: vec![ann],
4283            timing: Some(Timing {
4284                load: Some(1000),
4285                ..Default::default()
4286            }),
4287            ..Default::default()
4288        };
4289
4290        let df = samples_dataframe(&[sample]).unwrap();
4291
4292        // Timing column should be present because at least one field is non-null
4293        assert!(
4294            df.column("timing").is_ok(),
4295            "timing column should be present when partial data exists"
4296        );
4297    }
4298
4299    #[cfg(feature = "polars")]
4300    #[test]
4301    fn test_samples_dataframe_timing_all_none_omitted() {
4302        // All samples have timing: None — timing column should be omitted
4303        let mut ann = Annotation::new();
4304        ann.set_name(Some("test".to_string()));
4305        ann.set_label(Some("person".to_string()));
4306
4307        let sample = Sample {
4308            image_name: Some("test.jpg".to_string()),
4309            annotations: vec![ann],
4310            timing: None,
4311            ..Default::default()
4312        };
4313
4314        let df = samples_dataframe(&[sample]).unwrap();
4315
4316        assert!(
4317            df.column("timing").is_err(),
4318            "timing column should be omitted when all samples have timing: None"
4319        );
4320    }
4321
4322    // =========================================================================
4323    // Score boundary tests
4324    // =========================================================================
4325
4326    #[cfg(feature = "polars")]
4327    #[test]
4328    fn test_samples_dataframe_score_zero_survives() {
4329        // score = 0.0 must be non-null in the output (not confused with None)
4330        let mut ann = Annotation::new();
4331        ann.set_name(Some("test".to_string()));
4332        ann.set_box2d(Some(Box2d::new(0.1, 0.2, 0.3, 0.4)));
4333        ann.set_box2d_score(Some(0.0));
4334
4335        let sample = Sample {
4336            image_name: Some("test.jpg".to_string()),
4337            annotations: vec![ann],
4338            ..Default::default()
4339        };
4340
4341        let df = samples_dataframe(&[sample]).unwrap();
4342
4343        let scores = df.column("box2d_score").unwrap();
4344        let val = scores.f32().unwrap().get(0);
4345        assert_eq!(val, Some(0.0), "score of 0.0 should survive as non-null");
4346    }
4347
4348    #[cfg(feature = "polars")]
4349    #[test]
4350    fn test_samples_dataframe_score_one_survives() {
4351        let mut ann = Annotation::new();
4352        ann.set_name(Some("test".to_string()));
4353        ann.set_box2d(Some(Box2d::new(0.1, 0.2, 0.3, 0.4)));
4354        ann.set_box2d_score(Some(1.0));
4355
4356        let sample = Sample {
4357            image_name: Some("test.jpg".to_string()),
4358            annotations: vec![ann],
4359            ..Default::default()
4360        };
4361
4362        let df = samples_dataframe(&[sample]).unwrap();
4363
4364        let scores = df.column("box2d_score").unwrap();
4365        let val = scores.f32().unwrap().get(0);
4366        assert_eq!(val, Some(1.0), "score of 1.0 should survive as non-null");
4367    }
4368}