Trim use-can to buildable runtime sources and standalone Orin package

This commit is contained in:
zmv
2026-09-23 19:17:17 +08:00
parent 9aadf9cbda
commit 0f609612a6
108 changed files with 2365 additions and 35388 deletions
+10 -31
View File
@@ -1,41 +1,20 @@
# Python caches and local environments # Generated build/cache/runtime files
/build*/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.venv/
venv/
# Build outputs (keep the supplied vendor libraries in lib/)
build/
/build-*/
*.o *.o
*.a *.a
/vision-anti-pinch .venv/
venv/
# Models and test media
models/
*.engine
*.onnx
*.pt
*.mp4
*.avi
# Runtime outputs
logs/
outputs/ outputs/
out/ logs/
frames/
*.log *.log
*.pid *.pid
*.mp4
# Local credentials and legacy SSH authentication helper *.avi
/models/
*.onnx
# Local credentials
.env .env
.env.* .env.*
!.env.example !.env.example
ssh_expect.exp
# The checked-in Orin deployment bundle intentionally includes its runtime assets.
!/orin/build/
!/orin/build/libvision_native.so
!/orin/models/
!/orin/models/engines/
!/orin/models/engines/*.engine
-9
View File
@@ -1,9 +0,0 @@
# 分支说明
| 分支 | 内容 |
|---|---|
| main | CMS v9.10 原始 YOLO + RTMW + risk 部署包,入口 run_risk.py,含 ONNX 模型 |
| use-can | 0923 V4L2/CAN + TensorRT + CMS 风险集成,提供最新结果变量、可选可视化及 orin/run.sh 可运行包 |
| inference-only | 原 main 的纯 C++ TensorRT 摄像头推理版本,保留原提交 26c5bfb88ddf9919ea46efcf5b12df6e09ebdfd9 |
main 和 use-can 均在原 main 历史上追加提交;原 main 历史未改写。
+6 -6
View File
@@ -9,10 +9,10 @@ find_package(OpenCV REQUIRED COMPONENTS core imgproc)
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
find_path(TRT_INCLUDE NvInfer.h PATH_SUFFIXES aarch64-linux-gnu REQUIRED) find_path(TRT_INCLUDE NvInfer.h PATH_SUFFIXES aarch64-linux-gnu REQUIRED)
find_library(TRT_LIB nvinfer REQUIRED) find_library(TRT_LIB nvinfer REQUIRED)
add_library(vision_native SHARED native/native_api.cpp v4l2_capture.cpp can_comm.cpp add_library(vision_native SHARED native/native_api.cpp native/v4l2_capture.cpp native/can_comm.cpp
accelerator/src/trt_engine.cpp accelerator/src/pose_engine.cu native/trt_engine.cpp native/pose_engine.cu
accelerator/src/gpu_preprocess.cu accelerator/src/uyvy_converter.cu accelerator/src/detector.cpp) native/gpu_preprocess.cu native/uyvy_converter.cu native/detector.cpp)
target_include_directories(vision_native PRIVATE . include accelerator/include ${TRT_INCLUDE} ${OpenCV_INCLUDE_DIRS}) target_include_directories(vision_native PRIVATE native/include ${TRT_INCLUDE} ${OpenCV_INCLUDE_DIRS})
target_link_libraries(vision_native PRIVATE ${TRT_LIB} ${OpenCV_LIBS} Threads::Threads CUDA::cudart CUDA::nppc CUDA::nppig CUDA::nppidei CUDA::nppicc target_link_libraries(vision_native PRIVATE ${TRT_LIB} ${OpenCV_LIBS} Threads::Threads CUDA::cudart CUDA::nppc CUDA::nppig CUDA::nppidei CUDA::nppicc
"${CMAKE_SOURCE_DIR}/lib/libusbcanfd.so" "${CMAKE_SOURCE_DIR}/lib/libusb-1.0.so") "${CMAKE_SOURCE_DIR}/orin/lib/libusbcanfd.so" "${CMAKE_SOURCE_DIR}/orin/lib/libusb-1.0.so")
set_target_properties(vision_native PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "$ORIGIN/../lib;/usr/local/cuda/lib64") set_target_properties(vision_native PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "$ORIGIN/../lib;$ORIGIN/../orin/lib;/usr/local/cuda/lib64")
+3 -5
View File
@@ -1,11 +1,9 @@
.PHONY: all run test baseline clean .PHONY: all run package clean
all: all:
./scripts/build.sh ./scripts/build.sh
run: all run: all
./run_camera.sh ./run_camera.sh
test: package: all
python3 -m unittest discover -s tests -v ./scripts/package_orin.sh
baseline:
$(MAKE) -f Makefile.baseline
clean: clean:
cmake --build build --target clean cmake --build build --target clean
-71
View File
@@ -1,71 +0,0 @@
# Makefile - V4L2 MMAP 取图 demo (OpenCV + CAN 收发)
#
# 用法:
# make # 编译
# make clean # 清理
# make run # 编译并运行 (取 100 帧)
# make capture # 编译并取 5 帧保存到 ./out
# make no-can # 同 capture, 但关闭 CAN 收发
#
# 说明: 本 Makefile 在 Orin 上本地编译。
# CAN 模块依赖 include/zcan.h + lib/libusbcanfd.so + lib/libusb-1.0.so
# CAN 收发默认开启, 程序运行时加 --no-can 关闭
# ---- 工具链 ----
CXX ?= g++
CXXFLAGS ?= -std=c++17 -O3 -Wall -Wextra -Wno-unused-parameter -Wno-unused-result
LDFLAGS ?=
# ---- OpenCV 检测 (优先 opencv4, 回退 opencv) ----
OPENCV_PC := $(shell pkg-config --exists opencv4 && echo opencv4 || echo opencv)
OPENCV_CFLAGS := $(shell pkg-config --cflags $(OPENCV_PC))
OPENCV_LIBS := $(shell pkg-config --libs $(OPENCV_PC))
# ---- 本地头文件/库目录 (zcan.h, libusbcanfd.so, libusb-1.0.so) ----
INC_DIR := include
LIB_DIR := lib
IDIRS := -I$(INC_DIR) $(OPENCV_CFLAGS)
LDIRS := -L$(LIB_DIR)
RPATH := -Wl,-rpath,$$(pwd)/$(LIB_DIR)
CXXFLAGS += $(IDIRS)
LDLIBS += $(OPENCV_LIBS) -lm -lpthread -lusbcanfd -lusb-1.0
# ---- 目标与源文件 ----
BIN := vision-anti-pinch
SOURCES := main.cpp v4l2_capture.cpp can_comm.cpp
HEADERS := v4l2_capture.h can_comm.h $(INC_DIR)/zcan.h
OBJECTS := $(SOURCES:.cpp=.o)
# ---- 默认目标 ----
.PHONY: all clean run capture
all: $(BIN)
$(BIN): $(OBJECTS)
$(CXX) $(LDFLAGS) -o $@ $^ $(LDIRS) $(RPATH) $(LDLIBS)
# ---- 依赖规则 (头文件改动则全部重编) ----
$(OBJECTS): $(HEADERS)
%.o: %.cpp
$(CXX) $(CXXFLAGS) -c -o $@ $<
# ---- 便捷目标 ----
run: $(BIN)
./$(BIN) -d /dev/video5 -n 100 -v
capture: $(BIN)
mkdir -p out
./$(BIN) -d /dev/video5 -n 100 -s 5 -t jpg -o ./out
# CAN 收发默认开启, 与 capture 相同
can: capture
# 关闭 CAN 收发的取图
no-can: $(BIN)
mkdir -p out
./$(BIN) -d /dev/video5 -n 100 -s 5 -t jpg -o ./out --no-can -v
clean:
rm -f $(OBJECTS) $(BIN)
-61
View File
@@ -1,61 +0,0 @@
{
"baseline": "orin@192.168.195.100:/home/orin/vision-anti-pinch",
"baseline_sha256": [
"a6f884d241b3b345fbdc3bbb2d0119467f6a01d6828786a7d33d7f97ad101f6e main.cpp",
"ba7ce790ec4b43403fd6b9ccc4693148414b7314060c29d9f9f92dee931509cf can_comm.cpp",
"5aaeaba6f0f25a6202ff2cf61c626b61bda2350f5bd9b36e36c828b5b8fec994 can_comm.h",
"f5c6658eb3e8362569c18d528fd4c8a2e311e5f779d9687e8ff10b79a11dc5b6 v4l2_capture.cpp",
"dd7a6f92e4d3335368691dd587715cec3d24816e9a96d44ff11c91296f5082eb v4l2_capture.h",
"65b16fe3d4549ad218679f510afe4049ad0f197912832060daaca13acc0f5f4f Makefile"
],
"risk_source": "/home/thor/文档/cms_v9_10_yolo_rtmw_risk_deploy_20260923",
"risk_pipeline": {
"occlusion_aware_pose_v2.py": {
"source_sha256": "81088b8bc072162bec13f57c65eb0d396e6d1e1074a83f92bcf29e393e27e11f",
"integrated_sha256": "81088b8bc072162bec13f57c65eb0d396e6d1e1074a83f92bcf29e393e27e11f"
},
"fuse_rtmw_l_temporal.py": {
"source_sha256": "bc74a400ce5c5608c2fa49a69ccd8b41ec8633cd6a8239d9d594b8d9e5c3732a",
"integrated_sha256": "bc74a400ce5c5608c2fa49a69ccd8b41ec8633cd6a8239d9d594b8d9e5c3732a"
},
"rtmw_l_person_mask_ablation.py": {
"source_sha256": "d983c9431237afcd7a786c6c4610be6d57a8b4d9ea8f2e9d1889fc0b0ca1d5df",
"integrated_sha256": "bf8bb754cd841a52b4410f958a651b005de36ac4d17cc0992fbe1c90ed30caca"
},
"door_mask_risk.py": {
"source_sha256": "6d6d14a972bc4e5e9f8256514169e73ba0392a301cacd1221eaa26146aad00d2",
"integrated_sha256": "6d6d14a972bc4e5e9f8256514169e73ba0392a301cacd1221eaa26146aad00d2"
},
"live_pose_engine.py": {
"source_sha256": "a15baaedf5a25b9896ff898fc1a095388483dc52b1b7000b56d5506ff73bf67e",
"integrated_sha256": "cc8ea36d543896cd90f7a00822f06fc67771a1811859a4087995ac372df30276"
},
"predict_optimized_pose_videos.py": {
"source_sha256": "36d203f257444e57f890cdc3826dad0c7a46725b938e5f8507e357a70ae2cef9",
"integrated_sha256": "059e2a67c17ce63354986d3242a5d361103ee4241c5391f18140eeb19e47c939"
},
"door_zone_risk.py": {
"source_sha256": "46d70e1e90a1f36d544b7492ef1fbf08821377a1c460a6683f42d4d7006055c7",
"integrated_sha256": "46d70e1e90a1f36d544b7492ef1fbf08821377a1c460a6683f42d4d7006055c7"
},
"rtmw_risk_pipeline.py": {
"source_sha256": "1bb58d851ba5d684527b8d1e1940db6241d2b0b2862687fa3b151899a65320d2",
"integrated_sha256": "1bb58d851ba5d684527b8d1e1940db6241d2b0b2862687fa3b151899a65320d2"
},
"compare_rtmw_ml.py": {
"source_sha256": "5de11436bb888f2f1c4c0e7ad0853e3864193fe2625cc65294b40683ebe8729a",
"integrated_sha256": "fd3ae92255abf95ef037a1060ee75897f9705b4040f9394c3130f6ab860667e1"
}
},
"engines_sha256": {
"rtmw_l_384.engine": "ebcd09e8c2e35008adbb0c1d222e580b17e0df1c8093df3481ba3374cd9a8174",
"yolo11s.engine": "f6a65b926ac1144484f4aa93cd26d7997d32d62310ab93fe867f2bd4412222e9"
},
"engine_origin": "orin@192.168.195.100:/home/orin/vision-anti-pinch-0920/orin/engines",
"can_mapping_user_confirmed": [
"left_front",
"right_front",
"left_rear",
"right_rear"
]
}
-330
View File
@@ -1,330 +0,0 @@
# CMS v9.10 YOLO + RTMW 门区风险部署包
该目录是 v9.10 的精简部署版本,只保留运行 `YOLO + RTMW + risk` 所需的代码、模型、标定文件和依赖清单。历史推理结果、研发测试、标注网页、参考图片、缓存与离线分析入口均未放入部署包。
## 1. 功能链路
```text
视频帧
-> YOLO11s 人体检测与轨迹 ID
-> RTMW-L COCO-WholeBody 133 点关键点
-> 手臂/手部时序融合
-> 骨骼位置和尺度为主、人体框为辅的前后门归属
-> CAN 活跃门过滤
-> 手部区域(无有效手部时使用人体框兜底)
-> 与固定车门危险区 mask 计算二维风险
-> causal_results.jsonl
```
本版本不包含深度估计和 EdgeTAM。输出是二维图像风险,不是物理距离,也不是安全认证结论。
## 2. 目录结构
```text
run_risk.py 主入口
requirements-realtime-gpu.txt CUDA 推理依赖
requirements-tensorrt-windows.txt Windows TensorRT 可选依赖
models/
yolo11s.onnx 人体检测模型
rtmw-l-384.onnx WholeBody 关键点模型
annotations/
constant_door_mask.json 当前固定机位车门危险区
door_zone_multifeature.json 当前前门/后门多特征标定
pipeline/ 推理、融合、门区和风险计算代码
```
`annotations` 中的两个 JSON 都与摄像头机位相关。当前参数来自研发临时机位;换成实车机位后必须重新标定和验证,不能直接将临时机位结论作为量产参数。
## 3. 环境安装
推荐 Python 3.10~3.12 和 NVIDIA CUDA GPU。先安装与目标机 CUDA 或 JetPack 匹配的 PyTorch,再安装其余依赖:
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install -r requirements-realtime-gpu.txt
```
不要同时安装 CPU 版 `onnxruntime` 和 `onnxruntime-gpu`。Jetson Orin 应优先使用 NVIDIA 针对对应 JetPack 发布的 PyTorch/torchvision,而不是强制使用桌面 wheel。
如需在 Windows 上使用 RTMW TensorRT Execution Provider,再执行:
```powershell
pip install -r requirements-tensorrt-windows.txt
```
## 4. 运行
### 4.1 固定车门危险区
以后门 CAN=0、前门未进入电吸范围为例:
```powershell
python run_risk.py `
--video D:\data\segment_008.mp4 `
--constant-door-mask annotations\constant_door_mask.json `
--door-zone-config annotations\door_zone_multifeature.json `
--active-door rear `
--output outputs\seg_08_rear `
--det-device 0 `
--pose-device cuda `
--write-video
```
`--active-door` 对应当前 CAN=0 的车门:
- `front`:只有前门进入电吸范围;
- `rear`:只有后门进入电吸范围;
- `both`:前后门都进入电吸范围;
- `none`:两门都不进行风险判断。
当前命令行参数对整个视频保持不变,用于离线部署验证。实车 CAN 随帧变化时,集成层需要把每帧实际 CAN 状态传给门级风险选择逻辑。
不需要叠加视频时不要传 `--write-video`,可减少编码和磁盘 I/O。完整参数说明:
```powershell
python run_risk.py --help
```
### 4.2 TensorRT RTMW
```powershell
python run_risk.py `
--video D:\data\test.mp4 `
--constant-door-mask annotations\constant_door_mask.json `
--active-door rear `
--output outputs\test_trt `
--pose-provider tensorrt `
--pose-device cuda
```
首次运行会在 `models/rtmw_l_trt_cache` 生成 TensorRT 缓存,该缓存与目标 GPU、TensorRT 版本和运行环境相关,不随部署包预置。
## 5. 输出文件
输出目录包含:
- `causal_results.jsonl`:逐帧因果结果,一行一个 JSON 对象,是 Demo 对接的主要接口;
- `summary.json`:模型、帧数、吞吐、延迟及风险统计;
- `rtmw_risk_overlay.mp4`:仅在传入 `--write-video` 时生成。
`causal_results.jsonl` 每行顶层字段:
| 字段 | 含义 |
|---|---|
| `frame_index` | 从 0 开始的视频帧号 |
| `track_ids` | 当前帧人体轨迹 ID 数组 |
| `boxes` | 与 `track_ids` 同序的人体框,格式 `[x1,y1,x2,y2]` |
| `skeletons` | 与检测人体对齐的 133 点融合骨骼 |
| `active_door` | 本次运行指定的 CAN=0 门:`front/rear/both/none` |
| `door_assignments` | 每个轨迹的前门/后门归属及特征解释 |
| `door_risks` | `front` 和 `rear` 两扇门各自的最终风险 |
| `risk` | 经过 CAN 和门区过滤后供业务使用的当前风险 |
| `ungated_risk` | 未经过前后门/CAN 过滤的原始二维风险,仅用于诊断 |
数组关联规则:`boxes[i]`、`track_ids[i]`、`skeletons[i].person_index == i`、`door_assignments[i].person_index == i` 表示同一个人。跨帧关联应使用 `track_id`,不要依赖数组下标。
## 6. `risk` 的含义
### 6.1 应读取哪个字段
单门 CAN=0 时,业务防夹建议读取:
```text
risk.risk_type
risk.risk_level
risk.risk_score
risk.decision
```
需要同时展示前后门时分别读取:
```text
door_risks.front
door_risks.rear
```
`ungated_risk` 只用于排查“图像上是否靠近危险区”,不能直接代替门级最终结论,否则会绕过前后门归属和 CAN 过滤。
### 6.2 风险标签
`risk_type` 是带门位置的最终风险标签:
| `risk_type` | `risk_level` | 含义 |
|---|---|---|
| `FRONT_CLEAR` / `REAR_CLEAR` | `clear` | 对应活跃门没有风险 |
| `FRONT_WATCH` / `REAR_WATCH` | `watch` | 进入关注距离,但未达到 warning |
| `FRONT_WARNING` / `REAR_WARNING` | `warning` | 距离达到 warning 阈值 |
| `FRONT_DANGER` / `REAR_DANGER` | `danger` | 人体风险区域与车门危险区相交 |
| `FRONT_UNKNOWN` / `REAR_UNKNOWN` | `unknown` | 人物处于 `transition`,无法可靠确定前后门 |
| `FRONT_INACTIVE` / `REAR_INACTIVE` | `inactive` | 该门 CAN 不活跃,不计算防夹风险 |
| `NO_ACTIVE_DOOR` | `inactive` | 前后门均不活跃 |
对应的 `decision`:
| `decision` | 含义 |
|---|---|
| `DANGER_INTERSECTION` | 风险区域与车门 mask 有像素相交 |
| `WARNING_NEAR` | 相对距离小于等于 warning 阈值 |
| `WATCH` | 相对距离处于 warning 与 release 之间 |
| `CLEAR` | 相对距离大于 release 阈值 |
| `CLEAR_NO_PERSON_ASSIGNED_TO_ACTIVE_DOOR` | 没有人被分配给该活跃门 |
| `EVIDENCE_UNAVAILABLE_TRANSITION_ZONE` | 存在 `transition` 人员,门归属不确定 |
| `DOOR_CAN_NOT_ACTIVE` | 该门 CAN 不活跃 |
| `NO_DOOR_CAN_ACTIVE` | 没有活跃门 |
默认阈值:
```text
warning_distance_relative = 0.30
release_distance_relative = 0.45
```
对每个风险区域:
```text
distance_relative = min_distance_px / sqrt(region_area_px)
risk_score = clip(1 - distance_relative / release_distance_relative, 0, 1)
```
只要 `door_overlap_px > 0`,就强制 `risk_score=1.0` 且等级为 `danger`。因此 `risk_score` 是归一化二维接近程度,不是概率,也不是厘米/毫米距离。
### 6.3 `regions` 与证据来源
每个门风险中的 `regions` 保存独立人体区域的解释信息,常用字段包括:
- `track_id`、`side`:所属轨迹和左右手;
- `source`:`RTMW_HAND_KEYPOINTS` 或 `YOLO_BODY_BOX_FALLBACK`;
- `region_area_px`:风险区域面积;
- `door_overlap_px`、`region_overlap_ratio`:与车门 mask 的相交量;
- `min_distance_px`:区域到车门 mask 的最小像素距离;
- `region_scale_px`:`sqrt(region_area_px)`;
- `distance_relative`:尺度归一化距离;
- `risk_score`、`risk_level`、`decision`:该区域自己的风险结果。
存在可用手部时,每只手用 21 点凸包计算风险;一个人没有任何可用手部时,才使用整个人体框兜底。人体框兜底更保守,也更容易产生误报。
### 6.4 `transition` 的当前行为
`door_assignments[].door_zone` 的位置标签为:
- `front`:归属前门;
- `rear`:归属后门;
- `transition`:前后门证据不足或位于过渡区;
- `unassigned`:人体尺度低于有效阈值,不参与门级风险。
当前 v9.10 中,`transition` 人员即使在 `ungated_risk` 中距离很近,也不会直接变成 `FRONT_WARNING/REAR_WARNING` 或 `DANGER`,而是让活跃门输出 `*_UNKNOWN`,其 `risk_score` 为 `null`。Demo 必须显式展示或处理 `unknown`,不能把它当成 `clear`。这是当前版本行为,不代表最终量产安全策略。
## 7. `skeletons` 的含义与可视化对接
### 7.1 数据结构
每个人的骨骼对象如下:
```json
{
"track_id": 1,
"person_index": 0,
"layout": "coco_wholebody_133",
"keypoints": [[x0, y0], [x1, y1]],
"metadata": {
"left": {},
"right": {}
}
}
```
- `keypoints` 实际固定为 `133 x 2`,示例中省略了其余点;
- 坐标单位是原视频像素,原点在左上角,x 向右、y 向下;
- 坐标保留 3 位小数;
- 这里没有逐点置信度,`metadata` 提供的是手臂/手部融合后的状态;
- `keypoints` 使用的正是当前叠加视频 `draw_fused` 使用的融合结果。
### 7.2 COCO-WholeBody 133 点索引
| 索引 | 含义 |
|---|---|
| `0` | nose |
| `1/2` | left/right eye |
| `3/4` | left/right ear |
| `5/6` | left/right shoulder |
| `7/8` | left/right elbow |
| `9/10` | left/right wrist |
| `11/12` | left/right hip |
| `13/14` | left/right knee |
| `15/16` | left/right ankle |
| `17/18/19` | left big toe / small toe / heel |
| `20/21/22` | right big toe / small toe / heel |
| `23–90` | 68 个面部关键点 |
| `91–111` | 左手 21 点 |
| `112–132` | 右手 21 点 |
每只手的局部 21 点顺序:
```text
0 wrist
1-4 thumb
5-8 index finger
9-12 middle finger
13-16 ring finger
17-20 little finger
```
左手局部索引需要加 91,右手局部索引需要加 112,才能得到 `keypoints` 中的全局索引。
### 7.3 与当前叠加视频一致的绘制规则
当前风险叠加视频只绘制手臂和双手,不绘制面部、躯干和腿部。手臂连线:
```text
(5,7), (7,9) 左肩-左肘-左腕
(6,8), (8,10) 右肩-右肘-右腕
```
每只手使用以下局部连线,分别作用到 `91–111` 和 `112–132`:
```text
(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)
```
为了与现有视频表现一致:
- `metadata[side].limb_state == "consensus"`:手臂画实线,否则画虚线;
- `metadata[side].hand_state == "rejected"`:不画该手;
- 手未被拒绝且 `distance_eligible == true`:手部画实线;
- 手未被拒绝但 `distance_eligible == false`:手部画虚线;
- `alarm_eligible == true` 表示该手允许进入 risk 区域计算。
Demo 如果只需要复现当前可视化,读取上述 48 个点即可;如果后续需要完整人体展示,可以使用全部 133 点,但应自行补充完整 WholeBody 连线定义。
## 8. `door_assignments` 对接说明
常用字段:
- `track_id`、`person_index`:与检测框和骨骼关联;
- `door_zone`:经过最多 5 帧因果中值后的 `front/rear/transition/unassigned`;
- `raw_door_zone`:当前帧未经时序中值稳定的标签;
- `front_probability`:当前帧前门概率;
- `smoothed_front_probability`:因果时序中值;
- `confidence`:相对 0.5 的归属置信度,不是人体检测置信度;
- `pose_feature_count`:本帧可用骨骼特征数量;
- `features`、`feature_frontness`:标定特征值和各特征前门贡献;
- `history_frames`:当前轨迹已用于中值的历史帧数。
当前标定阈值为:`smoothed_front_probability >= 0.72` 判为前门,`<= 0.28` 判为后门,中间为 `transition`。这些参数应随实车机位重新验证。
## 9. Demo 集成注意事项
1. 每帧先按 `track_id` 关联检测框、骨骼、门区和风险,避免多人时按数组顺序长期跟踪。
2. 主界面风险展示使用 `risk`;需要前后门双状态时使用 `door_risks.front/rear`。
3. `unknown`、`inactive` 和 `clear` 是三种不同状态,不能合并显示。
4. `risk_score == null` 表示该状态没有可用数值风险,不应转换成 0。
5. `ungated_risk` 只用于诊断叠加,不能作为最终防夹指令。
6. 更换分辨率或裁剪策略后必须核对车门 mask、骨骼坐标与视频坐标是否仍一致。
7. 当前系统可能把玻璃倒影或反光误检成人,部署 Demo 应保留原视频、检测框、`track_id` 和风险解释字段用于问题回溯。
+56 -170
View File
@@ -1,205 +1,91 @@
# vision-anti-pinch-0923 # Vision Anti-Pinch — use-can
以 Orin `~/vision-anti-pinch` 的 V4L2 MMAP 摄像头及 USBCANFD 模块为基线,接入 CMS v9.10 `run_risk.py` 的完整风险流程。模型及 GPU 推理代码来自远端 `~/vision-anti-pinch-0920`。 V4L2 摄像头 + CAN 门状态 + YOLO/RTMW TensorRT 推理 + CMS 风险判断。
默认持续推理,将最新完整结果保存在内存;可视化和文件保存均按需开启。
``` ## Orin 直接运行
基线 V4L2 MMAP → NPP UYVY 转 BGR → 最新帧槽位
→ YOLO11s TensorRT → RTMW-L TensorRT(含小人遮挡隔离推理)
→ Python 原版轨迹 / 手臂手部融合 / 全局手关联
→ 骨骼主导的前后门归属 → 固定 mask 风险 → 逐帧 CAN 活跃门过滤
→ 内存中的最新只读结果(可选 JSONL / 浏览器预览)
```
## 仓库分支与 Orin 直接运行包 适用:Jetson AGX Orin,JetPack 5.1.2 / L4T R35.4.1、CUDA 11.4、TensorRT 8.5.x、系统 OpenCV 4.5.4;Python 3.8+,numpy、cv2。
`orin/` 包含编译库、TensorRT 引擎、CAN 库、摄像头驱动、Python 运行代码和标定文件,可以单独复制到其他目录。
- `main`:原 CMS v9.10 Python/ONNX 部署包。
- `inference-only`:保留原 main 的纯 C++ TensorRT 推理版本。
- `use-can`(本分支):0923 CAN 集成版本。
本分支已提供可单独复制的 `orin/` 运行包,包含预编译库和 TensorRT 模型:
```bash ```bash
git clone --branch use-can https://git.kendeji.fun/zmv/vision-anti-pinch.git cd orin
cd vision-anti-pinch/orin ./scripts/initialize_camera.sh # 开机后无 /dev/video5 时初始化,需要 sudo
./scripts/initialize_camera.sh # 开机后需要初始化摄像头时执行 ./run.sh # 默认无可视化,结果保存在内存
./run.sh # 默认无可视化,结果保存在内存 # 或开启浏览器预览:http://设备IP:8765
./run.sh --visualize # 可选预览 ./run.sh --visualize
``` ```
`orin/run.sh` 通过相对目录定位运行库、模型与标定,使用系统 Python,无需现场编译。目标环境及下游调用说明见 `orin/README.md`。 每次只运行一个采集进程;Ctrl+C / SIGTERM 停止。关闭浏览器不会停止推理。
源码变更后,在目标 Orin 构建并执行 `scripts/package_orin.sh` 重新生成运行包;`ENGINE_DIR` 和 `DRIVER_DIR` 可指定模型/驱动目录。 系统 CUDA/TensorRT/OpenCV/Python 运行环境由目标系统提供,不随包重复分发。TensorRT 引擎不能直接用于 Thor 或其他 JetPack/GPU 环境。
## 两端目录和环境 ## 从源码编译
- 本机:`/home/thor/文档/vision-anti-pinch-0923` 在目标 Orin 上执行,依赖 CMake 3.24+、CUDA/NPP、TensorRT、OpenCV 开发库:
- Orin:`/home/orin/vision-anti-pinch-0923`
- 目标推理环境:Orin,JetPack R35.4.1、CUDA 11.4、系统 Python 3.8、原生 TensorRT。
- 本机保存完整源码、测试报告、模型副本;本机是 Thor / R38,未发现 `/dev/video*`。Orin 的序列化 engine 不能直接作为 Thor 的本机模型使用,需在 Thor 上另行转换。本次实时采集/推理在 Orin,浏览器可在本机查看。
- 远端 `models/engines` 软链接到 `../../vision-anti-pinch-0920/orin/engines`,避免重复占用有限磁盘。不要删除被引用的 0920 模型。
- 原有远端 `vision-anti-pinch` / `vision-anti-pinch-0920` 源码均未修改。测试时已停止占用 `/dev/video5` 的旧 0920 演示进程,0923 可独立启动。
## 编译和运行(在 Orin)
```bash ```bash
cd ~/vision-anti-pinch-0923 ./scripts/build.sh # 或 make
./scripts/build.sh ./scripts/initialize_camera.sh # 摄像头尚未初始化时执行
# 开机后如 /dev/video5 不存在,加载已部署的 0920 摄像头驱动: ./run_camera.sh # 使用新编译的 build/libvision_native.so
./scripts/initialize_camera.sh
# 编译也可使用 make
./run_camera.sh --active-door can --can-side left
# 等价入口:python3 run_risk.py --active-door can --can-side left
``` ```
默认只在内存中发布结果,不创建结果目录,不绘制或编码图像,不启动 HTTP 服务。 源码入口复用 `orin/models/engines/` 和 `orin/lib/`,不依赖旧版工程。
修改源码后刷新直接运行包:
后台启动、停止:
```bash ```bash
./scripts/start.sh # 无可视化 ./scripts/package_orin.sh # 或 make package
./scripts/stop.sh cd orin && sha256sum -c SHA256SUMS
./scripts/start.sh --visualize # 可选浏览器预览
``` ```
默认从 `/dev/video5` 采集 UYVY 1920×1536,驱动当前为 30 FPS。驱动不支持 `VIDIOC_G_PARM`,程序会打印警告并继续采集,命令行 fps 不代表驱动一定支持修改。 打包会替换运行代码,清除已删除的旧 Python 模块,并更新文件校验值。
- 开启 `--visualize` 后,预览:[http://192.168.195.100:8765](http://192.168.195.100:8765) ## 下游读取结果
- 开启 `--visualize` 后,最新结果:[http://192.168.195.100:8765/result.json](http://192.168.195.100:8765/result.json)
- 开启 `--visualize` 后,最新图像:[http://192.168.195.100:8765/frame.jpg](http://192.168.195.100:8765/frame.jpg)
- 后台进程日志:`logs/live.log`,PID:`logs/camera.pid`;加保存开关后,后台结果写入 `outputs/live/`。
- `--visualize` 开启预览;仅设置 `--preview-port` 不会开启预览。监听地址默认 `0.0.0.0:8765`,可用 `--preview-host 127.0.0.1` 限制为本机。可视化在独立观察线程中绘制和编码,只保留最新待绘制帧。端口占用或绘制失败不会终止推理;关闭浏览器也不会停止推理。
摄像头限定时长、模拟门状态: 在同一个 Python 进程中创建流水线,然后读取单个最新只读快照:
```bash
./run_camera.sh --no-can --active-door both --duration 30 --save-results --output outputs/demo
```
有人视频回放(同一 TensorRT/融合/风险链路):
```bash
./run_camera.sh --video tests/person-test.mp4 --no-can --active-door both \
--max-frames 150 --save-results --save-pose-records --output outputs/replay
```
`tests/person-test.mp4` 来自用户提供的 `segment_009.mp4` 第 10 秒起的 150 帧片段。
## 下游直接读取结果变量
参考 0920 的 `LatestResult` / `CameraPipeline.latest()`,0923 提供同进程 Python 接口:
```python ```python
from run_camera import CameraPipeline, parse_args from run_camera import CameraPipeline, parse_args
args = parse_args([]) # 默认 CAN 模式;不打开可视化,不保存文件 with CameraPipeline(parse_args([])) as pipeline:
with CameraPipeline(args) as pipeline: result = pipeline.wait_for_result(timeout=10)
result = pipeline.wait_for_result(timeout=10) # 等待第一帧,可选 # 下游自己的循环/线程中读取;首帧前为 None:
# 在下游自己的循环/线程中读取当前完整快照:
result = pipeline.latest_result # 等价于 pipeline.latest() result = pipeline.latest_result # 等价于 pipeline.latest()
if result is not None: if result is not None:
risk = result["risk"] print(result['frame_index'], result['risk'])
door_risks = result["door_risks"] door_risks = result['door_risks']
skeletons = result["skeletons"] skeletons = result['skeletons']
can = result["can"]
print(result["frame_index"], risk["risk_type"], risk["risk_score"])
``` ```
完整持续读取示例:`examples/read_latest.py`,在 Orin 执行。示例会自己启动推理并占用摄像头,运行前先停止已有后台进程: 结果字段:`frame_index`、`track_ids`、`boxes`、`skeletons`(每人 133 点)、`door_assignments`、`active_door`、`door_risks`、`risk`、`ungated_risk`、`can`、`capture`、`capture_to_result_ms`、`calibration_verified`。
```bash - 读取不消费结果;多个读者可以持有同一个不可变快照,旧快照不会被下一帧改写。
./scripts/stop.sh - 只保留最新结果,慢读者可以跳过帧;`wait_for_result(last_frame_index, timeout)` 可等待更新。
python3 examples/read_latest.py - `request_stop()` 请求停止,`join()` 等待退出并传播异常;`with` 自动回收资源。
# 或开启可视化: - 停止后仍保留最后快照;下游应检查运行状态和帧时间,避免使用过期结果。
python3 examples/read_latest.py --visualize - `result_to_dict(result)` 生成可修改、可序列化的副本。变量属于当前进程,不是跨进程共享内存。
```
- `latest_result` 是单个最新结果变量,首帧前为 `None`。每帧完成风险计算后发布,早于任何可选绘制或文件写入。 ## CAN、风险与输出选项
- 快照深度只读:字典使用只读映射,数组使用 tuple;字段和索引含义与原 JSON 一致。旧快照不会随下一帧变化,多个读取线程互不消费或修改结果。
- 只保留最新一帧,没有结果队列;下游处理慢可跳过中间帧。需要每帧历史时显式开启 `--save-results`。
- `pipeline.wait_for_result(after_frame_index, timeout)` 是可选等待接口,返回更新的最新结果;超时或结束且无新帧时返回 `None`。
- `pipeline.request_stop()` 请求停止,`pipeline.join()` 等待退出并向调用方传播推理异常;停止后可读取最后快照及 `pipeline.summary`。使用 `with` 自动停止并回收资源。
- 停止后的快照只是最后结果;持续控制时应同时检查 `pipeline.running()` 及帧序号/时间戳,避免使用旧结果。
- 需要修改副本或 JSON 序列化时使用 `from run_camera import result_to_dict`,再调用 `result_to_dict(result)`。
- 变量属于创建 `CameraPipeline` 的 Python 进程。另一个进程或 C++ 程序不能直接 import 读取后台服务的变量,需要另行使用 IPC/HTTP 接口;当前没有新增 C++ 风险结果 ABI。
## 可选输出开关 `0x391` 的 data[0..3] 依次为左前、右前、左后、右后。默认 `--can-side left`,右侧使用 `--can-side right`。值为 0 表示对应门活跃;报文缺失或超过默认 1000 ms 时,输出 `CAN_UNKNOWN`、分数 null。当前接口不发送防夹执行器指令。
| 参数 | 行为 | 优先用融合后的有效手部区域判断风险,无有效手部时使用人体框兜底;最终读取 `risk` / `door_risks`。`ungated_risk` 仅供诊断,`unknown` 不能当作 clear。
标定文件来自研发机位,默认 `calibration_verified=false`,换机位须重新标定。
| 参数 | 作用 |
|---|---| |---|---|
| 无输出参数 | 只发布内存结果,不绘制、不保存结果文件 | | `--visualize` | 开启独立绘制线程及 HTTP 预览;默认不绘制、不编码、不监听端口 |
| `--visualize` | 开启独立可视化线程和 HTTP 预览 | | `--save-results` | 保存逐帧 JSONL 及 latest.json,默认不保存 |
| `--save-results` | 保存逐帧 JSONL 及约每 10 秒的 latest.json | | `--save-snapshots` | 每约 10 秒保存叠加图像 |
| `--save-snapshots` | 约每 10 秒绘制并保存 latest.jpg,无 HTTP 服务 | | `--write-video` | 保存叠加视频,可配合 `--duration` 限时运行 |
| `--save-pose-records` | 保存诊断姿态 JSONL | | `--output PATH` | 指定保存目录,本身不启用保存 |
| `--write-video` | 绘制并保存叠加 MP4,无需打开预览 | | `--no-can --active-door both` | 显式模拟门状态 |
输出开关可组合。`--output` 仅指定保存目录,不会自动开启保存。未开启保存时,旧输出文件保留但不会更新。任一保存开关开启后,停止时额外保存 summary.json;默认内存模式的统计保存在 `pipeline.summary` 并打印到终端。 运行 `./run_camera.sh --help` 或 `orin/run.sh --help` 查看全部选项。JSONL 每 32 MiB 轮换,保留两个历史文件;视频不轮换。开启可视化后可读取 `/result.json` 和 `/frame.jpg`。
恢复此前的预览与文件输出行为: ## 目录
```bash - `native/`:实际参与编译的摄像头/CAN、CUDA/NPP、TensorRT 源码及头文件。
./run_camera.sh --visualize --save-results --save-snapshots --output outputs/demo - `pipeline/`:运行时姿态融合、跟踪、遮挡处理、风险、可视化和结果变量。
``` - `annotations/`:源码运行时的标定文件。
- `scripts/`:构建、打包、摄像头初始化。
## CAN 约定 - `orin/`:可单独复制并直接执行的运行包。
按用户确认的 `0x391` 数据布局:
| 字节 | 车门 |
|---|---|
| data[0] | 左前 |
| data[1] | 右前 |
| data[2] | 左后 |
| data[3] | 右后 |
- `--can-side left` 使用 data[0]/data[2];`right` 使用 data[1]/data[3]。默认 left,请按摄像头安装侧选择。
- 值为 0 时对应门活跃,每帧读取最新 CAN 快照。有效报文至少需要 4 字节。
- 未收到有效报文或超过 `--can-timeout-ms`(默认 1000 ms),整体输出 `CAN_UNKNOWN`,门级输出 `FRONT_UNKNOWN/REAR_UNKNOWN`,分数为 null。
- 此时 `active_door=none` 仅为内部无有效活跃门输入,业务必须读取 `risk` 和 `can.valid`,不能将其解释为已确认两门关闭。
- 显式 `--active-door front/rear/both/none` 是模拟模式,输出 `can.source=simulation`,预览显示 CAN SIMULATION。`--no-can` 要求显式选择模拟状态。
- 本项目提供最终风险结果;基线未提供防夹执行器的 CAN 指令协议,因此没有自行添加执行器报文。
- 本次 USB-CAN `VCI_OpenDevice` 返回失败,`lsusb` 中未见 USB-CAN 设备;真实 0x391 报文链路尚未验证。连接适配器后重新启动程序;字节映射及门状态组合通过软件测试。
## 风险行为与机位标定
风险算法、默认 warning=0.30 / release=0.45、133 点骨骼、track_id、前后门归属、transition→unknown、无有效手部时人体框兜底均沿用原版。详见 `README-risk-upstream.md`。
`annotations/constant_door_mask.json` 和 `door_zone_multifeature.json` 原样复制,属于原研发机位。当前室内摄像头位置与其不一致,默认输出 `calibration_verified=false`,预览显示 CALIBRATION UNVERIFIED。实车验证需更换标定文件;`--calibration-verified` 仅标记经人工验证的标定,不会自动标定。
当前实时场景没有人,约 29 FPS 是该场景的实测值,不能当作多人满负载帧率。
## 优化与输出
- 重用 0920 的常驻 TensorRT engine、CUDA/NPP 预处理、显存及锁页缓存、GPU UYVY 转换。
- 独立取帧线程只保留最新帧,主动丢弃旧帧,避免积压;不像 0920 那样单独并行检测线程,完整 Python 融合阶段按结果顺序执行。
- 风险 mask / 距离变换按原版缓存;开启预览时默认每 3 帧提交一次待绘制图像;开启快照保存时每 10 秒一次。
- 长期运行清理过期轨迹,计时统计仅保留最近 3000 帧。
- 开启 `--save-results` 后,`causal_results.jsonl` 默认每 32 MiB 轮换,保留 `.1` 和 `.2`;`--jsonl-max-mb 0` 可禁用轮换。读取全部历史时按 `.2 → .1 → 当前文件` 顺序处理。
- `--save-pose-records` 额外保存同样轮换的诊断姿态记录;`--write-video` 录制叠加 MP4(视频不轮换,建议配合 `--duration`)。
- 文件输出遵循上面的独立开关;关闭可视化仍可完整读取内存结果。
- 新增 `capture`、`can`、`capture_to_result_ms`、`calibration_verified` 字段,原版核心 JSON 字段保留。
- 驱动原始时间戳存在时钟偏移,保存在 `capture.driver_timestamp_ms` 供诊断。延迟采用主机单调时钟在 DQBUF 后取样,表示“主机取帧到风险结果”,不包含曝光到驱动交付的时间。
## 文件关系与测试
- `main.cpp`, `v4l2_capture.*`, `can_comm.*`, `include/zcan.h`, `lib/`:基线工程。新工程修正错误 V4L2 buffer 处理;旧 main 同步修正 CAN 字节解析。
- `native/native_api.cpp`:采集线程、CAN 快照、TensorRT 的 ctypes C 接口。
- `accelerator/`:远端 0920 源码快照,当前构建只选取 TensorRT/预处理/检测/姿态源文件。
- `pipeline/`:原版 Python 算法,推理适配在 `native_backend.py`;ONNX/Ultralytics 的可选导入移到旧离线入口,原版风险公式不变。
- `Makefile.baseline` 和 `make baseline`:旧基线演示程序的编译方式;默认 `make` 编译新的原生共享库。
- `pipeline/latest_result.py`:线程安全的单结果发布槽位及不可变快照。
- `examples/read_latest.py`:同进程下游读取示例。
- `tests/test_latest_result.py`:结果所有权、线程生命周期、可选输出、独立可视化及异常传播测试。
- `tests/test_risk_integration.py`:CAN 左右门组合、失效状态、手部/人体兜底、transition、轨迹回收、日志轮换测试。
- `TEST_REPORT.md`:实测结果和限制。
```bash
python3 -m unittest discover -s tests -v
```
本机重新部署源码(会使用 SSH 交互认证,不保存密码):
```bash
./scripts/deploy.sh orin@192.168.195.100
```
重新编译共享库前应先停止 0923 运行进程,再启动新进程加载新库。
-56
View File
@@ -1,56 +0,0 @@
# 2026-09-23 集成验证
执行设备:192.168.195.100 / Orin;摄像头 /dev/video5,UYVY 1920×1536。当前机位朝向室内设备,没有测试人员;用户确认暂不调整机位。
| 验证 | 结果 |
|---|---|
| 原生 C++/CUDA 共享库 | Orin Release 编译通过 |
| 摄像头 + CAN 模式,30 秒 | 882 帧,29.399 FPS,无运行异常 |
| 主机取帧到风险结果 | 均值 42.369 ms,P95 57.368 ms |
| latest-frame 策略 | 30 秒期间丢弃 16 个积压帧 |
| 真实 CAN | USB-CAN 打开失败,lsusb 未见该设备;882 帧均为 CAN_UNKNOWN,score=null |
| 有人视频 150 帧回放 | 32.898 FPS,31 帧有 1 人,119 帧无有效人体框 |
| RTMW 骨骼 | 31 个人体记录,每个 133 点 |
| 风险证据 | 22 个手部区域,9 个人体框兜底区域 |
| 视频门级风险 | clear 132,watch 5,warning 2,unknown 11 |
| 原始 run_risk.py 对照 | 同一姿态记录重放 150 帧,核心输出完全一致 |
| 行为测试 | 本机与 Orin 的 8 项测试均通过 |
| HTTP 实时预览 | 本机访问远端 /result.json 成功,连续返回新帧 |
对照方式:将远端 `outputs/person-replay/pose_records.jsonl` 下载到本机,用用户原始工程的 `run_risk.py --pose-jsonl` 运行,逐帧比较原始 JSON 的所有顶层字段,包括 skeletons、door_assignments、door_risks、risk、ungated_risk。仅排除不同工程目录导致的绝对 mask 路径差异;150 帧无差异。**此项对照验证相同姿态输入下的风险集成,不是 TensorRT 与 ONNX 输出的数值误差测试。**
证据:
- `outputs/camera-can/summary.json`
- `outputs/person-replay/summary.json` 与 `causal_results.jsonl`
- `outputs/reference-replay/summary.json` 与 `causal_results.jsonl`
- `outputs/reference-parity.json`
- `logs/build.log`, `logs/camera-can.log`, `logs/person-replay.log`
`outputs/camera-smoke/` 是初次调通记录,使用了不可靠的驱动时间戳,延迟统计无效。已修正计时来源,性能结论以 `camera-can` 为准。
未完成的硬件验证:有效 CAN 报文到达与门切换、真实摄像头下人员/手部靠近门区、实车机位 mask 与前后门参数标定。当前无法据此宣称已完成实车防夹效果验收。
本机没有摄像头设备节点;本机完成源码/模型归档、风险对照及行为测试,通过网络预览 Orin 的实时结果。
## 最新结果变量与可选可视化改造
已对照重启后远端 0920 的 `include/latest_result.hpp` 和 `src/main.cpp`,采用默认关闭可视化、单个不可变最新快照、start/latest/request_stop/join 生命周期。
- 本机与 Orin:17 项测试通过,覆盖原有风险测试及只读快照、旧快照保持、跨线程等待/停止、错误传播、默认无绘制/HTTP/结果文件、JSON 与内存结果一致、预览端口失败不影响推理、独立可视化线程。
- Orin 真实 TensorRT 视频回放:下游示例读取 90 帧,27.321 FPS;未开启可视化和文件保存。保留 watch/warning/unknown 等风险输出。
- Orin 真实摄像头:下游示例读取 90 帧,28.858 FPS;主机取帧至结果均值 32.341 ms;关闭可视化,没有创建指定输出目录。
- `--visualize` 实机运行 15 秒:成功通过 HTTP 读取逐帧结果及 JPEG 图像;未开启文件保存,没有创建指定输出目录。
- 后台运行入口默认无可视化;开启预览需显式加 `--visualize`。
- 重启后摄像头节点未自动生成。0920 初始化脚本因驱动缺少 vermagic 元数据而判定不匹配;0923 新增初始化脚本,存在 vermagic 时检查内核版本,缺失时交给正常 insmod 的内核检查,未使用强制加载参数。加载原有 0920 驱动后 /dev/video5 恢复。
日志:`logs/tests-optional-local.log`、`logs/tests-optional-orin.log`、`logs/result-api-video.log`、`logs/result-api-camera.log`、`logs/optional-preview.log`、`logs/optional-preview-probe.json`。
## use-can Orin 可运行包验证
- 独立运行包位于 `orin/`:包含预编译 ARM64 原生库、TensorRT 引擎、Python 风险代码、CAN USB 库、GMSL 驱动和 ./run.sh。
- 在 Orin 新建的独立临时目录复制整个运行包,SHA256SUMS 全部通过。
- 从临时目录执行 `./run.sh --max-frames 90`,实际摄像头 90 帧完成,29.080 FPS,主机取帧至风险结果平均 36.310 ms;无结果文件输出。
- 原生库 RUNPATH 为 `$ORIGIN/../lib:/usr/local/cuda/lib64`,启动脚本按自身位置定位所有项目资产。
- 系统依赖仍为目标 JetPack 的 CUDA / TensorRT / OpenCV 与 Python numpy/cv2,不包含操作系统或完整 Python 环境。
-5
View File
@@ -1,5 +0,0 @@
*.sh text eol=lf
*.engine -text
*.ko -text
/orin/** -text
/config/door_mask.json -text
-4
View File
@@ -1,4 +0,0 @@
/build/
/engines/
/logs/
*.tmp
-23
View File
@@ -1,23 +0,0 @@
cmake_minimum_required(VERSION 3.24)
if(NOT DEFINED CMAKE_CUDA_ARCHITECTURES)
set(CMAKE_CUDA_ARCHITECTURES native)
endif()
project(camera_risk LANGUAGES CXX CUDA)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CUDA_STANDARD 17)
find_package(CUDAToolkit REQUIRED)
find_package(OpenCV REQUIRED COMPONENTS core imgproc imgcodecs highgui)
find_package(Threads REQUIRED)
find_path(TENSORRT_INCLUDE_DIR NvInfer.h PATH_SUFFIXES aarch64-linux-gnu x86_64-linux-gnu REQUIRED)
find_library(TENSORRT_LIBRARY nvinfer REQUIRED)
add_library(camera_risk_core
src/trt_engine.cpp src/pose_engine.cu src/gpu_preprocess.cu src/uyvy_converter.cu
src/detector.cpp src/door_risk.cpp src/camera.cpp src/camera_pipeline.cpp src/config.cpp)
target_include_directories(camera_risk_core PUBLIC include third_party ${TENSORRT_INCLUDE_DIR} ${OpenCV_INCLUDE_DIRS})
target_link_libraries(camera_risk_core PUBLIC ${TENSORRT_LIBRARY} ${OpenCV_LIBS} Threads::Threads CUDA::cudart CUDA::nppc CUDA::nppig CUDA::nppidei CUDA::nppicc)
target_compile_options(camera_risk_core PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-Wall;-Wextra;-Wpedantic>)
add_library(camera_risk_visualizer src/visualizer.cpp)
target_link_libraries(camera_risk_visualizer PUBLIC camera_risk_core)
add_executable(camera_risk src/main.cpp)
target_link_libraries(camera_risk PRIVATE camera_risk_visualizer)
-76
View File
@@ -1,76 +0,0 @@
# Vision Anti-Pinch
C++ / TensorRT 实时摄像头风险推理,算法来自最新 run_risk.py。
持续取图、推理并更新内存中的 FrameResult;可视化可选。
## Orin 直接运行
适用环境:Jetson AGX Orin、JetPack 5.1.2 / L4T R35.4.1、CUDA 11.4、TensorRT 8.5.2、OpenCV 4.5.4。
仓库已包含该板端编译的程序和重新构建的 TensorRT 引擎,无需先编译或转换。
```bash
git clone https://git.kendeji.fun/zmv/vision-anti-pinch.git
cd vision-anti-pinch/orin
./scripts/initialize_camera.sh # 开机后初始化 /dev/video5 摄像头链路
./run.sh # 持续推理,结果只保存在内存
```
可选板端本地可视化:
```bash
./run.sh --visualize
```
Ctrl+C / SIGTERM 停止程序。没有有限帧选项;正常运行和退出不输出统计。
关闭预览窗口或按 q/Esc 只关闭预览,推理继续。没有图像网络服务、端口转发、JSONL 或视频输出。
摄像头为 /dev/video5,1920×1536 UYVY,30 FPS。
当前 door mask 是指定的 constant_door_mask.json 副本,标定状态为 false;装车后应更换与实际画面匹配的门区并更新标定状态。
即使不开启可视化,风险计算仍需要门区。
## 下游获取结果
main.cpp 注释中也包含以下示例。下游通过自己的调用位置或线程读取:
```cpp
auto result = pipeline.latest();
if (result) {
const auto& risk = result->risk;
const auto& people = result->persons;
// result->frame_index / track_ids / boxes
// result->image / door_mask_pixels / capture / timing
// people[i].selected / smoothed / temporal:各 133 个关键点
}
```
latest() 返回 shared_ptr<const FrameResult>;首帧尚未完成时为空。
读取不删除结果,也不产生消费状态。推理不关心是否有下游读取。
下游持有的结果保持有效,后续更新不会改变旧快照;历史保存由下游自行负责。
公共定义位于 include/camera_pipeline.hpp 和 include/frame_result.hpp。
## 从源码构建
```bash
./scripts/build.sh
./scripts/initialize_camera.sh
./run_camera.sh
```
源码构建要求 CMake 3.24+、CUDA/NPP、TensorRT 和 OpenCV 开发依赖。
构建脚本会优先使用 Orin 上已有的 ~/miniconda3/envs/yolo/bin/cmake。
源码配置直接使用 orin/engines/ 中的板端引擎。该引擎仅适用于上述 Orin 环境;仓库不包含 ONNX 或模型转换脚本。
独立库目标为 camera_risk_core;可视化在 camera_risk_visualizer。
## 目录
- src/、include/:采集、推理、结果变量及可选绘制。
- config/:源码构建程序的配置和固定门区。
- scripts/:构建和摄像头初始化脚本。
- orin/:已编译程序、TensorRT 引擎、配置、当前 GMSL 驱动及启动脚本。
- third_party/:nlohmann JSON 及其许可证。
NPP 用于 UYVY 转换、YOLO 缩放填充、RTMW 仿射裁剪;CUDA 用于通道转换与归一化。
风险几何计算仍包含 CPU/OpenCV 操作;算法内部的临时 JSON 对象只在内存中使用。
当前摄像头驱动启动时偶发首帧 ERROR,应用会丢弃并计数;持续错误、采集超时或推理错误会明确报错并退出。
现有 GMSL 驱动停止采集时可产生 videobuf2 缓冲区告警,v4l2-ctl 同样复现;需在驱动侧完成量产修复和验证。
-22
View File
@@ -1,22 +0,0 @@
{
"camera": {
"device": "/dev/video5",
"width": 1920,
"height": 1536,
"fps": 30,
"buffers": 4,
"timeout_ms": 2000
},
"inference": {
"engine_directory": "../orin/engines",
"detector_confidence": 0.3,
"max_people": 3,
"queue_capacity": 2
},
"door": {
"mask": "door_mask.json",
"warning_relative": 0.3,
"release_relative": 0.45,
"calibration_verified": false
}
}
-77
View File
@@ -1,77 +0,0 @@
{
"version": "x-anylabeling",
"flags": {},
"shapes": [
{
"label": "door",
"points": [
[
212.86,
145.919
],
[
378.891,
851.857
],
[
468.293,
996.446
],
[
536.408,
1294.131
],
[
553.437,
1404.7
],
[
561.951,
1532.279
],
[
51.086,
1532.279
],
[
8.514,
1515.268
],
[
0.0,
1468.489
],
[
8.514,
26.845
],
[
25.543,
5.582
],
[
131.973,
-2.924
],
[
191.574,
1.329
],
[
208.603,
145.919
]
],
"group_id": null,
"shape_type": "polygon",
"flags": {}
}
],
"imagePath": "constant_door_mask_reference.jpg",
"imageData": null,
"imageHeight": 1536,
"imageWidth": 1920,
"sourceVideo": "D:\\huang.xuewei\\CMS\\video\\20260921104502\\segment_009.mp4",
"sourceFrameIndex": 244,
"constantDoorMask": true
}
-36
View File
@@ -1,36 +0,0 @@
#pragma once
#include <condition_variable>
#include <deque>
#include <mutex>
#include <optional>
#include <stdexcept>
namespace cms {
// FIFO with bounded ownership. Normal close drains; cancellation may discard.
template<class T> class BoundedQueue {
public:
explicit BoundedQueue(std::size_t capacity):capacity_(capacity) {
if(!capacity)throw std::invalid_argument("Queue capacity must be positive");
}
bool push(T item) {
std::unique_lock<std::mutex> lock(mutex_);
changed_.wait(lock,[&]{return closed_||items_.size()<capacity_;});
if(closed_)return false;
items_.push_back(std::move(item));changed_.notify_all();return true;
}
std::optional<T> pop() {
std::unique_lock<std::mutex> lock(mutex_);
changed_.wait(lock,[&]{return closed_||!items_.empty();});
if(items_.empty())return {};
T item=std::move(items_.front());items_.pop_front();changed_.notify_all();return item;
}
void close(bool discard=false) {
std::lock_guard<std::mutex> lock(mutex_);
closed_=true;if(discard)items_.clear();changed_.notify_all();
}
private:
const std::size_t capacity_;
std::mutex mutex_;std::condition_variable changed_;
std::deque<T> items_;bool closed_{};
};
}
-31
View File
@@ -1,31 +0,0 @@
#pragma once
#include "image.hpp"
#include <atomic>
#include <memory>
#include <optional>
#include <string>
namespace cms {
struct CameraConfig {
std::string device{"/dev/video5"};
int width{1920},height{1536},fps{30},buffers{4},timeout_ms{2000};
};
struct CapturedFrame {
CaptureMetadata metadata;
std::shared_ptr<const BgrImage> image;
};
// The capture thread exclusively owns read(); requestStop() is thread safe.
class Camera {
public:
explicit Camera(CameraConfig config);
~Camera();
Camera(Camera const&)=delete;
Camera& operator=(Camera const&)=delete;
std::optional<CapturedFrame> read();
void requestStop() noexcept;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
std::uint64_t monotonicNs();
}
-43
View File
@@ -1,43 +0,0 @@
#pragma once
#include "camera.hpp"
#include "frame_result.hpp"
#include <filesystem>
#include <memory>
namespace cms {
struct PipelineConfig {
CameraConfig camera;
std::filesystem::path engines,door_mask;
double detector_confidence{0.30};
int max_people{3};
double warning_relative{0.30},release_relative{0.45};
std::size_t queue_capacity{2};
bool door_mask_calibration_verified{false};
};
struct PipelineStats {
std::uint64_t captured{},published{},driver_sequence_gaps{},discarded_error_frames{};
double mean_capture_to_result_ms{};
};
// One lifecycle per instance. start() warms engines before starting capture.
// Runs continuously until requestStop() or a worker failure.
// latest() reads the immutable result variable without removing or acknowledging it.
// No result queue, consumer registration or frame-count termination.
// Stop is safe from any thread;
// destruction closes queues, interrupts capture and joins every worker.
class CameraPipeline {
public:
explicit CameraPipeline(PipelineConfig config);
~CameraPipeline();
CameraPipeline(CameraPipeline const&)=delete;
CameraPipeline& operator=(CameraPipeline const&)=delete;
void start();
FrameResultPtr latest() const;
bool running() const noexcept;
void requestStop() noexcept;
void join();
PipelineStats stats() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}
-5
View File
@@ -1,5 +0,0 @@
#pragma once
#include "camera_pipeline.hpp"
namespace cms {
PipelineConfig loadConfig(std::filesystem::path const& path);
}
-35
View File
@@ -1,35 +0,0 @@
#pragma once
#include <nlohmann/json.hpp>
#include <opencv2/core.hpp>
#include <filesystem>
#include <map>
#include <set>
using Json = nlohmann::ordered_json;
namespace cms {
struct DoorOptions {
std::filesystem::path constant, directory;
std::string format{"auto"};
std::set<std::string> labels{"door", "车门"};
int offset{};
bool require{};
double warning{0.30}, release{0.45};
};
class DoorRisk {
public:
DoorRisk(DoorOptions options, cv::Size size);
void initialize() { load(0); }
Json evaluate(int frame, Json& record);
cv::Mat const& mask() const { return mask_; }
private:
void load(int frame);
cv::Mat rasterize(std::filesystem::path const& path) const;
DoorOptions options_;
cv::Size size_;
std::string format_, status_;
std::filesystem::path loadedPath_;
std::map<int, std::filesystem::path> paths_;
cv::Mat mask_, distance_;
bool cached_{};
};
double rounded(double value, int digits);
}
-80
View File
@@ -1,80 +0,0 @@
#pragma once
// Public downstream contract. Owns its data; no GPU, JSON or network handles.
#include "image.hpp"
#include <array>
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <vector>
namespace cms {
struct DoorMaskResult {
std::string status;
int annotation_index{};
std::optional<std::string> path;
std::string format;
int area_px{};
};
struct RegionRisk {
std::string status;
std::optional<double> risk_score;
std::string risk_level;
std::string decision;
std::string source;
int track_id{};
std::string side;
std::optional<std::string> state;
// Unavailable evidence has no geometric measurements, matching the reference.
std::optional<int> region_area_px, door_overlap_px;
std::optional<double> region_overlap_ratio, min_distance_px;
std::optional<double> region_scale_px, distance_relative;
};
struct RiskResult {
std::string status;
std::optional<double> risk_score;
std::string risk_level;
std::string decision;
std::optional<std::string> source;
std::optional<int> track_id;
std::optional<std::string> side;
DoorMaskResult door_mask;
double warning_distance_relative{}, release_distance_relative{};
std::vector<RegionRisk> regions;
};
struct PosePoint { double x{},y{},score{}; };
struct HandState {
std::string state, source;
bool limb_consensus{}, distance_eligible{}, alarm_eligible{};
};
struct PersonResult {
int track_id{};
double confidence{};
std::array<double,4> box{};
std::array<PosePoint,133> selected{}, smoothed{}, temporal{};
std::array<HandState,2> hands; // left, right
};
struct HandRegion {
int person_id{},track_id{};
std::string side;
std::array<PosePoint,21> keypoints{};
};
struct StageTiming {
double detector_ms{}, pose_ms{}, fusion_risk_ms{}, capture_to_result_ms{};
};
struct FrameResult {
int frame_index{};
std::vector<int> track_ids;
std::vector<std::array<double,4>> boxes;
RiskResult risk;
CaptureMetadata capture;
StageTiming timing;
std::vector<PersonResult> persons;
std::vector<HandRegion> hand_regions;
// Both images belong to this result. The constant mask is shared across frames.
std::shared_ptr<const BgrImage> image;
std::shared_ptr<const BinaryMask> door_mask_pixels;
bool door_mask_calibration_verified{};
};
using FrameResultPtr = std::shared_ptr<const FrameResult>;
}
-25
View File
@@ -1,25 +0,0 @@
#pragma once
#include <cstdint>
#include <vector>
namespace cms {
// Immutable when published. Stride is explicit; no borrowed camera/GPU memory.
struct BgrImage {
int width{}, height{}, stride{};
std::vector<std::uint8_t> pixels;
};
struct BinaryMask {
int width{}, height{}, stride{};
std::vector<std::uint8_t> pixels;
};
struct CaptureMetadata {
std::uint64_t frame_index{};
std::uint32_t driver_sequence{};
std::uint64_t driver_timestamp_ns{};
std::uint64_t received_monotonic_ns{};
std::uint64_t capture_monotonic_ns{};
bool driver_timestamp_monotonic{};
std::uint32_t skipped_driver_frames{};
std::uint32_t discarded_error_frames{};
};
}
-20
View File
@@ -1,20 +0,0 @@
#pragma once
#include "frame_result.hpp"
#include <atomic>
#include <stdexcept>
namespace cms {
// One immutable result variable. Readers neither remove nor acknowledge results.
class LatestResult {
public:
void publish(FrameResultPtr result) {
if(!result)throw std::invalid_argument("Cannot publish a null result");
std::atomic_store_explicit(&latest_,std::move(result),std::memory_order_release);
}
FrameResultPtr load() const {
return std::atomic_load_explicit(&latest_,std::memory_order_acquire);
}
private:
FrameResultPtr latest_;
};
}
-100
View File
@@ -1,100 +0,0 @@
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
namespace fangjia {
struct Box {
float x1{};
float y1{};
float x2{};
float y2{};
float score{};
int trackId{};
};
struct Keypoint {
float x{};
float y{};
float score{};
};
struct PersonPose {
Box box;
std::array<Keypoint, 133> keypoints{};
};
enum class LimbState {
Observed,
Provisional,
Recovered,
Reassigned,
Interpolated,
PredictedShort,
ReachableMedium,
ReachableLong,
};
inline char const* limbStateName(LimbState state) {
switch (state) {
case LimbState::Observed: return "observed";
case LimbState::Provisional: return "provisional";
case LimbState::Recovered: return "recovered";
case LimbState::Reassigned: return "reassigned";
case LimbState::Interpolated: return "interpolated";
case LimbState::PredictedShort: return "predicted_short";
case LimbState::ReachableMedium: return "reachable_medium";
case LimbState::ReachableLong: return "reachable_long";
}
return "unknown";
}
struct LimbObservation {
int trackId{};
bool left{};
LimbState state{LimbState::ReachableLong};
Keypoint elbow{};
Keypoint wrist{};
std::array<Keypoint, 21> hand{};
Box personBox{};
bool handValid{};
bool alarmEligible{};
float handQuality{};
};
// Exact pre-V2 hand evidence consumed by the Python risk stage. These points
// come from raw/isolated fusion before temporal reconstruction.
struct GlobalHand {
int personId{};
int trackId{};
bool left{};
std::string source;
std::string state;
bool distanceEligible{};
bool alarmEligible{};
float score{};
float spanPx{};
std::array<Keypoint, 21> keypoints{};
std::vector<std::pair<int, bool>> aliases;
};
struct FusionMeta {
bool limbConsensus{}, handValid{}, distanceEligible{}, alarmEligible{};
double handScore{};
std::string handSource, handState;
};
struct PoseFrameResult {
std::vector<std::array<FusionMeta, 2>> metadata;
std::vector<PersonPose> selectedPeople;
std::vector<PersonPose> smoothedPeople;
std::vector<PersonPose> v2People;
std::vector<LimbObservation> limbs;
std::vector<GlobalHand> globalHands;
};
} // namespace fangjia
-108
View File
@@ -1,108 +0,0 @@
#pragma once
#include "pipeline_types.hpp"
#include "trt_engine.hpp"
#include <opencv2/core.hpp>
#include <deque>
#include <array>
#include <optional>
#include <string>
#include <unordered_map>
namespace fangjia {
class BoxTracker {
public:
std::vector<int> update(std::vector<Box>& boxes, int frameIndex, int width, int height);
private:
struct Track { Box box; int lastFrame{}; };
int nextId_{1};
std::unordered_map<int, Track> tracks_;
};
class RtmwEngine {
public:
RtmwEngine(
std::filesystem::path const& enginePath,
TrtLogger& logger,
int maxSourceWidth,
int maxSourceHeight,
int maxPeople = 3);
~RtmwEngine();
RtmwEngine(RtmwEngine const&) = delete;
RtmwEngine& operator=(RtmwEngine const&) = delete;
std::vector<PersonPose> infer(
cv::Mat const& frame,
std::vector<Box> const& boxes,
cudaStream_t stream,
float* preprocessMs = nullptr,
float* inferenceMs = nullptr,
float* postprocessMs = nullptr);
void warmup(cudaStream_t stream);
private:
TrtEngine engine_;
int maxSourceWidth_{};
int maxSourceHeight_{};
int maxPeople_{};
std::uint8_t* sourceDevice_{};
std::uint8_t* cropsDevice_{};
float* networkDevice_{};
std::vector<double> centersHost_;
std::vector<double> scalesHost_;
std::vector<float> simccXHost_;
std::vector<float> simccYHost_;
};
class PoseFusionTracker {
public:
PoseFrameResult update(
std::vector<PersonPose> const& raw,
std::vector<PersonPose> const& isolated,
int frameIndex);
private:
struct Vec2 { double x{}; double y{}; };
struct SideFusion {
std::array<Vec2, 3> limbPrevious{};
std::array<Vec2, 3> limbVelocity{};
std::array<Vec2, 21> handPrevious{};
std::array<Vec2, 21> handVelocity{};
bool hasLimbPrevious{};
bool hasLimbVelocity{};
bool hasHandPrevious{};
bool hasHandVelocity{};
std::string limbSource;
std::string handSource;
};
struct Endpoint {
bool hasWrist{};
Vec2 wrist{};
Vec2 velocity{};
std::array<Vec2, 21> handShape{};
bool hasHandShape{};
int lastObservedFrame{-1};
int missedFrames{};
bool hasLastElbow{};
Vec2 lastElbow{};
bool hasLastDirection{};
Vec2 lastDirection{};
};
struct PersonState {
std::array<SideFusion, 2> fusion;
std::array<Endpoint, 2> endpoints;
std::array<std::deque<std::array<double, 2>>, 2> boneSamples;
std::array<Vec2, 133> smoothed{};
bool hasSmoothed{};
int lastFrame{-1};
};
std::unordered_map<int, PersonState> states_;
};
} // namespace fangjia
-38
View File
@@ -1,38 +0,0 @@
#pragma once
#include "door_risk.hpp"
#include "pipeline_types.hpp"
namespace cms {
inline Json xy(std::vector<fangjia::PersonPose> const& people, int digits=3) {
Json a=Json::array(); for(auto const& p:people) { Json k=Json::array();for(auto const& v:p.keypoints) k.push_back({rounded(v.x,digits),rounded(v.y,digits)});a.push_back(k); } return a;
}
inline Json scores(std::vector<fangjia::PersonPose> const& people) {
Json a=Json::array();for(auto const& p:people) {Json k=Json::array();for(auto const& v:p.keypoints) k.push_back(v.score);a.push_back(k);}return a;
}
inline Json makeRecord(int frame,std::vector<fangjia::PersonPose> const& raw,std::vector<fangjia::PersonPose> const& isolated,fangjia::PoseFrameResult const& fused, bool diagnostics=true, bool visual=true) {
Json r={{"frame_index",frame},{"track_ids",Json::array()},{"boxes",Json::array()},{"det_confidences",Json::array()}};
for(auto const&p:raw) {auto const&b=p.box;r["track_ids"].push_back(b.trackId);r["boxes"].push_back({rounded(b.x1,3),rounded(b.y1,3),rounded(b.x2,3),rounded(b.y2,3)});r["det_confidences"].push_back(rounded(b.score,4));}
if(diagnostics) {
r["raw_keypoints"]=xy(raw,9);r["raw_scores"]=scores(raw);r["isolated_keypoints"]=xy(isolated,9);r["isolated_scores"]=scores(isolated);
r["inference_boxes"]=Json::array();for(auto const&p:raw)r["inference_boxes"].push_back({p.box.x1,p.box.y1,p.box.x2,p.box.y2});
}
if(visual||diagnostics) {
r["temporal_selected_keypoints"]=xy(fused.selectedPeople);r["temporal_keypoints"]=xy(fused.smoothedPeople);r["v2_keypoints"]=xy(fused.v2People);
r["temporal_metadata"]=Json::array();r["v2_metadata"]=Json::array();
for(size_t p=0;p<fused.metadata.size();++p) {
Json m=Json::object(),v=Json::object();
for(int s=0;s<2;++s) {auto side=s==0?"left":"right";auto const&t=fused.metadata[p][s];
m[side]={{"limb_state",t.limbConsensus?"consensus":"inferred"},{"hand_source",t.handSource},{"hand_state",t.handState},{"distance_eligible",t.distanceEligible},{"alarm_eligible",t.alarmEligible},{"selected_hand_valid",t.handValid},{"selected_hand_score",t.handScore}};
v[side]={{"state",fangjia::limbStateName(fused.limbs[p*2+s].state)}};
} r["temporal_metadata"].push_back(m);r["v2_metadata"].push_back(v);
}
}
r["global_hands"]=Json::array();
for(auto const& h:fused.globalHands) {
Json points=Json::array(),aliases=Json::array();double cx=0,cy=0;
for(auto const& k:h.keypoints) {points.push_back({rounded(k.x,3),rounded(k.y,3)});cx+=k.x;cy+=k.y;}
for(auto const&a:h.aliases) aliases.push_back({{"person_id",a.first},{"side",a.second?"left":"right"}});
r["global_hands"].push_back({{"person_id",h.personId},{"side",h.left?"left":"right"},{"source",h.source},{"state",h.state},{"distance_eligible",h.distanceEligible},{"alarm_eligible",h.alarmEligible},{"score",rounded(h.score,4)},{"center",{rounded(cx/21,3),rounded(cy/21,3)}},{"span_px",rounded(h.spanPx,3)},{"keypoints",points},{"aliases",aliases}});
}
return r;
}
}
-34
View File
@@ -1,34 +0,0 @@
#pragma once
#include "frame_result.hpp"
#include "door_risk.hpp"
namespace cms {
template<class T> inline std::optional<T> optionalField(Json const& value,char const* key) {
auto it=value.find(key);return it==value.end()||it->is_null()?std::nullopt:std::optional<T>(it->get<T>());
}
inline FrameResult makeFrameResult(Json const& record,Json const& risk,
CaptureMetadata capture) {
FrameResult out;
out.frame_index=record.at("frame_index").get<int>();
out.track_ids=record.at("track_ids").get<std::vector<int>>();
out.boxes=record.at("boxes").get<std::vector<std::array<double,4>>>();
out.capture=capture;
auto& r=out.risk;
r.status=risk.at("status");r.risk_score=optionalField<double>(risk,"risk_score");
r.risk_level=risk.at("risk_level");r.decision=risk.at("decision");
r.source=optionalField<std::string>(risk,"source");r.track_id=optionalField<int>(risk,"track_id");r.side=optionalField<std::string>(risk,"side");
auto const& d=risk.at("door_mask");
r.door_mask={d.at("status"),d.at("annotation_index"),optionalField<std::string>(d,"path"),d.at("format"),d.at("area_px")};
r.warning_distance_relative=risk.at("warning_distance_relative");r.release_distance_relative=risk.at("release_distance_relative");
for(auto const& j:risk.at("regions")) {
RegionRisk v;
v.status=j.at("status");v.risk_score=optionalField<double>(j,"risk_score");
v.risk_level=j.at("risk_level");v.decision=j.at("decision");v.source=j.at("source");
v.track_id=j.at("track_id");v.side=j.at("side");v.state=optionalField<std::string>(j,"state");
v.region_area_px=optionalField<int>(j,"region_area_px");v.door_overlap_px=optionalField<int>(j,"door_overlap_px");
v.region_overlap_ratio=optionalField<double>(j,"region_overlap_ratio");v.min_distance_px=optionalField<double>(j,"min_distance_px");
v.region_scale_px=optionalField<double>(j,"region_scale_px");v.distance_relative=optionalField<double>(j,"distance_relative");
r.regions.push_back(std::move(v));
}
return out;
}
}
-36
View File
@@ -1,36 +0,0 @@
#pragma once
#include "camera_pipeline.hpp"
#include <atomic>
#include <csignal>
#include <pthread.h>
#include <stdexcept>
#include <thread>
namespace cms {
// Construct before starting workers so all threads inherit the signal mask.
// Stop requests run in an ordinary thread, never an asynchronous signal handler.
class StopSignals {
public:
explicit StopSignals(CameraPipeline& pipeline):pipeline_(pipeline) {
sigemptyset(&signals_);sigaddset(&signals_,SIGINT);sigaddset(&signals_,SIGTERM);
if(pthread_sigmask(SIG_BLOCK,&signals_,&old_)!=0)throw std::runtime_error("Cannot block termination signals");
try {
thread_=std::thread([this]{
while(!done_) {
timespec timeout{0,100000000};
int signal=sigtimedwait(&signals_,nullptr,&timeout);
if(signal==SIGINT||signal==SIGTERM){pipeline_.requestStop();break;}
}
});
}catch(...){pthread_sigmask(SIG_SETMASK,&old_,nullptr);throw;}
}
~StopSignals(){done_=true;if(thread_.joinable())thread_.join();pthread_sigmask(SIG_SETMASK,&old_,nullptr);}
StopSignals(StopSignals const&)=delete;
StopSignals& operator=(StopSignals const&)=delete;
private:
CameraPipeline& pipeline_;
sigset_t signals_{},old_{};
std::atomic<bool> done_{false};
std::thread thread_;
};
}
-11
View File
@@ -1,11 +0,0 @@
#pragma once
#include "frame_result.hpp"
#include <opencv2/core.hpp>
namespace cms {
class CameraPipeline;
// Pure drawing from the immutable result. Never re-runs inference.
cv::Mat renderResult(FrameResult const& result);
// Optional observer: closing the window returns without stopping the pipeline.
void runLocalVisualization(CameraPipeline const& pipeline);
}
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
exec "$root/build/camera_risk" --config "$root/config/camera.json" "$@"
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cmake_bin="${CMAKE_COMMAND:-cmake}"
if [[ -x "$HOME/miniconda3/envs/yolo/bin/cmake" ]]; then cmake_bin="${CMAKE_COMMAND:-$HOME/miniconda3/envs/yolo/bin/cmake}"; fi
"$cmake_bin" -S "$root" -B "$root/build" -DCMAKE_BUILD_TYPE=Release -DCMAKE_CUDA_COMPILER="${CUDACXX:-/usr/local/cuda/bin/nvcc}" "$@"
"$cmake_bin" --build "$root/build" -j4
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$root/orin/scripts/initialize_camera.sh" "$@"
-146
View File
@@ -1,146 +0,0 @@
#include "camera.hpp"
#include "uyvy_converter.hpp"
#include <linux/videodev2.h>
#include <sys/eventfd.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <poll.h>
#include <fcntl.h>
#include <unistd.h>
#include <time.h>
#include <cerrno>
#include <cstring>
#include <limits>
#include <stdexcept>
namespace cms {
std::uint64_t monotonicNs() {
timespec time{};
if(clock_gettime(CLOCK_MONOTONIC,&time)!=0)throw std::runtime_error("clock_gettime failed");
return std::uint64_t(time.tv_sec)*1000000000ULL+time.tv_nsec;
}
namespace {
void checkedIoctl(int fd,unsigned long request,void* argument,char const* operation) {
int status;
do {status=ioctl(fd,request,argument);} while(status<0&&errno==EINTR);
if(status<0)throw std::runtime_error(std::string(operation)+": "+std::strerror(errno));
}
}
struct Camera::Impl {
struct Buffer {void* data{};std::size_t size{};};
CameraConfig config;
int fd{-1},stopFd{-1};bool streaming{};
std::uint32_t stride{},lastSequence{};bool haveSequence{};
std::uint64_t index{};
std::uint32_t consecutiveErrors{};
std::atomic<bool> stopped{false};
std::vector<Buffer> buffers;
std::unique_ptr<UyvyConverter> converter;
explicit Impl(CameraConfig c):config(std::move(c)) {
if(config.width<=0||config.width>8192||config.height<=0||config.height>8192||config.width%2||config.fps<=0||config.fps>240||config.buffers<2||config.buffers>32||config.timeout_ms<1)
throw std::invalid_argument("Invalid camera configuration");
try {
stopFd=eventfd(0,EFD_CLOEXEC|EFD_NONBLOCK);
if(stopFd<0)throw std::runtime_error("Cannot create camera stop event");
fd=open(config.device.c_str(),O_RDWR|O_NONBLOCK|O_CLOEXEC);
if(fd<0)throw std::runtime_error("Cannot open "+config.device+": "+std::strerror(errno));
v4l2_capability capability{};checkedIoctl(fd,VIDIOC_QUERYCAP,&capability,"VIDIOC_QUERYCAP");
auto caps=(capability.capabilities&V4L2_CAP_DEVICE_CAPS)?capability.device_caps:capability.capabilities;
if(!(caps&V4L2_CAP_VIDEO_CAPTURE)||!(caps&V4L2_CAP_STREAMING))throw std::runtime_error("Camera does not support single-plane streaming capture");
v4l2_format format{};format.type=V4L2_BUF_TYPE_VIDEO_CAPTURE;
format.fmt.pix.width=config.width;format.fmt.pix.height=config.height;
format.fmt.pix.pixelformat=V4L2_PIX_FMT_UYVY;format.fmt.pix.field=V4L2_FIELD_NONE;
checkedIoctl(fd,VIDIOC_S_FMT,&format,"VIDIOC_S_FMT");
if(format.fmt.pix.pixelformat!=V4L2_PIX_FMT_UYVY||format.fmt.pix.width!=static_cast<unsigned>(config.width)||format.fmt.pix.height!=static_cast<unsigned>(config.height))
throw std::runtime_error("Camera rejected the configured UYVY resolution; refusing mismatched mask coordinates");
stride=format.fmt.pix.bytesperline;
if(stride<static_cast<unsigned>(config.width*2))throw std::runtime_error("Invalid camera row stride");
v4l2_streamparm params{};params.type=V4L2_BUF_TYPE_VIDEO_CAPTURE;
if(ioctl(fd,VIDIOC_G_PARM,&params)==0&&(params.parm.capture.capability&V4L2_CAP_TIMEPERFRAME)) {
params.parm.capture.timeperframe.numerator=1;params.parm.capture.timeperframe.denominator=config.fps;
checkedIoctl(fd,VIDIOC_S_PARM,&params,"VIDIOC_S_PARM");
auto const& interval=params.parm.capture.timeperframe;
if(!interval.numerator||interval.denominator!=static_cast<unsigned>(config.fps)*interval.numerator)
throw std::runtime_error("Camera rejected requested frame rate");
}
v4l2_requestbuffers request{};request.count=config.buffers;
request.type=V4L2_BUF_TYPE_VIDEO_CAPTURE;request.memory=V4L2_MEMORY_MMAP;
checkedIoctl(fd,VIDIOC_REQBUFS,&request,"VIDIOC_REQBUFS");
if(request.count<2)throw std::runtime_error("Insufficient V4L2 buffers");
buffers.resize(request.count);
for(unsigned i=0;i<request.count;++i) {
v4l2_buffer buffer{};buffer.type=request.type;buffer.memory=request.memory;buffer.index=i;
checkedIoctl(fd,VIDIOC_QUERYBUF,&buffer,"VIDIOC_QUERYBUF");
if(buffer.length<std::size_t(stride)*config.height)throw std::runtime_error("Camera buffer smaller than negotiated frame");
buffers[i].size=buffer.length;
auto mapping=mmap(nullptr,buffer.length,PROT_READ|PROT_WRITE,MAP_SHARED,fd,buffer.m.offset);
if(mapping==MAP_FAILED)throw std::runtime_error("Camera mmap failed");
buffers[i].data=mapping;checkedIoctl(fd,VIDIOC_QBUF,&buffer,"VIDIOC_QBUF");
}
converter=std::make_unique<UyvyConverter>(config.width,config.height);
auto type=V4L2_BUF_TYPE_VIDEO_CAPTURE;checkedIoctl(fd,VIDIOC_STREAMON,&type,"VIDIOC_STREAMON");streaming=true;
} catch(...) {release();throw;}
}
~Impl(){release();}
void release() noexcept {
if(streaming){auto type=V4L2_BUF_TYPE_VIDEO_CAPTURE;ioctl(fd,VIDIOC_STREAMOFF,&type);streaming=false;}
for(auto const& b:buffers)if(b.data)munmap(b.data,b.size);
buffers.clear();
if(fd>=0){close(fd);fd=-1;}
if(stopFd>=0){close(stopFd);stopFd=-1;}
}
void stop() noexcept {
stopped=true;std::uint64_t one=1;
if(stopFd>=0){auto ignored=write(stopFd,&one,sizeof(one));(void)ignored;}
}
std::optional<CapturedFrame> readFrame() {
while(!stopped.load()) {
pollfd descriptors[2]={{fd,POLLIN,0},{stopFd,POLLIN,0}};
int ready=poll(descriptors,2,config.timeout_ms);
if(ready<0&&errno==EINTR)continue;
if(stopped.load()||(descriptors[1].revents&POLLIN))return {};
if(ready==0)throw std::runtime_error("Camera capture timeout: "+config.device);
if(ready<0||(descriptors[0].revents&(POLLERR|POLLHUP|POLLNVAL)))throw std::runtime_error("Camera disconnected or poll failed");
v4l2_buffer buffer{};buffer.type=V4L2_BUF_TYPE_VIDEO_CAPTURE;buffer.memory=V4L2_MEMORY_MMAP;
int status=ioctl(fd,VIDIOC_DQBUF,&buffer);
if(status<0&&(errno==EAGAIN||errno==EINTR))continue;
if(status<0)throw std::runtime_error("VIDIOC_DQBUF: "+std::string(std::strerror(errno)));
if(buffer.index>=buffers.size())throw std::runtime_error("Invalid V4L2 buffer index");
// The GMSL driver can mark its startup buffer as corrupt. Requeue
// it without conversion or inference; persistent corruption is fatal.
if(buffer.flags&V4L2_BUF_FLAG_ERROR) {
checkedIoctl(fd,VIDIOC_QBUF,&buffer,"VIDIOC_QBUF(error frame)");
if(++consecutiveErrors>=static_cast<unsigned>(config.fps))
throw std::runtime_error("Camera produced one second of consecutive error frames");
continue;
}
CapturedFrame frame;
try {
if(buffer.bytesused<std::size_t(stride)*config.height||buffer.bytesused>buffers[buffer.index].size)
throw std::runtime_error("Invalid camera frame: flags="+std::to_string(buffer.flags)+", used="+std::to_string(buffer.bytesused)+", required="+std::to_string(std::size_t(stride)*config.height)+", allocated="+std::to_string(buffers[buffer.index].size));
auto& m=frame.metadata;
m.discarded_error_frames=consecutiveErrors;consecutiveErrors=0;
m.driver_sequence=buffer.sequence;m.received_monotonic_ns=monotonicNs();
m.driver_timestamp_ns=std::uint64_t(buffer.timestamp.tv_sec)*1000000000ULL+std::uint64_t(buffer.timestamp.tv_usec)*1000;
m.driver_timestamp_monotonic=(buffer.flags&V4L2_BUF_FLAG_TIMESTAMP_MASK)==V4L2_BUF_FLAG_TIMESTAMP_MONOTONIC;
m.capture_monotonic_ns=m.driver_timestamp_monotonic&&m.driver_timestamp_ns>0&&m.driver_timestamp_ns<=m.received_monotonic_ns?m.driver_timestamp_ns:m.received_monotonic_ns;
if(haveSequence) {
auto delta=static_cast<std::uint32_t>(buffer.sequence-lastSequence);
if(delta==0||delta>0x7fffffffU)throw std::runtime_error("Camera sequence reset or repeated frame");
m.skipped_driver_frames=delta-1;index+=delta;
}
m.frame_index=index;
lastSequence=buffer.sequence;haveSequence=true;
// Convert before requeuing. No published result references MMAP memory.
frame.image=converter->convert(buffers[buffer.index].data,stride);
} catch(...) {ioctl(fd,VIDIOC_QBUF,&buffer);throw;}
checkedIoctl(fd,VIDIOC_QBUF,&buffer,"VIDIOC_QBUF");return frame;
}
return {};
}
};
Camera::Camera(CameraConfig config):impl_(std::make_unique<Impl>(std::move(config))){}
Camera::~Camera()=default;
std::optional<CapturedFrame> Camera::read(){return impl_->readFrame();}
void Camera::requestStop() noexcept {impl_->stop();}
}
-178
View File
@@ -1,178 +0,0 @@
#include "camera_pipeline.hpp"
#include "bounded_queue.hpp"
#include "latest_result.hpp"
#include "detector.hpp"
#include "door_risk.hpp"
#include "records.hpp"
#include "result_conversion.hpp"
#include <opencv2/core.hpp>
#include <atomic>
#include <cmath>
#include <cstring>
#include <exception>
#include <mutex>
#include <limits>
#include <thread>
namespace cms {
namespace {
struct Stream {
cudaStream_t value{};
Stream(){CUDA_CHECK(cudaStreamCreateWithFlags(&value,cudaStreamNonBlocking));}
~Stream(){cudaStreamDestroy(value);}
};
cv::Mat imageView(BgrImage const& image) {
return cv::Mat(image.height,image.width,CV_8UC3,const_cast<std::uint8_t*>(image.pixels.data()),image.stride);
}
double elapsedMs(std::uint64_t start){return (monotonicNs()-start)*1e-6;}
void attachPoses(FrameResult& result,fangjia::PoseFrameResult const& fused) {
for(std::size_t i=0;i<fused.selectedPeople.size();++i) {
auto const& source=fused.selectedPeople[i];
PersonResult person;person.track_id=source.box.trackId;person.confidence=source.box.score;
person.box={source.box.x1,source.box.y1,source.box.x2,source.box.y2};
for(std::size_t k=0;k<133;++k) {
auto const&a=source.keypoints[k];auto const&b=fused.smoothedPeople[i].keypoints[k];auto const&c=fused.v2People[i].keypoints[k];
person.selected[k]={a.x,a.y,a.score};person.smoothed[k]={b.x,b.y,b.score};person.temporal[k]={c.x,c.y,c.score};
}
for(int side=0;side<2;++side){auto const& m=fused.metadata[i][side];person.hands[side]={m.handState,m.handSource,m.limbConsensus,m.distanceEligible,m.alarmEligible};}
result.persons.push_back(std::move(person));
}
for(auto const& source:fused.globalHands) {
HandRegion hand;hand.person_id=source.personId;hand.track_id=source.trackId;hand.side=source.left?"left":"right";
for(std::size_t k=0;k<21;++k)hand.keypoints[k]={source.keypoints[k].x,source.keypoints[k].y,source.keypoints[k].score};
result.hand_regions.push_back(std::move(hand));
}
}
}
struct CameraPipeline::Impl {
struct Detected {CapturedFrame frame;std::vector<fangjia::Box> boxes;double elapsed_ms{};};
PipelineConfig config;
BoundedQueue<CapturedFrame> captures;
BoundedQueue<Detected> detections;
LatestResult results;
fangjia::TrtLogger logger;
std::unique_ptr<Detector> detector;
std::unique_ptr<fangjia::RtmwEngine> pose;
std::unique_ptr<DoorRisk> risk;
std::shared_ptr<const BinaryMask> doorPixels;
std::unique_ptr<Camera> camera;
std::thread captureThread,detectorThread,inferenceThread;
mutable std::mutex stateMutex;
std::exception_ptr failure;
bool started{};
std::atomic<bool> stopped{false},active{false};
std::atomic<std::uint64_t> captured{0},published{0},gaps{0},errorFrames{0};
double totalLatency{};
explicit Impl(PipelineConfig c):config(std::move(c)),captures(config.queue_capacity),detections(config.queue_capacity) {
if(config.queue_capacity>8)throw std::invalid_argument("Queue capacity must be between 1 and 8");
if(config.max_people<1||config.max_people>16||!std::isfinite(config.detector_confidence)||config.detector_confidence<=0||config.detector_confidence>=1)
throw std::invalid_argument("Invalid detector configuration");
if(!std::filesystem::is_regular_file(config.door_mask))throw std::invalid_argument("Missing door mask: "+config.door_mask.string());
}
~Impl(){stop();joinWorkers();}
void joinWorkers() noexcept {
if(captureThread.joinable())captureThread.join();
if(detectorThread.joinable())detectorThread.join();
if(inferenceThread.joinable())inferenceThread.join();
}
void stop() noexcept {
stopped=true;active=false;
// Camera stays alive until all threads have joined. start()/destruction
// are owner-thread operations; requestStop() only interrupts existing work.
{std::lock_guard<std::mutex> lock(stateMutex);if(camera)camera->requestStop();}
captures.close(true);detections.close(true);
}
void fail() noexcept {
{std::lock_guard<std::mutex> lock(stateMutex);if(!failure)failure=std::current_exception();}
stop();
}
void rethrowFailure() const {
std::lock_guard<std::mutex> lock(stateMutex);if(failure)std::rethrow_exception(failure);
}
void start() {
{std::lock_guard<std::mutex> lock(stateMutex);if(started||stopped)throw std::logic_error("CameraPipeline can only be started once");started=true;}
try {
detector=std::make_unique<Detector>(config.engines/"yolo11s.engine",logger,config.detector_confidence,config.max_people);
pose=std::make_unique<fangjia::RtmwEngine>(config.engines/"rtmw_l_384.engine",logger,config.camera.width,config.camera.height,config.max_people);
DoorOptions options;options.constant=config.door_mask;options.warning=config.warning_relative;options.release=config.release_relative;
risk=std::make_unique<DoorRisk>(options,cv::Size(config.camera.width,config.camera.height));risk->initialize();
if(risk->mask().empty()||cv::countNonZero(risk->mask())==0)throw std::runtime_error("Door mask has no usable region");
auto pixels=std::make_shared<BinaryMask>();pixels->width=config.camera.width;pixels->height=config.camera.height;pixels->stride=config.camera.width;
auto const& mask=risk->mask();pixels->pixels.resize(std::size_t(mask.cols)*mask.rows);
for(int y=0;y<mask.rows;++y)std::memcpy(pixels->pixels.data()+std::size_t(y)*mask.cols,mask.ptr(y),mask.cols);
doorPixels=std::move(pixels);
Stream warmup;
pose->warmup(warmup.value);
detector->infer(cv::Mat(config.camera.height,config.camera.width,CV_8UC3,cv::Scalar()),warmup.value);
{std::lock_guard<std::mutex> lock(stateMutex);camera=std::make_unique<Camera>(config.camera);if(stopped)camera->requestStop();}
active=!stopped.load();
captureThread=std::thread([this]{try{captureLoop();}catch(...){fail();}});
detectorThread=std::thread([this]{try{detectorLoop();}catch(...){fail();}});
inferenceThread=std::thread([this]{try{inferenceLoop();}catch(...){fail();}});
}catch(...){stop();joinWorkers();throw;}
}
void captureLoop() {
while(!stopped) {
auto frame=camera->read();if(!frame)break;
gaps+=frame->metadata.skipped_driver_frames;
errorFrames+=frame->metadata.discarded_error_frames;
if(!captures.push(std::move(*frame)))break;
++captured;
}
captures.close();
}
void detectorLoop() {
Stream stream;
while(!stopped) {
auto frame=captures.pop();if(!frame)break;
auto startedAt=monotonicNs();auto boxes=detector->infer(imageView(*frame->image),stream.value);
if(!detections.push({std::move(*frame),std::move(boxes),elapsedMs(startedAt)}))break;
}
detections.close();
}
void inferenceLoop() {
Stream stream;fangjia::BoxTracker boxesTracker;fangjia::PoseFusionTracker fusion;
while(!stopped) {
auto detected=detections.pop();if(!detected)break;
if(detected->frame.metadata.frame_index>static_cast<std::uint64_t>(std::numeric_limits<int>::max()))throw std::runtime_error("Frame index limit reached; restart pipeline");
auto index=static_cast<int>(detected->frame.metadata.frame_index);
auto& boxes=detected->boxes;
auto frame=imageView(*detected->frame.image);
boxesTracker.update(boxes,index,frame.cols,frame.rows);
auto poseStart=monotonicNs();
auto raw=pose->infer(frame,boxes,stream.value);auto isolated=raw;
for(std::size_t p=0;p<boxes.size();++p) {
auto const& box=boxes[p];double area=std::max(double(box.x2-box.x1)*(box.y2-box.y1),1.0);
bool needsIsolation=false;
for(std::size_t q=0;q<boxes.size();++q)if(q!=p&&std::max(double(boxes[q].x2-boxes[q].x1)*(boxes[q].y2-boxes[q].y1),1.0)<0.5*area)needsIsolation=true;
if(needsIsolation){auto masked=maskOtherPeople(frame,boxes,p);isolated[p]=pose->infer(masked,{box},stream.value).at(0);}
}
auto poseElapsed=elapsedMs(poseStart);auto fusionStart=monotonicNs();
auto fused=fusion.update(raw,isolated,index);
auto record=makeRecord(index,raw,isolated,fused,false,false);
auto riskData=risk->evaluate(index,record);
auto result=std::make_shared<FrameResult>(makeFrameResult(record,riskData,detected->frame.metadata));
attachPoses(*result,fused);
result->image=std::move(detected->frame.image);result->door_mask_pixels=doorPixels;
result->door_mask_calibration_verified=config.door_mask_calibration_verified;
result->timing={detected->elapsed_ms,poseElapsed,elapsedMs(fusionStart),elapsedMs(result->capture.capture_monotonic_ns)};
FrameResultPtr immutable=result;
results.publish(std::move(immutable));
{std::lock_guard<std::mutex> lock(stateMutex);totalLatency+=result->timing.capture_to_result_ms;++published;}
}
active=false;
}
};
CameraPipeline::CameraPipeline(PipelineConfig config):impl_(std::make_unique<Impl>(std::move(config))){}
CameraPipeline::~CameraPipeline()=default;
void CameraPipeline::start(){impl_->start();}
FrameResultPtr CameraPipeline::latest()const{return impl_->results.load();}
bool CameraPipeline::running()const noexcept{return impl_->active.load();}
void CameraPipeline::requestStop()noexcept{impl_->stop();}
void CameraPipeline::join(){impl_->joinWorkers();{std::lock_guard<std::mutex> lock(impl_->stateMutex);impl_->camera.reset();}impl_->rethrowFailure();}
PipelineStats CameraPipeline::stats()const {
std::lock_guard<std::mutex> lock(impl_->stateMutex);auto count=impl_->published.load();
return {impl_->captured.load(),count,impl_->gaps.load(),impl_->errorFrames.load(),count?impl_->totalLatency/count:0};
}
}
-24
View File
@@ -1,24 +0,0 @@
#include "config.hpp"
#include "door_risk.hpp"
#include <fstream>
#include <stdexcept>
namespace cms {
PipelineConfig loadConfig(std::filesystem::path const& path) {
std::ifstream file(path);
if(!file)throw std::runtime_error("Cannot read configuration: "+path.string());
auto j=Json::parse(file);PipelineConfig config;
auto const& camera=j.at("camera");
config.camera.device=camera.at("device");config.camera.width=camera.at("width");
config.camera.height=camera.at("height");config.camera.fps=camera.at("fps");
config.camera.buffers=camera.at("buffers");config.camera.timeout_ms=camera.at("timeout_ms");
auto resolve=[&](std::string const& value){auto p=std::filesystem::path(value);return std::filesystem::absolute(p.is_absolute()?p:path.parent_path()/p);};
auto const& inference=j.at("inference");
config.engines=resolve(inference.at("engine_directory"));config.max_people=inference.at("max_people");
config.detector_confidence=inference.at("detector_confidence");config.queue_capacity=inference.at("queue_capacity");
auto const& door=j.at("door");config.door_mask=resolve(door.at("mask"));
config.warning_relative=door.at("warning_relative");config.release_relative=door.at("release_relative");
config.door_mask_calibration_verified=door.at("calibration_verified");
return config;
}
}
-137
View File
@@ -1,137 +0,0 @@
#include "door_risk.hpp"
#include <opencv2/imgproc.hpp>
#include <opencv2/imgcodecs.hpp>
#include <algorithm>
#include <cmath>
#include <fstream>
#include <regex>
#include <stdexcept>
namespace cms {
double rounded(double v, int d) { double s=std::pow(10.0,d); return std::nearbyint(v*s)/s; }
static std::string lower(std::string s) { for(auto& c:s) if(static_cast<unsigned char>(c)<128) c=static_cast<char>(std::tolower(c)); return s; }
static bool raster(std::filesystem::path const& p) { auto e=lower(p.extension().string()); return e==".png"||e==".bmp"||e==".tif"||e==".tiff"; }
DoorRisk::DoorRisk(DoorOptions o, cv::Size size):options_(std::move(o)),size_(size) {
if(options_.constant.empty()==options_.directory.empty()) throw std::runtime_error("Provide exactly one constant mask or door-mask directory");
if(options_.warning<0 || options_.release<=options_.warning) throw std::runtime_error("Risk thresholds require 0 <= warning < release");
format_=options_.format;
if(!options_.constant.empty()) {
if(!std::filesystem::is_regular_file(options_.constant)) throw std::runtime_error("Missing mask: "+options_.constant.string());
if(format_=="auto") format_=lower(options_.constant.extension().string())==".json"?"xanylabeling":"raster";
} else {
std::vector<std::filesystem::path> files;
for(auto const& e:std::filesystem::recursive_directory_iterator(options_.directory)) if(e.is_regular_file()) files.push_back(e.path());
std::sort(files.begin(),files.end());
if(format_=="auto") format_=std::any_of(files.begin(),files.end(),[](auto const& p){return p.extension()==".json";})?"xanylabeling":"raster";
std::regex digits("[0-9]+");
for(auto const& p:files) {
if(format_=="xanylabeling"?p.extension()!=".json":!raster(p)) continue;
int index=-1; std::string stem=p.stem().string();
for(std::sregex_iterator i(stem.begin(),stem.end(),digits),end;i!=end;++i) index=std::stoi(i->str());
if(index<0 && format_=="xanylabeling") { std::ifstream in(p); auto j=Json::parse(in); if(j.contains("frame_index")) index=j["frame_index"].get<int>(); else if(j.contains("frameIndex")) index=j["frameIndex"].get<int>(); }
if(index<0) continue;
if(!paths_.emplace(index,p).second) throw std::runtime_error("Duplicate mask frame: "+std::to_string(index));
}
if(paths_.empty()) throw std::runtime_error("No indexed door masks in "+options_.directory.string());
}
if(format_!="raster" && format_!="xanylabeling") throw std::runtime_error("Unknown door mask format");
}
cv::Mat DoorRisk::rasterize(std::filesystem::path const& path) const {
cv::Mat mask(size_,CV_8U,cv::Scalar(0));
if(format_=="raster") {
auto img=cv::imread(path.string(),cv::IMREAD_UNCHANGED);
if(img.empty()) throw std::runtime_error("Cannot read mask: "+path.string());
if(img.channels()>1) { std::vector<cv::Mat> ch; cv::split(img,ch); img=ch[0]; for(size_t i=1;i<std::min(ch.size(),size_t(3));++i) cv::max(img,ch[i],img); }
if(img.size()!=size_) cv::resize(img,img,size_,0,0,cv::INTER_NEAREST);
return img>0;
}
std::ifstream in(path); auto j=Json::parse(in);
double w=j.contains("imageWidth")&&!j["imageWidth"].is_null()?j["imageWidth"].get<double>():size_.width;
double h=j.contains("imageHeight")&&!j["imageHeight"].is_null()?j["imageHeight"].get<double>():size_.height;
if(w==0) w=size_.width;
if(h==0) h=size_.height;
float sx=static_cast<float>(size_.width/std::max(w,1.0)),sy=static_cast<float>(size_.height/std::max(h,1.0));
for(auto const& s:j.value("shapes",Json::array())) {
if(!options_.labels.count(lower(s.value("label",std::string())))) continue;
std::vector<cv::Point> points;
for(auto const& p:s.value("points",Json::array())) points.emplace_back(static_cast<int>(std::nearbyint(p[0].get<float>()*sx)),static_cast<int>(std::nearbyint(p[1].get<float>()*sy)));
auto type=lower(s.value("shape_type",std::string("polygon")));
if(points.size()<2) continue;
if(type=="rectangle") cv::rectangle(mask,points[0],points[1],255,-1);
else if(type=="circle") cv::circle(mask,points[0],static_cast<int>(std::nearbyint(cv::norm(points[1]-points[0]))),255,-1);
else if(points.size()>=3) cv::fillPoly(mask,std::vector<std::vector<cv::Point>>{points},255);
}
return mask;
}
void DoorRisk::load(int frame) {
if(cached_) return;
loadedPath_.clear(); mask_.release(); distance_.release();
if(!options_.constant.empty()) loadedPath_=options_.constant;
else { auto it=paths_.find(frame+options_.offset); if(it!=paths_.end()) loadedPath_=it->second; }
if(loadedPath_.empty()) {
status_="missing";
if(options_.require) throw std::runtime_error("No door mask for frame "+std::to_string(frame));
return;
}
mask_=rasterize(loadedPath_);
status_=cv::countNonZero(mask_)?"loaded":"empty";
if(status_=="loaded") cv::distanceTransform(mask_==0,distance_,cv::DIST_L2,cv::DIST_MASK_PRECISE);
cached_=!options_.constant.empty();
}
Json DoorRisk::evaluate(int frame, Json& record) {
load(frame);
Json regions=Json::array(),fallbacks=Json::array(),nearest=nullptr;
std::set<int> withHands;
auto measure=[&](cv::Mat const& region, std::string const& source,int track,std::string const& side,std::string const& state) {
Json m={{"status","door_mask_"+status_},{"risk_score",nullptr},{"risk_level","unavailable"},{"decision","EVIDENCE_UNAVAILABLE"},{"source",source},{"track_id",track},{"side",side}};
if(status_=="loaded") {
int area=cv::countNonZero(region); cv::Mat intersection; cv::bitwise_and(region,mask_,intersection); int overlap=cv::countNonZero(intersection);
double distance=0; cv::minMaxLoc(distance_,&distance,nullptr,nullptr,nullptr,region);
double scale=std::max(std::sqrt(double(area)),1.0),relative=distance/scale;
double score=overlap?1:std::clamp(1-relative/options_.release,0.0,1.0);
std::string level=overlap?"danger":relative<=options_.warning?"warning":relative<=options_.release?"watch":"clear";
std::string decision=overlap?"DANGER_INTERSECTION":relative<=options_.warning?"WARNING_NEAR":relative<=options_.release?"WATCH":"CLEAR";
m={{"status","calculated"},{"risk_score",rounded(score,5)},{"risk_level",level},{"decision",decision},{"source",source},{"track_id",track},{"side",side},{"state",state},{"region_area_px",area},{"door_overlap_px",overlap},{"region_overlap_ratio",rounded(double(overlap)/std::max(area,1),6)},{"min_distance_px",rounded(distance,3)},{"region_scale_px",rounded(scale,3)},{"distance_relative",rounded(relative,6)}};
if(nearest.is_null() || m["risk_score"].get<double>()>nearest["risk_score"].get<double>()) nearest=m;
}
regions.push_back(m); return m;
};
auto const ids=record.value("track_ids",Json::array());
auto trackFor=[&](int pos){return pos>=0&&pos<int(ids.size())?ids[pos].get<int>():pos+1;};
if(!record.contains("global_hands")) record["global_hands"]=Json::array();
for(auto& hand:record["global_hands"]) {
int pos=hand.value("person_id",0)-1; std::vector<cv::Point> points;
bool finite=true;
for(auto const& p:hand.value("keypoints",Json::array())) {
float x=p[0].get<float>(),y=p[1].get<float>();
if(!std::isfinite(x)||!std::isfinite(y)) {finite=false;break;}
points.emplace_back(static_cast<int>(std::nearbyint(x)),static_cast<int>(std::nearbyint(y)));
}
if(!finite||points.size()<3) continue;
std::vector<cv::Point> hull; cv::convexHull(points,hull);
cv::Mat region(size_,CV_8U,cv::Scalar(0)); cv::fillConvexPoly(region,hull,255);
if(!cv::countNonZero(region)) continue;
withHands.insert(pos);
hand["risk"]=measure(region,"RTMW_HAND_KEYPOINTS",trackFor(pos),hand.value("side",std::string("unknown")),hand.value("state",std::string("unknown")));
}
auto const boxes=record.value("boxes",Json::array());
for(int pos=0;pos<int(boxes.size());++pos) {
if(withHands.count(pos)) continue;
auto const& b=boxes[pos]; if(b.size()<4) continue;
int x1=std::clamp(int(std::nearbyint(b[0].get<double>())),0,size_.width-1),y1=std::clamp(int(std::nearbyint(b[1].get<double>())),0,size_.height-1);
int x2=std::clamp(int(std::nearbyint(b[2].get<double>())),0,size_.width-1),y2=std::clamp(int(std::nearbyint(b[3].get<double>())),0,size_.height-1);
if(x1>x2) std::swap(x1,x2);
if(y1>y2) std::swap(y1,y2);
if(x2<=x1||y2<=y1) continue;
cv::Mat region(size_,CV_8U,cv::Scalar(0)); cv::rectangle(region,{x1,y1},{x2,y2},255,-1);
Json fallback={{"track_id",trackFor(pos)},{"side","body"},{"state","rtmw_hand_unavailable"},{"body_zone_box",b}};
fallback["risk"]=measure(region,"YOLO_BODY_BOX_FALLBACK",trackFor(pos),"body","rtmw_hand_unavailable"); fallbacks.push_back(fallback);
}
record["body_fallbacks"]=fallbacks;
Json result={{"status",status_!="loaded"?"door_mask_"+status_:nearest.is_null()?"person_region_missing":"calculated"}};
for(auto const* key:{"risk_score","risk_level","decision","source","track_id","side"}) result[key]=nearest.is_null()?Json(nullptr):nearest[key];
if(nearest.is_null()) {result["risk_level"]="unavailable";result["decision"]="EVIDENCE_UNAVAILABLE";}
result["door_mask"]={{"status",status_},{"annotation_index",options_.constant.empty()?frame+options_.offset:frame},{"path",loadedPath_.empty()?Json(nullptr):Json(std::filesystem::absolute(loadedPath_).string())},{"format",format_},{"area_px",mask_.empty()?0:cv::countNonZero(mask_)}};
result["warning_distance_relative"]=options_.warning; result["release_distance_relative"]=options_.release; result["regions"]=regions;
return result;
}
}
-43
View File
@@ -1,43 +0,0 @@
#include "camera_pipeline.hpp"
#include "config.hpp"
#include "stop_signals.hpp"
#include "visualizer.hpp"
#include <functional>
#include <iostream>
#include <map>
int main(int argc,char** argv)try {
auto root=std::filesystem::canonical("/proc/self/exe").parent_path().parent_path();
auto configPath=root/"config/camera.json";
bool visualize=false;
std::map<std::string,std::function<void(int&)>> arguments{
{"--visualize",[&](int&){visualize=true;}},
{"--config",[&](int&i){if(++i>=argc)throw std::invalid_argument("--config needs a file");configPath=argv[i];}},
{"--help",[](int&){std::cout<<"camera_risk [--config FILE] [--visualize]\nContinuous camera inference; latest FrameResult stays in memory. Stop with Ctrl+C or SIGTERM.\n";std::exit(0);}}
};
for(int i=1;i<argc;++i){auto handler=arguments.find(argv[i]);if(handler==arguments.end())throw std::invalid_argument("Unknown option: "+std::string(argv[i]));handler->second(i);}
cms::CameraPipeline pipeline(cms::loadConfig(configPath));
cms::StopSignals signals(pipeline);
pipeline.start();
/*
* 下游使用示例:在下游自己的线程/调用位置读取最新结果。
* 首帧尚未完成时 result 为空;读取不会影响取图和推理。
*
* auto result = pipeline.latest();
* if (result) {
* const auto& risk = result->risk;
* const auto& people = result->persons;
* // result->frame_index / track_ids / boxes
* // result->image / door_mask_pixels / capture / timing
* // people[i].selected / smoothed / temporal:各 133 个关键点
* }
*/
// Optional observer. Closing/failing the window never requests inference stop.
if(visualize) {
try {cms::runLocalVisualization(pipeline);}
catch(std::exception const& e){std::cerr<<"Visualization unavailable; inference continues: "<<e.what()<<std::endl;}
}
pipeline.join();
return 0;
}catch(std::exception const& e){std::cerr<<"Camera pipeline failed: "<<e.what()<<std::endl;return 1;}
-875
View File
@@ -1,875 +0,0 @@
#include "pose_engine.hpp"
#include "common.hpp"
#include "gpu_preprocess.hpp"
#include <opencv2/imgproc.hpp>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <numeric>
#include <set>
#include <tuple>
namespace fangjia {
namespace {
constexpr int kInputWidth = 288;
constexpr int kInputHeight = 384;
constexpr int kKeypoints = 133;
constexpr int kSimccX = 576;
constexpr int kSimccY = 768;
__global__ void poseNormalizeKernel(
float* output, std::uint8_t const* input, int people, int inputStep) {
int index = blockIdx.x * blockDim.x + threadIdx.x;
int pixels = kInputWidth * kInputHeight;
int total = people * pixels;
if (index >= total) return;
int person = index / pixels;
int pixel = index - person * pixels;
int y = pixel / kInputWidth;
int x = pixel - y * kInputWidth;
auto const* value = input + person * inputStep * kInputHeight + y * inputStep + x * 3;
float* destination = output + static_cast<std::size_t>(person) * 3 * pixels;
destination[pixel] = static_cast<float>((static_cast<double>(value[0]) - 123.675) / 58.395);
destination[pixels + pixel] = static_cast<float>((static_cast<double>(value[1]) - 116.28) / 57.12);
destination[2 * pixels + pixel] = static_cast<float>((static_cast<double>(value[2]) - 103.53) / 57.375);
}
float iou(Box const& a, Box const& b) {
float x1 = std::max(a.x1, b.x1);
float y1 = std::max(a.y1, b.y1);
float x2 = std::min(a.x2, b.x2);
float y2 = std::min(a.y2, b.y2);
float intersection = std::max(x2 - x1, 0.0F) * std::max(y2 - y1, 0.0F);
float areaA = std::max(a.x2 - a.x1, 0.0F) * std::max(a.y2 - a.y1, 0.0F);
float areaB = std::max(b.x2 - b.x1, 0.0F) * std::max(b.y2 - b.y1, 0.0F);
return intersection / std::max(areaA + areaB - intersection, 1.0F);
}
float distance(float x1, float y1, float x2, float y2) {
return std::hypot(x1 - x2, y1 - y2);
}
} // namespace
std::vector<int> BoxTracker::update(
std::vector<Box>& boxes, int frameIndex, int width, int height) {
// Tracks older than Python's max_age can never be matched again.
// Reclaim them to bound memory during continuous camera operation.
for(auto it=tracks_.begin();it!=tracks_.end();) {
if(frameIndex-it->second.lastFrame>30)it=tracks_.erase(it);
else ++it;
}
struct Pair { float score; int detection; int track; };
std::vector<Pair> pairs;
float imageDiagonal = std::hypot(static_cast<float>(width), static_cast<float>(height));
for (int d = 0; d < static_cast<int>(boxes.size()); ++d) {
float cx = 0.5F * (boxes[d].x1 + boxes[d].x2);
float cy = 0.5F * (boxes[d].y1 + boxes[d].y2);
for (auto const& [id, track] : tracks_) {
if (frameIndex - track.lastFrame > 30) continue;
float pcx = 0.5F * (track.box.x1 + track.box.x2);
float pcy = 0.5F * (track.box.y1 + track.box.y2);
float overlap = iou(boxes[d], track.box);
float centerDistance = distance(cx, cy, pcx, pcy) / imageDiagonal;
if (overlap >= 0.05F || centerDistance <= 0.18F) {
pairs.push_back({2.0F * overlap + std::max(0.0F, 1.0F - centerDistance / 0.18F), d, id});
}
}
}
std::sort(pairs.begin(), pairs.end(), [](Pair const& a, Pair const& b) {
if (a.score != b.score) return a.score > b.score;
if (a.detection != b.detection) return a.detection > b.detection;
return a.track > b.track;
});
std::vector<int> result(boxes.size(), 0);
std::unordered_map<int, bool> assigned;
for (auto const& pair : pairs) {
if (result[pair.detection] || assigned[pair.track]) continue;
result[pair.detection] = pair.track;
assigned[pair.track] = true;
}
for (int d = 0; d < static_cast<int>(boxes.size()); ++d) {
if (!result[d]) result[d] = nextId_++;
boxes[d].trackId = result[d];
tracks_[result[d]] = {boxes[d], frameIndex};
}
return result;
}
RtmwEngine::RtmwEngine(
std::filesystem::path const& enginePath, TrtLogger& logger,
int maxSourceWidth, int maxSourceHeight, int maxPeople)
: engine_(enginePath, logger), maxSourceWidth_(maxSourceWidth),
maxSourceHeight_(maxSourceHeight), maxPeople_(maxPeople),
centersHost_(maxPeople * 2), scalesHost_(maxPeople * 2),
simccXHost_(static_cast<std::size_t>(maxPeople) * kKeypoints * kSimccX),
simccYHost_(static_cast<std::size_t>(maxPeople) * kKeypoints * kSimccY) {
engine_.setInputShape("input", nvinfer1::Dims4{1,3,kInputHeight,kInputWidth});
engine_.prepare();
auto xs=engine_.shape("simcc_x"),ys=engine_.shape("simcc_y");
if(xs.nbDims!=3||ys.nbDims!=3||xs.d[0]!=1||ys.d[0]!=1||xs.d[1]!=kKeypoints||ys.d[1]!=kKeypoints||xs.d[2]!=kSimccX||ys.d[2]!=kSimccY)
throw std::runtime_error("RTMW engine I/O shape does not match the 133-point model");
for(auto name:{"input","simcc_x","simcc_y"})if(engine_.dataType(name)!=nvinfer1::DataType::kFLOAT)
throw std::runtime_error("RTMW requires float32 I/O bindings");
std::size_t const values = static_cast<std::size_t>(maxPeople) * 3 * kInputWidth * kInputHeight;
CUDA_CHECK(cudaMalloc(
&cropsDevice_, static_cast<std::size_t>(maxPeople_) * kInputWidth * kInputHeight * 3));
CUDA_CHECK(cudaMalloc(&networkDevice_, values * sizeof(float)));
CUDA_CHECK(cudaMalloc(&sourceDevice_, static_cast<size_t>(maxSourceWidth_)*maxSourceHeight_*3));
}
RtmwEngine::~RtmwEngine() {
if (sourceDevice_) cudaFree(sourceDevice_);
if (cropsDevice_) cudaFree(cropsDevice_);
if (networkDevice_) cudaFree(networkDevice_);
}
void RtmwEngine::warmup(cudaStream_t stream) {
std::vector<Box> boxes{{0, 0, static_cast<float>(maxSourceWidth_), static_cast<float>(maxSourceHeight_), 1, 1}};
cv::Mat black(maxSourceHeight_, maxSourceWidth_, CV_8UC3, cv::Scalar(0, 0, 0));
for (int i = 0; i < 2; ++i) infer(black, boxes, stream);
}
std::vector<PersonPose> RtmwEngine::infer(
cv::Mat const& frame, std::vector<Box> const& boxes, cudaStream_t stream,
float* preprocessMs, float* inferenceMs, float* postprocessMs) {
int batch = std::min(static_cast<int>(boxes.size()), maxPeople_);
if (batch <= 0) return {};
auto const preprocessStarted = std::chrono::steady_clock::now();
int const cropStep = kInputWidth * 3;
int const pixels = kInputWidth * kInputHeight;
if (frame.cols>maxSourceWidth_ || frame.rows>maxSourceHeight_) throw std::runtime_error("Pose input exceeds allocation");
auto npp = cms::nppContext(stream);
CUDA_CHECK(cudaMemcpy2DAsync(sourceDevice_, frame.cols*3, frame.data, frame.step, frame.cols*3, frame.rows, cudaMemcpyHostToDevice, stream));
for (int i = 0; i < batch; ++i) {
auto const& box = boxes[i];
double width = (static_cast<double>(box.x2) - static_cast<double>(box.x1)) * 1.25;
double height = (static_cast<double>(box.y2) - static_cast<double>(box.y1)) * 1.25;
constexpr double aspect = static_cast<double>(kInputWidth) / kInputHeight;
if (width > height * aspect) height = width / aspect;
else width = height * aspect;
centersHost_[i * 2] = 0.5 * (static_cast<double>(box.x1) + static_cast<double>(box.x2));
centersHost_[i * 2 + 1] = 0.5 * (static_cast<double>(box.y1) + static_cast<double>(box.y2));
scalesHost_[i * 2] = width;
scalesHost_[i * 2 + 1] = height;
cv::Point2f source[3]{
{static_cast<float>(centersHost_[i * 2]), static_cast<float>(centersHost_[i * 2 + 1])},
{static_cast<float>(centersHost_[i * 2]), static_cast<float>(centersHost_[i * 2 + 1] - width * 0.5)},
{static_cast<float>(centersHost_[i * 2] - width * 0.5), static_cast<float>(centersHost_[i * 2 + 1] - width * 0.5)}};
cv::Point2f destination[3]{
{kInputWidth * 0.5F, kInputHeight * 0.5F},
{kInputWidth * 0.5F, kInputHeight * 0.5F - kInputWidth * 0.5F},
{0.0F, kInputHeight * 0.5F - kInputWidth * 0.5F}};
cv::Mat affine = cv::getAffineTransform(source, destination);
auto* cropDevice=cropsDevice_+static_cast<size_t>(i)*cropStep*kInputHeight;
Npp8u fill[3]={0,0,0};
cms::nppCheck(nppiSet_8u_C3R_Ctx(fill,cropDevice,cropStep,{kInputWidth,kInputHeight},npp));
double coeff[2][3];
for(int row=0;row<2;++row) for(int col=0;col<3;++col) coeff[row][col]=affine.at<double>(row,col);
cms::nppCheck(nppiWarpAffine_8u_C3R_Ctx(sourceDevice_,{frame.cols,frame.rows},frame.cols*3,{0,0,frame.cols,frame.rows},cropDevice,cropStep,{0,0,kInputWidth,kInputHeight},coeff,NPPI_INTER_LINEAR,npp));
}
int const totalPixels = batch * pixels;
poseNormalizeKernel<<<(totalPixels + 255) / 256, 256, 0, stream>>>(
networkDevice_, cropsDevice_, batch, cropStep);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaStreamSynchronize(stream));
if (preprocessMs) {
*preprocessMs = static_cast<float>(std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - preprocessStarted).count());
}
// RTMPose's Python API invokes the ONNX/TensorRT session once per person.
// Preserve that execution contract instead of batching people together.
engine_.setInputShape("input", nvinfer1::Dims4{1, 3, kInputHeight, kInputWidth});
engine_.prepare();
CudaEventTimer timer;
float inferTime = 0.0F;
std::size_t const inputValues = static_cast<std::size_t>(3) * pixels;
std::size_t const xValues = static_cast<std::size_t>(kKeypoints) * kSimccX;
std::size_t const yValues = static_cast<std::size_t>(kKeypoints) * kSimccY;
for (int person = 0; person < batch; ++person) {
CUDA_CHECK(cudaMemcpyAsync(
engine_.device("input"), networkDevice_ + static_cast<std::size_t>(person) * inputValues,
inputValues * sizeof(float), cudaMemcpyDeviceToDevice, stream));
timer.start(stream);
if (!engine_.enqueue(stream)) throw std::runtime_error("RTMW enqueue failed");
inferTime += timer.stop(stream);
CUDA_CHECK(cudaMemcpyAsync(
simccXHost_.data() + static_cast<std::size_t>(person) * xValues,
engine_.device("simcc_x"), xValues * sizeof(float), cudaMemcpyDeviceToHost, stream));
CUDA_CHECK(cudaMemcpyAsync(
simccYHost_.data() + static_cast<std::size_t>(person) * yValues,
engine_.device("simcc_y"), yValues * sizeof(float), cudaMemcpyDeviceToHost, stream));
}
if (inferenceMs) *inferenceMs = inferTime;
auto const postprocessStarted = std::chrono::steady_clock::now();
CUDA_CHECK(cudaStreamSynchronize(stream));
std::vector<PersonPose> result(batch);
for (int person = 0; person < batch; ++person) {
result[person].box = boxes[person];
for (int keypoint = 0; keypoint < kKeypoints; ++keypoint) {
auto xBegin = simccXHost_.begin() + (person * kKeypoints + keypoint) * kSimccX;
auto yBegin = simccYHost_.begin() + (person * kKeypoints + keypoint) * kSimccY;
auto xIt = std::max_element(xBegin, xBegin + kSimccX);
auto yIt = std::max_element(yBegin, yBegin + kSimccY);
float score = 0.5F * (*xIt + *yIt);
if(!std::isfinite(score))throw std::runtime_error("Non-finite RTMW output");
float localX = score > 0.0F ? static_cast<float>(xIt - xBegin) * 0.5F : -0.5F;
float localY = score > 0.0F ? static_cast<float>(yIt - yBegin) * 0.5F : -0.5F;
double scaleX = scalesHost_[person * 2];
double scaleY = scalesHost_[person * 2 + 1];
result[person].keypoints[keypoint] = {
static_cast<float>(static_cast<double>(localX) / kInputWidth * scaleX + centersHost_[person * 2] - scaleX * 0.5),
static_cast<float>(static_cast<double>(localY) / kInputHeight * scaleY + centersHost_[person * 2 + 1] - scaleY * 0.5),
score};
}
}
float post = static_cast<float>(std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - postprocessStarted).count());
if (postprocessMs) *postprocessMs = post;
return result;
}
PoseFrameResult PoseFusionTracker::update(
std::vector<PersonPose> const& rawPeople,
std::vector<PersonPose> const& isolatedPeople,
int frameIndex) {
for(auto it=states_.begin();it!=states_.end();) {
if(it->second.lastFrame>=0&&frameIndex-it->second.lastFrame>30)it=states_.erase(it);
else ++it;
}
struct SideMeta {
bool limbConsensus{};
bool handValid{};
bool distanceEligible{};
bool alarmEligible{};
double handScore{};
double linkToSelectedLimb{};
std::string handSource;
std::string handState;
};
struct FusedPerson {
PersonPose pose;
std::array<SideMeta, 2> meta;
};
struct Measurement {
std::array<Vec2, 21> hand{};
bool precise{};
bool provisional{};
double quality{};
std::string source{"model"};
};
auto vec = [](Keypoint const& point) { return Vec2{point.x, point.y}; };
auto add = [](Vec2 a, Vec2 b) { return Vec2{a.x + b.x, a.y + b.y}; };
auto sub = [](Vec2 a, Vec2 b) { return Vec2{a.x - b.x, a.y - b.y}; };
auto mul = [](Vec2 a, double value) { return Vec2{a.x * value, a.y * value}; };
auto norm = [](Vec2 a) { return std::hypot(a.x, a.y); };
auto median = [](std::vector<double> values) {
if (values.empty()) return 0.0;
std::sort(values.begin(), values.end());
auto middle = values.size() / 2;
return values.size() % 2 ? values[middle] : 0.5 * (values[middle - 1] + values[middle]);
};
auto percentile90 = [](std::vector<double> values) {
if (values.empty()) return 0.0;
std::sort(values.begin(), values.end());
double position = 0.9 * (values.size() - 1);
auto lower = static_cast<std::size_t>(position);
auto upper = std::min(lower + 1, values.size() - 1);
return values[lower] * (upper - position) + values[upper] * (position - lower);
};
auto boxDiagonal = [](Box const& box) {
return std::max(std::hypot(box.x2 - box.x1, box.y2 - box.y1), 1.0F);
};
auto normalized = [](Keypoint const& point, Box const& box) {
return Vec2{
(point.x - box.x1) / std::max(box.x2 - box.x1, 1.0F),
(point.y - box.y1) / std::max(box.y2 - box.y1, 1.0F)};
};
auto denormalized = [](Vec2 point, Box const& box) {
return Vec2{
point.x * std::max(box.x2 - box.x1, 1.0F) + box.x1,
point.y * std::max(box.y2 - box.y1, 1.0F) + box.y1};
};
auto solveTwoBoneElbow = [&](Vec2 shoulder, Vec2 wrist, double upperLength,
double forearmLength, Vec2 reference) {
Vec2 direction = sub(wrist, shoulder);
double length = norm(direction);
if (length < 1e-6) return reference;
Vec2 unit = mul(direction, 1.0 / length);
if (length >= upperLength + forearmLength) return add(shoulder, mul(unit, upperLength));
if (length <= std::abs(upperLength - forearmLength)) {
double sign = upperLength >= forearmLength ? 1.0 : -1.0;
return add(shoulder, mul(unit, upperLength * sign));
}
double along = (upperLength * upperLength - forearmLength * forearmLength + length * length) /
(2.0 * length);
double height = std::sqrt(std::max(upperLength * upperLength - along * along, 0.0));
Vec2 base = add(shoulder, mul(unit, along));
Vec2 perpendicular{-unit.y, unit.x};
Vec2 first = add(base, mul(perpendicular, height));
Vec2 second = sub(base, mul(perpendicular, height));
return norm(sub(first, reference)) <= norm(sub(second, reference)) ? first : second;
};
struct Candidate {
PersonPose const* pose{};
std::string source;
std::array<Vec2, 3> limb{};
std::array<Vec2, 21> hand{};
double limbScore{};
double handScore{};
double handSpan{};
double wristLink{};
bool limbValid{};
bool handValid{};
};
auto buildCandidate = [&](PersonPose const& pose, int side, std::string source) {
int shoulder = side == 0 ? 5 : 6;
int elbow = side == 0 ? 7 : 8;
int wrist = side == 0 ? 9 : 10;
int handStart = side == 0 ? 91 : 112;
Candidate candidate;
candidate.pose = &pose;
candidate.source = std::move(source);
candidate.limb = {normalized(pose.keypoints[shoulder], pose.box),
normalized(pose.keypoints[elbow], pose.box),
normalized(pose.keypoints[wrist], pose.box)};
double minX = pose.keypoints[handStart].x, maxX = minX;
double minY = pose.keypoints[handStart].y, maxY = minY;
for (int index = 0; index < 21; ++index) {
candidate.hand[index] = normalized(pose.keypoints[handStart + index], pose.box);
minX = std::min<double>(minX, pose.keypoints[handStart + index].x);
maxX = std::max<double>(maxX, pose.keypoints[handStart + index].x);
minY = std::min<double>(minY, pose.keypoints[handStart + index].y);
maxY = std::max<double>(maxY, pose.keypoints[handStart + index].y);
candidate.handScore += pose.keypoints[handStart + index].score;
}
candidate.handScore /= 21.0;
candidate.limbScore = (pose.keypoints[shoulder].score + pose.keypoints[elbow].score + pose.keypoints[wrist].score) / 3.0;
double upper = norm(sub(candidate.limb[0], candidate.limb[1]));
double forearm = norm(sub(candidate.limb[1], candidate.limb[2]));
candidate.handSpan = std::hypot(maxX - minX, maxY - minY) / boxDiagonal(pose.box);
candidate.wristLink = distance(
pose.keypoints[wrist].x, pose.keypoints[wrist].y,
pose.keypoints[handStart].x, pose.keypoints[handStart].y) / boxDiagonal(pose.box);
candidate.limbValid = upper >= 0.025 && upper <= 0.8 && forearm >= 0.025 && forearm <= 0.8;
candidate.handValid = candidate.handSpan >= 0.008 && candidate.handSpan <= 0.35 && candidate.wristLink <= 0.12;
return candidate;
};
auto robustDistance = [&](auto const& points, auto const& target, bool available) {
if (!available) return 0.0;
std::vector<double> distances;
for (std::size_t index = 0; index < points.size(); ++index) {
distances.push_back(norm(sub(points[index], target[index])));
}
return median(std::move(distances));
};
std::vector<FusedPerson> fused;
fused.reserve(rawPeople.size());
for (std::size_t personIndex = 0; personIndex < rawPeople.size(); ++personIndex) {
auto const& raw = rawPeople[personIndex];
auto const& isolated = personIndex < isolatedPeople.size() ? isolatedPeople[personIndex] : raw;
auto& state = states_[raw.box.trackId];
FusedPerson output;
output.pose = raw;
for (int side = 0; side < 2; ++side) {
int shoulder = side == 0 ? 5 : 6;
int elbow = side == 0 ? 7 : 8;
int wrist = side == 0 ? 9 : 10;
int handStart = side == 0 ? 91 : 112;
auto& track = state.fusion[side];
std::array<Candidate, 2> candidates{
buildCandidate(raw, side, "raw"), buildCandidate(isolated, side, "isolated")};
int limbChoice = 0;
double bestLimbScore = -1e30;
for (int choice = 0; choice < 2; ++choice) {
std::array<Vec2, 3> target{};
if (track.hasLimbPrevious) {
for (int i = 0; i < 3; ++i) target[i] = add(track.limbPrevious[i], track.hasLimbVelocity ? mul(track.limbVelocity[i], 0.65) : Vec2{});
}
double temporal = robustDistance(candidates[choice].limb, target, track.hasLimbPrevious);
double score = candidates[choice].limbScore + (candidates[choice].limbValid ? 1.2 : -5.0)
+ (track.limbSource == candidates[choice].source ? 0.35 : 0.0) - 7.0 * temporal;
if (score > bestLimbScore) { bestLimbScore = score; limbChoice = choice; }
}
int handChoice = 0;
double bestHandScore = -1e30;
double chosenTemporal = 0.0;
double chosenLink = 1.0;
for (int choice = 0; choice < 2; ++choice) {
std::array<Vec2, 21> target{};
if (track.hasHandPrevious) {
for (int i = 0; i < 21; ++i) target[i] = add(track.handPrevious[i], track.hasHandVelocity ? mul(track.handVelocity[i], 0.65) : Vec2{});
}
double temporal = robustDistance(candidates[choice].hand, target, track.hasHandPrevious);
auto const& limbPose = *candidates[limbChoice].pose;
double link = distance(
candidates[choice].pose->keypoints[handStart].x,
candidates[choice].pose->keypoints[handStart].y,
limbPose.keypoints[wrist].x, limbPose.keypoints[wrist].y) / boxDiagonal(raw.box);
double linkPenalty = 1.5 * std::min(std::max(link - 0.12, 0.0), 0.2);
double score = candidates[choice].handScore + (candidates[choice].handValid ? 3.0 : -7.0)
+ (track.handSource == candidates[choice].source ? 0.45 : 0.0)
+ (candidates[choice].source == candidates[limbChoice].source ? 0.15 : 0.0)
- linkPenalty - 8.0 * std::min(temporal, 0.15);
if (score > bestHandScore) {
bestHandScore = score;
handChoice = choice;
chosenTemporal = temporal;
chosenLink = link;
}
}
double endpointAdvantage = candidates[handChoice].handScore - candidates[1 - handChoice].handScore;
if (handChoice != limbChoice && candidates[handChoice].handValid && endpointAdvantage >= 0.6) {
limbChoice = handChoice;
// fuse_person() rewrites this link after its visible-endpoint
// override. Temporal recovery consumes the rewritten value.
chosenLink = candidates[handChoice].wristLink;
}
auto const& limb = candidates[limbChoice];
auto const& hand = candidates[handChoice];
for (int index : {shoulder, elbow, wrist}) output.pose.keypoints[index] = limb.pose->keypoints[index];
for (int index = 0; index < 21; ++index) output.pose.keypoints[handStart + index] = hand.pose->keypoints[handStart + index];
std::vector<double> limbDistances, handDistances;
for (int index = 0; index < 3; ++index) limbDistances.push_back(norm(sub(candidates[0].limb[index], candidates[1].limb[index])));
for (int index = 0; index < 21; ++index) handDistances.push_back(norm(sub(candidates[0].hand[index], candidates[1].hand[index])));
double handDisagreement = percentile90(handDistances);
double scoreAdvantage = candidates[0].handScore - candidates[1].handScore;
auto& meta = output.meta[side];
meta.limbConsensus = median(limbDistances) <= 0.05;
meta.handValid = hand.handValid;
meta.distanceEligible = hand.handValid && (
handDisagreement <= 0.06 ||
(hand.source == "raw" && scoreAdvantage >= 0.6 && chosenTemporal <= 0.08));
meta.alarmEligible = meta.distanceEligible || (hand.handValid && hand.source == "raw" && scoreAdvantage >= 0.6);
meta.handScore = std::nearbyint(hand.handScore * 1e4) / 1e4;
meta.linkToSelectedLimb = std::nearbyint(chosenLink * 1e5) / 1e5;
meta.handSource = hand.source;
if (!hand.handValid) meta.handState = "rejected";
else if (handDisagreement <= 0.06) meta.handState = "consensus";
else if (hand.source == "raw" && scoreAdvantage >= 0.6 && chosenTemporal <= 0.08) meta.handState = "visible_endpoint";
else if (hand.source == "isolated") meta.handState = "inferred";
else meta.handState = "conflict";
if (track.hasLimbPrevious) {
for (int i = 0; i < 3; ++i) {
auto delta = sub(limb.limb[i], track.limbPrevious[i]);
track.limbVelocity[i] = track.hasLimbVelocity ? add(mul(track.limbVelocity[i], 0.65), mul(delta, 0.35)) : delta;
}
track.hasLimbVelocity = true;
}
if (hand.handValid) {
if (track.hasHandPrevious) {
for (int i = 0; i < 21; ++i) {
auto delta = sub(hand.hand[i], track.handPrevious[i]);
track.handVelocity[i] = track.hasHandVelocity ? add(mul(track.handVelocity[i], 0.65), mul(delta, 0.35)) : delta;
}
track.hasHandVelocity = true;
}
track.handPrevious = hand.hand;
track.hasHandPrevious = true;
track.handSource = hand.source;
}
track.limbPrevious = limb.limb;
track.hasLimbPrevious = true;
track.limbSource = limb.source;
}
// Same-person duplicate suppression from the reference fusion.
auto handCenter = [&](int side) {
int start = side == 0 ? 91 : 112;
Vec2 center{};
for (int i = 0; i < 21; ++i) center = add(center, vec(output.pose.keypoints[start + i]));
return mul(center, 1.0 / 21.0);
};
if (norm(sub(handCenter(0), handCenter(1))) / boxDiagonal(raw.box) < 0.08) {
int weaker = output.meta[0].handScore <= output.meta[1].handScore ? 0 : 1;
output.meta[weaker].handState = "duplicate";
output.meta[weaker].distanceEligible = false;
output.meta[weaker].alarmEligible = false;
}
// Motion-adaptive EMA in person-box coordinates.
std::array<Vec2, 133> now{};
for (int i = 0; i < 133; ++i) now[i] = normalized(output.pose.keypoints[i], raw.box);
if (!state.hasSmoothed) { state.smoothed = now; state.hasSmoothed = true; }
else {
for (int i = 0; i < 133; ++i) {
double displacement = norm(sub(now[i], state.smoothed[i]));
double alpha = 0.32 + 0.58 * std::clamp(displacement / 0.08, 0.0, 1.0);
state.smoothed[i] = add(mul(now[i], alpha), mul(state.smoothed[i], 1.0 - alpha));
}
}
fused.push_back(std::move(output));
}
// Exact build_global_hands() evidence: selected fusion output, stable
// score ordering, then the same cross-person duplicate collapse.
std::vector<GlobalHand> globalCandidates;
for (std::size_t person = 0; person < fused.size(); ++person) {
for (int side = 0; side < 2; ++side) {
auto const& meta = fused[person].meta[side];
if (!meta.alarmEligible) continue;
int const start = side == 0 ? 91 : 112;
GlobalHand hand;
hand.personId = static_cast<int>(person) + 1;
hand.trackId = fused[person].pose.box.trackId;
hand.left = side == 0;
hand.source = meta.handSource;
hand.state = meta.handState;
hand.distanceEligible = meta.distanceEligible;
hand.alarmEligible = meta.alarmEligible;
hand.score = static_cast<float>(meta.handScore);
float minX = fused[person].pose.keypoints[start].x;
float maxX = minX;
float minY = fused[person].pose.keypoints[start].y;
float maxY = minY;
for (int point = 0; point < 21; ++point) {
hand.keypoints[point] = fused[person].pose.keypoints[start + point];
minX = std::min(minX, hand.keypoints[point].x);
maxX = std::max(maxX, hand.keypoints[point].x);
minY = std::min(minY, hand.keypoints[point].y);
maxY = std::max(maxY, hand.keypoints[point].y);
}
hand.spanPx = std::hypot(maxX - minX, maxY - minY);
globalCandidates.push_back(std::move(hand));
}
}
std::stable_sort(globalCandidates.begin(), globalCandidates.end(), [](auto const& first, auto const& second) {
return first.score > second.score;
});
std::vector<GlobalHand> globalHands;
for (auto& candidate : globalCandidates) {
Vec2 candidateCenter{};
for (auto const& point : candidate.keypoints) candidateCenter = add(candidateCenter, vec(point));
candidateCenter = mul(candidateCenter, 1.0 / 21.0);
auto duplicate = globalHands.end();
for (auto iterator = globalHands.begin(); iterator != globalHands.end(); ++iterator) {
Vec2 existingCenter{};
for (auto const& point : iterator->keypoints) existingCenter = add(existingCenter, vec(point));
existingCenter = mul(existingCenter, 1.0 / 21.0);
double const threshold = std::clamp(
0.45 * std::max<double>(candidate.spanPx, iterator->spanPx), 18.0, 70.0);
if (norm(sub(candidateCenter, existingCenter)) <= threshold) {
duplicate = iterator;
break;
}
}
if (duplicate == globalHands.end()) globalHands.push_back(std::move(candidate));
else duplicate->aliases.emplace_back(candidate.personId, candidate.left);
}
// Build the model measurements, then collapse cross-person duplicates.
std::vector<std::array<std::optional<Measurement>, 2>> measurements(fused.size());
for (std::size_t person = 0; person < fused.size(); ++person) {
for (int side = 0; side < 2; ++side) {
if (!fused[person].meta[side].alarmEligible) continue;
Measurement measurement;
int start = side == 0 ? 91 : 112;
for (int i = 0; i < 21; ++i) measurement.hand[i] = vec(fused[person].pose.keypoints[start + i]);
measurement.precise = fused[person].meta[side].distanceEligible;
measurement.provisional = !measurement.precise;
measurement.quality = fused[person].meta[side].handScore;
measurements[person][side] = measurement;
}
}
auto handSpan = [&](Measurement const& measurement) {
double minX = measurement.hand[0].x, maxX = minX, minY = measurement.hand[0].y, maxY = minY;
for (auto point : measurement.hand) { minX = std::min(minX, point.x); maxX = std::max(maxX, point.x); minY = std::min(minY, point.y); maxY = std::max(maxY, point.y); }
return std::hypot(maxX - minX, maxY - minY);
};
auto handCenter = [&](Measurement const& measurement) {
Vec2 center{}; for (auto point : measurement.hand) center = add(center, point); return mul(center, 1.0 / 21.0);
};
for (std::size_t firstPerson = 0; firstPerson < fused.size(); ++firstPerson) {
for (int firstSide = 0; firstSide < 2; ++firstSide) {
if (!measurements[firstPerson][firstSide]) continue;
for (std::size_t secondPerson = firstPerson + 1; secondPerson < fused.size(); ++secondPerson) {
for (int secondSide = 0; secondSide < 2; ++secondSide) {
if (!measurements[firstPerson][firstSide] || !measurements[secondPerson][secondSide]) continue;
auto const& first = *measurements[firstPerson][firstSide];
auto const& second = *measurements[secondPerson][secondSide];
double firstSpan = handSpan(first), secondSpan = handSpan(second);
if (norm(sub(handCenter(first), handCenter(second))) > std::max(70.0, 0.5 * std::max(firstSpan, secondSpan))) continue;
double firstArea = std::max((fused[firstPerson].pose.box.x2 - fused[firstPerson].pose.box.x1) * (fused[firstPerson].pose.box.y2 - fused[firstPerson].pose.box.y1), 1.0F);
double secondArea = std::max((fused[secondPerson].pose.box.x2 - fused[secondPerson].pose.box.x1) * (fused[secondPerson].pose.box.y2 - fused[secondPerson].pose.box.y1), 1.0F);
if (firstArea >= 2.5 * secondArea &&
(firstSpan >= 1.45 * secondSpan || first.quality >= second.quality + 0.6)) {
measurements[secondPerson][secondSide].reset();
states_[fused[secondPerson].pose.box.trackId].endpoints[secondSide] = {};
} else if (secondArea >= 2.5 * firstArea &&
(secondSpan >= 1.45 * firstSpan || second.quality >= first.quality + 0.6)) {
measurements[firstPerson][firstSide].reset();
states_[fused[firstPerson].pose.box.trackId].endpoints[firstSide] = {};
}
}
}
}
}
// Match resolve_global_hand_assignments(): a large foreground track may reclaim
// a nearby hand that RTMW emitted only from a smaller background crop.
struct Reassignment {
double normalizedDistance{};
std::size_t targetPerson{};
int targetSide{};
std::size_t sourcePerson{};
int sourceSide{};
Vec2 sourceCenter{};
};
std::vector<Reassignment> reassignments;
for (std::size_t targetPerson = 0; targetPerson < fused.size(); ++targetPerson) {
for (int targetSide = 0; targetSide < 2; ++targetSide) {
if (measurements[targetPerson][targetSide]) continue;
auto& targetEndpoint = states_[fused[targetPerson].pose.box.trackId].endpoints[targetSide];
if (!targetEndpoint.hasWrist || !targetEndpoint.hasHandShape || targetEndpoint.missedFrames > 30) continue;
double targetArea = std::max(
(fused[targetPerson].pose.box.x2 - fused[targetPerson].pose.box.x1) *
(fused[targetPerson].pose.box.y2 - fused[targetPerson].pose.box.y1), 1.0F);
Vec2 shapeCenter{};
for (auto point : targetEndpoint.handShape) shapeCenter = add(shapeCenter, point);
shapeCenter = mul(shapeCenter, 1.0 / 21.0);
Vec2 predictedCenter = add(targetEndpoint.wrist, shapeCenter);
Measurement targetShape;
for (int point = 0; point < 21; ++point) targetShape.hand[point] = targetEndpoint.handShape[point];
double targetSpan = handSpan(targetShape);
double gate = 0.20 * boxDiagonal(fused[targetPerson].pose.box);
for (std::size_t sourcePerson = 0; sourcePerson < fused.size(); ++sourcePerson) {
if (sourcePerson == targetPerson) continue;
for (int sourceSide = 0; sourceSide < 2; ++sourceSide) {
auto const& source = measurements[sourcePerson][sourceSide];
if (!source || source->source != "model") continue;
double sourceArea = std::max(
(fused[sourcePerson].pose.box.x2 - fused[sourcePerson].pose.box.x1) *
(fused[sourcePerson].pose.box.y2 - fused[sourcePerson].pose.box.y1), 1.0F);
if (targetArea < 2.5 * sourceArea) continue;
Vec2 sourceCenter = handCenter(*source);
double sourceSpan = handSpan(*source);
double candidateDistance = norm(sub(sourceCenter, predictedCenter));
if (candidateDistance > gate || targetSpan < 1.45 * std::max(sourceSpan, 1.0)) continue;
reassignments.push_back({
candidateDistance / std::max(gate, 1e-6), targetPerson, targetSide,
sourcePerson, sourceSide, sourceCenter});
}
}
}
}
std::sort(reassignments.begin(), reassignments.end(), [](auto const& first, auto const& second) {
return std::tie(first.normalizedDistance, first.targetPerson, first.targetSide,
first.sourcePerson, first.sourceSide) <
std::tie(second.normalizedDistance, second.targetPerson, second.targetSide,
second.sourcePerson, second.sourceSide);
});
std::set<std::pair<std::size_t, int>> usedTargets, usedSources;
for (auto const& item : reassignments) {
auto targetKey = std::make_pair(item.targetPerson, item.targetSide);
auto sourceKey = std::make_pair(item.sourcePerson, item.sourceSide);
if (usedTargets.count(targetKey) || usedSources.count(sourceKey) ||
measurements[item.targetPerson][item.targetSide] ||
!measurements[item.sourcePerson][item.sourceSide]) continue;
auto& targetEndpoint = states_[fused[item.targetPerson].pose.box.trackId].endpoints[item.targetSide];
Vec2 shapeCenter{};
for (auto point : targetEndpoint.handShape) shapeCenter = add(shapeCenter, point);
shapeCenter = mul(shapeCenter, 1.0 / 21.0);
Vec2 reassignedWrist = sub(item.sourceCenter, shapeCenter);
Measurement reassigned;
for (int point = 0; point < 21; ++point) {
reassigned.hand[point] = add(reassignedWrist, targetEndpoint.handShape[point]);
}
reassigned.provisional = true;
reassigned.source = "global_reassignment";
reassigned.quality = measurements[item.sourcePerson][item.sourceSide]->quality;
measurements[item.targetPerson][item.targetSide] = reassigned;
measurements[item.sourcePerson][item.sourceSide].reset();
auto& sourceEndpoint = states_[fused[item.sourcePerson].pose.box.trackId].endpoints[item.sourceSide];
sourceEndpoint = {};
usedTargets.insert(targetKey);
usedSources.insert(sourceKey);
}
PoseFrameResult result;
result.globalHands = std::move(globalHands);
result.selectedPeople.reserve(fused.size());
result.smoothedPeople.reserve(fused.size());
result.v2People.reserve(fused.size());
for (auto const& person : fused) {
std::array<FusionMeta,2> meta{};
for (int side=0;side<2;++side) {
auto const& m=person.meta[side];
meta[side]={m.limbConsensus,m.handValid,m.distanceEligible,m.alarmEligible,m.handScore,m.handSource,m.handState};
}
result.metadata.push_back(meta);
result.selectedPeople.push_back(person.pose);
PersonPose smoothed = person.pose;
auto const& state = states_[person.pose.box.trackId];
for (int keypoint = 0; keypoint < 133; ++keypoint) {
Vec2 point = denormalized(state.smoothed[keypoint], person.pose.box);
smoothed.keypoints[keypoint].x = static_cast<float>(point.x);
smoothed.keypoints[keypoint].y = static_cast<float>(point.y);
}
result.smoothedPeople.push_back(smoothed);
result.v2People.push_back(std::move(smoothed));
}
for (std::size_t person = 0; person < fused.size(); ++person) {
auto& state = states_[fused[person].pose.box.trackId];
int frameGap = state.lastFrame < 0 ? 0 : std::max(frameIndex - state.lastFrame - 1, 0);
for (auto& endpoint : state.endpoints) endpoint.missedFrames += frameGap;
state.lastFrame = frameIndex;
for (int side = 0; side < 2; ++side) {
if (!fused[person].meta[side].limbConsensus || !fused[person].meta[side].distanceEligible) continue;
int shoulderIndex = side == 0 ? 5 : 6;
int elbowIndex = side == 0 ? 7 : 8;
int wristIndex = side == 0 ? 9 : 10;
Vec2 shoulder = vec(fused[person].pose.keypoints[shoulderIndex]);
Vec2 elbow = vec(fused[person].pose.keypoints[elbowIndex]);
Vec2 wrist = vec(fused[person].pose.keypoints[wristIndex]);
double diagonal = boxDiagonal(fused[person].pose.box);
double upper = norm(sub(shoulder, elbow)) / diagonal;
double forearm = norm(sub(elbow, wrist)) / diagonal;
if (upper >= 0.04 && upper <= 0.65 && forearm >= 0.04 && forearm <= 0.65) {
auto& samples = state.boneSamples[side];
samples.push_back({upper, forearm});
while (samples.size() > 120) samples.pop_front();
}
}
for (int side = 0; side < 2; ++side) {
int shoulderIndex = side == 0 ? 5 : 6;
int elbowIndex = side == 0 ? 7 : 8;
int wristIndex = side == 0 ? 9 : 10;
int handStart = side == 0 ? 91 : 112;
auto& endpoint = state.endpoints[side];
auto measurement = measurements[person][side];
// Reference temporal recovery gate.
bool const recoverableHandState = fused[person].meta[side].handState == "conflict" ||
fused[person].meta[side].handState == "inferred";
if (!measurement && endpoint.hasWrist && endpoint.hasHandShape && endpoint.missedFrames <= 30 &&
recoverableHandState && fused[person].meta[side].handValid &&
fused[person].meta[side].handScore >= 2.0 &&
fused[person].meta[side].linkToSelectedLimb <= 0.08) {
Vec2 measured = vec(fused[person].pose.keypoints[handStart]);
Vec2 prediction = add(endpoint.wrist, endpoint.velocity);
double gate = std::min(0.20, 0.05 + 0.008 * std::max(endpoint.missedFrames, 1)) * boxDiagonal(fused[person].pose.box);
if (norm(sub(measured, prediction)) <= gate) {
Measurement recovered;
for (int i = 0; i < 21; ++i) recovered.hand[i] = vec(fused[person].pose.keypoints[handStart + i]);
recovered.provisional = true; recovered.quality = fused[person].meta[side].handScore; recovered.source = "temporal_recovery";
measurement = recovered;
}
}
LimbState outputState;
std::array<Vec2, 21> outputHand{};
bool hasOutput = false;
bool returningAfterLong = measurement && endpoint.missedFrames > 12;
if (measurement) {
Vec2 measuredWrist = measurement->hand[0];
std::array<Vec2, 21> measuredShape{};
for (int i = 0; i < 21; ++i) measuredShape[i] = sub(measurement->hand[i], measuredWrist);
if (!endpoint.hasWrist || endpoint.missedFrames > 12) {
endpoint.wrist = measuredWrist; endpoint.velocity = {}; endpoint.handShape = measuredShape;
} else {
Vec2 prediction = add(endpoint.wrist, endpoint.velocity);
Vec2 residual = sub(measuredWrist, prediction);
double alpha = measurement->precise ? 0.78 : 0.88;
double beta = measurement->precise ? 0.18 : 0.24;
endpoint.wrist = add(prediction, mul(residual, alpha));
endpoint.velocity = add(mul(endpoint.velocity, 0.82), mul(residual, beta));
double shapeAlpha = measurement->precise ? 0.55 : 0.70;
for (int i = 0; i < 21; ++i) endpoint.handShape[i] = add(mul(measuredShape[i], shapeAlpha), mul(endpoint.handShape[i], 1.0 - shapeAlpha));
}
endpoint.hasWrist = endpoint.hasHandShape = true;
endpoint.lastObservedFrame = frameIndex;
endpoint.missedFrames = 0;
outputState = measurement->source == "global_reassignment" ? LimbState::Reassigned : measurement->source == "temporal_recovery" ? LimbState::Recovered : measurement->precise ? LimbState::Observed : measurement->provisional ? LimbState::Provisional : LimbState::Interpolated;
for (int i = 0; i < 21; ++i) outputHand[i] = add(endpoint.wrist, endpoint.handShape[i]);
hasOutput = true;
} else {
++endpoint.missedFrames;
if (!endpoint.hasWrist || endpoint.missedFrames > 30) outputState = LimbState::ReachableLong;
else if (endpoint.missedFrames <= 12) {
endpoint.wrist = add(endpoint.wrist, endpoint.velocity);
endpoint.velocity = mul(endpoint.velocity, 0.86);
for (int i = 0; i < 21; ++i) outputHand[i] = add(endpoint.wrist, endpoint.handShape[i]);
hasOutput = true; outputState = LimbState::PredictedShort;
} else outputState = LimbState::ReachableMedium;
}
Vec2 smoothedShoulder = denormalized(state.smoothed[shoulderIndex], fused[person].pose.box);
Vec2 smoothedElbow = denormalized(state.smoothed[elbowIndex], fused[person].pose.box);
Keypoint shoulder = fused[person].pose.keypoints[shoulderIndex];
shoulder.x = static_cast<float>(smoothedShoulder.x);
shoulder.y = static_cast<float>(smoothedShoulder.y);
Keypoint elbow = fused[person].pose.keypoints[elbowIndex];
elbow.x = static_cast<float>(smoothedElbow.x);
elbow.y = static_cast<float>(smoothedElbow.y);
Keypoint wrist = result.smoothedPeople[person].keypoints[wristIndex];
std::array<Keypoint, 21> hand{};
for (int i = 0; i < 21; ++i) {
hand[i] = result.smoothedPeople[person].keypoints[handStart + i];
}
if (hasOutput) {
auto& ownSamples = state.boneSamples[side];
auto& oppositeSamples = state.boneSamples[1 - side];
auto const& samples = ownSamples.size() < 10 && oppositeSamples.size() >= 10
? oppositeSamples : ownSamples;
double upperRatio = 0.24, forearmRatio = 0.22;
if (!samples.empty()) {
std::vector<double> upperValues, forearmValues;
upperValues.reserve(samples.size());
forearmValues.reserve(samples.size());
for (auto const& sample : samples) {
upperValues.push_back(sample[0]);
forearmValues.push_back(sample[1]);
}
upperRatio = median(std::move(upperValues));
forearmRatio = median(std::move(forearmValues));
}
double diagonal = boxDiagonal(fused[person].pose.box);
double upperLength = upperRatio * diagonal;
double forearmLength = forearmRatio * diagonal;
double totalLength = upperLength + forearmLength;
if (totalLength > 0.95 * diagonal) {
double lengthScale = 0.95 * diagonal / totalLength;
upperLength *= lengthScale;
forearmLength *= lengthScale;
}
Vec2 referenceElbow = returningAfterLong || !endpoint.hasLastElbow
? vec(fused[person].pose.keypoints[elbowIndex]) : endpoint.lastElbow;
if (!fused[person].meta[side].limbConsensus || outputState != LimbState::Observed) {
Vec2 solved = solveTwoBoneElbow(
smoothedShoulder, outputHand[0], upperLength, forearmLength, referenceElbow);
elbow.x = static_cast<float>(solved.x);
elbow.y = static_cast<float>(solved.y);
}
endpoint.lastElbow = vec(elbow);
endpoint.hasLastElbow = true;
Vec2 armDirection = sub(outputHand[0], smoothedShoulder);
if (norm(armDirection) > 1e-6) {
endpoint.lastDirection = mul(armDirection, 1.0 / norm(armDirection));
endpoint.hasLastDirection = true;
}
wrist.x = outputHand[0].x; wrist.y = outputHand[0].y;
for (int i = 0; i < 21; ++i) {
hand[i].x = outputHand[i].x; hand[i].y = outputHand[i].y;
}
}
auto& v2Pose = result.v2People[person];
v2Pose.keypoints[shoulderIndex] = shoulder;
v2Pose.keypoints[elbowIndex] = elbow;
v2Pose.keypoints[wristIndex] = wrist;
if (hasOutput) {
for (int point = 0; point < 21; ++point) v2Pose.keypoints[handStart + point] = hand[point];
}
result.limbs.push_back({
fused[person].pose.box.trackId, side == 0, outputState,
elbow, wrist, hand, fused[person].pose.box,
fused[person].meta[side].distanceEligible,
fused[person].meta[side].alarmEligible,
static_cast<float>(fused[person].meta[side].handScore)});
}
}
return result;
}
} // namespace fangjia
-97
View File
@@ -1,97 +0,0 @@
#include "visualizer.hpp"
#include "camera_pipeline.hpp"
#include <opencv2/highgui.hpp>
#include <opencv2/highgui/highgui_c.h>
#include <opencv2/imgproc.hpp>
#include <algorithm>
#include <cmath>
#include <iomanip>
#include <sstream>
#include <stdexcept>
namespace cms {
namespace {
constexpr char windowName[]="CMS Camera Risk";
cv::Point point(PosePoint const& p){return {int(std::nearbyint(p.x)),int(std::nearbyint(p.y))};}
cv::Scalar color(std::string const& level) {
if(level=="danger")return {40,40,255};
if(level=="warning")return {0,160,255};
if(level=="watch")return {0,240,240};
if(level=="clear")return {80,220,90};
return {190,190,190};
}
void segment(cv::Mat& image,cv::Point a,cv::Point b,cv::Scalar c,int thickness,bool dashed) {
if(!dashed){cv::line(image,a,b,c,thickness,cv::LINE_AA);return;}
cv::Point2d d=b-a;double length=cv::norm(d);if(length<1)return;d*=1.0/length;
for(double x=0;x<length;x+=14)cv::line(image,cv::Point2d(a)+d*x,cv::Point2d(a)+d*std::min(x+8,length),c,thickness,cv::LINE_AA);
}
}
cv::Mat renderResult(FrameResult const& r) {
if(!r.image)throw std::invalid_argument("Result has no frame image");
auto const& image=*r.image;
if(image.width<=0||image.height<=0||image.stride<image.width*3||image.pixels.size()<std::size_t(image.stride)*image.height)
throw std::invalid_argument("Malformed result image");
cv::Mat view(image.height,image.width,CV_8UC3,const_cast<std::uint8_t*>(image.pixels.data()),image.stride);
auto canvas=view.clone();
if(r.door_mask_pixels) {
auto const& m=*r.door_mask_pixels;
if(m.width!=image.width||m.height!=image.height||m.stride<m.width||m.pixels.size()<std::size_t(m.stride)*m.height)
throw std::invalid_argument("Result mask does not match image");
cv::Mat mask(m.height,m.width,CV_8U,const_cast<std::uint8_t*>(m.pixels.data()),m.stride);
auto tint=canvas.clone();tint.setTo(cv::Scalar(255,180,0),mask);
cv::addWeighted(canvas,0.75,tint,0.25,0,canvas);
std::vector<std::vector<cv::Point>> contours;cv::findContours(mask.clone(),contours,cv::RETR_EXTERNAL,cv::CHAIN_APPROX_SIMPLE);
cv::drawContours(canvas,contours,-1,{255,180,0},2);
}
for(auto const& person:r.persons) {
auto const& b=person.box;
cv::rectangle(canvas,{int(b[0]),int(b[1])},{int(b[2]),int(b[3])},{0,220,255},2);
std::ostringstream text;text<<"ID "<<person.track_id<<" "<<std::fixed<<std::setprecision(2)<<person.confidence;
cv::putText(canvas,text.str(),{int(b[0])+4,std::max(180,int(b[1])+25)},cv::FONT_HERSHEY_SIMPLEX,.8,{0,220,255},2,cv::LINE_AA);
for(int s=0;s<2;++s) {
auto const& state=person.hands[s];auto const& k=person.selected;
cv::Scalar arm=s==0?cv::Scalar(40,40,255):cv::Scalar(30,230,30);
for(auto e:std::vector<std::pair<int,int>>{{5+s,7+s},{7+s,9+s}}) {
segment(canvas,point(k[e.first]),point(k[e.second]),state.limb_consensus?arm:cv::Scalar(0,140,255),3,!state.limb_consensus);
cv::circle(canvas,point(k[e.second]),4,arm,-1,cv::LINE_AA);
}
if(state.state=="rejected")continue;
int start=s==0?91:112;
auto hand=state.distance_eligible?(s==0?cv::Scalar(255,80,220):cv::Scalar(255,190,40)):cv::Scalar(0,140,255);
for(int f=0;f<5;++f)for(int j=0;j<4;++j){int a=j==0?start:start+f*4+j;int b=start+1+f*4+j;segment(canvas,point(k[a]),point(k[b]),hand,2,!state.distance_eligible);}
for(int j=0;j<21;++j)cv::circle(canvas,point(k[start+j]),3,hand,-1,cv::LINE_AA);
}
}
for(auto const& h:r.hand_regions) {
std::vector<cv::Point> points,hull;for(auto const& p:h.keypoints)points.push_back(point(p));cv::convexHull(points,hull);
auto risk=std::find_if(r.risk.regions.begin(),r.risk.regions.end(),[&](auto const& item){return item.track_id==h.track_id&&item.side==h.side;});
auto c=color(risk==r.risk.regions.end()?"unavailable":risk->risk_level);
cv::polylines(canvas,std::vector<std::vector<cv::Point>>{hull},true,c,2,cv::LINE_AA);
}
for(auto const& region:r.risk.regions)if(region.source=="YOLO_BODY_BOX_FALLBACK") {
auto it=std::find(r.track_ids.begin(),r.track_ids.end(),region.track_id);
if(it!=r.track_ids.end()){auto const& b=r.boxes.at(it-r.track_ids.begin());cv::putText(canvas,"BODY FALLBACK",{int(b[0])+4,int(b[1])+50},cv::FONT_HERSHEY_SIMPLEX,.65,{0,160,255},2,cv::LINE_AA);}
}
cv::rectangle(canvas,{0,0},{canvas.cols,148},{20,24,30},-1);
std::ostringstream headline;headline<<"RISK ";if(r.risk.risk_score)headline<<std::fixed<<std::setprecision(3)<<*r.risk.risk_score;else headline<<"N/A";headline<<" "<<r.risk.risk_level;
cv::putText(canvas,headline.str(),{24,40},cv::FONT_HERSHEY_SIMPLEX,1.0,color(r.risk.risk_level),2,cv::LINE_AA);
std::ostringstream status;status<<"camera frame "<<r.frame_index<<" | seq "<<r.capture.driver_sequence<<" | latency "<<std::fixed<<std::setprecision(1)<<r.timing.capture_to_result_ms<<" ms";
cv::putText(canvas,status.str(),{24,82},cv::FONT_HERSHEY_SIMPLEX,.72,{235,235,235},2,cv::LINE_AA);
std::string calibration=r.door_mask_calibration_verified?"door calibration verified":"door calibration NOT verified (bring-up)";
cv::putText(canvas,calibration+" | q/Esc: close preview",{24,123},cv::FONT_HERSHEY_SIMPLEX,.70,{0,190,255},2,cv::LINE_AA);
return canvas;
}
void runLocalVisualization(CameraPipeline const& pipeline) {
struct Window {
Window(){cv::namedWindow(windowName,cv::WINDOW_NORMAL);cv::resizeWindow(windowName,1000,800);}
~Window(){try{if(cvGetWindowHandle(windowName))cv::destroyWindow(windowName);}catch(...) {}}
} window;
FrameResultPtr shown;
while(pipeline.running()) {
auto result=pipeline.latest();
if(result&&result!=shown){cv::imshow(windowName,renderResult(*result));shown=std::move(result);}
int key=cv::waitKey(16)&0xff;
if(key==27||key=='q'||key=='Q'||cvGetWindowHandle(windowName)==nullptr)return;
}
}
}
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2013-2022 Niels Lohmann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
-101
View File
@@ -1,101 +0,0 @@
#!/bin/bash
# build_and_run.sh - 在 Orin 上构建并运行 V4L2 取图 demo
#
# 使用方法:
# ./build_and_run.sh [选项]
#
# 选项:
# --deploy 将源码复制到 Orin 并构建
# --run 在 Orin 上运行已构建的程序
# --both 部署并运行 (默认)
#
# 示例:
# ./build_and_run.sh --both
# ./build_and_run.sh --deploy
# ./build_and_run.sh --run --args "-n 100 -v"
set -e
# 配置
ORIN_HOST="192.168.195.100"
ORIN_USER="orin"
ORIN_PASS="${ORIN_PASS:?Set ORIN_PASS locally, or use scripts/deploy.sh for interactive SSH}"
export SSHPASS="$ORIN_PASS"
LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$LOCAL_DIR/build"
BIN_NAME="vision-anti-pinch"
# 默认参数
ACTION="both"
RUN_ARGS="-n 100"
# 解析参数
while [[ $# -gt 0 ]]; do
case $1 in
--deploy) ACTION="deploy"; shift ;;
--run) ACTION="run"; shift ;;
--both) ACTION="both"; shift ;;
--args)
RUN_ARGS="$2"
shift 2
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# 部署函数
do_deploy() {
echo "=== 部署源码到 Orin ==="
# 创建临时目录用于传输
TEMP_DIR=$(mktemp -d)
cp -r "$LOCAL_DIR"/* "$TEMP_DIR/"
# 使用 sshpass 复制 (如果没有 sshpass, 手动复制)
if command -v sshpass &> /dev/null; then
echo "复制源码到 Orin..."
sshpass -e scp -o StrictHostKeyChecking=no -r "$TEMP_DIR"/* "${ORIN_USER}@${ORIN_HOST}:~/vision-anti-pinch/"
else
echo "警告: 未安装 sshpass, 请手动复制源码到 Orin:"
echo " scp -r $LOCAL_DIR/* ${ORIN_USER}@${ORIN_HOST}:~/vision-anti-pinch/"
return 1
fi
rm -rf "$TEMP_DIR"
echo "=== 在 Orin 上构建 ==="
sshpass -e ssh -o StrictHostKeyChecking=no "${ORIN_USER}@${ORIN_HOST}" << 'EOF'
cd ~/vision-anti-pinch
make clean
make -j$(nproc)
echo "构建完成"
EOF
echo "部署完成"
}
# 运行函数
do_run() {
echo "=== 在 Orin 上运行 ==="
sshpass -e ssh -o StrictHostKeyChecking=no "${ORIN_USER}@${ORIN_HOST}" << EOF
cd ~/vision-anti-pinch
./${BIN_NAME} ${RUN_ARGS}
EOF
}
# 主流程
echo "V4L2 Capture Demo - Build & Run"
echo "================================"
if [[ "$ACTION" == "deploy" ]] || [[ "$ACTION" == "both" ]]; then
do_deploy
fi
if [[ "$ACTION" == "run" ]] || [[ "$ACTION" == "both" ]]; then
do_run
fi
echo "完成"
-110
View File
@@ -1,110 +0,0 @@
#!/bin/bash
# camera_reset.sh - 修复 GMSL2 相机"第二次运行全绿"问题
#
# 问题原因:
# quick_bring_up_64g.sh 第二次运行时,仅重新设置 sensor_mode/trig_pin,
# 但 sgx_yuv_gmsl2 链路状态未重新初始化,导致相机输出全绿。
#
# 模块依赖关系 (modinfo 确认):
# sgx-yuv-gmsl2.ko depends: max9296, max9295
# 必须按顺序加载: max9295.ko -> max9296.ko -> sgx-yuv-gmsl2.ko
# 否则 insmod sgx-yuv-gmsl2.ko 报 "Unknown symbol in module"
#
# 本脚本: 依赖模块仅在未加载时 insmod (不重载, 避免链路抖动),
# sgx_yuv_gmsl2 先 rmmod 再 insmod 完成链路复位。
# 全绿图像多由 gmsl_mode 错误引起 (本工程固定 2,2,2,2);
# 若复位后仍异常, 请重启 Orin 后运行 quick_bring_up_64g.sh。
#
# 使用方法:
# sudo ./camera_reset.sh [camera_type] [port]
#
# camera_type: 0-17 对应 quick_bring_up_64g.sh 中的相机类型 (默认 8)
# port: 0-7 对应 video 设备号 (默认 5)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
KO_DIR="/home/orin/SG8A_ORIN_GMSL2-F_V2_AGX_Orin_YUV_JP5.1.2_L4TR35.4.1/ko"
VIDEO_PORT="${2:-5}"
CAMERA_TYPE="${1:-5}" # 默认 camera_type=5 (SG3S-ISX031C-GMSL2F)
echo "========================================================="
echo "Camera Reset Script"
echo "========================================================="
echo "Camera type: $CAMERA_TYPE"
echo "Video port: $VIDEO_PORT (/dev/video${VIDEO_PORT})"
echo ""
# Step 1: 停止占用 video 设备的进程
echo "[1/4] Stopping processes using video devices..."
pkill -9 gst-launch-1.0 2>/dev/null || true
pkill -9 vision-anti-pinch 2>/dev/null || true
sleep 1
# Step 2: 加载依赖模块 max9295/max9296 (未加载时), 再重载 sgx_yuv_gmsl2
echo "[2/4] Loading modules (max9295 -> max9296 -> sgx_yuv_gmsl2)..."
if ! lsmod | grep -q '^max9295'; then
sudo insmod "$KO_DIR/max9295.ko" || { echo "ERROR: insmod max9295 failed"; exit 1; }
echo " loaded max9295.ko"
else
echo " max9295 already loaded (skip)"
fi
if ! lsmod | grep -q '^max9296'; then
sudo insmod "$KO_DIR/max9296.ko" || { echo "ERROR: insmod max9296 failed"; exit 1; }
echo " loaded max9296.ko"
else
echo " max9296 already loaded (skip)"
fi
if lsmod | grep -q '^sgx_yuv_gmsl2'; then
sudo rmmod sgx_yuv_gmsl2
sleep 1
fi
sudo insmod "$KO_DIR/sgx-yuv-gmsl2.ko" gmsl_mode=2,2,2,2 || { echo "ERROR: insmod sgx-yuv-gmsl2 failed"; exit 1; }
echo " loaded sgx-yuv-gmsl2.ko (gmsl_mode=2,2,2,2)"
sleep 2
# Step 3: 验证设备节点
echo "[3/4] Verifying device nodes..."
if [ ! -c "/dev/video${VIDEO_PORT}" ]; then
echo "Error: /dev/video${VIDEO_PORT} not found!"
echo "Please reboot the system and run quick_bring_up_64g.sh again."
exit 1
fi
echo " /dev/video${VIDEO_PORT} exists"
# Step 4: 根据 camera_type 配置 sensor_mode 和 trig_pin
echo "[4/4] Configuring sensor..."
case "$CAMERA_TYPE" in
0) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=0,trig_pin=0xffff0007 ;;
1) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=0,trig_pin=0xffff0007 ;;
2) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=0,trig_pin=0xffff0007 ;;
3) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=1,trig_pin=0xffff0007 ;;
4) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=0,trig_pin=0xffff0007 ;;
5) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=1,trig_pin=0xffff0007 ;;
6) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=1,trig_pin=0xffff0007 ;;
7) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=2,trig_pin=0xffff0008,trig_mode=1 ;;
8) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=3,trig_pin=0xffff0007 ;;
9) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=3,trig_pin=0xffff0008 ;;
10) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=3,trig_pin=0xffff0007 ;;
11) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=3,trig_pin=0xffff0008 ;;
12) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=5,trig_pin=0xffff0007,trig_mode=3 ;;
13) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=4,trig_pin=0xffff0007,trig_mode=3 ;;
14) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=6,trig_pin=0xffff0007 ;;
15) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=8 ;;
16) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=9 ;;
17) v4l2-ctl -d /dev/video${VIDEO_PORT} -c sensor_mode=0 ;;
*) echo "Unknown camera_type: $CAMERA_TYPE"; exit 1 ;;
esac
sleep 1
echo ""
echo "========================================================="
echo "Camera reset complete!"
echo ""
echo "Now run: ./vision-anti-pinch -d /dev/video5 -W 1920 -H 1536 -n 100 -s 5 -t jpg"
echo ""
echo "⚠️ If image is still all-green, the GMSL2 link is damaged."
echo " Please REBOOT the Orin system to recover:"
echo " sudo reboot"
echo " (then run quick_bring_up_64g.sh after reboot)"
echo "========================================================="
-36
View File
@@ -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()
BIN
View File
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
libusb-1.0.so
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
libusbcanfd.so
-344
View File
@@ -1,344 +0,0 @@
// vision-anti-pinch - V4L2 MMAP 取图 + USBCANFD CAN 收发 (OpenCV 版本)
//
// 使用 OpenCV 进行 UYVY/YUYV -> BGR 转换,避免手动转换公式问题
// CAN 收发默认开启, --no-can 关闭
//
// 用法:
// ./vision-anti-pinch -d /dev/video5 -n 10 -s 2 -t jpg -o ./out
#include "v4l2_capture.h"
#include "can_comm.h"
#include "send.hpp"
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/imgcodecs.hpp>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <bitset>
#include <atomic>
#include <chrono>
#include <csignal>
#include <linux/videodev2.h>
std::atomic<uint64_t> g_Feedback_Of_The_Actual_Position_Of_The_Left_Front_Door_391_S;
std::atomic<uint64_t> g_Feedback_Of_The_Actual_Position_Of_The_Right_Front_Door_391_S;
std::atomic<uint64_t> g_Feedback_Of_The_Actual_Position_Of_The_Left_Rear_Door_391_S;
std::atomic<uint64_t> g_Feedback_Of_The_Actual_Position_Of_The_Right_Rear_Door_391_S;
// Sensing GMSL2 驱动自定义控制 ID
#define CID_SENSOR_MODE 0x009a2008
#define CID_TRIG_PIN 0x009a2084
#define CID_TRIG_MODE 0x009a2085
#define CID_GAIN 0x009a2009
#define CID_EXPOSURE 0x009a200a
static volatile sig_atomic_t g_stop = 0;
static void onSignal(int) { g_stop = 1; }
static void printUsage(const char *prog) {
printf(
"Usage: %s [options]\n"
"V4L2 MMAP capture demo for Sensing GMSL2 camera on Jetson Orin.\n"
"\n"
"Capture options:\n"
" -d <device> V4L2 device node (default: /dev/video5)\n"
" -W <width> frame width (default: 1920)\n"
" -H <height> frame height (default: 1536)\n"
" -f <fps> target frame rate (default: 30)\n"
" -F <format> pixel format: UYVY | YUYV (default: UYVY)\n"
" -m <mode> sensor_mode control value\n"
" -p <trig_pin> trig_pin control value (hex/dec)\n"
" --gain <val> sensor gain\n"
" --exposure <val> sensor exposure\n"
"\n"
"Output options:\n"
" -n <count> frames to capture, 0 = run forever\n"
" -s <count> frames to save\n"
" -o <dir> output directory (default: ./frames)\n"
" -t <type> file type: jpg | png | bmp | raw (default: jpg)\n"
" --quality <1..100> JPEG quality (default: 95)\n"
" -v verbose output\n"
" -h print this help\n"
"\n"
"CAN options:\n"
" --no-can disable USBCANFD CAN/CANFD receive (enabled by default)\n"
"\n"
"Example:\n"
" %s -d /dev/video5 -n 100 -s 5 -t jpg -o ./out\n"
" %s -d /dev/video5 -n 0 -v\n",
prog, prog, prog);
}
struct Args {
std::string device = "/dev/video5";
uint32_t width = 1920;
uint32_t height = 1536; // SG3S-ISX031C-GMSL2F 默认分辨率
uint32_t fps = 30;
std::string format = "UYVY";
long sensor_mode = -1;
long trig_pin = -1;
long trig_mode = -1;
long gain = -1;
long exposure = -1;
long count = 0;
long save = 0;
std::string out_dir = "./frames";
std::string save_type = "jpg";
int quality = 95;
bool verbose = false;
bool can_enabled = true; // 默认启用 CAN 收发, --no-can 关闭
};
// 手动解析参数
static bool parseArgs(int argc, char **argv, Args &a) {
auto need_val = [&](int &i) -> std::string {
if (i + 1 >= argc) {
printf("missing value for %s\n", argv[i]);
std::exit(1);
}
return argv[++i];
};
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "-d") a.device = need_val(i);
else if (arg == "-W") a.width = (uint32_t)std::atoi(need_val(i).c_str());
else if (arg == "-H") a.height = (uint32_t)std::atoi(need_val(i).c_str());
else if (arg == "-f") a.fps = (uint32_t)std::atoi(need_val(i).c_str());
else if (arg == "-F") a.format = need_val(i);
else if (arg == "-m") a.sensor_mode = std::strtol(need_val(i).c_str(), nullptr, 0);
else if (arg == "-p") a.trig_pin = std::strtol(need_val(i).c_str(), nullptr, 0);
else if (arg == "-M") a.trig_mode = std::strtol(need_val(i).c_str(), nullptr, 0);
else if (arg == "-n") a.count = std::strtol(need_val(i).c_str(), nullptr, 0);
else if (arg == "-s") a.save = std::strtol(need_val(i).c_str(), nullptr, 0);
else if (arg == "-o") a.out_dir = need_val(i);
else if (arg == "-t") a.save_type = need_val(i);
else if (arg == "-v") a.verbose = true;
else if (arg == "-h") { printUsage(argv[0]); std::exit(0); }
else if (arg == "--gain") a.gain = std::strtol(need_val(i).c_str(), nullptr, 0);
else if (arg == "--exposure") a.exposure = std::strtol(need_val(i).c_str(), nullptr, 0);
else if (arg == "--quality") a.quality = std::atoi(need_val(i).c_str());
else if (arg == "--no-can") a.can_enabled = false;
else {
printf("unknown option: %s\n", arg.c_str());
printUsage(argv[0]);
return false;
}
}
return true;
}
// 使用 OpenCV 将 V4L2 帧 (UYVY/YUYV) 转为 BGR Mat
static cv::Mat frameToBgr(const V4l2Capture::Frame &frame) {
// OpenCV 原生支持 UYVY/YUYV -> BGR 转换
// UYVY = COLOR_YUV2BGR_UYVY, YUYV = COLOR_YUV2BGR_YUYV
int code = (frame.pixelformat == V4L2_PIX_FMT_UYVY)
? cv::COLOR_YUV2BGR_UYVY
: cv::COLOR_YUV2BGR_YUYV;
cv::Mat yuv((int)frame.height, (int)frame.width, CV_8UC2, (void *)frame.data);
cv::Mat bgr;
cv::cvtColor(yuv, bgr, code);
return bgr;
}
/* =====================================================================
* CAN 报文接收解析 (由 can_comm 接收线程回调)
* ===================================================================== */
// 512 位报文位域, deal_msg 中按 msg_id 拷贝后可按位解析信号
static std::bitset<512> g_bitMsg;
static uint64_t get_bit_value(int star_bit, int bit_len)
{
uint64_t signal_value = 0;
for (int i = 0; i < bit_len; ++i)
{
signal_value |= g_bitMsg[star_bit + i] << i;
}
return signal_value;
}
// 收到报文的回调点: 目前仅解析 0x391, 后续信号解析在此扩展
static void deal_msg(int64_t time, uint32_t msg_id, const uint8_t *data, uint32_t len)
{
if (msg_id == 0x391 && len >= 4 && len <= 64)
{
memcpy(&g_bitMsg, data, len);
g_Feedback_Of_The_Actual_Position_Of_The_Left_Front_Door_391_S = data[0];
g_Feedback_Of_The_Actual_Position_Of_The_Right_Front_Door_391_S = data[1];
g_Feedback_Of_The_Actual_Position_Of_The_Left_Rear_Door_391_S = data[2];
g_Feedback_Of_The_Actual_Position_Of_The_Right_Rear_Door_391_S = data[3];
// Rear_BCM_0x391,0x391,64,1.0-1.7,8,Feedback_Of_The_Actual_Position_Of_The_Left_Front_Door_391_S
// Rear_BCM_0x391,0x391,64,2.0-2.7,8,Feedback_Of_The_Actual_Position_Of_The_Right_Front_Door_391_S
// Rear_BCM_0x391,0x391,64,3.0-3.7,8,Feedback_Of_The_Actual_Position_Of_The_Left_Rear_Door_391_S
// Rear_BCM_0x391,0x391,64,4.0-4.7,8,Feedback_Of_The_Actual_Position_Of_The_Right_Rear_Door_391_S
}
}
int main(int argc, char **argv) {
Args args;
if (!parseArgs(argc, argv, args)) {
return 1;
}
std::signal(SIGINT, onSignal);
std::signal(SIGTERM, onSignal);
uint32_t pixfmt = (args.format == "UYVY") ? V4L2_PIX_FMT_UYVY : V4L2_PIX_FMT_YUYV;
// 启动 CAN 收发(失败不致命, 可继续仅取图)
if (args.can_enabled) {
CanComm::set_msg_callback(deal_msg); // 注册报文解析回调
if (!CanComm::can_start()) {
printf("CAN start failed, continue with camera only.\n");
args.can_enabled = false;
}
}
V4l2Capture cap;
if (!cap.open(args.device)) {
return 1;
}
// 设置相机控制
if (args.sensor_mode >= 0) cap.setControl(CID_SENSOR_MODE, (int32_t)args.sensor_mode);
if (args.trig_pin >= 0) cap.setControl(CID_TRIG_PIN, (int32_t)args.trig_pin);
if (args.trig_mode >= 0) cap.setControl(CID_TRIG_MODE, (int32_t)args.trig_mode);
if (args.gain >= 0) cap.setControl(CID_GAIN, (int32_t)args.gain);
if (args.exposure >= 0) cap.setControl(CID_EXPOSURE, (int32_t)args.exposure);
if (!cap.setFormat(args.width, args.height, pixfmt)) {
return 1;
}
cap.setFps(args.fps);
if (!cap.initMmap(4)) {
return 1;
}
if (!cap.startStreaming()) {
return 1;
}
printf("=========================================================\n");
printf("Capturing %s %ux%u @ %u fps from %s\n",
args.format.c_str(), cap.width(), cap.height(), args.fps, args.device.c_str());
printf("Press Ctrl+C to stop.\n");
printf("=========================================================\n");
// 创建输出目录
if (args.save > 0) {
std::string mkcmd = "mkdir -p " + args.out_dir;
std::system(mkcmd.c_str());
}
using clock = std::chrono::steady_clock;
auto t_start = clock::now();
auto t_last_report = t_start;
uint64_t frame_count = 0;
uint64_t save_count = 0;
uint32_t timeout_consecutive = 0;
while (!g_stop) {
if (args.count > 0 && frame_count >= (uint64_t)args.count) {
break;
}
V4l2Capture::Frame frame;
if (!cap.getFrame(frame, 2000)) {
timeout_consecutive++;
if (timeout_consecutive > 5) {
printf("getFrame: timeout/empty for %u consecutive tries, aborting\n",
timeout_consecutive);
break;
}
continue;
}
timeout_consecutive = 0;
frame_count++;
// 保存帧
if (save_count < (uint64_t)args.save) {
bool ok = false;
char fname[512];
// 使用 OpenCV 转换并保存
cv::Mat bgr = frameToBgr(frame);
if (!bgr.empty())
{
std::snprintf(fname, sizeof(fname), "%s/frame_%06lu.%s",
args.out_dir.c_str(), save_count, args.save_type.c_str());
std::vector<int> params;
if (args.save_type == "jpg" || args.save_type == "jpeg")
{
params = {cv::IMWRITE_JPEG_QUALITY, args.quality};
}
else if (args.save_type == "png")
{
params = {cv::IMWRITE_PNG_COMPRESSION, 3};
}
ok = cv::imwrite(fname, bgr, params);
}
if (ok) {
printf("[saved] %s\n", fname);
save_count++;
} else {
printf("[save failed] frame %lu\n", save_count);
}
}
DebugInfo *pData = new DebugInfo();
// pData->CameraId = (ctx->cam_no == 1 ? 0 : 1);
// pData->TimeStampMs = epochTimeStamp_ms;
memcpy(pData->Img, frame.data, frame.size);
bool res = send_debuginfo(pData);
//printf("send %ld %d %d\n", pData->TimeStampMs, pData->CameraId, res);
delete pData;
pData = nullptr;
cap.releaseFrame(frame);
// if (args.verbose) {
// printf("frame #%lu seq=%u size=%zu\n",
// frame_count, frame.sequence, frame.size);
// }
auto now = clock::now();
auto since_report = std::chrono::duration_cast<std::chrono::milliseconds>(now - t_last_report);
if (since_report.count() >= 1000) {
double total_sec = std::chrono::duration_cast<std::chrono::milliseconds>(now - t_start).count() / 1000.0;
double avg_fps = total_sec > 0 ? frame_count / total_sec : 0;
printf("[stats] frames=%lu avg_fps=%.2f door_position[%ld %ld %ld %ld]\n", frame_count, avg_fps, g_Feedback_Of_The_Actual_Position_Of_The_Left_Front_Door_391_S.load(), g_Feedback_Of_The_Actual_Position_Of_The_Right_Front_Door_391_S.load(), g_Feedback_Of_The_Actual_Position_Of_The_Left_Rear_Door_391_S.load(), g_Feedback_Of_The_Actual_Position_Of_The_Right_Rear_Door_391_S.load());
t_last_report = now;
}
}
auto t_end = clock::now();
double total_sec = std::chrono::duration_cast<std::chrono::milliseconds>(t_end - t_start).count() / 1000.0;
printf("=========================================================\n");
printf("Capture finished. frames=%lu duration=%.2fs avg_fps=%.2f\n",
frame_count, total_sec, total_sec > 0 ? frame_count / total_sec : 0);
if (args.save > 0) {
printf("Saved %lu frames to %s/\n", save_count, args.out_dir.c_str());
}
if (args.can_enabled && CanComm::is_open()) {
printf("CAN received: %d frames\n", CanComm::receive_count());
}
printf("=========================================================\n");
cap.stopStreaming();
cap.close();
if (args.can_enabled) {
CanComm::can_stop();
}
return 0;
}
@@ -56,15 +56,4 @@ std::vector<Box> Detector::infer(cv::Mat const& frame,cudaStream_t stream,int fi
std::stable_sort(filtered.begin(),filtered.end(),[](auto const&a,auto const&b){return a.x1<b.x1;}); std::stable_sort(filtered.begin(),filtered.end(),[](auto const&a,auto const&b){return a.x1<b.x1;});
return filtered; return filtered;
} }
cv::Mat maskOtherPeople(cv::Mat const& frame,std::vector<Box> const& boxes,size_t target) {
auto masked=frame.clone(); auto const&t=boxes.at(target);
double targetArea=std::max(double(t.x2-t.x1)*(t.y2-t.y1),1.0);
for(size_t i=0;i<boxes.size();++i) {
auto const& b=boxes[i]; if(i==target||std::max(double(b.x2-b.x1)*(b.y2-b.y1),1.0)>=0.5*targetArea) continue;
int x1=std::clamp(int(std::nearbyint(b.x1)),0,frame.cols-1),x2=std::clamp(int(std::nearbyint(b.x2)),0,frame.cols-1);
int y1=std::clamp(int(std::nearbyint(b.y1)),0,frame.rows-1),y2=std::clamp(int(std::nearbyint(b.y2)),0,frame.rows-1);
if(x2>=x1&&y2>=y1) cv::rectangle(masked,{x1,y1},{x2,y2},{124,116,104},-1);
}
return masked;
}
} }
@@ -61,15 +61,6 @@ inline std::vector<std::uint8_t> readBinary(std::filesystem::path const& path) {
return bytes; return bytes;
} }
inline void writeBinary(std::filesystem::path const& path, void const* data, std::size_t size) {
std::filesystem::create_directories(path.parent_path());
std::ofstream stream(path, std::ios::binary);
if (!stream) {
throw std::runtime_error("Cannot write " + path.string());
}
stream.write(static_cast<char const*>(data), static_cast<std::streamsize>(size));
}
inline std::size_t volume(nvinfer1::Dims const& dims) { inline std::size_t volume(nvinfer1::Dims const& dims) {
std::size_t value = 1; std::size_t value = 1;
for (int index = 0; index < dims.nbDims; ++index) { for (int index = 0; index < dims.nbDims; ++index) {
@@ -14,5 +14,4 @@ private:
std::unique_ptr<YoloPreprocess> preprocess_; std::unique_ptr<YoloPreprocess> preprocess_;
std::vector<float> outputHost_; std::vector<float> outputHost_;
}; };
cv::Mat maskOtherPeople(cv::Mat const& frame,std::vector<fangjia::Box> const& boxes,size_t target);
} }
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <cstdint>
#include <vector>
namespace cms {
// Immutable when published. Stride is explicit; no borrowed camera/GPU memory.
struct BgrImage {
int width{}, height{}, stride{};
std::vector<std::uint8_t> pixels;
};
} // namespace cms
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
namespace fangjia {
struct Box {
float x1{};
float y1{};
float x2{};
float y2{};
float score{};
int trackId{};
};
struct Keypoint {
float x{};
float y{};
float score{};
};
struct PersonPose {
Box box;
std::array<Keypoint, 133> keypoints{};
};
} // namespace fangjia
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include "pipeline_types.hpp"
#include "trt_engine.hpp"
#include <opencv2/core.hpp>
#include <string>
namespace fangjia {
class RtmwEngine {
public:
RtmwEngine(
std::filesystem::path const& enginePath,
TrtLogger& logger,
int maxSourceWidth,
int maxSourceHeight,
int maxPeople = 3);
~RtmwEngine();
RtmwEngine(RtmwEngine const&) = delete;
RtmwEngine& operator=(RtmwEngine const&) = delete;
std::vector<PersonPose> infer(
cv::Mat const& frame,
std::vector<Box> const& boxes,
cudaStream_t stream,
float* preprocessMs = nullptr,
float* inferenceMs = nullptr,
float* postprocessMs = nullptr);
void warmup(cudaStream_t stream);
private:
TrtEngine engine_;
int maxSourceWidth_{};
int maxSourceHeight_{};
int maxPeople_{};
std::uint8_t* sourceDevice_{};
std::uint8_t* cropsDevice_{};
float* networkDevice_{};
std::vector<double> centersHost_;
std::vector<double> scalesHost_;
std::vector<float> simccXHost_;
std::vector<float> simccYHost_;
};
} // namespace fangjia
@@ -16,8 +16,6 @@ public:
void setInputShape(std::string const& name, nvinfer1::Dims const& shape); void setInputShape(std::string const& name, nvinfer1::Dims const& shape);
void prepare(); void prepare();
void bindExternal(std::string const& name, void* pointer, std::size_t bytes);
void useOwned(std::string const& name);
bool enqueue(cudaStream_t stream); bool enqueue(cudaStream_t stream);
void* device(std::string const& name); void* device(std::string const& name);
@@ -33,7 +31,6 @@ private:
void* active{}; void* active{};
std::size_t capacity{}; std::size_t capacity{};
std::size_t required{}; std::size_t required{};
bool external{};
}; };
Buffer& buffer(std::string const& name); Buffer& buffer(std::string const& name);
+175
View File
@@ -0,0 +1,175 @@
#include "pose_engine.hpp"
#include "common.hpp"
#include "gpu_preprocess.hpp"
#include <opencv2/imgproc.hpp>
#include <algorithm>
#include <chrono>
#include <cmath>
namespace fangjia {
namespace {
constexpr int kInputWidth = 288;
constexpr int kInputHeight = 384;
constexpr int kKeypoints = 133;
constexpr int kSimccX = 576;
constexpr int kSimccY = 768;
__global__ void poseNormalizeKernel(
float* output, std::uint8_t const* input, int people, int inputStep) {
int index = blockIdx.x * blockDim.x + threadIdx.x;
int pixels = kInputWidth * kInputHeight;
int total = people * pixels;
if (index >= total) return;
int person = index / pixels;
int pixel = index - person * pixels;
int y = pixel / kInputWidth;
int x = pixel - y * kInputWidth;
auto const* value = input + person * inputStep * kInputHeight + y * inputStep + x * 3;
float* destination = output + static_cast<std::size_t>(person) * 3 * pixels;
destination[pixel] = static_cast<float>((static_cast<double>(value[0]) - 123.675) / 58.395);
destination[pixels + pixel] = static_cast<float>((static_cast<double>(value[1]) - 116.28) / 57.12);
destination[2 * pixels + pixel] = static_cast<float>((static_cast<double>(value[2]) - 103.53) / 57.375);
}
} // namespace
RtmwEngine::RtmwEngine(
std::filesystem::path const& enginePath, TrtLogger& logger,
int maxSourceWidth, int maxSourceHeight, int maxPeople)
: engine_(enginePath, logger), maxSourceWidth_(maxSourceWidth),
maxSourceHeight_(maxSourceHeight), maxPeople_(maxPeople),
centersHost_(maxPeople * 2), scalesHost_(maxPeople * 2),
simccXHost_(static_cast<std::size_t>(maxPeople) * kKeypoints * kSimccX),
simccYHost_(static_cast<std::size_t>(maxPeople) * kKeypoints * kSimccY) {
engine_.setInputShape("input", nvinfer1::Dims4{1,3,kInputHeight,kInputWidth});
engine_.prepare();
auto xs=engine_.shape("simcc_x"),ys=engine_.shape("simcc_y");
if(xs.nbDims!=3||ys.nbDims!=3||xs.d[0]!=1||ys.d[0]!=1||xs.d[1]!=kKeypoints||ys.d[1]!=kKeypoints||xs.d[2]!=kSimccX||ys.d[2]!=kSimccY)
throw std::runtime_error("RTMW engine I/O shape does not match the 133-point model");
for(auto name:{"input","simcc_x","simcc_y"})if(engine_.dataType(name)!=nvinfer1::DataType::kFLOAT)
throw std::runtime_error("RTMW requires float32 I/O bindings");
std::size_t const values = static_cast<std::size_t>(maxPeople) * 3 * kInputWidth * kInputHeight;
CUDA_CHECK(cudaMalloc(
&cropsDevice_, static_cast<std::size_t>(maxPeople_) * kInputWidth * kInputHeight * 3));
CUDA_CHECK(cudaMalloc(&networkDevice_, values * sizeof(float)));
CUDA_CHECK(cudaMalloc(&sourceDevice_, static_cast<size_t>(maxSourceWidth_)*maxSourceHeight_*3));
}
RtmwEngine::~RtmwEngine() {
if (sourceDevice_) cudaFree(sourceDevice_);
if (cropsDevice_) cudaFree(cropsDevice_);
if (networkDevice_) cudaFree(networkDevice_);
}
void RtmwEngine::warmup(cudaStream_t stream) {
std::vector<Box> boxes{{0, 0, static_cast<float>(maxSourceWidth_), static_cast<float>(maxSourceHeight_), 1, 1}};
cv::Mat black(maxSourceHeight_, maxSourceWidth_, CV_8UC3, cv::Scalar(0, 0, 0));
for (int i = 0; i < 2; ++i) infer(black, boxes, stream);
}
std::vector<PersonPose> RtmwEngine::infer(
cv::Mat const& frame, std::vector<Box> const& boxes, cudaStream_t stream,
float* preprocessMs, float* inferenceMs, float* postprocessMs) {
int batch = std::min(static_cast<int>(boxes.size()), maxPeople_);
if (batch <= 0) return {};
auto const preprocessStarted = std::chrono::steady_clock::now();
int const cropStep = kInputWidth * 3;
int const pixels = kInputWidth * kInputHeight;
if (frame.cols>maxSourceWidth_ || frame.rows>maxSourceHeight_) throw std::runtime_error("Pose input exceeds allocation");
auto npp = cms::nppContext(stream);
CUDA_CHECK(cudaMemcpy2DAsync(sourceDevice_, frame.cols*3, frame.data, frame.step, frame.cols*3, frame.rows, cudaMemcpyHostToDevice, stream));
for (int i = 0; i < batch; ++i) {
auto const& box = boxes[i];
double width = (static_cast<double>(box.x2) - static_cast<double>(box.x1)) * 1.25;
double height = (static_cast<double>(box.y2) - static_cast<double>(box.y1)) * 1.25;
constexpr double aspect = static_cast<double>(kInputWidth) / kInputHeight;
if (width > height * aspect) height = width / aspect;
else width = height * aspect;
centersHost_[i * 2] = 0.5 * (static_cast<double>(box.x1) + static_cast<double>(box.x2));
centersHost_[i * 2 + 1] = 0.5 * (static_cast<double>(box.y1) + static_cast<double>(box.y2));
scalesHost_[i * 2] = width;
scalesHost_[i * 2 + 1] = height;
cv::Point2f source[3]{
{static_cast<float>(centersHost_[i * 2]), static_cast<float>(centersHost_[i * 2 + 1])},
{static_cast<float>(centersHost_[i * 2]), static_cast<float>(centersHost_[i * 2 + 1] - width * 0.5)},
{static_cast<float>(centersHost_[i * 2] - width * 0.5), static_cast<float>(centersHost_[i * 2 + 1] - width * 0.5)}};
cv::Point2f destination[3]{
{kInputWidth * 0.5F, kInputHeight * 0.5F},
{kInputWidth * 0.5F, kInputHeight * 0.5F - kInputWidth * 0.5F},
{0.0F, kInputHeight * 0.5F - kInputWidth * 0.5F}};
cv::Mat affine = cv::getAffineTransform(source, destination);
auto* cropDevice=cropsDevice_+static_cast<size_t>(i)*cropStep*kInputHeight;
Npp8u fill[3]={0,0,0};
cms::nppCheck(nppiSet_8u_C3R_Ctx(fill,cropDevice,cropStep,{kInputWidth,kInputHeight},npp));
double coeff[2][3];
for(int row=0;row<2;++row) for(int col=0;col<3;++col) coeff[row][col]=affine.at<double>(row,col);
cms::nppCheck(nppiWarpAffine_8u_C3R_Ctx(sourceDevice_,{frame.cols,frame.rows},frame.cols*3,{0,0,frame.cols,frame.rows},cropDevice,cropStep,{0,0,kInputWidth,kInputHeight},coeff,NPPI_INTER_LINEAR,npp));
}
int const totalPixels = batch * pixels;
poseNormalizeKernel<<<(totalPixels + 255) / 256, 256, 0, stream>>>(
networkDevice_, cropsDevice_, batch, cropStep);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaStreamSynchronize(stream));
if (preprocessMs) {
*preprocessMs = static_cast<float>(std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - preprocessStarted).count());
}
// RTMPose's Python API invokes the ONNX/TensorRT session once per person.
// Preserve that execution contract instead of batching people together.
engine_.setInputShape("input", nvinfer1::Dims4{1, 3, kInputHeight, kInputWidth});
engine_.prepare();
CudaEventTimer timer;
float inferTime = 0.0F;
std::size_t const inputValues = static_cast<std::size_t>(3) * pixels;
std::size_t const xValues = static_cast<std::size_t>(kKeypoints) * kSimccX;
std::size_t const yValues = static_cast<std::size_t>(kKeypoints) * kSimccY;
for (int person = 0; person < batch; ++person) {
CUDA_CHECK(cudaMemcpyAsync(
engine_.device("input"), networkDevice_ + static_cast<std::size_t>(person) * inputValues,
inputValues * sizeof(float), cudaMemcpyDeviceToDevice, stream));
timer.start(stream);
if (!engine_.enqueue(stream)) throw std::runtime_error("RTMW enqueue failed");
inferTime += timer.stop(stream);
CUDA_CHECK(cudaMemcpyAsync(
simccXHost_.data() + static_cast<std::size_t>(person) * xValues,
engine_.device("simcc_x"), xValues * sizeof(float), cudaMemcpyDeviceToHost, stream));
CUDA_CHECK(cudaMemcpyAsync(
simccYHost_.data() + static_cast<std::size_t>(person) * yValues,
engine_.device("simcc_y"), yValues * sizeof(float), cudaMemcpyDeviceToHost, stream));
}
if (inferenceMs) *inferenceMs = inferTime;
auto const postprocessStarted = std::chrono::steady_clock::now();
CUDA_CHECK(cudaStreamSynchronize(stream));
std::vector<PersonPose> result(batch);
for (int person = 0; person < batch; ++person) {
result[person].box = boxes[person];
for (int keypoint = 0; keypoint < kKeypoints; ++keypoint) {
auto xBegin = simccXHost_.begin() + (person * kKeypoints + keypoint) * kSimccX;
auto yBegin = simccYHost_.begin() + (person * kKeypoints + keypoint) * kSimccY;
auto xIt = std::max_element(xBegin, xBegin + kSimccX);
auto yIt = std::max_element(yBegin, yBegin + kSimccY);
float score = 0.5F * (*xIt + *yIt);
if(!std::isfinite(score))throw std::runtime_error("Non-finite RTMW output");
float localX = score > 0.0F ? static_cast<float>(xIt - xBegin) * 0.5F : -0.5F;
float localY = score > 0.0F ? static_cast<float>(yIt - yBegin) * 0.5F : -0.5F;
double scaleX = scalesHost_[person * 2];
double scaleY = scalesHost_[person * 2 + 1];
result[person].keypoints[keypoint] = {
static_cast<float>(static_cast<double>(localX) / kInputWidth * scaleX + centersHost_[person * 2] - scaleX * 0.5),
static_cast<float>(static_cast<double>(localY) / kInputHeight * scaleY + centersHost_[person * 2 + 1] - scaleY * 0.5),
score};
}
}
float post = static_cast<float>(std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - postprocessStarted).count());
if (postprocessMs) *postprocessMs = post;
return result;
}
} // namespace fangjia
@@ -44,12 +44,6 @@ void TrtEngine::prepare() {
} }
auto& item = buffer(name); auto& item = buffer(name);
item.required = volume(dims) * dataTypeSize(engine_->getTensorDataType(name.c_str())); item.required = volume(dims) * dataTypeSize(engine_->getTensorDataType(name.c_str()));
if (item.external) {
if (item.capacity < item.required) {
throw std::runtime_error("External buffer is too small for " + name);
}
continue;
}
if (item.capacity < item.required) { if (item.capacity < item.required) {
if (item.owned) CUDA_CHECK(cudaFree(item.owned)); if (item.owned) CUDA_CHECK(cudaFree(item.owned));
CUDA_CHECK(cudaMalloc(&item.owned, item.required)); CUDA_CHECK(cudaMalloc(&item.owned, item.required));
@@ -59,19 +53,6 @@ void TrtEngine::prepare() {
} }
} }
void TrtEngine::bindExternal(std::string const& name, void* pointer, std::size_t bytesValue) {
auto& item = buffer(name);
item.active = pointer;
item.capacity = bytesValue;
item.external = true;
}
void TrtEngine::useOwned(std::string const& name) {
auto& item = buffer(name);
item.external = false;
item.active = item.owned;
}
bool TrtEngine::enqueue(cudaStream_t stream) { bool TrtEngine::enqueue(cudaStream_t stream) {
for (int index = 0; index < engine_->getNbIOTensors(); ++index) { for (int index = 0; index < engine_->getNbIOTensors(); ++index) {
std::string const name = engine_->getIOTensorName(index); std::string const name = engine_->getIOTensorName(index);
+17 -23
View File
@@ -1,39 +1,33 @@
# Orin 可直接运行包 — use-can # Orin 直接运行包
这个目录可以单独复制到 Orin 的任意目录,不依赖兄弟目录 0920/0923,不需要重新编译。 适用 Jetson AGX Orin / JetPack 5.1.2 (R35.4.1)、CUDA 11.4、TensorRT 8.5.x、OpenCV 4.5.4。
包含板端编译的 TensorRT/CUDA 原生库、YOLO/RTMW engine、Python 风险代码、CAN USB 库、标定文件和原有 GMSL 驱动。 系统需要 Python 3.8+、numpy、cv2。无需重新编译,可单独复制整个目录。
环境: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 提供,未重复打包。
```bash ```bash
cd orin
sha256sum -c SHA256SUMS sha256sum -c SHA256SUMS
./scripts/initialize_camera.sh # 开机后无 /dev/video5 时执行;需要 sudo 权限 ./scripts/initialize_camera.sh # 开机后无 /dev/video5 时执行,需要 sudo
./run.sh # 默认 CAN 左侧、无可视化,结果保存在内存 ./run.sh # 默认 CAN 模式,结果保存在内存
./run.sh --visualize # 可选浏览器预览,默认端口 8765 ./run.sh --visualize # 可选浏览器预览,http://设备IP:8765
``` ```
Ctrl+C / SIGTERM 退出。仅有一个进程可以占用同一个摄像头。 只运行一个摄像头采集进程,Ctrl+C / SIGTERM 停止。
```bash - data[0..3]:左前、右前、左后、右后;`--can-side left/right` 选择摄像头侧。
./run.sh --no-can --active-door both --duration 10 - 未收到有效 CAN 或超时:CAN_UNKNOWN,score=null;不发送执行器控制报文。
./run.sh --save-results --output ./outputs/session - `--save-results` 保存 JSONL;`--save-snapshots` 保存快照;`--write-video` 保存叠加视频。默认均关闭。
./run.sh --visualize --save-results --save-snapshots --output ./outputs/session - `--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 ```python
from run_camera import CameraPipeline, parse_args from run_camera import CameraPipeline, parse_args
with CameraPipeline(parse_args([])) as pipeline: with CameraPipeline(parse_args([])) as pipeline:
result = pipeline.wait_for_result(timeout=10) result = pipeline.wait_for_result(timeout=10)
result = pipeline.latest_result # 等价于 pipeline.latest() result = pipeline.latest_result # 等价于 pipeline.latest()
if result is not None: 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
View File
@@ -1,27 +1,25 @@
4171567ecbea2f15b3c945b8d8363362d9f422b8c732d33ee1e15461b40014da README.md b6295cf4b0705c5c3d83c9ba35e41b2a0b865ea67742c09cfd1690f263f556d0 README.md
9ba768d559fd3b64e833b50c62c70f2adfd6c169620aea5fba32cfb1b4c5b0e9 annotations/constant_door_mask.json 9ba768d559fd3b64e833b50c62c70f2adfd6c169620aea5fba32cfb1b4c5b0e9 annotations/constant_door_mask.json
3544fb1c7b1b27b39db27d8f849d5e8652f5d09a950a59c5dd0c7703086b4f6b annotations/door_zone_multifeature.json 3544fb1c7b1b27b39db27d8f849d5e8652f5d09a950a59c5dd0c7703086b4f6b annotations/door_zone_multifeature.json
6d5e874fd27fcfd75d303d7e35d9672028af71041f52dcc82a1e83676faefa65 bin/drivers/max9295.ko 6d5e874fd27fcfd75d303d7e35d9672028af71041f52dcc82a1e83676faefa65 bin/drivers/max9295.ko
8a78d04ef8be44e9b62a5e4ee315d3d6d679e34aedff02a234bbf4d40e222933 bin/drivers/max9296.ko 8a78d04ef8be44e9b62a5e4ee315d3d6d679e34aedff02a234bbf4d40e222933 bin/drivers/max9296.ko
3da628587477cc35d5e3f8aee86e718e2ddf16808e893e00ba6e164b2307e843 bin/drivers/sgx-yuv-gmsl2.ko 3da628587477cc35d5e3f8aee86e718e2ddf16808e893e00ba6e164b2307e843 bin/drivers/sgx-yuv-gmsl2.ko
cd75632beaaf81df2f59ce3e83fb335c21cc745068ef2b721c74cc3b2b03db26 build/libvision_native.so 5ba7e4f0371a411cc1a6dfd1275e3b824032a14d07a9ccd7ca3d5a6630b322ef build/libvision_native.so
49cc1067c1fa9f82a3d57dbfa77a55a54e5e5062ceae7ae84144b77745db79cf examples/read_latest.py
8dae8c557deadbe6bd7241c0dcd7025cb007077ee39a79b115e953a2b22cd9c1 lib/libusb-1.0.so 8dae8c557deadbe6bd7241c0dcd7025cb007077ee39a79b115e953a2b22cd9c1 lib/libusb-1.0.so
ab8b7f7249d5fb73d1d33d14fa7532a522088ffb6c25f37ba334daf4c6b4d313 lib/libusbcanfd.so ab8b7f7249d5fb73d1d33d14fa7532a522088ffb6c25f37ba334daf4c6b4d313 lib/libusbcanfd.so
ebcd09e8c2e35008adbb0c1d222e580b17e0df1c8093df3481ba3374cd9a8174 models/engines/rtmw_l_384.engine ebcd09e8c2e35008adbb0c1d222e580b17e0df1c8093df3481ba3374cd9a8174 models/engines/rtmw_l_384.engine
f6a65b926ac1144484f4aa93cd26d7997d32d62310ab93fe867f2bd4412222e9 models/engines/yolo11s.engine f6a65b926ac1144484f4aa93cd26d7997d32d62310ab93fe867f2bd4412222e9 models/engines/yolo11s.engine
fd3ae92255abf95ef037a1060ee75897f9705b4040f9394c3130f6ab860667e1 pipeline/compare_rtmw_ml.py
6d6d14a972bc4e5e9f8256514169e73ba0392a301cacd1221eaa26146aad00d2 pipeline/door_mask_risk.py 6d6d14a972bc4e5e9f8256514169e73ba0392a301cacd1221eaa26146aad00d2 pipeline/door_mask_risk.py
46d70e1e90a1f36d544b7492ef1fbf08821377a1c460a6683f42d4d7006055c7 pipeline/door_zone_risk.py 46d70e1e90a1f36d544b7492ef1fbf08821377a1c460a6683f42d4d7006055c7 pipeline/door_zone_risk.py
bc74a400ce5c5608c2fa49a69ccd8b41ec8633cd6a8239d9d594b8d9e5c3732a pipeline/fuse_rtmw_l_temporal.py
bcf2cc99dd27c8749801a5b743656767d9d64ef03d568d227cb654c573d70b00 pipeline/latest_result.py bcf2cc99dd27c8749801a5b743656767d9d64ef03d568d227cb654c573d70b00 pipeline/latest_result.py
cc8ea36d543896cd90f7a00822f06fc67771a1811859a4087995ac372df30276 pipeline/live_pose_engine.py 294cdc11aad0e5475275fd8dece2057e229b9bdedba69c26afa62176fd51a4e9 pipeline/native_backend.py
da299c11bd25127a09ffa738876f1de6e0d53d27add2840aeb6092ab0fe7016b pipeline/native_backend.py 8b8c5b8c9c76c9bd2e1efa6cba60be337a18c9366e8f748038465e9e2fba2672 pipeline/pose_engine.py
81088b8bc072162bec13f57c65eb0d396e6d1e1074a83f92bcf29e393e27e11f pipeline/occlusion_aware_pose_v2.py 93d3323ea431ed1d3c8397cfed7d800d65ec6d7700690487335861945040a486 pipeline/pose_fusion.py
059e2a67c17ce63354986d3242a5d361103ee4241c5391f18140eeb19e47c939 pipeline/predict_optimized_pose_videos.py 2ebf58114835ba7c4779d4b66aeff65b8a94107128989c0336de556a4cf95fda pipeline/pose_geometry.py
bf8bb754cd841a52b4410f958a651b005de36ac4d17cc0992fbe1c90ed30caca pipeline/rtmw_l_person_mask_ablation.py f32fa5bebf893a3dc6b9ccfaa09a8e3a153ad5b36dde5373fdc561f10f9e526b pipeline/pose_occlusion.py
1bb58d851ba5d684527b8d1e1940db6241d2b0b2862687fa3b151899a65320d2 pipeline/rtmw_risk_pipeline.py 1a3f1f55d7b8a3f0ac96e69bf77f02f82017e8970adef96181d2bfade26e5bd5 pipeline/pose_tracking.py
dcf3cce5562ea52400ffa9842fdddaadac1efd672dabb0ed12ce34f20f779f37 pipeline/visualization.py
8a10e035334ed97b7480f558aff7b0464c255ac6138dd99f55a08ade68853e0e run.sh 8a10e035334ed97b7480f558aff7b0464c255ac6138dd99f55a08ade68853e0e run.sh
4393f367b4ac7e5a00908043ffd21d5573cc12ed19b956fd101897ada22afd45 run_camera.py 23e9af5e82999889fe77c762a77c77036d1b1b699525ff2bd5ca8a418183ba50 run_camera.py
11b5fbc3c17c0610ec0514a0a26cf5d92588d15c0e50e7086d9db5a5c0306e99 scripts/initialize_camera.sh 11b5fbc3c17c0610ec0514a0a26cf5d92588d15c0e50e7086d9db5a5c0306e99 scripts/initialize_camera.sh
4cc40e244495e1de60a0429a8fc58cdf383ccda471955e53360a1c36a278c822 scripts/initialize_camera_impl.sh bce5a22d49633454ed2c6bc92b608edf3b718af2424c283ed8f56ba93987200a scripts/initialize_camera_impl.sh
Binary file not shown.
-36
View File
@@ -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()
-384
View File
@@ -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()
-9
View File
@@ -1,11 +1,8 @@
"""Native Jetson TensorRT adapter; temporal/risk policy stays in upstream Python.""" """Native Jetson TensorRT adapter; temporal/risk policy stays in upstream Python."""
from __future__ import annotations from __future__ import annotations
import ctypes as C import ctypes as C
from collections import defaultdict, deque
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from live_pose_engine import LivePoseEngine
from predict_optimized_pose_videos import BoxTracker
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -67,9 +64,3 @@ class NativeBackend:
def close(self): def close(self):
if self.camera:self.lib.vap_camera_close(self.camera);self.camera=None 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 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 from __future__ import annotations
import argparse
import os
import time import time
from collections import defaultdict from collections import defaultdict, deque
from pathlib import Path
import numpy as np import numpy as np
from pose_tracking import BoxTracker
SIDES = ("left", "right") SIDES = ("left", "right")
class NativePoseEngine:
class LivePoseEngine: def __init__(self, args, native):
"""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
self.args = args self.args = args
self.detector = YOLO(str(args.det_model)) self.native = native
self.pose = RTMPose( self.pose = native.pose
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.box_tracker = BoxTracker(max_age=30) self.box_tracker = BoxTracker(max_age=30)
self.people = {} self.people = {}
self.timings = defaultdict(list) self.timings = defaultdict(lambda: deque(maxlen=3000))
self.isolated_inferences = 0 self.isolated_inferences = 0
def infer(self, frame: np.ndarray, frame_idx: int) -> dict: def infer(self, frame: np.ndarray, frame_idx: int) -> dict:
from compare_rtmw_ml import HAND_SLICES from pose_geometry import HAND_SLICES
from fuse_rtmw_l_temporal import adaptive_smooth, build_global_hands, fuse_person from pose_fusion import adaptive_smooth, build_global_hands, fuse_person
from occlusion_aware_pose_v2 import Measurement, resolve_global_hand_assignments from pose_occlusion import Measurement, resolve_global_hand_assignments
from predict_optimized_pose_videos import ( from pose_tracking import (
LivePersonState, LivePersonState,
filter_person_boxes,
mask_other_people, mask_other_people,
update_live_v2, update_live_v2,
) )
@@ -118,23 +33,8 @@ class LivePoseEngine:
detection_height = max( detection_height = max(
1, min(height, round(height * self.args.det_roi_y_max_ratio)) 1, min(height, round(height * self.args.det_roi_y_max_ratio))
) )
if hasattr(self, "native"): boxes, confidences = self.native.detect(frame[:detection_height])
boxes, confidences = self.native.detect(frame[:detection_height]) self.timings["detector_ms"].append((time.perf_counter() - started) * 1000)
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
)
track_ids = self.box_tracker.update(boxes, frame_idx, (height, width)) track_ids = self.box_tracker.update(boxes, frame_idx, (height, width))
# Retire disappeared people in long-running camera sessions. # Retire disappeared people in long-running camera sessions.
@@ -1,38 +1,18 @@
"""Fuse raw and person-isolated RTMW-L poses with temporal consistency. """Runtime pose fusion helpers."""
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.
"""
from __future__ import annotations from __future__ import annotations
import argparse
import json
import math import math
from collections import Counter, defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
import cv2 import cv2
import numpy as np import numpy as np
from pose_geometry import BODY_WRISTS, HAND_EDGES_LOCAL, HAND_SLICES, normalized_keypoints
from compare_rtmw_ml import (
BODY_EDGES,
BODY_WRISTS,
HAND_EDGES_LOCAL,
HAND_SLICES,
draw_pose,
normalized_keypoints,
summarize,
temporal_metrics,
)
BODY_SIDE_INDICES = {"left": (5, 7, 9), "right": (6, 8, 10)} BODY_SIDE_INDICES = {"left": (5, 7, 9), "right": (6, 8, 10)}
SIDE_COLORS = {"left": (40, 40, 255), "right": (30, 230, 30)} SIDE_COLORS = {"left": (40, 40, 255), "right": (30, 230, 30)}
HAND_COLORS = {"left": (255, 80, 220), "right": (255, 190, 40)} HAND_COLORS = {"left": (255, 80, 220), "right": (255, 190, 40)}
@@ -71,15 +51,6 @@ class PersonTrack:
smoothed: np.ndarray | None = None 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: def bbox_diagonal(bbox: list[float]) -> float:
return max(math.hypot(bbox[2] - bbox[0], bbox[3] - bbox[1]), 1.0) return max(math.hypot(bbox[2] - bbox[0], bbox[3] - bbox[1]), 1.0)
@@ -557,299 +528,3 @@ def draw_fused(
cv2.LINE_AA, cv2.LINE_AA,
) )
return canvas 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()
+28
View File
@@ -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
+513
View File
@@ -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
+304
View File
@@ -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()
-390
View File
@@ -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()
+116
View File
@@ -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
View File
@@ -16,11 +16,12 @@ import cv2
import numpy as np import numpy as np
ROOT=Path(__file__).resolve().parent ROOT=Path(__file__).resolve().parent
sys.path.insert(0,str(ROOT/'pipeline')) 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 latest_result import LatestResult, result_to_dict
from door_mask_risk import DoorMaskSequence, DoorRiskEvaluator from door_mask_risk import DoorMaskSequence, DoorRiskEvaluator
from door_zone_risk import DoorZoneConfig, DoorZoneAssigner, build_door_risks 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): def select_active_door(mode, snapshot, side='left', timeout_ms=1000):
@@ -121,7 +122,7 @@ class Preview:
def parse_args(argv=None): def parse_args(argv=None):
p=argparse.ArgumentParser(description=__doc__) 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('--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('--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('--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') p.add_argument('--active-door',choices=['can','front','rear','both','none'],default='can')
+2 -2
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/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 set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)" 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}" device="${1:-/dev/video5}"
kernel="$(uname -r)" kernel="$(uname -r)"
for driver in max9295 max9296 sgx-yuv-gmsl2; do for driver in max9295 max9296 sgx-yuv-gmsl2; do
-384
View File
@@ -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()
-9
View File
@@ -1,11 +1,8 @@
"""Native Jetson TensorRT adapter; temporal/risk policy stays in upstream Python.""" """Native Jetson TensorRT adapter; temporal/risk policy stays in upstream Python."""
from __future__ import annotations from __future__ import annotations
import ctypes as C import ctypes as C
from collections import defaultdict, deque
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from live_pose_engine import LivePoseEngine
from predict_optimized_pose_videos import BoxTracker
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -67,9 +64,3 @@ class NativeBackend:
def close(self): def close(self):
if self.camera:self.lib.vap_camera_close(self.camera);self.camera=None 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 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 from __future__ import annotations
import argparse
import os
import time import time
from collections import defaultdict from collections import defaultdict, deque
from pathlib import Path
import numpy as np import numpy as np
from pose_tracking import BoxTracker
SIDES = ("left", "right") SIDES = ("left", "right")
class NativePoseEngine:
class LivePoseEngine: def __init__(self, args, native):
"""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
self.args = args self.args = args
self.detector = YOLO(str(args.det_model)) self.native = native
self.pose = RTMPose( self.pose = native.pose
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.box_tracker = BoxTracker(max_age=30) self.box_tracker = BoxTracker(max_age=30)
self.people = {} self.people = {}
self.timings = defaultdict(list) self.timings = defaultdict(lambda: deque(maxlen=3000))
self.isolated_inferences = 0 self.isolated_inferences = 0
def infer(self, frame: np.ndarray, frame_idx: int) -> dict: def infer(self, frame: np.ndarray, frame_idx: int) -> dict:
from compare_rtmw_ml import HAND_SLICES from pose_geometry import HAND_SLICES
from fuse_rtmw_l_temporal import adaptive_smooth, build_global_hands, fuse_person from pose_fusion import adaptive_smooth, build_global_hands, fuse_person
from occlusion_aware_pose_v2 import Measurement, resolve_global_hand_assignments from pose_occlusion import Measurement, resolve_global_hand_assignments
from predict_optimized_pose_videos import ( from pose_tracking import (
LivePersonState, LivePersonState,
filter_person_boxes,
mask_other_people, mask_other_people,
update_live_v2, update_live_v2,
) )
@@ -118,23 +33,8 @@ class LivePoseEngine:
detection_height = max( detection_height = max(
1, min(height, round(height * self.args.det_roi_y_max_ratio)) 1, min(height, round(height * self.args.det_roi_y_max_ratio))
) )
if hasattr(self, "native"): boxes, confidences = self.native.detect(frame[:detection_height])
boxes, confidences = self.native.detect(frame[:detection_height]) self.timings["detector_ms"].append((time.perf_counter() - started) * 1000)
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
)
track_ids = self.box_tracker.update(boxes, frame_idx, (height, width)) track_ids = self.box_tracker.update(boxes, frame_idx, (height, width))
# Retire disappeared people in long-running camera sessions. # Retire disappeared people in long-running camera sessions.
@@ -1,38 +1,18 @@
"""Fuse raw and person-isolated RTMW-L poses with temporal consistency. """Runtime pose fusion helpers."""
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.
"""
from __future__ import annotations from __future__ import annotations
import argparse
import json
import math import math
from collections import Counter, defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
import cv2 import cv2
import numpy as np import numpy as np
from pose_geometry import BODY_WRISTS, HAND_EDGES_LOCAL, HAND_SLICES, normalized_keypoints
from compare_rtmw_ml import (
BODY_EDGES,
BODY_WRISTS,
HAND_EDGES_LOCAL,
HAND_SLICES,
draw_pose,
normalized_keypoints,
summarize,
temporal_metrics,
)
BODY_SIDE_INDICES = {"left": (5, 7, 9), "right": (6, 8, 10)} BODY_SIDE_INDICES = {"left": (5, 7, 9), "right": (6, 8, 10)}
SIDE_COLORS = {"left": (40, 40, 255), "right": (30, 230, 30)} SIDE_COLORS = {"left": (40, 40, 255), "right": (30, 230, 30)}
HAND_COLORS = {"left": (255, 80, 220), "right": (255, 190, 40)} HAND_COLORS = {"left": (255, 80, 220), "right": (255, 190, 40)}
@@ -71,15 +51,6 @@ class PersonTrack:
smoothed: np.ndarray | None = None 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: def bbox_diagonal(bbox: list[float]) -> float:
return max(math.hypot(bbox[2] - bbox[0], bbox[3] - bbox[1]), 1.0) return max(math.hypot(bbox[2] - bbox[0], bbox[3] - bbox[1]), 1.0)
@@ -557,299 +528,3 @@ def draw_fused(
cv2.LINE_AA, cv2.LINE_AA,
) )
return canvas 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()
+28
View File
@@ -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
+513
View File
@@ -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
+304
View File
@@ -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
-683
View File
@@ -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()
-166
View File
@@ -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()
-390
View File
@@ -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()
+116
View File
@@ -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
View File
@@ -16,11 +16,12 @@ import cv2
import numpy as np import numpy as np
ROOT=Path(__file__).resolve().parent ROOT=Path(__file__).resolve().parent
sys.path.insert(0,str(ROOT/'pipeline')) 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 latest_result import LatestResult, result_to_dict
from door_mask_risk import DoorMaskSequence, DoorRiskEvaluator from door_mask_risk import DoorMaskSequence, DoorRiskEvaluator
from door_zone_risk import DoorZoneConfig, DoorZoneAssigner, build_door_risks 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): def select_active_door(mode, snapshot, side='left', timeout_ms=1000):
@@ -121,7 +122,7 @@ class Preview:
def parse_args(argv=None): def parse_args(argv=None):
p=argparse.ArgumentParser(description=__doc__) 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('--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('--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('--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') p.add_argument('--active-door',choices=['can','front','rear','both','none'],default='can')
+1
View File
@@ -2,4 +2,5 @@
set -euo pipefail set -euo pipefail
root="$(cd "$(dirname "$0")" && pwd)" root="$(cd "$(dirname "$0")" && pwd)"
export OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=2 export OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=2
export LD_LIBRARY_PATH="$root/orin/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
exec "${PYTHON:-python3}" -u "$root/run_camera.py" "$@" exec "${PYTHON:-python3}" -u "$root/run_camera.py" "$@"
-5
View File
@@ -1,5 +0,0 @@
"""0923 entry point: native TensorRT camera/video inference and CMS v9.10 risk."""
from run_camera import main
if __name__ == '__main__':
main()

Some files were not shown because too many files have changed in this diff Show More