跳转至

08 · 问题与改进清单

本篇是 调研报告.md 的升级版: - 沿用原有 P0/P1/P2 分级 - 每个问题补充代码定位 + 最小修复 patch + 副作用分析 - 加入 Windows 验证回合后发现的新问题 - 末尾给出修复执行顺序与风险矩阵

用途:改 bug 时的工作清单。 前置:建议先读 04 · 主程序与 GUI05 · 检测模块06 · 模型与推理。 下一篇:09 · 开发指南


8.0 问题清单速览

编号 严重度 标题 影响 工作量
P0-1 🔴 模块停止机制失效 用户点停止后摄像头仍占用,GUI 状态不同步 1h
P0-2 🔴 每帧写入临时文件 性能瓶颈 + 双模块文件冲突 0.5h
P0-3 🔴 摄像头索引硬编码 无外接 USB 摄像头的电脑直接崩 0.5h
P0-4 🔴 NEW swellcamera 标签对调 推理结果可能与训练含义反 需向 gxl 确认
P1-1 🟡 模型类名错误(都叫 AgeClassifier) 可读性差 0.5h
P1-2 🟡 模型接口不统一 model4 vs model1/2/3 调用方式不一样 1h
P1-3 🟡 watercamera 字体无 fallback Linux/macOS 中文显示崩 0.5h
P1-4 🟡 torch.load 无 weights_only PyTorch 2.6+ 加载失败 + 安全风险 0.5h
P1-5 🟡 NEW watercamera 注释与 model 类别顺序不一致 阅读时误导 5min
P2-1 🟢 except: pass 调试困难 0.5h
P2-2 🟢 无日志系统 全用 print() 1h
P2-3 🟢 模型无延迟加载 启动慢 1h
P2-4 🟢 model*.py 测试代码硬编码 Windows 路径 泄漏开发者信息 + 测试不可复用 0.2h
P2-5 🟢 requirements.txt 大部分被注释 新用户不知道装啥 0.2h
P2-6 🟢 .idea/ 目录被打包 exe 体积增加 + 泄漏 IDE 信息 0.1h
P2-7 🟢 tempCodeRunnerFile.py 残留 临时文件污染 0.1h
P2-8 🟢 无训练代码 无法复现训练 不可修复(需 gxl 提供)
P2-9 🟢 NEW spec 引用不存在的 build.bat 打包警告 0.1h
P2-10 🟢 NEW add_hidden_imports.py 命名不符合 hook 规范 文件根本没被加载 0.1h
P2-11 🟢 NEW ModuleManager 的 swell_process/water_process 字段死代码 误导阅读 0.1h
P2-12 🟢 NEW WaterContentClassifier.class_names 字段未被使用 死数据 0.1h
P2-13 🟢 NEW efficientnet_b3_pytorch.pth 文件未被加载 多打包 48MB 0.1h
P2-14 🟢 NEW model1/2/3 的 ReLU 被替换为 Identity 推理与训练不一致 需向 gxl 确认

预估总工作量:约 7-9 小时(不含 P0-4、P2-14 需要 gxl 确认的部分)


8.1 P0 严重问题

🔴 P0-1:模块停止机制失效

位置: - main.py:112-120stop_swell_module) - main.py:165-173stop_water_module) - swellcamera.py:179-196(主循环不查 event) - watercamera.py:183-208(主循环不查 event)

Bug 链条

sequenceDiagram
    participant 用户
    participant App as CameraDetectionApp
    participant MM as ModuleManager
    participant SW as swellcamera.main

    用户->>App: 点"停止膨胀检测"
    App->>MM: stop_swell_module()
    MM->>MM: swell_stop_event.set()
    MM->>MM: swell_running = False
    MM-->>App: return True
    App->>App: 按钮文字变"启动膨胀检测"
    App->>用户: 弹窗"请按 q 键"

    Note over SW: ⚠️ swellcamera 主循环根本不查 swell_stop_event
    SW->>SW: while True 继续跑
    SW->>SW: 摄像头继续占用
    SW->>SW: OpenCV 窗口继续显示

