From 4efe5b871e3474ee92b512c2b7fed632ed2f9800 Mon Sep 17 00:00:00 2001 From: DXC Date: Thu, 4 Jun 2026 10:38:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(gui):=20=E4=B8=80=E9=94=AE=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=99=BA=E8=83=BD=E9=A2=84=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 段预检彻底解决切换 PipelineRunner 后报 TypeError/静默跳过等问题, 并升级一键运行 UX: - 预检 1: work_path + log + scan + auto_populate(无需弹窗, 静默回填) - 预检 2: step3 波段越界 60s 倒计时弹窗(BandConfirmDialog) + gdal 主线程同步读 RasterCount, 越界时 SpinBox 回写 UI - 预检 3: img_path 硬校验(warning + 跳 step1 + return) - 预检 4: csv_path 软提示(information + 不 return, 让用户在 QMessageBox.question 二次确认时自己决定是否跳过训练) 新增 src/gui/dialogs.py: BandConfirmDialog(QDialog 子类, 60s 倒计时) --- src/gui/dialogs.py | 147 +++++++++++++++++++++++++++++++++++ src/gui/water_quality_gui.py | 143 +++++++++++++++++++++++++++++++--- 2 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 src/gui/dialogs.py diff --git a/src/gui/dialogs.py b/src/gui/dialogs.py new file mode 100644 index 0000000..ba22b71 --- /dev/null +++ b/src/gui/dialogs.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +""" +自定义确认对话框集合 + +按"职责单一 + 不污染主 GUI 文件"原则拆分。 +与 water_quality_gui.py 保持 1:1 风格(中文注释 / 顶部 encoding 声明)。 +""" + +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QDialog, + QLabel, + QSpinBox, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QDialogButtonBox, + QWidget, +) + + +class BandConfirmDialog(QDialog): + """波段越界智能确认对话框(60 秒倒计时) + + 场景 + ---- + 用户在 step3 面板里设置了 nir_band=66,但实际影像只有 50 波段。 + Pipeline 一旦按 66 去取波段就会报 IndexError。 + + 行为约定 + -------- + - 启动时 QTimer 开始 60s 倒计时,按钮文字同步显示 "确定 (Ns)"。 + - 用户手动调整 QSpinBox + 点"确定":立即 accept(),返回当前 spinbox 值。 + - 用户 60s 未操作:定时器归零时自动 accept(),返回当前 spinbox 值 + (**默认值为影像最大波段数 = 用户拿不到想要波段时的兜底**)。 + - 用户点"取消运行":reject(),调用方应中止 run_full_pipeline。 + """ + + DEFAULT_TIMEOUT = 60 # 秒 + + def __init__( + self, + parent: QWidget = None, + requested_band: int = 66, + max_band: int = 50, + recommended_band: int = 66, + method_label: str = "NIR", + timeout_seconds: int = DEFAULT_TIMEOUT, + ): + super().__init__(parent) + self._requested_band = requested_band + self._max_band = max_band + self._recommended_band = recommended_band + self._method_label = method_label + self._timeout_seconds = timeout_seconds + self._remaining = timeout_seconds + self._selected_band = max_band # 默认 = 最大波段(兜底) + + self.setWindowTitle("波段索引越界") + self.setModal(True) + self.setMinimumWidth(420) + self._init_ui() + self._start_timer() + + def _init_ui(self): + """搭建 UI:警告文字 + 灰色推荐 + SpinBox + 倒计时按钮""" + layout = QVBoxLayout(self) + + # 1) 主提示(带 HTML 强调) + self._msg_label = QLabel( + f"影像仅有 {self._max_band} 个波段," + f"无法读取第 {self._requested_band} 波段({self._method_label})。" + ) + self._msg_label.setWordWrap(True) + self._msg_label.setFont(QFont("Microsoft YaHei", 10)) + layout.addWidget(self._msg_label) + + # 2) 灰色小字推荐 + hint_label = QLabel( + f"(推荐近红外波段序号:{self._recommended_band})" + ) + hint_label.setStyleSheet("color: #888; font-size: 10px;") + layout.addWidget(hint_label) + + # 3) 波段选择 SpinBox(默认值 = 最大波段 = 超时兜底) + spin_row = QHBoxLayout() + spin_row.addWidget(QLabel(f"请选择要使用的{self._method_label}索引:")) + self._spin = QSpinBox() + self._spin.setRange(0, self._max_band) + self._spin.setValue(self._max_band) + self._spin.setSuffix(f" / 0~{self._max_band}") + spin_row.addWidget(self._spin) + spin_row.addStretch(1) + layout.addLayout(spin_row) + + # 4) 倒计时说明 + countdown_tip = QLabel( + f"⏱ {self._timeout_seconds} 秒内不操作,将自动使用最大波段 " + f"({self._max_band})继续运行。" + ) + countdown_tip.setStyleSheet("color: #555; font-size: 9px;") + countdown_tip.setWordWrap(True) + layout.addWidget(countdown_tip) + + # 5) 按钮组(手动"确定 (Ns)" + "取消运行") + btn_box = QDialogButtonBox() + self._ok_btn = QPushButton(f"确定 ({self._remaining}s)") + self._ok_btn.setDefault(True) + self._ok_btn.clicked.connect(self.accept) + self._cancel_btn = QPushButton("取消运行") + self._cancel_btn.clicked.connect(self.reject) + btn_box.addButton(self._ok_btn, QDialogButtonBox.AcceptRole) + btn_box.addButton(self._cancel_btn, QDialogButtonBox.RejectRole) + layout.addWidget(btn_box) + + def _start_timer(self): + """启动 1Hz 倒计时;归零时自动 accept()""" + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self._tick) + self._timer.start() + + def _tick(self): + """每秒刷新按钮文字;归零时停表 + accept()""" + self._remaining -= 1 + if self._remaining <= 0: + self._timer.stop() + self.accept() # 超时:返回当前 spinbox 值(= max_band) + else: + self._ok_btn.setText(f"确定 ({self._remaining}s)") + + # ── 暴露给调用方的结果接口 ────────────────────────────── + def selected_band(self) -> int: + """弹窗关闭后由调用方取回用户选定的波段索引""" + return self._selected_band + + def accept(self): + """点"确定"或倒计时归零触发:记录当前 spinbox 值后真正关闭""" + self._selected_band = self._spin.value() + self._timer.stop() + super().accept() + + def reject(self): + """点"取消运行"触发:停表 + 关闭,调用方需中止流程""" + self._timer.stop() + super().reject() diff --git a/src/gui/water_quality_gui.py b/src/gui/water_quality_gui.py index ae03a99..95f1e1d 100644 --- a/src/gui/water_quality_gui.py +++ b/src/gui/water_quality_gui.py @@ -128,6 +128,7 @@ from src.gui.panels.step8_panel import Step8Panel from src.gui.panels.step8_5_panel import Step8_5Panel from src.gui.panels.step8_75_panel import Step8_75Panel from src.gui.panels.step9_panel import Step9Panel +from src.gui.dialogs import BandConfirmDialog from src.gui.panels.visualization_panel import VisualizationPanel from src.gui.panels.report_generation_panel import ReportGenerationPanel @@ -2743,7 +2744,110 @@ class WaterQualityGUI(QMainWindow): "电话:010-51292601\n" "邮箱:hanshanlong@iris-rs.cn\n" ) - + + def _precheck_step3_bands(self) -> bool: + """步骤 3 波段越界预检(主线程同步执行,避多线程弹窗坑) + + 读取 step1 影像的 RasterCount,校验 step3 面板当前方法下所有波段索引 + (nir_lower/nir_upper/nir_band/oxy_band/lower_oxy/upper_oxy/hedley_nir_band) + 是否越界。若越界,弹 BandConfirmDialog(60s 倒计时)让用户调整或取消。 + + Returns: + True: 预检通过或已自动调整,run_full_pipeline 继续 + False: 用户点"取消运行",run_full_pipeline 应 return + """ + # 1) 取 step1 影像路径 + step3 配置 + enabled 标志 + try: + img_path = self.step1_panel.img_file.get_path() if hasattr(self, 'step1_panel') else None + step3_cfg = self.step3_panel.get_config() if hasattr(self, 'step3_panel') else None + step3_enabled = self.step3_panel.enable_checkbox.isChecked() if hasattr(self, 'step3_panel') else False + except Exception as e: + self.log_message(f"⚠ step3 波段预检:读取面板状态失败 - {e}", "warning") + return True # 失败不阻断(防御性:放行比误杀好) + + # 早退条件:step3 禁用 / 无 img_path / 无 cfg + if not step3_enabled: + return True + if not img_path or not os.path.isfile(img_path): + self.log_message("⚠ step3 波段预检:未找到参考影像,跳过", "info") + return True + if not step3_cfg: + return True + + # 2) 读 RasterCount(gdal 头信息读取,毫秒级不卡 UI) + try: + dataset = gdal.Open(img_path) + if dataset is None: + self.log_message(f"⚠ step3 波段预检:gdal 无法打开影像 {img_path}", "warning") + return True + max_band = dataset.RasterCount + dataset = None + except Exception as e: + self.log_message(f"⚠ step3 波段预检:读取 RasterCount 失败 - {e}", "warning") + return True + + if max_band <= 0: + return True + + # 3) 不同方法对应不同的波段字段(cfg_key, panel_attr, 推荐值, 标签) + method = step3_cfg.get('method', 'goodman') + if method == 'goodman': + band_fields = [ + ('nir_lower', 'nir_lower', 65, 'NIR下波段'), + ('nir_upper', 'nir_upper', 91, 'NIR上波段'), + ] + elif method == 'kutser': + band_fields = [ + ('oxy_band', 'oxy_band', 38, '氧吸收波段'), + ('lower_oxy', 'lower_oxy', 36, '下氧吸收波段'), + ('upper_oxy', 'upper_oxy', 49, '上氧吸收波段'), + ('nir_band', 'nir_band', 47, 'NIR波段'), + ] + elif method == 'hedley': + band_fields = [ + ('hedley_nir_band', 'hedley_nir_band', 47, 'NIR波段'), + ] + else: # sugar 无波段索引 + return True + + # 4) 逐字段检查;遇到第一个越界就弹窗(用户处理完继续检查下一个) + for cfg_key, panel_attr, recommended, label in band_fields: + requested = step3_cfg.get(cfg_key) + if requested is None or requested <= max_band: + continue # 没设 / 没越界 + + self.log_message( + f"⚠ step3 波段越界:{label}={requested} > 影像波段数 {max_band}", + "warning", + ) + + dlg = BandConfirmDialog( + self, + requested_band=requested, + max_band=max_band, + recommended_band=recommended, + method_label=label, + ) + result = dlg.exec_() + if result == QDialog.Rejected: + self.log_message("✗ 用户取消运行(step3 波段越界未解决)", "warning") + return False + + new_band = dlg.selected_band() + try: + spin = getattr(self.step3_panel, panel_attr) + spin.setValue(new_band) + except AttributeError: + self.log_message(f"⚠ step3 panel 缺控件 {panel_attr},跳过回写", "warning") + continue + + self.log_message( + f"✓ {label}:{requested} → {new_band}(影像最多 {max_band} 波段)", + "info", + ) + + return True + def run_full_pipeline(self): """运行完整流程""" if not PIPELINE_AVAILABLE: @@ -2752,21 +2856,42 @@ class WaterQualityGUI(QMainWindow): "无法导入pipeline模块,请确保water_quality_inversion_pipeline_GUI.py文件存在!" ) return - - # 验证配置 + + # ── 1) 运行前智能预检与自动回填(硬盘已有产物自动跳过) ── + work_path = Path(getattr(self, 'work_dir', './work_dir')) + self.log_message("正在进行运行前环境预检与自动扫描...", "info") + self.scan_work_directory_for_files(work_path) + self.auto_populate_all_steps() + self.log_message("✓ 预检完成:已扫描工作目录并自动回填已落盘的产物", "info") + + # ── 1.5) step3 波段越界预检(60s 倒计时弹窗,主线程同步,避开多线程弹窗坑) ── + if not self._precheck_step3_bands(): + return # 用户点"取消运行" + + # ── 2) 刷新配置(拿到自动填充后的"满血版" config) ── config = self.get_current_config() - - # 基本验证 - if not config['step1'].get('mask_path'): - QMessageBox.warning(self, "警告", "请先配置步骤1的掩膜文件!") - # 找到第一个可选的步骤项 + + # ── 3) 根基数据校验:step1.img_path(参考影像) ── + if not config['step1'].get('img_path'): + QMessageBox.warning(self, "警告", "缺失核心数据:请先在步骤 1 中上传【参考影像】!") for i in range(self.step_list.count()): item = self.step_list.item(i) if item.data(Qt.UserRole) == 'step1': self.step_list.setCurrentRow(i) break return - + + # ── 4) 软提示:csv_path 缺失 → 模型训练步骤会被静默跳过(不阻断) ── + csv_path = config.get('step4', {}).get('csv_path') or config.get('step5', {}).get('csv_path') + if not csv_path: + QMessageBox.information( + self, + "提示:模型训练将被跳过", + "未检测到实测水质数据 (CSV)。\n" + "流程将自动跳过模型训练(步骤 4-6),仅执行预测与制图。\n" + "如果需要训练新模型,请先在步骤 4 中上传水质数据。", + ) + # 确认执行 reply = QMessageBox.question( self, "确认",