npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zzalai/leafer-point-annotation

v1.1.22

Published

A Vue3 component for point annotation and brush painting on images using LeaferJS, supporting COCO/YOLO/JSON export, designed for AI model training dataset annotation

Readme

@zzalai/leafer-point-annotation

English | 中文

基于 Vue 3 + LeaferJS 的图像点标注与笔刷涂抹工具,支持导出 COCO/YOLO/JSON 格式,专为 AI 模型训练数据集标注设计。

  • 📍 点标注(可拖拽、可编辑、自动重排、hover/selected 状态)
  • 🖌️ 多图层笔刷涂抹与擦除(颜色、透明度、大小、连续性可调,橡皮擦独立尺寸)
  • 🪢 套索工具(自由绘制闭合区域进行填充/擦除,轨迹黑白双线高对比度)
  • 🔀 一键根据点标注的轨迹生成笔刷多边形区域
  • 🖼️ 本地图片上传(点击 + 拖拽)或远程图片
  • 🎨 完整的点/笔刷样式自定义
  • ⬅️ 撤销 / 重做(Command 模式)
  • 📤 多格式导出:JSON / COCO / YOLO / Mask (dataURL / Blob / File)
  • 📱 支持自定义工具栏(隐藏内置工具栏,调用 ref API 自行构建)
  • 🔒 enableBrush: false 可完全禁用笔刷,仅保留点标注
  • ⌨️ 丰富的键盘快捷键(v p b e Ctrl+Z Ctrl+Y Delete 等,需 options.enableHotkeys: true
  • 🔍 beforeCreatePoint 前置判定回调(创建点标注前介入,支持同步/异步,适用于数量限制、区域限制、用户二次确认等场景)
  • 📦 TypeScript 类型集中导出import type { OptionsSource, PointAnnotationItem, ImageSource, PointStyle, BrushStyle, ... } from '@zzalai/leafer-point-annotation'

目录


安装

npm

npm install @zzalai/leafer-point-annotation

yarn

yarn add @zzalai/leafer-point-annotation

pnpm

pnpm add @zzalai/leafer-point-annotation

⚠️ 注意:vue@^3.3.0 为 peer dependency(不会被自动安装,需宿主项目已存在)。


快速开始

<template>
  <PointAnnotation
    ref="annotationRef"
    v-model:image-source="imageSource"
    :options="options"
    @point-change="handlePointChange"
    @load-success="handleLoadSuccess"
  />
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { PointAnnotation } from '@zzalai/leafer-point-annotation'

// 👇 必须手动导入样式
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

const annotationRef = ref<InstanceType<typeof PointAnnotation> | null>(null)

// 方式一:远程图片
const imageSource = computed(() => ({
  url: 'https://example.com/sample.jpg'
}))

// 方式二:不传 imageSource(初始为 null),用户本地上传后自动绑定
// const imageSource = ref<ImageSource | null>(null)

const options = {
  enableBrush: true,
  pointStyle: {
    circleFill: '#ff4d4f',
    circleStroke: '#ffffff'
  },
  brushStyle: {
    color: '#1890ff',
    opacity: 0.55,
    size: 100,
    eraserSize: 150  // 橡皮擦大小(可选,未设置时与笔刷大小一致)
  }
}

function handlePointChange(points: any[]) {
  console.log('点标注变化:', points)
}

function handleLoadSuccess(info: any) {
  console.log('图片加载成功:', info)
}
</script>

Props 配置

imageSource

| 字段 | 类型 | 说明 | |------|------|------| | url | string | 图片地址(远程 URL、dataURL 或 blob URL) | | id | string | 可选,业务标识 | | width | number | 图片宽度(加载成功后填充) | | height | number | 图片高度(加载成功后填充) | | isLocal | boolean | 是否为本地图片(本地选择时为 true) | | file | File | 原始文件对象(本地选择时有值,可直接用于后端 multipart 上传) |

单一数据源:图片加载完全依赖 props.imageSource。不传 imageSource 时,组件显示大面积上传区域,支持点击选择文件拖拽文件两种本地加载方式。

v-model 支持:组件支持 v-model:imageSource 双向绑定,内部选择图片后自动更新父组件数据。

options

完整的 OptionsSource

interface OptionsSource {
  // ============ 功能开关 ============
  enableBrush?: boolean                     // 是否启用笔刷(默认 true);
                                            // false 时笔刷按钮/面板不渲染,
                                            // brushTool/eraserTool/mask 导出等方法失效
  enableHotkeys?: boolean                   // 是否启用键盘快捷键(默认 false);
                                            // true 时才绑定 v/p/b/e/Ctrl+Z 等快捷键
  brushCursorEnabled?: boolean             // 是否显示笔刷/橡皮擦的自定义跟随光标(默认 true);
                                            // 切换到 brush/eraser 工具时光标显示为笔刷形状(颜色、大小、透明度实时同步)
  lassoFixedSizeOnZoom?: boolean           // 套索轨迹是否保持固定大小(不随画布缩放而变粗,默认 true);
                                            // 黑色主线 + 白色描边,高对比度适应各种背景
  loadingGradientColors?: [string, string]  // 加载中渐变动画的两个颜色(默认:淡紫 '#e8e0ff' -> 淡蓝 '#d8e8ff')
  loadingTextColor?: string                // 加载中文字颜色(默认:'#4a5568',蓝灰色,明显且不违和)

  // ============ UI 开关 ============
  showToolbar?: boolean                     // 是否显示内置工具栏(默认 true)
  showZoomController?: boolean              // 是否显示内置缩放控制器(默认 true)
  canvasBackground?: string                 // 画布背景色(默认 '#f6f6f6')

  // ============ 缩放 ============
  zoomMin?: number                          // 最小缩放比例(默认 0.2)
  zoomMax?: number                          // 最大缩放比例(默认 4)

  // ============ 点标注 ============
  pointStyle?: Partial<PointStyle>          // 点标注样式(覆盖默认)
  maxPoints?: number                        // 最大点数(可选)

  // ============ 笔刷 ============
  brushStyle?: Partial<BrushStyle>          // 笔刷样式(覆盖默认)
  brushLayers?: BrushLayerConfig[]          // 笔刷图层配置(不传则单图层 "default")
  maxBrushLayers?: number                   // 最大图层数(可选)

  // ============ 历史 ============
  maxUndoSteps?: number                     // 最大撤销步数(默认 100)

  // ============ Mask 导出 ============
  maskExportFormat?: 'png' | 'jpeg' | 'jpg' // Mask 默认导出格式(默认 png)
  maskExportForeground?: 'black' | 'white'  // Mask 默认前景色(默认 black)
}

PointStyle(点标注样式)

interface PointStyle {
  circleRadius: number
  circleFill: string
  circleStroke: string
  circleStrokeWidth: number

  // hover
  hoverCircleFill: string
  hoverCircleStroke: string

  // selected
  selectedCircleFill: string
  selectedCircleStroke: string
  selectedCircleScale: number

  // 文字
  circleTextFontSize: number
  circleTextFontFamily: string
  circleTextFill: string

  // label
  labelBackgroundColor: string
  labelTextColor: string
  labelFontSize: number
  labelPadding: number | number[]

  // 固定大小
  fixedSizeOnZoom?: boolean                 // 开启则点不随画布缩放变大
  fixedSizeScale?: number                   // 固定大小系数
}

默认值参考 src/types/index.ts

BrushStyle(笔刷样式)

interface BrushStyle {
  color: string                             // 笔刷颜色(十六进制)
  opacity: number                           // 透明度 0~1(通过 Group.opacity 控制)
  size: number                              // 笔刷大小(像素)
  eraserSize?: number                       // 橡皮擦大小(像素),未设置时使用 size
  minSize: number                           // 滑块最小
  maxSize: number                           // 滑块最大
  continuity: number                        // 两点间最大距离阈值(超过则断开)
}

橡皮擦独立尺寸eraserSize 用于独立配置橡皮擦尺寸,与笔刷尺寸分离。未设置时默认与 size 相同。笔刷配置面板中分别提供「笔刷大小」和「橡皮擦大小」两个滑块。

橡皮擦光标样式:采用白色 2px 外圈 + 黑色 1px 内圈的双环设计(类似 Photoshop),无论浅色还是深色背景都清晰可见,无需额外配置。

BrushLayerConfig(多图层配置)

interface BrushLayerConfig {
  label: string                             // 图层显示名
  value: string                             // 图层唯一标识
  color?: string                            // 该图层默认颜色
  opacity?: number                          // 该图层默认透明度
  size?: number                             // 该图层默认笔刷大小
}

不传 brushLayers 时默认为单图层 {label:'默认图层', value:'default'}

beforeCreatePoint(创建点前回调)

每次点标注被创建前调用,父组件可通过返回值允许/阻止该点被创建,支持同步返回与异步返回(例如弹确认框等待用户选择)。

type BeforeCreatePoint = (
  x: number,                   // 点击位置的像素 x(图片坐标系)
  y: number,                   // 点击位置的像素 y(图片坐标系)
  normalizedX: number,         // 归一化 x(0 ~ 1)
  normalizedY: number,         // 归一化 y(0 ~ 1)
  existingPointCount: number   // 当前已有多少个点(即 this.pointAnnotations.length)
) => boolean | Promise<boolean>

返回值规则

  • true → 允许创建
  • false → 阻止创建
  • Promise<true> / Promise<false> → 异步等待后决定是否创建(如弹 window.confirm

生效路径:用户画布点击 / 父组件通过 ref 调用 createPointAnnotation() 两种路径都会经过相同的前置判定。

典型用法

// 1) 限制最多 20 个点
function beforeCreatePoint(x: number, y: number, nx: number, ny: number, count: number) {
  return count < 20
}

// 2) 只允许在图片右半部分(x > 宽度的 50%)
function beforeCreatePoint(x: number, y: number, nx: number, ny: number) {
  return nx > 0.5
}

// 3) 异步:弹确认框(实际业务可替换为 UI 组件的确认弹窗)
async function beforeCreatePoint(x: number, y: number) {
  return window.confirm(`在坐标 (${Math.round(x)}, ${Math.round(y)}) 创建点?`)
}

// 4) 组合使用:数量限制 + 区域限制
async function beforeCreatePoint(x: number, y: number, nx: number, ny: number, count: number) {
  if (count >= 5) { console.warn('最多 5 个点'); return false }
  if (nx < 0.5) { console.warn('只允许在右半部分标注'); return false }
  return window.confirm('确认创建?')
}
<PointAnnotation
  :image-source="imageSource"
  :before-create-point="beforeCreatePoint"
/>

currentLayer(v-model:currentLayer)

受控图层切换。例如:

<PointAnnotation
  :options="{ brushLayers: [
    { label: '前景', value: 'foreground' },
    { label: '遮挡', value: 'occlusion' }
  ]}"
  v-model:current-layer="activeLayer"
