Files
WQ_GUI/src/gui/water_quality_gui.py

3019 lines
124 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
水质参数反演分析系统 - 图形用户界面
GUI for Water Quality Inversion Pipeline
"""
import os
import json
import copy
import sys
import traceback
from pathlib import Path
from datetime import datetime
from typing import Dict, Optional, List, Union
import numpy as np
import pandas as pd
import multiprocessing
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QLineEdit, QComboBox, QCheckBox, QSpinBox,
QDoubleSpinBox, QFileDialog, QTextEdit, QProgressBar, QMessageBox,
QScrollArea, QGroupBox, QTabWidget, QSplitter, QListWidget,
QListWidgetItem, QFrame, QGridLayout, QFormLayout, QSizePolicy, QDialog,
QStackedWidget, QTableView, QHeaderView, QAbstractItemView,
QRadioButton, QButtonGroup, QToolBar, QTreeWidget, QTreeWidgetItem,
QInputDialog,
)
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer, QAbstractTableModel, QSize
from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPalette, QColor, QPixmap
import sys
import traceback
def get_resource_path(relative_path: str) -> str:
"""获取资源的绝对路径,适配 PyInstaller 打包环境。
打包后资源位于 sys._MEIPASS解压临时目录开发环境则基于 __file__ 向上三级。
"""
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, relative_path)
return os.path.abspath(
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), relative_path)
)
def global_exception_handler(exc_type, exc_value, exc_traceback):
print("\n" + "="*50)
print("【严重错误拦截 - PyQt 崩溃死因】")
traceback.print_exception(exc_type, exc_value, exc_traceback)
print("="*50 + "\n")
# 挂载全局异常钩子,阻止 PyQt 静默闪退
sys.excepthook = global_exception_handler
# 导入样式模块 - 兼容开发环境和 PyInstaller 打包
try:
from styles import ModernStylesheet
except ImportError:
try:
from src.gui.styles import ModernStylesheet
except ImportError:
import sys
import os
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.abspath(os.path.join(current_dir, '..', '..'))
if project_root not in sys.path:
sys.path.insert(0, project_root)
from src.gui.styles import ModernStylesheet
# 导入自定义组件
from src.gui.components.custom_widgets import FileSelectWidget
# 导入面板组件
from src.gui.panels.step1_panel import Step1Panel
from src.gui.panels.step2_panel import Step2Panel
from src.gui.panels.step3_panel import Step3Panel
from src.gui.panels.step4_panel import Step4Panel
from src.gui.panels.step5_panel import Step5Panel
from src.gui.panels.step5_5_panel import Step5_5Panel
from src.gui.panels.step6_panel import Step6Panel
from src.gui.panels.step6_5_panel import Step6_5Panel
from src.gui.panels.step6_75_panel import Step6_75Panel
from src.gui.panels.step7_panel import Step7Panel
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.panels.visualization_panel import VisualizationPanel
from src.gui.panels.report_generation_panel import ReportGenerationPanel
# Matplotlib相关导入
import matplotlib
matplotlib.use('Qt5Agg')
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
import matplotlib.pyplot as plt
# 后台线程与 Pipeline 状态(由 core/worker_thread.py 提供)
from src.gui.core.worker_thread import (
WorkerThread,
PIPELINE_AVAILABLE,
PIPELINE_ERROR_INFO,
check_pipeline_dependencies,
diagnose_pipeline_import_error,
)
def _viz_training_spectra_csv_path(work_path: Path) -> Path:
"""可视化光谱/统计及模型散点图使用的训练光谱表路径与步骤5输出一致"""
return work_path / "5_training_spectra" / "training_spectra.csv"
class VisualizationWorkerThread(QThread):
"""可视化耗时计算放入后台线程,并临时使用 Agg 后端,避免主界面未响应。"""
finished_ok = pyqtSignal(object)
failed = pyqtSignal(str)
def __init__(self, task: str, work_dir: str, extra: Optional[dict] = None):
super().__init__()
self.task = task
self.work_dir = str(work_dir)
self.extra = extra or {}
def run(self):
mpl_prev = None
try:
import matplotlib
mpl_prev = matplotlib.get_backend()
except Exception:
pass
try:
import matplotlib.pyplot as plt
plt.switch_backend("Agg")
except Exception:
mpl_prev = None
try:
wp = Path(self.work_dir)
if self.task == "mask_glint":
from src.postprocessing.visualization_reports import WaterQualityVisualization
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
preview_paths = viz.generate_glint_deglint_previews(
work_dir=str(wp),
output_subdir="glint_deglint_previews",
)
cnt = len(preview_paths) if preview_paths else 0
self.finished_ok.emit({"task": "mask_glint", "count": cnt, "preview_paths": preview_paths})
elif self.task == "sampling_map":
hyperspectral_files = []
deglint_dir = wp / "3_deglint"
if deglint_dir.exists():
for ext in ("*.dat", "*.bsq", "*.tif", "*.tiff"):
hyperspectral_files.extend(list(deglint_dir.glob(ext)))
if not hyperspectral_files:
for ext in ("*.dat", "*.bsq", "*.tif", "*.tiff"):
hyperspectral_files.extend(list(wp.glob(f"**/{ext}")))
if not hyperspectral_files:
self.failed.emit("未找到高光谱影像文件(.dat/.bsq/.tif")
return
hyperspectral_path = str(hyperspectral_files[0])
csv_files = []
processed_dir = wp / "4_processed_data"
if processed_dir.exists():
csv_files = list(processed_dir.glob("*.csv"))
if not csv_files:
csv_files = (
list(wp.glob("**/*sampling*.csv"))
+ list(wp.glob("**/*point*.csv"))
+ list(wp.glob("**/*.csv"))
)
if not csv_files:
self.failed.emit("未找到采样点 CSV 文件。")
return
csv_path = str(csv_files[0])
from src.postprocessing.point_map import SamplingPointMap
map_generator = SamplingPointMap(
output_dir=str(wp / "14_visualization" / "sampling_maps"),
fast_mode=True,
)
map_path = map_generator.create_sampling_point_map(
hyperspectral_path=hyperspectral_path,
csv_path=csv_path,
point_color="red",
point_size=100,
point_alpha=0.9,
show_north_arrow=True,
show_scale_bar=True,
show_legend=True,
downsample=True,
dpi=180,
)
self.finished_ok.emit(
{
"task": "sampling_map",
"map_path": map_path,
"hyperspectral_path": hyperspectral_path,
"csv_path": csv_path,
}
)
elif self.task == "spectrum":
from src.postprocessing.visualization_reports import WaterQualityVisualization
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
csv_file = self.extra.get("csv_path")
wl = self.extra.get("wavelength_start_column", "UTM_Y")
n_groups = int(self.extra.get("n_groups", 5))
param_cols = self.extra.get("param_cols") or []
if param_cols:
output_paths: List[str] = []
err_lines: List[str] = []
for param_col in param_cols:
try:
out = viz.plot_spectrum_by_parameter(
csv_path=str(csv_file),
parameter_column=param_col,
wavelength_start_column=wl,
n_groups=n_groups,
)
output_paths.append(out)
except Exception as _ex:
err_lines.append(f"{param_col}: {_ex}")
if not output_paths:
self.failed.emit(
"所有参数列的光谱图均生成失败:\n" + "\n".join(err_lines[:20])
)
return
self.finished_ok.emit(
{
"task": "spectrum",
"output_paths": output_paths,
"errors": err_lines,
}
)
else:
param_col = self.extra.get("param_col")
out = viz.plot_spectrum_by_parameter(
csv_path=str(csv_file),
parameter_column=param_col,
wavelength_start_column=wl,
n_groups=n_groups,
)
self.finished_ok.emit(
{"task": "spectrum", "output_path": out, "param_col": param_col}
)
elif self.task == "statistics":
from src.postprocessing.visualization_reports import WaterQualityVisualization
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
csv_file = self.extra.get("csv_path")
param_cols = self.extra.get("param_cols") or []
output_paths = viz.plot_statistical_charts(
csv_path=str(csv_file),
parameter_columns=param_cols,
)
self.finished_ok.emit(
{"task": "statistics", "output_paths": output_paths}
)
elif self.task == "scatter":
from src.core.water_quality_inversion_pipeline_GUI import WaterQualityInversionPipeline
training_csv_path = (self.extra.get("training_csv_path") or "").strip()
models_dir = (self.extra.get("models_dir") or "").strip()
if not training_csv_path or not Path(training_csv_path).is_file():
self.failed.emit("训练光谱 CSV 无效或不存在请确认已选择步骤5输出的文件。")
return
if not models_dir or not Path(models_dir).is_dir():
self.failed.emit("模型目录无效或不存在请确认步骤6已生成 7_Supervised_Model_Training 下的参数子文件夹。")
return
pipeline = WaterQualityInversionPipeline(work_dir=str(wp))
scatter_paths = pipeline.generate_model_scatter_plots(
training_csv_path=training_csv_path,
models_dir=models_dir,
)
self.finished_ok.emit({"task": "scatter", "scatter_paths": scatter_paths or {}})
elif self.task == "generate_all_selected":
from src.postprocessing.visualization_reports import WaterQualityVisualization
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
parts = []
# 获取训练数据CSV路径多个图表类型共用
training_csv = wp / "5_training_spectra" / "training_spectra.csv"
# 生成散点图
if self.extra.get("gen_scatter"):
if training_csv.is_file():
models_dir = wp / "7_Supervised_Model_Training"
if models_dir.is_dir() and any(d.is_dir() for d in models_dir.iterdir()):
from src.core.water_quality_inversion_pipeline_GUI import WaterQualityInversionPipeline
pipeline = WaterQualityInversionPipeline(work_dir=str(wp))
scatter_paths = pipeline.generate_model_scatter_plots(
training_csv_path=str(training_csv),
models_dir=str(models_dir),
)
count = len(scatter_paths) if scatter_paths else 0
parts.append(f"散点图: {count}")
else:
parts.append("散点图: 跳过(无模型目录)")
else:
parts.append("散点图: 跳过(无训练数据)")
# 生成光谱图
if self.extra.get("gen_spectrum"):
if training_csv.is_file():
import pandas as pd
df = pd.read_csv(training_csv)
# 推断水质参数列(光谱波段列之前的数值型列)
wl_col = _viz_infer_wavelength_start_column(df)
if isinstance(wl_col, str):
idx = int(df.columns.get_loc(wl_col)) + 1
else:
idx = int(wl_col)
param_cols = []
if idx > 0 and idx < len(df.columns):
param_cols = [
c for c in df.columns[:idx]
if df[c].dtype.kind in 'iuf' and df[c].notna().sum() > 0
]
if param_cols:
# plot_spectrum_by_parameter 接受单个参数列,逐个调用
spectrum_paths = []
for param_col in param_cols:
try:
path = viz.plot_spectrum_by_parameter(
csv_path=str(training_csv),
parameter_column=param_col,
wavelength_start_column=wl_col,
n_groups=5,
)
if path:
spectrum_paths.append(path)
except Exception as e:
print(f"生成光谱图失败 ({param_col}): {e}")
count = len(spectrum_paths)
parts.append(f"光谱图: {count}")
else:
parts.append("光谱图: 跳过(无可用参数列)")
else:
parts.append("光谱图: 跳过(无训练数据)")
# 生成统计图
if self.extra.get("gen_boxplots"):
if training_csv.is_file():
import pandas as pd
df = pd.read_csv(training_csv)
# **只统计水质参数列(数值型),排除波长列**
# 获取水质参数列(数值型且不是波长、不是坐标列)
exclude_cols = ['longitude', 'latitude', 'lon', 'lat', 'x', 'y', 'coord', 'coordinate']
param_cols = [
c for c in df.select_dtypes(include=[np.number]).columns
if not any(exc in c.lower() for exc in exclude_cols)
]
# 排除光谱波长列:找到波长开始位置,只取之前的数值列
wl = _viz_infer_wavelength_start_column(df)
if isinstance(wl, str):
idx = int(df.columns.get_loc(wl)) + 1
else:
idx = int(wl)
if 0 < idx < len(df.columns):
meta_set = set(df.columns[:idx])
param_cols = [c for c in param_cols if c in meta_set]
if param_cols:
output_dict = viz.plot_statistical_charts(
csv_path=str(training_csv),
parameter_columns=param_cols,
)
# plot_statistical_charts 返回字典,统计值非空
count = len([v for v in output_dict.values() if v]) if output_dict else 0
parts.append(f"统计图: {count}")
else:
parts.append("统计图: 跳过(无可用水质参数列)")
else:
parts.append("统计图: 跳过(无训练数据)")
# 生成掩膜/耀斑预览图
if self.extra.get("gen_mask_glint"):
preview_paths = viz.generate_glint_deglint_previews(
work_dir=str(wp),
output_subdir="glint_deglint_previews",
)
parts.append(f"掩膜/耀斑预览: {len(preview_paths) if preview_paths else 0}")
# 生成采样点地图
if self.extra.get("gen_sampling_map"):
hyperspectral_files = []
deglint_dir = wp / "3_deglint"
if deglint_dir.exists():
for ext in ("*.dat", "*.bsq", "*.tif", "*.tiff"):
hyperspectral_files.extend(list(deglint_dir.glob(ext)))
if not hyperspectral_files:
for ext in ("*.dat", "*.bsq", "*.tif", "*.tiff"):
hyperspectral_files.extend(list(wp.glob(f"**/{ext}")))
if hyperspectral_files:
hyperspectral_path = str(hyperspectral_files[0])
csv_files = []
processed_dir = wp / "4_processed_data"
if processed_dir.exists():
csv_files = list(processed_dir.glob("*.csv"))
if not csv_files:
csv_files = (
list(wp.glob("**/*sampling*.csv"))
+ list(wp.glob("**/*point*.csv"))
+ list(wp.glob("**/*.csv"))
)
if csv_files:
csv_path = str(csv_files[0])
from src.postprocessing.point_map import SamplingPointMap
map_generator = SamplingPointMap(
output_dir=str(wp / "14_visualization" / "sampling_maps"),
fast_mode=True,
)
map_path = map_generator.create_sampling_point_map(
hyperspectral_path=hyperspectral_path,
csv_path=csv_path,
point_color="red",
point_size=100,
point_alpha=0.9,
show_north_arrow=True,
show_scale_bar=True,
show_legend=True,
downsample=True,
dpi=180,
)
parts.append(f"采样点图: {Path(map_path).name}")
else:
parts.append("采样点图: 跳过(无CSV)")
else:
parts.append("采样点图: 跳过(无影像)")
self.finished_ok.emit({"task": "generate_all_selected", "parts": parts})
else:
self.failed.emit(f"未知可视化任务: {self.task}")
except Exception as e:
self.failed.emit(f"{e}\n{traceback.format_exc()}")
finally:
if mpl_prev:
try:
import matplotlib.pyplot as plt
plt.switch_backend(mpl_prev)
except Exception:
pass
class PandasTableModel(QAbstractTableModel):
"""支持DataFrame的表格模型"""
def __init__(self, data_frame: pd.DataFrame):
super().__init__()
self._data = data_frame.copy()
if self._data.empty:
self._data = pd.DataFrame()
self._data.fillna("", inplace=True)
self._columns = [str(col) for col in self._data.columns]
def rowCount(self, parent=None):
return len(self._data)
def columnCount(self, parent=None):
return len(self._columns)
def data(self, index, role=Qt.DisplayRole):
if not index.isValid() or role != Qt.DisplayRole:
return None
value = self._data.iat[index.row(), index.column()]
if pd.isna(value):
return ""
return str(value)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role != Qt.DisplayRole:
return None
if orientation == Qt.Horizontal:
if section < len(self._columns):
return self._columns[section]
return str(section)
return str(section + 1)
def flags(self, index):
if not index.isValid():
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
class ChartViewerDialog(QDialog):
"""图表查看器对话框"""
def __init__(self, title="图表查看器", parent=None):
super().__init__(parent)
self.setWindowTitle(title)
self.resize(1000, 700)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
# 创建matplotlib图形
self.figure = Figure(figsize=(10, 7))
self.canvas = FigureCanvas(self.figure)
self.canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# 添加工具栏
self.toolbar = NavigationToolbar(self.canvas, self)
layout.addWidget(self.toolbar)
layout.addWidget(self.canvas)
# 按钮组
btn_layout = QHBoxLayout()
self.save_btn = QPushButton("保存图表")
self.save_btn.clicked.connect(self.save_chart)
btn_layout.addWidget(self.save_btn)
btn_layout.addStretch()
self.close_btn = QPushButton("关闭")
self.close_btn.clicked.connect(self.close)
btn_layout.addWidget(self.close_btn)
layout.addLayout(btn_layout)
self.setLayout(layout)
# 信号连接:影像文件路径变化时动态更新波段范围
def display_image(self, image_path):
"""显示图片"""
self.figure.clear()
ax = self.figure.add_subplot(111)
try:
import matplotlib.image as mpimg
img = mpimg.imread(image_path)
ax.imshow(img)
ax.axis('off')
self.figure.tight_layout()
self.canvas.draw()
self.current_image_path = image_path
except Exception as e:
ax.text(0.5, 0.5, f'加载图片失败:\n{str(e)}',
ha='center', va='center', transform=ax.transAxes)
self.canvas.draw()
def display_custom_plot(self, plot_func):
"""显示自定义绘图函数"""
self.figure.clear()
try:
plot_func(self.figure)
self.canvas.draw()
except Exception as e:
ax = self.figure.add_subplot(111)
ax.text(0.5, 0.5, f'绘图失败:\n{str(e)}',
ha='center', va='center', transform=ax.transAxes)
self.canvas.draw()
def save_chart(self):
"""保存图表"""
file_path, _ = QFileDialog.getSaveFileName(
self, "保存图表", "",
"PNG图片 (*.png);;JPG图片 (*.jpg);;PDF文件 (*.pdf);;所有文件 (*.*)"
)
if file_path:
try:
self.figure.savefig(file_path, dpi=300, bbox_inches='tight')
QMessageBox.information(self, "成功", f"图表已保存到:\n{file_path}")
except Exception as e:
QMessageBox.critical(self, "错误", f"保存失败:\n{str(e)}")
class ImageCategoryTree(QTreeWidget):
"""图像分类目录树 - 按类别组织图像文件"""
# 图像类别定义:(类别名称, 关键词列表, 图标)
CATEGORIES = [
("模型评估", ["scatter", "regression", "validation", "r2", "rmse"], "📊"),
("光谱分析", ["spectrum", "spectral", "band", "wavelength"], "📈"),
("统计图表", ["boxplot", "histogram", "heatmap", "statistics", "stats"], "📉"),
("处理结果", ["mask", "glint", "deglint", "preview", "overlay", "water_mask"], "🖼️"),
("含量分布图", [], "📁"),
]
def __init__(self, parent=None):
super().__init__(parent)
self.setHeaderLabel("图像目录")
self.setMaximumWidth(300)
self.setMinimumWidth(250)
self.setup_categories()
self.setStyleSheet("""
QTreeWidget {
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f8f9fa;
}
QTreeWidget::item {
padding: 5px;
border-radius: 3px;
}
QTreeWidget::item:selected {
background-color: #0078D4;
color: white;
}
QTreeWidget::item:hover {
background-color: #e3f2fd;
}
""")
def setup_categories(self):
"""初始化类别节点"""
self.category_items = {}
for category_name, keywords, icon in self.CATEGORIES:
item = QTreeWidgetItem(self)
item.setText(0, f"{icon} {category_name}")
item.setData(0, Qt.UserRole, {"type": "category", "keywords": keywords, "name": category_name})
item.setExpanded(True)
self.category_items[category_name] = item
def clear_all_images(self):
"""清除所有图像项"""
for category_item in self.category_items.values():
# 删除所有子项
while category_item.childCount() > 0:
category_item.removeChild(category_item.child(0))
def add_image(self, file_path: Path, display_name: str = None):
"""添加图像到对应的类别"""
if display_name is None:
display_name = file_path.stem
# 根据文件名关键词确定类别
category = self._determine_category(file_path.name)
category_item = self.category_items.get(category, self.category_items["含量分布图"])
# 创建图像项
image_item = QTreeWidgetItem(category_item)
image_item.setText(0, f" └─ {display_name}")
image_item.setData(0, Qt.UserRole, {"type": "image", "path": str(file_path)})
image_item.setToolTip(0, str(file_path))
return image_item
def _determine_category(self, filename: str) -> str:
"""根据文件名确定类别"""
filename_lower = filename.lower()
for category_name, keywords, _ in self.CATEGORIES:
if any(keyword in filename_lower for keyword in keywords):
return category_name
return "含量分布图"
def scan_directory(self, work_dir: str):
"""扫描目录中的所有图像文件"""
self.clear_all_images()
work_path = Path(work_dir)
if not work_path.exists():
return
# 查找所有图像文件14_visualization 为主,同时扫描步骤产出目录(如 1_water_mask 下的预览/叠置图)
image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp']
scan_roots: List[Path] = []
_viz = work_path / "14_visualization"
if _viz.is_dir():
scan_roots.append(_viz)
_wm = work_path / "1_water_mask"
if _wm.is_dir():
scan_roots.append(_wm)
if not scan_roots:
scan_roots.append(work_path)
seen_norm: set = set()
image_files: List[Path] = []
for root in scan_roots:
for ext in image_extensions:
for p in root.glob(f"**/{ext}"):
key = os.path.normcase(os.path.normpath(str(p.resolve())))
if key in seen_norm:
continue
seen_norm.add(key)
image_files.append(p)
# 添加图像到树
for img_file in sorted(image_files):
# 跳过缩略图和临时文件
if img_file.name.startswith('.') or 'thumb' in img_file.name.lower():
continue
self.add_image(img_file)
# 更新类别项文本显示数量
for category_name, item in self.category_items.items():
count = item.childCount()
if count > 0:
for cat_name, _, icon in self.CATEGORIES:
if cat_name == category_name:
item.setText(0, f"{icon} {category_name} ({count})")
break
def get_selected_image_path(self) -> Optional[str]:
"""获取当前选中的图像路径"""
selected_item = self.currentItem()
if not selected_item:
return None
data = selected_item.data(0, Qt.UserRole)
if data and data.get("type") == "image":
return data.get("path")
return None
class ImageViewerWidget(QWidget):
"""图像查看器组件 - 支持缩放、平移"""
def __init__(self, parent=None):
super().__init__(parent)
self.current_image_path = None
self.scale_factor = 1.0
self._update_timer = QTimer() # 防抖定时器
self._update_timer.setSingleShot(True)
self._update_timer.timeout.connect(self._do_update_display)
self._pending_scale = None # 待更新的缩放比例
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
# 工具栏
toolbar = QHBoxLayout()
self.refresh_btn = QPushButton("🔄 刷新目录")
self.refresh_btn.setToolTip("重新扫描工作目录中的图像文件")
toolbar.addWidget(self.refresh_btn)
# 添加分隔线
separator = QFrame()
separator.setFrameShape(QFrame.VLine)
separator.setFrameShadow(QFrame.Sunken)
toolbar.addWidget(separator)
self.zoom_in_btn = QPushButton("🔍+")
self.zoom_in_btn.setToolTip("放大")
self.zoom_in_btn.setMaximumWidth(50)
toolbar.addWidget(self.zoom_in_btn)
self.zoom_out_btn = QPushButton("🔍-")
self.zoom_out_btn.setToolTip("缩小")
self.zoom_out_btn.setMaximumWidth(50)
toolbar.addWidget(self.zoom_out_btn)
self.fit_btn = QPushButton("⬜ 适应窗口")
self.fit_btn.setToolTip("适应窗口大小")
toolbar.addWidget(self.fit_btn)
self.original_btn = QPushButton("1:1 原始大小")
self.original_btn.setToolTip("原始大小")
toolbar.addWidget(self.original_btn)
toolbar.addStretch()
self.save_btn = QPushButton("💾 保存")
self.save_btn.setToolTip("保存当前图像")
toolbar.addWidget(self.save_btn)
layout.addLayout(toolbar)
# 图像显示区域 - 使用 QLabel + QScrollArea
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setStyleSheet("background-color: white;")
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setStyleSheet("background-color: white;")
self.scroll_area.setWidget(self.image_label)
layout.addWidget(self.scroll_area, 1)
# 状态栏
status_layout = QHBoxLayout()
self.status_label = QLabel("就绪")
self.status_label.setStyleSheet("color: #666; font-size: 11px;")
status_layout.addWidget(self.status_label)
status_layout.addStretch()
layout.addLayout(status_layout)
self.setLayout(layout)
# 连接信号
self.zoom_in_btn.clicked.connect(self.zoom_in)
self.zoom_out_btn.clicked.connect(self.zoom_out)
self.fit_btn.clicked.connect(self.fit_to_window)
self.original_btn.clicked.connect(self.original_size)
self.save_btn.clicked.connect(self.save_image)
def load_image(self, image_path: str):
"""加载并显示图像"""
if not image_path or not Path(image_path).exists():
self.image_label.setText("图像不存在")
self.status_label.setText("图像加载失败")
return
self.current_image_path = image_path
self.scale_factor = 1.0
# 加载图像
pixmap = QPixmap(image_path)
if pixmap.isNull():
self.image_label.setText("无法加载图像")
self.status_label.setText("图像格式不支持")
return
self.original_pixmap = pixmap
# 默认适应窗口显示
self.fit_to_window()
# 更新状态
file_info = Path(image_path).stat()
size_mb = file_info.st_size / (1024 * 1024)
self.status_label.setText(f"{pixmap.width()}x{pixmap.height()} | {size_mb:.2f} MB | {Path(image_path).name} | 适应窗口")
def update_image_display(self):
"""更新图像显示 - 使用防抖避免频繁重绘卡顿"""
# 取消之前的待执行更新,重新计时
self._update_timer.stop()
self._pending_scale = self.scale_factor
self._update_timer.start(50) # 50ms后执行实际更新
def _do_update_display(self):
"""实际执行图像更新"""
if not hasattr(self, 'original_pixmap') or self.original_pixmap.isNull():
return
if self._pending_scale is None:
return
# 根据缩放比例选择变换模式大幅度缩放用Fast模式提升性能
if self._pending_scale > 2.0 or self._pending_scale < 0.5:
transform = Qt.FastTransformation
else:
transform = Qt.SmoothTransformation
scaled_pixmap = self.original_pixmap.scaled(
int(self.original_pixmap.width() * self._pending_scale),
int(self.original_pixmap.height() * self._pending_scale),
Qt.KeepAspectRatio,
transform
)
self.image_label.setPixmap(scaled_pixmap)
self._pending_scale = None
def wheelEvent(self, event):
"""鼠标滚轮缩放 - 实时响应"""
delta = event.angleDelta().y()
if delta > 0:
# 向上滚动 - 放大
if self.scale_factor < 5.0:
self.scale_factor = min(self.scale_factor * 1.1, 5.0)
self.update_image_display()
else:
# 向下滚动 - 缩小
if self.scale_factor > 0.1:
self.scale_factor = max(self.scale_factor / 1.1, 0.1)
self.update_image_display()
event.accept()
def zoom_in(self):
"""放大"""
if self.scale_factor < 5.0:
self.scale_factor = min(self.scale_factor * 1.25, 5.0)
self.update_image_display()
def zoom_out(self):
"""缩小"""
if self.scale_factor > 0.1:
self.scale_factor = max(self.scale_factor / 1.25, 0.1)
self.update_image_display()
def fit_to_window(self):
"""适应窗口"""
if not hasattr(self, 'original_pixmap') or self.original_pixmap.isNull():
return
# 计算适应窗口的缩放比例
view_size = self.scroll_area.viewport().size()
img_size = self.original_pixmap.size()
scale_w = view_size.width() / img_size.width()
scale_h = view_size.height() / img_size.height()
# 记录适应前的比例(用于后续恢复参考)
self._fit_scale = min(scale_w, scale_h)
self.scale_factor = self._fit_scale
self.update_image_display()
self.status_label.setText(f"适应窗口 | 缩放: {self.scale_factor:.1%}")
def original_size(self):
"""原始大小"""
self.scale_factor = 1.0
self._fit_scale = None # 清除适应记录
self.update_image_display()
self.status_label.setText("原始大小 | 缩放: 100%")
def save_image(self):
"""保存图像"""
if not self.current_image_path:
return
file_path, _ = QFileDialog.getSaveFileName(
self, "保存图像", Path(self.current_image_path).name,
"PNG图片 (*.png);;JPG图片 (*.jpg);;所有文件 (*.*)"
)
if file_path:
try:
import shutil
shutil.copy(self.current_image_path, file_path)
except Exception as e:
QMessageBox.critical(self, "错误", f"保存失败: {e}")
class ChartBrowserDialog(QDialog):
"""图表浏览器对话框"""
def __init__(self, chart_files, parent=None):
super().__init__(parent)
self.chart_files = sorted(chart_files, key=lambda x: x.stat().st_mtime, reverse=True)
self.current_index = 0
self.setWindowTitle("图表浏览器")
self.resize(1200, 800)
self.init_ui()
self.show_chart(0)
def init_ui(self):
layout = QVBoxLayout()
# 顶部:图表列表
list_group = QGroupBox(f"图表列表 (共 {len(self.chart_files)} 个)")
list_layout = QHBoxLayout()
self.chart_list = QListWidget()
self.chart_list.setMaximumHeight(150)
for chart_file in self.chart_files:
self.chart_list.addItem(chart_file.name)
self.chart_list.currentRowChanged.connect(self.show_chart)
list_layout.addWidget(self.chart_list)
list_group.setLayout(list_layout)
layout.addWidget(list_group)
# 中间:图表显示
self.figure = Figure(figsize=(12, 8))
self.canvas = FigureCanvas(self.figure)
self.canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar = NavigationToolbar(self.canvas, self)
layout.addWidget(self.toolbar)
layout.addWidget(self.canvas, 1)
# 底部:控制按钮
btn_layout = QHBoxLayout()
self.prev_btn = QPushButton("◀ 上一个")
self.prev_btn.clicked.connect(self.prev_chart)
btn_layout.addWidget(self.prev_btn)
self.next_btn = QPushButton("下一个 >")
self.next_btn.clicked.connect(self.next_chart)
btn_layout.addWidget(self.next_btn)
btn_layout.addStretch()
self.save_btn = QPushButton("💾 保存当前图表")
self.save_btn.clicked.connect(self.save_current_chart)
btn_layout.addWidget(self.save_btn)
self.close_btn = QPushButton("关闭")
self.close_btn.clicked.connect(self.close)
btn_layout.addWidget(self.close_btn)
layout.addLayout(btn_layout)
self.setLayout(layout)
# 信号连接:影像文件路径变化时动态更新波段范围
def show_chart(self, index):
"""显示指定索引的图表"""
if 0 <= index < len(self.chart_files):
self.current_index = index
self.chart_list.setCurrentRow(index)
chart_file = self.chart_files[index]
self.figure.clear()
ax = self.figure.add_subplot(111)
try:
import matplotlib.image as mpimg
img = mpimg.imread(str(chart_file))
ax.imshow(img)
ax.axis('off')
ax.set_title(chart_file.name, fontsize=12, pad=10)
self.figure.tight_layout()
self.canvas.draw()
except Exception as e:
ax.text(0.5, 0.5, f'加载图片失败:\n{str(e)}',
ha='center', va='center', transform=ax.transAxes)
self.canvas.draw()
# 更新按钮状态
self.prev_btn.setEnabled(index > 0)
self.next_btn.setEnabled(index < len(self.chart_files) - 1)
def prev_chart(self):
"""上一个图表"""
if self.current_index > 0:
self.show_chart(self.current_index - 1)
def next_chart(self):
"""下一个图表"""
if self.current_index < len(self.chart_files) - 1:
self.show_chart(self.current_index + 1)
def save_current_chart(self):
"""保存当前图表"""
if 0 <= self.current_index < len(self.chart_files):
current_file = self.chart_files[self.current_index]
file_path, _ = QFileDialog.getSaveFileName(
self, "保存图表", current_file.name,
"PNG图片 (*.png);;JPG图片 (*.jpg);;所有文件 (*.*)"
)
if file_path:
try:
import shutil
shutil.copy(str(current_file), file_path)
QMessageBox.information(self, "成功", f"图表已保存到:\n{file_path}")
except Exception as e:
QMessageBox.critical(self, "错误", f"保存失败:\n{str(e)}")
class InteractiveViewerDialog(QDialog):
"""交互式影像预览对话框:显示影像、参考点散点图、点击查询坐标/值"""
def __init__(self, parent, img_path, ref_csv=None):
super().__init__(parent)
self.img_path = img_path
self.ref_csv = ref_csv
self.geotransform = None
self.fig = None
self.canvas = None
self.ax = None
self.status_label = None
self.init_ui()
def init_ui(self):
self.setWindowTitle("👁️ 交互式影像预览")
self.setMinimumSize(900, 700)
layout = QVBoxLayout()
# 工具栏
toolbar = QHBoxLayout()
self.band_combo = QComboBox()
self.band_combo.currentIndexChanged.connect(self.on_band_changed)
toolbar.addWidget(QLabel("显示波段:"))
toolbar.addWidget(self.band_combo)
self.gray_check = QCheckBox("灰度显示")
self.gray_check.stateChanged.connect(self.on_band_changed)
toolbar.addWidget(self.gray_check)
toolbar.addStretch()
layout.addLayout(toolbar)
# Matplotlib 画布
try:
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import matplotlib
matplotlib.use('Qt5Agg')
self.fig = Figure(figsize=(10, 8))
self.canvas = FigureCanvas(self.fig)
self.ax = self.fig.add_subplot(111)
self.fig.tight_layout()
layout.addWidget(self.canvas)
# 读取影像并初始化显示
self.load_and_display()
except ImportError as e:
layout.addWidget(QLabel(f"Matplotlib 未安装: {e}"))
# 状态栏
self.status_label = QLabel("点击影像查看像素坐标和经纬度")
self.status_label.setStyleSheet("background:#f0f0f0;padding:4px;font-size:12px;")
self.status_label.setWordWrap(True)
layout.addWidget(self.status_label)
# 关闭按钮
close_btn = QPushButton("关闭")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn)
self.setLayout(layout)
def load_and_display(self):
"""加载影像并显示"""
from osgeo import gdal
import numpy as np
dataset = gdal.Open(self.img_path)
if dataset is None:
self.status_label.setText(f"无法打开影像: {self.img_path}")
return
self.geotransform = dataset.GetGeoTransform()
self.projection = dataset.GetProjection()
n_bands = dataset.RasterCount
self.height = dataset.RasterYSize
self.width = dataset.RasterXSize
# 填充波段选择下拉框
self.band_combo.clear()
if n_bands >= 3:
for i in range(1, n_bands + 1):
self.band_combo.addItem(f"RGB (B{i-0}, G{i-1}, R{i-2})" if i >= 3 else f"波段 {i}", i)
self.band_combo.addItem(f"单波段 (B1)", 0)
else:
for i in range(1, n_bands + 1):
self.band_combo.addItem(f"波段 {i}", i - 1)
self.band_combo.setCurrentIndex(0)
self.dataset = dataset
self.display_band(0, is_gray=False)
self.load_ref_points()
def display_band(self, band_idx, is_gray=False):
"""显示指定波段组合"""
from osgeo import gdal
import numpy as np
from matplotlib.pyplot import Normalize
from matplotlib.cm import ScalarMappable
dataset = self.dataset
self.ax.clear()
if is_gray or (self.band_combo.currentData() == 0 and dataset.RasterCount == 1):
# 灰度显示
band = dataset.GetRasterBand(1 if band_idx == 0 else band_idx + 1)
data = band.ReadAsArray()
data = np.nan_to_num(data, nan=0.0)
self.ax.imshow(data, cmap='gray')
self.ax.set_title(f"波段 {band_idx + 1} (灰度)")
else:
# 彩色显示取前3个波段
n = min(3, dataset.RasterCount)
bands_data = []
for i in range(n):
b = dataset.GetRasterBand(i + 1)
bd = b.ReadAsArray()
bd = np.nan_to_num(bd, nan=0.0)
bands_data.append(bd)
rgb = np.dstack(bands_data)
# 归一化到 [0, 1]
for i in range(rgb.shape[2]):
p2, p98 = np.percentile(rgb[:, :, i], [2, 98])
if p98 > p2:
rgb[:, :, i] = np.clip((rgb[:, :, i] - p2) / (p98 - p2), 0, 1)
else:
rgb[:, :, i] = np.clip(rgb[:, :, i] / (p98 + 1e-6), 0, 1)
self.ax.imshow(rgb)
self.ax.set_title(f"RGB 显示")
self.ax.set_xlabel("列 (Column)")
self.ax.set_ylabel("行 (Row)")
self.fig.tight_layout()
self.canvas.draw()
# 绑定点击事件
self.cid = self.canvas.mpl_connect('button_press_event', self.on_click)
def on_band_changed(self):
"""波段选择变化时更新显示"""
if not hasattr(self, 'dataset'):
return
is_gray = self.gray_check.isChecked()
band_data = self.band_combo.currentData()
self.display_band(band_data if band_data != 0 else 0, is_gray=is_gray)
def load_ref_points(self):
"""加载并显示参考点"""
import os
if not self.ref_csv or not os.path.isfile(self.ref_csv):
return
try:
import csv
lon_list, lat_list = [], []
with open(self.ref_csv, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
try:
lon = float(row.get('Lon', row.get('lon', row.get('LON', 0))))
lat = float(row.get('Lat', row.get('lat', row.get('LAT', 0))))
if lon and lat:
lon_list.append(lon)
lat_list.append(lat)
except (ValueError, TypeError):
continue
if not lon_list:
return
# 逆变换:经纬度 -> 像素坐标
px_list, py_list = [], []
gt = self.geotransform
if gt and (gt[1] != 0 or gt[5] != 0):
# GeoTransform: (originX, pixSizeX, rotX, originY, rotY, pixSizeY)
# pixel_x = (lon - gt[0]) / gt[1]
# line_y = (lat - gt[3]) / gt[5]
for lon, lat in zip(lon_list, lat_list):
px = (lon - gt[0]) / gt[1]
py = (lat - gt[3]) / gt[5]
if 0 <= px < self.width and 0 <= py < self.height:
px_list.append(px)
py_list.append(py)
if px_list:
self.ax.scatter(px_list, py_list, c='red', s=40, marker='o',
edgecolors='white', linewidths=0.8, zorder=5, alpha=0.9,
label=f'参考点 ({len(px_list)}个)')
self.ax.legend(loc='upper right', fontsize=9)
self.fig.tight_layout()
self.canvas.draw()
self.status_label.setText(
f"已加载 {len(px_list)} 个参考点(仅显示在影像范围内的点)"
)
except Exception as e:
self.status_label.setText(f"加载参考点失败: {e}")
def pixel_to_geo(self, px, py):
"""像素坐标转经纬度"""
gt = self.geotransform
if gt is None:
return None, None
lon = gt[0] + px * gt[1] + py * gt[2]
lat = gt[3] + px * gt[4] + py * gt[5]
return lon, lat
def on_click(self, event):
"""鼠标点击事件"""
if event.inaxes != self.ax or event.xdata is None or event.ydata is None:
return
px, py = int(round(event.xdata)), int(round(event.ydata))
if not (0 <= px < self.width and 0 <= py < self.height):
return
# 获取该像素在各波段的值
from osgeo import gdal
import numpy as np
dataset = self.dataset
n_bands = dataset.RasterCount
vals = []
for b in range(1, n_bands + 1):
val = dataset.GetRasterBand(b).ReadAsArray()[py, px]
vals.append(f"{val:.4f}" if isinstance(val, float) else str(val))
# 经纬度转换
lon, lat = self.pixel_to_geo(px, py)
geo_str = f"Lon={lon:.6f}, Lat={lat:.6f}" if lon is not None else "无地理参考"
self.status_label.setText(
f"像素: (行={py}, 列={px}) | {geo_str} | "
f"波段值: {' | '.join(vals[:5])}" +
(f" ... ({n_bands}波段的更多信息)" if n_bands > 5 else "")
)
class WaterQualityGUI(QMainWindow):
"""水质参数反演分析系统主窗口"""
def __init__(self):
super().__init__()
self.pipeline = None
self.worker = None
self.config_file = None
self.work_dir = None # 工作目录
# 训练数据模式状态
self.has_training_data = True # 默认有训练数据
# 步骤输出路径记录
self.step_outputs = {} # 记录每个步骤的输出路径
# 定义步骤依赖关系和标准输出路径
self._init_step_dependencies()
self.init_ui()
self.apply_stylesheet()
self._disable_wheel_for_all_spinboxes()
# 延迟调用工作目录选择对话框,确保主界面已完全渲染
# 100ms 延迟足以让 GUI 事件循环启动并显示主窗口
QTimer.singleShot(100, self.init_workspace)
def _init_step_dependencies(self):
"""初始化步骤依赖关系和标准输出路径"""
# 定义每个步骤的标准输出路径模式(相对于工作目录)
self.step_default_outputs = {
'step1': {
'water_mask_ndwi': '1_water_mask/water_mask_from_ndwi.dat',
'water_mask_shp': '1_water_mask/water_mask_from_shp.dat',
'hsi_preview': '1_water_mask/hsi_preview.png',
'water_mask_overlay': '1_water_mask/water_mask_overlay.png'
},
'step2': {
'glint_mask': '2_glint/severe_glint_area.dat'
},
'step3': {
'deglint_kutser': '3_deglint/deglint_kutser.bsq',
'deglint_goodman': '3_deglint/deglint_goodman.bsq',
'deglint_hedley': '3_deglint/deglint_hedley.bsq',
'deglint_sugar': '3_deglint/deglint_sugar.bsq',
'deglint_interpolated': '3_deglint/interpolated_*.bsq' # * = interpolation_method
},
'step4': {
'processed_data': '4_processed_data/processed_data.csv'
},
'step5': {
'training_spectra': '5_training_spectra/training_spectra.csv'
},
'step5_5': {
'water_indices': '6_water_quality_indices/water_quality_indices.csv'
},
'step6': {
'models': '7_Supervised_Model_Training/' # 目录,包含各参数子目录
},
'step6_5': {
'regression_models': '8_Regression_Modeling/' # 目录,包含各参数子目录
},
'step6_75': {
'custom_regression_models': '9_Custom_Regression_Modeling/' # 目录
},
'step7': {
'sampling_points': '10_sampling/sampling_spectra.csv'
},
'step8': {
'predictions': '11_12_13_predictions/Machine_Learning_Prediction/' # 目录,包含机器学习预测结果
},
'step8_5': {
'regression_predictions': '11_12_13_predictions/Non_Empirical_Prediction/' # 目录,包含非经验模型预测结果
},
'step8_75': {
'custom_predictions': '11_12_13_predictions/Custom_Regression_Prediction/' # 目录,包含自定义回归预测结果
},
'step9': {
'distribution_maps': '14_visualization/' # 目录,包含专题图
}
}
# 定义步骤间的依赖关系:{当前步骤: {输入字段: (依赖步骤, 输出类型, 面板属性名)}}
self.step_dependencies = {
'step2': {
'img_path': ('step1', 'reference_img', 'img_file'), # 步骤2需要参考影像
'water_mask_path': ('step1', 'water_mask', 'water_mask_file') # 步骤2可选水域掩膜
},
'step3': {
'img_path': ('step1', 'reference_img', 'img_file'), # 步骤3需要参考影像
'water_mask': ('step1', 'water_mask', 'water_mask_file'), # 步骤3需要水域掩膜
},
'step4': {
# 步骤4主要处理CSV文件一般不依赖前面步骤的输出
},
'step5': {
'deglint_img_path': ('step3', 'deglint_image', 'deglint_img_file'), # 步骤5需要去耀斑影像
'csv_path': ('step4', 'processed_data', 'csv_file'), # 步骤5需要处理后的CSV
'boundary_mask_path': ('step1', 'water_mask', 'boundary_mask_file'), # 步骤5可选水体掩膜
'glint_mask_path': ('step2', 'glint_mask', 'glint_mask_file') # 步骤5可选耀斑掩膜
},
'step5_5': {
'training_spectra_path': ('step5', 'training_spectra', 'output_file') # 步骤5.5需要步骤5输出的训练光谱
},
'step6': {
'csv_path': ('step5', 'training_spectra', 'csv_file') # 步骤6需要训练光谱数据
},
'step6_5': {
'csv_path': ('step5', 'training_spectra', 'csv_file') # 步骤6.5需要训练光谱数据
},
'step6_75': {
'csv_path': ('step5', 'training_spectra', 'csv_file') # 步骤6.75需要训练光谱数据
},
'step7': {
'deglint_img_path': ('step3', 'deglint_image', 'deglint_img_file'), # 步骤7需要去耀斑影像
'water_mask_path': ('step1', 'water_mask', 'water_mask_file'), # 步骤7需要水域掩膜
'glint_mask_path': ('step2', 'glint_mask', 'glint_mask_file') # 步骤7可选耀斑掩膜
},
'step8': {
'sampling_csv_path': ('step7', 'sampling_points', 'sampling_csv_file'), # 步骤8需要采样点
'models_dir': ('step6', 'models', 'models_dir_file') # 步骤8需要训练好的模型
},
'step8_5': {
'sampling_csv_path': ('step7', 'sampling_points', 'sampling_csv_file'), # 步骤8.5需要采样点
'models_dir': ('step6_5', 'regression_models', 'models_dir') # 步骤8.5需要回归模型
},
'step8_75': {
'sampling_csv_path': ('step7', 'sampling_points', 'sampling_csv_file'), # 步骤8.75需要采样点
'models_dir': ('step6_75', 'custom_regression_models', 'models_dir') # 步骤8.75需要自定义回归模型
},
'step9': {
'prediction_csv_path': ('step8', 'predictions', 'prediction_csv_file') # 步骤9需要预测结果CSV
}
}
def get_icon_path(self, icon_filename):
"""获取图标文件的完整路径(统一使用 get_resource_path"""
return get_resource_path(f"data/icons/{icon_filename}")
def _disable_wheel_for_all_spinboxes(self):
"""
遍历所有子控件,为 QSpinBox/QDoubleSpinBox/QComboBox 禁用滚轮事件
防止滚动页面时意外改变数值
"""
from PyQt5.QtCore import Qt
# 找到所有数值输入控件
for spinbox in self.findChildren(QSpinBox):
spinbox.setFocusPolicy(Qt.StrongFocus) # 只有聚焦时才响应滚轮
spinbox.wheelEvent = lambda event, sb=spinbox: None # 完全禁用滚轮
for spinbox in self.findChildren(QDoubleSpinBox):
spinbox.setFocusPolicy(Qt.StrongFocus)
spinbox.wheelEvent = lambda event, sb=spinbox: None
for combobox in self.findChildren(QComboBox):
combobox.setFocusPolicy(Qt.StrongFocus)
combobox.wheelEvent = lambda event, cb=combobox: None
def init_workspace(self):
"""
初始化工作空间:弹出对话框选择工作目录
此方法通过 QTimer 延迟调用,确保主界面已完全渲染后再弹出对话框
如果用户取消或关闭,则退出程序
"""
from PyQt5.QtWidgets import QMessageBox
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("选择工作目录")
msg_box.setText("欢迎使用Mega Water\n\n请选择工作目录来保存所有分析结果。")
msg_box.setInformativeText("工作目录将用于存储:\n• 水域掩膜文件\n• 耀斑检测结果\n• 模型训练数据\n• 预测结果与分布图\n\n点击'确定'选择目录")
msg_box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
msg_box.setDefaultButton(QMessageBox.Ok)
result = msg_box.exec_()
if result == QMessageBox.Cancel:
QMessageBox.warning(None, "取消操作", "未选择工作目录,程序将退出。")
sys.exit(0)
# 弹出目录选择对话框
work_dir = QFileDialog.getExistingDirectory(
self, # 使用 self 作为父窗口,而不是 None
"选择工作目录",
"",
QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks
)
if not work_dir:
QMessageBox.critical(self, "错误", "必须选择工作目录才能使用系统!\n程序即将退出。")
sys.exit(0)
self.work_dir = work_dir
print(f"✓ 已选择工作目录: {self.work_dir}")
# 选择完成后,自动填充输出路径
self._auto_fill_output_paths()
def _auto_fill_output_paths(self):
"""
根据工作目录自动填充各步骤的输出路径
注意Step1 的输出路径由 update_work_directory() 根据模式自动控制
"""
if not self.work_dir:
return
# Step1: 只传递工作目录引用,不直接填充路径
# 路径填充由 Step1Panel 根据单选按钮状态自动控制
if hasattr(self, 'step1_panel'):
self.step1_panel.update_work_directory(self.work_dir)
def init_ui(self):
"""初始化UI"""
self.setWindowTitle("MegaCube-Water Quality V1.1")
# 获取屏幕可用区域(排除任务栏)
screen_geometry = QApplication.primaryScreen().availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
# 初始尺寸:宽度固定 800高度占满屏幕
window_width = 1200
window_height = screen_height
# 仅设置初始大小,不锁定
self.resize(window_width, window_height)
# 计算水平居中、垂直贴顶的位置
x = (screen_width - window_width) // 2
y = 0
self.move(x, y)
# 可选:设置最小尺寸,防止用户缩得太小
self.setMinimumSize(600, 400)
# 创建自定义标题栏包含Logo和菜单栏
self.create_title_bar()
# 创建横幅区域
self.create_banner_widget()
# 创建中央部件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主布局
main_layout = QHBoxLayout()
# 创建左侧导航栏
self.create_navigation()
main_layout.addWidget(self.nav_widget, 1)
# 创建右侧内容区
self.create_content_area()
main_layout.addWidget(self.content_widget, 4)
central_widget.setLayout(main_layout)
# 创建状态栏
self.statusBar().showMessage("就绪")
def create_title_bar(self):
"""创建自定义标题栏Logo和菜单栏挨着且同等宽度"""
# 创建标题栏容器
title_widget = QWidget()
title_layout = QHBoxLayout()
title_layout.setContentsMargins(8, 4, 8, 4)
title_layout.setSpacing(0) # 让Logo和菜单栏紧挨着
# Logo部分左侧增加宽度
logo_label = QLabel()
logo_label.setFixedSize(180, 48) # 增加Logo宽度使其和菜单栏视觉平衡
logo_label.setAlignment(Qt.AlignCenter)
logo_label.setStyleSheet("""
QLabel {
background-color: #f8f9fa;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
""")
# 设置Logo图片路径
logo_path = get_resource_path("data/icons/logo.png")
logo_pixmap = QPixmap(logo_path)
if not logo_pixmap.isNull():
# 按高度缩放图片保持宽高比让Logo更显眼
scaled_pixmap = logo_pixmap.scaledToHeight(38, Qt.SmoothTransformation)
logo_label.setPixmap(scaled_pixmap)
else:
# 如果图片加载失败,显示占位符
logo_label.setText("Logo")
logo_label.setStyleSheet("""
QLabel {
background-color: #f8f9fa;
color: #333;
font-size: 14px;
font-weight: bold;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
""")
title_layout.addWidget(logo_label)
# 菜单栏紧挨着Logo右侧
menubar = self.menuBar()
menubar.setStyleSheet("""
QMenuBar {
background-color: #f8f9fa;
border: none;
padding: 4px 8px;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
QMenuBar::item {
padding: 6px 12px;
background-color: transparent;
font-size: 13px;
}
QMenuBar::item:selected {
background-color: #e6f0ff;
border-radius: 3px;
}
""")
# 文件菜单
file_menu = menubar.addMenu("文件")
new_action = file_menu.addAction("新建配置")
new_action.triggered.connect(self.new_config)
open_action = file_menu.addAction("打开配置")
open_action.triggered.connect(self.load_config_dialog)
save_action = file_menu.addAction("保存配置")
save_action.triggered.connect(self.save_config_dialog)
file_menu.addSeparator()
exit_action = file_menu.addAction("退出")
exit_action.triggered.connect(self.close)
# 工具菜单
tools_menu = menubar.addMenu("工具")
work_dir_action = tools_menu.addAction("设置工作目录")
work_dir_action.triggered.connect(self.set_work_directory)
open_dir_action = tools_menu.addAction("打开工作目录")
open_dir_action.triggered.connect(self.open_work_directory)
tools_menu.addSeparator()
# 添加自动填充功能
auto_fill_action = tools_menu.addAction("自动填充所有输入路径")
auto_fill_action.triggered.connect(self.auto_populate_all_steps)
auto_fill_action.setToolTip("根据工作目录中的文件自动填充各步骤的输入路径")
# 在工具菜单中添加训练数据模式切换按钮
self.training_mode_action = tools_menu.addAction("有训练数据模式")
self.training_mode_action.setCheckable(True)
self.training_mode_action.setChecked(True) # 默认有训练数据模式
self.training_mode_action.triggered.connect(self.toggle_training_data_mode)
# 帮助菜单
help_menu = menubar.addMenu("帮助")
pipeline_status_action = help_menu.addAction("检查Pipeline状态")
pipeline_status_action.triggered.connect(self.show_pipeline_status)
help_menu.addSeparator()
about_action = help_menu.addAction("关于")
about_action.triggered.connect(self.show_about)
title_layout.addWidget(menubar)
title_widget.setLayout(title_layout)
# 设置整体标题栏样式
title_widget.setStyleSheet("""
QWidget {
background-color: #f8f9fa;
border-bottom: 1px solid #d0d0d0;
}
""")
# 将标题栏添加到窗口顶部
self.setMenuWidget(title_widget)
def create_banner_widget(self):
"""创建横幅区域 - 支持自适应等比缩放"""
# 横幅标题文字(方便后续直接修改版本号)
self._APP_TITLE = "MegaCube-Water Quality V1.1"
# 创建横幅容器
banner_widget = QWidget()
banner_layout = QHBoxLayout()
banner_layout.setContentsMargins(0, 0, 0, 0)
banner_layout.setSpacing(0)
# ===== 底图层 =====
self.banner_label = QLabel()
self.banner_label.setMinimumHeight(140)
self.banner_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.banner_label.setScaledContents(False)
self.banner_label.setStyleSheet("margin: 0px; padding: 0px; border: none;")
# 强制 banner_widget 展开填充 toolbar 全部宽度(清除 addWidget 的默认居中行为)
banner_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
banner_widget.setMinimumWidth(0) # 确保可以被 layout 压缩/扩展到任意宽度
banner_widget.setStyleSheet("margin: 0px; padding: 0px; border: none;")
# 纯净底图路径(无水印文字)
banner_path = get_resource_path("data/icons/Mega Water 1.0.jpg")
self.banner_pixmap = QPixmap(banner_path)
if not self.banner_pixmap.isNull():
QTimer.singleShot(50, self.update_banner_image)
else:
self.banner_label.setText("背景图加载失败")
self.banner_label.setStyleSheet("""
QLabel {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #0078d4, stop:1 #00a0e9);
color: white; font-size: 26px; font-weight: bold;
border-bottom: 3px solid #005a9e;
}
""")
banner_layout.addWidget(self.banner_label)
# ===== 文字叠加层 =====
# 注意这里:第二个参数改成了 self.banner_label直接附着在底图上
self.banner_title_label = QLabel(self._APP_TITLE, self.banner_label)
self.banner_title_label.setStyleSheet("""
QLabel {
background: transparent;
color: white;
font-size: 48px; /* 显著增大字号 */
font-weight: normal; /* 取消粗体,还原原图的优雅感 */
font-family: "Times New Roman", "Georgia", "STZhongsong", serif; /* 衬线字体家族 */
letter-spacing: 1px;
}
""")
self.banner_title_label.setAttribute(Qt.WA_TransparentForMouseEvents) # 鼠标穿透
self.banner_title_label.show() # 第一道保险:强制现身
self.banner_title_label.raise_() # 第二道保险:强制图层置顶
banner_widget.setLayout(banner_layout)
# 将横幅添加到窗口顶部(在标题栏下方)
banner_toolbar = QToolBar()
banner_toolbar.setMovable(False)
banner_toolbar.setFloatable(False)
banner_toolbar.addWidget(banner_widget)
banner_toolbar.setContentsMargins(0, 0, 0, 0) # 清除工具栏布局的边距
banner_toolbar.setStyleSheet("""
QToolBar {
background-color: white;
border: none;
border-bottom: 1px solid #ddd;
padding: 0px;
margin: 0px;
spacing: 0px;
}
QToolBar QWidget {
margin: 0px;
padding: 0px;
}
QToolBar > QWidget {
margin: 0px;
padding: 0px;
}
""")
self.addToolBar(Qt.TopToolBarArea, banner_toolbar)
self.banner_widget = banner_toolbar
def create_navigation(self):
"""创建左侧导航栏"""
self.nav_widget = QWidget()
nav_layout = QVBoxLayout()
nav_layout.setContentsMargins(10, 15, 10, 15)
nav_layout.setSpacing(10)
# 标题
title = QLabel("流程步骤")
title.setFont(QFont("Arial", 13, QFont.Bold))
title.setAlignment(Qt.AlignCenter)
title.setStyleSheet(f"color: {ModernStylesheet.COLORS['text_primary']}; padding: 10px;")
nav_layout.addWidget(title)
# 步骤列表 - 分层结构
self.step_list = QListWidget()
self.step_list.setStyleSheet(ModernStylesheet.get_sidebar_stylesheet())
# 定义四阶段结构
self.process_stages = {
"阶段一:影像预处理": [
("step1", "1. 水域掩膜生成"),
("step2", "2. 耀斑区域识别"),
("step3", "3. 耀斑去除与修复"),
],
"阶段二:样本数据准备 ": [
("step4", "4. 数据标准化处理"),
("step5", "5. 光谱特征提取"),
("step5_5", "6. 水质参数指数计算"),
],
"阶段三:模型构建与训练": [
("step6", "7. 机器学习模型训练"),
("step6_5", "8. 回归模型训练"),
("step6_75", "9. 自定义回归模型训练"),
],
"阶段四:预测与成果输出 ": [
("step7", "10. 采样点布设"),
("step8", "11. 机器学习预测"),
("step8_5", "12. 回归预测"),
("step8_75", "13. 自定义回归预测"),
("step9", "14. 专题图生成"),
("step9_viz", "15. 可视化分析"),
("step_report", "16. 分析报告生成"),
]
}
# 存储步骤映射
self.step_name_map = {}
# 添加分组项到列表
for stage_idx, (stage_name, steps) in enumerate(self.process_stages.items()):
# 添加阶段标题项(可视化分组)
stage_item = QListWidgetItem(stage_name)
stage_font = QFont("Arial", 11, QFont.Bold)
stage_item.setFont(stage_font)
stage_item.setForeground(QColor(ModernStylesheet.COLORS.get('accent', '#0078D4')))
stage_item.setFlags(stage_item.flags() & ~Qt.ItemIsSelectable)
stage_item.setFlags(stage_item.flags() & ~Qt.ItemIsEnabled)
stage_item.setData(Qt.UserRole, "stage_header")
self.step_list.addItem(stage_item)
# 添加该阶段的所有步骤
HIDDEN_STEP_IDS = {"step6_5", "step6_75", "step8_5", "step8_75"}
for step_id, step_display in steps:
if step_id in HIDDEN_STEP_IDS:
continue
item = QListWidgetItem(f" └─ {step_display}")
item.setData(Qt.UserRole, step_id)
self.step_name_map[step_display] = step_id
# 设置步骤项的样式
step_font = QFont("Arial", 10)
item.setFont(step_font)
item.setForeground(QColor(ModernStylesheet.COLORS.get('text_secondary', '#666666')))
self.step_list.addItem(item)
# 在阶段间添加分隔符
if stage_idx < len(self.process_stages) - 1:
separator_item = QListWidgetItem("")
separator_item.setFlags(separator_item.flags() & ~Qt.ItemIsSelectable)
separator_item.setFlags(separator_item.flags() & ~Qt.ItemIsEnabled)
self.step_list.addItem(separator_item)
self.step_list.currentRowChanged.connect(self.on_step_changed)
nav_layout.addWidget(self.step_list)
# 控制按钮
btn_layout = QVBoxLayout()
btn_layout.setSpacing(8)
self.run_all_btn = QPushButton("> 运行完整流程")
self.run_all_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
self.run_all_btn.setMinimumHeight(35)
self.run_all_btn.clicked.connect(self.run_full_pipeline)
btn_layout.addWidget(self.run_all_btn)
self.stop_btn = QPushButton("⏹ 停止")
self.stop_btn.setEnabled(False)
self.stop_btn.setMinimumHeight(35)
self.stop_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('danger'))
self.stop_btn.clicked.connect(self.stop_pipeline)
btn_layout.addWidget(self.stop_btn)
nav_layout.addLayout(btn_layout)
self.nav_widget.setLayout(nav_layout)
self.nav_widget.setMaximumWidth(280)
self.nav_widget.setStyleSheet(f"background-color: {ModernStylesheet.COLORS['panel_bg']}; border-right: 1px solid {ModernStylesheet.COLORS['border_light']};")
def create_content_area(self):
"""创建右侧内容区"""
self.content_widget = QWidget()
self.content_widget.setStyleSheet(f"background-color: {ModernStylesheet.COLORS['main_bg']};")
content_layout = QVBoxLayout()
content_layout.setContentsMargins(15, 15, 15, 15)
content_layout.setSpacing(10)
# 创建步骤面板容器
self.step_stack = QTabWidget()
self.step_stack.setTabPosition(QTabWidget.North)
self.step_stack.setTabsClosable(False)
self.step_stack.setStyleSheet(ModernStylesheet.get_main_stylesheet())
# 添加各步骤面板
self.step1_panel = Step1Panel()
self.step_stack.addTab(self.create_scroll_area(self.step1_panel), QIcon(self.get_icon_path("1.png")), "水域掩膜")
self.step2_panel = Step2Panel()
self.step_stack.addTab(self.create_scroll_area(self.step2_panel), QIcon(self.get_icon_path("2.png")), "耀斑检测")
self.step3_panel = Step3Panel()
self.step_stack.addTab(self.create_scroll_area(self.step3_panel), QIcon(self.get_icon_path("3.png")), "耀斑去除")
self.step4_panel = Step4Panel()
self.step_stack.addTab(self.create_scroll_area(self.step4_panel), QIcon(self.get_icon_path("4.png")), "数据清洗")
self.step5_panel = Step5Panel()
self.step_stack.addTab(self.create_scroll_area(self.step5_panel), QIcon(self.get_icon_path("5.png")), "特征构建")
self.step5_5_panel = Step5_5Panel()
self.step_stack.addTab(self.create_scroll_area(self.step5_5_panel), QIcon(self.get_icon_path("5.png")), "水质指数")
self.step6_panel = Step6Panel()
self.step_stack.addTab(self.create_scroll_area(self.step6_panel), QIcon(self.get_icon_path("6.png")), "监督建模")
self.step6_5_panel = Step6_5Panel()
self.step_stack.addTab(self.create_scroll_area(self.step6_5_panel), QIcon(self.get_icon_path("6.png")), "回归建模")
self.step_stack.tabBar().setTabVisible(7, False) # 隐藏回归建模 Tab
self.step6_75_panel = Step6_75Panel()
self.step_stack.addTab(self.create_scroll_area(self.step6_75_panel), QIcon(self.get_icon_path("6.png")), "自定义回归建模")
self.step_stack.tabBar().setTabVisible(8, False) # 隐藏自定义回归建模 Tab
self.step7_panel = Step7Panel()
self.step_stack.addTab(self.create_scroll_area(self.step7_panel), QIcon(self.get_icon_path("7.png")), "采样点布设")
self.step8_panel = Step8Panel()
self.step_stack.addTab(self.create_scroll_area(self.step8_panel), QIcon(self.get_icon_path("8.png")), "监督预测")
self.step8_5_panel = Step8_5Panel()
self.step_stack.addTab(self.create_scroll_area(self.step8_5_panel), QIcon(self.get_icon_path("8.png")), "回归预测")
self.step_stack.tabBar().setTabVisible(11, False) # 隐藏回归预测 Tab
self.step8_75_panel = Step8_75Panel()
self.step_stack.addTab(self.create_scroll_area(self.step8_75_panel), QIcon(self.get_icon_path("8.png")), "自定义回归预测")
self.step_stack.tabBar().setTabVisible(12, False) # 隐藏自定义回归预测 Tab
self.step9_panel = Step9Panel()
self.step_stack.addTab(self.create_scroll_area(self.step9_panel), QIcon(self.get_icon_path("10.png")), "专题图生成")
self.viz_panel = VisualizationPanel()
self.step_stack.addTab(self.create_scroll_area(self.viz_panel), QIcon(self.get_icon_path("9.png")), "可视化")
self.report_panel = ReportGenerationPanel(main_window=self)
self.step_stack.addTab(self.create_scroll_area(self.report_panel), QIcon(self.get_icon_path("10.png")), "报告生成")
# 连接Tab切换信号实现双向同步必须在step_stack创建后
self.step_stack.currentChanged.connect(self.on_tab_changed)
content_layout.addWidget(self.step_stack, 3)
# 日志区域
log_group = QGroupBox("执行日志")
log_group.setStyleSheet(f"""
QGroupBox {{
background-color: {ModernStylesheet.COLORS['panel_bg']};
border: 1px solid {ModernStylesheet.COLORS['border_light']};
border-radius: 5px;
margin-top: 8px;
padding-top: 15px;
padding-left: 9px;
padding-right: 9px;
padding-bottom: 9px;
}}
QGroupBox::title {{
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 0 5px;
font-weight: bold;
color: {ModernStylesheet.COLORS['text_primary']};
}}
""")
log_layout = QVBoxLayout()
log_layout.setContentsMargins(5, 5, 5, 5)
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setMaximumHeight(200)
self.log_text.setStyleSheet(f"""
QTextEdit {{
background-color: {ModernStylesheet.COLORS['panel_bg']};
color: {ModernStylesheet.COLORS['text_primary']};
border: 1px solid {ModernStylesheet.COLORS['border']};
border-radius: 4px;
padding: 5px;
font-family: 'Courier New', monospace;
font-size: 10px;
}}
""")
log_layout.addWidget(self.log_text)
log_btn_layout = QHBoxLayout()
clear_log_btn = QPushButton("清空日志")
clear_log_btn.setMaximumWidth(100)
clear_log_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal'))
clear_log_btn.clicked.connect(self.clear_log)
log_btn_layout.addWidget(clear_log_btn)
log_btn_layout.addStretch()
log_layout.addLayout(log_btn_layout)
log_group.setLayout(log_layout)
content_layout.addWidget(log_group, 1)
# 进度条
progress_group = QGroupBox("执行进度")
progress_group.setStyleSheet(f"""
QGroupBox {{
background-color: {ModernStylesheet.COLORS['panel_bg']};
border: 1px solid {ModernStylesheet.COLORS['border_light']};
border-radius: 5px;
margin-top: 8px;
padding-top: 10px;
padding-left: 9px;
padding-right: 9px;
padding-bottom: 9px;
}}
QGroupBox::title {{
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 0 5px;
font-weight: bold;
color: {ModernStylesheet.COLORS['text_primary']};
}}
""")
progress_layout = QVBoxLayout()
progress_layout.setContentsMargins(5, 5, 5, 5)
self.progress_bar = QProgressBar()
self.progress_bar.setValue(0)
self.progress_bar.setStyleSheet(f"""
QProgressBar {{
background-color: {ModernStylesheet.COLORS['panel_bg']};
border: 1px solid {ModernStylesheet.COLORS['border']};
border-radius: 4px;
padding: 2px;
text-align: center;
height: 20px;
}}
QProgressBar::chunk {{
background-color: {ModernStylesheet.COLORS['success']};
border-radius: 3px;
}}
""")
progress_layout.addWidget(self.progress_bar)
progress_group.setLayout(progress_layout)
content_layout.addWidget(progress_group, 0)
self.content_widget.setLayout(content_layout)
# 初始化训练数据模式UI状态
self.update_ui_for_training_mode()
# 为步骤面板添加自动填充功能
self.add_auto_fill_buttons_to_panels()
# 显示pipeline状态
self.show_pipeline_status_on_startup()
def create_scroll_area(self, widget):
"""创建滚动区域"""
scroll = QScrollArea()
scroll.setWidget(widget)
scroll.setWidgetResizable(True)
return scroll
def on_step_changed(self, index):
"""步骤切换 - 处理分层列表结构"""
if index < 0:
return
# 获取选中项
item = self.step_list.item(index)
if not item:
return
# 检查是否是可选中的步骤项
item_data = item.data(Qt.UserRole)
if item_data == "stage_header" or item_data is None:
# 是阶段标题或分隔符,不切换
return
# 根据步骤ID查找对应的tab索引
step_id_to_tab = {
'step1': 0,
'step2': 1,
'step3': 2,
'step4': 3,
'step5': 4,
'step5_5': 5,
'step6': 6,
'step6_5': 7,
'step6_75': 8,
'step7': 9,
'step8': 10,
'step8_5': 11,
'step8_75': 12,
'step9': 13,
'step9_viz': 14,
'step_report': 15,
}
if item_data in step_id_to_tab:
tab_index = step_id_to_tab[item_data]
self.step_stack.setCurrentIndex(tab_index)
# 切换到步骤时自动填充输入路径
self.auto_populate_step_inputs(item_data)
def on_tab_changed(self, index):
"""Tab页面切换时同步更新左侧步骤列表"""
if index < 0:
return
# Tab索引到步骤ID的反向映射
tab_to_step_id = {
0: 'step1',
1: 'step2',
2: 'step3',
3: 'step4',
4: 'step5',
5: 'step5_5',
6: 'step6',
7: 'step6_5',
8: 'step6_75',
9: 'step7',
10: 'step8',
11: 'step8_5',
12: 'step8_75',
13: 'step9',
14: 'step9_viz',
15: 'step_report',
}
if index not in tab_to_step_id:
return
target_step_id = tab_to_step_id[index]
# 在step_list中查找对应的步骤项
for row in range(self.step_list.count()):
item = self.step_list.item(row)
if not item:
continue
item_data = item.data(Qt.UserRole)
if item_data == target_step_id:
self.step_list.setCurrentRow(row)
break
# Step2 切换时自动填充数据流转路径
if index == 1:
self.step2_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# Step3 切换时自动填充数据流转路径
elif index == 2:
self.step3_panel.update_from_config(work_dir=self.work_dir)
# Step4 切换时自动填充输出路径
elif index == 3:
self.step4_panel.update_from_config(work_dir=self.work_dir)
# Step5 切换时自动填充数据流转路径
elif index == 4:
self.step5_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# Step5_5 切换时自动填充输出路径
elif index == 5:
self.step5_5_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# Step6 切换时自动填充训练数据和输出路径
elif index == 6:
self.step6_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# Step6.5(非经验回归建模)切换时自动填充训练数据和模型目录
elif index == 7:
self.step6_5_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# Step6.75(自定义回归建模)切换时自动填充训练数据和模型目录
elif index == 8:
self.step6_75_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# Step7采样点布设切换时自动填充掩膜和输出路径
elif index == 9:
self.step7_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# Step8机器学习预测切换时自动填充采样光谱和模型目录
elif index == 10:
self.step8_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# Step8.5(非经验模型预测)切换时自动填充采样光谱和回归模型目录
elif index == 11:
self.step8_5_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# Step8.75(自定义回归预测)切换时自动填充采样光谱和自定义回归模型目录
elif index == 12:
self.step8_75_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# Step9专题图生成切换时自动填充预测结果目录
elif index == 13:
self.step9_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
# 可视化分析面板切换时自动推断图像目录并加载目录树
elif index == 14:
self.viz_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
def apply_stylesheet(self):
"""应用样式表 - 应用现代化设计风格"""
# 应用主样式表
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
def new_config(self):
"""新建配置"""
reply = QMessageBox.question(
self, "新建配置",
"是否清空当前配置?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
# 重置所有面板
self.log_message("已清空配置", "info")
def load_config_dialog(self):
"""加载配置对话框"""
file_path, _ = QFileDialog.getOpenFileName(
self, "加载配置", "", "JSON Files (*.json);;All Files (*.*)"
)
if file_path:
self.load_config(file_path)
def load_config(self, file_path):
"""加载配置"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# 应用配置到各面板
if 'step1' in config:
self.step1_panel.set_config(config['step1'])
if 'step2' in config:
self.step2_panel.set_config(config['step2'])
if 'step3' in config:
self.step3_panel.set_config(config['step3'])
if 'step4' in config:
self.step4_panel.set_config(config['step4'])
if 'step5' in config:
self.step5_panel.set_config(config['step5'])
if 'step5_5' in config:
self.step5_5_panel.set_config(config['step5_5'])
if 'step6' in config:
self.step6_panel.set_config(config['step6'])
if 'step6_5' in config:
self.step6_5_panel.set_config(config['step6_5'])
if 'step6_75' in config:
self.step6_75_panel.set_config(config['step6_75'])
if 'step7' in config:
self.step7_panel.set_config(config['step7'])
if 'step8' in config:
self.step8_panel.set_config(config['step8'])
if 'step8_5' in config:
self.step8_5_panel.set_config(config['step8_5'])
if 'step9' in config:
self.step9_panel.set_config(config['step9'])
if 'visualization' in config:
self.viz_panel.set_config(config['visualization'])
if 'report_generation' in config:
self.report_panel.set_config(config['report_generation'])
self.config_file = file_path
self.log_message(f"已加载配置: {file_path}", "info")
QMessageBox.information(self, "成功", "配置加载成功!")
except Exception as e:
self.log_message(f"加载配置失败: {str(e)}", "error")
QMessageBox.critical(self, "错误", f"加载配置失败:\n{str(e)}")
def save_config_dialog(self):
"""保存配置对话框"""
file_path, _ = QFileDialog.getSaveFileName(
self, "保存配置", "config.json", "JSON Files (*.json);;All Files (*.*)"
)
if file_path:
self.save_config(file_path)
def save_config(self, file_path):
"""保存配置"""
try:
config = self.get_current_config()
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=4, ensure_ascii=False)
self.config_file = file_path
self.log_message(f"已保存配置: {file_path}", "info")
QMessageBox.information(self, "成功", "配置保存成功!")
except Exception as e:
self.log_message(f"保存配置失败: {str(e)}", "error")
QMessageBox.critical(self, "错误", f"保存配置失败:\n{str(e)}")
def get_current_config(self):
"""获取当前配置"""
config = {
'step1': self.step1_panel.get_config(),
'step2': self.step2_panel.get_config(),
'step3': self.step3_panel.get_config(),
'step4': self.step4_panel.get_config(),
'step5': self.step5_panel.get_config(),
'step5_5': self.step5_5_panel.get_config(),
'step6': self.step6_panel.get_config(),
'step6_5': self.step6_5_panel.get_config(),
'step6_75': self.step6_75_panel.get_config(),
'step7': self.step7_panel.get_config(),
'step8': self.step8_panel.get_config(),
'step8_5': self.step8_5_panel.get_config(),
'step9': self.step9_panel.get_config(),
'visualization': self.viz_panel.get_config(),
'report_generation': self.report_panel.get_config(),
}
return config
def auto_populate_step_inputs(self, step_id):
"""自动填充指定步骤的输入路径,返回填充的字段数量"""
if step_id not in self.step_dependencies:
return 0 # 该步骤没有依赖关系
# 获取对应的面板
panel = self.get_step_panel(step_id)
if not panel:
return 0
work_dir = getattr(self, 'work_dir', './work_dir')
work_path = Path(work_dir)
dependencies = self.step_dependencies[step_id]
filled_count = 0
for input_field, (dep_step, output_type, panel_attr) in dependencies.items():
# 检查面板是否有对应的属性
if not hasattr(panel, panel_attr):
continue
file_widget = getattr(panel, panel_attr)
# 如果输入框已经有内容,跳过自动填充
if file_widget.get_path().strip():
continue
# 查找依赖步骤的输出文件
output_path = self.find_step_output(work_path, dep_step, output_type)
if output_path and Path(output_path).exists():
file_widget.set_path(output_path)
self.log_message(f"自动填充 {step_id}.{input_field}: {output_path}", "info")
filled_count += 1
return filled_count
def get_step_panel(self, step_id):
"""根据步骤ID获取对应的面板对象"""
panel_map = {
'step1': self.step1_panel,
'step2': self.step2_panel,
'step3': self.step3_panel,
'step4': self.step4_panel,
'step5': self.step5_panel,
'step5_5': self.step5_5_panel,
'step6': self.step6_panel,
'step6_5': self.step6_5_panel,
'step6_75': self.step6_75_panel,
'step7': self.step7_panel,
'step8': self.step8_panel,
'step8_5': self.step8_5_panel,
'step8_75': self.step8_75_panel,
'step9': self.step9_panel,
}
return panel_map.get(step_id)
def find_step_output(self, work_path, step_id, output_type):
"""查找指定步骤的输出文件"""
if step_id not in self.step_default_outputs:
return None
step_outputs = self.step_default_outputs[step_id]
# 特殊处理从step_outputs记录中查找实际输出路径
if step_id in self.step_outputs:
actual_outputs = self.step_outputs[step_id]
if output_type in actual_outputs:
return actual_outputs[output_type]
# 根据输出类型查找对应的文件
if output_type == 'water_mask':
# 水域掩膜优先查找NDWI生成的其次是shp生成的
for mask_type in ['water_mask_ndwi', 'water_mask_shp']:
if mask_type in step_outputs:
mask_path = work_path / step_outputs[mask_type]
if mask_path.exists():
return str(mask_path)
elif output_type == 'reference_img':
# 参考影像从step1的配置中获取用户输入的影像路径
if hasattr(self, 'step1_panel'):
img_path = self.step1_panel.img_file.get_path()
if img_path and Path(img_path).exists():
return img_path
elif output_type == 'deglint_image':
# 去耀斑影像查找step3的各种去耀斑方法输出
deglint_types = ['deglint_kutser', 'deglint_goodman', 'deglint_hedley', 'deglint_sugar']
for deglint_type in deglint_types:
if deglint_type in step_outputs:
deglint_path = work_path / step_outputs[deglint_type]
if deglint_path.exists():
return str(deglint_path)
# 还要检查插值方法生成的文件
deglint_dir = work_path / "3_deglint"
if deglint_dir.exists():
for file_path in deglint_dir.glob("interpolated_*.bsq"):
return str(file_path)
elif output_type in step_outputs:
# 直接匹配的输出类型
relative_path = step_outputs[output_type]
if relative_path.endswith('/'):
# 是目录
output_path = work_path / relative_path.rstrip('/')
if output_path.exists() and output_path.is_dir():
return str(output_path)
else:
# 是文件
output_path = work_path / relative_path
if output_path.exists():
return str(output_path)
return None
def scan_work_directory_for_files(self, work_path):
"""扫描工作目录,自动发现各步骤的输出文件"""
discovered_outputs = {}
# 扫描各个子目录
subdirs = {
'1_water_mask': 'step1',
'2_glint': 'step2',
'3_deglint': 'step3',
'4_processed_data': 'step4',
'5_training_spectra': 'step5',
'6_water_quality_indices': 'step5_5',
'7_Supervised_Model_Training': 'step6',
'8_Regression_Modeling': 'step6_5',
'9_Custom_Regression_Modeling': 'step6_75',
'10_sampling': 'step7',
'11_12_13_predictions/Machine_Learning_Prediction': 'step8',
'11_12_13_predictions/Non_Empirical_Prediction': 'step8_5',
'11_12_13_predictions/Custom_Regression_Prediction': 'step8_75',
'14_visualization': 'step9'
}
for subdir, step_ids in subdirs.items():
subdir_path = work_path / subdir
if not subdir_path.exists():
continue
if isinstance(step_ids, str):
step_ids = [step_ids]
# 扫描该目录下的文件
for file_path in subdir_path.rglob('*'):
if file_path.is_file():
file_name = file_path.name.lower()
# 根据文件名模式判断输出类型
for step_id in step_ids:
if step_id not in discovered_outputs:
discovered_outputs[step_id] = {}
# 匹配不同的文件类型
if 'water_mask' in file_name and step_id == 'step1':
discovered_outputs[step_id]['water_mask'] = str(file_path)
elif 'glint' in file_name and 'mask' in file_name and step_id == 'step2':
discovered_outputs[step_id]['glint_mask'] = str(file_path)
elif 'deglint' in file_name and step_id == 'step3':
discovered_outputs[step_id]['deglint_image'] = str(file_path)
elif 'processed_data' in file_name and step_id == 'step4':
discovered_outputs[step_id]['processed_data'] = str(file_path)
elif 'training_spectra' in file_name and step_id == 'step5':
discovered_outputs[step_id]['training_spectra'] = str(file_path)
elif 'water_quality_indices' in file_name and step_id == 'step5_5':
discovered_outputs[step_id]['water_indices'] = str(file_path)
elif 'sampling_spectra' in file_name and step_id == 'step7':
discovered_outputs[step_id]['sampling_points'] = str(file_path)
elif file_name.endswith('.csv') and step_id in ['step8', 'step8_5', 'step8_75']:
discovered_outputs[step_id]['predictions'] = str(file_path)
# 更新内部记录
for step_id, outputs in discovered_outputs.items():
if step_id not in self.step_outputs:
self.step_outputs[step_id] = {}
self.step_outputs[step_id].update(outputs)
return discovered_outputs
def auto_populate_all_steps(self):
"""自动填充所有步骤的输入路径"""
work_dir = getattr(self, 'work_dir', './work_dir')
work_path = Path(work_dir)
if not work_path.exists():
QMessageBox.warning(self, "警告", f"工作目录不存在: {work_dir}\n请先设置正确的工作目录。")
return
# 首先扫描工作目录发现已有的输出文件
self.scan_work_directory_for_files(work_path)
step_order = ['step2', 'step3', 'step4', 'step5', 'step5_5', 'step6', 'step6_5', 'step6_75',
'step7', 'step8', 'step8_5', 'step8_75', 'step9']
filled_count = 0
for step_id in step_order:
old_count = filled_count
filled_count += self.auto_populate_step_inputs(step_id)
if filled_count > 0:
self.log_message(f"已完成所有步骤的自动路径填充,共填充 {filled_count} 个输入字段", "info")
QMessageBox.information(self, "完成", f"自动填充完成!\n共填充了 {filled_count} 个输入字段。")
else:
self.log_message("未发现可自动填充的路径", "info")
QMessageBox.information(self, "完成", "未发现可自动填充的路径。\n请确保工作目录中有相关的输出文件。")
def add_auto_fill_buttons_to_panels(self):
"""为各个步骤面板添加自动填充按钮"""
# 这个方法会在UI初始化完成后调用
# 为每个有依赖关系的步骤面板添加自动填充功能
panels_with_dependencies = [
('step2', self.step2_panel),
('step3', self.step3_panel),
('step5', self.step5_panel),
('step5_5', self.step5_5_panel),
('step6', self.step6_panel),
('step6_5', self.step6_5_panel),
('step6_75', self.step6_75_panel),
('step7', self.step7_panel),
('step8', self.step8_panel),
('step8_5', self.step8_5_panel),
('step8_75', self.step8_75_panel),
('step9', self.step9_panel)
]
for step_id, panel in panels_with_dependencies:
if panel:
# 为面板添加自动填充方法
def create_auto_fill_method(sid):
def auto_fill_inputs():
self.auto_populate_step_inputs(sid)
return auto_fill_inputs
panel.auto_fill_inputs = create_auto_fill_method(step_id)
# 尝试添加自动填充按钮到面板(如果面板支持的话)
self.try_add_auto_fill_button(panel)
def try_add_auto_fill_button(self, panel):
"""尝试为面板添加自动填充按钮"""
try:
# 查找面板中的运行按钮,在其旁边添加自动填充按钮
if hasattr(panel, 'run_btn') and hasattr(panel, 'auto_fill_inputs'):
run_btn = panel.run_btn
# 检查是否已经有自动填充按钮了
if hasattr(panel, 'auto_fill_btn'):
return
# 创建自动填充按钮
auto_fill_btn = QPushButton("🔄 自动填充")
auto_fill_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal'))
auto_fill_btn.setToolTip("从工作目录自动填充输入路径")
auto_fill_btn.clicked.connect(panel.auto_fill_inputs)
auto_fill_btn.setMaximumWidth(120)
# 获取运行按钮的父布局
parent_layout = run_btn.parent().layout()
if parent_layout:
# 找到运行按钮在布局中的位置
for i in range(parent_layout.count()):
if parent_layout.itemAt(i).widget() == run_btn:
# 创建水平布局容器
btn_container = QWidget()
btn_layout = QHBoxLayout(btn_container)
btn_layout.setContentsMargins(0, 0, 0, 0)
# 从原布局中移除运行按钮
parent_layout.removeWidget(run_btn)
# 添加按钮到新的水平布局
btn_layout.addWidget(auto_fill_btn)
btn_layout.addWidget(run_btn)
# 将按钮容器添加到原位置
parent_layout.insertWidget(i, btn_container)
panel.auto_fill_btn = auto_fill_btn
break
except Exception as e:
# 如果添加失败,静默忽略
pass
def set_work_directory(self):
"""设置工作目录"""
dir_path = QFileDialog.getExistingDirectory(self, "选择工作目录")
if dir_path:
self.work_dir = dir_path
self.log_message(f"工作目录已设置: {dir_path}", "info")
self.statusBar().showMessage(f"工作目录: {dir_path}")
# 同步到可视化面板
if hasattr(self, 'viz_panel'):
self.viz_panel.set_work_dir(dir_path)
if hasattr(self, 'report_panel'):
self.report_panel.set_work_dir(dir_path)
def open_work_directory(self):
"""打开工作目录"""
work_dir = getattr(self, 'work_dir', './work_dir')
if os.path.exists(work_dir):
os.startfile(work_dir)
else:
QMessageBox.warning(self, "警告", "工作目录不存在!")
def show_pipeline_status_on_startup(self):
"""启动时显示Pipeline状态"""
if not PIPELINE_AVAILABLE:
# 如果pipeline不可用显示警告信息
status_msg = "[WARNING] Pipeline模块无法加载\n\n"
status_msg += "系统功能将受到限制,建议检查以下问题:\n"
status_msg += "• 项目文件结构是否完整\n"
status_msg += "• Python依赖包是否已安装\n"
status_msg += "• Python版本是否兼容\n\n"
status_msg += "点击 '帮助''检查Pipeline状态' 查看详细信息"
QMessageBox.warning(self, "Pipeline模块警告", status_msg)
else:
# 如果pipeline可用只在状态栏显示
self.statusBar().showMessage("Pipeline模块: 正常加载", 5000) # 显示5秒
def show_pipeline_status(self):
"""显示Pipeline状态"""
status_text = "Pipeline模块状态检查\n\n"
if PIPELINE_AVAILABLE:
status_text += "[OK] Pipeline模块状态: 正常\n\n"
status_text += "详细诊断信息:\n"
else:
status_text += "[ERROR] Pipeline模块状态: 不可用\n\n"
status_text += "详细诊断信息:\n"
for info in PIPELINE_ERROR_INFO:
status_text += info + "\n"
# 添加使用建议
status_text += "\n" + "="*50 + "\n"
if PIPELINE_AVAILABLE:
status_text += "[SUCCESS] Pipeline模块已成功加载可以正常使用所有功能\n"
else:
status_text += "[WARNING] Pipeline模块无法加载功能将受到限制\n"
status_text += "建议解决方案:\n"
status_text += "1. 检查项目文件结构是否完整\n"
status_text += "2. 安装所有必需的依赖包\n"
status_text += "3. 确认Python版本兼容性\n"
status_text += "4. 查看控制台输出获取更多详细信息\n"
# 创建消息框
msg_box = QMessageBox(self)
msg_box.setWindowTitle("Pipeline状态检查")
msg_box.setText(status_text)
# 根据状态设置图标
if PIPELINE_AVAILABLE:
msg_box.setIcon(QMessageBox.Information)
else:
msg_box.setIcon(QMessageBox.Warning)
# 设置详细文本(如果有的话)
if PIPELINE_ERROR_INFO:
detailed_text = "\n".join(PIPELINE_ERROR_INFO)
msg_box.setDetailedText(detailed_text)
msg_box.setStandardButtons(QMessageBox.Ok)
msg_box.exec_()
def show_about(self):
"""显示关于对话框"""
QMessageBox.about(
self, "关于",
"MegaCube-Water Quality V1.1\n\n"
"一个完整的水质参数反演工作流程工具\n\n"
"功能包括:\n"
"- 水域掩膜生成\n"
"- 耀斑检测与去除\n"
"- 光谱提取\n"
"- 机器学习建模\n"
"- 水质参数预测\n"
"- 可视化分析\n\n"
"公司:北京依锐思遥感技术有限公司\n"
"地址北京市海淀区清河安宁庄东路18号5号楼二层205\n"
"电话010-51292601\n"
"邮箱hanshanlong@iris-rs.cn\n"
)
def run_full_pipeline(self):
"""运行完整流程"""
if not PIPELINE_AVAILABLE:
QMessageBox.critical(
self, "错误",
"无法导入pipeline模块请确保water_quality_inversion_pipeline_GUI.py文件存在"
)
return
# 验证配置
config = self.get_current_config()
# 基本验证
if not config['step1'].get('mask_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
# 确认执行
reply = QMessageBox.question(
self, "确认",
"是否开始执行完整流程?\n\n这可能需要较长时间,请确保配置正确。",
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
return
# 创建pipeline实例
work_dir = getattr(self, 'work_dir', './work_dir')
self.log_message(f"初始化pipeline工作目录: {work_dir}", "info")
# 准备实际运行配置(排除未启用的步骤)
worker_config = copy.deepcopy(config)
step5_5_cfg = worker_config.get('step5_5')
if step5_5_cfg:
enabled = step5_5_cfg.pop('enabled', True)
if not enabled:
worker_config.pop('step5_5', None)
# 工作线程内创建 Pipeline避免主线程阻塞及 Qt5Agg 子线程绘图卡死
self.worker = WorkerThread(work_dir, worker_config, mode='full')
self.worker.log_message.connect(self.log_message, Qt.QueuedConnection)
self.worker.progress_update.connect(self.update_progress, Qt.QueuedConnection)
self.worker.step_completed.connect(self.on_step_completed, Qt.QueuedConnection)
self.worker.finished.connect(self.on_pipeline_finished, Qt.QueuedConnection)
# 更新UI状态
self.run_all_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self.progress_bar.setValue(0)
# 启动线程
self.worker.start()
self.log_message("="*50, "info")
self.log_message("开始执行完整流程...", "info")
self.log_message("="*50, "info")
def stop_pipeline(self):
"""停止流程"""
if self.worker and self.worker.isRunning():
reply = QMessageBox.question(
self, "确认",
"是否停止当前流程?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.worker.stop()
self.log_message("用户取消执行", "warning")
self.run_all_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
def on_pipeline_finished(self, success, message):
"""流程完成"""
self.run_all_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
if success:
self.progress_bar.setValue(100)
self.log_message("="*50, "info")
self.log_message("流程执行完成!", "info")
self.log_message("="*50, "info")
QMessageBox.information(self, "完成", "流程执行成功!\n\n请查看工作目录中的结果文件。")
else:
self.log_message("="*50, "error")
self.log_message(f"流程执行失败: {message}", "error")
self.log_message("="*50, "error")
QMessageBox.critical(self, "失败", f"流程执行失败:\n\n{message[:200]}")
def on_step_completed(self, step_name, success, message):
"""步骤完成回调:记录输出路径并更新后续步骤"""
if not success:
return
# 记录步骤输出路径到内存
work_dir = getattr(self, 'work_dir', './work_dir')
work_path = Path(work_dir)
# 根据步骤名称和约定路径,记录实际输出
if step_name not in self.step_outputs:
self.step_outputs[step_name] = {}
# 扫描工作目录,更新该步骤的输出路径
self.update_step_outputs(step_name, work_path)
# 自动填充依赖该步骤输出的后续步骤
self.auto_populate_dependent_steps(step_name)
def update_step_outputs(self, step_name, work_path):
"""更新指定步骤的输出路径记录"""
if step_name not in self.step_default_outputs:
return
step_outputs = self.step_default_outputs[step_name]
for output_type, relative_path in step_outputs.items():
if '*' in relative_path:
# 处理通配符路径
pattern_path = work_path / relative_path.replace('*', '*')
matching_files = list(pattern_path.parent.glob(pattern_path.name))
if matching_files:
# 选择最新的文件
latest_file = max(matching_files, key=lambda p: p.stat().st_mtime)
self.step_outputs[step_name][output_type] = str(latest_file)
else:
output_path = work_path / relative_path
if output_path.exists():
self.step_outputs[step_name][output_type] = str(output_path)
def auto_populate_dependent_steps(self, completed_step):
"""自动填充依赖于已完成步骤的后续步骤"""
for step_id, dependencies in self.step_dependencies.items():
for input_field, (dep_step, output_type, panel_attr) in dependencies.items():
if dep_step == completed_step:
# 找到依赖于刚完成步骤的后续步骤,尝试自动填充
panel = self.get_step_panel(step_id)
if panel and hasattr(panel, panel_attr):
file_widget = getattr(panel, panel_attr)
# 如果输入框为空,则自动填充
if not file_widget.get_path().strip():
work_dir = getattr(self, 'work_dir', './work_dir')
work_path = Path(work_dir)
output_path = self.find_step_output(work_path, dep_step, output_type)
if output_path and Path(output_path).exists():
file_widget.set_path(output_path)
self.log_message(f"步骤完成后自动填充 {step_id}.{input_field}: {output_path}", "info")
def run_single_step(self, step_name, config):
"""运行单个步骤"""
if not PIPELINE_AVAILABLE:
QMessageBox.critical(
self, "错误",
"无法导入pipeline模块请确保water_quality_inversion_pipeline_GUI.py文件存在"
)
return
# 创建pipeline实例
work_dir = getattr(self, 'work_dir', './work_dir')
self.log_message(f"初始化pipeline工作目录: {work_dir}", "info")
self.worker = WorkerThread(work_dir, config, mode='single_step', step_name=step_name)
self.worker.log_message.connect(self.log_message, Qt.QueuedConnection)
self.worker.progress_update.connect(self.update_progress, Qt.QueuedConnection)
self.worker.step_completed.connect(self.on_step_completed, Qt.QueuedConnection)
self.worker.finished.connect(self.on_pipeline_finished, Qt.QueuedConnection)
# 更新UI状态
self.run_all_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self.progress_bar.setValue(0)
# 启动线程
self.worker.start()
self.log_message("="*50, "info")
self.log_message(f"开始独立运行步骤 {step_name}...", "info")
self.log_message("="*50, "info")
def update_progress(self, percentage, message):
"""更新进度"""
self.progress_bar.setValue(percentage)
self.statusBar().showMessage(message)
def log_message(self, message, level='info'):
"""记录日志"""
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 设置颜色
if level == 'error':
color = 'red'
elif level == 'warning':
color = 'orange'
else:
color = 'black'
# 添加到日志
formatted_msg = f'<span style="color: {color};">[{timestamp}] {message}</span>'
self.log_text.append(formatted_msg)
# 自动滚动到底部
cursor = self.log_text.textCursor()
cursor.movePosition(QTextCursor.End)
self.log_text.setTextCursor(cursor)
def clear_log(self):
"""清空日志"""
self.log_text.clear()
self.log_message("日志已清空", "info")
def toggle_training_data_mode(self, checked):
"""切换训练数据模式"""
self.has_training_data = checked
self.update_ui_for_training_mode()
mode_text = "有训练数据" if checked else "无训练数据"
self.log_message(f"切换到{mode_text}模式", "info")
self.statusBar().showMessage(f"当前模式: {mode_text}")
# 更新按钮文本
self.training_mode_action.setText("有训练数据模式" if checked else "无训练数据模式")
def update_banner_image(self):
"""更新横幅图片 - 固定高度 + 居中裁剪(类似 CSS object-fit: cover"""
if not hasattr(self, 'banner_pixmap') or self.banner_pixmap.isNull():
return
TARGET_HEIGHT = 140
target_width = self.width()
# 手动计算 Cover 缩放比例:取宽/高各自所需比例的最大值,
# 确保缩放后图片的宽 ≥ target_width 且高 ≥ TARGET_HEIGHT
orig_w = self.banner_pixmap.width()
orig_h = self.banner_pixmap.height()
scale_factor = max(target_width / orig_w, TARGET_HEIGHT / orig_h)
new_w = int(orig_w * scale_factor)
new_h = int(orig_h * scale_factor)
scaled_pixmap = self.banner_pixmap.scaled(
new_w, new_h,
Qt.IgnoreAspectRatio,
Qt.SmoothTransformation
)
# 居中裁剪,截取中间核心区域
crop_x = (new_w - target_width) // 2
crop_y = (new_h - TARGET_HEIGHT) // 2
final_pixmap = scaled_pixmap.copy(crop_x, crop_y, target_width, TARGET_HEIGHT)
self.banner_label.setFixedHeight(TARGET_HEIGHT)
self.banner_label.setFixedWidth(target_width) # 强制宽度填满容器
self.banner_label.setPixmap(final_pixmap)
self.banner_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
# 文字叠加层:绝对定位,保持垂直居中
if hasattr(self, 'banner_title_label'):
title_x = 160
title_y = max(0, (TARGET_HEIGHT - 60) // 2)
self.banner_title_label.move(title_x, title_y)
self.banner_title_label.resize(target_width - title_x - 20, 60)
def resizeEvent(self, event):
"""窗口大小改变事件 - 实时更新横幅图片等比缩放"""
super().resizeEvent(event)
# 直接调用,不使用定时器延迟(或缩短到 10ms
self.update_banner_image()
def update_ui_for_training_mode(self):
"""根据训练数据模式更新UI状态"""
# 需要禁用的步骤ID对应无训练数据模式下需要禁用的步骤
disabled_step_ids = ['step4', 'step5', 'step5_5', 'step6', 'step6_5', 'step6_75']
# 更新标签页的启用/禁用状态
step_id_to_tab = {
'step1': 0, 'step2': 1, 'step3': 2, 'step4': 3,
'step5': 4, 'step5_5': 5, 'step6': 6, 'step6_5': 7,
'step6_75': 8, 'step7': 9, 'step8': 10, 'step8_5': 11,
'step8_75': 12, 'step9': 13, 'step9_viz': 14
}
for step_id in disabled_step_ids:
if step_id in step_id_to_tab:
tab_index = step_id_to_tab[step_id]
if tab_index < self.step_stack.count():
self.step_stack.setTabEnabled(tab_index, self.has_training_data)
# 同时更新导航列表的启用状态
for i in range(self.step_list.count()):
item = self.step_list.item(i)
item_data = item.data(Qt.UserRole)
# 跳过阶段标题和分隔符
if item_data == "stage_header" or item_data is None:
continue
# 检查步骤是否在禁用列表中
if item_data in disabled_step_ids:
if not self.has_training_data:
item.setFlags(item.flags() & ~Qt.ItemIsEnabled)
item.setForeground(QColor(128, 128, 128)) # 灰色
else:
item.setFlags(item.flags() | Qt.ItemIsEnabled)
item.setForeground(QColor(ModernStylesheet.COLORS.get('text_secondary', '#666666'))) # 原始颜色
def main():
"""主函数"""
app = QApplication(sys.argv)
# 设置应用信息
app.setApplicationName("Mega Water")
app.setOrganizationName("WaterQuality")
# 创建主窗口
window = WaterQualityGUI()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
#冻结只显示1个exe
# multiprocessing.freeze_support()
main()