跳到主要内容

图片标注与热区

标注工具的 source of truth 是 底图像素坐标上的几何,外加一份 JSON。Canvas 负责视口光栅和交互;导出给业务的是结构,不是涂鸦 PNG(除非产品就是要烧进图)。

1. 文档

export type Annotation =
| {
id: string;
type: "rect";
x: number;
y: number;
w: number;
h: number;
label: string;
}
| {
id: string;
type: "polygon";
points: { x: number; y: number }[];
label: string;
};

export interface AnnotatorDoc {
imageUrl: string;
naturalWidth: number;
naturalHeight: number;
annotations: Annotation[];
}

x/y/w/h 相对 图像自然像素,不是 CSS、不是当前缩放。换 DPR、换窗口,几何不变。

相机初始:scale = min(viewW / naturalWidth, viewH / naturalHeight),pan 居中。这和地图的 fit 一样。

2. 分层

底图层:ImageBitmap,只在 decode / 换图时画
标注层:矢量,scene 变才画
交互层:正在拉的框、hover 高亮,每帧
DOM:label 输入、侧栏列表

底图用 drawImage(bitmap, 0, 0, naturalWidth, naturalHeight),CTM 已含 camera,所以图像铺满世界 [0,nw]×[0,nh]。

热区命中:rect 用 AABB;polygon 用 Path2D + isPointInPath。坐标已是世界(=图像像素),点用 viewToWorld

3. 创建矩形

pointerdown 命中空处 → pending → drag 出 viewRectToWorld → 松手写入 annotation。最小尺寸(如 4 图像像素)以下当误触丢弃。

编辑:命中边/角做 resize,命中内部做 move。resize 时要按住的是 图像坐标,并夹紧在 [0,nw]×[0,nh],除非产品允许框出界。

4. 导出

export function serializeDoc(doc: AnnotatorDoc): string {
return JSON.stringify(doc);
}

export async function burnInPng(
bitmap: ImageBitmap,
doc: AnnotatorDoc,
): Promise<Blob> {
const canvas = new OffscreenCanvas(doc.naturalWidth, doc.naturalHeight);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2d unavailable");
ctx.drawImage(bitmap, 0, 0);
ctx.strokeStyle = "#ff4d4f";
ctx.lineWidth = Math.max(2, doc.naturalWidth / 400);
for (const a of doc.annotations) {
if (a.type === "rect") {
ctx.strokeRect(a.x, a.y, a.w, a.h);
}
}
return canvas.convertToBlob({ type: "image/png" });
}

训练数据、审核后台要 JSON。只有印刷/分享才 burn-in。两套导出不要共用一个按钮却无提示。

底图跨源必须 CORS,否则 burn-in 变 SecurityError。预览仍可能正常。见 污染 CORS

5. 失败形态

症状原因
缩放后框相对图片滑走框存在了 view 坐标
导出框变粗/变细lineWidth 用了屏幕像素去烧自然分辨率
点不准忘了 fit 时的 letterbox,用了 client 当图像像素
列表和画布选中不一致两份 selection state

权威资料

核对日期:2026-08-26