GÖREV KAYDI

GÖREV KAYDI

KAYIT AÇIK

ATILIM UAV · YER KONTROL İSTASYONU

GÖREV KAYDI / DETAY

Arşiv

CANLI · OKUMA MODU

Yazılım

Python'da RTSP Video Akışını İşlemek: Gecikme ve Buffer Sorunları

Python'da RTSP Video Akışını İşlemek: Gecikme ve Buffer Sorunları

Akış işlemeyi geride bıraktığında kare kuyruğunun büyümesi ve buna bağlı gecikme

FIG. 01

KAYIT KÜNYESİ

Kategori

Yazılım

TARİH

TÜM KAYITLAR

İlk kez bir saha testinde fark ettik: tespit kutusu ekranda, İHA hedefin çoktan üzerinden geçtikten uzun süre sonra belirdi. Model çalışıyordu, kutu da doğru yerdeydi; ama o "doğru yer" birkaç saniye öncesinin dünyasıydı. Uçuş ilerledikçe fark büyümeye devam etti; ilk birkaç saniyede göze batmıyordu, birkaç dakika sonra ise video tamamen geçmişte yaşıyordu.

Bu, tespit modelinin değil, video okuma katmanının sorunu. RTSP akışı kareleri sabit bir hızda üretir; sizin döngünüz ise model çıkarımı yaptığı için daha yavaş dönebilir. Aradaki fark bir yerde birikmek zorunda; o yer de siz istesiniz ya da istemeyin var olan bir kuyruk. Kuyruk hiç boşalmadığı sürece cap.read() size sıradaki en yeni kareyi değil, en eski kareyi verir.

Bu yazıda SUAS 2026 hava aracımızda kullandığımız çözümü anlatıyoruz: SIYI A8 mini gimbal kameradan gelen 1080p RTSP akışını Jetson Orin NX üzerinde gecikmeyi biriktirmeden nasıl okuyoruz, hangi ayarlar gerçekten bir işe yarıyor ve hangileri sessizce hiçbir şey yapmıyor.

RTSP nedir, İHA kameralarında neden karşımıza çıkar?

RTSP (Real Time Streaming Protocol) video verisinin kendisini taşımaz; oturumu yönetir: "oynat", "duraklat" ve "bana şu akışı gönder" gibi komutların protokolüdür. Asıl video paketleri RTP (Real-time Transport Protocol) üzerinden akar. Kamera bir RTSP sunucusu çalıştırır, siz de ona bir URL ile bağlanırsınız.

İHA kameralarında bu kadar yaygın olmasının nedeni pratik: gimbal kamera companion bilgisayara Ethernet üzerinden bağlanır ve aynı akışı tek bir adresten hem yer kontrol istasyonuna hem de tespit koduna sunar. ArduPilot dokümantasyonunun SIYI A8 mini ve ZR10 için verdiği adres rtsp://192.168.144.25:8554/main.264, varsayılan IP ise 192.168.144.25.

Akışı OpenCV ile açmak

OpenCV'nin FFmpeg arka ucu RTSP'yi doğrudan destekler. FFmpeg'e gidecek seçenekler OPENCV_FFMPEG_CAPTURE_OPTIONS ortam değişkeni üzerinden aktarılır. Biçim alışılmadıktır: anahtarı değerinden bir noktalı virgül, bir çifti diğerinden bir dikey çizgi ayırır.

import os

# Must be set BEFORE the VideoCapture is created: OpenCV reads this
# variable while it opens the stream. Format: "key;value|key;value"
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = (
    "rtsp_transport;tcp"
    "|fflags;nobuffer"          # reduces buffering during the initial analysis
    "|max_delay;300000"         # microseconds -> 0.3 s
    "|reorder_queue_size;0"     # do not wait on out-of-order packets
    "|probesize;100000"         # default is 5,000,000 bytes
    "|analyzeduration;500000"   # default is 5,000,000 us (5 s)
)

import cv2

URL = "rtsp://192.168.144.25:8554/main.264"
cap = cv2.VideoCapture(URL, cv2.CAP_FFMPEG)
print(cap.isOpened(), cap.get(cv2.CAP_PROP_FRAME_WIDTH), cap.get(cv2.CAP_PROP_FPS))
import os

