diff --git a/docker/docker-compose-backend.yml b/docker/docker-compose-backend.yml new file mode 100644 index 00000000..6a9632e4 --- /dev/null +++ b/docker/docker-compose-backend.yml @@ -0,0 +1,101 @@ +version: '3.8' + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: namucam + POSTGRES_USER: namucam + POSTGRES_PASSWORD: namucam + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U namucam"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + + minio: + image: minio/minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - minio_data:/data + ports: + - "9000:9000" + - "9001:9001" + + go2rtc: + image: alexxit/go2rtc + ports: + - "1984:1984" + volumes: + - ./go2rtc.yaml:/config/go2rtc.yaml + + backend: + build: + context: ../src/backend + dockerfile: Dockerfile + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://namucam:namucam@db:5432/namucam + REDIS_URL: redis://redis:6379/0 + MINIO_ENDPOINT: minio:9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + GO2RTC_URL: http://go2rtc:1984 + VLM_BASE_URL: ${VLM_BASE_URL:-http://host.docker.internal:5405} + LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:5407} + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + volumes: + - snapshots:/tmp/snapshots + + worker: + build: + context: ../src/backend + dockerfile: Dockerfile + command: celery -A backend.workers.celery_app worker --loglevel=info --concurrency=4 + environment: + DATABASE_URL: postgresql+asyncpg://namucam:namucam@db:5432/namucam + REDIS_URL: redis://redis:6379/0 + MINIO_ENDPOINT: minio:9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + GO2RTC_URL: http://go2rtc:1984 + VLM_BASE_URL: ${VLM_BASE_URL:-http://host.docker.internal:5405} + LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:5407} + depends_on: + - db + - redis + volumes: + - snapshots:/tmp/snapshots + + frontend: + build: + context: ../src/frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000} + NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-ws://localhost:8000} + ports: + - "3000:3000" + depends_on: + - backend + +volumes: + pg_data: + redis_data: + minio_data: + snapshots: diff --git a/docker/go2rtc.yaml b/docker/go2rtc.yaml new file mode 100644 index 00000000..ad227279 --- /dev/null +++ b/docker/go2rtc.yaml @@ -0,0 +1,12 @@ +api: + listen: ":1984" + +# Streams are added dynamically via the go2rtc REST API +# Example: +# streams: +# cam_001: rtsp://192.168.1.100/stream1 +# cam_002: rtsp://192.168.1.101/stream1 +streams: {} + +webrtc: + listen: ":8555" diff --git a/skills/analysis/traffic-vlm-analysis/SKILL.md b/skills/analysis/traffic-vlm-analysis/SKILL.md new file mode 100644 index 00000000..082d497b --- /dev/null +++ b/skills/analysis/traffic-vlm-analysis/SKILL.md @@ -0,0 +1,143 @@ +--- +name: traffic-vlm-analysis +description: "Traffic & public safety VLM analysis — accident, anomaly, and crowd detection using vision-language models" +version: 1.0.0 +category: analysis +runtime: python +entry: scripts/analyze.py +install: pip + +requirements: + python: ">=3.9" + platforms: ["linux", "macos", "windows"] + +parameters: + - name: analysis_mode + label: "Analysis Mode" + type: select + options: + - "traffic_accident" + - "crowd_anomaly" + - "suspicious_behavior" + - "wrong_way" + - "road_obstruction" + - "fire_smoke" + - "full_scan" + default: "full_scan" + description: "Focus the VLM on a specific threat type, or run all checks at once" + group: Analysis + + - name: sensitivity + label: "Sensitivity" + type: select + options: ["low", "medium", "high"] + default: "medium" + description: "low = only obvious incidents | high = flag edge cases and near-misses" + group: Analysis + + - name: fps + label: "Processing FPS" + type: select + options: [0.1, 0.2, 0.5, 1, 2] + default: 0.5 + description: "Frames per second sent to VLM — VLM is slower than YOLO, keep low" + group: Performance + + - name: min_confidence + label: "Min Confidence to Report" + type: number + min: 0.1 + max: 1.0 + default: 0.6 + description: "Suppress incidents below this confidence score" + group: Analysis + + - name: yolo_prefilter + label: "YOLO Pre-filter" + type: boolean + default: true + description: "Only send frames to VLM when YOLO detects relevant objects (saves VLM tokens)" + group: Performance + + - name: camera_location + label: "Camera Location Description" + type: string + default: "" + description: "e.g. 'Intersection of Main St and 5th Ave, downtown' — injected into VLM prompt for context" + group: Analysis + + - name: language + label: "Report Language" + type: select + options: ["english", "burmese", "both"] + default: "english" + description: "Language for incident descriptions in VLM output" + group: Analysis + +capabilities: + live_detection: + script: scripts/analyze.py + description: "Real-time VLM scene analysis for traffic and public safety" +--- + +# Traffic VLM Analysis + +Real-time traffic and public safety scene analysis using Vision-Language Models (VLMs). Runs on top of the Aegis camera frame pipeline and outputs structured incident events. + +## What It Detects + +| Mode | Examples | +|------|---------| +| `traffic_accident` | Collisions, overturned vehicles, post-crash debris | +| `crowd_anomaly` | Stampedes, unusually dense crowds, dispersal events | +| `suspicious_behavior` | Loitering, package abandonment, erratic movement | +| `wrong_way` | Vehicles driving against traffic | +| `road_obstruction` | Stopped vehicles blocking lanes, fallen objects | +| `fire_smoke` | Visible flames, smoke plumes from vehicles or structures | +| `full_scan` | All of the above in one pass | + +## Sensitivity Levels + +| Level | Behavior | +|-------|---------| +| `low` | Only clear, confirmed incidents (fewer false positives) | +| `medium` | Balanced — flags near-misses and developing situations | +| `high` | Flags edge cases, unusual patterns, and potential precursors | + +## VLM Compatibility + +Works with any OpenAI-compatible vision endpoint: +- **Local:** Qwen-VL, LLaVA, DeepSeek-VL, SmolVLM (via llama-server or Ollama) +- **Cloud:** GPT-4o Vision, Claude Sonnet (vision), Gemini Pro Vision + +## Protocol + +### Aegis → Skill (stdin) +```jsonl +{"event": "frame", "frame_id": 42, "camera_id": "cam_001", "frame_path": "/tmp/frame.jpg", "timestamp": "2026-07-01T10:30:00Z", "width": 1920, "height": 1080} +``` + +### Skill → Aegis (stdout) +```jsonl +{"event": "ready", "model": "qwen-vl", "mode": "full_scan", "sensitivity": "medium", "fps": 0.5} + +{"event": "analysis", "frame_id": 42, "camera_id": "cam_001", "timestamp": "...", + "incident_detected": true, + "incident_type": "traffic_accident", + "severity": "high", + "confidence": 0.91, + "description": "Two-vehicle collision detected at intersection. One vehicle has crossed the center line. Airbags may have deployed.", + "objects": ["car", "truck"], + "suggested_action": "Dispatch emergency services", + "skipped_reason": null} + +{"event": "analysis", "frame_id": 43, "camera_id": "cam_001", "timestamp": "...", + "incident_detected": false, "skipped_reason": "no_trigger_objects"} + +{"event": "error", "message": "VLM endpoint unreachable", "retriable": true} +``` + +### Stop Command +```jsonl +{"command": "stop"} +``` diff --git a/skills/analysis/traffic-vlm-analysis/assets/prompts/prompts.py b/skills/analysis/traffic-vlm-analysis/assets/prompts/prompts.py new file mode 100644 index 00000000..98f34228 --- /dev/null +++ b/skills/analysis/traffic-vlm-analysis/assets/prompts/prompts.py @@ -0,0 +1,151 @@ +""" +Traffic VLM prompt templates. + +Each prompt is a system message instructing the VLM to return a specific JSON schema. +The `build()` function assembles the final system + user messages for a given mode. +""" + +# --------------------------------------------------------------------------- +# Shared JSON schema instruction (appended to every system prompt) +# --------------------------------------------------------------------------- +_SCHEMA = """ +Respond ONLY with a valid JSON object. No markdown, no extra text. +Schema: +{ + "incident_detected": , + "incident_type": , + "severity": <"low"|"medium"|"high"|"critical"|null>, + "confidence": , + "description": , + "objects": , + "suggested_action": +} +If no incident is detected, set incident_detected=false and all other fields to null. +""" + +# --------------------------------------------------------------------------- +# Sensitivity preambles +# --------------------------------------------------------------------------- +_SENSITIVITY = { + "low": ( + "Report ONLY clear, confirmed incidents that are already happening. " + "Do not flag near-misses, ambiguous situations, or normal traffic congestion." + ), + "medium": ( + "Report confirmed incidents AND developing situations (near-misses, vehicles slowing abnormally, " + "crowds beginning to gather). Use your judgment on ambiguous cases." + ), + "high": ( + "Report any unusual pattern, near-miss, potential precursor to an incident, " + "or behavior that deviates from normal traffic flow. Err on the side of flagging." + ), +} + +# --------------------------------------------------------------------------- +# Mode-specific system prompts +# --------------------------------------------------------------------------- +_MODE_PROMPTS = { + "traffic_accident": """You are a traffic accident detection AI monitoring a city CCTV camera. +Analyze the frame for: +- Vehicle collisions (rear-end, side-impact, head-on) +- Overturned or heavily damaged vehicles +- Vehicles stopped in unusual positions after impact +- Post-crash debris on the road +- Pedestrians injured or struck by vehicles +- Motorcycles or bicycles involved in crashes +Ignore: normal traffic stops, parked cars, minor fender-benders with no safety impact.""", + + "crowd_anomaly": """You are a crowd safety AI monitoring a city CCTV camera. +Analyze the frame for: +- Stampedes or crowds moving in panic +- Abnormally dense crowd concentration (crush risk) +- Sudden crowd dispersal (possible fight or threat nearby) +- People falling or being trampled +- Blocked emergency access routes +- Crowd gathering around an incident +Ignore: normal pedestrian traffic, bus stops, markets with expected density.""", + + "suspicious_behavior": """You are a public safety AI monitoring a city CCTV camera. +Analyze the frame for: +- Individuals loitering in restricted or unusual areas for extended periods +- Abandoned bags, packages, or objects left unattended +- Erratic or aggressive movement between individuals +- Individuals surveilling or photographing infrastructure suspiciously +- Groups confronting or surrounding another person +- Individuals climbing fences, walls, or restricted structures +Ignore: people waiting normally, street vendors, delivery personnel.""", + + "wrong_way": """You are a wrong-way vehicle detection AI monitoring a city CCTV camera. +Analyze the frame for: +- Vehicles driving against the flow of traffic +- Vehicles entering one-way streets in the wrong direction +- Vehicles reversing on highways or main roads +- Vehicles driving on sidewalks or pedestrian areas +Use lane markings, traffic signals, and the direction of other vehicles as reference. +Ignore: emergency vehicles, reversing in parking areas.""", + + "road_obstruction": """You are a road obstruction detection AI monitoring a city CCTV camera. +Analyze the frame for: +- Vehicles stopped and blocking one or more lanes +- Fallen cargo, debris, or objects on the road +- Construction equipment or materials blocking traffic +- Broken-down vehicles in live traffic lanes +- Flooding or road damage blocking passage +- Pedestrians or animals on the road in dangerous positions +Ignore: normal traffic queues, vehicles stopped at red lights.""", + + "fire_smoke": """You are a fire and smoke detection AI monitoring a city CCTV camera. +Analyze the frame for: +- Visible flames from vehicles, buildings, or roadside objects +- Smoke plumes (black, grey, or white) from any source +- Burning debris on the road +- Smoke rising from underground (manhole fires) +- Vehicles on fire or smoldering +Report even small fires or initial smoke as they can escalate rapidly.""", + + "full_scan": """You are a comprehensive city traffic and public safety AI monitoring a CCTV camera. +In a single pass, analyze the frame for ANY of the following: +1. TRAFFIC ACCIDENTS — collisions, overturned vehicles, injured pedestrians +2. CROWD ANOMALIES — stampedes, dangerous density, panic dispersal +3. SUSPICIOUS BEHAVIOR — loitering, abandoned objects, confrontations +4. WRONG-WAY VEHICLES — driving against traffic or on restricted areas +5. ROAD OBSTRUCTIONS — blocked lanes, debris, breakdowns +6. FIRE & SMOKE — flames or smoke from any source +Report the MOST SEVERE incident if multiple are present. +Classify the incident_type accurately based on the primary threat.""", +} + + +def build( + mode: str, + sensitivity: str, + camera_location: str = "", + language: str = "english", +) -> tuple[str, str]: + """ + Build (system_prompt, user_message) for a given analysis mode. + + Returns: + system_prompt: Full system instruction with schema + user_message: Frame-specific user turn + """ + base = _MODE_PROMPTS.get(mode, _MODE_PROMPTS["full_scan"]) + sens = _SENSITIVITY.get(sensitivity, _SENSITIVITY["medium"]) + + location_ctx = f"\nCamera location: {camera_location}" if camera_location else "" + + lang_instruction = "" + if language == "burmese": + lang_instruction = "\nWrite the 'description' and 'suggested_action' fields in Burmese (Myanmar language)." + elif language == "both": + lang_instruction = "\nWrite the 'description' field as: English description first, then '|' separator, then Burmese translation." + + system_prompt = f"{base}\n\nSensitivity level: {sens}{location_ctx}{lang_instruction}\n\n{_SCHEMA}" + + user_message = "Analyze this camera frame and return the JSON response." + + return system_prompt, user_message + + +def get_available_modes() -> list[str]: + return list(_MODE_PROMPTS.keys()) diff --git a/skills/analysis/traffic-vlm-analysis/deploy.sh b/skills/analysis/traffic-vlm-analysis/deploy.sh new file mode 100755 index 00000000..8043a448 --- /dev/null +++ b/skills/analysis/traffic-vlm-analysis/deploy.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Deploy script for traffic-vlm-analysis skill. +# No special dependencies — VLM is called over HTTP. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "[traffic-vlm-analysis] Deploy check..." +python3 -c "import sys, json, base64, urllib.request, pathlib; print(' stdlib OK')" +python3 -c " +import sys +sys.path.insert(0, '$SCRIPT_DIR/assets/prompts') +from prompts import get_available_modes +print(f' prompts OK — {len(get_available_modes())} modes: {get_available_modes()}') +" +echo "[traffic-vlm-analysis] Ready." diff --git a/skills/analysis/traffic-vlm-analysis/requirements.txt b/skills/analysis/traffic-vlm-analysis/requirements.txt new file mode 100644 index 00000000..6a1b1daf --- /dev/null +++ b/skills/analysis/traffic-vlm-analysis/requirements.txt @@ -0,0 +1,2 @@ +# No extra dependencies — uses only Python stdlib (urllib, base64, json, signal) +# The VLM runs externally via HTTP (Aegis gateway or local llama-server) diff --git a/skills/analysis/traffic-vlm-analysis/scripts/analyze.py b/skills/analysis/traffic-vlm-analysis/scripts/analyze.py new file mode 100644 index 00000000..98b57055 --- /dev/null +++ b/skills/analysis/traffic-vlm-analysis/scripts/analyze.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +""" +Traffic VLM Analysis Skill — City CCTV accident and anomaly detection. + +Communicates via JSON lines over stdin/stdout (Aegis frame protocol): + stdin: {"event": "frame", "frame_id": N, "camera_id": "...", "frame_path": "...", ...} + stdout: {"event": "analysis", "frame_id": N, "incident_detected": bool, ...} + +Reads config from: + 1. --config JSON file from Aegis + 2. CLI args for standalone testing + 3. Env vars AEGIS_VLM_URL, AEGIS_VLM_MODEL, AEGIS_VLM_API_KEY + +Usage (standalone test): + python analyze.py --mode full_scan --sensitivity medium --vlm-url http://localhost:5405 + echo '{"event":"frame","frame_id":1,"camera_id":"cam1","frame_path":"/tmp/frame.jpg","timestamp":"2026-07-01T10:00:00Z"}' | python analyze.py +""" + +import sys +import os +import json +import argparse +import base64 +import signal +import time +import urllib.request +import urllib.error +from pathlib import Path + +# --------------------------------------------------------------------------- +# Import prompt templates +# --------------------------------------------------------------------------- +_skill_dir = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_skill_dir / "assets" / "prompts")) +from prompts import build as build_prompt, get_available_modes # noqa: E402 + +# --------------------------------------------------------------------------- +# YOLO trigger classes — only frames with these objects go to VLM +# (reduces VLM token cost significantly) +# --------------------------------------------------------------------------- +YOLO_TRIGGER_CLASSES = { + "traffic_accident": {"car", "truck", "bus", "motorcycle", "bicycle", "person"}, + "crowd_anomaly": {"person"}, + "suspicious_behavior": {"person", "backpack", "handbag", "suitcase"}, + "wrong_way": {"car", "truck", "bus", "motorcycle"}, + "road_obstruction": {"car", "truck", "bus", "motorcycle", "bicycle", "person", "dog", "cat"}, + "fire_smoke": {"car", "truck", "bus", "motorcycle"}, # fire has no YOLO class; any vehicle can burn + "full_scan": {"car", "truck", "bus", "motorcycle", "bicycle", "person", "backpack"}, +} + + +def emit(obj: dict): + print(json.dumps(obj, ensure_ascii=False), flush=True) + + +def log(msg: str): + print(f"[traffic-vlm] {msg}", file=sys.stderr, flush=True) + + +# --------------------------------------------------------------------------- +# VLM client +# --------------------------------------------------------------------------- + +def call_vlm( + frame_path: str, + system_prompt: str, + user_message: str, + vlm_url: str, + vlm_model: str, + api_key: str, + timeout: int = 30, +) -> dict: + """Call VLM with a base64-encoded frame. Returns parsed JSON dict.""" + with open(frame_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + + payload = { + "model": vlm_model, + "messages": [ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": [ + {"type": "text", "text": user_message}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}, + ], + }, + ], + "response_format": {"type": "json_object"}, + "max_tokens": 512, + "temperature": 0.1, + } + + endpoint = vlm_url.rstrip("/") + if not endpoint.endswith("/v1"): + endpoint += "/v1" + url = f"{endpoint}/chat/completions" + + data = json.dumps(payload).encode() + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = json.loads(resp.read()) + + content = body["choices"][0]["message"]["content"] + # Strip markdown code fences if the VLM ignores json_object format + content = content.strip() + if content.startswith("```"): + content = content.split("\n", 1)[1].rsplit("```", 1)[0].strip() + return json.loads(content) + + +# --------------------------------------------------------------------------- +# Frame throttle +# --------------------------------------------------------------------------- + +class FrameThrottle: + """Allows at most `fps` frames per second through to VLM.""" + + def __init__(self, fps: float): + self.interval = 1.0 / fps if fps > 0 else float("inf") + self._last = 0.0 + + def should_process(self) -> bool: + now = time.monotonic() + if now - self._last >= self.interval: + self._last = now + return True + return False + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def parse_args(): + p = argparse.ArgumentParser(description="Traffic VLM Analysis Skill") + p.add_argument("--config", help="JSON config file from Aegis") + p.add_argument("--mode", default="full_scan", choices=get_available_modes()) + p.add_argument("--sensitivity", default="medium", choices=["low", "medium", "high"]) + p.add_argument("--fps", type=float, default=0.5) + p.add_argument("--min-confidence", type=float, default=0.6) + p.add_argument("--yolo-prefilter", action="store_true", default=True) + p.add_argument("--no-yolo-prefilter", dest="yolo_prefilter", action="store_false") + p.add_argument("--camera-location", default="") + p.add_argument("--language", default="english", choices=["english", "burmese", "both"]) + p.add_argument("--vlm-url", default=os.environ.get("AEGIS_VLM_URL", "http://localhost:5405")) + p.add_argument("--vlm-model", default=os.environ.get("AEGIS_VLM_MODEL", "qwen-vl")) + p.add_argument("--vlm-api-key", default=os.environ.get("AEGIS_VLM_API_KEY", "none")) + p.add_argument("--vlm-timeout", type=int, default=30) + return p.parse_args() + + +def load_config(args) -> dict: + cfg = { + "mode": args.mode, + "sensitivity": args.sensitivity, + "fps": args.fps, + "min_confidence": args.min_confidence, + "yolo_prefilter": args.yolo_prefilter, + "camera_location": args.camera_location, + "language": args.language, + "vlm_url": args.vlm_url, + "vlm_model": args.vlm_model, + "vlm_api_key": args.vlm_api_key, + "vlm_timeout": args.vlm_timeout, + } + + if args.config: + try: + with open(args.config) as f: + file_cfg = json.load(f) + # Aegis injects skill params under "skill_params" or flat + params = file_cfg.get("skill_params", file_cfg) + cfg.update({k: v for k, v in params.items() if k in cfg}) + # Platform params + cfg["vlm_url"] = file_cfg.get("vlm_url", cfg["vlm_url"]) + cfg["vlm_model"] = file_cfg.get("vlm_model", cfg["vlm_model"]) + cfg["vlm_api_key"] = file_cfg.get("vlm_api_key", cfg["vlm_api_key"]) + except Exception as e: + log(f"Warning: could not load config file: {e}") + + # Env var overrides (highest priority) + if os.environ.get("AEGIS_VLM_URL"): + cfg["vlm_url"] = os.environ["AEGIS_VLM_URL"] + if os.environ.get("AEGIS_VLM_MODEL"): + cfg["vlm_model"] = os.environ["AEGIS_VLM_MODEL"] + if os.environ.get("AEGIS_VLM_API_KEY"): + cfg["vlm_api_key"] = os.environ["AEGIS_VLM_API_KEY"] + + return cfg + + +def main(): + args = parse_args() + cfg = load_config(args) + + mode = cfg["mode"] + sensitivity = cfg["sensitivity"] + fps = float(cfg["fps"]) + min_conf = float(cfg["min_confidence"]) + prefilter = bool(cfg["yolo_prefilter"]) + cam_loc = cfg["camera_location"] + language = cfg["language"] + vlm_url = cfg["vlm_url"] + vlm_model = cfg["vlm_model"] + vlm_api_key = cfg["vlm_api_key"] + vlm_timeout = int(cfg["vlm_timeout"]) + + trigger_classes = YOLO_TRIGGER_CLASSES.get(mode, set()) + system_prompt, user_message = build_prompt(mode, sensitivity, cam_loc, language) + throttle = FrameThrottle(fps) + + # Stats + frames_received = 0 + frames_analyzed = 0 + frames_skipped_throttle = 0 + frames_skipped_prefilter = 0 + incidents_detected = 0 + errors = 0 + + emit({ + "event": "ready", + "model": vlm_model, + "mode": mode, + "sensitivity": sensitivity, + "fps": fps, + "min_confidence": min_conf, + "yolo_prefilter": prefilter, + "language": language, + "camera_location": cam_loc or None, + }) + + def handle_signal(signum, _frame): + log(f"Received signal {signum}, shutting down") + emit({ + "event": "stats", + "frames_received": frames_received, + "frames_analyzed": frames_analyzed, + "frames_skipped_throttle": frames_skipped_throttle, + "frames_skipped_prefilter": frames_skipped_prefilter, + "incidents_detected": incidents_detected, + "errors": errors, + }) + sys.exit(0) + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + for line in sys.stdin: + line = line.strip() + if not line: + continue + + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + + if msg.get("command") == "stop": + break + + if msg.get("event") != "frame": + continue + + frames_received += 1 + frame_id = msg.get("frame_id") + camera_id = msg.get("camera_id", "unknown") + timestamp = msg.get("timestamp", "") + frame_path = msg.get("frame_path", "") + + # --- Throttle check --- + if not throttle.should_process(): + frames_skipped_throttle += 1 + continue + + # --- Frame existence check --- + if not frame_path or not Path(frame_path).exists(): + emit({"event": "error", "frame_id": frame_id, "message": f"Frame not found: {frame_path}", "retriable": True}) + errors += 1 + continue + + # --- YOLO pre-filter check --- + if prefilter: + yolo_objects = set(msg.get("yolo_objects", [])) # populated if YOLO skill runs first + if yolo_objects and trigger_classes and yolo_objects.isdisjoint(trigger_classes): + frames_skipped_prefilter += 1 + emit({ + "event": "analysis", + "frame_id": frame_id, + "camera_id": camera_id, + "timestamp": timestamp, + "incident_detected": False, + "skipped_reason": "no_trigger_objects", + }) + continue + + # --- VLM call --- + t0 = time.monotonic() + try: + result = call_vlm( + frame_path, system_prompt, user_message, + vlm_url, vlm_model, vlm_api_key, vlm_timeout, + ) + except urllib.error.URLError as e: + emit({"event": "error", "frame_id": frame_id, "message": f"VLM unreachable: {e}", "retriable": True}) + errors += 1 + continue + except json.JSONDecodeError as e: + emit({"event": "error", "frame_id": frame_id, "message": f"VLM returned non-JSON: {e}", "retriable": True}) + errors += 1 + continue + except Exception as e: + emit({"event": "error", "frame_id": frame_id, "message": str(e), "retriable": True}) + errors += 1 + continue + + frames_analyzed += 1 + vlm_ms = round((time.monotonic() - t0) * 1000, 1) + + # Validate and sanitise result + incident_detected = bool(result.get("incident_detected", False)) + confidence = float(result.get("confidence") or 0.0) + + # Apply confidence gate + if incident_detected and confidence < min_conf: + incident_detected = False + + if incident_detected: + incidents_detected += 1 + + emit({ + "event": "analysis", + "frame_id": frame_id, + "camera_id": camera_id, + "timestamp": timestamp, + "incident_detected": incident_detected, + "incident_type": result.get("incident_type") if incident_detected else None, + "severity": result.get("severity") if incident_detected else None, + "confidence": round(confidence, 3), + "description": result.get("description") if incident_detected else None, + "objects": result.get("objects") or [], + "suggested_action": result.get("suggested_action") if incident_detected else None, + "skipped_reason": None, + "vlm_ms": vlm_ms, + }) + + emit({ + "event": "stats", + "frames_received": frames_received, + "frames_analyzed": frames_analyzed, + "frames_skipped_throttle": frames_skipped_throttle, + "frames_skipped_prefilter": frames_skipped_prefilter, + "incidents_detected": incidents_detected, + "errors": errors, + }) + + +if __name__ == "__main__": + main() diff --git a/skills/analysis/traffic-vlm-analysis/scripts/test_prompts.py b/skills/analysis/traffic-vlm-analysis/scripts/test_prompts.py new file mode 100644 index 00000000..6e41b51d --- /dev/null +++ b/skills/analysis/traffic-vlm-analysis/scripts/test_prompts.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Standalone prompt tester — sends a real image to a VLM and prints the result. + +Usage: + python test_prompts.py --image /path/to/frame.jpg --mode traffic_accident + python test_prompts.py --image /tmp/cam.jpg --mode full_scan --sensitivity high --language burmese + python test_prompts.py --image /tmp/cam.jpg --all-modes # run every mode and compare +""" + +import sys +import json +import argparse +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "assets" / "prompts")) +from prompts import build as build_prompt, get_available_modes + + +def parse_args(): + p = argparse.ArgumentParser() + p.add_argument("--image", required=True) + p.add_argument("--mode", default="full_scan", choices=get_available_modes()) + p.add_argument("--all-modes", action="store_true") + p.add_argument("--sensitivity", default="medium", choices=["low", "medium", "high"]) + p.add_argument("--language", default="english", choices=["english", "burmese", "both"]) + p.add_argument("--camera-location", default="") + p.add_argument("--vlm-url", default="http://localhost:5405") + p.add_argument("--vlm-model", default="qwen-vl") + p.add_argument("--vlm-api-key", default="none") + p.add_argument("--timeout", type=int, default=30) + return p.parse_args() + + +def run_mode(args, mode: str): + import sys, os + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from analyze import call_vlm + + system_prompt, user_message = build_prompt( + mode, args.sensitivity, args.camera_location, args.language + ) + + print(f"\n{'='*60}") + print(f"MODE: {mode} | sensitivity: {args.sensitivity} | language: {args.language}") + if args.camera_location: + print(f"LOCATION: {args.camera_location}") + print(f"{'='*60}") + print("SYSTEM PROMPT:") + print(system_prompt[:800] + "…" if len(system_prompt) > 800 else system_prompt) + print(f"\n{'─'*60}") + + try: + result = call_vlm( + args.image, system_prompt, user_message, + args.vlm_url, args.vlm_model, args.vlm_api_key, args.timeout + ) + print("VLM RESPONSE:") + print(json.dumps(result, indent=2, ensure_ascii=False)) + except Exception as e: + print(f"ERROR: {e}") + + +def main(): + args = parse_args() + if not Path(args.image).exists(): + print(f"Image not found: {args.image}") + sys.exit(1) + + modes = get_available_modes() if args.all_modes else [args.mode] + for mode in modes: + run_mode(args, mode) + + +if __name__ == "__main__": + main() diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile new file mode 100644 index 00000000..726ece68 --- /dev/null +++ b/src/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev gcc && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app/backend + +ENV PYTHONPATH=/app + +# API server +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/src/backend/__init__.py b/src/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/api/__init__.py b/src/backend/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/api/agent.py b/src/backend/api/agent.py new file mode 100644 index 00000000..c7de4211 --- /dev/null +++ b/src/backend/api/agent.py @@ -0,0 +1,131 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel +from typing import Optional +from datetime import datetime, timedelta, timezone + +from backend.db.database import get_db +from backend.models.incident import Incident +from backend.models.camera import Camera +from backend.services.agent_service import query_agent + +router = APIRouter(prefix="/agent", tags=["agent"]) + + +class QueryRequest(BaseModel): + question: str + # Optional time window — defaults to last 24 hours + from_time: Optional[datetime] = None + to_time: Optional[datetime] = None + + +class QueryResponse(BaseModel): + question: str + answer: str + incidents_in_context: int + + +@router.post("/query", response_model=QueryResponse) +async def query(payload: QueryRequest, db: AsyncSession = Depends(get_db)): + """Natural language query over incident data.""" + now = datetime.now(timezone.utc) + from_time = payload.from_time or (now - timedelta(hours=24)) + to_time = payload.to_time or now + + # Build context from DB + incidents_result = await db.execute( + select(Incident) + .where(Incident.occurred_at >= from_time, Incident.occurred_at <= to_time) + .order_by(Incident.occurred_at.desc()) + .limit(100) + ) + incidents = incidents_result.scalars().all() + + cameras_result = await db.execute(select(Camera)) + cameras = {str(c.id): c.name for c in cameras_result.scalars().all()} + + context = { + "query_window": {"from": from_time.isoformat(), "to": to_time.isoformat()}, + "total_incidents": len(incidents), + "incidents": [ + { + "id": str(inc.id), + "camera": cameras.get(str(inc.camera_id), str(inc.camera_id)), + "type": inc.incident_type, + "severity": inc.severity, + "description": inc.description, + "occurred_at": inc.occurred_at.isoformat(), + "resolved": inc.resolved, + "confidence": inc.confidence, + } + for inc in incidents + ], + } + + answer = await query_agent(payload.question, context) + + return QueryResponse( + question=payload.question, + answer=answer, + incidents_in_context=len(incidents), + ) + + +@router.post("/report") +async def generate_report( + from_time: Optional[datetime] = None, + to_time: Optional[datetime] = None, + district: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + """Generate a structured shift/daily report using the LLM agent.""" + now = datetime.now(timezone.utc) + from_time = from_time or (now - timedelta(hours=8)) + to_time = to_time or now + + q = select(Incident).where( + Incident.occurred_at >= from_time, + Incident.occurred_at <= to_time, + ) + result = await db.execute(q) + incidents = result.scalars().all() + + cam_q = select(Camera) + if district: + cam_q = cam_q.where(Camera.district == district) + cam_result = await db.execute(cam_q) + cameras = {str(c.id): {"name": c.name, "location": c.location} for c in cam_result.scalars().all()} + + context = { + "report_period": {"from": from_time.isoformat(), "to": to_time.isoformat()}, + "district": district or "all", + "cameras": cameras, + "incidents": [ + { + "camera": cameras.get(str(inc.camera_id), {}).get("name", "unknown"), + "type": inc.incident_type, + "severity": inc.severity, + "description": inc.description, + "occurred_at": inc.occurred_at.isoformat(), + "resolved": inc.resolved, + } + for inc in incidents + if str(inc.camera_id) in cameras + ], + } + + prompt = ( + "Generate a formal shift surveillance report based on the provided incident data. " + "Include: executive summary, incident breakdown by type and severity, " + "notable events, unresolved incidents requiring attention, and recommendations." + ) + + report_text = await query_agent(prompt, context) + + return { + "period": {"from": from_time.isoformat(), "to": to_time.isoformat()}, + "district": district or "all", + "total_incidents": len(context["incidents"]), + "report": report_text, + } diff --git a/src/backend/api/analyze.py b/src/backend/api/analyze.py new file mode 100644 index 00000000..098bb4eb --- /dev/null +++ b/src/backend/api/analyze.py @@ -0,0 +1,100 @@ +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from typing import Optional +from uuid import UUID +import httpx +import tempfile +import os + +from backend.db.database import get_db +from backend.models.camera import Camera +from backend.models.incident import AnalysisJob +from backend.workers.tasks import analyze_frame_task +from backend.config import settings + +router = APIRouter(prefix="/analyze", tags=["analyze"]) + + +class AnalyzeRequest(BaseModel): + camera_id: UUID + custom_prompt: Optional[str] = None + + +class AnalyzeResponse(BaseModel): + job_id: str + status: str + message: str + + +class JobStatusResponse(BaseModel): + job_id: str + status: str + result: Optional[dict] = None + error: Optional[str] = None + + class Config: + from_attributes = True + + +@router.post("/trigger", response_model=AnalyzeResponse) +async def trigger_analysis(payload: AnalyzeRequest, db: AsyncSession = Depends(get_db)): + """Grab a snapshot from the camera and queue a VLM analysis job.""" + cam = await db.get(Camera, payload.camera_id) + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + if not cam.is_active: + raise HTTPException(status_code=400, detail="Camera is inactive") + + # Capture snapshot from go2rtc + snap_path = await _capture_snapshot(cam.name) + + # Create job record + job = AnalysisJob(camera_id=payload.camera_id, frame_path=snap_path, status="pending") + db.add(job) + await db.flush() + + # Queue Celery task + task = analyze_frame_task.delay( + str(payload.camera_id), + snap_path, + str(job.id), + ) + job.celery_task_id = task.id + + return AnalyzeResponse( + job_id=str(job.id), + status="queued", + message=f"Analysis queued for camera '{cam.name}'", + ) + + +@router.get("/jobs/{job_id}", response_model=JobStatusResponse) +async def get_job_status(job_id: UUID, db: AsyncSession = Depends(get_db)): + job = await db.get(AnalysisJob, job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return JobStatusResponse( + job_id=str(job.id), + status=job.status, + result=job.result, + error=job.error, + ) + + +async def _capture_snapshot(camera_name: str) -> str: + """Download a JPEG snapshot from go2rtc and save to a temp file.""" + name = camera_name.replace(" ", "_") + url = f"{settings.GO2RTC_URL}/snapshot?src={name}" + tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(url) + resp.raise_for_status() + tmp.write(resp.content) + tmp.flush() + return tmp.name + except Exception as exc: + tmp.close() + os.unlink(tmp.name) + raise HTTPException(status_code=502, detail=f"Failed to capture snapshot: {exc}") diff --git a/src/backend/api/cameras.py b/src/backend/api/cameras.py new file mode 100644 index 00000000..b604383a --- /dev/null +++ b/src/backend/api/cameras.py @@ -0,0 +1,128 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, delete +from pydantic import BaseModel, HttpUrl +from typing import Optional +from uuid import UUID +import httpx + +from backend.db.database import get_db +from backend.models.camera import Camera +from backend.config import settings + +router = APIRouter(prefix="/cameras", tags=["cameras"]) + + +class CameraCreate(BaseModel): + name: str + rtsp_url: str + location: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + district: Optional[str] = None + notes: Optional[str] = None + + +class CameraUpdate(BaseModel): + name: Optional[str] = None + rtsp_url: Optional[str] = None + location: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + district: Optional[str] = None + is_active: Optional[bool] = None + notes: Optional[str] = None + + +class CameraResponse(BaseModel): + id: UUID + name: str + rtsp_url: str + location: Optional[str] + latitude: Optional[float] + longitude: Optional[float] + district: Optional[str] + is_active: bool + notes: Optional[str] + + class Config: + from_attributes = True + + +@router.get("/", response_model=list[CameraResponse]) +async def list_cameras( + district: Optional[str] = None, + active_only: bool = False, + db: AsyncSession = Depends(get_db), +): + q = select(Camera) + if district: + q = q.where(Camera.district == district) + if active_only: + q = q.where(Camera.is_active == True) + result = await db.execute(q) + return result.scalars().all() + + +@router.post("/", response_model=CameraResponse, status_code=status.HTTP_201_CREATED) +async def create_camera(payload: CameraCreate, db: AsyncSession = Depends(get_db)): + cam = Camera(**payload.model_dump()) + db.add(cam) + await db.flush() + + # Register stream in go2rtc + await _register_go2rtc(cam.name, cam.rtsp_url) + + return cam + + +@router.get("/{camera_id}", response_model=CameraResponse) +async def get_camera(camera_id: UUID, db: AsyncSession = Depends(get_db)): + cam = await db.get(Camera, camera_id) + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + return cam + + +@router.patch("/{camera_id}", response_model=CameraResponse) +async def update_camera(camera_id: UUID, payload: CameraUpdate, db: AsyncSession = Depends(get_db)): + cam = await db.get(Camera, camera_id) + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + for field, value in payload.model_dump(exclude_none=True).items(): + setattr(cam, field, value) + return cam + + +@router.delete("/{camera_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_camera(camera_id: UUID, db: AsyncSession = Depends(get_db)): + cam = await db.get(Camera, camera_id) + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + await db.delete(cam) + + +@router.get("/{camera_id}/stream-url") +async def get_stream_url(camera_id: UUID, db: AsyncSession = Depends(get_db)): + """Return go2rtc HLS / WebRTC URLs for the camera.""" + cam = await db.get(Camera, camera_id) + if not cam: + raise HTTPException(status_code=404, detail="Camera not found") + name = cam.name.replace(" ", "_") + return { + "hls": f"{settings.GO2RTC_URL}/stream.m3u8?src={name}", + "webrtc": f"{settings.GO2RTC_URL}/webrtc?src={name}", + "snapshot": f"{settings.GO2RTC_URL}/snapshot?src={name}", + } + + +async def _register_go2rtc(name: str, rtsp_url: str): + """Push RTSP stream into go2rtc at runtime via its API.""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + await client.put( + f"{settings.GO2RTC_URL}/api/streams", + params={"name": name.replace(" ", "_"), "src": rtsp_url}, + ) + except Exception: + pass # go2rtc may not be running in dev; non-fatal diff --git a/src/backend/api/incidents.py b/src/backend/api/incidents.py new file mode 100644 index 00000000..d89e44a5 --- /dev/null +++ b/src/backend/api/incidents.py @@ -0,0 +1,134 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel +from typing import Optional +from uuid import UUID +from datetime import datetime + +from backend.db.database import get_db +from backend.models.incident import Incident, IncidentType, SeverityLevel + +router = APIRouter(prefix="/incidents", tags=["incidents"]) + + +class IncidentResponse(BaseModel): + id: UUID + camera_id: UUID + incident_type: IncidentType + severity: SeverityLevel + description: Optional[str] + snapshot_url: Optional[str] + clip_url: Optional[str] + confidence: Optional[float] + objects_detected: Optional[dict] + resolved: str + occurred_at: datetime + created_at: datetime + + class Config: + from_attributes = True + + +class ResolvePayload(BaseModel): + status: str # resolved / false_alarm + resolved_by: Optional[str] = None + + +class IncidentStats(BaseModel): + total: int + by_type: dict + by_severity: dict + by_camera: dict + + +@router.get("/", response_model=list[IncidentResponse]) +async def list_incidents( + camera_id: Optional[UUID] = None, + incident_type: Optional[IncidentType] = None, + severity: Optional[SeverityLevel] = None, + resolved: Optional[str] = None, + from_time: Optional[datetime] = None, + to_time: Optional[datetime] = None, + limit: int = Query(default=50, le=500), + offset: int = 0, + db: AsyncSession = Depends(get_db), +): + q = select(Incident).order_by(Incident.occurred_at.desc()) + + if camera_id: + q = q.where(Incident.camera_id == camera_id) + if incident_type: + q = q.where(Incident.incident_type == incident_type) + if severity: + q = q.where(Incident.severity == severity) + if resolved: + q = q.where(Incident.resolved == resolved) + if from_time: + q = q.where(Incident.occurred_at >= from_time) + if to_time: + q = q.where(Incident.occurred_at <= to_time) + + q = q.limit(limit).offset(offset) + result = await db.execute(q) + return result.scalars().all() + + +@router.get("/stats", response_model=IncidentStats) +async def get_stats( + from_time: Optional[datetime] = None, + to_time: Optional[datetime] = None, + db: AsyncSession = Depends(get_db), +): + q = select(Incident) + if from_time: + q = q.where(Incident.occurred_at >= from_time) + if to_time: + q = q.where(Incident.occurred_at <= to_time) + + result = await db.execute(q) + incidents = result.scalars().all() + + by_type: dict = {} + by_severity: dict = {} + by_camera: dict = {} + + for inc in incidents: + by_type[inc.incident_type] = by_type.get(inc.incident_type, 0) + 1 + by_severity[inc.severity] = by_severity.get(inc.severity, 0) + 1 + cam_key = str(inc.camera_id) + by_camera[cam_key] = by_camera.get(cam_key, 0) + 1 + + return IncidentStats( + total=len(incidents), + by_type=by_type, + by_severity=by_severity, + by_camera=by_camera, + ) + + +@router.get("/{incident_id}", response_model=IncidentResponse) +async def get_incident(incident_id: UUID, db: AsyncSession = Depends(get_db)): + inc = await db.get(Incident, incident_id) + if not inc: + raise HTTPException(status_code=404, detail="Incident not found") + return inc + + +@router.patch("/{incident_id}/resolve", response_model=IncidentResponse) +async def resolve_incident( + incident_id: UUID, + payload: ResolvePayload, + db: AsyncSession = Depends(get_db), +): + inc = await db.get(Incident, incident_id) + if not inc: + raise HTTPException(status_code=404, detail="Incident not found") + if payload.status not in ("resolved", "false_alarm"): + raise HTTPException(status_code=400, detail="status must be 'resolved' or 'false_alarm'") + + inc.resolved = payload.status + inc.resolved_by = payload.resolved_by + from datetime import timezone + inc.resolved_at = datetime.now(timezone.utc) + return inc diff --git a/src/backend/api/websocket.py b/src/backend/api/websocket.py new file mode 100644 index 00000000..66051e69 --- /dev/null +++ b/src/backend/api/websocket.py @@ -0,0 +1,31 @@ +import asyncio +import json +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +import redis.asyncio as aioredis +from backend.config import settings + +router = APIRouter(tags=["websocket"]) + +# Active WebSocket connections +_connections: set[WebSocket] = set() + + +@router.websocket("/ws/alerts") +async def alerts_websocket(websocket: WebSocket): + """Real-time incident alert stream via Redis pub/sub.""" + await websocket.accept() + _connections.add(websocket) + try: + async with aioredis.from_url(settings.REDIS_URL) as r: + pubsub = r.pubsub() + await pubsub.subscribe("namucam:alerts") + async for message in pubsub.listen(): + if message["type"] == "message": + data = message["data"] + if isinstance(data, bytes): + data = data.decode() + await websocket.send_text(data) + except WebSocketDisconnect: + pass + finally: + _connections.discard(websocket) diff --git a/src/backend/config.py b/src/backend/config.py new file mode 100644 index 00000000..cce498c3 --- /dev/null +++ b/src/backend/config.py @@ -0,0 +1,61 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # Database + DATABASE_URL: str = "postgresql+asyncpg://namucam:namucam@db:5432/namucam" + + # Redis + REDIS_URL: str = "redis://redis:6379/0" + + # MinIO + MINIO_ENDPOINT: str = "minio:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "minioadmin" + MINIO_BUCKET: str = "namucam-incidents" + MINIO_SECURE: bool = False + + # VLM (Aegis-compatible OpenAI endpoint) + VLM_BASE_URL: str = "http://localhost:5405" + VLM_MODEL: str = "qwen-vl" + VLM_API_KEY: str = "none" + + # LLM agent + LLM_BASE_URL: str = "http://localhost:5407" + LLM_MODEL: str = "qwen3" + LLM_API_KEY: str = "none" + + # go2rtc + GO2RTC_URL: str = "http://go2rtc:1984" + + # JWT + SECRET_KEY: str = "change-me-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 + + # YOLO pre-filter confidence threshold + YOLO_CONFIDENCE: float = 0.5 + + # VLM analysis traffic prompt template + VLM_SYSTEM_PROMPT: str = ( + "You are a city traffic surveillance AI. Analyze the camera frame and report: " + "1) Any traffic accidents or near-misses. " + "2) Suspicious or anomalous pedestrian behavior. " + "3) Crowd anomalies, fights, or public safety threats. " + "4) Abandoned objects, wrong-way vehicles, or road obstructions. " + "Be concise. Output JSON with keys: incident_detected (bool), incident_type, " + "severity (low/medium/high/critical), description, confidence (0-1)." + ) + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/src/backend/db/__init__.py b/src/backend/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/db/database.py b/src/backend/db/database.py new file mode 100644 index 00000000..cd27d67d --- /dev/null +++ b/src/backend/db/database.py @@ -0,0 +1,25 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from backend.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/src/backend/main.py b/src/backend/main.py new file mode 100644 index 00000000..fe8d5004 --- /dev/null +++ b/src/backend/main.py @@ -0,0 +1,39 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager + +from backend.db.database import init_db +from backend.api import cameras, incidents, analyze, agent, websocket + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI( + title="NamuCam City Surveillance API", + description="RTSP ingestion, VLM analysis, incident management, and AI agent reporting for city CCTV.", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(cameras.router, prefix="/api/v1") +app.include_router(incidents.router, prefix="/api/v1") +app.include_router(analyze.router, prefix="/api/v1") +app.include_router(agent.router, prefix="/api/v1") +app.include_router(websocket.router) + + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "namucam-backend"} diff --git a/src/backend/models/__init__.py b/src/backend/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/models/camera.py b/src/backend/models/camera.py new file mode 100644 index 00000000..abdf6773 --- /dev/null +++ b/src/backend/models/camera.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, String, Boolean, Float, DateTime, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func +import uuid +from backend.db.database import Base + + +class Camera(Base): + __tablename__ = "cameras" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String(255), nullable=False) + rtsp_url = Column(String(1024), nullable=False) + location = Column(String(255)) + latitude = Column(Float) + longitude = Column(Float) + district = Column(String(255)) + is_active = Column(Boolean, default=True) + notes = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/src/backend/models/incident.py b/src/backend/models/incident.py new file mode 100644 index 00000000..79efa911 --- /dev/null +++ b/src/backend/models/incident.py @@ -0,0 +1,59 @@ +from sqlalchemy import Column, String, Text, DateTime, Float, Integer, Enum, ForeignKey +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.sql import func +import uuid +import enum +from backend.db.database import Base + + +class SeverityLevel(str, enum.Enum): + low = "low" + medium = "medium" + high = "high" + critical = "critical" + + +class IncidentType(str, enum.Enum): + traffic_accident = "traffic_accident" + suspicious_person = "suspicious_person" + crowd_anomaly = "crowd_anomaly" + wrong_way = "wrong_way" + abandoned_object = "abandoned_object" + fight = "fight" + fire_smoke = "fire_smoke" + other = "other" + + +class Incident(Base): + __tablename__ = "incidents" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + camera_id = Column(UUID(as_uuid=True), ForeignKey("cameras.id"), nullable=False) + incident_type = Column(Enum(IncidentType), nullable=False) + severity = Column(Enum(SeverityLevel), nullable=False) + description = Column(Text) # VLM-generated description + vlm_raw_response = Column(Text) # full VLM output + snapshot_url = Column(String(1024)) # MinIO URL + clip_url = Column(String(1024)) # MinIO video clip URL + confidence = Column(Float) # detection confidence + objects_detected = Column(JSONB) # YOLO detections JSON + location_detail = Column(String(512)) + resolved = Column(String(10), default="open") # open / resolved / false_alarm + resolved_at = Column(DateTime(timezone=True)) + resolved_by = Column(String(255)) + occurred_at = Column(DateTime(timezone=True), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class AnalysisJob(Base): + __tablename__ = "analysis_jobs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + camera_id = Column(UUID(as_uuid=True), ForeignKey("cameras.id"), nullable=False) + celery_task_id = Column(String(255)) + status = Column(String(50), default="pending") # pending/running/done/failed + frame_path = Column(String(1024)) + result = Column(JSONB) + error = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + completed_at = Column(DateTime(timezone=True)) diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt new file mode 100644 index 00000000..af1b051f --- /dev/null +++ b/src/backend/requirements.txt @@ -0,0 +1,18 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0 +sqlalchemy>=2.0.0 +alembic>=1.13.0 +asyncpg>=0.29.0 +psycopg2-binary>=2.9.9 +pydantic>=2.7.0 +pydantic-settings>=2.2.0 +redis>=5.0.0 +celery>=5.4.0 +httpx>=0.27.0 +python-multipart>=0.0.9 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +minio>=7.2.0 +websockets>=12.0 +aiohttp>=3.9.0 +python-dotenv>=1.0.0 diff --git a/src/backend/services/__init__.py b/src/backend/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/services/agent_service.py b/src/backend/services/agent_service.py new file mode 100644 index 00000000..3c506b3a --- /dev/null +++ b/src/backend/services/agent_service.py @@ -0,0 +1,37 @@ +import httpx +from backend.config import settings + +AGENT_SYSTEM_PROMPT = """You are a city traffic surveillance assistant with access to a database of camera incidents. +You help operators query incidents, generate reports, and understand traffic patterns. +When answering, be concise and factual. If asked for a report, structure it clearly. +Today's context will be provided as JSON in the user message.""" + + +async def query_agent(question: str, context: dict) -> str: + """Send a natural language query + DB context to the LLM agent.""" + context_text = _format_context(context) + + payload = { + "model": settings.LLM_MODEL, + "messages": [ + {"role": "system", "content": AGENT_SYSTEM_PROMPT}, + {"role": "user", "content": f"Context:\n{context_text}\n\nQuestion: {question}"}, + ], + "max_tokens": 1024, + "temperature": 0.3, + } + + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + f"{settings.LLM_BASE_URL}/v1/chat/completions", + json=payload, + headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"}, + ) + resp.raise_for_status() + + return resp.json()["choices"][0]["message"]["content"] + + +def _format_context(context: dict) -> str: + import json + return json.dumps(context, indent=2, default=str) diff --git a/src/backend/services/storage_service.py b/src/backend/services/storage_service.py new file mode 100644 index 00000000..3f325e2e --- /dev/null +++ b/src/backend/services/storage_service.py @@ -0,0 +1,39 @@ +import io +from minio import Minio +from minio.error import S3Error +from backend.config import settings + +_client: Minio | None = None + + +def get_client() -> Minio: + global _client + if _client is None: + _client = Minio( + settings.MINIO_ENDPOINT, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + secure=settings.MINIO_SECURE, + ) + try: + if not _client.bucket_exists(settings.MINIO_BUCKET): + _client.make_bucket(settings.MINIO_BUCKET) + except S3Error: + pass + return _client + + +def upload_snapshot(incident_id: str, file_path: str) -> str: + client = get_client() + object_name = f"incidents/{incident_id}/snapshot.jpg" + client.fput_object(settings.MINIO_BUCKET, object_name, file_path) + return f"/{settings.MINIO_BUCKET}/{object_name}" + + +def get_presigned_url(object_path: str, expires_seconds: int = 3600) -> str: + from datetime import timedelta + client = get_client() + # object_path format: /bucket/object_name + parts = object_path.lstrip("/").split("/", 1) + bucket, obj = parts[0], parts[1] + return client.presigned_get_object(bucket, obj, expires=timedelta(seconds=expires_seconds)) diff --git a/src/backend/services/vlm_service.py b/src/backend/services/vlm_service.py new file mode 100644 index 00000000..f933ba59 --- /dev/null +++ b/src/backend/services/vlm_service.py @@ -0,0 +1,55 @@ +import base64 +import json +import httpx +from pathlib import Path +from backend.config import settings + + +async def analyze_frame(frame_path: str, camera_id: str, custom_prompt: str | None = None) -> dict: + """Send a camera frame to the VLM and return structured analysis.""" + image_data = _encode_image(frame_path) + + prompt = custom_prompt or settings.VLM_SYSTEM_PROMPT + + payload = { + "model": settings.VLM_MODEL, + "messages": [ + {"role": "system", "content": prompt}, + { + "role": "user", + "content": [ + {"type": "text", "text": f"Camera ID: {camera_id}. Analyze this frame."}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_data}"}}, + ], + }, + ], + "response_format": {"type": "json_object"}, + "max_tokens": 512, + } + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{settings.VLM_BASE_URL}/v1/chat/completions", + json=payload, + headers={"Authorization": f"Bearer {settings.VLM_API_KEY}"}, + ) + resp.raise_for_status() + + content = resp.json()["choices"][0]["message"]["content"] + + try: + return json.loads(content) + except json.JSONDecodeError: + # VLM returned non-JSON; wrap it + return { + "incident_detected": False, + "incident_type": "other", + "severity": "low", + "description": content, + "confidence": 0.0, + } + + +def _encode_image(frame_path: str) -> str: + with open(frame_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") diff --git a/src/backend/workers/__init__.py b/src/backend/workers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/workers/celery_app.py b/src/backend/workers/celery_app.py new file mode 100644 index 00000000..35adbcb0 --- /dev/null +++ b/src/backend/workers/celery_app.py @@ -0,0 +1,18 @@ +from celery import Celery +from backend.config import settings + +celery_app = Celery( + "namucam", + broker=settings.REDIS_URL, + backend=settings.REDIS_URL, + include=["backend.workers.tasks"], +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="UTC", + task_track_started=True, + task_acks_late=True, +) diff --git a/src/backend/workers/tasks.py b/src/backend/workers/tasks.py new file mode 100644 index 00000000..2d1053d5 --- /dev/null +++ b/src/backend/workers/tasks.py @@ -0,0 +1,74 @@ +import asyncio +import json +from datetime import datetime, timezone +from backend.workers.celery_app import celery_app +from backend.config import settings + + +@celery_app.task(bind=True, name="analyze_frame") +def analyze_frame_task(self, camera_id: str, frame_path: str, job_id: str): + """Celery task: run VLM analysis on a frame and save incident if detected.""" + return asyncio.get_event_loop().run_until_complete( + _analyze_and_store(self, camera_id, frame_path, job_id) + ) + + +async def _analyze_and_store(task, camera_id: str, frame_path: str, job_id: str): + from backend.services.vlm_service import analyze_frame + from backend.db.database import AsyncSessionLocal + from backend.models.incident import Incident, AnalysisJob, IncidentType, SeverityLevel + from sqlalchemy import update + import uuid + + async with AsyncSessionLocal() as session: + # Mark job as running + await session.execute( + update(AnalysisJob).where(AnalysisJob.id == job_id).values(status="running") + ) + await session.commit() + + try: + result = await analyze_frame(frame_path, camera_id) + + if result.get("incident_detected"): + incident = Incident( + camera_id=camera_id, + incident_type=result.get("incident_type", "other"), + severity=result.get("severity", "low"), + description=result.get("description", ""), + vlm_raw_response=json.dumps(result), + confidence=result.get("confidence", 0.0), + occurred_at=datetime.now(timezone.utc), + ) + session.add(incident) + + # Publish real-time alert via Redis pub/sub + import redis as redis_lib + r = redis_lib.from_url(settings.REDIS_URL) + r.publish("namucam:alerts", json.dumps({ + "type": "incident", + "camera_id": camera_id, + "incident_type": incident.incident_type, + "severity": incident.severity, + "description": incident.description, + "occurred_at": incident.occurred_at.isoformat(), + })) + + await session.execute( + update(AnalysisJob).where(AnalysisJob.id == job_id).values( + status="done", + result=result, + completed_at=datetime.now(timezone.utc), + ) + ) + await session.commit() + return result + + except Exception as exc: + await session.execute( + update(AnalysisJob).where(AnalysisJob.id == job_id).values( + status="failed", error=str(exc) + ) + ) + await session.commit() + raise diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile new file mode 100644 index 00000000..1eb2237f --- /dev/null +++ b/src/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +ARG NEXT_PUBLIC_API_URL=http://localhost:8000 +ARG NEXT_PUBLIC_WS_URL=ws://localhost:8000 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/src/frontend/app/agent/page.tsx b/src/frontend/app/agent/page.tsx new file mode 100644 index 00000000..692f9d36 --- /dev/null +++ b/src/frontend/app/agent/page.tsx @@ -0,0 +1,147 @@ +'use client' +import { useState, useRef, useEffect } from 'react' +import { api } from '@/lib/api' +import { Send, Bot, User, Loader2 } from 'lucide-react' +import { fmtTime } from '@/lib/utils' + +interface Message { + role: 'user' | 'assistant' + content: string + time: string + context?: number +} + +const SUGGESTIONS = [ + 'How many incidents occurred in the last 24 hours?', + 'List all critical incidents today', + 'Which camera has the most incidents?', + 'Summarize traffic accidents from this week', +] + +export default function AgentPage() { + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [loading, setLoading] = useState(false) + const bottomRef = useRef(null) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + async function send(question: string) { + if (!question.trim() || loading) return + const userMsg: Message = { role: 'user', content: question, time: new Date().toISOString() } + setMessages((prev) => [...prev, userMsg]) + setInput('') + setLoading(true) + + try { + const res = await api.agent.query(question) + setMessages((prev) => [ + ...prev, + { + role: 'assistant', + content: res.answer, + time: new Date().toISOString(), + context: res.incidents_in_context, + }, + ]) + } catch (e) { + setMessages((prev) => [ + ...prev, + { role: 'assistant', content: 'Failed to get a response. Is the backend running?', time: new Date().toISOString() }, + ]) + } finally { + setLoading(false) + } + } + + return ( +
+

