Abdulaziz Akyol

Python ve YOLO ile gerçek zamanlı nesne tespiti: RTSP kameradan olaya

Görüntü işleme · Yazılım geliştirme
25 Eylül 2026 · 8 dk okuma · Abdulaziz Akyol

Gerçek zamanlı nesne tespiti, bir kamera akışındaki karelerde nesnelerin sınıfını ve konumunu milisaniyeler içinde bulan işlemdir; tek başına bir sonuç değil, “kaç kişi girdi” ya da “yasak alanda biri var” gibi olaylara giden zincirin ortasındaki halkadır. Bu yazıda Python, OpenCV ve Ultralytics YOLO ile RTSP kameradan MQTT’ye giden zinciri çalışır bir betikle, parça parça kuruyoruz.

Zincirin mimarisini ve sahada nerede tıkandığını RTSP akışından olaya giden zincir yazısında anlattım. Burada aynı zinciri koda döküyorum: hangi satır neden orada, hangi varsayılan sizi sahada yanıltır.

Ne kuracağız?

Betiğin yaptığı iş beş adımdan oluşuyor:

  1. RTSP akışını ayrı bir iş parçacığında okumak ve yalnızca en son kareyi tutmak.
  2. Seçilen karelerde YOLO ile kişi tespiti yapmak.
  3. ByteTrack ile her kişiye kareler boyunca korunan bir iz kimliği vermek.
  4. İzler üzerinde iki kural çalıştırmak: çizgi geçişi (giriş/çıkış sayımı) ve bölge ihlali (yasak alanda belirli süreden uzun kalma).
  5. Kural tetiklenince görüntü içermeyen bir JSON olayı MQTT’ye ve istenirse bir webhook’a göndermek.
BileşenKütüphaneGörev
Akış okumaOpenCV (FFmpeg arka ucu)RTSP’yi açmak, kareyi çözmek, yeniden bağlanmak
Tespit ve takipUltralytics YOLO + ByteTrackKutular ve iz kimlikleri
KurallarSaf Python + cv2.pointPolygonTestÇizgi ve poligon mantığı
Olay çıkışıpaho-mqtt 2.x, urllibMQTT yayını ve webhook

Kodu yazmadan önce: lisans

Ultralytics YOLO AGPL-3.0 lisansıyla dağıtılıyor. Ultralytics’in lisans sayfasına göre bu lisansla uyum, uygulamanın tamamı dahil türetilmiş işin kaynak kodunu yayımlamak anlamına geliyor ve eğitilmiş modeller de varsayılan olarak AGPL-3.0’a tabi. Kapalı kaynak bir ürün, SaaS ya da uç cihaza gömülü bir sistem geliştiriyorsanız kurumsal lisans gerekiyor.

Benim önerim, lisans kararını ilk hafta vermek. Prototip AGPL ile rahatça yapılır; ama müşteriye teslim edilecek sistemde lisans sonradan fark edilirse ya kaynak kodu açmak ya da modeli baştan değiştirmek gerekir. Başka bir dedektör seçerseniz kodun lisansıyla ağırlıkların lisansını ayrı ayrı kontrol edin.

Adım 1: Ortamı kurmak

Python 3.10 ve üstü yeterli. ultralytics paketi PyTorch’u ve OpenCV’yi bağımlılık olarak getiriyor; MQTT için paho-mqtt ekliyoruz.

python3 -m venv .venv && source .venv/bin/activate
pip install ultralytics paho-mqtt
python -c "import torch; print('CUDA:', torch.cuda.is_available())"
python -c "import cv2; print([s.strip() for s in cv2.getBuildInformation().splitlines() if 'FFMPEG' in s])"

Son satır önemli: OpenCV’nin RTSP’yi FFmpeg arka ucuyla açabilmesi için derlemede FFmpeg desteğinin YES görünmesi gerekir. Her platformun hazır paketinde bu destek olmayabilir; sahaya çıkmadan önce hedef makinede kontrol edin.

Adım 2: RTSP akışını güvenilir okumak

İlk karar ana akış mı alt akış mı. Kişi sayma ve bölge ihlali gibi işler genellikle alt akışla çalışır; hem ağ trafiği hem kod çözme yükü düşer.

İkinci karar taşıma protokolü. OpenCV’nin FFmpeg arka ucu, OPENCV_FFMPEG_CAPTURE_OPTIONS ortam değişkenini anahtar;değer|anahtar;değer biçiminde okur. Değişken boşsa TCP’yi tercih eden bir bayrak kullanıyor; rtsp_transport;tcp yazmak davranışı açık ve sürümden bağımsız kılıyor. Değişkeni cv2 içe aktarılmadan önce ayarlayın.