/>

Events 事件

| 事件 | 参数 | 触发时机 | |------|------|---------| | point-change | (points: PointAnnotation[]) | 点新增 / 删除 / 修改 / 重排 | | load-start | - | 开始加载图片 | | load-success | { url, width, height, isLocal, file? } | 图片加载成功(本地图片时 isLocal=truefile 为原始 File 对象,可直接用于后端 multipart 上传) | | load-error | { error } | 图片加载失败 | | undo-state-change | { canUndo } | 撤销栈状态变化 | | redo-state-change | { canRedo } | 重做栈状态变化 | | update:currentLayer | layerValue | 当前笔刷图层变化(配合 v-model) | | layer-change | layerValue | 同 update:currentLayer | | update:imageSource | ImageSource | 组件内部选择图片后触发(配合 v-model:imageSource) | | point-hover | (pointData, isHovering: boolean) | 点 hover 状态变化:鼠标移入/移出某点时触发 | | point-select | (pointData, isSelected: boolean) | 点选中状态变化:点击选中/取消选中某点时触发 |


TypeScript 类型导入

所有对外类型与默认常量都从包根导出,可直接从 @zzalai/leafer-point-annotation 导入:

import PointAnnotation from '@zzalai/leafer-point-annotation'

// 👇 类型(import type,仅作类型提示用,不参与打包)
import type {
  PointAnnotationItem,   // 点标注数据结构(注意:命名为 PointAnnotationItem,
                         // 避免与 Vue 组件 PointAnnotation 命名冲突)
  OptionsSource,         // :options 的类型
  ImageSource,           // :image-source 的类型
  PointStyle,            // 点标注样式配置
  BrushStyle,            // 笔刷样式配置
  BrushLayerConfig,      // 多图层配置项
  BrushStrokeData,       // 笔刷笔画数据
  ToolType,              // 'select' | 'point' | 'brush' | 'eraser' | 'lasso'
  ExportFormat,          // 导出格式
  ExportOptions,         // 导出选项
  ImportOptions,         // 导入选项
  Statistics,            // 统计信息
  ExportData,            // 完整导出数据结构
} from '@zzalai/leafer-point-annotation'

