"""標註影片輸出:骨架 + track id + 狀態標籤 + ALARM 警示。 狀態來源是規則引擎的 per-frame debug 紀錄(單一事實來源: 畫面上看到的狀態就是引擎當下判定的狀態,而非事後由事件區間反推), ALARM 橫幅出現的時刻即真實告警延遲,demo 不美化。 """ from __future__ import annotations from pathlib import Path import cv2 import numpy as np import pandas as pd from ..config import Config from ..events.schema import FallEvent from ..io.cache import N_KPTS from ..io.video import H264VideoWriter, iter_frames, probe # COCO 17 骨架連線(肢段) SKELETON = [ (5, 7), (7, 9), # 左臂 (6, 8), (8, 10), # 右臂 (5, 6), # 肩線 (5, 11), (6, 12), # 軀幹 (11, 12), # 髖線 (11, 13), (13, 15), # 左腿 (12, 14), (14, 16), # 右腿 (0, 5), (0, 6), # 頭-肩 ] STATE_COLORS = { # BGR "UPRIGHT": (80, 200, 80), "FALLING": (0, 165, 255), "FALLEN": (0, 60, 255), "ALARM": (0, 0, 255), } def _draw_person( frame: np.ndarray, kpts_xy: np.ndarray, kpts_conf: np.ndarray, bbox: tuple, track_id: int, state: str, kpt_conf_min: float, ) -> None: color = STATE_COLORS.get(state, (200, 200, 200)) x1, y1, x2, y2 = (int(v) for v in bbox) cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) pts = kpts_xy.reshape(N_KPTS, 2) visible = kpts_conf >= kpt_conf_min for a, b in SKELETON: if visible[a] and visible[b]: pa = tuple(int(v) for v in pts[a]) pb = tuple(int(v) for v in pts[b]) cv2.line(frame, pa, pb, color, 2) for i in range(N_KPTS): if visible[i]: cv2.circle(frame, tuple(int(v) for v in pts[i]), 3, color, -1) label = f"id {track_id} {state}" (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.55, 2) ty = max(y1 - 8, th + 4) cv2.rectangle(frame, (x1, ty - th - 6), (x1 + tw + 6, ty + 4), color, -1) cv2.putText( frame, label, (x1 + 3, ty), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 2 ) def _draw_alarm_banner(frame: np.ndarray, track_ids: list[int]) -> None: h, w = frame.shape[:2] overlay = frame.copy() cv2.rectangle(overlay, (0, 0), (w, 46), (0, 0, 220), -1) cv2.addWeighted(overlay, 0.75, frame, 0.25, 0, frame) ids = ",".join(str(t) for t in sorted(set(track_ids))) cv2.putText( frame, f"FALL ALARM (track {ids})", (12, 32), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, ) def annotate_video( video_path: str | Path, cache_df: pd.DataFrame, fps: float, cfg: Config, events: list[FallEvent], debug_records: list[dict], out_path: str | Path, ) -> Path: """輸出標註影片(H.264)。 Args: cache_df: keypoint cache rows(骨架繪製來源)。 events: 引擎輸出的事件(用於畫面右上角事件計數)。 debug_records: ``run_engine(collect_debug=True)`` 的 per-frame 狀態。 """ out_path = Path(out_path) info = probe(video_path) state_by_frame_track: dict[tuple[int, int], str] = { (d["frame_idx"], d["track_id"]): d["state"] for d in debug_records } rows_by_frame: dict[int, list] = {} for row in cache_df.itertuples(index=False): rows_by_frame.setdefault(int(row.frame_idx), []).append(row) with H264VideoWriter(out_path, info.fps, info.width, info.height) as writer: for frame_idx, frame in iter_frames(video_path): t_s = frame_idx / fps alarm_tracks: list[int] = [] for row in rows_by_frame.get(frame_idx, []): tid = int(row.track_id) state = state_by_frame_track.get((frame_idx, tid), "UPRIGHT") if tid < 0: state = "-" # 未指派 track 的偵測:只畫框不標狀態 _draw_person( frame, np.asarray(row.kpts_xy), np.asarray(row.kpts_conf), (row.bbox_x1, row.bbox_y1, row.bbox_x2, row.bbox_y2), tid, state, cfg.model.kpt_conf_min, ) if state == "ALARM": alarm_tracks.append(tid) if alarm_tracks: _draw_alarm_banner(frame, alarm_tracks) n_done = sum(1 for e in events if e.end_time_s <= t_s) cv2.putText( frame, f"frame {frame_idx} t={t_s:6.2f}s events={n_done}/{len(events)}", (10, info.height - 12), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1, ) writer.write(frame) return out_path