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"、坐标始终以原图为基准存储。三件事做对,标注永远不错位。
Konva.js 滚轮缩放与平移:以鼠标为中心缩放 Stage 的正确实现
scale() vs width()/height() 缩放单个图片节点有两种方式: // scale():保持节点尺寸不变,视觉上缩放 imageNode.scale({ x: 2, y: 2 });// width()/height():真正修改节点尺寸 imageNode.width(600); imageNode.height(400);两者的区别在于 scale() 不改变节点的逻辑尺寸(width()/height() 返回值不变),而后者实际修改了尺寸。大多数场景用 scale() 更灵活。 标注工具:缩放整个 Stage 如果 Stage 上同时有图片和标注框(Rect/Line),不要缩放单个图片节点,应缩放整个 Stage: stage.scale({ x: newScale, y: newScale }); stage.position(newPos); stage.batchDraw();这样图片、标注框、文字所有节点都会同步缩放,坐标关系保持一致。CVAT、LabelImg 等标注工具都使用这种方式。 滚轮缩放(以鼠标位置为中心) const SCALE_BY = 1.05;stage.on('wheel', (e) => { e.evt.preventDefault(); const oldScale = stage.scaleX(); const pointer = stage.getPointerPosition(); // 鼠标在画布坐标系中的位置(排除当前 scale 和偏移) const mousePointTo = { x: (pointer.x - stage.x()) / oldScale, y: (pointer.y - stage.y()) / oldScale, }; const newScale = e.evt.deltaY > 0 ? oldScale / SCALE_BY // 向下:缩小 : oldScale * SCALE_BY; // 向上:放大 // 限制缩放范围 const clampedScale = Math.max(0.1, Math.min(newScale, 20)); stage.scale({ x: clampedScale, y: clampedScale }); // 调整偏移,使鼠标下方的点保持不动 stage.position({ x: pointer.x - mousePointTo.x * clampedScale, y: pointer.y - mousePointTo.y * clampedScale, }); stage.batchDraw(); });核心思路:缩放前记录鼠标在画布坐标系(未缩放坐标)的位置,缩放后重新计算 Stage 偏移使该点回到鼠标位置,从而实现"以鼠标为中心"的缩放效果。 拖拽平移 stage.draggable(true);启用后可以直接拖动整个 Stage。如果需要区分拖拽图片和拖拽画布,禁用 stage.draggable() 改为手动处理: let isDragging = false; let lastPos = null;stage.on('mousedown', () => { isDragging = true; lastPos = stage.getPointerPosition(); });stage.on('mousemove', () => { if (!isDragging) return; const pos = stage.getPointerPosition(); stage.position({ x: stage.x() + pos.x - lastPos.x, y: stage.y() + pos.y - lastPos.y, }); lastPos = pos; stage.batchDraw(); });stage.on('mouseup', () => { isDragging = false; });坐标系转换:获取原图坐标 点击事件的 stage.getPointerPosition() 返回的是屏幕坐标,需要转换为原图坐标: stage.on('click', () => { const pointer = stage.getPointerPosition(); const scale = stage.scaleX(); const imageX = (pointer.x - stage.x()) / scale; const imageY = (pointer.y - stage.y()) / scale; console.log('原图坐标:', imageX, imageY); });
CIDR 表示法:/24、/32 的含义和只匹配单个 IP 的写法
CIDR(无类别域间路由)用"IP地址/前缀长度"表示一段 IP 范围,前缀长度决定有多少个 IP 被包含在内。 /24 不等于单个 IP 43.255.122.56/24 表示前 24 位固定,等价于: 43.255.122.0 - 43.255.122.255共 256 个地址,包含 43.255.122.56,但也包含同网段的所有其他地址。 只匹配单个 IP:使用 /32 IPv4 地址是 32 位,/32 表示所有 32 位都固定: 43.255.122.56/32 → 仅匹配 43.255.122.56配置防火墙、ACL、路由规则、Cloudflare IP 规则时,允许或封禁单个 IP 都应使用 /32: # iptables iptables -A INPUT -s 43.255.122.56/32 -j ACCEPT# Nginx allow 43.255.122.56/32;如果不要求 CIDR 格式,直接写 IP 不加后缀也等同于 /32: 43.255.122.56常用前缀长度对照写法 匹配范围 地址数x.x.x.x/32 仅这一个 IP 1x.x.x.x/31 2 个(常用于点对点链路) 2x.x.x.x/30 4 个 4x.x.x.x/29 8 个 8x.x.x.x/28 16 个 16x.x.x.0/24 256 个(常见局域网段) 256x.x.0.0/16 65536 个 65536x.0.0.0/8 16777216 个(A 类网络) 167772160.0.0.0/0 所有 IPv4 地址 全部前缀长度与地址数的计算 地址数 = 2^(32 - 前缀长度)/32 → 2^0 = 1 /24 → 2^8 = 256 /16 → 2^16 = 65536网络地址与广播地址 在标准 IPv4 子网中,最小地址(如 43.255.122.0)是网络地址,最大地址(43.255.122.255)是广播地址,可用主机地址是中间的 254 个。但在防火墙规则和 CIDR 匹配中,这个区分通常不重要,/24 就是匹配全部 256 个地址。
CIDR 记法要写对:43.255.122.56/24 匹配的是一整段
配置防火墙、白名单、路由规则的时候,经常见到有人这样写: 43.255.122.56/24本意是想匹配单个 IP,结果匹配了 256 个。CIDR 的斜杠语法是"前面多少位固定",不是"IP 加上标签"。 /24 到底表示什么 43.255.122.56/24 意思是:IP 的前 24 位固定。IPv4 一共 32 位,24 位固定就是前三段(43.255.122)不变,最后一段(.56)被忽略、整段 0-255 都算命中。 等价于: 43.255.122.0 – 43.255.122.255 (256 个地址)所以: 43.255.122.0 ✅ 命中 43.255.122.56 ✅ 命中 43.255.122.100 ✅ 命中 43.255.122.255 ✅ 命中 43.255.123.56 ❌ 未命中想匹配单个 IP:/32 要精确匹配一个 IP,写 /32——32 位全都固定,就是它自己: 43.255.122.56/32如果配置格式不要求必须有 CIDR 后缀,直接写 IP 也行: 43.255.122.56/32 是最严格的等价形式。 常见 CIDR 对照写法 匹配范围 地址数43.255.122.56/32 只 43.255.122.56 143.255.122.56/31 43.255.122.56 – 57 243.255.122.56/30 43.255.122.56 – 59 443.255.122.56/29 43.255.122.56 – 63 843.255.122.56/28 43.255.122.48 – 63 1643.255.122.56/27 43.255.122.32 – 63 3243.255.122.56/26 43.255.122.0 – 63 6443.255.122.56/25 43.255.122.0 – 127 12843.255.122.56/24 43.255.122.0 – 255 25643.255.122.56/16 43.255.0.0 – 43.255.255.255 65,53643.255.122.56/8 43.0.0.0 – 43.255.255.255 16M+43.255.122.56/0 整个 IPv4 空间 全部注意:CIDR 里"起点 IP"随便写,实际会向下对齐。43.255.122.56/24 和 43.255.122.99/24 等价,都指 43.255.122.0/24 这个网段。 计算方法 给一个 CIDR A.B.C.D/N,怎么算范围? 掩码位数 → 主机位数 = 32 - N 主机位数决定了段长:主机位 = 0:1 个 IP(/32) 主机位 = 1:2 个(/31) 主机位 = 8:256 个(/24) 主机位 = 16:65536 个(/16) 主机位 = N:2^N 个起点:把 A.B.C.D 的后 (32-N) 位置零。 例:10.0.5.37/28N=28,主机位=4 段长 = 2^4 = 16 后 4 位置零:37 二进制 00100101 → 后 4 位置零 00100000 = 32 范围:10.0.5.32 – 10.0.5.47Linux 命令算 ipcalc 是最快的: $ ipcalc 43.255.122.56/24 Address: 43.255.122.56 Netmask: 255.255.255.0 = 24 Network: 43.255.122.0/24 HostMin: 43.255.122.1 HostMax: 43.255.122.254 Broadcast: 43.255.122.255 Hosts/Net: 254或者 Python 一行: import ipaddress net = ipaddress.ip_network("43.255.122.56/24", strict=False) print(net.network_address, net.broadcast_address, net.num_addresses)常见误区 误区 1:以为 /24 加个数字更保险不是。任何斜杠后缀都是"网段掩码",你越加越大。 误区 2:/32 单独 IP 时可以省掉大多数系统能省,但**某些严格配置格式(RBAC 白名单、AWS SG)**要求必须写 /32,别偷懒。 误区 3:以为 /0 是"无匹配"反过来——0.0.0.0/0 匹配整个 IPv4 空间,路由表里就是默认路由。 IPv6 版 IPv6 也用 CIDR: 2001:db8::1/128 单个 IP 2001:db8::/32 一整段 ::/0 整个 IPv6 空间规则一样,只是位数从 32 变成 128。 一句话总结 IP 加 /N 是网段,前 N 位固定其余任意。单个 IP 要写 /32(IPv6 是 /128)。别偷懒不加 CIDR 后缀,也别把 /24 当成"某个 IP 的编号"。
HTML 自定义鼠标指针:十字准星、全屏辅助线与 CSS cursor 类型
跟随鼠标的十字准星 用 ::before 和 ::after 绘制十字线,mousemove 更新位置: <style> #crosshair { position: fixed; width: 20px; height: 20px; pointer-events: none; /* 不阻挡下层元素的鼠标事件 */ z-index: 99999; }#crosshair::before, #crosshair::after { content: ""; position: absolute; background: red; }/* 垂直线 */ #crosshair::before { left: 50%; top: 0; width: 1px; height: 100%; transform: translateX(-50%); }/* 水平线 */ #crosshair::after { top: 50%; left: 0; width: 100%; height: 1px; transform: translateY(-50%); } </style><div id="crosshair"></div><script> const crosshair = document.getElementById("crosshair");document.addEventListener("mousemove", (e) => { crosshair.style.left = `${e.clientX - 10}px`; crosshair.style.top = `${e.clientY - 10}px`; }); </script>pointer-events: none 让准星元素"透明"——鼠标事件穿透到下层元素。 全屏辅助线(贯穿整个视口) <style> .guide-h, .guide-v { position: fixed; pointer-events: none; z-index: 99999; opacity: 0.4; }.guide-h { width: 100vw; height: 1px; left: 0; background: #00aaff; }.guide-v { height: 100vh; width: 1px; top: 0; background: #00aaff; } </style><div class="guide-h" id="guideH"></div> <div class="guide-v" id="guideV"></div><script> const guideH = document.getElementById("guideH"); const guideV = document.getElementById("guideV");document.addEventListener("mousemove", (e) => { guideH.style.top = `${e.clientY}px`; guideV.style.left = `${e.clientX}px`; }); </script>水平线跟随 clientY,垂直线跟随 clientX,始终贯穿整个视口。 显示/隐藏辅助线(快捷键切换) let visible = true;document.addEventListener("keydown", (e) => { if (e.key === "h") { // 按 H 键切换 visible = !visible; guideH.style.display = visible ? "block" : "none"; guideV.style.display = visible ? "block" : "none"; } });CSS cursor 常用值 /* 标注/选择场景 */ cursor: crosshair; /* 十字准星 */ cursor: cell; /* 单元格十字 *//* 拖拽场景 */ cursor: grab; /* 手型(可拖拽) */ cursor: grabbing; /* 手型(拖拽中)*/ cursor: move; /* 四方向箭头 *//* 调整大小 */ cursor: col-resize; /* 左右调宽 */ cursor: row-resize; /* 上下调高 */ cursor: nwse-resize; /* 左上-右下对角 */ cursor: nesw-resize; /* 右上-左下对角 *//* 缩放 */ cursor: zoom-in; cursor: zoom-out;/* 禁止操作 */ cursor: not-allowed;自定义图片作为鼠标 .custom-cursor { cursor: url("/img/cursor.png") 8 8, crosshair; /* url(...) 8 8 表示热点坐标(点击点相对图片左上角的偏移) */ /* 第二个值是 fallback cursor,浏览器不支持时使用 */ }图片尺寸建议不超过 128x128px,PNG 格式,透明背景。 Canvas 中覆盖系统鼠标 // 隐藏系统鼠标指针 canvas.style.cursor = "none";// 在 Canvas 内自行绘制准星 canvas.addEventListener("mousemove", (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; ctx.clearRect(0, 0, canvas.width, canvas.height); // ... 重绘内容 ... // 绘制准星 ctx.strokeStyle = "red"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x - 10, y); ctx.lineTo(x + 10, y); ctx.moveTo(x, y - 10); ctx.lineTo(x, y + 10); ctx.stroke(); });Canvas 场景下用 cursor: none + 手动绘制是最常见的方式,可以精确控制准星样式和动画。
