Skip to main content

oximedia_graph/filters/video/
crop.rs

1//! Video cropping filter.
2//!
3//! This filter crops video frames to a specified region, removing pixels
4//! outside the crop area.
5
6#![forbid(unsafe_code)]
7#![allow(clippy::cast_lossless)]
8#![allow(clippy::cast_precision_loss)]
9#![allow(clippy::cast_possible_truncation)]
10#![allow(clippy::cast_sign_loss)]
11#![allow(clippy::cast_possible_wrap)]
12#![allow(clippy::similar_names)]
13#![allow(clippy::many_single_char_names)]
14#![allow(clippy::missing_errors_doc)]
15#![allow(clippy::match_same_arms)]
16#![allow(clippy::doc_markdown)]
17#![allow(clippy::unused_self)]
18#![allow(clippy::unnecessary_cast)]
19#![allow(clippy::bool_to_int_with_if)]
20#![allow(clippy::needless_range_loop)]
21#![allow(clippy::too_many_lines)]
22#![allow(clippy::unnecessary_wraps)]
23#![allow(clippy::map_unwrap_or)]
24#![allow(clippy::no_effect_underscore_binding)]
25#![allow(clippy::unreadable_literal)]
26#![allow(dead_code)]
27
28use crate::error::{GraphError, GraphResult};
29use crate::frame::FilterFrame;
30use crate::node::{Node, NodeId, NodeState, NodeType};
31use crate::port::{InputPort, OutputPort, PortFormat, PortId, PortType, VideoPortFormat};
32use oximedia_codec::{Plane, VideoFrame};
33
34/// Configuration for the crop filter.
35#[derive(Clone, Debug)]
36pub struct CropConfig {
37    /// Left offset (pixels from left edge).
38    pub left: u32,
39    /// Top offset (pixels from top edge).
40    pub top: u32,
41    /// Crop width.
42    pub width: u32,
43    /// Crop height.
44    pub height: u32,
45    /// Automatically center the crop region.
46    pub auto_center: bool,
47    /// Preserve aspect ratio when cropping.
48    pub preserve_aspect: bool,
49    /// Target aspect ratio (width/height) when preserve_aspect is true.
50    pub target_aspect: Option<f64>,
51}
52
53impl CropConfig {
54    /// Create a new crop configuration.
55    #[must_use]
56    pub fn new(left: u32, top: u32, width: u32, height: u32) -> Self {
57        Self {
58            left,
59            top,
60            width,
61            height,
62            auto_center: false,
63            preserve_aspect: false,
64            target_aspect: None,
65        }
66    }
67
68    /// Create a centered crop configuration.
69    #[must_use]
70    pub fn centered(width: u32, height: u32) -> Self {
71        Self {
72            left: 0,
73            top: 0,
74            width,
75            height,
76            auto_center: true,
77            preserve_aspect: false,
78            target_aspect: None,
79        }
80    }
81
82    /// Create a crop configuration that preserves aspect ratio.
83    #[must_use]
84    pub fn with_aspect_ratio(width: u32, height: u32, aspect: f64) -> Self {
85        Self {
86            left: 0,
87            top: 0,
88            width,
89            height,
90            auto_center: true,
91            preserve_aspect: true,
92            target_aspect: Some(aspect),
93        }
94    }
95
96    /// Enable auto-centering.
97    #[must_use]
98    pub fn with_auto_center(mut self, enabled: bool) -> Self {
99        self.auto_center = enabled;
100        self
101    }
102
103    /// Set target aspect ratio for preservation.
104    #[must_use]
105    pub fn with_target_aspect(mut self, aspect: f64) -> Self {
106        self.preserve_aspect = true;
107        self.target_aspect = Some(aspect);
108        self
109    }
110
111    /// Validate the crop configuration against source dimensions.
112    pub fn validate(&self, src_width: u32, src_height: u32) -> GraphResult<()> {
113        if self.width == 0 || self.height == 0 {
114            return Err(GraphError::ConfigurationError(
115                "Crop dimensions cannot be zero".to_string(),
116            ));
117        }
118
119        if !self.auto_center {
120            if self.left + self.width > src_width {
121                return Err(GraphError::ConfigurationError(format!(
122                    "Crop region exceeds source width: {} + {} > {}",
123                    self.left, self.width, src_width
124                )));
125            }
126
127            if self.top + self.height > src_height {
128                return Err(GraphError::ConfigurationError(format!(
129                    "Crop region exceeds source height: {} + {} > {}",
130                    self.top, self.height, src_height
131                )));
132            }
133        }
134
135        Ok(())
136    }
137
138    /// Calculate the actual crop region for given source dimensions.
139    #[must_use]
140    pub fn calculate_region(&self, src_width: u32, src_height: u32) -> CropRegion {
141        let (width, height) = if self.preserve_aspect {
142            if let Some(target_aspect) = self.target_aspect {
143                calculate_aspect_crop(src_width, src_height, target_aspect)
144            } else {
145                (self.width.min(src_width), self.height.min(src_height))
146            }
147        } else {
148            (self.width.min(src_width), self.height.min(src_height))
149        };
150
151        let (left, top) = if self.auto_center {
152            let left = (src_width.saturating_sub(width)) / 2;
153            let top = (src_height.saturating_sub(height)) / 2;
154            (left, top)
155        } else {
156            (
157                self.left.min(src_width.saturating_sub(width)),
158                self.top.min(src_height.saturating_sub(height)),
159            )
160        };
161
162        CropRegion {
163            left,
164            top,
165            width,
166            height,
167        }
168    }
169}
170
171/// Calculated crop region.
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub struct CropRegion {
174    /// Left offset.
175    pub left: u32,
176    /// Top offset.
177    pub top: u32,
178    /// Crop width.
179    pub width: u32,
180    /// Crop height.
181    pub height: u32,
182}
183
184impl CropRegion {
185    /// Get the right edge position.
186    #[must_use]
187    pub fn right(&self) -> u32 {
188        self.left + self.width
189    }
190
191    /// Get the bottom edge position.
192    #[must_use]
193    pub fn bottom(&self) -> u32 {
194        self.top + self.height
195    }
196
197    /// Check if a point is within the crop region.
198    #[must_use]
199    pub fn contains(&self, x: u32, y: u32) -> bool {
200        x >= self.left && x < self.right() && y >= self.top && y < self.bottom()
201    }
202
203    /// Scale the region for chroma planes.
204    #[must_use]
205    pub fn scale_for_chroma(&self, h_ratio: u32, v_ratio: u32) -> Self {
206        Self {
207            left: self.left / h_ratio,
208            top: self.top / v_ratio,
209            width: self.width / h_ratio,
210            height: self.height / v_ratio,
211        }
212    }
213}
214
215/// Calculate crop dimensions to achieve target aspect ratio.
216fn calculate_aspect_crop(src_width: u32, src_height: u32, target_aspect: f64) -> (u32, u32) {
217    let src_aspect = src_width as f64 / src_height as f64;
218
219    if src_aspect > target_aspect {
220        // Source is wider, crop width
221        let new_width = (src_height as f64 * target_aspect).round() as u32;
222        (new_width, src_height)
223    } else {
224        // Source is taller, crop height
225        let new_height = (src_width as f64 / target_aspect).round() as u32;
226        (src_width, new_height)
227    }
228}
229
230/// Video cropping filter.
231///
232/// Crops video frames to a specified region, with support for automatic
233/// centering and aspect ratio preservation.
234///
235/// # Example
236///
237/// ```ignore
238/// use oximedia_graph::filters::video::{CropFilter, CropConfig};
239/// use oximedia_graph::node::NodeId;
240///
241/// // Create a centered crop
242/// let config = CropConfig::centered(1280, 720);
243/// let filter = CropFilter::new(NodeId(0), "crop", config);
244///
245/// // Create a crop for specific aspect ratio (16:9)
246/// let config = CropConfig::with_aspect_ratio(0, 0, 16.0 / 9.0);
247/// let filter = CropFilter::new(NodeId(1), "aspect_crop", config);
248/// ```
249pub struct CropFilter {
250    id: NodeId,
251    name: String,
252    state: NodeState,
253    inputs: Vec<InputPort>,
254    outputs: Vec<OutputPort>,
255    config: CropConfig,
256}
257
258impl CropFilter {
259    /// Create a new crop filter.
260    #[must_use]
261    pub fn new(id: NodeId, name: impl Into<String>, config: CropConfig) -> Self {
262        let output_format =
263            PortFormat::Video(VideoPortFormat::any().with_dimensions(config.width, config.height));
264
265        Self {
266            id,
267            name: name.into(),
268            state: NodeState::Idle,
269            inputs: vec![InputPort::new(PortId(0), "input", PortType::Video)
270                .with_format(PortFormat::Video(VideoPortFormat::any()))],
271            outputs: vec![
272                OutputPort::new(PortId(0), "output", PortType::Video).with_format(output_format)
273            ],
274            config,
275        }
276    }
277
278    /// Get the current configuration.
279    #[must_use]
280    pub fn config(&self) -> &CropConfig {
281        &self.config
282    }
283
284    /// Update the crop configuration.
285    pub fn set_config(&mut self, config: CropConfig) {
286        self.config = config;
287    }
288
289    /// Crop a single plane.
290    fn crop_plane(&self, src: &Plane, _src_width: u32, region: &CropRegion) -> Plane {
291        let mut dst_data = vec![0u8; region.width as usize * region.height as usize];
292
293        for y in 0..region.height as usize {
294            let src_y = region.top as usize + y;
295            let src_row = src.row(src_y);
296            let dst_start = y * region.width as usize;
297
298            for x in 0..region.width as usize {
299                let src_x = region.left as usize + x;
300                dst_data[dst_start + x] = src_row.get(src_x).copied().unwrap_or(0);
301            }
302        }
303
304        Plane::new(dst_data, region.width as usize)
305    }
306
307    /// Crop a video frame.
308    fn crop_frame(&self, input: &VideoFrame) -> GraphResult<VideoFrame> {
309        let region = self.config.calculate_region(input.width, input.height);
310
311        // Ensure crop region is within bounds
312        if region.right() > input.width || region.bottom() > input.height {
313            return Err(GraphError::ConfigurationError(
314                "Crop region exceeds frame dimensions".to_string(),
315            ));
316        }
317
318        let mut output = VideoFrame::new(input.format, region.width, region.height);
319        output.timestamp = input.timestamp;
320        output.frame_type = input.frame_type;
321        output.color_info = input.color_info;
322
323        for (i, src_plane) in input.planes.iter().enumerate() {
324            let (src_w, _src_h) = input.plane_dimensions(i);
325
326            let plane_region = if i > 0 && input.format.is_yuv() {
327                let (h_ratio, v_ratio) = input.format.chroma_subsampling();
328                region.scale_for_chroma(h_ratio, v_ratio)
329            } else {
330                region
331            };
332
333            let plane = self.crop_plane(src_plane, src_w, &plane_region);
334            output.planes.push(plane);
335        }
336
337        Ok(output)
338    }
339}
340
341impl Node for CropFilter {
342    fn id(&self) -> NodeId {
343        self.id
344    }
345
346    fn name(&self) -> &str {
347        &self.name
348    }
349
350    fn node_type(&self) -> NodeType {
351        NodeType::Filter
352    }
353
354    fn state(&self) -> NodeState {
355        self.state
356    }
357
358    fn set_state(&mut self, state: NodeState) -> GraphResult<()> {
359        if !self.state.can_transition_to(state) {
360            return Err(GraphError::InvalidStateTransition {
361                node: self.id,
362                from: self.state.to_string(),
363                to: state.to_string(),
364            });
365        }
366        self.state = state;
367        Ok(())
368    }
369
370    fn inputs(&self) -> &[InputPort] {
371        &self.inputs
372    }
373
374    fn outputs(&self) -> &[OutputPort] {
375        &self.outputs
376    }
377
378    fn process(&mut self, input: Option<FilterFrame>) -> GraphResult<Option<FilterFrame>> {
379        match input {
380            Some(FilterFrame::Video(frame)) => {
381                let cropped = self.crop_frame(&frame)?;
382                Ok(Some(FilterFrame::Video(cropped)))
383            }
384            Some(_) => Err(GraphError::PortTypeMismatch {
385                expected: "Video".to_string(),
386                actual: "Audio".to_string(),
387            }),
388            None => Ok(None),
389        }
390    }
391}
392
393/// Detect black borders in a frame for automatic cropping.
394#[derive(Debug)]
395pub struct BorderDetector {
396    /// Threshold for considering a pixel as black.
397    pub threshold: u8,
398    /// Minimum number of consecutive black rows/columns to consider a border.
399    pub min_border_size: u32,
400}
401
402impl Default for BorderDetector {
403    fn default() -> Self {
404        Self {
405            threshold: 16,
406            min_border_size: 4,
407        }
408    }
409}
410
411impl BorderDetector {
412    /// Create a new border detector.
413    #[must_use]
414    pub fn new(threshold: u8, min_border_size: u32) -> Self {
415        Self {
416            threshold,
417            min_border_size,
418        }
419    }
420
421    /// Detect borders in a frame and return the crop region.
422    #[must_use]
423    pub fn detect(&self, frame: &VideoFrame) -> CropRegion {
424        if frame.planes.is_empty() {
425            return CropRegion {
426                left: 0,
427                top: 0,
428                width: frame.width,
429                height: frame.height,
430            };
431        }
432
433        let luma = &frame.planes[0];
434
435        // Detect top border
436        let mut top = 0u32;
437        for y in 0..frame.height {
438            if !self.is_row_black(luma, y, frame.width) {
439                top = y;
440                break;
441            }
442        }
443
444        // Detect bottom border
445        let mut bottom = frame.height;
446        for y in (0..frame.height).rev() {
447            if !self.is_row_black(luma, y, frame.width) {
448                bottom = y + 1;
449                break;
450            }
451        }
452
453        // Detect left border
454        let mut left = 0u32;
455        for x in 0..frame.width {
456            if !self.is_column_black(luma, x, frame.height) {
457                left = x;
458                break;
459            }
460        }
461
462        // Detect right border
463        let mut right = frame.width;
464        for x in (0..frame.width).rev() {
465            if !self.is_column_black(luma, x, frame.height) {
466                right = x + 1;
467                break;
468            }
469        }
470
471        // Apply minimum border size filter
472        if top < self.min_border_size {
473            top = 0;
474        }
475        if (frame.height - bottom) < self.min_border_size {
476            bottom = frame.height;
477        }
478        if left < self.min_border_size {
479            left = 0;
480        }
481        if (frame.width - right) < self.min_border_size {
482            right = frame.width;
483        }
484
485        CropRegion {
486            left,
487            top,
488            width: right.saturating_sub(left),
489            height: bottom.saturating_sub(top),
490        }
491    }
492
493    /// Check if a row is entirely black.
494    fn is_row_black(&self, plane: &Plane, y: u32, width: u32) -> bool {
495        let row = plane.row(y as usize);
496        for x in 0..width as usize {
497            if row.get(x).copied().unwrap_or(0) > self.threshold {
498                return false;
499            }
500        }
501        true
502    }
503
504    /// Check if a column is entirely black.
505    fn is_column_black(&self, plane: &Plane, x: u32, height: u32) -> bool {
506        for y in 0..height {
507            let row = plane.row(y as usize);
508            if row.get(x as usize).copied().unwrap_or(0) > self.threshold {
509                return false;
510            }
511        }
512        true
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use oximedia_core::PixelFormat;
520
521    fn create_test_frame(width: u32, height: u32) -> VideoFrame {
522        let mut frame = VideoFrame::new(PixelFormat::Yuv420p, width, height);
523        frame.allocate();
524
525        // Fill Y plane with a gradient
526        if let Some(plane) = frame.planes.get_mut(0) {
527            let mut data = vec![0u8; width as usize * height as usize];
528            for y in 0..height as usize {
529                for x in 0..width as usize {
530                    data[y * width as usize + x] = ((x + y) % 256) as u8;
531                }
532            }
533            *plane = Plane::new(data, width as usize);
534        }
535
536        frame
537    }
538
539    #[test]
540    fn test_crop_config_creation() {
541        let config = CropConfig::new(10, 20, 100, 80);
542        assert_eq!(config.left, 10);
543        assert_eq!(config.top, 20);
544        assert_eq!(config.width, 100);
545        assert_eq!(config.height, 80);
546        assert!(!config.auto_center);
547    }
548
549    #[test]
550    fn test_crop_config_centered() {
551        let config = CropConfig::centered(640, 480);
552        assert!(config.auto_center);
553        assert_eq!(config.width, 640);
554        assert_eq!(config.height, 480);
555    }
556
557    #[test]
558    fn test_crop_config_with_aspect() {
559        let config = CropConfig::with_aspect_ratio(0, 0, 16.0 / 9.0);
560        assert!(config.preserve_aspect);
561        assert!(config.target_aspect.is_some());
562    }
563
564    #[test]
565    fn test_crop_region_calculation() {
566        let config = CropConfig::centered(320, 240);
567        let region = config.calculate_region(640, 480);
568
569        assert_eq!(region.left, 160);
570        assert_eq!(region.top, 120);
571        assert_eq!(region.width, 320);
572        assert_eq!(region.height, 240);
573    }
574
575    #[test]
576    fn test_crop_region_contains() {
577        let region = CropRegion {
578            left: 10,
579            top: 20,
580            width: 100,
581            height: 80,
582        };
583
584        assert!(region.contains(10, 20));
585        assert!(region.contains(50, 50));
586        assert!(region.contains(109, 99));
587        assert!(!region.contains(9, 20));
588        assert!(!region.contains(110, 50));
589    }
590
591    #[test]
592    fn test_crop_region_scale_for_chroma() {
593        let region = CropRegion {
594            left: 100,
595            top: 200,
596            width: 400,
597            height: 300,
598        };
599
600        let scaled = region.scale_for_chroma(2, 2);
601        assert_eq!(scaled.left, 50);
602        assert_eq!(scaled.top, 100);
603        assert_eq!(scaled.width, 200);
604        assert_eq!(scaled.height, 150);
605    }
606
607    #[test]
608    fn test_crop_filter_creation() {
609        let config = CropConfig::new(0, 0, 640, 480);
610        let filter = CropFilter::new(NodeId(0), "crop", config);
611
612        assert_eq!(filter.id(), NodeId(0));
613        assert_eq!(filter.name(), "crop");
614        assert_eq!(filter.node_type(), NodeType::Filter);
615    }
616
617    #[test]
618    fn test_crop_filter_process() {
619        let config = CropConfig::centered(320, 240);
620        let mut filter = CropFilter::new(NodeId(0), "crop", config);
621
622        let input = create_test_frame(640, 480);
623        let result = filter
624            .process(Some(FilterFrame::Video(input)))
625            .expect("operation should succeed")
626            .expect("operation should succeed");
627
628        if let FilterFrame::Video(frame) = result {
629            assert_eq!(frame.width, 320);
630            assert_eq!(frame.height, 240);
631        } else {
632            panic!("Expected video frame");
633        }
634    }
635
636    #[test]
637    fn test_crop_config_validation() {
638        let config = CropConfig::new(0, 0, 0, 100);
639        assert!(config.validate(640, 480).is_err());
640
641        let config = CropConfig::new(600, 0, 100, 100);
642        assert!(config.validate(640, 480).is_err());
643
644        let config = CropConfig::new(0, 0, 320, 240);
645        assert!(config.validate(640, 480).is_ok());
646    }
647
648    #[test]
649    fn test_aspect_crop_calculation() {
650        // 4:3 source to 16:9 target (wider)
651        let (w, h) = calculate_aspect_crop(640, 480, 16.0 / 9.0);
652        let result_aspect = w as f64 / h as f64;
653        assert!((result_aspect - 16.0 / 9.0).abs() < 0.01);
654
655        // 16:9 source to 4:3 target (taller)
656        let (w, h) = calculate_aspect_crop(1920, 1080, 4.0 / 3.0);
657        let result_aspect = w as f64 / h as f64;
658        assert!((result_aspect - 4.0 / 3.0).abs() < 0.01);
659    }
660
661    #[test]
662    fn test_border_detector_default() {
663        let detector = BorderDetector::default();
664        assert_eq!(detector.threshold, 16);
665        assert_eq!(detector.min_border_size, 4);
666    }
667
668    #[test]
669    fn test_border_detector_on_test_frame() {
670        let detector = BorderDetector::new(16, 1);
671        let frame = create_test_frame(640, 480);
672
673        let region = detector.detect(&frame);
674        // Test frame has gradient, so no black borders
675        assert!(region.width > 0);
676        assert!(region.height > 0);
677    }
678
679    #[test]
680    fn test_node_state_transitions() {
681        let config = CropConfig::centered(320, 240);
682        let mut filter = CropFilter::new(NodeId(0), "crop", config);
683
684        assert_eq!(filter.state(), NodeState::Idle);
685        filter
686            .set_state(NodeState::Processing)
687            .expect("set_state should succeed");
688        assert_eq!(filter.state(), NodeState::Processing);
689    }
690
691    #[test]
692    fn test_process_none_input() {
693        let config = CropConfig::centered(320, 240);
694        let mut filter = CropFilter::new(NodeId(0), "crop", config);
695
696        let result = filter.process(None).expect("process should succeed");
697        assert!(result.is_none());
698    }
699}