Files
camwatch/app/database.py
T
percoandClaude Sonnet 4.6 fd52942bd1 Add annotation UI for building a custom training dataset
New page /event/{id}/annotate:
- Frame selector grid (all frames from the event)
- Canvas with click-and-drag rectangle drawing to mark the plate bbox
- Touch support for tablets
- Plate text input (pre-filled with auto-detected plate)
- Saves image + YOLO label (.txt) + plates.csv to /data/annotations/
- Stores annotation metadata in DB (annotations table)

/dataset/export serves a ZIP with images/, labels/, plates.csv and
a data.yaml ready for YOLOv8/v9 fine-tuning on Google Colab.

"Annoter" button added to event detail header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:34:09 +02:00

241 lines
7.4 KiB
Python

import sqlite3
import os
DB_PATH = os.environ.get("DB_PATH", "/data/camwatch.db")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
camera TEXT,
start_time INTEGER,
snapshot_path TEXT,
clip_path TEXT,
plate TEXT,
color_hex TEXT,
color_name TEXT,
processed_at INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS whitelist (
plate TEXT PRIMARY KEY,
label TEXT,
added_at INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS annotations (
id TEXT PRIMARY KEY,
event_id TEXT,
frame_path TEXT,
plate_text TEXT,
bbox_cx REAL,
bbox_cy REAL,
bbox_w REAL,
bbox_h REAL,
created_at INTEGER
)
""")
# Migrate existing DBs that lack clip_path
try:
conn.execute("ALTER TABLE events ADD COLUMN clip_path TEXT")
except Exception:
pass
conn.commit()
conn.close()
def event_exists(event_id: str) -> bool:
conn = get_db()
row = conn.execute("SELECT id FROM events WHERE id = ?", (event_id,)).fetchone()
conn.close()
return row is not None
def insert_event(event_id, camera, start_time, snapshot_path, clip_path, plate, color_hex, color_name):
import time
conn = get_db()
conn.execute("""
INSERT OR IGNORE INTO events
(id, camera, start_time, snapshot_path, clip_path, plate, color_hex, color_name, processed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (event_id, camera, start_time, snapshot_path, clip_path, plate, color_hex, color_name, int(time.time())))
conn.commit()
conn.close()
def get_events(limit=100, offset=0, plate_filter=None, camera_filter=None, date_filter=None):
conn = get_db()
query = "SELECT * FROM events WHERE 1=1"
params = []
if plate_filter:
query += " AND plate LIKE ?"
params.append(f"%{plate_filter}%")
if camera_filter:
query += " AND camera = ?"
params.append(camera_filter)
if date_filter:
import time
from datetime import datetime
try:
dt = datetime.strptime(date_filter, "%Y-%m-%d")
ts_start = int(dt.timestamp())
ts_end = ts_start + 86400
query += " AND start_time >= ? AND start_time < ?"
params.extend([ts_start, ts_end])
except Exception:
pass
query += " ORDER BY start_time DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(query, params).fetchall()
conn.close()
return [dict(r) for r in rows]
def count_events(plate_filter=None, camera_filter=None, date_filter=None):
conn = get_db()
query = "SELECT COUNT(*) FROM events WHERE 1=1"
params = []
if plate_filter:
query += " AND plate LIKE ?"
params.append(f"%{plate_filter}%")
if camera_filter:
query += " AND camera = ?"
params.append(camera_filter)
if date_filter:
import time
from datetime import datetime
try:
dt = datetime.strptime(date_filter, "%Y-%m-%d")
ts_start = int(dt.timestamp())
ts_end = ts_start + 86400
query += " AND start_time >= ? AND start_time < ?"
params.extend([ts_start, ts_end])
except Exception:
pass
count = conn.execute(query, params).fetchone()[0]
conn.close()
return count
def get_event(event_id: str) -> dict | None:
conn = get_db()
row = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
conn.close()
return dict(row) if row else None
def delete_event(event_id: str):
conn = get_db()
conn.execute("DELETE FROM events WHERE id = ?", (event_id,))
conn.commit()
conn.close()
def update_plate(event_id: str, plate: str):
conn = get_db()
conn.execute("UPDATE events SET plate = ? WHERE id = ?", (plate.strip().upper() or None, event_id))
conn.commit()
conn.close()
def get_cameras():
conn = get_db()
rows = conn.execute("SELECT DISTINCT camera FROM events ORDER BY camera").fetchall()
conn.close()
return [r[0] for r in rows]
# ── Whitelist ─────────────────────────────────────────────────────────────────
def get_whitelist() -> list[dict]:
conn = get_db()
rows = conn.execute("SELECT plate, label, added_at FROM whitelist ORDER BY added_at DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
def is_whitelisted(plate: str) -> bool:
if not plate:
return False
conn = get_db()
row = conn.execute("SELECT plate FROM whitelist WHERE plate = ?", (plate.upper().strip(),)).fetchone()
conn.close()
return row is not None
def add_to_whitelist(plate: str, label: str = ""):
import time
conn = get_db()
conn.execute(
"INSERT OR REPLACE INTO whitelist (plate, label, added_at) VALUES (?, ?, ?)",
(plate.upper().strip(), label.strip(), int(time.time())),
)
conn.commit()
conn.close()
def remove_from_whitelist(plate: str):
conn = get_db()
conn.execute("DELETE FROM whitelist WHERE plate = ?", (plate.upper().strip(),))
conn.commit()
conn.close()
# ── Annotations ───────────────────────────────────────────────────────────────
def save_annotation(ann_id: str, event_id: str, frame_path: str, plate_text: str,
cx: float, cy: float, w: float, h: float):
import time
conn = get_db()
conn.execute("""
INSERT OR REPLACE INTO annotations
(id, event_id, frame_path, plate_text, bbox_cx, bbox_cy, bbox_w, bbox_h, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (ann_id, event_id, frame_path, plate_text, cx, cy, w, h, int(time.time())))
conn.commit()
conn.close()
def count_annotations() -> int:
conn = get_db()
count = conn.execute("SELECT COUNT(*) FROM annotations").fetchone()[0]
conn.close()
return count
def get_annotations() -> list[dict]:
conn = get_db()
rows = conn.execute(
"SELECT * FROM annotations ORDER BY created_at DESC"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_annotated_event_ids() -> set:
conn = get_db()
rows = conn.execute("SELECT DISTINCT event_id FROM annotations").fetchall()
conn.close()
return {r[0] for r in rows}
# ── Stats ─────────────────────────────────────────────────────────────────────
def get_plate_stats() -> list[dict]:
conn = get_db()
rows = conn.execute("""
SELECT plate,
COUNT(*) AS count,
MIN(start_time) AS first_seen,
MAX(start_time) AS last_seen
FROM events
WHERE plate IS NOT NULL AND plate != ''
GROUP BY plate
ORDER BY count DESC
""").fetchall()
conn.close()
return [dict(r) for r in rows]