5

1、图像的基础知识

1.1 位图和矢量图

图片一般可以分为 位图 和 矢量图

类别位图 Bitmap矢量图 Vector
定义由像素点组成的图像,每个像素点都有自己的颜色和位置信息。由数学方程描述的图像,使用直线、曲线、多边形等数学形状来定义图像。
常见格式JPEG、PNG、GIF 等SVG、AI 等
清晰度具有分辨率的概念,图像清晰度取决于分辨率的大小不依赖于分辨率,无论放大多少倍,图像都能保持清晰
优缺点优点是适合存储真实场景的图像,如照片;缺点是放大会失真,出现锯齿或模糊现象优点是无损放大缩小,适合图标、标志、图表等需要放大缩小的场景;缺点是无法准确地表达真实场景

1.2 颜色空间

颜色模型,是用来表示颜色的数学模型。一般的颜色模型,可以按照如下分类:

  • 面向硬件设备的颜色模型:RGB、CMYK、YCrCb;
  • 面向视觉感知的颜色模型:HSL、HSV(B)、HSI、Lab;

浏览器支持 RGB 和 HSL 两种颜色模型:

  • RGB 模型

    • RGB 模型的颜色由红(Red)、绿(Green)、蓝(Blue)三个颜色通道的不同亮度和组合来表示。每个通道的取值范围是 0-255,通过调整这三个通道的亮度可以得到不同的颜色。
    • RGB 模型 是一种加色混色模型,在叠加混合的过程中,亮度等于色彩亮度的综合,混合的越多亮度就越高,三色通道中每个颜色有 256 阶的亮度,为 0 时最暗,255 时最亮。 
  • HSL 模型

    • HSL 是对色相 (Hue)、饱和度 (Saturation)、亮度 (Lightness) 的处理得到颜色的一种模型。
    • 色相:代表人眼所能看到的不同的颜色,本质就是一种颜色。色相分布在一个圆环上,取值范围则是 0-360°,每个角度代表着一种颜色。

      image

    • 饱和度:是指颜色的强度或纯度,使用 0 ~ 100% 度量。表示色相中颜色成分所占的比例,数值越大,颜色中的灰色越少,颜色越鲜艳;
    • 亮度:表现颜色的明暗程度,使用 0 ~ 100% 度量。反映色彩中混入的黑白两色,数值越小混入的黑色越多;数值越大混入的白色越多,50% 处只有纯色;
  • HSV 模型

    • HSV 采用色相 (Hue)、饱和度 (Saturation)、明度(Value) 3个参数来表示颜色的一种方式。 HSV 和 HSL 两个模型都是更偏向于视觉上直观的感觉。 
    • Chrome 的颜色色盘,是基于 HSV 模型

1.3 像素和分辨率

image

位图放大后,会看到一个个小格子,每个格子为 1*1 的像素点。一张图有多少个像素点,与图片的分辨率有关,即常说的图片宽高尺寸,比如一张图的宽是 1920、高是 1080,则它拥有 1920 * 1080 = 2073600 个像素点。1920 * 1080就是图像的分辨率。

像素作为位图的最基本单位,会通过颜色模型来描述,最常见的即是 RGB,加上透明通道是 RGBA。

RGBA 共四个通道分量,每个分量使用 1 个字节来表示。每个字节有 8 个比特位,二进制取值在 00000000-11111111,即有 0-255 共 256 种取值。

1.4 位深度

色彩深度又叫作位深度,是针对位图的,表示位图中存储 1 个像素的颜色所需要的二进制位的数量。一般色彩深度越高,可用的颜色就越多,图片的色彩也就会越丰富,对应的图像数据更多,图像的体积就更大。

例如在 RGB 模型中,共 3 个通道分量,每个分量使用 1 个字节来表示。每个字节有 8 个比特位,因此对应 24 位图。而每个字节的二进制取值在 00000000-11111111之间,即有 0-255 共 256 种取值。那么 RGB = R(8) * G(8) * B(8) = 256 * 256 * 256,共1600多万色。如果多加一个 Alpha 通道,就是 32 位图。

bmp 格式的图片一般不会压缩,约等于原始图片,可以是 1-32位 的多种位深度的图片;

位数颜色数量说明图片举例
12单色二值图,只有一位二进制,0或1,它的每个像素将只有两个颜色:黑(0)和白(255)。image
41616种颜色image
8256256种颜色,gif动图一般都是简单的8位图image
1665536增强色,人眼能满足的视觉image
2416777216真彩色,人眼视觉上的极限,jpg 不支持透明通道位深度是24;image
321677721624位颜色+8位透明度,png 支持透明通道则位深度是32;

1.5 压缩方式

图片的压缩方式一般是三类:

  • 无压缩

    • 几乎不对图片进行压缩处理,尽量以原图的方式呈现图片,如 bmp 格式的图片就属于这一类。
  • 无损压缩

    • 很多图片都采用无损压缩的方式,如 png、gif 等。
    • 无损压缩采用对图片数据进行编码压缩,以不降低图片质量的情况下减少图片的大小,
    • 无损压缩只是对像素数据压缩,不会减少像素,几乎没有损耗,所以可以恢复到原始图片。
  • 有损压缩

    • 有损压缩最常见的就是 jpg 格式的图片,它一般是使用去除人眼无法识别的图片像素的方法,降低了图片的质量用以大幅度的减少图片的大小。
    • 这种情况下,有损压缩减少了图片的像素点,导致图片数据部分丢失了,属于不可逆的,所以无法恢复到原始图片。

1.6 图片格式

目前主流浏览器支持的图片格式一般有 7 种:jpg、png、gif、svg、bmp、ico、webp

格式压缩透明动画其他
jpg有损色彩丰富、文件小
png无损apng支持动画
gif无损256色、文件较小
bmp无压缩大,约等于原图(raw数据格式)
svg无损简单图形,矢量图
ico无损存储单个图案、多尺寸、多色板的图标文件
webp无损、有损目前相对最优
avif无损超压缩,新出,支持少

2、Javascript中的图像数据对象

用 JS 中处理图像时,大致有四种不同的图像数据类型:

  • 文件

    • Blob:该对象表示的是一个不可变、原始数据的类文件对象,本质上是一个二进制对象
    • File:继承自Blob对象,是一种特殊类型的Blob,它扩展了对系统文件的支持能力
    • ArrayBuffer:表示通用的、固定长度的原始二进制缓冲区
  • URL

    • Data-URL:带 Base64 字符串编码的图像资源
    • Object-URL:浏览器存储的Blob对象,并维护生成的一个图像资源
    • Http-URL:存储于服务器上的图像资源
  • 本地图像资源:本地图像资源

    • Image:img (HTMLImageElement),DOM标签 <img> 对应的对象和类型,用于加载图像资源
    • Canvas:canvas (HTMLCanvasElement)、ImageData、ImageBitmap。DOM标签 <canvas> 对应的对象和类型,用于加载图像资源和操作图像数据。

2.1 Image

Image 是最常见的对象,主要作用是加载一张图片资源,创建并返回一个新的 HTMLImageElement 实例,拥有图像元素的所有属性和事件。

const image = new Image();
img.src = 'chrome.png';

Image 对象实例的一些常用的属性和事件:

  • 属性:src、width、height、complete、alt、name等
  • 事件:onload、onerror、onabort等

src 属性可以取值:

  • 本地图像资源路径
  • HTTP-URL
  • Object-URL
  • Base64 图像字符串

new Image() 构造的图像实例,和 document.createElement('img') 创建一个图像对象,几乎是一样的。

单张或少量图片的加载性能上,Image() 和 createElement() 几乎没区别,都可以使用;但在大批量加载图片资源时,Image() 比 createElement() 稍微快一些。

2.2 Canvas

image

ImageData

ImageData 对象表示 canvas 元素指定区域的像素数据。前面提过,图片像素数据实际上就是一个个的颜色值,ImageData 使用 RGBA 颜色模型来表示,所以 ImageData 对象的像素数据,长度为 width * height * 4

new ImageData(array, width, height);
new ImageData(width, height);
  • array:Uint8ClampedArray 类型数组的实例,存储的是像素点的颜色值数据,数组元素按每个像素点的 RGBA 4通道的数值依次排列,该数组的长度必须为 windth * height * 4,否则报错。如果不给定该数组参数,则会根据宽高创建一个全透明的图像。
  • width:图像宽度
  • height:图像高度

Uint8ClampedArray 是8位无符号整型固定数组,属于 11 个类型化数组(TypeArray)中的一个,元素值固定在 0-255 区间。这个特点对于存储像素点的颜色值正好,RGBA 四个通道,每个通道的取值都是 0 - 255 间的数字。如[255, 0, 0, 255]表示红色、无透明。

image

ImageData 在 Canvas中应用
ImageData 图像数据,是基于浏览器的 Canvas 环境,应用也都是在 Canvas 操作中,常见的如创建方法 createImageData()、读取方法 getImageData()、更新方法 putImageData()

Canvas 操作 ImageData 的方法

  • createImageData():创建一个全新的空的ImageData对象,与 ImageData() 构造函数作用一样,返回像素点信息数据。

    context.createImageData(width, height)
    context.createImageData(imagedata)
  • getImageData():返回canvas画布中部分或全部的像素点信息数据。

    context.getImageData(sx, sy, sWidth, sHeight);
  • putImageData():将指定的 ImageData 对象像素点数据绘制到位图上。

    context.putImageData(imagedata, dx, dy [, dirtyX, [ dirtyY, [ dirtyWidth, [dirtyHeight]]]]);

使用 ImageData 的栗子

alt text

// 随机函数用于取颜色值
const randomRGB = () => Math.floor(Math.random() * (255 - 0) + 0)

// 定义宽高100*100的canvas元素
const canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 100;
document.body.append(canvas);

// 定义义ImageData像素点对象实例
const ctx = canvas.getContext('2d');
const imagedata = ctx.createImageData(100, 100);
const length = imagedata.data.length;
// 给imagedata的元素赋值,R通道默认255,BG通道取随机值
for (let i = 0; i < length; i += 4) {
  imagedata.data[i] = 255;
  imagedata.data[i + 1] = randomInRange();
  imagedata.data[i + 2] = randomInRange();
  imagedata.data[i + 3] = 255;
}
// 像素点绘制
ctx.putImageData(imagedata, 0, 0);
// 定时器 将颜色值的R通道改为0,再重新绘制图像
setTimeout(() => {
  const imgData = ctx.getImageData(0, 0, 100, 100);
  const len = imgData.data.length;
  for (let i = 0; i < len; i += 4) {
    imgData.data[i] = 0;
  }
  ctx.putImageData(imgData, 0, 0)
}, 1000)

