From c391bff40caed84fb16ab374cac412d640afdf4c Mon Sep 17 00:00:00 2001 From: TomHacker69 Date: Mon, 20 Jul 2026 23:21:23 +0530 Subject: [PATCH 1/2] refactor: auto-discover backend routes and dynamic frontend feature tabs - Backend: auto-discover routers from apps/backend/routes/ - Frontend: Dashboard.jsx dynamically loads feature components from features/ - Existing routes updated with prefixes for auto-discovery --- apps/backend/main.py | 9 +- apps/backend/routes/__init__.py | 32 +++++ apps/backend/routes/alerts.py | 2 +- apps/backend/routes/feedback.py | 2 +- apps/backend/routes/ingest.py | 2 +- apps/backend/routes/snapshot.py | 2 +- apps/dashboard/src/pages/Dashboard.jsx | 168 +++++++++++++++---------- 7 files changed, 138 insertions(+), 79 deletions(-) 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/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

+ )} +
+
+ ); +} From 57bd0ebf02b6200fa0eabce8c4df683cf3d83f68 Mon Sep 17 00:00:00 2001 From: TomHacker69 Date: Mon, 20 Jul 2026 23:30:15 +0530 Subject: [PATCH 2/2] feat: add custom detection overlay themes (#198) - Backend: theme CRUD API with preset and custom theme support - Frontend: ThemeSelector, ThemeCustomizer, and theme utilities - Feature tab registered in Dashboard via dynamic feature discovery - Added integration tests for theme API --- apps/backend/routes/themes.py | 165 +++++++++++++++++ .../src/components/ThemeCustomizer.jsx | 169 ++++++++++++++++++ .../src/components/ThemeSelector.jsx | 48 +++++ apps/dashboard/src/features/themes.jsx | 6 + apps/dashboard/src/utils/themes.js | 95 ++++++++++ tests/integration/test_themes.py | 93 ++++++++++ 6 files changed, 576 insertions(+) create mode 100644 apps/backend/routes/themes.py create mode 100644 apps/dashboard/src/components/ThemeCustomizer.jsx create mode 100644 apps/dashboard/src/components/ThemeSelector.jsx create mode 100644 apps/dashboard/src/features/themes.jsx create mode 100644 apps/dashboard/src/utils/themes.js create mode 100644 tests/integration/test_themes.py 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

+ +
+
+ + setName(e.target.value)} + placeholder="My Custom Theme" + className="w-full px-3 py-2 rounded bg-zinc-800 text-white border border-zinc-700 text-sm" + /> +
+ +
+ + +
+ +
+ + setOpacity(parseFloat(e.target.value))} className="w-full" /> +
+ +
+ + setBorderWidth(parseInt(e.target.value, 10))} className="w-full" /> +
+ +
+ + setFontSize(parseInt(e.target.value, 10))} className="w-full" /> +
+ +
+ + setTrailOpacity(parseFloat(e.target.value))} className="w-full" /> +
+ +
+ + setTrailWidth(parseInt(e.target.value, 10))} className="w-full" /> +
+ +
+ +
+ {palette.map((color, i) => ( + updatePalette(i, e.target.value)} + className="w-10 h-10 rounded cursor-pointer border-0" + /> + ))} +
+
+ +
+ setHighContrast(e.target.checked)} /> + +
+ +
+ setReducedMotion(e.target.checked)} /> + +
+ +
+ setLargeText(e.target.checked)} /> + +
+ + +
+ + {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/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"