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 ffmpeg_next as ffmpeg;
234
235/// Custom `FFmpeg` video decoder using `SWS_BILINEAR` for YUV -> RGB conversion.
236///
237/// Other scaler choices such as `SWS_AREA` produce slightly different pixel values
238/// during colorspace conversion. Those differences can affect borderline confidence
239/// predictions and lead to small detection drift, so `SWS_BILINEAR` is explicit here.
240/// Convert a decoded video frame to a tightly-packed RGB24 [`DynamicImage`] using a BILINEAR
241/// scaler. `scaler` caches the context and is rebuilt when the frame's format or size
242/// changes, so pass a persistent `Option` to reuse it across frames.
243#[cfg(feature = "video")]
244#[cfg_attr(coverage_nightly, coverage(off))]
245fn frame_to_rgb_image(
246    scaler: &mut Option<ffmpeg::software::scaling::context::Context>,
247    decoded: &ffmpeg::util::frame::video::Video,
248) -> Result<DynamicImage> {
249    // Drop a cached context whose source properties no longer match: a webcam can
250    // renegotiate format or resolution mid-capture.
251    let reusable = scaler.take().filter(|s| {
252        let i = s.input();
253        i.format == decoded.format() && i.width == decoded.width() && i.height == decoded.height()
254    });
255    let context = match reusable {
256        Some(s) => s,
257        None => ffmpeg::software::scaling::context::Context::get(
258            decoded.format(),
259            decoded.width(),
260            decoded.height(),
261            ffmpeg::format::Pixel::RGB24,
262            decoded.width(),
263            decoded.height(),
264            ffmpeg::software::scaling::flag::Flags::BILINEAR,
265        )
266        .map_err(|e| InferenceError::VideoError(format!("Scaler init: {e}")))?,
267    };
268
269    let mut rgb_frame = ffmpeg::util::frame::video::Video::empty();
270    scaler
271        .insert(context)
272        .run(decoded, &mut rgb_frame)
273        .map_err(|e| InferenceError::VideoError(format!("Scale: {e}")))?;
274
275    let width = rgb_frame.width();
276    let height = rgb_frame.height();
277    let data = rgb_frame.data(0);
278    let stride = rgb_frame.stride(0);
279
280    // Copy tightly-packed RGB data (stride may be wider than width*3).
281    let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
282    for y in 0..height as usize {
283        let row = &data[y * stride..y * stride + (width as usize) * 3];
284        rgb_data.extend_from_slice(row);
285    }
286
287    let img_buffer = image::RgbImage::from_raw(width, height, rgb_data).ok_or_else(|| {
288        InferenceError::ImageError("Failed to create image from video frame".into())
289    })?;
290    Ok(DynamicImage::ImageRgb8(img_buffer))
291}
292
293#[cfg(feature = "video")]
294struct BilinearVideoDecoder {
295    input_ctx: ffmpeg::format::context::Input,
296    decoder: ffmpeg::decoder::Video,
297    scaler: Option<ffmpeg::software::scaling::context::Context>,
298    stream_index: usize,
299    /// Total frames (estimated from duration * fps).
300    total_frames: Option<usize>,
301    /// Frames per second.
302    fps: f32,
303}
304
305#[cfg(feature = "video")]
306impl BilinearVideoDecoder {
307    /// Open `path` with `FFmpeg` and set up the decoder and bilinear `RGB24` scaler.
308    #[cfg_attr(coverage_nightly, coverage(off))]
309    fn new(path: &Path) -> Result<Self> {
310        ffmpeg::init().map_err(|e| InferenceError::VideoError(format!("FFmpeg init: {e}")))?;
311
312        let input_ctx = ffmpeg::format::input(path).map_err(|e| {
313            InferenceError::VideoError(format!("Cannot open {}: {e}", path.display()))
314        })?;
315
316        let stream = input_ctx
317            .streams()
318            .best(ffmpeg::media::Type::Video)
319            .ok_or_else(|| InferenceError::VideoError("No video stream found".into()))?;
320
321        let stream_index = stream.index();
322
323        // Estimate total frames
324        #[allow(clippy::cast_possible_truncation)]
325        let fps = f64::from(stream.avg_frame_rate()) as f32;
326        #[allow(clippy::cast_precision_loss)]
327        let duration_secs = input_ctx.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE);
328        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
329        let total_frames = if duration_secs > 0.0 && fps > 0.0 {
330            Some((duration_secs * f64::from(fps)) as usize)
331        } else {
332            None
333        };
334
335        let context_decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
336            .map_err(|e| InferenceError::VideoError(format!("Codec context: {e}")))?;
337        let decoder = context_decoder
338            .decoder()
339            .video()
340            .map_err(|e| InferenceError::VideoError(format!("Video decoder: {e}")))?;
341
342        Ok(Self {
343            input_ctx,
344            decoder,
345            scaler: None,
346            stream_index,
347            total_frames,
348            fps,
349        })
350    }
351
352    /// Decode the next frame as an RGB24 `DynamicImage`.
353    #[cfg_attr(coverage_nightly, coverage(off))]
354    fn decode_next(&mut self) -> Option<Result<DynamicImage>> {
355        let mut decoded = ffmpeg::util::frame::video::Video::empty();
356
357        loop {
358            // Try to receive a frame from the decoder first
359            if self.decoder.receive_frame(&mut decoded).is_ok() {
360                return Some(self.frame_to_image(&decoded));
361            }
362
363            // Read packets until we find one for our stream
364            let mut found_packet = false;
365            for (stream, packet) in self.input_ctx.packets() {
366                if stream.index() == self.stream_index {
367                    if self.decoder.send_packet(&packet).is_err() {
368                        continue;
369                    }
370                    found_packet = true;
371                    break;
372                }
373            }
374
375            if !found_packet {
376                // End of stream - flush decoder
377                let _ = self.decoder.send_eof();
378                return if self.decoder.receive_frame(&mut decoded).is_ok() {
379                    Some(self.frame_to_image(&decoded))
380                } else {
381                    None
382                };
383            }
384
385            // Try to receive again after sending the packet
386            if self.decoder.receive_frame(&mut decoded).is_ok() {
387                return Some(self.frame_to_image(&decoded));
388            }
389        }
390    }
391
392    /// Convert a decoded video frame to RGB24 `DynamicImage` using BILINEAR scaler.
393    #[cfg_attr(coverage_nightly, coverage(off))]
394    fn frame_to_image(
395        &mut self,
396        decoded: &ffmpeg::util::frame::video::Video,
397    ) -> Result<DynamicImage> {
398        frame_to_rgb_image(&mut self.scaler, decoded)
399    }
400}
401
402/// Iterator over frames from a source.
403pub struct SourceIterator {
404    source: Source,
405    current_frame: usize,
406    image_paths: Vec<PathBuf>,
407    #[cfg(feature = "video")]
408    decoder: Option<BilinearVideoDecoder>,
409    #[cfg(feature = "video")]
410    webcam_decoder: Option<(ffmpeg::format::context::Input, ffmpeg::decoder::Video)>,
411    /// Colorspace context reused across webcam frames, as the video path does.
412    #[cfg(feature = "video")]
413    webcam_scaler: Option<ffmpeg::software::scaling::context::Context>,
414    #[cfg(feature = "video")]
415    webcam_stream_index: usize,
416    #[cfg(feature = "video")]
417    total_frames: Option<usize>,
418    #[cfg(feature = "video")]
419    webcam_init_failed: bool,
420    #[cfg(feature = "video")]
421    video_init_failed: bool,
422}
423
424impl SourceIterator {
425    /// Create a new source iterator.
426    ///
427    /// # Arguments
428    ///
429    /// * `source` - The input source to iterate over.
430    ///
431    /// # Returns
432    ///
433    /// * A new `SourceIterator` instance.
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if the source cannot be opened (e.g. directory not found).
438    pub fn new(source: Source) -> Result<Self> {
439        let image_paths = match &source {
440            Source::Directory(path) => Self::collect_images_from_dir(path)?,
441            Source::Glob(pattern) => Self::collect_images_from_glob(pattern)?,
442            Source::Image(path) => vec![path.clone()],
443            // URLs are handled separately via next_image_url
444            Source::ImageList(paths) => paths.clone(),
445            _ => vec![],
446        };
447
448        Ok(Self {
449            source,
450            current_frame: 0,
451            image_paths,
452            #[cfg(feature = "video")]
453            decoder: None,
454            #[cfg(feature = "video")]
455            webcam_decoder: None,
456            #[cfg(feature = "video")]
457            webcam_scaler: None,
458            #[cfg(feature = "video")]
459            webcam_stream_index: 0,
460            #[cfg(feature = "video")]
461            total_frames: None,
462            #[cfg(feature = "video")]
463            webcam_init_failed: false,
464            #[cfg(feature = "video")]
465            video_init_failed: false,
466        })
467    }
468
469    /// Collect image paths from a directory.
470    fn collect_images_from_dir(dir: &Path) -> Result<Vec<PathBuf>> {
471        if !dir.is_dir() {
472            return Err(InferenceError::ImageError(format!(
473                "Not a directory: {}",
474                dir.display()
475            )));
476        }
477
478        let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
479            .filter_map(std::result::Result::ok)
480            .map(|entry| entry.path())
481            .filter(|path| Self::is_image_file(path))
482            .collect();
483
484        paths.sort();
485        Ok(paths)
486    }
487
488    /// Collect image paths from a glob pattern.
489    ///
490    /// Note: This is a simplified glob implementation that only supports patterns like "dir/*.jpg"
491    /// For more complex glob patterns, consider adding the `glob` crate.
492    fn collect_images_from_glob(pattern: &str) -> Result<Vec<PathBuf>> {
493        // Simple glob: split into directory and extension pattern
494        // Supports patterns like "images/*.jpg" or "path/to/dir/*.png"
495        if let Some(star_pos) = pattern.find('*') {
496            let dir_part = &pattern[..star_pos];
497            let dir = if dir_part.is_empty() {
498                Path::new(".")
499            } else {
500                Path::new(dir_part.trim_end_matches('/').trim_end_matches('\\'))
501            };
502
503            // Get extension filter from pattern (e.g., "*.jpg" -> "jpg")
504            let ext_filter: Option<String> = pattern[star_pos..]
505                .strip_prefix("*.")
506                .map(str::to_lowercase);
507
508            if !dir.is_dir() {
509                return Err(InferenceError::ImageError(format!(
510                    "Directory not found: {}",
511                    dir.display()
512                )));
513            }
514
515            let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
516                .filter_map(std::result::Result::ok)
517                .map(|entry| entry.path())
518                .filter(|path| {
519                    ext_filter.as_ref().map_or_else(
520                        || Self::is_image_file(path),
521                        |ext| {
522                            path.extension()
523                                .is_some_and(|e| e.to_string_lossy().to_lowercase() == *ext)
524                        },
525                    )
526                })
527                .collect();
528
529            paths.sort();
530            Ok(paths)
531        } else {
532            // No glob pattern, treat as single file
533            Ok(vec![PathBuf::from(pattern)])
534        }
535    }
536
537    /// Check if a path is an image file based on extension.
538    fn is_image_file(path: &Path) -> bool {
539        path.extension().is_some_and(|ext| {
540            let ext = ext.to_string_lossy().to_lowercase();
541            matches!(
542                ext.as_str(),
543                "jpg" | "jpeg" | "png" | "bmp" | "gif" | "webp" | "tiff" | "tif"
544            )
545        })
546    }
547
548    /// Download an image from a URL.
549    fn download_image(url: &str) -> Result<DynamicImage> {
550        let mut response = ureq::get(url)
551            .call()
552            .map_err(|e| InferenceError::ImageError(format!("Failed to download {url}: {e}")))?
553            .into_body();
554
555        let bytes = response.read_to_vec().map_err(|e| {
556            InferenceError::ImageError(format!("Failed to read response from {url}: {e}"))
557        })?;
558
559        image::load_from_memory(&bytes).map_err(|e| {
560            InferenceError::ImageError(format!("Failed to decode image from {url}: {e}"))
561        })
562    }
563
564    /// Get the next image from a URL.
565    fn next_image_url(&mut self, url: &str) -> Option<Result<(DynamicImage, SourceMeta)>> {
566        if self.current_frame > 0 {
567            return None;
568        }
569
570        self.current_frame = 1;
571        let meta = SourceMeta {
572            frame_idx: 0,
573            total_frames: Some(1),
574            path: url.to_string(),
575            fps: None,
576        };
577
578        match Self::download_image(url) {
579            Ok(img) => Some(Ok((img, meta))),
580            Err(e) => Some(Err(e)),
581        }
582    }
583
584    /// Get the next image from the source.
585    fn next_image(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
586        if self.current_frame >= self.image_paths.len() {
587            return None;
588        }
589
590        let path = &self.image_paths[self.current_frame];
591        let meta = SourceMeta {
592            frame_idx: self.current_frame,
593            total_frames: Some(self.image_paths.len()),
594            path: path.to_string_lossy().to_string(),
595            fps: None,
596        };
597
598        self.current_frame += 1;
599
600        match image::open(path) {
601            Ok(img) => Some(Ok((img, meta))),
602            Err(e) => Some(Err(InferenceError::ImageError(format!(
603                "Failed to load {}: {e}",
604                path.display()
605            )))),
606        }
607    }
608
609    /// Open webcam `idx` and store its decoder, using the platform's capture backend.
610    ///
611    /// Failures are returned; the caller records them once via `webcam_init_failed`.
612    #[cfg(feature = "video")]
613    #[cfg_attr(coverage_nightly, coverage(off))]
614    #[allow(unsafe_code)]
615    fn open_webcam(&mut self, idx: u32) -> Result<()> {
616        ffmpeg::init().ok();
617
618        let (format_name, device_name) = if cfg!(target_os = "macos") {
619            ("avfoundation", idx.to_string()) // avfoundation takes the bare index
620        } else if cfg!(target_os = "linux") {
621            ("video4linux2", format!("/dev/video{idx}"))
622        } else if cfg!(target_os = "windows") {
623            ("dshow", format!("video={idx}"))
624        } else {
625            return Err(InferenceError::VideoError(
626                "Unsupported OS for webcam".to_string(),
627            ));
628        };
629
630        // The format lookup has no safe wrapper.
631        let c_name = std::ffi::CString::new(format_name).map_err(|_| {
632            InferenceError::VideoError(format!("Invalid input format name '{format_name}'"))
633        })?;
634        let ptr = unsafe { ffmpeg::ffi::av_find_input_format(c_name.as_ptr()) };
635        if ptr.is_null() {
636            return Err(InferenceError::VideoError(format!(
637                "Input format '{format_name}' not found"
638            )));
639        }
640        #[allow(clippy::ptr_cast_constness)]
641        let input_format = unsafe { ffmpeg::format::Input::wrap(ptr.cast_mut()) };
642
643        // Explicit framerate avoids a default NTSC mismatch.
644        let mut options = ffmpeg::Dictionary::new();
645        options.set("framerate", "30");
646
647        let opened = ffmpeg::format::open_with(
648            &PathBuf::from(&device_name),
649            &ffmpeg::Format::Input(input_format),
650            options,
651        )
652        .map_err(|e| InferenceError::VideoError(format!("Failed to open webcam: {e}")))?;
653
654        let ffmpeg::format::context::Context::Input(ictx) = opened else {
655            return Err(InferenceError::VideoError(
656                "Opened context is not an input context".to_string(),
657            ));
658        };
659
660        let stream = ictx
661            .streams()
662            .best(ffmpeg::media::Type::Video)
663            .ok_or_else(|| {
664                InferenceError::VideoError("No video stream found in webcam".to_string())
665            })?;
666        self.webcam_stream_index = stream.index();
667
668        let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
669            .map_err(|e| {
670                InferenceError::VideoError(format!("Failed to read webcam stream parameters: {e}"))
671            })?
672            .decoder()
673            .video()
674            .map_err(|e| {
675                InferenceError::VideoError(format!("Failed to create webcam decoder: {e}"))
676            })?;
677
678        self.webcam_decoder = Some((ictx, decoder));
679        Ok(())
680    }
681
682    /// Get the next video frame.
683    #[cfg(feature = "video")]
684    #[cfg_attr(coverage_nightly, coverage(off))]
685    #[allow(unsafe_code, clippy::too_many_lines)]
686    fn next_video_frame(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
687        // Handle Webcam separately using native ffmpeg. Copy the index out so the source
688        // borrow ends before `open_webcam` takes `&mut self`.
689        let webcam_idx = match &self.source {
690            Source::Webcam(idx) => Some(*idx),
691            _ => None,
692        };
693        if let Some(idx) = webcam_idx {
694            if self.webcam_init_failed {
695                return None;
696            }
697
698            if self.webcam_decoder.is_none()
699                && let Err(e) = self.open_webcam(idx)
700            {
701                self.webcam_init_failed = true;
702                return Some(Err(e));
703            }
704            if let Some((ictx, decoder)) = &mut self.webcam_decoder {
705                let mut decoded = ffmpeg::util::frame::video::Video::empty();
706
707                // Read packets until we get a full frame
708                for (stream, packet) in ictx.packets() {
709                    if stream.index() == self.webcam_stream_index
710                        && decoder.send_packet(&packet).is_ok()
711                        && decoder.receive_frame(&mut decoded).is_ok()
712                    {
713                        let img = match frame_to_rgb_image(&mut self.webcam_scaler, &decoded) {
714                            Ok(img) => img,
715                            Err(e) => return Some(Err(e)),
716                        };
717
718                        let meta = SourceMeta {
719                            frame_idx: self.current_frame,
720                            total_frames: None,
721                            path: format!("Webcam {idx}"),
722                            fps: None,
723                        };
724                        self.current_frame += 1;
725                        return Some(Ok((img, meta)));
726                    }
727                }
728                return None; // End of stream or error
729            }
730            return None;
731        }
732
733        // Initialize decoder if needed (Video/Stream)
734        if self.decoder.is_none() {
735            if self.video_init_failed {
736                return None;
737            }
738
739            let path_str = match &self.source {
740                Source::Video(p) => Some(p.to_string_lossy().to_string()),
741                Source::Stream(s) => Some(s.clone()),
742                _ => None,
743            };
744
745            if let Some(path_str) = path_str {
746                match BilinearVideoDecoder::new(Path::new(&path_str)) {
747                    Ok(d) => {
748                        self.total_frames = d.total_frames;
749                        self.decoder = Some(d);
750                    }
751                    Err(e) => {
752                        self.video_init_failed = true;
753                        return Some(Err(InferenceError::VideoError(format!(
754                            "Failed to create decoder: {e}"
755                        ))));
756                    }
757                }
758            }
759        }
760
761        if let Some(decoder) = &mut self.decoder {
762            match decoder.decode_next() {
763                Some(Ok(img)) => {
764                    let meta = SourceMeta {
765                        frame_idx: self.current_frame,
766                        total_frames: self.total_frames,
767                        path: self
768                            .source
769                            .path()
770                            .map(|p| p.to_string_lossy().to_string())
771                            .unwrap_or_default(),
772                        fps: Some(decoder.fps),
773                    };
774                    self.current_frame += 1;
775                    Some(Ok((img, meta)))
776                }
777                Some(Err(e)) => Some(Err(e)),
778                None => None,
779            }
780        } else {
781            None
782        }
783    }
784
785    #[cfg(not(feature = "video"))]
786    #[allow(
787        clippy::unused_self,
788        clippy::unnecessary_wraps,
789        clippy::needless_pass_by_ref_mut
790    )]
791    fn next_video_frame(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
792        Some(Err(InferenceError::FeatureNotEnabled(
793            "Video support requires '--features video'".to_string(),
794        )))
795    }
796}
797
798impl Iterator for SourceIterator {
799    type Item = Result<(DynamicImage, SourceMeta)>;
800
801    fn next(&mut self) -> Option<Self::Item> {
802        match &self.source {
803            Source::Image(_) | Source::Directory(_) | Source::Glob(_) | Source::ImageList(_) => {
804                self.next_image()
805            }
806            Source::ImageUrl(url) => {
807                let url = url.clone();
808                self.next_image_url(&url)
809            }
810            Source::ImageBuffer(img) => {
811                if self.current_frame == 0 {
812                    self.current_frame = 1;
813                    let meta = SourceMeta::default();
814                    Some(Ok((img.clone(), meta)))
815                } else {
816                    None
817                }
818            }
819            Source::Array(arr) => {
820                if self.current_frame == 0 {
821                    self.current_frame = 1;
822                    let meta = SourceMeta::default();
823                    // Convert array to image
824                    match crate::utils::array_to_image(arr) {
825                        Ok(img) => Some(Ok((img, meta))),
826                        Err(e) => Some(Err(e)),
827                    }
828                } else {
829                    None
830                }
831            }
832            Source::Video(_) | Source::Webcam(_) | Source::Stream(_) => self.next_video_frame(),
833        }
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    #[test]
842    fn test_source_from_string() {
843        assert!(matches!(Source::from("image.jpg"), Source::Image(_)));
844        assert!(matches!(Source::from("video.mp4"), Source::Video(_)));
845        assert!(matches!(
846            Source::from("rtsp://example.com"),
847            Source::Stream(_)
848        ));
849        assert!(matches!(Source::from("0"), Source::Webcam(0)));
850        assert!(matches!(Source::from("*.jpg"), Source::Glob(_)));
851    }
852
853    #[test]
854    fn test_source_checks() {
855        let img = Source::Image(PathBuf::from("test.jpg"));
856        assert!(img.is_image());
857        assert!(!img.is_video());
858
859        let vid = Source::Video(PathBuf::from("test.mp4"));
860        assert!(!vid.is_image());
861        assert!(vid.is_video());
862
863        let dir = Source::Directory(PathBuf::from("./images"));
864        assert!(dir.is_batch());
865    }
866
867    #[test]
868    fn test_from_str_url_classification() {
869        // Image extension over HTTP -> ImageUrl; otherwise treated as a stream.
870        assert!(matches!(
871            Source::from("https://example.com/cat.png"),
872            Source::ImageUrl(_)
873        ));
874        assert!(matches!(
875            Source::from("http://example.com/dog.JPEG?size=large"),
876            Source::ImageUrl(_)
877        ));
878        assert!(matches!(
879            Source::from("https://example.com/live/stream"),
880            Source::Stream(_)
881        ));
882        assert!(matches!(
883            Source::from("rtmp://example.com/live"),
884            Source::Stream(_)
885        ));
886    }
887
888    #[test]
889    fn test_from_str_video_extensions() {
890        for ext in ["mp4", "avi", "mov", "mkv", "webm", "m4v", "mpeg", "mpg"] {
891            let s = format!("clip.{ext}");
892            assert!(
893                matches!(Source::from(s.as_str()), Source::Video(_)),
894                "{ext}"
895            );
896        }
897        // Uppercase extension is normalized.
898        assert!(matches!(Source::from("CLIP.MP4"), Source::Video(_)));
899    }
900
901    #[test]
902    fn test_is_image_url_helper() {
903        assert!(Source::is_image_url("a/b/c.jpg"));
904        assert!(Source::is_image_url("a.PNG?x=1"));
905        assert!(Source::is_image_url("a.tiff"));
906        assert!(!Source::is_image_url("a.mp4"));
907        assert!(!Source::is_image_url("no_extension"));
908    }
909
910    #[test]
911    fn test_from_conversions() {
912        assert!(matches!(
913            Source::from(String::from("a.jpg")),
914            Source::Image(_)
915        ));
916        assert!(matches!(
917            Source::from(PathBuf::from("a.jpg")),
918            Source::Image(_)
919        ));
920        assert!(matches!(Source::from(Path::new("a.jpg")), Source::Image(_)));
921        assert!(matches!(
922            Source::from(image::DynamicImage::new_rgb8(2, 2)),
923            Source::ImageBuffer(_)
924        ));
925        assert!(matches!(
926            Source::from(Array3::<u8>::zeros((2, 2, 3))),
927            Source::Array(_)
928        ));
929        assert!(matches!(Source::from(3u32), Source::Webcam(3)));
930        assert!(matches!(Source::from(5i32), Source::Webcam(5)));
931    }
932
933    #[test]
934    fn test_path_accessor() {
935        assert!(Source::Image(PathBuf::from("a.jpg")).path().is_some());
936        assert!(Source::Video(PathBuf::from("a.mp4")).path().is_some());
937        assert!(Source::Directory(PathBuf::from("d")).path().is_some());
938        assert!(Source::Webcam(0).path().is_none());
939        assert!(Source::Stream("rtsp://x".into()).path().is_none());
940    }
941
942    #[test]
943    fn test_source_meta_default() {
944        let m = SourceMeta::default();
945        assert_eq!(m.frame_idx, 0);
946        assert_eq!(m.total_frames, Some(1));
947        assert!(m.path.is_empty());
948        assert!(m.fps.is_none());
949    }
950
951    /// Write a tiny valid image to `path`.
952    fn write_image(path: &Path) {
953        image::DynamicImage::new_rgb8(4, 4).save(path).unwrap();
954    }
955
956    #[test]
957    fn test_collect_images_from_dir() {
958        let tmp = tempfile::tempdir().unwrap();
959        write_image(&tmp.path().join("b.png"));
960        write_image(&tmp.path().join("a.jpg"));
961        std::fs::write(tmp.path().join("notes.txt"), b"ignore me").unwrap();
962
963        let paths = SourceIterator::collect_images_from_dir(tmp.path()).unwrap();
964        // Only the two images, sorted by name.
965        assert_eq!(paths.len(), 2);
966        assert!(paths[0].ends_with("a.jpg"));
967        assert!(paths[1].ends_with("b.png"));
968
969        // Non-directory path is an error.
970        assert!(SourceIterator::collect_images_from_dir(Path::new("definitely/missing")).is_err());
971    }
972
973    #[test]
974    fn test_collect_images_from_glob() {
975        let tmp = tempfile::tempdir().unwrap();
976        write_image(&tmp.path().join("a.jpg"));
977        write_image(&tmp.path().join("b.png"));
978
979        // Extension-filtered glob picks only the matching extension.
980        let pattern = format!("{}/*.jpg", tmp.path().display());
981        let jpgs = SourceIterator::collect_images_from_glob(&pattern).unwrap();
982        assert_eq!(jpgs.len(), 1);
983        assert!(jpgs[0].ends_with("a.jpg"));
984
985        // Bare `*` (no extension) falls back to any image file.
986        let all_pattern = format!("{}/*", tmp.path().display());
987        let all = SourceIterator::collect_images_from_glob(&all_pattern).unwrap();
988        assert_eq!(all.len(), 2);
989
990        // Missing directory is an error.
991        assert!(SourceIterator::collect_images_from_glob("missing_dir/*.jpg").is_err());
992
993        // No star: treated as a single literal path.
994        let single = SourceIterator::collect_images_from_glob("just/a/file.jpg").unwrap();
995        assert_eq!(single, vec![PathBuf::from("just/a/file.jpg")]);
996    }
997
998    #[test]
999    fn test_iterator_image_buffer_yields_once() {
1000        let src = Source::ImageBuffer(image::DynamicImage::new_rgb8(4, 4));
1001        let mut it = SourceIterator::new(src).unwrap();
1002        assert!(it.next().is_some());
1003        assert!(it.next().is_none());
1004    }
1005
1006    #[test]
1007    fn test_iterator_array_yields_once() {
1008        let src = Source::Array(Array3::<u8>::zeros((4, 4, 3)));
1009        let mut it = SourceIterator::new(src).unwrap();
1010        let first = it.next().unwrap();
1011        assert!(first.is_ok());
1012        assert!(it.next().is_none());
1013    }
1014
1015    #[test]
1016    fn test_iterator_over_directory() {
1017        let tmp = tempfile::tempdir().unwrap();
1018        write_image(&tmp.path().join("a.jpg"));
1019        write_image(&tmp.path().join("b.png"));
1020
1021        let src = Source::Directory(tmp.path().to_path_buf());
1022        let it = SourceIterator::new(src).unwrap();
1023        let count = it.flatten().count();
1024        assert_eq!(count, 2);
1025    }
1026
1027    #[test]
1028    fn test_iterator_image_list_and_missing_file() {
1029        let tmp = tempfile::tempdir().unwrap();
1030        let good = tmp.path().join("a.jpg");
1031        write_image(&good);
1032        let missing = tmp.path().join("missing.jpg");
1033
1034        let src = Source::ImageList(vec![good, missing]);
1035        let mut it = SourceIterator::new(src).unwrap();
1036        assert!(it.next().unwrap().is_ok()); // good image decodes
1037        assert!(it.next().unwrap().is_err()); // missing path errors, no panic
1038        assert!(it.next().is_none());
1039    }
1040
1041    #[cfg(feature = "video")]
1042    #[test]
1043    fn test_iterator_over_video_file() {
1044        use crate::io::VideoWriter;
1045
1046        // Encode a short real mp4, then decode it back through the iterator.
1047        let tmp = tempfile::tempdir().unwrap();
1048        let path = tmp.path().join("clip.mp4");
1049        let mut writer = VideoWriter::new(&path, 32, 32, 10.0).unwrap();
1050        for _ in 0..5 {
1051            writer
1052                .write_frame(&image::DynamicImage::new_rgb8(32, 32))
1053                .unwrap();
1054        }
1055        writer.finish().unwrap();
1056
1057        let src = Source::Video(path);
1058        assert!(src.is_video());
1059        let mut it = SourceIterator::new(src).unwrap();
1060        // Decode at least the first frame back as an image.
1061        let (frame, _meta) = it.next().expect("a frame").expect("decodes");
1062        assert_eq!(frame.width(), 32);
1063        // Drain the rest; the iterator terminates cleanly at end-of-stream.
1064        let mut decoded = 1;
1065        for item in it.by_ref() {
1066            if item.is_ok() {
1067                decoded += 1;
1068            }
1069        }
1070        assert!(decoded >= 1);
1071        assert!(it.next().is_none());
1072    }
1073}