Quest:视频 + 元数据 + 可点击目标(与RK进行UDP通信)

这篇文章整理一次从零到闭环的开发过程:在 RK(Rockchip)侧采集并推流视频Quest ,同时通过 UDP 发送每帧的检测框元数据 ;Quest 在视频上叠加绘制方框(框可悬停、可点击),点击后将命中的目标信息 再 UDP 回传给 RK。最终目标是未来接入模型推理,但在早期阶段用"随机框模拟检测结果"把整条链路跑通。


1. 拆解思路

1.1 目标

  • RK:
    1. 摄像头视频推流到 Quest
    2. 每帧跑模型识别物体,得到 bbox/类别/置信度/track_id
    3. 将识别结果(最好逐帧)与视频同步发到 Quest
  • Quest:
    1. 播放视频
    2. 按元数据显示方框 + 类别名称
    3. 方框作为"按钮":被手柄射线点击
    4. 点击后把点击的目标(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,并把状态传给 renderer
  • Render():每帧解码/更新 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_:

视频面板渲染管线是:

  1. VideoPanelRenderer::BlitOesTo2D_(oesTexId)
    • samplerExternalOES 画进 blitFbo_ → 输出纹理 blitTex2D_
  2. DrawDetOverlay_(det)
    • 在同一个 blitFbo_ 上继续画 overlay(框、准星)
  3. 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:VideoPanelRendererblitFbo_ 上绘制粗框 + hover 黄 + click 红
  • Quest:Update() 射线命中框 -> sendto CLK1 -> RK 收到打印
  • Quest:准星修复(面板命中即可显示)

7.2 下一步接模型识别结果

保持协议不变,替换 RK 端 MetaSendLoop() 的"模拟框生成"为真实模型输出即可;Quest 端绘制/交互不需要动。

相关推荐
BackCatK Chen11 小时前
第15篇:TMC2240闭环控制软件实现|编码器数据融合+丢步修正(保姆级)
嵌入式硬件·闭环控制·tmc2240·stm32实战·编码器数据融合·丢步修正·定位精度优化
隔壁大炮12 小时前
I2C通信协议
单片机·嵌入式硬件·铁头山羊
凌晨7点13 小时前
DSP学习F28004x数据手册:第13章-ADC
单片机·嵌入式硬件·学习
非鱼䲆鱻䲜15 小时前
淘晶驰串口屏使用波形图控件,接收单片机或者串口助手数据生成图像的区别
单片机·嵌入式·串口屏
J-TS17 小时前
线性自抗扰控制LADRC
c语言·人工智能·stm32·单片机·算法
撩妹小狗20 小时前
单片机中断原理
stm32·单片机
码农三叔20 小时前
(1-1)人形机器人感知系统概述: 人形机器人感知的特点与挑战
人工智能·嵌入式硬件·机器人·人机交互·人形机器人
上海合宙LuatOS21 小时前
LuatOS核心库API——【hmeta 】硬件元数据
单片机·嵌入式硬件·物联网·算法·音视频·硬件工程·哈希算法
cameron_tt1 天前
定时器中断应用 HC-SR04超声波测距模块、定时器输出PWM应用 控制SG90舵机
c语言·嵌入式硬件