前端消费:从 detections 到 SVG 叠加的渲染全链路
本节是 ne101_camera MVP 阶段的前端渲染参考页,覆盖 effect-driven 渲染管线、按类别上色(golden-angle HSV)、SVG 叠加渲染、object-cover 坐标变换以及 ResizeObserver callback ref 模式。
渲染管线总览
ne101_camera 的渲染不是一次性的 JSX 模板,而是一条由多个 effect 驱动的状态管线。这条管线的起点是平台注入的 props(device / deviceImageSrc / virtualMetrics / config / onConfigChange),终点是挂载在媒体 <div> 上的 SVG 叠加层。中间穿过五个状态节点:
- Transform lifecycle effect(创建/更新/删除后端 Transform,见 5.7)
- WS + REST 合并 effect(拉取图像和 virtual 指标,见 4.7)
imageData/wsValues/virtualData三个 state 的setStateimgNatState(图像原始宽高)+ctrSizeState(容器宽高)驱动的ovTf坐标变换计算(见 5.4)- detections 数组按类别上色后映射成 SVG
<g>元素(见 5.2 / 5.3)。
任何一个节点的状态变化都会触发 React 重渲染,重新走一遍从 ovTf 计算到 SVG 映射的路径。
下图把这条 effect-driven 管线画成流程图,标出每一步的输入/输出 和触发条件。
为什么这条管线是 effect-driven 的:ne101_camera 是 NeoMind 平台的「React-in-IIFE」组件(见 2.1),它没有 Redux / Zustand 这类外部状态管理,所有跨帧状态都用 React.useState + React.useRef 管理。React 的核心心智模型就是「UI = f(state)」——只要 state 变了,渲染函数就会重跑。组件把 WS 推送、REST 回填、图像 onLoad、ResizeObserver 回调都接到了 setState 上,每一次外部事件都通过 state 变更驱动一次完整的重渲染。
这种模式的代价是:没有虚拟 DOM diffing 的优化空间(每次都全量重算 ovTf 和 detections 映射),但因为单组件的 DOM 节点数在 50 以内(一个 <svg> + N 个 <g>),全量重渲染的开销可以忽略。真正昂贵的是 neomind.createTransform 这类异步 API 调用,它们被严格限制在 effect 里、用 cancelled 标志位做取消保护(见 bundle.js L709)。
var payload = Object.assign({}, tplCfg, {
name: transformName,
scope: device.id,
description: 'ne101:' + device.id + ':' + processingExtId + ':' + processingTemplate
});
var cancelled = false;
var persist = function (id) {
transformIdRef.current = id;
if (onCfgChange) onCfgChange(Object.assign({}, config, { _transformId: id, _transformHash: _configHash }));
};
Source: bundle.js L704-L714
按类别上色:Golden-Angle HSV
检测框的颜色不是固定的,而是由类别标签(det.label)决定的。commit c276c23(feat(ne101): per-class detection colors via golden-angle HSV rotation)引入了 classColor(label) 函数,位于 bundle.js L55-L72。这个函数做了三件事:
// Per-class color via golden-angle HSV rotation — maximally distinct hues for any class count.
// Same label always yields the same color; 100+ classes still get good separation.
function classColor(label) {
var h = 0;
for (var i = 0; i < label.length; i++) { h = ((h << 5) - h + label.charCodeAt(i)) | 0; }
var hue = (Math.abs(h) * 137.508) % 360; // golden angle
var s = 0.78, v = 0.95;
var c = v * s, hp = hue / 60, x = c * (1 - Math.abs(hp % 2 - 1)), r = 0, g = 0, b = 0;
if (hp < 1) { r = c; g = x; }
else if (hp < 2) { r = x; g = c; }
else if (hp < 3) { g = c; b = x; }
else if (hp < 4) { g = x; b = c; }
else if (hp < 5) { r = x; b = c; }
else { r = c; b = x; }
var m = v - c, R = Math.round((r + m) * 255), G = Math.round((g + m) * 255), B = Math.round((b + m) * 255);
var rgb = R + ',' + G + ',' + B;
return { stroke: 'rgba(' + rgb + ',0.85)', fill: 'rgba(' + rgb + ',0.08)', text: 'rgba(' + rgb + ',0.95)' };
}
Source: bundle.js L55-L72
-
字符串哈希(L58-L59):用经典的
h = ((h << 5) - h + charCodeAt(i)) | 0累加器对 label 字符串做哈希。这是一个 32 位整数哈希,位移 5 + 减自身等价于乘以 31(((h << 5) - h) = h * 31),是 JavaString.hashCode()的同款算法。| 0把结果截断成 32 位有符号整数。 -
黄金角旋转(L60):
hue = (Math.abs(h) * 137.508) % 360。137.508° 是黄金角(golden angle),即圆周角按黄金比例分割后的较短弧。把哈希值乘以黄金角再 mod 360,等价于在色相环上按黄金比例步进取色——这是数学上让任意数量点在圆周上「最大化最小间距」的最优策略。同一个 label 永远哈希到同一个色相(纯函数,无副作用),而不同 label 即使哈希值只差 1,色相也会差 137.508°,视觉上几乎不可能撞色。
-
HSV → RGB(L61-L71):固定
s = 0.78, v = 0.95(饱和度和明度),用六段分段函数把 HSV 转成 RGB,最后返回rgba(r,g,b,α)三件套:stroke(描边,α=0.85)、fill(填充,α=0.08)、text(标签文字,α=0.95)。透明度的梯度让检测框在图像上既有可辨识的轮廓(描边深),又不会大面积遮挡图像内容(填充浅)。
在 c276c23 之前,检测框的颜色经历了两次迭代。最早是固定蓝色(#3b82f6 系),后来 commit 3cf1b27(style(ne101): change detection box and label color from blue to red)改成固定红色——但固定色在多类别场景下完全无法区分目标。黄金角 HSV 的引入彻底解决了这个问题:COCO 80 类、甚至 OpenImages 级别的 500+ 类都能得到视觉可分的色相。
设计决策:黄金角哈希 vs 固定调色板 vs 随机色
- 选择:字符串哈希 + 黄金角旋转。
- 备选方案 A:固定调色板(如
['#ef4444', '#3b82f6', '#10b981', ...],按类别 index 取色)。否决理由:调色板长度固定(通常 10-20 色),第 N 个类别(N > 调色板长度)会回绕到第一个颜色,多类别场景下颜色重复;且需要维护「类别 → index」的映射表,跨帧跨设备无法保证一致性。 - 备选方案 B:随机色(
Math.random())。否决理由:同一类别每次渲染都换颜色,视觉闪烁严重,用户无法建立「这个颜色 = 这个类别」的肌肉记忆。 - 理由:黄金角旋转在数学上保证了任意类别数的色相最大化分散;纯函数哈希保证了跨帧一致性;零配置(不需要预设调色板)。代价是 HSV 空间不是感知均匀的(蓝色区域人眼区分度低),但实测在 ≤ 50 类的场景下效果可接受。
按类别上色时,纯函数哈希 + 黄金角旋转是优于固定调色板和随机色的选择:保证跨帧一致性(纯函数),支持无限类别数(黄金角),零配置(不需要维护映射表)。
SVG 叠加渲染:Polygon + Rect Fallback
检测框的渲染不是画在 Canvas 上,而是用一层 SVG 叠加在 <img> 之上。这层 SVG 的渲染逻辑在 bundle.js L1210-L1272,核心是一个 detections.map(...) 调用,每个 detection 生成一个 <g> 元素,内含形状(polygon 或 rect)+ 标签文字。
// Detection boxes overlay — SVG (polygon when available, rect fallback) adjusted for object-cover image scaling
(processingEnabled && detections.length > 0 && ovTf)
? jsx('svg', {
key: 'det-svg',
className: 'absolute inset-0 w-full h-full',
style: { pointerEvents: 'none' },
viewBox: '0 0 100 100',
preserveAspectRatio: 'none',
children: detections.map(function (det, i) {
var detLabel = det.label || '';
var detConf = typeof det.confidence === 'number' ? Math.round(det.confidence * 100) : '';
var clr = classColor(detLabel || ('det' + i));
var children = [];
if (det.polygon && det.polygon.length >= 3) {
// Polygon mode (OCR scenarios): precise contour
var pts = det.polygon.map(function(p) {
var px = Array.isArray(p) ? p[0] : p.x;
var py = Array.isArray(p) ? p[1] : p.y;
var tx = ovTf ? ((px * ovTf.sx + ovTf.ox) * 100) : (px * 100);
var ty = ovTf ? ((py * ovTf.sy + ovTf.oy) * 100) : (py * 100);
return tx.toFixed(2) + ',' + ty.toFixed(2);
}).join(' ');
children.push(jsx('polygon', {
key: 'poly', points: pts,
fill: clr.fill, stroke: clr.stroke,
strokeWidth: '0.4'
}));
} else if (det.bbox && det.bbox.length >= 4) {
// Rect fallback (object detection scenarios)
// ... (25 lines omitted: rect coords + label text node)
}
// ... (14 lines omitted: label text rendering)
return children.length > 0 ? jsxs('g', { key: 'dbox-' + i, children: children }) : null;
})
})
: null,
Source: bundle.js L1210-L1272
Polygon 模式(L1224-L1237):当 det.polygon 存在且顶点数 ≥ 3 时,渲染 <polygon>。这是 OCR 场景(ocr_text_blocks responseType)的精确轮廓——OCR 文本框通常不是轴对齐矩形(倾斜文本、弯曲文本行),多边形比 bbox 更贴合。顶点坐标在 L1226-L1231 遍历,每个顶点先经过 ovTf 变换(见 5.4),再拼接成 SVG points 字符串。
Rect fallback(L1238-L1252):当只有 det.bbox(4 元素数组 [x1, y1, x2, y2])时,渲染 <rect>。这是 object detection 场景(objects_bbox / detections_bbox responseType)的标准矩形框。bbox 的四个坐标值分别经过 ovTf 变换后,拼成 <rect> 的 x / y / width / height。
顶点格式兼容(L1227-L1228):commit 403c0f1(fix(ne101): handle {x,y} object format for OCR polygon detection boxes)修复了一个关键的格式兼容问题。OCR 扩展返回的 polygon 顶点有两种格式:[x, y] 数组对(COCO 格式)和 {x, y} 对象(PaddleOCR 原生格式)。
L1227-L1228 用 Array.isArray(p) ? p[0] : p.x 做了双格式探测——如果顶点是数组就取下标 0/1,如果是对象就取 .x / .y 属性。这个兼容层在 polygon 模式(L1227-L1228)和标签定位(L1257-L1258)都出现了一次,确保两种格式的顶点都能正确映射到 SVG 坐标。
标签渲染(L1254-L1267):标签文字(detLabel + detConf)定位在检测框的第一个顶点或 bbox 左上角,垂直方向上偏移 -1.5(SVG 单位,即 viewBox 100 中的 1.5%)。标签用 classColor 返回的 text 颜色(α=0.95),monospace 字体加粗,确保在复杂背景图像上仍然可读。标签内容是 label + confidence%,例如 person 95%。
commit b746c02(feat(ne101): render OCR detection boxes as polygons with rect fallback)是这个分支结构的引入点——在它之前,所有检测框都硬编码渲染成 <rect>,OCR 的倾斜文本框被强行套进轴对齐矩形,视觉上严重失真。
设计决策:SVG over Canvas
-
选择:用 SVG
<svg viewBox="0 0 100 100" preserveAspectRatio="none">叠加层渲染检测框。 -
备选方案:用
<canvas>2D context 手动绘制。 -
理由:
- SVG 是声明式的,可以直接写成 JSX,与 React 的渲染模型天然契合——状态变了,React 重新调用
detections.map生成新的<polygon>/<rect>,无需手动clearRect+ 重绘 - SVG 原生支持
<text>元素,文字渲染由浏览器引擎处理,无需 Canvas 的fillText+ 字体加载 + 像素测量 - SVG 的
viewBox="0 0 100 100"+preserveAspectRatio="none"让检测框坐标可以直接用归一化值(0-100),与后端返回的 0-1 归一化坐标天然对齐(乘以 100 即可) - Canvas 需要手动处理 DPI 缩放、重绘调度、事件命中检测,代码量会翻倍。
- SVG 是声明式的,可以直接写成 JSX,与 React 的渲染模型天然契合——状态变了,React 重新调用
-
代价:SVG 在检测框数量极大(>500)时性能不如 Canvas(每个
<g>都是一个 DOM 节点)。但 ne101_camera 的典型场景(单帧摄像头画面)检测数通常 ≤ 30,SVG 的性能开销可以忽略。
object-cover 坐标变换
检测框的坐标是归一化到图像空间的(0-1 表示相对于原始图像宽高的比例),但图像在 DOM 里是用 object-cover 渲染的——图像会被缩放以完全覆盖容器,多余的部分被裁剪。这意味着图像的可见区域只是原始图像的一个子集,检测框坐标必须经过一个变换才能正确叠加在可见区域上。
这个变换就是 ovTf,计算逻辑在 bundle.js L879-L899。
// Object-cover transform: map normalized image coords (0-1) to container coords (0-1)
// object-cover scales image to cover container, cropping excess.
// In image space, only a portion is visible. We map image coords → container coords.
var imgNat = imgNatState[0];
var ctrSize = ctrSizeState[0];
var ovTf = null;
if (imgNat.w > 0 && imgNat.h > 0 && ctrSize.w > 0 && ctrSize.h > 0) {
var imgAsp = imgNat.w / imgNat.h;
var cAsp = ctrSize.w / ctrSize.h;
if (imgAsp > cAsp) {
// Image wider than container → sides cropped, image fills container height
var scX = (ctrSize.h / imgNat.h * imgNat.w) / ctrSize.w;
ovTf = { sx: scX, sy: 1, ox: (1 - scX) / 2, oy: 0 };
} else {
// Image taller than container → top/bottom cropped, image fills container width
var scY = (ctrSize.w / imgNat.w * imgNat.h) / ctrSize.h;
ovTf = { sx: 1, sy: scY, ox: 0, oy: (1 - scY) / 2 };
}
Source: bundle.js L879-L899
ovTf 是一个 {sx, sy, ox, oy} 四元组,含义是:把归一化图像坐标 (px, py) 映射到归一化容器坐标 (tx, ty) 的仿射变换——tx = px * sx + ox,ty = py * sy + oy。两个分支:
- 图像宽高比 > 容器宽高比(L888-L894):图像比容器「更宽」,缩放后图像的两侧被裁剪,容器只显示图像中间的一段宽度。此时
sy = 1(纵向不缩放),sx = (cH / iH * iW) / cW(横向缩放,因为图像被压缩到了更窄的容器宽度上),ox = (1 - sx) / 2(横向偏移,让裁剪居中),oy = 0。 - 图像宽高比 ≤ 容器宽高比(L895-L898):图像比容器「更高」,缩放后图像的上下被裁剪。此时
sx = 1(横向不缩放),sy = (cW / iW * iH) / cH(纵向缩放),oy = (1 - sy) / 2(纵向偏移),ox = 0。
这个变换在渲染时被应用到每一个检测框的每一个坐标上:polygon 顶点在 L1185-L1187(ROI 多边形)和 L1229-L1230(检测框 polygon),bbox 四角在 L1241-L1244,标签位置在 L1259-L1260。
变换公式统一是 tx = (px * ovTf.sx + ovTf.ox) * 100(乘以 100 是因为 SVG viewBox 是 100x100)。
以检测框 polygon 顶点为例:
// detection polygon 顶点 (L1229-L1230):
var dtx = ovTf ? ((px * ovTf.sx + ovTf.ox) * 100) : (px * 100);
var dty = ovTf ? ((py * ovTf.sy + ovTf.oy) * 100) : (py * 100);