// 👇 默认常量(value import,运行时可用)
import {
  DEFAULT_POINT_STYLE,   // 默认点标注样式
  DEFAULT_BRUSH_STYLE,   // 默认笔刷样式
} from '@zzalai/leafer-point-annotation'

// 必须手动导入样式
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

组件实例类型

const annotationRef = ref<InstanceType<typeof PointAnnotation> | null>(null)

父组件 Props 类型示例

import type { OptionsSource, ImageSource } from '@zzalai/leafer-point-annotation'

const imageSource = ref<ImageSource>({ url: 'https://example.com/image.jpg' })

const options = ref<Partial<OptionsSource>>({
  enableBrush: true,
  enableHotkeys: false,
  pointStyle: { circleFill: '#ff4d4f' },
  brushLayers: [
    { label: '前景', value: 'foreground', color: '#1890ff', opacity: 0.55 },
    { label: '遮挡', value: 'occlusion', color: '#faad14', opacity: 0.55 },
  ],
})

Ref API(父组件调用)

通过 ref 访问组件实例后可调用以下方法。

const annotationRef = ref<InstanceType<typeof PointAnnotation> | null>(null)

// 示例:
annotationRef.value?.pointTool()                 // 切到点标注工具
annotationRef.value?.createBrushFromPoints()     // 按点轨迹生成笔刷区域
annotationRef.value?.getMaskBlob()                // 导出当前图层 Blob(上传后端)

