简介: 随着机器学习的利用面越来越广,能在浏览器中跑模型推理的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在浏览器中的地位如下图所示:
这是wasm线性内存的布局:
Data数据段是从0x400开始的, Function Table表也在其中,起始地址为memoryBase(Emscripten中默认为1024,即0x400),STACKTOP为栈地址起始,堆地址起始为STACK_MAX。而咱们理论更关怀的是Javascript内存与wasm内存的相互拜访。
浏览器基于平安方面的思考,避免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 js
from js import foo # foo是Javascript里的一个全局对象
互访时的类型转换分为如下三个等级:
【主动转换】对于简略类型,如数字、字符串、布尔等,会被主动拷贝内存值,此时产生的就不是Proxy、而是最终的值了。
【半自动转换】非简略的内置类型,都须要通过to_js()、to_py()形式来显式转换:
这里列举了一部分,详情能够查文档(见文章底部)。
Javascript的模块也能够引入到Python中,这样Python就能间接调用该模块的接口和办法了。例如,pyodide没有编译opencv包,能够应用opencv.js:
import pyodide
import js.cv as cv2
print(dir(cv2))
这对于pyodide缺失的pypi包是个很好的补充。
咱们从一个空白页面开始。应用浏览器关上测试页面(测试页面见文章底部)。
为了不便察看运行过程,应用动静的形式加载所需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);
}
// 加载opencv
loadJS('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还是比拟快的,只有不清理浏览器缓存。
在pyodide初始化实现后,python零碎自带的规范模块能够间接import。第三方模块须要用micropip.install()装置:
pypi.org上的纯python包能够用micropip.install() 间接装置
含有C语言扩大(编译为动态链接库)的wheel包,须要对照官网已编译包的列表
下图展现了业内罕用的两种编译为wasm的形式。
本人编译wasm package的办法可参考官网手册,大抵步骤就是pull官网的编译根底镜像,把待编译包的setup.cfg文件放到模块目录里,再加上些hack的语句和配置(如果有的话),而后指定指标进行编译。编译胜利后部署时,须要留神2点:
上面是一个自建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 micropip
micropip.install(["numpy", "Pillow"])
`);
await pyodide.runPythonAsync(`
import pyodide
import js.cv as cv2
import js.onnx as onnxruntime
import numpy as np
`);
这样python所需的opencv、onnxruntime包就已全副导入了。
个别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,1920
img = 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, 192
mat = 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)
`);
最初,用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的胶水代码就实现了。
有时咱们须要对一些数据长久保留,能够利用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)之间共享大数据;能够把模型文件长久存储到文件系统里,无需每次都通过网络加载。
单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中调用了js库的话:
只管有上述种种缺点,得益于代码移植的高效率和逻辑上1:1复刻的高可靠性保障,咱们还是能够把这种办法使用到多种业务场景里,为推动机器学习技术的利用添砖加瓦。
原文链接
本文为阿里云原创内容,未经容许不得转载。