Repository: ultralytics/ultralytics

Issue:
Inference of the same frame multiple times with RTSP stream
### Search before asking

- [X] I have searched the YOLOv8 [issues](https://github.com/ultralytics/ultralytics/issues) and found no similar bug report.


### YOLOv8 Component

Detection

### Bug

When I iterate over the `model.predict` results with an RTSP stream, YOLO seems to infer over the last image over and over again until it receives a new one (the `result.orig_imag` are the same), generating over 20 FPS inferred with a camera that is limited to 1 FPS. Reading with OpenCV it does read at 1 FPS.

### Environment

- YOLO: Ultralytics YOLOv8.0.128 🚀 Python-3.11.3 torch-2.0.1 CPU
- OS: macOS Ventura 13.4 (22F66)

### Minimal Reproducible Example

```python
from time import monotonic

import cv2
from ultralytics import YOLO

RTSP_URL = 'rtsp://…'
TIMEOUT = 60


def main():
    model = YOLO('yolov8n.pt')
    count = 0
    start = monotonic()

    # 1. YOLO
    # 1/1: rtsp://… Success ✅ (inf frames of shape 1024x576 at 1.00 FPS)
    for result in model.predict(source=RTSP_URL, classes=[0], stream=True, device='mps'):
        count += 1
        if monotonic() - start >= TIMEOUT:
            break

    print(f'{round(count / TIMEOUT, 2)} FPS')  # 24.07 FPS ???

    # 2. OpenCV
    cap = cv2.VideoCapture(RTSP_URL)
    count = 0
    start = monotonic()

    try:
        while monotonic() - start < TIMEOUT:
            cap.read()
            count += 1
    finally:
        cap.release()

    print(f'{round(count / TIMEOUT, 2)} FPS')  # 1.0 FPS


if __name__ == '__main__':
    main()
```

### Additional

_No response_

### Are you willing to submit a PR?

- [x] Yes I'd like to help by submitting a PR!


Discussion:

Resolving patch:
diff --git a/ultralytics/yolo/data/dataloaders/stream_loaders.py b/ultralytics/yolo/data/dataloaders/stream_loaders.py
--- a/ultralytics/yolo/data/dataloaders/stream_loaders.py
+++ b/ultralytics/yolo/data/dataloaders/stream_loaders.py
@@ -39,7 +39,7 @@ def __init__(self, sources='file.streams', imgsz=640, vid_stride=1):
         sources = Path(sources).read_text().rsplit() if os.path.isfile(sources) else [sources]
         n = len(sources)
         self.sources = [ops.clean_str(x) for x in sources]  # clean source names for later
-        self.imgs, self.fps, self.frames, self.threads = [None] * n, [0] * n, [0] * n, [None] * n
+        self.imgs, self.fps, self.frames, self.threads, self.shape = [[]] * n, [0] * n, [0] * n, [None] * n, [None] * n
         for i, s in enumerate(sources):  # index, source
             # Start thread to read frames from video stream
             st = f'{i + 1}/{n}: {s}... '
@@ -59,9 +59,11 @@ def __init__(self, sources='file.streams', imgsz=640, vid_stride=1):
             self.frames[i] = max(int(cap.get(cv2.CAP_PROP_FRAME_COUNT)), 0) or float('inf')  # infinite stream fallback
             self.fps[i] = max((fps if math.isfinite(fps) else 0) % 100, 0) or 30  # 30 FPS fallback
 
-            success, self.imgs[i] = cap.read()  # guarantee first frame
-            if not success or self.imgs[i] is None:
+            success, im = cap.read()  # guarantee first frame
+            if not success or im is None:
                 raise ConnectionError(f'{st}Failed to read images from {s}')
+            self.imgs[i].append(im)
+            self.shape[i] = im.shape
             self.threads[i] = Thread(target=self.update, args=([i, cap, s]), daemon=True)
             LOGGER.info(f'{st}Success ✅ ({self.frames[i]} frames of shape {w}x{h} at {self.fps[i]:.2f} FPS)')
             self.threads[i].start()
@@ -74,17 +76,20 @@ def update(self, i, cap, stream):
         """Read stream `i` frames in daemon thread."""
         n, f = 0, self.frames[i]  # frame number, frame array
         while cap.isOpened() and n < f:
-            n += 1
-            cap.grab()  # .read() = .grab() followed by .retrieve()
-            if n % self.vid_stride == 0:
-                success, im = cap.retrieve()
-                if success:
-                    self.imgs[i] = im
-                else:
-                    LOGGER.warning('WARNING ⚠️ Video stream unresponsive, please check your IP camera connection.')
-                    self.imgs[i] = np.zeros_like(self.imgs[i])
-                    cap.open(stream)  # re-open stream if signal was lost
-            time.sleep(0.0)  # wait time
+            # Only read a new frame if the buffer is empty
+            if not self.imgs[i]:
+                n += 1
+                cap.grab()  # .read() = .grab() followed by .retrieve()
+                if n % self.vid_stride == 0:
+                    success, im = cap.retrieve()
+                    if success:
+                        self.imgs[i].append(im)  # add image to buffer
+                    else:
+                        LOGGER.warning('WARNING ⚠️ Video stream unresponsive, please check your IP camera connection.')
+                        self.imgs[i].append(np.zeros(self.shape[i]))
+                        cap.open(stream)  # re-open stream if signal was lost
+            else:
+                time.sleep(0.01)  # wait until the buffer is empty
 
     def __iter__(self):
         """Iterates through YOLO image feed and re-opens unresponsive streams."""
@@ -92,14 +97,18 @@ def __iter__(self):
         return self
 
     def __next__(self):
-        """Returns source paths, transformed and original images for processing YOLOv5."""
+        """Returns source paths, transformed and original images for processing."""
         self.count += 1
-        if not all(x.is_alive() for x in self.threads) or cv2.waitKey(1) == ord('q'):  # q to quit
-            cv2.destroyAllWindows()
-            raise StopIteration
 
-        im0 = self.imgs.copy()
-        return self.sources, im0, None, ''
+        # Wait until a frame is available in each buffer
+        while not all(self.imgs):
+            if not all(x.is_alive() for x in self.threads) or cv2.waitKey(1) == ord('q'):  # q to quit
+                cv2.destroyAllWindows()
+                raise StopIteration
+            time.sleep(1 / min(self.fps))
+
+        # Get and remove the next frame from imgs buffer
+        return self.sources, [x.pop(0) for x in self.imgs], None, ''
 
     def __len__(self):
         """Return the length of the sources object."""


Test patch: