第十三章 WebAssembly 在数字图像处理中的应用

2,383 阅读14分钟

作者:高臻熙

1. 前言

数字图像处理的本质就是对图片的再次加工,这其中又分好几个层次,如下图 1 所示:

13-1.png

图 1. 图像处理分类

1.1 低层次的图像处理

低层次图像处理主要对图象进行各种加工以改善图象的视觉效果、或突出有用信息,并为自动识别打基础,或通过编码以减少对其所需存储空间、传输时间或传输带宽的要求。

简单来说,我们以 x,y 分别表示图片横纵坐标,那么这就是(x, y) => f(x, y)的过程,即对输入的图片的所有像素进行处理,并生成一个新的图片,如下图 2 所示。

13-2.png

图 2. 低层次的滤镜处理

1.2 中层次的图像分析

中层次图像分析主要对图像中感兴趣的目标进行检测(或分割)和测量,以获得它们的客观信息,从而建立对图像中目标的描述,是一个从图像到数值或符号的过程。最终输出具有特征的数据,如下图 3 所示。

13-3.png

图 3. 中层次的关键点识别

1.3 高层次的图像理解

高层次图像理解在中级图像处理的基础上,进一步研究图像中各目标的性质和它们之间相互的联系,并得出对图像内容含义的理解(对象识别)及对原来客观场景的解释(计算机视觉),从而指导和规划行动。其特点是基于人类对客观世界的认知,结合图像数据,最终产出的是对图像的理解,如下图 4 所示。

13-4.png

图 4. 高层次的特征理解

底层的图像处理能力为上层能力提供基本的数学工具,而上层的分析或者理解结果又能进一步优化底层图像处理效果,比如最近很火的“抖音 AI 作画”,就是基于输入图片的特征,绘制一副新的图画,如下图 5 所示。

13-5.jpeg

图 5. AI 识别原图特征并作画

这里我们会优先重点介绍常见的基本图像处理方法——滤镜,以及基于伟大的计算机视觉库 OpenCV 做一些扩展的说明。

2. 常见滤镜实现效果说明

滤镜是一种经典的像素处理函数,大家知道图片是由大量纵横排列的像素构成的,每个像素在计算机中使用[红色,绿色,蓝色,不透明度]表示,所以滤镜的本质就是一个纯函数:

f(R,G,B,A)=>(R,G,B,A)f(R, G, B, A) => (R', G', B', A')

我们以这张图片作为原始图片,列举一些常见的滤镜函数算法:

13-6.png

图 6. 原始图片

2.1 灰度效果

按照 0.3 * R + 0.59 * G + 0.11 * B 的计算公式得出结果统一赋值给 R、G、B。

13-7.png

图 7. 灰度图片

2.2 黑白效果

判断 R、G、B 的平均值是否超过了 100(阈值),超过就是纯白 255,否则纯黑 0。

13-8.png

图 8. 黑白图片

2.3 反色效果

分别用 255 减去原始的 R、G、B 值,得到的就是反色值。

13-9.png

图 9. 反色图片

2.4 镜像效果

