3019 lines
124 KiB
Python
3019 lines
124 KiB
Python
#!/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()
|
||
|