先看最终效果:
图片标注从 0 到 1:第一步,先把坐标系设计对
很多人第一次做图片标注,第一反应是:
“不就是在图片上画个框吗?”
结果真正开始写的时候,很快就会遇到一堆问题:
- 图片原始尺寸是
4032 x 3024,页面上只显示成了672 x 504,框到底该怎么算 - 浏览器窗口一缩放,之前画好的框为什么全偏了
- 标签拖动之后,为什么连线对不上
- 后端到底该存像素,还是存百分比
这些问题表面上是“框没画对”,本质上其实都是同一个问题:
坐标系没有先设计好。
这篇文章只讲图片标注的第一步:坐标系的建立。
不依赖某个具体项目,不绑定某个框架。只要准备自己实现一个图片标注功能,这套思路就能直接拿来用。
一、先别急着画框,先想清楚到底在描述什么
一个图片标注系统,真正要保存的不是“屏幕上的一个 div”,而是:
这张图片中的某个区域,以及与它关联的一段说明。
也就是说,标注系统描述的是“图上的语义位置”,不是“浏览器这一帧的显示结果”。
比如有一张原图,尺寸是 1200 x 800。用户框选了一个区域:
x = 300
y = 160
width = 240
height = 120
如果把这组值理解成“当前页面上的像素位置”,那它只对当前这个显示尺寸有效。
但如果把它理解成“它在原图里的位置”,事情就完全不一样了。
所以第一步的目标可以明确成一句话:
建立一套与显示尺寸解耦的标注坐标体系。
二、为什么直接存像素,几乎一定会踩坑
很多入门实现会这么写:
{
"left": 150,
"top": 80,
"width": 120,
"height": 60
}
这套数据乍一看很直观,但问题非常大。
1. 它依赖当前显示尺寸
如果这张图片当时被显示成 600 x 400,那么:
left = 150top = 80
是成立的。
但如果下一次页面把图片显示成 900 x 600,这组值就不再对应原来的那个区域了。
2. 它不利于跨端和响应式
桌面端、移动端、弹窗预览页、详情页,图片展示尺寸通常都不一样。如果存的是“某次渲染出来的像素值”,每个地方都要补一层额外适配。
3. 它不利于后端长期存储
后端真正需要保存的,应该是“这个标注在原图里的位置关系”,而不是“某个页面当时怎么显示的”。
所以,图片标注这类功能最稳妥的做法通常都是:
存比例,不存显示像素。
三、图片标注里最重要的三套坐标系
要把这个问题讲明白,最好的方式是先把坐标系统拆成三层。
1. 原始图片坐标系
第一套坐标系,来自图片本身。
假设原图尺寸是:
imageWidth = 1200
imageHeight = 800
我们约定:
- 左上角是原点
(0, 0) - 向右是
x正方向 - 向下是
y正方向
那么右下角就是:
(1200, 800)
这套坐标系非常适合描述“这个框在原图哪里”。
例如:
{
"x": 300,
"y": 160,
"width": 240,
"height": 120
}
它的含义是:
- 矩形左上角距离图片左边
300px - 矩形左上角距离图片顶部
160px - 矩形宽度
240px - 矩形高度
120px
这套坐标很适合做中间计算,但不适合直接保存给前端页面长期使用。
2. 比例坐标系
第二套坐标系,是实际最值得保存的坐标系。
做法很简单,把所有位置都转成相对于图片宽高的比例:
ratioX = x / imageWidth
ratioY = y / imageHeight
ratioWidth = width / imageWidth
ratioHeight = height / imageHeight
带入刚才那组数据:
ratioX = 300 / 1200 = 0.25
ratioY = 160 / 800 = 0.20
ratioWidth = 240 / 1200 = 0.20
ratioHeight = 120 / 800 = 0.15
最终得到:
{
"x": 0.25,
"y": 0.20,
"width": 0.20,
"height": 0.15
}
这时候数据表达的就不是“某次显示下的像素位置”,而是:
- 左边缘在图片宽度的
25% - 上边缘在图片高度的
20% - 宽度占图片宽度的
20% - 高度占图片高度的
15%
这就是为什么比例坐标适合存储。它和当前页面显示尺寸彻底解耦了。
3. 画布渲染坐标系
第三套坐标系,是浏览器真正拿来渲染的坐标系。
举个例子,原图虽然是 1200 x 800,但页面里可能只显示成:
canvasWidth = 600
canvasHeight = 400
这时候,我们就把比例坐标再换回渲染像素:
renderLeft = ratioX * canvasWidth
renderTop = ratioY * canvasHeight
renderWidth = ratioWidth * canvasWidth
renderHeight = ratioHeight * canvasHeight
代入数据就是:
renderLeft = 0.25 * 600 = 150
renderTop = 0.20 * 400 = 80
renderWidth = 0.20 * 600 = 120
renderHeight = 0.15 * 400 = 60
于是页面上最终画出来的框是:
{
"left": 150,
"top": 80,
"width": 120,
"height": 60
}
这三层可以总结成一句话:
- 原始图片坐标:描述它在原图哪里
- 比例坐标:描述它应该怎么保存
- 渲染坐标:描述它当前该怎么画
四、建立统一坐标系时,推荐的基础规则
如果一开始就准备自己写一个标注系统,我建议先把下面几条规则定死。
规则 1:统一左上角为原点
这样和浏览器绝对定位、Canvas、SVG 的默认思维是一致的,后面实现最省心。
约定如下:
- 左上角
(0, 0) - 向右
x递增 - 向下
y递增
规则 2:矩形统一用 x + y + width + height
不要一开始就搞四个顶点、中心点、旋转点那一套。
对绝大多数“图片框选标注”场景来说,下面这种结构已经足够:
{
"region": {
"x": 0.25,
"y": 0.20,
"width": 0.20,
"height": 0.15
}
}
好处很直接:
- 便于拖拽
- 便于缩放
- 便于碰撞边界计算
- 便于转成 DOM 样式
规则 3:标签位置也走同一套比例坐标
如果标注系统除了框,还有文字标签,那标签也应该统一放进这套坐标系里。
比如:
{
"label": {
"x": 0.52,
"y": 0.16,
"text": "这是标签说明"
}
}
这里的 label.x、label.y 建议表示标签左上角相对图片的位置。
这样做的好处是:
- 图片缩放后标签不会乱飞
- 标签拖拽时逻辑和框的逻辑一致
- 后续画连接线时也方便统一换算
规则 4:视图缩放和标注数据解耦
用户放大图片,不代表标注区域变大了;用户旋转画面,也不代表原始标注数据旋转了。
所以一开始就要分清:
- 标注数据:描述图上的语义位置
- 视图变换:描述当前用户怎么看这张图
这两者不要混在一起。
五、把它写成代码,到底长什么样
讲原理很容易,真正落地时,最关键的是把“存储格式”和“转换函数”定下来。
下面给一套通用伪代码。
1. 推荐的标注数据结构
type Annotation = {
id: string
region: {
x: number // 0~1
y: number // 0~1
width: number // 0~1
height: number // 0~1
}
label: {
x: number // 0~1
y: number // 0~1
text: string
}
}
这里的关键点只有一个:
所有坐标都相对图片宽高归一化到 0~1。
2. 原始像素转比例坐标
function toRatioRegion(regionPx, imageWidth, imageHeight) {
return {
x: regionPx.x / imageWidth,
y: regionPx.y / imageHeight,
width: regionPx.width / imageWidth,
height: regionPx.height / imageHeight
}
}
如果担心越界,可以加上裁剪:
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max)
}
function toSafeRatioRegion(regionPx, imageWidth, imageHeight) {
const x = clamp(regionPx.x / imageWidth, 0, 1)
const y = clamp(regionPx.y / imageHeight, 0, 1)
const width = clamp(regionPx.width / imageWidth, 0, 1)
const height = clamp(regionPx.height / imageHeight, 0, 1)
return { x, y, width, height }
}
3. 比例坐标转渲染像素
function toRenderRegion(regionRatio, canvasWidth, canvasHeight) {
return {
left: regionRatio.x * canvasWidth,
top: regionRatio.y * canvasHeight,
width: regionRatio.width * canvasWidth,
height: regionRatio.height * canvasHeight
}
}
如果用的是普通 DOM 绝对定位,甚至可以直接转成样式:
function toRegionStyle(regionRatio) {
return {
left: `${regionRatio.x * 100}%`,
top: `${regionRatio.y * 100}%`,
width: `${regionRatio.width * 100}%`,
height: `${regionRatio.height * 100}%`
}
}
这也是很多前端标注实现里最顺手的一种做法。
六、完整链路应该长这样
从用户操作到最终存储,一条完整链路大概是下面这样:
用户框选得到像素区域
-> 按原图宽高归一化
-> 存成比例坐标
-> 页面渲染时根据当前画布宽高再换算
-> 生成 DOM / Canvas / SVG 上的真实位置
如果写成伪代码,就是:
// 第一步:用户在当前图片上框出一个像素区域
const regionPx = {
x: 300,
y: 160,
width: 240,
height: 120
}
// 第二步:转换成适合存储的比例坐标
const regionRatio = toRatioRegion(regionPx, 1200, 800)
// 第三步:保存到后端或本地
saveAnnotation({
id: "anno-1",
region: regionRatio,
label: {
x: 0.5,
y: 0.15,
text: "示例标签"
}
})
// 第四步:页面需要显示时,根据当前画布尺寸换算成渲染像素
const renderRegion = toRenderRegion(regionRatio, 600, 400)
// 第五步:把 renderRegion 渲染成一个矩形框
renderBox(renderRegion)
这套链路看似普通,但其实已经回答了图片标注里最核心的问题:
同一个标注,为什么在不同显示尺寸下还能稳定对齐。
七、这里最容易写错的 5 个点
1. x 和 width 都要除以图片宽度
很多人第一次写时会下意识搞混:
x应该除以imageWidthwidth也应该除以imageWidth
同理:
y应该除以imageHeightheight也应该除以imageHeight
不要把四个值都除以同一个数。
2. 归一化基准应该是图片,不是容器
比例坐标应该基于图片本身的尺寸,而不是外层容器尺寸。
否则一旦图片在容器中留白、居中或 contain 显示,计算就会错位。
3. 存储层和渲染层不要混着用
存储层里的:
{ x: 0.25, y: 0.20, width: 0.20, height: 0.15 }
和渲染层里的:
{ left: 150, top: 80, width: 120, height: 60 }
最好不要在同一个对象里来回改。职责一混,后面 debug 会非常痛苦。
4. 标签也要统一坐标体系
如果框存比例、标签存像素,前期看似能跑,后面一加缩放和拖动,问题会非常多。
建议从第一天开始就统一。
5. 精度不要太低
比例值通常会是小数,比如:
0.2537
0.1462
如果只保留两位,有些图上误差会很明显。通常保留 3~4 位小数会更稳妥。
八、第一步做完,其实已经拿下了最难的一半
很多人觉得图片标注最难的是拖拽、缩放、吸附、连线。
但实际做过就会发现,真正决定系统能不能长期稳定工作的,往往不是交互,而是最底层的数据表达方式。
只要坐标系设计对了,后面的事情都会顺很多:
- 画框,本质上是比例转像素
- 拖拽,本质上是像素再转回比例
- 标签移动,本质上还是同一套坐标换算
- 连线绘制,本质上是先把比例点换成画布像素点
所以“建立统一坐标系”这一步,不是前置准备,而是图片标注系统真正的地基。
九、这一篇先停在这里,下一篇该写什么
如果这一篇解决的是:
标注数据应该怎么定义
那么下一篇最自然的主题就是:
比例坐标确定之后,如何把它渲染成真正可交互的标注层。
也就是继续往下讲:
- 如何把比例坐标渲染成矩形框
- 如何渲染标签
- 如何计算连接线
- 如何在拖拽后把像素重新转回比例
如果准备写一个完整的图片标注系列,到这里第一篇的核心结论可以先收束成一句话:
先定义图片坐标,再定义比例坐标,最后再考虑怎么渲染。
图片标注从 0 到 1:第一步,建立统一坐标系
如果要从零开始实现一个图片标注功能,第一件事通常不是“怎么画框”,而是“怎么定义坐标”。
因为只要坐标系没想清楚,后面几乎所有问题都会变得混乱:
- 图片原始尺寸和页面显示尺寸不一致怎么办
- 浏览器窗口变化后,标注为什么会偏移
- 图片缩放后,矩形框和标签为什么对不上
- 保存到后端时,到底应该存像素还是存比例
所以在真正进入“框选、拖拽、连线、编辑”之前,必须先完成一个更底层的工作:建立统一坐标系。
本文基于当前项目里的图片标注实现,专门讲第一步:如何建立一套既适合渲染、又适合存储、还能适应缩放的坐标系统。
相关实现文件:
- 详情页:
src/views/image-annotation/detail.vue - 标注画布:
src/views/image-annotation/AnnotationCanvas.vue
一、为什么图片标注一定要先解决坐标问题
图片标注的本质,不是“在页面上放一个框”,而是:
在一张图片上,稳定地描述一个区域和一个标签位置。
这里的“稳定”很重要。因为同一张图片,在不同场景下可能有不同的显示尺寸:
- 原图是
1200 x 800 - 列表页缩略图可能只显示成
240 x 160 - 详情页可能显示成
900 x 600 - 用户缩放后又可能临时显示成
1350 x 900
如果直接保存的是“当前页面上的像素值”,那这些标注就只对某一个显示尺寸成立。一旦图片显示大小变了,框就会偏掉。
因此,一个可维护的标注系统必须满足两件事:
- 存储层不依赖当前页面尺寸
- 渲染层可以根据当前显示尺寸实时换算
这就是我们引入“比例坐标”的原因。
二、先定义三套坐标系
要把问题讲清楚,最好先把系统里的三套坐标系分开。
1. 原始图片坐标系
这是最直观的一套坐标系,基于图片本身的原始像素尺寸。
假设一张图片的真实尺寸是:
- 宽:
1200 - 高:
800
那么它的坐标原点在左上角:
- 左上角:
(0, 0) - 右下角:
(1200, 800)
如果一个标注框位于图片中的某个区域,它的原始像素坐标可能是:
{
"x": 300,
"y": 160,
"width": 240,
"height": 120
}
这组数据的含义是:
- 左上角横坐标是
300 - 左上角纵坐标是
160 - 矩形框宽度是
240 - 矩形框高度是
120
这种表示方式很好理解,但它有一个明显问题:
它强依赖于“原图尺寸”和“当前显示尺寸”的对应关系。
也就是说,原始像素坐标适合做中间计算,但不适合作为前端最终保存格式。
2. 比例坐标系
比例坐标系是当前项目里真正用于存储的坐标系。
它的核心思想是:
- 不存绝对像素
- 只存该位置占图片宽高的比例
还是刚才那张 1200 x 800 的图片,如果标注框数据是:
{
"x": 300,
"y": 160,
"width": 240,
"height": 120
}
那么转换成比例坐标后就是:
{
"x": 0.25,
"y": 0.20,
"width": 0.20,
"height": 0.15
}
计算过程如下:
x = 300 / 1200 = 0.25
y = 160 / 800 = 0.20
width = 240 / 1200 = 0.20
height = 120 / 800 = 0.15
这组比例值的含义是:
x = 0.25:框的左边缘位于图片宽度的25%y = 0.20:框的上边缘位于图片高度的20%width = 0.20:框宽占图片宽度的20%height = 0.15:框高占图片高度的15%
这时候,坐标和当前页面显示尺寸就解耦了。
无论图片被显示成:
1200 x 800600 x 400900 x 600
只要渲染时再乘上当前显示宽高,框都会落在正确位置。
3. 画布渲染坐标系
这是前端真正拿来“画出来”的坐标系。
在项目实现中,图片最终会被放进一个固定大小或受容器约束的显示区域中。这个显示区域就是当前画布。
比如原图虽然是 1200 x 800,但在页面中可能被渲染成:
600 x 400
这时,同样那组比例坐标:
{
"x": 0.25,
"y": 0.20,
"width": 0.20,
"height": 0.15
}
在画布上的实际像素结果就是:
left = 0.25 * 600 = 150px
top = 0.20 * 400 = 80px
width = 0.20 * 600 = 120px
height = 0.15 * 400 = 60px
换句话说:
- 原始图片坐标系解决“这个框在原图哪里”
- 比例坐标系解决“这个框怎么被稳定保存”
- 画布渲染坐标系解决“这个框现在该画到页面哪里”
三者职责不同,但彼此可以转换。
三、统一坐标系的核心规则
在当前这套实现里,坐标系统可以总结为 4 条规则:
规则 1:原点固定在左上角
所有位置计算都以图片左上角为原点:
- 向右,
x增大 - 向下,
y增大
这和浏览器里绝对定位元素的思路完全一致,所以前端实现非常自然。
规则 2:矩形框使用左上角 + 宽高表示
每个矩形区域都用以下字段表示:
{
"region": {
"x": 0.25,
"y": 0.20,
"width": 0.20,
"height": 0.15
}
}
这比存四个顶点更简单,也更适合拖拽和缩放。
规则 3:标签位置也使用比例坐标
除了矩形框,标签本身也需要位置。
在项目中,标签使用的是独立的比例坐标:
{
"label": {
"x": 0.50,
"y": 0.15,
"text": "这里是一个标签"
}
}
这里的 label.x 和 label.y 表示:
- 标签左上角在图片中的相对位置
注意,标签不是绑定死在矩形框边上的,而是一个可以单独移动的位置点。这样后面实现“拖动标签,连线跟随变化”会更自然。
规则 4:存比例,渲染时再换算成像素
这是最关键的一条:
- 保存时存比例
- 显示时乘当前画布宽高
也就是说,真正持久化的数据是:
{
"id": "anno-1",
"region": {
"x": 0.25,
"y": 0.20,
"width": 0.20,
"height": 0.15
},
"label": {
"x": 0.50,
"y": 0.15,
"text": "示例标签"
},
"meta": {
"isNew": false
}
}
而不是:
{
"left": 150,
"top": 80,
"width": 120,
"height": 60
}
后者只对某一个固定显示尺寸成立,前者才适合真正保存。
四、从原始像素到比例坐标,再到固定画布的完整转换
为了让这个过程更直观,我们用一组完整数字走一遍。
1. 已知原图尺寸
原图宽高:1200 x 800
2. 用户在原图上框出一个区域
x = 300
y = 160
width = 240
height = 120
3. 转换成比例坐标
region.x = 300 / 1200 = 0.25
region.y = 160 / 800 = 0.20
region.width = 240 / 1200 = 0.20
region.height = 120 / 800 = 0.15
4. 当前页面把图片显示成固定宽高
画布宽高:600 x 400
5. 渲染时把比例再转回像素
left = 0.25 * 600 = 150px
top = 0.20 * 400 = 80px
width = 0.20 * 600 = 120px
height = 0.15 * 400 = 60px
也就是说:
- 数据层存的是
0.25 / 0.20 / 0.20 / 0.15 - 视图层看到的是
150 / 80 / 120 / 60
这就是图片标注里最核心的一次坐标解耦。
五、在当前项目里,这套坐标系是怎么落地的
在 src/views/image-annotation/AnnotationCanvas.vue 中,矩形框和标签渲染都依赖比例坐标。
例如矩形框渲染时:
const getRegionStyle = (annotation) => ({
left: `${(annotation.region.x || 0) * 100}%`,
top: `${(annotation.region.y || 0) * 100}%`,
width: `${(annotation.region.width || 0) * 100}%`,
height: `${(annotation.region.height || 0) * 100}%`
})
这段代码说明:
- 存储层里
region.x = 0.25 - 渲染层就把它变成
left: 25%
也就是说,DOM 布局层本身就是建立在比例坐标上的。
而在需要做像素级计算时,比如生成连线、拖拽标签、调整框大小时,会再结合当前画布尺寸做换算:
const rectLeft = region.x * canvasSize.width
const rectTop = region.y * canvasSize.height
const rectWidth = region.width * canvasSize.width
const rectHeight = region.height * canvasSize.height
这说明项目里的真正策略是:
- 对外存比例
- 对内渲染时临时换算像素
这是一种非常适合前端标注场景的实现方式。
六、为什么不用“直接存像素”
很多人在第一次做标注功能时,最容易走的一条路是:
- 用户框出来多少像素,就直接存多少像素
这么做短期看起来简单,但后面会遇到很多问题:
1. 页面缩放后会错位
如果存的是 left: 150px,那它只对某一个固定显示宽高成立。
一旦图片被缩成更小尺寸,150px 这个位置就不再是原来那块区域了。
2. 不同设备上难以统一
同一张图片在大屏、笔记本、小窗口下显示尺寸都可能不同。直接存像素会导致前端必须知道“当时是以什么尺寸显示出来的”,实现会很麻烦。
3. 后端存储不稳定
如果后端拿到的是某次页面渲染时的像素值,这些数据本身就缺乏可迁移性。换个显示策略,数据就失真了。
而比例坐标恰好绕开了这些问题。
七、建立坐标系时最容易忽略的几个细节
1. 宽度和高度要分别归一化
不要把所有值都除以图片宽度,也不要只用一个统一比例。
正确做法是:
x / width用图片宽度归一化y / height用图片高度归一化region.width / width用图片宽度归一化region.height / height用图片高度归一化
2. 标签坐标也应该纳入同一套坐标系统
有些实现只把矩形框做成比例坐标,标签仍然存页面像素。这样会导致图片缩放后,框和标签脱节。
当前项目中,label.x 和 label.y 同样采用比例坐标,这样连接线才能稳定。
3. 存储精度要适中
比例坐标通常是小数,比如:
0.2537
0.1462
精度太低会导致位置误差,精度太高又没有必要。当前实现里做了适度保留小数位,这样既能保证位置稳定,也能避免数据冗长。
4. 视图缩放不应该直接修改标注数据
图片放大、缩小、旋转,本质上是“看图方式变化”,不是标注区域变化。
所以:
- 缩放和平移应该作用在视图层
- 标注数据本身仍然保持相对坐标不变
这也是后面实现“视图变换”和“标注数据”解耦的基础。
八、第一步完成后,已经具备了什么
当统一坐标系建立完成后,后续标注功能就有了稳定地基。
到这里,其实已经具备了:
- 一套可持久化的数据格式
- 一套可适配任意显示尺寸的坐标规则
- 一条从原始像素到比例坐标、再到渲染像素的转换链路
- 一种可支撑拖拽、连线、缩放、保存的底层表示方式
可以说,图片标注功能真正的第一步,不是画框,而是先回答这句话:
“同一个标注,在不同显示尺寸下,如何始终表示为同一个区域?”
当前项目给出的答案就是:
用比例坐标存储,用当前画布尺寸渲染。
九、第二步:把比例坐标真正渲染成“可交互画面”
如果说第一步解决的是:
“标注数据该怎么定义,才能不被显示尺寸绑死?”
那么第二步要解决的就是:
“这套比例坐标,怎么稳定地长成用户眼前能看、能点、能拖、能连线的标注界面?”
很多人做图片标注,卡住的地方并不是“不会画框”,而是下面这些更具体的问题:
- 框到底应该画在
canvas上,还是普通 DOM 上 - 标签为什么最好不要和框绑死成一个整体
- 连接线为什么经常画着画着就偏了
- 图片缩放后,框、标签、线为什么还能对得上
这一部分,本质上是在完成一件事:
把抽象的比例坐标,翻译成当前这一次渲染所需的像素布局。
十、先别急着写交互,先把“画面分层”想清楚
一个稳定的图片标注界面,通常至少要拆成 4 层:
- 图片层:负责展示原图
- 矩形框层:负责框选区域
- 标签层:负责文字和交互热点
- 连线层:负责把框和标签连接起来
这里我会把它画成这样:
flowchart TB
image[图片层 img]
rect[矩形框层 absolute DOM]
label[标签层 absolute DOM]
line[连线层 SVG]
image --> rect
rect --> label
label --> line
如果换成更接近页面结构的理解,大概是:
+--------------------------------------+
| annotation viewport |
| |
| +-------------------------------+ |
| | image | |
| | +--------+ | |
| | | region |------\ | |
| | +--------+ \ | |
| | [label] | |
| +-------------------------------+ |
| (svg line 覆盖其上) |
+--------------------------------------+
这一步为什么很重要?
因为图片标注不是“一个图形”,而是多种元素协同组成的交互系统。
如果一开始就把所有东西都塞进同一个绘图上下文里,后面很快会遇到这些麻烦:
- 标签里的文本样式不好控制
- hover、选中、点击命中区域很难精细处理
- 标签单独拖动时,框和线的更新逻辑会拧在一起
- 不同元素的层级关系很难维护
所以更稳的方案通常不是“用一个工具画完所有东西”,而是:
谁擅长做什么,就把什么交给最合适的层来做。
十一、为什么矩形框更适合用绝对定位 DOM
很多人第一反应会问:
“既然是在图片上画框,那为什么不直接全部用 Canvas?”
答案是:能画,不代表好维护。
矩形框这类元素,用绝对定位 DOM 往往更合适,原因主要有 4 个。
1. 它天然就是一个矩形布局问题
矩形框本质上只需要 4 个值:
lefttopwidthheight
这和 DOM 的绝对定位模型完全一致。
只要拿到了当前画布尺寸,就可以直接得到:
left = region.x * viewportWidth
top = region.y * viewportHeight
width = region.width * viewportWidth
height = region.height * viewportHeight
2. 选中态、hover 态、删除态更容易做
一个框常见的视觉状态有:
- 默认态
- hover 态
- 激活态
- 新建态
- 禁用态
DOM 在处理边框、透明背景、阴影、高亮、鼠标样式时都很顺手。
如果换成纯 Canvas,这些状态通常就意味着要自己维护重绘逻辑、命中检测和状态切换。
3. 后续要加拖拽锚点时更自然
哪怕当前版本只需要“显示框”,大概率后面也会遇到这些需求:
- 选中某个框
- 拖动框整体位置
- 拉伸 8 个方向锚点
- 显示辅助线
这些场景里,DOM 都更容易渐进式演进。
4. 它和业务组件体系更兼容
很多标注系统不是纯图形应用,而是业务页面的一部分。
这意味着往往还要处理:
- 权限态
- 评论态
- 审核态
- 错误提示
- tooltip / popover
这类内容和 DOM 生态天然更匹配。
所以在大多数偏业务的图片标注场景里,一个务实的选择是:
图片用 img,矩形框用绝对定位 DOM。
伪代码可以写成这样:
function renderRegionBox(region, viewportSize) {
const left = region.x * viewportSize.width
const top = region.y * viewportSize.height
const width = region.width * viewportSize.width
const height = region.height * viewportSize.height
return {
style: {
position: "absolute",
left,
top,
width,
height
}
}
}
注意这里最关键的一点:
DOM 只负责显示像素结果,不负责保存数据本体。
数据里仍然保存比例坐标,DOM 只是它在当前画布里的“一次投影”。
十二、这里不再重讲第一步,而是把它抽成“转换层”
写到这里,第一步里“为什么要用比例坐标”这件事其实已经讲清楚了。
所以进入渲染阶段后,我们不再重复推导这件事,而是直接把它收敛成一个工程上的约定:
数据层永远只存比例坐标,渲染层永远只消费像素结果。
中间这层负责翻译的,就是转换层。
这里我更倾向于把它理解成这样:
flowchart LR
data[标注数据 ratio] --> transformer[转换层]
transformer --> view[视图像素布局]
转换层本身不关心用的是什么框架,也不关心最后是 DOM、SVG 还是 Canvas。
它只做两类事情:
- 把区域坐标换成当前视口里的矩形像素
- 把标签点位换成当前视口里的位置像素
伪代码保持成两个纯函数就够了:
function regionToPixels(region, viewportSize) {
return {
left: region.x * viewportSize.width,
top: region.y * viewportSize.height,
width: region.width * viewportSize.width,
height: region.height * viewportSize.height
}
}
function pointToPixels(point, viewportSize) {
return {
x: point.x * viewportSize.width,
y: point.y * viewportSize.height
}
}
这样写的价值,不是公式本身有多复杂,而是它把职责切得非常干净:
- 数据层不碰页面像素
- 视图层不碰存储格式
- 所有缩放、重排、重算都只经过这一层
所以第二步真正关心的,不再是“比例坐标为什么成立”,而是:
有了这层转换器之后,框、标签、连线该如何协同渲染。
十三、为什么标签最好不要和矩形框绑成一个整体
很多初版实现,会把标签直接钉在框的右上角,比如:
+-----------+[标签]
| |
| region |
| |
+-----------+
这种做法在 demo 阶段没问题,但一旦进入真实交互,很快就会暴露局限。
1. 标签和框承担的职责根本不同
矩形框描述的是:
“图片中的哪块区域被标了出来。”
标签描述的是:
“这块区域应该如何被阅读和解释。”
一个负责空间定位,一个负责信息承载,它们天然就是两个角色。
2. 标签经常需要避让,不适合固定死
现实场景里很容易出现:
- 两个框很近,标签互相遮挡
- 框靠近图片边缘,标签放不下
- 标签文字较长,默认位置会压住内容
如果标签位置不能单独调整,用户体验会非常差。
所以更合理的做法是:
矩形框和标签分别存位置,连线只负责建立视觉关联。
示意图如下:
+-----------+
| region |
+-----------+
\
\
+-----------+
| label text|
+-----------+
3. 这会让交互模型清晰很多
拆开之后,系统的逻辑就变成了:
- 拖框:改变
region - 拖标签:改变
label - 重绘连线:根据二者当前像素位置重新计算
职责非常明确。
伪代码可以这样表达:
annotation = {
region: { x, y, width, height },
label: { x, y, text }
}
这比“把标签当成框上的一个偏移量”更稳定。
因为偏移量方案通常会遇到一个问题:
一旦框变化了,标签到底是跟着框走,还是保持视觉位置不变?
这个问题经常会把交互逻辑绕复杂。
而把标签当成独立坐标点,就简单很多。
十四、连接线为什么更适合用 SVG
当矩形框和标签分开以后,连接线就成了第三个问题:
它既不是纯文本,也不是简单矩形,而是一条动态变化的路径。
这时 SVG 的优势就出来了。
1. 它很适合描述“从 A 到 B 的线”
不管是直线、折线,还是稍微带一点弧度的路径,SVG 都能用很轻量的方式表达。
最基础的直线形式就是:
M x1 y1 L x2 y2
意思就是:
- 从
(x1, y1)出发 - 连到
(x2, y2)
如果希望线条更柔和一点,可以换成曲线或折线。
2. 它天然覆盖在页面上层
这里完全可以让一个 svg 铺满整个标注视口:
svg width = viewportWidth
svg height = viewportHeight
然后每条标注生成一条 path。
这样所有线都共享同一个坐标空间,不需要每条线各自维护独立容器。
3. 它比 Canvas 更适合“少量但需要实时更新的路径”
业务型图片标注里,线通常不会多到成千上万条。
这时 SVG 的优点是:
- 容易定位和调试
- 容易绑定样式
- 容易和 DOM 图层协同
典型结构可以理解成:
flowchart LR
boxCenter[框锚点]
labelEdge[标签锚点]
path[SVG path]
boxCenter --> path
labelEdge --> path
伪代码如下:
function buildConnectorPath(fromPoint, toPoint) {
return `M ${fromPoint.x} ${fromPoint.y} L ${toPoint.x} ${toPoint.y}`
}
如果还想做得更好看一点,也可以生成折线:
function buildPolylinePath(fromPoint, toPoint) {
const midX = (fromPoint.x + toPoint.x) / 2
return [
`M ${fromPoint.x} ${fromPoint.y}`,
`L ${midX} ${fromPoint.y}`,
`L ${midX} ${toPoint.y}`,
`L ${toPoint.x} ${toPoint.y}`
].join(" ")
}
这里最重要的不是“线长什么样”,而是:
线的起点和终点,必须来自同一个像素坐标空间。
只要框、标签、连线都基于同一块 viewport 换算,线就不会漂。
十五、真正决定连线稳不稳的,不是 SVG,而是锚点设计
很多人以为连线偏了,是 SVG 有问题。
其实大多数时候,真正有问题的是:
框到底从哪个点出发,又连到标签的哪个点。
常见做法是:
- 框锚点取中心点,或者最接近标签的一条边中点
- 标签锚点取左侧中点、右侧中点,或中心点
示意图:
region
+-----------+
| *-----+----------------*
| | |
+-----------+ label
其中:
- 左边
*是框的连接锚点 - 右边
*是标签的连接锚点
如果只图省事,直接拿框左上角和标签左上角连线,视觉上通常会很怪。
更自然的做法是先求锚点。
比如:
function getRegionAnchor(regionPixels) {
return {
x: regionPixels.left + regionPixels.width / 2,
y: regionPixels.top + regionPixels.height / 2
}
}
function getLabelAnchor(labelPixels, labelSize) {
return {
x: labelPixels.x,
y: labelPixels.y + labelSize.height / 2
}
}
如果还想更进一步,可以做“智能边选择”:
- 标签在框右边,就从框右边中点出发
- 标签在框左边,就从框左边中点出发
- 标签在框上方,就从框上边中点出发
- 标签在框下方,就从框下边中点出发
这样连线会明显自然很多。
十六、缩放之后为什么框、标签、线还能一起对齐
这其实是第一步坐标系设计带来的直接收益。
因为存的不是像素,而是比例,所以无论图片被显示成:
800 x 500640 x 4001000 x 625
本质上都只是同一套公式的重新计算:
pixelX = ratioX * viewportWidth
pixelY = ratioY * viewportHeight
只要 viewport 变了,整体就重算一遍:
- 框重算
- 标签重算
- 连线锚点重算
整个画面就会同步更新。
这条链路可以理解成:
flowchart LR
data[比例坐标数据] --> size[当前图片显示尺寸]
size --> box[框像素位置]
size --> label[标签像素位置]
box --> line[连线锚点]
label --> line
这里的核心思想非常重要:
不是“缩放时把旧像素跟着拉伸”,而是“缩放后重新从比例坐标计算一遍像素结果”。
这是“看起来一直对齐”和“越缩放越漂”的分水岭。
十七、交互阶段应该怎么更新数据
当画面渲染出来以后,真正的交互就开始了。
这时通常会有两类典型动作:
1. 拖动矩形框
用户拖的是像素,但最终要改回比例。
也就是说,更新流程应该是:
拖动得到新像素位置
-> 用当前 viewport 反算比例
-> 更新 annotation.region
-> 重新渲染框、标签、连线
伪代码如下:
function pixelsToRegion(nextPixels, viewportSize) {
return {
x: nextPixels.left / viewportSize.width,
y: nextPixels.top / viewportSize.height,
width: nextPixels.width / viewportSize.width,
height: nextPixels.height / viewportSize.height
}
}
2. 拖动标签
同样的逻辑,只不过这次更新的是标签点位:
function pixelsToPoint(nextPixels, viewportSize) {
return {
x: nextPixels.x / viewportSize.width,
y: nextPixels.y / viewportSize.height
}
}
所以整套交互可以总结成一句话:
渲染时:比例转像素。
交互后:像素再转回比例。
这就是一个完整的双向映射闭环。
十八、一个不强耦合具体项目的通用实现骨架
如果把上面这套思路真正落地,整个标注视图可以抽象成 3 层能力:
1. 数据层
只关心标注长什么样:
annotation = {
id: "anno-1",
region: { x: 0.2, y: 0.18, width: 0.22, height: 0.16 },
label: { x: 0.55, y: 0.14, text: "这是标签" }
}
2. 转换层
只关心两件事:
- 比例怎么变像素
- 像素怎么变比例
layout = measure(annotation, viewportSize)
nextAnnotation = updateFromInteraction(draftPixels, viewportSize)
3. 渲染层
只负责把 layout 画出来:
- 用 DOM 画框
- 用 DOM 画标签
- 用 SVG 画线
把它们串起来,大概就是下面这样:
function renderAnnotation(annotation, viewportSize) {
const regionPixels = regionToPixels(annotation.region, viewportSize)
const labelPixels = pointToPixels(annotation.label, viewportSize)
const fromPoint = getRegionAnchor(regionPixels)
const toPoint = getLabelAnchor(labelPixels, measureLabelSize())
const path = buildConnectorPath(fromPoint, toPoint)
return {
box: regionPixels,
label: labelPixels,
connector: path
}
}
这个结构的好处是:
- 换框架也好,数据和算法都不变
- 换 UI 实现也好,坐标模型都不变
- 甚至把 DOM 渲染换成 Canvas 渲染,转换层仍然能复用
也就是说,真正值得保护的,不是某个组件,而是:
“比例坐标 <-> 当前视口像素”的这层转换模型。
十九、到这里,图片标注才真正进入“画面呈现”
如果说第一步是先解决“数据怎么存”,那么这一部分解决的就是“数据怎么长成界面”。
会发现,一个看起来很简单的图片标注功能,背后其实至少包含了 4 个核心判断:
- 数据层用比例坐标,而不是显示像素
- 矩形框更适合用绝对定位 DOM
- 标签最好独立成可单独移动的节点
- 连线更适合用 SVG,并且依赖统一锚点计算
当这 4 件事理顺之后,整个系统的很多能力都会自然长出来:
- 缩放时仍然对齐
- 拖标签时连线实时更新
- 切换页面尺寸时位置稳定
- 保存时数据可直接持久化
这时候,图片标注功能才算真正从:
“我有一份坐标数据”
走到:
“我能把它变成一套稳定的交互界面”
二十、接下来再往下写,最适合讲什么
如果这篇继续往下展开,下一段最值得深挖的,其实不是“再多画几个框”,而是下面 3 个更实战的问题:
- 新建标注时,如何从鼠标拖拽实时生成比例坐标
- 如何限制框始终在图片内部,而标签允许拖到图片外部
- 如何处理缩放、滚动、容器 resize 后的实时重算
因为从这一步开始,图片标注就不再只是“渲染问题”,而会正式进入:
交互状态管理、边界约束、以及视图重计算。
这也是一个标注系统从“能显示”走向“能用起来”的真正分界线。
二十一、第三步:用户一拖,标注为什么就“长出来了”
到了这一步,问题已经不再是“框怎么显示”,而是:
用户在图片上按下鼠标、拖动、松开,这一串动作,怎么变成一条可保存的标注数据?
很多人第一次做这里,会下意识把逻辑写成:
mousedown 记起点
mousemove 改 div 宽高
mouseup 直接保存
这样当然能跑起来,但很快就会遇到几个典型问题:
- 往左上角反向拖拽时,框会出现负宽高
- 鼠标拖到图片外面,框会飞出去
- 页面缩放之后,保存的数据不稳定
- 拖拽过程中预览框和最终保存结果对不上
所以更稳的思路不是“直接改一个框”,而是先把整个创建流程拆成 3 个阶段:
- 记录起点
- 生成草稿框
- 把草稿框转换成可持久化标注
可以先看这条链路:
flowchart LR
down[mousedown 起点] --> move[mousemove 草稿框]
move --> up[mouseup 最终框]
up --> ratio[转成比例坐标]
ratio --> save[进入标注数据]
这 5 步如果顺了,后面的交互就不会乱。
二十二、先别管保存,先把“拖拽草稿框”算对
1. 拖拽的本质,是两个点决定一个矩形
用户拖拽时,真正拿到的其实不是一个矩形,而是两个点:
- 按下时的起点
start - 当前移动到的位置
current
例如:
start = (120, 80)
current = (320, 220)
这时矩形当然很好算。
但如果用户反着拖:
start = (320, 220)
current = (120, 80)
这时就不能再简单用:
width = current.x - start.x
height = current.y - start.y
因为这样会得到负数。
真正稳妥的写法,应该是先归一化成一个标准矩形:
function rectFromDrag(startPoint, currentPoint) {
const left = Math.min(startPoint.x, currentPoint.x)
const top = Math.min(startPoint.y, currentPoint.y)
const right = Math.max(startPoint.x, currentPoint.x)
const bottom = Math.max(startPoint.y, currentPoint.y)
return {
left,
top,
width: right - left,
height: bottom - top
}
}
这个函数很关键。
因为它解决的不是“怎么画框”,而是:
无论用户朝哪个方向拖,系统最后拿到的永远都是一个合法矩形。
2. 草稿框本身应该是像素态,不要急着转比例
很多人会在 mousemove 的每一帧里,先转比例,再转回像素显示。
这不是不行,但没有必要。
更自然的做法是:
- 拖拽中的草稿框,先保留像素态
- 用户松手后,再统一转成比例坐标
原因很简单:
拖拽过程发生在当前视口里,预览框本来就是一个“当前像素结果”。
示意图如下:
鼠标按下(start) *-------------------*
\ /
\ draft rect /
\ /
鼠标当前位置(curr) *-----------*
所以拖拽中的状态可以设计成:
draft = {
start: { x, y },
current: { x, y },
rect: { left, top, width, height }
}
一旦 mouseup,再把 draft.rect 转成最终 annotation.region。
二十三、为什么“框必须限制在图内”,而“标签可以拖到图外”
这两个约束看起来像一回事,其实完全不是。
1. 框描述的是图片区域,所以它必须被图片边界约束
一个标注框表达的是:
“图像中这一块内容被选中了。”
那它天然就不能跑到图片外面去。
否则保存出来的数据会变成一种很奇怪的状态:
- 标注区域有一部分不在图里
- 比例坐标可能小于
0 - 宽高可能超出
1
这会直接污染数据层。
所以在拖拽过程中,矩形框就应该先被裁剪到图片可用区域内。
可以把图片边界想象成这样:
+-----------------------------------+
| image bounds |
| +---------------------------+ |
| | valid draft rect | |
| +---------------------------+ |
+-----------------------------------+
对应的伪代码可以这么写:
function clampPointToBounds(point, bounds) {
return {
x: Math.max(bounds.left, Math.min(point.x, bounds.right)),
y: Math.max(bounds.top, Math.min(point.y, bounds.bottom))
}
}
然后在拖拽阶段这样用:
function updateDraftRect(startPoint, rawCurrentPoint, imageBounds) {
const currentPoint = clampPointToBounds(rawCurrentPoint, imageBounds)
return rectFromDrag(startPoint, currentPoint)
}
也就是说:
- 用户鼠标可以飞出图片
- 但草稿框的有效终点会被压回边界内
这样数据才稳定。
2. 标签承担的是解释职责,所以它允许出图避让
标签和框不同。
标签不是图像区域本身,而是这块区域的说明、注释、名称。
现实场景里,标签经常需要挪到图片外部,原因很常见:
- 图片区域太密,内部放不下标签
- 标签文字很长,放图内会挡住内容
- 多个标注靠得很近,需要把标签往外排开
所以更合理的约束通常是:
region必须 clamp 在图片内label允许超出图片,但仍然要处于整个视口的合理范围
这也是为什么前面一直强调:
框和标签虽然有关联,但不应该被当成同一个几何体。
二十四、从草稿框到最终标注,真正要保存的是什么
当用户松开鼠标后,草稿框只是一个像素矩形:
left = 120
top = 80
width = 200
height = 140
但真正要保存的,不是这组像素,而是比例坐标。
所以这里的流程应该非常明确:
flowchart LR
draft[草稿框 pixels] --> normalize[边界归一化]
normalize --> ratio[换算成 ratio]
ratio --> annotation[生成 annotation]
伪代码如下:
function pixelsToRegion(rect, viewportSize) {
return {
x: rect.left / viewportSize.width,
y: rect.top / viewportSize.height,
width: rect.width / viewportSize.width,
height: rect.height / viewportSize.height
}
}
function createAnnotationFromDraft(draftRect, viewportSize) {
return {
id: createId(),
region: pixelsToRegion(draftRect, viewportSize),
label: {
x: (draftRect.left + draftRect.width + 12) / viewportSize.width,
y: draftRect.top / viewportSize.height,
text: ""
}
}
}
这里顺便会引出一个很实用的细节:
新建标注时,标签初始位置通常不要和框完全重叠。
更自然的做法是:
- 默认放在框右上侧
- 如果右边放不下,再尝试下方或左侧
这样用户第一次看到成品时,会觉得系统“像是帮他排过版”。
二十五、真正难的不是创建,而是视图变化后的“重算”
很多标注系统,初版在固定尺寸下看起来完全没问题。
但一旦出现下面这些动作,问题就全出来了:
- 浏览器窗口改变大小
- 容器宽度变化
- 图片进入缩放模式
- 页面发生滚动
- 外层布局切换,导致图片重新排版
这时候最容易犯的错误是:
只更新了图片尺寸,没有同步重算框、标签和连线。
结果就是用户看到的典型现象:
- 框还在旧位置
- 标签偏了
- 连线挂空
所以这里要把“重算”看成一个一等公民能力,而不是补丁逻辑。
二十六、什么情况下必须触发一次完整重算
一个简单判断标准是:
只要当前 viewport 变了,就应该重算所有布局结果。
这里的 viewport,不一定只是浏览器窗口。
它更准确地说,是:
当前这张图片在界面里真正占据的可渲染区域。
只要这个区域的宽高、偏移、缩放因子有变化,就应该重新测量。
典型触发源包括:
- 图片首次加载完成
- 容器
resize - 页面缩放级别变化
- 用户切换侧边栏,导致可用区域变化
- 图片 fit 模式从 contain 切到 cover
整条链路可以画成这样:
flowchart LR
event[resize / zoom / layout change]
event --> measure[重新测量 viewport]
measure --> convert[重算 box / label / connector]
convert --> render[刷新界面]
如果把这条链路封装好,后面的稳定性会高很多。
二十七、重算时到底该重算什么
严格来说,真正应该重算的是 3 类结果:
1. 图片可视区域
这里首先要知道当前图片实际显示成了多大:
viewportSize = {
width,
height
}
如果还有留白、居中、padding、滚动偏移,这里可能还要带上 origin:
viewport = {
left,
top,
width,
height
}
2. 所有标注的像素布局
每一条标注都要重新测一次:
layout = annotations.map(annotation => {
const box = regionToPixels(annotation.region, viewport)
const label = pointToPixels(annotation.label, viewport)
return { box, label }
})
3. 所有连线锚点和路径
因为框和标签都变了,所以线一定也要跟着重算:
connector = buildConnectorPath(
getRegionAnchor(box),
getLabelAnchor(label, labelSize)
)
很多错位 bug,本质上不是坐标错了,而是:
框和标签重算了,连线没重算。
这类 bug 看起来很像“小问题”,但用户感知会非常强。
二十八、不要把“缩放”和“数据修改”混在一起
这一点特别重要。
用户做了两种完全不同的事:
- 改标注
- 改视图
前者会改变数据,后者不应该改变数据。
比如:
- 拖动框位置,这是标注修改
- 拖动标签位置,这是标注修改
- 放大图片查看细节,这只是视图修改
- 容器变宽导致图片显示变大,这也只是视图修改
所以系统里最好有一个很清晰的边界:
annotation data 负责“是什么”
view state 负责“怎么看”
示意图如下:
flowchart TB
data[annotation data]
state[view state: zoom / pan / viewport]
data --> layout[布局计算]
state --> layout
layout --> screen[最终画面]
只要这个边界不乱,很多问题都会简单很多:
- 缩放不会把数据越改越脏
- 重置视图不会影响标注结果
- 保存时也不需要关心当前放大了几倍
二十九、把这一段收束成一句工程化的话
写到这里会发现,一个“新建标注”的动作,背后其实至少包含了 4 个环节:
- 从鼠标事件中拿到拖拽起点和终点
- 生成一个始终合法的草稿矩形
- 把草稿矩形转换成比例坐标标注
- 在 resize / zoom / 重排时持续重算视图结果
如果这 4 件事拆得清楚,那么图片标注的交互层就会非常稳定。
甚至可以把它理解成一个小型状态机:
idle
-> drawing
-> draft-ready
-> saved
状态不复杂,但边界一定要清楚。
因为从这一刻开始,面对的已经不是“一个会显示的框”,而是:
一套既要响应鼠标,又要稳定存储,还要适应视图变化的交互系统。
三十、如果下一段继续写,最值得讲的就是“状态机”
再往下写,最适合接的主题其实就是:
如何把查看态、绘制态、编辑态、拖标签态,拆成清晰的交互状态机。
因为做到这一步之后,很多新的问题会马上冒出来:
- 什么情况下允许新建框
- 什么情况下只允许选中已有标注
- 拖框和拖标签冲突时,优先级怎么定
- 一个未保存草稿,什么时候该提交,什么时候该取消
这部分一旦讲透,整套图片标注系统就不只是“实现了”,而是真正开始具备:
可维护、可扩展、可持续演进的交互结构。
三十一、第四步:进入编辑态之后,真正的实操才开始
如果前面几步解决的是:
- 数据怎么定义
- 画面怎么渲染
- 新建框怎么生成
那么到了这里,终于进入用户最直观能感知的部分:
编辑态下,这个框到底怎么被“拉起来”、怎么被“拖起来”、怎么被“改大小”,最后又怎么被保存。
这也是整篇文章最接近真实开发的一段。
很多人做到这一步,第一反应都是自己手写 8 个控制点、自己算拖拽方向、自己处理四边和四角的缩放逻辑。
不是不能做,但成本很高。
因为一旦决定自己写,就意味着要自己处理:
- 命中检测
- 八方向控制点
- 拖拽中的最小尺寸
- 边界裁剪
- 缩放时的锚点基准
- 鼠标快速移动导致的抖动
所以在业务型图片标注系统里,一个非常务实的选择通常是:
用一类专门的交互插件,去接管“可拖拽 + 可缩放”的控制层。
比如:
- Moveable
- interact.js
- 其他支持 drag / resize handles 的交互库
这一节不绑定某个具体项目,但会以“Moveable 这一类插件”的思路来讲清楚整体实现。
三十二、先明确一下:这里说的“八角框”到底是什么
这里的“八角框”,其实不是说画一个八边形。
它真正指的是:
一个矩形外面带有 8 个可操作控制点。
也就是四个角 + 四条边中点:
[nw] [n] [ne]
*-----*-----*
| |
[w] * region * [e]
| |
*-----*-----*
[sw] [s] [se]
这 8 个点存在的意义是:
- 拖四个角:同时改宽和高
- 拖上下边:只改高
- 拖左右边:只改宽
用户看到的是“八角框”,但从工程角度看,真正接入的是:
一套八方向 resize handles。
这也是为什么这类功能特别适合交给现成插件处理。
三十三、为什么编辑态适合交给插件,而不是自己硬写
编辑态最麻烦的,不是“框能动”,而是“框要按照用户预期地动”。
比如用户抓住右下角拖动时,系统应该做到:
- 当前框仍然贴在图上
- 左上角保持稳定
- 宽高持续变化
- 宽高不能小到看不见
- 拖出图片边界时自动裁剪
- 如果开启锁定比例,还要同时维护长宽比
会发现,这已经不是一个简单的 mousemove 能优雅解决的问题了。
这类插件的价值,本质上就在这里:
- 帮我画出控制框和 8 个控制点
- 帮我管理拖拽和缩放事件
- 帮我给出当前最新的像素结果
- 让我只专注在“结果如何写回标注数据”
换句话说:
插件负责交互控制壳,业务代码负责数据收口。
这才是最省力也最稳的分工。
三十四、编辑态下,完整链路其实只有 5 步
把编辑态抽象一下,流程非常清楚:
flowchart LR
dblclick[双击图片]
dblclick --> init[初始化新标注]
init --> select[激活当前框]
select --> plugin[挂载拖拽/缩放插件]
plugin --> update[持续回写比例坐标]
update --> save[保存并结束新建态]
所以这里真正要做的,不是到处写事件,而是把这 5 步接起来。
三十五、第一步:双击之后,先初始化一个“可编辑新框”
双击新建时,系统通常要做 3 件事:
- 算出用户点击位置在当前图片中的像素点
- 以这个点为中心,创建一个默认大小的矩形
- 立即把它设成当前激活项,并标记为“新建态”
这里最重要的,不是框有多大,而是这个新框要立刻进入“可编辑状态”。
一个很典型的初始化数据可以是:
annotation = {
id: createId(),
region: {
x: 0.35,
y: 0.22,
width: 0.18,
height: 0.14
},
label: {
x: 0.56,
y: 0.20,
text: ""
},
meta: {
isNew: true
}
}
这里的 meta.isNew 很关键。
因为它不是给后端看的,而是给前端交互层看的。
它意味着:
- 这个框刚刚创建
- 它应该自动出现控制点
- 它应该允许拖动和缩放
- 保存成功后,这个状态要被关闭
双击后的初始化流程,伪代码可以写成这样:
function onCanvasDoubleClick(screenPoint, viewport) {
const canvasPoint = screenPointToCanvasPoint(screenPoint, viewport)
const draftRect = createDefaultRectAroundPoint(canvasPoint, viewport)
const annotation = createAnnotationFromRect(draftRect, viewport)
annotation.meta = {
...annotation.meta,
isNew: true
}
store.add(annotation)
store.setActive(annotation.id)
}
这一步做完之后,用户眼里的体验应该是:
双击一下,一个默认大小的新框立刻出现,并且已经自带可编辑控制点。
三十六、初始化时,默认框不要“贴边生成”
这是一条很实战的小经验。
双击点虽然是用户的意图中心,但这里不能直接把它当成矩形左上角。
更自然的方式是:
- 让双击点落在默认框中心附近
- 如果默认框会超出边界,再整体向内回推
示意图如下:
用户双击点
*
+--------+
| region |
+--------+
伪代码可以写成:
function createDefaultRectAroundPoint(point, bounds) {
const defaultWidth = bounds.width * 0.18
const defaultHeight = bounds.height * 0.14
const left = clamp(point.x - defaultWidth / 2, bounds.left, bounds.right - defaultWidth)
const top = clamp(point.y - defaultHeight / 2, bounds.top, bounds.bottom - defaultHeight)
return {
left,
top,
width: defaultWidth,
height: defaultHeight
}
}
这样做的好处是:
- 用户感觉“框是从点击位置长出来的”
- 新框不会一出生就有一半掉到图片外面
这个细节很小,但体验差距会非常明显。
三十七、第二步:把新框交给插件,显示出 8 个控制点
一旦新框被设成激活态,就可以把它交给插件接管。
这一层通常会有 3 个配置重点:
- 目标元素是谁
- 是否允许拖动
- 是否允许八方向缩放
如果用通用伪代码表达,可以像这样:
plugin.mount({
target: activeRegionElement,
draggable: true,
resizable: true,
renderDirections: ["nw", "n", "ne", "w", "e", "sw", "s", "se"],
keepRatio: false
})
这段配置的意思是:
- 当前激活的矩形框,交给插件管理
- 允许整框拖动
- 允许 8 个方向缩放
- 默认不锁定比例
如果某些场景需要锁定长宽比,也可以做成:
plugin.setOptions({
keepRatio: isRatioLocked
})
这里的核心设计建议是:
插件只绑定在当前激活的新建框上,不要同时绑在所有框上。
否则后面很容易出现:
- 多个控制框同时显示
- 选中态混乱
- 事件命中冲突
一个更稳的规则通常是:
- 查看态:不显示几何控制点
- 编辑态 + 当前新框:显示控制点
- 已保存老框:只允许选中、删除,必要时再进入单独编辑
三十八、第三步:移动框时,真正更新的是 region
用户拖动整个框时,插件给到的通常不是“新标注对象”,而是:
- 当前
left - 当前
top - 或者当前位移
deltaX / deltaY
这里要记住一件事:
插件输出的是像素结果,但存储层里要的是比例坐标。
所以移动过程应该走这条链路:
drag event
-> 得到像素位置
-> clamp 到图片边界
-> 转成比例 region
-> 回写 annotation
伪代码如下:
function onDrag(annotation, nextPixels, viewport) {
const clamped = clampRectToBounds(nextPixels, viewport)
return {
...annotation,
region: {
x: clamped.left / viewport.width,
y: clamped.top / viewport.height,
width: clamped.width / viewport.width,
height: clamped.height / viewport.height
}
}
}
这样看下来,拖动其实并不复杂。
真正要小心的是两个点:
- 拖动后框不能跑出图片边界
- 更新后标签和连线要同步重算
也就是说,拖动框不是“只改一下 left/top”,而是:
一次对整条布局链路的重新计算。
三十九、第四步:缩放框时,最难的是“从哪个点开始变”
缩放比拖动难一点。
因为拖动只是在整体平移,而缩放会引出两个问题:
- 哪个边或哪个角是当前控制方向
- 对边或对角是否应该保持稳定
比如用户抓住右下角 se 拖动,正常预期是:
- 左上角不动
- 宽度增加或减少
- 高度增加或减少
如果用户抓住左边 w 拖动,正常预期又变成:
- 右边保持稳定
- 左边移动
- 宽度变化,高度不变
这也是为什么缩放特别适合交给插件处理。
因为插件已经把这些事情算好了:
- 当前拖的是哪一个方向
- 当前矩形的最新像素宽高
- 在这个方向上如何生成最新结果
业务层只需要接住这个结果,再做边界和最小值收口。
四十、缩放时一定要做 3 个收口:最小尺寸、边界、比例
缩放不是“拿到多少就用多少”,而是至少要做 3 道收口。
1. 最小尺寸
否则框很容易被用户缩到几乎看不见。
function applyMinSize(rect, minWidth, minHeight) {
return {
...rect,
width: Math.max(rect.width, minWidth),
height: Math.max(rect.height, minHeight)
}
}
2. 图片边界
否则缩放过程中,框会被拖出图片外。
function clampRectToBounds(rect, bounds) {
const left = clamp(rect.left, bounds.left, bounds.right)
const top = clamp(rect.top, bounds.top, bounds.bottom)
const width = Math.min(rect.width, bounds.right - left)
const height = Math.min(rect.height, bounds.bottom - top)
return { left, top, width, height }
}
3. 比例策略
有些场景允许自由缩放,有些场景则希望维持长宽比。
例如:
- 普通文本标注框:通常自由缩放
- 人脸框 / 固定裁切框:可能要求锁定比例
所以这一层最好不要写死,而应该保留策略开关:
resizePolicy = {
keepRatio: false
}
如果要更完整地表达一次缩放收口,可以写成:
function onResize(annotation, nextRect, viewport, policy) {
let rect = applyMinSize(nextRect, 48, 32)
if (policy.keepRatio) {
rect = adjustRectByRatio(rect, annotation.region.width / annotation.region.height)
}
rect = clampRectToBounds(rect, viewport)
return {
...annotation,
region: pixelsToRegion(rect, viewport)
}
}
这一段看起来稍微复杂一点,但本质上就是一句话:
插件给出“候选矩形”,业务层把它收口成“可保存矩形”。
四十一、所谓“改变比例与大小”,本质上是在持续回写 region
用户看到的是框在被拉大、拉小、拉宽、拉高。
但从数据层角度看,真正变化的其实始终只有这 4 个值:
region.xregion.yregion.widthregion.height
所以不管是拖动,还是缩放,最终都可以被归纳成一句话:
把最新像素矩形持续换算成新的 region。
甚至可以把拖动和缩放统一成一个入口:
function commitRect(annotation, rect, viewport) {
return {
...annotation,
region: pixelsToRegion(rect, viewport)
}
}
然后:
- 拖动时算出新的 rect,调用一次
- 缩放时算出新的 rect,调用一次
这样交互逻辑会非常统一。
这一步一旦统一,后面很多事情都会变简单:
- 撤销重做好做
- 自动保存好做
- dirty 状态好做
- 保存前校验也好做
四十二、标签为什么通常不跟着插件一起缩放
这是一个特别容易写歪的地方。
既然框都接了插件,很多人就会想:
“那标签是不是也一起放进目标元素里,跟着一起拉伸?”
通常不建议这样做。
原因很简单:
- 框的几何变化,和标签的排版变化,不是一回事
- 标签是信息节点,不是图像区域本体
- 如果标签跟着缩放,文字可读性通常会变差
所以更合理的策略通常是:
- 插件只控制矩形框
- 标签位置独立计算
- 连线根据最新锚点自动重算
也就是说,编辑态下真正被插件接管的,是:
框的几何控制,而不是整条标注的所有视觉元素。
这条边界一定要守住。
四十三、第五步:什么时候算“编辑完成”,什么时候真正保存
做到这里,最后一个问题就来了:
用户拖完了、缩完了,这个新框到底什么时候算完成?
一般来说,这里至少有两层“完成”:
1. 交互完成
也就是:
- 用户停止拖拽
- 当前几何结果已经稳定
- 插件控制框还在,但本轮操作结束了
这时通常只需要做:
- 回写最新
region - 重新计算标签和连线
- 标记当前标注为 dirty
2. 业务完成
也就是:
- 用户点击保存
- 或者当前页面触发显式提交动作
这时才应该把这条新标注正式纳入持久化数据。
所以保存动作通常不应该只是“把当前框传给接口”,而是要做一次规范化:
function normalizeForSave(annotation) {
return {
id: annotation.id,
region: sanitizeRegion(annotation.region),
label: sanitizeLabel(annotation.label),
meta: {
...annotation.meta,
isNew: false
}
}
}
保存前后最好分别做两件事:
- 保存前:裁剪精度、清理临时态、补齐默认值
- 保存后:把
meta.isNew置为false
因为一旦保存成功,这个框就不应该再以“刚创建、待微调”的状态存在。
四十四、把整个编辑态流程串起来,其实会发现它很顺
如果把“编辑态实操”完整拉直,其实就是下面这条线:
flowchart LR
a[双击图片]
a --> b[创建默认新框]
b --> c[设为 active + isNew]
c --> d[插件显示八方向控制点]
d --> e[拖动或缩放]
e --> f[像素矩形收口]
f --> g[回写 region 比例坐标]
g --> h[重算标签与连线]
h --> i[点击保存]
i --> j[isNew=false 持久化]
到这里,前面整篇文章里分散讲的东西,终于串成了一个完整闭环:
- 坐标系提供稳定的数据基础
- DOM / SVG 提供稳定的渲染结构
- 插件提供稳定的几何交互能力
- 保存流程把临时编辑态收束成持久化数据
也就是说,一个真正能用的图片标注系统,靠的不是某一个“神奇组件”,而是这几层能力刚好扣在一起。
四十五、把整条链路收束一下
如果把整篇内容压缩成一句话,这条主线其实就是:
先把标注定义成稳定的比例坐标,再把它渲染成可交互图层,最后借助交互插件把编辑态收口成可保存数据。
从工程视角看,这套链路至少回答了 4 个关键问题:
- 为什么标注数据应该存比例,而不是存页面像素
- 为什么矩形框、标签、连线应该拆层渲染
- 为什么新建、拖动、缩放本质上都在更新同一组
region - 为什么编辑态适合交给 Moveable 这一类插件做几何控制
写到这里,这条主线其实已经完整了。
后面如果还要继续深入优化,就更适合进入专题化延展,比如:
-
如何做撤销 / 重做
-
如何做自动吸附和辅助线
-
如何处理旋转后的Label依然能正常显示(方便用户查看Label的文本是不应该旋转的)
-
如何做多人协同标注(我自己都没实现过) 当然呢,我们程序员都是跟着需求业务走的,没有必要把所有东西都写出来,只要把最核心的原理理清楚,按照需求一比一还原,然后根据需求业务进行扩展即可。