图片标注与热区
标注工具的 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