将包含 caffe 的 python 工程打包成一个 exe 文件

由于工作原因,需要将一个基于 caffe 的深度学习检测工程转换成 exe 文件,在此过程中遇到了许多问题,现将其记录下来,以供参考。

fpn_ported 改动

1 caffe/pycaffe.py L13 from ._caffe 改成了 from caffe._caffe。因为最后 dist\runcaffe_fpn\ 里有一个 caffe._caffe.pyd 文件,因此需要 import 此文件。(个人解释见下文)
2 caffe/_init_.py L2 from._caffe 改成了 from caffe._caffe
caffe/_init_.py L3 from._caffe 改成了 from caffe._caffe
3 caffe 中关于图像读入的部分都是用的 skimage 进行实现,但是似乎 pyinstaller 对 skimage 的支持并不好,因此需要对 caffe 中的相关部分用 cv2 进行替代。

My Python project includes A Caffe module which run a simple image classification process. One basic function is Caffe calling skimage.io to load image:
https://github.com/BVLC/caffe/blob/master/python/caffe/io.py

1
2
3
4
5
6
7
8
9
def load_image(filename, color=True):
img = skimage.img_as_float(skimage.io.imread(filename, as_grey=not color)).astype(np.float32)
if img.ndim == 2:
img = img[:, :, np.newaxis]
if color:
img = np.tile(img, (1, 1, 3))
elif img.shape[2] == 4:
img = img[:, :, :3]
return img

I wonder if PyInstaller currently has a good support for Python package skimage. But from what I know by now, it doesn’t.

Run from Python source code files, it works fine. But when I packed all things into one single binary file, it can not load image at all. And after debugging and googleing for a long time – I always thought maybe I did something wrong – I get rid of this. PyInstaller hates skimage! So at last I use cv2 instead. And it works smoothly.

1
2
3
4
5
6
7
8
9
10
11
12
def cv2_load_image(filename, color=True):
img = cv2.imread(filename).astype(np.float32) / 255
if img.ndim == 3:
img[:,:,:] = img[:,:,2::-1]

if img.ndim == 2:
img = img[:, :, np.newaxis]
if color:
img = np.tile(img, (1, 1, 3))
elif img.shape[2] == 4:
img = img[:, :, :3]
return img

For all above details, please do check out PyInstaller Documentation: https://media.readthedocs.org/pdf/pyinstaller/latest/pyinstaller.pdf

具体参考这篇文章


1 numpy.core._init_.py 中 from .info import __doc__ 改成 from numpy.core.info import __doc__
2 from . import multiarray 改成 from numpy.core import multiarray
3 将 numpy.core._init_.py 中其他的代表当前目录的 . 都替换成 numpy.core
4 将 numpy.fft.fftpack.py 中的 from . fft import fftpack_lite as fftpack 改成 from numpy.fft import fftpack_lite as fftpack
5.将 numpy.random._init_.py 中的 from .mtrand import * 改成 from numpy.random.mtrand import *
个人观点: 似乎无需像第3步一样将所有的 . 都改成绝对路径,貌似只有使用 . 导入 pyd 文件的导入语句会报找不到的错误。因此只需将导入 pyd 文件的语句更改即可。


Problem

  打包后运行时,在最外层的 runcaffe_fpn.py 中 import os 可以成功,但是在 models/rpn/proposal_layer.pylib/config.pyimport os 就会报错 no module named path


Analysis

  由于 os 和 os.path 的特殊性,即 os.py 中根据平台不同,import 不同的文件作为 path 模块,例如,在 Windows 中,使用 import ntpath as path 并将其命名为 os.path 模块。此外,os.pyntpath.py 文件存在相互 import 的情况,不知为什么,工程中一些文件中,如 config.py 中,import os 语句会报错 no module namded path。


