Hailo-8L 的逐帧检测

原创2026-09-23 16:33:36浏览1
1
Star

这是两篇里的上篇。上篇讲数据怎么从板子走到浏览器;下篇讲球为什么听话(四条控制律的坑)。

1 缘起:为什么不在板子上直接显示

第一反应当然是在板子上做——板子有 HDMI、有桌面,直接全屏显示最省事。走不通,两个原因:

一是板端没有可用的 Python 绑定。 想自己写个 filter 把检测结果算成特征,就得用 hailopython。 实测这条路在本板是死的:HailoRT 给的 Python 绑定只有一个 7 属性的空壳, hailopython 一加载用户模块就在 PAUSED 状态段错误—— 连 videotestsrc + 一个空的 run() 这种最小用例都崩,不是我的代码写错了。

二是调试成本。 视线跟随要反复调参数(死区、平滑、增益),在板子上改一次要看一次效果, 没有浏览器的 DevTools、没有热刷新、改一行要重新部署。而这个原型注定要改几十轮。

所以最终形态是:板子只负责"把画面变成数字",其余全在电脑上。

图 1:三层链路。板端出特征,PC 桥接,浏览器出画面。绿色是数据流,橙色是控制流。


2 三层各自的职责

干什么

输出

板端

摄像头取流 → NPU 推理 → 后处理 → 导出

逐帧 HailoObjects JSON,25 FPS

PC 桥接

部署/拉起板端、订阅、广播、落盘、服页面

一路 WebSocket + 一份 JSONL

浏览器

特征提取、标定、控制律、绘制

canvas 上的球

三层之间只有两个协议:板→PC 用 ZMQ,PC→浏览器用 WebSocket。 选它们的原因很实际:ZMQ 的 PUB/SUB 不用写重连逻辑,WebSocket 浏览器原生支持。

图 2:同一份信息在链路上被"越削越细"。一帧 1280×720 的画面最终只剩 9 个数字,再变成一个球位。

3 第一层:板端把一帧画面变成 9 个数字

板端的核心是一条 gst-launch 流水线。关键的一步是用 hailoexportzmq 做出口:

bash# 板端执行(项目里的 boardfiles/e-gaze-live.sh 就是这个)
gst-launch-1.0 -e v4l2src device=/dev/video4 ! \
  videoflip video-direction=180 ! \
  queue leaky=downstream max-size-buffers=2 max-size-bytes=0 max-size-time=0 ! \
  videoscale qos=false n-threads=2 ! \
  video/x-raw,pixel-aspect-ratio=1/1 ! \
  videoconvert n-threads=2 qos=false ! \
  hailonet hef-path=/home/user/scrfd_2.5g.hef batch-size=1 ! \
  queue leaky=downstream max-size-buffers=2 max-size-bytes=0 max-size-time=0 ! \
  hailofilter function-name=scrfd_2_5g \
    so-path=/home/user/libscrfd_post_fixed.so \
    config-path=/home/user/scrfd.json qos=false ! \
  hailoexportzmq address=tcp://*:9501 qos=false ! \
  fakesink sync=false

这里有四个必须写对的地方,每一个都是踩出来的:

hailoexportzmq 而不是自己写 filter。 它和离线用的 hailoexportfile 同源(都在 gst-hailotools 里),输出的 JSON 结构完全一样。换成 ZMQ 就得到实时流, 口径与离线完全一致——这一点很重要,否则线上和离线两套数据没法对拍。

leaky=downstream 不能省。 本板 NPU 实际推理约 20~25 FPS,而摄像头出 30 FPS。 队列不设 leaky 就会无限积压——实测延迟每秒涨 0.3 秒,跑一分钟画面就滞后半分钟。 (离线跑文件源时 leaky=no 才对:文件源是满速跑,不需要丢帧。)

pkill 必须加 -x

bashpkill -9 -x gst-launch-1.0        # 对
pkill -9 -f gst-launch-1.0        # 错:-f 会匹配到自己的命令行,把自己杀掉

④ 整条流水线要套 timeout 实时源没有 EOS,正常退出靠时长上限:

bashtimeout --signal=KILL -k 5 1800s gst-launch-1.0 ... 

-k 5 是关键——实测 gst-launch 段错误后会打印 Spinning. Please run 'gdb...'自旋等待、永不退出,没有 -k 的话脚本会永久卡住。

跑起来之后,板端一帧的输出长这样(节选):

json{"HailoROI": {"SubObjects": [{"HailoDetection": {
    "confidence": 0.91,
    "HailoBBox": {"xmin": 0.412, "ymin": 0.318, "width": 0.264, "height": 0.402}},
  "HailoLandmarks": {"points": [{"x": 0.31, "y": 0.42}, {"x": 0.68, "y": 0.41},
                                {"x": 0.50, "y": 0.72}]}}]}}

注意 HailoLandmarks 的坐标是相对人脸框的归一化值,用的时候要先乘回框再减框中心—— 这一步容易写错,我用了一组独立的对拍来验证(见第 5 节)。

验收这一步的命令(板上):

