Canvas去除图片周围空白区域,返回一张紧实的图片

4,463 阅读3分钟

核心代码

获取图片的像素信息

我们创建一个canvas,渲染图片,然后通过 getImageData 方法拿到图片信息。

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const image = new Image();
image.onload = function () {
   canvas.width = image.width;
   canvas.height = image.height;
   ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
   const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
   console.log(imageData);
}

image.src = 'https://cdn.shopifycdn.net/s/files/1/0504/5931/2316/files/Archer_Idle_1.png?v=1640657443';
image.crossOrigin = "Anonymous";

打印出来的图片信息为:

WeChat798fbd031b1c0071056ee433f503938f.png

  • width: 图片宽度
  • height: 图片高度
  • colorSpace: 色彩模式,这里是srgb
  • data: 色彩信息

我们重点来看看data:

  1. data是个一维数组
  2. 每4个元素构成一个RGBA像素块
  3. 每个元素的值范围都是0-255
  4. RGB代表红绿蓝三原色
  5. A代表透明度(0是完全透明,255是不透明)

获取像素块索引

我们已知widthheight,那么嵌套循环就能获取到每一个像素块索引:

for (let col = 0; col < width; col++) {
    for (let row = 0; row < height; row++) {
        // 当前像素块相对于图片的索引位置
        const pxIndex = (row * width + col);
    }
}

获取每个像素块的RGBA信息

我们有像素块的索引,又知道在色彩信息中,每4个元素表示一个像素颜色,那么我们就能获取到每个像素块的颜色信息了:

const pxStartIndex = pxIndex * 4;
const pxData = {
    r: data[pxStartIndex],
    g: data[pxStartIndex + 1],
    b: data[pxStartIndex + 2],
    a: data[pxStartIndex + 3]
};

计算裁剪的起点和终点坐标

我们需要将png图片周围的透明区域去掉。所以先判断是否存在色彩:

// 不透明
const colorExist = pxData.a !== 0;

起点和终点初始化值一定要先设置成极限值,也就是两点互换位置:

let startX = width,
    startY = height,
    endX = 0,
    endY = 0;
  • startX坐标取当前colstartX的最小值
  • endX坐标取当前colendX的最大值
  • startY坐标取当前rowstartY的最小值
  • endY坐标取当前rowendY的最大值 如果之前初始化的时候没有取极限,使用Math.min判断的时候一直会是0
if (colorExist) {
    startX = Math.min(col, startX);
    endX = Math.max(col, startX);
    startY = Math.min(row, startY);
    endY = Math.max(row, endY);
}

终点需要再向外扩大1px,否则裁剪的时候会少1px

endX += 1;
endY += 1;

我们可能并不想完全贴着图片裁剪,所以可以设置一个内边距padding

startX -= padding;
startY -= padding;
endX += padding;
endY += padding;

优化!更快的计算裁剪点位

上面计算点位需要遍历整张图片的像素,这样在图片偏大的时候,效率方面会有问题。 那我们有什么更好的办法呢?

我们可以单独计算每个方向的偏移,同时执行,然后通过Promise.all统一返回结果。

  • 左偏移:startX 左开始,一列一列去找
const getOffsetLeft = () => {
    for (let i = 0; i < width; i++) {
        for (let j = 0; j < height; j++) {
            const pxStartIndex = (j * width + i) * 4;
            if (data[pxStartIndex + 3] > 0) {
                return Promise.resolve(i);
            }
        }
    }
    return Promise.resolve(0);
};
  • 上偏移:startY 从左开始,一行一行的查找
const getOffsetTop = () => {
    for (let i = 0; i < height; i++) {
        for (let j = 0; j < width; j++) {
            const pxStartIndex = (i * width + j) * 4;
            if (data[pxStartIndex + 3] > 0) {
                return Promise.resolve(i);
            }
        }
    }
    return Promise.resolve(0);
};
  • 右偏移:endX 从右开始,一列一列去找
const getOffsetRight = () => {
    for (let i = width - 1; i >= 0; i--) {
        for (let j = 0; j < height; j++) {
            const pxStartIndex = (j * width + i) * 4;
            if (data[pxStartIndex + 3] > 0) {
                return Promise.resolve(i);
            }
        }
    }
    return Promise.resolve(0);
};
  • 下偏移:endY 从右开始,一行一行的查找