Üçüncüsü zaman aşımı. Kamera kapandığında read() çağrısının ne kadar bekleyeceğini CAP_PROP_OPEN_TIMEOUT_MSEC ve CAP_PROP_READ_TIMEOUT_MSEC belirler; bu iki özellik yalnızca FFmpeg ve GStreamer arka uçlarında geçerli ve açılışta parametre olarak verilmesi gerekiyor. Verilmezse OpenCV kaynak kodundaki varsayılan 30 saniye. Sahada 30 saniyelik sessiz bir bekleme, “sistem dondu” şikâyeti demektir.

En sık yapılan hata gecikmeyle ilgili. cap.read() kareleri sırayla verir; işlemeniz kameranın kare hızından yavaşsa aradaki fark birikir ve bir süre sonra birkaç saniye önceki görüntüyü işliyor olursunuz. CAP_PROP_BUFFERSIZE burada çözüm değil: OpenCV kaynak kodunda FFmpeg arka ucu bu özelliği hiç işlemiyor, özellik V4L2 gibi yerel kamera arka uçları için var. Çözüm, okumayı ayrı bir iş parçacığına almak ve yalnızca son kareyi saklamak:

cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG, [
    cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000,
    cv2.CAP_PROP_READ_TIMEOUT_MSEC, 5000,
])
while not stop.is_set():
    ok, frame = cap.read()
    if not ok:
        break                      # dış döngü üstel beklemeyle yeniden bağlanır
    with lock:
        latest_frame, seq = frame, seq + 1

H.264 ve H.265’te kareler birbirine bağımlı olduğu için okuyucu her kareyi çözmek zorunda; ama çıkarım yalnızca son kare üzerinde yapılır. Bağlantı koptuğunda okuyucu 1 saniyeden başlayıp 30 saniyeye kadar ikiye katlanan aralıklarla yeniden dener. RTSP adresindeki kullanıcı adı ve parolayı koda yazmayın, ortam değişkeninden okuyun ve adresi loglara basmayın.

Adım 3: YOLO ile tespit, ByteTrack ile takip

Ultralytics’in güncel model ailesi YOLO26; n, s, m, l, x ölçekleri var. Sahada genellikle en küçük ölçekle başlayıp doğruluk yetmiyorsa büyütmek doğru sıra.

from ultralytics import YOLO

model = YOLO("yolo26n.pt")         # her kamera için ayrı model nesnesi
result = model.track(frame, persist=True, tracker="bytetrack.yaml",
                     classes=[0], conf=0.35, device=0, verbose=False)[0]
if result.boxes and result.boxes.is_track:
    ids = result.boxes.id.int().cpu().tolist()
    boxes = result.boxes.xyxy.cpu().tolist()

Dört ayrıntı önemli:

  • persist=True, izleyiciye bu karenin aynı akışın bir sonraki karesi olduğunu söyler. Ultralytics dokümanı bunun ilgisiz görüntülerde ya da farklı akışlarda kullanılmamasını açıkça belirtiyor; bu yüzden birden fazla kamerayı tek model nesnesinden geçirmeyin, izleyici durumu karışır.
  • İzleyiciyi açıkça yazın. ultralytics 8.4.63 ile yeni izleyiciler geldi ve tracker verilmediğinde kullanılan varsayılan artık TrackTrack. Sürüm yükseltmesinde davranışın sessizce değişmesini istemiyorsanız bytetrack.yaml gibi sabit bir değer verin.
  • Karede iz yoksa boxes.id boş gelir; is_track kontrolü bu durumu yakalar.
  • bytetrack.yaml içindeki track_buffer (varsayılan 30), kaybolan bir izin kaç kare boyunca bekletileceğidir ve izleyiciye verilen kareler üzerinden sayılır. Saniyede 8 kare işliyorsanız 30 kare yaklaşık 3,75 saniyeye denk gelir. Kare atlama oranını değiştirdiğinizde bu değeri de gözden geçirin.

Adım 4: Çizgi geçişi ile sayma

Sayımın temeli basit geometri: noktanın bir doğrunun hangi tarafında olduğu, çapraz çarpımın işaretiyle bulunur. Bir iz kimliğinin işareti değiştiğinde çizgi geçilmiş demektir. Pratikte üç ek koşul gerekiyor:

  • Hangi nokta? Eğik bakan kamerada kutunun alt ortası (ayak noktası), tepeden bakan kamerada kutunun merkezi. Kutunun merkezi eğik açıda kişinin boyuna göre kayar.
  • Doğru parçası, sonsuz doğru değil. Noktanın izdüşümü çizginin iki ucu arasında değilse geçiş sayılmaz; yoksa kapının yanındaki koridordan geçen de sayılır.
  • Titreşim. Kutu her karede birkaç piksel oynar. Çizgiye HYSTERESIS_PX mesafesinden yakın noktaları yok saymak ve aynı kimlik için 1 saniyelik bekleme koymak çift sayımı keser.
