From c12b9d8d8a8552a5bf27d0fca3674e542d926c72 Mon Sep 17 00:00:00 2001 From: DXC Date: Thu, 7 May 2026 14:23:58 +0800 Subject: [PATCH] =?UTF-8?q?=E7=95=8C=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/prediction/inference_batch.py | 8 +- .../water_quality_inversion_pipeline_GUI.py | 6 +- src/gui/components/__init__.py | 1 + src/gui/components/custom_widgets.py | 78 + src/gui/core/__init__.py | 1 + src/gui/core/worker_thread.py | 327 ++ src/gui/model/waterindex.csv | 46 + src/gui/panels/report_generation_panel.py | 305 + src/gui/panels/step1_panel.py | 282 + src/gui/panels/step2_panel.py | 207 + src/gui/panels/step3_panel.py | 450 ++ src/gui/panels/step4_panel.py | 182 + src/gui/panels/step5_5_panel.py | 382 ++ src/gui/panels/step5_panel.py | 218 + src/gui/panels/step6_5_panel.py | 245 + src/gui/panels/step6_75_panel.py | 327 ++ src/gui/panels/step6_panel.py | 320 ++ src/gui/panels/step7_panel.py | 182 + src/gui/panels/step8_5_panel.py | 143 + src/gui/panels/step8_75_panel.py | 140 + src/gui/panels/step8_panel.py | 131 + src/gui/panels/step9_panel.py | 375 ++ src/gui/panels/visualization_panel.py | 1486 +++++ src/gui/water_quality_gui.py | 4886 +---------------- 24 files changed, 6090 insertions(+), 4638 deletions(-) create mode 100644 src/gui/components/__init__.py create mode 100644 src/gui/components/custom_widgets.py create mode 100644 src/gui/core/__init__.py create mode 100644 src/gui/core/worker_thread.py create mode 100644 src/gui/model/waterindex.csv create mode 100644 src/gui/panels/report_generation_panel.py create mode 100644 src/gui/panels/step1_panel.py create mode 100644 src/gui/panels/step2_panel.py create mode 100644 src/gui/panels/step3_panel.py create mode 100644 src/gui/panels/step4_panel.py create mode 100644 src/gui/panels/step5_5_panel.py create mode 100644 src/gui/panels/step5_panel.py create mode 100644 src/gui/panels/step6_5_panel.py create mode 100644 src/gui/panels/step6_75_panel.py create mode 100644 src/gui/panels/step6_panel.py create mode 100644 src/gui/panels/step7_panel.py create mode 100644 src/gui/panels/step8_5_panel.py create mode 100644 src/gui/panels/step8_75_panel.py create mode 100644 src/gui/panels/step8_panel.py create mode 100644 src/gui/panels/step9_panel.py create mode 100644 src/gui/panels/visualization_panel.py 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")