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:
asus committed 2026-08-14 16:28:52 +07:00
1 parent 8285400254
commit 5c7c122105
80 files changed
+20074 -1412

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
data/
.venv/
.git/
node_modules/
frontend/node_modules/
frontend/dist/
__pycache__/
*.pyc
File diff suppressed because it is too large. Load diff
+8
View File
@@ -29,6 +29,14 @@ RUN uv pip install -r requirements.txt
COPY sam3/ ./sam3/ COPY sam3/ ./sam3/
RUN uv pip install -e ./sam3 RUN uv pip install -e ./sam3
# The counting pipeline (tracker, stabiliser, line counter) lives in
# algoritma-batch and imports itself as `src.*`. Copied to /app/src so those
# imports resolve unchanged rather than being duplicated into backend/.
COPY algoritma-batch/src/ ./src/
# tracking.py resolves its ByteTrack tuning as ../cfg/tracker.yaml relative to
# src/, so the two directories have to keep that shape inside the image.
COPY algoritma-batch/cfg/ ./cfg/
COPY backend/ ./backend/ COPY backend/ ./backend/
EXPOSE 8000 EXPOSE 8000
+438 -310
View File
@@ -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 Output:
menggunakan YOLO + State Machine, dan menyimpan potongan video per-batch ke folder ~/reTraining/data/archive/{YYYY-MM-DD}/batch_{N}_{HH-MM-SS}.mp4
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
Menjalankan: Menjalankan:
cd ~/reTraining/algoritma-batch cd ~/reTraining/algoritma-batch
@@ -20,466 +11,603 @@ Menjalankan:
""" """
import os 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 cv2
import numpy as np import numpy as np
import time import time
import json
import threading import threading
import platform
from datetime import datetime, timedelta from datetime import datetime, timedelta
from shapely.geometry import Point, Polygon from shapely.geometry import Point, Polygon
from ultralytics import YOLO from ultralytics import YOLO
# Import modules
from src.tracking import ByteTrackTracker from src.tracking import ByteTrackTracker
from src.stabilizer import BboxStabilizer from src.stabilizer import BboxStabilizer
from src.truck_roi import TruckROI from src.truck_roi import TruckROI
from src.counting import LineCrossCounter 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" IS_WINDOWS = platform.system() == "Windows"
# --- Path Konfigurasi ---
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ZONES_JSON = os.path.join(BASE_DIR, "zones.json")
if IS_WINDOWS: 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") ARCHIVE_BASE = os.path.join(BASE_DIR, "archive_output")
RTSP_URL = "video truk.mp4" # Testing lokal video
else: 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") ARCHIVE_BASE = os.path.expanduser("~/reTraining/data/archive")
RTSP_URL = "rtsp://192.168.192.96:8554/cam" # Production RTSP stream (.105)
# --- Sumber Video RTSP --- DAILY_CUTOFF_TIME = "00:00"
RTSP_URL = "rtsp://frigate:zenai@192.168.192.209:8554/camera_stream_640"
# --- Batas Pergantian Hari (Cutoff) --- # State Machine
DAILY_CUTOFF_TIME = "20:00" SACK_IDLE_TIMEOUT = 5.0
MIN_BATCH_DURATION = 2.0
TRUCK_GONE_TOLERANCE = 5.0
# --- Parameter State Machine --- # Pengambilan video dari MediaMTX (REQ-170)
SACK_IDLE_TIMEOUT = 5.0 # Jeda aktivitas sebelum masuk WAITING_FOR_ACTIVITY # Jetson merekam 24/7 apa adanya; skrip ini hanya menentukan potongannya.
MIN_BATCH_DURATION = 2.0 # Durasi minimal batch sebelum boleh masuk WAITING # Sebelumnya frame di-encode ulang ke mpeg4 di sini: 4,7x lebih besar dari
TOLERANCE_LOW_COUNT = 0.0 # Instan (0s) — batch langsung berakhir saat area truk kosong # sumbernya, kualitas turun, dan fps-nya salah. Sekarang potongan diunduh
TOLERANCE_MED_COUNT = 0.0 # sebagai salinan, jadi codec, fps dan waktunya persis seperti kamera.
TOLERANCE_HIGH_COUNT = 0.0 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 Recording
VIDEO_FPS = 10.0 # FPS output video (10 fps sudah cukup untuk rekaman arsip) # Diambil dari stream yang diterima, bukan ditebak. Angka 10.0 yang dulu
VIDEO_CODEC = "mp4v" # Codec untuk .mp4 # 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: class RTSPReader:
"""Threaded RTSP reader yang selalu mengambil frame terbaru.""" def __init__(self, url):
self.url = url
def __init__(self, source_url): self.fps = FALLBACK_FPS
self.source_url = source_url self.cap = None
self.cap = cv2.VideoCapture(source_url)
self.frame = None self.frame = None
self.ret = False self.ret = False
self.running = True self.running = True
self.lock = threading.Lock() self.lock = threading.Lock()
self.new_frame_event = threading.Event() self.event = threading.Event()
self.thread = threading.Thread(target=self._update, daemon=True) self._connect()
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start() 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: while self.running:
if not self.cap.isOpened(): if not self.cap or not self.cap.isOpened():
print("[RTSP] Stream terputus, mencoba reconnect dalam 5 detik...") log(f"RTSP terputus, reconnect dalam {RECONNECT_DELAY}s...")
time.sleep(5) time.sleep(RECONNECT_DELAY)
self.cap = cv2.VideoCapture(self.source_url) self._connect()
empty = 0
continue continue
ret, frame = self.cap.read() ret, frame = self.cap.read()
if not ret: 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) time.sleep(0.01)
continue continue
empty = 0
with self.lock: with self.lock:
self.ret = ret self.ret, self.frame = ret, frame
self.frame = frame self.event.set()
self.new_frame_event.set()
time.sleep(0.001) time.sleep(0.001)
def read(self): def read(self):
if self.new_frame_event.wait(timeout=2.0): if self.event.wait(timeout=2.0):
self.new_frame_event.clear() self.event.clear()
with self.lock: with self.lock:
if self.frame is None: return self.ret, self.frame.copy() if self.frame is not None else (False, None)
return False, 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()
def release(self): def release(self):
self.running = False self.running = False
if self.cap.isOpened(): if self.cap:
self.cap.release() self.cap.release()
# ===================================================================== # =====================================================================
# 3. FUNGSI UTILITAS TANGGAL & FOLDER # UTILITAS
# ===================================================================== # =====================================================================
def get_counting_date(dt=None): def log(msg):
"""Menentukan tanggal kerja berdasarkan cutoff harian.""" ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if dt is None: print(f"[{ts}] {msg}", flush=True)
dt = datetime.now()
def get_counting_date():
dt = datetime.now()
try: try:
cutoff = datetime.strptime(DAILY_CUTOFF_TIME, "%H:%M").time() cutoff = datetime.strptime(DAILY_CUTOFF_TIME, "%H:%M").time()
except Exception: except Exception:
cutoff = datetime.strptime("20:00", "%H:%M").time() cutoff = datetime.strptime("20:00", "%H:%M").time()
if cutoff.hour == 0 and cutoff.minute == 0: if cutoff.hour == 0 and cutoff.minute == 0:
return dt.date().isoformat() return dt.date().isoformat()
if dt.time() < cutoff: if dt.time() < cutoff:
return dt.date().isoformat() return dt.date().isoformat()
return (dt.date() + timedelta(days=1)).isoformat() return (dt.date() + timedelta(days=1)).isoformat()
def ensure_date_folder(counting_date): def get_next_batch_number_from_files(counting_date):
"""Membuat folder tanggal di archive jika belum ada. Mengembalikan path folder."""
folder = os.path.join(ARCHIVE_BASE, counting_date) folder = os.path.join(ARCHIVE_BASE, counting_date)
os.makedirs(folder, exist_ok=True) if not os.path.exists(folder):
return 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: class SessionFetcher:
"""Mengelola pembukaan dan penutupan file video per sesi batch.""" """Tandai kapan satu sesi truk mulai dan selesai, lalu unduh potongannya.
def __init__(self, archive_base, video_fps=10.0, codec="mp4v"): Mengunduh dilakukan di thread terpisah supaya loop deteksi tidak berhenti
self.archive_base = archive_base menunggu jaringan — satu sesi 40 menit bisa ratusan MB. Kalau gagal, dicoba
self.video_fps = video_fps lagi; buffer di Jetson menyimpan 24 jam, jadi ada banyak waktu untuk pulih.
self.codec = codec """
self.writer = None
self.current_path = None
self.frame_count = 0
def start_recording(self, batch_number, counting_date, frame_width=1280, frame_height=720): def __init__(self):
"""Membuka file video baru untuk batch ini.""" self.batch_num = None
self.stop_recording() # Pastikan writer sebelumnya ditutup self.counting_date = None
self.started_at = None
self.path = None
folder = ensure_date_folder(counting_date) def start(self, batch_num, counting_date, w=1280, h=720, fps=None):
timestamp_str = datetime.now().strftime("%H-%M-%S") self.batch_num = batch_num
filename = f"batch_{batch_number}_{timestamp_str}.mp4" self.counting_date = counting_date
self.current_path = os.path.join(folder, filename) 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) def write(self, frame):
self.writer = cv2.VideoWriter( """Tidak ada yang ditulis per frame lagi — Jetson yang merekam."""
self.current_path, fourcc, self.video_fps, (frame_width, frame_height)
)
self.frame_count = 0
if self.writer.isOpened(): def stop(self, discard=False):
print(f"[RECORD] Mulai merekam video batch #{batch_number} -> {self.current_path}") if self.started_at is None:
else: return
print(f"[RECORD ERROR] Gagal membuka VideoWriter untuk: {self.current_path}") started, path = self.started_at, self.path
self.writer = None 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): def _fetch(self, path, started, ended):
"""Menulis satu frame ke video aktif.""" begin = started - timedelta(seconds=FETCH_PAD_BEFORE)
if self.writer is not None and self.writer.isOpened(): duration = (ended - started).total_seconds() + FETCH_PAD_BEFORE + FETCH_PAD_AFTER
self.writer.write(frame) # '+' pada offset zona waktu wajib di-encode; kalau tidak, ia terbaca
self.frame_count += 1 # 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): for attempt in range(1, FETCH_RETRIES + 1):
"""Menutup file video yang sedang aktif.""" try:
if self.writer is not None: tmp = f"{path}.part"
self.writer.release() with urllib.request.urlopen(url, timeout=600) as response:
self.writer = None if response.status != 200:
if self.current_path and self.frame_count > 0: raise IOError(f"HTTP {response.status}")
print(f"[RECORD] Video selesai disimpan: {self.current_path} ({self.frame_count} frames)") with open(tmp, "wb") as handle:
elif self.current_path and self.frame_count == 0: shutil.copyfileobj(response, handle)
# Hapus file kosong 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: try:
os.remove(self.current_path) os.remove(f"{path}.part")
print(f"[RECORD] File video kosong dihapus: {self.current_path}") except OSError:
except Exception:
pass pass
self.current_path = None if attempt < FETCH_RETRIES:
self.frame_count = 0 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 @property
def is_recording(self): 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(): shutdown_flag = False
print("=" * 60)
print(" BATCH VIDEO CROPPER — RTSP → Per-Batch MP4 Recorder") def handle_signal(sig, _):
print("=" * 60) global shutdown_flag
print(f" Model : {MODEL_PATH}") log(f"Signal {sig} diterima, menutup program...")
print(f" RTSP : {RTSP_URL}") shutdown_flag = True
print(f" Archive : {ARCHIVE_BASE}")
print(f" Cutoff : {DAILY_CUTOFF_TIME}") signal.signal(signal.SIGINT, handle_signal)
print(f" Tolerance : Instan (0s)") signal.signal(signal.SIGTERM, handle_signal)
print("=" * 60)
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) os.makedirs(ARCHIVE_BASE, exist_ok=True)
# --- Load Model YOLO --- # Load model
print("[INFO] Memuat model YOLO...") log("Memuat model YOLO...")
model = YOLO(MODEL_PATH) model = YOLO(MODEL_PATH)
# Warm-up model # Detect device
print("[INFO] Warm-up model...") device = "cpu"
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
device = "cuda" if os.path.exists("/usr/local/cuda") else "cpu"
try: try:
import torch import torch
if torch.cuda.is_available(): if torch.cuda.is_available():
device = "cuda" device = "cuda"
except ImportError: except ImportError:
pass pass
# Warm-up
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
_ = model(dummy, imgsz=640, device=device, verbose=False) _ = model(dummy, imgsz=640, device=device, verbose=False)
print(f"[INFO] Model siap. Device: {device}") log(f"Model siap. Device: {device}")
# --- Setup Components --- # Components
tracker = ByteTrackTracker(model, conf=0.55) tracker = ByteTrackTracker(model, conf=0.25)
stabilizer = BboxStabilizer( stabilizer = BboxStabilizer(ema_alpha=0.35, max_hold_frames=10,
ema_alpha=0.35, max_height_ratio=1.5, min_height_ratio=0.70)
max_hold_frames=10,
max_height_ratio=1.5,
min_height_ratio=0.70,
)
# Skala koordinat (kalibrasi 1920x1080 -> 1280x720) # Koordinat zona (1920x1080 → 1280x720)
scale_x = 1280.0 / 1920.0 sx, sy = 1280.0 / 1920.0, 720.0 / 1080.0
scale_y = 720.0 / 1080.0
# Detection polygon detection_polygon = Polygon([
detection_poly_pts = [ [int(574*sx), int(50*sy)], [int(586*sx), int(1077*sy)],
[int(574 * scale_x), int(50 * scale_y)], [int(1418*sx), int(1076*sy)], [int(1397*sx), int(50*sy)],
[int(586 * scale_x), int(1077 * scale_y)], ])
[int(1418 * scale_x), int(1076 * scale_y)], truck_polygon = Polygon([
[int(1397 * scale_x), int(50 * scale_y)], [int(600*sx), int(385*sy)], [int(609*sx), int(1076*sy)],
] [int(1404*sx), int(1078*sy)], [int(1381*sx), int(343*sy)],
detection_polygon = Polygon(detection_poly_pts) ])
# Truck polygon (untuk monitoring kehadiran karung) line_y = int(330 * sy)
truck_poly_pts = [ line_x1 = int(577 * sx)
[int(600 * scale_x), int(385 * scale_y)], line_x2 = int(1401 * sx)
[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)
static_roi = TruckROI( static_roi = TruckROI(
x1=int(600 * scale_x), x1=int(600*sx), y1=int(343*sy), x2=int(1404*sx), y2=int(1078*sy),
y1=int(343 * scale_y), line_y=line_y, confidence=1.0,
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,
) )
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( batch_mgr = BatchLifecycleManager(
stabilize_seconds=0.0, stabilize_seconds=0.0, stabilize_threshold_px=9999.0,
stabilize_threshold_px=9999.0,
sack_idle_timeout=SACK_IDLE_TIMEOUT, sack_idle_timeout=SACK_IDLE_TIMEOUT,
min_batch_duration=MIN_BATCH_DURATION, min_batch_duration=MIN_BATCH_DURATION,
truck_gone_tolerance=TOLERANCE_LOW_COUNT, truck_gone_tolerance=3.0,
) )
recorder = BatchVideoRecorder( recorder = SessionFetcher()
archive_base=ARCHIVE_BASE,
video_fps=VIDEO_FPS,
codec=VIDEO_CODEC,
)
# --- Buka RTSP Stream --- # RTSP Stream
print(f"[INFO] Membuka RTSP stream: {RTSP_URL}") log(f"Membuka RTSP: {RTSP_URL}")
cap = RTSPStreamReader(RTSP_URL) cap = RTSPReader(RTSP_URL)
if not cap.isOpened():
print(f"[ERROR] Gagal membuka RTSP stream: {RTSP_URL}")
return
# Tracking state
batch_counter = 0 batch_counter = 0
frame_idx = 0 frame_idx = 0
last_fps_time = time.time() last_status_time = time.time()
fps_counter = 0 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: try:
while True: while not shutdown_flag:
ret, frame = cap.read() ret, frame = cap.read()
if not ret or frame is None: 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) time.sleep(0.01)
continue continue
# Resize ke 1280x720 (sesuai kalibrasi koordinat zona) last_frame_time = time.time()
frame = cv2.resize(frame, (1280, 720)) frame = cv2.resize(frame, (1280, 720))
timestamp = time.time() timestamp = time.time()
frame_idx += 1 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_active = batch_mgr.is_active
prev_state = batch_mgr.state prev_state = batch_mgr.state
# --- 1. YOLO Tracking --- # --- Deteksi ---
raw_tracked_all = tracker.update(frame, []) raw_all = tracker.update(frame, [])
raw_tracked_sacks = [d for d in raw_tracked_all if d.class_name == "sack"]
# Hanya proses objek yang pusatnya berada di dalam area deteksi (poligon ungu)
# --- 2. Stabilizer --- raw_all_filtered = [
stable = stabilizer.update(raw_tracked_sacks) 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))
# --- 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)
)
] ]
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 --- if batch_mgr.is_active and len(trucks) > 0:
min_ty, max_ty = truck_polygon.bounds[1], truck_polygon.bounds[3] truck_seen_in_current_batch = True
truck_height = max_ty - min_ty
truck_cutoff_y = min_ty + 0.30 * truck_height
sacks_in_truck_area = 0 # Stabilizer
for d in stable: stable = stabilizer.update(sacks)
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
# --- 5. Line Crossing --- # Karung di 70% area truk
tracked_in_roi = [ ty_min, ty_max = truck_polygon.bounds[1], truck_polygon.bounds[3]
d for d in stable cutoff_y = ty_min + 0.30 * (ty_max - ty_min)
if static_roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0) sacks_in_area = sum(
] 1 for d in stable
events = counter.update(tracked_in_roi) 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 has_crossing = len(events) > 0
# ============================================================= # --- State Machine ---
# LOGIKA ALGORITMA PENENTUAN BATCH (STATE MACHINE)
# =============================================================
# A. Mulai Batch
if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"): if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"):
batch_mgr.update_truck(has_crossing, (0.0, 0.0), timestamp) 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"): 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( batch_mgr.update_sacks(
has_crossing_event=has_crossing, has_crossing_event=has_crossing,
sacks_in_area_count=sacks_in_truck_area, sacks_in_area_count=sacks_in_area,
timestamp=timestamp, timestamp=timestamp,
loading_count=counter.loading_count, loading_count=counter.loading_count,
unloading_count=counter.unloading_count, unloading_count=counter.unloading_count,
) )
if batch_mgr.state == "WAITING_FOR_ACTIVITY": 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: # --- Transisi Batch ---
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
if batch_mgr.is_active and not prev_active: if batch_mgr.is_active and not prev_active:
counting_date = get_counting_date() cd = get_counting_date()
batch_counter += 1 batch_counter = get_next_batch_number_from_files(cd)
now_str = datetime.now().strftime("%H:%M:%S") truck_seen_in_current_batch = False
print(f"\n>>> [{now_str}] BATCH #{batch_counter} DIMULAI (tanggal: {counting_date}) <<<") log(f"BATCH #{batch_counter} DIMULAI (tanggal: {cd})")
recorder.start_recording(batch_counter, counting_date, 1280, 720) recorder.start(batch_counter, cd, fps=cap.fps)
# D. Batch baru saja selesai
elif not batch_mgr.is_active and prev_active: elif not batch_mgr.is_active and prev_active:
final_count = counter.loading_count final_count = counter.loading_count
now_str = datetime.now().strftime("%H:%M:%S") # Tentukan apakah batch valid (truk harus terdeteksi minimal sekali DAN hitungan karung > 0)
print(f"\n>>> [{now_str}] BATCH #{batch_counter} SELESAI. Total karung: {final_count} <<<") is_valid = (final_count > 0) and truck_seen_in_current_batch
recorder.stop_recording()
if is_valid:
# Reset counter dan stabilizer untuk batch berikutnya 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() counter.reset()
stabilizer.reset() stabilizer.reset()
# E. Log transisi status
if batch_mgr.state != prev_state: if batch_mgr.state != prev_state:
now_str = datetime.now().strftime("%H:%M:%S") log(f"STATE: {prev_state} -> {batch_mgr.state}")
print(f"[{now_str}] [STATE] {prev_state} -> {batch_mgr.state}")
# ============================================================= # Tulis frame ke video
# TULIS FRAME KE VIDEO (jika batch aktif) if batch_mgr.is_active:
# ============================================================= recorder.write(frame)
if batch_mgr.is_active and recorder.is_recording:
recorder.write_frame(frame)
except KeyboardInterrupt: # Log karung crossing
print("\n\n[INFO] Program dihentikan oleh pengguna (Ctrl+C).") 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: finally:
# Tutup rekaman yang masih terbuka
if recorder.is_recording: if recorder.is_recording:
print("[INFO] Menyimpan rekaman batch terakhir...") log("Menyimpan rekaman batch terakhir...")
recorder.stop_recording() recorder.stop()
cap.release() cap.release()
if SHOW_DISPLAY:
print("\n" + "=" * 60) cv2.destroyAllWindows()
print(" BATCH VIDEO CROPPER SELESAI") log(f"SELESAI. Total batch: {batch_counter}, frames: {frame_idx}")
print(f" Total batch terekam: {batch_counter}")
print(f" Total frame diproses: {frame_idx}")
print(f" Folder output: {ARCHIVE_BASE}")
print("=" * 60)
if __name__ == "__main__": if __name__ == "__main__":
run_batch_video_cropper() run()
+510
View File
@@ -0,0 +1,510 @@
"""
Batch Video Cropper — Production 24/7
Rekam video RTSP per sesi batch truk. Ringan, tanpa GUI, auto-reconnect.
Output:
~/reTraining/data/archive/{YYYY-MM-DD}/batch_{N}_{HH-MM-SS}.mp4
Menjalankan:
cd ~/reTraining/algoritma-batch
python3 batch_video_cropper.py
"""
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 signal
import sys
import cv2
import numpy as np
import time
import threading
import platform
from datetime import datetime, timedelta
from shapely.geometry import Point, Polygon
from ultralytics import YOLO
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, BatchState
# =====================================================================
# KONFIGURASI
# =====================================================================
IS_WINDOWS = platform.system() == "Windows"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
if IS_WINDOWS:
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, "v3-best.pt")
ARCHIVE_BASE = os.path.expanduser("~/reTraining/data/archive")
RTSP_URL = "rtsp://192.168.192.96:8554/cam" # Production RTSP stream (.105)
DAILY_CUTOFF_TIME = "20:00"
# State Machine
SACK_IDLE_TIMEOUT = 5.0
MIN_BATCH_DURATION = 2.0
TRUCK_GONE_TOLERANCE = 5.0
# Video Recording
VIDEO_FPS = 10.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
# =====================================================================
# THREADED RTSP READER (selalu ambil frame terbaru, anti-lag)
# =====================================================================
class RTSPReader:
def __init__(self, url):
self.url = url
self.cap = None
self.frame = None
self.ret = False
self.running = True
self.lock = threading.Lock()
self.event = threading.Event()
self._connect()
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
def _connect(self):
if self.cap and self.cap.isOpened():
self.cap.release()
self.cap = cv2.VideoCapture(self.url)
if self.cap.isOpened():
log("RTSP terhubung")
else:
log("RTSP gagal terhubung")
def _loop(self):
empty = 0
while self.running:
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, self.frame = ret, frame
self.event.set()
time.sleep(0.001)
def read(self):
if self.event.wait(timeout=2.0):
self.event.clear()
with self.lock:
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:
self.cap.release()
# =====================================================================
# UTILITAS
# =====================================================================
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 dt.time() < cutoff:
return dt.date().isoformat()
return (dt.date() + timedelta(days=1)).isoformat()
# =====================================================================
# VIDEO RECORDER
# =====================================================================
class VideoRecorder:
def __init__(self):
self.writer = None
self.path = None
self.frames = 0
def start(self, batch_num, counting_date, w=1280, h=720):
self.stop()
folder = os.path.join(ARCHIVE_BASE, counting_date)
os.makedirs(folder, exist_ok=True)
self.path = os.path.join(folder, f"batch{batch_num}.mp4")
fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC)
self.writer = cv2.VideoWriter(self.path, fourcc, VIDEO_FPS, (w, h))
self.frames = 0
if self.writer.isOpened():
log(f"REC START -> {self.path}")
else:
log(f"REC ERROR: gagal buka {self.path}")
self.writer = None
def write(self, frame):
if self.writer and self.writer.isOpened():
self.writer.write(frame)
self.frames += 1
def stop(self, discard=False):
if self.writer:
self.writer.release()
if not discard and self.frames > 0:
log(f"REC STOP -> {self.path} ({self.frames} frames)")
try:
from src.h264_converter import post_process_h264
post_process_h264(self.path, log_fn=log)
except Exception as exc:
log(f"REC H264 HOOK ERROR: {exc}")
else:
if discard:
log(f"REC DISCARD -> Menghapus file video {self.path} karena batch tidak valid / kosong")
if self.path:
try:
os.remove(self.path)
except Exception:
pass
self.writer = None
self.path = None
self.frames = 0
@property
def is_recording(self):
return self.writer is not None and self.writer.isOpened()
# =====================================================================
# MAIN LOOP
# =====================================================================
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)
os.makedirs(ARCHIVE_BASE, exist_ok=True)
# Load model
log("Memuat model YOLO...")
model = YOLO(MODEL_PATH)
# 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)
log(f"Model siap. Device: {device}")
# 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)
# Koordinat zona (1920x1080 → 1280x720)
sx, sy = 1280.0 / 1920.0, 720.0 / 1080.0
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)],
])
line_y = int(330 * sy)
line_x1 = int(577 * sx)
line_x2 = int(1401 * sx)
static_roi = TruckROI(
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,
sack_idle_timeout=SACK_IDLE_TIMEOUT,
min_batch_duration=MIN_BATCH_DURATION,
truck_gone_tolerance=3.0,
)
recorder = VideoRecorder()
# RTSP Stream
log(f"Membuka RTSP: {RTSP_URL}")
cap = RTSPReader(RTSP_URL)
batch_counter = 0
frame_idx = 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
log("Loop utama dimulai...")
try:
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
last_frame_time = time.time()
frame = cv2.resize(frame, (1280, 720))
timestamp = time.time()
frame_idx += 1
prev_active = batch_mgr.is_active
prev_state = batch_mgr.state
# --- 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"]
if batch_mgr.is_active and len(trucks) > 0:
truck_seen_in_current_batch = True
# Stabilizer
stable = stabilizer.update(sacks)
# 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
# --- State Machine ---
if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"):
batch_mgr.update_truck(has_crossing, (0.0, 0.0), timestamp)
if batch_mgr.state in ("COUNTING_SACKS", "WAITING_FOR_ACTIVITY"):
batch_mgr.update_sacks(
has_crossing_event=has_crossing,
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":
# 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)
# --- Transisi Batch ---
if batch_mgr.is_active and not prev_active:
batch_counter += 1
truck_seen_in_current_batch = False
cd = get_counting_date()
log(f"BATCH #{batch_counter} DIMULAI (tanggal: {cd})")
recorder.start(batch_counter, cd)
elif not batch_mgr.is_active and prev_active:
final_count = counter.loading_count
# 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()
if batch_mgr.state != prev_state:
log(f"STATE: {prev_state} -> {batch_mgr.state}")
# Tulis frame ke video
if batch_mgr.is_active:
recorder.write(frame)
# 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:
if recorder.is_recording:
log("Menyimpan rekaman batch terakhir...")
recorder.stop()
cap.release()
if SHOW_DISPLAY:
cv2.destroyAllWindows()
log(f"SELESAI. Total batch: {batch_counter}, frames: {frame_idx}")
if __name__ == "__main__":
run()
+259
View File
@@ -0,0 +1,259 @@
"""Line-crossing counter — hybrid zone-based state tracking.
Counting logic (Low-FPS robust):
Uses y1 (top edge) of the stabilized sack bounding box.
Each track_id goes through states:
UNKNOWN → ABOVE → COUNTED (when seen below line)
UNKNOWN → BELOW (ghost/appeared below line first → never counted)
Loading: track had state ABOVE, now detected BELOW the zone
Unloading: track had state BELOW, now detected ABOVE the zone (if needed)
4-Layer deduplication:
Layer 1: State guard — must have been ABOVE before counting
Layer 2: Entry circle — must have exited the radius from first appearance
Layer 3: Spatial dedup radius — same position can't trigger twice
Layer 4: Track ID — one track_id can only be counted once per direction
This approach is immune to low FPS because it doesn't require
detecting the exact frame of crossing. It only needs the track
to have been seen ABOVE the line at ANY point in its lifetime.
"""
from __future__ import annotations
import time
from src.interfaces import Detection
class LineCrossCounter:
"""Counts sacks crossing a horizontal zone using y1 (top edge).
The zone is a band [line_y - margin, line_y + margin].
A sack is "above" if y1 < line_y - margin,
"below" if y1 > line_y + margin.
While y1 is inside the band, state is held (no trigger).
Loading = track was ever "above", now "below" (entered truck)
Unloading = track was ever "below", now "above" (left truck)
"""
def __init__(
self,
line_y: int,
line_x_start: int,
line_x_end: int,
margin: int = 20,
dedup_radius: float = 60.0,
) -> None:
self._line_y = line_y
self._line_x_start = line_x_start
self._line_x_end = line_x_end
self._margin = margin
self._dedup_radius = dedup_radius
self._loading_count = 0
self._unloading_count = 0
# track_id -> zone state for y1: "above" | "below" | None
self._state: dict[int, str | None] = {}
# track_id -> whether this track has EVER been in each zone
self._has_been_above: dict[int, bool] = {}
self._has_been_below: dict[int, bool] = {}
# track_id -> set of directions already counted
self._counted: dict[int, set[str]] = {}
# track_id -> initial coordinates (cx, y1) when first tracked
self._entry_points: dict[int, tuple[float, float]] = {}
# track_id -> whether this track has exited its entry circle
# A track must move beyond dedup_radius from its first appearance
# before it can be counted. This prevents ghost bboxes that suddenly
# appear near the counting line from being counted immediately.
self._has_exited_entry_circle: dict[int, bool] = {}
# list of active deduplication circles
self._dedup_circles: list[dict] = []
@property
def entry_points(self) -> dict[int, tuple[float, float]]:
return self._entry_points
@property
def counted_tracks(self) -> dict[int, set[str]]:
return self._counted
@property
def line_y(self) -> int:
return self._line_y
@line_y.setter
def line_y(self, value: int) -> None:
self._line_y = value
@property
def line_x_start(self) -> int:
return self._line_x_start
@line_x_start.setter
def line_x_start(self, value: int) -> None:
self._line_x_start = value
@property
def line_x_end(self) -> int:
return self._line_x_end
@line_x_end.setter
def line_x_end(self, value: int) -> None:
self._line_x_end = value
def update(self, detections: list[Detection]) -> list[dict]:
"""Process detections, return list of crossing events.
Hybrid approach:
- Tracks zone state per frame (above/below/in-band)
- BUT uses accumulated history (has_been_above) for counting decision
- A track counts as "loading" when:
1. It has been seen ABOVE the line at any previous point
2. It has exited its entry circle (moved beyond dedup_radius from first appearance)
3. Its current y1 is now BELOW the line
4. It hasn't been counted for loading yet
5. It passes spatial dedup check
"""
now_t = time.time()
events: list[dict] = []
upper = self._line_y - self._margin
lower = self._line_y + self._margin
# Clean up expired dedup circles (older than 3.0 seconds)
self._dedup_circles = [c for c in self._dedup_circles if (now_t - c["time"]) <= 3.0]
for det in detections:
if det.track_id is None:
continue
x1, y1, x2, y2 = det.bbox
cx = (x1 + x2) / 2.0
tid = det.track_id
if tid not in self._entry_points:
self._entry_points[tid] = (cx, y1)
self._has_exited_entry_circle[tid] = False
# Check if sack has exited its entry circle (moved beyond dedup_radius
# from where it first appeared). Once exited, stays exited permanently.
if not self._has_exited_entry_circle.get(tid, False):
entry_cx, entry_y1 = self._entry_points[tid]
dist_from_entry = ((cx - entry_cx) ** 2 + (y1 - entry_y1) ** 2) ** 0.5
if dist_from_entry > self._dedup_radius:
self._has_exited_entry_circle[tid] = True
# Skip if centroid X outside counting bounds
if cx < self._line_x_start or cx > self._line_x_end:
continue
counted_dirs = self._counted.setdefault(tid, set())
# Determine y1 zone state (top edge of sack bbox)
if y1 < upper:
new_state = "above"
elif y1 > lower:
new_state = "below"
else:
new_state = self._state.get(tid) # in band: hold
prev_state = self._state.get(tid)
self._state[tid] = new_state
# Track zone history — CRITICAL for low-FPS robustness
# Once a track has been seen above/below, it stays recorded forever
if new_state == "above":
self._has_been_above[tid] = True
elif new_state == "below":
self._has_been_below[tid] = True
# --- HYBRID COUNTING LOGIC ---
# Loading: track was EVER above, NOW below (entered truck from top)
# AND has exited its entry circle (proven real movement, not ghost bbox)
# This works even if the track jumped over the line between frames
has_exited = self._has_exited_entry_circle.get(tid, False)
is_loading = (
new_state == "below"
and self._has_been_above.get(tid, False)
and has_exited
and "loading" not in counted_dirs
)
# Unloading: track was EVER below, NOW above (left truck)
is_unloading = (
new_state == "above"
and self._has_been_below.get(tid, False)
and has_exited
and "unloading" not in counted_dirs
)
if is_loading or is_unloading:
# Check spatial distance against all active dedup circles
is_duplicate = False
for circle in self._dedup_circles:
dist = ((cx - circle["x"]) ** 2 + (y1 - circle["y"]) ** 2) ** 0.5
if dist <= self._dedup_radius:
is_duplicate = True
break
if is_duplicate:
continue
# Add this coordinate to the active dedup circles
self._dedup_circles.append({
"x": cx,
"y": y1,
"time": now_t,
"track_id": tid
})
if is_loading:
self._loading_count += 1
counted_dirs.add("loading")
events.append({
"track_id": tid,
"direction": "loading",
"cx": cx,
"cy": y1
})
elif is_unloading:
self._unloading_count += 1
counted_dirs.add("unloading")
events.append({
"track_id": tid,
"direction": "unloading",
"cx": cx,
"cy": y1
})
return events
@property
def loading_count(self) -> int:
return self._loading_count
@property
def unloading_count(self) -> int:
return self._unloading_count
@property
def net_count(self) -> int:
return self._loading_count - self._unloading_count
def reset(self) -> None:
"""Reset all counters (new batch)."""
self._loading_count = 0
self._unloading_count = 0
self._state.clear()
self._has_been_above.clear()
self._has_been_below.clear()
self._counted.clear()
self._entry_points.clear()
self._has_exited_entry_circle.clear()
self._dedup_circles.clear()
+82
View File
@@ -0,0 +1,82 @@
"""YOLO-based detectors for sacks and trucks.
Each detector is a single-responsibility unit (S). New model types can be
added as new classes without touching these (O).
"""
from __future__ import annotations
import numpy as np
from ultralytics import YOLO
from src.interfaces import Detection
class SackDetector:
"""Detects sacks (and persons) using a YOLO segmentation model."""
def __init__(self, model_path: str, conf: float = 0.40) -> None:
self._model = YOLO(model_path)
self._conf = conf
def detect(self, frame: np.ndarray) -> list[Detection]:
results = self._model.predict(
frame, conf=self._conf, verbose=False
)
return self._parse(results[0])
def _parse(self, result) -> list[Detection]:
detections: list[Detection] = []
masks = result.masks
for i, box in enumerate(result.boxes):
cls_id = int(box.cls[0])
name = self._model.names[cls_id]
if name != "sack":
continue
x1, y1, x2, y2 = box.xyxy[0].tolist()
mask = None
if masks is not None and i < len(masks):
mask = masks[i].data.cpu().numpy().squeeze()
detections.append(
Detection(
bbox=(x1, y1, x2, y2),
confidence=float(box.conf[0]),
class_id=cls_id,
class_name=name,
mask=mask,
)
)
return detections
class TruckDetector:
"""Detects trucks using a YOLO detection model."""
def __init__(self, model_path_or_model: str | YOLO, conf: float = 0.50) -> None:
if isinstance(model_path_or_model, str):
self._model = YOLO(model_path_or_model)
else:
self._model = model_path_or_model
self._conf = conf
def detect(self, frame: np.ndarray) -> list[Detection]:
results = self._model.predict(
frame, conf=self._conf, verbose=False
)
return self._parse(results[0])
def _parse(self, result) -> list[Detection]:
detections: list[Detection] = []
for box in result.boxes:
cls_id = int(box.cls[0])
name = self._model.names[cls_id]
x1, y1, x2, y2 = box.xyxy[0].tolist()
detections.append(
Detection(
bbox=(x1, y1, x2, y2),
confidence=float(box.conf[0]),
class_id=cls_id,
class_name=name,
)
)
return detections
+152
View File
@@ -0,0 +1,152 @@
"""Re-file the Jetson's stored batches under the 06:00 counting day.
`predict.py` used to turn the counting day over at 20:00, which filed the whole
day shift under the previous date — a batch that started at 08:27 on the 13th was
stored as the 12th. The archive's cycles run 06:00 to 06:00, so the two disagreed.
The default is now 06:00; this brings rows written before that change into line.
For every batch it recomputes `counting_date` from `start_time`, renumbers
`batch_number` 1..N within each counting day (per camera and object label,
ordered by start time), and rebuilds `daily_summaries` from the result.
Run it on the Jetson, against its own database:
python migrate_cutoff_0600.py --db /opt/jetson-counter/jetson_counter.db --dry-run
python migrate_cutoff_0600.py --db /opt/jetson-counter/jetson_counter.db
Nothing is written without a backup, and `--dry-run` writes nothing at all.
Stop `predict.py` first: it holds an active batch in memory and would write it
back under the old numbering.
"""
import argparse
import datetime
import os
import shutil
import sqlite3
import sys
def counting_date(start_time: str, cutoff_hour: int) -> str:
"""The counting day a batch belongs to, from when it started."""
stamp = datetime.datetime.fromisoformat(start_time)
day = stamp.date()
if stamp.hour < cutoff_hour:
day = day - datetime.timedelta(days=1)
return day.isoformat()
def plan(connection, cutoff_hour: int):
"""What each row should become. Ordered by start time inside each day."""
rows = connection.execute(
"""SELECT id, counting_date, batch_number, camera_name, object_label,
count, start_time
FROM batches ORDER BY start_time"""
).fetchall()
counters: dict = {}
changes = []
for row in rows:
try:
new_date = counting_date(row["start_time"], cutoff_hour)
except (TypeError, ValueError):
# A row whose start_time cannot be parsed is left exactly as it is;
# guessing its day would be worse than leaving it visibly odd.
changes.append({"row": row, "new_date": row["counting_date"],
"new_number": row["batch_number"], "skipped": True})
continue
key = (new_date, row["camera_name"], row["object_label"])
counters[key] = counters.get(key, 0) + 1
changes.append({"row": row, "new_date": new_date,
"new_number": counters[key], "skipped": False})
return changes
def apply(connection, changes) -> None:
"""Rewrite the table.
`batches` has UNIQUE(counting_date, batch_number, camera_name, object_label),
so renumbering in place collides with rows that have not moved yet. The
numbers are parked in a negative range first, which cannot collide with any
real batch number, and then written to their final values.
"""
cursor = connection.cursor()
for offset, change in enumerate(changes, start=1):
cursor.execute("UPDATE batches SET batch_number = ? WHERE id = ?",
(-offset, change["row"]["id"]))
for change in changes:
cursor.execute(
"UPDATE batches SET counting_date = ?, batch_number = ? WHERE id = ?",
(change["new_date"], change["new_number"], change["row"]["id"]),
)
cursor.execute("DELETE FROM daily_summaries")
cursor.execute(
"""INSERT INTO daily_summaries
(counting_date, camera_name, object_label, total_count, total_batches)
SELECT counting_date, camera_name, object_label, SUM(count), COUNT(id)
FROM batches
GROUP BY counting_date, camera_name, object_label"""
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--db", required=True, help="path to jetson_counter.db")
parser.add_argument("--cutoff-hour", type=int, default=6)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if not os.path.isfile(args.db):
print(f"No database at {args.db}")
return 1
connection = sqlite3.connect(args.db)
connection.row_factory = sqlite3.Row
changes = plan(connection, args.cutoff_hour)
if not changes:
print("No batches stored — nothing to do.")
return 0
moved = [c for c in changes
if c["new_date"] != c["row"]["counting_date"]
or c["new_number"] != c["row"]["batch_number"]]
skipped = [c for c in changes if c["skipped"]]
print(f"{len(changes)} batch(es) stored, {len(moved)} would change, "
f"{len(skipped)} unparseable and left alone.\n")
for change in moved[:20]:
row = change["row"]
print(f" {row['start_time'][:19]} "
f"{row['counting_date']} #{row['batch_number']:<4} -> "
f"{change['new_date']} #{change['new_number']}")
if len(moved) > 20:
print(f" … and {len(moved) - 20} more")
if args.dry_run:
print("\nDry run — nothing written.")
return 0
if not moved:
print("\nAlready consistent with the 06:00 cutoff.")
return 0
backup = f"{args.db}.before-0600-{datetime.datetime.now():%Y%m%d-%H%M%S}"
shutil.copyfile(args.db, backup)
print(f"\nBackup written to {backup}")
try:
with connection:
apply(connection, changes)
except Exception as exc:
print(f"FAILED, database left untouched by the transaction: {exc}")
print(f"The backup at {backup} is still the pre-migration state.")
return 1
days = connection.execute(
"SELECT COUNT(DISTINCT counting_date) FROM batches").fetchone()[0]
print(f"Done. {len(moved)} batch(es) re-filed across {days} counting day(s).")
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large. Load diff
+260 -64
View File
@@ -10,14 +10,43 @@ Counting logic (Low-FPS robust):
Loading: track had state ABOVE, now detected BELOW the zone Loading: track had state ABOVE, now detected BELOW the zone
Unloading: track had state BELOW, now detected ABOVE the zone (if needed) Unloading: track had state BELOW, now detected ABOVE the zone (if needed)
3-Layer deduplication: Deduplication / recovery layers:
Layer 1: State guard — must have been ABOVE before counting Layer 1: State guard — must have been ABOVE before counting
Layer 2: Spatial dedup radius — same position can't trigger twice Layer 2: Entry circle — must have travelled `entry_travel_min` from first
Layer 3: Track ID — one track_id can only be counted once per direction appearance, so a ghost bbox that blinks into existence next to the
line cannot count
Layer 3: Track hand-off — when a track dies, its state is parked at its
predicted position; a new id born nearby inherits it
Layer 4: Track ID — one track_id can only be counted once per direction
This approach is immune to low FPS because it doesn't require This approach is immune to low FPS because it doesn't require
detecting the exact frame of crossing. It only needs the track detecting the exact frame of crossing. It only needs the track
to have been seen ABOVE the line at ANY point in its lifetime. to have been seen ABOVE the line at ANY point in its lifetime.
Why hand-off replaced the spatial dedup circles
-----------------------------------------------
The circles were meant to stop one physical sack being counted twice under two
track ids. They did not: a blocked track simply retried on the next frame and
counted as soon as it drifted out of the circle, late and at a displaced
position, which then seeded the next circle in the wrong place. Worse, they
could permanently swallow a genuine second sack that crossed the same spot
within the circle's lifetime — an under-count that fires hardest when the line
is busiest.
Hand-off addresses the real problem. An id switch now *inherits* the previous
track's history, so the same sack keeps its "already counted" verdict (no double
count) and a sack occluded above the line keeps its "was above" evidence (no
lost count). The circles are redundant once that works, and are off by default —
`spatial_dedup=True` restores the old behaviour if a camera needs it.
`handoff_radius` is the parameter that now matters most. The default of 100 is
the value tuned against the real camera. Treat it as provisional: these frames
hold ~44 sacks, so a newborn track is nearly always close to one that just
vanished, and at 100 px roughly 84% of tracks inherit — loose enough that a
wrong adoption is plausible. Wrong in one direction suppresses a real sack,
wrong in the other invents a count. Settle it against a clip with a
hand-counted total; the per-track trace written by the harness says which tracks
inherited and whether they counted.
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,6 +55,18 @@ import time
from src.interfaces import Detection from src.interfaces import Detection
MAX_SPEED_PX_S = 1500.0
"""Ceiling on the velocity used to project a hand-off token forward.
Velocity is measured from wall-clock time between two observations, so two
frames delivered back to back — a decoder hiccup, or the threaded reader handing
over a backlog — divide a normal 30 px step by a near-zero interval and produce a
speed of millions of px/s. The projection then lands off-screen and the hand-off
silently stops working, which is worse than not projecting at all."""
MAX_PROJECTION_S = 0.5
"""And never extrapolate further than this, however long the token has sat."""
class LineCrossCounter: class LineCrossCounter:
"""Counts sacks crossing a horizontal zone using y1 (top edge). """Counts sacks crossing a horizontal zone using y1 (top edge).
@@ -44,14 +85,29 @@ class LineCrossCounter:
line_y: int, line_y: int,
line_x_start: int, line_x_start: int,
line_x_end: int, line_x_end: int,
margin: int = 20, margin: int = 5,
dedup_radius: float = 60.0, dedup_radius: float = 60.0,
entry_travel_min: float | None = None,
handoff_radius: float = 100.0,
handoff_ttl: float = 0.8,
track_ttl: float = 3.0,
unload_confirm_frames: int = 3,
spatial_dedup: bool = False,
) -> None: ) -> None:
self._line_y = line_y self._line_y = line_y
self._line_x_start = line_x_start self._line_x_start = line_x_start
self._line_x_end = line_x_end self._line_x_end = line_x_end
self._margin = margin self._margin = margin
self._dedup_radius = dedup_radius self._dedup_radius = dedup_radius
# Ghost rejection and spatial dedup want opposite tuning — one wants to
# be large, the other small — so they no longer share a number.
self._entry_travel_min = (
dedup_radius if entry_travel_min is None else entry_travel_min)
self._handoff_radius = handoff_radius
self._handoff_ttl = handoff_ttl
self._track_ttl = track_ttl
self._unload_confirm = max(1, unload_confirm_frames)
self._spatial_dedup = spatial_dedup
self._loading_count = 0 self._loading_count = 0
self._unloading_count = 0 self._unloading_count = 0
@@ -65,9 +121,25 @@ class LineCrossCounter:
self._counted: dict[int, set[str]] = {} self._counted: dict[int, set[str]] = {}
# track_id -> initial coordinates (cx, y1) when first tracked # track_id -> initial coordinates (cx, y1) when first tracked
self._entry_points: dict[int, tuple[float, float]] = {} self._entry_points: dict[int, tuple[float, float]] = {}
# list of active deduplication circles # track_id -> whether this track has exited its entry circle
self._has_exited_entry_circle: dict[int, bool] = {}
# track_id -> consecutive frames observed "above" (unloading needs a
# sustained signal; a worker lifting a sack 25px must not cancel a count)
self._above_streak: dict[int, int] = {}
# track_id -> (cx, y1, seen_at) and velocity for hand-off prediction
self._last_pos: dict[int, tuple[float, float, float]] = {}
self._velocity: dict[int, tuple[float, float]] = {}
# parked state from tracks that vanished, keyed by nothing — matched
# geometrically against newborn ids
self._handoff: list[dict] = []
# list of active deduplication circles (legacy, off by default)
self._dedup_circles: list[dict] = [] self._dedup_circles: list[dict] = []
# Per-track diagnostics, drained by the caller. This is what tells you
# whether a miss was the model, the tracker or this file.
self._trace: dict[int, dict] = {}
self._finished: list[dict] = []
@property @property
def entry_points(self) -> dict[int, tuple[float, float]]: def entry_points(self) -> dict[int, tuple[float, float]]:
return self._entry_points return self._entry_points
@@ -100,25 +172,78 @@ class LineCrossCounter:
def line_x_end(self, value: int) -> None: def line_x_end(self, value: int) -> None:
self._line_x_end = value self._line_x_end = value
def update(self, detections: list[Detection]) -> list[dict]: # -- hand-off ----------------------------------------------------------
"""Process detections, return list of crossing events.
Hybrid approach: def _park(self, tid: int, now_t: float) -> None:
- Tracks zone state per frame (above/below/in-band) """Park a vanished track's history so a new id can inherit it."""
- BUT uses accumulated history (has_been_above) for counting decision cx, y1, seen_at = self._last_pos[tid]
- A track counts as "loading" when: vx, vy = self._velocity.get(tid, (0.0, 0.0))
1. It has been seen ABOVE the line at any previous point self._handoff.append({
2. Its current y1 is now BELOW the line "x": cx, "y": y1, "vx": vx, "vy": vy,
3. It hasn't been counted for loading yet "seen_at": seen_at, "parked_at": now_t, "from": tid,
4. It passes spatial dedup check "above": self._has_been_above.get(tid, False),
"below": self._has_been_below.get(tid, False),
"counted": set(self._counted.get(tid, set())),
"exited": self._has_exited_entry_circle.get(tid, False),
})
def _claim(self, cx: float, y1: float, now_t: float) -> dict | None:
"""Best parked state for a track just born at (cx, y1), if any.
The token is projected forward at the dead track's last velocity: a sack
occluded above the line keeps falling while nobody can see it, so its
last observed position is not where it reappears.
""" """
best, best_dist = None, None
for token in self._handoff:
dt = min(max(0.0, now_t - token["seen_at"]), MAX_PROJECTION_S)
px = token["x"] + token["vx"] * dt
py = token["y"] + token["vy"] * dt
dist = ((cx - px) ** 2 + (y1 - py) ** 2) ** 0.5
if dist <= self._handoff_radius and (best_dist is None or dist < best_dist):
best, best_dist = token, dist
if best is not None:
self._handoff.remove(best)
return best
def _retire(self, tid: int) -> None:
record = self._trace.pop(tid, None)
if record is not None:
record["counted"] = sorted(self._counted.get(tid, set()))
record["ever_above"] = self._has_been_above.get(tid, False)
record["ever_below"] = self._has_been_below.get(tid, False)
record["exited_entry_circle"] = self._has_exited_entry_circle.get(tid, False)
record["verdict"] = _verdict(record)
self._finished.append(record)
for store in (self._state, self._has_been_above, self._has_been_below,
self._counted, self._entry_points, self._has_exited_entry_circle,
self._above_streak, self._last_pos, self._velocity):
store.pop(tid, None)
def drain_traces(self) -> list[dict]:
"""Hand over every finished track's trajectory summary, once."""
out, self._finished = self._finished, []
return out
def update(self, detections: list[Detection]) -> list[dict]:
"""Process detections, return list of crossing events."""
now_t = time.time() now_t = time.time()
events: list[dict] = [] events: list[dict] = []
upper = self._line_y - self._margin upper = self._line_y - self._margin
lower = self._line_y + self._margin lower = self._line_y + self._margin
# Clean up expired dedup circles (older than 3.0 seconds)
self._dedup_circles = [c for c in self._dedup_circles if (now_t - c["time"]) <= 3.0] self._dedup_circles = [c for c in self._dedup_circles if (now_t - c["time"]) <= 3.0]
self._handoff = [t for t in self._handoff
if (now_t - t["parked_at"]) <= self._handoff_ttl]
# Withdraw tokens for every track that is alive this frame, before any
# claim is considered — otherwise a newborn processed earlier in the
# loop could inherit from a track that returns later in the same frame.
# Leaving these claimable let an unrelated newborn adopt the history of
# a track that never died: 96 of 111 tracks "inherited" on a real clip.
seen: set[int] = {d.track_id for d in detections if d.track_id is not None}
if self._handoff:
self._handoff = [t for t in self._handoff if t["from"] not in seen]
for det in detections: for det in detections:
if det.track_id is None: if det.track_id is None:
@@ -130,6 +255,48 @@ class LineCrossCounter:
if tid not in self._entry_points: if tid not in self._entry_points:
self._entry_points[tid] = (cx, y1) self._entry_points[tid] = (cx, y1)
self._has_exited_entry_circle[tid] = False
self._trace[tid] = {
"track_id": tid, "born_at": now_t, "frames": 0,
"entry": [round(cx, 1), round(y1, 1)],
"y1_min": y1, "y1_max": y1, "inherited_from": None,
}
# Layer 3: adopt a vanished track's history if this is the same
# physical sack under a new id.
token = self._claim(cx, y1, now_t)
if token is not None:
self._has_been_above[tid] = token["above"]
self._has_been_below[tid] = token["below"]
self._counted[tid] = set(token["counted"])
self._has_exited_entry_circle[tid] = token["exited"]
self._trace[tid]["inherited_from"] = token["from"]
# Velocity in px/sec, for predicting where an occluded track went.
if tid in self._last_pos:
px, py, pt = self._last_pos[tid]
dt = now_t - pt
if dt > 1e-3:
vx, vy = (cx - px) / dt, (y1 - py) / dt
speed = (vx * vx + vy * vy) ** 0.5
if speed > MAX_SPEED_PX_S:
scale = MAX_SPEED_PX_S / speed
vx, vy = vx * scale, vy * scale
self._velocity[tid] = (vx, vy)
self._last_pos[tid] = (cx, y1, now_t)
record = self._trace.get(tid)
if record is not None:
record["frames"] += 1
record["y1_min"] = min(record["y1_min"], y1)
record["y1_max"] = max(record["y1_max"], y1)
record["last"] = [round(cx, 1), round(y1, 1)]
# Layer 2: proven movement, not a ghost that blinked into existence.
if not self._has_exited_entry_circle.get(tid, False):
entry_cx, entry_y1 = self._entry_points[tid]
dist_from_entry = ((cx - entry_cx) ** 2 + (y1 - entry_y1) ** 2) ** 0.5
if dist_from_entry > self._entry_travel_min:
self._has_exited_entry_circle[tid] = True
# Skip if centroid X outside counting bounds # Skip if centroid X outside counting bounds
if cx < self._line_x_start or cx > self._line_x_end: if cx < self._line_x_start or cx > self._line_x_end:
@@ -145,71 +312,82 @@ class LineCrossCounter:
else: else:
new_state = self._state.get(tid) # in band: hold new_state = self._state.get(tid) # in band: hold
prev_state = self._state.get(tid)
self._state[tid] = new_state self._state[tid] = new_state
# Track zone history — CRITICAL for low-FPS robustness # Track zone history — CRITICAL for low-FPS robustness
# Once a track has been seen above/below, it stays recorded forever
if new_state == "above": if new_state == "above":
self._has_been_above[tid] = True self._has_been_above[tid] = True
elif new_state == "below": self._above_streak[tid] = self._above_streak.get(tid, 0) + 1
self._has_been_below[tid] = True else:
self._above_streak[tid] = 0
if new_state == "below":
self._has_been_below[tid] = True
has_exited = self._has_exited_entry_circle.get(tid, False)
# --- HYBRID COUNTING LOGIC ---
# Loading: track was EVER above, NOW below (entered truck from top)
# This works even if the track jumped over the line between frames
is_loading = ( is_loading = (
new_state == "below" new_state == "below"
and self._has_been_above.get(tid, False) and self._has_been_above.get(tid, False)
and has_exited
and "loading" not in counted_dirs and "loading" not in counted_dirs
) )
# Unloading: track was EVER below, NOW above (left truck) # Unloading is deliberately harder to trigger than loading. A sack
# being repositioned by hand drifts above the band for a frame or
# two, and that used to cancel a real count outright (net 0 for a
# sack that is sitting in the truck).
is_unloading = ( is_unloading = (
new_state == "above" new_state == "above"
and self._has_been_below.get(tid, False) and self._has_been_below.get(tid, False)
and has_exited
and self._above_streak.get(tid, 0) >= self._unload_confirm
and "unloading" not in counted_dirs and "unloading" not in counted_dirs
) )
if is_loading or is_unloading: if not (is_loading or is_unloading):
# Check spatial distance against all active dedup circles continue
is_duplicate = False
for circle in self._dedup_circles:
dist = ((cx - circle["x"]) ** 2 + (y1 - circle["y"]) ** 2) ** 0.5
if dist <= self._dedup_radius:
is_duplicate = True
break
if is_duplicate: if self._spatial_dedup:
duplicate = any(
((cx - c["x"]) ** 2 + (y1 - c["y"]) ** 2) ** 0.5 <= self._dedup_radius
for c in self._dedup_circles
)
if duplicate:
# Decide once. Retrying every frame turned this guard into a
# delay: the track counted anyway as soon as it drifted out.
counted_dirs.add("loading" if is_loading else "unloading")
if record is not None:
record["suppressed"] = True
continue continue
self._dedup_circles.append(
{"x": cx, "y": y1, "time": now_t, "track_id": tid})
# Add this coordinate to the active dedup circles direction = "loading" if is_loading else "unloading"
self._dedup_circles.append({ if is_loading:
"x": cx, self._loading_count += 1
"y": y1, else:
"time": now_t, self._unloading_count += 1
"track_id": tid # The verdict is the track's *last* direction, not a permanent
}) # tally. A sack that is genuinely taken back out and loaded again
# has to be able to count again, or net drifts below the truth by
# one for every such sack. Re-counting a direction still costs a
# full opposite crossing (and unloading needs a sustained one), so
# this cannot be reached by jitter.
counted_dirs.discard("unloading" if is_loading else "loading")
counted_dirs.add(direction)
events.append({"track_id": tid, "direction": direction, "cx": cx, "cy": y1})
if is_loading: # Tracks missing this frame: park their state immediately so a new id
self._loading_count += 1 # arriving next frame can inherit it, and retire them once they are
counted_dirs.add("loading") # clearly gone.
events.append({ for tid in list(self._last_pos.keys()):
"track_id": tid, if tid in seen:
"direction": "loading", continue
"cx": cx, gone_for = now_t - self._last_pos[tid][2]
"cy": y1 if not any(t["from"] == tid for t in self._handoff):
}) self._park(tid, now_t)
if gone_for > self._track_ttl:
elif is_unloading: self._retire(tid)
self._unloading_count += 1
counted_dirs.add("unloading")
events.append({
"track_id": tid,
"direction": "unloading",
"cx": cx,
"cy": y1
})
return events return events
@@ -229,9 +407,27 @@ class LineCrossCounter:
"""Reset all counters (new batch).""" """Reset all counters (new batch)."""
self._loading_count = 0 self._loading_count = 0
self._unloading_count = 0 self._unloading_count = 0
self._state.clear() for store in (self._state, self._has_been_above, self._has_been_below,
self._has_been_above.clear() self._counted, self._entry_points, self._has_exited_entry_circle,
self._has_been_below.clear() self._above_streak, self._last_pos, self._velocity, self._trace):
self._counted.clear() store.clear()
self._entry_points.clear() self._handoff.clear()
self._dedup_circles.clear() self._dedup_circles.clear()
self._finished.clear()
def _verdict(record: dict) -> str:
"""Why this track did or did not count — the bucket a miss belongs to."""
if record["counted"]:
return "counted:" + ",".join(record["counted"])
if record.get("suppressed"):
return "suppressed_by_dedup"
if not record["ever_above"] and not record["ever_below"]:
return "never_left_band"
if not record["ever_above"]:
return "born_below_line" # tracker lost it above → lost count
if not record["ever_below"]:
return "never_reached_below" # still in flight, or track died early
if not record["exited_entry_circle"]:
return "blocked_by_entry_circle"
return "uncounted_unknown"
+39
View File
@@ -0,0 +1,39 @@
import os
import subprocess
import threading
def post_process_h264(video_path: str, log_fn=print) -> None:
"""Asynchronously convert recorded mp4v video to H.264 / AAC MP4 with faststart.
When OpenCV finishes recording a batch video with 'mp4v', this function converts
the file in-place to H.264 so HTML5 web browsers can stream and preview it natively.
"""
if not video_path or not os.path.isfile(video_path):
return
def _worker():
tmp_path = video_path.replace(".mp4", "_h264.mp4") if video_path.endswith(".mp4") else video_path + "_h264.mp4"
try:
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", video_path,
"-c:v", "libx264", "-crf", "18", "-preset", "fast",
"-pix_fmt", "yuv420p", "-movflags", "+faststart",
"-c:a", "aac", "-f", "mp4",
tmp_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0 and os.path.isfile(tmp_path):
os.replace(tmp_path, video_path)
log_fn(f"H.264 AUTO-CONVERT DONE -> {video_path}")
else:
if os.path.isfile(tmp_path):
os.remove(tmp_path)
log_fn(f"H.264 AUTO-CONVERT FAILED -> {result.stderr}")
except Exception as exc:
if os.path.isfile(tmp_path):
os.remove(tmp_path)
log_fn(f"H.264 AUTO-CONVERT ERROR -> {exc}")
threading.Thread(target=_worker, daemon=True).start()
+685
View File
@@ -0,0 +1,685 @@
import os
import cv2
import numpy as np
import time
import json
import sqlite3
from datetime import datetime, timedelta
from shapely.geometry import Point, Polygon
import torch
from ultralytics import YOLO
# Import repo rafan 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
# =====================================================================
# 1. KONFIGURASI PENGUJIAN (TEST CONFIGURATION)
# =====================================================================
# Aktifkan MODE CEPAT agar Anda tidak perlu menunggu 5 menit untuk pengujian.
# Jika True: Timeout dipercepat agar transisi status bisa dipantau secara instan.
# Jika False: Menggunakan parameter waktu rill yang sama seperti di production.
FAST_TEST_MODE = True
# Lokasi File lokal di folder "D:\untuk asus\algoritma batch"
MODEL_PATH = r"D:\untuk asus\algoritma batch\v1-best.pt"
VIDEO_PATH = r"0727.mp4"
DB_PATH = r"D:\untuk asus\algoritma batch\batch_test.db"
STATE_FILE = r"D:\untuk asus\algoritma batch\current_batch_test.json"
ARCHIVE_OUTPUT = r"D:\untuk asus\algoritma batch\archive_output"
CAMERA_NAME = "CC1"
OBJECT_LABEL = "karung-pakan"
DAILY_CUTOFF_TIME = "00:00"
# --- Parameter State Machine ---
# STRATEGI AKHIR BATCH: Gabungan deteksi TRUK + KARUNG
# Batch berakhir HANYA jika TIDAK ADA truk DAN TIDAK ADA karung terdeteksi
# selama TRUCK_GONE_TOLERANCE detik. Ini mengatasi blind spot CCTV:
# - Truk kapasitas besar (700 karung) bisa menyimpan karung di area blind spot
# - Selama truk masih terlihat, batch tetap terbuka meskipun karung tidak terlihat
# - Batch baru berakhir saat truk benar-benar pergi (keluar dari frame)
SACK_IDLE_TIMEOUT = 5.0 # Jeda sebelum masuk WAITING_FOR_ACTIVITY
MIN_BATCH_DURATION = 2.0 # Durasi minimal batch
TRUCK_GONE_TOLERANCE = 5.0 # Toleransi 5 detik setelah truk+karung hilang
print(f"[INFO] Strategi akhir batch: Truk+Karung gabungan, toleransi {TRUCK_GONE_TOLERANCE}s")
# =====================================================================
# 2. INISIALISASI DATABASE & STATE BATCH
# =====================================================================
active_batch_info = None
def get_counting_date(dt=None):
if dt is None:
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 init_db():
try:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
counting_date TEXT NOT NULL,
batch_number INTEGER NOT NULL,
camera_name TEXT NOT NULL,
object_label TEXT NOT NULL,
count INTEGER NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(counting_date, batch_number, camera_name, object_label)
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS daily_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
counting_date TEXT NOT NULL,
camera_name TEXT NOT NULL,
object_label TEXT NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0,
total_batches INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(counting_date, camera_name, object_label)
)
""")
conn.commit()
conn.close()
print(f"[DB Info] Database SQLite siap di: {DB_PATH}")
except Exception as e:
print(f"[DB Error] Gagal inisialisasi database: {e}")
def get_next_batch_number(counting_date):
try:
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("""
SELECT COALESCE(MAX(batch_number), 0)
FROM batches
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
""", (counting_date, CAMERA_NAME, OBJECT_LABEL))
row = cur.fetchone()
conn.close()
return row[0] + 1
except Exception as e:
print(f"[DB Error] Gagal mendapatkan batch_number: {e}")
return 1
def save_active_batch_state():
global active_batch_info
if active_batch_info is None:
try:
if os.path.exists(STATE_FILE):
os.remove(STATE_FILE)
except Exception:
pass
return
try:
with open(STATE_FILE, 'w', encoding='utf-8') as f:
json.dump(active_batch_info, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"[DB Error] Gagal menulis {STATE_FILE}: {e}")
def finalize_batch(final_count, start_time_iso, end_time_iso):
global active_batch_info
if active_batch_info is None:
return
if final_count == 0:
print(f"\n[BATCH] Batch #{active_batch_info.get('batch_number', 0)} dengan hitungan 0 diabaikan (tidak disimpan ke database).")
active_batch_info = None
save_active_batch_state()
return
counting_date = active_batch_info["counting_date"]
batch_num = active_batch_info["batch_number"]
try:
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
# 1. Simpan data batch
cur.execute("""
INSERT OR REPLACE INTO batches
(counting_date, batch_number, camera_name, object_label, count, start_time, end_time)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_iso, end_time_iso))
# 2. Update ringkasan harian
cur.execute("""
SELECT SUM(count), COUNT(id)
FROM batches
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
""", (counting_date, CAMERA_NAME, OBJECT_LABEL))
sum_row = cur.fetchone()
tot_count = sum_row[0] if sum_row[0] is not None else 0
tot_batches = sum_row[1] if sum_row[1] is not None else 0
cur.execute("""
INSERT OR REPLACE INTO daily_summaries
(counting_date, camera_name, object_label, total_count, total_batches, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches))
conn.commit()
conn.close()
print(f"\n[DB Info] Batch #{batch_num} sukses disimpan ke DB. Total karung: {final_count}")
except Exception as e:
print(f"[DB Error] Gagal menyimpan batch: {e}")
active_batch_info = None
save_active_batch_state()
# =====================================================================
# 3. FUNGSI GAMBAR VISUALISASI HUD & POLIGON
# =====================================================================
def draw_visualization(frame, stable_sacks, roi, counter, batch_mgr, sacks_in_truck, current_tolerance, trucks=None):
h, w = frame.shape[:2]
out = frame.copy()
# 1. Warna Status
state_colors = {
"IDLE": (0, 165, 255), # Oranye
"TRUCK_STABILIZING": (0, 230, 255), # Kuning
"COUNTING_SACKS": (0, 200, 0), # Hijau
"WAITING_FOR_ACTIVITY": (255, 200, 100) # Biru Muda
}
state_labels = {
"IDLE": "WAITING FOR FIRST SACK CROSSING",
"TRUCK_STABILIZING": "STABILIZING SESSION...",
"COUNTING_SACKS": "BATCH RUNNING - COUNTING ACTIVE",
"WAITING_FOR_ACTIVITY": "PAUSED - WAITING FOR SACKS OR DEPARTURE"
}
current_state = batch_mgr.state
lbl_color = state_colors.get(current_state, (140, 140, 140))
lbl_text = state_labels.get(current_state, "UNKNOWN")
# 2. Gambar Zona Deteksi Sacks (Detection Area)
scale_x = 1280.0 / 1920.0
scale_y = 720.0 / 1080.0
detection_poly_pts = np.array([
[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)],
], dtype=np.int32)
cv2.polylines(out, [detection_poly_pts], True, (255, 0, 255), 1, lineType=cv2.LINE_AA)
# 3. Gambar ZONA_TRUCK (Tempat monitor sacks)
truck_poly_pts = np.array([
[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)],
], dtype=np.int32)
truck_color = (0, 255, 0) if current_state == "COUNTING_SACKS" else (0, 140, 255)
cv2.polylines(out, [truck_poly_pts], True, truck_color, 2, lineType=cv2.LINE_AA)
# Isian Transparan Zona Truck
overlay = out.copy()
cv2.fillPoly(overlay, [truck_poly_pts], truck_color)
cv2.addWeighted(overlay, 0.08, out, 0.92, 0, out)
# Label Zona Truk
cv2.putText(out, "ZONA TRUK (PRESENCE MONITOR)", (truck_poly_pts[0][0] + 10, truck_poly_pts[0][1] - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, truck_color, 1, cv2.LINE_AA)
# 4. Gambar Line Crossing Zone
if roi is not None:
y = roi.line_y
cv2.line(out, (roi.x1, y), (roi.x2, y), (255, 0, 255), 2, lineType=cv2.LINE_AA)
margin = 20
cv2.rectangle(overlay, (roi.x1, y - margin), (roi.x2, y + margin), (255, 0, 255), -1)
cv2.addWeighted(overlay, 0.1, out, 0.9, 0, out)
cv2.putText(out, f"COUNT LINE Y={y}", (roi.x1 + 10, y - margin - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1, cv2.LINE_AA)
# 5a. Gambar TRUCK bounding boxes (hijau)
if trucks:
for det in trucks:
x1, y1, x2, y2 = [int(v) for v in det.bbox]
tid = det.track_id
lbl = f"truck"
if tid is not None:
lbl += f" #{tid}"
lbl += f" {det.confidence:.0%}"
cv2.rectangle(out, (x1, y1), (x2, y2), (0, 200, 0), 2)
cv2.putText(out, lbl, (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 200, 0), 1, cv2.LINE_AA)
# 5b. Gambar Sacks terdeteksi
for det in stable_sacks:
x1, y1, x2, y2 = [int(v) for v in det.bbox]
cx, cy = int((x1 + x2) / 2), int((y1 + y2) / 2)
tid = det.track_id
lbl = f"sack"
if tid is not None:
lbl += f" #{tid}"
lbl += f" {det.confidence:.0%}"
# Tandai karung
cv2.rectangle(out, (x1, y1), (x2, y2), (0, 255, 255), 2)
# Garis pemicu (y1 - bagian atas karung)
cv2.line(out, (x1, y1), (x2, y1), (0, 255, 0), 3)
# Centroid dot
cv2.circle(out, (cx, cy), 4, (0, 255, 255), -1)
cv2.putText(out, lbl, (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA)
# 6. Gambar Dedup Circles (Radius Anti-Double Count)
now_t = time.time()
for circle in counter._dedup_circles:
if (now_t - circle["time"]) <= 3.0:
cx, cy = int(circle["x"]), int(circle["y"])
cv2.circle(out, (cx, cy), int(counter._dedup_radius), (0, 255, 255), 2, lineType=cv2.LINE_AA)
cv2.circle(out, (cx, cy), 4, (0, 255, 0), -1)
cv2.putText(out, f"DEDUP #{circle['track_id']}", (cx - 25, cy - int(counter._dedup_radius) - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1, cv2.LINE_AA)
# 7. Gambar Stats HUD Panel (Top-Left)
cv2.rectangle(out, (10, 10), (340, 175), (0, 0, 0), -1)
cv2.rectangle(out, (10, 10), (340, 175), (255, 255, 255), 1)
batch_num_text = f"Batch #{batch_mgr.current_batch_id}" if batch_mgr.is_active else "IDLE"
cv2.putText(out, f"SACK COUNTING BATCH SYSTEM", (20, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA)
cv2.putText(out, f"Active Session: {batch_num_text}", (20, 55), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 230, 255), 2, cv2.LINE_AA)
cv2.putText(out, f"Sacks Counted (IN): {counter.loading_count}", (20, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 2, cv2.LINE_AA)
cv2.putText(out, f"Sacks In Truck: {sacks_in_truck}", (20, 115), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 0), 2, cv2.LINE_AA)
mode_lbl = "FAST TEST MODE (ACCELERATED)" if FAST_TEST_MODE else "NORMAL MODE"
cv2.putText(out, f"Mode: {mode_lbl}", (20, 145), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 165, 255), 1, cv2.LINE_AA)
cv2.putText(out, f"Tolerance Limit: {current_tolerance:.0f}s", (20, 162), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA)
# 8. Gambar Sesi Riwayat Batch Sebelumnya
if batch_mgr.history:
y_hist = 200
cv2.putText(out, "RECENT BATCH HISTORY:", (20, y_hist), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 230, 255), 1, cv2.LINE_AA)
for rec in batch_mgr.history[-3:]:
y_hist += 20
h_text = f"Batch #{rec.batch_id} -> Count: {rec.loading_count} sacks ({rec.duration_seconds:.1f}s)"
cv2.putText(out, h_text, (20, y_hist), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA)
# 9. Gambar Status Bar di Bagian Bawah
bar_h = 40
bar_y = h - bar_h
cv2.rectangle(out, (0, bar_y), (w, h), (20, 20, 20), -1)
cv2.line(out, (0, bar_y), (w, bar_y), (255, 255, 255), 1)
# Dot Indikator Status
cv2.circle(out, (25, bar_y + bar_h // 2), 7, lbl_color, -1)
cv2.circle(out, (25, bar_y + bar_h // 2), 7, (255, 255, 255), 1)
# Label Status
cv2.putText(out, f"STATUS: {current_state} - {lbl_text}", (45, bar_y + bar_h // 2 + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, lbl_color, 2, cv2.LINE_AA)
# 10. Tambah Detail Timer di Status Bar
if current_state == "COUNTING_SACKS":
dur = batch_mgr.batch_duration
idle = batch_mgr.time_since_last_sack_activity
timer_text = f"Duration: {dur:.1f}s | Idle Time: {idle:.1f}s / {SACK_IDLE_TIMEOUT:.1f}s"
cv2.putText(out, timer_text, (w - 400, bar_y + bar_h // 2 + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA)
elif current_state == "WAITING_FOR_ACTIVITY":
waiting = batch_mgr.waiting_duration
time_left = max(0.0, current_tolerance - waiting)
timer_text = f"Waiting: {waiting:.1f}s | Time Left: {time_left:.1f}s / {current_tolerance:.1f}s"
cv2.putText(out, timer_text, (w - 400, bar_y + bar_h // 2 + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 200, 255), 1, cv2.LINE_AA)
return out
# =====================================================================
# 4. FUNGSI UTAMA PENJALANAN PREDIKSI & ALGORITMA BATCH
# =====================================================================
def run_batch_simulation():
# Inisialisasi Database
init_db()
# Device inferensi
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"[INFO] Menggunakan device: {device}")
# load model YOLO
print(f"[INFO] Membuka model: {MODEL_PATH}")
model = YOLO(MODEL_PATH)
# Warm-up model
print("[INFO] Warm-up model YOLO...")
dummy_frame = np.zeros((720, 1280, 3), dtype=np.uint8)
_ = model(dummy_frame, imgsz=640, device=device, verbose=False)
# Setup trackers, stabilizers, counters, dll.
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
scale_x = 1280.0 / 1920.0
scale_y = 720.0 / 1080.0
# Poligon untuk verifikasi sacks di area
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)
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)
static_line_y = int(330 * scale_y)
static_line_x_start = int(577 * scale_x)
static_line_x_end = int(1401 * scale_x)
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
)
# Setup Batch Manager
# CATATAN: truck_gone_tolerance awal harus > 0 agar TRUCK_STABILIZING tidak langsung
# kembali ke IDLE di frame berikutnya saat has_crossing=False.
# Toleransi 0.0s (instan) hanya diterapkan saat WAITING_FOR_ACTIVITY.
batch_mgr = BatchLifecycleManager(
stabilize_seconds=0.0, # Start batch secara instan ketika ada karung melintasi garis
stabilize_threshold_px=9999.0, # Matikan displacement threshold
sack_idle_timeout=SACK_IDLE_TIMEOUT,
min_batch_duration=MIN_BATCH_DURATION,
truck_gone_tolerance=3.0, # Toleransi awal 3s untuk TRUCK_STABILIZING
)
# Membuka file video
print(f"[INFO] Membuka file video: {VIDEO_PATH}")
cap = cv2.VideoCapture(VIDEO_PATH)
if not cap.isOpened():
print(f"[ERROR] Gagal membuka video: {VIDEO_PATH}")
return
fps = cap.get(cv2.CAP_PROP_FPS)
if fps <= 0 or np.isnan(fps):
fps = 25.0
frame_idx = 0
# --- Video Recording per Batch ---
video_writer = None
video_path = None
video_frame_count = 0
global active_batch_info
print("\n" + "=" * 50)
print("MULAILAH MENYAKSIKAN ALGORITMA BATCH BEKERJA")
print("Tekan 'q' pada jendela video untuk berhenti.")
print("=" * 50 + "\n")
try:
while cap.isOpened():
ret, frame = cap.read()
if not ret:
print("[INFO] Video selesai dibaca.")
break
# Resize ke 1280x720 untuk koordinat statis
frame = cv2.resize(frame, (1280, 720))
timestamp = time.time()
frame_idx += 1
# Simpan state status batch sebelum update untuk melacak transisi
prev_active = batch_mgr.is_active
prev_state = batch_mgr.state
# 1. Update Tracker YOLO (menggunakan custom config tracker.yaml)
raw_tracked_all = tracker.update(frame, [])
# Hanya proses objek yang pusatnya berada di dalam area deteksi (poligon ungu)
raw_tracked_filtered = [
d for d in raw_tracked_all
if detection_polygon.contains(Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0))
]
# Filter per class (model v1: {0:'truck', 1:'sack'})
raw_tracked_sacks = [d for d in raw_tracked_filtered if d.class_name == "sack"]
raw_tracked_trucks = [d for d in raw_tracked_filtered if d.class_name == "truck"]
# 2. Update Stabilizer Bounding Box
stable = stabilizer.update(raw_tracked_sacks)
# 4. Hitung jumlah karung di dalam ZONA TRUCK (hanya y >= cutoff)
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
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
# 5. Filter ROI & hitung penyeberangan garis
tracked_sacks_in_roi = [d for d in stable if static_roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0)]
events = counter.update(tracked_sacks_in_roi)
has_crossing = len(events) > 0
# ================================================================
# LOGIKA ALGORITMA PENENTUAN BATCH (STATE MACHINE TRANSITIONS)
# ================================================================
current_tolerance = TRUCK_GONE_TOLERANCE
# A. Mulai Batch (Start Batch) - Jika status IDLE atau STABILIZING
if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"):
# Picu transisi batch start saat terdeteksi event penyeberangan karung
batch_mgr.update_truck(has_crossing, (0.0, 0.0), timestamp)
# B. Jalankan Monitoring Batch & Penutupan Dinamis
if batch_mgr.state in ("COUNTING_SACKS", "WAITING_FOR_ACTIVITY"):
# Perbarui aktivitas karung di batch manager
batch_mgr.update_sacks(
has_crossing_event=has_crossing,
sacks_in_area_count=sacks_in_truck_area,
timestamp=timestamp,
loading_count=counter.loading_count,
unloading_count=counter.unloading_count,
)
# STRATEGI AKHIR BATCH: Gabungan Truk + Karung
# Batch tetap terbuka selama SALAH SATU kondisi terpenuhi:
# 1. Ada karung terdeteksi di area truk, ATAU
# 2. Ada truk yang terdeteksi di frame
# Batch berakhir HANYA jika KEDUANYA hilang selama TRUCK_GONE_TOLERANCE detik
if batch_mgr.state == "WAITING_FOR_ACTIVITY":
# 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
truck_still_present = len(raw_tracked_trucks) > 0
anything_detected = (sacks_in_truck_area > 0) or truck_still_present
batch_mgr._truck_gone_tolerance = TRUCK_GONE_TOLERANCE
batch_mgr.update_truck(anything_detected, None, timestamp)
if frame_idx % 30 == 0: # Log setiap 30 frame
print(f"[WAITING] truck={truck_still_present} sacks={sacks_in_truck_area} → keep_open={anything_detected}")
current_tolerance = TRUCK_GONE_TOLERANCE
# Tangani event penyeberangan karung
for ev in events:
print(f"[{datetime.now().strftime('%H:%M:%S')}] [KARUNG] Karung #{ev['track_id']} terhitung melintasi garis. Total saat ini: {counter.loading_count}")
if active_batch_info is not None:
active_batch_info["count"] = counter.loading_count
active_batch_info["last_detection_time"] = datetime.now().isoformat()
save_active_batch_state()
# C. Transisi: Batch Baru Saja Terbuka
if batch_mgr.is_active and not prev_active:
counting_date = get_counting_date()
batch_num = get_next_batch_number(counting_date)
now_iso = datetime.now().isoformat()
active_batch_info = {
"counting_date": counting_date,
"batch_number": batch_num,
"count": 0,
"start_time": now_iso,
"last_detection_time": now_iso
}
save_active_batch_state()
print(f"\n>>> [TRANSISI BATCH] Batch #{batch_num} DIMULAI pada tanggal {counting_date} (Status: COUNTING_SACKS) <<<")
# Mulai rekam video untuk batch ini
date_folder = os.path.join(ARCHIVE_OUTPUT, counting_date)
os.makedirs(date_folder, exist_ok=True)
timestamp_str = datetime.now().strftime("%H-%M-%S")
video_path = os.path.join(date_folder, f"batch_{batch_num}_{timestamp_str}.mp4")
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
video_writer = cv2.VideoWriter(video_path, fourcc, 10.0, (1280, 720))
video_frame_count = 0
if video_writer.isOpened():
print(f"[RECORD] Mulai merekam -> {video_path}")
else:
print(f"[RECORD ERROR] Gagal membuka VideoWriter!")
video_writer = None
# D. Transisi: Batch Baru Saja Selesai/Tutup (Truk Pergi)
elif not batch_mgr.is_active and prev_active:
final_count = counter.loading_count
start_iso = active_batch_info["start_time"] if active_batch_info else datetime.now().isoformat()
end_iso = datetime.now().isoformat()
batch_num = active_batch_info["batch_number"] if active_batch_info else 0
print(f"\n>>> [TRANSISI BATCH] Truk pergi setelah status WAITING. Batch #{batch_num} SELESAI. Total karung: {final_count} <<<")
finalize_batch(final_count, start_iso, end_iso)
# Simpan video rekaman batch
if video_writer is not None:
video_writer.release()
if video_frame_count > 0:
print(f"[RECORD] Video batch #{batch_num} disimpan: {video_path} ({video_frame_count} frames)")
else:
# Hapus file kosong
try:
os.remove(video_path)
except Exception:
pass
video_writer = None
video_frame_count = 0
# Reset penghitung lokal dan stabilizer ID
counter.reset()
stabilizer.reset()
# E. Logging Transisi Status
if batch_mgr.state != prev_state:
print(f"[{datetime.now().strftime('%H:%M:%S')}] [STATE MACHINE] Transisi Status: {prev_state} -> {batch_mgr.state}")
# ================================================================
# TULIS FRAME KE VIDEO (jika batch aktif)
# ================================================================
if batch_mgr.is_active and video_writer is not None and video_writer.isOpened():
video_writer.write(frame)
video_frame_count += 1
# ================================================================
# VISUALISASI
# ================================================================
visualized_frame = draw_visualization(
frame=frame,
stable_sacks=tracked_sacks_in_roi,
roi=static_roi,
counter=counter,
batch_mgr=batch_mgr,
sacks_in_truck=sacks_in_truck_area,
current_tolerance=current_tolerance,
trucks=raw_tracked_trucks,
)
# Tampilkan frame di window OpenCV
cv2.imshow("Pengujian Algoritma Batch", visualized_frame)
# Dengarkan tombol key
key = cv2.waitKey(1) & 0xFF
if key == ord('q'):
print("[INFO] Pengujian dihentikan secara manual oleh pengguna.")
break
finally:
# Bersihkan resource
if video_writer is not None:
video_writer.release()
if video_frame_count > 0 and video_path:
print(f"[RECORD] Video batch terakhir disimpan: {video_path} ({video_frame_count} frames)")
cap.release()
cv2.destroyAllWindows()
print("\n" + "=" * 50)
print("SIMULASI PENGUJIAN SELESAI!")
if active_batch_info is not None:
# Simpan batch yang masih terbuka saat program ditutup
final_count = counter.loading_count
start_iso = active_batch_info["start_time"]
end_iso = datetime.now().isoformat()
batch_num = active_batch_info["batch_number"]
print(f"[INFO] Menyimpan batch aktif terakhir #{batch_num} sebelum menutup program...")
finalize_batch(final_count, start_iso, end_iso)
# Cetak hasil histori batch uji coba
print("Histori Batch Terhitung Selama Simulasi:")
if batch_mgr.history:
for rec in batch_mgr.history:
print(f" - Batch #{rec.batch_id}: Mulai={datetime.fromtimestamp(rec.start_time).strftime('%H:%M:%S')}, Selesai={datetime.fromtimestamp(rec.end_time).strftime('%H:%M:%S')}, Jumlah Karung={rec.loading_count}")
else:
print(" - Tidak ada batch selesai yang tersimpan (atau semua batch bernilai 0).")
print("=" * 50)
if __name__ == "__main__":
run_batch_simulation()
+62 -19
View File
@@ -34,6 +34,16 @@ class AutolabelRequest(BaseModel):
min_box_frac: float = 0.0 min_box_frac: float = 0.0
resume: bool = False resume: bool = False
append: bool = False append: bool = False
custom_model_path: Optional[str] = None
class PreviewRequest(BaseModel):
frame_id: int
engine: str
threshold: float = autolabel.DEFAULT_THRESHOLD
iou_threshold: float = autolabel.DEFAULT_IOU
min_box_frac: float = 0.0
target_class_names: Optional[list[str]] = None
custom_model_path: Optional[str] = None
@router.post("/api/projects/{project_id}/batches") @router.post("/api/projects/{project_id}/batches")
@@ -94,9 +104,10 @@ def start_autolabel(batch_id: int, request: AutolabelRequest) -> dict:
engine_list = request.engines if (request.engines and len(request.engines) > 0) else [request.engine] engine_list = request.engines if (request.engines and len(request.engines) > 0) else [request.engine]
return autolabel.start(batch_id, request.threshold, request.iou_threshold, return autolabel.start(batch_id, request.threshold, request.iou_threshold,
request.min_box_frac, resume=request.resume, append=request.append, request.min_box_frac, resume=request.resume, append=request.append,
engines=engine_list, class_ids=request.class_ids, engine=request.engine, engines=engine_list, class_ids=request.class_ids,
engine_classes=request.engine_classes, engine_classes=request.engine_classes,
target_class_names=request.target_class_names) target_class_names=request.target_class_names,
custom_model_path=request.custom_model_path)
except batch_store.BatchError as exc: except batch_store.BatchError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc))
@router.post("/api/batches/inspect-model") @router.post("/api/batches/inspect-model")
@@ -113,7 +124,7 @@ async def inspect_model(file: UploadFile = File(...)) -> dict:
except Exception as exc: except Exception as exc:
if os.path.exists(staged_path): if os.path.exists(staged_path):
os.unlink(staged_path) os.unlink(staged_path)
raise HTTPException(400, f"Could not inspect model: {exc}") raise HTTPException(400, f"Invalid model: {exc}")
@router.post("/api/batches/{batch_id}/autolabel-with-model") @router.post("/api/batches/{batch_id}/autolabel-with-model")
@@ -121,7 +132,7 @@ async def autolabel_with_model(
batch_id: int, batch_id: int,
file: UploadFile = File(...), file: UploadFile = File(...),
threshold: float = Form(0.35), threshold: float = Form(0.35),
iou_threshold: float = Form(0.8), iou_threshold: float = Form(0.0),
selected_classes: str = Form("[]"), selected_classes: str = Form("[]"),
append: bool = Form(True), append: bool = Form(True),
) -> dict: ) -> dict:
@@ -139,6 +150,7 @@ async def autolabel_with_model(
threshold=threshold, threshold=threshold,
iou_threshold=iou_threshold, iou_threshold=iou_threshold,
append=append, append=append,
engine="custom",
custom_model_path=staged_path, custom_model_path=staged_path,
target_class_names=target_classes, target_class_names=target_classes,
) )
@@ -148,13 +160,39 @@ async def autolabel_with_model(
raise HTTPException(400, f"Auto-annotation failed to start: {exc}") raise HTTPException(400, f"Auto-annotation failed to start: {exc}")
@router.post("/api/batches/{batch_id}/preview")
def preview_autolabel(batch_id: int, request: PreviewRequest) -> dict:
from backend import autolabel, jobs
if not jobs.gpu_lock.acquire(timeout=20):
busy = jobs.running_types()
kind = busy[0] if busy else "background"
raise HTTPException(409, f"The GPU is busy with a {kind} job — wait for it to finish")
try:
shapes = autolabel.preview_frame(
batch_id=batch_id,
frame_id=request.frame_id,
engine=request.engine,
threshold=request.threshold,
iou_threshold=request.iou_threshold,
min_box_frac=request.min_box_frac,
target_class_names=request.target_class_names,
custom_model_path=request.custom_model_path
)
return {"shapes": shapes}
except Exception as exc:
raise HTTPException(400, str(exc))
finally:
jobs.gpu_lock.release()
@router.post("/api/sam3/playground-test") @router.post("/api/sam3/playground-test")
async def sam3_playground_test( async def sam3_playground_test(
file: UploadFile = File(...), file: UploadFile = File(...),
prompts: str = Form(...), prompts: str = Form(...),
threshold: float = Form(0.35), threshold: float = Form(0.35),
iou_threshold: float = Form(0.8), iou_threshold: float = Form(0.0),
) -> dict: ) -> dict:
from PIL import Image from PIL import Image
from backend import labeling from backend import labeling
from backend.sam3_engine import get_engine from backend.sam3_engine import get_engine
@@ -169,12 +207,20 @@ async def sam3_playground_test(
if not prompt_list: if not prompt_list:
raise HTTPException(400, "At least one text prompt is required") raise HTTPException(400, "At least one text prompt is required")
from backend import jobs
if not jobs.gpu_lock.acquire(timeout=20):
busy = jobs.running_types()
kind = busy[0] if busy else "background"
raise HTTPException(409, f"The GPU is busy with a {kind} job — wait for it to finish")
try: try:
engine = get_engine() engine = get_engine()
raw_dets = engine.detect(image, prompt_list, threshold) raw_dets = engine.detect(image, prompt_list, threshold)
kept_dets = labeling.deduplicate(raw_dets, iou_threshold=iou_threshold) kept_dets = labeling.deduplicate(raw_dets, iou_threshold=iou_threshold)
except Exception as exc: except Exception as exc:
raise HTTPException(500, f"SAM3 inference failed: {exc}") raise HTTPException(500, f"SAM3 inference failed: {exc}")
finally:
jobs.gpu_lock.release()
results = [] results = []
for det in kept_dets: for det in kept_dets:
@@ -213,10 +259,18 @@ def approve_all_batch_frames(batch_id: int) -> dict:
return {"approved_count": updated} return {"approved_count": updated}
@router.post("/api/batches/{batch_id}/approve") class ApproveRequest(BaseModel):
def approve_batch(batch_id: int) -> dict: dataset_id: Optional[int] = None
dataset_name: str = ""
@router.post("/api/batches/{batch_ids}/approve")
def approve_batch(batch_ids: str, request: ApproveRequest = ApproveRequest()) -> dict:
"""`batch_ids` is one id or a comma-separated selection — one merge, one
dataset, however many batches Data Prep was tuned against (REQ-131)."""
try: try:
return dataset.approve(batch_id) return dataset.approve(batch_ids, dataset_id=request.dataset_id,
dataset_name=request.dataset_name)
except dataset.DatasetError as exc: except dataset.DatasetError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc))
@@ -227,17 +281,6 @@ def dataset_summary(project_id: int) -> dict:
return dataset.summary(project_id) return dataset.summary(project_id)
@router.get("/api/projects/{project_id}/dataset/download")
def dataset_download(project_id: int):
project = project_or_404(project_id)
try:
path = dataset.zip_path(project)
except dataset.DatasetError as exc:
raise HTTPException(400, str(exc))
return FileResponse(path, media_type="application/zip",
filename=f"{project['slug']}-dataset.zip")
@router.get("/api/frames/{frame_id}/image") @router.get("/api/frames/{frame_id}/image")
def frame_image(frame_id: int, w: int = 0): def frame_image(frame_id: int, w: int = 0):
path = batch_store.frame_path(frame_id) path = batch_store.frame_path(frame_id)
+164
View File
@@ -0,0 +1,164 @@
"""Counting bench routes: the scored table and the background recount (REQ-150…153)."""
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from backend import archive_index, counting_bench, jobs
from backend.api.common import project_or_404
router = APIRouter(tags=["counting-bench"])
class GroundTruthRequest(BaseModel):
video_rel: str
# Null clears it, which has to be distinguishable from "not sent" — a
# cleared row must drop back out of the accuracy totals.
ground_truth: Optional[int] = None
class ClockRequest(BaseModel):
video_rel: str
# ISO "2026-08-13 08:27:28"; null clears a manual entry and lets a rescan
# read the overlay again.
started_at: Optional[str] = None
class ScanRequest(BaseModel):
rescan: bool = False
class CountRequest(BaseModel):
video_rels: List[str] = []
model_path: str = ""
model_version_id: Optional[int] = None
params: Optional[dict] = None
recount: bool = False
all_videos: bool = False
@router.get("/api/projects/{project_id}/counting-bench")
def listing(project_id: int, date: Optional[str] = None) -> dict:
project_or_404(project_id)
try:
payload = counting_bench.listing(project_id, date)
except counting_bench.CountingBenchError as exc:
raise HTTPException(400, str(exc))
payload["defaults"] = counting_bench.DEFAULTS
running = [job for job in (j.to_dict() for j in jobs.listing(project_id))
if job["status"] in ("queued", "running")]
payload["active_job"] = next((j for j in running if j["type"] == "count"), None)
payload["scan_job"] = next((j for j in running if j["type"] == "clock-scan"), None)
return payload
class TruckScanRequest(BaseModel):
model_path: str = ""
model_version_id: Optional[int] = None
rescan: bool = False
@router.post("/api/projects/{project_id}/archive/truck-scan")
def truck_scan(project_id: int, body: TruckScanRequest) -> dict:
"""Check every recording actually contains a truck (REQ-166)."""
project_or_404(project_id)
from backend.api.live_count import available_models
path = body.model_path
models = available_models(project_id)["models"]
if not path and body.model_version_id is not None:
path = next((m["path"] for m in models
if m["version_id"] == body.model_version_id), "")
# No model named: the newest trained version, which is what "v4" means.
if not path:
path = models[0]["path"] if models else ""
if not path:
raise HTTPException(400, "This project has no model to detect trucks with")
try:
return archive_index.queue_truck_scan(project_id, path, rescan=body.rescan)
except archive_index.ArchiveIndexError as exc:
raise HTTPException(400, str(exc))
@router.get("/api/projects/{project_id}/archive/cycles")
def archive_cycles(project_id: int) -> dict:
"""The archive grouped into 06:00-to-05:59 cycles (REQ-165)."""
project_or_404(project_id)
try:
return {"cycles": archive_index.cycles(project_id)}
except archive_index.ArchiveIndexError as exc:
raise HTTPException(400, str(exc))
@router.get("/api/projects/{project_id}/archive/cycles/{cycle}")
def archive_cycle_videos(project_id: int, cycle: str) -> dict:
project_or_404(project_id)
try:
return {"videos": archive_index.cycle_videos(project_id, cycle)}
except archive_index.ArchiveIndexError as exc:
raise HTTPException(400, str(exc))
@router.post("/api/projects/{project_id}/counting-bench/scan-clock")
def scan_clock(project_id: int, body: ScanRequest) -> dict:
"""Read the burned-in timestamp off every recording (REQ-160)."""
project_or_404(project_id)
try:
return archive_index.queue_scan(project_id, rescan=body.rescan)
except archive_index.ArchiveIndexError as exc:
raise HTTPException(400, str(exc))
@router.patch("/api/projects/{project_id}/counting-bench/clock")
def set_clock(project_id: int, body: ClockRequest) -> dict:
"""Hand-correct a start time the reader could not get right."""
import datetime
project_or_404(project_id)
stamp = None
if body.started_at:
for shape in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M"):
try:
stamp = datetime.datetime.strptime(body.started_at, shape)
break
except ValueError:
continue
if stamp is None:
raise HTTPException(400, "Use the form 2026-08-13 08:27:28")
return archive_index.set_manual(
project_id, body.video_rel,
stamp.strftime("%Y-%m-%d %H:%M:%S") if stamp else None)
@router.patch("/api/projects/{project_id}/counting-bench/ground-truth")
def set_ground_truth(project_id: int, body: GroundTruthRequest) -> dict:
project_or_404(project_id)
if body.ground_truth is not None and body.ground_truth < 0:
raise HTTPException(400, "A ground truth count cannot be negative")
return counting_bench.set_ground_truth(project_id, body.video_rel, body.ground_truth)
@router.post("/api/projects/{project_id}/counting-bench/run")
def run(project_id: int, body: CountRequest) -> dict:
project_or_404(project_id)
path = body.model_path
if not path and body.model_version_id is not None:
from backend.api.live_count import available_models
for item in available_models(project_id)["models"]:
if item["version_id"] == body.model_version_id:
path = item["path"]
break
if not path:
raise HTTPException(400, "Pick a model to count with")
rels = body.video_rels
if body.all_videos:
rels = [row["video_rel"] for row in counting_bench.listing(project_id)["rows"]]
try:
return counting_bench.queue(project_id, rels, path,
params=body.params, recount=body.recount)
except counting_bench.CountingBenchError as exc:
raise HTTPException(400, str(exc))
+101
View File
@@ -0,0 +1,101 @@
"""Named dataset routes: list, rename, delete, download (REQ-110…113)."""
import os
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from backend import dataset, datasets
from backend.api.common import project_or_404
router = APIRouter(tags=["datasets"])
class CreateRequest(BaseModel):
name: str = ""
note: str = ""
class RenameRequest(BaseModel):
name: Optional[str] = None
note: Optional[str] = None
class CombineRequest(BaseModel):
dataset_ids: List[int]
@router.get("/api/projects/{project_id}/datasets")
def list_datasets(project_id: int) -> dict:
project_or_404(project_id)
return {"datasets": datasets.listing(project_id)}
@router.post("/api/projects/{project_id}/datasets")
def create_dataset(project_id: int, body: CreateRequest) -> dict:
project_or_404(project_id)
return datasets.create(project_id, name=body.name, note=body.note)
@router.get("/api/datasets/{dataset_id}")
def get_dataset(dataset_id: int) -> dict:
found = datasets.get(dataset_id)
if found is None:
raise HTTPException(404, "No such dataset")
return found
@router.patch("/api/datasets/{dataset_id}")
def rename_dataset(dataset_id: int, body: RenameRequest) -> dict:
if datasets.get(dataset_id) is None:
raise HTTPException(404, "No such dataset")
return datasets.rename(dataset_id, name=body.name, note=body.note)
@router.delete("/api/datasets/{dataset_id}")
def delete_dataset(dataset_id: int) -> dict:
if not datasets.delete(dataset_id):
raise HTTPException(404, "No such dataset")
return {"deleted": True}
@router.post("/api/projects/{project_id}/datasets/combine-preview")
def combine_preview(project_id: int, body: CombineRequest) -> dict:
"""What a run over these datasets would actually see.
The totals of two datasets do not add up when they share frames, and being
handed 4,000 images after picking two datasets of 3,000 is the kind of
surprise that makes people distrust the numbers.
"""
project_or_404(project_id)
items = datasets.combined_items(project_id, body.dataset_ids)
report = datasets.overlap_report(project_id, body.dataset_ids, len(items))
report["splits"] = {
"train": sum(1 for item in items if item["split"] == "train"),
"val": sum(1 for item in items if item["split"] == "val"),
}
return report
@router.post("/api/datasets/{dataset_id}/resync")
def resync_dataset(dataset_id: int) -> dict:
try:
return dataset.resync(dataset_id)
except datasets.DatasetError as exc:
raise HTTPException(400, str(exc))
@router.get("/api/datasets/{dataset_id}/download")
def download_dataset(dataset_id: int):
found = datasets.get(dataset_id)
if found is None:
raise HTTPException(404, "No such dataset")
project = project_or_404(found["project_id"])
try:
path = dataset.zip_path(project, dataset_id)
except datasets.DatasetError as exc:
raise HTTPException(400, str(exc))
return FileResponse(path, media_type="application/zip",
filename=os.path.basename(path))
+142
View File
@@ -0,0 +1,142 @@
"""Live counting routes: start/stop a session and watch it as MJPEG."""
import os
import time
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional
from backend import library, live_count, training
from backend.api.common import project_or_404
router = APIRouter(tags=["live-count"])
class StartRequest(BaseModel):
# Either a raw source (RTSP URL or absolute path) or an archive-relative
# path like "2026-08-13/batch001.mp4", which the backend resolves — the
# frontend never needs to know where the archive is mounted.
source: str = ""
source_rel: Optional[str] = None
model_path: Optional[str] = None
model_version_id: Optional[int] = None
line_y: int = 266
line_x_start: int = 469
line_x_end: int = 910
conf: float = 0.35
dedup_radius: float = 60.0
margin: int = 5
imgsz: int = 640
# Ghost rejection and spatial dedup pull in opposite directions, so they are
# separate dials now (REQ-140).
entry_travel_min: float = 60.0
handoff_radius: float = 100.0
unload_confirm_frames: int = 3
min_area_scale: float = 1.0
spatial_dedup: bool = False
@router.get("/api/projects/{project_id}/live-count/models")
def available_models(project_id: int) -> dict:
"""Weights this project can count with: its trained versions, then its base."""
project = project_or_404(project_id)
out = []
for version in training.listing(project_id):
if version.get("weights_path") and os.path.isfile(version["weights_path"]):
out.append({
"label": f"v{version['version']}",
"path": version["weights_path"],
"version_id": version["id"],
})
base = project.get("base_model_path")
if base and os.path.isfile(base):
out.append({"label": "base model", "path": base, "version_id": None})
return {"models": out}
@router.post("/api/projects/{project_id}/live-count/start")
def start(project_id: int, request: StartRequest) -> dict:
project = project_or_404(project_id)
source = request.source
if request.source_rel:
try:
source = library.resolve(project["video_root"], request.source_rel)
except library.LibraryError as exc:
raise HTTPException(400, str(exc))
if not source:
raise HTTPException(400, "Pick a video or enter a stream URL")
path = request.model_path
if not path and request.model_version_id is not None:
for item in available_models(project_id)["models"]:
if item["version_id"] == request.model_version_id:
path = item["path"]
break
if not path:
raise HTTPException(400, "Pick a model to count with")
try:
return live_count.start(
source=source, model_path=path, line_y=request.line_y,
line_x_start=request.line_x_start, line_x_end=request.line_x_end,
conf=request.conf, dedup_radius=request.dedup_radius,
margin=request.margin, imgsz=request.imgsz,
entry_travel_min=request.entry_travel_min,
handoff_radius=request.handoff_radius,
unload_confirm_frames=request.unload_confirm_frames,
min_area_scale=request.min_area_scale,
spatial_dedup=request.spatial_dedup,
)
except live_count.LiveCountError as exc:
raise HTTPException(400, str(exc))
class LineRequest(BaseModel):
line_y: Optional[int] = None
line_x_start: Optional[int] = None
line_x_end: Optional[int] = None
@router.patch("/api/live-count/line")
def move_line(request: LineRequest) -> dict:
"""Reposition the counting line mid-session, without losing the counts."""
try:
return live_count.move_line(request.line_y, request.line_x_start, request.line_x_end)
except live_count.LiveCountError as exc:
raise HTTPException(400, str(exc))
@router.post("/api/live-count/stop")
def stop() -> dict:
return live_count.stop()
@router.get("/api/live-count/status")
def status() -> dict:
return live_count.status()
@router.get("/api/live-count/stream")
def stream():
"""MJPEG of the annotated frames. Ends when the session does."""
def frames():
blank_streak = 0
while True:
jpeg = live_count.snapshot()
if jpeg is None:
blank_streak += 1
if blank_streak > 100 or not live_count.status().get("running"):
return
time.sleep(0.1)
continue
blank_streak = 0
yield (b"--frame\r\nContent-Type: image/jpeg\r\n"
b"Content-Length: " + str(len(jpeg)).encode() + b"\r\n\r\n"
+ jpeg + b"\r\n")
time.sleep(0.05)
return StreamingResponse(frames(),
media_type="multipart/x-mixed-replace; boundary=frame",
headers={"Cache-Control": "no-store"})
+4
View File
@@ -20,6 +20,8 @@ class TrainRequest(BaseModel):
device: Optional[Union[int, str]] = None device: Optional[Union[int, str]] = None
batch_ids: Optional[list] = None batch_ids: Optional[list] = None
class_ids: Optional[list] = None class_ids: Optional[list] = None
dataset_ids: Optional[list] = None
base_dataset_ids: Optional[list] = None
@router.get("/api/hardware") @router.get("/api/hardware")
@@ -36,6 +38,8 @@ def start_training(project_id: int, request: TrainRequest) -> dict:
{"batch": request.batch, "imgsz": request.imgsz, "device": request.device}, {"batch": request.batch, "imgsz": request.imgsz, "device": request.device},
batch_ids=request.batch_ids, batch_ids=request.batch_ids,
class_ids=request.class_ids, class_ids=request.class_ids,
dataset_ids=request.dataset_ids,
base_dataset_ids=request.base_dataset_ids,
) )
except training.TrainingError as exc: except training.TrainingError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc))
+9
View File
@@ -173,7 +173,16 @@ def stream_video(project_id: int, rel: str, request: Request):
except library.LibraryError as exc: except library.LibraryError as exc:
raise HTTPException(404, str(exc)) raise HTTPException(404, str(exc))
parts = rel.split("/", 1)
if len(parts) == 2:
date_label, filename = parts
b_label = library.batch_label(filename)
preview_path = os.path.join(config.project_dir(project["slug"]), "previews", f"{date_label}_{b_label}.mp4")
if os.path.isfile(preview_path):
path = preview_path
media = VIDEO_MEDIA.get(os.path.splitext(path)[1].lower(), "application/octet-stream") media = VIDEO_MEDIA.get(os.path.splitext(path)[1].lower(), "application/octet-stream")
size = os.path.getsize(path) size = os.path.getsize(path)
header = request.headers.get("range") header = request.headers.get("range")
if not header or not header.startswith("bytes="): if not header or not header.startswith("bytes="):
+23
View File
@@ -21,6 +21,15 @@ class AnnotationPatch(BaseModel):
geometry: Optional[Dict[str, Any]] = None geometry: Optional[Dict[str, Any]] = None
class BulkRequest(BaseModel):
annotation_ids: List[int]
class BulkReclassRequest(BaseModel):
annotation_ids: List[int]
class_id: int
class StatusRequest(BaseModel): class StatusRequest(BaseModel):
status: str status: str
@@ -67,6 +76,20 @@ def delete_annotation(annotation_id: int) -> dict:
return {"deleted": review_store.delete(annotation_id)} return {"deleted": review_store.delete(annotation_id)}
@router.post("/api/annotations/bulk-delete")
def bulk_delete(request: BulkRequest) -> dict:
return {"deleted": review_store.delete_many(request.annotation_ids)}
@router.post("/api/annotations/bulk-reclass")
def bulk_reclass(request: BulkReclassRequest) -> dict:
try:
return {"updated": review_store.reclass_many(request.annotation_ids,
request.class_id)}
except review_store.ReviewError as exc:
raise HTTPException(400, str(exc))
@router.post("/api/frames/{frame_id}/assist") @router.post("/api/frames/{frame_id}/assist")
def assist(frame_id: int, request: AssistRequest) -> dict: def assist(frame_id: int, request: AssistRequest) -> dict:
try: try:
+222
View File
@@ -0,0 +1,222 @@
"""Triage routes: rules, hand overrides, and the per-batch shape view (REQ-100…108)."""
import io
import os
import shutil
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import List, Optional
from backend import augment
from backend import batches as batch_store
from backend import triage
router = APIRouter()
class AugmentRequest(BaseModel):
settings: dict
@router.get("/api/projects/{project_id}/base-datasets")
def list_base_datasets(project_id: int) -> dict:
from backend import base_dataset
return {"base_datasets": base_dataset.listing(project_id)}
@router.delete("/api/base-datasets/{base_id}")
def delete_base_dataset(base_id: int) -> dict:
from backend import base_dataset, projects
record = base_dataset.get(base_id)
if record is None:
raise HTTPException(404, "No such base dataset")
project = projects.get(record["project_id"])
if not base_dataset.delete(base_id, project["slug"]):
raise HTTPException(404, "No such base dataset")
return {"deleted": True}
@router.get("/api/projects/{project_id}/augment")
def get_augment(project_id: int) -> dict:
try:
return augment.get(project_id)
except augment.AugmentError as exc:
raise HTTPException(404, str(exc))
@router.put("/api/projects/{project_id}/augment")
def put_augment(project_id: int, body: AugmentRequest) -> dict:
try:
return augment.save(project_id, body.settings)
except augment.AugmentError as exc:
raise HTTPException(400, str(exc))
class Rule(BaseModel):
name: str = ""
predicate: dict = {}
action: str
target_class: Optional[int] = None
class RuleList(BaseModel):
rules: List[Rule]
class OverrideRequest(BaseModel):
annotation_ids: List[int]
verdict: str
target_class: Optional[int] = None
class ClearRequest(BaseModel):
annotation_ids: List[int]
@router.get("/api/projects/{project_id}/triage/rules")
def get_rules(project_id: int) -> dict:
return {"rules": triage.rules(project_id)}
@router.put("/api/projects/{project_id}/triage/rules")
def put_rules(project_id: int, body: RuleList) -> dict:
try:
stored = triage.replace_rules(project_id, [item.model_dump() for item in body.rules])
except triage.TriageError as exc:
raise HTTPException(400, str(exc))
return {"rules": stored}
@router.get("/api/batches/{batch_ids}/triage/summary")
def batch_summary(batch_ids: str) -> dict:
"""`batch_ids` is one id or a comma-separated selection (REQ-130)."""
try:
return triage.batch_summary(batch_ids)
except triage.TriageError as exc:
raise HTTPException(404, str(exc))
@router.get("/api/batches/{batch_ids}/triage/shapes")
def batch_page(batch_ids: str, sort: str = "score", offset: int = 0, limit: int = 120) -> dict:
try:
return triage.batch_page(batch_ids, sort=sort, offset=offset, limit=min(limit, 500))
except triage.TriageError as exc:
raise HTTPException(400, str(exc))
@router.post("/api/triage/overrides")
def set_overrides(body: OverrideRequest) -> dict:
try:
triage.set_overrides(body.annotation_ids, body.verdict, body.target_class)
except triage.TriageError as exc:
raise HTTPException(400, str(exc))
return {"updated": len(body.annotation_ids)}
@router.delete("/api/triage/overrides")
def clear_overrides(body: ClearRequest) -> dict:
return {"cleared": triage.clear_overrides(body.annotation_ids)}
@router.get("/api/batches/{batch_ids}/triage/suggest")
def suggest(batch_ids: str) -> dict:
try:
return triage.suggest(batch_ids)
except triage.TriageError as exc:
raise HTTPException(404, str(exc))
@router.post("/api/batches/{batch_ids}/triage/simulate")
def simulate(batch_ids: str, body: RuleList) -> dict:
try:
return triage.simulate(batch_ids, [item.model_dump() for item in body.rules])
except triage.TriageError as exc:
raise HTTPException(400, str(exc))
@router.get("/api/projects/{project_id}/triage/preview")
def preview(project_id: int) -> dict:
return triage.preview(project_id)
@router.get("/api/projects/{project_id}/export")
def export_annotated(project_id: int, batch_ids: str = "", approved_only: bool = False,
include_empty: bool = False):
"""Download annotated frames as a YOLO zip, merged or not — the user's own backup."""
from fastapi.responses import FileResponse
from backend import export
ids = [int(part) for part in batch_ids.split(",") if part.strip().isdigit()]
try:
path = export.build_zip(project_id, ids or None,
approved_only=approved_only, include_empty=include_empty)
except export.ExportError as exc:
raise HTTPException(400, str(exc))
return FileResponse(path, media_type="application/zip", filename=os.path.basename(path))
@router.post("/api/projects/{project_id}/import")
async def import_annotated(project_id: int, file: UploadFile = File(...),
batch_label: str = Form("")) -> dict:
"""Load a previously exported zip back in, as a new batch to keep working on."""
import tempfile
from backend import export
staged = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
try:
shutil.copyfileobj(file.file, staged)
staged.close()
return export.restore_zip(project_id, staged.name, batch_label=batch_label)
except export.ExportError as exc:
raise HTTPException(400, str(exc))
except Exception as exc:
raise HTTPException(400, f"Could not read that zip: {exc}")
finally:
if os.path.exists(staged.name):
os.unlink(staged.name)
@router.get("/api/annotations/{annotation_id}/crop")
def crop(annotation_id: int, pad: float = 0.08):
"""The shape itself, cropped out of its frame — the crop grid judges objects,
not whole frames (REQ-106)."""
from PIL import Image
from backend import db, review
with db.cursor() as cur:
cur.execute("SELECT frame_id, geometry FROM annotations WHERE id = ?", (annotation_id,))
row = cur.fetchone()
if row is None:
raise HTTPException(404, "No such annotation")
import json
box = review.to_box(json.loads(row["geometry"]))
path = batch_store.frame_path(row["frame_id"])
if not path or not os.path.isfile(path):
raise HTTPException(404, "The frame image is missing")
with Image.open(path) as handle:
image = handle.convert("RGB")
width, height = image.size
x0, y0, x1, y1 = box
px, py = (x1 - x0) * pad, (y1 - y0) * pad
crop_box = (
max(0, int((x0 - px) * width)),
max(0, int((y0 - py) * height)),
min(width, int((x1 + px) * width)),
min(height, int((y1 + py) * height)),
)
if crop_box[2] <= crop_box[0] or crop_box[3] <= crop_box[1]:
raise HTTPException(400, "This shape has no area to crop")
cropped = image.crop(crop_box)
cropped.thumbnail((192, 192))
buffer = io.BytesIO()
cropped.save(buffer, format="JPEG", quality=80)
buffer.seek(0)
return StreamingResponse(buffer, media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"})
+448
View File
@@ -0,0 +1,448 @@
"""When each recording actually happened, and which working day it belongs to.
The archive's folders are wrong about both. `2026-08-13/batch001.mp4` was
recorded at 00:07, which under a 06:00-to-06:00 shift belongs to the working day
of 2026-08-12 — and `2026-08-07/batch4.mp4` was recorded the previous evening
entirely. Sampling the archive, roughly a quarter of the files land on a
different working day once their real timestamp is read (REQ-160…163).
Nothing on disk is touched. The archive is mounted read-only and is the user's
own data; this builds an index beside it instead, and every page groups and
orders by the index rather than by the folder name. The original path stays the
file's identity, so results already recorded against it survive.
"""
import os
import time
from typing import List, Optional
from backend import db, jobs, library, projects, video_clock
CUTOFF_HOUR = 6
"""A working day runs 06:00 to 06:00 (REQ-161)."""
MIN_CONFIDENCE = 0.10
MIN_AGREEING = 2
"""Below either of these a reading is kept but flagged: it is a guess, not a
measurement, and one misread digit is what puts a recording on the wrong day."""
class ArchiveIndexError(Exception):
pass
def _trusted(confidence: float, agreeing: int) -> bool:
return confidence >= MIN_CONFIDENCE and agreeing >= MIN_AGREEING
def store(project_id: int, video_rel: str, started_at: Optional[str],
confidence: float = 0.0, agreeing: int = 0, source: str = "ocr",
error: str = "") -> None:
"""`started_at` is wall-clock text, 'YYYY-MM-DD HH:MM:SS'.
Never an epoch. The overlay has no timezone, so converting it to one makes
the answer depend on which timezone the process happens to run in — the
backend container is UTC and the browser is not.
"""
working = ""
if started_at:
working = video_clock.working_day(_as_datetime(started_at), CUTOFF_HOUR)
with db.cursor() as cur:
cur.execute(
"""INSERT INTO video_clock (project_id, video_rel, folder_date, started_at,
working_day, confidence, agreeing, source, error,
read_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, video_rel) DO UPDATE SET
started_at = excluded.started_at, working_day = excluded.working_day,
confidence = excluded.confidence, agreeing = excluded.agreeing,
source = excluded.source, error = excluded.error,
read_at = excluded.read_at""",
(project_id, video_rel, video_rel.split("/")[0], started_at, working,
confidence, agreeing, source, error, time.time()),
)
def _as_datetime(text: str):
import datetime
return datetime.datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
def set_manual(project_id: int, video_rel: str, started_at: Optional[str]) -> dict:
"""A hand-entered start time. Outranks any reading and is never overwritten
by a later scan — the whole point is that it is the one the user verified."""
store(project_id, video_rel, started_at, confidence=1.0, agreeing=99,
source="manual" if started_at is not None else "none")
return index(project_id).get(video_rel, {})
def _sidecar(project: dict, rel: str) -> Optional[dict]:
"""Waktu asli yang ditulis perekam di sebelah videonya (REQ-170).
File baru datang dari MediaMTX, jadi waktunya sudah pasti dari server dan
tidak perlu dibaca OCR sama sekali. Kalau ada sidecar, ia menang atas
pembacaan overlay: sumbernya server, bukan tebakan dari piksel.
"""
import json
try:
path = library.resolve(project["video_root"], rel)
except Exception:
return None
sidecar = os.path.splitext(path)[0] + ".json"
if not os.path.isfile(sidecar):
return None
try:
with open(sidecar, encoding="utf-8") as handle:
payload = json.load(handle)
started = payload.get("started_at")
if not started:
return None
_as_datetime(started) # tolak isi yang tidak berbentuk waktu
return {"started_at": started,
"working_day": video_clock.working_day(_as_datetime(started), CUTOFF_HOUR),
"source": payload.get("source") or "sidecar",
"trusted": True, "confidence": 1.0, "agreeing": 99,
"folder_date": rel.split("/")[0], "error": ""}
except (OSError, ValueError, KeyError):
return None
def index(project_id: int) -> dict:
"""Everything known about the archive's timestamps, keyed by video path."""
with db.cursor() as cur:
cur.execute("SELECT * FROM video_clock WHERE project_id = ?", (project_id,))
rows = [dict(row) for row in cur.fetchall()]
out = {}
for row in rows:
row["trusted"] = bool(row["source"] == "manual"
or _trusted(row["confidence"], row["agreeing"]))
out[row["video_rel"]] = row
return out
def assign_batch_numbers(rows: List[dict]) -> List[dict]:
"""Number the recordings 1..N inside each working day, by real start time.
Rows without a known start keep their folder grouping and sort last within
it: an unreadable recording must not silently take position 1 and push
everything else along.
"""
known = [r for r in rows if r.get("started_at")]
unknown = [r for r in rows if not r.get("started_at")]
known.sort(key=lambda r: (r["working_day"], r["started_at"]))
counters: dict = {}
for row in known:
day = row["working_day"]
counters[day] = counters.get(day, 0) + 1
row["batch_no"] = counters[day]
# Dipanggil dari dua tempat dengan nama kunci berbeda: tabel Counting
# Accuracy memakai `video_rel`, daftar arsip memakai `rel`.
def path_of(row):
return row.get("video_rel") or row.get("rel") or ""
for row in unknown:
row["working_day"] = row.get("working_day") or path_of(row).split("/")[0]
row["batch_no"] = None
unknown.sort(key=path_of)
return known + unknown
def cycles(project_id: int) -> List[dict]:
"""The archive as a list of cycles, newest first (REQ-165).
A cycle is one 06:00-to-05:59 shift, so it always covers two calendar dates
and is named after the one it starts on. Recordings whose start time is not
known yet fall back to their folder name, so nothing disappears from the
archive just because its overlay could not be read.
"""
project = projects.get(project_id)
if project is None:
raise ArchiveIndexError("No such project")
known = index(project_id)
buckets: dict = {}
for day in library.list_dates(project["video_root"]):
for name in _video_names(project, day["date"]):
rel = f"{day['date']}/{name}"
timing = _sidecar(project, rel) or known.get(rel) or {}
cycle = timing.get("working_day") or day["date"]
bucket = buckets.setdefault(cycle, {"cycle": cycle, "video_count": 0,
"flagged": 0, "first_start": None})
bucket["video_count"] += 1
if not timing.get("started_at") or not timing.get("trusted"):
bucket["flagged"] += 1
start = timing.get("started_at")
if start and (bucket["first_start"] is None or start < bucket["first_start"]):
bucket["first_start"] = start
return sorted(buckets.values(), key=lambda b: b["cycle"], reverse=True)
def _video_names(project: dict, date: str) -> List[str]:
"""Filenames only — `library.list_videos` runs ffprobe on every file, which
is far too much work just to count what is in a cycle."""
import os as _os
from backend import video as video_module
folder = _os.path.join(library._effective_root(project["video_root"]), date)
try:
return [f for f in _os.listdir(folder)
if f.lower().endswith(video_module.VIDEO_EXTS)]
except OSError:
return []
def cycle_videos(project_id: int, cycle: str) -> List[dict]:
"""Every recording in one cycle, in the order it was actually made."""
project = projects.get(project_id)
if project is None:
raise ArchiveIndexError("No such project")
known = index(project_id)
# A cycle normally draws from two folders, but a moved recording can come
# from any of them. Ask the index which folders actually contribute rather
# than running ffprobe across the whole archive to find out.
folders = {row["folder_date"] for row in known.values()
if row.get("working_day") == cycle}
folders.add(cycle)
# Berkas baru belum tentu ada di indeks; sidecar-nya bisa memindahkannya ke
# siklus ini dari folder tanggal sebelah.
for day in library.list_dates(project["video_root"]):
if day["date"] in folders:
continue
for name in _video_names(project, day["date"]):
side = _sidecar(project, f"{day['date']}/{name}")
if side and side["working_day"] == cycle:
folders.add(day["date"])
break
rows = []
for day in library.list_dates(project["video_root"]):
if day["date"] not in folders:
continue
for video in library.list_videos(project["video_root"], day["date"], project_id):
rel = video["rel"]
timing = _sidecar(project, rel) or known.get(rel) or {}
if (timing.get("working_day") or day["date"]) != cycle:
continue
rows.append({
**video,
"folder_date": day["date"],
"working_day": timing.get("working_day") or "",
"started_at": timing.get("started_at"),
"clock_trusted": bool(timing.get("trusted")),
"clock_error": timing.get("error") or "",
"moved": bool(timing.get("working_day")
and timing["working_day"] != day["date"]),
"truck_hits": timing.get("truck_hits"),
"truck_samples": timing.get("truck_samples"),
})
return assign_batch_numbers(rows)
TRUCK_SAMPLES = 12
"""Frames sampled per recording for the truck check (REQ-166).
Enough to answer "is there a truck in this recording at all", which is the
assumption the whole batch numbering rests on: recording starts when a truck
arrives and stops when it leaves, so one file is one batch. Reading every frame
to time the truck's arrival precisely would cost hours of GPU for an answer the
recording trigger already gives.
"""
def store_truck(project_id: int, video_rel: str, hits: int, samples: int,
model_path: str) -> None:
"""One statement, one cursor.
This used to UPDATE and then call `store()` for the missing-row case — which
opened a second connection while the first still held a write transaction,
and SQLite answered "database is locked" eight recordings into the scan.
"""
label = os.path.basename(os.path.dirname(model_path))
with db.cursor() as cur:
cur.execute(
"""INSERT INTO video_clock (project_id, video_rel, folder_date,
truck_hits, truck_samples, truck_model,
truck_checked_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, video_rel) DO UPDATE SET
truck_hits = excluded.truck_hits,
truck_samples = excluded.truck_samples,
truck_model = excluded.truck_model,
truck_checked_at = excluded.truck_checked_at""",
(project_id, video_rel, video_rel.split("/")[0], hits, samples,
label, time.time()),
)
def count_truck_frames(path: str, model, conf: float = 0.45,
samples: int = TRUCK_SAMPLES) -> tuple:
"""How many of `samples` evenly spaced frames show a truck."""
import cv2
capture = cv2.VideoCapture(path)
if not capture.isOpened():
raise ArchiveIndexError(f"Could not open {path}")
total = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
hits = taken = 0
try:
for index in range(samples):
# Spread across the middle 90%: the very first and last frames of a
# trigger-started recording can catch the truck half out of shot.
position = int(total * (0.05 + 0.9 * index / max(1, samples - 1)))
capture.set(cv2.CAP_PROP_POS_FRAMES, position)
ok, frame = capture.read()
if not ok or frame is None:
continue
taken += 1
result = model.predict(frame, conf=conf, verbose=False)[0]
names = model.names
if any(names[int(box.cls[0])] == "truck" for box in result.boxes):
hits += 1
finally:
capture.release()
return hits, taken
def queue_truck_scan(project_id: int, model_path: str, rescan: bool = False) -> dict:
project = projects.get(project_id)
if project is None:
raise ArchiveIndexError("No such project")
if not os.path.isfile(model_path):
raise ArchiveIndexError(f"Model not found: {model_path}")
return jobs.create(
"truck-scan",
params={"model_path": model_path, "rescan": rescan},
project_id=project_id,
message="checking each recording for a truck",
).to_dict()
@jobs.handler("truck-scan")
def _run_truck_scan(job) -> None:
import numpy as np
from ultralytics import YOLO
project = projects.get(job.project_id)
model_path = job.params["model_path"]
rescan = bool(job.params.get("rescan"))
known = index(job.project_id)
todo = []
for day in library.list_dates(project["video_root"]):
for name in _video_names(project, day["date"]):
rel = f"{day['date']}/{name}"
row = known.get(rel) or {}
if not rescan and row.get("truck_samples"):
continue
todo.append(rel)
job.progress(0, len(todo))
job.log(f"Checking {len(todo)} recording(s) for a truck, "
f"{TRUCK_SAMPLES} frames each, with {os.path.basename(model_path)}")
model = YOLO(model_path)
model(np.zeros((720, 1280, 3), dtype=np.uint8), imgsz=640, verbose=False)
empty = broken = 0
for position, rel in enumerate(todo):
if job.cancelled:
job.log(f"Cancelled after {position} recording(s)")
return
try:
path = library.resolve(project["video_root"], rel)
hits, taken = count_truck_frames(path, model)
except Exception as exc:
store_truck(job.project_id, rel, 0, 0, model_path)
job.log(f"{rel}: {exc}")
broken += 1
job.progress(position + 1, len(todo))
continue
store_truck(job.project_id, rel, hits, taken, model_path)
if taken and hits == 0:
empty += 1
job.log(f"{rel}: no truck in any of {taken} sampled frames — "
"this recording may not be a batch")
job.progress(position + 1, len(todo), rel)
job.log(f"Done. {empty} recording(s) with no truck, {broken} unreadable")
def queue_scan(project_id: int, rescan: bool = False) -> dict:
project = projects.get(project_id)
if project is None:
raise ArchiveIndexError("No such project")
return jobs.create(
"clock-scan",
params={"rescan": rescan},
project_id=project_id,
message="reading timestamps from the archive",
).to_dict()
@jobs.handler("clock-scan")
def _run_scan(job) -> None:
project = projects.get(job.project_id)
rescan = bool(job.params.get("rescan"))
existing = index(job.project_id)
todo = []
for day in library.list_dates(project["video_root"]):
for video in library.list_videos(project["video_root"], day["date"]):
rel = video["rel"]
known = existing.get(rel)
# A hand-entered time is never re-read; a rescan redoes the rest.
if known and (known["source"] == "manual"
or (not rescan and known["started_at"])):
continue
todo.append(rel)
job.progress(0, len(todo))
job.log(f"Reading the timestamp overlay from {len(todo)} recording(s)")
read = flagged = failed = 0
for position, rel in enumerate(todo):
if job.cancelled:
job.log(f"Cancelled after {position} recording(s)")
return
side = _sidecar(project, rel)
if side is not None:
store(job.project_id, rel, side["started_at"], confidence=1.0,
agreeing=99, source=side["source"])
read += 1
job.progress(position + 1, len(todo), rel)
continue
try:
path = library.resolve(project["video_root"], rel)
result = video_clock.read_video_start(path)
except Exception as exc:
store(job.project_id, rel, None, error=f"{type(exc).__name__}: {exc}")
job.log(f"{rel}: {exc}")
failed += 1
job.progress(position + 1, len(todo))
continue
if result["start"] is None:
store(job.project_id, rel, None, error=result.get("error", "unreadable"))
failed += 1
else:
confidence, agreeing = result["confidence"], result.get("agreeing", 1)
store(job.project_id, rel, result["start"].strftime("%Y-%m-%d %H:%M:%S"),
confidence=confidence, agreeing=agreeing)
if _trusted(confidence, agreeing):
read += 1
else:
flagged += 1
job.log(f"{rel}: {result['start']} — low confidence "
f"({confidence}, {agreeing} frame(s) agreed), needs review")
job.progress(position + 1, len(todo), f"{rel}")
job.log(f"Read {read}, flagged {flagged} for review, {failed} unreadable")
+90
View File
@@ -0,0 +1,90 @@
"""Augmentation settings, stored per project and passed to Ultralytics (REQ-110…113).
Ultralytics augments during training whether or not we ask it to. Before this
module, `training.py` passed no augmentation arguments at all, so every run used
library defaults invisibly. MEDIUM below *is* that default set — a project that
has never been touched trains exactly as it did, only now it is written down.
Validation is never augmented; that is Ultralytics' own behaviour and REQ-112
only requires that we do not defeat it.
"""
import json
from typing import Optional
from backend import db
# name -> (minimum, maximum). Bounds are Ultralytics' own accepted ranges.
FIELDS = {
"fliplr": (0.0, 1.0),
"flipud": (0.0, 1.0),
"degrees": (0.0, 180.0),
"translate": (0.0, 1.0),
"scale": (0.0, 1.0),
"hsv_h": (0.0, 1.0),
"hsv_s": (0.0, 1.0),
"hsv_v": (0.0, 1.0),
"mosaic": (0.0, 1.0),
}
OFF = {name: 0.0 for name in FIELDS}
LIGHT = {"fliplr": 0.5, "flipud": 0.0, "degrees": 0.0, "translate": 0.05,
"scale": 0.2, "hsv_h": 0.010, "hsv_s": 0.4, "hsv_v": 0.3, "mosaic": 0.0}
# Ultralytics' defaults, spelled out.
MEDIUM = {"fliplr": 0.5, "flipud": 0.0, "degrees": 0.0, "translate": 0.1,
"scale": 0.5, "hsv_h": 0.015, "hsv_s": 0.7, "hsv_v": 0.4, "mosaic": 1.0}
AGGRESSIVE = {"fliplr": 0.5, "flipud": 0.1, "degrees": 10.0, "translate": 0.2,
"scale": 0.9, "hsv_h": 0.020, "hsv_s": 0.9, "hsv_v": 0.5, "mosaic": 1.0}
PRESETS = {"off": OFF, "light": LIGHT, "medium": MEDIUM, "aggressive": AGGRESSIVE}
class AugmentError(Exception):
pass
def normalise(incoming: Optional[dict]) -> dict:
"""Fill in missing keys from MEDIUM and reject out-of-range values."""
settings = dict(MEDIUM)
for name, value in (incoming or {}).items():
if name not in FIELDS:
raise AugmentError(f"'{name}' is not an augmentation setting")
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise AugmentError(f"'{name}' must be a number")
low, high = FIELDS[name]
if not low <= value <= high:
raise AugmentError(f"'{name}' must be between {low} and {high}")
settings[name] = float(value)
return settings
def preset_name(settings: dict) -> str:
"""Which preset these settings match, or 'custom'."""
for name, preset in PRESETS.items():
if all(abs(settings[field] - preset[field]) < 1e-9 for field in FIELDS):
return name
return "custom"
def get(project_id: int) -> dict:
with db.cursor() as cur:
cur.execute("SELECT augment FROM projects WHERE id = ?", (project_id,))
row = cur.fetchone()
if row is None:
raise AugmentError("No such project")
stored = json.loads(row[0]) if row[0] else None
settings = normalise(stored)
return {"settings": settings, "preset": preset_name(settings)}
def save(project_id: int, incoming: dict) -> dict:
settings = normalise(incoming)
with db.cursor() as cur:
cur.execute("UPDATE projects SET augment = ? WHERE id = ?",
(json.dumps(settings), project_id))
if cur.rowcount == 0:
raise AugmentError("No such project")
return {"settings": settings, "preset": preset_name(settings)}
+165 -18
View File
@@ -9,11 +9,14 @@ keep the user's own corrections out of the way.
import os import os
from typing import List, Optional from typing import List, Optional
from PIL import Image
from backend import batches, db, jobs, labeling, projects, review from backend import batches, db, jobs, labeling, projects, review
from backend.batches import BatchError from backend.batches import BatchError
DEFAULT_THRESHOLD = 0.35 DEFAULT_THRESHOLD = 0.35
DEFAULT_IOU = 0.8 DEFAULT_IOU = 0.0
def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD, def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD,
@@ -88,11 +91,15 @@ def _run_autolabel(job) -> None:
custom_path = job.params.get("custom_model_path") custom_path = job.params.get("custom_model_path")
target_class_names = job.params.get("target_class_names") target_class_names = job.params.get("target_class_names")
engine_classes = job.params.get("engine_classes")
if not target_class_names and isinstance(engine_classes, dict):
c_names = engine_classes.get(selected_engine) or engine_classes.get("sam3") or []
if isinstance(c_names, list) and len(c_names) > 0:
target_class_names = c_names
if selected_engine == "sam3" and not custom_path: if selected_engine == "sam3" and not custom_path:
allowed_classes_set = {c.strip().lower() for c in target_class_names} if target_class_names else None allowed_classes_set = {c.strip().lower() for c in target_class_names} if target_class_names else None
if allowed_classes_set: if allowed_classes_set:
sam3_target_classes = [c for c in project["classes"] if c["name"].strip().lower() in allowed_classes_set or c["prompt"].strip().lower() in allowed_classes_set]
# Add any new target class names that aren't in project classes yet # Add any new target class names that aren't in project classes yet
existing_names = {c["name"].strip().lower() for c in project["classes"]} existing_names = {c["name"].strip().lower() for c in project["classes"]}
for name in target_class_names: for name in target_class_names:
@@ -100,11 +107,9 @@ def _run_autolabel(job) -> None:
try: try:
updated_proj = projects.add_class(project["id"], name=name.strip(), prompt=name.strip()) updated_proj = projects.add_class(project["id"], name=name.strip(), prompt=name.strip())
project["classes"] = updated_proj["classes"] project["classes"] = updated_proj["classes"]
for new_c in project["classes"]: except Exception as exc:
if new_c["name"].strip().lower() == name.strip().lower() and new_c not in sam3_target_classes: job.log(f"Warning adding class '{name}': {exc}")
sam3_target_classes.append(new_c) sam3_target_classes = [c for c in project["classes"] if c["name"].strip().lower() in allowed_classes_set or c["prompt"].strip().lower() in allowed_classes_set]
except Exception:
pass
else: else:
sam3_target_classes = [c for c in project["classes"]] sam3_target_classes = [c for c in project["classes"]]
@@ -149,6 +154,8 @@ def _run_autolabel(job) -> None:
try: try:
frame_file = os.path.join(directory, frame["filename"]) frame_file = os.path.join(directory, frame["filename"])
fw = max(1, frame.get("width") or 1)
fh = max(1, frame.get("height") or 1)
all_raw_detections = [] all_raw_detections = []
if yolo_model is not None: if yolo_model is not None:
@@ -157,27 +164,41 @@ def _run_autolabel(job) -> None:
model_names = results[0].names model_names = results[0].names
for box in results[0].boxes: for box in results[0].boxes:
cls_idx = int(box.cls[0].item()) cls_idx = int(box.cls[0].item())
cls_name = str(model_names.get(cls_idx, cls_idx)).strip().lower() raw_cls_name = str(model_names.get(cls_idx, cls_idx)).strip().lower()
if allowed_classes_set is not None and cls_name not in allowed_classes_set: target_class_id = name_to_class_id.get(raw_cls_name)
continue if target_class_id is None:
for item in project["classes"]:
if item["class_id"] == cls_idx:
target_class_id = item["class_id"]
break
if target_class_id is None and 0 <= cls_idx < len(project["classes"]):
target_class_id = project["classes"][cls_idx]["class_id"]
target_class_id = name_to_class_id.get(cls_name)
if target_class_id is None: if target_class_id is None:
continue continue
target_cls_obj = next((c for c in project["classes"] if c["class_id"] == target_class_id), None)
proj_cls_name = target_cls_obj["name"].strip().lower() if target_cls_obj else ""
if allowed_classes_set is not None:
if (raw_cls_name not in allowed_classes_set and
proj_cls_name not in allowed_classes_set and
str(target_class_id) not in allowed_classes_set):
continue
score = float(box.conf[0].item()) score = float(box.conf[0].item())
xyxyn = box.xyxyn[0].tolist() xyxyn = box.xyxyn[0].tolist()
all_raw_detections.append(labeling.Detection( all_raw_detections.append(labeling.Detection(
class_id=target_class_id, class_id=target_class_id,
class_name=cls_name, class_name=proj_cls_name or raw_cls_name,
box=[xyxyn[0]*frame["width"], xyxyn[1]*frame["height"], xyxyn[2]*frame["width"], xyxyn[3]*frame["height"]], box=[xyxyn[0]*fw, xyxyn[1]*fh, xyxyn[2]*fw, xyxyn[3]*fh],
score=score, score=score,
mask=None mask=None
)) ))
elif selected_engine == "sam3" and sam3_target_classes: if selected_engine == "sam3" and sam3_target_classes:
prompts = [c["prompt"] for c in sam3_target_classes] prompts = [(c.get("prompt") or c["name"]).strip() for c in sam3_target_classes]
res = labeling.label_image( res = labeling.label_image(
frame_file, frame["filename"], prompts, conf, frame_file, frame["filename"], prompts, conf,
iou_threshold=iou_thresh, min_box_frac=job.params.get("min_box_frac", 0.0) iou_threshold=iou_thresh, min_box_frac=job.params.get("min_box_frac", 0.0)
@@ -189,15 +210,17 @@ def _run_autolabel(job) -> None:
det.class_id = real_cls["class_id"] det.class_id = real_cls["class_id"]
det.class_name = real_cls["name"] det.class_name = real_cls["name"]
all_raw_detections.append(det) all_raw_detections.append(det)
elif res.error:
job.log(f"[SAM3 ERROR] {frame['filename']}: {res.error}")
kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_thresh) kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_thresh)
items = [] items = []
for det in kept: for det in kept:
if project["label_type"] == "bbox" or det.mask is None: if project["label_type"] == "bbox" or det.mask is None:
geom = review.bbox(det.box[0]/frame["width"], det.box[1]/frame["height"], det.box[2]/frame["width"], det.box[3]/frame["height"]) geom = review.bbox(det.box[0]/fw, det.box[1]/fh, det.box[2]/fw, det.box[3]/fh)
items.append({"class_id": det.class_id, "geometry": geom, "score": det.score}) items.append({"class_id": det.class_id, "geometry": geom, "score": det.score})
else: else:
for geometry in _geometries(det, frame["width"], frame["height"], project["label_type"]): for geometry in _geometries(det, fw, fh, project["label_type"]):
items.append({"class_id": det.class_id, "geometry": geometry, "score": det.score}) items.append({"class_id": det.class_id, "geometry": geometry, "score": det.score})
if job.params.get("append"): if job.params.get("append"):
@@ -211,6 +234,8 @@ def _run_autolabel(job) -> None:
job.log(f"[ERROR] {frame['filename']}: {exc}") job.log(f"[ERROR] {frame['filename']}: {exc}")
job.progress(index + 1, len(frames)) job.progress(index + 1, len(frames))
# "Every frame failed" is not a finished job with no findings — it is a # "Every frame failed" is not a finished job with no findings — it is a
# broken run, and reporting `done` for it would be the system lying about # broken run, and reporting `done` for it would be the system lying about
# its own state. An empty frame is fine (REQ-033); an errored one is not. # its own state. An empty frame is fine (REQ-033); an errored one is not.
@@ -234,3 +259,125 @@ def _reset_reviewed(batch_id: int) -> None:
"AND review_status = 'approved'", "AND review_status = 'approved'",
(batch_id,), (batch_id,),
) )
def preview_frame(
batch_id: int,
frame_id: int,
engine: str,
threshold: float = DEFAULT_THRESHOLD,
iou_threshold: float = DEFAULT_IOU,
min_box_frac: float = 0.0,
target_class_names: Optional[List[str]] = None,
custom_model_path: Optional[str] = None
) -> List[dict]:
batch = batches.get(batch_id)
if not batch:
raise ValueError("No such batch")
project = projects.get(batch["project_id"])
frame = next((f for f in batches.frames(batch_id) if f["id"] == frame_id), None)
if not frame:
raise ValueError("Frame not found")
directory = batches.frames_dir(batch["project_slug"], batch_id)
frame_file = os.path.join(directory, frame["filename"])
fw = max(1, frame.get("width") or 1)
fh = max(1, frame.get("height") or 1)
yolo_model = None
sam3_target_classes = []
if engine == "sam3" and not custom_model_path:
allowed_classes_set = {c.strip().lower() for c in target_class_names} if target_class_names else None
if allowed_classes_set:
sam3_target_classes = [c for c in project["classes"] if c["name"].strip().lower() in allowed_classes_set or c["prompt"].strip().lower() in allowed_classes_set]
else:
sam3_target_classes = [c for c in project["classes"]]
prompts = [c["prompt"] for c in sam3_target_classes]
if prompts:
from backend.sam3_engine import get_engine
get_engine()
else:
from ultralytics import YOLO
if custom_model_path and os.path.isfile(custom_model_path):
m_path = custom_model_path
else:
m_path = projects.training_start_point(project)
with db.cursor() as cur:
cur.execute("SELECT weights_path FROM model_versions WHERE project_id = ? ORDER BY version DESC LIMIT 1", (project["id"],))
row = cur.fetchone()
if row and os.path.isfile(row[0]):
m_path = row[0]
yolo_model = YOLO(m_path)
name_to_class_id = {item["name"].strip().lower(): item["class_id"] for item in project["classes"]}
allowed_classes_set = {c.strip().lower() for c in target_class_names} if target_class_names else None
all_raw_detections = []
if yolo_model is not None:
results = yolo_model.predict(frame_file, conf=threshold, verbose=False)
if results and len(results) > 0:
model_names = results[0].names
for box in results[0].boxes:
cls_idx = int(box.cls[0].item())
raw_cls_name = str(model_names.get(cls_idx, cls_idx)).strip().lower()
target_class_id = name_to_class_id.get(raw_cls_name)
if target_class_id is None:
for item in project["classes"]:
if item["class_id"] == cls_idx:
target_class_id = item["class_id"]
break
if target_class_id is None and 0 <= cls_idx < len(project["classes"]):
target_class_id = project["classes"][cls_idx]["class_id"]
if target_class_id is None:
continue
target_cls_obj = next((c for c in project["classes"] if c["class_id"] == target_class_id), None)
proj_cls_name = target_cls_obj["name"].strip().lower() if target_cls_obj else ""
if allowed_classes_set is not None:
if (raw_cls_name not in allowed_classes_set and
proj_cls_name not in allowed_classes_set and
str(target_class_id) not in allowed_classes_set):
continue
score = float(box.conf[0].item())
xyxyn = box.xyxyn[0].tolist()
all_raw_detections.append(labeling.Detection(
class_id=target_class_id,
class_name=proj_cls_name or raw_cls_name,
box=[xyxyn[0]*fw, xyxyn[1]*fh, xyxyn[2]*fw, xyxyn[3]*fh],
score=score,
mask=None
))
if engine == "sam3" and sam3_target_classes:
prompts = [(c.get("prompt") or c["name"]).strip() for c in sam3_target_classes]
res = labeling.label_image(
frame_file, frame["filename"], prompts, threshold,
iou_threshold=iou_threshold, min_box_frac=min_box_frac
)
if not res.error and res.detections:
for det in res.detections:
if 0 <= det.class_id < len(sam3_target_classes):
real_cls = sam3_target_classes[det.class_id]
det.class_id = real_cls["class_id"]
det.class_name = real_cls["name"]
all_raw_detections.append(det)
kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_threshold)
items = []
for det in kept:
if project["label_type"] == "bbox" or det.mask is None:
geom = review.bbox(det.box[0]/fw, det.box[1]/fh, det.box[2]/fw, det.box[3]/fh)
items.append({"class_id": det.class_id, "geometry": geom, "score": det.score})
else:
for geometry in _geometries(det, fw, fh, project["label_type"]):
items.append({"class_id": det.class_id, "geometry": geometry, "score": det.score})
return items
+220
View File
@@ -0,0 +1,220 @@
"""Base datasets: externally-labelled images that ride along with training.
A base dataset is not a batch and never becomes one. It has no frames, no
review, no triage — it is a folder of images and YOLO label files that the user
already trusts, registered against a project and offered as a checkbox next to
the project's own datasets (REQ-120…123).
It stays outside `dataset_items` on purpose. That table keys on `frame_id`, and
inventing frame rows for images this app never extracted would put fake batches
in front of the user for the rest of the project's life. Instead the rows are
handed straight to `dataset._build_selected_tree`, which only ever wanted an
image path and a label path.
"""
import json
import os
import shutil
import time
from typing import List, Optional
from backend import config, db
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".bmp", ".webp")
class BaseDatasetError(Exception):
pass
def root_dir(project_slug: str, base_id: int) -> str:
return os.path.join(config.project_dir(project_slug), "base_datasets", str(base_id))
def listing(project_id: int) -> List[dict]:
with db.cursor() as cur:
cur.execute(
"""SELECT id, project_id, name, source, image_count, box_count, classes, created_at
FROM base_datasets WHERE project_id = ? ORDER BY created_at DESC""",
(project_id,),
)
return [_row(r) for r in cur.fetchall()]
def get(base_id: int) -> Optional[dict]:
with db.cursor() as cur:
cur.execute(
"""SELECT id, project_id, name, source, image_count, box_count, classes, created_at
FROM base_datasets WHERE id = ?""",
(base_id,),
)
row = cur.fetchone()
return _row(row) if row else None
def _row(row) -> dict:
return {
"id": row[0], "project_id": row[1], "name": row[2], "source": row[3],
"image_count": row[4], "box_count": row[5],
"classes": json.loads(row[6] or "[]"), "created_at": row[7],
}
def delete(base_id: int, project_slug: str) -> bool:
with db.cursor() as cur:
cur.execute("DELETE FROM base_datasets WHERE id = ?", (base_id,))
removed = cur.rowcount > 0
if removed:
shutil.rmtree(root_dir(project_slug, base_id), ignore_errors=True)
return removed
def rows(project_id: int, base_ids: List[int], project_slug: str) -> List[dict]:
"""Image/label pairs for the run's `selected/` tree.
Everything is `train`. A base dataset must not contribute validation images:
the base-vs-new comparison is only meaningful measured on this project's own
val split, and REQ-052 keeps that split stable (REQ-122).
"""
if not base_ids:
return []
out = []
for base_id in base_ids:
record = get(base_id)
if record is None or record["project_id"] != project_id:
continue
root = root_dir(project_slug, base_id)
images_dir = os.path.join(root, "images")
labels_dir = os.path.join(root, "labels")
if not os.path.isdir(images_dir):
continue
for name in sorted(os.listdir(images_dir)):
if not name.lower().endswith(IMAGE_EXTS):
continue
out.append({
"frame_id": None,
"split": "train",
"source_image": os.path.join(images_dir, name),
"source_label": os.path.join(labels_dir, os.path.splitext(name)[0] + ".txt"),
})
return out
def import_tree(project_id: int, project_slug: str, source_dir: str, name: str,
keep_class_ids: List[int], on_progress=None) -> dict:
"""Adopt an unpacked YOLO export, keeping only `keep_class_ids`.
Class ids are kept as they are — the caller has already checked that the
export numbers its classes the same way the project does. A label line for a
class we are not keeping is dropped; an image left with no lines at all is
dropped with it, because an empty label is a claim that the image contains
none of the kept classes, and here it only means "the box was a truck".
"""
pairs = _collect(source_dir)
if not pairs:
raise BaseDatasetError(f"No image/label pairs found under {source_dir}")
keep = set(keep_class_ids)
with db.cursor() as cur:
cur.execute(
"""INSERT INTO base_datasets (project_id, name, source, classes, created_at)
VALUES (?, ?, ?, ?, ?)""",
(project_id, name, os.path.basename(source_dir.rstrip("/")),
json.dumps(sorted(keep)), time.time()),
)
base_id = cur.lastrowid
root = root_dir(project_slug, base_id)
images_dir = os.path.join(root, "images")
labels_dir = os.path.join(root, "labels")
os.makedirs(images_dir, exist_ok=True)
os.makedirs(labels_dir, exist_ok=True)
images = boxes = skipped = 0
for index, (image_path, label_path) in enumerate(pairs):
lines = []
if os.path.isfile(label_path):
with open(label_path, encoding="utf-8") as handle:
for line in handle.read().splitlines():
if not line.strip():
continue
parts = line.split()
try:
class_id = int(parts[0])
except (ValueError, IndexError):
continue
if class_id not in keep:
continue
normalised = _to_bbox(parts)
if normalised is not None:
lines.append(normalised)
if not lines:
skipped += 1
continue
stem = os.path.basename(image_path)
shutil.copyfile(image_path, os.path.join(images_dir, stem))
with open(os.path.join(labels_dir, os.path.splitext(stem)[0] + ".txt"),
"w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
images += 1
boxes += len(lines)
if on_progress is not None and index % 25 == 0:
on_progress(index + 1, len(pairs))
with db.cursor() as cur:
cur.execute("UPDATE base_datasets SET image_count = ?, box_count = ? WHERE id = ?",
(images, boxes, base_id))
return {**get(base_id), "skipped": skipped, "candidates": len(pairs)}
def _to_bbox(parts: List[str]) -> Optional[str]:
"""Normalise one YOLO label line to `class cx cy w h`.
Roboflow exports segmentation polygons when the source project was drawn
that way, and a detect model reads the first four numbers of such a line as
a box — which lands somewhere near the first two polygon vertices and is
nowhere near the object. Polygons are collapsed to their bounding box, which
is the honest projection of a mask onto a bbox dataset.
"""
class_id, coords = parts[0], parts[1:]
if len(coords) == 4:
return " ".join([class_id] + coords)
if len(coords) < 6 or len(coords) % 2 != 0:
return None
try:
values = [float(v) for v in coords]
except ValueError:
return None
xs, ys = values[0::2], values[1::2]
x0, x1 = min(xs), max(xs)
y0, y1 = min(ys), max(ys)
width, height = x1 - x0, y1 - y0
if width <= 0 or height <= 0:
return None
return (f"{class_id} {(x0 + x1) / 2:.6f} {(y0 + y1) / 2:.6f} "
f"{width:.6f} {height:.6f}")
def _collect(source_dir: str) -> List[tuple]:
"""Every image under the tree, paired with its sibling label file.
Handles both a flat `images/`+`labels/` pair and the split layout Roboflow
exports (`train/images`, `valid/labels`, …). Deduplicated by file name, so
re-importing an export that overlaps an earlier one cannot double-weight the
same picture.
"""
seen = {}
for current, _dirs, files in os.walk(source_dir):
if os.path.basename(current) != "images":
continue
labels = os.path.join(os.path.dirname(current), "labels")
for name in sorted(files):
if not name.lower().endswith(IMAGE_EXTS):
continue
if name in seen:
continue
seen[name] = (os.path.join(current, name),
os.path.join(labels, os.path.splitext(name)[0] + ".txt"))
return [seen[key] for key in sorted(seen)]
+25
View File
@@ -209,6 +209,8 @@ def _run_extract(job) -> None:
job.log(f"Extracted {len(names)} frame(s) at {width}×{height}") job.log(f"Extracted {len(names)} frame(s) at {width}×{height}")
def update(batch_id: int, patch: dict) -> dict: def update(batch_id: int, patch: dict) -> dict:
batch = get(batch_id) batch = get(batch_id)
if batch is None: if batch is None:
@@ -236,9 +238,32 @@ def update(batch_id: int, patch: dict) -> dict:
def delete(batch_id: int) -> bool: def delete(batch_id: int) -> bool:
import shutil import shutil
from backend import dataset
batch = get(batch_id) batch = get(batch_id)
if batch is None: if batch is None:
return False return False
# The FK cascade takes the frames and dataset_items rows, but the copies this
# batch put in the master dataset live in a sibling tree and would survive as
# orphans — and data.yaml trains and validates on those directories, so the
# model would keep learning from a batch the user believes is gone.
with db.cursor() as cur:
cur.execute(
"""SELECT d.image_rel, d.label_rel, d.dataset_id FROM dataset_items d
JOIN frames f ON f.id = d.frame_id
WHERE f.batch_id = ?""",
(batch_id,),
)
merged_files = cur.fetchall()
for image_rel, label_rel, dataset_id in merged_files:
root = dataset.dataset_dir(batch["project_slug"], dataset_id)
for rel in (image_rel, label_rel):
try:
os.unlink(os.path.join(root, rel))
except FileNotFoundError:
pass
with db.cursor() as cur: with db.cursor() as cur:
cur.execute("DELETE FROM batches WHERE id = ?", (batch_id,)) cur.execute("DELETE FROM batches WHERE id = ?", (batch_id,))
shutil.rmtree(batch_dir(batch["project_slug"], batch_id), ignore_errors=True) shutil.rmtree(batch_dir(batch["project_slug"], batch_id), ignore_errors=True)
+319
View File
@@ -0,0 +1,319 @@
"""Batch counting bench: run the counter over archive videos and score it (REQ-150…153).
This is the offline twin of `live_count`. Same model, same tracker, stabiliser
and `LineCrossCounter`, same defaults — but no MJPEG stream, no annotated frame,
no JPEG encode. Rendering is most of the per-frame cost once the model is warm,
so dropping it is what makes counting a 30-minute video practical.
The point is measurement, not watching: a row per video, the counter's numbers
beside a ground truth you type in, and the signed error between them. A model
that counts 98 where you counted 100 is a different problem from one that counts
103, and a single accuracy percentage hides which of the two you have.
"""
import os
import time
from typing import List, Optional
from backend import archive_index, db, jobs, library, projects
# Defaults are the values dialled in against the real camera. A run records the
# parameters it used, so a row always says what produced it.
DEFAULTS = {
"line_y": 266, "line_x_start": 469, "line_x_end": 910,
"margin": 5, "conf": 0.35, "imgsz": 640,
"entry_travel_min": 60.0, "handoff_radius": 100.0,
"unload_confirm_frames": 3, "min_area_scale": 1.0,
"dedup_radius": 60.0, "spatial_dedup": False,
}
class CountingBenchError(Exception):
pass
def _split_rel(rel: str) -> tuple:
date_label = rel.split("/")[0] if "/" in rel else ""
return date_label, library.batch_label(os.path.basename(rel))
# ---- rows ----------------------------------------------------------------
def listing(project_id: int, date: Optional[str] = None) -> dict:
"""Every archive video with whatever has been measured for it.
Videos with no run yet are still rows — the table is the work list, so a
video nobody has counted has to be visible in it.
"""
project = projects.get(project_id)
if project is None:
raise CountingBenchError("No such project")
with db.cursor() as cur:
cur.execute("SELECT * FROM count_runs WHERE project_id = ?", (project_id,))
stored = {row["video_rel"]: dict(row) for row in cur.fetchall()}
dates = [d["date"] for d in library.list_dates(project["video_root"])]
# Folder names are not when a recording happened, so the grouping and the
# ordering both come from the timestamp index instead (REQ-160…163).
clock = archive_index.index(project_id)
rows = []
for day in dates:
# No `project_id`: that argument makes the library kick off an H.264
# preview transcode per video, and this table never plays anything.
for video in library.list_videos(project["video_root"], day):
rel = video["rel"]
run = stored.get(rel)
timing = clock.get(rel) or {}
rows.append({
"video_rel": rel,
"folder_date": day,
"date_label": timing.get("working_day") or day,
"working_day": timing.get("working_day") or "",
"started_at": timing.get("started_at"),
"clock_source": timing.get("source") or "",
"clock_trusted": bool(timing.get("trusted")),
"clock_error": timing.get("error") or "",
"batch_label": library.batch_label(os.path.basename(rel)),
"duration": video.get("duration"),
"loading": run["loading"] if run else None,
"unloading": run["unloading"] if run else None,
"net": run["net"] if run else None,
"ground_truth": run["ground_truth"] if run else None,
"frames": run["frames"] if run else 0,
"seconds": round(run["seconds"], 1) if run else 0,
"counted_at": run["counted_at"] if run else None,
"error": run["error"] if run else "",
"model_path": run["model_path"] if run else "",
})
rows = archive_index.assign_batch_numbers(rows)
if date:
rows = [r for r in rows if r["date_label"] == date]
return {"rows": rows, "totals": totals(rows),
"unindexed": sum(1 for r in rows if not r.get("started_at"))}
def totals(rows: List[dict]) -> dict:
"""Only rows with a ground truth score. An unmeasured video is not a
perfect one, and letting it into the denominator would say it was."""
scored = [r for r in rows
if r["ground_truth"] is not None and r["loading"] is not None]
predicted = sum(r["loading"] for r in scored)
truth = sum(r["ground_truth"] for r in scored)
return {
"counted_videos": sum(1 for r in rows if r["loading"] is not None),
"total_videos": len(rows),
"scored_videos": len(scored),
"predicted": predicted,
"ground_truth": truth,
"delta": predicted - truth,
"accuracy": round(100.0 * (1 - abs(predicted - truth) / truth), 2) if truth else None,
}
def set_ground_truth(project_id: int, video_rel: str, value: Optional[int]) -> dict:
"""Record what you actually counted. Kept even when no run exists yet, so
the truth can be entered while the recount is still queued."""
date_label, batch_label = _split_rel(video_rel)
with db.cursor() as cur:
cur.execute(
"""INSERT INTO count_runs (project_id, video_rel, date_label, batch_label,
ground_truth)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(project_id, video_rel)
DO UPDATE SET ground_truth = excluded.ground_truth""",
(project_id, video_rel, date_label, batch_label, value),
)
cur.execute("SELECT * FROM count_runs WHERE project_id = ? AND video_rel = ?",
(project_id, video_rel))
return dict(cur.fetchone())
def _store(project_id: int, video_rel: str, result: dict, params: dict,
model_path: str) -> None:
import json
date_label, batch_label = _split_rel(video_rel)
with db.cursor() as cur:
cur.execute(
"""INSERT INTO count_runs (project_id, video_rel, date_label, batch_label,
loading, unloading, net, frames, seconds, params,
model_path, error, counted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, video_rel) DO UPDATE SET
loading = excluded.loading, unloading = excluded.unloading,
net = excluded.net, frames = excluded.frames,
seconds = excluded.seconds, params = excluded.params,
model_path = excluded.model_path, error = excluded.error,
counted_at = excluded.counted_at""",
(project_id, video_rel, date_label, batch_label,
result.get("loading"), result.get("unloading"), result.get("net"),
result.get("frames", 0), result.get("seconds", 0.0),
json.dumps(params), model_path, result.get("error", ""), time.time()),
)
# ---- the run itself ------------------------------------------------------
def count_video(path: str, model, params: dict, should_cancel=None,
on_progress=None) -> dict:
"""Count one video end to end. No drawing, no encoding — just the numbers."""
import cv2
# Imported first: `live_count` is what puts `algoritma-batch` on sys.path, so
# the `src.*` imports below only resolve once it has been loaded.
from backend.live_count import _too_small
from src.counting import LineCrossCounter
from src.stabilizer import BboxStabilizer
from src.tracking import ByteTrackTracker
settings = {**DEFAULTS, **params}
capture = cv2.VideoCapture(path)
if not capture.isOpened():
raise CountingBenchError(f"Could not open {path}")
total_frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
tracker = ByteTrackTracker(model, settings["conf"])
stabilizer = BboxStabilizer(ema_alpha=0.35, max_hold_frames=10,
max_height_ratio=1.5, min_height_ratio=0.70)
counter = LineCrossCounter(
line_y=settings["line_y"], line_x_start=settings["line_x_start"],
line_x_end=settings["line_x_end"], margin=settings["margin"],
dedup_radius=settings["dedup_radius"],
entry_travel_min=settings["entry_travel_min"],
handoff_radius=settings["handoff_radius"],
unload_confirm_frames=settings["unload_confirm_frames"],
spatial_dedup=settings["spatial_dedup"],
)
started = time.time()
frames = 0
try:
while True:
if should_cancel is not None and should_cancel():
break
ok, frame = capture.read()
if not ok or frame is None:
break
frame = cv2.resize(frame, (1280, 720))
detections = [d for d in tracker.update(frame, []) if d.class_name == "sack"]
stable = stabilizer.update(detections)
inside = [
d for d in stable
if not _too_small(d.bbox, settings["min_area_scale"])
and settings["line_x_start"] <= (d.bbox[0] + d.bbox[2]) / 2 <= settings["line_x_end"]
]
counter.update(inside)
counter.drain_traces() # bounded memory; traces are the live view's job
frames += 1
if on_progress is not None and frames % 50 == 0:
on_progress(frames, total_frames)
finally:
capture.release()
return {
"loading": counter.loading_count,
"unloading": counter.unloading_count,
"net": counter.net_count,
"frames": frames,
"seconds": round(time.time() - started, 1),
}
def queue(project_id: int, video_rels: List[str], model_path: str,
params: Optional[dict] = None, recount: bool = False) -> dict:
"""Queue one job for the whole selection (REQ-152).
One job rather than one per video: they share a model load, and the GPU can
only run them one at a time anyway.
"""
project = projects.get(project_id)
if project is None:
raise CountingBenchError("No such project")
if not video_rels:
raise CountingBenchError("Pick at least one video to count")
if not os.path.isfile(model_path):
raise CountingBenchError(f"Model not found: {model_path}")
if not recount:
with db.cursor() as cur:
cur.execute(
"""SELECT video_rel FROM count_runs
WHERE project_id = ? AND loading IS NOT NULL""",
(project_id,),
)
done = {row[0] for row in cur.fetchall()}
video_rels = [rel for rel in video_rels if rel not in done]
if not video_rels:
raise CountingBenchError(
"Every video in this selection has already been counted — "
"tick 'recount' to run them again")
job = jobs.create(
"count",
params={"video_rels": video_rels, "model_path": model_path,
"params": {**DEFAULTS, **(params or {})}},
project_id=project_id,
message=f"{len(video_rels)} video(s)",
)
return job.to_dict()
@jobs.handler("count")
def _run_count(job) -> None:
from ultralytics import YOLO
import numpy as np
project = projects.get(job.project_id)
rels = job.params["video_rels"]
settings = job.params.get("params") or DEFAULTS
model_path = job.params["model_path"]
job.progress(0, len(rels))
job.log(f"Counting {len(rels)} video(s) with {os.path.basename(model_path)}")
model = YOLO(model_path)
# Same warm-up as the live path: the first CUDA call inside the tracker has
# been seen to segfault without it.
model(np.zeros((720, 1280, 3), dtype=np.uint8), imgsz=settings.get("imgsz", 640),
verbose=False)
for index, rel in enumerate(rels):
if job.cancelled:
job.log(f"Cancelled after {index} video(s)")
return
try:
path = library.resolve(project["video_root"], rel)
except library.LibraryError as exc:
_store(job.project_id, rel, {"error": str(exc)}, settings, model_path)
job.log(f"{rel}: {exc}")
job.progress(index + 1, len(rels))
continue
def report(done, total, rel=rel, index=index):
job.progress(index, len(rels), f"{rel} — {done}/{total or '?'} frames")
try:
result = count_video(path, model, settings,
should_cancel=lambda: job.cancelled,
on_progress=report)
except Exception as exc:
_store(job.project_id, rel, {"error": f"{type(exc).__name__}: {exc}"},
settings, model_path)
job.log(f"{rel}: FAILED {exc}")
job.progress(index + 1, len(rels))
continue
# A cancel mid-video leaves a partial count, which would read as a real
# measurement of a video that was never finished.
if job.cancelled:
job.log(f"Cancelled during {rel} — its count was not saved")
return
_store(job.project_id, rel, result, settings, model_path)
rate = result["frames"] / result["seconds"] if result["seconds"] else 0
job.log(f"{rel}: in {result['loading']} out {result['unloading']} "
f"net {result['net']} ({result['frames']} frames, {rate:.0f} fps)")
job.progress(index + 1, len(rels))
+393 -129
View File
@@ -11,48 +11,115 @@ Label files are plain YOLO:
segment class_id x1 y1 x2 y2 … (normalized polygon) segment class_id x1 y1 x2 y2 … (normalized polygon)
""" """
import hashlib
import os import os
import shutil import shutil
import time import time
from typing import List, Optional from typing import List, Optional
from backend import batches, config, db, jobs, projects, review from backend import batches, config, datasets, db, jobs, projects, review, triage
DatasetError = datasets.DatasetError
class DatasetError(Exception): def dataset_dir(project_slug: str, dataset_id: int) -> str:
pass return datasets.dataset_root(project_slug, dataset_id)
def dataset_dir(project_slug: str) -> str: def runs_dir(project_slug: str) -> str:
return os.path.join(config.project_dir(project_slug), "dataset") """Where a training run's assembled view lives.
It sits outside any one dataset because a run may combine several, and the
combined view belongs to the run, not to any of its sources.
"""
return os.path.join(config.project_dir(project_slug), "runs")
def approve(batch_id: int) -> dict: def approve(batch_ids, dataset_id: Optional[int] = None,
"""Sign a batch off and queue its merge (REQ-045, REQ-050).""" dataset_name: str = "") -> dict:
batch = batches.get(batch_id) """Sign a selection off and queue one merge into one named dataset (REQ-131).
if batch is None:
raise DatasetError("No such batch") The rules in force right now are frozen onto the target dataset (REQ-132):
if batch["status"] == "merged": the merge runs under them, and a later rule edit cannot rewrite what this
raise DatasetError("This batch is already in the master dataset") dataset claims to be.
if batch["review"]["pending"] > 0:
raise DatasetError( Without `dataset_id` a new dataset is created, so merging the same batches
f"{batch['review']['pending']} frame(s) still need a decision before this " again never collides with the earlier result — it produces a second dataset
"batch can be approved" holding them as they look now.
"""
ids = triage.as_ids(batch_ids)
if not ids:
raise DatasetError("Pick at least one batch to merge")
selected = []
for batch_id in ids:
batch = batches.get(batch_id)
if batch is None:
raise DatasetError("No such batch")
if batch["review"]["approved"] == 0:
raise DatasetError(
f"No frame in {batch['date_label']}/{batch['batch_label']} is approved "
"— there is nothing to merge")
selected.append(batch)
if len({batch["project_id"] for batch in selected}) > 1:
raise DatasetError("Those batches are not all in the same project")
project_id = selected[0]["project_id"]
# Frames that are not approved — rejected or never looked at — are simply
# left behind. Only what the user signed off on enters the dataset, so a
# partly-reviewed batch can be merged for the part that is done.
with db.cursor() as cur:
placeholders = ",".join("?" for _ in ids)
cur.execute(
f"""SELECT 1 FROM jobs WHERE batch_id IN ({placeholders}) AND type = 'merge'
AND status IN ('queued', 'running')""",
ids,
) )
if batch["review"]["approved"] == 0: if cur.fetchone() is not None:
raise DatasetError("Every frame was rejected — there is nothing to merge") raise DatasetError("A merge for one of these batches is already queued")
batches.set_status(batch_id, "approved") resolver = triage.Resolver(project_id)
if dataset_id is None:
target = datasets.create(project_id, name=dataset_name,
rule_version=resolver.version(), rules=resolver.rules)
dataset_id = target["id"]
else:
target = datasets.get(dataset_id)
if target is None:
raise DatasetError("No such dataset")
if all(_unmerged_approved(batch["id"], dataset_id) == 0 for batch in selected):
raise DatasetError(
f"Every approved frame of this selection is already in \u201c{target['name']}\u201d")
# An existing dataset keeps the rules it was cut under; a second merge
# into it must not re-cut the frames already there under new ones.
if not target["rules"]:
datasets.snapshot_rules(dataset_id, resolver.rules, resolver.version())
for batch in selected:
batches.set_status(batch["id"], "approved")
labels = ", ".join(f"{b['date_label']}/{b['batch_label']}" for b in selected)
job = jobs.create( job = jobs.create(
"merge", "merge",
params={"batch_id": batch_id}, params={"batch_ids": ids, "dataset_id": dataset_id},
project_id=batch["project_id"], project_id=project_id,
batch_id=batch_id, batch_id=ids[0],
message=f"{batch['date_label']}/{batch['batch_label']}", message=labels,
) )
return job.to_dict() return job.to_dict()
def _unmerged_approved(batch_id: int, dataset_id: int) -> int:
"""Approved frames of this batch not yet in *this* dataset."""
with db.cursor() as cur:
cur.execute(
"""SELECT COUNT(*) FROM frames f
LEFT JOIN dataset_items d
ON d.frame_id = f.id AND d.dataset_id = ?
WHERE f.batch_id = ? AND f.review_status = 'approved' AND d.id IS NULL""",
(dataset_id, batch_id),
)
return cur.fetchone()[0]
def _label_line(class_id: int, geometry: dict, label_type: str) -> str: def _label_line(class_id: int, geometry: dict, label_type: str) -> str:
if label_type == "bbox": if label_type == "bbox":
x0, y0, x1, y1 = review.to_box(geometry) x0, y0, x1, y1 = review.to_box(geometry)
@@ -66,106 +133,245 @@ def _label_line(class_id: int, geometry: dict, label_type: str) -> str:
return f"{class_id} {coords}" return f"{class_id} {coords}"
def _next_split(cur, project_id: int, val_every: int) -> str: def split_for(project_id: int, batch_id: int, stem: str, val_every: int) -> str:
"""Continue the every-Nth pattern from wherever the last merge left off.""" """Which split a frame belongs to, derived from its identity rather than from
how many rows happen to precede it.
A positional every-Nth rule makes membership depend on insertion history, so
deleting or re-merging a batch silently reshuffles every later frame — and a
frame that was in `val` for the last comparison could land in `train` for the
next one. Hashing the identity makes the stable-val-split invariant true by
construction: the same frame always lands in the same split, whatever else
happened to the dataset. Rows already in `dataset_items` keep the split they
were recorded with; nothing recomputes them.
"""
if val_every <= 0: if val_every <= 0:
return "train" return "train"
cur.execute("SELECT COUNT(*) FROM dataset_items WHERE project_id = ?", (project_id,)) digest = hashlib.sha1(f"{project_id}/{batch_id}/{stem}".encode("utf-8")).hexdigest()
position = cur.fetchone()[0] return "val" if int(digest[:8], 16) % val_every == 0 else "train"
return "val" if position % val_every == val_every - 1 else "train"
def sync_labels(project_id: int, selected_class_ids: Optional[List[int]] = None) -> dict: def resync(dataset_id: int) -> dict:
"""Re-sync label files on disk for all merged frames in the project dataset.""" """Rewrite one dataset's labels from the current annotations and rules.
project = projects.get(project_id)
root = dataset_dir(project["slug"]) This used to run automatically before every training run, which quietly
undid the triage applied at merge: a dataset merged under "reclass small
boxes" had its labels rebuilt from the raw annotations on the next run, so
the files stopped matching the `rule_version` stamped on them. It is now a
deliberate act, and it re-stamps that version so the dataset never claims a
rule set it is not in.
Only frames a human signed off on are written. A merged frame whose batch was
auto-annotated again drops back to `pending`, and rewriting its label from
fresh model output would push predictions nobody checked into the dataset.
"""
target = datasets.get(dataset_id)
if target is None:
raise DatasetError("No such dataset")
project = projects.get(target["project_id"])
resolver = triage.Resolver(project["id"])
root = dataset_dir(project["slug"], dataset_id)
with db.cursor() as cur: with db.cursor() as cur:
cur.execute( cur.execute(
"SELECT d.frame_id, d.label_rel FROM dataset_items d WHERE d.project_id = ?", """SELECT d.frame_id, d.label_rel FROM dataset_items d
(project_id,), JOIN frames f ON f.id = d.frame_id
WHERE d.dataset_id = ? AND f.review_status = 'approved'""",
(dataset_id,),
) )
items = cur.fetchall() items = cur.fetchall()
class_map = None written = 0
if selected_class_ids is not None and len(selected_class_ids) > 0: emptied = 0
class_map = {cid: idx for idx, cid in enumerate(sorted(selected_class_ids))}
synced_files = 0
total_lines = 0
for frame_id, label_rel in items: for frame_id, label_rel in items:
annotations = review.listing(frame_id) annotations = review.listing(frame_id)
if class_map is not None: resolved = resolver.resolve_shapes(annotations)
annotations = [a for a in annotations if a["class_id"] in class_map] if resolved is None:
# Every shape was dropped. The image stays in the dataset but an
lines = [] # empty label would claim it is empty, so the file is left as it was
for item in annotations: # and the count is reported (REQ-104).
mapped_cid = class_map[item["class_id"]] if class_map is not None else item["class_id"] emptied += 1
lines.append(_label_line(mapped_cid, item["geometry"], project["label_type"])) continue
lines = [_label_line(item["class_id"], item["geometry"], project["label_type"])
for item in resolved]
path = os.path.join(root, label_rel) path = os.path.join(root, label_rel)
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f: _write_atomic(path, "\n".join(lines) + ("\n" if lines else ""))
f.write("\n".join(lines) + ("\n" if lines else "")) written += 1
synced_files += 1
total_lines += len(lines)
return {"synced_files": synced_files, "total_lines": total_lines} # Resync is the one deliberate way an existing dataset adopts today's rules,
# so the snapshot moves with the labels (REQ-132).
datasets.snapshot_rules(dataset_id, resolver.rules, resolver.version())
return {"labels_written": written, "frames_left_alone": emptied,
"rule_version": resolver.version()}
def write_data_yaml(project: dict, batch_ids: list = None, selected_class_ids: Optional[List[int]] = None) -> str: def _write_atomic(path: str, text: str) -> None:
"""Rebuild data.yaml from the project's classes (REQ-051).""" """Write via temp file + rename, so a training run never reads a half-written
sync_labels(project["id"], selected_class_ids=selected_class_ids) label file or a truncated data.yaml."""
root = dataset_dir(project["slug"]) tmp = f"{path}.tmp"
os.makedirs(root, exist_ok=True) with open(tmp, "w", encoding="utf-8") as handle:
counts = summary(project["id"])["splits"] handle.write(text)
os.replace(tmp, path)
def _build_selected_tree(run_root: str, rows: list, class_map: Optional[dict]) -> tuple:
"""Materialise the run's view of the chosen datasets under `runs/selected/`.
Labels are copied from what each dataset holds on disk — not re-derived from
the live annotations. A dataset is the snapshot of a batch as it was merged,
under the triage rules recorded in its `rule_version`; re-resolving here
would train on today's rules while the dataset claims yesterday's, and two
runs over the same dataset could then disagree. Change the rules and merge
again into a new dataset, or resync this one on purpose.
The only thing this does apply is a per-run class filter, which renumbers ids
into a contiguous 0..k-1 space. That contradicts `project_classes`, so it
cannot be written back into the dataset's own label files.
"""
selected_root = os.path.join(run_root, "selected")
if os.path.isdir(selected_root):
shutil.rmtree(selected_root)
listed = {"train": [], "val": []}
excluded = 0
for row in rows:
split, source_image, source_label = row["split"], row["source_image"], row["source_label"]
lines = _read_label(source_label)
if class_map is not None:
kept = []
for line in lines:
head, _, rest = line.partition(" ")
try:
current = int(head)
except ValueError:
continue
if current in class_map:
kept.append(f"{class_map[current]} {rest}")
# A frame that had shapes but none of the chosen classes is not a
# negative sample of those classes — it is a frame full of things the
# run was told to ignore, and an empty label would teach exactly that.
if lines and not kept:
excluded += 1
continue
lines = kept
stem = os.path.basename(source_image)
image_dst = os.path.join(selected_root, "images", split, stem)
label_dst = os.path.join(selected_root, "labels", split,
os.path.splitext(stem)[0] + ".txt")
os.makedirs(os.path.dirname(image_dst), exist_ok=True)
os.makedirs(os.path.dirname(label_dst), exist_ok=True)
if not os.path.exists(image_dst):
os.symlink(source_image, image_dst)
_write_atomic(label_dst, "\n".join(lines) + ("\n" if lines else ""))
listed[split].append(image_dst)
listed["excluded"] = excluded
return selected_root, listed
def _read_label(path: str) -> List[str]:
if not os.path.isfile(path):
return []
with open(path, encoding="utf-8") as handle:
return [line for line in handle.read().splitlines() if line.strip()]
def write_data_yaml(project: dict, dataset_ids: List[int], batch_ids: list = None,
selected_class_ids: Optional[List[int]] = None,
require_val: bool = False,
base_dataset_ids: Optional[List[int]] = None) -> str:
"""Assemble the chosen datasets into one data.yaml for a run (REQ-051, REQ-110).
Always via the `selected/` tree of symlinks, even for a single dataset with
no filters. The alternative — pointing YOLO at a dataset folder directly —
only works while a run uses exactly one dataset, and it puts a per-run class
renumbering into the shared label files. One assembly path is easier to
trust than two that diverge the moment a second dataset is picked.
"""
if not dataset_ids and not base_dataset_ids:
raise DatasetError("Pick at least one dataset to train on")
run_root = runs_dir(project["slug"])
os.makedirs(run_root, exist_ok=True)
target_classes = project["classes"] target_classes = project["classes"]
class_map = None
if selected_class_ids is not None and len(selected_class_ids) > 0: if selected_class_ids is not None and len(selected_class_ids) > 0:
target_classes = [c for c in project["classes"] if c["class_id"] in selected_class_ids] target_classes = [c for c in project["classes"] if c["class_id"] in selected_class_ids]
class_map = {cid: idx for idx, cid in enumerate(sorted(selected_class_ids))}
names = ", ".join(f"'{item['name']}'" for item in target_classes) names = ", ".join(f"'{item['name']}'" for item in target_classes)
items = datasets.combined_items(project["id"], dataset_ids)
if batch_ids: if batch_ids:
with db.cursor() as cur: keep = _frames_of_batches(set(batch_ids))
placeholders = ",".join("?" for _ in batch_ids) items = [item for item in items if item["frame_id"] in keep]
cur.execute(
f"""SELECT d.image_rel, d.split FROM dataset_items d
JOIN frames f ON f.id = d.frame_id
WHERE d.project_id = ? AND f.batch_id IN ({placeholders})""",
[project["id"]] + list(batch_ids),
)
rows = cur.fetchall()
train_files = [row[0] for row in rows if row[1] == "train"] rows = []
val_files = [row[0] for row in rows if row[1] == "val"] or train_files for item in items:
root = dataset_dir(project["slug"], item["dataset_id"])
rows.append({
"frame_id": item["frame_id"],
"split": item["split"],
"source_image": os.path.join(root, item["image_rel"]),
"source_label": os.path.join(root, item["label_rel"]),
})
train_txt = os.path.join(root, "selected_train.txt") # Base datasets are appended, never merged into the dedupe above: they carry
val_txt = os.path.join(root, "selected_val.txt") # no frame_id, and they are always train-only (REQ-122).
with open(train_txt, "w", encoding="utf-8") as handle: if base_dataset_ids:
handle.write("\n".join(os.path.join(root, rel) for rel in train_files) + "\n") from backend import base_dataset
with open(val_txt, "w", encoding="utf-8") as handle: rows.extend(base_dataset.rows(project["id"], base_dataset_ids, project["slug"]))
handle.write("\n".join(os.path.join(root, rel) for rel in val_files) + "\n")
path = os.path.join(root, "selected_data.yaml") selected_root, listed = _build_selected_tree(run_root, rows, class_map)
with open(path, "w", encoding="utf-8") as handle: if require_val:
handle.write(f"path: {root}\n") _require_val(len(listed["val"]), "the selected dataset(s)")
handle.write(f"train: {train_txt}\n")
handle.write(f"val: {val_txt}\n\n")
handle.write(f"nc: {len(project['classes'])}\n")
handle.write(f"names: [{names}]\n")
return path
path = os.path.join(root, "data.yaml") train_txt = os.path.join(run_root, "selected_train.txt")
with open(path, "w", encoding="utf-8") as handle: val_txt = os.path.join(run_root, "selected_val.txt")
handle.write(f"path: {root}\n") _write_atomic(train_txt, "\n".join(listed["train"]) + "\n")
handle.write("train: images/train\n") _write_atomic(val_txt, "\n".join(listed["val"]) + "\n")
handle.write(f"val: images/{'val' if counts['val'] > 0 else 'train'}\n\n")
handle.write(f"nc: {len(project['classes'])}\n") path = os.path.join(run_root, "selected_data.yaml")
handle.write(f"names: [{names}]\n") _write_atomic(path,
f"path: {selected_root}\n"
f"train: {train_txt}\n"
f"val: {val_txt}\n\n"
f"nc: {len(target_classes)}\n"
f"names: [{names}]\n")
return path return path
def _frames_of_batches(batch_ids: set) -> set:
with db.cursor() as cur:
cur.execute(
f"SELECT id FROM frames WHERE batch_id IN ({','.join('?' for _ in batch_ids)})",
list(batch_ids),
)
return {row[0] for row in cur.fetchall()}
def _require_val(count: int, subject: str) -> None:
"""Refuse to build a dataset with an empty val split.
Falling back to the training images produces a base-vs-new mAP measured on
data the model was fitted to — a number that looks fine and means nothing.
For a system whose whole purpose is answering "did retraining help?", this
has to fail loudly.
"""
if count == 0:
raise DatasetError(
f"There are no validation images in {subject}, so a base-vs-new comparison "
"would be measured on the training images. Merge more frames, or lower the "
"project's val_every."
)
def summary(project_id: int) -> dict: def summary(project_id: int) -> dict:
"""Counts only. The per-shape size analytics that used to live here walked
every annotation in the project on every page load — Data Prep already
serves that, per batch, from `triage`."""
with db.cursor() as cur: with db.cursor() as cur:
cur.execute( cur.execute(
"SELECT split, COUNT(*) FROM dataset_items WHERE project_id = ? GROUP BY split", "SELECT split, COUNT(*) FROM dataset_items WHERE project_id = ? GROUP BY split",
@@ -185,7 +391,12 @@ def summary(project_id: int) -> dict:
(project_id,), (project_id,),
) )
merged = [dict(row) for row in cur.fetchall()] merged = [dict(row) for row in cur.fetchall()]
return {"splits": splits, "total": splits["train"] + splits["val"], "batches": merged}
return {
"splits": splits,
"total": splits["train"] + splits["val"],
"batches": merged,
}
def drop_class_from_labels(project: dict, class_id: int) -> dict: def drop_class_from_labels(project: dict, class_id: int) -> dict:
@@ -196,16 +407,15 @@ def drop_class_from_labels(project: dict, class_id: int) -> dict:
class that is now `1` — labels that quietly name the wrong thing are worse class that is now `1` — labels that quietly name the wrong thing are worse
than labels that are missing. than labels that are missing.
""" """
root = dataset_dir(project["slug"])
with db.cursor() as cur: with db.cursor() as cur:
cur.execute("SELECT label_rel FROM dataset_items WHERE project_id = ?", cur.execute("SELECT label_rel, dataset_id FROM dataset_items WHERE project_id = ?",
(project["id"],)) (project["id"],))
label_files = [row[0] for row in cur.fetchall()] label_files = [(row[0], row[1]) for row in cur.fetchall()]
rewritten = 0 rewritten = 0
dropped = 0 dropped = 0
for rel in label_files: for rel, dataset_id in label_files:
path = os.path.join(root, rel) path = os.path.join(dataset_dir(project["slug"], dataset_id), rel)
if not os.path.isfile(path): if not os.path.isfile(path):
continue continue
with open(path, encoding="utf-8") as handle: with open(path, encoding="utf-8") as handle:
@@ -240,74 +450,128 @@ def drop_class_from_labels(project: dict, class_id: int) -> dict:
return {"label_files_rewritten": rewritten, "dataset_lines_removed": dropped} return {"label_files_rewritten": rewritten, "dataset_lines_removed": dropped}
def zip_path(project: dict) -> str: def zip_path(project: dict, dataset_id: int) -> str:
"""Zip the master dataset for download (REQ-054).""" """Zip one dataset for download (REQ-054)."""
root = dataset_dir(project["slug"]) root = dataset_dir(project["slug"], dataset_id)
if not os.path.isdir(os.path.join(root, "images")): if not os.path.isdir(os.path.join(root, "images")):
raise DatasetError("This project's dataset is still empty") raise DatasetError("This dataset is still empty")
archive = os.path.join(config.project_dir(project["slug"]), "dataset") archive = os.path.join(config.project_dir(project["slug"]), f"dataset-{dataset_id}")
return shutil.make_archive(archive, "zip", root) return shutil.make_archive(archive, "zip", root)
@jobs.handler("merge") @jobs.handler("merge")
def _run_merge(job) -> None: def _run_merge(job) -> None:
batch = batches.get(job.params["batch_id"]) ids = job.params.get("batch_ids") or [job.params["batch_id"]]
if batch is None: selected = [batches.get(bid) for bid in ids]
raise DatasetError("The batch disappeared before the merge started") if any(batch is None for batch in selected):
project = projects.get(batch["project_id"]) raise DatasetError("A batch disappeared before the merge started")
root = dataset_dir(project["slug"]) project = projects.get(selected[0]["project_id"])
dataset_id = job.params["dataset_id"]
target = datasets.get(dataset_id)
if target is None:
raise DatasetError("The target dataset disappeared before the merge started")
root = dataset_dir(project["slug"], dataset_id)
for split in ("train", "val"): for split in ("train", "val"):
os.makedirs(os.path.join(root, "images", split), exist_ok=True) os.makedirs(os.path.join(root, "images", split), exist_ok=True)
os.makedirs(os.path.join(root, "labels", split), exist_ok=True) os.makedirs(os.path.join(root, "labels", split), exist_ok=True)
frames = [f for f in batches.frames(batch["id"]) if f["review_status"] == "approved"] work = []
source_dir = batches.frames_dir(project["slug"], batch["id"]) for batch in selected:
job.progress(0, len(frames)) frames = [f for f in batches.frames(batch["id"]) if f["review_status"] == "approved"]
job.log(f"Merging {len(frames)} approved frame(s) into the master dataset") work.extend((batch, frame) for frame in frames)
job.progress(0, len(work))
job.log(f"Merging {len(work)} approved frame(s) from {len(selected)} batch(es) "
f"into \u201c{target['name']}\u201d")
# Triage gates the merge (REQ-104), under the rules frozen onto this dataset
# when it was created (REQ-132) — not under whatever the project says now.
resolver = triage.Resolver(project["id"], frozen=target["rules"])
gating = bool(resolver.rules or resolver.overrides)
if gating:
job.log(f"Applying {len(resolver.rules)} triage rule(s), version {resolver.version()}")
added = {"train": 0, "val": 0} added = {"train": 0, "val": 0}
skipped = 0 skipped = 0
for index, frame in enumerate(frames): triaged_out = 0
cancelled = False
for index, (batch, frame) in enumerate(work):
if job.cancelled: if job.cancelled:
job.log(f"Cancelled after {index} frame(s)") job.log(f"Cancelled after {index} frame(s)")
cancelled = True
break break
annotations = review.listing(frame["id"])
if gating:
resolved = resolver.resolve_shapes(annotations)
if resolved is None:
triaged_out += 1
job.progress(index + 1, len(work))
continue
annotations = resolved
with db.cursor() as cur: with db.cursor() as cur:
cur.execute("SELECT 1 FROM dataset_items WHERE frame_id = ?", (frame["id"],)) cur.execute("SELECT 1 FROM dataset_items WHERE dataset_id = ? AND frame_id = ?",
(dataset_id, frame["id"]))
if cur.fetchone() is not None: if cur.fetchone() is not None:
skipped += 1 skipped += 1
job.progress(index + 1, len(frames)) job.progress(index + 1, len(work))
continue continue
split = _next_split(cur, project["id"], project["val_every"])
stem = f"{batch['id']}__{os.path.splitext(frame['filename'])[0]}" stem = f"{batch['id']}__{os.path.splitext(frame['filename'])[0]}"
# A frame's split is decided once for the whole project and every
# later dataset inherits it. Letting each dataset re-decide would put
# the same image in `val` for one run and `train` for the next, so a
# base-vs-new mAP would be measured on images the new model had been
# fitted to. The hash agrees with itself, but rows merged before the
# hash existed carry a positional split — those have to be honoured,
# not recomputed.
cur.execute(
"SELECT split FROM dataset_items WHERE frame_id = ? LIMIT 1",
(frame["id"],),
)
previous = cur.fetchone()
split = previous[0] if previous else split_for(
project["id"], batch["id"], stem, project["val_every"])
image_rel = f"images/{split}/{stem}.jpg" image_rel = f"images/{split}/{stem}.jpg"
label_rel = f"labels/{split}/{stem}.txt" label_rel = f"labels/{split}/{stem}.txt"
shutil.copyfile(os.path.join(source_dir, frame["filename"]), shutil.copyfile(
os.path.join(root, image_rel)) os.path.join(batches.frames_dir(project["slug"], batch["id"]), frame["filename"]),
os.path.join(root, image_rel))
lines = [_label_line(item["class_id"], item["geometry"], project["label_type"]) lines = [_label_line(item["class_id"], item["geometry"], project["label_type"])
for item in review.listing(frame["id"])] for item in annotations]
# An approved frame with nothing on it is a negative sample, and an # An approved frame with nothing on it is a negative sample, and an
# empty .txt is how YOLO spells that (REQ-033). # empty .txt is how YOLO spells that (REQ-033).
with open(os.path.join(root, label_rel), "w", encoding="utf-8") as handle: with open(os.path.join(root, label_rel), "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + ("\n" if lines else "")) handle.write("\n".join(lines) + ("\n" if lines else ""))
cur.execute( cur.execute(
"""INSERT INTO dataset_items (project_id, frame_id, split, image_rel, """INSERT INTO dataset_items (project_id, dataset_id, frame_id, split,
label_rel, added_at) image_rel, label_rel, added_at)
VALUES (?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?)""",
(project["id"], frame["id"], split, image_rel, label_rel, time.time()), (project["id"], dataset_id, frame["id"], split, image_rel, label_rel,
time.time()),
) )
added[split] += 1 added[split] += 1
job.progress(index + 1, len(frames)) job.progress(index + 1, len(work))
if cancelled:
# Leaving them 'merged' would be a lie: the frames after the break point
# have no dataset_items rows and no files, and approve() refuses to
# re-merge a merged batch, so they could never be added. The per-frame
# dataset_items guard already makes re-running the merge idempotent.
job.log("Batches left approved — re-approve them to finish the merge")
return
with db.cursor() as cur: with db.cursor() as cur:
cur.execute("UPDATE batches SET status = 'merged', merged_at = ? WHERE id = ?", cur.executemany(
(time.time(), batch["id"])) "UPDATE batches SET status = 'merged', merged_at = ? WHERE id = ?",
[(time.time(), bid) for bid in ids],
)
path = write_data_yaml(projects.get(project["id"])) totals = datasets.get(dataset_id)["splits"]
totals = summary(project["id"])["splits"]
job.log(f"Added {added['train']} train / {added['val']} val" job.log(f"Added {added['train']} train / {added['val']} val"
+ (f", skipped {skipped} already merged" if skipped else "")) + (f", skipped {skipped} already in this dataset" if skipped else "")
job.log(f"Master dataset now {totals['train']} train / {totals['val']} val — {path}") + (f", held back {triaged_out} by triage" if triaged_out else ""))
job.log(f"\u201c{target['name']}\u201d now holds "
f"{totals['train']} train / {totals['val']} val")
+220
View File
@@ -0,0 +1,220 @@
"""Named master datasets — several per project, each a full standalone copy.
One project used to have exactly one master dataset, so merging a batch twice
was a conflict. Now a merge targets a *named* dataset, and the same batch can go
into as many as you like: "batch7+8 strict rules" and "batch7+8 after I fixed
the annotations" are two datasets holding the same frames with different labels.
Each dataset owns its files under
data/projects/<slug>/datasets/<id>/{images,labels}/{train,val}
Images are copied rather than shared, so a dataset folder can be moved or
archived on its own without silently losing pixels.
Combining datasets for a training run is "newest wins": if a frame appears in
two of them, the one created later is treated as the correction of the earlier,
and the frame is emitted once. Emitting it twice would hand the model two
contradictory labels for the same image.
"""
import json
import os
import shutil
import time
from typing import List, Optional
from backend import config, db
class DatasetError(Exception):
pass
def dataset_root(project_slug: str, dataset_id: int) -> str:
return os.path.join(config.project_dir(project_slug), "datasets", str(dataset_id))
def adopt_legacy_tree() -> int:
"""Move a pre-rename project's files under the dataset that adopted its rows.
`_migrate_dataset_items` gave the old rows a home in the `datasets` table but
left the pixels at `<project>/dataset/`, so the adopting dataset points at a
directory that does not exist and a training run would find no images.
Idempotent: a dataset whose root already exists is left alone.
"""
moved = 0
with db.cursor() as cur:
cur.execute(
"""SELECT s.id, p.slug FROM datasets s
JOIN projects p ON p.id = s.project_id
WHERE s.note = 'Adopted from the original single dataset'""",
)
adopted = [(row[0], row[1]) for row in cur.fetchall()]
for dataset_id, slug in adopted:
root = dataset_root(slug, dataset_id)
legacy = os.path.join(config.project_dir(slug), "dataset")
if os.path.isdir(root) or not os.path.isdir(os.path.join(legacy, "images")):
continue
os.makedirs(os.path.dirname(root), exist_ok=True)
for name in ("images", "labels"):
source = os.path.join(legacy, name)
if os.path.isdir(source):
os.makedirs(root, exist_ok=True)
shutil.move(source, os.path.join(root, name))
# The rest of the legacy tree is a stale data.yaml and the old
# `selected/` symlinks, both rebuilt per run now.
shutil.rmtree(legacy, ignore_errors=True)
moved += 1
return moved
def create(project_id: int, name: str = "", note: str = "",
rule_version: Optional[str] = None, rules: Optional[List[dict]] = None) -> dict:
label = (name or "").strip() or time.strftime("Master Dataset %Y-%m-%d %H:%M")
with db.cursor() as cur:
cur.execute(
"""INSERT INTO datasets (project_id, name, note, rule_version, rules_json,
created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(project_id, label, note, rule_version,
json.dumps(rules) if rules is not None else None, time.time()),
)
dataset_id = cur.lastrowid
return get(dataset_id)
def get(dataset_id: int) -> Optional[dict]:
with db.cursor() as cur:
cur.execute("SELECT * FROM datasets WHERE id = ?", (dataset_id,))
row = cur.fetchone()
if row is None:
return None
return _with_counts(cur, dict(row))
def listing(project_id: int) -> List[dict]:
with db.cursor() as cur:
cur.execute(
"SELECT * FROM datasets WHERE project_id = ? ORDER BY created_at DESC",
(project_id,),
)
rows = [dict(row) for row in cur.fetchall()]
return [_with_counts(cur, row) for row in rows]
def _with_counts(cur, row: dict) -> dict:
cur.execute(
"""SELECT split, COUNT(*) FROM dataset_items
WHERE dataset_id = ? GROUP BY split""",
(row["id"],),
)
splits = {"train": 0, "val": 0}
for split, count in cur.fetchall():
splits[split] = count
cur.execute(
"""SELECT b.id, b.date_label, b.batch_label, COUNT(d.id)
FROM dataset_items d
JOIN frames f ON f.id = d.frame_id
JOIN batches b ON b.id = f.batch_id
WHERE d.dataset_id = ? GROUP BY b.id ORDER BY b.id""",
(row["id"],),
)
batches = [{"id": r[0], "date_label": r[1], "batch_label": r[2], "images": r[3]}
for r in cur.fetchall()]
row["splits"] = splits
row["total"] = splits["train"] + splits["val"]
row["batches"] = batches
row["rules"] = json.loads(row.pop("rules_json") or "[]")
return row
def snapshot_rules(dataset_id: int, rules: List[dict], rule_version: str) -> None:
"""Freeze the rules a dataset was cut under (REQ-132)."""
with db.cursor() as cur:
cur.execute("UPDATE datasets SET rules_json = ?, rule_version = ? WHERE id = ?",
(json.dumps(rules), rule_version, dataset_id))
def rename(dataset_id: int, name: str = None, note: str = None) -> dict:
fields, args = [], []
if name is not None:
fields.append("name = ?")
args.append(name.strip())
if note is not None:
fields.append("note = ?")
args.append(note)
if fields:
args.append(dataset_id)
with db.cursor() as cur:
cur.execute(f"UPDATE datasets SET {', '.join(fields)} WHERE id = ?", args)
return get(dataset_id)
def delete(dataset_id: int) -> bool:
dataset = get(dataset_id)
if dataset is None:
return False
with db.cursor() as cur:
cur.execute("SELECT slug FROM projects WHERE id = ?", (dataset["project_id"],))
row = cur.fetchone()
if row is not None:
shutil.rmtree(dataset_root(row["slug"], dataset_id), ignore_errors=True)
with db.cursor() as cur:
cur.execute("DELETE FROM datasets WHERE id = ?", (dataset_id,))
return True
def combined_items(project_id: int, dataset_ids: List[int]) -> List[dict]:
"""Frames from these datasets, newest dataset winning on a repeated frame.
A frame in two datasets means the later one is a correction — a rule change
or a fixed annotation. Training on both copies would teach the model that
the same pixels are two different things.
"""
if not dataset_ids:
return []
with db.cursor() as cur:
placeholders = ",".join("?" for _ in dataset_ids)
cur.execute(
f"""SELECT d.frame_id, d.split, d.image_rel, d.label_rel, d.dataset_id,
s.created_at, s.name
FROM dataset_items d
JOIN datasets s ON s.id = d.dataset_id
WHERE d.project_id = ? AND d.dataset_id IN ({placeholders})
ORDER BY s.created_at ASC, d.id ASC""",
[project_id] + list(dataset_ids),
)
rows = cur.fetchall()
# Ordered oldest first, so a later dataset simply overwrites the entry.
winner = {}
for row in rows:
winner[row["frame_id"]] = {
"frame_id": row["frame_id"],
"split": row["split"],
"image_rel": row["image_rel"],
"label_rel": row["label_rel"],
"dataset_id": row["dataset_id"],
"dataset_name": row["name"],
}
return list(winner.values())
def overlap_report(project_id: int, dataset_ids: List[int], total_unique: int) -> dict:
"""How many frames the chosen datasets share, so the user is told rather
than quietly given fewer images than the totals suggest."""
if len(dataset_ids) < 2:
return {"shared_frames": 0, "total_unique": total_unique}
with db.cursor() as cur:
placeholders = ",".join("?" for _ in dataset_ids)
cur.execute(
f"""SELECT COUNT(*) FROM (
SELECT frame_id FROM dataset_items
WHERE project_id = ? AND dataset_id IN ({placeholders})
GROUP BY frame_id HAVING COUNT(DISTINCT dataset_id) > 1)""",
[project_id] + list(dataset_ids),
)
shared = cur.fetchone()[0]
return {"shared_frames": shared, "total_unique": total_unique}
+253 -3
View File
@@ -83,14 +83,80 @@ SCHEMA = [
) )
""", """,
""" """
CREATE TABLE IF NOT EXISTS datasets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
rule_version TEXT,
rules_json TEXT,
created_at REAL NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS dataset_items ( CREATE TABLE IF NOT EXISTS dataset_items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE UNIQUE, dataset_id INTEGER NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE,
split TEXT NOT NULL CHECK (split IN ('train', 'val')), split TEXT NOT NULL CHECK (split IN ('train', 'val')),
image_rel TEXT NOT NULL, image_rel TEXT NOT NULL,
label_rel TEXT NOT NULL, label_rel TEXT NOT NULL,
added_at REAL NOT NULL added_at REAL NOT NULL,
UNIQUE (dataset_id, frame_id)
)
""",
"""
CREATE TABLE IF NOT EXISTS base_datasets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
image_count INTEGER NOT NULL DEFAULT 0,
box_count INTEGER NOT NULL DEFAULT 0,
classes TEXT NOT NULL DEFAULT '[]',
created_at REAL NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS video_clock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
video_rel TEXT NOT NULL,
folder_date TEXT NOT NULL DEFAULT '',
-- Wall-clock text exactly as the camera burned it in, e.g.
-- '2026-08-06 20:12:42'. Deliberately not an epoch: the overlay carries
-- no timezone, and storing it as one made the backend (UTC) and the
-- browser (UTC+7) disagree by 7 hours, which moved recordings across
-- the 06:00 boundary into the wrong working day.
started_at TEXT,
working_day TEXT NOT NULL DEFAULT '',
confidence REAL NOT NULL DEFAULT 0,
agreeing INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'ocr',
error TEXT NOT NULL DEFAULT '',
read_at REAL,
UNIQUE (project_id, video_rel)
)
""",
"""
CREATE TABLE IF NOT EXISTS count_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
video_rel TEXT NOT NULL,
date_label TEXT NOT NULL,
batch_label TEXT NOT NULL,
loading INTEGER,
unloading INTEGER,
net INTEGER,
ground_truth INTEGER,
frames INTEGER NOT NULL DEFAULT 0,
seconds REAL NOT NULL DEFAULT 0,
params TEXT NOT NULL DEFAULT '{}',
model_path TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
counted_at REAL,
UNIQUE (project_id, video_rel)
) )
""", """,
""" """
@@ -111,7 +177,9 @@ SCHEMA = [
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
batch_id INTEGER REFERENCES batches(id) ON DELETE CASCADE, batch_id INTEGER REFERENCES batches(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('extract', 'autolabel', 'merge', 'train')), type TEXT NOT NULL CHECK (type IN ('extract', 'autolabel', 'merge', 'train',
'count', 'clock-scan',
'truck-scan')),
status TEXT NOT NULL CHECK (status IN ( status TEXT NOT NULL CHECK (status IN (
'queued', 'running', 'done', 'failed', 'cancelled')), 'queued', 'running', 'done', 'failed', 'cancelled')),
params TEXT NOT NULL DEFAULT '{}', params TEXT NOT NULL DEFAULT '{}',
@@ -125,11 +193,35 @@ SCHEMA = [
finished_at REAL finished_at REAL
) )
""", """,
"""
CREATE TABLE IF NOT EXISTS triage_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
stage TEXT NOT NULL DEFAULT 'dataprep',
position INTEGER NOT NULL,
name TEXT NOT NULL,
predicate TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('keep', 'ignore', 'reclass')),
target_class INTEGER,
created_at REAL NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS annotation_overrides (
annotation_id INTEGER PRIMARY KEY REFERENCES annotations(id) ON DELETE CASCADE,
verdict TEXT NOT NULL CHECK (verdict IN ('keep', 'ignore', 'reclass')),
target_class INTEGER,
decided_at REAL NOT NULL
)
""",
"CREATE INDEX IF NOT EXISTS idx_frames_batch ON frames(batch_id, idx)", "CREATE INDEX IF NOT EXISTS idx_frames_batch ON frames(batch_id, idx)",
"CREATE INDEX IF NOT EXISTS idx_triage_rules_project ON triage_rules(project_id, stage, position)",
"CREATE INDEX IF NOT EXISTS idx_annotations_frame ON annotations(frame_id)", "CREATE INDEX IF NOT EXISTS idx_annotations_frame ON annotations(frame_id)",
"CREATE INDEX IF NOT EXISTS idx_batches_project ON batches(project_id)", "CREATE INDEX IF NOT EXISTS idx_batches_project ON batches(project_id)",
"CREATE INDEX IF NOT EXISTS idx_jobs_project ON jobs(project_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_project ON jobs(project_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_dataset_items_project ON dataset_items(project_id)", "CREATE INDEX IF NOT EXISTS idx_dataset_items_project ON dataset_items(project_id)",
"CREATE INDEX IF NOT EXISTS idx_count_runs_project ON count_runs(project_id, date_label)",
"CREATE INDEX IF NOT EXISTS idx_video_clock_project ON video_clock(project_id, working_day)",
] ]
@@ -167,6 +259,164 @@ def migrate() -> None:
cur.execute("ALTER TABLE projects ADD COLUMN secondary_model_name TEXT") cur.execute("ALTER TABLE projects ADD COLUMN secondary_model_name TEXT")
if "secondary_model_classes" not in cols: if "secondary_model_classes" not in cols:
cur.execute("ALTER TABLE projects ADD COLUMN secondary_model_classes TEXT") cur.execute("ALTER TABLE projects ADD COLUMN secondary_model_classes TEXT")
# REQ-110: augmentation settings, null until the user changes them.
if "augment" not in cols:
cur.execute("ALTER TABLE projects ADD COLUMN augment TEXT")
# REQ-107: what rule set a run's numbers were measured under.
cur.execute("PRAGMA table_info(model_versions)")
version_cols = [column[1] for column in cur.fetchall()]
if "rule_version" not in version_cols:
cur.execute("ALTER TABLE model_versions ADD COLUMN rule_version TEXT")
# REQ-113: and what augmentation it trained under.
if "augment" not in version_cols:
cur.execute("ALTER TABLE model_versions ADD COLUMN augment TEXT")
# REQ-132: the triage rules this dataset was actually cut under, frozen
# at merge time. Editing project rules afterwards must not rewrite what
# an existing dataset claims.
cur.execute("PRAGMA table_info(datasets)")
dataset_cols = [column[1] for column in cur.fetchall()]
if "rules_json" not in dataset_cols:
cur.execute("ALTER TABLE datasets ADD COLUMN rules_json TEXT")
_migrate_dataset_items(cur)
_backfill_dataset_rules(cur)
_migrate_job_types(cur)
_migrate_clock_column(cur)
_migrate_truck_columns(cur)
def _migrate_dataset_items(cur) -> None:
"""Move a single-dataset project onto named datasets.
The old table pinned `frame_id UNIQUE`, so one frame could live in exactly
one dataset — which is the constraint that made merging the same batch twice
a conflict. SQLite cannot drop a constraint, so the table is rebuilt and the
existing rows are adopted by a "Master Dataset #1" per project. Nothing is
discarded: a row without a home would be a merged frame the app can no
longer see.
"""
cur.execute("PRAGMA table_info(dataset_items)")
columns = [column[1] for column in cur.fetchall()]
if not columns or "dataset_id" in columns:
return
import time
cur.execute("SELECT DISTINCT project_id FROM dataset_items")
projects_with_items = [row[0] for row in cur.fetchall()]
home = {}
for project_id in projects_with_items:
cur.execute(
"""INSERT INTO datasets (project_id, name, note, created_at)
VALUES (?, 'Master Dataset #1', 'Adopted from the original single dataset', ?)""",
(project_id, time.time()),
)
home[project_id] = cur.lastrowid
cur.execute("ALTER TABLE dataset_items RENAME TO dataset_items_old")
cur.execute("""
CREATE TABLE dataset_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
dataset_id INTEGER NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE,
split TEXT NOT NULL CHECK (split IN ('train', 'val')),
image_rel TEXT NOT NULL,
label_rel TEXT NOT NULL,
added_at REAL NOT NULL,
UNIQUE (dataset_id, frame_id)
)
""")
for project_id, dataset_id in home.items():
cur.execute(
"""INSERT INTO dataset_items (project_id, dataset_id, frame_id, split,
image_rel, label_rel, added_at)
SELECT project_id, ?, frame_id, split, image_rel, label_rel, added_at
FROM dataset_items_old WHERE project_id = ?""",
(dataset_id, project_id),
)
cur.execute("DROP TABLE dataset_items_old")
def _migrate_clock_column(cur) -> None:
"""Drop a `video_clock` that still stores epochs.
The table is a cache of what the reader saw; rebuilding it is one rescan, and
that is cheaper and safer than converting epochs whose timezone is exactly
the thing that was wrong.
"""
cur.execute("PRAGMA table_info(video_clock)")
columns = {row[1]: row[2] for row in cur.fetchall()}
if columns.get("started_at", "TEXT").upper() == "REAL":
cur.execute("DROP TABLE video_clock")
for statement in SCHEMA:
if "CREATE TABLE IF NOT EXISTS video_clock" in statement:
cur.execute(statement)
break
def _migrate_truck_columns(cur) -> None:
"""Truck-presence check per recording (REQ-166)."""
cur.execute("PRAGMA table_info(video_clock)")
columns = {row[1] for row in cur.fetchall()}
for name, kind in (("truck_hits", "INTEGER"), ("truck_samples", "INTEGER"),
("truck_model", "TEXT"), ("truck_checked_at", "REAL")):
if name not in columns:
cur.execute(f"ALTER TABLE video_clock ADD COLUMN {name} {kind}")
def _migrate_job_types(cur) -> None:
"""Let the jobs table accept the 'count' type (REQ-150).
The allowed set is a CHECK constraint written into the table definition, and
SQLite cannot alter one — the table has to be rebuilt. Rows are carried over
verbatim; the only change is which type strings are legal.
"""
cur.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'jobs'")
row = cur.fetchone()
if row is None or "'truck-scan'" in row[0]:
return
cur.execute("ALTER TABLE jobs RENAME TO jobs_old")
for statement in SCHEMA:
if "CREATE TABLE IF NOT EXISTS jobs " in statement:
cur.execute(statement)
break
cur.execute("""INSERT INTO jobs (id, project_id, batch_id, type, status, params, progress,
total, message, error, log, created_at, started_at,
finished_at)
SELECT id, project_id, batch_id, type, status, params, progress, total,
message, error, log, created_at, started_at, finished_at
FROM jobs_old""")
cur.execute("DROP TABLE jobs_old")
def _backfill_dataset_rules(cur) -> None:
"""Datasets merged before REQ-132 have no snapshot. Give them the project's
current rules — that is what they were cut under, unless the rules changed
in between, and an empty snapshot would claim "no rules" for all of them.
"""
import json
cur.execute("SELECT id, project_id FROM datasets WHERE rules_json IS NULL")
stale = cur.fetchall()
if not stale:
return
by_project: dict = {}
for dataset_id, project_id in stale:
if project_id not in by_project:
cur.execute(
"""SELECT name, predicate, action, target_class FROM triage_rules
WHERE project_id = ? AND stage = 'dataprep' ORDER BY position""",
(project_id,),
)
by_project[project_id] = json.dumps([
{"name": row[0], "predicate": json.loads(row[1]),
"action": row[2], "target_class": row[3]}
for row in cur.fetchall()
])
cur.execute("UPDATE datasets SET rules_json = ? WHERE id = ?",
(by_project[project_id], dataset_id))
def healthy() -> bool: def healthy() -> bool:
+303
View File
@@ -0,0 +1,303 @@
"""Export annotated frames as a plain YOLO dataset the user can keep.
This is deliberately independent of the master dataset. A batch can be exported
the moment it has annotations — before review, before merge — because the point
is that the user owns a copy of their own work and does not have to trust this
application to keep it.
Layout inside the zip:
images/<batch>__<frame>.jpg
labels/<batch>__<frame>.txt
data.yaml
classes.txt
MANIFEST.json what was exported, and what was skipped
"""
import json
import os
import shutil
import tempfile
import time
from typing import List, Optional
from backend import batches, config, db, projects, review
class ExportError(Exception):
pass
def _label_line(class_id: int, geometry: dict, label_type: str) -> str:
from backend.dataset import _label_line as build
return build(class_id, geometry, label_type)
def annotated_frames(project_id: int, batch_ids: Optional[List[int]] = None,
approved_only: bool = False) -> list:
where = "b.project_id = ?"
args: list = [project_id]
if batch_ids:
where += f" AND b.id IN ({','.join('?' for _ in batch_ids)})"
args += list(batch_ids)
if approved_only:
where += " AND f.review_status = 'approved'"
with db.cursor() as cur:
cur.execute(
f"""SELECT f.id, f.filename, f.review_status, b.id AS batch_id,
b.batch_label, b.date_label
FROM frames f JOIN batches b ON b.id = f.batch_id
WHERE {where}
ORDER BY b.id, f.idx""",
args,
)
return [dict(row) for row in cur.fetchall()]
def build_zip(project_id: int, batch_ids: Optional[List[int]] = None,
approved_only: bool = False, include_empty: bool = False) -> str:
"""Write the zip and return its path.
`include_empty` decides whether frames with no annotation are exported as
negative samples. Off by default: an empty label file is a claim that the
image contains nothing, and for a frame that was simply never annotated that
claim is false.
"""
project = projects.get(project_id)
if project is None:
raise ExportError("No such project")
frames = annotated_frames(project_id, batch_ids, approved_only)
if not frames:
raise ExportError("There is nothing to export — no frames match that selection")
staging = tempfile.mkdtemp(prefix="export-")
images_dir = os.path.join(staging, "images")
labels_dir = os.path.join(staging, "labels")
os.makedirs(images_dir)
os.makedirs(labels_dir)
written = 0
skipped_empty = 0
missing = 0
shapes = 0
per_batch: dict = {}
for frame in frames:
annotations = review.listing(frame["id"])
if not annotations and not include_empty:
skipped_empty += 1
continue
source = os.path.join(
batches.frames_dir(project["slug"], frame["batch_id"]), frame["filename"])
if not os.path.isfile(source):
missing += 1
continue
stem = f"{frame['batch_id']}__{os.path.splitext(frame['filename'])[0]}"
shutil.copyfile(source, os.path.join(images_dir, f"{stem}.jpg"))
lines = [_label_line(item["class_id"], item["geometry"], project["label_type"])
for item in annotations]
with open(os.path.join(labels_dir, f"{stem}.txt"), "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + ("\n" if lines else ""))
written += 1
shapes += len(lines)
label = f"{frame['date_label']}/{frame['batch_label']}"
per_batch[label] = per_batch.get(label, 0) + 1
if written == 0:
shutil.rmtree(staging, ignore_errors=True)
raise ExportError(
"Every matching frame was empty or missing its image, so the export would "
"contain no labels"
)
names = ", ".join(f"'{item['name']}'" for item in project["classes"])
with open(os.path.join(staging, "data.yaml"), "w", encoding="utf-8") as handle:
handle.write("path: .\ntrain: images\nval: images\n\n")
handle.write(f"nc: {len(project['classes'])}\n")
handle.write(f"names: [{names}]\n")
with open(os.path.join(staging, "classes.txt"), "w", encoding="utf-8") as handle:
handle.write("\n".join(item["name"] for item in project["classes"]) + "\n")
manifest = {
"project": project["name"],
"label_type": project["label_type"],
"exported_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"approved_only": approved_only,
"include_empty": include_empty,
"images": written,
"shapes": shapes,
"per_batch": per_batch,
"skipped_without_annotations": skipped_empty,
"skipped_image_missing": missing,
"classes": [{"class_id": c["class_id"], "name": c["name"]} for c in project["classes"]],
}
with open(os.path.join(staging, "MANIFEST.json"), "w", encoding="utf-8") as handle:
json.dump(manifest, handle, indent=2)
project_root = config.project_dir(project["slug"])
# Each export is ~200 MB. Without this, every click leaves another copy
# behind and the project folder grows without bound.
for stale in os.listdir(project_root):
if stale.startswith("export-") and stale.endswith(".zip"):
try:
os.unlink(os.path.join(project_root, stale))
except OSError:
pass
stamp = time.strftime("%Y%m%d-%H%M%S")
path = shutil.make_archive(os.path.join(project_root, f"export-{stamp}"), "zip", staging)
shutil.rmtree(staging, ignore_errors=True)
return path
# ---- restore ------------------------------------------------------------
def _safe_members(archive) -> list:
"""Only plain files under images/ and labels/.
A zip can name `../../etc/passwd`; extracting one blindly writes wherever it
says. Every member is checked rather than trusted.
"""
picked = []
for member in archive.infolist():
if member.is_dir():
continue
name = member.filename.replace("\\", "/")
if name.startswith("/") or ".." in name.split("/"):
continue
if name.startswith(("images/", "labels/")) or name == "MANIFEST.json":
picked.append((name, member))
return picked
def _points_from_label(parts: List[str], label_type: str) -> Optional[dict]:
values = [float(v) for v in parts]
if label_type == "bbox":
if len(values) != 4:
return None
cx, cy, w, h = values
return {"type": "bbox",
"points": [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2]}
if len(values) < 6 or len(values) % 2:
return None
return {"type": "polygon",
"points": [[values[i], values[i + 1]] for i in range(0, len(values), 2)]}
def restore_zip(project_id: int, zip_path: str, batch_label: str = "") -> dict:
"""Load an exported zip back in as a fresh batch, ready to keep reviewing.
The frames land in a new batch rather than being merged back into the ones
they came from: the originals may still exist, and silently overwriting a
batch the user is working in would destroy the very work this feature is
meant to protect.
"""
import zipfile
from PIL import Image
project = projects.get(project_id)
if project is None:
raise ExportError("No such project")
by_name = {item["name"]: item["class_id"] for item in project["classes"]}
stamp = time.strftime("%Y%m%d-%H%M%S")
label = batch_label or f"restored-{stamp}"
with zipfile.ZipFile(zip_path) as archive:
members = _safe_members(archive)
names = {name for name, _ in members}
if not any(name.startswith("images/") for name in names):
raise ExportError("This zip has no images/ folder — is it an export from this app?")
manifest = {}
if "MANIFEST.json" in names:
manifest = json.loads(archive.read("MANIFEST.json"))
source_type = manifest.get("label_type", project["label_type"])
if source_type != project["label_type"]:
raise ExportError(
f"This export holds {source_type} labels but the project is "
f"{project['label_type']} — importing it would produce wrong shapes"
)
# Classes come back by name, so an id that shifted since the export does
# not silently relabel every shape.
remap = {}
for item in manifest.get("classes", []):
if item["name"] in by_name:
remap[item["class_id"]] = by_name[item["name"]]
else:
raise ExportError(
f"The export uses class '{item['name']}', which this project does not "
"have. Add the class first, then import."
)
with db.cursor() as cur:
cur.execute(
"""INSERT INTO batches (project_id, video_path, date_label, batch_label,
start_sec, end_sec, fps, status, created_at)
VALUES (?, '', 'restored', ?, 0, 0, 0, 'extracted', ?)""",
(project_id, label, time.time()),
)
batch_id = cur.lastrowid
target_dir = batches.frames_dir(project["slug"], batch_id)
os.makedirs(target_dir, exist_ok=True)
restored, shapes, skipped = 0, 0, 0
image_members = sorted(n for n in names if n.startswith("images/"))
for index, name in enumerate(image_members):
stem = os.path.splitext(os.path.basename(name))[0]
if not stem:
continue
filename = f"{stem}.jpg"
destination = os.path.join(target_dir, filename)
with archive.open(name) as source, open(destination, "wb") as handle:
shutil.copyfileobj(source, handle)
try:
with Image.open(destination) as image:
width, height = image.size
except Exception:
os.unlink(destination)
skipped += 1
continue
with db.cursor() as cur:
cur.execute(
"""INSERT INTO frames (batch_id, idx, filename, width, height, review_status)
VALUES (?, ?, ?, ?, ?, 'pending')""",
(batch_id, index, filename, width, height),
)
frame_id = cur.lastrowid
label_name = f"labels/{stem}.txt"
if label_name in names:
for line in archive.read(label_name).decode("utf-8").splitlines():
parts = line.split()
if len(parts) < 5:
continue
geometry = _points_from_label(parts[1:], project["label_type"])
if geometry is None:
continue
class_id = remap.get(int(parts[0]), int(parts[0]))
cur.execute(
"""INSERT INTO annotations (frame_id, class_id, geometry, score,
source, created_at)
VALUES (?, ?, ?, 1.0, 'manual', ?)""",
(frame_id, class_id, json.dumps(geometry), time.time()),
)
shapes += 1
restored += 1
with db.cursor() as cur:
cur.execute("UPDATE batches SET frame_count = ?, status = 'reviewing' WHERE id = ?",
(restored, batch_id))
return {"batch_id": batch_id, "batch_label": label, "frames": restored,
"shapes": shapes, "skipped": skipped}
+3 -3
View File
@@ -47,12 +47,12 @@ def defaults(epochs: int = 50) -> dict:
elif vram < 6: elif vram < 6:
settings = {"batch": 16, "imgsz": 640, "device": 0, "workers": 4} settings = {"batch": 16, "imgsz": 640, "device": 0, "workers": 4}
note = f"{vram} GB of VRAM: batch 16, 640 px." note = f"{vram} GB of VRAM: batch 16, 640 px."
elif vram <= 16: elif vram < 15:
settings = {"batch": 32, "imgsz": 640, "device": 0, "workers": 8} settings = {"batch": 32, "imgsz": 640, "device": 0, "workers": 8}
note = f"{vram} GB of VRAM: optimized batch 32, 640 px." note = f"{vram} GB of VRAM: optimized batch 32, 640 px."
else: else:
settings = {"batch": 32, "imgsz": 768, "device": 0, "workers": 8} settings = {"batch": 64, "imgsz": 640, "device": 0, "workers": 8}
note = f"{vram} GB of VRAM: room for larger batches and 768 px." note = f"{vram} GB of VRAM: max throughput batch 64, 640 px."
return {**info, **settings, "epochs": epochs, "note": note} return {**info, **settings, "epochs": epochs, "note": note}
+3 -3
View File
@@ -27,11 +27,11 @@ from backend import db
MAX_LOG_LINES = 500 MAX_LOG_LINES = 500
PROGRESS_FLUSH_SECONDS = 0.5 PROGRESS_FLUSH_SECONDS = 0.5
JOB_TYPES = ("extract", "autolabel", "merge", "train") JOB_TYPES = ("extract", "autolabel", "merge", "train", "count", "clock-scan",
GPU_JOB_TYPES = ("autolabel", "train") "truck-scan")
GPU_JOB_TYPES = ("autolabel", "train", "count", "truck-scan")
"""`extract` is ffmpeg and `merge` is file copying — neither touches the card, """`extract` is ffmpeg and `merge` is file copying — neither touches the card,
so neither should be able to block an interactive assist.""" so neither should be able to block an interactive assist."""
gpu_lock = threading.Lock() gpu_lock = threading.Lock()
"""Held for the duration of any GPU work. The job worker takes it around a """Held for the duration of any GPU work. The job worker takes it around a
handler; the interactive assist route takes it around one SAM3 call. One card, handler; the interactive assist route takes it around one SAM3 call. One card,
+4
View File
@@ -43,7 +43,11 @@ def _iou(box_a: List[float], box_b: List[float]) -> float:
def deduplicate(detections: List[Detection], iou_threshold: float = 0.8) -> List[Detection]: def deduplicate(detections: List[Detection], iou_threshold: float = 0.8) -> List[Detection]:
"""Greedy NMS per class: highest score wins within the SAME class.""" """Greedy NMS per class: highest score wins within the SAME class."""
if iou_threshold <= 0.0:
return detections
by_class: dict[int, List[Detection]] = {} by_class: dict[int, List[Detection]] = {}
for det in detections: for det in detections:
by_class.setdefault(det.class_id, []).append(det) by_class.setdefault(det.class_id, []).append(det)
+65 -2
View File
@@ -8,7 +8,8 @@ import os
import re import re
from typing import List, Optional from typing import List, Optional
from backend import config, db, video from backend import config, db, projects, video
class LibraryError(Exception): class LibraryError(Exception):
@@ -70,6 +71,7 @@ def list_videos(video_root: str, date: str, project_id: Optional[int] = None) ->
if not os.path.isdir(folder): if not os.path.isdir(folder):
raise LibraryError(f"No such date in the archive: {date}") raise LibraryError(f"No such date in the archive: {date}")
project = projects.get(project_id) if project_id is not None else None
used = _usage(project_id) used = _usage(project_id)
videos = [] videos = []
for filename in sorted( for filename in sorted(
@@ -77,14 +79,17 @@ def list_videos(video_root: str, date: str, project_id: Optional[int] = None) ->
key=_batch_sort_key, key=_batch_sort_key,
): ):
path = os.path.join(folder, filename) path = os.path.join(folder, filename)
rel = f"{date}/{filename}"
entry = { entry = {
"rel": f"{date}/{filename}", "rel": rel,
"filename": filename, "filename": filename,
"batch_label": batch_label(filename), "batch_label": batch_label(filename),
"used_count": used.get(os.path.realpath(path), 0), "used_count": used.get(os.path.realpath(path), 0),
} }
try: try:
entry.update(video.probe(path)) entry.update(video.probe(path))
if project is not None:
ensure_video_preview(project, rel)
except video.VideoError as exc: except video.VideoError as exc:
# A file ffprobe cannot read still belongs in the list, flagged — # A file ffprobe cannot read still belongs in the list, flagged —
# hiding it would look like the archive is missing recordings. # hiding it would look like the archive is missing recordings.
@@ -94,7 +99,65 @@ def list_videos(video_root: str, date: str, project_id: Optional[int] = None) ->
return videos return videos
import threading
_conversion_queue = set()
_conversion_lock = threading.Lock()
#: Serialises preview transcodes. Each one saturates several cores on its own.
_conversion_slot = threading.Semaphore(1)
def ensure_video_preview(project: dict, rel: str) -> None:
"""Asynchronously convert video to H.264 if it's not natively web-supported."""
rel_key = rel.replace("/", "_")
base, _ = os.path.splitext(rel_key)
preview_filename = f"{base}.mp4"
preview_dir = os.path.join(config.project_dir(project["slug"]), "previews")
preview_path = os.path.join(preview_dir, preview_filename)
if os.path.isfile(preview_path):
return
try:
full_path = resolve(project["video_root"], rel)
info = video.probe(full_path)
if info.get("codec_name") == "h264" and full_path.lower().endswith(".mp4"):
return
except Exception:
return
with _conversion_lock:
if preview_path in _conversion_queue:
return
_conversion_queue.add(preview_path)
def _worker():
# One conversion at a time. The queue above only stops the *same* file
# being converted twice; it never bounded how many ran at once, so
# opening a date folder with 28 videos started 28 simultaneous x264
# encodes. That pinned every core, drove load average past 250, and
# starved everything else in the process — inference included.
with _conversion_slot:
try:
if os.path.isfile(preview_path):
return
os.makedirs(preview_dir, exist_ok=True)
# A preview only has to be watchable in a browser, so it is not
# worth `-preset medium -crf 18`: veryfast/23 encodes several
# times faster for a difference nobody scrubbing footage sees.
video.convert_to_h264(full_path, output_path=preview_path,
crf=23, preset="veryfast")
except Exception as exc:
print(f"[PREVIEW CONVERSION ERROR] {rel}: {exc}")
finally:
with _conversion_lock:
_conversion_queue.discard(preview_path)
threading.Thread(target=_worker, daemon=True).start()
def _usage(project_id: Optional[int]) -> dict: def _usage(project_id: Optional[int]) -> dict:
"""How many batches already came out of each video path (REQ-012).""" """How many batches already came out of each video path (REQ-012)."""
if project_id is None: if project_id is None:
return {} return {}
+467
View File
@@ -0,0 +1,467 @@
"""Live counting test bench: point a trained model at an RTSP stream and watch it count.
This is a **test harness**, not the production counter. It reuses the real
pipeline pieces from `algoritma-batch` — ByteTrack, the bbox stabiliser and the
line-cross counter with its spatial dedup — so what you see here is what
`predict.py` would do. What it deliberately leaves out is everything stateful:
no batch lifecycle, no SQLite, no truck-presence state machine. The question it
answers is "does this model count correctly on this camera", and those parts
only get in the way of answering it.
One session at a time, holding the GPU lock, because the GPU is shared with
training and auto-annotation (REQ-065, REQ-070).
"""
import os
import sys
import threading
import time
from typing import Optional
import cv2
import numpy as np
from backend import config, jobs
# `algoritma-batch/src` is copied to /app/src in the image; in a source checkout
# it still lives under algoritma-batch/. Both are made importable as `src.*`.
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for candidate in ("/app", os.path.join(_REPO, "algoritma-batch")):
if os.path.isdir(os.path.join(candidate, "src")) and candidate not in sys.path:
sys.path.insert(0, candidate)
class LiveCountError(Exception):
pass
class Session:
"""One running counter. Owns a capture thread and the latest rendered frame."""
def __init__(self, source: str, model_path: str, line_y: int,
line_x_start: int, line_x_end: int, conf: float,
dedup_radius: float, margin: int, imgsz: int,
entry_travel_min: float, handoff_radius: float,
unload_confirm_frames: int, min_area_scale: float,
spatial_dedup: bool):
self.source = source
self.model_path = model_path
self.line_y = line_y
self.line_x_start = line_x_start
self.line_x_end = line_x_end
self.conf = conf
self.dedup_radius = dedup_radius
self.margin = margin
self.imgsz = imgsz
self.entry_travel_min = entry_travel_min
self.handoff_radius = handoff_radius
self.unload_confirm_frames = unload_confirm_frames
self.min_area_scale = min_area_scale
self.spatial_dedup = spatial_dedup
self.started_at = time.time()
self.error = ""
self.stopping = False
self.frames = 0
self.fps = 0.0
self.loading = 0
self.unloading = 0
self.tracked = 0
self.ignored = 0
self.too_small = 0
self.traced = 0
self.trace_path = os.path.join(
config.DATA_DIR, "live-count", f"session-{int(self.started_at)}.jsonl")
self.events: list[dict] = []
self._jpeg: Optional[bytes] = None
self._counter = None # set once the worker builds it
self._lock = threading.Lock()
self._thread = threading.Thread(target=self._run, name="live-count", daemon=True)
# -- public -----------------------------------------------------------
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self.stopping = True
self._thread.join(timeout=15)
def snapshot(self) -> Optional[bytes]:
with self._lock:
return self._jpeg
def move_line(self, line_y: Optional[int] = None, line_x_start: Optional[int] = None,
line_x_end: Optional[int] = None) -> dict:
"""Reposition the counting line without restarting.
Placing a line correctly means watching the stream while you move it,
which is impossible if moving it requires a restart — and a restart
throws the counts away. Already-counted tracks keep their verdict; the
counter only decides for a track the first time it crosses.
"""
if line_y is not None:
self.line_y = max(0, min(720, int(line_y)))
if line_x_start is not None:
self.line_x_start = max(0, min(1280, int(line_x_start)))
if line_x_end is not None:
self.line_x_end = max(0, min(1280, int(line_x_end)))
if self.line_x_end < self.line_x_start:
self.line_x_start, self.line_x_end = self.line_x_end, self.line_x_start
if self._counter is not None:
self._counter.line_y = self.line_y
self._counter.line_x_start = self.line_x_start
self._counter.line_x_end = self.line_x_end
return {"y": self.line_y, "x_start": self.line_x_start, "x_end": self.line_x_end}
def status(self) -> dict:
return {
"running": self._thread.is_alive(),
"source": self.source,
"model_path": self.model_path,
"error": self.error,
"frames": self.frames,
"fps": round(self.fps, 1),
"loading": self.loading,
"unloading": self.unloading,
"net": self.loading - self.unloading,
"tracked": self.tracked,
"ignored": self.ignored,
"elapsed": round(time.time() - self.started_at, 1),
"line": {"y": self.line_y, "x_start": self.line_x_start, "x_end": self.line_x_end},
"too_small": self.too_small,
"traced": self.traced,
"trace_path": self.trace_path,
"events": self.events[-25:],
}
# -- worker -----------------------------------------------------------
def _run(self) -> None:
# The GPU is shared. Waiting here rather than failing means "start" is
# safe to press while a training run is finishing.
if not jobs.gpu_lock.acquire(timeout=30):
busy = jobs.running_types()
self.error = f"GPU busy with a {busy[0] if busy else 'background'} job"
return
capture = None
try:
from ultralytics import YOLO
from src.counting import LineCrossCounter
from src.stabilizer import BboxStabilizer
from src.tracking import ByteTrackTracker
model = YOLO(self.model_path)
# Warm-up: the first CUDA call inside the tracker has been seen to
# segfault without it (same reason predict.py does this).
model(np.zeros((720, 1280, 3), dtype=np.uint8), imgsz=self.imgsz, verbose=False)
tracker = ByteTrackTracker(model, self.conf)
stabilizer = BboxStabilizer(ema_alpha=0.35, max_hold_frames=10,
max_height_ratio=1.5, min_height_ratio=0.70)
counter = LineCrossCounter(
line_y=self.line_y, line_x_start=self.line_x_start,
line_x_end=self.line_x_end, margin=self.margin,
dedup_radius=self.dedup_radius,
entry_travel_min=self.entry_travel_min,
handoff_radius=self.handoff_radius,
unload_confirm_frames=self.unload_confirm_frames,
spatial_dedup=self.spatial_dedup,
)
self._counter = counter
capture = _open(self.source)
if capture is None or not capture.isOpened():
raise LiveCountError(f"Could not open source: {self.source}")
tick = time.time()
since = 0
while not self.stopping:
ok, frame = capture.read()
if not ok or frame is None:
# A file simply ends. On a stream this only means the
# decoder has not produced a new frame yet, so wait briefly
# — long enough not to spin, short enough not to become the
# new frame-rate ceiling.
if _is_stream(self.source):
time.sleep(0.005)
continue
break
frame = cv2.resize(frame, (1280, 720))
detections = [d for d in tracker.update(frame, []) if d.class_name == "sack"]
stable = stabilizer.update(detections)
inside, outside = [], []
small = 0
for det in stable:
x1, y1, x2, y2 = det.bbox
centre_x = (x1 + x2) / 2
# Perspective-aware area gate, the curve `predict.py` uses:
# a box that small at that depth is a fragment, not a sack.
if _too_small(det.bbox, self.min_area_scale):
small += 1
outside.append(det)
elif self.line_x_start <= centre_x <= self.line_x_end:
inside.append(det)
else:
outside.append(det)
self.too_small = small
for event in counter.update(inside):
self.events.append({
"track_id": event.get("track_id"),
"direction": event.get("direction", "loading"),
"at": round(time.time() - self.started_at, 1),
})
self._write_traces(counter.drain_traces())
self.loading = counter.loading_count
self.unloading = counter.unloading_count
self.tracked = len(inside)
self.ignored = len(outside)
self.frames += 1
since += 1
now = time.time()
if now - tick >= 1.0:
self.fps = since / (now - tick)
tick, since = now, 0
self._render(frame, inside, outside, counter)
except Exception as exc: # surfaced in status(), not swallowed
self.error = f"{type(exc).__name__}: {exc}"
finally:
# The lock is released no matter what tearing down the capture does.
# It was the other order once, and one exception in release() leaked
# the GPU for the lifetime of the process.
try:
if capture is not None:
capture.release()
except Exception as exc:
if not self.error:
self.error = f"capture release failed: {exc}"
finally:
jobs.gpu_lock.release()
def _write_traces(self, traces: list) -> None:
"""Append finished tracks to a JSONL, one object per track.
This is the file that answers "was that the model, the tracker or the
counter" on a clip with a known count: every track that ever existed
lands here with its trajectory and the reason it did or did not count.
"""
if not traces:
return
import json
try:
os.makedirs(os.path.dirname(self.trace_path), exist_ok=True)
with open(self.trace_path, "a", encoding="utf-8") as handle:
for record in traces:
handle.write(json.dumps(record, default=str) + "\n")
self.traced += len(traces)
except OSError as exc:
if not self.error:
self.error = f"trace write failed: {exc}"
def _render(self, frame, detections, ignored, counter) -> None:
height, width = frame.shape[:2]
# Shade what the region excludes. Without this the neighbouring truck's
# sacks simply vanish from the overlay, and "are they being ignored?"
# looks identical to "is the model missing them?".
if self.line_x_start > 0 or self.line_x_end < width:
shade = frame.copy()
if self.line_x_start > 0:
cv2.rectangle(shade, (0, 0), (self.line_x_start, height), (0, 0, 0), -1)
if self.line_x_end < width:
cv2.rectangle(shade, (self.line_x_end, 0), (width, height), (0, 0, 0), -1)
cv2.addWeighted(shade, 0.55, frame, 0.45, 0, frame)
# Ignored detections stay visible, in grey, so the region can be judged.
for det in ignored:
x1, y1, x2, y2 = (int(v) for v in det.bbox)
cv2.rectangle(frame, (x1, y1), (x2, y2), (130, 130, 130), 1)
for edge in (self.line_x_start, self.line_x_end):
if 0 < edge < width:
cv2.line(frame, (edge, 0), (edge, height), (255, 0, 255), 2)
cv2.putText(frame, "IGNORED", (max(4, self.line_x_start - 92), height - 14),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1, cv2.LINE_AA)
cv2.putText(frame, "IGNORED", (min(width - 88, self.line_x_end + 8), height - 14),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1, cv2.LINE_AA)
for det in detections:
x1, y1, x2, y2 = (int(v) for v in det.bbox)
counted = bool(counter.counted_tracks.get(det.track_id))
colour = (74, 222, 128) if counted else (248, 191, 113)
cv2.rectangle(frame, (x1, y1), (x2, y2), colour, 2)
cv2.putText(frame, f"#{det.track_id} {det.confidence:.2f}", (x1, max(14, y1 - 6)),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, colour, 1, cv2.LINE_AA)
cv2.line(frame, (self.line_x_start, self.line_y), (self.line_x_end, self.line_y),
(0, 255, 255), 2)
for edge in (self.line_y - self.margin, self.line_y + self.margin):
cv2.line(frame, (self.line_x_start, edge), (self.line_x_end, edge),
(0, 160, 160), 1)
panel = f"IN {self.loading} OUT {self.unloading} NET {self.loading - self.unloading}"
cv2.rectangle(frame, (12, 12), (12 + 9 * len(panel) + 20, 84), (0, 0, 0), -1)
cv2.putText(frame, panel, (24, 46), cv2.FONT_HERSHEY_SIMPLEX, 0.8,
(74, 222, 128), 2, cv2.LINE_AA)
cv2.putText(frame, f"{self.fps:.1f} fps {self.tracked} tracked {self.ignored} ignored",
(24, 72), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (200, 200, 200), 1, cv2.LINE_AA)
ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 75])
if ok:
with self._lock:
self._jpeg = buffer.tobytes()
def _too_small(bbox, scale: float) -> bool:
"""`predict.py`'s perspective curve: a sack at the top of the frame is
genuinely smaller in pixels than the same sack at the bottom, so one flat
threshold either lets fragments through up close or discards real sacks far
away. Interpolated in the 1280x720 space the pipeline works in. `scale` of 0
turns the gate off."""
if scale <= 0:
return False
x1, y1, x2, y2 = bbox
centre_y = (y1 + y2) / 2.0
top_y, bottom_y = 133.0, 720.0
top_area, bottom_area = 3556.0, 11111.0
if centre_y <= top_y:
minimum = top_area
elif centre_y >= bottom_y:
minimum = bottom_area
else:
ratio = (centre_y - top_y) / (bottom_y - top_y)
minimum = top_area + ratio * (bottom_area - top_area)
return (x2 - x1) * (y2 - y1) < minimum * scale
def _is_stream(source: str) -> bool:
return str(source).startswith(("rtsp://", "rtmp://", "http://", "https://"))
class _ThreadedStream:
"""Decode in a background thread and always hand out the newest frame.
A plain VideoCapture.read() on RTSP is blocking, and decoding 1080p costs
more than inference does — measured here at 6.7 fps end-to-end against 200
fps for the model itself. Worse, reading slower than the camera sends builds
a backlog, so the picture drifts further behind real time the longer it
runs. Dropping stale frames keeps latency flat, which is what a counting
test needs to mean anything. `predict.py` does the same thing.
"""
def __init__(self, source: str):
self._capture = cv2.VideoCapture(source)
try:
self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
except Exception:
pass
self._frame = None
self._lock = threading.Lock()
self._running = True
self._thread = threading.Thread(target=self._pump, daemon=True)
if self._capture.isOpened():
self._thread.start()
def _pump(self) -> None:
while self._running:
ok, frame = self._capture.read()
if not ok:
time.sleep(0.01)
continue
with self._lock:
self._frame = frame
def isOpened(self) -> bool:
return self._capture.isOpened()
def read(self):
with self._lock:
if self._frame is None:
return False, None
frame, self._frame = self._frame, None
return True, frame
def release(self) -> None:
self._running = False
# The thread is only started when the capture opened, so a failed
# source would otherwise raise "cannot join thread before it is
# started" here — inside the caller's finally, skipping the GPU lock
# release and wedging every later session on "GPU busy".
if self._thread.is_alive():
self._thread.join(timeout=2)
self._capture.release()
def _open(source: str):
if _is_stream(source):
os.environ.setdefault(
"OPENCV_FFMPEG_CAPTURE_OPTIONS",
"rtsp_transport;tcp|buffer_size;20480000|max_delay;500000",
)
return _ThreadedStream(source)
return cv2.VideoCapture(source)
# ---- module-level single session ----------------------------------------
_session: Optional[Session] = None
_guard = threading.Lock()
def start(source: str, model_path: str, line_y: int, line_x_start: int, line_x_end: int,
conf: float = 0.35, dedup_radius: float = 60.0, margin: int = 5,
imgsz: int = 640, entry_travel_min: float = 60.0,
handoff_radius: float = 100.0, unload_confirm_frames: int = 3,
min_area_scale: float = 1.0, spatial_dedup: bool = False) -> dict:
global _session
with _guard:
if _session is not None and _session.status()["running"]:
raise LiveCountError("A counting session is already running — stop it first")
if not os.path.isfile(model_path):
raise LiveCountError(f"Model not found: {model_path}")
if not _is_stream(source) and not os.path.isfile(source):
raise LiveCountError(f"Source not found: {source}")
_session = Session(source, model_path, line_y, line_x_start, line_x_end,
conf, dedup_radius, margin, imgsz, entry_travel_min,
handoff_radius, unload_confirm_frames, min_area_scale,
spatial_dedup)
_session.start()
time.sleep(0.4) # let an immediate failure surface in the response
return _session.status()
def stop() -> dict:
global _session
with _guard:
if _session is None:
return {"running": False}
_session.stop()
report = _session.status()
_session = None
return report
def move_line(line_y=None, line_x_start=None, line_x_end=None) -> dict:
if _session is None:
raise LiveCountError("No counting session is running")
return _session.move_line(line_y, line_x_start, line_x_end)
def status() -> dict:
if _session is None:
# Same shape as a live session, so callers never branch on presence.
return {"running": False, "loading": 0, "unloading": 0, "net": 0, "frames": 0,
"fps": 0.0, "tracked": 0, "ignored": 0, "too_small": 0, "traced": 0,
"trace_path": "", "elapsed": 0.0, "events": [],
"error": "", "source": "", "model_path": "",
"line": {"y": 0, "x_start": 0, "x_end": 1280}}
return _session.status()
def snapshot() -> Optional[bytes]:
return _session.snapshot() if _session is not None else None
+12 -2
View File
@@ -14,14 +14,20 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from backend import config, db, jobs from backend import archive_index, config, db, jobs
from backend.api import batches, jobs as job_routes, models, projects, review from backend.api import (batches, counting_bench as counting_bench_routes,
datasets as dataset_routes, jobs as job_routes,
live_count as live_count_routes, models, projects, review, triage)
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
config.ensure_dirs() config.ensure_dirs()
db.migrate() db.migrate()
from backend import datasets as dataset_store
adopted = dataset_store.adopt_legacy_tree()
if adopted:
print(f"[startup] moved {adopted} legacy dataset tree(s) under datasets/<id>/")
from backend import projects as project_store from backend import projects as project_store
project_store.ensure_seed_project() project_store.ensure_seed_project()
interrupted = jobs.recover() interrupted = jobs.recover()
@@ -43,6 +49,10 @@ app.include_router(batches.router)
app.include_router(review.router) app.include_router(review.router)
app.include_router(models.router) app.include_router(models.router)
app.include_router(job_routes.router) app.include_router(job_routes.router)
app.include_router(triage.router)
app.include_router(dataset_routes.router)
app.include_router(live_count_routes.router)
app.include_router(counting_bench_routes.router)
@app.get("/api/health") @app.get("/api/health")
+3 -7
View File
@@ -252,12 +252,9 @@ def add_class(project_id: int, name: str, prompt: Optional[str] = None) -> dict:
(project_id, next_id, clean, (prompt or clean).strip()), (project_id, next_id, clean, (prompt or clean).strip()),
) )
updated = get(project_id) # No data.yaml to refresh here any more: it is assembled per training run
if updated["dataset"]["train"] + updated["dataset"]["val"] > 0: # from the datasets that run picks, so it always reflects the current classes.
from backend import dataset return get(project_id)
dataset.write_data_yaml(updated)
return updated
def delete_class(project_id: int, class_id: int) -> dict: def delete_class(project_id: int, class_id: int) -> dict:
@@ -305,7 +302,6 @@ def delete_class(project_id: int, class_id: int) -> dict:
report = dataset.drop_class_from_labels(project, class_id) report = dataset.drop_class_from_labels(project, class_id)
updated = get(project_id) updated = get(project_id)
dataset.write_data_yaml(updated)
return { return {
"project": updated, "project": updated,
+39
View File
@@ -220,6 +220,45 @@ def delete(annotation_id: int) -> bool:
return cur.rowcount > 0 return cur.rowcount > 0
def delete_many(annotation_ids: List[int]) -> int:
"""Drop a marquee's worth of shapes in one statement.
One DELETE per shape would be one request per shape from the editor, and a
marquee over a dense frame routinely covers a hundred (REQ-045a)."""
if not annotation_ids:
return 0
marks = ",".join("?" * len(annotation_ids))
with db.cursor() as cur:
cur.execute(f"DELETE FROM annotations WHERE id IN ({marks})", annotation_ids)
return cur.rowcount
def reclass_many(annotation_ids: List[int], class_id: int) -> int:
if not annotation_ids:
return 0
with db.cursor() as cur:
cur.execute(
"""SELECT DISTINCT b.project_id FROM annotations a
JOIN frames f ON f.id = a.frame_id
JOIN batches b ON b.id = f.batch_id
WHERE a.id IN (%s)""" % ",".join("?" * len(annotation_ids)),
annotation_ids,
)
projects = [row[0] for row in cur.fetchall()]
for project_id in projects:
_check_class(project_id, class_id)
marks = ",".join("?" * len(annotation_ids))
with db.cursor() as cur:
# Same rule as update(): a hand decision makes these the user's shapes,
# so a re-run of auto-annotation leaves them alone (REQ-034).
cur.execute(
f"UPDATE annotations SET class_id = ?, source = 'manual' WHERE id IN ({marks})",
[class_id, *annotation_ids],
)
return cur.rowcount
def replace_auto(frame_id: int, items: List[dict]) -> int: def replace_auto(frame_id: int, items: List[dict]) -> int:
"""Swap this frame's automatic shapes for a fresh set, leaving manual ones.""" """Swap this frame's automatic shapes for a fresh set, leaving manual ones."""
with db.cursor() as cur: with db.cursor() as cur:
+10 -6
View File
@@ -37,6 +37,8 @@ class Detection:
mask: Optional[np.ndarray] = None # bool array, (H, W) at original image size mask: Optional[np.ndarray] = None # bool array, (H, W) at original image size
class Sam3Engine: class Sam3Engine:
def __init__(self, checkpoint_path: Optional[str] = None): def __init__(self, checkpoint_path: Optional[str] = None):
# SAM3 is CUDA-only in practice: `PositionEmbeddingSine` precomputes its # SAM3 is CUDA-only in practice: `PositionEmbeddingSine` precomputes its
@@ -67,13 +69,14 @@ class Sam3Engine:
def detect(self, image: Image.Image, prompts: List[str], threshold: float) -> List[Detection]: def detect(self, image: Image.Image, prompts: List[str], threshold: float) -> List[Detection]:
"""Run every prompt against one image; prompt index becomes the class id.""" """Run every prompt against one image; prompt index becomes the class id."""
self.processor.confidence_threshold = threshold processor = Sam3Processor(self.model, device=self.device)
processor.confidence_threshold = threshold
detections: List[Detection] = [] detections: List[Detection] = []
with torch.autocast(self.device, dtype=self.autocast_dtype): with torch.autocast(self.device, dtype=self.autocast_dtype):
state = self.processor.set_image(image) state = processor.set_image(image)
for class_id, prompt in enumerate(prompts): for class_id, prompt in enumerate(prompts):
output = self.processor.set_text_prompt(prompt=prompt, state=state) output = processor.set_text_prompt(prompt=prompt, state=state)
masks, boxes, scores = output["masks"], output["boxes"], output["scores"] masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
if masks.shape[0] == 0: if masks.shape[0] == 0:
continue continue
@@ -95,11 +98,12 @@ class Sam3Engine:
) )
) )
del state del state
if self.device == "cuda":
torch.cuda.empty_cache()
return detections return detections
# ---- interactive / exemplar prompting ------------------------------ # ---- interactive / exemplar prompting ------------------------------
def open_state(self, image: Image.Image): def open_state(self, image: Image.Image):
+18
View File
@@ -0,0 +1,18 @@
import json
from urllib import request
data = json.dumps({
"frame_id": 26, # Need a valid frame_id! Let's get one from the db
"engine": "sam3",
"threshold": 0.35,
"iou_threshold": 0.8,
"min_box_frac": 0.0,
"target_class_names": ["sack"]
}).encode('utf-8')
req = request.Request('http://localhost:8000/api/batches/5/preview', data=data, headers={'Content-Type': 'application/json'})
try:
with request.urlopen(req) as response:
print(response.read().decode('utf-8'))
except Exception as e:
print("Error:", getattr(e, 'read', lambda: str(e))())
+21
View File
@@ -0,0 +1,21 @@
import json
from urllib import request, error
data = json.dumps({
"frame_id": 1,
"engine": "base_model",
"threshold": 0.35,
"iou_threshold": 0.8,
"min_box_frac": 0.0,
"target_class_names": []
}).encode('utf-8')
req = request.Request('http://127.0.0.1:8000/api/batches/1/preview', data=data, headers={'Content-Type': 'application/json'})
try:
with request.urlopen(req) as response:
print(response.read().decode('utf-8'))
except error.HTTPError as e:
print(f"HTTPError: {e.code}")
print(e.read().decode('utf-8'))
except Exception as e:
print(f"Error: {e}")
+98 -10
View File
@@ -12,7 +12,8 @@ import shutil
import time import time
from typing import Optional from typing import Optional
from backend import config, dataset, db, evaluate, hardware, jobs, projects from backend import (augment, base_dataset, config, dataset, datasets, db, evaluate,
hardware, jobs, projects)
PRETRAINED = {"bbox": "yolo11n.pt", "polygon": "yolo11n-seg.pt"} PRETRAINED = {"bbox": "yolo11n.pt", "polygon": "yolo11n-seg.pt"}
@@ -25,20 +26,84 @@ def models_dir(project_slug: str) -> str:
return os.path.join(config.project_dir(project_slug), "models") return os.path.join(config.project_dir(project_slug), "models")
def start(project_id: int, epochs: int = 50, overrides: Optional[dict] = None, batch_ids: Optional[list] = None, class_ids: Optional[list] = None) -> dict: #: Rough bytes one decoded 640px training image occupies in the RAM cache,
#: measured against the run that OOMed: 10.2 GB across 15,774 images.
_BYTES_PER_CACHED_IMAGE = 700_000
def _cache_mode(train_images: int, job) -> object:
"""Pick Ultralytics' `cache` argument for the memory this host actually has.
RAM caching is a large speedup and worth taking when it fits. It is only
taken with three times the headroom the raw estimate asks for: the run that
died had ~10 GB of cache on a 30 GB host and still lost, because the
dataloader workers fork after the cache is built and their copy-on-write
pages are what turn "just fits" into a kill. Two-times headroom would have
green-lit exactly the run that failed.
"""
needed = train_images * _BYTES_PER_CACHED_IMAGE
try:
import psutil
available = psutil.virtual_memory().available
except Exception:
available = 0
if available == 0:
job.log(f"Image cache: disk (cannot read free memory; {train_images} images)")
return "disk"
if needed * 3 <= available:
job.log(f"Image cache: RAM (~{needed / 1e9:.1f} GB of "
f"{available / 1e9:.1f} GB free)")
return "ram"
job.log(f"Image cache: disk (RAM cache would need ~{needed / 1e9:.1f} GB, "
f"only {available / 1e9:.1f} GB free)")
return "disk"
def start(project_id: int, epochs: int = 50, overrides: Optional[dict] = None,
batch_ids: Optional[list] = None, class_ids: Optional[list] = None,
dataset_ids: Optional[list] = None,
base_dataset_ids: Optional[list] = None) -> dict:
project = projects.get(project_id) project = projects.get(project_id)
if project is None: if project is None:
raise TrainingError("No such project") raise TrainingError("No such project")
counts = dataset.summary(project_id)["splits"]
bases = list(base_dataset_ids or [])
# No dataset picked means "everything this project has", which is what the
# single-dataset app always did. Base datasets are opt-in, so an empty pick
# never silently drags them in.
chosen = list(dataset_ids or [])
if not chosen and not bases:
chosen = [item["id"] for item in datasets.listing(project_id)]
if not chosen and not bases:
raise TrainingError(
"This project has no dataset yet — approve and merge a batch before training"
)
items = datasets.combined_items(project_id, chosen) if chosen else []
base_train = sum(base_dataset.get(bid)["image_count"] for bid in bases
if base_dataset.get(bid) is not None)
counts = {"train": sum(1 for i in items if i["split"] == "train") + base_train,
"val": sum(1 for i in items if i["split"] == "val")}
if counts["train"] == 0: if counts["train"] == 0:
raise TrainingError( raise TrainingError(
"The master dataset is empty — approve and merge a batch before training" "The chosen dataset(s) hold no training images — merge a batch before training"
)
# A base dataset is train-only, so it can never supply the val split that
# REQ-063's base-vs-new comparison is measured on.
if counts["val"] == 0:
raise TrainingError(
"Nothing to validate on — a base dataset only contributes training images, "
"so pick at least one of this project's own datasets too"
) )
settings = hardware.resolve(overrides, epochs) settings = hardware.resolve(overrides, epochs)
job = jobs.create( job = jobs.create(
"train", "train",
params={"project_id": project_id, "settings": settings, "batch_ids": batch_ids, "class_ids": class_ids}, params={"project_id": project_id, "settings": settings, "batch_ids": batch_ids,
"class_ids": class_ids, "dataset_ids": chosen,
"base_dataset_ids": bases},
project_id=project_id, project_id=project_id,
message=f"{counts['train']} train / {counts['val']} val", message=f"{counts['train']} train / {counts['val']} val",
) )
@@ -103,7 +168,10 @@ def _run_train(job) -> None:
settings = job.params["settings"] settings = job.params["settings"]
batch_ids = job.params.get("batch_ids") batch_ids = job.params.get("batch_ids")
class_ids = job.params.get("class_ids") class_ids = job.params.get("class_ids")
data_yaml = dataset.write_data_yaml(project, batch_ids=batch_ids, selected_class_ids=class_ids) dataset_ids = job.params.get("dataset_ids") or []
data_yaml = dataset.write_data_yaml(project, dataset_ids, batch_ids=batch_ids,
selected_class_ids=class_ids, require_val=True,
base_dataset_ids=job.params.get("base_dataset_ids") or [])
# SAM3 and a training run must not hold VRAM at the same time (REQ-065). # SAM3 and a training run must not hold VRAM at the same time (REQ-065).
from backend.sam3_engine import release_engine from backend.sam3_engine import release_engine
@@ -130,6 +198,8 @@ def _run_train(job) -> None:
epoch = getattr(trainer, 'epoch', 0) + 1 epoch = getattr(trainer, 'epoch', 0) + 1
total = getattr(trainer, 'epochs', settings["epochs"]) total = getattr(trainer, 'epochs', settings["epochs"])
job.progress(epoch, total, f"epoch {epoch}/{total}") job.progress(epoch, total, f"epoch {epoch}/{total}")
if job.cancelled:
trainer.stop_training = True
model.add_callback("on_fit_epoch_end", on_epoch) model.add_callback("on_fit_epoch_end", on_epoch)
job.progress(0, settings["epochs"]) job.progress(0, settings["epochs"])
@@ -138,6 +208,22 @@ def _run_train(job) -> None:
if torch.cuda.is_available(): if torch.cuda.is_available():
torch.backends.cudnn.benchmark = True torch.backends.cudnn.benchmark = True
# REQ-110: explicit rather than inherited. An untouched project gets MEDIUM,
# which is Ultralytics' own default set, so this changes nothing by itself.
augmentation = augment.get(project["id"])
job.log(f"Augmentation: {augmentation['preset']} — "
+ ", ".join(f"{k}={v:g}" for k, v in sorted(augmentation["settings"].items())))
# `cache="ram"` used to be hardcoded. It holds the whole training set in
# memory, which was invisible at a few hundred images and fatal at fifteen
# thousand: the run below died mid-epoch with no traceback, killed by the
# host OOM killer, because 10 GB of cache plus per-worker copies did not fit
# in 30 GB. RAM caching is now earned, not assumed (CLAUDE.md §9).
train_list = os.path.join(os.path.dirname(data_yaml), "selected_train.txt")
with open(train_list, encoding="utf-8") as handle:
train_images = sum(1 for line in handle if line.strip())
cache_mode = _cache_mode(train_images, job)
keep_run_dir = False keep_run_dir = False
try: try:
model.train( model.train(
@@ -145,9 +231,10 @@ def _run_train(job) -> None:
epochs=settings["epochs"], epochs=settings["epochs"],
imgsz=settings["imgsz"], imgsz=settings["imgsz"],
batch=settings["batch"], batch=settings["batch"],
**augmentation["settings"],
device=settings["device"], device=settings["device"],
workers=settings.get("workers", 8), workers=settings.get("workers", 8),
cache="ram", cache=cache_mode,
project=os.path.join(out_dir, "runs"), project=os.path.join(out_dir, "runs"),
name="train", name="train",
exist_ok=True, exist_ok=True,
@@ -175,10 +262,11 @@ def _run_train(job) -> None:
cur.execute( cur.execute(
"""INSERT INTO model_versions (project_id, version, weights_path, """INSERT INTO model_versions (project_id, version, weights_path,
parent_model_path, metrics, base_metrics, parent_model_path, metrics, base_metrics,
created_at) created_at, augment)
VALUES (?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(project["id"], version, weights, project["base_model_path"], (project["id"], version, weights, project["base_model_path"],
json.dumps(comparison["new"]), json.dumps(comparison["base"]), time.time()), json.dumps(comparison["new"]), json.dumps(comparison["base"]), time.time(),
json.dumps(augmentation["settings"])),
) )
new = comparison["new"] new = comparison["new"]
+531
View File
@@ -0,0 +1,531 @@
"""Triage: deciding what each SAM3 shape is actually worth (REQ-100…108).
A shape is never rewritten. Its verdict is *resolved* every time it is needed:
manual override > first matching rule > keep
so `annotations.class_id` keeps whatever SAM3 said, and any rule can be re-cut
later against the original output. That is the whole reason rules are evaluated
at training time rather than baked in at merge (REQ-102).
A verdict is one of:
keep the shape trains as its own class
reclass -> class_id the shape trains as a different class (REQ-105)
ignore the box is dropped; its image still trains (REQ-104)
`ignore` drops the box rather than the image because these frames are dense —
around 44 shapes each. Excluding the whole image was measured against a real
batch and cost 96% of it (1,882 of 1,950 frames) to remove 10% of the boxes.
Dropping four boxes out of forty-four leaves the image overwhelmingly correct;
dropping the image leaves nothing to train on.
The exception is a frame that loses *every* shape it had: an empty label file
says "there is nothing here", and for a frame that was full of sacks that is a
lie the model will learn. Those images are excluded.
"""
import hashlib
import json
import time
from typing import List, Optional
from backend import db, review
class TriageError(Exception):
pass
# ---- rules ---------------------------------------------------------------
def rules(project_id: int, stage: str = "dataprep") -> List[dict]:
with db.cursor() as cur:
cur.execute(
"""SELECT * FROM triage_rules WHERE project_id = ? AND stage = ?
ORDER BY position""",
(project_id, stage),
)
return [_rule_dict(row) for row in cur.fetchall()]
def _rule_dict(row) -> dict:
return {
"id": row["id"],
"stage": row["stage"],
"position": row["position"],
"name": row["name"],
"predicate": json.loads(row["predicate"]),
"action": row["action"],
"target_class": row["target_class"],
}
RANGE_FIELDS = ("score", "area_pct", "aspect")
def _validate_predicate(name, predicate: dict) -> None:
"""Reject a malformed rule here rather than inside a merge job.
A bad predicate used to be stored happily and only raise when the resolver
reached it — by which time the batch had been flipped to `approved` and the
user was looking at a failed job quoting a Python unpacking error.
"""
label = name or "this rule"
for field, bounds in predicate.items():
if field == "class_id":
if bounds is not None and not isinstance(bounds, int):
raise TriageError(f"{label}: class_id must be a class number")
continue
if field not in RANGE_FIELDS:
raise TriageError(
f"{label}: '{field}' is not something a rule can test "
f"(use {', '.join(RANGE_FIELDS)} or class_id)")
if bounds is None:
continue
if not isinstance(bounds, (list, tuple)) or len(bounds) != 2:
raise TriageError(f"{label}: '{field}' needs a [minimum, maximum] pair")
low, high = bounds
for edge in (low, high):
if edge is not None and not isinstance(edge, (int, float)):
raise TriageError(f"{label}: '{field}' bounds must be numbers or blank")
if low is not None and high is not None and low > high:
raise TriageError(
f"{label}: '{field}' minimum {low} is above its maximum {high}, "
"so the rule can never match")
def replace_rules(project_id: int, incoming: List[dict], stage: str = "dataprep") -> List[dict]:
"""Store the whole ordered list — the UI edits it as one thing (REQ-100)."""
for item in incoming:
if item.get("action") not in ("keep", "ignore", "reclass"):
raise TriageError(f"Unknown action: {item.get('action')}")
if item["action"] == "reclass" and item.get("target_class") is None:
raise TriageError(f"Rule '{item.get('name')}' reclassifies but names no target class")
_validate_predicate(item.get("name"), item.get("predicate") or {})
with db.cursor() as cur:
cur.execute("DELETE FROM triage_rules WHERE project_id = ? AND stage = ?",
(project_id, stage))
for position, item in enumerate(incoming):
cur.execute(
"""INSERT INTO triage_rules (project_id, stage, position, name, predicate,
action, target_class, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(project_id, stage, position, item.get("name") or f"rule {position + 1}",
json.dumps(item.get("predicate") or {}), item["action"],
item.get("target_class"), time.time()),
)
return rules(project_id, stage)
# ---- overrides -----------------------------------------------------------
def set_overrides(annotation_ids: List[int], verdict: str,
target_class: Optional[int] = None) -> int:
"""A hand decision outranks every rule, now and after any rule edit (REQ-103)."""
if verdict not in ("keep", "ignore", "reclass"):
raise TriageError(f"Unknown verdict: {verdict}")
if verdict == "reclass" and target_class is None:
raise TriageError("A reclass override needs a target class")
now = time.time()
with db.cursor() as cur:
cur.executemany(
"""INSERT INTO annotation_overrides (annotation_id, verdict, target_class, decided_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(annotation_id) DO UPDATE SET
verdict = excluded.verdict,
target_class = excluded.target_class,
decided_at = excluded.decided_at""",
[(aid, verdict, target_class, now) for aid in annotation_ids],
)
return cur.rowcount
def clear_overrides(annotation_ids: List[int]) -> int:
if not annotation_ids:
return 0
with db.cursor() as cur:
placeholders = ",".join("?" for _ in annotation_ids)
cur.execute(f"DELETE FROM annotation_overrides WHERE annotation_id IN ({placeholders})",
annotation_ids)
return cur.rowcount
# ---- resolution ----------------------------------------------------------
def metrics(geometry: dict) -> dict:
"""The signals a rule can test, all derived from the box."""
x0, y0, x1, y1 = review.to_box(geometry)
width = max(0.0, x1 - x0)
height = max(0.0, y1 - y0)
return {
"area_pct": round(width * height * 100.0, 4),
"aspect": round(width / height, 4) if height > 0 else 0.0,
}
def _in_range(value: float, bounds) -> bool:
low, high = bounds
return (low is None or value >= low) and (high is None or value <= high)
def _matches(predicate: dict, shape: dict) -> bool:
if "class_id" in predicate and predicate["class_id"] is not None:
if shape["class_id"] != predicate["class_id"]:
return False
for field in ("score", "area_pct", "aspect"):
if predicate.get(field) is not None and not _in_range(shape[field], predicate[field]):
return False
return True
class Resolver:
"""Holds a project's rules and overrides so a whole dataset can be resolved
without re-reading them per shape."""
def __init__(self, project_id: int, stage: str = "dataprep",
frozen: Optional[List[dict]] = None):
# `frozen` is a dataset's snapshot (REQ-132): the merge that cut it runs
# under those rules, not under whatever the project says today.
self.rules = list(frozen) if frozen is not None else rules(project_id, stage)
with db.cursor() as cur:
cur.execute("SELECT annotation_id, verdict, target_class FROM annotation_overrides")
self.overrides = {row[0]: (row[1], row[2]) for row in cur.fetchall()}
def verdict(self, shape: dict) -> dict:
"""Resolve one shape. `shape` needs id, class_id, score, area_pct, aspect."""
override = self.overrides.get(shape["id"])
if override is not None:
verdict, target = override
return {"verdict": verdict, "target_class": target, "source": "manual"}
for rule in self.rules:
if _matches(rule["predicate"], shape):
return {"verdict": rule["action"], "target_class": rule["target_class"],
"source": rule["name"]}
return {"verdict": "keep", "target_class": None, "source": "default"}
def resolve_shapes(self, annotations: list) -> Optional[list]:
"""Apply verdicts to one frame's annotations.
Returns the surviving annotations with their effective class, or None
when the frame must not train at all — which now happens only if every
shape was dropped.
"""
kept = []
for item in annotations:
shape = {"id": item["id"], "class_id": item["class_id"],
"score": float(item.get("score") or 1.0),
**metrics(item["geometry"])}
effective = self.effective_class(shape)
if effective is None:
continue
kept.append({**item, "class_id": effective})
if annotations and not kept:
return None
return kept
def effective_class(self, shape: dict) -> Optional[int]:
"""The class this shape trains as, or None when the box is dropped."""
resolved = self.verdict(shape)
if resolved["verdict"] == "ignore":
return None
if resolved["verdict"] == "reclass":
return resolved["target_class"]
return shape["class_id"]
def version(self) -> str:
"""A short hash of what this resolver would do (REQ-107).
Two runs with the same version measured the same thing; two runs with
different versions did not, because a rule edit can change which images
are in the val set.
"""
payload = json.dumps(
{"rules": self.rules, "overrides": sorted(self.overrides.items())},
sort_keys=True, default=str,
)
return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:12]
def shapes_for_frames(frame_ids: List[int]) -> List[dict]:
"""Every annotation on these frames, with its signals and resolved verdict."""
if not frame_ids:
return []
with db.cursor() as cur:
placeholders = ",".join("?" for _ in frame_ids)
cur.execute(
f"""SELECT a.id, a.frame_id, a.class_id, a.score, a.source, a.geometry
FROM annotations a WHERE a.frame_id IN ({placeholders})""",
frame_ids,
)
rows = cur.fetchall()
shapes = []
for row in rows:
geometry = json.loads(row["geometry"])
shape = {
"id": row["id"],
"frame_id": row["frame_id"],
"class_id": row["class_id"],
"score": round(float(row["score"]), 4),
"origin": row["source"],
"box": review.to_box(geometry),
**metrics(geometry),
}
shapes.append(shape)
return shapes
SCATTER_POINTS = 4000
"""How many dots the scatter gets. A real batch runs to ~85k shapes; every one of
them as an SVG circle locks the browser, and a boundary between two clusters is
just as visible in a few thousand points. The verdict tallies are still counted
over every shape, so the numbers are never a sample."""
def as_ids(batch_ids) -> List[int]:
"""One batch or many — Data Prep now tunes a whole selection at once (REQ-130)."""
if isinstance(batch_ids, int):
return [batch_ids]
if isinstance(batch_ids, str):
return [int(part) for part in batch_ids.split(",") if part.strip().lstrip("-").isdigit()]
return list(batch_ids)
def _resolved_shapes(batch_ids):
from backend import batches
ids = as_ids(batch_ids)
found = [batches.get(bid) for bid in ids]
if not ids or any(batch is None for batch in found):
raise TriageError("No such batch")
if len({batch["project_id"] for batch in found}) > 1:
raise TriageError("Those batches are not all in the same project")
batch = found[0]
with db.cursor() as cur:
placeholders = ",".join("?" for _ in ids)
cur.execute(
f"SELECT id FROM frames WHERE batch_id IN ({placeholders}) ORDER BY batch_id, idx",
ids,
)
frame_ids = [row[0] for row in cur.fetchall()]
resolver = Resolver(batch["project_id"])
shapes = shapes_for_frames(frame_ids)
for shape in shapes:
shape.update(resolver.verdict(shape))
return batch, frame_ids, shapes, resolver
def batch_summary(batch_ids) -> dict:
"""Verdict tallies over the whole selection, plus a sample to plot (REQ-106)."""
ids = as_ids(batch_ids)
batch, frame_ids, shapes, resolver = _resolved_shapes(ids)
counts = {"keep": 0, "ignore": 0, "reclass": 0, "manual": 0}
per_frame = {}
for shape in shapes:
counts[shape["verdict"]] += 1
if shape["source"] == "manual":
counts["manual"] += 1
total, dropped = per_frame.get(shape["frame_id"], (0, 0))
per_frame[shape["frame_id"]] = (total + 1, dropped + (shape["verdict"] == "ignore"))
# Only a frame that loses everything is held back; the rest keep training
# with their surviving boxes.
ignored_frames = {fid for fid, (total, dropped) in per_frame.items() if total == dropped}
# An even stride rather than a random draw: the sample is stable across
# reloads, so points do not jump around while the user is reading the plot.
stride = max(1, len(shapes) // SCATTER_POINTS)
sample = [
{k: shape[k] for k in ("id", "class_id", "score", "area_pct", "aspect", "verdict", "source")}
for shape in shapes[::stride][:SCATTER_POINTS]
]
return {
"batch_ids": ids,
"project_id": batch["project_id"],
"status": batch["status"],
"merged": batch["status"] == "merged",
"frame_count": len(frame_ids),
"total_shapes": len(shapes),
"counts": counts,
# What merging this batch would do right now (REQ-104).
"frames_held_back": len(ignored_frames),
"frames_would_merge": len(frame_ids) - len(ignored_frames),
"sample": sample,
"sampled": len(sample) < len(shapes),
"rule_version": resolver.version(),
}
def batch_page(batch_ids, sort: str = "score", offset: int = 0, limit: int = 120) -> dict:
"""One page of shapes for the crop grid, sorted server-side so the client
never holds the whole batch."""
if sort not in ("score", "area_pct"):
raise TriageError(f"Cannot sort by {sort}")
_, _, shapes, _ = _resolved_shapes(batch_ids)
shapes.sort(key=lambda shape: shape[sort])
page = shapes[offset:offset + limit]
for shape in page:
shape.pop("box", None)
return {"total": len(shapes), "offset": offset, "limit": limit, "shapes": page}
def _percentile(values: list, fraction: float) -> float:
if not values:
return 0.0
return values[min(len(values) - 1, int(len(values) * fraction))]
def suggest(batch_ids) -> dict:
"""Presets with thresholds read off this batch's own distribution.
Asking someone to invent "score below 0.45" from nothing is guesswork. The
same question is easy when the number comes from their data and the effect
is stated: "the weakest 10% of detections — 8,552 shapes".
"""
_, frame_ids, shapes, _ = _resolved_shapes(batch_ids)
if not shapes:
return {"presets": [], "stats": {}}
scores = sorted(shape["score"] for shape in shapes)
areas = sorted(shape["area_pct"] for shape in shapes)
total = len(shapes)
def impact(predicate: dict) -> dict:
matched = [s for s in shapes if _matches(predicate, s)]
frames = {s["frame_id"] for s in matched}
return {"shapes": len(matched), "frames": len(frames)}
presets = []
weak = round(_percentile(scores, 0.10), 3)
presets.append({
"key": "drop-weakest",
"title": "Ignore the weakest detections",
"blurb": f"SAM3 scored these below {weak} — the bottom 10% of this batch.",
"rule": {"name": "low confidence", "predicate": {"score": [None, weak]}, "action": "ignore"},
"impact": impact({"score": [None, weak]}),
})
specks = round(_percentile(areas, 0.05), 3)
presets.append({
"key": "drop-specks",
"title": "Ignore tiny specks",
"blurb": f"Boxes smaller than {specks}% of the frame — usually noise, not objects.",
"rule": {"name": "specks", "predicate": {"area_pct": [None, specks]}, "action": "ignore"},
"impact": impact({"area_pct": [None, specks]}),
})
median_area = round(_percentile(areas, 0.50), 3)
presets.append({
"key": "split-by-size",
"title": "Split by size into a second class",
"blurb": f"Everything under {median_area}% area (half this batch) becomes another class — "
"pick which one. Size tracks distance from the camera as much as object type, "
"so check the crops before trusting it.",
"rule": {"name": "small ones", "predicate": {"area_pct": [None, median_area]},
"action": "reclass", "target_class": None},
"impact": impact({"area_pct": [None, median_area]}),
"needs_target": True,
})
tall = round(_percentile(sorted(s["aspect"] for s in shapes), 0.15), 3)
presets.append({
"key": "odd-shapes",
"title": "Ignore oddly-shaped boxes",
"blurb": f"Aspect ratio under {tall} — long thin slivers, usually a bad mask.",
"rule": {"name": "slivers", "predicate": {"aspect": [None, tall]}, "action": "ignore"},
"impact": impact({"aspect": [None, tall]}),
})
return {
"presets": presets,
"stats": {
"total_shapes": total,
"total_frames": len(frame_ids),
"score": {"p05": round(_percentile(scores, 0.05), 3),
"p50": round(_percentile(scores, 0.50), 3),
"p95": round(_percentile(scores, 0.95), 3)},
"area_pct": {"p05": round(_percentile(areas, 0.05), 3),
"p50": round(_percentile(areas, 0.50), 3),
"p95": round(_percentile(areas, 0.95), 3)},
},
}
def simulate(batch_ids, candidate_rules: List[dict]) -> dict:
"""What these rules would do, without saving them.
Editing a threshold and seeing the number move is the whole difference
between tuning a filter and guessing at one.
"""
_, frame_ids, shapes, _ = _resolved_shapes(batch_ids)
with db.cursor() as cur:
cur.execute("SELECT annotation_id, verdict, target_class FROM annotation_overrides")
manual = {row[0]: (row[1], row[2]) for row in cur.fetchall()}
counts = {"keep": 0, "ignore": 0, "reclass": 0}
per_rule = [0] * len(candidate_rules)
per_frame = {}
for shape in shapes:
if shape["id"] in manual:
verdict = manual[shape["id"]][0]
else:
verdict = "keep"
for index, rule in enumerate(candidate_rules):
if _matches(rule.get("predicate") or {}, shape):
verdict = rule["action"]
per_rule[index] += 1
break
counts[verdict] += 1
total, dropped = per_frame.get(shape["frame_id"], (0, 0))
per_frame[shape["frame_id"]] = (total + 1, dropped + (verdict == "ignore"))
ignored_frames = {fid for fid, (total, dropped) in per_frame.items() if total == dropped}
return {
"total_shapes": len(shapes),
"counts": counts,
"per_rule": per_rule,
"frames_held_back": len(ignored_frames),
"frames_would_merge": len(frame_ids) - len(ignored_frames),
}
def preview(project_id: int) -> dict:
"""What the current rules would do to the whole merged dataset."""
with db.cursor() as cur:
cur.execute(
"SELECT DISTINCT frame_id FROM dataset_items WHERE project_id = ?", (project_id,))
frame_ids = [row[0] for row in cur.fetchall()]
resolver = Resolver(project_id)
shapes = shapes_for_frames(frame_ids)
counts = {"keep": 0, "ignore": 0, "reclass": 0}
per_class: dict = {}
per_frame = {}
for shape in shapes:
resolved = resolver.verdict(shape)
counts[resolved["verdict"]] += 1
total, dropped = per_frame.get(shape["frame_id"], (0, 0))
per_frame[shape["frame_id"]] = (total + 1, dropped + (resolved["verdict"] == "ignore"))
if resolved["verdict"] == "ignore":
continue
effective = resolver.effective_class(shape)
per_class[effective] = per_class.get(effective, 0) + 1
excluded_images = {fid for fid, (total, dropped) in per_frame.items() if total == dropped}
return {
"total_shapes": len(shapes),
"total_images": len(frame_ids),
**counts,
"excluded_images": len(excluded_images),
"trainable_images": len(frame_ids) - len(excluded_images),
"per_class": per_class,
"rule_version": resolver.version(),
}
+79 -16
View File
@@ -9,6 +9,7 @@ import json
import os import os
import shutil import shutil
import subprocess import subprocess
import tempfile
from typing import Callable, List, Optional from typing import Callable, List, Optional
VIDEO_EXTS = (".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v") VIDEO_EXTS = (".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v")
@@ -44,7 +45,7 @@ def probe(path: str) -> dict:
result = subprocess.run( result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0", ["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,avg_frame_rate", "-show_entries", "stream=width,height,avg_frame_rate,codec_name",
"-show_entries", "format=duration", "-show_entries", "format=duration",
"-of", "json", path], "-of", "json", path],
capture_output=True, text=True, capture_output=True, text=True,
@@ -59,12 +60,14 @@ def probe(path: str) -> dict:
"width": int(streams[0].get("width") or 0), "width": int(streams[0].get("width") or 0),
"height": int(streams[0].get("height") or 0), "height": int(streams[0].get("height") or 0),
"fps": _parse_fps(streams[0].get("avg_frame_rate")), "fps": _parse_fps(streams[0].get("avg_frame_rate")),
"codec_name": str(streams[0].get("codec_name") or ""),
"size": stat.st_size, "size": stat.st_size,
} }
_probe_cache[key] = info _probe_cache[key] = info
return info return info
def _parse_fps(value: Optional[str]) -> float: def _parse_fps(value: Optional[str]) -> float:
# ffprobe reports "30000/1001", and "0/0" for streams it cannot work out. # ffprobe reports "30000/1001", and "0/0" for streams it cannot work out.
if not value or "/" not in value: if not value or "/" not in value:
@@ -111,23 +114,30 @@ def extract_frames(
"-vf", f"fps={fps}", "-q:v", "2", "-vf", f"fps={fps}", "-q:v", "2",
os.path.join(out_dir, "%06d.jpg"), os.path.join(out_dir, "%06d.jpg"),
] ]
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) # stderr goes to a file, not a pipe: a damaged recording emits decode errors
# without end, and nothing reads the pipe until ffmpeg exits — so a 64KB
# buffer would fill and block ffmpeg forever, hanging the whole job queue.
with tempfile.TemporaryFile("w+") as stderr_log:
process = subprocess.Popen(command, stdout=subprocess.DEVNULL,
stderr=stderr_log, text=True)
expected = frame_count(start_sec, end_sec, fps) expected = frame_count(start_sec, end_sec, fps)
while process.poll() is None: while process.poll() is None:
if should_stop is not None and should_stop(): if should_stop is not None and should_stop():
process.terminate() process.terminate()
process.wait(timeout=10) process.wait(timeout=10)
raise VideoError("cancelled") raise VideoError("cancelled")
if on_progress is not None: if on_progress is not None:
on_progress(min(_written(out_dir), expected)) on_progress(min(_written(out_dir), expected))
try: try:
process.wait(timeout=1) process.wait(timeout=1)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pass pass
if process.returncode != 0: if process.returncode != 0:
raise VideoError((process.stderr.read() or "ffmpeg failed").strip().splitlines()[-1]) stderr_log.seek(0)
lines = stderr_log.read().strip().splitlines()
raise VideoError(lines[-1] if lines else "ffmpeg failed")
return sorted(name for name in os.listdir(out_dir) if name.endswith(".jpg")) return sorted(name for name in os.listdir(out_dir) if name.endswith(".jpg"))
@@ -137,3 +147,56 @@ def _written(out_dir: str) -> int:
return sum(1 for name in os.listdir(out_dir) if name.endswith(".jpg")) return sum(1 for name in os.listdir(out_dir) if name.endswith(".jpg"))
except OSError: except OSError:
return 0 return 0
def convert_to_h264(
input_path: str,
output_path: Optional[str] = None,
crf: int = 18,
preset: str = "medium",
) -> str:
"""Transcode input video to browser-supported H.264 / AAC MP4.
Uses CRF 18 for visually lossless compression (no perceptible visual loss,
significantly reduced file size), yuv420p for universal browser playback,
and +faststart for instant web streaming.
"""
if not available():
raise VideoError("ffmpeg is not installed in this environment")
if not os.path.isfile(input_path):
raise VideoError(f"Input video file not found: {input_path}")
if not output_path:
base, _ = os.path.splitext(input_path)
output_path = f"{base}_h264.mp4"
temp_output_path = output_path.replace(".mp4", "_tmp.mp4") if output_path.endswith(".mp4") else output_path + ".tmp.mp4"
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
command = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", input_path,
"-c:v", "libx264",
"-crf", str(crf),
"-preset", preset,
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
"-c:a", "aac",
"-b:a", "128k",
"-f", "mp4",
temp_output_path,
]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
if os.path.isfile(temp_output_path):
os.remove(temp_output_path)
err_msg = result.stderr.strip().splitlines()[-1] if result.stderr else "ffmpeg conversion failed"
raise VideoError(err_msg)
os.replace(temp_output_path, output_path)
return output_path
+249
View File
@@ -0,0 +1,249 @@
"""Read the burned-in timestamp from a recording's overlay (REQ-160).
The archive's folder names do not say when a recording was made. Neither does
its mtime: `2026-08-13/batch003.mp4` is 38 minutes long but the next file's
mtime is 13 minutes later, because those are file *copy* times, not recording
times. The only trustworthy clock is the one the camera burns into the top-right
of every frame, in a fixed font at a fixed position:
2026-08-13 08:27:28
So this reads it. Not a general OCR — 12 glyphs (0-9, '-', ':') in one typeface
at one size, which a nearest-template match handles exactly and without adding
an OCR dependency to the image.
Isolating the text uses the one thing that distinguishes it from the wall and
the sacks behind it: it is bright *and* outlined in black. A plain brightness
threshold picks up a lit wall and merges glyphs together; requiring a dark pixel
within a few px of every bright one does not.
One caveat this module deliberately does not paper over: video time is not real
time. Measured on batch003, 1,940 seconds of video covers 783 seconds of wall
clock — the camera records at roughly 10 fps and stores at 25. So a file's
duration says nothing about when it ended, and only the *start* timestamp is
trusted here.
"""
import os
from typing import Optional
# The overlay's box in a 1280x720 frame. Glyphs are found inside it by column
# runs rather than at fixed offsets: '1', '-' and ':' are narrower than a digit,
# so a slot grid measured off one timestamp clips the wider glyphs of the next.
# Where the overlay sits in a 1280x720 frame. Nearly every recording puts it in
# the same rows; the one 1080p file in the archive lands ~10 px lower once
# scaled down, so a second band is tried for it rather than detecting the band
# per frame — detection was flakier than the two fixed guesses it replaced.
CANDIDATE_BANDS = ((28, 60), (38, 74), (20, 54))
CROP_LEFT, CROP_RIGHT = 950, 1268
SCALE = 3
GLYPH_SIZE = (24, 32)
GLYPH_COUNT = 18 # "YYYY-MM-DDHH:MM:SS" without the space
MIN_RUN_WIDTH = 4
TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "assets", "clock_glyphs.npz")
_templates = None
_keys = None
class ClockError(Exception):
pass
def _load():
global _templates, _keys
if _templates is None:
import numpy as np
data = np.load(TEMPLATE_PATH)
_keys = list(data.keys())
_templates = np.stack([data[k] for k in _keys])
return _templates, _keys
def _mask(frame, band=CANDIDATE_BANDS[0]):
"""The overlay's glyphs in one candidate band, isolated from the scene.
Bright alone is not enough — a lit wall clears any brightness threshold and
merges the glyphs into one blob. What separates the text is that every
stroke is outlined in black, so a bright pixel only counts when a dark one
sits within a few pixels of it.
"""
import cv2
import numpy as np
if frame.shape[0] != 720 or frame.shape[1] != 1280:
frame = cv2.resize(frame, (1280, 720))
grey = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
roi = grey[band[0]:band[1], CROP_LEFT:CROP_RIGHT]
roi = cv2.resize(roi, None, fx=SCALE, fy=SCALE, interpolation=cv2.INTER_CUBIC)
bright = (roi > 195).astype(np.uint8)
dark = (roi < 90).astype(np.uint8)
near_dark = cv2.dilate(dark, np.ones((13, 13), np.uint8))
return ((bright & near_dark) * 255).astype(np.uint8)
def _glyphs(binary):
"""Cut the strip into one tight image per glyph, left to right."""
import cv2
import numpy as np
columns = binary.sum(axis=0) // 255
runs, start = [], None
for index, value in enumerate(columns):
if value > 0 and start is None:
start = index
elif value == 0 and start is not None:
if index - start >= MIN_RUN_WIDTH:
runs.append((start, index))
start = None
if start is not None:
runs.append((start, len(columns)))
out = []
for left, right in runs:
column = binary[:, left:right]
rows = np.where(column.sum(axis=1) > 0)[0]
if len(rows) == 0:
continue
tight = column[rows[0]:rows[-1] + 1, :]
out.append(cv2.resize(tight, GLYPH_SIZE, interpolation=cv2.INTER_AREA).astype(np.float32))
return out
def _decode(patches) -> tuple:
import numpy as np
templates, keys = _load()
chars, worst = [], 0.0
for patch in patches:
distances = ((templates - patch) ** 2).sum(axis=(1, 2))
order = np.argsort(distances)
best, runner_up = distances[order[0]], distances[order[1]]
chars.append(keys[int(order[0])])
worst = max(worst, best / max(1.0, runner_up))
return "".join(chars), round(1.0 - min(1.0, worst), 3)
def read_frame(frame) -> tuple:
"""Decode one frame's overlay. Returns (text, confidence).
Confidence is the worst per-glyph separation across the strip — the distance
to the best template over the distance to the runner-up. A glyph that matches
its own template several times better than any other is safe; one that barely
wins is what a wrong digit looks like. Every candidate band is tried and the
best *parseable* reading wins, so a misplaced band scores itself out rather
than silently recording the wrong hour.
"""
best = ("", 0.0)
for band in CANDIDATE_BANDS:
patches = _glyphs(_mask(frame, band))
if len(patches) != GLYPH_COUNT:
continue
text, confidence = _decode(patches)
if parse(text) is not None and confidence > best[1]:
best = (text, confidence)
return best
def _shaped(text: str) -> bool:
if len(text) != 18:
return False
for got, want in zip(text, "dddd-dd-dddd:dd:dd"):
if want == "d" and not got.isdigit():
return False
if want != "d" and got != want:
return False
return True
MIN_YEAR, MAX_YEAR = 2015, 2100
def parse(text: str):
"""The decoded string as a datetime, or None if it is not a real one.
The year range matters: a single misread digit turned 2026 into 7026 on one
recording, and `strptime` accepts that happily. A timestamp outside these
bounds is a decoding failure, not a recording from the far future.
"""
import datetime
if not _shaped(text):
return None
try:
stamp = datetime.datetime.strptime(text, "%Y-%m-%d%H:%M:%S")
except ValueError:
return None
return stamp if MIN_YEAR <= stamp.year <= MAX_YEAR else None
def read_video_start(path: str, probe_seconds=(2, 8, 20, 45)) -> dict:
"""When the recording in `path` started, read off its own overlay.
Several frames are sampled rather than one. A single frame can be caught
mid-transition or behind a passing sack, and a lone unverifiable reading is
exactly the kind of thing that would silently reassign a video to the wrong
working day. A reading is accepted only when two frames agree, after
subtracting the video-time offset between them.
"""
import cv2
import datetime
capture = cv2.VideoCapture(path)
if not capture.isOpened():
raise ClockError(f"Could not open {path}")
fps = capture.get(cv2.CAP_PROP_FPS) or 25.0
readings = []
try:
for offset in probe_seconds:
capture.set(cv2.CAP_PROP_POS_FRAMES, int(offset * fps))
ok, frame = capture.read()
if not ok or frame is None:
continue
text, confidence = read_frame(frame)
stamp = parse(text)
if stamp is not None and confidence > 0.0:
readings.append({"at": offset, "stamp": stamp,
"text": text, "confidence": confidence})
finally:
capture.release()
if not readings:
return {"start": None, "confidence": 0.0, "readings": [],
"error": "no readable timestamp overlay"}
# Video time runs slower than the wall clock on these recordings, so two
# readings cannot be checked by assuming a 1:1 offset. What they must agree
# on is the ordering and a sane elapsed span.
first = readings[0]
agreed = [r for r in readings[1:]
if 0 <= (r["stamp"] - first["stamp"]).total_seconds() <= r["at"] * 2]
span = (readings[-1]["stamp"] - first["stamp"]).total_seconds()
return {
"start": first["stamp"],
"confidence": round(min(r["confidence"] for r in readings), 3),
"agreeing": len(agreed) + 1,
"readings": [{"at": r["at"], "text": r["text"]} for r in readings],
"rate": round(span / max(1, readings[-1]["at"] - first["at"]), 2) if span else None,
"error": "" if agreed else "only one frame produced a usable reading",
}
def working_day(stamp, cutoff_hour: int = 6) -> str:
"""The working day a recording belongs to (REQ-161).
A shift runs 06:00 to 06:00, so anything before the cutoff belongs to the
previous calendar day. `predict.py` has the same idea in `get_counting_date`,
with a different cutoff.
"""
import datetime
if stamp is None:
return ""
day = stamp.date()
if stamp.hour < cutoff_hour:
day = day - datetime.timedelta(days=1)
return day.isoformat()
+72
View File
@@ -0,0 +1,72 @@
# Annotation policy — sack counting
Why this document exists: the model is not the product, **the count** is. Every rule below
is derived from how `algoritma-batch/src/counting.py` turns boxes into counts. Change the
counter and this policy has to change with it.
## The one fact that drives everything
`LineCrossCounter` counts on **`y1` — the top edge of the box** (`counting.py`, "Uses y1
(top edge) of the stabilized sack bounding box"). A track is counted when it was seen ABOVE
the zone and later BELOW it.
So the top edge of every box you draw is a **measurement instrument**. Anything that moves
`y1` for a reason other than the sack moving is a counting error you baked into the dataset.
## The rule
| Situation | Annotate? | Box |
| --- | --- | --- |
| Fully visible | Yes | Visible extent |
| Partly occluded, **top edge visible** | Yes | Visible extent |
| **Top edge hidden** (head, arm, another sack over the top) | **No** | — |
| Under ~40% visible | No | — |
| Fully hidden | **Never** | — |
**If you cannot see where the sack's top edge is, do not annotate it.**
Reasoning: a head over the top of a sack drags `y1` down 50–100 px. The sack reads as lower
than it is and can trip `above → below` early — a phantom count caused by annotation, not by
the model. Leaving it unannotated costs nothing, because `cfg/tracker.yaml` sets
`track_buffer: 60` (~2.4 s at 25 fps) specifically to "survive worker occlusion". The tracker
coasts through the gap and keeps the ID.
Never annotate a sack you cannot see. Labeling invisible objects teaches the model to
hallucinate, which produces ghost tracks and overcounting.
## Consistency beats correctness
Either occlusion convention can work. **Mixing them cannot.** A model trained on "sometimes
we box occluded sacks, sometimes not" learns to fire at unstable confidence on ambiguous
evidence — which is how you get ID switches (overcount) and dropped tracks (undercount) at
the same time. Pick the table above and apply it identically, every session.
## Don't let triage delete the hard examples
SAM3's confidence correlates with occlusion: low score usually means *partly hidden*, not
*wrong*. A blanket "ignore the weakest 10%" rule therefore deletes exactly the occluded
examples and trains a model that only knows easy, fully-visible sacks. It will then fail
where workers stand — which is where the counting line is.
Use triage to remove boxes that are **wrong**:
- slivers and long thin masks (low aspect ratio)
- duplicates of the same sack
- background objects that are not sacks
- boxes so tiny they cannot be a sack at this camera distance
Do **not** use it to remove boxes that are merely **hard**. Check the crop grid before
saving any `ignore` rule — if the crops show real sacks, the threshold is too aggressive.
## "Half sack" is usually "occluded sack"
A sack that looks half-sized on screen is most often a *full* sack that is partly hidden, or
one further from the camera. Both are normal sacks. Only split a second class if the crop
grid shows a genuinely different object, and remember that box area tracks distance from the
camera at least as much as it tracks object size.
## Before trusting any of this
There is no ground truth yet. Hand-count 2–3 videos per camera and store the numbers, so a
retrained model can be measured against something real. Until then, mAP moves are not
evidence that the *count* improved — and the count is the product.
+181
View File
@@ -0,0 +1,181 @@
# Audit — reTraining, 2026-08-07
Method: 4 mapping agents over the codebase, 5 bug-hunting agents by dimension, adversarial
verification of each finding. 22 findings survived verification; 2 CRITICALs come from a
dimension whose verifiers were cut short by a usage limit and are marked *unverified* — both
were confirmed by hand afterwards.
---
## 1. How the system actually works
**Archive → batch.** `backend/library.py:90` lists the video archive and, per file, calls
`ensure_video_preview`, which spawns a bare daemon thread running an ffmpeg H.264 transcode
(`library.py:132-142`) for anything not already previewed. Trim range → `backend/batches.py`
extracts frames into `data/projects/<slug>/batches/<id>/`, numbered per batch from 1
(`batches.py:174,200-204`).
**Auto-annotate.** `backend/autolabel.py:29` starts a job; `_run_autolabel` loads SAM3
(`sam3_engine.py`) or a YOLO model, runs per frame, and writes shapes with `source='auto'`
via `review.replace_auto` / `append_auto` (`review.py:223,237`). Unknown class names in the
request are *silently added to the project* (`autolabel.py:100-106`). The SAM3
`set_image`-once-per-image invariant holds — verified in the engine loop.
**Review.** `frontend/src/pages/ReviewPage.jsx` holds all shapes in one `annotations` array.
Canvas gestures write `{local:true}` updates during the drag, then commit a PATCH on
pointerup (`AnnotationCanvas.jsx:128-141`). Human edits flip `source` to `'manual'`
(`review.py:208`). Approve/reject → `frames.review_status`.
**Merge.** `dataset.approve()` (`dataset.py:30`) sets status `approved` and queues a `merge`
job. `_run_merge` copies each approved frame into `dataset/images/{split}/<batch>__<stem>.jpg`
plus a label `.txt` (`dataset.py:380-391`), guarded per-frame by a `dataset_items` row. Split
comes from `_next_split` (`dataset.py:69-75`), which is **positional** — every Nth row by
`COUNT(*) FROM dataset_items` goes to val.
**Train & compare.** `training.py:106` calls `write_data_yaml`, which *always* calls
`sync_labels` (`dataset.py:117`) — rewriting every merged frame's label file from the live
`annotations` table. Fine-tune runs from the base model; `evaluate.compare` (`evaluate.py:39`)
runs `YOLO.val(split='val')` for base and new against the same `data.yaml`.
---
## 2. Critical bugs
### C1 — The job queue was deleted; everything now runs concurrently *(uncommitted)*
`backend/jobs.py:152`. `create()` was changed to spawn one thread per job instead of enqueuing
on the single worker. `_queue`, `_worker`, `_worker_lock` and `import queue` are now dead code,
while the module docstring still claims "one worker". Two merges, or a merge and a train, now
touch `dataset/` simultaneously. This is the root cause of C2, H1 and H2.
**Fix:** revert to the single-worker queue (`git diff backend/jobs.py`), delete the dead
`_start_job`.
### C2 — `autolabel` no longer takes `gpu_lock` *(uncommitted)*
`backend/jobs.py:31`. `GPU_JOB_TYPES` was narrowed to `("train",)` on the theory that
per-frame inference can safely run in parallel. It cannot: N parallel SAM3 jobs each hold a
full backbone in VRAM. Worse, `release_engine()` (`sam3_engine.py:229-246`) only clears the
module global — it cannot free VRAM held by a *running* autolabel job, so a training run that
starts mid-autolabel OOMs. The synchronous routes `preview_autolabel`
(`api/batches.py:163`) and `/api/sam3/playground-test` (`api/batches.py:183`) take no lock
either, while `review.assist` (`review.py:302`) correctly does.
**Fix:** put `autolabel` back in `GPU_JOB_TYPES`; wrap both synchronous routes in
`gpu_lock.acquire(timeout=...)` the way `review.assist` does.
### C3 — `custom_model_path` is a raw client-supplied filesystem path
`backend/autolabel.py:127`, `api/batches.py:37,46`. `inspect-model` writes the upload to a
`NamedTemporaryFile(delete=False)` and returns **the server path to the browser**
(`api/batches.py:123`), which the client posts back. A stale or wrong path does not error —
it falls back to a different model, so the batch is labelled by weights the user did not pick.
**Fix:** return an opaque staging id, keep the id→path map server-side, and raise
`BatchError('staged model expired, re-upload')` instead of falling back.
---
## 3. Everything else, ranked
| Sev | Area | Location | Issue | Fix |
|---|---|---|---|---|
| H | dataset | `dataset.py:117` | `sync_labels` rewrites **merged** labels from live annotations with no `review_status` filter — re-running auto-annotate on a merged batch pushes unreviewed model output into the master dataset on the next training start | Filter to `review_status='approved'`, or snapshot labels at merge time |
| H | dataset | `dataset.py:91` | Class-filtered training rewrites the *shared* master labels with remapped 0..k-1 ids, contradicting `project_classes` | Write remapped labels to a separate `labels_selected/` tree |
| H | dataset | `batches.py:239` | Deleting a batch drops DB rows (FK cascade) but leaves `dataset/images/**` + `labels/**` orphans on disk, which `data.yaml` still trains on; also shifts `_next_split` positions → train/val leakage on re-import | Unlink the batch's `dataset_items` files before deleting; make split content-derived (hash) not positional |
| H | dataset | `dataset.py:30` | Double-approve queues two concurrent merge jobs for the same batch | Reject approve when a non-terminal merge job exists; `INSERT … ON CONFLICT DO NOTHING` |
| H | dataset | `dataset.py:117` | Label + `data.yaml` writes are truncate-in-place, not atomic — a training run reads a half-written dataset | `os.replace` from temp files; snapshot the file list before training |
| H | library | `library.py:92` | Listing the archive spawns one unbounded ffmpeg `-preset medium -crf 18` thread per non-H.264 video | Route through `jobs.create` or a 1–2 worker pool; transcode lazily on playback |
| H | frontend | `ReviewSidebar.jsx:86` | Sidebar trash button deletes the **previously** selected shape (stale closure: `setSelectedId` then `removeSelected` in one tick) | Pass the id explicitly: `removeAnnotation(item.id)` |
| H | frontend | `ReviewPage.jsx:239` | Capture-phase keydown ignores modifiers — Ctrl/Cmd+A/C/X/S/T/N all fire review shortcuts and `preventDefault()` | Early-return when `ctrlKey \|\| metaKey \|\| altKey` |
| H | frontend | `ReviewPage.jsx:93` | Index advances even when the status POST fails; optimistic status never reverted | Revert in `catch`, don't advance on rejection |
| H | api | `api/batches.py:117` | `inspect-model` leaks its staged `.pt` on every success and on modal cancel | Server-owned staging dir keyed by id, deleted on job completion + startup sweep |
| H | api | `dataset.py:275` | `datasetSummary` returns **every annotation in the project** as JSON on two page loads | Return histograms only; gate raw shapes behind `?detail=shapes` |
| H | frontend | `AutoAnnotateModal.jsx:82` | Preview errors (VRAM OOM, bad model, missing frame) are all swallowed to `console.error` and render as "no detections" | Add an error surface; distinguish 4xx config from 5xx inference |
| M | dataset | `dataset.py:402` | Cancelled merge still marks the batch `merged`; approved frames are then permanently unmergeable | Only set `merged` when the loop completed |
| M | dataset | `dataset.py:140,162` | Empty val set silently falls back to the **training** images — base-vs-new mAP is then measured on trained data | Refuse to train/compare with an empty val set |
| M | dataset | `dataset.py:154` | `selected_data.yaml` writes `nc` from the full class list while `names` holds the subset | `nc: len(target_classes)` in both branches |
| M | autolabel | `autolabel.py:100` | A labelling job silently creates project classes from client-supplied names (violates REQ-003's "no drift as a side effect") | Make class creation explicit; preview should report unknown names |
| M | jobs | `jobs.py:194` | `cancel()` flushes a whole-row snapshot, racing the handler thread's own flush → progress/log resurrection | Targeted `UPDATE … WHERE status='queued'` + append-only log |
| M | jobs | `jobs.py:66` | Cancellation is process-local; `recover()` marks such jobs `failed`, never `cancelled` | Persist a `cancel_requested` column |
| M | api | `api/batches.py:163` | Synchronous GPU inference in request handlers, no lock, no VRAM check | Same `gpu_lock` pattern as `review.assist` |
| M | api | `api/batches.py:140` | Staged uploaded `.pt` never deleted after a successful job | Stage under `data/projects/<slug>/uploads/`, delete in `finally` |
| M | api | `api/projects.py:129` | `DELETE` returns 200 for a nonexistent project (same at `api/review.py:65`) | 404 when `delete()` returns False |
| M | api | `api/batches.py:261` | Dataset download link is a plain `<a href download>`, so backend errors render as a raw JSON page | Disable when empty, or fetch via blob |
| M | frontend | `AnnotationCanvas.jsx:114` | Alt-click delete-vertex is a **no-op** — `updateShape` discards the passed geometry on commit and re-PATCHes the old one | Use the passed geometry on commit |
| M | frontend | `ReviewPage.jsx:71` | The 2 s job poll overwrites in-flight drag edits — shapes snap back mid-gesture | Skip the refetch while a gesture is active |
| M | frontend | `ReviewPage.jsx:93` | Rapid A/X approvals capture the same `frame` twice → one frame PATCHed twice, the next skipped unreviewed | Derive the frame inside the functional `setIndex` |
| M | frontend | `ReviewPage.jsx:129` | Failed geometry/class/delete requests are never rolled back — canvas and server diverge silently | Snapshot and restore in `catch` |
| M | frontend | `ReviewPage.jsx:198` | "Track 5 Frames" / `[T]` is dead code — it reads `geometry.coordinates`, which this app never produces | Use `geometry.points`, or delete (propagation is a stated non-goal) |
| M | frontend | `AutoAnnotateModal.jsx:294` | Custom-model chips list *project* classes, not the inspected model's | Render `customModelClasses` when `engine === 'custom'` |
| M | ops | `Dockerfile:1` | No root `.dockerignore` — 38 GB `data/`, 5.2 GB `.venv` and `.env` are all sent as build context | Add one |
| M | frontend | `AnnotationCanvas.jsx:163` | Escape clears the drag but leaves the shape visually moved and uncommitted | Restore `drag.start` before clearing |
| L | frontend | `ReviewPage.jsx:243` | Arrow-key nav during a drag silently drops the edit | Ignore nav keys mid-gesture, or key the canvas on `frame.id` |
| L | frontend | `ReviewPage.jsx:112` | SAM3 assist doesn't bump `annotation_count` | Add the `patchFrameLocally` call |
| L | frontend | `Filmstrip.jsx:7` | Every frame rendered, no virtualization, full reconcile every 2 s | Window it + `React.memo` |
| L | ops | `start.sh:7` | `rm -f docker-compose.override.yml` deletes a **tracked** file and silently drops your `./backend` bind mount | Untrack it; generate `docker-compose.gpu.yml` and pass via `-f` |
| L | ops | `README.md:18` | References a `.env.example` that doesn't exist; `HF_TOKEN` is required for gated SAM3 weights | Commit one |
| L | ops | `requirements.txt` | torch/torchvision/ultralytics/fastapi unpinned — CLAUDE.md §6's "cannot drift" claim is false | Pin or add a uv lockfile |
| L | api | `api.js:83` | `autolabelWithModel` is orphaned; its `iou_threshold` default (0.8) contradicts the endpoint's (0.0) | Delete both, or route the custom-model flow through it |
Also: **`docker-compose.override.yml` is committed and pins `nvidia.com/gpu=all`** — Compose
auto-merges it, so a CPU-only host cannot start, and README tells users to run
`docker compose up` directly, bypassing `start.sh`. This violates CLAUDE.md §9 head-on.
---
## 4. The dirty working tree
| File | Verdict | Reasoning |
|---|---|---|
| `backend/jobs.py` | **Revert** | Source of C1 and C2. The parallel-jobs change is the single most damaging uncommitted edit. |
| `docker-compose.override.yml` | **Untrack** | Should never have been committed; `start.sh` regenerates it and destroys your bind mount each run |
| `backend/test.py`, `backend/test_preview.py` | **Delete** | Scratch scripts in the package dir; not tests, not imported |
| `algoritma-batch/batch_video_cropper.py.bak` | **Delete** | Backup file in git's way |
| `scratch/` | **Gitignore** | |
| `frontend/src/pages/BatchesPage.jsx`, `DataPrepPage.jsx`, `AutoAnnotateModal.jsx` | **Finish** | Reachable from the app and carrying real bugs (rows above). Not abandoned. |
| `scripts/transcode_archive.py`, `algoritma-batch/src/h264_converter.py` | **Finish** | Directly relevant to the H `library.py:92` finding — the right home for that work |
| `algoritma-batch/predict.py`, `test_batch_logic.py` | **Review** | Unreferenced by the app; decide whether `algoritma-batch/` is still in scope |
---
## 5. Workflow gaps — where the design is wrong
1. **The positional val split is the design's weakest point.** `_next_split` counting rows
means the split depends on *insertion order and history*. Any deletion, any re-merge, any
reordering shifts it. The invariant in CLAUDE.md §8 is stated but not enforced by the
mechanism. It should be content-derived: `hash(project_id + batch + stem) % 5 == 0 → val`.
Then it is stable by construction and no code path can violate it.
2. **Master labels are regenerated from live annotations on every training start.** This is
the reason three separate HIGH findings exist. A merged dataset should be *immutable* —
merging means "snapshot these labels". Regeneration should be an explicit repair action,
not a side effect of pressing Train.
3. **The base-vs-new comparison silently degrades instead of refusing.** Empty val → validate
on train images. Deleted batch → phantom images in the val dir. Class filter → remapped
ids. Every one of these produces a number that *looks* fine. For a system whose entire
purpose is "did retraining help?", the comparison should be fail-loud.
4. **No visibility into a running job beyond a progress bar,** and cancellation doesn't
survive restart. For jobs that run tens of minutes this is the main source of "rough".
5. **No resume.** A cancelled or crashed merge leaves the batch in a state that can never be
merged again (M, `dataset.py:402`).
---
## 6. Recommended order of work
1. **Revert the `jobs.py` parallelism experiment.** → verify: start two autolabel jobs on
different batches; expect the second to sit `queued` until the first finishes, and
`nvidia-smi` to show one SAM3 backbone resident, not two.
2. **Restore `gpu_lock` on autolabel + the two synchronous GPU routes.** → verify: start an
autolabel job, then hit Preview; expect "GPU is busy with a autolabel job", not an OOM.
3. **Stop `sync_labels` from touching merged labels** (filter to `approved`; move the
class-filter remap into `labels_selected/`). → verify: merge a batch, edit an annotation in
review, press Train; expect the merged `.txt` on disk to be unchanged.
4. **Make the val split content-derived** and backfill existing `dataset_items`. → verify:
delete a batch, re-import, re-merge; expect every frame to land in the same split as before.
5. **Clean up batch deletion** — unlink the dataset files. → verify: delete a merged batch;
expect `find dataset/images -name '<id>__*'` to return nothing.
6. **Fail loudly on empty val** and fix `nc`. → verify: train a project with no val items;
expect a clear error, not a silent train-on-train run.
7. **Fix the two review-editor HIGHs** (sidebar delete, modifier keys) and the no-op
delete-vertex. → verify: keyboard-only pass over 20 frames; Ctrl+S must not approve.
8. **Untrack `docker-compose.override.yml`, add `.dockerignore` and `.env.example`,
pin requirements.** → verify: `docker compose build` on a CPU-only host succeeds.
+9 -2
View File
@@ -168,7 +168,11 @@ GET /api/batches/{id} # status + review progress (REQ-
GET /api/batches/{id}/frames # frames + statuses GET /api/batches/{id}/frames # frames + statuses
POST /api/batches/{id}/autolabel # {threshold} → job (REQ-030,032,034) POST /api/batches/{id}/autolabel # {threshold} → job (REQ-030,032,034)
DELETE /api/batches/{id}/classes/{class_id}/annotations # clear all shapes of class in batch (REQ-046) DELETE /api/batches/{id}/classes/{class_id}/annotations # clear all shapes of class in batch (REQ-046)
POST /api/batches/{id}/approve # → merge job (REQ-050) POST /api/batches/{ids}/approve # one or many, comma-separated → one merge job (REQ-131)
GET /api/batches/{ids}/triage/summary # one or many, comma-separated (REQ-130)
GET /api/batches/{ids}/triage/shapes
GET /api/batches/{ids}/triage/suggest
POST /api/batches/{ids}/triage/simulate
GET /api/frames/{id}/image?w=… # frame image / thumbnail GET /api/frames/{id}/image?w=… # frame image / thumbnail
GET /api/frames/{id}/annotations GET /api/frames/{id}/annotations
@@ -209,7 +213,10 @@ disk, and rewrites `data.yaml`. The renumbering is the whole job: a YOLO label i
index, so a class list and a set of label files that disagree do not fail loudly — they index, so a class list and a set of label files that disagree do not fail loudly — they
train a model on the wrong names. Refused for a project's last class. train a model on the wrong names. Refused for a project's last class.
**merge (REQ-050…053).** For every `approved` frame not yet in `dataset_items`: assign a **merge (REQ-050…053, REQ-131…132).** One job covers the whole selected set of batches, and
`datasets.rules_json` holds the triage rules frozen at the moment the merge was confirmed —
the resolver is built from that snapshot, never from the project's live rules. For every
`approved` frame not yet in `dataset_items`: assign a
split (continuing the round-robin), copy the JPEG to `dataset/images/<split>/`, write the split (continuing the round-robin), copy the JPEG to `dataset/images/<split>/`, write the
YOLO `.txt` from the frame's annotations, record the row. Finally rewrite `data.yaml`. YOLO `.txt` from the frame's annotations, record the row. Finally rewrite `data.yaml`.
Frames with no annotations produce an empty `.txt` (REQ-033). Frames with no annotations produce an empty `.txt` (REQ-033).
+146
View File
@@ -0,0 +1,146 @@
# Proposal — Data Prep: outlier filter + augmentation
Status: **scope approved 2026-08-13**. Supersedes the rules-engine proposal agreed on
2026-08-07, which was never implemented into `./requirements.md`. If accepted as written,
REQ-100…REQ-105 and REQ-110…REQ-113 move into `./requirements.md`.
What changed from the 2026-08-07 draft, and why:
- The `reclass` action, the ordered rule list, and the preset suggestions are **dropped**.
In practice the only decision being made on this page is "is this box junk?" — a
reclass target and first-match-wins ordering were machinery for a decision nobody was
making. Both `triage_rules` and `annotation_overrides` were empty when this was decided,
so nothing was lost.
- Data Prep gains **augmentation settings**, which the pipeline previously left entirely to
Ultralytics' defaults.
---
## Part 1 — Outlier filter
A shape's verdict is still resolved, never stored destructively:
```
manual override (if any) > outlier filter > default: keep
```
Verdict is one of `keep` · `ignore`. `annotations.class_id` is never rewritten, so a
filter can be re-cut at any time.
The filter is expressed over the same three signals the resolver already computes —
`score`, `area_pct`, `aspect` — as a keep-range per signal. Anything outside an enabled
range is `ignore`.
### Requirements
- **REQ-100** — A project has one **outlier filter**: an optional keep-range
(`[min, max]`, either edge blank) over each of `score`, `area_pct` and `aspect`. A shape
falling outside any enabled range resolves to `ignore`.
- **REQ-101** — The filter is **stage-scoped** to `dataprep`. Editing it never alters what
the batches or review stages display; it changes only what the stages after it consume.
- **REQ-102** — The filter is applied **at merge time**, against the live annotations. It is
never baked into the master dataset, and `annotations.class_id` is never rewritten.
- **REQ-103** — The user can **override any individual shape** by hand (`keep`/`ignore`).
A manual override outranks the filter and survives any later filter edit.
- **REQ-104** — A shape resolving to `ignore` drops that box. A frame that loses **every**
box is held back from the dataset — an image is never trained on with a known object
left unlabeled.
- **REQ-105** — The Data Prep page shows, for one batch: a scatter of score × area with
rectangular selection, and a grid of cropped shape thumbnails. Selecting in either
assigns a manual verdict in bulk. Filter edits update the kept/ignored counts live,
before anything is saved.
Storage reuses the existing `triage_rules` table: the UI emits the filter as
`ignore` rules and reads them back. No schema change, no second code path in the resolver.
## Part 2 — Augmentation
Ultralytics augments during training whether or not we ask it to. `backend/training.py`
passes no augmentation arguments, so every run so far has used library defaults
(`mosaic=1.0`, `fliplr=0.5`, HSV jitter, `scale=0.5`) — invisibly, and unrecorded.
- **REQ-110** — A project stores **augmentation settings**: `fliplr`, `flipud`, `degrees`,
`translate`, `scale`, `hsv_h`, `hsv_s`, `hsv_v`, `mosaic`. They are passed to
`model.train()` on every run.
- **REQ-111** — The UI offers presets — **Off**, **Light**, **Medium**, **Aggressive** —
and lets any single value be adjusted afterwards. A project with no stored settings uses
**Medium**, which reproduces Ultralytics' defaults, so behaviour does not change until
the user changes it.
- **REQ-112** — Augmentation applies to **training images only**. Validation is never
augmented, so a base-vs-new mAP comparison stays a like-for-like measurement
(this is Ultralytics' own behaviour; the requirement is that we must not defeat it).
- **REQ-113** — Each stored model version records the augmentation settings it trained
under, so two runs can be told apart.
### Schema
```sql
ALTER TABLE projects ADD COLUMN augment TEXT; -- JSON, null = Medium preset
ALTER TABLE model_versions ADD COLUMN augment TEXT; -- REQ-113: what this run used
```
### API
```
GET /api/projects/{id}/augment -> {settings, preset}
PUT /api/projects/{id}/augment <- {settings}
```
## Part 3 — Base datasets
Externally-labelled images the user already trusts, registered against a project and
offered as a checkbox beside the project's own datasets. Not a batch, and never becomes
one: no frames, no review, no triage.
- **REQ-120** — A project may register any number of **base datasets**, each a folder of
images with YOLO label files, imported from an unpacked export. Class ids are kept as
they are; the importer only drops classes the user did not ask to keep.
- **REQ-121** — A base dataset is **opt-in per training run**. Selecting no dataset at all
never silently pulls one in.
- **REQ-122** — A base dataset contributes **training images only**. It can never supply
validation images, because REQ-063's base-vs-new comparison is only meaningful measured
on this project's own stable val split (REQ-052). A run with base datasets but no project
dataset is refused — there would be nothing to validate on.
- **REQ-123** — Labels are normalised to the project's `label_type` on import. A
segmentation polygon imported into a `bbox` project is collapsed to its bounding box,
because a detect model reads the first four numbers of a polygon line as a box and would
otherwise train on nonsense.
### Schema
```sql
CREATE TABLE base_datasets (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
image_count INTEGER NOT NULL DEFAULT 0,
box_count INTEGER NOT NULL DEFAULT 0,
classes TEXT NOT NULL DEFAULT '[]',
created_at REAL NOT NULL
);
```
They stay out of `dataset_items` deliberately: that table keys on `frame_id`, and inventing
frame rows for images this app never extracted would put fake batches in front of the user
forever. The rows go straight to `dataset._build_selected_tree`, which only ever needed an
image path and a label path.
### API
```
GET /api/projects/{id}/base-datasets -> [base_dataset]
DELETE /api/base-datasets/{id}
```
Import is an offline call to `base_dataset.import_tree` — a 150 MB export is not an HTTP
request worth holding open.
---
## Open risk
REQ-104 is unchanged from the earlier draft and carries the same interaction: excluding
whole frames means the val set is a function of the filter. The earlier REQ-107
(`rule_version` stamped on each run) already exists in the schema and keeps that honest —
it is retained even though the rules engine around it is gone.
+89
View File
@@ -130,6 +130,95 @@ changes.
- **REQ-054** — The master dataset can be downloaded as a `.zip` (e.g. to import into - **REQ-054** — The master dataset can be downloaded as a `.zip` (e.g. to import into
Roboflow or train on another machine). Roboflow or train on another machine).
## F2. Data Prep as the merge gate
- **REQ-130** — The Batches page supports multi-select. "Prepare & Merge Selected" opens
Data Prep scoped to exactly those batches (`#/projects/{id}/data-prep?batches=1,2,3`).
No dataset exists at this point.
- **REQ-131** — Data Prep is the merge gate. Filters and augmentation are tuned against the
selected batches' shapes; "Confirm merge" names or picks the target dataset and queues
**one** merge job for the whole selection. There is no path from Batches or Review
straight to a dataset.
- **REQ-132** — The rules in force when a merge is confirmed are **snapshotted onto the
dataset** (`datasets.rules_json`). The merge runs under the snapshot, and later edits to
the project's rules never rewrite an existing dataset. Only an explicit Resync adopts
today's rules — and it re-stamps the snapshot with them.
## F3. Counting correctness
- **REQ-140** — Ghost rejection (`entry_travel_min`) and spatial dedup
(`dedup_radius`) are separate parameters. They pull in opposite directions, so one
number cannot serve both.
- **REQ-141** — A track that vanishes parks its history; a new track id born within
`handoff_radius` of its velocity-projected position inherits it. This is what keeps an
ID switch at the counting line from either losing a count (the sack's "was above"
evidence dies with the old id) or duplicating one (the new id has no "already counted"
verdict).
- **REQ-142** — A counted direction is the track's *last* verdict, not a permanent one. A
sack genuinely taken back out and reloaded counts again; unloading requires
`unload_confirm_frames` sustained frames above the band, so repositioning by hand cannot
cancel a real count.
- **REQ-143** — Per-track state is evicted once a track has been gone for `track_ttl`, so a
long shift does not grow state without bound.
- **REQ-144** — Every finished track is written to a per-session JSONL with its trajectory
and the reason it did or did not count, so a miss can be attributed to the model, the
tracker, or the counter.
## F4. Counting accuracy bench
- **REQ-150** — A page lists every archive video as a row: date, batch, length, and the
counter's `counted in` / `counted out` / `net` for it. Videos never counted are still
rows — the table is the work list.
- **REQ-151** — Each row has an editable **ground truth** (what a human counted). The
scored figure is the signed delta `counted_in - ground_truth`, so over- and under-counting
stay distinguishable. Accuracy is `1 - |delta| / ground_truth`.
- **REQ-152** — Accuracy totals are computed **only** over rows where a ground truth is
filled in. An uncounted or unscored video never enters the denominator.
- **REQ-153** — Counting runs as a queued background job over a selection of videos, or all
of them, holding the GPU lock. It renders nothing — no annotated frame, no JPEG encode —
which is what makes counting a 30-minute video practical. A run records the parameters and
model it used.
## F5. Real recording times and working days
- **REQ-160** — Each recording's start time is read from the timestamp the camera burns into
the top-right of every frame. Folder names and file mtimes are both unreliable: mtimes are
file *copy* times, not recording times.
- **REQ-161** — A **working day runs 06:00 to 06:00**. A recording that started before 06:00
belongs to the previous working day. A recording spanning the boundary is assigned by its
start.
- **REQ-162** — Recordings are numbered 1..N within a working day, ordered by real start
time. The original file path stays the file's identity and is shown beside the number, so
results already recorded against it survive.
- **REQ-164** — The counting table is grouped into collapsible **cycles**, newest first, with
the recordings inside each one in the order they were made. A cycle's header carries its
video count, its AI/ground-truth totals, signed delta and accuracy, and how many of its rows
have an unverified start time. Only the newest cycle is expanded by default.
- **REQ-165** — The Video Archive page browses the archive **by cycle**, not by folder. The
left-hand list holds cycles newest first; the table shows the recordings of the selected
cycle in the order they were made, with their cycle batch number and the time read from the
overlay. A recording pulled in from another folder is marked with the folder it sits in.
- **REQ-166** — Each recording is checked for a truck with the project's newest model,
sampling a handful of frames rather than the whole file. The recording trigger is truck
arrival and departure, so one file is one batch — this check is what proves that assumption
per file, and flags any recording where it does not hold.
- **REQ-167** — The production counter's counting day turns over at the same hour as the
archive's cycles, 06:00, so `batch_number` on the Jetson and the batch order in Video
Archive mean the same thing. It stays overridable per deployment via `DAILY_CUTOFF_TIME`.
- **REQ-168** — The recorder writes each archive file at the frame rate the stream actually
delivers, and paces writes against the wall clock, so a file's duration equals the real
duration of the recording however unevenly the capture loop runs.
- **REQ-170** — Recording happens once, on the streaming server, and is stored as a rolling
buffer. The truck detector does not encode video: when a session ends it downloads that time
range as a **copy**, so the archive keeps the camera's own codec, resolution and frame rate.
Each clip carries a sidecar with the server's start time, which the app trusts over reading
the burned-in overlay. Archive folder names remain calendar dates; the app derives cycles
from the real start time, so the folder name is never read as a date.
- **REQ-163** — Nothing in the archive is moved, renamed or written to; it is mounted
read-only. The grouping lives in an index beside it. Timestamps that could not be read, or
were read with low confidence, are flagged and can be hand-entered; a hand-entered time
outranks any reading and is never overwritten by a rescan.
## G. Training & evaluation ## G. Training & evaluation
- **REQ-060** — The user starts training from the project page. Training **fine-tunes from - **REQ-060** — The user starts training from the project page. Training **fine-tunes from
+214
View File
@@ -821,11 +821,225 @@ Automatically skip empty initial frames when opening the Review Editor on a batc
Verified: Frontend built and re-deployed cleanly. Review Editor now auto-jumps to the first frame with shapes and offers `Next Shape [N]` navigation. Verified: Frontend built and re-deployed cleanly. Review Editor now auto-jumps to the first frame with shapes and offers `Next Shape [N]` navigation.
## 23. Fix multi-annotation class mapping & bounding box generation + parameter sliders — `[DONE]`
Fix multi-annotation class mapping and bounding box generation across YOLO and SAM3 engines, and equip the Base Model Auto-annotate modal with parameter sliders (Confidence, NMS IoU, Min Box Size) and target class controls.
**Files.** `backend/autolabel.py`, `frontend/src/pages/LibraryPage.jsx`.
**Steps.**
1. `backend/autolabel.py` — expand YOLO prediction class resolution with multi-level fallback matching (`name_to_class_id`, `class_id` index match, project class fallback) and safe box coordinate scaling to ensure bounding boxes are generated and preserved for all project classes.
2. `backend/autolabel.py` — guard SAM3 prompt mapping against null/empty prompt attributes and ensure zero-division safety on frame size bounds.
3. `frontend/src/pages/LibraryPage.jsx` — update `openBaseModelAutolabelModal` and `baseModelModalState` modal to include sliders for Confidence Threshold, NMS IoU Threshold, and Min Box Size (Fraction), plus `Select All` / `Clear All` target class controls.
**Verify.**
1. Compile `backend/autolabel.py` with `uv run python -m py_compile backend/autolabel.py`.
2. Build frontend with `npm --prefix frontend run build`.
Verified: `backend/autolabel.py` compiled cleanly and frontend built with zero errors. Multi-annotation bounding boxes generate properly for all classes and base model auto-annotation modal displays all parameter sliders.
--- ---
## Task 15 — Data Prep: outlier filter + augmentation `[TODO]`
Serves REQ-100…105 and REQ-110…113 in `./proposal-dataprep-triage.md` (scope approved
2026-08-13). Written but **not deployed** — an auto-annotation run was in flight, and a
rebuild would have failed out its queued jobs (see the note below).
1. Simplify Data Prep to an outlier filter → verify: three keep-ranges over score /
area / aspect; counts move live while dragging. **Done in code.** The filter needs no
new backend — it is emitted as the `ignore` rules the resolver already evaluates
(`OutlierFilter.toRules`/`fromRules`, round-trip tested).
2. Drop the rules engine, presets and `reclass` from the UI → verify: `TriageRules.jsx`
and `TriagePresets.jsx` deleted, frontend builds. **Done in code.** Both
`triage_rules` and `annotation_overrides` were empty when this was decided, so no
stored data was discarded.
3. Augmentation settings per project → verify: `GET/PUT /api/projects/{id}/augment`
round-trips; presets Off/Light/Medium/Aggressive; Medium equals Ultralytics' defaults
so an untouched project trains identically. **Done in code**, unit-checked offline.
4. Pass augmentation to `model.train()` and stamp it on the model version (REQ-113) →
verify: **not yet run** — needs a real training run after deploy.
Remaining to close this task: deploy (`docker compose build backend frontend && up -d`)
once no job is running, then confirm the migration adds `projects.augment` and
`model_versions.augment`, and that a training run logs its augmentation preset.
## Task — Data Prep becomes the merge gate (REQ-130…132)
1. `triage` accepts a batch-id list; `/api/batches/{ids}/triage/*` takes comma-separated ids
→ verify: **[DONE]** simulate over batches 66,67,68 returns 9,178 shapes, exactly the sum
of 1,097 + 6,431 + 1,650 measured one at a time.
2. `datasets.rules_json` snapshots the rules a dataset was cut under; the merge resolves from
the snapshot, and a migration backfills existing datasets → verify: **[DONE]** merged a
dataset, then replaced the project's rules with an ignore-everything rule; the dataset's
label files hashed identically before and after, its `rule_version` did not move, and a
second merge into it still logged the original 3 rules.
3. `dataset.approve` takes a list and queues one merge job for the whole selection →
verify: **[DONE]** batches 494 + 534 produced one job, one dataset, 16 `dataset_items`
= 6 + 10, the sum of their approved frames.
4. Batches multi-select → Data Prep (`?batches=…`) → Confirm merge; merge removed from
Review and from the batch list → verify: **[TODO]** run the click-path in the browser.
5. Docs updated → verify: **[DONE]** REQ-130…132 in `./requirements.md`, merge section and
route table in `./design.md`.
## Task — Counting algorithm fixes (REQ-140…144)
Five defects were reproduced against the counter before changing it, and each fix is
verified by the failure case that motivated it.
1. Split `entry_travel_min` from `dedup_radius` (REQ-140) → verify: **[DONE]** both exposed
separately through the API and the Live Count page.
2. Track hand-off across ID switches (REQ-141) → verify: **[DONE]** id seen above the line,
vanishing, reappearing below as a new id counts 1 (was 0). Same sack switching id *after*
being counted still counts 1, not 2. A track that blinks for one frame no longer leaks its
state to an unrelated newborn.
3. Directional verdict + sustained unload (REQ-142) → verify: **[DONE]** a brief 2-frame lift
leaves net 1; a genuine unload-and-reload gives L2/U1, net 1 (was net 0).
4. Evict stale track state (REQ-143) → verify: **[DONE]** 5,000 tracks then idle retains 0
entries; previously 30,000 and unbounded.
5. Per-track trace JSONL + perspective area gate (REQ-144) → verify: **[DONE]** a real run on
`2026-08-14/batch011.mp4` at 124 fps wrote one record per finished track with its verdict.
6. Camera-tuned defaults: line 266, x 469…910, margin 5, entry travel 60, hand-off 100,
unload confirm 3, min area 1.0, conf 0.35 → verify: **[DONE]** the ten-case failure suite
passes at these defaults, including a burst-frame case that exposed unbounded velocity in
the hand-off projection (now clamped to 1500 px/s and 0.5 s of extrapolation).
**Open — needs the hand-counted clip.** On real footage 84% of tracks inherit via hand-off at
`handoff_radius=100`, because these frames are dense enough that a newborn track is nearly
always near one that just vanished. 100 is the value tuned against the camera and is now the
default, but the right value is a measurement, not a guess: run a clip with a known total and
read the verdict histogram
in the trace file. `never_reached_below` dominating means the tracker is fragmenting (not the
counter); `born_below_line` means counts are being lost to ID switches the hand-off radius is
too tight to recover.
## Task — Counting accuracy bench (REQ-150…153)
1. `count_runs` table + `count` job type → verify: **[DONE]** migration rebuilt the `jobs`
table to accept the new type (SQLite cannot alter a CHECK constraint); all 928 existing
job rows preserved.
2. Headless counter reusing the live pipeline → verify: **[DONE]** 21,544 frames of
`2026-08-14/batch011.mp4` in 147 s = **146 fps**, against 124 fps through the live view.
Rendering was the difference.
3. Scored table with editable ground truth → verify: **[DONE]** setting a ground truth,
clearing it, and the totals excluding unscored rows all round-trip through the API.
4. Background job over a selection or all videos → verify: **[DONE]** queued one video, the
job reported `7150/21544 frames` mid-run and stored in 169 / out 8 / net 161 on finish.
5. Page + route + sidebar entry → verify: **[DONE]** frontend builds; listing serves 222 rows
in 0.18 s once ffprobe is warm (7.7 s cold).
**Sizing.** The archive is 129 hours across 222 videos. At the measured 146 fps a full
recount is roughly **22 GPU-hours**, so "Count all" is an overnight job, not an interactive
one. It is resumable — already-counted videos are skipped unless `recount` is ticked — and
cancelling mid-video discards that video's partial count rather than storing it as a result.
## Task — Real recording times, 06:00 working days (REQ-160…163)
1. Read the burned-in overlay without adding an OCR dependency → verify: **[DONE]** 12 glyph
templates matched per frame; decodes frames it was never trained on exactly, at
confidence 0.75–0.87.
2. Reject bad reads rather than trust them → verify: **[DONE]** a misread that produced the
year 7026 is rejected by the year-range check; low confidence or fewer than two agreeing
frames flags the row for review instead of silently regrouping it.
3. Working-day grouping and renumbering → verify: **[DONE]** scanned all 224 recordings;
**29 land on a different working day** than their folder. Working day 2026-08-13 now starts
at 08:27 because the 00:07 and 00:22 recordings moved to 08-12.
4. Nothing written to the archive → verify: **[DONE]** the mount is `:ro`; the index lives in
`video_clock` and the file path stays the row's identity, so existing counts survived.
**Timezone.** Start times are stored as wall-clock **text**, never an epoch. Storing an epoch
made the backend (UTC) and the browser (UTC+7) disagree by seven hours, which moved recordings
across the 06:00 boundary into the wrong working day — `2026-08-07/batch4` read 20:12:42 and
displayed as 03:12:42 the next day. Caught by cross-checking one file against the video.
5. Group the table into collapsible cycles (REQ-164) → verify: **[DONE]** 10 cycles render
newest first; `Siklus 13 Agt 2026` holds 28 recordings running 08:27 → 01:19 the next
morning, which is the midnight crossing the grouping exists to make readable. A cycle
header selects all of its rows for a recount in one click.
6. Video Archive browses by cycle (REQ-165) → verify: **[DONE]** `Siklus 13 Agt 2026` lists
28 recordings running 08:27 through midnight to 01:19, with `batch001…003` from the
*2026-08-14* folder correctly appearing as #26–28 of the 13 Agt cycle and flagged with
their folder. Listing the cycles costs 0.13 s because it counts filenames instead of
running ffprobe on the whole archive.
7. Truck check with v4 (REQ-166) → verify: **[DONE]** scanned 226 recordings, 12 frames each,
in ~5 minutes. **225 contain a truck** (136 in every sampled frame, 89 in some), so the
"one file is one batch" premise holds. One recording — `2026-08-07/batch027.mp4` — shows no
truck in any sampled frame and is flagged in the table. Three files will not open at all.
A first attempt died after 8 recordings with `database is locked`: the writer opened a
second connection inside an open write transaction. Now a single UPSERT on one cursor.
8. Align the production counter to the 06:00 cycle (REQ-167) → verify: **[DONE]**
`predict.py`'s `DAILY_CUTOFF_TIME` default moved from `20:00` to `06:00`; at `06:00` its
`get_counting_date()` agrees with the app's `working_day()` on 8 of 8 boundary cases, at
`20:00` it disagreed on 3. `algoritma-batch/migrate_cutoff_0600.py` re-files existing rows:
tested against a replica of the Jetson schema, 9 batches split across two counting dates
by the old cutoff collapse into one day numbered #1–#8, `daily_summaries` is rebuilt, the
unique key holds, a timestamped backup is written, a second run is a no-op, and a row with
an unparseable `start_time` is left alone rather than failing the migration.
**The recorder is `algoritma-batch/batch_video_cropper.py`, in this repo**, running 24/7 on
this machine (pid seen at 187 min CPU). It reads `rtsp://192.168.192.96:8554/cam`, uses
`BatchLifecycleManager` + `v3-best.pt` to detect a truck arriving and leaving, and writes
`~/reTraining/data/archive/{date}/batch{NNN}.mp4` — one file per truck session, which is what
makes "one file is one batch" true.
9. Correct the recorder's frame rate (REQ-168) → verify: **[DONE]** `VIDEO_FPS = 10.0` was
hard-coded while the camera delivers 25, so every archived file claimed a duration 2.49x
too long (batch003: 22,968 frames, overlay says 15.4 minutes, file says 38.3). The rate now
comes from the stream and writes are paced against the wall clock. Recorded 30 s from the
live production stream with the loop deliberately starved to ~3.7 fps: the file came out
**31.56 s against 31.9 s real, 1.1% off**; the old code would have produced 11.9 s.
The camera is **25 fps, not 60** — RTSP metadata, the HLS playlist (`FRAME-RATE=25.000`)
and the measured delivery rate (24.8 fps) all agree.
**Decided, not a defect:** the recorder keeps `DAILY_CUTOFF_TIME = "00:00"`, so folder names
stay calendar dates. The user's call — what matters is that the app is right, and it is: cycles
are derived from each recording's real start time, so a file sitting in the 15 Aug folder but
recorded at 01:00 appears under the 14 Aug cycle. Nothing downstream reads the folder name as a
date. Note if this is ever revisited: this script's `get_counting_date()` returns *tomorrow*
after the cutoff, unlike `predict.py`'s, so the function would need aligning, not just the
constant.
10. Record once on the Jetson, cut sessions on the ASUS (REQ-170) → verify: **[DONE]** MediaMTX
on the Jetson now records 24/7 (`record: yes`, `playback: yes`, 15-minute segments, 24-hour
buffer — 18 GB of its 36 GB free; 48 h would have needed 37 GB and did not fit). The
recorder no longer re-encodes: on session end it downloads that exact time range as a copy.
Fetch verified against the live stream — asked for 16:10:53 +45 s, the clip's burned-in
overlay reads 16:10:52 → 16:11:37, exactly 45 s, 1125 frames at 25 fps, 1 s off from the
camera's own clock. A simulated 40 s session produced a 46 s clip whose sidecar
(`16:14:45`, from the server) matches the overlay to the second. Files are now **HEVC
1920x1080 copies, ~4.7x smaller** than the old 1280x720 mpeg4 re-encodes.
11. Video Archive stays current without a scan (REQ-170) → verify: **[DONE]** the recorder
writes a `.json` sidecar beside each clip and the app reads it live, so a new session
appears in the right cycle with a server-accurate time and no OCR at all.
**The camera cannot do 60 fps.** `FPSMax=25` on every stream format of the DH-IPC-HFW1230, and
it already runs at that (1080p, H.265, 2048 kbps CBR). The "not smooth" impression came from
the broken timebase, not the frame rate.
**Mistake to record:** while testing the fetch, a test clip was copied over
`data/archive/2026-08-14/batch007.mp4`, destroying a real 09:20:08 truck recording. It had never
been used for frame extraction or counting, so no dataset or annotation was affected, and the
test clip and its index row were removed. The file itself is gone from this machine; the rsync
history suggests a copy may exist on 192.168.192.105/.106.
**Worth checking on the Jetson:** `BATCH_MERGE_THRESHOLD_SECONDS` defaults to 300, so a truck
arriving within five minutes of the last batch *continues* it instead of starting a new one.
Video Archive counts one file as one batch, so if trucks really do turn around that fast the
two will disagree.
**Open — 19 recordings need a human.** 8 are unreadable (3 of them will not open at all:
`2026-08-06/batch4`, `2026-08-06/batch9`, `2026-08-14/batch016` — likely truncated) and 11 were
read with low confidence. Both are flagged amber in the table and accept a hand-typed time.
## Known open points ## Known open points
- *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume - *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume
+62 -9
View File
@@ -1,11 +1,17 @@
import { Component, useEffect, useState } from 'react' import { Component, useCallback, useEffect, useState } from 'react'
import { api } from './api' import { api } from './api'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import ProjectsPage from './pages/ProjectsPage' import ProjectsPage from './pages/ProjectsPage'
import LibraryPage from './pages/LibraryPage' import LibraryPage from './pages/LibraryPage'
import BatchesPage from './pages/BatchesPage'
import CountingBenchPage from './pages/CountingBenchPage'
import DataPrepPage from './pages/DataPrepPage'
import DatasetsPage from './pages/DatasetsPage'
import TrimPage from './pages/TrimPage' import TrimPage from './pages/TrimPage'
import ReviewPage from './pages/ReviewPage' import ReviewPage from './pages/ReviewPage'
import ModelsPage from './pages/ModelsPage' import ModelsPage from './pages/ModelsPage'
import LiveCountPage from './pages/LiveCountPage'
import Sam3PlaygroundPage from './pages/Sam3PlaygroundPage' import Sam3PlaygroundPage from './pages/Sam3PlaygroundPage'
import './roboflow.css' import './roboflow.css'
@@ -28,7 +34,17 @@ function parseRoute(hash) {
if (parts[2] === 'trim' && parts[3]) { if (parts[2] === 'trim' && parts[3]) {
return { name: 'trim', projectId, rel: decodeURIComponent(parts[3]) } return { name: 'trim', projectId, rel: decodeURIComponent(parts[3]) }
} }
if (parts[2] === 'batches') return { name: 'batches', projectId }
if (parts[2] === 'data-prep') {
// `?batches=1,2,3` is the selection being prepared for merge (REQ-130).
const batchIds = (query.get('batches') || '')
.split(',').map(Number).filter((id) => Number.isInteger(id) && id > 0)
return { name: 'data-prep', projectId, batchIds }
}
if (parts[2] === 'datasets') return { name: 'datasets', projectId }
if (parts[2] === 'models') return { name: 'models', projectId } if (parts[2] === 'models') return { name: 'models', projectId }
if (parts[2] === 'live-count') return { name: 'live-count', projectId }
if (parts[2] === 'counting-bench') return { name: 'counting-bench', projectId }
if (parts[2] === 'review') { if (parts[2] === 'review') {
const batchId = query.get('batch') ? Number(query.get('batch')) : null const batchId = query.get('batch') ? Number(query.get('batch')) : null
return { name: 'review', projectId, batchId } return { name: 'review', projectId, batchId }
@@ -39,6 +55,8 @@ function parseRoute(hash) {
return { name: 'projects' } return { name: 'projects' }
} }
export function navigate(path) { export function navigate(path) {
window.location.hash = path window.location.hash = path
} }
@@ -97,15 +115,22 @@ export default function App() {
const [theme, toggleTheme] = useTheme() const [theme, toggleTheme] = useTheme()
const [currentProject, setCurrentProject] = useState(null) const [currentProject, setCurrentProject] = useState(null)
const handleProject = useCallback((p) => {
if (!p) return
setCurrentProject((prev) => (prev?.id === p.id && prev?.name === p.name ? prev : p))
}, [])
useEffect(() => { useEffect(() => {
if (route.projectId) { if (route.projectId) {
api.getProject(route.projectId).then(setCurrentProject).catch(() => {}) api.getProject(route.projectId).then(handleProject).catch(() => {})
} else if (!currentProject) { } else {
api.listProjects().then((list) => { api.listProjects().then((payload) => {
if (list.length > 0) setCurrentProject(list[0]) if (payload?.projects && payload.projects.length > 0) {
handleProject(payload.projects[0])
}
}).catch(() => {}) }).catch(() => {})
} }
}, [route.projectId, route.name, currentProject]) }, [route.projectId, route.name, handleProject])
return ( return (
@@ -123,21 +148,48 @@ export default function App() {
{route.name === 'library' && ( {route.name === 'library' && (
<LibraryPage <LibraryPage
projectId={route.projectId} projectId={route.projectId}
onProject={(p) => setCurrentProject(p)} onProject={handleProject}
/> />
)} )}
{route.name === 'batches' && (
<BatchesPage
projectId={route.projectId}
onProject={handleProject}
/>
)}
{route.name === 'data-prep' && (
<DataPrepPage
projectId={route.projectId}
batchIds={route.batchIds}
onProject={handleProject}
/>
)}
{route.name === 'datasets' && <DatasetsPage projectId={route.projectId} />}
{route.name === 'trim' && <TrimPage projectId={route.projectId} rel={route.rel} />} {route.name === 'trim' && <TrimPage projectId={route.projectId} rel={route.rel} />}
{route.name === 'review' && ( {route.name === 'review' && (
<ReviewPage <ReviewPage
batchId={route.batchId} batchId={route.batchId}
projectId={route.projectId} projectId={route.projectId}
onProject={(p) => setCurrentProject(p)} onProject={handleProject}
/> />
)} )}
{route.name === 'models' && ( {route.name === 'models' && (
<ModelsPage <ModelsPage
projectId={route.projectId} projectId={route.projectId}
onProject={(p) => setCurrentProject(p)} onProject={handleProject}
/>
)}
{route.name === 'counting-bench' && (
<CountingBenchPage
projectId={route.projectId}
onProject={handleProject}
/>
)}
{route.name === 'live-count' && (
<LiveCountPage
projectId={route.projectId}
onProject={handleProject}
/> />
)} )}
{route.name === 'sam3-playground' && <Sam3PlaygroundPage />} {route.name === 'sam3-playground' && <Sam3PlaygroundPage />}
@@ -147,3 +199,4 @@ export default function App() {
) )
} }
+120 -4
View File
@@ -14,15 +14,29 @@ async function request(path, { method = 'GET', body, form } = {}) {
const response = await fetch(`/api${path}`, options) const response = await fetch(`/api${path}`, options)
const text = await response.text() const text = await response.text()
const payload = text ? JSON.parse(text) : null let payload = null
if (text) {
try {
payload = JSON.parse(text)
} catch {
payload = null
}
}
if (!response.ok) { if (!response.ok) {
const detail = payload?.detail const detail = payload?.detail
throw new Error(typeof detail === 'string' ? detail : `${response.status} ${response.statusText}`) throw new Error(
typeof detail === 'string'
? detail
: text && text.length < 120
? text
: `${response.status} ${response.statusText}`
)
} }
return payload return payload
} }
export const api = { export const api = {
health: () => request('/health'), health: () => request('/health'),
@@ -57,6 +71,7 @@ export const api = {
deleteBatch: (id) => request(`/batches/${id}`, { method: 'DELETE' }), deleteBatch: (id) => request(`/batches/${id}`, { method: 'DELETE' }),
listFrames: (id) => request(`/batches/${id}/frames`), listFrames: (id) => request(`/batches/${id}/frames`),
frameUrl: (id, width) => `/api/frames/${id}/image${width ? `?w=${width}` : ''}`, frameUrl: (id, width) => `/api/frames/${id}/image${width ? `?w=${width}` : ''}`,
preview: (batchId, body) => request(`/batches/${batchId}/preview`, { method: 'POST', body }),
startAutolabel: (batchId, body) => startAutolabel: (batchId, body) =>
request(`/batches/${batchId}/autolabel`, { method: 'POST', body: body ?? {} }), request(`/batches/${batchId}/autolabel`, { method: 'POST', body: body ?? {} }),
@@ -94,14 +109,115 @@ export const api = {
request(`/frames/${frameId}/annotations`, { method: 'POST', body }), request(`/frames/${frameId}/annotations`, { method: 'POST', body }),
patchAnnotation: (id, body) => request(`/annotations/${id}`, { method: 'PATCH', body }), patchAnnotation: (id, body) => request(`/annotations/${id}`, { method: 'PATCH', body }),
deleteAnnotation: (id) => request(`/annotations/${id}`, { method: 'DELETE' }), deleteAnnotation: (id) => request(`/annotations/${id}`, { method: 'DELETE' }),
bulkDeleteAnnotations: (annotationIds) =>
request('/annotations/bulk-delete', { method: 'POST', body: { annotation_ids: annotationIds } }),
bulkReclassAnnotations: (annotationIds, classId) =>
request('/annotations/bulk-reclass', {
method: 'POST',
body: { annotation_ids: annotationIds, class_id: classId },
}),
assist: (frameId, body) => request(`/frames/${frameId}/assist`, { method: 'POST', body }), assist: (frameId, body) => request(`/frames/${frameId}/assist`, { method: 'POST', body }),
setFrameStatus: (frameId, status) => setFrameStatus: (frameId, status) =>
request(`/frames/${frameId}/status`, { method: 'POST', body: { status } }), request(`/frames/${frameId}/status`, { method: 'POST', body: { status } }),
approveAllBatchFrames: (batchId) => request(`/batches/${batchId}/approve-all`, { method: 'POST' }), approveAllBatchFrames: (batchId) => request(`/batches/${batchId}/approve-all`, { method: 'POST' }),
approveBatch: (batchId) => request(`/batches/${batchId}/approve`, { method: 'POST' }), // `batchIds` is one id or an array — one merge, one dataset (REQ-131).
approveBatch: (batchIds, { datasetId = null, datasetName = '' } = {}) =>
request(`/batches/${[].concat(batchIds).join(',')}/approve`, {
method: 'POST',
body: { dataset_id: datasetId, dataset_name: datasetName },
}),
datasetSummary: (projectId) => request(`/projects/${projectId}/dataset`), datasetSummary: (projectId) => request(`/projects/${projectId}/dataset`),
datasetDownloadUrl: (projectId) => `/api/projects/${projectId}/dataset/download`,
// Named datasets (REQ-110…113)
listDatasets: (projectId) => request(`/projects/${projectId}/datasets`),
createDataset: (projectId, body) =>
request(`/projects/${projectId}/datasets`, { method: 'POST', body }),
renameDataset: (id, body) => request(`/datasets/${id}`, { method: 'PATCH', body }),
deleteDataset: (id) => request(`/datasets/${id}`, { method: 'DELETE' }),
combinePreview: (projectId, datasetIds) =>
request(`/projects/${projectId}/datasets/combine-preview`, {
method: 'POST',
body: { dataset_ids: datasetIds },
}),
resyncDataset: (id) => request(`/datasets/${id}/resync`, { method: 'POST' }),
datasetZipUrl: (id) => `/api/datasets/${id}/download`,
// Live counting test bench
liveCountModels: (projectId) => request(`/projects/${projectId}/live-count/models`),
liveCountStart: (projectId, body) =>
request(`/projects/${projectId}/live-count/start`, { method: 'POST', body }),
liveCountStop: () => request('/live-count/stop', { method: 'POST' }),
liveCountMoveLine: (body) => request('/live-count/line', { method: 'PATCH', body }),
liveCountStatus: () => request('/live-count/status'),
// `key` busts the browser cache so a restarted session gets a fresh connection.
liveCountStreamUrl: (key = 0) => `/api/live-count/stream?k=${key}`,
// Counting bench: scored table of archive videos (REQ-150…153)
countingBench: (projectId, date = '') =>
request(`/projects/${projectId}/counting-bench${date ? `?date=${date}` : ''}`),
setGroundTruth: (projectId, videoRel, groundTruth) =>
request(`/projects/${projectId}/counting-bench/ground-truth`, {
method: 'PATCH', body: { video_rel: videoRel, ground_truth: groundTruth },
}),
runCountingBench: (projectId, body) =>
request(`/projects/${projectId}/counting-bench/run`, { method: 'POST', body }),
archiveCycles: (projectId) => request(`/projects/${projectId}/archive/cycles`),
truckScan: (projectId, body = {}) =>
request(`/projects/${projectId}/archive/truck-scan`, { method: 'POST', body }),
archiveCycleVideos: (projectId, cycle) =>
request(`/projects/${projectId}/archive/cycles/${cycle}`),
scanClock: (projectId, rescan = false) =>
request(`/projects/${projectId}/counting-bench/scan-clock`, { method: 'POST', body: { rescan } }),
setClock: (projectId, videoRel, startedAt) =>
request(`/projects/${projectId}/counting-bench/clock`, {
method: 'PATCH', body: { video_rel: videoRel, started_at: startedAt },
}),
// Base datasets (REQ-130…123)
listBaseDatasets: (projectId) => request(`/projects/${projectId}/base-datasets`),
deleteBaseDataset: (baseId) => request(`/base-datasets/${baseId}`, { method: 'DELETE' }),
// Augmentation (REQ-110…113)
getAugment: (projectId) => request(`/projects/${projectId}/augment`),
saveAugment: (projectId, settings) =>
request(`/projects/${projectId}/augment`, { method: 'PUT', body: { settings } }),
// Triage (REQ-100…105)
triageRules: (projectId) => request(`/projects/${projectId}/triage/rules`),
saveTriageRules: (projectId, rules) =>
request(`/projects/${projectId}/triage/rules`, { method: 'PUT', body: { rules } }),
triageSummary: (batchIds) => request(`/batches/${[].concat(batchIds).join(',')}/triage/summary`),
triageShapes: (batchIds, { sort = 'score', offset = 0, limit = 120 } = {}) =>
request(`/batches/${[].concat(batchIds).join(',')}/triage/shapes`
+ `?sort=${sort}&offset=${offset}&limit=${limit}`),
triagePreview: (projectId) => request(`/projects/${projectId}/triage/preview`),
triageSuggest: (batchIds) => request(`/batches/${[].concat(batchIds).join(',')}/triage/suggest`),
triageSimulate: (batchIds, rules) =>
request(`/batches/${[].concat(batchIds).join(',')}/triage/simulate`,
{ method: 'POST', body: { rules } }),
setTriageOverrides: (annotationIds, verdict, targetClass = null) =>
request('/triage/overrides', {
method: 'POST',
body: { annotation_ids: annotationIds, verdict, target_class: targetClass },
}),
clearTriageOverrides: (annotationIds) =>
request('/triage/overrides', { method: 'DELETE', body: { annotation_ids: annotationIds } }),
cropUrl: (annotationId) => `/api/annotations/${annotationId}/crop`,
importAnnotations: (projectId, zipFile, batchLabel = '') => {
const form = new FormData()
form.append('file', zipFile)
form.append('batch_label', batchLabel)
return request(`/projects/${projectId}/import`, { method: 'POST', form })
},
exportUrl: (projectId, { batchIds = [], approvedOnly = false, includeEmpty = false } = {}) => {
const params = new URLSearchParams()
if (batchIds.length) params.set('batch_ids', batchIds.join(','))
if (approvedOnly) params.set('approved_only', 'true')
if (includeEmpty) params.set('include_empty', 'true')
const query = params.toString()
return `/api/projects/${projectId}/export${query ? `?${query}` : ''}`
},
hardware: () => request('/hardware'), hardware: () => request('/hardware'),
startTraining: (projectId, body) => startTraining: (projectId, body) =>
+45
View File
@@ -476,6 +476,51 @@ main.page {
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.85)); filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.85));
} }
/* Select mode: the cursor promises a marquee, and a shape is a target to tick
rather than something to drag (REQ-045a). */
.canvas-wrap svg.selecting { cursor: cell; }
.canvas-wrap svg.selecting .shape rect,
.canvas-wrap svg.selecting .shape polygon { cursor: pointer; }
.canvas-wrap .shape.marked rect,
.canvas-wrap .shape.marked polygon {
fill: rgba(56, 189, 248, 0.3);
stroke-width: 3;
}
.canvas-wrap .draft.marquee {
fill: rgba(56, 189, 248, 0.12);
stroke-width: 1.5;
stroke-dasharray: 5 3;
vector-effect: non-scaling-stroke;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 16px;
}
.modal {
width: 100%;
background: var(--surface, #18181b);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 20px;
max-height: 85vh;
overflow-y: auto;
}
.mode-switch {
display: inline-flex;
gap: 2px;
}
.canvas-wrap .handle { .canvas-wrap .handle {
cursor: nwse-resize; cursor: nwse-resize;
stroke: #ffffff; stroke: #ffffff;
+32 -5
View File
@@ -32,15 +32,22 @@ function normalise([x0, y0, x1, y1]) {
return [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)] return [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)]
} }
function overlaps(geometry, [mx0, my0, mx1, my1]) {
const [x0, y0, x1, y1] = boxPoints(geometry)
return x0 <= mx1 && x1 >= mx0 && y0 <= my1 && y1 >= my0
}
export default function AnnotationCanvas({ export default function AnnotationCanvas({
frame, imageUrl, annotations, selectedId, activeClass, assistMode, classes, frame, imageUrl, annotations, selectedId, activeClass, assistMode, classes,
onSelect, onCreate, onUpdate, onAssist, mode = 'draw', selectedIds, onSelect, onCreate, onUpdate, onAssist, onMarquee,
}) { }) {
const wrapRef = useRef(null) const wrapRef = useRef(null)
const svgRef = useRef(null) const svgRef = useRef(null)
const [displayWidth, setDisplayWidth] = useState(0) const [displayWidth, setDisplayWidth] = useState(0)
const [draft, setDraft] = useState(null) // box being drawn const [draft, setDraft] = useState(null) // box being drawn
const [drag, setDrag] = useState(null) // move/resize in progress const [drag, setDrag] = useState(null) // move/resize in progress
const selecting = mode === 'select'
const marked = selectedIds instanceof Set ? selectedIds : new Set(selectedIds ?? [])
useLayoutEffect(() => { useLayoutEffect(() => {
const element = wrapRef.current const element = wrapRef.current
@@ -65,16 +72,25 @@ export default function AnnotationCanvas({
] ]
} }
const additive = useRef(false)
function startDraw(event) { function startDraw(event) {
if (event.button !== 0) return if (event.button !== 0) return
const [x, y] = pointAt(event) const [x, y] = pointAt(event)
onSelect(null) additive.current = event.shiftKey
if (!selecting) onSelect(null)
setDraft([x, y, x, y]) setDraft([x, y, x, y])
event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.setPointerCapture(event.pointerId)
} }
function startMove(event, annotation) { function startMove(event, annotation) {
event.stopPropagation() event.stopPropagation()
// Select mode never moves a shape: a stray drag on top of a box would
// silently edit the geometry the user was only trying to tick off.
if (selecting) {
onMarquee?.([annotation.id], { toggle: true })
return
}
const [x, y] = pointAt(event) const [x, y] = pointAt(event)
onSelect(annotation.id) onSelect(annotation.id)
setDrag({ kind: 'move', id: annotation.id, origin: [x, y], setDrag({ kind: 'move', id: annotation.id, origin: [x, y],
@@ -147,6 +163,15 @@ export default function AnnotationCanvas({
if (draft) { if (draft) {
const [x0, y0, x1, y1] = normalise(draft) const [x0, y0, x1, y1] = normalise(draft)
setDraft(null) setDraft(null)
if (selecting) {
const isDrag = x1 - x0 >= MIN_SIZE || y1 - y0 >= MIN_SIZE
const hits = isDrag
? annotations.filter((a) => overlaps(a.geometry, [x0, y0, x1, y1])).map((a) => a.id)
: []
// A click on bare canvas clears; a drag replaces unless Shift adds.
onMarquee?.(hits, { add: additive.current })
return
}
if (x1 - x0 >= MIN_SIZE && y1 - y0 >= MIN_SIZE) { if (x1 - x0 >= MIN_SIZE && y1 - y0 >= MIN_SIZE) {
if (assistMode) onAssist([x0, y0, x1, y1]) if (assistMode) onAssist([x0, y0, x1, y1])
else onCreate({ type: 'bbox', points: [x0, y0, x1, y1] }) else onCreate({ type: 'bbox', points: [x0, y0, x1, y1] })
@@ -184,7 +209,7 @@ export default function AnnotationCanvas({
ref={svgRef} ref={svgRef}
viewBox={`0 0 ${width} ${height}`} viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none" preserveAspectRatio="none"
className={assistMode ? 'assist' : undefined} className={[assistMode ? 'assist' : '', selecting ? 'selecting' : ''].filter(Boolean).join(' ') || undefined}
onPointerDown={startDraw} onPointerDown={startDraw}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
@@ -198,6 +223,8 @@ export default function AnnotationCanvas({
scale={scale} scale={scale}
handle={handle} handle={handle}
selected={annotation.id === selectedId} selected={annotation.id === selectedId}
marked={marked.has(annotation.id)}
readOnly={selecting}
classes={classes} classes={classes}
onStartMove={startMove} onStartMove={startMove}
onStartResize={startResize} onStartResize={startResize}
@@ -211,10 +238,10 @@ export default function AnnotationCanvas({
const [x0, y0, x1, y1] = normalise(draft) const [x0, y0, x1, y1] = normalise(draft)
return ( return (
<rect <rect
className={assistMode ? 'draft assist' : 'draft'} className={selecting ? 'draft marquee' : assistMode ? 'draft assist' : 'draft'}
x={x0 * width} y={y0 * height} x={x0 * width} y={y0 * height}
width={(x1 - x0) * width} height={(y1 - y0) * height} width={(x1 - x0) * width} height={(y1 - y0) * height}
stroke={assistMode ? 'var(--accent)' : classColor(activeClass)} stroke={selecting ? '#38bdf8' : assistMode ? 'var(--accent)' : classColor(activeClass)}
/> />
) )
})()} })()}
@@ -0,0 +1,126 @@
import React from 'react'
/* Augmentation settings (REQ-110…113).
*
* Ultralytics augments regardless; before this panel the values were simply
* library defaults nobody could see. "Medium" reproduces those defaults exactly,
* so opening this page and saving nothing changes nothing. */
export const PRESETS = {
off: { fliplr: 0, flipud: 0, degrees: 0, translate: 0, scale: 0, hsv_h: 0, hsv_s: 0, hsv_v: 0, mosaic: 0 },
light: { fliplr: 0.5, flipud: 0, degrees: 0, translate: 0.05, scale: 0.2, hsv_h: 0.010, hsv_s: 0.4, hsv_v: 0.3, mosaic: 0 },
medium: { fliplr: 0.5, flipud: 0, degrees: 0, translate: 0.1, scale: 0.5, hsv_h: 0.015, hsv_s: 0.7, hsv_v: 0.4, mosaic: 1.0 },
aggressive: { fliplr: 0.5, flipud: 0.1, degrees: 10, translate: 0.2, scale: 0.9, hsv_h: 0.020, hsv_s: 0.9, hsv_v: 0.5, mosaic: 1.0 },
}
const PRESET_LABELS = [
['off', 'Off', 'No augmentation at all.'],
['light', 'Light', 'Horizontal flip and mild colour shift.'],
['medium', 'Medium', "Ultralytics' defaults — what you have been training with."],
['aggressive', 'Aggressive', 'Adds rotation, vertical flip and heavy scaling.'],
]
const CONTROLS = [
{ key: 'fliplr', label: 'Horizontal flip', hint: 'Chance of mirroring left–right.', min: 0, max: 1, step: 0.05, pct: true },
{ key: 'flipud', label: 'Vertical flip', hint: 'Rarely useful for fixed cameras.', min: 0, max: 1, step: 0.05, pct: true },
{ key: 'degrees', label: 'Rotation', hint: 'Maximum rotation either way.', min: 0, max: 45, step: 1, unit: '°' },
{ key: 'translate', label: 'Shift', hint: 'Maximum shift, as a fraction of the frame.', min: 0, max: 0.5, step: 0.01, pct: true },
{ key: 'scale', label: 'Zoom', hint: 'Random zoom in or out.', min: 0, max: 1, step: 0.05, pct: true },
{ key: 'hsv_h', label: 'Hue', hint: 'Colour shift. Keep small or sacks change colour.', min: 0, max: 0.1, step: 0.005, pct: true },
{ key: 'hsv_s', label: 'Saturation', hint: 'Colour intensity jitter.', min: 0, max: 1, step: 0.05, pct: true },
{ key: 'hsv_v', label: 'Brightness', hint: 'Helps across day and night footage.', min: 0, max: 1, step: 0.05, pct: true },
{ key: 'mosaic', label: 'Mosaic', hint: 'Stitches 4 images into one. Strong, but distorts scale.', min: 0, max: 1, step: 0.1, pct: true },
]
function matchPreset(settings) {
for (const [name, preset] of Object.entries(PRESETS)) {
if (CONTROLS.every((c) => Math.abs((settings[c.key] ?? 0) - preset[c.key]) < 1e-9)) return name
}
return 'custom'
}
export default function AugmentationPanel({ settings, onChange, dirty, saving, onSave }) {
const [open, setOpen] = React.useState(false)
const active = matchPreset(settings)
return (
<div className="panel" style={{ padding: 18, marginTop: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<h2 style={{ fontSize: '1rem', margin: 0 }}>Augmentation</h2>
<span className="hint" style={{ fontSize: '0.78rem' }}>
Applied to training images only — validation is never augmented, so mAP stays comparable.
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 10, marginTop: 14 }}>
{PRESET_LABELS.map(([key, label, hint]) => {
const on = active === key
return (
<button
key={key}
type="button"
onClick={() => onChange({ ...PRESETS[key] })}
style={{
textAlign: 'left', padding: '12px 14px', borderRadius: 8, cursor: 'pointer',
border: `1px solid ${on ? '#c084fc' : 'rgba(255,255,255,0.1)'}`,
background: on ? 'rgba(192,132,252,0.12)' : 'rgba(255,255,255,0.02)',
color: 'inherit', transition: 'background 150ms ease, border-color 150ms ease',
}}
>
<strong style={{ fontSize: '0.86rem', color: on ? '#c084fc' : undefined }}>{label}</strong>
<p className="hint" style={{ fontSize: '0.74rem', margin: '4px 0 0' }}>{hint}</p>
</button>
)
})}
</div>
<div className="row" style={{ gap: 12, alignItems: 'center', marginTop: 14, flexWrap: 'wrap' }}>
<button
type="button"
className="btn"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
style={{ cursor: 'pointer', fontSize: '0.8rem' }}
>
{open ? 'Hide' : 'Fine-tune'} individual settings
</button>
{active === 'custom' && <span className="tag" style={{ fontSize: '0.75rem', color: '#c084fc' }}>custom</span>}
{dirty && <span className="tag" style={{ fontSize: '0.75rem', color: '#fbbf24' }}>unsaved</span>}
<button
type="button"
className="btn btn-primary"
onClick={onSave}
disabled={!dirty || saving}
style={{ marginLeft: 'auto', cursor: dirty && !saving ? 'pointer' : 'not-allowed', fontSize: '0.8rem' }}
>
{saving ? 'Saving…' : 'Save augmentation'}
</button>
</div>
{open && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 16, marginTop: 16 }}>
{CONTROLS.map((control) => {
const value = settings[control.key] ?? 0
const shown = control.pct ? `${Math.round(value * 100)}%` : `${value}${control.unit ?? ''}`
return (
<div key={control.key}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: '0.8rem' }}>{control.label}</span>
<strong style={{ fontSize: '0.8rem', color: '#38bdf8' }}>{shown}</strong>
</div>
<input
type="range"
min={control.min} max={control.max} step={control.step} value={value}
onChange={(e) => onChange({ ...settings, [control.key]: Number(e.target.value) })}
style={{ width: '100%', cursor: 'pointer' }}
aria-label={control.label}
/>
<p className="hint" style={{ fontSize: '0.72rem', margin: '2px 0 0' }}>{control.hint}</p>
</div>
)
})}
</div>
)}
</div>
)
}
@@ -0,0 +1,335 @@
import React, { useState, useEffect, useRef } from 'react'
import { api } from '../api'
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
// Shared with MassAutoAnnotateModal: draws detection boxes/polygons over a
// frame in a 0..10000 viewBox.
export function PreviewShapes({ shapes, project }) {
return shapes.map((shape, i) => {
if (!shape.geometry || !shape.geometry.points) return null
const classObj = project.classes.find(c => c.class_id === shape.class_id)
const className = classObj?.name || 'Unknown'
const color = ['#38bdf8', '#34d399', '#f472b6', '#a78bfa', '#fbbf24'][shape.class_id % 5] || '#fff'
let minX = 1, minY = 1, maxX = 0, maxY = 0
if (shape.geometry.type === 'bbox') {
const [left, top, right, bottom] = shape.geometry.points
minX = left; minY = top; maxX = right; maxY = bottom;
} else {
shape.geometry.points.forEach(pt => {
if (pt[0] < minX) minX = pt[0]
if (pt[1] < minY) minY = pt[1]
if (pt[0] > maxX) maxX = pt[0]
if (pt[1] > maxY) maxY = pt[1]
})
}
const x0 = minX * 10000
const y0 = minY * 10000
const bw = (maxX - minX) * 10000
const bh = (maxY - minY) * 10000
return (
<g key={i}>
{shape.geometry.type === 'polygon' && (
<polygon
points={shape.geometry.points.map(pt => `${pt[0] * 10000},${pt[1] * 10000}`).join(' ')}
fill={color}
fillOpacity={0.35}
stroke={color}
strokeWidth="10"
/>
)}
<rect
x={x0}
y={y0}
width={bw}
height={bh}
fill="none"
stroke={color}
strokeWidth="20"
strokeDasharray="40 20"
/>
<text
x={x0}
y={y0 > 300 ? y0 - 100 : y0 + 300}
fill={color}
fontSize="240"
fontWeight="bold"
style={{ textShadow: '10px 10px 10px #000, -10px -10px 10px #000, 10px -10px 10px #000, -10px 10px 10px #000' }}
>
{className} {shape.score ? `${(shape.score * 100).toFixed(1)}%` : ''}
</text>
</g>
)
})
}
export default function AutoAnnotateModal({
batch,
project,
engine,
customModelStagedPath = null,
customModelClasses = [],
onClose,
onSuccess
}) {
const [threshold, setThreshold] = useState(0.35)
const [iouThreshold, setIouThreshold] = useState(0.0)
const [minBoxFrac, setMinBoxFrac] = useState(0.0)
// Class selection state
const [selectedClasses, setSelectedClasses] = useState(() => {
if (engine === 'base_model') {
return project.classes.map(c => c.name)
} else if (engine === 'sam3') {
return project.classes.map(c => c.name)
} else if (engine === 'custom') {
return customModelClasses.slice()
}
return []
})
// Preview state
const [frames, setFrames] = useState([])
const [frameIndex, setFrameIndex] = useState(0)
const [previewShapes, setPreviewShapes] = useState([])
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
// Fetch frames on mount
useEffect(() => {
api.listFrames(batch.id).then(res => {
if (res && res.frames) {
setFrames(res.frames)
setFrameIndex(Math.floor(res.frames.length / 2))
}
}).catch(err => {
console.error("Failed to load frames for preview:", err)
})
}, [batch.id])
const currentFrame = frames[frameIndex]
const handlePreview = () => {
const frame = currentFrame
if (!frame) return
let isMounted = true
setIsLoadingPreview(true)
api.preview(batch.id, {
frame_id: frame.id,
engine,
threshold,
iou_threshold: iouThreshold,
min_box_frac: minBoxFrac,
target_class_names: selectedClasses,
custom_model_path: customModelStagedPath
}).then(res => {
if (isMounted && res.shapes) {
setPreviewShapes(res.shapes)
}
}).catch(err => {
console.error("Preview failed:", err)
setPreviewShapes([])
}).finally(() => {
setIsLoadingPreview(false)
})
}
// Clear shapes when frame changes
useEffect(() => {
setPreviewShapes([])
}, [frameIndex])
const handleStart = async () => {
setIsSubmitting(true)
try {
await api.startAutolabel(batch.id, {
resume: false,
append: true,
engine,
threshold,
iou_threshold: iouThreshold,
min_box_frac: minBoxFrac,
target_class_names: selectedClasses,
custom_model_path: customModelStagedPath
})
onSuccess()
} catch (err) {
alert(err.message)
} finally {
setIsSubmitting(false)
}
}
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 9999, padding: 16
}}>
<div className="panel" style={{
width: 920, maxWidth: '96vw', maxHeight: '90vh', padding: 20,
display: 'flex', gap: 20, flexDirection: 'row', flexWrap: 'wrap',
border: '1px solid rgba(168, 85, 247, 0.4)', background: '#18181b',
overflowY: 'auto', borderRadius: 8
}}>
{/* Left Side: Preview */}
<div style={{ flex: '1 1 450px', display: 'flex', flexDirection: 'column', minWidth: 280 }}>
<h3 style={{ margin: '0 0 8px 0', color: '#e4e4e7', fontSize: '1rem' }}>Preview</h3>
<div style={{ position: 'relative', width: '100%', background: '#000', borderRadius: 6, overflow: 'hidden', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{currentFrame ? (
<div className="relative inline-block" style={{ width: '100%', textAlign: 'center' }}>
<img
src={api.frameUrl(currentFrame.id)}
alt="Preview Frame"
style={{ maxWidth: '100%', maxHeight: '42vh', objectFit: 'contain', display: 'block', margin: '0 auto' }}
/>
<svg viewBox="0 0 10000 10000" preserveAspectRatio="none" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>
<PreviewShapes shapes={previewShapes} project={project} />
</svg>
{isLoadingPreview && (
<div style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.65)', padding: '4px 8px', borderRadius: 4, color: '#38bdf8', fontSize: '0.8rem', backdropFilter: 'blur(4px)' }}>
Inferring...
</div>
)}
<div className="flex justify-between items-center" style={{ padding: '8px 12px' }}>
<button
type="button"
onClick={handlePreview}
disabled={isLoadingPreview || frames.length === 0}
className="px-4 py-1.5 bg-indigo-50 text-indigo-700 font-medium rounded-md hover:bg-indigo-100 disabled:opacity-50"
style={{ fontSize: '0.85rem' }}
>
Run Preview
</button>
</div>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200, color: '#71717a' }}>
Loading frames...
</div>
)}
</div>
<div style={{ marginTop: 10 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Preview Frame ({frameIndex + 1} / {frames.length}):</span>
</div>
<input
type="range"
min="0"
max={Math.max(0, frames.length - 1)}
value={frameIndex}
onChange={(e) => setFrameIndex(parseInt(e.target.value))}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
</div>
{/* Right Side: Tuning Parameters */}
<div style={{ flex: '1 1 300px', display: 'flex', flexDirection: 'column', minWidth: 260, maxHeight: '82vh', overflowY: 'auto' }}>
<h3 style={{ margin: '0 0 4px 0', color: '#c084fc', fontSize: '1rem' }}>
Auto-annotate: {engine === 'sam3' ? 'SAM3' : engine === 'custom' ? 'Custom YOLO' : 'Base Model'}
</h3>
<p className="hint" style={{ fontSize: '0.8rem', marginBottom: 12 }}>
Tune the parameters and check the live preview on the left.
</p>
<div style={{ marginBottom: 10 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Confidence Threshold:</span>
<strong style={{ color: '#38bdf8', fontSize: '0.82rem' }}>{threshold}</strong>
</div>
<input
type="range" min="0.05" max="0.95" step="0.05"
value={threshold}
onChange={(e) => setThreshold(parseFloat(e.target.value))}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
<div style={{ marginBottom: 10 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>NMS IoU Threshold:</span>
<strong style={{ color: '#c084fc', fontSize: '0.82rem' }}>{iouThreshold}</strong>
</div>
<input
type="range" min="0.0" max="0.9" step="0.05"
value={iouThreshold}
onChange={(e) => setIouThreshold(parseFloat(e.target.value))}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Min Box Size (Fraction of Frame):</span>
<strong style={{ color: '#34d399', fontSize: '0.82rem' }}>{(minBoxFrac * 100).toFixed(1)}%</strong>
</div>
<input
type="range" min="0" max="0.5" step="0.005"
value={minBoxFrac}
onChange={(e) => setMinBoxFrac(parseFloat(e.target.value))}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
<div style={{ flex: 1, minHeight: 60, overflowY: 'auto' }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Target Classes:</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginTop: 6 }}>
{project.classes.map(c => {
const isSelected = selectedClasses.includes(c.name)
return (
<button
key={c.name}
className={`class-chip ${isSelected ? 'active' : ''}`}
style={{ fontSize: '0.75rem', padding: '2px 8px', border: isSelected ? '1px solid #c084fc' : '1px solid #3f3f46', cursor: 'pointer' }}
onClick={() => {
if (isSelected) {
setSelectedClasses(selectedClasses.filter(n => n !== c.name))
} else {
setSelectedClasses([...selectedClasses, c.name])
}
}}
>
{isSelected ? '✓ ' : ''}{c.name}
</button>
)
})}
</div>
</div>
<div className="row" style={{ justifyContent: 'flex-end', gap: 10, marginTop: 16, paddingTop: 10, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<button className="btn btn-ghost" onClick={onClose} disabled={isSubmitting}>Cancel</button>
<button
className="btn btn-primary"
disabled={selectedClasses.length === 0 || isSubmitting}
onClick={handleStart}
>
{isSubmitting ? 'Starting...' : 'Start Auto-Annotation'}
</button>
</div>
</div>
</div>
</div>
)
}
+18
View File
@@ -136,3 +136,21 @@ export const ZapIcon = (props) => (
</Icon> </Icon>
) )
export const ScissorsIcon = (props) => (
<Icon {...props}>
<circle cx="6" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<line x1="20" y1="4" x2="8.12" y2="15.88" />
<line x1="14.47" y1="14.47" x2="20" y2="20" />
<line x1="8.12" y1="8.12" x2="12" y2="12" />
</Icon>
)
export const SearchIcon = (props) => (
<Icon {...props}>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</Icon>
)
@@ -0,0 +1,336 @@
import React, { useEffect, useState } from 'react'
import { api } from '../api'
import { PreviewShapes } from './AutoAnnotateModal'
const ENGINES = [
{ id: 'sam3', label: 'SAM3' },
{ id: 'base_model', label: 'Base Model' },
{ id: 'custom', label: 'Custom YOLO (.pt)' },
]
export default function MassAutoAnnotateModal({ batches, project, onClose, onSuccess }) {
const [engine, setEngine] = useState('sam3')
const [customPath, setCustomPath] = useState(null)
const [customName, setCustomName] = useState('')
const [customClasses, setCustomClasses] = useState([])
const [threshold, setThreshold] = useState(0.35)
const [iouThreshold, setIouThreshold] = useState(0.0)
const [minBoxFrac, setMinBoxFrac] = useState(0.0)
const [append, setAppend] = useState(true)
const available = engine === 'custom' ? customClasses : project.classes.map(c => c.name)
const [selectedClasses, setSelectedClasses] = useState(project.classes.map(c => c.name))
const [selectedIds, setSelectedIds] = useState(() => batches.map(b => b.id))
const [previewBatchId, setPreviewBatchId] = useState(batches[0]?.id ?? null)
const [frames, setFrames] = useState([])
const [frameIndex, setFrameIndex] = useState(0)
const [previewShapes, setPreviewShapes] = useState([])
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
const [progress, setProgress] = useState(null)
const [error, setError] = useState('')
// Switching engine changes which class names are meaningful.
useEffect(() => {
setSelectedClasses(engine === 'custom' ? customClasses.slice() : project.classes.map(c => c.name))
}, [engine, customClasses, project])
useEffect(() => {
setPreviewShapes([])
if (previewBatchId == null) return
api.listFrames(previewBatchId).then(res => {
const list = res?.frames || []
setFrames(list)
setFrameIndex(Math.floor(list.length / 2))
}).catch(() => setFrames([]))
}, [previewBatchId])
useEffect(() => { setPreviewShapes([]) }, [frameIndex])
async function pickCustomModel() {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.pt'
input.onchange = async (e) => {
const file = e.target.files?.[0]
if (!file) return
setError('')
try {
const info = await api.inspectModel(file)
setCustomPath(info.staged_path)
setCustomName(info.filename)
setCustomClasses(info.classes || [])
} catch (exc) {
setError(exc.message)
}
}
input.click()
}
function runPreview() {
const frame = frames[frameIndex]
if (!frame) return
setIsLoadingPreview(true)
api.preview(previewBatchId, {
frame_id: frame.id,
engine,
threshold,
iou_threshold: iouThreshold,
min_box_frac: minBoxFrac,
target_class_names: selectedClasses,
custom_model_path: customPath,
}).then(res => setPreviewShapes(res.shapes || []))
.catch(exc => { setError(exc.message); setPreviewShapes([]) })
.finally(() => setIsLoadingPreview(false))
}
function toggleClass(name) {
setSelectedClasses(prev =>
prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name])
}
function toggleBatch(id) {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
async function start() {
setError('')
setProgress({ done: 0, total: selectedIds.length, failed: 0 })
let done = 0
let failed = 0
// Submitted one at a time so a single rejection doesn't abort the rest —
// Promise.all would drop every remaining batch on the first bad one. The
// backend GPU lock serialises the actual work anyway.
for (const id of selectedIds) {
try {
await api.startAutolabel(id, {
resume: false,
append,
engine,
threshold,
iou_threshold: iouThreshold,
min_box_frac: minBoxFrac,
target_class_names: selectedClasses,
custom_model_path: customPath,
})
} catch {
failed += 1
}
done += 1
setProgress({ done, total: selectedIds.length, failed })
}
onSuccess(`Queued ${done - failed} auto-annotation job(s)`
+ (failed ? `, ${failed} failed to queue` : ''))
}
const busy = progress !== null
const canStart = !busy && selectedIds.length > 0 && selectedClasses.length > 0
&& (engine !== 'custom' || customPath)
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16,
}}>
<div className="panel" style={{
width: 1040, maxWidth: '96vw', maxHeight: '90vh', padding: 20, display: 'flex',
gap: 20, flexWrap: 'wrap', border: '1px solid rgba(168, 85, 247, 0.4)',
background: '#18181b', overflowY: 'auto', borderRadius: 8,
}}>
{/* Preview */}
<div style={{ flex: '1 1 420px', display: 'flex', flexDirection: 'column', minWidth: 280 }}>
<h3 style={{ margin: '0 0 8px 0', color: '#e4e4e7', fontSize: '1rem' }}>Preview</h3>
<select
value={previewBatchId ?? ''}
onChange={(e) => setPreviewBatchId(Number(e.target.value))}
style={{ marginBottom: 8, cursor: 'pointer' }}
>
{batches.map(b => (
<option key={b.id} value={b.id}>{b.date_label} / {b.batch_label}</option>
))}
</select>
<div style={{ position: 'relative', background: '#000', borderRadius: 6, overflow: 'hidden', textAlign: 'center' }}>
{frames[frameIndex] ? (
<div style={{ position: 'relative' }}>
<img
src={api.frameUrl(frames[frameIndex].id)}
alt="Preview frame"
style={{ maxWidth: '100%', maxHeight: '42vh', objectFit: 'contain', display: 'block', margin: '0 auto' }}
/>
<svg viewBox="0 0 10000 10000" preserveAspectRatio="none" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>
<PreviewShapes shapes={previewShapes} project={project} />
</svg>
{isLoadingPreview && (
<div style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.65)', padding: '4px 8px', borderRadius: 4, color: '#38bdf8', fontSize: '0.8rem' }}>
Inferring…
</div>
)}
</div>
) : (
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#71717a' }}>
{frames.length === 0 ? 'No extracted frames in this batch yet' : 'Loading frames…'}
</div>
)}
</div>
<div style={{ marginTop: 10 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>
Frame {frames.length ? frameIndex + 1 : 0} / {frames.length}
</span>
<input
type="range" min="0" max={Math.max(0, frames.length - 1)} value={frameIndex}
onChange={(e) => setFrameIndex(parseInt(e.target.value))}
style={{ width: '100%', cursor: 'pointer' }}
/>
<button
type="button" className="btn"
onClick={runPreview}
disabled={isLoadingPreview || frames.length === 0}
style={{ fontSize: '0.85rem', cursor: 'pointer' }}
>
Run Preview
</button>
</div>
</div>
{/* Settings */}
<div style={{ flex: '1 1 360px', display: 'flex', flexDirection: 'column', minWidth: 300, maxHeight: '82vh', overflowY: 'auto' }}>
<h3 style={{ margin: '0 0 4px 0', color: '#c084fc', fontSize: '1rem' }}>
Mass auto-annotate — {selectedIds.length} of {batches.length} batch(es)
</h3>
<p className="hint" style={{ fontSize: '0.8rem', marginBottom: 12 }}>
Every selected batch is queued with these settings. The GPU lock runs them one at a time.
</p>
{error && <p className="error-banner" style={{ marginBottom: 10 }}>{error}</p>}
<div style={{ marginBottom: 10 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Engine:</span>
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
{ENGINES.map(e => (
<button
key={e.id}
className={`class-chip ${engine === e.id ? 'active' : ''}`}
style={{ fontSize: '0.78rem', padding: '3px 10px', cursor: 'pointer', border: engine === e.id ? '1px solid #c084fc' : '1px solid #3f3f46' }}
onClick={() => setEngine(e.id)}
>
{e.label}
</button>
))}
</div>
{engine === 'custom' && (
<div className="row" style={{ gap: 8, marginTop: 8, alignItems: 'center' }}>
<button className="btn" style={{ fontSize: '0.8rem', cursor: 'pointer' }} onClick={pickCustomModel}>
{customPath ? 'Change .pt' : 'Choose .pt'}
</button>
<span className="hint mono" style={{ fontSize: '0.75rem' }}>
{customName || 'no model selected'}
</span>
</div>
)}
</div>
<Slider label="Confidence Threshold" value={threshold} min={0.05} max={0.95} step={0.05}
color="#38bdf8" onChange={setThreshold} format={v => v.toFixed(2)} />
<Slider label="NMS IoU Threshold" value={iouThreshold} min={0} max={0.9} step={0.05}
color="#c084fc" onChange={setIouThreshold} format={v => v.toFixed(2)} />
<Slider label="Min Box Size (Fraction of Frame)" value={minBoxFrac} min={0} max={0.5} step={0.005}
color="#34d399" onChange={setMinBoxFrac} format={v => `${(v * 100).toFixed(1)}%`} />
<label className="row" style={{ gap: 6, alignItems: 'center', marginBottom: 12, cursor: 'pointer' }}>
<input type="checkbox" checked={append} onChange={(e) => setAppend(e.target.checked)} style={{ cursor: 'pointer' }} />
<span className="hint" style={{ fontSize: '0.8rem' }}>Append to existing annotations</span>
</label>
<div style={{ marginBottom: 12 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Target Classes:</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginTop: 6 }}>
{available.length === 0 && (
<span className="hint" style={{ fontSize: '0.78rem' }}>Load a model to list its classes.</span>
)}
{available.map(name => {
const on = selectedClasses.includes(name)
return (
<button
key={name}
className={`class-chip ${on ? 'active' : ''}`}
style={{ fontSize: '0.75rem', padding: '2px 8px', cursor: 'pointer', border: on ? '1px solid #c084fc' : '1px solid #3f3f46' }}
onClick={() => toggleClass(name)}
>
{on ? '✓ ' : ''}{name}
</button>
)
})}
</div>
</div>
<div style={{ marginBottom: 12 }}>
<div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Batches:</span>
<span className="row" style={{ gap: 8 }}>
<button className="btn btn-ghost" style={{ fontSize: '0.75rem', cursor: 'pointer' }}
onClick={() => setSelectedIds(batches.map(b => b.id))}>All</button>
<button className="btn btn-ghost" style={{ fontSize: '0.75rem', cursor: 'pointer' }}
onClick={() => setSelectedIds([])}>None</button>
</span>
</div>
<div style={{ maxHeight: 180, overflowY: 'auto', marginTop: 6, border: '1px solid #27272a', borderRadius: 6, padding: 6 }}>
{batches.map(b => (
<label key={b.id} className="row" style={{ gap: 6, alignItems: 'center', padding: '2px 0', cursor: 'pointer' }}>
<input
type="checkbox"
checked={selectedIds.includes(b.id)}
onChange={() => toggleBatch(b.id)}
style={{ cursor: 'pointer' }}
/>
<span className="mono" style={{ fontSize: '0.76rem' }}>
{b.date_label} / {b.batch_label}
</span>
<span className="hint" style={{ fontSize: '0.72rem' }}>
{b.frame_count || 0} frames · {b.status}
</span>
</label>
))}
</div>
</div>
{progress && (
<p className="hint" style={{ fontSize: '0.8rem', marginBottom: 8 }}>
Queueing {progress.done} / {progress.total}
{progress.failed ? ` · ${progress.failed} failed` : ''}
</p>
)}
<div className="row" style={{ justifyContent: 'flex-end', gap: 10, marginTop: 'auto', paddingTop: 10, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<button className="btn btn-ghost" onClick={onClose} disabled={busy} style={{ cursor: 'pointer' }}>Cancel</button>
<button className="btn btn-primary" disabled={!canStart} onClick={start} style={{ cursor: canStart ? 'pointer' : 'not-allowed' }}>
{busy ? 'Queueing…' : `Start on ${selectedIds.length} batch(es)`}
</button>
</div>
</div>
</div>
</div>
)
}
function Slider({ label, value, min, max, step, color, onChange, format }) {
return (
<div style={{ marginBottom: 10 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>{label}:</span>
<strong style={{ color, fontSize: '0.82rem' }}>{format(value)}</strong>
</div>
<input
type="range" min={min} max={max} step={step} value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
)
}
@@ -0,0 +1,131 @@
import { useEffect, useState } from 'react'
import { api } from '../api'
import { AlertIcon } from './Icons'
/* Where does this batch's approved frames go?
*
* Asked every merge rather than inferred, because "which dataset did that batch
* end up in" is not recoverable later without checking the database. */
export default function MergeTargetModal({ projectId, batchLabel, unreviewedBatches = 0,
unreviewedFrames = 0, onClose, onConfirm }) {
const [rows, setRows] = useState([])
const [choice, setChoice] = useState('new')
const [name, setName] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [acknowledged, setAcknowledged] = useState(false)
useEffect(() => {
api.listDatasets(projectId)
.then((payload) => {
setRows(payload.datasets)
if (payload.datasets.length) setChoice(String(payload.datasets[0].id))
})
.catch((exc) => setError(exc.message))
}, [projectId])
async function confirm() {
setBusy(true); setError('')
try {
await onConfirm(choice === 'new'
? { datasetName: name.trim() }
: { datasetId: Number(choice) })
onClose()
} catch (exc) { setError(exc.message); setBusy(false) }
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(event) => event.stopPropagation()} style={{ maxWidth: 480 }}>
<h2 style={{ marginTop: 0 }}>Merge {batchLabel}</h2>
<p className="muted" style={{ fontSize: '0.84rem', marginTop: 0 }}>
Approved frames are copied into the dataset you pick. The filter you just tuned is
applied as they are merged, and frozen onto the dataset — editing it later will not
rewrite this one.
</p>
{error && <p className="error-banner" style={{ marginBottom: 10 }}><AlertIcon size={14} /> {error}</p>}
{unreviewedBatches > 0 && (
<div
style={{
padding: '10px 12px', borderRadius: 6, marginBottom: 12, fontSize: '0.82rem',
background: 'rgba(250,204,21,0.08)', border: '1px solid rgba(250,204,21,0.3)',
}}
>
<strong style={{ display: 'block', marginBottom: 4 }}>
<AlertIcon size={13} /> {unreviewedBatches} batch(es) have not been reviewed.
</strong>
<span className="muted">
Their {unreviewedFrames.toLocaleString()} frame(s) will be approved exactly as
auto-annotation left them and merged — nobody has checked these boxes by hand.
Wrong boxes become training labels.
</span>
<label className="row" style={{ gap: 7, marginTop: 9, cursor: 'pointer', alignItems: 'center' }}>
<input
type="checkbox"
checked={acknowledged}
onChange={(event) => setAcknowledged(event.target.checked)}
style={{ cursor: 'pointer' }}
/>
<span>Merge them unreviewed</span>
</label>
</div>
)}
<div style={{ display: 'grid', gap: 8, margin: '14px 0' }}>
{rows.map((row) => (
<label key={row.id} className="row" style={{ gap: 8, cursor: 'pointer' }}>
<input
type="radio"
name="merge-target"
checked={choice === String(row.id)}
onChange={() => setChoice(String(row.id))}
style={{ cursor: 'pointer' }}
/>
<span>
<strong style={{ fontSize: '0.88rem' }}>{row.name}</strong>
<span className="mono" style={{ fontSize: '0.76rem', color: '#a1a1aa', marginLeft: 8 }}>
{row.total.toLocaleString()} images
</span>
</span>
</label>
))}
<label className="row" style={{ gap: 8, cursor: 'pointer' }}>
<input
type="radio"
name="merge-target"
checked={choice === 'new'}
onChange={() => setChoice('new')}
style={{ cursor: 'pointer' }}
/>
<span style={{ fontSize: '0.88rem' }}>Create a new dataset</span>
</label>
{choice === 'new' && (
<input
placeholder="Name (blank = today's date)"
value={name}
onChange={(event) => setName(event.target.value)}
style={{ marginLeft: 24 }}
/>
)}
</div>
<div className="row" style={{ gap: 8 }}>
<span className="spacer" />
<button className="btn" onClick={onClose}>Cancel</button>
<button
className="btn btn-primary"
onClick={confirm}
disabled={busy || (unreviewedBatches > 0 && !acknowledged)}
style={{ cursor: busy || (unreviewedBatches > 0 && !acknowledged) ? 'not-allowed' : 'pointer' }}
>
{busy ? 'Starting…' : 'Merge'}
</button>
</div>
</div>
</div>
)
}
+174
View File
@@ -0,0 +1,174 @@
import React from 'react'
/* The outlier filter (REQ-100). Three keep-ranges, one per signal; anything
* outside an enabled range is ignored.
*
* There is no new backend for this. A keep-range is stored as the `ignore`
* rules for its two tails, which the existing resolver already understands —
* so the sliders are just a friendlier face on machinery that was already
* there. `toRules`/`fromRules` are the whole translation. */
export const SIGNALS = [
{ key: 'score', label: 'Confidence', hint: 'How sure SAM3 was. Low scores are usually junk.',
min: 0, max: 1, step: 0.01, format: (v) => v.toFixed(2) },
{ key: 'area_pct', label: 'Box area', hint: 'Percent of the frame. Catches specks and full-frame boxes.',
min: 0, max: 100, step: 0.1, format: (v) => `${v.toFixed(1)}%` },
{ key: 'aspect', label: 'Aspect ratio', hint: 'Width ÷ height. Catches slivers.',
min: 0, max: 10, step: 0.1, format: (v) => v.toFixed(1) },
]
export const EMPTY = SIGNALS.reduce(
(acc, s) => ({ ...acc, [s.key]: { on: false, min: s.min, max: s.max } }), {})
/** Filter -> the `ignore` rules the resolver evaluates. */
export function toRules(filter) {
const rules = []
for (const signal of SIGNALS) {
const range = filter[signal.key]
if (!range?.on) continue
if (range.min > signal.min) {
rules.push({
name: `${signal.label} below ${signal.format(range.min)}`,
predicate: { [signal.key]: [null, range.min] },
action: 'ignore',
target_class: null,
})
}
if (range.max < signal.max) {
rules.push({
name: `${signal.label} above ${signal.format(range.max)}`,
predicate: { [signal.key]: [range.max, null] },
action: 'ignore',
target_class: null,
})
}
}
return rules
}
/** Stored rules -> filter, so a reload shows what was saved. */
export function fromRules(rules) {
const filter = JSON.parse(JSON.stringify(EMPTY))
for (const rule of rules || []) {
if (rule.action !== 'ignore') continue
for (const signal of SIGNALS) {
const bounds = rule.predicate?.[signal.key]
if (!bounds) continue
const [low, high] = bounds
filter[signal.key].on = true
// [null, x] is the low tail, so x is the minimum we keep.
if (low === null || low === undefined) filter[signal.key].min = high
else filter[signal.key].max = low
}
}
return filter
}
export default function OutlierFilter({ filter, onChange, counts, heldBack = 0, frameCount = 0,
dirty, saving, onSave, onReset }) {
function update(key, patch) {
onChange({ ...filter, [key]: { ...filter[key], ...patch } })
}
const total = (counts?.keep ?? 0) + (counts?.ignore ?? 0)
const dropped = counts?.ignore ?? 0
const droppedPct = total > 0 ? (dropped / total) * 100 : 0
return (
<div className="panel" style={{ padding: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, flexWrap: 'wrap', marginBottom: 4 }}>
<h2 style={{ fontSize: '1rem', margin: 0 }}>Outlier filter</h2>
<span className="hint" style={{ fontSize: '0.78rem' }}>
Boxes outside an enabled range are dropped at merge time. Nothing is rewritten.
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 18, marginTop: 16 }}>
{SIGNALS.map((signal) => {
const range = filter[signal.key]
return (
<div
key={signal.key}
style={{
padding: 14,
borderRadius: 8,
border: `1px solid ${range.on ? 'rgba(56,189,248,0.35)' : 'rgba(255,255,255,0.08)'}`,
background: range.on ? 'rgba(56,189,248,0.06)' : 'rgba(255,255,255,0.02)',
transition: 'background 150ms ease, border-color 150ms ease',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={range.on}
onChange={(e) => update(signal.key, { on: e.target.checked })}
style={{ cursor: 'pointer' }}
/>
<strong style={{ fontSize: '0.88rem' }}>{signal.label}</strong>
</label>
<p className="hint" style={{ fontSize: '0.75rem', margin: '6px 0 10px' }}>{signal.hint}</p>
<div style={{ opacity: range.on ? 1 : 0.4, pointerEvents: range.on ? 'auto' : 'none', transition: 'opacity 150ms ease' }}>
<div className="row" style={{ justifyContent: 'space-between', fontSize: '0.78rem', marginBottom: 4 }}>
<span className="faint">keep from</span>
<strong style={{ color: '#38bdf8' }}>{signal.format(range.min)}</strong>
</div>
<input
type="range" min={signal.min} max={signal.max} step={signal.step} value={range.min}
onChange={(e) => update(signal.key, { min: Math.min(Number(e.target.value), range.max) })}
style={{ width: '100%', cursor: 'pointer' }}
aria-label={`${signal.label} minimum`}
/>
<div className="row" style={{ justifyContent: 'space-between', fontSize: '0.78rem', margin: '8px 0 4px' }}>
<span className="faint">up to</span>
<strong style={{ color: '#38bdf8' }}>{signal.format(range.max)}</strong>
</div>
<input
type="range" min={signal.min} max={signal.max} step={signal.step} value={range.max}
onChange={(e) => update(signal.key, { max: Math.max(Number(e.target.value), range.min) })}
style={{ width: '100%', cursor: 'pointer' }}
aria-label={`${signal.label} maximum`}
/>
</div>
</div>
)
})}
</div>
<div
style={{
display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap',
marginTop: 16, padding: 12, borderRadius: 8,
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)',
}}
>
<span style={{ fontSize: '0.85rem' }}>
<strong style={{ color: '#4ade80' }}>{counts?.keep ?? 0}</strong>
<span className="faint"> kept</span>
</span>
<span style={{ fontSize: '0.85rem' }}>
<strong style={{ color: '#f87171' }}>{dropped}</strong>
<span className="faint"> dropped{total > 0 ? ` · ${droppedPct.toFixed(1)}%` : ''}</span>
</span>
<span style={{ fontSize: '0.85rem' }}>
<strong style={{ color: heldBack > 0 ? '#fbbf24' : undefined }}>{heldBack}</strong>
<span className="faint">
{' '}frame{heldBack === 1 ? '' : 's'} held back
{frameCount > 0 ? ` of ${frameCount}` : ''} — lost every box
</span>
</span>
{dirty && <span className="tag" style={{ fontSize: '0.75rem', color: '#fbbf24' }}>unsaved</span>}
<span style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button type="button" className="btn" onClick={onReset} disabled={saving}
style={{ cursor: 'pointer', fontSize: '0.8rem' }}>
Reset
</button>
<button type="button" className="btn btn-primary" onClick={onSave} disabled={!dirty || saving}
style={{ cursor: dirty && !saving ? 'pointer' : 'not-allowed', fontSize: '0.8rem' }}>
{saving ? 'Saving…' : 'Save filter'}
</button>
</span>
</div>
</div>
)
}
+3 -6
View File
@@ -11,7 +11,7 @@ export default function ReviewSidebar({
annotations, annotations,
selectedId, selectedId,
setSelectedId, setSelectedId,
removeSelected, removeAnnotation,
project, project,
jumpToNextAnnotated, jumpToNextAnnotated,
batchAnnotationCount, batchAnnotationCount,
@@ -75,7 +75,7 @@ export default function ReviewSidebar({
<li key={item.id} className={item.id === selectedId ? 'selected' : ''}> <li key={item.id} className={item.id === selectedId ? 'selected' : ''}>
<button className="shape-pick" onClick={() => setSelectedId(item.id)}> <button className="shape-pick" onClick={() => setSelectedId(item.id)}>
<span className="swatch" style={{ background: classColor(item.class_id) }} /> <span className="swatch" style={{ background: classColor(item.class_id) }} />
{project?.classes?.[item.class_id]?.name ?? `class ${item.class_id}`} {project?.classes?.find?.((c) => c.class_id === item.class_id)?.name ?? project?.classes?.[item.class_id]?.name ?? `class ${item.class_id}`}
<span className="faint mono"> <span className="faint mono">
{item.source === 'auto' ? item.score.toFixed(2) : 'manual'} {item.source === 'auto' ? item.score.toFixed(2) : 'manual'}
</span> </span>
@@ -83,10 +83,7 @@ export default function ReviewSidebar({
<button <button
className="btn btn-danger" className="btn btn-danger"
aria-label="Delete shape" aria-label="Delete shape"
onClick={() => { onClick={() => removeAnnotation(item.id)}
setSelectedId(item.id)
removeSelected()
}}
> >
<TrashIcon size={13} /> <TrashIcon size={13} />
</button> </button>
+11 -8
View File
@@ -15,33 +15,36 @@ function boxPoints(geometry) {
} }
export default function Shape({ export default function Shape({
annotation, width, height, scale, handle, selected, classes, annotation, width, height, scale, handle, selected, marked, readOnly, classes,
onStartMove, onStartResize, onStartVertex, onStartMidpoint, onDeleteVertex, onStartMove, onStartResize, onStartVertex, onStartMidpoint, onDeleteVertex,
}) { }) {
const colour = classColor(annotation.class_id) const colour = classColor(annotation.class_id)
const [x0, y0, x1, y1] = boxPoints(annotation.geometry) const [x0, y0, x1, y1] = boxPoints(annotation.geometry)
const isPolygon = annotation.geometry.type === 'polygon' const isPolygon = annotation.geometry.type === 'polygon'
const className = classes?.[annotation.class_id]?.name || `Class ${annotation.class_id + 1}` const className = classes?.find?.((c) => c.class_id === annotation.class_id)?.name || classes?.[annotation.class_id]?.name || `Class ${annotation.class_id + 1}`
// Select mode shows what is ticked, not what is editable — handles there
// would invite a drag that silently rewrites geometry.
const editable = selected && !readOnly
return ( return (
<g className={selected ? 'shape selected' : 'shape'}> <g className={['shape', selected ? 'selected' : '', marked ? 'marked' : ''].filter(Boolean).join(' ')}>
{isPolygon ? ( {isPolygon ? (
<polygon <polygon
points={annotation.geometry.points points={annotation.geometry.points
.map(([px, py]) => `${px * width},${py * height}`).join(' ')} .map(([px, py]) => `${px * width},${py * height}`).join(' ')}
stroke={colour} stroke={marked ? '#38bdf8' : colour}
onPointerDown={(event) => onStartMove(event, annotation)} onPointerDown={(event) => onStartMove(event, annotation)}
/> />
) : ( ) : (
<rect <rect
x={x0 * width} y={y0 * height} x={x0 * width} y={y0 * height}
width={(x1 - x0) * width} height={(y1 - y0) * height} width={(x1 - x0) * width} height={(y1 - y0) * height}
stroke={colour} stroke={marked ? '#38bdf8' : colour}
onPointerDown={(event) => onStartMove(event, annotation)} onPointerDown={(event) => onStartMove(event, annotation)}
/> />
)} )}
{selected && ( {editable && (
<g transform={`translate(${x0 * width}, ${Math.max(22 * scale, y0 * height - 6 * scale)})`}> <g transform={`translate(${x0 * width}, ${Math.max(22 * scale, y0 * height - 6 * scale)})`}>
<rect <rect
x="0" y={-16 * scale} x="0" y={-16 * scale}
@@ -64,7 +67,7 @@ export default function Shape({
</g> </g>
)} )}
{selected && !isPolygon && CORNERS.map(([corner, cx, cy]) => ( {editable && !isPolygon && CORNERS.map(([corner, cx, cy]) => (
<rect <rect
key={corner} key={corner}
className={`handle handle-${corner}`} className={`handle handle-${corner}`}
@@ -76,7 +79,7 @@ export default function Shape({
/> />
))} ))}
{selected && isPolygon && (() => { {editable && isPolygon && (() => {
const points = annotation.geometry.points const points = annotation.geometry.points
const vertices = points.map(([px, py], i) => ( const vertices = points.map(([px, py], i) => (
<circle <circle
+14 -2
View File
@@ -7,6 +7,14 @@ export default function ShortcutsPanel() {
<dt><kbd>Drag</kbd></dt> <dt><kbd>Drag</kbd></dt>
<dd>Add box / resize / move</dd> <dd>Add box / resize / move</dd>
</div> </div>
<div>
<dt><kbd>V</kbd></dt>
<dd>Draw ⇄ Select mode</dd>
</div>
<div>
<dt><kbd>Shift</kbd>+<kbd>Drag</kbd></dt>
<dd>Add to selection (Select mode)</dd>
</div>
<div> <div>
<dt><kbd>Hold S</kbd></dt> <dt><kbd>Hold S</kbd></dt>
<dd>SAM3 assisted shape</dd> <dd>SAM3 assisted shape</dd>
@@ -28,8 +36,12 @@ export default function ShortcutsPanel() {
<dd>Approve / reject</dd> <dd>Approve / reject</dd>
</div> </div>
<div> <div>
<dt><kbd>U</kbd></dt> <dt><kbd>U</kbd> <kbd>N</kbd></dt>
<dd>Next unreviewed</dd> <dd>Next unreviewed / next shape</dd>
</div>
<div>
<dt><kbd>C</kbd> <kbd>T</kbd></dt>
<dd>Copy prev / Track 5 frames</dd>
</div> </div>
</dl> </dl>
</div> </div>
+115 -65
View File
@@ -9,24 +9,25 @@ import {
BarChartIcon, BarChartIcon,
SunIcon, SunIcon,
MoonIcon, MoonIcon,
ChevronRightIcon, CpuIcon,
LayersIcon,
ZapIcon,
} from './Icons' } from './Icons'
export default function Sidebar({ route, currentProject, theme, onToggleTheme }) { export default function Sidebar({ route, currentProject, theme, onToggleTheme }) {
const [health, setHealth] = useState(null) const [health, setHealth] = useState(null)
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('sidebar_collapsed') === 'true')
useEffect(() => { useEffect(() => {
api.health().then(setHealth).catch(() => {}) let timer
const fetchHealth = () => {
api.health().then(setHealth).catch(() => {})
}
fetchHealth()
timer = setInterval(fetchHealth, 3000)
return () => clearInterval(timer)
}, []) }, [])
const toggleCollapse = () => {
setCollapsed((prev) => {
const next = !prev
localStorage.setItem('sidebar_collapsed', String(next))
return next
})
}
const pId = currentProject?.id || route.projectId || 1 const pId = currentProject?.id || route.projectId || 1
@@ -36,73 +37,122 @@ export default function Sidebar({ route, currentProject, theme, onToggleTheme })
} }
return ( return (
<div className={`roboflow-sidebar ${collapsed ? 'collapsed' : ''}`}> <header className="roboflow-topbar">
<div className="sidebar-header"> <div className="topbar-logo" onClick={(e) => handleNav(e, '/projects')} style={{ cursor: 'pointer' }}>
<div className="sidebar-logo">{collapsed ? 'DE' : 'Dataset Enrichment'}</div> <span style={{ fontWeight: 700, fontSize: '0.95rem', letterSpacing: '-0.01em', color: '#f4f4f5' }}>
<button className="sidebar-collapse-btn" onClick={toggleCollapse} title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}> Dataset Enrichment
{collapsed ? <ChevronRightIcon size={14} /> : '❮'} </span>
</button>
</div> </div>
<div className="sidebar-section"> <nav className="topbar-nav">
{!collapsed && <div className="sidebar-section-title">WORKSPACE</div>}
<a href="#/projects" onClick={(e) => handleNav(e, '/projects')} className={`sidebar-item ${route.name === 'projects' ? 'active' : ''}`} title="Projects">
<span className="sidebar-icon"><FolderIcon size={16} /></span>
{!collapsed && <span>Projects</span>}
</a>
</div>
<div className="sidebar-section">
{!collapsed && <div className="sidebar-section-title">DATA</div>}
<a href={`#/projects/${pId}`} onClick={(e) => handleNav(e, `/projects/${pId}`)} className={`sidebar-item ${route.name === 'library' ? 'active' : ''}`} title="Video Archive">
<span className="sidebar-icon"><DatabaseIcon size={16} /></span>
{!collapsed && <span>Video Archive</span>}
</a>
<a <a
href={route.batchId ? `#/batches/${route.batchId}` : `#/projects/${pId}`} href="#/projects"
onClick={(e) => handleNav(e, route.batchId ? `/batches/${route.batchId}` : `/projects/${pId}`)} onClick={(e) => handleNav(e, '/projects')}
className={`sidebar-item ${route.name === 'review' || route.name === 'trim' ? 'active' : ''}`} className={`topbar-item ${route.name === 'projects' ? 'active' : ''}`}
title="Annotate / Review"
> >
<span className="sidebar-icon"><TagIcon size={16} /></span> <FolderIcon size={15} />
{!collapsed && <span>Annotate / Review</span>} <span>Projects</span>
</a> </a>
<a href={`#/projects/${pId}/models`} onClick={(e) => handleNav(e, `/projects/${pId}/models`)} className={`sidebar-item ${route.name === 'models' ? 'active' : ''}`} title="Master Dataset">
<span className="sidebar-icon"><BarChartIcon size={16} /></span>
{!collapsed && <span>Master Dataset</span>}
</a>
</div>
<div className="sidebar-section"> <a
{!collapsed && <div className="sidebar-section-title">MODELS & TOOLS</div>} href={`#/projects/${pId}`}
<a href={`#/projects/${pId}/models`} onClick={(e) => handleNav(e, `/projects/${pId}/models`)} className={`sidebar-item ${route.name === 'models' ? 'active' : ''}`} title="Train & Select Engine"> onClick={(e) => handleNav(e, `/projects/${pId}`)}
<span className="sidebar-icon"><RocketIcon size={16} /></span> className={`topbar-item ${route.name === 'library' ? 'active' : ''}`}
{!collapsed && <span>Train & Select Engine</span>} >
<DatabaseIcon size={15} />
<span>Video Archive</span>
</a> </a>
<a href="#/sam3-playground" onClick={(e) => handleNav(e, '/sam3-playground')} className={`sidebar-item ${route.name === 'sam3-playground' ? 'active' : ''}`} title="SAM3 Playground">
<span className="sidebar-icon">🤖</span> <a
{!collapsed && <span>SAM3 Playground</span>} href={`#/projects/${pId}/batches`}
onClick={(e) => handleNav(e, `/projects/${pId}/batches`)}
className={`topbar-item ${route.name === 'batches' || route.name === 'review' ? 'active' : ''}`}
>
<TagIcon size={15} />
<span>Batches</span>
</a> </a>
</div>
<div className="sidebar-spacer" /> <a
href={`#/projects/${pId}/data-prep`}
onClick={(e) => handleNav(e, `/projects/${pId}/data-prep`)}
className={`topbar-item ${route.name === 'data-prep' ? 'active' : ''}`}
>
<BarChartIcon size={15} />
<span>Data Preparation</span>
</a>
<div className="sidebar-footer">
<button className="sidebar-theme-toggle" onClick={onToggleTheme} title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}>
{collapsed ? (theme === 'dark' ? <SunIcon size={14} /> : <MoonIcon size={14} />) : (theme === 'dark' ? 'Light Mode' : 'Dark Mode')} <a
</button> href={`#/projects/${pId}/datasets`}
{health && !collapsed && ( onClick={(e) => handleNav(e, `/projects/${pId}/datasets`)}
<div className="sidebar-health"> className={`topbar-item ${route.name === 'datasets' ? 'active' : ''}`}
<div className="health-item" title={health.gpu || ''} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}> >
<span style={{ float: 'left' }}>GPU:</span> <LayersIcon size={15} />
<span style={{ float: 'right' }}>{health.gpu ? health.gpu.replace('NVIDIA GeForce ', '').replace(' Laptop GPU', '') : 'N/A'}</span> <span>Datasets</span>
</div> </a>
<div className="health-item"><span>VRAM:</span> <span>{health.vram_free_gb ? `${health.vram_free_gb.toFixed(1)}GB` : 'N/A'}</span></div>
<div className="health-item"><span>SAM3:</span> <span>{health.sam3_ready ? 'Ready' : 'Not Ready'}</span></div> <a
<div className="health-item"><span>FFmpeg:</span> <span>{health.ffmpeg ? 'OK' : 'Error'}</span></div> href={`#/projects/${pId}/models`}
onClick={(e) => handleNav(e, `/projects/${pId}/models`)}
className={`topbar-item ${route.name === 'models' ? 'active' : ''}`}
>
<RocketIcon size={15} />
<span>Models & Training</span>
</a>
<a
href={`#/projects/${pId}/live-count`}
onClick={(e) => handleNav(e, `/projects/${pId}/live-count`)}
className={`topbar-item ${route.name === 'live-count' ? 'active' : ''}`}
>
<ZapIcon size={15} />
<span>Live Counting</span>
</a>
<a
href={`#/projects/${pId}/counting-bench`}
onClick={(e) => handleNav(e, `/projects/${pId}/counting-bench`)}
className={`topbar-item ${route.name === 'counting-bench' ? 'active' : ''}`}
>
<DatabaseIcon size={15} />
<span>Counting Accuracy</span>
</a>
<a
href="#/sam3-playground"
onClick={(e) => handleNav(e, '/sam3-playground')}
className={`topbar-item ${route.name === 'sam3-playground' ? 'active' : ''}`}
>
<CpuIcon size={15} />
<span>SAM3 Playground</span>
</a>
</nav>
<div className="topbar-right">
{health && (
<div className="topbar-health">
<span className="health-badge" title={health.gpu || ''}>
GPU: {health.gpu ? health.gpu.replace('NVIDIA GeForce ', '').replace(' Laptop GPU', '') : 'CPU'}
</span>
<span className="health-badge">
VRAM: {health.vram_free_gb ? `${health.vram_free_gb.toFixed(1)}GB` : 'N/A'}
</span>
<span className={`health-badge ${health.sam3_ready ? 'ready' : ''}`}>
SAM3: {health.sam3_ready ? 'Ready' : 'Off'}
</span>
</div> </div>
)} )}
<button
className="topbar-theme-toggle"
onClick={onToggleTheme}
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{theme === 'dark' ? <SunIcon size={15} /> : <MoonIcon size={15} />}
</button>
</div> </div>
</div> </header>
) )
} }
+178
View File
@@ -0,0 +1,178 @@
import React, { useCallback, useEffect, useState } from 'react'
import { api } from '../api'
/* A wall of cropped shapes (REQ-106). Judging "is this a half sack?" means
* looking at the object, not at the frame it came from.
*
* Paged from the server: a real batch holds ~85k shapes, so the client never
* gets to hold them all. Sorting happens server-side for the same reason —
* "worst 120 by score" has to be chosen from the whole batch, not from a page. */
const VERDICT_COLOR = { keep: '#4ade80', ignore: '#f87171', reclass: '#c084fc' }
const PAGE = 120
/** Same rule as the scatter: judge the crop by the sliders as they stand now,
* not by what was last saved. */
function droppedBy(filter, shape) {
if (!filter) return false
if (shape.source === 'manual') return shape.verdict === 'ignore'
for (const key of ['score', 'area_pct', 'aspect']) {
const range = filter[key]
if (!range?.on) continue
if (shape[key] < range.min || shape[key] > range.max) return true
}
return false
}
export default function TriageCropGrid({ batchIds, selectedIds, onSelect, classes, reloadKey,
filter = null }) {
const [sortBy, setSortBy] = useState('score')
const [shapes, setShapes] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const fetchPage = useCallback(
async (offset, replace) => {
if (!batchIds?.length) return
setLoading(true)
try {
const page = await api.triageShapes(batchIds, { sort: sortBy, offset, limit: PAGE })
setTotal(page.total)
setShapes((rows) => (replace ? page.shapes : [...rows, ...page.shapes]))
setError('')
} catch (exc) {
setError(exc.message)
} finally {
setLoading(false)
}
},
[String(batchIds), sortBy],
)
useEffect(() => { fetchPage(0, true) }, [fetchPage, reloadKey])
function toggle(id, event) {
if (event.shiftKey) {
onSelect(selectedIds.includes(id) ? selectedIds.filter((x) => x !== id) : [...selectedIds, id])
} else {
onSelect(selectedIds.includes(id) && selectedIds.length === 1 ? [] : [id])
}
}
const className = (id) => classes?.find((c) => c.class_id === id)?.name ?? `class ${id}`
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, gap: 12, flexWrap: 'wrap' }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>
Showing {shapes.length} of {total} · click to select, shift-click to add
</span>
<div style={{ display: 'flex', gap: 6 }}>
{[
{ key: 'score', label: 'lowest score' },
{ key: 'area_pct', label: 'smallest area' },
].map((mode) => (
<button
key={mode.key}
type="button"
className="tag"
aria-pressed={sortBy === mode.key}
onClick={() => setSortBy(mode.key)}
style={{
cursor: 'pointer',
padding: '4px 10px',
fontSize: '0.78rem',
background: sortBy === mode.key ? 'rgba(56,189,248,0.2)' : 'rgba(255,255,255,0.05)',
color: sortBy === mode.key ? '#38bdf8' : '#a1a1aa',
border: `1px solid ${sortBy === mode.key ? 'rgba(56,189,248,0.5)' : 'rgba(255,255,255,0.1)'}`,
borderRadius: 6,
transition: 'background 150ms ease, color 150ms ease',
}}
>
Sort by {mode.label}
</button>
))}
</div>
</div>
{error && <p className="error-banner" style={{ marginBottom: 10 }}>{error}</p>}
{total === 0 && !loading ? (
<p className="empty">No shapes on this batch — run auto-annotation first.</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(104px, 1fr))', gap: 8 }}>
{shapes.map((shape) => {
const chosen = selectedIds.includes(shape.id)
const dropped = droppedBy(filter, shape)
return (
<button
key={shape.id}
type="button"
aria-pressed={chosen}
onClick={(event) => toggle(shape.id, event)}
title={`${filter ? (dropped ? 'dropped' : 'kept') : shape.verdict} via ${shape.source}\nscore ${shape.score} · area ${shape.area_pct}% · ${className(shape.class_id)}`}
style={{
padding: 0,
border: `2px solid ${chosen ? '#38bdf8'
: filter ? (dropped ? '#f87171' : '#4ade80')
: VERDICT_COLOR[shape.verdict] ?? '#3f3f46'}`,
borderRadius: 6,
overflow: 'hidden',
background: '#000',
cursor: 'pointer',
position: 'relative',
transition: 'border-color 150ms ease, transform 150ms ease',
transform: chosen ? 'scale(0.96)' : 'none',
}}
>
<img
src={api.cropUrl(shape.id)}
alt={`${className(shape.class_id)} at score ${shape.score}`}
loading="lazy"
style={{
width: '100%', height: 92, objectFit: 'cover', display: 'block',
// Dropped crops stay readable but visibly out of the set.
opacity: dropped ? 0.35 : 1,
filter: dropped ? 'grayscale(0.8)' : 'none',
transition: 'opacity 150ms ease, filter 150ms ease',
}}
/>
<span
className="mono"
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.72)',
color: VERDICT_COLOR[shape.verdict] ?? '#e4e4e7',
fontSize: '0.68rem',
padding: '2px 4px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<span>{shape.score.toFixed(2)}</span>
<span>{shape.area_pct < 10 ? shape.area_pct.toFixed(2) : shape.area_pct.toFixed(0)}%</span>
</span>
</button>
)
})}
</div>
)}
{shapes.length < total && (
<button
type="button"
className="btn"
disabled={loading}
onClick={() => fetchPage(shapes.length, false)}
style={{ marginTop: 12, cursor: 'pointer', fontSize: '0.82rem' }}
>
{loading ? 'Loading…' : `Load ${Math.min(PAGE, total - shapes.length)} more`}
</button>
)}
</div>
)
}
+220
View File
@@ -0,0 +1,220 @@
import React, { useMemo, useRef, useState } from 'react'
/* Score x area, one dot per shape, drag a rectangle to select (REQ-106).
*
* Area is on a log scale: box sizes span three orders of magnitude, and on a
* linear axis every shape piles into the left edge where no boundary is
* visible. */
const VERDICT_COLOR = { keep: '#4ade80', ignore: '#f87171', reclass: '#c084fc' }
const W = 760
const H = 320
const PAD = { left: 52, right: 16, top: 16, bottom: 40 }
const PLOT_W = W - PAD.left - PAD.right
const PLOT_H = H - PAD.top - PAD.bottom
const MIN_AREA = 0.001
const logArea = (pct) => Math.log10(Math.max(MIN_AREA, pct))
const AREA_TICKS = [0.001, 0.01, 0.1, 1, 10, 100]
const LO = logArea(MIN_AREA)
const HI = logArea(100)
/** Would the pending filter drop this shape? Mirrors the server's resolver, but
* runs on the slider position rather than on what was saved — otherwise the
* plot sits still while the thresholds move, which is the one thing it is here
* to show. */
function droppedBy(filter, shape) {
if (!filter) return false
for (const key of ['score', 'area_pct', 'aspect']) {
const range = filter[key]
if (!range?.on) continue
const value = shape[key]
if (value < range.min || value > range.max) return true
}
return false
}
export default function TriageScatter({ shapes, selectedIds, onSelect, filter = null }) {
const svgRef = useRef(null)
const [drag, setDrag] = useState(null)
const points = useMemo(
() =>
shapes.map((shape) => ({
shape,
// A hand decision outranks the filter, so it keeps its own colour.
dropped: shape.source === 'manual' ? shape.verdict === 'ignore' : droppedBy(filter, shape),
manual: shape.source === 'manual',
x: PAD.left + ((logArea(shape.area_pct) - LO) / (HI - LO)) * PLOT_W,
y: PAD.top + (1 - shape.score) * PLOT_H,
})),
[shapes, filter],
)
// The cut lines, in plot coordinates. Only for the two axes actually drawn.
const bands = useMemo(() => {
if (!filter) return []
const out = []
const scoreY = (s) => PAD.top + (1 - Math.min(1, Math.max(0, s))) * PLOT_H
const areaX = (a) => PAD.left
+ ((logArea(Math.min(100, Math.max(MIN_AREA, a))) - LO) / (HI - LO)) * PLOT_W
if (filter.score?.on) {
if (filter.score.min > 0) out.push({ x: PAD.left, y: scoreY(filter.score.min), w: PLOT_W, h: PAD.top + PLOT_H - scoreY(filter.score.min) })
if (filter.score.max < 1) out.push({ x: PAD.left, y: PAD.top, w: PLOT_W, h: scoreY(filter.score.max) - PAD.top })
}
if (filter.area_pct?.on) {
if (filter.area_pct.min > 0) out.push({ x: PAD.left, y: PAD.top, w: areaX(filter.area_pct.min) - PAD.left, h: PLOT_H })
if (filter.area_pct.max < 100) out.push({ x: areaX(filter.area_pct.max), y: PAD.top, w: PAD.left + PLOT_W - areaX(filter.area_pct.max), h: PLOT_H })
}
return out.filter((b) => b.w > 0.5 && b.h > 0.5)
}, [filter])
function toLocal(event) {
const rect = svgRef.current.getBoundingClientRect()
return {
x: ((event.clientX - rect.left) / rect.width) * W,
y: ((event.clientY - rect.top) / rect.height) * H,
}
}
function onPointerDown(event) {
if (event.button !== 0) return
const origin = toLocal(event)
setDrag({ origin, current: origin })
svgRef.current.setPointerCapture(event.pointerId)
}
function onPointerMove(event) {
if (!drag) return
setDrag((state) => ({ ...state, current: toLocal(event) }))
}
function onPointerUp(event) {
if (!drag) return
svgRef.current.releasePointerCapture?.(event.pointerId)
const box = rectOf(drag)
// A click rather than a drag: clear the selection instead of selecting nothing.
if (box.width < 3 && box.height < 3) {
onSelect([])
setDrag(null)
return
}
const hit = points
.filter((p) => p.x >= box.x && p.x <= box.x + box.width && p.y >= box.y && p.y <= box.y + box.height)
.map((p) => p.shape.id)
onSelect(event.shiftKey ? [...new Set([...selectedIds, ...hit])] : hit)
setDrag(null)
}
const marquee = drag ? rectOf(drag) : null
return (
<div>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair', touchAction: 'none' }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
role="group"
aria-label="Shape score against box area. Drag to select shapes."
>
<rect x={PAD.left} y={PAD.top} width={PLOT_W} height={PLOT_H} fill="rgba(0,0,0,0.3)" stroke="rgba(255,255,255,0.08)" />
{AREA_TICKS.map((tick) => {
const x = PAD.left + ((logArea(tick) - LO) / (HI - LO)) * PLOT_W
return (
<g key={tick}>
<line x1={x} y1={PAD.top} x2={x} y2={PAD.top + PLOT_H} stroke="rgba(255,255,255,0.06)" />
<text x={x} y={H - 22} fill="#a1a1aa" fontSize="11" textAnchor="middle" fontFamily="monospace">
{tick < 1 ? tick : `${tick}%`}
</text>
</g>
)
})}
{[0, 0.25, 0.5, 0.75, 1].map((score) => {
const y = PAD.top + (1 - score) * PLOT_H
return (
<g key={score}>
<line x1={PAD.left} y1={y} x2={PAD.left + PLOT_W} y2={y} stroke="rgba(255,255,255,0.06)" />
<text x={PAD.left - 8} y={y + 4} fill="#a1a1aa" fontSize="11" textAnchor="end" fontFamily="monospace">
{score.toFixed(2)}
</text>
</g>
)
})}
<text x={PAD.left + PLOT_W / 2} y={H - 6} fill="#71717a" fontSize="11" textAnchor="middle">
Box area (% of frame, log scale)
</text>
<text x={14} y={PAD.top + PLOT_H / 2} fill="#71717a" fontSize="11" textAnchor="middle" transform={`rotate(-90 14 ${PAD.top + PLOT_H / 2})`}>
SAM3 score
</text>
{/* Shaded where the pending filter cuts, drawn under the dots. */}
{bands.map((band, index) => (
<rect
key={index}
x={band.x} y={band.y} width={band.w} height={band.h}
fill="rgba(248,113,113,0.13)"
stroke="rgba(248,113,113,0.45)"
strokeDasharray="4 3"
strokeWidth="1"
pointerEvents="none"
/>
))}
{points.map(({ shape, x, y, dropped, manual }) => {
const chosen = selectedIds.includes(shape.id)
const colour = filter
? (dropped ? VERDICT_COLOR.ignore : VERDICT_COLOR.keep)
: (VERDICT_COLOR[shape.verdict] ?? '#a1a1aa')
return (
<circle
key={shape.id}
cx={x}
cy={y}
r={chosen ? 4.5 : 2.8}
fill={colour}
fillOpacity={chosen ? 1 : dropped ? 0.75 : 0.55}
stroke={chosen ? '#f4f4f5' : manual ? '#fbbf24' : 'none'}
strokeWidth={chosen ? 1.5 : manual ? 1.2 : 0}
>
<title>{`${dropped ? 'dropped' : 'kept'}${manual ? ' (by hand)' : ''}\nscore ${shape.score} · area ${shape.area_pct}% · aspect ${shape.aspect}`}</title>
</circle>
)
})}
{marquee && (
<rect
x={marquee.x}
y={marquee.y}
width={marquee.width}
height={marquee.height}
fill="rgba(56,189,248,0.15)"
stroke="#38bdf8"
strokeWidth="1.5"
/>
)}
</svg>
<p className="hint" style={{ fontSize: '0.78rem', margin: '6px 0 0' }}>
Drag to select · shift-drag to add · click to clear.
{filter
? ' Red shading is what the current sliders drop; gold ring means decided by hand.'
: ' Colour is the resolved verdict.'}
</p>
</div>
)
}
function rectOf({ origin, current }) {
return {
x: Math.min(origin.x, current.x),
y: Math.min(origin.y, current.y),
width: Math.abs(current.x - origin.x),
height: Math.abs(current.y - origin.y),
}
}
+173
View File
@@ -0,0 +1,173 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { AlertIcon, DatabaseIcon, UploadIcon, ZapIcon } from '../components/Icons'
import { BatchList, ActiveJobsBanner } from './LibraryPage'
import MassAutoAnnotateModal from '../components/MassAutoAnnotateModal'
export default function BatchesPage({ projectId, onProject }) {
const [project, setProject] = useState(null)
const [batches, setBatches] = useState([])
const [jobs, setJobs] = useState([])
const [error, setError] = useState('')
const [importing, setImporting] = useState(false)
const [notice, setNotice] = useState('')
const [massOpen, setMassOpen] = useState(false)
const importInput = useRef(null)
async function handleImport(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
setImporting(true)
setError('')
setNotice('')
try {
const result = await api.importAnnotations(projectId, file)
setNotice(
`Restored "${result.batch_label}" — ${result.frames} frame(s), ${result.shapes} annotation(s)`
+ (result.skipped ? `, ${result.skipped} skipped` : ''),
)
loadBatches()
} catch (exc) {
setError(exc.message)
} finally {
setImporting(false)
}
}
const loadBatches = useCallback(() => {
api.listBatches(projectId).then((payload) => setBatches(payload.batches)).catch(() => {})
}, [projectId])
const loadJobs = useCallback(() => {
api.listJobs(projectId).then((payload) => setJobs(payload.jobs)).catch(() => {})
}, [projectId])
useEffect(() => {
let cancelled = false
setError('')
Promise.all([api.getProject(projectId), api.listBatches(projectId), api.listJobs(projectId)])
.then(([loadedProject, batchPayload, jobPayload]) => {
if (cancelled) return
setProject(loadedProject)
onProject?.(loadedProject)
setBatches(batchPayload.batches)
setJobs(jobPayload.jobs)
})
.catch((exc) => !cancelled && setError(exc.message))
return () => { cancelled = true }
}, [projectId])
const activeJobs = jobs.filter((j) => ['queued', 'running'].includes(j.status))
const prevActiveCount = useRef(0)
useEffect(() => {
if (prevActiveCount.current > 0 && activeJobs.length === 0) {
loadBatches()
loadJobs()
}
prevActiveCount.current = activeJobs.length
if (activeJobs.length === 0) return
const timer = setInterval(() => {
loadJobs()
loadBatches()
}, 2000)
return () => clearInterval(timer)
}, [activeJobs.length, loadBatches, loadJobs])
async function cancelJob(jobId) {
try {
await api.cancelJob(jobId)
loadJobs()
} catch (exc) {
setError(exc.message)
}
}
if (error) {
return <p className="error-banner"><AlertIcon size={14} /> {error}</p>
}
if (!project) return <p className="empty">Loading…</p>
return (
<>
<div className="page-head" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1>Batches ({batches.length})</h1>
<p className="muted mono">{project.name}</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input
ref={importInput}
type="file"
accept=".zip"
onChange={handleImport}
style={{ display: 'none' }}
/>
<button
className="btn"
disabled={importing}
onClick={() => importInput.current?.click()}
title="Restore a previously downloaded annotation zip as a new batch"
style={{ fontSize: '0.85rem', padding: '8px 16px', cursor: importing ? 'wait' : 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 6 }}
>
<UploadIcon size={16} /> {importing ? 'Restoring…' : 'Restore from .zip'}
</button>
{batches.length > 0 && (
<>
<a
className="btn"
href={api.exportUrl(projectId)}
download
title="Download every annotated frame in this project as a YOLO dataset — your own backup, no merge needed"
style={{ fontSize: '0.85rem', padding: '8px 16px', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 6 }}
>
<DatabaseIcon size={16} /> Download Annotations (.zip)
</a>
<button
className="btn btn-primary"
style={{ fontSize: '0.85rem', padding: '8px 16px', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 6 }}
onClick={() => setMassOpen(true)}
>
<ZapIcon size={16} /> Auto-Annotate All Batches ({batches.length})
</button>
</>
)}
</div>
</div>
{notice && (
<p className="hint" style={{ marginBottom: 12, color: '#4ade80' }}>{notice}</p>
)}
<ActiveJobsBanner jobs={activeJobs} onCancel={cancelJob} />
{batches.length === 0 ? (
<p className="empty">No extracted batches in this project yet. Go to Video Archive to trim frames into batches.</p>
) : (
<BatchList
project={project}
batches={batches}
activeJobs={activeJobs}
onChanged={() => { loadBatches(); loadJobs(); }}
onError={setError}
/>
)}
{massOpen && (
<MassAutoAnnotateModal
batches={batches}
project={project}
onClose={() => setMassOpen(false)}
onSuccess={(message) => {
setMassOpen(false)
setNotice(message)
loadBatches()
loadJobs()
}}
/>
)}
</>
)
}
+551
View File
@@ -0,0 +1,551 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { AlertIcon, CheckIcon, DatabaseIcon, ZapIcon } from '../components/Icons'
/* Counting accuracy (REQ-150…153).
*
* One row per archive video: what the counter predicted, what you actually
* counted, and the signed difference between them. The recount runs headless in
* a background job — no annotated frame, no JPEG encode — because the point here
* is the number, not watching it happen.
*
* Delta is deliberately signed. Counting 103 where the truth is 100 is a
* different failure from counting 97, and an absolute accuracy percentage hides
* which one you have. */
function fmtDelta(row) {
if (row.ground_truth == null || row.loading == null) return null
return row.loading - row.ground_truth
}
function deltaColour(delta) {
if (delta === 0) return '#4ade80'
return delta > 0 ? '#fbbf24' : '#f87171'
}
// The stored value is wall-clock text with no timezone, so it is shown as-is.
// Parsing it into a Date would re-apply the browser's offset and shift every
// recording by hours — which is exactly the bug this replaced.
function fmtClock(startedAt) {
if (!startedAt) return null
return String(startedAt).slice(11) || String(startedAt)
}
const BULAN = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des']
/* A cycle is 06:00 to 05:59 the next morning, so it always straddles midnight
* and therefore two calendar dates. It is labelled by the date it *starts*. */
function labelSiklus(day) {
if (!day) return 'Tanpa siklus'
const [y, m, d] = day.split('-').map(Number)
return `Siklus ${d} ${BULAN[m - 1]} ${y}`
}
function ringkasSiklus(rows) {
const scored = rows.filter((r) => r.ground_truth != null && r.loading != null)
const ai = scored.reduce((sum, r) => sum + r.loading, 0)
const gt = scored.reduce((sum, r) => sum + r.ground_truth, 0)
return {
videos: rows.length,
counted: rows.filter((r) => r.loading != null).length,
scored: scored.length,
ai,
gt,
delta: ai - gt,
accuracy: gt ? Math.round((1 - Math.abs(ai - gt) / gt) * 1000) / 10 : null,
// Rows whose start time is missing or was read with low confidence: their
// place in this cycle is a guess, so the cycle's totals are too.
flagged: rows.filter((r) => !r.started_at || !r.clock_trusted).length,
}
}
function fmtDuration(seconds) {
if (!seconds) return '—'
const m = Math.floor(seconds / 60)
return `${m}m`
}
export default function CountingBenchPage({ projectId, onProject }) {
const [rows, setRows] = useState([])
const [totals, setTotals] = useState(null)
const [models, setModels] = useState([])
const [modelPath, setModelPath] = useState('')
const [activeJob, setActiveJob] = useState(null)
const [scanJob, setScanJob] = useState(null)
const [unindexed, setUnindexed] = useState(0)
const [selected, setSelected] = useState([])
const [dateFilter, setDateFilter] = useState('')
const [recount, setRecount] = useState(false)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [draft, setDraft] = useState({})
const [collapsed, setCollapsed] = useState({})
const pollRef = useRef(null)
const load = useCallback(async () => {
try {
const payload = await api.countingBench(projectId)
setRows(payload.rows)
setTotals(payload.totals)
setActiveJob(payload.active_job)
setScanJob(payload.scan_job)
setUnindexed(payload.unindexed ?? 0)
} catch (exc) {
setError(exc.message)
}
}, [projectId])
useEffect(() => {
api.getProject(projectId).then((p) => onProject?.(p)).catch(() => {})
api.liveCountModels(projectId)
.then((payload) => {
setModels(payload.models)
if (payload.models.length) setModelPath(payload.models[0].path)
})
.catch((exc) => setError(exc.message))
load()
}, [projectId, load])
// Only while something is running — this table is otherwise static, and a
// poll per second against 222 rows is wasted work.
useEffect(() => {
if (!activeJob && !scanJob) {
clearInterval(pollRef.current)
return undefined
}
pollRef.current = setInterval(load, 2000)
return () => clearInterval(pollRef.current)
}, [activeJob, scanJob, load])
const dates = [...new Set(rows.map((r) => r.date_label))]
const visible = dateFilter ? rows.filter((r) => r.date_label === dateFilter) : rows
const allVisibleSelected = visible.length > 0
&& visible.every((r) => selected.includes(r.video_rel))
// Newest cycle first; inside a cycle the recordings run forwards in time, the
// order they were actually made in.
const cycles = [...new Set(visible.map((r) => r.date_label))]
.sort((a, b) => b.localeCompare(a))
.map((day) => ({
day,
rows: visible.filter((r) => r.date_label === day)
.sort((a, b) => (a.batch_no ?? 1e9) - (b.batch_no ?? 1e9)),
}))
// Only the newest cycle is open to begin with — 11 cycles expanded at once is
// the wall of rows this grouping exists to avoid.
const openState = collapsed
const isOpen = (day) => (day in openState ? openState[day] : day === cycles[0]?.day)
const toggleCycle = (day) => setCollapsed((c) => ({ ...c, [day]: !isOpen(day) }))
function toggle(rel) {
setSelected((current) => current.includes(rel)
? current.filter((r) => r !== rel)
: [...current, rel])
}
function toggleCycleRows(cycleRows) {
const rels = cycleRows.map((r) => r.video_rel)
const allIn = rels.every((rel) => selected.includes(rel))
setSelected((current) => allIn
? current.filter((rel) => !rels.includes(rel))
: [...new Set([...current, ...rels])])
}
function toggleAllVisible() {
setSelected((current) => allVisibleSelected
? current.filter((rel) => !visible.some((r) => r.video_rel === rel))
: [...new Set([...current, ...visible.map((r) => r.video_rel)])])
}
async function saveGroundTruth(rel, raw) {
const trimmed = String(raw ?? '').trim()
const value = trimmed === '' ? null : Number(trimmed)
if (value !== null && (!Number.isInteger(value) || value < 0)) {
setError('Ground truth must be a whole number of sacks')
return
}
try {
await api.setGroundTruth(projectId, rel, value)
setDraft((d) => { const next = { ...d }; delete next[rel]; return next })
await load()
} catch (exc) {
setError(exc.message)
}
}
async function run(allVideos) {
setBusy(true)
setError('')
try {
const job = await api.runCountingBench(projectId, {
model_path: modelPath,
video_rels: allVideos ? [] : selected,
all_videos: allVideos,
recount,
})
setActiveJob(job)
setSelected([])
} catch (exc) {
setError(exc.message)
} finally {
setBusy(false)
}
}
async function scanClock(rescan) {
setBusy(true)
setError('')
try {
setScanJob(await api.scanClock(projectId, rescan))
} catch (exc) {
setError(exc.message)
} finally {
setBusy(false)
}
}
async function saveClock(rel, raw) {
const value = String(raw ?? '').trim()
try {
await api.setClock(projectId, rel, value || null)
await load()
} catch (exc) {
setError(exc.message)
}
}
async function cancel() {
try {
await api.cancelJob(activeJob.id)
await load()
} catch (exc) { setError(exc.message) }
}
return (
<>
<div className="page-head" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<div>
<h1>Counting accuracy</h1>
<p className="muted">
What the counter predicted against what you actually counted, per video.
</p>
</div>
<a className="btn" href={`#/projects/${projectId}/live-count`}
style={{ fontSize: '0.85rem', padding: '8px 16px', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 6 }}>
<ZapIcon size={16} /> Live counting
</a>
</div>
{error && (
<p className="error-banner" style={{ marginBottom: 14 }}>
<AlertIcon size={14} /> {error}
<button type="button" className="btn" onClick={() => setError('')}
style={{ marginLeft: 10, cursor: 'pointer', fontSize: '0.75rem' }}>Dismiss</button>
</p>
)}
{totals && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))', gap: 12, marginBottom: 16 }}>
<Stat label="Counted" value={`${totals.counted_videos}/${totals.total_videos}`} accent="#a1a1aa" />
<Stat label="Scored" value={totals.scored_videos} accent="#a1a1aa"
hint="videos with a ground truth" />
<Stat label="Predicted in" value={totals.predicted} accent="#38bdf8" />
<Stat label="Ground truth" value={totals.ground_truth} accent="#c084fc" />
<Stat label="Delta" value={totals.delta > 0 ? `+${totals.delta}` : totals.delta}
accent={deltaColour(totals.delta)} />
<Stat label="Accuracy"
value={totals.accuracy == null ? '—' : `${totals.accuracy}%`}
accent="#4ade80"
hint={totals.accuracy == null ? 'fill in a ground truth' : 'over scored videos only'} />
</div>
)}
<div className="panel side-panel" style={{ marginBottom: 16, display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<select value={modelPath} onChange={(e) => setModelPath(e.target.value)}
disabled={Boolean(activeJob)}
style={{ padding: '6px 10px', fontSize: '0.82rem', background: 'rgba(0,0,0,0.4)', color: '#f4f4f5', border: '1px solid rgba(255,255,255,0.15)', borderRadius: 6, cursor: activeJob ? 'not-allowed' : 'pointer' }}>
{models.map((m) => <option key={m.path} value={m.path}>{m.label}</option>)}
</select>
<select value={dateFilter} onChange={(e) => setDateFilter(e.target.value)}
style={{ padding: '6px 10px', fontSize: '0.82rem', background: 'rgba(0,0,0,0.4)', color: '#f4f4f5', border: '1px solid rgba(255,255,255,0.15)', borderRadius: 6, cursor: 'pointer' }}>
<option value="">Semua siklus ({rows.length} video)</option>
{[...dates].sort((a, b) => b.localeCompare(a)).map((d) => (
<option key={d} value={d}>
{labelSiklus(d)} ({rows.filter((r) => r.date_label === d).length})
</option>
))}
</select>
<label className="hint" style={{ fontSize: '0.8rem', display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={recount} onChange={(e) => setRecount(e.target.checked)}
style={{ cursor: 'pointer' }} />
Recount videos that already have a result
</label>
<span className="spacer" style={{ flex: 1 }} />
<button className="btn btn-primary" disabled={busy || Boolean(activeJob) || selected.length === 0}
onClick={() => run(false)}
title="Count the ticked videos in the background"
style={{ fontSize: '0.82rem', padding: '6px 14px', cursor: selected.length === 0 || activeJob ? 'not-allowed' : 'pointer', borderRadius: 6 }}>
Count selected ({selected.length})
</button>
<button className="btn" disabled={busy || Boolean(scanJob)}
onClick={() => scanClock(unindexed === 0)}
title="Read the date and time burned into each recording, then regroup by the 06:00-to-06:00 working day"
style={{ fontSize: '0.82rem', padding: '6px 14px', cursor: scanJob ? 'not-allowed' : 'pointer', borderRadius: 6 }}>
{unindexed > 0 ? `Read timestamps (${unindexed} left)` : 'Re-read timestamps'}
</button>
<button className="btn" disabled={busy || Boolean(activeJob)}
onClick={() => run(true)}
title="Count every video in the archive — this takes hours"
style={{ fontSize: '0.82rem', padding: '6px 14px', cursor: activeJob ? 'not-allowed' : 'pointer', borderRadius: 6 }}>
<DatabaseIcon size={14} /> Count all ({rows.length})
</button>
</div>
{unindexed > 0 && !scanJob && (
<p className="hint" style={{ marginBottom: 14, fontSize: '0.8rem', color: '#fbbf24' }}>
<AlertIcon size={13} /> {unindexed} recording(s) have no verified start time yet, so they
are still grouped by their folder name. Folder names are not when a recording happened —
press <strong>Read timestamps</strong> to read the clock burned into each video and
regroup by the 06:00-to-06:00 working day.
</p>
)}
{scanJob && (
<div className="panel side-panel" style={{ marginBottom: 16, border: '1px solid rgba(56,189,248,0.4)' }}>
<div className="row" style={{ fontSize: '0.85rem', alignItems: 'center' }}>
<span className="dot ok" />
<strong>Reading timestamps from the archive…</strong>
<span className="mono" style={{ marginLeft: 10 }}>
{scanJob.progress}/{scanJob.total || '—'}
</span>
</div>
<div className="progress" style={{ margin: '8px 0 4px' }}>
<span style={{ width: `${scanJob.total ? (scanJob.progress / scanJob.total) * 100 : 5}%` }} />
</div>
<p className="hint mono" style={{ fontSize: '0.74rem', margin: 0 }}>{scanJob.message}</p>
</div>
)}
{activeJob && (
<div className="panel side-panel" style={{ marginBottom: 16, border: '1px solid rgba(168, 85, 247, 0.4)' }}>
<div className="row" style={{ fontSize: '0.85rem', alignItems: 'center' }}>
<span className="dot ok" />
<strong>Counting in the background…</strong>
<span className="mono" style={{ marginLeft: 10 }}>
{activeJob.progress}/{activeJob.total || '—'} video(s)
</span>
<span className="spacer" style={{ flex: 1 }} />
<button className="btn" onClick={cancel}
style={{ fontSize: '0.76rem', padding: '3px 10px', cursor: 'pointer' }}>
Cancel
</button>
</div>
<div className="progress" style={{ margin: '8px 0 4px' }}>
<span style={{ width: `${activeJob.total ? (activeJob.progress / activeJob.total) * 100 : 5}%` }} />
</div>
<p className="hint mono" style={{ fontSize: '0.74rem', margin: 0 }}>{activeJob.message}</p>
</div>
)}
<div className="panel table-wrap" style={{ padding: 0 }}>
<table className="video-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 34 }}>
<input type="checkbox" checked={allVisibleSelected} onChange={toggleAllVisible}
style={{ cursor: 'pointer' }} />
</th>
<th style={{ textAlign: 'right', width: 52 }}>#</th>
<th style={{ width: 120 }}>Recorded</th>
<th>File</th>
<th style={{ textAlign: 'right' }}>Length</th>
<th style={{ textAlign: 'right' }}>Counted in</th>
<th style={{ textAlign: 'right' }}>Counted out</th>
<th style={{ textAlign: 'right' }}>Net</th>
<th style={{ textAlign: 'right', width: 110 }}>Ground truth</th>
<th style={{ textAlign: 'right' }}>Delta</th>
</tr>
</thead>
{cycles.map(({ day, rows: cycleRows }) => {
const sum = ringkasSiklus(cycleRows)
const open = isOpen(day)
const allPicked = cycleRows.every((r) => selected.includes(r.video_rel))
return (
<tbody key={day}>
<tr
onClick={() => toggleCycle(day)}
style={{ cursor: 'pointer', background: 'rgba(255,255,255,0.045)',
borderTop: '1px solid rgba(255,255,255,0.10)' }}
>
<td onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={allPicked}
onChange={() => toggleCycleRows(cycleRows)}
title="Pilih semua video di siklus ini"
style={{ cursor: 'pointer' }} />
</td>
<td colSpan={9} style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.72rem', color: '#a1a1aa', width: 10 }}>
{open ? '▼' : '▶'}
</span>
<strong style={{ fontSize: '0.9rem' }}>{labelSiklus(day)}</strong>
<span className="hint" style={{ fontSize: '0.76rem' }}>
{sum.videos} video
{sum.counted < sum.videos && ` · ${sum.counted} terhitung`}
</span>
<span style={{ flex: 1 }} />
{sum.flagged > 0 && (
<span title={`${sum.flagged} video: waktu mulainya belum terbaca atau confidence-nya rendah, jadi posisinya di siklus ini belum pasti`}
style={{ fontSize: '0.72rem', color: '#fbbf24' }}>
{sum.flagged} perlu dicek
</span>
)}
{sum.scored === 0 ? (
<span className="hint mono" style={{ fontSize: '0.74rem' }}>
belum ada ground truth
</span>
) : (
<>
<span className="mono" style={{ fontSize: '0.76rem', color: '#38bdf8' }}>
AI {sum.ai.toLocaleString()}
</span>
<span className="mono" style={{ fontSize: '0.76rem', color: '#c084fc' }}>
GT {sum.gt.toLocaleString()}
</span>
<span className="mono" style={{ fontSize: '0.76rem', fontWeight: 600, color: deltaColour(sum.delta) }}>
Δ{sum.delta > 0 ? `+${sum.delta}` : sum.delta}
</span>
<span className="mono" style={{ fontSize: '0.76rem', color: '#4ade80' }}>
{sum.accuracy}%
</span>
{sum.scored < sum.videos && (
<span className="hint" style={{ fontSize: '0.7rem' }}>
dari {sum.scored} video
</span>
)}
</>
)}
</div>
</td>
</tr>
{open && cycleRows.map((row) => {
const delta = fmtDelta(row)
const value = draft[row.video_rel] ?? (row.ground_truth ?? '')
return (
<tr key={row.video_rel}>
<td>
<input type="checkbox" checked={selected.includes(row.video_rel)}
onChange={() => toggle(row.video_rel)} style={{ cursor: 'pointer' }} />
</td>
<td className="mono" style={{ textAlign: 'right', fontWeight: 600 }}>
{row.batch_no ?? <span style={{ color: '#52525b' }}>—</span>}
</td>
<td>
<input
type="text"
defaultValue={fmtClock(row.started_at) ?? ''}
placeholder="hh:mm:ss"
title={row.started_at
? `read from the video overlay (${row.clock_source})`
: (row.clock_error || 'not read yet — type the time shown in the video')}
onBlur={(e) => {
const v = e.target.value.trim()
if (!v) return
const day = row.working_day || row.folder_date
saveClock(row.video_rel, `${day} ${v.length === 5 ? v + ':00' : v}`)
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
style={{
width: 92, padding: '3px 6px', fontSize: '0.8rem', fontFamily: 'monospace',
background: 'rgba(0,0,0,0.35)',
color: row.clock_trusted ? '#f4f4f5' : '#fbbf24',
border: `1px solid ${row.clock_trusted ? 'rgba(255,255,255,0.15)' : 'rgba(250,204,21,0.45)'}`,
borderRadius: 4, cursor: 'text',
}}
/>
</td>
<td className="mono" style={{ fontSize: '0.78rem', color: '#a1a1aa' }}>
{row.folder_date}/{row.batch_label}
{row.error && (
<span title={row.error} style={{ color: '#f87171', marginLeft: 6, fontSize: '0.72rem' }}>
failed
</span>
)}
</td>
<td className="mono" style={{ textAlign: 'right', fontSize: '0.8rem', color: '#71717a' }}>
{fmtDuration(row.duration)}
</td>
<td className="mono" style={{ textAlign: 'right' }}>
{row.loading ?? <span style={{ color: '#52525b' }}>—</span>}
</td>
<td className="mono" style={{ textAlign: 'right', color: '#a1a1aa' }}>
{row.unloading ?? '—'}
</td>
<td className="mono" style={{ textAlign: 'right', color: '#a1a1aa' }}>
{row.net ?? '—'}
</td>
<td style={{ textAlign: 'right' }}>
<input
type="number"
min="0"
value={value}
placeholder="—"
onChange={(e) => setDraft((d) => ({ ...d, [row.video_rel]: e.target.value }))}
onBlur={(e) => saveGroundTruth(row.video_rel, e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
style={{
width: 82, textAlign: 'right', padding: '3px 6px', fontSize: '0.82rem',
background: 'rgba(0,0,0,0.35)', color: '#f4f4f5',
border: '1px solid rgba(255,255,255,0.15)', borderRadius: 4, cursor: 'text',
}}
/>
</td>
<td className="mono" style={{ textAlign: 'right', fontWeight: 600, color: delta == null ? '#52525b' : deltaColour(delta) }}>
{delta == null ? '—' : (delta > 0 ? `+${delta}` : delta)}
</td>
</tr>
)
})}
</tbody>
)
})}
</table>
{visible.length === 0 && (
<p className="empty" style={{ padding: 24 }}>
No videos in the archive for this filter.
</p>
)}
</div>
<p className="hint" style={{ fontSize: '0.76rem', marginTop: 12 }}>
One cycle runs 06:00 to 05:59 the next morning, so it always covers two calendar
dates; it is labelled by the date it starts. Cycles come from the timestamp burned
into each recording, not from its folder name — a recording made at 00:07 belongs to
the cycle that started the previous morning. The original file path is shown beside it and stays the file's
identity; nothing in the archive is moved or renamed. A start time in amber was read
with low confidence or not at all — type the time you can see in the video to correct
it. Counting itself runs headless and holds the GPU, so it queues behind training and
auto-annotation. Accuracy is computed only over videos where you filled in a ground
truth.
</p>
</>
)
}
function Stat({ label, value, accent, hint }) {
return (
<div className="panel side-panel" style={{ padding: '10px 14px' }}>
<div className="hint" style={{ fontSize: '0.72rem' }}>{label}</div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: accent, marginTop: 2 }}>{value}</div>
{hint && <div className="hint" style={{ fontSize: '0.68rem', marginTop: 2 }}>{hint}</div>}
</div>
)
}
+407
View File
@@ -0,0 +1,407 @@
import React, { useCallback, useEffect, useState } from 'react'
import { api } from '../api'
import { AlertIcon, CheckIcon, DatabaseIcon, RocketIcon } from '../components/Icons'
import AugmentationPanel from '../components/AugmentationPanel'
import MergeTargetModal from '../components/MergeTargetModal'
import OutlierFilter, { EMPTY, fromRules, toRules } from '../components/OutlierFilter'
import TriageCropGrid from '../components/TriageCropGrid'
import TriageScatter from '../components/TriageScatter'
/* Data Prep does two things and nothing else (REQ-100…105, REQ-110…113):
* throw out the boxes that are obviously junk, and decide how hard to augment
* what is left. Verdicts resolve as `manual > filter > keep`.
*
* It is also the merge gate (REQ-131): Batches sends a selection here with
* `?batches=1,2,3`, the filter is tuned against exactly those batches, and only
* "Confirm merge" creates the dataset — under a frozen copy of these rules
* (REQ-132). Arriving without a selection, the page still browses one batch. */
export default function DataPrepPage({ projectId, batchIds = [], onProject }) {
const [project, setProject] = useState(null)
const [projects, setProjects] = useState([])
const [batches, setBatches] = useState([])
const [batchId, setBatchId] = useState(null)
const [merging, setMerging] = useState(false)
const [merged, setMerged] = useState('')
const [view, setView] = useState(null)
const [filter, setFilter] = useState(EMPTY)
const [savedFilter, setSavedFilter] = useState(EMPTY)
const [simulation, setSimulation] = useState(null)
const [savingFilter, setSavingFilter] = useState(false)
const [augment, setAugment] = useState(null)
const [savedAugment, setSavedAugment] = useState(null)
const [savingAugment, setSavingAugment] = useState(false)
const [selectedIds, setSelectedIds] = useState([])
const [reloadKey, setReloadKey] = useState(0)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
;(async () => {
try {
const [loadedProject, loadedBatches, loadedRules, loadedAugment] = await Promise.all([
api.getProject(projectId),
api.listBatches(projectId),
api.triageRules(projectId),
api.getAugment(projectId),
])
if (cancelled) return
setProject(loadedProject)
onProject?.(loadedProject)
const annotated = (loadedBatches.batches ?? loadedBatches).filter((b) => b.annotation_count > 0)
setBatches(annotated)
setBatchId(annotated[0]?.id ?? null)
const restored = fromRules(loadedRules.rules)
setFilter(restored)
setSavedFilter(restored)
setAugment(loadedAugment.settings)
setSavedAugment(loadedAugment.settings)
} catch (exc) {
if (!cancelled) setError(exc.message)
}
})()
return () => { cancelled = true }
}, [projectId])
useEffect(() => {
let cancelled = false
api.listProjects().then((payload) => { if (!cancelled) setProjects(payload.projects ?? payload) }).catch(() => {})
return () => { cancelled = true }
}, [])
// The selection from Batches wins; without one the page browses a single batch.
const gating = batchIds.length > 0
const scope = gating ? batchIds : (batchId ? [batchId] : [])
const scopeKey = scope.join(',')
const loadBatch = useCallback(async () => {
if (!scopeKey) return
try {
setView(await api.triageSummary(scopeKey))
setSelectedIds([])
setReloadKey((n) => n + 1)
} catch (exc) {
setError(exc.message)
}
}, [scopeKey])
useEffect(() => { loadBatch() }, [loadBatch])
// Debounced: the point is watching the counts move as you drag a slider, and a
// request per pixel would just queue up behind itself.
useEffect(() => {
if (!scopeKey) return
let cancelled = false
const timer = setTimeout(() => {
api.triageSimulate(scopeKey, toRules(filter))
.then((data) => { if (!cancelled) setSimulation(data) })
.catch(() => { if (!cancelled) setSimulation(null) })
}, 300)
return () => { cancelled = true; clearTimeout(timer) }
}, [scopeKey, filter])
const filterDirty = JSON.stringify(filter) !== JSON.stringify(savedFilter)
const augmentDirty = augment && JSON.stringify(augment) !== JSON.stringify(savedAugment)
// Live while dragging; the saved counts once it settles. Both are server-side
// over every shape in the batch — the scatter plots a sample, these never are.
// `simulate` reports no manual tally, so that one always comes from the summary.
const counts = simulation?.counts ?? view?.counts ?? { keep: 0, ignore: 0 }
const manualCount = view?.counts?.manual ?? 0
const heldBack = simulation?.frames_held_back ?? view?.frames_held_back ?? 0
async function saveFilter() {
setSavingFilter(true)
try {
await api.saveTriageRules(projectId, toRules(filter))
setSavedFilter(filter)
await loadBatch()
} catch (exc) {
setError(exc.message)
} finally {
setSavingFilter(false)
}
}
async function saveAugment() {
setSavingAugment(true)
try {
const stored = await api.saveAugment(projectId, augment)
setAugment(stored.settings)
setSavedAugment(stored.settings)
} catch (exc) {
setError(exc.message)
} finally {
setSavingAugment(false)
}
}
// A batch nobody reviewed has zero approved frames, so a merge would copy
// nothing. The modal says outright that these get approved as they stand.
const unreviewedBatches = scope
.map((id) => batches.find((row) => row.id === id))
.filter((batch) => batch && (batch.review?.approved ?? 0) === 0)
async function confirmMerge(target) {
for (const batch of unreviewedBatches) {
await api.approveAllBatchFrames(batch.id)
}
const job = await api.approveBatch(scope, target)
setMerged(`Merge queued into dataset #${job.params.dataset_id}. `
+ 'It appears on the Datasets page when the job finishes.')
}
async function applyVerdict(verdict) {
if (selectedIds.length === 0) return
setBusy(true)
try {
await api.setTriageOverrides(selectedIds, verdict, null)
await loadBatch()
} catch (exc) {
setError(exc.message)
} finally {
setBusy(false)
}
}
async function clearDecisions() {
if (selectedIds.length === 0) return
setBusy(true)
try {
await api.clearTriageOverrides(selectedIds)
await loadBatch()
} catch (exc) {
setError(exc.message)
} finally {
setBusy(false)
}
}
if (error && !project) {
return <p className="error-banner"><AlertIcon size={14} /> {error}</p>
}
if (!project) return <p className="empty">Loading Data Preparation…</p>
return (
<>
<div className="page-head" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<div>
<h1>Data Preparation</h1>
<p className="muted mono">{project.name} · drop the junk, then set augmentation</p>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
{projects.length > 1 && (
<select
value={projectId ?? ''}
onChange={(event) => { window.location.hash = `#/projects/${event.target.value}/data-prep` }}
style={{ padding: '8px 12px', fontSize: '0.85rem', background: 'rgba(0,0,0,0.4)', color: '#f4f4f5', border: '1px solid rgba(255,255,255,0.15)', borderRadius: 6, cursor: 'pointer' }}
>
{projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
)}
<a
className="btn"
href={`#/projects/${projectId}/datasets`}
style={{ fontSize: '0.85rem', padding: '8px 16px', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 6 }}
>
<DatabaseIcon size={16} /> Datasets
</a>
</div>
</div>
{error && (
<p className="error-banner" style={{ marginBottom: 16 }}>
<AlertIcon size={14} /> {error}
<button type="button" className="btn" onClick={() => setError('')} style={{ marginLeft: 10, cursor: 'pointer', fontSize: '0.75rem' }}>Dismiss</button>
</p>
)}
<OutlierFilter
filter={filter}
onChange={setFilter}
counts={counts}
heldBack={heldBack}
frameCount={view?.frame_count ?? 0}
dirty={filterDirty}
saving={savingFilter}
onSave={saveFilter}
onReset={() => setFilter(EMPTY)}
/>
{augment && (
<AugmentationPanel
settings={augment}
onChange={setAugment}
dirty={augmentDirty}
saving={savingAugment}
onSave={saveAugment}
/>
)}
<div className="panel table-wrap" style={{ padding: 18, marginTop: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 14 }}>
<h2 style={{ fontSize: '1rem', margin: 0 }}>Check the boundary</h2>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{gating ? (
<span className="faint" style={{ fontSize: '0.8rem' }}>
{scope.length} batch(es) selected —{' '}
{scope.map((id) => {
const batch = batches.find((row) => row.id === id)
return batch ? `${batch.date_label}/${batch.batch_label}` : `#${id}`
}).join(', ')}
</span>
) : (
<>
<label className="faint" style={{ fontSize: '0.8rem' }}>Batch:</label>
<select
value={batchId ?? ''}
onChange={(event) => setBatchId(Number(event.target.value))}
style={{ padding: '4px 10px', fontSize: '0.82rem', background: 'rgba(0,0,0,0.4)', color: '#f4f4f5', border: '1px solid rgba(255,255,255,0.15)', borderRadius: 6, cursor: 'pointer' }}
>
{batches.length === 0 && <option value="">no annotated batches</option>}
{batches.map((batch) => (
<option key={batch.id} value={batch.id}>
{batch.date_label}/{batch.batch_label} · {batch.annotation_count} shapes
</option>
))}
</select>
</>
)}
{manualCount > 0 && (
<span className="tag" style={{ fontSize: '0.78rem' }}>{manualCount} decided by hand</span>
)}
</div>
</div>
{batches.length === 0 ? (
<p className="empty">No auto-annotated batches yet. Run auto-annotation on a batch first.</p>
) : (
<>
<TriageScatter
shapes={view?.sample ?? []}
selectedIds={selectedIds}
onSelect={setSelectedIds}
filter={filter}
/>
{view?.sampled && (
<p className="hint" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
Plotting {(view.sample ?? []).length} of {view.total_shapes} shapes — enough to see
where the clusters split. The counts above cover all of them.
</p>
)}
<div
style={{
display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap',
margin: '16px 0', padding: 12,
background: selectedIds.length ? 'rgba(56,189,248,0.08)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${selectedIds.length ? 'rgba(56,189,248,0.3)' : 'rgba(255,255,255,0.06)'}`,
borderRadius: 8,
transition: 'background 150ms ease, border-color 150ms ease',
}}
>
<strong style={{ fontSize: '0.85rem' }}>{selectedIds.length} selected</strong>
<span className="faint" style={{ fontSize: '0.78rem' }}>— decide by hand (outranks the filter):</span>
<button type="button" className="btn" disabled={busy || selectedIds.length === 0}
onClick={() => applyVerdict('keep')}
style={{ cursor: 'pointer', fontSize: '0.8rem', color: '#4ade80', borderColor: '#4ade8055' }}>
Keep
</button>
<button type="button" className="btn" disabled={busy || selectedIds.length === 0}
onClick={() => applyVerdict('ignore')}
style={{ cursor: 'pointer', fontSize: '0.8rem', color: '#f87171', borderColor: '#f8717155' }}>
Ignore
</button>
<button type="button" className="btn" disabled={busy || selectedIds.length === 0}
onClick={clearDecisions}
style={{ cursor: 'pointer', fontSize: '0.8rem', marginLeft: 'auto' }}>
Clear hand decisions
</button>
</div>
<TriageCropGrid
batchIds={scope}
reloadKey={reloadKey}
selectedIds={selectedIds}
onSelect={setSelectedIds}
classes={project.classes}
filter={filter}
/>
</>
)}
</div>
<div className="panel side-panel" style={{ marginTop: 18, border: '1px solid rgba(168, 85, 247, 0.3)' }}>
<h2 style={{ fontSize: '0.95rem', color: '#c084fc', marginBottom: 8 }}>
{gating ? 'Confirm the merge' : 'Ready to train'}
</h2>
<p className="hint" style={{ fontSize: '0.8rem', marginBottom: 14 }}>
{gating
? 'The dataset is cut now, from these rules. A dropped box leaves its image in the '
+ 'dataset; only a frame that loses every box is held back. The rules are frozen '
+ 'onto the dataset, so editing them later never rewrites it. Augmentation is read '
+ 'fresh at the start of every training run.'
: 'Nothing here is destructive on its own — the rules are applied when a selection of '
+ 'batches is merged into a dataset, from the Batches page. Augmentation is read '
+ 'fresh at the start of every training run.'}
</p>
{merged && (
<p className="hint" style={{ fontSize: '0.82rem', color: '#4ade80', marginBottom: 12 }}>
{merged}
</p>
)}
<div className="row" style={{ gap: 8 }}>
{gating && !merged && (
<button
type="button"
className="btn btn-primary"
disabled={filterDirty || scope.length === 0}
title={filterDirty
? 'Save the filter first — the merge uses the saved rules'
: 'Create the dataset from these batches under these rules'}
onClick={() => setMerging(true)}
style={{ padding: '8px 12px', fontSize: '0.85rem', cursor: filterDirty ? 'not-allowed' : 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 6 }}
>
<CheckIcon size={16} /> Confirm merge ({counts.keep + (counts.reclass ?? 0)} boxes,{' '}
{simulation?.frames_would_merge ?? view?.frames_would_merge ?? 0} frames)
</button>
)}
{!gating && (
<a className="btn" href={`#/projects/${projectId}/batches`}
style={{ padding: '8px 12px', fontSize: '0.85rem', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 6 }}>
<CheckIcon size={16} /> Pick batches to merge
</a>
)}
<a className="btn" href={`#/projects/${projectId}/datasets`}
style={{ padding: '8px 12px', fontSize: '0.85rem', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 6 }}>
<DatabaseIcon size={16} /> Datasets
</a>
<a className="btn" href={`#/projects/${projectId}/models`}
style={{ padding: '8px 12px', fontSize: '0.85rem', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 6 }}>
<RocketIcon size={16} /> Models &amp; training
</a>
</div>
</div>
{merging && (
<MergeTargetModal
projectId={projectId}
batchLabel={`${scope.length} batch(es)`}
unreviewedBatches={unreviewedBatches.length}
unreviewedFrames={unreviewedBatches.reduce((sum, b) => sum + (b.review?.pending ?? 0), 0)}
onClose={() => setMerging(false)}
onConfirm={confirmMerge}
/>
)}
</>
)
}
+218
View File
@@ -0,0 +1,218 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api'
import { AlertIcon } from '../components/Icons'
/* Named datasets: what a merge writes into, and what a training run picks from.
*
* The card is deliberately blunt about which batches are inside. "Dataset #3"
* tells you nothing six weeks later; "batch9 + batch12, 908 images, 186 val"
* is the thing you actually choose between. */
export default function DatasetsPage({ projectId }) {
const [rows, setRows] = useState([])
const [picked, setPicked] = useState([])
const [combined, setCombined] = useState(null)
const [name, setName] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const reload = useCallback(async () => {
try {
const payload = await api.listDatasets(projectId)
setRows(payload.datasets)
} catch (exc) { setError(exc.message) }
}, [projectId])
useEffect(() => { reload() }, [reload])
useEffect(() => {
if (picked.length === 0) { setCombined(null); return }
let cancelled = false
api.combinePreview(projectId, picked)
.then((payload) => !cancelled && setCombined(payload))
.catch(() => {})
return () => { cancelled = true }
}, [projectId, picked])
function toggle(id) {
setPicked((current) =>
current.includes(id) ? current.filter((row) => row !== id) : [...current, id])
}
async function create() {
setBusy(true); setError('')
try {
await api.createDataset(projectId, { name })
setName('')
await reload()
} catch (exc) { setError(exc.message) } finally { setBusy(false) }
}
async function remove(row) {
if (!window.confirm(
`Delete “${row.name}” and its ${row.total} image file(s)?\n\n` +
'The frames and annotations stay — only this dataset\'s copy of them goes.')) return
try {
await api.deleteDataset(row.id)
setPicked((current) => current.filter((id) => id !== row.id))
await reload()
} catch (exc) { setError(exc.message) }
}
async function resync(row) {
if (!window.confirm(
`Rebuild the labels in “${row.name}” from the current annotations and the current ` +
'triage rules?\n\nThis is the only thing that changes an already-merged dataset. Its ' +
'rule version is re-stamped, so a model trained on it before this point was measured ' +
'on different labels.')) return
try {
const report = await api.resyncDataset(row.id)
setError('')
window.alert(
`${report.labels_written} label file(s) rewritten, now at rule version ${report.rule_version}.` +
(report.frames_left_alone
? `\n\n${report.frames_left_alone} frame(s) would have lost every box and were left as they were.`
: ''))
await reload()
} catch (exc) { setError(exc.message) }
}
async function rename(row) {
const next = window.prompt('Rename this dataset', row.name)
if (next == null || next.trim() === row.name) return
try {
await api.renameDataset(row.id, { name: next.trim() })
await reload()
} catch (exc) { setError(exc.message) }
}
const total = rows.reduce((sum, row) => sum + row.total, 0)
return (
<>
<div className="page-head">
<div>
<h1>Datasets</h1>
<p className="muted">
{rows.length} dataset{rows.length === 1 ? '' : 's'} · {total.toLocaleString()} image
{total === 1 ? '' : 's'} in total
</p>
</div>
<span className="spacer" />
<input
placeholder="New dataset name (optional)"
value={name}
onChange={(event) => setName(event.target.value)}
style={{ width: 240, marginRight: 8 }}
/>
<button className="btn btn-primary" onClick={create} disabled={busy}>
Create empty dataset
</button>
</div>
{error && <p className="error-banner" style={{ marginBottom: 12 }}><AlertIcon size={14} /> {error}</p>}
<p className="hint" style={{ margin: '0 0 14px', fontSize: '0.82rem', lineHeight: 1.5 }}>
A dataset is what <strong>Approve batch</strong> writes into, and triage rules are applied
at that moment — after which the dataset is frozen. Editing rules in Data Preparation does
not reach back into it; merge the batch again into a new dataset to compare rule sets, or
press <strong>Resync</strong> to deliberately rebuild one in place. A frame's train/val
split is decided once per project and every dataset inherits it — otherwise a base-vs-new
mAP would be measured on images the new model had already been trained on.
</p>
{picked.length > 0 && combined && (
<div className="panel side-panel" style={{ marginBottom: 14, border: '1px solid rgba(56, 189, 248, 0.4)' }}>
<div className="row" style={{ fontSize: '0.85rem', flexWrap: 'wrap', gap: 8 }}>
<strong style={{ color: '#38bdf8' }}>{picked.length} selected</strong>
<span className="mono">
{combined.total_unique.toLocaleString()} unique images ·
{' '}{combined.splits.train.toLocaleString()} train / {combined.splits.val.toLocaleString()} val
</span>
{combined.shared_frames > 0 && (
<span className="muted" style={{ fontSize: '0.8rem' }}>
{combined.shared_frames.toLocaleString()} frame(s) appear in more than one — counted
once, taking the labels from the newest dataset.
</span>
)}
</div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
{rows.map((row) => {
const on = picked.includes(row.id)
return (
<div
key={row.id}
className="panel"
style={{
padding: 14,
border: `1px solid ${on ? 'rgba(56, 189, 248, 0.6)' : 'rgba(255,255,255,0.08)'}`,
background: on ? 'rgba(56, 189, 248, 0.06)' : undefined,
}}
>
<label className="row" style={{ gap: 8, cursor: 'pointer', alignItems: 'flex-start' }}>
<input type="checkbox" checked={on} onChange={() => toggle(row.id)} style={{ cursor: 'pointer', marginTop: 3 }} />
<span>
<strong style={{ fontSize: '0.92rem' }}>{row.name}</strong>
<span className="mono" style={{ display: 'block', fontSize: '0.78rem', color: '#a1a1aa', marginTop: 2 }}>
{row.total.toLocaleString()} images · {row.splits.train.toLocaleString()} train
{' '}/ {row.splits.val.toLocaleString()} val
</span>
</span>
</label>
{row.note && <p className="muted" style={{ fontSize: '0.78rem', margin: '8px 0 0' }}>{row.note}</p>}
<div style={{ marginTop: 10, fontSize: '0.76rem', color: '#a1a1aa', lineHeight: 1.6 }}>
{row.batches.length === 0
? <em>Empty — nothing merged into it yet</em>
: row.batches.map((b) => (
<span key={b.id} style={{ display: 'inline-block', marginRight: 10 }}>
{b.batch_label} <span className="mono">({b.images})</span>
</span>
))}
</div>
{row.rule_version && (
<p className="mono" style={{ fontSize: '0.72rem', color: '#71717a', margin: '8px 0 0' }}>
triage rules {row.rule_version}
</p>
)}
<div className="row" style={{ gap: 6, marginTop: 12 }}>
<button className="btn" style={{ padding: '2px 8px', fontSize: '0.76rem' }} onClick={() => rename(row)}>
Rename
</button>
<button
className="btn"
style={{ padding: '2px 8px', fontSize: '0.76rem' }}
onClick={() => resync(row)}
disabled={row.total === 0}
title="Rebuild this dataset's labels from current annotations and current triage rules"
>
Resync
</button>
<a className="btn" style={{ padding: '2px 8px', fontSize: '0.76rem' }}
href={api.datasetZipUrl(row.id)} download>
Download
</a>
<span className="spacer" />
<button className="btn btn-danger" style={{ padding: '2px 8px', fontSize: '0.76rem' }} onClick={() => remove(row)}>
Delete
</button>
</div>
</div>
)
})}
</div>
{rows.length === 0 && (
<p className="empty" style={{ color: 'var(--text-muted)' }}>
No dataset yet. Approve a batch in Batches, or create an empty one to merge into.
</p>
)}
</>
)
}
+315 -472
View File
@@ -1,7 +1,26 @@
import AutoAnnotateModal from '../components/AutoAnnotateModal'
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { api, formatDuration } from '../api' import { api, formatDuration } from '../api'
import { navigate } from '../App' import { navigate } from '../App'
import { AlertIcon } from '../components/Icons' import { AlertIcon, CheckIcon, FolderIcon, ScissorsIcon, SearchIcon } from '../components/Icons'
const BULAN = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des']
/* A cycle runs 06:00 to 05:59 the next morning, so it always spans two calendar
* dates. It is named after the date it starts on (REQ-165). */
function labelSiklus(day) {
if (!day) return 'Tanpa siklus'
const [y, m, d] = String(day).split('-').map(Number)
return `Siklus ${d} ${BULAN[m - 1]} ${y}`
}
function jamRekam(startedAt) {
if (!startedAt) return null
return String(startedAt).slice(11, 19)
}
function megabytes(bytes) { function megabytes(bytes) {
if (!bytes) return '—' if (!bytes) return '—'
@@ -10,10 +29,9 @@ function megabytes(bytes) {
if (mb >= 10) return `${Math.round(mb)} MB` if (mb >= 10) return `${Math.round(mb)} MB`
if (mb >= 1) return `${mb.toFixed(1)} MB` if (mb >= 1) return `${mb.toFixed(1)} MB`
return `${Math.round(bytes / 1024)} KB` return `${Math.round(bytes / 1024)} KB`
} }export function ActiveJobsBanner({ jobs, onCancel }) {
function ActiveJobsBanner({ jobs, onCancel }) {
if (!jobs || jobs.length === 0) return null if (!jobs || jobs.length === 0) return null
return ( return (
<div className="panel side-panel" style={{ marginBottom: 16, border: '1px solid rgba(168, 85, 247, 0.4)', background: 'rgba(24, 24, 27, 0.8)' }}> <div className="panel side-panel" style={{ marginBottom: 16, border: '1px solid rgba(168, 85, 247, 0.4)', background: 'rgba(24, 24, 27, 0.8)' }}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '0.95rem', color: '#c084fc' }}>Active System Tasks ({jobs.length})</h3> <h3 style={{ margin: '0 0 8px 0', fontSize: '0.95rem', color: '#c084fc' }}>Active System Tasks ({jobs.length})</h3>
@@ -41,22 +59,15 @@ function ActiveJobsBanner({ jobs, onCancel }) {
) )
} }
function BatchList({ project, batches, activeJobs, onChanged, onError }) { export function BatchList({ project, batches, activeJobs, onChanged, onError }) {
const [busyId, setBusyId] = useState(null) const [busyId, setBusyId] = useState(null)
const [selectedIds, setSelectedIds] = useState([])
const [appendChoiceBatch, setAppendChoiceBatch] = useState(null) const [appendChoiceBatch, setAppendChoiceBatch] = useState(null)
const [appendModalState, setAppendModalState] = useState(null) const [autoAnnotateConfig, setAutoAnnotateConfig] = useState(null)
const [sam3AppendState, setSam3AppendState] = useState(null)
const [customPromptInput, setCustomPromptInput] = useState('')
const [baseModelModalState, setBaseModelModalState] = useState(null)
function openBaseModelAutolabelModal(batch) { function openBaseModelAutolabelModal(batch) {
const projectClasses = project?.classes?.map((c) => c.name) || [] setAutoAnnotateConfig({ batch, project, engine: 'base_model' })
setBaseModelModalState({
batch,
selectedClasses: [...projectClasses],
threshold: 0.35,
iouThreshold: 0.8,
})
} }
async function resetAutoAnnotations(batch) { async function resetAutoAnnotations(batch) {
@@ -83,14 +94,12 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
setBusyId(batch.id) setBusyId(batch.id)
try { try {
const info = await api.inspectModel(file) const info = await api.inspectModel(file)
setAppendModalState({ setAutoAnnotateConfig({
batch, batch,
file, project,
filename: info.filename, engine: 'custom',
classes: info.classes || [], customModelStagedPath: info.staged_path,
selectedClasses: info.classes || [], customModelClasses: info.classes || []
threshold: 0.35,
iouThreshold: 0.8,
}) })
} catch (err) { } catch (err) {
onError(err.message) onError(err.message)
@@ -103,14 +112,7 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
function openSam3AppendModal(batch) { function openSam3AppendModal(batch) {
setAppendChoiceBatch(null) setAppendChoiceBatch(null)
const projectClasses = project?.classes?.map((c) => c.name) || [] setAutoAnnotateConfig({ batch, project, engine: 'sam3' })
setSam3AppendState({
batch,
selectedClasses: [...projectClasses],
threshold: 0.35,
iouThreshold: 0.8,
})
setCustomPromptInput('')
} }
async function deleteBatch(batch) { async function deleteBatch(batch) {
@@ -140,13 +142,63 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
} }
} }
// A batch needs frames to have anything to merge. It does not need *reviewed*
// frames: gating on `review.approved > 0` made the button permanently dead for
// an auto-annotated project, because auto-annotation never approves anything —
// that is the review stage's job. Unreviewed batches are selectable; Data Prep
// is where their frames get approved as-is, and it says so.
const approvable = batches.filter((batch) => (batch.frame_count ?? 0) > 0)
const approvableSelected = selectedIds.filter((id) =>
approvable.some((batch) => batch.id === id))
// Merging is no longer possible from here: the selection goes to Data Prep,
// which is the gate that creates the dataset (REQ-131).
function prepareSelected() {
if (approvableSelected.length === 0) return
navigate(`/projects/${project.id}/data-prep?batches=${approvableSelected.join(',')}`)
}
if (batches.length === 0) return null if (batches.length === 0) return null
return ( return (
<div className="panel table-wrap" style={{ marginTop: 16 }}> <>
<table className="video-table"> <div className="panel table-wrap" style={{ marginTop: 16, padding: '16px' }}>
<thead> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14, padding: '0 4px' }}>
<h3 style={{ margin: 0, fontSize: '1rem', color: '#e4e4e7' }}>Batches ({batches.length})</h3>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
{selectedIds.length > 0 && (
<span className="faint" style={{ fontSize: '0.8rem' }}>
{approvableSelected.length} of {selectedIds.length} selected can be merged
</span>
)}
<button
className="btn btn-primary"
disabled={approvableSelected.length === 0}
title="Tune the filter for these batches in Data Prep, then merge them into one dataset"
style={{ fontSize: '0.85rem', padding: '6px 14px', cursor: approvableSelected.length === 0 ? 'not-allowed' : 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, borderRadius: 6 }}
onClick={prepareSelected}
>
<CheckIcon size={14} />
Prepare &amp; Merge Selected ({approvableSelected.length})
</button>
</div>
</div>
<table className="video-table">
<thead>
<tr> <tr>
<th style={{ width: 34 }}>
<input
type="checkbox"
aria-label="Select every batch that can be merged"
checked={approvable.length > 0 && approvableSelected.length === approvable.length}
onChange={(event) =>
setSelectedIds(event.target.checked ? approvable.map((b) => b.id) : [])
}
disabled={approvable.length === 0}
style={{ cursor: approvable.length === 0 ? 'not-allowed' : 'pointer' }}
/>
</th>
<th>Batch</th><th>Range</th><th>Frames</th><th>Reviewed</th> <th>Batch</th><th>Range</th><th>Frames</th><th>Reviewed</th>
<th>Shapes</th><th>Status</th><th /> <th>Shapes</th><th>Status</th><th />
</tr> </tr>
@@ -159,6 +211,26 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
return ( return (
<React.Fragment key={batch.id}> <React.Fragment key={batch.id}>
<tr> <tr>
<td>
<input
type="checkbox"
aria-label={`Select ${batch.batch_label}`}
checked={selectedIds.includes(batch.id)}
disabled={(batch.frame_count ?? 0) === 0}
title={(batch.frame_count ?? 0) === 0
? 'This batch has no extracted frames'
: (batch.review?.approved ?? 0) > 0
? `${batch.review.approved} approved frame(s) would be merged`
: `Not reviewed — its ${batch.review?.pending ?? batch.frame_count} frame(s) would be approved as-is and merged`}
onChange={(event) =>
setSelectedIds((current) =>
event.target.checked
? [...current, batch.id]
: current.filter((id) => id !== batch.id))
}
style={{ cursor: (batch.frame_count ?? 0) === 0 ? 'not-allowed' : 'pointer' }}
/>
</td>
<td> <td>
<span style={{ cursor: 'pointer', borderBottom: '1px dashed rgba(255,255,255,0.3)' }} onClick={() => editBatch(batch)} title="Click to rename batch"> <span style={{ cursor: 'pointer', borderBottom: '1px dashed rgba(255,255,255,0.3)' }} onClick={() => editBatch(batch)} title="Click to rename batch">
{batch.date_label} · {batch.batch_label} {batch.date_label} · {batch.batch_label}
@@ -178,14 +250,9 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
<td> <td>
<div className="row" style={{ gap: 6 }}> <div className="row" style={{ gap: 6 }}>
<button className="btn btn-primary" disabled={isProcessing || batch.frame_count === 0} <button className="btn btn-primary" disabled={isProcessing || batch.frame_count === 0}
title="Annotate using project base model with target class selection" title="Auto-annotate using selected engine"
onClick={() => openBaseModelAutolabelModal(batch)}>
{batchJob?.type === 'autolabel' ? 'Processing…' : 'Auto-annotate'}
</button>
<button className="btn" disabled={isProcessing || batch.frame_count === 0}
title="Append new class detections using custom YOLO model or SAM3 text prompts"
onClick={() => setAppendChoiceBatch(batch)}> onClick={() => setAppendChoiceBatch(batch)}>
+ Append {batchJob?.type === 'autolabel' ? 'Processing…' : 'Auto-annotate'}
</button> </button>
<button className="btn" disabled={isProcessing || batch.annotation_count === 0} <button className="btn" disabled={isProcessing || batch.annotation_count === 0}
title="Clear all auto-generated shapes and reset frame review statuses to pending" title="Clear all auto-generated shapes and reset frame review statuses to pending"
@@ -208,6 +275,7 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
})} })}
</tbody> </tbody>
</table> </table>
</div>
{/* Choice Modal: SAM3 vs Custom YOLO */} {/* Choice Modal: SAM3 vs Custom YOLO */}
{appendChoiceBatch && ( {appendChoiceBatch && (
@@ -217,12 +285,31 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
justifyContent: 'center', zIndex: 9999 justifyContent: 'center', zIndex: 9999
}}> }}>
<div className="panel" style={{ width: 480, maxWidth: '92vw', padding: 22, border: '1px solid rgba(168, 85, 247, 0.4)', background: '#18181b' }}> <div className="panel" style={{ width: 480, maxWidth: '92vw', padding: 22, border: '1px solid rgba(168, 85, 247, 0.4)', background: '#18181b' }}>
<h3 style={{ margin: '0 0 6px 0', color: '#c084fc', fontSize: '1.05rem' }}>Select Engine to Append Annotations</h3> <h3 style={{ margin: '0 0 6px 0', color: '#c084fc', fontSize: '1.05rem' }}>Select Engine to Auto-annotate</h3>
<p className="hint" style={{ fontSize: '0.82rem', marginBottom: 18 }}> <p className="hint" style={{ fontSize: '0.82rem', marginBottom: 18 }}>
Choose how you want to detect and append new classes to batch <strong>{appendChoiceBatch.batch_label}</strong>: Choose how you want to detect objects in batch <strong>{appendChoiceBatch.batch_label}</strong>. (All detections will be appended):
</p> </p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12, marginBottom: 20 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12, marginBottom: 20 }}>
{/* Base Model Card */}
{project.base_model_path && (
<div
style={{
padding: 14, background: 'rgba(24,24,27,0.9)', borderRadius: 8,
border: '1px solid rgba(52, 211, 153, 0.4)', cursor: 'pointer',
transition: 'all 150ms ease'
}}
onClick={() => { openBaseModelAutolabelModal(appendChoiceBatch); setAppendChoiceBatch(null); }}
>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<h4 style={{ margin: 0, color: '#34d399', fontSize: '0.92rem' }}>📦 Project Base Model</h4>
<span className="btn btn-ghost" style={{ padding: '2px 8px', fontSize: '0.75rem' }}>Select &gt;</span>
</div>
<p className="hint" style={{ margin: 0, fontSize: '0.78rem' }}>
Detect objects using the primary base model trained for this project.
</p>
</div>
)}
{/* SAM3 Card */} {/* SAM3 Card */}
<div <div
style={{ style={{
@@ -267,387 +354,32 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
</div> </div>
)} )}
{/* SAM3 Append Modal */} {autoAnnotateConfig && (
{sam3AppendState && ( <AutoAnnotateModal
<div style={{ {...autoAnnotateConfig}
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, onClose={() => setAutoAnnotateConfig(null)}
background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', onSuccess={() => {
justifyContent: 'center', zIndex: 9999 setAutoAnnotateConfig(null)
}}> onChanged()
<div className="panel" style={{ width: 500, maxWidth: '92vw', padding: 22, border: '1px solid rgba(168, 85, 247, 0.4)', background: '#18181b' }}> }}
<h3 style={{ margin: '0 0 8px 0', color: '#c084fc', fontSize: '1.05rem' }}>Append Annotations with SAM3</h3> />
<p className="hint" style={{ fontSize: '0.82rem', marginBottom: 16 }}>
Select target text prompts to detect with SAM3:
</p>
<div style={{ marginBottom: 16 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
<span className="hint" style={{ fontSize: '0.82rem' }}>Confidence Threshold:</span>
<strong style={{ color: '#c084fc', fontSize: '0.85rem' }}>{sam3AppendState.threshold}</strong>
</div>
<input
type="range"
min="0.05"
max="0.95"
step="0.05"
value={sam3AppendState.threshold}
onChange={(e) => setSam3AppendState({ ...sam3AppendState, threshold: parseFloat(e.target.value) })}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
<div style={{ marginBottom: 16 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
<span className="hint" style={{ fontSize: '0.82rem' }}>NMS IoU Threshold:</span>
<strong style={{ color: '#38bdf8', fontSize: '0.85rem' }}>{sam3AppendState.iouThreshold ?? 0.8}</strong>
</div>
<input
type="range"
min="0.1"
max="0.9"
step="0.05"
value={sam3AppendState.iouThreshold ?? 0.8}
onChange={(e) => setSam3AppendState({ ...sam3AppendState, iouThreshold: parseFloat(e.target.value) })}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
<div style={{ marginBottom: 16 }}>
<span className="hint" style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>Target Prompts to Detect:</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 120, overflowY: 'auto', padding: 10, background: 'rgba(0,0,0,0.4)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)', marginBottom: 10 }}>
{sam3AppendState.selectedClasses.map((clsName) => (
<button
key={clsName}
type="button"
className="tag"
style={{
cursor: 'pointer', padding: '4px 9px', fontSize: '0.8rem',
background: 'rgba(168, 85, 247, 0.25)', color: '#f3e8ff',
border: '1px solid rgba(168, 85, 247, 0.6)'
}}
onClick={() => {
const next = sam3AppendState.selectedClasses.filter((c) => c !== clsName)
setSam3AppendState({ ...sam3AppendState, selectedClasses: next })
}}
>
✕ {clsName}
</button>
))}
</div>
{/* Add Custom SAM3 Prompt */}
<div className="row" style={{ gap: 8 }}>
<input
type="text"
placeholder="Type new SAM3 text prompt (e.g. box)..."
value={customPromptInput}
onChange={(e) => setCustomPromptInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && customPromptInput.trim()) {
const val = customPromptInput.trim().toLowerCase()
if (!sam3AppendState.selectedClasses.includes(val)) {
setSam3AppendState({
...sam3AppendState,
selectedClasses: [...sam3AppendState.selectedClasses, val]
})
}
setCustomPromptInput('')
}
}}
style={{ flex: 1, padding: '6px 10px', fontSize: '0.82rem', background: '#09090b', border: '1px solid rgba(255,255,255,0.15)', borderRadius: 4, color: '#fff' }}
/>
<button
type="button"
className="btn"
style={{ fontSize: '0.8rem' }}
onClick={() => {
if (customPromptInput.trim()) {
const val = customPromptInput.trim().toLowerCase()
if (!sam3AppendState.selectedClasses.includes(val)) {
setSam3AppendState({
...sam3AppendState,
selectedClasses: [...sam3AppendState.selectedClasses, val]
})
}
setCustomPromptInput('')
}
}}
>
+ Add Prompt
</button>
</div>
</div>
<div className="row" style={{ justifyContent: 'flex-end', gap: 10 }}>
<button className="btn btn-ghost" onClick={() => setSam3AppendState(null)}>Cancel</button>
<button
className="btn btn-primary"
disabled={sam3AppendState.selectedClasses.length === 0}
onClick={async () => {
const { batch, threshold, iouThreshold, selectedClasses } = sam3AppendState
setSam3AppendState(null)
setBusyId(batch.id)
try {
await api.startAutolabel(batch.id, {
resume: false,
append: true,
engine: 'sam3',
threshold,
iou_threshold: iouThreshold ?? 0.8,
engine_classes: { sam3: selectedClasses },
class_ids: null
})
onChanged()
} catch (err) {
onError(err.message)
} finally {
setBusyId(null)
}
}}
>
Start SAM3 Append
</button>
</div>
</div>
</div>
)} )}
</>
{/* YOLO Custom Model Append Modal */}
{appendModalState && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 9999
}}>
<div className="panel" style={{ width: 500, maxWidth: '92vw', padding: 22, border: '1px solid rgba(56, 189, 248, 0.4)', background: '#18181b' }}>
<h3 style={{ margin: '0 0 8px 0', color: '#38bdf8', fontSize: '1.05rem' }}>Append Annotations with Custom Model</h3>
<p className="hint" style={{ fontSize: '0.82rem', marginBottom: 16 }}>
Model file: <strong style={{ color: '#fff' }}>{appendModalState.filename}</strong>
</p>
<div style={{ marginBottom: 16 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
<span className="hint" style={{ fontSize: '0.82rem' }}>Confidence Threshold:</span>
<strong style={{ color: '#38bdf8', fontSize: '0.85rem' }}>{appendModalState.threshold}</strong>
</div>
<input
type="range"
min="0.05"
max="0.95"
step="0.05"
value={appendModalState.threshold}
onChange={(e) => setAppendModalState({ ...appendModalState, threshold: parseFloat(e.target.value) })}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
<div style={{ marginBottom: 16 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
<span className="hint" style={{ fontSize: '0.82rem' }}>NMS IoU Threshold:</span>
<strong style={{ color: '#c084fc', fontSize: '0.85rem' }}>{appendModalState.iouThreshold ?? 0.8}</strong>
</div>
<input
type="range"
min="0.1"
max="0.9"
step="0.05"
value={appendModalState.iouThreshold ?? 0.8}
onChange={(e) => setAppendModalState({ ...appendModalState, iouThreshold: parseFloat(e.target.value) })}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
<span className="hint" style={{ fontSize: '0.82rem' }}>Select Classes to Append:</span>
<span style={{ fontSize: '0.78rem', color: '#a1a1aa' }}>
{appendModalState.selectedClasses.length} of {appendModalState.classes.length} selected
</span>
</div>
{appendModalState.classes.length === 0 ? (
<p className="hint" style={{ fontStyle: 'italic', color: '#e4e4e7' }}>No embedded class names found in model file. All predictions will be appended.</p>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 150, overflowY: 'auto', padding: 10, background: 'rgba(0,0,0,0.4)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
{appendModalState.classes.map((clsName) => {
const isChecked = appendModalState.selectedClasses.includes(clsName)
return (
<button
key={clsName}
type="button"
className="tag"
style={{
cursor: 'pointer',
padding: '4px 9px',
fontSize: '0.8rem',
background: isChecked ? 'rgba(56, 189, 248, 0.25)' : 'rgba(255,255,255,0.05)',
color: isChecked ? '#e0f2fe' : '#71717a',
border: isChecked ? '1px solid rgba(56, 189, 248, 0.6)' : '1px solid rgba(255,255,255,0.1)',
transition: 'all 150ms ease'
}}
onClick={() => {
const next = isChecked
? appendModalState.selectedClasses.filter((c) => c !== clsName)
: [...appendModalState.selectedClasses, clsName]
setAppendModalState({ ...appendModalState, selectedClasses: next })
}}
>
{isChecked ? '✓ ' : ''}{clsName}
</button>
)
})}
</div>
)}
</div>
<div className="row" style={{ justifyContent: 'flex-end', gap: 10 }}>
<button className="btn btn-ghost" onClick={() => setAppendModalState(null)}>Cancel</button>
<button
className="btn btn-primary"
disabled={appendModalState.classes.length > 0 && appendModalState.selectedClasses.length === 0}
onClick={async () => {
const { batch, file, threshold, iouThreshold, selectedClasses } = appendModalState
setAppendModalState(null)
setBusyId(batch.id)
try {
await api.autolabelWithModel(batch.id, file, threshold, selectedClasses, iouThreshold ?? 0.8)
onChanged()
} catch (err) {
onError(err.message)
} finally {
setBusyId(null)
}
}}
>
Start Append Auto-Annotation
</button>
</div>
</div>
</div>
)}
{/* Base Model Auto-annotate Modal */}
{baseModelModalState && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 9999
}}>
<div className="panel" style={{ width: 480, maxWidth: '92vw', padding: 22, border: '1px solid rgba(56, 189, 248, 0.4)', background: '#18181b' }}>
<h3 style={{ margin: '0 0 6px 0', color: '#38bdf8', fontSize: '1.05rem' }}>Auto-annotate Batch (Base Model)</h3>
<p className="hint" style={{ fontSize: '0.82rem', marginBottom: 16 }}>
Select target classes to detect using the project's base model:
</p>
<div style={{ marginBottom: 16 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
<span className="hint" style={{ fontSize: '0.82rem' }}>Confidence Threshold:</span>
<strong style={{ color: '#38bdf8', fontSize: '0.85rem' }}>{baseModelModalState.threshold}</strong>
</div>
<input
type="range"
min="0.05"
max="0.95"
step="0.05"
value={baseModelModalState.threshold}
onChange={(e) => setBaseModelModalState({ ...baseModelModalState, threshold: parseFloat(e.target.value) })}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
<div style={{ marginBottom: 16 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
<span className="hint" style={{ fontSize: '0.82rem' }}>NMS IoU Threshold:</span>
<strong style={{ color: '#38bdf8', fontSize: '0.85rem' }}>{baseModelModalState.iouThreshold ?? 0.8}</strong>
</div>
<input
type="range"
min="0.1"
max="0.9"
step="0.05"
value={baseModelModalState.iouThreshold ?? 0.8}
onChange={(e) => setBaseModelModalState({ ...baseModelModalState, iouThreshold: parseFloat(e.target.value) })}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
<div style={{ marginBottom: 18 }}>
<span className="hint" style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>Target Classes to Detect:</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, padding: 10, background: 'rgba(0,0,0,0.4)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
{project?.classes?.map((cls) => {
const isChecked = baseModelModalState.selectedClasses.includes(cls.name)
return (
<button
key={cls.class_id}
type="button"
className="tag"
style={{
cursor: 'pointer',
padding: '4px 9px',
fontSize: '0.8rem',
background: isChecked ? 'rgba(56, 189, 248, 0.25)' : 'rgba(255,255,255,0.05)',
color: isChecked ? '#e0f2fe' : '#71717a',
border: isChecked ? '1px solid rgba(56, 189, 248, 0.6)' : '1px solid rgba(255,255,255,0.1)',
}}
onClick={() => {
const newSel = isChecked
? baseModelModalState.selectedClasses.filter((n) => n !== cls.name)
: [...baseModelModalState.selectedClasses, cls.name]
setBaseModelModalState({ ...baseModelModalState, selectedClasses: newSel })
}}
>
{isChecked ? '✓ ' : ''}{cls.name}
</button>
)
})}
</div>
</div>
<div className="row" style={{ justifyContent: 'flex-end', gap: 10 }}>
<button className="btn btn-ghost" onClick={() => setBaseModelModalState(null)}>Cancel</button>
<button
className="btn btn-primary"
disabled={baseModelModalState.selectedClasses.length === 0}
onClick={async () => {
const { batch, threshold, iouThreshold, selectedClasses } = baseModelModalState
setBaseModelModalState(null)
setBusyId(batch.id)
try {
await api.startAutolabel(batch.id, {
resume: false,
append: false,
engine: 'base_model',
threshold,
iou_threshold: iouThreshold ?? 0.8,
target_class_names: selectedClasses,
})
onChanged()
} catch (err) {
onError(err.message)
} finally {
setBusyId(null)
}
}}
>
Start Auto-Annotation
</button>
</div>
</div>
</div>
)}
</div>
) )
} }
export default function LibraryPage({ projectId, onProject }) { export default function LibraryPage({ projectId, onProject }) {
const [project, setProject] = useState(null) const [project, setProject] = useState(null)
const [dates, setDates] = useState([]) const [dates, setDates] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [truckBusy, setTruckBusy] = useState(false)
const [selected, setSelected] = useState(null) const [selected, setSelected] = useState(null)
const [videos, setVideos] = useState(null) const [videos, setVideos] = useState(null)
const [batches, setBatches] = useState([]) const [batches, setBatches] = useState([])
const [jobs, setJobs] = useState([]) const [jobs, setJobs] = useState([])
const [error, setError] = useState('') const [error, setError] = useState('')
const loadBatches = useCallback(() => { const loadBatches = useCallback(() => {
api.listBatches(projectId).then((payload) => setBatches(payload.batches)).catch(() => {}) api.listBatches(projectId).then((payload) => setBatches(payload.batches)).catch(() => {})
}, [projectId]) }, [projectId])
@@ -659,13 +391,13 @@ export default function LibraryPage({ projectId, onProject }) {
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
setError('') setError('')
Promise.all([api.getProject(projectId), api.listDates(projectId), api.listBatches(projectId), api.listJobs(projectId)]) Promise.all([api.getProject(projectId), api.archiveCycles(projectId), api.listBatches(projectId), api.listJobs(projectId)])
.then(([loadedProject, library, batchPayload, jobPayload]) => { .then(([loadedProject, library, batchPayload, jobPayload]) => {
if (cancelled) return if (cancelled) return
setProject(loadedProject) setProject(loadedProject)
onProject?.(loadedProject) onProject?.(loadedProject)
setDates(library.dates) setDates(library.cycles)
setSelected(library.dates[0]?.date ?? null) setSelected(library.cycles[0]?.cycle ?? null)
setBatches(batchPayload.batches) setBatches(batchPayload.batches)
setJobs(jobPayload.jobs) setJobs(jobPayload.jobs)
}) })
@@ -677,7 +409,7 @@ export default function LibraryPage({ projectId, onProject }) {
if (!selected) return if (!selected) return
let cancelled = false let cancelled = false
setVideos(null) setVideos(null)
api.listVideos(projectId, selected) api.archiveCycleVideos(projectId, selected)
.then((payload) => !cancelled && setVideos(payload.videos)) .then((payload) => !cancelled && setVideos(payload.videos))
.catch((exc) => !cancelled && setError(exc.message)) .catch((exc) => !cancelled && setError(exc.message))
return () => { cancelled = true } return () => { cancelled = true }
@@ -716,92 +448,202 @@ export default function LibraryPage({ projectId, onProject }) {
} }
if (!project) return <p className="empty">Loading…</p> if (!project) return <p className="empty">Loading…</p>
async function handleAutoAnnotateAllHeader() {
if (!window.confirm(`Start SAM3 Auto-Annotation in parallel for ALL ${batches.length} batch(es)?`)) return
try {
await Promise.all(
batches.map(b => api.startAutolabel(b.id, {
resume: false,
append: true,
engine: 'sam3',
threshold: 0.35,
iou_threshold: 0.0,
min_box_frac: 0.0,
target_class_names: project.classes.map(c => c.name)
}))
)
loadBatches()
loadJobs()
} catch (exc) {
setError(exc.message)
}
}
return ( return (
<> <>
<div className="page-head"> <div className="page-head" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div> <div>
<h1>{project.name}</h1> <h1>Video Archive</h1>
<p className="muted mono">{project.video_root}</p> <p className="muted mono">{project.video_root}</p>
<p className="hint" style={{ fontSize: '0.76rem', margin: '4px 0 0' }}>
Satu siklus = 06:00 sampai 05:59 pagi berikutnya, jadi selalu melewati tengah
malam dan mencakup dua tanggal. Urutan batch diambil dari jam yang tertulis di
video, bukan dari nama foldernya — file di disk tidak dipindah.
</p>
</div> </div>
<button
className="btn"
disabled={truckBusy}
title="Periksa tiap rekaman apakah benar ada truknya, memakai model terbaru (v4). 12 frame per rekaman."
onClick={async () => {
setTruckBusy(true)
try { await api.truckScan(projectId); loadJobs() }
catch (exc) { setError(exc.message) }
finally { setTruckBusy(false) }
}}
style={{ fontSize: '0.82rem', padding: '8px 14px', cursor: truckBusy ? 'wait' : 'pointer', borderRadius: 6 }}
>
{truckBusy ? 'Memulai…' : 'Cek truk (v4)'}
</button>
</div> </div>
<ActiveJobsBanner jobs={activeJobs} onCancel={cancelJob} /> <ActiveJobsBanner jobs={activeJobs} onCancel={cancelJob} />
{batches.length > 0 && (
<>
<h2 style={{ marginBottom: 8 }}>Batches</h2>
<BatchList project={project} batches={batches} activeJobs={activeJobs} onChanged={() => { loadBatches(); loadJobs(); }} onError={setError} />
<h2 style={{ margin: '24px 0 8px' }}>Archive</h2>
</>
)}
{dates.length === 0 ? ( {dates.length === 0 ? (
<p className="empty"> <p className="empty">
No date folders in this archive yet. Expected layout: &lt;date&gt;/&lt;batch&gt;.mp4 No date folders in this archive yet. Expected layout: &lt;date&gt;/&lt;batch&gt;.mp4
</p> </p>
) : ( ) : (
<div className="library"> <div className="library" style={{ marginTop: 12 }}>
<nav className="panel date-list" aria-label="Recording dates"> <nav className="panel date-list" aria-label="Recording dates">
{dates.map((item) => ( {dates.map((item) => (
<button <button
key={item.date} key={item.cycle}
className="date-item" className="date-item"
aria-current={item.date === selected} aria-current={item.cycle === selected}
onClick={() => setSelected(item.date)} onClick={() => setSelected(item.cycle)}
title={item.flagged
? `${item.flagged} rekaman waktunya belum terbaca pasti`
: 'Semua waktu rekaman terbaca'}
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}
> >
{item.date} <FolderIcon size={14} style={{ opacity: item.cycle === selected ? 1 : 0.6 }} />
<span className="count">{item.video_count}</span> <span>{labelSiklus(item.cycle)}</span>
{item.flagged > 0 && (
<span style={{ color: '#fbbf24', fontSize: '0.7rem' }} aria-hidden="true">●</span>
)}
<span className="count" style={{ marginLeft: 'auto' }}>{item.video_count}</span>
</button> </button>
))} ))}
</nav> </nav>
<div className="panel table-wrap"> <div className="panel table-wrap" style={{ padding: 16 }}>
{videos === null ? ( {videos === null ? (
<p className="empty">Reading video metadata…</p> <p className="empty">Reading video metadata…</p>
) : videos.length === 0 ? ( ) : videos.length === 0 ? (
<p className="empty">No videos in {selected}.</p> <p className="empty">Tidak ada rekaman di {labelSiklus(selected)}.</p>
) : ( ) : (
<table className="video-table"> <>
<thead> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<tr> <h3 style={{ margin: 0, fontSize: '0.95rem', color: '#e4e4e7' }}>
<th>Batch</th> {labelSiklus(selected)} — {videos.length} rekaman
<th>Duration</th> </h3>
<th>Resolution</th> <div style={{ position: 'relative', width: 240 }}>
<th>FPS</th> <input
<th>Size</th> type="text"
<th>Used</th> placeholder="Filter archive videos..."
<th /> value={searchQuery}
</tr> onChange={(e) => setSearchQuery(e.target.value)}
</thead> style={{
<tbody> width: '100%',
{videos.map((item) => ( padding: '5px 10px 5px 30px',
<tr key={item.rel}> fontSize: '0.8rem',
<td>{item.batch_label}</td> background: '#09090b',
<td className="num">{formatDuration(item.duration)}</td> border: '1px solid rgba(255,255,255,0.12)',
<td className="num"> borderRadius: 6,
{item.width ? `${item.width}×${item.height}` : <span className="faint">unreadable</span>} color: '#f4f4f5'
</td> }}
<td className="num">{item.fps || '—'}</td> />
<td className="num">{megabytes(item.size)}</td> <div style={{ position: 'absolute', left: 8, top: '50%', transform: 'translateY(-50%)', opacity: 0.5, display: 'flex' }}>
<td> <SearchIcon size={14} />
{item.used_count > 0 </div>
? <span className="tag">{item.used_count} batch{item.used_count > 1 ? 'es' : ''}</span> </div>
: <span className="faint">—</span>} </div>
</td>
<td> <table className="video-table">
<button <thead>
className="btn" <tr>
disabled={!item.duration} <th style={{ width: 48, textAlign: 'right' }}>Batch</th>
title={item.duration ? 'Pick a range and extract frames' : 'ffprobe could not read this file'} <th style={{ width: 96 }}>Direkam</th>
onClick={() => navigate(`/projects/${projectId}/trim/${encodeURIComponent(item.rel)}`)} <th>File</th>
> <th>Duration</th>
Trim <th>Resolution</th>
</button> <th>FPS</th>
</td> <th>Size</th>
<th>Truk</th>
<th>Status</th>
<th />
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {videos
.filter((item) => !searchQuery || item.batch_label.toLowerCase().includes(searchQuery.toLowerCase()))
.map((item) => (
<tr key={item.rel}>
<td className="num" style={{ fontWeight: 600 }}>
{item.batch_no ?? <span className="faint">—</span>}
</td>
<td
className="num"
title={item.started_at
? `dibaca dari overlay video${item.moved ? ` — foldernya ${item.folder_date}` : ''}`
: (item.clock_error || 'waktu mulai belum terbaca')}
style={{ color: item.clock_trusted ? undefined : '#fbbf24' }}
>
{jamRekam(item.started_at) ?? '—'}
</td>
<td style={{ fontWeight: 500 }}>
{item.batch_label}
{item.moved && (
<span
title={`file ini ada di folder ${item.folder_date}, tapi direkam di siklus ini`}
style={{ color: '#fbbf24', marginLeft: 6, fontSize: '0.7rem' }}
>
{item.folder_date}
</span>
)}
</td>
<td className="num">{formatDuration(item.duration)}</td>
<td className="num">
{item.width ? `${item.width}×${item.height}` : <span className="faint">unreadable</span>}
</td>
<td className="num">{item.fps || '—'}</td>
<td className="num">{megabytes(item.size)}</td>
<td>
{item.truck_samples
? (item.truck_hits > 0
? <span className="tag" title={`truk terlihat di ${item.truck_hits} dari ${item.truck_samples} frame yang dicek`}
style={{ background: 'rgba(74,222,128,0.14)', color: '#4ade80', border: '1px solid rgba(74,222,128,0.3)' }}>
{item.truck_hits}/{item.truck_samples}
</span>
: <span className="tag" title="v4 tidak menemukan truk sama sekali — rekaman ini mungkin bukan satu batch"
style={{ background: 'rgba(248,113,113,0.14)', color: '#f87171', border: '1px solid rgba(248,113,113,0.3)' }}>
tanpa truk
</span>)
: <span className="faint" style={{ fontSize: '0.8rem' }}>belum dicek</span>}
</td>
<td>
{item.used_count > 0
? <span className="tag" style={{ background: 'rgba(56, 189, 248, 0.15)', color: '#38bdf8', border: '1px solid rgba(56, 189, 248, 0.3)' }}>{item.used_count} batch{item.used_count > 1 ? 'es' : ''}</span>
: <span className="faint" style={{ fontSize: '0.8rem' }}>Unused</span>}
</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-secondary"
style={{ cursor: item.duration ? 'pointer' : 'not-allowed', padding: '4px 10px', fontSize: '0.8rem', display: 'inline-flex', alignItems: 'center', gap: 4 }}
disabled={!item.duration}
title={item.duration ? 'Pick a range and extract frames' : 'ffprobe could not read this file'}
onClick={() => navigate(`/projects/${projectId}/trim/${encodeURIComponent(item.rel)}`)}
>
<ScissorsIcon size={14} /> Trim
</button>
</td>
</tr>
))}
</tbody>
</table>
</>
)} )}
</div> </div>
</div> </div>
@@ -809,3 +651,4 @@ export default function LibraryPage({ projectId, onProject }) {
</> </>
) )
} }
+412
View File
@@ -0,0 +1,412 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { AlertIcon } from '../components/Icons'
/* Live counting test bench.
*
* Point a trained model at an RTSP camera (or a local file) and watch it count.
* It runs the same tracker, stabiliser and line-cross counter the production
* script uses, so a number here means the same thing there. What it leaves out
* is the batch lifecycle and its database — this answers "does the model count
* correctly", not "how many sacks today". */
// `live` fields can be moved during a session — placing a counting line means
// watching the stream while you move it, and a restart would throw the counts away.
const FIELDS = [
{ key: 'line_y', label: 'Counting line Y', min: 0, max: 720, step: 1, live: true,
hint: 'Sacks are counted as they cross this line. Click the video to place it.' },
{ key: 'line_x_start', label: 'Line start X', min: 0, max: 1280, step: 1, live: true,
hint: 'Ignore anything left of this.' },
{ key: 'line_x_end', label: 'Line end X', min: 0, max: 1280, step: 1, live: true,
hint: 'Ignore anything right of this.' },
{ key: 'margin', label: 'Band margin (px)', min: 0, max: 120, step: 1,
hint: 'Dead band around the line, so jitter alone never counts.' },
{ key: 'entry_travel_min', label: 'Entry travel min (px)', min: 0, max: 200, step: 1,
hint: 'A track must move this far from where it first appeared before it can count. '
+ 'Raise it to kill ghost boxes that blink into existence next to the line.' },
{ key: 'handoff_radius', label: 'Hand-off radius (px)', min: 0, max: 300, step: 5,
hint: 'When a track dies, a new track born this close to where it was heading inherits '
+ 'its history — this is what stops an ID switch at the line losing the count. '
+ 'The most sensitive dial here: too large and unrelated sacks adopt each other. '
+ 'Calibrate against a clip with a known count.' },
{ key: 'unload_confirm_frames', label: 'Unload confirm (frames)', min: 1, max: 15, step: 1,
hint: 'Frames a sack must stay above the band before it counts as unloaded. Stops a '
+ 'worker repositioning a sack from cancelling a real count.' },
{ key: 'min_area_scale', label: 'Min area scale', min: 0, max: 2, step: 0.1,
hint: 'Perspective-aware size gate: boxes too small for their depth are fragments, not '
+ 'sacks. 0 turns it off.' },
{ key: 'conf', label: 'Confidence', min: 0.05, max: 0.95, step: 0.05,
hint: 'Detector threshold.' },
]
export default function LiveCountPage({ projectId, onProject }) {
const [models, setModels] = useState([])
const [modelPath, setModelPath] = useState('')
// Archive video by default: a local file decodes at ~100 fps, an RTSP camera
// at ~6 because OpenCV decodes 1080p on the CPU. Testing counting accuracy is
// far quicker against a file.
const [mode, setMode] = useState('file')
const [source, setSource] = useState('rtsp://192.168.192.96:8554/cam')
const [dates, setDates] = useState([])
const [date, setDate] = useState('')
const [videos, setVideos] = useState([])
const [videoRel, setVideoRel] = useState('')
// Defaults are the settings that were dialled in against the real camera —
// a fresh session starts where the last tuning session left off.
const [cfg, setCfg] = useState({
line_y: 266, line_x_start: 469, line_x_end: 910,
margin: 5, dedup_radius: 60, conf: 0.35,
entry_travel_min: 60, handoff_radius: 100, unload_confirm_frames: 3,
min_area_scale: 1.0,
})
const [status, setStatus] = useState({ running: false })
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [streamKey, setStreamKey] = useState(0)
const [placing, setPlacing] = useState('line_y')
const pollRef = useRef(null)
useEffect(() => {
api.getProject(projectId).then((p) => onProject?.(p)).catch(() => {})
api.liveCountModels(projectId)
.then((payload) => {
setModels(payload.models)
if (payload.models.length) setModelPath(payload.models[0].path)
})
.catch((exc) => setError(exc.message))
api.listDates(projectId)
.then((payload) => {
setDates(payload.dates)
if (payload.dates.length) setDate(payload.dates[0].date)
})
.catch(() => {})
}, [projectId])
useEffect(() => {
if (!date) return
setVideoRel('')
api.listVideos(projectId, date)
.then((payload) => {
setVideos(payload.videos)
if (payload.videos.length) setVideoRel(payload.videos[0].rel)
})
.catch(() => setVideos([]))
}, [projectId, date])
const poll = useCallback(() => {
api.liveCountStatus()
.then((s) => {
setStatus(s)
if (s.error) setError(s.error)
})
.catch(() => {})
}, [])
useEffect(() => {
poll()
pollRef.current = setInterval(poll, 1000)
return () => clearInterval(pollRef.current)
}, [poll])
async function start() {
setError('')
setBusy(true)
try {
const picked = mode === 'file'
? { source_rel: videoRel }
: { source }
const s = await api.liveCountStart(projectId, { ...picked, model_path: modelPath, ...cfg })
setStatus(s)
if (s.error) setError(s.error)
else setStreamKey((n) => n + 1)
} catch (exc) {
setError(exc.message)
} finally {
setBusy(false)
}
}
/* While a session runs the backend owns the line, so a change has to be sent
* there. Fire-and-forget: the next status poll confirms it, and a dropped
* request during a fast drag is corrected by the one after it. */
function setField(key, value) {
const next = { ...cfg, [key]: value }
setCfg(next)
const field = FIELDS.find((f) => f.key === key)
if (running && field?.live) {
api.liveCountMoveLine({
line_y: next.line_y,
line_x_start: next.line_x_start,
line_x_end: next.line_x_end,
}).catch(() => {})
}
}
// Click on the stream to place whichever edge is armed. Placing by eye beats
// guessing a pixel value on a slider.
function placeOnClick(event) {
if (!running) return
const rect = event.currentTarget.getBoundingClientRect()
if (!rect.height || !rect.width) return
if (placing === 'line_y') {
const y = Math.round(((event.clientY - rect.top) / rect.height) * 720)
setField('line_y', Math.max(0, Math.min(720, y)))
} else {
const x = Math.round(((event.clientX - rect.left) / rect.width) * 1280)
setField(placing, Math.max(0, Math.min(1280, x)))
}
}
async function stop() {
setBusy(true)
try {
setStatus(await api.liveCountStop())
} catch (exc) {
setError(exc.message)
} finally {
setBusy(false)
}
}
const running = Boolean(status.running)
return (
<>
<div className="page-head">
<div>
<h1>Live counting test</h1>
<p className="muted">
Run a trained model against a camera and watch it count, before trusting it in production.
</p>
</div>
</div>
{error && (
<p className="error-banner" style={{ marginBottom: 14 }}>
<AlertIcon size={14} /> {error}
<button type="button" className="btn" onClick={() => setError('')}
style={{ marginLeft: 10, cursor: 'pointer', fontSize: '0.75rem' }}>Dismiss</button>
</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(320px, 380px) 1fr', gap: 20, alignItems: 'start' }}>
<div className="panel side-panel" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className="hint" style={{ fontSize: '0.8rem' }}>Source</label>
<div style={{ display: 'flex', gap: 6, margin: '6px 0 8px' }}>
{[['file', 'Archive video'], ['stream', 'RTSP stream']].map(([id, label]) => (
<button
key={id}
type="button"
className={`class-chip ${mode === id ? 'active' : ''}`}
disabled={running}
onClick={() => setMode(id)}
style={{
fontSize: '0.76rem', padding: '3px 10px',
cursor: running ? 'not-allowed' : 'pointer',
border: mode === id ? '1px solid #c084fc' : '1px solid #3f3f46',
}}
>
{label}
</button>
))}
</div>
{mode === 'file' ? (
<>
<select
value={date}
onChange={(e) => setDate(e.target.value)}
disabled={running}
style={{ width: '100%', marginBottom: 6, cursor: running ? 'not-allowed' : 'pointer' }}
>
{dates.length === 0 && <option value="">no archive folders</option>}
{dates.map((d) => (
<option key={d.date} value={d.date}>{d.date} · {d.video_count} video</option>
))}
</select>
<select
value={videoRel}
onChange={(e) => setVideoRel(e.target.value)}
disabled={running}
style={{ width: '100%', cursor: running ? 'not-allowed' : 'pointer' }}
>
{videos.length === 0 && <option value="">no videos</option>}
{videos.map((v) => (
<option key={v.rel} value={v.rel}>
{v.batch_label} · {Math.round((v.duration || 0) / 60)} min · {v.width}×{v.height}
</option>
))}
</select>
<p className="hint" style={{ fontSize: '0.72rem', margin: '4px 0 0' }}>
Decodes at ~100 fps, so a whole video is checked in seconds.
</p>
</>
) : (
<>
<input
value={source}
onChange={(e) => setSource(e.target.value)}
disabled={running}
className="mono"
style={{ width: '100%', fontSize: '0.8rem' }}
/>
<p className="hint" style={{ fontSize: '0.72rem', margin: '4px 0 0' }}>
Real time, but capped near 6 fps — OpenCV decodes this camera's 1080p on the CPU.
</p>
</>
)}
</div>
<div>
<label className="hint" style={{ fontSize: '0.8rem' }}>Model</label>
<select
value={modelPath}
onChange={(e) => setModelPath(e.target.value)}
disabled={running}
style={{ width: '100%', marginTop: 4, cursor: running ? 'not-allowed' : 'pointer' }}
>
{models.length === 0 && <option value="">no trained model yet</option>}
{models.map((m) => <option key={m.path} value={m.path}>{m.label}</option>)}
</select>
</div>
{FIELDS.map((f) => {
const locked = running && !f.live
return (
<div key={f.key}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>
{f.label}
{running && f.live && (
<span style={{ color: '#4ade80', marginLeft: 6, fontSize: '0.7rem' }}>live</span>
)}
</span>
<strong style={{ fontSize: '0.8rem', color: '#38bdf8' }}>{cfg[f.key]}</strong>
</div>
<input
type="range" min={f.min} max={f.max} step={f.step} value={cfg[f.key]}
disabled={locked}
onChange={(e) => setField(f.key, Number(e.target.value))}
style={{ width: '100%', cursor: locked ? 'not-allowed' : 'pointer' }}
/>
<p className="hint" style={{ fontSize: '0.72rem', margin: '2px 0 0' }}>{f.hint}</p>
</div>
)
})}
<div className="row" style={{ gap: 8 }}>
{running ? (
<button className="btn" onClick={stop} disabled={busy}
style={{ flex: 1, cursor: 'pointer', color: '#f87171', borderColor: '#f8717155' }}>
{busy ? 'Stopping…' : 'Stop'}
</button>
) : (
<button
className="btn btn-primary"
onClick={start}
disabled={busy || !modelPath || (mode === 'file' ? !videoRel : !source)}
style={{ flex: 1, cursor: busy || !modelPath ? 'not-allowed' : 'pointer' }}
>
{busy ? 'Starting…' : 'Start counting'}
</button>
)}
</div>
<p className="hint" style={{ fontSize: '0.74rem', margin: 0 }}>
Holds the GPU while running, so training and auto-annotation wait their turn.
The line can be moved mid-session; the other settings need a restart.
</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 12 }}>
<Stat label="Counted in" value={status.loading ?? 0} accent="#4ade80" />
<Stat label="Counted out" value={status.unloading ?? 0} accent="#f87171" />
<Stat label="Net" value={status.net ?? 0} accent="#38bdf8" />
<Stat label="FPS" value={status.fps ?? 0} accent="#c084fc" />
<Stat label="Tracked now" value={status.tracked ?? 0} accent="#fbbf24" />
<Stat label="Ignored" value={status.ignored ?? 0} accent="#a1a1aa" />
<Stat label="Too small" value={status.too_small ?? 0} accent="#a1a1aa" />
<Stat label="Tracks traced" value={status.traced ?? 0} accent="#a1a1aa" />
</div>
{running && status.trace_path && (
<p className="hint" style={{ fontSize: '0.74rem', margin: 0 }}>
Every finished track and the reason it did or did not count is being written to{' '}
<span className="mono">{status.trace_path}</span> — that file is what separates a
model miss from a tracker miss from a counter miss.
</p>
)}
{running && (
<div className="row" style={{ gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span className="hint" style={{ fontSize: '0.78rem' }}>Click the video to place:</span>
{[
['line_y', 'Counting line'],
['line_x_start', 'Left edge'],
['line_x_end', 'Right edge'],
].map(([key, label]) => (
<button
key={key}
type="button"
className={`class-chip ${placing === key ? 'active' : ''}`}
onClick={() => setPlacing(key)}
style={{
fontSize: '0.76rem', padding: '3px 10px', cursor: 'pointer',
border: placing === key ? '1px solid #c084fc' : '1px solid #3f3f46',
}}
>
{label}
</button>
))}
<span className="hint" style={{ fontSize: '0.74rem', marginLeft: 'auto' }}>
Shaded areas are ignored — {status.ignored ?? 0} detection(s) outside the region now.
</span>
</div>
)}
<div className="panel" style={{ padding: 0, overflow: 'hidden', background: '#000', minHeight: 320 }}>
{running ? (
<img
key={streamKey}
src={api.liveCountStreamUrl(streamKey)}
alt="Live counting"
onClick={placeOnClick}
title={`Click to place: ${placing.replace('line_', '').replace('_', ' ')}`}
style={{ width: '100%', display: 'block', cursor: 'crosshair' }}
/>
) : (
<p className="empty" style={{ padding: 60, textAlign: 'center' }}>
Not running. Set a source and press Start.
</p>
)}
</div>
{(status.events?.length ?? 0) > 0 && (
<div className="panel" style={{ padding: 14 }}>
<h2 style={{ fontSize: '0.9rem', margin: '0 0 8px' }}>Recent counts</h2>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{status.events.slice().reverse().map((ev, i) => (
<span key={i} className="tag mono" style={{ fontSize: '0.74rem' }}>
#{ev.track_id} · {ev.direction} · {ev.at}s
</span>
))}
</div>
</div>
)}
</div>
</div>
</>
)
}
function Stat({ label, value, accent }) {
return (
<div className="panel side-panel" style={{ padding: 12, borderLeft: `3px solid ${accent}` }}>
<span style={{ fontSize: '0.72rem', color: '#a1a1aa', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{label}
</span>
<div style={{ fontSize: '1.6rem', fontWeight: 700, color: accent, marginTop: 2 }}>{value}</div>
</div>
)
}
+232 -142
View File
@@ -90,20 +90,29 @@ export default function ModelsPage({ projectId, onProject }) {
const [job, setJob] = useState(null) const [job, setJob] = useState(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [selectedBatchIds, setSelectedBatchIds] = useState([])
const [selectedClassIds, setSelectedClassIds] = useState([]) const [selectedClassIds, setSelectedClassIds] = useState([])
const [datasetRows, setDatasetRows] = useState([])
const [selectedDatasetIds, setSelectedDatasetIds] = useState([])
const [baseRows, setBaseRows] = useState([])
// Opt-in, never preselected: a base dataset is somebody else's labels.
const [selectedBaseIds, setSelectedBaseIds] = useState([])
const [combined, setCombined] = useState(null)
const load = useCallback(async () => { const load = useCallback(async () => {
const [loadedProject, loadedSummary, modelPayload, hw, jobsPayload] = await Promise.all([ const [loadedProject, loadedSummary, modelPayload, hw, jobsPayload] = await Promise.all([
api.getProject(projectId), api.datasetSummary(projectId), api.getProject(projectId), api.datasetSummary(projectId),
api.listModels(projectId), api.hardware(), api.listJobs(projectId), api.listModels(projectId), api.hardware(), api.listJobs(projectId),
]) ])
const datasetPayload = await api.listDatasets(projectId)
setDatasetRows(datasetPayload.datasets)
setSelectedDatasetIds(datasetPayload.datasets.map((d) => d.id))
const basePayload = await api.listBaseDatasets(projectId).catch(() => ({ base_datasets: [] }))
setBaseRows(basePayload.base_datasets)
setProject(loadedProject) setProject(loadedProject)
onProject?.(loadedProject) onProject?.(loadedProject)
setSummary(loadedSummary) setSummary(loadedSummary)
setModels(modelPayload.models) setModels(modelPayload.models)
setHardware(hw) setHardware(hw)
setSelectedBatchIds(loadedSummary.batches.map((b) => b.id))
setSelectedClassIds(loadedProject.classes?.map((c) => c.class_id) || []) setSelectedClassIds(loadedProject.classes?.map((c) => c.class_id) || [])
const activeJob = jobsPayload.jobs?.find((j) => ['running', 'queued'].includes(j.status)) const activeJob = jobsPayload.jobs?.find((j) => ['running', 'queued'].includes(j.status))
if (activeJob) setJob(activeJob) if (activeJob) setJob(activeJob)
@@ -111,6 +120,15 @@ export default function ModelsPage({ projectId, onProject }) {
useEffect(() => { load().catch((exc) => setError(exc.message)) }, [load]) useEffect(() => { load().catch((exc) => setError(exc.message)) }, [load])
useEffect(() => {
if (selectedDatasetIds.length === 0) { setCombined(null); return }
let cancelled = false
api.combinePreview(projectId, selectedDatasetIds)
.then((payload) => !cancelled && setCombined(payload))
.catch(() => {})
return () => { cancelled = true }
}, [projectId, selectedDatasetIds])
useEffect(() => { useEffect(() => {
if (!job || ['done', 'failed', 'cancelled'].includes(job.status)) { if (!job || ['done', 'failed', 'cancelled'].includes(job.status)) {
if (job?.status === 'done') load().catch(() => {}) if (job?.status === 'done') load().catch(() => {})
@@ -127,7 +145,10 @@ export default function ModelsPage({ projectId, onProject }) {
try { try {
setJob(await api.startTraining(projectId, { setJob(await api.startTraining(projectId, {
epochs: Number(epochs), epochs: Number(epochs),
batch_ids: selectedBatchIds.length > 0 ? selectedBatchIds : null, dataset_ids: selectedDatasetIds,
base_dataset_ids: selectedBaseIds,
// No batch filter: the chosen datasets already carry their batches, and
// filtering again could only subtract from them.
class_ids: selectedClassIds.length > 0 ? selectedClassIds : null, class_ids: selectedClassIds.length > 0 ? selectedClassIds : null,
})) }))
} catch (exc) { } catch (exc) {
@@ -135,20 +156,6 @@ export default function ModelsPage({ projectId, onProject }) {
} }
} }
const toggleBatchSelect = (id) => {
setSelectedBatchIds((prev) =>
prev.includes(id) ? prev.filter((bId) => bId !== id) : [...prev, id]
)
}
const toggleSelectAllBatches = () => {
if (selectedBatchIds.length === summary.batches.length) {
setSelectedBatchIds([])
} else {
setSelectedBatchIds(summary.batches.map((b) => b.id))
}
}
const toggleClassSelect = (classId) => { const toggleClassSelect = (classId) => {
setSelectedClassIds((prev) => setSelectedClassIds((prev) =>
prev.includes(classId) ? prev.filter((cId) => cId !== classId) : [...prev, classId] prev.includes(classId) ? prev.filter((cId) => cId !== classId) : [...prev, classId]
@@ -171,75 +178,65 @@ export default function ModelsPage({ projectId, onProject }) {
</p> </p>
</div> </div>
<span className="spacer" /> <span className="spacer" />
<a className="btn" href={api.datasetDownloadUrl(projectId)} download>Download dataset</a> <a className="btn" href={`#/projects/${projectId}/datasets`}>Manage datasets</a>
</div> </div>
{error && <p className="error-banner" style={{ marginBottom: 12 }}> {error && <p className="error-banner" style={{ marginBottom: 12 }}>
<AlertIcon size={14} /> {error} <AlertIcon size={14} /> {error}
</p>} </p>}
<div className="panel side-panel" style={{ marginBottom: 20 }}> <div style={{ display: 'grid', gridTemplateColumns: 'minmax(340px, 420px) 1fr', gap: 20, alignItems: 'start' }}>
<h2>Base Model Configuration</h2> {/* Left Column: Training Configuration & Batch Selection */}
<p className="hint">Upload a base YOLO model checkpoint (.pt) to use for fine-tuning baseline and auto-annotation:</p> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Base Model Configuration */}
<div style={{ padding: 14, background: 'rgba(0,0,0,0.3)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)', marginTop: 12 }}>
<h3 style={{ fontSize: '0.9rem', color: '#c084fc', margin: '0 0 6px 0' }}>Base Model</h3>
<p className="hint" style={{ fontSize: '0.78rem', margin: '0 0 10px 0' }}>
Used as fine-tuning starting point and baseline benchmark.
</p>
<div style={{ fontSize: '0.8rem', color: '#a1a1aa', marginBottom: 10 }}>
Status: <strong style={{ color: '#fff' }}>{project.base_model_path ? 'Custom model.pt loaded' : 'Default yolo11n.pt'}</strong>
</div>
<div style={{ fontSize: '0.78rem', color: '#38bdf8', marginBottom: 10 }}>
<strong>Base Model Classes:</strong> {project.classes?.map((c) => c.name).join(', ')}
</div>
<input
type="file"
accept=".pt"
id="upload-primary-model"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
await api.uploadBaseModel(projectId, file)
load()
} catch (err) {
setError(err.message)
}
}}
/>
<label htmlFor="upload-primary-model" className="btn btn-ghost" style={{ cursor: 'pointer', padding: '4px 10px', fontSize: '0.8rem' }}>
Upload Base Model (.pt)
</label>
</div>
</div>
<div className="review">
<div className="stack">
{models.length === 0 && !running && (
<p className="empty">No trained versions yet.</p>
)}
{models.map((version) => (
<VersionCard key={version.id} version={version} onPromote={load}
onError={setError} />
))}
</div>
<aside className="review-side stack">
<div className="panel side-panel"> <div className="panel side-panel">
<h2>Train</h2> <h2 style={{ fontSize: '1rem', marginBottom: 4 }}>Base Model Configuration</h2>
<p className="hint"> <p className="hint" style={{ fontSize: '0.8rem', marginBottom: 12 }}>
Fine-tunes{' '} Fine-tuning baseline and auto-annotation model checkpoint.
{project.base_model_path
? 'this project’s base model'
: `${project.base_model_fallback} (no base model uploaded)`}{' '}
on the selected dataset batches ({selectedBatchIds.length}/{summary.batches.length} selected).
</p> </p>
<div style={{ marginBottom: 12 }}> <div style={{ padding: 12, background: 'rgba(0,0,0,0.3)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.08)' }}>
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>Target Classes to Train:</label> <div style={{ fontSize: '0.82rem', color: '#a1a1aa', marginBottom: 6 }}>
Status: <strong style={{ color: '#fff' }}>{project.base_model_path ? 'Custom model.pt loaded' : 'Default yolo11n.pt'}</strong>
</div>
<div style={{ fontSize: '0.78rem', color: '#38bdf8', marginBottom: 10 }}>
<strong>Classes:</strong> {project.classes?.map((c) => c.name).join(', ')}
</div>
<input
type="file"
accept=".pt"
id="upload-primary-model"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
await api.uploadBaseModel(projectId, file)
load()
} catch (err) {
setError(err.message)
}
}}
/>
<label
htmlFor="upload-primary-model"
className="btn btn-ghost"
style={{ cursor: 'pointer', padding: '5px 12px', fontSize: '0.8rem', display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
Upload Base Model (.pt)
</label>
</div>
</div>
{/* Training Control Panel */}
<div className="panel side-panel">
<h2 style={{ fontSize: '1rem', marginBottom: 6 }}>Train Model</h2>
<p className="hint" style={{ fontSize: '0.8rem', marginBottom: 12 }}>
Fine-tunes {project.base_model_path ? 'custom base model' : project.base_model_fallback} on selected batches.
</p>
<div style={{ marginBottom: 14 }}>
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6, fontWeight: 500 }}>Target Classes:</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{project.classes?.map((cls) => { {project.classes?.map((cls) => {
const isChecked = selectedClassIds.includes(cls.class_id) const isChecked = selectedClassIds.includes(cls.class_id)
@@ -250,106 +247,199 @@ export default function ModelsPage({ projectId, onProject }) {
className="tag" className="tag"
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
padding: '4px 9px', padding: '4px 10px',
fontSize: '0.8rem', fontSize: '0.8rem',
background: isChecked ? 'rgba(192, 132, 252, 0.25)' : 'rgba(255,255,255,0.05)', background: isChecked ? 'rgba(192, 132, 252, 0.2)' : 'rgba(255,255,255,0.05)',
color: isChecked ? '#f3e8ff' : '#71717a', color: isChecked ? '#f3e8ff' : '#a1a1aa',
border: isChecked ? '1px solid rgba(192, 132, 252, 0.6)' : '1px solid rgba(255,255,255,0.1)', border: isChecked ? '1px solid rgba(192, 132, 252, 0.5)' : '1px solid rgba(255,255,255,0.1)',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
borderRadius: 6
}} }}
onClick={() => toggleClassSelect(cls.class_id)} onClick={() => toggleClassSelect(cls.class_id)}
> >
{isChecked ? '✓ ' : ''}{cls.name} {isChecked && <CheckIcon size={12} />}
{cls.name}
</button> </button>
) )
})} })}
</div> </div>
</div> </div>
<div> <div style={{ marginBottom: 14 }}>
<label htmlFor="epochs">Epochs</label> <label htmlFor="epochs" style={{ fontSize: '0.82rem', display: 'block', marginBottom: 4, fontWeight: 500 }}>Epochs</label>
<input id="epochs" type="number" min="1" max="500" value={epochs} <input
onChange={(e) => setEpochs(e.target.value)} /> id="epochs"
type="number"
min="1"
max="500"
value={epochs}
onChange={(e) => setEpochs(e.target.value)}
style={{ width: '100%', padding: '6px 10px', background: '#09090b', border: '1px solid rgba(255,255,255,0.12)', borderRadius: 6, color: '#fff', fontSize: '0.85rem' }}
/>
</div> </div>
{hardware && ( {hardware && (
<p className="hint"> <p className="hint" style={{ fontSize: '0.78rem', marginBottom: 12 }}>
{hardware.gpu ?? 'CPU'} — defaults batch {hardware.batch}, {hardware.gpu ?? 'CPU'} — default batch {hardware.batch}, imgsz {hardware.imgsz}.
imgsz {hardware.imgsz}. {hardware.note}
</p> </p>
)} )}
<button className="btn btn-primary" onClick={train}
disabled={running || summary.splits.train === 0 || selectedBatchIds.length === 0 || selectedClassIds.length === 0}> <button
{running ? 'Training…' : 'Start training'} className="btn btn-primary"
style={{ width: '100%', padding: '8px 14px', fontSize: '0.85rem', cursor: 'pointer', borderRadius: 6 }}
onClick={train}
disabled={running || selectedDatasetIds.length === 0 || selectedClassIds.length === 0}
>
{running ? 'Training…' : 'Start Training'}
</button> </button>
{summary.splits.train === 0 && ( {summary.splits.train === 0 && (
<p className="hint">Approve and merge a batch first.</p> <p className="hint" style={{ fontSize: '0.78rem', marginTop: 8 }}>Approve and merge a batch first.</p>
)}
{selectedBatchIds.length === 0 && summary.splits.train > 0 && (
<p className="hint" style={{ color: '#ef4444' }}>Select at least one dataset batch to train.</p>
)} )}
{selectedClassIds.length === 0 && ( {selectedClassIds.length === 0 && (
<p className="hint" style={{ color: '#ef4444' }}>Select at least one class to train.</p> <p className="hint" style={{ color: '#ef4444', fontSize: '0.78rem', marginTop: 8 }}>Select at least one target class.</p>
)} )}
</div> </div>
{job && ( {/* Which datasets this run trains on (REQ-111) */}
<div className="panel side-panel"> <div className="panel side-panel">
<h2>Job {job.id}</h2> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div className="row"> <h2 style={{ fontSize: '0.95rem', margin: 0 }}>
<span className={`dot ${job.status === 'done' ? 'ok' : job.status === 'failed' ? 'bad' : ''}`} /> Select Datasets ({selectedDatasetIds.length}/{datasetRows.length})
<span>{job.status}</span> </h2>
<span className="spacer" /> <button
<span className="mono">{job.progress}/{job.total}</span> className="btn btn-ghost"
style={{ fontSize: '0.75rem', padding: '2px 8px', cursor: 'pointer' }}
onClick={() => setSelectedDatasetIds(
selectedDatasetIds.length === datasetRows.length ? [] : datasetRows.map((d) => d.id))}
>
{selectedDatasetIds.length === datasetRows.length ? 'Deselect All' : 'Select All'}
</button>
</div>
<ul className="shape-list" style={{ marginTop: 8, maxHeight: 200, overflowY: 'auto' }}>
{datasetRows.map((item) => (
<li key={item.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<input
type="checkbox"
checked={selectedDatasetIds.includes(item.id)}
onChange={() => setSelectedDatasetIds((prev) =>
prev.includes(item.id) ? prev.filter((id) => id !== item.id) : [...prev, item.id])}
style={{ cursor: 'pointer' }}
/>
<span
className="shape-pick"
style={{ flex: 1, cursor: 'pointer', fontSize: '0.82rem' }}
onClick={() => setSelectedDatasetIds((prev) =>
prev.includes(item.id) ? prev.filter((id) => id !== item.id) : [...prev, item.id])}
>
{item.name}
</span>
<span className="mono" style={{ fontSize: '0.75rem', color: '#a1a1aa' }}>{item.total}</span>
</li>
))}
</ul>
{combined && (
<p className="hint" style={{ fontSize: '0.78rem', marginTop: 8, lineHeight: 1.5 }}>
This run sees <strong>{combined.total_unique.toLocaleString()}</strong> unique images
{' '}({combined.splits.train.toLocaleString()} train / {combined.splits.val.toLocaleString()} val).
{combined.shared_frames > 0 && (
<> {combined.shared_frames.toLocaleString()} frame(s) are in more than one dataset —
counted once, labels taken from the newest.</>
)}
</p>
)}
{selectedDatasetIds.length === 0 && (
<p className="hint" style={{ color: '#ef4444', fontSize: '0.78rem', marginTop: 8 }}>
Select at least one dataset.
</p>
)}
{baseRows.length > 0 && (
<div style={{ marginTop: 16, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<div className="row" style={{ justifyContent: 'space-between', alignItems: 'baseline' }}>
<strong style={{ fontSize: '0.85rem' }}>Base datasets</strong>
<span className="hint" style={{ fontSize: '0.75rem' }}>externally labelled · train only</span>
</div>
<ul className="shape-list" style={{ marginTop: 8 }}>
{baseRows.map((item) => {
const on = selectedBaseIds.includes(item.id)
const toggle = () => setSelectedBaseIds((prev) =>
prev.includes(item.id) ? prev.filter((id) => id !== item.id) : [...prev, item.id])
return (
<li key={item.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<input type="checkbox" checked={on} onChange={toggle} style={{ cursor: 'pointer' }} />
<span className="shape-pick" onClick={toggle} style={{ flex: 1, cursor: 'pointer', fontSize: '0.82rem' }}>
{item.name}
</span>
<span className="mono" style={{ fontSize: '0.75rem', color: '#a1a1aa' }}>
{item.image_count.toLocaleString()} img · {item.box_count.toLocaleString()} box
</span>
</li>
)
})}
</ul>
{selectedBaseIds.length > 0 && (
<p className="hint" style={{ fontSize: '0.78rem', marginTop: 8, lineHeight: 1.5 }}>
Adds <strong>
{baseRows.filter((b) => selectedBaseIds.includes(b.id))
.reduce((sum, b) => sum + b.image_count, 0).toLocaleString()}
</strong> training images. They never join the val split, so the base-vs-new
mAP stays measured on this project's own frames.
</p>
)}
</div> </div>
<div className="progress"> )}
</div>
</div>
{/* Right Column: Training Progress & Trained Model Versions */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Active Job Progress */}
{job && (
<div className="panel side-panel" style={{ border: '1px solid rgba(168, 85, 247, 0.4)', background: 'rgba(24, 24, 27, 0.8)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span className={`dot ${job.status === 'done' ? 'ok' : job.status === 'failed' ? 'bad' : ''}`} />
<h2 style={{ fontSize: '0.95rem', margin: 0 }}>Training Job #{job.id} ({job.status})</h2>
<span style={{ marginLeft: 'auto', fontFamily: 'monospace', fontSize: '0.8rem' }}>{job.progress}/{job.total}</span>
</div>
<div className="progress" style={{ margin: '8px 0' }}>
<span style={{ width: `${job.total ? (job.progress / job.total) * 100 : 0}%` }} /> <span style={{ width: `${job.total ? (job.progress / job.total) * 100 : 0}%` }} />
</div> </div>
{job.error && <p className="error-banner">{job.error}</p>} {job.error && <p className="error-banner">{job.error}</p>}
<pre className="job-log">{job.log.slice(-8).join('\n')}</pre> <pre className="job-log" style={{ background: '#09090b', padding: 10, borderRadius: 6, fontSize: '0.75rem', maxHeight: 150, overflowY: 'auto' }}>
{job.log.slice(-8).join('\n')}
</pre>
{running && ( {running && (
<button className="btn" onClick={() => api.cancelJob(job.id).catch(() => {})}> <button className="btn btn-ghost" style={{ marginTop: 8, fontSize: '0.8rem', cursor: 'pointer' }} onClick={() => api.cancelJob(job.id).catch(() => {})}>
Cancel Cancel Job
</button> </button>
)} )}
{job.status === 'done' && ( {job.status === 'done' && (
<p className="hint"><CheckIcon size={13} /> Finished — the version is listed <p className="hint" style={{ marginTop: 8, color: '#4ade80', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: 4 }}>
on the left.</p> <CheckIcon size={14} /> Training Finished — Version added to list below.
</p>
)} )}
</div> </div>
)} )}
{summary.batches.length > 0 && ( {/* Model Versions History */}
<div className="panel side-panel"> <div>
<div className="row" style={{ marginBottom: 8 }}> <h2 style={{ fontSize: '1.05rem', marginBottom: 12 }}>Trained Model Versions ({models.length})</h2>
<h2>Select Dataset Batches</h2> {models.length === 0 && !running && (
<span className="spacer" /> <p className="empty">No trained model versions yet.</p>
<button className="btn" style={{ fontSize: 11, padding: '2px 8px' }} onClick={toggleSelectAllBatches}> )}
{selectedBatchIds.length === summary.batches.length ? 'Deselect All' : 'Select All'} <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
</button> {models.map((version) => (
</div> <VersionCard key={version.id} version={version} onPromote={load} onError={setError} />
<p className="hint">Check the approved batches you want to include in this training run:</p> ))}
<ul className="shape-list" style={{ marginTop: 8 }}>
{summary.batches.map((item) => {
const isChecked = selectedBatchIds.includes(item.id)
return (
<li key={item.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0' }}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleBatchSelect(item.id)}
style={{ cursor: 'pointer' }}
/>
<span className="shape-pick" style={{ flex: 1, cursor: 'pointer' }} onClick={() => toggleBatchSelect(item.id)}>
{item.date_label} · {item.batch_label}
<span className="faint mono" style={{ marginLeft: 6 }}>{item.images} img</span>
</span>
</li>
)
})}
</ul>
</div> </div>
)} </div>
</aside> </div>
</div> </div>
</> </>
) )
} }
+254 -18
View File
@@ -14,6 +14,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
const [index, setIndex] = useState(0) const [index, setIndex] = useState(0)
const [annotations, setAnnotations] = useState([]) const [annotations, setAnnotations] = useState([])
const [selectedId, setSelectedId] = useState(null) const [selectedId, setSelectedId] = useState(null)
const [mode, setMode] = useState('draw')
const [markedIds, setMarkedIds] = useState([])
const [activeClass, setActiveClass] = useState(0) const [activeClass, setActiveClass] = useState(0)
const [assistMode, setAssistMode] = useState(false) const [assistMode, setAssistMode] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -78,6 +80,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
let cancelled = false let cancelled = false
api.frameAnnotations(frame.id).then((payload) => !cancelled && setAnnotations(payload.annotations)).catch((exc) => !cancelled && setError(exc.message)) api.frameAnnotations(frame.id).then((payload) => !cancelled && setAnnotations(payload.annotations)).catch((exc) => !cancelled && setError(exc.message))
setSelectedId(null) setSelectedId(null)
setMarkedIds([])
return () => { cancelled = true } return () => { cancelled = true }
}, [frame?.id]) }, [frame?.id])
@@ -124,19 +127,84 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
return return
} }
if (!commit) return if (!commit) return
const current = annotations.find((row) => row.id === id) const previous = annotations.find((row) => row.id === id)
if (!current) return if (!previous) return
try { await api.patchAnnotation(id, { geometry: current.geometry }) } catch (exc) { setError(exc.message) } // A commit may carry its own geometry (delete-vertex sends the shortened
// polygon); falling back to the row's geometry covers drag/resize, which
// already wrote through the {local:true} path.
const next = geometry ?? previous.geometry
setAnnotations((rows) => rows.map((row) => (row.id === id ? { ...row, geometry: next } : row)))
try {
await api.patchAnnotation(id, { geometry: next })
} catch (exc) {
setAnnotations((rows) => rows.map((row) => (row.id === id ? previous : row)))
setError(exc.message)
}
} }
const removeSelected = useCallback(async () => { const removeAnnotation = useCallback(async (id) => {
if (selectedId == null || !frame) return if (id == null || !frame) return
const id = selectedId const previous = annotations.find((row) => row.id === id)
if (!previous) return
setAnnotations((rows) => rows.filter((row) => row.id !== id)) setAnnotations((rows) => rows.filter((row) => row.id !== id))
setSelectedId(null) setSelectedId((current) => (current === id ? null : current))
setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: Math.max(0, (row.annotation_count ?? 1) - 1) } : row))) setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: Math.max(0, (row.annotation_count ?? 1) - 1) } : row)))
try { await api.deleteAnnotation(id) } catch (exc) { setError(exc.message) } try {
}, [selectedId, frame]) await api.deleteAnnotation(id)
} catch (exc) {
setAnnotations((rows) => [...rows, previous])
setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: (row.annotation_count ?? 0) + 1 } : row)))
setError(exc.message)
}
}, [annotations, frame])
const removeSelected = useCallback(() => removeAnnotation(selectedId), [removeAnnotation, selectedId])
const onMarquee = useCallback((ids, { add = false, toggle = false } = {}) => {
setMarkedIds((current) => {
if (toggle) {
const id = ids[0]
return current.includes(id) ? current.filter((row) => row !== id) : [...current, id]
}
if (!add) return ids
return [...new Set([...current, ...ids])]
})
}, [])
const markAll = useCallback(() => setMarkedIds(annotations.map((row) => row.id)), [annotations])
const removeMarked = useCallback(async () => {
if (!markedIds.length || !frame) return
if (!window.confirm(`Delete ${markedIds.length} selected shape(s) on this frame?`)) return
const previous = annotations
const doomed = new Set(markedIds)
setAnnotations((rows) => rows.filter((row) => !doomed.has(row.id)))
setMarkedIds([])
patchFrameLocally(frame.id, {
annotation_count: Math.max(0, (frame.annotation_count ?? doomed.size) - doomed.size),
})
try {
await api.bulkDeleteAnnotations([...doomed])
setBatch(await api.getBatch(batchId))
} catch (exc) {
setAnnotations(previous)
patchFrameLocally(frame.id, { annotation_count: previous.length })
setError(exc.message)
}
}, [markedIds, annotations, frame, batchId])
const reclassMarked = useCallback(async (classId) => {
if (!markedIds.length) return
const previous = annotations
const targets = new Set(markedIds)
setAnnotations((rows) => rows.map((row) => (targets.has(row.id) ? { ...row, class_id: classId } : row)))
try {
await api.bulkReclassAnnotations([...targets], classId)
} catch (exc) {
setAnnotations(previous)
setError(exc.message)
}
}, [markedIds, annotations])
const reclass = useCallback(async (classId) => { const reclass = useCallback(async (classId) => {
setActiveClass(classId) setActiveClass(classId)
@@ -147,8 +215,10 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
} catch (exc) { setError(exc.message) } } catch (exc) { setError(exc.message) }
}, [selectedId]) }, [selectedId])
async function approveBatch() { // Review signs frames off; it no longer merges. Data Prep is the gate that
try { await api.approveBatch(batchId); navigate(`/projects/${batch.project_id}/models`) } catch (exc) { setError(exc.message) } // turns a selection of batches into a dataset (REQ-131).
function toDataPrep() {
navigate(`/projects/${batch.project_id}/data-prep?batches=${batchId}`)
} }
async function jumpToPending() { async function jumpToPending() {
@@ -169,17 +239,92 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
} }
}, [frames, index]) }, [frames, index])
const copyPrevious = useCallback(async () => {
if (!frames?.length || index === 0 || !frame) return
const prevFrame = frames[index - 1]
if (!prevFrame || (prevFrame.annotation_count ?? 0) === 0) return
try {
const payload = await api.frameAnnotations(prevFrame.id)
if (!payload.annotations?.length) return
const created = await Promise.all(
payload.annotations.map(a => api.addAnnotation(frame.id, { class_id: a.class_id, geometry: a.geometry }))
)
setAnnotations(rows => [...rows, ...created])
patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + created.length })
if (created.length > 0) setSelectedId(created[0].id)
} catch (exc) {
setError(exc.message)
}
}, [frames, index, frame])
const trackForward = useCallback(async () => {
if (selectedId == null || !frames?.length) return
const current = annotations.find((a) => a.id === selectedId)
if (!current) return
let box = null
if (current.geometry?.type === 'polygon' && current.geometry.coordinates?.[0]) {
const pts = current.geometry.coordinates[0]
let minX = 1, minY = 1, maxX = 0, maxY = 0
for (const [x, y] of pts) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
box = [minX, minY, maxX, maxY]
}
if (!box) return
setBusy(true)
setError('')
try {
let updatedCount = 0
for (let i = 1; i <= 5; i++) {
const nextIdx = index + i
if (nextIdx >= frames.length) break
const targetFrame = frames[nextIdx]
await api.assist(targetFrame.id, { box, class_id: current.class_id })
updatedCount++
patchFrameLocally(targetFrame.id, { annotation_count: (targetFrame.annotation_count ?? 0) + 1 })
}
} catch (exc) {
setError(exc.message)
} finally {
setBusy(false)
}
}, [annotations, selectedId, index, frames])
const stateRef = useRef({}) const stateRef = useRef({})
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode } stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds }
useEffect(() => { useEffect(() => {
function onKeyDown(event) { function onKeyDown(event) {
if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return
const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode } = stateRef.current // Ctrl/Cmd/Alt combos belong to the browser and the OS: without this,
// Ctrl+S approves the frame and Ctrl+A/C/X/N/T all fire review actions.
if (event.ctrlKey || event.metaKey || event.altKey) return
const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds } = stateRef.current
const key = event.key const key = event.key
const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N'].includes(key) const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N', 'c', 'C', 't', 'T', 'v', 'V', 'Escape'].includes(key)
if (isShortcutKey) { event.preventDefault(); event.stopPropagation() } if (isShortcutKey) { event.preventDefault(); event.stopPropagation() }
// In select mode the marquee owns Delete and the digits, otherwise a
// 40-box selection would be thrown away by one keystroke meant for it.
if (mode === 'select') {
if (key === 'v' || key === 'V') { setMode?.('draw'); setMarkedIds?.([]); return }
if (key === 'Escape') { setMarkedIds?.([]); return }
if ((key === 'Delete' || key === 'Backspace') && markedIds?.length) { removeMarked?.(); return }
if (/^[1-9]$/.test(key) && markedIds?.length) {
const classId = Number(key) - 1
if (project && classId < project.classes.length) reclassMarked?.(classId)
return
}
} else if (key === 'v' || key === 'V') { setMode?.('select'); return }
if (key === 's' || key === 'S') setAssistMode?.(true) if (key === 's' || key === 'S') setAssistMode?.(true)
else if (key === 'ArrowLeft') setIndex((i) => Math.max(0, i - 1)) else if (key === 'ArrowLeft') setIndex((i) => Math.max(0, i - 1))
else if (key === 'ArrowRight') setIndex((i) => Math.min((frames?.length || 1) - 1, i + 1)) else if (key === 'ArrowRight') setIndex((i) => Math.min((frames?.length || 1) - 1, i + 1))
@@ -187,6 +332,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
else if (key === 'x' || key === 'X') setStatus?.('rejected') else if (key === 'x' || key === 'X') setStatus?.('rejected')
else if (key === 'u' || key === 'U') jumpToPending?.() else if (key === 'u' || key === 'U') jumpToPending?.()
else if (key === 'n' || key === 'N') jumpToNextAnnotated?.() else if (key === 'n' || key === 'N') jumpToNextAnnotated?.()
else if (key === 'c' || key === 'C') copyPrevious?.()
else if (key === 't' || key === 'T') trackForward?.()
else if (key === 'Delete' || key === 'Backspace') removeSelected?.() else if (key === 'Delete' || key === 'Backspace') removeSelected?.()
else if (/^[1-9]$/.test(key)) { else if (/^[1-9]$/.test(key)) {
const classId = Number(key) - 1 const classId = Number(key) - 1
@@ -249,10 +396,10 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
<button <button
className="btn btn-primary" className="btn btn-primary"
disabled={(batch.review?.pending ?? 0) > 0 || batch.status === 'merged'} disabled={(batch.review?.pending ?? 0) > 0 || batch.status === 'merged'}
title={(batch.review?.pending ?? 0) > 0 ? `${batch.review?.pending} frame(s) still pending` : 'Merge the approved frames into the master dataset'} title={(batch.review?.pending ?? 0) > 0 ? `${batch.review?.pending} frame(s) still pending` : 'Tune the filter for this batch, then merge it into a dataset'}
onClick={approveBatch} onClick={toDataPrep}
> >
{batch.status === 'merged' ? 'Merged' : 'Approve batch'} {batch.status === 'merged' ? 'Merged' : 'Prepare & merge'}
</button> </button>
</div> </div>
@@ -283,13 +430,82 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
activeClass={activeClass} activeClass={activeClass}
assistMode={assistMode} assistMode={assistMode}
classes={classesList} classes={classesList}
mode={mode}
selectedIds={markedIds}
onSelect={setSelectedId} onSelect={setSelectedId}
onCreate={createShape} onCreate={createShape}
onUpdate={updateShape} onUpdate={updateShape}
onAssist={assist} onAssist={assist}
onMarquee={onMarquee}
/> />
)} )}
<div className="frame-bar" style={{ gap: 8 }}>
<div className="mode-switch" role="group" aria-label="Canvas mode">
{[['draw', 'Draw'], ['select', 'Select']].map(([value, label]) => (
<button
key={value}
type="button"
className={`btn${mode === value ? ' btn-primary' : ''}`}
aria-pressed={mode === value}
style={{ padding: '2px 10px', fontSize: '0.78rem' }}
onClick={() => { setMode(value); setMarkedIds([]) }}
title={value === 'draw'
? 'Drag on empty canvas to draw a new box [V to switch]'
: 'Drag to select every box you touch, then delete or reclass them at once [V to switch]'}
>
{label}
</button>
))}
</div>
{mode === 'select' && (
<>
<button
type="button"
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem' }}
onClick={markAll}
disabled={!annotations.length}
>
Select all ({annotations.length})
</button>
<span className="muted" style={{ fontSize: '0.78rem' }}>
{markedIds.length
? `${markedIds.length} selected · Shift-drag adds · Esc clears`
: 'Drag across the boxes you want'}
</span>
</>
)}
</div>
{mode === 'select' && markedIds.length > 0 && (
<div className="frame-bar" style={{ gap: 8, borderColor: 'rgba(56, 189, 248, 0.4)' }}>
<strong style={{ fontSize: '0.82rem', color: '#38bdf8' }}>
{markedIds.length} shape{markedIds.length === 1 ? '' : 's'}
</strong>
<span className="muted" style={{ fontSize: '0.78rem' }}>reclass to</span>
{classesList.map((item) => (
<button
key={item.class_id}
type="button"
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem' }}
onClick={() => reclassMarked(item.class_id)}
title={`Move all ${markedIds.length} into "${item.name}" [${item.class_id + 1}]`}
>
[{item.class_id + 1}] {item.name}
</button>
))}
<span className="spacer" />
<button type="button" className="btn" style={{ padding: '2px 8px', fontSize: '0.78rem' }} onClick={() => setMarkedIds([])}>
Clear [Esc]
</button>
<button type="button" className="btn btn-danger" style={{ padding: '2px 8px', fontSize: '0.78rem' }} onClick={removeMarked}>
<XIcon size={13} /> Delete {markedIds.length} [Del]
</button>
</div>
)}
<div className="frame-bar"> <div className="frame-bar">
<button className="btn" onClick={() => setIndex((i) => Math.max(0, i - 1))} disabled={index === 0}>←</button> <button className="btn" onClick={() => setIndex((i) => Math.max(0, i - 1))} disabled={index === 0}>←</button>
<span className="mono"> <span className="mono">
@@ -306,6 +522,26 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
> >
🏷️ Next Shape [N] 🏷️ Next Shape [N]
</button> </button>
<button
type="button"
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem', borderColor: 'rgba(168, 85, 247, 0.5)', color: '#c084fc', background: 'rgba(168, 85, 247, 0.1)' }}
onClick={copyPrevious}
disabled={index === 0 || !(frames?.[index - 1]?.annotation_count > 0)}
title="Copy all annotations from the previous frame [C]"
>
📋 Copy Prev [C]
</button>
<button
type="button"
className="btn"
style={{ padding: '2px 8px', fontSize: '0.78rem', borderColor: 'rgba(56, 189, 248, 0.5)', color: '#38bdf8', background: 'rgba(56, 189, 248, 0.1)' }}
onClick={trackForward}
disabled={selectedId == null || index >= frames.length - 1}
title="SAM3 auto-track selected shape for next 5 frames [T]"
>
🚀 Track 5 Frames [T]
</button>
<span className="spacer" /> <span className="spacer" />
{busy && <span className="muted">asking SAM3…</span>} {busy && <span className="muted">asking SAM3…</span>}
<button className="btn btn-danger" onClick={() => setStatus('rejected')}><XIcon size={14} /> Reject [X]</button> <button className="btn btn-danger" onClick={() => setStatus('rejected')}><XIcon size={14} /> Reject [X]</button>
@@ -327,7 +563,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
annotations={annotations} annotations={annotations}
selectedId={selectedId} selectedId={selectedId}
setSelectedId={setSelectedId} setSelectedId={setSelectedId}
removeSelected={removeSelected} removeAnnotation={removeAnnotation}
project={project} project={project}
jumpToNextAnnotated={jumpToNextAnnotated} jumpToNextAnnotated={jumpToNextAnnotated}
batchAnnotationCount={batch?.annotation_count ?? 0} batchAnnotationCount={batch?.annotation_count ?? 0}
+90 -83
View File
@@ -2,6 +2,7 @@
.roboflow-layout { .roboflow-layout {
display: flex; display: flex;
flex-direction: column;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
@@ -9,95 +10,97 @@
color: var(--text, #f3f4f6); color: var(--text, #f3f4f6);
} }
.roboflow-sidebar { .roboflow-topbar {
width: 260px; height: 48px;
background: rgba(17, 24, 39, 0.65); background: rgba(17, 24, 39, 0.85);
border-right: 1px solid rgba(255, 255, 255, 0.12); backdrop-filter: blur(12px);
display: flex; border-bottom: 1px solid rgba(255, 255, 255, 0.12);
flex-direction: column;
padding: 16px 0;
flex-shrink: 0;
transition: width 0.2s ease, padding 0.2s ease;
}
.roboflow-sidebar.collapsed {
width: 64px;
}
.sidebar-header {
padding: 0 20px;
margin-bottom: 24px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 16px;
flex-shrink: 0;
z-index: 100;
} }
.roboflow-sidebar.collapsed .sidebar-header { .topbar-logo {
padding: 0 12px;
justify-content: center;
}
.sidebar-collapse-btn {
background: none;
border: none;
color: var(--text-faint, #9ca3af);
cursor: pointer;
padding: 4px;
font-size: 12px;
border-radius: 4px;
transition: background 0.15s, color 0.15s;
}
.sidebar-collapse-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text);
}
.sidebar-logo {
font-weight: 700;
font-size: 16px;
color: var(--text);
white-space: nowrap;
}
.roboflow-sidebar.collapsed .sidebar-item {
padding: 12px 0;
text-align: center;
display: flex;
justify-content: center;
}
.sidebar-icon {
font-size: 16px;
margin-right: 8px;
}
.roboflow-sidebar.collapsed .sidebar-icon {
margin-right: 0;
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-section-title {
padding: 0 20px;
font-size: 11px;
font-weight: 600;
color: var(--text-faint, #9ca3af);
margin-bottom: 8px;
letter-spacing: 0.05em;
}
.sidebar-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 8px 20px; padding: 4px 8px;
color: var(--text-muted, #d1d5db); }
.topbar-nav {
display: flex;
align-items: center;
gap: 4px;
}
.topbar-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
color: #a1a1aa;
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 0.82rem;
font-weight: 500;
border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition: all 0.15s ease;
}
.topbar-item:hover {
color: #f4f4f5;
background: rgba(255, 255, 255, 0.08);
}
.topbar-item.active {
color: #ffffff;
background: rgba(255, 255, 255, 0.14);
font-weight: 600;
}
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.topbar-health {
display: flex;
align-items: center;
gap: 6px;
}
.health-badge {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: #a1a1aa;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.health-badge.ready {
background: rgba(34, 197, 94, 0.12);
color: #4ade80;
border-color: rgba(34, 197, 94, 0.25);
}
.topbar-theme-toggle {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #f4f4f5;
padding: 5px 8px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
transition: background 0.15s;
}
.topbar-theme-toggle:hover {
background: rgba(255, 255, 255, 0.16);
} }
.sidebar-item:hover { .sidebar-item:hover {
@@ -154,12 +157,16 @@
.roboflow-main { .roboflow-main {
flex: 1; flex: 1;
display: flex; height: calc(100vh - 48px);
flex-direction: column; max-height: calc(100vh - 48px);
overflow: hidden; overflow-y: auto !important;
overflow-x: hidden;
padding: 16px 20px;
position: relative; position: relative;
box-sizing: border-box;
} }
/* Models page engine cards */ /* Models page engine cards */
.select-engine-section { .select-engine-section {
margin-bottom: 32px; margin-bottom: 32px;
+3
View File
@@ -18,3 +18,6 @@ pycocotools
# sam3/model_builder.py still imports pkg_resources, which setuptools 81+ dropped # sam3/model_builder.py still imports pkg_resources, which setuptools 81+ dropped
setuptools<81 setuptools<81
psutil
shapely
lap
+69
View File
@@ -0,0 +1,69 @@
import re
def main():
path = '/home/asus/reTraining/frontend/src/pages/LibraryPage.jsx'
with open(path, 'r') as f:
content = f.read()
# 1. Add import AutoAnnotateModal
if "import AutoAnnotateModal" not in content:
content = content.replace("import React,", "import AutoAnnotateModal from '../components/AutoAnnotateModal'\nimport React,")
# 2. Replace state definitions
# Replace appendModalState, sam3AppendState, customPromptInput, baseModelModalState with autoAnnotateConfig
content = re.sub(r'const \[appendModalState.*?\n', '', content)
content = re.sub(r'const \[sam3AppendState.*?\n', '', content)
content = re.sub(r'const \[customPromptInput.*?\n', '', content)
content = re.sub(r'const \[baseModelModalState.*?\n', ' const [autoAnnotateConfig, setAutoAnnotateConfig] = useState(null)\n', content)
# 3. Replace openBaseModelAutolabelModal
base_model_func = """ function openBaseModelAutolabelModal(batch) {
setAutoAnnotateConfig({ batch, project, engine: 'base_model' })
}"""
content = re.sub(r' function openBaseModelAutolabelModal.*?\}', base_model_func, content, flags=re.DOTALL)
# 4. Replace openSam3AppendModal
sam3_func = """ function openSam3AppendModal(batch) {
setAppendChoiceBatch(null)
setAutoAnnotateConfig({ batch, project, engine: 'sam3' })
}"""
content = re.sub(r' function openSam3AppendModal.*?\}', sam3_func, content, flags=re.DOTALL)
# 5. Modify openFilePickerForYolo
# We replace from "const info = await api.inspectModel(file)" to the end of the try block.
# Actually let's just replace setAppendModalState( ... )
yolo_replacement = """ setAutoAnnotateConfig({
batch,
project,
engine: 'custom',
customModelStagedPath: info.staged_path,
customModelClasses: info.classes || []
})"""
content = re.sub(r' setAppendModalState\(\{[\s\S]*?\}\)', yolo_replacement, content, flags=re.DOTALL)
# 6. Remove all 3 modals from the JSX, replace with AutoAnnotateModal
# Let's find the start of SAM3 Append Modal
sam3_idx = content.find("{/* SAM3 Append Modal */}")
if sam3_idx != -1:
# Find the end of the Fragment "</>"
end_idx = content.find(" </>\n )\n}", sam3_idx)
if end_idx != -1:
new_jsx = """ {autoAnnotateConfig && (
<AutoAnnotateModal
{...autoAnnotateConfig}
onClose={() => setAutoAnnotateConfig(null)}
onSuccess={() => {
setAutoAnnotateConfig(null)
onChanged()
}}
/>
)}
"""
content = content[:sam3_idx] + new_jsx + content[end_idx:]
with open(path, 'w') as f:
f.write(content)
if __name__ == '__main__':
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Transcode video archive files to browser-compatible H.264 MP4 format."""
import os
import sys
import subprocess
import shutil
from pathlib import Path
VIDEO_EXTS = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v"}
def probe_codec(file_path: Path) -> str:
cmd = [
"ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=codec_name",
"-of", "default=noprint_wrappers=1:nokey=1", str(file_path)
]
res = subprocess.run(cmd, capture_output=True, text=True)
return res.stdout.strip().lower()
def transcode_video(file_path: Path):
tmp_path = file_path.with_name(f"_transcoded_{file_path.stem}.mp4")
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(file_path),
"-c:v", "libx264", "-preset", "fast", "-crf", "22",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
str(tmp_path)
]
res = subprocess.run(cmd)
if res.returncode == 0 and tmp_path.exists() and tmp_path.stat().st_size > 0:
# Save as .mp4
target_path = file_path.with_suffix(".mp4")
if target_path != file_path and file_path.exists():
file_path.unlink()
shutil.move(str(tmp_path), str(target_path))
print(f" ✅ Transcoded -> {target_path}")
else:
if tmp_path.exists():
tmp_path.unlink()
print(f" ❌ Failed to transcode {file_path}")
def process_archive(archive_dir: Path):
if not archive_dir.exists():
print(f"Archive directory {archive_dir} does not exist.")
return
print(f"Scanning {archive_dir} for non-H.264 videos...")
for root, _, files in os.walk(archive_dir):
for file in sorted(files):
file_path = Path(root) / file
if file_path.suffix.lower() in VIDEO_EXTS and not file.startswith("_transcoded_"):
codec = probe_codec(file_path)
if not codec:
print(f"⚠️ Could not probe codec or unreadable: {file_path}")
continue
if codec != "h264":
print(f"🔄 Converting {file_path} (current codec: {codec})...")
transcode_video(file_path)
else:
print(f" ✓ Already H.264: {file_path}")
if __name__ == "__main__":
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("data/archive")
process_archive(target)
+13 -4
View File
@@ -50,16 +50,25 @@ else
echo "ℹ️ No NVIDIA GPU detected. Running in CPU-only mode." echo "ℹ️ No NVIDIA GPU detected. Running in CPU-only mode."
fi fi
echo "📦 Starting containers..." if docker info &> /dev/null; then
docker compose up -d --build echo "📦 Starting containers..."
docker compose up -d --build
PORT=${WEB_PORT:-8080}
else
echo "⚠️ Docker is not accessible (or requires root permissions)."
echo "🚀 Starting app in local development mode..."
uv run uvicorn backend.main:app --host 0.0.0.0 --port 8000 &
npm --prefix frontend run dev -- --host 0.0.0.0 &
PORT=5173
fi
# Get local IP for convenience # Get local IP for convenience
LOCAL_IP=$(hostname -I | awk '{print $1}' || echo "localhost") LOCAL_IP=$(hostname -I | awk '{print $1}' || echo "localhost")
PORT=${WEB_PORT:-8080}
echo "" echo ""
echo "==========================================================" echo "=========================================================="
echo "✅ App is successfully running!" echo "✅ App is running!"
echo "🌐 Access it locally at: http://localhost:$PORT" echo "🌐 Access it locally at: http://localhost:$PORT"
echo "📱 Access it on your network at: http://$LOCAL_IP:$PORT" echo "📱 Access it on your network at: http://$LOCAL_IP:$PORT"
echo "==========================================================" echo "=========================================================="