def signed_distance(p, a, b):
    abx, aby = b[0] - a[0], b[1] - a[1]
    cross = abx * (p[1] - a[1]) - aby * (p[0] - a[0])
    return cross / max((abx * abx + aby * aby) ** 0.5, 1e-9)

d = signed_distance(p, a, b)
if abs(d) >= HYSTERESIS_PX:
    side = 1 if d > 0 else -1
    if prev_side[tid] not in (None, side) and projects_onto_segment(p, a, b):
        direction = "in" if side > 0 else "out"

Yönü, çizginin uç noktalarının sırası belirler. İlk testte giriş ile çıkış ters çıkarsa A ile B’yi yer değiştirmeniz yeterli. Koordinatları 0–1 aralığında (normalize) tutmak, kamera alt akıştan ana akışa geçtiğinde çizgiyi yeniden çizme zahmetini ortadan kaldırıyor. Sayımın sahada ne kadar doğru olduğunu ölçmek ayrı bir iş: kabul testini, elle sayılmış gerçek geçişlere karşı yapın.

Adım 5: Bölge (poligon) ihlali ve süre kuralı

Bölge kuralında OpenCV’nin pointPolygonTest fonksiyonu işi görüyor: üçüncü parametre False iken içerideyse pozitif, kenardaysa sıfır, dışarıdaysa negatif döner.

inside = cv2.pointPolygonTest(poly, (float(x), float(y)), False) >= 0
if inside:
    t0 = entered_at.setdefault(tid, now)
    if tid not in alerted and now - t0 >= ZONE_MIN_SECONDS:
        alerted.add(tid)                     # aynı giriş için tek olay
        emit(make_event("zone.intrusion", ...))
else:
    entered_at.pop(tid, None); alerted.discard(tid)

Süre eşiği iki işe yarıyor: bölgenin kenarından geçen kişiyi ve tek karelik yanlış tespitleri eliyor. Aynı kişi bölgede kaldığı sürece tek olay üretilir; çıkıp yeniden girerse sayaç sıfırdan başlar.

Adım 6: Olayı JSON olarak MQTT’ye ya da webhook’a göndermek

Olay şeması, IoT ve kamera analitiğini aynı olay hattında birleştirdiğimiz mimarideki şemanın aynısı; yalnızca tekrar eden teslimatları ayıklamak için bir event_id ekledim:

{
  "event_id": "5f0c2a4e-8a51-4f0e-9d5c-2f1b7f3c9e10",
  "source": "cam-07",
  "type": "line.cross",
  "ts": "2026-09-25T09:14:03.120Z",
  "site": "gebze-01",
  "zone": "entrance",
  "confidence": 0.912,
  "rule": "line-entrance",
  "payload": {"direction": "in", "track_id": 42, "object": "person", "count_in": 118, "count_out": 97}
}

Konu cx/{site}/{kamera}/{olay tipi} biçiminde. paho-mqtt 2.x’te istemciyi oluştururken geri çağırma API sürümünü belirtmek gerekiyor; CallbackAPIVersion.VERSION2 ile on_connect imzası (client, userdata, flags, reason_code, properties) oluyor ve reason_code bir nesne. Bağlantının reddedilip reddedilmediğini reason_code.is_failure ile kontrol ediyorum.

Üç tasarım kararı var:

  • Çıkarım döngüsü ağı beklemez. Olaylar bir kuyruğa girer, ayrı bir iş parçacığı MQTT’ye yayınlar ve webhook’u zaman aşımı ile yeniden denemeli çağırır. Webhook sunucusu yavaşladığında kamera döngüsü yavaşlamaz.
  • QoS 1 ve bellek sınırı. paho, bağlantı yokken QoS 1 mesajları bellekte tutar ve bağlanınca gönderir. max_queued_messages_set(1000) bu kuyruğa üst sınır koyar. QoS 1 “en az bir kez” teslim ettiği için tüketici tarafı event_id ile tekrarları ayıklamalı.
  • Durum konusu. cx/{site}/{kamera}/status konusuna retain bayraklı online yazılır; son vasiyet (Last Will) mesajı offline. Panel, kameranın analitik servisinin ayakta olup olmadığını buradan görür.

