前端图片裁剪总是差几个像素?先把这四套坐标系分清楚

做过图片裁剪的人多半都遇到过这个场景:页面上拖出来的裁剪框看着好好的,点确认,导出的图要么偏了十几像素,要么整体缩小了一圈,要么在高分屏上正常、到同事的老显示器上就错位。

排查这类问题,八成不是算法写错了,是坐标系搞混了。一张图从原始文件到最终裁剪结果,中间至少要经过四套不同的坐标系,每一步都有自己的缩放和偏移。只要有一步没换算,结果就偏。

这篇把这四套坐标系拆开说清楚,再给一份能直接套用的换算代码。

一、四套坐标系分别是什么

假设用户上传了一张 4000×3000 的照片,页面把它显示在一个 800×600 的容器里。

第 1 套:图片的自然坐标系(natural)

就是图片文件本身的像素网格,4000×3000。img.naturalWidth / img.naturalHeight 拿到的就是它。最终裁剪要落在这套坐标上——因为 drawImage 的源矩形参数用的是它。

第 2 套:CSS 布局坐标系(layout)

图片在页面上实际占的 CSS 像素区域。用了 object-fit: contain 之后,800×600 的容器里,这张 4:3 的图正好铺满,显示尺寸是 800×600。但如果图是 4000×1000(4:1),contain 会让它变成 800×200,上下各留 200px 空白。这个空白就是第一个常见的偏移来源。

img.getBoundingClientRect() 拿到的是容器的盒子,不是图片实际绘制的区域。这两个在 object-fit 不是 fill 的时候不相等。

第 3 套:视口坐标系(viewport)

鼠标事件给你的 clientX / clientY 就在这套坐标里,原点是视口左上角。它和布局坐标之间差一个元素的 getBoundingClientRect().left/top,还要考虑页面滚动(clientX 已经排除了滚动,pageX 没有,别混用)。

第 4 套:Canvas 位图坐标系(bitmap)

canvas.width / canvas.height 定义的位图网格,和 canvas.style.width/height 定义的显示尺寸是两回事。在 DPR=2 的屏幕上,一个显示 800px 宽的 canvas,位图通常设成 1600。所有 ctx.drawImagectx.fillRect 的参数都在位图坐标里。

二、错位是怎么产生的

把上面四套串起来,一次裁剪的数据流是:

鼠标 (viewport)
  → 减去元素位置 → 裁剪框 (layout)
  → 除以显示缩放、减去 letterbox 偏移 → 源矩形 (natural)
  → drawImage 到 → 输出画布 (bitmap)

常见的四种错法:

错法一:用容器尺寸算缩放比。 scale = naturalWidth / rect.width,其中 rect 是容器。图和容器比例不一致时,这个 scale 是错的,而且横纵还不一样错。

错法二:忘了 letterbox 偏移。 object-fit: contain 会在短边方向留白。用户在留白区域按下鼠标,换算出来的源坐标会是负数或者超出图片边界,drawImage 那边表现为黑边或者干脆画不出来。

错法三:DPR 乘了两次或者一次没乘。 最典型的是用 getBoundingClientRect() 的值直接当 canvas 的 width,然后又在 ctx.scale(dpr, dpr) 里乘了一遍。结果是导出图刚好放大/缩小一倍。

错法四:CSS transform 没算进去。 容器上如果有 transform: scale()(比如做了缩放预览),getBoundingClientRect() 返回的是变换后的尺寸,而 offsetWidth 返回的是变换前的。混用这两个必错。

三、把换算写对

先写一个函数,算出图片在容器里实际绘制区域contain 模式):

function getRenderedBox(natW, natH, boxW, boxH) {
  const scale = Math.min(boxW / natW, boxH / natH)
  const w = natW * scale
  const h = natH * scale
  return {
    left: (boxW - w) / 2,   // letterbox 偏移
    top:  (boxH - h) / 2,
    width: w,
    height: h,
    scale,                   // layout 像素 → natural 像素的倒数
  }
}

cover 模式把 Math.min 换成 Math.max 即可,偏移会变成负数,表示图片被容器裁掉了一部分,逻辑同样成立。

然后是鼠标坐标 → 图片自然坐标:

function clientToNatural(e, el, natW, natH) {
  const rect = el.getBoundingClientRect()
  const box = getRenderedBox(natW, natH, rect.width, rect.height)

  // viewport → layout(相对容器)
  const lx = e.clientX - rect.left
  const ly = e.clientY - rect.top

  // layout → natural,去掉 letterbox 偏移再除以缩放
  const nx = (lx - box.left) / box.scale
  const ny = (ly - box.top) / box.scale

  // 夹到图片范围内,避免出现负数或越界
  return {
    x: Math.min(Math.max(nx, 0), natW),
    y: Math.min(Math.max(ny, 0), natH),
  }
}

注意 rect.width 已经包含了祖先链上所有 transform: scale 的效果,所以这里不需要单独再处理 transform——只要全程都用 getBoundingClientRect(),不去混 offsetWidth

裁剪框本身在拖动过程中建议只存自然坐标。很多实现存的是 layout 坐标,然后在容器尺寸变化(窗口 resize、侧栏展开)时整个框就飘了。存自然坐标,每次渲染时正向换算回去画,resize 自动跟随:

