大疆-机场上云 Api-AI识别-SEI 识别(前端)

11 阅读3分钟

DJI 无人机-机场3-上云 1.0 版本-AI的SEI帧数据提取与实时渲染

本文章适用于 大疆无人机机场上云开发者。文章代码 匹配 机场3+M4td 机型的AI识别的SEI提取。代码可直接复制粘贴使用。

*** 当前流媒体服务器是 ZlmediaKit,若使用 srs 等其他流媒体组件,可自行修改函数入口 ***

// 获取 SEI 帧信息 player.value.on(ZLMRTCClient.Events.WEBRTC_ON_REMOTE_STREAMS, function (e) {

  console.log("zml 收到流,准备拦截 SEI...", e.streams);

const videoDom = videoRef.value; const stream = e.streams[0];

// 1. 尝试获取底层的 RTCPeerConnection // ZLMRTCClient 通常会将 pc 实例存储在 _pc 或 pc 属性中,我们需要“偷”出来 // 请在控制台打印 player.value 确认一下属性名,通常是 _pc console.log('player.value >>> ', player.value); const pc = player.value._pc || player.value.pc || player.value.peerConnection;

if (pc) { // 2. 找到视频的接收器 (Receiver) const receivers = pc.getReceivers(); const videoReceiver = receivers.find(r => r.track && r.track.kind === 'video');

if (videoReceiver && videoReceiver.createEncodedStreams) {
  console.log("成功找到 VideoReceiver,开启 Insertable Streams 模式");
  
  // 3. 创建编码流通道 (这才是真正的 H.264 数据,包含 SEI)
  const encodedStreams = videoReceiver.createEncodedStreams();
  const { readable, writable } = encodedStreams;
  const transformer = new TransformStream({
    transform(encodedChunk, controller) {
      // encodedChunk 是 EncodedVideoChunk (不是 VideoFrame 了!)
      
      // 这里的 copyTo 是安全的,因为它是编码后的数据块
      const buffer = new Uint8Array(encodedChunk.data);

      // --- 调试:打印前 30 个字节的十六进制 ---
      const hex = Array.from(buffer.slice(0, 30))
        .map(b => b.toString(16).padStart(2, '0').toUpperCase())
        .join(' ');
      // ----------------------------------------

      // 解析 SEI
      try {
         parseDJISEI(buffer);
      } catch (err) {
         console.info("SEI 没获取到", err);
      }

      // 继续传递数据,否则画面会卡住
      controller.enqueue(encodedChunk);
    }
  });

  // 4. 组装管道
  readable
    .pipeThrough(transformer)
    .pipeTo(writable)
    .catch(err => console.error("Pipeline error:", err));
    
} else {
  console.warn("无法创建 EncodedStreams,可能是浏览器不支持或 SDK 限制");
}

} else { console.warn("无法获取底层 RTCPeerConnection,无法拦截 SEI"); }

// 5. 正常播放逻辑 (流已经被我们修改过了,直接播原来的 stream 即可) if (videoDom) { videoDom.srcObject = stream; videoDom.play().catch(e => console.error("Play failed", e)); } isLoading.value = false;

  // //获取到了远端流,尝试自动播放
  // // console.log("zml 获取到了远端流,尝试自动播放 ===============", e.streams, videoDom);
  // // 直接调用播放(支持静音自动播放策略)
  // isLoading.value = false;
  // videoDom.play().catch((error) => {
  //   console.error("zml 自动播放失败,可能需要用户交互触发:", error);
  // });
});

// ------ 开始解析 dji sei 帧 -----