# Must be set BEFORE the VideoCapture is created: OpenCV reads this
# variable while it opens the stream. Format: "key;value|key;value"
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = (
    "rtsp_transport;tcp"
    "|fflags;nobuffer"          # reduces buffering during the initial analysis
    "|max_delay;300000"         # microseconds -> 0.3 s
    "|reorder_queue_size;0"     # do not wait on out-of-order packets
    "|probesize;100000"         # default is 5,000,000 bytes
    "|analyzeduration;500000"   # default is 5,000,000 us (5 s)
)

import cv2

URL = "rtsp://192.168.144.25:8554/main.264"
cap = cv2.VideoCapture(URL, cv2.CAP_FFMPEG)
print(cap.isOpened(), cap.get(cv2.CAP_PROP_FRAME_WIDTH), cap.get(cv2.CAP_PROP_FPS))

probesize ve analyzeduration varsayılanları akışın açılmasını uzatır; bu değerleri düşürmek ilk kareye kadar geçen süreyi kısaltır. Fazla düşürürseniz FFmpeg akış biçimini çözemez; o yüzden kademeli olarak azaltın ve isOpened() ne döndürüyor, ona bakın.

Asıl sorun: kuyruk büyür, video geride kalır

Basit aritmetik: akış 30 FPS üretiyorsa (kare başına ~33 ms) ve döngünüz saniyede 15 kare işliyorsa (kare başına ~67 ms), her saniye 15 kare geriye düşersiniz. 10 saniye sonra kuyrukta 150 kare birikir; bu da kabaca 5 saniyelik gecikme demektir. Bizim uçuş konfigürasyonumuzda YOLO11m, 640 piksel tam-kare çıkarımda 15-20 FPS çalışıyor; akış hızı bunun üzerinde kaldığı sürece fark kaçınılmaz olarak birikiyor.

Önemli olan şu: bu sabit bir gecikme değil, büyüyen bir gecikme. Sabit 200 ms'lik bir gecikmeyle yaşayabilirsiniz; her dakika büyüyen bir gecikmeyle otonom bırakma görevi uçuramazsınız.



RTSP kare kuyruğunun zaman içinde büyümesi

Çözüm: yalnızca en yeni kareyi tutan bir okuyucu thread

Doğru zihinsel model şu: akışa bir kuyruk gibi değil, tek gözlü bir kutu gibi davranın. Arka planda çalışan bir thread sürekli okur ve her yeni kareyle bir öncekinin üzerine yazar. İşleme döngüsü kutuya her baktığında en güncel kareyi bulur; arada kaçırdığı kareler sessizce düşer. Bir tespit sisteminde tam olarak istediğimiz davranış budur: tazeliği kaybetmektense kare kaybetmeyi tercih ederiz.

import threading
import time
import cv2

class FreshestFrame:
    """Reads an RTSP stream in the background, keeps only the LATEST frame.

    No queue: every new frame overwrites the previous one. If processing is
    slow, old frames are dropped, so latency never accumulates.
    """

    def __init__(self, url, backend=cv2.CAP_FFMPEG, reconnect_delay=2.0):
        self.url = url
        self.backend = backend
        self.reconnect_delay = reconnect_delay

        self._lock = threading.Lock()
        self._frame = None      # latest frame (BGR ndarray)
        self._seq = 0           # counter: keeps us from processing a frame twice
        self._stamp = 0.0       # when the frame was captured (monotonic)
        self._running = True

        self._cap = self._open()
        self._thread = threading.Thread(target=self._loop, daemon=True)
        self._thread.start()

    def _open(self):
        cap = cv2.VideoCapture(self.url, self.backend)
        # A no-op on the FFMPEG backend; works on backends like GStreamer/V4L2.
        cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
        return cap

    def _loop(self):
        fails = 0
        while self._running:
            if self._cap is None or not self._cap.isOpened():
                time.sleep(self.reconnect_delay)
                self._cap = self._open()
                continue

            ok, frame = self._cap.read()
            if not ok:
                fails += 1
                if fails >= 5:              # not a transient glitch, the link is down
                    self._cap.release()
                    self._cap = None
                    fails = 0
                continue

            fails = 0
            with self._lock:
                self._frame = frame
                self._seq += 1
                self._stamp = time.monotonic()

    def read(self, last_seq=0, timeout=1.0):
        """Return the latest frame; if last_seq is given, wait for a NEW one."""
        deadline = time.monotonic() + timeout
        while True:
            with self._lock:
                if self._frame is not None and self._seq > last_seq:
                    return self._seq, self._frame, self._stamp
            if time.monotonic() > deadline:
                return last_seq, None, 0.0
            time.sleep(0.002)

    def release(self):
        self._running = False
        self._thread.join(timeout=2.0)
        if self._cap is not None:
            self._cap.release()
