作者:高臻熙
1. 前言
数字图像处理的本质就是对图片的再次加工,这其中又分好几个层次,如下图 1 所示:
图 1. 图像处理分类
1.1 低层次的图像处理
低层次图像处理主要对图象进行各种加工以改善图象的视觉效果、或突出有用信息,并为自动识别打基础,或通过编码以减少对其所需存储空间、传输时间或传输带宽的要求。
简单来说,我们以 x
,y
分别表示图片横纵坐标,那么这就是(x, y) => f(x, y)
的过程,即对输入的图片的所有像素进行处理,并生成一个新的图片,如下图 2 所示。
图 2. 低层次的滤镜处理
1.2 中层次的图像分析
中层次图像分析主要对图像中感兴趣的目标进行检测(或分割)和测量,以获得它们的客观信息,从而建立对图像中目标的描述,是一个从图像到数值或符号的过程。最终输出具有特征的数据,如下图 3 所示。
图 3. 中层次的关键点识别
1.3 高层次的图像理解
高层次图像理解在中级图像处理的基础上,进一步研究图像中各目标的性质和它们之间相互的联系,并得出对图像内容含义的理解(对象识别)及对原来客观场景的解释(计算机视觉),从而指导和规划行动。其特点是基于人类对客观世界的认知,结合图像数据,最终产出的是对图像的理解,如下图 4 所示。
图 4. 高层次的特征理解
底层的图像处理能力为上层能力提供基本的数学工具,而上层的分析或者理解结果又能进一步优化底层图像处理效果,比如最近很火的“抖音 AI 作画”,就是基于输入图片的特征,绘制一副新的图画,如下图 5 所示。
图 5. AI 识别原图特征并作画
这里我们会优先重点介绍常见的基本图像处理方法——滤镜,以及基于伟大的计算机视觉库 OpenCV 做一些扩展的说明。
2. 常见滤镜实现效果说明
滤镜是一种经典的像素处理函数,大家知道图片是由大量纵横排列的像素构成的,每个像素在计算机中使用[红色,绿色,蓝色,不透明度]表示,所以滤镜的本质就是一个纯函数:
我们以这张图片作为原始图片,列举一些常见的滤镜函数算法:
图 6. 原始图片
2.1 灰度效果
按照 0.3 * R + 0.59 * G + 0.11 * B
的计算公式得出结果统一赋值给 R、G、B。
图 7. 灰度图片
2.2 黑白效果
判断 R、G、B 的平均值是否超过了 100(阈值),超过就是纯白 255,否则纯黑 0。
图 8. 黑白图片
2.3 反色效果
分别用 255 减去原始的 R、G、B 值,得到的就是反色值。
图 9. 反色图片
2.4 镜像效果
把图片上 Y 对称轴左边的每一个像素,转移到对称点 (x' = width - x)
即可。
图 10. 镜像图片
2.5 浮雕效果
按个扫描图像单元,分别用后一个图像单元的 R、G、B 减去前一个单元,得到的结果再进行灰度处理(不然会有色彩残留),此算法原理就是相当于绘制 R、G、B 相差特别大的轮廓。
图 11. 浮雕图片
2.6 卡通效果
按照如下公式计算,会使得图片色彩变得更加鲜艳
图 12. 卡通图片
2.7 怀旧效果
图 13. 怀旧图片
2.8 熔铸效果
图 14. 熔铸图片
2.9 毛玻璃效果
遍历每一个图像单元,然后取其附近的单元(如随机数取左右 1-8 个中任意一个),将这个随机单元数据填充到原始单元中,就会造成一种毛毛糙糙的感觉。
图 15. 毛玻璃图片
2.10 高斯模糊效果
高斯模糊首先要确定模糊半径,原理就是让每个像素挨个去计算半径内其他像素的平均值,色彩平均了那就看起来是模糊的效果。具体原理可见阮一峰的博客
图 16. 高斯模糊图片
2.11 素描效果
这里需要两个图层,A 图层是原图经过一次灰度效果后形成。然后拷贝 A 图层,应用反色处理生成 B 图层,再对 B 图层应用高斯模糊。A、B 图层合并的时候,分别对 R、G、B 采用如下公式计算。
图 17. 素描图片
3. 一个简单的图片处理站点
我设计了一个前端处理站点,它可以接受摄像机或者用户上传的图片作为输入,支持多种类型的处理函数,最终输出图像元素。这并不是一个最好的实践,但是可以帮助大家更多了解一些基本知识。
图 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
成熟的运行环境了
图 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. 写一个实时视频转化工具
有没有想过可以用少量代码写一个实时转化视频的案例?
图 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 进行线条化滤镜的绘制了,以下面这张图为例
图 21. 原图
总体过程可以分为 5 步:
5.1 灰度化
OpenCV 自带了灰度处理函数,使用常量 cv.COLOR_RGBA2GRAY 表示
const grayMat = new cv.Mat();
cv.cvtColor(frameMat, grayMat, cv.COLOR_RGBA2GRAY);
图 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);
图 23. 模糊处理
5.3 黑白化
黑白化(专业名词叫二值化),即像素若大于某个值则为最大值,小于某个值即为最小值
let thresholdMat = new cv.Mat();
cv.adaptiveThreshold(blurMat, thresholdMat, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY, 5, 2);
图 24. 黑白化处理
接下来分别进行一次高斯模糊和黑白化,
图 25. 第二次模糊处理
图 26. 第二次黑白处理
可以让线条轮廓更加明显一点,当然还有其他优化效果的算法,这里不赘述了,最后,千万记得释放内存。
OpenCV 是一个非常强大的工具库,上面举得只是一个非常简单的例子。结合机器学习模型,可以把实时的人脸识别、车牌 OCR、美颜相机、闯红灯识别等直接放在前端来完成,不得不说 WebAssembly 为前端同学提供了一个很好融合其它学科精华的机会。
6. 总结
正如上面所说,WebAssembly 可以方便的整合成熟的工具库,让它们以高性能在浏览器环境上运行。
图 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…
扫码关注公众号 👆 追更不迷路