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