点标注

| 方法 | 说明 | |------|------| | getPointAnnotations(): PointAnnotation[] | 获取当前所有点 | | createPointAnnotation(x: number, y: number, label?: string): Promise<string \| null> | 程序化新增一个点(返回新点 id 或 null;若配置了 beforeCreatePoint 并返回 false 则返回 null) | | removePointAnnotation(id: string): boolean | 程序化删除一个点 | | updatePointAnnotationLabel(id: string, label: string): boolean | 修改某个点的 label | | setPointHoverState(pointId: string, isHover: boolean): boolean | 编程式设置某个点的 hover 样式(仅修改视觉,不触发事件,不会形成双向循环) | | setPointSelectState(pointId: string, isSelected: boolean): boolean | 编程式设置某个点的选中状态(通过 app.editor.select 更新 editor 内部选中集,与点元素样式和 previousSelectedStates 保持一致;isExternalSelectSync 标志防止双向循环触发 point-select 事件) | | findPointBySequenceNumber(seq: number): { id: string; data: any } \| null | 按圆圈内显示的数字序号查找点,返回该点的 id 和完整数据(父组件跨实例联动时,按视觉序号匹配更可靠) |

图片 & 画布

| 方法 | 说明 | |------|------| | getImageInfo() | { url, width, height, isLocal, file? }(本地选图时有 file 原始 File 对象) | | resetCanvas() | 重置画布到无图片初始化状态(清除所有标注、笔刷、撤销栈) |

图片加载说明:图片加载完全通过 props.imageSource 控制,不再对外暴露 loadImage()openFileDialog() 方法。组件内部选择图片后通过 v-model:imageSource 自动同步到父组件。

工具切换

| 方法 | 说明 | |------|------| | getCurrentTool(): 'select' \| 'point' \| 'brush' \| 'eraser' \| 'lasso' | - | | setTool(tool) | 切换到指定工具(enableBrush=false 时 brush/eraser/lasso 被拦截) | | selectTool() | 选择工具 | | pointTool() | 点标注工具 | | brushTool(openPanel?: boolean) | 笔刷工具 | | eraserTool() | 橡皮擦工具 | | lassoTool() | 套索工具 |

删除 & 清空

| 方法 | 说明 | |------|------| | deleteSelected() | 删除当前选中的点(带 confirm) | | clearAllAnnotations() | 清除所有标注点(不动笔刷) | | clearAllAnnotationsAndBrush() | 清空所有点 + 笔刷(带 confirm) | | clearBrush() | 清空当前图层的笔刷 | | clearAllBrushLayers() | 清空所有图层的笔刷 |

