feat: add counting bench, triage, and dataset modules
This commit includes major additions and updates to the frontend and backend architectures, introducing new dataset management, live counting features, batch processing, and triage logic. Includes new UI pages, components, and API routes.
This commit is contained in:
1 parent
8285400254
commit
5c7c122105
80 files changed
+20074
-1412
No files matched your search
@@ -1,18 +1,9 @@
|
||||
"""
|
||||
Batch Video Cropper — Rekam Video RTSP per Sesi Batch Truk
|
||||
Batch Video Cropper — Production 24/7
|
||||
Rekam video RTSP per sesi batch truk. Ringan, tanpa GUI, auto-reconnect.
|
||||
|
||||
Program ini membaca livestream CCTV (RTSP), menjalankan algoritma penentuan batch
|
||||
menggunakan YOLO + State Machine, dan menyimpan potongan video per-batch ke folder
|
||||
yang terorganisir berdasarkan tanggal.
|
||||
|
||||
Struktur Output:
|
||||
~/reTraining/data/archive/
|
||||
├── 2026-08-05/
|
||||
│ ├── batch_1_09-15-30.mp4
|
||||
│ ├── batch_2_10-22-45.mp4
|
||||
│ └── batch_3_14-08-12.mp4
|
||||
└── 2026-08-06/
|
||||
└── batch_1_07-30-00.mp4
|
||||
Output:
|
||||
~/reTraining/data/archive/{YYYY-MM-DD}/batch_{N}_{HH-MM-SS}.mp4
|
||||
|
||||
Menjalankan:
|
||||
cd ~/reTraining/algoritma-batch
|
||||
@@ -20,466 +11,603 @@ Menjalankan:
|
||||
"""
|
||||
|
||||
import os
|
||||
# KRITIS: Konfigurasi RTSP transport — HARUS sebelum import cv2
|
||||
# Tanpa ini, OpenCV pakai UDP (default) yang sering drop koneksi
|
||||
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = (
|
||||
"rtsp_transport;tcp|buffer_size;20480000|max_delay;500000|reorder_queue_size;500"
|
||||
)
|
||||
import json
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import cv2
|
||||
import numpy as np
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
import platform
|
||||
from datetime import datetime, timedelta
|
||||
from shapely.geometry import Point, Polygon
|
||||
from ultralytics import YOLO
|
||||
|
||||
# Import modules
|
||||
from src.tracking import ByteTrackTracker
|
||||
from src.stabilizer import BboxStabilizer
|
||||
from src.truck_roi import TruckROI
|
||||
from src.counting import LineCrossCounter
|
||||
from src.batch import BatchLifecycleManager, BatchRecord
|
||||
from src.batch import BatchLifecycleManager, BatchState
|
||||
|
||||
# =====================================================================
|
||||
# 1. KONFIGURASI
|
||||
# KONFIGURASI
|
||||
# =====================================================================
|
||||
|
||||
import platform
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
# --- Path Konfigurasi ---
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ZONES_JSON = os.path.join(BASE_DIR, "zones.json")
|
||||
|
||||
if IS_WINDOWS:
|
||||
MODEL_PATH = os.path.join(BASE_DIR, "v1-best.pt")
|
||||
MODEL_PATH = os.path.join(BASE_DIR, "v3-best.pt")
|
||||
ARCHIVE_BASE = os.path.join(BASE_DIR, "archive_output")
|
||||
RTSP_URL = "video truk.mp4" # Testing lokal video
|
||||
else:
|
||||
MODEL_PATH = os.path.join(BASE_DIR, "v1-best.pt")
|
||||
MODEL_PATH = os.path.join(BASE_DIR, "v3-best.pt")
|
||||
ARCHIVE_BASE = os.path.expanduser("~/reTraining/data/archive")
|
||||
RTSP_URL = "rtsp://192.168.192.96:8554/cam" # Production RTSP stream (.105)
|
||||
|
||||
# --- Sumber Video RTSP ---
|
||||
RTSP_URL = "rtsp://frigate:zenai@192.168.192.209:8554/camera_stream_640"
|
||||
DAILY_CUTOFF_TIME = "00:00"
|
||||
|
||||
# --- Batas Pergantian Hari (Cutoff) ---
|
||||
DAILY_CUTOFF_TIME = "20:00"
|
||||
# State Machine
|
||||
SACK_IDLE_TIMEOUT = 5.0
|
||||
MIN_BATCH_DURATION = 2.0
|
||||
TRUCK_GONE_TOLERANCE = 5.0
|
||||
|
||||
# --- Parameter State Machine ---
|
||||
SACK_IDLE_TIMEOUT = 5.0 # Jeda aktivitas sebelum masuk WAITING_FOR_ACTIVITY
|
||||
MIN_BATCH_DURATION = 2.0 # Durasi minimal batch sebelum boleh masuk WAITING
|
||||
TOLERANCE_LOW_COUNT = 0.0 # Instan (0s) — batch langsung berakhir saat area truk kosong
|
||||
TOLERANCE_MED_COUNT = 0.0
|
||||
TOLERANCE_HIGH_COUNT = 0.0
|
||||
# Pengambilan video dari MediaMTX (REQ-170)
|
||||
# Jetson merekam 24/7 apa adanya; skrip ini hanya menentukan potongannya.
|
||||
# Sebelumnya frame di-encode ulang ke mpeg4 di sini: 4,7x lebih besar dari
|
||||
# sumbernya, kualitas turun, dan fps-nya salah. Sekarang potongan diunduh
|
||||
# sebagai salinan, jadi codec, fps dan waktunya persis seperti kamera.
|
||||
PLAYBACK_URL = os.getenv("PLAYBACK_URL", "http://192.168.192.96:9996/get")
|
||||
PLAYBACK_PATH = os.getenv("PLAYBACK_PATH", "cam")
|
||||
FETCH_PAD_BEFORE = 3.0 # detik diambil sebelum truk terdeteksi
|
||||
FETCH_PAD_AFTER = 3.0 # dan sesudahnya, supaya tidak terpotong
|
||||
FETCH_RETRIES = 3
|
||||
FETCH_RETRY_DELAY = 20.0
|
||||
|
||||
# --- Video Recording ---
|
||||
VIDEO_FPS = 10.0 # FPS output video (10 fps sudah cukup untuk rekaman arsip)
|
||||
VIDEO_CODEC = "mp4v" # Codec untuk .mp4
|
||||
# Video Recording
|
||||
# Diambil dari stream yang diterima, bukan ditebak. Angka 10.0 yang dulu
|
||||
# di-hardcode membuat SETIAP file di arsip punya timebase salah: kamera
|
||||
# mengirim 25 fps, file mengaku 10 fps, jadi rekaman 15,4 menit tersimpan
|
||||
# sebagai 38,3 menit dan diputar 2,49x lebih lambat dari kenyataan.
|
||||
# Dipakai hanya kalau fps stream tidak terbaca.
|
||||
FALLBACK_FPS = 25.0
|
||||
MIN_FPS, MAX_FPS = 1.0, 60.0
|
||||
VIDEO_CODEC = "mp4v"
|
||||
|
||||
# Reconnect
|
||||
RECONNECT_DELAY = 5 # Detik menunggu sebelum reconnect RTSP
|
||||
MAX_EMPTY_FRAMES = 300 # Maks frame kosong sebelum reconnect (~30 detik)
|
||||
|
||||
# Matikan tampilan visualisasi agar program sangat ringan 24/7
|
||||
SHOW_DISPLAY = False
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 2. THREADED RTSP READER (Menghindari Lag Buffer)
|
||||
# THREADED RTSP READER (selalu ambil frame terbaru, anti-lag)
|
||||
# =====================================================================
|
||||
class RTSPStreamReader:
|
||||
"""Threaded RTSP reader yang selalu mengambil frame terbaru."""
|
||||
|
||||
def __init__(self, source_url):
|
||||
self.source_url = source_url
|
||||
self.cap = cv2.VideoCapture(source_url)
|
||||
class RTSPReader:
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
self.fps = FALLBACK_FPS
|
||||
self.cap = None
|
||||
self.frame = None
|
||||
self.ret = False
|
||||
self.running = True
|
||||
self.lock = threading.Lock()
|
||||
self.new_frame_event = threading.Event()
|
||||
self.thread = threading.Thread(target=self._update, daemon=True)
|
||||
self.event = threading.Event()
|
||||
self._connect()
|
||||
self.thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _update(self):
|
||||
def _connect(self):
|
||||
if self.cap and self.cap.isOpened():
|
||||
self.cap.release()
|
||||
self.cap = cv2.VideoCapture(self.url)
|
||||
if self.cap.isOpened():
|
||||
self.fps = self._read_fps()
|
||||
log(f"RTSP terhubung ({self.fps:.1f} fps)")
|
||||
else:
|
||||
log("RTSP gagal terhubung")
|
||||
|
||||
def _read_fps(self):
|
||||
"""Fps yang diumumkan stream, dibatasi ke rentang masuk akal."""
|
||||
try:
|
||||
reported = float(self.cap.get(cv2.CAP_PROP_FPS) or 0.0)
|
||||
except Exception:
|
||||
reported = 0.0
|
||||
if MIN_FPS <= reported <= MAX_FPS:
|
||||
return reported
|
||||
log(f"Fps stream tidak masuk akal ({reported}), pakai {FALLBACK_FPS}")
|
||||
return FALLBACK_FPS
|
||||
|
||||
def _loop(self):
|
||||
empty = 0
|
||||
while self.running:
|
||||
if not self.cap.isOpened():
|
||||
print("[RTSP] Stream terputus, mencoba reconnect dalam 5 detik...")
|
||||
time.sleep(5)
|
||||
self.cap = cv2.VideoCapture(self.source_url)
|
||||
if not self.cap or not self.cap.isOpened():
|
||||
log(f"RTSP terputus, reconnect dalam {RECONNECT_DELAY}s...")
|
||||
time.sleep(RECONNECT_DELAY)
|
||||
self._connect()
|
||||
empty = 0
|
||||
continue
|
||||
ret, frame = self.cap.read()
|
||||
if not ret:
|
||||
empty += 1
|
||||
if empty > MAX_EMPTY_FRAMES:
|
||||
log(f"RTSP {empty} frame kosong, reconnect...")
|
||||
self._connect()
|
||||
empty = 0
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
empty = 0
|
||||
with self.lock:
|
||||
self.ret = ret
|
||||
self.frame = frame
|
||||
self.new_frame_event.set()
|
||||
self.ret, self.frame = ret, frame
|
||||
self.event.set()
|
||||
time.sleep(0.001)
|
||||
|
||||
def read(self):
|
||||
if self.new_frame_event.wait(timeout=2.0):
|
||||
self.new_frame_event.clear()
|
||||
if self.event.wait(timeout=2.0):
|
||||
self.event.clear()
|
||||
with self.lock:
|
||||
if self.frame is None:
|
||||
return False, None
|
||||
return self.ret, self.frame.copy()
|
||||
else:
|
||||
with self.lock:
|
||||
if self.frame is None:
|
||||
return False, None
|
||||
return self.ret, self.frame.copy()
|
||||
|
||||
def isOpened(self):
|
||||
return self.cap.isOpened()
|
||||
return self.ret, self.frame.copy() if self.frame is not None else (False, None)
|
||||
return False, None
|
||||
|
||||
def release(self):
|
||||
self.running = False
|
||||
if self.cap.isOpened():
|
||||
if self.cap:
|
||||
self.cap.release()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 3. FUNGSI UTILITAS TANGGAL & FOLDER
|
||||
# UTILITAS
|
||||
# =====================================================================
|
||||
def get_counting_date(dt=None):
|
||||
"""Menentukan tanggal kerja berdasarkan cutoff harian."""
|
||||
if dt is None:
|
||||
dt = datetime.now()
|
||||
def log(msg):
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
|
||||
def get_counting_date():
|
||||
dt = datetime.now()
|
||||
try:
|
||||
cutoff = datetime.strptime(DAILY_CUTOFF_TIME, "%H:%M").time()
|
||||
except Exception:
|
||||
cutoff = datetime.strptime("20:00", "%H:%M").time()
|
||||
|
||||
|
||||
if cutoff.hour == 0 and cutoff.minute == 0:
|
||||
return dt.date().isoformat()
|
||||
|
||||
|
||||
if dt.time() < cutoff:
|
||||
return dt.date().isoformat()
|
||||
return (dt.date() + timedelta(days=1)).isoformat()
|
||||
|
||||
|
||||
def ensure_date_folder(counting_date):
|
||||
"""Membuat folder tanggal di archive jika belum ada. Mengembalikan path folder."""
|
||||
def get_next_batch_number_from_files(counting_date):
|
||||
folder = os.path.join(ARCHIVE_BASE, counting_date)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
return folder
|
||||
if not os.path.exists(folder):
|
||||
return 1
|
||||
max_num = 0
|
||||
try:
|
||||
import re
|
||||
pattern = re.compile(r'^batch_?(\d+)\.mp4$', re.IGNORECASE)
|
||||
for filename in os.listdir(folder):
|
||||
match = pattern.match(filename)
|
||||
if match:
|
||||
num = int(match.group(1))
|
||||
max_num = max(max_num, num)
|
||||
except Exception as e:
|
||||
log(f"Error scanning folder for batch files: {e}")
|
||||
return max_num + 1
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 4. BATCH VIDEO RECORDER (Mengelola VideoWriter per Batch)
|
||||
# VIDEO RECORDER
|
||||
# =====================================================================
|
||||
class BatchVideoRecorder:
|
||||
"""Mengelola pembukaan dan penutupan file video per sesi batch."""
|
||||
class SessionFetcher:
|
||||
"""Tandai kapan satu sesi truk mulai dan selesai, lalu unduh potongannya.
|
||||
|
||||
def __init__(self, archive_base, video_fps=10.0, codec="mp4v"):
|
||||
self.archive_base = archive_base
|
||||
self.video_fps = video_fps
|
||||
self.codec = codec
|
||||
self.writer = None
|
||||
self.current_path = None
|
||||
self.frame_count = 0
|
||||
Mengunduh dilakukan di thread terpisah supaya loop deteksi tidak berhenti
|
||||
menunggu jaringan — satu sesi 40 menit bisa ratusan MB. Kalau gagal, dicoba
|
||||
lagi; buffer di Jetson menyimpan 24 jam, jadi ada banyak waktu untuk pulih.
|
||||
"""
|
||||
|
||||
def start_recording(self, batch_number, counting_date, frame_width=1280, frame_height=720):
|
||||
"""Membuka file video baru untuk batch ini."""
|
||||
self.stop_recording() # Pastikan writer sebelumnya ditutup
|
||||
def __init__(self):
|
||||
self.batch_num = None
|
||||
self.counting_date = None
|
||||
self.started_at = None
|
||||
self.path = None
|
||||
|
||||
folder = ensure_date_folder(counting_date)
|
||||
timestamp_str = datetime.now().strftime("%H-%M-%S")
|
||||
filename = f"batch_{batch_number}_{timestamp_str}.mp4"
|
||||
self.current_path = os.path.join(folder, filename)
|
||||
def start(self, batch_num, counting_date, w=1280, h=720, fps=None):
|
||||
self.batch_num = batch_num
|
||||
self.counting_date = counting_date
|
||||
self.started_at = datetime.now()
|
||||
folder = os.path.join(ARCHIVE_BASE, counting_date)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
self.path = os.path.join(folder, f"batch{batch_num:03d}.mp4")
|
||||
log(f"REC MARK START -> {self.path} @ {self.started_at:%H:%M:%S}")
|
||||
|
||||
fourcc = cv2.VideoWriter_fourcc(*self.codec)
|
||||
self.writer = cv2.VideoWriter(
|
||||
self.current_path, fourcc, self.video_fps, (frame_width, frame_height)
|
||||
)
|
||||
self.frame_count = 0
|
||||
def write(self, frame):
|
||||
"""Tidak ada yang ditulis per frame lagi — Jetson yang merekam."""
|
||||
|
||||
if self.writer.isOpened():
|
||||
print(f"[RECORD] Mulai merekam video batch #{batch_number} -> {self.current_path}")
|
||||
else:
|
||||
print(f"[RECORD ERROR] Gagal membuka VideoWriter untuk: {self.current_path}")
|
||||
self.writer = None
|
||||
def stop(self, discard=False):
|
||||
if self.started_at is None:
|
||||
return
|
||||
started, path = self.started_at, self.path
|
||||
ended = datetime.now()
|
||||
self.started_at = self.path = None
|
||||
if discard:
|
||||
log(f"REC DISCARD -> {path} tidak diunduh (batch tidak valid)")
|
||||
return
|
||||
threading.Thread(target=self._fetch, args=(path, started, ended),
|
||||
daemon=True).start()
|
||||
|
||||
def write_frame(self, frame):
|
||||
"""Menulis satu frame ke video aktif."""
|
||||
if self.writer is not None and self.writer.isOpened():
|
||||
self.writer.write(frame)
|
||||
self.frame_count += 1
|
||||
def _fetch(self, path, started, ended):
|
||||
begin = started - timedelta(seconds=FETCH_PAD_BEFORE)
|
||||
duration = (ended - started).total_seconds() + FETCH_PAD_BEFORE + FETCH_PAD_AFTER
|
||||
# '+' pada offset zona waktu wajib di-encode; kalau tidak, ia terbaca
|
||||
# sebagai spasi dan MediaMTX menolak dengan "invalid start".
|
||||
start_param = urllib.parse.quote(begin.astimezone().isoformat(timespec="seconds"),
|
||||
safe="")
|
||||
url = (f"{PLAYBACK_URL}?path={PLAYBACK_PATH}&start={start_param}"
|
||||
f"&duration={duration:.0f}&format=mp4")
|
||||
|
||||
def stop_recording(self):
|
||||
"""Menutup file video yang sedang aktif."""
|
||||
if self.writer is not None:
|
||||
self.writer.release()
|
||||
self.writer = None
|
||||
if self.current_path and self.frame_count > 0:
|
||||
print(f"[RECORD] Video selesai disimpan: {self.current_path} ({self.frame_count} frames)")
|
||||
elif self.current_path and self.frame_count == 0:
|
||||
# Hapus file kosong
|
||||
for attempt in range(1, FETCH_RETRIES + 1):
|
||||
try:
|
||||
tmp = f"{path}.part"
|
||||
with urllib.request.urlopen(url, timeout=600) as response:
|
||||
if response.status != 200:
|
||||
raise IOError(f"HTTP {response.status}")
|
||||
with open(tmp, "wb") as handle:
|
||||
shutil.copyfileobj(response, handle)
|
||||
size = os.path.getsize(tmp)
|
||||
if size < 1024:
|
||||
raise IOError(f"hasil terlalu kecil ({size} byte)")
|
||||
os.replace(tmp, path)
|
||||
_write_sidecar(path, begin, duration)
|
||||
log(f"REC FETCHED -> {path} ({size/1e6:.0f} MB, {duration:.0f} detik)")
|
||||
return
|
||||
except Exception as exc:
|
||||
log(f"REC FETCH gagal ({attempt}/{FETCH_RETRIES}) {path}: {exc}")
|
||||
try:
|
||||
os.remove(self.current_path)
|
||||
print(f"[RECORD] File video kosong dihapus: {self.current_path}")
|
||||
except Exception:
|
||||
os.remove(f"{path}.part")
|
||||
except OSError:
|
||||
pass
|
||||
self.current_path = None
|
||||
self.frame_count = 0
|
||||
if attempt < FETCH_RETRIES:
|
||||
time.sleep(FETCH_RETRY_DELAY)
|
||||
log(f"REC FETCH MENYERAH -> {path}. Rekaman masih ada di buffer Jetson "
|
||||
f"selama 24 jam sejak {begin:%Y-%m-%d %H:%M:%S}")
|
||||
|
||||
@property
|
||||
def is_recording(self):
|
||||
return self.writer is not None and self.writer.isOpened()
|
||||
return self.started_at is not None
|
||||
|
||||
|
||||
def _write_sidecar(video_path, begin, duration):
|
||||
"""Waktu sebenarnya, di sebelah videonya.
|
||||
|
||||
Aplikasi tidak perlu lagi membaca jam dari overlay dengan OCR untuk file
|
||||
baru: waktunya datang dari server rekaman, tepat sampai detik.
|
||||
"""
|
||||
payload = {
|
||||
"started_at": begin.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"duration_seconds": round(duration, 1),
|
||||
"source": "mediamtx-playback",
|
||||
"written_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
sidecar = os.path.splitext(video_path)[0] + ".json"
|
||||
try:
|
||||
with open(sidecar, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
except OSError as exc:
|
||||
log(f"Gagal menulis sidecar {sidecar}: {exc}")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 5. FUNGSI UTAMA — LOOP UTAMA DETEKSI & PEREKAMAN
|
||||
# MAIN LOOP
|
||||
# =====================================================================
|
||||
def run_batch_video_cropper():
|
||||
print("=" * 60)
|
||||
print(" BATCH VIDEO CROPPER — RTSP → Per-Batch MP4 Recorder")
|
||||
print("=" * 60)
|
||||
print(f" Model : {MODEL_PATH}")
|
||||
print(f" RTSP : {RTSP_URL}")
|
||||
print(f" Archive : {ARCHIVE_BASE}")
|
||||
print(f" Cutoff : {DAILY_CUTOFF_TIME}")
|
||||
print(f" Tolerance : Instan (0s)")
|
||||
print("=" * 60)
|
||||
shutdown_flag = False
|
||||
|
||||
def handle_signal(sig, _):
|
||||
global shutdown_flag
|
||||
log(f"Signal {sig} diterima, menutup program...")
|
||||
shutdown_flag = True
|
||||
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
|
||||
|
||||
def run():
|
||||
global shutdown_flag
|
||||
|
||||
log("=" * 50)
|
||||
log("BATCH VIDEO CROPPER — Production 24/7")
|
||||
log(f"Model : {MODEL_PATH}")
|
||||
log(f"RTSP : {RTSP_URL}")
|
||||
log(f"Archive : {ARCHIVE_BASE}")
|
||||
log(f"Toleransi batch: {TRUCK_GONE_TOLERANCE}s (truk+karung)")
|
||||
log("=" * 50)
|
||||
|
||||
# Pastikan folder archive ada
|
||||
os.makedirs(ARCHIVE_BASE, exist_ok=True)
|
||||
|
||||
# --- Load Model YOLO ---
|
||||
print("[INFO] Memuat model YOLO...")
|
||||
# Load model
|
||||
log("Memuat model YOLO...")
|
||||
model = YOLO(MODEL_PATH)
|
||||
|
||||
# Warm-up model
|
||||
print("[INFO] Warm-up model...")
|
||||
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||
device = "cuda" if os.path.exists("/usr/local/cuda") else "cpu"
|
||||
# Detect device
|
||||
device = "cpu"
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
device = "cuda"
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Warm-up
|
||||
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||
_ = model(dummy, imgsz=640, device=device, verbose=False)
|
||||
print(f"[INFO] Model siap. Device: {device}")
|
||||
log(f"Model siap. Device: {device}")
|
||||
|
||||
# --- Setup Components ---
|
||||
tracker = ByteTrackTracker(model, conf=0.55)
|
||||
stabilizer = BboxStabilizer(
|
||||
ema_alpha=0.35,
|
||||
max_hold_frames=10,
|
||||
max_height_ratio=1.5,
|
||||
min_height_ratio=0.70,
|
||||
)
|
||||
# Components
|
||||
tracker = ByteTrackTracker(model, conf=0.25)
|
||||
stabilizer = BboxStabilizer(ema_alpha=0.35, max_hold_frames=10,
|
||||
max_height_ratio=1.5, min_height_ratio=0.70)
|
||||
|
||||
# Skala koordinat (kalibrasi 1920x1080 -> 1280x720)
|
||||
scale_x = 1280.0 / 1920.0
|
||||
scale_y = 720.0 / 1080.0
|
||||
# Koordinat zona (1920x1080 → 1280x720)
|
||||
sx, sy = 1280.0 / 1920.0, 720.0 / 1080.0
|
||||
|
||||
# Detection polygon
|
||||
detection_poly_pts = [
|
||||
[int(574 * scale_x), int(50 * scale_y)],
|
||||
[int(586 * scale_x), int(1077 * scale_y)],
|
||||
[int(1418 * scale_x), int(1076 * scale_y)],
|
||||
[int(1397 * scale_x), int(50 * scale_y)],
|
||||
]
|
||||
detection_polygon = Polygon(detection_poly_pts)
|
||||
detection_polygon = Polygon([
|
||||
[int(574*sx), int(50*sy)], [int(586*sx), int(1077*sy)],
|
||||
[int(1418*sx), int(1076*sy)], [int(1397*sx), int(50*sy)],
|
||||
])
|
||||
truck_polygon = Polygon([
|
||||
[int(600*sx), int(385*sy)], [int(609*sx), int(1076*sy)],
|
||||
[int(1404*sx), int(1078*sy)], [int(1381*sx), int(343*sy)],
|
||||
])
|
||||
|
||||
# Truck polygon (untuk monitoring kehadiran karung)
|
||||
truck_poly_pts = [
|
||||
[int(600 * scale_x), int(385 * scale_y)],
|
||||
[int(609 * scale_x), int(1076 * scale_y)],
|
||||
[int(1404 * scale_x), int(1078 * scale_y)],
|
||||
[int(1381 * scale_x), int(343 * scale_y)],
|
||||
]
|
||||
truck_polygon = Polygon(truck_poly_pts)
|
||||
|
||||
# Line crossing
|
||||
static_line_y = int(330 * scale_y)
|
||||
static_line_x_start = int(577 * scale_x)
|
||||
static_line_x_end = int(1401 * scale_x)
|
||||
line_y = int(330 * sy)
|
||||
line_x1 = int(577 * sx)
|
||||
line_x2 = int(1401 * sx)
|
||||
|
||||
static_roi = TruckROI(
|
||||
x1=int(600 * scale_x),
|
||||
y1=int(343 * scale_y),
|
||||
x2=int(1404 * scale_x),
|
||||
y2=int(1078 * scale_y),
|
||||
line_y=static_line_y,
|
||||
confidence=1.0,
|
||||
)
|
||||
|
||||
counter = LineCrossCounter(
|
||||
line_y=static_line_y,
|
||||
line_x_start=static_line_x_start,
|
||||
line_x_end=static_line_x_end,
|
||||
margin=20,
|
||||
dedup_radius=60.0,
|
||||
x1=int(600*sx), y1=int(343*sy), x2=int(1404*sx), y2=int(1078*sy),
|
||||
line_y=line_y, confidence=1.0,
|
||||
)
|
||||
counter = LineCrossCounter(line_y=line_y, line_x_start=line_x1,
|
||||
line_x_end=line_x2, margin=20, dedup_radius=60.0)
|
||||
|
||||
batch_mgr = BatchLifecycleManager(
|
||||
stabilize_seconds=0.0,
|
||||
stabilize_threshold_px=9999.0,
|
||||
stabilize_seconds=0.0, stabilize_threshold_px=9999.0,
|
||||
sack_idle_timeout=SACK_IDLE_TIMEOUT,
|
||||
min_batch_duration=MIN_BATCH_DURATION,
|
||||
truck_gone_tolerance=TOLERANCE_LOW_COUNT,
|
||||
truck_gone_tolerance=3.0,
|
||||
)
|
||||
|
||||
recorder = BatchVideoRecorder(
|
||||
archive_base=ARCHIVE_BASE,
|
||||
video_fps=VIDEO_FPS,
|
||||
codec=VIDEO_CODEC,
|
||||
)
|
||||
recorder = SessionFetcher()
|
||||
|
||||
# --- Buka RTSP Stream ---
|
||||
print(f"[INFO] Membuka RTSP stream: {RTSP_URL}")
|
||||
cap = RTSPStreamReader(RTSP_URL)
|
||||
if not cap.isOpened():
|
||||
print(f"[ERROR] Gagal membuka RTSP stream: {RTSP_URL}")
|
||||
return
|
||||
# RTSP Stream
|
||||
log(f"Membuka RTSP: {RTSP_URL}")
|
||||
cap = RTSPReader(RTSP_URL)
|
||||
|
||||
# Tracking state
|
||||
batch_counter = 0
|
||||
frame_idx = 0
|
||||
last_fps_time = time.time()
|
||||
fps_counter = 0
|
||||
last_status_time = time.time()
|
||||
truck_seen_in_current_batch = False
|
||||
last_frame_time = time.time()
|
||||
NO_FRAME_BATCH_TIMEOUT = 30.0 # Akhiri batch jika tidak ada frame 30 detik
|
||||
|
||||
print("\n[INFO] Memulai loop utama... Tekan Ctrl+C untuk berhenti.\n")
|
||||
log("Loop utama dimulai...")
|
||||
|
||||
try:
|
||||
while True:
|
||||
while not shutdown_flag:
|
||||
ret, frame = cap.read()
|
||||
if not ret or frame is None:
|
||||
# Saat tidak ada frame DAN batch aktif, cek timeout
|
||||
if batch_mgr.is_active:
|
||||
no_frame_duration = time.time() - last_frame_time
|
||||
if no_frame_duration >= NO_FRAME_BATCH_TIMEOUT:
|
||||
log(f"RTSP drop {no_frame_duration:.0f}s. Force-end BATCH #{batch_counter}. Karung: {counter.loading_count}")
|
||||
# Force-end: langsung reset state machine (bypass update_truck)
|
||||
batch_mgr._state = BatchState.IDLE
|
||||
batch_mgr._current_batch_id = None
|
||||
batch_mgr._truck_is_stable = False
|
||||
recorder.stop(discard=True)
|
||||
counter.reset()
|
||||
stabilizer.reset()
|
||||
last_frame_time = time.time() # Reset timer agar tidak spam
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
# Resize ke 1280x720 (sesuai kalibrasi koordinat zona)
|
||||
last_frame_time = time.time()
|
||||
|
||||
frame = cv2.resize(frame, (1280, 720))
|
||||
timestamp = time.time()
|
||||
frame_idx += 1
|
||||
|
||||
# FPS counter
|
||||
fps_counter += 1
|
||||
if fps_counter % 100 == 0:
|
||||
elapsed = time.time() - last_fps_time
|
||||
fps = 100.0 / elapsed if elapsed > 0 else 0
|
||||
last_fps_time = time.time()
|
||||
state_str = batch_mgr.state
|
||||
rec_str = "REC" if recorder.is_recording else "---"
|
||||
print(f"[FPS] {fps:.1f} fps | State: {state_str} | {rec_str} | Frames: {frame_idx}")
|
||||
|
||||
# Simpan state sebelum update
|
||||
prev_active = batch_mgr.is_active
|
||||
prev_state = batch_mgr.state
|
||||
|
||||
# --- 1. YOLO Tracking ---
|
||||
raw_tracked_all = tracker.update(frame, [])
|
||||
raw_tracked_sacks = [d for d in raw_tracked_all if d.class_name == "sack"]
|
||||
|
||||
# --- 2. Stabilizer ---
|
||||
stable = stabilizer.update(raw_tracked_sacks)
|
||||
|
||||
# --- 3. Filter Detection Polygon ---
|
||||
stable = [
|
||||
d for d in stable
|
||||
if detection_polygon.contains(
|
||||
Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0)
|
||||
)
|
||||
# --- Deteksi ---
|
||||
raw_all = tracker.update(frame, [])
|
||||
|
||||
# Hanya proses objek yang pusatnya berada di dalam area deteksi (poligon ungu)
|
||||
raw_all_filtered = [
|
||||
d for d in raw_all
|
||||
if detection_polygon.contains(Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0))
|
||||
]
|
||||
|
||||
sacks = [d for d in raw_all_filtered if d.class_name == "sack"]
|
||||
trucks = [d for d in raw_all_filtered if d.class_name == "truck"]
|
||||
|
||||
# --- 4. Hitung karung di 70% area bawah truk ---
|
||||
min_ty, max_ty = truck_polygon.bounds[1], truck_polygon.bounds[3]
|
||||
truck_height = max_ty - min_ty
|
||||
truck_cutoff_y = min_ty + 0.30 * truck_height
|
||||
if batch_mgr.is_active and len(trucks) > 0:
|
||||
truck_seen_in_current_batch = True
|
||||
|
||||
sacks_in_truck_area = 0
|
||||
for d in stable:
|
||||
cx = (d.bbox[0] + d.bbox[2]) / 2.0
|
||||
cy = (d.bbox[1] + d.bbox[3]) / 2.0
|
||||
if truck_polygon.contains(Point(cx, cy)) and cy >= truck_cutoff_y:
|
||||
sacks_in_truck_area += 1
|
||||
# Stabilizer
|
||||
stable = stabilizer.update(sacks)
|
||||
|
||||
# --- 5. Line Crossing ---
|
||||
tracked_in_roi = [
|
||||
d for d in stable
|
||||
if static_roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0)
|
||||
]
|
||||
events = counter.update(tracked_in_roi)
|
||||
# Karung di 70% area truk
|
||||
ty_min, ty_max = truck_polygon.bounds[1], truck_polygon.bounds[3]
|
||||
cutoff_y = ty_min + 0.30 * (ty_max - ty_min)
|
||||
sacks_in_area = sum(
|
||||
1 for d in stable
|
||||
if truck_polygon.contains(Point((d.bbox[0]+d.bbox[2])/2, (d.bbox[1]+d.bbox[3])/2))
|
||||
and (d.bbox[1]+d.bbox[3])/2 >= cutoff_y
|
||||
)
|
||||
|
||||
# Line crossing
|
||||
in_roi = [d for d in stable if static_roi.contains_x((d.bbox[0]+d.bbox[2])/2)]
|
||||
events = counter.update(in_roi)
|
||||
has_crossing = len(events) > 0
|
||||
|
||||
# =============================================================
|
||||
# LOGIKA ALGORITMA PENENTUAN BATCH (STATE MACHINE)
|
||||
# =============================================================
|
||||
|
||||
# A. Mulai Batch
|
||||
# --- State Machine ---
|
||||
if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"):
|
||||
batch_mgr.update_truck(has_crossing, (0.0, 0.0), timestamp)
|
||||
|
||||
# B. Monitoring Batch Aktif
|
||||
if batch_mgr.state in ("COUNTING_SACKS", "WAITING_FOR_ACTIVITY"):
|
||||
current_count = counter.loading_count
|
||||
if current_count < 20:
|
||||
batch_mgr._truck_gone_tolerance = TOLERANCE_LOW_COUNT
|
||||
elif current_count >= 40:
|
||||
batch_mgr._truck_gone_tolerance = TOLERANCE_HIGH_COUNT
|
||||
else:
|
||||
batch_mgr._truck_gone_tolerance = TOLERANCE_MED_COUNT
|
||||
|
||||
batch_mgr.update_sacks(
|
||||
has_crossing_event=has_crossing,
|
||||
sacks_in_area_count=sacks_in_truck_area,
|
||||
sacks_in_area_count=sacks_in_area,
|
||||
timestamp=timestamp,
|
||||
loading_count=counter.loading_count,
|
||||
unloading_count=counter.unloading_count,
|
||||
)
|
||||
|
||||
if batch_mgr.state == "WAITING_FOR_ACTIVITY":
|
||||
batch_mgr.update_truck(sacks_in_truck_area > 0, None, timestamp)
|
||||
# Sinkronkan _truck_last_seen agar countdown toleransi
|
||||
# mulai dari saat WAITING dimulai, bukan dari TRUCK_STABILIZING
|
||||
if batch_mgr._truck_last_seen < batch_mgr._waiting_since:
|
||||
batch_mgr._truck_last_seen = batch_mgr._waiting_since
|
||||
|
||||
anything = (sacks_in_area > 0) or (len(trucks) > 0)
|
||||
batch_mgr._truck_gone_tolerance = TRUCK_GONE_TOLERANCE
|
||||
batch_mgr.update_truck(anything, None, timestamp)
|
||||
|
||||
for ev in events:
|
||||
now_str = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{now_str}] [KARUNG] #{ev['track_id']} melintasi garis. Total: {counter.loading_count}")
|
||||
|
||||
# =============================================================
|
||||
# TRANSISI BATCH — MULAI/SELESAI REKAMAN VIDEO
|
||||
# =============================================================
|
||||
|
||||
# C. Batch baru saja dimulai
|
||||
# --- Transisi Batch ---
|
||||
if batch_mgr.is_active and not prev_active:
|
||||
counting_date = get_counting_date()
|
||||
batch_counter += 1
|
||||
now_str = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"\n>>> [{now_str}] BATCH #{batch_counter} DIMULAI (tanggal: {counting_date}) <<<")
|
||||
recorder.start_recording(batch_counter, counting_date, 1280, 720)
|
||||
cd = get_counting_date()
|
||||
batch_counter = get_next_batch_number_from_files(cd)
|
||||
truck_seen_in_current_batch = False
|
||||
log(f"BATCH #{batch_counter} DIMULAI (tanggal: {cd})")
|
||||
recorder.start(batch_counter, cd, fps=cap.fps)
|
||||
|
||||
# D. Batch baru saja selesai
|
||||
elif not batch_mgr.is_active and prev_active:
|
||||
final_count = counter.loading_count
|
||||
now_str = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"\n>>> [{now_str}] BATCH #{batch_counter} SELESAI. Total karung: {final_count} <<<")
|
||||
recorder.stop_recording()
|
||||
|
||||
# Reset counter dan stabilizer untuk batch berikutnya
|
||||
# Tentukan apakah batch valid (truk harus terdeteksi minimal sekali DAN hitungan karung > 0)
|
||||
is_valid = (final_count > 0) and truck_seen_in_current_batch
|
||||
|
||||
if is_valid:
|
||||
log(f"BATCH #{batch_counter} SELESAI. Karung: {final_count}")
|
||||
recorder.stop(discard=False)
|
||||
else:
|
||||
log(f"BATCH #{batch_counter} DIABAIKAN (Karung={final_count}, Truk Terdeteksi={truck_seen_in_current_batch})")
|
||||
recorder.stop(discard=True)
|
||||
# Kembalikan nomor counter batch karena batch ini dianulir
|
||||
batch_counter = max(0, batch_counter - 1)
|
||||
|
||||
counter.reset()
|
||||
stabilizer.reset()
|
||||
|
||||
# E. Log transisi status
|
||||
if batch_mgr.state != prev_state:
|
||||
now_str = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{now_str}] [STATE] {prev_state} -> {batch_mgr.state}")
|
||||
log(f"STATE: {prev_state} -> {batch_mgr.state}")
|
||||
|
||||
# =============================================================
|
||||
# TULIS FRAME KE VIDEO (jika batch aktif)
|
||||
# =============================================================
|
||||
if batch_mgr.is_active and recorder.is_recording:
|
||||
recorder.write_frame(frame)
|
||||
# Tulis frame ke video
|
||||
if batch_mgr.is_active:
|
||||
recorder.write(frame)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n[INFO] Program dihentikan oleh pengguna (Ctrl+C).")
|
||||
# Log karung crossing
|
||||
for ev in events:
|
||||
log(f"KARUNG #{ev['track_id']} crossing. Total: {counter.loading_count}")
|
||||
|
||||
# Status log setiap 5 menit
|
||||
if timestamp - last_status_time >= 300:
|
||||
last_status_time = timestamp
|
||||
rec = "REC" if recorder.is_recording else "---"
|
||||
log(f"STATUS: frames={frame_idx} batches={batch_counter} "
|
||||
f"state={batch_mgr.state} {rec}")
|
||||
|
||||
# --- Visualisasi Live Predict (Lokal Windows saja) ---
|
||||
if SHOW_DISPLAY:
|
||||
display = frame.copy()
|
||||
|
||||
# Gambar detection polygon (magenta)
|
||||
det_pts = np.array([
|
||||
[int(574*sx), int(50*sy)], [int(586*sx), int(1077*sy)],
|
||||
[int(1418*sx), int(1076*sy)], [int(1397*sx), int(50*sy)]
|
||||
], dtype=np.int32)
|
||||
cv2.polylines(display, [det_pts], True, (255, 0, 255), 2)
|
||||
|
||||
# Gambar truck polygon (orange)
|
||||
trk_pts = np.array([
|
||||
[int(600*sx), int(385*sy)], [int(609*sx), int(1076*sy)],
|
||||
[int(1404*sx), int(1078*sy)], [int(1381*sx), int(343*sy)]
|
||||
], dtype=np.int32)
|
||||
cv2.polylines(display, [trk_pts], True, (0, 165, 255), 2)
|
||||
|
||||
# Gambar count line (cyan)
|
||||
cv2.line(display, (line_x1, line_y), (line_x2, line_y), (255, 255, 0), 2)
|
||||
cv2.putText(display, "COUNTING LINE", (line_x1 + 10, line_y - 8),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1)
|
||||
|
||||
# Gambar bbox TRUCK (hijau)
|
||||
for d in trucks:
|
||||
x1, y1, x2, y2 = [int(v) for v in d.bbox]
|
||||
cv2.rectangle(display, (x1, y1), (x2, y2), (0, 200, 0), 2)
|
||||
lbl = f"truck #{d.track_id} ({d.confidence:.2f})"
|
||||
cv2.putText(display, lbl, (x1, y1 - 5),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 200, 0), 1)
|
||||
|
||||
# Gambar bbox SACK (cyan untuk stable)
|
||||
for d in stable:
|
||||
x1, y1, x2, y2 = [int(v) for v in d.bbox]
|
||||
cv2.rectangle(display, (x1, y1), (x2, y2), (255, 255, 0), 2)
|
||||
lbl = f"sack #{d.track_id}"
|
||||
cv2.putText(display, lbl, (x1, y2 + 15),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 0), 1)
|
||||
|
||||
# Overlay HUD
|
||||
overlay = display.copy()
|
||||
cv2.rectangle(overlay, (5, 5), (380, 150), (0, 0, 0), -1)
|
||||
cv2.addWeighted(overlay, 0.65, display, 0.35, 0, display)
|
||||
|
||||
state_str = batch_mgr.state
|
||||
rec_str = "● RECORDING" if recorder.is_recording else "○ IDLE"
|
||||
color_state = (0, 250, 0) if batch_mgr.is_active else (0, 165, 255)
|
||||
|
||||
cv2.putText(display, f"State: {state_str}", (15, 30),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, color_state, 2)
|
||||
cv2.putText(display, f"Total Counted: {counter.loading_count}", (15, 55),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1)
|
||||
cv2.putText(display, f"Sacks in Area: {sacks_in_area}", (15, 80),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1)
|
||||
cv2.putText(display, f"Current Batch: #{batch_counter} ({rec_str})", (15, 105),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||
cv2.putText(display, f"Sacks: {len(stable)} | Trucks: {len(trucks)}",
|
||||
(15, 130), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1)
|
||||
|
||||
# Resize agar muat layar lokal
|
||||
resized = cv2.resize(display, (960, 540))
|
||||
cv2.imshow("Batch Video Cropper - Local Predict", resized)
|
||||
if cv2.waitKey(1) & 0xFF == ord('q'):
|
||||
log("Dihentikan secara manual melalui tombol 'q'")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
log(f"ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
finally:
|
||||
# Tutup rekaman yang masih terbuka
|
||||
if recorder.is_recording:
|
||||
print("[INFO] Menyimpan rekaman batch terakhir...")
|
||||
recorder.stop_recording()
|
||||
|
||||
log("Menyimpan rekaman batch terakhir...")
|
||||
recorder.stop()
|
||||
cap.release()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" BATCH VIDEO CROPPER SELESAI")
|
||||
print(f" Total batch terekam: {batch_counter}")
|
||||
print(f" Total frame diproses: {frame_idx}")
|
||||
print(f" Folder output: {ARCHIVE_BASE}")
|
||||
print("=" * 60)
|
||||
if SHOW_DISPLAY:
|
||||
cv2.destroyAllWindows()
|
||||
log(f"SELESAI. Total batch: {batch_counter}, frames: {frame_idx}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_batch_video_cropper()
|
||||
run()
|
||||
Reference in new issue
Block a user