Skip to main content

ultralytics_inference/
source.rs

1// Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
2
3//! Input source handling for YOLO inference.
4//!
5//! This module provides abstractions for various input sources including
6//! images, videos, webcams, and streaming URLs.
7
8use std::path::{Path, PathBuf};
9
10use image::DynamicImage;
11use ndarray::Array3;
12
13use crate::error::{InferenceError, Result};
14
15/// Represents different input sources for inference.
16#[derive(Debug, Clone)]
17pub enum Source {
18    /// Path to an image file.
19    Image(PathBuf),
20    /// In-memory image.
21    ImageBuffer(DynamicImage),
22    /// Raw HWC u8 array.
23    Array(Array3<u8>),
24    /// HTTP/HTTPS URL to an image file.
25    ImageUrl(String),
26    /// List of image paths.
27    ImageList(Vec<PathBuf>),
28    /// Path to a video file.
29    Video(PathBuf),
30    /// Webcam device index.
31    Webcam(u32),
32    /// Streaming URL (RTSP, RTMP, HTTP).
33    Stream(String),
34    /// Directory containing images.
35    Directory(PathBuf),
36    /// Glob pattern for images.
37    Glob(String),
38}
39
40impl Source {
41    /// Check if this source is a single image.
42    ///
43    /// # Returns
44    ///
45    /// * `true` if the source is an image type (file, buffer, array, URL).
46    #[must_use]
47    pub const fn is_image(&self) -> bool {
48        matches!(
49            self,
50            Self::Image(_) | Self::ImageBuffer(_) | Self::Array(_) | Self::ImageUrl(_)
51        )
52    }
53
54    /// Check if this source is a video or stream.
55    ///
56    /// # Returns
57    ///
58    /// * `true` if the source is a video type (file, webcam, stream).
59    #[must_use]
60    pub const fn is_video(&self) -> bool {
61        matches!(self, Self::Video(_) | Self::Webcam(_) | Self::Stream(_))
62    }
63
64    /// Check if this source is a directory or glob pattern.
65    ///
66    /// # Returns
67    ///
68    /// * `true` if the source represents a batch of images.
69    #[must_use]
70    pub const fn is_batch(&self) -> bool {
71        matches!(
72            self,
73            Self::Directory(_) | Self::Glob(_) | Self::ImageList(_)
74        )
75    }
76
77    /// Get the path if this source has one.
78    ///
79    /// # Returns
80    ///
81    /// * `Some` path reference if applicable, otherwise `None`.
82    #[must_use]
83    pub fn path(&self) -> Option<&Path> {
84        match self {
85            Self::Image(p) | Self::Video(p) | Self::Directory(p) => Some(p),
86            _ => None,
87        }
88    }
89
90    /// Check if a URL points to an image based on extension.
91    fn is_image_url(url: &str) -> bool {
92        let url_lower = url.to_lowercase();
93        // Remove query parameters if present
94        let path_part = url_lower.split('?').next().unwrap_or(&url_lower);
95
96        std::path::Path::new(path_part)
97            .extension()
98            .is_some_and(|ext| {
99                let s = ext.to_string_lossy();
100                s.eq_ignore_ascii_case("jpg")
101                    || s.eq_ignore_ascii_case("jpeg")
102                    || s.eq_ignore_ascii_case("png")
103                    || s.eq_ignore_ascii_case("bmp")
104                    || s.eq_ignore_ascii_case("gif")
105                    || s.eq_ignore_ascii_case("webp")
106                    || s.eq_ignore_ascii_case("tiff")
107                    || s.eq_ignore_ascii_case("tif")
108            })
109    }
110}
111
112/// Convert from a string path to Source.
113impl From<&str> for Source {
114    fn from(s: &str) -> Self {
115        // Check for webcam index
116        if let Ok(idx) = s.parse::<u32>() {
117            return Self::Webcam(idx);
118        }
119
120        // Check for HTTP/HTTPS URLs
121        if s.starts_with("http://") || s.starts_with("https://") {
122            // Check if it's an image URL by extension
123            if Self::is_image_url(s) {
124                return Self::ImageUrl(s.to_string());
125            }
126            // Otherwise treat as video stream
127            return Self::Stream(s.to_string());
128        }
129
130        // Check for streaming URLs
131        if s.starts_with("rtsp://") || s.starts_with("rtmp://") {
132            return Self::Stream(s.to_string());
133        }
134
135        // Check for glob pattern
136        if s.contains('*') {
137            return Self::Glob(s.to_string());
138        }
139
140        let path = PathBuf::from(s)
141            .canonicalize()
142            .unwrap_or_else(|_| PathBuf::from(s));
143
144        // Check if it's a directory
145        if path.is_dir() {
146            return Self::Directory(path);
147        }
148
149        // Check file extension for video
150        if let Some(ext) = path.extension() {
151            let ext = ext.to_string_lossy().to_lowercase();
152            if matches!(
153                ext.as_str(),
154                "mp4" | "avi" | "mov" | "mkv" | "wmv" | "flv" | "webm" | "m4v" | "mpeg" | "mpg"
155            ) {
156                return Self::Video(path);
157            }
158        }
159
160        // Default to image
161        Self::Image(path)
162    }
163}
164
165impl From<String> for Source {
166    fn from(s: String) -> Self {
167        Self::from(s.as_str())
168    }
169}
170
171impl From<PathBuf> for Source {
172    fn from(path: PathBuf) -> Self {
173        Self::from(path.to_string_lossy().as_ref())
174    }
175}
176
177impl From<&Path> for Source {
178    fn from(path: &Path) -> Self {
179        Self::from(path.to_string_lossy().as_ref())
180    }
181}
182
183impl From<DynamicImage> for Source {
184    fn from(img: DynamicImage) -> Self {
185        Self::ImageBuffer(img)
186    }
187}
188
189impl From<Array3<u8>> for Source {
190    fn from(arr: Array3<u8>) -> Self {
191        Self::Array(arr)
192    }
193}
194
195impl From<u32> for Source {
196    fn from(idx: u32) -> Self {
197        Self::Webcam(idx)
198    }
199}
200
201impl From<i32> for Source {
202    fn from(idx: i32) -> Self {
203        #[allow(clippy::cast_sign_loss)]
204        Self::Webcam(idx as u32)
205    }
206}
207
208/// Metadata about a source frame.
209#[derive(Debug, Clone)]
210pub struct SourceMeta {
211    /// Frame index (0 for single images).
212    pub frame_idx: usize,
213    /// Total frames (1 for single images, may be unknown for streams).
214    pub total_frames: Option<usize>,
215    /// Source path or identifier.
216    pub path: String,
217    /// Frames per second (for video sources).
218    pub fps: Option<f32>,
219}
220
221impl Default for SourceMeta {
222    fn default() -> Self {
223        Self {
224            frame_idx: 0,
225            total_frames: Some(1),
226            path: String::new(),
227            fps: None,
228        }
229    }
230}
231
232#[cfg(feature = "video")]
233use video_rs::ffmpeg;
234
235/// Custom `FFmpeg` video decoder using `SWS_BILINEAR` for YUV -> RGB conversion.
236///
237/// `video-rs` defaults to `SWS_AREA`, which can produce slightly different
238/// pixel values during colorspace conversion. Those differences can affect
239/// borderline confidence predictions and lead to small detection drift.
240/// For consistency, this decoder uses `SWS_BILINEAR` explicitly.
241#[cfg(feature = "video")]
242struct BilinearVideoDecoder {
243    input_ctx: ffmpeg::format::context::Input,
244    decoder: ffmpeg::decoder::Video,
245    scaler: Option<ffmpeg::software::scaling::context::Context>,
246    stream_index: usize,
247    /// Total frames (estimated from duration * fps).
248    total_frames: Option<usize>,
249    /// Frames per second.
250    fps: f32,
251}
252
253#[cfg(feature = "video")]
254impl BilinearVideoDecoder {
255    #[cfg_attr(coverage_nightly, coverage(off))]
256    fn new(path: &Path) -> Result<Self> {
257        ffmpeg::init().map_err(|e| InferenceError::VideoError(format!("FFmpeg init: {e}")))?;
258
259        let input_ctx = ffmpeg::format::input(path).map_err(|e| {
260            InferenceError::VideoError(format!("Cannot open {}: {e}", path.display()))
261        })?;
262
263        let stream = input_ctx
264            .streams()
265            .best(ffmpeg::media::Type::Video)
266            .ok_or_else(|| InferenceError::VideoError("No video stream found".into()))?;
267
268        let stream_index = stream.index();
269
270        // Estimate total frames
271        #[allow(clippy::cast_possible_truncation)]
272        let fps = f64::from(stream.avg_frame_rate()) as f32;
273        #[allow(clippy::cast_precision_loss)]
274        let duration_secs = input_ctx.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE);
275        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
276        let total_frames = if duration_secs > 0.0 && fps > 0.0 {
277            Some((duration_secs * f64::from(fps)) as usize)
278        } else {
279            None
280        };
281
282        let context_decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
283            .map_err(|e| InferenceError::VideoError(format!("Codec context: {e}")))?;
284        let decoder = context_decoder
285            .decoder()
286            .video()
287            .map_err(|e| InferenceError::VideoError(format!("Video decoder: {e}")))?;
288
289        Ok(Self {
290            input_ctx,
291            decoder,
292            scaler: None,
293            stream_index,
294            total_frames,
295            fps,
296        })
297    }
298
299    /// Decode the next frame as an RGB24 `DynamicImage`.
300    #[cfg_attr(coverage_nightly, coverage(off))]
301    fn decode_next(&mut self) -> Option<Result<DynamicImage>> {
302        let mut decoded = ffmpeg::util::frame::video::Video::empty();
303
304        loop {
305            // Try to receive a frame from the decoder first
306            if self.decoder.receive_frame(&mut decoded).is_ok() {
307                return Some(self.frame_to_image(&decoded));
308            }
309
310            // Read packets until we find one for our stream
311            let mut found_packet = false;
312            for (stream, packet) in self.input_ctx.packets() {
313                if stream.index() == self.stream_index {
314                    if self.decoder.send_packet(&packet).is_err() {
315                        continue;
316                    }
317                    found_packet = true;
318                    break;
319                }
320            }
321
322            if !found_packet {
323                // End of stream - flush decoder
324                let _ = self.decoder.send_eof();
325                return if self.decoder.receive_frame(&mut decoded).is_ok() {
326                    Some(self.frame_to_image(&decoded))
327                } else {
328                    None
329                };
330            }
331
332            // Try to receive again after sending the packet
333            if self.decoder.receive_frame(&mut decoded).is_ok() {
334                return Some(self.frame_to_image(&decoded));
335            }
336        }
337    }
338
339    /// Convert a decoded video frame to RGB24 `DynamicImage` using BILINEAR scaler.
340    #[cfg_attr(coverage_nightly, coverage(off))]
341    fn frame_to_image(
342        &mut self,
343        decoded: &ffmpeg::util::frame::video::Video,
344    ) -> Result<DynamicImage> {
345        // Initialize scaler on first frame (we need actual dimensions)
346        if self.scaler.is_none() {
347            self.scaler = Some(
348                ffmpeg::software::scaling::context::Context::get(
349                    decoded.format(),
350                    decoded.width(),
351                    decoded.height(),
352                    ffmpeg::format::Pixel::RGB24,
353                    decoded.width(),
354                    decoded.height(),
355                    ffmpeg::software::scaling::flag::Flags::BILINEAR,
356                )
357                .map_err(|e| InferenceError::VideoError(format!("Scaler init: {e}")))?,
358            );
359        }
360
361        let mut rgb_frame = ffmpeg::util::frame::video::Video::empty();
362        self.scaler
363            .as_mut()
364            .unwrap()
365            .run(decoded, &mut rgb_frame)
366            .map_err(|e| InferenceError::VideoError(format!("Scale: {e}")))?;
367
368        let width = rgb_frame.width();
369        let height = rgb_frame.height();
370        let data = rgb_frame.data(0);
371        let stride = rgb_frame.stride(0);
372
373        // Copy tightly-packed RGB data (stride may be wider than width*3)
374        let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
375        for y in 0..height as usize {
376            let row = &data[y * stride..y * stride + (width as usize) * 3];
377            rgb_data.extend_from_slice(row);
378        }
379
380        let img_buffer = image::RgbImage::from_raw(width, height, rgb_data).ok_or_else(|| {
381            InferenceError::ImageError("Failed to create image from video frame".into())
382        })?;
383        Ok(DynamicImage::ImageRgb8(img_buffer))
384    }
385}
386
387/// Iterator over frames from a source.
388pub struct SourceIterator {
389    source: Source,
390    current_frame: usize,
391    image_paths: Vec<PathBuf>,
392    #[cfg(feature = "video")]
393    decoder: Option<BilinearVideoDecoder>,
394    #[cfg(feature = "video")]
395    webcam_decoder: Option<(ffmpeg::format::context::Input, ffmpeg::decoder::Video)>,
396    #[cfg(feature = "video")]
397    webcam_stream_index: usize,
398    #[cfg(feature = "video")]
399    total_frames: Option<usize>,
400    #[cfg(feature = "video")]
401    webcam_init_failed: bool,
402    #[cfg(feature = "video")]
403    video_init_failed: bool,
404}
405
406impl SourceIterator {
407    /// Create a new source iterator.
408    ///
409    /// # Arguments
410    ///
411    /// * `source` - The input source to iterate over.
412    ///
413    /// # Returns
414    ///
415    /// * A new `SourceIterator` instance.
416    ///
417    /// # Errors
418    ///
419    /// Returns an error if the source cannot be opened (e.g. directory not found).
420    pub fn new(source: Source) -> Result<Self> {
421        let image_paths = match &source {
422            Source::Directory(path) => Self::collect_images_from_dir(path)?,
423            Source::Glob(pattern) => Self::collect_images_from_glob(pattern)?,
424            Source::Image(path) => vec![path.clone()],
425            // URLs are handled separately via next_image_url
426            Source::ImageList(paths) => paths.clone(),
427            _ => vec![],
428        };
429
430        Ok(Self {
431            source,
432            current_frame: 0,
433            image_paths,
434            #[cfg(feature = "video")]
435            decoder: None,
436            #[cfg(feature = "video")]
437            webcam_decoder: None,
438            #[cfg(feature = "video")]
439            webcam_stream_index: 0,
440            #[cfg(feature = "video")]
441            total_frames: None,
442            #[cfg(feature = "video")]
443            webcam_init_failed: false,
444            #[cfg(feature = "video")]
445            video_init_failed: false,
446        })
447    }
448
449    /// Collect image paths from a directory.
450    fn collect_images_from_dir(dir: &Path) -> Result<Vec<PathBuf>> {
451        if !dir.is_dir() {
452            return Err(InferenceError::ImageError(format!(
453                "Not a directory: {}",
454                dir.display()
455            )));
456        }
457
458        let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
459            .filter_map(std::result::Result::ok)
460            .map(|entry| entry.path())
461            .filter(|path| Self::is_image_file(path))
462            .collect();
463
464        paths.sort();
465        Ok(paths)
466    }
467
468    /// Collect image paths from a glob pattern.
469    ///
470    /// Note: This is a simplified glob implementation that only supports patterns like "dir/*.jpg"
471    /// For more complex glob patterns, consider adding the `glob` crate.
472    fn collect_images_from_glob(pattern: &str) -> Result<Vec<PathBuf>> {
473        // Simple glob: split into directory and extension pattern
474        // Supports patterns like "images/*.jpg" or "path/to/dir/*.png"
475        if let Some(star_pos) = pattern.find('*') {
476            let dir_part = &pattern[..star_pos];
477            let dir = if dir_part.is_empty() {
478                Path::new(".")
479            } else {
480                Path::new(dir_part.trim_end_matches('/').trim_end_matches('\\'))
481            };
482
483            // Get extension filter from pattern (e.g., "*.jpg" -> "jpg")
484            let ext_filter: Option<String> = pattern[star_pos..]
485                .strip_prefix("*.")
486                .map(str::to_lowercase);
487
488            if !dir.is_dir() {
489                return Err(InferenceError::ImageError(format!(
490                    "Directory not found: {}",
491                    dir.display()
492                )));
493            }
494
495            let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
496                .filter_map(std::result::Result::ok)
497                .map(|entry| entry.path())
498                .filter(|path| {
499                    ext_filter.as_ref().map_or_else(
500                        || Self::is_image_file(path),
501                        |ext| {
502                            path.extension()
503                                .is_some_and(|e| e.to_string_lossy().to_lowercase() == *ext)
504                        },
505                    )
506                })
507                .collect();
508
509            paths.sort();
510            Ok(paths)
511        } else {
512            // No glob pattern, treat as single file
513            Ok(vec![PathBuf::from(pattern)])
514        }
515    }
516
517    /// Check if a path is an image file based on extension.
518    fn is_image_file(path: &Path) -> bool {
519        path.extension().is_some_and(|ext| {
520            let ext = ext.to_string_lossy().to_lowercase();
521            matches!(
522                ext.as_str(),
523                "jpg" | "jpeg" | "png" | "bmp" | "gif" | "webp" | "tiff" | "tif"
524            )
525        })
526    }
527
528    /// Download an image from a URL.
529    fn download_image(url: &str) -> Result<DynamicImage> {
530        let mut response = ureq::get(url)
531            .call()
532            .map_err(|e| InferenceError::ImageError(format!("Failed to download {url}: {e}")))?
533            .into_body();
534
535        let bytes = response.read_to_vec().map_err(|e| {
536            InferenceError::ImageError(format!("Failed to read response from {url}: {e}"))
537        })?;
538
539        image::load_from_memory(&bytes).map_err(|e| {
540            InferenceError::ImageError(format!("Failed to decode image from {url}: {e}"))
541        })
542    }
543
544    /// Get the next image from a URL.
545    fn next_image_url(&mut self, url: &str) -> Option<Result<(DynamicImage, SourceMeta)>> {
546        if self.current_frame > 0 {
547            return None;
548        }
549
550        self.current_frame = 1;
551        let meta = SourceMeta {
552            frame_idx: 0,
553            total_frames: Some(1),
554            path: url.to_string(),
555            fps: None,
556        };
557
558        match Self::download_image(url) {
559            Ok(img) => Some(Ok((img, meta))),
560            Err(e) => Some(Err(e)),
561        }
562    }
563
564    /// Get the next image from the source.
565    fn next_image(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
566        if self.current_frame >= self.image_paths.len() {
567            return None;
568        }
569
570        let path = &self.image_paths[self.current_frame];
571        let meta = SourceMeta {
572            frame_idx: self.current_frame,
573            total_frames: Some(self.image_paths.len()),
574            path: path.to_string_lossy().to_string(),
575            fps: None,
576        };
577
578        self.current_frame += 1;
579
580        match image::open(path) {
581            Ok(img) => Some(Ok((img, meta))),
582            Err(e) => Some(Err(InferenceError::ImageError(format!(
583                "Failed to load {}: {e}",
584                path.display()
585            )))),
586        }
587    }
588
589    /// Get the next video frame.
590    #[cfg(feature = "video")]
591    #[cfg_attr(coverage_nightly, coverage(off))]
592    #[allow(unsafe_code, clippy::too_many_lines)]
593    fn next_video_frame(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
594        // Handle Webcam separately using native ffmpeg
595        if let Source::Webcam(idx) = &self.source {
596            if self.webcam_init_failed {
597                return None;
598            }
599
600            if self.webcam_decoder.is_none() {
601                // Initialize webcam
602                ffmpeg::init().ok();
603
604                // Get format by name (returns Option<Format>)
605                let input_format_name = if cfg!(target_os = "macos") {
606                    "avfoundation"
607                } else if cfg!(target_os = "linux") {
608                    "video4linux2"
609                } else if cfg!(target_os = "windows") {
610                    "dshow"
611                } else {
612                    self.webcam_init_failed = true;
613                    return Some(Err(InferenceError::VideoError(
614                        "Unsupported OS for webcam".to_string(),
615                    )));
616                };
617
618                // Find input format by name using low-level C API
619                let c_name = std::ffi::CString::new(input_format_name).unwrap();
620                #[allow(unsafe_code)]
621                let ptr = unsafe { video_rs::ffmpeg::ffi::av_find_input_format(c_name.as_ptr()) };
622
623                let input_format = if ptr.is_null() {
624                    self.webcam_init_failed = true;
625                    return Some(Err(InferenceError::VideoError(format!(
626                        "Input format '{input_format_name}' not found"
627                    ))));
628                } else {
629                    #[allow(unsafe_code, clippy::ptr_cast_constness)]
630                    unsafe {
631                        ffmpeg::format::Input::wrap(ptr.cast_mut())
632                    }
633                };
634
635                // Determine device name based on OS and index
636                let device_name = if cfg!(target_os = "macos") {
637                    idx.to_string() // Just index for avfoundation
638                } else if cfg!(target_os = "linux") {
639                    format!("/dev/video{idx}")
640                } else if cfg!(target_os = "windows") {
641                    format!("video={idx}")
642                } else {
643                    self.webcam_init_failed = true;
644                    return Some(Err(InferenceError::VideoError(
645                        "Unsupported OS for webcam device name".to_string(),
646                    )));
647                };
648
649                // Set explicit framerate to avoid default NTSC mismatch
650                let mut options = ffmpeg::Dictionary::new();
651                options.set("framerate", "30");
652
653                match ffmpeg::format::open_with(
654                    &PathBuf::from(&device_name),
655                    &ffmpeg::Format::Input(input_format),
656                    options,
657                ) {
658                    #[allow(clippy::single_match_else)]
659                    Ok(ctx) => match ctx {
660                        ffmpeg::format::context::Context::Input(ictx) => {
661                            let input =
662                                ictx.streams()
663                                    .best(ffmpeg::media::Type::Video)
664                                    .ok_or_else(|| {
665                                        InferenceError::VideoError(
666                                            "No video stream found in webcam".to_string(),
667                                        )
668                                    });
669
670                            match input {
671                                Ok(stream) => {
672                                    let stream_index = stream.index();
673                                    self.webcam_stream_index = stream_index;
674                                    let context_decoder =
675                                        ffmpeg::codec::context::Context::from_parameters(
676                                            stream.parameters(),
677                                        )
678                                        .unwrap();
679                                    match context_decoder.decoder().video() {
680                                        Ok(decoder) => {
681                                            self.webcam_decoder = Some((ictx, decoder));
682                                        }
683                                        Err(e) => {
684                                            self.webcam_init_failed = true;
685                                            return Some(Err(InferenceError::VideoError(format!(
686                                                "Failed to create webcam decoder: {e}"
687                                            ))));
688                                        }
689                                    }
690                                }
691                                Err(e) => {
692                                    self.webcam_init_failed = true;
693                                    return Some(Err(e));
694                                }
695                            }
696                        }
697                        ffmpeg::format::context::Context::Output(_) => {
698                            self.webcam_init_failed = true;
699                            return Some(Err(InferenceError::VideoError(
700                                "Opened context is not an input context".to_string(),
701                            )));
702                        }
703                    },
704                    Err(e) => {
705                        self.webcam_init_failed = true;
706                        return Some(Err(InferenceError::VideoError(format!(
707                            "Failed to open webcam: {e}"
708                        ))));
709                    }
710                }
711            }
712
713            if let Some((ictx, decoder)) = &mut self.webcam_decoder {
714                let mut decoded = ffmpeg::util::frame::video::Video::empty();
715
716                // Read packets until we get a full frame
717                for (stream, packet) in ictx.packets() {
718                    if stream.index() == self.webcam_stream_index
719                        && decoder.send_packet(&packet).is_ok()
720                        && decoder.receive_frame(&mut decoded).is_ok()
721                    {
722                        // Convert to DynamicImage
723                        // Handle pixel formatting manually or use a helper
724                        // For simplicity, we assume RGB24 or BGR24 or similar, likely need swscale
725
726                        // We need a scaler to ensure RGB output
727                        let mut rgb_frame = ffmpeg::util::frame::video::Video::empty();
728                        let mut scaler = ffmpeg::software::scaling::context::Context::get(
729                            decoded.format(),
730                            decoded.width(),
731                            decoded.height(),
732                            ffmpeg::format::Pixel::RGB24,
733                            decoded.width(),
734                            decoded.height(),
735                            ffmpeg::software::scaling::flag::Flags::BILINEAR,
736                        )
737                        .unwrap();
738
739                        scaler.run(&decoded, &mut rgb_frame).ok();
740
741                        let width = rgb_frame.width();
742                        let height = rgb_frame.height();
743                        let data = rgb_frame.data(0);
744                        let stride = rgb_frame.stride(0);
745
746                        // Tightly packed RGB data
747                        let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
748                        for y in 0..height as usize {
749                            let row = &data[y * stride..y * stride + (width as usize) * 3];
750                            rgb_data.extend_from_slice(row);
751                        }
752
753                        let img_buffer =
754                            image::RgbImage::from_raw(width, height, rgb_data).unwrap();
755                        let img = DynamicImage::ImageRgb8(img_buffer);
756
757                        let meta = SourceMeta {
758                            frame_idx: self.current_frame,
759                            total_frames: None,
760                            path: format!("Webcam {idx}"),
761                            fps: None,
762                        };
763                        self.current_frame += 1;
764                        return Some(Ok((img, meta)));
765                    }
766                }
767                return None; // End of stream or error
768            }
769            return None;
770        }
771
772        // Initialize decoder if needed (Video/Stream)
773        if self.decoder.is_none() {
774            if self.video_init_failed {
775                return None;
776            }
777
778            let path_str = match &self.source {
779                Source::Video(p) => Some(p.to_string_lossy().to_string()),
780                Source::Stream(s) => Some(s.clone()),
781                _ => None,
782            };
783
784            if let Some(path_str) = path_str {
785                match BilinearVideoDecoder::new(Path::new(&path_str)) {
786                    Ok(d) => {
787                        self.total_frames = d.total_frames;
788                        self.decoder = Some(d);
789                    }
790                    Err(e) => {
791                        self.video_init_failed = true;
792                        return Some(Err(InferenceError::VideoError(format!(
793                            "Failed to create decoder: {e}"
794                        ))));
795                    }
796                }
797            }
798        }
799
800        if let Some(decoder) = &mut self.decoder {
801            match decoder.decode_next() {
802                Some(Ok(img)) => {
803                    let meta = SourceMeta {
804                        frame_idx: self.current_frame,
805                        total_frames: self.total_frames,
806                        path: self
807                            .source
808                            .path()
809                            .map(|p| p.to_string_lossy().to_string())
810                            .unwrap_or_default(),
811                        fps: Some(decoder.fps),
812                    };
813                    self.current_frame += 1;
814                    Some(Ok((img, meta)))
815                }
816                Some(Err(e)) => Some(Err(e)),
817                None => None,
818            }
819        } else {
820            None
821        }
822    }
823
824    #[cfg(not(feature = "video"))]
825    #[allow(
826        clippy::unused_self,
827        clippy::unnecessary_wraps,
828        clippy::needless_pass_by_ref_mut
829    )]
830    fn next_video_frame(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
831        Some(Err(InferenceError::FeatureNotEnabled(
832            "Video support requires '--features video'".to_string(),
833        )))
834    }
835}
836
837impl Iterator for SourceIterator {
838    type Item = Result<(DynamicImage, SourceMeta)>;
839
840    fn next(&mut self) -> Option<Self::Item> {
841        match &self.source {
842            Source::Image(_) | Source::Directory(_) | Source::Glob(_) | Source::ImageList(_) => {
843                self.next_image()
844            }
845            Source::ImageUrl(url) => {
846                let url = url.clone();
847                self.next_image_url(&url)
848            }
849            Source::ImageBuffer(img) => {
850                if self.current_frame == 0 {
851                    self.current_frame = 1;
852                    let meta = SourceMeta::default();
853                    Some(Ok((img.clone(), meta)))
854                } else {
855                    None
856                }
857            }
858            Source::Array(arr) => {
859                if self.current_frame == 0 {
860                    self.current_frame = 1;
861                    let meta = SourceMeta::default();
862                    // Convert array to image
863                    match crate::utils::array_to_image(arr) {
864                        Ok(img) => Some(Ok((img, meta))),
865                        Err(e) => Some(Err(e)),
866                    }
867                } else {
868                    None
869                }
870            }
871            Source::Video(_) | Source::Webcam(_) | Source::Stream(_) => self.next_video_frame(),
872        }
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879
880    #[test]
881    fn test_source_from_string() {
882        assert!(matches!(Source::from("image.jpg"), Source::Image(_)));
883        assert!(matches!(Source::from("video.mp4"), Source::Video(_)));
884        assert!(matches!(
885            Source::from("rtsp://example.com"),
886            Source::Stream(_)
887        ));
888        assert!(matches!(Source::from("0"), Source::Webcam(0)));
889        assert!(matches!(Source::from("*.jpg"), Source::Glob(_)));
890    }
891
892    #[test]
893    fn test_source_checks() {
894        let img = Source::Image(PathBuf::from("test.jpg"));
895        assert!(img.is_image());
896        assert!(!img.is_video());
897
898        let vid = Source::Video(PathBuf::from("test.mp4"));
899        assert!(!vid.is_image());
900        assert!(vid.is_video());
901
902        let dir = Source::Directory(PathBuf::from("./images"));
903        assert!(dir.is_batch());
904    }
905
906    #[test]
907    fn test_from_str_url_classification() {
908        // Image extension over HTTP -> ImageUrl; otherwise treated as a stream.
909        assert!(matches!(
910            Source::from("https://example.com/cat.png"),
911            Source::ImageUrl(_)
912        ));
913        assert!(matches!(
914            Source::from("http://example.com/dog.JPEG?size=large"),
915            Source::ImageUrl(_)
916        ));
917        assert!(matches!(
918            Source::from("https://example.com/live/stream"),
919            Source::Stream(_)
920        ));
921        assert!(matches!(
922            Source::from("rtmp://example.com/live"),
923            Source::Stream(_)
924        ));
925    }
926
927    #[test]
928    fn test_from_str_video_extensions() {
929        for ext in ["mp4", "avi", "mov", "mkv", "webm", "m4v", "mpeg", "mpg"] {
930            let s = format!("clip.{ext}");
931            assert!(
932                matches!(Source::from(s.as_str()), Source::Video(_)),
933                "{ext}"
934            );
935        }
936        // Uppercase extension is normalized.
937        assert!(matches!(Source::from("CLIP.MP4"), Source::Video(_)));
938    }
939
940    #[test]
941    fn test_is_image_url_helper() {
942        assert!(Source::is_image_url("a/b/c.jpg"));
943        assert!(Source::is_image_url("a.PNG?x=1"));
944        assert!(Source::is_image_url("a.tiff"));
945        assert!(!Source::is_image_url("a.mp4"));
946        assert!(!Source::is_image_url("no_extension"));
947    }
948
949    #[test]
950    fn test_from_conversions() {
951        assert!(matches!(
952            Source::from(String::from("a.jpg")),
953            Source::Image(_)
954        ));
955        assert!(matches!(
956            Source::from(PathBuf::from("a.jpg")),
957            Source::Image(_)
958        ));
959        assert!(matches!(Source::from(Path::new("a.jpg")), Source::Image(_)));
960        assert!(matches!(
961            Source::from(image::DynamicImage::new_rgb8(2, 2)),
962            Source::ImageBuffer(_)
963        ));
964        assert!(matches!(
965            Source::from(Array3::<u8>::zeros((2, 2, 3))),
966            Source::Array(_)
967        ));
968        assert!(matches!(Source::from(3u32), Source::Webcam(3)));
969        assert!(matches!(Source::from(5i32), Source::Webcam(5)));
970    }
971
972    #[test]
973    fn test_path_accessor() {
974        assert!(Source::Image(PathBuf::from("a.jpg")).path().is_some());
975        assert!(Source::Video(PathBuf::from("a.mp4")).path().is_some());
976        assert!(Source::Directory(PathBuf::from("d")).path().is_some());
977        assert!(Source::Webcam(0).path().is_none());
978        assert!(Source::Stream("rtsp://x".into()).path().is_none());
979    }
980
981    #[test]
982    fn test_source_meta_default() {
983        let m = SourceMeta::default();
984        assert_eq!(m.frame_idx, 0);
985        assert_eq!(m.total_frames, Some(1));
986        assert!(m.path.is_empty());
987        assert!(m.fps.is_none());
988    }
989
990    /// Write a tiny valid image to `path`.
991    fn write_image(path: &Path) {
992        image::DynamicImage::new_rgb8(4, 4).save(path).unwrap();
993    }
994
995    #[test]
996    fn test_collect_images_from_dir() {
997        let tmp = tempfile::tempdir().unwrap();
998        write_image(&tmp.path().join("b.png"));
999        write_image(&tmp.path().join("a.jpg"));
1000        std::fs::write(tmp.path().join("notes.txt"), b"ignore me").unwrap();
1001
1002        let paths = SourceIterator::collect_images_from_dir(tmp.path()).unwrap();
1003        // Only the two images, sorted by name.
1004        assert_eq!(paths.len(), 2);
1005        assert!(paths[0].ends_with("a.jpg"));
1006        assert!(paths[1].ends_with("b.png"));
1007
1008        // Non-directory path is an error.
1009        assert!(SourceIterator::collect_images_from_dir(Path::new("definitely/missing")).is_err());
1010    }
1011
1012    #[test]
1013    fn test_collect_images_from_glob() {
1014        let tmp = tempfile::tempdir().unwrap();
1015        write_image(&tmp.path().join("a.jpg"));
1016        write_image(&tmp.path().join("b.png"));
1017
1018        // Extension-filtered glob picks only the matching extension.
1019        let pattern = format!("{}/*.jpg", tmp.path().display());
1020        let jpgs = SourceIterator::collect_images_from_glob(&pattern).unwrap();
1021        assert_eq!(jpgs.len(), 1);
1022        assert!(jpgs[0].ends_with("a.jpg"));
1023
1024        // Bare `*` (no extension) falls back to any image file.
1025        let all_pattern = format!("{}/*", tmp.path().display());
1026        let all = SourceIterator::collect_images_from_glob(&all_pattern).unwrap();
1027        assert_eq!(all.len(), 2);
1028
1029        // Missing directory is an error.
1030        assert!(SourceIterator::collect_images_from_glob("missing_dir/*.jpg").is_err());
1031
1032        // No star: treated as a single literal path.
1033        let single = SourceIterator::collect_images_from_glob("just/a/file.jpg").unwrap();
1034        assert_eq!(single, vec![PathBuf::from("just/a/file.jpg")]);
1035    }
1036
1037    #[test]
1038    fn test_iterator_image_buffer_yields_once() {
1039        let src = Source::ImageBuffer(image::DynamicImage::new_rgb8(4, 4));
1040        let mut it = SourceIterator::new(src).unwrap();
1041        assert!(it.next().is_some());
1042        assert!(it.next().is_none());
1043    }
1044
1045    #[test]
1046    fn test_iterator_array_yields_once() {
1047        let src = Source::Array(Array3::<u8>::zeros((4, 4, 3)));
1048        let mut it = SourceIterator::new(src).unwrap();
1049        let first = it.next().unwrap();
1050        assert!(first.is_ok());
1051        assert!(it.next().is_none());
1052    }
1053
1054    #[test]
1055    fn test_iterator_over_directory() {
1056        let tmp = tempfile::tempdir().unwrap();
1057        write_image(&tmp.path().join("a.jpg"));
1058        write_image(&tmp.path().join("b.png"));
1059
1060        let src = Source::Directory(tmp.path().to_path_buf());
1061        let it = SourceIterator::new(src).unwrap();
1062        let count = it.flatten().count();
1063        assert_eq!(count, 2);
1064    }
1065
1066    #[test]
1067    fn test_iterator_image_list_and_missing_file() {
1068        let tmp = tempfile::tempdir().unwrap();
1069        let good = tmp.path().join("a.jpg");
1070        write_image(&good);
1071        let missing = tmp.path().join("missing.jpg");
1072
1073        let src = Source::ImageList(vec![good, missing]);
1074        let mut it = SourceIterator::new(src).unwrap();
1075        assert!(it.next().unwrap().is_ok()); // good image decodes
1076        assert!(it.next().unwrap().is_err()); // missing path errors, no panic
1077        assert!(it.next().is_none());
1078    }
1079
1080    #[cfg(feature = "video")]
1081    #[test]
1082    fn test_iterator_over_video_file() {
1083        use crate::io::VideoWriter;
1084
1085        // Encode a short real mp4, then decode it back through the iterator.
1086        let tmp = tempfile::tempdir().unwrap();
1087        let path = tmp.path().join("clip.mp4");
1088        let mut writer = VideoWriter::new(&path, 32, 32, 10.0).unwrap();
1089        for _ in 0..5 {
1090            writer
1091                .write_frame(&image::DynamicImage::new_rgb8(32, 32))
1092                .unwrap();
1093        }
1094        writer.finish().unwrap();
1095
1096        let src = Source::Video(path);
1097        assert!(src.is_video());
1098        let mut it = SourceIterator::new(src).unwrap();
1099        // Decode at least the first frame back as an image.
1100        let (frame, _meta) = it.next().expect("a frame").expect("decodes");
1101        assert_eq!(frame.width(), 32);
1102        // Drain the rest; the iterator terminates cleanly at end-of-stream.
1103        let mut decoded = 1;
1104        for item in it.by_ref() {
1105            if item.is_ok() {
1106                decoded += 1;
1107            }
1108        }
1109        assert!(decoded >= 1);
1110        assert!(it.next().is_none());
1111    }
1112}