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, "确认",