const getOffsetBottom = () => {
    for (let i = height - 1; i >= 0; i--) {
        for (let j = 0; j < width; j++) {
            const pxStartIndex = (i * width + j) * 4;
            if (data[pxStartIndex + 3] > 0) {
                return Promise.resolve(i);
            }
        }
    }
    return Promise.resolve(0);
};
  • 然后一起执行
const queue = [
  getOffsetLeft(),
  getOffsetTop(),
  getOffsetRight(),
  getOffsetBottom()
];

Promise.all(queue).then(res => {
    let [startX, startY, endX, endY] = res;
    ...
})

经测试,一张3000x3000的图片,使用之前方式大概需要200ms,而优化后的方法基本稳定在10ms左右,感觉提升巨大是不是,其实实际使用的时候感觉并不明显,200ms10ms,相对于加载图片的耗时可以完全忽略不计,如果追求极致的性能可以使用优化后的方法,只是代码量会多一点。

优化后的代码在线地址

codepen在线地址

裁剪新图

const cropCanvas = document.createElement("canvas");
const cropCtx = cropCanvas.getContext("2d");
cropCanvas.width = endX - startX;
cropCanvas.height = endY - startY;
cropCtx.drawImage(
    image,
    startX,
    startY,
    cropCanvas.width,
    cropCanvas.height,
    0,
    0,
    cropCanvas.width,
    cropCanvas.height
);
cropCanvas.toDataURL()

在线地址

codepen地址

完整代码

async function handleClear() {
  const url =
    "https://cdn.shopifycdn.net/s/files/1/0343/0275/4948/files/png_7261d2f1-9f99-4972-8e2f-7a00535a9f34.png?v=1634027745";
  // const url =
  //   "https://cdn.shopifycdn.net/s/files/1/0504/5931/2316/files/Archer_Idle_1.png?v=1640657443";
  const base64 = await clearImageEdgeBlank(url, 4);
  document.getElementById("img").setAttribute("src", base64);
}

/**
 * 清楚图片周围空白区域
 * @param {string} url - 图片地址或base64
 * @param {number} [padding=0] - 内边距
 * @return {string} base64 - 裁剪后的图片字符串
 */
function clearImageEdgeBlank(url, padding = 0) {
  return new Promise((resolve, reject) => {
    // create canvas
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d");

    // create image
    const image = new Image();
    image.onload = draw;
    image.src = url;
    image.crossOrigin = "Anonymous";

    function draw() {
      canvas.width = image.width;
      canvas.height = image.height;
      
      ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
      console.log(imageData);
      const { data, width, height } = imageData;

      // 裁剪需要的起点和终点,初始值为画布左上和右下点互换设置成极限值。
      let startX = width,
        startY = height,
        endX = 0,
        endY = 0;

      /*
      col为列,row为行,两层循环构造每一个网格,
      便利所有网格的像素,如果有色彩则设置裁剪的起点和终点
      */
      for (let col = 0; col < width; col++) {
        for (let row = 0; row < height; row++) {
          // 网格索引
          const pxStartIndex = (row * width + col) * 4;

          // 网格的实际像素RGBA
          const pxData = {
            r: data[pxStartIndex],
            g: data[pxStartIndex + 1],
            b: data[pxStartIndex + 2],
            a: data[pxStartIndex + 3]
          };

          // 存在色彩:不透明
          const colorExist = pxData.a !== 0;

          /*
          如果当前像素点有色彩
          startX坐标取当前col和startX的最小值
          endX坐标取当前col和endX的最大值
          startY坐标取当前row和startY的最小值
          endY坐标取当前row和endY的最大值
          */
          if (colorExist) {
            startX = Math.min(col, startX);
            endX = Math.max(col, startX);
            startY = Math.min(row, startY);
            endY = Math.max(row, endY);
          }
        }
      }

      // 右下坐标需要扩展1px,才能完整的截取到图像
      endX += 1;
      endY += 1;

      // 加上padding
      startX -= padding;
      startY -= padding;
      endX += padding;
      endY += padding;

      // 根据计算的起点终点进行裁剪
      const cropCanvas = document.createElement("canvas");
      const cropCtx = cropCanvas.getContext("2d");
      cropCanvas.width = endX - startX;
      cropCanvas.height = endY - startY;
      cropCtx.drawImage(
        image,
        startX,
        startY,
        cropCanvas.width,
        cropCanvas.height,
        0,
        0,
        cropCanvas.width,
        cropCanvas.height
      );

      // rosolve裁剪后的图像字符串
      resolve(cropCanvas.toDataURL());
    }
  });
}