简介: 随着机器学习的利用面越来越广,能在浏览器中跑模型推理的Javascript框架引擎也越来越多了。在我的项目中,前端同学可能会找到一些跑在服务端的python算法模型,很想将其间接集成到本人的代码中,以Javascript语言在浏览器中运行。本文就基于pyodide框架,从实践和实战两个角度,帮忙前端同学解决简单模型的移植这一辣手问题。

作者 | 道仙
起源 | 阿里技术公众号

一 背景

随着机器学习的利用面越来越广,能在浏览器中跑模型推理的Javascript框架引擎也越来越多了。在我的项目中,前端同学可能会找到一些跑在服务端的python算法模型,很想将其间接集成到本人的代码中,以Javascript语言在浏览器中运行。

对于一部分简略的模型,推理的前解决、后处理比拟容易,不波及简单的科学计算,碰到这种模型,最多做个模型格局转化,而后用推理框架间接跑就能够了,这种移植老本很低。

而很大一部分模型会波及简单的前解决、后处理,包含大量的矩阵运算、图像处理等Python代码。这种状况个别的思路就是用Javascript语言将Python代码手工翻译一遍,这么做的问题是费时费力还容易出错。

Pyodide作为浏览器中的科学计算框架,很好的解决了这个问题:浏览器中运行原生的Python代码进行前、后处理,大量numpy、scipy的矩阵、张量等计算无需翻译为Javascript,为移植节俭了很多工作。本文就基于pyodide框架,从实践和实战两个角度,帮忙前端同学解决简单模型的移植这一辣手问题。

二 原理篇

Pyodide是个能够在浏览器中跑的WebAssembly(wasm)利用。它基于CPython的源代码进行了扩大,应用emscripten编译成为wasm,同时也把一大堆科学计算相干的pypi包也编译成了wasm,这样就能在浏览器中解释执行python语句进行科学计算了。所以pyodide也必然遵循wasm的各种束缚。Pyodide在浏览器中的地位如下图所示:

1 wasm内存布局

这是wasm线性内存的布局:

Data数据段是从0x400开始的, Function Table表也在其中,起始地址为memoryBase(Emscripten中默认为1024,即0x400),STACKTOP为栈地址起始,堆地址起始为STACK_MAX。而咱们理论更关怀的是Javascript内存与wasm内存的相互拜访。

2 Javascript与Python的互访

浏览器基于平安方面的思考,避免wasm程序把浏览器搞解体,通过把wasm运行在一个沙箱化的执行环境中,禁止了wasm程序拜访Javascript内存,而Javascript代码却能够拜访wasm内存。因为wasm内存实质上是一个微小的ArrayBuffer,承受Javascript的治理。咱们称之为“单向内存拜访”。

作为一个wasm格局的一般程序,pyodide被调用起来后,当然只能间接拜访wasm内存。

为了实现互访,pyodide引入了proxy,相似于指针:在Javascript侧,通过一个PyProxy对象来援用python内存里的对象;在Python侧,通过一个JsProxy对象来援用Javascript内存里的对象。

在Javascript侧生成一个PyProxy对象:

const arr_pyproxy = pyodide.globals.get('arr')  // arr是python里的一个全局对象

在Python侧生成一个JsProxy对象:

import jsfrom js import foo   # foo是Javascript里的一个全局对象

互访时的类型转换分为如下三个等级:

【主动转换】对于简略类型,如数字、字符串、布尔等,会被主动拷贝内存值,此时产生的就不是Proxy、而是最终的值了。

【半自动转换】非简略的内置类型,都须要通过to_js()、to_py()形式来显式转换:

  • 对于Python内置的list、dict、numpy.ndarray等对象,不属于简略类型,不会主动转换类型,必须通过pyodide.to_js()来转,相应的会被转成JS的list、map、TypedArray类型
  • 反过来也相似,通过to_py()办法,JS的TypedArray转为memoryview,list、map转为list、dict
  • 【手动转换】各种class、function和用户自定义类型,因为对方的语言没有对应的现成类型,所以只能以proxy的模式存在,须要通过运算符来间接操纵,就像操纵提线木偶一样。为了达到不便操纵的目标,pyodide对两种语言进行了语法模仿,用一种语言里的操作符模仿另一种语言的相似行为。例如:JS中的let a=new XXX(),在Python中就变为a=XXX.new()。

这里列举了一部分,详情能够查文档(见文章底部)。

Javascript的模块也能够引入到Python中,这样Python就能间接调用该模块的接口和办法了。例如,pyodide没有编译opencv包,能够应用opencv.js:

import pyodideimport js.cv as cv2print(dir(cv2))

这对于pyodide缺失的pypi包是个很好的补充。

三 实际篇

咱们从一个空白页面开始。应用浏览器关上测试页面(测试页面见文章底部)。