bashbash /home/user/e-gaze-live.sh --sec 1800 --flip 180
# 预期输出:
# ===== 实时特征导出 · scrfd_2.5g · Hailo-8L =====
#   ZMQ 地址 : tcp://*:9501(逐帧 HailoObjects JSON)
#   模式     : 实时 /dev/video4(翻转 180),运行 1800s
#   gst 退出码: 137
#   [i] 到达时长上限被强杀(正常收尾方式)
# ===== 错误扫描 =====
#   无 ERROR / 段错误(完整日志 /tmp/gaze_live.log)
# DONE

退出码 137 是正常的——那是 timeout --signal=KILL 到点强杀的结果, 是这套流水线的正常收尾方式,不是错误。

4 第二层:PC 桥接服务干四件事

diag/gaze_live_server.py 一条命令把四件事做完:

pythonSSH 连上板子(paramiko)
  ├─ SFTP 上传 boardfiles/e-gaze-live.sh → 板上 bash -n 语法检查
  ├─ nohup 后台拉起流水线 → pgrep 确认 gst 真的起来了
  └─ 记下 --sec,看门狗要用

ZMQ SUB 连 tcp://<板IP>:9501
  ├─ 每帧:落盘 JSONL(事后复盘用)
  └─ 每帧:WebSocket 广播给所有浏览器

HTTP 在 127.0.0.1:9500 服 gaze_proto_live.html

有两个细节值得单独说。

一是落盘。 每帧都往 faces/gaze9/live_proto_<时间戳>.jsonl 写一行。 这件事的价值远超"留个记录":它是我判断"这次到底跑没跑起来"的唯一硬证据—— 文件系统不会骗人,而控制台的绿点会(第 7 节)。

二是看门狗。 板端流水线有自己的 --sec 上限,到点会自己收; 但 PC 侧服务不知道,会一直活着。于是会出现"服务在跑、页面开着、却永远没有新帧"。 修法是让 ZMQ 线程自己发现:

pythonexcept zmq.Again:
    silent = time.time() - last_frame
    if silent > WATCHDOG_SILENT and restarts < WATCHDOG_MAX:   # 20s / 最多 5 次
        restarts += 1
        log("★ 板端已 %.0f 秒没出帧 —— 多半是流水线到点自收了" % silent)
        _ws_send_all(json.dumps({"type": "link", "state": "down", ...}))
        start_board(ssh_connect(), sec, flip)   # 板端脚本还部署着,直接重拉

为什么这事必须由服务来做--sec 到点自收是设计如此,不是故障。 但"板端停了"这件事,以前只有人去看才知道——于是表现为"我什么都没改,昨天还好好的"。

启动命令(Windows PowerShell):

powershell$py = "C:\Users\123\.workbuddy\binaries\python\envs\default\Scripts\python.exe"
cd C:\Users\123\WorkBuddy\2026-09-14-16-21-04
& $py -X utf8 "diag\gaze_live_server.py" --sec 3600

预期输出:

[22:20:01] 端口自检:9500/9502 空闲
[22:20:02] 板端脚本已部署并校验通过 (e-gaze-live.sh)
[22:20:05] 板端流水线已启动(flip=180,最长 3600s)
[22:20:05] 逐帧数据落盘: faces\gaze9\live_proto_20260921_222005.jsonl
[22:20:05] WebSocket 就绪 ws://127.0.0.1:9502
[22:20:05] 原型页: http://127.0.0.1:9500/   (自动打开浏览器)
[22:20:06] 正在连接板端 ZMQ tcp://192.168.31.102:9501 ...
[22:20:07] ★ 已收到第一帧 —— 链路通(板端 → ZMQ → 本服务 → 浏览器)
[22:20:12] 近 5s:124 帧(24.8 FPS),检出 96%

看到「★ 已收到第一帧」才叫通了,这句话是这条链路的唯一权威判据。

5 第三层:浏览器把 JSON 变成 9 个数字

浏览器端只做一件事:从 HailoObjects 里提出 9 个特征。

jsfunction feat(frame) {
  const subs = (frame["HailoROI"] || {})["SubObjects"] || [];
  // 只留置信度最高的那个人脸(画面里可能不止一个人)
  let best = null;
  for (const s of subs) {
    const det = s["HailoDetection"]; if (!det) continue;
    const pts = findLm(s); if (!pts || pts.length < 3) continue;
    const conf = det["confidence"] || 0;
    if (!best || conf > best[0]) best = [conf, det, pts];
  }
  if (!best) return null;                       // 这一帧没检出人 → 返回 null
  const bb = best[1]["HailoBBox"];
  // ★ 关键:点位是「相对人脸框」的归一化值,先乘回框尺寸、再加框原点
  const Q = best[2].map(p => [bb.xmin + p.x * bb.width, bb.ymin + p.y * bb.height]);
  const fcx = bb.xmin + bb.width / 2, fcy = bb.ymin + bb.height / 2;
  const ec  = [(Q[0][0] + Q[1][0]) / 2, (Q[0][1] + Q[1][1]) / 2];   // 双眼中心
  return [ (ec[0]-fcx)/bb.width, (ec[1]-fcy)/bb.height,              // 0,1 眼相对脸
           Math.abs(Q[1][0]-Q[0][0])/bb.width,                       // 2   眼距
           Math.abs(Q[1][1]-Q[0][1])/bb.height,                      // 3   眼高差
           (Q[2][0]-fcx)/bb.width, (Q[2][1]-fcy)/bb.height,          // 4,5 鼻嘴相对脸
           fcx, fcy, bb.width ];                                     // 6,7,8 脸心 x,y + 脸宽
}

