图片标注引擎从 0 到 1实现

0 阅读53分钟

先看最终效果:

图片标注引擎.gif

图片标注从 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 = 150
  • top = 80

是成立的。

但如果下一次页面把图片显示成 900 x 600,这组值就不再对应原来的那个区域了。

2. 它不利于跨端和响应式

桌面端、移动端、弹窗预览页、详情页,图片展示尺寸通常都不一样。如果存的是“某次渲染出来的像素值”,每个地方都要补一层额外适配。

3. 它不利于后端长期存储

后端真正需要保存的,应该是“这个标注在原图里的位置关系”,而不是“某个页面当时怎么显示的”。

所以,图片标注这类功能最稳妥的做法通常都是:

存比例,不存显示像素。


Generated_image.png

三、图片标注里最重要的三套坐标系

要把这个问题讲明白,最好的方式是先把坐标系统拆成三层。

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.xlabel.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. xwidth 都要除以图片宽度

很多人第一次写时会下意识搞混:

  • x 应该除以 imageWidth
  • width 也应该除以 imageWidth

同理:

  • y 应该除以 imageHeight
  • height 也应该除以 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. 存储层不依赖当前页面尺寸
  2. 渲染层可以根据当前显示尺寸实时换算

这就是我们引入“比例坐标”的原因。


二、先定义三套坐标系

要把问题讲清楚,最好先把系统里的三套坐标系分开。

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 800
  • 600 x 400
  • 900 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.xlabel.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.xlabel.y 同样采用比例坐标,这样连接线才能稳定。

3. 存储精度要适中

比例坐标通常是小数,比如:

0.2537
0.1462

精度太低会导致位置误差,精度太高又没有必要。当前实现里做了适度保留小数位,这样既能保证位置稳定,也能避免数据冗长。

4. 视图缩放不应该直接修改标注数据

图片放大、缩小、旋转,本质上是“看图方式变化”,不是标注区域变化。

所以:

  • 缩放和平移应该作用在视图层
  • 标注数据本身仍然保持相对坐标不变

这也是后面实现“视图变换”和“标注数据”解耦的基础。


八、第一步完成后,已经具备了什么

当统一坐标系建立完成后,后续标注功能就有了稳定地基。

到这里,其实已经具备了:

  1. 一套可持久化的数据格式
  2. 一套可适配任意显示尺寸的坐标规则
  3. 一条从原始像素到比例坐标、再到渲染像素的转换链路
  4. 一种可支撑拖拽、连线、缩放、保存的底层表示方式

可以说,图片标注功能真正的第一步,不是画框,而是先回答这句话:

“同一个标注,在不同显示尺寸下,如何始终表示为同一个区域?”

当前项目给出的答案就是:

用比例坐标存储,用当前画布尺寸渲染。


九、第二步:把比例坐标真正渲染成“可交互画面”

如果说第一步解决的是:

“标注数据该怎么定义,才能不被显示尺寸绑死?”

那么第二步要解决的就是:

“这套比例坐标,怎么稳定地长成用户眼前能看、能点、能拖、能连线的标注界面?”

很多人做图片标注,卡住的地方并不是“不会画框”,而是下面这些更具体的问题:

  • 框到底应该画在 canvas 上,还是普通 DOM 上
  • 标签为什么最好不要和框绑死成一个整体
  • 连接线为什么经常画着画着就偏了
  • 图片缩放后,框、标签、线为什么还能对得上

这一部分,本质上是在完成一件事:

把抽象的比例坐标,翻译成当前这一次渲染所需的像素布局。


十、先别急着写交互,先把“画面分层”想清楚

一个稳定的图片标注界面,通常至少要拆成 4 层:

  1. 图片层:负责展示原图
  2. 矩形框层:负责框选区域
  3. 标签层:负责文字和交互热点
  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 个值:

  • left
  • top
  • width
  • height

这和 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。

它只做两类事情:

  1. 把区域坐标换成当前视口里的矩形像素
  2. 把标签点位换成当前视口里的位置像素

伪代码保持成两个纯函数就够了:

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 500
  • 640 x 400
  • 1000 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 个核心判断:

  1. 数据层用比例坐标,而不是显示像素
  2. 矩形框更适合用绝对定位 DOM
  3. 标签最好独立成可单独移动的节点
  4. 连线更适合用 SVG,并且依赖统一锚点计算

当这 4 件事理顺之后,整个系统的很多能力都会自然长出来:

  • 缩放时仍然对齐
  • 拖标签时连线实时更新
  • 切换页面尺寸时位置稳定
  • 保存时数据可直接持久化

这时候,图片标注功能才算真正从:

“我有一份坐标数据”

走到:

“我能把它变成一套稳定的交互界面”


二十、接下来再往下写,最适合讲什么

如果这篇继续往下展开,下一段最值得深挖的,其实不是“再多画几个框”,而是下面 3 个更实战的问题:

  1. 新建标注时,如何从鼠标拖拽实时生成比例坐标
  2. 如何限制框始终在图片内部,而标签允许拖到图片外部
  3. 如何处理缩放、滚动、容器 resize 后的实时重算

因为从这一步开始,图片标注就不再只是“渲染问题”,而会正式进入:

交互状态管理、边界约束、以及视图重计算。

这也是一个标注系统从“能显示”走向“能用起来”的真正分界线。


二十一、第三步:用户一拖,标注为什么就“长出来了”

到了这一步,问题已经不再是“框怎么显示”,而是:

用户在图片上按下鼠标、拖动、松开,这一串动作,怎么变成一条可保存的标注数据?

很多人第一次做这里,会下意识把逻辑写成:

mousedown 记起点
mousemove 改 div 宽高
mouseup 直接保存

这样当然能跑起来,但很快就会遇到几个典型问题:

  • 往左上角反向拖拽时,框会出现负宽高
  • 鼠标拖到图片外面,框会飞出去
  • 页面缩放之后,保存的数据不稳定
  • 拖拽过程中预览框和最终保存结果对不上

所以更稳的思路不是“直接改一个框”,而是先把整个创建流程拆成 3 个阶段:

  1. 记录起点
  2. 生成草稿框
  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,不一定只是浏览器窗口。

它更准确地说,是:

当前这张图片在界面里真正占据的可渲染区域。

只要这个区域的宽高、偏移、缩放因子有变化,就应该重新测量。

典型触发源包括:

  1. 图片首次加载完成
  2. 容器 resize
  3. 页面缩放级别变化
  4. 用户切换侧边栏,导致可用区域变化
  5. 图片 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 看起来很像“小问题”,但用户感知会非常强。


二十八、不要把“缩放”和“数据修改”混在一起

这一点特别重要。

用户做了两种完全不同的事:

  1. 改标注
  2. 改视图

前者会改变数据,后者不应该改变数据。

比如:

  • 拖动框位置,这是标注修改
  • 拖动标签位置,这是标注修改
  • 放大图片查看细节,这只是视图修改
  • 容器变宽导致图片显示变大,这也只是视图修改

所以系统里最好有一个很清晰的边界:

annotation data 负责“是什么”
view state 负责“怎么看”

示意图如下:

flowchart TB
    data[annotation data]
    state[view state: zoom / pan / viewport]
    data --> layout[布局计算]
    state --> layout
    layout --> screen[最终画面]

只要这个边界不乱,很多问题都会简单很多:

  • 缩放不会把数据越改越脏
  • 重置视图不会影响标注结果
  • 保存时也不需要关心当前放大了几倍

二十九、把这一段收束成一句工程化的话

写到这里会发现,一个“新建标注”的动作,背后其实至少包含了 4 个环节:

  1. 从鼠标事件中拿到拖拽起点和终点
  2. 生成一个始终合法的草稿矩形
  3. 把草稿矩形转换成比例坐标标注
  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。

这也是为什么这类功能特别适合交给现成插件处理。


三十三、为什么编辑态适合交给插件,而不是自己硬写

编辑态最麻烦的,不是“框能动”,而是“框要按照用户预期地动”。

比如用户抓住右下角拖动时,系统应该做到:

  1. 当前框仍然贴在图上
  2. 左上角保持稳定
  3. 宽高持续变化
  4. 宽高不能小到看不见
  5. 拖出图片边界时自动裁剪
  6. 如果开启锁定比例,还要同时维护长宽比

会发现,这已经不是一个简单的 mousemove 能优雅解决的问题了。

这类插件的价值,本质上就在这里:

  • 帮我画出控制框和 8 个控制点
  • 帮我管理拖拽和缩放事件
  • 帮我给出当前最新的像素结果
  • 让我只专注在“结果如何写回标注数据”

换句话说:

插件负责交互控制壳,业务代码负责数据收口。

这才是最省力也最稳的分工。


三十四、编辑态下,完整链路其实只有 5 步

把编辑态抽象一下,流程非常清楚:

flowchart LR
    dblclick[双击图片]
    dblclick --> init[初始化新标注]
    init --> select[激活当前框]
    select --> plugin[挂载拖拽/缩放插件]
    plugin --> update[持续回写比例坐标]
    update --> save[保存并结束新建态]

所以这里真正要做的,不是到处写事件,而是把这 5 步接起来。


三十五、第一步:双击之后,先初始化一个“可编辑新框”

双击新建时,系统通常要做 3 件事:

  1. 算出用户点击位置在当前图片中的像素点
  2. 以这个点为中心,创建一个默认大小的矩形
  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 个配置重点:

  1. 目标元素是谁
  2. 是否允许拖动
  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
    }
  }
}

这样看下来,拖动其实并不复杂。

真正要小心的是两个点:

  1. 拖动后框不能跑出图片边界
  2. 更新后标签和连线要同步重算

也就是说,拖动框不是“只改一下 left/top”,而是:

一次对整条布局链路的重新计算。


三十九、第四步:缩放框时,最难的是“从哪个点开始变”

缩放比拖动难一点。

因为拖动只是在整体平移,而缩放会引出两个问题:

  1. 哪个边或哪个角是当前控制方向
  2. 对边或对角是否应该保持稳定

比如用户抓住右下角 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.x
  • region.y
  • region.width
  • region.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 个关键问题:

  1. 为什么标注数据应该存比例,而不是存页面像素
  2. 为什么矩形框、标签、连线应该拆层渲染
  3. 为什么新建、拖动、缩放本质上都在更新同一组 region
  4. 为什么编辑态适合交给 Moveable 这一类插件做几何控制

写到这里,这条主线其实已经完整了。

后面如果还要继续深入优化,就更适合进入专题化延展,比如:

  • 如何做撤销 / 重做

  • 如何做自动吸附和辅助线

  • 如何处理旋转后的Label依然能正常显示(方便用户查看Label的文本是不应该旋转的)

  • 如何做多人协同标注(我自己都没实现过) 当然呢,我们程序员都是跟着需求业务走的,没有必要把所有东西都写出来,只要把最核心的原理理清楚,按照需求一比一还原,然后根据需求业务进行扩展即可。