Broker tarafında TLS, kullanıcı doğrulama ve konu bazlı yetki olmadan bu yapı sahaya çıkmamalı; ayrıntıları MQTT ile güvenli IoT yazısında anlattım.

Adım 7: Kare atlama ve GPU bütçesi

Betik kareleri zaman tabanlı seçiyor: PROCESS_FPS=8 ise en fazla 125 milisaniyede bir kare işleniyor, arada gelenler atlanıyor. “Her N. kare” yerine zamana bakmak, kamera kare hızı değiştiğinde ya da akış takıldığında da tutarlı sonuç veriyor.

Bütçe hesabı basit bir çarpım. 12 kamerayı saniyede 8 kareyle işlemek saniyede 96 çıkarım demek; tek GPU’da sırayla çalışıyorsa kare başına yaklaşık 10,4 milisaniyelik bütçe kalıyor. Model bu sürenin üstündeyse kaldıraçlar şunlar:

KaldıraçEtkisiBedeli
PROCESS_FPS düşürmekDoğrusal kazançHızlı olaylar kaçabilir, track_buffer gözden geçirilmeli
Alt akış / küçük imgszÇözme ve çıkarım ucuzlarUzak ve küçük nesnelerde doğruluk düşer
Küçük model ölçeğiÇıkarım hızlanırDoğruluk sahada ölçülmeli
TensorRT FP16/INT8Aynı modelle daha düşük gecikmeDerleme ve doğrulama adımı eklenir
Kameraları toplu (batch) işlemekGPU daha verimli kullanılırTek kamera gecikmesi artar, kod karmaşıklaşır

TensorRT derleme ve INT8 tarafını ONNX, TensorRT ve INT8 yazısında ayrıntılı işledim. Betik, dakikada bir işlenen kare hızını, yeniden bağlanma sayısını ve kuyruk uzunluğunu logluyor; hedef hızın altına düşüldüğünü ilk bu satırdan görürsünüz.

Tam betik

Aşağıdaki betik yukarıdaki parçaları birleştiriyor. Ayarlar ortam değişkenlerinden okunuyor; çizgi ve bölge dosyanın başında 0–1 koordinatlarıyla tanımlı.

#!/usr/bin/env python3
"""RTSP kamera -> YOLO + ByteTrack -> çizgi geçişi / bölge ihlali -> JSON olay (MQTT ve/veya webhook).

Kare diske yazılmaz, olaylara görüntü eklenmez. Ayarlar ortam değişkenlerinden okunur.
"""
from __future__ import annotations

import json
import logging
import os
import queue
import signal
import threading
import time
import urllib.request
import uuid
from datetime import datetime, timezone

# FFmpeg arka ucuna RTSP'yi TCP üzerinden açmasını söyle (cv2 içe aktarılmadan önce ayarlanmalı).
os.environ.setdefault("OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp")

import cv2  # noqa: E402
import numpy as np  # noqa: E402
import paho.mqtt.client as mqtt  # noqa: E402
from ultralytics import YOLO  # noqa: E402

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("cam")

# --- Ayarlar -----------------------------------------------------------------
RTSP_URL = os.environ["RTSP_URL"]  # rtsp://kullanici:parola@10.0.0.21:554/stream2
SITE = os.getenv("SITE", "gebze-01")
CAMERA_ID = os.getenv("CAMERA_ID", "cam-07")
MODEL_PATH = os.getenv("MODEL_PATH", "yolo26n.pt")  # TensorRT için .engine dosyası da olur
DEVICE = os.getenv("DEVICE", "0")  # "0" = ilk GPU, "cpu" = işlemci
PROCESS_FPS = float(os.getenv("PROCESS_FPS", "8"))
CONF = float(os.getenv("CONF", "0.35"))
CLASSES = [0]  # COCO: 0 = person
ANCHOR = os.getenv("ANCHOR", "bottom")  # "bottom" = ayak noktası, "center" = kutu merkezi

# Geometri 0-1 arası (normalize) koordinatlarla: çözünürlük değişse de geçerli kalır.
LINE = ((0.10, 0.60), (0.90, 0.60))  # sayım çizgisi A -> B
ZONE = [(0.60, 0.20), (0.95, 0.20), (0.95, 0.55), (0.60, 0.55)]  # yasak bölge
ZONE_MIN_SECONDS = 5.0  # bölgede bu süreyi aşan iz için olay üret
HYSTERESIS_PX = 4.0  # çizgiye bu mesafeden yakın noktalar yan değiştirmiş sayılmaz

