用原生 SVG 稳定叠加图片识别标注
文章以照片曲线标注为例,讲解无需 Canvas 或第三方库的 SVG 覆盖层实现。后端按实际分析图片返回归一化坐标,便于处理页面缩放与不同屏幕下的标注对齐。
AI 深度解读
AI 已经识别出照片上的目标,前端画出来却偏了:电脑上正常,手机上错位,窗口一缩放又开始漂移。这类问题往往出在图片与标注没有共用一套坐标规则。
作者结合 PalmReading.pro 的手掌曲线展示,给出了一套不依赖框架的 SVG 方案:归一化坐标负责数据交换,viewBox 负责缩放,再补上曲线平滑、描边和标签避让。它适合少量图片标注,但原文的代码距离完整可用还有几处缺口。
坐标对齐,先确定模型到底看了哪张图
作者要求后端返回相对分析图片的坐标,横纵坐标都在 0 到 1 之间,同时保留图片尺寸。下面的数据描述了三条手掌曲线:
{
"width": 1024,
"height": 1365,
"lines": [
{"name": "Heart line", "points": [[0.18,0.36],[0.42,0.33],[0.71,0.30]]},
{"name": "Head line", "points": [[0.20,0.45],[0.47,0.47],[0.69,0.52]]},
{"name": "Life line", "points": [[0.24,0.44],[0.30,0.62],[0.38,0.82]]}
]
}
要看清 width 和 height:它们对应模型实际分析的图片,不一定是用户刚上传的原文件。
归一化坐标记录的是位置比例,页面变宽或图片等比缩小后,比例仍然有效。但它不能自动处理裁剪和旋转。服务端分析一张裁剪图,前端却展示原图,坐标照样会错。
展示时,把 <img> 和 <svg> 放进同一个定位容器,容器使用 position: relative; display: inline-block;。两者都设置 display: block; width: 100%; height: auto;,SVG 再用 position: absolute; inset: 0; height: 100%; pointer-events: none; 覆盖图片。
SVG 设置 viewBox="0 0 1024 1365" 和 preserveAspectRatio="xMidYMid meet",就建立了与分析图片一致的内部坐标系。
图片和 SVG 的比例、显示区域一致时,浏览器就能接管缩放计算。
因此,这个布局不需要额外监听 resize。不过,若页面又用固定尺寸裁剪图片,上述前提就需要重新核对。
曲线画得顺,还要让人看得清
几个点直接连成折线,适合表达折线路径,却未必适合手掌纹路。作者使用 Catmull-Rom 样条,把采样点转换成三次贝塞尔曲线,让路径经过每个点。
下面这段代码同时完成坐标还原与路径绘制:
function smoothPath(pts) {
if (pts.length < 2) return "";
let d = `M ${pts[0][0]} ${pts[0][1]}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] || p2;
const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
d += ` C ${c1} ${c2} ${p2[0]} ${p2[1]}`;
}
return d;
}
function render(svg, result) {
const { width: w, height: h } = result;
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
svg.innerHTML = "";
for (const line of result.lines) {
const pts = line.points.map(([x, y]) => [x * w, y * h]);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", smoothPath(pts));
path.setAttribute("class", "trace");
svg.append(path);
}
}
关键是 line.points.map 那一行:横坐标乘宽度,纵坐标乘高度,把比例还原为 viewBox 内的坐标。
线条还得适应用户照片里不可预测的颜色。作者建议同一路径叠画两次:底层 .trace.halo 使用 rgba(0,0,0,.55)、6px 描边,上层 .trace.main 使用 #ffd166、2.5px 描边。基础 .trace 设置 fill: none、stroke-linecap: round 和 vector-effect: non-scaling-stroke,避免路径缩放时线宽跟着变化。
这里有一个实现缺口:上面的 render 每条曲线只创建一个 .trace 路径。要获得双层描边,仍需补上两条路径以及对应的 halo、main 类名,单独添加样式不会自动产生效果。
标签和手机方向,才是容易漏掉的部分
标签全放在曲线起点,很容易挤成一团。作者的规则是先选曲线上离其他曲线较远的位置,再检查标签是否相交:插入真实的 <text> 元素后调用 getBBox(),如果两个文字框重叠,就把后放入的标签向下移动,距离为重叠量加一点间隔。
作者认为,三个到五个标签用这轮贪心避让就够了。这里的「贪心」是按顺序解决眼前冲突,不回头寻找全局最佳布局。原文没有给出这部分代码,也没说明如何处理标签移出图片边界。
另一个坑是 EXIF 方向。浏览器通常会按默认的 image-orientation: from-image 显示手机照片,但服务端或模型未必做相同处理,曲线就可能偏转 90 度。
作者建议分析前统一方向:通过 createImageBitmap(file, { imageOrientation: "from-image" }) 获取图像,画入 canvas 后再上传。标题里的「no canvas」指展示层无需 canvas 重绘,上传预处理仍然用了它。
无障碍也不能省。作者要求在图片旁重复展示文字结果,给 SVG 添加 role="img" 和概括内容的 aria-label,标签保留为真实 <text>。
我的判断
这套方案最有价值的地方,是把坐标协议、浏览器缩放和视觉可读性一起考虑。对少量检测框、关键点或曲线,SVG 能直接利用 DOM 和文本元素,值得作为实现起点。
但作者展示的是来自自身业务的做法,没有提供设备测试矩阵、性能对照或标签避让成功率。「三个到五个标签够用」属于经验判断,不能直接推广到密集标注。
平滑也只是呈现选择。曲线经过采样点,不代表点之间的路径更接近真实目标;需要严格保留几何形状时,应先判断是否适合平滑。原文也没有证明 SVG 在大量标注下优于 canvas,选型仍要看实际规模和交互需求。
能拿来做什么
- 给图片识别接口约定归一化坐标、分析尺寸和方向处理方式,并确保前端展示的是同一份图像内容。
- 在前端封装图片与 SVG 共用容器的标注组件,先验证不同宽度下的位置,再加入平滑路径和双层描边。
- 对少量标签实现
getBBox()避让,同时补上边界检查;验收时覆盖深浅背景、手机旋转照片和窄屏展示。 - 用 AI 辅助编写组件时,把双路径描边、真实
<text>标签和旁侧文字结果写进验收要求,检查生成代码是否实际实现。