把图片上 Y 对称轴左边的每一个像素,转移到对称点 (x' = width - x) 即可。

13-10.png

图 10. 镜像图片

2.5 浮雕效果

按个扫描图像单元,分别用后一个图像单元的 R、G、B 减去前一个单元,得到的结果再进行灰度处理(不然会有色彩残留),此算法原理就是相当于绘制 R、G、B 相差特别大的轮廓。

13-11.png

图 11. 浮雕图片

2.6 卡通效果

按照如下公式计算,会使得图片色彩变得更加鲜艳

r=2gb+r/256r' = |2g - b + r| / 256

g=2bg+r/256g' = |2b - g + r| / 256

b=2bg+r/256b' = |2b - g + r| / 256

13-12.png

图 12. 卡通图片

2.7 怀旧效果

r=0.393r+0.769g+0.189br' = 0.393 * r + 0.769 * g + 0.189 * b

g=0.349r+0.686g+0.168bg' = 0.349 * r + 0.686 * g + 0.168 * b

b=0.272r+0.534g+0.131bb' = 0.272 * r + 0.534 * g + 0.131 * b

13-13.png

图 13. 怀旧图片

2.8 熔铸效果

r=(r128)/(g+b+1)r' = (r * 128) / (g + b + 1)

g=(g128)/(r+b+1)g' = (g * 128) / (r + b + 1)

b=(b128)/(g+r+1)b' = (b * 128) / (g + r + 1)

13-14.png

图 14. 熔铸图片

2.9 毛玻璃效果

遍历每一个图像单元,然后取其附近的单元(如随机数取左右 1-8 个中任意一个),将这个随机单元数据填充到原始单元中,就会造成一种毛毛糙糙的感觉。

13-15.png

图 15. 毛玻璃图片

2.10 高斯模糊效果

高斯模糊首先要确定模糊半径,原理就是让每个像素挨个去计算半径内其他像素的平均值,色彩平均了那就看起来是模糊的效果。具体原理可见阮一峰的博客

13-16.png

图 16. 高斯模糊图片

2.11 素描效果

这里需要两个图层,A 图层是原图经过一次灰度效果后形成。然后拷贝 A 图层,应用反色处理生成 B 图层,再对 B 图层应用高斯模糊。A、B 图层合并的时候,分别对 R、G、B 采用如下公式计算。

v=a.v+(a.vb.v)/(255b.v) v = a.v + (a.v * b.v) / (255 - b.v)

13-17.png

图 17. 素描图片

3. 一个简单的图片处理站点

我设计了一个前端处理站点,它可以接受摄像机或者用户上传的图片作为输入,支持多种类型的处理函数,最终输出图像元素。这并不是一个最好的实践,但是可以帮助大家更多了解一些基本知识。

13-18.png

图 18. 简单的图片站点

图片的输入源可以是上传的图片,也可以是通过浏览器提供的摄像机 API 进行拍照,我们将输入图片的像素数据统一转化为二进制数据格式,在中间中转层来进行滤镜处理。中转层可以支持多种运行方式,比如放在纯前端(JavaScript、WebAssembly),或者把数据传输给后端,使用相同的逻辑处理后再返回给前端。一方面可以验证各服务实现效果是否一致,另一方面也很容易进行对比总结。

3.1 数据类型

所有的图片都是按照像素来处理的,也就是说无论调用摄像头 API 向 Canvas 绘制数据的,或者读取用户上传的图片数据,以及最后绘制的结果,都是以 Uint8ClampedArray 作为数据格式传输的。这是一种特定类型的数组,它的元素限制为 8 位无符号整型,也就是值在 0-255 区间,如果赋值超过此区间,则按照最近的值处理,比如赋值 1000 会修改为 255,赋值-23 会修改为 0。如果使用Uint8Array类型,则只是读取前 8 位,-23 因为补码形式的存在,会被修改为 233。

不过,当数据需要传递给服务端的时候,往往需要编码为占用空间更小的 Base64 字符串,后端进行解码并处理后,再编码为 Base64 返回。

3.2 AssemblyScript

大家知道,WebAssembly[1] 是一种紧凑的二进制程序格式,它不是一种高级语言,难以用人能表达清楚的方式直接编写,所以一般都会有专门的工具提供转译的手段。

AssemblyScript 是一种类似 TypeScript,且对前端相对友好的编译语言,我们简单看一下它的代码:

export function add(a: i32, b: i32): i32 {
    return a + b;
}

相比 TypeScript,看起来只是多了一个更细的类型定义。

不过如果只是这么简单的场景,根本没必要使用 WebAssembly 绕一圈,我们完整的介绍一下流程。

  • 先写一个 AssemblyScript 函数
// 导出灰度函数,语法与TS高度类似,为了节省空间,我们直接修改入参并返回结果
export function gray(data: Uint32Array): Uint32Array {
  const { length } = data;
  for (let i = 0; i < length; i += 4) {
    // 使用了乘法的情况下,可能存在溢出情况,需要统一转为i32处理,实际上类型转化可能是耗费性能的关键点
    const grayRgb = i32(0.3 * data[i] + 0.59 * data[i + 1] + 0.11 * data[i + 2]);
    data[i] = grayRgb;
    data[i + 1] = grayRgb;
    data[i + 2] = grayRgb;
  }
  // 事实上这里已经不是uint32Array了,不过AS内置了一套数据类型转换
  return data;
}
// 指定Uint32Array类型的数组并导出,实例化时需要
export const Uint32Array_ID = idof<Uint32Array>();
  • 使用官方工具编译为 WebAssembly 格式的文件
asc filter.ts --outFile filter.wasm --optimize
  • 在 Web Browser 引入,使用官方封装好的工具实例化更方便一些
import { instantiate } from '@assemblyscript/loader'; // or require

const instance = instantiate(
  fetch('your wasm.wasm'), {/* importObject */}
).catch(console.error);
  • 使用的时候,要特别注意数据是需要通过指针的形式传入的,需要关注指针的生命周期
// 首先拿到wasm实例
const wasm = await instance.then();
// 通过上述的loader加载和编译设置,wasm.export提供了所有导出的函数和工具函数
// __newArray用来生成wasm内部数组,__getArray用来将wasm
// __pin是一个指针指向wasm内部数据,__unpin则是释放这个指针触发垃圾回收
// Uint32Array_ID是上面导出的标识,gray是上文自定义的灰度函数
const { __newArray, __getArray, __pin, __unpin, Uint32Array_ID, gray } = wasm.exports;
// 将js中的canvasData传入__newArray函数中,在wasm中生成对应的数组内容
// 再用__pin获取指向这个数据的指针
const arrayPtr = __pin(__newArray(Uint32Array_ID, canvasData));
// 调用wasm生成的函数进行数据处理(传入上一步wasm指针),返回一个wasm内部ID
const result = gray(arrayPtr);
// 通过__getArray将内部ID解析为JS数组
const resultArray = __getArray(result);
// 最后需要释放指针引用,触发垃圾回收
__unpin(arrayPtr);

在之前的测试中,因为 JS 和 WebAssembly 的数据通信需要大量的拷贝,加上 AssemblyScript 对代码的优化不如 Rust 等编译器做的好,再加上 Chrome V8 TurboFan 强大的 JIT 优化能力,WebAssembly 的处理速度往往不一定能超过原生的 JavaScript,不过 AssemblyScript 仍然是前端同学开发 WebAssembly 最方便的途径之一,值得推荐。

3.3 Rust

Rust[2] 可以算是当今最“网红”的编程语言了,不过它确实配的上,Rust 通过把在运行时可能会遇到的问题提前到编译时进行,相当于用开发时间换取运行效率。Rust 可能是支持 WebAssembly 最好的语言了,不仅有非常完整体系的工具链,性能更好,服务端运行时 (WASI) 也提供相当完备的生态。

我们这里只介绍一下 Rust 编译 WebAssembly 在前端的应用:

  • 安装 Rust 环境和 wasm-pack 包,初始化工程
curl https://sh.rustup.rs -sSf | sh

cargo install wasm-pack

cargo new --lib hello-wasm
  • 接着我们书写逻辑代码,比如一个可调用的函数参考如下,注意我们要包含 wasm 相关的工具链和 json 处理工具
extern crate wasm_bindgen;
extern crate serde_json;
use wasm_bindgen::prelude::*;

#[macro_use]
extern crate serde_derive;

#[wasm_bindgen]
pub fn desaturate(js_objects: &JsValue) -> String {
    let elements: Vec<i32> = js_objects.into_serde().unwrap();
    let mut i = 0;
    let data_len = data.len();
    while i < data_len {
        let min = min(data[i], min(data[i + 1], data[i + 2]));
        let max = max(data[i], max(data[i + 1], data[i + 2]));
        let avg_rgb = (min + max) / 2 as i32;
        data[i] = avg_rgb;
        data[i + 1] = avg_rgb;
        data[i + 2] = avg_rgb;
        i = i + 4;
    }
    String::from("去色滤镜")
}
  • 我们需要配置一下描述文件
[package]
name = "sample"
......

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

然后运行 wasm-pack build,这会生成一个pkg目录,包括相关的 JS 胶水代码。

  • 浏览器端运行,我们直接引入刚才生成好的文件即可
const wasm = await import("./wasm.js");

wasm.desaturate(array);

胶水层会帮我们处理好 JavaScript 对象和 WebAssembly 指针相关的处理逻辑,因此使用起来还是比较方便的。

3.4 FaaS Edge

实际上,字节跳动基础架构的 faas 团队已经提供了 Rust->WebAssembly 成熟的运行环境了

13-19.png

图 19. FaaS 概念示意

基于此服务,我们可以很轻松的做到:

  • serverless,无需关注服务器的申请,部署,运维等等

  • WebAssembly 天然自带冷启动快、资源隔离的优势

  • 部署在边缘,成本更低,网络消耗更低

4. 更专业的工具 —— OpenCV

上面讲到的是低层次的图像处理,其实我们可以基于一个成熟的库来尝试往更多方向进行探索。OpenCV 是计算机视觉(Computer vision)领域最受欢迎的开源库,它最初是由 intel 使用 C 和 C++进行开发的,如今已经演化为一个免费的、跨平台的、且高度优化过的库。

借助 WebAssembly 特性,OpenCV 能很方便的提供能力给前端或者 Node.js 开发者。

4.1 构建

OpenCV 官方已经封装好了基于 Emscripten 的工具链[3],大概有下面这些方式:

  • 构建 asm.js 风格的产物
emcmake python ./opencv/platforms/js/build_js.py build_js

asm.js 是一种带有特定类型声明的 JavaScript,在早期 WebAssembly 未诞生时作为一种内置的优化手段

  • 构建 wasm 产物
emcmake python ./opencv/platforms/js/build_js.py build_wasm --build_wasm

// 往往需要配合loader扩展多线程、SIMD能力
emcmake python ./opencv/platforms/js/build_js.py build_js --build_loader
  • 使用官方文件

官方会提供 opencv.js 格式的文件[4][5],一般建议项目自己拷贝一份下来,避免海外网络拥塞导致系统不稳定。

  • 使用封装库[推荐]

NPM 仓库有很多人封装过了 OpenCV,并添加了或多或少的辅助工具。我使用的是 opencv-ts[6],不仅不需要打包,还提供了 TypeScript 语法提示能力,使用起来是比较方便的。

4.2 基本用法

官方已经提供比较完整的说明文档,这里不详细介绍具体的 API 了,我们简单说一下用法。

注意:所有的代码应该写在初始化函数onRuntimeInitialized中。

cv 提供了imread(imageid | HTMLImageElement | HTMLCanvasElement) 的函数签名,这可以从指定的源读取图片内容,并返回一个cv.Mat类型的对象,这是一个基本的存储二维空间的数据结构——矩阵,这里用来存储图片的像素结构,后面很多操作都是基于Mat类型来操作的。

输出图片也非常方便,直接调用cv.imshow(canvasid | HTMLCanvasElement, cv.Mat对象) 就可以把图像绘制在屏幕上。

需要记住的是 WebAssembly 不会帮助你回收内存,所以记得 cv.Mat 对象用完后及时调用delete()方法释放内存。

5. 写一个实时视频转化工具

有没有想过可以用少量代码写一个实时转化视频的案例?

13-20.gif

图 20. 实时视频滤镜

我们先创建两个 DOM 节点,分别放置视频和画布

<video controls src="/play.mp4" width="640" height="360" />

<canvas id="output" width="640" height="360"/>

其实,视频本质上可以当做一帧又一帧的图像轮播,OpenCV 内置了对视频流的截取能力

const capture = new cv.VideoCapture(videoElement);

const frameMat = new cv.Mat(videoElement.height, videoElement.width, cv.CV_8UC4);

因此我们尽量保证在每一帧进行一次渲染计算即可

const onPaint = () => {
  // 把图像信息绘制在矩阵中
  capture.read(frameMat);
  // 调用绘制函数,下面会讨论实现
  render(frameMat);
  // 触发下一次帧渲染
  requestAnimationFrame(onPaint);
}

onPaint();

那么现在问题就到了如何基于 OpenCV 进行线条化滤镜的绘制了,以下面这张图为例

13-21.png

图 21. 原图

总体过程可以分为 5 步:

5.1 灰度化

OpenCV 自带了灰度处理函数,使用常量 cv.COLOR_RGBA2GRAY 表示

const grayMat = new cv.Mat();
cv.cvtColor(frameMat, grayMat, cv.COLOR_RGBA2GRAY);

13-22.png

图 22. 灰度化处理

5.2 高斯模糊

上面也提到了高斯模糊算法,需要一个像素半径作为指标即可,OpenCV 自带了高斯模糊方法

const ksize = new cv.Size(5,5);

const blurMat = new cv.Mat();
cv.GaussianBlur(grayMat, blurMat, ksize, 0, 0, cv.BORDER_CONSTANT);

13-23.png

图 23. 模糊处理

5.3 黑白化

黑白化(专业名词叫二值化),即像素若大于某个值则为最大值,小于某个值即为最小值

let thresholdMat = new cv.Mat();
cv.adaptiveThreshold(blurMat, thresholdMat, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY, 5, 2);

13-24.png

图 24. 黑白化处理

接下来分别进行一次高斯模糊和黑白化,

13-25.png

图 25. 第二次模糊处理

13-26.png

图 26. 第二次黑白处理

可以让线条轮廓更加明显一点,当然还有其他优化效果的算法,这里不赘述了,最后,千万记得释放内存

OpenCV 是一个非常强大的工具库,上面举得只是一个非常简单的例子。结合机器学习模型,可以把实时的人脸识别、车牌 OCR、美颜相机、闯红灯识别等直接放在前端来完成,不得不说 WebAssembly 为前端同学提供了一个很好融合其它学科精华的机会。

6. 总结

正如上面所说,WebAssembly 可以方便的整合成熟的工具库,让它们以高性能在浏览器环境上运行。

13-27.png

图 27. WebAssembly 应用

其实可以这样盲下结论,理论上可在浏览器上运行的,最终都可以在浏览器端运行。

WebAssembly 另一个巨大的应用前景就是浏览器外,轻量级、高效率、天然隔离性让它在容器化、特别是在低端设备上有很大的发展潜力。

然而,WebAssembly 还处于发展的初始阶段,很多能力都亟待进一步的提高。

  • Web 端性能

V8 的 TurboFan 提供了 JIT 能力,能把热点代码直接转化为机器码执行,能达到 C 语言效率级别,另一方面 WebAssembly 和 JavaScript 通信需要一定的上下文开销,单纯的浏览器场景下,如果 WebAssembly 效率不能稳定优于 JavaScript,那其实大部分业务场景下没有绝对的必要。

  • 开发不友好

Emscripten 和 WASM-PACK 可以说是里程碑式的工具,它们让 WebAssembly 走进前端变成了可能,不过像周边的生态,特别是调试、断点、部署等还是比较麻烦的。

  • 浏览器外可靠的虚拟机

WebAssembly 需要一个可靠的运行时才能在浏览器外稳定运行,目前像字节码联盟或者一些边缘云公司 (fastly) 会提供一些运行时,这些运行时各有自身适合的场景,区别于 Docker 几乎成为容器的事实标准,百花齐放恰恰也说明了 WebAssembly 的生态还有很长的路要探索。

7. 参考文献

[1]. AssemblyScript: www.assemblyscript.org/introductio…
[2]. Rust: rustwasm.github.io/
[2]. Build OpenCV.js: docs.opencv.org/4.x/d4/da1/…
[3]. Using OpenCV.js: docs.opencv.org/4.6.0/d0/d8…
[4]. opencv.js: docs.opencv.org/4.6.0/openc…
[5]. opencv-ts: www.npmjs.com/package/ope…


尾部关注.gif

扫码关注公众号 👆 追更不迷路