这是两篇里的上篇。上篇讲数据怎么从板子走到浏览器;下篇讲球为什么听话(四条控制律的坑)。
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 的.bat在goto标签和括号块上会出各种怪问题,而且症状跟内容无关,极难查。 中文内容还要配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:真实终端输出(节选)。9500 和 9502 都被 PID 26776 占着;9502 真绑时报 WinError 10013。清理后两个端口都能真 bind。
有一个反常识的细节值得记住:9500 那条测试显示 FREE, 但那不是因为没被占,而是因为 HTTPServer 带 SO_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上篇完。下篇讲球为什么听话——四条控制律的坑,以及"平滑"和"幅度"这对看起来 不可调和的矛盾是怎么被拆开的。
