@zzalai/leafer-point-annotation 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/README_EN.md +308 -0
- package/docs/assets/index-DGiYiG5f.css +1 -0
- package/docs/assets/index-L8gL3x2V.js +1 -0
- package/docs/index.html +14 -0
- package/index.html +13 -0
- package/package.json +64 -0
- package/project-docs/ARCHITECTURE.md +401 -0
- package/project-docs/IMPLEMENTATION_PLAN.md +196 -0
- package/project-docs/REQUIREMENTS.md +517 -0
- package/project-docs/TODO.md +167 -0
- package/project-docs/leafer-development-guide/LEAFER_DEVELOPMENT_GUIDE.md +835 -0
- package/project-docs/leafer-development-guide/LEAFER_UNDO_REDO_GUIDE.md +329 -0
- package/project-docs/leafer-development-guide/TINYKEYS_GUIDE.md +407 -0
- package/src/App.vue +464 -0
- package/src/components/BrushSizeSlider.vue +190 -0
- package/src/components/BrushStylePanel.vue +295 -0
- package/src/components/PointAnnotation.vue +1663 -0
- package/src/elements/PointAnnotationElement.ts +155 -0
- package/src/index.ts +4 -0
- package/src/main.ts +4 -0
- package/src/types/index.ts +122 -0
- package/src/utils/BrushCommands.ts +47 -0
- package/src/utils/BrushStroke.ts +96 -0
- package/src/utils/COCOExporter.ts +90 -0
- package/src/utils/CanvasBrush.ts +179 -0
- package/src/utils/PointCommands.ts +74 -0
- package/src/utils/YOLOExporter.ts +39 -0
- package/src/vite-env.d.ts +7 -0
- package/tsconfig.json +24 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +42 -0
- package/vite.docs.config.ts +28 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
# LeaferJS 开发指南 - 点标注与笔刷工具项目实战
|
|
2
|
+
|
|
3
|
+
## 目录
|
|
4
|
+
|
|
5
|
+
1. [LeaferJS 核心概念](#leaferjs-核心概念)
|
|
6
|
+
2. [项目架构设计](#项目架构设计)
|
|
7
|
+
3. [关键技术实现](#关键技术实现)
|
|
8
|
+
4. [性能优化技巧](#性能优化技巧)
|
|
9
|
+
5. [常见问题解决方案](#常见问题解决方案)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## LeaferJS 核心概念
|
|
14
|
+
|
|
15
|
+
### 1.1 LeaferUI 基础
|
|
16
|
+
|
|
17
|
+
LeaferUI 是一个高性能的 2D 图形渲染引擎,基于 Canvas 实现。项目使用 LeaferUI 2.0.8+ 版本。
|
|
18
|
+
|
|
19
|
+
#### 核心类
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { App, Group, Ellipse, Text, Image, Canvas, PointerEvent, ZoomEvent } from 'leafer-ui'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| 类名 | 说明 |
|
|
26
|
+
|------|------|
|
|
27
|
+
| App | 应用实例,管理整个画布 |
|
|
28
|
+
| Group | 组容器,用于组织多个元素 |
|
|
29
|
+
| Ellipse | 椭圆/圆形元素 |
|
|
30
|
+
| Text | 文本元素 |
|
|
31
|
+
| Image | 图片元素 |
|
|
32
|
+
| Canvas | Canvas 元素,用于自定义绘制 |
|
|
33
|
+
| PointerEvent | 指针事件 |
|
|
34
|
+
| ZoomEvent | 缩放事件 |
|
|
35
|
+
|
|
36
|
+
#### 常用方法
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// 创建应用
|
|
40
|
+
const app = new App({
|
|
41
|
+
view: container, // DOM 容器
|
|
42
|
+
width: 800,
|
|
43
|
+
height: 600,
|
|
44
|
+
fill: '#f5f5f5'
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// 添加元素
|
|
48
|
+
const group = new Group()
|
|
49
|
+
app.tree.add(group)
|
|
50
|
+
|
|
51
|
+
// 元素操作
|
|
52
|
+
element.set({ x: 100, y: 100 })
|
|
53
|
+
element.destroy()
|
|
54
|
+
|
|
55
|
+
// 视口操作
|
|
56
|
+
app.tree.zoom(2) // 缩放
|
|
57
|
+
app.tree.x = 100 // 平移
|
|
58
|
+
app.tree.y = 100
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 1.2 插件系统
|
|
62
|
+
|
|
63
|
+
项目使用多个 Leafer 插件:
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import '@leafer-in/editor' // 编辑器插件
|
|
67
|
+
import '@leafer-in/resize' // 缩放插件
|
|
68
|
+
import '@leafer-in/viewport' // 视口插件
|
|
69
|
+
import '@leafer-in/view' // 视图插件
|
|
70
|
+
import '@leafer-in/text-editor' // 文本编辑插件
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
#### Editor 插件使用
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// 启用/禁用编辑器
|
|
77
|
+
app.editor.config.moveable = true
|
|
78
|
+
app.editor.config.resizeable = false
|
|
79
|
+
app.editor.config.multipleSelect = true
|
|
80
|
+
|
|
81
|
+
// 获取选中元素
|
|
82
|
+
const selected = app.editor.list
|
|
83
|
+
|
|
84
|
+
// 选中元素
|
|
85
|
+
app.editor.select(element)
|
|
86
|
+
|
|
87
|
+
// 取消选中
|
|
88
|
+
app.editor.cancel()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 1.3 事件系统
|
|
92
|
+
|
|
93
|
+
LeaferUI 提供完整的事件系统:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
// 指针事件
|
|
97
|
+
app.on(PointerEvent.DOWN, handleDown)
|
|
98
|
+
app.on(PointerEvent.MOVE, handleMove)
|
|
99
|
+
app.on(PointerEvent.UP, handleUp)
|
|
100
|
+
app.on(PointerEvent.TAP, handleTap)
|
|
101
|
+
|
|
102
|
+
// 缩放事件
|
|
103
|
+
app.on(ZoomEvent.ZOOM, handleZoom)
|
|
104
|
+
|
|
105
|
+
// 编辑器事件
|
|
106
|
+
import { EditorEvent } from '@leafer-in/editor'
|
|
107
|
+
app.editor.on(EditorEvent.SELECT, handleSelect)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### 坐标转换
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
// 获取相对于内容层的坐标
|
|
114
|
+
const point = contentLayer.getBoxPoint({ x: event.x, y: event.y })
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 项目架构设计
|
|
120
|
+
|
|
121
|
+
### 2.1 组件架构
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
PointAnnotation (主组件)
|
|
125
|
+
├── Canvas 容器与状态管理
|
|
126
|
+
├── 工具栏 UI
|
|
127
|
+
├── 笔刷配置面板
|
|
128
|
+
├── 缩放控制器
|
|
129
|
+
└── 核心业务逻辑
|
|
130
|
+
├── 工具切换
|
|
131
|
+
├── 事件处理
|
|
132
|
+
├── 命令管理
|
|
133
|
+
├── 数据导出
|
|
134
|
+
└── 图片加载
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### 2.2 图层设计
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
app.tree (根层)
|
|
141
|
+
├── contentLayer (内容层)
|
|
142
|
+
│ ├── imageBox (图片)
|
|
143
|
+
│ └── brushGroup (笔刷组)
|
|
144
|
+
│ └── canvasBrush (Canvas 元素)
|
|
145
|
+
└── pointLayer (点标注层)
|
|
146
|
+
├── point1 (点标注元素)
|
|
147
|
+
├── point2
|
|
148
|
+
└── ...
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
#### 关键设计:使用 Group 控制透明度
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
// CanvasBrush 类设计
|
|
155
|
+
class CanvasBrush {
|
|
156
|
+
private group: Group
|
|
157
|
+
private canvas: Canvas
|
|
158
|
+
private ctx: CanvasRenderingContext2D
|
|
159
|
+
|
|
160
|
+
constructor(width: number, height: number) {
|
|
161
|
+
this.group = new Group()
|
|
162
|
+
this.canvas = new Canvas({ width, height })
|
|
163
|
+
this.group.add(this.canvas)
|
|
164
|
+
|
|
165
|
+
// 在 Group 上设置透明度,避免 Canvas 上多次叠加
|
|
166
|
+
this.group.opacity = 0.55
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### 2.3 元素封装
|
|
172
|
+
|
|
173
|
+
自定义元素需要继承 Group 并实现特定功能:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import { Group, Ellipse, Text } from 'leafer-ui'
|
|
177
|
+
|
|
178
|
+
export class PointAnnotationElement extends Group {
|
|
179
|
+
public circle: Ellipse
|
|
180
|
+
public label: Text
|
|
181
|
+
public _element_tag = 'point-annotation'
|
|
182
|
+
public data: PointAnnotation
|
|
183
|
+
|
|
184
|
+
constructor(data: PointAnnotation, style: PointStyle) {
|
|
185
|
+
super()
|
|
186
|
+
|
|
187
|
+
this.data = data
|
|
188
|
+
this.id = data.id
|
|
189
|
+
this.x = data.pixel.x
|
|
190
|
+
this.y = data.pixel.y
|
|
191
|
+
|
|
192
|
+
this.initCircle(style)
|
|
193
|
+
this.initLabel(data.label, style)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## 关键技术实现
|
|
201
|
+
|
|
202
|
+
### 3.1 笔刷绘制实现
|
|
203
|
+
|
|
204
|
+
#### 核心原理
|
|
205
|
+
|
|
206
|
+
使用 LeaferJS 的 Canvas 元素,通过原生 Canvas 2D API 绘制:
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
class CanvasBrush {
|
|
210
|
+
private canvas: Canvas
|
|
211
|
+
private ctx: CanvasRenderingContext2D
|
|
212
|
+
private lastPoint: { x: number; y: number } | null = null
|
|
213
|
+
|
|
214
|
+
constructor(width: number, height: number) {
|
|
215
|
+
this.canvas = new Canvas({ width, height })
|
|
216
|
+
this.ctx = this.canvas.context as CanvasRenderingContext2D
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 绘制点
|
|
220
|
+
drawPoint(x: number, y: number, size: number, color: string) {
|
|
221
|
+
this.ctx.fillStyle = color
|
|
222
|
+
this.ctx.beginPath()
|
|
223
|
+
this.ctx.arc(x, y, size / 2, 0, Math.PI * 2)
|
|
224
|
+
this.ctx.fill()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 连线(保证连续性)
|
|
228
|
+
drawLine(x1: number, y1: number, x2: number, y2: number, size: number, color: string) {
|
|
229
|
+
this.ctx.strokeStyle = color
|
|
230
|
+
this.ctx.lineWidth = size
|
|
231
|
+
this.ctx.lineCap = 'round'
|
|
232
|
+
this.ctx.lineJoin = 'round'
|
|
233
|
+
this.ctx.beginPath()
|
|
234
|
+
this.ctx.moveTo(x1, y1)
|
|
235
|
+
this.ctx.lineTo(x2, y2)
|
|
236
|
+
this.ctx.stroke()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 绘制方法
|
|
240
|
+
draw(x: number, y: number, size: number, color: string, continuity: number) {
|
|
241
|
+
if (this.lastPoint) {
|
|
242
|
+
const dx = x - this.lastPoint.x
|
|
243
|
+
const dy = y - this.lastPoint.y
|
|
244
|
+
const distance = Math.sqrt(dx * dx + dy * dy)
|
|
245
|
+
|
|
246
|
+
if (distance > continuity) {
|
|
247
|
+
// 距离超过阈值,连线
|
|
248
|
+
this.drawLine(this.lastPoint.x, this.lastPoint.y, x, y, size, color)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this.drawPoint(x, y, size, color)
|
|
253
|
+
this.lastPoint = { x, y }
|
|
254
|
+
|
|
255
|
+
// 触发重绘
|
|
256
|
+
this.canvas.paint()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 擦除方法
|
|
260
|
+
erase(x: number, y: number, size: number, continuity: number) {
|
|
261
|
+
if (this.lastPoint) {
|
|
262
|
+
const dx = x - this.lastPoint.x
|
|
263
|
+
const dy = y - this.lastPoint.y
|
|
264
|
+
const distance = Math.sqrt(dx * dx + dy * dy)
|
|
265
|
+
|
|
266
|
+
if (distance > continuity) {
|
|
267
|
+
this.ctx.globalCompositeOperation = 'destination-out'
|
|
268
|
+
this.drawLine(this.lastPoint.x, this.lastPoint.y, x, y, size, '#000000')
|
|
269
|
+
this.ctx.globalCompositeOperation = 'source-over'
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
this.ctx.globalCompositeOperation = 'destination-out'
|
|
274
|
+
this.drawPoint(x, y, size, '#000000')
|
|
275
|
+
this.ctx.globalCompositeOperation = 'source-over'
|
|
276
|
+
this.lastPoint = { x, y }
|
|
277
|
+
this.canvas.paint()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 检测是否有内容
|
|
281
|
+
hasContent(): boolean {
|
|
282
|
+
if (this.canvas.width === 0 || this.canvas.height === 0) return false
|
|
283
|
+
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
|
|
284
|
+
const data = imageData.data
|
|
285
|
+
for (let i = 3; i < data.length; i += 4) {
|
|
286
|
+
if (data[i] > 0) return true
|
|
287
|
+
}
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 获取图片数据
|
|
292
|
+
getImageData(): string {
|
|
293
|
+
return this.canvas.toDataURL('image/png')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 恢复图片数据
|
|
297
|
+
restoreImageData(imageData: string): Promise<void> {
|
|
298
|
+
return new Promise((resolve) => {
|
|
299
|
+
const img = document.createElement('img')
|
|
300
|
+
img.onload = () => {
|
|
301
|
+
this.ctx.drawImage(img, 0, 0)
|
|
302
|
+
this.canvas.paint()
|
|
303
|
+
resolve()
|
|
304
|
+
}
|
|
305
|
+
img.src = imageData
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 清除
|
|
310
|
+
clear() {
|
|
311
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
|
312
|
+
this.canvas.paint()
|
|
313
|
+
this.lastPoint = null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 控制事件拦截
|
|
317
|
+
setPointerEvents(value: boolean) {
|
|
318
|
+
this.canvas.pointerEvents = value ? 'all' : 'none'
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### 3.2 撤销/重做系统
|
|
324
|
+
|
|
325
|
+
使用 `@zzalai/leafer-undo-redo` 插件,实现命令模式:
|
|
326
|
+
|
|
327
|
+
#### 命令接口
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
interface ICommand {
|
|
331
|
+
execute(): void
|
|
332
|
+
undo(): void
|
|
333
|
+
redo(): void
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
#### 点标注添加命令
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
export class AddPointCommand implements ICommand {
|
|
341
|
+
private container: Group
|
|
342
|
+
private element: PointAnnotationElement
|
|
343
|
+
private dataArray: PointAnnotation[]
|
|
344
|
+
private pointData: PointAnnotation
|
|
345
|
+
|
|
346
|
+
constructor(
|
|
347
|
+
container: Group,
|
|
348
|
+
element: PointAnnotationElement,
|
|
349
|
+
dataArray: PointAnnotation[],
|
|
350
|
+
pointData: PointAnnotation
|
|
351
|
+
) {
|
|
352
|
+
this.container = container
|
|
353
|
+
this.element = element
|
|
354
|
+
this.dataArray = dataArray
|
|
355
|
+
this.pointData = pointData
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
execute(): void {
|
|
359
|
+
this.container.add(this.element)
|
|
360
|
+
this.dataArray.push(this.pointData)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
undo(): void {
|
|
364
|
+
this.container.remove(this.element)
|
|
365
|
+
const index = this.dataArray.findIndex(p => p.id === this.pointData.id)
|
|
366
|
+
if (index > -1) {
|
|
367
|
+
this.dataArray.splice(index, 1)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
redo(): void {
|
|
372
|
+
this.execute()
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
#### 点标注删除命令
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
export class RemovePointCommand implements ICommand {
|
|
381
|
+
private container: Group
|
|
382
|
+
private element: PointAnnotationElement
|
|
383
|
+
private dataArray: PointAnnotation[]
|
|
384
|
+
private data: PointAnnotation
|
|
385
|
+
private index: number
|
|
386
|
+
|
|
387
|
+
constructor(container: Group, element: PointAnnotationElement, dataArray: PointAnnotation[]) {
|
|
388
|
+
this.container = container
|
|
389
|
+
this.element = element
|
|
390
|
+
this.dataArray = dataArray
|
|
391
|
+
this.data = element.data
|
|
392
|
+
this.index = dataArray.findIndex(p => p.id === element.id)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
execute(): void {
|
|
396
|
+
this.container.remove(this.element)
|
|
397
|
+
if (this.index > -1) {
|
|
398
|
+
this.dataArray.splice(this.index, 1)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
undo(): void {
|
|
403
|
+
this.container.add(this.element)
|
|
404
|
+
if (this.index > -1) {
|
|
405
|
+
this.dataArray.splice(this.index, 0, this.data)
|
|
406
|
+
} else {
|
|
407
|
+
this.dataArray.push(this.data)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
redo(): void {
|
|
412
|
+
this.execute()
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
#### 笔刷快照命令
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
export class BrushSnapshotCommand implements ICommand {
|
|
421
|
+
private brush: CanvasBrush
|
|
422
|
+
private beforeImage: string
|
|
423
|
+
private afterImage: string | null = null
|
|
424
|
+
private isClear: boolean
|
|
425
|
+
|
|
426
|
+
constructor(brush: CanvasBrush, beforeImage: string, isClear: boolean = false) {
|
|
427
|
+
this.brush = brush
|
|
428
|
+
this.beforeImage = beforeImage
|
|
429
|
+
this.isClear = isClear
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
execute(): void {
|
|
433
|
+
if (this.isClear) {
|
|
434
|
+
// 用于清除操作,execute 保存当前状态,undo 恢复
|
|
435
|
+
this.afterImage = this.brush.getImageData()
|
|
436
|
+
this.brush.clear()
|
|
437
|
+
} else {
|
|
438
|
+
// 普通操作,execute 已在绘制时完成
|
|
439
|
+
this.afterImage = this.brush.getImageData()
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
undo(): void {
|
|
444
|
+
this.brush.restoreImageData(this.beforeImage)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
redo(): void {
|
|
448
|
+
if (this.afterImage) {
|
|
449
|
+
this.brush.restoreImageData(this.afterImage)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
#### 命令管理器使用
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
import { CommandManager } from '@zzalai/leafer-undo-redo'
|
|
459
|
+
|
|
460
|
+
// 初始化
|
|
461
|
+
const commandManager = new CommandManager(100)
|
|
462
|
+
|
|
463
|
+
// 执行命令
|
|
464
|
+
commandManager.executeCommand(new AddPointCommand(container, element, dataArray, data))
|
|
465
|
+
|
|
466
|
+
// 撤销
|
|
467
|
+
if (commandManager.canUndo()) {
|
|
468
|
+
commandManager.undo()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 重做
|
|
472
|
+
if (commandManager.canRedo()) {
|
|
473
|
+
commandManager.redo()
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### 3.3 导出功能实现
|
|
478
|
+
|
|
479
|
+
#### JSON 导出
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
exportCanvasJSON(): string {
|
|
483
|
+
const exportData = {
|
|
484
|
+
version: '1.0',
|
|
485
|
+
imageUrl: props.imageSource.url || '',
|
|
486
|
+
imageWidth: imageWidth.value,
|
|
487
|
+
imageHeight: imageHeight.value,
|
|
488
|
+
pointAnnotations: [...pointAnnotations.value],
|
|
489
|
+
brushMask: canvasBrush?.getImageData() || null,
|
|
490
|
+
exportTime: Date.now()
|
|
491
|
+
}
|
|
492
|
+
return JSON.stringify(exportData, null, 2)
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
#### COCO 格式导出
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
export function exportCOCOFormat(
|
|
500
|
+
annotations: PointAnnotation[],
|
|
501
|
+
imageUrl: string,
|
|
502
|
+
imageWidth: number,
|
|
503
|
+
imageHeight: number
|
|
504
|
+
): COCOExport {
|
|
505
|
+
const cocoData: COCOExport = {
|
|
506
|
+
info: {
|
|
507
|
+
description: 'Point Annotation Export',
|
|
508
|
+
version: '1.0',
|
|
509
|
+
year: new Date().getFullYear(),
|
|
510
|
+
date_created: new Date().toISOString().split('T')[0]
|
|
511
|
+
},
|
|
512
|
+
licenses: [],
|
|
513
|
+
images: [
|
|
514
|
+
{
|
|
515
|
+
id: 1,
|
|
516
|
+
file_name: imageUrl.split('/').pop() || 'image.jpg',
|
|
517
|
+
width: imageWidth,
|
|
518
|
+
height: imageHeight
|
|
519
|
+
}
|
|
520
|
+
],
|
|
521
|
+
annotations: annotations.map((anno, index) => {
|
|
522
|
+
const radius = 12
|
|
523
|
+
return {
|
|
524
|
+
id: index + 1,
|
|
525
|
+
image_id: 1,
|
|
526
|
+
category_id: 1,
|
|
527
|
+
keypoints: [anno.pixel.x, anno.pixel.y, 2],
|
|
528
|
+
num_keypoints: 1,
|
|
529
|
+
bbox: [anno.pixel.x - radius, anno.pixel.y - radius, radius * 2, radius * 2],
|
|
530
|
+
area: Math.PI * radius * radius,
|
|
531
|
+
iscrowd: 0
|
|
532
|
+
}
|
|
533
|
+
}),
|
|
534
|
+
categories: [
|
|
535
|
+
{
|
|
536
|
+
id: 1,
|
|
537
|
+
name: 'point',
|
|
538
|
+
keypoints: ['point'],
|
|
539
|
+
skeleton: []
|
|
540
|
+
}
|
|
541
|
+
]
|
|
542
|
+
}
|
|
543
|
+
return cocoData
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
#### YOLO 格式导出
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
export function exportYOLOFormat(
|
|
551
|
+
annotations: PointAnnotation[],
|
|
552
|
+
imageWidth: number,
|
|
553
|
+
imageHeight: number
|
|
554
|
+
): YOLOExport {
|
|
555
|
+
const lines = annotations.map((anno) => {
|
|
556
|
+
const x = anno.normalized.x
|
|
557
|
+
const y = anno.normalized.y
|
|
558
|
+
const w = 24 / imageWidth
|
|
559
|
+
const h = 24 / imageHeight
|
|
560
|
+
return `0 ${x} ${y} ${w} ${h}`
|
|
561
|
+
})
|
|
562
|
+
return {
|
|
563
|
+
annotations: lines.join('\n'),
|
|
564
|
+
classNames: 'point'
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
#### 二值图导出
|
|
570
|
+
|
|
571
|
+
```typescript
|
|
572
|
+
async function exportMaskImage(
|
|
573
|
+
format: 'png' | 'jpg' | 'jpeg' = 'png',
|
|
574
|
+
fgColor: 'black' | 'white' = 'black'
|
|
575
|
+
): Promise<string | null> {
|
|
576
|
+
const maskData = canvasBrush?.getImageData()
|
|
577
|
+
if (!maskData) return null
|
|
578
|
+
|
|
579
|
+
return new Promise((resolve) => {
|
|
580
|
+
const img = document.createElement('img')
|
|
581
|
+
img.onload = () => {
|
|
582
|
+
const canvas = document.createElement('canvas')
|
|
583
|
+
canvas.width = imageWidth.value || 0
|
|
584
|
+
canvas.height = imageHeight.value || 0
|
|
585
|
+
const ctx = canvas.getContext('2d')
|
|
586
|
+
if (!ctx) {
|
|
587
|
+
resolve(null)
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
ctx.drawImage(img, 0, 0)
|
|
592
|
+
|
|
593
|
+
const isWhite = fgColor === 'white'
|
|
594
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
|
595
|
+
const data = imageData.data
|
|
596
|
+
|
|
597
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
598
|
+
if (data[i + 3] > 0) {
|
|
599
|
+
data[i] = isWhite ? 255 : 0
|
|
600
|
+
data[i + 1] = isWhite ? 255 : 0
|
|
601
|
+
data[i + 2] = isWhite ? 255 : 0
|
|
602
|
+
data[i + 3] = 255
|
|
603
|
+
} else if (format !== 'png') {
|
|
604
|
+
data[i] = isWhite ? 0 : 255
|
|
605
|
+
data[i + 1] = isWhite ? 0 : 255
|
|
606
|
+
data[i + 2] = isWhite ? 0 : 255
|
|
607
|
+
data[i + 3] = 255
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
ctx.putImageData(imageData, 0, 0)
|
|
611
|
+
|
|
612
|
+
if (format === 'png') {
|
|
613
|
+
resolve(canvas.toDataURL('image/png'))
|
|
614
|
+
} else {
|
|
615
|
+
resolve(canvas.toDataURL('image/jpeg', 0.95))
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
img.onerror = () => {
|
|
620
|
+
resolve(null)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
img.src = maskData
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### 3.4 热键系统
|
|
629
|
+
|
|
630
|
+
使用 `tinykeys` 实现热键:
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
import { tinykeys } from 'tinykeys'
|
|
634
|
+
|
|
635
|
+
const unsubscribe = tinykeys(window, {
|
|
636
|
+
V: (e) => {
|
|
637
|
+
if (!isCanvasFocused && !isMouseOverCanvas) return
|
|
638
|
+
e.preventDefault()
|
|
639
|
+
selectTool()
|
|
640
|
+
},
|
|
641
|
+
P: (e) => {
|
|
642
|
+
if (!isCanvasFocused && !isMouseOverCanvas) return
|
|
643
|
+
e.preventDefault()
|
|
644
|
+
pointTool()
|
|
645
|
+
},
|
|
646
|
+
B: (e) => {
|
|
647
|
+
if (!isCanvasFocused && !isMouseOverCanvas) return
|
|
648
|
+
e.preventDefault()
|
|
649
|
+
brushTool()
|
|
650
|
+
},
|
|
651
|
+
E: (e) => {
|
|
652
|
+
if (!isCanvasFocused && !isMouseOverCanvas) return
|
|
653
|
+
e.preventDefault()
|
|
654
|
+
eraserTool()
|
|
655
|
+
},
|
|
656
|
+
'$mod+KeyZ': (e) => {
|
|
657
|
+
if (!isCanvasFocused && !isMouseOverCanvas) return
|
|
658
|
+
e.preventDefault()
|
|
659
|
+
e.stopPropagation()
|
|
660
|
+
undo()
|
|
661
|
+
},
|
|
662
|
+
'$mod+KeyY': (e) => {
|
|
663
|
+
if (!isCanvasFocused && !isMouseOverCanvas) return
|
|
664
|
+
e.preventDefault()
|
|
665
|
+
e.stopPropagation()
|
|
666
|
+
redo()
|
|
667
|
+
},
|
|
668
|
+
Delete: (e) => {
|
|
669
|
+
if (!isCanvasFocused && !isMouseOverCanvas) return
|
|
670
|
+
e.preventDefault()
|
|
671
|
+
e.stopPropagation()
|
|
672
|
+
deleteSelected()
|
|
673
|
+
}
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
onUnmounted(() => {
|
|
677
|
+
unsubscribe()
|
|
678
|
+
})
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
---
|
|
682
|
+
|
|
683
|
+
## 性能优化技巧
|
|
684
|
+
|
|
685
|
+
### 4.1 渲染优化
|
|
686
|
+
|
|
687
|
+
1. **使用 Group 组织元素**
|
|
688
|
+
- 减少重绘区域
|
|
689
|
+
- 批量处理子元素
|
|
690
|
+
|
|
691
|
+
2. **合理使用 zIndex**
|
|
692
|
+
- 减少不必要的层级处理
|
|
693
|
+
- 固定层顺序
|
|
694
|
+
|
|
695
|
+
3. **使用 set() 批量更新**
|
|
696
|
+
```typescript
|
|
697
|
+
element.set({ x: 100, y: 100, width: 200 })
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
### 4.2 事件处理优化
|
|
701
|
+
|
|
702
|
+
1. **条件监听**
|
|
703
|
+
- 仅在需要时监听事件
|
|
704
|
+
- CanvasBrush 使用 pointerEvents 控制
|
|
705
|
+
|
|
706
|
+
2. **避免高频操作**
|
|
707
|
+
- 笔刷绘制使用 requestAnimationFrame 优化
|
|
708
|
+
- 避免在事件处理中执行复杂计算
|
|
709
|
+
|
|
710
|
+
### 4.3 内存管理
|
|
711
|
+
|
|
712
|
+
1. **及时销毁元素**
|
|
713
|
+
```typescript
|
|
714
|
+
element.destroy()
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
2. **限制历史记录**
|
|
718
|
+
```typescript
|
|
719
|
+
const commandManager = new CommandManager(100)
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
3. **重用 Canvas**
|
|
723
|
+
- 避免频繁创建新 Canvas 元素
|
|
724
|
+
|
|
725
|
+
---
|
|
726
|
+
|
|
727
|
+
## 常见问题解决方案
|
|
728
|
+
|
|
729
|
+
### 5.1 笔刷叠加透明度问题
|
|
730
|
+
|
|
731
|
+
**问题**:笔刷涂抹同一位置多次,透明度叠加导致颜色变深。
|
|
732
|
+
|
|
733
|
+
**解决方案**:使用 Group 控制整体透明度,而不是在 Canvas 上设置透明度。
|
|
734
|
+
|
|
735
|
+
```typescript
|
|
736
|
+
const group = new Group()
|
|
737
|
+
const canvas = new Canvas()
|
|
738
|
+
group.add(canvas)
|
|
739
|
+
group.opacity = 0.55 // 在 Group 上设置透明度
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
### 5.2 标签编辑权限控制
|
|
743
|
+
|
|
744
|
+
**问题**:任何工具都能编辑标签,容易误操作。
|
|
745
|
+
|
|
746
|
+
**解决方案**:根据当前工具动态控制标签可编辑性。
|
|
747
|
+
|
|
748
|
+
```typescript
|
|
749
|
+
function updateLabelEditable(editable: boolean) {
|
|
750
|
+
if (pointLayer && pointLayer.children) {
|
|
751
|
+
pointLayer.children.forEach((element: any) => {
|
|
752
|
+
if (element._element_tag === 'point-annotation' && element.label) {
|
|
753
|
+
element.label.editable = editable
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
### 5.3 Editor 状态冲突
|
|
761
|
+
|
|
762
|
+
**问题**:笔刷模式下 Editor 仍在工作,导致冲突。
|
|
763
|
+
|
|
764
|
+
**解决方案**:动态配置 Editor 状态。
|
|
765
|
+
|
|
766
|
+
```typescript
|
|
767
|
+
function brushTool() {
|
|
768
|
+
currentTool.value = 'brush'
|
|
769
|
+
app.editor.config.moveable = false
|
|
770
|
+
app.editor.config.resizeable = false
|
|
771
|
+
app.editor.config.multipleSelect = false
|
|
772
|
+
canvasBrush.setPointerEvents(true)
|
|
773
|
+
updateLabelEditable(false)
|
|
774
|
+
}
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
### 5.4 坐标转换问题
|
|
778
|
+
|
|
779
|
+
**问题**:不同层的坐标需要正确转换。
|
|
780
|
+
|
|
781
|
+
**解决方案**:使用 `getBoxPoint()` 方法。
|
|
782
|
+
|
|
783
|
+
```typescript
|
|
784
|
+
const point = contentLayer.getBoxPoint({ x: event.x, y: event.y })
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### 5.5 导出时图片加载问题
|
|
788
|
+
|
|
789
|
+
**问题**:异步图片加载导致导出不完整。
|
|
790
|
+
|
|
791
|
+
**解决方案**:使用 Promise 等待图片加载完成。
|
|
792
|
+
|
|
793
|
+
```typescript
|
|
794
|
+
restoreImageData(imageData: string): Promise<void> {
|
|
795
|
+
return new Promise((resolve) => {
|
|
796
|
+
const img = document.createElement('img')
|
|
797
|
+
img.onload = () => {
|
|
798
|
+
this.ctx.drawImage(img, 0, 0)
|
|
799
|
+
this.canvas.paint()
|
|
800
|
+
resolve()
|
|
801
|
+
}
|
|
802
|
+
img.src = imageData
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
### 5.6 点标注大小不随缩放变化
|
|
808
|
+
|
|
809
|
+
**问题**:缩放画布时,点标注也跟着缩放,影响视觉效果。
|
|
810
|
+
|
|
811
|
+
**解决方案**:监听缩放事件,动态调整点标注大小。
|
|
812
|
+
|
|
813
|
+
```typescript
|
|
814
|
+
const changePointScaleRelativeCanvas = (pointLayer: Group) => {
|
|
815
|
+
if (!pointStyle.fixedSizeOnZoom) return
|
|
816
|
+
|
|
817
|
+
if (pointLayer && pointLayer.children) {
|
|
818
|
+
const scale = 1 / (app?.tree.scaleX || 1)
|
|
819
|
+
const finalScale = scale * (pointStyle.fixedSizeScale || 1)
|
|
820
|
+
pointLayer.children.forEach((element) => {
|
|
821
|
+
element.scale = finalScale
|
|
822
|
+
})
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
app.on(ZoomEvent.ZOOM, () => {
|
|
827
|
+
updateZoomLevel()
|
|
828
|
+
changePointScaleRelativeCanvas(pointLayer)
|
|
829
|
+
})
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
---
|
|
833
|
+
|
|
834
|
+
**文档版本**:1.0
|
|
835
|
+
**最后更新**:2024-05-01
|