ImageBitmap

ImageBitmap 表示一个位图图像,可绘制到 canvas 中,并且具有低延迟的特性。

  • 与 ImageData 一样的是,他们都是在浏览器环境下可以访问的全局对象。
  • 与 ImageData 不一样的是,ImageBitmap 没有构造函数,可以直接引用对象(无意义),但无法通过构造函数创建,而需要借助 createImageBitmap() 进行创建。

createImageBitmap() 接受不同的图像资源,返回一个成功结果为ImageBitmap的 Promise 异步对象。

createImageBitmap(image[, options])
createImageBitmap(image, sx, sy, sw, sh[, options])

createImageBitmap 参数

createImageBitmap 可以直接读取多种图像数据源,比如 ImageData、File、以及多种HTML元素对象等等,这个函数更加灵活的处理图像数据。在 canvas 中使用 ImageBitmap 主要使用 drawImage 函数加载位图对象:

<input id="input-file" type="file" accept="image/*" multiple />
document.getElementById('input-file').onchange = (e) => {
  const file = e.target.files[0]
  createImageBitmap(file).then(imageBitmap => {
    const canvas = document.createElement('canvas')
    canvas.width = imageBitmap.width
    canvas.height = imageBitmap.height
    const ctx = canvas.getContext('2d')
    ctx.drawImage(imageBitmap, 0, 0)
    document.body.append(canvas)
  })
}

2.3 URL

Base64

下面的这段字符串,应该是大家都很常见的。通过这种固定的格式,来表示一张图片,并被浏览器识别,可以完整的展示出图片:

data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0c......

Base64 是在电子邮件中扩充 MIME 后,定义了非ASCII码的编码传输规则,基于 64 个可打印字符来表示二进制数据的编解码方式。正因为可编解码,所以它主要的作用不在于安全性,而在于让内容能在各个网关间无错的传输。正常情况下,Base64编码的数据体积通常比原数据的体积大三分之一。

Base64 在前端方面的应用,多数都是针对图片的处理,一般都是基于DataURL的方式来使用。

Data URL 由 data:前缀MIME类型(表明数据类型)、base64标志位(如果是文本,则可选)以及 数据本身(Base64字符串)四部分组成。

具体的格式:data:\[<mime type>\]\[;base64\],<data>

FileReader用来读取文件的数据,可以通过它的 readAsDataURL() 方法,将文件数据读取为 Base64 编码的字符串数据:

const reader = new FileReader()
reader.onload = () => {
  let base64Img = reader.result;
};
reader.readAsDataURL(file);

Canvas 有提供 toDataURL()方法,将画布导出生成为一张图片,该图片将以Base64编码的格式进行保存。

const dataUrl = canvasEl.toDataURL();

Object-URL

URL 是浏览器环境提供的,用于处理 url 链接的一个接口对象。可以通过它,解析、构造、规范和编码各种 url 链接。URL 提供的一个静态方法 createObjectURL(),可以用来处理 Blob 和 File 文件对象。它返回一个包含给定的 Blob 或 File 对象的 url,就可以当做文件资源被加载。这个 url 就是被称为伪协议的 Objct URL。 Object URL的格式为:blob:origin/唯一标识(uuid)

document.getElementById('input-file').onchange = (e) => {
  const file = e.target.files[0];
  const url = URL.createObjectURL(file);
  const img = new Image();
  img.onload = () => {
    document.body.append(img);
  }
  img.src = url;
}

image

blob:http://localhost:8088/29c8f4a5-9b47-436f-8983-03643c917f1c 就是一个 object-url

ObjctURL 的生命周期和它的窗口同步,窗口关闭这个 url 就自动释放了。如果要手动释放,则需要URL的另外一个静态方法:URL.revokeObjectURL(),它用于销毁之前创建的URL实例,在合适的时机调用即可销毁 Object URL。

Http-URL

Http-URL 很常见,大部分网络上的图片都是这种形式:

https://img.alicdn.com/imgextra/i2/O1CN01rQWEDB1YJuXOhBr44\_!!6000000003039-0-tps-800-1200.jpg

本地图片

本地图片通常是通过 File 文件来体现,扩展了系统文件的支持能力。

image

2.4 文件

Blob

Blob,即 Binary Large Object,本质上是一个二进制对象,该对象表示的是一个不可变(只读)、原始数据的类文件对象。

image

URL 转 Blob。在服务接口请求中,可以将 url 转换成blob对象:

fetch(url).then((res) => res.blob());

Canvas 提供了转 Blob 的函数:

canvas.toBlob(callback, mimeType, quality);

File

File对象继承了 Blob 对象,是一种特殊类型的 Blob,它扩展了对系统文件的支持能力。File 提供文件信息,并能够在 javascript 中进行访问,一般在使用 <input> 标签选择文件时返回。File 相较于 Blob 还多了 lastModified name 等属性。

<input id="input-file" type="file" accept="image/*" />
document.getElementById('input-file').onchange = (e) => {
  const file = e.target.files[0]
  console.log(file)
}

image

ArrayBuffer

表示通用的、固定长度的原始二进制缓冲区,Blob 函数构造的入参之一类型就是 ArrayBuffer。Blob 也提供了 .arrayBuffer 方法将文件转换为 ArrayBuffer