import threading
import time
import cv2

class FreshestFrame:
    """Reads an RTSP stream in the background, keeps only the LATEST frame.

    No queue: every new frame overwrites the previous one. If processing is
    slow, old frames are dropped, so latency never accumulates.
    """

    def __init__(self, url, backend=cv2.CAP_FFMPEG, reconnect_delay=2.0):
        self.url = url
        self.backend = backend
        self.reconnect_delay = reconnect_delay

        self._lock = threading.Lock()
        self._frame = None      # latest frame (BGR ndarray)
        self._seq = 0           # counter: keeps us from processing a frame twice
        self._stamp = 0.0       # when the frame was captured (monotonic)
        self._running = True

        self._cap = self._open()
        self._thread = threading.Thread(target=self._loop, daemon=True)
        self._thread.start()

    def _open(self):
        cap = cv2.VideoCapture(self.url, self.backend)
        # A no-op on the FFMPEG backend; works on backends like GStreamer/V4L2.
        cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
        return cap

    def _loop(self):
        fails = 0
        while self._running:
            if self._cap is None or not self._cap.isOpened():
                time.sleep(self.reconnect_delay)
                self._cap = self._open()
                continue

            ok, frame = self._cap.read()
            if not ok:
                fails += 1
                if fails >= 5:              # not a transient glitch, the link is down
                    self._cap.release()
                    self._cap = None
                    fails = 0
                continue

            fails = 0
            with self._lock:
                self._frame = frame
                self._seq += 1
                self._stamp = time.monotonic()

    def read(self, last_seq=0, timeout=1.0):
        """Return the latest frame; if last_seq is given, wait for a NEW one."""
        deadline = time.monotonic() + timeout
        while True:
            with self._lock:
                if self._frame is not None and self._seq > last_seq:
                    return self._seq, self._frame, self._stamp
            if time.monotonic() > deadline:
                return last_seq, None, 0.0
            time.sleep(0.002)

    def release(self):
        self._running = False
        self._thread.join(timeout=2.0)
        if self._cap is not None:
            self._cap.release()

Kullanımı, kare yaşı da ölçülerek:

stream = FreshestFrame("rtsp://192.168.144.25:8554/main.264")
seq = 0
try:
    while True:
        seq, frame, stamp = stream.read(last_seq=seq, timeout=2.0)
        if frame is None:
            continue                       # no stream, do not spin the loop
        age_ms = (time.monotonic() - stamp) * 1000.0
        results = model.predict(frame, imgsz=640, conf=0.25, verbose=False)
        print(f"frame {seq} | age when processing starts: {age_ms:.0f} ms")
finally:
    stream.release()
stream = FreshestFrame("rtsp://192.168.144.25:8554/main.264")
seq = 0
try:
    while True:
        seq, frame, stamp = stream.read(last_seq=seq, timeout=2.0)
        if frame is None:
            continue                       # no stream, do not spin the loop
        age_ms = (time.monotonic() - stamp) * 1000.0
        results = model.predict(frame, imgsz=640, conf=0.25, verbose=False)
        print(f"frame {seq} | age when processing starts: {age_ms:.0f} ms")
finally:
    stream.release()

Bu yazının en değerli satırı belki de şu age_ms satırı: gecikmeyi tahmin etmeyi bırakıp ölçmeye başlıyorsunuz. Sayı sabit kalıyorsa sorun yok; tırmanmaya devam ediyorsa bir yerde hâlâ kuyruk var.

