墨奇科技在业务中有很多应用需要在网页中对于图像进行处理、特征识别,让我们对于计算机视觉在前端应用中的实践有了一些自己的认识和了解,希望能够借此机会与大家进行分享,尤其是如何搭建基于 WebAssembly 的技术方案。
OpenCV 作为计算机视觉领域最为成熟的开源库,在实际业务中经常被运用于进行图像和视频处理。OpenCV 使用 C/C++ 进行编写,并且提供了 Python、Java、C#、Go、Javascript 等接口。在前端领域,当遇到需要在浏览器端进行图像处理时,使用 OpenCV.js 可以在多数业务场景中满足产品需求,提高开发速度,调高运行效率。
OpenCV.js 是将 C++ 版的 OpenCV 通过 Emscripten 编译为 WebAssembly 版本的 OpenCV.js 库。常年来 Web 都只能运行 Javascript,而 Javascript 的语言特性从而决定了它在执行一些类似于图像处理等计算密集型操作时性能较差,即使在 V8 引擎的加持下仍然会有性能瓶颈。WebAssembly 赋予了 Web 端运行 C++、Rust 等语言编写的代码的能力,实现多数情况下更高效的计算性能,达到接近于 Native 的计算水平。
图片来源互联网
如果希望在前端 Javascript 代码中使用 OpenCV.js 需要经过以下几个步骤:
第一步:获得 OpenCV.js 文件。参考官方教程,我们可以直接获得一个预编译的版本,或者当我们需要进行一些编译的配置时,也可以自己进行编译。如果自行编译的推荐可以使用 docker 的方式,方便快捷稳定。
第二步:在代码中引入 OpenCV.js 文件。当我们获得了文件后,可以通过 script 标签进行引入。一个 OpenCV.js 标准库的大小在 10MB 左右,所以建议使用 async 属性让文件异步载入。
第三步:确保 OpenCV.js 已经载入到内存中。使用 async 进行异步载入时,可以在标签的 onLoad 回调函数中执行后续操作,此时 OpenCV.js 文件已经下载完毕。虽然此时文件已经下载完毕,但是相关的函数和变量可能还没有完全加载到内存中,所以建议书写一个 cvFuncLoaded 函数用于判断运行环境中 OpenCV 相关的函数是否都已载入到内存中。
OpenCV 提供的预编译的版本只有标准库的内容,当我们希望使用 opencv_contrib 中的扩展模块内容时,就需要我们将我们所需要的扩展模块内容添加到编译选项中,具体可以参考官方提供的教程。
OpenCV 提供的预编译的版本或者默认编译下的 OpenCV.js 文件通常都比较大,通常需要 10MB 左右,如果引入了额外的 opencv_contrib 中的内容的话,整体体积会更大。这对于前端项目来说是难以接受的,每个用户在第一次打开该页面时需要加载 10MB 以上的资源,将会耗费较长时间,所以我们需要对于包体积进行优化。
OpenCV 包含了非常丰富的各种图像处理的工具,但是在我们实际中可能并不会用到其中大部分的内容,所以可以在编译时对于需要编译的内容进行裁剪,只编译实际业务中所需要用到的部分。
以我们实际中的业务场景为例,主要用到的是标准库的一些图像旋转、缩放、透视变换以及扩展模块中的 Aruco 相关部分,通过不断裁剪将编译文件体积减小了近 90%,极大地缩短了 OpenCV.js 的载入时间。
基于 OpenCV.js 的方案便于上手。作为成熟的开源类库,OpenCV 使得从 C++ -> WebAssembly 的开发、编译流程非常顺滑,而大量成熟的 CV 算子可供直接调用,能够覆盖多数应用场景。然而在实际使用过程中,这套方案存在 2 个不足:
OpenCV 的体积较大。适当裁剪 modules 可以优化,但在简单的场景中,module 的颗粒度依然过大。例如,在一个仅需要做图像风格转换的业务中,我们也许只需要维护一个数组,不需要完整的 cv::Mat 作为数据结构,很多内部逻辑显得冗余。又例如,如果业务只需要处理 uint8 类型的灰度图,裁剪掉 3 & 4 通道以及浮点数输入的逻辑是比较困难的。
基于 OpenCV 的定制化开发并不少见。但对于不熟悉 CV 的前端工程师,在相对复杂的 OpenCV 实现中找到对应逻辑,修改、重新编译并测试的成本较高。尤其是出于通用性及性能优化,OpenCV 相当多的实现基于模板元编程,并且与硬件架构相关。在前端场景下,考虑这两点的收益并不大。
因而,我们认为在上下文明确、功能单一的业务场景中,一套轻量级的 CV 方案是必要的。前端在处理如 CV 的计算密集型任务时,有以下 3 个较主流的方案:
WebAssembly
WebGL
WebGPU
在选择技术方案时,我们有 3 个考虑方向:
业务场景:我们处理的是哪类任务?兼容性要求是什么?
性能指标:我们希望于达成什么级别的计算性能,相比于现有方案带来多大提升?
开发体验:工程师完成技术实现的成本如何?
(1)业务场景
我们的业务场景面向企业用户,以 PC 端为主。CV 实现的功能类型单一,以风格转换、图像增强为主。系统需要兼容的最低操作系统为 Windows XP,对应的浏览器版本也较低(e. g. Chrome 49 / Firefox 52)。这意味着我们不可能完全依赖一套非 JS 方案,而需要做降级兼容,并且尽量选择覆盖率高,且在各版本浏览器中表现稳定的技术。具体地看:
WebAssembly 无法支持 Chrome 57 以下的版本,但可以覆盖所有 Firefox 52 以上版本。
WebGL 的硬件加速无法在 Windows XP 上开启,且我们实测即使对 Windows 7 系统,Chrome 60 以下版本中的表现也并不稳定。
WebGPU 标准还不成熟,落地风险大。
(2)性能指标
现有业务系统有一套基础的 JS 实现,该实现存在的问题是,对大尺寸图像,高计算复杂度的图像操作可能需要秒级的响应时间,用户等待时间过长。我们第一次上线前的目标如下:
技术层面,首先过滤出所有高复杂度的计算函数,对业务系统中可能出现的最大分辨率图像,经过 Wasm 加速的此类 CV 函数,处理时延能控制在 350ms 以下。
用户实际感知上,所有的操作都应是准实时的。
(3)开发体验
团队中有 C++ 经验的工程师不多,搭建、维护 C++ 项目的难度大。
综上,权衡之后,我们决定以 Rust 作为开发语言,并搭建一套 JS + WebAssembly 的复合方案。
我们将技术方案拆分为 2 个维度:开发流水线与运行时模型。
(1)开发流水线
开发流水线可参考下图:
①计算函数的实现:Rust
我们常常听说 Rust 是一门特性比较独特(e. g. ownership)的编程语言,存在较高上手成本。但事实上,Rust 具有非常优质的官方教程,且设计体系有理可寻(e. g. Affine type system vs ownership / move)。
计算类函数的实现难点,大多是在算法的理解,而不依赖对高级语言特性的使用。因而,在较短的适应期后,对于单个计算函数,从理解算法、使用 Rust 实现,到完成基本的正确性和性能测试,我们的前端工程师能将开发周期控制在 5-6 小时之内(取决于算法自身的复杂度),在项目推进中,并没有成为时间瓶颈。
而相比于 C++,Rust 额外的优势还在于其较现代的包管理体系及对内存安全的重视。
对于习惯了基于 Yarn / npm 进行依赖管理的前端工程师而言,使用 Cargo 远比使用 CMake 手动引入头文件路径及链接依赖库更易上手。不需要手动管理内存或学习智能指针也降低了心理负担。
对于习惯了编写如 JavaScript 的动态类型语言的工程师而言,Rust 严格的编译期类型检查最初会让人不习惯,但这确实规避了不少运行期错误。例如,在计算操作中,整型、浮点数之间的转换(例如双线性 resize 中将像素点位置代入计算,获得浮点系数),大量乘加操作造成的溢出(例如 uint8 的卷积)都可能对精度产生影响,Rust 能显式地让我们意识到这些问题。
②Fallback 实现: JavaScript
由于 WebAssembly 无法覆盖所有目标浏览器,对每一个计算函数,我们都会实现一个对应的 JS 版本作为 fallback。
③从 Rust -> WebAssembly: 编译与打包
从 C++ 到 WebAssembly,Emscripten 确实是一套较为成熟的工具,但使用中仍需要工程师对 C++ 编译、链接流程有所了解,编写 CMakeLists.txt 时需要额外注意不少针对 WebAssembly 的选项(e. g. TOTAL_STACK / INITIAL_MEMORY),细节较多。
相比之下,Rust 官方提供的工具 wasm-pack 使整个过程更为透明:在编写时,为需要暴露给 JS 调用的函数、类等添加 wasm-bindgen 的 attribute,按官方示例配置好 cargo.toml(optimization level 与 wee_alloc 是 2 个对 WebAssembly 而言比较重要的设置),只需执行简单的命令 wasm pack build,在绝大多数情况下,即可得到 wasm 产物。
值得一提的是,wasm-pack 事实上更像是一个工具集的抽象。例如,其通过 wasm-bindgen 完成对 attribute 的解析、语法树的 transform,并通过 wasm-opt 完成对 wasm 生成代码的后优化。
④JavaScript / TypeScript 侧调用
Wasm-pack 生成的是一对 js / wasm 产物。使用时,只需在业务组件中引入 js 文件,获得实例,并通过实例调用函数即可,与一般的 JS 函数调用并无二致。具体设计可见下章。
(2)运行时模型
我们将计算函数想象成一个黑盒,其接受一个输入、返回一个输出。运行时模型,指这个盒子的初始化及内部处理逻辑。
①初始化检查服务
尽管相比于 WebGL 与 WebGPU,WebAssembly 对于我们的用户群体覆盖率更好,但依然无法完成对所有目标浏览器的兼容。初始化检查服务所做的,便是判断当前浏览器环境,决定 WebAssembly 的可用性。
②惰性加载
在我们的前端系统中,WebAssembly Module 只有在当前页面需要使用,且检查服务通过时才会被加载。
③调用分发器
基于检查服务的结果,我们将调用分发器决定调用 WebAssembly 或是 JS 函数。
④WebAssembly 实例
我们使用一个实例对象作为所用 WebAssembly 函数的 callee。如此,WebAssembly 相关的逻辑可以被较好地隔离开。
⑤内存管理
维护 JS 中对象到 WebAssembly 的内存空间是值得额外注意的。我们可以选择每次返回一个新的 buffer 并重新构建 TypedArray,或是直接采用拷贝。仅维护一个映射到 WebAssembly 内存地址的 buffer 是不安全的:在诸如 grow memory 这样的操作后,旧有区间可能被整体复制,那么原先的地址及对应的 buffer 也就失效了。
覆盖率
目前,所有业务系统中的计算函数均实现了 WebAssembly + JS 的方案。
性能
对于 profiling 后过滤出的高耗时计算函数(如:锐化、颜色混合、自定义均衡等),在测试浏览器环境(Chrome 57 / Chrome 60 / Chrome 70 / Chrome 79 / Firefox 52 / Firefox 72)中,平均性能达到了 4-5 倍的提升。
对于个别浏览器环境中的个别函数,如 Firefox 72 中的颜色混合,提升大于 20 倍。对于长宽均超过 2000 像素的大尺寸图像,此类函数在大多数测试环境中,均可在 350ms 内完成计算。
对于低耗时计算函数(如:亮度、对比度、反相),测试浏览器环境中的性能提升也基本达到了 4 倍。在 Chrome 79 / Firefox 72 中,对于长宽均超过 2000 像素的大尺寸图像,此类操作的时延能控制在 ~10ms。
下图即为某次 release 前,Chrome 79 上各 CV 函数的 JS/WASM 时延指标对比。处理的输入图大小为 2040x2040:
反思
当然,性能优化时并不乏“意外”。我们不得不承认,受制于开发初期的不确定性,最初的指标制定、profiling 规则都不够细致。我们也确实对 Rust 的实现进行了额外的优化以达到预期的指标。同时,从结果上看,确实存在部分函数,在一些低版本的测试环境中,提升效果不尽如人意的情况。技术层面,在高版本的浏览器中,没有 web worker 和 SIMD 加持的 WebAssembly 相比于 WebGL 也未有性能优势。性能优化中的经验、反思、迭代,我们不在此篇文章中做深入展开。
本文第一部分主要介绍了如何引入 OpenCV.js 到运行环境,讲解了在前端应用中如何使用现有的成熟 OpenCV 库,以及如何优化引入的包体积。第二部分主要介绍了在我们需要自己实现一个计算机视觉库的时候,如何搭建基于 WebAssembly 的技术方案,以及我们在实际业务场景中带来的性能提升。本系列的下一篇文章是《计算机视觉在前端应用中的实践(二) — 性能优化》,敬请期待。
墨奇科技有良好的工作环境和各种福利,让你能够充分的发挥自己的技术,不断挑战自我。墨奇全栈组在持续热招前端、后端、Android、C++、嵌入式等开发岗位↓
点我直接进入内推通道