function naturalToLayout(rectN, el, natW, natH) {
  const rect = el.getBoundingClientRect()
  const box = getRenderedBox(natW, natH, rect.width, rect.height)
  return {
    left:   box.left + rectN.x * box.scale,
    top:    box.top  + rectN.y * box.scale,
    width:  rectN.w * box.scale,
    height: rectN.h * box.scale,
  }
}

裁剪框的选区尺寸和位置以图片自然坐标记录,和屏幕上框的 CSS 尺寸不是一回事

四、导出那一步

导出时只关心自然坐标和输出位图,和页面显示尺寸完全无关:

function cropToBlob(img, rectN, { type = 'image/jpeg', quality = 0.92 } = {}) {
  const canvas = document.createElement('canvas')
  // 输出多大就设多大,不要乘 DPR
  canvas.width  = Math.round(rectN.w)
  canvas.height = Math.round(rectN.h)

  const ctx = canvas.getContext('2d')
  ctx.imageSmoothingQuality = 'high'
  ctx.drawImage(
    img,
    rectN.x, rectN.y, rectN.w, rectN.h,   // 源矩形:自然坐标
    0, 0, canvas.width, canvas.height     // 目标矩形:位图坐标
  )

  return new Promise(res => canvas.toBlob(res, type, quality))
}

这里不要乘 DPR。 DPR 是"屏幕上一个 CSS 像素用几个物理像素画",只跟显示有关。导出的是文件,1:1 用自然坐标就是原图清晰度,乘了 DPR 只是把图插值放大,文件变大四倍而画面并没有变清楚。

DPR 该出现在哪?只在预览画布上:

const dpr = window.devicePixelRatio || 1
previewCanvas.width  = cssW * dpr
previewCanvas.height = cssH * dpr
previewCanvas.style.width  = cssW + 'px'
previewCanvas.style.height = cssH + 'px'
previewCtx.scale(dpr, dpr)   // 之后所有绘制指令都用 CSS 像素写

设完 ctx.scale(dpr, dpr),后面画裁剪框、控制点、蒙层时就都按 CSS 像素写,不用到处乘 dpr,这是最不容易错的写法。

五、几个容易忽略的细节

EXIF 方向。 手机拍的 JPG 常带 orientation 标记,实际像素是横的,靠 EXIF 告诉软件"请旋转 90 度显示"。现代浏览器在 <img> 渲染时会自动应用这个旋转(image-orientation: from-image 是默认值),但 naturalWidth/naturalHeight 在各浏览器上的表现历史上并不一致,drawImage 的行为也曾经和 <img> 显示不一致。

稳妥的做法是上传后先用 createImageBitmap(file, { imageOrientation: 'from-image' }) 解一次,拿到已经转正的位图,后续全部基于它来算:

const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
// bitmap.width / bitmap.height 就是转正后的尺寸,drawImage 直接用

老浏览器不支持这个选项时,需要自己读 EXIF 再手动旋转,代码量不小,但逻辑是清楚的。

四舍五入要统一。 自然坐标经过除法之后几乎必然是小数。如果拖动时向下取整、导出时四舍五入,就会出现"预览里框住的和导出的差一像素"。建议只在最后一步 Math.round,中间全程保留小数。

边界裁剪要在自然坐标里做。 在 layout 坐标里夹边界,遇到 letterbox 时夹的是容器边,不是图片边,还是会越界。

最小尺寸限制。 用户可能把框拖成 0×0 或者 1×2。canvas.width = 0toBlob 在部分浏览器返回 null,不是抛异常,很容易变成一个静默失败。加一条下限判断,顺手把 null 也处理掉。

大图的内存。 一张 8000×6000 的图,解码成位图是 8000×6000×4 ≈ 183MB。移动端同时开两三个这样的 canvas 很容易被系统杀掉。真要支持超大图,得先按屏幕尺寸降采样出一份预览用的小图,裁剪时才回到原图去取那一块。

六、说点这套方案做不到的

它不处理旋转裁剪。 上面所有换算都假设裁剪框和图片是同向的矩形。一旦允许用户旋转裁剪框,drawImage 的源矩形参数就不够用了,得改成先 ctx.translate + ctx.rotate 再画,换算复杂度上一个台阶。

它不保证跨浏览器像素级一致。 不同浏览器的图片解码和缩放插值算法有差别,同样的参数,Chrome 和 Safari 导出的 JPEG 二进制不会完全相同,肉眼看不出来,但如果你的业务要对文件做哈希比对,这点得提前知道。

它不解决"裁出来变糊"。 如果用户在一张 800×600 的图上裁出 100×80 再放大到 400×320 展示,糊是必然的,这是信息量问题,不是代码问题。真要放大,那是超分辨率的事,和裁剪无关。

toBlob 是异步且可能失败的。 内存不足、canvas 被污染(跨域图片没有正确的 CORS 头)都会让它失败。跨域这条尤其常见——img.crossOrigin = 'anonymous' 加上服务端的 Access-Control-Allow-Origin 缺一不可,否则 toBlob 直接抛 SecurityError

最后

这些坐标系的换算不难,难的是全流程只用一套统一的表示。我的经验是定一条规矩:内部状态一律存自然坐标,只在渲染那一刻换算成 layout,只在导出那一刻换算成 bitmap。中间任何一个环节想"顺手用一下 rect.width",就是 bug 的开始。

我在 forxi.cn 上做图片裁剪这个功能的时候,前后翻车过好几次,基本都是上面列的那几种。这篇写的是收敛之后的通用做法,代码是照着思路重写的示例,不涉及具体产品实现,拿去直接改改就能用。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值