diff --git a/.gitignore b/.gitignore
index a8ca3f4..e09b405 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,8 @@ data/clips_live/*
data/cache/*
docs/
+media/
+report_metrics/
final_report.md
final_report.pdf
tests/notes.txt
diff --git a/backend/analytics/report_generator.py b/backend/analytics/report_generator.py
index 93f83d6..e084a42 100644
--- a/backend/analytics/report_generator.py
+++ b/backend/analytics/report_generator.py
@@ -1,5 +1,6 @@
import io
import os
+import time
from datetime import datetime
import cv2
@@ -23,6 +24,7 @@
from backend.analytics.heatmap_generator import generate_heatmap_from_db
from backend.analytics import stats_engine
from utils import config
+from utils.runtime_metrics import record_runtime_metric
def _safe_int(value, default=0):
@@ -166,6 +168,7 @@ def _build_heatmap_image(camera_id, date_from=None, date_to=None, rule_name=None
def generate_report(
filepath, date_from=None, date_to=None, camera_id=None, rule_name=None, min_alarm_level=None, time_basis=None, gender=None
):
+ started_at = time.perf_counter()
doc = SimpleDocTemplate(filepath, pagesize=A4, topMargin=30, bottomMargin=30)
styles = getSampleStyleSheet()
title_style = ParagraphStyle("ReportTitle", parent=styles["Title"], fontSize=24, textColor=colors.HexColor("#1a73e8"), spaceAfter=20)
@@ -416,4 +419,9 @@ def generate_report(
elements.append(Paragraph("
".join(notes), styles["Normal"]))
doc.build(elements)
+ record_runtime_metric(
+ "report_export_time",
+ (time.perf_counter() - started_at) * 1000.0,
+ context={"path": str(filepath)},
+ )
return filepath
diff --git a/backend/camera/camera_thread.py b/backend/camera/camera_thread.py
index 2883d8f..efead24 100644
--- a/backend/camera/camera_thread.py
+++ b/backend/camera/camera_thread.py
@@ -14,6 +14,7 @@
from backend.pipeline.inference_utils import build_state
from backend.services.pipeline_service import PipelineService
from backend.services.service_manager import get_service_manager
+from utils.runtime_metrics import record_runtime_metric
_DEFAULT_INFER_INTERVAL = 1
_LIVE_CLIP_SECONDS = 5
@@ -253,6 +254,8 @@ def _predict_entry(entry):
def run(self):
self._running = True
+ run_started_at = time.perf_counter()
+ startup_recorded = False
try:
os.environ.setdefault("OPENCV_VIDEOIO_PRIORITY_MSMF", "1")
os.environ.setdefault("OPENCV_VIDEOIO_DISABLE_DIRECTSHOW", "1")
@@ -422,9 +425,15 @@ def _resolve_source():
def _do_inference(infer_frame, cid, fw, fh, infer_scale=1.0, source_ts=None):
try:
- t0 = time.time()
+ t0 = time.perf_counter()
det = detector.process_frame(infer_frame, cid)
- infer_ms = (time.time() - t0) * 1000.0
+ infer_ms = (time.perf_counter() - t0) * 1000.0
+ record_runtime_metric(
+ "average_inference_time_per_frame",
+ infer_ms,
+ context={"source": "live_camera", "camera_id": cid},
+ min_interval_sec=1.0,
+ )
if infer_scale < 0.999:
_inv = 1.0 / infer_scale
for _fi in det.get("faces", []):
@@ -437,6 +446,7 @@ def _do_inference(infer_frame, cid, fw, fh, infer_scale=1.0, source_ts=None):
_oi["bbox"] = [int(_b[0] * _inv), int(_b[1] * _inv), int(_b[2] * _inv), int(_b[3] * _inv)]
primary, all_triggered = build_state(det, cid)
primary["_triggered"] = all_triggered
+ primary["_rule_trigger_perf"] = time.perf_counter() if all_triggered else 0.0
primary["_fw"] = fw
primary["_fh"] = fh
primary["_infer_ms"] = infer_ms
@@ -566,6 +576,13 @@ def _submit_inference(frame, fw, fh):
consecutive_failures = 0
self._suppress_errors = False
frame_num += 1
+ if not startup_recorded:
+ record_runtime_metric(
+ "camera_startup_time",
+ (time.perf_counter() - run_started_at) * 1000.0,
+ context={"camera_id": self._camera_id, "source": str(self._source)},
+ )
+ startup_recorded = True
fh, fw = frame.shape[:2]
self._append_clip_frame(frame, time.time())
@@ -587,6 +604,12 @@ def _submit_inference(frame, fw, fh):
self._fps = self._frame_count / (now - self._last_fps_time)
self._frame_count = 0
self._last_fps_time = now
+ record_runtime_metric(
+ "average_displayed_fps",
+ self._fps,
+ context={"source": "live_camera", "camera_id": self._camera_id},
+ min_interval_sec=1.0,
+ )
self.fps_updated.emit(self._camera_id, self._fps)
if self._last_inference_ts > 0.0 and now - self._last_inference_ts >= 2.0:
self._infer_fps = 0.0
diff --git a/backend/camera/playback_thread.py b/backend/camera/playback_thread.py
index 5d1e3fc..3dc07e0 100644
--- a/backend/camera/playback_thread.py
+++ b/backend/camera/playback_thread.py
@@ -14,6 +14,7 @@
from backend.pipeline.detector_manager import get_manager
from backend.pipeline.inference_utils import build_state
from backend.repository import db
+from utils.runtime_metrics import record_runtime_metric
_AUTO_CLIP_SECONDS = 5
_AUTO_CLIP_LATENCY_SLACK_SECONDS = 5
@@ -111,8 +112,11 @@ def run(self):
last_detect_frame_idx = -1
frame_idx = 0
_clip_cooldown = 0
+ display_frame_count = 0
+ last_display_fps_ts = time.perf_counter()
def _evaluate_frame(frame, w, h, infer_idx):
+ t0 = time.perf_counter()
detection_results = detector.process_frame(
frame,
self._camera_id,
@@ -127,6 +131,12 @@ def _evaluate_frame(frame, w, h, infer_idx):
detection_results["faces"] = []
elif detection_results.get("faces"):
detector.identify_faces_lightweight(self._camera_id, detection_results["faces"])
+ record_runtime_metric(
+ "average_inference_time_per_frame",
+ (time.perf_counter() - t0) * 1000.0,
+ context={"source": "playback", "camera_id": self._camera_id},
+ min_interval_sec=1.0,
+ )
if self._disabled_object_classes:
detection_results["objects"] = [
o
@@ -273,6 +283,18 @@ def _handle_triggers(primary_state, triggered, frame_idx, video_fps):
_clip_cooldown -= 1
self.frame_ready.emit(self._camera_id, frame, primary_state)
+ display_frame_count += 1
+ display_fps_now = time.perf_counter()
+ display_fps_elapsed = display_fps_now - last_display_fps_ts
+ if display_fps_elapsed >= 1.0:
+ record_runtime_metric(
+ "average_displayed_fps",
+ display_frame_count / display_fps_elapsed,
+ context={"source": "playback", "camera_id": self._camera_id},
+ min_interval_sec=1.0,
+ )
+ display_frame_count = 0
+ last_display_fps_ts = display_fps_now
if not self._paused:
frame_idx += 1
elapsed = time.time() - t_start
diff --git a/backend/database/db.py b/backend/database/db.py
index 37bbb26..afdee1d 100644
--- a/backend/database/db.py
+++ b/backend/database/db.py
@@ -148,12 +148,19 @@ def __setattr__(self, name, value):
"theme_json_path": {"value": "", "type": "string", "label": "Theme JSON Path", "section": "general"},
"log_retention_days": {"value": "90", "type": "int", "label": "Log Retention (days)", "section": "data"},
"logs_auto_refresh_enabled": {"value": "0", "type": "bool", "label": "Auto-refresh Logs", "section": "data"},
+ "runtime_metrics_enabled": {"value": "1", "type": "bool", "label": "Record Runtime Metrics", "section": "reports"},
"auto_start_cameras": {"value": "0", "type": "bool", "label": "Auto-start cameras on launch", "section": "general"},
"minimize_to_tray": {"value": "0", "type": "bool", "label": "Minimize to tray", "section": "general"},
"popup_notifications_enabled": {"value": "1", "type": "bool", "label": "Popup notifications", "section": "notifications"},
"debug_mode_enabled": {"value": "0", "type": "bool", "label": "Debugging mode", "section": "general"},
"experimental_mode_enabled": {"value": "0", "type": "bool", "label": "Experimental settings", "section": "general"},
"liveness_check_global": {"value": "0", "type": "bool", "label": "Require Liveness Globally", "section": "detection"},
+ "liveness_skip_presentation_for_stream_sources": {
+ "value": "1",
+ "type": "bool",
+ "label": "Skip Presentation Block For Stream Sources",
+ "section": "detection",
+ },
}
_DYNAMIC_SETTING_PATTERNS = (
re.compile(r"^camera_\d+_max_faces$"),
diff --git a/backend/database/migrations.py b/backend/database/migrations.py
index 680ac62..5ced70b 100644
--- a/backend/database/migrations.py
+++ b/backend/database/migrations.py
@@ -5,7 +5,7 @@
import uuid
-CURRENT_VERSION = 48
+CURRENT_VERSION = 50
def apply(conn):
@@ -107,10 +107,60 @@ def apply(conn):
_migrate_v47(conn)
if version < 48:
_migrate_v48(conn)
+ if version < 49:
+ _migrate_v49(conn)
+ if version < 50:
+ _migrate_v50(conn)
conn.execute(f"PRAGMA user_version = {CURRENT_VERSION}")
conn.commit()
+def _migrate_v50(conn):
+ conn.execute(
+ "INSERT OR IGNORE INTO app_settings (key, value, type, label, section) VALUES (?, ?, ?, ?, ?)",
+ (
+ "runtime_metrics_enabled",
+ "1",
+ "bool",
+ "Record Runtime Metrics",
+ "reports",
+ ),
+ )
+ conn.execute(
+ "UPDATE app_settings SET type=?, label=?, section=? WHERE key=?",
+ (
+ "bool",
+ "Record Runtime Metrics",
+ "reports",
+ "runtime_metrics_enabled",
+ ),
+ )
+ conn.commit()
+
+
+def _migrate_v49(conn):
+ conn.execute(
+ "INSERT OR IGNORE INTO app_settings (key, value, type, label, section) VALUES (?, ?, ?, ?, ?)",
+ (
+ "liveness_skip_presentation_for_stream_sources",
+ "1",
+ "bool",
+ "Skip Presentation Block For Stream Sources",
+ "detection",
+ ),
+ )
+ conn.execute(
+ "UPDATE app_settings SET type=?, label=?, section=? WHERE key=?",
+ (
+ "bool",
+ "Skip Presentation Block For Stream Sources",
+ "detection",
+ "liveness_skip_presentation_for_stream_sources",
+ ),
+ )
+ conn.commit()
+
+
def _migrate_v48(conn):
conn.execute(
"INSERT OR IGNORE INTO app_settings (key, value, type, label, section) VALUES (?, ?, ?, ?, ?)",
diff --git a/backend/database/schema.sql b/backend/database/schema.sql
index 0858121..0da8a00 100644
--- a/backend/database/schema.sql
+++ b/backend/database/schema.sql
@@ -222,6 +222,7 @@ INSERT OR IGNORE INTO app_settings VALUES ('liveness_pass_revoke_threshold', '0.
INSERT OR IGNORE INTO app_settings VALUES ('liveness_identity_track_min_iou', '0.20', 'float', 'Liveness Identity Track Minimum IOU', 'detection');
INSERT OR IGNORE INTO app_settings VALUES ('liveness_failure_log_cooldown_sec', '20.0', 'float', 'Liveness Failure Log Cooldown', 'detection');
INSERT OR IGNORE INTO app_settings VALUES ('liveness_block_screen_presentations', '1', 'bool', 'Block Screen Presentations', 'detection');
+INSERT OR IGNORE INTO app_settings VALUES ('liveness_skip_presentation_for_stream_sources', '1', 'bool', 'Skip Presentation Block For Stream Sources', 'detection');
INSERT OR IGNORE INTO app_settings VALUES ('liveness_challenge_seconds', '8.0', 'float', 'Liveness Challenge Seconds', 'detection');
INSERT OR IGNORE INTO app_settings VALUES ('liveness_yaw_threshold', '0.16', 'float', 'Liveness Head Turn Threshold', 'detection');
INSERT OR IGNORE INTO app_settings VALUES ('liveness_pose_frames', '2', 'int', 'Liveness Consecutive Pose Frames', 'detection');
@@ -232,6 +233,7 @@ INSERT OR IGNORE INTO app_settings VALUES ('log_retention_days', '90', 'int', 'L
INSERT OR IGNORE INTO app_settings VALUES ('logs_auto_refresh_enabled', '0', 'bool', 'Auto-refresh Logs', 'data');
INSERT OR IGNORE INTO app_settings VALUES ('db_size_limit_bytes', '0', 'int', 'DB Size Limit (bytes)', 'data');
INSERT OR IGNORE INTO app_settings VALUES ('report_logo_path', '', 'string', 'Report Logo Path', 'reports');
+INSERT OR IGNORE INTO app_settings VALUES ('runtime_metrics_enabled', '1', 'bool', 'Record Runtime Metrics', 'reports');
INSERT OR IGNORE INTO app_settings VALUES ('smtp_host', '', 'string', 'SMTP Host', 'notifications');
INSERT OR IGNORE INTO app_settings VALUES ('smtp_port', '587', 'int', 'SMTP Port', 'notifications');
INSERT OR IGNORE INTO app_settings VALUES ('smtp_user', '', 'string', 'SMTP Username', 'notifications');
diff --git a/backend/pipeline/detector_manager.py b/backend/pipeline/detector_manager.py
index d373596..c363bb7 100644
--- a/backend/pipeline/detector_manager.py
+++ b/backend/pipeline/detector_manager.py
@@ -16,6 +16,7 @@
logger = logging.getLogger(__name__)
_MAX_INFER_DIM = 768
+_DEMO_VIDEO_EXTENSIONS = (".mp4", ".avi", ".mkv", ".mov", ".wmv", ".webm", ".m4v")
def _scale_frame(frame, max_dim=_MAX_INFER_DIM):
@@ -100,6 +101,17 @@ def _class_name_key(value):
return str(value or "").strip().lower().replace("_", " ").replace("-", " ")
+def _is_demo_stream_source(source, *, http_as_live: bool = False) -> bool:
+ text = str(source or "").strip().lower()
+ if not text:
+ return False
+ if "twitch.tv/" in text or "www.twitch.tv/" in text:
+ return True
+ if http_as_live and text.startswith(("http://", "https://")):
+ return True
+ return text.endswith(_DEMO_VIDEO_EXTENSIONS)
+
+
def _face_linked_to_person_box(person_box, face_box):
if not person_box or not face_box:
return False
@@ -1335,8 +1347,23 @@ def _identify_faces_for_frame(self, camera_id, faces, aggressive_mode, max_ident
existing_trackers = list(state.trackers)
self._identify_faces(camera_id, faces_for_identify, existing_trackers, aggressive_mode, max_identify, frame_for_liveness, frame_idx)
+ def _skip_presentation_block_for_camera(self, camera_id) -> bool:
+ try:
+ if not db.get_bool("liveness_skip_presentation_for_stream_sources", True):
+ return False
+ cam = self._get_camera_settings_cached(camera_id)
+ if not cam:
+ return False
+ return _is_demo_stream_source(
+ cam.get("source"),
+ http_as_live=db.get_bool("http_stream_as_live", False),
+ )
+ except Exception:
+ return False
+
def _evaluate_liveness_for_frame(self, camera_id, faces, objects, frame_for_liveness, frame_idx):
try:
+ skip_presentation_block = self._skip_presentation_block_for_camera(camera_id)
for f in faces:
try:
liveness_required = config.liveness_global()
@@ -1352,7 +1379,11 @@ def _evaluate_liveness_for_frame(self, camera_id, faces, objects, frame_for_live
block_presentations = config.get("liveness_block_screen_presentations", True)
if isinstance(block_presentations, str):
block_presentations = block_presentations.strip().lower() in ("1", "true", "yes", "on")
- if block_presentations and self._liveness.detect_presentation_attack(frame_for_liveness, f, objects=objects):
+ if (
+ block_presentations
+ and not skip_presentation_block
+ and self._liveness.detect_presentation_attack(frame_for_liveness, f, objects=objects)
+ ):
f["liveness"] = 0.0
f["_spoof_type"] = "screen_presentation"
f.pop("_liveness_pending", None)
@@ -1364,7 +1395,12 @@ def _evaluate_liveness_for_frame(self, camera_id, faces, objects, frame_for_live
if liveness_required and self._liveness is not None:
try:
lval, spoof, pending, seconds_left = self._liveness.evaluate(
- camera_id, frame_for_liveness, f, frame_idx, objects=objects
+ camera_id,
+ frame_for_liveness,
+ f,
+ frame_idx,
+ objects=objects,
+ block_presentation=not skip_presentation_block,
)
f["liveness"] = lval
if spoof:
diff --git a/backend/pipeline/liveness_manager.py b/backend/pipeline/liveness_manager.py
index 3ee781c..428124e 100644
--- a/backend/pipeline/liveness_manager.py
+++ b/backend/pipeline/liveness_manager.py
@@ -560,7 +560,7 @@ def _evaluate_passive(self, camera_id, frame, face, bbox, frame_idx, tr: dict, n
self._record_failure(camera_id, frame, face, bbox, tr, fail_type, None)
return 0.0, fail_type, False, 0.0
- def evaluate(self, camera_id, frame, face, frame_idx, objects=None):
+ def evaluate(self, camera_id, frame, face, frame_idx, objects=None, *, block_presentation: bool = True):
"""
Returns: (liveness_float, fail_type_or_None, pending_bool, seconds_left)
"""
@@ -588,7 +588,7 @@ def evaluate(self, camera_id, frame, face, frame_idx, objects=None):
return 0.0, tr.get("fail_type", "turn_failed"), False, 0.0
self._reset_challenge(tr, bbox, now)
- if self._looks_like_presentation_attack(frame, bbox, objects or []):
+ if block_presentation and self._looks_like_presentation_attack(frame, bbox, objects or []):
tr["passed_at"] = 0.0
tr["failed_at"] = now
tr["fail_type"] = "screen_presentation"
diff --git a/frontend/pages/dashboard/_page.py b/frontend/pages/dashboard/_page.py
index 687db92..0c95c89 100644
--- a/frontend/pages/dashboard/_page.py
+++ b/frontend/pages/dashboard/_page.py
@@ -74,6 +74,7 @@
SPACE_XL,
SPACE_XXXS,
)
+from utils.runtime_metrics import record_runtime_metric
warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*disconnect.*")
@@ -512,6 +513,22 @@ def _update_alarms(self, camera_id, state):
key = (camera_id, rid)
current_ids.add(key)
if key not in self._alarm_badges:
+ try:
+ trigger_perf = float(state.get("_rule_trigger_perf", 0.0) or 0.0)
+ if trigger_perf > 0.0:
+ visible_delay_ms = (time.perf_counter() - trigger_perf) * 1000.0
+ visible_delay_ms = max(visible_delay_ms, float(v.get("duration", 0.0) or 0.0) * 1000.0)
+ record_runtime_metric(
+ "alarm_response_delay",
+ visible_delay_ms,
+ context={
+ "camera_id": camera_id,
+ "rule_id": rid,
+ "rule_name": str(v.get("rule_name") or ""),
+ },
+ )
+ except Exception:
+ logger.debug("Failed to record alarm response delay", exc_info=True)
badge = AlarmBadgeWidget(v["rule_name"], v["level"])
self._alarms_layout.addWidget(badge)
self._alarm_badges[key] = badge
diff --git a/frontend/pages/rules_manager/_widgets.py b/frontend/pages/rules_manager/_widgets.py
index bb110a9..2058be0 100644
--- a/frontend/pages/rules_manager/_widgets.py
+++ b/frontend/pages/rules_manager/_widgets.py
@@ -2,6 +2,7 @@
import os
import sqlite3
+from typing import ClassVar
from PySide6.QtCore import Qt, QSize, Signal
from PySide6.QtGui import QColor, QFont, QIcon, QLinearGradient, QPainter, QPixmap
@@ -477,7 +478,7 @@ def get_value(self) -> str:
class ConditionRow(QFrame):
remove_requested = Signal(object)
- _OPS = [
+ _OPS: ClassVar[list[tuple[str, str]]] = [
("equals", "eq"),
("not equals", "neq"),
("contains", "contains"),
@@ -486,7 +487,7 @@ class ConditionRow(QFrame):
("greater than or equal", "gte"),
("less than or equal", "lte"),
]
- _OPS_BY_ATTR = {
+ _OPS_BY_ATTR: ClassVar[dict[str, tuple[str, ...]]] = {
"identity": ("eq", "neq", "contains"),
"gender": ("eq", "neq"),
"object": ("eq", "neq", "contains"),
diff --git a/frontend/services/log_service.py b/frontend/services/log_service.py
index f49c67a..aefbf44 100644
--- a/frontend/services/log_service.py
+++ b/frontend/services/log_service.py
@@ -6,6 +6,7 @@
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
+from typing import ClassVar
from backend.repository import db
@@ -27,7 +28,7 @@ def total_pages(self) -> int:
class LogService:
- TYPE_LABELS = {
+ TYPE_LABELS: ClassVar[dict[str, str]] = {
"all": "All types",
"face": "Faces",
"object": "Objects",
diff --git a/scripts/update_runtime_metrics_table.py b/scripts/update_runtime_metrics_table.py
new file mode 100644
index 0000000..881745c
--- /dev/null
+++ b/scripts/update_runtime_metrics_table.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import argparse
+import csv
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_REPORT = ROOT / "docs" / "final_report.md"
+DEFAULT_SUMMARY = ROOT / "data" / "report_metrics" / "runtime_metrics_summary.csv"
+
+MEASURE_ORDER = [
+ "Average inference time per frame",
+ "Average displayed FPS",
+ "Camera startup time",
+ "Alarm response delay",
+ "Report export time",
+]
+
+
+def _placeholder() -> str:
+ return 'Insert measured value.'
+
+
+def load_summary(path: Path) -> dict[str, dict[str, str]]:
+ if not path.exists():
+ raise FileNotFoundError(f"Runtime metrics summary was not found: {path}")
+ with path.open("r", encoding="utf-8", newline="") as fh:
+ return {
+ str(row.get("runtime_measure") or "").strip(): row
+ for row in csv.DictReader(fh)
+ if str(row.get("runtime_measure") or "").strip()
+ }
+
+
+def build_runtime_table(summary: dict[str, dict[str, str]]) -> list[str]:
+ rows = [
+ "| Runtime Measure | Value | Notes |",
+ "|---|---:|---|",
+ ]
+ for measure in MEASURE_ORDER:
+ row = summary.get(measure, {})
+ value = str(row.get("value") or "").strip() or _placeholder()
+ notes = str(row.get("notes") or "").strip()
+ count = str(row.get("sample_count") or "").strip()
+ if count and count != "0":
+ sample_text = f"{count} sample" if count == "1" else f"{count} samples"
+ notes = f"{notes}; {sample_text}" if notes else sample_text
+ rows.append(f"| {measure} | {value} | {notes} |")
+ return rows
+
+
+def update_report(report_path: Path, summary_path: Path) -> None:
+ summary = load_summary(summary_path)
+ lines = report_path.read_text(encoding="utf-8").splitlines()
+ start = None
+ for idx, line in enumerate(lines):
+ if line.strip() == "| Runtime Measure | Value | Notes |":
+ start = idx
+ break
+ if start is None:
+ raise RuntimeError("Runtime metrics table header was not found in final_report.md")
+
+ end = start + 1
+ while end < len(lines) and lines[end].startswith("|"):
+ end += 1
+
+ updated = lines[:start] + build_runtime_table(summary) + lines[end:]
+ report_path.write_text("\n".join(updated) + "\n", encoding="utf-8")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Update docs/final_report.md from runtime metrics CSV.")
+ parser.add_argument("--report", default=str(DEFAULT_REPORT), help="Markdown report path.")
+ parser.add_argument("--summary", default=str(DEFAULT_SUMMARY), help="runtime_metrics_summary.csv path.")
+ args = parser.parse_args()
+
+ update_report(Path(args.report).resolve(), Path(args.summary).resolve())
+ print(f"Updated runtime metrics table in {Path(args.report).resolve()}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_database_core.py b/tests/test_database_core.py
index 4972118..24de901 100644
--- a/tests/test_database_core.py
+++ b/tests/test_database_core.py
@@ -20,6 +20,8 @@ def test_schema_migrations_and_defaults(temp_db):
assert temp_db.get_bool("live_clip_enabled", False) is True
assert temp_db.get_int("live_clip_seconds", 0) == 5
assert temp_db.get_bool("playback_record_enabled", False) is True
+ assert temp_db.get_bool("liveness_skip_presentation_for_stream_sources", False) is True
+ assert temp_db.get_bool("runtime_metrics_enabled", False) is True
def test_detection_log_normalizes_gender_and_identity(temp_db):
@@ -111,6 +113,39 @@ def test_liveness_failure_evaluation_does_not_create_detection_log(temp_db):
assert temp_db.count_detection_logs() == 0
+def test_liveness_presentation_block_can_be_skipped_for_stream_sources(temp_db):
+ import numpy as np
+
+ from backend.pipeline.detector_manager import _is_demo_stream_source
+ from backend.pipeline.liveness_manager import LivenessManager
+ from utils import config
+
+ temp_db.set_setting("liveness_mode", "active")
+ temp_db.set_setting("liveness_challenge_seconds", "-1")
+ config.invalidate_cache()
+
+ frame = np.zeros((80, 80, 3), dtype=np.uint8)
+ face = {"bbox": [20, 20, 40, 40], "identity": {"id": 1, "name": "Alice"}}
+ objects = [{"class_name": "screen", "bbox": [0, 0, 80, 80]}]
+
+ assert _is_demo_stream_source("https://www.twitch.tv/example")
+ assert _is_demo_stream_source("demo.mp4")
+ assert not _is_demo_stream_source("0")
+
+ blocked = LivenessManager().evaluate(100, frame, face, frame_idx=1, objects=objects)
+ skipped = LivenessManager().evaluate(
+ 101,
+ frame,
+ face,
+ frame_idx=1,
+ objects=objects,
+ block_presentation=False,
+ )
+
+ assert blocked[:3] == (0.0, "screen_presentation", False)
+ assert skipped[1] != "screen_presentation"
+
+
def test_seed_detection_logs_preserves_derived_columns(temp_db):
cam_id = temp_db.add_camera("Cam 1", "debug://cam/1")
inserted = temp_db.seed_detection_logs(
diff --git a/utils/runtime_metrics.py b/utils/runtime_metrics.py
new file mode 100644
index 0000000..58cf011
--- /dev/null
+++ b/utils/runtime_metrics.py
@@ -0,0 +1,310 @@
+from __future__ import annotations
+
+import csv
+import json
+import os
+import threading
+import time
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+
+_ROOT = Path(__file__).resolve().parents[1]
+_OUT_DIR = _ROOT / "data" / "report_metrics"
+_EVENTS_CSV = _OUT_DIR / "runtime_metrics_events.csv"
+_SUMMARY_CSV = _OUT_DIR / "runtime_metrics_summary.csv"
+_SUMMARY_TXT = _OUT_DIR / "runtime_metrics_summary.txt"
+
+_EVENT_FIELDS = [
+ "timestamp",
+ "metric_key",
+ "runtime_measure",
+ "value",
+ "unit",
+ "notes",
+ "context_json",
+]
+_SUMMARY_FIELDS = [
+ "runtime_measure",
+ "value",
+ "notes",
+ "sample_count",
+ "minimum",
+ "maximum",
+ "latest",
+ "unit",
+]
+
+_METADATA = {
+ "average_inference_time_per_frame": {
+ "runtime_measure": "Average inference time per frame",
+ "unit": "ms",
+ "notes": "measured inside local application",
+ },
+ "average_displayed_fps": {
+ "runtime_measure": "Average displayed FPS",
+ "unit": "FPS",
+ "notes": "dashboard or playback test",
+ },
+ "camera_startup_time": {
+ "runtime_measure": "Camera startup time",
+ "unit": "ms",
+ "notes": "time to begin live monitoring",
+ },
+ "alarm_response_delay": {
+ "runtime_measure": "Alarm response delay",
+ "unit": "ms",
+ "notes": "time from rule trigger to visible alert",
+ },
+ "report_export_time": {
+ "runtime_measure": "Report export time",
+ "unit": "ms",
+ "notes": "analytics report generation test",
+ },
+}
+
+
+@dataclass
+class _MetricStats:
+ runtime_measure: str
+ unit: str
+ notes: str
+ count: int = 0
+ total: float = 0.0
+ minimum: float | None = None
+ maximum: float | None = None
+ latest: float | None = None
+
+ def add(self, value: float) -> None:
+ self.count += 1
+ self.total += value
+ self.latest = value
+ self.minimum = value if self.minimum is None else min(self.minimum, value)
+ self.maximum = value if self.maximum is None else max(self.maximum, value)
+
+ @property
+ def average(self) -> float:
+ return self.total / self.count if self.count else 0.0
+
+
+_lock = threading.RLock()
+_stats: dict[str, _MetricStats] = {}
+_stats_loaded = False
+_last_record_ts: dict[str, float] = {}
+_enabled_cache: tuple[float, bool] = (0.0, True)
+
+
+def _truthy(value: Any, default: bool = True) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return value != 0
+ text = str(value).strip().lower()
+ if text in {"1", "true", "yes", "on"}:
+ return True
+ if text in {"0", "false", "no", "off", ""}:
+ return False
+ return default
+
+
+def _is_enabled() -> bool:
+ global _enabled_cache
+ now = time.monotonic()
+ cached_at, cached_value = _enabled_cache
+ if now - cached_at < 5.0:
+ return cached_value
+
+ env_value = os.environ.get("SMART_EYE_RUNTIME_METRICS")
+ if env_value is not None:
+ enabled = _truthy(env_value, True)
+ _enabled_cache = (now, enabled)
+ return enabled
+
+ try:
+ from backend.repository import db
+
+ enabled = bool(db.get_bool("runtime_metrics_enabled", True))
+ except Exception:
+ enabled = True
+ _enabled_cache = (now, enabled)
+ return enabled
+
+
+def _format_value(value: float, unit: str) -> str:
+ if unit == "FPS":
+ return f"{value:.1f} FPS"
+ if unit == "ms":
+ if value >= 1000.0:
+ return f"{value / 1000.0:.2f} s"
+ return f"{value:.1f} ms"
+ return f"{value:.2f} {unit}".strip()
+
+
+def _append_event(metric_key: str, value: float, unit: str, notes: str, context: dict[str, Any] | None) -> None:
+ _OUT_DIR.mkdir(parents=True, exist_ok=True)
+ needs_header = not _EVENTS_CSV.exists() or _EVENTS_CSV.stat().st_size == 0
+ with _EVENTS_CSV.open("a", newline="", encoding="utf-8") as fh:
+ writer = csv.DictWriter(fh, fieldnames=_EVENT_FIELDS)
+ if needs_header:
+ writer.writeheader()
+ writer.writerow(
+ {
+ "timestamp": datetime.now().isoformat(timespec="seconds"),
+ "metric_key": metric_key,
+ "runtime_measure": _stats[metric_key].runtime_measure,
+ "value": f"{value:.6f}",
+ "unit": unit,
+ "notes": notes,
+ "context_json": json.dumps(context or {}, sort_keys=True),
+ }
+ )
+
+
+def _stats_for(metric_key: str, *, unit: str | None = None, notes: str | None = None) -> _MetricStats:
+ meta = _METADATA.get(metric_key, {})
+ return _stats.setdefault(
+ metric_key,
+ _MetricStats(
+ runtime_measure=str(meta.get("runtime_measure") or metric_key),
+ unit=unit or str(meta.get("unit") or ""),
+ notes=notes or str(meta.get("notes") or ""),
+ ),
+ )
+
+
+def _load_existing_events_locked() -> None:
+ global _stats_loaded
+ if _stats_loaded:
+ return
+ _stats_loaded = True
+ if not _EVENTS_CSV.exists():
+ return
+ try:
+ with _EVENTS_CSV.open("r", newline="", encoding="utf-8") as fh:
+ for row in csv.DictReader(fh):
+ metric_key = str(row.get("metric_key") or "").strip()
+ if not metric_key:
+ continue
+ try:
+ value = float(row.get("value") or 0.0)
+ except (TypeError, ValueError):
+ continue
+ stat = _stats_for(
+ metric_key,
+ unit=str(row.get("unit") or "") or None,
+ notes=str(row.get("notes") or "") or None,
+ )
+ stat.add(value)
+ except Exception:
+ return
+
+
+def _write_summary() -> None:
+ _OUT_DIR.mkdir(parents=True, exist_ok=True)
+ rows = []
+ for metric_key in _METADATA:
+ stat = _stats.get(metric_key)
+ meta = _METADATA[metric_key]
+ if stat and stat.count:
+ rows.append(
+ {
+ "runtime_measure": stat.runtime_measure,
+ "value": _format_value(stat.average, stat.unit),
+ "notes": stat.notes,
+ "sample_count": str(stat.count),
+ "minimum": _format_value(stat.minimum or 0.0, stat.unit),
+ "maximum": _format_value(stat.maximum or 0.0, stat.unit),
+ "latest": _format_value(stat.latest or 0.0, stat.unit),
+ "unit": stat.unit,
+ }
+ )
+ else:
+ rows.append(
+ {
+ "runtime_measure": meta["runtime_measure"],
+ "value": "",
+ "notes": meta["notes"],
+ "sample_count": "0",
+ "minimum": "",
+ "maximum": "",
+ "latest": "",
+ "unit": meta["unit"],
+ }
+ )
+
+ with _SUMMARY_CSV.open("w", newline="", encoding="utf-8") as fh:
+ writer = csv.DictWriter(fh, fieldnames=_SUMMARY_FIELDS)
+ writer.writeheader()
+ writer.writerows(rows)
+
+ width = max(len(row["runtime_measure"]) for row in rows)
+ lines = ["Runtime Metrics Summary", "=" * 23, ""]
+ for row in rows:
+ value = row["value"] or "not measured"
+ count = row["sample_count"]
+ sample_text = f"{count} sample" if count == "1" else f"{count} samples"
+ lines.append(f"{row['runtime_measure']:<{width}} {value} ({sample_text}) {row['notes']}")
+ _SUMMARY_TXT.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def record_runtime_metric(
+ metric_key: str,
+ value: float,
+ *,
+ unit: str | None = None,
+ notes: str | None = None,
+ context: dict[str, Any] | None = None,
+ min_interval_sec: float = 0.0,
+) -> None:
+ """Record a report-facing runtime metric as CSV events plus a summary file."""
+ if not _is_enabled():
+ return
+ try:
+ numeric_value = float(value)
+ except (TypeError, ValueError):
+ return
+ if numeric_value < 0:
+ return
+
+ now = time.monotonic()
+ with _lock:
+ _load_existing_events_locked()
+ if min_interval_sec > 0:
+ previous = _last_record_ts.get(metric_key, 0.0)
+ if now - previous < min_interval_sec:
+ return
+ _last_record_ts[metric_key] = now
+
+ meta = _METADATA.get(metric_key, {})
+ metric_unit = unit or str(meta.get("unit") or "")
+ metric_notes = notes or str(meta.get("notes") or "")
+ stat = _stats_for(metric_key, unit=metric_unit, notes=metric_notes)
+ if unit:
+ stat.unit = unit
+ if notes:
+ stat.notes = notes
+ stat.add(numeric_value)
+ _append_event(metric_key, numeric_value, stat.unit, stat.notes, context)
+ _write_summary()
+
+
+def rebuild_runtime_metric_summaries() -> None:
+ """Rebuild summary CSV/TXT from the append-only runtime metric event CSV."""
+ global _stats_loaded
+ with _lock:
+ _stats.clear()
+ _stats_loaded = False
+ _load_existing_events_locked()
+ _write_summary()
+
+
+def summary_paths() -> dict[str, Path]:
+ return {
+ "events_csv": _EVENTS_CSV,
+ "summary_csv": _SUMMARY_CSV,
+ "summary_txt": _SUMMARY_TXT,
+ }