MQTT_HOST = os.getenv("MQTT_HOST")  # boşsa MQTT kapalı
MQTT_PORT = int(os.getenv("MQTT_PORT", "8883"))
MQTT_CA = os.getenv("MQTT_CA")  # örn. /etc/cx/ca.crt; tanımlıysa TLS açılır
MQTT_USER = os.getenv("MQTT_USER")
MQTT_PASS = os.getenv("MQTT_PASS")
WEBHOOK_URL = os.getenv("WEBHOOK_URL")  # boşsa webhook kapalı

TOPIC_BASE = f"cx/{SITE}/{CAMERA_ID}"


# --- 1) RTSP okuyucu: ayrı iş parçacığı, yalnızca son kare ------------------------
class LatestFrameReader(threading.Thread):
    """Akışı sürekli okur, yalnızca en son kareyi tutar; kopunca üstel bekleme ile yeniden bağlanır."""

    def __init__(self, url: str, open_timeout_ms: int = 5000, read_timeout_ms: int = 5000):
        super().__init__(daemon=True, name="rtsp-reader")
        self.url = url
        self.params = [
            cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, open_timeout_ms,
            cv2.CAP_PROP_READ_TIMEOUT_MSEC, read_timeout_ms,
        ]
        self._lock = threading.Lock()
        self._frame: np.ndarray | None = None
        self._seq = 0
        self._stop_evt = threading.Event()
        self.reconnects = 0

    def run(self) -> None:
        backoff = 1.0
        while not self._stop_evt.is_set():
            cap = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG, self.params)
            if not cap.isOpened():
                log.warning("Akış açılamadı; %.0f sn sonra tekrar denenecek", backoff)
                self._stop_evt.wait(backoff)
                backoff = min(backoff * 2, 30.0)
                continue
            log.info("Akış açıldı")
            backoff = 1.0
            while not self._stop_evt.is_set():
                ok, frame = cap.read()
                if not ok:
                    log.warning("Kare okunamadı; yeniden bağlanılıyor")
                    break
                with self._lock:
                    self._frame = frame
                    self._seq += 1
            cap.release()
            self.reconnects += 1

    def latest(self) -> tuple[int, np.ndarray | None]:
        with self._lock:
            return self._seq, self._frame

    def stop(self) -> None:
        self._stop_evt.set()


# --- 2) Kurallar: çizgi geçişi ve bölge ihlali ----------------------------------
def signed_distance(p, a, b) -> float:
    """p noktasının AB doğrusuna işaretli uzaklığı (piksel). İşaret, hangi tarafta olduğunu verir."""
    abx, aby = b[0] - a[0], b[1] - a[1]
    cross = abx * (p[1] - a[1]) - aby * (p[0] - a[0])
    return cross / max((abx * abx + aby * aby) ** 0.5, 1e-9)


def projects_onto_segment(p, a, b) -> bool:
    """p'nin AB doğrusuna izdüşümü, doğru parçasının içinde mi (sonsuz doğruda değil)?"""
    abx, aby = b[0] - a[0], b[1] - a[1]
    t = ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / max(abx * abx + aby * aby, 1e-9)
    return 0.0 <= t <= 1.0


class LineCounter:
    def __init__(self, a, b, hysteresis_px: float = HYSTERESIS_PX, cooldown_s: float = 1.0):
        self.a, self.b = a, b
        self.h = hysteresis_px
        self.cooldown_s = cooldown_s
        self.last_side: dict[int, int] = {}
        self.last_cross: dict[int, float] = {}
        self.count_in = 0
        self.count_out = 0

    def update(self, tid: int, p, now: float) -> str | None:
        d = signed_distance(p, self.a, self.b)
        if abs(d) < self.h:  # çizginin hemen üstündeyken titreşimi yok say
            return None
        side = 1 if d > 0 else -1
        prev = self.last_side.get(tid)
        self.last_side[tid] = side
        if prev is None or prev == side or not projects_onto_segment(p, self.a, self.b):
            return None
        if now - self.last_cross.get(tid, 0.0) < self.cooldown_s:
            return None
        self.last_cross[tid] = now
        if prev < 0 < side:
            self.count_in += 1
            return "in"
        self.count_out += 1
        return "out"

    def forget(self, tid: int) -> None:
        self.last_side.pop(tid, None)
        self.last_cross.pop(tid, None)