根本原因threading.Event 是双向协议——设置方设了,读取方必须主动 .is_set() 检查才有用。子模块完全没参与这个协议。

最小修复 patch

# 1. 改 swellcamera.py:160-196 让 main() 接受 stop_event 参数
def main(stop_event=None):
    classifier = RealTimeExpansionClassifier()
    camera_index = 1
    cap = cv2.VideoCapture(camera_index)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
    if not cap.isOpened():
        print(f"无法打开摄像头,请检查索引{camera_index}是否正确")
        return
    print("实时膨胀分类系统已启动 (按Q退出)...")

    while True:
        # ★ 新增:检查停止事件
        if stop_event is not None and stop_event.is_set():
            print("收到停止信号,退出膨胀检测")
            break

        ret, frame = cap.read()
        if not ret:
            break
        result = classifier.process_frame(frame)
        frame = classifier.display_result(frame, result)
        cv2.imshow('Real-time Expansion Detection', frame)
        if cv2.waitKey(1) & 0xFF == ord('q'):
            break
    cap.release()
    cv2.destroyAllWindows()
# 2. 改 main.py:91 把 stop_event 传进去
swellcamera.main(stop_event=self.swell_stop_event)
# 3. 改 main.py:117-119 stop_swell_module(保持不变就行,event.set() 已经在那里了)

对 watercamera 做同样修改watercamera.py:151def main():def main(stop_event=None):,循环里加同样的 break 检查。

副作用:无。已有的 q 键退出仍然工作。


🔴 P0-2:每帧写入临时文件

位置: - swellcamera.py:78-80 - watercamera.py:82-83

temp_path = "temp_frame.jpg"
cv2.imwrite(temp_path, frame)
# 然后 classifier.predict(temp_path) 又会 Image.open(temp_path)

两个问题: 1. 性能:30 fps 摄像头 = 每秒 30 次磁盘写 + 30 次磁盘读 + 30 次 delete。固态硬盘还好,机械盘明显卡 2. 文件冲突:两个模块都用 "temp_frame.jpg",同时运行会互相覆盖

最小修复 patch

# swellcamera.py:76-111
def process_frame(self, frame):
    """处理单帧图像并返回分类结果(标签已对调)"""
    try:
        # ★ 内存转换替代磁盘 I/O
        frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        pil_image = Image.fromarray(frame_rgb)

        # 但 classifier.predict 接受路径,需要改成接受 PIL Image
        result = self.classifier.predict_pil(pil_image)

        # ... 后续逻辑不变

同时改 model4.py:65-83 加一个 predict_pil 方法:

def predict(self, image_path):
    """原有接口保留"""
    img = Image.open(image_path).convert('RGB')
    return self.predict_pil(img)

def predict_pil(self, pil_image):
    """新增:直接接受 PIL Image"""
    if pil_image.mode != 'RGB':
        pil_image = pil_image.convert('RGB')
    img_tensor = self.transform(pil_image).unsqueeze(0)
    return self._predict(img_tensor)

对 model1/2/3 + watercamera 做对称修改

副作用: - API 增量变化(新增方法),向后兼容 - 如果要彻底改造,可以让 predict() 直接接受 Union[str, Image]


🔴 P0-3:摄像头索引硬编码

位置: - swellcamera.py:165 camera_index = 1 - watercamera.py:156 camera_index = 1

影响: - 笔记本自带摄像头通常是 0,外接 USB 是 1。设 1 是 gxl 的开发环境 - 只有一台摄像头(笔记本默认)的电脑会 cap.isOpened() == False - 没有外接摄像头的台式机直接崩

最小修复 patch

# swellcamera.py:160-175
def main(stop_event=None, camera_index=None):
    classifier = RealTimeExpansionClassifier()

    # ★ 智能 fallback:先试参数,再试 0,再试 1
    candidate_indices = [camera_index] if camera_index is not None else [0, 1, 2]
    cap = None
    for idx in candidate_indices:
        if idx is None:
            continue
        cap_try = cv2.VideoCapture(idx)
        if cap_try.isOpened():
            cap = cap_try
            actual_index = idx
            print(f"已打开摄像头索引 {idx}")
            break
        cap_try.release()

    if cap is None or not cap.isOpened():
        print(f"无法打开任何摄像头(试过 {candidate_indices})")
        return

    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
    # ... 后续不变