CAP_PROP_BUFFERSIZE çoğu zaman neden hiçbir işe yaramaz?

İnternette en sık önerilen çözüm cap.set(cv2.CAP_PROP_BUFFERSIZE, 1). Oysa OpenCV 4.x kaynağında FFmpeg arka ucunun setProperty fonksiyonu bu özelliği hiç ele almaz. Yalnızca CAP_PROP_POS_MSEC, CAP_PROP_POS_FRAMES, CAP_PROP_POS_AVI_RATIO, CAP_PROP_FORMAT ve CAP_PROP_CONVERT_RGB işlenir. Yani RTSP + FFmpeg ikilisinde bu çağrı sessizce hiçbir şey yapmaz ve set() geriye False döner.

Yine de yukarıdaki sınıfta bıraktık: aynı kod bir USB kamerada (V4L2) ya da GStreamer arka ucunda çalıştığında etkisini gösteriyor. Kural şu: her set() çağrısının dönüş değerini mutlaka kontrol edin. OpenCV desteklenmeyen bir özellik için hata fırlatmaz, sadece False döner.

FFmpeg seçenekleri ve TCP/UDP kararı

Seçenek

Ne yapar

Ne zaman kullanılır

rtsp_transport

Taşıma katmanını seçer: udp, tcp, udp_multicast, http, https

Varsayılanı bilerek ezmek istediğinizde

rtsp_flags;prefer_tcp

Mümkün olduğunda önce TCP'yi dener

OpenCV zaten varsayılan olarak bunu yapıyor

fflags;nobuffer

Akış analizi sırasındaki tamponlamayı azaltır

Her canlı akışta

max_delay

Demux gecikmesinin üst sınırı (mikrosaniye)

Kayıp bir pakette takılıp kalmasını istemediğinizde

reorder_queue_size

Sırasız paketleri yeniden sıralamak için tamponlanan paket sayısı

UDP'de tazelik isteyip kaybı göze alabildiğinizde

buffer_size

Soket tamponunun bayt cinsinden üst sınırı

Yüksek bit hızlı akışlarda paket düşerken

timeout

Soket TCP G/Ç zaman aşımı (mikrosaniye)

Ölü bir bağlantı sonsuza kadar bloklamasın diye

rtsp:// adresleri için OpenCV, libavformat sürümüne göre varsayılan olarak ya rtsp_flags=prefer_tcp (yeni libavformat) ya da rtsp_transport=tcp (eskiler) uygular. Yani hiçbir şey yapmasanız da zaten TCP üzerindesiniz.



TCP

UDP

Paket kaybı

Yeniden gönderilir, kare bozulmaz

Kayıp yerinde kalır, blok bozulmaları görürsünüz

Gecikme davranışı

Paket kaybolduğu anda gecikme sıçrar

Kayba rağmen gecikme sabit kalır

Ağ geçidi/NAT

Genelde sorun çıkarmaz

Engellenebilir

Ne zaman

Kaybın az olduğu kablolu Ethernet'te; ayrıca kayıt alırken

Telsiz ya da paylaşımlı linklerde, tazelik kare bütünlüğünden daha önemliyken

Hava aracında gimbal ile companion bilgisayar arasındaki bağlantı kablolu Ethernet olduğu için TCP'de kaldık; kayıp zaten sıfıra yakınken UDP'nin sunduğu avantaj ortadan kalkıyor.

Yeniden bağlanma: uçuşta kaçınılmaz

Kamera yeniden başlar, bir kabloda mikro kopma olur, switch bir paketi yutar. Yukarıdaki _loop bunu hallediyor: art arda 5 başarısız read() çağrısından sonra capture nesnesini bırakıp bir sonraki turda yeniden açıyor. Tek bir False dönüşünün bağlantıyı yıkmaması önemli; geçici kare hataları normaldir.