class ZoneWatcher:
    def __init__(self, polygon_px: np.ndarray, min_seconds: float):
        self.poly = polygon_px.reshape(-1, 1, 2).astype(np.int32)
        self.min_seconds = min_seconds
        self.entered_at: dict[int, float] = {}
        self.alerted: set[int] = set()

    def update(self, tid: int, p, now: float) -> float | None:
        inside = cv2.pointPolygonTest(self.poly, (float(p[0]), float(p[1])), False) >= 0
        if not inside:
            self.forget(tid)
            return None
        t0 = self.entered_at.setdefault(tid, now)
        if tid not in self.alerted and now - t0 >= self.min_seconds:
            self.alerted.add(tid)
            return now - t0
        return None

    def forget(self, tid: int) -> None:
        self.entered_at.pop(tid, None)
        self.alerted.discard(tid)


# --- 3) Olay çıkışı: kuyruk + gönderici iş parçacığı ------------------------------
def make_event(etype: str, rule: str, zone: str, confidence: float, payload: dict) -> dict:
    return {
        "event_id": str(uuid.uuid4()),
        "source": CAMERA_ID,
        "type": etype,
        "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
        "site": SITE,
        "zone": zone,
        "confidence": round(confidence, 3),
        "rule": rule,
        "payload": payload,
    }


def build_mqtt() -> mqtt.Client | None:
    if not MQTT_HOST:
        return None
    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"{SITE}-{CAMERA_ID}",
                         protocol=mqtt.MQTTv5)
    if MQTT_CA:
        client.tls_set(ca_certs=MQTT_CA)
    if MQTT_USER:
        client.username_pw_set(MQTT_USER, MQTT_PASS)
    client.will_set(f"{TOPIC_BASE}/status", "offline", qos=1, retain=True)
    client.reconnect_delay_set(min_delay=1, max_delay=60)
    client.max_queued_messages_set(1000)  # bağlantı yokken bellekte bekleyecek en fazla mesaj

    def on_connect(c, userdata, flags, reason_code, properties):
        if reason_code.is_failure:
            log.error("MQTT bağlantısı reddedildi: %s", reason_code)
            return
        log.info("MQTT bağlı")
        c.publish(f"{TOPIC_BASE}/status", "online", qos=1, retain=True)

    def on_disconnect(c, userdata, flags, reason_code, properties):
        log.warning("MQTT bağlantısı koptu: %s", reason_code)

    client.on_connect = on_connect
    client.on_disconnect = on_disconnect
    client.connect_async(MQTT_HOST, MQTT_PORT, keepalive=30)
    client.loop_start()  # ağ döngüsü ve otomatik yeniden bağlanma arka planda
    return client


def post_webhook(body: bytes, attempts: int = 3) -> None:
    for i in range(attempts):
        try:
            req = urllib.request.Request(WEBHOOK_URL, data=body, method="POST",
                                         headers={"Content-Type": "application/json"})
            with urllib.request.urlopen(req, timeout=3) as resp:
                if 200 <= resp.status < 300:
                    return
        except OSError as exc:
            log.warning("Webhook denemesi %d başarısız: %s", i + 1, exc)
        time.sleep(2 ** i)
    log.error("Webhook teslim edilemedi, olay atlandı")


def sender_loop(events: queue.Queue, client: mqtt.Client | None, stop: threading.Event) -> None:
    while not stop.is_set() or not events.empty():
        try:
            event = events.get(timeout=0.5)
        except queue.Empty:
            continue
        body = json.dumps(event, ensure_ascii=False).encode("utf-8")
        if client is not None:
            client.publish(f"{TOPIC_BASE}/{event['type']}", body, qos=1)
        if WEBHOOK_URL:
            post_webhook(body)


# --- 4) Ana döngü --------------------------------------------------------------
def to_px(points, w: int, h: int) -> list[tuple[float, float]]:
    return [(x * w, y * h) for x, y in points]


def anchor_point(xyxy) -> tuple[float, float]:
    x1, y1, x2, y2 = (float(v) for v in xyxy)
    return ((x1 + x2) / 2, y2) if ANCHOR == "bottom" else ((x1 + x2) / 2, (y1 + y2) / 2)


