04 · 主程序与 GUI¶
本篇逐节拆解
main.py(849 行),讲清 Tkinter GUI、ModuleManager、状态机、计时器、PyInstaller 兼容代码。 前置:03 · 系统架构 下一篇:05 · 检测模块
4.1 文件总览¶
main.py 的代码结构如下:
行号区间 内容
───────── ──────────────────────────────────────────
L1-13 模块文档字符串 + 标准库导入
L17-25 resource_path() ← PyInstaller 资源路径
L28-29 sys.path 注入 ← 让子目录可作为模块导入
L32-55 fix_tkinter_for_pyinstaller()
L54-55 条件调用上述修复函数
L57-187 class ModuleManager ← 子模块生命周期管理
L188-802 class CameraDetectionApp ← Tkinter 主应用
L188-227 __init__
L228-252 center_window
L254-400 create_interface
L402-450 create_fallback_interface ← 主界面失败时的备用
L452-530 create_stats_labels
L532-548 update_stats
L550-582 bind_hover_effects ← 鼠标悬停换色
L584-623 module_status_callback ← 子模块回调入口
L625-679 四个 toggle/start/stop 方法
L681-699 stop_all_detections
L701-737 start_status_updater ← 100ms 队列轮询
L739-775 start_timer ← 1s 计时器
L777-802 exit_app
L804-849 main() + if __name__
总行数 849 行,比调研报告里写的"848 行"多 1 行(差在末尾空行)。
4.2 PyInstaller 兼容层(L17-55)¶
resource_path() — 统一资源寻址¶
# main.py:17-25
def resource_path(relative_path):
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS # 打包后路径
else:
base_path = os.path.abspath(".") # 开发时路径
return os.path.join(base_path, relative_path)
PyInstaller 打包后,所有数据文件被解压到一个临时目录 sys._MEIPASS,例如 C:\Users\...\AppData\Local\Temp\_MEI12345\。这个函数让代码无论在开发还是部署都能用同样的相对路径访问资源。
调用位点:
- main.py:80 swell_path = resource_path("swellcamera")
- main.py:133 water_path = resource_path("watercamera")
- main.py:821 icon_path = resource_path('logo.ico')
注意:每个子模块内部也各自实现了一份 get_resource_path()(swellcamera.py:11-15、watercamera.py:无(实际没有,依赖主程序)、model1.py:9-16 等),实现略有差异——这是个应该统一但没统一的工程债。
fix_tkinter_for_pyinstaller() — TCL/TK 环境修复¶
# main.py:32-55
def fix_tkinter_for_pyinstaller():
if getattr(sys, 'frozen', False):
try:
tcl_path = os.path.join(sys._MEIPASS, 'tcl')
tk_path = os.path.join(sys._MEIPASS, 'tk')
if os.path.exists(tcl_path):
os.environ['TCL_LIBRARY'] = tcl_path
if os.path.exists(tk_path):
os.environ['TK_LIBRARY'] = tk_path
tkinter_path = os.path.join(sys._MEIPASS, 'tkinter')
if os.path.exists(tkinter_path):
sys.path.insert(0, tkinter_path)
except Exception as e:
print(f"Tkinter 路径设置失败: {e}")
打包后 Tcl/Tk 库不在标准位置,必须通过环境变量告诉 Tkinter 去哪找。本函数在 main.py:54-55 和 main.py:808-809 被双重调用——一次在 import 前,一次在 main 函数里——这是保险但也冗余的做法。
看不懂的细节:tkinter 在 import 时就会去读 TCL_LIBRARY,所以 L54-55 的那一次(import 前)才是真正生效的;L808-809 的那一次实际上没用,但也不会出错。
4.3 ModuleManager(L57-187)— 子模块生命周期¶
类成员¶
# main.py:59-67
def __init__(self):
self.swell_process = None # ⚠️ 命名误导,从未实际启动 process
self.water_process = None # ⚠️ 同上
self.swell_thread = None # 真正使用的 thread
self.water_thread = None
self.swell_running = False
self.water_running = False
self.swell_stop_event = threading.Event()
self.water_stop_event = threading.Event()
swell_process / water_process 是死代码——通篇没有任何赋值/读取它们的地方。猜测 gxl 最初打算用 subprocess.Popen 启动子模块(更安全),后来改用了 threading,遗留了这两个变量。
start_swell_module(callback)¶
# main.py:69-110
def start_swell_module(self, callback=None):
if self.swell_running:
return False
self.swell_stop_event.clear()
def run_swell():
try:
swell_path = resource_path("swellcamera")
if swell_path not in sys.path:
sys.path.append(swell_path)
import swellcamera
self.swell_running = True
if callback:
callback("swell", "started")
swellcamera.main() # ★ 阻塞直到 swellcamera 返回
except ImportError as e:
error_msg = f"无法导入膨胀检测模块: {str(e)}"
print(error_msg)
if callback:
callback("swell", f"error: {error_msg}")
except Exception as e:
error_msg = f"膨胀检测运行错误: {str(e)}"
print(error_msg)
if callback:
callback("swell", f"error: {error_msg}")
finally:
self.swell_running = False
if callback:
callback("swell", "stopped")
self.swell_thread = threading.Thread(target=run_swell, daemon=True)
self.swell_thread.start()
return True
关键观察:
1. swell_running = True 是在线程内部设置的,存在轻微竞态(按钮可能在 callback="started" 之前就被点了两次),但因为 GUI 单线程不可能并发点击,所以实际不出问题
2. daemon=True 表示主进程退出时线程会被强制杀死
3. swellcamera.main() 是阻塞调用,整个 thread 的生死取决于这个函数
4. finally 块保证无论怎么退出,swell_running 都会复位、callback("stopped") 都会触发
stop_swell_module() — P0 bug 的源头¶
# main.py:112-120
def stop_swell_module(self):
if not self.swell_running:
return False
self.swell_stop_event.set() # ★ set 了但没人读
self.swell_running = False # ★ 立即标记为 False,与实际线程状态不同步
return True
bug 链条:
1. 用户点击按钮 → stop_swell_module() 被调用
2. swell_stop_event.set() 被执行
3. 但 swellcamera.py 主循环(L179-196)完全没引用 swell_stop_event
4. swellcamera.main() 仍在跑,OpenCV 窗口仍在显示
5. 与此同时 self.swell_running = False 让 GUI 以为已停止
6. 按钮回到「启动」状态,但 OpenCV 还在跑
7. 唯一能真正退出的方式是在 OpenCV 窗口里按 q,触发 swellcamera.py:195 的 break
→ 这是 08 · 问题与改进 P0-1,修复方案见那里。
water 模块对称¶
start_water_module / stop_water_module(main.py:122-173)与 swell 完全对称,只是字段名换成 water_*。stop_all_modules(L183-186)只是依次调用两个 stop。
4.4 CameraDetectionApp(L188-802)— 主 GUI¶
状态字典¶
# main.py:199-214
self.swell_stats = {
"状态": "未启动",
"检测结果": "无",
"运行时间": "0s",
"最后更新": "未更新"
}
self.water_stats = {...} # 结构相同
self.swell_start_time = None # 记录启动 time.time()
self.water_start_time = None
这是 GUI 的本地状态,与 ModuleManager 的 swell_running 是两套独立状态,靠 module_status_callback 同步——所以一旦 callback 丢消息或线程崩溃,两边状态会不一致。
界面布局¶
create_interface()(L254-400)的 Frame 嵌套:
root (600×500, 不可调整)
├── title_frame (蓝色 #2E86AB, 高 80)
│ └── title_label "摄像头检测系统"
├── subtitle_label "独立模块控制"
├── button_frame
│ ├── swell_button (紫色 #A23B72, 启动膨胀检测)
│ └── water_button (橙色 #F18F01, 启动含水量检测)
├── stats_frame (LabelFrame: 实时检测统计)
│ └── columns_frame
│ ├── swell_stats_frame (灰色 #F8F9FA)
│ │ ├── swell_status_label 状态:
│ │ ├── swell_result_label 检测结果:
│ │ ├── swell_time_label 运行时间:
│ │ └── swell_update_label 最后更新:
│ └── water_stats_frame (灰色 #F8F9FA)
│ └── (同上四个 label)
└── control_frame
├── stop_all_button (红色 #E74C3C, 停止所有检测)
├── status_label "系统就绪"
└── exit_button (深灰 #34495E, 退出程序)
字体统一:所有标题用 Microsoft YaHei(雅黑),标签用同字族小号。Linux 上无此字体会回退到默认字体,不会崩但视觉变差。
备用界面¶
create_fallback_interface()(L402-450):当主界面构建抛异常时,会清空 root 重建一个极简界面(标题 + 两个启动按钮 + 一个退出按钮)。这是 README 里宣传的「程序包含备用界面创建机制」的实际实现。
实际触发条件几乎不存在(除非 Tkinter 本身坏了),属于防御性过度设计。
按钮悬停效果¶
bind_hover_effects()(L550-582):根据 is_swell_running() / is_water_running() 决定悬停时变亮还是变暗。颜色编码是写死的十六进制——美观但完全不可主题化。
4.5 状态回调与队列轮询¶
module_status_callback(module_type, status)(L584-623)¶
子模块线程通过此回调向 GUI 报告状态。status 字符串可能是:
| status 值 | 触发位置 | 含义 |
|---|---|---|
"started" |
main.py:88 |
检测模块已开始 |
"stopped" |
main.py:106 |
检测模块已结束(无论原因) |
"error: ..." |
main.py:97/102 |
导入或运行异常 |
回调做两件事:
1. 同步更新 swell_stats / water_stats 字典(L588-620)
2. 入队:self.status_queue.put((module_type, status))(L623)—— 让 GUI 主线程异步处理 UI 变更
注意第 1 步是直接在子线程里改 dict——技术上 Python 的 GIL 保证了原子性,但仍是不推荐的跨线程写共享状态做法。
start_status_updater() — 100ms 轮询(L701-737)¶
def check_status():
try:
while True:
module_type, status = self.status_queue.get_nowait()
# 根据 status 更新按钮 + 状态标签
except queue.Empty:
pass
self.root.after(100, check_status) # 100ms 后再来一次
self.root.after(100, check_status) # 启动第一次
经典的 Tkinter 单线程事件循环里嵌入轮询的模式。get_nowait() 不阻塞,循环一次性消费所有积压的事件再退出。
start_timer() — 1s 计时器(L739-775)¶
def update_timer():
current_time = time.time()
if self.swell_start_time:
elapsed = int(current_time - self.swell_start_time)
# 格式化为 "1h2m3s" / "2m3s" / "3s"
self.swell_stats["运行时间"] = ...
if self.water_start_time:
...
self.update_stats() # 把 dict 同步到 Label
self.root.after(1000, update_timer)
self.root.after(1000, update_timer)
每秒重算两个模块的运行时间,并触发 update_stats() 把 dict 同步到 Label 文本。
4.6 按钮回调矩阵¶
| 按钮 | 回调 | 行号 | 关键动作 |
|---|---|---|---|
| 启动膨胀检测 | toggle_swell_detection |
L625-630 |
已运行则停止,未运行则启动 |
| ↳ 启动 | start_swell_detection |
L632-639 |
module_manager.start_swell_module(callback) |
| ↳ 停止 | stop_swell_detection |
L641-651 |
stop_swell_module() + 弹窗提示按 q |
| 启动含水量检测 | toggle_water_detection |
L653-658 |
(对称) |
| ↳ 启动 | start_water_detection |
L660-667 |
(对称) |
| ↳ 停止 | stop_water_detection |
L669-679 |
(对称) |
| 停止所有检测 | stop_all_detections |
L681-699 |
收集运行中的模块 → 各自停 → 弹窗提示按 q |
| 退出程序 | exit_app |
L777-802 |
弹确认 → stop_all → root.destroy → sys.exit |
注意 main.py:649 的弹窗文案:
膨胀检测正在停止,请在检测窗口中按 'q' 键完全退出
这句话直接承认了停止机制失效——gxl 已经意识到了 P0-1 bug,但选择用提示对话框绕过,而不是修代码。
4.7 入口函数(L804-849)¶
def main():
try:
if getattr(sys, 'frozen', False):
fix_tkinter_for_pyinstaller() # 二次调用(冗余)
root = tk.Tk()
root.title("摄像头检测系统")
root.geometry("600x500") # 不带位置,靠 center_window 后定位
root.resizable(False, False)
try:
icon_path = resource_path('logo.ico')
if os.path.exists(icon_path):
root.iconbitmap(icon_path)
except Exception as e:
print(f"无法加载图标: {e}")
app = CameraDetectionApp(root)
root.protocol("WM_DELETE_WINDOW", app.exit_app)
root.mainloop()
except Exception as e:
print(f"程序启动失败: {e}")
import traceback
traceback.print_exc()
try:
input("按Enter键退出...") # 保持控制台不消失
except:
time.sleep(5)
if __name__ == "__main__":
main()
亮点:捕获全局异常后用 input() 阻塞,防止打包成 console 程序时窗口一闪而过。失败回退到 time.sleep(5) 保证 5 秒可见。
问题:main.py:800 的 except: pass 是裸 except(详见 08 · 问题与改进 P2-1)。
4.8 关键代码引用速查表¶
| 任务 | 文件位置 |
|---|---|
| 添加新检测按钮 | main.py:281-312 |
| 修改状态文字 | main.py:584-623 |
| 调整轮询频率 | main.py:734/737 after(100, ...) |
| 调整计时器频率 | main.py:772/775 after(1000, ...) |
| 改窗口大小 | main.py:231-232 |
| 改主题颜色 | main.py:266(标题蓝)/ L289(膨胀紫)/ L304(含水量橙) |
| 修复停止 bug | 需同时改 main.py:112-120 和 swellcamera.py:179 |