副作用:增加启动时间(最坏情况扫 3 个索引),但仅一次。

更完善的方案:在 GUI 加摄像头选择下拉框,让用户主动选——但这就是 P2 优化了。


🔴 P0-4 (NEW):swellcamera 标签对调

位置swellcamera.py:48-53

self.label_mapping = {
    "膨胀": "不膨胀",
    "不膨胀": "膨胀",
    "expanded": "normal",
    "normal": "expanded"
}

问题:model4 实际输出"膨胀"时,会被强制改成"不膨胀"再显示。

可能的成因(推测): 1. 训练时数据标签错位("膨胀"目录被打成 label=0 但应该是 label=1) 2. gxl 推理验证时发现结果"看起来反了",懒得重训,加 mapping 修正

风险: - 如果 gxl 当时真的搞反了标签,这个 mapping 是必要的 hack,删了会让结果变反 - 如果 gxl 误以为搞反了(其实没反),那 mapping 才是 bug,删了反而对

必须做的事向 gxl 当面确认。具体问法:

「水气模型 model4 的训练标签里,'膨胀'这个类对应的是 label=0 还是 label=1?数据集里 '膨胀结束/' 这个目录下的图片,标签是 0 还是 1?」

「swellcamera.py 第 48-53 行的 label_mapping,是因为模型输出反了所以加了 hack,还是别的原因?」

临时建议:在修复之前保留 label_mapping——它至少让 gxl 验证时的指标对得上。


8.2 P1 中等问题

🟡 P1-1:模型类名都叫 AgeClassifier

位置model1.py:18 / model2.py:19 / model3.py:18 / model4.py:18

问题:明显是从某个年龄分类项目复制改来的,没改类名。

修复

# model1.py / model2.py / model3.py
class WaterContentBinaryClassifier:  # 或 WaterModel{N}
    ...

# model4.py
class ExpansionClassifier:
    ...

副作用: - 需要同步改 swellcamera.py:24 的 import alias - watercamera.py:40model_module.AgeClassifier() 也要改 - utils/model_loader.py 可能需要返回类而不是模块

工作量:0.5h,但需小心改 import 链。


🟡 P1-2:predict() 接口不统一

位置:见 06 · 模型与推理 § 6.5

目标:统一所有 predict() 都返回相同结构:

@dataclass
class PredictionResult:
    pred_label: str                          # 预测类别名
    confidence: float                        # 主类置信度
    probabilities: dict[str, float]          # 各类别概率

或返回 dict:

{
    'class': str,
    'confidence': float,
    'probabilities': dict
}

修复:把 model1/2/3 改成与 model4 一样的 dict 返回。watercamera 调用方相应改造。

副作用:所有调用方都要改。这是与 P1-1 一起改最划算(反正都要动)。


🟡 P1-3:watercamera 字体无 fallback

位置watercamera.py:113

font = ImageFont.truetype("simhei.ttf", 32)

问题:相对路径,依赖工作目录有 simhei.ttf

修复:模仿 swellcamera 的字体搜索:

def _get_font_path():
    candidates = [
        get_resource_path(os.path.join("fonts", "simhei.ttf")),
        "C:/Windows/Fonts/simhei.ttf",
        "C:/Windows/Fonts/msyh.ttc",
        "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
        "/System/Library/Fonts/PingFang.ttc",  # macOS
    ]
    for c in candidates:
        if c and os.path.exists(c):
            return c
    return None

# 在 display_result 里:
font_path = _get_font_path()
if font_path:
    font = ImageFont.truetype(font_path, 32)
else:
    font = ImageFont.load_default()

🟡 P1-4:torch.load 无 weights_only

位置:所有 4 个 model*.py 的 torch.load 调用: - model1.py:40 - model2.py:41 - model3.py:40 - model4.py:52

model.load_state_dict(torch.load(model_weight_path, map_location=self.device))

