这篇文章整理一次从零到闭环的开发过程:在 RK(Rockchip)侧采集并推流视频 到 Quest ,同时通过 UDP 发送每帧的检测框元数据 ;Quest 在视频上叠加绘制方框(框可悬停、可点击),点击后将命中的目标信息 再 UDP 回传给 RK。最终目标是未来接入模型推理,但在早期阶段用"随机框模拟检测结果"把整条链路跑通。
1. 拆解思路
1.1 目标
- RK:
- 摄像头视频推流到 Quest
- 每帧跑模型识别物体,得到 bbox/类别/置信度/track_id
- 将识别结果(最好逐帧)与视频同步发到 Quest
- Quest:
- 播放视频
- 按元数据显示方框 + 类别名称
- 方框作为"按钮":被手柄射线点击
- 点击后把点击的目标(frame_id/track_id/uv)回传 RK
1.2 过程划分
这类跨端实时系统最怕一次性集成太多变量(视频、网络、渲染、输入、协议、同步、性能)。所以采用逐步验证:
- Milestone 0:RK 侧推流 + UDP 协议结构设计(上一章已经完成,相对比较简单)
- Milestone 1:Quest 只收 DetPacket 打日志,证明元数据链路正常(很简单,开线程,接收,打印出来即可)
- Milestone 2:Quest 把检测框画到视频面板上(不点击)(稍微复杂,主要卡在了数据传输上,由于sample工程解耦程度太低了,代码又多,为了规避代码耦合性过高,需要细细处理方框的坐标信息等数据在工程中如何移动,那一层负责传输,哪一层负责渲染,在什么地方用什么方式注入数据或者对象)
- Milestone 3:射线命中框 -> 发 ClickPacket 回 RK (复杂,主要是需要处理hover以及变色情况,sample之前已经处理的很熟练了,特别是对于帧处理等)
- Milestone 4:加粗边框、悬停黄、点击红、准星显示逻辑修复(查漏补缺)
2. RK 端实现:视频推流 + 元数据模拟 + 回传接收
之前的 rk_server.cpp 里其实已经把需要的 3 类数据通道都搭好了(详见上一篇博客,此处略,Quest 端按协议对齐即可)。
3. Quest 端 Milestone 1:只做 DetPacket 接收与日志验证
3.1 目标
不改渲染、不做交互,只验证 Quest 能稳定收到 RK 发来的 DPK1。
3.2 关键实现:DetUdpReceiver(独立线程 recvfrom + latest 快照)
Milestone 1 的核心类是 DetUdpReceiver:一个后台线程收包,主/渲染线程读"最新一帧 det 快照"。
3.2.1 Wire 协议结构(与 RK 的 DPK1 一致)
DetUdpReceiver.cpp 里定义了 wire struct,并强制 1 字节对齐:
cpp
#pragma pack(push, 1)
struct DetBoxWire {
uint32_t track_id;
uint32_t class_id;
float score;
float x0, y0, x1, y1;
};
struct DetPacketV1Wire {
uint32_t magic; // 'DPK1'
uint32_t version; // 1
uint64_t ts_ns;
uint32_t frame_id;
uint32_t num;
// DetBoxWire[num] follows
};
#pragma pack(pop)
static constexpr uint32_t kMagicDPK1 = 0x314B5044; // 'DPK1'
static constexpr uint32_t kVersionDPK1 = 1;
这里的两个关键点,后面每次排查都很有用:
magic/version:防止误收其他 UDP 数据导致错误解析#pragma pack(1):保证跨端二进制布局一致(RK端也这么做)
3.2.2 启动监听:socket + bind + thread
Start() 负责把监听端口拉起来(在 main 的 SessionInit() 调用 detRx_.Start(6000)):
cpp
bool DetUdpReceiver::Start(int listenPort) {
port_ = listenPort;
fd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (fd_ < 0) { ALOGE("DetUdpReceiver socket"); return false; }
int yes = 1;
setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons((uint16_t)port_);
if (bind(fd_, (sockaddr*)&addr, sizeof(addr)) < 0) {
ALOGE("DetUdpReceiver bind");
close(fd_);
fd_ = -1;
return false;
}
running_.store(true);
th_ = std::thread([this]{ ThreadMain_(); });
return true;
}
3.2.3 接收线程:recvfrom → 校验 → 解析 → 写 latest_
ThreadMain_() 做了"最小而正确"的处理链:
cpp
void DetUdpReceiver::ThreadMain_() {
uint8_t buf[2048];
while (running_.load()) {
sockaddr_in src{};
socklen_t sl = sizeof(src);
ssize_t n = recvfrom(fd_, buf, sizeof(buf), 0, (sockaddr*)&src, &sl);
if (n <= 0) continue;
if ((size_t)n < sizeof(DetPacketV1Wire)) continue;
DetPacketV1Wire hdr{};
memcpy(&hdr, buf, sizeof(hdr));
if (hdr.magic != kMagicDPK1 || hdr.version != kVersionDPK1) continue;
size_t need = sizeof(DetPacketV1Wire) + (size_t)hdr.num * sizeof(DetBoxWire);
if ((size_t)n < need) continue;
DetFrame f;
f.ts_ns = hdr.ts_ns;
f.frame_id = hdr.frame_id;
f.boxes.resize(hdr.num);
auto* b = (const DetBoxWire*)(buf + sizeof(DetPacketV1Wire));
for (uint32_t i = 0; i < hdr.num; ++i) {
f.boxes[i] = { b[i].track_id, b[i].class_id, b[i].score,
b[i].x0, b[i].y0, b[i].x1, b[i].y1 };
}
{
std::lock_guard<std::mutex> lk(mtx_);
latest_ = std::move(f);
hasLatest_ = true;
}
// 每 30 帧打印一次,避免 log 爆炸
if ((hdr.frame_id % 30) == 0) { ... }
}
}
- 之前的工程已经把网络数据从"wire bytes"升格成了"渲染可用的 DetFrame"
- 且用
mutex + latest_ + hasLatest_做了线程安全的"最新快照"
3.2.4 渲染线程读取快照:GetLatest()
cpp
bool DetUdpReceiver::GetLatest(DetFrame& out) {
std::lock_guard<std::mutex> lk(mtx_);
if (!hasLatest_) return false;
out = latest_;
return true;
}
这一句
out = latest_是"拷贝快照",非常适合渲染线程使用:锁持有时间短,逻辑稳定。
3.3 要点
- UDP 包头必须校验
magic/version - 两端 struct 必须
#pragma pack(1) - log 不要每帧全量打印,否则 Quest 或 RK 都会因为 IO 被拖慢
4. Milestone 2:把框画到视频面板上(Overlay)
4.1 数据通路梳理(配合 Update/Render)
当前 Quest 端的整体节奏是:
SessionInit():启动视频接收、det 接收、click sender、以及手柄包发送线程Update():同步 OpenXR action + locate space;计算射线、hover、click,并把状态传给 rendererRender():每帧解码/更新 OES 纹理,然后调用videoPanel_.Render(...)完成 blit + overlay + 显示
在 SessionInit() 里明确启动了 det:
videoRx_.Start();
detRx_.Start(6000);
在 Render() 首次初始化 VideoPanelRenderer 时把 receiver 指针交给渲染层:
videoPanel_.SetDetReceiver(&detRx_);
这行非常关键:VideoPanelRenderer 并不拥有 detRx_,它只是使用指针在渲染时读取 latest det。
4.2 Overlay 必须画到 blitFbo_:
视频面板渲染管线是:
VideoPanelRenderer::BlitOesTo2D_(oesTexId)- 把
samplerExternalOES画进blitFbo_→ 输出纹理blitTex2D_
- 把
DrawDetOverlay_(det)- 在同一个
blitFbo_上继续画 overlay(框、准星)
- 在同一个
PanelSurface_把blitTex2D_作为 sampler2D 显示到世界面板
代码位置就在 BlitOesTo2D_():
cpp
glBindFramebuffer(GL_FRAMEBUFFER, blitFbo_);
glViewport(0, 0, blitW_, blitH_);
// draw OES quad ...
glDrawElements(...);
DetFrame det;
if (detRx_ && detRx_->GetLatest(det)) {
DrawDetOverlay_(det);
} else {
static int nd = 0;
if ((++nd % 120) == 0) LOGI("Det: no latest frame yet");
}
这段非常适合在博客里画一张小图:OES → blitFbo(tex2D) → overlay → panel。
4.3 第一次错误:overlay program 没创建 / state 污染(源码对应修复)
错误 A:overlayProg_ / overlayLocColor_ 未初始化
现在的修复是:在 Init() 里显式构建 overlay program:
cpp
if (!BuildOverlayProgram_()) {
LOGE("Init: BuildOverlayProgram_ failed");
return;
}
对应的 build 代码:
cpp
overlayProg_ = LinkProgram(vs, fs, "Overlay Program");
overlayLocColor_ = glGetUniformLocation(overlayProg_, "uColor");
错误 B:FBO / viewport 状态被污染
现在在 DrawDetOverlay_() 里完整保存/恢复状态:
cpp
GLint prevFbo = 0;
GLint prevViewport[4] = {0,0,0,0};
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
glGetIntegerv(GL_VIEWPORT, prevViewport);
glBindFramebuffer(GL_FRAMEBUFFER, blitFbo_);
glViewport(0, 0, blitW_, blitH_);
// ... draw overlay
glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo);
glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]);
这一步是之前出现"黑屏/随机异常"的核心原因之一:overlay pass 不恢复状态会污染后续 panel draw。
4.4 加粗边框:为什么不用 GL_LINES
之前已经踩到移动端典型坑:glLineWidth(2.0f) 经常没效果。所以改成"4 个矩形条",用 GL_TRIANGLES 画"粗边框"。
在的 DrawDetOverlay_() 里,厚度按像素定义并转成 NDC:
cpp
const float thickPx = 4.0f;
const float tX = 2.0f * thickPx / (float)blitW_;
const float tY = 2.0f * thickPx / (float)blitH_;
然后用 appendThickRect() 把一个框变成 4 条 bar:
cpp
// top bar
appendBarQuad(v, x0, y0 - tY, x1, y0 + tY);
// bottom bar
appendBarQuad(v, x0, y1 - tY, x1, y1 + tY);
// left bar
appendBarQuad(v, x0 - tX, y0, x0 + tX, y1);
// right bar
appendBarQuad(v, x1 - tX, y0, x1 + tX, y1);
最后 glDrawArrays(GL_TRIANGLES, ...),这在移动端表现稳定。
5. Milestone 3:点击闭环(Ray -> 面板UV -> Box 命中 -> UDP 回传)
5.1 命中逻辑分两层:面板命中 和 框命中
在 VideoPanelRenderer 里把这两层拆开:
5.1.1 只要打到面板就返回 UV:HitTestPanelUV()
cpp
bool VideoPanelRenderer::HitTestPanelUV(const OVR::Vector3f& roWs,
const OVR::Vector3f& rdWs,
float& outU, float& outV) const
{
return RayPanelUv_TopDownV(ModelMatrix_, width_, height_, roWs, rdWs, outU, outV);
}
5.1.2 命中框才返回 track_id:HitTestRay()
cpp
bool VideoPanelRenderer::HitTestRay(const OVR::Vector3f& roWs,
const OVR::Vector3f& rdWs,
HitResult& out) const
{
float u=0, v=0;
if (!RayPanelUv_TopDownV(..., u, v)) return false;
DetFrame det;
if (!(detRx_ && detRx_->GetLatest(det))) return false;
// 找 (u,v) 落在哪个框里,取 score 最大
...
}
后面出现了准星只在框内显示的问题(详见第 6.2)。
5.2 ClickPacket 回传(代码就在 Update 里)
在 Update() 中做了 rising edge 点击检测:
cpp
auto selR = GetActionStateBoolean(actionSelect_, handPath);
const bool clickDownEdgeR = selR.changedSinceLastSync && selR.currentState;
命中框后构造并 UDP 发给 RK(6001):
cpp
ClickPacketV1 c{};
c.magic = kMagicCLK1;
c.version = kVersionCLK1;
c.ts_ns = NowNs();
c.frame_id = hit.frameId;
c.track_id = hit.trackId;
c.action = kActionClick;
c.u = hit.u;
c.v = hit.v;
sendto(clickSock_, &c, sizeof(c), 0, (sockaddr*)&rkClickAddr_, sizeof(rkClickAddr_));
6. Milestone 4:体验优化与关键 Bug 修复
6.1 悬停黄、点击红:状态在 Update 算,Renderer 负责画
在 Update() 里每帧算 hoverTrackId_、点击成功后设定"红色持续 0.5s":
cpp
hoverTrackId_ = hit.trackId;
if (sentOk) {
clickedTrackId_ = hit.trackId;
clickedUntilNs_ = NowNs() + 500000000ull; // 0.5s
}
然后把状态传给 renderer:
cpp
videoPanel_.SetHoverTrackId(hoverTrackId_);
videoPanel_.SetClickedTrackId(clickedTrackId_, clickedUntilNs_);
Renderer 里按 track_id 分类到 3 个 batch(绿/黄/红),并用 uniform color 三次 draw:
cpp
if (b.track_id == clickedTrackId_ && nowNs < clickedUntilNs_) -> vertsRed
else if (b.track_id == hoverTrackId_) -> vertsYellow
else -> vertsGreen
6.2 "十字只在框内出现"的根因与修复
现象:准星(十字)只有当射线落在某个框内才显示;射线落在框外(但仍在面板上)准星消失。
根因(当时的逻辑绑定方式)
当时只有 HitTestRay(),它的返回 true 条件是"命中面板 且 命中 det 框"。于是笔者当时把准星显示与 HitTestRay() 绑定,就会出现"框外不显示"。
正确修复:用 HitTestPanelUV 控制准星,用 HitTestRay 控制 hover/click
现在 Update里已经是正确写法:
cpp
// 1) 十字:只要打到面板就显示
if (videoPanel_.HitTestPanelUV(ro, rd, u, v)) {
videoPanel_.SetAimPointUV(true, u, v);
} else {
videoPanel_.SetAimPointUV(false, 0, 0);
}
// 2) hover/click:要求在框里
if (videoPanel_.HitTestRay(ro, rd, hit)) {
hoverTrackId_ = hit.trackId;
}
配套修复:DrawDetOverlay 的早退条件:
if (det.boxes.empty() && !aimValid_) return;
这保证了:即使没有 det 框,但准星有效,overlay pass 仍会跑,从而十字仍然绘制。
十字绘制源码(也在 DrawDetOverlay_ 内)
在 overlay 最后追加了 crosshair 的两个矩形条(横竖):
cpp
// horizontal bar
appendQuad(cx - hX, cy - tY, cx + hX, cy + tY);
// vertical bar
appendQuad(cx - tX, cy - hY, cx + tX, cy + hY);
glUniform4f(overlayLocColor_, 1,1,1,0.95);
glDrawArrays(GL_TRIANGLES, ...);
准星也是 overlay 的一部分,因此必须在 blitFbo 上画,且不能被 det.empty 早退挡掉
7. 当前成果与下一步接模型的落点
7.1 现在已经完成的闭环
- RK:视频 RTP 推流
- RK:DPK1 元数据每帧发送(模拟 track)
- Quest:
DetUdpReceiver收 det + latest 快照 - Quest:
VideoPanelRenderer在blitFbo_上绘制粗框 + hover 黄 + click 红 - Quest:
Update()射线命中框 -> sendto CLK1 -> RK 收到打印 - Quest:准星修复(面板命中即可显示)
7.2 下一步接模型识别结果
保持协议不变,替换 RK 端 MetaSendLoop() 的"模拟框生成"为真实模型输出即可;Quest 端绘制/交互不需要动。