Konva 是我做图片标注类工具时首选的 canvas 库。做缩放平移功能时,一个常犯的错——去缩放图片节点本身——会导致标注框和图片坐标错位。正确的做法是缩放整个 Stage。
缩放 Image 还是缩放 Stage
先看差别:
// ❌ 缩放 Image 节点
imageNode.scale({ x: 2, y: 2 }); // 只有图片变大,标注框不动
// ✅ 缩放整个 Stage
stage.scale({ x: 2, y: 2 }); // 图片和所有标注框一起变大,坐标对齐
CVAT、LabelStudio、AnyLabeling 这类专业标注工具全走 Stage 缩放路线。
鼠标位置为中心的滚轮缩放
图片查看器的标配——滚轮向前放大、向后缩小、以鼠标所在位置为中心。核心逻辑:
stage.on("wheel", e => {
e.evt.preventDefault();
const scaleBy = 1.05;
const oldScale = stage.scaleX();
const pointer = stage.getPointerPosition();
// 缩放前鼠标在"世界坐标"里对应的点
const mousePointTo = {
x: (pointer.x - stage.x()) / oldScale,
y: (pointer.y - stage.y()) / oldScale,
};
const newScale = e.evt.deltaY > 0 ? oldScale / scaleBy : oldScale * scaleBy;
stage.scale({ x: newScale, y: newScale });
// 保持鼠标下的那个点不移动
stage.position({
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
});
stage.batchDraw();
});
关键在这两步——先记录鼠标下的世界坐标,缩放后把 stage.position 反算回来,让那个点仍然停在鼠标下。这个技巧不止 Konva,任何 canvas 缩放都能套。
平移(拖拽画布)
按住空格或者中键拖动画布:
stage.draggable(true); // 全局启用拖拽
或者只在按住空格时拖:
window.addEventListener("keydown", e => {
if (e.code === "Space") stage.draggable(true);
});
window.addEventListener("keyup", e => {
if (e.code === "Space") stage.draggable(false);
});
缩放限制
别让用户无脑放大 100 倍或缩小成一个点:
const newScale = Math.max(0.1, Math.min(oldScale * factor, 20));
屏幕坐标 → 原图坐标
标注框保存到后端时,得存原图坐标,不能存屏幕坐标(用户下次打开缩放级别不一样就错了):
function stagePointToImage(pos) {
return {
x: (pos.x - stage.x()) / stage.scaleX(),
y: (pos.y - stage.y()) / stage.scaleY(),
};
}
// 用法
const pos = stage.getPointerPosition();
const realPos = stagePointToImage(pos);
反过来”原图坐标 → 屏幕位置”用于展示已有标注:
function imageToStagePoint(p) {
return {
x: p.x * stage.scaleX() + stage.x(),
y: p.y * stage.scaleY() + stage.y(),
};
}
推荐的图层结构
标注类项目一般这样组织:
Stage
└─ Layer
├─ Image (背景图)
├─ Group (标注)
│ ├─ Rect
│ ├─ Rect
│ └─ Rect
└─ Group (UI) (十字线、悬浮提示等)
Layer 里可以再分组,方便批量控制显隐(比如”隐藏所有标注只看图”):
annotationsGroup.visible(false);
一句话总结
Konva 做标注工具:缩放 Stage 而不是 Image 节点、鼠标为中心的缩放靠”世界坐标反算 position”、坐标始终以原图为基准存储。三件事做对,标注永远不错位。