1 初始化python

为了不便察看运行过程,应用动静的形式加载所需js和执行python代码。关上浏览器控制台,顺次运行以下语句:

function loadJS( url, callback ){  var script = document.createElement('script'),  fn = callback || function(){};  script.type = 'text/javascript';  script.onload = function(){      fn();  };  script.src = url;  document.getElementsByTagName('head')[0].appendChild(script);}// 加载opencvloadJS('https://test-bucket-duplicate.oss-cn-hangzhou.aliyuncs.com/public/opencv/opencv.js',  function(){    console.log('js load ok');});// 加载推理引擎onnxruntime.js。当然也能够应用其余推理引擎loadJS('https://test-bucket-duplicate.oss-cn-hangzhou.aliyuncs.com/public/onnxruntime/onnx.min.js',  function(){    console.log('js load ok');});// 初始化python运行环境loadJS('https://test-bucket-duplicate.oss-cn-hangzhou.aliyuncs.com/public/pyodide/0.18.0/pyodide.js',  function(){    console.log('js load ok');});pyodide = await loadPyodide({ indexURL : "https://test-bucket-duplicate.oss-cn-hangzhou.aliyuncs.com/public/pyodide/0.18.0/"});await pyodide.loadPackage(['micropip']);

至此,python和pip就装置结束了,都位于内存文件系统中。咱们能够查看一下python被装置到了哪里:

留神,这个文件系统是内存里虚构进去的,刷新页面就失落了。不过因为浏览器自身有缓存,所以刷新页面后从服务端再次加载pyodide的疏导js和主体wasm还是比拟快的,只有不清理浏览器缓存。

2 加载pypi包

在pyodide初始化实现后,python零碎自带的规范模块能够间接import。第三方模块须要用micropip.install()装置:

pypi.org上的纯python包能够用micropip.install() 间接装置
含有C语言扩大(编译为动态链接库)的wheel包,须要对照官网已编译包的列表

  • 在列表中的间接用micropip.install()装置
  • 不在这个列表里的,就须要本人手动编译后公布到服务器后再用micropip.install()装置。

下图展现了业内罕用的两种编译为wasm的形式。

本人编译wasm package的办法可参考官网手册,大抵步骤就是pull官网的编译根底镜像,把待编译包的setup.cfg文件放到模块目录里,再加上些hack的语句和配置(如果有的话),而后指定指标进行编译。编译胜利后部署时,须要留神2点:

  • 设置容许跨域
  • 对于wasm格局的文件申请,响应Header里该当带上:"Content-type": "application/wasm"

上面是一个自建wasm服务器的nginx/openresty示例配置:

 location ~ ^/wasm/ {   add_header 'Access-Control-Allow-Origin' "*";   add_header 'Access-Control-Allow-Credentials' "true";   root /path/to/wasm_dir;   header_filter_by_lua '     uri = ngx.var.uri     if string.match(uri, ".js$") == nil then         ngx.header["Content-type"] = "application/wasm"     end   '; }

回到咱们的推理实例, 当初用pip装置模型推理所需的numpy和Pillow包并将其import:

await pyodide.runPythonAsync(`import micropipmicropip.install(["numpy", "Pillow"])`);await pyodide.runPythonAsync(`import pyodideimport js.cv as cv2import js.onnx as onnxruntimeimport numpy as np`);

这样python所需的opencv、onnxruntime包就已全副导入了。

3 opencv的应用

个别python里的图片数组都是从JS里传过来的,这里咱们模仿结构一张图片,而后用opencv对其resize。下面提到过,pyodide官网的opencv还没编译进去。如果波及到的opencv办法调用有其余pypi包的替代品,那是最好的:比方,cv.resize能够用Pillow库的PIL.resize代替(留神Pillow的resize速度比opencv的resize要慢);cv.threshold能够用numpy.where代替。 否则只能调用opencv.js的能力了。为了演示pyodide语法,这里都从opencv.js库里调用。

await pyodide.runPythonAsync(`# 结构一个1080p图片h,w = 1080,1920img = np.arange(h * w * 3, dtype=np.uint8).reshape(h, w, 3)# 应用cv2.resize将其放大为1/10# 原python代码:small_img = cv2.resize(img, (h_small, w_small))# 改成调用opencv.js:h_small,w_small = 108, 192mat = cv2.matFromArray(h, w, cv2.CV_8UC3, pyodide.to_js(img.reshape(h * w * 3)))dst = cv2.Mat.new(h_small, w_small, cv2.CV_8UC3)  cv2.resize(mat, dst, cv2.Size.new(w_small, h_small), 0, 0, cv2.INTER_NEAREST)small_img = np.asarray(dst.data.to_py()).reshape(h_small, w_small, 3)`);