AI Agent

+ + {/* Chat window */} +
+
+ {messages.length === 0 && ( +
+ +

Ask anything about incidents, cameras, or traffic patterns.

+
+ {SUGGESTIONS.map((s) => ( + + ))} +
+
+ )} + + {messages.map((msg, i) => ( +
+ {msg.role === 'assistant' && ( +
+ +
+ )} +
+
+ {msg.content} +
+ + {fmtTime(msg.time)} + {msg.context != null && ` · ${msg.context} incidents in context`} + +
+ {msg.role === 'user' && ( +
+ +
+ )} +
+ ))} + + {loading && ( +
+
+ +
+
+ + Thinking… +
+
+ )} +
+
+ + {/* Input bar */} +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && send(input)} + placeholder="Ask about incidents, cameras, or traffic…" + className="flex-1 bg-gray-800 text-white text-sm rounded-lg px-4 py-2.5 outline-none placeholder-gray-600 focus:ring-1 focus:ring-brand-500" + /> + +
+
+
+ ) +} diff --git a/src/frontend/app/cameras/page.tsx b/src/frontend/app/cameras/page.tsx new file mode 100644 index 00000000..3e13a199 --- /dev/null +++ b/src/frontend/app/cameras/page.tsx @@ -0,0 +1,14 @@ +import { api } from '@/lib/api' +import { CameraManager } from '@/components/camera/CameraManager' + +export const revalidate = 0 + +export default async function CamerasPage() { + const cameras = await api.cameras.list().catch(() => []) + return ( +
+

Cameras

+ +
+ ) +} diff --git a/src/frontend/app/dashboard/page.tsx b/src/frontend/app/dashboard/page.tsx new file mode 100644 index 00000000..8e63dfdc --- /dev/null +++ b/src/frontend/app/dashboard/page.tsx @@ -0,0 +1,57 @@ +import { api } from '@/lib/api' +import { StatCard } from '@/components/ui/StatCard' +import { SeverityChart } from '@/components/incident/SeverityChart' +import { IncidentTypeChart } from '@/components/incident/IncidentTypeChart' +import { RecentIncidents } from '@/components/incident/RecentIncidents' +import { CameraGrid } from '@/components/camera/CameraGrid' +import { AlertTriangle, Camera, Activity, ShieldAlert } from 'lucide-react' + +export const revalidate = 30 + +export default async function DashboardPage() { + const [cameras, stats, recentIncidents] = await Promise.all([ + api.cameras.list().catch(() => []), + api.incidents.stats().catch(() => ({ total: 0, by_type: {}, by_severity: {}, by_camera: {} })), + api.incidents.list({ limit: '10', resolved: 'open' }).catch(() => []), + ]) + + const critical = stats.by_severity['critical'] ?? 0 + const activeCams = cameras.filter((c) => c.is_active).length + + return ( +
+

Dashboard

+ + {/* Stat cards */} +
+ + + + +
+ + {/* Charts row */} +
+
+

Incidents by Severity

+ +
+
+

Incidents by Type

+ +
+
+ + {/* Camera grid + recent incidents */} +
+
+ +
+
+

Open Incidents

+ +
+
+
+ ) +} diff --git a/src/frontend/app/globals.css b/src/frontend/app/globals.css new file mode 100644 index 00000000..074342d5 --- /dev/null +++ b/src/frontend/app/globals.css @@ -0,0 +1,16 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-gray-950 text-gray-100 antialiased; + } +} + +@layer utilities { + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: #374151 transparent; + } +} diff --git a/src/frontend/app/incidents/page.tsx b/src/frontend/app/incidents/page.tsx new file mode 100644 index 00000000..b06c5e23 --- /dev/null +++ b/src/frontend/app/incidents/page.tsx @@ -0,0 +1,26 @@ +import { api } from '@/lib/api' +import { IncidentTable } from '@/components/incident/IncidentTable' + +export const revalidate = 0 + +export default async function IncidentsPage({ + searchParams, +}: { + searchParams: Record +}) { + const incidents = await api.incidents + .list({ + incident_type: searchParams.type, + severity: searchParams.severity, + resolved: searchParams.resolved ?? 'open', + limit: '100', + }) + .catch(() => []) + + return ( +
+

Incidents

+ +
+ ) +} diff --git a/src/frontend/app/layout.tsx b/src/frontend/app/layout.tsx new file mode 100644 index 00000000..335f6ff5 --- /dev/null +++ b/src/frontend/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next' +import './globals.css' +import { Sidebar } from '@/components/layout/Sidebar' +import { AlertBar } from '@/components/layout/AlertBar' + +export const metadata: Metadata = { + title: 'NamuCam — City Surveillance', + description: 'AI-powered city CCTV surveillance dashboard', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
+ +
{children}
+
+ + + ) +} diff --git a/src/frontend/app/page.tsx b/src/frontend/app/page.tsx new file mode 100644 index 00000000..77cff689 --- /dev/null +++ b/src/frontend/app/page.tsx @@ -0,0 +1,2 @@ +import { redirect } from 'next/navigation' +export default function Root() { redirect('/dashboard') } diff --git a/src/frontend/app/report/page.tsx b/src/frontend/app/report/page.tsx new file mode 100644 index 00000000..6e272045 --- /dev/null +++ b/src/frontend/app/report/page.tsx @@ -0,0 +1,101 @@ +'use client' +import { useState } from 'react' +import { api } from '@/lib/api' +import { FileText, Download, Loader2 } from 'lucide-react' + +export default function ReportPage() { + const [loading, setLoading] = useState(false) + const [report, setReport] = useState<{ period: { from: string; to: string }; district: string; total_incidents: number; report: string } | null>(null) + const [district, setDistrict] = useState('') + const [hours, setHours] = useState('8') + + async function generate() { + setLoading(true) + setReport(null) + try { + const to = new Date() + const from = new Date(to.getTime() - parseInt(hours) * 3600 * 1000) + const result = await api.agent.report(from.toISOString(), to.toISOString(), district || undefined) + setReport(result) + } finally { + setLoading(false) + } + } + + function download() { + if (!report) return + const blob = new Blob([report.report], { type: 'text/plain' }) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = `report-${new Date().toISOString().slice(0, 16)}.txt` + a.click() + } + + return ( +
+

Shift Report Generator

+ +
+
+
+ + +
+
+ + setDistrict(e.target.value)} + className="w-full bg-gray-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-brand-500 placeholder-gray-600" + /> +
+
+ + +
+ + {report && ( +
+
+
+

+ Report — {report.district === 'all' ? 'All Districts' : report.district} +

+

+ {new Date(report.period.from).toLocaleString()} → {new Date(report.period.to).toLocaleString()} + {' · '}{report.total_incidents} incidents +

+
+ +
+
+            {report.report}
+          
+
+ )} +
+ ) +} diff --git a/src/frontend/components/camera/CameraCell.tsx b/src/frontend/components/camera/CameraCell.tsx new file mode 100644 index 00000000..b4d99bb9 --- /dev/null +++ b/src/frontend/components/camera/CameraCell.tsx @@ -0,0 +1,65 @@ +'use client' +import { useState } from 'react' +import type { Camera } from '@/types' +import { Scan, MapPin, Loader2 } from 'lucide-react' +import { api } from '@/lib/api' + +interface Props { camera: Camera } + +export function CameraCell({ camera }: Props) { + const [analyzing, setAnalyzing] = useState(false) + const [msg, setMsg] = useState(null) + + async function triggerAnalysis() { + setAnalyzing(true) + setMsg(null) + try { + const job = await api.analyze.trigger(camera.id) + setMsg(`Job queued: ${job.job_id.slice(0, 8)}…`) + } catch { + setMsg('Failed to queue job') + } finally { + setAnalyzing(false) + } + } + + return ( +
+ {/* Stream placeholder — replace src with actual HLS URL in production */} +
+ {camera.name} (e.currentTarget.style.display = 'none')} + /> +
+
+
+
+ {camera.is_active ? 'Live' : 'Offline'} +
+ + {/* Analyze button overlay */} + +
+ +
+

{camera.name}

+ {camera.location && ( +

+ {camera.location} +

+ )} + {msg &&

{msg}

} +
+
+ ) +} diff --git a/src/frontend/components/camera/CameraGrid.tsx b/src/frontend/components/camera/CameraGrid.tsx new file mode 100644 index 00000000..92c58ee4 --- /dev/null +++ b/src/frontend/components/camera/CameraGrid.tsx @@ -0,0 +1,27 @@ +'use client' +import { useState } from 'react' +import type { Camera } from '@/types' +import { CameraCell } from './CameraCell' +import { Grid2X2, Grid3X3 } from 'lucide-react' + +export function CameraGrid({ cameras }: { cameras: Camera[] }) { + const [cols, setCols] = useState<2 | 3>(2) + + return ( +
+
+

Live Cameras

+
+ + +
+
+
+ {cameras.map((cam) => )} + {!cameras.length && ( +
No cameras configured
+ )} +
+
+ ) +} diff --git a/src/frontend/components/camera/CameraManager.tsx b/src/frontend/components/camera/CameraManager.tsx new file mode 100644 index 00000000..de92e451 --- /dev/null +++ b/src/frontend/components/camera/CameraManager.tsx @@ -0,0 +1,134 @@ +'use client' +import { useState } from 'react' +import type { Camera } from '@/types' +import { api } from '@/lib/api' +import { Plus, Trash2, ToggleLeft, ToggleRight, MapPin } from 'lucide-react' + +interface Props { initialCameras: Camera[] } + +const EMPTY: Partial = { name: '', rtsp_url: '', location: '', district: '' } + +export function CameraManager({ initialCameras }: Props) { + const [cameras, setCameras] = useState(initialCameras) + const [form, setForm] = useState>(EMPTY) + const [showForm, setShowForm] = useState(false) + const [saving, setSaving] = useState(false) + + async function addCamera() { + if (!form.name || !form.rtsp_url) return + setSaving(true) + try { + const cam = await api.cameras.create(form) + setCameras((prev) => [...prev, cam]) + setForm(EMPTY) + setShowForm(false) + } finally { + setSaving(false) + } + } + + async function toggleActive(cam: Camera) { + const updated = await api.cameras.update(cam.id, { is_active: !cam.is_active }) + setCameras((prev) => prev.map((c) => (c.id === cam.id ? updated : c))) + } + + async function deleteCamera(id: string) { + if (!confirm('Delete this camera?')) return + await api.cameras.delete(id) + setCameras((prev) => prev.filter((c) => c.id !== id)) + } + + return ( +
+ {/* Add camera button */} +
+ +
+ + {/* Add camera form */} + {showForm && ( +
+

New Camera

+
+ {[ + { key: 'name', placeholder: 'Camera name *', required: true }, + { key: 'rtsp_url', placeholder: 'rtsp://... *', required: true }, + { key: 'location', placeholder: 'Location (e.g. Main St & 5th Ave)' }, + { key: 'district', placeholder: 'District' }, + ].map(({ key, placeholder }) => ( + )[key] ?? ''} + onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))} + className="bg-gray-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-brand-500 placeholder-gray-600" + /> + ))} +
+
+ + +
+
+ )} + + {/* Camera list */} +
+ + + + + + + + + + + + + {cameras.map((cam) => ( + + + + + + + + + ))} + {!cameras.length && ( + + )} + +
NameRTSP URLLocationDistrictStatusActions
{cam.name}{cam.rtsp_url} + {cam.location ? {cam.location} : '—'} + {cam.district ?? '—'} + + {cam.is_active ? 'Active' : 'Inactive'} + + +
+ + +
+
No cameras yet. Add one above.
+
+
+ ) +} diff --git a/src/frontend/components/incident/IncidentTable.tsx b/src/frontend/components/incident/IncidentTable.tsx new file mode 100644 index 00000000..c8458b33 --- /dev/null +++ b/src/frontend/components/incident/IncidentTable.tsx @@ -0,0 +1,84 @@ +'use client' +import { useState } from 'react' +import type { Incident } from '@/types' +import { SEVERITY_COLOR, INCIDENT_LABEL, fmtDateTime } from '@/lib/utils' +import { api } from '@/lib/api' +import { CheckCircle, XCircle, Image as ImageIcon } from 'lucide-react' + +export function IncidentTable({ incidents }: { incidents: Incident[] }) { + const [rows, setRows] = useState(incidents) + + async function resolve(id: string, status: 'resolved' | 'false_alarm') { + const updated = await api.incidents.resolve(id, status) + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, resolved: updated.resolved } : r))) + } + + if (!rows.length) + return
No incidents found
+ + return ( +
+
+ + + + + + + + + + + + + + {rows.map((inc) => ( + + + + + + + + + + ))} + +
TimeTypeSeverityDescriptionConf.StatusActions
{fmtDateTime(inc.occurred_at)}{INCIDENT_LABEL[inc.incident_type]} + + {inc.severity} + + {inc.description ?? '—'} + {inc.confidence != null ? `${(inc.confidence * 100).toFixed(0)}%` : '—'} + + + {inc.resolved} + + + {inc.resolved === 'open' && ( +
+ + +
+ )} +
+
+
+ ) +} diff --git a/src/frontend/components/incident/IncidentTypeChart.tsx b/src/frontend/components/incident/IncidentTypeChart.tsx new file mode 100644 index 00000000..45ce3c2e --- /dev/null +++ b/src/frontend/components/incident/IncidentTypeChart.tsx @@ -0,0 +1,26 @@ +'use client' +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts' +import { INCIDENT_LABEL } from '@/lib/utils' +import type { IncidentType } from '@/types' + +export function IncidentTypeChart({ data }: { data: Record }) { + const entries = Object.entries(data).map(([key, value]) => ({ + name: INCIDENT_LABEL[key as IncidentType] ?? key, + value, + })).sort((a, b) => b.value - a.value) + + if (!entries.length) return

