Konva 做标注工具:Stage 缩放平移的正确姿势

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”、坐标始终以原图为基准存储。三件事做对,标注永远不错位。