Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ data/clips_live/*
data/cache/*

docs/
media/
report_metrics/
final_report.md
final_report.pdf
tests/notes.txt
Expand Down
8 changes: 8 additions & 0 deletions backend/analytics/report_generator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import io
import os
import time
from datetime import datetime

import cv2
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -416,4 +419,9 @@ def generate_report(
elements.append(Paragraph("<br/>".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
27 changes: 25 additions & 2 deletions backend/camera/camera_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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", []):
Expand All @@ -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
Expand Down Expand Up @@ -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())

Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions backend/camera/playback_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions backend/database/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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$"),
Expand Down
52 changes: 51 additions & 1 deletion backend/database/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import uuid


CURRENT_VERSION = 48
CURRENT_VERSION = 50


def apply(conn):
Expand Down Expand Up @@ -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 (?, ?, ?, ?, ?)",
Expand Down
2 changes: 2 additions & 0 deletions backend/database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
40 changes: 38 additions & 2 deletions backend/pipeline/detector_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions backend/pipeline/liveness_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions frontend/pages/dashboard/_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
SPACE_XL,
SPACE_XXXS,
)
from utils.runtime_metrics import record_runtime_metric


warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*disconnect.*")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading