# CSLabelTool
**Repository Path**: qinshige/cs-label-tool
## Basic Information
- **Project Name**: CSLabelTool
- **Description**: 一个基于canvas绘制svg展示的绘制工具, 尚在开发中.
- **Primary Language**: JavaScript
- **License**: MulanPSL-2.0
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 2
- **Forks**: 1
- **Created**: 2022-03-31
- **Last Updated**: 2026-07-15
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# cs-label-tool
在线 Demo:
一个用 Canvas 做图片标注的 TypeScript 库。没有运行时依赖,可以直接调用函数,也可以使用已经绑定好的 `editor` 实例。
当前支持:
- 图片 URL、`Blob`、`ImageBitmap` 加载。
- 选择、框选、套索选择、自由轮廓、点、矩形、椭圆、折线、多边形、涂抹和橡皮擦工具。
- 矩形和椭圆的移动、旋转与八方向缩放,折线和多边形的顶点编辑。
- 多选、图层顺序、锁定、隐藏、复制粘贴、克隆、分组和取消分组。
- 图片级单选分类,分类结果与 Canvas 标注一起进入快照。
- Mask 点击选择、拖拽、删除、改标签和近距离合并。
- 涂抹区域重叠合并;橡皮擦实时透明预览和连通域分割。
- 缩放、平移、适屏、坐标转换和 DPR 高分屏渲染。
- 标签管理、区域查询、撤销重做、事件订阅和自定义工具。
## 环境与安装
当前版本为 `2.0.4`,仅提供 ESM,没有运行时依赖。
- Chrome、Edge、Firefox 最近两个大版本
- Safari 17+
- 仅提供 ESM
```bash
npm install cs-label-tool
```
容器必须具有可计算的宽高:
```html
```
## 快速开始
推荐使用绑定实例。实例方法不需要重复传递 `annotator`:
```ts
import { mount, createStandardImageSource } from 'cs-label-tool'
const editor = mount('#annotator', { historyLimit: 100 })
editor.addLabel({ id: 'person', name: '人物', color: '#ff4d4f' })
editor.addLabel({ id: 'vehicle', name: '车辆', color: '#1677ff' })
editor.setActiveLabel('person')
await editor.setImage(createStandardImageSource('/images/example.webp'))
editor.fitToScreen()
editor.tools.rect()
```
完整页面示例:
```html
```
```ts
import {
mount,
createStandardImageSource,
type AnnotationSnapshot,
} from 'cs-label-tool'
const editor = mount('#annotator', { historyLimit: 100 })
editor.addLabel({ id: 'person', name: '人物', color: '#ff4d4f' })
editor.addLabel({ id: 'vehicle', name: '车辆', color: '#1677ff' })
editor.setActiveLabel('person')
await editor.setImage(createStandardImageSource('/a.webp'))
editor.fitToScreen()
document.querySelector('#select')?.addEventListener('click', () => {
editor.tools.select()
})
document.querySelector('#freehand')?.addEventListener('click', () => {
editor.tools.freehand({ simplifyTolerance: 1.5 })
})
document.querySelector('#point')?.addEventListener('click', () => editor.tools.point())
document.querySelector('#rect')?.addEventListener('click', () => {
editor.tools.rect({ minimumSize: 3 })
})
document.querySelector('#ellipse')?.addEventListener('click', () => {
editor.tools.ellipse({ minimumRadius: 2 })
})
document.querySelector('#polyline')?.addEventListener('click', () => {
editor.tools.polyline()
})
document.querySelector('#polygon')?.addEventListener('click', () => {
editor.tools.polygon()
})
document.querySelector('#brush')?.addEventListener('click', () => {
editor.tools.brush({ size: 24 })
})
document.querySelector('#eraser')?.addEventListener('click', () => {
editor.tools.eraser({ size: 18 })
})
document.querySelector('#delete')?.addEventListener('click', () => {
editor.tools.deleteSelection()
})
const unsubscribe = editor.subscribe('change', () => {
const snapshot: AnnotationSnapshot = editor.snapshot()
console.log(snapshot.annotations)
})
window.addEventListener('beforeunload', () => {
unsubscribe()
editor.destroy()
}, { once: true })
```
## 统一工具 API
### 实例调用
`editor.tools` 封装了所有内置交互工具和常用选择操作:
```ts
editor.tools.select()
editor.tools.lasso()
editor.tools.freehand({ simplifyTolerance: 1.5 })
editor.tools.point()
editor.tools.rect()
editor.tools.ellipse()
editor.tools.polyline()
editor.tools.polygon()
editor.tools.brush({ size: 24 })
editor.tools.eraser({ size: 18 })
const activeToolId = editor.tools.activeId()
const tools = editor.tools.list()
const brush = editor.tools.get('brush')
editor.tools.cancel()
```
### 函数式调用
已有原始 `Annotator` 时,可以创建一次绑定工具对象:
```ts
import {
createAnnotator,
createToolApi,
createStandardImageSource,
setImageSource,
addLabel,
setActiveLabel,
} from 'cs-label-tool'
const annotator = createAnnotator({
container: document.querySelector('#annotator')!,
historyLimit: 100,
})
addLabel(annotator, { id: 'person', name: '人物', color: '#ff4d4f' })
setActiveLabel(annotator, 'person')
await setImageSource(annotator, createStandardImageSource('/a.webp'))
const tools = createToolApi(annotator)
tools.freehand({ labelId: 'person', simplifyTolerance: 2 })
tools.brush({ size: 20 })
```
也可以直接调用原有函数:
```ts
import {
useSelect,
useLasso,
useFreehand,
usePoint,
useRect,
useEllipse,
usePolyline,
usePolygon,
useBrush,
useEraser,
} from 'cs-label-tool'
useSelect(annotator)
useLasso(annotator)
useFreehand(annotator, { labelId: 'person', simplifyTolerance: 1.5 })
usePoint(annotator, { labelId: 'person' })
useRect(annotator, { labelId: 'person', minimumSize: 3 })
useEllipse(annotator, { labelId: 'person', minimumRadius: 2 })
usePolyline(annotator, { labelId: 'person' })
usePolygon(annotator, { labelId: 'person' })
useBrush(annotator, { labelId: 'person', size: 24, color: '#ff4d4f' })
useEraser(annotator, { size: 18 })
```
### 工具参数
| 工具 | 方法 | 参数 |
| ---- | ------------------- | ----------------------------------- |
| 选择 | `select()` | 无 |
| 套索选择 | `lasso()` | 无 |
| 自由轮廓 | `freehand(options)` | `labelId?`, `simplifyTolerance?` |
| 点 | `point(options)` | `labelId?` |
| 矩形 | `rect(options)` | `labelId?`, `minimumSize?` |
| 椭圆 | `ellipse(options)` | `labelId?`, `minimumRadius?` |
| 折线 | `polyline(options)` | `labelId?` |
| 多边形 | `polygon(options)` | `labelId?` |
| 涂抹 | `brush(options)` | `labelId?`, `size?`, `color?`(预览颜色) |
| 橡皮擦 | `eraser(options)` | `size?` |
未传 `labelId` 时,点、矩形、椭圆、折线、多边形、自由轮廓和涂抹使用当前激活标签。调用绘制工具前必须加载图片并激活一个标签。
### 工具行为
- 点:单击生成一个独立点,选择后可直接拖动。
- 矩形:按下拖动,抬起完成;选择后可移动、旋转和拖动八个控制点缩放。
- 椭圆:按下拖动,抬起完成;按住 `Shift` 画正圆,选择后的编辑方式与矩形一致。
- 折线:逐点点击,按 `Enter` 或双击完成;双击线段插入顶点,`Backspace` 删除选中顶点。
- 多边形:逐点点击,按 `Enter` 或双击完成,`Backspace` 删除最后一个点。
- 自由轮廓:按住鼠标连续绘制,抬起后自动闭合并生成带标签的 `PolygonAnnotation`。
- 涂抹:按下拖动,抬起完成;同标签且重叠的 mask 自动合并。
- 橡皮擦:按下拖动时实时透明擦除,抬起提交;只影响 mask。
- Mask 分割:橡皮擦切断区域后,每个连通块成为独立标注。
- Mask 合并:选择后拖动,同标签区域进入约 8 屏幕像素范围时自动合并。
- 选择:空白处拖动是框选,`Shift + 点击` 追加或取消选择;组内标注默认整组选中,`Alt + 点击` 只选当前成员。
- 视图导航:按住空格或中键拖动画布;滚轮以鼠标位置为锚点缩放。
缩放时图片层、标注层和交互层共享同一 viewport 变换。矩形、多边形、标签和控制点会按当前 DPR 重新绘制;Mask 放大时关闭插值,保持像素边界清晰。原始位图本身仍受源图片分辨率限制,不能像真正的 SVG 一样无限放大。
## 每个工具怎么用
下面的例子都接着“快速开始”里的 `editor` 使用。先加载图片、添加标签,再启用工具。
### 1. 选择工具
选择工具不创建标注。它用来点击、拖动、缩放、改标签和删除已有标注。
```ts
// 启用选择工具,之后直接在画布上点击标注
editor.tools.select()
// 已经知道标注 ID 时,也可以直接选中
editor.tools.selectAnnotation(annotationId)
// 查看当前选中了哪些标注
const selectedIds = editor.tools.selection()
// 修改选中标注的标签,返回修改数量
const changedCount = editor.tools.setSelectionLabel('vehicle')
// 删除选中标注,返回删除数量
const deletedCount = editor.tools.deleteSelection()
// 只取消选择,不删除数据
editor.tools.clearSelection()
```
页面上的常见删除按钮可以这样写:
```ts
document.querySelector('#delete')?.addEventListener('click', () => {
const count = editor.tools.deleteSelection()
console.log(`删除了 ${count} 个标注`)
})
```
函数式写法:
```ts
import {
useSelect,
selectAnnotation,
getSelection,
clearSelection,
updateSelectedAnnotationsLabel,
deleteSelectedAnnotations,
} from 'cs-label-tool'
useSelect(annotator)
selectAnnotation(annotator, annotationId)
updateSelectedAnnotationsLabel(annotator, 'vehicle')
deleteSelectedAnnotations(annotator)
clearSelection(annotator)
console.log(getSelection(annotator))
```
### 2. 矩形工具
启用工具后,在图片上按住鼠标拖动,松开鼠标就会生成矩形。
```ts
editor.tools.rect({
labelId: 'person', // 不传就用当前激活标签
minimumSize: 3, // 小于 3 个原图像素的矩形不保存
})
```
不用鼠标,也可以直接用代码添加:
```ts
const rectId = editor.addRect({
labelId: 'person',
x: 100,
y: 80,
width: 300,
height: 500,
rotation: 25, // 可选,顺时针角度;不传就是 0 度
})
```
编辑矩形:
```ts
// 先选中。用户也可以切到选择工具后直接点击矩形
editor.tools.selectAnnotation(rectId)
editor.tools.select()
// 直接修改坐标、大小和角度
editor.updateAnnotation(rectId, {
type: 'rect',
x: 120,
y: 90,
width: 320,
height: 480,
rotation: 45,
})
// 删除
editor.removeAnnotation(rectId)
```
选择工具下,拖动矩形内部可以移动,拖动八个方形控制点可以缩放。上边中间外侧的圆形控制点用于旋转。矩形旋转后仍然按照自身方向缩放,不会退化成普通多边形。
`rotation` 的单位是度,正值表示顺时针旋转。API 会把角度整理到 `0` 到 `360` 之间,例如 `450` 度保存为 `90` 度。旧数据不包含 `rotation` 时仍按普通矩形处理。
### 3. 多边形工具
启用后逐点点击。按 `Enter` 或双击结束,按 `Backspace` 删除刚加的点,按 `Escape` 取消这次绘制。
```ts
editor.tools.polygon({ labelId: 'person' })
```
直接用代码添加时,点使用 `{ x, y }`:
```ts
const polygonId = editor.addPolygon({
labelId: 'person',
points: [
{ x: 20, y: 20 },
{ x: 120, y: 20 },
{ x: 80, y: 140 },
],
})
```
移动顶点、删除顶点和保存修改:
```ts
import { movePolygonVertex, removePolygonVertex } from 'cs-label-tool'
const polygon = editor.snapshot().annotations.find(item => item.id === polygonId)
if (polygon?.geometry.type === 'polygon') {
const moved = movePolygonVertex(
polygon.geometry,
0,
{ x: 30, y: 35 },
)
editor.updateAnnotation(polygonId, moved)
// 多边形至少保留 3 个有效顶点。不能删除时返回 null
const withoutSecondPoint = removePolygonVertex(moved, 1)
if (withoutSecondPoint !== null) {
editor.updateAnnotation(polygonId, withoutSecondPoint)
}
}
editor.removeAnnotation(polygonId)
```
快照里的 `PolygonGeometry.points` 是 `[x, y]` 元组;`addPolygon()` 的输入是 `{ x, y }`。这两个格式不要混用。
### 3.1 自由轮廓工具
自由轮廓适合快速勾画不规则目标。按住鼠标开始绘制,移动鼠标记录轮廓,抬起后自动闭合。保存结果仍然是标准 `PolygonAnnotation`,不是套索选区,也没有新增一套特殊数据格式。
使用当前激活标签:
```ts
editor.setActiveLabel('person')
editor.tools.freehand()
```
指定标签和路径简化精度:
```ts
editor.tools.freehand({
labelId: 'person',
// 单位是原图像素。数值越大,生成的顶点越少
simplifyTolerance: 2,
})
```
函数式写法:
```ts
import { useFreehand } from 'cs-label-tool'
useFreehand(annotator, {
labelId: 'person',
simplifyTolerance: 2,
})
```
交互式绘制不会同步返回 ID。可以通过 `change` 事件记录刚创建的自由轮廓,然后在业务按钮中执行编辑。选中以后,移动、顶点编辑、换标签、复制和删除都与普通多边形相同:
```ts
let latestFreehandId: string | null = null
const stopTracking = editor.subscribe('change', event => {
if (event.kind !== 'annotation:add') return
const annotation = editor.snapshot().annotations.at(-1)
if (annotation?.geometry.type !== 'polygon') return
latestFreehandId = annotation.id
})
function editLatestFreehand() {
if (latestFreehandId === null) return
editor.tools.selectAnnotation(latestFreehandId)
editor.tools.select()
// 改标签
editor.tools.setSelectionLabel('vehicle')
// 克隆一个可以独立编辑的新标注
const copiedIds = editor.tools.duplicateSelection()
console.log('复制出的标注', copiedIds)
// 删除当前选中的标注
editor.tools.deleteSelection()
latestFreehandId = null
}
document.querySelector('#edit-latest')?.addEventListener(
'click',
editLatestFreehand,
)
window.addEventListener('beforeunload', stopTracking, { once: true })
```
选择工具激活后,拖动轮廓内部可以整体移动,拖动顶点可以修改边界。也可以直接使用 `updateAnnotation()`、`updateAnnotationLabel()` 和 `removeAnnotation()` 按 ID 操作。
`simplifyTolerance` 默认是 `1.5`。传 `0` 会保留所有非重复采样点;传负数、`NaN` 或无限大会抛出 `RangeError`。少于三个有效点、零面积或自相交的路径不会创建标注。
### 4. 点工具
点是独立标注,不会伪装成一个很小的矩形。启用后单击图片即可落点:
```ts
editor.tools.point({ labelId: 'person' })
const pointId = editor.addPoint({
labelId: 'person',
x: 320,
y: 180,
})
editor.updateAnnotation(pointId, { type: 'point', x: 340, y: 200 })
editor.tools.selectAnnotation(pointId)
editor.removeAnnotation(pointId)
```
函数式写法:
```ts
import { usePoint, addPoint, updateAnnotation, removeAnnotation } from 'cs-label-tool'
usePoint(annotator, { labelId: 'person' })
const id = addPoint(annotator, { labelId: 'person', x: 320, y: 180 })
updateAnnotation(annotator, id, { type: 'point', x: 340, y: 200 })
removeAnnotation(annotator, id)
```
### 5. 折线工具
逐点点击,按 `Enter` 或双击完成。折线至少要有两个不同的点。
```ts
editor.tools.polyline({ labelId: 'vehicle' })
const lineId = editor.addPolyline({
labelId: 'vehicle',
points: [
{ x: 80, y: 120 },
{ x: 220, y: 140 },
{ x: 360, y: 110 },
],
})
editor.updateAnnotation(lineId, {
type: 'polyline',
points: [[80, 120], [240, 150], [360, 110]],
})
editor.removeAnnotation(lineId)
```
选择折线后可拖动整条线,也可以拖顶点。双击线段会插入新顶点,选中顶点后按 `Backspace` 删除;至少保留两个点。
函数式入口是 `usePolyline(annotator)`、`addPolyline(annotator, input)`、`updateAnnotation()` 和 `removeAnnotation()`。
### 6. 椭圆和圆形工具
拖动绘制椭圆,按住 `Shift` 拖动会得到正圆。选择后有八个缩放点和一个旋转点。
```ts
editor.tools.ellipse({ labelId: 'person', minimumRadius: 2 })
const ellipseId = editor.addEllipse({
labelId: 'person',
cx: 420,
cy: 260,
radiusX: 90,
radiusY: 50,
rotation: 15,
})
editor.updateAnnotation(ellipseId, {
type: 'ellipse',
cx: 440,
cy: 280,
radiusX: 100,
radiusY: 60,
rotation: 30,
})
editor.removeAnnotation(ellipseId)
```
函数式入口是 `useEllipse`、`addEllipse`、`resizeEllipse`、`rotateEllipse`、`updateAnnotation` 和 `removeAnnotation`。
### 7. 涂抹工具
按住鼠标开始涂,松开鼠标保存。同标签的涂抹区域发生重叠时会自动合并。
```ts
editor.tools.brush({
labelId: 'person',
size: 24, // 原图坐标中的画笔直径
color: '#ff4d4f', // 拖动时的预览颜色
})
```
涂抹保存后是 `mask` 标注,最终显示颜色来自标签颜色。可以不经过鼠标,直接把一组笔迹点转成 mask:
```ts
import { createBrushMaskGeometry } from 'cs-label-tool'
const geometry = createBrushMaskGeometry({
imageWidth: 1920,
imageHeight: 1080,
brushSize: 24,
points: [
{ x: 300, y: 220 },
{ x: 340, y: 250 },
{ x: 390, y: 270 },
],
})
const maskId = editor.addMask({
labelId: 'person',
width: geometry.width,
height: geometry.height,
rle: geometry.rle,
})
```
移动、改标签和删除 mask:
```ts
import {
decodeBinaryMaskRle,
encodeBinaryMaskRle,
translateBinaryMask,
} from 'cs-label-tool'
const mask = editor.snapshot().annotations.find(item => item.id === maskId)
if (mask?.geometry.type === 'mask') {
const pixels = decodeBinaryMaskRle(
mask.geometry.rle,
mask.geometry.width,
mask.geometry.height,
)
const movedPixels = translateBinaryMask(
pixels,
mask.geometry.width,
mask.geometry.height,
20,
10,
)
editor.updateAnnotation(maskId, {
...mask.geometry,
rle: encodeBinaryMaskRle(movedPixels),
})
}
editor.updateAnnotationLabel(maskId, 'vehicle')
editor.removeAnnotation(maskId)
```
在选择工具下也可以直接拖动 mask。拖到同标签 mask 附近时,两块会合并成一个标注。
### 8. 橡皮擦工具
橡皮擦不会创建新标注,它只擦已有的 mask。矩形和多边形不会被擦掉。
```ts
editor.tools.eraser({ size: 18 })
```
按住鼠标时画面会实时显示擦除结果,松开鼠标后写入数据。如果一块 mask 被擦断,剩下的每个连通区域都会变成独立标注。
```ts
// 擦完以后找到所有 mask
const masks = editor.snapshot().annotations.filter(
item => item.geometry.type === 'mask',
)
// 每块都有自己的 ID,可以单独选择、改标签和删除
const firstMask = masks[0]
if (firstMask !== undefined) {
editor.tools.selectAnnotation(firstMask.id)
editor.tools.setSelectionLabel('vehicle')
editor.tools.deleteSelection()
}
```
如果服务端直接返回二进制 mask,可以用这些函数自行处理:
```ts
import {
encodeBinaryMaskRle,
decodeBinaryMaskRle,
getBinaryMaskBounds,
splitBinaryMaskComponents,
binaryMasksWithinDistance,
} from 'cs-label-tool'
const pixels = decodeBinaryMaskRle(rle, width, height)
const bounds = getBinaryMaskBounds(pixels, width, height)
const blocks = splitBinaryMaskComponents(pixels, width, height)
const isNear = binaryMasksWithinDistance(
blocks[0] ?? new Uint8Array(width * height),
blocks[1] ?? new Uint8Array(width * height),
width,
height,
8,
)
const firstBlockRle = blocks[0] === undefined
? null
: encodeBinaryMaskRle(blocks[0])
```
### 9. 取消当前绘制
用户可以按 `Escape`。代码里可以这样取消:
```ts
editor.tools.cancel()
// 旧写法仍然可用
editor.cancelGesture()
```
## 多选、框选和套索选择
选择工具在空白处拖动就是框选,套索选择用于自由圈选已有标注。两种选区都是“相交就选中”,不要求标注完全落在选区里。
套索选择不会创建标注,也不绑定标签。需要按住拖动并生成可编辑标注时,请使用前面的 `freehand()` 自由轮廓工具。
```ts
editor.tools.select() // 空白拖动框选
editor.tools.lasso() // 按住鼠标圈选已有标注,松开完成选择
// 已知 ID 时直接多选。默认会展开整组
import {
selectAnnotations,
selectAnnotationsInBounds,
selectAnnotationsInLasso,
toggleAnnotationSelection,
} from 'cs-label-tool'
selectAnnotations(editor.annotator, [firstId, secondId])
toggleAnnotationSelection(editor.annotator, thirdId)
selectAnnotationsInBounds(editor.annotator, { x: 0, y: 0, width: 400, height: 300 })
selectAnnotationsInLasso(editor.annotator, [
{ x: 20, y: 20 },
{ x: 300, y: 30 },
{ x: 260, y: 240 },
])
// 不展开组,只选当前成员。画布上的 Alt + 点击也是这个行为
selectAnnotations(editor.annotator, [firstId], { expandGroups: false })
```
`Shift + 点击`用于追加或取消选择。多选后拖动任意未锁定成员,所有未锁定选中项会一起移动;锁定项留在原位。
## 分组、锁定、隐藏和图层
常用操作可以直接走绑定好的 `editor.tools`:
```ts
editor.tools.groupSelection()
editor.tools.ungroupSelection()
editor.tools.lockSelection(true)
editor.tools.lockSelection(false)
editor.tools.hideSelection(true)
editor.tools.hideSelection(false)
editor.tools.bringSelectionForward()
editor.tools.sendSelectionBackward()
editor.tools.bringSelectionToFront()
editor.tools.sendSelectionToBack()
```
函数式 API 适合列表菜单和批量任务:
```ts
import {
groupAnnotations,
ungroupAnnotations,
setAnnotationsLocked,
setAnnotationsHidden,
bringForward,
sendBackward,
bringToFront,
sendToBack,
removeAnnotations,
updateAnnotationsLabel,
} from 'cs-label-tool'
const groupId = groupAnnotations(annotator, [rectId, pointId])
ungroupAnnotations(annotator, [rectId])
setAnnotationsLocked(annotator, [rectId], true)
setAnnotationsHidden(annotator, [pointId], true)
bringToFront(annotator, [rectId, pointId])
removeAnnotations(annotator, [rectId, pointId])
updateAnnotationsLabel(annotator, [rectId, pointId], 'vehicle')
```
锁定标注仍可选中和查看,但不能移动、缩放、旋转、改顶点、删除、隐藏、改标签或调整图层。单条修改会抛出 `ANNOTATION_LOCKED`;批量操作会跳过锁定项,并返回实际修改数量。隐藏标注不渲染,也不参与画布命中和框选。
组内任意成员默认带出整组。`Alt + 点击`只选当前成员。组删到只剩一个成员时,最后一个成员会自动解除分组。
## 复制、粘贴和克隆
这里使用库内部剪贴板,不会读取或覆盖用户的系统剪贴板:
```ts
editor.tools.copySelection()
const pastedIds = editor.tools.paste()
const clonedIds = editor.tools.duplicateSelection()
```
函数式写法:
```ts
import { copyAnnotations, pasteAnnotations, duplicateAnnotations } from 'cs-label-tool'
copyAnnotations(annotator, [rectId, pointId])
const pastedIds = pasteAnnotations(annotator)
const clonedIds = duplicateAnnotations(annotator, [rectId, pointId])
```
粘贴会生成新的标注 ID 和组 ID,并按 12 个屏幕像素逐次错开。缩放画布后,偏移仍按屏幕距离计算。快捷键是 `Ctrl/Cmd + C`、`Ctrl/Cmd + V` 和 `Ctrl/Cmd + D`。
## 图片单选分类
分类属于整张图片,不是 Canvas 图形,所以不会出现在 `annotations` 或空间索引里。
```ts
editor.setClassificationOptions([
{ id: 'normal', name: '正常', color: '#22c55e' },
{ id: 'abnormal', name: '异常', color: '#ef4444' },
])
editor.setImageClassification('normal')
console.log(editor.getImageClassification()) // normal
console.log(editor.getClassificationOptions())
editor.clearImageClassification()
```
函数式写法:
```ts
import {
setClassificationOptions,
setImageClassification,
getImageClassification,
getClassificationOptions,
clearImageClassification,
} from 'cs-label-tool'
setClassificationOptions(annotator, [{ id: 'normal', name: '正常' }])
setImageClassification(annotator, 'normal')
clearImageClassification(annotator)
```
分类是单选,设置新值会替换旧值。设置不存在的 ID 会抛出 `UNKNOWN_CLASSIFICATION`。分类变化支持撤销重做,也会进入 `getSnapshot()`。
## 标注查询和历史记录
```ts
const annotations = editor.queryAnnotations({
x: 0,
y: 0,
width: 500,
height: 500,
})
if (editor.canUndo()) editor.undo()
if (editor.canRedo()) editor.redo()
```
函数式版本为 `addRect`、`addPolygon`、`addMask`、`updateAnnotation`、`updateAnnotationLabel`、`removeAnnotation`、`queryAnnotations`、`undo`、`redo`、`canUndo` 和 `canRedo`。这些函数的第一个参数都是 `annotator`。
## 标签管理
```ts
editor.addLabel({ id: 'person', name: '人物', color: '#ff4d4f' })
editor.setActiveLabel('person')
const activeLabelId = editor.getActiveLabel()
editor.updateLabel('person', {
name: '行人',
color: '#ff7875',
})
```
函数式版本:`addLabel`、`setActiveLabel`、`getActiveLabel`、`updateLabel`。
## 图片与视图
```ts
import { createStandardImageSource } from 'cs-label-tool'
await editor.setImage(createStandardImageSource('/images/example.webp'))
editor.fitToScreen()
editor.zoomTo(2)
editor.zoomTo(2, { x: 100, y: 100 })
editor.zoomBy(1.25)
editor.panBy({ x: 100, y: 50 })
console.log(editor.getZoom())
console.log(editor.hasImage())
// 容器尺寸发生变化后调用
editor.resizeViewport()
```
函数式 API 还提供坐标转换:
```ts
import { imageToClient, clientToImage } from 'cs-label-tool'
const clientPoint = imageToClient(annotator, { x: 100, y: 100 })
const imagePoint = clientToImage(annotator, clientPoint)
```
## 事件与快照
```ts
const unsubscribe = editor.subscribe('change', event => {
switch (event.kind) {
case 'annotation:add':
case 'annotation:update':
case 'annotation:remove':
console.log(editor.snapshot().annotations)
break
case 'selection:update':
console.log(editor.tools.selection())
break
}
})
const snapshot = editor.snapshot()
const json = JSON.stringify(snapshot)
unsubscribe()
```
快照结构:
```ts
interface AnnotationSnapshot {
readonly schemaVersion: 1
readonly revision: number
readonly annotations: readonly Annotation[]
readonly labels: readonly LabelDefinition[]
readonly classificationOptions?: readonly ClassificationOption[]
readonly classificationId?: string | null
}
```
## 自定义工具
```ts
import { addRect, type Tool } from 'cs-label-tool'
const pointMarker: Tool = {
id: 'point-marker',
name: '点标记',
description: '创建一个 8 x 8 的点标记',
cursor: 'crosshair',
category: 'drawing',
shortcuts: [{ key: 'm' }],
handle(input, context) {
if (input.type !== 'down') return
addRect(context.annotator, {
labelId: 'person',
x: input.imagePoint.x - 4,
y: input.imagePoint.y - 4,
width: 8,
height: 8,
})
},
cancel() {},
}
editor.registerTool(pointMarker)
editor.activateToolById('point-marker')
console.log(editor.getTool('point-marker'))
console.log(editor.listTools())
console.log(editor.listToolsByCategory('drawing'))
editor.unregisterTool('point-marker')
```
也可以通过统一工具 API 激活工具对象:
```ts
editor.tools.register(pointMarker)
editor.tools.activateById('point-marker')
editor.tools.listByCategory('drawing')
editor.tools.unregister('point-marker')
// 不注册也可以直接激活一个 Tool 对象
editor.tools.activate(pointMarker)
```
## 三种入口的区别
| 入口 | 返回值 | 适用场景 |
| -------------------------------- | ------------------- | --------------------------- |
| `mount('#app')` | `AnnotatorInstance` | 推荐,带 Web Component UI 和绑定方法 |
| `create({ container })` | `AnnotatorInstance` | 自建 UI,使用绑定方法 |
| `mountAnnotator/createAnnotator` | 原始 `Annotator` | 函数式 API、框架适配 |
| 默认导出 `csLabelTool` | 函数集合 | 需要单一命名空间时 |
默认导出示例:
```ts
import csLabelTool from 'cs-label-tool'
const annotator = csLabelTool.mount('#app', { historyLimit: 100 })
csLabelTool.addLabel(annotator, {
id: 'person',
name: '人物',
color: '#ff4d4f',
})
await csLabelTool.setImage(
annotator,
csLabelTool.createImageSource('/a.webp'),
)
const tools = csLabelTool.createToolApi(annotator)
tools.brush({ size: 24 })
```
## Web Component
```ts
import {
defineAnnotatorElements,
type CSAnnotatorElement,
} from 'cs-label-tool'
defineAnnotatorElements()
const element = document.createElement('cs-annotator') as CSAnnotatorElement
document.body.append(element)
const annotator = element.configure({ historyLimit: 100 })
```
卸载自动挂载的组件:
```ts
import { unmountAnnotator } from 'cs-label-tool'
unmountAnnotator('#app')
```
## API 名字清单
这里按用途列出包根目录公开导出的 API。实例方法通常省略第一个 `annotator` 参数,功能与函数式版本相同。
### 创建和销毁
| API | 用途 |
| ------------------------- | ------------------------------------------- |
| `mount` | 创建带默认 Web Component 界面的 `AnnotatorInstance` |
| `create` | 在指定容器创建 `AnnotatorInstance`,界面由项目自己做 |
| `mountAnnotator` | 创建带默认界面的原始 `Annotator` |
| `createAnnotator` | 创建原始 `Annotator` |
| `destroyAnnotator` | 销毁原始 `Annotator` |
| `getSnapshot` | 获取只读快照 |
| `defineAnnotatorElements` | 注册 `` |
| `unmountAnnotator` | 卸载默认组件 |
### 标注和标签
| API | 用途 |
| -------------------------------------------------------------------------------- | ------------ |
| `addPoint` / `addRect` / `addEllipse` / `addPolyline` / `addPolygon` / `addMask` | 直接添加标注 |
| `updateAnnotation` | 修改标注几何 |
| `updateAnnotationLabel` | 修改单条标注的标签 |
| `removeAnnotation` | 删除单条标注 |
| `queryAnnotations` | 查询指定图片区域里的标注 |
| `undo` / `redo` | 撤销和重做 |
| `canUndo` / `canRedo` | 判断当前能否撤销或重做 |
| `addLabel` / `updateLabel` | 添加或修改标签 |
| `setActiveLabel` / `getActiveLabel` | 设置或读取当前绘制标签 |
| `setClassificationOptions` / `getClassificationOptions` | 设置或读取图片分类选项 |
| `setImageClassification` / `getImageClassification` / `clearImageClassification` | 设置、读取或清空单选分类 |
### 内置工具和选择
| API | 用途 |
| -------------------------------------------------------------------- | ----------------------- |
| `createToolApi` | 创建绑定到某个 annotator 的工具对象 |
| `useSelect` / `useLasso` | 启用选择或套索选择工具 |
| `useFreehand` | 启用自由轮廓绘制工具 |
| `usePoint` / `useRect` / `useEllipse` / `usePolyline` / `usePolygon` | 启用矢量绘制工具 |
| `useBrush` / `useEraser` | 启用涂抹、橡皮擦工具 |
| `getActiveToolId` | 读取当前工具 ID |
| `cancelActiveGesture` | 取消当前未完成操作 |
| `selectAnnotation` / `clearSelection` / `getSelection` | 管理选择状态 |
| `updateSelectedAnnotationsLabel` | 修改选中标注的标签 |
| `deleteSelectedAnnotations` | 删除选中标注 |
| `selectAnnotations` / `toggleAnnotationSelection` | 多选或切换单条选择 |
| `selectAnnotationsInBounds` / `selectAnnotationsInLasso` | 按框或套索相交选择 |
| `groupAnnotations` / `ungroupAnnotations` | 分组或取消分组 |
| `setAnnotationsLocked` / `setAnnotationsHidden` | 批量锁定或隐藏 |
| `bringForward` / `sendBackward` / `bringToFront` / `sendToBack` | 调整图层顺序 |
| `copyAnnotations` / `pasteAnnotations` / `duplicateAnnotations` | 内部复制、粘贴和克隆 |
### 工具注册
| API | 用途 |
| ----------------------------------------------------- | ---------------- |
| `activateTool` / `activateToolById` | 激活工具对象或指定 ID 的工具 |
| `registerTool` / `unregisterTool` | 注册和移除自定义工具 |
| `getTool` / `listTools` / `listToolsByCategory` | 查询工具 |
| `getRegisteredTools` / `getRegisteredToolsByCategory` | 读取底层注册表中的工具 |
| `createToolRegistry` / `createDefaultToolRegistry` | 创建空注册表或默认注册表 |
### 图片和视图
| API | 用途 |
| --------------------------------- | ------------------------------ |
| `createStandardImageSource` | 从 URL、Blob 或 ImageBitmap 创建图片源 |
| `setImageSource` | 加载图片 |
| `hasImage` | 判断图片是否加载完成 |
| `fitToScreen` | 让整张图片进入画布 |
| `zoomTo` / `zoomBy` / `getZoom` | 设置、调整和读取缩放比例 |
| `panBy` | 平移画布 |
| `resizeViewport` | 容器尺寸变化后更新画布 |
| `imageToClient` / `clientToImage` | 原图坐标与浏览器坐标互转 |
### 几何和 Mask
| API | 用途 |
| ------------------------------------------------ | ------------------- |
| `normalizeRect` / `pointInRect` | 普通矩形计算和命中判断 |
| `normalizeRotation` / `pointInRotatedRect` | 角度整理和旋转矩形精确命中 |
| `getRotatedRectCorners` / `getRotatedRectBounds` | 旋转矩形顶点和外接包围盒 |
| `rectLocalToWorld` / `rectWorldToLocal` | 矩形局部坐标与图片坐标互转 |
| `pointInPolygon` / `validatePolygon` | 多边形命中判断和有效性检查 |
| `moveRect` / `resizeRect` / `rotateRect` | 计算移动、缩放或旋转后的矩形 |
| `getRectHandlePoints` | 计算八个缩放点和旋转手柄位置 |
| `movePolygonVertex` / `removePolygonVertex` | 移动或删除多边形顶点 |
| `createBrushMaskGeometry` | 把笔迹点转成 MaskGeometry |
| `encodeBinaryMaskRle` / `decodeBinaryMaskRle` | 二进制 mask 与 RLE 互转 |
| `getBinaryMaskBounds` | 计算 mask 的实际像素边界 |
| `translateBinaryMask` | 平移 mask 像素 |
| `splitBinaryMaskComponents` | 把不相连的区域拆成多个 mask |
| `binaryMasksWithinDistance` | 判断两块 mask 是否足够接近 |
### 底层视口、空间索引和工具状态机
这些 API 主要给自定义渲染器、框架适配或自定义工具使用。普通业务通常用不到。
| API | 用途 |
| ----------------------------------------------------------- | ------------------- |
| `createViewport` / `fitViewport` | 创建和适配 viewport |
| `imageToScreen` / `screenToImage` | 原图坐标与 viewport 坐标互转 |
| `zoomAt` / `panViewport` | 计算缩放或平移后的 viewport |
| `createGridIndex` | 创建空间索引 |
| `insertSpatialItem` / `updateSpatialItem` | 添加或更新索引项 |
| `removeSpatialItem` / `querySpatialBounds` | 删除或查询索引项 |
| `createSelectTool` / `createRectTool` / `createPolygonTool` | 创建底层交互工具 |
| `createFreehandTool` | 创建自由轮廓底层工具 |
| `createBrushTool` / `createEraserTool` | 创建底层 mask 工具 |
| `createRectToolState` / `reduceRectTool` | 单独使用矩形状态机 |
| `createPolygonToolState` / `reducePolygonTool` | 单独使用多边形状态机 |
### 事件
| API | 用途 |
| ------------------------------------------ | ------------------ |
| `subscribe(annotator, 'change', listener)` | 监听标注、标签、图片、选择和历史变化 |
| `subscribe(annotator, 'error', listener)` | 监听订阅回调抛出的错误 |
公共类型包括 `Annotator`、`AnnotatorInstance`、`Annotation`、`PolygonAnnotation`、`RectGeometry`、`PolygonGeometry`、`MaskGeometry`、`FreehandToolOptions`、`LabelDefinition`、`Tool`、`AnnotationToolApi`、`ImageSource`、`ViewportState`、`Point` 和 `Bounds`。
## 核心类型
```ts
interface RectGeometry {
readonly type: 'rect'
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly rotation?: number // 顺时针角度,单位为度
}
interface PolygonGeometry {
readonly type: 'polygon'
readonly points: readonly (readonly [number, number])[]
}
interface PointGeometry {
readonly type: 'point'
readonly x: number
readonly y: number
}
interface PolylineGeometry {
readonly type: 'polyline'
readonly points: readonly (readonly [number, number])[]
}
interface EllipseGeometry {
readonly type: 'ellipse'
readonly cx: number
readonly cy: number
readonly radiusX: number
readonly radiusY: number
readonly rotation?: number
}
interface MaskGeometry {
readonly type: 'mask'
readonly width: number
readonly height: number
readonly rle: readonly number[]
}
interface AnnotationBase {
readonly groupId?: string
readonly locked?: boolean
readonly hidden?: boolean
}
type Annotation =
| PointAnnotation
| RectAnnotation
| EllipseAnnotation
| PolylineAnnotation
| PolygonAnnotation
| MaskAnnotation
type ToolCategory = 'selection' | 'drawing' | 'navigation' | 'utility'
```
## 开发命令
```bash
npm run dev
npm run typecheck
npm test
npm run test:e2e
npm run build
```