feat(gui): 一键运行智能预检

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 倒计时)
This commit is contained in:
DXC
2026-06-04 10:38:46 +08:00
parent 2139715829
commit 4efe5b871e
2 changed files with 281 additions and 9 deletions

147
src/gui/dialogs.py Normal file
View File

@ -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"影像仅有 <b>{self._max_band}</b> 个波段,"
f"无法读取第 <b>{self._requested_band}</b> 波段({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()

View File

@ -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
@ -2744,6 +2745,109 @@ class WaterQualityGUI(QMainWindow):
"邮箱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
是否越界。若越界,弹 BandConfirmDialog60s 倒计时)让用户调整或取消。
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) 读 RasterCountgdal 头信息读取,毫秒级不卡 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:
@ -2753,13 +2857,23 @@ class WaterQualityGUI(QMainWindow):
)
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':
@ -2767,6 +2881,17 @@ class WaterQualityGUI(QMainWindow):
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, "确认",