1背景随着机器学习的应用越来越广泛,能够在浏览器中运行模型推理的Javascript框架引擎越来越多。前端同学在项目中可能会发现一些运行在服务器端的python算法模型,想直接将其集成到自己的代码中,用Javascript语言在浏览器中运行。对于一些简单的模型,推理的前处理和后处理都比较容易,不涉及复杂的科学计算。遇到这种模型,顶多做个模型格式转换,直接用推理框架跑起来就可以了。这种移植成本很低。模型中很大一部分涉及复杂的前处理和后处理,包括大量的矩阵运算、图像处理等Python代码。这种情况下的总体思路是用Javascript语言手动翻译Python代码。这样做的问题是费时费力且容易出错。Pyodide作为浏览器中的科学计算框架,很好的解决了这个问题:在浏览器中运行原生的Python代码进行前处理和后处理,大量的numpy、scipy矩阵、tensor等计算不需要翻译成Javascript,方便移植。节省了很多工作。本文基于pyodide框架,从理论和实践两个角度帮助前端同学解决移植复杂模型的难题。2原理Pyodide是一个可以在浏览器中运行的WebAssembly(wasm)应用程序。它基于CPython的源代码进行了扩展,使用emscripten编译成wasm,大量与科学计算相关的pypi包也编译成wasm,从而可以在浏览器中解释执行python语句进行科学计算.所以pyodide也必须遵循wasm的各种约束。Pyodide在浏览器中的位置如下图所示:1wasm内存布局这是wasm线性内存的布局:Data段从0x400开始,FunctionTable表也在里面,起始地址是memoryBase(Emscripten中默认是1024,即0x400),STACKTOP是栈地址的开始,堆地址的开始是STACK_MAX。我们其实更关心的是Javascript内存和wasm内存之间的相互访问。2Javascript和Python交换浏览器是基于安全的考虑,防止wasm程序导致浏览器崩溃。通过在沙盒执行环境中运行wasm,禁止wasm程序访问Javascript内存,而Javascript代码可以访问wasm内存。因为wasm内存本质上是一个巨大的ArrayBuffer,由Javascript管理。我们称之为“单向内存访问”。作为wasm格式的普通程序,调用pyodide后,当然只能直接访问wasm内存。pyodide为了实现相互访问,引入了代理,类似于指针:在Javascript端,通过一个PyProxy对象来引用python内存中的一个对象;在Python端,JsProxy对象用于引用Javascript内存中的对象。Javascript端生成PyProxy对象:constarr_pyproxy=pyodide.globals.get('arr')//arr是python中的全局对象Python端生成JsProxy对象:importjsfromjsimportfoo#foo是Javascript中的全局对象互访基于时间的类型转换分为以下三个层次:【自动转换】对于简单类型,如数字、字符串、布尔等,会自动复制内存值,此时不会生成最终值时间。【半自动转换】非简单内置类型需要通过to_js()和to_py()显式转换:对于Python内置的list、dict、numpy.ndarray等对象,不属于简单类型并且不会自动转换类型,必须通过pyodide.to_js()调用,对应的类型会转换为JS的list、map、TypedArray类型,反之亦然。通过to_py()方法,JSTypedArray转成memoryview,list,map转成list,dict[手动转换]各种类,函数和自定义类型,因为对方语言没有对应的现成类型,所以它们只能以代理的形式存在,需要通过运营商间接操纵,就像操纵木偶一样。为了便于操作,pyodide模拟了两种语言的语法,使用一种语言中的运算符来模拟另一种语言中的类似行为。例如:让JS中的a=newXXX()变成Python中的a=XXX.new()。这里列出了一部分,具体可以查看文档(见文末)。也可以将Javascript模块导入Python,让Python直接调用模块的接口和方法。比如pyodide不编译opencv包,可以使用opencv.js:importpyodideimportjs.cvascv2print(dir(cv2))这个很好的补充了pyodide缺少的pypi包。三篇实用文章我们从空白页开始。使用浏览器打开测试页面(测试页面见文末)。1初始化python为了方便观察运行过程,采用动态的方式加载需要的js,执行python代码。打开浏览器控制台,依次运行以下语句:functionloadJS(url,callback){varscript=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('jsloadok');});//在nxruntime.js上加载推理引擎。当然也可以使用其他推理引擎log('jsloadok');});//初始化python运行环境loadJS('https://test-bucket-duplicate.oss-cn-hangzhou.aliyuncs.com/public/pyodide/0.18.0/pyodide.js',function(){console.log('jsloadok');});pyodide=awaitloadPyodide({indexURL:"https://test-bucket-duplicate.oss-cn-hangzhou.aliyuncs.com/public/pyodide/0.18.0/"});awaitpyodide.loadPackage(['micropip']);至此,python和pip安装完毕,都位于内存文件系统中。我们可以查看一下python的安装位置:注意这个文件系统是虚拟在内存中的,页面刷新的时候会丢失。不过由于浏览器本身有缓存,所以刷新页面后从服务器重新加载pyodide的bootjs和mainwasm还是比较快的,只要不清除浏览器缓存即可。2加载pypi包pyodide初始化完成后,直接导入python系统自带的标准模块即可。第三方模块需要用micropip.install()安装:pypi.org上的纯python包可以直接用micropip.install()安装包含C语言扩展的wheel包(编译成动态链接库),需要对照官方编译好的包,如果在列表中,直接使用micropip.install()安装。如果不在这个列表中,你需要手动编译并发布到服务器,然后再用micropip.install()安装。下图展示了业界常用的两种编译为wasm的方式。自己编译wasm包的方法可以参考官方手册。大致步骤是拉取官方的编译基础镜像,将需要编译的包的setup.cfg文件放到模块目录下,添加一些hack语句和配置(如果有的话),然后指定编译目标。编译成功后部署时,需要注意两点:设置允许跨域请求wasm格式的文件,响应头中要包含:"Content-type":"application/wasm"下面是自己的-builtwasmservernginx/Openrestysampleconfiguration: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.uriifstring.match(uri,".js$")==nilthenngx.header["Content-type"]="application/wasm"end';}回到我们的推理实例,现在用pip安装模型推理所需的numpy和Pillow包并导入:awaitpyodide.runPythonAsync(`importmicropipmicropip.install(["numpy","Pillow"])`);awaitpyodide.runPythonAsync(`importpyodideimportjs.cvascv2importjs.onnxasonnxruntimeimportnumpyasnp`);python需要的opencv和onnxruntime包都已经导入了。3opencv的使用一般python中的图像数组都是从JS传过来的。这里我们模拟构建一个图像,然后使用opencv调整它的大小。上面说了pyodide的官方opencv还没有编译好。如果涉及的opencv方法调用有其他pypi包的替代方案,那是最好的:比如cv.resize可以换成Pillow库的PIL.resize(注意Pillow的resize速度比Pillow慢)打开简历);cv.threshold可以替换为numpy.where。否则只能调用opencv.js的能力。为了演示pyodide语法,这里从opencv.js库调用它。awaitpyodide.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的轮廓:awaitpyodide.runPythonAsync(`#Usecv2.findContourstodetectthecontour.假设mask是一个二维的numpy数组,只有0和1两个值#原python代码:contours=cv2.findContours(mask,cv2.RETR_CCOMP,cv2.CHAIN_APPROX_NONE)#改为调用opencv.js:contours_jproxy=cv2.MatVector。new()#cv2.Mat数组,对应opencv.js中contours=newcv.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)#contoursjs格式为python格式contours=[]foriinrange(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)轮廓.追加(c)`);4推理引擎的使用最后,使用onnx.js加载模型并进行推理。详细语法请参考onnx.js官方文档。其他js版本的推理引擎也可以参考各自的文档。awaitpyodide.runPythonAsync(`model_url="onnx模型的地址"session=onnxruntime.InferenceSession.new()session.loadModel(model_url)session.run(...)`);通过以上操作,我们保证了Everything是在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','helloworld');//真正将文件保存到持久化文件系统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','helloworld');//真正保存File到持久文件系统pyodide.FS.syncfs(function(err){console.log(err);});运行结果如下:当然上面的语句也可以用python中Proxy的语法运行。持久文件系统有很多用途。比如可以帮助我们在多线程(webworker)之间共享大数据;它可以将模型文件持久存储在文件系统中,而不必每次都通过网络加载它们。6无需将单个Python文件打包到wheel包中,只需将其视为一个巨大的字符串,交给pyodide.runPythonAsync()运行即可。当有多个Python文件时,我们可以将这些python文件打包成普通的wheel包,部署到webserver上,然后使用micropip直接安装wheel包:micropip.install("https://foo.com/bar-1.2.3-xxx.whl")frombarimport...注意打wheel包需要__init__.py文件,即使是空文件。存在的四大缺陷目前pyodide存在以下缺陷:Python运行环境的加载和初始化时间有点长,根据网络情况不同,可能需要几秒到几十秒。pypi包支持不完整。虽然pypi.org上的纯python包可以直接使用,但是如果涉及到C扩展写的包,如果官方包还没有编译,就需要自己编译了。一些非常常用的包,比如opencv,没有编译成功;没有模型推理框架。好在可以通过相应的JS库来弥补。如果在python中调用js库:可能会有一定的内存拷贝开销(从wasm内存到JS内存来回拷贝)。尤其是使用大数组作为参数或返回值时,在高速要求的情况下,额外的内存拷贝开销是不容忽视的。python库的方法接口可能与对应的js库的接口参数和返回值格式不一致,存在一定的适配工作量。五、小结尽管存在上述缺陷,但得益于代码移植的高效率和逻辑1:1复制的高可靠性保证,我们仍然可以将该方法应用到多种业务场景中,促进机器学习技术的发展。积木的应用。链接:1.测试页面:https://test-bucket-duplicate.oss-cn-hangzhou.aliyuncs.com/public/pyodide/test.html2.文档:https://pyodide.org/en/stable/用法/type-conversions.html3。官方编译包列表:https://github.com/pyodide/pyodide/tree/main/packages4。手册:https://emscripten.org/docs/api_reference/Filesystem-API。网页格式
