@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,155 @@
|
|
|
1
|
+
import { Group, Ellipse, Text } from 'leafer-ui';
|
|
2
|
+
import type { PointAnnotation, PointStyle } from '@/types';
|
|
3
|
+
import { DEFAULT_POINT_STYLE } from '@/types';
|
|
4
|
+
import "@leafer-in/state"
|
|
5
|
+
import "@leafer-in/text-editor"
|
|
6
|
+
|
|
7
|
+
export class PointAnnotationElement extends Group {
|
|
8
|
+
public circle: Ellipse;
|
|
9
|
+
public label: Text;
|
|
10
|
+
public data: PointAnnotation;
|
|
11
|
+
private style: PointStyle;
|
|
12
|
+
public _element_tag: string
|
|
13
|
+
private _lastValidLabel: string;
|
|
14
|
+
|
|
15
|
+
constructor(data: PointAnnotation, style?: Partial<PointStyle>) {
|
|
16
|
+
super();
|
|
17
|
+
|
|
18
|
+
this.data = data;
|
|
19
|
+
this.style = { ...DEFAULT_POINT_STYLE, ...style };
|
|
20
|
+
this._lastValidLabel = data.label;
|
|
21
|
+
|
|
22
|
+
// 创建圆点 - Ellipse 的 x, y 是圆心位置,around 设置为 center
|
|
23
|
+
this.circle = new Ellipse({
|
|
24
|
+
x: 0,
|
|
25
|
+
y: 0,
|
|
26
|
+
width: this.style.circleRadius,
|
|
27
|
+
height: this.style.circleRadius,
|
|
28
|
+
fill: this.style.circleFill,
|
|
29
|
+
stroke: this.style.circleStroke,
|
|
30
|
+
strokeWidth: this.style.circleStrokeWidth,
|
|
31
|
+
draggable: false,
|
|
32
|
+
editable: false,
|
|
33
|
+
around: 'center',
|
|
34
|
+
hoverStyle: {
|
|
35
|
+
fill: this.style.hoverCircleFill ? this.style.hoverCircleFill : this.style.circleFill
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 创建标签 - around 设置为 bottom-left,显示在圆点右上角
|
|
40
|
+
this.label = new Text({
|
|
41
|
+
x: this.style.circleRadius / 2 - 2,
|
|
42
|
+
y: - this.style.circleRadius / 2 + 2,
|
|
43
|
+
text: data.label,
|
|
44
|
+
fontSize: this.style.labelFontSize,
|
|
45
|
+
fill: this.style.labelTextColor,
|
|
46
|
+
padding: this.style.labelPadding,
|
|
47
|
+
editable: true,
|
|
48
|
+
editConfig: {
|
|
49
|
+
strokeWidth: 0, // 需要置0,否则有边难看
|
|
50
|
+
moveable: false,
|
|
51
|
+
resizeable: false
|
|
52
|
+
},
|
|
53
|
+
around: 'bottom-left',
|
|
54
|
+
boxStyle: {
|
|
55
|
+
fill: this.style.labelBackgroundColor,
|
|
56
|
+
cornerRadius: 4,
|
|
57
|
+
whiteSpace: 'nowrap',
|
|
58
|
+
shadow: {
|
|
59
|
+
x: 1,
|
|
60
|
+
y: 1,
|
|
61
|
+
blur: 2,
|
|
62
|
+
color: 'rgba(0,0,0, .2)'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// 添加到组
|
|
68
|
+
this.add(this.circle);
|
|
69
|
+
this.add(this.label);
|
|
70
|
+
|
|
71
|
+
// 设置组属性 - 组的位置就是圆心位置
|
|
72
|
+
this.x = data.pixel.x;
|
|
73
|
+
this.y = data.pixel.y;
|
|
74
|
+
this.draggable = true;
|
|
75
|
+
this.editable = true;
|
|
76
|
+
// this.hitChildren = false;
|
|
77
|
+
this.editConfig = {
|
|
78
|
+
strokeWidth: 0, // 需要置0,否则有边难看
|
|
79
|
+
resizeable: false
|
|
80
|
+
},
|
|
81
|
+
this._element_tag = 'point-annotation';
|
|
82
|
+
|
|
83
|
+
// 绑定事件
|
|
84
|
+
this.bindEvents();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private bindEvents(): void {
|
|
88
|
+
// Hover 效果已通过 hoverStyle 配置,无需监听事件
|
|
89
|
+
// this.circle.on('pointerenter', () => this.updateHover(true));
|
|
90
|
+
// this.circle.on('pointerleave', () => this.updateHover(false));
|
|
91
|
+
|
|
92
|
+
// 标签编辑完成
|
|
93
|
+
// this.label.on('text:change', (e: any) => this.handleLabelChange(e.text));
|
|
94
|
+
this.label.on('property.change', (e) => {
|
|
95
|
+
// console.log(e);
|
|
96
|
+
// 2.0.8 的标准数据结构
|
|
97
|
+
const data = e.data || e;
|
|
98
|
+
if (data.attrName === 'text') {
|
|
99
|
+
console.log('数据落地了:', data.newValue);
|
|
100
|
+
this.handleLabelChange(data.newValue);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 外部调用的选中处理方法
|
|
106
|
+
public handlePointAnnotationSelected(isSelected: boolean): void {
|
|
107
|
+
const targetStyle = isSelected ? this.style.selectedCircleFill : this.style.circleFill;
|
|
108
|
+
const targetStroke = isSelected ? this.style.selectedCircleStroke : this.style.circleStroke;
|
|
109
|
+
const targetScale = isSelected ? this.style.selectedCircleScale : 1;
|
|
110
|
+
|
|
111
|
+
this.circle.set({
|
|
112
|
+
fill: targetStyle,
|
|
113
|
+
stroke: targetStroke,
|
|
114
|
+
scale: targetScale,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public handleLabelChange(newLabel: string): void {
|
|
119
|
+
// 去除首尾空格
|
|
120
|
+
const trimmedLabel = newLabel.trim();
|
|
121
|
+
|
|
122
|
+
// 空值或空白,恢复上一次的值
|
|
123
|
+
if (!trimmedLabel) {
|
|
124
|
+
this.label.text = this._lastValidLabel;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 更新有效标签
|
|
129
|
+
this._lastValidLabel = trimmedLabel;
|
|
130
|
+
this.data.label = trimmedLabel;
|
|
131
|
+
this.data.updatedAt = Date.now();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public updatePosition(x: number, y: number): void {
|
|
135
|
+
this.x = x;
|
|
136
|
+
this.y = y;
|
|
137
|
+
this.data.pixel = { x, y };
|
|
138
|
+
this.data.updatedAt = Date.now();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public updateLabel(label: string): void {
|
|
142
|
+
this.label.text = label;
|
|
143
|
+
this._lastValidLabel = label;
|
|
144
|
+
this.data.label = label;
|
|
145
|
+
this.data.updatedAt = Date.now();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public getLabel(): string {
|
|
149
|
+
return this.data.label;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public getLastValidLabel(): string {
|
|
153
|
+
return this._lastValidLabel;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/index.ts
ADDED
package/src/main.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// 点标注数据结构
|
|
2
|
+
export interface PointAnnotation {
|
|
3
|
+
id: string;
|
|
4
|
+
pixel: {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
};
|
|
8
|
+
normalized: {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
};
|
|
12
|
+
label: string;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
updatedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 点标注样式配置
|
|
18
|
+
export interface PointStyle {
|
|
19
|
+
circleRadius: number;
|
|
20
|
+
circleFill: string;
|
|
21
|
+
circleStroke: string;
|
|
22
|
+
circleStrokeWidth: number;
|
|
23
|
+
hoverCircleFill: string;
|
|
24
|
+
hoverCircleStroke: string;
|
|
25
|
+
selectedCircleFill: string;
|
|
26
|
+
selectedCircleStroke: string;
|
|
27
|
+
selectedCircleScale: number;
|
|
28
|
+
labelBackgroundColor: string;
|
|
29
|
+
labelTextColor: string;
|
|
30
|
+
labelFontSize: number;
|
|
31
|
+
labelPadding: number | number[];
|
|
32
|
+
fixedSizeOnZoom?: boolean; // 是否开启标注点固定大小(不随画布缩放)
|
|
33
|
+
fixedSizeScale?: number; // 固定大小的缩放系数(默认为1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 笔刷样式配置
|
|
37
|
+
export interface BrushStyle {
|
|
38
|
+
color: string;
|
|
39
|
+
opacity: number;
|
|
40
|
+
size: number;
|
|
41
|
+
minSize: number;
|
|
42
|
+
maxSize: number;
|
|
43
|
+
continuity: number; // 连续性阈值:两个点之间最大距离(像素),超过就用直线连接
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 笔刷笔画数据
|
|
47
|
+
export interface BrushStrokeData {
|
|
48
|
+
id: string;
|
|
49
|
+
points: { x: number; y: number }[];
|
|
50
|
+
color: string;
|
|
51
|
+
opacity: number;
|
|
52
|
+
size: number;
|
|
53
|
+
createdAt: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 工具类型
|
|
57
|
+
export type ToolType = 'select' | 'point' | 'brush' | 'eraser';
|
|
58
|
+
|
|
59
|
+
// 导出格式类型
|
|
60
|
+
export type ExportFormat = 'json-full' | 'json-points' | 'coco' | 'yolo' | 'image-mask';
|
|
61
|
+
|
|
62
|
+
// 导出选项
|
|
63
|
+
export interface ExportOptions {
|
|
64
|
+
includeImage?: boolean;
|
|
65
|
+
maskBackground?: string;
|
|
66
|
+
maskForeground?: string;
|
|
67
|
+
prettyPrint?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 导入选项
|
|
71
|
+
export interface ImportOptions {
|
|
72
|
+
replace?: boolean;
|
|
73
|
+
resetZoom?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 统计信息
|
|
77
|
+
export interface Statistics {
|
|
78
|
+
pointCount: number;
|
|
79
|
+
brushStrokeCount: number;
|
|
80
|
+
brushAreaPercent: number;
|
|
81
|
+
hasChanges: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 导出数据结构
|
|
85
|
+
export interface ExportData {
|
|
86
|
+
version: string;
|
|
87
|
+
imageUrl: string;
|
|
88
|
+
imageWidth: number;
|
|
89
|
+
imageHeight: number;
|
|
90
|
+
pointAnnotations: PointAnnotation[];
|
|
91
|
+
brushStrokes: BrushStrokeData[];
|
|
92
|
+
exportTime: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 默认点标注样式
|
|
96
|
+
export const DEFAULT_POINT_STYLE: PointStyle = {
|
|
97
|
+
circleRadius: 12, // 8x8 的圆点
|
|
98
|
+
circleFill: '#ff4d4f',
|
|
99
|
+
circleStroke: '#ffffff',
|
|
100
|
+
circleStrokeWidth: 2,
|
|
101
|
+
hoverCircleFill: '#ff7875',
|
|
102
|
+
hoverCircleStroke: '#ffffff',
|
|
103
|
+
selectedCircleFill: '#1890ff',
|
|
104
|
+
selectedCircleStroke: '#ffffff',
|
|
105
|
+
selectedCircleScale: 1.2,
|
|
106
|
+
labelBackgroundColor: '#ffffff',
|
|
107
|
+
labelTextColor: '#333333',
|
|
108
|
+
labelFontSize: 12,
|
|
109
|
+
labelPadding: [2,4],
|
|
110
|
+
fixedSizeOnZoom: false, // 默认关闭固定大小功能
|
|
111
|
+
fixedSizeScale: 1, // 默认缩放系数为1
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// 默认笔刷样式
|
|
115
|
+
export const DEFAULT_BRUSH_STYLE: BrushStyle = {
|
|
116
|
+
color: 'rgba(255,0,0,1)',
|
|
117
|
+
opacity: 0.55,
|
|
118
|
+
size: 100,
|
|
119
|
+
minSize: 50,
|
|
120
|
+
maxSize: 150,
|
|
121
|
+
continuity: 28, // 默认连续性阈值:20像素
|
|
122
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ICommand } from '@zzalai/leafer-undo-redo';
|
|
2
|
+
import { CanvasBrush } from './CanvasBrush';
|
|
3
|
+
|
|
4
|
+
export class BrushSnapshotCommand implements ICommand {
|
|
5
|
+
private canvasBrush: CanvasBrush;
|
|
6
|
+
private beforeSnapshot: string;
|
|
7
|
+
private afterSnapshot: string;
|
|
8
|
+
private isFirstExecute: boolean;
|
|
9
|
+
private isClearOperation: boolean;
|
|
10
|
+
|
|
11
|
+
constructor(canvasBrush: CanvasBrush, beforeSnapshot: string, isClearOperation: boolean = false) {
|
|
12
|
+
this.canvasBrush = canvasBrush;
|
|
13
|
+
this.beforeSnapshot = beforeSnapshot;
|
|
14
|
+
this.afterSnapshot = '';
|
|
15
|
+
this.isFirstExecute = true;
|
|
16
|
+
this.isClearOperation = isClearOperation;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
execute(): void {
|
|
20
|
+
if (this.isFirstExecute) {
|
|
21
|
+
if (this.isClearOperation) {
|
|
22
|
+
this.afterSnapshot = '';
|
|
23
|
+
} else {
|
|
24
|
+
this.afterSnapshot = this.canvasBrush.getImageData();
|
|
25
|
+
}
|
|
26
|
+
this.isFirstExecute = false;
|
|
27
|
+
} else {
|
|
28
|
+
if (this.afterSnapshot) {
|
|
29
|
+
this.canvasBrush.restoreImageData(this.afterSnapshot);
|
|
30
|
+
} else {
|
|
31
|
+
this.canvasBrush.clear();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
undo(): void {
|
|
37
|
+
this.canvasBrush.restoreImageData(this.beforeSnapshot);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
redo(): void {
|
|
41
|
+
if (this.afterSnapshot) {
|
|
42
|
+
this.canvasBrush.restoreImageData(this.afterSnapshot);
|
|
43
|
+
} else {
|
|
44
|
+
this.canvasBrush.clear();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Path, Group } from 'leafer-ui';
|
|
2
|
+
|
|
3
|
+
export class BrushStroke {
|
|
4
|
+
public path: Path;
|
|
5
|
+
private points: { x: number; y: number }[] = [];
|
|
6
|
+
private isFinished: boolean = false;
|
|
7
|
+
private pathData: string = '';
|
|
8
|
+
private container: Group | any;
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
container: Group | any,
|
|
12
|
+
color: string,
|
|
13
|
+
size: number,
|
|
14
|
+
opacity: number,
|
|
15
|
+
isErase: boolean = false
|
|
16
|
+
) {
|
|
17
|
+
this.container = container;
|
|
18
|
+
|
|
19
|
+
this.path = new Path({
|
|
20
|
+
stroke: color,
|
|
21
|
+
strokeWidth: size,
|
|
22
|
+
opacity: opacity,
|
|
23
|
+
strokeLinecap: 'round',
|
|
24
|
+
strokeLinejoin: 'round',
|
|
25
|
+
globalCompositeOperation: isErase ? 'destination-out' : 'source-over',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 支持 Group 或 App 作为容器
|
|
29
|
+
if (container.add) {
|
|
30
|
+
container.add(this.path);
|
|
31
|
+
} else if (container.tree?.add) {
|
|
32
|
+
container.tree.add(this.path);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public addPoint(x: number, y: number): void {
|
|
37
|
+
if (this.isFinished) return;
|
|
38
|
+
|
|
39
|
+
this.points.push({ x, y });
|
|
40
|
+
|
|
41
|
+
if (this.points.length === 1) {
|
|
42
|
+
this.pathData = `M${x} ${y}`;
|
|
43
|
+
} else {
|
|
44
|
+
this.pathData += ` L${x} ${y}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Leafer UI 使用 path 属性而不是 d 属性
|
|
48
|
+
(this.path as any).path = this.pathData;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public finish(): { points: { x: number; y: number }[] } {
|
|
52
|
+
this.isFinished = true;
|
|
53
|
+
return { points: [...this.points] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public remove(): void {
|
|
57
|
+
if (this.container.remove) {
|
|
58
|
+
this.container.remove(this.path);
|
|
59
|
+
} else if (this.container.tree?.remove) {
|
|
60
|
+
this.container.tree.remove(this.path);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public getPathData(): string {
|
|
65
|
+
return this.pathData;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public getPoints(): { x: number; y: number }[] {
|
|
69
|
+
return [...this.points];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public isEmpty(): boolean {
|
|
73
|
+
return this.points.length === 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public getBoundingBox(): { x: number; y: number; width: number; height: number } | null {
|
|
77
|
+
if (this.points.length === 0) return null;
|
|
78
|
+
|
|
79
|
+
let minX = Infinity, maxX = -Infinity;
|
|
80
|
+
let minY = Infinity, maxY = -Infinity;
|
|
81
|
+
|
|
82
|
+
this.points.forEach(point => {
|
|
83
|
+
minX = Math.min(minX, point.x);
|
|
84
|
+
maxX = Math.max(maxX, point.x);
|
|
85
|
+
minY = Math.min(minY, point.y);
|
|
86
|
+
maxY = Math.max(maxY, point.y);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
x: minX,
|
|
91
|
+
y: minY,
|
|
92
|
+
width: maxX - minX,
|
|
93
|
+
height: maxY - minY,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { PointAnnotation } from '@/types';
|
|
2
|
+
|
|
3
|
+
export interface COCOExportResult {
|
|
4
|
+
info: {
|
|
5
|
+
year: number;
|
|
6
|
+
version: string;
|
|
7
|
+
contributor: string;
|
|
8
|
+
date_created: string;
|
|
9
|
+
};
|
|
10
|
+
images: Array<{
|
|
11
|
+
id: number;
|
|
12
|
+
file_name: string;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}>;
|
|
16
|
+
annotations: Array<{
|
|
17
|
+
id: number;
|
|
18
|
+
image_id: number;
|
|
19
|
+
category_id: number;
|
|
20
|
+
keypoints: number[];
|
|
21
|
+
num_keypoints: number;
|
|
22
|
+
iscrowd: number;
|
|
23
|
+
}>;
|
|
24
|
+
categories: Array<{
|
|
25
|
+
id: number;
|
|
26
|
+
name: string;
|
|
27
|
+
supercategory: string;
|
|
28
|
+
keypoints: string[];
|
|
29
|
+
skeleton: number[][];
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function exportCOCOFormat(
|
|
34
|
+
pointAnnotations: PointAnnotation[],
|
|
35
|
+
imageUrl: string,
|
|
36
|
+
imageWidth: number,
|
|
37
|
+
imageHeight: number,
|
|
38
|
+
options?: {
|
|
39
|
+
categoryName?: string;
|
|
40
|
+
imageId?: number;
|
|
41
|
+
}
|
|
42
|
+
): COCOExportResult {
|
|
43
|
+
const imageId = options?.imageId || 1;
|
|
44
|
+
const categoryName = options?.categoryName || 'point';
|
|
45
|
+
|
|
46
|
+
const getFileName = (url: string): string => {
|
|
47
|
+
try {
|
|
48
|
+
const urlObj = new URL(url);
|
|
49
|
+
return urlObj.pathname.split('/').pop() || 'image.jpg';
|
|
50
|
+
} catch {
|
|
51
|
+
return 'image.jpg';
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const coco: COCOExportResult = {
|
|
56
|
+
info: {
|
|
57
|
+
year: new Date().getFullYear(),
|
|
58
|
+
version: '1.0',
|
|
59
|
+
contributor: 'leafer-point-annotation',
|
|
60
|
+
date_created: new Date().toISOString().split('T')[0],
|
|
61
|
+
},
|
|
62
|
+
images: [
|
|
63
|
+
{
|
|
64
|
+
id: imageId,
|
|
65
|
+
file_name: getFileName(imageUrl),
|
|
66
|
+
width: imageWidth,
|
|
67
|
+
height: imageHeight,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
annotations: pointAnnotations.map((point, index) => ({
|
|
71
|
+
id: index + 1,
|
|
72
|
+
image_id: imageId,
|
|
73
|
+
category_id: 1,
|
|
74
|
+
keypoints: [point.pixel.x, point.pixel.y, 2],
|
|
75
|
+
num_keypoints: 1,
|
|
76
|
+
iscrowd: 0,
|
|
77
|
+
})),
|
|
78
|
+
categories: [
|
|
79
|
+
{
|
|
80
|
+
id: 1,
|
|
81
|
+
name: categoryName,
|
|
82
|
+
supercategory: 'annotation',
|
|
83
|
+
keypoints: ['point'],
|
|
84
|
+
skeleton: [],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return coco;
|
|
90
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Canvas, Group } from 'leafer-ui';
|
|
2
|
+
|
|
3
|
+
export interface BrushStyle {
|
|
4
|
+
color: string;
|
|
5
|
+
opacity: number;
|
|
6
|
+
size: number;
|
|
7
|
+
minSize: number;
|
|
8
|
+
maxSize: number;
|
|
9
|
+
continuity: number; // 连续性阈值:两个点之间最大距离(像素),超过就用直线连接
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class CanvasBrush {
|
|
13
|
+
private canvas: Canvas;
|
|
14
|
+
private group: Group;
|
|
15
|
+
private width: number;
|
|
16
|
+
private height: number;
|
|
17
|
+
private lastPoint: { x: number; y: number } | null = null;
|
|
18
|
+
|
|
19
|
+
constructor(width: number, height: number, style: BrushStyle) {
|
|
20
|
+
this.width = width;
|
|
21
|
+
this.height = height;
|
|
22
|
+
|
|
23
|
+
this.canvas = new Canvas({
|
|
24
|
+
width,
|
|
25
|
+
height,
|
|
26
|
+
});
|
|
27
|
+
// 默认禁用点击事件,让点击穿透到图片
|
|
28
|
+
(this.canvas as any).set({ pointerEvents: false });
|
|
29
|
+
|
|
30
|
+
// 外层 Group,用于设置统一透明度
|
|
31
|
+
this.group = new Group({
|
|
32
|
+
opacity: style.opacity,
|
|
33
|
+
});
|
|
34
|
+
this.group.add(this.canvas);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public getCanvas(): Canvas {
|
|
38
|
+
return this.canvas;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public getGroup(): Group {
|
|
42
|
+
return this.group;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public setOpacity(opacity: number): void {
|
|
46
|
+
this.group.set({ opacity });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public setPointerEvents(value: boolean): void {
|
|
50
|
+
(this.canvas as any).set({ pointerEvents: value });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public resetLastPoint(): void {
|
|
54
|
+
this.lastPoint = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public draw(
|
|
58
|
+
x: number,
|
|
59
|
+
y: number,
|
|
60
|
+
size: number,
|
|
61
|
+
color: string,
|
|
62
|
+
_opacity: number,
|
|
63
|
+
continuity: number
|
|
64
|
+
): void {
|
|
65
|
+
const { context } = this.canvas;
|
|
66
|
+
|
|
67
|
+
// 保存当前状态
|
|
68
|
+
context.save();
|
|
69
|
+
|
|
70
|
+
context.fillStyle = color;
|
|
71
|
+
context.globalAlpha = 1; // 始终 1,透明度由 Group 控制
|
|
72
|
+
|
|
73
|
+
// 如果有上一个点,且两点之间距离超过连续性阈值,先画连线
|
|
74
|
+
if (this.lastPoint) {
|
|
75
|
+
const dx = x - this.lastPoint.x;
|
|
76
|
+
const dy = y - this.lastPoint.y;
|
|
77
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
78
|
+
|
|
79
|
+
if (distance > continuity) {
|
|
80
|
+
// 画连线(用多个圆填充)
|
|
81
|
+
const steps = Math.ceil(distance / (size / 2));
|
|
82
|
+
for (let i = 1; i <= steps; i++) {
|
|
83
|
+
const t = i / steps;
|
|
84
|
+
const midX = this.lastPoint.x + dx * t;
|
|
85
|
+
const midY = this.lastPoint.y + dy * t;
|
|
86
|
+
context.beginPath();
|
|
87
|
+
context.arc(midX, midY, size / 2, 0, Math.PI * 2);
|
|
88
|
+
context.fill();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 画当前点
|
|
94
|
+
context.beginPath();
|
|
95
|
+
context.arc(x, y, size / 2, 0, Math.PI * 2);
|
|
96
|
+
context.fill();
|
|
97
|
+
|
|
98
|
+
// 恢复状态
|
|
99
|
+
context.restore();
|
|
100
|
+
|
|
101
|
+
// 保存当前点
|
|
102
|
+
this.lastPoint = { x, y };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public erase(x: number, y: number, size: number, continuity: number): void {
|
|
106
|
+
const { context } = this.canvas;
|
|
107
|
+
|
|
108
|
+
// 保存当前状态
|
|
109
|
+
context.save();
|
|
110
|
+
|
|
111
|
+
context.globalCompositeOperation = 'destination-out';
|
|
112
|
+
|
|
113
|
+
// 如果有上一个点,且两点之间距离超过连续性阈值,先画连线
|
|
114
|
+
if (this.lastPoint) {
|
|
115
|
+
const dx = x - this.lastPoint.x;
|
|
116
|
+
const dy = y - this.lastPoint.y;
|
|
117
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
118
|
+
|
|
119
|
+
if (distance > continuity) {
|
|
120
|
+
// 画连线(用多个圆填充)
|
|
121
|
+
const steps = Math.ceil(distance / (size / 2));
|
|
122
|
+
for (let i = 1; i <= steps; i++) {
|
|
123
|
+
const t = i / steps;
|
|
124
|
+
const midX = this.lastPoint.x + dx * t;
|
|
125
|
+
const midY = this.lastPoint.y + dy * t;
|
|
126
|
+
context.beginPath();
|
|
127
|
+
context.arc(midX, midY, size / 2, 0, Math.PI * 2);
|
|
128
|
+
context.fill();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 画当前点
|
|
134
|
+
context.beginPath();
|
|
135
|
+
context.arc(x, y, size / 2, 0, Math.PI * 2);
|
|
136
|
+
context.fill();
|
|
137
|
+
|
|
138
|
+
// 恢复状态
|
|
139
|
+
context.restore();
|
|
140
|
+
|
|
141
|
+
// 保存当前点
|
|
142
|
+
this.lastPoint = { x, y };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
public clear(): void {
|
|
146
|
+
const { context } = this.canvas;
|
|
147
|
+
context.clearRect(0, 0, this.width, this.height);
|
|
148
|
+
this.resetLastPoint();
|
|
149
|
+
this.canvas.paint();
|
|
150
|
+
this.group.set({ dirty: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
public getImageData(): string {
|
|
154
|
+
return this.canvas.context.canvas.toDataURL('image/png');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public restoreImageData(dataUrl: string, callback?: () => void): void {
|
|
158
|
+
const img = new Image();
|
|
159
|
+
img.onload = () => {
|
|
160
|
+
const { context } = this.canvas;
|
|
161
|
+
context.clearRect(0, 0, this.width, this.height);
|
|
162
|
+
context.drawImage(img, 0, 0);
|
|
163
|
+
this.canvas.paint();
|
|
164
|
+
this.group.set({ dirty: true });
|
|
165
|
+
if (callback) callback();
|
|
166
|
+
};
|
|
167
|
+
img.src = dataUrl;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
public hasContent(): boolean {
|
|
171
|
+
if (this.width === 0 || this.height === 0) return false;
|
|
172
|
+
const imageData = this.canvas.context.getImageData(0, 0, this.width, this.height);
|
|
173
|
+
const data = imageData.data;
|
|
174
|
+
for (let i = 3; i < data.length; i += 4) {
|
|
175
|
+
if (data[i] > 0) return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|