跳转至

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 文件)

pyinstaller --clean CameraMonitorSystem.spec

输出会在 dist/CameraMonitorSystem/CameraMonitorSystem2.1.exe

--clean 标志会先清理 build/ 缓存,避免上次打包的残留导致问题。

⚠️ README 提到但仓库内不存在的方式

README L77-78 提到:

# 使用批处理脚本 (Windows)
build.bat

但仓库根目录没有 build.batCameraMonitorSystem.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 语句决定要打包哪些模块,但下列情况会被遗漏:

  1. 动态 importimportlib.import_module):本项目的 utils/model_loader.py 就是用 importlib,PyInstaller 无法静态分析
  2. C 扩展_tkintercv2numpy 等通过 .pyd/.so 加载
  3. 条件 importtry: import X except: ... 也可能被漏掉

hiddenimports 显式告诉 PyInstaller "这些模块一定要打进去"。

冗余项pandasmatplotlib 实际没有被项目代码使用——这俩可以删掉,能省 ~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 里的 pandasmatplotlib(节省 ~80 MB) - 用 torch-cpu 版本(节省 CUDA 库,省 ~500 MB) - 清理 models/.idea/__pycache__/(节省 < 10 MB) - 实在不行用 --onefile 单文件模式(启动慢但分发简单)


7.7 生产部署建议

7.7.1 关闭控制台窗口

当前 console=True 让 exe 启动时弹黑窗。最终用户演示场景应改为 console=False

# CameraMonitorSystem.spec:162
console=False,           # 改这里

代价:看不到 print() 调试输出。可结合 disable_windowed_traceback=True 屏蔽崩溃弹窗。

7.7.2 加版本信息(可选)

# 在 EXE() 里加
version='version.txt',

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.pytry/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=TrueFalse
加新隐藏导入 spec L64-112 hiddenimports 列表
加新数据文件 spec L21-27 datas 列表
减小体积 删 hiddenimports 里的 pandas, matplotlib;用 torch-cpu
单文件分发 spec L148onefile=True
改成 GUI 启动(无控制台) console=False + disable_windowed_traceback=True