笔刷图层

| 方法 | 说明 | |------|------| | getCurrentLayer(): string | 当前激活图层 value | | setActiveLayer(value: string): boolean | 切换到指定图层 | | getAllLayers(): BrushLayerConfig[] | 所有图层配置 |

笔刷样式

| 方法 | 说明 | |------|------| | getBrushStyle(): BrushStyle | 返回当前样式的拷贝 | | updateBrushStyle(partial: Partial<BrushStyle>): void | 动态更新(如颜色/透明度/大小) |

点轨迹生成笔刷区域

| 方法 | 说明 | |------|------| | createBrushFromPoints(): boolean | 按 sequenceNumber 顺序将点的像素坐标连成闭合多边形,使用当前笔刷样式填充;点数量 < 3 时不操作 |

缩放

| 方法 | 说明 | |------|------| | zoomIn() | 放大 | | zoomOut() | 缩小 | | resetZoom() | 重置到 100% |

撤销 / 重做

| 方法 | 说明 | |------|------| | undo() | 撤销上一步 | | redo() | 重做上一步 |

导入 / 导出

| 方法 | 说明 | |------|------| | exportCanvasJSON(): string | 全量导出(点 + 笔刷快照 + 图片信息) | | importCanvasJSON(data: string \| object): boolean | 从导出的 JSON 恢复 | | exportMaskImage(format?, fg?): Promise<string \| null> | 当前图层 mask(dataURL) | | exportMaskImageByLayer(layerValue, format?, fg?): Promise<string \| null> | 指定图层 mask | | exportAllMaskImages(format?, fg?): Promise<Record<string, string>> | 所有图层 mask | | getMaskBlob(layerValue?, format?, fg?): Promise<Blob \| null> | 当前/指定图层 Blob(后端上传) | | getMaskFile(layerValue?, filename?, format?, fg?): Promise<File \| null> | 当前/指定图层 File | | getAllMaskBlobs(format?, fg?): Promise<Record<string, Blob>> | 所有图层 Blob 集合 | | exportCOCO(): string | 导出 COCO JSON(点标注 = keypoints) | | exportYOLO(): string | 导出 YOLO 标注 |

参数说明:format = 'png' \| 'jpeg' \| 'jpg'fg = 'black' \| 'white'(mask 前景色)。 注:所有 Mask/Blob/File 相关方法需在浏览器环境调用,且在 enableBrush=false 时返回 null/{}。


使用示例

最小示例

<template>
  <PointAnnotation
    ref="annotationRef"
    v-model:image-source="imageSource"
    :options="{ enableBrush: false }"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { PointAnnotation } from '@zzalai/leafer-point-annotation'
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

const annotationRef = ref<InstanceType<typeof PointAnnotation> | null>(null)
</script>

完整自定义(隐藏内置工具栏)

<template>
  <div class="my-toolbar">
    <button @click="() => annotationRef.value?.pointTool()">点标注</button>
    <button @click="() => annotationRef.value?.brushTool()">笔刷</button>
    <button @click="() => annotationRef.value?.eraserTool()">橡皮</button>
    <button @click="() => annotationRef.value?.deleteSelected()">删除</button>
    <button @click="() => annotationRef.value?.clearAllAnnotationsAndBrush()">全部清空</button>
    <button @click="() => annotationRef.value?.undo()">撤销</button>
    <button @click="() => annotationRef.value?.redo()">重做</button>
    <button @click="() => annotationRef.value?.createBrushFromPoints()">点→多边形</button>
    <button @click="uploadMask">上传 Mask</button>
  </div>

  <PointAnnotation
    ref="annotationRef"
    :image-source="{ url: 'https://example.com/image.jpg' }"
    :options="{ showToolbar: false, showZoomController: false }"
    @point-change="handlePoints"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { PointAnnotation } from '@zzalai/leafer-point-annotation'
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

const annotationRef = ref<InstanceType<typeof PointAnnotation> | null>(null)

function handlePoints(points: any[]) {
  console.log('points:', points)
}

async function uploadMask() {
  const blob = await annotationRef.value?.getMaskBlob()
  if (!blob) return
  const fd = new FormData()
  fd.append('file', blob, 'mask.png')
  await fetch('/api/upload', { method: 'POST', body: fd })
}
</script>

