edgefirst_client/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4// SPDX-License-Identifier: Apache-2.0
5// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
6
7//! # EdgeFirst Studio Client Library
8//!
9//! The EdgeFirst Studio Client Library provides a Rust client for interacting
10//! with EdgeFirst Studio, a comprehensive platform for computer vision and
11//! machine learning workflows. This library enables developers to
12//! programmatically manage datasets, annotations, training sessions, and other
13//! Studio resources.
14//!
15//! ## Features
16//!
17//! - **Authentication**: Secure token-based authentication with automatic
18//!   renewal
19//! - **Dataset Management**: Upload, download, and manage datasets with various
20//!   file types
21//! - **Annotation Management**: Create, update, and retrieve annotations for
22//!   computer vision tasks
23//! - **Training & Validation**: Manage machine learning training and validation
24//!   sessions
25//! - **Project Organization**: Organize work into projects with hierarchical
26//!   structure
27//! - **Polars Integration**: Optional integration with Polars DataFrames for
28//!   data analysis
29//!
30//! ## Quick Start
31//!
32//! ```rust,no_run
33//! use edgefirst_client::{Client, Error};
34//!
35//! #[tokio::main]
36//! async fn main() -> Result<(), Error> {
37//!     // Create a new client
38//!     let client = Client::new()?;
39//!
40//!     // Authenticate with username and password
41//!     let client = client.with_login("username", "password").await?;
42//!
43//!     // List available projects
44//!     let projects = client.projects(None).await?;
45//!     println!("Found {} projects", projects.len());
46//!
47//!     Ok(())
48//! }
49//! ```
50//!
51//! ## Optional Features
52//!
53//! - `polars`: Enables integration with Polars DataFrames for enhanced data
54//!   manipulation
55
56mod api;
57mod client;
58mod dataset;
59mod error;
60
61pub use crate::{
62    api::{
63        AnnotationSetID, AppId, Artifact, DatasetID, DatasetParams, Experiment, ExperimentID,
64        ImageId, Organization, OrganizationID, Parameter, PresignedUrl, Project, ProjectID,
65        SampleID, SamplesPopulateParams, SamplesPopulateResult, SequenceId, SnapshotID, Stage,
66        Task, TaskID, TaskInfo, TrainingSession, TrainingSessionID, ValidationSession,
67        ValidationSessionID,
68    },
69    client::{Client, Progress},
70    dataset::{
71        Annotation, AnnotationSet, AnnotationType, Box2d, Box3d, Dataset, FileType, GpsData,
72        ImuData, Label, Location, Mask, Sample, SampleFile,
73    },
74    error::Error,
75};
76
77#[cfg(feature = "polars")]
78pub use crate::dataset::annotations_dataframe;
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use polars::frame::UniqueKeepStrategy;
84    use std::{
85        collections::HashMap,
86        env,
87        fs::{File, read_to_string},
88        io::Write,
89        path::PathBuf,
90    };
91    use tokio::time::{Duration, sleep};
92
93    /// Get the test data directory (target/testdata)
94    /// Creates it if it doesn't exist
95    fn get_test_data_dir() -> PathBuf {
96        let test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
97            .parent()
98            .unwrap()
99            .parent()
100            .unwrap()
101            .join("target")
102            .join("testdata");
103
104        std::fs::create_dir_all(&test_dir).expect("Failed to create test data directory");
105        test_dir
106    }
107
108    #[ctor::ctor]
109    fn init() {
110        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
111    }
112
113    #[tokio::test]
114    async fn test_version() -> Result<(), Error> {
115        let client = match env::var("STUDIO_SERVER") {
116            Ok(server) => Client::new()?.with_server(&server)?,
117            Err(_) => Client::new()?,
118        };
119        let result = client.version().await?;
120        println!("EdgeFirst Studio Version: {}", result);
121        Ok(())
122    }
123
124    async fn get_client() -> Result<Client, Error> {
125        let client = Client::new()?.with_token_path(None)?;
126
127        let client = match env::var("STUDIO_TOKEN") {
128            Ok(token) => client.with_token(&token)?,
129            Err(_) => client,
130        };
131
132        let client = match env::var("STUDIO_SERVER") {
133            Ok(server) => client.with_server(&server)?,
134            Err(_) => client,
135        };
136
137        let client = match (env::var("STUDIO_USERNAME"), env::var("STUDIO_PASSWORD")) {
138            (Ok(username), Ok(password)) => client.with_login(&username, &password).await?,
139            _ => client,
140        };
141
142        client.verify_token().await?;
143
144        Ok(client)
145    }
146
147    #[tokio::test]
148    async fn test_token() -> Result<(), Error> {
149        let client = get_client().await?;
150        let token = client.token().await;
151        assert!(!token.is_empty());
152        println!("Token: {}", token);
153
154        let exp = client.token_expiration().await?;
155        println!("Token Expiration: {}", exp);
156
157        let username = client.username().await?;
158        assert!(!username.is_empty());
159        println!("Username: {}", username);
160
161        // Wait for 2 seconds to ensure token renewal updates the time
162        sleep(Duration::from_secs(2)).await;
163
164        client.renew_token().await?;
165        let new_token = client.token().await;
166        assert!(!new_token.is_empty());
167        assert_ne!(token, new_token);
168        println!("New Token Expiration: {}", client.token_expiration().await?);
169
170        Ok(())
171    }
172
173    #[tokio::test]
174    async fn test_organization() -> Result<(), Error> {
175        let client = get_client().await?;
176        let org = client.organization().await?;
177        println!(
178            "Organization: {}\nID: {}\nCredits: {}",
179            org.name(),
180            org.id(),
181            org.credits()
182        );
183        Ok(())
184    }
185
186    #[tokio::test]
187    async fn test_projects() -> Result<(), Error> {
188        let client = get_client().await?;
189        let project = client.projects(Some("Unit Testing")).await?;
190        assert!(!project.is_empty());
191        Ok(())
192    }
193
194    #[tokio::test]
195    async fn test_datasets() -> Result<(), Error> {
196        let client = get_client().await?;
197        let project = client.projects(Some("Unit Testing")).await?;
198        assert!(!project.is_empty());
199        let project = project.first().unwrap();
200        let datasets = client.datasets(project.id(), None).await?;
201
202        for dataset in datasets {
203            let dataset_id = dataset.id();
204            let result = client.dataset(dataset_id).await?;
205            assert_eq!(result.id(), dataset_id);
206        }
207
208        Ok(())
209    }
210
211    #[tokio::test]
212    async fn test_labels() -> Result<(), Error> {
213        let client = get_client().await?;
214        let project = client.projects(Some("Unit Testing")).await?;
215        assert!(!project.is_empty());
216        let project = project.first().unwrap();
217        let datasets = client.datasets(project.id(), Some("Test Labels")).await?;
218        let dataset = datasets.first().unwrap_or_else(|| {
219            panic!(
220                "Dataset 'Test Labels' not found in project '{}'",
221                project.name()
222            )
223        });
224
225        let labels = dataset.labels(&client).await?;
226        for label in labels {
227            label.remove(&client).await?;
228        }
229
230        let labels = dataset.labels(&client).await?;
231        assert_eq!(labels.len(), 0);
232
233        dataset.add_label(&client, "test").await?;
234        let labels = dataset.labels(&client).await?;
235        assert_eq!(labels.len(), 1);
236        assert_eq!(labels[0].name(), "test");
237
238        dataset.remove_label(&client, "test").await?;
239        let labels = dataset.labels(&client).await?;
240        assert_eq!(labels.len(), 0);
241
242        Ok(())
243    }
244
245    #[tokio::test]
246    async fn test_coco() -> Result<(), Error> {
247        let coco_labels = HashMap::from([
248            (0, "person"),
249            (1, "bicycle"),
250            (2, "car"),
251            (3, "motorcycle"),
252            (4, "airplane"),
253            (5, "bus"),
254            (6, "train"),
255            (7, "truck"),
256            (8, "boat"),
257            (9, "traffic light"),
258            (10, "fire hydrant"),
259            (11, "stop sign"),
260            (12, "parking meter"),
261            (13, "bench"),
262            (14, "bird"),
263            (15, "cat"),
264            (16, "dog"),
265            (17, "horse"),
266            (18, "sheep"),
267            (19, "cow"),
268            (20, "elephant"),
269            (21, "bear"),
270            (22, "zebra"),
271            (23, "giraffe"),
272            (24, "backpack"),
273            (25, "umbrella"),
274            (26, "handbag"),
275            (27, "tie"),
276            (28, "suitcase"),
277            (29, "frisbee"),
278            (30, "skis"),
279            (31, "snowboard"),
280            (32, "sports ball"),
281            (33, "kite"),
282            (34, "baseball bat"),
283            (35, "baseball glove"),
284            (36, "skateboard"),
285            (37, "surfboard"),
286            (38, "tennis racket"),
287            (39, "bottle"),
288            (40, "wine glass"),
289            (41, "cup"),
290            (42, "fork"),
291            (43, "knife"),
292            (44, "spoon"),
293            (45, "bowl"),
294            (46, "banana"),
295            (47, "apple"),
296            (48, "sandwich"),
297            (49, "orange"),
298            (50, "broccoli"),
299            (51, "carrot"),
300            (52, "hot dog"),
301            (53, "pizza"),
302            (54, "donut"),
303            (55, "cake"),
304            (56, "chair"),
305            (57, "couch"),
306            (58, "potted plant"),
307            (59, "bed"),
308            (60, "dining table"),
309            (61, "toilet"),
310            (62, "tv"),
311            (63, "laptop"),
312            (64, "mouse"),
313            (65, "remote"),
314            (66, "keyboard"),
315            (67, "cell phone"),
316            (68, "microwave"),
317            (69, "oven"),
318            (70, "toaster"),
319            (71, "sink"),
320            (72, "refrigerator"),
321            (73, "book"),
322            (74, "clock"),
323            (75, "vase"),
324            (76, "scissors"),
325            (77, "teddy bear"),
326            (78, "hair drier"),
327            (79, "toothbrush"),
328        ]);
329
330        let client = get_client().await?;
331        let project = client.projects(Some("Sample Project")).await?;
332        assert!(!project.is_empty());
333        let project = project.first().unwrap();
334        let datasets = client.datasets(project.id(), Some("COCO")).await?;
335        assert!(!datasets.is_empty());
336        // Filter to avoid fetching the COCO People dataset.
337        let dataset = datasets.iter().find(|d| d.name() == "COCO").unwrap();
338
339        let labels = dataset.labels(&client).await?;
340        assert_eq!(labels.len(), 80);
341
342        for label in &labels {
343            assert_eq!(label.name(), coco_labels[&label.index()]);
344        }
345
346        let n_samples = client
347            .samples_count(dataset.id(), None, &[], &["val".to_string()], &[])
348            .await?;
349        assert_eq!(n_samples.total, 5000);
350
351        let samples = client
352            .samples(dataset.id(), None, &[], &["val".to_string()], &[], None)
353            .await?;
354        assert_eq!(samples.len(), 5000);
355
356        Ok(())
357    }
358
359    #[cfg(feature = "polars")]
360    #[tokio::test]
361    async fn test_coco_dataframe() -> Result<(), Error> {
362        let client = get_client().await?;
363        let project = client.projects(Some("Sample Project")).await?;
364        assert!(!project.is_empty());
365        let project = project.first().unwrap();
366        let datasets = client.datasets(project.id(), Some("COCO")).await?;
367        assert!(!datasets.is_empty());
368        // Filter to avoid fetching the COCO People dataset.
369        let dataset = datasets.iter().find(|d| d.name() == "COCO").unwrap();
370
371        let annotation_set_id = dataset
372            .annotation_sets(&client)
373            .await?
374            .first()
375            .unwrap()
376            .id();
377
378        let annotations = client
379            .annotations(annotation_set_id, &["val".to_string()], &[], None)
380            .await?;
381        let df = annotations_dataframe(&annotations);
382        let df = df
383            .unique_stable(Some(&["name".to_string()]), UniqueKeepStrategy::First, None)
384            .unwrap();
385        assert_eq!(df.height(), 5000);
386
387        Ok(())
388    }
389
390    #[tokio::test]
391    async fn test_snapshots() -> Result<(), Error> {
392        let client = get_client().await?;
393        let snapshots = client.snapshots(None).await?;
394
395        for snapshot in snapshots {
396            let snapshot_id = snapshot.id();
397            let result = client.snapshot(snapshot_id).await?;
398            assert_eq!(result.id(), snapshot_id);
399        }
400
401        Ok(())
402    }
403
404    #[tokio::test]
405    async fn test_experiments() -> Result<(), Error> {
406        let client = get_client().await?;
407        let project = client.projects(Some("Unit Testing")).await?;
408        assert!(!project.is_empty());
409        let project = project.first().unwrap();
410        let experiments = client.experiments(project.id(), None).await?;
411
412        for experiment in experiments {
413            let experiment_id = experiment.id();
414            let result = client.experiment(experiment_id).await?;
415            assert_eq!(result.id(), experiment_id);
416        }
417
418        Ok(())
419    }
420
421    #[tokio::test]
422    async fn test_training_session() -> Result<(), Error> {
423        let client = get_client().await?;
424        let project = client.projects(Some("Unit Testing")).await?;
425        assert!(!project.is_empty());
426        let project = project.first().unwrap();
427        let experiment = client
428            .experiments(project.id(), Some("Unit Testing"))
429            .await?;
430        let experiment = experiment.first().unwrap();
431
432        let sessions = client
433            .training_sessions(experiment.id(), Some("modelpack-usermanaged"))
434            .await?;
435        assert_ne!(sessions.len(), 0);
436        let session = sessions.first().unwrap();
437
438        let metrics = HashMap::from([
439            ("epochs".to_string(), Parameter::Integer(10)),
440            ("loss".to_string(), Parameter::Real(0.05)),
441            (
442                "model".to_string(),
443                Parameter::String("modelpack".to_string()),
444            ),
445        ]);
446
447        session.set_metrics(&client, metrics).await?;
448        let updated_metrics = session.metrics(&client).await?;
449        assert_eq!(updated_metrics.len(), 3);
450        assert_eq!(updated_metrics.get("epochs"), Some(&Parameter::Integer(10)));
451        assert_eq!(updated_metrics.get("loss"), Some(&Parameter::Real(0.05)));
452        assert_eq!(
453            updated_metrics.get("model"),
454            Some(&Parameter::String("modelpack".to_string()))
455        );
456
457        println!("Updated Metrics: {:?}", updated_metrics);
458
459        let mut labels = tempfile::NamedTempFile::new()?;
460        write!(labels, "background")?;
461        labels.flush()?;
462
463        session
464            .upload(
465                &client,
466                &[(
467                    "artifacts/labels.txt".to_string(),
468                    labels.path().to_path_buf(),
469                )],
470            )
471            .await?;
472
473        let labels = session.download(&client, "artifacts/labels.txt").await?;
474        assert_eq!(labels, "background");
475
476        Ok(())
477    }
478
479    #[tokio::test]
480    async fn test_validate() -> Result<(), Error> {
481        let client = get_client().await?;
482        let project = client.projects(Some("Unit Testing")).await?;
483        assert!(!project.is_empty());
484        let project = project.first().unwrap();
485
486        let sessions = client.validation_sessions(project.id()).await?;
487        for session in &sessions {
488            let s = client.validation_session(session.id()).await?;
489            assert_eq!(s.id(), session.id());
490            assert_eq!(s.description(), session.description());
491        }
492
493        let session = sessions
494            .into_iter()
495            .find(|s| s.name() == "modelpack-usermanaged")
496            .unwrap_or_else(|| {
497                panic!(
498                    "Validation session 'modelpack-usermanaged' not found in project '{}'",
499                    project.name()
500                )
501            });
502
503        let metrics = HashMap::from([("accuracy".to_string(), Parameter::Real(0.95))]);
504        session.set_metrics(&client, metrics).await?;
505
506        let metrics = session.metrics(&client).await?;
507        assert_eq!(metrics.get("accuracy"), Some(&Parameter::Real(0.95)));
508
509        Ok(())
510    }
511
512    #[tokio::test]
513    async fn test_artifacts() -> Result<(), Error> {
514        let client = get_client().await?;
515        let project = client.projects(Some("Unit Testing")).await?;
516        assert!(!project.is_empty());
517        let project = project.first().unwrap();
518        let experiment = client
519            .experiments(project.id(), Some("Unit Testing"))
520            .await?;
521        let experiment = experiment.first().unwrap();
522        let trainer = client
523            .training_sessions(experiment.id(), Some("modelpack-960x540"))
524            .await?;
525        let trainer = trainer.first().unwrap();
526        let artifacts = client.artifacts(trainer.id()).await?;
527        assert!(!artifacts.is_empty());
528
529        let test_dir = get_test_data_dir();
530
531        for artifact in artifacts {
532            let output_path = test_dir.join(artifact.name());
533            client
534                .download_artifact(
535                    trainer.id(),
536                    artifact.name(),
537                    Some(output_path.clone()),
538                    None,
539                )
540                .await?;
541
542            // Clean up downloaded file
543            if output_path.exists() {
544                std::fs::remove_file(&output_path)?;
545            }
546        }
547
548        let fake_path = test_dir.join("fakefile.txt");
549        let res = client
550            .download_artifact(trainer.id(), "fakefile.txt", Some(fake_path.clone()), None)
551            .await;
552        assert!(res.is_err());
553        assert!(!fake_path.exists());
554
555        Ok(())
556    }
557
558    #[tokio::test]
559    async fn test_checkpoints() -> Result<(), Error> {
560        let client = get_client().await?;
561        let project = client.projects(Some("Unit Testing")).await?;
562        assert!(!project.is_empty());
563        let project = project.first().unwrap();
564        let experiment = client
565            .experiments(project.id(), Some("Unit Testing"))
566            .await?;
567        let experiment = experiment.first().unwrap_or_else(|| {
568            panic!(
569                "Experiment 'Unit Testing' not found in project '{}'",
570                project.name()
571            )
572        });
573        let trainer = client
574            .training_sessions(experiment.id(), Some("modelpack-usermanaged"))
575            .await?;
576        let trainer = trainer.first().unwrap();
577
578        let test_dir = get_test_data_dir();
579        let checkpoint_path = test_dir.join("checkpoint.txt");
580        let checkpoint2_path = test_dir.join("checkpoint2.txt");
581
582        {
583            let mut chkpt = File::create(&checkpoint_path)?;
584            chkpt.write_all(b"Test Checkpoint")?;
585        }
586
587        trainer
588            .upload(
589                &client,
590                &[(
591                    "checkpoints/checkpoint.txt".to_string(),
592                    checkpoint_path.clone(),
593                )],
594            )
595            .await?;
596
597        client
598            .download_checkpoint(
599                trainer.id(),
600                "checkpoint.txt",
601                Some(checkpoint2_path.clone()),
602                None,
603            )
604            .await?;
605
606        let chkpt = read_to_string(&checkpoint2_path)?;
607        assert_eq!(chkpt, "Test Checkpoint");
608
609        let fake_path = test_dir.join("fakefile.txt");
610        let res = client
611            .download_checkpoint(trainer.id(), "fakefile.txt", Some(fake_path.clone()), None)
612            .await;
613        assert!(res.is_err());
614        assert!(!fake_path.exists());
615
616        // Clean up
617        if checkpoint_path.exists() {
618            std::fs::remove_file(&checkpoint_path)?;
619        }
620        if checkpoint2_path.exists() {
621            std::fs::remove_file(&checkpoint2_path)?;
622        }
623
624        Ok(())
625    }
626
627    #[tokio::test]
628    async fn test_tasks() -> Result<(), Error> {
629        let client = get_client().await?;
630        let project = client.projects(Some("Unit Testing")).await?;
631        let project = project.first().unwrap();
632        let tasks = client.tasks(None, None, None, None).await?;
633
634        for task in tasks {
635            let task_info = client.task_info(task.id()).await?;
636            println!("{} - {}", task, task_info);
637        }
638
639        let tasks = client
640            .tasks(Some("modelpack-usermanaged"), None, None, None)
641            .await?;
642        let tasks = tasks
643            .into_iter()
644            .map(|t| client.task_info(t.id()))
645            .collect::<Vec<_>>();
646        let tasks = futures::future::try_join_all(tasks).await?;
647        let tasks = tasks
648            .into_iter()
649            .filter(|t| t.project_id() == Some(project.id()))
650            .collect::<Vec<_>>();
651        assert_ne!(tasks.len(), 0);
652        let task = &tasks[0];
653
654        let t = client.task_status(task.id(), "training").await?;
655        assert_eq!(t.id(), task.id());
656        assert_eq!(t.status(), "training");
657
658        let stages = [
659            ("download", "Downloading Dataset"),
660            ("train", "Training Model"),
661            ("export", "Exporting Model"),
662        ];
663        client.set_stages(task.id(), &stages).await?;
664
665        client
666            .update_stage(task.id(), "download", "running", "Downloading dataset", 50)
667            .await?;
668
669        let task = client.task_info(task.id()).await?;
670        println!("task progress: {:?}", task.stages());
671
672        Ok(())
673    }
674
675    /// Generate a 640x480 PNG image with a red circle and return the image data
676    /// plus the bounding box coordinates (x, y, w, h) in pixels.
677    /// Generate a 640x480 image with a red circle in the specified format.
678    /// Returns the image data plus the bounding box coordinates (x, y, w, h) in
679    /// pixels. Supported formats: "png", "jpeg"
680    fn generate_test_image_with_circle_format(format: &str) -> (Vec<u8>, (f32, f32, f32, f32)) {
681        use image::{ImageBuffer, Rgb, RgbImage};
682        use std::io::Cursor;
683
684        let width = 640u32;
685        let height = 480u32;
686
687        // Create white image
688        let mut img: RgbImage = ImageBuffer::from_pixel(width, height, Rgb([255u8, 255u8, 255u8]));
689
690        // Draw a red circle in the top-left quadrant
691        let center_x = 150.0;
692        let center_y = 120.0;
693        let radius = 50.0;
694
695        for y in 0..height {
696            for x in 0..width {
697                let dx = x as f32 - center_x;
698                let dy = y as f32 - center_y;
699                let distance = (dx * dx + dy * dy).sqrt();
700
701                if distance <= radius {
702                    img.put_pixel(x, y, Rgb([255u8, 0u8, 0u8])); // Red
703                }
704            }
705        }
706
707        // Encode in the specified format
708        let mut image_data = Vec::new();
709        let mut cursor = Cursor::new(&mut image_data);
710
711        match format {
712            "jpeg" | "jpg" => {
713                img.write_to(&mut cursor, image::ImageFormat::Jpeg).unwrap();
714            }
715            "png" => {
716                img.write_to(&mut cursor, image::ImageFormat::Png).unwrap();
717            }
718            _ => panic!("Unsupported format: {}", format),
719        }
720
721        // Calculate bounding box around the circle (with some padding)
722        let bbox_x = center_x - radius - 5.0;
723        let bbox_y = center_y - radius - 5.0;
724        let bbox_w = (radius * 2.0) + 10.0;
725        let bbox_h = (radius * 2.0) + 10.0;
726
727        (image_data, (bbox_x, bbox_y, bbox_w, bbox_h))
728    }
729
730    #[tokio::test]
731    async fn test_populate_samples() -> Result<(), Error> {
732        let client = get_client().await?;
733
734        // Find the Unit Testing project and Test Labels dataset
735        let projects = client.projects(Some("Unit Testing")).await?;
736        let project = projects.first().unwrap();
737
738        let datasets = client.datasets(project.id(), Some("Test Labels")).await?;
739        let dataset = datasets.first().unwrap();
740
741        // Get the first annotation set
742        let annotation_sets = client.annotation_sets(dataset.id()).await?;
743        let annotation_set = annotation_sets.first().unwrap();
744
745        // Generate a 640x480 PNG image with a red circle
746        // (Tested with JPEG too - server doesn't return width/height for either format)
747        let test_format = "png";
748        let file_extension = "png";
749
750        // Generate a 640x480 image with a red circle
751        let (image_data, circle_bbox) = generate_test_image_with_circle_format(test_format);
752        eprintln!(
753            "Generated {} image with circle at bbox: ({:.1}, {:.1}, {:.1}, {:.1})",
754            test_format, circle_bbox.0, circle_bbox.1, circle_bbox.2, circle_bbox.3
755        );
756
757        // Create temporary file
758        let timestamp = std::time::SystemTime::now()
759            .duration_since(std::time::UNIX_EPOCH)
760            .unwrap()
761            .as_secs();
762        let temp_dir = std::env::temp_dir();
763        let test_image_path =
764            temp_dir.join(format!("test_populate_{}.{}", timestamp, file_extension));
765        std::fs::write(&test_image_path, &image_data)?;
766
767        // Also save a copy to target/testdata for manual inspection
768        let testdata_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
769            .parent()
770            .unwrap()
771            .parent()
772            .unwrap()
773            .join("target")
774            .join("testdata");
775        std::fs::create_dir_all(&testdata_dir).ok();
776        let local_copy = testdata_dir.join(format!(
777            "test_populate_circle_{}.{}",
778            timestamp, file_extension
779        ));
780        std::fs::write(&local_copy, &image_data)?;
781        eprintln!("Test image saved to: {:?}", local_copy);
782
783        // Create sample with annotation
784        let mut sample = Sample::new();
785        let img_width = 640.0;
786        let img_height = 480.0;
787        // Don't set width/height - let populate_samples() extract from image
788        sample.group = Some("train".to_string());
789        // UUID will be auto-generated
790
791        // Add file
792        sample.files = vec![SampleFile::with_filename(
793            "image".to_string(),
794            test_image_path.to_str().unwrap().to_string(),
795        )];
796
797        // Add bounding box annotation with NORMALIZED coordinates
798        let mut annotation = Annotation::new();
799        annotation.set_label(Some("circle".to_string()));
800        annotation.set_object_id(Some("circle-obj-1".to_string()));
801
802        // Normalize coordinates: divide pixel values by image dimensions
803        let normalized_x = circle_bbox.0 / img_width;
804        let normalized_y = circle_bbox.1 / img_height;
805        let normalized_w = circle_bbox.2 / img_width;
806        let normalized_h = circle_bbox.3 / img_height;
807
808        eprintln!(
809            "Normalized bbox: ({:.3}, {:.3}, {:.3}, {:.3})",
810            normalized_x, normalized_y, normalized_w, normalized_h
811        );
812
813        let bbox = Box2d::new(normalized_x, normalized_y, normalized_w, normalized_h);
814        annotation.set_box2d(Some(bbox));
815        sample.annotations = vec![annotation];
816
817        // Populate the sample
818        let results = client
819            .populate_samples(dataset.id(), Some(annotation_set.id()), vec![sample], None)
820            .await?;
821
822        assert_eq!(results.len(), 1);
823        let result = &results[0];
824        assert_eq!(result.urls.len(), 1);
825
826        // The image filename we'll search for when fetching back
827        let image_filename = format!("test_populate_{}.{}", timestamp, file_extension);
828
829        // Give the server a moment to process the upload
830        tokio::time::sleep(std::time::Duration::from_secs(2)).await;
831
832        // Verify the sample was created by fetching it back and searching by image_name
833        let samples = client
834            .samples(
835                dataset.id(),
836                Some(annotation_set.id()),
837                &[],
838                &[], // Don't filter by group - get all samples
839                &[],
840                None,
841            )
842            .await?;
843
844        eprintln!("Looking for image: {}", image_filename);
845        eprintln!("Found {} samples total", samples.len());
846
847        // Find the sample by image_name (server doesn't return UUID we sent)
848        let created_sample = samples
849            .iter()
850            .find(|s| s.image_name.as_deref() == Some(&image_filename));
851
852        assert!(
853            created_sample.is_some(),
854            "Sample with image_name '{}' should exist in dataset",
855            image_filename
856        );
857        let created_sample = created_sample.unwrap();
858
859        eprintln!("✓ Found sample by image_name: {}", image_filename);
860
861        // Verify basic properties
862        assert_eq!(
863            created_sample.image_name.as_deref(),
864            Some(&image_filename[..])
865        );
866        assert_eq!(created_sample.group, Some("train".to_string()));
867
868        eprintln!("\nSample verification:");
869        eprintln!("  ✓ image_name: {:?}", created_sample.image_name);
870        eprintln!("  ✓ group: {:?}", created_sample.group);
871        eprintln!(
872            "  ✓ annotations: {} item(s)",
873            created_sample.annotations.len()
874        );
875
876        // Note: The server currently doesn't return width/height or UUID fields in
877        // samples.list This is a known server limitation (bug report
878        // submitted).
879        eprintln!(
880            "  ⚠ uuid: {:?} (not returned by server)",
881            created_sample.uuid
882        );
883        eprintln!(
884            "  ⚠ width: {:?} (not returned by server)",
885            created_sample.width
886        );
887        eprintln!(
888            "  ⚠ height: {:?} (not returned by server)",
889            created_sample.height
890        );
891
892        // Verify annotations are returned correctly
893        let annotations = &created_sample.annotations;
894        assert_eq!(annotations.len(), 1, "Should have exactly one annotation");
895
896        let annotation = &annotations[0];
897        assert_eq!(annotation.label(), Some(&"circle".to_string()));
898        assert!(
899            annotation.box2d().is_some(),
900            "Bounding box should be present"
901        );
902
903        let returned_bbox = annotation.box2d().unwrap();
904        eprintln!("\nAnnotation verification:");
905        eprintln!("  ✓ label: {:?}", annotation.label());
906        eprintln!(
907            "  ✓ bbox: x={:.3}, y={:.3}, w={:.3}, h={:.3}",
908            returned_bbox.left(),
909            returned_bbox.top(),
910            returned_bbox.width(),
911            returned_bbox.height()
912        );
913
914        // Verify the bounding box coordinates match what we sent (within tolerance)
915        assert!(
916            (returned_bbox.left() - normalized_x).abs() < 0.01,
917            "bbox.x should match (sent: {:.3}, got: {:.3})",
918            normalized_x,
919            returned_bbox.left()
920        );
921        assert!(
922            (returned_bbox.top() - normalized_y).abs() < 0.01,
923            "bbox.y should match (sent: {:.3}, got: {:.3})",
924            normalized_y,
925            returned_bbox.top()
926        );
927        assert!(
928            (returned_bbox.width() - normalized_w).abs() < 0.01,
929            "bbox.w should match (sent: {:.3}, got: {:.3})",
930            normalized_w,
931            returned_bbox.width()
932        );
933        assert!(
934            (returned_bbox.height() - normalized_h).abs() < 0.01,
935            "bbox.h should match (sent: {:.3}, got: {:.3})",
936            normalized_h,
937            returned_bbox.height()
938        );
939
940        // Verify the uploaded image matches what we sent (byte-for-byte)
941        eprintln!("\nImage verification:");
942        let downloaded_image = created_sample.download(&client, FileType::Image).await?;
943        assert!(
944            downloaded_image.is_some(),
945            "Should be able to download the image"
946        );
947        let downloaded_data = downloaded_image.unwrap();
948
949        assert_eq!(
950            image_data.len(),
951            downloaded_data.len(),
952            "Downloaded image should have same size as uploaded"
953        );
954        assert_eq!(
955            image_data, downloaded_data,
956            "Downloaded image should match uploaded image byte-for-byte"
957        );
958        eprintln!("  ✓ Image data matches ({} bytes)", image_data.len());
959
960        // Clean up
961        let _ = std::fs::remove_file(&test_image_path);
962
963        Ok(())
964    }
965}