传参准则:除了简略的数字、字符串类型能够间接传,其余类型都须要通过pyodide.to_js()转换后再传入。 返回值的获取也相似,除了简略的数字、字符串类型能够间接获取,其余类型都须要通过xx.to_py()转换后获取后果。

接着对一个mask检测其轮廓:

await pyodide.runPythonAsync(`# 应用cv2.findContours来检测轮廓。假如mask为二维numpy数组,只有0、1两个值# 原python代码:contours  = cv2.findContours(mask, cv2.RETR_CCOMP,cv2.CHAIN_APPROX_NONE)# 改成调用opencv.js:contours_jsproxy = cv2.MatVector.new()   # cv2.Mat数组,对应opencv.js中的 contours = new cv.MatVector()语句hierarchy_jsproxy = cv2.Mat.new()mat = cv2.matFromArray(mask.shape[0], mask.shape[1],  cv2.CV_8UC1, pyodide.to_js(mask.reshape(mask.size)))cv2.findContours(mat, pyodide.to_js(contours_jsproxy), pyodide.to_js(hierarchy_jsproxy), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)# contours js格局转python格局contours = []for i in range(contours_jsproxy.size()):  c_jsproxy = contours_jsproxy.get(i)  c = np.asarray(c_jsproxy.data32S.to_py()).reshape(c_jsproxy.rows, c_jsproxy.cols, 2)  contours.append(c)`);

4 推理引擎的应用

最初,用onnx.js加载模型并进行推理,具体语法可参考onnx.js官网文档。其余js版的推理引擎也都能够参考各自的文档。

await pyodide.runPythonAsync(`model_url="onnx模型的地址"session = onnxruntime.InferenceSession.new()session.loadModel(model_url)session.run(......)`);

通过以上的操作,咱们确保了一切都在python语法范畴内进行,这样批改原始的Python文件就比拟容易了:把不反对的函数替换成咱们自定义的调用js的办法;原Python里的推理替换成调用js版的推理引擎;最初在Javascript主程序框架里加少许调用Python的胶水代码就实现了。

5 挂载长久存储文件系统

有时咱们须要对一些数据长久保留,能够利用pyodide提供的长久化文件系统(其实是emscripten提供的),见手册(文章底部)。

// 创立挂载点pyodide.FS.mkdir('/mnt');// 挂载文件系统pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, {}, '/mnt');// 写入一个文件pyodide.FS.writeFile('/mnt/test.txt', 'hello world');// 真正的保留文件到长久文件系统pyodide.FS.syncfs(function (err) {    console.log(err);});

这样文件就长久保留了。即便当咱们刷新页面后,仍能够通过挂载该文件系统来读出外面的内容:

// 创立挂载点pyodide.FS.mkdir('/mnt');// 挂载文件系统pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, {}, '/mnt');// 写入一个文件pyodide.FS.writeFile('/mnt/test.txt', 'hello world');// 真正的保留文件到长久文件系统pyodide.FS.syncfs(function (err) {    console.log(err);});

运行后果如下:

当然,以上语句能够在python中以Proxy的语法形式运行。

长久文件系统有很多用途。例如,能够帮咱们在多线程(webworker)之间共享大数据;能够把模型文件长久存储到文件系统里,无需每次都通过网络加载。

6 打wheel包

单Python文件无需打包,间接当成一个微小的字符串,交给pyodide.runPythonAsync()运行就好了。当有多个Python文件时,咱们能够把这些python文件打成一般wheel包,部署到webserver,而后能够用micropip间接装置该wheel包:

micropip.install("https://foo.com/bar-1.2.3-xxx.whl")from bar import ...

留神,打wheel包须要有__init__.py文件,哪怕是个空文件。

四 存在的缺点

目前pyodide有如下几个缺点:

  • Python运行环境加载和初始化工夫有点儿长,视网络状况,几秒到几十秒都有可能。
  • pypi包反对的不残缺。尽管pypi.org上的纯python包都能够间接应用,但波及到C扩大写的包,如果官网还没编译进去,那就须要本人入手编译了。
  • 个别很罕用的包,例如opencv,还没胜利编译进去;模型推理框架一个都没有。不过还好能够通过相应的JS库来补救。

如果python中调用了js库的话:

  • 可能会产生肯定的内存拷贝开销(从wasm内存到JS内存的来回拷贝)。尤其是大数组作为参数或返回值,在速度要求高的场合下,额定的内存拷贝开销就不能漠视了。
  • python库的办法接口可能跟其对应的js库的接口参数、返回值格局不统一,有肯定的适配工作量。

五 总结

只管有上述种种缺点,得益于代码移植的高效率和逻辑上1:1复刻的高可靠性保障,咱们还是能够把这种办法使用到多种业务场景里,为推动机器学习技术的利用添砖加瓦。

原文链接
本文为阿里云原创内容,未经容许不得转载。