多图层笔刷

<template>
  <PointAnnotation
    ref="annotationRef"
    :image-source="{ url: 'https://example.com/image.jpg' }"
    :options="{
      brushLayers: [
        { label: '前景', value: 'foreground', color: '#1890ff', opacity: 0.55 },
        { label: '遮挡', value: 'occlusion',  color: '#faad14', opacity: 0.55 },
        { label: '背景', value: 'background', color: '#52c41a', opacity: 0.55 }
      ]
    }"
    v-model:current-layer="activeLayer"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { PointAnnotation } from '@zzalai/leafer-point-annotation'
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

const annotationRef = ref<InstanceType<typeof PointAnnotation> | null>(null)
const activeLayer = ref('foreground')
</script>

后端上传 Mask(Blob/File)

<template>
  <PointAnnotation ref="annotationRef" :image-source="{ url: '...' }" />
  <button @click="uploadAllMasks">上传所有图层 Mask</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { PointAnnotation } from '@zzalai/leafer-point-annotation'
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

const annotationRef = ref<InstanceType<typeof PointAnnotation> | null>(null)

async function uploadAllMasks() {
  const blobs = await annotationRef.value?.getAllMaskBlobs('png', 'black')
  if (!blobs) return
  for (const [layerValue, blob] of Object.entries(blobs)) {
    const fd = new FormData()
    fd.append('file', blob, `${layerValue}.png`)
    await fetch('/api/mask-upload', { method: 'POST', body: fd })
  }
}
</script>

只启用点标注(禁用笔刷)

<template>
  <PointAnnotation
    ref="annotationRef"
    :image-source="{ url: '...' }"
    :options="{ enableBrush: false }"
    @point-change="handlePoints"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { PointAnnotation } from '@zzalai/leafer-point-annotation'
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

const annotationRef = ref<InstanceType<typeof PointAnnotation> | null>(null)
function handlePoints(points: any[]) {
  console.log('标注:', points)
}
</script>

套索工具(自由绘制闭合区域)

套索工具用于自由绘制闭合区域并自动填充/擦除,与笔刷共享颜色、透明度和图层设置,支持撤销/重做。

<template>
  <PointAnnotation
    ref="annotationRef"
    :image-source="{ url: '...' }"
    :options="{
      enableBrush: true,
      lassoFixedSizeOnZoom: true,  // 套索轨迹保持固定屏幕大小(默认 true)
      brushStyle: {
        color: '#1890ff',
        opacity: 0.55,
        size: 100,
      }
    }"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import PointAnnotation from '@zzalai/leafer-point-annotation'
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

const annotationRef = ref<InstanceType<typeof PointAnnotation> | null>(null)

// 程序化切换到套索工具
function useLasso() {
  annotationRef.value?.lassoTool()
}
</script>

套索轨迹样式:采用黑色 1px 主线 + 白色 3px 描边的高对比度设计,无论浅色还是深色背景都清晰可见。

lassoFixedSizeOnZoom:控制套索轨迹是否随画布缩放而变粗。默认 true(固定屏幕像素大小,类似标注点的 fixedSizeOnZoom),设为 false 则线宽随画布缩放变化。

点标注前回调(beforeCreatePoint)

<template>
  <PointAnnotation
    ref="annotationRef"
    :image-source="{ url: '...' }"
    :before-create-point="beforeCreatePoint"
    @point-change="handlePoints"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import PointAnnotation from '@zzalai/leafer-point-annotation'
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

const annotationRef = ref<InstanceType<typeof PointAnnotation> | null>(null)

// 示例:限制最多 5 个点 + 只允许在图片右半部分创建 + 创建前二次确认
async function beforeCreatePoint(
  x: number, y: number,
  nx: number, ny: number,
  count: number
) {
  if (count >= 5) {
    console.warn('最多 5 个点标注')
    return false
  }
  if (nx < 0.5) {
    console.warn('只允许在右半部分标注')
    return false
  }
  return window.confirm(`在 (${Math.round(x)}, ${Math.round(y)}) 创建第 ${count + 1} 个点?`)
}

function handlePoints(points: any[]) {
  console.log('点列表:', points)
}
</script>

多实例双向联动(A ↔ B 点 hover/选中同步)