image

ArrayBuffer 与前述的图像像素数据,怎么看着有些像?但其实完全是两个不同的概念。我们以同一张图片为例,上传后获取图片文件的 arraybuffer,并在 canvas 中显示获取图片的 imageData:

<input id="input-file" type="file" accept="image/*" />
<canvas id="canvas-output />
  
document.getElementById('input-file').onchange = (e) => {
  const file = e.target.files[0];
  file.arrayBuffer().then((arrayBuffer) => {
      console.log(arrayBuffer);
  });
  
  const url = URL.createObjectURL(file);
  const image = new Image();
  image.onload = () => {
      const canvas = document.createElement('canvas');
      canvas.width = image.width;
      canvas.height = image.height;
      const ctx = canvas.getContext('2d');
      ctx.drawImage(image, 0, 0);
      const imageData = ctx.getImageData(0, 0, image.width, image.height);
      console.log(imageData);
  };
  image.src = url;
}

ArrayBuffer 是最原始的二进制文件,ImageData 是直观的原始图像像素数据。

image

如果要将二进制文件变成图像像素数据,首先得先经过格式解码,比如 jpg 或者 png 的解码器是不同的,而要将图像像素数据转换为二进制文件,则需要经过编码器。这个编码和解码的过程,在 Canvas 中已经内置帮我们实现了。(Chrome Canvas 底层使用的是 skia 图形渲染库)