Solution

  由于 config.py 等文件中调用了 os.py 中的 makedirs 等函数以及 ntpath.py 中的一些函数。因此,尝试将 os.pyntpath.py 文件解耦(其实是无法解耦的,因为 os.py 需要导入 ntpath.py 中的属性并供其函数使用),因此将 os.pyimport ntpath as path 句注释,将 sys.modules['os.path'] = pathfrom os.path import (curdir, pardir, sep, pathsep, defpath, extsep, altsep, devnull) 注释掉,换成 from _ntpath import (curdir, pardir, sep, pathsep, defpath, extsep, altsep, devnull)from _ntpath 是因为如果直接使用 from ntpath 的话,它会自动调用 anaconda 下的 ntpath.py 文件,会导致前面的错误,因此,将 os.pyntpath.py 文件拷贝至一个包下,并都在前面加上下划线进行重命名)。实际操作中,我是创建了一个名为 os_modified 的包,并将重命名后的 _os.py 和 _ntpath.py 文件放进去作为模块。在工程文件中调用 os 和 os.path 的地方,将 import osimport os.path as osp 改成 import modified_os._os as osimport modified_os._ntpath as osp。工程文件中导入的其他一些包,如 genericpath,也会 import os,我也将这些文件拷贝进 modified_os 中重命名, 如 _genericpath,并将其中的 import os 改成 import _os as os。在工程文件中有 import genericpath 的地方改成 import modified_path._genericpath as genericpath。其他有类似情况的也按此方法处理。总之,os_modified 模块下,都是在工程文件中使用到,且其有 import osimport os.path 的,我将所有这些文件集中到一个名为 modified_os 的模块下,当工程文件中调用到这些时,就调用此模块下的版本,而不是 anaconda 的原始版本。这样就解决了 no module named path 的错误。


Comment

  上面所述的关于 os, os.path 的部分全部不需要,只需要将 os.py, ntpath.py 两个文件通过 --add-binary="/path/to/os.py;." --add-binary="/path/to/ntpath.py;." 添加进去即可。这样,在 dist/runcaffe_fpn/ 下就会有 os.pyntpath.py 两个文件。当一个 .py 文件是从 exe 文件中开始执行,即是 boundled 的时候,pyinstaller 的 bootloader 将 frozen 属性添加进 sys 模块中。因此,可以通过

1
2
3
4
5
import sys
if getattr(sys, 'frozen', False):
# running in a bundle
else:
# running live

来在工程文件中进行判断此工程文件是用 python 脚本调用执行的还是用 exe 文件调用执行的。如果是用 python 脚本调用,则根据其导入模块的结构进行 import。比如 fast_rcnn/nms_wrapper.py 需要导入 nms/gpu_nms.pydfast_rcnn/config.py。在脚本模式下,需使用 from nms.gpu_nms import gpu_nmsfrom fast_rcnn.config import cfg 进行调用。而在 boundle 模式下,使用 from gpu_nms import gpu_nmsfrom config import cfg 进行调用即可。需要注意的是,在 boundle 模式下,还需使用 --add-binary 选项将 gpu_nms.pydconfig.py 两个文件添加进去,这样它的 import 语句才会生效。

上述做法有效的原因是:pyinstaller 似乎将工程中不同层次的脚本(即存在调用关系)所需的依赖都展开放在最后打包的依赖项所在的文件夹下,即 sys._MEIPASS 所指向路径下,是没有目录层次的。因此,当处于 boundle 模式下时,from gpu_nms import gpu_nms 语句会直接对 sys.__MEIPASS 目录下的 gpu_nms.pyd 文件进行 import。在解决 os, os.path 问题时,将 os.pyntpath.py 文件都添加进去,就不会有找不到的问题。并且按照理论,import os 所导入的文件应该是 sys._MEIPASS 路径下的 os.py。(已验证)

工程文件中的示例如下:

1
2
3
4
5
6
7
import sys
if getattr(sys, 'frozen', False): # boundle 模式
from gpu_nms import gpu_nms
from config import cfg
else: # 脚本模式
from nms.gpu_nms import gpu_nms
from fast_rcnn.config import cfg

  解决了 os 和 pyd 文件导入的问题后,exe 可以成功运行了。但是美中不足的是,运行 exe 除了需要作为参数的那些文件外,还一直需要 `proposal_layer.py` 文件,这是不好的,因为不能向客户透露源码。因此,尝试将 `proposal_layer.py` 文件也用 `--add-binary` 选项添加进去。但是这样并没有用。究其原因是,`proposal_layer.py` 是在 `.prototxt` 文件中指定的(即 python 层中的 module 参数),原来为 `module: rpn.proposal_layer`。这是因为 prototxt 文件同级目录下有一个 rpn 文件夹,其下有一个 `proposal_layer.py` 文件。由于将 `proposal_layer.py` 文件添加进 `sys._MEIPASS` 后其名称仍为 `proposal_layer.py`,而 prototxt 中指定为 `rpn.proposal_layer`,不统一。因此,将 rpn 文件夹下的两个文件,`proposal_layer.py` 和 `generate_anchors.py`,放置到与 prototxt 文件同级目录下,并将 prototxt 中的 `rpn.proposal_layer` 改成 `proposal_layer`. 再将 `proposal_layer.py` 用 `--add-binary` 添加进去,就可以在 `sys._MEIPASS` 中调用 `proposal_layer.py` 了,这样再运行单个的 exe 文件时就不需带着 `proposal_layer.py` 文件了,因为它会调用 `sys._MEIPASS` 下的 `proposal_layer.py`。

Pyinstaller 打包命令如下:

1
2
3
4
5
6
7
8
9
pyinstaller -F --add-binary="models/nms/gpu_nms.pyd;." ^
--add-binary="models/fast_rcnn/bbox_transform.py;." ^
--add-binary="models/fast_rcnn/config.py;." ^
--add-binary="models/fast_rcnn/nms_wrapper.py;." ^
--add-binary="models/proposal_layer.py;." ^
--add-binary="models/generate_anchors.py;." ^
--add-binary="H:/Develop/Anaconda2/setup/Lib/os.py;." ^
--add-binary="H:/Develop/Anaconda2/setup/Lib/ntpath.py;." ^
runcaffe_fpn.py

最后,如上面的代码所示,我是将所有的除 caffe 模块外的额外 python 脚本都添加进了 sys._MEIPASS 中,保证可以有效调用。

Pyinstaller 使用方法记录

Pyinstaller 运行时路径问题

  pyinstaller 在打包后,会将 frozen 属性添加进 sys 变量。因此,可以使用 getattr(sys, 'frozen', False) 来判断当前执行的脚本是否是用 python 脚本执行的还是 exe 文件执行的。

  pyinstaller 有两种打包模式,一种是 onefile 模式,即将所有的依赖文件都打包成一个单独的 exe 文件,在运行此 exe 文件时,将其所包含的依赖文件临时解压到某个目录下,并在此目录下进行执行。另一种是 one-folder 模式,将生成的 exe 文件和依赖文件都放在 dist 文件夹下与被打包的 script 同名的文件夹中。在运行 exe 文件时,不需要将依赖文件解压,因此速度更快一些。很显然,one-file 模式的 exe 文件会比较大。当运行 exe 文件时,pyinstaller 的 bootloader 将 bundle folder 的绝对路径添加进 sys._MEIPASS 中。其实,这就是 exe 文件执行时,依赖文件所在的位置。需要注意的是,one-folder 模式下的 exe 文件必须和依赖文件在同一路径下,否则会找不到依赖。


hidden-import

在一次测试中,我用 R.py 导入了 libs/rpn/proposal_layer.py,在proposal_layer.pyfrom ..fast_rcnn.nms_wrapper import nms,在 libs/fast_rcnn.nms_wrapper.pyfrom ..nms.gpu_nms import gpu_nms,在 libs/nms/ 下有一个 gpu_nms.pyd 文件。


文章作者: taosean
文章链接: https://taosean.github.io/2018/07/09/Convert-python-project-to-exe/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 taosean's 学习之旅