当页面上有两个或多个 PointAnnotation 实例时,可通过 point-hover / point-select 事件 + setPointHoverState / setPointSelectState API 实现跨实例的点状态联动。推荐按 sequenceNumber(圆圈内显示的数字序号)匹配,因为它始终连续,不受删除/undo/redo 影响:

<template>
  <div style="display: flex; gap: 20px">
    <div style="flex: 1">
      <h3>实例 A</h3>
      <PointAnnotation
        ref="refA"
        :image-source="{ url: imageUrlA }"
        @point-hover="(p, h) => onPointHover('A', p, h)"
        @point-select="(p, s) => onPointSelect('A', p, s)"
      />
    </div>
    <div style="flex: 1">
      <h3>实例 B</h3>
      <PointAnnotation
        ref="refB"
        :image-source="{ url: imageUrlB }"
        @point-hover="(p, h) => onPointHover('B', p, h)"
        @point-select="(p, s) => onPointSelect('B', p, s)"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import PointAnnotation from '@zzalai/leafer-point-annotation'
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

const refA = ref<InstanceType<typeof PointAnnotation> | null>(null)
const refB = ref<InstanceType<typeof PointAnnotation> | null>(null)
const imageUrlA = '/demo-a.jpg'
const imageUrlB = '/demo-b.jpg'

// 按 sequenceNumber(圆圈显示的数字序号)在目标实例中找到对应点
function syncBySeq(targetRef: any, sourcePoint: any, apply: (pointId: string) => void) {
  const target = targetRef.value?.findPointBySequenceNumber(sourcePoint.sequenceNumber);
  if (target) apply(target.id);
}

// hover 双向同步
function onPointHover(source: 'A' | 'B', pointData: any, isHover: boolean) {
  const targetRef = source === 'A' ? refB : refA;
  syncBySeq(targetRef, pointData, (targetId) => {
    targetRef.value?.setPointHoverState(targetId, isHover);
  });
}

// 选中状态双向同步
function onPointSelect(source: 'A' | 'B', pointData: any, isSelected: boolean) {
  const targetRef = source === 'A' ? refB : refA;
  syncBySeq(targetRef, pointData, (targetId) => {
    targetRef.value?.setPointSelectState(targetId, isSelected);
  });
}
</script>

匹配规则说明

  • 默认按 sequenceNumber(圆圈内显示的数字 1、2、3…)匹配,不受删除/undo/redo 影响
  • 如需按 id 或其他字段匹配,只需替换 syncBySeq 中的查找逻辑
  • setPointHoverState 是纯样式修改;setPointSelectState 通过 app.editor.select 同步 editor 内部选中集,二者均通过 isExternalSelectSync 标志避免双向循环

删除联动最佳实践(推荐方案)

不建议基于 point-change 事件做删除匹配(sequenceNumber 在删除后会重排,容易删错),推荐以下方案:

<template>
  <div>
    <!-- 双实例均隐藏工具栏 + 禁用组件内部快捷键 -->
    <PointAnnotation
      ref="refA"
      :image-source="{ url: imageUrlA }"
      :options="{ showToolbar: false, enableHotkeys: false }"
      @point-hover="(p, h) => onPointHover('A', p, h)"
      @point-select="(p, s) => onPointSelect('A', p, s)"
    />
    <PointAnnotation
      ref="refB"
      :image-source="{ url: imageUrlB }"
      :options="{ showToolbar: false, enableHotkeys: false }"
      @point-hover="(p, h) => onPointHover('B', p, h)"
      @point-select="(p, s) => onPointSelect('B', p, s)"
    />
    <!-- 父组件统一删除按钮 -->
    <button @click="deleteSelectedBoth">🗑 删除选中</button>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import PointAnnotation from '@zzalai/leafer-point-annotation'
import '@zzalai/leafer-point-annotation/dist/leafer-point-annotation.css'

const refA = ref<InstanceType<typeof PointAnnotation> | null>(null)
const refB = ref<InstanceType<typeof PointAnnotation> | null>(null)

// hover 双向同步
function onPointHover(source: 'A' | 'B', pointData: any, isHover: boolean) {
  const targetRef = source === 'A' ? refB : refA
  const target = targetRef.value?.findPointBySequenceNumber(pointData.sequenceNumber)
  if (target) targetRef.value?.setPointHoverState(target.id, isHover)
}

