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, + }