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 帧 ---------