feat: add counting bench, triage, and dataset modules
This commit includes major additions and updates to the frontend and backend architectures, introducing new dataset management, live counting features, batch processing, and triage logic. Includes new UI pages, components, and API routes.
This commit is contained in:
1 parent
8285400254
commit
5c7c122105
80 files changed
+19984
-1322
No files matched your search
@@ -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
@@ -29,6 +29,14 @@ RUN uv pip install -r requirements.txt
|
||||
COPY sam3/ ./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/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
"""
|
||||
Batch Video Cropper — Rekam Video RTSP per Sesi Batch Truk
|
||||
Batch Video Cropper — Production 24/7
|
||||
Rekam video RTSP per sesi batch truk. Ringan, tanpa GUI, auto-reconnect.
|
||||
|
||||
Program ini membaca livestream CCTV (RTSP), menjalankan algoritma penentuan batch
|
||||
menggunakan YOLO + State Machine, dan menyimpan potongan video per-batch ke folder
|
||||
yang terorganisir berdasarkan tanggal.
|
||||
|
||||
Struktur Output:
|
||||
~/reTraining/data/archive/
|
||||
├── 2026-08-05/
|
||||
│ ├── batch_1_09-15-30.mp4
|
||||
│ ├── batch_2_10-22-45.mp4
|
||||
│ └── batch_3_14-08-12.mp4
|
||||
└── 2026-08-06/
|
||||
└── batch_1_07-30-00.mp4
|
||||
Output:
|
||||
~/reTraining/data/archive/{YYYY-MM-DD}/batch_{N}_{HH-MM-SS}.mp4
|
||||
|
||||
Menjalankan:
|
||||
cd ~/reTraining/algoritma-batch
|
||||
@@ -20,120 +11,168 @@ Menjalankan:
|
||||
"""
|
||||
|
||||
import os
|
||||
# KRITIS: Konfigurasi RTSP transport — HARUS sebelum import cv2
|
||||
# Tanpa ini, OpenCV pakai UDP (default) yang sering drop koneksi
|
||||
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = (
|
||||
"rtsp_transport;tcp|buffer_size;20480000|max_delay;500000|reorder_queue_size;500"
|
||||
)
|
||||
import json
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import cv2
|
||||
import numpy as np
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
import platform
|
||||
from datetime import datetime, timedelta
|
||||
from shapely.geometry import Point, Polygon
|
||||
from ultralytics import YOLO
|
||||
|
||||
# Import modules
|
||||
from src.tracking import ByteTrackTracker
|
||||
from src.stabilizer import BboxStabilizer
|
||||
from src.truck_roi import TruckROI
|
||||
from src.counting import LineCrossCounter
|
||||
from src.batch import BatchLifecycleManager, BatchRecord
|
||||
from src.batch import BatchLifecycleManager, BatchState
|
||||
|
||||
# =====================================================================
|
||||
# 1. KONFIGURASI
|
||||
# KONFIGURASI
|
||||
# =====================================================================
|
||||
|
||||
import platform
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
# --- Path Konfigurasi ---
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ZONES_JSON = os.path.join(BASE_DIR, "zones.json")
|
||||
|
||||
if IS_WINDOWS:
|
||||
MODEL_PATH = os.path.join(BASE_DIR, "v1-best.pt")
|
||||
MODEL_PATH = os.path.join(BASE_DIR, "v3-best.pt")
|
||||
ARCHIVE_BASE = os.path.join(BASE_DIR, "archive_output")
|
||||
RTSP_URL = "video truk.mp4" # Testing lokal video
|
||||
else:
|
||||
MODEL_PATH = os.path.join(BASE_DIR, "v1-best.pt")
|
||||
MODEL_PATH = os.path.join(BASE_DIR, "v3-best.pt")
|
||||
ARCHIVE_BASE = os.path.expanduser("~/reTraining/data/archive")
|
||||
RTSP_URL = "rtsp://192.168.192.96:8554/cam" # Production RTSP stream (.105)
|
||||
|
||||
# --- Sumber Video RTSP ---
|
||||
RTSP_URL = "rtsp://frigate:zenai@192.168.192.209:8554/camera_stream_640"
|
||||
DAILY_CUTOFF_TIME = "00:00"
|
||||
|
||||
# --- Batas Pergantian Hari (Cutoff) ---
|
||||
DAILY_CUTOFF_TIME = "20:00"
|
||||
# State Machine
|
||||
SACK_IDLE_TIMEOUT = 5.0
|
||||
MIN_BATCH_DURATION = 2.0
|
||||
TRUCK_GONE_TOLERANCE = 5.0
|
||||
|
||||
# --- Parameter State Machine ---
|
||||
SACK_IDLE_TIMEOUT = 5.0 # Jeda aktivitas sebelum masuk WAITING_FOR_ACTIVITY
|
||||
MIN_BATCH_DURATION = 2.0 # Durasi minimal batch sebelum boleh masuk WAITING
|
||||
TOLERANCE_LOW_COUNT = 0.0 # Instan (0s) — batch langsung berakhir saat area truk kosong
|
||||
TOLERANCE_MED_COUNT = 0.0
|
||||
TOLERANCE_HIGH_COUNT = 0.0
|
||||
# Pengambilan video dari MediaMTX (REQ-170)
|
||||
# Jetson merekam 24/7 apa adanya; skrip ini hanya menentukan potongannya.
|
||||
# Sebelumnya frame di-encode ulang ke mpeg4 di sini: 4,7x lebih besar dari
|
||||
# sumbernya, kualitas turun, dan fps-nya salah. Sekarang potongan diunduh
|
||||
# sebagai salinan, jadi codec, fps dan waktunya persis seperti kamera.
|
||||
PLAYBACK_URL = os.getenv("PLAYBACK_URL", "http://192.168.192.96:9996/get")
|
||||
PLAYBACK_PATH = os.getenv("PLAYBACK_PATH", "cam")
|
||||
FETCH_PAD_BEFORE = 3.0 # detik diambil sebelum truk terdeteksi
|
||||
FETCH_PAD_AFTER = 3.0 # dan sesudahnya, supaya tidak terpotong
|
||||
FETCH_RETRIES = 3
|
||||
FETCH_RETRY_DELAY = 20.0
|
||||
|
||||
# --- Video Recording ---
|
||||
VIDEO_FPS = 10.0 # FPS output video (10 fps sudah cukup untuk rekaman arsip)
|
||||
VIDEO_CODEC = "mp4v" # Codec untuk .mp4
|
||||
# Video Recording
|
||||
# Diambil dari stream yang diterima, bukan ditebak. Angka 10.0 yang dulu
|
||||
# di-hardcode membuat SETIAP file di arsip punya timebase salah: kamera
|
||||
# mengirim 25 fps, file mengaku 10 fps, jadi rekaman 15,4 menit tersimpan
|
||||
# sebagai 38,3 menit dan diputar 2,49x lebih lambat dari kenyataan.
|
||||
# Dipakai hanya kalau fps stream tidak terbaca.
|
||||
FALLBACK_FPS = 25.0
|
||||
MIN_FPS, MAX_FPS = 1.0, 60.0
|
||||
VIDEO_CODEC = "mp4v"
|
||||
|
||||
# Reconnect
|
||||
RECONNECT_DELAY = 5 # Detik menunggu sebelum reconnect RTSP
|
||||
MAX_EMPTY_FRAMES = 300 # Maks frame kosong sebelum reconnect (~30 detik)
|
||||
|
||||
# Matikan tampilan visualisasi agar program sangat ringan 24/7
|
||||
SHOW_DISPLAY = False
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 2. THREADED RTSP READER (Menghindari Lag Buffer)
|
||||
# THREADED RTSP READER (selalu ambil frame terbaru, anti-lag)
|
||||
# =====================================================================
|
||||
class RTSPStreamReader:
|
||||
"""Threaded RTSP reader yang selalu mengambil frame terbaru."""
|
||||
|
||||
def __init__(self, source_url):
|
||||
self.source_url = source_url
|
||||
self.cap = cv2.VideoCapture(source_url)
|
||||
class RTSPReader:
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
self.fps = FALLBACK_FPS
|
||||
self.cap = None
|
||||
self.frame = None
|
||||
self.ret = False
|
||||
self.running = True
|
||||
self.lock = threading.Lock()
|
||||
self.new_frame_event = threading.Event()
|
||||
self.thread = threading.Thread(target=self._update, daemon=True)
|
||||
self.event = threading.Event()
|
||||
self._connect()
|
||||
self.thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _update(self):
|
||||
def _connect(self):
|
||||
if self.cap and self.cap.isOpened():
|
||||
self.cap.release()
|
||||
self.cap = cv2.VideoCapture(self.url)
|
||||
if self.cap.isOpened():
|
||||
self.fps = self._read_fps()
|
||||
log(f"RTSP terhubung ({self.fps:.1f} fps)")
|
||||
else:
|
||||
log("RTSP gagal terhubung")
|
||||
|
||||
def _read_fps(self):
|
||||
"""Fps yang diumumkan stream, dibatasi ke rentang masuk akal."""
|
||||
try:
|
||||
reported = float(self.cap.get(cv2.CAP_PROP_FPS) or 0.0)
|
||||
except Exception:
|
||||
reported = 0.0
|
||||
if MIN_FPS <= reported <= MAX_FPS:
|
||||
return reported
|
||||
log(f"Fps stream tidak masuk akal ({reported}), pakai {FALLBACK_FPS}")
|
||||
return FALLBACK_FPS
|
||||
|
||||
def _loop(self):
|
||||
empty = 0
|
||||
while self.running:
|
||||
if not self.cap.isOpened():
|
||||
print("[RTSP] Stream terputus, mencoba reconnect dalam 5 detik...")
|
||||
time.sleep(5)
|
||||
self.cap = cv2.VideoCapture(self.source_url)
|
||||
if not self.cap or not self.cap.isOpened():
|
||||
log(f"RTSP terputus, reconnect dalam {RECONNECT_DELAY}s...")
|
||||
time.sleep(RECONNECT_DELAY)
|
||||
self._connect()
|
||||
empty = 0
|
||||
continue
|
||||
ret, frame = self.cap.read()
|
||||
if not ret:
|
||||
empty += 1
|
||||
if empty > MAX_EMPTY_FRAMES:
|
||||
log(f"RTSP {empty} frame kosong, reconnect...")
|
||||
self._connect()
|
||||
empty = 0
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
empty = 0
|
||||
with self.lock:
|
||||
self.ret = ret
|
||||
self.frame = frame
|
||||
self.new_frame_event.set()
|
||||
self.ret, self.frame = ret, frame
|
||||
self.event.set()
|
||||
time.sleep(0.001)
|
||||
|
||||
def read(self):
|
||||
if self.new_frame_event.wait(timeout=2.0):
|
||||
self.new_frame_event.clear()
|
||||
if self.event.wait(timeout=2.0):
|
||||
self.event.clear()
|
||||
with self.lock:
|
||||
if self.frame is None:
|
||||
return self.ret, self.frame.copy() if self.frame is not None else (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):
|
||||
self.running = False
|
||||
if self.cap.isOpened():
|
||||
if self.cap:
|
||||
self.cap.release()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 3. FUNGSI UTILITAS TANGGAL & FOLDER
|
||||
# UTILITAS
|
||||
# =====================================================================
|
||||
def get_counting_date(dt=None):
|
||||
"""Menentukan tanggal kerja berdasarkan cutoff harian."""
|
||||
if dt is None:
|
||||
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()
|
||||
@@ -148,338 +187,427 @@ def get_counting_date(dt=None):
|
||||
return (dt.date() + timedelta(days=1)).isoformat()
|
||||
|
||||
|
||||
def ensure_date_folder(counting_date):
|
||||
"""Membuat folder tanggal di archive jika belum ada. Mengembalikan path folder."""
|
||||
def get_next_batch_number_from_files(counting_date):
|
||||
folder = os.path.join(ARCHIVE_BASE, counting_date)
|
||||
if not os.path.exists(folder):
|
||||
return 1
|
||||
max_num = 0
|
||||
try:
|
||||
import re
|
||||
pattern = re.compile(r'^batch_?(\d+)\.mp4$', re.IGNORECASE)
|
||||
for filename in os.listdir(folder):
|
||||
match = pattern.match(filename)
|
||||
if match:
|
||||
num = int(match.group(1))
|
||||
max_num = max(max_num, num)
|
||||
except Exception as e:
|
||||
log(f"Error scanning folder for batch files: {e}")
|
||||
return max_num + 1
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# VIDEO RECORDER
|
||||
# =====================================================================
|
||||
class SessionFetcher:
|
||||
"""Tandai kapan satu sesi truk mulai dan selesai, lalu unduh potongannya.
|
||||
|
||||
Mengunduh dilakukan di thread terpisah supaya loop deteksi tidak berhenti
|
||||
menunggu jaringan — satu sesi 40 menit bisa ratusan MB. Kalau gagal, dicoba
|
||||
lagi; buffer di Jetson menyimpan 24 jam, jadi ada banyak waktu untuk pulih.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.batch_num = None
|
||||
self.counting_date = None
|
||||
self.started_at = None
|
||||
self.path = None
|
||||
|
||||
def start(self, batch_num, counting_date, w=1280, h=720, fps=None):
|
||||
self.batch_num = batch_num
|
||||
self.counting_date = counting_date
|
||||
self.started_at = datetime.now()
|
||||
folder = os.path.join(ARCHIVE_BASE, counting_date)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
return folder
|
||||
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}")
|
||||
|
||||
def write(self, frame):
|
||||
"""Tidak ada yang ditulis per frame lagi — Jetson yang merekam."""
|
||||
|
||||
# =====================================================================
|
||||
# 4. BATCH VIDEO RECORDER (Mengelola VideoWriter per Batch)
|
||||
# =====================================================================
|
||||
class BatchVideoRecorder:
|
||||
"""Mengelola pembukaan dan penutupan file video per sesi batch."""
|
||||
def stop(self, discard=False):
|
||||
if self.started_at is None:
|
||||
return
|
||||
started, path = self.started_at, self.path
|
||||
ended = datetime.now()
|
||||
self.started_at = self.path = None
|
||||
if discard:
|
||||
log(f"REC DISCARD -> {path} tidak diunduh (batch tidak valid)")
|
||||
return
|
||||
threading.Thread(target=self._fetch, args=(path, started, ended),
|
||||
daemon=True).start()
|
||||
|
||||
def __init__(self, archive_base, video_fps=10.0, codec="mp4v"):
|
||||
self.archive_base = archive_base
|
||||
self.video_fps = video_fps
|
||||
self.codec = codec
|
||||
self.writer = None
|
||||
self.current_path = None
|
||||
self.frame_count = 0
|
||||
def _fetch(self, path, started, ended):
|
||||
begin = started - timedelta(seconds=FETCH_PAD_BEFORE)
|
||||
duration = (ended - started).total_seconds() + FETCH_PAD_BEFORE + FETCH_PAD_AFTER
|
||||
# '+' pada offset zona waktu wajib di-encode; kalau tidak, ia terbaca
|
||||
# sebagai spasi dan MediaMTX menolak dengan "invalid start".
|
||||
start_param = urllib.parse.quote(begin.astimezone().isoformat(timespec="seconds"),
|
||||
safe="")
|
||||
url = (f"{PLAYBACK_URL}?path={PLAYBACK_PATH}&start={start_param}"
|
||||
f"&duration={duration:.0f}&format=mp4")
|
||||
|
||||
def start_recording(self, batch_number, counting_date, frame_width=1280, frame_height=720):
|
||||
"""Membuka file video baru untuk batch ini."""
|
||||
self.stop_recording() # Pastikan writer sebelumnya ditutup
|
||||
|
||||
folder = ensure_date_folder(counting_date)
|
||||
timestamp_str = datetime.now().strftime("%H-%M-%S")
|
||||
filename = f"batch_{batch_number}_{timestamp_str}.mp4"
|
||||
self.current_path = os.path.join(folder, filename)
|
||||
|
||||
fourcc = cv2.VideoWriter_fourcc(*self.codec)
|
||||
self.writer = cv2.VideoWriter(
|
||||
self.current_path, fourcc, self.video_fps, (frame_width, frame_height)
|
||||
)
|
||||
self.frame_count = 0
|
||||
|
||||
if self.writer.isOpened():
|
||||
print(f"[RECORD] Mulai merekam video batch #{batch_number} -> {self.current_path}")
|
||||
else:
|
||||
print(f"[RECORD ERROR] Gagal membuka VideoWriter untuk: {self.current_path}")
|
||||
self.writer = None
|
||||
|
||||
def write_frame(self, frame):
|
||||
"""Menulis satu frame ke video aktif."""
|
||||
if self.writer is not None and self.writer.isOpened():
|
||||
self.writer.write(frame)
|
||||
self.frame_count += 1
|
||||
|
||||
def stop_recording(self):
|
||||
"""Menutup file video yang sedang aktif."""
|
||||
if self.writer is not None:
|
||||
self.writer.release()
|
||||
self.writer = None
|
||||
if self.current_path and self.frame_count > 0:
|
||||
print(f"[RECORD] Video selesai disimpan: {self.current_path} ({self.frame_count} frames)")
|
||||
elif self.current_path and self.frame_count == 0:
|
||||
# Hapus file kosong
|
||||
for attempt in range(1, FETCH_RETRIES + 1):
|
||||
try:
|
||||
os.remove(self.current_path)
|
||||
print(f"[RECORD] File video kosong dihapus: {self.current_path}")
|
||||
except Exception:
|
||||
tmp = f"{path}.part"
|
||||
with urllib.request.urlopen(url, timeout=600) as response:
|
||||
if response.status != 200:
|
||||
raise IOError(f"HTTP {response.status}")
|
||||
with open(tmp, "wb") as handle:
|
||||
shutil.copyfileobj(response, handle)
|
||||
size = os.path.getsize(tmp)
|
||||
if size < 1024:
|
||||
raise IOError(f"hasil terlalu kecil ({size} byte)")
|
||||
os.replace(tmp, path)
|
||||
_write_sidecar(path, begin, duration)
|
||||
log(f"REC FETCHED -> {path} ({size/1e6:.0f} MB, {duration:.0f} detik)")
|
||||
return
|
||||
except Exception as exc:
|
||||
log(f"REC FETCH gagal ({attempt}/{FETCH_RETRIES}) {path}: {exc}")
|
||||
try:
|
||||
os.remove(f"{path}.part")
|
||||
except OSError:
|
||||
pass
|
||||
self.current_path = None
|
||||
self.frame_count = 0
|
||||
if attempt < FETCH_RETRIES:
|
||||
time.sleep(FETCH_RETRY_DELAY)
|
||||
log(f"REC FETCH MENYERAH -> {path}. Rekaman masih ada di buffer Jetson "
|
||||
f"selama 24 jam sejak {begin:%Y-%m-%d %H:%M:%S}")
|
||||
|
||||
@property
|
||||
def is_recording(self):
|
||||
return self.writer is not None and self.writer.isOpened()
|
||||
return self.started_at is not None
|
||||
|
||||
|
||||
def _write_sidecar(video_path, begin, duration):
|
||||
"""Waktu sebenarnya, di sebelah videonya.
|
||||
|
||||
Aplikasi tidak perlu lagi membaca jam dari overlay dengan OCR untuk file
|
||||
baru: waktunya datang dari server rekaman, tepat sampai detik.
|
||||
"""
|
||||
payload = {
|
||||
"started_at": begin.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"duration_seconds": round(duration, 1),
|
||||
"source": "mediamtx-playback",
|
||||
"written_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
sidecar = os.path.splitext(video_path)[0] + ".json"
|
||||
try:
|
||||
with open(sidecar, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
except OSError as exc:
|
||||
log(f"Gagal menulis sidecar {sidecar}: {exc}")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 5. FUNGSI UTAMA — LOOP UTAMA DETEKSI & PEREKAMAN
|
||||
# MAIN LOOP
|
||||
# =====================================================================
|
||||
def run_batch_video_cropper():
|
||||
print("=" * 60)
|
||||
print(" BATCH VIDEO CROPPER — RTSP → Per-Batch MP4 Recorder")
|
||||
print("=" * 60)
|
||||
print(f" Model : {MODEL_PATH}")
|
||||
print(f" RTSP : {RTSP_URL}")
|
||||
print(f" Archive : {ARCHIVE_BASE}")
|
||||
print(f" Cutoff : {DAILY_CUTOFF_TIME}")
|
||||
print(f" Tolerance : Instan (0s)")
|
||||
print("=" * 60)
|
||||
shutdown_flag = False
|
||||
|
||||
def handle_signal(sig, _):
|
||||
global shutdown_flag
|
||||
log(f"Signal {sig} diterima, menutup program...")
|
||||
shutdown_flag = True
|
||||
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
|
||||
|
||||
def run():
|
||||
global shutdown_flag
|
||||
|
||||
log("=" * 50)
|
||||
log("BATCH VIDEO CROPPER — Production 24/7")
|
||||
log(f"Model : {MODEL_PATH}")
|
||||
log(f"RTSP : {RTSP_URL}")
|
||||
log(f"Archive : {ARCHIVE_BASE}")
|
||||
log(f"Toleransi batch: {TRUCK_GONE_TOLERANCE}s (truk+karung)")
|
||||
log("=" * 50)
|
||||
|
||||
# Pastikan folder archive ada
|
||||
os.makedirs(ARCHIVE_BASE, exist_ok=True)
|
||||
|
||||
# --- Load Model YOLO ---
|
||||
print("[INFO] Memuat model YOLO...")
|
||||
# Load model
|
||||
log("Memuat model YOLO...")
|
||||
model = YOLO(MODEL_PATH)
|
||||
|
||||
# Warm-up model
|
||||
print("[INFO] Warm-up model...")
|
||||
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||
device = "cuda" if os.path.exists("/usr/local/cuda") else "cpu"
|
||||
# Detect device
|
||||
device = "cpu"
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
device = "cuda"
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Warm-up
|
||||
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||
_ = model(dummy, imgsz=640, device=device, verbose=False)
|
||||
print(f"[INFO] Model siap. Device: {device}")
|
||||
log(f"Model siap. Device: {device}")
|
||||
|
||||
# --- Setup Components ---
|
||||
tracker = ByteTrackTracker(model, conf=0.55)
|
||||
stabilizer = BboxStabilizer(
|
||||
ema_alpha=0.35,
|
||||
max_hold_frames=10,
|
||||
max_height_ratio=1.5,
|
||||
min_height_ratio=0.70,
|
||||
)
|
||||
# Components
|
||||
tracker = ByteTrackTracker(model, conf=0.25)
|
||||
stabilizer = BboxStabilizer(ema_alpha=0.35, max_hold_frames=10,
|
||||
max_height_ratio=1.5, min_height_ratio=0.70)
|
||||
|
||||
# Skala koordinat (kalibrasi 1920x1080 -> 1280x720)
|
||||
scale_x = 1280.0 / 1920.0
|
||||
scale_y = 720.0 / 1080.0
|
||||
# Koordinat zona (1920x1080 → 1280x720)
|
||||
sx, sy = 1280.0 / 1920.0, 720.0 / 1080.0
|
||||
|
||||
# Detection polygon
|
||||
detection_poly_pts = [
|
||||
[int(574 * scale_x), int(50 * scale_y)],
|
||||
[int(586 * scale_x), int(1077 * scale_y)],
|
||||
[int(1418 * scale_x), int(1076 * scale_y)],
|
||||
[int(1397 * scale_x), int(50 * scale_y)],
|
||||
]
|
||||
detection_polygon = Polygon(detection_poly_pts)
|
||||
detection_polygon = Polygon([
|
||||
[int(574*sx), int(50*sy)], [int(586*sx), int(1077*sy)],
|
||||
[int(1418*sx), int(1076*sy)], [int(1397*sx), int(50*sy)],
|
||||
])
|
||||
truck_polygon = Polygon([
|
||||
[int(600*sx), int(385*sy)], [int(609*sx), int(1076*sy)],
|
||||
[int(1404*sx), int(1078*sy)], [int(1381*sx), int(343*sy)],
|
||||
])
|
||||
|
||||
# Truck polygon (untuk monitoring kehadiran karung)
|
||||
truck_poly_pts = [
|
||||
[int(600 * scale_x), int(385 * scale_y)],
|
||||
[int(609 * scale_x), int(1076 * scale_y)],
|
||||
[int(1404 * scale_x), int(1078 * scale_y)],
|
||||
[int(1381 * scale_x), int(343 * scale_y)],
|
||||
]
|
||||
truck_polygon = Polygon(truck_poly_pts)
|
||||
|
||||
# Line crossing
|
||||
static_line_y = int(330 * scale_y)
|
||||
static_line_x_start = int(577 * scale_x)
|
||||
static_line_x_end = int(1401 * scale_x)
|
||||
line_y = int(330 * sy)
|
||||
line_x1 = int(577 * sx)
|
||||
line_x2 = int(1401 * sx)
|
||||
|
||||
static_roi = TruckROI(
|
||||
x1=int(600 * scale_x),
|
||||
y1=int(343 * scale_y),
|
||||
x2=int(1404 * scale_x),
|
||||
y2=int(1078 * scale_y),
|
||||
line_y=static_line_y,
|
||||
confidence=1.0,
|
||||
)
|
||||
|
||||
counter = LineCrossCounter(
|
||||
line_y=static_line_y,
|
||||
line_x_start=static_line_x_start,
|
||||
line_x_end=static_line_x_end,
|
||||
margin=20,
|
||||
dedup_radius=60.0,
|
||||
x1=int(600*sx), y1=int(343*sy), x2=int(1404*sx), y2=int(1078*sy),
|
||||
line_y=line_y, confidence=1.0,
|
||||
)
|
||||
counter = LineCrossCounter(line_y=line_y, line_x_start=line_x1,
|
||||
line_x_end=line_x2, margin=20, dedup_radius=60.0)
|
||||
|
||||
batch_mgr = BatchLifecycleManager(
|
||||
stabilize_seconds=0.0,
|
||||
stabilize_threshold_px=9999.0,
|
||||
stabilize_seconds=0.0, stabilize_threshold_px=9999.0,
|
||||
sack_idle_timeout=SACK_IDLE_TIMEOUT,
|
||||
min_batch_duration=MIN_BATCH_DURATION,
|
||||
truck_gone_tolerance=TOLERANCE_LOW_COUNT,
|
||||
truck_gone_tolerance=3.0,
|
||||
)
|
||||
|
||||
recorder = BatchVideoRecorder(
|
||||
archive_base=ARCHIVE_BASE,
|
||||
video_fps=VIDEO_FPS,
|
||||
codec=VIDEO_CODEC,
|
||||
)
|
||||
recorder = SessionFetcher()
|
||||
|
||||
# --- Buka RTSP Stream ---
|
||||
print(f"[INFO] Membuka RTSP stream: {RTSP_URL}")
|
||||
cap = RTSPStreamReader(RTSP_URL)
|
||||
if not cap.isOpened():
|
||||
print(f"[ERROR] Gagal membuka RTSP stream: {RTSP_URL}")
|
||||
return
|
||||
# RTSP Stream
|
||||
log(f"Membuka RTSP: {RTSP_URL}")
|
||||
cap = RTSPReader(RTSP_URL)
|
||||
|
||||
# Tracking state
|
||||
batch_counter = 0
|
||||
frame_idx = 0
|
||||
last_fps_time = time.time()
|
||||
fps_counter = 0
|
||||
last_status_time = time.time()
|
||||
truck_seen_in_current_batch = False
|
||||
last_frame_time = time.time()
|
||||
NO_FRAME_BATCH_TIMEOUT = 30.0 # Akhiri batch jika tidak ada frame 30 detik
|
||||
|
||||
print("\n[INFO] Memulai loop utama... Tekan Ctrl+C untuk berhenti.\n")
|
||||
log("Loop utama dimulai...")
|
||||
|
||||
try:
|
||||
while True:
|
||||
while not shutdown_flag:
|
||||
ret, frame = cap.read()
|
||||
if not ret or frame is None:
|
||||
# Saat tidak ada frame DAN batch aktif, cek timeout
|
||||
if batch_mgr.is_active:
|
||||
no_frame_duration = time.time() - last_frame_time
|
||||
if no_frame_duration >= NO_FRAME_BATCH_TIMEOUT:
|
||||
log(f"RTSP drop {no_frame_duration:.0f}s. Force-end BATCH #{batch_counter}. Karung: {counter.loading_count}")
|
||||
# Force-end: langsung reset state machine (bypass update_truck)
|
||||
batch_mgr._state = BatchState.IDLE
|
||||
batch_mgr._current_batch_id = None
|
||||
batch_mgr._truck_is_stable = False
|
||||
recorder.stop(discard=True)
|
||||
counter.reset()
|
||||
stabilizer.reset()
|
||||
last_frame_time = time.time() # Reset timer agar tidak spam
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
# Resize ke 1280x720 (sesuai kalibrasi koordinat zona)
|
||||
last_frame_time = time.time()
|
||||
|
||||
frame = cv2.resize(frame, (1280, 720))
|
||||
timestamp = time.time()
|
||||
frame_idx += 1
|
||||
|
||||
# FPS counter
|
||||
fps_counter += 1
|
||||
if fps_counter % 100 == 0:
|
||||
elapsed = time.time() - last_fps_time
|
||||
fps = 100.0 / elapsed if elapsed > 0 else 0
|
||||
last_fps_time = time.time()
|
||||
state_str = batch_mgr.state
|
||||
rec_str = "REC" if recorder.is_recording else "---"
|
||||
print(f"[FPS] {fps:.1f} fps | State: {state_str} | {rec_str} | Frames: {frame_idx}")
|
||||
|
||||
# Simpan state sebelum update
|
||||
prev_active = batch_mgr.is_active
|
||||
prev_state = batch_mgr.state
|
||||
|
||||
# --- 1. YOLO Tracking ---
|
||||
raw_tracked_all = tracker.update(frame, [])
|
||||
raw_tracked_sacks = [d for d in raw_tracked_all if d.class_name == "sack"]
|
||||
# --- Deteksi ---
|
||||
raw_all = tracker.update(frame, [])
|
||||
|
||||
# --- 2. Stabilizer ---
|
||||
stable = stabilizer.update(raw_tracked_sacks)
|
||||
# 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))
|
||||
]
|
||||
|
||||
# --- 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"]
|
||||
|
||||
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
|
||||
)
|
||||
]
|
||||
|
||||
# --- 4. Hitung karung di 70% area bawah truk ---
|
||||
min_ty, max_ty = truck_polygon.bounds[1], truck_polygon.bounds[3]
|
||||
truck_height = max_ty - min_ty
|
||||
truck_cutoff_y = min_ty + 0.30 * truck_height
|
||||
|
||||
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. Line Crossing ---
|
||||
tracked_in_roi = [
|
||||
d for d in stable
|
||||
if static_roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0)
|
||||
]
|
||||
events = counter.update(tracked_in_roi)
|
||||
# Line crossing
|
||||
in_roi = [d for d in stable if static_roi.contains_x((d.bbox[0]+d.bbox[2])/2)]
|
||||
events = counter.update(in_roi)
|
||||
has_crossing = len(events) > 0
|
||||
|
||||
# =============================================================
|
||||
# LOGIKA ALGORITMA PENENTUAN BATCH (STATE MACHINE)
|
||||
# =============================================================
|
||||
|
||||
# A. Mulai Batch
|
||||
# --- State Machine ---
|
||||
if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"):
|
||||
batch_mgr.update_truck(has_crossing, (0.0, 0.0), timestamp)
|
||||
|
||||
# B. Monitoring Batch Aktif
|
||||
if batch_mgr.state in ("COUNTING_SACKS", "WAITING_FOR_ACTIVITY"):
|
||||
current_count = counter.loading_count
|
||||
if current_count < 20:
|
||||
batch_mgr._truck_gone_tolerance = TOLERANCE_LOW_COUNT
|
||||
elif current_count >= 40:
|
||||
batch_mgr._truck_gone_tolerance = TOLERANCE_HIGH_COUNT
|
||||
else:
|
||||
batch_mgr._truck_gone_tolerance = TOLERANCE_MED_COUNT
|
||||
|
||||
batch_mgr.update_sacks(
|
||||
has_crossing_event=has_crossing,
|
||||
sacks_in_area_count=sacks_in_truck_area,
|
||||
sacks_in_area_count=sacks_in_area,
|
||||
timestamp=timestamp,
|
||||
loading_count=counter.loading_count,
|
||||
unloading_count=counter.unloading_count,
|
||||
)
|
||||
|
||||
if batch_mgr.state == "WAITING_FOR_ACTIVITY":
|
||||
batch_mgr.update_truck(sacks_in_truck_area > 0, None, timestamp)
|
||||
# Sinkronkan _truck_last_seen agar countdown toleransi
|
||||
# mulai dari saat WAITING dimulai, bukan dari TRUCK_STABILIZING
|
||||
if batch_mgr._truck_last_seen < batch_mgr._waiting_since:
|
||||
batch_mgr._truck_last_seen = batch_mgr._waiting_since
|
||||
|
||||
for ev in events:
|
||||
now_str = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{now_str}] [KARUNG] #{ev['track_id']} melintasi garis. Total: {counter.loading_count}")
|
||||
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 — MULAI/SELESAI REKAMAN VIDEO
|
||||
# =============================================================
|
||||
|
||||
# C. Batch baru saja dimulai
|
||||
# --- Transisi Batch ---
|
||||
if batch_mgr.is_active and not prev_active:
|
||||
counting_date = get_counting_date()
|
||||
batch_counter += 1
|
||||
now_str = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"\n>>> [{now_str}] BATCH #{batch_counter} DIMULAI (tanggal: {counting_date}) <<<")
|
||||
recorder.start_recording(batch_counter, counting_date, 1280, 720)
|
||||
cd = get_counting_date()
|
||||
batch_counter = get_next_batch_number_from_files(cd)
|
||||
truck_seen_in_current_batch = False
|
||||
log(f"BATCH #{batch_counter} DIMULAI (tanggal: {cd})")
|
||||
recorder.start(batch_counter, cd, fps=cap.fps)
|
||||
|
||||
# D. Batch baru saja selesai
|
||||
elif not batch_mgr.is_active and prev_active:
|
||||
final_count = counter.loading_count
|
||||
now_str = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"\n>>> [{now_str}] BATCH #{batch_counter} SELESAI. Total karung: {final_count} <<<")
|
||||
recorder.stop_recording()
|
||||
# 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)
|
||||
|
||||
# Reset counter dan stabilizer untuk batch berikutnya
|
||||
counter.reset()
|
||||
stabilizer.reset()
|
||||
|
||||
# E. Log transisi status
|
||||
if batch_mgr.state != prev_state:
|
||||
now_str = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{now_str}] [STATE] {prev_state} -> {batch_mgr.state}")
|
||||
log(f"STATE: {prev_state} -> {batch_mgr.state}")
|
||||
|
||||
# =============================================================
|
||||
# TULIS FRAME KE VIDEO (jika batch aktif)
|
||||
# =============================================================
|
||||
if batch_mgr.is_active and recorder.is_recording:
|
||||
recorder.write_frame(frame)
|
||||
# Tulis frame ke video
|
||||
if batch_mgr.is_active:
|
||||
recorder.write(frame)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n[INFO] Program dihentikan oleh pengguna (Ctrl+C).")
|
||||
# Log karung crossing
|
||||
for ev in events:
|
||||
log(f"KARUNG #{ev['track_id']} crossing. Total: {counter.loading_count}")
|
||||
|
||||
# Status log setiap 5 menit
|
||||
if timestamp - last_status_time >= 300:
|
||||
last_status_time = timestamp
|
||||
rec = "REC" if recorder.is_recording else "---"
|
||||
log(f"STATUS: frames={frame_idx} batches={batch_counter} "
|
||||
f"state={batch_mgr.state} {rec}")
|
||||
|
||||
# --- Visualisasi Live Predict (Lokal Windows saja) ---
|
||||
if SHOW_DISPLAY:
|
||||
display = frame.copy()
|
||||
|
||||
# Gambar detection polygon (magenta)
|
||||
det_pts = np.array([
|
||||
[int(574*sx), int(50*sy)], [int(586*sx), int(1077*sy)],
|
||||
[int(1418*sx), int(1076*sy)], [int(1397*sx), int(50*sy)]
|
||||
], dtype=np.int32)
|
||||
cv2.polylines(display, [det_pts], True, (255, 0, 255), 2)
|
||||
|
||||
# Gambar truck polygon (orange)
|
||||
trk_pts = np.array([
|
||||
[int(600*sx), int(385*sy)], [int(609*sx), int(1076*sy)],
|
||||
[int(1404*sx), int(1078*sy)], [int(1381*sx), int(343*sy)]
|
||||
], dtype=np.int32)
|
||||
cv2.polylines(display, [trk_pts], True, (0, 165, 255), 2)
|
||||
|
||||
# Gambar count line (cyan)
|
||||
cv2.line(display, (line_x1, line_y), (line_x2, line_y), (255, 255, 0), 2)
|
||||
cv2.putText(display, "COUNTING LINE", (line_x1 + 10, line_y - 8),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1)
|
||||
|
||||
# Gambar bbox TRUCK (hijau)
|
||||
for d in trucks:
|
||||
x1, y1, x2, y2 = [int(v) for v in d.bbox]
|
||||
cv2.rectangle(display, (x1, y1), (x2, y2), (0, 200, 0), 2)
|
||||
lbl = f"truck #{d.track_id} ({d.confidence:.2f})"
|
||||
cv2.putText(display, lbl, (x1, y1 - 5),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 200, 0), 1)
|
||||
|
||||
# Gambar bbox SACK (cyan untuk stable)
|
||||
for d in stable:
|
||||
x1, y1, x2, y2 = [int(v) for v in d.bbox]
|
||||
cv2.rectangle(display, (x1, y1), (x2, y2), (255, 255, 0), 2)
|
||||
lbl = f"sack #{d.track_id}"
|
||||
cv2.putText(display, lbl, (x1, y2 + 15),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 0), 1)
|
||||
|
||||
# Overlay HUD
|
||||
overlay = display.copy()
|
||||
cv2.rectangle(overlay, (5, 5), (380, 150), (0, 0, 0), -1)
|
||||
cv2.addWeighted(overlay, 0.65, display, 0.35, 0, display)
|
||||
|
||||
state_str = batch_mgr.state
|
||||
rec_str = "● RECORDING" if recorder.is_recording else "○ IDLE"
|
||||
color_state = (0, 250, 0) if batch_mgr.is_active else (0, 165, 255)
|
||||
|
||||
cv2.putText(display, f"State: {state_str}", (15, 30),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, color_state, 2)
|
||||
cv2.putText(display, f"Total Counted: {counter.loading_count}", (15, 55),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1)
|
||||
cv2.putText(display, f"Sacks in Area: {sacks_in_area}", (15, 80),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1)
|
||||
cv2.putText(display, f"Current Batch: #{batch_counter} ({rec_str})", (15, 105),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||
cv2.putText(display, f"Sacks: {len(stable)} | Trucks: {len(trucks)}",
|
||||
(15, 130), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1)
|
||||
|
||||
# Resize agar muat layar lokal
|
||||
resized = cv2.resize(display, (960, 540))
|
||||
cv2.imshow("Batch Video Cropper - Local Predict", resized)
|
||||
if cv2.waitKey(1) & 0xFF == ord('q'):
|
||||
log("Dihentikan secara manual melalui tombol 'q'")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
log(f"ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
finally:
|
||||
# Tutup rekaman yang masih terbuka
|
||||
if recorder.is_recording:
|
||||
print("[INFO] Menyimpan rekaman batch terakhir...")
|
||||
recorder.stop_recording()
|
||||
|
||||
log("Menyimpan rekaman batch terakhir...")
|
||||
recorder.stop()
|
||||
cap.release()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" BATCH VIDEO CROPPER SELESAI")
|
||||
print(f" Total batch terekam: {batch_counter}")
|
||||
print(f" Total frame diproses: {frame_idx}")
|
||||
print(f" Folder output: {ARCHIVE_BASE}")
|
||||
print("=" * 60)
|
||||
if SHOW_DISPLAY:
|
||||
cv2.destroyAllWindows()
|
||||
log(f"SELESAI. Total batch: {batch_counter}, frames: {frame_idx}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_batch_video_cropper()
|
||||
run()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
+257
-61
@@ -10,14 +10,43 @@ Counting logic (Low-FPS robust):
|
||||
Loading: track had state ABOVE, now detected BELOW the zone
|
||||
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 2: Spatial dedup radius — same position can't trigger twice
|
||||
Layer 3: Track ID — one track_id can only be counted once per direction
|
||||
Layer 2: Entry circle — must have travelled `entry_travel_min` from first
|
||||
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
|
||||
detecting the exact frame of crossing. It only needs the track
|
||||
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
|
||||
@@ -26,6 +55,18 @@ import time
|
||||
|
||||
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:
|
||||
"""Counts sacks crossing a horizontal zone using y1 (top edge).
|
||||
@@ -44,14 +85,29 @@ class LineCrossCounter:
|
||||
line_y: int,
|
||||
line_x_start: int,
|
||||
line_x_end: int,
|
||||
margin: int = 20,
|
||||
margin: int = 5,
|
||||
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:
|
||||
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
|
||||
# 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._unloading_count = 0
|
||||
@@ -65,9 +121,25 @@ class LineCrossCounter:
|
||||
self._counted: dict[int, set[str]] = {}
|
||||
# track_id -> initial coordinates (cx, y1) when first tracked
|
||||
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] = []
|
||||
|
||||
# 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
|
||||
def entry_points(self) -> dict[int, tuple[float, float]]:
|
||||
return self._entry_points
|
||||
@@ -100,25 +172,78 @@ class LineCrossCounter:
|
||||
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.
|
||||
# -- hand-off ----------------------------------------------------------
|
||||
|
||||
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. Its current y1 is now BELOW the line
|
||||
3. It hasn't been counted for loading yet
|
||||
4. It passes spatial dedup check
|
||||
def _park(self, tid: int, now_t: float) -> None:
|
||||
"""Park a vanished track's history so a new id can inherit it."""
|
||||
cx, y1, seen_at = self._last_pos[tid]
|
||||
vx, vy = self._velocity.get(tid, (0.0, 0.0))
|
||||
self._handoff.append({
|
||||
"x": cx, "y": y1, "vx": vx, "vy": vy,
|
||||
"seen_at": seen_at, "parked_at": now_t, "from": tid,
|
||||
"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()
|
||||
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]
|
||||
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:
|
||||
if det.track_id is None:
|
||||
@@ -130,6 +255,48 @@ class LineCrossCounter:
|
||||
|
||||
if tid not in self._entry_points:
|
||||
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
|
||||
if cx < self._line_x_start or cx > self._line_x_end:
|
||||
@@ -145,71 +312,82 @@ class LineCrossCounter:
|
||||
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._above_streak[tid] = self._above_streak.get(tid, 0) + 1
|
||||
else:
|
||||
self._above_streak[tid] = 0
|
||||
if new_state == "below":
|
||||
self._has_been_below[tid] = True
|
||||
|
||||
# --- 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
|
||||
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)
|
||||
# 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 = (
|
||||
new_state == "above"
|
||||
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
|
||||
)
|
||||
|
||||
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:
|
||||
if not (is_loading or is_unloading):
|
||||
continue
|
||||
|
||||
# Add this coordinate to the active dedup circles
|
||||
self._dedup_circles.append({
|
||||
"x": cx,
|
||||
"y": y1,
|
||||
"time": now_t,
|
||||
"track_id": tid
|
||||
})
|
||||
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
|
||||
self._dedup_circles.append(
|
||||
{"x": cx, "y": y1, "time": now_t, "track_id": tid})
|
||||
|
||||
direction = "loading" if is_loading else "unloading"
|
||||
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:
|
||||
else:
|
||||
self._unloading_count += 1
|
||||
counted_dirs.add("unloading")
|
||||
events.append({
|
||||
"track_id": tid,
|
||||
"direction": "unloading",
|
||||
"cx": cx,
|
||||
"cy": y1
|
||||
})
|
||||
# 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})
|
||||
|
||||
# Tracks missing this frame: park their state immediately so a new id
|
||||
# arriving next frame can inherit it, and retire them once they are
|
||||
# clearly gone.
|
||||
for tid in list(self._last_pos.keys()):
|
||||
if tid in seen:
|
||||
continue
|
||||
gone_for = now_t - self._last_pos[tid][2]
|
||||
if not any(t["from"] == tid for t in self._handoff):
|
||||
self._park(tid, now_t)
|
||||
if gone_for > self._track_ttl:
|
||||
self._retire(tid)
|
||||
|
||||
return events
|
||||
|
||||
@@ -229,9 +407,27 @@ class LineCrossCounter:
|
||||
"""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()
|
||||
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, self._trace):
|
||||
store.clear()
|
||||
self._handoff.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"
|
||||
@@ -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()
|
||||
@@ -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
@@ -34,6 +34,16 @@ class AutolabelRequest(BaseModel):
|
||||
min_box_frac: float = 0.0
|
||||
resume: 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")
|
||||
@@ -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]
|
||||
return autolabel.start(batch_id, request.threshold, request.iou_threshold,
|
||||
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,
|
||||
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:
|
||||
raise HTTPException(400, str(exc))
|
||||
@router.post("/api/batches/inspect-model")
|
||||
@@ -113,7 +124,7 @@ async def inspect_model(file: UploadFile = File(...)) -> dict:
|
||||
except Exception as exc:
|
||||
if os.path.exists(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")
|
||||
@@ -121,7 +132,7 @@ async def autolabel_with_model(
|
||||
batch_id: int,
|
||||
file: UploadFile = File(...),
|
||||
threshold: float = Form(0.35),
|
||||
iou_threshold: float = Form(0.8),
|
||||
iou_threshold: float = Form(0.0),
|
||||
selected_classes: str = Form("[]"),
|
||||
append: bool = Form(True),
|
||||
) -> dict:
|
||||
@@ -139,6 +150,7 @@ async def autolabel_with_model(
|
||||
threshold=threshold,
|
||||
iou_threshold=iou_threshold,
|
||||
append=append,
|
||||
engine="custom",
|
||||
custom_model_path=staged_path,
|
||||
target_class_names=target_classes,
|
||||
)
|
||||
@@ -148,13 +160,39 @@ async def autolabel_with_model(
|
||||
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")
|
||||
async def sam3_playground_test(
|
||||
file: UploadFile = File(...),
|
||||
prompts: str = Form(...),
|
||||
threshold: float = Form(0.35),
|
||||
iou_threshold: float = Form(0.8),
|
||||
iou_threshold: float = Form(0.0),
|
||||
) -> dict:
|
||||
|
||||
from PIL import Image
|
||||
from backend import labeling
|
||||
from backend.sam3_engine import get_engine
|
||||
@@ -169,12 +207,20 @@ async def sam3_playground_test(
|
||||
if not prompt_list:
|
||||
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:
|
||||
engine = get_engine()
|
||||
raw_dets = engine.detect(image, prompt_list, threshold)
|
||||
kept_dets = labeling.deduplicate(raw_dets, iou_threshold=iou_threshold)
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"SAM3 inference failed: {exc}")
|
||||
finally:
|
||||
jobs.gpu_lock.release()
|
||||
|
||||
results = []
|
||||
for det in kept_dets:
|
||||
@@ -213,10 +259,18 @@ def approve_all_batch_frames(batch_id: int) -> dict:
|
||||
return {"approved_count": updated}
|
||||
|
||||
|
||||
@router.post("/api/batches/{batch_id}/approve")
|
||||
def approve_batch(batch_id: int) -> dict:
|
||||
class ApproveRequest(BaseModel):
|
||||
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:
|
||||
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:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
@@ -227,17 +281,6 @@ def dataset_summary(project_id: int) -> dict:
|
||||
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")
|
||||
def frame_image(frame_id: int, w: int = 0):
|
||||
path = batch_store.frame_path(frame_id)
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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"})
|
||||
@@ -20,6 +20,8 @@ class TrainRequest(BaseModel):
|
||||
device: Optional[Union[int, str]] = None
|
||||
batch_ids: Optional[list] = None
|
||||
class_ids: Optional[list] = None
|
||||
dataset_ids: Optional[list] = None
|
||||
base_dataset_ids: Optional[list] = None
|
||||
|
||||
|
||||
@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_ids=request.batch_ids,
|
||||
class_ids=request.class_ids,
|
||||
dataset_ids=request.dataset_ids,
|
||||
base_dataset_ids=request.base_dataset_ids,
|
||||
)
|
||||
except training.TrainingError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
@@ -173,7 +173,16 @@ def stream_video(project_id: int, rel: str, request: Request):
|
||||
except library.LibraryError as 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")
|
||||
|
||||
size = os.path.getsize(path)
|
||||
header = request.headers.get("range")
|
||||
if not header or not header.startswith("bytes="):
|
||||
|
||||
@@ -21,6 +21,15 @@ class AnnotationPatch(BaseModel):
|
||||
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):
|
||||
status: str
|
||||
|
||||
@@ -67,6 +76,20 @@ def delete_annotation(annotation_id: int) -> dict:
|
||||
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")
|
||||
def assist(frame_id: int, request: AssistRequest) -> dict:
|
||||
try:
|
||||
|
||||
@@ -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"})
|
||||
@@ -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")
|
||||
@@ -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)}
|
||||
+164
-17
@@ -9,11 +9,14 @@ keep the user's own corrections out of the way.
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
from PIL import Image
|
||||
from backend import batches, db, jobs, labeling, projects, review
|
||||
from backend.batches import BatchError
|
||||
|
||||
DEFAULT_THRESHOLD = 0.35
|
||||
DEFAULT_IOU = 0.8
|
||||
DEFAULT_IOU = 0.0
|
||||
|
||||
|
||||
|
||||
|
||||
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")
|
||||
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:
|
||||
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]
|
||||
# Add any new target class names that aren't in project classes yet
|
||||
existing_names = {c["name"].strip().lower() for c in project["classes"]}
|
||||
for name in target_class_names:
|
||||
@@ -100,11 +107,9 @@ def _run_autolabel(job) -> None:
|
||||
try:
|
||||
updated_proj = projects.add_class(project["id"], name=name.strip(), prompt=name.strip())
|
||||
project["classes"] = updated_proj["classes"]
|
||||
for new_c in project["classes"]:
|
||||
if new_c["name"].strip().lower() == name.strip().lower() and new_c not in sam3_target_classes:
|
||||
sam3_target_classes.append(new_c)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
job.log(f"Warning adding class '{name}': {exc}")
|
||||
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"]]
|
||||
|
||||
@@ -149,6 +154,8 @@ def _run_autolabel(job) -> None:
|
||||
|
||||
try:
|
||||
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 = []
|
||||
|
||||
if yolo_model is not None:
|
||||
@@ -157,27 +164,41 @@ def _run_autolabel(job) -> None:
|
||||
model_names = results[0].names
|
||||
for box in results[0].boxes:
|
||||
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)
|
||||
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_class_id = name_to_class_id.get(cls_name)
|
||||
if target_class_id is None:
|
||||
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=cls_name,
|
||||
box=[xyxyn[0]*frame["width"], xyxyn[1]*frame["height"], xyxyn[2]*frame["width"], xyxyn[3]*frame["height"]],
|
||||
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
|
||||
))
|
||||
|
||||
elif selected_engine == "sam3" and sam3_target_classes:
|
||||
prompts = [c["prompt"] for c in sam3_target_classes]
|
||||
if selected_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, conf,
|
||||
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_name = real_cls["name"]
|
||||
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)
|
||||
items = []
|
||||
for det in kept:
|
||||
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})
|
||||
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})
|
||||
|
||||
if job.params.get("append"):
|
||||
@@ -211,6 +234,8 @@ def _run_autolabel(job) -> None:
|
||||
job.log(f"[ERROR] {frame['filename']}: {exc}")
|
||||
job.progress(index + 1, len(frames))
|
||||
|
||||
|
||||
|
||||
# "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
|
||||
# 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'",
|
||||
(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
|
||||
@@ -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)]
|
||||
@@ -209,6 +209,8 @@ def _run_extract(job) -> None:
|
||||
job.log(f"Extracted {len(names)} frame(s) at {width}×{height}")
|
||||
|
||||
|
||||
|
||||
|
||||
def update(batch_id: int, patch: dict) -> dict:
|
||||
batch = get(batch_id)
|
||||
if batch is None:
|
||||
@@ -236,9 +238,32 @@ def update(batch_id: int, patch: dict) -> dict:
|
||||
|
||||
def delete(batch_id: int) -> bool:
|
||||
import shutil
|
||||
from backend import dataset
|
||||
|
||||
batch = get(batch_id)
|
||||
if batch is None:
|
||||
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:
|
||||
cur.execute("DELETE FROM batches WHERE id = ?", (batch_id,))
|
||||
shutil.rmtree(batch_dir(batch["project_slug"], batch_id), ignore_errors=True)
|
||||
|
||||
@@ -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))
|
||||
+387
-123
@@ -11,48 +11,115 @@ Label files are plain YOLO:
|
||||
segment class_id x1 y1 x2 y2 … (normalized polygon)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
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):
|
||||
pass
|
||||
def dataset_dir(project_slug: str, dataset_id: int) -> str:
|
||||
return datasets.dataset_root(project_slug, dataset_id)
|
||||
|
||||
|
||||
def dataset_dir(project_slug: str) -> str:
|
||||
return os.path.join(config.project_dir(project_slug), "dataset")
|
||||
def runs_dir(project_slug: str) -> str:
|
||||
"""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:
|
||||
"""Sign a batch off and queue its merge (REQ-045, REQ-050)."""
|
||||
def approve(batch_ids, dataset_id: Optional[int] = None,
|
||||
dataset_name: str = "") -> dict:
|
||||
"""Sign a selection off and queue one merge into one named dataset (REQ-131).
|
||||
|
||||
The rules in force right now are frozen onto the target dataset (REQ-132):
|
||||
the merge runs under them, and a later rule edit cannot rewrite what this
|
||||
dataset claims to be.
|
||||
|
||||
Without `dataset_id` a new dataset is created, so merging the same batches
|
||||
again never collides with the earlier result — it produces a second dataset
|
||||
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["status"] == "merged":
|
||||
raise DatasetError("This batch is already in the master dataset")
|
||||
if batch["review"]["pending"] > 0:
|
||||
raise DatasetError(
|
||||
f"{batch['review']['pending']} frame(s) still need a decision before this "
|
||||
"batch can be approved"
|
||||
)
|
||||
if batch["review"]["approved"] == 0:
|
||||
raise DatasetError("Every frame was rejected — there is nothing to merge")
|
||||
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"]
|
||||
|
||||
batches.set_status(batch_id, "approved")
|
||||
# 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 cur.fetchone() is not None:
|
||||
raise DatasetError("A merge for one of these batches is already queued")
|
||||
|
||||
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(
|
||||
"merge",
|
||||
params={"batch_id": batch_id},
|
||||
project_id=batch["project_id"],
|
||||
batch_id=batch_id,
|
||||
message=f"{batch['date_label']}/{batch['batch_label']}",
|
||||
params={"batch_ids": ids, "dataset_id": dataset_id},
|
||||
project_id=project_id,
|
||||
batch_id=ids[0],
|
||||
message=labels,
|
||||
)
|
||||
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:
|
||||
if label_type == "bbox":
|
||||
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}"
|
||||
|
||||
|
||||
def _next_split(cur, project_id: int, val_every: int) -> str:
|
||||
"""Continue the every-Nth pattern from wherever the last merge left off."""
|
||||
def split_for(project_id: int, batch_id: int, stem: str, val_every: int) -> str:
|
||||
"""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:
|
||||
return "train"
|
||||
cur.execute("SELECT COUNT(*) FROM dataset_items WHERE project_id = ?", (project_id,))
|
||||
position = cur.fetchone()[0]
|
||||
return "val" if position % val_every == val_every - 1 else "train"
|
||||
digest = hashlib.sha1(f"{project_id}/{batch_id}/{stem}".encode("utf-8")).hexdigest()
|
||||
return "val" if int(digest[:8], 16) % val_every == 0 else "train"
|
||||
|
||||
|
||||
def sync_labels(project_id: int, selected_class_ids: Optional[List[int]] = None) -> dict:
|
||||
"""Re-sync label files on disk for all merged frames in the project dataset."""
|
||||
project = projects.get(project_id)
|
||||
root = dataset_dir(project["slug"])
|
||||
def resync(dataset_id: int) -> dict:
|
||||
"""Rewrite one dataset's labels from the current annotations and rules.
|
||||
|
||||
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:
|
||||
cur.execute(
|
||||
"SELECT d.frame_id, d.label_rel FROM dataset_items d WHERE d.project_id = ?",
|
||||
(project_id,),
|
||||
"""SELECT d.frame_id, d.label_rel FROM dataset_items d
|
||||
JOIN frames f ON f.id = d.frame_id
|
||||
WHERE d.dataset_id = ? AND f.review_status = 'approved'""",
|
||||
(dataset_id,),
|
||||
)
|
||||
items = cur.fetchall()
|
||||
|
||||
class_map = None
|
||||
if selected_class_ids is not None and len(selected_class_ids) > 0:
|
||||
class_map = {cid: idx for idx, cid in enumerate(sorted(selected_class_ids))}
|
||||
|
||||
synced_files = 0
|
||||
total_lines = 0
|
||||
written = 0
|
||||
emptied = 0
|
||||
for frame_id, label_rel in items:
|
||||
annotations = review.listing(frame_id)
|
||||
if class_map is not None:
|
||||
annotations = [a for a in annotations if a["class_id"] in class_map]
|
||||
|
||||
lines = []
|
||||
for item in annotations:
|
||||
mapped_cid = class_map[item["class_id"]] if class_map is not None else item["class_id"]
|
||||
lines.append(_label_line(mapped_cid, item["geometry"], project["label_type"]))
|
||||
|
||||
resolved = resolver.resolve_shapes(annotations)
|
||||
if resolved is None:
|
||||
# Every shape was dropped. The image stays in the dataset but an
|
||||
# empty label would claim it is empty, so the file is left as it was
|
||||
# and the count is reported (REQ-104).
|
||||
emptied += 1
|
||||
continue
|
||||
lines = [_label_line(item["class_id"], item["geometry"], project["label_type"])
|
||||
for item in resolved]
|
||||
path = os.path.join(root, label_rel)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
synced_files += 1
|
||||
total_lines += len(lines)
|
||||
_write_atomic(path, "\n".join(lines) + ("\n" if lines else ""))
|
||||
written += 1
|
||||
|
||||
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:
|
||||
"""Rebuild data.yaml from the project's classes (REQ-051)."""
|
||||
sync_labels(project["id"], selected_class_ids=selected_class_ids)
|
||||
root = dataset_dir(project["slug"])
|
||||
os.makedirs(root, exist_ok=True)
|
||||
counts = summary(project["id"])["splits"]
|
||||
def _write_atomic(path: str, text: str) -> None:
|
||||
"""Write via temp file + rename, so a training run never reads a half-written
|
||||
label file or a truncated data.yaml."""
|
||||
tmp = f"{path}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as handle:
|
||||
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"]
|
||||
class_map = None
|
||||
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]
|
||||
class_map = {cid: idx for idx, cid in enumerate(sorted(selected_class_ids))}
|
||||
|
||||
names = ", ".join(f"'{item['name']}'" for item in target_classes)
|
||||
|
||||
items = datasets.combined_items(project["id"], dataset_ids)
|
||||
if batch_ids:
|
||||
keep = _frames_of_batches(set(batch_ids))
|
||||
items = [item for item in items if item["frame_id"] in keep]
|
||||
|
||||
rows = []
|
||||
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"]),
|
||||
})
|
||||
|
||||
# Base datasets are appended, never merged into the dedupe above: they carry
|
||||
# no frame_id, and they are always train-only (REQ-122).
|
||||
if base_dataset_ids:
|
||||
from backend import base_dataset
|
||||
rows.extend(base_dataset.rows(project["id"], base_dataset_ids, project["slug"]))
|
||||
|
||||
selected_root, listed = _build_selected_tree(run_root, rows, class_map)
|
||||
if require_val:
|
||||
_require_val(len(listed["val"]), "the selected dataset(s)")
|
||||
|
||||
train_txt = os.path.join(run_root, "selected_train.txt")
|
||||
val_txt = os.path.join(run_root, "selected_val.txt")
|
||||
_write_atomic(train_txt, "\n".join(listed["train"]) + "\n")
|
||||
_write_atomic(val_txt, "\n".join(listed["val"]) + "\n")
|
||||
|
||||
path = os.path.join(run_root, "selected_data.yaml")
|
||||
_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
|
||||
|
||||
|
||||
def _frames_of_batches(batch_ids: set) -> set:
|
||||
with db.cursor() as cur:
|
||||
placeholders = ",".join("?" for _ in batch_ids)
|
||||
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),
|
||||
f"SELECT id FROM frames WHERE batch_id IN ({','.join('?' for _ in batch_ids)})",
|
||||
list(batch_ids),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return {row[0] for row in cur.fetchall()}
|
||||
|
||||
train_files = [row[0] for row in rows if row[1] == "train"]
|
||||
val_files = [row[0] for row in rows if row[1] == "val"] or train_files
|
||||
|
||||
train_txt = os.path.join(root, "selected_train.txt")
|
||||
val_txt = os.path.join(root, "selected_val.txt")
|
||||
with open(train_txt, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(os.path.join(root, rel) for rel in train_files) + "\n")
|
||||
with open(val_txt, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(os.path.join(root, rel) for rel in val_files) + "\n")
|
||||
def _require_val(count: int, subject: str) -> None:
|
||||
"""Refuse to build a dataset with an empty val split.
|
||||
|
||||
path = os.path.join(root, "selected_data.yaml")
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(f"path: {root}\n")
|
||||
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")
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(f"path: {root}\n")
|
||||
handle.write("train: images/train\n")
|
||||
handle.write(f"val: images/{'val' if counts['val'] > 0 else 'train'}\n\n")
|
||||
handle.write(f"nc: {len(project['classes'])}\n")
|
||||
handle.write(f"names: [{names}]\n")
|
||||
return path
|
||||
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:
|
||||
"""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:
|
||||
cur.execute(
|
||||
"SELECT split, COUNT(*) FROM dataset_items WHERE project_id = ? GROUP BY split",
|
||||
@@ -185,7 +391,12 @@ def summary(project_id: int) -> dict:
|
||||
(project_id,),
|
||||
)
|
||||
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:
|
||||
@@ -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
|
||||
than labels that are missing.
|
||||
"""
|
||||
root = dataset_dir(project["slug"])
|
||||
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"],))
|
||||
label_files = [row[0] for row in cur.fetchall()]
|
||||
label_files = [(row[0], row[1]) for row in cur.fetchall()]
|
||||
|
||||
rewritten = 0
|
||||
dropped = 0
|
||||
for rel in label_files:
|
||||
path = os.path.join(root, rel)
|
||||
for rel, dataset_id in label_files:
|
||||
path = os.path.join(dataset_dir(project["slug"], dataset_id), rel)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
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}
|
||||
|
||||
|
||||
def zip_path(project: dict) -> str:
|
||||
"""Zip the master dataset for download (REQ-054)."""
|
||||
root = dataset_dir(project["slug"])
|
||||
def zip_path(project: dict, dataset_id: int) -> str:
|
||||
"""Zip one dataset for download (REQ-054)."""
|
||||
root = dataset_dir(project["slug"], dataset_id)
|
||||
if not os.path.isdir(os.path.join(root, "images")):
|
||||
raise DatasetError("This project's dataset is still empty")
|
||||
archive = os.path.join(config.project_dir(project["slug"]), "dataset")
|
||||
raise DatasetError("This dataset is still empty")
|
||||
archive = os.path.join(config.project_dir(project["slug"]), f"dataset-{dataset_id}")
|
||||
return shutil.make_archive(archive, "zip", root)
|
||||
|
||||
|
||||
@jobs.handler("merge")
|
||||
def _run_merge(job) -> None:
|
||||
batch = batches.get(job.params["batch_id"])
|
||||
if batch is None:
|
||||
raise DatasetError("The batch disappeared before the merge started")
|
||||
project = projects.get(batch["project_id"])
|
||||
root = dataset_dir(project["slug"])
|
||||
ids = job.params.get("batch_ids") or [job.params["batch_id"]]
|
||||
selected = [batches.get(bid) for bid in ids]
|
||||
if any(batch is None for batch in selected):
|
||||
raise DatasetError("A batch disappeared before the merge started")
|
||||
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"):
|
||||
os.makedirs(os.path.join(root, "images", split), exist_ok=True)
|
||||
os.makedirs(os.path.join(root, "labels", split), exist_ok=True)
|
||||
|
||||
work = []
|
||||
for batch in selected:
|
||||
frames = [f for f in batches.frames(batch["id"]) if f["review_status"] == "approved"]
|
||||
source_dir = batches.frames_dir(project["slug"], batch["id"])
|
||||
job.progress(0, len(frames))
|
||||
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}
|
||||
skipped = 0
|
||||
for index, frame in enumerate(frames):
|
||||
triaged_out = 0
|
||||
cancelled = False
|
||||
for index, (batch, frame) in enumerate(work):
|
||||
if job.cancelled:
|
||||
job.log(f"Cancelled after {index} frame(s)")
|
||||
cancelled = True
|
||||
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:
|
||||
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:
|
||||
skipped += 1
|
||||
job.progress(index + 1, len(frames))
|
||||
job.progress(index + 1, len(work))
|
||||
continue
|
||||
split = _next_split(cur, project["id"], project["val_every"])
|
||||
|
||||
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"
|
||||
label_rel = f"labels/{split}/{stem}.txt"
|
||||
shutil.copyfile(os.path.join(source_dir, frame["filename"]),
|
||||
shutil.copyfile(
|
||||
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"])
|
||||
for item in review.listing(frame["id"])]
|
||||
for item in annotations]
|
||||
# An approved frame with nothing on it is a negative sample, and an
|
||||
# empty .txt is how YOLO spells that (REQ-033).
|
||||
with open(os.path.join(root, label_rel), "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO dataset_items (project_id, frame_id, split, image_rel,
|
||||
label_rel, added_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(project["id"], frame["id"], split, image_rel, label_rel, time.time()),
|
||||
"""INSERT INTO dataset_items (project_id, dataset_id, frame_id, split,
|
||||
image_rel, label_rel, added_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(project["id"], dataset_id, frame["id"], split, image_rel, label_rel,
|
||||
time.time()),
|
||||
)
|
||||
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:
|
||||
cur.execute("UPDATE batches SET status = 'merged', merged_at = ? WHERE id = ?",
|
||||
(time.time(), batch["id"]))
|
||||
cur.executemany(
|
||||
"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 = summary(project["id"])["splits"]
|
||||
totals = datasets.get(dataset_id)["splits"]
|
||||
job.log(f"Added {added['train']} train / {added['val']} val"
|
||||
+ (f", skipped {skipped} already merged" if skipped else ""))
|
||||
job.log(f"Master dataset now {totals['train']} train / {totals['val']} val — {path}")
|
||||
+ (f", skipped {skipped} already in this dataset" if skipped else "")
|
||||
+ (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")
|
||||
@@ -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
@@ -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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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')),
|
||||
image_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,
|
||||
project_id INTEGER REFERENCES projects(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 (
|
||||
'queued', 'running', 'done', 'failed', 'cancelled')),
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
@@ -125,11 +193,35 @@ SCHEMA = [
|
||||
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_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_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_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")
|
||||
if "secondary_model_classes" not in cols:
|
||||
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:
|
||||
|
||||
@@ -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
@@ -47,12 +47,12 @@ def defaults(epochs: int = 50) -> dict:
|
||||
elif vram < 6:
|
||||
settings = {"batch": 16, "imgsz": 640, "device": 0, "workers": 4}
|
||||
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}
|
||||
note = f"{vram} GB of VRAM: optimized batch 32, 640 px."
|
||||
else:
|
||||
settings = {"batch": 32, "imgsz": 768, "device": 0, "workers": 8}
|
||||
note = f"{vram} GB of VRAM: room for larger batches and 768 px."
|
||||
settings = {"batch": 64, "imgsz": 640, "device": 0, "workers": 8}
|
||||
note = f"{vram} GB of VRAM: max throughput batch 64, 640 px."
|
||||
|
||||
return {**info, **settings, "epochs": epochs, "note": note}
|
||||
|
||||
|
||||
+3
-3
@@ -27,11 +27,11 @@ from backend import db
|
||||
MAX_LOG_LINES = 500
|
||||
PROGRESS_FLUSH_SECONDS = 0.5
|
||||
|
||||
JOB_TYPES = ("extract", "autolabel", "merge", "train")
|
||||
GPU_JOB_TYPES = ("autolabel", "train")
|
||||
JOB_TYPES = ("extract", "autolabel", "merge", "train", "count", "clock-scan",
|
||||
"truck-scan")
|
||||
GPU_JOB_TYPES = ("autolabel", "train", "count", "truck-scan")
|
||||
"""`extract` is ffmpeg and `merge` is file copying — neither touches the card,
|
||||
so neither should be able to block an interactive assist."""
|
||||
|
||||
gpu_lock = threading.Lock()
|
||||
"""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,
|
||||
|
||||
@@ -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]:
|
||||
"""Greedy NMS per class: highest score wins within the SAME class."""
|
||||
if iou_threshold <= 0.0:
|
||||
return detections
|
||||
|
||||
by_class: dict[int, List[Detection]] = {}
|
||||
|
||||
for det in detections:
|
||||
by_class.setdefault(det.class_id, []).append(det)
|
||||
|
||||
|
||||
+65
-2
@@ -8,7 +8,8 @@ import os
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from backend import config, db, video
|
||||
from backend import config, db, projects, video
|
||||
|
||||
|
||||
|
||||
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):
|
||||
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)
|
||||
videos = []
|
||||
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,
|
||||
):
|
||||
path = os.path.join(folder, filename)
|
||||
rel = f"{date}/{filename}"
|
||||
entry = {
|
||||
"rel": f"{date}/{filename}",
|
||||
"rel": rel,
|
||||
"filename": filename,
|
||||
"batch_label": batch_label(filename),
|
||||
"used_count": used.get(os.path.realpath(path), 0),
|
||||
}
|
||||
try:
|
||||
entry.update(video.probe(path))
|
||||
if project is not None:
|
||||
ensure_video_preview(project, rel)
|
||||
except video.VideoError as exc:
|
||||
# A file ffprobe cannot read still belongs in the list, flagged —
|
||||
# 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
|
||||
|
||||
|
||||
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:
|
||||
|
||||
"""How many batches already came out of each video path (REQ-012)."""
|
||||
if project_id is None:
|
||||
return {}
|
||||
|
||||
@@ -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
@@ -14,14 +14,20 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from backend import config, db, jobs
|
||||
from backend.api import batches, jobs as job_routes, models, projects, review
|
||||
from backend import archive_index, config, db, jobs
|
||||
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
|
||||
async def lifespan(_app: FastAPI):
|
||||
config.ensure_dirs()
|
||||
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
|
||||
project_store.ensure_seed_project()
|
||||
interrupted = jobs.recover()
|
||||
@@ -43,6 +49,10 @@ app.include_router(batches.router)
|
||||
app.include_router(review.router)
|
||||
app.include_router(models.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")
|
||||
|
||||
+3
-7
@@ -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()),
|
||||
)
|
||||
|
||||
updated = get(project_id)
|
||||
if updated["dataset"]["train"] + updated["dataset"]["val"] > 0:
|
||||
from backend import dataset
|
||||
|
||||
dataset.write_data_yaml(updated)
|
||||
return updated
|
||||
# No data.yaml to refresh here any more: it is assembled per training run
|
||||
# from the datasets that run picks, so it always reflects the current classes.
|
||||
return get(project_id)
|
||||
|
||||
|
||||
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)
|
||||
updated = get(project_id)
|
||||
dataset.write_data_yaml(updated)
|
||||
|
||||
return {
|
||||
"project": updated,
|
||||
|
||||
@@ -220,6 +220,45 @@ def delete(annotation_id: int) -> bool:
|
||||
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:
|
||||
"""Swap this frame's automatic shapes for a fresh set, leaving manual ones."""
|
||||
with db.cursor() as cur:
|
||||
|
||||
@@ -37,6 +37,8 @@ class Detection:
|
||||
mask: Optional[np.ndarray] = None # bool array, (H, W) at original image size
|
||||
|
||||
|
||||
|
||||
|
||||
class Sam3Engine:
|
||||
def __init__(self, checkpoint_path: Optional[str] = None):
|
||||
# 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]:
|
||||
"""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] = []
|
||||
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):
|
||||
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"]
|
||||
if masks.shape[0] == 0:
|
||||
continue
|
||||
@@ -96,10 +99,11 @@ class Sam3Engine:
|
||||
)
|
||||
|
||||
del state
|
||||
if self.device == "cuda":
|
||||
torch.cuda.empty_cache()
|
||||
return detections
|
||||
|
||||
|
||||
|
||||
|
||||
# ---- interactive / exemplar prompting ------------------------------
|
||||
|
||||
def open_state(self, image: Image.Image):
|
||||
|
||||
@@ -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))())
|
||||
@@ -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
@@ -12,7 +12,8 @@ import shutil
|
||||
import time
|
||||
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"}
|
||||
|
||||
@@ -25,20 +26,84 @@ def models_dir(project_slug: str) -> str:
|
||||
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)
|
||||
if project is None:
|
||||
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:
|
||||
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)
|
||||
job = jobs.create(
|
||||
"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,
|
||||
message=f"{counts['train']} train / {counts['val']} val",
|
||||
)
|
||||
@@ -103,7 +168,10 @@ def _run_train(job) -> None:
|
||||
settings = job.params["settings"]
|
||||
batch_ids = job.params.get("batch_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).
|
||||
from backend.sam3_engine import release_engine
|
||||
@@ -130,6 +198,8 @@ def _run_train(job) -> None:
|
||||
epoch = getattr(trainer, 'epoch', 0) + 1
|
||||
total = getattr(trainer, 'epochs', settings["epochs"])
|
||||
job.progress(epoch, total, f"epoch {epoch}/{total}")
|
||||
if job.cancelled:
|
||||
trainer.stop_training = True
|
||||
|
||||
model.add_callback("on_fit_epoch_end", on_epoch)
|
||||
job.progress(0, settings["epochs"])
|
||||
@@ -138,6 +208,22 @@ def _run_train(job) -> None:
|
||||
if torch.cuda.is_available():
|
||||
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
|
||||
try:
|
||||
model.train(
|
||||
@@ -145,9 +231,10 @@ def _run_train(job) -> None:
|
||||
epochs=settings["epochs"],
|
||||
imgsz=settings["imgsz"],
|
||||
batch=settings["batch"],
|
||||
**augmentation["settings"],
|
||||
device=settings["device"],
|
||||
workers=settings.get("workers", 8),
|
||||
cache="ram",
|
||||
cache=cache_mode,
|
||||
project=os.path.join(out_dir, "runs"),
|
||||
name="train",
|
||||
exist_ok=True,
|
||||
@@ -175,10 +262,11 @@ def _run_train(job) -> None:
|
||||
cur.execute(
|
||||
"""INSERT INTO model_versions (project_id, version, weights_path,
|
||||
parent_model_path, metrics, base_metrics,
|
||||
created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
created_at, augment)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(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"]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
+66
-3
@@ -9,6 +9,7 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
VIDEO_EXTS = (".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v")
|
||||
@@ -44,7 +45,7 @@ def probe(path: str) -> dict:
|
||||
|
||||
result = subprocess.run(
|
||||
["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",
|
||||
"-of", "json", path],
|
||||
capture_output=True, text=True,
|
||||
@@ -59,12 +60,14 @@ def probe(path: str) -> dict:
|
||||
"width": int(streams[0].get("width") or 0),
|
||||
"height": int(streams[0].get("height") or 0),
|
||||
"fps": _parse_fps(streams[0].get("avg_frame_rate")),
|
||||
"codec_name": str(streams[0].get("codec_name") or ""),
|
||||
"size": stat.st_size,
|
||||
}
|
||||
_probe_cache[key] = info
|
||||
return info
|
||||
|
||||
|
||||
|
||||
def _parse_fps(value: Optional[str]) -> float:
|
||||
# ffprobe reports "30000/1001", and "0/0" for streams it cannot work out.
|
||||
if not value or "/" not in value:
|
||||
@@ -111,7 +114,12 @@ def extract_frames(
|
||||
"-vf", f"fps={fps}", "-q:v", "2",
|
||||
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)
|
||||
while process.poll() is None:
|
||||
@@ -127,7 +135,9 @@ def extract_frames(
|
||||
pass
|
||||
|
||||
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"))
|
||||
|
||||
@@ -137,3 +147,56 @@ def _written(out_dir: str) -> int:
|
||||
return sum(1 for name in os.listdir(out_dir) if name.endswith(".jpg"))
|
||||
except OSError:
|
||||
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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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
@@ -168,7 +168,11 @@ GET /api/batches/{id} # status + review progress (REQ-
|
||||
GET /api/batches/{id}/frames # frames + statuses
|
||||
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)
|
||||
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}/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
|
||||
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
|
||||
YOLO `.txt` from the frame's annotations, record the row. Finally rewrite `data.yaml`.
|
||||
Frames with no annotations produce an empty `.txt` (REQ-033).
|
||||
|
||||
@@ -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.
|
||||
@@ -130,6 +130,95 @@ changes.
|
||||
- **REQ-054** — The master dataset can be downloaded as a `.zip` (e.g. to import into
|
||||
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
|
||||
|
||||
- **REQ-060** — The user starts training from the project page. Training **fine-tunes from
|
||||
|
||||
+214
@@ -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.
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
- *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume
|
||||
|
||||
+62
-9
@@ -1,11 +1,17 @@
|
||||
import { Component, useEffect, useState } from 'react'
|
||||
import { Component, useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { api } from './api'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
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 ReviewPage from './pages/ReviewPage'
|
||||
import ModelsPage from './pages/ModelsPage'
|
||||
import LiveCountPage from './pages/LiveCountPage'
|
||||
import Sam3PlaygroundPage from './pages/Sam3PlaygroundPage'
|
||||
import './roboflow.css'
|
||||
|
||||
@@ -28,7 +34,17 @@ function parseRoute(hash) {
|
||||
if (parts[2] === 'trim' && 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] === 'live-count') return { name: 'live-count', projectId }
|
||||
if (parts[2] === 'counting-bench') return { name: 'counting-bench', projectId }
|
||||
if (parts[2] === 'review') {
|
||||
const batchId = query.get('batch') ? Number(query.get('batch')) : null
|
||||
return { name: 'review', projectId, batchId }
|
||||
@@ -39,6 +55,8 @@ function parseRoute(hash) {
|
||||
return { name: 'projects' }
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function navigate(path) {
|
||||
window.location.hash = path
|
||||
}
|
||||
@@ -97,15 +115,22 @@ export default function App() {
|
||||
const [theme, toggleTheme] = useTheme()
|
||||
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(() => {
|
||||
if (route.projectId) {
|
||||
api.getProject(route.projectId).then(setCurrentProject).catch(() => {})
|
||||
} else if (!currentProject) {
|
||||
api.listProjects().then((list) => {
|
||||
if (list.length > 0) setCurrentProject(list[0])
|
||||
api.getProject(route.projectId).then(handleProject).catch(() => {})
|
||||
} else {
|
||||
api.listProjects().then((payload) => {
|
||||
if (payload?.projects && payload.projects.length > 0) {
|
||||
handleProject(payload.projects[0])
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [route.projectId, route.name, currentProject])
|
||||
}, [route.projectId, route.name, handleProject])
|
||||
|
||||
|
||||
return (
|
||||
@@ -123,21 +148,48 @@ export default function App() {
|
||||
{route.name === 'library' && (
|
||||
<LibraryPage
|
||||
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 === 'review' && (
|
||||
<ReviewPage
|
||||
batchId={route.batchId}
|
||||
projectId={route.projectId}
|
||||
onProject={(p) => setCurrentProject(p)}
|
||||
onProject={handleProject}
|
||||
/>
|
||||
)}
|
||||
{route.name === 'models' && (
|
||||
<ModelsPage
|
||||
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 />}
|
||||
@@ -147,3 +199,4 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+120
-4
@@ -14,15 +14,29 @@ async function request(path, { method = 'GET', body, form } = {}) {
|
||||
|
||||
const response = await fetch(`/api${path}`, options)
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
export const api = {
|
||||
health: () => request('/health'),
|
||||
|
||||
@@ -57,6 +71,7 @@ export const api = {
|
||||
deleteBatch: (id) => request(`/batches/${id}`, { method: 'DELETE' }),
|
||||
listFrames: (id) => request(`/batches/${id}/frames`),
|
||||
frameUrl: (id, width) => `/api/frames/${id}/image${width ? `?w=${width}` : ''}`,
|
||||
preview: (batchId, body) => request(`/batches/${batchId}/preview`, { method: 'POST', body }),
|
||||
|
||||
startAutolabel: (batchId, body) =>
|
||||
request(`/batches/${batchId}/autolabel`, { method: 'POST', body: body ?? {} }),
|
||||
@@ -94,14 +109,115 @@ export const api = {
|
||||
request(`/frames/${frameId}/annotations`, { method: 'POST', body }),
|
||||
patchAnnotation: (id, body) => request(`/annotations/${id}`, { method: 'PATCH', body }),
|
||||
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 }),
|
||||
setFrameStatus: (frameId, status) =>
|
||||
request(`/frames/${frameId}/status`, { method: 'POST', body: { status } }),
|
||||
|
||||
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`),
|
||||
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'),
|
||||
startTraining: (projectId, body) =>
|
||||
|
||||
@@ -476,6 +476,51 @@ main.page {
|
||||
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 {
|
||||
cursor: nwse-resize;
|
||||
stroke: #ffffff;
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
|
||||
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({
|
||||
frame, imageUrl, annotations, selectedId, activeClass, assistMode, classes,
|
||||
onSelect, onCreate, onUpdate, onAssist,
|
||||
mode = 'draw', selectedIds, onSelect, onCreate, onUpdate, onAssist, onMarquee,
|
||||
}) {
|
||||
const wrapRef = useRef(null)
|
||||
const svgRef = useRef(null)
|
||||
const [displayWidth, setDisplayWidth] = useState(0)
|
||||
const [draft, setDraft] = useState(null) // box being drawn
|
||||
const [drag, setDrag] = useState(null) // move/resize in progress
|
||||
const selecting = mode === 'select'
|
||||
const marked = selectedIds instanceof Set ? selectedIds : new Set(selectedIds ?? [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = wrapRef.current
|
||||
@@ -65,16 +72,25 @@ export default function AnnotationCanvas({
|
||||
]
|
||||
}
|
||||
|
||||
const additive = useRef(false)
|
||||
|
||||
function startDraw(event) {
|
||||
if (event.button !== 0) return
|
||||
const [x, y] = pointAt(event)
|
||||
onSelect(null)
|
||||
additive.current = event.shiftKey
|
||||
if (!selecting) onSelect(null)
|
||||
setDraft([x, y, x, y])
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function startMove(event, annotation) {
|
||||
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)
|
||||
onSelect(annotation.id)
|
||||
setDrag({ kind: 'move', id: annotation.id, origin: [x, y],
|
||||
@@ -147,6 +163,15 @@ export default function AnnotationCanvas({
|
||||
if (draft) {
|
||||
const [x0, y0, x1, y1] = normalise(draft)
|
||||
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 (assistMode) onAssist([x0, y0, x1, y1])
|
||||
else onCreate({ type: 'bbox', points: [x0, y0, x1, y1] })
|
||||
@@ -184,7 +209,7 @@ export default function AnnotationCanvas({
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className={assistMode ? 'assist' : undefined}
|
||||
className={[assistMode ? 'assist' : '', selecting ? 'selecting' : ''].filter(Boolean).join(' ') || undefined}
|
||||
onPointerDown={startDraw}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
@@ -198,6 +223,8 @@ export default function AnnotationCanvas({
|
||||
scale={scale}
|
||||
handle={handle}
|
||||
selected={annotation.id === selectedId}
|
||||
marked={marked.has(annotation.id)}
|
||||
readOnly={selecting}
|
||||
classes={classes}
|
||||
onStartMove={startMove}
|
||||
onStartResize={startResize}
|
||||
@@ -211,10 +238,10 @@ export default function AnnotationCanvas({
|
||||
const [x0, y0, x1, y1] = normalise(draft)
|
||||
return (
|
||||
<rect
|
||||
className={assistMode ? 'draft assist' : 'draft'}
|
||||
className={selecting ? 'draft marquee' : assistMode ? 'draft assist' : 'draft'}
|
||||
x={x0 * width} y={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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,3 +136,21 @@ export const ZapIcon = (props) => (
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default function ReviewSidebar({
|
||||
annotations,
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
removeSelected,
|
||||
removeAnnotation,
|
||||
project,
|
||||
jumpToNextAnnotated,
|
||||
batchAnnotationCount,
|
||||
@@ -75,7 +75,7 @@ export default function ReviewSidebar({
|
||||
<li key={item.id} className={item.id === selectedId ? 'selected' : ''}>
|
||||
<button className="shape-pick" onClick={() => setSelectedId(item.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">
|
||||
{item.source === 'auto' ? item.score.toFixed(2) : 'manual'}
|
||||
</span>
|
||||
@@ -83,10 +83,7 @@ export default function ReviewSidebar({
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
aria-label="Delete shape"
|
||||
onClick={() => {
|
||||
setSelectedId(item.id)
|
||||
removeSelected()
|
||||
}}
|
||||
onClick={() => removeAnnotation(item.id)}
|
||||
>
|
||||
<TrashIcon size={13} />
|
||||
</button>
|
||||
|
||||
@@ -15,33 +15,36 @@ function boxPoints(geometry) {
|
||||
}
|
||||
|
||||
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,
|
||||
}) {
|
||||
const colour = classColor(annotation.class_id)
|
||||
const [x0, y0, x1, y1] = boxPoints(annotation.geometry)
|
||||
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 (
|
||||
<g className={selected ? 'shape selected' : 'shape'}>
|
||||
<g className={['shape', selected ? 'selected' : '', marked ? 'marked' : ''].filter(Boolean).join(' ')}>
|
||||
{isPolygon ? (
|
||||
<polygon
|
||||
points={annotation.geometry.points
|
||||
.map(([px, py]) => `${px * width},${py * height}`).join(' ')}
|
||||
stroke={colour}
|
||||
stroke={marked ? '#38bdf8' : colour}
|
||||
onPointerDown={(event) => onStartMove(event, annotation)}
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={x0 * width} y={y0 * height}
|
||||
width={(x1 - x0) * width} height={(y1 - y0) * height}
|
||||
stroke={colour}
|
||||
stroke={marked ? '#38bdf8' : colour}
|
||||
onPointerDown={(event) => onStartMove(event, annotation)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
{editable && (
|
||||
<g transform={`translate(${x0 * width}, ${Math.max(22 * scale, y0 * height - 6 * scale)})`}>
|
||||
<rect
|
||||
x="0" y={-16 * scale}
|
||||
@@ -64,7 +67,7 @@ export default function Shape({
|
||||
</g>
|
||||
)}
|
||||
|
||||
{selected && !isPolygon && CORNERS.map(([corner, cx, cy]) => (
|
||||
{editable && !isPolygon && CORNERS.map(([corner, cx, cy]) => (
|
||||
<rect
|
||||
key={corner}
|
||||
className={`handle handle-${corner}`}
|
||||
@@ -76,7 +79,7 @@ export default function Shape({
|
||||
/>
|
||||
))}
|
||||
|
||||
{selected && isPolygon && (() => {
|
||||
{editable && isPolygon && (() => {
|
||||
const points = annotation.geometry.points
|
||||
const vertices = points.map(([px, py], i) => (
|
||||
<circle
|
||||
|
||||
@@ -7,6 +7,14 @@ export default function ShortcutsPanel() {
|
||||
<dt><kbd>Drag</kbd></dt>
|
||||
<dd>Add box / resize / move</dd>
|
||||
</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>
|
||||
<dt><kbd>Hold S</kbd></dt>
|
||||
<dd>SAM3 assisted shape</dd>
|
||||
@@ -28,8 +36,12 @@ export default function ShortcutsPanel() {
|
||||
<dd>Approve / reject</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>U</kbd></dt>
|
||||
<dd>Next unreviewed</dd>
|
||||
<dt><kbd>U</kbd> <kbd>N</kbd></dt>
|
||||
<dd>Next unreviewed / next shape</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>C</kbd> <kbd>T</kbd></dt>
|
||||
<dd>Copy prev / Track 5 frames</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -9,24 +9,25 @@ import {
|
||||
BarChartIcon,
|
||||
SunIcon,
|
||||
MoonIcon,
|
||||
ChevronRightIcon,
|
||||
CpuIcon,
|
||||
LayersIcon,
|
||||
ZapIcon,
|
||||
} from './Icons'
|
||||
|
||||
|
||||
export default function Sidebar({ route, currentProject, theme, onToggleTheme }) {
|
||||
const [health, setHealth] = useState(null)
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('sidebar_collapsed') === 'true')
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
|
||||
@@ -36,73 +37,122 @@ export default function Sidebar({ route, currentProject, theme, onToggleTheme })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`roboflow-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-logo">{collapsed ? 'DE' : 'Dataset Enrichment'}</div>
|
||||
<button className="sidebar-collapse-btn" onClick={toggleCollapse} title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
|
||||
{collapsed ? <ChevronRightIcon size={14} /> : '❮'}
|
||||
</button>
|
||||
<header className="roboflow-topbar">
|
||||
<div className="topbar-logo" onClick={(e) => handleNav(e, '/projects')} style={{ cursor: 'pointer' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.95rem', letterSpacing: '-0.01em', color: '#f4f4f5' }}>
|
||||
Dataset Enrichment
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
{!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>
|
||||
<nav className="topbar-nav">
|
||||
<a
|
||||
href={route.batchId ? `#/batches/${route.batchId}` : `#/projects/${pId}`}
|
||||
onClick={(e) => handleNav(e, route.batchId ? `/batches/${route.batchId}` : `/projects/${pId}`)}
|
||||
className={`sidebar-item ${route.name === 'review' || route.name === 'trim' ? 'active' : ''}`}
|
||||
title="Annotate / Review"
|
||||
href="#/projects"
|
||||
onClick={(e) => handleNav(e, '/projects')}
|
||||
className={`topbar-item ${route.name === 'projects' ? 'active' : ''}`}
|
||||
>
|
||||
<span className="sidebar-icon"><TagIcon size={16} /></span>
|
||||
{!collapsed && <span>Annotate / Review</span>}
|
||||
<FolderIcon size={15} />
|
||||
<span>Projects</span>
|
||||
</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">
|
||||
{!collapsed && <div className="sidebar-section-title">MODELS & TOOLS</div>}
|
||||
<a href={`#/projects/${pId}/models`} onClick={(e) => handleNav(e, `/projects/${pId}/models`)} className={`sidebar-item ${route.name === 'models' ? 'active' : ''}`} title="Train & Select Engine">
|
||||
<span className="sidebar-icon"><RocketIcon size={16} /></span>
|
||||
{!collapsed && <span>Train & Select Engine</span>}
|
||||
<a
|
||||
href={`#/projects/${pId}`}
|
||||
onClick={(e) => handleNav(e, `/projects/${pId}`)}
|
||||
className={`topbar-item ${route.name === 'library' ? 'active' : ''}`}
|
||||
>
|
||||
<DatabaseIcon size={15} />
|
||||
<span>Video Archive</span>
|
||||
</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>
|
||||
{!collapsed && <span>SAM3 Playground</span>}
|
||||
|
||||
<a
|
||||
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>
|
||||
</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')}
|
||||
</button>
|
||||
{health && !collapsed && (
|
||||
<div className="sidebar-health">
|
||||
<div className="health-item" title={health.gpu || ''} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}>
|
||||
<span style={{ float: 'left' }}>GPU:</span>
|
||||
<span style={{ float: 'right' }}>{health.gpu ? health.gpu.replace('NVIDIA GeForce ', '').replace(' Laptop GPU', '') : 'N/A'}</span>
|
||||
</div>
|
||||
<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>
|
||||
<div className="health-item"><span>FFmpeg:</span> <span>{health.ffmpeg ? 'OK' : 'Error'}</span></div>
|
||||
|
||||
|
||||
<a
|
||||
href={`#/projects/${pId}/datasets`}
|
||||
onClick={(e) => handleNav(e, `/projects/${pId}/datasets`)}
|
||||
className={`topbar-item ${route.name === 'datasets' ? 'active' : ''}`}
|
||||
>
|
||||
<LayersIcon size={15} />
|
||||
<span>Datasets</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+279
-436
@@ -1,7 +1,26 @@
|
||||
import AutoAnnotateModal from '../components/AutoAnnotateModal'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, formatDuration } from '../api'
|
||||
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) {
|
||||
if (!bytes) return '—'
|
||||
@@ -10,10 +29,9 @@ function megabytes(bytes) {
|
||||
if (mb >= 10) return `${Math.round(mb)} MB`
|
||||
if (mb >= 1) return `${mb.toFixed(1)} MB`
|
||||
return `${Math.round(bytes / 1024)} KB`
|
||||
}
|
||||
|
||||
function ActiveJobsBanner({ jobs, onCancel }) {
|
||||
}export function ActiveJobsBanner({ jobs, onCancel }) {
|
||||
if (!jobs || jobs.length === 0) return null
|
||||
|
||||
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)' }}>
|
||||
<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 [selectedIds, setSelectedIds] = useState([])
|
||||
|
||||
const [appendChoiceBatch, setAppendChoiceBatch] = useState(null)
|
||||
const [appendModalState, setAppendModalState] = useState(null)
|
||||
const [sam3AppendState, setSam3AppendState] = useState(null)
|
||||
const [customPromptInput, setCustomPromptInput] = useState('')
|
||||
const [baseModelModalState, setBaseModelModalState] = useState(null)
|
||||
const [autoAnnotateConfig, setAutoAnnotateConfig] = useState(null)
|
||||
|
||||
function openBaseModelAutolabelModal(batch) {
|
||||
const projectClasses = project?.classes?.map((c) => c.name) || []
|
||||
setBaseModelModalState({
|
||||
batch,
|
||||
selectedClasses: [...projectClasses],
|
||||
threshold: 0.35,
|
||||
iouThreshold: 0.8,
|
||||
})
|
||||
setAutoAnnotateConfig({ batch, project, engine: 'base_model' })
|
||||
}
|
||||
|
||||
async function resetAutoAnnotations(batch) {
|
||||
@@ -83,14 +94,12 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
setBusyId(batch.id)
|
||||
try {
|
||||
const info = await api.inspectModel(file)
|
||||
setAppendModalState({
|
||||
setAutoAnnotateConfig({
|
||||
batch,
|
||||
file,
|
||||
filename: info.filename,
|
||||
classes: info.classes || [],
|
||||
selectedClasses: info.classes || [],
|
||||
threshold: 0.35,
|
||||
iouThreshold: 0.8,
|
||||
project,
|
||||
engine: 'custom',
|
||||
customModelStagedPath: info.staged_path,
|
||||
customModelClasses: info.classes || []
|
||||
})
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
@@ -103,14 +112,7 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
|
||||
function openSam3AppendModal(batch) {
|
||||
setAppendChoiceBatch(null)
|
||||
const projectClasses = project?.classes?.map((c) => c.name) || []
|
||||
setSam3AppendState({
|
||||
batch,
|
||||
selectedClasses: [...projectClasses],
|
||||
threshold: 0.35,
|
||||
iouThreshold: 0.8,
|
||||
})
|
||||
setCustomPromptInput('')
|
||||
setAutoAnnotateConfig({ batch, project, engine: 'sam3' })
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<div className="panel table-wrap" style={{ marginTop: 16 }}>
|
||||
<>
|
||||
<div className="panel table-wrap" style={{ marginTop: 16, padding: '16px' }}>
|
||||
<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 & Merge Selected ({approvableSelected.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="video-table">
|
||||
|
||||
<thead>
|
||||
<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>Shapes</th><th>Status</th><th />
|
||||
</tr>
|
||||
@@ -159,6 +211,26 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
return (
|
||||
<React.Fragment key={batch.id}>
|
||||
<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>
|
||||
<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}
|
||||
@@ -178,14 +250,9 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
<td>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<button className="btn btn-primary" disabled={isProcessing || batch.frame_count === 0}
|
||||
title="Annotate using project base model with target class selection"
|
||||
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"
|
||||
title="Auto-annotate using selected engine"
|
||||
onClick={() => setAppendChoiceBatch(batch)}>
|
||||
+ Append
|
||||
{batchJob?.type === 'autolabel' ? 'Processing…' : 'Auto-annotate'}
|
||||
</button>
|
||||
<button className="btn" disabled={isProcessing || batch.annotation_count === 0}
|
||||
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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Choice Modal: SAM3 vs Custom YOLO */}
|
||||
{appendChoiceBatch && (
|
||||
@@ -217,12 +285,31 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
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' }}>
|
||||
<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 }}>
|
||||
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>
|
||||
|
||||
<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 ></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 */}
|
||||
<div
|
||||
style={{
|
||||
@@ -267,387 +354,32 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SAM3 Append Modal */}
|
||||
{sam3AppendState && (
|
||||
<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(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
|
||||
})
|
||||
{autoAnnotateConfig && (
|
||||
<AutoAnnotateModal
|
||||
{...autoAnnotateConfig}
|
||||
onClose={() => setAutoAnnotateConfig(null)}
|
||||
onSuccess={() => {
|
||||
setAutoAnnotateConfig(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 }) {
|
||||
const [project, setProject] = useState(null)
|
||||
const [dates, setDates] = useState([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [truckBusy, setTruckBusy] = useState(false)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [videos, setVideos] = useState(null)
|
||||
const [batches, setBatches] = useState([])
|
||||
const [jobs, setJobs] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
|
||||
const loadBatches = useCallback(() => {
|
||||
api.listBatches(projectId).then((payload) => setBatches(payload.batches)).catch(() => {})
|
||||
}, [projectId])
|
||||
@@ -659,13 +391,13 @@ export default function LibraryPage({ projectId, onProject }) {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
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]) => {
|
||||
if (cancelled) return
|
||||
setProject(loadedProject)
|
||||
onProject?.(loadedProject)
|
||||
setDates(library.dates)
|
||||
setSelected(library.dates[0]?.date ?? null)
|
||||
setDates(library.cycles)
|
||||
setSelected(library.cycles[0]?.cycle ?? null)
|
||||
setBatches(batchPayload.batches)
|
||||
setJobs(jobPayload.jobs)
|
||||
})
|
||||
@@ -677,7 +409,7 @@ export default function LibraryPage({ projectId, onProject }) {
|
||||
if (!selected) return
|
||||
let cancelled = false
|
||||
setVideos(null)
|
||||
api.listVideos(projectId, selected)
|
||||
api.archiveCycleVideos(projectId, selected)
|
||||
.then((payload) => !cancelled && setVideos(payload.videos))
|
||||
.catch((exc) => !cancelled && setError(exc.message))
|
||||
return () => { cancelled = true }
|
||||
@@ -716,67 +448,162 @@ export default function LibraryPage({ projectId, onProject }) {
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div className="page-head" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h1>{project.name}</h1>
|
||||
<h1>Video Archive</h1>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 ? (
|
||||
<p className="empty">
|
||||
No date folders in this archive yet. Expected layout: <date>/<batch>.mp4
|
||||
</p>
|
||||
) : (
|
||||
<div className="library">
|
||||
<div className="library" style={{ marginTop: 12 }}>
|
||||
<nav className="panel date-list" aria-label="Recording dates">
|
||||
{dates.map((item) => (
|
||||
<button
|
||||
key={item.date}
|
||||
key={item.cycle}
|
||||
className="date-item"
|
||||
aria-current={item.date === selected}
|
||||
onClick={() => setSelected(item.date)}
|
||||
aria-current={item.cycle === selected}
|
||||
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}
|
||||
<span className="count">{item.video_count}</span>
|
||||
<FolderIcon size={14} style={{ opacity: item.cycle === selected ? 1 : 0.6 }} />
|
||||
<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>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="panel table-wrap">
|
||||
<div className="panel table-wrap" style={{ padding: 16 }}>
|
||||
{videos === null ? (
|
||||
<p className="empty">Reading video metadata…</p>
|
||||
) : videos.length === 0 ? (
|
||||
<p className="empty">No videos in {selected}.</p>
|
||||
<p className="empty">Tidak ada rekaman di {labelSiklus(selected)}.</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0, fontSize: '0.95rem', color: '#e4e4e7' }}>
|
||||
{labelSiklus(selected)} — {videos.length} rekaman
|
||||
</h3>
|
||||
<div style={{ position: 'relative', width: 240 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter archive videos..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '5px 10px 5px 30px',
|
||||
fontSize: '0.8rem',
|
||||
background: '#09090b',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 6,
|
||||
color: '#f4f4f5'
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: 'absolute', left: 8, top: '50%', transform: 'translateY(-50%)', opacity: 0.5, display: 'flex' }}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="video-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Batch</th>
|
||||
<th style={{ width: 48, textAlign: 'right' }}>Batch</th>
|
||||
<th style={{ width: 96 }}>Direkam</th>
|
||||
<th>File</th>
|
||||
<th>Duration</th>
|
||||
<th>Resolution</th>
|
||||
<th>FPS</th>
|
||||
<th>Size</th>
|
||||
<th>Used</th>
|
||||
<th>Truk</th>
|
||||
<th>Status</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{videos.map((item) => (
|
||||
{videos
|
||||
.filter((item) => !searchQuery || item.batch_label.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.map((item) => (
|
||||
<tr key={item.rel}>
|
||||
<td>{item.batch_label}</td>
|
||||
<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>}
|
||||
@@ -784,24 +611,39 @@ export default function LibraryPage({ projectId, onProject }) {
|
||||
<td className="num">{item.fps || '—'}</td>
|
||||
<td className="num">{megabytes(item.size)}</td>
|
||||
<td>
|
||||
{item.used_count > 0
|
||||
? <span className="tag">{item.used_count} batch{item.used_count > 1 ? 'es' : ''}</span>
|
||||
: <span className="faint">—</span>}
|
||||
{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"
|
||||
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)}`)}
|
||||
>
|
||||
Trim
|
||||
<ScissorsIcon size={14} /> Trim
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -809,3 +651,4 @@ export default function LibraryPage({ projectId, onProject }) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+205
-115
@@ -90,20 +90,29 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
const [job, setJob] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [selectedBatchIds, setSelectedBatchIds] = 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 [loadedProject, loadedSummary, modelPayload, hw, jobsPayload] = await Promise.all([
|
||||
api.getProject(projectId), api.datasetSummary(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)
|
||||
onProject?.(loadedProject)
|
||||
setSummary(loadedSummary)
|
||||
setModels(modelPayload.models)
|
||||
setHardware(hw)
|
||||
setSelectedBatchIds(loadedSummary.batches.map((b) => b.id))
|
||||
setSelectedClassIds(loadedProject.classes?.map((c) => c.class_id) || [])
|
||||
const activeJob = jobsPayload.jobs?.find((j) => ['running', 'queued'].includes(j.status))
|
||||
if (activeJob) setJob(activeJob)
|
||||
@@ -111,6 +120,15 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
|
||||
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(() => {
|
||||
if (!job || ['done', 'failed', 'cancelled'].includes(job.status)) {
|
||||
if (job?.status === 'done') load().catch(() => {})
|
||||
@@ -127,7 +145,10 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
try {
|
||||
setJob(await api.startTraining(projectId, {
|
||||
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,
|
||||
}))
|
||||
} 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) => {
|
||||
setSelectedClassIds((prev) =>
|
||||
prev.includes(classId) ? prev.filter((cId) => cId !== classId) : [...prev, classId]
|
||||
@@ -171,27 +178,29 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{error && <p className="error-banner" style={{ marginBottom: 12 }}>
|
||||
<AlertIcon size={14} /> {error}
|
||||
</p>}
|
||||
|
||||
<div className="panel side-panel" style={{ marginBottom: 20 }}>
|
||||
<h2>Base Model Configuration</h2>
|
||||
<p className="hint">Upload a base YOLO model checkpoint (.pt) to use for fine-tuning baseline and auto-annotation:</p>
|
||||
|
||||
<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.
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(340px, 420px) 1fr', gap: 20, alignItems: 'start' }}>
|
||||
{/* Left Column: Training Configuration & Batch Selection */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Base Model Configuration */}
|
||||
<div className="panel side-panel">
|
||||
<h2 style={{ fontSize: '1rem', marginBottom: 4 }}>Base Model Configuration</h2>
|
||||
<p className="hint" style={{ fontSize: '0.8rem', marginBottom: 12 }}>
|
||||
Fine-tuning baseline and auto-annotation model checkpoint.
|
||||
</p>
|
||||
<div style={{ fontSize: '0.8rem', color: '#a1a1aa', marginBottom: 10 }}>
|
||||
|
||||
<div style={{ padding: 12, background: 'rgba(0,0,0,0.3)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<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>Base Model Classes:</strong> {project.classes?.map((c) => c.name).join(', ')}
|
||||
<strong>Classes:</strong> {project.classes?.map((c) => c.name).join(', ')}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@@ -209,37 +218,25 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="upload-primary-model" className="btn btn-ghost" style={{ cursor: 'pointer', padding: '4px 10px', fontSize: '0.8rem' }}>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{/* Training Control Panel */}
|
||||
<div className="panel side-panel">
|
||||
<h2>Train</h2>
|
||||
<p className="hint">
|
||||
Fine-tunes{' '}
|
||||
{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).
|
||||
<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: 12 }}>
|
||||
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>Target Classes to Train:</label>
|
||||
<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 }}>
|
||||
{project.classes?.map((cls) => {
|
||||
const isChecked = selectedClassIds.includes(cls.class_id)
|
||||
@@ -250,106 +247,199 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
className="tag"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '4px 9px',
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.8rem',
|
||||
background: isChecked ? 'rgba(192, 132, 252, 0.25)' : 'rgba(255,255,255,0.05)',
|
||||
color: isChecked ? '#f3e8ff' : '#71717a',
|
||||
border: isChecked ? '1px solid rgba(192, 132, 252, 0.6)' : '1px solid rgba(255,255,255,0.1)',
|
||||
background: isChecked ? 'rgba(192, 132, 252, 0.2)' : 'rgba(255,255,255,0.05)',
|
||||
color: isChecked ? '#f3e8ff' : '#a1a1aa',
|
||||
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)}
|
||||
>
|
||||
{isChecked ? '✓ ' : ''}{cls.name}
|
||||
{isChecked && <CheckIcon size={12} />}
|
||||
{cls.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="epochs">Epochs</label>
|
||||
<input id="epochs" type="number" min="1" max="500" value={epochs}
|
||||
onChange={(e) => setEpochs(e.target.value)} />
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<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}
|
||||
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>
|
||||
|
||||
{hardware && (
|
||||
<p className="hint">
|
||||
{hardware.gpu ?? 'CPU'} — defaults batch {hardware.batch},
|
||||
imgsz {hardware.imgsz}. {hardware.note}
|
||||
<p className="hint" style={{ fontSize: '0.78rem', marginBottom: 12 }}>
|
||||
{hardware.gpu ?? 'CPU'} — default batch {hardware.batch}, imgsz {hardware.imgsz}.
|
||||
</p>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={train}
|
||||
disabled={running || summary.splits.train === 0 || selectedBatchIds.length === 0 || selectedClassIds.length === 0}>
|
||||
{running ? 'Training…' : 'Start training'}
|
||||
|
||||
<button
|
||||
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>
|
||||
|
||||
{summary.splits.train === 0 && (
|
||||
<p className="hint">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>
|
||||
<p className="hint" style={{ fontSize: '0.78rem', marginTop: 8 }}>Approve and merge a batch first.</p>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{job && (
|
||||
{/* Which datasets this run trains on (REQ-111) */}
|
||||
<div className="panel side-panel">
|
||||
<h2>Job {job.id}</h2>
|
||||
<div className="row">
|
||||
<span className={`dot ${job.status === 'done' ? 'ok' : job.status === 'failed' ? 'bad' : ''}`} />
|
||||
<span>{job.status}</span>
|
||||
<span className="spacer" />
|
||||
<span className="mono">{job.progress}/{job.total}</span>
|
||||
</div>
|
||||
<div className="progress">
|
||||
<span style={{ width: `${job.total ? (job.progress / job.total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
{job.error && <p className="error-banner">{job.error}</p>}
|
||||
<pre className="job-log">{job.log.slice(-8).join('\n')}</pre>
|
||||
{running && (
|
||||
<button className="btn" onClick={() => api.cancelJob(job.id).catch(() => {})}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{job.status === 'done' && (
|
||||
<p className="hint"><CheckIcon size={13} /> Finished — the version is listed
|
||||
on the left.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary.batches.length > 0 && (
|
||||
<div className="panel side-panel">
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<h2>Select Dataset Batches</h2>
|
||||
<span className="spacer" />
|
||||
<button className="btn" style={{ fontSize: 11, padding: '2px 8px' }} onClick={toggleSelectAllBatches}>
|
||||
{selectedBatchIds.length === summary.batches.length ? 'Deselect All' : 'Select All'}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h2 style={{ fontSize: '0.95rem', margin: 0 }}>
|
||||
Select Datasets ({selectedDatasetIds.length}/{datasetRows.length})
|
||||
</h2>
|
||||
<button
|
||||
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>
|
||||
<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' }}>
|
||||
<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={isChecked}
|
||||
onChange={() => toggleBatchSelect(item.id)}
|
||||
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' }} onClick={() => toggleBatchSelect(item.id)}>
|
||||
{item.date_label} · {item.batch_label}
|
||||
<span className="faint mono" style={{ marginLeft: 6 }}>{item.images} img</span>
|
||||
<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>
|
||||
)}
|
||||
</aside>
|
||||
</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}%` }} />
|
||||
</div>
|
||||
{job.error && <p className="error-banner">{job.error}</p>}
|
||||
<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 && (
|
||||
<button className="btn btn-ghost" style={{ marginTop: 8, fontSize: '0.8rem', cursor: 'pointer' }} onClick={() => api.cancelJob(job.id).catch(() => {})}>
|
||||
Cancel Job
|
||||
</button>
|
||||
)}
|
||||
{job.status === 'done' && (
|
||||
<p className="hint" style={{ marginTop: 8, color: '#4ade80', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<CheckIcon size={14} /> Training Finished — Version added to list below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Versions History */}
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.05rem', marginBottom: 12 }}>Trained Model Versions ({models.length})</h2>
|
||||
{models.length === 0 && !running && (
|
||||
<p className="empty">No trained model versions yet.</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{models.map((version) => (
|
||||
<VersionCard key={version.id} version={version} onPromote={load} onError={setError} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
const [index, setIndex] = useState(0)
|
||||
const [annotations, setAnnotations] = useState([])
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [mode, setMode] = useState('draw')
|
||||
const [markedIds, setMarkedIds] = useState([])
|
||||
const [activeClass, setActiveClass] = useState(0)
|
||||
const [assistMode, setAssistMode] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -78,6 +80,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
let cancelled = false
|
||||
api.frameAnnotations(frame.id).then((payload) => !cancelled && setAnnotations(payload.annotations)).catch((exc) => !cancelled && setError(exc.message))
|
||||
setSelectedId(null)
|
||||
setMarkedIds([])
|
||||
return () => { cancelled = true }
|
||||
}, [frame?.id])
|
||||
|
||||
@@ -124,19 +127,84 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
return
|
||||
}
|
||||
if (!commit) return
|
||||
const current = annotations.find((row) => row.id === id)
|
||||
if (!current) return
|
||||
try { await api.patchAnnotation(id, { geometry: current.geometry }) } catch (exc) { setError(exc.message) }
|
||||
const previous = annotations.find((row) => row.id === id)
|
||||
if (!previous) return
|
||||
// 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 () => {
|
||||
if (selectedId == null || !frame) return
|
||||
const id = selectedId
|
||||
const removeAnnotation = useCallback(async (id) => {
|
||||
if (id == null || !frame) return
|
||||
const previous = annotations.find((row) => row.id === id)
|
||||
if (!previous) return
|
||||
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)))
|
||||
try { await api.deleteAnnotation(id) } catch (exc) { setError(exc.message) }
|
||||
}, [selectedId, frame])
|
||||
try {
|
||||
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) => {
|
||||
setActiveClass(classId)
|
||||
@@ -147,8 +215,10 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
} catch (exc) { setError(exc.message) }
|
||||
}, [selectedId])
|
||||
|
||||
async function approveBatch() {
|
||||
try { await api.approveBatch(batchId); navigate(`/projects/${batch.project_id}/models`) } catch (exc) { setError(exc.message) }
|
||||
// Review signs frames off; it no longer merges. Data Prep is the gate that
|
||||
// turns a selection of batches into a dataset (REQ-131).
|
||||
function toDataPrep() {
|
||||
navigate(`/projects/${batch.project_id}/data-prep?batches=${batchId}`)
|
||||
}
|
||||
|
||||
async function jumpToPending() {
|
||||
@@ -169,17 +239,92 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
}
|
||||
}, [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({})
|
||||
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(() => {
|
||||
function onKeyDown(event) {
|
||||
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 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() }
|
||||
|
||||
// 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)
|
||||
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))
|
||||
@@ -187,6 +332,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
else if (key === 'x' || key === 'X') setStatus?.('rejected')
|
||||
else if (key === 'u' || key === 'U') jumpToPending?.()
|
||||
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 (/^[1-9]$/.test(key)) {
|
||||
const classId = Number(key) - 1
|
||||
@@ -249,10 +396,10 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
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'}
|
||||
onClick={approveBatch}
|
||||
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={toDataPrep}
|
||||
>
|
||||
{batch.status === 'merged' ? 'Merged' : 'Approve batch'}
|
||||
{batch.status === 'merged' ? 'Merged' : 'Prepare & merge'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -283,13 +430,82 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
activeClass={activeClass}
|
||||
assistMode={assistMode}
|
||||
classes={classesList}
|
||||
mode={mode}
|
||||
selectedIds={markedIds}
|
||||
onSelect={setSelectedId}
|
||||
onCreate={createShape}
|
||||
onUpdate={updateShape}
|
||||
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">
|
||||
<button className="btn" onClick={() => setIndex((i) => Math.max(0, i - 1))} disabled={index === 0}>←</button>
|
||||
<span className="mono">
|
||||
@@ -306,6 +522,26 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
>
|
||||
🏷️ Next Shape [N]
|
||||
</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" />
|
||||
{busy && <span className="muted">asking SAM3…</span>}
|
||||
<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}
|
||||
selectedId={selectedId}
|
||||
setSelectedId={setSelectedId}
|
||||
removeSelected={removeSelected}
|
||||
removeAnnotation={removeAnnotation}
|
||||
project={project}
|
||||
jumpToNextAnnotated={jumpToNextAnnotated}
|
||||
batchAnnotationCount={batch?.annotation_count ?? 0}
|
||||
|
||||
+90
-83
@@ -2,6 +2,7 @@
|
||||
|
||||
.roboflow-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
@@ -9,95 +10,97 @@
|
||||
color: var(--text, #f3f4f6);
|
||||
}
|
||||
|
||||
.roboflow-sidebar {
|
||||
width: 260px;
|
||||
background: rgba(17, 24, 39, 0.65);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
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;
|
||||
.roboflow-topbar {
|
||||
height: 48px;
|
||||
background: rgba(17, 24, 39, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.roboflow-sidebar.collapsed .sidebar-header {
|
||||
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 {
|
||||
.topbar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
color: var(--text-muted, #d1d5db);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 13px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
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 {
|
||||
@@ -154,12 +157,16 @@
|
||||
|
||||
.roboflow-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 48px);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 20px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
/* Models page engine cards */
|
||||
.select-engine-section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
@@ -18,3 +18,6 @@ pycocotools
|
||||
|
||||
# sam3/model_builder.py still imports pkg_resources, which setuptools 81+ dropped
|
||||
setuptools<81
|
||||
psutil
|
||||
shapely
|
||||
lap
|
||||
@@ -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()
|
||||
Executable
+66
@@ -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)
|
||||
@@ -50,16 +50,25 @@ else
|
||||
echo "ℹ️ No NVIDIA GPU detected. Running in CPU-only mode."
|
||||
fi
|
||||
|
||||
if docker info &> /dev/null; then
|
||||
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
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}' || echo "localhost")
|
||||
PORT=${WEB_PORT:-8080}
|
||||
|
||||
echo ""
|
||||
echo "=========================================================="
|
||||
echo "✅ App is successfully running!"
|
||||
echo "✅ App is running!"
|
||||
echo "🌐 Access it locally at: http://localhost:$PORT"
|
||||
echo "📱 Access it on your network at: http://$LOCAL_IP:$PORT"
|
||||
echo "=========================================================="
|
||||
|
||||
Reference in new issue
Block a user