/**

  • 修复版:自动兼容 String / Uint8Array / ArrayBuffer */ function parseDJISEI(data) { let rawBytes;

// --- 1. 智能类型转换 --- if (typeof data === 'string') { // 去掉可能存在的 "0x" 前缀 if (data.startsWith('0x')) data = data.slice(2); const match = data.match(/.{1,2}/g); if (!match) return { error: "无效的 Hex 字符串" }; rawBytes = new Uint8Array(match.map(byte => parseInt(byte, 16))); } else if (data instanceof Uint8Array) { // Uint8Array rawBytes = data; } else if (data instanceof ArrayBuffer) { // ArrayBuffer rawBytes = new Uint8Array(data); } else { console.warn("[SEI解析] 收到不支持的数据类型:", data); return null; }

// --- 2. H.264 防竞争字节处理 --- const buffer = removeH264EmulationBytes(rawBytes);

const dataView = new DataView(buffer.buffer);

let offset = 0;

// --- 3. 寻找 SEI NALU (Type 6) --- // 遍历 buffer 寻找 NALU 头 while (offset < buffer.length - 4) { // 找 00 00 01 if (buffer[offset] === 0x00 && buffer[offset + 1] === 0x00 && buffer[offset + 2] === 0x01) { const nalType = buffer[offset + 3] & 0x1F;

  // 找到 Type 6 (SEI)
  if (nalType === 6) {
    // console.log("找到 SEI NALU,偏移量:", offset);
    // 这里我们要传入的是 Payload 的起始位置
    // NALU 头通常是 4字节 (00 00 01 06)
    // 或者是 5字节 (00 00 00 01 06) -> 这种情况下 StartCode 是 4字节
    
    let headerLen = 3; // 默认 00 00 01
    if (offset > 0 && buffer[offset - 1] === 0x00) {
        headerLen = 4; // 是 00 00 00 01
    }
    
    // 开始解析 Payload
    const payloadResult = parseSEIPayload(buffer, offset + 3 + 1, dataView);
    if (payloadResult) return payloadResult; // 只要解析到一个 AI 包就返回
  }
}
offset++;

}

return { error: "未在当前包中找到大疆 AI 数据" }; }

/**

  • 内部函数:解析 SEI Payload 链表 */ function parseSEIPayload(buffer, startOffset, dataView) { let offset = startOffset;

while (offset < buffer.length) { // 读取 payload type let payloadType = 0; while (offset < buffer.length && buffer[offset] === 0xFF) { payloadType += 255; offset++; } if (offset >= buffer.length) break; payloadType += buffer[offset++];

// 读取 payload size
let payloadSize = 0;
while (offset < buffer.length && buffer[offset] === 0xFF) {
  payloadSize += 255;
  offset++;
}
if (offset >= buffer.length) break;
payloadSize += buffer[offset++];

// 判断:是不是大疆自定义数据 (Type 0xF5 = 245)
if (payloadType === 0xF5) {

  // console.log(`找到自定义 SEI (Type 0xF5), 长度: ${payloadSize}`);
  const payloadEnd = offset + payloadSize;
  
  // 在 Payload 内部寻找具体的 SubType (0x0007 - AI识别)
  // 内部格式通常为: [SubType(2Byte)] [SubLen(2Byte)] [Data...]
  let innerOffset = offset;
  
  while (innerOffset < payloadEnd - 4) {
    // 读取 2字节 SubType (Little Endian)
    const subType = dataView.getUint16(innerOffset, true);
    const subLen = dataView.getUint16(innerOffset + 2, true);
    
    innerOffset += 4; // 跳过头
    
    if (subType === 0x0007) { 
       // 命中目标!调用之前的结构体解析函数
       const seiPayload = parseAIObjectData(dataView, innerOffset)
       
       console.log('结构体解析>>>>>>obj_group_count:', seiPayload.obj_group_count) 
       console.log('结构体解析>>>>>>groups:', seiPayload.groups)
    }
    
    innerOffset += subLen;
  }
  
  offset += payloadSize; 
} else {
  offset += payloadSize; // 跳过其他无关 SEI
}

} return null; }

