07 · 打包与部署¶
本篇拆解 PyInstaller 打包流程:spec 文件、Tkinter 钩子、隐藏导入、产物结构、跨平台限制。 前置:06 · 模型与推理 下一篇:08 · 问题与改进
7.1 打包资产总览¶
仓库内与打包相关的文件:
| 文件 | 大小 | 作用 |
|---|---|---|
CameraMonitorSystem.spec |
5KB | PyInstaller 主配置 |
hook-tkinter.py |
3KB | Tkinter/Tcl 库收集钩子 |
add_hidden_imports.py |
0.2KB | win32 子模块收集辅助 |
| logo.ico | 4KB | exe 程序图标 |
| fonts/simhei.ttf | 9.5MB | 中文字体(被打进 exe) |
main.py:17-55 |
— | 运行时的 PyInstaller 兼容代码 |
7.2 PyInstaller 打包命令¶
推荐方式(用 spec 文件)¶
输出会在 dist/CameraMonitorSystem/CameraMonitorSystem2.1.exe。
--clean标志会先清理build/缓存,避免上次打包的残留导致问题。
⚠️ README 提到但仓库内不存在的方式¶
README L77-78 提到:
但仓库根目录没有 build.bat(CameraMonitorSystem.spec:26 还引用了它,会导致 spec 解析时 glob 找不到此文件——但因为 datas 是用 list comprehension 加的,不存在的文件不会被加进 datas,只会被静默忽略;不过 spec 的 ('build.bat', '.') 这一行如果 build.bat 真的不存在会让 PyInstaller 报警)。
实际打包时建议直接用上面的 pyinstaller --clean 命令。
7.3 CameraMonitorSystem.spec 详解¶
7.3.1 数据文件(datas)¶
# CameraMonitorSystem.spec:21-27
datas = [
('logo.ico', '.'),
('requirements.txt', '.'),
('README.md', '.'),
('hook-tkinter.py', '.'),
('build.bat', '.'), # ⚠️ 实际不存在
]
每个元组 (源路径, 目标路径) 告诉 PyInstaller "把这个文件打进去,放在解压目录的某处"。'.' 表示根目录。
接着是递归收集 4 个目录(L29-48):
for f in glob.glob('fonts/**/*', recursive=True):
if os.path.isfile(f):
datas.append((f, os.path.join('fonts', os.path.relpath(f, 'fonts'))))
# 同样的 pattern 用于 models/, swellcamera/, watercamera/
效果:把 fonts/、models/、swellcamera/、watercamera/ 四个目录的所有文件逐个加进 datas,保持目录结构。
会带上垃圾文件:
.idea/、__pycache__/、tempCodeRunnerFile.py都会被一起打进 exe,膨胀 exe 体积。详见 08 · 问题与改进 P2-6/P2-7。
7.3.2 Tcl 库自动收集¶
# CameraMonitorSystem.spec:50-57
try:
import tkinter
tcl_root = os.path.join(os.path.dirname(tkinter.__file__), 'tcl')
if os.path.exists(tcl_root):
datas.append((tcl_root, 'tcl'))
except Exception as e:
pass
把 Python 安装目录下的 tkinter/tcl 整个搬进打包目标。这是 Tkinter 在 Windows 上的依赖。
7.3.3 hiddenimports — 隐藏导入¶
# CameraMonitorSystem.spec:64-112
hiddenimports = [
'main',
'swellcamera', 'swellcamera.swellcamera',
'watercamera', 'watercamera.watercamera',
'models.model1', 'models.model2', 'models.model3', 'models.model4',
'tkinter', 'tkinter.ttk', 'tkinter.messagebox', 'tkinter.filedialog',
'tkinter.colorchooser', 'tkinter.commondialog', 'tkinter.simpledialog',
'tkinter.font', 'tkinter.scrolledtext', '_tkinter',
'threading', 'queue', 'time', 'sys', 'os', 'pathlib',
'pywintypes', 'win32api', 'win32ctypes',
'cv2', 'torch', 'torchvision', 'PIL', 'efficientnet_pytorch',
'numpy', 'pandas', 'matplotlib',
]
hiddenimports = list(set(hiddenimports)) # 去重
为什么需要 hiddenimports:PyInstaller 通过静态分析 import 语句决定要打包哪些模块,但下列情况会被遗漏:
- 动态 import(
importlib.import_module):本项目的utils/model_loader.py就是用 importlib,PyInstaller 无法静态分析 - C 扩展:
_tkinter、cv2、numpy等通过.pyd/.so加载 - 条件 import:
try: import X except: ...也可能被漏掉
hiddenimports 显式告诉 PyInstaller "这些模块一定要打进去"。
冗余项:pandas 和 matplotlib 实际没有被项目代码使用——这俩可以删掉,能省 ~80MB 包体积。
7.3.4 Analysis 配置¶
# CameraMonitorSystem.spec:122-138
pathex = ['.', './models', './swellcamera', './watercamera']
a = Analysis(
['main.py'],
pathex=pathex,
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=['.'], # ★ 当前目录的钩子文件会被自动加载
runtime_hooks=[],
excludes=[],
...
)
hookspath=['.'] 是关键——告诉 PyInstaller 去当前目录找 hook-*.py 文件。我们仓库里的 hook-tkinter.py 就是被这个机制找到并执行的。
7.3.5 EXE 配置¶
# CameraMonitorSystem.spec:148-168
exe = EXE(
pyz,
a.scripts, a.binaries, a.zipfiles, a.datas,
[],
name='CameraMonitorSystem2.1',
debug=False,
strip=False,
upx=True, # ★ 启用 UPX 压缩
runtime_tmpdir=None,
console=True, # ★ 显示控制台窗口
icon='logo.ico' if os.path.exists('logo.ico') else None,
disable_windowed_traceback=False,
...
)
几个关键开关:
| 选项 | 当前值 | 影响 |
|---|---|---|
console=True |
是 | exe 启动时会弹出黑色控制台,可以看到 print() 输出。生产部署应改为 False(详见 7.7) |
upx=True |
是 | 启用 UPX 压缩,可减小 exe 30-50%,但需要系统装 UPX |
debug=False |
否 | 不启用 PyInstaller bootloader 调试 |
strip=False |
否 | 不剥离调试符号(在 Windows 上意义不大) |
7.3.6 COLLECT — 目录分发版本¶
# CameraMonitorSystem.spec:173-182
coll = COLLECT(
exe,
a.binaries, a.zipfiles, a.datas,
strip=False, upx=True,
name='CameraMonitorSystem' # 输出到 dist/CameraMonitorSystem/
)
这表示目录分发模式——所有 .dll、.pyd、数据文件都散布在 dist/CameraMonitorSystem/ 下,与 CameraMonitorSystem2.1.exe 同级。
对比单文件模式(onefile=True,本项目没用):
- 优点:分发只有一个 exe 文件
- 缺点:每次启动都要解压到临时目录(~3 秒),慢且占磁盘
→ gxl 选择了目录分发模式,启动更快,但需要分发整个目录。
7.4 hook-tkinter.py 详解¶
这是 PyInstaller 钩子文件,会在 Analysis 阶段被执行,往打包配置里追加东西。
# hook-tkinter.py:7-77
datas = []
binaries = []
hiddenimports = []
try:
# ① 收集标准 Tkinter 数据
datas += collect_data_files('tkinter')
# ② Windows 特定:用真实 Tk 实例获取 Tcl/Tk 库路径
if sys.platform.startswith('win'):
import tkinter
try:
tk_root = tkinter.Tk()
tk_root.withdraw() # 隐藏窗口
tcl_library = tk_root.tk.exprstring('$tcl_library')
tk_library = tk_root.tk.exprstring('$tk_library')
if os.path.exists(tcl_library):
datas.append((tcl_library, 'tcl'))
if os.path.exists(tk_library):
datas.append((tk_library, 'tk'))
tk_root.destroy()
except:
# 回退方法:根据 tkinter 模块路径推测
tk_dir = os.path.dirname(get_module_file_attribute('tkinter'))
tcl_dir = os.path.join(tk_dir, 'tcl')
tk_dir = os.path.join(tk_dir, 'tk')
# ...
# ③ 收集 DLL
if sys.platform == 'win32':
try:
tk_dir = os.path.dirname(get_module_file_attribute('tkinter'))
for f in os.listdir(tk_dir):
if f.lower().endswith('.dll') and f.lower().startswith(('tcl', 'tk')):
binaries.append((os.path.join(tk_dir, f), '.'))
except:
pass
# ④ 显式声明隐藏导入
hiddenimports = [
'tkinter', '_tkinter', 'tkinter.ttk',
'tkinter.messagebox', 'tkinter.filedialog',
'PIL', 'cv2', 'numpy'
]
# ⑤ 添加自定义模块
if os.path.exists('swellcamera'):
hiddenimports.append('swellcamera')
if os.path.exists('watercamera'):
hiddenimports.append('watercamera')
except Exception as e:
print(f"Tkinter 钩子错误: {e}")
print(traceback.format_exc())
print(f"[Tkinter 钩子] 数据文件: {len(datas)}")
print(f"[Tkinter 钩子] 二进制文件: {len(binaries)}")
print(f"[Tkinter 钩子] 隐藏导入: {len(hiddenimports)}")
7.4.1 三重保险机制¶
注意 spec 文件 + hook 文件 + main.py 运行时 三处都在处理 Tcl/Tk:
| 层 | 文件 | 做法 |
|---|---|---|
| 1 | spec L50-57 |
把 tkinter/tcl 目录加入 datas |
| 2 | hook L18-32 |
启动一个真实 Tk 实例查询 $tcl_library,把真实路径加入 datas |
| 3 | main.py L32-55 |
运行时设置 TCL_LIBRARY / TK_LIBRARY 环境变量 |
这种"防御性冗余"在 Windows + PyInstaller + Tkinter 的复杂组合下是必要的——任何一层失败都还有备份。但也意味着代码复杂、难维护。
7.4.2 启动一个真实 Tk 实例的副作用¶
hook L20-21 做了 tk_root = tkinter.Tk(); tk_root.withdraw(),这会在打包时短暂弹出一个隐藏窗口——通常无害,但如果在无 DISPLAY 的 CI 环境打包会失败(Linux 服务器、Docker 容器)。
7.5 add_hidden_imports.py¶
完整代码:
from PyInstaller.utils.hooks import collect_submodules
hiddenimports = collect_submodules('pywintypes') \
+ collect_submodules('win32api') \
+ collect_submodules('win32ctypes')
这是另一个隐式钩子文件——但它并没有被 spec 文件显式引用(spec L131 hookspath=['.']),PyInstaller 会自动加载所有 hook-*.py 文件,但本文件叫 add_hidden_imports.py,不符合 hook 命名规范。
实际效果:这个文件根本不会被 PyInstaller 自动执行。它只是一个孤立的脚本,需要被显式 import 或重命名为 hook-pywin32.py 才有用。
→ 这是一个潜在的 P2 工程债——见 08 · 问题与改进。
7.6 打包产物结构¶
执行 pyinstaller --clean CameraMonitorSystem.spec 后会产生:
build/ # PyInstaller 中间产物(可删)
├── CameraMonitorSystem.spec
├── CameraMonitorSystem2.1/ # 打包过程的临时目录
└── ...
dist/ # 最终分发目录
└── CameraMonitorSystem/ # ← 整个目录可拷贝分发
├── CameraMonitorSystem2.1.exe # ★ 主可执行文件(~50 MB)
├── _internal/ # PyInstaller 6.x 把所有依赖放这里
│ ├── *.dll # Windows 系统 DLL(zlib, vcruntime 等)
│ ├── *.pyd # Python 扩展(_tkinter.pyd, _ssl.pyd 等)
│ ├── tcl/ # Tcl 解释器库
│ ├── tk/ # Tk GUI 库
│ ├── tkinter/ # Python tkinter 模块
│ ├── torch/ # PyTorch(~500-800 MB)
│ ├── numpy/ # NumPy
│ ├── PIL/ # Pillow
│ ├── cv2/ # OpenCV
│ ├── efficientnet_pytorch/ # ~5 MB
│ ├── models/ # ★ 4 个 best_model*.pth + .py
│ ├── swellcamera/ # ★ 子模块代码
│ ├── watercamera/ # ★ 子模块代码
│ ├── fonts/ # ★ simhei.ttf
│ ├── logo.ico
│ └── ...
└── ...
总体积估计:~1-2 GB(主要是 torch)。
如何减小体积: - 删除 hiddenimports 里的
pandas和matplotlib(节省 ~80 MB) - 用torch-cpu版本(节省 CUDA 库,省 ~500 MB) - 清理models/.idea/和__pycache__/(节省 < 10 MB) - 实在不行用--onefile单文件模式(启动慢但分发简单)
7.7 生产部署建议¶
7.7.1 关闭控制台窗口¶
当前 console=True 让 exe 启动时弹黑窗。最终用户演示场景应改为 console=False:
代价:看不到 print() 调试输出。可结合 disable_windowed_traceback=True 屏蔽崩溃弹窗。
7.7.2 加版本信息(可选)¶
version.txt 是 Windows 资源文件格式,会在 exe 的"属性"对话框里显示版本号、公司名等。
7.7.3 数字签名¶
省创结题不需要,但分发给老师/工厂时若 Windows Defender 报警,可考虑用 signtool.exe 加签名。
7.8 打包常见故障¶
Q1:ImportError: DLL load failed while importing _tkinter¶
原因:Tcl/Tk 库没被打包进去。
修复:检查 hook-tkinter.py 是否被正确加载,spec hookspath 应为 ['.']。
Q2:打包成功但 exe 启动闪退¶
修复路径:
1. 先把 console=True,能看到错误信息
2. 检查 main.py 的 try/except 是否吞了关键异常
3. 若是模型加载失败,确认 dist/CameraMonitorSystem/_internal/models/*.pth 都在
Q3:找不到 efficientnet_pytorch¶
原因:PyInstaller 没识别这个包。
修复:已在 hiddenimports 里加了,若仍失败检查是否安装了该包(pip show efficientnet_pytorch)。
Q4:中文乱码¶
原因:simhei.ttf 没被打包,或运行时找不到字体。
修复:检查 dist/CameraMonitorSystem/_internal/fonts/simhei.ttf 是否存在。
Q5:打包后 import error: No module named 'models.model4'¶
原因:hiddenimports 漏了。
修复:spec L75-78 已显式声明 models.model1 ~ models.model4。
7.9 跨平台限制¶
当前打包配置只在 Windows 上工作。原因:
| 限制 | 位置 |
|---|---|
| TCL/TK 路径 | hook-tkinter.py:17-55 只处理 sys.platform.startswith('win') |
| win32 子模块 | spec L99-101 引用了 pywintypes/win32api/win32ctypes,Linux/macOS 没有 |
| 字体路径 | swellcamera.py:63-67 只列 C:/Windows/Fonts/ |
| 摄像头索引 | camera_index=1 在 Linux 上要改成 /dev/video0 之类 |
要打 Linux/macOS 版需要:
1. 把 win32 相关 hiddenimports 改为平台条件判断
2. 给 swellcamera 加 Linux 字体路径(/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc 等)
3. 把 watercamera 的硬编码 simhei.ttf 换成动态查找
4. 在另一台对应系统上运行 PyInstaller(PyInstaller 不支持交叉打包)
7.10 速查表¶
| 你想 | 改哪 |
|---|---|
| 改 exe 名字 | spec L155 name='CameraMonitorSystem2.1' |
| 改图标 | 替换 logo.ico 或改 spec L163 |
| 关掉控制台 | spec L162 console=True → False |
| 加新隐藏导入 | spec L64-112 hiddenimports 列表 |
| 加新数据文件 | spec L21-27 datas 列表 |
| 减小体积 | 删 hiddenimports 里的 pandas, matplotlib;用 torch-cpu |
| 单文件分发 | 改 spec L148 加 onefile=True |
| 改成 GUI 启动(无控制台) | console=False + disable_windowed_traceback=True |