简介:这是一个基于HTML5 Canvas实现的JavaScript图像距离测量与比例尺工具,面向需要在线量图、标注尺寸的前端开发者或设计工具爱好者。资源解决了在图片上通过两点绘制测量线并换算实际距离的核心问题,适用于地图标注、建筑图纸查看等场景。压缩包共12个文件,以6个JavaScript脚本为主体,配合2个CSS样式、2个PNG图片、1个HTML入口页面和1个说明文档,整体仅108KB,轻量易部署。JS脚本覆盖画布绘制、坐标计算、比例尺设置以及测量数据保存等模块,README提供了使用说明。目前已有917人学习。通过该资源可掌握canvas绘图、鼠标事件监听、像素与实际长度换算,以及基于localStorage的多组测量结果持久化思路,还能了解FileSaver.js等依赖的用法,是一份适合进阶前端图像处理的完整示例。 做图片测量这个需求,最早是帮一个做室内设计的同学搞的。他手里一堆户型图,有些是扫描件,CAD 源文件早没了,客户随口问一句“这块墙到窗户大概多少米”,他只能拿尺子对着屏幕量,量完还要用计算器按比例换算,效率低到离谱。他想让我做个工具,直接在网页里打开图片,点两个点就能读出实际距离。我把这个需求拆了一下,核心其实就是两个问题:怎么在图片上拿到可靠的像素距离,以及怎么把像素距离转成真实的物理距离。后者依赖一个标定参数,也就是常说的比例尺。这篇文章就完整记录一下我当时的实现方案,里面有完整代码、坐标换算的推导,还有几个只有实测才能发现的坑,希望能帮到要做类似工具的读者。
1. 需求分析与方案选型:为什么用 Canvas 而不是 DOM 硬算
这类页面工具的核心链路其实非常短:加载图片 → 用户点击取点 → 计算点与点的像素距离 → 乘上比例尺得到真实距离。链路虽短,但坏就坏在“像素距离”四个字上,因为网页里的图片一旦涉及缩放、平移、高清屏适配,坐标体系就不是一张白纸那么单纯了。
我一开始也想过笨办法:把图片固定显示在页面上,用offsetX/offsetY直接取事件坐标,再用原生 DOM 方式画几条线段覆盖图上面。但很快就发现这条路走不通。原因有两个,第一,offsetX是相对当前目标元素的偏移,如果图片外面再套一层容器、加个边框或 padding,坐标起点就会偏移;第二,图片如果做了 CSS 缩放(比如max-width: 100%),实际渲染尺寸和图片原始尺寸会不一致,你还得先算一个缩放比例,再手动把 DOM 坐标映射回图片原始像素坐标,代码写起来又丑又容易错。
所以最终选了 Canvas 方案。Canvas 的坐标系统一,所有绘制、取点、计算都在同一个坐标系里完成,只要保证“绘制时用的坐标系”和“取点时用的坐标系”一致,就不会串台。另外 Canvas 本身就是画布,画线段、画点、写文字都顺手,不需要额外堆叠元素,性能也完全够用。
整体架构我分为四层:
| 层级 | 职责 | 关键实现 |
|---|---|---|
| 视图层 | 图片显示、缩放、平移 | Canvas + 视口变换 |
| 交互层 | 鼠标/触摸事件的选区、取点、撤销 | 事件监听与坐标换算 |
| 测量层 | 计算像素距离、管理测量点与测量结果 | 状态管理 + 数学公式 |
| 标定层 | 比例尺标定与物理距离换算 | 比例尺系数计算与单位格式化 |
有了这个分层,后面写代码的思路会非常清晰。
2. 核心原理拆解:坐标系换算与比例尺标定
2.1 坐标系的三种形态
图片测量工具里会涉及到三套坐标系,我在前期调研和方案验证时把这个理清了,后面才没走弯路。
- 屏幕坐标:鼠标事件直接给过来的
clientX/clientY,相对浏览器视口左上角。 - Canvas 本地坐标:相对 Canvas 元素左上角,通常用
e.clientX - canvas.getBoundingClientRect().left得到。这是事件交互层最常使用的坐标。 - 图片原始坐标:相对图片左上角(也就是图片自身的像素)。注意,Canvas 绘制图片时如果做了缩放或者平移,那图片原始坐标和 Canvas 本地坐标就不相等。
测量和绘制的所有计算,我统一在图片原始坐标里做。因为无论视图怎么放大缩小、怎么平移,图片原始坐标是不会变的,测量结果也就不会因为视图变化而产生偏移。鼠标点击后要做的第一件事,就是把屏幕坐标或者 Canvas 本地坐标换算成图片原始坐标,再塞进测量逻辑里。
换算公式非常简单。假设维护一个视口对象viewport = { x, y, scale },其中x、y是图片原点在 Canvas 上的偏移量,scale是缩放比例,那么从 Canvas 本地坐标到图片原始坐标的换算公式就是:
imgX = (canvasX - viewport.x) / viewport.scale imgY = (canvasY - viewport.y) / viewport.scale这个公式就是整个工具的地基。不管缩放了多少倍、拖到了什么位置,只要能用它把点击坐标换算回图片坐标,剩下的距离计算就是纯数学的欧氏距离。
2.2 比例尺标定:像素与实际距离的挂钩
拿到的两张图上有大小不同的物体,如果没有外部信息,仅靠像素距离是无法得到“米”或者“厘米”的。比例尺的作用就是建立像素和物理单位之间的换算系数。
我实现了两种标定方式:
- 已知比例尺:用户直接输入“1 像素等于多少毫米”或“1 厘米等于多少像素”,代码直接存系数。
- 两点标定:用户先在图上点两个点,代表一段已知长度的距离,然后输入这段距离的实际长度。代码根据两者的比值计算系数。
两种方式各有适用场景,实际项目中点两点标定更方便。举个例子,户型图上标注了一段墙体是 3600mm,用户就点墙体的两个端点,输入 3600,系统算出当前图片的像素密度,后面再去量其他位置就全部按这个系数换算。这个标定方式的公式也简单:
unitPerPx = realDistance / pixelDistance其中pixelDistance是标定时两个点在图片原始坐标里的欧氏距离,realDistance是用户输入的实际长度。这样每个像素对应的真实长度就出来了。注意这个系数的单位是“单位长度/像素”,比如毫米/像素。算出系数后,测量任意两点时,只需要:
realDistance = pixelDistance * unitPerPx2.3 高清屏的坐标系陷阱
Canvas 在高清屏(devicePixelRatio 大于 1)上会有一个隐形坑。默认情况下,Canvas 的 CSS 尺寸和绘制缓冲区尺寸是 1:1 的,但高分屏的物理像素密度更高,如果不处理,画出来的内容会发虚,线段和文字边缘割裂感明显。更关键的是,坐标也会有偏差。
解决办法是让 Canvas 的width和height设为 CSS 尺寸乘以window.devicePixelRatio,然后通过ctx.scale(dpr, dpr)把绘制坐标系缩放回 CSS 尺寸。但这样一来, Canvas 本地坐标的获取也要相应调整。从事件拿到的坐标是基于 CSS 像素的,而 Canvas 内部绘图已经做了 dpr 缩放,这两套坐标在计算时需要小心。
我采用的策略是:事件坐标统一基于 CSS 像素计算,绘制时用ctx.setTransform(dpr, 0, 0, dpr, 0, 0)统一缩放。这样在逻辑层面心智负担小很多,也不会出偏差。后面讲实测时会再说一个和它相关的具体坑。
3. 完整实现:代码结构与关键环节拆解
3.1 初始化与图片加载
首先准备一个函数把图片加载到 Canvas 里,并让图片以“适应画布”的方式居中显示。这个显示逻辑独立于测量逻辑,只负责初始展示。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>图片测量工具</title> <style> body { margin: 0; background: #1f2430; display: flex; justify-content: center; align-items: center; height: 100vh; font-family: system-ui, sans-serif; } .container { background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); padding: 16px; } canvas { display: block; cursor: crosshair; background: #f0f0f0; border-radius: 8px; } .toolbar { display: flex; gap: 12px; padding: 12px 4px 4px; flex-wrap: wrap; align-items: center; } button { padding: 6px 14px; border: 1px solid #ccc; background: #fff; border-radius: 6px; cursor: pointer; } button.active { background: #3b82f6; color: #fff; border-color: #3b82f6; } input { width: 120px; padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; } .info { margin-left: auto; font-size: 13px; color: #333; background: #f3f4f6; border-radius: 6px; padding: 6px 10px; } </style> </head> <body> <div class="container"> <canvas id="canvas" width="900" height="600"></canvas> <div class="toolbar"> <button id="btnLoad" type="button">选择图片</button> <button id="btnCalibrate" type="button">标定比例尺</button> <button id="btnMeasure" type="button" class="active">测量距离</button> <button id="btnUndo" type="button">撤销</button> <button id="btnClear" type="button">清空</button> <input id="realDistanceInput" type="number" placeholder="标定真实长度(mm)" /> <span class="info" id="statusInfo">模式:测量</span> </div> <input id="fileInput" type="file" accept="image/*" style="display:none" /> </div> <script src="./measure.js"></script> </body> </html>在measure.js里,核心是一个MeasureTool类,这样状态和数据封装在一起,不会乱。
class MeasureTool { constructor(canvas, options = {}) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); // 基础状态 this.dpr = window.devicePixelRatio || 1; this.img = null; this.viewport = { x: 0, y: 0, scale: 1 }; // 图片在画布上的偏移与缩放 // 测量状态 this.mode = 'measure'; // 'measure' | 'calibrate' this.measurePoints = []; // 测量模式下的点集合 this.calibratePoints = []; // 标定模式下的两个点 this.unitPerPx = null; // 每像素对应的实际长度(mm/px) // 初始化 this.initCanvas(); this.bindEvents(); } initCanvas() { // 固定一个展示尺寸,然后用 dpr 放大绘图物理分辨率 const cssWidth = 900; const cssHeight = 600; this.canvas.style.width = cssWidth + 'px'; this.canvas.style.height = cssHeight + 'px'; this.canvas.width = cssWidth * this.dpr; this.canvas.height = cssHeight * this.dpr; this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); this.cssWidth = cssWidth; this.cssHeight = cssHeight; } }这里特别强调一下,canvas.width和canvas.height不再等于 CSS 尺寸,而是物理像素尺寸。后面所有的绘制坐标都默认是 CSS 像素,因为有setTransform(this.dpr, 0, 0, this.dpr, 0, 0)这个缩放,绘制函数里可以继续用 CSS 像素为单位的数值,实际画出来的清晰度又能匹配高分屏。
图片加载后,要做一次“适应窗口”的初始变换。因为如果没有这一步,图片左上角会出现在 Canvas 的左上角,大图直接超出边界,显然不符合实际使用习惯。
loadImage(img) { this.img = img; // 让图片完整显示在画布内,留一点边距 const margin = 20; const scaleX = (this.cssWidth - margin * 2) / img.naturalWidth; const scaleY = (this.cssHeight - margin * 2) / img.naturalHeight; const fitScale = Math.min(scaleX, scaleY); this.viewport = { x: (this.cssWidth - img.naturalWidth * fitScale) / 2, y: (this.cssHeight - img.naturalHeight * fitScale) / 2, scale: fitScale, }; this.draw(); }3.2 鼠标事件与坐标换算
这是整个工具最核心的部分。事件监听要处理三种情况:鼠标点击取点、右键撤销、滚轮缩放与拖拽平移。先看点击与坐标换算。
bindEvents() { this.canvas.addEventListener('mousedown', (e) => { if (e.button === 2) { // 右键撤销 this.undo(); return; } if (e.button !== 0) return; const point = this.getImgPoint(e); this.handlePoint(point); }); // 右键拦截,避免弹出浏览器菜单 this.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // 滚轮缩放 this.canvas.addEventListener('wheel', (e) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; this.zoomAt(e, delta); }); // 拖拽平移 this.canvas.addEventListener('mousedown', (e) => { this.isDragging = false; this.dragStart = { x: e.clientX, y: e.clientY }; const startView = { ...this.viewport }; const onMove = (ev) => { const dx = ev.clientX - this.dragStart.x; const dy = ev.clientY - this.dragStart.y; this.viewport.x = startView.x + dx; this.viewport.y = startView.y + dy; this.draw(); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }这里有一个实际踩坑的细节:拖拽和平移、点击取点这两个逻辑是叠加在同一个mousedown里的。如果用户想点击测量,但鼠标轻轻移动了几个像素,也会触发拖拽平移,导致点没画上,图倒是被拖着跑了。这个问题我放在后面问题排查里详细展开,建议的做法是判断拖动距离超过某个阈值(比如 5px)才视为拖拽,否则视为点击。
坐标换算的核心方法:
getImgPoint(e) { const rect = this.canvas.getBoundingClientRect(); // canvasX / canvasY 是 CSS 像素下的坐标 const canvasX = e.clientX - rect.left; const canvasY = e.clientY - rect.top; return { x: (canvasX - this.viewport.x) / this.viewport.scale, y: (canvasY - this.viewport.y) / this.viewport.scale, }; }这样返回的就是图片原始坐标系的点了,直接可以存进测量数组。
3.3 距离计算与结果渲染
测量模式下,用户每点一次就往measurePoints里加一个点,连续加点就形成了一条折线。我默认以“当前所选点数为一段距离”来展示,也就是点击第一个点,再点击第二个点,即时展示这两点之间的距离。如果是多点连续测量,每次绘制相邻两点间线段,并在当前最后一段上方标注距离。
handlePoint(point) { if (this.mode === 'calibrate') { if (this.calibratePoints.length >= 2) { this.calibratePoints = []; } this.calibratePoints.push(point); if (this.calibratePoints.length === 2) { // 自动提取输入框数值,完成标定 const input = document.getElementById('realDistanceInput'); const realDist = parseFloat(input.value); if (realDist > 0) { this.calibrate(realDist); this.mode = 'measure'; document.getElementById('btnMeasure').classList.add('active'); document.getElementById('btnCalibrate').classList.remove('active'); document.getElementById('statusInfo').textContent = '模式:测量(已标定)'; } else { alert('请输入标定对应的真实长度'); } } } else { this.measurePoints.push(point); } this.draw(); }两点间距离,用最简单的欧氏距离公式:
dist(p1, p2) { return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); }真实距离则乘比例尺系数:
calcRealLength(p1, p2) { const pxDist = this.dist(p1, p2); if (!this.unitPerPx) return null; return pxDist * this.unitPerPx; }标定的实现:
calibrate(realDist) { const [p1, p2] = this.calibratePoints; const pxDist = this.dist(p1, p2); if (pxDist <= 0) return; this.unitPerPx = realDist / pxDist; }绘制函数里,需要把图片和所有点、线、标注都画出来。注意 Canvas 的绘制状态在每次draw()前必须清空并重设,否则会出现重影或者线条叠成一片的情况。
draw() { const ctx = this.ctx; ctx.clearRect(0, 0, this.cssWidth, this.cssHeight); // 绘制背景网格,方便观察缩放和平移 ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1; const gridStep = 30; for (let gx = 0; gx <= this.cssWidth; gx += gridStep) { ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, this.cssHeight); ctx.stroke(); } for (let gy = 0; gy <= this.cssHeight; gy += gridStep) { ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(this.cssWidth, gy); ctx.stroke(); } if (this.img) { ctx.save(); ctx.translate(this.viewport.x, this.viewport.y); ctx.scale(this.viewport.scale, this.viewport.scale); ctx.drawImage(this.img, 0, 0, this.img.naturalWidth, this.img.naturalHeight); ctx.restore(); } // 绘制标定点 if (this.calibratePoints.length) { this.drawPoints(this.calibratePoints, '#ef4444', '标定'); } // 绘制测量点 if (this.measurePoints.length) { this.drawPoints(this.measurePoints, '#3b82f6', '测量'); } }绘制测量线段和标注是内容比较多的部分,我单独拆了一个方法。每个测量点都用圆圈标记,线段用实线,线段中点位置放置距离文本,文字背景用半透明的白色色块做遮罩,避免图和线干扰阅读。
drawPoints(points, color, label) { const ctx = this.ctx; ctx.save(); // 先把坐标系切换到图片坐标系,方便绘制 ctx.translate(this.viewport.x, this.viewport.y); ctx.scale(this.viewport.scale, this.viewport.scale); ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 2 / this.viewport.scale; for (let i = 0; i < points.length; i++) { const p = points[i]; // 画点 ctx.beginPath(); ctx.arc(p.x, p.y, 5 / this.viewport.scale, 0, Math.PI * 2); ctx.fill(); if (i > 0) { const prev = points[i - 1]; ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke(); // 计算并标注距离 if (this.mode === 'measure' || points === this.measurePoints) { const realLen = this.calcRealLength(prev, p); const text = realLen != null ? this.formatLength(realLen) : this.dist(prev, p).toFixed(0) + 'px'; const mx = (prev.x + p.x) / 2; const my = (prev.y + p.y) / 2; ctx.font = `${14 / this.viewport.scale}px sans-serif`; const metrics = ctx.measureText(text); const padX = 4 / this.viewport.scale; const padY = 4 / this.viewport.scale; ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.fillRect(mx - metrics.width / 2 - padX, my - 16 / this.viewport.scale - padY, metrics.width + padX * 2, 20 / this.viewport.scale + padY * 2); ctx.fillStyle = color; ctx.fillText(text, mx - metrics.width / 2, my - 2 / this.viewport.scale); } } } ctx.restore(); }这里实现了关键的“线宽、文字大小、点的半径都跟随缩放比例反向调整”。好处是无论缩放多少倍,文字和线条在屏幕上看起来粗细、大小基本一致,不会因为放大而变成一坨大圆斑。这个细节很影响实际观感,我第一次做的时候没注意,放大以后测量点直接把图片盖住了。
3.4 比例尺数值与单位格式化
同一套像素密度,用户可能想显示成毫米、厘米、米、英寸等不同单位。我在最终输出时做了一层单位格式化,并且做了更友好的显示:
formatLength(value) { if (value == null) return '未标定'; if (value < 1) return value.toFixed(3) + ' mm'; if (value < 1000) return value.toFixed(2) + ' mm'; if (value < 1000000) { return (value / 1000).toFixed(2) + ' m'; } return (value / 1000000).toFixed(2) + ' km'; }用户标定时输入单位是毫米,内部统一用毫米做基准。标定和测量逻辑里所有计算也都是毫米,这样代码里不需要维护很多单位分支。
3.5 拖拽与缩放的实测注意点
滚轮缩放我是以鼠标位置为锚点做的。这是这类工具里另一个常见盲点:如果直接以画布中心缩放,你盯着图片的某个角落放大,放大之后想看的角度直接跑出视野,操作体验很差。以鼠标所在位置为锚点的话,鼠标指到哪里,哪里就是缩放中心,图片会像粘在鼠标下一样稳定。
zoomAt(e, factor) { const rect = this.canvas.getBoundingClientRect(); const mouseCanvasX = e.clientX - rect.left; const mouseCanvasY = e.clientY - rect.top; // 鼠标所在位置对应的图片坐标 const imgX = (mouseCanvasX - this.viewport.x) / this.viewport.scale; const imgY = (mouseCanvasY - this.viewport.y) / this.viewport.scale; // 更新缩放比例 const newScale = Math.min(Math.max(this.viewport.scale * factor, 0.1), 20); this.viewport.scale = newScale; // 让缩放中心保持在鼠标处 this.viewport.x = mouseCanvasX - imgX * newScale; this.viewport.y = mouseCanvasY - imgY * newScale; this.draw(); }这一步的顺序很重要:先取出鼠标对应的图片坐标,再改 scale,最后反推新的 viewport 偏移。如果你先改了 scale 再取坐标,算出来的锚点就是错的,图片会跳动。
4. 实操中的高频事故与排查方案
4.1 坐标偏移错位:事件坐标和绘制坐标不是一套体系
前端初学者最常见的问题就是用offsetX和offsetY取事件坐标,再去和图片像素比对。一旦 Canvas 外有 padding、边框、或者 Canvas 本身被 CSS 缩放,offsetX拿到的坐标和ctx.drawImage里的坐标就对不上,结果是点始终画不到鼠标位置。
我测试时还遇到一个更隐蔽的情况:getBoundingClientRect()返回的尺寸和 Canvas 内部逻辑尺寸不一样。因为盒子模型默认包括 padding 和 border,如果 Canvas 本身设了 padding,事件坐标就会被 padding 挤偏。所以事件处理里所有坐标运算,必须统一用实时rect做减法,不要缓存一份rect用到底。窗口大小改变或者内容滚动,rect都会变。
4.2 高清屏上点不准:坑得很无语的问题
把canvas.width直接设成 CSS 宽度,在普通屏幕上游刃有余,但在 MacBook 等高分屏上就出事儿了。现象是图片边缘发虚,点出来的位置和鼠标位置有 1-2 像素的偏差,放大后偏差更明显,线段起终点和实际点击位置对不齐。
原因前面讲过,是画布缓冲区尺寸和物理像素不匹配。解决办法就是我在initCanvas里写的这段:
this.canvas.width = cssWidth * this.dpr; this.canvas.height = cssHeight * this.dpr; this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);这样处理后,绘制逻辑里依然用 CSS 像素坐标,但画布物理分辨率够高,文字和线段的渲染也清晰。需要注意的是,getBoundingClientRect()返回的是 CSS 尺寸,坐标换算也用这个尺寸,就不会被 dpr 影响。对角线方向测出来的数值偏差,也是这个原因引起的。
4.3 点击与拖拽的冲突:测量时图乱跑
这个问题是最影响日常操作体验的。最初的版本里,鼠标只要按下,无论是否移动,结束后都会触发一次点绘制。但用户在测量的第一反应是“点一下”,手指或者鼠标往往会有轻微位移,这部分位移超过 5 像素后,就被我的代码归为“拖拽模式”,于是图被拖动了,却没有加点。
后来我把逻辑调成:mousedown 时先只记录起点,mousemove 事件里如果位移超过 5px 则切换为拖拽,如果 mouseup 时位移小于 5px 则判定为点击取点。这个阈值是实际手感测试出来的,5px 比较合适。太小了拖拽误判率高,太大了快速点击的轻微移动会被判定为点击,反而不跟手。
4.4 标定距离偏差大的原因与处理
标定两次点选的位置,和实际距离之间有误差,这个工具测出来就全偏。这个误差来源通常有两个。第一是标定时点选的位置不够精确,户型图边缘的线条本身就有粗有细,鼠标点选可能偏差好几个像素;第二是标定参考长度本身有误,比如用户输入的墙体长度并不是图纸标注值。
针对第一个问题,我加了一个辅助放大镜功能:在鼠标移动时,把鼠标附近的图像区域放大 4 倍画在小窗里,方便用户精确对准线段端点。这个功能代码不多,但对体验提升很大。对于第二个问题,我额外做了一个“重新标定”按钮,标定错了可以一键清空标定点重新来,不用刷新页面。
辅助放大镜的核心逻辑就是在 Canvas 上加一个mousemove监听,实时在角落画一个小区域:
canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const canvasX = e.clientX - rect.left; const canvasY = e.clientY - rect.top; const imgPoint = this.getImgPoint(e); // 画放大镜窗口 this.drawMagnifier(canvasX, canvasY, imgPoint); });把mousemove事件拿到图片坐标后,再画出一个矩形区域,在角落用drawImage按放大系数绘制,同时画一个十字线标示中心点。实现时要注意每次绘制前要做状态保存,绘制后要恢复,避免污染主画面的绘制状态。
4.5 测量结果精度分析和说明
必须坦诚地说,图片测量工具的精度是有限的。因为图片本身的分辨率、拍摄角度、畸变都会引入误差。比如用户拍一张带角度的照片,远处物体的像素比例和近处不一致,单纯的线性比例尺就无法处理这种透视畸变。unitPerPx是在“图片为正视图”的前提下推导的,如果图片本身是斜着拍的,那测量结果只能作为近似参考。
所以我在实际交付的时候,在界面上加了一行提醒文字,明确说明了这个工具的定位:用于在平面图、正投影图上做快速估算,不适合作为工程精确测量的最终依据。专业测量还是需要 CAD 源文件或专门的测绘工具。
另外小数位数的控制也很重要。标定时输入了3600,测量结果后如果直接乘一个近似系数,小数位可能变得特别长,比如873.482591...,这样显示出来既难看又造成不必要的精度假象。我会统一用formatLength做四舍五入和单位切换,这样输出数字不会误导用户。
5. 使用技巧与扩展想法
5.1 提高测量精度的几个实操细节
标定环节是整个工具准确性的命脉,我实际使用的经验是:标定时尽量选择图片上距离最长的已知线段。因为比例尺系数的误差,会等比例放大到所有测量结果中。假设标定时两点之间像素距离是 100px,你的点击误差是 2px,那误差率就是 2%。如果标定距离是 1000px,同样的 2px 点击误差,误差率只有 0.2%。所以,不要为了省事随便找一段短线段做标定,一定要选整张图里最长的、标注也最明确的线。
测量时也要用放大镜或者把视图放大到局部,确认点选的位置确实落在目标线段端点上。线段端点通常有明显的断点特征,图像放大后更好辨认。
5.2 功能扩展:面积测量与数据导出
实现距离测量之后,面积测量几乎是自然延伸。多加一个多边形面积计算函数就行。测量时连续点击形成封闭多边形,最后双击闭合或者自动闭合,用鞋带公式(Shoelace formula)计算像素面积,再乘上unitPerPx * unitPerPx换算成真实面积。鞋带公式很成熟:
polygonArea(points) { let area = 0; for (let i = 0; i < points.length; i++) { const j = (i + 1) % points.length; area += points[i].x * points[j].y; area -= points[j].x * points[i].y; } return Math.abs(area) / 2; }另外可以把测量结果导出成 JSON 或 CSV,方便后续整理和归档。我在自己项目里加了“导出测量记录”,导出的内容包括每段测量的起止点坐标、像素距离、真实距离、比例尺系数和图片名称。对工程预算和设计沟通环节非常有用。
5.3 触摸屏适配
如果需要在平板上使用,量房场景确实很常见。触摸事件的坐标获取方式跟鼠标类似,但要注意三个事件:touchstart、touchmove、touchend。我做过一轮适配,发现最大的坑是触摸事件里的clientX在touchstart的changedTouches[0]里,而不是touches[0]。搞混的话,在多点触控时会取错触点。
另外触摸屏上的“右键撤销”不可用,我改成了一键清空当前测量模式下的最后一个点,并在界面右上角放了一个显眼的撤销按钮。手势下拉的问题也需要注意,触摸拖动时 Canvas 会默认触发页面滚动,需要在touchmove里加e.preventDefault(),这个手势在移动端非常容易误触。
6. 为什么这个方案最适合你这个场景
其实市面上已经有不少前端测量库,比如 OpenCV.js、Fabric.js 的测量扩展,或者干脆用 Leaflet 做地图距离测量。但具体到“一张静态图片上点两个点读取长度”这个需求,用中型框架反而杀鸡用牛刀——库体积大、学习成本高、定制化灵活度还差。原生 Canvas 方案几十行核心代码就能跑起来,没有第三方依赖,体积占了绝对优势。
如果你的使用场景是动态地图,或者需要多图层叠加、地理坐标系换算,那就应该考虑 Leaflet 或者 Mapbox,它们自带成熟的坐标投影和比例尺体系。如果是单纯的图片标注测量,原生 Canvas 够了,维护成本也更低。
我最终交付的版本,还把代码封装成了一个配置项较多的构造函数,支持自定义颜色、字号、单位、显示格式等参数。这样后续如果不需要改动逻辑,只调外观,直接传配置就能用。代码量约 400 行,体积下来不到 10KB,对加载性能几乎零影响。
我自己实际用下来的体验是,这类小功能往往不是技术难度高,而是把交互细节和坐标系处理想清楚。如果你也想做一个类似的图片测量工具,先别急着堆功能,把“点击→换算→测量→标定”这条主链路走通,再优化细节,会顺畅很多。按我上面给的方案直接上手,应该一小时就能跑出原型,剩下的时间都花在调 interactivity 的手感上,值得。
本文还有配套的精品资源,点击获取