function parseAIObjectData(dv, start) { let p = start;

// 1. 解析顶层 Header (对应文档 image_9c8df1.png) const result = { version: dv.getUint8(p), // Offset 0: 版本号 time_stamp: dv.getUint32(p + 1, true), // Offset 1: 时间戳 (4字节) frame_type: dv.getUint8(p + 5), // Offset 5: 帧类型 (0无效, 1有效)

// Offset 6: frame_ext[12] (12个字节的保留扩展区)
// 这里我们通常不需要解析,直接跳过这 12 个字节
// 如果你需要,可以用 new Uint8Array(dv.buffer, p + 6, 12) 读取

track_id: dv.getUint16(p + 18, true),   // Offset 18: 跟踪轨迹 ID (2字节)
reserved2: dv.getUint8(p + 20),         // Offset 20: 保留字节
obj_group_count: dv.getUint8(p + 21),   // Offset 21: 后面紧跟的 Group 数量
groups: []

};

// 指针移动 22 字节 (1+4+1+12+2+1+1),指向 groups 数组的起始位置 p += 22;

// 2. 循环解析 Group 数据 (对应文档 image_9c8dd7.jpg) for (let i = 0; i < result.obj_group_count; i++) { // 防止数组越界 if (p >= dv.byteLength) break;

const groupType = dv.getUint8(p);       // Group 类型
const groupCount = dv.getUint8(p + 1);  // Group 内元素数量
p += 2; // 跳过 Group 头

const groupData = {
  type: groupType,
  count: groupCount,
  objects: []
};

// --- 分情况解析具体的 Object ---

// 情况 A: 目标框 + 距离 (Type = 10)
// 对应结构体: dji_ai_obj_2d_box_with_distance (大小 16字节)
if (groupType === 10) { 
  console.log('dji_ai_obj_2d_box_with_distance 触发')
  for (let j = 0; j < groupCount; j++) {
    if (p + 16 > dv.byteLength) break; // 安全检查

    const obj = {
      id: dv.getUint16(p, true),            // Offset 0: 目标 ID
      type: dv.getUint8(p + 2),             // Offset 2: 目标类型 (人/车/船)
      type_desc: parseObjType(dv.getUint8(p + 2)), // 中文描述
      state: dv.getUint8(p + 3),            // Offset 3: 识别状态
      cx: dv.getUint16(p + 4, true),        // Offset 4: 中心 X (万分比)
      cy: dv.getUint16(p + 6, true),        // Offset 6: 中心 Y (万分比)
      w: dv.getUint16(p + 8, true),         // Offset 8: 宽
      h: dv.getUint16(p + 10, true),        // Offset 10: 高
      distance: dv.getUint32(p + 12, true)  // Offset 12: 距离 (毫米)
    };
    groupData.objects.push(obj);
    p += 16; // 每个对象占 16 字节
  }
} else {
  for (let j = 0; j < groupCount; j++) {
    if (p + 3 > dv.byteLength) break; // 安全检查

    const obj = {
      type: dv.getUint8(p),             // Offset 0: 目标类型
      type_desc: parseObjType(dv.getUint8(p)), // 中文描述
      count: dv.getUint16(p + 1, true)  // Offset 1: 数量
    };
    groupData.objects.push(obj);
    p += 3; // uint8(1) + uint16(2) = 3字节
  }
}

result.groups.push(groupData);

}

return result; }

// 目标识别枚举 function parseObjType(typeCode) { const types = { 0: "无效 (Invalid)", 1: "未知 (Unknown)", 2: "人 (Person)", 3: "车 (Car)", 4: "船 (Boat)" }; return types[typeCode] || 未知类型(${typeCode}); }

function removeH264EmulationBytes(bytes) { const newBuffer = []; for (let i = 0; i < bytes.length; i++) { if (i >= 2 && bytes[i] === 0x03 && bytes[i-1] === 0x00 && bytes[i-2] === 0x00) { continue; } newBuffer.push(bytes[i]); } return new Uint8Array(newBuffer); }

// ------- 结束解析 dji sei 帧 ---------