diff --git a/src/core/prediction/inference_batch.py b/src/core/prediction/inference_batch.py
index 9204fef..b922fa1 100644
--- a/src/core/prediction/inference_batch.py
+++ b/src/core/prediction/inference_batch.py
@@ -555,7 +555,13 @@ class WaterQualityInference:
print(f"输入数据形状: {spectra_processed.shape}")
try:
- predictions = model.predict(spectra_processed)
+ # 清洗 NaN / Inf,防止 SVR 等模型报错
+ spectra_clean = np.nan_to_num(spectra_processed, nan=0.0, posinf=0.0, neginf=0.0)
+ if np.any(np.isnan(spectra_clean)) or np.any(np.isinf(spectra_clean)):
+ print("警告: 清洗后数据中仍存在 NaN/Inf,已重置为 0")
+ spectra_clean = np.nan_to_num(spectra_clean, nan=0.0, posinf=0.0, neginf=0.0)
+
+ predictions = model.predict(spectra_clean)
print(f"预测完成,结果形状: {predictions.shape}")
print(f"预测值范围: [{np.min(predictions):.4f}, {np.max(predictions):.4f}]")
print(f"预测值统计: 均值={np.mean(predictions):.4f}, 标准差={np.std(predictions):.4f}")
diff --git a/src/core/water_quality_inversion_pipeline_GUI.py b/src/core/water_quality_inversion_pipeline_GUI.py
index 3395a6e..3973baa 100644
--- a/src/core/water_quality_inversion_pipeline_GUI.py
+++ b/src/core/water_quality_inversion_pipeline_GUI.py
@@ -1724,7 +1724,11 @@ class WaterQualityInversionPipeline:
final_water_mask, temp_shape, geotransform, projection, img_path
)
- # 应用Goodman算法:直接传递文件路径,让算法类使用GDAL逐波段处理
+ # 加载影像数据(Goodman算法需要numpy数组用于后插值)
+ image_array, geotransform, projection = self._load_image_as_array(img_path)
+ print(f"影像尺寸: {image_array.shape}")
+
+ # 应用Goodman算法:传递文件路径
goodman = Goodman(img_path, NIR_lower=nir_lower, NIR_upper=nir_upper,
A=goodman_A, B=goodman_B, water_mask=mask_for_algorithm,
output_path=output_path) # 传递output_path,算法类会保存
diff --git a/src/gui/components/__init__.py b/src/gui/components/__init__.py
new file mode 100644
index 0000000..0d92269
--- /dev/null
+++ b/src/gui/components/__init__.py
@@ -0,0 +1 @@
+# src.gui.components package
\ No newline at end of file
diff --git a/src/gui/components/custom_widgets.py b/src/gui/components/custom_widgets.py
new file mode 100644
index 0000000..df4df43
--- /dev/null
+++ b/src/gui/components/custom_widgets.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+自定义组件 - 文件选择控件等公共组件
+"""
+
+import os
+
+from PyQt5.QtWidgets import (
+ QWidget, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog,
+)
+from PyQt5.QtCore import Qt
+
+
+class FileSelectWidget(QWidget):
+ """文件选择组件"""
+ def __init__(self, label_text, file_filter="All Files (*.*)", mode="open", parent=None):
+ """
+ 初始化文件选择组件
+
+ Args:
+ label_text: 标签文本
+ file_filter: 文件过滤器
+ mode: 选择模式 - "open"(打开文件) 或 "save"(保存文件)
+ parent: 父控件
+ """
+ super().__init__(parent)
+ self.file_filter = file_filter
+ self.mode = mode # "open" 或 "save"
+ self.init_ui(label_text)
+
+ def init_ui(self, label_text):
+ layout = QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self.label = QLabel(label_text)
+ self.label.setMinimumWidth(120)
+ self.line_edit = QLineEdit()
+ placeholder = "请选择保存路径..." if self.mode == "save" else "请选择文件..."
+ self.line_edit.setPlaceholderText(placeholder)
+ self.browse_btn = QPushButton("浏览...")
+ self.browse_btn.setMaximumWidth(80)
+ self.browse_btn.clicked.connect(self.browse_file)
+
+ layout.addWidget(self.label)
+ layout.addWidget(self.line_edit, 1)
+ layout.addWidget(self.browse_btn)
+
+ self.setLayout(layout)
+
+ def browse_file(self):
+ """浏览文件"""
+ current_text = self.line_edit.text().strip()
+ initial_dir = ""
+
+ if current_text:
+ dir_path = os.path.dirname(current_text)
+ if dir_path and os.path.exists(dir_path):
+ initial_dir = dir_path
+
+ if self.mode == "save":
+ file_path, _ = QFileDialog.getSaveFileName(
+ self, "保存文件", initial_dir, self.file_filter
+ )
+ else:
+ file_path, _ = QFileDialog.getOpenFileName(
+ self, "选择文件", initial_dir, self.file_filter
+ )
+ if file_path:
+ self.line_edit.setText(file_path)
+
+ def get_path(self):
+ """获取路径"""
+ return self.line_edit.text()
+
+ def set_path(self, path):
+ """设置路径"""
+ self.line_edit.setText(str(path))
\ No newline at end of file
diff --git a/src/gui/core/__init__.py b/src/gui/core/__init__.py
new file mode 100644
index 0000000..64f374a
--- /dev/null
+++ b/src/gui/core/__init__.py
@@ -0,0 +1 @@
+# src.gui.core
diff --git a/src/gui/core/worker_thread.py b/src/gui/core/worker_thread.py
new file mode 100644
index 0000000..0ee6f90
--- /dev/null
+++ b/src/gui/core/worker_thread.py
@@ -0,0 +1,327 @@
+# -*- coding: utf-8 -*-
+"""
+后台线程模块:Pipeline 执行线程与诊断逻辑。
+"""
+import traceback
+from PyQt5.QtCore import QThread, pyqtSignal
+
+
+# =============================================================================
+# 依赖诊断
+# =============================================================================
+
+def check_pipeline_dependencies():
+ """检查pipeline模块的依赖项"""
+ missing_deps = []
+ dep_errors = {}
+
+ required_packages = [
+ 'numpy', 'pandas', 'scipy', 'matplotlib', 'sklearn',
+ 'joblib', 'PIL', 'cv2', 'rasterio', 'geopandas'
+ ]
+
+ for package in required_packages:
+ try:
+ if package == 'PIL':
+ import PIL
+ elif package == 'cv2':
+ import cv2
+ else:
+ __import__(package)
+ except Exception as e:
+ missing_deps.append(package)
+ dep_errors[package] = repr(e)
+
+ return missing_deps, dep_errors
+
+
+def diagnose_pipeline_import_error():
+ """诊断pipeline导入错误"""
+ import sys
+ import os
+
+ error_info = []
+
+ is_frozen = getattr(sys, "frozen", False) or bool(getattr(sys, "_MEIPASS", None))
+
+ if is_frozen:
+ error_info.append(
+ "[INFO] PyInstaller 环境:Pipeline 从程序内置包加载,跳过对仓库路径 src/core/*.py 的磁盘检查"
+ )
+ else:
+ pipeline_file = os.path.normpath(
+ os.path.join(os.path.dirname(__file__), "..", "..", "core", "water_quality_inversion_pipeline_GUI.py")
+ )
+ if not os.path.exists(pipeline_file):
+ error_info.append(f"[ERROR] Pipeline文件不存在: {pipeline_file}")
+ error_info.append(
+ " 解决方案: 请确保项目结构完整,检查 src/core/ 下是否有 water_quality_inversion_pipeline_GUI.py"
+ )
+ else:
+ error_info.append(f"[OK] Pipeline文件存在: {pipeline_file}")
+
+ current_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
+ if current_dir not in sys.path:
+ sys.path.insert(0, current_dir)
+ error_info.append(f"[INFO] 已添加路径到sys.path: {current_dir}")
+
+ missing_deps, dep_errors = check_pipeline_dependencies()
+ if missing_deps:
+ error_info.append(f"[ERROR] 缺少必需的依赖包: {', '.join(missing_deps)}")
+ for pkg in missing_deps:
+ if pkg in dep_errors:
+ error_info.append(f" - {pkg} 导入失败原因: {dep_errors[pkg]}")
+ error_info.append(" 解决方案: 请运行以下命令安装依赖:")
+ error_info.append(" pip install -r requirements.txt")
+ error_info.append(" 或使用conda:")
+ error_info.append(" conda install numpy pandas scipy matplotlib scikit-learn joblib pillow opencv-python rasterio geopandas")
+ else:
+ error_info.append("[OK] 主要依赖包均已安装")
+
+ try:
+ from osgeo import gdal # noqa: F401
+ error_info.append("[OK] GDAL (osgeo) 可用")
+ except ImportError:
+ try:
+ from osgeo import gdal # noqa: F401
+ error_info.append("[OK] GDAL 可用")
+ except ImportError:
+ error_info.append("[WARNING] GDAL/osgeo 不可用,将影响栅格与地理数据处理")
+ error_info.append(" 开发环境: conda install gdal")
+ error_info.append(" 打包环境: 请在构建所用 Conda 环境中打包,并确保 spec 已收集 Library/bin 中依赖 DLL")
+
+ try:
+ import unittest
+ error_info.append("[OK] unittest模块可用")
+ except ImportError:
+ error_info.append("[WARNING] unittest模块不可用,这可能是PyInstaller打包环境导致的")
+ error_info.append(" 这不会影响主要功能,但可能影响某些测试相关特性")
+
+ return error_info
+
+
+# =============================================================================
+# Pipeline 可用性标志(模块级状态)
+# =============================================================================
+
+PIPELINE_AVAILABLE = False
+PIPELINE_ERROR_INFO = []
+
+try:
+ error_info = diagnose_pipeline_import_error()
+ from src.core.water_quality_inversion_pipeline_GUI import WaterQualityInversionPipeline
+ PIPELINE_AVAILABLE = True
+ print("[OK] 成功导入pipeline模块")
+ PIPELINE_ERROR_INFO = error_info
+
+except ImportError as e:
+ PIPELINE_AVAILABLE = False
+ error_info = diagnose_pipeline_import_error()
+
+ print("="*60)
+ print("[ERROR] PIPELINE导入失败 - 详细诊断信息:")
+ print("="*60)
+
+ for info in error_info:
+ print(info)
+
+ print("-"*60)
+ print(f"原始ImportError: {str(e)}")
+ print("-"*60)
+
+ if "unittest" in str(e):
+ print("[INFO] unittest模块缺失 - 这通常在PyInstaller打包环境中发生")
+ print("解决方案:")
+ print(" 1. 这不会影响主要功能,程序仍可正常运行")
+ print(" 2. 如果需要修复,可以在.spec文件中添加unittest模块:")
+ print(" a = Analysis(..., hiddenimports=['unittest', 'unittest.mock'])")
+ print(" 3. 或在PyInstaller命令中添加: --hidden-import unittest")
+ elif "water_quality_inversion_pipeline_GUI" in str(e):
+ print("[INFO] 可能的解决方案:")
+ print(" 1. 检查src/core/water_quality_inversion_pipeline_GUI.py文件是否存在")
+ print(" 2. 确保Python路径设置正确")
+ print(" 3. 尝试重新安装依赖: pip install -r requirements.txt")
+ print(" 4. 检查Python版本是否兼容(推荐Python 3.8-3.11)")
+
+ import traceback
+ print("\n完整错误追踪:")
+ traceback.print_exc()
+ print("="*60)
+
+ PIPELINE_ERROR_INFO = error_info
+
+except Exception as e:
+ PIPELINE_AVAILABLE = False
+ error_info = diagnose_pipeline_import_error()
+
+ print("="*60)
+ print("[ERROR] PIPELINE导入失败 - 其他错误:")
+ print("="*60)
+
+ for info in error_info:
+ print(info)
+
+ print("-"*60)
+ print(f"原始错误: {str(e)}")
+ print("-"*60)
+
+ print("[INFO] 可能的解决方案:")
+ print(" 1. 检查Python环境和依赖包版本")
+ print(" 2. 尝试重新安装所有依赖")
+ print(" 3. 检查是否有语法错误或其他模块导入问题")
+
+ import traceback
+ print("\n完整错误追踪:")
+ traceback.print_exc()
+ print("="*60)
+
+ PIPELINE_ERROR_INFO = error_info
+
+
+# =============================================================================
+# WorkerThread
+# =============================================================================
+
+class WorkerThread(QThread):
+ """后台工作线程,用于执行耗时任务(在工作线程内创建 Pipeline,避免阻塞 UI)。"""
+ progress_update = pyqtSignal(int, str) # 进度更新信号 (percentage, message)
+ log_message = pyqtSignal(str, str) # 日志消息信号 (message, level: 'info'/'warning'/'error')
+ step_completed = pyqtSignal(str, bool, str) # 步骤完成信号 (step_name, success, message)
+ finished = pyqtSignal(bool, str) # 完成信号 (success, message)
+
+ def __init__(self, work_dir: str, config, mode='full', step_name=None):
+ super().__init__()
+ self.work_dir = str(work_dir)
+ self.config = config
+ self.mode = mode # 'full' 或 'single_step'
+ self.step_name = step_name # 单步执行时的步骤名称
+ self.pipeline = None
+ self.is_running = True
+ self.current_step = None
+ self.step_count = 0
+ self.total_steps = 9
+
+ def pipeline_callback(self, step_name, status, message=""):
+ """Pipeline回调函数,用于接收步骤状态"""
+ if status == "start":
+ self.log_message.emit(f"[START] 开始执行: {step_name}", "info")
+ progress = int((self.step_count / self.total_steps) * 100)
+ self.progress_update.emit(progress, f"正在执行: {step_name}")
+ elif status == "completed":
+ self.step_count += 1
+ self.log_message.emit(f"[DONE] 完成: {step_name} {message}", "info")
+ self.step_completed.emit(step_name, True, message)
+ progress = int((self.step_count / self.total_steps) * 100)
+ self.progress_update.emit(progress, f"已完成: {step_name}")
+ elif status == "skipped":
+ self.step_count += 1
+ self.log_message.emit(f"[SKIP] 跳过: {step_name} {message}", "warning")
+ self.step_completed.emit(step_name, True, f"跳过: {message}")
+ progress = int((self.step_count / self.total_steps) * 100)
+ self.progress_update.emit(progress, f"已跳过: {step_name}")
+ elif status == "error":
+ self.log_message.emit(f"[ERROR] 错误: {step_name} - {message}", "error")
+ self.step_completed.emit(step_name, False, message)
+ elif status == "info":
+ self.log_message.emit(f" {message}", "info")
+ elif status == "warning":
+ self.log_message.emit(f" [WARNING] {message}", "warning")
+
+ def run(self):
+ """运行 pipeline:子线程内切换 Matplotlib 为 Agg,避免 Qt5Agg 在后台线程绘图导致界面卡死。"""
+ 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:
+ from src.core.water_quality_inversion_pipeline_GUI import WaterQualityInversionPipeline
+ self.pipeline = WaterQualityInversionPipeline(work_dir=self.work_dir)
+
+ if self.mode == 'full':
+ self.log_message.emit("开始运行完整流程...", "info")
+ self.step_count = 0
+
+ if hasattr(self.pipeline, 'set_callback'):
+ self.pipeline.set_callback(self.pipeline_callback)
+
+ self.pipeline.run_full_pipeline(self.config)
+
+ self.progress_update.emit(100, "流程执行完成")
+ self.finished.emit(True, "完整流程执行成功!")
+ else:
+ self.log_message.emit(f"开始独立运行步骤: {self.step_name}", "info")
+ self.progress_update.emit(0, f"正在执行: {self.step_name}")
+
+ if hasattr(self.pipeline, 'set_callback'):
+ self.pipeline.set_callback(self.pipeline_callback)
+
+ self.run_single_step(self.step_name, self.config)
+
+ self.progress_update.emit(100, f"步骤 {self.step_name} 执行完成")
+ self.finished.emit(True, f"步骤 {self.step_name} 独立运行成功!")
+ except Exception as e:
+ error_msg = f"执行失败: {str(e)}\n{traceback.format_exc()}"
+ self.log_message.emit(error_msg, "error")
+ self.finished.emit(False, error_msg)
+ finally:
+ if mpl_prev:
+ try:
+ import matplotlib.pyplot as plt
+ plt.switch_backend(mpl_prev)
+ except Exception:
+ pass
+
+ def run_single_step(self, step_name, config):
+ """运行单个步骤"""
+ step_method_map = {
+ 'step1': 'step1_generate_water_mask',
+ 'step2': 'step2_find_glint_area',
+ 'step3': 'step3_remove_glint',
+ 'step4': 'step4_process_csv',
+ 'step5': 'step5_extract_training_spectra',
+ 'step5_5': 'step5_5_calculate_water_quality_indices',
+ 'step6': 'step6_train_models',
+ 'step6_5': 'step6_5_non_empirical_modeling',
+ 'step6_75': 'step6_75_custom_regression',
+ 'step7': 'step7_generate_sampling_points',
+ 'step8': 'step8_predict_water_quality',
+ 'step8_5': 'step8_5_predict_with_non_empirical_models',
+ 'step8_75': 'step8_75_predict_with_custom_regression',
+ 'step9': 'step9_generate_distribution_map'
+ }
+
+ if step_name not in step_method_map:
+ raise ValueError(f"未知的步骤名称: {step_name}")
+
+ method_name = step_method_map[step_name]
+ step_config = dict(config.get(step_name, {}))
+
+ step_config['skip_dependency_check'] = True
+
+ if step_name == 'step9':
+ step_config.pop('step9_batch_mode', None)
+ step_config.pop('prediction_csv_dir', None)
+ step_config.pop('recursive_csv_scan', None)
+
+ if step_name in ['step2', 'step3', 'step4', 'step5', 'step7', 'step8', 'step8_5', 'step8_75']:
+ step_config.pop('output_path', None)
+
+ if step_name == 'step8_5' and 'models_dir' in step_config:
+ step_config['non_empirical_models_dir'] = step_config.pop('models_dir')
+
+ method = getattr(self.pipeline, method_name)
+ result = method(**step_config)
+
+ return result
+
+ def stop(self):
+ """停止执行"""
+ self.is_running = False
+ self.terminate()
diff --git a/src/gui/model/waterindex.csv b/src/gui/model/waterindex.csv
new file mode 100644
index 0000000..0dfee5f
--- /dev/null
+++ b/src/gui/model/waterindex.csv
@@ -0,0 +1,46 @@
+Formula_Name,Category,Formula,Reference
+BGA_Am09KBBI,Phycocyanin (BGA_PC),(w686 - w658) / (w686 + w658),"Amin, R.; Zhou, J.; Gilerson, A.; Gross, B.; Moshary, F.; Ahmed, S.; Novel optical techniques for detecting and classifying toxic dinoflagellate Karenia brevis blooms using satellite imagery, Optics Express, 2009, 17, 11, 1-13."
+BGA_Be162B643sub629,Phycocyanin (BGA_PC),w644 - w629,"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 538."
+BGA_Be162B700sub601,Phycocyanin (BGA_PC),w700 - w601,"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 539."
+BGA_Be162BsubPhy,Phycocyanin (BGA_PC),w715 - w615,"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 540."
+BGA_Be16FLHBlueRedNIR,Phycocyanin (BGA_PC),w658 - (w857 + (w458 - w857)),"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 538."
+BGA_Be16FLHGreenRedNIR,Phycocyanin (BGA_PC),w658 - (w857 + (w558 - w857)),"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 539."
+BGA_Be16FLHVioletRedNIR,Phycocyanin (BGA_PC),w658 - (w857 + (w444 - w857)),"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 538."
+BGA_Be16MPI,Phycocyanin (BGA_PC),(w615 - w601) - (w644 - w601),"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 539."
+BGA_Be16NDPhyI,Phycocyanin (BGA_PC),(w700 - w622) / (w700 + w622),"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 540."
+BGA_Be16NDPhyI644over615,Phycocyanin (BGA_PC),(w644 - w615) / (w644 + w615),"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 541."
+BGA_Be16NDPhyI644over629,Phycocyanin (BGA_PC),(w644 - w629) / (w644 + w629),"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 542."
+BGA_Be16Phy2BDA644over629,Phycocyanin (BGA_PC),w644 / w629,"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 545."
+BGA_Da052BDA,Phycocyanin (BGA_PC),w714 / w672,"Wynne, T. T., Stumpf, R. P., Tomlinson, M. C., Warner, R. A., Tester, P. A., Dyble, J.; Relating spectral shape to cyanobacterial blooms in the Laurentian Great Lakes. Int. J. Remote Sens., 2008, 29, 3665-3672."
+BGA_Go04MCI,Phycocyanin (BGA_PC),w709 - w681 - (w753 - w681),"Gower, J.F.R.; Brown,L.; Borstad, G.A.; Observation of chlorophyll fluorescence in west coast waters of Canada using the MODIS satellite sensor. Can. J. Remote Sens., 2004, 30 (1), 17闁?5."
+BGA_HU103BDA,Phycocyanin (BGA_PC),(((1 / w615) - (1 / w600)) - w725),"Hunter, P.D.; Tyler, A.N.; Willby, N.J.; Gilvear, D.J.; The spatial dynamics of vertical migration by Microcystis aeruginosa in a eutrophic shallow lake: A case study using high spatial resolution time-series airborne remote sensing. Limn. Oceanogr. 2008, 53, 2391-2406"
+BGA_Ku15PhyCI,Phycocyanin (BGA_PC),(-1 * (W681 - W665 - (W709 - W665))),"Kudela, R.M., Palacios, S.L., Austerberry, D.C., Accorsi, E.K., Guild, L.S.; Application of hyperspectral remote sensing to cyanobacterial blooms in inland waters, Torres-Perez, J., 2015, Remote Sens. Environ., 2015, 167, 1-10."
+BGA_Ku15SLH,Phycocyanin (BGA_PC),(w715 - w658) + (w715 - w658),"Kudela, R.M., Palacios, S.L., Austerberry, D.C., Accorsi, E.K., Guild, L.S.; Application of hyperspectral remote sensing to cyanobacterial blooms in inland waters, Torres-Perez, J., 2015, Remote Sens. Environ., 2015, 167, 1-11."
+BGA_MI092BDA,Phycocyanin (BGA_PC),w700 / w600,"Mishra, S.; Mishra, D.R.; Schluchter, W. M., A novel algorithm for predicting PC concentrations in cyanobacteria: A proximal hyperspectral remote sensing approach. Remote Sens., 2009, 1, 758闁?75."
+BGA_MM092BDA,Phycocyanin (BGA_PC),w724 / w600,"Mishra, S.; Mishra, D.R.; Schluchter, W. M., A novel algorithm for predicting PC concentrations in cyanobacteria: A proximal hyperspectral remote sensing approach. Remote Sens., 2009, 1, 758闁?76."
+BGA_MM12NDCIalt,Phycocyanin (BGA_PC),(w700 - w658) / (w700 + w658),"Mishra, S.; Mishra, D.R.; A novel remote sensing algorithm to quantify phycocyanin in cyanobacterial algal blooms, Env. Res. Lett., 2014, 9 (11), DOI:10.1088/1748-9326/9/11/114003"
+BGA_MM143BDAopt,Phycocyanin (BGA_PC),((1 / w629) - (1 / w659)) * w724,"Mishra, S.; Mishra, D.R.; A novel remote sensing algorithm to quantify phycocyanin in cyanobacterial algal blooms, Env. Res. Lett., 2014, 9 (11), DOI:10.1088/1748-9326/9/11/114004"
+BGA_SI052BDA,Phycocyanin (BGA_PC),w709 / w620,"Simis, S. G. H.; Peters, S.W. M.; Gons, H. J.; Remote sensing of the cyanobacteria pigment phycocyanin in turbid inland water. Limn. Oceanogr., 2005, 50, 237闁?45"
+BGA_SM122BDA,Phycocyanin (BGA_PC),w709 / w600,"Mishra, S. Remote sensing of cyanobacteria in turbid productive waters, PhD Dissertation. Mississippi State University, USA. 2012."
+BGA_SY002BDA,Phycocyanin (BGA_PC),w650 / w625,"Schalles, J.; Yacobi, Y. Remote detection and seasonal patterns of phycocyanin, carotenoid and chlorophyll-a pigments in eutrophic waters. Archiv fur Hydrobiologie, Special Issues Advances in Limnology, 2000, 55,153闁?68"
+BGA_Wy08CI,Phycocyanin (BGA_PC),(-1 * (W686 - W672 - (W715 - W672))),"Wynne, T. T., Stumpf, R. P., Tomlinson, M. C., Warner, R. A., Tester, P. A., Dyble, J.; Relating spectral shape to cyanobacterial blooms in the Laurentian Great Lakes. Int. J. Remote Sens., 2008, 29, 3665-3672."
+Chl_Al10SABI,chlorophyll_a,(w857 - w644) / (w458 + w529),"Alawadi, F. Detection of surface algal blooms using the newly developed algorithm surface algal bloom index (SABI). Proc. SPIE 2010, 7825."
+Chl_Am092Bsub,chlorophyll_a,w681 - w665,"Amin, R.; Zhou, J.; Gilerson, A.; Gross, B.; Moshary, F.; Ahmed, S. Novel optical techniques for detecting and classifying toxic dinoflagellate Karenia brevis blooms using satellite imagery. Opt. Express 2009, 17, 9126闁?144."
+Chl_Be16FLHblue,chlorophyll_a,w529 - (w644 + (w458 - w644)),"Beck, R.A. and 22 others; Comparison of satellite reflectance algorithms for estimating chlorophyll-a in a temperate reservoir using coincident hyperspectral aircraft imagery and dense coincident surface observations, Remote Sens. Environ., 2016, 178, 15-30."
+Chl_Be16FLHviolet,chlorophyll_a,w529 - (w644 + (w429 - w644)),"Beck, R.A. and 22 others; Comparison of satellite reflectance algorithms for estimating chlorophyll-a in a temperate reservoir using coincident hyperspectral aircraft imagery and dense coincident surface observations, Remote Sens. Environ., 2016, 178, 15-30."
+Chl_Be16NDTIblue,chlorophyll_a,(w658 - w458) / (w658 + w458),"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 543."
+Chl_Be16NDTIviolet,chlorophyll_a,(w658 - w444) / (w658 + w444),"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 544."
+Chl_De933BDA,chlorophyll_a,w600 - w648 - w625,"Dekker, A.; Detection of the optical water quality parameters for eutrophic waters by high resolution remote sensing, Ph.D. thesis, 1993, Free University, Amsterdam."
+Chl_Gi033BDA,chlorophyll_a,((1 / w672) - (1 / w715)) * w757,"Gitelson, A.A.; U. Gritz, and M. N. Merzlyak.; Relationships between leaf chlorophyll content and spectral reflectance and algorithms for non-destructive chlorophyll assessment in higher plant leaves. J. Plant Phys. 2003, 160, 271-282."
+Chl_Kn07KIVU,chlorophyll_a,(w458 - w644) / w529,"Kneubuhler, M.; Frank T.; Kellenberger, T.W; Pasche N.; Schmid M.; Mapping chlorophyll-a in Lake Kivu with remote sensing methods. 2007, Proceedings of the Envisat Symposium 2007, Montreux, Switzerland 23闁?7 April 2007 (ESA SP-636, July 2007)."
+Chl_MM12NDCI,chlorophyll_a,(w715 - w686) / (w715 + w686),"Mishra, S.; and Mishra, D.R. Normalized difference chlorophyll index: A novel model for remote estimation of chlorophyll-a concentration in turbid productive waters, Remote Sens. Environ., 2012, 117, 394-406"
+Chl_Zh10FLH,chlorophyll_a,w686 - (w715 + (w672 - w751)),"Zhao, D.Z.; Xing, X.G.; Liu, Y.G.; Yang, J.H.; Wang, L. The relation of chlorophyll-a concentration with the reflectance peak near 700 nm in algae-dominated waters and sensitivity of fluorescence algorithms for detecting algal bloom. Int. J. Remote Sens. 2010, 31, 39-48"
+Turb_Be16GreenPlusRedBothOverViolet,Turbidity,(w558 + w658) / w444,"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 538"
+Turb_Be16RedOverViolet,Turbidity,w658 / w444,"Beck, R.; Xu, M.; Zhan, S.; Liu, H.; Johansen, R.A.; Tong, S.; Yang, B.; Shu, S.; Wu, Q.; Wang, S.; Berling, K.; Murray, A.; Emery, E.; Reif, M.; Harwood, J.; Young, J.; Martin, M.; Stillings, G.; Stumpf, R.; Su, H.; Ye, Z.; Huang, Y. Comparison of Satellite Reflectance Algorithms for Estimating Phycocyanin Values and Cyanobacterial Total Biovolume in a Temperate Reservoir Using Coincident Hyperspectral Aircraft Imagery and Dense Coincident Surface Observations. Remote Sens. 2017, 9, 539"
+Turb_Bow06RedOverGreen,Turbidity,w658 / w558,"Bowers, D. G., and C. E. Binding. 2006. 闁炽儲缈籬e Optical Properties of Mineral Suspended Particles: A Review and Synthesis.闁?Estuarine Coastal and Shelf Science 67 (1闁?): 219闁?30. doi:10.1016/j.ecss.2005.11.010"
+Turb_Chip09NIROverGreen,Turbidity,w857 / w558,"Chipman, J. W.; Olmanson, L.G.; Gitelson, A.A.; Remote sensing methods for lake management: A guide for resource managers and decision-makers. 2009."
+Turb_Dox02NIRoverRed,Turbidity,w857 / w658,"Doxaran, D., Froidefond, J.-M.; Castaing, P. ; A reflectance band ratio used to estimate suspended matter concentrations in sediment-dominated coastal waters, Remote Sens., 2002, 23, 5079-5085"
+Turb_Frohn09GreenPlusRedBothOverBlue,Turbidity,(w558 + w658) / w458,"Frohn, R. C., & Autrey, B. C. (2009). Water quality assessment in the Ohio River using new indices for turbidity and chlorophyll-a with Landsat-7 Imagery. Draft Internal Report, US Environmental Protection Agency."
+Turb_Harr92NIR,Turbidity,w857,"Schiebe F.R., Harrington J.A., Ritchie J.C. Remote-Sensing of Suspended Sediments闁炽儲鏁刪e Lake Chicot, Arkansas Project. Int. J. Remote Sens. 1992;13:1487闁?509"
+Turb_Lath91RedOverBlue,Turbidity,w658 / w458,"Lathrop, R. G., Jr., T. M. Lillesand, and B. S. Yandell, 1991. Testing the utility of simple multi-date Thematic Mapper calibration algorithms for monitoring turbid inland waters. International Journal of Remote Sensing"
+Turb_Moore80Red,Turbidity,w658,"Moore, G.K., Satellite remote sensing of water turbidity, Hydrological Sciences, 1980, 25, 4, 407-422"
diff --git a/src/gui/panels/report_generation_panel.py b/src/gui/panels/report_generation_panel.py
new file mode 100644
index 0000000..f272bae
--- /dev/null
+++ b/src/gui/panels/report_generation_panel.py
@@ -0,0 +1,305 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+ReportGenerationPanel - Word 分析报告生成面板
+"""
+
+import os
+import traceback
+from pathlib import Path
+from typing import Optional
+
+from PyQt5.QtCore import Qt, QThread, pyqtSignal
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
+ QLabel, QCheckBox, QPushButton, QLineEdit, QSpinBox,
+ QMessageBox, QFileDialog,
+)
+
+from src.gui.styles import ModernStylesheet
+
+
+class ReportGenerateThread(QThread):
+ """后台生成 Word 报告(避免阻塞 UI)。"""
+ finished_ok = pyqtSignal(str)
+ failed = pyqtSignal(str)
+ log_message = pyqtSignal(str, str)
+
+ def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, options: dict):
+ super().__init__()
+ self.work_dir = work_dir
+ self.output_dir = output_dir
+ self.report_title = report_title
+ self.options = options
+
+ def run(self):
+ try:
+ from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig
+
+ url = (self.options.get("ollama_url") or "").strip() or None
+ vision = (self.options.get("ollama_vision_model") or "").strip() or None
+ text = (self.options.get("ollama_text_model") or "").strip() or None
+ if self.options.get("text_same_as_vision"):
+ text = vision
+ timeout = self.options.get("ollama_timeout_s")
+ enable_ai = self.options.get("enable_ai_analysis")
+
+ ai_cfg = ReportGenerationConfig(
+ ollama_base_url=url,
+ ollama_vision_model=vision,
+ ollama_text_model=text,
+ ollama_timeout_s=int(timeout) if timeout is not None else None,
+ enable_ai_analysis=bool(enable_ai),
+ )
+ self.log_message.emit(
+ f"报告生成:工作目录={self.work_dir},AI={'开' if enable_ai else '关'},"
+ f"模型URL={url or '(环境变量 OLLAMA_URL)'}",
+ "info",
+ )
+ gen = WaterQualityReportGenerator(
+ work_dir=self.work_dir,
+ output_dir=self.output_dir,
+ ai_config=ai_cfg,
+ )
+ out_path = gen.generate_report(
+ work_dir=self.work_dir,
+ report_title=self.report_title or "水质参数反演分析报告",
+ )
+ self.finished_ok.emit(str(out_path))
+ except Exception as e:
+ self.failed.emit(f"{e}\n{traceback.format_exc()}")
+
+
+class ReportGenerationPanel(QWidget):
+ """Word 报告生成:工作目录、输出目录、Ollama URL/模型、是否启用 AI 等。"""
+
+ def __init__(self, main_window=None, parent=None):
+ super().__init__(parent)
+ self.main_window = main_window
+ self._report_thread = None
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+ layout.setContentsMargins(10, 10, 10, 10)
+ layout.setSpacing(10)
+
+ intro = QLabel(
+ "根据工作目录下的可视化结果(14_visualization 等)生成 Word 分析报告。"
+ "需已存在可视化图表;AI 分析通过 Ollama /api/chat 调用本地或远程服务。"
+ )
+ intro.setWordWrap(True)
+ intro.setStyleSheet(
+ f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};"
+ )
+ layout.addWidget(intro)
+
+ path_group = QGroupBox("路径")
+ path_form = QFormLayout()
+
+ wd_row = QHBoxLayout()
+ self.work_dir_edit = QLineEdit()
+ self.work_dir_edit.setPlaceholderText("选择流程工作目录(含 14_visualization)…")
+ wd_browse = QPushButton("浏览…")
+ wd_browse.clicked.connect(self.browse_work_dir)
+ sync_btn = QPushButton("同步主窗口工作目录")
+ sync_btn.clicked.connect(self.sync_work_dir_from_main)
+ wd_row.addWidget(self.work_dir_edit, 1)
+ wd_row.addWidget(wd_browse)
+ wd_row.addWidget(sync_btn)
+ path_form.addRow("工作目录:", wd_row)
+
+ out_row = QHBoxLayout()
+ self.output_dir_edit = QLineEdit()
+ self.output_dir_edit.setPlaceholderText("留空则保存到 工作目录/14_visualization")
+ out_browse = QPushButton("浏览…")
+ out_browse.clicked.connect(self.browse_output_dir)
+ out_row.addWidget(self.output_dir_edit, 1)
+ out_row.addWidget(out_browse)
+ path_form.addRow("报告输出目录:", out_row)
+
+ self.report_title_edit = QLineEdit()
+ self.report_title_edit.setText("水质参数反演分析报告")
+ path_form.addRow("报告标题:", self.report_title_edit)
+
+ path_group.setLayout(path_form)
+ layout.addWidget(path_group)
+
+ ai_group = QGroupBox("AI 分析(Ollama)")
+ ai_form = QFormLayout()
+
+ self.enable_ai_cb = QCheckBox("启用 AI 图表解读与综合总结")
+ self.enable_ai_cb.setChecked(
+ os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"}
+ )
+ ai_form.addRow(self.enable_ai_cb)
+
+ self.ollama_url_edit = QLineEdit()
+ self.ollama_url_edit.setText(
+ os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/")
+ )
+ ai_form.addRow("服务 URL:", self.ollama_url_edit)
+
+ self.vision_model_edit = QLineEdit()
+ self.vision_model_edit.setText(
+ os.environ.get("OLLAMA_VISION_MODEL", "qwen3-vl:8b")
+ )
+ ai_form.addRow("视觉模型:", self.vision_model_edit)
+
+ self.same_text_model_cb = QCheckBox("文本总结与视觉使用同一模型")
+ self.same_text_model_cb.setChecked(True)
+ ai_form.addRow(self.same_text_model_cb)
+
+ self.text_model_edit = QLineEdit()
+ self.text_model_edit.setText(
+ os.environ.get(
+ "OLLAMA_TEXT_MODEL",
+ self.vision_model_edit.text() or "qwen3-vl:8b"
+ )
+ )
+ self.text_model_edit.setEnabled(False)
+ self.same_text_model_cb.toggled.connect(self._on_same_text_toggled)
+ self.vision_model_edit.textChanged.connect(self._sync_text_model_if_linked)
+ ai_form.addRow("文本模型:", self.text_model_edit)
+
+ self.timeout_spin = QSpinBox()
+ self.timeout_spin.setRange(30, 3600)
+ self.timeout_spin.setSingleStep(30)
+ self.timeout_spin.setValue(int(os.environ.get("OLLAMA_TIMEOUT_S", "120")))
+ ai_form.addRow("请求超时(秒):", self.timeout_spin)
+
+ ai_group.setLayout(ai_form)
+ layout.addWidget(ai_group)
+
+ btn_row = QHBoxLayout()
+ self.generate_btn = QPushButton("生成 Word 报告")
+ self.generate_btn.setStyleSheet(
+ ModernStylesheet.get_button_stylesheet("success")
+ )
+ self.generate_btn.clicked.connect(self.on_generate_clicked)
+ btn_row.addWidget(self.generate_btn)
+ btn_row.addStretch()
+ layout.addLayout(btn_row)
+
+ layout.addStretch()
+ self.setLayout(layout)
+
+ def _on_same_text_toggled(self, checked: bool):
+ self.text_model_edit.setEnabled(not checked)
+ if checked:
+ self.text_model_edit.setText(self.vision_model_edit.text())
+
+ def _sync_text_model_if_linked(self, _t=None):
+ if self.same_text_model_cb.isChecked():
+ self.text_model_edit.blockSignals(True)
+ self.text_model_edit.setText(self.vision_model_edit.text())
+ self.text_model_edit.blockSignals(False)
+
+ def browse_work_dir(self):
+ d = QFileDialog.getExistingDirectory(self, "选择工作目录")
+ if d:
+ self.work_dir_edit.setText(d)
+
+ def browse_output_dir(self):
+ d = QFileDialog.getExistingDirectory(self, "选择报告输出目录")
+ if d:
+ self.output_dir_edit.setText(d)
+
+ def sync_work_dir_from_main(self):
+ mw = self.main_window
+ if mw is not None and getattr(mw, "work_dir", None):
+ self.work_dir_edit.setText(str(mw.work_dir))
+ else:
+ QMessageBox.information(self, "提示", "主窗口尚未设置工作目录。")
+
+ def set_work_dir(self, work_dir):
+ if work_dir:
+ self.work_dir_edit.setText(str(work_dir))
+
+ def get_config(self):
+ return {
+ "work_dir": self.work_dir_edit.text().strip() or None,
+ "output_dir": self.output_dir_edit.text().strip() or None,
+ "report_title": self.report_title_edit.text().strip() or "水质参数反演分析报告",
+ "ollama_url": self.ollama_url_edit.text().strip(),
+ "ollama_vision_model": self.vision_model_edit.text().strip(),
+ "ollama_text_model": self.text_model_edit.text().strip(),
+ "text_same_as_vision": self.same_text_model_cb.isChecked(),
+ "ollama_timeout_s": self.timeout_spin.value(),
+ "enable_ai_analysis": self.enable_ai_cb.isChecked(),
+ }
+
+ def set_config(self, config):
+ if not config:
+ return
+ if config.get("work_dir"):
+ self.work_dir_edit.setText(str(config["work_dir"]))
+ if "output_dir" in config:
+ self.output_dir_edit.setText(str(config["output_dir"] or ""))
+ if config.get("report_title"):
+ self.report_title_edit.setText(str(config["report_title"]))
+ if config.get("ollama_url"):
+ self.ollama_url_edit.setText(str(config["ollama_url"]))
+ if config.get("ollama_vision_model"):
+ self.vision_model_edit.setText(str(config["ollama_vision_model"]))
+ if "text_same_as_vision" in config:
+ self.same_text_model_cb.setChecked(bool(config["text_same_as_vision"]))
+ if config.get("ollama_text_model"):
+ self.text_model_edit.setText(str(config["ollama_text_model"]))
+ if config.get("ollama_timeout_s") is not None:
+ self.timeout_spin.setValue(int(config["ollama_timeout_s"]))
+ if "enable_ai_analysis" in config:
+ self.enable_ai_cb.setChecked(bool(config["enable_ai_analysis"]))
+
+ def on_generate_clicked(self):
+ wd = self.work_dir_edit.text().strip()
+ if not wd or not os.path.isdir(wd):
+ QMessageBox.warning(self, "提示", "请选择有效的工作目录。")
+ return
+ viz = Path(wd) / "14_visualization"
+ if not viz.is_dir():
+ QMessageBox.warning(
+ self,
+ "提示",
+ f"未找到可视化目录:\n{viz}\n请先完成流程或生成可视化。",
+ )
+ return
+ if self._report_thread and self._report_thread.isRunning():
+ QMessageBox.information(self, "提示", "报告正在生成中,请稍候。")
+ return
+
+ out = self.output_dir_edit.text().strip() or None
+ title = self.report_title_edit.text().strip() or "水质参数反演分析报告"
+ opts = {
+ "ollama_url": self.ollama_url_edit.text().strip(),
+ "ollama_vision_model": self.vision_model_edit.text().strip(),
+ "ollama_text_model": self.text_model_edit.text().strip(),
+ "text_same_as_vision": self.same_text_model_cb.isChecked(),
+ "ollama_timeout_s": self.timeout_spin.value(),
+ "enable_ai_analysis": self.enable_ai_cb.isChecked(),
+ }
+ self.generate_btn.setEnabled(False)
+ self._report_thread = ReportGenerateThread(wd, out, title, opts)
+ self._report_thread.log_message.connect(self._forward_log, Qt.QueuedConnection)
+ self._report_thread.finished_ok.connect(self._on_report_ok, Qt.QueuedConnection)
+ self._report_thread.failed.connect(self._on_report_fail, Qt.QueuedConnection)
+ self._report_thread.finished.connect(
+ lambda: self.generate_btn.setEnabled(True), Qt.QueuedConnection
+ )
+ self._report_thread.start()
+ self._forward_log("已开始生成 Word 报告…", "info")
+
+ def _forward_log(self, msg: str, level: str):
+ mw = self.main_window
+ if mw is not None and hasattr(mw, "log_message"):
+ mw.log_message(msg, level)
+ else:
+ print(f"[{level}] {msg}")
+
+ def _on_report_ok(self, path: str):
+ QMessageBox.information(self, "完成", f"报告已生成:\n{path}")
+ self._forward_log(f"Word 报告已保存: {path}", "info")
+
+ def _on_report_fail(self, err: str):
+ QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}")
+ self._forward_log(err, "error")
diff --git a/src/gui/panels/step1_panel.py b/src/gui/panels/step1_panel.py
new file mode 100644
index 0000000..d9d046c
--- /dev/null
+++ b/src/gui/panels/step1_panel.py
@@ -0,0 +1,282 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step1 面板 - 水域掩膜生成
+"""
+
+import os
+
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel,
+ QDoubleSpinBox, QCheckBox, QPushButton, QFormLayout, QRadioButton,
+ QMessageBox,
+)
+from PyQt5.QtCore import Qt
+
+# 从公共组件库导入
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step1Panel(QWidget):
+ """1. 水域掩膜生成"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 标题
+
+
+ # 掩膜生成方式选择
+ method_group = QGroupBox("掩膜生成方式")
+ method_layout = QVBoxLayout()
+
+ # 使用现有掩膜文件
+ self.use_existing_radio = QRadioButton("使用现有掩膜文件")
+ self.use_existing_radio.setChecked(True)
+ method_layout.addWidget(self.use_existing_radio)
+
+ # 使用NDWI自动生成
+ self.use_ndwi_radio = QRadioButton("使用NDWI自动生成")
+ method_layout.addWidget(self.use_ndwi_radio)
+
+ # 应用QRadioButton样式(实心选中点)
+ radio_style = """
+ QRadioButton::indicator {
+ width: 16px;
+ height: 16px;
+ border-radius: 8px;
+ border: 2px solid #999;
+ }
+ QRadioButton::indicator:checked {
+ background-color: #0078D7;
+ border: 2px solid #0078D7;
+ }
+ QRadioButton::indicator:unchecked {
+ background-color: white;
+ border: 2px solid #999;
+ }
+ QRadioButton::indicator:hover {
+ border: 2px solid #0078D7;
+ }
+ """
+ self.use_existing_radio.setStyleSheet(radio_style)
+ self.use_ndwi_radio.setStyleSheet(radio_style)
+
+ method_group.setLayout(method_layout)
+ layout.addWidget(method_group)
+
+ # 掩膜文件选择
+ self.mask_file = FileSelectWidget(
+ "掩膜文件:",
+ "Shapefiles (*.shp);;Raster Files (*.dat *.tif);;All Files (*.*)"
+ )
+ layout.addWidget(self.mask_file)
+
+ # 影像文件选择(用于shp栅格化或NDWI生成)
+ self.img_file = FileSelectWidget(
+ "参考影像:",
+ "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
+ )
+ layout.addWidget(self.img_file)
+
+ # NDWI参数设置
+ self.ndwi_group = QGroupBox("NDWI参数设置")
+ ndwi_layout = QVBoxLayout()
+
+ # NDWI阈值
+ threshold_layout = QHBoxLayout()
+ threshold_layout.addWidget(QLabel("NDWI阈值:"))
+ self.ndwi_threshold = QDoubleSpinBox()
+ self.ndwi_threshold.setRange(0.0, 1.0)
+ self.ndwi_threshold.setSingleStep(0.05)
+ self.ndwi_threshold.setValue(0.4)
+ self.ndwi_threshold.setDecimals(2)
+ threshold_layout.addWidget(self.ndwi_threshold)
+ threshold_layout.addStretch()
+ ndwi_layout.addLayout(threshold_layout)
+
+ self.ndwi_group.setLayout(ndwi_layout)
+ layout.addWidget(self.ndwi_group)
+
+ # 输出文件路径(使用save模式)
+ self.output_file = FileSelectWidget(
+ "输出掩膜:",
+ "Mask Files (*.dat *.tif);;All Files (*.*)",
+ mode="save"
+ )
+ self.output_file.line_edit.setPlaceholderText("water_mask.dat")
+ layout.addWidget(self.output_file)
+
+ # 提示信息 - 专业的 Info Alert 样式
+ hint = QLabel("💡 提示: 如果掩膜文件是Shapefile(.shp),需要提供参考影像用于栅格化;如果使用NDWI自动生成,只需要提供参考影像")
+ hint.setWordWrap(True) # 允许自动换行
+ hint.setStyleSheet("""
+ QLabel {
+ color: #0055D4;
+ font-size: 13px;
+ font-weight: bold;
+ background-color: #E8F4FF;
+ border: 2px solid #0055D4;
+ border-radius: 8px;
+ padding: 12px 16px;
+ margin: 8px 0px;
+ }
+ """)
+ layout.addWidget(hint)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_btn = QPushButton("独立运行此步骤")
+ self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_btn.clicked.connect(self.run_step)
+ layout.addWidget(self.run_btn)
+
+ # 连接信号
+ self.use_existing_radio.toggled.connect(self.update_ui_state)
+ self.use_ndwi_radio.toggled.connect(self.update_ui_state)
+
+ layout.addStretch()
+ self.setLayout(layout)
+
+ # 初始UI状态
+ self.update_ui_state()
+
+ def update_ui_state(self):
+ """根据选择的掩膜生成方式更新UI状态(使用显示/隐藏控制)"""
+ use_ndwi = self.use_ndwi_radio.isChecked()
+
+ # 动态显示/隐藏组件
+ if use_ndwi:
+ # 使用NDWI模式:隐藏掩膜文件,显示NDWI参数和输出掩膜
+ self.mask_file.setVisible(False)
+ self.ndwi_group.setVisible(True)
+ self.output_file.setVisible(True) # 显示输出掩膜路径
+
+ # 当切换到NDWI模式时,如果工作目录已设置,自动填充输出路径
+ if hasattr(self, 'work_dir') and self.work_dir:
+ self._auto_fill_output_path()
+ else:
+ # 使用现有掩膜模式:显示掩膜文件,隐藏NDWI参数和输出掩膜
+ self.mask_file.setVisible(True)
+ self.ndwi_group.setVisible(False)
+ self.output_file.setVisible(False) # 隐藏输出掩膜路径
+
+ # 参考影像在两种模式下都显示
+ self.img_file.setVisible(True)
+
+ def update_work_directory(self, work_dir):
+ """
+ 保存工作目录引用,用于后续自动填充路径
+
+ Args:
+ work_dir: 工作目录路径
+ """
+ if not work_dir:
+ return
+
+ # 保存工作目录引用
+ self.work_dir = work_dir
+
+ # 如果当前选中的是NDWI模式,立即填充输出路径
+ if self.use_ndwi_radio.isChecked():
+ self._auto_fill_output_path()
+
+ def _auto_fill_output_path(self):
+ """
+ 自动填充输出掩膜路径(仅在NDWI模式下)
+ 确保路径使用正斜杠,避免斜杠混用
+ """
+ if not hasattr(self, 'work_dir') or not self.work_dir:
+ return
+
+ # 生成输出掩膜的完整路径
+ output_dir = os.path.join(self.work_dir, "1_water_mask")
+ os.makedirs(output_dir, exist_ok=True) # 确保目录存在
+
+ # 统一使用正斜杠,避免 \ 和 / 混用
+ default_output_path = os.path.join(output_dir, "water_mask_out.dat").replace('\\', '/')
+ self.output_file.set_path(default_output_path)
+
+ def get_config(self):
+ """获取配置"""
+ use_ndwi = self.use_ndwi_radio.isChecked()
+
+ config = {
+ 'mask_path': None if use_ndwi else self.mask_file.get_path(),
+ 'use_ndwi': use_ndwi,
+ 'ndwi_threshold': self.ndwi_threshold.value()
+ }
+
+ # 参考影像路径(两种模式都可能需要)
+ img_path = self.img_file.get_path()
+ if img_path:
+ config['img_path'] = img_path
+
+ # 输出路径:仅在NDWI模式下有效
+ if use_ndwi:
+ output_path = self.output_file.get_path()
+ if output_path:
+ config['output_path'] = output_path
+ else:
+ # 使用现有掩膜时,不传递output_path,避免底层错误尝试保存文件
+ config['output_path'] = None
+
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'mask_path' in config:
+ self.mask_file.set_path(config['mask_path'])
+ if 'img_path' in config:
+ self.img_file.set_path(config['img_path'])
+ if 'output_path' in config:
+ self.output_file.set_path(config['output_path'])
+ if 'use_ndwi' in config:
+ if config['use_ndwi']:
+ self.use_ndwi_radio.setChecked(True)
+ else:
+ self.use_existing_radio.setChecked(True)
+ if 'ndwi_threshold' in config:
+ self.ndwi_threshold.setValue(config['ndwi_threshold'])
+
+ self.update_ui_state()
+
+ def run_step(self):
+ """独立运行步骤1"""
+ # 验证输入
+ if self.use_ndwi_radio.isChecked():
+ # NDWI模式:需要影像文件
+ img_path = self.img_file.get_path()
+ if not img_path:
+ QMessageBox.warning(self, "输入错误", "请选择参考影像文件!")
+ return
+ else:
+ # 现有掩膜模式:需要掩膜文件
+ mask_path = self.mask_file.get_path()
+ if not mask_path:
+ QMessageBox.warning(self, "输入错误", "请选择掩膜文件!")
+ return
+
+ # 如果是shp文件,还需要影像文件
+ if mask_path.lower().endswith('.shp'):
+ img_path = self.img_file.get_path()
+ if not img_path:
+ QMessageBox.warning(self, "输入错误", "当使用shp文件时,需要提供参考影像用于栅格化!")
+ return
+
+ # 获取父窗口并运行步骤
+ parent = self.parent()
+ while parent and not hasattr(parent, 'run_single_step'):
+ parent = parent.parent()
+
+ if parent and hasattr(parent, 'run_single_step'):
+ config = {'step1': self.get_config()}
+ parent.run_single_step("step1", config)
\ No newline at end of file
diff --git a/src/gui/panels/step2_panel.py b/src/gui/panels/step2_panel.py
new file mode 100644
index 0000000..4f271c3
--- /dev/null
+++ b/src/gui/panels/step2_panel.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step2 面板 - 耀斑区域识别
+"""
+
+import os
+
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout,
+ QDoubleSpinBox, QSpinBox, QComboBox, QCheckBox, QPushButton,
+ QMessageBox,
+)
+from PyQt5.QtCore import Qt
+
+# 从公共组件库导入
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step2Panel(QWidget):
+ """2. 耀斑区域识别"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.work_dir = None
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 标题
+
+
+ # 影像文件
+ self.img_file = FileSelectWidget(
+ "影像文件:",
+ "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
+ )
+ layout.addWidget(self.img_file)
+
+ # 水域掩膜文件(可选,用于独立运行)
+ self.water_mask_file = FileSelectWidget(
+ "水域掩膜:",
+ "Mask Files (*.dat *.tif);;All Files (*.*)"
+ )
+ self.water_mask_file.label.setText("水域掩膜:")
+ layout.addWidget(self.water_mask_file)
+
+ # 参数设置
+ params_group = QGroupBox("检测参数")
+ params_layout = QFormLayout()
+
+ # 耀斑波长
+ self.glint_wave = QDoubleSpinBox()
+ self.glint_wave.setRange(300, 1000)
+ self.glint_wave.setValue(750.0)
+ self.glint_wave.setSuffix(" nm")
+ params_layout.addRow("耀斑检测波长:", self.glint_wave)
+
+ # 检测方法
+ self.method = QComboBox()
+ self.method.addItem("Otsu 阈值法", "otsu")
+ self.method.addItem("Z-Score 方法", "zscore")
+ self.method.addItem("百分位数法", "percentile")
+ self.method.addItem("IQR 四分位距法", "iqr")
+ self.method.addItem("自适应阈值法", "adaptive")
+ self.method.addItem("多波段综合法", "multi_band")
+ params_layout.addRow("检测方法:", self.method)
+
+ # 最大连通域面积
+ self.max_area = QSpinBox()
+ self.max_area.setRange(0, 100000)
+ self.max_area.setValue(50)
+ self.max_area.setSpecialValueText("不过滤")
+ params_layout.addRow("最大连通域面积:", self.max_area)
+
+ # 岸边缓冲区
+ self.buffer_size = QSpinBox()
+ self.buffer_size.setRange(0, 200)
+ self.buffer_size.setValue(10)
+ self.buffer_size.setSpecialValueText("不设置")
+ params_layout.addRow("岸边缓冲区大小:", self.buffer_size)
+
+ params_group.setLayout(params_layout)
+ layout.addWidget(params_group)
+
+ # 输出文件路径
+ self.output_file = FileSelectWidget(
+ "输出耀斑掩膜:",
+ "Mask Files (*.dat *.tif);;All Files (*.*)"
+ )
+ self.output_file.line_edit.setPlaceholderText("")
+ layout.addWidget(self.output_file)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_btn = QPushButton("独立运行此步骤")
+ self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_btn.clicked.connect(self.run_step)
+ layout.addWidget(self.run_btn)
+
+ layout.addStretch()
+ self.setLayout(layout)
+ # 信号连接:影像文件路径变化时动态更新波段范围
+ def get_config(self):
+ """获取配置"""
+ config = {
+ 'img_path': self.img_file.get_path(),
+ 'glint_wave': self.glint_wave.value(),
+ 'method': self.method.currentData(), # 使用 currentData() 获取英文ID
+ }
+ if self.max_area.value() > 0:
+ config['max_area'] = self.max_area.value()
+ if self.buffer_size.value() > 0:
+ config['buffer_size'] = self.buffer_size.value()
+ # 添加水域掩膜路径(用于独立运行)
+ water_mask_path = self.water_mask_file.get_path()
+ if water_mask_path:
+ config['water_mask_path'] = water_mask_path
+ # 添加输出路径
+ output_path = self.output_file.get_path()
+ if output_path:
+ config['output_path'] = output_path
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'img_path' in config:
+ self.img_file.set_path(config['img_path'])
+ if 'glint_wave' in config:
+ self.glint_wave.setValue(config['glint_wave'])
+ if 'method' in config:
+ idx = self.method.findData(config['method']) # 使用 findData()
+ if idx >= 0:
+ self.method.setCurrentIndex(idx)
+ if 'max_area' in config:
+ self.max_area.setValue(config['max_area'])
+ if 'buffer_size' in config:
+ self.buffer_size.setValue(config['buffer_size'])
+ if 'water_mask_path' in config:
+ self.water_mask_file.set_path(config['water_mask_path'])
+ if 'output_path' in config:
+ self.output_file.set_path(config['output_path'])
+
+ def update_from_config(self, work_dir=None, pipeline=None):
+ """
+ 从全局配置/Pipeline 或 Step1Panel 自动填充路径,实现上下游数据流转
+
+ Args:
+ work_dir: 工作目录路径
+ pipeline: Pipeline 实例,用于获取步骤1生成的水域掩膜路径
+ """
+ # 保存工作目录引用
+ if work_dir:
+ self.work_dir = work_dir
+ elif hasattr(self, 'work_dir') and self.work_dir:
+ pass # 保持现有工作目录
+ else:
+ self.work_dir = None
+
+ # 1. 尝试从 Pipeline 获取
+ mask_path = None
+ if pipeline and hasattr(pipeline, 'water_mask_path') and pipeline.water_mask_path:
+ mask_path = pipeline.water_mask_path
+
+ # 2. 如果 Pipeline 中没有,则尝试直接从 Step1 界面读取(关键修复)
+ main_window = self.window()
+ if not mask_path and hasattr(main_window, 'step1_panel'):
+ if main_window.step1_panel.use_ndwi_radio.isChecked():
+ # NDWI模式,读取输出框的路径
+ mask_path = main_window.step1_panel.output_file.get_path()
+ else:
+ # 导入现有模式,读取输入框的路径
+ mask_path = main_window.step1_panel.mask_file.get_path()
+
+ # 填充获取到的路径
+ if mask_path:
+ self.water_mask_file.set_path(mask_path)
+
+ # 3. 自动填充输出路径(基于工作目录)
+ if self.work_dir:
+ # 生成输出耀斑掩膜的标准路径:workspace/2_glint_mask/glint_mask_out.dat
+ output_dir = os.path.join(self.work_dir, "2_glint_mask")
+ os.makedirs(output_dir, exist_ok=True)
+ default_output_path = os.path.join(output_dir, "glint_mask_out.dat").replace('\\', '/')
+ self.output_file.set_path(default_output_path)
+ else:
+ # 没有工作目录时,清空输出路径
+ self.output_file.set_path("")
+
+ def run_step(self):
+ """独立运行步骤2"""
+ # 验证输入
+ img_path = self.img_file.get_path()
+ if not img_path:
+ QMessageBox.warning(self, "输入错误", "请选择影像文件!")
+ return
+
+ # 获取主窗口并运行步骤
+ main_window = self.window()
+ if hasattr(main_window, 'run_single_step'):
+ config = {'step2': self.get_config()}
+ main_window.run_single_step('step2', config)
\ No newline at end of file
diff --git a/src/gui/panels/step3_panel.py b/src/gui/panels/step3_panel.py
new file mode 100644
index 0000000..e782018
--- /dev/null
+++ b/src/gui/panels/step3_panel.py
@@ -0,0 +1,450 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step3 面板 - 耀斑去除
+"""
+
+import os
+
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout,
+ QDoubleSpinBox, QSpinBox, QComboBox, QCheckBox, QPushButton,
+ QLabel, QLineEdit, QMessageBox,
+)
+from PyQt5.QtCore import Qt
+
+# 从公共组件库导入
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step3Panel(QWidget):
+ """步骤3:耀斑去除"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 标题
+
+
+ # 影像文件
+ self.img_file = FileSelectWidget(
+ "影像文件:",
+ "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
+ )
+ layout.addWidget(self.img_file)
+
+ # 水域掩膜/边界:完整流程可由步骤1自动生成;独立单步运行时须手动指定
+ self.water_mask_file = FileSelectWidget(
+ "水域掩膜/边界:",
+ "Mask/Boundary (*.dat *.tif *.shp);;All Files (*.*)"
+ )
+ layout.addWidget(self.water_mask_file)
+ step3_mask_hint = QLabel(
+ "提示:独立运行本步骤时必须选择水域掩膜或边界(与影像同区域的 .dat/.tif 掩膜,或 .shp 矢量)。"
+ )
+ step3_mask_hint.setWordWrap(True)
+ step3_mask_hint.setStyleSheet("color: #666; font-size: 10px;")
+ layout.addWidget(step3_mask_hint)
+
+ # 方法选择
+ method_group = QGroupBox("去耀斑方法")
+ method_layout = QVBoxLayout()
+
+ self.method = QComboBox()
+ for text, data in [('Goodman方法', 'goodman'), ('Kutser方法', 'kutser'),
+ ('Hedley方法', 'hedley'), ('SUGAR算法', 'sugar')]:
+ self.method.addItem(text, data)
+ self.method.currentIndexChanged.connect(self._on_method_changed)
+ method_layout.addWidget(self.method)
+
+ method_group.setLayout(method_layout)
+ layout.addWidget(method_group)
+
+ # Goodman参数组
+ self.goodman_group = QGroupBox("Goodman方法参数")
+ goodman_layout = QFormLayout()
+
+ self.nir_lower = QSpinBox()
+ self.nir_lower.setRange(0, 200)
+ self.nir_lower.setValue(65)
+ goodman_layout.addRow("NIR下波段索引:", self.nir_lower)
+
+ self.nir_upper = QSpinBox()
+ self.nir_upper.setRange(0, 200)
+ self.nir_upper.setValue(91)
+ goodman_layout.addRow("NIR上波段索引:", self.nir_upper)
+
+ self.goodman_a = QDoubleSpinBox()
+ self.goodman_a.setDecimals(6)
+ self.goodman_a.setRange(0, 1)
+ self.goodman_a.setValue(0.000019)
+ goodman_layout.addRow("参数A:", self.goodman_a)
+
+ self.goodman_b = QDoubleSpinBox()
+ self.goodman_b.setDecimals(2)
+ self.goodman_b.setRange(0, 1)
+ self.goodman_b.setValue(0.1)
+ goodman_layout.addRow("参数B:", self.goodman_b)
+
+ self.goodman_group.setLayout(goodman_layout)
+ layout.addWidget(self.goodman_group)
+
+ # Kutser参数组
+ self.kutser_group = QGroupBox("Kutser方法参数")
+ kutser_layout = QFormLayout()
+
+ self.oxy_band = QSpinBox()
+ self.oxy_band.setRange(0, 200)
+ self.oxy_band.setValue(8)
+ kutser_layout.addRow("氧吸收波段索引:", self.oxy_band)
+
+ self.lower_oxy = QDoubleSpinBox()
+ self.lower_oxy.setDecimals(2)
+ self.lower_oxy.setRange(0, 1000)
+ self.lower_oxy.setValue(756.54)
+ kutser_layout.addRow("下氧吸收波长(nm):", self.lower_oxy)
+
+ self.upper_oxy = QDoubleSpinBox()
+ self.upper_oxy.setDecimals(2)
+ self.upper_oxy.setRange(0, 1000)
+ self.upper_oxy.setValue(766.54)
+ kutser_layout.addRow("上氧吸收波长(nm):", self.upper_oxy)
+
+ self.nir_band = QSpinBox()
+ self.nir_band.setRange(0, 200)
+ self.nir_band.setValue(65)
+ kutser_layout.addRow("NIR波段索引:", self.nir_band)
+
+ self.kutser_group.setLayout(kutser_layout)
+ self.kutser_group.setVisible(False)
+ layout.addWidget(self.kutser_group)
+
+ # Hedley参数组
+ self.hedley_group = QGroupBox("Hedley方法参数")
+ hedley_layout = QFormLayout()
+
+ self.hedley_nir_band = QSpinBox()
+ self.hedley_nir_band.setRange(0, 200)
+ self.hedley_nir_band.setValue(47)
+ hedley_layout.addRow("NIR波段索引:", self.hedley_nir_band)
+
+ self.hedley_group.setLayout(hedley_layout)
+ self.hedley_group.setVisible(False)
+ layout.addWidget(self.hedley_group)
+
+ # SUGAR参数组
+ self.sugar_group = QGroupBox("SUGAR方法参数")
+ sugar_layout = QFormLayout()
+
+ self.sugar_iter = QSpinBox()
+ self.sugar_iter.setRange(1, 20)
+ self.sugar_iter.setValue(3)
+ self.sugar_iter.setSpecialValueText("自动")
+ sugar_layout.addRow("迭代次数:", self.sugar_iter)
+
+ self.sugar_sigma = QDoubleSpinBox()
+ self.sugar_sigma.setDecimals(2)
+ self.sugar_sigma.setRange(0.1, 10)
+ self.sugar_sigma.setValue(1.0)
+ sugar_layout.addRow("LoG平滑σ:", self.sugar_sigma)
+
+ self.sugar_estimate_background = QCheckBox()
+ self.sugar_estimate_background.setChecked(True)
+ sugar_layout.addRow("估计背景光谱:", self.sugar_estimate_background)
+
+ self.sugar_glint_mask_method = QComboBox()
+ self.sugar_glint_mask_method.addItems(['cdf', 'otsu'])
+ self.sugar_glint_mask_method.setCurrentText('cdf')
+ sugar_layout.addRow("耀斑掩膜方法:", self.sugar_glint_mask_method)
+
+ self.sugar_termination_thresh = QDoubleSpinBox()
+ self.sugar_termination_thresh.setDecimals(2)
+ self.sugar_termination_thresh.setRange(1, 100)
+ self.sugar_termination_thresh.setValue(20.0)
+ sugar_layout.addRow("终止阈值:", self.sugar_termination_thresh)
+
+ self.sugar_bounds = QLineEdit()
+ self.sugar_bounds.setText("[(1, 2)]")
+ sugar_layout.addRow("优化边界:", self.sugar_bounds)
+
+ self.sugar_group.setLayout(sugar_layout)
+ self.sugar_group.setVisible(False)
+ layout.addWidget(self.sugar_group)
+
+ # 插值选项
+ interp_group = QGroupBox("0值像素插值")
+ interp_layout = QFormLayout()
+
+ self.interpolate_zeros = QCheckBox("启用插值")
+ interp_layout.addRow("", self.interpolate_zeros)
+
+ self.interp_method = QComboBox()
+ for text, data in [('最近邻插值', 'nearest'), ('双线性插值', 'bilinear'),
+ ('样条插值', 'spline'), ('克里金插值', 'kriging')]:
+ self.interp_method.addItem(text, data)
+ self.interp_method.setCurrentIndex(1) # 默认双线性插值
+ interp_layout.addRow("插值方法:", self.interp_method)
+
+ interp_group.setLayout(interp_layout)
+ layout.addWidget(interp_group)
+
+ # # 实测经纬度参考点
+ # self.ref_csv_file = FileSelectWidget(
+ # "实测经纬度CSV:",
+ # "CSV Files (*.csv);;All Files (*.*)"
+ # )
+ # self.ref_csv_file.line_edit.setPlaceholderText("可选:包含 Lon/Lat 列的 CSV 文件")
+ # layout.addWidget(self.ref_csv_file)
+
+ # 交互式预览按钮
+ # self.preview_btn = QPushButton("👁️ 打开交互式影像预览")
+ # self.preview_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('info'))
+ # self.preview_btn.clicked.connect(self.open_interactive_viewer)
+ # layout.addWidget(self.preview_btn)
+
+ # 输出文件路径
+ self.output_file = FileSelectWidget(
+ "输出影像:",
+ "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
+ )
+ self.output_file.line_edit.setPlaceholderText("deglint_image.dat")
+ layout.addWidget(self.output_file)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_btn = QPushButton("独立运行此步骤")
+ self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_btn.clicked.connect(self.run_step)
+ layout.addWidget(self.run_btn)
+
+ layout.addStretch()
+ self.setLayout(layout)
+ # 信号连接:影像文件路径变化时动态更新波段范围
+ self.img_file.line_edit.textChanged.connect(self._update_band_ranges)
+
+ def open_interactive_viewer(self):
+ """打开交互式影像预览"""
+ from src.gui.water_quality_gui import InteractiveViewerDialog
+ img_path = self.img_file.get_path()
+ if not img_path or not os.path.isfile(img_path):
+ QMessageBox.warning(self, "警告", "请先选择影像文件!")
+ return
+
+ water_mask = self.water_mask_file.get_path()
+
+ dialog = InteractiveViewerDialog(img_path, self)
+ if water_mask and os.path.isfile(water_mask):
+ dialog.load_water_mask(water_mask)
+ dialog.exec_()
+
+ def _update_band_ranges(self, file_path):
+ """根据选择的影像动态限制波段索引的输入范围"""
+ from osgeo import gdal
+
+ if not file_path or not os.path.isfile(file_path):
+ return
+
+ try:
+ dataset = gdal.Open(file_path)
+ if dataset is None:
+ return
+ raster_count = dataset.RasterCount
+ max_band = max(0, raster_count - 1)
+ self.nir_lower.setMaximum(max_band)
+ self.nir_upper.setMaximum(max_band)
+ self.oxy_band.setMaximum(max_band)
+ self.nir_band.setMaximum(max_band)
+ self.hedley_nir_band.setMaximum(max_band)
+ dataset = None
+ except Exception:
+ pass
+
+ def update_from_config(self, work_dir=None, pipeline=None):
+ """
+ 从 Step1Panel 自动填充水域掩膜路径,实现上下游数据流转
+
+ Args:
+ work_dir: 工作目录路径
+ pipeline: Pipeline 实例(未使用,保留接口兼容性)
+ """
+ # 保存工作目录引用
+ if work_dir:
+ self.work_dir = work_dir
+ elif hasattr(self, 'work_dir') and self.work_dir:
+ pass # 保持现有工作目录
+ else:
+ self.work_dir = None
+
+ # 从 Step1 界面读取水域掩膜路径
+ main_window = self.window()
+ if hasattr(main_window, 'step1_panel'):
+ if main_window.step1_panel.use_ndwi_radio.isChecked():
+ # NDWI模式,读取输出框的路径
+ mask_path = main_window.step1_panel.output_file.get_path()
+ else:
+ # 导入现有模式,读取输入框的路径
+ mask_path = main_window.step1_panel.mask_file.get_path()
+
+ if mask_path:
+ self.water_mask_file.set_path(mask_path)
+
+ # 自动填充输出路径(基于工作目录)
+ if self.work_dir:
+ output_dir = os.path.join(self.work_dir, "3_deglint")
+ os.makedirs(output_dir, exist_ok=True)
+ default_output_path = os.path.join(output_dir, "deglint_image.dat").replace('\\', '/')
+ self.output_file.set_path(default_output_path)
+ else:
+ self.output_file.set_path("")
+
+ def _on_method_changed(self, index):
+ """方法改变时更新参数显示"""
+ method_id = self.method.currentData()
+ self.goodman_group.setVisible(method_id == 'goodman')
+ self.kutser_group.setVisible(method_id == 'kutser')
+ self.hedley_group.setVisible(method_id == 'hedley')
+ self.sugar_group.setVisible(method_id == 'sugar')
+
+ def get_config(self):
+ """获取配置"""
+ config = {
+ 'img_path': self.img_file.get_path(),
+ 'method': self.method.currentData(), # 使用 currentData() 获取英文ID
+ 'enabled': self.enable_checkbox.isChecked(),
+ 'interpolate_zeros': self.interpolate_zeros.isChecked(),
+ 'interpolation_method': self.interp_method.currentData(), # 使用 currentData()
+ }
+ water_mask_path = self.water_mask_file.get_path()
+ if water_mask_path:
+ config['water_mask'] = water_mask_path
+ output_path = self.output_file.get_path()
+ if output_path:
+ config['output_path'] = output_path
+
+ method = self.method.currentData() # 使用 currentData()
+
+ if method == 'goodman':
+ config['nir_lower'] = self.nir_lower.value()
+ config['nir_upper'] = self.nir_upper.value()
+ config['goodman_A'] = self.goodman_a.value()
+ config['goodman_B'] = self.goodman_b.value()
+
+ elif method == 'kutser':
+ config['oxy_band'] = self.oxy_band.value()
+ config['lower_oxy'] = self.lower_oxy.value()
+ config['upper_oxy'] = self.upper_oxy.value()
+ config['nir_band'] = self.nir_band.value()
+
+ elif method == 'hedley':
+ config['hedley_nir_band'] = self.hedley_nir_band.value()
+
+ elif method == 'sugar':
+ config['sugar_iter'] = self.sugar_iter.value() if self.sugar_iter.value() > 0 else None
+ config['sugar_sigma'] = self.sugar_sigma.value()
+ config['sugar_estimate_background'] = self.sugar_estimate_background.isChecked()
+ config['sugar_glint_mask_method'] = self.sugar_glint_mask_method.currentData()
+ config['sugar_termination_thresh'] = self.sugar_termination_thresh.value()
+ # 解析bounds字符串
+ try:
+ import ast
+ config['sugar_bounds'] = ast.literal_eval(self.sugar_bounds.text())
+ except:
+ config['sugar_bounds'] = [(1, 2)] # 默认值
+
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'img_path' in config:
+ self.img_file.set_path(config['img_path'])
+ if 'water_mask' in config:
+ self.water_mask_file.set_path(config['water_mask'])
+ if 'output_path' in config:
+ self.output_file.set_path(config['output_path'])
+ if 'reference_csv' in config:
+ self.ref_csv_file.set_path(config['reference_csv'])
+ if 'method' in config:
+ idx = self.method.findData(config['method']) # 使用 findData()
+ if idx >= 0:
+ self.method.setCurrentIndex(idx)
+ if 'enabled' in config:
+ self.enable_checkbox.setChecked(config['enabled'])
+ if 'interpolate_zeros' in config:
+ self.interpolate_zeros.setChecked(config['interpolate_zeros'])
+ if 'interpolation_method' in config:
+ idx = self.interp_method.findData(config['interpolation_method']) # 使用 findData()
+ if idx >= 0:
+ self.interp_method.setCurrentIndex(idx)
+
+ # Goodman参数
+ if 'nir_lower' in config:
+ self.nir_lower.setValue(config['nir_lower'])
+ if 'nir_upper' in config:
+ self.nir_upper.setValue(config['nir_upper'])
+ if 'goodman_A' in config:
+ self.goodman_a.setValue(config['goodman_A'])
+ if 'goodman_B' in config:
+ self.goodman_b.setValue(config['goodman_B'])
+
+ # Kutser参数
+ if 'oxy_band' in config:
+ self.oxy_band.setValue(config['oxy_band'])
+ if 'lower_oxy' in config:
+ self.lower_oxy.setValue(config['lower_oxy'])
+ if 'upper_oxy' in config:
+ self.upper_oxy.setValue(config['upper_oxy'])
+ if 'nir_band' in config:
+ self.nir_band.setValue(config['nir_band'])
+
+ # Hedley参数
+ if 'hedley_nir_band' in config:
+ self.hedley_nir_band.setValue(config['hedley_nir_band'])
+
+ # SUGAR参数
+ if 'sugar_iter' in config:
+ self.sugar_iter.setValue(config['sugar_iter'] if config['sugar_iter'] is not None else 0)
+ if 'sugar_sigma' in config:
+ self.sugar_sigma.setValue(config['sugar_sigma'])
+ if 'sugar_estimate_background' in config:
+ self.sugar_estimate_background.setChecked(config['sugar_estimate_background'])
+ if 'sugar_glint_mask_method' in config:
+ idx = self.sugar_glint_mask_method.findData(config['sugar_glint_mask_method']) # 使用 findData()
+ if idx >= 0:
+ self.sugar_glint_mask_method.setCurrentIndex(idx)
+ if 'sugar_termination_thresh' in config:
+ self.sugar_termination_thresh.setValue(config['sugar_termination_thresh'])
+ if 'sugar_bounds' in config:
+ self.sugar_bounds.setText(str(config['sugar_bounds']))
+
+ def run_step(self):
+ """独立运行步骤3"""
+ # 验证输入
+ img_path = self.img_file.get_path()
+ if not img_path:
+ QMessageBox.warning(self, "输入错误", "请选择影像文件!")
+ return
+ if self.enable_checkbox.isChecked():
+ water_mask_path = self.water_mask_file.get_path()
+ if not water_mask_path:
+ QMessageBox.warning(
+ self,
+ "输入错误",
+ "独立运行耀斑去除时,必须选择水域掩膜或边界文件。\n\n"
+ "请提供与当前影像空间一致的水域栅格掩膜(.dat/.tif),或水域矢量边界(.shp)。\n"
+ "若刚跑过完整流程,可使用步骤1生成的水域掩膜文件。",
+ )
+ return
+
+ # 获取主窗口并运行步骤
+ main_window = self.window()
+ if hasattr(main_window, 'run_single_step'):
+ config = {'step3': self.get_config()}
+ main_window.run_single_step('step3', config)
\ No newline at end of file
diff --git a/src/gui/panels/step4_panel.py b/src/gui/panels/step4_panel.py
new file mode 100644
index 0000000..bb6996c
--- /dev/null
+++ b/src/gui/panels/step4_panel.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step4 面板 - 数据预处理
+"""
+
+import os
+
+import pandas as pd
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QHBoxLayout, QLabel,
+ QSpinBox, QPushButton, QCheckBox, QTableView,
+ QAbstractItemView, QHeaderView, QMessageBox,
+)
+from PyQt5.QtCore import Qt
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step4Panel(QWidget):
+ """步骤4:数据预处理"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 标题
+
+ # CSV文件
+ self.csv_file = FileSelectWidget(
+ "水质参数文件:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ layout.addWidget(self.csv_file)
+
+ hint = QLabel("提示: 处理CSV文件,筛选剔除异常值")
+ hint.setStyleSheet("color: #666; font-size: 10px;")
+ layout.addWidget(hint)
+
+ preview_group = QGroupBox("CSV数据预览")
+ preview_layout = QVBoxLayout()
+
+ controls_layout = QHBoxLayout()
+ controls_layout.addWidget(QLabel("预览行数:"))
+ self.preview_rows_spin = QSpinBox()
+ self.preview_rows_spin.setRange(1, 200)
+ self.preview_rows_spin.setValue(10)
+ controls_layout.addWidget(self.preview_rows_spin)
+ self.preview_btn = QPushButton("刷新预览")
+ self.preview_btn.clicked.connect(self.load_csv_preview)
+ controls_layout.addWidget(self.preview_btn)
+ controls_layout.addStretch()
+
+ self.preview_table = QTableView()
+ self.preview_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
+ self.preview_table.setSelectionBehavior(QAbstractItemView.SelectRows)
+ self.preview_table.setSelectionMode(QAbstractItemView.SingleSelection)
+ self.preview_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
+ self.preview_table.verticalHeader().setVisible(False)
+ self.preview_table.setMinimumHeight(200)
+
+ self.preview_status_label = QLabel("请选择CSV文件并点击刷新预览")
+ self.preview_status_label.setStyleSheet("color: #666; font-size: 11px;")
+
+ preview_layout.addLayout(controls_layout)
+ preview_layout.addWidget(self.preview_table)
+ preview_layout.addWidget(self.preview_status_label)
+ preview_group.setLayout(preview_layout)
+ layout.addWidget(preview_group)
+
+ # 输出文件路径
+ self.output_file = FileSelectWidget(
+ "输出处理后CSV:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ self.output_file.line_edit.setPlaceholderText("processed_data.csv")
+ layout.addWidget(self.output_file)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_btn = QPushButton("独立运行此步骤")
+ self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_btn.clicked.connect(self.run_step)
+ layout.addWidget(self.run_btn)
+
+ layout.addStretch()
+ self.setLayout(layout)
+ self.reset_preview()
+
+ def get_config(self):
+ """获取配置"""
+ config = {
+ 'csv_path': self.csv_file.get_path(),
+ }
+ output_path = self.output_file.get_path()
+ if output_path:
+ config['output_path'] = output_path
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'csv_path' in config:
+ self.csv_file.set_path(config['csv_path'])
+ self.load_csv_preview()
+ if 'output_path' in config:
+ self.output_file.set_path(config['output_path'])
+
+ def update_from_config(self, work_dir=None, pipeline=None):
+ """从全局配置自动填充输出路径
+
+ Args:
+ work_dir: 工作目录路径
+ pipeline: Pipeline 实例(未使用,保留接口兼容性)
+ """
+ if work_dir:
+ self.work_dir = work_dir
+ elif hasattr(self, 'work_dir') and self.work_dir:
+ pass
+ else:
+ self.work_dir = None
+
+ if self.work_dir:
+ output_dir = os.path.join(self.work_dir, "4_processed_data")
+ os.makedirs(output_dir, exist_ok=True)
+ default_output_path = os.path.join(output_dir, "processed_data.csv").replace('\\', '/')
+ self.output_file.set_path(default_output_path)
+ else:
+ self.output_file.set_path("")
+
+ def run_step(self):
+ """独立运行步骤4"""
+ # 验证输入
+ csv_path = self.csv_file.get_path()
+ if not csv_path:
+ QMessageBox.warning(self, "输入错误", "请选择水质参数文件!")
+ return
+
+ # 获取主窗口并运行步骤
+ main_window = self.window()
+ if hasattr(main_window, 'run_single_step'):
+ config = {'step4': self.get_config()}
+ main_window.run_single_step('step4', config)
+
+ def reset_preview(self, message="请选择CSV文件并点击刷新预览"):
+ """重置预览表格"""
+ from src.gui.water_quality_gui import PandasTableModel
+ empty_model = PandasTableModel(pd.DataFrame())
+ self.preview_table.setModel(empty_model)
+ self.preview_status_label.setText(message)
+
+ def load_csv_preview(self):
+ """加载CSV预览数据"""
+ from src.gui.water_quality_gui import PandasTableModel
+ csv_path = self.csv_file.get_path()
+ if not csv_path:
+ self.reset_preview("请先选择CSV文件")
+ return
+ if not os.path.exists(csv_path):
+ self.reset_preview("文件不存在,请检查路径")
+ return
+
+ try:
+ rows_to_preview = max(1, self.preview_rows_spin.value())
+ df = pd.read_csv(csv_path, nrows=rows_to_preview)
+ if df.empty:
+ self.reset_preview("CSV文件为空")
+ return
+
+ model = PandasTableModel(df)
+ self.preview_table.setModel(model)
+ self.preview_status_label.setText(
+ f"预览 {len(df)} 行,{len(df.columns)} 列(总行数可能更多)"
+ )
+ except Exception as exc:
+ self.reset_preview(f"加载失败: {exc}")
diff --git a/src/gui/panels/step5_5_panel.py b/src/gui/panels/step5_5_panel.py
new file mode 100644
index 0000000..770f52a
--- /dev/null
+++ b/src/gui/panels/step5_5_panel.py
@@ -0,0 +1,382 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step5_5 面板 - 水质指数计算
+"""
+
+import os
+from pathlib import Path
+from typing import Dict, List, Union
+
+import pandas as pd
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout,
+ QHBoxLayout, QLabel, QLineEdit, QComboBox, QCheckBox,
+ QPushButton, QMessageBox,
+)
+from PyQt5.QtCore import Qt
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step5_5Panel(QWidget):
+ """步骤5.5:水质指数计算"""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.index_checkboxes: Dict[str, QCheckBox] = {}
+ self.csv_columns = [] # 存储CSV文件列名
+ self.init_ui()
+
+ def init_ui(self):
+ main_layout = QVBoxLayout()
+
+ # 标题
+
+
+ # 数据文件选择
+ data_group = QGroupBox("数据文件")
+ data_layout = QVBoxLayout()
+
+ # 训练数据CSV文件选择
+ self.training_data_widget = FileSelectWidget("训练数据CSV文件:", "CSV Files (*.csv)")
+ data_layout.addWidget(self.training_data_widget)
+
+ # 公式CSV文件选择
+ self.formula_csv_widget = FileSelectWidget("公式CSV文件:", "CSV Files (*.csv)")
+ data_layout.addWidget(self.formula_csv_widget)
+
+ # 刷新公式按钮
+ refresh_layout = QHBoxLayout()
+ self.refresh_button = QPushButton("刷新公式列表")
+ self.refresh_button.clicked.connect(self.refresh_formulas)
+ refresh_layout.addWidget(self.refresh_button)
+ refresh_layout.addStretch()
+ data_layout.addLayout(refresh_layout)
+
+ data_group.setLayout(data_layout)
+ main_layout.addWidget(data_group)
+
+ # 公式选择区域
+ self.formula_group = QGroupBox("选择要计算的公式")
+ formula_outer_layout = QVBoxLayout()
+
+ # 按钮控制区域
+ button_layout = QHBoxLayout()
+ self.select_all_btn = QPushButton("全选")
+ self.select_all_btn.clicked.connect(self.select_all_formulas)
+ self.deselect_all_btn = QPushButton("清空")
+ self.deselect_all_btn.clicked.connect(self.deselect_all_formulas)
+ button_layout.addWidget(self.select_all_btn)
+ button_layout.addWidget(self.deselect_all_btn)
+ button_layout.addStretch()
+
+ formula_outer_layout.addLayout(button_layout)
+
+ # 公式勾选框网格布局
+ self.formula_layout = QGridLayout()
+ formula_outer_layout.addLayout(self.formula_layout)
+
+ self.formula_group.setLayout(formula_outer_layout)
+ main_layout.addWidget(self.formula_group)
+
+ # 输出文件设置
+ output_group = QGroupBox("输出设置")
+ output_layout = QVBoxLayout()
+
+ output_hbox = QHBoxLayout()
+ output_hbox.addWidget(QLabel("输出文件名:"))
+ self.output_filename = QLineEdit("water_quality_indices.csv")
+ output_hbox.addWidget(self.output_filename)
+ output_layout.addLayout(output_hbox)
+
+ output_group.setLayout(output_layout)
+ main_layout.addWidget(output_group)
+
+ # 启用选项
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ main_layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_button = QPushButton("独立运行此步骤")
+ self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_button.clicked.connect(self.run_step)
+ main_layout.addWidget(self.run_button)
+
+ # 公式编辑区域
+ formula_edit_group = QGroupBox("添加自定义公式")
+ formula_edit_layout = QFormLayout()
+
+ self.formula_name_edit = QLineEdit()
+
+ # 公式类别下拉选择框
+ self.formula_category_combo = QComboBox()
+ self.formula_category_combo.addItems([
+ "chlorophyll_a",
+ "Phycocyanin (BGA_PC)",
+ "Total Nitrogen (TN)",
+ "Total Phosphorus (TP)",
+ "Orthophosphate",
+ "COD",
+ "BOD",
+ "TOC",
+ "Dissolved Oxygen (DO)",
+ "E. coli",
+ "Total Coliforms",
+ "Turbidity",
+ "Total Suspended Solids (TSS)",
+ "Color",
+ "pH",
+ "Temperature",
+ "Conductivity",
+ "Total Dissolved Solids (TDS)"
+ ])
+ self.formula_category_combo.setEditable(True) # 允许用户输入自定义类别
+
+ self.formula_expression_edit = QLineEdit()
+ self.formula_reference_edit = QLineEdit()
+
+ formula_edit_layout.addRow("公式名称:", self.formula_name_edit)
+ formula_edit_layout.addRow("公式类别:", self.formula_category_combo)
+ formula_edit_layout.addRow("公式表达式:", self.formula_expression_edit)
+ formula_edit_layout.addRow("参考文献:", self.formula_reference_edit)
+
+ add_button = QPushButton("添加公式")
+ add_button.clicked.connect(self.add_custom_formula)
+ formula_edit_layout.addRow(add_button)
+
+ formula_edit_group.setLayout(formula_edit_layout)
+ main_layout.addWidget(formula_edit_group)
+
+ main_layout.addStretch()
+ self.setLayout(main_layout)
+
+ # 自动加载内置公式文件
+ formula_csv_path = (
+ Path(__file__).resolve().parent.parent / "model" / "waterindex.csv"
+ )
+ if formula_csv_path.is_file():
+ self.formula_csv_widget.set_path(str(formula_csv_path))
+ self.refresh_formulas()
+
+ def refresh_formulas(self):
+ """刷新公式列表"""
+ formula_csv_path = self.formula_csv_widget.get_path()
+ if not formula_csv_path or not os.path.exists(formula_csv_path):
+ QMessageBox.warning(self, "警告", "请先选择有效的公式CSV文件")
+ return
+
+ try:
+ # 清除现有的勾选框
+ for checkbox in self.index_checkboxes.values():
+ self.formula_layout.removeWidget(checkbox)
+ checkbox.deleteLater()
+ self.index_checkboxes.clear()
+
+ # 读取公式CSV文件
+ df = pd.read_csv(formula_csv_path)
+ if df.empty or 'Formula_Name' not in df.columns:
+ QMessageBox.warning(self, "警告", "公式CSV文件格式不正确")
+ return
+
+ # 获取所有公式名称(跳过第一行)
+ formula_names = df['Formula_Name'].tolist()[1:]
+
+ # 创建3列布局的勾选框
+ row, col = 0, 0
+ for formula_name in formula_names:
+ if pd.isna(formula_name) or not formula_name.strip():
+ continue
+
+ checkbox = QCheckBox(formula_name.strip())
+ checkbox.setChecked(True)
+ self.index_checkboxes[formula_name.strip()] = checkbox
+ self.formula_layout.addWidget(checkbox, row, col)
+
+ col += 1
+ if col >= 3: # 每行3列
+ col = 0
+ row += 1
+
+ except Exception as e:
+ QMessageBox.critical(self, "错误", f"读取公式文件失败: {str(e)}")
+
+ def add_custom_formula(self):
+ """添加自定义公式到公式CSV文件"""
+ formula_csv_path = self.formula_csv_widget.get_path()
+ if not formula_csv_path:
+ QMessageBox.warning(self, "警告", "请先选择公式CSV文件")
+ return
+
+ formula_name = self.formula_name_edit.text().strip()
+ formula_category = self.formula_category_combo.currentText().strip()
+ formula_expression = self.formula_expression_edit.text().strip()
+ formula_reference = self.formula_reference_edit.text().strip()
+
+ if not all([formula_name, formula_category, formula_expression]):
+ QMessageBox.warning(self, "警告", "请填写公式名称、类别和表达式")
+ return
+
+ try:
+ # 读取现有公式文件或创建新文件
+ if os.path.exists(formula_csv_path):
+ df = pd.read_csv(formula_csv_path)
+ else:
+ df = pd.DataFrame(columns=['Formula_Name', 'Category', 'Formula', 'Reference'])
+
+ # 添加新公式
+ new_row = pd.DataFrame({
+ 'Formula_Name': [formula_name],
+ 'Category': [formula_category],
+ 'Formula': [formula_expression],
+ 'Reference': [formula_reference]
+ })
+ df = pd.concat([df, new_row], ignore_index=True)
+
+ # 保存文件
+ df.to_csv(formula_csv_path, index=False, encoding='utf-8')
+
+ # 清空输入框
+ self.formula_name_edit.clear()
+ self.formula_category_combo.setCurrentIndex(0) # 重置到第一个选项
+ self.formula_expression_edit.clear()
+ self.formula_reference_edit.clear()
+
+ # 刷新公式列表
+ self.refresh_formulas()
+
+ QMessageBox.information(self, "成功", "公式添加成功")
+
+ except Exception as e:
+ QMessageBox.critical(self, "错误", f"添加公式失败: {str(e)}")
+
+ def get_config(self) -> Dict[str, Union[List[str], str, bool]]:
+ """获取配置"""
+ selected = [
+ name for name, checkbox in self.index_checkboxes.items()
+ if checkbox.isChecked()
+ ]
+ return {
+ 'training_spectra_path': self.training_data_widget.get_path() or None,
+ 'formula_csv_file': self.formula_csv_widget.get_path() or None,
+ 'formula_names': selected,
+ 'output_filename': self.output_filename.text().strip() or "water_quality_indices.csv",
+ 'enabled': self.enable_checkbox.isChecked()
+ }
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'training_spectra_path' in config:
+ self.training_data_widget.set_path(config['training_spectra_path'])
+
+ if 'formula_csv_file' in config:
+ self.formula_csv_widget.set_path(config['formula_csv_file'])
+ # 设置CSV路径后自动刷新公式信息
+ self.refresh_formulas()
+
+ if 'formula_names' in config:
+ selected_formulas = set(config['formula_names'])
+ for name, checkbox in self.index_checkboxes.items():
+ checkbox.setChecked(name in selected_formulas)
+
+ if 'output_filename' in config:
+ self.output_filename.setText(config['output_filename'])
+
+ if 'enabled' in config:
+ self.enable_checkbox.setChecked(config['enabled'])
+
+ def update_from_config(self, work_dir=None, pipeline=None):
+ """从全局配置自动填充训练数据和输出路径
+
+ Args:
+ work_dir: 工作目录路径
+ pipeline: Pipeline 实例(未使用,保留接口兼容性)
+ """
+ if work_dir:
+ self.work_dir = work_dir
+ elif hasattr(self, 'work_dir') and self.work_dir:
+ pass
+ else:
+ self.work_dir = None
+
+ # 1. 自动填入训练数据路径(从 Step5 的输出中获取)
+ # 优先级:直接 widget > pipeline.step_outputs 回退
+ main_window = self.window()
+ if hasattr(main_window, 'step5_panel'):
+ # 优先直接从 Step5 的输出 widget 读取(已运行的最新输出)
+ step5_output = main_window.step5_panel.output_file.get_path()
+ if step5_output:
+ self.training_data_widget.set_path(step5_output)
+ else:
+ # 退而求其次,使用 Step5 的输入 CSV
+ step5_csv = main_window.step5_panel.csv_file.get_path()
+ if step5_csv:
+ self.training_data_widget.set_path(step5_csv)
+
+ # 如果上述都没找到,尝试从 pipeline.step_outputs 回退
+ if not self.training_data_widget.get_path() and pipeline and hasattr(pipeline, 'step_outputs'):
+ step5_outputs = getattr(pipeline, 'step_outputs', {}).get('step5', {})
+ training_path = step5_outputs.get('training_spectra')
+ if training_path:
+ self.training_data_widget.set_path(training_path)
+
+ # 2. 自动填充输出文件名(通用逻辑,由 run_step 根据输入文件名动态覆盖)
+ # 核心方法只接受文件名,不接受完整路径。
+ # 保持默认值,run_step 会根据输入自动填入 _indices.csv 后缀
+
+ def is_enabled(self) -> bool:
+ return self.enable_checkbox.isChecked()
+
+ def select_all_formulas(self):
+ """全选所有公式"""
+ for checkbox in self.index_checkboxes.values():
+ checkbox.setChecked(True)
+
+ def deselect_all_formulas(self):
+ """清空所有公式"""
+ for checkbox in self.index_checkboxes.values():
+ checkbox.setChecked(False)
+
+ def run_step(self):
+ """独立运行步骤5.5:计算水质指数。
+
+ 动态根据输入 CSV 文件名生成输出文件名,自动填入 output_filename 文本框。
+ 例如:training_spectra.csv → training_spectra_indices.csv
+ sampling_spectra.csv → sampling_spectra_indices.csv
+ """
+ # 验证输入
+ training_csv_path = self.training_data_widget.get_path()
+ formula_csv_path = self.formula_csv_widget.get_path()
+
+ if not training_csv_path:
+ QMessageBox.warning(self, "输入验证失败", "请选择训练数据CSV文件")
+ return
+ if not formula_csv_path:
+ QMessageBox.warning(self, "输入验证失败", "请选择公式CSV文件")
+ return
+ if not os.path.exists(training_csv_path):
+ QMessageBox.warning(self, "输入验证失败", "训练数据CSV文件不存在")
+ return
+ if not os.path.exists(formula_csv_path):
+ QMessageBox.warning(self, "输入验证失败", "公式CSV文件不存在")
+ return
+
+ # 动态生成输出文件名:自动拼接 _indices 后缀
+ input_name = Path(training_csv_path).stem
+ dynamic_output = f"{input_name}_indices.csv"
+ self.output_filename.setText(dynamic_output)
+
+ # 获取配置
+ config = self.get_config()
+
+ # 调用GUI的run_single_step方法
+ parent = self.parent()
+ while parent and not hasattr(parent, 'run_single_step'):
+ parent = parent.parent()
+
+ if parent and hasattr(parent, 'run_single_step'):
+ parent.run_single_step('step5_5', {'step5_5': config})
+ else:
+ QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
diff --git a/src/gui/panels/step5_panel.py b/src/gui/panels/step5_panel.py
new file mode 100644
index 0000000..cae532f
--- /dev/null
+++ b/src/gui/panels/step5_panel.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step5 面板 - 光谱提取
+"""
+
+import os
+
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout, QLabel,
+ QSpinBox, QPushButton, QCheckBox, QMessageBox,
+)
+from PyQt5.QtGui import QFont
+from PyQt5.QtCore import Qt
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step5Panel(QWidget):
+ """步骤5:光谱提取"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 标题
+ title = QLabel("步骤5:训练样本光谱提取")
+ title.setFont(QFont("Arial", 12, QFont.Bold))
+ layout.addWidget(title)
+
+ # 去耀斑影像文件(用于独立运行)
+ self.deglint_img_file = FileSelectWidget(
+ "去耀斑影像:",
+ "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
+ )
+ layout.addWidget(self.deglint_img_file)
+
+ # 处理后的CSV文件(用于独立运行)
+ self.csv_file = FileSelectWidget(
+ "处理后CSV:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ layout.addWidget(self.csv_file)
+
+ # 水体掩膜文件(可选,用于独立运行)
+ self.water_mask_file = FileSelectWidget(
+ "水体掩膜:",
+ "Mask Files (*.dat *.tif);;All Files (*.*)"
+ )
+ self.water_mask_file.line_edit.setPlaceholderText("可选,如不选择则自动生成")
+ layout.addWidget(self.water_mask_file)
+
+ self.glint_mask_file = FileSelectWidget(
+ "耀斑掩膜:",
+ "Mask Files (*.dat *.tif);;All Files (*.*)"
+ )
+ layout.addWidget(self.glint_mask_file)
+ step5_glint_hint = QLabel(
+ "提示:独立运行本步骤时必须选择耀斑掩膜(通常为步骤2输出的 severe_glint_area.dat),用于在采样时避开耀斑像元。"
+ )
+ step5_glint_hint.setWordWrap(True)
+ step5_glint_hint.setStyleSheet("color: #666; font-size: 10px;")
+ layout.addWidget(step5_glint_hint)
+
+ # 参数设置
+ params_group = QGroupBox("提取参数")
+ params_layout = QFormLayout()
+
+ self.radius = QSpinBox()
+ self.radius.setRange(1, 50)
+ self.radius.setValue(5)
+ params_layout.addRow("采样半径(像素):", self.radius)
+
+ self.source_epsg = QSpinBox()
+ self.source_epsg.setRange(1000, 99999)
+ self.source_epsg.setValue(4326)
+ params_layout.addRow("源坐标系EPSG:", self.source_epsg)
+
+ params_group.setLayout(params_layout)
+ layout.addWidget(params_group)
+
+ # 输出文件路径
+ self.output_file = FileSelectWidget(
+ "输出训练数据:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ self.output_file.line_edit.setPlaceholderText("training_spectra.csv")
+ layout.addWidget(self.output_file)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_btn = QPushButton("独立运行此步骤")
+ self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_btn.clicked.connect(self.run_step)
+ layout.addWidget(self.run_btn)
+
+ layout.addStretch()
+ self.setLayout(layout)
+ # 信号连接:影像文件路径变化时动态更新波段范围
+
+ def get_config(self):
+ """获取配置"""
+ config = {
+ 'radius': self.radius.value(),
+ 'source_epsg': self.source_epsg.value(),
+ }
+ # 添加独立运行所需的文件路径
+ deglint_img_path = self.deglint_img_file.get_path()
+ if deglint_img_path:
+ config['deglint_img_path'] = deglint_img_path
+ csv_path = self.csv_file.get_path()
+ if csv_path:
+ config['csv_path'] = csv_path
+ water_mask_path = self.water_mask_file.get_path()
+ if water_mask_path:
+ config['boundary_path'] = water_mask_path
+ glint_mask_path = self.glint_mask_file.get_path()
+ if glint_mask_path:
+ config['glint_mask_path'] = glint_mask_path
+ # 注意:step5_extract_training_spectra 不接受 output_path / training_spectra_path
+ # 参数,输出路径由 pipeline 内部根据 training_spectra_dir 自动生成。
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'radius' in config:
+ self.radius.setValue(config['radius'])
+ if 'source_epsg' in config:
+ self.source_epsg.setValue(config['source_epsg'])
+ if 'deglint_img_path' in config:
+ self.deglint_img_file.set_path(config['deglint_img_path'])
+ if 'csv_path' in config:
+ self.csv_file.set_path(config['csv_path'])
+ if 'boundary_path' in config:
+ self.water_mask_file.set_path(config['boundary_path'])
+ if 'glint_mask_path' in config:
+ self.glint_mask_file.set_path(config['glint_mask_path'])
+
+ def update_from_config(self, work_dir=None, pipeline=None):
+ """从全局配置/Pipeline 或 Step1Panel 自动填充路径,实现上下游数据流转
+
+ Args:
+ work_dir: 工作目录路径
+ pipeline: Pipeline 实例,用于获取步骤1生成的水域掩膜路径
+ """
+ # 保存工作目录引用
+ if work_dir:
+ self.work_dir = work_dir
+ elif hasattr(self, 'work_dir') and self.work_dir:
+ pass
+ else:
+ self.work_dir = None
+
+ # 1. 尝试从 Pipeline 获取水体掩膜路径
+ mask_path = None
+ if pipeline and hasattr(pipeline, 'water_mask_path') and pipeline.water_mask_path:
+ mask_path = pipeline.water_mask_path
+
+ # 2. 如果 Pipeline 中没有,则尝试直接从 Step1 界面读取
+ main_window = self.window()
+ if not mask_path and hasattr(main_window, 'step1_panel'):
+ if main_window.step1_panel.use_ndwi_radio.isChecked():
+ mask_path = main_window.step1_panel.output_file.get_path()
+ else:
+ mask_path = main_window.step1_panel.mask_file.get_path()
+
+ # 填充水体掩膜路径
+ if mask_path:
+ self.water_mask_file.set_path(mask_path)
+
+ # 3. 尝试从 Step2 界面读取耀斑掩膜路径
+ main_window = self.window()
+ if hasattr(main_window, 'step2_panel'):
+ glint_path = main_window.step2_panel.output_file.get_path()
+ if glint_path:
+ self.glint_mask_file.set_path(glint_path)
+
+ # 4. 自动填充输出路径(基于工作目录)
+ if self.work_dir:
+ output_dir = os.path.join(self.work_dir, "5_training_spectra")
+ os.makedirs(output_dir, exist_ok=True)
+ default_output_path = os.path.join(output_dir, "training_spectra.csv").replace('\\', '/')
+ self.output_file.set_path(default_output_path)
+ else:
+ self.output_file.set_path("")
+
+ def run_step(self):
+ """独立运行步骤5"""
+ # 验证输入
+ deglint_img_path = self.deglint_img_file.get_path()
+ csv_path = self.csv_file.get_path()
+ if not deglint_img_path:
+ QMessageBox.warning(self, "输入错误", "请选择去耀斑影像文件!")
+ return
+ if not csv_path:
+ QMessageBox.warning(self, "输入错误", "请选择处理后的CSV文件!")
+ return
+ if not self.glint_mask_file.get_path():
+ QMessageBox.warning(
+ self,
+ "输入错误",
+ "独立运行光谱特征提取时,必须选择耀斑掩膜文件。\n\n"
+ "请提供与去耀斑影像对应的耀斑二值掩膜(一般为步骤2输出的 severe_glint_area.dat)。",
+ )
+ return
+
+ # 获取主窗口并运行步骤
+ main_window = self.window()
+ if hasattr(main_window, 'run_single_step'):
+ config = {'step5': self.get_config()}
+ main_window.run_single_step('step5', config)
diff --git a/src/gui/panels/step6_5_panel.py b/src/gui/panels/step6_5_panel.py
new file mode 100644
index 0000000..b15e71e
--- /dev/null
+++ b/src/gui/panels/step6_5_panel.py
@@ -0,0 +1,245 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step6_5 面板 - 非经验统计回归建模
+"""
+
+import os
+from pathlib import Path
+
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout,
+ QHBoxLayout, QLabel, QCheckBox, QSpinBox, QPushButton,
+ QFileDialog, QMessageBox,
+)
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step6_5Panel(QWidget):
+ """步骤6.5:非经验统计回归建模"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 标题
+
+
+ # 训练数据文件(用于独立运行)
+ self.training_csv_file = FileSelectWidget(
+ "训练数据CSV:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ layout.addWidget(self.training_csv_file)
+
+ # 参数设置
+ params_group = QGroupBox("模型参数")
+ params_layout = QFormLayout()
+
+ # 预处理方法
+ self.preproc_checkboxes = {}
+ preproc_group = QGroupBox("预处理方法 (可多选)")
+ preproc_layout = QVBoxLayout()
+ preproc_grid = QGridLayout()
+ preproc_methods = ['None', 'MMS', 'SS', 'SNV', 'MA', 'SG', 'MSC', 'D1', 'D2', 'DT', 'CT']
+
+ for i, method in enumerate(preproc_methods):
+ checkbox = QCheckBox(method)
+ checkbox.setChecked(True)
+ self.preproc_checkboxes[method] = checkbox
+ preproc_grid.addWidget(checkbox, i // 4, i % 4)
+
+ button_layout = QHBoxLayout()
+ select_all_btn = QPushButton("全选")
+ deselect_all_btn = QPushButton("全不选")
+ select_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, True))
+ deselect_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, False))
+ button_layout.addWidget(select_all_btn)
+ button_layout.addWidget(deselect_all_btn)
+ button_layout.addStretch()
+
+ preproc_layout.addLayout(preproc_grid)
+ preproc_layout.addLayout(button_layout)
+ preproc_group.setLayout(preproc_layout)
+ params_layout.addRow(preproc_group)
+
+ # 算法选择(可多选)
+ self.algorithm_inputs = {}
+ algorithms_widget = QWidget()
+ algorithms_layout = QVBoxLayout()
+ algorithms_layout.setContentsMargins(0, 0, 0, 0)
+ algorithms_layout.setSpacing(4)
+
+ algorithm_list = ['chl_a', 'nh3', 'mno4', 'tn', 'tp', 'tss']
+ for algorithm in algorithm_list:
+ row_widget = QWidget()
+ row_layout = QHBoxLayout()
+ row_layout.setContentsMargins(0, 0, 0, 0)
+ checkbox = QCheckBox(algorithm)
+ checkbox.setChecked(True)
+ spinbox = QSpinBox()
+ spinbox.setRange(0, 500)
+ spinbox.setValue(0)
+ spinbox.setMaximumWidth(90)
+ row_layout.addWidget(checkbox)
+ row_layout.addWidget(QLabel("对应值列索引:"))
+ row_layout.addWidget(spinbox)
+ row_layout.addStretch()
+ row_widget.setLayout(row_layout)
+ algorithms_layout.addWidget(row_widget)
+ self.algorithm_inputs[algorithm] = (checkbox, spinbox)
+
+ algorithms_widget.setLayout(algorithms_layout)
+ params_layout.addRow("非经验算法选择:", algorithms_widget)
+
+ # 光谱起始列
+ self.spectral_start_col = QSpinBox()
+ self.spectral_start_col.setRange(0, 100)
+ self.spectral_start_col.setValue(1)
+ params_layout.addRow("光谱起始列索引:", self.spectral_start_col)
+
+ # 窗口大小
+ self.window = QSpinBox()
+ self.window.setRange(1, 20)
+ self.window.setValue(5)
+ params_layout.addRow("窗口大小:", self.window)
+
+ params_group.setLayout(params_layout)
+ layout.addWidget(params_group)
+
+ # 输出文件路径
+ self.output_dir = FileSelectWidget(
+ "输出模型目录:",
+ "Directories;;All Files (*.*)"
+ )
+ self.output_dir.line_edit.setPlaceholderText("8_Regression_Modeling")
+ self.output_dir.browse_btn.clicked.disconnect()
+ self.output_dir.browse_btn.clicked.connect(self.browse_output_dir)
+ layout.addWidget(self.output_dir)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_button = QPushButton("独立运行此步骤")
+ self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_button.clicked.connect(self.run_step)
+ layout.addWidget(self.run_button)
+
+ layout.addStretch()
+ self.setLayout(layout)
+
+ def get_config(self):
+ """获取配置"""
+ selected_algorithms = [
+ name for name, (checkbox, _) in self.algorithm_inputs.items()
+ if checkbox.isChecked()
+ ]
+ if not selected_algorithms:
+ selected_algorithms = list(self.algorithm_inputs.keys())
+
+ value_cols = {
+ name: spinbox.value()
+ for name, (_, spinbox) in self.algorithm_inputs.items()
+ if name in selected_algorithms
+ }
+
+ preprocessing_methods = [
+ method for method, checkbox in self.preproc_checkboxes.items()
+ if checkbox.isChecked()
+ ] or ['None']
+
+ config = {
+ 'preprocessing_methods': preprocessing_methods,
+ 'algorithms': selected_algorithms,
+ 'value_cols': value_cols,
+ 'spectral_start_col': self.spectral_start_col.value(),
+ 'window': self.window.value(),
+ 'enabled': self.enable_checkbox.isChecked()
+ }
+
+ output_dir = self.output_dir.get_path()
+ if not output_dir:
+ main_window = self.parent().window()
+ if hasattr(main_window, 'work_dir') and main_window.work_dir:
+ output_dir = str(Path(main_window.work_dir) / "8_Regression_Modeling")
+ else:
+ output_dir = str(Path.cwd() / "8_Regression_Modeling")
+ config['output_dir'] = output_dir
+
+ training_csv_path = self.training_csv_file.get_path()
+ if training_csv_path:
+ config['csv_path'] = training_csv_path
+
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'preprocessing_methods' in config:
+ methods = config['preprocessing_methods']
+ for method, checkbox in self.preproc_checkboxes.items():
+ checkbox.setChecked(method in methods)
+
+ if 'algorithms' in config:
+ algorithm_values = config['algorithms']
+ for algorithm, (checkbox, spinbox) in self.algorithm_inputs.items():
+ checkbox.setChecked(algorithm in algorithm_values)
+
+ if 'value_cols' in config:
+ value_cols = config['value_cols']
+ if isinstance(value_cols, dict):
+ for algorithm, (_, spinbox) in self.algorithm_inputs.items():
+ if algorithm in value_cols:
+ spinbox.setValue(value_cols[algorithm])
+ else:
+ for _, spinbox in self.algorithm_inputs.values():
+ spinbox.setValue(value_cols)
+
+ if 'spectral_start_col' in config:
+ self.spectral_start_col.setValue(config['spectral_start_col'])
+
+ if 'window' in config:
+ self.window.setValue(config['window'])
+ if 'output_dir' in config:
+ self.output_dir.set_path(config['output_dir'])
+ if 'csv_path' in config:
+ self.training_csv_file.set_path(config['csv_path'])
+
+ def browse_output_dir(self):
+ """浏览输出目录"""
+ dir_path = QFileDialog.getExistingDirectory(self, "选择输出模型目录", "")
+ if dir_path:
+ self.output_dir.set_path(dir_path)
+
+ def run_step(self):
+ """独立运行步骤6.5"""
+ training_csv_path = self.training_csv_file.get_path()
+ if not training_csv_path:
+ QMessageBox.warning(self, "输入错误", "请选择训练数据CSV文件!")
+ return
+
+ if not os.path.exists(training_csv_path):
+ QMessageBox.warning(self, "输入错误", "训练数据CSV文件不存在!")
+ return
+
+ config = self.get_config()
+
+ parent = self.parent()
+ while parent and not hasattr(parent, 'run_single_step'):
+ parent = parent.parent()
+
+ if parent and hasattr(parent, 'run_single_step'):
+ parent.run_single_step('step6_5', {'step6_5': config})
+ else:
+ QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
+
+ def _toggle_checkboxes(self, checkboxes_dict, checked):
+ """统一设置预处理checkbox状态"""
+ for checkbox in checkboxes_dict.values():
+ checkbox.setChecked(checked)
diff --git a/src/gui/panels/step6_75_panel.py b/src/gui/panels/step6_75_panel.py
new file mode 100644
index 0000000..7498df0
--- /dev/null
+++ b/src/gui/panels/step6_75_panel.py
@@ -0,0 +1,327 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step6_75 面板 - 自定义回归分析
+"""
+
+import os
+from typing import Dict
+
+import pandas as pd
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout,
+ QHBoxLayout, QLabel, QLineEdit, QCheckBox, QPushButton,
+ QScrollArea, QMessageBox,
+)
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step6_75Panel(QWidget):
+ """步骤6.75:自定义回归分析"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.x_column_checkboxes: Dict[str, QCheckBox] = {}
+ self.y_column_checkboxes: Dict[str, QCheckBox] = {}
+ self.method_checkboxes: Dict[str, QCheckBox] = {}
+ self.csv_columns = []
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ hint = QLabel("指定自变量与因变量列,批量尝试不同回归方法")
+ hint.setStyleSheet("color: #666; font-size: 11px;")
+ layout.addWidget(hint)
+
+ # CSV文件选择
+ csv_group = QGroupBox("数据文件")
+ csv_layout = QVBoxLayout()
+
+ self.csv_file = FileSelectWidget(
+ "输入CSV文件:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ self.csv_file.line_edit.textChanged.connect(self.on_csv_file_changed)
+ csv_layout.addWidget(self.csv_file)
+
+ self.refresh_btn = QPushButton("刷新列信息")
+ self.refresh_btn.clicked.connect(self.refresh_csv_columns)
+ csv_layout.addWidget(self.refresh_btn)
+
+ csv_group.setLayout(csv_layout)
+ layout.addWidget(csv_group)
+
+ # 自变量选择
+ x_group = QGroupBox("自变量列选择 (可多选)")
+ x_layout = QVBoxLayout()
+
+ x_scroll = QScrollArea()
+ x_scroll.setWidgetResizable(True)
+ x_scroll.setMinimumHeight(250)
+ x_scroll.setMaximumHeight(350)
+
+ x_widget = QWidget()
+ self.x_columns_layout = QGridLayout()
+ x_widget.setLayout(self.x_columns_layout)
+
+ x_scroll.setWidget(x_widget)
+ x_layout.addWidget(x_scroll)
+
+ x_btn_layout = QHBoxLayout()
+ self.x_select_all = QPushButton("全选")
+ self.x_deselect_all = QPushButton("全不选")
+ self.x_select_all.clicked.connect(lambda: self.toggle_checkboxes(self.x_column_checkboxes, True))
+ self.x_deselect_all.clicked.connect(lambda: self.toggle_checkboxes(self.x_column_checkboxes, False))
+ x_btn_layout.addWidget(self.x_select_all)
+ x_btn_layout.addWidget(self.x_deselect_all)
+ x_btn_layout.addStretch()
+ x_layout.addLayout(x_btn_layout)
+
+ x_group.setLayout(x_layout)
+ layout.addWidget(x_group)
+
+ # 因变量选择
+ y_group = QGroupBox("因变量列选择 (可多选)")
+ y_layout = QVBoxLayout()
+
+ y_scroll = QScrollArea()
+ y_scroll.setWidgetResizable(True)
+ y_scroll.setMinimumHeight(200)
+ y_scroll.setMaximumHeight(300)
+
+ y_widget = QWidget()
+ self.y_columns_layout = QGridLayout()
+ y_widget.setLayout(self.y_columns_layout)
+
+ y_scroll.setWidget(y_widget)
+ y_layout.addWidget(y_scroll)
+
+ y_btn_layout = QHBoxLayout()
+ self.y_select_all = QPushButton("全选")
+ self.y_deselect_all = QPushButton("全不选")
+ self.y_select_all.clicked.connect(lambda: self.toggle_checkboxes(self.y_column_checkboxes, True))
+ self.y_deselect_all.clicked.connect(lambda: self.toggle_checkboxes(self.y_column_checkboxes, False))
+ y_btn_layout.addWidget(self.y_select_all)
+ y_btn_layout.addWidget(self.y_deselect_all)
+ y_btn_layout.addStretch()
+ y_layout.addLayout(y_btn_layout)
+
+ y_group.setLayout(y_layout)
+ layout.addWidget(y_group)
+
+ # 回归方法选择
+ method_group = QGroupBox("回归方法选择 (可多选)")
+ method_layout = QVBoxLayout()
+
+ method_grid = QGridLayout()
+ regression_methods = [
+ 'linear', 'exponential', 'power', 'logarithmic',
+ 'polynomial', 'hyperbolic', 'sigmoidal'
+ ]
+
+ for i, method in enumerate(regression_methods):
+ checkbox = QCheckBox(method)
+ if method in ['linear', 'exponential', 'power', 'logarithmic']:
+ checkbox.setChecked(True)
+ self.method_checkboxes[method] = checkbox
+ method_grid.addWidget(checkbox, i // 3, i % 3)
+
+ method_layout.addLayout(method_grid)
+
+ method_btn_layout = QHBoxLayout()
+ self.method_select_all = QPushButton("全选")
+ self.method_deselect_all = QPushButton("全不选")
+ self.method_select_all.clicked.connect(lambda: self.toggle_checkboxes(self.method_checkboxes, True))
+ self.method_deselect_all.clicked.connect(lambda: self.toggle_checkboxes(self.method_checkboxes, False))
+ method_btn_layout.addWidget(self.method_select_all)
+ method_btn_layout.addWidget(self.method_deselect_all)
+ method_btn_layout.addStretch()
+ method_layout.addLayout(method_btn_layout)
+
+ method_group.setLayout(method_layout)
+ layout.addWidget(method_group)
+
+ # 输出目录
+ output_group = QGroupBox("输出设置")
+ output_layout = QFormLayout()
+
+ self.output_dir = QLineEdit()
+ self.output_dir.setText("9_Custom_Regression_Modeling")
+ output_layout.addRow("输出目录名:", self.output_dir)
+
+ output_group.setLayout(output_layout)
+ layout.addWidget(output_group)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_button = QPushButton("独立运行此步骤")
+ self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_button.clicked.connect(self.run_step)
+ layout.addWidget(self.run_button)
+
+ layout.addStretch()
+ self.setLayout(layout)
+
+ def toggle_checkboxes(self, checkboxes_dict, checked):
+ """统一设置checkbox状态"""
+ for checkbox in checkboxes_dict.values():
+ checkbox.setChecked(checked)
+
+ def on_csv_file_changed(self):
+ """CSV文件改变时自动刷新列信息"""
+ self.refresh_csv_columns()
+
+ def refresh_csv_columns(self):
+ """刷新CSV文件的列信息"""
+ csv_path = self.csv_file.get_path()
+ if not csv_path or not os.path.exists(csv_path):
+ self.csv_columns = []
+ self.update_column_widgets()
+ return
+
+ try:
+ df = pd.read_csv(csv_path, nrows=0)
+ self.csv_columns = list(df.columns)
+ self.update_column_widgets()
+ except Exception as e:
+ self.csv_columns = []
+ self.update_column_widgets()
+ print(f"读取CSV列信息失败: {e}")
+
+ def update_column_widgets(self):
+ """更新列选择组件"""
+ for checkbox in self.x_column_checkboxes.values():
+ checkbox.setParent(None)
+ self.x_column_checkboxes.clear()
+
+ for checkbox in self.y_column_checkboxes.values():
+ checkbox.setParent(None)
+ self.y_column_checkboxes.clear()
+
+ if not self.csv_columns:
+ return
+
+ for i, col in enumerate(self.csv_columns):
+ checkbox = QCheckBox(col)
+ if any(keyword in col.lower() for keyword in ['index', 'ratio', 'normalized', 'nd', 'b']):
+ checkbox.setChecked(True)
+ self.x_column_checkboxes[col] = checkbox
+ self.x_columns_layout.addWidget(checkbox, i // 3, i % 3)
+
+ for i, col in enumerate(self.csv_columns):
+ checkbox = QCheckBox(col)
+ if any(keyword in col.lower() for keyword in ['chl', 'tn', 'tp', 'turbidity', 'do', 'ph', 'conductivity']):
+ checkbox.setChecked(True)
+ self.y_column_checkboxes[col] = checkbox
+ self.y_columns_layout.addWidget(checkbox, i // 2, i % 2)
+
+ self.x_columns_layout.update()
+ self.y_columns_layout.update()
+
+ def get_config(self):
+ selected_x_columns = [
+ col for col, checkbox in self.x_column_checkboxes.items()
+ if checkbox.isChecked()
+ ]
+ selected_y_columns = [
+ col for col, checkbox in self.y_column_checkboxes.items()
+ if checkbox.isChecked()
+ ]
+ selected_methods = [
+ method for method, checkbox in self.method_checkboxes.items()
+ if checkbox.isChecked()
+ ]
+ if not selected_methods:
+ selected_methods = 'all'
+
+ return {
+ 'csv_path': self.csv_file.get_path() or None,
+ 'x_columns': selected_x_columns,
+ 'y_columns': selected_y_columns,
+ 'methods': selected_methods,
+ 'output_dir': self.output_dir.text().strip() or None,
+ 'enabled': self.enable_checkbox.isChecked()
+ }
+
+ def set_config(self, config):
+ if 'csv_path' in config:
+ self.csv_file.set_path(config['csv_path'])
+ self.refresh_csv_columns()
+
+ if 'x_columns' in config:
+ selected_x = set(config['x_columns']) if isinstance(config['x_columns'], list) else set()
+ for col, checkbox in self.x_column_checkboxes.items():
+ checkbox.setChecked(col in selected_x)
+
+ if 'y_columns' in config:
+ selected_y = set(config['y_columns']) if isinstance(config['y_columns'], list) else set()
+ for col, checkbox in self.y_column_checkboxes.items():
+ checkbox.setChecked(col in selected_y)
+
+ if 'methods' in config:
+ methods = config['methods']
+ if isinstance(methods, list):
+ selected_methods = set(methods)
+ elif methods == 'all':
+ selected_methods = set(self.method_checkboxes.keys())
+ else:
+ selected_methods = set()
+ for method, checkbox in self.method_checkboxes.items():
+ checkbox.setChecked(method in selected_methods)
+
+ if 'output_dir' in config:
+ self.output_dir.setText(config['output_dir'] or "9_Custom_Regression_Modeling")
+ if 'enabled' in config:
+ self.enable_checkbox.setChecked(config['enabled'])
+
+ def run_step(self):
+ """独立运行步骤6.75"""
+ csv_path = self.csv_file.get_path()
+
+ if not csv_path:
+ QMessageBox.warning(self, "输入验证失败", "请选择输入CSV文件")
+ return
+ if not os.path.exists(csv_path):
+ QMessageBox.warning(self, "输入验证失败", "输入CSV文件不存在")
+ return
+
+ selected_x_columns = [
+ col for col, checkbox in self.x_column_checkboxes.items()
+ if checkbox.isChecked()
+ ]
+ if not selected_x_columns:
+ QMessageBox.warning(self, "输入验证失败", "请至少选择一个自变量列")
+ return
+
+ selected_y_columns = [
+ col for col, checkbox in self.y_column_checkboxes.items()
+ if checkbox.isChecked()
+ ]
+ if not selected_y_columns:
+ QMessageBox.warning(self, "输入验证失败", "请至少选择一个因变量列")
+ return
+
+ selected_methods = [
+ method for method, checkbox in self.method_checkboxes.items()
+ if checkbox.isChecked()
+ ]
+ if not selected_methods:
+ QMessageBox.warning(self, "输入验证失败", "请至少选择一种回归方法")
+ return
+
+ config = self.get_config()
+
+ parent = self.parent()
+ while parent and not hasattr(parent, 'run_single_step'):
+ parent = parent.parent()
+
+ if parent and hasattr(parent, 'run_single_step'):
+ parent.run_single_step('step6_75', {'step6_75': config})
+ else:
+ QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
diff --git a/src/gui/panels/step6_panel.py b/src/gui/panels/step6_panel.py
new file mode 100644
index 0000000..f357b6b
--- /dev/null
+++ b/src/gui/panels/step6_panel.py
@@ -0,0 +1,320 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step6 面板 - 机器学习建模
+"""
+
+import os
+
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout,
+ QHBoxLayout, QLabel, QLineEdit, QSpinBox, QCheckBox,
+ QPushButton, QFileDialog, QMessageBox,
+)
+from PyQt5.QtCore import Qt
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step6Panel(QWidget):
+ """步骤6:机器学习建模"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 标题
+
+
+ # 训练数据文件(用于独立运行)
+ self.training_csv_file = FileSelectWidget(
+ "训练数据:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ layout.addWidget(self.training_csv_file)
+
+ # 机器学习模型页面
+ self.ml_page = QWidget()
+ self.create_ml_page()
+ layout.addWidget(self.ml_page)
+
+ # 输出文件路径
+ self.output_dir = FileSelectWidget(
+ "输出模型目录:",
+ "Directories;;All Files (*.*)"
+ )
+ self.output_dir.line_edit.setPlaceholderText("models_output")
+ # 修改浏览按钮为选择目录
+ self.output_dir.browse_btn.clicked.disconnect()
+ self.output_dir.browse_btn.clicked.connect(self.browse_output_dir)
+ layout.addWidget(self.output_dir)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_btn = QPushButton("独立运行此步骤")
+ self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_btn.clicked.connect(self.run_step)
+ layout.addWidget(self.run_btn)
+
+ layout.addStretch()
+ self.setLayout(layout)
+
+ def create_ml_page(self):
+ """创建机器学习模型页面"""
+ layout = QVBoxLayout()
+
+ # 参数设置
+ params_group = QGroupBox("训练参数")
+ params_layout = QFormLayout()
+
+ self.feature_start = QLineEdit()
+ self.feature_start.setText("374.285004")
+ params_layout.addRow("特征起始列:", self.feature_start)
+
+ self.cv_folds = QSpinBox()
+ self.cv_folds.setRange(2, 10)
+ self.cv_folds.setValue(3)
+ params_layout.addRow("交叉验证折数:", self.cv_folds)
+
+ params_group.setLayout(params_layout)
+ layout.addWidget(params_group)
+
+ # 预处理方法 - 多选
+ preproc_group = QGroupBox("预处理方法 (可多选)")
+ preproc_layout = QVBoxLayout()
+
+ preproc_grid = QGridLayout()
+ self.preproc_checkboxes = {}
+ preproc_methods = ['None', 'MMS', 'SS', 'SNV', 'MA', 'SG', 'MSC', 'D1', 'D2', 'DT', 'CT']
+
+ for i, method in enumerate(preproc_methods):
+ checkbox = QCheckBox(method)
+ checkbox.setChecked(True)
+ self.preproc_checkboxes[method] = checkbox
+ preproc_grid.addWidget(checkbox, i // 4, i % 4)
+
+ button_layout = QHBoxLayout()
+ select_all_btn = QPushButton("全选")
+ deselect_all_btn = QPushButton("全不选")
+ select_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, True))
+ deselect_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, False))
+ button_layout.addWidget(select_all_btn)
+ button_layout.addWidget(deselect_all_btn)
+ button_layout.addStretch()
+
+ preproc_layout.addLayout(preproc_grid)
+ preproc_layout.addLayout(button_layout)
+ preproc_group.setLayout(preproc_layout)
+ layout.addWidget(preproc_group)
+
+ # 模型选择 - 多选
+ model_group = QGroupBox("模型类型 (可多选)")
+ model_layout = QVBoxLayout()
+
+ model_grid = QGridLayout()
+ self.model_checkboxes = {}
+
+ model_groups = [
+ ("线性模型", ['LinearRegression', 'Ridge', 'Lasso', 'ElasticNet', 'PLS']),
+ ("树模型", ['DecisionTree', 'RF', 'ExtraTrees', 'XGBoost', 'LightGBM', 'CatBoost']),
+ ("集成学习", ['GradientBoosting', 'AdaBoost']),
+ ("其他模型", ['SVR', 'KNN', 'MLP'])
+ ]
+
+ row = 0
+ for group_name, models in model_groups:
+ group_label = QLabel(f"{group_name}")
+ group_label.setStyleSheet(
+ f"background-color: {ModernStylesheet.COLORS['hover']}; "
+ f"padding: 5px; border: 1px solid {ModernStylesheet.COLORS['border_light']}; "
+ f"border-radius: 3px;"
+ )
+ model_grid.addWidget(group_label, row, 0, 1, 4)
+ row += 1
+
+ for i, model in enumerate(models):
+ checkbox = QCheckBox(model)
+ checkbox.setChecked(model in ['SVR', 'RF', 'Ridge', 'Lasso'])
+ self.model_checkboxes[model] = checkbox
+ model_grid.addWidget(checkbox, row, i % 4)
+ if (i + 1) % 4 == 0:
+ row += 1
+
+ row += 1
+
+ model_button_layout = QHBoxLayout()
+ model_select_all = QPushButton("全选")
+ model_deselect_all = QPushButton("全不选")
+ model_select_all.clicked.connect(lambda: self._toggle_checkboxes(self.model_checkboxes, True))
+ model_deselect_all.clicked.connect(lambda: self._toggle_checkboxes(self.model_checkboxes, False))
+ model_button_layout.addWidget(model_select_all)
+ model_button_layout.addWidget(model_deselect_all)
+ model_button_layout.addStretch()
+
+ model_layout.addLayout(model_grid)
+ model_layout.addLayout(model_button_layout)
+ model_group.setLayout(model_layout)
+ layout.addWidget(model_group)
+
+ # 数据划分方法 - 多选
+ split_group = QGroupBox("数据划分方法 (可多选)")
+ split_layout = QVBoxLayout()
+
+ split_grid = QGridLayout()
+ self.split_checkboxes = {}
+ split_methods = ['spxy', 'ks', 'random']
+
+ for i, method in enumerate(split_methods):
+ checkbox = QCheckBox(method)
+ checkbox.setChecked(True)
+ self.split_checkboxes[method] = checkbox
+ split_grid.addWidget(checkbox, 0, i)
+
+ split_button_layout = QHBoxLayout()
+ split_select_all = QPushButton("全选")
+ split_deselect_all = QPushButton("全不选")
+ split_select_all.clicked.connect(lambda: self._toggle_checkboxes(self.split_checkboxes, True))
+ split_deselect_all.clicked.connect(lambda: self._toggle_checkboxes(self.split_checkboxes, False))
+ split_button_layout.addWidget(split_select_all)
+ split_button_layout.addWidget(split_deselect_all)
+ split_button_layout.addStretch()
+
+ split_layout.addLayout(split_grid)
+ split_layout.addLayout(split_button_layout)
+ split_group.setLayout(split_layout)
+ layout.addWidget(split_group)
+
+ self.ml_page.setLayout(layout)
+
+ def _toggle_checkboxes(self, checkboxes_dict, checked):
+ """统一设置checkbox状态"""
+ for checkbox in checkboxes_dict.values():
+ checkbox.setChecked(checked)
+
+ def browse_output_dir(self):
+ """浏览输出目录"""
+ dir_path = QFileDialog.getExistingDirectory(self, "选择输出模型目录", "")
+ if dir_path:
+ self.output_dir.set_path(dir_path)
+
+ def get_config(self):
+ """获取配置"""
+ preprocessing_methods = [
+ method for method, checkbox in self.preproc_checkboxes.items()
+ if checkbox.isChecked()
+ ]
+ model_names = [
+ model for model, checkbox in self.model_checkboxes.items()
+ if checkbox.isChecked()
+ ]
+ split_methods = [
+ method for method, checkbox in self.split_checkboxes.items()
+ if checkbox.isChecked()
+ ]
+
+ config = {
+ 'feature_start_column': self.feature_start.text(),
+ 'preprocessing_methods': preprocessing_methods if preprocessing_methods else ['None'],
+ 'model_names': model_names if model_names else ['SVR'],
+ 'split_methods': split_methods if split_methods else ['random'],
+ 'cv_folds': self.cv_folds.value()
+ }
+ training_csv_path = self.training_csv_file.get_path()
+ if training_csv_path:
+ config['training_csv_path'] = training_csv_path
+ # 注意:step6_train_models 不接受 output_dir 参数,输出目录由 pipeline 内部根据 work_dir 生成
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'feature_start_column' in config:
+ self.feature_start.setText(str(config['feature_start_column']))
+ if 'cv_folds' in config:
+ self.cv_folds.setValue(config['cv_folds'])
+ if 'preprocessing_methods' in config:
+ methods = config['preprocessing_methods']
+ for method, checkbox in self.preproc_checkboxes.items():
+ checkbox.setChecked(method in methods)
+ if 'model_names' in config:
+ models = config['model_names']
+ for model, checkbox in self.model_checkboxes.items():
+ checkbox.setChecked(model in models)
+ if 'split_methods' in config:
+ methods = config['split_methods']
+ for method, checkbox in self.split_checkboxes.items():
+ checkbox.setChecked(method in methods)
+ if 'training_csv_path' in config:
+ self.training_csv_file.set_path(config['training_csv_path'])
+ if 'output_dir' in config:
+ self.output_dir.set_path(config['output_dir'])
+
+ def update_from_config(self, work_dir=None, pipeline=None):
+ """从全局配置自动填充训练数据和输出路径
+
+ Args:
+ work_dir: 工作目录路径
+ pipeline: Pipeline 实例(未使用,保留接口兼容性)
+ """
+ if work_dir:
+ self.work_dir = work_dir
+ elif hasattr(self, 'work_dir') and self.work_dir:
+ pass
+ else:
+ self.work_dir = None
+
+ # 1. 尝试从 Step5 界面读取训练数据路径
+ main_window = self.window()
+ if hasattr(main_window, 'step5_panel'):
+ # 优先直接从 Step5 的输出 widget 读取
+ step5_output = main_window.step5_panel.output_file.get_path()
+ if step5_output:
+ self.training_csv_file.set_path(step5_output)
+ elif hasattr(main_window, 'step5_panel') and hasattr(main_window.step5_panel, 'get_config'):
+ # 回退:从 Step5 的 config 字典中查找可能的键名
+ step5_cfg = main_window.step5_panel.get_config()
+ step5_csv = (
+ step5_cfg.get('training_spectra_path')
+ or step5_cfg.get('output_file')
+ or step5_cfg.get('csv_path')
+ or step5_cfg.get('output_csv')
+ )
+ if step5_csv:
+ self.training_csv_file.set_path(step5_csv)
+
+ # 2. 自动填充输出目录(基于工作目录)
+ if self.work_dir:
+ output_dir = os.path.join(self.work_dir, "7_Supervised_Model_Training")
+ os.makedirs(output_dir, exist_ok=True)
+ self.output_dir.set_path(output_dir.replace('\\', '/'))
+ else:
+ self.output_dir.set_path("")
+
+ def run_step(self):
+ """独立运行步骤6"""
+ training_csv_path = self.training_csv_file.get_path()
+ if not training_csv_path:
+ QMessageBox.warning(self, "输入错误", "请选择训练数据CSV文件!")
+ return
+
+ main_window = self.window()
+ if hasattr(main_window, 'run_single_step'):
+ config = {'step6': self.get_config()}
+ main_window.run_single_step('step6', config)
+
+ def get_training_params(self):
+ """获取模型训练参数"""
+ return {
+ 'pipeline_type': 'machine_learning',
+ 'feature_start': float(self.feature_start.text()),
+ 'cv_folds': self.cv_folds.value(),
+ 'preprocess_methods': [method for method, cb in self.preproc_checkboxes.items() if cb.isChecked()],
+ 'model_types': [model for model, cb in self.model_checkboxes.items() if cb.isChecked()],
+ 'split_methods': [method for method, cb in self.split_checkboxes.items() if cb.isChecked()]
+ }
diff --git a/src/gui/panels/step7_panel.py b/src/gui/panels/step7_panel.py
new file mode 100644
index 0000000..87ec1bc
--- /dev/null
+++ b/src/gui/panels/step7_panel.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step7 面板 - 采样点生成
+"""
+
+import os
+
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout,
+ QPushButton, QCheckBox, QSpinBox, QMessageBox,
+)
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step7Panel(QWidget):
+ """步骤7:采样点生成"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 去耀斑影像文件(用于独立运行)
+ self.deglint_img_file = FileSelectWidget(
+ "去耀斑影像:",
+ "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
+ )
+ layout.addWidget(self.deglint_img_file)
+
+ # 水域掩膜文件(可选,用于独立运行)
+ self.water_mask_file = FileSelectWidget(
+ "水域掩膜:",
+ "Mask Files (*.dat *.tif);;All Files (*.*)"
+ )
+ self.water_mask_file.label.setText("水域掩膜:")
+ layout.addWidget(self.water_mask_file)
+
+ # 参数设置
+ params_group = QGroupBox("采样参数")
+ params_layout = QFormLayout()
+
+ self.interval = QSpinBox()
+ self.interval.setRange(10, 500)
+ self.interval.setValue(50)
+ params_layout.addRow("采样点间隔(像素):", self.interval)
+
+ self.sample_radius = QSpinBox()
+ self.sample_radius.setRange(1, 50)
+ self.sample_radius.setValue(5)
+ params_layout.addRow("采样半径(像素):", self.sample_radius)
+
+ self.chunk_size = QSpinBox()
+ self.chunk_size.setRange(100, 10000)
+ self.chunk_size.setValue(1000)
+ params_layout.addRow("处理块大小:", self.chunk_size)
+
+ params_group.setLayout(params_layout)
+ layout.addWidget(params_group)
+
+ # 输出文件路径
+ self.output_file = FileSelectWidget(
+ "输出采样点:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ self.output_file.line_edit.setPlaceholderText("sampling_points.csv")
+ layout.addWidget(self.output_file)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_btn = QPushButton("独立运行此步骤")
+ self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_btn.clicked.connect(self.run_step)
+ layout.addWidget(self.run_btn)
+
+ layout.addStretch()
+ self.setLayout(layout)
+
+ def get_config(self):
+ """获取配置"""
+ config = {
+ 'interval': self.interval.value(),
+ 'sample_radius': self.sample_radius.value(),
+ 'chunk_size': self.chunk_size.value(),
+ }
+ deglint_img_path = self.deglint_img_file.get_path()
+ if deglint_img_path:
+ config['deglint_img_path'] = deglint_img_path
+ water_mask_path = self.water_mask_file.get_path()
+ if water_mask_path:
+ config['water_mask_path'] = water_mask_path
+ # 注意:step7_generate_sampling_points 不接受 output_path 参数,输出路径由 pipeline 内部自动生成
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'interval' in config:
+ self.interval.setValue(config['interval'])
+ if 'sample_radius' in config:
+ self.sample_radius.setValue(config['sample_radius'])
+ if 'chunk_size' in config:
+ self.chunk_size.setValue(config['chunk_size'])
+ if 'deglint_img_path' in config:
+ self.deglint_img_file.set_path(config['deglint_img_path'])
+ if 'water_mask_path' in config:
+ self.water_mask_file.set_path(config['water_mask_path'])
+ if 'glint_mask_path' in config:
+ self.glint_mask_file.set_path(config['glint_mask_path'])
+
+ def update_from_config(self, work_dir=None, pipeline=None):
+ """从全局配置自动填充去耀斑影像和掩膜路径
+
+ Args:
+ work_dir: 工作目录路径
+ pipeline: Pipeline 实例(用于从 step_outputs 获取绝对路径)
+ """
+ if work_dir:
+ self.work_dir = work_dir
+ elif hasattr(self, 'work_dir') and self.work_dir:
+ pass
+ else:
+ self.work_dir = None
+
+ main_window = self.window()
+
+ # 1. 填充去耀斑影像路径(优先从 pipeline.step_outputs 获取绝对路径)
+ deglint_path = None
+ if pipeline and hasattr(pipeline, 'step_outputs'):
+ step3_outputs = getattr(pipeline, 'step_outputs', {}).get('step3', {})
+ deglint_path = (
+ step3_outputs.get('deglint_image')
+ or step3_outputs.get('output_path')
+ or step3_outputs.get('output_file')
+ or step3_outputs.get('deglint_img_path')
+ )
+ # 回退:从 step3 面板 widget 直接读取(可能是相对路径)
+ if not deglint_path and hasattr(main_window, 'step3_panel'):
+ deglint_path = main_window.step3_panel.output_file.get_path()
+
+ if deglint_path:
+ self.deglint_img_file.set_path(deglint_path)
+
+ # 2. 填充水域掩膜路径(优先从 pipeline.step_outputs 获取绝对路径)
+ water_mask_path = None
+ if pipeline and hasattr(pipeline, 'step_outputs'):
+ step1_outputs = getattr(pipeline, 'step_outputs', {}).get('step1', {})
+ water_mask_path = (
+ step1_outputs.get('water_mask')
+ or step1_outputs.get('output_path')
+ or step1_outputs.get('output_file')
+ )
+ # 回退:从 step1 面板 widget 直接读取
+ if not water_mask_path and hasattr(main_window, 'step1_panel'):
+ water_mask_path = main_window.step1_panel.output_file.get_path()
+
+ if water_mask_path:
+ self.water_mask_file.set_path(water_mask_path)
+
+ # 3. 自动填充输出路径(绝对路径)
+ if self.work_dir:
+ output_path = os.path.join(self.work_dir, "10_sampling", "sampling_spectra.csv")
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+ self.output_file.set_path(output_path.replace('\\', '/'))
+
+ def run_step(self):
+ """独立运行步骤7"""
+ deglint_img_path = self.deglint_img_file.get_path()
+ if not deglint_img_path:
+ QMessageBox.warning(self, "输入错误", "请选择去耀斑影像文件!")
+ return
+
+ main_window = self.window()
+ if hasattr(main_window, 'run_single_step'):
+ config = {'step7': self.get_config()}
+ main_window.run_single_step('step7', config)
diff --git a/src/gui/panels/step8_5_panel.py b/src/gui/panels/step8_5_panel.py
new file mode 100644
index 0000000..07e0f2e
--- /dev/null
+++ b/src/gui/panels/step8_5_panel.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step8_5 面板 - 非经验模型预测
+"""
+
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout,
+ QPushButton, QCheckBox, QComboBox, QLineEdit, QMessageBox,
+ QFileDialog,
+)
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step8_5Panel(QWidget):
+ """步骤8.5:非经验模型预测"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 采样光谱CSV文件选择
+ self.sampling_csv_file = FileSelectWidget(
+ "采样光谱CSV:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ layout.addWidget(self.sampling_csv_file)
+
+ # 模型目录选择
+ self.models_dir_file = FileSelectWidget(
+ "模型目录:",
+ "Directories;;All Files (*.*)"
+ )
+ self.models_dir_file.label.setText("模型目录:")
+ self.models_dir_file.browse_btn.clicked.disconnect()
+ self.models_dir_file.browse_btn.clicked.connect(self.browse_models_dir)
+ layout.addWidget(self.models_dir_file)
+
+ # 参数设置
+ params_group = QGroupBox("预测参数")
+ params_layout = QFormLayout()
+
+ self.metric = QComboBox()
+ self.metric.addItems(['Average Accuracy(%)', 'Min Accuracy(%)', 'Max Accuracy(%)'])
+ params_layout.addRow("模型选择指标:", self.metric)
+
+ self.prediction_column = QLineEdit()
+ self.prediction_column.setText("prediction")
+ params_layout.addRow("预测列名:", self.prediction_column)
+
+ params_group.setLayout(params_layout)
+ layout.addWidget(params_group)
+
+ # 输出路径
+ self.output_file = FileSelectWidget(
+ "输出文件夹:",
+ "Directories;;All Files (*.*)"
+ )
+ self.output_file.label.setText("输出文件夹:")
+ self.output_file.browse_btn.clicked.disconnect()
+ self.output_file.browse_btn.clicked.connect(self.browse_output_dir)
+ layout.addWidget(self.output_file)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_button = QPushButton("独立运行此步骤")
+ self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_button.clicked.connect(self.run_step)
+ layout.addWidget(self.run_button)
+
+ layout.addStretch()
+ self.setLayout(layout)
+
+ def browse_models_dir(self):
+ """浏览模型目录"""
+ dir_path = QFileDialog.getExistingDirectory(self, "选择模型目录", "")
+ if dir_path:
+ self.models_dir_file.set_path(dir_path)
+
+ def browse_output_dir(self):
+ """浏览输出目录"""
+ dir_path = QFileDialog.getExistingDirectory(self, "选择输出文件夹", "")
+ if dir_path:
+ self.output_file.set_path(dir_path)
+
+ def get_config(self):
+ """获取配置"""
+ config = {
+ 'metric': self.metric.currentText(),
+ 'prediction_column': self.prediction_column.text(),
+ 'enabled': self.enable_checkbox.isChecked()
+ }
+ sampling_csv_path = self.sampling_csv_file.get_path()
+ if sampling_csv_path:
+ config['sampling_csv_path'] = sampling_csv_path
+ models_dir = self.models_dir_file.get_path()
+ if models_dir:
+ config['models_dir'] = models_dir
+ output_path = self.output_file.get_path()
+ if output_path:
+ config['output_path'] = output_path
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'metric' in config:
+ idx = self.metric.findText(config['metric'])
+ if idx >= 0:
+ self.metric.setCurrentIndex(idx)
+ if 'prediction_column' in config:
+ self.prediction_column.setText(config['prediction_column'])
+ if 'sampling_csv_path' in config:
+ self.sampling_csv_file.set_path(config['sampling_csv_path'])
+ if 'models_dir' in config:
+ self.models_dir_file.set_path(config['models_dir'])
+ if 'enabled' in config:
+ self.enable_checkbox.setChecked(config['enabled'])
+
+ def run_step(self):
+ """独立运行步骤8.5"""
+ sampling_csv_path = self.sampling_csv_file.get_path()
+ if not sampling_csv_path:
+ QMessageBox.warning(self, "输入错误", "请选择采样光谱CSV文件!")
+ return
+
+ config = self.get_config()
+
+ parent = self.parent()
+ while parent and not hasattr(parent, 'run_single_step'):
+ parent = parent.parent()
+
+ if parent and hasattr(parent, 'run_single_step'):
+ parent.run_single_step('step8_5', {'step8_5': config})
+ else:
+ QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
diff --git a/src/gui/panels/step8_75_panel.py b/src/gui/panels/step8_75_panel.py
new file mode 100644
index 0000000..0d84214
--- /dev/null
+++ b/src/gui/panels/step8_75_panel.py
@@ -0,0 +1,140 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step8_75 面板 - 自定义回归预测
+"""
+
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox,
+ QPushButton, QCheckBox, QMessageBox, QFileDialog,
+)
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step8_75Panel(QWidget):
+ """步骤8.75:自定义回归预测"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 采样光谱CSV文件选择
+ self.sampling_csv_file = FileSelectWidget(
+ "采样光谱CSV:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ layout.addWidget(self.sampling_csv_file)
+
+ # 自定义回归模型目录选择(9_Custom_Regression_Modeling)
+ self.regression_models_dir = FileSelectWidget(
+ "回归模型目录:",
+ "Directories;;All Files (*.*)"
+ )
+ self.regression_models_dir.label.setText("回归模型目录:")
+ self.regression_models_dir.browse_btn.clicked.disconnect()
+ self.regression_models_dir.browse_btn.clicked.connect(self.browse_regression_models_dir)
+ self.regression_models_dir.set_path("9_Custom_Regression_Modeling")
+ layout.addWidget(self.regression_models_dir)
+
+ # 公式CSV文件选择(用于查找index_formula)
+ self.formula_csv_file = FileSelectWidget(
+ "公式CSV文件:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ self.formula_csv_file.label.setText("公式CSV文件:")
+ layout.addWidget(self.formula_csv_file)
+
+ # 输出目录选择
+ self.output_dir_widget = FileSelectWidget(
+ "输出目录:",
+ "Directories;;All Files (*.*)"
+ )
+ self.output_dir_widget.label.setText("输出目录:")
+ self.output_dir_widget.browse_btn.clicked.disconnect()
+ self.output_dir_widget.browse_btn.clicked.connect(self.browse_output_dir)
+ self.output_dir_widget.line_edit.setPlaceholderText("留空使用默认prediction目录")
+ layout.addWidget(self.output_dir_widget)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_button = QPushButton("独立运行此步骤")
+ self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_button.clicked.connect(self.run_step)
+ layout.addWidget(self.run_button)
+
+ layout.addStretch()
+ self.setLayout(layout)
+
+ def browse_regression_models_dir(self):
+ """浏览回归模型目录"""
+ dir_path = QFileDialog.getExistingDirectory(self, "选择回归模型目录", "")
+ if dir_path:
+ self.regression_models_dir.set_path(dir_path)
+
+ def browse_output_dir(self):
+ """浏览输出目录"""
+ dir_path = QFileDialog.getExistingDirectory(self, "选择输出目录", "")
+ if dir_path:
+ self.output_dir_widget.set_path(dir_path)
+
+ def get_config(self):
+ """获取配置"""
+ config = {
+ 'enabled': self.enable_checkbox.isChecked()
+ }
+ sampling_csv_path = self.sampling_csv_file.get_path()
+ if sampling_csv_path:
+ config['sampling_csv_path'] = sampling_csv_path
+ regression_models_dir = self.regression_models_dir.get_path()
+ if regression_models_dir:
+ config['custom_regression_dir'] = regression_models_dir
+ formula_csv_path = self.formula_csv_file.get_path()
+ if formula_csv_path:
+ config['formula_csv_path'] = formula_csv_path
+ output_dir = self.output_dir_widget.get_path()
+ if output_dir:
+ config['output_dir'] = output_dir
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'sampling_csv_path' in config:
+ self.sampling_csv_file.set_path(config['sampling_csv_path'])
+ if 'custom_regression_dir' in config:
+ self.regression_models_dir.set_path(config['custom_regression_dir'])
+ if 'formula_csv_path' in config:
+ self.formula_csv_file.set_path(config['formula_csv_path'])
+ if 'output_dir' in config:
+ self.output_dir_widget.set_path(config['output_dir'])
+ if 'enabled' in config:
+ self.enable_checkbox.setChecked(config['enabled'])
+
+ def run_step(self):
+ """独立运行步骤8.75"""
+ sampling_csv_path = self.sampling_csv_file.get_path()
+ if not sampling_csv_path:
+ QMessageBox.warning(self, "输入错误", "请选择采样光谱CSV文件!")
+ return
+ regression_models_dir = self.regression_models_dir.get_path()
+ if not regression_models_dir:
+ QMessageBox.warning(self, "输入错误", "请选择回归模型目录!")
+ return
+
+ config = self.get_config()
+
+ parent = self.parent()
+ while parent and not hasattr(parent, 'run_single_step'):
+ parent = parent.parent()
+
+ if parent and hasattr(parent, 'run_single_step'):
+ parent.run_single_step('step8_75', {'step8_75': config})
+ else:
+ QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
diff --git a/src/gui/panels/step8_panel.py b/src/gui/panels/step8_panel.py
new file mode 100644
index 0000000..7eee5d0
--- /dev/null
+++ b/src/gui/panels/step8_panel.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step8 面板 - 机器学习预测
+"""
+
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout,
+ QPushButton, QCheckBox, QComboBox, QLineEdit, QMessageBox,
+ QFileDialog,
+)
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+
+class Step8Panel(QWidget):
+ """步骤8:机器学习预测"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ # 采样光谱CSV文件(用于独立运行)
+ self.sampling_csv_file = FileSelectWidget(
+ "采样光谱CSV:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ layout.addWidget(self.sampling_csv_file)
+
+ # 模型目录(用于独立运行)
+ self.models_dir_file = FileSelectWidget(
+ "模型目录:",
+ "Directories;;All Files (*.*)"
+ )
+ self.models_dir_file.label.setText("模型目录:")
+ self.models_dir_file.browse_btn.clicked.disconnect()
+ self.models_dir_file.browse_btn.clicked.connect(self.browse_models_dir)
+ layout.addWidget(self.models_dir_file)
+
+ # 参数设置
+ params_group = QGroupBox("预测参数")
+ params_layout = QFormLayout()
+
+ self.metric = QComboBox()
+ self.metric.addItems(['test_r2', 'test_rmse', 'test_mae'])
+ params_layout.addRow("模型选择指标:", self.metric)
+
+ self.prediction_column = QLineEdit()
+ self.prediction_column.setText("prediction")
+ params_layout.addRow("预测列名:", self.prediction_column)
+
+ params_group.setLayout(params_layout)
+ layout.addWidget(params_group)
+
+ # 输出路径
+ self.output_file = FileSelectWidget(
+ "输出路径:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ layout.addWidget(self.output_file)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_btn = QPushButton("独立运行此步骤")
+ self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_btn.clicked.connect(self.run_step)
+ layout.addWidget(self.run_btn)
+
+ layout.addStretch()
+ self.setLayout(layout)
+
+ def browse_models_dir(self):
+ """浏览模型目录"""
+ dir_path = QFileDialog.getExistingDirectory(self, "选择模型目录", "")
+ if dir_path:
+ self.models_dir_file.set_path(dir_path)
+
+ def get_config(self):
+ """获取配置"""
+ config = {
+ 'metric': self.metric.currentText(),
+ 'prediction_column': self.prediction_column.text(),
+ }
+ sampling_csv_path = self.sampling_csv_file.get_path()
+ if sampling_csv_path:
+ config['sampling_csv_path'] = sampling_csv_path
+ models_dir = self.models_dir_file.get_path()
+ if models_dir:
+ config['models_dir'] = models_dir
+ output_path = self.output_file.get_path()
+ if output_path:
+ config['output_path'] = output_path
+ return config
+
+ def set_config(self, config):
+ """设置配置"""
+ if 'metric' in config:
+ idx = self.metric.findText(config['metric'])
+ if idx >= 0:
+ self.metric.setCurrentIndex(idx)
+ if 'prediction_column' in config:
+ self.prediction_column.setText(config['prediction_column'])
+ if 'sampling_csv_path' in config:
+ self.sampling_csv_file.set_path(config['sampling_csv_path'])
+ if 'models_dir' in config:
+ self.models_dir_file.set_path(config['models_dir'])
+ if 'output_path' in config:
+ self.output_file.set_path(config['output_path'])
+
+ def run_step(self):
+ """独立运行步骤8"""
+ sampling_csv_path = self.sampling_csv_file.get_path()
+ models_dir = self.models_dir_file.get_path()
+ if not sampling_csv_path:
+ QMessageBox.warning(self, "输入错误", "请选择采样光谱CSV文件!")
+ return
+ if not models_dir:
+ QMessageBox.warning(self, "输入错误", "请选择模型目录!")
+ return
+
+ main_window = self.window()
+ if hasattr(main_window, 'run_single_step'):
+ config = {'step8': self.get_config()}
+ main_window.run_single_step('step8', config)
diff --git a/src/gui/panels/step9_panel.py b/src/gui/panels/step9_panel.py
new file mode 100644
index 0000000..d4fe49d
--- /dev/null
+++ b/src/gui/panels/step9_panel.py
@@ -0,0 +1,375 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Step9 面板 - 分布图生成
+"""
+
+import os
+import traceback
+from pathlib import Path
+from typing import List, Optional
+
+from PyQt5.QtCore import Qt, QThread, pyqtSignal
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QGroupBox, QFormLayout, QHBoxLayout,
+ QLabel, QCheckBox, QPushButton, QLineEdit, QDoubleSpinBox,
+ QRadioButton, QButtonGroup, QMessageBox, QFileDialog,
+)
+
+from src.gui.components.custom_widgets import FileSelectWidget
+from src.gui.styles import ModernStylesheet
+
+# Pipeline 可用性(与 core/worker_thread.py 保持一致)
+try:
+ from src.core.water_quality_inversion_pipeline_GUI import WaterQualityInversionPipeline
+ PIPELINE_AVAILABLE = True
+except ImportError:
+ PIPELINE_AVAILABLE = False
+
+
+class Step9BatchThread(QThread):
+ """专题图:按文件夹内多个预测 CSV 批量生成分布图。"""
+
+ finished_ok = pyqtSignal(int)
+ failed = pyqtSignal(str)
+ log_message = pyqtSignal(str, str)
+
+ def __init__(self, work_dir: str, csv_paths: List[str], step9_kwargs: dict, output_dir_optional: Optional[str]):
+ super().__init__()
+ self.work_dir = work_dir
+ self.csv_paths = csv_paths
+ self.step9_kwargs = step9_kwargs
+ self.output_dir_optional = (output_dir_optional or "").strip() or None
+
+ 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:
+ from src.core.water_quality_inversion_pipeline_GUI import WaterQualityInversionPipeline
+ pipeline = WaterQualityInversionPipeline(work_dir=self.work_dir)
+ n = len(self.csv_paths)
+ for i, csv_p in enumerate(self.csv_paths):
+ self.log_message.emit(f"专题图 [{i + 1}/{n}] {csv_p}", "info")
+ kw = {**self.step9_kwargs, "prediction_csv_path": csv_p, "skip_dependency_check": True}
+ if self.output_dir_optional:
+ stem = Path(csv_p).stem
+ kw["output_image_path"] = str(Path(self.output_dir_optional) / f"{stem}_distribution.png")
+ else:
+ kw["output_image_path"] = None
+ pipeline.step9_generate_distribution_map(**kw)
+ self.finished_ok.emit(n)
+ 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 Step9Panel(QWidget):
+ """步骤9:分布图生成"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._batch_thread = None
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout()
+
+ hint = QLabel(
+ "独立运行:可选「单个 CSV」或「文件夹批量」(扫描目录下所有 .csv)。"
+ "完整流程中预测 CSV 由步骤11、12、13 自动传入,无需在此选择。"
+ )
+ hint.setWordWrap(True)
+ hint.setStyleSheet(
+ f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};"
+ )
+ layout.addWidget(hint)
+
+ mode_row = QHBoxLayout()
+ self.mode_single_rb = QRadioButton("单个 CSV 文件")
+ self.mode_folder_rb = QRadioButton("文件夹批量")
+ self.mode_single_rb.setChecked(True)
+ self._mode_group = QButtonGroup(self)
+ self._mode_group.addButton(self.mode_single_rb, 0)
+ self._mode_group.addButton(self.mode_folder_rb, 1)
+ self.mode_single_rb.toggled.connect(self._on_step9_mode_changed)
+ self.mode_folder_rb.toggled.connect(self._on_step9_mode_changed)
+ mode_row.addWidget(self.mode_single_rb)
+ mode_row.addWidget(self.mode_folder_rb)
+ mode_row.addStretch()
+ layout.addLayout(mode_row)
+
+ self.prediction_csv_file = FileSelectWidget(
+ "预测结果CSV:",
+ "CSV Files (*.csv);;All Files (*.*)"
+ )
+ layout.addWidget(self.prediction_csv_file)
+
+ folder_row = QHBoxLayout()
+ self.prediction_csv_dir_label = QLabel("预测CSV目录:")
+ self.prediction_csv_dir_label.setMinimumWidth(120)
+ self.prediction_csv_dir_edit = QLineEdit()
+ self.prediction_csv_dir_edit.setPlaceholderText("选择含多个预测结果 CSV 的文件夹…")
+ pred_dir_btn = QPushButton("浏览…")
+ pred_dir_btn.setMaximumWidth(80)
+ pred_dir_btn.clicked.connect(self.browse_prediction_csv_dir)
+ folder_row.addWidget(self.prediction_csv_dir_label)
+ folder_row.addWidget(self.prediction_csv_dir_edit, 1)
+ folder_row.addWidget(pred_dir_btn)
+ self._folder_row_widget = QWidget()
+ self._folder_row_widget.setLayout(folder_row)
+ layout.addWidget(self._folder_row_widget)
+
+ self.recursive_csv_cb = QCheckBox("包含子文件夹(递归扫描 *.csv)")
+ layout.addWidget(self.recursive_csv_cb)
+
+ self.boundary_file = FileSelectWidget(
+ "边界文件:",
+ "Shapefiles (*.shp);;All Files (*.*)"
+ )
+ layout.addWidget(self.boundary_file)
+
+ # 参数设置
+ params_group = QGroupBox("生成参数")
+ params_layout = QFormLayout()
+
+ self.resolution = QDoubleSpinBox()
+ self.resolution.setRange(1, 1000)
+ self.resolution.setValue(30)
+ params_layout.addRow("分辨率(米):", self.resolution)
+
+ self.input_crs = QLineEdit()
+ self.input_crs.setText("EPSG:32651")
+ params_layout.addRow("输入坐标系:", self.input_crs)
+
+ self.output_crs = QLineEdit()
+ self.output_crs.setText("EPSG:4326")
+ params_layout.addRow("输出坐标系:", self.output_crs)
+
+ self.show_points = QCheckBox("显示采样点")
+ params_layout.addRow("", self.show_points)
+
+ self.use_diffusion = QCheckBox("启用距离扩散")
+ self.use_diffusion.setChecked(True)
+ params_layout.addRow("", self.use_diffusion)
+
+ params_group.setLayout(params_layout)
+ layout.addWidget(params_group)
+
+ # 输出目录
+ self.output_dir = FileSelectWidget(
+ "输出分布图目录:",
+ "Directories;;All Files (*.*)"
+ )
+ self.output_dir.line_edit.setPlaceholderText("留空→工作目录/14_visualization")
+ self.output_dir.browse_btn.clicked.disconnect()
+ self.output_dir.browse_btn.clicked.connect(self.browse_output_dir)
+ layout.addWidget(self.output_dir)
+
+ # 启用步骤
+ self.enable_checkbox = QCheckBox("启用此步骤")
+ self.enable_checkbox.setChecked(True)
+ layout.addWidget(self.enable_checkbox)
+
+ # 独立运行按钮
+ self.run_button = QPushButton("独立运行此步骤")
+ self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
+ self.run_button.clicked.connect(self.run_step)
+ layout.addWidget(self.run_button)
+
+ layout.addStretch()
+ self.setLayout(layout)
+ self._on_step9_mode_changed()
+
+ def _on_step9_mode_changed(self):
+ folder_mode = self.mode_folder_rb.isChecked()
+ self.prediction_csv_file.setEnabled(not folder_mode)
+ self._folder_row_widget.setEnabled(folder_mode)
+ self.recursive_csv_cb.setEnabled(folder_mode)
+
+ def browse_prediction_csv_dir(self):
+ d = QFileDialog.getExistingDirectory(self, "选择预测结果 CSV 所在文件夹")
+ if d:
+ self.prediction_csv_dir_edit.setText(d)
+
+ def _collect_csv_paths_from_folder(self) -> List[str]:
+ folder = (self.prediction_csv_dir_edit.text() or "").strip()
+ if not folder or not os.path.isdir(folder):
+ return []
+ root = Path(folder)
+ if self.recursive_csv_cb.isChecked():
+ files = sorted(root.rglob("*.csv"))
+ else:
+ files = sorted(root.glob("*.csv"))
+ return [str(p) for p in files if p.is_file()]
+
+ def _step9_base_pipeline_kwargs(self) -> dict:
+ return {
+ 'boundary_shp_path': self.boundary_file.get_path(),
+ 'resolution': self.resolution.value(),
+ 'input_crs': self.input_crs.text(),
+ 'output_crs': self.output_crs.text(),
+ 'show_sample_points': self.show_points.isChecked(),
+ 'use_distance_diffusion': self.use_diffusion.isChecked(),
+ }
+
+ def get_config(self):
+ pred_csv = (self.prediction_csv_file.get_path() or "").strip()
+ folder_mode = self.mode_folder_rb.isChecked()
+ pred_dir = (self.prediction_csv_dir_edit.text() or "").strip()
+ config = {
+ 'step9_batch_mode': 'folder' if folder_mode else 'single',
+ 'prediction_csv_dir': pred_dir if pred_dir else None,
+ 'recursive_csv_scan': self.recursive_csv_cb.isChecked(),
+ 'prediction_csv_path': None if folder_mode else (pred_csv if pred_csv else None),
+ 'boundary_shp_path': self.boundary_file.get_path(),
+ 'resolution': self.resolution.value(),
+ 'input_crs': self.input_crs.text(),
+ 'output_crs': self.output_crs.text(),
+ 'show_sample_points': self.show_points.isChecked(),
+ 'use_distance_diffusion': self.use_diffusion.isChecked(),
+ }
+ out_dir = (self.output_dir.get_path() or "").strip()
+ if not folder_mode and pred_csv and out_dir:
+ stem = Path(pred_csv).stem
+ config['output_image_path'] = str(Path(out_dir) / f"{stem}_distribution.png")
+ else:
+ config['output_image_path'] = None
+ return config
+
+ def set_config(self, config):
+ mode = config.get('step9_batch_mode', 'single')
+ if mode == 'folder':
+ self.mode_folder_rb.setChecked(True)
+ else:
+ self.mode_single_rb.setChecked(True)
+ if config.get('prediction_csv_dir'):
+ self.prediction_csv_dir_edit.setText(str(config['prediction_csv_dir']))
+ if 'recursive_csv_scan' in config:
+ self.recursive_csv_cb.setChecked(bool(config['recursive_csv_scan']))
+ if 'prediction_csv_path' in config and config['prediction_csv_path']:
+ self.prediction_csv_file.set_path(str(config['prediction_csv_path']))
+ if 'boundary_shp_path' in config:
+ self.boundary_file.set_path(config['boundary_shp_path'])
+ if 'resolution' in config:
+ self.resolution.setValue(config['resolution'])
+ if 'input_crs' in config:
+ self.input_crs.setText(config['input_crs'])
+ if 'output_crs' in config:
+ self.output_crs.setText(config['output_crs'])
+ if 'show_sample_points' in config:
+ self.show_points.setChecked(config['show_sample_points'])
+ if 'use_distance_diffusion' in config:
+ self.use_diffusion.setChecked(config['use_distance_diffusion'])
+ if 'output_dir' in config and config['output_dir']:
+ self.output_dir.set_path(str(config['output_dir']))
+ elif config.get('output_image_path'):
+ p = Path(str(config['output_image_path']))
+ if p.parent and str(p.parent) != '.':
+ self.output_dir.set_path(str(p.parent))
+
+ def browse_output_dir(self):
+ """浏览输出目录"""
+ dir_path = QFileDialog.getExistingDirectory(self, "选择输出模型目录", "")
+ if dir_path:
+ self.output_dir.set_path(dir_path)
+
+ def run_step(self):
+ """独立运行步骤9"""
+ if self._batch_thread and self._batch_thread.isRunning():
+ QMessageBox.information(self, "提示", "批量任务正在运行,请稍候。")
+ return
+
+ boundary_shp_path = self.boundary_file.get_path()
+ if not boundary_shp_path:
+ QMessageBox.warning(self, "输入验证失败", "请选择边界文件")
+ return
+ if not os.path.exists(boundary_shp_path):
+ QMessageBox.warning(self, "输入验证失败", "边界文件不存在")
+ return
+
+ parent = self.parent()
+ while parent and not hasattr(parent, 'run_single_step'):
+ parent = parent.parent()
+
+ if not parent or not hasattr(parent, 'run_single_step'):
+ QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
+ return
+
+ if self.mode_folder_rb.isChecked():
+ csv_list = self._collect_csv_paths_from_folder()
+ if not csv_list:
+ QMessageBox.warning(
+ self,
+ "输入验证失败",
+ "所选文件夹中未找到 .csv 文件,或目录无效。\n"
+ "可勾选「包含子文件夹」以递归扫描。",
+ )
+ return
+ if not PIPELINE_AVAILABLE:
+ QMessageBox.critical(self, "错误", "Pipeline 模块不可用,无法批量生成专题图。")
+ return
+ work_dir = getattr(parent, "work_dir", None) or "./work_dir"
+ work_dir = str(work_dir)
+ base_kw = self._step9_base_pipeline_kwargs()
+ out_dir_opt = (self.output_dir.get_path() or "").strip() or None
+ self.run_button.setEnabled(False)
+ self._batch_thread = Step9BatchThread(work_dir, csv_list, base_kw, out_dir_opt)
+ main_win = parent
+
+ def _batch_log(msg, lvl):
+ if hasattr(main_win, "log_message"):
+ main_win.log_message(msg, lvl)
+
+ self._batch_thread.log_message.connect(_batch_log, Qt.QueuedConnection)
+ self._batch_thread.finished_ok.connect(self._on_step9_batch_ok, Qt.QueuedConnection)
+ self._batch_thread.failed.connect(self._on_step9_batch_fail, Qt.QueuedConnection)
+ self._batch_thread.finished.connect(lambda: self.run_button.setEnabled(True), Qt.QueuedConnection)
+ self._batch_thread.start()
+ if hasattr(parent, "log_message"):
+ parent.log_message(f"专题图批量:共 {len(csv_list)} 个 CSV,工作目录 {work_dir}", "info")
+ return
+
+ prediction_csv_path = (self.prediction_csv_file.get_path() or "").strip()
+ if not prediction_csv_path:
+ QMessageBox.warning(
+ self,
+ "输入验证失败",
+ "请选择「预测结果 CSV」文件,或切换到「文件夹批量」。",
+ )
+ return
+ if not os.path.isfile(prediction_csv_path):
+ QMessageBox.warning(self, "输入验证失败", "预测结果 CSV 不存在或不是文件")
+ return
+
+ config = self.get_config()
+ parent.run_single_step('step9', {'step9': config})
+
+ def _on_step9_batch_ok(self, n: int):
+ QMessageBox.information(self, "完成", f"已批量生成 {n} 个分布图。")
+ parent = self.parent()
+ while parent and not hasattr(parent, "log_message"):
+ parent = parent.parent()
+ if parent and hasattr(parent, "log_message"):
+ parent.log_message(f"专题图批量完成,共 {n} 个文件。", "info")
+
+ def _on_step9_batch_fail(self, err: str):
+ QMessageBox.critical(self, "失败", f"批量生成中断:\n{err[:900]}")
+ parent = self.parent()
+ while parent and not hasattr(parent, "log_message"):
+ parent = parent.parent()
+ if parent and hasattr(parent, "log_message"):
+ parent.log_message(err, "error")
diff --git a/src/gui/panels/visualization_panel.py b/src/gui/panels/visualization_panel.py
new file mode 100644
index 0000000..c26864a
--- /dev/null
+++ b/src/gui/panels/visualization_panel.py
@@ -0,0 +1,1486 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+VisualizationPanel - 可视化分析面板
+左侧目录树 + 右侧图像查看器,支持多种图表生成。
+"""
+
+import os
+import traceback
+from pathlib import Path
+from typing import Optional, List, Union
+
+import numpy as np
+import pandas as pd
+
+from PyQt5.QtCore import Qt, QTimer, QThread, pyqtSignal, QAbstractTableModel
+from PyQt5.QtGui import QPixmap
+from PyQt5.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
+ QLabel, QCheckBox, QPushButton, QLineEdit, QMessageBox,
+ QFileDialog, QFrame, QSizePolicy,
+ QDialog, QTreeWidget, QListWidget, QAbstractItemView, QHeaderView,QTreeWidgetItem,QScrollArea
+)
+from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
+from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
+from matplotlib.figure import Figure
+
+# Pipeline 可用性(与 core/worker_thread.py 保持一致)
+try:
+ from src.core.water_quality_inversion_pipeline_GUI import WaterQualityInversionPipeline
+ PIPELINE_AVAILABLE = True
+except ImportError:
+ PIPELINE_AVAILABLE = False
+
+
+def _viz_training_spectra_csv_path(work_path: Path) -> Path:
+ """可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤5输出一致)。
+
+ 注意:步骤5.5(水质指数计算)执行后会覆盖此文件为94维增强版本,
+ 因此下游步骤无需任何修改,直接读取此路径即可。
+ """
+ return work_path / "5_training_spectra" / "training_spectra.csv"
+
+
+def _viz_infer_wavelength_start_column(df: pd.DataFrame) -> Union[str, int]:
+ """推断光谱起始列(training_spectra 通常以波长数值为列名,未必含 UTM_Y)。"""
+ for i, col in enumerate(df.columns):
+ name = str(col).strip().lstrip("\ufeff")
+ try:
+ v = float(name)
+ except ValueError:
+ continue
+ if 200.0 <= v <= 3000.0:
+ return i
+ if "UTM_Y" in df.columns:
+ return "UTM_Y"
+ return 0
+
+
+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 = []
+
+ 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:
+ 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,
+ )
+ 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()
+
+ 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
+
+ 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)
+
+ 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)
+
+ def _do_update_display(self):
+ """实际执行图像更新"""
+ if not hasattr(self, 'original_pixmap') or self.original_pixmap.isNull():
+ return
+
+ if self._pending_scale is None:
+ return
+
+ 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 VisualizationPanel(QWidget):
+ """可视化分析面板 - 重构版:左侧目录树 + 右侧图像查看器"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.work_dir = None
+ self.chart_viewer = None
+ self._viz_thread = None
+ self.init_ui()
+
+ def _viz_set_busy(self, busy: bool):
+ for w in (
+ getattr(self, "gen_all_btn", None),
+ getattr(self, "scan_btn", None),
+ ):
+ if w is not None:
+ w.setEnabled(not busy)
+
+ def _start_visualization_thread(self, task: str, extra: Optional[dict] = None) -> bool:
+ if not self.work_dir:
+ QMessageBox.warning(self, "警告", "请先选择工作目录!")
+ return False
+ work_path = Path(self.work_dir)
+ if not work_path.exists():
+ QMessageBox.warning(self, "警告", "工作目录不存在!")
+ return False
+ if self._viz_thread and self._viz_thread.isRunning():
+ QMessageBox.information(self, "提示", "可视化任务正在运行,请稍候。")
+ return False
+ self._viz_thread = VisualizationWorkerThread(task, str(work_path), extra or {})
+ self._viz_thread.finished_ok.connect(self._on_visualization_worker_ok, Qt.QueuedConnection)
+ self._viz_thread.failed.connect(self._on_visualization_worker_fail, Qt.QueuedConnection)
+ self._viz_thread.finished.connect(lambda: self._viz_set_busy(False), Qt.QueuedConnection)
+ self._viz_set_busy(True)
+ self._viz_thread.start()
+ return True
+
+ def _spectrum_meta_param_columns(self, df: pd.DataFrame) -> List[str]:
+ """光谱图可选的水质参数列(光谱波段列之前、且为数值型)。"""
+ wl = _viz_infer_wavelength_start_column(df)
+ if isinstance(wl, str):
+ idx = int(df.columns.get_loc(wl)) + 1
+ else:
+ idx = int(wl)
+ if idx <= 0 or idx >= len(df.columns):
+ numeric = df.select_dtypes(include=[np.number]).columns.tolist()
+ return [
+ c
+ for c in numeric
+ if not any(x in str(c).lower() for x in ("utm", "lat", "lon", "x", "y"))
+ ]
+ meta = list(df.columns[:idx])
+ return [c for c in meta if pd.api.types.is_numeric_dtype(df[c])]
+
+ def _statistics_param_columns(self, df: pd.DataFrame) -> List[str]:
+ """统计图用的参数列:只统计水质参数列(数值型),排除波长列。"""
+ numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
+ wl = _viz_infer_wavelength_start_column(df)
+ if isinstance(wl, str):
+ idx = int(df.columns.get_loc(wl)) + 1
+ else:
+ idx = int(wl)
+ coord_kw = ("utm", "lat", "lon")
+ if 0 < idx < len(df.columns):
+ meta_set = set(df.columns[:idx])
+ return [
+ col
+ for col in numeric_cols
+ if col in meta_set and not any(x in str(col).lower() for x in coord_kw)
+ ]
+ return [
+ col
+ for col in numeric_cols
+ if not any(x in str(col).lower() for x in coord_kw + ("x", "y"))
+ ]
+
+ def _on_visualization_worker_ok(self, payload):
+ if not isinstance(payload, dict):
+ self.scan_work_directory()
+ return
+ t = payload.get("task")
+ if t == "mask_glint":
+ cnt = int(payload.get("count") or 0)
+ if cnt > 0:
+ QMessageBox.information(
+ self,
+ "成功",
+ f"掩膜和耀斑缩略图生成完成,共 {cnt} 个预览图。\n"
+ f"保存位置: 14_visualization/glint_deglint_previews/",
+ )
+ else:
+ QMessageBox.warning(
+ self,
+ "警告",
+ "未找到可处理的影像文件(2_glint/3_deglint 等)。",
+ )
+ elif t == "sampling_map":
+ map_path = payload.get("map_path")
+ QMessageBox.information(
+ self,
+ "成功",
+ "采样点地图生成完成。\n"
+ f"输出: {Path(map_path).name if map_path else ''}\n"
+ "路径: 14_visualization/sampling_maps/",
+ )
+ if map_path:
+ self.show_chart_viewer(map_path, "采样点分布图")
+ elif t == "spectrum":
+ multi = payload.get("output_paths")
+ if isinstance(multi, list) and multi:
+ ok_paths = [p for p in multi if p and Path(str(p)).is_file()]
+ errs = payload.get("errors") or []
+ msg = (
+ f"已为 {len(ok_paths)} 个水质参数生成光谱对比图。\n"
+ f"保存目录: 工作目录/14_visualization/"
+ )
+ if errs:
+ msg += f"\n\n以下列未生成或出错 ({len(errs)} 项,详见日志):\n"
+ msg += "\n".join(str(e) for e in errs[:8])
+ if len(errs) > 8:
+ msg += "\n..."
+ QMessageBox.information(self, "成功", msg)
+ if ok_paths:
+ self.show_chart_viewer(ok_paths[0], "光谱曲线对比(首张)")
+ else:
+ outp = payload.get("output_path")
+ param = payload.get("param_col", "")
+ QMessageBox.information(self, "成功", f"光谱图已生成:\n{outp}")
+ if outp:
+ self.show_chart_viewer(outp, f"{param} - 光谱曲线对比")
+ elif t == "statistics":
+ outp = payload.get("output_paths") or {}
+ QMessageBox.information(
+ self, "成功", f"统计图表已生成,共 {len(outp)} 项。"
+ )
+ if isinstance(outp, dict) and "boxplot" in outp:
+ self.show_chart_viewer(outp["boxplot"], "水质参数箱线图")
+ elif t == "scatter":
+ paths = payload.get("scatter_paths") or {}
+ ok_paths = [p for p in paths.values() if p and Path(str(p)).is_file()]
+ if ok_paths:
+ QMessageBox.information(
+ self,
+ "成功",
+ f"已生成 {len(ok_paths)} 个模型评估散点图。\n"
+ f"保存位置: 14_visualization/scatter_plots/",
+ )
+ self.show_chart_viewer(ok_paths[0], "模型评估散点图")
+ else:
+ QMessageBox.warning(
+ self,
+ "提示",
+ "未生成任何散点图。请确认 7_Supervised_Model_Training 下已有各参数子目录及模型文件,"
+ "且训练 CSV 与建模时一致。",
+ )
+ elif t == "generate_all_selected":
+ parts = payload.get("parts") or []
+ QMessageBox.information(
+ self,
+ "完成",
+ "批量可视化已执行:\n" + "\n".join(parts) if parts else "(无选中项或已跳过)",
+ )
+ self.scan_work_directory()
+
+ def _on_visualization_worker_fail(self, err: str):
+ QMessageBox.critical(self, "错误", f"可视化任务失败:\n{err[:1200]}")
+
+ def init_ui(self):
+ """初始化UI - 使用左右分栏布局"""
+ main_layout = QHBoxLayout()
+ main_layout.setSpacing(10)
+ main_layout.setContentsMargins(10, 10, 10, 10)
+
+ # ===== 左侧面板 =====
+ left_panel = QWidget()
+ left_layout = QVBoxLayout()
+ left_layout.setContentsMargins(0, 0, 0, 0)
+
+ # 工作目录选择
+ dir_group = QGroupBox("工作目录")
+ dir_layout = QHBoxLayout()
+ self.work_dir_edit = QLineEdit()
+ self.work_dir_edit.setPlaceholderText("选择工作目录...")
+ self.work_dir_edit.setReadOnly(True)
+ dir_browse_btn = QPushButton("浏览")
+ dir_browse_btn.clicked.connect(self.browse_work_dir)
+ dir_layout.addWidget(self.work_dir_edit, 1)
+ dir_layout.addWidget(dir_browse_btn)
+ dir_group.setLayout(dir_layout)
+ left_layout.addWidget(dir_group)
+
+ # 图像目录树
+ tree_group = QGroupBox("图像目录")
+ tree_layout = QVBoxLayout()
+ self.image_tree = ImageCategoryTree()
+ self.image_tree.itemClicked.connect(self.on_tree_item_clicked)
+ tree_layout.addWidget(self.image_tree)
+ tree_group.setLayout(tree_layout)
+ left_layout.addWidget(tree_group, 1)
+
+ # 可视化配置
+ config_group = QGroupBox("可视化配置")
+ config_layout = QVBoxLayout()
+
+ self.gen_scatter = QCheckBox("模型评估散点图")
+ self.gen_scatter.setChecked(True)
+ config_layout.addWidget(self.gen_scatter)
+
+ self.gen_spectrum = QCheckBox("光谱曲线图")
+ self.gen_spectrum.setChecked(True)
+ config_layout.addWidget(self.gen_spectrum)
+
+ self.gen_boxplots = QCheckBox("统计图表")
+ self.gen_boxplots.setChecked(True)
+ config_layout.addWidget(self.gen_boxplots)
+
+ self.gen_mask_glint = QCheckBox("掩膜和耀斑缩略图")
+ self.gen_mask_glint.setChecked(True)
+ config_layout.addWidget(self.gen_mask_glint)
+
+ self.gen_sampling_map = QCheckBox("采样点地图")
+ self.gen_sampling_map.setChecked(True)
+ config_layout.addWidget(self.gen_sampling_map)
+
+ config_layout.addSpacing(10)
+ line = QFrame()
+ line.setFrameShape(QFrame.HLine)
+ line.setStyleSheet("color: #ddd;")
+ config_layout.addWidget(line)
+ config_layout.addSpacing(10)
+
+ self.gen_all_btn = QPushButton("🚀 生成全部")
+ self.gen_all_btn.setToolTip("生成所有类型的可视化图表")
+ self.gen_all_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
+ self.gen_all_btn.clicked.connect(self.generate_all_visualizations)
+ config_layout.addWidget(self.gen_all_btn)
+
+ self.scan_btn = QPushButton("📁 扫描目录")
+ self.scan_btn.setToolTip("扫描工作目录中的图像文件")
+ self.scan_btn.clicked.connect(self.scan_work_directory)
+ config_layout.addWidget(self.scan_btn)
+
+ config_group.setLayout(config_layout)
+ left_layout.addWidget(config_group)
+
+ left_panel.setLayout(left_layout)
+ left_panel.setMaximumWidth(350)
+ main_layout.addWidget(left_panel, 0)
+
+ # ===== 右侧面板 =====
+ right_panel = QWidget()
+ right_layout = QVBoxLayout()
+ right_layout.setContentsMargins(0, 0, 0, 0)
+ self.image_viewer = ImageViewerWidget()
+ self.image_viewer.refresh_btn.clicked.connect(self.scan_work_directory)
+ right_layout.addWidget(self.image_viewer, 1)
+ right_panel.setLayout(right_layout)
+ main_layout.addWidget(right_panel, 1)
+
+ self.setLayout(main_layout)
+
+ def set_work_dir(self, work_dir):
+ """设置工作目录"""
+ self.work_dir = work_dir
+ self.work_dir_edit.setText(str(work_dir))
+ if work_dir:
+ QTimer.singleShot(100, self.scan_work_directory)
+
+ def browse_work_dir(self):
+ """浏览工作目录"""
+ dir_path = QFileDialog.getExistingDirectory(self, "选择工作目录")
+ if dir_path:
+ self.work_dir = dir_path
+ self.work_dir_edit.setText(dir_path)
+ self.scan_work_directory()
+
+ def scan_work_directory(self):
+ """扫描工作目录中的图像文件"""
+ if not self.work_dir:
+ return
+ work_path = Path(self.work_dir)
+ if not work_path.exists():
+ return
+ print(f"扫描工作目录: {work_path}")
+ self.image_tree.scan_directory(str(work_path))
+ self._setup_prediction_output_dirs(work_path)
+ viz_dir = work_path / "14_visualization"
+ if viz_dir.exists():
+ image_files = list(viz_dir.glob("**/*.png")) + list(viz_dir.glob("**/*.jpg"))
+ if image_files:
+ self.image_viewer.load_image(str(image_files[0]))
+
+ def _setup_prediction_output_dirs(self, work_path: Path):
+ """设置三个预测步骤的默认输出目录"""
+ try:
+ base_prediction_dir = work_path / "11_12_13_predictions"
+ ml_dir = base_prediction_dir / "Machine_Learning_Prediction"
+ reg_dir = base_prediction_dir / "Regression_Model_Prediction"
+ custom_dir = base_prediction_dir / "Custom_Regression_Prediction"
+ ml_dir.mkdir(parents=True, exist_ok=True)
+ reg_dir.mkdir(parents=True, exist_ok=True)
+ custom_dir.mkdir(parents=True, exist_ok=True)
+ if hasattr(self, 'step8_panel') and hasattr(self.step8_panel, 'output_file'):
+ self.step8_panel.output_file.set_path(str(ml_dir))
+ if hasattr(self, 'step8_5_panel') and hasattr(self.step8_5_panel, 'output_file'):
+ self.step8_5_panel.output_file.set_path(str(reg_dir))
+ if hasattr(self, 'step8_75_panel') and hasattr(self.step8_75_panel, 'output_dir_widget'):
+ self.step8_75_panel.output_dir_widget.set_path(str(custom_dir))
+ print(f"预测输出目录已设置:\n ML: {ml_dir}\n Reg: {reg_dir}\n Custom: {custom_dir}")
+ except Exception as e:
+ print(f"设置预测输出目录失败: {e}")
+
+ def on_tree_item_clicked(self, item, column):
+ """目录树项点击事件"""
+ data = item.data(0, Qt.UserRole)
+ if not data:
+ return
+ if data.get("type") == "image":
+ image_path = data.get("path")
+ if image_path and Path(image_path).exists():
+ self.image_viewer.load_image(image_path)
+
+ def generate_all_visualizations(self):
+ """生成所有可视化图表"""
+ if not self.work_dir:
+ QMessageBox.warning(self, "警告", "请先选择工作目录!")
+ return
+ work_path = Path(self.work_dir)
+ if not work_path.exists():
+ QMessageBox.warning(self, "警告", "工作目录不存在!")
+ return
+ if not (self.gen_scatter.isChecked() or self.gen_spectrum.isChecked() or
+ self.gen_boxplots.isChecked() or self.gen_mask_glint.isChecked() or
+ self.gen_sampling_map.isChecked()):
+ QMessageBox.information(self, "提示", "请至少勾选一项可视化配置选项以生成图表。")
+ return
+ reply = QMessageBox.question(
+ self, "确认生成",
+ "将根据左侧勾选项在后台生成可视化图表,可能需要较长时间。\n是否继续?",
+ QMessageBox.Yes | QMessageBox.No
+ )
+ if reply != QMessageBox.Yes:
+ return
+ extra = {
+ "gen_scatter": self.gen_scatter.isChecked(),
+ "gen_spectrum": self.gen_spectrum.isChecked(),
+ "gen_boxplots": self.gen_boxplots.isChecked(),
+ "gen_mask_glint": self.gen_mask_glint.isChecked(),
+ "gen_sampling_map": self.gen_sampling_map.isChecked(),
+ }
+ self._start_visualization_thread("generate_all_selected", extra)
+
+ def generate_chart(self, chart_type):
+ """生成图表"""
+ if not self.work_dir:
+ QMessageBox.warning(self, "警告", "请先选择工作目录!")
+ return
+ work_path = Path(self.work_dir)
+ if not work_path.exists():
+ QMessageBox.warning(self, "警告", "工作目录不存在!")
+ return
+ try:
+ training_spectra_csv = _viz_training_spectra_csv_path(work_path)
+ if chart_type == 'scatter':
+ if not training_spectra_csv.is_file():
+ QMessageBox.warning(
+ self, "警告",
+ "未找到 5_training_spectra\\training_spectra.csv。\n"
+ "请先执行步骤5(光谱特征提取)生成该文件。",
+ )
+ return
+ training_csv = training_spectra_csv
+ models_dir = work_path / "7_Supervised_Model_Training"
+ if not models_dir.is_dir() or not any(d.is_dir() for d in models_dir.iterdir()):
+ mdir = QFileDialog.getExistingDirectory(
+ self, "选择模型根目录(内含各水质参数子文件夹)", str(work_path))
+ if not mdir:
+ return
+ models_dir = Path(mdir)
+ self._start_visualization_thread(
+ "scatter",
+ {"training_csv_path": str(training_csv), "models_dir": str(models_dir)},
+ )
+ return
+ if chart_type == 'spectrum':
+ if not training_spectra_csv.is_file():
+ QMessageBox.warning(
+ self, "警告",
+ "未找到 5_training_spectra\\training_spectra.csv。\n"
+ "光谱分析固定使用该文件,请先执行步骤5(光谱特征提取)。",
+ )
+ return
+ csv_file = training_spectra_csv
+ df = pd.read_csv(csv_file)
+ columns = self._spectrum_meta_param_columns(df)
+ if not columns:
+ QMessageBox.warning(
+ self, "警告",
+ "当前 CSV 中没有可用的数值型水质参数列,无法按参数分组绘制光谱图。",
+ )
+ return
+ wl_col = _viz_infer_wavelength_start_column(df)
+ self._start_visualization_thread(
+ "spectrum",
+ {"csv_path": str(csv_file), "param_cols": columns,
+ "wavelength_start_column": wl_col, "n_groups": 5},
+ )
+ return
+ if chart_type == 'statistics':
+ if not training_spectra_csv.is_file():
+ QMessageBox.warning(
+ self, "警告",
+ "未找到 5_training_spectra\\training_spectra.csv。\n"
+ "统计分析固定使用该文件,请先执行步骤5(光谱特征提取)。",
+ )
+ return
+ csv_file = training_spectra_csv
+ df = pd.read_csv(csv_file)
+ param_cols = self._statistics_param_columns(df)
+ if not param_cols:
+ QMessageBox.warning(self, "警告", "未找到可用的水质参数列!")
+ return
+ self._start_visualization_thread(
+ "statistics",
+ {"csv_path": str(csv_file), "param_cols": param_cols},
+ )
+ return
+ if chart_type == 'sampling_map':
+ self.generate_sampling_point_map()
+ return
+ except ImportError:
+ QMessageBox.critical(
+ self, "错误",
+ "无法导入可视化模块!\n请确保 visualization_reports.py 文件存在。",
+ )
+ except Exception as e:
+ QMessageBox.critical(
+ self, "错误",
+ f"生成图表时出错:\n{str(e)}\n\n{traceback.format_exc()}",
+ )
+
+ def generate_mask_glint_previews(self):
+ """生成掩膜和耀斑缩略图"""
+ self._start_visualization_thread("mask_glint")
+
+ def generate_sampling_point_map(self):
+ """生成采样点地图"""
+ self._start_visualization_thread("sampling_map")
+
+ def view_chart(self, chart_type):
+ """查看图表"""
+ if not self.work_dir:
+ QMessageBox.warning(self, "警告", "请先选择工作目录!")
+ return
+ work_path = Path(self.work_dir)
+ viz_dir = work_path / "14_visualization"
+ viz_dir2 = viz_dir / "boxplots"
+ viz_dir3 = viz_dir / "scatter_plots"
+ if not viz_dir.exists():
+ QMessageBox.warning(self, "警告", f"可视化目录不存在:\n{viz_dir}\n\n请先生成图表。")
+ return
+ chart_files = []
+ if chart_type == 'scatter':
+ chart_files = list(viz_dir3.glob("*scatter*.png"))
+ elif chart_type == 'spectrum':
+ chart_files = list(viz_dir.glob("*spectrum*.png"))
+ elif chart_type == 'statistics':
+ chart_files = list(viz_dir2.glob("*boxplot.png")) + \
+ list(viz_dir.glob("*histogram.png")) + \
+ list(viz_dir.glob("*heatmap.png"))
+ elif chart_type == 'distribution':
+ chart_files = list(viz_dir.glob("**/*distribution.png"))
+ elif chart_type == 'mask_glint':
+ glint_dir = viz_dir / "glint_deglint_previews"
+ chart_files = list(glint_dir.glob("*preview.png")) if glint_dir.exists() else \
+ list(viz_dir.glob("*preview.png")) + \
+ list(viz_dir.glob("*glint*.png")) + \
+ list(viz_dir.glob("*mask*.png"))
+ elif chart_type == 'sampling_map':
+ sampling_dir = viz_dir / "sampling_maps"
+ chart_files = list(sampling_dir.glob("*sampling_map.png")) if sampling_dir.exists() else \
+ list(viz_dir.glob("*sampling*.png"))
+ if not chart_files:
+ QMessageBox.warning(self, "警告", f"未找到{chart_type}类型的图表文件!\n\n请先生成图表。")
+ return
+ if len(chart_files) > 1:
+ from PyQt5.QtWidgets import QInputDialog
+ file_names = [f.name for f in chart_files]
+ file_name, ok = QInputDialog.getItem(
+ self, "选择图表", "请选择要查看的图表:", file_names, 0, False)
+ if ok:
+ selected_file = next(f for f in chart_files if f.name == file_name)
+ self.show_chart_viewer(str(selected_file), file_name)
+ else:
+ self.show_chart_viewer(str(chart_files[0]), chart_files[0].name)
+
+ def browse_all_charts(self):
+ """浏览所有图表"""
+ if not self.work_dir:
+ QMessageBox.warning(self, "警告", "请先选择工作目录!")
+ return
+ work_path = Path(self.work_dir)
+ chart_files = list(work_path.glob("**/*.png")) + list(work_path.glob("**/*.jpg"))
+ if not chart_files:
+ QMessageBox.warning(self, "警告", "未找到图表文件!")
+ return
+ dialog = ChartBrowserDialog(chart_files, self)
+ dialog.exec_()
+
+ def show_chart_viewer(self, image_path, title="图表查看器"):
+ """显示图表查看器"""
+ viewer = ChartViewerDialog(title=title, parent=self)
+ viewer.display_image(image_path)
+ viewer.exec_()
+
+ def get_config(self):
+ """获取配置"""
+ return {
+ 'generate_scatter': self.gen_scatter.isChecked(),
+ 'generate_boxplots': self.gen_boxplots.isChecked(),
+ 'generate_spectrum': self.gen_spectrum.isChecked(),
+ 'generate_glint_previews': self.gen_mask_glint.isChecked(),
+ 'generate_sampling_maps': self.gen_sampling_map.isChecked(),
+ 'scatter_config': {
+ 'metric': 'test_r2', 'feature_start_column': 13,
+ 'test_size': 0.2, 'random_state': 42
+ },
+ 'boxplot_config': {
+ 'data_start_column': 4, 'save_individual': True, 'use_seaborn': True
+ },
+ 'glint_preview_config': {
+ 'work_dir': None, 'output_subdir': 'glint_deglint_previews',
+ 'generate_glint': True, 'generate_deglint': True
+ }
+ }
+
+ def set_config(self, config):
+ """设置配置"""
+ if not config:
+ return
+ if 'generate_scatter' in config:
+ self.gen_scatter.setChecked(config['generate_scatter'])
+ if 'generate_boxplots' in config:
+ self.gen_boxplots.setChecked(config['generate_boxplots'])
+ if 'generate_spectrum' in config:
+ self.gen_spectrum.setChecked(config['generate_spectrum'])
+ if 'generate_glint_previews' in config:
+ self.gen_mask_glint.setChecked(config['generate_glint_previews'])
+ if 'generate_sampling_maps' in config:
+ self.gen_sampling_map.setChecked(config.get('generate_sampling_maps', True))
diff --git a/src/gui/water_quality_gui.py b/src/gui/water_quality_gui.py
index 31486f8..8ccbee3 100644
--- a/src/gui/water_quality_gui.py
+++ b/src/gui/water_quality_gui.py
@@ -31,14 +31,11 @@ from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPalette, QColor, QPixmap
# 导入样式模块 - 兼容开发环境和 PyInstaller 打包
try:
- # 开发环境或正确添加到 sys.path 时
from styles import ModernStylesheet
except ImportError:
- # PyInstaller 打包后或路径不正确时
try:
from src.gui.styles import ModernStylesheet
except ImportError:
- # 最终兜底:添加路径后导入
import sys
import os
current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -47,6 +44,27 @@ except ImportError:
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')
@@ -55,437 +73,14 @@ from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as Navigatio
from matplotlib.figure import Figure
import matplotlib.pyplot as plt
-# 导入原有的pipeline类
-def check_pipeline_dependencies():
- """检查pipeline模块的依赖项"""
- missing_deps = []
- dep_errors = {}
-
- # 检查必需的Python包
- required_packages = [
- 'numpy', 'pandas', 'scipy', 'matplotlib', 'sklearn',
- 'joblib', 'PIL', 'cv2', 'rasterio', 'geopandas'
- ]
-
- for package in required_packages:
- try:
- if package == 'PIL':
- import PIL
- elif package == 'cv2':
- import cv2
- else:
- __import__(package)
- except Exception as e:
- missing_deps.append(package)
- dep_errors[package] = repr(e)
-
- return missing_deps, dep_errors
-
-def diagnose_pipeline_import_error():
- """诊断pipeline导入错误"""
- import sys
- import os
-
- error_info = []
-
- # 检查是否在PyInstaller环境中运行
- is_frozen = getattr(sys, "frozen", False) or bool(getattr(sys, "_MEIPASS", None))
-
- if is_frozen:
- # 打包后模块在 PyInstaller 归档内,磁盘上不再有「项目根/src/core/*.py」布局;
- # 切勿把 exe 所在目录(如 scripts/dist)的上级当成源码根,否则会误报「文件不存在」。
- error_info.append(
- "[INFO] PyInstaller 环境:Pipeline 从程序内置包加载,跳过对仓库路径 src/core/*.py 的磁盘检查"
- )
- else:
- pipeline_file = os.path.normpath(
- os.path.join(os.path.dirname(__file__), "..", "core", "water_quality_inversion_pipeline_GUI.py")
- )
- if not os.path.exists(pipeline_file):
- error_info.append(f"[ERROR] Pipeline文件不存在: {pipeline_file}")
- error_info.append(
- " 解决方案: 请确保项目结构完整,检查 src/core/ 下是否有 water_quality_inversion_pipeline_GUI.py"
- )
- else:
- error_info.append(f"[OK] Pipeline文件存在: {pipeline_file}")
-
- current_dir = os.path.dirname(os.path.dirname(__file__))
- if current_dir not in sys.path:
- sys.path.insert(0, current_dir)
- error_info.append(f"[INFO] 已添加路径到sys.path: {current_dir}")
-
- # 检查依赖项
- missing_deps, dep_errors = check_pipeline_dependencies()
- if missing_deps:
- error_info.append(f"[ERROR] 缺少必需的依赖包: {', '.join(missing_deps)}")
- # 额外输出真实的导入失败原因(常见于 DLL 缺失,而不是包没安装)
- for pkg in missing_deps:
- if pkg in dep_errors:
- error_info.append(f" - {pkg} 导入失败原因: {dep_errors[pkg]}")
- error_info.append(" 解决方案: 请运行以下命令安装依赖:")
- error_info.append(" pip install -r requirements.txt")
- error_info.append(" 或使用conda:")
- error_info.append(" conda install numpy pandas scipy matplotlib scikit-learn joblib pillow opencv-python rasterio geopandas")
- else:
- error_info.append("[OK] 主要依赖包均已安装")
-
- # 检查 GDAL(优先 osgeo,与运行时一致)
- try:
- from osgeo import gdal # noqa: F401
-
- error_info.append("[OK] GDAL (osgeo) 可用")
- except ImportError:
- try:
- from osgeo import gdal # noqa: F401
-
- error_info.append("[OK] GDAL 可用")
- except ImportError:
- error_info.append("[WARNING] GDAL/osgeo 不可用,将影响栅格与地理数据处理")
- error_info.append(" 开发环境: conda install gdal")
- error_info.append(" 打包环境: 请在构建所用 Conda 环境中打包,并确保 spec 已收集 Library/bin 中依赖 DLL")
-
- # 检查unittest模块(PyInstaller打包时可能缺失)
- try:
- import unittest
- error_info.append("[OK] unittest模块可用")
- except ImportError:
- error_info.append("[WARNING] unittest模块不可用,这可能是PyInstaller打包环境导致的")
- error_info.append(" 这不会影响主要功能,但可能影响某些测试相关特性")
-
- return error_info
-
-PIPELINE_AVAILABLE = False
-PIPELINE_ERROR_INFO = []
-
-try:
- # 首先检查依赖和文件
- error_info = diagnose_pipeline_import_error()
-
- # 尝试导入
- from src.core.water_quality_inversion_pipeline_GUI import WaterQualityInversionPipeline
- PIPELINE_AVAILABLE = True
- print("[OK] 成功导入pipeline模块")
- PIPELINE_ERROR_INFO = error_info
-
-except ImportError as e:
- PIPELINE_AVAILABLE = False
- error_info = diagnose_pipeline_import_error()
-
- print("="*60)
- print("[ERROR] PIPELINE导入失败 - 详细诊断信息:")
- print("="*60)
-
- for info in error_info:
- print(info)
-
- print("-"*60)
- print(f"原始ImportError: {str(e)}")
- print("-"*60)
-
- # 检查常见的导入问题
- if "unittest" in str(e):
- print("[INFO] unittest模块缺失 - 这通常在PyInstaller打包环境中发生")
- print("解决方案:")
- print(" 1. 这不会影响主要功能,程序仍可正常运行")
- print(" 2. 如果需要修复,可以在.spec文件中添加unittest模块:")
- print(" a = Analysis(..., hiddenimports=['unittest', 'unittest.mock'])")
- print(" 3. 或在PyInstaller命令中添加: --hidden-import unittest")
- elif "water_quality_inversion_pipeline_GUI" in str(e):
- print("[INFO] 可能的解决方案:")
- print(" 1. 检查src/core/water_quality_inversion_pipeline_GUI.py文件是否存在")
- print(" 2. 确保Python路径设置正确")
- print(" 3. 尝试重新安装依赖: pip install -r requirements.txt")
- print(" 4. 检查Python版本是否兼容(推荐Python 3.8-3.11)")
-
- import traceback
- print("\n完整错误追踪:")
- traceback.print_exc()
- print("="*60)
-
- PIPELINE_ERROR_INFO = error_info
-
-except Exception as e:
- PIPELINE_AVAILABLE = False
- error_info = diagnose_pipeline_import_error()
-
- print("="*60)
- print("[ERROR] PIPELINE导入失败 - 其他错误:")
- print("="*60)
-
- for info in error_info:
- print(info)
-
- print("-"*60)
- print(f"原始错误: {str(e)}")
- print("-"*60)
-
- print("[INFO] 可能的解决方案:")
- print(" 1. 检查Python环境和依赖包版本")
- print(" 2. 尝试重新安装所有依赖")
- print(" 3. 检查是否有语法错误或其他模块导入问题")
-
- import traceback
- print("\n完整错误追踪:")
- traceback.print_exc()
- print("="*60)
-
- PIPELINE_ERROR_INFO = error_info
-
-
-class WorkerThread(QThread):
- """后台工作线程,用于执行耗时任务(在工作线程内创建 Pipeline,避免阻塞 UI)。"""
- progress_update = pyqtSignal(int, str) # 进度更新信号 (percentage, message)
- log_message = pyqtSignal(str, str) # 日志消息信号 (message, level: 'info'/'warning'/'error')
- step_completed = pyqtSignal(str, bool, str) # 步骤完成信号 (step_name, success, message)
- finished = pyqtSignal(bool, str) # 完成信号 (success, message)
-
- def __init__(self, work_dir: str, config, mode='full', step_name=None):
- super().__init__()
- self.work_dir = str(work_dir)
- self.config = config
- self.mode = mode # 'full' 或 'single_step'
- self.step_name = step_name # 单步执行时的步骤名称
- self.pipeline = None
- self.is_running = True
- self.current_step = None
- self.step_count = 0
- self.total_steps = 9
-
- def pipeline_callback(self, step_name, status, message=""):
- """Pipeline回调函数,用于接收步骤状态"""
- if status == "start":
- self.log_message.emit(f"[START] 开始执行: {step_name}", "info")
- # 更新进度
- progress = int((self.step_count / self.total_steps) * 100)
- self.progress_update.emit(progress, f"正在执行: {step_name}")
- elif status == "completed":
- self.step_count += 1
- self.log_message.emit(f"[DONE] 完成: {step_name} {message}", "info")
- self.step_completed.emit(step_name, True, message)
- # 更新进度
- progress = int((self.step_count / self.total_steps) * 100)
- self.progress_update.emit(progress, f"已完成: {step_name}")
- elif status == "skipped":
- self.step_count += 1
- self.log_message.emit(f"[SKIP] 跳过: {step_name} {message}", "warning")
- self.step_completed.emit(step_name, True, f"跳过: {message}")
- # 更新进度
- progress = int((self.step_count / self.total_steps) * 100)
- self.progress_update.emit(progress, f"已跳过: {step_name}")
- elif status == "error":
- self.log_message.emit(f"[ERROR] 错误: {step_name} - {message}", "error")
- self.step_completed.emit(step_name, False, message)
- elif status == "info":
- self.log_message.emit(f" {message}", "info")
- elif status == "warning":
- self.log_message.emit(f" [WARNING] {message}", "warning")
-
- def run(self):
- """运行 pipeline:子线程内切换 Matplotlib 为 Agg,避免 Qt5Agg 在后台线程绘图导致界面卡死。"""
- 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:
- from src.core.water_quality_inversion_pipeline_GUI import WaterQualityInversionPipeline
- self.pipeline = WaterQualityInversionPipeline(work_dir=self.work_dir)
-
- if self.mode == 'full':
- self.log_message.emit("开始运行完整流程...", "info")
- self.step_count = 0
-
- if hasattr(self.pipeline, 'set_callback'):
- self.pipeline.set_callback(self.pipeline_callback)
-
- self.pipeline.run_full_pipeline(self.config)
-
- self.progress_update.emit(100, "流程执行完成")
- self.finished.emit(True, "完整流程执行成功!")
- else:
- self.log_message.emit(f"开始独立运行步骤: {self.step_name}", "info")
- self.progress_update.emit(0, f"正在执行: {self.step_name}")
-
- if hasattr(self.pipeline, 'set_callback'):
- self.pipeline.set_callback(self.pipeline_callback)
-
- self.run_single_step(self.step_name, self.config)
-
- self.progress_update.emit(100, f"步骤 {self.step_name} 执行完成")
- self.finished.emit(True, f"步骤 {self.step_name} 独立运行成功!")
- except Exception as e:
- error_msg = f"执行失败: {str(e)}\n{traceback.format_exc()}"
- self.log_message.emit(error_msg, "error")
- self.finished.emit(False, error_msg)
- finally:
- if mpl_prev:
- try:
- import matplotlib.pyplot as plt
- plt.switch_backend(mpl_prev)
- except Exception:
- pass
-
- def run_single_step(self, step_name, config):
- """运行单个步骤"""
- step_method_map = {
- 'step1': 'step1_generate_water_mask',
- 'step2': 'step2_find_glint_area',
- 'step3': 'step3_remove_glint',
- 'step4': 'step4_process_csv',
- 'step5': 'step5_extract_training_spectra',
- 'step5_5': 'step5_5_calculate_water_quality_indices',
- 'step6': 'step6_train_models',
- 'step6_5': 'step6_5_non_empirical_modeling',
- 'step6_75': 'step6_75_custom_regression',
- 'step7': 'step7_generate_sampling_points',
- 'step8': 'step8_predict_water_quality',
- 'step8_5': 'step8_5_predict_with_non_empirical_models',
- 'step8_75': 'step8_75_predict_with_custom_regression',
- 'step9': 'step9_generate_distribution_map'
- }
-
- if step_name not in step_method_map:
- raise ValueError(f"未知的步骤名称: {step_name}")
-
- method_name = step_method_map[step_name]
- step_config = dict(config.get(step_name, {}))
-
- # 为独立运行添加 skip_dependency_check=True
- step_config['skip_dependency_check'] = True
-
- # step9:去掉仅用于 GUI/配置保存的字段,避免传入 pipeline 报错
- if step_name == 'step9':
- step_config.pop('step9_batch_mode', None)
- step_config.pop('prediction_csv_dir', None)
- step_config.pop('recursive_csv_scan', None)
-
- # 拦截掉底层不需要的 GUI 专用输出路径字段,防止报错
- if step_name in ['step2', 'step3', 'step4', 'step5', 'step7', 'step8', 'step8_5', 'step8_75']:
- step_config.pop('output_path', None)
-
- # 参数名映射:将GUI中的参数名映射为pipeline方法期望的参数名
- if step_name == 'step8_5' and 'models_dir' in step_config:
- step_config['non_empirical_models_dir'] = step_config.pop('models_dir')
-
- # 调用对应的方法
- method = getattr(self.pipeline, method_name)
- result = method(**step_config)
-
- return result
-
- def stop(self):
- """停止执行"""
- self.is_running = False
- self.terminate()
-
-
-class ReportGenerateThread(QThread):
- """后台生成 Word 报告(避免阻塞 UI)。"""
- finished_ok = pyqtSignal(str)
- failed = pyqtSignal(str)
- log_message = pyqtSignal(str, str)
-
- def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, options: dict):
- super().__init__()
- self.work_dir = work_dir
- self.output_dir = output_dir
- self.report_title = report_title
- self.options = options
-
- def run(self):
- import traceback
- try:
- from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig
-
- url = (self.options.get("ollama_url") or "").strip() or None
- vision = (self.options.get("ollama_vision_model") or "").strip() or None
- text = (self.options.get("ollama_text_model") or "").strip() or None
- if self.options.get("text_same_as_vision"):
- text = vision
- timeout = self.options.get("ollama_timeout_s")
- enable_ai = self.options.get("enable_ai_analysis")
-
- ai_cfg = ReportGenerationConfig(
- ollama_base_url=url,
- ollama_vision_model=vision,
- ollama_text_model=text,
- ollama_timeout_s=int(timeout) if timeout is not None else None,
- enable_ai_analysis=bool(enable_ai),
- )
- self.log_message.emit(
- f"报告生成:工作目录={self.work_dir},AI={'开' if enable_ai else '关'},"
- f"模型URL={url or '(环境变量 OLLAMA_URL)'}",
- "info",
- )
- gen = WaterQualityReportGenerator(
- work_dir=self.work_dir,
- output_dir=self.output_dir,
- ai_config=ai_cfg,
- )
- out_path = gen.generate_report(
- work_dir=self.work_dir,
- report_title=self.report_title or "水质参数反演分析报告",
- )
- self.finished_ok.emit(str(out_path))
- except Exception as e:
- self.failed.emit(f"{e}\n{traceback.format_exc()}")
-
-
-class Step9BatchThread(QThread):
- """专题图:按文件夹内多个预测 CSV 批量生成分布图。"""
-
- finished_ok = pyqtSignal(int)
- failed = pyqtSignal(str)
- log_message = pyqtSignal(str, str)
-
- def __init__(self, work_dir: str, csv_paths: List[str], step9_kwargs: dict, output_dir_optional: Optional[str]):
- super().__init__()
- self.work_dir = work_dir
- self.csv_paths = csv_paths
- self.step9_kwargs = step9_kwargs
- self.output_dir_optional = (output_dir_optional or "").strip() or None
-
- 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:
- from src.core.water_quality_inversion_pipeline_GUI import WaterQualityInversionPipeline
- pipeline = WaterQualityInversionPipeline(work_dir=self.work_dir)
- n = len(self.csv_paths)
- for i, csv_p in enumerate(self.csv_paths):
- self.log_message.emit(f"专题图 [{i + 1}/{n}] {csv_p}", "info")
- kw = {**self.step9_kwargs, "prediction_csv_path": csv_p, "skip_dependency_check": True}
- if self.output_dir_optional:
- stem = Path(csv_p).stem
- kw["output_image_path"] = str(Path(self.output_dir_optional) / f"{stem}_distribution.png")
- else:
- kw["output_image_path"] = None
- pipeline.step9_generate_distribution_map(**kw)
- self.finished_ok.emit(n)
- 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
+# 后台线程与 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:
@@ -493,21 +88,6 @@ def _viz_training_spectra_csv_path(work_path: Path) -> Path:
return work_path / "5_training_spectra" / "training_spectra.csv"
-def _viz_infer_wavelength_start_column(df: pd.DataFrame) -> Union[str, int]:
- """推断光谱起始列(training_spectra 通常以波长数值为列名,未必含 UTM_Y)。"""
- for i, col in enumerate(df.columns):
- name = str(col).strip().lstrip("\ufeff")
- try:
- v = float(name)
- except ValueError:
- continue
- if 200.0 <= v <= 3000.0:
- return i
- if "UTM_Y" in df.columns:
- return "UTM_Y"
- return 0
-
-
class VisualizationWorkerThread(QThread):
"""可视化耗时计算放入后台线程,并临时使用 Agg 后端,避免主界面未响应。"""
@@ -836,112 +416,6 @@ class VisualizationWorkerThread(QThread):
pass
-class FileSelectWidget(QWidget):
- """文件选择组件"""
- def __init__(self, label_text, file_filter="All Files (*.*)", mode="open", parent=None):
- """
- 初始化文件选择组件
-
- Args:
- label_text: 标签文本
- file_filter: 文件过滤器
- mode: 选择模式 - "open"(打开文件) 或 "save"(保存文件)
- parent: 父控件
- """
- super().__init__(parent)
- self.file_filter = file_filter
- self.mode = mode # "open" 或 "save"
- self.init_ui(label_text)
-
- def init_ui(self, label_text):
- layout = QHBoxLayout()
- layout.setContentsMargins(0, 0, 0, 0)
-
- self.label = QLabel(label_text)
- self.label.setMinimumWidth(120)
- self.line_edit = QLineEdit()
- placeholder = "请选择保存路径..." if self.mode == "save" else "请选择文件..."
- self.line_edit.setPlaceholderText(placeholder)
- self.browse_btn = QPushButton("浏览...")
- self.browse_btn.setMaximumWidth(80)
- self.browse_btn.clicked.connect(self.browse_file)
-
- layout.addWidget(self.label)
- layout.addWidget(self.line_edit, 1)
- layout.addWidget(self.browse_btn)
-
- self.setLayout(layout)
-
- def update_from_config(self, work_dir=None, pipeline=None):
- """
- 从 Step1Panel 自动填充水域掩膜路径,实现上下游数据流转
-
- Args:
- work_dir: 工作目录路径
- pipeline: Pipeline 实例(未使用,保留接口兼容性)
- """
- # 保存工作目录引用
- if work_dir:
- self.work_dir = work_dir
- elif hasattr(self, 'work_dir') and self.work_dir:
- pass # 保持现有工作目录
- else:
- self.work_dir = None
-
- # 从 Step1 界面读取水域掩膜路径
- main_window = self.window()
- if hasattr(main_window, 'step1_panel'):
- if main_window.step1_panel.use_ndwi_radio.isChecked():
- # NDWI模式,读取输出框的路径
- mask_path = main_window.step1_panel.output_file.get_path()
- else:
- # 导入现有模式,读取输入框的路径
- mask_path = main_window.step1_panel.mask_file.get_path()
-
- if mask_path:
- self.water_mask_file.set_path(mask_path)
-
- # 自动填充输出路径(基于工作目录)
- if self.work_dir:
- output_dir = os.path.join(self.work_dir, "3_deglint")
- os.makedirs(output_dir, exist_ok=True)
- default_output_path = os.path.join(output_dir, "deglint_image.dat").replace('\\', '/')
- self.output_file.set_path(default_output_path)
- else:
- self.output_file.set_path("")
-
- def browse_file(self):
- """浏览文件"""
- # 获取当前输入框中的文本,尝试从中提取初始目录
- current_text = self.line_edit.text().strip()
- initial_dir = ""
-
- if current_text:
- # 尝试使用当前路径的目录作为初始目录
- dir_path = os.path.dirname(current_text)
- if dir_path and os.path.exists(dir_path):
- initial_dir = dir_path
-
- if self.mode == "save":
- file_path, _ = QFileDialog.getSaveFileName(
- self, "保存文件", initial_dir, self.file_filter
- )
- else:
- file_path, _ = QFileDialog.getOpenFileName(
- self, "选择文件", initial_dir, self.file_filter
- )
- if file_path:
- self.line_edit.setText(file_path)
-
- def get_path(self):
- """获取路径"""
- return self.line_edit.text()
-
- def set_path(self, path):
- """设置路径"""
- self.line_edit.setText(str(path))
-
-
class PandasTableModel(QAbstractTableModel):
"""支持DataFrame的表格模型"""
def __init__(self, data_frame: pd.DataFrame):
@@ -982,2642 +456,6 @@ class PandasTableModel(QAbstractTableModel):
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
-class Step1Panel(QWidget):
- """1. 水域掩膜生成"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- # 标题
-
-
- # 掩膜生成方式选择
- method_group = QGroupBox("掩膜生成方式")
- method_layout = QVBoxLayout()
-
- # 使用现有掩膜文件
- self.use_existing_radio = QRadioButton("使用现有掩膜文件")
- self.use_existing_radio.setChecked(True)
- method_layout.addWidget(self.use_existing_radio)
-
- # 使用NDWI自动生成
- self.use_ndwi_radio = QRadioButton("使用NDWI自动生成")
- method_layout.addWidget(self.use_ndwi_radio)
-
- # 应用QRadioButton样式(实心选中点)
- radio_style = """
- QRadioButton::indicator {
- width: 16px;
- height: 16px;
- border-radius: 8px;
- border: 2px solid #999;
- }
- QRadioButton::indicator:checked {
- background-color: #0078D7;
- border: 2px solid #0078D7;
- }
- QRadioButton::indicator:unchecked {
- background-color: white;
- border: 2px solid #999;
- }
- QRadioButton::indicator:hover {
- border: 2px solid #0078D7;
- }
- """
- self.use_existing_radio.setStyleSheet(radio_style)
- self.use_ndwi_radio.setStyleSheet(radio_style)
-
- method_group.setLayout(method_layout)
- layout.addWidget(method_group)
-
- # 掩膜文件选择
- self.mask_file = FileSelectWidget(
- "掩膜文件:",
- "Shapefiles (*.shp);;Raster Files (*.dat *.tif);;All Files (*.*)"
- )
- layout.addWidget(self.mask_file)
-
- # 影像文件选择(用于shp栅格化或NDWI生成)
- self.img_file = FileSelectWidget(
- "参考影像:",
- "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
- )
- layout.addWidget(self.img_file)
-
- # NDWI参数设置
- self.ndwi_group = QGroupBox("NDWI参数设置")
- ndwi_layout = QVBoxLayout()
-
- # NDWI阈值
- threshold_layout = QHBoxLayout()
- threshold_layout.addWidget(QLabel("NDWI阈值:"))
- self.ndwi_threshold = QDoubleSpinBox()
- self.ndwi_threshold.setRange(0.0, 1.0)
- self.ndwi_threshold.setSingleStep(0.05)
- self.ndwi_threshold.setValue(0.4)
- self.ndwi_threshold.setDecimals(2)
- threshold_layout.addWidget(self.ndwi_threshold)
- threshold_layout.addStretch()
- ndwi_layout.addLayout(threshold_layout)
-
- self.ndwi_group.setLayout(ndwi_layout)
- layout.addWidget(self.ndwi_group)
-
- # 输出文件路径(使用save模式)
- self.output_file = FileSelectWidget(
- "输出掩膜:",
- "Mask Files (*.dat *.tif);;All Files (*.*)",
- mode="save"
- )
- self.output_file.line_edit.setPlaceholderText("water_mask.dat")
- layout.addWidget(self.output_file)
-
- # 提示信息 - 专业的 Info Alert 样式
- hint = QLabel("💡 提示: 如果掩膜文件是Shapefile(.shp),需要提供参考影像用于栅格化;如果使用NDWI自动生成,只需要提供参考影像")
- hint.setWordWrap(True) # 允许自动换行
- hint.setStyleSheet("""
- QLabel {
- color: #0055D4;
- font-size: 13px;
- font-weight: bold;
- background-color: #E8F4FF;
- border: 2px solid #0055D4;
- border-radius: 8px;
- padding: 12px 16px;
- margin: 8px 0px;
- }
- """)
- layout.addWidget(hint)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_btn = QPushButton("独立运行此步骤")
- self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
- self.run_btn.clicked.connect(self.run_step)
- layout.addWidget(self.run_btn)
-
- # 连接信号
- self.use_existing_radio.toggled.connect(self.update_ui_state)
- self.use_ndwi_radio.toggled.connect(self.update_ui_state)
-
- layout.addStretch()
- self.setLayout(layout)
-
- # 初始UI状态
- self.update_ui_state()
-
- def update_ui_state(self):
- """根据选择的掩膜生成方式更新UI状态(使用显示/隐藏控制)"""
- use_ndwi = self.use_ndwi_radio.isChecked()
-
- # 动态显示/隐藏组件
- if use_ndwi:
- # 使用NDWI模式:隐藏掩膜文件,显示NDWI参数和输出掩膜
- self.mask_file.setVisible(False)
- self.ndwi_group.setVisible(True)
- self.output_file.setVisible(True) # 显示输出掩膜路径
-
- # 当切换到NDWI模式时,如果工作目录已设置,自动填充输出路径
- if hasattr(self, 'work_dir') and self.work_dir:
- self._auto_fill_output_path()
- else:
- # 使用现有掩膜模式:显示掩膜文件,隐藏NDWI参数和输出掩膜
- self.mask_file.setVisible(True)
- self.ndwi_group.setVisible(False)
- self.output_file.setVisible(False) # 隐藏输出掩膜路径
-
- # 参考影像在两种模式下都显示
- self.img_file.setVisible(True)
-
- def update_work_directory(self, work_dir):
- """
- 保存工作目录引用,用于后续自动填充路径
-
- Args:
- work_dir: 工作目录路径
- """
- if not work_dir:
- return
-
- # 保存工作目录引用
- self.work_dir = work_dir
-
- # 如果当前选中的是NDWI模式,立即填充输出路径
- if self.use_ndwi_radio.isChecked():
- self._auto_fill_output_path()
-
- def _auto_fill_output_path(self):
- """
- 自动填充输出掩膜路径(仅在NDWI模式下)
- 确保路径使用正斜杠,避免斜杠混用
- """
- if not hasattr(self, 'work_dir') or not self.work_dir:
- return
-
- # 生成输出掩膜的完整路径
- output_dir = os.path.join(self.work_dir, "1_water_mask")
- os.makedirs(output_dir, exist_ok=True) # 确保目录存在
-
- # 统一使用正斜杠,避免 \ 和 / 混用
- default_output_path = os.path.join(output_dir, "water_mask_out.dat").replace('\\', '/')
- self.output_file.set_path(default_output_path)
-
- def get_config(self):
- """获取配置"""
- use_ndwi = self.use_ndwi_radio.isChecked()
-
- config = {
- 'mask_path': None if use_ndwi else self.mask_file.get_path(),
- 'use_ndwi': use_ndwi,
- 'ndwi_threshold': self.ndwi_threshold.value()
- }
-
- # 参考影像路径(两种模式都可能需要)
- img_path = self.img_file.get_path()
- if img_path:
- config['img_path'] = img_path
-
- # 输出路径:仅在NDWI模式下有效
- if use_ndwi:
- output_path = self.output_file.get_path()
- if output_path:
- config['output_path'] = output_path
- else:
- # 使用现有掩膜时,不传递output_path,避免底层错误尝试保存文件
- config['output_path'] = None
-
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'mask_path' in config:
- self.mask_file.set_path(config['mask_path'])
- if 'img_path' in config:
- self.img_file.set_path(config['img_path'])
- if 'output_path' in config:
- self.output_file.set_path(config['output_path'])
- if 'use_ndwi' in config:
- if config['use_ndwi']:
- self.use_ndwi_radio.setChecked(True)
- else:
- self.use_existing_radio.setChecked(True)
- if 'ndwi_threshold' in config:
- self.ndwi_threshold.setValue(config['ndwi_threshold'])
-
- self.update_ui_state()
-
- def run_step(self):
- """独立运行步骤1"""
- # 验证输入
- if self.use_ndwi_radio.isChecked():
- # NDWI模式:需要影像文件
- img_path = self.img_file.get_path()
- if not img_path:
- QMessageBox.warning(self, "输入错误", "请选择参考影像文件!")
- return
- else:
- # 现有掩膜模式:需要掩膜文件
- mask_path = self.mask_file.get_path()
- if not mask_path:
- QMessageBox.warning(self, "输入错误", "请选择掩膜文件!")
- return
-
- # 如果是shp文件,还需要影像文件
- if mask_path.lower().endswith('.shp'):
- img_path = self.img_file.get_path()
- if not img_path:
- QMessageBox.warning(self, "输入错误", "当使用shp文件时,需要提供参考影像用于栅格化!")
- return
-
- # 获取父窗口并运行步骤
- parent = self.parent()
- while parent and not hasattr(parent, 'run_single_step'):
- parent = parent.parent()
-
- if parent and hasattr(parent, 'run_single_step'):
- config = {'step1': self.get_config()}
- parent.run_single_step("step1", config)
-
-
-class Step2Panel(QWidget):
- """2. 耀斑区域识别"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.work_dir = None
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- # 标题
-
-
- # 影像文件
- self.img_file = FileSelectWidget(
- "影像文件:",
- "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
- )
- layout.addWidget(self.img_file)
-
- # 水域掩膜文件(可选,用于独立运行)
- self.water_mask_file = FileSelectWidget(
- "水域掩膜:",
- "Mask Files (*.dat *.tif);;All Files (*.*)"
- )
- self.water_mask_file.label.setText("水域掩膜:")
- layout.addWidget(self.water_mask_file)
-
- # 参数设置
- params_group = QGroupBox("检测参数")
- params_layout = QFormLayout()
-
- # 耀斑波长
- self.glint_wave = QDoubleSpinBox()
- self.glint_wave.setRange(300, 1000)
- self.glint_wave.setValue(750.0)
- self.glint_wave.setSuffix(" nm")
- params_layout.addRow("耀斑检测波长:", self.glint_wave)
-
- # 检测方法
- self.method = QComboBox()
- self.method.addItem("Otsu 阈值法", "otsu")
- self.method.addItem("Z-Score 方法", "zscore")
- self.method.addItem("百分位数法", "percentile")
- self.method.addItem("IQR 四分位距法", "iqr")
- self.method.addItem("自适应阈值法", "adaptive")
- self.method.addItem("多波段综合法", "multi_band")
- params_layout.addRow("检测方法:", self.method)
-
- # 最大连通域面积
- self.max_area = QSpinBox()
- self.max_area.setRange(0, 100000)
- self.max_area.setValue(50)
- self.max_area.setSpecialValueText("不过滤")
- params_layout.addRow("最大连通域面积:", self.max_area)
-
- # 岸边缓冲区
- self.buffer_size = QSpinBox()
- self.buffer_size.setRange(0, 200)
- self.buffer_size.setValue(10)
- self.buffer_size.setSpecialValueText("不设置")
- params_layout.addRow("岸边缓冲区大小:", self.buffer_size)
-
- params_group.setLayout(params_layout)
- layout.addWidget(params_group)
-
- # 输出文件路径
- self.output_file = FileSelectWidget(
- "输出耀斑掩膜:",
- "Mask Files (*.dat *.tif);;All Files (*.*)"
- )
- self.output_file.line_edit.setPlaceholderText("")
- layout.addWidget(self.output_file)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_btn = QPushButton("独立运行此步骤")
- self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
- self.run_btn.clicked.connect(self.run_step)
- layout.addWidget(self.run_btn)
-
- layout.addStretch()
- self.setLayout(layout)
- # 信号连接:影像文件路径变化时动态更新波段范围
- def get_config(self):
- """获取配置"""
- config = {
- 'img_path': self.img_file.get_path(),
- 'glint_wave': self.glint_wave.value(),
- 'method': self.method.currentData(), # 使用 currentData() 获取英文ID
- }
- if self.max_area.value() > 0:
- config['max_area'] = self.max_area.value()
- if self.buffer_size.value() > 0:
- config['buffer_size'] = self.buffer_size.value()
- # 添加水域掩膜路径(用于独立运行)
- water_mask_path = self.water_mask_file.get_path()
- if water_mask_path:
- config['water_mask_path'] = water_mask_path
- # 添加输出路径
- output_path = self.output_file.get_path()
- if output_path:
- config['output_path'] = output_path
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'img_path' in config:
- self.img_file.set_path(config['img_path'])
- if 'glint_wave' in config:
- self.glint_wave.setValue(config['glint_wave'])
- if 'method' in config:
- idx = self.method.findData(config['method']) # 使用 findData()
- if idx >= 0:
- self.method.setCurrentIndex(idx)
- if 'max_area' in config:
- self.max_area.setValue(config['max_area'])
- if 'buffer_size' in config:
- self.buffer_size.setValue(config['buffer_size'])
- if 'water_mask_path' in config:
- self.water_mask_file.set_path(config['water_mask_path'])
- if 'output_path' in config:
- self.output_file.set_path(config['output_path'])
-
- def update_from_config(self, work_dir=None, pipeline=None):
- """
- 从全局配置/Pipeline 或 Step1Panel 自动填充路径,实现上下游数据流转
-
- Args:
- work_dir: 工作目录路径
- pipeline: Pipeline 实例,用于获取步骤1生成的水域掩膜路径
- """
- # 保存工作目录引用
- if work_dir:
- self.work_dir = work_dir
- elif hasattr(self, 'work_dir') and self.work_dir:
- pass # 保持现有工作目录
- else:
- self.work_dir = None
-
- # 1. 尝试从 Pipeline 获取
- mask_path = None
- if pipeline and hasattr(pipeline, 'water_mask_path') and pipeline.water_mask_path:
- mask_path = pipeline.water_mask_path
-
- # 2. 如果 Pipeline 中没有,则尝试直接从 Step1 界面读取(关键修复)
- main_window = self.window()
- if not mask_path and hasattr(main_window, 'step1_panel'):
- if main_window.step1_panel.use_ndwi_radio.isChecked():
- # NDWI模式,读取输出框的路径
- mask_path = main_window.step1_panel.output_file.get_path()
- else:
- # 导入现有模式,读取输入框的路径
- mask_path = main_window.step1_panel.mask_file.get_path()
-
- # 填充获取到的路径
- if mask_path:
- self.water_mask_file.set_path(mask_path)
-
- # 3. 自动填充输出路径(基于工作目录)
- if self.work_dir:
- # 生成输出耀斑掩膜的标准路径:workspace/2_glint_mask/glint_mask_out.dat
- output_dir = os.path.join(self.work_dir, "2_glint_mask")
- os.makedirs(output_dir, exist_ok=True)
- default_output_path = os.path.join(output_dir, "glint_mask_out.dat").replace('\\', '/')
- self.output_file.set_path(default_output_path)
- else:
- # 没有工作目录时,清空输出路径
- self.output_file.set_path("")
-
- def run_step(self):
- """独立运行步骤2"""
- # 验证输入
- img_path = self.img_file.get_path()
- if not img_path:
- QMessageBox.warning(self, "输入错误", "请选择影像文件!")
- return
-
- # 获取主窗口并运行步骤
- main_window = self.window()
- if hasattr(main_window, 'run_single_step'):
- config = {'step2': self.get_config()}
- main_window.run_single_step('step2', config)
-
-
-class Step3Panel(QWidget):
- """步骤3:耀斑去除"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- # 标题
-
-
- # 影像文件
- self.img_file = FileSelectWidget(
- "影像文件:",
- "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
- )
- layout.addWidget(self.img_file)
-
- # 水域掩膜/边界:完整流程可由步骤1自动生成;独立单步运行时须手动指定
- self.water_mask_file = FileSelectWidget(
- "水域掩膜/边界:",
- "Mask/Boundary (*.dat *.tif *.shp);;All Files (*.*)"
- )
- layout.addWidget(self.water_mask_file)
- step3_mask_hint = QLabel(
- "提示:独立运行本步骤时必须选择水域掩膜或边界(与影像同区域的 .dat/.tif 掩膜,或 .shp 矢量)。"
- )
- step3_mask_hint.setWordWrap(True)
- step3_mask_hint.setStyleSheet("color: #666; font-size: 10px;")
- layout.addWidget(step3_mask_hint)
-
- # 方法选择
- method_group = QGroupBox("去耀斑方法")
- method_layout = QVBoxLayout()
-
- self.method = QComboBox()
- for text, data in [('Goodman方法', 'goodman'), ('Kutser方法', 'kutser'),
- ('Hedley方法', 'hedley'), ('SUGAR算法', 'sugar')]:
- self.method.addItem(text, data)
- self.method.currentIndexChanged.connect(self._on_method_changed)
- method_layout.addWidget(self.method)
-
- method_group.setLayout(method_layout)
- layout.addWidget(method_group)
-
- # Goodman参数组
- self.goodman_group = QGroupBox("Goodman方法参数")
- goodman_layout = QFormLayout()
-
- self.nir_lower = QSpinBox()
- self.nir_lower.setRange(0, 200)
- self.nir_lower.setValue(65)
- goodman_layout.addRow("NIR下波段索引:", self.nir_lower)
-
- self.nir_upper = QSpinBox()
- self.nir_upper.setRange(0, 200)
- self.nir_upper.setValue(91)
- goodman_layout.addRow("NIR上波段索引:", self.nir_upper)
-
- self.goodman_a = QDoubleSpinBox()
- self.goodman_a.setDecimals(6)
- self.goodman_a.setRange(0, 1)
- self.goodman_a.setValue(0.000019)
- goodman_layout.addRow("参数A:", self.goodman_a)
-
- self.goodman_b = QDoubleSpinBox()
- self.goodman_b.setDecimals(2)
- self.goodman_b.setRange(0, 1)
- self.goodman_b.setValue(0.1)
- goodman_layout.addRow("参数B:", self.goodman_b)
-
- self.goodman_group.setLayout(goodman_layout)
- layout.addWidget(self.goodman_group)
-
- # Kutser参数组
- self.kutser_group = QGroupBox("Kutser方法参数")
- kutser_layout = QFormLayout()
-
- self.oxy_band = QSpinBox()
- self.oxy_band.setRange(0, 200)
- self.oxy_band.setValue(8)
- kutser_layout.addRow("氧吸收波段索引:", self.oxy_band)
-
- self.lower_oxy = QDoubleSpinBox()
- self.lower_oxy.setDecimals(2)
- self.lower_oxy.setRange(0, 1000)
- self.lower_oxy.setValue(756.54)
- kutser_layout.addRow("下氧吸收波长(nm):", self.lower_oxy)
-
- self.upper_oxy = QDoubleSpinBox()
- self.upper_oxy.setDecimals(2)
- self.upper_oxy.setRange(0, 1000)
- self.upper_oxy.setValue(766.54)
- kutser_layout.addRow("上氧吸收波长(nm):", self.upper_oxy)
-
- self.nir_band = QSpinBox()
- self.nir_band.setRange(0, 200)
- self.nir_band.setValue(65)
- kutser_layout.addRow("NIR波段索引:", self.nir_band)
-
- self.kutser_group.setLayout(kutser_layout)
- self.kutser_group.setVisible(False)
- layout.addWidget(self.kutser_group)
-
- # Hedley参数组
- self.hedley_group = QGroupBox("Hedley方法参数")
- hedley_layout = QFormLayout()
-
- self.hedley_nir_band = QSpinBox()
- self.hedley_nir_band.setRange(0, 200)
- self.hedley_nir_band.setValue(47)
- hedley_layout.addRow("NIR波段索引:", self.hedley_nir_band)
-
- self.hedley_group.setLayout(hedley_layout)
- self.hedley_group.setVisible(False)
- layout.addWidget(self.hedley_group)
-
- # SUGAR参数组
- self.sugar_group = QGroupBox("SUGAR方法参数")
- sugar_layout = QFormLayout()
-
- self.sugar_iter = QSpinBox()
- self.sugar_iter.setRange(1, 20)
- self.sugar_iter.setValue(3)
- self.sugar_iter.setSpecialValueText("自动")
- sugar_layout.addRow("迭代次数:", self.sugar_iter)
-
- self.sugar_sigma = QDoubleSpinBox()
- self.sugar_sigma.setDecimals(2)
- self.sugar_sigma.setRange(0.1, 10)
- self.sugar_sigma.setValue(1.0)
- sugar_layout.addRow("LoG平滑σ:", self.sugar_sigma)
-
- self.sugar_estimate_background = QCheckBox()
- self.sugar_estimate_background.setChecked(True)
- sugar_layout.addRow("估计背景光谱:", self.sugar_estimate_background)
-
- self.sugar_glint_mask_method = QComboBox()
- self.sugar_glint_mask_method.addItems(['cdf', 'otsu'])
- self.sugar_glint_mask_method.setCurrentText('cdf')
- sugar_layout.addRow("耀斑掩膜方法:", self.sugar_glint_mask_method)
-
- self.sugar_termination_thresh = QDoubleSpinBox()
- self.sugar_termination_thresh.setDecimals(2)
- self.sugar_termination_thresh.setRange(1, 100)
- self.sugar_termination_thresh.setValue(20.0)
- sugar_layout.addRow("终止阈值:", self.sugar_termination_thresh)
-
- self.sugar_bounds = QLineEdit()
- self.sugar_bounds.setText("[(1, 2)]")
- sugar_layout.addRow("优化边界:", self.sugar_bounds)
-
- self.sugar_group.setLayout(sugar_layout)
- self.sugar_group.setVisible(False)
- layout.addWidget(self.sugar_group)
-
- # 插值选项
- interp_group = QGroupBox("0值像素插值")
- interp_layout = QFormLayout()
-
- self.interpolate_zeros = QCheckBox("启用插值")
- interp_layout.addRow("", self.interpolate_zeros)
-
- self.interp_method = QComboBox()
- for text, data in [('最近邻插值', 'nearest'), ('双线性插值', 'bilinear'),
- ('样条插值', 'spline'), ('克里金插值', 'kriging')]:
- self.interp_method.addItem(text, data)
- self.interp_method.setCurrentIndex(1) # 默认双线性插值
- interp_layout.addRow("插值方法:", self.interp_method)
-
- interp_group.setLayout(interp_layout)
- layout.addWidget(interp_group)
-
- # 输出文件路径
- self.output_file = FileSelectWidget(
- "输出影像:",
- "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
- )
- self.output_file.line_edit.setPlaceholderText("deglint_image.dat")
- layout.addWidget(self.output_file)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_btn = QPushButton("独立运行此步骤")
- self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
- self.run_btn.clicked.connect(self.run_step)
- layout.addWidget(self.run_btn)
-
- layout.addStretch()
- self.setLayout(layout)
- # 信号连接:影像文件路径变化时动态更新波段范围
- self.img_file.line_edit.textChanged.connect(self._update_band_ranges)
-
-
- def _update_band_ranges(self, file_path):
- """根据选择的影像动态限制波段索引的输入范围"""
- import os
- from osgeo import gdal
-
- if not file_path or not os.path.isfile(file_path):
- return
-
- try:
- dataset = gdal.Open(file_path)
- if dataset is None:
- return
- raster_count = dataset.RasterCount
- max_band = max(0, raster_count - 1)
- self.nir_lower.setMaximum(max_band)
- self.nir_upper.setMaximum(max_band)
- self.oxy_band.setMaximum(max_band)
- self.nir_band.setMaximum(max_band)
- self.hedley_nir_band.setMaximum(max_band)
- dataset = None
- except Exception:
- pass
-
- def update_from_config(self, work_dir=None, pipeline=None):
- """
- 从 Step1Panel 自动填充水域掩膜路径,实现上下游数据流转
-
- Args:
- work_dir: 工作目录路径
- pipeline: Pipeline 实例(未使用,保留接口兼容性)
- """
- # 保存工作目录引用
- if work_dir:
- self.work_dir = work_dir
- elif hasattr(self, 'work_dir') and self.work_dir:
- pass # 保持现有工作目录
- else:
- self.work_dir = None
-
- # 从 Step1 界面读取水域掩膜路径
- main_window = self.window()
- if hasattr(main_window, 'step1_panel'):
- if main_window.step1_panel.use_ndwi_radio.isChecked():
- # NDWI模式,读取输出框的路径
- mask_path = main_window.step1_panel.output_file.get_path()
- else:
- # 导入现有模式,读取输入框的路径
- mask_path = main_window.step1_panel.mask_file.get_path()
-
- if mask_path:
- self.water_mask_file.set_path(mask_path)
-
- # 自动填充输出路径(基于工作目录)
- if self.work_dir:
- output_dir = os.path.join(self.work_dir, "3_deglint")
- os.makedirs(output_dir, exist_ok=True)
- default_output_path = os.path.join(output_dir, "deglint_image.dat").replace('\\', '/')
- self.output_file.set_path(default_output_path)
- else:
- self.output_file.set_path("")
-
- def _on_method_changed(self, index):
- """方法改变时更新参数显示"""
- method_id = self.method.currentData()
- self.goodman_group.setVisible(method_id == 'goodman')
- self.kutser_group.setVisible(method_id == 'kutser')
- self.hedley_group.setVisible(method_id == 'hedley')
- self.sugar_group.setVisible(method_id == 'sugar')
-
- def get_config(self):
- """获取配置"""
- config = {
- 'img_path': self.img_file.get_path(),
- 'method': self.method.currentData(), # 使用 currentData() 获取英文ID
- 'enabled': self.enable_checkbox.isChecked(),
- 'interpolate_zeros': self.interpolate_zeros.isChecked(),
- 'interpolation_method': self.interp_method.currentData(), # 使用 currentData()
- }
- water_mask_path = self.water_mask_file.get_path()
- if water_mask_path:
- config['water_mask'] = water_mask_path
- output_path = self.output_file.get_path()
- if output_path:
- config['output_path'] = output_path
-
- method = self.method.currentData() # 使用 currentData()
-
- if method == 'goodman':
- config['nir_lower'] = self.nir_lower.value()
- config['nir_upper'] = self.nir_upper.value()
- config['goodman_A'] = self.goodman_a.value()
- config['goodman_B'] = self.goodman_b.value()
-
- elif method == 'kutser':
- config['oxy_band'] = self.oxy_band.value()
- config['lower_oxy'] = self.lower_oxy.value()
- config['upper_oxy'] = self.upper_oxy.value()
- config['nir_band'] = self.nir_band.value()
-
- elif method == 'hedley':
- config['hedley_nir_band'] = self.hedley_nir_band.value()
-
- elif method == 'sugar':
- config['sugar_iter'] = self.sugar_iter.value() if self.sugar_iter.value() > 0 else None
- config['sugar_sigma'] = self.sugar_sigma.value()
- config['sugar_estimate_background'] = self.sugar_estimate_background.isChecked()
- config['sugar_glint_mask_method'] = self.sugar_glint_mask_method.currentData()
- config['sugar_termination_thresh'] = self.sugar_termination_thresh.value()
- # 解析bounds字符串
- try:
- import ast
- config['sugar_bounds'] = ast.literal_eval(self.sugar_bounds.text())
- except:
- config['sugar_bounds'] = [(1, 2)] # 默认值
-
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'img_path' in config:
- self.img_file.set_path(config['img_path'])
- if 'water_mask' in config:
- self.water_mask_file.set_path(config['water_mask'])
- if 'output_path' in config:
- self.output_file.set_path(config['output_path'])
- if 'method' in config:
- idx = self.method.findData(config['method']) # 使用 findData()
- if idx >= 0:
- self.method.setCurrentIndex(idx)
- if 'enabled' in config:
- self.enable_checkbox.setChecked(config['enabled'])
- if 'interpolate_zeros' in config:
- self.interpolate_zeros.setChecked(config['interpolate_zeros'])
- if 'interpolation_method' in config:
- idx = self.interp_method.findData(config['interpolation_method']) # 使用 findData()
- if idx >= 0:
- self.interp_method.setCurrentIndex(idx)
-
- # Goodman参数
- if 'nir_lower' in config:
- self.nir_lower.setValue(config['nir_lower'])
- if 'nir_upper' in config:
- self.nir_upper.setValue(config['nir_upper'])
- if 'goodman_A' in config:
- self.goodman_a.setValue(config['goodman_A'])
- if 'goodman_B' in config:
- self.goodman_b.setValue(config['goodman_B'])
-
- # Kutser参数
- if 'oxy_band' in config:
- self.oxy_band.setValue(config['oxy_band'])
- if 'lower_oxy' in config:
- self.lower_oxy.setValue(config['lower_oxy'])
- if 'upper_oxy' in config:
- self.upper_oxy.setValue(config['upper_oxy'])
- if 'nir_band' in config:
- self.nir_band.setValue(config['nir_band'])
-
- # Hedley参数
- if 'hedley_nir_band' in config:
- self.hedley_nir_band.setValue(config['hedley_nir_band'])
-
- # SUGAR参数
- if 'sugar_iter' in config:
- self.sugar_iter.setValue(config['sugar_iter'] if config['sugar_iter'] is not None else 0)
- if 'sugar_sigma' in config:
- self.sugar_sigma.setValue(config['sugar_sigma'])
- if 'sugar_estimate_background' in config:
- self.sugar_estimate_background.setChecked(config['sugar_estimate_background'])
- if 'sugar_glint_mask_method' in config:
- idx = self.sugar_glint_mask_method.findData(config['sugar_glint_mask_method']) # 使用 findData()
- if idx >= 0:
- self.sugar_glint_mask_method.setCurrentIndex(idx)
- if 'sugar_termination_thresh' in config:
- self.sugar_termination_thresh.setValue(config['sugar_termination_thresh'])
- if 'sugar_bounds' in config:
- self.sugar_bounds.setText(str(config['sugar_bounds']))
-
- def run_step(self):
- """独立运行步骤3"""
- # 验证输入
- img_path = self.img_file.get_path()
- if not img_path:
- QMessageBox.warning(self, "输入错误", "请选择影像文件!")
- return
- if self.enable_checkbox.isChecked():
- water_mask_path = self.water_mask_file.get_path()
- if not water_mask_path:
- QMessageBox.warning(
- self,
- "输入错误",
- "独立运行耀斑去除时,必须选择水域掩膜或边界文件。\n\n"
- "请提供与当前影像空间一致的水域栅格掩膜(.dat/.tif),或水域矢量边界(.shp)。\n"
- "若刚跑过完整流程,可使用步骤1生成的水域掩膜文件。",
- )
- return
-
- # 获取主窗口并运行步骤
- main_window = self.window()
- if hasattr(main_window, 'run_single_step'):
- config = {'step3': self.get_config()}
- main_window.run_single_step('step3', config)
-
-
-class Step4Panel(QWidget):
- """步骤4:数据预处理"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- # 标题
-
- # CSV文件
- self.csv_file = FileSelectWidget(
- "水质参数文件:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- layout.addWidget(self.csv_file)
-
- hint = QLabel("提示: 处理CSV文件,筛选剔除异常值")
- hint.setStyleSheet("color: #666; font-size: 10px;")
- layout.addWidget(hint)
-
- preview_group = QGroupBox("CSV数据预览")
- preview_layout = QVBoxLayout()
-
- controls_layout = QHBoxLayout()
- controls_layout.addWidget(QLabel("预览行数:"))
- self.preview_rows_spin = QSpinBox()
- self.preview_rows_spin.setRange(1, 200)
- self.preview_rows_spin.setValue(10)
- controls_layout.addWidget(self.preview_rows_spin)
- self.preview_btn = QPushButton("刷新预览")
- self.preview_btn.clicked.connect(self.load_csv_preview)
- controls_layout.addWidget(self.preview_btn)
- controls_layout.addStretch()
-
- self.preview_table = QTableView()
- self.preview_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
- self.preview_table.setSelectionBehavior(QAbstractItemView.SelectRows)
- self.preview_table.setSelectionMode(QAbstractItemView.SingleSelection)
- self.preview_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
- self.preview_table.verticalHeader().setVisible(False)
- self.preview_table.setMinimumHeight(200)
-
- self.preview_status_label = QLabel("请选择CSV文件并点击刷新预览")
- self.preview_status_label.setStyleSheet("color: #666; font-size: 11px;")
-
- preview_layout.addLayout(controls_layout)
- preview_layout.addWidget(self.preview_table)
- preview_layout.addWidget(self.preview_status_label)
- preview_group.setLayout(preview_layout)
- layout.addWidget(preview_group)
-
- # 输出文件路径
- self.output_file = FileSelectWidget(
- "输出处理后CSV:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- self.output_file.line_edit.setPlaceholderText("processed_data.csv")
- layout.addWidget(self.output_file)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_btn = QPushButton("独立运行此步骤")
- self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
- self.run_btn.clicked.connect(self.run_step)
- layout.addWidget(self.run_btn)
-
- layout.addStretch()
- self.setLayout(layout)
- self.reset_preview()
-
- def get_config(self):
- """获取配置"""
- config = {
- 'csv_path': self.csv_file.get_path(),
- }
- output_path = self.output_file.get_path()
- if output_path:
- config['output_path'] = output_path
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'csv_path' in config:
- self.csv_file.set_path(config['csv_path'])
- self.load_csv_preview()
- if 'output_path' in config:
- self.output_file.set_path(config['output_path'])
-
- def run_step(self):
- """独立运行步骤4"""
- # 验证输入
- csv_path = self.csv_file.get_path()
- if not csv_path:
- QMessageBox.warning(self, "输入错误", "请选择水质参数文件!")
- return
-
- # 获取主窗口并运行步骤
- main_window = self.window()
- if hasattr(main_window, 'run_single_step'):
- config = {'step4': self.get_config()}
- main_window.run_single_step('step4', config)
-
- def reset_preview(self, message="请选择CSV文件并点击刷新预览"):
- """重置预览表格"""
- empty_model = PandasTableModel(pd.DataFrame())
- self.preview_table.setModel(empty_model)
- self.preview_status_label.setText(message)
-
- def load_csv_preview(self):
- """加载CSV预览数据"""
- csv_path = self.csv_file.get_path()
- if not csv_path:
- self.reset_preview("请先选择CSV文件")
- return
- if not os.path.exists(csv_path):
- self.reset_preview("文件不存在,请检查路径")
- return
-
- try:
- rows_to_preview = max(1, self.preview_rows_spin.value())
- df = pd.read_csv(csv_path, nrows=rows_to_preview)
- if df.empty:
- self.reset_preview("CSV文件为空")
- return
-
- model = PandasTableModel(df)
- self.preview_table.setModel(model)
- self.preview_status_label.setText(
- f"预览 {len(df)} 行,{len(df.columns)} 列(总行数可能更多)"
- )
- except Exception as exc:
- self.reset_preview(f"加载失败: {exc}")
-
-
-class Step5Panel(QWidget):
- """步骤5:光谱提取"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- # 标题
- title = QLabel("步骤5:训练样本光谱提取")
- title.setFont(QFont("Arial", 12, QFont.Bold))
- layout.addWidget(title)
-
- # 去耀斑影像文件(用于独立运行)
- self.deglint_img_file = FileSelectWidget(
- "去耀斑影像:",
- "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
- )
- layout.addWidget(self.deglint_img_file)
-
- # 处理后的CSV文件(用于独立运行)
- self.csv_file = FileSelectWidget(
- "处理后CSV:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- layout.addWidget(self.csv_file)
-
- # 水体掩膜文件(可选,用于独立运行)
- self.boundary_mask_file = FileSelectWidget(
- "水体掩膜:",
- "Mask Files (*.dat *.tif);;All Files (*.*)"
- )
- self.boundary_mask_file.line_edit.setPlaceholderText("可选,如不选择则自动生成")
- layout.addWidget(self.boundary_mask_file)
-
- self.glint_mask_file = FileSelectWidget(
- "耀斑掩膜:",
- "Mask Files (*.dat *.tif);;All Files (*.*)"
- )
- layout.addWidget(self.glint_mask_file)
- step5_glint_hint = QLabel(
- "提示:独立运行本步骤时必须选择耀斑掩膜(通常为步骤2输出的 severe_glint_area.dat),用于在采样时避开耀斑像元。"
- )
- step5_glint_hint.setWordWrap(True)
- step5_glint_hint.setStyleSheet("color: #666; font-size: 10px;")
- layout.addWidget(step5_glint_hint)
-
- # 参数设置
- params_group = QGroupBox("提取参数")
- params_layout = QFormLayout()
-
- self.radius = QSpinBox()
- self.radius.setRange(1, 50)
- self.radius.setValue(5)
- params_layout.addRow("采样半径(像素):", self.radius)
-
- self.source_epsg = QSpinBox()
- self.source_epsg.setRange(1000, 99999)
- self.source_epsg.setValue(4326)
- params_layout.addRow("源坐标系EPSG:", self.source_epsg)
-
- params_group.setLayout(params_layout)
- layout.addWidget(params_group)
-
- # 输出文件路径
- self.output_file = FileSelectWidget(
- "输出训练数据:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- self.output_file.line_edit.setPlaceholderText("training_spectra.csv")
- layout.addWidget(self.output_file)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_btn = QPushButton("独立运行此步骤")
- self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
- self.run_btn.clicked.connect(self.run_step)
- layout.addWidget(self.run_btn)
-
- layout.addStretch()
- self.setLayout(layout)
- # 信号连接:影像文件路径变化时动态更新波段范围
- def get_config(self):
- """获取配置"""
- config = {
- 'radius': self.radius.value(),
- 'source_epsg': self.source_epsg.value(),
- }
- # 添加独立运行所需的文件路径
- deglint_img_path = self.deglint_img_file.get_path()
- if deglint_img_path:
- config['deglint_img_path'] = deglint_img_path
- csv_path = self.csv_file.get_path()
- if csv_path:
- config['csv_path'] = csv_path
- boundary_path = self.boundary_mask_file.get_path()
- if boundary_path:
- config['boundary_path'] = boundary_path
- glint_mask_path = self.glint_mask_file.get_path()
- if glint_mask_path:
- config['glint_mask_path'] = glint_mask_path
- # 添加输出路径
- output_path = self.output_file.get_path()
- if output_path:
- config['output_path'] = output_path
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'radius' in config:
- self.radius.setValue(config['radius'])
- if 'source_epsg' in config:
- self.source_epsg.setValue(config['source_epsg'])
- if 'deglint_img_path' in config:
- self.deglint_img_file.set_path(config['deglint_img_path'])
- if 'csv_path' in config:
- self.csv_file.set_path(config['csv_path'])
- if 'boundary_path' in config:
- self.boundary_mask_file.set_path(config['boundary_path'])
- if 'glint_mask_path' in config:
- self.glint_mask_file.set_path(config['glint_mask_path'])
- if 'output_path' in config:
- self.output_file.set_path(config['output_path'])
-
- def run_step(self):
- """独立运行步骤5"""
- # 验证输入
- deglint_img_path = self.deglint_img_file.get_path()
- csv_path = self.csv_file.get_path()
- if not deglint_img_path:
- QMessageBox.warning(self, "输入错误", "请选择去耀斑影像文件!")
- return
- if not csv_path:
- QMessageBox.warning(self, "输入错误", "请选择处理后的CSV文件!")
- return
- if not self.glint_mask_file.get_path():
- QMessageBox.warning(
- self,
- "输入错误",
- "独立运行光谱特征提取时,必须选择耀斑掩膜文件。\n\n"
- "请提供与去耀斑影像对应的耀斑二值掩膜(一般为步骤2输出的 severe_glint_area.dat)。",
- )
- return
-
- # 获取主窗口并运行步骤
- main_window = self.window()
- if hasattr(main_window, 'run_single_step'):
- config = {'step5': self.get_config()}
- main_window.run_single_step('step5', config)
-
-
-class Step5_5Panel(QWidget):
- """步骤5.5:水质指数计算"""
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.index_checkboxes: Dict[str, QCheckBox] = {}
- self.csv_columns = [] # 存储CSV文件列名
- self.init_ui()
-
- def init_ui(self):
- main_layout = QVBoxLayout()
-
- # 标题
-
-
- # 数据文件选择
- data_group = QGroupBox("数据文件")
- data_layout = QVBoxLayout()
-
- # 训练数据CSV文件选择
- self.training_data_widget = FileSelectWidget("训练数据CSV文件:", "CSV Files (*.csv)")
- data_layout.addWidget(self.training_data_widget)
-
- # 公式CSV文件选择
- self.formula_csv_widget = FileSelectWidget("公式CSV文件:", "CSV Files (*.csv)")
- data_layout.addWidget(self.formula_csv_widget)
-
- # 刷新公式按钮
- refresh_layout = QHBoxLayout()
- self.refresh_button = QPushButton("刷新公式列表")
- self.refresh_button.clicked.connect(self.refresh_formulas)
- refresh_layout.addWidget(self.refresh_button)
- refresh_layout.addStretch()
- data_layout.addLayout(refresh_layout)
-
- data_group.setLayout(data_layout)
- main_layout.addWidget(data_group)
-
- # 公式选择区域
- self.formula_group = QGroupBox("选择要计算的公式")
- formula_outer_layout = QVBoxLayout()
-
- # 按钮控制区域
- button_layout = QHBoxLayout()
- self.select_all_btn = QPushButton("全选")
- self.select_all_btn.clicked.connect(self.select_all_formulas)
- self.deselect_all_btn = QPushButton("清空")
- self.deselect_all_btn.clicked.connect(self.deselect_all_formulas)
- button_layout.addWidget(self.select_all_btn)
- button_layout.addWidget(self.deselect_all_btn)
- button_layout.addStretch()
-
- formula_outer_layout.addLayout(button_layout)
-
- # 公式勾选框网格布局
- self.formula_layout = QGridLayout()
- formula_outer_layout.addLayout(self.formula_layout)
-
- self.formula_group.setLayout(formula_outer_layout)
- main_layout.addWidget(self.formula_group)
-
- # 输出文件设置
- output_group = QGroupBox("输出设置")
- output_layout = QVBoxLayout()
-
- output_hbox = QHBoxLayout()
- output_hbox.addWidget(QLabel("输出文件名:"))
- self.output_filename = QLineEdit("water_quality_indices.csv")
- output_hbox.addWidget(self.output_filename)
- output_layout.addLayout(output_hbox)
-
- output_group.setLayout(output_layout)
- main_layout.addWidget(output_group)
-
- # 启用选项
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- main_layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_button = QPushButton("独立运行此步骤")
- self.run_button.setStyleSheet("""
- QPushButton {
- background-color: #4CAF50;
- color: white;
- padding: 8px 16px;
- border: none;
- border-radius: 4px;
- font-weight: bold;
- }
- QPushButton:hover {
- background-color: #45a049;
- }
- QPushButton:pressed {
- background-color: #3e8e41;
- }
- """)
- self.run_button.clicked.connect(self.run_step)
- main_layout.addWidget(self.run_button)
-
- # 公式编辑区域
- formula_edit_group = QGroupBox("添加自定义公式")
- formula_edit_layout = QFormLayout()
-
- self.formula_name_edit = QLineEdit()
-
- # 公式类别下拉选择框
- self.formula_category_combo = QComboBox()
- self.formula_category_combo.addItems([
- "chlorophyll_a",
- "Phycocyanin (BGA_PC)",
- "Total Nitrogen (TN)",
- "Total Phosphorus (TP)",
- "Orthophosphate",
- "COD",
- "BOD",
- "TOC",
- "Dissolved Oxygen (DO)",
- "E. coli",
- "Total Coliforms",
- "Turbidity",
- "Total Suspended Solids (TSS)",
- "Color",
- "pH",
- "Temperature",
- "Conductivity",
- "Total Dissolved Solids (TDS)"
- ])
- self.formula_category_combo.setEditable(True) # 允许用户输入自定义类别
-
- self.formula_expression_edit = QLineEdit()
- self.formula_reference_edit = QLineEdit()
-
- formula_edit_layout.addRow("公式名称:", self.formula_name_edit)
- formula_edit_layout.addRow("公式类别:", self.formula_category_combo)
- formula_edit_layout.addRow("公式表达式:", self.formula_expression_edit)
- formula_edit_layout.addRow("参考文献:", self.formula_reference_edit)
-
- add_button = QPushButton("添加公式")
- add_button.clicked.connect(self.add_custom_formula)
- formula_edit_layout.addRow(add_button)
-
- formula_edit_group.setLayout(formula_edit_layout)
- main_layout.addWidget(formula_edit_group)
-
- main_layout.addStretch()
- self.setLayout(main_layout)
-
- def refresh_formulas(self):
- """刷新公式列表"""
- formula_csv_path = self.formula_csv_widget.get_path()
- if not formula_csv_path or not os.path.exists(formula_csv_path):
- QMessageBox.warning(self, "警告", "请先选择有效的公式CSV文件")
- return
-
- try:
- # 清除现有的勾选框
- for checkbox in self.index_checkboxes.values():
- self.formula_layout.removeWidget(checkbox)
- checkbox.deleteLater()
- self.index_checkboxes.clear()
-
- # 读取公式CSV文件
- df = pd.read_csv(formula_csv_path)
- if df.empty or 'Formula_Name' not in df.columns:
- QMessageBox.warning(self, "警告", "公式CSV文件格式不正确")
- return
-
- # 获取所有公式名称(跳过第一行)
- formula_names = df['Formula_Name'].tolist()[1:]
-
- # 创建3列布局的勾选框
- row, col = 0, 0
- for formula_name in formula_names:
- if pd.isna(formula_name) or not formula_name.strip():
- continue
-
- checkbox = QCheckBox(formula_name.strip())
- checkbox.setChecked(True)
- self.index_checkboxes[formula_name.strip()] = checkbox
- self.formula_layout.addWidget(checkbox, row, col)
-
- col += 1
- if col >= 3: # 每行3列
- col = 0
- row += 1
-
- except Exception as e:
- QMessageBox.critical(self, "错误", f"读取公式文件失败: {str(e)}")
-
- def add_custom_formula(self):
- """添加自定义公式到公式CSV文件"""
- formula_csv_path = self.formula_csv_widget.get_path()
- if not formula_csv_path:
- QMessageBox.warning(self, "警告", "请先选择公式CSV文件")
- return
-
- formula_name = self.formula_name_edit.text().strip()
- formula_category = self.formula_category_combo.currentText().strip()
- formula_expression = self.formula_expression_edit.text().strip()
- formula_reference = self.formula_reference_edit.text().strip()
-
- if not all([formula_name, formula_category, formula_expression]):
- QMessageBox.warning(self, "警告", "请填写公式名称、类别和表达式")
- return
-
- try:
- # 读取现有公式文件或创建新文件
- if os.path.exists(formula_csv_path):
- df = pd.read_csv(formula_csv_path)
- else:
- df = pd.DataFrame(columns=['Formula_Name', 'Category', 'Formula', 'Reference'])
-
- # 添加新公式
- new_row = pd.DataFrame({
- 'Formula_Name': [formula_name],
- 'Category': [formula_category],
- 'Formula': [formula_expression],
- 'Reference': [formula_reference]
- })
- df = pd.concat([df, new_row], ignore_index=True)
-
- # 保存文件
- df.to_csv(formula_csv_path, index=False, encoding='utf-8')
-
- # 清空输入框
- self.formula_name_edit.clear()
- self.formula_category_combo.setCurrentIndex(0) # 重置到第一个选项
- self.formula_expression_edit.clear()
- self.formula_reference_edit.clear()
-
- # 刷新公式列表
- self.refresh_formulas()
-
- QMessageBox.information(self, "成功", "公式添加成功")
-
- except Exception as e:
- QMessageBox.critical(self, "错误", f"添加公式失败: {str(e)}")
-
- def get_config(self) -> Dict[str, Union[List[str], str, bool]]:
- """获取配置"""
- selected = [
- name for name, checkbox in self.index_checkboxes.items()
- if checkbox.isChecked()
- ]
- return {
- 'training_spectra_path': self.training_data_widget.get_path() or None,
- 'formula_csv_file': self.formula_csv_widget.get_path() or None,
- 'formula_names': selected,
- 'output_filename': self.output_filename.text().strip() or "water_quality_indices.csv",
- 'enabled': self.enable_checkbox.isChecked()
- }
-
- def set_config(self, config):
- """设置配置"""
- if 'training_spectra_path' in config:
- self.training_data_widget.set_path(config['training_spectra_path'])
-
- if 'formula_csv_file' in config:
- self.formula_csv_widget.set_path(config['formula_csv_file'])
- # 设置CSV路径后自动刷新公式信息
- self.refresh_formulas()
-
- if 'formula_names' in config:
- selected_formulas = set(config['formula_names'])
- for name, checkbox in self.index_checkboxes.items():
- checkbox.setChecked(name in selected_formulas)
-
- if 'output_filename' in config:
- self.output_filename.setText(config['output_filename'])
-
- if 'enabled' in config:
- self.enable_checkbox.setChecked(config['enabled'])
-
- def is_enabled(self) -> bool:
- return self.enable_checkbox.isChecked()
-
- def select_all_formulas(self):
- """全选所有公式"""
- for checkbox in self.index_checkboxes.values():
- checkbox.setChecked(True)
-
- def deselect_all_formulas(self):
- """清空所有公式"""
- for checkbox in self.index_checkboxes.values():
- checkbox.setChecked(False)
-
- def run_step(self):
- """独立运行步骤5.5"""
- # 验证输入
- training_csv_path = self.training_data_widget.get_path()
- formula_csv_path = self.formula_csv_widget.get_path()
-
- if not training_csv_path:
- QMessageBox.warning(self, "输入验证失败", "请选择训练数据CSV文件")
- return
- if not formula_csv_path:
- QMessageBox.warning(self, "输入验证失败", "请选择公式CSV文件")
- return
- if not os.path.exists(training_csv_path):
- QMessageBox.warning(self, "输入验证失败", "训练数据CSV文件不存在")
- return
- if not os.path.exists(formula_csv_path):
- QMessageBox.warning(self, "输入验证失败", "公式CSV文件不存在")
- return
-
- # 获取配置
- config = self.get_config()
-
- # 调用GUI的run_single_step方法
- parent = self.parent()
- while parent and not hasattr(parent, 'run_single_step'):
- parent = parent.parent()
-
- if parent and hasattr(parent, 'run_single_step'):
- parent.run_single_step('step5_5', {'step5_5': config})
- else:
- QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
-
-
-class Step6Panel(QWidget):
- """步骤6:机器学习建模"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- # 标题
-
-
- # 训练数据文件(用于独立运行)
- self.training_csv_file = FileSelectWidget(
- "训练数据:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- layout.addWidget(self.training_csv_file)
-
- # 机器学习模型页面
- self.ml_page = QWidget()
- self.create_ml_page()
- layout.addWidget(self.ml_page)
-
- # 输出文件路径
- self.output_dir = FileSelectWidget(
- "输出模型目录:",
- "Directories;;All Files (*.*)"
- )
- self.output_dir.line_edit.setPlaceholderText("models_output")
- # 修改浏览按钮为选择目录
- self.output_dir.browse_btn.clicked.disconnect()
- self.output_dir.browse_btn.clicked.connect(self.browse_output_dir)
- layout.addWidget(self.output_dir)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_btn = QPushButton("独立运行此步骤")
- self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
- self.run_btn.clicked.connect(self.run_step)
- layout.addWidget(self.run_btn)
-
- layout.addStretch()
- self.setLayout(layout)
- # 信号连接:影像文件路径变化时动态更新波段范围
- def create_ml_page(self):
- """创建机器学习模型页面"""
- layout = QVBoxLayout()
-
- # 参数设置
- params_group = QGroupBox("训练参数")
- params_layout = QFormLayout()
-
- self.feature_start = QLineEdit()
- self.feature_start.setText("374.285004")
- params_layout.addRow("特征起始列:", self.feature_start)
-
- self.cv_folds = QSpinBox()
- self.cv_folds.setRange(2, 10)
- self.cv_folds.setValue(3)
- params_layout.addRow("交叉验证折数:", self.cv_folds)
-
- params_group.setLayout(params_layout)
- layout.addWidget(params_group)
-
- # 预处理方法 - 多选
- preproc_group = QGroupBox("预处理方法 (可多选)")
- preproc_layout = QVBoxLayout()
-
- # 创建网格布局来放置checkbox
- preproc_grid = QGridLayout()
- self.preproc_checkboxes = {}
- preproc_methods = ['None', 'MMS', 'SS', 'SNV', 'MA', 'SG', 'MSC', 'D1', 'D2', 'DT', 'CT']
-
- for i, method in enumerate(preproc_methods):
- checkbox = QCheckBox(method)
- checkbox.setChecked(True) # 默认全选
- self.preproc_checkboxes[method] = checkbox
- preproc_grid.addWidget(checkbox, i // 4, i % 4)
-
- # 全选/反选按钮
- button_layout = QHBoxLayout()
- select_all_btn = QPushButton("全选")
- deselect_all_btn = QPushButton("全不选")
- select_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, True))
- deselect_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, False))
- button_layout.addWidget(select_all_btn)
- button_layout.addWidget(deselect_all_btn)
- button_layout.addStretch()
-
- preproc_layout.addLayout(preproc_grid)
- preproc_layout.addLayout(button_layout)
- preproc_group.setLayout(preproc_layout)
- layout.addWidget(preproc_group)
-
- # 模型选择 - 多选
- model_group = QGroupBox("模型类型 (可多选)")
- model_layout = QVBoxLayout()
-
- model_grid = QGridLayout()
- self.model_checkboxes = {}
-
- # 按照模型类型分组排序
- model_groups = [
- ("线性模型", ['LinearRegression', 'Ridge', 'Lasso', 'ElasticNet', 'PLS']),
- ("树模型", ['DecisionTree', 'RF', 'ExtraTrees', 'XGBoost', 'LightGBM', 'CatBoost']),
- ("集成学习", ['GradientBoosting', 'AdaBoost']),
- ("其他模型", ['SVR', 'KNN', 'MLP'])
- ]
-
- row = 0
- for group_name, models in model_groups:
- # 添加分组标签
- group_label = QLabel(f"{group_name}")
- group_label.setStyleSheet(f"background-color: {ModernStylesheet.COLORS['hover']}; padding: 5px; border: 1px solid {ModernStylesheet.COLORS['border_light']}; border-radius: 3px;")
- model_grid.addWidget(group_label, row, 0, 1, 4) # 跨4列
- row += 1
-
- # 添加该组的模型checkbox
- for i, model in enumerate(models):
- checkbox = QCheckBox(model)
- # 默认选择常用的4个
- checkbox.setChecked(model in ['SVR', 'RF', 'Ridge', 'Lasso'])
- self.model_checkboxes[model] = checkbox
- model_grid.addWidget(checkbox, row, i % 4)
-
- # 如果这一行满了,换到下一行
- if (i + 1) % 4 == 0:
- row += 1
-
- # 每组结束后换行
- row += 1
-
- model_button_layout = QHBoxLayout()
- model_select_all = QPushButton("全选")
- model_deselect_all = QPushButton("全不选")
- model_select_all.clicked.connect(lambda: self._toggle_checkboxes(self.model_checkboxes, True))
- model_deselect_all.clicked.connect(lambda: self._toggle_checkboxes(self.model_checkboxes, False))
- model_button_layout.addWidget(model_select_all)
- model_button_layout.addWidget(model_deselect_all)
- model_button_layout.addStretch()
-
- model_layout.addLayout(model_grid)
- model_layout.addLayout(model_button_layout)
- model_group.setLayout(model_layout)
- layout.addWidget(model_group)
-
- # 数据划分方法 - 多选
- split_group = QGroupBox("数据划分方法 (可多选)")
- split_layout = QVBoxLayout()
-
- split_grid = QGridLayout()
- self.split_checkboxes = {}
- split_methods = ['spxy', 'ks', 'random']
-
- for i, method in enumerate(split_methods):
- checkbox = QCheckBox(method)
- checkbox.setChecked(True) # 默认全选
- self.split_checkboxes[method] = checkbox
- split_grid.addWidget(checkbox, 0, i)
-
- split_button_layout = QHBoxLayout()
- split_select_all = QPushButton("全选")
- split_deselect_all = QPushButton("全不选")
- split_select_all.clicked.connect(lambda: self._toggle_checkboxes(self.split_checkboxes, True))
- split_deselect_all.clicked.connect(lambda: self._toggle_checkboxes(self.split_checkboxes, False))
- split_button_layout.addWidget(split_select_all)
- split_button_layout.addWidget(split_deselect_all)
- split_button_layout.addStretch()
-
- split_layout.addLayout(split_grid)
- split_layout.addLayout(split_button_layout)
- split_group.setLayout(split_layout)
- layout.addWidget(split_group)
-
- self.ml_page.setLayout(layout)
-
-
- def _toggle_checkboxes(self, checkboxes_dict, checked):
- """统一设置checkbox状态"""
- for checkbox in checkboxes_dict.values():
- checkbox.setChecked(checked)
-
- def browse_output_dir(self):
- """浏览输出目录"""
- dir_path = QFileDialog.getExistingDirectory(self, "选择输出模型目录", "")
- if dir_path:
- self.output_dir.set_path(dir_path)
-
- def get_config(self):
- """获取配置"""
- # 获取选中的预处理方法
- preprocessing_methods = [method for method, checkbox in self.preproc_checkboxes.items()
- if checkbox.isChecked()]
-
- # 获取选中的模型类型
- model_names = [model for model, checkbox in self.model_checkboxes.items()
- if checkbox.isChecked()]
-
- # 获取选中的数据划分方法
- split_methods = [method for method, checkbox in self.split_checkboxes.items()
- if checkbox.isChecked()]
-
- config = {
- 'feature_start_column': self.feature_start.text(),
- 'preprocessing_methods': preprocessing_methods if preprocessing_methods else ['None'],
- 'model_names': model_names if model_names else ['SVR'],
- 'split_methods': split_methods if split_methods else ['random'],
- 'cv_folds': self.cv_folds.value()
- }
- # 添加训练数据路径(用于独立运行)
- training_csv_path = self.training_csv_file.get_path()
- if training_csv_path:
- config['training_csv_path'] = training_csv_path
- # 添加输出路径
- output_dir = self.output_dir.get_path()
- if output_dir:
- config['output_dir'] = output_dir
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'feature_start_column' in config:
- self.feature_start.setText(str(config['feature_start_column']))
- if 'cv_folds' in config:
- self.cv_folds.setValue(config['cv_folds'])
-
- # 设置预处理方法
- if 'preprocessing_methods' in config:
- methods = config['preprocessing_methods']
- for method, checkbox in self.preproc_checkboxes.items():
- checkbox.setChecked(method in methods)
-
- # 设置模型类型
- if 'model_names' in config:
- models = config['model_names']
- for model, checkbox in self.model_checkboxes.items():
- checkbox.setChecked(model in models)
-
- # 设置数据划分方法
- if 'split_methods' in config:
- methods = config['split_methods']
- for method, checkbox in self.split_checkboxes.items():
- checkbox.setChecked(method in methods)
- if 'training_csv_path' in config:
- self.training_csv_file.set_path(config['training_csv_path'])
- if 'output_dir' in config:
- self.output_dir.set_path(config['output_dir'])
-
- def run_step(self):
- """独立运行步骤6"""
- # 验证输入
- training_csv_path = self.training_csv_file.get_path()
- if not training_csv_path:
- QMessageBox.warning(self, "输入错误", "请选择训练数据CSV文件!")
- return
-
- # 获取主窗口并运行步骤
- main_window = self.window()
- if hasattr(main_window, 'run_single_step'):
- config = {'step6': self.get_config()}
- main_window.run_single_step('step6', config)
-
- def get_training_params(self):
- """获取模型训练参数"""
- return {
- 'pipeline_type': 'machine_learning',
- 'feature_start': float(self.feature_start.text()),
- 'cv_folds': self.cv_folds.value(),
- 'preprocess_methods': [method for method, cb in self.preproc_checkboxes.items() if cb.isChecked()],
- 'model_types': [model for model, cb in self.model_checkboxes.items() if cb.isChecked()],
- 'split_methods': [method for method, cb in self.split_checkboxes.items() if cb.isChecked()]
- }
-
-
-class Step7Panel(QWidget):
- """步骤7:采样点生成"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- # 标题
-
-
- # 去耀斑影像文件(用于独立运行)
- self.deglint_img_file = FileSelectWidget(
- "去耀斑影像:",
- "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
- )
- layout.addWidget(self.deglint_img_file)
-
- # 水域掩膜文件(可选,用于独立运行)
- self.water_mask_file = FileSelectWidget(
- "水域掩膜:",
- "Mask Files (*.dat *.tif);;All Files (*.*)"
- )
- self.water_mask_file.label.setText("水域掩膜:")
- layout.addWidget(self.water_mask_file)
-
- # 参数设置
- params_group = QGroupBox("采样参数")
- params_layout = QFormLayout()
-
- self.interval = QSpinBox()
- self.interval.setRange(10, 500)
- self.interval.setValue(50)
- params_layout.addRow("采样点间隔(像素):", self.interval)
-
- self.sample_radius = QSpinBox()
- self.sample_radius.setRange(1, 50)
- self.sample_radius.setValue(5)
- params_layout.addRow("采样半径(像素):", self.sample_radius)
-
- self.chunk_size = QSpinBox()
- self.chunk_size.setRange(100, 10000)
- self.chunk_size.setValue(1000)
- params_layout.addRow("处理块大小:", self.chunk_size)
-
- params_group.setLayout(params_layout)
- layout.addWidget(params_group)
-
- # 输出文件路径
- self.output_file = FileSelectWidget(
- "输出采样点:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- self.output_file.line_edit.setPlaceholderText("sampling_points.csv")
- layout.addWidget(self.output_file)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_btn = QPushButton("独立运行此步骤")
- self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
- self.run_btn.clicked.connect(self.run_step)
- layout.addWidget(self.run_btn)
-
- layout.addStretch()
- self.setLayout(layout)
- # 信号连接:影像文件路径变化时动态更新波段范围
- def get_config(self):
- """获取配置"""
- config = {
- 'interval': self.interval.value(),
- 'sample_radius': self.sample_radius.value(),
- 'chunk_size': self.chunk_size.value(),
- }
- # 添加独立运行所需的文件路径
- deglint_img_path = self.deglint_img_file.get_path()
- if deglint_img_path:
- config['deglint_img_path'] = deglint_img_path
- water_mask_path = self.water_mask_file.get_path()
- if water_mask_path:
- config['water_mask_path'] = water_mask_path
- # 添加输出路径
- output_path = self.output_file.get_path()
- if output_path:
- config['output_path'] = output_path
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'interval' in config:
- self.interval.setValue(config['interval'])
- if 'sample_radius' in config:
- self.sample_radius.setValue(config['sample_radius'])
- if 'chunk_size' in config:
- self.chunk_size.setValue(config['chunk_size'])
- if 'deglint_img_path' in config:
- self.deglint_img_file.set_path(config['deglint_img_path'])
- if 'water_mask_path' in config:
- self.water_mask_file.set_path(config['water_mask_path'])
- if 'output_path' in config:
- self.output_file.set_path(config['output_path'])
-
- def run_step(self):
- """独立运行步骤7"""
- # 验证输入
- deglint_img_path = self.deglint_img_file.get_path()
- if not deglint_img_path:
- QMessageBox.warning(self, "输入错误", "请选择去耀斑影像文件!")
- return
-
- # 获取主窗口并运行步骤
- main_window = self.window()
- if hasattr(main_window, 'run_single_step'):
- config = {'step7': self.get_config()}
- main_window.run_single_step('step7', config)
-
-
-class Step8Panel(QWidget):
- """步骤8:机器学习预测"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- # 标题
-
-
- # 采样光谱CSV文件(用于独立运行)
- self.sampling_csv_file = FileSelectWidget(
- "采样光谱CSV:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- layout.addWidget(self.sampling_csv_file)
-
- # 模型目录(用于独立运行)
- self.models_dir_file = FileSelectWidget(
- "模型目录:",
- "Directories;;All Files (*.*)"
- )
- self.models_dir_file.label.setText("模型目录:")
- # 修改浏览按钮为选择目录
- self.models_dir_file.browse_btn.clicked.disconnect()
- self.models_dir_file.browse_btn.clicked.connect(self.browse_models_dir)
- layout.addWidget(self.models_dir_file)
-
- # 参数设置
- params_group = QGroupBox("预测参数")
- params_layout = QFormLayout()
-
- self.metric = QComboBox()
- self.metric.addItems(['test_r2', 'test_rmse', 'test_mae'])
- params_layout.addRow("模型选择指标:", self.metric)
-
- self.prediction_column = QLineEdit()
- self.prediction_column.setText("prediction")
- params_layout.addRow("预测列名:", self.prediction_column)
-
- params_group.setLayout(params_layout)
- layout.addWidget(params_group)
-
- # 输出路径
- self.output_file = FileSelectWidget(
- "输出路径:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- layout.addWidget(self.output_file)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_btn = QPushButton("独立运行此步骤")
- self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
- self.run_btn.clicked.connect(self.run_step)
- layout.addWidget(self.run_btn)
-
- layout.addStretch()
- self.setLayout(layout)
-
- def browse_models_dir(self):
- """浏览模型目录"""
- dir_path = QFileDialog.getExistingDirectory(self, "选择模型目录", "")
- if dir_path:
- self.models_dir_file.set_path(dir_path)
-
- def get_config(self):
- """获取配置"""
- config = {
- 'metric': self.metric.currentText(),
- 'prediction_column': self.prediction_column.text(),
- }
- # 添加独立运行所需的文件路径
- sampling_csv_path = self.sampling_csv_file.get_path()
- if sampling_csv_path:
- config['sampling_csv_path'] = sampling_csv_path
- models_dir = self.models_dir_file.get_path()
- if models_dir:
- config['models_dir'] = models_dir
- # 添加输出路径
- output_path = self.output_file.get_path()
- if output_path:
- config['output_path'] = output_path
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'metric' in config:
- idx = self.metric.findText(config['metric'])
- if idx >= 0:
- self.metric.setCurrentIndex(idx)
-
- if 'prediction_column' in config:
- self.prediction_column.setText(config['prediction_column'])
- if 'sampling_csv_path' in config:
- self.sampling_csv_file.set_path(config['sampling_csv_path'])
- if 'models_dir' in config:
- self.models_dir_file.set_path(config['models_dir'])
- if 'output_path' in config:
- self.output_file.set_path(config['output_path'])
-
- def run_step(self):
- """独立运行步骤8"""
- # 验证输入
- sampling_csv_path = self.sampling_csv_file.get_path()
- models_dir = self.models_dir_file.get_path()
- if not sampling_csv_path:
- QMessageBox.warning(self, "输入错误", "请选择采样光谱CSV文件!")
- return
- if not models_dir:
- QMessageBox.warning(self, "输入错误", "请选择模型目录!")
- return
-
- # 获取主窗口并运行步骤
- main_window = self.window()
- if hasattr(main_window, 'run_single_step'):
- config = {'step8': self.get_config()}
- main_window.run_single_step('step8', config)
-
-
-class Step9Panel(QWidget):
- """步骤9:分布图生成"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self._batch_thread = None
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- hint = QLabel(
- "独立运行:可选「单个 CSV」或「文件夹批量」(扫描目录下所有 .csv)。"
- "完整流程中预测 CSV 由步骤11、12、13 自动传入,无需在此选择。"
- )
- hint.setWordWrap(True)
- hint.setStyleSheet(f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};")
- layout.addWidget(hint)
-
- mode_row = QHBoxLayout()
- self.mode_single_rb = QRadioButton("单个 CSV 文件")
- self.mode_folder_rb = QRadioButton("文件夹批量")
- self.mode_single_rb.setChecked(True)
- self._mode_group = QButtonGroup(self)
- self._mode_group.addButton(self.mode_single_rb, 0)
- self._mode_group.addButton(self.mode_folder_rb, 1)
- self.mode_single_rb.toggled.connect(self._on_step9_mode_changed)
- self.mode_folder_rb.toggled.connect(self._on_step9_mode_changed)
- mode_row.addWidget(self.mode_single_rb)
- mode_row.addWidget(self.mode_folder_rb)
- mode_row.addStretch()
- layout.addLayout(mode_row)
-
- self.prediction_csv_file = FileSelectWidget(
- "预测结果CSV:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- layout.addWidget(self.prediction_csv_file)
-
- folder_row = QHBoxLayout()
- self.prediction_csv_dir_label = QLabel("预测CSV目录:")
- self.prediction_csv_dir_label.setMinimumWidth(120)
- self.prediction_csv_dir_edit = QLineEdit()
- self.prediction_csv_dir_edit.setPlaceholderText("选择含多个预测结果 CSV 的文件夹…")
- pred_dir_btn = QPushButton("浏览…")
- pred_dir_btn.setMaximumWidth(80)
- pred_dir_btn.clicked.connect(self.browse_prediction_csv_dir)
- folder_row.addWidget(self.prediction_csv_dir_label)
- folder_row.addWidget(self.prediction_csv_dir_edit, 1)
- folder_row.addWidget(pred_dir_btn)
- self._folder_row_widget = QWidget()
- self._folder_row_widget.setLayout(folder_row)
- layout.addWidget(self._folder_row_widget)
-
- self.recursive_csv_cb = QCheckBox("包含子文件夹(递归扫描 *.csv)")
- layout.addWidget(self.recursive_csv_cb)
-
- self.boundary_file = FileSelectWidget(
- "边界文件:",
- "Shapefiles (*.shp);;All Files (*.*)"
- )
- layout.addWidget(self.boundary_file)
-
- # 参数设置
- params_group = QGroupBox("生成参数")
- params_layout = QFormLayout()
-
- self.resolution = QDoubleSpinBox()
- self.resolution.setRange(1, 1000)
- self.resolution.setValue(30)
- params_layout.addRow("分辨率(米):", self.resolution)
-
- self.input_crs = QLineEdit()
- self.input_crs.setText("EPSG:32651")
- params_layout.addRow("输入坐标系:", self.input_crs)
-
- self.output_crs = QLineEdit()
- self.output_crs.setText("EPSG:4326")
- params_layout.addRow("输出坐标系:", self.output_crs)
-
- self.show_points = QCheckBox("显示采样点")
- params_layout.addRow("", self.show_points)
-
- self.use_diffusion = QCheckBox("启用距离扩散")
- self.use_diffusion.setChecked(True)
- params_layout.addRow("", self.use_diffusion)
-
- params_group.setLayout(params_layout)
- layout.addWidget(params_group)
-
- # 输出目录(可选):在此目录下生成「CSV文件名_distribution.png」;留空则用工作目录/14_visualization
- self.output_dir = FileSelectWidget(
- "输出分布图目录:",
- "Directories;;All Files (*.*)"
- )
- self.output_dir.line_edit.setPlaceholderText("留空→工作目录/14_visualization")
- # 修改浏览按钮为选择目录
- self.output_dir.browse_btn.clicked.disconnect()
- self.output_dir.browse_btn.clicked.connect(self.browse_output_dir)
- layout.addWidget(self.output_dir)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_button = QPushButton("独立运行此步骤")
- self.run_button.setStyleSheet("""
- QPushButton {
- background-color: #4CAF50;
- color: white;
- padding: 8px 16px;
- border: none;
- border-radius: 4px;
- font-weight: bold;
- }
- QPushButton:hover {
- background-color: #45a049;
- }
- QPushButton:pressed {
- background-color: #3e8e41;
- }
- """)
- self.run_button.clicked.connect(self.run_step)
- layout.addWidget(self.run_button)
-
- layout.addStretch()
- self.setLayout(layout)
- self._on_step9_mode_changed()
-
- def _on_step9_mode_changed(self):
- folder_mode = self.mode_folder_rb.isChecked()
- self.prediction_csv_file.setEnabled(not folder_mode)
- self._folder_row_widget.setEnabled(folder_mode)
- self.recursive_csv_cb.setEnabled(folder_mode)
-
- def browse_prediction_csv_dir(self):
- d = QFileDialog.getExistingDirectory(self, "选择预测结果 CSV 所在文件夹")
- if d:
- self.prediction_csv_dir_edit.setText(d)
-
- def _collect_csv_paths_from_folder(self) -> List[str]:
- folder = (self.prediction_csv_dir_edit.text() or "").strip()
- if not folder or not os.path.isdir(folder):
- return []
- root = Path(folder)
- if self.recursive_csv_cb.isChecked():
- files = sorted(root.rglob("*.csv"))
- else:
- files = sorted(root.glob("*.csv"))
- return [str(p) for p in files if p.is_file()]
-
- def _step9_base_pipeline_kwargs(self) -> dict:
- return {
- 'boundary_shp_path': self.boundary_file.get_path(),
- 'resolution': self.resolution.value(),
- 'input_crs': self.input_crs.text(),
- 'output_crs': self.output_crs.text(),
- 'show_sample_points': self.show_points.isChecked(),
- 'use_distance_diffusion': self.use_diffusion.isChecked(),
- }
-
- def get_config(self):
- """含 GUI 专用字段 step9_batch_mode / prediction_csv_dir / recursive_csv_scan;pipeline 调用前会剔除。"""
- pred_csv = (self.prediction_csv_file.get_path() or "").strip()
- folder_mode = self.mode_folder_rb.isChecked()
- pred_dir = (self.prediction_csv_dir_edit.text() or "").strip()
- config = {
- 'step9_batch_mode': 'folder' if folder_mode else 'single',
- 'prediction_csv_dir': pred_dir if pred_dir else None,
- 'recursive_csv_scan': self.recursive_csv_cb.isChecked(),
- 'prediction_csv_path': None if folder_mode else (pred_csv if pred_csv else None),
- 'boundary_shp_path': self.boundary_file.get_path(),
- 'resolution': self.resolution.value(),
- 'input_crs': self.input_crs.text(),
- 'output_crs': self.output_crs.text(),
- 'show_sample_points': self.show_points.isChecked(),
- 'use_distance_diffusion': self.use_diffusion.isChecked(),
- }
- out_dir = (self.output_dir.get_path() or "").strip()
- if not folder_mode and pred_csv and out_dir:
- stem = Path(pred_csv).stem
- config['output_image_path'] = str(Path(out_dir) / f"{stem}_distribution.png")
- else:
- config['output_image_path'] = None
- return config
-
- def set_config(self, config):
- """设置配置"""
- mode = config.get('step9_batch_mode', 'single')
- if mode == 'folder':
- self.mode_folder_rb.setChecked(True)
- else:
- self.mode_single_rb.setChecked(True)
- if config.get('prediction_csv_dir'):
- self.prediction_csv_dir_edit.setText(str(config['prediction_csv_dir']))
- if 'recursive_csv_scan' in config:
- self.recursive_csv_cb.setChecked(bool(config['recursive_csv_scan']))
- if 'prediction_csv_path' in config and config['prediction_csv_path']:
- self.prediction_csv_file.set_path(str(config['prediction_csv_path']))
- if 'boundary_shp_path' in config:
- self.boundary_file.set_path(config['boundary_shp_path'])
- if 'resolution' in config:
- self.resolution.setValue(config['resolution'])
- if 'input_crs' in config:
- self.input_crs.setText(config['input_crs'])
- if 'output_crs' in config:
- self.output_crs.setText(config['output_crs'])
- if 'show_sample_points' in config:
- self.show_points.setChecked(config['show_sample_points'])
- if 'use_distance_diffusion' in config:
- self.use_diffusion.setChecked(config['use_distance_diffusion'])
- if 'output_dir' in config and config['output_dir']:
- self.output_dir.set_path(str(config['output_dir']))
- elif config.get('output_image_path'):
- p = Path(str(config['output_image_path']))
- if p.parent and str(p.parent) != '.':
- self.output_dir.set_path(str(p.parent))
-
- def browse_output_dir(self):
- """浏览输出目录"""
- dir_path = QFileDialog.getExistingDirectory(self, "选择输出模型目录", "")
- if dir_path:
- self.output_dir.set_path(dir_path)
-
- def run_step(self):
- """独立运行步骤9(单文件走原 WorkerThread;文件夹走批量线程)"""
- if self._batch_thread and self._batch_thread.isRunning():
- QMessageBox.information(self, "提示", "批量任务正在运行,请稍候。")
- return
-
- boundary_shp_path = self.boundary_file.get_path()
- if not boundary_shp_path:
- QMessageBox.warning(self, "输入验证失败", "请选择边界文件")
- return
- if not os.path.exists(boundary_shp_path):
- QMessageBox.warning(self, "输入验证失败", "边界文件不存在")
- return
-
- parent = self.parent()
- while parent and not hasattr(parent, 'run_single_step'):
- parent = parent.parent()
-
- if not parent or not hasattr(parent, 'run_single_step'):
- QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
- return
-
- if self.mode_folder_rb.isChecked():
- csv_list = self._collect_csv_paths_from_folder()
- if not csv_list:
- QMessageBox.warning(
- self,
- "输入验证失败",
- "所选文件夹中未找到 .csv 文件,或目录无效。\n"
- "可勾选「包含子文件夹」以递归扫描。",
- )
- return
- if not PIPELINE_AVAILABLE:
- QMessageBox.critical(self, "错误", "Pipeline 模块不可用,无法批量生成专题图。")
- return
- work_dir = getattr(parent, "work_dir", None) or "./work_dir"
- work_dir = str(work_dir)
- base_kw = self._step9_base_pipeline_kwargs()
- out_dir_opt = (self.output_dir.get_path() or "").strip() or None
- self.run_button.setEnabled(False)
- self._batch_thread = Step9BatchThread(work_dir, csv_list, base_kw, out_dir_opt)
- main_win = parent
-
- def _batch_log(msg, lvl):
- if hasattr(main_win, "log_message"):
- main_win.log_message(msg, lvl)
-
- self._batch_thread.log_message.connect(_batch_log, Qt.QueuedConnection)
- self._batch_thread.finished_ok.connect(self._on_step9_batch_ok, Qt.QueuedConnection)
- self._batch_thread.failed.connect(self._on_step9_batch_fail, Qt.QueuedConnection)
- self._batch_thread.finished.connect(lambda: self.run_button.setEnabled(True), Qt.QueuedConnection)
- self._batch_thread.start()
- if hasattr(parent, "log_message"):
- parent.log_message(f"专题图批量:共 {len(csv_list)} 个 CSV,工作目录 {work_dir}", "info")
- return
-
- prediction_csv_path = (self.prediction_csv_file.get_path() or "").strip()
- if not prediction_csv_path:
- QMessageBox.warning(
- self,
- "输入验证失败",
- "请选择「预测结果 CSV」文件,或切换到「文件夹批量」。",
- )
- return
- if not os.path.isfile(prediction_csv_path):
- QMessageBox.warning(self, "输入验证失败", "预测结果 CSV 不存在或不是文件")
- return
-
- config = self.get_config()
- parent.run_single_step('step9', {'step9': config})
-
- def _on_step9_batch_ok(self, n: int):
- QMessageBox.information(self, "完成", f"已批量生成 {n} 个分布图。")
- parent = self.parent()
- while parent and not hasattr(parent, "log_message"):
- parent = parent.parent()
- if parent and hasattr(parent, "log_message"):
- parent.log_message(f"专题图批量完成,共 {n} 个文件。", "info")
-
- def _on_step9_batch_fail(self, err: str):
- QMessageBox.critical(self, "失败", f"批量生成中断:\n{err[:900]}")
- parent = self.parent()
- while parent and not hasattr(parent, "log_message"):
- parent = parent.parent()
- if parent and hasattr(parent, "log_message"):
- parent.log_message(err, "error")
-
-
-class Step8_5Panel(QWidget):
- """步骤8.5:非经验模型预测"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- # 标题
-
-
- # 采样光谱CSV文件选择
- self.sampling_csv_file = FileSelectWidget(
- "采样光谱CSV:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- layout.addWidget(self.sampling_csv_file)
-
- # 模型目录选择
- self.models_dir_file = FileSelectWidget(
- "模型目录:",
- "Directories;;All Files (*.*)"
- )
- self.models_dir_file.label.setText("模型目录:")
- # 修改浏览按钮为选择目录
- self.models_dir_file.browse_btn.clicked.disconnect()
- self.models_dir_file.browse_btn.clicked.connect(self.browse_models_dir)
- layout.addWidget(self.models_dir_file)
-
- # 参数设置
- params_group = QGroupBox("预测参数")
- params_layout = QFormLayout()
-
- # 模型选择指标
- self.metric = QComboBox()
- self.metric.addItems(['Average Accuracy(%)', 'Min Accuracy(%)', 'Max Accuracy(%)'])
- params_layout.addRow("模型选择指标:", self.metric)
-
- # 预测列名
- self.prediction_column = QLineEdit()
- self.prediction_column.setText("prediction")
- params_layout.addRow("预测列名:", self.prediction_column)
-
- params_group.setLayout(params_layout)
- layout.addWidget(params_group)
-
- # 输出路径
- self.output_file = FileSelectWidget(
- "输出文件夹:",
- "Directories;;All Files (*.*)"
- )
- self.output_file.label.setText("输出文件夹:")
- # 修改浏览按钮为选择目录
- self.output_file.browse_btn.clicked.disconnect()
- self.output_file.browse_btn.clicked.connect(self.browse_output_dir)
- layout.addWidget(self.output_file)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_button = QPushButton("独立运行此步骤")
- self.run_button.setStyleSheet("""
- QPushButton {
- background-color: #4CAF50;
- color: white;
- padding: 8px 16px;
- border: none;
- border-radius: 4px;
- font-weight: bold;
- }
- QPushButton:hover {
- background-color: #45a049;
- }
- QPushButton:pressed {
- background-color: #3e8e41;
- }
- """)
- self.run_button.clicked.connect(self.run_step)
- layout.addWidget(self.run_button)
-
- layout.addStretch()
- self.setLayout(layout)
-
- def browse_models_dir(self):
- """浏览模型目录"""
- dir_path = QFileDialog.getExistingDirectory(self, "选择模型目录", "")
- if dir_path:
- self.models_dir_file.set_path(dir_path)
-
- def browse_output_dir(self):
- """浏览输出目录"""
- dir_path = QFileDialog.getExistingDirectory(self, "选择输出文件夹", "")
- if dir_path:
- self.output_file.set_path(dir_path)
-
- def get_config(self):
- """获取配置"""
- config = {
- 'metric': self.metric.currentText(),
- 'prediction_column': self.prediction_column.text(),
- 'enabled': self.enable_checkbox.isChecked()
- }
- # 添加采样光谱CSV路径
- sampling_csv_path = self.sampling_csv_file.get_path()
- if sampling_csv_path:
- config['sampling_csv_path'] = sampling_csv_path
- # 添加模型目录路径
- models_dir = self.models_dir_file.get_path()
- if models_dir:
- config['models_dir'] = models_dir
- # 添加输出路径
- output_path = self.output_file.get_path()
- if output_path:
- config['output_path'] = output_path
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'metric' in config:
- idx = self.metric.findText(config['metric'])
- if idx >= 0:
- self.metric.setCurrentIndex(idx)
-
- if 'prediction_column' in config:
- self.prediction_column.setText(config['prediction_column'])
-
- if 'sampling_csv_path' in config:
- self.sampling_csv_file.set_path(config['sampling_csv_path'])
-
- if 'models_dir' in config:
- self.models_dir_file.set_path(config['models_dir'])
-
- if 'enabled' in config:
- self.enable_checkbox.setChecked(config['enabled'])
-
- def run_step(self):
- """独立运行步骤8.5"""
- # 验证输入
- sampling_csv_path = self.sampling_csv_file.get_path()
- if not sampling_csv_path:
- QMessageBox.warning(self, "输入错误", "请选择采样光谱CSV文件!")
- return
-
- # 获取配置
- config = self.get_config()
-
- # 调用GUI的run_single_step方法
- parent = self.parent()
- while parent and not hasattr(parent, 'run_single_step'):
- parent = parent.parent()
-
- if parent and hasattr(parent, 'run_single_step'):
- parent.run_single_step('step8_5', {'step8_5': config})
- else:
- QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
-
-
-class Step8_75Panel(QWidget):
- """步骤8.75:自定义回归预测"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
-
- # 标题
-
-
- # 采样光谱CSV文件选择
- self.sampling_csv_file = FileSelectWidget(
- "采样光谱CSV:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- layout.addWidget(self.sampling_csv_file)
-
- # 自定义回归模型目录选择(9_Custom_Regression_Modeling)
- self.regression_models_dir = FileSelectWidget(
- "回归模型目录:",
- "Directories;;All Files (*.*)"
- )
- self.regression_models_dir.label.setText("回归模型目录:")
- # 修改浏览按钮为选择目录
- self.regression_models_dir.browse_btn.clicked.disconnect()
- self.regression_models_dir.browse_btn.clicked.connect(self.browse_regression_models_dir)
- self.regression_models_dir.set_path("9_Custom_Regression_Modeling") # 设置默认值
- layout.addWidget(self.regression_models_dir)
-
- # 公式CSV文件选择(用于查找index_formula)
- self.formula_csv_file = FileSelectWidget(
- "公式CSV文件:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- self.formula_csv_file.label.setText("公式CSV文件:")
- layout.addWidget(self.formula_csv_file)
-
- # 输出目录选择
- self.output_dir_widget = FileSelectWidget(
- "输出目录:",
- "Directories;;All Files (*.*)"
- )
- self.output_dir_widget.label.setText("输出目录:")
- # 修改浏览按钮为选择目录
- self.output_dir_widget.browse_btn.clicked.disconnect()
- self.output_dir_widget.browse_btn.clicked.connect(self.browse_output_dir)
- self.output_dir_widget.line_edit.setPlaceholderText("留空使用默认prediction目录")
- layout.addWidget(self.output_dir_widget)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_button = QPushButton("独立运行此步骤")
- self.run_button.setStyleSheet("""
- QPushButton {
- background-color: #4CAF50;
- color: white;
- padding: 8px 16px;
- border: none;
- border-radius: 4px;
- font-weight: bold;
- }
- QPushButton:hover {
- background-color: #45a049;
- }
- QPushButton:pressed {
- background-color: #3e8e41;
- }
- """)
- self.run_button.clicked.connect(self.run_step)
- layout.addWidget(self.run_button)
-
- layout.addStretch()
- self.setLayout(layout)
-
- def browse_regression_models_dir(self):
- """浏览回归模型目录"""
- dir_path = QFileDialog.getExistingDirectory(self, "选择回归模型目录", "")
- if dir_path:
- self.regression_models_dir.set_path(dir_path)
-
- def browse_output_dir(self):
- """浏览输出目录"""
- dir_path = QFileDialog.getExistingDirectory(self, "选择输出目录", "")
- if dir_path:
- self.output_dir_widget.set_path(dir_path)
-
- def get_config(self):
- """获取配置"""
- config = {
- 'enabled': self.enable_checkbox.isChecked()
- }
-
- # 添加采样光谱CSV路径
- sampling_csv_path = self.sampling_csv_file.get_path()
- if sampling_csv_path:
- config['sampling_csv_path'] = sampling_csv_path
-
- # 添加回归模型目录路径
- regression_models_dir = self.regression_models_dir.get_path()
- if regression_models_dir:
- config['custom_regression_dir'] = regression_models_dir
-
- # 添加公式CSV文件路径
- formula_csv_path = self.formula_csv_file.get_path()
- if formula_csv_path:
- config['formula_csv_path'] = formula_csv_path
-
- # 添加输出目录路径
- output_dir = self.output_dir_widget.get_path()
- if output_dir:
- config['output_dir'] = output_dir
-
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'sampling_csv_path' in config:
- self.sampling_csv_file.set_path(config['sampling_csv_path'])
-
- if 'custom_regression_dir' in config:
- self.regression_models_dir.set_path(config['custom_regression_dir'])
-
- if 'formula_csv_path' in config:
- self.formula_csv_file.set_path(config['formula_csv_path'])
-
- if 'output_dir' in config:
- self.output_dir_widget.set_path(config['output_dir'])
-
- if 'enabled' in config:
- self.enable_checkbox.setChecked(config['enabled'])
-
- def run_step(self):
- """独立运行步骤8.75"""
- # 验证输入
- sampling_csv_path = self.sampling_csv_file.get_path()
- if not sampling_csv_path:
- QMessageBox.warning(self, "输入错误", "请选择采样光谱CSV文件!")
- return
-
- regression_models_dir = self.regression_models_dir.get_path()
- if not regression_models_dir:
- QMessageBox.warning(self, "输入错误", "请选择回归模型目录!")
- return
-
- # 获取配置
- config = self.get_config()
-
- # 调用GUI的run_single_step方法
- parent = self.parent()
- while parent and not hasattr(parent, 'run_single_step'):
- parent = parent.parent()
-
- if parent and hasattr(parent, 'run_single_step'):
- parent.run_single_step('step8_75', {'step8_75': config})
- else:
- QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
-
-
class ChartViewerDialog(QDialog):
"""图表查看器对话框"""
def __init__(self, title="图表查看器", parent=None):
@@ -4055,887 +893,6 @@ class ImageViewerWidget(QWidget):
QMessageBox.critical(self, "错误", f"保存失败: {e}")
-class VisualizationPanel(QWidget):
- """可视化分析面板 - 重构版:左侧目录树 + 右侧图像查看器"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.work_dir = None
- self.chart_viewer = None
- self._viz_thread = None
- self.init_ui()
-
- def _viz_set_busy(self, busy: bool):
- for w in (
- getattr(self, "gen_all_btn", None),
- getattr(self, "scan_btn", None),
- ):
- if w is not None:
- w.setEnabled(not busy)
-
- def _start_visualization_thread(self, task: str, extra: Optional[dict] = None) -> bool:
- if not self.work_dir:
- QMessageBox.warning(self, "警告", "请先选择工作目录!")
- return False
- work_path = Path(self.work_dir)
- if not work_path.exists():
- QMessageBox.warning(self, "警告", "工作目录不存在!")
- return False
- if self._viz_thread and self._viz_thread.isRunning():
- QMessageBox.information(self, "提示", "可视化任务正在运行,请稍候。")
- return False
- self._viz_thread = VisualizationWorkerThread(task, str(work_path), extra or {})
- self._viz_thread.finished_ok.connect(self._on_visualization_worker_ok, Qt.QueuedConnection)
- self._viz_thread.failed.connect(self._on_visualization_worker_fail, Qt.QueuedConnection)
- self._viz_thread.finished.connect(lambda: self._viz_set_busy(False), Qt.QueuedConnection)
- self._viz_set_busy(True)
- self._viz_thread.start()
- return True
-
- def _spectrum_meta_param_columns(self, df: pd.DataFrame) -> List[str]:
- """光谱图可选的水质参数列(光谱波段列之前、且为数值型)。"""
- wl = _viz_infer_wavelength_start_column(df)
- if isinstance(wl, str):
- idx = int(df.columns.get_loc(wl)) + 1
- else:
- idx = int(wl)
- if idx <= 0 or idx >= len(df.columns):
- numeric = df.select_dtypes(include=[np.number]).columns.tolist()
- return [
- c
- for c in numeric
- if not any(x in str(c).lower() for x in ("utm", "lat", "lon", "x", "y"))
- ]
- meta = list(df.columns[:idx])
- return [c for c in meta if pd.api.types.is_numeric_dtype(df[c])]
-
- def _statistics_param_columns(self, df: pd.DataFrame) -> List[str]:
- """统计图用的参数列:**只统计水质参数列(数值型),排除波长列**。
- - 包括:数值型的水质参数(浓度、含量等)
- - 排除:光谱波长列(虽然也是数值型,但不是水质参数)
- - 排除:坐标列(UTM_X, UTM_Y, lat, lon等)
- 若存在光谱波段,则只统计波段前的数值字段。
- """
- # 选择数值类型列
- numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
- wl = _viz_infer_wavelength_start_column(df)
- if isinstance(wl, str):
- idx = int(df.columns.get_loc(wl)) + 1
- else:
- idx = int(wl)
- coord_kw = ("utm", "lat", "lon")
- if 0 < idx < len(df.columns):
- # 只取波长开始之前的列(水质参数区域)
- meta_set = set(df.columns[:idx])
- return [
- col
- for col in numeric_cols
- if col in meta_set and not any(x in str(col).lower() for x in coord_kw)
- ]
- return [
- # 如果没有找到波长列,排除坐标相关列
- col
- for col in numeric_cols
- if not any(x in str(col).lower() for x in coord_kw + ("x", "y"))
- ]
-
- def _on_visualization_worker_ok(self, payload):
- if not isinstance(payload, dict):
- self.scan_work_directory()
- return
- t = payload.get("task")
- if t == "mask_glint":
- cnt = int(payload.get("count") or 0)
- if cnt > 0:
- QMessageBox.information(
- self,
- "成功",
- f"掩膜和耀斑缩略图生成完成,共 {cnt} 个预览图。\n"
- f"保存位置: 14_visualization/glint_deglint_previews/",
- )
- else:
- QMessageBox.warning(
- self,
- "警告",
- "未找到可处理的影像文件(2_glint/3_deglint 等)。",
- )
- elif t == "sampling_map":
- map_path = payload.get("map_path")
- QMessageBox.information(
- self,
- "成功",
- "采样点地图生成完成。\n"
- f"输出: {Path(map_path).name if map_path else ''}\n"
- "路径: 14_visualization/sampling_maps/",
- )
- if map_path:
- self.show_chart_viewer(map_path, "采样点分布图")
- elif t == "spectrum":
- multi = payload.get("output_paths")
- if isinstance(multi, list) and multi:
- ok_paths = [p for p in multi if p and Path(str(p)).is_file()]
- errs = payload.get("errors") or []
- msg = (
- f"已为 {len(ok_paths)} 个水质参数生成光谱对比图。\n"
- f"保存目录: 工作目录/14_visualization/"
- )
- if errs:
- msg += f"\n\n以下列未生成或出错 ({len(errs)} 项,详见日志):\n"
- msg += "\n".join(str(e) for e in errs[:8])
- if len(errs) > 8:
- msg += "\n..."
- QMessageBox.information(self, "成功", msg)
- if ok_paths:
- self.show_chart_viewer(ok_paths[0], "光谱曲线对比(首张)")
- else:
- outp = payload.get("output_path")
- param = payload.get("param_col", "")
- QMessageBox.information(self, "成功", f"光谱图已生成:\n{outp}")
- if outp:
- self.show_chart_viewer(outp, f"{param} - 光谱曲线对比")
- elif t == "statistics":
- outp = payload.get("output_paths") or {}
- QMessageBox.information(
- self, "成功", f"统计图表已生成,共 {len(outp)} 项。"
- )
- if isinstance(outp, dict) and "boxplot" in outp:
- self.show_chart_viewer(outp["boxplot"], "水质参数箱线图")
- elif t == "scatter":
- paths = payload.get("scatter_paths") or {}
- ok_paths = [p for p in paths.values() if p and Path(str(p)).is_file()]
- if ok_paths:
- QMessageBox.information(
- self,
- "成功",
- f"已生成 {len(ok_paths)} 个模型评估散点图。\n"
- f"保存位置: 14_visualization/scatter_plots/",
- )
- self.show_chart_viewer(ok_paths[0], "模型评估散点图")
- else:
- QMessageBox.warning(
- self,
- "提示",
- "未生成任何散点图。请确认 7_Supervised_Model_Training 下已有各参数子目录及模型文件,"
- "且训练 CSV 与建模时一致。",
- )
- elif t == "generate_all_selected":
- parts = payload.get("parts") or []
- QMessageBox.information(
- self,
- "完成",
- "批量可视化已执行:\n" + "\n".join(parts) if parts else "(无选中项或已跳过)",
- )
- self.scan_work_directory()
-
- def _on_visualization_worker_fail(self, err: str):
- QMessageBox.critical(self, "错误", f"可视化任务失败:\n{err[:1200]}")
-
- def init_ui(self):
- """初始化UI - 使用左右分栏布局"""
- main_layout = QHBoxLayout()
- main_layout.setSpacing(10)
- main_layout.setContentsMargins(10, 10, 10, 10)
-
- # ===== 左侧面板 =====
- left_panel = QWidget()
- left_layout = QVBoxLayout()
- left_layout.setContentsMargins(0, 0, 0, 0)
-
- # 工作目录选择
- dir_group = QGroupBox("工作目录")
- dir_layout = QHBoxLayout()
- self.work_dir_edit = QLineEdit()
- self.work_dir_edit.setPlaceholderText("选择工作目录...")
- self.work_dir_edit.setReadOnly(True)
- dir_browse_btn = QPushButton("浏览")
- dir_browse_btn.clicked.connect(self.browse_work_dir)
- dir_layout.addWidget(self.work_dir_edit, 1)
- dir_layout.addWidget(dir_browse_btn)
- dir_group.setLayout(dir_layout)
- left_layout.addWidget(dir_group)
-
- # 图像目录树
- tree_group = QGroupBox("图像目录")
- tree_layout = QVBoxLayout()
- self.image_tree = ImageCategoryTree()
- self.image_tree.itemClicked.connect(self.on_tree_item_clicked)
- tree_layout.addWidget(self.image_tree)
-
- tree_group.setLayout(tree_layout)
- left_layout.addWidget(tree_group, 1)
-
- # 可视化配置
- config_group = QGroupBox("可视化配置")
- config_layout = QVBoxLayout()
-
- self.gen_scatter = QCheckBox("模型评估散点图")
- self.gen_scatter.setChecked(True)
- config_layout.addWidget(self.gen_scatter)
-
- self.gen_spectrum = QCheckBox("光谱曲线图")
- self.gen_spectrum.setChecked(True)
- config_layout.addWidget(self.gen_spectrum)
-
- self.gen_boxplots = QCheckBox("统计图表")
- self.gen_boxplots.setChecked(True)
- config_layout.addWidget(self.gen_boxplots)
-
- self.gen_mask_glint = QCheckBox("掩膜和耀斑缩略图")
- self.gen_mask_glint.setChecked(True)
- config_layout.addWidget(self.gen_mask_glint)
-
- self.gen_sampling_map = QCheckBox("采样点地图")
- self.gen_sampling_map.setChecked(True)
- config_layout.addWidget(self.gen_sampling_map)
-
- # 添加分隔线
- config_layout.addSpacing(10)
- line = QFrame()
- line.setFrameShape(QFrame.HLine)
- line.setStyleSheet("color: #ddd;")
- config_layout.addWidget(line)
- config_layout.addSpacing(10)
-
- # 生成全部按钮
- self.gen_all_btn = QPushButton("🚀 生成全部")
- self.gen_all_btn.setToolTip("生成所有类型的可视化图表")
- self.gen_all_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
- self.gen_all_btn.clicked.connect(self.generate_all_visualizations)
- config_layout.addWidget(self.gen_all_btn)
-
- # 扫描按钮
- self.scan_btn = QPushButton("📁 扫描目录")
- self.scan_btn.setToolTip("扫描工作目录中的图像文件")
- self.scan_btn.clicked.connect(self.scan_work_directory)
- config_layout.addWidget(self.scan_btn)
-
- config_group.setLayout(config_layout)
- left_layout.addWidget(config_group)
-
- left_panel.setLayout(left_layout)
- left_panel.setMaximumWidth(350)
- main_layout.addWidget(left_panel, 0)
-
- # ===== 右侧面板 =====
- right_panel = QWidget()
- right_layout = QVBoxLayout()
- right_layout.setContentsMargins(0, 0, 0, 0)
-
- # 图像查看器
- self.image_viewer = ImageViewerWidget()
- self.image_viewer.refresh_btn.clicked.connect(self.scan_work_directory)
- right_layout.addWidget(self.image_viewer, 1)
-
- right_panel.setLayout(right_layout)
- main_layout.addWidget(right_panel, 1)
-
- self.setLayout(main_layout)
-
- def set_work_dir(self, work_dir):
- """设置工作目录"""
- self.work_dir = work_dir
- self.work_dir_edit.setText(str(work_dir))
- # 自动扫描目录
- if work_dir:
- QTimer.singleShot(100, self.scan_work_directory) # 延迟执行确保UI更新
-
- def browse_work_dir(self):
- """浏览工作目录"""
- dir_path = QFileDialog.getExistingDirectory(self, "选择工作目录")
- if dir_path:
- self.work_dir = dir_path
- self.work_dir_edit.setText(dir_path)
- # 自动扫描目录
- self.scan_work_directory()
-
- def scan_work_directory(self):
- """扫描工作目录中的图像文件"""
- if not self.work_dir:
- return
-
- work_path = Path(self.work_dir)
- if not work_path.exists():
- return
-
- print(f"扫描工作目录: {work_path}")
- self.image_tree.scan_directory(str(work_path))
-
- # 设置三个预测步骤的默认输出路径
- self._setup_prediction_output_dirs(work_path)
-
- # 如果有图像,自动选择第一个
- viz_dir = work_path / "14_visualization"
- if viz_dir.exists():
- image_files = list(viz_dir.glob("**/*.png")) + list(viz_dir.glob("**/*.jpg"))
- if image_files:
- self.image_viewer.load_image(str(image_files[0]))
-
- def _setup_prediction_output_dirs(self, work_path: Path):
- """
- 设置三个预测步骤的默认输出目录
- 在11_12_13_predictions下创建三个子文件夹
- """
- try:
- # 基础预测目录
- base_prediction_dir = work_path / "11_12_13_predictions"
-
- # 三个子文件夹路径
- ml_dir = base_prediction_dir / "Machine_Learning_Prediction"
- reg_dir = base_prediction_dir / "Regression_Model_Prediction"
- custom_dir = base_prediction_dir / "Custom_Regression_Prediction"
-
- # 创建目录(如果不存在)
- ml_dir.mkdir(parents=True, exist_ok=True)
- reg_dir.mkdir(parents=True, exist_ok=True)
- custom_dir.mkdir(parents=True, exist_ok=True)
-
- # 设置Step8Panel(机器学习预测)的默认输出路径
- if hasattr(self, 'step8_panel') and hasattr(self.step8_panel, 'output_file'):
- self.step8_panel.output_file.set_path(str(ml_dir))
-
- # 设置Step8_5Panel(回归模型预测)的默认输出路径
- if hasattr(self, 'step8_5_panel') and hasattr(self.step8_5_panel, 'output_file'):
- self.step8_5_panel.output_file.set_path(str(reg_dir))
-
- # 设置Step8_75Panel(自定义回归预测)的默认输出路径
- if hasattr(self, 'step8_75_panel') and hasattr(self.step8_75_panel, 'output_dir_widget'):
- self.step8_75_panel.output_dir_widget.set_path(str(custom_dir))
-
- print(f"预测输出目录已设置:\n ML: {ml_dir}\n Reg: {reg_dir}\n Custom: {custom_dir}")
- except Exception as e:
- print(f"设置预测输出目录失败: {e}")
-
- def on_tree_item_clicked(self, item, column):
- """目录树项点击事件"""
- data = item.data(0, Qt.UserRole)
- if not data:
- return
-
- if data.get("type") == "image":
- image_path = data.get("path")
- if image_path and Path(image_path).exists():
- self.image_viewer.load_image(image_path)
-
- def generate_all_visualizations(self):
- """生成所有可视化图表(耗时任务在后台线程执行,避免界面未响应)。"""
- if not self.work_dir:
- QMessageBox.warning(self, "警告", "请先选择工作目录!")
- return
-
- work_path = Path(self.work_dir)
- if not work_path.exists():
- QMessageBox.warning(self, "警告", "工作目录不存在!")
- return
-
- # 检查是否有任何选项被勾选
- if not (self.gen_scatter.isChecked() or self.gen_spectrum.isChecked() or
- self.gen_boxplots.isChecked() or self.gen_mask_glint.isChecked() or
- self.gen_sampling_map.isChecked()):
- QMessageBox.information(
- self,
- "提示",
- "请至少勾选一项可视化配置选项以生成图表。",
- )
- return
-
- reply = QMessageBox.question(
- self, "确认生成",
- "将根据左侧勾选项在后台生成可视化图表,可能需要较长时间。\n是否继续?",
- QMessageBox.Yes | QMessageBox.No
- )
-
- if reply != QMessageBox.Yes:
- return
-
- # 收集所有选中的任务
- extra = {
- "gen_scatter": self.gen_scatter.isChecked(),
- "gen_spectrum": self.gen_spectrum.isChecked(),
- "gen_boxplots": self.gen_boxplots.isChecked(),
- "gen_mask_glint": self.gen_mask_glint.isChecked(),
- "gen_sampling_map": self.gen_sampling_map.isChecked(),
- }
- self._start_visualization_thread("generate_all_selected", extra)
-
- def generate_chart(self, chart_type):
- """生成图表(光谱/统计图在后台线程绘制)。"""
- if not self.work_dir:
- QMessageBox.warning(self, "警告", "请先选择工作目录!")
- return
-
- work_path = Path(self.work_dir)
- if not work_path.exists():
- QMessageBox.warning(self, "警告", "工作目录不存在!")
- return
-
- try:
- training_spectra_csv = _viz_training_spectra_csv_path(work_path)
-
- if chart_type == 'scatter':
- if not training_spectra_csv.is_file():
- QMessageBox.warning(
- self,
- "警告",
- "未找到 5_training_spectra\\training_spectra.csv。\n"
- "请先在工作目录中执行步骤5(光谱特征提取)生成该文件。",
- )
- return
- training_csv = training_spectra_csv
- models_dir = work_path / "7_Supervised_Model_Training"
- if not models_dir.is_dir() or not any(
- d.is_dir() for d in models_dir.iterdir()
- ):
- mdir = QFileDialog.getExistingDirectory(
- self,
- "选择模型根目录(内含各水质参数子文件夹,如 chl_a)",
- str(work_path),
- )
- if not mdir:
- return
- models_dir = Path(mdir)
- self._start_visualization_thread(
- "scatter",
- {
- "training_csv_path": str(training_csv),
- "models_dir": str(models_dir),
- },
- )
- return
-
- if chart_type == 'spectrum':
- if not training_spectra_csv.is_file():
- QMessageBox.warning(
- self,
- "警告",
- "未找到 5_training_spectra\\training_spectra.csv。\n"
- "光谱分析固定使用该文件,请先执行步骤5(光谱特征提取)。",
- )
- return
- csv_file = training_spectra_csv
- df = pd.read_csv(csv_file)
- columns = self._spectrum_meta_param_columns(df)
- if not columns:
- QMessageBox.warning(
- self,
- "警告",
- "当前 CSV 中没有可用的数值型水质参数列,无法按参数分组绘制光谱图。\n"
- "请使用步骤5输出的 training_spectra.csv(含参数列+波段列)。",
- )
- return
- wl_col = _viz_infer_wavelength_start_column(df)
- self._start_visualization_thread(
- "spectrum",
- {
- "csv_path": str(csv_file),
- "param_cols": columns,
- "wavelength_start_column": wl_col,
- "n_groups": 5,
- },
- )
- return
-
- if chart_type == 'statistics':
- if not training_spectra_csv.is_file():
- QMessageBox.warning(
- self,
- "警告",
- "未找到 5_training_spectra\\training_spectra.csv。\n"
- "统计分析固定使用该文件,请先执行步骤5(光谱特征提取)。",
- )
- return
- csv_file = training_spectra_csv
- df = pd.read_csv(csv_file)
- param_cols = self._statistics_param_columns(df)
- if not param_cols:
- QMessageBox.warning(self, "警告", "未找到可用的水质参数列!")
- return
- self._start_visualization_thread(
- "statistics",
- {"csv_path": str(csv_file), "param_cols": param_cols},
- )
- return
-
- if chart_type == 'sampling_map':
- self.generate_sampling_point_map()
- return
-
- except ImportError:
- QMessageBox.critical(
- self,
- "错误",
- "无法导入可视化模块!\n请确保 visualization_reports.py 文件存在。",
- )
- except Exception as e:
- QMessageBox.critical(
- self,
- "错误",
- f"生成图表时出错:\n{str(e)}\n\n{traceback.format_exc()}",
- )
-
- def generate_mask_glint_previews(self):
- """生成掩膜和耀斑缩略图(后台线程)。"""
- self._start_visualization_thread("mask_glint")
-
- def generate_sampling_point_map(self):
- """生成采样点地图(后台线程)。"""
- self._start_visualization_thread("sampling_map")
-
- def view_chart(self, chart_type):
- """查看图表"""
- if not self.work_dir:
- QMessageBox.warning(self, "警告", "请先选择工作目录!")
- return
-
- work_path = Path(self.work_dir)
- viz_dir = work_path / "14_visualization"
- viz_dir2 = work_path / "14_visualization/boxplots"
- viz_dir3 = work_path / "14_visualization/scatter_plots"
- if not viz_dir.exists():
- QMessageBox.warning(self, "警告",
- f"可视化目录不存在:\n{viz_dir}\n\n请先生成图表。")
- return
-
- # 根据类型查找图表文件
- chart_files = []
- if chart_type == 'scatter':
- chart_files = list(viz_dir3.glob("*scatter*.png"))
- elif chart_type == 'spectrum':
- chart_files = list(viz_dir.glob("*spectrum*.png"))
- elif chart_type == 'statistics':
- chart_files = list(viz_dir2.glob("*boxplot.png")) + \
- list(viz_dir.glob("*histogram.png")) + \
- list(viz_dir.glob("*heatmap.png"))
- elif chart_type == 'distribution':
- chart_files = list(viz_dir.glob("**/*distribution.png"))
- elif chart_type == 'mask_glint':
- # 查找掩膜和耀斑缩略图
- glint_dir = viz_dir / "glint_deglint_previews"
- if glint_dir.exists():
- chart_files = list(glint_dir.glob("*preview.png"))
- else:
- # 如果专用目录不存在,从根目录查找
- chart_files = list(viz_dir.glob("*preview.png")) + \
- list(viz_dir.glob("*glint*.png")) + \
- list(viz_dir.glob("*mask*.png"))
- elif chart_type == 'sampling_map':
- # 查找采样点地图
- sampling_dir = viz_dir / "sampling_maps"
- if sampling_dir.exists():
- chart_files = list(sampling_dir.glob("*sampling_map.png"))
- else:
- chart_files = list(viz_dir.glob("*sampling*.png"))
-
- if not chart_files:
- if chart_type == 'mask_glint':
- QMessageBox.warning(self, "警告",
- "未找到掩膜和耀斑缩略图!\n\n"
- "请先点击'生成掩膜&耀斑缩略图'按钮生成预览图。\n"
- "需要2_glint或3_deglint文件夹中存在影像文件。")
- else:
- QMessageBox.warning(self, "警告",
- f"未找到{chart_type}类型的图表文件!\n\n请先生成图表。")
- return
-
- # 如果有多个文件,让用户选择
- if len(chart_files) > 1:
- from PyQt5.QtWidgets import QInputDialog
- file_names = [f.name for f in chart_files]
- file_name, ok = QInputDialog.getItem(
- self, "选择图表", "请选择要查看的图表:",
- file_names, 0, False
- )
- if ok:
- selected_file = next(f for f in chart_files if f.name == file_name)
- self.show_chart_viewer(str(selected_file), file_name)
- else:
- self.show_chart_viewer(str(chart_files[0]), chart_files[0].name)
-
- def browse_all_charts(self):
- """浏览所有图表"""
- if not self.work_dir:
- QMessageBox.warning(self, "警告", "请先选择工作目录!")
- return
-
- work_path = Path(self.work_dir)
-
- # 查找所有图表文件
- chart_files = []
- chart_files.extend(work_path.glob("**/*.png"))
- chart_files.extend(work_path.glob("**/*.jpg"))
-
- if not chart_files:
- QMessageBox.warning(self, "警告", "未找到图表文件!")
- return
-
- # 创建图表浏览对话框
- dialog = ChartBrowserDialog(chart_files, self)
- dialog.exec_()
-
- def show_chart_viewer(self, image_path, title="图表查看器"):
- """显示图表查看器"""
- viewer = ChartViewerDialog(title=title, parent=self)
- viewer.display_image(image_path)
- viewer.exec_()
-
- def get_config(self):
- """获取配置"""
- return {
- 'generate_scatter': self.gen_scatter.isChecked(),
- 'generate_boxplots': self.gen_boxplots.isChecked(),
- 'generate_spectrum': self.gen_spectrum.isChecked(),
- 'generate_glint_previews': self.gen_mask_glint.isChecked(),
- 'generate_sampling_maps': self.gen_sampling_map.isChecked(),
- 'scatter_config': {
- 'metric': 'test_r2',
- 'feature_start_column': 13,
- 'test_size': 0.2,
- 'random_state': 42
- },
- 'boxplot_config': {
- 'data_start_column': 4,
- 'save_individual': True,
- 'use_seaborn': True
- },
- 'glint_preview_config': {
- 'work_dir': None,
- 'output_subdir': 'glint_deglint_previews',
- 'generate_glint': True,
- 'generate_deglint': True
- }
- }
-
- def set_config(self, config):
- """设置配置"""
- if 'generate_scatter' in config:
- self.gen_scatter.setChecked(config['generate_scatter'])
- if 'generate_boxplots' in config:
- self.gen_boxplots.setChecked(config['generate_boxplots'])
- if 'generate_spectrum' in config:
- self.gen_spectrum.setChecked(config['generate_spectrum'])
- if 'generate_glint_previews' in config:
- self.gen_mask_glint.setChecked(config['generate_glint_previews'])
- if 'generate_sampling_maps' in config:
- self.gen_sampling_map.setChecked(config.get('generate_sampling_maps', True))
-
-
-
-class ReportGenerationPanel(QWidget):
- """Word 报告生成:工作目录、输出目录、Ollama URL/模型、是否启用 AI 等。"""
-
- def __init__(self, main_window=None, parent=None):
- super().__init__(parent)
- self.main_window = main_window
- self._report_thread = None
- self.init_ui()
-
- def init_ui(self):
- layout = QVBoxLayout()
- layout.setContentsMargins(10, 10, 10, 10)
- layout.setSpacing(10)
-
- intro = QLabel(
- "根据工作目录下的可视化结果(14_visualization 等)生成 Word 分析报告。"
- "需已存在可视化图表;AI 分析通过 Ollama /api/chat 调用本地或远程服务。"
- )
- intro.setWordWrap(True)
- intro.setStyleSheet(f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};")
- layout.addWidget(intro)
-
- path_group = QGroupBox("路径")
- path_form = QFormLayout()
-
- wd_row = QHBoxLayout()
- self.work_dir_edit = QLineEdit()
- self.work_dir_edit.setPlaceholderText("选择流程工作目录(含 14_visualization)…")
- wd_browse = QPushButton("浏览…")
- wd_browse.clicked.connect(self.browse_work_dir)
- sync_btn = QPushButton("同步主窗口工作目录")
- sync_btn.clicked.connect(self.sync_work_dir_from_main)
- wd_row.addWidget(self.work_dir_edit, 1)
- wd_row.addWidget(wd_browse)
- wd_row.addWidget(sync_btn)
- path_form.addRow("工作目录:", wd_row)
-
- out_row = QHBoxLayout()
- self.output_dir_edit = QLineEdit()
- self.output_dir_edit.setPlaceholderText("留空则保存到 工作目录/14_visualization")
- out_browse = QPushButton("浏览…")
- out_browse.clicked.connect(self.browse_output_dir)
- out_row.addWidget(self.output_dir_edit, 1)
- out_row.addWidget(out_browse)
- path_form.addRow("报告输出目录:", out_row)
-
- self.report_title_edit = QLineEdit()
- self.report_title_edit.setText("水质参数反演分析报告")
- path_form.addRow("报告标题:", self.report_title_edit)
-
- path_group.setLayout(path_form)
- layout.addWidget(path_group)
-
- ai_group = QGroupBox("AI 分析(Ollama)")
- ai_form = QFormLayout()
-
- self.enable_ai_cb = QCheckBox("启用 AI 图表解读与综合总结")
- self.enable_ai_cb.setChecked(os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"})
- ai_form.addRow(self.enable_ai_cb)
-
- self.ollama_url_edit = QLineEdit()
- self.ollama_url_edit.setText(os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/"))
- ai_form.addRow("服务 URL:", self.ollama_url_edit)
-
- self.vision_model_edit = QLineEdit()
- self.vision_model_edit.setText(os.environ.get("OLLAMA_VISION_MODEL", "qwen3-vl:8b"))
- ai_form.addRow("视觉模型:", self.vision_model_edit)
-
- self.same_text_model_cb = QCheckBox("文本总结与视觉使用同一模型")
- self.same_text_model_cb.setChecked(True)
- ai_form.addRow(self.same_text_model_cb)
-
- self.text_model_edit = QLineEdit()
- self.text_model_edit.setText(os.environ.get("OLLAMA_TEXT_MODEL", self.vision_model_edit.text() or "qwen3-vl:8b"))
- self.text_model_edit.setEnabled(False)
- self.same_text_model_cb.toggled.connect(self._on_same_text_toggled)
- self.vision_model_edit.textChanged.connect(self._sync_text_model_if_linked)
- ai_form.addRow("文本模型:", self.text_model_edit)
-
- self.timeout_spin = QSpinBox()
- self.timeout_spin.setRange(30, 3600)
- self.timeout_spin.setSingleStep(30)
- self.timeout_spin.setValue(int(os.environ.get("OLLAMA_TIMEOUT_S", "120")))
- ai_form.addRow("请求超时(秒):", self.timeout_spin)
-
- ai_group.setLayout(ai_form)
- layout.addWidget(ai_group)
-
- btn_row = QHBoxLayout()
- self.generate_btn = QPushButton("生成 Word 报告")
- self.generate_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success"))
- self.generate_btn.clicked.connect(self.on_generate_clicked)
- btn_row.addWidget(self.generate_btn)
- btn_row.addStretch()
- layout.addLayout(btn_row)
-
- layout.addStretch()
- self.setLayout(layout)
-
- def _on_same_text_toggled(self, checked: bool):
- self.text_model_edit.setEnabled(not checked)
- if checked:
- self.text_model_edit.setText(self.vision_model_edit.text())
-
- def _sync_text_model_if_linked(self, _t=None):
- if self.same_text_model_cb.isChecked():
- self.text_model_edit.blockSignals(True)
- self.text_model_edit.setText(self.vision_model_edit.text())
- self.text_model_edit.blockSignals(False)
-
- def browse_work_dir(self):
- d = QFileDialog.getExistingDirectory(self, "选择工作目录")
- if d:
- self.work_dir_edit.setText(d)
-
- def browse_output_dir(self):
- d = QFileDialog.getExistingDirectory(self, "选择报告输出目录")
- if d:
- self.output_dir_edit.setText(d)
-
- def sync_work_dir_from_main(self):
- mw = self.main_window
- if mw is not None and getattr(mw, "work_dir", None):
- self.work_dir_edit.setText(str(mw.work_dir))
- else:
- QMessageBox.information(self, "提示", "主窗口尚未设置工作目录。")
-
- def set_work_dir(self, work_dir):
- if work_dir:
- self.work_dir_edit.setText(str(work_dir))
-
- def get_config(self):
- return {
- "work_dir": self.work_dir_edit.text().strip() or None,
- "output_dir": self.output_dir_edit.text().strip() or None,
- "report_title": self.report_title_edit.text().strip() or "水质参数反演分析报告",
- "ollama_url": self.ollama_url_edit.text().strip(),
- "ollama_vision_model": self.vision_model_edit.text().strip(),
- "ollama_text_model": self.text_model_edit.text().strip(),
- "text_same_as_vision": self.same_text_model_cb.isChecked(),
- "ollama_timeout_s": self.timeout_spin.value(),
- "enable_ai_analysis": self.enable_ai_cb.isChecked(),
- }
-
- def set_config(self, config):
- if not config:
- return
- if config.get("work_dir"):
- self.work_dir_edit.setText(str(config["work_dir"]))
- if "output_dir" in config:
- self.output_dir_edit.setText(str(config["output_dir"] or ""))
- if config.get("report_title"):
- self.report_title_edit.setText(str(config["report_title"]))
- if config.get("ollama_url"):
- self.ollama_url_edit.setText(str(config["ollama_url"]))
- if config.get("ollama_vision_model"):
- self.vision_model_edit.setText(str(config["ollama_vision_model"]))
- if "text_same_as_vision" in config:
- self.same_text_model_cb.setChecked(bool(config["text_same_as_vision"]))
- if config.get("ollama_text_model"):
- self.text_model_edit.setText(str(config["ollama_text_model"]))
- if config.get("ollama_timeout_s") is not None:
- self.timeout_spin.setValue(int(config["ollama_timeout_s"]))
- if "enable_ai_analysis" in config:
- self.enable_ai_cb.setChecked(bool(config["enable_ai_analysis"]))
-
- def on_generate_clicked(self):
- wd = self.work_dir_edit.text().strip()
- if not wd or not os.path.isdir(wd):
- QMessageBox.warning(self, "提示", "请选择有效的工作目录。")
- return
- viz = Path(wd) / "14_visualization"
- if not viz.is_dir():
- QMessageBox.warning(
- self,
- "提示",
- f"未找到可视化目录:\n{viz}\n请先完成流程或生成可视化。",
- )
- return
- if self._report_thread and self._report_thread.isRunning():
- QMessageBox.information(self, "提示", "报告正在生成中,请稍候。")
- return
-
- out = self.output_dir_edit.text().strip() or None
- title = self.report_title_edit.text().strip() or "水质参数反演分析报告"
- opts = {
- "ollama_url": self.ollama_url_edit.text().strip(),
- "ollama_vision_model": self.vision_model_edit.text().strip(),
- "ollama_text_model": self.text_model_edit.text().strip(),
- "text_same_as_vision": self.same_text_model_cb.isChecked(),
- "ollama_timeout_s": self.timeout_spin.value(),
- "enable_ai_analysis": self.enable_ai_cb.isChecked(),
- }
- self.generate_btn.setEnabled(False)
- self._report_thread = ReportGenerateThread(wd, out, title, opts)
- self._report_thread.log_message.connect(self._forward_log, Qt.QueuedConnection)
- self._report_thread.finished_ok.connect(self._on_report_ok, Qt.QueuedConnection)
- self._report_thread.failed.connect(self._on_report_fail, Qt.QueuedConnection)
- self._report_thread.finished.connect(lambda: self.generate_btn.setEnabled(True), Qt.QueuedConnection)
- self._report_thread.start()
- self._forward_log("已开始生成 Word 报告…", "info")
-
- def _forward_log(self, msg: str, level: str):
- mw = self.main_window
- if mw is not None and hasattr(mw, "log_message"):
- mw.log_message(msg, level)
- else:
- print(f"[{level}] {msg}")
-
- def _on_report_ok(self, path: str):
- QMessageBox.information(self, "完成", f"报告已生成:\n{path}")
- self._forward_log(f"Word 报告已保存: {path}", "info")
-
- def _on_report_fail(self, err: str):
- QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}")
- self._forward_log(err, "error")
-
-
class ChartBrowserDialog(QDialog):
"""图表浏览器对话框"""
def __init__(self, chart_files, parent=None):
@@ -5051,609 +1008,246 @@ class ChartBrowserDialog(QDialog):
QMessageBox.critical(self, "错误", f"保存失败:\n{str(e)}")
-class Step6_5Panel(QWidget):
- """步骤6.5:非经验统计回归建模"""
- def __init__(self, parent=None):
+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()
-
- # 标题
-
- # 训练数据文件(用于独立运行)
- self.training_csv_file = FileSelectWidget(
- "训练数据CSV:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- layout.addWidget(self.training_csv_file)
-
- # 参数设置
- params_group = QGroupBox("模型参数")
- params_layout = QFormLayout()
-
- # 预处理方法
- self.preproc_checkboxes = {}
- preproc_group = QGroupBox("预处理方法 (可多选)")
- preproc_layout = QVBoxLayout()
- preproc_grid = QGridLayout()
- preproc_methods = ['None', 'MMS', 'SS', 'SNV', 'MA', 'SG', 'MSC', 'D1', 'D2', 'DT', 'CT']
+ # 工具栏
+ toolbar = QHBoxLayout()
+ self.band_combo = QComboBox()
+ self.band_combo.currentIndexChanged.connect(self.on_band_changed)
+ toolbar.addWidget(QLabel("显示波段:"))
+ toolbar.addWidget(self.band_combo)
- for i, method in enumerate(preproc_methods):
- checkbox = QCheckBox(method)
- checkbox.setChecked(True)
- self.preproc_checkboxes[method] = checkbox
- preproc_grid.addWidget(checkbox, i // 4, i % 4)
+ self.gray_check = QCheckBox("灰度显示")
+ self.gray_check.stateChanged.connect(self.on_band_changed)
+ toolbar.addWidget(self.gray_check)
+ toolbar.addStretch()
+ layout.addLayout(toolbar)
- button_layout = QHBoxLayout()
- select_all_btn = QPushButton("全选")
- deselect_all_btn = QPushButton("全不选")
- select_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, True))
- deselect_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, False))
- button_layout.addWidget(select_all_btn)
- button_layout.addWidget(deselect_all_btn)
- button_layout.addStretch()
+ # Matplotlib 画布
+ try:
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
+ from matplotlib.figure import Figure
+ import matplotlib
+ matplotlib.use('Qt5Agg')
- preproc_layout.addLayout(preproc_grid)
- preproc_layout.addLayout(button_layout)
- preproc_group.setLayout(preproc_layout)
- params_layout.addRow(preproc_group)
-
- # 算法选择(可多选)
- self.algorithm_inputs = {}
- algorithms_widget = QWidget()
- algorithms_layout = QVBoxLayout()
- algorithms_layout.setContentsMargins(0, 0, 0, 0)
- algorithms_layout.setSpacing(4)
-
- algorithm_list = ['chl_a', 'nh3', 'mno4', 'tn', 'tp', 'tss']
- for algorithm in algorithm_list:
- row_widget = QWidget()
- row_layout = QHBoxLayout()
- row_layout.setContentsMargins(0, 0, 0, 0)
- checkbox = QCheckBox(algorithm)
- checkbox.setChecked(True)
- spinbox = QSpinBox()
- spinbox.setRange(0, 500)
- spinbox.setValue(0)
- spinbox.setMaximumWidth(90)
- row_layout.addWidget(checkbox)
- row_layout.addWidget(QLabel("对应值列索引:"))
- row_layout.addWidget(spinbox)
- row_layout.addStretch()
- row_widget.setLayout(row_layout)
- algorithms_layout.addWidget(row_widget)
- self.algorithm_inputs[algorithm] = (checkbox, spinbox)
+ 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)
- algorithms_widget.setLayout(algorithms_layout)
- params_layout.addRow("非经验算法选择:", algorithms_widget)
-
- # 光谱起始列
- self.spectral_start_col = QSpinBox()
- self.spectral_start_col.setRange(0, 100)
- self.spectral_start_col.setValue(1)
- params_layout.addRow("光谱起始列索引:", self.spectral_start_col)
-
- # 窗口大小
- self.window = QSpinBox()
- self.window.setRange(1, 20)
- self.window.setValue(5)
- params_layout.addRow("窗口大小:", self.window)
+ # 读取影像并初始化显示
+ self.load_and_display()
- params_group.setLayout(params_layout)
- layout.addWidget(params_group)
+ except ImportError as e:
+ layout.addWidget(QLabel(f"Matplotlib 未安装: {e}"))
- # 输出文件路径
- self.output_dir = FileSelectWidget(
- "输出模型目录:",
- "Directories;;All Files (*.*)"
- )
- self.output_dir.line_edit.setPlaceholderText("8_Regression_Modeling")
- # 修改浏览按钮为选择目录
- self.output_dir.browse_btn.clicked.disconnect()
- self.output_dir.browse_btn.clicked.connect(self.browse_output_dir)
- layout.addWidget(self.output_dir)
+ # 状态栏
+ 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)
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
+ # 关闭按钮
+ close_btn = QPushButton("关闭")
+ close_btn.clicked.connect(self.close)
+ layout.addWidget(close_btn)
- # 独立运行按钮
- self.run_button = QPushButton("独立运行此步骤")
- self.run_button.setStyleSheet("""
- QPushButton {
- background-color: #4CAF50;
- color: white;
- padding: 8px 16px;
- border: none;
- border-radius: 4px;
- font-weight: bold;
- }
- QPushButton:hover {
- background-color: #45a049;
- }
- QPushButton:pressed {
- background-color: #3e8e41;
- }
- """)
- self.run_button.clicked.connect(self.run_step)
- layout.addWidget(self.run_button)
-
- layout.addStretch()
self.setLayout(layout)
- # 信号连接:影像文件路径变化时动态更新波段范围
- def get_config(self):
- """获取配置"""
- selected_algorithms = [
- name for name, (checkbox, _) in self.algorithm_inputs.items()
- if checkbox.isChecked()
- ]
- if not selected_algorithms:
- selected_algorithms = list(self.algorithm_inputs.keys())
- value_cols = {
- name: spinbox.value()
- for name, (_, spinbox) in self.algorithm_inputs.items()
- if name in selected_algorithms
- }
+ def load_and_display(self):
+ """加载影像并显示"""
+ from osgeo import gdal
+ import numpy as np
- preprocessing_methods = [
- method for method, checkbox in self.preproc_checkboxes.items()
- if checkbox.isChecked()
- ] or ['None']
-
- config = {
- 'preprocessing_methods': preprocessing_methods,
- 'algorithms': selected_algorithms,
- 'value_cols': value_cols,
- 'spectral_start_col': self.spectral_start_col.value(),
- 'window': self.window.value(),
- 'enabled': self.enable_checkbox.isChecked()
- }
-
- # 添加输出路径 - 使用更简洁的方式,参照其他步骤
- output_dir = self.output_dir.get_path()
- if not output_dir:
- # 如果output_dir为空,使用工作目录或当前目录
- main_window = self.parent().window()
- if hasattr(main_window, 'work_dir') and main_window.work_dir:
- output_dir = str(Path(main_window.work_dir) / "8_Regression_Modeling")
- else:
- output_dir = str(Path.cwd() / "8_Regression_Modeling")
- config['output_dir'] = output_dir
-
- # 添加训练数据路径(用于独立运行)
- training_csv_path = self.training_csv_file.get_path()
- if training_csv_path:
- config['csv_path'] = training_csv_path
-
- return config
-
- def set_config(self, config):
- """设置配置"""
- if 'preprocessing_methods' in config:
- methods = config['preprocessing_methods']
- for method, checkbox in self.preproc_checkboxes.items():
- checkbox.setChecked(method in methods)
-
- if 'algorithms' in config:
- algorithm_values = config['algorithms']
- for algorithm, (checkbox, spinbox) in self.algorithm_inputs.items():
- checkbox.setChecked(algorithm in algorithm_values)
-
- if 'value_cols' in config:
- value_cols = config['value_cols']
- if isinstance(value_cols, dict):
- for algorithm, (_, spinbox) in self.algorithm_inputs.items():
- if algorithm in value_cols:
- spinbox.setValue(value_cols[algorithm])
- else:
- for _, spinbox in self.algorithm_inputs.values():
- spinbox.setValue(value_cols)
-
- if 'spectral_start_col' in config:
- self.spectral_start_col.setValue(config['spectral_start_col'])
-
- if 'window' in config:
- self.window.setValue(config['window'])
- if 'output_dir' in config:
- self.output_dir.set_path(config['output_dir'])
-
- # 添加训练数据路径设置
- if 'csv_path' in config:
- self.training_csv_file.set_path(config['csv_path'])
-
- def browse_output_dir(self):
- """浏览输出目录"""
- dir_path = QFileDialog.getExistingDirectory(self, "选择输出模型目录", "")
- if dir_path:
- self.output_dir.set_path(dir_path)
-
- def run_step(self):
- """独立运行步骤6.5"""
- # 验证输入
- training_csv_path = self.training_csv_file.get_path()
- if not training_csv_path:
- QMessageBox.warning(self, "输入错误", "请选择训练数据CSV文件!")
+ dataset = gdal.Open(self.img_path)
+ if dataset is None:
+ self.status_label.setText(f"无法打开影像: {self.img_path}")
return
-
- if not os.path.exists(training_csv_path):
- QMessageBox.warning(self, "输入错误", "训练数据CSV文件不存在!")
- return
-
- # 获取配置
- config = self.get_config()
-
- # 调用GUI的run_single_step方法
- parent = self.parent()
- while parent and not hasattr(parent, 'run_single_step'):
- parent = parent.parent()
-
- if parent and hasattr(parent, 'run_single_step'):
- parent.run_single_step('step6_5', {'step6_5': config})
+
+ 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:
- QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
+ for i in range(1, n_bands + 1):
+ self.band_combo.addItem(f"波段 {i}", i - 1)
+ self.band_combo.setCurrentIndex(0)
- def _toggle_checkboxes(self, checkboxes_dict, checked):
- """统一设置预处理checkbox状态"""
- for checkbox in checkboxes_dict.values():
- checkbox.setChecked(checked)
+ 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
-class Step6_75Panel(QWidget):
- """步骤6.75:自定义回归分析"""
- def __init__(self, parent=None):
- super().__init__(parent)
- self.x_column_checkboxes: Dict[str, QCheckBox] = {}
- self.y_column_checkboxes: Dict[str, QCheckBox] = {}
- self.method_checkboxes: Dict[str, QCheckBox] = {}
- self.csv_columns = []
- self.init_ui()
+ dataset = self.dataset
+ self.ax.clear()
- def init_ui(self):
- layout = QVBoxLayout()
+ 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 显示")
- hint = QLabel("指定自变量与因变量列,批量尝试不同回归方法")
- hint.setStyleSheet("color: #666; font-size: 11px;")
- layout.addWidget(hint)
+ self.ax.set_xlabel("列 (Column)")
+ self.ax.set_ylabel("行 (Row)")
+ self.fig.tight_layout()
+ self.canvas.draw()
- # CSV文件选择
- csv_group = QGroupBox("数据文件")
- csv_layout = QVBoxLayout()
+ # 绑定点击事件
+ self.cid = self.canvas.mpl_connect('button_press_event', self.on_click)
- self.csv_file = FileSelectWidget(
- "输入CSV文件:",
- "CSV Files (*.csv);;All Files (*.*)"
- )
- self.csv_file.line_edit.textChanged.connect(self.on_csv_file_changed)
- csv_layout.addWidget(self.csv_file)
+ 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)
- self.refresh_btn = QPushButton("刷新列信息")
- self.refresh_btn.clicked.connect(self.refresh_csv_columns)
- csv_layout.addWidget(self.refresh_btn)
-
- csv_group.setLayout(csv_layout)
- layout.addWidget(csv_group)
-
- # 自变量选择
- x_group = QGroupBox("自变量列选择 (可多选)")
- x_layout = QVBoxLayout()
-
- # 创建滚动区域来容纳自变量选择
- x_scroll = QScrollArea()
- x_scroll.setWidgetResizable(True)
- x_scroll.setMinimumHeight(250)
- x_scroll.setMaximumHeight(350)
-
- x_widget = QWidget()
- self.x_columns_layout = QGridLayout()
- x_widget.setLayout(self.x_columns_layout)
-
- x_scroll.setWidget(x_widget)
- x_layout.addWidget(x_scroll)
-
- # 全选/反选按钮
- x_btn_layout = QHBoxLayout()
- self.x_select_all = QPushButton("全选")
- self.x_deselect_all = QPushButton("全不选")
- self.x_select_all.clicked.connect(lambda: self.toggle_checkboxes(self.x_column_checkboxes, True))
- self.x_deselect_all.clicked.connect(lambda: self.toggle_checkboxes(self.x_column_checkboxes, False))
- x_btn_layout.addWidget(self.x_select_all)
- x_btn_layout.addWidget(self.x_deselect_all)
- x_btn_layout.addStretch()
- x_layout.addLayout(x_btn_layout)
-
- x_group.setLayout(x_layout)
- layout.addWidget(x_group)
-
- # 因变量选择
- y_group = QGroupBox("因变量列选择 (可多选)")
- y_layout = QVBoxLayout()
-
- # 创建滚动区域来容纳因变量选择
- y_scroll = QScrollArea()
- y_scroll.setWidgetResizable(True)
- y_scroll.setMinimumHeight(200)
- y_scroll.setMaximumHeight(300)
-
- y_widget = QWidget()
- self.y_columns_layout = QGridLayout()
- y_widget.setLayout(self.y_columns_layout)
-
- y_scroll.setWidget(y_widget)
- y_layout.addWidget(y_scroll)
-
- # 全选/反选按钮
- y_btn_layout = QHBoxLayout()
- self.y_select_all = QPushButton("全选")
- self.y_deselect_all = QPushButton("全不选")
- self.y_select_all.clicked.connect(lambda: self.toggle_checkboxes(self.y_column_checkboxes, True))
- self.y_deselect_all.clicked.connect(lambda: self.toggle_checkboxes(self.y_column_checkboxes, False))
- y_btn_layout.addWidget(self.y_select_all)
- y_btn_layout.addWidget(self.y_deselect_all)
- y_btn_layout.addStretch()
- y_layout.addLayout(y_btn_layout)
-
- y_group.setLayout(y_layout)
- layout.addWidget(y_group)
-
- # 回归方法选择
- method_group = QGroupBox("回归方法选择 (可多选)")
- method_layout = QVBoxLayout()
-
- method_grid = QGridLayout()
- regression_methods = [
- 'linear', 'exponential', 'power', 'logarithmic',
- 'polynomial', 'hyperbolic', 'sigmoidal'
- ]
-
- for i, method in enumerate(regression_methods):
- checkbox = QCheckBox(method)
- # 默认选择常用的方法
- if method in ['linear', 'exponential', 'power', 'logarithmic']:
- checkbox.setChecked(True)
- self.method_checkboxes[method] = checkbox
- method_grid.addWidget(checkbox, i // 3, i % 3)
-
- method_layout.addLayout(method_grid)
-
- # 方法全选/反选按钮
- method_btn_layout = QHBoxLayout()
- self.method_select_all = QPushButton("全选")
- self.method_deselect_all = QPushButton("全不选")
- self.method_select_all.clicked.connect(lambda: self.toggle_checkboxes(self.method_checkboxes, True))
- self.method_deselect_all.clicked.connect(lambda: self.toggle_checkboxes(self.method_checkboxes, False))
- method_btn_layout.addWidget(self.method_select_all)
- method_btn_layout.addWidget(self.method_deselect_all)
- method_btn_layout.addStretch()
- method_layout.addLayout(method_btn_layout)
-
- method_group.setLayout(method_layout)
- layout.addWidget(method_group)
-
- # 输出目录
- output_group = QGroupBox("输出设置")
- output_layout = QFormLayout()
-
- self.output_dir = QLineEdit()
- self.output_dir.setText("9_Custom_Regression_Modeling")
- output_layout.addRow("输出目录名:", self.output_dir)
-
- output_group.setLayout(output_layout)
- layout.addWidget(output_group)
-
- # 启用步骤
- self.enable_checkbox = QCheckBox("启用此步骤")
- self.enable_checkbox.setChecked(True)
- layout.addWidget(self.enable_checkbox)
-
- # 独立运行按钮
- self.run_button = QPushButton("独立运行此步骤")
- self.run_button.setStyleSheet("""
- QPushButton {
- background-color: #4CAF50;
- color: white;
- padding: 8px 16px;
- border: none;
- border-radius: 4px;
- font-weight: bold;
- }
- QPushButton:hover {
- background-color: #45a049;
- }
- QPushButton:pressed {
- background-color: #3e8e41;
- }
- """)
- self.run_button.clicked.connect(self.run_step)
- layout.addWidget(self.run_button)
-
- layout.addStretch()
- self.setLayout(layout)
-
- def toggle_checkboxes(self, checkboxes_dict, checked):
- """统一设置checkbox状态"""
- for checkbox in checkboxes_dict.values():
- checkbox.setChecked(checked)
-
- def on_csv_file_changed(self):
- """CSV文件改变时自动刷新列信息"""
- self.refresh_csv_columns()
-
- def refresh_csv_columns(self):
- """刷新CSV文件的列信息"""
- csv_path = self.csv_file.get_path()
- if not csv_path or not os.path.exists(csv_path):
- self.csv_columns = []
- self.update_column_widgets()
+ def load_ref_points(self):
+ """加载并显示参考点"""
+ import os
+ if not self.ref_csv or not os.path.isfile(self.ref_csv):
return
try:
- # 读取CSV文件的第一行作为列名
- df = pd.read_csv(csv_path, nrows=0)
- self.csv_columns = list(df.columns)
- self.update_column_widgets()
+ 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.csv_columns = []
- self.update_column_widgets()
- print(f"读取CSV列信息失败: {e}")
+ self.status_label.setText(f"加载参考点失败: {e}")
- def update_column_widgets(self):
- """更新列选择组件"""
- # 清空现有的自变量checkbox
- for checkbox in self.x_column_checkboxes.values():
- checkbox.setParent(None)
- self.x_column_checkboxes.clear()
+ 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
- # 清空现有的因变量checkbox
- for checkbox in self.y_column_checkboxes.values():
- checkbox.setParent(None)
- self.y_column_checkboxes.clear()
-
- if not self.csv_columns:
+ def on_click(self, event):
+ """鼠标点击事件"""
+ if event.inaxes != self.ax or event.xdata is None or event.ydata is None:
return
- # 添加自变量checkbox(三列排列)
- for i, col in enumerate(self.csv_columns):
- checkbox = QCheckBox(col)
- # 默认选择一些常见的指数列
- if any(keyword in col.lower() for keyword in ['index', 'ratio', 'normalized', 'nd', 'b']):
- checkbox.setChecked(True)
- self.x_column_checkboxes[col] = checkbox
- self.x_columns_layout.addWidget(checkbox, i // 3, i % 3)
-
- # 添加因变量checkbox(两列排列)
- for i, col in enumerate(self.csv_columns):
- checkbox = QCheckBox(col)
- # 默认选择一些常见的水质参数列
- if any(keyword in col.lower() for keyword in ['chl', 'tn', 'tp', 'turbidity', 'do', 'ph', 'conductivity']):
- checkbox.setChecked(True)
- self.y_column_checkboxes[col] = checkbox
- self.y_columns_layout.addWidget(checkbox, i // 2, i % 2)
-
- # 重新布局
- self.x_columns_layout.update()
- self.y_columns_layout.update()
-
- def get_config(self):
- # 获取选中的自变量列
- selected_x_columns = [
- col for col, checkbox in self.x_column_checkboxes.items()
- if checkbox.isChecked()
- ]
-
- # 获取选中的因变量列
- selected_y_columns = [
- col for col, checkbox in self.y_column_checkboxes.items()
- if checkbox.isChecked()
- ]
-
- # 获取选中的回归方法
- selected_methods = [
- method for method, checkbox in self.method_checkboxes.items()
- if checkbox.isChecked()
- ]
- if not selected_methods:
- selected_methods = 'all'
-
- return {
- 'csv_path': self.csv_file.get_path() or None,
- 'x_columns': selected_x_columns,
- 'y_columns': selected_y_columns,
- 'methods': selected_methods,
- 'output_dir': self.output_dir.text().strip() or None,
- 'enabled': self.enable_checkbox.isChecked()
- }
-
- def set_config(self, config):
- if 'csv_path' in config:
- self.csv_file.set_path(config['csv_path'])
- # 设置CSV路径后自动刷新列信息
- self.refresh_csv_columns()
-
- if 'x_columns' in config:
- selected_x = set(config['x_columns']) if isinstance(config['x_columns'], list) else set()
- for col, checkbox in self.x_column_checkboxes.items():
- checkbox.setChecked(col in selected_x)
-
- if 'y_columns' in config:
- selected_y = set(config['y_columns']) if isinstance(config['y_columns'], list) else set()
- for col, checkbox in self.y_column_checkboxes.items():
- checkbox.setChecked(col in selected_y)
-
- if 'methods' in config:
- methods = config['methods']
- if isinstance(methods, list):
- selected_methods = set(methods)
- elif methods == 'all':
- selected_methods = set(self.method_checkboxes.keys())
- else:
- selected_methods = set()
-
- for method, checkbox in self.method_checkboxes.items():
- checkbox.setChecked(method in selected_methods)
-
- if 'output_dir' in config:
- self.output_dir.setText(config['output_dir'] or "9_Custom_Regression_Modeling")
- if 'enabled' in config:
- self.enable_checkbox.setChecked(config['enabled'])
-
- def run_step(self):
- """独立运行步骤6.75"""
- # 验证输入
- csv_path = self.csv_file.get_path()
-
- if not csv_path:
- QMessageBox.warning(self, "输入验证失败", "请选择输入CSV文件")
- return
- if not os.path.exists(csv_path):
- QMessageBox.warning(self, "输入验证失败", "输入CSV文件不存在")
+ px, py = int(round(event.xdata)), int(round(event.ydata))
+ if not (0 <= px < self.width and 0 <= py < self.height):
return
- # 检查是否有选中的自变量
- selected_x_columns = [
- col for col, checkbox in self.x_column_checkboxes.items()
- if checkbox.isChecked()
- ]
- if not selected_x_columns:
- QMessageBox.warning(self, "输入验证失败", "请至少选择一个自变量列")
- 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))
- # 检查是否有选中的因变量
- selected_y_columns = [
- col for col, checkbox in self.y_column_checkboxes.items()
- if checkbox.isChecked()
- ]
- if not selected_y_columns:
- QMessageBox.warning(self, "输入验证失败", "请至少选择一个因变量列")
- return
+ # 经纬度转换
+ lon, lat = self.pixel_to_geo(px, py)
+ geo_str = f"Lon={lon:.6f}, Lat={lat:.6f}" if lon is not None else "无地理参考"
- # 检查是否有选中的回归方法
- selected_methods = [
- method for method, checkbox in self.method_checkboxes.items()
- if checkbox.isChecked()
- ]
- if not selected_methods:
- QMessageBox.warning(self, "输入验证失败", "请至少选择一种回归方法")
- return
+ self.status_label.setText(
+ f"像素: (行={py}, 列={px}) | {geo_str} | "
+ f"波段值: {' | '.join(vals[:5])}" +
+ (f" ... ({n_bands}波段的更多信息)" if n_bands > 5 else "")
+ )
- # 获取配置
- config = self.get_config()
-
- # 调用GUI的run_single_step方法
- parent = self.parent()
- while parent and not hasattr(parent, 'run_single_step'):
- parent = parent.parent()
-
- if parent and hasattr(parent, 'run_single_step'):
- parent.run_single_step('step6_75', {'step6_75': config})
- else:
- QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
class WaterQualityGUI(QMainWindow):
@@ -5758,7 +1352,7 @@ class WaterQualityGUI(QMainWindow):
'glint_mask_path': ('step2', 'glint_mask', 'glint_mask_file') # 步骤5可选耀斑掩膜
},
'step5_5': {
- 'csv_path': ('step4', 'processed_data', 'csv_file') # 步骤5.5需要处理后的CSV
+ 'training_spectra_path': ('step5', 'training_spectra', 'output_file') # 步骤5.5需要步骤5输出的训练光谱
},
'step6': {
'csv_path': ('step5', 'training_spectra', 'csv_file') # 步骤6需要训练光谱数据
@@ -6512,6 +2106,26 @@ class WaterQualityGUI(QMainWindow):
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)
+
+ # Step7(采样点布设)切换时自动填充掩膜和输出路径
+ elif index == 9:
+ self.step7_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline)
+
def apply_stylesheet(self):
"""应用样式表 - 应用现代化设计风格"""
# 应用主样式表
@@ -7090,7 +2704,7 @@ class WaterQualityGUI(QMainWindow):
"""流程完成"""
self.run_all_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
-
+
if success:
self.progress_bar.setValue(100)
self.log_message("="*50, "info")