数据契约:从 MQTT 遥测到 virtual 指标的全链路 schema
本节是 ne101_camera 案例 MVP 阶段的契约参考页,覆盖三层契约(设备遥测 → 扩展响应 → virtual 指标)、四种 responseType 归一化、JSON string 解析坑点以及 ROI 重叠判定算法。
数据契约三层模型
ne101_camera 的数据契约不是一张扁平的 schema 表,而是一条三层流水线:
- 第一层是 NE101 设备通过 MQTT 推送的原始遥测(图像 URL/base64 + 电池 + 时间戳等标量)
- 第二层是 AI 扩展返回的推理响应(四种
responseType之一,字段结构各不相同) - 第三层是组件 Transform 生成的 synthetic virtual 指标(带
virtual.<ext_id>.前缀,由主控写回设备指标存储,组件再读取渲染)
这三层之间存在两个形状转换边界:
- 边界 A(设备遥测 → 扩展输入)由组件的
generateTransformJsCode用input_raw桥接 - 边界 B(扩展响应 → virtual 指标)由同一个代码生成器内部的归一化逻辑完成
把它们 拆成三层而不是合成一张大表的根本原因是解耦——设备协议(MQTT 主题名、字段命名)可能随固件升级变化,AI 扩展的响应格式由扩展作者决定,而 virtual 指标的 schema 由组件自己掌控。
三层分离后,任何一层的变化都不会穿透到另外两层。
下图把三层从左到右串起来,标出每一层的「拥有者」和关键字段,方便后续小节交叉引用。
这条链路有一个关键的时序约定:virtual 指标的 source_ts 必须与当前图像的 ts 匹配,组件才会渲染对应的检测框。
这是因为扩展推理是异步的——当用户在第 5 秒看到图像 A 时,针对图像 A 的检测结果可能还在扩展队列里排队,而 store 里已有的检测结果其实是针对第 0 秒的图像 B 的。
如果没有 source_ts 对齐,用户会看到「图像 A 上叠着图像 B 的检测框」这种错配。
source_ts 的对齐逻辑在 bundle.js L858-L874,4.4 会展开:
// bundle.js L858-L874
var vSourceTs = getFirst(vals, [pfx + 'source_ts', 'values.' + pfx + 'source_ts']);
// Match: detections' source_ts must align with the current image timestamp
var imgTsVal = imgTs;
var tsMatch = !vSourceTs || !imgTsVal || String(vSourceTs) === String(imgTsVal);
if (Array.isArray(vDet) && vDet.length > 0 && tsMatch) {
detections = vDet;
lastDetsRef.current = vDet;
lastDetsTsRef.current = imgTsVal;
} else if (Array.isArray(vDet) && vDet.length > 0) {
// Detections exist but from a different image — cache but don't display
lastDetsRef.current = vDet;
lastDetsTsRef.current = vSourceTs;
} else if (lastDetsRef.current.length > 0 && lastDetsTsRef.current != null &&
String(lastDetsTsRef.current) === String(imgTsVal)) {
// No detections in store — use cache only if it matches current image
detections = lastDetsRef.current;
}
设备遥测:MQTT 主题与 WebSocket 消息
NE101 设备的遥测通过 MQTT 上报到 devices/{device_id}/telemetry 主题,NeoMind 主控订阅后将增量通过 WebSocket 推给前端组件的 wsValues state。组件消费的关键设备指标集中在 bundle.js L830-L842:
// bundle.js L830-L842
var batteryVal = getFirst(vals, ['values.battery', 'battery']);
var devName = device.name || getFirst(vals, ['values.devName', 'devName']) || 'NE101 Camera';
var metrics = (deviceType && deviceType.metrics) || [];
var displayMetrics = [];
for (var i = 0; i < metrics.length; i++) {
var m = metrics[i];
var n = (m.name || '').toLowerCase();
if (n === 'ts' || n === 'timestamp' || n === 'time') continue;
if (n === 'values.battery' || n === 'battery') continue;
if (n.indexOf('image') >= 0 || n.indexOf('photo') >= 0 || n.indexOf('picture') >= 0) continue;
if (n === 'values.devname' || n === 'devname') continue;
displayMetrics.push(m);
}
-
battery—— 电池百分比(0-100),用batteryMeta()映射成绿/黄/红三色条(L830)。这是 NE101 低功耗设计的核心健康指标。 -
图像字段(多别名) —— 抓拍到的 JPEG,可能是 URL 也可能是 base64。组件用
getFirst()按优先级探测一组别名,见bundle.jsL634:['values.imageUrl', 'values.image', 'values.photo', 'imageUrl', 'image', 'photo', 'values.picture', 'picture']。多别名是因为不同固件版本和不同部署模式下字段命名不统一——有的固件写
imageUrl,有的写image,REST 拉取和 WebSocket 推送的字段名也可能不同。getFirst按数组顺序返回第一个非空值,兼容了这种历史包袱。 -
ts/timestamp—— 抓拍时间戳,用于source_ts对齐(见 4.4)和图像 cache-buster(见下文)。 -
devName—— 设备名,回退到device.name(L831)。
图像源的处理有几个容易踩的坑,都集中在 bundle.js L634-L648:
// bundle.js L634-L648
var rawImageSrc = getFirst(_vals, ['values.imageUrl', 'values.image', 'values.photo', 'imageUrl', 'image', 'photo', 'values.picture', 'picture']);
// Guard: only strings can be image sources — numbers/objects from metrics crash .indexOf()/.match()
if (typeof rawImageSrc !== 'string') rawImageSrc = null;
var isBase64Image = rawImageSrc && (rawImageSrc.indexOf('data:image') === 0 || !rawImageSrc.match(/^https?:\/\//));
// For URL images: append ts-based cache buster; for base64: use as-is (ts change triggers re-render via new imageSrc ref)
var imgTs = getFirst(_vals, ['ts', 'values.ts', 'timestamp', 'values.timestamp']);
var imageSrc;
if (!rawImageSrc) {
imageSrc = '';
} else if (isBase64Image) {
// Ensure base64 has data URI prefix for <img> display
imageSrc = rawImageSrc.indexOf('data:') === 0 ? rawImageSrc : 'data:image/jpeg;base64,' + rawImageSrc;
} else {
imageSrc = rawImageSrc + (rawImageSrc.indexOf('?') >= 0 ? '&' : '?') + '_t=' + (imgTs || 0);
}
坑 1:非字符串守卫(commit c4fe7bf)。L636 有一行 if (typeof rawImageSrc !== 'string') rawImageSrc = null;。
这是因为某些后端会把图像字段误存成数字(比如把 base64 长度当成了值)或对象(嵌套的 metric wrapper)。如果不守卫,后续的 rawImageSrc.indexOf('data:image') 和 rawImageSrc.match(/^https?:\/\//) 会直接抛 TypeError: rawImageSrc.indexOf is not a function,导致整个组件白屏。
commit c4fe7bf(fix(ne101): guard rawImageSrc against non-string metric values)就是专门修这个崩溃的。
坑 2:base64 vs URL 检测。L637 用 isBase64Image = rawImageSrc.indexOf('data:image') === 0 || !rawImageSrc.match(/^https?:\/\//) 判断。
注意这里用的是**「或」而不是「且」——只要不是 http(s):// 开头就按 base64 处理,这是一种乐观判定**:
- 宁可把一个奇怪的字符串当 base64 试一次(
<img>标签对非法 src 会静默失败) - 也不要把一个 base64 字符串当 URL 去请求(会触发一个无意义的 HTTP 请求 + CORS 报错)
坑 3:URL 图像的 cache-buster。L647 给 URL 图像追加 _t=<ts>:imageSrc = rawImageSrc + (...) + '_t=' + (imgTs || 0);。
NE101 每次抓拍后 URL 可能不变(同一个 devices/{id}/latest.jpg 端点),但图像内容变了。如果不加 cache-buster,浏览器会复用缓存的旧图像,用户永远看不到新抓拍。
base64 图像不需要 cache-buster,因为每次 base64 字符串本身就是新的引用,<img> 会重新解码。
扩展响应的四种归一化
AI 扩展的响应格式由扩展作者决定,ne101_camera 不能控制。
为了在组件内部统一处理,generateTransformJsCode 在生成 Transform 代码时把四种 responseType 都归一化成同一个内部形状:{bbox: [x1, y1, x2, y2], label, confidence}(坐标归一化到 0-1)。
这四种 responseType 的分发逻辑在 bundle.js L288-L329:
四种 responseType 的数据路径和字段格式对照如下:
| responseType | 数据路径 | 字段格式 |
|---|---|---|
boxes_x1y1x2y2 | r.boxes | `{x1,y1,x2,y2}` 像素坐标(代码归一化后 0-1) |
objects_bbox | r.objects[].bbox | `{x,y,w,h}` 像素坐标 |
detections_bbox | r.detections[].bbox | `{x,y,w,h}` 像素坐标 |
ocr_text_blocks | r.text_blocks | 见下方代码块(有 polygon) |
分发逻辑在 bundle.js L288-L329。其中前三种 responseType 的归一化逻辑高度相似——都是把各自格式的坐标转换成 [x1,y1,x2,y2] 并除以图像宽高 W/H 得到 0-1 范围。
boxes_x1y1x2y2(locate-anything 系) —— 标签不在 box 里,而在 r.answer 字符串里以 <ref>label</ref> 标签形式按顺序排列,代码用正则提取配对。commit 8656148 在 L282 给这个扩展额外透传了 nms_iou_threshold: 0.5 参数。
objects_bbox(image-analyzer) —— 归一化时把 {x, y, width, height} 转成 [x1, y1, x2, y2](x2 = x + width),再除以 W/H。
detections_bbox(yolo-device-inference) —— 字段结构与 objects_bbox 几乎一样,只是顶层 key 从 objects 变成了 detections。
之所以单独列为一种 responseType 而不是复用 objects_bbox,是因为 image-analyzer 专用,与 yolo 共用 analyze_image 命令但响应路径不同,且未来 yolo-device-inference 可能在响应里增加设备端独有的字段(如推理耗时、模型版本)。
ocr_text_blocks(ocr-device-inference) —— 见 bundle.js L316-L328。
响应结构是 r.data.text_blocks[],每个 block 有 text、confidence、bbox: {x, y, width, height} 和可选的 polygon(多边形顶点数组)。
归一化时 保留 polygon 字段(polygon: b.polygon || null),因为 OCR 文本框通常不是轴对齐矩形(倾斜文本),多边形比 bbox 更贴合。坐标已经是 0-1 归一化的,不再除以 W/H。
commit 403c0f1(fix(ne101): handle {x,y} object format for OCR polygon detection boxes)修复了 polygon 顶点可能是 {x, y} 对象格式也可能是 [x, y] 数组格式的兼容问题:
// bundle.js L316-L328
} else if (mode.responseType === 'ocr_text_blocks') {
L.push('var data = r.data || r;');
L.push('var blocks = data.text_blocks || [];');
L.push('var dets = blocks.map(function(b) {');
L.push(' var b2 = b.bbox || {};');
L.push(' return {');
L.push(' bbox: [b2.x, b2.y, (b2.x||0) + (b2.width||0), (b2.y||0) + (b2.height||0)],');
L.push(' polygon: b.polygon || null,');
L.push(' label: b.text || \'\',');
L.push(' confidence: b.confidence || null');
L.push(' };');
L.push('});');
L.push('var texts = blocks.map(function(b) { return b.text; }).filter(Boolean);');
}
下图把四种 responseType 到统一内部形状的映射关系做成表格化的 mermaid,方便速查。
扩展模式目录:四种 responseType 背后是四个扩展的「模式目录」,定义在 bundle.js L155-L171 的 EXT_MODES 对象里。
每个扩展对应一个模式数组,每个模式有 {id, command, imageArg, responseType, label, args}。例如 locate-anything 有 5 个模式(object_detection / grounding / text_detection / ground_gui / point),都走 boxes_x1y1x2y2 响应格式:
// bundle.js L155-L171
var EXT_MODES = {
'locate-anything': [
{ id: 'object_detection', command: 'detect', imageArg: 'image_base64', responseType: 'boxes_x1y1x2y2', label: 'Object Detection', desc: 'Detect objects by category', icon: 'search', args: ['categories'] },
{ id: 'grounding', command: 'ground', imageArg: 'image_base64', responseType: 'boxes_x1y1x2y2', label: 'Grounding', desc: 'Find objects by description', icon: 'target', args: ['phrase'] },
// ... (3 modes omitted)
],
'image-analyzer': [
{ id: 'object_detection', command: 'analyze_image', imageArg: 'image', responseType: 'objects_bbox', label: 'Object Detection', desc: 'YOLOv8 object detection', icon: 'search', args: [] }
],
'yolo-device-inference': [
{ id: 'object_detection', command: 'analyze_image', imageArg: 'image', responseType: 'detections_bbox', label: 'Object Detection', desc: 'YOLOv8 device inference', icon: 'search', args: [] }
],
'ocr-device-inference': [
{ id: 'text_detection', command: 'recognize_image', imageArg: 'image', responseType: 'ocr_text_blocks', label: 'Text Detection', desc: 'OCR text recognition', icon: 'text', args: [] }
]
};
宽容回退策略:bundle.js L181-L193 的 getExtMode() 函数在扩展 ID 不在 EXT_MODES 里时,返回一个默认的 object_detection 模式对象(responseType: 'boxes_x1y1x2y2')。这是一个设计决策:
// bundle.js L181-L193
// Fallback: return default object_detection mode for unknown extensions
// This allows Transform creation to proceed even for unlisted extensions
return {
id: templateName || 'object_detection',
command: 'detect',
imageArg: 'image',
responseType: 'boxes_x1y1x2y2',
label: 'Object Detection',
desc: 'Generic detection',
icon: 'search',
args: []
};
- 选择:宽容回退——未列出的扩展也能创建 Transform,默认走
boxes_x1y1x2y2响应格式。 - 备选方案:严格白名单——未列出的扩展直接报错「不支持」。
- 理由:NeoMind 生态的 AI 扩展会持续增加,如果每次新增扩展都要改 ne101_camera 的
EXT_MODES白名单,组件版本和扩展版本会产生强耦合。宽容回退让「新扩展 + 旧组件」至少能跑起来(多数检测类扩展的响应格式接近boxes_x1y1x2y2),用户只会在响应格式不匹配时看到空检测,而不是直接被挡在门外。 - 代价:如果新扩展的响应格式确实不同(比如返 回
segments而不是boxes),宽容回退会产生静默失败——组件不报错但检测框为空。这个代价用 5 前端消费的调试日志(console.warn('empty detections'))来缓解。
Virtual 指标输出契约
Transform 生成的 synthetic 指标遵循一个严格的命名约定:前缀 virtual.<ext_id_normalized>. + 字段名。
ext_id_normalized 是把扩展 ID 里的连字符替换成下划线(例如 yolo-device-inference → yolo_device_inference),因为指标名里不允许出现连字符(会与某些后端的 key parser 冲突)。
这个归一化在 bundle.js L854 完成:var pfx = 'virtual.' + processingExtId.replace(/-/g, '_') + '.';:
// bundle.js L854
var pfx = 'virtual.' + processingExtId.replace(/-/g, '_') + '.';
输出指标的生成逻辑在 bundle.js L406-L436,按模板类型和 ROI 配置分发:
// bundle.js L406-L436
L.push('var out = {};');
L.push('out[\'' + pfx + 'detections\'] = outputDets;');
if (templateName === 'object_detection') {
L.push('out[\'' + pfx + 'total_count\'] = outputDets.length;');
L.push('out[\'' + pfx + 'count_by_class\'] = outputDets.reduce(function(a, d) { a[d.label] = (a[d.label]||0)+1; return a; }, {});');
}
if (rois.length > 0) {
L.push('out[\'' + pfx + 'roi_count\'] = dets.filter(inAnyRoi).length;');
L.push('for (var ri = 0; ri < roiRegions.length; ri++) {');
L.push(' var rn = roiRegions[ri].name; var rp = roiRegions[ri].poly;');
L.push(' var rd = dets.filter(function(d) { return detOverlapsRoi(d, rp); });');
L.push(' out[\'' + pfx + '\' + rn + \'_count\'] = rd.length;');
L.push(' out[\'' + pfx + '\' + rn + \'_detections\'] = rd;');
// ... (count_by_class omitted for brevity)
L.push('}');
}
if (mode.responseType === 'ocr_text_blocks') {
L.push('out[\'' + pfx + 'texts\'] = texts || [];');
}
L.push('out[\'' + pfx + 'inference_time_ms\'] = r.inference_time_ms || r.processing_time_ms || null;');
L.push('out[\'' + pfx + 'source_ts\'] = input_raw && (input_raw.ts || input_raw.timestamp) || null;');
| 输出指标 | 条件 | 行号 | 用途 |
|---|---|---|---|
<pfx>detections | 始终输出 | L408 | 归一化后的检测数组,组件渲染检测框的核心数据源 |
<pfx>total_count | 仅 object_detection 模板 | L411 | 检测到的目标总数,用于指标卡片汇总 |
<pfx>count_by_class | 仅 object_detection 模板 | L412 | 按类别分组的计数对象,例如 {person: 3, car: 2} |
<pfx>roi_count | 配置了 ROI 时 | L417 | 落在任意 ROI 内的检测总数(全局) |
<pfx><roi_name>_count | 配置了 ROI 时,每个 ROI | L423 | 该 ROI 内的检测数 |
<pfx><roi_name>_detections | 配置了 ROI 时,每个 ROI | L424 | 该 ROI 内的检测数组 |
<pfx><roi_name>_count_by_class | 配置了 ROI 且 object_detection | L426 | 该 ROI 内按类别分组的计数 |
<pfx>texts | 仅 ocr_text_blocks 模板 | L432 | OCR 识别出的文本字符串数组 |
<pfx>inference_time_ms | 始终输出 | L435 | 扩展推理耗时(毫秒),用于性能监控 |
<pfx>source_ts | 始终输出 | L436 | 输入图像的时间戳,检测与图像对齐的唯一依据 |
输出前缀和设备类型规则在 fillTemplate() 里固定写死:bundle.js L452-L453 设置 output_prefix: 'virtual' 和 rule: { device_type: 'ne101_camera' }。
这意味着所有 synthetic 指标都落在 virtual.* 命名空间下,且 Transform 只对 device_type === 'ne101_camera' 的设备生效——防止误把其它设备类型的遥测喂给 AI 扩展:
// bundle.js L452-L453
js_code: jsCode,
output_prefix: 'virtual',
rule: { device_id: pipe.deviceId || '', device_type: 'ne101_camera' }
source_ts 对齐机制是 virtual 指标契约里最精妙的部分,逻辑在 bundle.js L858-L874。
组件读取 virtual 指标后,会取出 source_ts(L858)和当前图像的 imgTs(L860),做字符串比对(L861):tsMatch = !vSourceTs || !imgTsVal || String(vSourceTs) === String(imgTsVal)。
只有 tsMatch === true 且检测数组非空时,才把检测渲染到 Canvas 上(L862-L865)。如果 source_ts 不匹配(说明检测是针对旧图像的),检测会被缓存到 lastDetsRef 但不渲染(L866-L869)。
这个机制确保用户永远不会看到「图像 A 叠着图像 B 的检测框」这种时空错配:
// bundle.js L858-L874
var vSourceTs = getFirst(vals, [pfx + 'source_ts', 'values.' + pfx + 'source_ts']);
// Match: detections' source_ts must align with the current image timestamp
var imgTsVal = imgTs;
var tsMatch = !vSourceTs || !imgTsVal || String(vSourceTs) === String(imgTsVal);
if (Array.isArray(vDet) && vDet.length > 0 && tsMatch) {
detections = vDet;
lastDetsRef.current = vDet;
lastDetsTsRef.current = imgTsVal;
} else if (Array.isArray(vDet) && vDet.length > 0) {
// Detections exist but from a different image — cache but don't display
lastDetsRef.current = vDet;
lastDetsTsRef.current = vSourceTs;
} else if (lastDetsRef.current.length > 0 && lastDetsTsRef.current != null &&
String(lastDetsTsRef.current) === String(imgTsVal)) {
// No detections in store — use cache only if it matches current image
detections = lastDetsRef.current;
}