No data

+ + return ( + + + + + + + {entries.map((_, i) => )} + + + + ) +} diff --git a/src/frontend/components/incident/RecentIncidents.tsx b/src/frontend/components/incident/RecentIncidents.tsx new file mode 100644 index 00000000..8e84c26f --- /dev/null +++ b/src/frontend/components/incident/RecentIncidents.tsx @@ -0,0 +1,26 @@ +import type { Incident } from '@/types' +import { SEVERITY_COLOR, INCIDENT_LABEL, fmtDateTime } from '@/lib/utils' + +export function RecentIncidents({ incidents }: { incidents: Incident[] }) { + if (!incidents.length) + return

No open incidents

+ + return ( +
    + {incidents.map((inc) => ( +
  • +
    + + {inc.severity} + + {fmtDateTime(inc.occurred_at)} +
    +

    {INCIDENT_LABEL[inc.incident_type]}

    + {inc.description && ( +

    {inc.description}

    + )} +
  • + ))} +
+ ) +} diff --git a/src/frontend/components/incident/SeverityChart.tsx b/src/frontend/components/incident/SeverityChart.tsx new file mode 100644 index 00000000..293ba017 --- /dev/null +++ b/src/frontend/components/incident/SeverityChart.tsx @@ -0,0 +1,27 @@ +'use client' +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts' + +const COLORS: Record = { + low: '#60a5fa', + medium: '#facc15', + high: '#f97316', + critical: '#ef4444', +} + +export function SeverityChart({ data }: { data: Record }) { + const entries = Object.entries(data).map(([name, value]) => ({ name, value })) + if (!entries.length) return

