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