Add H.264 inference session recording and replay
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# H.264 推理过程录制与回灌验证(2026-09-24)
|
||||
|
||||
范围:按本次要求,仅修改 `inference-only`。测试直接在当前 `orin-desktop` 的 Jetson Orin 上执行;真实 `/dev/video5`,1920×1536 UYVY,L4T R35.4.1,CUDA 11.4,TensorRT,OpenCV 4.5.4 / FFmpeg H.264。未使用 SSH。
|
||||
|
||||
## 本机模拟与编解码回归
|
||||
|
||||
- `tests/run_session.sh`:实际 H.264 编码/解码,使用人工图像及模拟结果,不加载 TensorRT。覆盖有步长的 BGR 输入、视频帧顺序、帧号间隔、配置恢复、原始时间戳保留、编码器刷新、正常 EOF、空会话、拒绝覆盖、缺失 JSON、非法/截断索引、不完整会话和奇数尺寸。启用 UBSan。
|
||||
- `tests/run_camera_timeout.sh`:模拟 V4L2 采集;通过超时重试、有效帧后重新计数、第六次超时失败与主动停止回归。此项不是本次真实硬件故障恢复的证明。
|
||||
|
||||
## 真实摄像头与 TensorRT
|
||||
|
||||
脚本:`tests/orin_session_test.py`。完整数据见 `orin_session_results.json`。
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| 摄像头录制 | 13 个完成推理的帧,H.264 / MP4,1920×1536 |
|
||||
| 视频体积 | 303,989 字节;同帧数未压缩 BGR 为 115,015,680 字节,约缩小 378 倍,仅代表本次静态场景 |
|
||||
| 完整回灌 | 13/13 帧,顺序与原帧号一致,原始采集元数据保留,EOF 正常退出 |
|
||||
| 结果检查 | 13/13 跟踪 ID 与风险决策一致;本次无检测到的人员 |
|
||||
| 录制期间 SIGTERM | 1.417 秒完整退出,视频封装成功 |
|
||||
| 回灌期间 SIGINT | 0.865 秒完整退出,保存 2 帧后停止,视频封装成功 |
|
||||
| 实际运行程序核对 | `/proc/<pid>/exe` 与源码构建二进制 SHA256 相同 |
|
||||
|
||||
实机首次测试发现:若在 `CameraPipeline` 构造期间打开视频解码器,FFmpeg 工作线程可能在 `StopSignals` 安装前创建,SIGINT 可直接终止进程。已将解码器打开操作移至继承信号掩码的采集线程,修复后重跑上述实机测试全部通过。
|
||||
|
||||
测试前没有 `camera_risk` 进程,未更改既有启动参数;测试脚本仅管理自己启动的进程,测试后不留下常驻进程。原始视频、逐帧记录与日志在 `/tmp/vap-video-session-hardware-20260924-fixed/`,不提交摄像头图像。
|
||||
|
||||
## 板端故障注入与范围限制
|
||||
|
||||
本次未做板端 V4L2 故障注入或物理拔线操作;共享相机恢复实现保持原样,其既有硬件报告见 `ORIN_TEST_REPORT.md`,不能视作本次新增功能的实测覆盖。当前实机画面没有检测到人员,RTMW 完成初始化/预热,但未覆盖有人员的逐帧姿态推理、遮挡隔离推理或风险变化;报告中的关键点差异为 0 不代表有人员场景的精度结论。
|
||||
|
||||
未覆盖 GUI 预览、GPU 故障、磁盘写满、断电/强杀后的媒体恢复、长时间录制与 NVENC/NVDEC 硬件编解码。当前为软件 H.264 编解码;强杀留下的未完成会话按设计拒绝回灌。
|
||||
|
||||
## 经测试的程序 SHA256
|
||||
|
||||
`0f178057529eb8be44bb9a639504dfe57a1e353a84d2351fd907304f6d951c11`
|
||||
|
||||
独立包 `orin/build/camera_risk` 已更新,`orin/SHA256SUMS` 全部校验通过。使用独立包实际运行并完整回灌 13 帧,再次核对 `/proc/<pid>/exe` 与上述哈希一致;部署检查日志位于 `/tmp/vap-video-session-deployment-20260924/`。测试结束恢复原来的无常驻进程状态。
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"platform": "orin-desktop",
|
||||
"binary": "/home/orin/vision-anti-pinch-0923/build/camera_risk",
|
||||
"binary_sha256": "0f178057529eb8be44bb9a639504dfe57a1e353a84d2351fd907304f6d951c11",
|
||||
"running_binary_sha256": "0f178057529eb8be44bb9a639504dfe57a1e353a84d2351fd907304f6d951c11",
|
||||
"recorded_frames": 13,
|
||||
"replayed_frames": 13,
|
||||
"frames_with_people": 0,
|
||||
"max_raw_pose_error_px": 0.0,
|
||||
"matching_track_frames": 13,
|
||||
"matching_decision_frames": 13,
|
||||
"video_bytes": 303989,
|
||||
"uncompressed_bgr_bytes": 115015680,
|
||||
"sigterm_seconds": 1.4174426249992393,
|
||||
"replay_sigint_seconds": 0.8654193540005508,
|
||||
"interrupted_frames": 2,
|
||||
"standalone_deployment": {
|
||||
"binary": "/home/orin/vision-anti-pinch-0923/orin/build/camera_risk",
|
||||
"binary_sha256": "0f178057529eb8be44bb9a639504dfe57a1e353a84d2351fd907304f6d951c11",
|
||||
"running_binary_sha256": "0f178057529eb8be44bb9a639504dfe57a1e353a84d2351fd907304f6d951c11",
|
||||
"replayed_frames": 13
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real camera + TensorRT record/replay check. Runs only explicitly on Orin.
|
||||
Records to a new test directory; never stops an existing deployment process.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
def rows(session):
|
||||
index = session / 'frames.txt'
|
||||
return index.read_text().splitlines() if index.exists() else []
|
||||
|
||||
|
||||
def wait_frames(process, session, count, timeout=90):
|
||||
deadline = time.monotonic() + timeout
|
||||
while len(rows(session)) < count:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f'Inference exited early: {process.returncode}')
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('Timed out awaiting real inference frames')
|
||||
time.sleep(.05)
|
||||
|
||||
|
||||
def stop(process, signum=signal.SIGTERM):
|
||||
start = time.monotonic()
|
||||
process.send_signal(signum)
|
||||
code = process.wait(timeout=15)
|
||||
if code:
|
||||
raise RuntimeError(f'Nonzero stop exit: {code}')
|
||||
return time.monotonic() - start
|
||||
|
||||
|
||||
def run_record(command, session, log, count, signum=signal.SIGTERM):
|
||||
with log.open('w') as output:
|
||||
process = subprocess.Popen(command + ['--record-session', str(session)], stdout=output, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
wait_frames(process, session, count)
|
||||
return stop(process, signum)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=15)
|
||||
|
||||
|
||||
def load(session):
|
||||
return [json.loads((session / 'frames' / f'{stem}.json').read_text()) for stem in rows(session)]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--binary', type=Path, required=True)
|
||||
parser.add_argument('--config', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--replay-input', type=Path, help='Verify deployment with an existing session; no camera access')
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=False)
|
||||
binary = args.binary.resolve()
|
||||
config = args.config.resolve()
|
||||
command = [str(binary), '--config', str(config)]
|
||||
# Refuse camera contention rather than alter an existing deployment.
|
||||
if subprocess.run(['pgrep', '-x', 'camera_risk'], stdout=subprocess.DEVNULL).returncode == 0:
|
||||
raise RuntimeError('An existing camera_risk process is running; preserve its launch parameters first')
|
||||
if args.replay_input:
|
||||
replayed = args.output / 'replayed'
|
||||
with (args.output/'replay.log').open('w') as log:
|
||||
process = subprocess.Popen(command + ['--replay-session', str(args.replay_input.resolve()), '--record-session', str(replayed)], stdout=log, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
running_hash = hashlib.sha256(Path(f'/proc/{process.pid}/exe').read_bytes()).hexdigest()
|
||||
assert process.wait(timeout=120) == 0, 'Deployment replay failed'
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=15)
|
||||
original, restored = load(args.replay_input), load(replayed)
|
||||
assert [r['frame_index'] for r in original] == [r['frame_index'] for r in restored]
|
||||
result = {'binary': str(binary), 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
'running_binary_sha256': running_hash, 'replayed_frames': len(restored)}
|
||||
assert result['binary_sha256'] == running_hash
|
||||
(args.output/'report.json').write_text(json.dumps(result, indent=2)+'\n')
|
||||
print(json.dumps(result, indent=2))
|
||||
return
|
||||
recorded, replayed = args.output / 'recorded', args.output / 'replayed'
|
||||
term_seconds = run_record(command, recorded, args.output / 'record.log', 12)
|
||||
original = load(recorded)
|
||||
with (args.output / 'replay.log').open('w') as log:
|
||||
process = subprocess.Popen(command + ['--replay-session', str(recorded), '--record-session', str(replayed)], stdout=log, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
# Verify the running executable, not just the file on disk.
|
||||
running_hash = hashlib.sha256(Path(f'/proc/{process.pid}/exe').read_bytes()).hexdigest()
|
||||
if process.wait(timeout=120):
|
||||
raise RuntimeError('Replay failed')
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=15)
|
||||
restored = load(replayed)
|
||||
assert len(original) == len(restored) >= 12, 'EOF dropped frames'
|
||||
max_pose_error = 0.0
|
||||
matching_tracks = matching_decisions = 0
|
||||
for before, after, stem in zip(original, restored, rows(recorded)):
|
||||
assert before['frame_index'] == after['frame_index']
|
||||
assert before['capture'] == after['source_capture']
|
||||
assert before['video_frame_index'] == after['video_frame_index']
|
||||
matching_tracks += before['result']['track_ids'] == after['result']['track_ids']
|
||||
matching_decisions += before['result']['risk']['decision'] == after['result']['risk']['decision']
|
||||
for stage in ('raw_keypoints', 'isolated_keypoints'):
|
||||
for a, b in zip(before['stages'][stage], after['stages'][stage]):
|
||||
for ka, kb in zip(a, b):
|
||||
max_pose_error = max(max_pose_error, *(abs(x-y) for x, y in zip(ka, kb)))
|
||||
# H.264 is lossy: report algorithm differences, do not require bitwise equality.
|
||||
probe = json.loads(subprocess.check_output(['ffprobe', '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_name,nb_frames,width,height', '-of', 'json', str(recorded/'frames.mp4')]))['streams'][0]
|
||||
assert probe['codec_name'] == 'h264' and int(probe['nb_frames']) == len(original)
|
||||
# Exercise cancellation while replay has queued input, and reuse afterwards.
|
||||
interrupt_seconds = run_record(command + ['--replay-session', str(recorded)], args.output/'interrupted', args.output/'interrupt.log', 1, signal.SIGINT)
|
||||
interrupted_count = json.loads((args.output/'interrupted/complete.json').read_text())['frame_count']
|
||||
assert 0 < interrupted_count < len(original), 'Cancellation did not stop before EOF'
|
||||
result = {'platform': os.uname().nodename, 'binary': str(binary), 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
'running_binary_sha256': running_hash, 'recorded_frames': len(original), 'replayed_frames': len(restored),
|
||||
'frames_with_people': sum(bool(row['result']['track_ids']) for row in original),
|
||||
'max_raw_pose_error_px': max_pose_error, 'matching_track_frames': matching_tracks, 'matching_decision_frames': matching_decisions,
|
||||
'video_bytes': (recorded/'frames.mp4').stat().st_size, 'uncompressed_bgr_bytes': len(original)*int(probe['width'])*int(probe['height'])*3, 'sigterm_seconds': term_seconds, 'replay_sigint_seconds': interrupt_seconds, 'interrupted_frames': interrupted_count}
|
||||
assert result['binary_sha256'] == running_hash
|
||||
(args.output/'report.json').write_text(json.dumps(result, indent=2)+'\n')
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
test_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
flags=(-std=c++17 -O1 -g -Wall -Wextra -Wpedantic -pthread -fsanitize=undefined -fno-sanitize-recover=all -I"$root/include")
|
||||
flags=(-std=c++17 -O1 -g -Wall -Wextra -Wpedantic -pthread -fsanitize=undefined -fno-sanitize-recover=all -I"$root/include" -I"$root/third_party")
|
||||
links=()
|
||||
if [[ -f "$root/include/v4l2_capture.h" ]]; then
|
||||
flags+=(-DTEST_V4L2_CAPTURE -I"$root/tests/stubs")
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
test_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
read -ra opencv_flags <<< "$(pkg-config --cflags --libs opencv4)"
|
||||
"${CXX:-g++}" -std=c++17 -O1 -g -Wall -Wextra -Wpedantic -fsanitize=undefined -fno-sanitize-recover=all \
|
||||
-I"$root/include" -I"$root/third_party" "$root/tests/session_test.cpp" \
|
||||
"$root/src/inference_session.cpp" "$root/src/serialization.cpp" "$root/src/door_risk.cpp" \
|
||||
"${opencv_flags[@]}" -o "$test_dir/session_test"
|
||||
"$test_dir/session_test" "$test_dir"
|
||||
@@ -0,0 +1,66 @@
|
||||
#include "inference_session.hpp"
|
||||
#include "camera_pipeline.hpp"
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#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();
|
||||
}
|
||||
}
|
||||
using namespace cms;
|
||||
void check(bool condition,char const* message){if(!condition)throw std::runtime_error(message);}
|
||||
template<class F> void rejects(F f){bool failed=false;try{f();}catch(std::exception const&){failed=true;}check(failed,"Expected rejection");}
|
||||
int main(int argc,char** argv)try {
|
||||
if(argc!=2)return 2;
|
||||
std::filesystem::path root=argv[1];
|
||||
PipelineConfig c;c.record_session=root/"recorded";c.engines=root;c.door_mask=root/"mask.json";
|
||||
c.camera.width=64;c.camera.height=48;c.detector_confidence=.42;
|
||||
std::ofstream(c.door_mask)<<"{}";
|
||||
for(auto name:{"yolo11s.engine","rtmw_l_384.engine"})std::ofstream(root/name)<<"fixture";
|
||||
auto im=std::make_shared<BgrImage>();im->width=64;im->height=48;im->stride=200;
|
||||
im->pixels.resize(9600);
|
||||
for(int y=0;y<48;++y)for(int x=0;x<192;++x)im->pixels[y*200+x]=32+x/3+y%4;
|
||||
FrameResult r;r.image=im;r.frame_index=3;r.capture.frame_index=3;r.capture.driver_sequence=42;
|
||||
r.capture.capture_monotonic_ns=123;r.capture.received_monotonic_ns=124;
|
||||
{
|
||||
SessionWriter writer(c);writer.write(r,{{"raw_keypoints",{1,2,3}}});
|
||||
r.frame_index=9;r.capture.frame_index=9;
|
||||
for(auto& pixel:im->pixels)pixel+=80;
|
||||
writer.write(r,{{"raw_keypoints",{4,5,6}}});
|
||||
writer.finish();writer.finish();
|
||||
rejects([&]{writer.write(r,{});});
|
||||
}
|
||||
rejects([&]{SessionWriter duplicate(c);});
|
||||
PipelineConfig replay;replay.replay_session=c.record_session;
|
||||
SessionReader reader(replay);check(replay.camera.width==64&&replay.detector_confidence==.42,"Settings not restored");
|
||||
auto first=reader.read();check(first&&first->metadata.frame_index==3,"First index mismatch");
|
||||
check(first->metadata.driver_sequence==42&&first->metadata.capture_monotonic_ns!=123,"Capture metadata mismatch");
|
||||
check(first->replay_record->at("capture").at("capture_monotonic_ns")==123,"Original clock lost");
|
||||
for(int y=0;y<48;++y)for(int x=0;x<192;++x)check(std::abs(int(first->image->pixels[y*192+x])-(32+x/3+y%4))<12,"Decoded first frame mismatch");
|
||||
auto second=reader.read();check(second&&second->metadata.frame_index==9,"Gap/order lost");
|
||||
check(second->image->pixels[0]>first->image->pixels[0]+60,"Video frame order lost");
|
||||
check(!reader.read(),"EOF not drained");
|
||||
// Unindexed temporary metadata must not become input.
|
||||
std::ofstream(c.record_session/"frames/000000000002.json.tmp")<<"incomplete";
|
||||
SessionReader prefix(replay);check(prefix.read()&&prefix.read()&&!prefix.read(),"Uncommitted data replayed");
|
||||
std::filesystem::remove(c.record_session/"frames/000000000001.json");
|
||||
SessionReader missing(replay);missing.read();rejects([&]{missing.read();});
|
||||
std::ofstream(c.record_session/"frames.txt",std::ios::trunc)<<"../bad\n";
|
||||
SessionReader invalid(replay);rejects([&]{invalid.read();});
|
||||
std::ofstream(c.record_session/"frames.txt",std::ios::trunc)<<"000000000000";
|
||||
SessionReader truncated(replay);rejects([&]{truncated.read();});
|
||||
std::ofstream(c.record_session/"frames.txt",std::ios::trunc)<<"000000000000\n";
|
||||
SessionReader shortIndex(replay);shortIndex.read();rejects([&]{shortIndex.read();});
|
||||
std::filesystem::remove(c.record_session/"complete.json");
|
||||
rejects([&]{SessionReader incomplete(replay);});
|
||||
c.record_session=root/"empty";
|
||||
{SessionWriter empty(c);empty.finish();}
|
||||
replay.replay_session=c.record_session;
|
||||
SessionReader empty(replay);check(!empty.read(),"Empty recording has frames");
|
||||
c.camera.width=63;c.record_session=root/"odd";
|
||||
rejects([&]{SessionWriter odd(c);});
|
||||
std::cout<<"H.264 encode/decode, settings, order, EOF, stop finalization and corruption checks passed\n";
|
||||
}catch(std::exception const& e){std::cerr<<e.what()<<'\n';return 1;}
|
||||
Reference in New Issue
Block a user