diff --git a/apps/backend/main.py b/apps/backend/main.py
index 103b779..2be3000 100644
--- a/apps/backend/main.py
+++ b/apps/backend/main.py
@@ -1,13 +1,12 @@
from __future__ import annotations
import logging
-import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from prometheus_client import make_asgi_app
from libs.config.settings import settings
-from apps.backend.routes import alerts, ingest, snapshot, cameras, feedback
+from apps.backend.routes import router as feature_routers
logging.basicConfig(
level = logging.INFO,
@@ -30,11 +29,7 @@ def create_app() -> FastAPI:
allow_headers = ["*"],
)
- app.include_router(ingest.router, prefix="/ingest", tags=["ingest"])
- app.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
- app.include_router(snapshot.router, prefix="/snapshot", tags=["snapshot"])
- app.include_router(cameras.router, prefix="/cameras", tags=["cameras"])
- app.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
+ app.include_router(feature_routers)
# Prometheus metrics scrape endpoint
metrics_app = make_asgi_app()
diff --git a/apps/backend/routes/__init__.py b/apps/backend/routes/__init__.py
index e69de29..9fe38d2 100644
--- a/apps/backend/routes/__init__.py
+++ b/apps/backend/routes/__init__.py
@@ -0,0 +1,32 @@
+"""
+Auto-discover and register all routers in this package.
+Each module may expose one or more APIRouter instances.
+"""
+from __future__ import annotations
+import importlib
+import pkgutil
+import logging
+
+from fastapi import APIRouter
+
+logger = logging.getLogger(__name__)
+
+_discovered_router = APIRouter()
+
+
+def _autodiscover():
+ package_dir = __path__[0]
+ for _, module_name, _ in pkgutil.iter_modules([package_dir]):
+ if module_name in ("__init__",):
+ continue
+ module = importlib.import_module(f"{__name__}.{module_name}")
+ for attr_name in dir(module):
+ obj = getattr(module, attr_name)
+ if isinstance(obj, APIRouter):
+ _discovered_router.include_router(obj)
+ logger.debug("Discovered router %s from %s", attr_name, module_name)
+
+
+_autodiscover()
+
+router = _discovered_router
diff --git a/apps/backend/routes/alerts.py b/apps/backend/routes/alerts.py
index ac35382..4855031 100644
--- a/apps/backend/routes/alerts.py
+++ b/apps/backend/routes/alerts.py
@@ -19,7 +19,7 @@
logger = logging.getLogger("eagle.alerts")
-router = APIRouter()
+router = APIRouter(prefix="/alerts", tags=["alerts"])
def _parse_alert(raw: str) -> AlertResponse | None:
try:
diff --git a/apps/backend/routes/feedback.py b/apps/backend/routes/feedback.py
index 8847cfa..6a60585 100644
--- a/apps/backend/routes/feedback.py
+++ b/apps/backend/routes/feedback.py
@@ -14,7 +14,7 @@
logger = logging.getLogger("eagle.feedback")
-router = APIRouter()
+router = APIRouter(prefix="/feedback", tags=["feedback"])
@router.post("/{alert_id}", response_model=FeedbackResponse)
def record_feedback(
diff --git a/apps/backend/routes/ingest.py b/apps/backend/routes/ingest.py
index a797e89..0c88537 100644
--- a/apps/backend/routes/ingest.py
+++ b/apps/backend/routes/ingest.py
@@ -20,7 +20,7 @@
logger = logging.getLogger("eagle.ingest")
-router = APIRouter()
+router = APIRouter(prefix="/ingest", tags=["ingest"])
def _decode_frame(b64: str | None) -> np.ndarray:
"""Decode base64 JPEG to BGR numpy array. Returns blank frame if None."""
diff --git a/apps/backend/routes/snapshot.py b/apps/backend/routes/snapshot.py
index 6c2231c..38b5fcd 100644
--- a/apps/backend/routes/snapshot.py
+++ b/apps/backend/routes/snapshot.py
@@ -5,7 +5,7 @@
logger = logging.getLogger("eagle.snapshot")
-router = APIRouter()
+router = APIRouter(prefix="/snapshot", tags=["snapshot"])
@router.get("", tags=["snapshot"])
diff --git a/apps/backend/routes/themes.py b/apps/backend/routes/themes.py
new file mode 100644
index 0000000..ddc145c
--- /dev/null
+++ b/apps/backend/routes/themes.py
@@ -0,0 +1,165 @@
+"""
+Detection Overlay Theme Management API
+
+Endpoints:
+ GET /themes - List all themes (presets + custom)
+ GET /themes/{name} - Get a specific theme
+ POST /themes - Save a custom theme
+ DELETE /themes/{name} - Delete a custom theme
+"""
+from __future__ import annotations
+import logging
+import json
+import os
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/themes", tags=["themes"])
+
+THEME_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "config", "themes")
+THEMES_FILE = os.path.join(THEME_DIR, "custom_themes.json")
+
+os.makedirs(THEME_DIR, exist_ok=True)
+
+PRESETS = {
+ "default": {
+ "name": "Default",
+ "description": "Standard surveillance overlay",
+ "boundingBoxColors": {"palette": ["#ef4444", "#3b82f6", "#22c55e", "#f59e0b", "#a855f7"], "opacity": 0.8, "borderWidth": 4},
+ "label": {"visible": True, "fontSize": 12, "fontFamily": "monospace", "backgroundColor": "rgba(0,0,0,0.6)", "textColor": "#ffffff"},
+ "confidence": {"visible": True, "mode": "percentage", "color": "#ffffff"},
+ "trackingTrails": {"enabled": True, "maxLength": 20, "trailOpacity": 0.5, "trailWidth": 2},
+ "accessibility": {"highContrast": False, "reducedMotion": False, "largeText": False},
+ },
+ "professional": {
+ "name": "Professional",
+ "description": "Clean, muted palette for long shifts",
+ "boundingBoxColors": {"palette": ["#f87171", "#60a5fa", "#4ade80", "#fbbf24", "#c084fc"], "opacity": 0.6, "borderWidth": 3},
+ "label": {"visible": True, "fontSize": 11, "fontFamily": "sans-serif", "backgroundColor": "rgba(30,30,30,0.7)", "textColor": "#e0e0e0"},
+ "confidence": {"visible": True, "mode": "percentage", "color": "#d1d5db"},
+ "trackingTrails": {"enabled": True, "maxLength": 15, "trailOpacity": 0.4, "trailWidth": 2},
+ "accessibility": {"highContrast": False, "reducedMotion": False, "largeText": False},
+ },
+ "highContrast": {
+ "name": "High Contrast",
+ "description": "Maximum visibility for accessibility",
+ "boundingBoxColors": {"palette": ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff"], "opacity": 1.0, "borderWidth": 5},
+ "label": {"visible": True, "fontSize": 14, "fontFamily": "sans-serif", "backgroundColor": "rgba(0,0,0,0.9)", "textColor": "#ffffff", "fontWeight": "bold"},
+ "confidence": {"visible": True, "mode": "bar", "color": "#00ff00"},
+ "trackingTrails": {"enabled": True, "maxLength": 25, "trailOpacity": 0.8, "trailWidth": 3},
+ "accessibility": {"highContrast": True, "reducedMotion": False, "largeText": True},
+ },
+ "minimal": {
+ "name": "Minimal",
+ "description": "Subtle overlay with reduced visual clutter",
+ "boundingBoxColors": {"palette": ["#dc2626", "#2563eb", "#16a34a", "#d97706", "#9333ea"], "opacity": 0.4, "borderWidth": 2},
+ "label": {"visible": True, "fontSize": 10, "fontFamily": "monospace", "backgroundColor": "transparent", "textColor": "#ffffff"},
+ "confidence": {"visible": False, "mode": "off", "color": "#ffffff"},
+ "trackingTrails": {"enabled": False, "maxLength": 10, "trailOpacity": 0.3, "trailWidth": 1},
+ "accessibility": {"highContrast": False, "reducedMotion": True, "largeText": False},
+ },
+ "nightMode": {
+ "name": "Night Mode",
+ "description": "Dimmed palette for dark environments",
+ "boundingBoxColors": {"palette": ["#ff6b6b", "#74b9ff", "#55efc4", "#ffeaa7", "#a29bfe"], "opacity": 0.5, "borderWidth": 3},
+ "label": {"visible": True, "fontSize": 12, "fontFamily": "monospace", "backgroundColor": "rgba(10,10,20,0.8)", "textColor": "#a0a0b0"},
+ "confidence": {"visible": True, "mode": "percentage", "color": "#a0a0b0"},
+ "trackingTrails": {"enabled": True, "maxLength": 15, "trailOpacity": 0.3, "trailWidth": 2},
+ "accessibility": {"highContrast": False, "reducedMotion": False, "largeText": False},
+ },
+ "colorBlind": {
+ "name": "Color Blind Friendly",
+ "description": "Pattern-based cues for color vision deficiency",
+ "boundingBoxColors": {"palette": ["#e69f00", "#56b4e9", "#009e73", "#f0e442", "#0072b2"], "opacity": 0.85, "borderWidth": 4},
+ "label": {"visible": True, "fontSize": 13, "fontFamily": "sans-serif", "backgroundColor": "rgba(0,0,0,0.7)", "textColor": "#ffffff"},
+ "confidence": {"visible": True, "mode": "bar", "color": "#56b4e9"},
+ "trackingTrails": {"enabled": True, "maxLength": 20, "trailOpacity": 0.6, "trailWidth": 3},
+ "accessibility": {"highContrast": True, "reducedMotion": False, "largeText": True},
+ },
+}
+
+
+class ThemeRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=100)
+ boundingBoxColors: dict
+ label: dict
+ confidence: dict
+ trackingTrails: dict
+ accessibility: dict
+
+
+class ThemeResponse(BaseModel):
+ name: str
+ boundingBoxColors: dict
+ label: dict
+ confidence: dict
+ trackingTrails: dict
+ accessibility: dict
+ isCustom: bool = False
+
+
+def _load_custom_themes() -> dict:
+ if not os.path.exists(THEMES_FILE):
+ return {}
+ try:
+ with open(THEMES_FILE, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ return {}
+
+
+def _save_custom_themes(themes: dict):
+ os.makedirs(os.path.dirname(THEMES_FILE), exist_ok=True)
+ with open(THEMES_FILE, "w", encoding="utf-8") as f:
+ json.dump(themes, f, indent=2)
+
+
+@router.get("", response_model=dict[str, ThemeResponse])
+def list_themes():
+ customs = _load_custom_themes()
+ result = {}
+ for k, v in PRESETS.items():
+ result[k] = ThemeResponse(name=v.get("name", k), boundingBoxColors=v["boundingBoxColors"], label=v["label"], confidence=v["confidence"], trackingTrails=v["trackingTrails"], accessibility=v["accessibility"], isCustom=False)
+ for k, v in customs.items():
+ result[k] = ThemeResponse(name=v.get("name", k), boundingBoxColors=v["boundingBoxColors"], label=v["label"], confidence=v["confidence"], trackingTrails=v["trackingTrails"], accessibility=v["accessibility"], isCustom=True)
+ return result
+
+
+@router.get("/{theme_name}", response_model=ThemeResponse)
+def get_theme(theme_name: str):
+ all_themes = {**PRESETS, **_load_custom_themes()}
+ if theme_name not in all_themes:
+ raise HTTPException(status_code=404, detail=f"Theme {theme_name!r} not found")
+ t = all_themes[theme_name]
+ is_custom = theme_name in _load_custom_themes()
+ return ThemeResponse(name=t.get("name", theme_name), boundingBoxColors=t["boundingBoxColors"], label=t["label"], confidence=t["confidence"], trackingTrails=t["trackingTrails"], accessibility=t["accessibility"], isCustom=is_custom)
+
+
+@router.post("", response_model=ThemeResponse, status_code=201)
+def save_theme(body: ThemeRequest):
+ customs = _load_custom_themes()
+ customs[body.name] = {
+ "name": body.name,
+ "boundingBoxColors": body.boundingBoxColors,
+ "label": body.label,
+ "confidence": body.confidence,
+ "trackingTrails": body.trackingTrails,
+ "accessibility": body.accessibility,
+ }
+ _save_custom_themes(customs)
+ logger.info("Custom theme saved name=%s", body.name)
+ return ThemeResponse(name=body.name, boundingBoxColors=body.boundingBoxColors, label=body.label, confidence=body.confidence, trackingTrails=body.trackingTrails, accessibility=body.accessibility, isCustom=True)
+
+
+@router.delete("/{theme_name}")
+def delete_theme(theme_name: str):
+ customs = _load_custom_themes()
+ if theme_name not in customs:
+ raise HTTPException(status_code=404, detail=f"Custom theme {theme_name!r} not found")
+ del customs[theme_name]
+ _save_custom_themes(customs)
+ logger.info("Custom theme deleted name=%s", theme_name)
+ return {"deleted": theme_name}
diff --git a/apps/dashboard/src/components/ThemeCustomizer.jsx b/apps/dashboard/src/components/ThemeCustomizer.jsx
new file mode 100644
index 0000000..4c480c1
--- /dev/null
+++ b/apps/dashboard/src/components/ThemeCustomizer.jsx
@@ -0,0 +1,169 @@
+import { useState } from "react";
+import { createCustomTheme, saveCustomTheme, getCustomThemes, themePresets } from "../utils/themes";
+
+export default function ThemeCustomizer({ onThemeCreated }) {
+ const [name, setName] = useState("");
+ const [baseTheme, setBaseTheme] = useState("default");
+ const [opacity, setOpacity] = useState(0.8);
+ const [borderWidth, setBorderWidth] = useState(4);
+ const [palette, setPalette] = useState(["#ef4444", "#3b82f6", "#22c55e", "#f59e0b", "#a855f7"]);
+ const [fontSize, setFontSize] = useState(12);
+ const [trailOpacity, setTrailOpacity] = useState(0.5);
+ const [trailWidth, setTrailWidth] = useState(2);
+ const [highContrast, setHighContrast] = useState(false);
+ const [reducedMotion, setReducedMotion] = useState(false);
+ const [largeText, setLargeText] = useState(false);
+
+ function updatePalette(index, color) {
+ setPalette((prev) => {
+ const next = [...prev];
+ next[index] = color;
+ return next;
+ });
+ }
+
+ function handleSave() {
+ if (!name.trim()) return;
+ const base = themePresets[baseTheme] || themePresets.default;
+ const custom = createCustomTheme(name.trim(), {
+ boundingBoxColors: {
+ palette,
+ opacity,
+ borderWidth,
+ },
+ label: {
+ ...base.label,
+ fontSize,
+ },
+ confidence: base.confidence,
+ trackingTrails: {
+ ...base.trackingTrails,
+ trailOpacity,
+ trailWidth,
+ },
+ accessibility: {
+ highContrast,
+ reducedMotion,
+ largeText,
+ },
+ });
+ saveCustomTheme(custom);
+ if (onThemeCreated) onThemeCreated(name.trim());
+ setName("");
+ }
+
+ const customThemes = getCustomThemes();
+
+ return (
+
+
Customize Theme
+
+
+
+ {Object.keys(customThemes).length > 0 && (
+
+
Saved Custom Themes
+
+ {Object.entries(customThemes).map(([key, theme]) => (
+
+
{theme.name}
+
+ {theme.boundingBoxColors.palette.slice(0, 3).map((color, i) => (
+
+ ))}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/components/ThemeSelector.jsx b/apps/dashboard/src/components/ThemeSelector.jsx
new file mode 100644
index 0000000..659e100
--- /dev/null
+++ b/apps/dashboard/src/components/ThemeSelector.jsx
@@ -0,0 +1,48 @@
+import { useState, useEffect } from 'react';
+import { themePresets, THEME_STORAGE_KEY, getActiveTheme, setActiveTheme, getCustomThemes } from '../utils/themes';
+
+export default function ThemeSelector({ onThemeChange }) {
+ const [activeTheme, setActive] = useState(getActiveTheme());
+ const [customThemes, setCustomThemes] = useState(getCustomThemes());
+ const allThemes = { ...themePresets, ...customThemes };
+
+ useEffect(() => {
+ const saved = localStorage.getItem(THEME_STORAGE_KEY);
+ if (saved && allThemes[saved]) setActive(saved);
+ }, [customThemes]);
+
+ function handleSelect(themeName) {
+ setActiveTheme(themeName);
+ setActive(themeName);
+ if (onThemeChange) onThemeChange(themeName);
+ }
+
+ return (
+
+
Detection Overlay Theme
+
+ {Object.entries(allThemes).map(([key, theme]) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/dashboard/src/features/themes.jsx b/apps/dashboard/src/features/themes.jsx
new file mode 100644
index 0000000..a5f84c0
--- /dev/null
+++ b/apps/dashboard/src/features/themes.jsx
@@ -0,0 +1,6 @@
+import ThemeSelector from "../components/ThemeSelector"
+import ThemeCustomizer from "../components/ThemeCustomizer"
+
+export { ThemeSelector, ThemeCustomizer }
+export default ThemeSelector
+export const label = "Overlay Themes"
diff --git a/apps/dashboard/src/pages/Dashboard.jsx b/apps/dashboard/src/pages/Dashboard.jsx
index 3c0a3d2..3dd193b 100644
--- a/apps/dashboard/src/pages/Dashboard.jsx
+++ b/apps/dashboard/src/pages/Dashboard.jsx
@@ -1,81 +1,113 @@
-import { useState } from "react";
+import { useState, useEffect, useMemo } from "react";
import CameraCard from "../components/CameraCard"
-export default function Dashboard() {
+const CAMERA_VIEW = "__eagle_cameras__";
+export default function Dashboard() {
const [selectedTrack, setSelectedTrack] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
- const cameras = [
- { id: 1, title: "Camera 1", trackId: "P-101" },
- { id: 2, title: "Camera 2", trackId: "P-102" },
- { id: 3, title: "Camera 3", trackId: "P-101" },
- { id: 4, title: "Camera 4", trackId: "P-103" },
-];
- return (
-
-
+ const [view, setView] = useState(CAMERA_VIEW);
-
setSearchQuery(e.target.value)}
- className="w-full mb-4 px-4 py-2 rounded bg-zinc-900 text-white"
- />
+ const featureModules = useMemo(() => {
+ try {
+ return import.meta.glob("./features/*.jsx", { eager: true });
+ } catch {
+ return {};
+ }
+ }, []);
-
+ const featureTabs = useMemo(() => {
+ const tabs = [{ id: CAMERA_VIEW, label: "Cameras", component: null }];
+ Object.entries(featureModules).forEach(([path, mod]) => {
+ const name = path.replace(/^\.\/features\//, "").replace(/\.jsx$/, "");
+ const comp = mod.default || mod;
+ if (comp && typeof comp === "function") {
+ tabs.push({ id: name, label: mod.label || name, component: comp });
+ }
+ });
+ return tabs;
+ }, [featureModules]);
- {cameras
- .filter((cam) =>
- cam.trackId.toLowerCase().includes(searchQuery.toLowerCase())
- )
- .map((cam) => (
-
setSelectedTrack(cam)}
- className={`cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-2xl ${
- selectedTrack?.id === cam.id
- ? "border-2 border-green-500 scale-105 rounded-lg shadow-green-500/40 shadow-2xl"
- : ""
- }`}
- >
-
-
-))
-}
-
-
-
+ const activeComponent = useMemo(() => {
+ const tab = featureTabs.find((t) => t.id === view);
+ return tab?.component || null;
+ }, [view, featureTabs]);
- {selectedTrack !== null ? (
- <>
-
- Identity Panel
-
+ const cameras = [
+ { id: 1, title: "Camera 1", trackId: "P-101" },
+ { id: 2, title: "Camera 2", trackId: "P-102" },
+ { id: 3, title: "Camera 3", trackId: "P-101" },
+ { id: 4, title: "Camera 4", trackId: "P-103" },
+ ];
-
- Camera:{" "}
- {selectedTrack.title}
-
+ return (
+
+
+
+ {featureTabs.map((tab) => (
+
+ ))}
+
-
- Track ID:{" "}
- {selectedTrack.trackId}
-
+ {view === CAMERA_VIEW && (
+ <>
+
setSearchQuery(e.target.value)}
+ className="w-full mb-4 px-4 py-2 rounded bg-zinc-900 text-white"
+ />
+
+ {cameras
+ .filter((cam) =>
+ cam.trackId.toLowerCase().includes(searchQuery.toLowerCase())
+ )
+ .map((cam) => (
+
setSelectedTrack(cam)}
+ className={`cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-2xl ${
+ selectedTrack?.id === cam.id
+ ? "border-2 border-green-500 scale-105 rounded-lg shadow-green-500/40 shadow-2xl"
+ : ""
+ }`}
+ >
+
+
+ ))}
+
+ >
+ )}
-
- ACTIVE TRACK
-
- >
- ) : (
-
Select a camera track
- )}
+ {activeComponent &&
}
+
-
-
- )
-}
\ No newline at end of file
+
+ {selectedTrack !== null && view === CAMERA_VIEW ? (
+ <>
+
Identity Panel
+
+ Camera: {selectedTrack.title}
+
+
+ Track ID: {selectedTrack.trackId}
+
+
ACTIVE TRACK
+ >
+ ) : (
+
Select a camera track
+ )}
+
+
+ );
+}
diff --git a/apps/dashboard/src/utils/themes.js b/apps/dashboard/src/utils/themes.js
new file mode 100644
index 0000000..da3065e
--- /dev/null
+++ b/apps/dashboard/src/utils/themes.js
@@ -0,0 +1,95 @@
+export const THEME_STORAGE_KEY = 'eagle_overlay_theme';
+
+export const themePresets = {
+ default: {
+ name: 'Default',
+ description: 'Standard surveillance overlay',
+ boundingBoxColors: { palette: ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#a855f7'], opacity: 0.8, borderWidth: 4 },
+ label: { visible: true, fontSize: 12, fontFamily: 'monospace', backgroundColor: 'rgba(0,0,0,0.6)', textColor: '#ffffff' },
+ confidence: { visible: true, mode: 'percentage', color: '#ffffff' },
+ trackingTrails: { enabled: true, maxLength: 20, trailOpacity: 0.5, trailWidth: 2 },
+ accessibility: { highContrast: false, reducedMotion: false, largeText: false }
+ },
+ professional: {
+ name: 'Professional',
+ description: 'Clean, muted palette for long shifts',
+ boundingBoxColors: { palette: ['#f87171', '#60a5fa', '#4ade80', '#fbbf24', '#c084fc'], opacity: 0.6, borderWidth: 3 },
+ label: { visible: true, fontSize: 11, fontFamily: 'sans-serif', backgroundColor: 'rgba(30,30,30,0.7)', textColor: '#e0e0e0' },
+ confidence: { visible: true, mode: 'percentage', color: '#d1d5db' },
+ trackingTrails: { enabled: true, maxLength: 15, trailOpacity: 0.4, trailWidth: 2 },
+ accessibility: { highContrast: false, reducedMotion: false, largeText: false }
+ },
+ highContrast: {
+ name: 'High Contrast',
+ description: 'Maximum visibility for accessibility',
+ boundingBoxColors: { palette: ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff'], opacity: 1.0, borderWidth: 5 },
+ label: { visible: true, fontSize: 14, fontFamily: 'sans-serif', backgroundColor: 'rgba(0,0,0,0.9)', textColor: '#ffffff', fontWeight: 'bold' },
+ confidence: { visible: true, mode: 'bar', color: '#00ff00' },
+ trackingTrails: { enabled: true, maxLength: 25, trailOpacity: 0.8, trailWidth: 3 },
+ accessibility: { highContrast: true, reducedMotion: false, largeText: true }
+ },
+ minimal: {
+ name: 'Minimal',
+ description: 'Subtle overlay with reduced visual clutter',
+ boundingBoxColors: { palette: ['#dc2626', '#2563eb', '#16a34a', '#d97706', '#9333ea'], opacity: 0.4, borderWidth: 2 },
+ label: { visible: true, fontSize: 10, fontFamily: 'monospace', backgroundColor: 'transparent', textColor: '#ffffff' },
+ confidence: { visible: false, mode: 'off', color: '#ffffff' },
+ trackingTrails: { enabled: false, maxLength: 10, trailOpacity: 0.3, trailWidth: 1 },
+ accessibility: { highContrast: false, reducedMotion: true, largeText: false }
+ },
+ nightMode: {
+ name: 'Night Mode',
+ description: 'Dimmed palette for dark environments',
+ boundingBoxColors: { palette: ['#ff6b6b', '#74b9ff', '#55efc4', '#ffeaa7', '#a29bfe'], opacity: 0.5, borderWidth: 3 },
+ label: { visible: true, fontSize: 12, fontFamily: 'monospace', backgroundColor: 'rgba(10,10,20,0.8)', textColor: '#a0a0b0' },
+ confidence: { visible: true, mode: 'percentage', color: '#a0a0b0' },
+ trackingTrails: { enabled: true, maxLength: 15, trailOpacity: 0.3, trailWidth: 2 },
+ accessibility: { highContrast: false, reducedMotion: false, largeText: false }
+ },
+ colorBlind: {
+ name: 'Color Blind Friendly',
+ description: 'Pattern-based cues for color vision deficiency',
+ boundingBoxColors: { palette: ['#e69f00', '#56b4e9', '#009e73', '#f0e442', '#0072b2'], opacity: 0.85, borderWidth: 4 },
+ label: { visible: true, fontSize: 13, fontFamily: 'sans-serif', backgroundColor: 'rgba(0,0,0,0.7)', textColor: '#ffffff' },
+ confidence: { visible: true, mode: 'bar', color: '#56b4e9' },
+ trackingTrails: { enabled: true, maxLength: 20, trailOpacity: 0.6, trailWidth: 3 },
+ accessibility: { highContrast: true, reducedMotion: false, largeText: true }
+ }
+};
+
+export function getActiveTheme() {
+ try { const saved = localStorage.getItem(THEME_STORAGE_KEY); if (saved && themePresets[saved]) return saved; } catch (e) {}
+ return 'default';
+}
+
+export function setActiveTheme(themeName) {
+ try { localStorage.setItem(THEME_STORAGE_KEY, themeName); } catch (e) {}
+}
+
+export function getThemeForTrack(trackIndex, themeName) {
+ const name = themeName || getActiveTheme();
+ const theme = themePresets[name] || themePresets.default;
+ const palette = theme.boundingBoxColors.palette;
+ return { borderColor: palette[trackIndex % palette.length], ...theme };
+}
+
+export function createCustomTheme(name, overrides = {}) {
+ const base = { ...themePresets.default, ...overrides };
+ return { name, ...base };
+}
+
+export function saveCustomTheme(theme) {
+ try {
+ const stored = JSON.parse(localStorage.getItem('eagle_custom_themes') || '{}');
+ stored[theme.name] = theme;
+ localStorage.setItem('eagle_custom_themes', JSON.stringify(stored));
+ } catch (e) {}
+}
+
+export function getCustomThemes() {
+ try {
+ return JSON.parse(localStorage.getItem('eagle_custom_themes') || '{}');
+ } catch (e) {
+ return {};
+ }
+}
diff --git a/tests/integration/test_themes.py b/tests/integration/test_themes.py
new file mode 100644
index 0000000..390e3dd
--- /dev/null
+++ b/tests/integration/test_themes.py
@@ -0,0 +1,93 @@
+"""
+tests/integration/test_themes.py
+
+Integration tests for the detection overlay theme management API.
+"""
+from __future__ import annotations
+
+import os
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+import apps.backend.main as backend
+from apps.backend.routes import themes
+
+
+@pytest.fixture()
+def app():
+ original = themes.PRESETS.copy()
+ themes.THEMES_FILE = os.path.join(os.path.dirname(__file__), "..", "..", "config", "themes", "test_custom_themes.json")
+ if os.path.exists(themes.THEMES_FILE):
+ os.remove(themes.THEMES_FILE)
+ yield backend.app
+ if os.path.exists(themes.THEMES_FILE):
+ os.remove(themes.THEMES_FILE)
+ themes.PRESETS = original
+
+
+@pytest.mark.asyncio
+async def test_list_themes(app):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
+ response = await client.get("/themes")
+ assert response.status_code == 200
+ body = response.json()
+ assert "default" in body
+ assert "highContrast" in body
+ assert body["default"]["isCustom"] is False
+
+
+@pytest.mark.asyncio
+async def test_get_preset_theme(app):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
+ response = await client.get("/themes/default")
+ assert response.status_code == 200
+ body = response.json()
+ assert body["name"] == "Default"
+ assert body["isCustom"] is False
+
+
+@pytest.mark.asyncio
+async def test_get_theme_not_found(app):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
+ response = await client.get("/themes/nonexistent")
+ assert response.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_save_custom_theme(app):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
+ response = await client.post(
+ "/themes",
+ json={
+ "name": "MyTheme",
+ "boundingBoxColors": {"palette": ["#ff0000"], "opacity": 1.0, "borderWidth": 4},
+ "label": {"visible": True, "fontSize": 12, "fontFamily": "monospace", "backgroundColor": "rgba(0,0,0,0.6)", "textColor": "#ffffff"},
+ "confidence": {"visible": True, "mode": "percentage", "color": "#ffffff"},
+ "trackingTrails": {"enabled": True, "maxLength": 20, "trailOpacity": 0.5, "trailWidth": 2},
+ "accessibility": {"highContrast": False, "reducedMotion": False, "largeText": False},
+ },
+ )
+ assert response.status_code == 201
+ body = response.json()
+ assert body["name"] == "MyTheme"
+ assert body["isCustom"] is True
+
+
+@pytest.mark.asyncio
+async def test_delete_custom_theme(app):
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
+ await client.post(
+ "/themes",
+ json={
+ "name": "ToDelete",
+ "boundingBoxColors": {"palette": ["#ff0000"], "opacity": 1.0, "borderWidth": 4},
+ "label": {"visible": True, "fontSize": 12, "fontFamily": "monospace", "backgroundColor": "rgba(0,0,0,0.6)", "textColor": "#ffffff"},
+ "confidence": {"visible": True, "mode": "percentage", "color": "#ffffff"},
+ "trackingTrails": {"enabled": True, "maxLength": 20, "trailOpacity": 0.5, "trailWidth": 2},
+ "accessibility": {"highContrast": False, "reducedMotion": False, "largeText": False},
+ },
+ )
+ response = await client.delete("/themes/ToDelete")
+ assert response.status_code == 200
+ assert response.json()["deleted"] == "ToDelete"