// 选中状态双向同步
function onPointSelect(source: 'A' | 'B', pointData: any, isSelected: boolean) {
  const targetRef = source === 'A' ? refB : refA
  const target = targetRef.value?.findPointBySequenceNumber(pointData.sequenceNumber)
  if (target) targetRef.value?.setPointSelectState(target.id, isSelected)
}

// 统一删除:两端选中状态已经联动同步,分别调用 deleteSelected() 即可
const deleteSelectedBoth = () => {
  refA.value?.deleteSelected()
  refB.value?.deleteSelected()
}

// 双实例模式下:父组件监听 Delete 键,统一触发删除
const onAppKeyDown = (e: KeyboardEvent) => {
  const target = e.target as HTMLElement
  if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return
  if (e.code === 'Delete' || e.key === 'Delete') {
    e.preventDefault()
    deleteSelectedBoth()
  }
}

onMounted(() => { window.addEventListener('keydown', onAppKeyDown) })
onUnmounted(() => { window.removeEventListener('keydown', onAppKeyDown) })
</script>

为什么推荐此方案

  • hover/selected 状态通过 point-hover / point-select 事件双向同步,保证两端选中的点始终一致
  • 删除操作由父组件统一执行,直接调用两个实例的 deleteSelected(),不依赖 sequenceNumberid 匹配
  • 不受删除后重排的影响,更可靠、更简单
  • showToolbar: false 隐藏内置工具栏,enableHotkeys: false 禁用组件内部快捷键,避免 Delete 键触发两次删除

快捷键

生效条件:options.enableHotkeys: true + (画布获得焦点鼠标 hover 在画布上)

enableHotkeys 默认 false,需显式传 true 才会绑定快捷键。

| 按键 | 功能 | 限制 | |------|------|------| | v | 选择工具 | 需 enableHotkeys=true | | p | 点标注工具 | 需 enableHotkeys=true | | b | 笔刷工具 | 需 enableHotkeys=true + enableBrush=true | | e | 橡皮擦工具 | 需 enableHotkeys=true + enableBrush=true | | l | 套索工具 | 需 enableHotkeys=true + enableBrush=true | | Ctrl + Z | 撤销 | 需 enableHotkeys=true | | Ctrl + Y | 重做 | 需 enableHotkeys=true | | Delete | 删除选中的点 | 需 enableHotkeys=true | | Ctrl + + | 放大 | 需 enableHotkeys=true | | Ctrl + - | 缩小 | 需 enableHotkeys=true | | Ctrl + 0 | 重置缩放 | 需 enableHotkeys=true | | Alt | 显示/隐藏快捷键提示浮层 | 需 enableHotkeys=true |


开发与构建

# 安装依赖
pnpm install

# 本地开发(App.vue 为演示入口)
pnpm dev

# 构建库产物(dist/)
pnpm build

# 构建演示站点(docs/)
pnpm docs:build

# 同时构建库 + 演示站
pnpm build:all

# 类型检查
pnpm tsc --noEmit

项目结构

src/
├── components/
│   ├── PointAnnotation.vue        # 核心主组件(所有能力整合)
│   ├── BrushSizeSlider.vue        # 笔刷大小滑块
│   └── BrushStylePanel.vue        # 笔刷样式面板
├── elements/
│   └── PointAnnotationElement.ts  # 自定义点元素(Group + Ellipse + Text)
├── utils/
│   ├── CanvasBrush.ts             # 笔刷底层(canvas + 绘制快照)
│   ├── LassoOverlay.ts            # 套索工具(自由绘制闭合区域,黑白双线高对比度)
│   ├── BrushCommands.ts           # 笔刷撤销命令
│   ├── PointCommands.ts           # 点撤销命令
│   ├── BrushStroke.ts             # 笔画数据
│   ├── COCOExporter.ts            # COCO 导出
│   └── YOLOExporter.ts            # YOLO 导出
├── types/
│   └── index.ts                   # 所有对外类型与默认值
├── App.vue                        # dev 演示页
├── index.ts                       # 对外导出
└── main.ts                        # dev 入口

发布流程

  1. pnpm installpnpm build:all
  2. 确认 dist/docs/ 最新
  3. 更新 package.jsonversion
  4. npm publish
  5. git push 到 GitHub(触发 Pages 重新部署)

许可证

MIT © zzalai