问题: 1. PyTorch ≥2.6 默认 weights_only=True,但旧 .pth 文件可能包含非张量数据(如 Optimizer state),会报错 2. weights_only=False 模式下,torch.load 走 pickle 反序列化,理论上可以执行任意代码——如果 .pth 来自不可信源就有 RCE 风险

修复

# 改成显式声明(旧 PyTorch 也兼容)
model.load_state_dict(torch.load(
    model_weight_path,
    map_location=self.device,
    weights_only=True             # 强制只加载权重
))

如果 weights_only=True 加载失败(说明 .pth 里有非权重数据),就 fallback:

try:
    state = torch.load(path, map_location=device, weights_only=True)
except (RuntimeError, AttributeError):
    # 旧 .pth 可能含 Optimizer state,回退
    state = torch.load(path, map_location=device, weights_only=False)
model.load_state_dict(state)

🟡 P1-5 (NEW):watercamera 注释与 model 类别顺序不一致

位置watercamera.py:14-18model{1,2,3}.py:23

注释(watercamera) 实际(model.class_names)
Model1 (30-35 vs 35-40) ["35-40", "30-35"]
Model2 (35-40 vs 40-45) ["40-45", "35-40"]
Model3 (30-35 vs 40-45) ["40-45", "30-35"]

影响:不影响运行(投票走 dict 键名),但阅读时让人困惑。

修复:要么改注释,要么改 model.class_names 的顺序(前者更省事)。

# watercamera.py:14-18
self.model_weights = {
    '1': 0.91,  # Model1 (35-40 vs 30-35)
    '2': 0.95,  # Model2 (40-45 vs 35-40)
    '3': 0.90   # Model3 (40-45 vs 30-35)
}

8.3 P2 轻微问题

🟢 P2-1:裸 except: pass

位置main.py:800exit_app 末尾)

try:
    self.root.quit()
    self.root.destroy()
except:
    pass
sys.exit(0)

修复:改成具体异常:

try:
    self.root.quit()
    self.root.destroy()
except tk.TclError:
    pass  # 窗口已被销毁

🟢 P2-2:无日志系统

位置:全局,所有 print() 调用。

修复:引入 logging

# 在 main.py 顶部
import logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(name)s] %(levelname)s: %(message)s',
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler('monitor.log', encoding='utf-8')
    ]
)
logger = logging.getLogger(__name__)

# 然后把所有 print() 改成 logger.info() / .error()

🟢 P2-3:模型无延迟加载

问题:主程序启动时不会加载模型,只在用户点"启动检测"按钮时才加载。首次点击会卡 3 秒(CPU)。

轻度优化:在主程序启动后异步预热模型(loading 一次):

# main.py:CameraDetectionApp.__init__ 末尾
threading.Thread(target=self._preload_models, daemon=True).start()

def _preload_models(self):
    """后台预加载模型,避免首次点击的延迟"""
    try:
        from models.model4 import AgeClassifier
        _ = AgeClassifier('models/best_model4.pth')
        # 加载 model1/2/3 同理
    except Exception as e:
        logger.warning(f"模型预热失败: {e}")

🟢 P2-4:测试代码硬编码 Windows 路径

位置model1.py:90 / model2.py:99 / model3.py:90 / model4.py:109

# model4.py:108-117
classifier = AgeClassifier(
    model_path=r"C:\Users\lenovo\Desktop\ovo策略尝试\models\best_model4.pth"
)

问题: - 路径不存在,无法运行 - 泄漏开发者本地路径C:\Users\lenovo 暗示 gxl 用的联想电脑、E:\圆盘干燥机智能化运行图片 透露原始数据集命名)

修复:改用 argparse 或环境变量:

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--image', required=True, help='输入图片路径')
    parser.add_argument('--model', default='models/best_model4.pth')
    args = parser.parse_args()

    classifier = AgeClassifier(args.model)
    result = classifier.predict(args.image)
    print(result)

🟢 P2-5:requirements.txt 大部分被注释

位置requirements.txt:14-21

# 可选的额外依赖 (根据子模块需要添加)
# opencv-python>=4.5.0
# numpy>=1.20.0
# pillow>=8.0.0