No data

+ + return ( + + + `${name} ${(percent * 100).toFixed(0)}%`}> + {entries.map((entry) => ( + + ))} + + + + + ) +} diff --git a/src/frontend/components/layout/AlertBar.tsx b/src/frontend/components/layout/AlertBar.tsx new file mode 100644 index 00000000..50740f2b --- /dev/null +++ b/src/frontend/components/layout/AlertBar.tsx @@ -0,0 +1,36 @@ +'use client' +import { useAlerts } from '@/hooks/useAlerts' +import { SEVERITY_DOT, INCIDENT_LABEL, fmtTime } from '@/lib/utils' +import { Wifi, WifiOff } from 'lucide-react' + +export function AlertBar() { + const { alerts, connected } = useAlerts() + const latest = alerts[0] + + return ( +
+ {/* Connection indicator */} + + {connected ? : } + {connected ? 'Live' : 'Offline'} + + + {/* Latest alert ticker */} + {latest ? ( +
+ + + [{fmtTime(latest.occurred_at)}] {INCIDENT_LABEL[latest.incident_type]} —{' '} + {latest.description?.slice(0, 100)} + +
+ ) : ( + No alerts yet + )} + + + {alerts.length} alert{alerts.length !== 1 ? 's' : ''} + +
+ ) +} diff --git a/src/frontend/components/layout/Sidebar.tsx b/src/frontend/components/layout/Sidebar.tsx new file mode 100644 index 00000000..6b44cd86 --- /dev/null +++ b/src/frontend/components/layout/Sidebar.tsx @@ -0,0 +1,45 @@ +'use client' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { LayoutDashboard, Camera, AlertTriangle, MessageSquare, FileText } from 'lucide-react' +import { cn } from '@/lib/utils' + +const NAV = [ + { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/cameras', label: 'Cameras', icon: Camera }, + { href: '/incidents', label: 'Incidents', icon: AlertTriangle }, + { href: '/agent', label: 'AI Agent', icon: MessageSquare }, + { href: '/report', label: 'Reports', icon: FileText }, +] + +export function Sidebar() { + const path = usePathname() + return ( + + ) +} diff --git a/src/frontend/components/ui/StatCard.tsx b/src/frontend/components/ui/StatCard.tsx new file mode 100644 index 00000000..55be75de --- /dev/null +++ b/src/frontend/components/ui/StatCard.tsx @@ -0,0 +1,30 @@ +import { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +const COLOR = { + blue: 'text-blue-400 bg-blue-900/30', + red: 'text-red-400 bg-red-900/30', + green: 'text-green-400 bg-green-900/30', + yellow: 'text-yellow-400 bg-yellow-900/30', +} + +interface Props { + label: string + value: number + icon: LucideIcon + color: keyof typeof COLOR +} + +export function StatCard({ label, value, icon: Icon, color }: Props) { + return ( +
+
+ +
+
+

{value}

+

{label}

+
+
+ ) +} diff --git a/src/frontend/hooks/useAlerts.ts b/src/frontend/hooks/useAlerts.ts new file mode 100644 index 00000000..9f354609 --- /dev/null +++ b/src/frontend/hooks/useAlerts.ts @@ -0,0 +1,35 @@ +'use client' +import { useEffect, useRef, useState } from 'react' +import type { AlertMessage } from '@/types' + +const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8000' +const MAX_ALERTS = 50 + +export function useAlerts() { + const [alerts, setAlerts] = useState([]) + const [connected, setConnected] = useState(false) + const wsRef = useRef(null) + + useEffect(() => { + function connect() { + const ws = new WebSocket(`${WS_URL}/ws/alerts`) + wsRef.current = ws + + ws.onopen = () => setConnected(true) + ws.onclose = () => { + setConnected(false) + setTimeout(connect, 3000) // reconnect + } + ws.onmessage = (e) => { + try { + const msg: AlertMessage = JSON.parse(e.data) + setAlerts((prev) => [msg, ...prev].slice(0, MAX_ALERTS)) + } catch {} + } + } + connect() + return () => wsRef.current?.close() + }, []) + + return { alerts, connected } +} diff --git a/src/frontend/lib/api.ts b/src/frontend/lib/api.ts new file mode 100644 index 00000000..b2574b37 --- /dev/null +++ b/src/frontend/lib/api.ts @@ -0,0 +1,77 @@ +import type { Camera, Incident, IncidentStats } from '@/types' + +const BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000' + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}/api/v1${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...init, + }) + if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`) + return res.json() +} + +// ---------- Cameras ---------- +export const api = { + cameras: { + list: (district?: string) => + req(`/cameras/${district ? `?district=${district}` : ''}`), + get: (id: string) => req(`/cameras/${id}`), + create: (body: Partial) => + req('/cameras/', { method: 'POST', body: JSON.stringify(body) }), + update: (id: string, body: Partial) => + req(`/cameras/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), + delete: (id: string) => + fetch(`${BASE}/api/v1/cameras/${id}`, { method: 'DELETE' }), + streamUrls: (id: string) => + req<{ hls: string; webrtc: string; snapshot: string }>(`/cameras/${id}/stream-url`), + }, + + incidents: { + list: (params?: Record) => { + const qs = new URLSearchParams( + Object.fromEntries(Object.entries(params ?? {}).filter(([, v]) => v != null)) as Record + ).toString() + return req(`/incidents/${qs ? `?${qs}` : ''}`) + }, + get: (id: string) => req(`/incidents/${id}`), + stats: (params?: { from_time?: string; to_time?: string }) => { + const qs = new URLSearchParams(params as Record).toString() + return req(`/incidents/stats${qs ? `?${qs}` : ''}`) + }, + resolve: (id: string, status: 'resolved' | 'false_alarm', resolved_by?: string) => + req(`/incidents/${id}/resolve`, { + method: 'PATCH', + body: JSON.stringify({ status, resolved_by }), + }), + }, + + analyze: { + trigger: (camera_id: string) => + req<{ job_id: string; status: string; message: string }>('/analyze/trigger', { + method: 'POST', + body: JSON.stringify({ camera_id }), + }), + jobStatus: (job_id: string) => + req<{ job_id: string; status: string; result?: Record }>(`/analyze/jobs/${job_id}`), + }, + + agent: { + query: (question: string, from_time?: string, to_time?: string) => + req<{ question: string; answer: string; incidents_in_context: number }>('/agent/query', { + method: 'POST', + body: JSON.stringify({ question, from_time, to_time }), + }), + report: (from_time?: string, to_time?: string, district?: string) => { + const qs = new URLSearchParams( + Object.fromEntries( + Object.entries({ from_time, to_time, district }).filter(([, v]) => v != null) + ) as Record + ).toString() + return req<{ period: object; district: string; total_incidents: number; report: string }>( + `/agent/report${qs ? `?${qs}` : ''}`, + { method: 'POST' } + ) + }, + }, +} diff --git a/src/frontend/lib/utils.ts b/src/frontend/lib/utils.ts new file mode 100644 index 00000000..6bcb82c9 --- /dev/null +++ b/src/frontend/lib/utils.ts @@ -0,0 +1,42 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' +import type { SeverityLevel, IncidentType } from '@/types' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export const SEVERITY_COLOR: Record = { + low: 'bg-blue-100 text-blue-800', + medium: 'bg-yellow-100 text-yellow-800', + high: 'bg-orange-100 text-orange-800', + critical: 'bg-red-100 text-red-800', +} + +export const SEVERITY_DOT: Record = { + low: 'bg-blue-400', + medium: 'bg-yellow-400', + high: 'bg-orange-500', + critical: 'bg-red-600', +} + +export const INCIDENT_LABEL: Record = { + traffic_accident: 'Traffic Accident', + suspicious_person: 'Suspicious Person', + crowd_anomaly: 'Crowd Anomaly', + wrong_way: 'Wrong Way', + abandoned_object: 'Abandoned Object', + fight: 'Fight', + fire_smoke: 'Fire / Smoke', + other: 'Other', +} + +export function fmtTime(iso: string) { + return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +export function fmtDateTime(iso: string) { + return new Date(iso).toLocaleString('en-GB', { + day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', + }) +} diff --git a/src/frontend/next.config.js b/src/frontend/next.config.js new file mode 100644 index 00000000..bad9a8a0 --- /dev/null +++ b/src/frontend/next.config.js @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + env: { + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000', + NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000', + }, +} + +module.exports = nextConfig diff --git a/src/frontend/package.json b/src/frontend/package.json new file mode 100644 index 00000000..14515cdb --- /dev/null +++ b/src/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "namucam-ui", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "start": "next start -p 3000", + "lint": "next lint" + }, + "dependencies": { + "next": "14.2.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "lucide-react": "^0.417.0", + "date-fns": "^3.6.0", + "recharts": "^2.12.7", + "clsx": "^2.1.1", + "tailwind-merge": "^2.4.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.40", + "tailwindcss": "^3.4.7", + "typescript": "^5" + } +} diff --git a/src/frontend/postcss.config.js b/src/frontend/postcss.config.js new file mode 100644 index 00000000..29595670 --- /dev/null +++ b/src/frontend/postcss.config.js @@ -0,0 +1 @@ +module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } } diff --git a/src/frontend/tailwind.config.ts b/src/frontend/tailwind.config.ts new file mode 100644 index 00000000..35b62118 --- /dev/null +++ b/src/frontend/tailwind.config.ts @@ -0,0 +1,23 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + './app/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + ], + theme: { + extend: { + colors: { + brand: { + 50: '#f0f9ff', + 500: '#0ea5e9', + 600: '#0284c7', + 900: '#0c4a6e', + }, + }, + }, + }, + plugins: [], +} + +export default config diff --git a/src/frontend/tsconfig.json b/src/frontend/tsconfig.json new file mode 100644 index 00000000..6420eed1 --- /dev/null +++ b/src/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/src/frontend/types/index.ts b/src/frontend/types/index.ts new file mode 100644 index 00000000..ea4bd013 --- /dev/null +++ b/src/frontend/types/index.ts @@ -0,0 +1,54 @@ +export interface Camera { + id: string + name: string + rtsp_url: string + location: string | null + latitude: number | null + longitude: number | null + district: string | null + is_active: boolean + notes: string | null +} + +export interface Incident { + id: string + camera_id: string + incident_type: IncidentType + severity: SeverityLevel + description: string | null + snapshot_url: string | null + clip_url: string | null + confidence: number | null + objects_detected: Record | null + resolved: 'open' | 'resolved' | 'false_alarm' + occurred_at: string + created_at: string +} + +export type IncidentType = + | 'traffic_accident' + | 'suspicious_person' + | 'crowd_anomaly' + | 'wrong_way' + | 'abandoned_object' + | 'fight' + | 'fire_smoke' + | 'other' + +export type SeverityLevel = 'low' | 'medium' | 'high' | 'critical' + +export interface IncidentStats { + total: number + by_type: Record + by_severity: Record + by_camera: Record +} + +export interface AlertMessage { + type: 'incident' + camera_id: string + incident_type: IncidentType + severity: SeverityLevel + description: string + occurred_at: string +}