def main() -> None:
    stop = threading.Event()
    signal.signal(signal.SIGTERM, lambda *_: stop.set())
    signal.signal(signal.SIGINT, lambda *_: stop.set())

    model = YOLO(MODEL_PATH)  # her kamera için ayrı model nesnesi: izleyici durumu buna bağlı
    reader = LatestFrameReader(RTSP_URL)
    reader.start()

    events: queue.Queue = queue.Queue(maxsize=1000)
    client = build_mqtt()
    sender = threading.Thread(target=sender_loop, args=(events, client, stop), daemon=True)
    sender.start()

    def emit(event: dict) -> None:
        try:
            events.put_nowait(event)
        except queue.Full:
            log.error("Olay kuyruğu dolu; olay atlandı: %s", event["type"])

    line = zone = None
    last_seen: dict[int, float] = {}
    period = 1.0 / PROCESS_FPS
    next_run = time.monotonic()
    last_seq = -1
    processed, stats_t0 = 0, time.monotonic()

    while not stop.is_set():
        now = time.monotonic()
        if now < next_run:
            time.sleep(min(next_run - now, 0.01))
            continue
        seq, frame = reader.latest()
        if frame is None or seq == last_seq:  # yeni kare yok
            time.sleep(0.005)
            continue
        last_seq, next_run = seq, now + period  # kare atlama: saniyede en çok PROCESS_FPS kare

        if line is None:  # geometriyi ilk karenin çözünürlüğüne göre piksele çevir
            h, w = frame.shape[:2]
            a, b = to_px(LINE, w, h)
            line = LineCounter(a, b)
            zone = ZoneWatcher(np.array(to_px(ZONE, w, h)), ZONE_MIN_SECONDS)

        result = model.track(frame, persist=True, tracker="bytetrack.yaml", classes=CLASSES,
                             conf=CONF, device=DEVICE, verbose=False)[0]
        boxes = result.boxes
        if boxes and boxes.is_track:  # iz kimliği yoksa (boş kare) atla
            ids = boxes.id.int().cpu().tolist()
            confs = boxes.conf.cpu().tolist()
            for tid, xyxy, conf in zip(ids, boxes.xyxy.cpu().tolist(), confs):
                last_seen[tid] = now
                p = anchor_point(xyxy)
                direction = line.update(tid, p, now)
                if direction:
                    emit(make_event("line.cross", "line-entrance", "entrance", conf,
                                    {"direction": direction, "track_id": tid, "object": "person",
                                     "count_in": line.count_in, "count_out": line.count_out}))
                dwell = zone.update(tid, p, now)
                if dwell is not None:
                    emit(make_event("zone.intrusion", "zone-restricted", "restricted", conf,
                                    {"duration_s": round(dwell, 1), "track_id": tid,
                                     "object": "person"}))

        for tid in [t for t, ts in last_seen.items() if now - ts > 10.0]:  # eski izleri temizle
            last_seen.pop(tid)
            line.forget(tid)
            zone.forget(tid)

        processed += 1
        if now - stats_t0 >= 60:
            log.info("işlenen fps=%.1f yeniden_bağlanma=%d kuyruk=%d",
                     processed / (now - stats_t0), reader.reconnects, events.qsize())
            processed, stats_t0 = 0, now

    reader.stop()
    sender.join(timeout=5)
    if client is not None:  # düzgün kapanışta LWT tetiklenmez; durumu kendimiz yazarız
        try:
            client.publish(f"{TOPIC_BASE}/status", "offline", qos=1, retain=True).wait_for_publish(3)
        except (RuntimeError, ValueError):
            pass
        client.disconnect()
        client.loop_stop()


if __name__ == "__main__":
    main()

Çalıştırmak için:

export RTSP_URL='rtsp://<kullanici>:<parola>@10.0.0.21:554/stream2'
export MQTT_HOST=broker.local MQTT_CA=/etc/cx/ca.crt MQTT_USER=cam-07 MQTT_PASS='<parola>'
export MODEL_PATH=yolo26n.pt PROCESS_FPS=8
python rtsp_yolo_events.py

Üretime alırken dikkat edilecekler

KVKK ve görüntü saklamamak. Kamera görüntüsü kişisel veridir. Betik kareyi diske yazmıyor, olaya görüntü eklemiyor, logda yalnızca sayılar var. Geliştirme sırasında kutulu görüntüyü ekranda görmek isteyeceksiniz; bunu yalnızca test ortamında, gerekli bilgilendirme yapılmış alanlarda yapın ve üretim imajına koymayın. Hukuki çerçeveyi KVKK ve görüntü işleme yazısında anlattım.

Saat. Olay zaman damgası UTC ve sunucu saatinden geliyor. Sunucu NTP’ye bağlı değilse olayları başka sistemlerin kayıtlarıyla eşleştiremezsiniz.

Süreç yönetimi. Betik SIGTERM sinyalini yakalayıp düzgün kapanıyor ve durum konusuna offline yazıyor; systemd ya da konteyner altında çalıştırmaya hazır. Beklenmedik bir çökme durumunda aynı bilgiyi broker, son vasiyet mesajıyla yayınlıyor.