问题:让新用户以为这些是可选的,实际是必需的。

修复:取消注释 + 补全所有真实依赖(详见 02 · 环境与运行 § 2.2)。


🟢 P2-6:.idea/ 被打包

位置models/.idea/

修复: 1. 加 .gitignore

.idea/
__pycache__/
*.pyc
tempCodeRunnerFile.py
*.spec.bak
  1. 删除现有 .idea/
rm -rf models/.idea
  1. CameraMonitorSystem.spec 的 glob 部分加过滤:
for f in glob.glob('models/**/*', recursive=True):
    if os.path.isfile(f) and '.idea' not in f and '__pycache__' not in f:
        datas.append((f, ...))

🟢 P2-7:tempCodeRunnerFile.py 残留

位置models/tempCodeRunnerFile.py(64 字节)

修复:直接删除并加进 .gitignore。这是 VS Code 用"Run Selection"时生成的临时文件。


🟢 P2-8:无训练代码

问题:仓库只有推理,无法复现训练。

修复:不可修复,只能找 gxl 要训练脚本(09 · 开发指南 § 9.2 提供了不重训也能换模型的方案)。


🟢 P2-9 (NEW):spec 引用不存在的 build.bat

位置CameraMonitorSystem.spec:26

datas = [
    ...
    ('build.bat', '.'),       # ⚠️ 文件不存在
]

影响:打包时 PyInstaller 警告找不到该文件,但不会致命。

修复:要么删掉这一行,要么补建 build.bat

@echo off
echo Building CameraMonitorSystem...
pyinstaller --clean CameraMonitorSystem.spec
echo Done. Output in dist/CameraMonitorSystem/
pause

🟢 P2-10 (NEW):add_hidden_imports.py 命名不符合 hook 规范

位置add_hidden_imports.py

问题:PyInstaller 只自动加载命名为 hook-*.py 的文件,本文件名为 add_hidden_imports.py实际上没被加载

修复:要么重命名为 hook-pywin32.py,要么把内容并入 spec:

# CameraMonitorSystem.spec 顶部
from PyInstaller.utils.hooks import collect_submodules
hiddenimports = (
    collect_submodules('pywintypes')
    + collect_submodules('win32api')
    + collect_submodules('win32ctypes')
    + [...] # 现有 hiddenimports
)

🟢 P2-11 (NEW):ModuleManager 死字段

位置main.py:60-61

self.swell_process = None
self.water_process = None

问题:通篇没有再用到这两个字段。

修复:删除即可,或加注释说明"曾打算用 subprocess,后改 threading"。


🟢 P2-12 (NEW):WaterContentClassifier.class_names 死字段

位置watercamera.py:20-24

self.class_names = {
    '1': ['30-35', '35-40'],
    '2': ['35-40', '40-45'],
    '3': ['30-35', '40-45']
}

问题:只赋值不读取,L52-57 唯一一处使用 self.class_names[model_id] 是写进 all_probs 用作调试输出。可以保留也可以删。


🟢 P2-13 (NEW):efficientnet_b3_pytorch.pth 文件未被加载

位置models/efficientnet_b3_pytorch.pth(48 MB)

问题:代码用 EfficientNet.from_name(...) 而非 from_pretrained,这个备份权重从未被加载,但被打包进 exe 多占 48 MB。

修复:直接删除文件,或在 spec 里过滤:

for f in glob.glob('models/**/*', recursive=True):
    if os.path.isfile(f) and 'efficientnet_b3_pytorch' not in f:
        datas.append((f, ...))

🟢 P2-14 (NEW):model1/2/3 的 ReLU 被替换为 Identity

位置model1.py:31-38

问题:分类头中本应是 ReLU 的位置(_fc.2)被替换为 Identity()

nn.Identity(),                  # _fc.0 占位(原为 Dropout)
nn.Linear(num_features, 256),   # _fc.1
nn.Identity(),                  # _fc.2 占位(原为 ReLU?)  ← 这里
nn.BatchNorm1d(256),            # _fc.3
nn.Identity(),                  # _fc.4 占位(原为 Dropout)
nn.Linear(256, 1)               # _fc.5