Bir uyarı: OpenCV'nin read() ve açma çağrıları bloklayıcıdır ve varsayılan bekleme süreleri uzundur. CAP_PROP_OPEN_TIMEOUT_MSEC ve CAP_PROP_READ_TIMEOUT_MSEC bunları kısaltır, ama ikisi de yalnızca açılış anında geçerlidir: parametre listesinde verilmeleri gerekir, örneğin cv2.VideoCapture(url, cv2.CAP_FFMPEG, [cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000]) şeklinde; sonradan set() ile değiştirilemezler. Bazı opencv-python sürümleri bu sabitleri sunmaz, o yüzden hasattr(cv2, "CAP_PROP_OPEN_TIMEOUT_MSEC") ile kontrol edin.

Sık düşülen tuzaklar

  • cap.set(CAP_PROP_BUFFERSIZE, 1): bunu yazıp sorunun çözüldüğünü sanmak. FFmpeg arka ucunda hiçbir etkisi yok; dönüş değerini yazdırın.

  • Ortam değişkenini cv2.VideoCapture çağrısından sonra ayarlamak. Değişken akış açılırken okunur; sonradan yapılan bir değişiklik o oturumu etkilemez.

  • Ayraçları karıştırmak. Doğrusu rtsp_transport;tcp|fflags;nobuffer; virgül ya da = kullanmak seçeneğin sessizce yok sayılmasına yol açar.

  • Kodek seviyesindeki bayrakları demuxer sözlüğüne koymak. OPENCV_FFMPEG_CAPTURE_OPTIONS içeriği akış açılışına (avformat_open_input) gider. ffplay -flags low_delay gibi kodek bayraklarının bu yoldan geçtiğini varsaymayın; ölçmeden etkisine inanmayın.

  • Kuyruğu bir grab() döngüsüyle "boşaltmaya" çalışmak. Sabit sayıda kareyi atmak yalnızca o andaki hız farkına denk gelir; hız değiştiğinde ya gecikme geri gelir ya da boşuna CPU yakarsınız. Thread yaklaşımı ise kendini ayarlar.

  • Kare yaşını hiç ölçmemek. Göz kararı "gecikmeli görünüyor" demek yerine tek satırlık bir zaman damgası ekleyin; hangi değişikliğin gerçekten işe yaradığını ancak böyle görürsünüz.

  • Jetson'da yazılım kod çözücüde kalmak. Orin NX'te donanım kod çözücüyü kullanan bir GStreamer pipeline'ı (rtspsrc latency=0 ! rtph264depay ! h264parse ! nvv4l2decoder ! nvvidconv ! video/x-raw,format=BGRx ! videoconvert ! video/x-raw,format=BGR ! appsink drop=true max-buffers=1) CPU'yu tespit modeline bırakır; appsink üzerindeki drop=true max-buffers=1 ise aynı "en yeni kare" mantığını pipeline'ın içine taşır.

Pratik özet

  1. Akışı ana döngünüzde okumayın. Arka planda okuyup en yeni kareyi tek gözlü bir kutuda tutan bir thread kurun; kare düşürmek gecikme biriktirmekten iyidir.

  2. Her karenin yakalanma zamanını saklayın ve işleme başlarken yaşını milisaniye cinsinden yazdırın. Sabit bir sayı iyidir; artan bir sayı hâlâ kuyruk olduğu anlamına gelir.

  3. FFmpeg seçeneklerini, capture nesnesini oluşturmadan önce OPENCV_FFMPEG_CAPTURE_OPTIONS üzerinden key;value|key;value biçiminde geçirin. probesize ve analyzeduration değerlerini düşürmek ilk kareye daha hızlı ulaşmanızı sağlar.

  4. Kablolu Ethernet üzerinde TCP'de kalın (OpenCV zaten varsayılan olarak TCP'yi tercih ediyor). UDP'yi yalnızca telsiz ya da paylaşımlı bir linkte ve yalnızca kare bütünlüğünden vazgeçebiliyorsanız düşünün.

  5. Yeniden bağlanmayı en baştan yazın: capture'ı tek bir read() hatasında değil, art arda birkaç başarısızlıktan sonra bırakıp yeniden açın; açma/okuma zaman aşımlarını constructor parametresi üzerinden kısaltın.

©2026 ATILIM UAV TEAM

©2026 ATILIM UAV TEAM