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:
147
src/gui/dialogs.py
Normal file
147
src/gui/dialogs.py
Normal 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()
|
||||||
@ -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_5_panel import Step8_5Panel
|
||||||
from src.gui.panels.step8_75_panel import Step8_75Panel
|
from src.gui.panels.step8_75_panel import Step8_75Panel
|
||||||
from src.gui.panels.step9_panel import Step9Panel
|
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.visualization_panel import VisualizationPanel
|
||||||
from src.gui.panels.report_generation_panel import ReportGenerationPanel
|
from src.gui.panels.report_generation_panel import ReportGenerationPanel
|
||||||
|
|
||||||
@ -2744,6 +2745,109 @@ class WaterQualityGUI(QMainWindow):
|
|||||||
"邮箱:hanshanlong@iris-rs.cn\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):
|
def run_full_pipeline(self):
|
||||||
"""运行完整流程"""
|
"""运行完整流程"""
|
||||||
if not PIPELINE_AVAILABLE:
|
if not PIPELINE_AVAILABLE:
|
||||||
@ -2753,13 +2857,23 @@ class WaterQualityGUI(QMainWindow):
|
|||||||
)
|
)
|
||||||
return
|
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()
|
config = self.get_current_config()
|
||||||
|
|
||||||
# 基本验证
|
# ── 3) 根基数据校验:step1.img_path(参考影像) ──
|
||||||
if not config['step1'].get('mask_path'):
|
if not config['step1'].get('img_path'):
|
||||||
QMessageBox.warning(self, "警告", "请先配置步骤1的掩膜文件!")
|
QMessageBox.warning(self, "警告", "缺失核心数据:请先在步骤 1 中上传【参考影像】!")
|
||||||
# 找到第一个可选的步骤项
|
|
||||||
for i in range(self.step_list.count()):
|
for i in range(self.step_list.count()):
|
||||||
item = self.step_list.item(i)
|
item = self.step_list.item(i)
|
||||||
if item.data(Qt.UserRole) == 'step1':
|
if item.data(Qt.UserRole) == 'step1':
|
||||||
@ -2767,6 +2881,17 @@ class WaterQualityGUI(QMainWindow):
|
|||||||
break
|
break
|
||||||
return
|
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(
|
reply = QMessageBox.question(
|
||||||
self, "确认",
|
self, "确认",
|
||||||
|
|||||||
Reference in New Issue
Block a user