Skip to main content

torsh_data/
augmentation_pipeline.rs

1//! Data augmentation pipeline for machine learning training
2//!
3//! This module provides a comprehensive augmentation system for training data preprocessing.
4//! Augmentation is a critical technique for improving model generalization by applying
5//! random transformations to training data.
6//!
7//! # Features
8//!
9//! - **Pipeline composition**: AugmentationPipeline for chaining multiple transforms
10//! - **Probabilistic transforms**: ConditionalTransform for random application
11//! - **Image augmentations**: Color, brightness, contrast, and geometric transforms
12//! - **Noise augmentation**: GaussianNoise for regularization
13//! - **Cutout/Erasing**: RandomErasing for occlusion robustness
14//! - **Preset pipelines**: Common augmentation configurations for different use cases
15
16use crate::transforms::Transform;
17use torsh_core::dtype::{FloatElement, TensorElement};
18use torsh_core::error::Result;
19use torsh_tensor::Tensor;
20
21#[cfg(not(feature = "std"))]
22use alloc::{boxed::Box, vec::Vec};
23
24#[cfg(feature = "std")]
25use scirs2_core::random::thread_rng;
26
27#[cfg(not(feature = "std"))]
28use scirs2_core::random::thread_rng;
29
30/// Augmentation pipeline builder for easy composition of transforms
31pub struct AugmentationPipeline<T> {
32    transforms: Vec<Box<dyn Transform<T, Output = T> + Send + Sync>>,
33    probability: f32,
34}
35
36impl<T: 'static + Send + Sync> AugmentationPipeline<T> {
37    /// Create a new augmentation pipeline
38    pub fn new() -> Self {
39        Self {
40            transforms: Vec::new(),
41            probability: 1.0,
42        }
43    }
44
45    /// Set the probability of applying the entire pipeline
46    pub fn with_probability(mut self, prob: f32) -> Self {
47        assert!(
48            (0.0..=1.0).contains(&prob),
49            "Probability must be between 0 and 1"
50        );
51        self.probability = prob;
52        self
53    }
54
55    /// Add a transform to the pipeline
56    pub fn add_transform<F>(mut self, transform: F) -> Self
57    where
58        F: Transform<T, Output = T> + 'static,
59    {
60        self.transforms.push(Box::new(transform));
61        self
62    }
63
64    /// Add a conditional transform that only applies with given probability
65    pub fn add_conditional<F>(self, transform: F, prob: f32) -> Self
66    where
67        F: Transform<T, Output = T> + 'static,
68    {
69        self.add_transform(ConditionalTransform::new(transform, prob))
70    }
71}
72
73impl<T: 'static + Send + Sync> Default for AugmentationPipeline<T> {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl<T> Transform<T> for AugmentationPipeline<T> {
80    type Output = T;
81
82    fn transform(&self, mut input: T) -> Result<Self::Output> {
83        let mut rng = thread_rng();
84
85        // Check if we should apply the pipeline at all
86        if rng.random::<f32>() > self.probability {
87            return Ok(input);
88        }
89
90        // Apply all transforms in sequence
91        for transform in &self.transforms {
92            input = transform.transform(input)?;
93        }
94
95        Ok(input)
96    }
97}
98
99/// Conditional transform that applies with a given probability
100pub struct ConditionalTransform<T, F> {
101    transform: F,
102    probability: f32,
103    _phantom: core::marker::PhantomData<T>,
104}
105
106impl<T, F> ConditionalTransform<T, F> {
107    pub fn new(transform: F, probability: f32) -> Self {
108        assert!(
109            (0.0..=1.0).contains(&probability),
110            "Probability must be between 0 and 1"
111        );
112        Self {
113            transform,
114            probability,
115            _phantom: core::marker::PhantomData,
116        }
117    }
118}
119
120impl<T, F> Transform<T> for ConditionalTransform<T, F>
121where
122    F: Transform<T, Output = T>,
123    T: Send + Sync,
124{
125    type Output = T;
126
127    fn transform(&self, input: T) -> Result<Self::Output> {
128        let mut rng = thread_rng();
129
130        if rng.random::<f32>() < self.probability {
131            self.transform.transform(input)
132        } else {
133            Ok(input)
134        }
135    }
136}
137
138/// Random brightness adjustment
139pub struct RandomBrightness {
140    factor_range: (f32, f32),
141}
142
143impl RandomBrightness {
144    pub fn new(factor_range: (f32, f32)) -> Self {
145        assert!(factor_range.0 <= factor_range.1, "Invalid factor range");
146        Self { factor_range }
147    }
148
149    /// Create with symmetric range around 1.0
150    pub fn symmetric(factor: f32) -> Self {
151        Self::new((1.0 - factor, 1.0 + factor))
152    }
153}
154
155impl<T: FloatElement> Transform<Tensor<T>> for RandomBrightness {
156    type Output = Tensor<T>;
157
158    fn transform(&self, input: Tensor<T>) -> Result<Self::Output> {
159        let mut rng = thread_rng();
160        let (lo, hi) = self.factor_range;
161        let factor = lo + rng.random::<f32>() * (hi - lo);
162        let shape = input.shape().dims().to_vec();
163        let device = input.device();
164        let data = input
165            .to_vec()
166            .map_err(|e| torsh_core::error::TorshError::Other(format!("to_vec failed: {}", e)))?;
167        let out: Vec<T> = data
168            .iter()
169            .map(|&x| {
170                let v = <T as TensorElement>::to_f64(&x).unwrap_or(0.0) * factor as f64;
171                let clamped = v.max(0.0).min(1.0);
172                <T as TensorElement>::from_f64(clamped).unwrap_or(x)
173            })
174            .collect();
175        Tensor::from_data(out, shape, device).map_err(|e| e.into())
176    }
177}
178
179/// Random contrast adjustment
180pub struct RandomContrast {
181    factor_range: (f32, f32),
182}
183
184impl RandomContrast {
185    pub fn new(factor_range: (f32, f32)) -> Self {
186        assert!(factor_range.0 <= factor_range.1, "Invalid factor range");
187        Self { factor_range }
188    }
189
190    /// Create with symmetric range around 1.0
191    pub fn symmetric(factor: f32) -> Self {
192        Self::new((1.0 - factor, 1.0 + factor))
193    }
194}
195
196impl<T: FloatElement> Transform<Tensor<T>> for RandomContrast {
197    type Output = Tensor<T>;
198
199    fn transform(&self, input: Tensor<T>) -> Result<Self::Output> {
200        let mut rng = thread_rng();
201        let (lo, hi) = self.factor_range;
202        let factor = lo + rng.random::<f32>() * (hi - lo);
203        let shape = input.shape().dims().to_vec();
204        let device = input.device();
205        let data = input
206            .to_vec()
207            .map_err(|e| torsh_core::error::TorshError::Other(format!("to_vec failed: {}", e)))?;
208        let n = data.len();
209        let mean: f64 = data
210            .iter()
211            .map(|x| <T as TensorElement>::to_f64(x).unwrap_or(0.0))
212            .sum::<f64>()
213            / n.max(1) as f64;
214        let out: Vec<T> = data
215            .iter()
216            .map(|&x| {
217                let v =
218                    mean + (<T as TensorElement>::to_f64(&x).unwrap_or(0.0) - mean) * factor as f64;
219                let clamped = v.max(0.0).min(1.0);
220                <T as TensorElement>::from_f64(clamped).unwrap_or(x)
221            })
222            .collect();
223        Tensor::from_data(out, shape, device).map_err(|e| e.into())
224    }
225}
226
227/// Random saturation adjustment (for color images)
228pub struct RandomSaturation {
229    factor_range: (f32, f32),
230}
231
232impl RandomSaturation {
233    pub fn new(factor_range: (f32, f32)) -> Self {
234        assert!(factor_range.0 <= factor_range.1, "Invalid factor range");
235        Self { factor_range }
236    }
237
238    /// Create with symmetric range around 1.0
239    pub fn symmetric(factor: f32) -> Self {
240        Self::new((1.0 - factor, 1.0 + factor))
241    }
242}
243
244impl<T: FloatElement> Transform<Tensor<T>> for RandomSaturation {
245    type Output = Tensor<T>;
246
247    fn transform(&self, input: Tensor<T>) -> Result<Self::Output> {
248        let mut rng = thread_rng();
249        let (lo, hi) = self.factor_range;
250        let factor = lo + rng.random::<f32>() * (hi - lo);
251        let binding = input.shape();
252        let dims = binding.dims();
253        // Only apply saturation for 3-channel CHW images
254        if dims.len() != 3 || dims[0] != 3 {
255            return Ok(input);
256        }
257        let (_, height, width) = (dims[0], dims[1], dims[2]);
258        let hw = height * width;
259        let device = input.device();
260        let data = input
261            .to_vec()
262            .map_err(|e| torsh_core::error::TorshError::Other(format!("to_vec failed: {}", e)))?;
263        // ITU-R BT.601 luminance coefficients
264        const LUM_R: f64 = 0.299;
265        const LUM_G: f64 = 0.587;
266        const LUM_B: f64 = 0.114;
267        let mut out = data.clone();
268        for px in 0..hw {
269            let r = <T as TensorElement>::to_f64(&data[px]).unwrap_or(0.0);
270            let g = <T as TensorElement>::to_f64(&data[hw + px]).unwrap_or(0.0);
271            let b = <T as TensorElement>::to_f64(&data[2 * hw + px]).unwrap_or(0.0);
272            let lum = LUM_R * r + LUM_G * g + LUM_B * b;
273            let sat = factor as f64;
274            let new_r = (lum + sat * (r - lum)).max(0.0).min(1.0);
275            let new_g = (lum + sat * (g - lum)).max(0.0).min(1.0);
276            let new_b = (lum + sat * (b - lum)).max(0.0).min(1.0);
277            out[px] = <T as TensorElement>::from_f64(new_r).unwrap_or(data[px]);
278            out[hw + px] = <T as TensorElement>::from_f64(new_g).unwrap_or(data[hw + px]);
279            out[2 * hw + px] = <T as TensorElement>::from_f64(new_b).unwrap_or(data[2 * hw + px]);
280        }
281        Tensor::from_data(out, dims.to_vec(), device).map_err(|e| e.into())
282    }
283}
284
285/// Random hue adjustment (for color images)
286pub struct RandomHue {
287    delta_range: (f32, f32),
288}
289
290impl RandomHue {
291    pub fn new(delta_range: (f32, f32)) -> Self {
292        assert!(delta_range.0 <= delta_range.1, "Invalid delta range");
293        assert!(
294            delta_range.0 >= -1.0 && delta_range.1 <= 1.0,
295            "Hue delta must be in [-1, 1]"
296        );
297        Self { delta_range }
298    }
299
300    /// Create with symmetric range
301    pub fn symmetric(delta: f32) -> Self {
302        Self::new((-delta, delta))
303    }
304}
305
306impl<T: FloatElement> Transform<Tensor<T>> for RandomHue {
307    type Output = Tensor<T>;
308
309    fn transform(&self, input: Tensor<T>) -> Result<Self::Output> {
310        // Note: True hue rotation requires RGB→HSV→RGB conversion.
311        // This is a per-channel scale approximation: each channel is
312        // scaled by a slightly different random factor derived from the
313        // hue delta, which shifts the apparent color balance.
314        let mut rng = thread_rng();
315        let (lo, hi) = self.delta_range;
316        let delta = lo + rng.random::<f32>() * (hi - lo);
317        let binding = input.shape();
318        let dims = binding.dims();
319        // Only apply to 3-channel CHW images
320        if dims.len() != 3 || dims[0] != 3 {
321            return Ok(input);
322        }
323        let (_, height, width) = (dims[0], dims[1], dims[2]);
324        let hw = height * width;
325        let device = input.device();
326        let data = input
327            .to_vec()
328            .map_err(|e| torsh_core::error::TorshError::Other(format!("to_vec failed: {}", e)))?;
329        // Hue shift: rotate color weights using delta as rotation angle proxy
330        // R channel boosted by sin(delta*pi), G unchanged, B reduced by sin(delta*pi)
331        let angle = delta as f64 * std::f64::consts::PI;
332        let r_scale = 1.0 + angle.sin() * 0.5;
333        let g_scale = 1.0 - angle.abs().sin() * 0.1;
334        let b_scale = 1.0 - angle.sin() * 0.5;
335        let mut out = data.clone();
336        for px in 0..hw {
337            let r = <T as TensorElement>::to_f64(&data[px]).unwrap_or(0.0);
338            let g = <T as TensorElement>::to_f64(&data[hw + px]).unwrap_or(0.0);
339            let b = <T as TensorElement>::to_f64(&data[2 * hw + px]).unwrap_or(0.0);
340            out[px] =
341                <T as TensorElement>::from_f64((r * r_scale).max(0.0).min(1.0)).unwrap_or(data[px]);
342            out[hw + px] = <T as TensorElement>::from_f64((g * g_scale).max(0.0).min(1.0))
343                .unwrap_or(data[hw + px]);
344            out[2 * hw + px] = <T as TensorElement>::from_f64((b * b_scale).max(0.0).min(1.0))
345                .unwrap_or(data[2 * hw + px]);
346        }
347        Tensor::from_data(out, dims.to_vec(), device).map_err(|e| e.into())
348    }
349}
350
351/// Random vertical flip
352pub struct RandomVerticalFlip {
353    prob: f32,
354}
355
356impl RandomVerticalFlip {
357    pub fn new(prob: f32) -> Self {
358        assert!(
359            (0.0..=1.0).contains(&prob),
360            "Probability must be between 0 and 1"
361        );
362        Self { prob }
363    }
364}
365
366impl<T: FloatElement> Transform<Tensor<T>> for RandomVerticalFlip {
367    type Output = Tensor<T>;
368
369    fn transform(&self, input: Tensor<T>) -> Result<Self::Output> {
370        let mut rng = thread_rng();
371        if rng.random::<f32>() >= self.prob {
372            return Ok(input);
373        }
374        let binding = input.shape();
375        let dims = binding.dims();
376        if dims.len() < 2 {
377            return Err(torsh_core::error::TorshError::InvalidArgument(
378                "Input tensor must have at least 2 dimensions for vertical flip".to_string(),
379            ));
380        }
381        let device = input.device();
382        let data = input
383            .to_vec()
384            .map_err(|e| torsh_core::error::TorshError::Other(format!("to_vec failed: {}", e)))?;
385        // For CHW (3D+), flip dim[-2] (height); for HW (2D), flip dim 0 (height)
386        let (height, width, channels) = if dims.len() == 2 {
387            (dims[0], dims[1], 1usize)
388        } else {
389            (
390                dims[dims.len() - 2],
391                dims[dims.len() - 1],
392                dims[..dims.len() - 2].iter().product(),
393            )
394        };
395        let mut out = data.clone();
396        for c in 0..channels {
397            for row in 0..height / 2 {
398                let mirror_row = height - 1 - row;
399                for col in 0..width {
400                    let idx1 = c * height * width + row * width + col;
401                    let idx2 = c * height * width + mirror_row * width + col;
402                    out.swap(idx1, idx2);
403                }
404            }
405        }
406        Tensor::from_data(out, dims.to_vec(), device).map_err(|e| e.into())
407    }
408}
409
410/// Gaussian noise addition
411pub struct GaussianNoise {
412    mean: f32,
413    std: f32,
414}
415
416impl GaussianNoise {
417    pub fn new(mean: f32, std: f32) -> Self {
418        assert!(std >= 0.0, "Standard deviation must be non-negative");
419        Self { mean, std }
420    }
421
422    /// Create with zero mean
423    pub fn with_std(std: f32) -> Self {
424        Self::new(0.0, std)
425    }
426}
427
428impl<T: FloatElement> Transform<Tensor<T>> for GaussianNoise {
429    type Output = Tensor<T>;
430
431    fn transform(&self, input: Tensor<T>) -> Result<Self::Output> {
432        if self.std <= 0.0 {
433            return Ok(input);
434        }
435        let mut rng = thread_rng();
436        let shape = input.shape().dims().to_vec();
437        let device = input.device();
438        let data = input
439            .to_vec()
440            .map_err(|e| torsh_core::error::TorshError::Other(format!("to_vec failed: {}", e)))?;
441        let mean_f64 = self.mean as f64;
442        let std_f64 = self.std as f64;
443        let out: Vec<T> = data
444            .iter()
445            .map(|&x| {
446                // Box-Muller transform for Gaussian noise
447                let u1: f32 = rng.random::<f32>().max(f32::EPSILON);
448                let u2: f32 = rng.random::<f32>();
449                let noise = ((-2.0 * u1.ln()) as f64).sqrt()
450                    * (2.0 * std::f64::consts::PI * u2 as f64).cos();
451                let noisy =
452                    <T as TensorElement>::to_f64(&x).unwrap_or(0.0) + mean_f64 + std_f64 * noise;
453                <T as TensorElement>::from_f64(noisy).unwrap_or(x)
454            })
455            .collect();
456        Tensor::from_data(out, shape, device).map_err(|e| e.into())
457    }
458}
459
460/// Random erasing (cutout) augmentation
461pub struct RandomErasing {
462    prob: f32,
463    scale_range: (f32, f32),
464    ratio_range: (f32, f32),
465    fill_value: f32,
466}
467
468impl RandomErasing {
469    pub fn new(prob: f32, scale_range: (f32, f32), ratio_range: (f32, f32)) -> Self {
470        assert!(
471            (0.0..=1.0).contains(&prob),
472            "Probability must be between 0 and 1"
473        );
474        assert!(scale_range.0 <= scale_range.1, "Invalid scale range");
475        assert!(ratio_range.0 <= ratio_range.1, "Invalid ratio range");
476
477        Self {
478            prob,
479            scale_range,
480            ratio_range,
481            fill_value: 0.0,
482        }
483    }
484
485    pub fn with_fill_value(mut self, fill_value: f32) -> Self {
486        self.fill_value = fill_value;
487        self
488    }
489}
490
491impl<T: FloatElement> Transform<Tensor<T>> for RandomErasing {
492    type Output = Tensor<T>;
493
494    fn transform(&self, input: Tensor<T>) -> Result<Self::Output> {
495        let mut rng = thread_rng();
496        if rng.random::<f32>() >= self.prob {
497            return Ok(input);
498        }
499        let binding = input.shape();
500        let dims = binding.dims();
501        if dims.len() < 2 {
502            return Err(torsh_core::error::TorshError::InvalidArgument(
503                "Input tensor must have at least 2 dimensions for random erasing".to_string(),
504            ));
505        }
506        let device = input.device();
507        let (height, width, channels) = if dims.len() == 2 {
508            (dims[0], dims[1], 1usize)
509        } else {
510            (
511                dims[dims.len() - 2],
512                dims[dims.len() - 1],
513                dims[..dims.len() - 2].iter().product(),
514            )
515        };
516        let total_area = (height * width) as f32;
517        // Sample erasing area as fraction of total
518        let (scale_lo, scale_hi) = self.scale_range;
519        let area_frac = scale_lo + rng.random::<f32>() * (scale_hi - scale_lo);
520        let erase_area = (total_area * area_frac) as usize;
521        // Sample aspect ratio
522        let (ratio_lo, ratio_hi) = self.ratio_range;
523        let ratio = ratio_lo + rng.random::<f32>() * (ratio_hi - ratio_lo);
524        let erase_h = ((erase_area as f32 / ratio).sqrt() as usize).clamp(1, height);
525        let erase_w = ((erase_area as f32 * ratio).sqrt() as usize).clamp(1, width);
526        if erase_h >= height || erase_w >= width {
527            return Ok(input);
528        }
529        let top = rng.gen_range(0..=(height - erase_h));
530        let left = rng.gen_range(0..=(width - erase_w));
531        let fill = <T as TensorElement>::from_f64(self.fill_value as f64)
532            .unwrap_or_else(<T as TensorElement>::zero);
533        let mut data = input
534            .to_vec()
535            .map_err(|e| torsh_core::error::TorshError::Other(format!("to_vec failed: {}", e)))?;
536        for c in 0..channels {
537            for row in top..(top + erase_h) {
538                for col in left..(left + erase_w) {
539                    let idx = c * height * width + row * width + col;
540                    data[idx] = fill;
541                }
542            }
543        }
544        Tensor::from_data(data, dims.to_vec(), device).map_err(|e| e.into())
545    }
546}
547
548/// Common augmentation presets
549impl AugmentationPipeline<Tensor<f32>> {
550    /// Create a light augmentation pipeline for training
551    pub fn light_augmentation() -> Self {
552        Self::new()
553            .add_conditional(
554                crate::tensor_transforms::RandomHorizontalFlip::new(0.5),
555                1.0,
556            )
557            .add_conditional(RandomBrightness::symmetric(0.1), 0.3)
558            .add_conditional(RandomContrast::symmetric(0.1), 0.3)
559    }
560
561    /// Create a medium augmentation pipeline
562    pub fn medium_augmentation() -> Self {
563        Self::new()
564            .add_conditional(
565                crate::tensor_transforms::RandomHorizontalFlip::new(0.5),
566                1.0,
567            )
568            .add_conditional(RandomVerticalFlip::new(0.1), 1.0)
569            .add_conditional(RandomBrightness::symmetric(0.2), 0.5)
570            .add_conditional(RandomContrast::symmetric(0.2), 0.5)
571            .add_conditional(RandomSaturation::symmetric(0.2), 0.3)
572            .add_conditional(GaussianNoise::with_std(0.01), 0.2)
573    }
574
575    /// Create a heavy augmentation pipeline
576    pub fn heavy_augmentation() -> Self {
577        Self::new()
578            .add_conditional(
579                crate::tensor_transforms::RandomHorizontalFlip::new(0.5),
580                1.0,
581            )
582            .add_conditional(RandomVerticalFlip::new(0.2), 1.0)
583            .add_conditional(RandomBrightness::symmetric(0.3), 0.7)
584            .add_conditional(RandomContrast::symmetric(0.3), 0.7)
585            .add_conditional(RandomSaturation::symmetric(0.3), 0.5)
586            .add_conditional(RandomHue::symmetric(0.1), 0.3)
587            .add_conditional(GaussianNoise::with_std(0.02), 0.3)
588            .add_conditional(RandomErasing::new(0.5, (0.02, 0.33), (0.3, 3.3)), 1.0)
589    }
590
591    /// Create an augmentation pipeline for ImageNet-style training
592    pub fn imagenet_augmentation() -> Self {
593        Self::new()
594            .add_conditional(
595                crate::tensor_transforms::RandomHorizontalFlip::new(0.5),
596                1.0,
597            )
598            .add_conditional(RandomBrightness::symmetric(0.2), 0.4)
599            .add_conditional(RandomContrast::symmetric(0.2), 0.4)
600            .add_conditional(RandomSaturation::symmetric(0.2), 0.4)
601            .add_conditional(RandomHue::symmetric(0.1), 0.1)
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use torsh_core::device::DeviceType;
609    use torsh_tensor::Tensor;
610
611    // Mock tensor for testing
612    fn mock_tensor() -> Tensor<f32> {
613        Tensor::from_data(vec![1.0f32, 2.0, 3.0, 4.0], vec![2, 2], DeviceType::Cpu).unwrap()
614    }
615
616    #[test]
617    fn test_augmentation_pipeline_creation() {
618        let pipeline = AugmentationPipeline::<i32>::new();
619        assert_eq!(pipeline.probability, 1.0);
620        assert_eq!(pipeline.transforms.len(), 0);
621    }
622
623    #[test]
624    fn test_augmentation_pipeline_with_probability() {
625        let pipeline = AugmentationPipeline::<i32>::new().with_probability(0.5);
626        assert_eq!(pipeline.probability, 0.5);
627    }
628
629    #[test]
630    #[should_panic(expected = "Probability must be between 0 and 1")]
631    fn test_invalid_probability() {
632        AugmentationPipeline::<i32>::new().with_probability(1.5);
633    }
634
635    #[test]
636    fn test_conditional_transform_creation() {
637        let transform: ConditionalTransform<i32, _> =
638            ConditionalTransform::new(crate::transforms::lambda(|x: i32| Ok(x * 2)), 0.5);
639        assert_eq!(transform.probability, 0.5);
640    }
641
642    #[test]
643    fn test_random_brightness_creation() {
644        let brightness = RandomBrightness::new((0.8, 1.2));
645        assert_eq!(brightness.factor_range, (0.8, 1.2));
646    }
647
648    #[test]
649    fn test_random_brightness_symmetric() {
650        let brightness = RandomBrightness::symmetric(0.2);
651        assert_eq!(brightness.factor_range, (0.8, 1.2));
652    }
653
654    #[test]
655    fn test_gaussian_noise_creation() {
656        let noise = GaussianNoise::new(0.0, 0.1);
657        assert_eq!(noise.mean, 0.0);
658        assert_eq!(noise.std, 0.1);
659    }
660
661    #[test]
662    fn test_gaussian_noise_with_std() {
663        let noise = GaussianNoise::with_std(0.05);
664        assert_eq!(noise.mean, 0.0);
665        assert_eq!(noise.std, 0.05);
666    }
667
668    #[test]
669    fn test_random_erasing_creation() {
670        let erasing = RandomErasing::new(0.5, (0.02, 0.33), (0.3, 3.3));
671        assert_eq!(erasing.prob, 0.5);
672        assert_eq!(erasing.scale_range, (0.02, 0.33));
673        assert_eq!(erasing.ratio_range, (0.3, 3.3));
674        assert_eq!(erasing.fill_value, 0.0);
675    }
676
677    #[test]
678    fn test_light_augmentation_preset() {
679        let pipeline = AugmentationPipeline::light_augmentation();
680        assert_eq!(pipeline.transforms.len(), 3);
681    }
682
683    #[test]
684    fn test_medium_augmentation_preset() {
685        let pipeline = AugmentationPipeline::medium_augmentation();
686        assert_eq!(pipeline.transforms.len(), 6);
687    }
688
689    #[test]
690    fn test_heavy_augmentation_preset() {
691        let pipeline = AugmentationPipeline::heavy_augmentation();
692        assert_eq!(pipeline.transforms.len(), 8);
693    }
694
695    #[test]
696    fn test_augmentation_transform_shape_preserved() {
697        let tensor = mock_tensor();
698        let brightness = RandomBrightness::symmetric(0.1);
699        let result = brightness.transform(tensor.clone()).unwrap();
700
701        // Transforms operate on shape-preserving tensors
702        assert_eq!(result.shape(), tensor.shape());
703    }
704
705    #[test]
706    fn test_random_brightness_changes_tensor() {
707        // factor range that never includes 1.0, so output always differs from input (all-1.0 tensor)
708        let brightness = RandomBrightness::new((0.5, 0.7));
709        let tensor = Tensor::from_data(vec![1.0f32; 4], vec![2, 2], DeviceType::Cpu).unwrap();
710        let result = brightness.transform(tensor).unwrap();
711        let result_data = result.to_vec().unwrap();
712        // Should be between 0.5 and 0.7 (darkened from 1.0)
713        assert!(
714            result_data.iter().all(|&x| x < 1.0),
715            "Brightness transform must darken the tensor (factor in [0.5, 0.7])"
716        );
717    }
718
719    #[test]
720    fn test_gaussian_noise_changes_tensor() {
721        let noise = GaussianNoise::with_std(0.5);
722        // Run multiple times to be nearly certain noise is non-zero
723        let mut changed = false;
724        for _ in 0..10 {
725            let tensor = Tensor::from_data(vec![0.5f32; 16], vec![4, 4], DeviceType::Cpu).unwrap();
726            let result = noise.transform(tensor).unwrap();
727            let data = result.to_vec().unwrap();
728            if data.iter().any(|&x| (x - 0.5f32).abs() > 1e-6) {
729                changed = true;
730                break;
731            }
732        }
733        assert!(changed, "GaussianNoise must change tensor values");
734    }
735
736    #[test]
737    fn test_random_vertical_flip_changes_tensor() {
738        // Always flip (prob=1.0)
739        let flip = RandomVerticalFlip::new(1.0);
740        // HW tensor: [[1,2],[3,4]] -> after vertical flip -> [[3,4],[1,2]]
741        let tensor =
742            Tensor::from_data(vec![1.0f32, 2.0, 3.0, 4.0], vec![2, 2], DeviceType::Cpu).unwrap();
743        let result = flip.transform(tensor).unwrap();
744        let data = result.to_vec().unwrap();
745        // After vertical flip: row 0 becomes [3,4], row 1 becomes [1,2]
746        assert!(
747            (data[0] - 3.0).abs() < 1e-6 && (data[1] - 4.0).abs() < 1e-6,
748            "Vertical flip must reverse rows: got {:?}",
749            data
750        );
751    }
752
753    #[test]
754    fn test_random_erasing_changes_tensor() {
755        // Always erase (prob=1.0), large erase area
756        let erasing = RandomErasing::new(1.0, (0.5, 0.9), (1.0, 1.0));
757        // Large tensor of all 1.0s; after erasing, some should be 0.0 (fill_value)
758        let tensor = Tensor::from_data(vec![1.0f32; 100], vec![10, 10], DeviceType::Cpu).unwrap();
759        let result = erasing.transform(tensor).unwrap();
760        let data = result.to_vec().unwrap();
761        assert!(
762            data.iter().any(|&x| x == 0.0f32),
763            "RandomErasing must fill some values with fill_value (0.0)"
764        );
765    }
766}