Port complete CAN-aware risk pipeline to C++
This commit is contained in:
+18
-12
@@ -1,18 +1,24 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(vision_anti_pinch_0923 LANGUAGES CXX CUDA)
|
project(vision_anti_pinch LANGUAGES CXX CUDA)
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(CMAKE_CUDA_STANDARD 17)
|
set(CMAKE_CUDA_STANDARD 17)
|
||||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
|
||||||
set(CMAKE_CUDA_ARCHITECTURES 87)
|
set(CMAKE_CUDA_ARCHITECTURES 87)
|
||||||
find_package(CUDAToolkit REQUIRED)
|
find_package(CUDAToolkit REQUIRED)
|
||||||
find_package(OpenCV REQUIRED COMPONENTS core imgproc)
|
find_package(OpenCV REQUIRED COMPONENTS core imgproc imgcodecs highgui videoio)
|
||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
find_path(TRT_INCLUDE NvInfer.h PATH_SUFFIXES aarch64-linux-gnu REQUIRED)
|
find_path(TENSORRT_INCLUDE_DIR NvInfer.h PATH_SUFFIXES aarch64-linux-gnu REQUIRED)
|
||||||
find_library(TRT_LIB nvinfer REQUIRED)
|
find_library(TENSORRT_LIBRARY nvinfer REQUIRED)
|
||||||
add_library(vision_native SHARED native/native_api.cpp native/v4l2_capture.cpp native/can_comm.cpp
|
add_library(camera_risk_core
|
||||||
native/trt_engine.cpp native/pose_engine.cu
|
src/trt_engine.cpp src/pose_engine.cu src/gpu_preprocess.cu src/uyvy_converter.cu
|
||||||
native/gpu_preprocess.cu native/uyvy_converter.cu native/detector.cpp)
|
src/detector.cpp src/v4l2_capture.cpp src/camera.cpp src/can_comm.cpp src/can_state.cpp
|
||||||
target_include_directories(vision_native PRIVATE native/include ${TRT_INCLUDE} ${OpenCV_INCLUDE_DIRS})
|
src/door_risk.cpp src/door_zone.cpp src/camera_pipeline.cpp src/config.cpp src/serialization.cpp)
|
||||||
target_link_libraries(vision_native PRIVATE ${TRT_LIB} ${OpenCV_LIBS} Threads::Threads CUDA::cudart CUDA::nppc CUDA::nppig CUDA::nppidei CUDA::nppicc
|
target_include_directories(camera_risk_core PUBLIC include third_party ${TENSORRT_INCLUDE_DIR} ${OpenCV_INCLUDE_DIRS})
|
||||||
"${CMAKE_SOURCE_DIR}/orin/lib/libusbcanfd.so" "${CMAKE_SOURCE_DIR}/orin/lib/libusb-1.0.so")
|
target_link_libraries(camera_risk_core PUBLIC ${TENSORRT_LIBRARY} ${OpenCV_LIBS} Threads::Threads CUDA::cudart CUDA::nppc CUDA::nppig CUDA::nppidei CUDA::nppicc
|
||||||
set_target_properties(vision_native PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "$ORIGIN/../lib;$ORIGIN/../orin/lib;/usr/local/cuda/lib64")
|
"${CMAKE_SOURCE_DIR}/orin/lib/libusbcanfd.so" "${CMAKE_SOURCE_DIR}/orin/lib/libusb-1.0.so")
|
||||||
|
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)
|
||||||
|
set_target_properties(camera_risk PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "$ORIGIN/../lib;$ORIGIN/../orin/lib;/usr/local/cuda/lib64")
|
||||||
|
|||||||
@@ -1,91 +1,96 @@
|
|||||||
# Vision Anti-Pinch — use-can
|
# Vision Anti-Pinch — use-can(纯 C++)
|
||||||
|
|
||||||
V4L2 摄像头 + CAN 门状态 + YOLO/RTMW TensorRT 推理 + CMS 风险判断。
|
本分支将 CMS `run_risk.py` 的处理流程移植到 C++,复用 0920 的 TensorRT 模型、CUDA/NPP 预处理及流水线实现。运行入口、时序融合、前后门归属、CAN 门级风险过滤和结果发布均在 C++ 进程中完成,不调用 Python。
|
||||||
默认持续推理,将最新完整结果保存在内存;可视化和文件保存均按需开启。
|
|
||||||
|
|
||||||
## 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。
|
目标环境:Jetson AGX Orin、JetPack 5.1.2 / L4T R35.4.1、CUDA 11.4、TensorRT 8.5.x、OpenCV 4.5.4。
|
||||||
`orin/` 包含编译库、TensorRT 引擎、CAN 库、摄像头驱动、Python 运行代码和标定文件,可以单独复制到其他目录。
|
源码编译另需 CMake 3.24+ 和相应开发库。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build.sh # 或 make
|
||||||
|
./scripts/initialize_camera.sh # 开机后摄像头尚未初始化时执行
|
||||||
|
./run_camera.sh # 执行 build/camera_risk
|
||||||
|
./run_camera.sh --visualize # 可选 OpenCV 本地窗口
|
||||||
|
```
|
||||||
|
|
||||||
|
预编译 Orin 包:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd orin
|
cd orin
|
||||||
./scripts/initialize_camera.sh # 开机后无 /dev/video5 时初始化,需要 sudo
|
./scripts/initialize_camera.sh
|
||||||
./run.sh # 默认无可视化,结果保存在内存
|
./run.sh # 脚本直接 exec C++ ELF 程序
|
||||||
# 或开启浏览器预览:http://设备IP:8765
|
# 也可以直接执行:
|
||||||
./run.sh --visualize
|
./build/camera_risk --config config/camera.json
|
||||||
```
|
```
|
||||||
|
|
||||||
每次只运行一个采集进程;Ctrl+C / SIGTERM 停止。关闭浏览器不会停止推理。
|
默认无窗口、不保存文件;Ctrl+C / SIGTERM 停止。`--visualize` 使用 Orin 的图形桌面,关闭窗口仅停止显示、推理继续。没有桌面环境时保持无窗口推理。没有 Python HTTP 预览服务。
|
||||||
系统 CUDA/TensorRT/OpenCV/Python 运行环境由目标系统提供,不随包重复分发。TensorRT 引擎不能直接用于 Thor 或其他 JetPack/GPU 环境。
|
|
||||||
|
|
||||||
## 从源码编译
|
## 流水线与结果变量
|
||||||
|
|
||||||
在目标 Orin 上执行,依赖 CMake 3.24+、CUDA/NPP、TensorRT、OpenCV 开发库:
|
```
|
||||||
|
V4L2 MMAP 采集 / NPP UYVY 转换(线程 1)
|
||||||
|
→ YOLO TensorRT 检测(线程 2)
|
||||||
|
→ RTMW TensorRT / 隔离推理 / C++ 时序融合(线程 3)
|
||||||
|
→ 前后门多特征归属 / 因果中值 / mask 风险 / CAN 过滤
|
||||||
|
→ shared_ptr<const FrameResult> 最新结果变量
|
||||||
|
```
|
||||||
|
|
||||||
|
摄像头使用有界队列并丢弃积压旧帧;可视化独立读取最新结果。
|
||||||
|
下游直接链接 `camera_risk_core`,在自己的线程中读取:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "camera_pipeline.hpp"
|
||||||
|
#include "config.hpp"
|
||||||
|
|
||||||
|
cms::CameraPipeline pipeline(cms::loadConfig("config/camera.json"));
|
||||||
|
pipeline.start();
|
||||||
|
// 下游自己的循环中读取;首帧前 result 为空。
|
||||||
|
auto result = pipeline.latest();
|
||||||
|
if (result) {
|
||||||
|
const auto& risk = result->risk;
|
||||||
|
const auto& front = result->door_risks.at("front");
|
||||||
|
const auto& rear = result->door_risks.at("rear");
|
||||||
|
const auto& people = result->persons; // 每人 selected/smoothed/temporal 各 133 点
|
||||||
|
const auto& assignments = result->door_assignments;
|
||||||
|
const auto& can = result->can;
|
||||||
|
// risk.risk_type / risk.risk_level / risk.risk_score(std::optional<double>)
|
||||||
|
}
|
||||||
|
// 退出时:
|
||||||
|
pipeline.requestStop();
|
||||||
|
pipeline.join();
|
||||||
|
```
|
||||||
|
|
||||||
|
`latest()` 返回拥有数据的只读快照,读者互不消费结果,旧引用不会被新帧修改。慢读者可以跳过中间帧;异常通过 `join()` 传播。停止后的快照只代表最后结果,持续控制应检查运行状态和时间戳。
|
||||||
|
公共结构定义见 `include/frame_result.hpp`,接口见 `include/camera_pipeline.hpp`。
|
||||||
|
|
||||||
|
## CAN 与风险
|
||||||
|
|
||||||
|
- 0x391 的 data[0..3]:左前、右前、左后、右后。
|
||||||
|
- 默认左侧,`--can-side right` 切到右侧;值为 0 时对应门活跃。
|
||||||
|
- 缺失或过期(默认 1000 ms)报文输出 `CAN_UNKNOWN`,分数为空,不能解释为 clear。
|
||||||
|
- `--no-can --active-door both` 显式模拟门状态;也可选择 front/rear/none。
|
||||||
|
- 优先使用有效手部凸包,无有效手部时用人体框兜底;transition 保留 unknown 语义。
|
||||||
|
- 最终使用 `risk` 或 `door_risks`;`ungated_risk` 仅用于诊断。当前只接收门状态,不发送执行器指令。
|
||||||
|
- mask 和前后门参数来自研发机位,默认 calibration_verified=false;装车后需要重新标定。
|
||||||
|
|
||||||
|
配置位于 `config/camera.json`,模型位于 `orin/models/engines/`。详细参数:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/build.sh # 或 make
|
./run_camera.sh --help
|
||||||
./scripts/initialize_camera.sh # 摄像头尚未初始化时执行
|
./run_camera.sh --duration 30
|
||||||
./run_camera.sh # 使用新编译的 build/libvision_native.so
|
./run_camera.sh --save-results /path/results.jsonl
|
||||||
```
|
```
|
||||||
|
|
||||||
源码入口复用 `orin/models/engines/` 和 `orin/lib/`,不依赖旧版工程。
|
显式保存时每帧写一行 JSON,核心字段保留 frame_index、track_ids、boxes、skeletons、door_assignments、door_risks、risk、ungated_risk、can;文件由用户指定路径管理。`--video` 可使用同一 C++ 流程处理视频文件,尺寸需匹配配置。
|
||||||
修改源码后刷新直接运行包:
|
|
||||||
|
|
||||||
```bash
|
## 目录与打包
|
||||||
./scripts/package_orin.sh # 或 make package
|
|
||||||
cd orin && sha256sum -c SHA256SUMS
|
|
||||||
```
|
|
||||||
|
|
||||||
打包会替换运行代码,清除已删除的旧 Python 模块,并更新文件校验值。
|
- `src/`:C++ / CUDA 源文件及 main.cpp。
|
||||||
|
- `include/`:头文件及下游数据结构。
|
||||||
|
- `third_party/nlohmann/`:C++ JSON 依赖及许可证。
|
||||||
|
- `config/`、`annotations/`:运行配置与标定。
|
||||||
|
- `scripts/`:编译、摄像头初始化、打包。
|
||||||
|
- `orin/`:可单独复制的 ELF 程序、模型、驱动、CAN 库和配置。
|
||||||
|
|
||||||
## 下游读取结果
|
修改源码后,在 Orin 重新编译并执行 `./scripts/package_orin.sh`,或执行 `make package`。运行、打包均不使用 Python。打包后可在 orin 目录运行 `sha256sum -c SHA256SUMS`。
|
||||||
|
|
||||||
在同一个 Python 进程中创建流水线,然后读取单个最新只读快照:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from run_camera import CameraPipeline, parse_args
|
|
||||||
|
|
||||||
with CameraPipeline(parse_args([])) as pipeline:
|
|
||||||
result = pipeline.wait_for_result(timeout=10)
|
|
||||||
# 下游自己的循环/线程中读取;首帧前为 None:
|
|
||||||
result = pipeline.latest_result # 等价于 pipeline.latest()
|
|
||||||
if result is not None:
|
|
||||||
print(result['frame_index'], result['risk'])
|
|
||||||
door_risks = result['door_risks']
|
|
||||||
skeletons = result['skeletons']
|
|
||||||
```
|
|
||||||
|
|
||||||
结果字段:`frame_index`、`track_ids`、`boxes`、`skeletons`(每人 133 点)、`door_assignments`、`active_door`、`door_risks`、`risk`、`ungated_risk`、`can`、`capture`、`capture_to_result_ms`、`calibration_verified`。
|
|
||||||
|
|
||||||
- 读取不消费结果;多个读者可以持有同一个不可变快照,旧快照不会被下一帧改写。
|
|
||||||
- 只保留最新结果,慢读者可以跳过帧;`wait_for_result(last_frame_index, timeout)` 可等待更新。
|
|
||||||
- `request_stop()` 请求停止,`join()` 等待退出并传播异常;`with` 自动回收资源。
|
|
||||||
- 停止后仍保留最后快照;下游应检查运行状态和帧时间,避免使用过期结果。
|
|
||||||
- `result_to_dict(result)` 生成可修改、可序列化的副本。变量属于当前进程,不是跨进程共享内存。
|
|
||||||
|
|
||||||
## CAN、风险与输出选项
|
|
||||||
|
|
||||||
`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 预览;默认不绘制、不编码、不监听端口 |
|
|
||||||
| `--save-results` | 保存逐帧 JSONL 及 latest.json,默认不保存 |
|
|
||||||
| `--save-snapshots` | 每约 10 秒保存叠加图像 |
|
|
||||||
| `--write-video` | 保存叠加视频,可配合 `--duration` 限时运行 |
|
|
||||||
| `--output PATH` | 指定保存目录,本身不启用保存 |
|
|
||||||
| `--no-can --active-door both` | 显式模拟门状态 |
|
|
||||||
|
|
||||||
运行 `./run_camera.sh --help` 或 `orin/run.sh --help` 查看全部选项。JSONL 每 32 MiB 轮换,保留两个历史文件;视频不轮换。开启可视化后可读取 `/result.json` 和 `/frame.jpg`。
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
- `native/`:实际参与编译的摄像头/CAN、CUDA/NPP、TensorRT 源码及头文件。
|
|
||||||
- `pipeline/`:运行时姿态融合、跟踪、遮挡处理、风险、可视化和结果变量。
|
|
||||||
- `annotations/`:源码运行时的标定文件。
|
|
||||||
- `scripts/`:构建、打包、摄像头初始化。
|
|
||||||
- `orin/`:可单独复制并直接执行的运行包。
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"camera": {"device":"/dev/video5","width":1920,"height":1536,"fps":30,"buffers":4,"timeout_ms":2000},
|
||||||
|
"inference": {"engine_directory":"../orin/models/engines","detector_confidence":0.30,"max_people":3,"queue_capacity":2},
|
||||||
|
"door": {"mask":"../annotations/constant_door_mask.json","zone_config":"../annotations/door_zone_multifeature.json","warning_relative":0.30,"release_relative":0.45,"calibration_verified":false},
|
||||||
|
"can": {"enabled":true,"side":"left","timeout_ms":1000}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
|
bool pushLatest(T item) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if(closed_)return false;
|
||||||
|
if(items_.size()>=capacity_)items_.pop_front();
|
||||||
|
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_{};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "image.hpp"
|
||||||
|
#include <atomic>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace cms {
|
||||||
|
struct CameraConfig {
|
||||||
|
std::string device{"/dev/video5"};
|
||||||
|
std::string video;
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#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,door_zone_config;
|
||||||
|
bool can_enabled{true};
|
||||||
|
std::string can_side{"left"},active_door{"can"};
|
||||||
|
double can_timeout_ms{1000};
|
||||||
|
std::uint64_t max_frames{};
|
||||||
|
std::filesystem::path results_path,pose_records_path;
|
||||||
|
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_;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <array>
|
||||||
|
#include <string>
|
||||||
|
namespace cms {
|
||||||
|
struct CanSnapshot {
|
||||||
|
std::array<int,4> positions{{-1,-1,-1,-1}}; // LF, RF, LR, RR
|
||||||
|
double age_ms{-1};
|
||||||
|
bool valid{false};
|
||||||
|
std::string source{"hardware"},side{"left"};
|
||||||
|
};
|
||||||
|
class CanInput {
|
||||||
|
public:
|
||||||
|
explicit CanInput(bool enabled);
|
||||||
|
~CanInput();
|
||||||
|
CanInput(CanInput const&)=delete;
|
||||||
|
CanInput& operator=(CanInput const&)=delete;
|
||||||
|
CanSnapshot snapshot(std::string const& side,double timeoutMs) const;
|
||||||
|
private:
|
||||||
|
bool opened_{};
|
||||||
|
};
|
||||||
|
std::string activeDoor(CanSnapshot const& state);
|
||||||
|
}
|
||||||
@@ -61,6 +61,15 @@ 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) {
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "camera_pipeline.hpp"
|
||||||
|
namespace cms {
|
||||||
|
PipelineConfig loadConfig(std::filesystem::path const& path);
|
||||||
|
}
|
||||||
@@ -14,4 +14,5 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "door_risk.hpp"
|
||||||
|
#include <deque>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace cms {
|
||||||
|
class DoorZoneAssigner {
|
||||||
|
public:
|
||||||
|
explicit DoorZoneAssigner(std::filesystem::path const& configuration);
|
||||||
|
Json update(int frame, Json const& record, cv::Size size);
|
||||||
|
private:
|
||||||
|
std::string classify(double probability) const;
|
||||||
|
Json features_;
|
||||||
|
double frontMin_{},rearMax_{},minimumHeight_{};
|
||||||
|
int minimumPoseFeatures_{},historyFrames_{},staleFrames_{};
|
||||||
|
std::unordered_map<int,std::deque<double>> history_;
|
||||||
|
std::unordered_map<int,int> lastSeen_;
|
||||||
|
};
|
||||||
|
struct DoorDecisions { Json doors; Json selected; };
|
||||||
|
DoorDecisions buildDoorRisks(Json const& raw, Json const& assignments,
|
||||||
|
std::string const& activeDoor, bool canValid);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#pragma once
|
||||||
|
// Public downstream contract. Owns its data; no GPU, JSON or network handles.
|
||||||
|
#include "image.hpp"
|
||||||
|
#include "can_state.hpp"
|
||||||
|
#include <map>
|
||||||
|
#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;
|
||||||
|
std::string door_zone;
|
||||||
|
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 risk_type;
|
||||||
|
std::optional<std::string> door_id;
|
||||||
|
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 DoorAssignment {
|
||||||
|
int track_id{}, person_index{}, pose_feature_count{}, history_frames{};
|
||||||
|
std::string door_zone, raw_door_zone, evidence_source;
|
||||||
|
double front_probability{}, smoothed_front_probability{}, confidence{};
|
||||||
|
std::map<std::string,double> features, feature_frontness;
|
||||||
|
};
|
||||||
|
struct FrameResult {
|
||||||
|
int frame_index{};
|
||||||
|
std::vector<int> track_ids;
|
||||||
|
std::vector<std::array<double,4>> boxes;
|
||||||
|
RiskResult risk;
|
||||||
|
RiskResult ungated_risk;
|
||||||
|
std::map<std::string,RiskResult> door_risks;
|
||||||
|
std::vector<DoorAssignment> door_assignments;
|
||||||
|
std::string active_door;
|
||||||
|
CanSnapshot can;
|
||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#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{};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#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_;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "frame_result.hpp"
|
||||||
|
#include "door_risk.hpp"
|
||||||
|
namespace cms {
|
||||||
|
template<class T> inline std::optional<T> optionalField(Json const& j,char const* key) {
|
||||||
|
auto it=j.find(key);return it==j.end()||it->is_null()?std::nullopt:std::optional<T>(it->get<T>());
|
||||||
|
}
|
||||||
|
inline RiskResult parseRisk(Json const& j) {
|
||||||
|
RiskResult r;r.status=j.at("status");r.risk_score=optionalField<double>(j,"risk_score");r.risk_level=j.at("risk_level");r.decision=j.at("decision");
|
||||||
|
r.risk_type=j.value("risk_type",std::string());r.door_id=optionalField<std::string>(j,"door_id");
|
||||||
|
r.source=optionalField<std::string>(j,"source");r.track_id=optionalField<int>(j,"track_id");r.side=optionalField<std::string>(j,"side");
|
||||||
|
if(j.contains("door_mask")) {auto const& d=j.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=j.value("warning_distance_relative",.30);r.release_distance_relative=j.value("release_distance_relative",.45);
|
||||||
|
for(auto const& x:j.at("regions")) {
|
||||||
|
RegionRisk v;v.status=x.at("status");v.risk_score=optionalField<double>(x,"risk_score");v.risk_level=x.at("risk_level");v.decision=x.at("decision");v.source=x.at("source");
|
||||||
|
v.track_id=x.at("track_id");v.side=x.at("side");v.state=optionalField<std::string>(x,"state");v.door_zone=x.value("door_zone",std::string());
|
||||||
|
v.region_area_px=optionalField<int>(x,"region_area_px");v.door_overlap_px=optionalField<int>(x,"door_overlap_px");
|
||||||
|
v.region_overlap_ratio=optionalField<double>(x,"region_overlap_ratio");v.min_distance_px=optionalField<double>(x,"min_distance_px");
|
||||||
|
v.region_scale_px=optionalField<double>(x,"region_scale_px");v.distance_relative=optionalField<double>(x,"distance_relative");r.regions.push_back(std::move(v));
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
inline DoorAssignment parseAssignment(Json const& j) {
|
||||||
|
DoorAssignment a;a.track_id=j.at("track_id");a.person_index=j.at("person_index");a.door_zone=j.at("door_zone");a.raw_door_zone=j.at("raw_door_zone");
|
||||||
|
a.front_probability=j.at("front_probability");a.smoothed_front_probability=j.at("smoothed_front_probability");a.confidence=j.at("confidence");a.evidence_source=j.at("evidence_source");
|
||||||
|
a.pose_feature_count=j.at("pose_feature_count");a.history_frames=j.at("history_frames");a.features=j.at("features").get<std::map<std::string,double>>();a.feature_frontness=j.at("feature_frontness").get<std::map<std::string,double>>();return a;
|
||||||
|
}
|
||||||
|
inline FrameResult makeFrameResult(Json const& record,Json const& raw,CaptureMetadata capture) {
|
||||||
|
FrameResult r;r.frame_index=record.at("frame_index");r.track_ids=record.at("track_ids").get<std::vector<int>>();r.boxes=record.at("boxes").get<std::vector<std::array<double,4>>>();r.capture=capture;r.ungated_risk=parseRisk(raw);return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "frame_result.hpp"
|
||||||
|
#include "door_risk.hpp"
|
||||||
|
namespace cms {
|
||||||
|
Json serializeResult(FrameResult const& result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#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_;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ 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);
|
||||||
@@ -31,6 +33,7 @@ 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);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
@@ -1,11 +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;
|
|
||||||
};
|
|
||||||
} // namespace cms
|
|
||||||
@@ -1,31 +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{};
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace fangjia
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#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
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
// Baseline V4L2/CAN + 0920 TensorRT/NPP, with a narrow ctypes ABI.
|
|
||||||
#include "v4l2_capture.h"
|
|
||||||
#include "can_comm.h"
|
|
||||||
#include "detector.hpp"
|
|
||||||
#include "uyvy_converter.hpp"
|
|
||||||
#include <linux/videodev2.h>
|
|
||||||
#include <atomic>
|
|
||||||
#include <chrono>
|
|
||||||
#include <condition_variable>
|
|
||||||
#include <cstring>
|
|
||||||
#include <mutex>
|
|
||||||
#include <thread>
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
using Clock=std::chrono::steady_clock;
|
|
||||||
double nowMs(){return std::chrono::duration<double,std::milli>(Clock::now().time_since_epoch()).count();}
|
|
||||||
thread_local std::string error;
|
|
||||||
std::mutex canMutex;
|
|
||||||
int positions[4]={-1,-1,-1,-1};
|
|
||||||
double canTime=0;
|
|
||||||
void receiveCan(int64_t, uint32_t id, const uint8_t* data, uint32_t len){
|
|
||||||
if(id!=0x391||len<4)return;
|
|
||||||
std::lock_guard<std::mutex> lock(canMutex);
|
|
||||||
// Baseline comments specify bytes 1.0..4.7 (not overlapping bits 0,1,2,3).
|
|
||||||
for(int i=0;i<4;++i)positions[i]=data[i];
|
|
||||||
canTime=nowMs();
|
|
||||||
}
|
|
||||||
struct Engine {
|
|
||||||
fangjia::TrtLogger logger;
|
|
||||||
std::unique_ptr<cms::Detector> detector;
|
|
||||||
std::unique_ptr<fangjia::RtmwEngine> pose;
|
|
||||||
cudaStream_t stream{};
|
|
||||||
int width,height,maxPeople;
|
|
||||||
Engine(const char* dir,int w,int h,int n,float confidence):width(w),height(h),maxPeople(n){
|
|
||||||
detector=std::make_unique<cms::Detector>(std::filesystem::path(dir)/"yolo11s.engine",logger,confidence,n);
|
|
||||||
pose=std::make_unique<fangjia::RtmwEngine>(std::filesystem::path(dir)/"rtmw_l_384.engine",logger,w,h,n);
|
|
||||||
CUDA_CHECK(cudaStreamCreateWithFlags(&stream,cudaStreamNonBlocking));
|
|
||||||
try {pose->warmup(stream);detector->infer(cv::Mat(h,w,CV_8UC3,cv::Scalar()),stream);}catch(...){cudaStreamDestroy(stream);throw;}
|
|
||||||
}
|
|
||||||
~Engine(){if(stream)cudaStreamDestroy(stream);}
|
|
||||||
};
|
|
||||||
struct Camera {
|
|
||||||
V4l2Capture capture;
|
|
||||||
std::unique_ptr<cms::UyvyConverter> converter;
|
|
||||||
std::thread worker;
|
|
||||||
std::mutex mutex;
|
|
||||||
std::condition_variable ready;
|
|
||||||
std::atomic<bool> stop{false};
|
|
||||||
std::shared_ptr<const cms::BgrImage> latest;
|
|
||||||
uint64_t generation=0, consumed=0, sequence=0;
|
|
||||||
double timestamp=0,driverTimestamp=0;
|
|
||||||
int width,height;
|
|
||||||
bool canEnabled=false;
|
|
||||||
std::string failure;
|
|
||||||
Camera(const char* device,int w,int h,int fps,bool can):width(w),height(h){
|
|
||||||
if(!capture.open(device)||!capture.setFormat(w,h,V4L2_PIX_FMT_UYVY))throw std::runtime_error("Camera open/format failed");
|
|
||||||
if(capture.width()!=uint32_t(w)||capture.height()!=uint32_t(h))throw std::runtime_error("Camera negotiated unexpected dimensions");
|
|
||||||
capture.setFps(fps);
|
|
||||||
if(!capture.initMmap(4)||!capture.startStreaming())throw std::runtime_error("Camera streaming failed");
|
|
||||||
converter=std::make_unique<cms::UyvyConverter>(w,h);
|
|
||||||
if(can){CanComm::set_msg_callback(receiveCan);canEnabled=CanComm::can_start();}
|
|
||||||
try {worker=std::thread([this]{loop();});}catch(...){if(canEnabled)CanComm::can_stop();throw;}
|
|
||||||
}
|
|
||||||
~Camera(){stop=true;ready.notify_all();if(worker.joinable())worker.join();capture.close();if(canEnabled)CanComm::can_stop();}
|
|
||||||
void loop(){
|
|
||||||
try {
|
|
||||||
int misses=0;
|
|
||||||
while(!stop){
|
|
||||||
V4l2Capture::Frame frame;
|
|
||||||
if(!capture.getFrame(frame,1000)){if(++misses>=5)throw std::runtime_error("Camera timed out for 5 seconds");continue;}
|
|
||||||
double received=nowMs();
|
|
||||||
misses=0;
|
|
||||||
auto stride=frame.bytes_per_line?frame.bytes_per_line:width*2;
|
|
||||||
if(frame.size<size_t(stride)*height){capture.releaseFrame(frame);continue;}
|
|
||||||
std::shared_ptr<const cms::BgrImage> image;
|
|
||||||
try {image=converter->convert(frame.data,stride);}catch(...){capture.releaseFrame(frame);throw;}
|
|
||||||
auto seq=frame.sequence;double driverTs=frame.timestamp_us/1000.0;
|
|
||||||
capture.releaseFrame(frame);
|
|
||||||
{std::lock_guard<std::mutex> lock(mutex);latest=std::move(image);sequence=seq;timestamp=received;driverTimestamp=driverTs;++generation;}
|
|
||||||
ready.notify_one();
|
|
||||||
}
|
|
||||||
}catch(std::exception const&e){std::lock_guard<std::mutex> lock(mutex);failure=e.what();stop=true;ready.notify_all();}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
extern "C" {
|
|
||||||
const char* vap_error(){return error.c_str();}
|
|
||||||
void* vap_engine(const char* dir,int w,int h,int n,float confidence){try {return new Engine(dir,w,h,n,confidence);}catch(std::exception const&e){error=e.what();return nullptr;}}
|
|
||||||
void vap_engine_close(void* p){delete static_cast<Engine*>(p);}
|
|
||||||
int vap_detect(void* p,unsigned char* pixels,int w,int h,float* out){try {
|
|
||||||
auto& e=*static_cast<Engine*>(p);auto boxes=e.detector->infer(cv::Mat(h,w,CV_8UC3,pixels),e.stream);
|
|
||||||
for(size_t i=0;i<boxes.size();++i){auto& b=boxes[i];float v[]={b.x1,b.y1,b.x2,b.y2,b.score};std::memcpy(out+i*5,v,sizeof(v));}
|
|
||||||
return boxes.size();
|
|
||||||
}catch(std::exception const&e){error=e.what();return -1;}}
|
|
||||||
int vap_pose(void* p,unsigned char* pixels,int w,int h,const float* boxes,int count,float* out){try {
|
|
||||||
auto& e=*static_cast<Engine*>(p);
|
|
||||||
if(count<0||count>e.maxPeople||w>e.width||h>e.height)throw std::runtime_error("Pose input exceeds configured capacity");
|
|
||||||
std::vector<fangjia::Box> input;for(int i=0;i<count;++i)input.push_back({boxes[i*4],boxes[i*4+1],boxes[i*4+2],boxes[i*4+3],1,0});
|
|
||||||
auto poses=e.pose->infer(cv::Mat(h,w,CV_8UC3,pixels),input,e.stream);
|
|
||||||
for(size_t i=0;i<poses.size();++i)for(int k=0;k<133;++k){auto& pt=poses[i].keypoints[k];auto* v=out+(i*133+k)*3;v[0]=pt.x;v[1]=pt.y;v[2]=pt.score;}
|
|
||||||
return poses.size();
|
|
||||||
}catch(std::exception const&e){error=e.what();return -1;}}
|
|
||||||
void* vap_camera(const char* device,int w,int h,int fps,int can){try{return new Camera(device,w,h,fps,can);}catch(std::exception const&e){error=e.what();return nullptr;}}
|
|
||||||
void vap_camera_close(void* p){delete static_cast<Camera*>(p);}
|
|
||||||
int vap_read(void* p,unsigned char* out,double* meta){try {
|
|
||||||
auto& c=*static_cast<Camera*>(p);std::shared_ptr<const cms::BgrImage> image;
|
|
||||||
{std::unique_lock<std::mutex> lock(c.mutex);
|
|
||||||
if(!c.ready.wait_for(lock,std::chrono::seconds(6),[&]{return c.generation>c.consumed||c.stop;}))throw std::runtime_error("No camera frame");
|
|
||||||
if(!c.failure.empty())throw std::runtime_error(c.failure);
|
|
||||||
if(c.stop)return 0;
|
|
||||||
image=c.latest;meta[0]=c.sequence;meta[1]=c.timestamp;meta[2]=c.generation-c.consumed-1;meta[3]=c.driverTimestamp;c.consumed=c.generation;}
|
|
||||||
std::memcpy(out,image->pixels.data(),image->pixels.size());return 1;
|
|
||||||
}catch(std::exception const&e){error=e.what();return -1;}}
|
|
||||||
void vap_can(double* out){std::lock_guard<std::mutex> lock(canMutex);for(int i=0;i<4;++i)out[i]=positions[i];out[4]=canTime?nowMs()-canTime:-1;}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
#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
|
|
||||||
+11
-22
@@ -1,33 +1,22 @@
|
|||||||
# Orin 直接运行包
|
# Orin 纯 C++ 运行包
|
||||||
|
|
||||||
适用 Jetson AGX Orin / JetPack 5.1.2 (R35.4.1)、CUDA 11.4、TensorRT 8.5.x、OpenCV 4.5.4。
|
适用 AGX Orin / JetPack 5.1.2 (R35.4.1)、CUDA 11.4、TensorRT 8.5.x、OpenCV 4.5.4。不依赖 Python。
|
||||||
系统需要 Python 3.8+、numpy、cv2。无需重新编译,可单独复制整个目录。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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 # exec build/camera_risk,默认无可视化
|
||||||
./run.sh --visualize # 可选浏览器预览,http://设备IP:8765
|
./run.sh --visualize # 可选 Orin 本地图形窗口
|
||||||
|
# 直接执行 ELF:
|
||||||
|
./build/camera_risk --config config/camera.json
|
||||||
```
|
```
|
||||||
|
|
||||||
只运行一个摄像头采集进程,Ctrl+C / SIGTERM 停止。
|
只运行一个摄像头进程。Ctrl+C / SIGTERM 停止;关闭可视化窗口不停止推理。无图形桌面时继续无窗口运行。
|
||||||
|
|
||||||
- data[0..3]:左前、右前、左后、右后;`--can-side left/right` 选择摄像头侧。
|
CAN 0x391 data[0..3] 为左前、右前、左后、右后。默认左侧,`--can-side right` 切换右侧。缺失或超时报文输出 CAN_UNKNOWN,风险分数为空。
|
||||||
- 未收到有效 CAN 或超时:CAN_UNKNOWN,score=null;不发送执行器控制报文。
|
|
||||||
- `--save-results` 保存 JSONL;`--save-snapshots` 保存快照;`--write-video` 保存叠加视频。默认均关闭。
|
|
||||||
- `--output PATH` 指定输出目录;`--duration 30` 限时运行。完整参数见 `./run.sh --help`。
|
|
||||||
- 标定参数仍需匹配实际机位,默认 calibration_verified=false。
|
|
||||||
|
|
||||||
同进程下游示例(先停止其他采集进程):
|
`--no-can --active-door both` 模拟门状态;`--duration 30` 限时运行;`--save-results FILE.jsonl` 显式保存结果,默认不写文件。完整参数见 `./run.sh --help`。
|
||||||
|
|
||||||
```python
|
最新结果保存在进程内的 `shared_ptr<const cms::FrameResult>`;C++ 下游调用 `pipeline.latest()` 读取。接口头文件位于仓库 include/,不是 Python 变量或跨进程共享内存。
|
||||||
from run_camera import CameraPipeline, parse_args
|
|
||||||
with CameraPipeline(parse_args([])) as pipeline:
|
|
||||||
result = pipeline.wait_for_result(timeout=10)
|
|
||||||
result = pipeline.latest_result # 等价于 pipeline.latest()
|
|
||||||
if result is not None:
|
|
||||||
print(result['risk'], result['door_risks'], result['skeletons'])
|
|
||||||
```
|
|
||||||
|
|
||||||
变量是线程安全的最新只读快照,读取不会消费结果,不是跨进程共享变量。
|
模型、驱动和编译产物绑定目标 GPU、TensorRT 与内核环境。标定文件需匹配实际机位,默认 calibration_verified=false。系统 CUDA/TensorRT/OpenCV 库由 JetPack 提供。
|
||||||
系统 CUDA/TensorRT/OpenCV 运行库由 JetPack 提供。引擎和驱动不能直接用于其他 GPU 或不匹配的内核。
|
|
||||||
|
|||||||
+15
-25
@@ -1,25 +1,15 @@
|
|||||||
b6295cf4b0705c5c3d83c9ba35e41b2a0b865ea67742c09cfd1690f263f556d0 README.md
|
1cff77e5547d14cd188b3bb38ba9332e9ae5904646f9248c2a3904a199318738 ./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
|
||||||
5ba7e4f0371a411cc1a6dfd1275e3b824032a14d07a9ccd7ca3d5a6630b322ef build/libvision_native.so
|
d37bb5880f921ef8d5f937e5bf9dba38539929d95430f4fb8f575a2ede691a78 ./build/camera_risk
|
||||||
8dae8c557deadbe6bd7241c0dcd7025cb007077ee39a79b115e953a2b22cd9c1 lib/libusb-1.0.so
|
718c43eba7c9a86178b272404b14e201445a3998dae81cd32b840f83018ba2b5 ./config/camera.json
|
||||||
ab8b7f7249d5fb73d1d33d14fa7532a522088ffb6c25f37ba334daf4c6b4d313 lib/libusbcanfd.so
|
8dae8c557deadbe6bd7241c0dcd7025cb007077ee39a79b115e953a2b22cd9c1 ./lib/libusb-1.0.so
|
||||||
ebcd09e8c2e35008adbb0c1d222e580b17e0df1c8093df3481ba3374cd9a8174 models/engines/rtmw_l_384.engine
|
ab8b7f7249d5fb73d1d33d14fa7532a522088ffb6c25f37ba334daf4c6b4d313 ./lib/libusbcanfd.so
|
||||||
f6a65b926ac1144484f4aa93cd26d7997d32d62310ab93fe867f2bd4412222e9 models/engines/yolo11s.engine
|
ebcd09e8c2e35008adbb0c1d222e580b17e0df1c8093df3481ba3374cd9a8174 ./models/engines/rtmw_l_384.engine
|
||||||
6d6d14a972bc4e5e9f8256514169e73ba0392a301cacd1221eaa26146aad00d2 pipeline/door_mask_risk.py
|
f6a65b926ac1144484f4aa93cd26d7997d32d62310ab93fe867f2bd4412222e9 ./models/engines/yolo11s.engine
|
||||||
46d70e1e90a1f36d544b7492ef1fbf08821377a1c460a6683f42d4d7006055c7 pipeline/door_zone_risk.py
|
4e3bd58314a9d70b6b243f14c0e26ffd1b1c6dcf8bcbf4c44629f1767f72a5ec ./run.sh
|
||||||
bcf2cc99dd27c8749801a5b743656767d9d64ef03d568d227cb654c573d70b00 pipeline/latest_result.py
|
11b5fbc3c17c0610ec0514a0a26cf5d92588d15c0e50e7086d9db5a5c0306e99 ./scripts/initialize_camera.sh
|
||||||
294cdc11aad0e5475275fd8dece2057e229b9bdedba69c26afa62176fd51a4e9 pipeline/native_backend.py
|
bce5a22d49633454ed2c6bc92b608edf3b718af2424c283ed8f56ba93987200a ./scripts/initialize_camera_impl.sh
|
||||||
8b8c5b8c9c76c9bd2e1efa6cba60be337a18c9366e8f748038465e9e2fba2672 pipeline/pose_engine.py
|
|
||||||
93d3323ea431ed1d3c8397cfed7d800d65ec6d7700690487335861945040a486 pipeline/pose_fusion.py
|
|
||||||
2ebf58114835ba7c4779d4b66aeff65b8a94107128989c0336de556a4cf95fda pipeline/pose_geometry.py
|
|
||||||
f32fa5bebf893a3dc6b9ccfaa09a8e3a153ad5b36dde5373fdc561f10f9e526b pipeline/pose_occlusion.py
|
|
||||||
1a3f1f55d7b8a3f0ac96e69bf77f02f82017e8970adef96181d2bfade26e5bd5 pipeline/pose_tracking.py
|
|
||||||
dcf3cce5562ea52400ffa9842fdddaadac1efd672dabb0ed12ce34f20f779f37 pipeline/visualization.py
|
|
||||||
8a10e035334ed97b7480f558aff7b0464c255ac6138dd99f55a08ade68853e0e run.sh
|
|
||||||
23e9af5e82999889fe77c762a77c77036d1b1b699525ff2bd5ca8a418183ba50 run_camera.py
|
|
||||||
11b5fbc3c17c0610ec0514a0a26cf5d92588d15c0e50e7086d9db5a5c0306e99 scripts/initialize_camera.sh
|
|
||||||
bce5a22d49633454ed2c6bc92b608edf3b718af2424c283ed8f56ba93987200a scripts/initialize_camera_impl.sh
|
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"camera": {"device":"/dev/video5","width":1920,"height":1536,"fps":30,"buffers":4,"timeout_ms":2000},
|
||||||
|
"inference": {"engine_directory":"../models/engines","detector_confidence":0.30,"max_people":3,"queue_capacity":2},
|
||||||
|
"door": {"mask":"../annotations/constant_door_mask.json","zone_config":"../annotations/door_zone_multifeature.json","warning_relative":0.30,"release_relative":0.45,"calibration_verified":false},
|
||||||
|
"can": {"enabled":true,"side":"left","timeout_ms":1000}
|
||||||
|
}
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
"""Per-frame door-mask loading and YOLO/RTMW-only 2-D risk scoring."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from collections import Counter
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
|
||||||
RASTER_SUFFIXES = {".png", ".bmp", ".tif", ".tiff"}
|
|
||||||
DEFAULT_DOOR_LABELS = ("door", "车门")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LoadedDoorMask:
|
|
||||||
status: str
|
|
||||||
annotation_index: int
|
|
||||||
path: Path | None
|
|
||||||
mask: np.ndarray | None
|
|
||||||
source_format: str | None
|
|
||||||
|
|
||||||
def metadata(self) -> dict:
|
|
||||||
return {
|
|
||||||
"status": self.status,
|
|
||||||
"annotation_index": self.annotation_index,
|
|
||||||
"path": str(self.path.resolve()) if self.path is not None else None,
|
|
||||||
"format": self.source_format,
|
|
||||||
"area_px": int(np.count_nonzero(self.mask)) if self.mask is not None else 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _frame_index_from_path(path: Path) -> int | None:
|
|
||||||
matches = re.findall(r"\d+", path.stem)
|
|
||||||
return int(matches[-1]) if matches else None
|
|
||||||
|
|
||||||
|
|
||||||
class DoorMaskSequence:
|
|
||||||
"""Index a directory of X-AnyLabeling JSON polygons or raster masks."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
directory: Path | None = None,
|
|
||||||
mask_format: str = "auto",
|
|
||||||
labels: tuple[str, ...] = DEFAULT_DOOR_LABELS,
|
|
||||||
frame_offset: int = 0,
|
|
||||||
require_every_frame: bool = False,
|
|
||||||
constant_mask: Path | None = None,
|
|
||||||
):
|
|
||||||
if (directory is None) == (constant_mask is None):
|
|
||||||
raise ValueError("Provide exactly one of directory or constant_mask")
|
|
||||||
self.constant_path = (
|
|
||||||
Path(constant_mask).expanduser().resolve()
|
|
||||||
if constant_mask is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
self.directory = (
|
|
||||||
self.constant_path.parent if self.constant_path is not None else Path(directory)
|
|
||||||
)
|
|
||||||
if self.constant_path is not None and not self.constant_path.is_file():
|
|
||||||
raise ValueError(f"Constant door mask does not exist: {self.constant_path}")
|
|
||||||
if self.constant_path is None and not self.directory.is_dir():
|
|
||||||
raise ValueError(f"Door mask directory does not exist: {self.directory}")
|
|
||||||
self.labels = {label.casefold() for label in labels}
|
|
||||||
self.frame_offset = int(frame_offset)
|
|
||||||
self.require_every_frame = require_every_frame
|
|
||||||
|
|
||||||
if self.constant_path is not None:
|
|
||||||
if mask_format == "auto":
|
|
||||||
selected_format = (
|
|
||||||
"xanylabeling"
|
|
||||||
if self.constant_path.suffix.lower() == ".json"
|
|
||||||
else "raster"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
selected_format = mask_format
|
|
||||||
self.source_format = selected_format
|
|
||||||
self.paths = {0: self.constant_path}
|
|
||||||
return
|
|
||||||
|
|
||||||
json_paths = sorted(self.directory.rglob("*.json"))
|
|
||||||
raster_paths = sorted(
|
|
||||||
path
|
|
||||||
for path in self.directory.rglob("*")
|
|
||||||
if path.is_file() and path.suffix.lower() in RASTER_SUFFIXES
|
|
||||||
)
|
|
||||||
if mask_format == "auto":
|
|
||||||
selected_format = "xanylabeling" if json_paths else "raster"
|
|
||||||
else:
|
|
||||||
selected_format = mask_format
|
|
||||||
candidates = json_paths if selected_format == "xanylabeling" else raster_paths
|
|
||||||
if not candidates:
|
|
||||||
expected = "JSON" if selected_format == "xanylabeling" else "PNG/BMP/TIFF"
|
|
||||||
raise ValueError(f"No {expected} door masks found in {self.directory}")
|
|
||||||
|
|
||||||
self.source_format = selected_format
|
|
||||||
self.paths: dict[int, Path] = {}
|
|
||||||
for path in candidates:
|
|
||||||
index = _frame_index_from_path(path)
|
|
||||||
if index is None and selected_format == "xanylabeling":
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
||||||
explicit = data.get("frame_index", data.get("frameIndex"))
|
|
||||||
index = int(explicit) if explicit is not None else None
|
|
||||||
if index is None:
|
|
||||||
continue
|
|
||||||
if index in self.paths:
|
|
||||||
raise ValueError(
|
|
||||||
f"Multiple door masks map to annotation frame {index}: "
|
|
||||||
f"{self.paths[index]} and {path}"
|
|
||||||
)
|
|
||||||
self.paths[index] = path
|
|
||||||
if not self.paths:
|
|
||||||
raise ValueError(
|
|
||||||
"Door mask filenames need a frame number, for example 000000.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
def load(self, video_frame_index: int, frame_shape: tuple[int, int]) -> LoadedDoorMask:
|
|
||||||
if self.constant_path is not None:
|
|
||||||
annotation_index = video_frame_index
|
|
||||||
path = self.constant_path
|
|
||||||
else:
|
|
||||||
annotation_index = video_frame_index + self.frame_offset
|
|
||||||
path = self.paths.get(annotation_index)
|
|
||||||
if path is None:
|
|
||||||
if self.require_every_frame:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"No door mask for video frame {video_frame_index} "
|
|
||||||
f"(expected annotation index {annotation_index})"
|
|
||||||
)
|
|
||||||
return LoadedDoorMask("missing", annotation_index, None, None, self.source_format)
|
|
||||||
|
|
||||||
if self.source_format == "xanylabeling":
|
|
||||||
mask = self._load_xanylabeling(path, frame_shape)
|
|
||||||
else:
|
|
||||||
mask = self._load_raster(path, frame_shape)
|
|
||||||
status = "loaded" if np.any(mask) else "empty"
|
|
||||||
return LoadedDoorMask(status, annotation_index, path, mask, self.source_format)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _load_raster(path: Path, frame_shape: tuple[int, int]) -> np.ndarray:
|
|
||||||
image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
|
||||||
if image is None:
|
|
||||||
raise RuntimeError(f"Cannot read door mask: {path}")
|
|
||||||
if image.ndim == 3:
|
|
||||||
image = np.max(image[:, :, :3], axis=2)
|
|
||||||
height, width = frame_shape
|
|
||||||
if image.shape != (height, width):
|
|
||||||
image = cv2.resize(image, (width, height), interpolation=cv2.INTER_NEAREST)
|
|
||||||
return image > 0
|
|
||||||
|
|
||||||
def _load_xanylabeling(
|
|
||||||
self, path: Path, frame_shape: tuple[int, int]
|
|
||||||
) -> np.ndarray:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
||||||
height, width = frame_shape
|
|
||||||
source_width = float(data.get("imageWidth") or width)
|
|
||||||
source_height = float(data.get("imageHeight") or height)
|
|
||||||
scale = np.asarray(
|
|
||||||
[width / max(source_width, 1.0), height / max(source_height, 1.0)],
|
|
||||||
dtype=np.float32,
|
|
||||||
)
|
|
||||||
mask = np.zeros((height, width), dtype=np.uint8)
|
|
||||||
for shape in data.get("shapes", []):
|
|
||||||
if str(shape.get("label", "")).casefold() not in self.labels:
|
|
||||||
continue
|
|
||||||
points = np.asarray(shape.get("points", []), dtype=np.float32).reshape(-1, 2)
|
|
||||||
if len(points) < 2:
|
|
||||||
continue
|
|
||||||
points = np.rint(points * scale).astype(np.int32)
|
|
||||||
shape_type = str(shape.get("shape_type", "polygon")).lower()
|
|
||||||
if shape_type == "rectangle" and len(points) >= 2:
|
|
||||||
x1, y1 = points[0]
|
|
||||||
x2, y2 = points[1]
|
|
||||||
cv2.rectangle(mask, (x1, y1), (x2, y2), 1, -1)
|
|
||||||
elif shape_type == "circle" and len(points) >= 2:
|
|
||||||
radius = int(round(float(np.linalg.norm(points[1] - points[0]))))
|
|
||||||
cv2.circle(mask, tuple(points[0]), radius, 1, -1)
|
|
||||||
elif len(points) >= 3:
|
|
||||||
cv2.fillPoly(mask, [points], 1)
|
|
||||||
return mask > 0
|
|
||||||
|
|
||||||
|
|
||||||
def _fit_mask(mask: np.ndarray, frame_shape: tuple[int, int]) -> np.ndarray:
|
|
||||||
height, width = frame_shape
|
|
||||||
result = np.asarray(mask) > 0
|
|
||||||
if result.shape != (height, width):
|
|
||||||
result = cv2.resize(
|
|
||||||
result.astype(np.uint8), (width, height), interpolation=cv2.INTER_NEAREST
|
|
||||||
) > 0
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _box_mask(box: list[float] | None, frame_shape: tuple[int, int]) -> np.ndarray | None:
|
|
||||||
if box is None or len(box) < 4:
|
|
||||||
return None
|
|
||||||
height, width = frame_shape
|
|
||||||
x1, y1, x2, y2 = [int(round(float(value))) for value in box[:4]]
|
|
||||||
x1, x2 = sorted((max(0, min(width - 1, x1)), max(0, min(width - 1, x2))))
|
|
||||||
y1, y2 = sorted((max(0, min(height - 1, y1)), max(0, min(height - 1, y2))))
|
|
||||||
if x2 <= x1 or y2 <= y1:
|
|
||||||
return None
|
|
||||||
mask = np.zeros((height, width), dtype=bool)
|
|
||||||
mask[y1 : y2 + 1, x1 : x2 + 1] = True
|
|
||||||
return mask
|
|
||||||
|
|
||||||
|
|
||||||
def _hand_mask(points: list, frame_shape: tuple[int, int]) -> np.ndarray | None:
|
|
||||||
hand = np.asarray(points, dtype=np.float32).reshape(-1, 2)
|
|
||||||
if len(hand) < 3 or not np.isfinite(hand).all():
|
|
||||||
return None
|
|
||||||
hull = cv2.convexHull(np.rint(hand).astype(np.int32))
|
|
||||||
mask = np.zeros(frame_shape, dtype=np.uint8)
|
|
||||||
cv2.fillConvexPoly(mask, hull, 1)
|
|
||||||
return mask > 0 if np.any(mask) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _decision(distance_relative: float, overlap_px: int, warning: float, release: float) -> tuple[str, str]:
|
|
||||||
if overlap_px > 0:
|
|
||||||
return "danger", "DANGER_INTERSECTION"
|
|
||||||
if distance_relative <= warning:
|
|
||||||
return "warning", "WARNING_NEAR"
|
|
||||||
if distance_relative <= release:
|
|
||||||
return "watch", "WATCH"
|
|
||||||
return "clear", "CLEAR"
|
|
||||||
|
|
||||||
|
|
||||||
class DoorRiskEvaluator:
|
|
||||||
"""Score RTMW hand regions, with one YOLO body-box fallback per person."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
sequence: DoorMaskSequence,
|
|
||||||
warning_relative: float = 0.30,
|
|
||||||
release_relative: float = 0.45,
|
|
||||||
):
|
|
||||||
if warning_relative < 0 or release_relative <= warning_relative:
|
|
||||||
raise ValueError("Risk thresholds require 0 <= warning < release")
|
|
||||||
self.sequence = sequence
|
|
||||||
self.warning_relative = float(warning_relative)
|
|
||||||
self.release_relative = float(release_relative)
|
|
||||||
self.mask_status_counts: Counter = Counter()
|
|
||||||
self.level_counts: Counter = Counter()
|
|
||||||
self.source_counts: Counter = Counter()
|
|
||||||
self.maximum_score = 0.0
|
|
||||||
self.timing_seconds: Counter = Counter()
|
|
||||||
self.timing_counts: Counter = Counter()
|
|
||||||
self._constant_cache_shape: tuple[int, int] | None = None
|
|
||||||
self._constant_loaded: LoadedDoorMask | None = None
|
|
||||||
self._constant_distance_map: np.ndarray | None = None
|
|
||||||
self.distance_map_cache_builds = 0
|
|
||||||
|
|
||||||
def _record_timing(self, name: str, started: float) -> None:
|
|
||||||
self.timing_seconds[name] += time.perf_counter() - started
|
|
||||||
self.timing_counts[name] += 1
|
|
||||||
|
|
||||||
def _measure_region(
|
|
||||||
self,
|
|
||||||
region: np.ndarray,
|
|
||||||
door_mask: np.ndarray,
|
|
||||||
distance_map: np.ndarray,
|
|
||||||
source: str,
|
|
||||||
track_id: int,
|
|
||||||
side: str,
|
|
||||||
state: str | None,
|
|
||||||
) -> dict:
|
|
||||||
area_px = int(np.count_nonzero(region))
|
|
||||||
overlap_px = int(np.count_nonzero(region & door_mask))
|
|
||||||
distances = distance_map[region]
|
|
||||||
distance_px = float(np.min(distances)) if len(distances) else math.inf
|
|
||||||
scale_px = max(math.sqrt(area_px), 1.0)
|
|
||||||
distance_relative = distance_px / scale_px
|
|
||||||
score = float(np.clip(1.0 - distance_relative / self.release_relative, 0.0, 1.0))
|
|
||||||
if overlap_px:
|
|
||||||
score = 1.0
|
|
||||||
level, decision = _decision(
|
|
||||||
distance_relative,
|
|
||||||
overlap_px,
|
|
||||||
self.warning_relative,
|
|
||||||
self.release_relative,
|
|
||||||
)
|
|
||||||
result = {
|
|
||||||
"status": "calculated",
|
|
||||||
"risk_score": round(score, 5),
|
|
||||||
"risk_level": level,
|
|
||||||
"decision": decision,
|
|
||||||
"source": source,
|
|
||||||
"track_id": int(track_id),
|
|
||||||
"side": side,
|
|
||||||
"state": state,
|
|
||||||
"region_area_px": area_px,
|
|
||||||
"door_overlap_px": overlap_px,
|
|
||||||
"region_overlap_ratio": round(overlap_px / max(area_px, 1), 6),
|
|
||||||
"min_distance_px": round(distance_px, 3),
|
|
||||||
"region_scale_px": round(scale_px, 3),
|
|
||||||
"distance_relative": round(distance_relative, 6),
|
|
||||||
}
|
|
||||||
self.level_counts[level] += 1
|
|
||||||
self.source_counts[source] += 1
|
|
||||||
self.maximum_score = max(self.maximum_score, score)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _unavailable(source: str, track_id: int, side: str, status: str) -> dict:
|
|
||||||
return {
|
|
||||||
"status": status,
|
|
||||||
"risk_score": None,
|
|
||||||
"risk_level": "unavailable",
|
|
||||||
"decision": "EVIDENCE_UNAVAILABLE",
|
|
||||||
"source": source,
|
|
||||||
"track_id": int(track_id),
|
|
||||||
"side": side,
|
|
||||||
}
|
|
||||||
|
|
||||||
def evaluate(
|
|
||||||
self,
|
|
||||||
frame_index: int,
|
|
||||||
record: dict,
|
|
||||||
frame_shape: tuple[int, int],
|
|
||||||
) -> tuple[dict, np.ndarray | None]:
|
|
||||||
total_started = time.perf_counter()
|
|
||||||
stage_started = time.perf_counter()
|
|
||||||
use_constant_cache = self.sequence.constant_path is not None
|
|
||||||
if use_constant_cache and self._constant_cache_shape == frame_shape:
|
|
||||||
cached = self._constant_loaded
|
|
||||||
assert cached is not None
|
|
||||||
loaded = LoadedDoorMask(
|
|
||||||
cached.status,
|
|
||||||
frame_index,
|
|
||||||
cached.path,
|
|
||||||
cached.mask,
|
|
||||||
cached.source_format,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
loaded = self.sequence.load(frame_index, frame_shape)
|
|
||||||
if use_constant_cache:
|
|
||||||
self._constant_cache_shape = frame_shape
|
|
||||||
self._constant_loaded = loaded
|
|
||||||
self._constant_distance_map = None
|
|
||||||
self._record_timing("door_mask_load", stage_started)
|
|
||||||
self.mask_status_counts[loaded.status] += 1
|
|
||||||
mask_ready = loaded.status == "loaded" and loaded.mask is not None
|
|
||||||
door_mask = loaded.mask if mask_ready else None
|
|
||||||
distance_map = None
|
|
||||||
if door_mask is not None and use_constant_cache and self._constant_distance_map is not None:
|
|
||||||
distance_map = self._constant_distance_map
|
|
||||||
elif door_mask is not None:
|
|
||||||
stage_started = time.perf_counter()
|
|
||||||
distance_map = cv2.distanceTransform(
|
|
||||||
(~door_mask).astype(np.uint8), cv2.DIST_L2, cv2.DIST_MASK_PRECISE
|
|
||||||
)
|
|
||||||
self._record_timing("distance_transform", stage_started)
|
|
||||||
if use_constant_cache:
|
|
||||||
self._constant_distance_map = distance_map
|
|
||||||
self.distance_map_cache_builds += 1
|
|
||||||
|
|
||||||
stage_started = time.perf_counter()
|
|
||||||
regions: list[tuple[dict, np.ndarray, str, int, str, str | None]] = []
|
|
||||||
track_ids = [int(value) for value in record.get("track_ids", [])]
|
|
||||||
person_positions_with_hands: set[int] = set()
|
|
||||||
for hand in record.get("global_hands", []):
|
|
||||||
position = int(hand.get("person_id", 0)) - 1
|
|
||||||
track_id = track_ids[position] if 0 <= position < len(track_ids) else position + 1
|
|
||||||
side = str(hand.get("side", "unknown"))
|
|
||||||
region = _hand_mask(hand.get("keypoints", []), frame_shape)
|
|
||||||
if region is None:
|
|
||||||
continue
|
|
||||||
person_positions_with_hands.add(position)
|
|
||||||
regions.append(
|
|
||||||
(
|
|
||||||
hand,
|
|
||||||
region,
|
|
||||||
"RTMW_HAND_KEYPOINTS",
|
|
||||||
track_id,
|
|
||||||
side,
|
|
||||||
str(hand.get("state", "unknown")),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
boxes = record.get("boxes", [])
|
|
||||||
body_fallbacks = []
|
|
||||||
for position, box in enumerate(boxes):
|
|
||||||
if position in person_positions_with_hands:
|
|
||||||
continue
|
|
||||||
region = _box_mask(box, frame_shape)
|
|
||||||
if region is None:
|
|
||||||
continue
|
|
||||||
track_id = track_ids[position] if position < len(track_ids) else position + 1
|
|
||||||
fallback = {
|
|
||||||
"track_id": track_id,
|
|
||||||
"side": "body",
|
|
||||||
"state": "rtmw_hand_unavailable",
|
|
||||||
"body_zone_box": box,
|
|
||||||
}
|
|
||||||
body_fallbacks.append(fallback)
|
|
||||||
regions.append(
|
|
||||||
(
|
|
||||||
fallback,
|
|
||||||
region,
|
|
||||||
"YOLO_BODY_BOX_FALLBACK",
|
|
||||||
track_id,
|
|
||||||
"body",
|
|
||||||
"rtmw_hand_unavailable",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
record["body_fallbacks"] = body_fallbacks
|
|
||||||
self._record_timing("region_build", stage_started)
|
|
||||||
|
|
||||||
stage_started = time.perf_counter()
|
|
||||||
measurements = []
|
|
||||||
unavailable_status = f"door_mask_{loaded.status}"
|
|
||||||
for owner, region, source, track_id, side, state in regions:
|
|
||||||
if door_mask is None or distance_map is None:
|
|
||||||
measurement = self._unavailable(
|
|
||||||
source, track_id, side, unavailable_status
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
measurement = self._measure_region(
|
|
||||||
region, door_mask, distance_map, source, track_id, side, state
|
|
||||||
)
|
|
||||||
owner["risk"] = measurement
|
|
||||||
measurements.append(measurement)
|
|
||||||
self._record_timing("region_measure", stage_started)
|
|
||||||
|
|
||||||
calculated = [item for item in measurements if item["risk_score"] is not None]
|
|
||||||
nearest = max(calculated, key=lambda item: item["risk_score"]) if calculated else None
|
|
||||||
if loaded.status != "loaded":
|
|
||||||
status = unavailable_status
|
|
||||||
elif not calculated:
|
|
||||||
status = "person_region_missing"
|
|
||||||
else:
|
|
||||||
status = "calculated"
|
|
||||||
frame_risk = {
|
|
||||||
"status": status,
|
|
||||||
"risk_score": nearest["risk_score"] if nearest else None,
|
|
||||||
"risk_level": nearest["risk_level"] if nearest else "unavailable",
|
|
||||||
"decision": nearest["decision"] if nearest else "EVIDENCE_UNAVAILABLE",
|
|
||||||
"source": nearest["source"] if nearest else None,
|
|
||||||
"track_id": nearest["track_id"] if nearest else None,
|
|
||||||
"side": nearest["side"] if nearest else None,
|
|
||||||
"door_mask": loaded.metadata(),
|
|
||||||
"warning_distance_relative": self.warning_relative,
|
|
||||||
"release_distance_relative": self.release_relative,
|
|
||||||
"regions": measurements,
|
|
||||||
}
|
|
||||||
self._record_timing("risk_total", total_started)
|
|
||||||
return frame_risk, loaded.mask
|
|
||||||
|
|
||||||
def summary(self) -> dict:
|
|
||||||
return {
|
|
||||||
"mask_directory": str(self.sequence.directory.resolve()),
|
|
||||||
"constant_mask": (
|
|
||||||
str(self.sequence.constant_path)
|
|
||||||
if self.sequence.constant_path is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"constant_for_all_frames": self.sequence.constant_path is not None,
|
|
||||||
"mask_format": self.sequence.source_format,
|
|
||||||
"door_labels": sorted(self.sequence.labels),
|
|
||||||
"frame_offset": self.sequence.frame_offset,
|
|
||||||
"warning_distance_relative": self.warning_relative,
|
|
||||||
"release_distance_relative": self.release_relative,
|
|
||||||
"mask_status_counts": dict(self.mask_status_counts),
|
|
||||||
"risk_level_counts": dict(self.level_counts),
|
|
||||||
"source_counts": dict(self.source_counts),
|
|
||||||
"maximum_risk_score": round(self.maximum_score, 5),
|
|
||||||
"constant_distance_map_cached": self.sequence.constant_path is not None,
|
|
||||||
"distance_map_cache_builds": self.distance_map_cache_builds,
|
|
||||||
"timing_ms": {
|
|
||||||
name: {
|
|
||||||
"count": int(self.timing_counts[name]),
|
|
||||||
"total": round(float(seconds) * 1000.0, 3),
|
|
||||||
"mean": round(
|
|
||||||
float(seconds) * 1000.0
|
|
||||||
/ max(int(self.timing_counts[name]), 1),
|
|
||||||
3,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for name, seconds in self.timing_seconds.items()
|
|
||||||
},
|
|
||||||
"score_semantics": "2-D image overlap/proximity research score; not millimeters or safety certification",
|
|
||||||
"region_policy": "RTMW alarm-eligible hand hulls; YOLO body box only when a person has no usable RTMW hand region",
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
"""Multi-feature person-to-door assignment and active-CAN risk gating."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
from collections import Counter, defaultdict, deque
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from statistics import median
|
|
||||||
|
|
||||||
|
|
||||||
DOORS = ("front", "rear")
|
|
||||||
POSE_FEATURES = {
|
|
||||||
"shoulder_center_x",
|
|
||||||
"shoulder_center_y",
|
|
||||||
"shoulder_width",
|
|
||||||
"torso_height",
|
|
||||||
"hip_center_y",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FeatureCalibration:
|
|
||||||
rear: float
|
|
||||||
front: float
|
|
||||||
weight: float
|
|
||||||
|
|
||||||
def frontness(self, value: float) -> float:
|
|
||||||
span = self.front - self.rear
|
|
||||||
if span == 0:
|
|
||||||
return 0.5
|
|
||||||
return min(1.0, max(0.0, (value - self.rear) / span))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DoorZoneConfig:
|
|
||||||
features: dict[str, FeatureCalibration]
|
|
||||||
front_probability_min: float
|
|
||||||
rear_probability_max: float
|
|
||||||
minimum_person_height: float
|
|
||||||
minimum_pose_features: int
|
|
||||||
history_frames: int
|
|
||||||
stale_track_frames: int
|
|
||||||
source_path: Path | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load(cls, path: Path) -> "DoorZoneConfig":
|
|
||||||
source = Path(path).expanduser().resolve()
|
|
||||||
data = json.loads(source.read_text(encoding="utf-8-sig"))
|
|
||||||
features = {
|
|
||||||
name: FeatureCalibration(
|
|
||||||
rear=float(values["rear"]),
|
|
||||||
front=float(values["front"]),
|
|
||||||
weight=float(values["weight"]),
|
|
||||||
)
|
|
||||||
for name, values in data["features"].items()
|
|
||||||
}
|
|
||||||
return cls(
|
|
||||||
features=features,
|
|
||||||
front_probability_min=float(data.get("front_probability_min", 0.72)),
|
|
||||||
rear_probability_max=float(data.get("rear_probability_max", 0.28)),
|
|
||||||
minimum_person_height=float(data.get("minimum_person_height", 0.40)),
|
|
||||||
minimum_pose_features=int(data.get("minimum_pose_features", 4)),
|
|
||||||
history_frames=int(data.get("history_frames", 5)),
|
|
||||||
stale_track_frames=int(data.get("stale_track_frames", 120)),
|
|
||||||
source_path=source,
|
|
||||||
)
|
|
||||||
|
|
||||||
def classify(self, probability: float) -> str:
|
|
||||||
if probability >= self.front_probability_min:
|
|
||||||
return "front"
|
|
||||||
if probability <= self.rear_probability_max:
|
|
||||||
return "rear"
|
|
||||||
return "transition"
|
|
||||||
|
|
||||||
|
|
||||||
def _point(keypoints: list, index: int) -> tuple[float, float] | None:
|
|
||||||
if index >= len(keypoints) or len(keypoints[index]) < 2:
|
|
||||||
return None
|
|
||||||
x, y = float(keypoints[index][0]), float(keypoints[index][1])
|
|
||||||
return (x, y) if math.isfinite(x) and math.isfinite(y) else None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_person_features(
|
|
||||||
record: dict,
|
|
||||||
position: int,
|
|
||||||
frame_shape: tuple[int, int],
|
|
||||||
) -> tuple[dict[str, float], int]:
|
|
||||||
"""Extract fixed-camera pose features, with box geometry as low-weight fallback."""
|
|
||||||
|
|
||||||
height, width = frame_shape
|
|
||||||
box = record.get("boxes", [])[position]
|
|
||||||
x1, y1, x2, y2 = [float(value) for value in box[:4]]
|
|
||||||
features = {
|
|
||||||
"box_center_x": ((x1 + x2) * 0.5) / max(width, 1),
|
|
||||||
"box_height": (y2 - y1) / max(height, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
all_keypoints = record.get("temporal_selected_keypoints", [])
|
|
||||||
if position >= len(all_keypoints):
|
|
||||||
return features, 0
|
|
||||||
keypoints = all_keypoints[position]
|
|
||||||
left_shoulder = _point(keypoints, 5)
|
|
||||||
right_shoulder = _point(keypoints, 6)
|
|
||||||
left_hip = _point(keypoints, 11)
|
|
||||||
right_hip = _point(keypoints, 12)
|
|
||||||
if None in (left_shoulder, right_shoulder, left_hip, right_hip):
|
|
||||||
return features, 0
|
|
||||||
|
|
||||||
shoulder_x = (left_shoulder[0] + right_shoulder[0]) * 0.5
|
|
||||||
shoulder_y = (left_shoulder[1] + right_shoulder[1]) * 0.5
|
|
||||||
hip_y = (left_hip[1] + right_hip[1]) * 0.5
|
|
||||||
features.update(
|
|
||||||
{
|
|
||||||
"shoulder_center_x": shoulder_x / max(width, 1),
|
|
||||||
"shoulder_center_y": shoulder_y / max(height, 1),
|
|
||||||
"shoulder_width": math.hypot(
|
|
||||||
left_shoulder[0] - right_shoulder[0],
|
|
||||||
left_shoulder[1] - right_shoulder[1],
|
|
||||||
)
|
|
||||||
/ max(width, 1),
|
|
||||||
"torso_height": abs(hip_y - shoulder_y) / max(height, 1),
|
|
||||||
"hip_center_y": hip_y / max(height, 1),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return features, len(POSE_FEATURES)
|
|
||||||
|
|
||||||
|
|
||||||
class DoorZoneAssigner:
|
|
||||||
"""Fuse pose-dominant evidence and smooth front probability per track."""
|
|
||||||
|
|
||||||
def __init__(self, config: DoorZoneConfig):
|
|
||||||
self.config = config
|
|
||||||
self.history: dict[int, deque[float]] = defaultdict(
|
|
||||||
lambda: deque(maxlen=self.config.history_frames)
|
|
||||||
)
|
|
||||||
self.last_seen: dict[int, int] = {}
|
|
||||||
self.zone_counts: Counter = Counter()
|
|
||||||
|
|
||||||
def _score(self, features: dict[str, float]) -> tuple[float, dict[str, float]]:
|
|
||||||
weighted_sum = 0.0
|
|
||||||
total_weight = 0.0
|
|
||||||
contributions = {}
|
|
||||||
for name, calibration in self.config.features.items():
|
|
||||||
if name not in features:
|
|
||||||
continue
|
|
||||||
frontness = calibration.frontness(float(features[name]))
|
|
||||||
contributions[name] = round(frontness, 6)
|
|
||||||
weighted_sum += frontness * calibration.weight
|
|
||||||
total_weight += calibration.weight
|
|
||||||
probability = weighted_sum / total_weight if total_weight else 0.5
|
|
||||||
return probability, contributions
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
frame_index: int,
|
|
||||||
record: dict,
|
|
||||||
frame_shape: tuple[int, int],
|
|
||||||
) -> list[dict]:
|
|
||||||
track_ids = [int(value) for value in record.get("track_ids", [])]
|
|
||||||
assignments = []
|
|
||||||
for position, _box in enumerate(record.get("boxes", [])):
|
|
||||||
track_id = track_ids[position] if position < len(track_ids) else position + 1
|
|
||||||
features, pose_feature_count = extract_person_features(
|
|
||||||
record, position, frame_shape
|
|
||||||
)
|
|
||||||
probability, contributions = self._score(features)
|
|
||||||
enough_pose = pose_feature_count >= self.config.minimum_pose_features
|
|
||||||
if features["box_height"] < self.config.minimum_person_height:
|
|
||||||
raw_zone = "unassigned"
|
|
||||||
door_zone = "unassigned"
|
|
||||||
elif not enough_pose:
|
|
||||||
raw_zone = "transition"
|
|
||||||
door_zone = "transition"
|
|
||||||
else:
|
|
||||||
raw_zone = self.config.classify(probability)
|
|
||||||
values = self.history[track_id]
|
|
||||||
values.append(probability)
|
|
||||||
door_zone = self.config.classify(float(median(values)))
|
|
||||||
|
|
||||||
values = self.history.get(track_id, ())
|
|
||||||
smoothed_probability = (
|
|
||||||
float(median(values)) if values else float(probability)
|
|
||||||
)
|
|
||||||
self.last_seen[track_id] = int(frame_index)
|
|
||||||
self.zone_counts[door_zone] += 1
|
|
||||||
assignments.append(
|
|
||||||
{
|
|
||||||
"track_id": track_id,
|
|
||||||
"person_index": position,
|
|
||||||
"door_zone": door_zone,
|
|
||||||
"raw_door_zone": raw_zone,
|
|
||||||
"front_probability": round(float(probability), 6),
|
|
||||||
"smoothed_front_probability": round(smoothed_probability, 6),
|
|
||||||
"confidence": round(abs(smoothed_probability - 0.5) * 2.0, 6),
|
|
||||||
"evidence_source": "pose_primary_bbox_secondary",
|
|
||||||
"pose_feature_count": pose_feature_count,
|
|
||||||
"features": {name: round(value, 6) for name, value in features.items()},
|
|
||||||
"feature_frontness": contributions,
|
|
||||||
"history_frames": len(values),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
expired = [
|
|
||||||
track_id
|
|
||||||
for track_id, seen_at in self.last_seen.items()
|
|
||||||
if frame_index - seen_at > self.config.stale_track_frames
|
|
||||||
]
|
|
||||||
for track_id in expired:
|
|
||||||
self.history.pop(track_id, None)
|
|
||||||
self.last_seen.pop(track_id, None)
|
|
||||||
return assignments
|
|
||||||
|
|
||||||
def summary(self) -> dict:
|
|
||||||
return {
|
|
||||||
"policy": "pose-primary weighted front probability with causal median",
|
|
||||||
"config": str(self.config.source_path) if self.config.source_path else None,
|
|
||||||
"history_frames": self.config.history_frames,
|
|
||||||
"front_probability_min": self.config.front_probability_min,
|
|
||||||
"rear_probability_max": self.config.rear_probability_max,
|
|
||||||
"minimum_person_height": self.config.minimum_person_height,
|
|
||||||
"minimum_pose_features": self.config.minimum_pose_features,
|
|
||||||
"features": {
|
|
||||||
name: {
|
|
||||||
"rear": calibration.rear,
|
|
||||||
"front": calibration.front,
|
|
||||||
"weight": calibration.weight,
|
|
||||||
}
|
|
||||||
for name, calibration in self.config.features.items()
|
|
||||||
},
|
|
||||||
"zone_counts": dict(self.zone_counts),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _inactive_risk(door: str) -> dict:
|
|
||||||
return {
|
|
||||||
"door_id": door,
|
|
||||||
"status": "inactive",
|
|
||||||
"risk_score": None,
|
|
||||||
"risk_level": "inactive",
|
|
||||||
"decision": "DOOR_CAN_NOT_ACTIVE",
|
|
||||||
"risk_type": f"{door.upper()}_INACTIVE",
|
|
||||||
"source": None,
|
|
||||||
"track_id": None,
|
|
||||||
"side": None,
|
|
||||||
"regions": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_risk(door: str) -> dict:
|
|
||||||
return {
|
|
||||||
"door_id": door,
|
|
||||||
"status": "calculated",
|
|
||||||
"risk_score": 0.0,
|
|
||||||
"risk_level": "clear",
|
|
||||||
"decision": "CLEAR_NO_PERSON_ASSIGNED_TO_ACTIVE_DOOR",
|
|
||||||
"risk_type": f"{door.upper()}_CLEAR",
|
|
||||||
"source": None,
|
|
||||||
"track_id": None,
|
|
||||||
"side": None,
|
|
||||||
"regions": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _unknown_risk(door: str, transition_regions: list[dict]) -> dict:
|
|
||||||
return {
|
|
||||||
"door_id": door,
|
|
||||||
"status": "door_zone_ambiguous",
|
|
||||||
"risk_score": None,
|
|
||||||
"risk_level": "unknown",
|
|
||||||
"decision": "EVIDENCE_UNAVAILABLE_TRANSITION_ZONE",
|
|
||||||
"risk_type": f"{door.upper()}_UNKNOWN",
|
|
||||||
"source": None,
|
|
||||||
"track_id": None,
|
|
||||||
"side": None,
|
|
||||||
"regions": transition_regions,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_door_risks(
|
|
||||||
raw_risk: dict,
|
|
||||||
assignments: list[dict],
|
|
||||||
active_door: str,
|
|
||||||
) -> tuple[dict[str, dict], dict]:
|
|
||||||
"""Build per-door risk and select the CAN-active door result."""
|
|
||||||
|
|
||||||
zone_by_track = {
|
|
||||||
int(assignment["track_id"]): str(assignment["door_zone"])
|
|
||||||
for assignment in assignments
|
|
||||||
}
|
|
||||||
annotated_regions = []
|
|
||||||
for original in raw_risk.get("regions", []):
|
|
||||||
region = dict(original)
|
|
||||||
region["door_zone"] = zone_by_track.get(int(region.get("track_id", -1)), "unknown")
|
|
||||||
annotated_regions.append(region)
|
|
||||||
|
|
||||||
active_doors = set(DOORS) if active_door == "both" else {active_door}
|
|
||||||
if active_door == "none":
|
|
||||||
active_doors.clear()
|
|
||||||
|
|
||||||
door_risks: dict[str, dict] = {}
|
|
||||||
for door in DOORS:
|
|
||||||
if door not in active_doors:
|
|
||||||
door_risks[door] = _inactive_risk(door)
|
|
||||||
continue
|
|
||||||
exact = [region for region in annotated_regions if region["door_zone"] == door]
|
|
||||||
transition = [
|
|
||||||
region for region in annotated_regions if region["door_zone"] == "transition"
|
|
||||||
]
|
|
||||||
calculated = [region for region in exact if region.get("risk_score") is not None]
|
|
||||||
if calculated:
|
|
||||||
nearest = max(calculated, key=lambda item: float(item["risk_score"]))
|
|
||||||
level = str(nearest["risk_level"])
|
|
||||||
if transition and level in ("clear", "watch"):
|
|
||||||
door_risks[door] = _unknown_risk(door, transition)
|
|
||||||
else:
|
|
||||||
door_risks[door] = {
|
|
||||||
"door_id": door,
|
|
||||||
"status": "calculated",
|
|
||||||
"risk_score": nearest["risk_score"],
|
|
||||||
"risk_level": level,
|
|
||||||
"decision": nearest["decision"],
|
|
||||||
"risk_type": f"{door.upper()}_{level.upper()}",
|
|
||||||
"source": nearest["source"],
|
|
||||||
"track_id": nearest["track_id"],
|
|
||||||
"side": nearest["side"],
|
|
||||||
"regions": exact,
|
|
||||||
}
|
|
||||||
elif transition:
|
|
||||||
door_risks[door] = _unknown_risk(door, transition)
|
|
||||||
else:
|
|
||||||
door_risks[door] = _clear_risk(door)
|
|
||||||
|
|
||||||
if len(active_doors) == 1:
|
|
||||||
selected = dict(door_risks[next(iter(active_doors))])
|
|
||||||
elif not active_doors:
|
|
||||||
selected = {
|
|
||||||
"door_id": None,
|
|
||||||
"status": "inactive",
|
|
||||||
"risk_score": None,
|
|
||||||
"risk_level": "inactive",
|
|
||||||
"decision": "NO_DOOR_CAN_ACTIVE",
|
|
||||||
"risk_type": "NO_ACTIVE_DOOR",
|
|
||||||
"source": None,
|
|
||||||
"track_id": None,
|
|
||||||
"side": None,
|
|
||||||
"regions": [],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
severity = {"danger": 4, "warning": 3, "watch": 2, "unknown": 1, "clear": 0}
|
|
||||||
selected = dict(
|
|
||||||
max(door_risks.values(), key=lambda item: severity.get(item["risk_level"], -1))
|
|
||||||
)
|
|
||||||
selected["door_mask"] = raw_risk.get("door_mask")
|
|
||||||
selected["warning_distance_relative"] = raw_risk.get("warning_distance_relative")
|
|
||||||
selected["release_distance_relative"] = raw_risk.get("release_distance_relative")
|
|
||||||
return door_risks, selected
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""One owned, immutable latest result, mirroring the 0920 LatestResult contract."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
import threading
|
|
||||||
from types import MappingProxyType
|
|
||||||
|
|
||||||
|
|
||||||
def freeze(value):
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return MappingProxyType({key: freeze(item) for key, item in value.items()})
|
|
||||||
if isinstance(value, (list, tuple)):
|
|
||||||
return tuple(freeze(item) for item in value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def result_to_dict(value):
|
|
||||||
"""Return a mutable JSON-compatible copy when a consumer needs serialization."""
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return {key: result_to_dict(item) for key, item in value.items()}
|
|
||||||
if isinstance(value, tuple):
|
|
||||||
return [result_to_dict(item) for item in value]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class LatestResult:
|
|
||||||
"""Readers share a stable snapshot; reading never removes or acknowledges it.
|
|
||||||
|
|
||||||
Only the latest result is retained here. Slow readers can skip frames, and
|
|
||||||
references they already hold remain valid after subsequent publications.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._condition = threading.Condition()
|
|
||||||
self._value = None
|
|
||||||
self._closed = False
|
|
||||||
|
|
||||||
def publish(self, result):
|
|
||||||
if result is None:
|
|
||||||
raise ValueError("Cannot publish a null result")
|
|
||||||
snapshot = freeze(result)
|
|
||||||
with self._condition:
|
|
||||||
if self._closed:
|
|
||||||
raise RuntimeError("Result publisher is closed")
|
|
||||||
self._value = snapshot
|
|
||||||
self._condition.notify_all()
|
|
||||||
|
|
||||||
def load(self):
|
|
||||||
with self._condition:
|
|
||||||
return self._value
|
|
||||||
|
|
||||||
def wait(self, after_frame_index=-1, timeout=None):
|
|
||||||
"""Wait for a newer result; return None on timeout/end without a new frame."""
|
|
||||||
with self._condition:
|
|
||||||
def has_new_frame():
|
|
||||||
return self._value is not None and self._value['frame_index'] > after_frame_index
|
|
||||||
|
|
||||||
self._condition.wait_for(lambda: has_new_frame() or self._closed, timeout)
|
|
||||||
return self._value if has_new_frame() else None
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
with self._condition:
|
|
||||||
self._closed = True
|
|
||||||
self._condition.notify_all()
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Native Jetson TensorRT adapter; temporal/risk policy stays in upstream Python."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import ctypes as C
|
|
||||||
from pathlib import Path
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
|
|
||||||
class NativeBackend:
|
|
||||||
def __init__(self, args):
|
|
||||||
self.lib = C.CDLL(str(ROOT / 'build/libvision_native.so'))
|
|
||||||
pointer = C.c_void_p
|
|
||||||
signatures = {
|
|
||||||
'vap_error': ([], C.c_char_p),
|
|
||||||
'vap_engine': ([C.c_char_p,C.c_int,C.c_int,C.c_int,C.c_float],pointer),
|
|
||||||
'vap_engine_close': ([pointer],None),
|
|
||||||
'vap_detect': ([pointer,pointer,C.c_int,C.c_int,pointer],C.c_int),
|
|
||||||
'vap_pose': ([pointer,pointer,C.c_int,C.c_int,pointer,C.c_int,pointer],C.c_int),
|
|
||||||
'vap_camera': ([C.c_char_p,C.c_int,C.c_int,C.c_int,C.c_int],pointer),
|
|
||||||
'vap_camera_close': ([pointer],None),
|
|
||||||
'vap_read': ([pointer,pointer,pointer],C.c_int),
|
|
||||||
'vap_can': ([pointer],None),
|
|
||||||
}
|
|
||||||
for name,(inputs,output) in signatures.items():
|
|
||||||
fn=getattr(self.lib,name);fn.argtypes=inputs;fn.restype=output
|
|
||||||
self.engine = self.lib.vap_engine(str(args.engines.resolve()).encode(),args.width,args.height,args.max_people,args.det_conf)
|
|
||||||
if not self.engine: self.fail()
|
|
||||||
self.camera=None
|
|
||||||
self.args=args
|
|
||||||
|
|
||||||
def fail(self):
|
|
||||||
raise RuntimeError(self.lib.vap_error().decode())
|
|
||||||
|
|
||||||
def open_camera(self):
|
|
||||||
a=self.args
|
|
||||||
self.camera=self.lib.vap_camera(a.camera.encode(),a.width,a.height,a.fps,not a.no_can)
|
|
||||||
if not self.camera:self.fail()
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
image=np.empty((self.args.height,self.args.width,3),np.uint8)
|
|
||||||
meta=np.zeros(4,np.float64)
|
|
||||||
result=self.lib.vap_read(self.camera,image.ctypes.data,meta.ctypes.data)
|
|
||||||
if result<0:self.fail()
|
|
||||||
if result==0:return None,None
|
|
||||||
return image,{'sequence':int(meta[0]),'capture_monotonic_ms':float(meta[1]),'dropped_before_inference':int(meta[2]),'driver_timestamp_ms':float(meta[3]),'latency_clock':'host_monotonic_at_dequeue'}
|
|
||||||
|
|
||||||
def can_snapshot(self):
|
|
||||||
data=np.zeros(5,np.float64);self.lib.vap_can(data.ctypes.data)
|
|
||||||
return {'positions':[int(x) for x in data[:4]],'age_ms':float(data[4])}
|
|
||||||
|
|
||||||
def detect(self, frame):
|
|
||||||
frame=np.ascontiguousarray(frame);out=np.empty((self.args.max_people,5),np.float32)
|
|
||||||
n=self.lib.vap_detect(self.engine,frame.ctypes.data,frame.shape[1],frame.shape[0],out.ctypes.data)
|
|
||||||
if n<0:self.fail()
|
|
||||||
return out[:n,:4].tolist(),out[:n,4].tolist()
|
|
||||||
|
|
||||||
def pose(self,frame,bboxes):
|
|
||||||
frame=np.ascontiguousarray(frame);boxes=np.ascontiguousarray(bboxes,dtype=np.float32)
|
|
||||||
out=np.empty((len(boxes),133,3),np.float32)
|
|
||||||
n=self.lib.vap_pose(self.engine,frame.ctypes.data,frame.shape[1],frame.shape[0],boxes.ctypes.data,len(boxes),out.ctypes.data)
|
|
||||||
if n<0:self.fail()
|
|
||||||
return out[:n,:,:2].astype(np.float64),out[:n,:,2].astype(np.float64)
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self.camera:self.lib.vap_camera_close(self.camera);self.camera=None
|
|
||||||
if self.engine:self.lib.vap_engine_close(self.engine);self.engine=None
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"""Resident native TensorRT inference with causal pose fusion and tracking."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import time
|
|
||||||
from collections import defaultdict, deque
|
|
||||||
import numpy as np
|
|
||||||
from pose_tracking import BoxTracker
|
|
||||||
|
|
||||||
SIDES = ("left", "right")
|
|
||||||
|
|
||||||
class NativePoseEngine:
|
|
||||||
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
|
|
||||||
|
|
||||||
def infer(self, frame: np.ndarray, frame_idx: int) -> dict:
|
|
||||||
from pose_geometry import HAND_SLICES
|
|
||||||
from pose_fusion import adaptive_smooth, build_global_hands, fuse_person
|
|
||||||
from pose_occlusion import Measurement, resolve_global_hand_assignments
|
|
||||||
from pose_tracking import (
|
|
||||||
LivePersonState,
|
|
||||||
mask_other_people,
|
|
||||||
update_live_v2,
|
|
||||||
)
|
|
||||||
|
|
||||||
infer_started = time.perf_counter()
|
|
||||||
height, width = frame.shape[:2]
|
|
||||||
started = time.perf_counter()
|
|
||||||
detection_height = max(
|
|
||||||
1, min(height, round(height * self.args.det_roi_y_max_ratio))
|
|
||||||
)
|
|
||||||
boxes, confidences = self.native.detect(frame[:detection_height])
|
|
||||||
self.timings["detector_ms"].append((time.perf_counter() - started) * 1000)
|
|
||||||
track_ids = self.box_tracker.update(boxes, frame_idx, (height, width))
|
|
||||||
|
|
||||||
# Retire disappeared people in long-running camera sessions.
|
|
||||||
for stale_id in list(self.people):
|
|
||||||
if stale_id not in self.box_tracker.tracks:
|
|
||||||
del self.people[stale_id]
|
|
||||||
if boxes:
|
|
||||||
started = time.perf_counter()
|
|
||||||
raw_keypoints, raw_scores = self.pose(frame, bboxes=boxes)
|
|
||||||
self.timings["raw_pose_ms"].append((time.perf_counter() - started) * 1000)
|
|
||||||
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)
|
|
||||||
keypoints, scores = self.pose(masked, bboxes=[bbox])
|
|
||||||
isolated_keypoints[person_idx] = keypoints[0]
|
|
||||||
isolated_scores[person_idx] = scores[0]
|
|
||||||
self.isolated_inferences += 1
|
|
||||||
self.timings["isolated_extra_ms"].append(
|
|
||||||
(time.perf_counter() - started) * 1000
|
|
||||||
)
|
|
||||||
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, metadata_list, states = [], [], [], []
|
|
||||||
for person_idx, (bbox, track_id) in enumerate(zip(boxes, track_ids)):
|
|
||||||
state = self.people.setdefault(track_id, LivePersonState())
|
|
||||||
selected, metadata = fuse_person(
|
|
||||||
raw_keypoints[person_idx],
|
|
||||||
raw_scores[person_idx],
|
|
||||||
isolated_keypoints[person_idx],
|
|
||||||
isolated_scores[person_idx],
|
|
||||||
bbox,
|
|
||||||
state.fusion,
|
|
||||||
)
|
|
||||||
selected_list.append(selected)
|
|
||||||
smoothed_list.append(adaptive_smooth(selected, bbox, state.fusion))
|
|
||||||
metadata_list.append(metadata)
|
|
||||||
states.append(state)
|
|
||||||
|
|
||||||
measurements, endpoint_tracks = {}, {}
|
|
||||||
for person_idx, (selected, metadata, state) in enumerate(
|
|
||||||
zip(selected_list, metadata_list, states)
|
|
||||||
):
|
|
||||||
for side, (start, end) in HAND_SLICES.items():
|
|
||||||
meta = metadata[side]
|
|
||||||
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]
|
|
||||||
measurements, assignments = resolve_global_hand_assignments(
|
|
||||||
measurements, endpoint_tracks, boxes, 30
|
|
||||||
)
|
|
||||||
|
|
||||||
v2_list, v2_meta_list = [], []
|
|
||||||
for person_idx, (bbox, selected, smoothed, metadata, state) in enumerate(
|
|
||||||
zip(boxes, selected_list, smoothed_list, metadata_list, states)
|
|
||||||
):
|
|
||||||
v2, v2_meta = update_live_v2(
|
|
||||||
frame_idx,
|
|
||||||
bbox,
|
|
||||||
selected,
|
|
||||||
smoothed,
|
|
||||||
metadata,
|
|
||||||
state,
|
|
||||||
{side: measurements[(person_idx, side)] for side in SIDES},
|
|
||||||
{side: assignments.get((person_idx, side)) for side in SIDES},
|
|
||||||
12,
|
|
||||||
30,
|
|
||||||
)
|
|
||||||
v2_list.append(v2)
|
|
||||||
v2_meta_list.append(v2_meta)
|
|
||||||
|
|
||||||
self.timings["pose_pipeline_total_ms"].append(
|
|
||||||
(time.perf_counter() - infer_started) * 1000
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"frame_index": frame_idx,
|
|
||||||
"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": metadata_list,
|
|
||||||
"v2_keypoints": np.round(np.asarray(v2_list), 3).tolist(),
|
|
||||||
"v2_metadata": v2_meta_list,
|
|
||||||
"global_hands": build_global_hands(selected_list, metadata_list),
|
|
||||||
}
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
"""Runtime pose fusion helpers."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import math
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
from pose_geometry import BODY_WRISTS, HAND_EDGES_LOCAL, HAND_SLICES, normalized_keypoints
|
|
||||||
|
|
||||||
|
|
||||||
BODY_SIDE_INDICES = {"left": (5, 7, 9), "right": (6, 8, 10)}
|
|
||||||
|
|
||||||
|
|
||||||
SIDE_COLORS = {"left": (40, 40, 255), "right": (30, 230, 30)}
|
|
||||||
|
|
||||||
|
|
||||||
HAND_COLORS = {"left": (255, 80, 220), "right": (255, 190, 40)}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Candidate:
|
|
||||||
source: str
|
|
||||||
keypoints: np.ndarray
|
|
||||||
scores: np.ndarray
|
|
||||||
normalized: np.ndarray
|
|
||||||
limb_score: float
|
|
||||||
hand_score: float
|
|
||||||
upper_length: float
|
|
||||||
forearm_length: float
|
|
||||||
hand_span: float
|
|
||||||
wrist_link: float
|
|
||||||
limb_valid: bool
|
|
||||||
hand_valid: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SideTrack:
|
|
||||||
limb_previous: np.ndarray | None = None
|
|
||||||
limb_velocity: np.ndarray | None = None
|
|
||||||
hand_previous: np.ndarray | None = None
|
|
||||||
hand_velocity: np.ndarray | None = None
|
|
||||||
limb_source: str | None = None
|
|
||||||
hand_source: str | None = None
|
|
||||||
previous_hand_eligible: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PersonTrack:
|
|
||||||
sides: dict[str, SideTrack] = field(
|
|
||||||
default_factory=lambda: {"left": SideTrack(), "right": SideTrack()}
|
|
||||||
)
|
|
||||||
smoothed: 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 build_candidate(
|
|
||||||
source: str,
|
|
||||||
keypoints: np.ndarray,
|
|
||||||
scores: np.ndarray,
|
|
||||||
bbox: list[float],
|
|
||||||
side: str,
|
|
||||||
) -> Candidate:
|
|
||||||
normalized = normalized_keypoints(keypoints, bbox)
|
|
||||||
shoulder, elbow, wrist = BODY_SIDE_INDICES[side]
|
|
||||||
hand_start, hand_end = HAND_SLICES[side]
|
|
||||||
hand = keypoints[hand_start:hand_end]
|
|
||||||
diagonal = bbox_diagonal(bbox)
|
|
||||||
upper_length = float(np.linalg.norm(normalized[shoulder] - normalized[elbow]))
|
|
||||||
forearm_length = float(np.linalg.norm(normalized[elbow] - normalized[wrist]))
|
|
||||||
hand_span = math.hypot(float(np.ptp(hand[:, 0])), float(np.ptp(hand[:, 1]))) / diagonal
|
|
||||||
wrist_link = float(np.linalg.norm(keypoints[wrist] - hand[0])) / diagonal
|
|
||||||
limb_score = float(np.mean(scores[[shoulder, elbow, wrist]]))
|
|
||||||
hand_score = float(np.mean(scores[hand_start:hand_end]))
|
|
||||||
limb_valid = 0.025 <= upper_length <= 0.8 and 0.025 <= forearm_length <= 0.8
|
|
||||||
hand_valid = 0.008 <= hand_span <= 0.35 and wrist_link <= 0.12
|
|
||||||
return Candidate(
|
|
||||||
source=source,
|
|
||||||
keypoints=keypoints,
|
|
||||||
scores=scores,
|
|
||||||
normalized=normalized,
|
|
||||||
limb_score=limb_score,
|
|
||||||
hand_score=hand_score,
|
|
||||||
upper_length=upper_length,
|
|
||||||
forearm_length=forearm_length,
|
|
||||||
hand_span=hand_span,
|
|
||||||
wrist_link=wrist_link,
|
|
||||||
limb_valid=limb_valid,
|
|
||||||
hand_valid=hand_valid,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def predicted(previous: np.ndarray | None, velocity: np.ndarray | None) -> np.ndarray | None:
|
|
||||||
if previous is None:
|
|
||||||
return None
|
|
||||||
if velocity is None:
|
|
||||||
return previous
|
|
||||||
return previous + 0.65 * velocity
|
|
||||||
|
|
||||||
|
|
||||||
def robust_distance(points: np.ndarray, target: np.ndarray | None) -> float:
|
|
||||||
if target is None:
|
|
||||||
return 0.0
|
|
||||||
distances = np.linalg.norm(points - target, axis=1)
|
|
||||||
return float(np.median(distances))
|
|
||||||
|
|
||||||
|
|
||||||
def select_limb(
|
|
||||||
candidates: list[Candidate],
|
|
||||||
side: str,
|
|
||||||
track: SideTrack | None,
|
|
||||||
) -> tuple[Candidate, dict[str, float]]:
|
|
||||||
indices = np.asarray(BODY_SIDE_INDICES[side])
|
|
||||||
target = predicted(track.limb_previous, track.limb_velocity) if track else None
|
|
||||||
scored: list[tuple[float, Candidate, float]] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
temporal_cost = robust_distance(candidate.normalized[indices], target)
|
|
||||||
geometry_bonus = 1.2 if candidate.limb_valid else -5.0
|
|
||||||
switch_bonus = 0.35 if track and candidate.source == track.limb_source else 0.0
|
|
||||||
score = candidate.limb_score + geometry_bonus + switch_bonus - 7.0 * temporal_cost
|
|
||||||
scored.append((score, candidate, temporal_cost))
|
|
||||||
score, selected, temporal_cost = max(scored, key=lambda item: item[0])
|
|
||||||
return selected, {"score": score, "temporal_cost": temporal_cost}
|
|
||||||
|
|
||||||
|
|
||||||
def hand_link_to_limb(
|
|
||||||
candidate: Candidate,
|
|
||||||
selected_limb: Candidate,
|
|
||||||
bbox: list[float],
|
|
||||||
side: str,
|
|
||||||
) -> float:
|
|
||||||
hand_start, _ = HAND_SLICES[side]
|
|
||||||
wrist_idx = BODY_WRISTS[side]
|
|
||||||
return float(
|
|
||||||
np.linalg.norm(candidate.keypoints[hand_start] - selected_limb.keypoints[wrist_idx])
|
|
||||||
) / bbox_diagonal(bbox)
|
|
||||||
|
|
||||||
|
|
||||||
def select_hand(
|
|
||||||
candidates: list[Candidate],
|
|
||||||
selected_limb: Candidate,
|
|
||||||
bbox: list[float],
|
|
||||||
side: str,
|
|
||||||
track: SideTrack | None,
|
|
||||||
) -> tuple[Candidate, bool, dict[str, float]]:
|
|
||||||
start, end = HAND_SLICES[side]
|
|
||||||
target = predicted(track.hand_previous, track.hand_velocity) if track else None
|
|
||||||
scored: list[tuple[float, Candidate, bool, float, float]] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
temporal_cost = robust_distance(candidate.normalized[start:end], target)
|
|
||||||
link = hand_link_to_limb(candidate, selected_limb, bbox, side)
|
|
||||||
valid = candidate.hand_valid
|
|
||||||
geometry_bonus = 3.0 if valid else -7.0
|
|
||||||
switch_bonus = 0.45 if track and candidate.source == track.hand_source else 0.0
|
|
||||||
limb_source_bonus = 0.15 if candidate.source == selected_limb.source else 0.0
|
|
||||||
# A newly visible endpoint may disagree with the currently selected
|
|
||||||
# (occlusion-driven) limb. Keep this as a soft cost so strong image
|
|
||||||
# evidence can switch both the hand and its limb together.
|
|
||||||
link_penalty = 1.5 * min(max(link - 0.12, 0.0), 0.2)
|
|
||||||
score = (
|
|
||||||
candidate.hand_score
|
|
||||||
+ geometry_bonus
|
|
||||||
+ switch_bonus
|
|
||||||
+ limb_source_bonus
|
|
||||||
- link_penalty
|
|
||||||
- 8.0 * min(temporal_cost, 0.15)
|
|
||||||
)
|
|
||||||
scored.append((score, candidate, valid, temporal_cost, link))
|
|
||||||
score, selected, valid, temporal_cost, link = max(scored, key=lambda item: item[0])
|
|
||||||
return selected, valid, {
|
|
||||||
"score": score,
|
|
||||||
"temporal_cost": temporal_cost,
|
|
||||||
"link_to_selected_limb": link,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def candidate_disagreement(
|
|
||||||
raw: Candidate,
|
|
||||||
isolated: Candidate,
|
|
||||||
side: str,
|
|
||||||
) -> tuple[float, float]:
|
|
||||||
shoulder, elbow, wrist = BODY_SIDE_INDICES[side]
|
|
||||||
start, end = HAND_SLICES[side]
|
|
||||||
limb = float(
|
|
||||||
np.median(
|
|
||||||
np.linalg.norm(
|
|
||||||
raw.normalized[[shoulder, elbow, wrist]]
|
|
||||||
- isolated.normalized[[shoulder, elbow, wrist]],
|
|
||||||
axis=1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
hand_distances = np.linalg.norm(
|
|
||||||
raw.normalized[start:end] - isolated.normalized[start:end], axis=1
|
|
||||||
)
|
|
||||||
hand = float(np.percentile(hand_distances, 90))
|
|
||||||
return limb, hand
|
|
||||||
|
|
||||||
|
|
||||||
def suppress_duplicate_hands(
|
|
||||||
fused: np.ndarray,
|
|
||||||
metadata: dict,
|
|
||||||
bbox: list[float],
|
|
||||||
) -> None:
|
|
||||||
left_start, left_end = HAND_SLICES["left"]
|
|
||||||
right_start, right_end = HAND_SLICES["right"]
|
|
||||||
left_center = np.mean(fused[left_start:left_end], axis=0)
|
|
||||||
right_center = np.mean(fused[right_start:right_end], axis=0)
|
|
||||||
separation = float(np.linalg.norm(left_center - right_center)) / bbox_diagonal(bbox)
|
|
||||||
if separation >= 0.08:
|
|
||||||
return
|
|
||||||
weaker = min(
|
|
||||||
("left", "right"),
|
|
||||||
key=lambda side: metadata[side]["selected_hand_score"],
|
|
||||||
)
|
|
||||||
metadata[weaker]["hand_state"] = "duplicate"
|
|
||||||
metadata[weaker]["distance_eligible"] = False
|
|
||||||
metadata[weaker]["alarm_eligible"] = False
|
|
||||||
metadata[weaker]["duplicate_hand_separation"] = round(separation, 5)
|
|
||||||
|
|
||||||
|
|
||||||
def fuse_person(
|
|
||||||
raw_keypoints: np.ndarray,
|
|
||||||
raw_scores: np.ndarray,
|
|
||||||
isolated_keypoints: np.ndarray,
|
|
||||||
isolated_scores: np.ndarray,
|
|
||||||
bbox: list[float],
|
|
||||||
track: PersonTrack | None,
|
|
||||||
) -> tuple[np.ndarray, dict]:
|
|
||||||
fused = raw_keypoints.copy()
|
|
||||||
metadata: dict[str, dict] = {}
|
|
||||||
|
|
||||||
for side in ("left", "right"):
|
|
||||||
side_track = track.sides[side] if track else None
|
|
||||||
raw = build_candidate("raw", raw_keypoints, raw_scores, bbox, side)
|
|
||||||
isolated = build_candidate(
|
|
||||||
"isolated", isolated_keypoints, isolated_scores, bbox, side
|
|
||||||
)
|
|
||||||
candidates = [raw, isolated]
|
|
||||||
limb, limb_detail = select_limb(candidates, side, side_track)
|
|
||||||
hand, hand_valid, hand_detail = select_hand(
|
|
||||||
candidates, limb, bbox, side, side_track
|
|
||||||
)
|
|
||||||
|
|
||||||
other_hand = isolated if hand.source == "raw" else raw
|
|
||||||
endpoint_advantage = hand.hand_score - other_hand.hand_score
|
|
||||||
if hand.source != limb.source and hand_valid and endpoint_advantage >= 0.6:
|
|
||||||
limb = hand
|
|
||||||
limb_detail["visible_endpoint_override"] = 1.0
|
|
||||||
hand_detail["link_to_selected_limb"] = hand.wrist_link
|
|
||||||
|
|
||||||
body_indices = np.asarray(BODY_SIDE_INDICES[side])
|
|
||||||
hand_start, hand_end = HAND_SLICES[side]
|
|
||||||
fused[body_indices] = limb.keypoints[body_indices]
|
|
||||||
fused[hand_start:hand_end] = hand.keypoints[hand_start:hand_end]
|
|
||||||
|
|
||||||
limb_disagreement, hand_disagreement = candidate_disagreement(raw, isolated, side)
|
|
||||||
if limb_disagreement <= 0.05:
|
|
||||||
limb_state = "consensus"
|
|
||||||
else:
|
|
||||||
limb_state = "inferred"
|
|
||||||
|
|
||||||
score_advantage = raw.hand_score - isolated.hand_score
|
|
||||||
raw_temporally_stable = hand_detail["temporal_cost"] <= 0.08
|
|
||||||
if not hand_valid:
|
|
||||||
hand_state = "rejected"
|
|
||||||
distance_eligible = False
|
|
||||||
elif hand_disagreement <= 0.06:
|
|
||||||
hand_state = "consensus"
|
|
||||||
distance_eligible = True
|
|
||||||
elif (
|
|
||||||
hand.source == "raw"
|
|
||||||
and score_advantage >= 0.6
|
|
||||||
and raw_temporally_stable
|
|
||||||
):
|
|
||||||
hand_state = "visible_endpoint"
|
|
||||||
distance_eligible = True
|
|
||||||
elif hand.source == "isolated":
|
|
||||||
hand_state = "inferred"
|
|
||||||
distance_eligible = False
|
|
||||||
else:
|
|
||||||
hand_state = "conflict"
|
|
||||||
distance_eligible = False
|
|
||||||
fast_alarm_endpoint = (
|
|
||||||
hand_valid and hand.source == "raw" and score_advantage >= 0.6
|
|
||||||
)
|
|
||||||
alarm_eligible = distance_eligible or fast_alarm_endpoint
|
|
||||||
|
|
||||||
metadata[side] = {
|
|
||||||
"limb_source": limb.source,
|
|
||||||
"hand_source": hand.source,
|
|
||||||
"limb_state": limb_state,
|
|
||||||
"hand_state": hand_state,
|
|
||||||
"distance_eligible": distance_eligible,
|
|
||||||
"alarm_eligible": alarm_eligible,
|
|
||||||
"limb_disagreement": round(limb_disagreement, 5),
|
|
||||||
"hand_disagreement": round(hand_disagreement, 5),
|
|
||||||
"raw_hand_score": round(raw.hand_score, 4),
|
|
||||||
"isolated_hand_score": round(isolated.hand_score, 4),
|
|
||||||
"selected_hand_span": round(hand.hand_span, 5),
|
|
||||||
"selected_hand_score": round(hand.hand_score, 4),
|
|
||||||
"selected_hand_valid": hand_valid,
|
|
||||||
"limb_selection": {key: round(value, 5) for key, value in limb_detail.items()},
|
|
||||||
"hand_selection": {key: round(value, 5) for key, value in hand_detail.items()},
|
|
||||||
}
|
|
||||||
|
|
||||||
if side_track is not None:
|
|
||||||
limb_now = limb.normalized[body_indices]
|
|
||||||
hand_now = hand.normalized[hand_start:hand_end]
|
|
||||||
if side_track.limb_previous is not None:
|
|
||||||
delta = limb_now - side_track.limb_previous
|
|
||||||
side_track.limb_velocity = (
|
|
||||||
delta
|
|
||||||
if side_track.limb_velocity is None
|
|
||||||
else 0.65 * side_track.limb_velocity + 0.35 * delta
|
|
||||||
)
|
|
||||||
if hand_valid:
|
|
||||||
if side_track.hand_previous is not None:
|
|
||||||
delta = hand_now - side_track.hand_previous
|
|
||||||
side_track.hand_velocity = (
|
|
||||||
delta
|
|
||||||
if side_track.hand_velocity is None
|
|
||||||
else 0.65 * side_track.hand_velocity + 0.35 * delta
|
|
||||||
)
|
|
||||||
side_track.hand_previous = hand_now
|
|
||||||
side_track.hand_source = hand.source
|
|
||||||
side_track.limb_previous = limb_now
|
|
||||||
side_track.limb_source = limb.source
|
|
||||||
side_track.previous_hand_eligible = distance_eligible
|
|
||||||
|
|
||||||
suppress_duplicate_hands(fused, metadata, bbox)
|
|
||||||
return fused, metadata
|
|
||||||
|
|
||||||
|
|
||||||
def adaptive_smooth(
|
|
||||||
keypoints: np.ndarray,
|
|
||||||
bbox: list[float],
|
|
||||||
track: PersonTrack,
|
|
||||||
) -> np.ndarray:
|
|
||||||
normalized = normalized_keypoints(keypoints, bbox)
|
|
||||||
if track.smoothed is None:
|
|
||||||
track.smoothed = normalized
|
|
||||||
else:
|
|
||||||
displacement = np.linalg.norm(normalized - track.smoothed, axis=1)
|
|
||||||
alpha = 0.32 + 0.58 * np.clip(displacement / 0.08, 0.0, 1.0)
|
|
||||||
track.smoothed = alpha[:, None] * normalized + (1.0 - alpha[:, None]) * track.smoothed
|
|
||||||
|
|
||||||
x1, y1, x2, y2 = bbox
|
|
||||||
result = track.smoothed.copy()
|
|
||||||
result[:, 0] = result[:, 0] * max(x2 - x1, 1.0) + x1
|
|
||||||
result[:, 1] = result[:, 1] * max(y2 - y1, 1.0) + y1
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def build_global_hands(
|
|
||||||
keypoints: list[np.ndarray],
|
|
||||||
metadata: list[dict],
|
|
||||||
) -> list[dict]:
|
|
||||||
candidates = []
|
|
||||||
for person_idx, (person_keypoints, person_meta) in enumerate(
|
|
||||||
zip(keypoints, metadata), start=1
|
|
||||||
):
|
|
||||||
for side, (start, end) in HAND_SLICES.items():
|
|
||||||
meta = person_meta[side]
|
|
||||||
if not meta["alarm_eligible"]:
|
|
||||||
continue
|
|
||||||
hand = person_keypoints[start:end]
|
|
||||||
span_px = math.hypot(float(np.ptp(hand[:, 0])), float(np.ptp(hand[:, 1])))
|
|
||||||
candidates.append(
|
|
||||||
{
|
|
||||||
"person_id": person_idx,
|
|
||||||
"side": side,
|
|
||||||
"source": meta["hand_source"],
|
|
||||||
"state": meta["hand_state"],
|
|
||||||
"distance_eligible": meta["distance_eligible"],
|
|
||||||
"alarm_eligible": meta["alarm_eligible"],
|
|
||||||
"score": meta["selected_hand_score"],
|
|
||||||
"center": np.mean(hand, axis=0),
|
|
||||||
"span_px": span_px,
|
|
||||||
"keypoints": hand,
|
|
||||||
"aliases": [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
kept: list[dict] = []
|
|
||||||
for candidate in sorted(candidates, key=lambda item: item["score"], reverse=True):
|
|
||||||
duplicate = None
|
|
||||||
for existing in kept:
|
|
||||||
threshold = float(np.clip(0.45 * max(candidate["span_px"], existing["span_px"]), 18, 70))
|
|
||||||
if float(np.linalg.norm(candidate["center"] - existing["center"])) <= threshold:
|
|
||||||
duplicate = existing
|
|
||||||
break
|
|
||||||
if duplicate is None:
|
|
||||||
kept.append(candidate)
|
|
||||||
else:
|
|
||||||
duplicate["aliases"].append(
|
|
||||||
{"person_id": candidate["person_id"], "side": candidate["side"]}
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"person_id": item["person_id"],
|
|
||||||
"side": item["side"],
|
|
||||||
"source": item["source"],
|
|
||||||
"state": item["state"],
|
|
||||||
"distance_eligible": item["distance_eligible"],
|
|
||||||
"alarm_eligible": item["alarm_eligible"],
|
|
||||||
"score": item["score"],
|
|
||||||
"center": np.round(item["center"], 3).tolist(),
|
|
||||||
"span_px": round(item["span_px"], 3),
|
|
||||||
"keypoints": np.round(item["keypoints"], 3).tolist(),
|
|
||||||
"aliases": item["aliases"],
|
|
||||||
}
|
|
||||||
for item in kept
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def draw_dashed_line(
|
|
||||||
image: np.ndarray,
|
|
||||||
start: np.ndarray,
|
|
||||||
end: np.ndarray,
|
|
||||||
color: tuple[int, int, int],
|
|
||||||
thickness: int = 2,
|
|
||||||
) -> None:
|
|
||||||
a = np.asarray(start, dtype=np.float64)
|
|
||||||
b = np.asarray(end, dtype=np.float64)
|
|
||||||
length = float(np.linalg.norm(b - a))
|
|
||||||
if length < 1.0:
|
|
||||||
return
|
|
||||||
direction = (b - a) / length
|
|
||||||
for offset in np.arange(0.0, length, 14.0):
|
|
||||||
segment_start = a + direction * offset
|
|
||||||
segment_end = a + direction * min(offset + 8.0, length)
|
|
||||||
cv2.line(
|
|
||||||
image,
|
|
||||||
tuple(np.round(segment_start).astype(int)),
|
|
||||||
tuple(np.round(segment_end).astype(int)),
|
|
||||||
color,
|
|
||||||
thickness,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_fused(
|
|
||||||
image: np.ndarray,
|
|
||||||
boxes: list[list[float]],
|
|
||||||
keypoints: list[np.ndarray],
|
|
||||||
metadata: list[dict],
|
|
||||||
title: str,
|
|
||||||
) -> np.ndarray:
|
|
||||||
canvas = image.copy()
|
|
||||||
for person_idx, (bbox, kpts, person_meta) in enumerate(
|
|
||||||
zip(boxes, keypoints, metadata), 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.72,
|
|
||||||
(0, 220, 255),
|
|
||||||
2,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
for side in ("left", "right"):
|
|
||||||
shoulder, elbow, wrist = BODY_SIDE_INDICES[side]
|
|
||||||
side_meta = person_meta[side]
|
|
||||||
color = SIDE_COLORS[side]
|
|
||||||
for a, b in ((shoulder, elbow), (elbow, wrist)):
|
|
||||||
if side_meta["limb_state"] == "consensus":
|
|
||||||
cv2.line(
|
|
||||||
canvas,
|
|
||||||
tuple(np.round(kpts[a]).astype(int)),
|
|
||||||
tuple(np.round(kpts[b]).astype(int)),
|
|
||||||
color,
|
|
||||||
3,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
draw_dashed_line(canvas, kpts[a], kpts[b], (0, 140, 255), 3)
|
|
||||||
|
|
||||||
start, end = HAND_SLICES[side]
|
|
||||||
hand = kpts[start:end]
|
|
||||||
if side_meta["hand_state"] != "rejected":
|
|
||||||
hand_color = HAND_COLORS[side]
|
|
||||||
if not side_meta["distance_eligible"]:
|
|
||||||
hand_color = (0, 140, 255)
|
|
||||||
for a, b in HAND_EDGES_LOCAL:
|
|
||||||
if side_meta["distance_eligible"]:
|
|
||||||
cv2.line(
|
|
||||||
canvas,
|
|
||||||
tuple(np.round(hand[a]).astype(int)),
|
|
||||||
tuple(np.round(hand[b]).astype(int)),
|
|
||||||
hand_color,
|
|
||||||
2,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
draw_dashed_line(canvas, hand[a], hand[b], hand_color, 2)
|
|
||||||
label = (
|
|
||||||
f"{side[0].upper()}:{side_meta['hand_state'][:4]}"
|
|
||||||
f"/{side_meta['hand_source'][0]}"
|
|
||||||
)
|
|
||||||
label_y = max(45, y1 + (48 if side == "left" else 70))
|
|
||||||
cv2.putText(
|
|
||||||
canvas,
|
|
||||||
label,
|
|
||||||
(x1 + 4, label_y),
|
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
|
||||||
0.5,
|
|
||||||
HAND_COLORS[side] if side_meta["distance_eligible"] else (0, 140, 255),
|
|
||||||
1,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
|
|
||||||
cv2.rectangle(canvas, (0, 0), (650, 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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
"""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,116 +0,0 @@
|
|||||||
"""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
|
|
||||||
+1
-3
@@ -1,7 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
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 LD_LIBRARY_PATH="$root/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
export LD_LIBRARY_PATH="$root/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||||
export PYTHONDONTWRITEBYTECODE=1
|
exec "$root/build/camera_risk" --config "$root/config/camera.json" "$@"
|
||||||
exec "${PYTHON:-python3}" -u "$root/run_camera.py" "$@"
|
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Live baseline V4L2/CAN capture + native TensorRT + CMS v9.10 risk."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import argparse
|
|
||||||
from collections import Counter, deque
|
|
||||||
from contextlib import ExitStack
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from pathlib import Path
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
ROOT=Path(__file__).resolve().parent
|
|
||||||
sys.path.insert(0,str(ROOT/'pipeline'))
|
|
||||||
from native_backend import NativeBackend
|
|
||||||
from pose_engine import NativePoseEngine
|
|
||||||
from latest_result import LatestResult, result_to_dict
|
|
||||||
from door_mask_risk import DoorMaskSequence, DoorRiskEvaluator
|
|
||||||
from door_zone_risk import DoorZoneConfig, DoorZoneAssigner, build_door_risks
|
|
||||||
from visualization import render_frame, visualization_skeletons, timing_stats
|
|
||||||
|
|
||||||
|
|
||||||
def select_active_door(mode, snapshot, side='left', timeout_ms=1000):
|
|
||||||
if mode!='can': return mode, True
|
|
||||||
p=snapshot['positions'];offset=0 if side=='left' else 1
|
|
||||||
if snapshot['age_ms']<0 or snapshot['age_ms']>timeout_ms or min(p[offset],p[offset+2])<0:
|
|
||||||
return 'none',False
|
|
||||||
front,rear=p[offset]==0,p[offset+2]==0
|
|
||||||
return ('both' if front and rear else 'front' if front else 'rear' if rear else 'none'),True
|
|
||||||
|
|
||||||
|
|
||||||
def gate_risks(raw, assignments, active, can_valid):
|
|
||||||
doors,risk=build_door_risks(raw,assignments,active)
|
|
||||||
if not can_valid:
|
|
||||||
for door,item in doors.items():
|
|
||||||
item.update(risk_type=door.upper()+'_UNKNOWN',risk_level='unknown',risk_score=None,decision='CAN_UNAVAILABLE_OR_STALE')
|
|
||||||
risk=dict(risk,risk_type='CAN_UNKNOWN',risk_level='unknown',risk_score=None,decision='CAN_UNAVAILABLE_OR_STALE')
|
|
||||||
return doors,risk
|
|
||||||
|
|
||||||
|
|
||||||
class JsonlWriter:
|
|
||||||
"""Bound the disk usage of continuous camera runs (current + two backups)."""
|
|
||||||
def __init__(self,path,max_bytes):
|
|
||||||
self.path=path;self.max_bytes=max_bytes;self.size=0
|
|
||||||
self.handle=path.open('w',encoding='utf-8',buffering=1)
|
|
||||||
def __enter__(self):return self
|
|
||||||
def __exit__(self,*args):self.close()
|
|
||||||
def close(self):self.handle.close()
|
|
||||||
def write(self,text):
|
|
||||||
size=len(text.encode('utf-8'))
|
|
||||||
if self.max_bytes and self.size and self.size+size>self.max_bytes:
|
|
||||||
self.handle.close()
|
|
||||||
backup=self.path.with_suffix(self.path.suffix+'.1')
|
|
||||||
if backup.exists():backup.replace(self.path.with_suffix(self.path.suffix+'.2'))
|
|
||||||
self.path.replace(backup)
|
|
||||||
self.handle=self.path.open('w',encoding='utf-8',buffering=1);self.size=0
|
|
||||||
self.handle.write(text);self.size+=size
|
|
||||||
|
|
||||||
|
|
||||||
def render_output(frame, record, risk, mask, assignments, status):
|
|
||||||
canvas = render_frame(frame, record, risk, mask, assignments)
|
|
||||||
cv2.putText(canvas,status,(24,frame.shape[0]-30),cv2.FONT_HERSHEY_SIMPLEX,.8,(0,180,255),2)
|
|
||||||
return canvas
|
|
||||||
|
|
||||||
|
|
||||||
class Preview:
|
|
||||||
def __init__(self,host,port,results):
|
|
||||||
self.jpeg=None;self.results=results;self.lock=threading.Lock()
|
|
||||||
self.pending=threading.Condition();self.packet=None;self.stopped=False
|
|
||||||
outer=self
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
with outer.lock:
|
|
||||||
if self.path.split('?')[0]=='/frame.jpg': payload=outer.jpeg or b'';mime='image/jpeg'
|
|
||||||
elif self.path=='/result.json':payload=json.dumps(result_to_dict(outer.results.load()) or {},ensure_ascii=False,allow_nan=False).encode();mime='application/json'
|
|
||||||
else:
|
|
||||||
payload=b'''<!doctype html><meta charset="utf-8"><title>Vision anti-pinch 0923</title><body style="background:#101820;color:white;font:18px sans-serif"><h2>Vision anti-pinch 0923 / Live camera</h2><p id="state"></p><img id="cam" style="max-width:100%;max-height:83vh"><script>let img=document.querySelector('#cam'); function next(){setTimeout(()=>img.src='/frame.jpg?t='+Date.now(),150)}img.onload=next;img.onerror=next;next();setInterval(async()=>{let r=await(await fetch('/result.json')).json();document.querySelector('#state').textContent=JSON.stringify({frame:r.frame_index,active_door:r.active_door,risk:r.risk,can:r.can,calibration_verified:r.calibration_verified})},1000)</script>''';mime='text/html; charset=utf-8'
|
|
||||||
self.send_response(200 if payload else 503);self.send_header('Content-Type',mime);self.send_header('Cache-Control','no-store');self.send_header('Content-Length',str(len(payload)));self.end_headers()
|
|
||||||
try:self.wfile.write(payload)
|
|
||||||
except (BrokenPipeError,ConnectionResetError):pass
|
|
||||||
def log_message(self,*args):pass
|
|
||||||
self.server=ThreadingHTTPServer((host,port),Handler)
|
|
||||||
self.thread=threading.Thread(target=self.server.serve_forever,daemon=True);self.thread.start()
|
|
||||||
self.renderer=threading.Thread(target=self._render_loop,name='risk-preview',daemon=True)
|
|
||||||
self.renderer.start()
|
|
||||||
def submit(self, frame, record, risk, mask, assignments, status):
|
|
||||||
# All inputs belong to a finished frame; inference never modifies them
|
|
||||||
# after submission. Retain only one pending frame, replacing older work.
|
|
||||||
with self.pending:
|
|
||||||
if not self.stopped:
|
|
||||||
self.packet=(frame,record,risk,mask,assignments,status)
|
|
||||||
self.pending.notify()
|
|
||||||
|
|
||||||
def _render_loop(self):
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
with self.pending:
|
|
||||||
self.pending.wait_for(lambda:self.packet is not None or self.stopped)
|
|
||||||
if self.stopped:return
|
|
||||||
packet=self.packet;self.packet=None
|
|
||||||
self.update(render_output(*packet))
|
|
||||||
except Exception as exc:
|
|
||||||
with self.pending:self.stopped=True;self.packet=None
|
|
||||||
with self.lock:self.jpeg=None
|
|
||||||
print('Visualization disabled; inference continues: '+str(exc),file=sys.stderr,flush=True)
|
|
||||||
|
|
||||||
def update(self,image):
|
|
||||||
w=960;h=round(image.shape[0]*w/image.shape[1]);image=cv2.resize(image,(w,h))
|
|
||||||
ok,jpeg=cv2.imencode('.jpg',image,[cv2.IMWRITE_JPEG_QUALITY,80])
|
|
||||||
if ok:
|
|
||||||
with self.lock:self.jpeg=jpeg.tobytes()
|
|
||||||
def close(self):
|
|
||||||
with self.pending:self.stopped=True;self.packet=None;self.pending.notify_all()
|
|
||||||
self.renderer.join()
|
|
||||||
self.server.shutdown();self.server.server_close();self.thread.join()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv=None):
|
|
||||||
p=argparse.ArgumentParser(description=__doc__)
|
|
||||||
p.add_argument('--video',type=Path,help='Diagnostic video replay through the same TensorRT/risk chain');p.add_argument('--camera',default='/dev/video5');p.add_argument('--width',type=int,default=1920);p.add_argument('--height',type=int,default=1536);p.add_argument('--fps',type=int,default=30)
|
|
||||||
p.add_argument('--engines',type=Path,default=ROOT/'orin/models/engines' if (ROOT/'orin').is_dir() else ROOT/'models/engines')
|
|
||||||
p.add_argument('--constant-door-mask',type=Path,default=ROOT/'annotations/constant_door_mask.json')
|
|
||||||
p.add_argument('--door-zone-config',type=Path,default=ROOT/'annotations/door_zone_multifeature.json')
|
|
||||||
p.add_argument('--active-door',choices=['can','front','rear','both','none'],default='can')
|
|
||||||
p.add_argument('--can-side',choices=['left','right'],default='left');p.add_argument('--can-timeout-ms',type=float,default=1000);p.add_argument('--no-can',action='store_true')
|
|
||||||
p.add_argument('--max-people',type=int,default=3);p.add_argument('--det-conf',type=float,default=.30)
|
|
||||||
p.add_argument('--max-frames',type=int,default=0);p.add_argument('--duration',type=float,default=0)
|
|
||||||
p.add_argument('--jsonl-max-mb',type=float,default=32,help='Rotate each JSONL at this size; keep two backups; 0 disables rotation');p.add_argument('--output',type=Path,default=ROOT/'outputs'/time.strftime('%Y%m%d-%H%M%S'))
|
|
||||||
p.add_argument('--visualize',action='store_true',help='Enable optional HTTP visualization; disabled by default')
|
|
||||||
p.add_argument('--save-results',action='store_true',help='Save per-frame JSONL and periodic latest.json')
|
|
||||||
p.add_argument('--save-snapshots',action='store_true',help='Render and save latest.jpg every 10 seconds')
|
|
||||||
p.add_argument('--preview-port',type=int,default=8765);p.add_argument('--preview-host',default='0.0.0.0');p.add_argument('--preview-every',type=int,default=3)
|
|
||||||
p.add_argument('--write-video',action='store_true');p.add_argument('--save-pose-records',action='store_true');p.add_argument('--calibration-verified',action='store_true')
|
|
||||||
a=p.parse_args(argv)
|
|
||||||
if not 1<=a.max_people<=16 or not 0<a.det_conf<1 or min(a.width,a.height,a.fps,a.preview_every)<=0 or a.can_timeout_ms<=0 or a.jsonl_max_mb<0:p.error('Invalid camera/inference configuration')
|
|
||||||
if a.no_can and a.active_door=='can':p.error('--no-can requires explicit --active-door front/rear/both/none')
|
|
||||||
if not 0<=a.preview_port<=65535 or (a.visualize and a.preview_port==0):p.error('--visualize requires a port between 1 and 65535')
|
|
||||||
if a.max_frames<0 or a.duration<0:p.error('Frame count and duration cannot be negative')
|
|
||||||
a.det_roi_y_max_ratio=1.;return a
|
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline(a, results, stop, summary_sink=None):
|
|
||||||
"""Produce results independently of optional visualization and disk outputs."""
|
|
||||||
save_any = a.save_results or a.save_snapshots or a.save_pose_records or a.write_video
|
|
||||||
if save_any:
|
|
||||||
a.output.mkdir(parents=True,exist_ok=True)
|
|
||||||
cv2.setNumThreads(2)
|
|
||||||
native=None;preview=None;writer=None;video=None;count=0;dropped=0;levels=Counter();latencies=deque(maxlen=3000);pose_ms=deque(maxlen=3000);risk_ms=deque(maxlen=3000);ages=deque(maxlen=3000)
|
|
||||||
started=time.monotonic();run_started=None;error=None
|
|
||||||
try:
|
|
||||||
if a.video:
|
|
||||||
video=cv2.VideoCapture(str(a.video))
|
|
||||||
if not video.isOpened():raise RuntimeError('Cannot open video: '+str(a.video))
|
|
||||||
a.width=int(video.get(cv2.CAP_PROP_FRAME_WIDTH));a.height=int(video.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
||||||
evaluator=DoorRiskEvaluator(DoorMaskSequence(constant_mask=a.constant_door_mask,require_every_frame=True))
|
|
||||||
assigner=DoorZoneAssigner(DoorZoneConfig.load(a.door_zone_config))
|
|
||||||
# Fail before acquiring camera if calibration mask is invalid.
|
|
||||||
initial=evaluator.sequence.load(0,(a.height,a.width))
|
|
||||||
if initial.status!='loaded' or initial.mask is None or not np.any(initial.mask):raise ValueError('Door mask has no usable pixels')
|
|
||||||
native=NativeBackend(a);pose=NativePoseEngine(a,native)
|
|
||||||
if a.visualize:
|
|
||||||
try:preview=Preview(a.preview_host,a.preview_port,results)
|
|
||||||
except Exception as exc:
|
|
||||||
print('Visualization unavailable; inference continues: '+str(exc),file=sys.stderr,flush=True)
|
|
||||||
if not video:native.open_camera()
|
|
||||||
run_started=time.monotonic();last_report=run_started;last_snapshot=0
|
|
||||||
if a.write_video:
|
|
||||||
writer=cv2.VideoWriter(str(a.output/'rtmw_risk_overlay.mp4'),cv2.VideoWriter_fourcc(*'mp4v'),a.fps,(a.width,a.height))
|
|
||||||
if not writer.isOpened():raise RuntimeError('Video writer could not open')
|
|
||||||
with ExitStack() as output_stack:
|
|
||||||
out=output_stack.enter_context(JsonlWriter(a.output/'causal_results.jsonl',int(a.jsonl_max_mb*1024*1024))) if a.save_results else None
|
|
||||||
pose_out=JsonlWriter(a.output/'pose_records.jsonl',int(a.jsonl_max_mb*1024*1024)) if a.save_pose_records else None
|
|
||||||
try:
|
|
||||||
while not stop.is_set():
|
|
||||||
if (a.max_frames and count>=a.max_frames) or (a.duration and time.monotonic()-run_started>=a.duration):break
|
|
||||||
if video:
|
|
||||||
ok,frame=video.read()
|
|
||||||
if not ok:break
|
|
||||||
capture={'sequence':count,'capture_monotonic_ms':time.monotonic()*1000,'dropped_before_inference':0,'latency_clock':'video_decode_complete'}
|
|
||||||
else:frame,capture=native.read()
|
|
||||||
if frame is None:break
|
|
||||||
tick=time.monotonic();record=pose.infer(frame,count);pose_ms.append((time.monotonic()-tick)*1000)
|
|
||||||
rt=time.monotonic();snapshot=native.can_snapshot();active,valid=select_active_door(a.active_door,snapshot,a.can_side,a.can_timeout_ms)
|
|
||||||
assignments=assigner.update(count,record,(a.height,a.width));raw,mask=evaluator.evaluate(count,record,(a.height,a.width))
|
|
||||||
doors,risk=gate_risks(raw,assignments,active,valid);risk_ms.append((time.monotonic()-rt)*1000)
|
|
||||||
result={'frame_index':count,'track_ids':record['track_ids'],'boxes':record['boxes'],'skeletons':visualization_skeletons(record),'active_door':active,'door_assignments':assignments,'door_risks':doors,'risk':risk,'ungated_risk':raw,'capture':capture,'can':dict(snapshot,valid=valid,source='hardware' if a.active_door=='can' else 'simulation',side=a.can_side),'calibration_verified':a.calibration_verified}
|
|
||||||
result['capture_to_result_ms']=round(time.monotonic()*1000-capture['capture_monotonic_ms'],3)
|
|
||||||
results.publish(result)
|
|
||||||
if out:out.write(json.dumps(result,ensure_ascii=False,allow_nan=False)+'\n')
|
|
||||||
if pose_out:pose_out.write(json.dumps(record,ensure_ascii=False,allow_nan=False)+'\n')
|
|
||||||
now=time.monotonic()
|
|
||||||
snapshot_due=now-last_snapshot>=10
|
|
||||||
if a.save_results and snapshot_due:
|
|
||||||
(a.output/'latest.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
|
|
||||||
status=('CAN LIVE' if a.active_door=='can' else 'CAN SIMULATION: '+active)+' | '+('CALIBRATED' if a.calibration_verified else 'CALIBRATION UNVERIFIED')
|
|
||||||
if preview and count%a.preview_every==0:
|
|
||||||
preview.submit(frame,record,risk,mask,assignments,status)
|
|
||||||
if writer or (a.save_snapshots and snapshot_due):
|
|
||||||
canvas=render_output(frame,record,risk,mask,assignments,status)
|
|
||||||
if writer:writer.write(canvas)
|
|
||||||
if a.save_snapshots and snapshot_due:
|
|
||||||
cv2.imwrite(str(a.output/'latest.jpg'),canvas)
|
|
||||||
if snapshot_due:last_snapshot=now
|
|
||||||
count+=1;dropped+=capture['dropped_before_inference'];levels[risk['risk_level']]+=1;ages.append(result['capture_to_result_ms']);latencies.append((time.monotonic()-tick)*1000)
|
|
||||||
if now-last_report>=5:
|
|
||||||
print(json.dumps({'frames':count,'fps':round(count/(now-run_started),2),'people':len(record['boxes']),'risk':risk['risk_type'],'latency_ms':round(float(np.mean(latencies)),1),'capture_age_ms':round(float(np.mean(ages)),1),'can_valid':valid}),flush=True);last_report=now
|
|
||||||
finally:
|
|
||||||
if pose_out:pose_out.close()
|
|
||||||
except Exception as exc:
|
|
||||||
error=str(exc);raise
|
|
||||||
finally:
|
|
||||||
ended=time.monotonic()
|
|
||||||
if video:video.release()
|
|
||||||
if native:native.close()
|
|
||||||
if writer:writer.release()
|
|
||||||
if preview:preview.close()
|
|
||||||
elapsed=ended-(run_started or started)
|
|
||||||
summary={'mode':'baseline_v4l2_can_native_trt_cms_v9_10','camera':a.camera if not a.video else None,'video':str(a.video) if a.video else None,'frames':count,'elapsed_seconds':round(elapsed,3),'throughput_fps':round(count/max(elapsed,.001),3),'dropped_latest_frames':dropped,'risk_levels':dict(levels),'active_door_mode':a.active_door,'can_side':a.can_side,'calibration_verified':a.calibration_verified,'visualize_requested':a.visualize,'save_results':a.save_results,'save_snapshots':a.save_snapshots,'latency_window_frames':3000,'latency_ms':{'total':timing_stats(list(latencies)),'pose':timing_stats(list(pose_ms)),'risk':timing_stats(list(risk_ms)),'capture_to_result':timing_stats(list(ages))},'error':error}
|
|
||||||
if summary_sink is not None:summary_sink.update(summary)
|
|
||||||
if save_any:(a.output/'summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
|
|
||||||
print(json.dumps(summary,ensure_ascii=False),flush=True)
|
|
||||||
|
|
||||||
class CameraPipeline:
|
|
||||||
"""One lifecycle per instance, with the 0920 start/latest/stop/join pattern.
|
|
||||||
|
|
||||||
The worker owns inference. Downstream threads read the latest immutable
|
|
||||||
snapshot directly, without JSON files, HTTP, callbacks or a result queue.
|
|
||||||
"""
|
|
||||||
def __init__(self, args=None):
|
|
||||||
self.args = copy.deepcopy(args if args is not None else parse_args([]))
|
|
||||||
self._results = LatestResult()
|
|
||||||
self._stop = threading.Event()
|
|
||||||
self._lifecycle_lock = threading.Lock()
|
|
||||||
self._thread = None
|
|
||||||
self._error = None
|
|
||||||
self.summary = {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def latest_result(self):
|
|
||||||
"""The latest complete result variable, or None before the first frame."""
|
|
||||||
return self._results.load()
|
|
||||||
|
|
||||||
def latest(self):
|
|
||||||
return self.latest_result
|
|
||||||
|
|
||||||
def wait_for_result(self, after_frame_index=-1, timeout=None):
|
|
||||||
return self._results.wait(after_frame_index, timeout)
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
with self._lifecycle_lock:
|
|
||||||
if self._thread is not None:
|
|
||||||
raise RuntimeError('CameraPipeline can only be started once')
|
|
||||||
self._thread = threading.Thread(target=self._run, name='vision-risk')
|
|
||||||
self._thread.start()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
try:
|
|
||||||
run_pipeline(self.args, self._results, self._stop, self.summary)
|
|
||||||
except BaseException as exc:
|
|
||||||
self._error = exc
|
|
||||||
finally:
|
|
||||||
self._results.close()
|
|
||||||
|
|
||||||
def running(self):
|
|
||||||
return self._thread is not None and self._thread.is_alive()
|
|
||||||
|
|
||||||
def request_stop(self):
|
|
||||||
self._stop.set()
|
|
||||||
|
|
||||||
def join(self, timeout=None):
|
|
||||||
if self._thread is not None:
|
|
||||||
self._thread.join(timeout)
|
|
||||||
if not self._thread.is_alive() and self._error is not None:
|
|
||||||
raise self._error
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self.start()
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
self.request_stop()
|
|
||||||
self.join()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
pipeline=CameraPipeline(parse_args())
|
|
||||||
for sig in (signal.SIGINT,signal.SIGTERM):
|
|
||||||
signal.signal(sig,lambda *_:pipeline.request_stop())
|
|
||||||
pipeline.start()
|
|
||||||
# Periodic joins allow Python to deliver stop signals on the main thread.
|
|
||||||
while pipeline.running():
|
|
||||||
pipeline.join(.2)
|
|
||||||
pipeline.join()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__=='__main__':main()
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
"""Per-frame door-mask loading and YOLO/RTMW-only 2-D risk scoring."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from collections import Counter
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
|
||||||
RASTER_SUFFIXES = {".png", ".bmp", ".tif", ".tiff"}
|
|
||||||
DEFAULT_DOOR_LABELS = ("door", "车门")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LoadedDoorMask:
|
|
||||||
status: str
|
|
||||||
annotation_index: int
|
|
||||||
path: Path | None
|
|
||||||
mask: np.ndarray | None
|
|
||||||
source_format: str | None
|
|
||||||
|
|
||||||
def metadata(self) -> dict:
|
|
||||||
return {
|
|
||||||
"status": self.status,
|
|
||||||
"annotation_index": self.annotation_index,
|
|
||||||
"path": str(self.path.resolve()) if self.path is not None else None,
|
|
||||||
"format": self.source_format,
|
|
||||||
"area_px": int(np.count_nonzero(self.mask)) if self.mask is not None else 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _frame_index_from_path(path: Path) -> int | None:
|
|
||||||
matches = re.findall(r"\d+", path.stem)
|
|
||||||
return int(matches[-1]) if matches else None
|
|
||||||
|
|
||||||
|
|
||||||
class DoorMaskSequence:
|
|
||||||
"""Index a directory of X-AnyLabeling JSON polygons or raster masks."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
directory: Path | None = None,
|
|
||||||
mask_format: str = "auto",
|
|
||||||
labels: tuple[str, ...] = DEFAULT_DOOR_LABELS,
|
|
||||||
frame_offset: int = 0,
|
|
||||||
require_every_frame: bool = False,
|
|
||||||
constant_mask: Path | None = None,
|
|
||||||
):
|
|
||||||
if (directory is None) == (constant_mask is None):
|
|
||||||
raise ValueError("Provide exactly one of directory or constant_mask")
|
|
||||||
self.constant_path = (
|
|
||||||
Path(constant_mask).expanduser().resolve()
|
|
||||||
if constant_mask is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
self.directory = (
|
|
||||||
self.constant_path.parent if self.constant_path is not None else Path(directory)
|
|
||||||
)
|
|
||||||
if self.constant_path is not None and not self.constant_path.is_file():
|
|
||||||
raise ValueError(f"Constant door mask does not exist: {self.constant_path}")
|
|
||||||
if self.constant_path is None and not self.directory.is_dir():
|
|
||||||
raise ValueError(f"Door mask directory does not exist: {self.directory}")
|
|
||||||
self.labels = {label.casefold() for label in labels}
|
|
||||||
self.frame_offset = int(frame_offset)
|
|
||||||
self.require_every_frame = require_every_frame
|
|
||||||
|
|
||||||
if self.constant_path is not None:
|
|
||||||
if mask_format == "auto":
|
|
||||||
selected_format = (
|
|
||||||
"xanylabeling"
|
|
||||||
if self.constant_path.suffix.lower() == ".json"
|
|
||||||
else "raster"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
selected_format = mask_format
|
|
||||||
self.source_format = selected_format
|
|
||||||
self.paths = {0: self.constant_path}
|
|
||||||
return
|
|
||||||
|
|
||||||
json_paths = sorted(self.directory.rglob("*.json"))
|
|
||||||
raster_paths = sorted(
|
|
||||||
path
|
|
||||||
for path in self.directory.rglob("*")
|
|
||||||
if path.is_file() and path.suffix.lower() in RASTER_SUFFIXES
|
|
||||||
)
|
|
||||||
if mask_format == "auto":
|
|
||||||
selected_format = "xanylabeling" if json_paths else "raster"
|
|
||||||
else:
|
|
||||||
selected_format = mask_format
|
|
||||||
candidates = json_paths if selected_format == "xanylabeling" else raster_paths
|
|
||||||
if not candidates:
|
|
||||||
expected = "JSON" if selected_format == "xanylabeling" else "PNG/BMP/TIFF"
|
|
||||||
raise ValueError(f"No {expected} door masks found in {self.directory}")
|
|
||||||
|
|
||||||
self.source_format = selected_format
|
|
||||||
self.paths: dict[int, Path] = {}
|
|
||||||
for path in candidates:
|
|
||||||
index = _frame_index_from_path(path)
|
|
||||||
if index is None and selected_format == "xanylabeling":
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
||||||
explicit = data.get("frame_index", data.get("frameIndex"))
|
|
||||||
index = int(explicit) if explicit is not None else None
|
|
||||||
if index is None:
|
|
||||||
continue
|
|
||||||
if index in self.paths:
|
|
||||||
raise ValueError(
|
|
||||||
f"Multiple door masks map to annotation frame {index}: "
|
|
||||||
f"{self.paths[index]} and {path}"
|
|
||||||
)
|
|
||||||
self.paths[index] = path
|
|
||||||
if not self.paths:
|
|
||||||
raise ValueError(
|
|
||||||
"Door mask filenames need a frame number, for example 000000.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
def load(self, video_frame_index: int, frame_shape: tuple[int, int]) -> LoadedDoorMask:
|
|
||||||
if self.constant_path is not None:
|
|
||||||
annotation_index = video_frame_index
|
|
||||||
path = self.constant_path
|
|
||||||
else:
|
|
||||||
annotation_index = video_frame_index + self.frame_offset
|
|
||||||
path = self.paths.get(annotation_index)
|
|
||||||
if path is None:
|
|
||||||
if self.require_every_frame:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"No door mask for video frame {video_frame_index} "
|
|
||||||
f"(expected annotation index {annotation_index})"
|
|
||||||
)
|
|
||||||
return LoadedDoorMask("missing", annotation_index, None, None, self.source_format)
|
|
||||||
|
|
||||||
if self.source_format == "xanylabeling":
|
|
||||||
mask = self._load_xanylabeling(path, frame_shape)
|
|
||||||
else:
|
|
||||||
mask = self._load_raster(path, frame_shape)
|
|
||||||
status = "loaded" if np.any(mask) else "empty"
|
|
||||||
return LoadedDoorMask(status, annotation_index, path, mask, self.source_format)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _load_raster(path: Path, frame_shape: tuple[int, int]) -> np.ndarray:
|
|
||||||
image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
|
||||||
if image is None:
|
|
||||||
raise RuntimeError(f"Cannot read door mask: {path}")
|
|
||||||
if image.ndim == 3:
|
|
||||||
image = np.max(image[:, :, :3], axis=2)
|
|
||||||
height, width = frame_shape
|
|
||||||
if image.shape != (height, width):
|
|
||||||
image = cv2.resize(image, (width, height), interpolation=cv2.INTER_NEAREST)
|
|
||||||
return image > 0
|
|
||||||
|
|
||||||
def _load_xanylabeling(
|
|
||||||
self, path: Path, frame_shape: tuple[int, int]
|
|
||||||
) -> np.ndarray:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
||||||
height, width = frame_shape
|
|
||||||
source_width = float(data.get("imageWidth") or width)
|
|
||||||
source_height = float(data.get("imageHeight") or height)
|
|
||||||
scale = np.asarray(
|
|
||||||
[width / max(source_width, 1.0), height / max(source_height, 1.0)],
|
|
||||||
dtype=np.float32,
|
|
||||||
)
|
|
||||||
mask = np.zeros((height, width), dtype=np.uint8)
|
|
||||||
for shape in data.get("shapes", []):
|
|
||||||
if str(shape.get("label", "")).casefold() not in self.labels:
|
|
||||||
continue
|
|
||||||
points = np.asarray(shape.get("points", []), dtype=np.float32).reshape(-1, 2)
|
|
||||||
if len(points) < 2:
|
|
||||||
continue
|
|
||||||
points = np.rint(points * scale).astype(np.int32)
|
|
||||||
shape_type = str(shape.get("shape_type", "polygon")).lower()
|
|
||||||
if shape_type == "rectangle" and len(points) >= 2:
|
|
||||||
x1, y1 = points[0]
|
|
||||||
x2, y2 = points[1]
|
|
||||||
cv2.rectangle(mask, (x1, y1), (x2, y2), 1, -1)
|
|
||||||
elif shape_type == "circle" and len(points) >= 2:
|
|
||||||
radius = int(round(float(np.linalg.norm(points[1] - points[0]))))
|
|
||||||
cv2.circle(mask, tuple(points[0]), radius, 1, -1)
|
|
||||||
elif len(points) >= 3:
|
|
||||||
cv2.fillPoly(mask, [points], 1)
|
|
||||||
return mask > 0
|
|
||||||
|
|
||||||
|
|
||||||
def _fit_mask(mask: np.ndarray, frame_shape: tuple[int, int]) -> np.ndarray:
|
|
||||||
height, width = frame_shape
|
|
||||||
result = np.asarray(mask) > 0
|
|
||||||
if result.shape != (height, width):
|
|
||||||
result = cv2.resize(
|
|
||||||
result.astype(np.uint8), (width, height), interpolation=cv2.INTER_NEAREST
|
|
||||||
) > 0
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _box_mask(box: list[float] | None, frame_shape: tuple[int, int]) -> np.ndarray | None:
|
|
||||||
if box is None or len(box) < 4:
|
|
||||||
return None
|
|
||||||
height, width = frame_shape
|
|
||||||
x1, y1, x2, y2 = [int(round(float(value))) for value in box[:4]]
|
|
||||||
x1, x2 = sorted((max(0, min(width - 1, x1)), max(0, min(width - 1, x2))))
|
|
||||||
y1, y2 = sorted((max(0, min(height - 1, y1)), max(0, min(height - 1, y2))))
|
|
||||||
if x2 <= x1 or y2 <= y1:
|
|
||||||
return None
|
|
||||||
mask = np.zeros((height, width), dtype=bool)
|
|
||||||
mask[y1 : y2 + 1, x1 : x2 + 1] = True
|
|
||||||
return mask
|
|
||||||
|
|
||||||
|
|
||||||
def _hand_mask(points: list, frame_shape: tuple[int, int]) -> np.ndarray | None:
|
|
||||||
hand = np.asarray(points, dtype=np.float32).reshape(-1, 2)
|
|
||||||
if len(hand) < 3 or not np.isfinite(hand).all():
|
|
||||||
return None
|
|
||||||
hull = cv2.convexHull(np.rint(hand).astype(np.int32))
|
|
||||||
mask = np.zeros(frame_shape, dtype=np.uint8)
|
|
||||||
cv2.fillConvexPoly(mask, hull, 1)
|
|
||||||
return mask > 0 if np.any(mask) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _decision(distance_relative: float, overlap_px: int, warning: float, release: float) -> tuple[str, str]:
|
|
||||||
if overlap_px > 0:
|
|
||||||
return "danger", "DANGER_INTERSECTION"
|
|
||||||
if distance_relative <= warning:
|
|
||||||
return "warning", "WARNING_NEAR"
|
|
||||||
if distance_relative <= release:
|
|
||||||
return "watch", "WATCH"
|
|
||||||
return "clear", "CLEAR"
|
|
||||||
|
|
||||||
|
|
||||||
class DoorRiskEvaluator:
|
|
||||||
"""Score RTMW hand regions, with one YOLO body-box fallback per person."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
sequence: DoorMaskSequence,
|
|
||||||
warning_relative: float = 0.30,
|
|
||||||
release_relative: float = 0.45,
|
|
||||||
):
|
|
||||||
if warning_relative < 0 or release_relative <= warning_relative:
|
|
||||||
raise ValueError("Risk thresholds require 0 <= warning < release")
|
|
||||||
self.sequence = sequence
|
|
||||||
self.warning_relative = float(warning_relative)
|
|
||||||
self.release_relative = float(release_relative)
|
|
||||||
self.mask_status_counts: Counter = Counter()
|
|
||||||
self.level_counts: Counter = Counter()
|
|
||||||
self.source_counts: Counter = Counter()
|
|
||||||
self.maximum_score = 0.0
|
|
||||||
self.timing_seconds: Counter = Counter()
|
|
||||||
self.timing_counts: Counter = Counter()
|
|
||||||
self._constant_cache_shape: tuple[int, int] | None = None
|
|
||||||
self._constant_loaded: LoadedDoorMask | None = None
|
|
||||||
self._constant_distance_map: np.ndarray | None = None
|
|
||||||
self.distance_map_cache_builds = 0
|
|
||||||
|
|
||||||
def _record_timing(self, name: str, started: float) -> None:
|
|
||||||
self.timing_seconds[name] += time.perf_counter() - started
|
|
||||||
self.timing_counts[name] += 1
|
|
||||||
|
|
||||||
def _measure_region(
|
|
||||||
self,
|
|
||||||
region: np.ndarray,
|
|
||||||
door_mask: np.ndarray,
|
|
||||||
distance_map: np.ndarray,
|
|
||||||
source: str,
|
|
||||||
track_id: int,
|
|
||||||
side: str,
|
|
||||||
state: str | None,
|
|
||||||
) -> dict:
|
|
||||||
area_px = int(np.count_nonzero(region))
|
|
||||||
overlap_px = int(np.count_nonzero(region & door_mask))
|
|
||||||
distances = distance_map[region]
|
|
||||||
distance_px = float(np.min(distances)) if len(distances) else math.inf
|
|
||||||
scale_px = max(math.sqrt(area_px), 1.0)
|
|
||||||
distance_relative = distance_px / scale_px
|
|
||||||
score = float(np.clip(1.0 - distance_relative / self.release_relative, 0.0, 1.0))
|
|
||||||
if overlap_px:
|
|
||||||
score = 1.0
|
|
||||||
level, decision = _decision(
|
|
||||||
distance_relative,
|
|
||||||
overlap_px,
|
|
||||||
self.warning_relative,
|
|
||||||
self.release_relative,
|
|
||||||
)
|
|
||||||
result = {
|
|
||||||
"status": "calculated",
|
|
||||||
"risk_score": round(score, 5),
|
|
||||||
"risk_level": level,
|
|
||||||
"decision": decision,
|
|
||||||
"source": source,
|
|
||||||
"track_id": int(track_id),
|
|
||||||
"side": side,
|
|
||||||
"state": state,
|
|
||||||
"region_area_px": area_px,
|
|
||||||
"door_overlap_px": overlap_px,
|
|
||||||
"region_overlap_ratio": round(overlap_px / max(area_px, 1), 6),
|
|
||||||
"min_distance_px": round(distance_px, 3),
|
|
||||||
"region_scale_px": round(scale_px, 3),
|
|
||||||
"distance_relative": round(distance_relative, 6),
|
|
||||||
}
|
|
||||||
self.level_counts[level] += 1
|
|
||||||
self.source_counts[source] += 1
|
|
||||||
self.maximum_score = max(self.maximum_score, score)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _unavailable(source: str, track_id: int, side: str, status: str) -> dict:
|
|
||||||
return {
|
|
||||||
"status": status,
|
|
||||||
"risk_score": None,
|
|
||||||
"risk_level": "unavailable",
|
|
||||||
"decision": "EVIDENCE_UNAVAILABLE",
|
|
||||||
"source": source,
|
|
||||||
"track_id": int(track_id),
|
|
||||||
"side": side,
|
|
||||||
}
|
|
||||||
|
|
||||||
def evaluate(
|
|
||||||
self,
|
|
||||||
frame_index: int,
|
|
||||||
record: dict,
|
|
||||||
frame_shape: tuple[int, int],
|
|
||||||
) -> tuple[dict, np.ndarray | None]:
|
|
||||||
total_started = time.perf_counter()
|
|
||||||
stage_started = time.perf_counter()
|
|
||||||
use_constant_cache = self.sequence.constant_path is not None
|
|
||||||
if use_constant_cache and self._constant_cache_shape == frame_shape:
|
|
||||||
cached = self._constant_loaded
|
|
||||||
assert cached is not None
|
|
||||||
loaded = LoadedDoorMask(
|
|
||||||
cached.status,
|
|
||||||
frame_index,
|
|
||||||
cached.path,
|
|
||||||
cached.mask,
|
|
||||||
cached.source_format,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
loaded = self.sequence.load(frame_index, frame_shape)
|
|
||||||
if use_constant_cache:
|
|
||||||
self._constant_cache_shape = frame_shape
|
|
||||||
self._constant_loaded = loaded
|
|
||||||
self._constant_distance_map = None
|
|
||||||
self._record_timing("door_mask_load", stage_started)
|
|
||||||
self.mask_status_counts[loaded.status] += 1
|
|
||||||
mask_ready = loaded.status == "loaded" and loaded.mask is not None
|
|
||||||
door_mask = loaded.mask if mask_ready else None
|
|
||||||
distance_map = None
|
|
||||||
if door_mask is not None and use_constant_cache and self._constant_distance_map is not None:
|
|
||||||
distance_map = self._constant_distance_map
|
|
||||||
elif door_mask is not None:
|
|
||||||
stage_started = time.perf_counter()
|
|
||||||
distance_map = cv2.distanceTransform(
|
|
||||||
(~door_mask).astype(np.uint8), cv2.DIST_L2, cv2.DIST_MASK_PRECISE
|
|
||||||
)
|
|
||||||
self._record_timing("distance_transform", stage_started)
|
|
||||||
if use_constant_cache:
|
|
||||||
self._constant_distance_map = distance_map
|
|
||||||
self.distance_map_cache_builds += 1
|
|
||||||
|
|
||||||
stage_started = time.perf_counter()
|
|
||||||
regions: list[tuple[dict, np.ndarray, str, int, str, str | None]] = []
|
|
||||||
track_ids = [int(value) for value in record.get("track_ids", [])]
|
|
||||||
person_positions_with_hands: set[int] = set()
|
|
||||||
for hand in record.get("global_hands", []):
|
|
||||||
position = int(hand.get("person_id", 0)) - 1
|
|
||||||
track_id = track_ids[position] if 0 <= position < len(track_ids) else position + 1
|
|
||||||
side = str(hand.get("side", "unknown"))
|
|
||||||
region = _hand_mask(hand.get("keypoints", []), frame_shape)
|
|
||||||
if region is None:
|
|
||||||
continue
|
|
||||||
person_positions_with_hands.add(position)
|
|
||||||
regions.append(
|
|
||||||
(
|
|
||||||
hand,
|
|
||||||
region,
|
|
||||||
"RTMW_HAND_KEYPOINTS",
|
|
||||||
track_id,
|
|
||||||
side,
|
|
||||||
str(hand.get("state", "unknown")),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
boxes = record.get("boxes", [])
|
|
||||||
body_fallbacks = []
|
|
||||||
for position, box in enumerate(boxes):
|
|
||||||
if position in person_positions_with_hands:
|
|
||||||
continue
|
|
||||||
region = _box_mask(box, frame_shape)
|
|
||||||
if region is None:
|
|
||||||
continue
|
|
||||||
track_id = track_ids[position] if position < len(track_ids) else position + 1
|
|
||||||
fallback = {
|
|
||||||
"track_id": track_id,
|
|
||||||
"side": "body",
|
|
||||||
"state": "rtmw_hand_unavailable",
|
|
||||||
"body_zone_box": box,
|
|
||||||
}
|
|
||||||
body_fallbacks.append(fallback)
|
|
||||||
regions.append(
|
|
||||||
(
|
|
||||||
fallback,
|
|
||||||
region,
|
|
||||||
"YOLO_BODY_BOX_FALLBACK",
|
|
||||||
track_id,
|
|
||||||
"body",
|
|
||||||
"rtmw_hand_unavailable",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
record["body_fallbacks"] = body_fallbacks
|
|
||||||
self._record_timing("region_build", stage_started)
|
|
||||||
|
|
||||||
stage_started = time.perf_counter()
|
|
||||||
measurements = []
|
|
||||||
unavailable_status = f"door_mask_{loaded.status}"
|
|
||||||
for owner, region, source, track_id, side, state in regions:
|
|
||||||
if door_mask is None or distance_map is None:
|
|
||||||
measurement = self._unavailable(
|
|
||||||
source, track_id, side, unavailable_status
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
measurement = self._measure_region(
|
|
||||||
region, door_mask, distance_map, source, track_id, side, state
|
|
||||||
)
|
|
||||||
owner["risk"] = measurement
|
|
||||||
measurements.append(measurement)
|
|
||||||
self._record_timing("region_measure", stage_started)
|
|
||||||
|
|
||||||
calculated = [item for item in measurements if item["risk_score"] is not None]
|
|
||||||
nearest = max(calculated, key=lambda item: item["risk_score"]) if calculated else None
|
|
||||||
if loaded.status != "loaded":
|
|
||||||
status = unavailable_status
|
|
||||||
elif not calculated:
|
|
||||||
status = "person_region_missing"
|
|
||||||
else:
|
|
||||||
status = "calculated"
|
|
||||||
frame_risk = {
|
|
||||||
"status": status,
|
|
||||||
"risk_score": nearest["risk_score"] if nearest else None,
|
|
||||||
"risk_level": nearest["risk_level"] if nearest else "unavailable",
|
|
||||||
"decision": nearest["decision"] if nearest else "EVIDENCE_UNAVAILABLE",
|
|
||||||
"source": nearest["source"] if nearest else None,
|
|
||||||
"track_id": nearest["track_id"] if nearest else None,
|
|
||||||
"side": nearest["side"] if nearest else None,
|
|
||||||
"door_mask": loaded.metadata(),
|
|
||||||
"warning_distance_relative": self.warning_relative,
|
|
||||||
"release_distance_relative": self.release_relative,
|
|
||||||
"regions": measurements,
|
|
||||||
}
|
|
||||||
self._record_timing("risk_total", total_started)
|
|
||||||
return frame_risk, loaded.mask
|
|
||||||
|
|
||||||
def summary(self) -> dict:
|
|
||||||
return {
|
|
||||||
"mask_directory": str(self.sequence.directory.resolve()),
|
|
||||||
"constant_mask": (
|
|
||||||
str(self.sequence.constant_path)
|
|
||||||
if self.sequence.constant_path is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"constant_for_all_frames": self.sequence.constant_path is not None,
|
|
||||||
"mask_format": self.sequence.source_format,
|
|
||||||
"door_labels": sorted(self.sequence.labels),
|
|
||||||
"frame_offset": self.sequence.frame_offset,
|
|
||||||
"warning_distance_relative": self.warning_relative,
|
|
||||||
"release_distance_relative": self.release_relative,
|
|
||||||
"mask_status_counts": dict(self.mask_status_counts),
|
|
||||||
"risk_level_counts": dict(self.level_counts),
|
|
||||||
"source_counts": dict(self.source_counts),
|
|
||||||
"maximum_risk_score": round(self.maximum_score, 5),
|
|
||||||
"constant_distance_map_cached": self.sequence.constant_path is not None,
|
|
||||||
"distance_map_cache_builds": self.distance_map_cache_builds,
|
|
||||||
"timing_ms": {
|
|
||||||
name: {
|
|
||||||
"count": int(self.timing_counts[name]),
|
|
||||||
"total": round(float(seconds) * 1000.0, 3),
|
|
||||||
"mean": round(
|
|
||||||
float(seconds) * 1000.0
|
|
||||||
/ max(int(self.timing_counts[name]), 1),
|
|
||||||
3,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for name, seconds in self.timing_seconds.items()
|
|
||||||
},
|
|
||||||
"score_semantics": "2-D image overlap/proximity research score; not millimeters or safety certification",
|
|
||||||
"region_policy": "RTMW alarm-eligible hand hulls; YOLO body box only when a person has no usable RTMW hand region",
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
"""Multi-feature person-to-door assignment and active-CAN risk gating."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
from collections import Counter, defaultdict, deque
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from statistics import median
|
|
||||||
|
|
||||||
|
|
||||||
DOORS = ("front", "rear")
|
|
||||||
POSE_FEATURES = {
|
|
||||||
"shoulder_center_x",
|
|
||||||
"shoulder_center_y",
|
|
||||||
"shoulder_width",
|
|
||||||
"torso_height",
|
|
||||||
"hip_center_y",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FeatureCalibration:
|
|
||||||
rear: float
|
|
||||||
front: float
|
|
||||||
weight: float
|
|
||||||
|
|
||||||
def frontness(self, value: float) -> float:
|
|
||||||
span = self.front - self.rear
|
|
||||||
if span == 0:
|
|
||||||
return 0.5
|
|
||||||
return min(1.0, max(0.0, (value - self.rear) / span))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DoorZoneConfig:
|
|
||||||
features: dict[str, FeatureCalibration]
|
|
||||||
front_probability_min: float
|
|
||||||
rear_probability_max: float
|
|
||||||
minimum_person_height: float
|
|
||||||
minimum_pose_features: int
|
|
||||||
history_frames: int
|
|
||||||
stale_track_frames: int
|
|
||||||
source_path: Path | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load(cls, path: Path) -> "DoorZoneConfig":
|
|
||||||
source = Path(path).expanduser().resolve()
|
|
||||||
data = json.loads(source.read_text(encoding="utf-8-sig"))
|
|
||||||
features = {
|
|
||||||
name: FeatureCalibration(
|
|
||||||
rear=float(values["rear"]),
|
|
||||||
front=float(values["front"]),
|
|
||||||
weight=float(values["weight"]),
|
|
||||||
)
|
|
||||||
for name, values in data["features"].items()
|
|
||||||
}
|
|
||||||
return cls(
|
|
||||||
features=features,
|
|
||||||
front_probability_min=float(data.get("front_probability_min", 0.72)),
|
|
||||||
rear_probability_max=float(data.get("rear_probability_max", 0.28)),
|
|
||||||
minimum_person_height=float(data.get("minimum_person_height", 0.40)),
|
|
||||||
minimum_pose_features=int(data.get("minimum_pose_features", 4)),
|
|
||||||
history_frames=int(data.get("history_frames", 5)),
|
|
||||||
stale_track_frames=int(data.get("stale_track_frames", 120)),
|
|
||||||
source_path=source,
|
|
||||||
)
|
|
||||||
|
|
||||||
def classify(self, probability: float) -> str:
|
|
||||||
if probability >= self.front_probability_min:
|
|
||||||
return "front"
|
|
||||||
if probability <= self.rear_probability_max:
|
|
||||||
return "rear"
|
|
||||||
return "transition"
|
|
||||||
|
|
||||||
|
|
||||||
def _point(keypoints: list, index: int) -> tuple[float, float] | None:
|
|
||||||
if index >= len(keypoints) or len(keypoints[index]) < 2:
|
|
||||||
return None
|
|
||||||
x, y = float(keypoints[index][0]), float(keypoints[index][1])
|
|
||||||
return (x, y) if math.isfinite(x) and math.isfinite(y) else None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_person_features(
|
|
||||||
record: dict,
|
|
||||||
position: int,
|
|
||||||
frame_shape: tuple[int, int],
|
|
||||||
) -> tuple[dict[str, float], int]:
|
|
||||||
"""Extract fixed-camera pose features, with box geometry as low-weight fallback."""
|
|
||||||
|
|
||||||
height, width = frame_shape
|
|
||||||
box = record.get("boxes", [])[position]
|
|
||||||
x1, y1, x2, y2 = [float(value) for value in box[:4]]
|
|
||||||
features = {
|
|
||||||
"box_center_x": ((x1 + x2) * 0.5) / max(width, 1),
|
|
||||||
"box_height": (y2 - y1) / max(height, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
all_keypoints = record.get("temporal_selected_keypoints", [])
|
|
||||||
if position >= len(all_keypoints):
|
|
||||||
return features, 0
|
|
||||||
keypoints = all_keypoints[position]
|
|
||||||
left_shoulder = _point(keypoints, 5)
|
|
||||||
right_shoulder = _point(keypoints, 6)
|
|
||||||
left_hip = _point(keypoints, 11)
|
|
||||||
right_hip = _point(keypoints, 12)
|
|
||||||
if None in (left_shoulder, right_shoulder, left_hip, right_hip):
|
|
||||||
return features, 0
|
|
||||||
|
|
||||||
shoulder_x = (left_shoulder[0] + right_shoulder[0]) * 0.5
|
|
||||||
shoulder_y = (left_shoulder[1] + right_shoulder[1]) * 0.5
|
|
||||||
hip_y = (left_hip[1] + right_hip[1]) * 0.5
|
|
||||||
features.update(
|
|
||||||
{
|
|
||||||
"shoulder_center_x": shoulder_x / max(width, 1),
|
|
||||||
"shoulder_center_y": shoulder_y / max(height, 1),
|
|
||||||
"shoulder_width": math.hypot(
|
|
||||||
left_shoulder[0] - right_shoulder[0],
|
|
||||||
left_shoulder[1] - right_shoulder[1],
|
|
||||||
)
|
|
||||||
/ max(width, 1),
|
|
||||||
"torso_height": abs(hip_y - shoulder_y) / max(height, 1),
|
|
||||||
"hip_center_y": hip_y / max(height, 1),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return features, len(POSE_FEATURES)
|
|
||||||
|
|
||||||
|
|
||||||
class DoorZoneAssigner:
|
|
||||||
"""Fuse pose-dominant evidence and smooth front probability per track."""
|
|
||||||
|
|
||||||
def __init__(self, config: DoorZoneConfig):
|
|
||||||
self.config = config
|
|
||||||
self.history: dict[int, deque[float]] = defaultdict(
|
|
||||||
lambda: deque(maxlen=self.config.history_frames)
|
|
||||||
)
|
|
||||||
self.last_seen: dict[int, int] = {}
|
|
||||||
self.zone_counts: Counter = Counter()
|
|
||||||
|
|
||||||
def _score(self, features: dict[str, float]) -> tuple[float, dict[str, float]]:
|
|
||||||
weighted_sum = 0.0
|
|
||||||
total_weight = 0.0
|
|
||||||
contributions = {}
|
|
||||||
for name, calibration in self.config.features.items():
|
|
||||||
if name not in features:
|
|
||||||
continue
|
|
||||||
frontness = calibration.frontness(float(features[name]))
|
|
||||||
contributions[name] = round(frontness, 6)
|
|
||||||
weighted_sum += frontness * calibration.weight
|
|
||||||
total_weight += calibration.weight
|
|
||||||
probability = weighted_sum / total_weight if total_weight else 0.5
|
|
||||||
return probability, contributions
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
frame_index: int,
|
|
||||||
record: dict,
|
|
||||||
frame_shape: tuple[int, int],
|
|
||||||
) -> list[dict]:
|
|
||||||
track_ids = [int(value) for value in record.get("track_ids", [])]
|
|
||||||
assignments = []
|
|
||||||
for position, _box in enumerate(record.get("boxes", [])):
|
|
||||||
track_id = track_ids[position] if position < len(track_ids) else position + 1
|
|
||||||
features, pose_feature_count = extract_person_features(
|
|
||||||
record, position, frame_shape
|
|
||||||
)
|
|
||||||
probability, contributions = self._score(features)
|
|
||||||
enough_pose = pose_feature_count >= self.config.minimum_pose_features
|
|
||||||
if features["box_height"] < self.config.minimum_person_height:
|
|
||||||
raw_zone = "unassigned"
|
|
||||||
door_zone = "unassigned"
|
|
||||||
elif not enough_pose:
|
|
||||||
raw_zone = "transition"
|
|
||||||
door_zone = "transition"
|
|
||||||
else:
|
|
||||||
raw_zone = self.config.classify(probability)
|
|
||||||
values = self.history[track_id]
|
|
||||||
values.append(probability)
|
|
||||||
door_zone = self.config.classify(float(median(values)))
|
|
||||||
|
|
||||||
values = self.history.get(track_id, ())
|
|
||||||
smoothed_probability = (
|
|
||||||
float(median(values)) if values else float(probability)
|
|
||||||
)
|
|
||||||
self.last_seen[track_id] = int(frame_index)
|
|
||||||
self.zone_counts[door_zone] += 1
|
|
||||||
assignments.append(
|
|
||||||
{
|
|
||||||
"track_id": track_id,
|
|
||||||
"person_index": position,
|
|
||||||
"door_zone": door_zone,
|
|
||||||
"raw_door_zone": raw_zone,
|
|
||||||
"front_probability": round(float(probability), 6),
|
|
||||||
"smoothed_front_probability": round(smoothed_probability, 6),
|
|
||||||
"confidence": round(abs(smoothed_probability - 0.5) * 2.0, 6),
|
|
||||||
"evidence_source": "pose_primary_bbox_secondary",
|
|
||||||
"pose_feature_count": pose_feature_count,
|
|
||||||
"features": {name: round(value, 6) for name, value in features.items()},
|
|
||||||
"feature_frontness": contributions,
|
|
||||||
"history_frames": len(values),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
expired = [
|
|
||||||
track_id
|
|
||||||
for track_id, seen_at in self.last_seen.items()
|
|
||||||
if frame_index - seen_at > self.config.stale_track_frames
|
|
||||||
]
|
|
||||||
for track_id in expired:
|
|
||||||
self.history.pop(track_id, None)
|
|
||||||
self.last_seen.pop(track_id, None)
|
|
||||||
return assignments
|
|
||||||
|
|
||||||
def summary(self) -> dict:
|
|
||||||
return {
|
|
||||||
"policy": "pose-primary weighted front probability with causal median",
|
|
||||||
"config": str(self.config.source_path) if self.config.source_path else None,
|
|
||||||
"history_frames": self.config.history_frames,
|
|
||||||
"front_probability_min": self.config.front_probability_min,
|
|
||||||
"rear_probability_max": self.config.rear_probability_max,
|
|
||||||
"minimum_person_height": self.config.minimum_person_height,
|
|
||||||
"minimum_pose_features": self.config.minimum_pose_features,
|
|
||||||
"features": {
|
|
||||||
name: {
|
|
||||||
"rear": calibration.rear,
|
|
||||||
"front": calibration.front,
|
|
||||||
"weight": calibration.weight,
|
|
||||||
}
|
|
||||||
for name, calibration in self.config.features.items()
|
|
||||||
},
|
|
||||||
"zone_counts": dict(self.zone_counts),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _inactive_risk(door: str) -> dict:
|
|
||||||
return {
|
|
||||||
"door_id": door,
|
|
||||||
"status": "inactive",
|
|
||||||
"risk_score": None,
|
|
||||||
"risk_level": "inactive",
|
|
||||||
"decision": "DOOR_CAN_NOT_ACTIVE",
|
|
||||||
"risk_type": f"{door.upper()}_INACTIVE",
|
|
||||||
"source": None,
|
|
||||||
"track_id": None,
|
|
||||||
"side": None,
|
|
||||||
"regions": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_risk(door: str) -> dict:
|
|
||||||
return {
|
|
||||||
"door_id": door,
|
|
||||||
"status": "calculated",
|
|
||||||
"risk_score": 0.0,
|
|
||||||
"risk_level": "clear",
|
|
||||||
"decision": "CLEAR_NO_PERSON_ASSIGNED_TO_ACTIVE_DOOR",
|
|
||||||
"risk_type": f"{door.upper()}_CLEAR",
|
|
||||||
"source": None,
|
|
||||||
"track_id": None,
|
|
||||||
"side": None,
|
|
||||||
"regions": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _unknown_risk(door: str, transition_regions: list[dict]) -> dict:
|
|
||||||
return {
|
|
||||||
"door_id": door,
|
|
||||||
"status": "door_zone_ambiguous",
|
|
||||||
"risk_score": None,
|
|
||||||
"risk_level": "unknown",
|
|
||||||
"decision": "EVIDENCE_UNAVAILABLE_TRANSITION_ZONE",
|
|
||||||
"risk_type": f"{door.upper()}_UNKNOWN",
|
|
||||||
"source": None,
|
|
||||||
"track_id": None,
|
|
||||||
"side": None,
|
|
||||||
"regions": transition_regions,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_door_risks(
|
|
||||||
raw_risk: dict,
|
|
||||||
assignments: list[dict],
|
|
||||||
active_door: str,
|
|
||||||
) -> tuple[dict[str, dict], dict]:
|
|
||||||
"""Build per-door risk and select the CAN-active door result."""
|
|
||||||
|
|
||||||
zone_by_track = {
|
|
||||||
int(assignment["track_id"]): str(assignment["door_zone"])
|
|
||||||
for assignment in assignments
|
|
||||||
}
|
|
||||||
annotated_regions = []
|
|
||||||
for original in raw_risk.get("regions", []):
|
|
||||||
region = dict(original)
|
|
||||||
region["door_zone"] = zone_by_track.get(int(region.get("track_id", -1)), "unknown")
|
|
||||||
annotated_regions.append(region)
|
|
||||||
|
|
||||||
active_doors = set(DOORS) if active_door == "both" else {active_door}
|
|
||||||
if active_door == "none":
|
|
||||||
active_doors.clear()
|
|
||||||
|
|
||||||
door_risks: dict[str, dict] = {}
|
|
||||||
for door in DOORS:
|
|
||||||
if door not in active_doors:
|
|
||||||
door_risks[door] = _inactive_risk(door)
|
|
||||||
continue
|
|
||||||
exact = [region for region in annotated_regions if region["door_zone"] == door]
|
|
||||||
transition = [
|
|
||||||
region for region in annotated_regions if region["door_zone"] == "transition"
|
|
||||||
]
|
|
||||||
calculated = [region for region in exact if region.get("risk_score") is not None]
|
|
||||||
if calculated:
|
|
||||||
nearest = max(calculated, key=lambda item: float(item["risk_score"]))
|
|
||||||
level = str(nearest["risk_level"])
|
|
||||||
if transition and level in ("clear", "watch"):
|
|
||||||
door_risks[door] = _unknown_risk(door, transition)
|
|
||||||
else:
|
|
||||||
door_risks[door] = {
|
|
||||||
"door_id": door,
|
|
||||||
"status": "calculated",
|
|
||||||
"risk_score": nearest["risk_score"],
|
|
||||||
"risk_level": level,
|
|
||||||
"decision": nearest["decision"],
|
|
||||||
"risk_type": f"{door.upper()}_{level.upper()}",
|
|
||||||
"source": nearest["source"],
|
|
||||||
"track_id": nearest["track_id"],
|
|
||||||
"side": nearest["side"],
|
|
||||||
"regions": exact,
|
|
||||||
}
|
|
||||||
elif transition:
|
|
||||||
door_risks[door] = _unknown_risk(door, transition)
|
|
||||||
else:
|
|
||||||
door_risks[door] = _clear_risk(door)
|
|
||||||
|
|
||||||
if len(active_doors) == 1:
|
|
||||||
selected = dict(door_risks[next(iter(active_doors))])
|
|
||||||
elif not active_doors:
|
|
||||||
selected = {
|
|
||||||
"door_id": None,
|
|
||||||
"status": "inactive",
|
|
||||||
"risk_score": None,
|
|
||||||
"risk_level": "inactive",
|
|
||||||
"decision": "NO_DOOR_CAN_ACTIVE",
|
|
||||||
"risk_type": "NO_ACTIVE_DOOR",
|
|
||||||
"source": None,
|
|
||||||
"track_id": None,
|
|
||||||
"side": None,
|
|
||||||
"regions": [],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
severity = {"danger": 4, "warning": 3, "watch": 2, "unknown": 1, "clear": 0}
|
|
||||||
selected = dict(
|
|
||||||
max(door_risks.values(), key=lambda item: severity.get(item["risk_level"], -1))
|
|
||||||
)
|
|
||||||
selected["door_mask"] = raw_risk.get("door_mask")
|
|
||||||
selected["warning_distance_relative"] = raw_risk.get("warning_distance_relative")
|
|
||||||
selected["release_distance_relative"] = raw_risk.get("release_distance_relative")
|
|
||||||
return door_risks, selected
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""One owned, immutable latest result, mirroring the 0920 LatestResult contract."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
import threading
|
|
||||||
from types import MappingProxyType
|
|
||||||
|
|
||||||
|
|
||||||
def freeze(value):
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return MappingProxyType({key: freeze(item) for key, item in value.items()})
|
|
||||||
if isinstance(value, (list, tuple)):
|
|
||||||
return tuple(freeze(item) for item in value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def result_to_dict(value):
|
|
||||||
"""Return a mutable JSON-compatible copy when a consumer needs serialization."""
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return {key: result_to_dict(item) for key, item in value.items()}
|
|
||||||
if isinstance(value, tuple):
|
|
||||||
return [result_to_dict(item) for item in value]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class LatestResult:
|
|
||||||
"""Readers share a stable snapshot; reading never removes or acknowledges it.
|
|
||||||
|
|
||||||
Only the latest result is retained here. Slow readers can skip frames, and
|
|
||||||
references they already hold remain valid after subsequent publications.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._condition = threading.Condition()
|
|
||||||
self._value = None
|
|
||||||
self._closed = False
|
|
||||||
|
|
||||||
def publish(self, result):
|
|
||||||
if result is None:
|
|
||||||
raise ValueError("Cannot publish a null result")
|
|
||||||
snapshot = freeze(result)
|
|
||||||
with self._condition:
|
|
||||||
if self._closed:
|
|
||||||
raise RuntimeError("Result publisher is closed")
|
|
||||||
self._value = snapshot
|
|
||||||
self._condition.notify_all()
|
|
||||||
|
|
||||||
def load(self):
|
|
||||||
with self._condition:
|
|
||||||
return self._value
|
|
||||||
|
|
||||||
def wait(self, after_frame_index=-1, timeout=None):
|
|
||||||
"""Wait for a newer result; return None on timeout/end without a new frame."""
|
|
||||||
with self._condition:
|
|
||||||
def has_new_frame():
|
|
||||||
return self._value is not None and self._value['frame_index'] > after_frame_index
|
|
||||||
|
|
||||||
self._condition.wait_for(lambda: has_new_frame() or self._closed, timeout)
|
|
||||||
return self._value if has_new_frame() else None
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
with self._condition:
|
|
||||||
self._closed = True
|
|
||||||
self._condition.notify_all()
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Native Jetson TensorRT adapter; temporal/risk policy stays in upstream Python."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import ctypes as C
|
|
||||||
from pathlib import Path
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
|
|
||||||
class NativeBackend:
|
|
||||||
def __init__(self, args):
|
|
||||||
self.lib = C.CDLL(str(ROOT / 'build/libvision_native.so'))
|
|
||||||
pointer = C.c_void_p
|
|
||||||
signatures = {
|
|
||||||
'vap_error': ([], C.c_char_p),
|
|
||||||
'vap_engine': ([C.c_char_p,C.c_int,C.c_int,C.c_int,C.c_float],pointer),
|
|
||||||
'vap_engine_close': ([pointer],None),
|
|
||||||
'vap_detect': ([pointer,pointer,C.c_int,C.c_int,pointer],C.c_int),
|
|
||||||
'vap_pose': ([pointer,pointer,C.c_int,C.c_int,pointer,C.c_int,pointer],C.c_int),
|
|
||||||
'vap_camera': ([C.c_char_p,C.c_int,C.c_int,C.c_int,C.c_int],pointer),
|
|
||||||
'vap_camera_close': ([pointer],None),
|
|
||||||
'vap_read': ([pointer,pointer,pointer],C.c_int),
|
|
||||||
'vap_can': ([pointer],None),
|
|
||||||
}
|
|
||||||
for name,(inputs,output) in signatures.items():
|
|
||||||
fn=getattr(self.lib,name);fn.argtypes=inputs;fn.restype=output
|
|
||||||
self.engine = self.lib.vap_engine(str(args.engines.resolve()).encode(),args.width,args.height,args.max_people,args.det_conf)
|
|
||||||
if not self.engine: self.fail()
|
|
||||||
self.camera=None
|
|
||||||
self.args=args
|
|
||||||
|
|
||||||
def fail(self):
|
|
||||||
raise RuntimeError(self.lib.vap_error().decode())
|
|
||||||
|
|
||||||
def open_camera(self):
|
|
||||||
a=self.args
|
|
||||||
self.camera=self.lib.vap_camera(a.camera.encode(),a.width,a.height,a.fps,not a.no_can)
|
|
||||||
if not self.camera:self.fail()
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
image=np.empty((self.args.height,self.args.width,3),np.uint8)
|
|
||||||
meta=np.zeros(4,np.float64)
|
|
||||||
result=self.lib.vap_read(self.camera,image.ctypes.data,meta.ctypes.data)
|
|
||||||
if result<0:self.fail()
|
|
||||||
if result==0:return None,None
|
|
||||||
return image,{'sequence':int(meta[0]),'capture_monotonic_ms':float(meta[1]),'dropped_before_inference':int(meta[2]),'driver_timestamp_ms':float(meta[3]),'latency_clock':'host_monotonic_at_dequeue'}
|
|
||||||
|
|
||||||
def can_snapshot(self):
|
|
||||||
data=np.zeros(5,np.float64);self.lib.vap_can(data.ctypes.data)
|
|
||||||
return {'positions':[int(x) for x in data[:4]],'age_ms':float(data[4])}
|
|
||||||
|
|
||||||
def detect(self, frame):
|
|
||||||
frame=np.ascontiguousarray(frame);out=np.empty((self.args.max_people,5),np.float32)
|
|
||||||
n=self.lib.vap_detect(self.engine,frame.ctypes.data,frame.shape[1],frame.shape[0],out.ctypes.data)
|
|
||||||
if n<0:self.fail()
|
|
||||||
return out[:n,:4].tolist(),out[:n,4].tolist()
|
|
||||||
|
|
||||||
def pose(self,frame,bboxes):
|
|
||||||
frame=np.ascontiguousarray(frame);boxes=np.ascontiguousarray(bboxes,dtype=np.float32)
|
|
||||||
out=np.empty((len(boxes),133,3),np.float32)
|
|
||||||
n=self.lib.vap_pose(self.engine,frame.ctypes.data,frame.shape[1],frame.shape[0],boxes.ctypes.data,len(boxes),out.ctypes.data)
|
|
||||||
if n<0:self.fail()
|
|
||||||
return out[:n,:,:2].astype(np.float64),out[:n,:,2].astype(np.float64)
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self.camera:self.lib.vap_camera_close(self.camera);self.camera=None
|
|
||||||
if self.engine:self.lib.vap_engine_close(self.engine);self.engine=None
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"""Resident native TensorRT inference with causal pose fusion and tracking."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import time
|
|
||||||
from collections import defaultdict, deque
|
|
||||||
import numpy as np
|
|
||||||
from pose_tracking import BoxTracker
|
|
||||||
|
|
||||||
SIDES = ("left", "right")
|
|
||||||
|
|
||||||
class NativePoseEngine:
|
|
||||||
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
|
|
||||||
|
|
||||||
def infer(self, frame: np.ndarray, frame_idx: int) -> dict:
|
|
||||||
from pose_geometry import HAND_SLICES
|
|
||||||
from pose_fusion import adaptive_smooth, build_global_hands, fuse_person
|
|
||||||
from pose_occlusion import Measurement, resolve_global_hand_assignments
|
|
||||||
from pose_tracking import (
|
|
||||||
LivePersonState,
|
|
||||||
mask_other_people,
|
|
||||||
update_live_v2,
|
|
||||||
)
|
|
||||||
|
|
||||||
infer_started = time.perf_counter()
|
|
||||||
height, width = frame.shape[:2]
|
|
||||||
started = time.perf_counter()
|
|
||||||
detection_height = max(
|
|
||||||
1, min(height, round(height * self.args.det_roi_y_max_ratio))
|
|
||||||
)
|
|
||||||
boxes, confidences = self.native.detect(frame[:detection_height])
|
|
||||||
self.timings["detector_ms"].append((time.perf_counter() - started) * 1000)
|
|
||||||
track_ids = self.box_tracker.update(boxes, frame_idx, (height, width))
|
|
||||||
|
|
||||||
# Retire disappeared people in long-running camera sessions.
|
|
||||||
for stale_id in list(self.people):
|
|
||||||
if stale_id not in self.box_tracker.tracks:
|
|
||||||
del self.people[stale_id]
|
|
||||||
if boxes:
|
|
||||||
started = time.perf_counter()
|
|
||||||
raw_keypoints, raw_scores = self.pose(frame, bboxes=boxes)
|
|
||||||
self.timings["raw_pose_ms"].append((time.perf_counter() - started) * 1000)
|
|
||||||
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)
|
|
||||||
keypoints, scores = self.pose(masked, bboxes=[bbox])
|
|
||||||
isolated_keypoints[person_idx] = keypoints[0]
|
|
||||||
isolated_scores[person_idx] = scores[0]
|
|
||||||
self.isolated_inferences += 1
|
|
||||||
self.timings["isolated_extra_ms"].append(
|
|
||||||
(time.perf_counter() - started) * 1000
|
|
||||||
)
|
|
||||||
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, metadata_list, states = [], [], [], []
|
|
||||||
for person_idx, (bbox, track_id) in enumerate(zip(boxes, track_ids)):
|
|
||||||
state = self.people.setdefault(track_id, LivePersonState())
|
|
||||||
selected, metadata = fuse_person(
|
|
||||||
raw_keypoints[person_idx],
|
|
||||||
raw_scores[person_idx],
|
|
||||||
isolated_keypoints[person_idx],
|
|
||||||
isolated_scores[person_idx],
|
|
||||||
bbox,
|
|
||||||
state.fusion,
|
|
||||||
)
|
|
||||||
selected_list.append(selected)
|
|
||||||
smoothed_list.append(adaptive_smooth(selected, bbox, state.fusion))
|
|
||||||
metadata_list.append(metadata)
|
|
||||||
states.append(state)
|
|
||||||
|
|
||||||
measurements, endpoint_tracks = {}, {}
|
|
||||||
for person_idx, (selected, metadata, state) in enumerate(
|
|
||||||
zip(selected_list, metadata_list, states)
|
|
||||||
):
|
|
||||||
for side, (start, end) in HAND_SLICES.items():
|
|
||||||
meta = metadata[side]
|
|
||||||
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]
|
|
||||||
measurements, assignments = resolve_global_hand_assignments(
|
|
||||||
measurements, endpoint_tracks, boxes, 30
|
|
||||||
)
|
|
||||||
|
|
||||||
v2_list, v2_meta_list = [], []
|
|
||||||
for person_idx, (bbox, selected, smoothed, metadata, state) in enumerate(
|
|
||||||
zip(boxes, selected_list, smoothed_list, metadata_list, states)
|
|
||||||
):
|
|
||||||
v2, v2_meta = update_live_v2(
|
|
||||||
frame_idx,
|
|
||||||
bbox,
|
|
||||||
selected,
|
|
||||||
smoothed,
|
|
||||||
metadata,
|
|
||||||
state,
|
|
||||||
{side: measurements[(person_idx, side)] for side in SIDES},
|
|
||||||
{side: assignments.get((person_idx, side)) for side in SIDES},
|
|
||||||
12,
|
|
||||||
30,
|
|
||||||
)
|
|
||||||
v2_list.append(v2)
|
|
||||||
v2_meta_list.append(v2_meta)
|
|
||||||
|
|
||||||
self.timings["pose_pipeline_total_ms"].append(
|
|
||||||
(time.perf_counter() - infer_started) * 1000
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"frame_index": frame_idx,
|
|
||||||
"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": metadata_list,
|
|
||||||
"v2_keypoints": np.round(np.asarray(v2_list), 3).tolist(),
|
|
||||||
"v2_metadata": v2_meta_list,
|
|
||||||
"global_hands": build_global_hands(selected_list, metadata_list),
|
|
||||||
}
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
"""Runtime pose fusion helpers."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import math
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
from pose_geometry import BODY_WRISTS, HAND_EDGES_LOCAL, HAND_SLICES, normalized_keypoints
|
|
||||||
|
|
||||||
|
|
||||||
BODY_SIDE_INDICES = {"left": (5, 7, 9), "right": (6, 8, 10)}
|
|
||||||
|
|
||||||
|
|
||||||
SIDE_COLORS = {"left": (40, 40, 255), "right": (30, 230, 30)}
|
|
||||||
|
|
||||||
|
|
||||||
HAND_COLORS = {"left": (255, 80, 220), "right": (255, 190, 40)}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Candidate:
|
|
||||||
source: str
|
|
||||||
keypoints: np.ndarray
|
|
||||||
scores: np.ndarray
|
|
||||||
normalized: np.ndarray
|
|
||||||
limb_score: float
|
|
||||||
hand_score: float
|
|
||||||
upper_length: float
|
|
||||||
forearm_length: float
|
|
||||||
hand_span: float
|
|
||||||
wrist_link: float
|
|
||||||
limb_valid: bool
|
|
||||||
hand_valid: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SideTrack:
|
|
||||||
limb_previous: np.ndarray | None = None
|
|
||||||
limb_velocity: np.ndarray | None = None
|
|
||||||
hand_previous: np.ndarray | None = None
|
|
||||||
hand_velocity: np.ndarray | None = None
|
|
||||||
limb_source: str | None = None
|
|
||||||
hand_source: str | None = None
|
|
||||||
previous_hand_eligible: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PersonTrack:
|
|
||||||
sides: dict[str, SideTrack] = field(
|
|
||||||
default_factory=lambda: {"left": SideTrack(), "right": SideTrack()}
|
|
||||||
)
|
|
||||||
smoothed: 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 build_candidate(
|
|
||||||
source: str,
|
|
||||||
keypoints: np.ndarray,
|
|
||||||
scores: np.ndarray,
|
|
||||||
bbox: list[float],
|
|
||||||
side: str,
|
|
||||||
) -> Candidate:
|
|
||||||
normalized = normalized_keypoints(keypoints, bbox)
|
|
||||||
shoulder, elbow, wrist = BODY_SIDE_INDICES[side]
|
|
||||||
hand_start, hand_end = HAND_SLICES[side]
|
|
||||||
hand = keypoints[hand_start:hand_end]
|
|
||||||
diagonal = bbox_diagonal(bbox)
|
|
||||||
upper_length = float(np.linalg.norm(normalized[shoulder] - normalized[elbow]))
|
|
||||||
forearm_length = float(np.linalg.norm(normalized[elbow] - normalized[wrist]))
|
|
||||||
hand_span = math.hypot(float(np.ptp(hand[:, 0])), float(np.ptp(hand[:, 1]))) / diagonal
|
|
||||||
wrist_link = float(np.linalg.norm(keypoints[wrist] - hand[0])) / diagonal
|
|
||||||
limb_score = float(np.mean(scores[[shoulder, elbow, wrist]]))
|
|
||||||
hand_score = float(np.mean(scores[hand_start:hand_end]))
|
|
||||||
limb_valid = 0.025 <= upper_length <= 0.8 and 0.025 <= forearm_length <= 0.8
|
|
||||||
hand_valid = 0.008 <= hand_span <= 0.35 and wrist_link <= 0.12
|
|
||||||
return Candidate(
|
|
||||||
source=source,
|
|
||||||
keypoints=keypoints,
|
|
||||||
scores=scores,
|
|
||||||
normalized=normalized,
|
|
||||||
limb_score=limb_score,
|
|
||||||
hand_score=hand_score,
|
|
||||||
upper_length=upper_length,
|
|
||||||
forearm_length=forearm_length,
|
|
||||||
hand_span=hand_span,
|
|
||||||
wrist_link=wrist_link,
|
|
||||||
limb_valid=limb_valid,
|
|
||||||
hand_valid=hand_valid,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def predicted(previous: np.ndarray | None, velocity: np.ndarray | None) -> np.ndarray | None:
|
|
||||||
if previous is None:
|
|
||||||
return None
|
|
||||||
if velocity is None:
|
|
||||||
return previous
|
|
||||||
return previous + 0.65 * velocity
|
|
||||||
|
|
||||||
|
|
||||||
def robust_distance(points: np.ndarray, target: np.ndarray | None) -> float:
|
|
||||||
if target is None:
|
|
||||||
return 0.0
|
|
||||||
distances = np.linalg.norm(points - target, axis=1)
|
|
||||||
return float(np.median(distances))
|
|
||||||
|
|
||||||
|
|
||||||
def select_limb(
|
|
||||||
candidates: list[Candidate],
|
|
||||||
side: str,
|
|
||||||
track: SideTrack | None,
|
|
||||||
) -> tuple[Candidate, dict[str, float]]:
|
|
||||||
indices = np.asarray(BODY_SIDE_INDICES[side])
|
|
||||||
target = predicted(track.limb_previous, track.limb_velocity) if track else None
|
|
||||||
scored: list[tuple[float, Candidate, float]] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
temporal_cost = robust_distance(candidate.normalized[indices], target)
|
|
||||||
geometry_bonus = 1.2 if candidate.limb_valid else -5.0
|
|
||||||
switch_bonus = 0.35 if track and candidate.source == track.limb_source else 0.0
|
|
||||||
score = candidate.limb_score + geometry_bonus + switch_bonus - 7.0 * temporal_cost
|
|
||||||
scored.append((score, candidate, temporal_cost))
|
|
||||||
score, selected, temporal_cost = max(scored, key=lambda item: item[0])
|
|
||||||
return selected, {"score": score, "temporal_cost": temporal_cost}
|
|
||||||
|
|
||||||
|
|
||||||
def hand_link_to_limb(
|
|
||||||
candidate: Candidate,
|
|
||||||
selected_limb: Candidate,
|
|
||||||
bbox: list[float],
|
|
||||||
side: str,
|
|
||||||
) -> float:
|
|
||||||
hand_start, _ = HAND_SLICES[side]
|
|
||||||
wrist_idx = BODY_WRISTS[side]
|
|
||||||
return float(
|
|
||||||
np.linalg.norm(candidate.keypoints[hand_start] - selected_limb.keypoints[wrist_idx])
|
|
||||||
) / bbox_diagonal(bbox)
|
|
||||||
|
|
||||||
|
|
||||||
def select_hand(
|
|
||||||
candidates: list[Candidate],
|
|
||||||
selected_limb: Candidate,
|
|
||||||
bbox: list[float],
|
|
||||||
side: str,
|
|
||||||
track: SideTrack | None,
|
|
||||||
) -> tuple[Candidate, bool, dict[str, float]]:
|
|
||||||
start, end = HAND_SLICES[side]
|
|
||||||
target = predicted(track.hand_previous, track.hand_velocity) if track else None
|
|
||||||
scored: list[tuple[float, Candidate, bool, float, float]] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
temporal_cost = robust_distance(candidate.normalized[start:end], target)
|
|
||||||
link = hand_link_to_limb(candidate, selected_limb, bbox, side)
|
|
||||||
valid = candidate.hand_valid
|
|
||||||
geometry_bonus = 3.0 if valid else -7.0
|
|
||||||
switch_bonus = 0.45 if track and candidate.source == track.hand_source else 0.0
|
|
||||||
limb_source_bonus = 0.15 if candidate.source == selected_limb.source else 0.0
|
|
||||||
# A newly visible endpoint may disagree with the currently selected
|
|
||||||
# (occlusion-driven) limb. Keep this as a soft cost so strong image
|
|
||||||
# evidence can switch both the hand and its limb together.
|
|
||||||
link_penalty = 1.5 * min(max(link - 0.12, 0.0), 0.2)
|
|
||||||
score = (
|
|
||||||
candidate.hand_score
|
|
||||||
+ geometry_bonus
|
|
||||||
+ switch_bonus
|
|
||||||
+ limb_source_bonus
|
|
||||||
- link_penalty
|
|
||||||
- 8.0 * min(temporal_cost, 0.15)
|
|
||||||
)
|
|
||||||
scored.append((score, candidate, valid, temporal_cost, link))
|
|
||||||
score, selected, valid, temporal_cost, link = max(scored, key=lambda item: item[0])
|
|
||||||
return selected, valid, {
|
|
||||||
"score": score,
|
|
||||||
"temporal_cost": temporal_cost,
|
|
||||||
"link_to_selected_limb": link,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def candidate_disagreement(
|
|
||||||
raw: Candidate,
|
|
||||||
isolated: Candidate,
|
|
||||||
side: str,
|
|
||||||
) -> tuple[float, float]:
|
|
||||||
shoulder, elbow, wrist = BODY_SIDE_INDICES[side]
|
|
||||||
start, end = HAND_SLICES[side]
|
|
||||||
limb = float(
|
|
||||||
np.median(
|
|
||||||
np.linalg.norm(
|
|
||||||
raw.normalized[[shoulder, elbow, wrist]]
|
|
||||||
- isolated.normalized[[shoulder, elbow, wrist]],
|
|
||||||
axis=1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
hand_distances = np.linalg.norm(
|
|
||||||
raw.normalized[start:end] - isolated.normalized[start:end], axis=1
|
|
||||||
)
|
|
||||||
hand = float(np.percentile(hand_distances, 90))
|
|
||||||
return limb, hand
|
|
||||||
|
|
||||||
|
|
||||||
def suppress_duplicate_hands(
|
|
||||||
fused: np.ndarray,
|
|
||||||
metadata: dict,
|
|
||||||
bbox: list[float],
|
|
||||||
) -> None:
|
|
||||||
left_start, left_end = HAND_SLICES["left"]
|
|
||||||
right_start, right_end = HAND_SLICES["right"]
|
|
||||||
left_center = np.mean(fused[left_start:left_end], axis=0)
|
|
||||||
right_center = np.mean(fused[right_start:right_end], axis=0)
|
|
||||||
separation = float(np.linalg.norm(left_center - right_center)) / bbox_diagonal(bbox)
|
|
||||||
if separation >= 0.08:
|
|
||||||
return
|
|
||||||
weaker = min(
|
|
||||||
("left", "right"),
|
|
||||||
key=lambda side: metadata[side]["selected_hand_score"],
|
|
||||||
)
|
|
||||||
metadata[weaker]["hand_state"] = "duplicate"
|
|
||||||
metadata[weaker]["distance_eligible"] = False
|
|
||||||
metadata[weaker]["alarm_eligible"] = False
|
|
||||||
metadata[weaker]["duplicate_hand_separation"] = round(separation, 5)
|
|
||||||
|
|
||||||
|
|
||||||
def fuse_person(
|
|
||||||
raw_keypoints: np.ndarray,
|
|
||||||
raw_scores: np.ndarray,
|
|
||||||
isolated_keypoints: np.ndarray,
|
|
||||||
isolated_scores: np.ndarray,
|
|
||||||
bbox: list[float],
|
|
||||||
track: PersonTrack | None,
|
|
||||||
) -> tuple[np.ndarray, dict]:
|
|
||||||
fused = raw_keypoints.copy()
|
|
||||||
metadata: dict[str, dict] = {}
|
|
||||||
|
|
||||||
for side in ("left", "right"):
|
|
||||||
side_track = track.sides[side] if track else None
|
|
||||||
raw = build_candidate("raw", raw_keypoints, raw_scores, bbox, side)
|
|
||||||
isolated = build_candidate(
|
|
||||||
"isolated", isolated_keypoints, isolated_scores, bbox, side
|
|
||||||
)
|
|
||||||
candidates = [raw, isolated]
|
|
||||||
limb, limb_detail = select_limb(candidates, side, side_track)
|
|
||||||
hand, hand_valid, hand_detail = select_hand(
|
|
||||||
candidates, limb, bbox, side, side_track
|
|
||||||
)
|
|
||||||
|
|
||||||
other_hand = isolated if hand.source == "raw" else raw
|
|
||||||
endpoint_advantage = hand.hand_score - other_hand.hand_score
|
|
||||||
if hand.source != limb.source and hand_valid and endpoint_advantage >= 0.6:
|
|
||||||
limb = hand
|
|
||||||
limb_detail["visible_endpoint_override"] = 1.0
|
|
||||||
hand_detail["link_to_selected_limb"] = hand.wrist_link
|
|
||||||
|
|
||||||
body_indices = np.asarray(BODY_SIDE_INDICES[side])
|
|
||||||
hand_start, hand_end = HAND_SLICES[side]
|
|
||||||
fused[body_indices] = limb.keypoints[body_indices]
|
|
||||||
fused[hand_start:hand_end] = hand.keypoints[hand_start:hand_end]
|
|
||||||
|
|
||||||
limb_disagreement, hand_disagreement = candidate_disagreement(raw, isolated, side)
|
|
||||||
if limb_disagreement <= 0.05:
|
|
||||||
limb_state = "consensus"
|
|
||||||
else:
|
|
||||||
limb_state = "inferred"
|
|
||||||
|
|
||||||
score_advantage = raw.hand_score - isolated.hand_score
|
|
||||||
raw_temporally_stable = hand_detail["temporal_cost"] <= 0.08
|
|
||||||
if not hand_valid:
|
|
||||||
hand_state = "rejected"
|
|
||||||
distance_eligible = False
|
|
||||||
elif hand_disagreement <= 0.06:
|
|
||||||
hand_state = "consensus"
|
|
||||||
distance_eligible = True
|
|
||||||
elif (
|
|
||||||
hand.source == "raw"
|
|
||||||
and score_advantage >= 0.6
|
|
||||||
and raw_temporally_stable
|
|
||||||
):
|
|
||||||
hand_state = "visible_endpoint"
|
|
||||||
distance_eligible = True
|
|
||||||
elif hand.source == "isolated":
|
|
||||||
hand_state = "inferred"
|
|
||||||
distance_eligible = False
|
|
||||||
else:
|
|
||||||
hand_state = "conflict"
|
|
||||||
distance_eligible = False
|
|
||||||
fast_alarm_endpoint = (
|
|
||||||
hand_valid and hand.source == "raw" and score_advantage >= 0.6
|
|
||||||
)
|
|
||||||
alarm_eligible = distance_eligible or fast_alarm_endpoint
|
|
||||||
|
|
||||||
metadata[side] = {
|
|
||||||
"limb_source": limb.source,
|
|
||||||
"hand_source": hand.source,
|
|
||||||
"limb_state": limb_state,
|
|
||||||
"hand_state": hand_state,
|
|
||||||
"distance_eligible": distance_eligible,
|
|
||||||
"alarm_eligible": alarm_eligible,
|
|
||||||
"limb_disagreement": round(limb_disagreement, 5),
|
|
||||||
"hand_disagreement": round(hand_disagreement, 5),
|
|
||||||
"raw_hand_score": round(raw.hand_score, 4),
|
|
||||||
"isolated_hand_score": round(isolated.hand_score, 4),
|
|
||||||
"selected_hand_span": round(hand.hand_span, 5),
|
|
||||||
"selected_hand_score": round(hand.hand_score, 4),
|
|
||||||
"selected_hand_valid": hand_valid,
|
|
||||||
"limb_selection": {key: round(value, 5) for key, value in limb_detail.items()},
|
|
||||||
"hand_selection": {key: round(value, 5) for key, value in hand_detail.items()},
|
|
||||||
}
|
|
||||||
|
|
||||||
if side_track is not None:
|
|
||||||
limb_now = limb.normalized[body_indices]
|
|
||||||
hand_now = hand.normalized[hand_start:hand_end]
|
|
||||||
if side_track.limb_previous is not None:
|
|
||||||
delta = limb_now - side_track.limb_previous
|
|
||||||
side_track.limb_velocity = (
|
|
||||||
delta
|
|
||||||
if side_track.limb_velocity is None
|
|
||||||
else 0.65 * side_track.limb_velocity + 0.35 * delta
|
|
||||||
)
|
|
||||||
if hand_valid:
|
|
||||||
if side_track.hand_previous is not None:
|
|
||||||
delta = hand_now - side_track.hand_previous
|
|
||||||
side_track.hand_velocity = (
|
|
||||||
delta
|
|
||||||
if side_track.hand_velocity is None
|
|
||||||
else 0.65 * side_track.hand_velocity + 0.35 * delta
|
|
||||||
)
|
|
||||||
side_track.hand_previous = hand_now
|
|
||||||
side_track.hand_source = hand.source
|
|
||||||
side_track.limb_previous = limb_now
|
|
||||||
side_track.limb_source = limb.source
|
|
||||||
side_track.previous_hand_eligible = distance_eligible
|
|
||||||
|
|
||||||
suppress_duplicate_hands(fused, metadata, bbox)
|
|
||||||
return fused, metadata
|
|
||||||
|
|
||||||
|
|
||||||
def adaptive_smooth(
|
|
||||||
keypoints: np.ndarray,
|
|
||||||
bbox: list[float],
|
|
||||||
track: PersonTrack,
|
|
||||||
) -> np.ndarray:
|
|
||||||
normalized = normalized_keypoints(keypoints, bbox)
|
|
||||||
if track.smoothed is None:
|
|
||||||
track.smoothed = normalized
|
|
||||||
else:
|
|
||||||
displacement = np.linalg.norm(normalized - track.smoothed, axis=1)
|
|
||||||
alpha = 0.32 + 0.58 * np.clip(displacement / 0.08, 0.0, 1.0)
|
|
||||||
track.smoothed = alpha[:, None] * normalized + (1.0 - alpha[:, None]) * track.smoothed
|
|
||||||
|
|
||||||
x1, y1, x2, y2 = bbox
|
|
||||||
result = track.smoothed.copy()
|
|
||||||
result[:, 0] = result[:, 0] * max(x2 - x1, 1.0) + x1
|
|
||||||
result[:, 1] = result[:, 1] * max(y2 - y1, 1.0) + y1
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def build_global_hands(
|
|
||||||
keypoints: list[np.ndarray],
|
|
||||||
metadata: list[dict],
|
|
||||||
) -> list[dict]:
|
|
||||||
candidates = []
|
|
||||||
for person_idx, (person_keypoints, person_meta) in enumerate(
|
|
||||||
zip(keypoints, metadata), start=1
|
|
||||||
):
|
|
||||||
for side, (start, end) in HAND_SLICES.items():
|
|
||||||
meta = person_meta[side]
|
|
||||||
if not meta["alarm_eligible"]:
|
|
||||||
continue
|
|
||||||
hand = person_keypoints[start:end]
|
|
||||||
span_px = math.hypot(float(np.ptp(hand[:, 0])), float(np.ptp(hand[:, 1])))
|
|
||||||
candidates.append(
|
|
||||||
{
|
|
||||||
"person_id": person_idx,
|
|
||||||
"side": side,
|
|
||||||
"source": meta["hand_source"],
|
|
||||||
"state": meta["hand_state"],
|
|
||||||
"distance_eligible": meta["distance_eligible"],
|
|
||||||
"alarm_eligible": meta["alarm_eligible"],
|
|
||||||
"score": meta["selected_hand_score"],
|
|
||||||
"center": np.mean(hand, axis=0),
|
|
||||||
"span_px": span_px,
|
|
||||||
"keypoints": hand,
|
|
||||||
"aliases": [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
kept: list[dict] = []
|
|
||||||
for candidate in sorted(candidates, key=lambda item: item["score"], reverse=True):
|
|
||||||
duplicate = None
|
|
||||||
for existing in kept:
|
|
||||||
threshold = float(np.clip(0.45 * max(candidate["span_px"], existing["span_px"]), 18, 70))
|
|
||||||
if float(np.linalg.norm(candidate["center"] - existing["center"])) <= threshold:
|
|
||||||
duplicate = existing
|
|
||||||
break
|
|
||||||
if duplicate is None:
|
|
||||||
kept.append(candidate)
|
|
||||||
else:
|
|
||||||
duplicate["aliases"].append(
|
|
||||||
{"person_id": candidate["person_id"], "side": candidate["side"]}
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"person_id": item["person_id"],
|
|
||||||
"side": item["side"],
|
|
||||||
"source": item["source"],
|
|
||||||
"state": item["state"],
|
|
||||||
"distance_eligible": item["distance_eligible"],
|
|
||||||
"alarm_eligible": item["alarm_eligible"],
|
|
||||||
"score": item["score"],
|
|
||||||
"center": np.round(item["center"], 3).tolist(),
|
|
||||||
"span_px": round(item["span_px"], 3),
|
|
||||||
"keypoints": np.round(item["keypoints"], 3).tolist(),
|
|
||||||
"aliases": item["aliases"],
|
|
||||||
}
|
|
||||||
for item in kept
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def draw_dashed_line(
|
|
||||||
image: np.ndarray,
|
|
||||||
start: np.ndarray,
|
|
||||||
end: np.ndarray,
|
|
||||||
color: tuple[int, int, int],
|
|
||||||
thickness: int = 2,
|
|
||||||
) -> None:
|
|
||||||
a = np.asarray(start, dtype=np.float64)
|
|
||||||
b = np.asarray(end, dtype=np.float64)
|
|
||||||
length = float(np.linalg.norm(b - a))
|
|
||||||
if length < 1.0:
|
|
||||||
return
|
|
||||||
direction = (b - a) / length
|
|
||||||
for offset in np.arange(0.0, length, 14.0):
|
|
||||||
segment_start = a + direction * offset
|
|
||||||
segment_end = a + direction * min(offset + 8.0, length)
|
|
||||||
cv2.line(
|
|
||||||
image,
|
|
||||||
tuple(np.round(segment_start).astype(int)),
|
|
||||||
tuple(np.round(segment_end).astype(int)),
|
|
||||||
color,
|
|
||||||
thickness,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_fused(
|
|
||||||
image: np.ndarray,
|
|
||||||
boxes: list[list[float]],
|
|
||||||
keypoints: list[np.ndarray],
|
|
||||||
metadata: list[dict],
|
|
||||||
title: str,
|
|
||||||
) -> np.ndarray:
|
|
||||||
canvas = image.copy()
|
|
||||||
for person_idx, (bbox, kpts, person_meta) in enumerate(
|
|
||||||
zip(boxes, keypoints, metadata), 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.72,
|
|
||||||
(0, 220, 255),
|
|
||||||
2,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
for side in ("left", "right"):
|
|
||||||
shoulder, elbow, wrist = BODY_SIDE_INDICES[side]
|
|
||||||
side_meta = person_meta[side]
|
|
||||||
color = SIDE_COLORS[side]
|
|
||||||
for a, b in ((shoulder, elbow), (elbow, wrist)):
|
|
||||||
if side_meta["limb_state"] == "consensus":
|
|
||||||
cv2.line(
|
|
||||||
canvas,
|
|
||||||
tuple(np.round(kpts[a]).astype(int)),
|
|
||||||
tuple(np.round(kpts[b]).astype(int)),
|
|
||||||
color,
|
|
||||||
3,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
draw_dashed_line(canvas, kpts[a], kpts[b], (0, 140, 255), 3)
|
|
||||||
|
|
||||||
start, end = HAND_SLICES[side]
|
|
||||||
hand = kpts[start:end]
|
|
||||||
if side_meta["hand_state"] != "rejected":
|
|
||||||
hand_color = HAND_COLORS[side]
|
|
||||||
if not side_meta["distance_eligible"]:
|
|
||||||
hand_color = (0, 140, 255)
|
|
||||||
for a, b in HAND_EDGES_LOCAL:
|
|
||||||
if side_meta["distance_eligible"]:
|
|
||||||
cv2.line(
|
|
||||||
canvas,
|
|
||||||
tuple(np.round(hand[a]).astype(int)),
|
|
||||||
tuple(np.round(hand[b]).astype(int)),
|
|
||||||
hand_color,
|
|
||||||
2,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
draw_dashed_line(canvas, hand[a], hand[b], hand_color, 2)
|
|
||||||
label = (
|
|
||||||
f"{side[0].upper()}:{side_meta['hand_state'][:4]}"
|
|
||||||
f"/{side_meta['hand_source'][0]}"
|
|
||||||
)
|
|
||||||
label_y = max(45, y1 + (48 if side == "left" else 70))
|
|
||||||
cv2.putText(
|
|
||||||
canvas,
|
|
||||||
label,
|
|
||||||
(x1 + 4, label_y),
|
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
|
||||||
0.5,
|
|
||||||
HAND_COLORS[side] if side_meta["distance_eligible"] else (0, 140, 255),
|
|
||||||
1,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
|
|
||||||
cv2.rectangle(canvas, (0, 0), (650, 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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
"""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,116 +0,0 @@
|
|||||||
"""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
|
|
||||||
-300
@@ -1,300 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Live baseline V4L2/CAN capture + native TensorRT + CMS v9.10 risk."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import argparse
|
|
||||||
from collections import Counter, deque
|
|
||||||
from contextlib import ExitStack
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from pathlib import Path
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
ROOT=Path(__file__).resolve().parent
|
|
||||||
sys.path.insert(0,str(ROOT/'pipeline'))
|
|
||||||
from native_backend import NativeBackend
|
|
||||||
from pose_engine import NativePoseEngine
|
|
||||||
from latest_result import LatestResult, result_to_dict
|
|
||||||
from door_mask_risk import DoorMaskSequence, DoorRiskEvaluator
|
|
||||||
from door_zone_risk import DoorZoneConfig, DoorZoneAssigner, build_door_risks
|
|
||||||
from visualization import render_frame, visualization_skeletons, timing_stats
|
|
||||||
|
|
||||||
|
|
||||||
def select_active_door(mode, snapshot, side='left', timeout_ms=1000):
|
|
||||||
if mode!='can': return mode, True
|
|
||||||
p=snapshot['positions'];offset=0 if side=='left' else 1
|
|
||||||
if snapshot['age_ms']<0 or snapshot['age_ms']>timeout_ms or min(p[offset],p[offset+2])<0:
|
|
||||||
return 'none',False
|
|
||||||
front,rear=p[offset]==0,p[offset+2]==0
|
|
||||||
return ('both' if front and rear else 'front' if front else 'rear' if rear else 'none'),True
|
|
||||||
|
|
||||||
|
|
||||||
def gate_risks(raw, assignments, active, can_valid):
|
|
||||||
doors,risk=build_door_risks(raw,assignments,active)
|
|
||||||
if not can_valid:
|
|
||||||
for door,item in doors.items():
|
|
||||||
item.update(risk_type=door.upper()+'_UNKNOWN',risk_level='unknown',risk_score=None,decision='CAN_UNAVAILABLE_OR_STALE')
|
|
||||||
risk=dict(risk,risk_type='CAN_UNKNOWN',risk_level='unknown',risk_score=None,decision='CAN_UNAVAILABLE_OR_STALE')
|
|
||||||
return doors,risk
|
|
||||||
|
|
||||||
|
|
||||||
class JsonlWriter:
|
|
||||||
"""Bound the disk usage of continuous camera runs (current + two backups)."""
|
|
||||||
def __init__(self,path,max_bytes):
|
|
||||||
self.path=path;self.max_bytes=max_bytes;self.size=0
|
|
||||||
self.handle=path.open('w',encoding='utf-8',buffering=1)
|
|
||||||
def __enter__(self):return self
|
|
||||||
def __exit__(self,*args):self.close()
|
|
||||||
def close(self):self.handle.close()
|
|
||||||
def write(self,text):
|
|
||||||
size=len(text.encode('utf-8'))
|
|
||||||
if self.max_bytes and self.size and self.size+size>self.max_bytes:
|
|
||||||
self.handle.close()
|
|
||||||
backup=self.path.with_suffix(self.path.suffix+'.1')
|
|
||||||
if backup.exists():backup.replace(self.path.with_suffix(self.path.suffix+'.2'))
|
|
||||||
self.path.replace(backup)
|
|
||||||
self.handle=self.path.open('w',encoding='utf-8',buffering=1);self.size=0
|
|
||||||
self.handle.write(text);self.size+=size
|
|
||||||
|
|
||||||
|
|
||||||
def render_output(frame, record, risk, mask, assignments, status):
|
|
||||||
canvas = render_frame(frame, record, risk, mask, assignments)
|
|
||||||
cv2.putText(canvas,status,(24,frame.shape[0]-30),cv2.FONT_HERSHEY_SIMPLEX,.8,(0,180,255),2)
|
|
||||||
return canvas
|
|
||||||
|
|
||||||
|
|
||||||
class Preview:
|
|
||||||
def __init__(self,host,port,results):
|
|
||||||
self.jpeg=None;self.results=results;self.lock=threading.Lock()
|
|
||||||
self.pending=threading.Condition();self.packet=None;self.stopped=False
|
|
||||||
outer=self
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
with outer.lock:
|
|
||||||
if self.path.split('?')[0]=='/frame.jpg': payload=outer.jpeg or b'';mime='image/jpeg'
|
|
||||||
elif self.path=='/result.json':payload=json.dumps(result_to_dict(outer.results.load()) or {},ensure_ascii=False,allow_nan=False).encode();mime='application/json'
|
|
||||||
else:
|
|
||||||
payload=b'''<!doctype html><meta charset="utf-8"><title>Vision anti-pinch 0923</title><body style="background:#101820;color:white;font:18px sans-serif"><h2>Vision anti-pinch 0923 / Live camera</h2><p id="state"></p><img id="cam" style="max-width:100%;max-height:83vh"><script>let img=document.querySelector('#cam'); function next(){setTimeout(()=>img.src='/frame.jpg?t='+Date.now(),150)}img.onload=next;img.onerror=next;next();setInterval(async()=>{let r=await(await fetch('/result.json')).json();document.querySelector('#state').textContent=JSON.stringify({frame:r.frame_index,active_door:r.active_door,risk:r.risk,can:r.can,calibration_verified:r.calibration_verified})},1000)</script>''';mime='text/html; charset=utf-8'
|
|
||||||
self.send_response(200 if payload else 503);self.send_header('Content-Type',mime);self.send_header('Cache-Control','no-store');self.send_header('Content-Length',str(len(payload)));self.end_headers()
|
|
||||||
try:self.wfile.write(payload)
|
|
||||||
except (BrokenPipeError,ConnectionResetError):pass
|
|
||||||
def log_message(self,*args):pass
|
|
||||||
self.server=ThreadingHTTPServer((host,port),Handler)
|
|
||||||
self.thread=threading.Thread(target=self.server.serve_forever,daemon=True);self.thread.start()
|
|
||||||
self.renderer=threading.Thread(target=self._render_loop,name='risk-preview',daemon=True)
|
|
||||||
self.renderer.start()
|
|
||||||
def submit(self, frame, record, risk, mask, assignments, status):
|
|
||||||
# All inputs belong to a finished frame; inference never modifies them
|
|
||||||
# after submission. Retain only one pending frame, replacing older work.
|
|
||||||
with self.pending:
|
|
||||||
if not self.stopped:
|
|
||||||
self.packet=(frame,record,risk,mask,assignments,status)
|
|
||||||
self.pending.notify()
|
|
||||||
|
|
||||||
def _render_loop(self):
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
with self.pending:
|
|
||||||
self.pending.wait_for(lambda:self.packet is not None or self.stopped)
|
|
||||||
if self.stopped:return
|
|
||||||
packet=self.packet;self.packet=None
|
|
||||||
self.update(render_output(*packet))
|
|
||||||
except Exception as exc:
|
|
||||||
with self.pending:self.stopped=True;self.packet=None
|
|
||||||
with self.lock:self.jpeg=None
|
|
||||||
print('Visualization disabled; inference continues: '+str(exc),file=sys.stderr,flush=True)
|
|
||||||
|
|
||||||
def update(self,image):
|
|
||||||
w=960;h=round(image.shape[0]*w/image.shape[1]);image=cv2.resize(image,(w,h))
|
|
||||||
ok,jpeg=cv2.imencode('.jpg',image,[cv2.IMWRITE_JPEG_QUALITY,80])
|
|
||||||
if ok:
|
|
||||||
with self.lock:self.jpeg=jpeg.tobytes()
|
|
||||||
def close(self):
|
|
||||||
with self.pending:self.stopped=True;self.packet=None;self.pending.notify_all()
|
|
||||||
self.renderer.join()
|
|
||||||
self.server.shutdown();self.server.server_close();self.thread.join()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv=None):
|
|
||||||
p=argparse.ArgumentParser(description=__doc__)
|
|
||||||
p.add_argument('--video',type=Path,help='Diagnostic video replay through the same TensorRT/risk chain');p.add_argument('--camera',default='/dev/video5');p.add_argument('--width',type=int,default=1920);p.add_argument('--height',type=int,default=1536);p.add_argument('--fps',type=int,default=30)
|
|
||||||
p.add_argument('--engines',type=Path,default=ROOT/'orin/models/engines' if (ROOT/'orin').is_dir() else ROOT/'models/engines')
|
|
||||||
p.add_argument('--constant-door-mask',type=Path,default=ROOT/'annotations/constant_door_mask.json')
|
|
||||||
p.add_argument('--door-zone-config',type=Path,default=ROOT/'annotations/door_zone_multifeature.json')
|
|
||||||
p.add_argument('--active-door',choices=['can','front','rear','both','none'],default='can')
|
|
||||||
p.add_argument('--can-side',choices=['left','right'],default='left');p.add_argument('--can-timeout-ms',type=float,default=1000);p.add_argument('--no-can',action='store_true')
|
|
||||||
p.add_argument('--max-people',type=int,default=3);p.add_argument('--det-conf',type=float,default=.30)
|
|
||||||
p.add_argument('--max-frames',type=int,default=0);p.add_argument('--duration',type=float,default=0)
|
|
||||||
p.add_argument('--jsonl-max-mb',type=float,default=32,help='Rotate each JSONL at this size; keep two backups; 0 disables rotation');p.add_argument('--output',type=Path,default=ROOT/'outputs'/time.strftime('%Y%m%d-%H%M%S'))
|
|
||||||
p.add_argument('--visualize',action='store_true',help='Enable optional HTTP visualization; disabled by default')
|
|
||||||
p.add_argument('--save-results',action='store_true',help='Save per-frame JSONL and periodic latest.json')
|
|
||||||
p.add_argument('--save-snapshots',action='store_true',help='Render and save latest.jpg every 10 seconds')
|
|
||||||
p.add_argument('--preview-port',type=int,default=8765);p.add_argument('--preview-host',default='0.0.0.0');p.add_argument('--preview-every',type=int,default=3)
|
|
||||||
p.add_argument('--write-video',action='store_true');p.add_argument('--save-pose-records',action='store_true');p.add_argument('--calibration-verified',action='store_true')
|
|
||||||
a=p.parse_args(argv)
|
|
||||||
if not 1<=a.max_people<=16 or not 0<a.det_conf<1 or min(a.width,a.height,a.fps,a.preview_every)<=0 or a.can_timeout_ms<=0 or a.jsonl_max_mb<0:p.error('Invalid camera/inference configuration')
|
|
||||||
if a.no_can and a.active_door=='can':p.error('--no-can requires explicit --active-door front/rear/both/none')
|
|
||||||
if not 0<=a.preview_port<=65535 or (a.visualize and a.preview_port==0):p.error('--visualize requires a port between 1 and 65535')
|
|
||||||
if a.max_frames<0 or a.duration<0:p.error('Frame count and duration cannot be negative')
|
|
||||||
a.det_roi_y_max_ratio=1.;return a
|
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline(a, results, stop, summary_sink=None):
|
|
||||||
"""Produce results independently of optional visualization and disk outputs."""
|
|
||||||
save_any = a.save_results or a.save_snapshots or a.save_pose_records or a.write_video
|
|
||||||
if save_any:
|
|
||||||
a.output.mkdir(parents=True,exist_ok=True)
|
|
||||||
cv2.setNumThreads(2)
|
|
||||||
native=None;preview=None;writer=None;video=None;count=0;dropped=0;levels=Counter();latencies=deque(maxlen=3000);pose_ms=deque(maxlen=3000);risk_ms=deque(maxlen=3000);ages=deque(maxlen=3000)
|
|
||||||
started=time.monotonic();run_started=None;error=None
|
|
||||||
try:
|
|
||||||
if a.video:
|
|
||||||
video=cv2.VideoCapture(str(a.video))
|
|
||||||
if not video.isOpened():raise RuntimeError('Cannot open video: '+str(a.video))
|
|
||||||
a.width=int(video.get(cv2.CAP_PROP_FRAME_WIDTH));a.height=int(video.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
||||||
evaluator=DoorRiskEvaluator(DoorMaskSequence(constant_mask=a.constant_door_mask,require_every_frame=True))
|
|
||||||
assigner=DoorZoneAssigner(DoorZoneConfig.load(a.door_zone_config))
|
|
||||||
# Fail before acquiring camera if calibration mask is invalid.
|
|
||||||
initial=evaluator.sequence.load(0,(a.height,a.width))
|
|
||||||
if initial.status!='loaded' or initial.mask is None or not np.any(initial.mask):raise ValueError('Door mask has no usable pixels')
|
|
||||||
native=NativeBackend(a);pose=NativePoseEngine(a,native)
|
|
||||||
if a.visualize:
|
|
||||||
try:preview=Preview(a.preview_host,a.preview_port,results)
|
|
||||||
except Exception as exc:
|
|
||||||
print('Visualization unavailable; inference continues: '+str(exc),file=sys.stderr,flush=True)
|
|
||||||
if not video:native.open_camera()
|
|
||||||
run_started=time.monotonic();last_report=run_started;last_snapshot=0
|
|
||||||
if a.write_video:
|
|
||||||
writer=cv2.VideoWriter(str(a.output/'rtmw_risk_overlay.mp4'),cv2.VideoWriter_fourcc(*'mp4v'),a.fps,(a.width,a.height))
|
|
||||||
if not writer.isOpened():raise RuntimeError('Video writer could not open')
|
|
||||||
with ExitStack() as output_stack:
|
|
||||||
out=output_stack.enter_context(JsonlWriter(a.output/'causal_results.jsonl',int(a.jsonl_max_mb*1024*1024))) if a.save_results else None
|
|
||||||
pose_out=JsonlWriter(a.output/'pose_records.jsonl',int(a.jsonl_max_mb*1024*1024)) if a.save_pose_records else None
|
|
||||||
try:
|
|
||||||
while not stop.is_set():
|
|
||||||
if (a.max_frames and count>=a.max_frames) or (a.duration and time.monotonic()-run_started>=a.duration):break
|
|
||||||
if video:
|
|
||||||
ok,frame=video.read()
|
|
||||||
if not ok:break
|
|
||||||
capture={'sequence':count,'capture_monotonic_ms':time.monotonic()*1000,'dropped_before_inference':0,'latency_clock':'video_decode_complete'}
|
|
||||||
else:frame,capture=native.read()
|
|
||||||
if frame is None:break
|
|
||||||
tick=time.monotonic();record=pose.infer(frame,count);pose_ms.append((time.monotonic()-tick)*1000)
|
|
||||||
rt=time.monotonic();snapshot=native.can_snapshot();active,valid=select_active_door(a.active_door,snapshot,a.can_side,a.can_timeout_ms)
|
|
||||||
assignments=assigner.update(count,record,(a.height,a.width));raw,mask=evaluator.evaluate(count,record,(a.height,a.width))
|
|
||||||
doors,risk=gate_risks(raw,assignments,active,valid);risk_ms.append((time.monotonic()-rt)*1000)
|
|
||||||
result={'frame_index':count,'track_ids':record['track_ids'],'boxes':record['boxes'],'skeletons':visualization_skeletons(record),'active_door':active,'door_assignments':assignments,'door_risks':doors,'risk':risk,'ungated_risk':raw,'capture':capture,'can':dict(snapshot,valid=valid,source='hardware' if a.active_door=='can' else 'simulation',side=a.can_side),'calibration_verified':a.calibration_verified}
|
|
||||||
result['capture_to_result_ms']=round(time.monotonic()*1000-capture['capture_monotonic_ms'],3)
|
|
||||||
results.publish(result)
|
|
||||||
if out:out.write(json.dumps(result,ensure_ascii=False,allow_nan=False)+'\n')
|
|
||||||
if pose_out:pose_out.write(json.dumps(record,ensure_ascii=False,allow_nan=False)+'\n')
|
|
||||||
now=time.monotonic()
|
|
||||||
snapshot_due=now-last_snapshot>=10
|
|
||||||
if a.save_results and snapshot_due:
|
|
||||||
(a.output/'latest.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
|
|
||||||
status=('CAN LIVE' if a.active_door=='can' else 'CAN SIMULATION: '+active)+' | '+('CALIBRATED' if a.calibration_verified else 'CALIBRATION UNVERIFIED')
|
|
||||||
if preview and count%a.preview_every==0:
|
|
||||||
preview.submit(frame,record,risk,mask,assignments,status)
|
|
||||||
if writer or (a.save_snapshots and snapshot_due):
|
|
||||||
canvas=render_output(frame,record,risk,mask,assignments,status)
|
|
||||||
if writer:writer.write(canvas)
|
|
||||||
if a.save_snapshots and snapshot_due:
|
|
||||||
cv2.imwrite(str(a.output/'latest.jpg'),canvas)
|
|
||||||
if snapshot_due:last_snapshot=now
|
|
||||||
count+=1;dropped+=capture['dropped_before_inference'];levels[risk['risk_level']]+=1;ages.append(result['capture_to_result_ms']);latencies.append((time.monotonic()-tick)*1000)
|
|
||||||
if now-last_report>=5:
|
|
||||||
print(json.dumps({'frames':count,'fps':round(count/(now-run_started),2),'people':len(record['boxes']),'risk':risk['risk_type'],'latency_ms':round(float(np.mean(latencies)),1),'capture_age_ms':round(float(np.mean(ages)),1),'can_valid':valid}),flush=True);last_report=now
|
|
||||||
finally:
|
|
||||||
if pose_out:pose_out.close()
|
|
||||||
except Exception as exc:
|
|
||||||
error=str(exc);raise
|
|
||||||
finally:
|
|
||||||
ended=time.monotonic()
|
|
||||||
if video:video.release()
|
|
||||||
if native:native.close()
|
|
||||||
if writer:writer.release()
|
|
||||||
if preview:preview.close()
|
|
||||||
elapsed=ended-(run_started or started)
|
|
||||||
summary={'mode':'baseline_v4l2_can_native_trt_cms_v9_10','camera':a.camera if not a.video else None,'video':str(a.video) if a.video else None,'frames':count,'elapsed_seconds':round(elapsed,3),'throughput_fps':round(count/max(elapsed,.001),3),'dropped_latest_frames':dropped,'risk_levels':dict(levels),'active_door_mode':a.active_door,'can_side':a.can_side,'calibration_verified':a.calibration_verified,'visualize_requested':a.visualize,'save_results':a.save_results,'save_snapshots':a.save_snapshots,'latency_window_frames':3000,'latency_ms':{'total':timing_stats(list(latencies)),'pose':timing_stats(list(pose_ms)),'risk':timing_stats(list(risk_ms)),'capture_to_result':timing_stats(list(ages))},'error':error}
|
|
||||||
if summary_sink is not None:summary_sink.update(summary)
|
|
||||||
if save_any:(a.output/'summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
|
|
||||||
print(json.dumps(summary,ensure_ascii=False),flush=True)
|
|
||||||
|
|
||||||
class CameraPipeline:
|
|
||||||
"""One lifecycle per instance, with the 0920 start/latest/stop/join pattern.
|
|
||||||
|
|
||||||
The worker owns inference. Downstream threads read the latest immutable
|
|
||||||
snapshot directly, without JSON files, HTTP, callbacks or a result queue.
|
|
||||||
"""
|
|
||||||
def __init__(self, args=None):
|
|
||||||
self.args = copy.deepcopy(args if args is not None else parse_args([]))
|
|
||||||
self._results = LatestResult()
|
|
||||||
self._stop = threading.Event()
|
|
||||||
self._lifecycle_lock = threading.Lock()
|
|
||||||
self._thread = None
|
|
||||||
self._error = None
|
|
||||||
self.summary = {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def latest_result(self):
|
|
||||||
"""The latest complete result variable, or None before the first frame."""
|
|
||||||
return self._results.load()
|
|
||||||
|
|
||||||
def latest(self):
|
|
||||||
return self.latest_result
|
|
||||||
|
|
||||||
def wait_for_result(self, after_frame_index=-1, timeout=None):
|
|
||||||
return self._results.wait(after_frame_index, timeout)
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
with self._lifecycle_lock:
|
|
||||||
if self._thread is not None:
|
|
||||||
raise RuntimeError('CameraPipeline can only be started once')
|
|
||||||
self._thread = threading.Thread(target=self._run, name='vision-risk')
|
|
||||||
self._thread.start()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
try:
|
|
||||||
run_pipeline(self.args, self._results, self._stop, self.summary)
|
|
||||||
except BaseException as exc:
|
|
||||||
self._error = exc
|
|
||||||
finally:
|
|
||||||
self._results.close()
|
|
||||||
|
|
||||||
def running(self):
|
|
||||||
return self._thread is not None and self._thread.is_alive()
|
|
||||||
|
|
||||||
def request_stop(self):
|
|
||||||
self._stop.set()
|
|
||||||
|
|
||||||
def join(self, timeout=None):
|
|
||||||
if self._thread is not None:
|
|
||||||
self._thread.join(timeout)
|
|
||||||
if not self._thread.is_alive() and self._error is not None:
|
|
||||||
raise self._error
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self.start()
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
self.request_stop()
|
|
||||||
self.join()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
pipeline=CameraPipeline(parse_args())
|
|
||||||
for sig in (signal.SIGINT,signal.SIGTERM):
|
|
||||||
signal.signal(sig,lambda *_:pipeline.request_stop())
|
|
||||||
pipeline.start()
|
|
||||||
# Periodic joins allow Python to deliver stop signals on the main thread.
|
|
||||||
while pipeline.running():
|
|
||||||
pipeline.join(.2)
|
|
||||||
pipeline.join()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__=='__main__':main()
|
|
||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
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 LD_LIBRARY_PATH="$root/orin/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
export LD_LIBRARY_PATH="$root/orin/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||||
exec "${PYTHON:-python3}" -u "$root/run_camera.py" "$@"
|
exec "$root/build/camera_risk" --config "$root/config/camera.json" "$@"
|
||||||
|
|||||||
+15
-23
@@ -1,26 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Refresh the standalone runtime after compiling on the target Orin.
|
# Assemble the C++ runtime; no Python interpreter is used for packaging or execution.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
python3 - "$root" <<'PY'
|
package="$root/orin"
|
||||||
from pathlib import Path
|
[[ -x "$root/build/camera_risk" ]] || { echo 'Compile on Orin with scripts/build.sh first.' >&2; exit 1; }
|
||||||
import hashlib,shutil,sys
|
mkdir -p "$package/build" "$package/config" "$package/annotations" "$package/scripts"
|
||||||
root=Path(sys.argv[1]);package=root/'orin'
|
cp -p "$root/build/camera_risk" "$package/build/"
|
||||||
library=root/'build/libvision_native.so'
|
cp -p "$root/annotations/"*.json "$package/annotations/"
|
||||||
if not library.is_file():raise SystemExit('Run scripts/build.sh on Orin first.')
|
# Source and standalone configurations differ only in the engine directory.
|
||||||
# Replace generated Python directories so removed modules cannot survive packaging.
|
sed 's|../orin/models/engines|../models/engines|' "$root/config/camera.json" > "$package/config/camera.json"
|
||||||
for name in ('pipeline','annotations'):
|
cp -p "$root/scripts/initialize_camera.sh" "$package/scripts/initialize_camera_impl.sh"
|
||||||
target=package/name
|
(
|
||||||
if target.exists():shutil.rmtree(target)
|
cd "$package"
|
||||||
shutil.copytree(root/name,target,ignore=shutil.ignore_patterns('__pycache__','*.pyc'))
|
find . -type f ! -path './outputs/*' ! -path './logs/*' ! -name SHA256SUMS -print0 |
|
||||||
(package/'build').mkdir(exist_ok=True)
|
sort -z | xargs -0 sha256sum > SHA256SUMS
|
||||||
shutil.copy2(library,package/'build/libvision_native.so')
|
)
|
||||||
shutil.copy2(root/'run_camera.py',package/'run_camera.py')
|
echo "C++ runtime ready: $package/build/camera_risk"
|
||||||
shutil.copy2(root/'scripts/initialize_camera.sh',package/'scripts/initialize_camera_impl.sh')
|
|
||||||
for name in ('lib/libusbcanfd.so','lib/libusb-1.0.so','models/engines/yolo11s.engine','models/engines/rtmw_l_384.engine','bin/drivers/max9295.ko','bin/drivers/max9296.ko','bin/drivers/sgx-yuv-gmsl2.ko'):
|
|
||||||
if not (package/name).is_file():raise SystemExit('Missing packaged dependency: '+name)
|
|
||||||
ignored={'__pycache__','outputs','logs'}
|
|
||||||
files=sorted(p for p in package.rglob('*') if p.is_file() and not p.is_symlink() and p.name!='SHA256SUMS' and not ignored.intersection(p.relative_to(package).parts))
|
|
||||||
(package/'SHA256SUMS').write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest()+' '+p.relative_to(package).as_posix()+'\n' for p in files))
|
|
||||||
print('Package ready:',package/'run.sh')
|
|
||||||
PY
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#include "camera.hpp"
|
||||||
|
#include "v4l2_capture.h"
|
||||||
|
#include "uyvy_converter.hpp"
|
||||||
|
#include <linux/videodev2.h>
|
||||||
|
#include <opencv2/videoio.hpp>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstring>
|
||||||
|
#include <stdexcept>
|
||||||
|
namespace cms {
|
||||||
|
std::uint64_t monotonicNs(){return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();}
|
||||||
|
struct Camera::Impl {
|
||||||
|
CameraConfig config;
|
||||||
|
V4l2Capture capture;
|
||||||
|
cv::VideoCapture video;
|
||||||
|
std::unique_ptr<UyvyConverter> converter;
|
||||||
|
std::atomic<bool> stopped{false};
|
||||||
|
std::uint64_t index{};
|
||||||
|
std::uint32_t previousSequence{};
|
||||||
|
bool haveSequence{};
|
||||||
|
explicit Impl(CameraConfig c):config(std::move(c)) {
|
||||||
|
if(!config.video.empty()) {
|
||||||
|
if(!video.open(config.video))throw std::runtime_error("Cannot open video: "+config.video);
|
||||||
|
if(int(video.get(cv::CAP_PROP_FRAME_WIDTH))!=config.width||int(video.get(cv::CAP_PROP_FRAME_HEIGHT))!=config.height)throw std::runtime_error("Video dimensions must match camera configuration");
|
||||||
|
} else {
|
||||||
|
if(!capture.open(config.device)||!capture.setFormat(config.width,config.height,V4L2_PIX_FMT_UYVY))throw std::runtime_error("Camera open/format failed");
|
||||||
|
if(capture.width()!=unsigned(config.width)||capture.height()!=unsigned(config.height))throw std::runtime_error("Unexpected negotiated camera dimensions");
|
||||||
|
capture.setFps(config.fps);
|
||||||
|
if(!capture.initMmap(config.buffers)||!capture.startStreaming())throw std::runtime_error("Camera streaming failed");
|
||||||
|
converter=std::make_unique<UyvyConverter>(config.width,config.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::optional<CapturedFrame> read() {
|
||||||
|
if(stopped)return {};
|
||||||
|
if(video.isOpened()) {
|
||||||
|
cv::Mat bgr;if(!video.read(bgr))return {};
|
||||||
|
auto image=std::make_shared<BgrImage>();image->width=bgr.cols;image->height=bgr.rows;image->stride=bgr.cols*3;
|
||||||
|
image->pixels.resize(std::size_t(image->stride)*image->height);
|
||||||
|
for(int y=0;y<bgr.rows;++y)std::memcpy(image->pixels.data()+y*image->stride,bgr.ptr(y),image->stride);
|
||||||
|
CaptureMetadata m{};m.frame_index=index;m.driver_sequence=index++;m.capture_monotonic_ns=m.received_monotonic_ns=monotonicNs();return CapturedFrame{m,image};
|
||||||
|
}
|
||||||
|
auto started=monotonicNs();unsigned misses=0;
|
||||||
|
while(!stopped) {
|
||||||
|
V4l2Capture::Frame f;
|
||||||
|
if(!capture.getFrame(f,100)) {
|
||||||
|
++misses;
|
||||||
|
if((monotonicNs()-started)*1e-6>=config.timeout_ms)throw std::runtime_error("Camera timeout or repeated invalid frames");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
CaptureMetadata m{};m.frame_index=index++;m.driver_sequence=f.sequence;
|
||||||
|
m.received_monotonic_ns=m.capture_monotonic_ns=monotonicNs();m.driver_timestamp_ns=f.timestamp_us*1000;m.discarded_error_frames=misses;
|
||||||
|
if(haveSequence){auto delta=std::uint32_t(f.sequence-previousSequence);if(delta>0&&delta<0x80000000U)m.skipped_driver_frames=delta-1;}
|
||||||
|
previousSequence=f.sequence;haveSequence=true;
|
||||||
|
auto stride=f.bytes_per_line?f.bytes_per_line:unsigned(config.width*2);
|
||||||
|
try {
|
||||||
|
if(f.size<std::size_t(stride)*config.height)throw std::runtime_error("Incomplete camera frame");
|
||||||
|
auto image=converter->convert(f.data,stride);capture.releaseFrame(f);return CapturedFrame{m,std::move(image)};
|
||||||
|
}catch(...){capture.releaseFrame(f);throw;}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Camera::Camera(CameraConfig c):impl_(std::make_unique<Impl>(std::move(c))){}
|
||||||
|
Camera::~Camera()=default;
|
||||||
|
std::optional<CapturedFrame> Camera::read(){return impl_->read();}
|
||||||
|
void Camera::requestStop()noexcept{impl_->stopped=true;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
#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 "door_zone.hpp"
|
||||||
|
#include "can_state.hpp"
|
||||||
|
#include "serialization.hpp"
|
||||||
|
#include <fstream>
|
||||||
|
#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::unique_ptr<DoorZoneAssigner> zone;
|
||||||
|
std::unique_ptr<CanInput> can;
|
||||||
|
std::ofstream output, poseOutput;
|
||||||
|
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.can_side!="left"&&config.can_side!="right")throw std::invalid_argument("CAN side must be left or right");
|
||||||
|
if(config.active_door!="can"&&config.active_door!="front"&&config.active_door!="rear"&&config.active_door!="both"&&config.active_door!="none")throw std::invalid_argument("Invalid active door");
|
||||||
|
if(!config.can_enabled&&config.active_door=="can")throw std::invalid_argument("Disabled CAN requires an explicit simulated active door");
|
||||||
|
if(config.can_timeout_ms<=0)throw std::invalid_argument("CAN timeout must be positive");
|
||||||
|
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);
|
||||||
|
zone=std::make_unique<DoorZoneAssigner>(config.door_zone_config);
|
||||||
|
auto openOutput=[](std::ofstream& out,std::filesystem::path const& path) {
|
||||||
|
if(path.empty())return;
|
||||||
|
if(!path.parent_path().empty())std::filesystem::create_directories(path.parent_path());
|
||||||
|
out.exceptions(std::ios::failbit|std::ios::badbit);out.open(path);
|
||||||
|
};
|
||||||
|
openOutput(output,config.results_path);openOutput(poseOutput,config.pose_records_path);
|
||||||
|
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();}
|
||||||
|
can=std::make_unique<CanInput>(config.can_enabled);
|
||||||
|
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;
|
||||||
|
bool queued=config.camera.video.empty()?captures.pushLatest(std::move(*frame)):captures.push(std::move(*frame));
|
||||||
|
if(!queued)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);
|
||||||
|
Detected item{std::move(*frame),std::move(boxes),elapsedMs(startedAt)};
|
||||||
|
bool queued=config.camera.video.empty()?detections.pushLatest(std::move(item)):detections.push(std::move(item));
|
||||||
|
if(!queued)break;
|
||||||
|
}
|
||||||
|
detections.close();
|
||||||
|
}
|
||||||
|
void inferenceLoop() {
|
||||||
|
Stream stream;fangjia::BoxTracker boxesTracker;fangjia::PoseFusionTracker fusion;std::uint64_t processed=0;
|
||||||
|
while(!stopped) {
|
||||||
|
auto detected=detections.pop();if(!detected)break;
|
||||||
|
if(processed>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>(processed++);
|
||||||
|
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,poseOutput.is_open(),true);
|
||||||
|
auto riskData=risk->evaluate(index,record);
|
||||||
|
auto result=std::make_shared<FrameResult>(makeFrameResult(record,riskData,detected->frame.metadata));
|
||||||
|
auto assignments=zone->update(index,record,frame.size());
|
||||||
|
result->can=can->snapshot(config.can_side,config.can_timeout_ms);
|
||||||
|
result->active_door=activeDoor(result->can);
|
||||||
|
if(config.active_door!="can") {result->active_door=config.active_door;result->can.source="simulation";result->can.valid=true;}
|
||||||
|
auto decisions=buildDoorRisks(riskData,assignments,result->active_door,result->can.valid);
|
||||||
|
result->risk=parseRisk(decisions.selected);
|
||||||
|
for(auto const& entry:decisions.doors.items())result->door_risks[entry.key()]=parseRisk(entry.value());
|
||||||
|
for(auto const& a:assignments)result->door_assignments.push_back(parseAssignment(a));
|
||||||
|
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;}
|
||||||
|
if(output.is_open())output<<serializeResult(*result).dump()<<'\n';
|
||||||
|
if(poseOutput.is_open())poseOutput<<record.dump()<<'\n';
|
||||||
|
if(config.max_frames&&processed>=config.max_frames){stop();break;}
|
||||||
|
}
|
||||||
|
if(output.is_open())output.flush();
|
||||||
|
if(poseOutput.is_open())poseOutput.flush();
|
||||||
|
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_->can.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};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
|
#include <atomic>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
#include "zcan.h"
|
#include "zcan.h"
|
||||||
@@ -31,26 +32,27 @@ typedef struct
|
|||||||
int dev_idx; // 设备索引
|
int dev_idx; // 设备索引
|
||||||
int chn_idx; // 通道号
|
int chn_idx; // 通道号
|
||||||
int total; // 接收总数
|
int total; // 接收总数
|
||||||
int stop; // 线程结束标志
|
std::atomic<int> stop{0}; // 线程结束标志
|
||||||
} THREAD_CTX;
|
} THREAD_CTX;
|
||||||
|
|
||||||
/* CAN 运行标志, 由信号处理函数(Ctrl+C)或外部置位 */
|
/* CAN 运行标志, 由信号处理函数(Ctrl+C)或外部置位 */
|
||||||
static volatile bool g_can_running = true;
|
static volatile bool g_can_running = true;
|
||||||
|
|
||||||
static int g_receive_count = 0;
|
static std::atomic<int> g_receive_count{0};
|
||||||
|
|
||||||
/* ===== 仅供 can_start/can_stop 共享的文件内静态变量 ===== */
|
/* ===== 仅供 can_start/can_stop 共享的文件内静态变量 ===== */
|
||||||
static int s_DevType = USBCANFD;
|
static int s_DevType = USBCANFD;
|
||||||
static int s_DevIdx = 0;
|
static int s_DevIdx = 0;
|
||||||
static THREAD_CTX s_rx_ctx[MAX_CHANNELS];
|
static THREAD_CTX s_rx_ctx[MAX_CHANNELS];
|
||||||
static pthread_t s_rx_threads[MAX_CHANNELS];
|
static pthread_t s_rx_threads[MAX_CHANNELS];
|
||||||
|
static bool s_thread_started[MAX_CHANNELS]{};
|
||||||
static bool s_can_opened = false;
|
static bool s_can_opened = false;
|
||||||
|
|
||||||
namespace CanComm {
|
namespace CanComm {
|
||||||
|
|
||||||
bool is_open() { return s_can_opened; }
|
bool is_open() { return s_can_opened; }
|
||||||
|
|
||||||
int receive_count() { return g_receive_count; }
|
int receive_count() { return g_receive_count.load(); }
|
||||||
|
|
||||||
/* ===== 报文解析回调 (由 main.cpp 通过 set_msg_callback 注册) ===== */
|
/* ===== 报文解析回调 (由 main.cpp 通过 set_msg_callback 注册) ===== */
|
||||||
static MsgCallback s_msg_cb = nullptr;
|
static MsgCallback s_msg_cb = nullptr;
|
||||||
@@ -218,7 +220,7 @@ bool can_start()
|
|||||||
// 初始化,启动通道
|
// 初始化,启动通道
|
||||||
for (int i = 0; i < MAX_CHANNELS; i++)
|
for (int i = 0; i < MAX_CHANNELS; i++)
|
||||||
{
|
{
|
||||||
ZCAN_INIT init; // 波特率结构体,数据根据zcanpro的波特率计算器得出
|
ZCAN_INIT init{}; // 波特率结构体,数据根据zcanpro的波特率计算器得出
|
||||||
init.clk = 60000000; // clock: 60M(V1.01) 80M(V1.03即以上)
|
init.clk = 60000000; // clock: 60M(V1.01) 80M(V1.03即以上)
|
||||||
init.mode = 0; // 0-正常
|
init.mode = 0; // 0-正常
|
||||||
|
|
||||||
@@ -237,7 +239,7 @@ bool can_start()
|
|||||||
if (!VCI_InitCAN(s_DevType, s_DevIdx, i, &init)) // 初始化通道
|
if (!VCI_InitCAN(s_DevType, s_DevIdx, i, &init)) // 初始化通道
|
||||||
{
|
{
|
||||||
printf("InitCAN(%d) fail\n", i);
|
printf("InitCAN(%d) fail\n", i);
|
||||||
return false;
|
can_stop();return false;
|
||||||
}
|
}
|
||||||
printf("InitCAN(%d) success\n", i);
|
printf("InitCAN(%d) success\n", i);
|
||||||
|
|
||||||
@@ -258,7 +260,7 @@ bool can_start()
|
|||||||
if (!VCI_StartCAN(s_DevType, s_DevIdx, i)) // 启动通道
|
if (!VCI_StartCAN(s_DevType, s_DevIdx, i)) // 启动通道
|
||||||
{
|
{
|
||||||
printf("StartCAN(%d) fail\n", i);
|
printf("StartCAN(%d) fail\n", i);
|
||||||
return false;
|
can_stop();return false;
|
||||||
}
|
}
|
||||||
printf("StartCAN(%d) success\n", i);
|
printf("StartCAN(%d) success\n", i);
|
||||||
|
|
||||||
@@ -270,12 +272,13 @@ bool can_start()
|
|||||||
|
|
||||||
if (IsMerge && i == 0)
|
if (IsMerge && i == 0)
|
||||||
{
|
{
|
||||||
pthread_create(&s_rx_threads[i], NULL, rx_merge_thread, &s_rx_ctx[i]); // 合并接收线程
|
s_thread_started[i]=pthread_create(&s_rx_threads[i], NULL, rx_merge_thread, &s_rx_ctx[i])==0; // 合并接收线程
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
pthread_create(&s_rx_threads[i], NULL, rx_thread, &s_rx_ctx[i]); // 创建接收线程
|
s_thread_started[i]=pthread_create(&s_rx_threads[i], NULL, rx_thread, &s_rx_ctx[i])==0; // 创建接收线程
|
||||||
}
|
}
|
||||||
|
if(!s_thread_started[i]){can_stop();return false;}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -293,7 +296,7 @@ void can_stop()
|
|||||||
for (int i = 0; i < MAX_CHANNELS; i++)
|
for (int i = 0; i < MAX_CHANNELS; i++)
|
||||||
{
|
{
|
||||||
s_rx_ctx[i].stop = 1;
|
s_rx_ctx[i].stop = 1;
|
||||||
pthread_join(s_rx_threads[i], NULL); // 等待接收线程退出
|
if(s_thread_started[i]){pthread_join(s_rx_threads[i], NULL);s_thread_started[i]=false;} // 等待接收线程退出
|
||||||
|
|
||||||
if (!VCI_ResetCAN(s_DevType, s_DevIdx, i)) // 复位通道
|
if (!VCI_ResetCAN(s_DevType, s_DevIdx, i)) // 复位通道
|
||||||
printf("ResetCAN(%d) fail\n", i);
|
printf("ResetCAN(%d) fail\n", i);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#include "can_state.hpp"
|
||||||
|
#include "can_comm.h"
|
||||||
|
#include "camera.hpp"
|
||||||
|
#include <mutex>
|
||||||
|
#include <stdexcept>
|
||||||
|
namespace cms {
|
||||||
|
namespace {
|
||||||
|
std::mutex mutex;
|
||||||
|
std::array<int,4> positions{{-1,-1,-1,-1}};
|
||||||
|
std::uint64_t timestamp{};
|
||||||
|
bool owned{};
|
||||||
|
void received(int64_t,uint32_t id,uint8_t const* data,uint32_t size) {
|
||||||
|
if(id!=0x391||size<4)return;
|
||||||
|
std::lock_guard<std::mutex> lock(mutex);
|
||||||
|
for(int i=0;i<4;++i)positions[i]=data[i];
|
||||||
|
timestamp=monotonicNs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CanInput::CanInput(bool enabled) {
|
||||||
|
{std::lock_guard<std::mutex> lock(mutex);if(owned)throw std::logic_error("Only one CAN input may run per process");owned=true;positions.fill(-1);timestamp=0;}
|
||||||
|
if(enabled){CanComm::set_msg_callback(received);opened_=CanComm::can_start();}
|
||||||
|
}
|
||||||
|
CanInput::~CanInput() {
|
||||||
|
if(opened_)CanComm::can_stop();
|
||||||
|
CanComm::set_msg_callback(nullptr);
|
||||||
|
std::lock_guard<std::mutex> lock(mutex);owned=false;
|
||||||
|
}
|
||||||
|
CanSnapshot CanInput::snapshot(std::string const& side,double timeoutMs) const {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex);CanSnapshot s;s.positions=positions;s.side=side;
|
||||||
|
s.age_ms=timestamp?(monotonicNs()-timestamp)*1e-6:-1;
|
||||||
|
int offset=side=="left"?0:1;
|
||||||
|
s.valid=s.age_ms>=0&&s.age_ms<=timeoutMs&&s.positions[offset]>=0&&s.positions[offset+2]>=0;return s;
|
||||||
|
}
|
||||||
|
std::string activeDoor(CanSnapshot const& s) {
|
||||||
|
if(!s.valid)return "none";
|
||||||
|
int i=s.side=="left"?0:1;bool front=s.positions[i]==0,rear=s.positions[i+2]==0;
|
||||||
|
return front&&rear?"both":front?"front":rear?"rear":"none";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#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");
|
||||||
|
config.door_zone_config=resolve(door.at("zone_config"));
|
||||||
|
if(j.contains("can")) {
|
||||||
|
auto const& can=j.at("can");config.can_enabled=can.value("enabled",true);
|
||||||
|
config.can_side=can.value("side",std::string("left"));config.can_timeout_ms=can.value("timeout_ms",1000.0);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,4 +56,15 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#include "door_zone.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <fstream>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
namespace cms {
|
||||||
|
namespace {
|
||||||
|
double median(std::deque<double> const& values) {
|
||||||
|
std::vector<double> sorted(values.begin(),values.end());
|
||||||
|
std::sort(sorted.begin(),sorted.end());auto n=sorted.size();
|
||||||
|
return n%2?sorted[n/2]:(sorted[n/2-1]+sorted[n/2])*0.5;
|
||||||
|
}
|
||||||
|
std::string upper(std::string value) { for(auto& c:value)c=char(std::toupper(static_cast<unsigned char>(c)));return value; }
|
||||||
|
Json features(Json const& record,std::size_t position,cv::Size size,int& poseCount) {
|
||||||
|
auto const& b=record.at("boxes").at(position);
|
||||||
|
double w=std::max(size.width,1),h=std::max(size.height,1);
|
||||||
|
Json result={{"box_center_x",(b[0].get<double>()+b[2].get<double>())*.5/w},{"box_height",(b[3].get<double>()-b[1].get<double>())/h}};
|
||||||
|
poseCount=0;
|
||||||
|
if(!record.contains("temporal_selected_keypoints")||position>=record["temporal_selected_keypoints"].size())return result;
|
||||||
|
auto const& points=record["temporal_selected_keypoints"][position];
|
||||||
|
for(int k:{5,6,11,12})if(k>=int(points.size())||points[k].size()<2||!std::isfinite(points[k][0].get<double>())||!std::isfinite(points[k][1].get<double>()))return result;
|
||||||
|
double sx=(points[5][0].get<double>()+points[6][0].get<double>())*.5;
|
||||||
|
double sy=(points[5][1].get<double>()+points[6][1].get<double>())*.5;
|
||||||
|
double hy=(points[11][1].get<double>()+points[12][1].get<double>())*.5;
|
||||||
|
result["shoulder_center_x"]=sx/w;result["shoulder_center_y"]=sy/h;
|
||||||
|
result["shoulder_width"]=std::hypot(points[5][0].get<double>()-points[6][0].get<double>(),points[5][1].get<double>()-points[6][1].get<double>())/w;
|
||||||
|
result["torso_height"]=std::abs(hy-sy)/h;result["hip_center_y"]=hy/h;poseCount=5;return result;
|
||||||
|
}
|
||||||
|
Json baseRisk(std::string const& door,std::string const& level,Json score,std::string const& status,std::string const& decision,Json regions=Json::array()) {
|
||||||
|
return {{"door_id",door},{"status",status},{"risk_score",score},{"risk_level",level},{"decision",decision},{"risk_type",upper(door)+"_"+upper(level)},{"source",nullptr},{"track_id",nullptr},{"side",nullptr},{"regions",regions}};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DoorZoneAssigner::DoorZoneAssigner(std::filesystem::path const& configuration) {
|
||||||
|
std::ifstream file(configuration);if(!file)throw std::runtime_error("Cannot read door-zone configuration: "+configuration.string());
|
||||||
|
auto j=Json::parse(file);features_=j.at("features");
|
||||||
|
frontMin_=j.value("front_probability_min",.72);rearMax_=j.value("rear_probability_max",.28);
|
||||||
|
minimumHeight_=j.value("minimum_person_height",.4);minimumPoseFeatures_=j.value("minimum_pose_features",4);
|
||||||
|
historyFrames_=j.value("history_frames",5);staleFrames_=j.value("stale_track_frames",120);
|
||||||
|
if(historyFrames_<1||staleFrames_<0||rearMax_<0||frontMin_>1||rearMax_>=frontMin_)throw std::invalid_argument("Invalid door-zone configuration");
|
||||||
|
}
|
||||||
|
std::string DoorZoneAssigner::classify(double p) const {return p>=frontMin_?"front":p<=rearMax_?"rear":"transition";}
|
||||||
|
Json DoorZoneAssigner::update(int frame,Json const& record,cv::Size size) {
|
||||||
|
Json assignments=Json::array();auto const& boxes=record.at("boxes");auto ids=record.value("track_ids",Json::array());
|
||||||
|
for(std::size_t i=0;i<boxes.size();++i) {
|
||||||
|
int id=i<ids.size()?ids[i].get<int>():int(i)+1,count=0;
|
||||||
|
auto f=features(record,i,size,count);double weighted=0,total=0;Json contributions=Json::object();
|
||||||
|
for(auto const& entry:features_.items())if(f.contains(entry.key())) {
|
||||||
|
auto const& c=entry.value();double rear=c.at("rear"),front=c.at("front"),weight=c.at("weight");
|
||||||
|
double score=front==rear?.5:std::clamp((f[entry.key()].get<double>()-rear)/(front-rear),0.0,1.0);
|
||||||
|
contributions[entry.key()]=rounded(score,6);weighted+=score*weight;total+=weight;
|
||||||
|
}
|
||||||
|
double probability=total?weighted/total:.5;std::string rawZone,zone;
|
||||||
|
if(f["box_height"].get<double>()<minimumHeight_)rawZone=zone="unassigned";
|
||||||
|
else if(count<minimumPoseFeatures_)rawZone=zone="transition";
|
||||||
|
else {rawZone=classify(probability);auto& values=history_[id];values.push_back(probability);while(int(values.size())>historyFrames_)values.pop_front();zone=classify(median(values));}
|
||||||
|
auto it=history_.find(id);double smoothed=it==history_.end()||it->second.empty()?probability:median(it->second);
|
||||||
|
lastSeen_[id]=frame;for(auto& value:f.items())value.value()=rounded(value.value().get<double>(),6);
|
||||||
|
assignments.push_back({{"track_id",id},{"person_index",i},{"door_zone",zone},{"raw_door_zone",rawZone},{"front_probability",rounded(probability,6)},{"smoothed_front_probability",rounded(smoothed,6)},{"confidence",rounded(std::abs(smoothed-.5)*2,6)},{"evidence_source","pose_primary_bbox_secondary"},{"pose_feature_count",count},{"features",f},{"feature_frontness",contributions},{"history_frames",it==history_.end()?0:it->second.size()}});
|
||||||
|
}
|
||||||
|
for(auto it=lastSeen_.begin();it!=lastSeen_.end();)if(frame-it->second>staleFrames_){history_.erase(it->first);it=lastSeen_.erase(it);}else ++it;
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
DoorDecisions buildDoorRisks(Json const& raw,Json const& assignments,std::string const& active,bool canValid) {
|
||||||
|
std::map<int,std::string> zones;for(auto const& a:assignments)zones[a.at("track_id").get<int>()]=a.at("door_zone");
|
||||||
|
Json regions=Json::array();for(auto region:raw.value("regions",Json::array())){int id=region.value("track_id",-1);region["door_zone"]=zones.count(id)?zones[id]:"unknown";regions.push_back(std::move(region));}
|
||||||
|
DoorDecisions result{Json::object(),Json::object()};
|
||||||
|
for(std::string door:{"front","rear"}) {
|
||||||
|
if(active!="both"&&active!=door){result.doors[door]=baseRisk(door,"inactive",nullptr,"inactive","DOOR_CAN_NOT_ACTIVE");continue;}
|
||||||
|
Json exact=Json::array(),transition=Json::array(),nearest=nullptr;
|
||||||
|
for(auto const& r:regions){if(r["door_zone"]==door){exact.push_back(r);if(!r.at("risk_score").is_null()&&(nearest.is_null()||r["risk_score"].get<double>()>nearest["risk_score"].get<double>()))nearest=r;}if(r["door_zone"]=="transition")transition.push_back(r);}
|
||||||
|
if(!nearest.is_null()&&(transition.empty()||(nearest["risk_level"]!="clear"&&nearest["risk_level"]!="watch"))) {
|
||||||
|
auto level=nearest["risk_level"].get<std::string>();auto r=baseRisk(door,level,nearest["risk_score"],"calculated",nearest["decision"],exact);
|
||||||
|
for(auto key:{"source","track_id","side"}){r[key]=nearest[key];}
|
||||||
|
result.doors[door]=std::move(r);
|
||||||
|
} else if(!transition.empty())result.doors[door]=baseRisk(door,"unknown",nullptr,"door_zone_ambiguous","EVIDENCE_UNAVAILABLE_TRANSITION_ZONE",transition);
|
||||||
|
else result.doors[door]=baseRisk(door,"clear",0.0,"calculated","CLEAR_NO_PERSON_ASSIGNED_TO_ACTIVE_DOOR");
|
||||||
|
}
|
||||||
|
if(active=="front"||active=="rear")result.selected=result.doors[active];
|
||||||
|
else if(active=="none") {
|
||||||
|
result.selected=baseRisk("none","inactive",nullptr,"inactive","NO_DOOR_CAN_ACTIVE");result.selected["door_id"]=nullptr;result.selected["risk_type"]="NO_ACTIVE_DOOR";
|
||||||
|
} else {
|
||||||
|
std::map<std::string,int> severity{{"danger",4},{"warning",3},{"watch",2},{"unknown",1},{"clear",0}};
|
||||||
|
result.selected=result.doors["front"];
|
||||||
|
if(severity[result.doors["rear"]["risk_level"]]>severity[result.selected["risk_level"]])result.selected=result.doors["rear"];
|
||||||
|
}
|
||||||
|
for(auto key:{"door_mask","warning_distance_relative","release_distance_relative"})result.selected[key]=raw.at(key);
|
||||||
|
if(!canValid) {
|
||||||
|
for(auto& entry:result.doors.items()){auto& r=entry.value();r["risk_type"]=upper(entry.key())+"_UNKNOWN";r["risk_level"]="unknown";r["risk_score"]=nullptr;r["decision"]="CAN_UNAVAILABLE_OR_STALE";}
|
||||||
|
result.selected["risk_type"]="CAN_UNKNOWN";result.selected["risk_level"]="unknown";result.selected["risk_score"]=nullptr;result.selected["decision"]="CAN_UNAVAILABLE_OR_STALE";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#include "camera_pipeline.hpp"
|
||||||
|
#include "config.hpp"
|
||||||
|
#include "stop_signals.hpp"
|
||||||
|
#include "visualizer.hpp"
|
||||||
|
#include <chrono>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
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";
|
||||||
|
for(int i=1;i<argc;++i)if(std::string(argv[i])=="--config") {
|
||||||
|
if(++i>=argc)throw std::invalid_argument("--config needs a path");configPath=argv[i];
|
||||||
|
}
|
||||||
|
for(int i=1;i<argc;++i)if(std::string(argv[i])=="--help"||std::string(argv[i])=="-h") {
|
||||||
|
std::cout<<"camera_risk [--config FILE] [--visualize] [--active-door can|front|rear|both|none]\n"
|
||||||
|
<<" [--can-side left|right] [--no-can] [--duration SECONDS] [--max-frames N]\n"
|
||||||
|
<<" [--video FILE] [--save-results FILE.jsonl] [--save-pose-records FILE.jsonl]\n"
|
||||||
|
<<"Pure C++ inference; the latest immutable FrameResult is kept in memory.\n";return 0;
|
||||||
|
}
|
||||||
|
auto config=cms::loadConfig(configPath);bool visualize=false;double duration=0;
|
||||||
|
auto value=[&](int& i){if(++i>=argc)throw std::invalid_argument("Missing option value");return std::string(argv[i]);};
|
||||||
|
for(int i=1;i<argc;++i) {
|
||||||
|
std::string arg=argv[i];
|
||||||
|
if(arg=="--config")value(i);
|
||||||
|
else if(arg=="--visualize")visualize=true;
|
||||||
|
else if(arg=="--no-can")config.can_enabled=false;
|
||||||
|
else if(arg=="--active-door")config.active_door=value(i);
|
||||||
|
else if(arg=="--can-side")config.can_side=value(i);
|
||||||
|
else if(arg=="--video")config.camera.video=value(i);
|
||||||
|
else if(arg=="--save-results")config.results_path=value(i);
|
||||||
|
else if(arg=="--save-pose-records")config.pose_records_path=value(i);
|
||||||
|
else if(arg=="--max-frames"){auto n=std::stoll(value(i));if(n<0)throw std::invalid_argument("Negative frame count");config.max_frames=n;}
|
||||||
|
else if(arg=="--duration"){duration=std::stod(value(i));if(!std::isfinite(duration)||duration<0)throw std::invalid_argument("Invalid duration");}
|
||||||
|
else throw std::invalid_argument("Unknown option: "+arg);
|
||||||
|
}
|
||||||
|
cv::setNumThreads(2);
|
||||||
|
cms::CameraPipeline pipeline(config);cms::StopSignals signals(pipeline);pipeline.start();
|
||||||
|
auto started=std::chrono::steady_clock::now();std::atomic<bool> monitorStop{false};
|
||||||
|
std::thread monitor([&] {
|
||||||
|
auto report=started;
|
||||||
|
while(!monitorStop&&pipeline.running()) {
|
||||||
|
auto now=std::chrono::steady_clock::now();double elapsed=std::chrono::duration<double>(now-started).count();
|
||||||
|
if(duration>0&&elapsed>=duration){pipeline.requestStop();break;}
|
||||||
|
if(std::chrono::duration<double>(now-report).count()>=5) {
|
||||||
|
auto s=pipeline.stats();auto r=pipeline.latest();
|
||||||
|
std::cout<<"frames="<<s.published<<" fps="<<s.published/elapsed<<" risk="<<(r?r->risk.risk_type:"STARTING")<<std::endl;report=now;
|
||||||
|
}
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if(visualize) {
|
||||||
|
if(!std::getenv("DISPLAY")&&!std::getenv("WAYLAND_DISPLAY"))std::cerr<<"No display session; inference continues without visualization.\n";
|
||||||
|
else try{cms::runLocalVisualization(pipeline);}catch(std::exception const& e){std::cerr<<"Visualization unavailable; inference continues: "<<e.what()<<std::endl;}
|
||||||
|
}
|
||||||
|
// Downstream C++ code reads this same variable from its own thread:
|
||||||
|
// auto result = pipeline.latest(); // shared_ptr<const cms::FrameResult>
|
||||||
|
// if(result) { use(result->risk, result->door_risks, result->persons); }
|
||||||
|
pipeline.join();
|
||||||
|
}catch(...){monitorStop=true;pipeline.requestStop();monitor.join();throw;}
|
||||||
|
monitorStop=true;monitor.join();auto stats=pipeline.stats();
|
||||||
|
auto seconds=std::chrono::duration<double>(std::chrono::steady_clock::now()-started).count();
|
||||||
|
std::cout<<"frames="<<stats.published<<" elapsed_seconds="<<seconds<<" fps="<<stats.published/std::max(seconds,.001)<<" capture_to_result_ms="<<stats.mean_capture_to_result_ms<<std::endl;
|
||||||
|
return 0;
|
||||||
|
}catch(std::exception const& e){std::cerr<<"Camera pipeline failed: "<<e.what()<<std::endl;return 1;}
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#include "serialization.hpp"
|
||||||
|
namespace cms {
|
||||||
|
namespace {
|
||||||
|
template<class T> Json optional(std::optional<T> const& x){return x?Json(*x):Json(nullptr);}
|
||||||
|
Json riskJson(RiskResult const& r) {
|
||||||
|
Json out={{"status",r.status},{"risk_score",optional(r.risk_score)},{"risk_level",r.risk_level},{"decision",r.decision},{"source",optional(r.source)},{"track_id",optional(r.track_id)},{"side",optional(r.side)},{"regions",Json::array()}};
|
||||||
|
if(!r.risk_type.empty()){out["risk_type"]=r.risk_type;out["door_id"]=optional(r.door_id);}
|
||||||
|
if(!r.door_mask.status.empty()) {
|
||||||
|
auto const& d=r.door_mask;out["door_mask"]={{"status",d.status},{"annotation_index",d.annotation_index},{"path",optional(d.path)},{"format",d.format},{"area_px",d.area_px}};
|
||||||
|
out["warning_distance_relative"]=r.warning_distance_relative;out["release_distance_relative"]=r.release_distance_relative;
|
||||||
|
}
|
||||||
|
for(auto const& v:r.regions) {
|
||||||
|
Json j={{"status",v.status},{"risk_score",optional(v.risk_score)},{"risk_level",v.risk_level},{"decision",v.decision},{"source",v.source},{"track_id",v.track_id},{"side",v.side}};
|
||||||
|
if(v.state)j["state"]=*v.state;
|
||||||
|
if(!v.door_zone.empty())j["door_zone"]=v.door_zone;
|
||||||
|
if(v.region_area_px) {
|
||||||
|
j["region_area_px"]=optional(v.region_area_px);j["door_overlap_px"]=optional(v.door_overlap_px);j["region_overlap_ratio"]=optional(v.region_overlap_ratio);
|
||||||
|
j["min_distance_px"]=optional(v.min_distance_px);j["region_scale_px"]=optional(v.region_scale_px);j["distance_relative"]=optional(v.distance_relative);
|
||||||
|
}
|
||||||
|
out["regions"].push_back(std::move(j));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Json serializeResult(FrameResult const& r) {
|
||||||
|
Json out={{"frame_index",r.frame_index},{"track_ids",r.track_ids},{"boxes",r.boxes},{"skeletons",Json::array()},{"active_door",r.active_door},{"door_assignments",Json::array()},{"door_risks",Json::object()},{"risk",riskJson(r.risk)},{"ungated_risk",riskJson(r.ungated_risk)}};
|
||||||
|
for(std::size_t i=0;i<r.persons.size();++i) {
|
||||||
|
auto const& p=r.persons[i];Json points=Json::array(),metadata=Json::object();for(auto const& k:p.selected)points.push_back({rounded(k.x,3),rounded(k.y,3)});
|
||||||
|
for(int s=0;s<2;++s){auto const& h=p.hands[s];metadata[s==0?"left":"right"]={{"limb_state",h.limb_consensus?"consensus":"inferred"},{"hand_source",h.source},{"hand_state",h.state},{"distance_eligible",h.distance_eligible},{"alarm_eligible",h.alarm_eligible}};}
|
||||||
|
out["skeletons"].push_back({{"track_id",p.track_id},{"person_index",i},{"layout","coco_wholebody_133"},{"keypoints",points},{"metadata",metadata}});
|
||||||
|
}
|
||||||
|
for(auto const& a:r.door_assignments)out["door_assignments"].push_back({{"track_id",a.track_id},{"person_index",a.person_index},{"door_zone",a.door_zone},{"raw_door_zone",a.raw_door_zone},{"front_probability",a.front_probability},{"smoothed_front_probability",a.smoothed_front_probability},{"confidence",a.confidence},{"evidence_source",a.evidence_source},{"pose_feature_count",a.pose_feature_count},{"history_frames",a.history_frames},{"features",a.features},{"feature_frontness",a.feature_frontness}});
|
||||||
|
for(auto const& entry:r.door_risks)out["door_risks"][entry.first]=riskJson(entry.second);
|
||||||
|
out["can"]={{"positions",r.can.positions},{"age_ms",r.can.age_ms},{"valid",r.can.valid},{"source",r.can.source},{"side",r.can.side}};
|
||||||
|
out["capture"]={{"sequence",r.capture.driver_sequence},{"capture_monotonic_ms",r.capture.capture_monotonic_ns*1e-6},{"driver_timestamp_ms",r.capture.driver_timestamp_ns*1e-6}};
|
||||||
|
out["capture_to_result_ms"]=rounded(r.timing.capture_to_result_ms,3);out["calibration_verified"]=r.door_mask_calibration_verified;return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,12 @@ 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));
|
||||||
@@ -53,6 +59,19 @@ 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);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#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(auto const& a:r.door_assignments)if(a.track_id==person.track_id)
|
||||||
|
cv::putText(canvas,a.door_zone,{int(b[0])+4,std::max(155,int(b[1])-8)},cv::FONT_HERSHEY_SIMPLEX,.7,{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<<r.risk.risk_type<<" ";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+" | CAN "+r.can.source+" | 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
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.
|
||||||
Vendored
+24765
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user