Trim use-can to buildable runtime sources and standalone Orin package
This commit is contained in:
+17
-23
@@ -1,39 +1,33 @@
|
||||
# Orin 可直接运行包 — use-can
|
||||
# Orin 直接运行包
|
||||
|
||||
这个目录可以单独复制到 Orin 的任意目录,不依赖兄弟目录 0920/0923,不需要重新编译。
|
||||
包含板端编译的 TensorRT/CUDA 原生库、YOLO/RTMW engine、Python 风险代码、CAN USB 库、标定文件和原有 GMSL 驱动。
|
||||
|
||||
环境:Jetson AGX Orin,JetPack 5.1.2 / L4T R35.4.1,Linux 5.10.120-tegra,CUDA 11.4,TensorRT 8.5.x,系统 OpenCV 4.5.4 库;Python 3.8+,numpy、cv2。系统 CUDA/TensorRT/OpenCV 运行库由 JetPack 提供,未重复打包。
|
||||
适用 Jetson AGX Orin / JetPack 5.1.2 (R35.4.1)、CUDA 11.4、TensorRT 8.5.x、OpenCV 4.5.4。
|
||||
系统需要 Python 3.8+、numpy、cv2。无需重新编译,可单独复制整个目录。
|
||||
|
||||
```bash
|
||||
cd orin
|
||||
sha256sum -c SHA256SUMS
|
||||
./scripts/initialize_camera.sh # 开机后无 /dev/video5 时执行;需要 sudo 权限
|
||||
./run.sh # 默认 CAN 左侧、无可视化,结果保存在内存
|
||||
./run.sh --visualize # 可选浏览器预览,默认端口 8765
|
||||
./scripts/initialize_camera.sh # 开机后无 /dev/video5 时执行,需要 sudo
|
||||
./run.sh # 默认 CAN 模式,结果保存在内存
|
||||
./run.sh --visualize # 可选浏览器预览,http://设备IP:8765
|
||||
```
|
||||
|
||||
Ctrl+C / SIGTERM 退出。仅有一个进程可以占用同一个摄像头。
|
||||
只运行一个摄像头采集进程,Ctrl+C / SIGTERM 停止。
|
||||
|
||||
```bash
|
||||
./run.sh --no-can --active-door both --duration 10
|
||||
./run.sh --save-results --output ./outputs/session
|
||||
./run.sh --visualize --save-results --save-snapshots --output ./outputs/session
|
||||
```
|
||||
- data[0..3]:左前、右前、左后、右后;`--can-side left/right` 选择摄像头侧。
|
||||
- 未收到有效 CAN 或超时:CAN_UNKNOWN,score=null;不发送执行器控制报文。
|
||||
- `--save-results` 保存 JSONL;`--save-snapshots` 保存快照;`--write-video` 保存叠加视频。默认均关闭。
|
||||
- `--output PATH` 指定输出目录;`--duration 30` 限时运行。完整参数见 `./run.sh --help`。
|
||||
- 标定参数仍需匹配实际机位,默认 calibration_verified=false。
|
||||
|
||||
CAN 0x391 的 data[0..3] 顺序为左前、右前、左后、右后。`--can-side right` 切换右侧。未收到有效 CAN 或报文过期时输出 CAN_UNKNOWN,不发送执行器控制报文。
|
||||
标定文件仍为研发机位,默认 calibration_verified=false。
|
||||
|
||||
同进程下游接口:
|
||||
同进程下游示例(先停止其他采集进程):
|
||||
|
||||
```python
|
||||
from run_camera import CameraPipeline, parse_args
|
||||
with CameraPipeline(parse_args([])) as pipeline:
|
||||
result = pipeline.wait_for_result(timeout=10)
|
||||
result = pipeline.latest_result # 等价于 pipeline.latest()
|
||||
result = pipeline.latest_result # 等价于 pipeline.latest()
|
||||
if result is not None:
|
||||
print(result['risk'], result['door_risks'])
|
||||
print(result['risk'], result['door_risks'], result['skeletons'])
|
||||
```
|
||||
|
||||
持续读取示例:`python3 examples/read_latest.py`(先停止其他采集进程)。`latest_result` 是单个只读快照;它不是跨进程共享变量。
|
||||
TensorRT engine 绑定目标 GPU/TRT 环境,不能直接用于 Thor、其他 JetPack 版本或桌面 GPU。
|
||||
变量是线程安全的最新只读快照,读取不会消费结果,不是跨进程共享变量。
|
||||
系统 CUDA/TensorRT/OpenCV 运行库由 JetPack 提供。引擎和驱动不能直接用于其他 GPU 或不匹配的内核。
|
||||
|
||||
+11
-13
@@ -1,27 +1,25 @@
|
||||
4171567ecbea2f15b3c945b8d8363362d9f422b8c732d33ee1e15461b40014da README.md
|
||||
b6295cf4b0705c5c3d83c9ba35e41b2a0b865ea67742c09cfd1690f263f556d0 README.md
|
||||
9ba768d559fd3b64e833b50c62c70f2adfd6c169620aea5fba32cfb1b4c5b0e9 annotations/constant_door_mask.json
|
||||
3544fb1c7b1b27b39db27d8f849d5e8652f5d09a950a59c5dd0c7703086b4f6b annotations/door_zone_multifeature.json
|
||||
6d5e874fd27fcfd75d303d7e35d9672028af71041f52dcc82a1e83676faefa65 bin/drivers/max9295.ko
|
||||
8a78d04ef8be44e9b62a5e4ee315d3d6d679e34aedff02a234bbf4d40e222933 bin/drivers/max9296.ko
|
||||
3da628587477cc35d5e3f8aee86e718e2ddf16808e893e00ba6e164b2307e843 bin/drivers/sgx-yuv-gmsl2.ko
|
||||
cd75632beaaf81df2f59ce3e83fb335c21cc745068ef2b721c74cc3b2b03db26 build/libvision_native.so
|
||||
49cc1067c1fa9f82a3d57dbfa77a55a54e5e5062ceae7ae84144b77745db79cf examples/read_latest.py
|
||||
5ba7e4f0371a411cc1a6dfd1275e3b824032a14d07a9ccd7ca3d5a6630b322ef build/libvision_native.so
|
||||
8dae8c557deadbe6bd7241c0dcd7025cb007077ee39a79b115e953a2b22cd9c1 lib/libusb-1.0.so
|
||||
ab8b7f7249d5fb73d1d33d14fa7532a522088ffb6c25f37ba334daf4c6b4d313 lib/libusbcanfd.so
|
||||
ebcd09e8c2e35008adbb0c1d222e580b17e0df1c8093df3481ba3374cd9a8174 models/engines/rtmw_l_384.engine
|
||||
f6a65b926ac1144484f4aa93cd26d7997d32d62310ab93fe867f2bd4412222e9 models/engines/yolo11s.engine
|
||||
fd3ae92255abf95ef037a1060ee75897f9705b4040f9394c3130f6ab860667e1 pipeline/compare_rtmw_ml.py
|
||||
6d6d14a972bc4e5e9f8256514169e73ba0392a301cacd1221eaa26146aad00d2 pipeline/door_mask_risk.py
|
||||
46d70e1e90a1f36d544b7492ef1fbf08821377a1c460a6683f42d4d7006055c7 pipeline/door_zone_risk.py
|
||||
bc74a400ce5c5608c2fa49a69ccd8b41ec8633cd6a8239d9d594b8d9e5c3732a pipeline/fuse_rtmw_l_temporal.py
|
||||
bcf2cc99dd27c8749801a5b743656767d9d64ef03d568d227cb654c573d70b00 pipeline/latest_result.py
|
||||
cc8ea36d543896cd90f7a00822f06fc67771a1811859a4087995ac372df30276 pipeline/live_pose_engine.py
|
||||
da299c11bd25127a09ffa738876f1de6e0d53d27add2840aeb6092ab0fe7016b pipeline/native_backend.py
|
||||
81088b8bc072162bec13f57c65eb0d396e6d1e1074a83f92bcf29e393e27e11f pipeline/occlusion_aware_pose_v2.py
|
||||
059e2a67c17ce63354986d3242a5d361103ee4241c5391f18140eeb19e47c939 pipeline/predict_optimized_pose_videos.py
|
||||
bf8bb754cd841a52b4410f958a651b005de36ac4d17cc0992fbe1c90ed30caca pipeline/rtmw_l_person_mask_ablation.py
|
||||
1bb58d851ba5d684527b8d1e1940db6241d2b0b2862687fa3b151899a65320d2 pipeline/rtmw_risk_pipeline.py
|
||||
294cdc11aad0e5475275fd8dece2057e229b9bdedba69c26afa62176fd51a4e9 pipeline/native_backend.py
|
||||
8b8c5b8c9c76c9bd2e1efa6cba60be337a18c9366e8f748038465e9e2fba2672 pipeline/pose_engine.py
|
||||
93d3323ea431ed1d3c8397cfed7d800d65ec6d7700690487335861945040a486 pipeline/pose_fusion.py
|
||||
2ebf58114835ba7c4779d4b66aeff65b8a94107128989c0336de556a4cf95fda pipeline/pose_geometry.py
|
||||
f32fa5bebf893a3dc6b9ccfaa09a8e3a153ad5b36dde5373fdc561f10f9e526b pipeline/pose_occlusion.py
|
||||
1a3f1f55d7b8a3f0ac96e69bf77f02f82017e8970adef96181d2bfade26e5bd5 pipeline/pose_tracking.py
|
||||
dcf3cce5562ea52400ffa9842fdddaadac1efd672dabb0ed12ce34f20f779f37 pipeline/visualization.py
|
||||
8a10e035334ed97b7480f558aff7b0464c255ac6138dd99f55a08ade68853e0e run.sh
|
||||
4393f367b4ac7e5a00908043ffd21d5573cc12ed19b956fd101897ada22afd45 run_camera.py
|
||||
23e9af5e82999889fe77c762a77c77036d1b1b699525ff2bd5ca8a418183ba50 run_camera.py
|
||||
11b5fbc3c17c0610ec0514a0a26cf5d92588d15c0e50e7086d9db5a5c0306e99 scripts/initialize_camera.sh
|
||||
4cc40e244495e1de60a0429a8fc58cdf383ccda471955e53360a1c36a278c822 scripts/initialize_camera_impl.sh
|
||||
bce5a22d49633454ed2c6bc92b608edf3b718af2424c283ed8f56ba93987200a scripts/initialize_camera_impl.sh
|
||||
|
||||
Binary file not shown.
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Same-process downstream example. Run on Orin, which owns the camera/GPU."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from run_camera import CameraPipeline, parse_args
|
||||
|
||||
|
||||
def main():
|
||||
# No visualization or disk output by default. CLI flags can enable either.
|
||||
pipeline = CameraPipeline(parse_args())
|
||||
last_frame = -1
|
||||
try:
|
||||
pipeline.start()
|
||||
while True:
|
||||
# Reading the variable is enough for a consumer with its own clock:
|
||||
# result = pipeline.latest_result # equivalent to pipeline.latest()
|
||||
# This sample waits to avoid busy polling; still only the latest
|
||||
# result is returned, without queuing/acknowledging individual frames.
|
||||
result = pipeline.wait_for_result(last_frame, timeout=1.0)
|
||||
if result is not None:
|
||||
last_frame = result['frame_index']
|
||||
print(last_frame, result['risk']['risk_type'],
|
||||
result['risk']['risk_score'], flush=True)
|
||||
# Downstream code uses result['door_risks'], ['skeletons'], ['can'].
|
||||
elif not pipeline.running():
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
pipeline.request_stop()
|
||||
pipeline.join() # Also propagates worker errors to the caller.
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,384 +0,0 @@
|
||||
"""Compare RTMW-M 256x192 and RTMW-L 384x288 on cached YOLO boxes.
|
||||
|
||||
This is an experiment script. It intentionally keeps the candidate weights
|
||||
outside the formal models/ directory and does not change deployment code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
BODY_EDGES = ((5, 7), (7, 9), (6, 8), (8, 10))
|
||||
HAND_EDGES_LOCAL = (
|
||||
(0, 1), (1, 2), (2, 3), (3, 4),
|
||||
(0, 5), (5, 6), (6, 7), (7, 8),
|
||||
(0, 9), (9, 10), (10, 11), (11, 12),
|
||||
(0, 13), (13, 14), (14, 15), (15, 16),
|
||||
(0, 17), (17, 18), (18, 19), (19, 20),
|
||||
)
|
||||
HAND_SLICES = {"left": (91, 112), "right": (112, 133)}
|
||||
BODY_WRISTS = {"left": 9, "right": 10}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video", type=Path, required=True)
|
||||
parser.add_argument("--boxes", type=Path, required=True)
|
||||
parser.add_argument("--model-m", type=Path, required=True)
|
||||
parser.add_argument("--model-l", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--max-people", type=int, default=2)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_boxes(path: Path, max_people: int) -> dict[int, list[list[float]]]:
|
||||
frames: dict[int, list[list[float]]] = {}
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
item = json.loads(line)
|
||||
boxes = [entry["xyxy"] for entry in item["boxes"]]
|
||||
frames[int(item["frame_index"])] = sorted(boxes, key=lambda b: b[0])[:max_people]
|
||||
return frames
|
||||
|
||||
|
||||
def point(pt: np.ndarray) -> tuple[int, int]:
|
||||
return int(round(float(pt[0]))), int(round(float(pt[1])))
|
||||
|
||||
|
||||
def draw_pose(
|
||||
image: np.ndarray,
|
||||
boxes: list[list[float]],
|
||||
keypoints: np.ndarray,
|
||||
title: str,
|
||||
) -> np.ndarray:
|
||||
canvas = image.copy()
|
||||
for person_idx, (bbox, kpts) in enumerate(zip(boxes, keypoints), start=1):
|
||||
x1, y1, x2, y2 = (int(round(value)) for value in bbox)
|
||||
cv2.rectangle(canvas, (x1, y1), (x2, y2), (0, 220, 255), 2)
|
||||
cv2.putText(
|
||||
canvas,
|
||||
f"P{person_idx}",
|
||||
(x1 + 4, max(22, y1 + 24)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.75,
|
||||
(0, 220, 255),
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
|
||||
for edge_idx, (a, b) in enumerate(BODY_EDGES):
|
||||
color = (40, 40, 255) if edge_idx < 2 else (30, 230, 30)
|
||||
cv2.line(canvas, point(kpts[a]), point(kpts[b]), color, 3, cv2.LINE_AA)
|
||||
for idx in (5, 6, 7, 8, 9, 10):
|
||||
color = (40, 40, 255) if idx in (5, 7, 9) else (30, 230, 30)
|
||||
cv2.circle(canvas, point(kpts[idx]), 5, color, -1, cv2.LINE_AA)
|
||||
|
||||
for side, (start, end) in HAND_SLICES.items():
|
||||
hand = kpts[start:end]
|
||||
color = (255, 80, 220) if side == "left" else (255, 190, 40)
|
||||
for a, b in HAND_EDGES_LOCAL:
|
||||
cv2.line(canvas, point(hand[a]), point(hand[b]), color, 2, cv2.LINE_AA)
|
||||
for hand_point in hand:
|
||||
cv2.circle(canvas, point(hand_point), 2, color, -1, cv2.LINE_AA)
|
||||
|
||||
cv2.rectangle(canvas, (0, 0), (610, 45), (0, 0, 0), -1)
|
||||
cv2.putText(
|
||||
canvas,
|
||||
title,
|
||||
(10, 31),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.8,
|
||||
(255, 255, 255),
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
return canvas
|
||||
|
||||
|
||||
def normalized_keypoints(kpts: np.ndarray, bbox: list[float]) -> np.ndarray:
|
||||
x1, y1, x2, y2 = bbox
|
||||
width = max(x2 - x1, 1.0)
|
||||
height = max(y2 - y1, 1.0)
|
||||
result = kpts.copy().astype(np.float64)
|
||||
result[:, 0] = (result[:, 0] - x1) / width
|
||||
result[:, 1] = (result[:, 1] - y1) / height
|
||||
return result
|
||||
|
||||
|
||||
def hand_span(kpts: np.ndarray, bbox: list[float], side: str) -> float:
|
||||
start, end = HAND_SLICES[side]
|
||||
hand = kpts[start:end]
|
||||
span = math.hypot(
|
||||
float(np.ptp(hand[:, 0])),
|
||||
float(np.ptp(hand[:, 1])),
|
||||
)
|
||||
x1, y1, x2, y2 = bbox
|
||||
return span / max(math.hypot(x2 - x1, y2 - y1), 1.0)
|
||||
|
||||
|
||||
def summarize(values: list[float]) -> dict[str, float | int | None]:
|
||||
if not values:
|
||||
return {"count": 0, "median": None, "p95": None, "max": None}
|
||||
array = np.asarray(values, dtype=np.float64)
|
||||
return {
|
||||
"count": int(array.size),
|
||||
"median": round(float(np.median(array)), 5),
|
||||
"p95": round(float(np.percentile(array, 95)), 5),
|
||||
"max": round(float(np.max(array)), 5),
|
||||
}
|
||||
|
||||
|
||||
def temporal_metrics(records: list[dict]) -> dict:
|
||||
by_model_person: dict[tuple[str, int], list[dict]] = defaultdict(list)
|
||||
for record in records:
|
||||
for model_name in ("M", "L"):
|
||||
for person_idx in range(len(record[model_name]["keypoints"])):
|
||||
by_model_person[(model_name, person_idx)].append(
|
||||
{
|
||||
"frame": record["frame_index"],
|
||||
"bbox": record["boxes"][person_idx],
|
||||
"kpts": np.asarray(
|
||||
record[model_name]["keypoints"][person_idx], dtype=np.float64
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
result: dict[str, dict] = {"M": {}, "L": {}}
|
||||
for (model_name, person_idx), sequence in by_model_person.items():
|
||||
upper_steps: list[float] = []
|
||||
wrist_steps = {"left": [], "right": []}
|
||||
hand_steps = {"left": [], "right": []}
|
||||
hand_spans = {"left": [], "right": []}
|
||||
bone_lengths = {"left_upper": [], "left_forearm": [],
|
||||
"right_upper": [], "right_forearm": []}
|
||||
|
||||
previous: np.ndarray | None = None
|
||||
for entry in sequence:
|
||||
normalized = normalized_keypoints(entry["kpts"], entry["bbox"])
|
||||
if previous is not None:
|
||||
steps = np.linalg.norm(normalized - previous, axis=1)
|
||||
upper_steps.extend(float(steps[idx]) for idx in (5, 6, 7, 8, 9, 10))
|
||||
for side, idx in BODY_WRISTS.items():
|
||||
wrist_steps[side].append(float(steps[idx]))
|
||||
for side, (start, end) in HAND_SLICES.items():
|
||||
hand_steps[side].extend(float(value) for value in steps[start:end])
|
||||
previous = normalized
|
||||
|
||||
hand_spans["left"].append(hand_span(entry["kpts"], entry["bbox"], "left"))
|
||||
hand_spans["right"].append(hand_span(entry["kpts"], entry["bbox"], "right"))
|
||||
bone_lengths["left_upper"].append(float(np.linalg.norm(normalized[5] - normalized[7])))
|
||||
bone_lengths["left_forearm"].append(float(np.linalg.norm(normalized[7] - normalized[9])))
|
||||
bone_lengths["right_upper"].append(float(np.linalg.norm(normalized[6] - normalized[8])))
|
||||
bone_lengths["right_forearm"].append(float(np.linalg.norm(normalized[8] - normalized[10])))
|
||||
|
||||
span_summary = {}
|
||||
for side, values in hand_spans.items():
|
||||
median = float(np.median(values))
|
||||
gross_limit = max(0.2, median * 2.5)
|
||||
span_summary[side] = {
|
||||
**summarize(values),
|
||||
"gross_limit": round(gross_limit, 5),
|
||||
"gross_outlier_frames": int(sum(value > gross_limit for value in values)),
|
||||
}
|
||||
|
||||
bone_summary = {}
|
||||
for name, values in bone_lengths.items():
|
||||
array = np.asarray(values, dtype=np.float64)
|
||||
mean = float(np.mean(array))
|
||||
bone_summary[name] = {
|
||||
**summarize(values),
|
||||
"coefficient_of_variation": round(float(np.std(array) / max(mean, 1e-9)), 5),
|
||||
}
|
||||
|
||||
result[model_name][f"P{person_idx + 1}"] = {
|
||||
"upper_body_frame_step": summarize(upper_steps),
|
||||
"wrist_frame_step": {side: summarize(values) for side, values in wrist_steps.items()},
|
||||
"hand_landmark_frame_step": {
|
||||
side: summarize(values) for side, values in hand_steps.items()
|
||||
},
|
||||
"hand_span_relative_to_person": span_summary,
|
||||
"bone_length_relative_to_box": bone_summary,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def model_disagreement(records: list[dict]) -> tuple[dict, list[int]]:
|
||||
values: dict[str, list[float]] = defaultdict(list)
|
||||
per_frame: list[tuple[float, int]] = []
|
||||
for record in records:
|
||||
frame_max = 0.0
|
||||
for person_idx, bbox in enumerate(record["boxes"]):
|
||||
m = normalized_keypoints(
|
||||
np.asarray(record["M"]["keypoints"][person_idx]), bbox
|
||||
)
|
||||
l = normalized_keypoints(
|
||||
np.asarray(record["L"]["keypoints"][person_idx]), bbox
|
||||
)
|
||||
distance = np.linalg.norm(m - l, axis=1)
|
||||
values[f"P{person_idx + 1}_upper"].extend(
|
||||
float(distance[idx]) for idx in (5, 6, 7, 8, 9, 10)
|
||||
)
|
||||
for side, idx in BODY_WRISTS.items():
|
||||
values[f"P{person_idx + 1}_{side}_wrist"].append(float(distance[idx]))
|
||||
frame_max = max(frame_max, float(np.max(distance[[5, 6, 7, 8, 9, 10]])))
|
||||
per_frame.append((frame_max, record["frame_index"]))
|
||||
worst_frames = [frame for _, frame in sorted(per_frame, reverse=True)[:12]]
|
||||
return {name: summarize(items) for name, items in values.items()}, worst_frames
|
||||
|
||||
|
||||
def write_montage(
|
||||
video_path: Path,
|
||||
records: list[dict],
|
||||
selected_frames: list[int],
|
||||
output_path: Path,
|
||||
titles: tuple[str, str] = ("RTMW-M 256x192", "RTMW-L 384x288"),
|
||||
) -> None:
|
||||
selected = set(selected_frames)
|
||||
record_map = {record["frame_index"]: record for record in records}
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
rows: list[np.ndarray] = []
|
||||
frame_idx = 0
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
if frame_idx in selected:
|
||||
record = record_map[frame_idx]
|
||||
m = draw_pose(
|
||||
frame,
|
||||
record["boxes"],
|
||||
np.asarray(record["M"]["keypoints"]),
|
||||
f"{titles[0]} | frame {frame_idx}",
|
||||
)
|
||||
l = draw_pose(
|
||||
frame,
|
||||
record["boxes"],
|
||||
np.asarray(record["L"]["keypoints"]),
|
||||
f"{titles[1]} | frame {frame_idx}",
|
||||
)
|
||||
m = cv2.resize(m, (640, 360), interpolation=cv2.INTER_AREA)
|
||||
l = cv2.resize(l, (640, 360), interpolation=cv2.INTER_AREA)
|
||||
rows.append(np.hstack([m, l]))
|
||||
frame_idx += 1
|
||||
cap.release()
|
||||
if rows:
|
||||
cv2.imwrite(str(output_path), np.vstack(rows))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from rtmlib import RTMPose
|
||||
args = parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
cached_boxes = load_boxes(args.boxes, args.max_people)
|
||||
|
||||
pose_m = RTMPose(
|
||||
str(args.model_m), model_input_size=(192, 256), backend="onnxruntime", device="cpu"
|
||||
)
|
||||
pose_l = RTMPose(
|
||||
str(args.model_l), model_input_size=(288, 384), backend="onnxruntime", device="cpu"
|
||||
)
|
||||
|
||||
cap = cv2.VideoCapture(str(args.video))
|
||||
fps = float(cap.get(cv2.CAP_PROP_FPS))
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
video_output = args.output / "reference_video_rtmw_m_vs_l.mp4"
|
||||
writer = cv2.VideoWriter(
|
||||
str(video_output),
|
||||
cv2.VideoWriter_fourcc(*"mp4v"),
|
||||
fps,
|
||||
(width, height // 2),
|
||||
)
|
||||
|
||||
records: list[dict] = []
|
||||
timings = {"M": [], "L": []}
|
||||
frame_idx = 0
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
boxes = cached_boxes[frame_idx]
|
||||
|
||||
started = time.perf_counter()
|
||||
kpts_m, scores_m = pose_m(frame, bboxes=boxes)
|
||||
timings["M"].append((time.perf_counter() - started) * 1000.0)
|
||||
|
||||
started = time.perf_counter()
|
||||
kpts_l, scores_l = pose_l(frame, bboxes=boxes)
|
||||
timings["L"].append((time.perf_counter() - started) * 1000.0)
|
||||
|
||||
records.append(
|
||||
{
|
||||
"frame_index": frame_idx,
|
||||
"boxes": boxes,
|
||||
"M": {
|
||||
"keypoints": np.round(kpts_m, 3).tolist(),
|
||||
"raw_scores": np.round(scores_m, 4).tolist(),
|
||||
},
|
||||
"L": {
|
||||
"keypoints": np.round(kpts_l, 3).tolist(),
|
||||
"raw_scores": np.round(scores_l, 4).tolist(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
left = draw_pose(frame, boxes, kpts_m, f"RTMW-M 256x192 | frame {frame_idx}")
|
||||
right = draw_pose(frame, boxes, kpts_l, f"RTMW-L 384x288 | frame {frame_idx}")
|
||||
left = cv2.resize(left, (width // 2, height // 2), interpolation=cv2.INTER_AREA)
|
||||
right = cv2.resize(right, (width // 2, height // 2), interpolation=cv2.INTER_AREA)
|
||||
writer.write(np.hstack([left, right]))
|
||||
|
||||
if frame_idx % 50 == 0:
|
||||
print(f"processed frame {frame_idx}", flush=True)
|
||||
frame_idx += 1
|
||||
|
||||
cap.release()
|
||||
writer.release()
|
||||
|
||||
jsonl_path = args.output / "reference_video_rtmw_m_vs_l.jsonl"
|
||||
with jsonl_path.open("w", encoding="utf-8") as handle:
|
||||
for record in records:
|
||||
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
disagreement, worst_frames = model_disagreement(records)
|
||||
fixed_frames = [0, 60, 120, 180, 240, 252, 300, 360, 411]
|
||||
montage_frames = sorted(set(fixed_frames + worst_frames))
|
||||
write_montage(
|
||||
args.video,
|
||||
records,
|
||||
montage_frames,
|
||||
args.output / "rtmw_m_vs_l_full_montage.jpg",
|
||||
)
|
||||
|
||||
summary = {
|
||||
"frames": len(records),
|
||||
"input_sizes_wh": {"M": [192, 256], "L": [288, 384]},
|
||||
"raw_score_note": (
|
||||
"M and L raw SimCC peak scores use different scales and must not be compared "
|
||||
"or thresholded with the same numeric value."
|
||||
),
|
||||
"timing_ms_two_people_cpu": {
|
||||
name: summarize(values[5:] if len(values) > 5 else values)
|
||||
for name, values in timings.items()
|
||||
},
|
||||
"temporal_metrics_normalized_to_yolo_box": temporal_metrics(records),
|
||||
"m_l_position_disagreement_normalized_to_yolo_box": disagreement,
|
||||
"largest_upper_body_disagreement_frames": worst_frames,
|
||||
"montage_frames": montage_frames,
|
||||
}
|
||||
with (args.output / "summary.json").open("w", encoding="utf-8") as handle:
|
||||
json.dump(summary, handle, ensure_ascii=False, indent=2)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,11 +1,8 @@
|
||||
"""Native Jetson TensorRT adapter; temporal/risk policy stays in upstream Python."""
|
||||
from __future__ import annotations
|
||||
import ctypes as C
|
||||
from collections import defaultdict, deque
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from live_pose_engine import LivePoseEngine
|
||||
from predict_optimized_pose_videos import BoxTracker
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -67,9 +64,3 @@ class NativeBackend:
|
||||
def close(self):
|
||||
if self.camera:self.lib.vap_camera_close(self.camera);self.camera=None
|
||||
if self.engine:self.lib.vap_engine_close(self.engine);self.engine=None
|
||||
|
||||
class NativePoseEngine(LivePoseEngine):
|
||||
def __init__(self,args,native):
|
||||
self.args=args;self.native=native;self.pose=native.pose
|
||||
self.box_tracker=BoxTracker(max_age=30);self.people={}
|
||||
self.timings=defaultdict(lambda:deque(maxlen=3000));self.isolated_inferences=0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,113 +1,28 @@
|
||||
"""Resident YOLO11s + RTMW-L pose stage extracted from the v9.9 pipeline."""
|
||||
|
||||
"""Resident native TensorRT inference with causal pose fusion and tracking."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from collections import defaultdict, deque
|
||||
import numpy as np
|
||||
|
||||
from pose_tracking import BoxTracker
|
||||
|
||||
SIDES = ("left", "right")
|
||||
|
||||
|
||||
class LivePoseEngine:
|
||||
"""Run the v9.9 YOLO/RTMW pose and temporal-fusion path without EdgeTAM."""
|
||||
|
||||
def __init__(self, args: argparse.Namespace):
|
||||
self._trt_dll_directory = None
|
||||
if args.pose_provider == "tensorrt" and os.name == "nt":
|
||||
try:
|
||||
import tensorrt_libs
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"--pose-provider tensorrt on Windows requires the "
|
||||
"tensorrt-cu12-libs package in the active Python environment."
|
||||
) from exc
|
||||
|
||||
trt_lib_dir = Path(tensorrt_libs.__file__).resolve().parent
|
||||
self._trt_dll_directory = os.add_dll_directory(str(trt_lib_dir))
|
||||
os.environ["PATH"] = (
|
||||
str(trt_lib_dir) + os.pathsep + os.environ.get("PATH", "")
|
||||
)
|
||||
|
||||
if args.pose_device.lower().startswith("cuda"):
|
||||
import onnxruntime as ort
|
||||
|
||||
if "CUDAExecutionProvider" not in ort.get_available_providers():
|
||||
raise RuntimeError(
|
||||
"--pose-device cuda requires onnxruntime-gpu. Uninstall "
|
||||
"onnxruntime and install onnxruntime-gpu==1.22.0."
|
||||
)
|
||||
|
||||
from rtmlib import RTMPose
|
||||
from ultralytics import YOLO
|
||||
from predict_optimized_pose_videos import BoxTracker
|
||||
|
||||
class NativePoseEngine:
|
||||
def __init__(self, args, native):
|
||||
self.args = args
|
||||
self.detector = YOLO(str(args.det_model))
|
||||
self.pose = RTMPose(
|
||||
str(args.pose_model),
|
||||
model_input_size=(288, 384),
|
||||
backend="onnxruntime",
|
||||
device=args.pose_device,
|
||||
)
|
||||
if args.pose_provider == "tensorrt":
|
||||
import onnxruntime as ort
|
||||
|
||||
available = ort.get_available_providers()
|
||||
if "TensorrtExecutionProvider" not in available:
|
||||
raise RuntimeError(
|
||||
"--pose-provider tensorrt requires ONNX Runtime with "
|
||||
"TensorrtExecutionProvider. "
|
||||
f"Available providers: {available}"
|
||||
)
|
||||
cache_dir = args.pose_trt_cache.expanduser().resolve()
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
device_id = (
|
||||
int(str(args.pose_device).split(":")[-1])
|
||||
if ":" in str(args.pose_device)
|
||||
else 0
|
||||
)
|
||||
trt_options = {
|
||||
"device_id": device_id,
|
||||
"trt_fp16_enable": "True",
|
||||
"trt_engine_cache_enable": "True",
|
||||
"trt_engine_cache_path": str(cache_dir),
|
||||
"trt_timing_cache_enable": "True",
|
||||
"trt_timing_cache_path": str(cache_dir),
|
||||
}
|
||||
self.pose.session = ort.InferenceSession(
|
||||
str(args.pose_model.resolve()),
|
||||
providers=[
|
||||
("TensorrtExecutionProvider", trt_options),
|
||||
("CUDAExecutionProvider", {"device_id": device_id}),
|
||||
],
|
||||
)
|
||||
active_providers = self.pose.session.get_providers()
|
||||
if not active_providers or active_providers[0] != "TensorrtExecutionProvider":
|
||||
raise RuntimeError(
|
||||
"RTMW TensorRT initialization failed instead of using the "
|
||||
"requested provider. Active providers: "
|
||||
f"{active_providers}"
|
||||
)
|
||||
print(f"RTMW ONNX Runtime providers: {active_providers}")
|
||||
|
||||
self.native = native
|
||||
self.pose = native.pose
|
||||
self.box_tracker = BoxTracker(max_age=30)
|
||||
self.people = {}
|
||||
self.timings = defaultdict(list)
|
||||
self.timings = defaultdict(lambda: deque(maxlen=3000))
|
||||
self.isolated_inferences = 0
|
||||
|
||||
def infer(self, frame: np.ndarray, frame_idx: int) -> dict:
|
||||
from compare_rtmw_ml import HAND_SLICES
|
||||
from fuse_rtmw_l_temporal import adaptive_smooth, build_global_hands, fuse_person
|
||||
from occlusion_aware_pose_v2 import Measurement, resolve_global_hand_assignments
|
||||
from predict_optimized_pose_videos import (
|
||||
from pose_geometry import HAND_SLICES
|
||||
from pose_fusion import adaptive_smooth, build_global_hands, fuse_person
|
||||
from pose_occlusion import Measurement, resolve_global_hand_assignments
|
||||
from pose_tracking import (
|
||||
LivePersonState,
|
||||
filter_person_boxes,
|
||||
mask_other_people,
|
||||
update_live_v2,
|
||||
)
|
||||
@@ -118,23 +33,8 @@ class LivePoseEngine:
|
||||
detection_height = max(
|
||||
1, min(height, round(height * self.args.det_roi_y_max_ratio))
|
||||
)
|
||||
if hasattr(self, "native"):
|
||||
boxes, confidences = self.native.detect(frame[:detection_height])
|
||||
self.timings["detector_ms"].append((time.perf_counter() - started) * 1000)
|
||||
else:
|
||||
detection = self.detector.predict(
|
||||
frame[:detection_height],
|
||||
imgsz=self.args.det_imgsz,
|
||||
conf=self.args.det_conf,
|
||||
iou=0.30,
|
||||
classes=[0],
|
||||
device=self.args.det_device,
|
||||
verbose=False,
|
||||
)[0]
|
||||
self.timings["detector_ms"].append((time.perf_counter() - started) * 1000)
|
||||
boxes, confidences, _ = filter_person_boxes(
|
||||
detection, width, height, self.args.max_people
|
||||
)
|
||||
boxes, confidences = self.native.detect(frame[:detection_height])
|
||||
self.timings["detector_ms"].append((time.perf_counter() - started) * 1000)
|
||||
track_ids = self.box_tracker.update(boxes, frame_idx, (height, width))
|
||||
|
||||
# Retire disappeared people in long-running camera sessions.
|
||||
@@ -1,38 +1,18 @@
|
||||
"""Fuse raw and person-isolated RTMW-L poses with temporal consistency.
|
||||
|
||||
The fusion keeps the two inference views as hypotheses. It selects body limbs
|
||||
and hands separately, rejects implausible hand geometry, adds source hysteresis
|
||||
and constant-velocity consistency, then applies a motion-adaptive EMA in
|
||||
person-box coordinates. Occluded/conflicting limbs remain labelled as such so
|
||||
they are not accidentally consumed as precise anti-pinch distance evidence.
|
||||
"""
|
||||
|
||||
"""Runtime pose fusion helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from compare_rtmw_ml import (
|
||||
BODY_EDGES,
|
||||
BODY_WRISTS,
|
||||
HAND_EDGES_LOCAL,
|
||||
HAND_SLICES,
|
||||
draw_pose,
|
||||
normalized_keypoints,
|
||||
summarize,
|
||||
temporal_metrics,
|
||||
)
|
||||
from pose_geometry import BODY_WRISTS, HAND_EDGES_LOCAL, HAND_SLICES, normalized_keypoints
|
||||
|
||||
|
||||
BODY_SIDE_INDICES = {"left": (5, 7, 9), "right": (6, 8, 10)}
|
||||
|
||||
|
||||
SIDE_COLORS = {"left": (40, 40, 255), "right": (30, 230, 30)}
|
||||
|
||||
|
||||
HAND_COLORS = {"left": (255, 80, 220), "right": (255, 190, 40)}
|
||||
|
||||
|
||||
@@ -71,15 +51,6 @@ class PersonTrack:
|
||||
smoothed: np.ndarray | None = None
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video", type=Path, required=True)
|
||||
parser.add_argument("--raw-jsonl", type=Path, required=True)
|
||||
parser.add_argument("--isolated-jsonl", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def bbox_diagonal(bbox: list[float]) -> float:
|
||||
return max(math.hypot(bbox[2] - bbox[0], bbox[3] - bbox[1]), 1.0)
|
||||
|
||||
@@ -557,299 +528,3 @@ def draw_fused(
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
return canvas
|
||||
|
||||
|
||||
def make_montage(
|
||||
video_path: Path,
|
||||
records: list[dict],
|
||||
selected_frames: list[int],
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
selected = set(selected_frames)
|
||||
by_frame = {record["frame_index"]: record for record in records}
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
rows = []
|
||||
frame_idx = 0
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
if frame_idx in selected:
|
||||
record = by_frame[frame_idx]
|
||||
boxes = record["boxes"]
|
||||
raw = draw_pose(
|
||||
frame,
|
||||
boxes,
|
||||
np.asarray(record["raw_keypoints"]),
|
||||
f"RTMW-L raw | frame {frame_idx}",
|
||||
)
|
||||
spatial = draw_fused(
|
||||
frame,
|
||||
boxes,
|
||||
[np.asarray(points) for points in record["spatial_keypoints"]],
|
||||
record["spatial_metadata"],
|
||||
f"spatial fusion | frame {frame_idx}",
|
||||
)
|
||||
temporal = draw_fused(
|
||||
frame,
|
||||
boxes,
|
||||
[np.asarray(points) for points in record["temporal_keypoints"]],
|
||||
record["temporal_metadata"],
|
||||
f"temporal fusion | frame {frame_idx}",
|
||||
)
|
||||
panels = [
|
||||
cv2.resize(panel, (480, 270), interpolation=cv2.INTER_AREA)
|
||||
for panel in (raw, spatial, temporal)
|
||||
]
|
||||
rows.append(np.hstack(panels))
|
||||
frame_idx += 1
|
||||
cap.release()
|
||||
if rows:
|
||||
cv2.imwrite(str(output_path), np.vstack(rows))
|
||||
|
||||
|
||||
def active_hand_temporal_metrics(records: list[dict], variant: str) -> dict:
|
||||
values: dict[str, list[float]] = defaultdict(list)
|
||||
previous: dict[tuple[int, str], tuple[int, np.ndarray]] = {}
|
||||
for record in records:
|
||||
for person_idx, bbox in enumerate(record["boxes"]):
|
||||
kpts = normalized_keypoints(
|
||||
np.asarray(record[f"{variant}_keypoints"][person_idx]), bbox
|
||||
)
|
||||
meta = record[f"{variant}_metadata"][person_idx]
|
||||
for side, (start, end) in HAND_SLICES.items():
|
||||
key = (person_idx, side)
|
||||
if not meta[side]["distance_eligible"]:
|
||||
previous.pop(key, None)
|
||||
continue
|
||||
prior = previous.get(key)
|
||||
if prior is not None and prior[0] == record["frame_index"] - 1:
|
||||
step = np.linalg.norm(kpts[start:end] - prior[1], axis=1)
|
||||
values[f"P{person_idx + 1}_{side}"].extend(float(v) for v in step)
|
||||
previous[key] = (record["frame_index"], kpts[start:end])
|
||||
return {name: summarize(items) for name, items in values.items()}
|
||||
|
||||
|
||||
def metadata_summary(records: list[dict], variant: str) -> dict:
|
||||
state_counts: Counter = Counter()
|
||||
source_counts: Counter = Counter()
|
||||
eligible_counts: Counter = Counter()
|
||||
alarm_counts: Counter = Counter()
|
||||
switches: Counter = Counter()
|
||||
previous_source: dict[tuple[int, str, str], str] = {}
|
||||
for record in records:
|
||||
for person_idx, person_meta in enumerate(record[f"{variant}_metadata"]):
|
||||
for side in ("left", "right"):
|
||||
meta = person_meta[side]
|
||||
key_prefix = f"P{person_idx + 1}_{side}"
|
||||
state_counts[f"{key_prefix}_{meta['hand_state']}"] += 1
|
||||
source_counts[f"{key_prefix}_{meta['hand_source']}"] += 1
|
||||
if meta["distance_eligible"]:
|
||||
eligible_counts[key_prefix] += 1
|
||||
if meta["alarm_eligible"]:
|
||||
alarm_counts[key_prefix] += 1
|
||||
for kind in ("limb", "hand"):
|
||||
source = meta[f"{kind}_source"]
|
||||
key = (person_idx, side, kind)
|
||||
if key in previous_source and previous_source[key] != source:
|
||||
switches[f"{key_prefix}_{kind}"] += 1
|
||||
previous_source[key] = source
|
||||
return {
|
||||
"hand_state_counts": dict(sorted(state_counts.items())),
|
||||
"source_counts": dict(sorted(source_counts.items())),
|
||||
"distance_eligible_frames": dict(sorted(eligible_counts.items())),
|
||||
"alarm_eligible_frames": dict(sorted(alarm_counts.items())),
|
||||
"source_switches": dict(sorted(switches.items())),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
raw_records = [
|
||||
json.loads(line) for line in args.raw_jsonl.read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
isolated_records = [
|
||||
json.loads(line)
|
||||
for line in args.isolated_jsonl.read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
|
||||
cap = cv2.VideoCapture(str(args.video))
|
||||
fps = float(cap.get(cv2.CAP_PROP_FPS))
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
writer = cv2.VideoWriter(
|
||||
str(args.output / "rtmw_l_raw_spatial_temporal.mp4"),
|
||||
cv2.VideoWriter_fourcc(*"mp4v"),
|
||||
fps,
|
||||
(1920, 360),
|
||||
)
|
||||
|
||||
tracks = [PersonTrack(), PersonTrack()]
|
||||
output_records: list[dict] = []
|
||||
frame_idx = 0
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
raw_record = raw_records[frame_idx]
|
||||
isolated_record = isolated_records[frame_idx]
|
||||
boxes = raw_record["boxes"][:2]
|
||||
raw_keypoints = np.asarray(raw_record["L"]["keypoints"][:2], dtype=np.float64)
|
||||
raw_scores = np.asarray(raw_record["L"]["raw_scores"][:2], dtype=np.float64)
|
||||
isolated_keypoints = np.asarray(
|
||||
isolated_record["L"]["keypoints"][:2], dtype=np.float64
|
||||
)
|
||||
isolated_scores = np.asarray(
|
||||
isolated_record["masked_raw_scores"][:2], dtype=np.float64
|
||||
)
|
||||
|
||||
spatial_keypoints: list[np.ndarray] = []
|
||||
spatial_metadata: list[dict] = []
|
||||
temporal_keypoints: list[np.ndarray] = []
|
||||
temporal_selected_keypoints: list[np.ndarray] = []
|
||||
temporal_metadata: list[dict] = []
|
||||
for person_idx in range(2):
|
||||
spatial, spatial_meta = fuse_person(
|
||||
raw_keypoints[person_idx],
|
||||
raw_scores[person_idx],
|
||||
isolated_keypoints[person_idx],
|
||||
isolated_scores[person_idx],
|
||||
boxes[person_idx],
|
||||
track=None,
|
||||
)
|
||||
temporal_unfiltered, temporal_meta = fuse_person(
|
||||
raw_keypoints[person_idx],
|
||||
raw_scores[person_idx],
|
||||
isolated_keypoints[person_idx],
|
||||
isolated_scores[person_idx],
|
||||
boxes[person_idx],
|
||||
track=tracks[person_idx],
|
||||
)
|
||||
temporal = adaptive_smooth(
|
||||
temporal_unfiltered, boxes[person_idx], tracks[person_idx]
|
||||
)
|
||||
spatial_keypoints.append(spatial)
|
||||
spatial_metadata.append(spatial_meta)
|
||||
temporal_keypoints.append(temporal)
|
||||
temporal_selected_keypoints.append(temporal_unfiltered)
|
||||
temporal_metadata.append(temporal_meta)
|
||||
|
||||
global_hands = build_global_hands(
|
||||
temporal_selected_keypoints, temporal_metadata
|
||||
)
|
||||
|
||||
output_record = {
|
||||
"frame_index": frame_idx,
|
||||
"boxes": boxes,
|
||||
"raw_keypoints": np.round(raw_keypoints, 3).tolist(),
|
||||
"spatial_keypoints": np.round(np.asarray(spatial_keypoints), 3).tolist(),
|
||||
"spatial_metadata": spatial_metadata,
|
||||
"temporal_keypoints": np.round(np.asarray(temporal_keypoints), 3).tolist(),
|
||||
"temporal_selected_keypoints": np.round(
|
||||
np.asarray(temporal_selected_keypoints), 3
|
||||
).tolist(),
|
||||
"temporal_metadata": temporal_metadata,
|
||||
"global_hands": global_hands,
|
||||
}
|
||||
output_records.append(output_record)
|
||||
|
||||
raw_panel = draw_pose(frame, boxes, raw_keypoints, f"RTMW-L raw | frame {frame_idx}")
|
||||
spatial_panel = draw_fused(
|
||||
frame,
|
||||
boxes,
|
||||
spatial_keypoints,
|
||||
spatial_metadata,
|
||||
f"spatial fusion | frame {frame_idx}",
|
||||
)
|
||||
temporal_panel = draw_fused(
|
||||
frame,
|
||||
boxes,
|
||||
temporal_keypoints,
|
||||
temporal_metadata,
|
||||
f"temporal fusion | frame {frame_idx}",
|
||||
)
|
||||
panels = [
|
||||
cv2.resize(panel, (640, 360), interpolation=cv2.INTER_AREA)
|
||||
for panel in (raw_panel, spatial_panel, temporal_panel)
|
||||
]
|
||||
writer.write(np.hstack(panels))
|
||||
frame_idx += 1
|
||||
|
||||
cap.release()
|
||||
writer.release()
|
||||
|
||||
with (args.output / "fusion.jsonl").open("w", encoding="utf-8") as handle:
|
||||
for record in output_records:
|
||||
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
def comparison_records(variant: str) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"frame_index": record["frame_index"],
|
||||
"boxes": record["boxes"],
|
||||
"M": {"keypoints": record["raw_keypoints"]},
|
||||
"L": {"keypoints": record[f"{variant}_keypoints"]},
|
||||
}
|
||||
for record in output_records
|
||||
]
|
||||
|
||||
summary = {
|
||||
"frames": len(output_records),
|
||||
"fusion_policy": {
|
||||
"candidates": ["RTMW-L raw", "RTMW-L smaller-person isolated"],
|
||||
"selection": [
|
||||
"limb and hand selected separately",
|
||||
"geometry validity",
|
||||
"constant-velocity temporal consistency",
|
||||
"source-switch hysteresis",
|
||||
"motion-adaptive EMA in person-box coordinates",
|
||||
],
|
||||
"distance_rule": (
|
||||
"Only consensus or temporally stable raw visible endpoints are eligible. "
|
||||
"Inferred/conflict/rejected hands are excluded from precise distance."
|
||||
),
|
||||
"alarm_rule": (
|
||||
"Alarm attack is asymmetric: a geometrically valid raw endpoint whose score "
|
||||
"is clearly stronger than the isolated view is admitted immediately. Alarm "
|
||||
"release should still require temporal confirmation and hysteresis."
|
||||
),
|
||||
"risk_distance_note": (
|
||||
"Use global_hands and temporal_selected_keypoints (unfiltered) for alarm entry "
|
||||
"to avoid smoothing delay. Use distance_eligible hands and temporal_keypoints "
|
||||
"for stable reporting. When the door edge is available, a conservative "
|
||||
"implementation can use the smaller of the unfiltered and smoothed distances."
|
||||
),
|
||||
},
|
||||
"spatial": metadata_summary(output_records, "spatial"),
|
||||
"temporal": metadata_summary(output_records, "temporal"),
|
||||
"active_hand_frame_step": {
|
||||
"spatial": active_hand_temporal_metrics(output_records, "spatial"),
|
||||
"temporal": active_hand_temporal_metrics(output_records, "temporal"),
|
||||
},
|
||||
"raw_vs_spatial_temporal_metrics": temporal_metrics(comparison_records("spatial")),
|
||||
"raw_vs_temporal_temporal_metrics": temporal_metrics(comparison_records("temporal")),
|
||||
"global_hand_count_frames": dict(
|
||||
sorted(Counter(len(record["global_hands"]) for record in output_records).items())
|
||||
),
|
||||
"cross_person_hand_aliases_total": sum(
|
||||
len(hand["aliases"])
|
||||
for record in output_records
|
||||
for hand in record["global_hands"]
|
||||
),
|
||||
}
|
||||
with (args.output / "summary.json").open("w", encoding="utf-8") as handle:
|
||||
json.dump(summary, handle, ensure_ascii=False, indent=2)
|
||||
|
||||
key_frames = [0, 45, 50, 55, 120, 132, 136, 180, 240, 252, 295, 300, 304, 360, 411]
|
||||
make_montage(
|
||||
args.video,
|
||||
output_records,
|
||||
key_frames,
|
||||
args.output / "rtmw_l_raw_spatial_temporal_montage.jpg",
|
||||
)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Runtime pose geometry helpers."""
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
|
||||
|
||||
HAND_EDGES_LOCAL = (
|
||||
(0, 1), (1, 2), (2, 3), (3, 4),
|
||||
(0, 5), (5, 6), (6, 7), (7, 8),
|
||||
(0, 9), (9, 10), (10, 11), (11, 12),
|
||||
(0, 13), (13, 14), (14, 15), (15, 16),
|
||||
(0, 17), (17, 18), (18, 19), (19, 20),
|
||||
)
|
||||
|
||||
|
||||
HAND_SLICES = {"left": (91, 112), "right": (112, 133)}
|
||||
|
||||
|
||||
BODY_WRISTS = {"left": 9, "right": 10}
|
||||
|
||||
|
||||
def normalized_keypoints(kpts: np.ndarray, bbox: list[float]) -> np.ndarray:
|
||||
x1, y1, x2, y2 = bbox
|
||||
width = max(x2 - x1, 1.0)
|
||||
height = max(y2 - y1, 1.0)
|
||||
result = kpts.copy().astype(np.float64)
|
||||
result[:, 0] = (result[:, 0] - x1) / width
|
||||
result[:, 1] = (result[:, 1] - y1) / height
|
||||
return result
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Runtime pose occlusion helpers."""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from pose_geometry import HAND_SLICES
|
||||
|
||||
|
||||
@dataclass
|
||||
class Measurement:
|
||||
keypoints: np.ndarray
|
||||
precise: bool
|
||||
provisional: bool
|
||||
source: str = "model"
|
||||
quality: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndpointTrack:
|
||||
wrist: np.ndarray | None = None
|
||||
velocity: np.ndarray | None = None
|
||||
hand_shape: np.ndarray | None = None
|
||||
last_observed_frame: int | None = None
|
||||
missed_frames: int = 0
|
||||
last_elbow: np.ndarray | None = None
|
||||
last_direction: np.ndarray | None = None
|
||||
|
||||
|
||||
def bbox_diagonal(bbox: list[float]) -> float:
|
||||
return max(math.hypot(bbox[2] - bbox[0], bbox[3] - bbox[1]), 1.0)
|
||||
|
||||
|
||||
def solve_two_bone_elbow(
|
||||
shoulder: np.ndarray,
|
||||
wrist: np.ndarray,
|
||||
upper_length: float,
|
||||
forearm_length: float,
|
||||
reference_elbow: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
direction = wrist - shoulder
|
||||
distance = float(np.linalg.norm(direction))
|
||||
if distance < 1e-6:
|
||||
return reference_elbow.copy()
|
||||
unit = direction / distance
|
||||
|
||||
if distance >= upper_length + forearm_length:
|
||||
return shoulder + unit * upper_length
|
||||
if distance <= abs(upper_length - forearm_length):
|
||||
sign = 1.0 if upper_length >= forearm_length else -1.0
|
||||
return shoulder + unit * upper_length * sign
|
||||
|
||||
along = (
|
||||
upper_length * upper_length
|
||||
- forearm_length * forearm_length
|
||||
+ distance * distance
|
||||
) / (2.0 * distance)
|
||||
height = math.sqrt(max(upper_length * upper_length - along * along, 0.0))
|
||||
base = shoulder + unit * along
|
||||
perpendicular = np.asarray([-unit[1], unit[0]])
|
||||
candidates = (base + perpendicular * height, base - perpendicular * height)
|
||||
return min(candidates, key=lambda candidate: float(np.linalg.norm(candidate - reference_elbow)))
|
||||
|
||||
|
||||
def update_endpoint_track(
|
||||
track: EndpointTrack,
|
||||
measurement: Measurement | None,
|
||||
frame_idx: int,
|
||||
short_occlusion_frames: int,
|
||||
medium_occlusion_frames: int,
|
||||
) -> tuple[np.ndarray | None, np.ndarray | None, str]:
|
||||
if measurement is not None:
|
||||
measured_wrist = measurement.keypoints[0]
|
||||
measured_shape = measurement.keypoints - measured_wrist
|
||||
if track.wrist is None or track.missed_frames > short_occlusion_frames:
|
||||
track.wrist = measured_wrist.copy()
|
||||
track.velocity = np.zeros(2, dtype=np.float64)
|
||||
track.hand_shape = measured_shape.copy()
|
||||
else:
|
||||
prediction = track.wrist + track.velocity
|
||||
residual = measured_wrist - prediction
|
||||
alpha = 0.78 if measurement.precise else 0.88
|
||||
beta = 0.18 if measurement.precise else 0.24
|
||||
track.wrist = prediction + alpha * residual
|
||||
track.velocity = 0.82 * track.velocity + beta * residual
|
||||
shape_alpha = 0.55 if measurement.precise else 0.7
|
||||
track.hand_shape = (
|
||||
shape_alpha * measured_shape + (1.0 - shape_alpha) * track.hand_shape
|
||||
)
|
||||
track.last_observed_frame = frame_idx
|
||||
track.missed_frames = 0
|
||||
if measurement.source == "global_reassignment":
|
||||
state = "reassigned"
|
||||
elif measurement.source == "temporal_recovery":
|
||||
state = "recovered"
|
||||
elif measurement.precise:
|
||||
state = "observed"
|
||||
elif measurement.provisional:
|
||||
state = "provisional"
|
||||
else:
|
||||
state = "interpolated"
|
||||
return track.wrist.copy(), track.wrist + track.hand_shape, state
|
||||
|
||||
track.missed_frames += 1
|
||||
if track.wrist is None:
|
||||
return None, None, "reachable_long"
|
||||
if track.missed_frames <= short_occlusion_frames:
|
||||
track.wrist = track.wrist + track.velocity
|
||||
track.velocity *= 0.86
|
||||
return track.wrist.copy(), track.wrist + track.hand_shape, "predicted_short"
|
||||
if track.missed_frames <= medium_occlusion_frames:
|
||||
return None, None, "reachable_medium"
|
||||
return None, None, "reachable_long"
|
||||
|
||||
|
||||
def temporal_recovery_measurement(
|
||||
keypoints: np.ndarray,
|
||||
metadata: dict,
|
||||
side: str,
|
||||
bbox: list[float],
|
||||
track: EndpointTrack,
|
||||
medium_occlusion_frames: int,
|
||||
) -> Measurement | None:
|
||||
"""Recover a weak current-frame hand only when history strongly supports it."""
|
||||
if track.wrist is None or track.missed_frames > medium_occlusion_frames:
|
||||
return None
|
||||
if metadata.get("hand_state") not in {"conflict", "inferred"}:
|
||||
return None
|
||||
if not metadata.get("selected_hand_valid", False):
|
||||
return None
|
||||
if float(metadata.get("selected_hand_score", 0.0)) < 2.0:
|
||||
return None
|
||||
link = float(metadata.get("hand_selection", {}).get("link_to_selected_limb", 1.0))
|
||||
if link > 0.08:
|
||||
return None
|
||||
|
||||
start, end = HAND_SLICES[side]
|
||||
hand = np.asarray(keypoints[start:end], dtype=np.float64)
|
||||
if hand.shape != (end - start, 2) or not np.isfinite(hand).all():
|
||||
return None
|
||||
prediction = track.wrist + track.velocity
|
||||
diagonal = bbox_diagonal(bbox)
|
||||
gate = min(0.20, 0.05 + 0.008 * max(track.missed_frames, 1)) * diagonal
|
||||
if float(np.linalg.norm(hand[0] - prediction)) > gate:
|
||||
return None
|
||||
return Measurement(
|
||||
keypoints=hand,
|
||||
precise=False,
|
||||
provisional=True,
|
||||
source="temporal_recovery",
|
||||
quality=float(metadata.get("selected_hand_score", 0.0)),
|
||||
)
|
||||
|
||||
|
||||
def hand_span_px(hand: np.ndarray) -> float:
|
||||
return math.hypot(float(np.ptp(hand[:, 0])), float(np.ptp(hand[:, 1])))
|
||||
|
||||
|
||||
def bbox_area(bbox: list[float]) -> float:
|
||||
return max((bbox[2] - bbox[0]) * (bbox[3] - bbox[1]), 1.0)
|
||||
|
||||
|
||||
def reset_endpoint_track(track: EndpointTrack) -> None:
|
||||
track.wrist = None
|
||||
track.velocity = None
|
||||
track.hand_shape = None
|
||||
track.last_observed_frame = None
|
||||
track.missed_frames = 0
|
||||
track.last_elbow = None
|
||||
track.last_direction = None
|
||||
|
||||
|
||||
def resolve_global_hand_assignments(
|
||||
frame_measurements: dict[tuple[int, str], Measurement | None],
|
||||
tracks: dict[tuple[int, str], EndpointTrack],
|
||||
boxes: list[list[float]],
|
||||
medium_occlusion_frames: int,
|
||||
) -> tuple[dict[tuple[int, str], Measurement | None], dict[tuple[int, str], dict]]:
|
||||
"""Resolve overlapping-person hands before binding them to body sides.
|
||||
|
||||
Large foreground top-down crops may include a smaller background person.
|
||||
The same physical hand can therefore be emitted for both people, or only by
|
||||
the smaller crop even though it belongs to the foreground person. Direct
|
||||
duplicates are kept on the foreground track when their apparent scales
|
||||
agree. A missing foreground side can then reclaim a nearby candidate using
|
||||
its recent hand trajectory and historical hand scale.
|
||||
"""
|
||||
resolved = dict(frame_measurements)
|
||||
events: dict[tuple[int, str], dict] = {}
|
||||
keys = list(resolved)
|
||||
|
||||
# First collapse direct cross-person duplicates. Keep this deliberately
|
||||
# asymmetric: a substantially larger foreground crop must also carry the
|
||||
# substantially larger historical hand shape.
|
||||
for index, first_key in enumerate(keys):
|
||||
first = resolved[first_key]
|
||||
if first is None:
|
||||
continue
|
||||
for second_key in keys[index + 1 :]:
|
||||
second = resolved[second_key]
|
||||
if second is None or first_key[0] == second_key[0]:
|
||||
continue
|
||||
first_hand = first.keypoints
|
||||
second_hand = second.keypoints
|
||||
first_span = hand_span_px(first_hand)
|
||||
second_span = hand_span_px(second_hand)
|
||||
center_distance = float(
|
||||
np.linalg.norm(np.mean(first_hand, axis=0) - np.mean(second_hand, axis=0))
|
||||
)
|
||||
if center_distance > max(70.0, 0.5 * max(first_span, second_span)):
|
||||
continue
|
||||
|
||||
first_area = bbox_area(boxes[first_key[0]])
|
||||
second_area = bbox_area(boxes[second_key[0]])
|
||||
if first_area >= 2.5 * second_area and (
|
||||
first_span >= 1.45 * second_span
|
||||
or first.quality >= second.quality + 0.6
|
||||
):
|
||||
owner_key, released_key = first_key, second_key
|
||||
elif second_area >= 2.5 * first_area and (
|
||||
second_span >= 1.45 * first_span
|
||||
or second.quality >= first.quality + 0.6
|
||||
):
|
||||
owner_key, released_key = second_key, first_key
|
||||
else:
|
||||
continue
|
||||
resolved[released_key] = None
|
||||
reset_endpoint_track(tracks[released_key])
|
||||
events[owner_key] = {
|
||||
"method": "foreground_duplicate_owner",
|
||||
"released_person_id": released_key[0] + 1,
|
||||
"released_side": released_key[1],
|
||||
}
|
||||
events[released_key] = {
|
||||
"method": "reassigned_away",
|
||||
"assigned_person_id": owner_key[0] + 1,
|
||||
"assigned_side": owner_key[1],
|
||||
}
|
||||
|
||||
# Then recover missing foreground hands from another crop. The candidate
|
||||
# must fall inside the target's recent motion gate and be much smaller than
|
||||
# the target's historical foreground hand shape.
|
||||
available_sources = [key for key, value in resolved.items() if value is not None]
|
||||
target_candidates = []
|
||||
for target_key, target_measurement in resolved.items():
|
||||
if target_measurement is not None:
|
||||
continue
|
||||
target_track = tracks[target_key]
|
||||
if (
|
||||
target_track.wrist is None
|
||||
or target_track.hand_shape is None
|
||||
or target_track.missed_frames > medium_occlusion_frames
|
||||
):
|
||||
continue
|
||||
target_area = bbox_area(boxes[target_key[0]])
|
||||
predicted_center = target_track.wrist + np.mean(target_track.hand_shape, axis=0)
|
||||
target_span = hand_span_px(target_track.hand_shape)
|
||||
gate = 0.20 * bbox_diagonal(boxes[target_key[0]])
|
||||
for source_key in available_sources:
|
||||
if source_key[0] == target_key[0]:
|
||||
continue
|
||||
source = resolved[source_key]
|
||||
if source is None or source.source != "model":
|
||||
continue
|
||||
source_area = bbox_area(boxes[source_key[0]])
|
||||
if target_area < 2.5 * source_area:
|
||||
continue
|
||||
source_center = np.mean(source.keypoints, axis=0)
|
||||
source_span = hand_span_px(source.keypoints)
|
||||
distance = float(np.linalg.norm(source_center - predicted_center))
|
||||
if distance > gate or target_span < 1.45 * max(source_span, 1.0):
|
||||
continue
|
||||
target_candidates.append(
|
||||
(distance / gate, target_key, source_key, source_center)
|
||||
)
|
||||
|
||||
used_targets = set()
|
||||
used_sources = set()
|
||||
for _, target_key, source_key, source_center in sorted(target_candidates):
|
||||
if target_key in used_targets or source_key in used_sources:
|
||||
continue
|
||||
target_track = tracks[target_key]
|
||||
shape_center = np.mean(target_track.hand_shape, axis=0)
|
||||
reassigned_wrist = source_center - shape_center
|
||||
reassigned_hand = reassigned_wrist + target_track.hand_shape
|
||||
resolved[target_key] = Measurement(
|
||||
keypoints=reassigned_hand,
|
||||
precise=False,
|
||||
provisional=True,
|
||||
source="global_reassignment",
|
||||
quality=resolved[source_key].quality,
|
||||
)
|
||||
resolved[source_key] = None
|
||||
reset_endpoint_track(tracks[source_key])
|
||||
events[target_key] = {
|
||||
"method": "foreground_temporal_reassignment",
|
||||
"source_person_id": source_key[0] + 1,
|
||||
"source_side": source_key[1],
|
||||
"observed_center": np.round(source_center, 3).tolist(),
|
||||
}
|
||||
events[source_key] = {
|
||||
"method": "reassigned_away",
|
||||
"assigned_person_id": target_key[0] + 1,
|
||||
"assigned_side": target_key[1],
|
||||
}
|
||||
used_targets.add(target_key)
|
||||
used_sources.add(source_key)
|
||||
return resolved, events
|
||||
|
||||
|
||||
def track_direction(track: EndpointTrack) -> np.ndarray:
|
||||
if track.last_direction is not None:
|
||||
return track.last_direction / max(float(np.linalg.norm(track.last_direction)), 1e-6)
|
||||
if track.velocity is not None and float(np.linalg.norm(track.velocity)) > 1e-6:
|
||||
return track.velocity / np.linalg.norm(track.velocity)
|
||||
return np.asarray([1.0, 0.0], dtype=np.float64)
|
||||
|
||||
|
||||
def ellipse_polygon(
|
||||
center: np.ndarray,
|
||||
major_axis: float,
|
||||
minor_axis: float,
|
||||
direction: np.ndarray,
|
||||
point_count: int = 64,
|
||||
) -> np.ndarray:
|
||||
perpendicular = np.asarray([-direction[1], direction[0]], dtype=np.float64)
|
||||
angles = np.linspace(0.0, 2.0 * math.pi, point_count, endpoint=False)
|
||||
return (
|
||||
center
|
||||
+ np.cos(angles)[:, None] * major_axis * direction
|
||||
+ np.sin(angles)[:, None] * minor_axis * perpendicular
|
||||
)
|
||||
|
||||
|
||||
def clip_polygon_from_anchor_to_circle(
|
||||
points: np.ndarray,
|
||||
anchor: np.ndarray,
|
||||
circle_center: np.ndarray,
|
||||
radius: float,
|
||||
) -> np.ndarray:
|
||||
"""Clip radial polygon samples to a shoulder-centered anatomical limit."""
|
||||
clipped = []
|
||||
offset = anchor - circle_center
|
||||
for point in points:
|
||||
if float(np.linalg.norm(point - circle_center)) <= radius:
|
||||
clipped.append(point)
|
||||
continue
|
||||
ray = point - anchor
|
||||
a = float(np.dot(ray, ray))
|
||||
b = 2.0 * float(np.dot(offset, ray))
|
||||
c = float(np.dot(offset, offset) - radius * radius)
|
||||
discriminant = max(b * b - 4.0 * a * c, 0.0)
|
||||
roots = [
|
||||
value
|
||||
for value in (
|
||||
(-b - math.sqrt(discriminant)) / max(2.0 * a, 1e-6),
|
||||
(-b + math.sqrt(discriminant)) / max(2.0 * a, 1e-6),
|
||||
)
|
||||
if 0.0 <= value <= 1.0
|
||||
]
|
||||
clipped.append(anchor + (max(roots) if roots else 0.0) * ray)
|
||||
return np.asarray(clipped, dtype=np.float64)
|
||||
|
||||
|
||||
def medium_motion_region(
|
||||
shoulder: np.ndarray,
|
||||
upper_length: float,
|
||||
forearm_length: float,
|
||||
bbox: list[float],
|
||||
track: EndpointTrack,
|
||||
short_occlusion_frames: int,
|
||||
) -> dict:
|
||||
diagonal = bbox_diagonal(bbox)
|
||||
direction = track_direction(track)
|
||||
hand_shape = (
|
||||
track.hand_shape
|
||||
if track.hand_shape is not None
|
||||
else np.zeros((21, 2), dtype=np.float64)
|
||||
)
|
||||
shape_center = np.mean(hand_shape, axis=0)
|
||||
centered_shape = hand_shape - shape_center
|
||||
perpendicular = np.asarray([-direction[1], direction[0]], dtype=np.float64)
|
||||
hand_major = float(np.max(np.abs(centered_shape @ direction)))
|
||||
hand_minor = float(np.max(np.abs(centered_shape @ perpendicular)))
|
||||
|
||||
extra_frames = max(track.missed_frames - short_occlusion_frames, 1)
|
||||
motion_major = min(0.20, 0.04 + 0.007 * extra_frames) * diagonal
|
||||
motion_minor = min(0.13, 0.03 + 0.0045 * extra_frames) * diagonal
|
||||
major_axis = max(hand_major + motion_major, 0.08 * diagonal)
|
||||
minor_axis = max(hand_minor + motion_minor, 0.06 * diagonal)
|
||||
|
||||
center = track.wrist + shape_center
|
||||
tracked_hand_extent = float(np.max(np.linalg.norm(centered_shape, axis=1)))
|
||||
hand_reach = max(tracked_hand_extent, 0.10 * diagonal)
|
||||
anatomical_reach = upper_length + forearm_length + hand_reach + 0.02 * diagonal
|
||||
shoulder_to_center = center - shoulder
|
||||
center_distance = float(np.linalg.norm(shoulder_to_center))
|
||||
if center_distance > 0.96 * anatomical_reach:
|
||||
center = shoulder + shoulder_to_center / center_distance * 0.96 * anatomical_reach
|
||||
|
||||
polygon = ellipse_polygon(center, major_axis, minor_axis, direction)
|
||||
polygon = clip_polygon_from_anchor_to_circle(
|
||||
polygon,
|
||||
center,
|
||||
shoulder,
|
||||
anatomical_reach,
|
||||
)
|
||||
direction_deg = math.degrees(math.atan2(float(direction[1]), float(direction[0])))
|
||||
return {
|
||||
"geometry": "motion_corridor",
|
||||
"center": np.round(center, 3).tolist(),
|
||||
"predicted_wrist": np.round(track.wrist, 3).tolist(),
|
||||
"major_axis_px": round(major_axis, 3),
|
||||
"minor_axis_px": round(minor_axis, 3),
|
||||
"outer_radius_px": round(major_axis, 3),
|
||||
"direction_deg": round(direction_deg, 3),
|
||||
"max_reach_from_shoulder_px": round(anatomical_reach, 3),
|
||||
"polygon": np.round(polygon, 3).tolist(),
|
||||
"requires_door_intersection": True,
|
||||
"render_full_region": True,
|
||||
"distance_lower_bound_rule": "minimum distance from motion-corridor polygon to door_edge",
|
||||
}
|
||||
|
||||
|
||||
def long_reachable_region(
|
||||
shoulder: np.ndarray,
|
||||
upper_length: float,
|
||||
forearm_length: float,
|
||||
bbox: list[float],
|
||||
track: EndpointTrack,
|
||||
medium_occlusion_frames: int,
|
||||
) -> dict:
|
||||
diagonal = bbox_diagonal(bbox)
|
||||
height = max(bbox[3] - bbox[1], 1.0)
|
||||
arm_reach = upper_length + forearm_length
|
||||
hand_reach = min(0.13 * diagonal, 0.17 * height)
|
||||
safety_margin = 0.025 * diagonal
|
||||
outer_radius = arm_reach + hand_reach + safety_margin
|
||||
inner_radius = max(abs(upper_length - forearm_length) - 0.04 * diagonal, 0.0)
|
||||
if track.last_direction is None:
|
||||
direction_deg = 0.0
|
||||
half_angle_deg = 180.0
|
||||
else:
|
||||
direction = track_direction(track)
|
||||
direction_deg = math.degrees(math.atan2(float(direction[1]), float(direction[0])))
|
||||
half_angle_deg = min(
|
||||
180.0,
|
||||
65.0 + 2.0 * max(track.missed_frames - medium_occlusion_frames, 0),
|
||||
)
|
||||
return {
|
||||
"geometry": "shoulder_sector",
|
||||
"center": np.round(shoulder, 3).tolist(),
|
||||
"inner_radius_px": round(inner_radius, 3),
|
||||
"outer_radius_px": round(outer_radius, 3),
|
||||
"arm_reach_px": round(arm_reach, 3),
|
||||
"hand_reach_px": round(hand_reach, 3),
|
||||
"safety_margin_px": round(safety_margin, 3),
|
||||
"direction_deg": round(direction_deg, 3),
|
||||
"half_angle_deg": round(half_angle_deg, 3),
|
||||
"requires_door_intersection": True,
|
||||
"render_full_region": False,
|
||||
"distance_lower_bound_rule": "intersect shoulder sector with door danger band before alarming",
|
||||
}
|
||||
|
||||
|
||||
def reachable_region(
|
||||
shoulder: np.ndarray,
|
||||
upper_length: float,
|
||||
forearm_length: float,
|
||||
bbox: list[float],
|
||||
track: EndpointTrack,
|
||||
state: str = "reachable_long",
|
||||
short_occlusion_frames: int = 12,
|
||||
medium_occlusion_frames: int = 30,
|
||||
) -> dict:
|
||||
if state == "reachable_medium" and track.wrist is not None:
|
||||
return medium_motion_region(
|
||||
shoulder,
|
||||
upper_length,
|
||||
forearm_length,
|
||||
bbox,
|
||||
track,
|
||||
short_occlusion_frames,
|
||||
)
|
||||
return long_reachable_region(
|
||||
shoulder,
|
||||
upper_length,
|
||||
forearm_length,
|
||||
bbox,
|
||||
track,
|
||||
medium_occlusion_frames,
|
||||
)
|
||||
|
||||
|
||||
def calibrated_bone_lengths(
|
||||
upper_ratio: float,
|
||||
forearm_ratio: float,
|
||||
bbox: list[float],
|
||||
) -> tuple[float, float]:
|
||||
diagonal = bbox_diagonal(bbox)
|
||||
height = max(bbox[3] - bbox[1], 1.0)
|
||||
upper = upper_ratio * diagonal
|
||||
forearm = forearm_ratio * diagonal
|
||||
total = upper + forearm
|
||||
# Keep only a loose sanity cap. The previous 0.55 * bbox-height cap was
|
||||
# unsafe for close, clipped CMS views: a valid projected arm can occupy
|
||||
# almost the full detection height. The ratios are already robust medians,
|
||||
# so this cap only limits catastrophic calibration outliers.
|
||||
maximum_total = 0.95 * diagonal
|
||||
if total > maximum_total:
|
||||
scale = maximum_total / total
|
||||
upper *= scale
|
||||
forearm *= scale
|
||||
return upper, forearm
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Runtime pose tracking helpers."""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
import numpy as np
|
||||
from pose_geometry import HAND_SLICES
|
||||
from pose_fusion import BODY_SIDE_INDICES, PersonTrack
|
||||
from pose_occlusion import EndpointTrack, Measurement, calibrated_bone_lengths, reachable_region, solve_two_bone_elbow, temporal_recovery_measurement, update_endpoint_track
|
||||
|
||||
|
||||
def bbox_iou(a: np.ndarray, b: np.ndarray) -> float:
|
||||
x1 = max(float(a[0]), float(b[0]))
|
||||
y1 = max(float(a[1]), float(b[1]))
|
||||
x2 = min(float(a[2]), float(b[2]))
|
||||
y2 = min(float(a[3]), float(b[3]))
|
||||
intersection = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
|
||||
union = (
|
||||
max(float(a[2] - a[0]), 0.0) * max(float(a[3] - a[1]), 0.0)
|
||||
+ max(float(b[2] - b[0]), 0.0) * max(float(b[3] - b[1]), 0.0)
|
||||
- intersection
|
||||
)
|
||||
return intersection / max(union, 1.0)
|
||||
|
||||
|
||||
class BoxTracker:
|
||||
def __init__(self, max_age: int = 30):
|
||||
self.max_age = max_age
|
||||
self.next_id = 1
|
||||
self.tracks: dict[int, dict] = {}
|
||||
|
||||
def update(
|
||||
self,
|
||||
boxes: list[list[float]],
|
||||
frame_idx: int,
|
||||
image_shape: tuple[int, int],
|
||||
) -> list[int]:
|
||||
height, width = image_shape
|
||||
image_diagonal = math.hypot(width, height)
|
||||
active_ids = [
|
||||
track_id
|
||||
for track_id, track in self.tracks.items()
|
||||
if frame_idx - track["last_frame"] <= self.max_age
|
||||
]
|
||||
self.tracks = {track_id: self.tracks[track_id] for track_id in active_ids}
|
||||
pairs = []
|
||||
for detection_idx, box in enumerate(boxes):
|
||||
box_array = np.asarray(box, dtype=np.float64)
|
||||
center = (box_array[:2] + box_array[2:]) * 0.5
|
||||
for track_id in active_ids:
|
||||
prior = np.asarray(self.tracks[track_id]["box"], dtype=np.float64)
|
||||
prior_center = (prior[:2] + prior[2:]) * 0.5
|
||||
iou = bbox_iou(box_array, prior)
|
||||
center_distance = float(np.linalg.norm(center - prior_center)) / image_diagonal
|
||||
if iou >= 0.05 or center_distance <= 0.18:
|
||||
score = 2.0 * iou + max(0.0, 1.0 - center_distance / 0.18)
|
||||
pairs.append((score, detection_idx, track_id))
|
||||
|
||||
assigned_detections = set()
|
||||
assigned_tracks = set()
|
||||
result: list[int | None] = [None] * len(boxes)
|
||||
for _, detection_idx, track_id in sorted(pairs, reverse=True):
|
||||
if detection_idx in assigned_detections or track_id in assigned_tracks:
|
||||
continue
|
||||
assigned_detections.add(detection_idx)
|
||||
assigned_tracks.add(track_id)
|
||||
result[detection_idx] = track_id
|
||||
|
||||
for detection_idx, box in enumerate(boxes):
|
||||
track_id = result[detection_idx]
|
||||
if track_id is None:
|
||||
track_id = self.next_id
|
||||
self.next_id += 1
|
||||
result[detection_idx] = track_id
|
||||
self.tracks[track_id] = {"box": box, "last_frame": frame_idx}
|
||||
return [int(track_id) for track_id in result]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LivePersonState:
|
||||
fusion: PersonTrack = field(default_factory=PersonTrack)
|
||||
endpoints: dict[str, EndpointTrack] = field(
|
||||
default_factory=lambda: {"left": EndpointTrack(), "right": EndpointTrack()}
|
||||
)
|
||||
bone_samples: dict[str, deque] = field(
|
||||
default_factory=lambda: {
|
||||
"left": deque(maxlen=120),
|
||||
"right": deque(maxlen=120),
|
||||
}
|
||||
)
|
||||
last_frame: int = -1
|
||||
|
||||
|
||||
def bbox_diagonal(bbox: list[float]) -> float:
|
||||
return max(math.hypot(bbox[2] - bbox[0], bbox[3] - bbox[1]), 1.0)
|
||||
|
||||
|
||||
def update_bone_samples(
|
||||
state: LivePersonState,
|
||||
keypoints: np.ndarray,
|
||||
metadata: dict,
|
||||
bbox: list[float],
|
||||
) -> None:
|
||||
diagonal = bbox_diagonal(bbox)
|
||||
for side, (shoulder, elbow, wrist) in BODY_SIDE_INDICES.items():
|
||||
meta = metadata[side]
|
||||
if meta["limb_state"] != "consensus" or not meta["distance_eligible"]:
|
||||
continue
|
||||
upper = float(np.linalg.norm(keypoints[shoulder] - keypoints[elbow])) / diagonal
|
||||
forearm = float(np.linalg.norm(keypoints[elbow] - keypoints[wrist])) / diagonal
|
||||
if 0.04 <= upper <= 0.65 and 0.04 <= forearm <= 0.65:
|
||||
state.bone_samples[side].append((upper, forearm))
|
||||
|
||||
|
||||
def current_bone_ratios(state: LivePersonState, side: str) -> tuple[float, float]:
|
||||
samples = state.bone_samples[side]
|
||||
if len(samples) < 10:
|
||||
opposite = "right" if side == "left" else "left"
|
||||
if len(state.bone_samples[opposite]) >= 10:
|
||||
samples = state.bone_samples[opposite]
|
||||
if not samples:
|
||||
return 0.24, 0.22
|
||||
values = np.asarray(samples, dtype=np.float64)
|
||||
return float(np.median(values[:, 0])), float(np.median(values[:, 1]))
|
||||
|
||||
|
||||
def update_live_v2(
|
||||
frame_idx: int,
|
||||
bbox: list[float],
|
||||
selected_keypoints: np.ndarray,
|
||||
smoothed_keypoints: np.ndarray,
|
||||
fusion_metadata: dict,
|
||||
state: LivePersonState,
|
||||
assigned_measurements: dict[str, Measurement | None] | None,
|
||||
identity_assignments: dict[str, dict] | None,
|
||||
short_occlusion_frames: int,
|
||||
medium_occlusion_frames: int,
|
||||
) -> tuple[np.ndarray, dict]:
|
||||
frame_gap = 0 if state.last_frame < 0 else max(frame_idx - state.last_frame - 1, 0)
|
||||
for endpoint in state.endpoints.values():
|
||||
endpoint.missed_frames += frame_gap
|
||||
state.last_frame = frame_idx
|
||||
update_bone_samples(state, selected_keypoints, fusion_metadata, bbox)
|
||||
|
||||
output = smoothed_keypoints.copy()
|
||||
output_metadata = {}
|
||||
diagonal = bbox_diagonal(bbox)
|
||||
for side, (shoulder_idx, elbow_idx, wrist_idx) in BODY_SIDE_INDICES.items():
|
||||
endpoint = state.endpoints[side]
|
||||
meta = fusion_metadata[side]
|
||||
start, end = HAND_SLICES[side]
|
||||
measurement = (
|
||||
assigned_measurements.get(side)
|
||||
if assigned_measurements is not None
|
||||
else None
|
||||
)
|
||||
if assigned_measurements is None and meta["alarm_eligible"]:
|
||||
measurement = Measurement(
|
||||
keypoints=selected_keypoints[start:end],
|
||||
precise=bool(meta["distance_eligible"]),
|
||||
provisional=not bool(meta["distance_eligible"]),
|
||||
quality=float(meta.get("selected_hand_score", 0.0)),
|
||||
)
|
||||
if measurement is None:
|
||||
measurement = temporal_recovery_measurement(
|
||||
selected_keypoints,
|
||||
meta,
|
||||
side,
|
||||
bbox,
|
||||
endpoint,
|
||||
medium_occlusion_frames,
|
||||
)
|
||||
returning_after_long = (
|
||||
measurement is not None
|
||||
and endpoint.missed_frames > short_occlusion_frames
|
||||
)
|
||||
wrist, hand, endpoint_state = update_endpoint_track(
|
||||
endpoint,
|
||||
measurement,
|
||||
frame_idx,
|
||||
short_occlusion_frames,
|
||||
medium_occlusion_frames,
|
||||
)
|
||||
shoulder = smoothed_keypoints[shoulder_idx]
|
||||
upper_ratio, forearm_ratio = current_bone_ratios(state, side)
|
||||
upper_length, forearm_length = calibrated_bone_lengths(
|
||||
upper_ratio, forearm_ratio, bbox
|
||||
)
|
||||
region = None
|
||||
if wrist is not None and hand is not None:
|
||||
reference_elbow = (
|
||||
selected_keypoints[elbow_idx]
|
||||
if returning_after_long or endpoint.last_elbow is None
|
||||
else endpoint.last_elbow
|
||||
)
|
||||
use_ik = (
|
||||
meta["limb_state"] != "consensus"
|
||||
or endpoint_state
|
||||
in ("provisional", "recovered", "reassigned", "predicted_short")
|
||||
)
|
||||
elbow = (
|
||||
solve_two_bone_elbow(
|
||||
shoulder,
|
||||
wrist,
|
||||
upper_length,
|
||||
forearm_length,
|
||||
reference_elbow,
|
||||
)
|
||||
if use_ik
|
||||
else smoothed_keypoints[elbow_idx]
|
||||
)
|
||||
endpoint.last_elbow = elbow.copy()
|
||||
direction = wrist - shoulder
|
||||
if float(np.linalg.norm(direction)) > 1e-6:
|
||||
endpoint.last_direction = direction / np.linalg.norm(direction)
|
||||
output[shoulder_idx] = shoulder
|
||||
output[elbow_idx] = elbow
|
||||
output[wrist_idx] = wrist
|
||||
output[start:end] = hand
|
||||
uncertainty = (
|
||||
0.025 * diagonal
|
||||
if endpoint_state == "observed"
|
||||
else (0.04 + 0.015 * endpoint.missed_frames) * diagonal
|
||||
)
|
||||
precise = endpoint_state == "observed"
|
||||
else:
|
||||
region = reachable_region(
|
||||
shoulder,
|
||||
upper_length,
|
||||
forearm_length,
|
||||
bbox,
|
||||
endpoint,
|
||||
endpoint_state,
|
||||
short_occlusion_frames,
|
||||
medium_occlusion_frames,
|
||||
)
|
||||
uncertainty = region["outer_radius_px"]
|
||||
precise = False
|
||||
output_metadata[side] = {
|
||||
"state": endpoint_state,
|
||||
"precise_distance": precise,
|
||||
"fast_alarm_candidate": True,
|
||||
"risk_mode": (
|
||||
"precise_hand"
|
||||
if endpoint_state == "observed"
|
||||
else (
|
||||
"motion_corridor_intersection"
|
||||
if endpoint_state == "reachable_medium"
|
||||
else (
|
||||
"door_intersection_only"
|
||||
if endpoint_state == "reachable_long"
|
||||
else (
|
||||
"reassigned_hand"
|
||||
if endpoint_state == "reassigned"
|
||||
else "temporal_hand"
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
"requires_door_intersection": endpoint_state
|
||||
in ("reachable_medium", "reachable_long"),
|
||||
"identity_assignment": (
|
||||
identity_assignments.get(side)
|
||||
if identity_assignments is not None
|
||||
else None
|
||||
),
|
||||
"missed_frames": endpoint.missed_frames,
|
||||
"uncertainty_radius_px": round(float(uncertainty), 3),
|
||||
"bone_lengths_px": {
|
||||
"upper": round(upper_length, 3),
|
||||
"forearm": round(forearm_length, 3),
|
||||
},
|
||||
"reachable_region": region,
|
||||
}
|
||||
return output, output_metadata
|
||||
|
||||
|
||||
def mask_other_people(
|
||||
frame: np.ndarray,
|
||||
boxes: list[list[float]],
|
||||
target_idx: int,
|
||||
) -> np.ndarray:
|
||||
masked = frame.copy()
|
||||
height, width = frame.shape[:2]
|
||||
mask = np.zeros((height, width), dtype=bool)
|
||||
target = boxes[target_idx]
|
||||
target_area = max((target[2] - target[0]) * (target[3] - target[1]), 1.0)
|
||||
for idx, bbox in enumerate(boxes):
|
||||
if idx == target_idx:
|
||||
continue
|
||||
other_area = max((bbox[2] - bbox[0]) * (bbox[3] - bbox[1]), 1.0)
|
||||
# The failure in this video is the small background person being
|
||||
# absorbed by the much larger foreground person's 3:4 pose crop.
|
||||
# Mask only markedly smaller people. Masking a larger occluder while
|
||||
# estimating a small person removes genuine target pixels at overlap.
|
||||
if other_area >= 0.5 * target_area:
|
||||
continue
|
||||
x1, y1, x2, y2 = (int(round(value)) for value in bbox)
|
||||
x1, x2 = max(0, x1), min(width - 1, x2)
|
||||
y1, y2 = max(0, y1), min(height - 1, y2)
|
||||
mask[y1:y2 + 1, x1:x2 + 1] = True
|
||||
|
||||
masked[mask] = (124, 116, 104)
|
||||
return masked
|
||||
@@ -1,683 +0,0 @@
|
||||
"""End-to-end optimized RTMW-L prediction for CMS videos.
|
||||
|
||||
Pipeline:
|
||||
YOLO11s person -> fixed-camera false-positive filters -> lightweight track IDs
|
||||
-> RTMW-L raw/smaller-person-isolated views -> temporal fusion -> causal
|
||||
occlusion-aware V4 global hand assignment. Reachable regions stay in JSON and are hidden in the main
|
||||
comparison video.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from collections import Counter, defaultdict, deque
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from compare_rtmw_ml import HAND_SLICES, draw_pose, summarize
|
||||
from fuse_rtmw_l_temporal import (
|
||||
BODY_SIDE_INDICES,
|
||||
PersonTrack,
|
||||
adaptive_smooth,
|
||||
build_global_hands,
|
||||
draw_fused,
|
||||
fuse_person,
|
||||
)
|
||||
from occlusion_aware_pose_v2 import (
|
||||
EndpointTrack,
|
||||
Measurement,
|
||||
calibrated_bone_lengths,
|
||||
draw_v2,
|
||||
reachable_region,
|
||||
resolve_global_hand_assignments,
|
||||
solve_two_bone_elbow,
|
||||
temporal_recovery_measurement,
|
||||
update_endpoint_track,
|
||||
)
|
||||
from rtmw_l_person_mask_ablation import mask_other_people
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--videos", type=Path, nargs="+", required=True)
|
||||
parser.add_argument("--det-model", type=Path, required=True)
|
||||
parser.add_argument("--pose-model", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--det-device",
|
||||
default="0",
|
||||
help="Ultralytics YOLO device, for example 0, cuda:0, or cpu.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pose-device",
|
||||
default="cpu",
|
||||
help="RTMW ONNX Runtime device: cpu or cuda.",
|
||||
)
|
||||
parser.add_argument("--det-imgsz", type=int, default=960)
|
||||
parser.add_argument("--det-conf", type=float, default=0.30)
|
||||
parser.add_argument("--det-roi-y-max-ratio", type=float, default=1.0)
|
||||
parser.add_argument("--short-occlusion-frames", type=int, default=12)
|
||||
parser.add_argument("--medium-occlusion-frames", type=int, default=30)
|
||||
parser.add_argument("--max-people", type=int, default=3)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def bbox_iou(a: np.ndarray, b: np.ndarray) -> float:
|
||||
x1 = max(float(a[0]), float(b[0]))
|
||||
y1 = max(float(a[1]), float(b[1]))
|
||||
x2 = min(float(a[2]), float(b[2]))
|
||||
y2 = min(float(a[3]), float(b[3]))
|
||||
intersection = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
|
||||
union = (
|
||||
max(float(a[2] - a[0]), 0.0) * max(float(a[3] - a[1]), 0.0)
|
||||
+ max(float(b[2] - b[0]), 0.0) * max(float(b[3] - b[1]), 0.0)
|
||||
- intersection
|
||||
)
|
||||
return intersection / max(union, 1.0)
|
||||
|
||||
|
||||
class BoxTracker:
|
||||
def __init__(self, max_age: int = 30):
|
||||
self.max_age = max_age
|
||||
self.next_id = 1
|
||||
self.tracks: dict[int, dict] = {}
|
||||
|
||||
def update(
|
||||
self,
|
||||
boxes: list[list[float]],
|
||||
frame_idx: int,
|
||||
image_shape: tuple[int, int],
|
||||
) -> list[int]:
|
||||
height, width = image_shape
|
||||
image_diagonal = math.hypot(width, height)
|
||||
active_ids = [
|
||||
track_id
|
||||
for track_id, track in self.tracks.items()
|
||||
if frame_idx - track["last_frame"] <= self.max_age
|
||||
]
|
||||
self.tracks = {track_id: self.tracks[track_id] for track_id in active_ids}
|
||||
pairs = []
|
||||
for detection_idx, box in enumerate(boxes):
|
||||
box_array = np.asarray(box, dtype=np.float64)
|
||||
center = (box_array[:2] + box_array[2:]) * 0.5
|
||||
for track_id in active_ids:
|
||||
prior = np.asarray(self.tracks[track_id]["box"], dtype=np.float64)
|
||||
prior_center = (prior[:2] + prior[2:]) * 0.5
|
||||
iou = bbox_iou(box_array, prior)
|
||||
center_distance = float(np.linalg.norm(center - prior_center)) / image_diagonal
|
||||
if iou >= 0.05 or center_distance <= 0.18:
|
||||
score = 2.0 * iou + max(0.0, 1.0 - center_distance / 0.18)
|
||||
pairs.append((score, detection_idx, track_id))
|
||||
|
||||
assigned_detections = set()
|
||||
assigned_tracks = set()
|
||||
result: list[int | None] = [None] * len(boxes)
|
||||
for _, detection_idx, track_id in sorted(pairs, reverse=True):
|
||||
if detection_idx in assigned_detections or track_id in assigned_tracks:
|
||||
continue
|
||||
assigned_detections.add(detection_idx)
|
||||
assigned_tracks.add(track_id)
|
||||
result[detection_idx] = track_id
|
||||
|
||||
for detection_idx, box in enumerate(boxes):
|
||||
track_id = result[detection_idx]
|
||||
if track_id is None:
|
||||
track_id = self.next_id
|
||||
self.next_id += 1
|
||||
result[detection_idx] = track_id
|
||||
self.tracks[track_id] = {"box": box, "last_frame": frame_idx}
|
||||
return [int(track_id) for track_id in result]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LivePersonState:
|
||||
fusion: PersonTrack = field(default_factory=PersonTrack)
|
||||
endpoints: dict[str, EndpointTrack] = field(
|
||||
default_factory=lambda: {"left": EndpointTrack(), "right": EndpointTrack()}
|
||||
)
|
||||
bone_samples: dict[str, deque] = field(
|
||||
default_factory=lambda: {
|
||||
"left": deque(maxlen=120),
|
||||
"right": deque(maxlen=120),
|
||||
}
|
||||
)
|
||||
last_frame: int = -1
|
||||
|
||||
|
||||
def bbox_diagonal(bbox: list[float]) -> float:
|
||||
return max(math.hypot(bbox[2] - bbox[0], bbox[3] - bbox[1]), 1.0)
|
||||
|
||||
|
||||
def update_bone_samples(
|
||||
state: LivePersonState,
|
||||
keypoints: np.ndarray,
|
||||
metadata: dict,
|
||||
bbox: list[float],
|
||||
) -> None:
|
||||
diagonal = bbox_diagonal(bbox)
|
||||
for side, (shoulder, elbow, wrist) in BODY_SIDE_INDICES.items():
|
||||
meta = metadata[side]
|
||||
if meta["limb_state"] != "consensus" or not meta["distance_eligible"]:
|
||||
continue
|
||||
upper = float(np.linalg.norm(keypoints[shoulder] - keypoints[elbow])) / diagonal
|
||||
forearm = float(np.linalg.norm(keypoints[elbow] - keypoints[wrist])) / diagonal
|
||||
if 0.04 <= upper <= 0.65 and 0.04 <= forearm <= 0.65:
|
||||
state.bone_samples[side].append((upper, forearm))
|
||||
|
||||
|
||||
def current_bone_ratios(state: LivePersonState, side: str) -> tuple[float, float]:
|
||||
samples = state.bone_samples[side]
|
||||
if len(samples) < 10:
|
||||
opposite = "right" if side == "left" else "left"
|
||||
if len(state.bone_samples[opposite]) >= 10:
|
||||
samples = state.bone_samples[opposite]
|
||||
if not samples:
|
||||
return 0.24, 0.22
|
||||
values = np.asarray(samples, dtype=np.float64)
|
||||
return float(np.median(values[:, 0])), float(np.median(values[:, 1]))
|
||||
|
||||
|
||||
def update_live_v2(
|
||||
frame_idx: int,
|
||||
bbox: list[float],
|
||||
selected_keypoints: np.ndarray,
|
||||
smoothed_keypoints: np.ndarray,
|
||||
fusion_metadata: dict,
|
||||
state: LivePersonState,
|
||||
assigned_measurements: dict[str, Measurement | None] | None,
|
||||
identity_assignments: dict[str, dict] | None,
|
||||
short_occlusion_frames: int,
|
||||
medium_occlusion_frames: int,
|
||||
) -> tuple[np.ndarray, dict]:
|
||||
frame_gap = 0 if state.last_frame < 0 else max(frame_idx - state.last_frame - 1, 0)
|
||||
for endpoint in state.endpoints.values():
|
||||
endpoint.missed_frames += frame_gap
|
||||
state.last_frame = frame_idx
|
||||
update_bone_samples(state, selected_keypoints, fusion_metadata, bbox)
|
||||
|
||||
output = smoothed_keypoints.copy()
|
||||
output_metadata = {}
|
||||
diagonal = bbox_diagonal(bbox)
|
||||
for side, (shoulder_idx, elbow_idx, wrist_idx) in BODY_SIDE_INDICES.items():
|
||||
endpoint = state.endpoints[side]
|
||||
meta = fusion_metadata[side]
|
||||
start, end = HAND_SLICES[side]
|
||||
measurement = (
|
||||
assigned_measurements.get(side)
|
||||
if assigned_measurements is not None
|
||||
else None
|
||||
)
|
||||
if assigned_measurements is None and meta["alarm_eligible"]:
|
||||
measurement = Measurement(
|
||||
keypoints=selected_keypoints[start:end],
|
||||
precise=bool(meta["distance_eligible"]),
|
||||
provisional=not bool(meta["distance_eligible"]),
|
||||
quality=float(meta.get("selected_hand_score", 0.0)),
|
||||
)
|
||||
if measurement is None:
|
||||
measurement = temporal_recovery_measurement(
|
||||
selected_keypoints,
|
||||
meta,
|
||||
side,
|
||||
bbox,
|
||||
endpoint,
|
||||
medium_occlusion_frames,
|
||||
)
|
||||
returning_after_long = (
|
||||
measurement is not None
|
||||
and endpoint.missed_frames > short_occlusion_frames
|
||||
)
|
||||
wrist, hand, endpoint_state = update_endpoint_track(
|
||||
endpoint,
|
||||
measurement,
|
||||
frame_idx,
|
||||
short_occlusion_frames,
|
||||
medium_occlusion_frames,
|
||||
)
|
||||
shoulder = smoothed_keypoints[shoulder_idx]
|
||||
upper_ratio, forearm_ratio = current_bone_ratios(state, side)
|
||||
upper_length, forearm_length = calibrated_bone_lengths(
|
||||
upper_ratio, forearm_ratio, bbox
|
||||
)
|
||||
region = None
|
||||
if wrist is not None and hand is not None:
|
||||
reference_elbow = (
|
||||
selected_keypoints[elbow_idx]
|
||||
if returning_after_long or endpoint.last_elbow is None
|
||||
else endpoint.last_elbow
|
||||
)
|
||||
use_ik = (
|
||||
meta["limb_state"] != "consensus"
|
||||
or endpoint_state
|
||||
in ("provisional", "recovered", "reassigned", "predicted_short")
|
||||
)
|
||||
elbow = (
|
||||
solve_two_bone_elbow(
|
||||
shoulder,
|
||||
wrist,
|
||||
upper_length,
|
||||
forearm_length,
|
||||
reference_elbow,
|
||||
)
|
||||
if use_ik
|
||||
else smoothed_keypoints[elbow_idx]
|
||||
)
|
||||
endpoint.last_elbow = elbow.copy()
|
||||
direction = wrist - shoulder
|
||||
if float(np.linalg.norm(direction)) > 1e-6:
|
||||
endpoint.last_direction = direction / np.linalg.norm(direction)
|
||||
output[shoulder_idx] = shoulder
|
||||
output[elbow_idx] = elbow
|
||||
output[wrist_idx] = wrist
|
||||
output[start:end] = hand
|
||||
uncertainty = (
|
||||
0.025 * diagonal
|
||||
if endpoint_state == "observed"
|
||||
else (0.04 + 0.015 * endpoint.missed_frames) * diagonal
|
||||
)
|
||||
precise = endpoint_state == "observed"
|
||||
else:
|
||||
region = reachable_region(
|
||||
shoulder,
|
||||
upper_length,
|
||||
forearm_length,
|
||||
bbox,
|
||||
endpoint,
|
||||
endpoint_state,
|
||||
short_occlusion_frames,
|
||||
medium_occlusion_frames,
|
||||
)
|
||||
uncertainty = region["outer_radius_px"]
|
||||
precise = False
|
||||
output_metadata[side] = {
|
||||
"state": endpoint_state,
|
||||
"precise_distance": precise,
|
||||
"fast_alarm_candidate": True,
|
||||
"risk_mode": (
|
||||
"precise_hand"
|
||||
if endpoint_state == "observed"
|
||||
else (
|
||||
"motion_corridor_intersection"
|
||||
if endpoint_state == "reachable_medium"
|
||||
else (
|
||||
"door_intersection_only"
|
||||
if endpoint_state == "reachable_long"
|
||||
else (
|
||||
"reassigned_hand"
|
||||
if endpoint_state == "reassigned"
|
||||
else "temporal_hand"
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
"requires_door_intersection": endpoint_state
|
||||
in ("reachable_medium", "reachable_long"),
|
||||
"identity_assignment": (
|
||||
identity_assignments.get(side)
|
||||
if identity_assignments is not None
|
||||
else None
|
||||
),
|
||||
"missed_frames": endpoint.missed_frames,
|
||||
"uncertainty_radius_px": round(float(uncertainty), 3),
|
||||
"bone_lengths_px": {
|
||||
"upper": round(upper_length, 3),
|
||||
"forearm": round(forearm_length, 3),
|
||||
},
|
||||
"reachable_region": region,
|
||||
}
|
||||
return output, output_metadata
|
||||
|
||||
|
||||
def filter_person_boxes(
|
||||
result,
|
||||
width: int,
|
||||
height: int,
|
||||
max_people: int,
|
||||
) -> tuple[list[list[float]], list[float], Counter]:
|
||||
boxes = result.boxes.xyxy.cpu().numpy()
|
||||
scores = result.boxes.conf.cpu().numpy()
|
||||
rejected = Counter()
|
||||
kept = []
|
||||
for box, score in zip(boxes, scores):
|
||||
center_x = float(box[0] + box[2]) * 0.5
|
||||
box_height = float(box[3] - box[1])
|
||||
if center_x > 0.80 * width:
|
||||
rejected["right_reflection_roi"] += 1
|
||||
continue
|
||||
if box_height < 0.20 * height:
|
||||
rejected["too_short"] += 1
|
||||
continue
|
||||
kept.append((box.tolist(), float(score)))
|
||||
kept.sort(key=lambda item: item[1], reverse=True)
|
||||
if len(kept) > max_people:
|
||||
rejected["over_max_people"] += len(kept) - max_people
|
||||
kept = kept[:max_people]
|
||||
kept.sort(key=lambda item: item[0][0])
|
||||
return [item[0] for item in kept], [item[1] for item in kept], rejected
|
||||
|
||||
|
||||
def process_video(
|
||||
video_path: Path,
|
||||
output_dir: Path,
|
||||
detector: YOLO,
|
||||
pose: RTMPose,
|
||||
det_device: str,
|
||||
det_imgsz: int,
|
||||
det_conf: float,
|
||||
det_roi_y_max_ratio: float,
|
||||
short_occlusion_frames: int,
|
||||
medium_occlusion_frames: int,
|
||||
max_people: int,
|
||||
video_label: str,
|
||||
) -> dict:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
fps = float(cap.get(cv2.CAP_PROP_FPS))
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
writer = cv2.VideoWriter(
|
||||
str(output_dir / "raw_temporal_occlusion_v4.mp4"),
|
||||
cv2.VideoWriter_fourcc(*"mp4v"),
|
||||
fps,
|
||||
(1920, 360),
|
||||
)
|
||||
|
||||
box_tracker = BoxTracker(max_age=30)
|
||||
people: dict[int, LivePersonState] = {}
|
||||
records = []
|
||||
detection_counts = Counter()
|
||||
rejection_counts = Counter()
|
||||
state_counts = Counter()
|
||||
timings = defaultdict(list)
|
||||
isolated_inferences = 0
|
||||
frame_idx = 0
|
||||
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
started = time.perf_counter()
|
||||
detection_height = max(1, min(height, round(height * det_roi_y_max_ratio)))
|
||||
detection = detector.predict(
|
||||
frame[:detection_height],
|
||||
imgsz=det_imgsz,
|
||||
conf=det_conf,
|
||||
iou=0.30,
|
||||
classes=[0],
|
||||
device=det_device,
|
||||
verbose=False,
|
||||
)[0]
|
||||
timings["detector_ms"].append((time.perf_counter() - started) * 1000.0)
|
||||
boxes, confidences, rejected = filter_person_boxes(
|
||||
detection, width, height, max_people
|
||||
)
|
||||
rejection_counts.update(rejected)
|
||||
detection_counts[len(boxes)] += 1
|
||||
track_ids = box_tracker.update(boxes, frame_idx, (height, width))
|
||||
|
||||
if boxes:
|
||||
started = time.perf_counter()
|
||||
raw_keypoints, raw_scores = pose(frame, bboxes=boxes)
|
||||
timings["raw_pose_ms"].append((time.perf_counter() - started) * 1000.0)
|
||||
isolated_keypoints = raw_keypoints.copy()
|
||||
isolated_scores = raw_scores.copy()
|
||||
started = time.perf_counter()
|
||||
for person_idx, bbox in enumerate(boxes):
|
||||
target_area = max((bbox[2] - bbox[0]) * (bbox[3] - bbox[1]), 1.0)
|
||||
needs_isolation = any(
|
||||
idx != person_idx
|
||||
and max((other[2] - other[0]) * (other[3] - other[1]), 1.0)
|
||||
< 0.5 * target_area
|
||||
for idx, other in enumerate(boxes)
|
||||
)
|
||||
if not needs_isolation:
|
||||
continue
|
||||
masked = mask_other_people(frame, boxes, person_idx)
|
||||
kpts, scores = pose(masked, bboxes=[bbox])
|
||||
isolated_keypoints[person_idx] = kpts[0]
|
||||
isolated_scores[person_idx] = scores[0]
|
||||
isolated_inferences += 1
|
||||
timings["isolated_extra_ms"].append((time.perf_counter() - started) * 1000.0)
|
||||
else:
|
||||
raw_keypoints = np.empty((0, 133, 2), dtype=np.float64)
|
||||
raw_scores = np.empty((0, 133), dtype=np.float64)
|
||||
isolated_keypoints = raw_keypoints.copy()
|
||||
isolated_scores = raw_scores.copy()
|
||||
|
||||
selected_list = []
|
||||
smoothed_list = []
|
||||
fusion_meta_list = []
|
||||
person_states = []
|
||||
for person_idx, (bbox, track_id) in enumerate(zip(boxes, track_ids)):
|
||||
state = people.setdefault(track_id, LivePersonState())
|
||||
selected, fusion_meta = fuse_person(
|
||||
raw_keypoints[person_idx],
|
||||
raw_scores[person_idx],
|
||||
isolated_keypoints[person_idx],
|
||||
isolated_scores[person_idx],
|
||||
bbox,
|
||||
state.fusion,
|
||||
)
|
||||
smoothed = adaptive_smooth(selected, bbox, state.fusion)
|
||||
selected_list.append(selected)
|
||||
smoothed_list.append(smoothed)
|
||||
fusion_meta_list.append(fusion_meta)
|
||||
person_states.append(state)
|
||||
|
||||
frame_measurements = {}
|
||||
endpoint_tracks = {}
|
||||
for person_idx, (selected, fusion_meta, state) in enumerate(
|
||||
zip(selected_list, fusion_meta_list, person_states)
|
||||
):
|
||||
for side, (start, end) in HAND_SLICES.items():
|
||||
meta = fusion_meta[side]
|
||||
frame_measurements[(person_idx, side)] = (
|
||||
Measurement(
|
||||
keypoints=selected[start:end],
|
||||
precise=bool(meta["distance_eligible"]),
|
||||
provisional=not bool(meta["distance_eligible"]),
|
||||
quality=float(meta.get("selected_hand_score", 0.0)),
|
||||
)
|
||||
if meta["alarm_eligible"]
|
||||
else None
|
||||
)
|
||||
endpoint_tracks[(person_idx, side)] = state.endpoints[side]
|
||||
frame_measurements, assignment_events = resolve_global_hand_assignments(
|
||||
frame_measurements,
|
||||
endpoint_tracks,
|
||||
boxes,
|
||||
medium_occlusion_frames,
|
||||
)
|
||||
|
||||
v2_list = []
|
||||
v2_meta_list = []
|
||||
for person_idx, (bbox, selected, smoothed, fusion_meta, state) in enumerate(
|
||||
zip(
|
||||
boxes,
|
||||
selected_list,
|
||||
smoothed_list,
|
||||
fusion_meta_list,
|
||||
person_states,
|
||||
)
|
||||
):
|
||||
v2, v2_meta = update_live_v2(
|
||||
frame_idx,
|
||||
bbox,
|
||||
selected,
|
||||
smoothed,
|
||||
fusion_meta,
|
||||
state,
|
||||
{
|
||||
side: frame_measurements[(person_idx, side)]
|
||||
for side in ("left", "right")
|
||||
},
|
||||
{
|
||||
side: assignment_events.get((person_idx, side))
|
||||
for side in ("left", "right")
|
||||
},
|
||||
short_occlusion_frames,
|
||||
medium_occlusion_frames,
|
||||
)
|
||||
v2_list.append(v2)
|
||||
v2_meta_list.append(v2_meta)
|
||||
for side in ("left", "right"):
|
||||
state_counts[f"{v2_meta[side]['state']}"] += 1
|
||||
|
||||
global_hands = build_global_hands(selected_list, fusion_meta_list)
|
||||
records.append(
|
||||
{
|
||||
"frame_index": frame_idx,
|
||||
"timestamp_ms": round(frame_idx / fps * 1000.0, 3),
|
||||
"track_ids": track_ids,
|
||||
"boxes": np.round(np.asarray(boxes), 3).tolist() if boxes else [],
|
||||
"det_confidences": [round(value, 4) for value in confidences],
|
||||
"raw_keypoints": np.round(raw_keypoints, 3).tolist(),
|
||||
"temporal_selected_keypoints": np.round(np.asarray(selected_list), 3).tolist(),
|
||||
"temporal_keypoints": np.round(np.asarray(smoothed_list), 3).tolist(),
|
||||
"temporal_metadata": fusion_meta_list,
|
||||
"v2_keypoints": np.round(np.asarray(v2_list), 3).tolist(),
|
||||
"v2_metadata": v2_meta_list,
|
||||
"global_hands": global_hands,
|
||||
}
|
||||
)
|
||||
|
||||
raw_panel = draw_pose(
|
||||
frame,
|
||||
boxes,
|
||||
raw_keypoints,
|
||||
f"{video_label} RTMW-L raw | frame {frame_idx}",
|
||||
)
|
||||
temporal_panel = draw_fused(
|
||||
frame,
|
||||
boxes,
|
||||
smoothed_list,
|
||||
fusion_meta_list,
|
||||
f"{video_label} temporal fusion | frame {frame_idx}",
|
||||
)
|
||||
v2_panel = draw_v2(
|
||||
frame,
|
||||
boxes,
|
||||
np.asarray(v2_list),
|
||||
v2_meta_list,
|
||||
f"{video_label} occlusion V4 | frame {frame_idx}",
|
||||
show_reachable=False,
|
||||
)
|
||||
panels = [
|
||||
cv2.resize(panel, (640, 360), interpolation=cv2.INTER_AREA)
|
||||
for panel in (raw_panel, temporal_panel, v2_panel)
|
||||
]
|
||||
writer.write(np.hstack(panels))
|
||||
if frame_idx == 0 and video_path.suffix.lower() in {
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".bmp",
|
||||
".webp",
|
||||
}:
|
||||
cv2.imwrite(str(output_dir / "raw_pose.jpg"), raw_panel)
|
||||
cv2.imwrite(str(output_dir / "spatial_fusion.jpg"), temporal_panel)
|
||||
cv2.imwrite(str(output_dir / "occlusion_v2.jpg"), v2_panel)
|
||||
panel_width = 960
|
||||
panel_height = round(height * panel_width / width)
|
||||
image_panels = [
|
||||
cv2.resize(
|
||||
panel,
|
||||
(panel_width, panel_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
for panel in (raw_panel, temporal_panel, v2_panel)
|
||||
]
|
||||
cv2.imwrite(
|
||||
str(output_dir / "raw_spatial_occlusion_v2.jpg"),
|
||||
np.hstack(image_panels),
|
||||
)
|
||||
if frame_idx % 100 == 0:
|
||||
print(f"{video_label}: processed frame {frame_idx}", flush=True)
|
||||
frame_idx += 1
|
||||
|
||||
cap.release()
|
||||
writer.release()
|
||||
with (output_dir / "predictions.jsonl").open("w", encoding="utf-8") as handle:
|
||||
for record in records:
|
||||
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
summary = {
|
||||
"occlusion_algorithm": "v4_global_assignment",
|
||||
"video": str(video_path.resolve()),
|
||||
"frames": len(records),
|
||||
"fps": round(fps, 3),
|
||||
"resolution": [width, height],
|
||||
"person_filter": {
|
||||
"center_x_max_ratio": 0.80,
|
||||
"minimum_box_height_ratio": 0.20,
|
||||
"max_people": max_people,
|
||||
},
|
||||
"detector_settings": {
|
||||
"imgsz": det_imgsz,
|
||||
"confidence": det_conf,
|
||||
"roi_y_max_ratio": det_roi_y_max_ratio,
|
||||
},
|
||||
"filtered_person_count_frames": dict(sorted(detection_counts.items())),
|
||||
"rejected_detection_counts": dict(sorted(rejection_counts.items())),
|
||||
"track_ids_created": box_tracker.next_id - 1,
|
||||
"isolated_pose_inferences": isolated_inferences,
|
||||
"short_occlusion_frames": short_occlusion_frames,
|
||||
"medium_occlusion_frames": medium_occlusion_frames,
|
||||
"v2_state_counts": dict(sorted(state_counts.items())),
|
||||
"timing_ms_cpu": {
|
||||
name: summarize(values[5:] if len(values) > 5 else values)
|
||||
for name, values in timings.items()
|
||||
},
|
||||
"output_note": (
|
||||
"Main video hides reachable-region arcs. Reachable-region geometry remains in "
|
||||
"predictions.jsonl for later door-distance lower-bound calculation."
|
||||
),
|
||||
}
|
||||
with (output_dir / "summary.json").open("w", encoding="utf-8") as handle:
|
||||
json.dump(summary, handle, ensure_ascii=False, indent=2)
|
||||
return summary
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from rtmlib import RTMPose
|
||||
from ultralytics import YOLO
|
||||
args = parse_args()
|
||||
detector = YOLO(str(args.det_model))
|
||||
pose = RTMPose(
|
||||
str(args.pose_model),
|
||||
model_input_size=(288, 384),
|
||||
backend="onnxruntime",
|
||||
device=args.pose_device,
|
||||
)
|
||||
all_summaries = {}
|
||||
for video_idx, video_path in enumerate(args.videos, start=1):
|
||||
label = f"video{video_idx}"
|
||||
output_dir = args.output / label
|
||||
all_summaries[label] = process_video(
|
||||
video_path,
|
||||
output_dir,
|
||||
detector,
|
||||
pose,
|
||||
args.det_device,
|
||||
args.det_imgsz,
|
||||
args.det_conf,
|
||||
args.det_roi_y_max_ratio,
|
||||
args.short_occlusion_frames,
|
||||
args.medium_occlusion_frames,
|
||||
args.max_people,
|
||||
label,
|
||||
)
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
with (args.output / "summary.json").open("w", encoding="utf-8") as handle:
|
||||
json.dump(all_summaries, handle, ensure_ascii=False, indent=2)
|
||||
print(json.dumps(all_summaries, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Test whether other people inside a top-down crop cause RTMW-L mix-ups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from compare_rtmw_ml import (
|
||||
draw_pose,
|
||||
model_disagreement,
|
||||
summarize,
|
||||
temporal_metrics,
|
||||
write_montage,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video", type=Path, required=True)
|
||||
parser.add_argument("--baseline-jsonl", type=Path, required=True)
|
||||
parser.add_argument("--model-l", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def mask_other_people(
|
||||
frame: np.ndarray,
|
||||
boxes: list[list[float]],
|
||||
target_idx: int,
|
||||
) -> np.ndarray:
|
||||
masked = frame.copy()
|
||||
height, width = frame.shape[:2]
|
||||
mask = np.zeros((height, width), dtype=bool)
|
||||
target = boxes[target_idx]
|
||||
target_area = max((target[2] - target[0]) * (target[3] - target[1]), 1.0)
|
||||
for idx, bbox in enumerate(boxes):
|
||||
if idx == target_idx:
|
||||
continue
|
||||
other_area = max((bbox[2] - bbox[0]) * (bbox[3] - bbox[1]), 1.0)
|
||||
# The failure in this video is the small background person being
|
||||
# absorbed by the much larger foreground person's 3:4 pose crop.
|
||||
# Mask only markedly smaller people. Masking a larger occluder while
|
||||
# estimating a small person removes genuine target pixels at overlap.
|
||||
if other_area >= 0.5 * target_area:
|
||||
continue
|
||||
x1, y1, x2, y2 = (int(round(value)) for value in bbox)
|
||||
x1, x2 = max(0, x1), min(width - 1, x2)
|
||||
y1, y2 = max(0, y1), min(height - 1, y2)
|
||||
mask[y1:y2 + 1, x1:x2 + 1] = True
|
||||
|
||||
masked[mask] = (124, 116, 104)
|
||||
return masked
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from rtmlib import RTMPose
|
||||
args = parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
baseline = [
|
||||
json.loads(line)
|
||||
for line in args.baseline_jsonl.read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
pose_l = RTMPose(
|
||||
str(args.model_l), model_input_size=(288, 384), backend="onnxruntime", device="cpu"
|
||||
)
|
||||
|
||||
cap = cv2.VideoCapture(str(args.video))
|
||||
fps = float(cap.get(cv2.CAP_PROP_FPS))
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
writer = cv2.VideoWriter(
|
||||
str(args.output / "rtmw_l_raw_vs_person_masked.mp4"),
|
||||
cv2.VideoWriter_fourcc(*"mp4v"),
|
||||
fps,
|
||||
(width, height // 2),
|
||||
)
|
||||
|
||||
comparison: list[dict] = []
|
||||
timings: list[float] = []
|
||||
frame_idx = 0
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
baseline_record = baseline[frame_idx]
|
||||
boxes = baseline_record["boxes"][:2]
|
||||
masked_keypoints = []
|
||||
masked_scores = []
|
||||
started = time.perf_counter()
|
||||
for person_idx, bbox in enumerate(boxes):
|
||||
masked_frame = mask_other_people(frame, boxes, person_idx)
|
||||
keypoints, scores = pose_l(masked_frame, bboxes=[bbox])
|
||||
masked_keypoints.append(keypoints[0])
|
||||
masked_scores.append(scores[0])
|
||||
timings.append((time.perf_counter() - started) * 1000.0)
|
||||
masked_keypoints_array = np.asarray(masked_keypoints)
|
||||
|
||||
raw_keypoints = np.asarray(baseline_record["L"]["keypoints"][:2])
|
||||
comparison.append(
|
||||
{
|
||||
"frame_index": frame_idx,
|
||||
"boxes": boxes,
|
||||
"M": {"keypoints": raw_keypoints.tolist()},
|
||||
"L": {"keypoints": np.round(masked_keypoints_array, 3).tolist()},
|
||||
"masked_raw_scores": np.round(np.asarray(masked_scores), 4).tolist(),
|
||||
}
|
||||
)
|
||||
|
||||
left = draw_pose(frame, boxes, raw_keypoints, f"RTMW-L raw | frame {frame_idx}")
|
||||
right = draw_pose(
|
||||
frame,
|
||||
boxes,
|
||||
masked_keypoints_array,
|
||||
f"RTMW-L mask other person | frame {frame_idx}",
|
||||
)
|
||||
left = cv2.resize(left, (width // 2, height // 2), interpolation=cv2.INTER_AREA)
|
||||
right = cv2.resize(right, (width // 2, height // 2), interpolation=cv2.INTER_AREA)
|
||||
writer.write(np.hstack([left, right]))
|
||||
if frame_idx % 50 == 0:
|
||||
print(f"processed frame {frame_idx}", flush=True)
|
||||
frame_idx += 1
|
||||
|
||||
cap.release()
|
||||
writer.release()
|
||||
|
||||
with (args.output / "rtmw_l_raw_vs_person_masked.jsonl").open(
|
||||
"w", encoding="utf-8"
|
||||
) as handle:
|
||||
for record in comparison:
|
||||
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
disagreement, worst_frames = model_disagreement(comparison)
|
||||
fixed_frames = [0, 60, 120, 132, 136, 180, 240, 252, 300, 360, 411]
|
||||
montage_frames = sorted(set(fixed_frames + worst_frames[:8]))
|
||||
write_montage(
|
||||
args.video,
|
||||
comparison,
|
||||
montage_frames,
|
||||
args.output / "rtmw_l_raw_vs_person_masked_montage.jpg",
|
||||
titles=("RTMW-L raw", "RTMW-L smaller-person masked"),
|
||||
)
|
||||
|
||||
summary = {
|
||||
"frames": len(comparison),
|
||||
"labels": {
|
||||
"M": "RTMW-L raw crop",
|
||||
"L": "RTMW-L with markedly smaller people masked",
|
||||
},
|
||||
"timing_ms_two_people_cpu": summarize(timings[5:]),
|
||||
"temporal_metrics_normalized_to_yolo_box": temporal_metrics(comparison),
|
||||
"position_change_after_masking": disagreement,
|
||||
"largest_change_frames": worst_frames,
|
||||
"montage_frames": montage_frames,
|
||||
}
|
||||
with (args.output / "summary.json").open("w", encoding="utf-8") as handle:
|
||||
json.dump(summary, handle, ensure_ascii=False, indent=2)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,390 +0,0 @@
|
||||
"""Lightweight causal door-risk inference using only YOLO11s and RTMW-L."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
PACKAGE_DIR = Path(__file__).resolve().parents[1]
|
||||
PIPELINE_DIR = Path(__file__).resolve().parent
|
||||
for import_dir in (PIPELINE_DIR, PACKAGE_DIR):
|
||||
if str(import_dir) not in sys.path:
|
||||
sys.path.insert(0, str(import_dir))
|
||||
|
||||
from door_mask_risk import DoorMaskSequence, DoorRiskEvaluator # noqa: E402
|
||||
from door_zone_risk import ( # noqa: E402
|
||||
DoorZoneAssigner,
|
||||
DoorZoneConfig,
|
||||
build_door_risks,
|
||||
)
|
||||
from live_pose_engine import LivePoseEngine # noqa: E402
|
||||
|
||||
|
||||
def parse_image_size(value: str) -> int | tuple[int, int]:
|
||||
parts = value.lower().replace(",", "x").split("x")
|
||||
if len(parts) == 1:
|
||||
return int(parts[0])
|
||||
if len(parts) == 2:
|
||||
return int(parts[0]), int(parts[1])
|
||||
raise argparse.ArgumentTypeError(
|
||||
"image size must be one integer or HEIGHTxWIDTH, for example 960 or 544x960"
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--video", type=Path, required=True)
|
||||
door_source = parser.add_mutually_exclusive_group(required=True)
|
||||
door_source.add_argument("--door-mask-dir", type=Path)
|
||||
door_source.add_argument(
|
||||
"--constant-door-mask",
|
||||
type=Path,
|
||||
help="One X-AnyLabeling JSON or raster mask reused for every video frame.",
|
||||
)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--det-model", type=Path, default=PACKAGE_DIR / "models" / "yolo11s.onnx"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pose-model", type=Path, default=PACKAGE_DIR / "models" / "rtmw-l-384.onnx"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pose-jsonl",
|
||||
type=Path,
|
||||
help="Diagnostic replay input; bypasses live YOLO/RTMW model execution.",
|
||||
)
|
||||
parser.add_argument("--det-device", default="0")
|
||||
parser.add_argument("--pose-device", default="cuda")
|
||||
parser.add_argument(
|
||||
"--pose-provider",
|
||||
choices=("cuda", "tensorrt"),
|
||||
default="cuda",
|
||||
help="ONNX Runtime provider for RTMW.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pose-trt-cache",
|
||||
type=Path,
|
||||
default=PACKAGE_DIR / "models" / "rtmw_l_trt_cache",
|
||||
)
|
||||
parser.add_argument("--det-imgsz", type=parse_image_size, default=960)
|
||||
parser.add_argument("--det-conf", type=float, default=0.30)
|
||||
parser.add_argument("--det-roi-y-max-ratio", type=float, default=1.0)
|
||||
parser.add_argument("--max-people", type=int, default=3)
|
||||
parser.add_argument(
|
||||
"--door-mask-format",
|
||||
choices=("auto", "xanylabeling", "raster"),
|
||||
default="auto",
|
||||
)
|
||||
parser.add_argument("--door-label", action="append")
|
||||
parser.add_argument("--door-frame-offset", type=int, default=0)
|
||||
parser.add_argument("--require-door-mask", action="store_true")
|
||||
parser.add_argument("--risk-warning-relative", type=float, default=0.30)
|
||||
parser.add_argument("--risk-release-relative", type=float, default=0.45)
|
||||
parser.add_argument(
|
||||
"--active-door",
|
||||
choices=("front", "rear", "both", "none"),
|
||||
default="both",
|
||||
help="Door(s) whose CAN state is 0. Use front/rear for deployment simulation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--door-zone-config",
|
||||
type=Path,
|
||||
default=PACKAGE_DIR / "annotations" / "door_zone_multifeature.json",
|
||||
help="Pose-primary fixed-camera front/rear calibration JSON.",
|
||||
)
|
||||
parser.add_argument("--max-frames", type=int, default=0)
|
||||
parser.add_argument("--write-video", action="store_true")
|
||||
parser.add_argument("--progress-every", type=int, default=100)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_pose_records(path: Path) -> dict[int, dict]:
|
||||
records = {}
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if not line.strip():
|
||||
continue
|
||||
record = json.loads(line)
|
||||
records[int(record["frame_index"])] = record
|
||||
return records
|
||||
|
||||
|
||||
def percentile(values: list[float], p: float) -> float | None:
|
||||
return round(float(np.percentile(values, p)), 3) if values else None
|
||||
|
||||
|
||||
def timing_stats(values: list[float]) -> dict:
|
||||
return {
|
||||
"count": len(values),
|
||||
"total": round(float(np.sum(values)), 3) if values else 0.0,
|
||||
"mean": round(float(np.mean(values)), 3) if values else None,
|
||||
"median": percentile(values, 50),
|
||||
"p95": percentile(values, 95),
|
||||
}
|
||||
|
||||
|
||||
def visualization_skeletons(record: dict) -> list[dict]:
|
||||
"""Return the exact fused keypoints and metadata consumed by draw_fused."""
|
||||
|
||||
boxes = record.get("boxes", [])
|
||||
track_ids = [int(value) for value in record.get("track_ids", [])]
|
||||
keypoints = record.get("temporal_selected_keypoints", [])
|
||||
metadata = record.get("temporal_metadata", [])
|
||||
if not (len(boxes) == len(keypoints) == len(metadata)):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"track_id": track_ids[position] if position < len(track_ids) else position + 1,
|
||||
"person_index": position,
|
||||
"layout": "coco_wholebody_133",
|
||||
"keypoints": person_keypoints,
|
||||
"metadata": metadata[position],
|
||||
}
|
||||
for position, person_keypoints in enumerate(keypoints)
|
||||
]
|
||||
|
||||
|
||||
def render_frame(
|
||||
frame: np.ndarray,
|
||||
record: dict,
|
||||
frame_risk: dict,
|
||||
door_mask: np.ndarray | None,
|
||||
door_assignments: list[dict],
|
||||
) -> np.ndarray:
|
||||
canvas = frame.copy()
|
||||
boxes = record.get("boxes", [])
|
||||
keypoints = record.get("temporal_selected_keypoints", [])
|
||||
metadata = record.get("temporal_metadata", [])
|
||||
if len(boxes) == len(keypoints) == len(metadata) and boxes:
|
||||
from fuse_rtmw_l_temporal import draw_fused
|
||||
|
||||
canvas = draw_fused(canvas, boxes, keypoints, metadata, "YOLO + RTMW risk")
|
||||
else:
|
||||
for position, box in enumerate(boxes):
|
||||
x1, y1, x2, y2 = [int(round(float(value))) for value in box[:4]]
|
||||
cv2.rectangle(canvas, (x1, y1), (x2, y2), (0, 220, 255), 2)
|
||||
cv2.putText(
|
||||
canvas,
|
||||
f"P{position + 1}",
|
||||
(x1 + 4, max(22, y1 + 24)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.7,
|
||||
(0, 220, 255),
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
zone_colors = {
|
||||
"front": (0, 220, 255),
|
||||
"rear": (255, 180, 0),
|
||||
"transition": (0, 140, 255),
|
||||
"unassigned": (150, 150, 150),
|
||||
}
|
||||
for assignment in door_assignments:
|
||||
position = int(assignment["person_index"])
|
||||
if not 0 <= position < len(boxes):
|
||||
continue
|
||||
box = boxes[position]
|
||||
x1, y1 = int(round(float(box[0]))), int(round(float(box[1])))
|
||||
zone = str(assignment["door_zone"])
|
||||
cv2.putText(
|
||||
canvas,
|
||||
f"T{assignment['track_id']} {zone.upper()}",
|
||||
(max(0, x1 + 4), max(24, y1 - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.68,
|
||||
zone_colors.get(zone, (160, 160, 160)),
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
if door_mask is not None and np.any(door_mask):
|
||||
overlay = np.zeros_like(canvas)
|
||||
overlay[door_mask > 0] = (255, 180, 0)
|
||||
canvas = cv2.addWeighted(canvas, 1.0, overlay, 0.22, 0)
|
||||
|
||||
score = frame_risk.get("risk_score")
|
||||
score_text = "N/A" if score is None else f"{float(score):.3f}"
|
||||
level = str(frame_risk.get("risk_level", "unavailable"))
|
||||
color = (0, 0, 255) if level == "danger" else (0, 180, 255)
|
||||
if level in ("clear", "unavailable", "inactive", "unknown"):
|
||||
color = (40, 210, 80) if level == "clear" else (160, 160, 160)
|
||||
cv2.rectangle(canvas, (12, 55), (490, 95), (0, 0, 0), -1)
|
||||
cv2.putText(
|
||||
canvas,
|
||||
f"{frame_risk.get('risk_type', 'DOOR_RISK')} score={score_text} level={level}",
|
||||
(24, 83),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.68,
|
||||
color,
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
return canvas
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
if args.pose_provider == "tensorrt" and not args.pose_device.lower().startswith("cuda"):
|
||||
raise ValueError("--pose-provider tensorrt requires --pose-device cuda")
|
||||
|
||||
capture = cv2.VideoCapture(str(args.video.resolve()))
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(f"Cannot open video: {args.video}")
|
||||
input_fps = float(capture.get(cv2.CAP_PROP_FPS)) or 30.0
|
||||
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
|
||||
risk_evaluator = DoorRiskEvaluator(
|
||||
DoorMaskSequence(
|
||||
args.door_mask_dir,
|
||||
mask_format=args.door_mask_format,
|
||||
labels=tuple(args.door_label or ("door", "车门")),
|
||||
frame_offset=args.door_frame_offset,
|
||||
require_every_frame=args.require_door_mask,
|
||||
constant_mask=args.constant_door_mask,
|
||||
),
|
||||
warning_relative=args.risk_warning_relative,
|
||||
release_relative=args.risk_release_relative,
|
||||
)
|
||||
zone_assigner = DoorZoneAssigner(DoorZoneConfig.load(args.door_zone_config))
|
||||
print(
|
||||
f"Door masks: {risk_evaluator.sequence.source_format} "
|
||||
+ (
|
||||
f"(constant: {risk_evaluator.sequence.constant_path})"
|
||||
if risk_evaluator.sequence.constant_path is not None
|
||||
else f"({len(risk_evaluator.sequence.paths)} indexed frames)"
|
||||
)
|
||||
)
|
||||
|
||||
pose_records = load_pose_records(args.pose_jsonl) if args.pose_jsonl else None
|
||||
load_started = time.perf_counter()
|
||||
pose_engine = None if pose_records is not None else LivePoseEngine(args)
|
||||
model_load_seconds = time.perf_counter() - load_started
|
||||
|
||||
writer = None
|
||||
if args.write_video:
|
||||
writer = cv2.VideoWriter(
|
||||
str(args.output / "rtmw_risk_overlay.mp4"),
|
||||
cv2.VideoWriter_fourcc(*"mp4v"),
|
||||
input_fps,
|
||||
(width, height),
|
||||
)
|
||||
|
||||
frame_latencies = []
|
||||
pose_latencies = []
|
||||
risk_latencies = []
|
||||
render_latencies = []
|
||||
started_all = time.perf_counter()
|
||||
frame_index = 0
|
||||
output_path = args.output / "causal_results.jsonl"
|
||||
with output_path.open("w", encoding="utf-8") as output_handle:
|
||||
while True:
|
||||
if args.max_frames > 0 and frame_index >= args.max_frames:
|
||||
break
|
||||
ok, frame = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
frame_started = time.perf_counter()
|
||||
pose_started = time.perf_counter()
|
||||
if pose_records is None:
|
||||
record = pose_engine.infer(frame, frame_index)
|
||||
else:
|
||||
record = pose_records.get(frame_index)
|
||||
if record is None:
|
||||
raise RuntimeError(f"Pose JSONL has no record for frame {frame_index}")
|
||||
pose_latencies.append((time.perf_counter() - pose_started) * 1000)
|
||||
|
||||
risk_started = time.perf_counter()
|
||||
assignments = zone_assigner.update(
|
||||
frame_index, record, (height, width)
|
||||
)
|
||||
raw_risk, door_mask = risk_evaluator.evaluate(
|
||||
frame_index, record, (height, width)
|
||||
)
|
||||
door_risks, frame_risk = build_door_risks(
|
||||
raw_risk, assignments, args.active_door
|
||||
)
|
||||
risk_latencies.append((time.perf_counter() - risk_started) * 1000)
|
||||
output_record = {
|
||||
"frame_index": frame_index,
|
||||
"track_ids": record.get("track_ids", []),
|
||||
"boxes": record.get("boxes", []),
|
||||
"skeletons": visualization_skeletons(record),
|
||||
"active_door": args.active_door,
|
||||
"door_assignments": assignments,
|
||||
"door_risks": door_risks,
|
||||
"risk": frame_risk,
|
||||
"ungated_risk": raw_risk,
|
||||
}
|
||||
output_handle.write(json.dumps(output_record, ensure_ascii=False) + "\n")
|
||||
|
||||
if writer is not None:
|
||||
render_started = time.perf_counter()
|
||||
writer.write(
|
||||
render_frame(frame, record, frame_risk, door_mask, assignments)
|
||||
)
|
||||
render_latencies.append((time.perf_counter() - render_started) * 1000)
|
||||
|
||||
frame_latencies.append((time.perf_counter() - frame_started) * 1000)
|
||||
if args.progress_every > 0 and frame_index % args.progress_every == 0:
|
||||
recent = frame_latencies[-min(len(frame_latencies), args.progress_every) :]
|
||||
print(
|
||||
f"frame={frame_index} recent_ms={np.mean(recent):.1f} "
|
||||
f"risk={frame_risk['risk_level']}",
|
||||
flush=True,
|
||||
)
|
||||
frame_index += 1
|
||||
|
||||
capture.release()
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
elapsed = time.perf_counter() - started_all
|
||||
summary = {
|
||||
"mode": "yolo_rtmw_door_risk_multifeature_v9_10",
|
||||
"video": str(args.video.resolve()),
|
||||
"pose_source": "jsonl_diagnostic" if pose_records is not None else "resident_yolo_rtmw",
|
||||
"models_loaded": [] if pose_records is not None else ["YOLO11s", "RTMW-L"],
|
||||
"edgetam_loaded": False,
|
||||
"risk_region_policy": (
|
||||
"RTMW alarm-eligible hand hulls; YOLO body box only when a person "
|
||||
"has no usable RTMW hand region"
|
||||
),
|
||||
"frames": frame_index,
|
||||
"input_fps": round(input_fps, 3),
|
||||
"elapsed_seconds": round(elapsed, 3),
|
||||
"throughput_fps": round(frame_index / elapsed, 3) if elapsed else None,
|
||||
"model_load_seconds": round(model_load_seconds, 3),
|
||||
"latency_ms": {
|
||||
"total": timing_stats(frame_latencies),
|
||||
"pose": timing_stats(pose_latencies),
|
||||
"risk": timing_stats(risk_latencies),
|
||||
"render": timing_stats(render_latencies),
|
||||
},
|
||||
"write_video": args.write_video,
|
||||
"active_door": args.active_door,
|
||||
"door_zone": zone_assigner.summary(),
|
||||
"door_risk": risk_evaluator.summary(),
|
||||
}
|
||||
if pose_engine is not None:
|
||||
summary["pose"] = {
|
||||
"provider": args.pose_provider,
|
||||
"isolated_inferences": pose_engine.isolated_inferences,
|
||||
"timing_ms": {
|
||||
key: timing_stats(values) for key, values in pose_engine.timings.items()
|
||||
},
|
||||
}
|
||||
(args.output / "summary.json").write_text(
|
||||
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Runtime visualization helpers."""
|
||||
from __future__ import annotations
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def percentile(values: list[float], p: float) -> float | None:
|
||||
return round(float(np.percentile(values, p)), 3) if values else None
|
||||
|
||||
|
||||
def timing_stats(values: list[float]) -> dict:
|
||||
return {
|
||||
"count": len(values),
|
||||
"total": round(float(np.sum(values)), 3) if values else 0.0,
|
||||
"mean": round(float(np.mean(values)), 3) if values else None,
|
||||
"median": percentile(values, 50),
|
||||
"p95": percentile(values, 95),
|
||||
}
|
||||
|
||||
|
||||
def visualization_skeletons(record: dict) -> list[dict]:
|
||||
"""Return the exact fused keypoints and metadata consumed by draw_fused."""
|
||||
|
||||
boxes = record.get("boxes", [])
|
||||
track_ids = [int(value) for value in record.get("track_ids", [])]
|
||||
keypoints = record.get("temporal_selected_keypoints", [])
|
||||
metadata = record.get("temporal_metadata", [])
|
||||
if not (len(boxes) == len(keypoints) == len(metadata)):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"track_id": track_ids[position] if position < len(track_ids) else position + 1,
|
||||
"person_index": position,
|
||||
"layout": "coco_wholebody_133",
|
||||
"keypoints": person_keypoints,
|
||||
"metadata": metadata[position],
|
||||
}
|
||||
for position, person_keypoints in enumerate(keypoints)
|
||||
]
|
||||
|
||||
|
||||
def render_frame(
|
||||
frame: np.ndarray,
|
||||
record: dict,
|
||||
frame_risk: dict,
|
||||
door_mask: np.ndarray | None,
|
||||
door_assignments: list[dict],
|
||||
) -> np.ndarray:
|
||||
canvas = frame.copy()
|
||||
boxes = record.get("boxes", [])
|
||||
keypoints = record.get("temporal_selected_keypoints", [])
|
||||
metadata = record.get("temporal_metadata", [])
|
||||
if len(boxes) == len(keypoints) == len(metadata) and boxes:
|
||||
from pose_fusion import draw_fused
|
||||
|
||||
canvas = draw_fused(canvas, boxes, keypoints, metadata, "YOLO + RTMW risk")
|
||||
else:
|
||||
for position, box in enumerate(boxes):
|
||||
x1, y1, x2, y2 = [int(round(float(value))) for value in box[:4]]
|
||||
cv2.rectangle(canvas, (x1, y1), (x2, y2), (0, 220, 255), 2)
|
||||
cv2.putText(
|
||||
canvas,
|
||||
f"P{position + 1}",
|
||||
(x1 + 4, max(22, y1 + 24)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.7,
|
||||
(0, 220, 255),
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
zone_colors = {
|
||||
"front": (0, 220, 255),
|
||||
"rear": (255, 180, 0),
|
||||
"transition": (0, 140, 255),
|
||||
"unassigned": (150, 150, 150),
|
||||
}
|
||||
for assignment in door_assignments:
|
||||
position = int(assignment["person_index"])
|
||||
if not 0 <= position < len(boxes):
|
||||
continue
|
||||
box = boxes[position]
|
||||
x1, y1 = int(round(float(box[0]))), int(round(float(box[1])))
|
||||
zone = str(assignment["door_zone"])
|
||||
cv2.putText(
|
||||
canvas,
|
||||
f"T{assignment['track_id']} {zone.upper()}",
|
||||
(max(0, x1 + 4), max(24, y1 - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.68,
|
||||
zone_colors.get(zone, (160, 160, 160)),
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
if door_mask is not None and np.any(door_mask):
|
||||
overlay = np.zeros_like(canvas)
|
||||
overlay[door_mask > 0] = (255, 180, 0)
|
||||
canvas = cv2.addWeighted(canvas, 1.0, overlay, 0.22, 0)
|
||||
|
||||
score = frame_risk.get("risk_score")
|
||||
score_text = "N/A" if score is None else f"{float(score):.3f}"
|
||||
level = str(frame_risk.get("risk_level", "unavailable"))
|
||||
color = (0, 0, 255) if level == "danger" else (0, 180, 255)
|
||||
if level in ("clear", "unavailable", "inactive", "unknown"):
|
||||
color = (40, 210, 80) if level == "clear" else (160, 160, 160)
|
||||
cv2.rectangle(canvas, (12, 55), (490, 95), (0, 0, 0), -1)
|
||||
cv2.putText(
|
||||
canvas,
|
||||
f"{frame_risk.get('risk_type', 'DOOR_RISK')} score={score_text} level={level}",
|
||||
(24, 83),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.68,
|
||||
color,
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
return canvas
|
||||
+4
-3
@@ -16,11 +16,12 @@ import cv2
|
||||
import numpy as np
|
||||
ROOT=Path(__file__).resolve().parent
|
||||
sys.path.insert(0,str(ROOT/'pipeline'))
|
||||
from native_backend import NativeBackend, NativePoseEngine
|
||||
from native_backend import NativeBackend
|
||||
from pose_engine import NativePoseEngine
|
||||
from latest_result import LatestResult, result_to_dict
|
||||
from door_mask_risk import DoorMaskSequence, DoorRiskEvaluator
|
||||
from door_zone_risk import DoorZoneConfig, DoorZoneAssigner, build_door_risks
|
||||
from rtmw_risk_pipeline import render_frame, visualization_skeletons, timing_stats
|
||||
from visualization import render_frame, visualization_skeletons, timing_stats
|
||||
|
||||
|
||||
def select_active_door(mode, snapshot, side='left', timeout_ms=1000):
|
||||
@@ -121,7 +122,7 @@ class Preview:
|
||||
def parse_args(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--video',type=Path,help='Diagnostic video replay through the same TensorRT/risk chain');p.add_argument('--camera',default='/dev/video5');p.add_argument('--width',type=int,default=1920);p.add_argument('--height',type=int,default=1536);p.add_argument('--fps',type=int,default=30)
|
||||
p.add_argument('--engines',type=Path,default=ROOT/'models/engines')
|
||||
p.add_argument('--engines',type=Path,default=ROOT/'orin/models/engines' if (ROOT/'orin').is_dir() else ROOT/'models/engines')
|
||||
p.add_argument('--constant-door-mask',type=Path,default=ROOT/'annotations/constant_door_mask.json')
|
||||
p.add_argument('--door-zone-config',type=Path,default=ROOT/'annotations/door_zone_multifeature.json')
|
||||
p.add_argument('--active-door',choices=['can','front','rear','both','none'],default='can')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Use the previously deployed 0920 drivers; do not force module compatibility.
|
||||
# Initialize the camera using the packaged Orin drivers.
|
||||
set -euo pipefail
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
driver_dir="${DRIVER_DIR:-$root/../vision-anti-pinch-0920/orin/bin/drivers}"
|
||||
driver_dir="${DRIVER_DIR:-$root/orin/bin/drivers}"
|
||||
device="${1:-/dev/video5}"
|
||||
kernel="$(uname -r)"
|
||||
for driver in max9295 max9296 sgx-yuv-gmsl2; do
|
||||
|
||||
Reference in New Issue
Block a user