Kamera başına süreç. Her kamera için ayrı süreç hatayı yalıtır ve izleyici durumlarını karıştırmaz; bedeli, her sürecin modeli GPU belleğine ayrıca yüklemesidir. Kamera sayısı arttıkça bellek ölçümü yapıp gerekirse birden fazla kamerayı tek süreçte toplu işlemeye geçin.

Doğruluk. Hazır COCO modeli “person” sınıfında iyi bir başlangıç, ama kendi sahanızın ışığında, açısında ve gece modunda ölçülmeden kabul edilmemeli.

Kontrol listesi

  • Lisans kararı verildi (AGPL-3.0 uyumu ya da kurumsal lisans).
  • OpenCV derlemesinde FFmpeg desteği hedef makinede doğrulandı.
  • RTSP TCP’ye zorlandı, açma/okuma zaman aşımları verildi, yeniden bağlanma test edildi (kamera kablosu çekilerek).
  • Okuma ayrı iş parçacığında, yalnızca son kare tutuluyor.
  • İzleyici açıkça belirtildi, track_buffer işlenen kare hızına göre ayarlandı.
  • Çizgi yönü ve bölge sınırları sahada gerçek geçişlerle doğrulandı.
  • Olay şeması sabitlendi; tüketici event_id ile tekrarları ayıklıyor.
  • MQTT bağlantısı TLS ve kullanıcı doğrulamalı, konu yetkileri kısıtlı.
  • Kare diske yazılmıyor, olaylarda ve loglarda görüntü ya da parola yok.
  • İşlenen kare hızı, yeniden bağlanma ve kuyruk uzunluğu izleniyor.

Sık sorulan sorular

Python ile RTSP kamera görüntüsü nasıl okunur?

OpenCV'de cv2.VideoCapture(url, cv2.CAP_FFMPEG) ile açılır. Akışı TCP'ye zorlamak için OPENCV_FFMPEG_CAPTURE_OPTIONS="rtsp_transport;tcp" ayarlanır, açma ve okuma zaman aşımları CAP_PROP_OPEN_TIMEOUT_MSEC ve CAP_PROP_READ_TIMEOUT_MSEC ile verilir. Gecikme birikmesin diye okuma ayrı bir iş parçacığında yapılır ve yalnızca son kare tutulur.

YOLO ile nesne takibi nasıl yapılır?

Ultralytics'te model.track(frame, persist=True, tracker="bytetrack.yaml") her karede çağrılır. persist=True izleyicinin önceki karelerdeki durumu korumasını sağlar, iz kimlikleri results[0].boxes.id alanından okunur. Varsayılan izleyici sürümle değişebildiği için izleyiciyi açıkça belirtmek gerekir.

YOLO ile çizgiden geçen kişi nasıl sayılır?

Her iz kimliği için ayak noktasının çizginin hangi tarafında olduğu, çapraz çarpımın işaretiyle hesaplanır. İşaret değiştiğinde ve nokta çizgi parçasının hizasındaysa geçiş sayılır. Çizgiye birkaç piksel yakın noktaları yok saymak ve aynı kimlik için kısa bir bekleme süresi koymak titreşimden doğan çift sayımı önler.

Ultralytics YOLO ticari projede ücretsiz kullanılabilir mi?

Ultralytics YOLO AGPL-3.0 ile lisanslıdır; bu lisans, türetilmiş işin tamamının kaynak kodunun açılmasını ister ve Ultralytics'e göre eğitilmiş modeller de varsayılan olarak bu lisansa tabidir. Kaynak kodunu açmadan ticari üründe, SaaS'ta ya da uç cihazda kullanmak için kurumsal lisans gerekir.

Gerçek zamanlı nesne tespitinde GPU yükü nasıl azaltılır?

Her kare çözülür ama her kare işlenmez: uygulamanın ihtiyacına göre saniyede 8–15 kare işlemek çoğu kural için yeterlidir. Kamera alt akışı, daha küçük giriş boyutu, küçük model ölçeği ve TensorRT ile FP16/INT8 derleme diğer kaldıraçlardır.

Kaynaklar

  1. Ultralytics Docs — Multi-Object Tracking (Track mode) docs.ultralytics.com
  2. Ultralytics — Licensing (AGPL-3.0 and Enterprise) ultralytics.com
  3. OpenCV — cv::VideoCapture Class Reference docs.opencv.org
  4. Eclipse Paho MQTT Python Client — Migrations (2.0) eclipse.dev
  5. 6698 sayılı Kişisel Verilerin Korunması Kanunu mevzuat.gov.tr

YOLOPythonOpenCVRTSPByteTrackMQTTUltralytics Markdown sürümü

İletişim

Konuşalım.