这个函数的维度 6 / 7 / 8(脸心 x、脸心 y、脸宽)就是后面控制律真正用到的三个量: 头往哪偏,脸心就往哪动;坐得离摄像头远近,脸宽就变。

别自己发明口径。 我踩过的坑是:浏览器里的 feat() 和离线分析脚本里 gaze9_analyze.frame_features() 长得像,但归一化基准只要差一点(除以 w 还是除以 h), 后面所有阈值就全废。所以两边逐字对齐,并用同一条视频对拍验证:

9 维特征 mean±std 对拍(离线 exportfile vs 实时 ZMQ,同一段视频)
  dim0~dim8  全部 ✓ 一致

6 一键启动:把终端语法差异绕过去

链路涉及 3 个进程、5 个参数、两种终端语法。让操作的人记这些不现实, 所以入口是一个双击就跑的 .bat

bat@echo off
chcp 65001 >nul
set PY=C:\Users\123\.workbuddy\binaries\python\envs\default\Scripts\python.exe
cd /d "%~dp0"
"%PY%" -X utf8 "diag\gaze_live_server.py"
pause

踩过的坑.bat 文件必须是 UTF-8 无 BOM + CRLF 换行。LF-only 的 .batgoto 标签和括号块上会出各种怪问题,而且症状跟内容无关,极难查。 中文内容还要配 chcp 65001,否则控制台输出乱码。

7 翻车现场:一帧都没有,但页面样样正常

上面这套跑通了 5 轮调试都好好的。第 6 轮,反馈是四个字:「依旧不行」

先说结论:不是算法问题,是链路断在一处很有欺骗性的地方。

现场是这条链:

板端流水线到点自收(--sec 3600)          ← 设计如此,不是故障
        
PC 侧服务却 serve_forever 还活着 → 页面能打开、圆点也许还绿
        
 ZMQ 一帧都没有 → 页面「无数据流」
        
而新启的服务 WebSocket 绑不上 9502(被上次没退的进程占着)
        
页面的 new WebSocket("ws://127.0.0.1:9502") 一连就「成功」—— 连的是那个僵尸
        
「刷新页面、按 C、失败;再刷新、再按 C、还是失败」

图 3:同一个端口只能有一个监听者。旧服务没退,新服务的 WebSocket 就绑不上; 而浏览器的 WebSocket 构造函数对"连上了但对方不说话"是无感的——它只报"连接成功"。

取证很直接,两条命令:

图 4:真实终端输出(节选)。95009502 都被 PID 26776 占着;9502 真绑时报 WinError 10013。清理后两个端口都能真 bind。

有一个反常识的细节值得记住:9500 那条测试显示 FREE, 但那不是因为没被占,而是因为 HTTPServerSO_REUSEADDR—— Windows 上两个进程可以同时绑同一个端口。所以"端口测试显示空闲" 不能作为"端口没被占"的证据,必须去看 netstat -ano 的监听列表。

还有一个旁证,是我后来才意识到该第一时间看的: faces/gaze9/ 下没有任何新的 live_proto_*.jsonl 真跑起来必定留文件。这条比任何日志都可靠。

8 排障:十分钟检查清单

从这次开始,链路自带三层自愈与自证:启动清僵尸、跑中看门狗、失败带数字

图 5:从"标定总是失败"倒推的排查路径。第一步永远是先看圆点,而不是改算法。

照抄就能跑的检查命令(PowerShell):

powershell# ① 端口被谁占着?(不看"能不能绑",看监听列表)
Get-NetTCPConnection -State Listen |
  Where-Object { $_.LocalPort -in 9500,9502 } |
  Select-Object LocalAddress, LocalPort, OwningProcess

# ② 这次到底跑起来了吗?看有没有新文件(最可靠的证据)
Get-ChildItem faces\gaze9\live_proto_*.jsonl |
  Sort-Object LastWriteTime -Descending | Select-Object -First 3 Name, LastWriteTime

# ③ 端口真能绑吗(不用 SO_REUSEADDR 才测得准)
& $py -X utf8 diag\_bindtest.py

# ④ 清理僵尸服务并复验
& $py -X utf8 diag\_reclaim.py

第 ④ 步的预期输出:

=== 清理前:端口占用 ===
  9500 -> PID [26776]
  9502 -> PID [26776]
=== 执行 reclaim_ports ===
[服务台] 端口自检:清理僵尸服务(端口 9500 上的 PID 26776)
被杀掉的 PID: [26776]
=== 清理后 ===  9500 -> 无     9502 -> 无
=== 真 bind ===  9500: OK      9502: OK

上篇完。下篇讲球为什么听话——四条控制律的坑,以及"平滑"和"幅度"这对看起来 不可调和的矛盾是怎么被拆开的。