TrackEngine 单状态机架构
视觉一秒钟几十帧、语音一句话几秒钟 —— 两种节奏的活儿凭什么同居一进程还不打架?答案是单状态机:TrackEngine 只有一条 RUN 主循环(读帧 → 处理 → 排干指令 → 计算绘制 → 发布),耗时的 LLM 调用全部推给独立线程 CommandWorker;指令满了丢最旧(Queue maxsize=8),等指令时暂停处理但 30 秒看门狗自动恢复。
01问题引入:语音和画面凭什么不互相卡
一次 LLM FunctionCall 往返动辄秒级,而视频帧 30ms 一帧。若把"跑 LLM"直接塞进主循环,画面立刻冻成幻灯片;若每个线程都能随便改追踪状态,ID 表会被改出"灵异事件"。源工程用两个设计解掉这对矛盾:
02原理通俗讲解:电梯楼层调度
把 TrackEngine 想成电梯调度系统:轿厢(主循环)沿固定节拍"开门—关门—上下行—播报楼层",每一步都有规程,不会因为某个乘客(指令)催两句就跳过关门流程直接冲刺。乘客按键(语音指令)只是登记进按键队列,调度器在每个到达层统一取按钮执行 —— 电梯永远不会"一边开门一边加速",正是因为它一次只处于一种状态、按固定顺序流转。
L89-93 起的主循环:读帧 → pipeline.process → _drain_commands → _compute_draw → _publish(L63 发布)。每圈固定顺序,杜绝"跳步"。
apps/track_app.py L196-235:threading.Thread 起线程,队列 Queue(maxsize=8),L232-236 put_nowait —— 队满丢最旧,绝不阻塞。
L246-269:pending.set 通知主循环 → epoch+1 丢弃迟到指令(L249-251)→ 场景上下文 → LLM → dispatch → on_reply 回播报。
tracking.pause_wait=true 时主循环停在"处理"步等待 pending;超 pause_timeout=30.0 秒自动放弃等待恢复跑,防死等。
03关联知识点
Queue(maxsize=8) 是有界缓冲:队满 put_nowait 抛出即丢弃,且丢最旧 —— 指令是"过期即失效"的,丢旧比丢新更符合交互语义。
pending 是 threading.Event:指令线程 set 通知,主循环 wait;epoch 是世代计数,每轮新指令 +1,旧世代的迟到结果一律丢弃。
判定顺序即优先级:清除短路 → 非指令回落闲聊 → ID 存在性对齐 → 无 id 带描述走 VLM → else 放行(详见 EXP-05)。
pipeline 内部,8 维状态 [cx,cy,aspect,height,vx,vy,va,vh] 按 F=np.eye(8); F[0:4,4:]=np.eye(4) 预测;CHI2INV95(df4=9.4877)卡关联。
| 角色 | 运行位置 | 能做什么 | 不能做什么 |
|---|---|---|---|
| 主循环 | engine.run | 改追踪状态、apply 指令、绘制发布 | 不能阻塞等 LLM |
| CommandWorker | 子线程 | 调 LLM、门控判定、往队列塞结果 | 不能直接改 follow_ids |
| 词表预路由 | 入队前 | 清除词/口令词直接给语义 | 不能代替门控最终判定 |
04实验目标
- 默画 RUN 主循环五步(L89-93 ← L63 发布),并说出每步该在源码哪一行
- 解释 CommandWorker 与主循环的唯一通信渠道:Queue(maxsize=8) + put_nowait
- 说清 pending 暂停的是"处理帧"而非"读帧",以及 30s 兜底语义
- 按顺序复述 _native_gate 五分支优先级(清除 > 闲聊回落 > 存在性 > VLM > 放行)
- 读懂 session.py L12-25 的 set_follow / visible_ids 对状态的两种写入口
- 用 grep 在 5 分钟内定位 CommandWorker、_native_gate、submit_effect、pending、epoch 五处锚点
05实验环境与准备
主入口:CommandWorker(L196-235)、_handle(L246-269)、词表路由 parse_instruction(L45-68)、_native_gate(L320-366)。
engine.py:run 主循环、_drain_commands、事件处理 _handle_event、_compute_draw、_publish;session.py L12-25:set_follow、visible_ids。
无需相机与麦克风:--video ... --text 直接在 stdin 敲指令,终端上能看到 [cmd] 打印的门控分支走向。
# 进入工程,确认修改时间与你要读的文件同批 cd ~/ros2_ws/src/xflz_eai_car1/app/object_tracking ls apps/ tracking/ session/ 2>/dev/null || ls apps/ tracking/ # 准备一份离线测试视频(讲义所用样例) # /home/iflytek/ros2_ws/src/xflz_eai_car1/vision/07_multi_object_tracking/test.mp4
06动画讲解:单状态机五步循环与指令流
左边是主循环节拍,右边是 CommandWorker 线程 —— 看两者如何通过那条 8 格有界队列握手:
07互动讲解:状态机事件步进器
按顺序点事件,观察主循环停在哪一步、队列与 pending/epoch 如何变化:
08实操步骤:源码定位双流水线关键行
# ① 五处架构锚点一次抓齐(源讲义原命令) grep -n "class CommandWorker\|_native_gate\|submit_effect\|pending\|epoch" apps/track_app.py # ② 主循环五步:打开 run 看 L89-93,发布在 L63 grep -n "def run\|_drain_commands\|_compute_draw\|_publish" tracking/engine.py # ③ 文本模式实测一条指令(离线样例视频) python3 apps/track_app.py --video /home/iflytek/ros2_ws/src/xflz_eai_car1/vision/07_multi_object_tracking/test.mp4 --text # 终端敲:追踪 id3 # [cmd] track_ids {3} → 回复"好的,只追踪 id3",主循环第③步 apply # ④ session 两个状态入口 grep -n "set_follow\|visible_ids" tracking/session.py # L12-25
09重点与易错点
| ⚠️ 常见坑 | 现象 → 解法 |
|---|---|
| 队列满以为是 bug | 连敲指令丢消息 → 设计如此:maxsize=8 + put_nowait,队满丢最旧(L232-236) |
| 改状态不走主循环 | 在子线程直接改 follow_ids → 违反单写者原则,必须 submit_effect 入队由主循环 apply |
| 等 LLM 引发死等 | 模型无响应画面停 → 正常应靠 pause_timeout=30.0 自愈;若真停了查 pause_wait 配置 |
| 迟到指令突然生效 | 打断后旧指令又执行 → epoch 未递增;确认 _handle L249-251 有"epoch 已变,丢弃迟到指令" |
| 门控分支顺序记反 | 以为 ID 判定先于清除 → 实际五分支:清除短路 → 闲聊回落 → ID 存在性 → 无 id 带描述 VLM → else 放行 |
| pause_timeout 调太小 | LLM 首次加载 30-60s 被误杀 → timeout 与模型就绪时间要匹配,别小于服务启动耗时 |
- 五步节拍与源码行号(L89-93 / L63 / L246-269 / L232-236 / session L12-25)能对上
- 能画出"两条流水线 + 8 格队列"的架构草图
- 把 pause_wait 与 pause_timeout 的语义用自己的话讲给同桌听
- 门控五分支顺序倒背如流(下一步 EXP-05 会考)
10总结与思考
主循环五步、子线程队列、pending/epoch 同步机制全部定位到行号。
状态只有一个写者,其余线程递纸条;竞态区被压缩到主循环一处。
用关键词清单在陌生大型文件里快速建立坐标系。
maxsize、pause_timeout、门控开关都来自 track_config.yaml —— 下个实验拆它。