Skip to main content

oximedia_transcode/
crop_scale.rs

1//! Crop and scale pipeline for video transcoding.
2//!
3//! Provides aspect ratio preservation, pillarbox/letterbox padding,
4//! and smart crop detection for video scaling operations.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9/// Describes a rectangular region in a frame.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct Rect {
12    /// X offset from the left edge.
13    pub x: u32,
14    /// Y offset from the top edge.
15    pub y: u32,
16    /// Width in pixels.
17    pub width: u32,
18    /// Height in pixels.
19    pub height: u32,
20}
21
22impl Rect {
23    /// Creates a new rectangle.
24    #[must_use]
25    pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
26        Self {
27            x,
28            y,
29            width,
30            height,
31        }
32    }
33
34    /// Returns the aspect ratio as a float.
35    #[must_use]
36    pub fn aspect_ratio(&self) -> f64 {
37        f64::from(self.width) / f64::from(self.height)
38    }
39
40    /// Returns the area in pixels.
41    #[must_use]
42    pub fn area(&self) -> u64 {
43        u64::from(self.width) * u64::from(self.height)
44    }
45}
46
47/// How to handle aspect ratio mismatches during scaling.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum AspectMode {
50    /// Add black bars to preserve the source aspect ratio (letterbox or pillarbox).
51    Pad,
52    /// Crop the source to fill the target resolution.
53    Crop,
54    /// Stretch to fill ignoring aspect ratio.
55    Stretch,
56    /// Scale to fit entirely within the target (may leave empty space).
57    Fit,
58}
59
60/// Where to place the image when padding.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum PadAlignment {
63    /// Center the image (default).
64    Center,
65    /// Align to the top-left.
66    TopLeft,
67    /// Align to the bottom-right.
68    BottomRight,
69}
70
71/// The type of padding being applied.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum PadType {
74    /// Horizontal bars (letterbox) — source is wider than target.
75    Letterbox,
76    /// Vertical bars (pillarbox) — source is taller than target.
77    Pillarbox,
78    /// No padding required.
79    None,
80}
81
82/// Configuration for a crop-and-scale operation.
83#[derive(Debug, Clone)]
84pub struct CropScaleConfig {
85    /// Source resolution.
86    pub source_width: u32,
87    /// Source height in pixels.
88    pub source_height: u32,
89    /// Target width in pixels.
90    pub target_width: u32,
91    /// Target height in pixels.
92    pub target_height: u32,
93    /// Aspect ratio handling mode.
94    pub aspect_mode: AspectMode,
95    /// Padding alignment when using `AspectMode::Pad`.
96    pub pad_alignment: PadAlignment,
97    /// Padding color as (R, G, B).
98    pub pad_color: (u8, u8, u8),
99    /// Optional manual crop region applied before scaling.
100    pub manual_crop: Option<Rect>,
101}
102
103impl CropScaleConfig {
104    /// Creates a new crop/scale configuration.
105    #[must_use]
106    pub fn new(
107        source_width: u32,
108        source_height: u32,
109        target_width: u32,
110        target_height: u32,
111    ) -> Self {
112        Self {
113            source_width,
114            source_height,
115            target_width,
116            target_height,
117            aspect_mode: AspectMode::Pad,
118            pad_alignment: PadAlignment::Center,
119            pad_color: (0, 0, 0),
120            manual_crop: None,
121        }
122    }
123
124    /// Sets the aspect ratio handling mode.
125    #[must_use]
126    pub fn with_aspect_mode(mut self, mode: AspectMode) -> Self {
127        self.aspect_mode = mode;
128        self
129    }
130
131    /// Sets the padding alignment.
132    #[must_use]
133    pub fn with_pad_alignment(mut self, alignment: PadAlignment) -> Self {
134        self.pad_alignment = alignment;
135        self
136    }
137
138    /// Sets the padding color (used for letterbox/pillarbox bars).
139    #[must_use]
140    pub fn with_pad_color(mut self, r: u8, g: u8, b: u8) -> Self {
141        self.pad_color = (r, g, b);
142        self
143    }
144
145    /// Sets a manual crop region applied before scaling.
146    #[must_use]
147    pub fn with_manual_crop(mut self, crop: Rect) -> Self {
148        self.manual_crop = Some(crop);
149        self
150    }
151
152    /// Returns the source aspect ratio.
153    #[must_use]
154    pub fn source_aspect(&self) -> f64 {
155        f64::from(self.source_width) / f64::from(self.source_height)
156    }
157
158    /// Returns the target aspect ratio.
159    #[must_use]
160    pub fn target_aspect(&self) -> f64 {
161        f64::from(self.target_width) / f64::from(self.target_height)
162    }
163
164    /// Determines the type of padding needed.
165    #[must_use]
166    pub fn pad_type(&self) -> PadType {
167        let sa = self.source_aspect();
168        let ta = self.target_aspect();
169        if (sa - ta).abs() < 1e-4 {
170            PadType::None
171        } else if sa > ta {
172            // Source is wider than target: add horizontal bars
173            PadType::Letterbox
174        } else {
175            // Source is taller (narrower) than target: add vertical bars
176            PadType::Pillarbox
177        }
178    }
179
180    /// Computes the scaled region (before padding) that fits within the target.
181    ///
182    /// Returns `(scaled_width, scaled_height)`.
183    #[must_use]
184    pub fn compute_scaled_size(&self) -> (u32, u32) {
185        let source_w = f64::from(self.source_width);
186        let source_h = f64::from(self.source_height);
187        let target_w = f64::from(self.target_width);
188        let target_h = f64::from(self.target_height);
189
190        match self.aspect_mode {
191            AspectMode::Stretch => (self.target_width, self.target_height),
192            AspectMode::Pad | AspectMode::Fit => {
193                let scale = (target_w / source_w).min(target_h / source_h);
194                let w = (source_w * scale).round() as u32;
195                let h = (source_h * scale).round() as u32;
196                (w, h)
197            }
198            AspectMode::Crop => {
199                let scale = (target_w / source_w).max(target_h / source_h);
200                let w = (source_w * scale).round() as u32;
201                let h = (source_h * scale).round() as u32;
202                (w, h)
203            }
204        }
205    }
206
207    /// Computes the padding offsets when using `AspectMode::Pad`.
208    ///
209    /// Returns `(x_offset, y_offset)` for the image within the padded frame.
210    #[must_use]
211    pub fn compute_pad_offsets(&self) -> (u32, u32) {
212        let (sw, sh) = self.compute_scaled_size();
213        match self.pad_alignment {
214            PadAlignment::Center => {
215                let x = (self.target_width.saturating_sub(sw)) / 2;
216                let y = (self.target_height.saturating_sub(sh)) / 2;
217                (x, y)
218            }
219            PadAlignment::TopLeft => (0, 0),
220            PadAlignment::BottomRight => {
221                let x = self.target_width.saturating_sub(sw);
222                let y = self.target_height.saturating_sub(sh);
223                (x, y)
224            }
225        }
226    }
227
228    /// Computes the crop rect when using `AspectMode::Crop`.
229    #[must_use]
230    pub fn compute_crop_rect(&self) -> Rect {
231        let (sw, sh) = self.compute_scaled_size();
232        let x = (sw.saturating_sub(self.target_width)) / 2;
233        let y = (sh.saturating_sub(self.target_height)) / 2;
234        Rect::new(x, y, self.target_width, self.target_height)
235    }
236}
237
238/// Smart crop detector that finds areas of interest in a frame.
239#[derive(Debug, Clone)]
240pub struct SmartCropDetector {
241    /// Minimum saliency threshold (0.0–1.0).
242    pub saliency_threshold: f32,
243    /// Weight given to face regions.
244    pub face_weight: f32,
245    /// Weight given to motion regions.
246    pub motion_weight: f32,
247}
248
249impl SmartCropDetector {
250    /// Creates a new smart crop detector with default settings.
251    #[must_use]
252    pub fn new() -> Self {
253        Self {
254            saliency_threshold: 0.5,
255            face_weight: 2.0,
256            motion_weight: 1.5,
257        }
258    }
259
260    /// Sets the saliency threshold.
261    #[must_use]
262    pub fn with_saliency_threshold(mut self, threshold: f32) -> Self {
263        self.saliency_threshold = threshold;
264        self
265    }
266
267    /// Computes a mock crop region centered on the "region of interest".
268    ///
269    /// In a real implementation this would analyze frame content.
270    #[must_use]
271    pub fn compute_crop(
272        &self,
273        frame_width: u32,
274        frame_height: u32,
275        target_width: u32,
276        target_height: u32,
277    ) -> Rect {
278        // Default: center crop
279        let x = (frame_width.saturating_sub(target_width)) / 2;
280        let y = (frame_height.saturating_sub(target_height)) / 2;
281        let w = target_width.min(frame_width);
282        let h = target_height.min(frame_height);
283        Rect::new(x, y, w, h)
284    }
285}
286
287impl Default for SmartCropDetector {
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_rect_aspect_ratio() {
299        let r = Rect::new(0, 0, 1920, 1080);
300        assert!((r.aspect_ratio() - 16.0 / 9.0).abs() < 1e-6);
301    }
302
303    #[test]
304    fn test_rect_area() {
305        let r = Rect::new(0, 0, 1920, 1080);
306        assert_eq!(r.area(), 1920 * 1080);
307    }
308
309    #[test]
310    fn test_pad_type_none_when_same_aspect() {
311        let cfg = CropScaleConfig::new(1920, 1080, 1280, 720);
312        assert_eq!(cfg.pad_type(), PadType::None);
313    }
314
315    #[test]
316    fn test_pad_type_letterbox() {
317        // 16:9 source into 4:3 target → letterbox (horizontal bars)
318        let cfg = CropScaleConfig::new(1920, 1080, 1024, 768);
319        assert_eq!(cfg.pad_type(), PadType::Letterbox);
320    }
321
322    #[test]
323    fn test_pad_type_pillarbox() {
324        // 4:3 source into 16:9 target → pillarbox (vertical bars)
325        let cfg = CropScaleConfig::new(1024, 768, 1920, 1080);
326        assert_eq!(cfg.pad_type(), PadType::Pillarbox);
327    }
328
329    #[test]
330    fn test_compute_scaled_size_pad() {
331        let cfg = CropScaleConfig::new(1920, 1080, 1280, 720).with_aspect_mode(AspectMode::Pad);
332        let (w, h) = cfg.compute_scaled_size();
333        assert_eq!(w, 1280);
334        assert_eq!(h, 720);
335    }
336
337    #[test]
338    fn test_compute_scaled_size_stretch() {
339        let cfg = CropScaleConfig::new(1920, 1080, 800, 600).with_aspect_mode(AspectMode::Stretch);
340        let (w, h) = cfg.compute_scaled_size();
341        assert_eq!(w, 800);
342        assert_eq!(h, 600);
343    }
344
345    #[test]
346    fn test_compute_pad_offsets_center() {
347        // 4:3 into 16:9 => pillarbox, centered
348        let cfg = CropScaleConfig::new(1024, 768, 1920, 1080)
349            .with_aspect_mode(AspectMode::Pad)
350            .with_pad_alignment(PadAlignment::Center);
351        let (x, y) = cfg.compute_pad_offsets();
352        // Scaled: 1080/768 * 1024 = 1440 wide, 1080 tall
353        assert_eq!(y, 0); // full height used
354        assert!(x > 0); // some horizontal padding
355    }
356
357    #[test]
358    fn test_compute_pad_offsets_topleft() {
359        let cfg = CropScaleConfig::new(1024, 768, 1920, 1080)
360            .with_aspect_mode(AspectMode::Pad)
361            .with_pad_alignment(PadAlignment::TopLeft);
362        let (x, y) = cfg.compute_pad_offsets();
363        assert_eq!(x, 0);
364        assert_eq!(y, 0);
365    }
366
367    #[test]
368    fn test_compute_crop_rect() {
369        let cfg = CropScaleConfig::new(1920, 1080, 1280, 720).with_aspect_mode(AspectMode::Crop);
370        let rect = cfg.compute_crop_rect();
371        assert_eq!(rect.width, 1280);
372        assert_eq!(rect.height, 720);
373    }
374
375    #[test]
376    fn test_pad_color() {
377        let cfg = CropScaleConfig::new(1920, 1080, 1280, 720).with_pad_color(255, 255, 255);
378        assert_eq!(cfg.pad_color, (255, 255, 255));
379    }
380
381    #[test]
382    fn test_manual_crop() {
383        let crop = Rect::new(100, 50, 1720, 980);
384        let cfg = CropScaleConfig::new(1920, 1080, 1280, 720).with_manual_crop(crop);
385        assert!(cfg.manual_crop.is_some());
386        assert_eq!(cfg.manual_crop.expect("should succeed in test").x, 100);
387    }
388
389    #[test]
390    fn test_smart_crop_detector_default() {
391        let det = SmartCropDetector::new();
392        assert!((det.saliency_threshold - 0.5).abs() < 1e-6);
393    }
394
395    #[test]
396    fn test_smart_crop_computes_center_crop() {
397        let det = SmartCropDetector::new();
398        let rect = det.compute_crop(1920, 1080, 1280, 720);
399        assert_eq!(rect.width, 1280);
400        assert_eq!(rect.height, 720);
401        assert_eq!(rect.x, (1920 - 1280) / 2);
402        assert_eq!(rect.y, (1080 - 720) / 2);
403    }
404}