Showing Posts From

Canvas

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); });

Canvas 拖拽标注框:startBox 模式避免累计误差与 offsetX 自然拖拽感

问题:mousemove 中累加导致误差 // 错误写法:每次 mousemove 都叠加 canvas.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - lastX; const dy = e.clientY - lastY; bbox.x += dx; // 浮点累加,误差逐帧积累 bbox.y += dy; lastX = e.clientX; lastY = e.clientY; });每帧的 dx 存在浮点舍入,经过几百次 mousemove 后,bbox 位置与鼠标实际位置产生偏差。 解决:startBox 模式 mousedown 时保存初始状态,mousemove 始终基于初始状态计算绝对位置: let dragState = null;canvas.addEventListener('mousedown', (e) => { const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); if (isInsideBbox(mouseX, mouseY, bbox)) { dragState = { startX: mouseX, startY: mouseY, startBox: { ...bbox } // 拷贝初始位置 }; } });canvas.addEventListener('mousemove', (e) => { if (!dragState) return; const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); const dx = mouseX - dragState.startX; const dy = mouseY - dragState.startY; // 每帧都基于 startBox 计算,不累加 bbox.x = dragState.startBox.x + dx; bbox.y = dragState.startBox.y + dy; redraw(); });canvas.addEventListener('mouseup', () => { dragState = null; });bbox.x = startBox.x + dx 而不是 bbox.x += dx,误差不再累积。 offsetX 模式:自然拖拽手感 上面的写法有个体验问题:点击框的右下角时,鼠标移动会让框的左上角跳到鼠标位置。 offsetX 模式记录点击点与框左上角的偏移量,保持鼠标与抓取点的相对位置不变: canvas.addEventListener('mousedown', (e) => { const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); if (isInsideBbox(mouseX, mouseY, bbox)) { dragState = { offsetX: mouseX - bbox.x, // 鼠标在框内的相对位置 offsetY: mouseY - bbox.y }; } });canvas.addEventListener('mousemove', (e) => { if (!dragState) return; const mouseX = getCanvasX(e); const mouseY = getCanvasY(e); bbox.x = mouseX - dragState.offsetX; bbox.y = mouseY - dragState.offsetY; redraw(); });鼠标无论点击框内哪个位置,拖拽过程中该点始终跟随鼠标,手感自然。 三种方案对比方案 误差 拖拽手感 适用场景bbox.x += dx(lastX 累加) 有累计误差 一般 不推荐startBox 模式 无误差 左上角跳到鼠标 精确位移要求高offsetX 模式 无误差 自然,保持抓取点 用户交互体验优先大多数标注工具选择 offsetX 模式,既无误差又有自然手感。 坐标转换 Canvas 内部坐标需要考虑缩放和滚动: function getCanvasX(e) { const rect = canvas.getBoundingClientRect(); return (e.clientX - rect.left) / scale; }function getCanvasY(e) { const rect = canvas.getBoundingClientRect(); return (e.clientY - rect.top) / scale; }scale 是 Canvas 的缩放比例,不除以 scale 会导致缩放后拖拽位置错误。

JS 把 img 元素转 Base64:canvas + 跨域 tainted 问题

前端要把一张 <img> 转成 Base64——通常是为了上传、缓存、内联到别的地方。最直接的方式是 canvas。 基础版:canvas.toDataURL function imgToBase64(img, type = "image/png") { const canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0); return canvas.toDataURL(type); }const img = document.querySelector("img"); img.onload = () => console.log(imgToBase64(img));关键点:用 naturalWidth/naturalHeight,不是 width/height(后者是显示尺寸,会被 CSS 缩放) 图片必须加载完成才能 draw,判 img.complete 或者监听 onload type 可选 image/png、image/jpeg、image/webp JPEG 可以传第二个参数控制画质:canvas.toDataURL("image/jpeg", 0.8)只有 URL 没有 img 元素 async function urlToBase64(url) { const img = new Image(); img.crossOrigin = "anonymous"; // 关键,让 canvas 不 tainted img.src = url; await new Promise((res, rej) => { img.onload = res; img.onerror = rej; }); const canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; canvas.getContext("2d").drawImage(img, 0, 0); return canvas.toDataURL("image/png"); }跨域的 tainted 坑 跨域图片经 canvas 一处理,toDataURL 直接抛: SecurityError: Tainted canvases may not be exported原因:canvas 一旦画了跨域资源就"被污染",浏览器不让你导出数据(防止读取用户其它站的私有图片)。 要出数据,需要满足:图片服务器返回 Access-Control-Allow-Origin: *(或匹配你的域) <img> 元素设置 crossOrigin="anonymous"如果对方服务器没配 CORS,只能:走自己的 Nginx 反代加 CORS 头 fetch + blob + FileReader 绕开 canvas更稳的方案:fetch + blob + FileReader async function urlToBase64(url) { const res = await fetch(url); const blob = await res.blob(); return await new Promise(resolve => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); }返回的就是: data:image/png;base64,iVBORw0KGgoAAAANS...优势:不经过 canvas,没有 tainted 问题 支持任何图片格式,包括 GIF 动图(canvas 会把动图变成第一帧) 保持原始质量,不会重新压缩但 fetch 依然受 CORS 约束——服务器不配 CORS 时仍然会失败,只是不再看到"tainted"的错误,而是看到网络请求被拦。 想跳过 CORS 的最后一招 在自己的服务端加个代理: 浏览器 → your-server/proxy?url=xxx → 目标图片服务端拿到图片,加上 CORS 头再回给浏览器。前端就走同源了。 Base64 有多大 Base64 编码后体积变大约 33%。10 KB 的图变 13.3 KB 左右。做本地缓存 / 内联小图(favicon、图标)合适;大图别用 Base64,走 URL 更省。 一句话总结 canvas.toDataURL 一行搞定,跨域首选 fetch → blob → FileReader。用 canvas 就得先解决 CORS,别硬撞 tainted 报错。