影响: - 推理时少了一次非线性激活 - 训练时如果用了 ReLU,那现在的推理结果不与训练时严格一致 - 但 BatchNorm 之后激活幅度不大,差异可能很小

修复:需向 gxl 确认训练时 _fc.2 是什么层。如果确实是 ReLU,则改为:

nn.Identity(),
nn.Linear(num_features, 256),
nn.ReLU(),                       # 恢复
nn.BatchNorm1d(256),
nn.Identity(),
nn.Linear(256, 1)

8.4 风险优先级矩阵

quadrantChart
    title 风险矩阵(影响 × 工作量)
    x-axis "工作量小" --> "工作量大"
    y-axis "影响小" --> "影响大"
    quadrant-1 重要紧急(先做)
    quadrant-2 长期投入
    quadrant-3 可忽略
    quadrant-4 顺手做
    "P0-1 停止机制": [0.4, 0.95]
    "P0-2 临时文件": [0.2, 0.85]
    "P0-3 摄像头索引": [0.2, 0.9]
    "P0-4 标签对调": [0.1, 0.7]
    "P1-1 类名混乱": [0.3, 0.5]
    "P1-2 接口不统一": [0.5, 0.5]
    "P1-3 字体fallback": [0.2, 0.4]
    "P1-4 weights_only": [0.2, 0.45]
    "P1-5 注释错位": [0.05, 0.15]
    "P2-1 except pass": [0.2, 0.2]
    "P2-2 日志系统": [0.5, 0.3]
    "P2-3 延迟加载": [0.4, 0.25]
    "P2-4 测试路径": [0.1, 0.2]
    "P2-5 requirements": [0.1, 0.2]
    "P2-6 idea清理": [0.05, 0.1]
    "P2-7 临时文件": [0.05, 0.05]
    "P2-13 48MB备份": [0.05, 0.15]

8.5 推荐修复执行顺序

第一阶段:让结题时能演示(必做,~3h)

  1. P0-3 摄像头索引可配置(30 min)← 演示时拿到任何电脑都能跑
  2. P2-5 修 requirements.txt(10 min)← 让老师能 pip install -r 复现环境
  3. P0-1 修停止机制(1h)← 演示时点停止能真的停
  4. P0-2 消除临时文件(30 min)← 解决双模块冲突
  5. P2-6/P2-7 清理 .idea/ 和 tempCodeRunnerFile.py(10 min)← 仓库面貌
  6. P1-3 watercamera 字体 fallback(30 min)← 跨平台兼容

第二阶段:让代码可读(可选,~2h)

  1. P1-1 + P1-2 类名 + 接口统一(一起改,1.5h)
  2. P1-5 修注释(5 min)
  3. P2-11/P2-12 删死代码(10 min)

第三阶段:长期改进(不影响结题)

  1. P0-4 + P2-14:向 gxl 确认标签对调与 ReLU 替换的真实原因
  2. P1-4 weights_only:升级 PyTorch 时再改
  3. P2-2 logging 系统:从 print 迁移
  4. P2-3 延迟加载:优化启动速度

不要做的事

  • 不要在结题前重训模型(来不及,也无必要——gxl 的模型够用)
  • 不要大重构(如把 model1/2/3 合并、把 ModuleManager 改成 subprocess)——会引入新 bug
  • 不要改动模型权重文件(.pth 是黑盒)

8.6 修复前/后对比示例:P0-1

当前行为

用户点"停止膨胀检测"
  ├─ GUI 按钮变"启动膨胀检测"  ← 看起来停了
  ├─ 弹窗"请按 q 键"            ← gxl 已承认 bug
  └─ OpenCV 窗口仍在跑           ← 实际没停

修复后行为

用户点"停止膨胀检测"
  ├─ GUI 按钮变"启动膨胀检测"
  ├─ swell_stop_event.set()
  ├─ swellcamera.main() 主循环检测到 → break
  ├─ cap.release() + destroyAllWindows()
  └─ OpenCV 窗口关闭             ← 真的停了

弹窗"请按 q 键" 这句话也可以从 main.py:649 删掉(或改成"已停止")。