扩展侧:processingExtensionId 通用 AI 处理契约
本节是 ne101_camera 案例的扩展侧契约参考页,覆盖
processingExtensionId通用 AI 处理契约:白名单校验(AI_EXT_IDS)、模式映射(EXT_MODES)、__imageData注入机制和降级策略。
processingExtensionId 通用契约
ne101_camera 组件最容易被误解的一点是:它看起来在做「AI 物体检测」,但翻遍 1972 行 bundle.js,你找不到 一行 YOLO 推理、一行 ONNX runtime、一行模型权重加载。
组件本身不做任何 AI。 所有 AI 推理都被外包给了用户通过 processingExtensionId 配置字段指定的扩展。这个字段位于 manifest.json L23-L24 的 default_config 块里:
"processingEnabled": false,
"processingExtensionId": "",
processingEnabled 是总开关(默认 false,开箱即用只是一个纯展示摄像头画面的组件),processingExtensionId 是扩展 ID 槽位(默认空字符串 = 未选中任何扩展 = 不做处理)。
当用户在 AdvancedPanel 里把总开关打开并从下拉框选择了一个扩展(例如 locate-anything-v2)后,组件的 generateTransformJsCode 会把扩展 ID 写进生成的 Transform 代码的 extensions.invoke() 调用里,Transform 在主控沙箱执行时由平台负责把调用路由到对应扩展的 HTTP/RPC 端点。
这个「组件 + 可插拔扩展」契约是 NeoMind 生态 AI 复用的模板:一个组件,N 个推理后端。同一个 ne101_camera 组件,搭配 locate-anything-v2 就是「开放词汇目标检测」,搭配 ocr-device-inference 就是「OCR 文字识别」,搭配 yolo-device-inference 就是「边缘设备端 YOLOv8 推理」。
组件本身不需要知道这些扩展的内部实现,只需要知道如何调用它们、如何归一化它们的响应(见 4.3)。
为什么选择「可插拔扩展」而不是「内置 AI」:如果组件自己打包了一个 YOLO 模型(例如把 onnxruntime-web + yolov8n.weights 嵌进 bundle),会有三个严重后果。
- bundle 体积爆炸——一个量化后的 YOLOv8n 权重就有 12MB,加上 onnxruntime-web 的 WASM 大约 12MB,整个 bundle 从 80KB 暴涨到 25MB 以上,平台加载时间从毫秒级变成秒级。
- 模型选择被锁死——用户想要 OCR 就得换一个「内置 OCR 模型」版本的组件,组件市场的 SKU 数量成倍增长。
- 模型更新和组件更新强耦合——YOLO 模型每迭代一版就要发一个新组件版本,而扩展是独立部署的(由用户或平台运维单独升级),扩展更新不需要触碰组件代码。
「可插拔扩展」把这三个问题全部解开了:组件保持 80KB,用户自己选模型,扩展可以独立更新。
组件零 AI、推理外包给扩展是 NeoMind 组件市场的核心范式。同一个组件搭配不同扩展就能做不同任务(检测 / OCR / 描述),组件保持 80KB 轻量,扩展可独立升级。这就是「一组件多用途」范式的根基。
下图展示了「组件 → processingExtensionId → N 个候选扩展」的扇出关系。组件只暴露一个槽位,由用户的下拉选择决定实际调用哪一个扩展,扩展之间互不感知。
设计决策:可插拔扩展 vs 内置 AI 模型
- 选择:组件零 AI,推理外包给
processingExtensionId指定的扩展(manifest.json L24)。 - 备选方案 A:组件打包自己的 YOLO 模型(onnxruntime-web + 权重文件)。否决理由:bundle 体积从 80KB 暴涨到 25MB+,加载时间从毫秒变秒,且模型选择被锁死——想要 OCR 必须换组件版本。
- 备选方案 B:组件打包多个模型(一个检测 + 一个 OCR),按用户配置在运行时切换。否决理由:体积问题更严重(25MB x 2),且模型之间会有 GPU/WASM 内存竞争。
- 理由:可插拔扩展让组件保持轻量(80KB),模型选择权交给用户(按场景挑扩展),扩展可独立升级(不影响组件版本)。这是 NeoMind 组件市场「一组件多用途」范式的根基。
- 代价:组件无法在「没有安装任何 AI 扩展」的环境下做检测——但这正是
processingExtensionId默认空字符串的语义(纯展示模式)。
AI_EXT_IDS 白名单
平台上有许多扩展(天气、ONVIF 桥接、各种 AI 推理),但 ne101_camera 只关心能消费图像输入并返回检测结果的 AI 扩展。组件用一个硬编码的白名单来过滤,定义在 bundle.js L144:
var AI_EXT_IDS = ['locate-anything-v2', 'image-analyzer-v2', 'yolo-device-inference', 'ocr-device-inference'];
这四个扩展的职责分别是:
locate-anything-v2—— 基于 Grounding DINO 风格的开放词汇目标检测,支持「找一只猫」「找红色汽车」这类自由文本描述(phrase),是四个扩展里模式最多(5 个)、能力最广的一个。image-analyzer-v2—— 服务端 YOLOv8 目标检测,固定类别集(COCO 80 类),不需要 phrase 输入,适合「数人头」「数车辆」这类标准检测场景。yolo-device-inference—— 边缘设备端 YOLOv8 推理,与image-analyzer-v2功能类似但推理发生在 NE101 设备本身(而非服务端),延迟更低、不占服务端 GPU。ocr-device-inference—— PaddleOCR 文字识别,返回文本块及其多边形包围盒,用于「车牌识别」「告示牌文字提取」场景。
这个白名单在 AdvancedPanel 的扩展加载逻辑里被使用,位于 bundle.js L1488-L1491。组件调用 window.neomind.listExtensions() 拿到平台所有已安装扩展的列表,然后用白名单做过滤:
var arr = Array.isArray(exts) ? exts : [];
var filtered = [];
for (var i = 0; i < arr.length; i++) {
if (AI_EXT_IDS.indexOf(arr[i].id) >= 0) filtered.push(arr[i]);
}
过滤后的 filtered 数组才会传给 ExtDropdown 渲染成下拉选项。这意味着即使平台上装了 weather-forecast-v2、onvif-bridge、uink-rms-bridge 等非 AI 扩展,它们也不会出现在 ne101_camera 的扩展选择下拉框里——因为这些扩展无法消费图像输入,选中了也只是 Transform 调用失败。
为什么用硬编码白名单而不是「显示所有扩展」:用户体验是核心理由。如果下拉框里混入 weather-forecast-v2,用户可能会选中它,然后困惑于「为什么摄像头画面上没有检测框」——天气扩展根本不接受图像参数。白名单把「能用的」和「不能用的」在 UI 层就分开了,避免用户进入「选错了但不知道为什么」的死胡同。
设计决策:硬编码白名单 vs 元数据驱动 vs 显示全部
- 选择:在
bundle.js里硬编码AI_EXT_IDS = ['locate-anything-v2', ...]四元素数组(L144),用indexOf做过滤(L1488-L1491)。 - 备选方案 A:元数据驱动——扩展在自己的 manifest 里声明
"supports_image": true,组件读这个字段做过滤。否决理由:这要求所有扩展作者遵守一个「声明能力」的契约,而 NeoMind 当前的扩展 manifest 没有这个字段。引入它需要平台层面的标准化,短期内不会发生。 - 备选方案 B:显示所有已安装扩展。否决理由:非 AI 扩展(天气、ONVIF 桥)混入下拉框,用户选错后 Transform 调用失败,体验极差,且排错困难(错误可能延迟到运行 Transform 时才暴露)。
- 理由:硬编码白名单是最简单的方案——四个扩展是已知的、稳定的 AI 集合,新增一个 AI 扩展时只需要往数组里加一个字符串。在没有扩展元数据标准的当下,这是务实的选择。
- 代价:新增 AI 扩展需要修改组件代码(往
AI_EXT_IDS加元素 + 往EXT_MODES加模式定义)。但 3.7 的宽容回退机制保证了「新扩展 + 旧白名单」也能跑起来(走默认detect命令)。
EXT_MODES 模式目录
每个扩展不只有一种调用方式——locate-anything-v2 既能做按类别的目标检测,也能做按自由文本的 grounding,还能做 OCR。组件用一个模式目录 EXT_MODES 来描述「这个扩展支持哪几种调用模式,每种模式的参数和响应格式是什么」。这个目录位于 bundle.js L154-L171,结构是一个以扩展 ID 为键的对象,值是该扩展支持的模式数组。每个模式是一个对象,包含 id / command / imageArg / responseType / label / desc / icon / args 八个字段。
var EXT_MODES = {
'locate-anything-v2': [
{ 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'] },
{ id: 'text_detection', command: 'detect_text', imageArg: 'image_base64', responseType: 'boxes_x1y1x2y2', label: 'Text Detection', desc: 'Extract text from image', icon: 'text', args: [] },
{ id: 'ground_gui', command: 'ground_gui', imageArg: 'image_base64', responseType: 'boxes_x1y1x2y2', label: 'UI Grounding', desc: 'Locate UI elements by description', icon: 'monitor', args: ['phrase'] },
{ id: 'point', command: 'point', imageArg: 'image_base64', responseType: 'boxes_x1y1x2y2', label: 'Point', desc: 'Point to specific objects', icon: 'cursor', args: ['phrase'] }
],
'image-analyzer-v2': [
{ 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: [] }
]
};
Source: bundle.js L154-L171
四个扩展的模式分布如下:
locate-anything-v2(L155-L161)—— 5 个模式,全部走boxes_x1y1x2y2响应格式,全部用image_base64入参:object_detection(按类别检测,需要categories输入)grounding(按短语定位,需要phrase输入)text_detection(文字检测,无额外输入)ground_gui(UI 元素定位,需要phrase输入)point(指向特定目标,需要phrase输入)
image-analyzer-v2(L162-L164)—— 1 个模式:object_detection,走objects_bbox响应格式,image入参。yolo-device-inference(L165-L167)—— 1 个模式:object_detection,走detections_bbox响应格式,image入参。ocr-device-inference(L168-L170)—— 1 个模式:text_detection,走ocr_text_blocks响应格式,image入参。
args 字段如何驱动 UI:每个模式的 args 数组决定了 AdvancedPanel 在该模式被选中时显示哪些输入框。args: ['categories'] 会渲染一个「类别过滤」输入框(用户填 person,car);args: ['phrase'] 会渲染一个「描述短语」输入框(用户填「a red car」);args: [] 不渲染额外输入框。这个机制让同一个 AdvancedPanel 能根据所选扩展和模式动态调整输入字段,不需要为每个扩展写独立的配置面板。
模式选择 UI 的行为:当用户在 ExtDropdown 里选了 locate-anything-v2,下方的模式选择区会显示 5 张模式卡片(object_detection / grounding / text_detection / ground_gui / point);选了 image-analyzer-v2 则只显示 1 张卡片。这个「按扩展展开模式」的逻辑由 bundle.js L196-L198 的 getExtModes(extId) 函数驱动——它返回 EXT_MODES[extId] 数组,AdvancedPanel 遍历这个数组渲染卡片。
/** Get available modes for an extension */
function getExtModes(extensionId) {
return EXT_MODES[extensionId] || [{ id: 'object_detection', command: 'detect', imageArg: 'image', responseType: 'boxes_x1y1x2y2', label: 'Object Detection', desc: 'Generic detection', icon: 'search' }];
}
Source: bundle.js L195-L198
设计决策:每扩展模式目录 vs 单一通用 detect 模式
- 选 择:
EXT_MODES按扩展列出所有模式(L154-L171),getExtModes(extId)返回该扩展的模式数组供 UI 渲染(L196-L198)。 - 备选方案:所有扩展共用一个通用
detect模式(command: 'detect'+ 固定参数集)。否决理由:不同扩展的能力差异巨大——locate-anything-v2支持 grounding(按短语定位),这个能力在 YOLO 类扩展上根本不存在。如果强制所有扩展走同一个detect命令,要么 grounding 模式无法暴露给用户(功能丢失),要么 YOLO 扩展收到它不认识的ground命令后报错(运行时崩溃)。模式目录让每个扩展只暴露它真正支持的能力。 - 理由:扩展之间的能力差异是客观存在的(Grounding DINO 有 5 种调用方式,YOLO 只有 1 种),模式目录是这种差异的显式声明。UI 根据目录动态渲染,既不会给用户展示不存在的选项,也不会把无效的命令发给扩展。
imageArg 与 responseType 三元组
每个模式对象里最关键的两个字段是 imageArg 和 responseType——它们定义了组件与扩展之间的接口契约。imageArg 描述「组件用什么参数名把图像传给扩展」,responseType 描述「扩展返回什么形状的数据」。这两个字段的含义在源码注释里写得很清楚,位于 bundle.js L146-L153:
imageArg: extension's input parameter name for the image
'image_base64' = locate-anything-v2 style (expects raw base64 string)
'image' = most other extensions (expects base64 string under 'image' key)
responseType: how the extension returns detection results
'boxes_x1y1x2y2' = { boxes: [{x1,y1,x2,y2}, ...] } (pixel coords)
'objects_bbox' = { objects: [{label, confidence, bbox:{x,y,width,height}}] } (pixel coords)
'detections_bbox'= { detections: [{label, confidence, bbox:{x,y,width,height}}] } (pixel coords)
'ocr_text_blocks'= { success, data: { text_blocks: [...] } } (normalized 0-1)
imageArg 的两种取值:image_base64(locate-anything-v2 系)把 base64 字符串直接作为参数值传;image(其它三个扩展)把 base64 放在 image 键下传。
这种差异源于扩展作者各自的实现习惯——locate-anything-v2 的 API 设计得更「扁平」(直接传 base64 字符串),其它扩展更「结构化」(参数包在对象里)。模式目录通过 imageArg 字段把这种差异归一化了——组件在生成 Transform 代码时根据 imageArg 的值决定参数名,不需要用户关心。
Transform 中的实际调用:bundle.js L277-L278 生成的代码长这样:
var r = extensions.invoke('locate-anything-v2', 'detect', {
image_base64: __imageData,
categories: 'person,car',
nms_iou_threshold: 0.5
});
这里的 'locate-anything-v2' 是扩展 ID、'detect' 是模式目录里的 command 字段、image_base64 是模式的 imageArg 字段、__imageData 是平台在 Transform 执行时注入的设备抓拍 JPEG 的 base64 编码(见 3.6)。扩展被调用后返回一个对象,其形状由 responseType 描述——组件在生成代码的后半段(L288-L329)根据 responseType 分发到不同的归一化分支,把四种异构响应统一成 {bbox, label, confidence} 内部形状(详见 4.3)。
// Parse detections from extension response
if (mode.responseType === 'boxes_x1y1x2y2') {
L.push('var rawBoxes = r.boxes || [];');
L.push('var refTags = (r.answer || \'\').match(/<ref>(.*?)<\\/ref>/g) || [];');
L.push('var dets = rawBoxes.map(function(b, i) {');
L.push(' return {');
L.push(' bbox: [b.x1 / W, b.y1 / H, b.x2 / W, b.y2 / H],');
L.push(' label: (refTags[i] || \'\').replace(/<\\/?ref>/g, \'\'),');
L.push(' confidence: b.score || b.confidence || null');
L.push(' };');
L.push('});');
} else if (mode.responseType === 'objects_bbox') {
L.push('var dets = (r.objects || []).map(function(o) {');
L.push(' var b = o.bbox || {};');
L.push(' return {');
L.push(' bbox: [(b.x||0)/W, (b.y||0)/H, ((b.x||0)+(b.width||0))/W, ((b.y||0)+(b.height||0))/H],');
L.push(' label: o.label || \'\',');
L.push(' confidence: o.confidence || null');
L.push(' };');
L.push('});');
} else if (mode.responseType === 'detections_bbox') {
L.push('var dets = (r.detections || []).map(function(d) {');
L.push(' var b = d.bbox || {};');
L.push(' return {');
L.push(' bbox: [(b.x||0)/W, (b.y||0)/H, ((b.x||0)+(b.width||0))/W, ((b.y||0)+(b.height||0))/H],');
L.push(' label: d.label || \'\',');
L.push(' confidence: d.confidence || null');
L.push(' };');
L.push('});');
} 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);');
}
Source: bundle.js L287-L329
下图把「组件 → 图像入参 → 扩展 → 响应出参 → 组件归一化」这条契约链画成时序图,标出 imageArg 和 responseType 各自定义了链路上的哪一段。
设计决策:按模式定义 imageArg vs 全局统一参数名
- 选择:每个模式对象自己声明
imageArg(L146-L148),组件在生成调用代码时读取这个字段作为参数名。 - 备选方案:全局约定所有扩展都用同一个参数名(如
image),组件硬编码{ image: __imageData }。否决理由:这要求所有扩展作者修改自己的 API 来对齐参数名——locate-anything-v2已经上线且 API 固定为image_base64,强行改名会破坏已有调用方。模式目录的imageArg字段让组件适配扩展的既有命名约定,而不是反过来要求扩展适配组件。 - 理由:扩展是独立演进的,组件是后写的。让组件去适配扩展的既有 API(通过
imageArg字段)比要求扩展改 API 的成本低得多——前者只改组件代码,后者要协调多个扩展作者 + 处理向后兼容。 - 代价:模式对象多了一个字段(
imageArg),认知负担略增。但这是用「数据描述」替代「代码分支」的标准权衡——如果不用imageArg,组件就要写if (extId === 'locate-anything-v2') arg = 'image_base64'; else arg = 'image';这样的 if-else 链,可维护性更差。
locate-anything-v2 的 NMS 阈值特例
在所有扩展里,locate-anything-v2 享受一个特殊待遇:组件在生成调用代码时,会额外给它透传一个 nms_iou_threshold: 0.5 参数。这个特例由 commit 8656148(feat(ne101): pass NMS IoU threshold 0.5 to locate-anything-v2)引入,代码位于 bundle.js L281-L282:
// Pass NMS threshold to locate-anything-v2 — extension postprocess_args reads it from args
if (extensionId === 'locate-anything-v2') L.push(', nms_iou_threshold: 0.5');
为什么需要 NMS:locate-anything-v2 是 Grounding DINO 风格的开放词汇检测器,它的推理机制(文本-图像跨模态匹配)天然倾向于对同一个目标产生多个高度重叠的候选框——模型「不确定」精确边界在哪里,就吐出一簇覆盖不同裁剪范围的框。如果不做 NMS(Non-Maximum Suppression,非极大值抑制),用户会在画面上看到同一个人被 5 个重叠的框包围,体验极其混乱。NMS 的作用是:按置信度排序候选框,对每个高置信度框,移除与它 IoU(Intersection over Union)超过阈值的所有低置信度框,只保留最优的那个。
为什么是 0.5:IoU 0.5 是 NMS 的「万金油」默认值——低于 0.5 重叠的框几乎肯定不是同一个目标的重复检测(保留),高于 0.5 重叠的框很可能是重复检测(抑制)。这个值在 COCO 评测协议、MMDetection 默认配置、torchvision.ops.nms 文档里都是推荐起点。locate-anything-v2 的扩展后处理从 postprocess_args 里读取这个参数(commit summary 提到了 postprocess_args),如果没有透传则用自己的默认值(可能不是 0.5)。
为什么硬编码而不是用户可配:NMS 阈值是一个专家级调参旋钮——95% 的用户不知道 IoU 是什么,更不知道 0.5 和 0.6 的区别。把它暴露成 AdvancedPanel 里的滑块只会让普通用户困惑(「这个 0.5 是什么意思?我该调到多少?」),而真正需要调 NMS 的 power user 可以直接修改生成的 Transform 代码(代码里有注释 // Generated by component config — safe to customize 提示这是可改的)。组件选择一个公认安全的默认值(0.5)硬编码进去,换取 UI 的简洁。
设计决策:硬编码 NMS 阈值 0.5 vs 用户可配 vs 扩展默认
- 选择:硬编码
nms_iou_threshold: 0.5,仅在extensionId === 'locate-anything-v2'时透传(L281-L282,commit8656148)。 - 备选方案 A:用户可配——在
AdvancedPanel加一个 NMS 阈值滑块。否决理由:NMS 是专家概念,暴露给普通用户增加认知负担;且 0.5 是公认安全默认值,99% 的场景不需要调。 - 备选方案 B:不透传,让扩展用自己的默认值。否决理由:
locate-anything-v2的默认 NMS 行为不可控(可能不开启 NMS,导致重叠框),组件需要保证渲染效果的可预期性。 - 理由:硬编码 0.5 是「最低惊讶原则」的体现——用户看到的检测框数量合理(没有重复),且不需要理解 NMS 概念。需要调参的 power user 可以直接改 Transform 代码。
- 代价