跳转至

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-15watercamera.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-55main.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_modulemain.py:122-173)与 swell 完全对称,只是字段名换成 water_*stop_all_modulesL183-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:800except: 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