图像的编码和解码也是数字图像中重要的一部分。如果将原始图像的像素数据存下来文件会非常大。例如上述图片 985x985,如果直接存储 ImageData 的数据,所占文件大小为 985*985*4/(1024*1024) = 3.7M,而实际文件大小在 914k左右(936056/(1024*1024)。

3、数字图像处理

传统的数字图像处理是指通过计算机对图像进行去噪、增强、复原、分割、提取特征等处理方法和技术。在前端能够将图像数字化的基础之上,我们就可以应用各种数字图像处理的算法。

一般图像处理可以分为这些方面:

  • 基本运算:对图像执行一些基本的数学运算。主要可分为点运算(线性 & 分段 & 非线性点运算)、代数运算(加法 & 减法运算)、逻辑运算、几何运算(图像平移 & 旋转 & 翻转 & 镜像 & 缩放)
  • 图像压缩:减少图像中的冗余信息,以更高效的格式进行存储和传输数据。一般可分为有损压缩和无损压缩;
  • 图像增强:提高图像对比度与清晰度,使改善后的图像更适应于人的视觉特性或易于机器识别。简单来说,就是要突出感兴趣的特征,抑制不感兴趣的特征。如强化图像中的高频分量,可使图像中物体轮廓清晰,细节更加明显;而强化图像中的低频分量可以减少图像中的噪声影响。
  • 图像复原:利用退化过程的先验知识,去恢复已被退化图像的本来面目,比如高斯滤波就是用来去除高斯噪声、均值滤波和中值滤波有助于去除胡椒噪声、边滤波则能够在滤波的同时保证一定的边缘信息,但是计算复杂度较高。
  • 图像分割:图像分割是将图像中有意义的特征部分提取出来,其有意义的特征有图像中的边缘、区域等,这是进一步进行图像识别、分析和理解的基础。
  • 图像形态学操作:指的是一系列处理图像形状特征的图像处理技术,比如腐蚀和膨胀、开运算与闭运算、形态学梯度(用于保留边缘轮廓)、白色和黑色顶帽变换。

因为图像处理是本身是一门学科,底层还涉及到很多算法和数学,具体也不再赘述,在网上都有相关资料可以参考。

在业务中,我们有很多针对图像数据的操作,可以以这些应用为例来详细来看看。

3.1 图像二值化

在 SD 生成任务的时候,需要将图片中不变的部分作为 Mask 传给模型,而 Mask 就是一张二值图片,即只有黑白。如下所示:

原图抠图Mask
imageimageimage

用户通过抠图操作以后,得到图2 的结果,那么如何从抠图结果转换到 图3 的 Mask 图呢?其实也很简单,遍历所有图像所有像素,如果某个像素值的 alpha 通道值是0,那么新图中对应的像素点颜色为黑,否则为白。

const binaryFilter = (imgData) => {
  const data = imgData.data;
  for (let i = 0; i < data.length; i += 4) {
    const alpha = data[i + 3]
    const value = alpha === 0 ? 0 : 255;
    data[i] = value;
    data[i + 1] = value;
    data[i + 2] = value;
    data[i + 3] = 255; // alpha通道置位有值
  }
  return imgData;
};
const image = new Image();
image.onload = () => {
  const context = canvas.getContext('2d');
  context.drawImage(image, 0,0)
  const imageData = context.getImageData(0, 0, image.width, image.height);
  context.putImageData(binaryFilter(imageData), 0, 0) // 绘制处理后图片
}
image.src = 'xxxx';

3.2 图像混合

如果我们觉得原图的背景不好看,我们想在抠图主体基础上给它换个背景,作为 SD 的输入图呢?

背景图抠图叠加图
imageimageimage

这个过程是图像混合(Blend Mode),简单理解是对两张图片(source 源和destination 目标)的对应像素点的像素值进行加和运算得到新的像素值,加和的计算逻辑有很多,比如像素值直接相加,比如某些条件下取 src 的像素值,某些条件下取 dst 的像素值等等。

image

在我们的诉求里,是 srcOver模式,即 抠图 叠加在 背景图之上。这里默认两张图的大小相同

const srcOver = (src, dst) => {
  const a = dst.a + src.a - dst.a * src.a;
  const r = (src.r * src.a + dst.r * dst.a * (1 - src.a)) / a;
  const g = (src.g * src.a + dst.g * dst.a * (1 - src.a)) / a;
  const b = (src.b * src.a + dst.b * dst.a * (1 - src.a)) / a;
  return {
    r, g, b, a
  };
};
const composite = (srcImageData, dstImageData) => {
  const srcData = srcImageData.data;
  const dstData = dstImageData.data;
  for (let i = 0; i < srcData.length; i += 4) {
    const src = {r: srcData[i], g: srcData[i+1], b: srcData[i+2], a: srcData[i+3] };
    const dst = {r: dstData[i], g: dstData[i+1], b: dstData[i+2], a: dstData[i+3] };
    const value = srcOver(src, dst);
    dstData[i] = value.r;
    dstData[i+1] = value.g;
    dstData[i+2] = value.b;
    dstData[i+3] = value.a;
  }
  return dstImageData;
}

3.3 图像滤镜

我们还可以对图像数据应用简单的数学运算,实现一些滤镜效果,例如复古滤镜:

const sepiaFilter = (imgData) => {
  let d = imgData.data
  for (let i = 0; i < d.length; i += 4) {
    let r = d[i];
    let g = d[i + 1];
    let b = d[i + 2];
    d[i] = (r * 0.393) + (g * 0.769) + (b * 0.189); // red
    d[i + 1] = (r * 0.349) + (g * 0.686) + (b * 0.168); // green
    d[i + 2] = (r * 0.272) + (g * 0.534) + (b * 0.131); // blue
  }
  return imgData;
}
imageimage

通过控制每个像素4个数据的值,即可达到简单滤镜的效果。但是复杂的滤镜比如边缘检测,就需要用到卷积运算来实现。

3.4 图像卷积

1、卷积运算过程

image

卷积运算是使用一个卷积核对输入图像中的每个像素进行一系列四则运算。 卷积核(算子)是用来做图像处理时的矩阵,通常为3x3矩阵。 使用卷积进行计算时,需要将卷积核的中心放置在要计算的像素上,一次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结构就是该位置的新像素值。

image

按照我们上面讲的图片卷积,如果原始图片尺寸为6 x 6,卷积核尺寸为3 x 3,则卷积后的图片尺寸为(6-3+1) x (6-3+1) = 4 x 4,卷积运算后,输出图片尺寸缩小了,这显然不是我们想要的结果! 为了解决这个问题,可以使用padding方法,即把原始图片尺寸进行扩展,扩展区域补零,扩展尺寸为卷积核的半径(3x3卷积核半径为1,5x5卷积核半径为2)。

image

一个尺寸6 x 6的数据矩阵,经过padding后,尺寸变为8 * 8,卷积运算后输出尺寸为 6 x 6,保证了图片尺寸不变化。

2、卷积核特性

  • 大小应该是奇数,这样它才有一个中心,例如3x3,5x5或者7x7。
  • 卷积核上的每一位乘数被称为权值,它们决定了这个像素的分量有多重。
  • 它们的总和加起来如果等于1,计算结果不会改变图像的灰度强度。
  • 如果大于1,会增加灰度强度,计算结果使得图像变亮。
  • 如果小于1,会减少灰度强度,计算结果使得图像变暗。
  • 如果和为0,计算结果图像不会变黑,但也会非常暗。

3、卷积核函数

对 imageData 应用卷积核,默认卷积核为 3x3 大小

// 卷积计算函数
function convolutionMatrix(input, kernel) {
    const w = input.width;
    const h = input.height;
    const inputData = input.data;
    const output = new ImageData(w, h);
    const outputData = output.data;
    for (let y = 1; y < h - 1; y += 1) {
        for (let x = 1; x < w - 1; x += 1) {
            for (let c = 0; c < 3; c += 1) {
                let i = (y * w + x) * 4 + c;
                outputData[i] = kernel[0] * inputData[i - w * 4 - 4] +
                        kernel[1] * inputData[i - w * 4] +
                        kernel[2] * inputData[i - w * 4 + 4] +
                        kernel[3] * inputData[i - 4] +
                        kernel[4] * inputData[i] +
                        kernel[5] * inputData[i + 4] +
                        kernel[6] * inputData[i + w * 4 - 4] +
                        kernel[7] * inputData[i + w * 4] +
                        kernel[8] * inputData[i + w * 4 + 4];
            }
            outputData[(y * w + x) * 4 + 3] = 255;
        }
    }
    return output;
}

卷积核的设定是一件比较有技术+运气的事,这里不展开描述why。我们以应用为例:

边缘检测锐化浮雕
[-1, -1, -1,  -1, 8, -1,  -1, -1, -1][-1, -1, -1,  -1, 9, -1,  -1, -1, -1][-2, -1, 0,  -1, 1, -1,  0, 1, 2]
imageimageimage

3.5 图像压缩

在上传素材图片的阶段,考虑到后续算法处理的性能问题,如果图片分辨率过大,会爆显存,因此如果用户上传的图片分辨率过大,需要先压缩图像到某个尺寸范围内。这儿的压缩是指尺寸层面的压缩,用 resize 来描述更合适,显然从一张大分辨率的图变成小分辨率,肯定会导致图像信息丢失,属于有损压缩。

Canvas 压缩

Javascript 实现图片压缩最简单的方法是利用 canvas 的绘制图片能力和导出图片能力,主要涉及到两个 API drawImage 和 toBlob

  • 绘制时 压缩尺寸

    用 canvas 绘制图片,使用 drawImage API,将大尺寸图片绘制到小尺寸的画布上

    canvas.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

    image

  • 导出时压缩质量

    用 canvas 绘制图片后使用 toBlob 或者 toDataUrl 导出,第三个参数 quality 可以制定导出图片的质量。

    canvas.toBlob(callback, type, quality)

使用 canvas 将原图原图从 2768x4927 压缩到 674x1200:

原图canvas压缩PS
imageimageimage

canvas 压缩的效果和 ps 一对比就会明显感觉到差异,细节锯齿比较明显。

图像尺寸压缩的原理

图像尺寸的压缩具体到像素级别上,比如一张 1200x1200 的图缩小 0.5 倍,缩放到 600x600 像素,那么压缩过程需要确定压缩图上某个像素点,如压缩图中第一行第一列的像素值,与原图中像素值的计算关系是什么。这个过程也称为插值,和前述的卷积核数学层面是相同的。插值方式有很多种,结果也不尽相同。

image

传统的图像插值算法主要有以下几种:最邻近插值法;双线性插值法;双三次插值法;lanczos插值。以上算法效果按顺序越来越好,但计算量也是越来越大。

原图最邻近插值二次插值三次插值lanczos(a=2)lanczos(a=3)
imageimageimageimageimageimage

上面的一组效果图均是先将原图缩小50%,然后使用不同算法放大到原图大小得到的。由上面这组图我们可以发现:

- 效果最差的是最邻近插值算法,效果最好的是双线性三次插值,Lanczos 算法跟三次插值大致一致;
- 算法耗时上,最邻近插值速度最快,三次插值速度最慢,而 Lanczos 算法与二次插值相仿。

  • 最邻近插值

    在一维空间中,最近点插值就相当于四舍五入取整。在二维图像中,像素点的坐标都是整数,该方法就是选取离目标点最近的点。虽然简单,但是很简单粗暴,变换后的每个像素点的像素值,只由原图像中的一个像素点确定,放大后的图像有很严重的马赛克,会出现明显的块状效应;缩小后的图像有很严重的失真。

    image

  • 双线性插值

    双线性插值参考了源像素相应位置周围2x2个点的值,根据两个方向位置取对应的权重,经过计算之后得到目标图像。双线性内插值算法在图像的缩放处理中具有抗锯齿功能,效果上比较平滑。但是会造成图像细节退化,尤其是缩小时。

    image

  • 双三次插值

    效果过上比双线性插值更少锯齿, 更平滑。双三次比双线性的采样点更多,即取目标像素点周围的16个采样点的加权平均求得目标像素值。并且计算权重的过滤函数是三次多项式。

    imageimageimage
  • Lanczos 插值

    一维的 Lanczos 插值是在目标点的左边和右边各取四个点做插值,这八个点的权重是由高阶函数计算得到。二维的兰索斯插值在x,y方向分别对相邻的八个点进行插值,也就是计算加权和,所以它是一个8x8的描述子。

    效果上比双三次插值更清晰锐利。但在图像的高频信号区域(像素陡变的地方,比如素描的线条边缘),会有振铃效应(Ringing Artifact)。Lanczos 插值取卷积核为4*4时,计算过程对应的矩阵表示和“双三次插值矩阵”一样。

    image

    通过 chromium 中 drawImage 源码 分析可以知道,drawImage 默认使用的是双线性插值方法,那么如果要提高压缩图片的质量,需要采用更优的采样方法。

工具库

但是看看上面 Lanczos 插值算法的公式,就已经劝退了大半,而且计算量这么大,浏览器性能也无法保证。

先来看看前端有什么图片压缩的工具库:

原图canvas压缩BIC compressorjspython-PILphotopeapicaPS
imageimageimageimageimageimageimageimage

结论:

  • ps pica photopea 的压缩效果都很好,细节平滑
  • 前端使用 canvas 的压缩,不管是 browser-image-compression 还是 compressorjs, 细节锯齿比较明显,和 pica pohtopea 相差比较大
  • canvas压缩 和 PIL 默认的压缩效果差不多

Pica

https://nodeca.github.io/pica/demo/

image

  • Pica 的定位是在浏览器上实现高质量而且高性能的图片大小调整,目标是在浏览器中以最快的速度进行高品质图像缩放。
  • Pica内置了四个插值算法:最邻近插值、hermite插值、lanczos(2)差值、lanczos(3)插值和 mks2013 插值
  • Pica 有一个执行数学计算的底层库(主要涉及到卷积计算),尽可能地减少了封装带来的影响,会根据浏览器环境,从从web-workers,web assembly,createImageBitmap 和 纯 JS 中自动选择最佳的可用技术。
  • 值得一提的是,Pica 默认使用 mks 作为差值算法,magic kernel 也是一个很神奇的东西,有兴趣可以参看这篇文章。效果是好于 Lanczos 的,mks 也是是 Facebook 和 Instagram 内部采用的图像压缩内核。

3.6 图像液化

液化是在预处理操作中需要的操作,改变素材的某些形态与模特进行对齐。

在图像处理中,液化是图像扭曲中的一种,属于图像形态学操作。核心是对图像进行几何变换,即将源图像每个像素点的位置通过某种函数映射到目标图像中的相应位置,从而实现图像的扭曲变形。

image

图像扭曲可以被应用在很多领域,比如:

特征点匹配图像拼接三维重建 — 纹理映射图像融合
imageimageimageimage

此外在各类图像软件中也被广泛使用,至少都会提供一种图像扭曲工具,或者基于图像扭曲的效果。例如在 PS 中的图像扭曲应用如下图,美图秀秀常用的瘦脸工具,也是图像扭曲典型的应用案例。

原图旋转膨胀收缩
imageimageimageimage

图像扭曲是移动像素点的位置,并不改变像素值,因此主要计算是进行像素点位置的映射。

之前写过一篇文章关于如何实现液化功能,再次不赘述。

4、工程实现

我们都知道 OpenCV 是一个非常经典的计算机视觉库,它提供了很多函数,这些函数非常高效地实现了计算机视觉算法(从图像显示,到像素操作,到目标检测等)。上述的这些问题,OpenCV 都能解决。那么在浏览器侧有没有类似的图像处理库呢?问问 ChatGPT:

image

重点看一下 Jimp 和 OpenCV.js。(Tensorflow.js 是在机器学习领域里使用,CamanJS 停止维护,PixiJS 类似于 ThreeJS,侧重 WebGL 渲染,Fabric.js 类似于 Konva,是基于 Canvas API 的工具库)

Jimp

https://github.com/jimp-dev/jimp

Jimp 是一个用纯 JS 实现的图像处理库。简单、轻量级且易于使用,可以在浏览器和 Node.js 环境中进行图像处理操作。Jimp 提供了许多功能,包括调整图像大小、裁剪、旋转、翻转、添加滤镜和效果等。它还支持图像的基本操作,如像素级别的访问和修改,以及图像的加载和保存。

看看它提供的功能:

imageimage

同时它也提供了自定义的口子,可以自己实现更复杂功能的插件和图片编码解码器。

举个🌰

用 Jimp 来实现一个上述的图像混合功能:

背景图抠图叠加图
imageimageimage
import Jimp from 'jimp';

const bgUrl = 'https://img.alicdn.com/imgextra/i2/O1CN01rQWEDB1YJuXOhBr44_!!6000000003039-0-tps-800-1200.jpg';
const cutoutUrl = 'https://img.alicdn.com/imgextra/i3/O1CN01C2mkou1ezwZtv8slg_!!6000000003943-2-tps-985-1200.png';

// 读取背景图
const background = await jimp.read('bgUrl');
// 读取抠图
const cutout = await jimp.read('cutoutUrl');
// 缩放到与底图相同大小
background.resize(cutout.bitmap.width, cutout.bitmap.height);
// 叠加抠图部分
background.composite(cutout, 0, 0);
// 图片导出显示
background
  .getBase64(Jimp.AUTO, (err, src) => {
    const img = document.createElement('img');
    img.setAttribute('src', src);
    document.body.appendChild(img);
  });

Jimp 提供了很多图像处理的基本函数,相较于自己实现前一节所述的像素处理过程,肯定会方便不少。但是像二值化或者根据 Mask 提取图片等一些自定义程度较高的图像像素操作,就需要自行实现。但是基于 Jimp 能够直接操作图像像素数据,所以实现这些函数也不复杂。

Web Worker 性能优化

上述 demo 在 codesandbox 中跑起来并没有感觉变慢或者卡顿, 但是当我将 Jimp 引入到工程中却发现页面直接失去了响应。

以实现剪裁功能为例,主体部分是下面这个函数:

async function cropImage({ image, area }) {
  const jimage = await Jimp.read(image);
  jimage.crop(area.x, area.y, area.width, area.height);
  const cropBuffer = await jimage.getBufferAsync(jimage.getMIME());
  const blob = new Blob([cropBuffer]);
  const file = new File([blob], image.name, { type: image.type, lastModified: image.lastModified });
  return file;
}

点了提交,最开始按钮的loading效果是没有的,直到这个函数处理完之后页面才有响应。

打印下时间看这个函数执行了4-5s

image

用 Performance 分析,确实是这段函数的执行卡主了主进程,导致页面失去响应。

imageimage

在 Jimp 的首页提到过,在某些情况下可能会消耗大量内存。因为 Jimp 是纯 JS 实现的图像处理,当图像像素越大,占用的内存显然越大。https://github.com/jimp-dev/jimp/issues/153

image

Jimp.read 会分配内存给图像数据,由于潜在的依赖关系并没有清除。这个不释放内存的操作会大量消耗主进程的资源,导致内存泄漏。但是看起来官方也并不准备 fix 这个问题。

抛开 Jimp,即使自行使用 canvas 实现图像处理的功能,要在浏览器实现图像处理,占用内存资源是不可避免的,导致页面卡顿无响应。那我们要解决的是这个问题。

Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application. The advantage of this is that laborious processing can be performed in a separate thread, allowing the main (usually the UI) thread to run without being blocked/slowed down.

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程就会很流畅,使主线程更加专注于页面渲染和交互,不会被阻塞或拖慢。

看起来 WebWorker 是一个很好的解决方案。WebWorker 的 API 就不详细说明了。试试:

// main.js
const workerUrl = './crop.worker.js';

const worker = new Worker(workerUrl);
worker.postMessage({ image, area });
worker.onmessage = (e) => {
  const cropFile = e.data;
};

// crop.worker.js
import Jimp from 'jimp';

self.addEventListener('message', (e) => {
  const { image, area } = e.data;
  const buffer = await image.arrayBuffer();
  const jimage = await Jimp.read(Buffer.from(buffer));
  jimage.crop(area.x, area.y, area.width, area.height);
  const cropBuffer = await jimage.getBufferAsync(jimage.getMIME());
  const blob = new Blob([cropBuffer]);
  const file = new File([blob], image.name, { type: image.type, lastModified: image.lastModified });
  self.postMessage(file);
});

操作耗时快了不少:

image

image

虽然这段代码处理的时间仍然是 Long task,但是至少没有阻塞整体的页面渲染。

如果不用 JS

上述的性能问题主要因为 JS 本身的限制所带来的,但实际调研还发现了  Sharp ImageMagick。这些库的功能定位上与 Jimp 基本相同,但是底层的实现都是基于 C/C++的,性能会好不少。

image

image

image

OpenCV.js

编译

OpenCV.js 是 JavaScript 开发者与 OpenCV 计算机图形处理库之间的桥梁,起先仅仅是部分 JavaScript 开发者自行开发的 OpenCV 应用接口,其原理是借助 LLVM-to-Javascript 的编译器 —— Emscripten 将库底层 C++ 代码编译为可在浏览器运行的 asm.js 或者 WebAssembly ,后来该项目日趋完善,并于 2017 年并入整个 OpenCV 项目。

编译方式:https://docs.opencv.org/4.x/d4/da1/tutorial\_js\_setup.html

当然网上也有很多构建好的版本。源码构建先需要先有 Emscripten 环境,步骤比较麻烦。下载方式版本固定且方便,但如果要修改 OpenCV 源码实现特殊功能,那就不行了。

OpenCV 与上述的图像处理库最大的区别是,它适合处理复杂的图像处理任务,比如图像滤波、边缘检测、形态学操作、特征检测等,以及一些高级的计算机视觉任务,比如图像识别、目标跟踪和人脸检测等。

举个🌰

用 OpenCV 实现 Canny 边缘检测:

const imgElement = document.getElementById('imageSrc');
imgElement.src = 'https://img.alicdn.com/imgextra/i3/O1CN01xsPtfh1aXlk0C5yaV_!!6000000003340-2-tps-512-512.png';
imgElement.onload = function() {
  const src = cv.imread(imgElement);
  const dst = new cv.Mat();
  cv.Canny(src,dst, 50, 100, 3, false);
  cv.imshow('canvasOutput', dst);
  src.delete();
  dst.delete();
};
imageimage

高斯模糊

const src = cv.imread(imgElement);
const dst = new cv.Mat();
const ksize = new cv.Size(9, 9);
cv.GaussianBlur(src, dst, ksize, 0, 0, cv.BORDER_DEFAULT);
cv.imshow('canvasOutput', dst);
src.delete();
dst.delete();
imageimage

Haar Cascades 人脸检测

let src = cv.imread('canvasInput');
let gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
let faces = new cv.RectVector();
let eyes = new cv.RectVector();
let faceCascade = new cv.CascadeClassifier();
let eyeCascade = new cv.CascadeClassifier();
// load pre-trained classifiers
faceCascade.load('haarcascade_frontalface_default.xml');
eyeCascade.load('haarcascade_eye.xml');
// detect faces
let msize = new cv.Size(0, 0);
faceCascade.detectMultiScale(gray, faces, 1.1, 3, 0, msize, msize);
for (let i = 0; i < faces.size(); ++i) {
    let roiGray = gray.roi(faces.get(i));
    let roiSrc = src.roi(faces.get(i));
    let point1 = new cv.Point(faces.get(i).x, faces.get(i).y);
    let point2 = new cv.Point(faces.get(i).x + faces.get(i).width,
                              faces.get(i).y + faces.get(i).height);
    cv.rectangle(src, point1, point2, [255, 0, 0, 255]);
    // detect eyes in face ROI
    eyeCascade.detectMultiScale(roiGray, eyes);
    for (let j = 0; j < eyes.size(); ++j) {
        let point1 = new cv.Point(eyes.get(j).x, eyes.get(j).y);
        let point2 = new cv.Point(eyes.get(j).x + eyes.get(j).width,
                                  eyes.get(j).y + eyes.get(j).height);
        cv.rectangle(roiSrc, point1, point2, [0, 0, 255, 255]);
    }
    roiGray.delete(); roiSrc.delete();
}
cv.imshow('canvasOutput', src);
src.delete(); gray.delete(); faceCascade.delete();
eyeCascade.delete(); faces.delete(); eyes.delete();
imageimage

OpenCV 官网上还提供了很多示例可供探索。https://docs.opencv.org/4.x/d5/d10/tutorial\_js\_root.html

OpenCV 虽然功能强大,但是目前还没有实际在业务上使用的场景,仍在持续探索中...

作者:ES2049 / [Timeless]
文章可随意转载,但请保留此 原文链接
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com

ES2049
3.7k 声望3.2k 粉丝