#!/usr/bin/env python # -*- coding: utf-8 -*- """ 水质参数反演分析系统 - 图形用户界面 GUI for Water Quality Inversion Pipeline """ import os import json import copy import sys import traceback from pathlib import Path from datetime import datetime from typing import Dict, Optional, List, Union import numpy as np import pandas as pd import multiprocessing from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox, QFileDialog, QTextEdit, QProgressBar, QMessageBox, QScrollArea, QGroupBox, QTabWidget, QSplitter, QListWidget, QListWidgetItem, QFrame, QGridLayout, QFormLayout, QSizePolicy, QDialog, QStackedWidget, QTableView, QHeaderView, QAbstractItemView, QRadioButton, QButtonGroup, QToolBar, QTreeWidget, QTreeWidgetItem, QInputDialog, ) from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer, QAbstractTableModel, QSize from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPalette, QColor, QPixmap # 导入样式模块 - 兼容开发环境和 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__)) project_root = os.path.abspath(os.path.join(current_dir, '..', '..')) if project_root not in sys.path: sys.path.insert(0, project_root) from src.gui.styles import ModernStylesheet # Matplotlib相关导入 import matplotlib matplotlib.use('Qt5Agg') from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar from matplotlib.figure import Figure import matplotlib.pyplot as plt # 导入原有的pipeline类 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) # step5:输出路径由管线固定到工作目录,GUI 占位字段勿传入 if step_name == 'step5': 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 def _viz_training_spectra_csv_path(work_path: Path) -> Path: """可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤5输出一致)。""" 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_models 下的参数子文件夹。") 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 = [] 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 FileSelectWidget(QWidget): """文件选择组件""" def __init__(self, label_text, file_filter="All Files (*.*)", parent=None): super().__init__(parent) self.file_filter = file_filter 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() self.line_edit.setPlaceholderText("请选择文件...") 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): """浏览文件""" file_path, _ = QFileDialog.getOpenFileName( self, "选择文件", "", 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): 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 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) 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参数设置 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) ndwi_group.setLayout(ndwi_layout) layout.addWidget(ndwi_group) # 输出文件路径 self.output_file = FileSelectWidget( "输出掩膜:", "Mask Files (*.dat *.tif);;All Files (*.*)" ) self.output_file.line_edit.setPlaceholderText("water_mask.dat") layout.addWidget(self.output_file) # 提示信息 hint = QLabel("提示: 如果掩膜文件是Shapefile(.shp),需要提供参考影像用于栅格化;如果使用NDWI自动生成,只需要提供参考影像") hint.setStyleSheet("color: #666; font-size: 10px;") 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() # 掩膜文件在NDWI模式下禁用 self.mask_file.setEnabled(not use_ndwi) # 影像文件在两种模式下都需要 self.img_file.setEnabled(True) # NDWI参数在NDWI模式下启用 for i in range(self.layout().count()): widget = self.layout().itemAt(i).widget() if widget and isinstance(widget, QGroupBox) and widget.title() == "NDWI参数设置": widget.setEnabled(use_ndwi) break def get_config(self): """获取配置""" config = { 'mask_path': None if self.use_ndwi_radio.isChecked() else self.mask_file.get_path(), 'use_ndwi': self.use_ndwi_radio.isChecked(), 'ndwi_threshold': self.ndwi_threshold.value() } img_path = self.img_file.get_path() if img_path: config['img_path'] = img_path output_path = self.output_file.get_path() if output_path: config['output_path'] = output_path 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.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.addItems(['otsu', 'zscore', 'percentile', 'iqr', 'adaptive', '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("glint_mask.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) def get_config(self): """获取配置""" config = { 'img_path': self.img_file.get_path(), 'glint_wave': self.glint_wave.value(), 'method': self.method.currentText(), } 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.findText(config['method']) 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 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() self.method.addItems(['goodman', 'kutser', 'hedley', 'sugar']) self.method.currentTextChanged.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() self.interp_method.addItems(['nearest', 'bilinear', 'spline', 'kriging']) self.interp_method.setCurrentText('bilinear') 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) def on_method_changed(self, method): """方法改变时更新参数显示""" self.goodman_group.setVisible(method == 'goodman') self.kutser_group.setVisible(method == 'kutser') self.hedley_group.setVisible(method == 'hedley') self.sugar_group.setVisible(method == 'sugar') def get_config(self): """获取配置""" config = { 'img_path': self.img_file.get_path(), 'method': self.method.currentText(), 'enabled': self.enable_checkbox.isChecked(), 'interpolate_zeros': self.interpolate_zeros.isChecked(), 'interpolation_method': self.interp_method.currentText(), } 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.currentText() 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.currentText() 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.findText(config['method']) 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.findText(config['interpolation_method']) 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.findText(config['sugar_glint_mask_method']) 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) # 公式CSV文件选择 self.formula_csv_file = FileSelectWidget( "公式CSV文件:", "CSV Files (*.csv);;All Files (*.*)" ) layout.addWidget(self.formula_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.prediction_column = QLineEdit() self.prediction_column.setText("prediction") params_layout.addRow("预测列名:", self.prediction_column) params_group.setLayout(params_layout) layout.addWidget(params_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 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 = { '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 # 添加公式CSV文件路径 formula_csv_path = self.formula_csv_file.get_path() if formula_csv_path: config['formula_csv_file'] = formula_csv_path # 添加模型目录路径 models_dir = self.models_dir_file.get_path() if models_dir: config['custom_regression_dir'] = models_dir return config def set_config(self, config): """设置配置""" 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 'formula_csv_file' in config: self.formula_csv_file.set_path(config['formula_csv_file']) if 'custom_regression_dir' in config: self.models_dir_file.set_path(config['custom_regression_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 formula_csv_path = self.formula_csv_file.get_path() if not 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('step8_75', {'step8_75': config}) else: QMessageBox.critical(self, "错误", "无法找到父级GUI对象") class ChartViewerDialog(QDialog): """图表查看器对话框""" def __init__(self, title="图表查看器", parent=None): super().__init__(parent) self.setWindowTitle(title) self.resize(1000, 700) self.init_ui() def init_ui(self): layout = QVBoxLayout() # 创建matplotlib图形 self.figure = Figure(figsize=(10, 7)) self.canvas = FigureCanvas(self.figure) self.canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # 添加工具栏 self.toolbar = NavigationToolbar(self.canvas, self) layout.addWidget(self.toolbar) layout.addWidget(self.canvas) # 按钮组 btn_layout = QHBoxLayout() self.save_btn = QPushButton("保存图表") self.save_btn.clicked.connect(self.save_chart) btn_layout.addWidget(self.save_btn) btn_layout.addStretch() self.close_btn = QPushButton("关闭") self.close_btn.clicked.connect(self.close) btn_layout.addWidget(self.close_btn) layout.addLayout(btn_layout) self.setLayout(layout) def display_image(self, image_path): """显示图片""" self.figure.clear() ax = self.figure.add_subplot(111) try: import matplotlib.image as mpimg img = mpimg.imread(image_path) ax.imshow(img) ax.axis('off') self.figure.tight_layout() self.canvas.draw() self.current_image_path = image_path except Exception as e: ax.text(0.5, 0.5, f'加载图片失败:\n{str(e)}', ha='center', va='center', transform=ax.transAxes) self.canvas.draw() def display_custom_plot(self, plot_func): """显示自定义绘图函数""" self.figure.clear() try: plot_func(self.figure) self.canvas.draw() except Exception as e: ax = self.figure.add_subplot(111) ax.text(0.5, 0.5, f'绘图失败:\n{str(e)}', ha='center', va='center', transform=ax.transAxes) self.canvas.draw() def save_chart(self): """保存图表""" file_path, _ = QFileDialog.getSaveFileName( self, "保存图表", "", "PNG图片 (*.png);;JPG图片 (*.jpg);;PDF文件 (*.pdf);;所有文件 (*.*)" ) if file_path: try: self.figure.savefig(file_path, dpi=300, bbox_inches='tight') QMessageBox.information(self, "成功", f"图表已保存到:\n{file_path}") except Exception as e: QMessageBox.critical(self, "错误", f"保存失败:\n{str(e)}") class ImageCategoryTree(QTreeWidget): """图像分类目录树 - 按类别组织图像文件""" # 图像类别定义:(类别名称, 关键词列表, 图标) CATEGORIES = [ ("模型评估", ["scatter", "regression", "validation", "r2", "rmse"], "📊"), ("光谱分析", ["spectrum", "spectral", "band", "wavelength"], "📈"), ("统计图表", ["boxplot", "histogram", "heatmap", "statistics", "stats"], "📉"), ("处理结果", ["mask", "glint", "deglint", "preview", "overlay", "water_mask"], "🖼️"), ("采样分析", ["sampling", "flight_path", "point_map", "trajectory"], "📍"), ("其他图表", [], "📁"), ] def __init__(self, parent=None): super().__init__(parent) self.setHeaderLabel("图像目录") self.setMaximumWidth(300) self.setMinimumWidth(250) self.setup_categories() self.setStyleSheet(""" QTreeWidget { border: 1px solid #ddd; border-radius: 5px; background-color: #f8f9fa; } QTreeWidget::item { padding: 5px; border-radius: 3px; } QTreeWidget::item:selected { background-color: #0078D4; color: white; } QTreeWidget::item:hover { background-color: #e3f2fd; } """) def setup_categories(self): """初始化类别节点""" self.category_items = {} for category_name, keywords, icon in self.CATEGORIES: item = QTreeWidgetItem(self) item.setText(0, f"{icon} {category_name}") item.setData(0, Qt.UserRole, {"type": "category", "keywords": keywords, "name": category_name}) item.setExpanded(True) self.category_items[category_name] = item def clear_all_images(self): """清除所有图像项""" for category_item in self.category_items.values(): # 删除所有子项 while category_item.childCount() > 0: category_item.removeChild(category_item.child(0)) def add_image(self, file_path: Path, display_name: str = None): """添加图像到对应的类别""" if display_name is None: display_name = file_path.stem # 根据文件名关键词确定类别 category = self._determine_category(file_path.name) category_item = self.category_items.get(category, self.category_items["其他图表"]) # 创建图像项 image_item = QTreeWidgetItem(category_item) image_item.setText(0, f" └─ {display_name}") image_item.setData(0, Qt.UserRole, {"type": "image", "path": str(file_path)}) image_item.setToolTip(0, str(file_path)) return image_item def _determine_category(self, filename: str) -> str: """根据文件名确定类别""" filename_lower = filename.lower() for category_name, keywords, _ in self.CATEGORIES: if any(keyword in filename_lower for keyword in keywords): return category_name return "其他图表" def scan_directory(self, work_dir: str): """扫描目录中的所有图像文件""" self.clear_all_images() work_path = Path(work_dir) if not work_path.exists(): return # 查找所有图像文件:14_visualization 为主,同时扫描步骤产出目录(如 1_water_mask 下的预览/叠置图) image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp'] scan_roots: List[Path] = [] _viz = work_path / "14_visualization" if _viz.is_dir(): scan_roots.append(_viz) _wm = work_path / "1_water_mask" if _wm.is_dir(): scan_roots.append(_wm) if not scan_roots: scan_roots.append(work_path) seen_norm: set = set() image_files: List[Path] = [] for root in scan_roots: for ext in image_extensions: for p in root.glob(f"**/{ext}"): key = os.path.normcase(os.path.normpath(str(p.resolve()))) if key in seen_norm: continue seen_norm.add(key) image_files.append(p) # 添加图像到树 for img_file in sorted(image_files): # 跳过缩略图和临时文件 if img_file.name.startswith('.') or 'thumb' in img_file.name.lower(): continue self.add_image(img_file) # 更新类别项文本显示数量 for category_name, item in self.category_items.items(): count = item.childCount() if count > 0: for cat_name, _, icon in self.CATEGORIES: if cat_name == category_name: item.setText(0, f"{icon} {category_name} ({count})") break def get_selected_image_path(self) -> Optional[str]: """获取当前选中的图像路径""" selected_item = self.currentItem() if not selected_item: return None data = selected_item.data(0, Qt.UserRole) if data and data.get("type") == "image": return data.get("path") return None class ImageViewerWidget(QWidget): """图像查看器组件 - 支持缩放、平移""" def __init__(self, parent=None): super().__init__(parent) self.current_image_path = None self.scale_factor = 1.0 self._update_timer = QTimer() # 防抖定时器 self._update_timer.setSingleShot(True) self._update_timer.timeout.connect(self._do_update_display) self._pending_scale = None # 待更新的缩放比例 self.setup_ui() def setup_ui(self): layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) # 工具栏 toolbar = QHBoxLayout() self.refresh_btn = QPushButton("🔄 刷新目录") self.refresh_btn.setToolTip("重新扫描工作目录中的图像文件") toolbar.addWidget(self.refresh_btn) # 添加分隔线 separator = QFrame() separator.setFrameShape(QFrame.VLine) separator.setFrameShadow(QFrame.Sunken) toolbar.addWidget(separator) self.zoom_in_btn = QPushButton("🔍+") self.zoom_in_btn.setToolTip("放大") self.zoom_in_btn.setMaximumWidth(50) toolbar.addWidget(self.zoom_in_btn) self.zoom_out_btn = QPushButton("🔍-") self.zoom_out_btn.setToolTip("缩小") self.zoom_out_btn.setMaximumWidth(50) toolbar.addWidget(self.zoom_out_btn) self.fit_btn = QPushButton("⬜ 适应窗口") self.fit_btn.setToolTip("适应窗口大小") toolbar.addWidget(self.fit_btn) self.original_btn = QPushButton("1:1 原始大小") self.original_btn.setToolTip("原始大小") toolbar.addWidget(self.original_btn) toolbar.addStretch() self.save_btn = QPushButton("💾 保存") self.save_btn.setToolTip("保存当前图像") toolbar.addWidget(self.save_btn) layout.addLayout(toolbar) # 图像显示区域 - 使用 QLabel + QScrollArea self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) self.scroll_area.setStyleSheet("background-color: white;") self.image_label = QLabel() self.image_label.setAlignment(Qt.AlignCenter) self.image_label.setStyleSheet("background-color: white;") self.scroll_area.setWidget(self.image_label) layout.addWidget(self.scroll_area, 1) # 状态栏 status_layout = QHBoxLayout() self.status_label = QLabel("就绪") self.status_label.setStyleSheet("color: #666; font-size: 11px;") status_layout.addWidget(self.status_label) status_layout.addStretch() layout.addLayout(status_layout) self.setLayout(layout) # 连接信号 self.zoom_in_btn.clicked.connect(self.zoom_in) self.zoom_out_btn.clicked.connect(self.zoom_out) self.fit_btn.clicked.connect(self.fit_to_window) self.original_btn.clicked.connect(self.original_size) self.save_btn.clicked.connect(self.save_image) def load_image(self, image_path: str): """加载并显示图像""" if not image_path or not Path(image_path).exists(): self.image_label.setText("图像不存在") self.status_label.setText("图像加载失败") return self.current_image_path = image_path self.scale_factor = 1.0 # 加载图像 pixmap = QPixmap(image_path) if pixmap.isNull(): self.image_label.setText("无法加载图像") self.status_label.setText("图像格式不支持") return self.original_pixmap = pixmap # 默认适应窗口显示 self.fit_to_window() # 更新状态 file_info = Path(image_path).stat() size_mb = file_info.st_size / (1024 * 1024) self.status_label.setText(f"{pixmap.width()}x{pixmap.height()} | {size_mb:.2f} MB | {Path(image_path).name} | 适应窗口") def update_image_display(self): """更新图像显示 - 使用防抖避免频繁重绘卡顿""" # 取消之前的待执行更新,重新计时 self._update_timer.stop() self._pending_scale = self.scale_factor self._update_timer.start(50) # 50ms后执行实际更新 def _do_update_display(self): """实际执行图像更新""" if not hasattr(self, 'original_pixmap') or self.original_pixmap.isNull(): return if self._pending_scale is None: return # 根据缩放比例选择变换模式:大幅度缩放用Fast模式提升性能 if self._pending_scale > 2.0 or self._pending_scale < 0.5: transform = Qt.FastTransformation else: transform = Qt.SmoothTransformation scaled_pixmap = self.original_pixmap.scaled( int(self.original_pixmap.width() * self._pending_scale), int(self.original_pixmap.height() * self._pending_scale), Qt.KeepAspectRatio, transform ) self.image_label.setPixmap(scaled_pixmap) self._pending_scale = None def wheelEvent(self, event): """鼠标滚轮缩放 - 实时响应""" delta = event.angleDelta().y() if delta > 0: # 向上滚动 - 放大 if self.scale_factor < 5.0: self.scale_factor = min(self.scale_factor * 1.1, 5.0) self.update_image_display() else: # 向下滚动 - 缩小 if self.scale_factor > 0.1: self.scale_factor = max(self.scale_factor / 1.1, 0.1) self.update_image_display() event.accept() def zoom_in(self): """放大""" if self.scale_factor < 5.0: self.scale_factor = min(self.scale_factor * 1.25, 5.0) self.update_image_display() def zoom_out(self): """缩小""" if self.scale_factor > 0.1: self.scale_factor = max(self.scale_factor / 1.25, 0.1) self.update_image_display() def fit_to_window(self): """适应窗口""" if not hasattr(self, 'original_pixmap') or self.original_pixmap.isNull(): return # 计算适应窗口的缩放比例 view_size = self.scroll_area.viewport().size() img_size = self.original_pixmap.size() scale_w = view_size.width() / img_size.width() scale_h = view_size.height() / img_size.height() # 记录适应前的比例(用于后续恢复参考) self._fit_scale = min(scale_w, scale_h) self.scale_factor = self._fit_scale self.update_image_display() self.status_label.setText(f"适应窗口 | 缩放: {self.scale_factor:.1%}") def original_size(self): """原始大小""" self.scale_factor = 1.0 self._fit_scale = None # 清除适应记录 self.update_image_display() self.status_label.setText("原始大小 | 缩放: 100%") def save_image(self): """保存图像""" if not self.current_image_path: return file_path, _ = QFileDialog.getSaveFileName( self, "保存图像", Path(self.current_image_path).name, "PNG图片 (*.png);;JPG图片 (*.jpg);;所有文件 (*.*)" ) if file_path: try: import shutil shutil.copy(self.current_image_path, file_path) except Exception as e: QMessageBox.critical(self, "错误", f"保存失败: {e}") class 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, "gen_scatter_btn", None), getattr(self, "gen_spectrum_btn", None), getattr(self, "gen_stats_btn", None), getattr(self, "gen_mask_glint_btn", None), getattr(self, "gen_sampling_map_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_models 下已有各参数子目录及模型文件," "且训练 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) # 生成按钮组 gen_btn_layout = QHBoxLayout() 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) gen_btn_layout.addWidget(self.gen_all_btn) self.scan_btn = QPushButton("📁 扫描") self.scan_btn.setToolTip("扫描工作目录中的图像文件") self.scan_btn.clicked.connect(self.scan_work_directory) gen_btn_layout.addWidget(self.scan_btn) tree_layout.addLayout(gen_btn_layout) 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_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) # 生成特定图表按钮组 specific_group = QGroupBox("生成特定图表") specific_layout = QHBoxLayout() self.gen_scatter_btn = QPushButton("📊 散点图") self.gen_scatter_btn.setToolTip( "基于工作目录下 5_training_spectra/training_spectra.csv 与 7_models 生成模型评估散点图" ) self.gen_scatter_btn.clicked.connect(lambda: self.generate_chart('scatter')) specific_layout.addWidget(self.gen_scatter_btn) self.gen_spectrum_btn = QPushButton("📈 光谱图") self.gen_spectrum_btn.setToolTip( "基于 5_training_spectra/training_spectra.csv,为每个数值型水质参数各生成一张光谱对比图(无需选择)" ) self.gen_spectrum_btn.clicked.connect(lambda: self.generate_chart('spectrum')) specific_layout.addWidget(self.gen_spectrum_btn) self.gen_stats_btn = QPushButton("📉 统计图") self.gen_stats_btn.setToolTip( "基于工作目录下 5_training_spectra/training_spectra.csv 生成箱线图、直方图与相关性热力图" ) self.gen_stats_btn.clicked.connect(lambda: self.generate_chart('statistics')) specific_layout.addWidget(self.gen_stats_btn) self.gen_mask_glint_btn = QPushButton("🖼️ 掩膜图") self.gen_mask_glint_btn.clicked.connect(lambda: self.generate_mask_glint_previews()) specific_layout.addWidget(self.gen_mask_glint_btn) self.gen_sampling_map_btn = QPushButton("📍 采样点图") self.gen_sampling_map_btn.clicked.connect(lambda: self.generate_sampling_point_map()) specific_layout.addWidget(self.gen_sampling_map_btn) specific_group.setLayout(specific_layout) right_layout.addWidget(specific_group) 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)) # 如果有图像,自动选择第一个 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 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 reply = QMessageBox.question( self, "确认生成", "将按左侧勾选项在后台生成可视化(掩膜/耀斑预览、采样点图等),可能需要较长时间。\n是否继续?", QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: return if self.gen_scatter.isChecked(): print("生成散点图...(占位,请用建模/可视化流程生成)") if self.gen_spectrum.isChecked(): print("生成光谱图...(占位,请用下方「光谱图」按钮)") if self.gen_boxplots.isChecked(): print("生成统计图...(占位,请用下方「统计图」按钮)") if not self.gen_mask_glint.isChecked() and not self.gen_sampling_map.isChecked(): QMessageBox.information( self, "提示", "请至少勾选「掩膜和耀斑缩略图」或「采样点地图」以执行后台批量任务。", ) return extra = { "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_models" 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_statistics': self.gen_stats_btn.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_statistics' in config: self.gen_stats_btn.setChecked(config['generate_statistics']) 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): 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 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_non_empirical_models") # 修改浏览按钮为选择目录 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) 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: # 如果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_non_empirical_models") else: output_dir = str(Path.cwd() / "8_non_empirical_models") 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() # 调用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}) else: QMessageBox.critical(self, "错误", "无法找到父级GUI对象") def _toggle_checkboxes(self, checkboxes_dict, checked): """统一设置预处理checkbox状态""" for checkbox in checkboxes_dict.values(): checkbox.setChecked(checked) 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.setMaximumHeight(200) 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.setMaximumHeight(150) 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") 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() return try: # 读取CSV文件的第一行作为列名 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): """更新列选择组件""" # 清空现有的自变量checkbox for checkbox in self.x_column_checkboxes.values(): checkbox.setParent(None) self.x_column_checkboxes.clear() # 清空现有的因变量checkbox for checkbox in self.y_column_checkboxes.values(): checkbox.setParent(None) self.y_column_checkboxes.clear() if not self.csv_columns: 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") 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() # 调用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): """水质参数反演分析系统主窗口""" def __init__(self): super().__init__() self.pipeline = None self.worker = None self.config_file = None # 训练数据模式状态 self.has_training_data = True # 默认有训练数据 self.init_ui() self.apply_stylesheet() self._disable_wheel_for_all_spinboxes() def get_icon_path(self, icon_filename): """ 获取图标文件的完整路径 在开发环境中从../data/icons/获取,在打包后从data/icons/获取 """ if hasattr(sys, '_MEIPASS'): # 打包后的环境 icon_dir = os.path.join(sys._MEIPASS, 'data', 'icons') else: # 开发环境 current_dir = os.path.dirname(os.path.abspath(__file__)) icon_dir = os.path.join(current_dir, '..', '..', 'data', 'icons') return os.path.join(icon_dir, icon_filename) def _disable_wheel_for_all_spinboxes(self): """ 遍历所有子控件,为 QSpinBox/QDoubleSpinBox/QComboBox 禁用滚轮事件 防止滚动页面时意外改变数值 """ from PyQt5.QtCore import Qt # 找到所有数值输入控件 for spinbox in self.findChildren(QSpinBox): spinbox.setFocusPolicy(Qt.StrongFocus) # 只有聚焦时才响应滚轮 spinbox.wheelEvent = lambda event, sb=spinbox: None # 完全禁用滚轮 for spinbox in self.findChildren(QDoubleSpinBox): spinbox.setFocusPolicy(Qt.StrongFocus) spinbox.wheelEvent = lambda event, sb=spinbox: None for combobox in self.findChildren(QComboBox): combobox.setFocusPolicy(Qt.StrongFocus) combobox.wheelEvent = lambda event, cb=combobox: None def init_ui(self): """初始化UI""" self.setWindowTitle("水质参数反演分析系统 v1.0") # 获取屏幕可用区域(排除任务栏) screen_geometry = QApplication.primaryScreen().availableGeometry() screen_width = screen_geometry.width() screen_height = screen_geometry.height() # 初始尺寸:宽度固定 800,高度占满屏幕 window_width = 1200 window_height = screen_height # 仅设置初始大小,不锁定 self.resize(window_width, window_height) # 计算水平居中、垂直贴顶的位置 x = (screen_width - window_width) // 2 y = 0 self.move(x, y) # 可选:设置最小尺寸,防止用户缩得太小 self.setMinimumSize(600, 400) # 创建自定义标题栏(包含Logo和菜单栏) self.create_title_bar() # 创建横幅区域 self.create_banner_widget() # 创建中央部件 central_widget = QWidget() self.setCentralWidget(central_widget) # 主布局 main_layout = QHBoxLayout() # 创建左侧导航栏 self.create_navigation() main_layout.addWidget(self.nav_widget, 1) # 创建右侧内容区 self.create_content_area() main_layout.addWidget(self.content_widget, 4) central_widget.setLayout(main_layout) # 创建状态栏 self.statusBar().showMessage("就绪") def create_title_bar(self): """创建自定义标题栏(Logo和菜单栏挨着且同等宽度)""" # 创建标题栏容器 title_widget = QWidget() title_layout = QHBoxLayout() title_layout.setContentsMargins(8, 4, 8, 4) title_layout.setSpacing(0) # 让Logo和菜单栏紧挨着 # Logo部分(左侧,增加宽度) logo_label = QLabel() logo_label.setFixedSize(180, 48) # 增加Logo宽度,使其和菜单栏视觉平衡 logo_label.setAlignment(Qt.AlignCenter) logo_label.setStyleSheet(""" QLabel { background-color: #f8f9fa; border-top-left-radius: 4px; border-bottom-left-radius: 4px; } """) # 设置Logo图片路径 - 使用相对路径(打包兼容) from pathlib import Path if hasattr(sys, '_MEIPASS'): logo_path = os.path.join(sys._MEIPASS, 'data', 'icons', 'logo.png') else: logo_path = str(Path(__file__).parent.parent.parent / "data" / "icons" / "logo.png") logo_pixmap = QPixmap(logo_path) if not logo_pixmap.isNull(): # 按高度缩放图片,保持宽高比,让Logo更显眼 scaled_pixmap = logo_pixmap.scaledToHeight(38, Qt.SmoothTransformation) logo_label.setPixmap(scaled_pixmap) else: # 如果图片加载失败,显示占位符 logo_label.setText("Logo") logo_label.setStyleSheet(""" QLabel { background-color: #f8f9fa; color: #333; font-size: 14px; font-weight: bold; border-top-left-radius: 4px; border-bottom-left-radius: 4px; } """) title_layout.addWidget(logo_label) # 菜单栏(紧挨着Logo右侧) menubar = self.menuBar() menubar.setStyleSheet(""" QMenuBar { background-color: #f8f9fa; border: none; padding: 4px 8px; border-top-right-radius: 4px; border-bottom-right-radius: 4px; } QMenuBar::item { padding: 6px 12px; background-color: transparent; font-size: 13px; } QMenuBar::item:selected { background-color: #e6f0ff; border-radius: 3px; } """) # 文件菜单 file_menu = menubar.addMenu("文件") new_action = file_menu.addAction("新建配置") new_action.triggered.connect(self.new_config) open_action = file_menu.addAction("打开配置") open_action.triggered.connect(self.load_config_dialog) save_action = file_menu.addAction("保存配置") save_action.triggered.connect(self.save_config_dialog) file_menu.addSeparator() exit_action = file_menu.addAction("退出") exit_action.triggered.connect(self.close) # 工具菜单 tools_menu = menubar.addMenu("工具") work_dir_action = tools_menu.addAction("设置工作目录") work_dir_action.triggered.connect(self.set_work_directory) open_dir_action = tools_menu.addAction("打开工作目录") open_dir_action.triggered.connect(self.open_work_directory) # 在工具菜单中添加训练数据模式切换按钮 self.training_mode_action = tools_menu.addAction("有训练数据模式") self.training_mode_action.setCheckable(True) self.training_mode_action.setChecked(True) # 默认有训练数据模式 self.training_mode_action.triggered.connect(self.toggle_training_data_mode) # 帮助菜单 help_menu = menubar.addMenu("帮助") pipeline_status_action = help_menu.addAction("检查Pipeline状态") pipeline_status_action.triggered.connect(self.show_pipeline_status) help_menu.addSeparator() about_action = help_menu.addAction("关于") about_action.triggered.connect(self.show_about) title_layout.addWidget(menubar) title_widget.setLayout(title_layout) # 设置整体标题栏样式 title_widget.setStyleSheet(""" QWidget { background-color: #f8f9fa; border-bottom: 1px solid #d0d0d0; } """) # 将标题栏添加到窗口顶部 self.setMenuWidget(title_widget) def create_banner_widget(self): """创建横幅区域 - 支持自适应等比缩放""" # 创建横幅容器 banner_widget = QWidget() banner_layout = QHBoxLayout() banner_layout.setContentsMargins(0, 0, 0, 0) banner_layout.setSpacing(0) # 不设置居中对齐,让横幅填满整个容器 # 创建横幅标签 - 完全跟随窗口等比缩放,填满整个区域 self.banner_label = QLabel() # 最小高度保证:当窗口很小时至少显示 38px 高 (200px 宽 / 5.25) self.banner_label.setMinimumHeight(int(200 / 5.25)) # ≈ 38px # 使用 Expanding 策略让标签填满可用空间 self.banner_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.banner_label.setScaledContents(False) # 清除 QLabel 默认的 margin 和 padding,消除右侧空白 self.banner_label.setStyleSheet("margin: 0px; padding: 0px; border: none;") # 保存原始pixmap用于后续缩放 if hasattr(sys, '_MEIPASS'): banner_path = os.path.join(sys._MEIPASS, 'data', 'icons', 'Mega Water 1.0.png') else: banner_path = str(Path(__file__).parent.parent.parent / "data" / "icons" / "Mega Water 1.0.png") self.banner_pixmap = QPixmap(banner_path) if not self.banner_pixmap.isNull(): # 延迟执行,确保窗口已初始化 QTimer.singleShot(50, self.update_banner_image) else: # 如果图片加载失败,显示占位符 self.banner_label.setText("水质参数反演分析系统") self.banner_label.setStyleSheet(""" QLabel { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #0078d4, stop:1 #00a0e9); color: white; font-size: 26px; font-weight: bold; border-bottom: 3px solid #005a9e; } """) banner_layout.addWidget(self.banner_label) banner_widget.setLayout(banner_layout) # 将横幅添加到窗口顶部(在标题栏下方) banner_toolbar = QToolBar() banner_toolbar.setMovable(False) banner_toolbar.setFloatable(False) banner_toolbar.addWidget(banner_widget) banner_toolbar.setContentsMargins(0, 0, 0, 0) # 清除工具栏布局的边距 banner_toolbar.setStyleSheet(""" QToolBar { background-color: white; border: none; border-bottom: 1px solid #ddd; padding: 0px; margin: 0px; spacing: 0px; } QToolBar QWidget { margin: 0px; padding: 0px; } """) self.addToolBar(Qt.TopToolBarArea, banner_toolbar) self.banner_widget = banner_toolbar def create_navigation(self): """创建左侧导航栏""" self.nav_widget = QWidget() nav_layout = QVBoxLayout() nav_layout.setContentsMargins(10, 15, 10, 15) nav_layout.setSpacing(10) # 标题 title = QLabel("流程步骤") title.setFont(QFont("Arial", 13, QFont.Bold)) title.setAlignment(Qt.AlignCenter) title.setStyleSheet(f"color: {ModernStylesheet.COLORS['text_primary']}; padding: 10px;") nav_layout.addWidget(title) # 步骤列表 - 分层结构 self.step_list = QListWidget() self.step_list.setStyleSheet(ModernStylesheet.get_sidebar_stylesheet()) # 定义三阶段结构 self.process_stages = { "阶段一:数据预处理": [ ("step1", "1. 水域掩膜生成"), ("step2", "2. 耀斑区域识别"), ("step3", "3. 耀斑去除与修复"), ("step4", "4. 数据标准化处理"), ], "阶段二:特征提取与建模": [ ("step5", "5. 光谱特征提取"), ("step5_5", "6. 水质参数指数计算"), ("step6", "7. 监督学习模型训练"), ("step6_5", "8. 经验统计回归"), ("step6_75", "9. 自定义回归模型"), ], "阶段三:应用与可视化": [ ("step7", "10. 采样点布设"), ("step8", "11. 基于监督学习预测"), ("step8_5", "12. 基于统计回归预测"), ("step8_75", "13. 基于自定义回归预测"), ("step9", "14. 专题图生成"), ("step9_viz", "15. 可视化分析"), ("step_report", "16. 分析报告生成"), ] } # 存储步骤映射 self.step_name_map = {} # 添加分组项到列表 for stage_idx, (stage_name, steps) in enumerate(self.process_stages.items()): # 添加阶段标题项(可视化分组) stage_item = QListWidgetItem(stage_name) stage_font = QFont("Arial", 11, QFont.Bold) stage_item.setFont(stage_font) stage_item.setForeground(QColor(ModernStylesheet.COLORS.get('accent', '#0078D4'))) stage_item.setFlags(stage_item.flags() & ~Qt.ItemIsSelectable) stage_item.setFlags(stage_item.flags() & ~Qt.ItemIsEnabled) stage_item.setData(Qt.UserRole, "stage_header") self.step_list.addItem(stage_item) # 添加该阶段的所有步骤 for step_id, step_display in steps: item = QListWidgetItem(f" └─ {step_display}") item.setData(Qt.UserRole, step_id) self.step_name_map[step_display] = step_id # 设置步骤项的样式 step_font = QFont("Arial", 10) item.setFont(step_font) item.setForeground(QColor(ModernStylesheet.COLORS.get('text_secondary', '#666666'))) self.step_list.addItem(item) # 在阶段间添加分隔符 if stage_idx < len(self.process_stages) - 1: separator_item = QListWidgetItem("") separator_item.setFlags(separator_item.flags() & ~Qt.ItemIsSelectable) separator_item.setFlags(separator_item.flags() & ~Qt.ItemIsEnabled) self.step_list.addItem(separator_item) self.step_list.currentRowChanged.connect(self.on_step_changed) nav_layout.addWidget(self.step_list) # 控制按钮 btn_layout = QVBoxLayout() btn_layout.setSpacing(8) self.run_all_btn = QPushButton("> 运行完整流程") self.run_all_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) self.run_all_btn.setMinimumHeight(35) self.run_all_btn.clicked.connect(self.run_full_pipeline) btn_layout.addWidget(self.run_all_btn) self.stop_btn = QPushButton("⏹ 停止") self.stop_btn.setEnabled(False) self.stop_btn.setMinimumHeight(35) self.stop_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('danger')) self.stop_btn.clicked.connect(self.stop_pipeline) btn_layout.addWidget(self.stop_btn) nav_layout.addLayout(btn_layout) self.nav_widget.setLayout(nav_layout) self.nav_widget.setMaximumWidth(280) self.nav_widget.setStyleSheet(f"background-color: {ModernStylesheet.COLORS['panel_bg']}; border-right: 1px solid {ModernStylesheet.COLORS['border_light']};") def create_content_area(self): """创建右侧内容区""" self.content_widget = QWidget() self.content_widget.setStyleSheet(f"background-color: {ModernStylesheet.COLORS['main_bg']};") content_layout = QVBoxLayout() content_layout.setContentsMargins(15, 15, 15, 15) content_layout.setSpacing(10) # 创建步骤面板容器 self.step_stack = QTabWidget() self.step_stack.setTabPosition(QTabWidget.North) self.step_stack.setTabsClosable(False) self.step_stack.setStyleSheet(ModernStylesheet.get_main_stylesheet()) # 添加各步骤面板 self.step1_panel = Step1Panel() self.step_stack.addTab(self.create_scroll_area(self.step1_panel), QIcon(self.get_icon_path("1.png")), "水域掩膜") self.step2_panel = Step2Panel() self.step_stack.addTab(self.create_scroll_area(self.step2_panel), QIcon(self.get_icon_path("2.png")), "耀斑检测") self.step3_panel = Step3Panel() self.step_stack.addTab(self.create_scroll_area(self.step3_panel), QIcon(self.get_icon_path("3.png")), "耀斑去除") self.step4_panel = Step4Panel() self.step_stack.addTab(self.create_scroll_area(self.step4_panel), QIcon(self.get_icon_path("4.png")), "数据清洗") self.step5_panel = Step5Panel() self.step_stack.addTab(self.create_scroll_area(self.step5_panel), QIcon(self.get_icon_path("5.png")), "特征构建") self.step5_5_panel = Step5_5Panel() self.step_stack.addTab(self.create_scroll_area(self.step5_5_panel), QIcon(self.get_icon_path("5.png")), "水质指数") self.step6_panel = Step6Panel() self.step_stack.addTab(self.create_scroll_area(self.step6_panel), QIcon(self.get_icon_path("6.png")), "监督建模") self.step6_5_panel = Step6_5Panel() self.step_stack.addTab(self.create_scroll_area(self.step6_5_panel), QIcon(self.get_icon_path("6.png")), "回归建模") self.step6_75_panel = Step6_75Panel() self.step_stack.addTab(self.create_scroll_area(self.step6_75_panel), QIcon(self.get_icon_path("6.png")), "自定义回归建模") self.step7_panel = Step7Panel() self.step_stack.addTab(self.create_scroll_area(self.step7_panel), QIcon(self.get_icon_path("7.png")), "采样点布设") self.step8_panel = Step8Panel() self.step_stack.addTab(self.create_scroll_area(self.step8_panel), QIcon(self.get_icon_path("8.png")), "监督预测") self.step8_5_panel = Step8_5Panel() self.step_stack.addTab(self.create_scroll_area(self.step8_5_panel), QIcon(self.get_icon_path("8.png")), "回归预测") self.step8_75_panel = Step8_75Panel() self.step_stack.addTab(self.create_scroll_area(self.step8_75_panel), QIcon(self.get_icon_path("8.png")), "自定义回归预测") self.step9_panel = Step9Panel() self.step_stack.addTab(self.create_scroll_area(self.step9_panel), QIcon(self.get_icon_path("10.png")), "专题图生成") self.viz_panel = VisualizationPanel() self.step_stack.addTab(self.create_scroll_area(self.viz_panel), QIcon(self.get_icon_path("9.png")), "可视化") self.report_panel = ReportGenerationPanel(main_window=self) self.step_stack.addTab(self.create_scroll_area(self.report_panel), QIcon(self.get_icon_path("10.png")), "报告生成") # 连接Tab切换信号,实现双向同步(必须在step_stack创建后) self.step_stack.currentChanged.connect(self.on_tab_changed) content_layout.addWidget(self.step_stack, 3) # 日志区域 log_group = QGroupBox("执行日志") log_group.setStyleSheet(f""" QGroupBox {{ background-color: {ModernStylesheet.COLORS['panel_bg']}; border: 1px solid {ModernStylesheet.COLORS['border_light']}; border-radius: 5px; margin-top: 8px; padding-top: 15px; padding-left: 9px; padding-right: 9px; padding-bottom: 9px; }} QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; padding: 0 5px; font-weight: bold; color: {ModernStylesheet.COLORS['text_primary']}; }} """) log_layout = QVBoxLayout() log_layout.setContentsMargins(5, 5, 5, 5) self.log_text = QTextEdit() self.log_text.setReadOnly(True) self.log_text.setMaximumHeight(200) self.log_text.setStyleSheet(f""" QTextEdit {{ background-color: {ModernStylesheet.COLORS['panel_bg']}; color: {ModernStylesheet.COLORS['text_primary']}; border: 1px solid {ModernStylesheet.COLORS['border']}; border-radius: 4px; padding: 5px; font-family: 'Courier New', monospace; font-size: 10px; }} """) log_layout.addWidget(self.log_text) log_btn_layout = QHBoxLayout() clear_log_btn = QPushButton("清空日志") clear_log_btn.setMaximumWidth(100) clear_log_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal')) clear_log_btn.clicked.connect(self.clear_log) log_btn_layout.addWidget(clear_log_btn) log_btn_layout.addStretch() log_layout.addLayout(log_btn_layout) log_group.setLayout(log_layout) content_layout.addWidget(log_group, 1) # 进度条 progress_group = QGroupBox("执行进度") progress_group.setStyleSheet(f""" QGroupBox {{ background-color: {ModernStylesheet.COLORS['panel_bg']}; border: 1px solid {ModernStylesheet.COLORS['border_light']}; border-radius: 5px; margin-top: 8px; padding-top: 10px; padding-left: 9px; padding-right: 9px; padding-bottom: 9px; }} QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; padding: 0 5px; font-weight: bold; color: {ModernStylesheet.COLORS['text_primary']}; }} """) progress_layout = QVBoxLayout() progress_layout.setContentsMargins(5, 5, 5, 5) self.progress_bar = QProgressBar() self.progress_bar.setValue(0) self.progress_bar.setStyleSheet(f""" QProgressBar {{ background-color: {ModernStylesheet.COLORS['panel_bg']}; border: 1px solid {ModernStylesheet.COLORS['border']}; border-radius: 4px; padding: 2px; text-align: center; height: 20px; }} QProgressBar::chunk {{ background-color: {ModernStylesheet.COLORS['success']}; border-radius: 3px; }} """) progress_layout.addWidget(self.progress_bar) progress_group.setLayout(progress_layout) content_layout.addWidget(progress_group, 0) self.content_widget.setLayout(content_layout) # 初始化训练数据模式UI状态 self.update_ui_for_training_mode() # 显示pipeline状态 self.show_pipeline_status_on_startup() def create_scroll_area(self, widget): """创建滚动区域""" scroll = QScrollArea() scroll.setWidget(widget) scroll.setWidgetResizable(True) return scroll def on_step_changed(self, index): """步骤切换 - 处理分层列表结构""" if index < 0: return # 获取选中项 item = self.step_list.item(index) if not item: return # 检查是否是可选中的步骤项 item_data = item.data(Qt.UserRole) if item_data == "stage_header" or item_data is None: # 是阶段标题或分隔符,不切换 return # 根据步骤ID查找对应的tab索引 step_id_to_tab = { 'step1': 0, 'step2': 1, 'step3': 2, 'step4': 3, 'step5': 4, 'step5_5': 5, 'step6': 6, 'step6_5': 7, 'step6_75': 8, 'step7': 9, 'step8': 10, 'step8_5': 11, 'step8_75': 12, 'step9': 13, 'step9_viz': 14, 'step_report': 15, } if item_data in step_id_to_tab: tab_index = step_id_to_tab[item_data] self.step_stack.setCurrentIndex(tab_index) def on_tab_changed(self, index): """Tab页面切换时同步更新左侧步骤列表""" if index < 0: return # Tab索引到步骤ID的反向映射 tab_to_step_id = { 0: 'step1', 1: 'step2', 2: 'step3', 3: 'step4', 4: 'step5', 5: 'step5_5', 6: 'step6', 7: 'step6_5', 8: 'step6_75', 9: 'step7', 10: 'step8', 11: 'step8_5', 12: 'step8_75', 13: 'step9', 14: 'step9_viz', 15: 'step_report', } if index not in tab_to_step_id: return target_step_id = tab_to_step_id[index] # 在step_list中查找对应的步骤项 for row in range(self.step_list.count()): item = self.step_list.item(row) if not item: continue item_data = item.data(Qt.UserRole) if item_data == target_step_id: # 找到对应的步骤项,设置为当前选中 self.step_list.setCurrentRow(row) break def apply_stylesheet(self): """应用样式表 - 应用现代化设计风格""" # 应用主样式表 self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) def new_config(self): """新建配置""" reply = QMessageBox.question( self, "新建配置", "是否清空当前配置?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: # 重置所有面板 self.log_message("已清空配置", "info") def load_config_dialog(self): """加载配置对话框""" file_path, _ = QFileDialog.getOpenFileName( self, "加载配置", "", "JSON Files (*.json);;All Files (*.*)" ) if file_path: self.load_config(file_path) def load_config(self, file_path): """加载配置""" try: with open(file_path, 'r', encoding='utf-8') as f: config = json.load(f) # 应用配置到各面板 if 'step1' in config: self.step1_panel.set_config(config['step1']) if 'step2' in config: self.step2_panel.set_config(config['step2']) if 'step3' in config: self.step3_panel.set_config(config['step3']) if 'step4' in config: self.step4_panel.set_config(config['step4']) if 'step5' in config: self.step5_panel.set_config(config['step5']) if 'step5_5' in config: self.step5_5_panel.set_config(config['step5_5']) if 'step6' in config: self.step6_panel.set_config(config['step6']) if 'step6_5' in config: self.step6_5_panel.set_config(config['step6_5']) if 'step6_75' in config: self.step6_75_panel.set_config(config['step6_75']) if 'step7' in config: self.step7_panel.set_config(config['step7']) if 'step8' in config: self.step8_panel.set_config(config['step8']) if 'step8_5' in config: self.step8_5_panel.set_config(config['step8_5']) if 'step9' in config: self.step9_panel.set_config(config['step9']) if 'visualization' in config: self.viz_panel.set_config(config['visualization']) if 'report_generation' in config: self.report_panel.set_config(config['report_generation']) self.config_file = file_path self.log_message(f"已加载配置: {file_path}", "info") QMessageBox.information(self, "成功", "配置加载成功!") except Exception as e: self.log_message(f"加载配置失败: {str(e)}", "error") QMessageBox.critical(self, "错误", f"加载配置失败:\n{str(e)}") def save_config_dialog(self): """保存配置对话框""" file_path, _ = QFileDialog.getSaveFileName( self, "保存配置", "config.json", "JSON Files (*.json);;All Files (*.*)" ) if file_path: self.save_config(file_path) def save_config(self, file_path): """保存配置""" try: config = self.get_current_config() with open(file_path, 'w', encoding='utf-8') as f: json.dump(config, f, indent=4, ensure_ascii=False) self.config_file = file_path self.log_message(f"已保存配置: {file_path}", "info") QMessageBox.information(self, "成功", "配置保存成功!") except Exception as e: self.log_message(f"保存配置失败: {str(e)}", "error") QMessageBox.critical(self, "错误", f"保存配置失败:\n{str(e)}") def get_current_config(self): """获取当前配置""" config = { 'step1': self.step1_panel.get_config(), 'step2': self.step2_panel.get_config(), 'step3': self.step3_panel.get_config(), 'step4': self.step4_panel.get_config(), 'step5': self.step5_panel.get_config(), 'step5_5': self.step5_5_panel.get_config(), 'step6': self.step6_panel.get_config(), 'step6_5': self.step6_5_panel.get_config(), 'step6_75': self.step6_75_panel.get_config(), 'step7': self.step7_panel.get_config(), 'step8': self.step8_panel.get_config(), 'step8_5': self.step8_5_panel.get_config(), 'step9': self.step9_panel.get_config(), 'visualization': self.viz_panel.get_config(), 'report_generation': self.report_panel.get_config(), } return config def set_work_directory(self): """设置工作目录""" dir_path = QFileDialog.getExistingDirectory(self, "选择工作目录") if dir_path: self.work_dir = dir_path self.log_message(f"工作目录已设置: {dir_path}", "info") self.statusBar().showMessage(f"工作目录: {dir_path}") # 同步到可视化面板 if hasattr(self, 'viz_panel'): self.viz_panel.set_work_dir(dir_path) if hasattr(self, 'report_panel'): self.report_panel.set_work_dir(dir_path) def open_work_directory(self): """打开工作目录""" work_dir = getattr(self, 'work_dir', './work_dir') if os.path.exists(work_dir): os.startfile(work_dir) else: QMessageBox.warning(self, "警告", "工作目录不存在!") def show_pipeline_status_on_startup(self): """启动时显示Pipeline状态""" if not PIPELINE_AVAILABLE: # 如果pipeline不可用,显示警告信息 status_msg = "[WARNING] Pipeline模块无法加载\n\n" status_msg += "系统功能将受到限制,建议检查以下问题:\n" status_msg += "• 项目文件结构是否完整\n" status_msg += "• Python依赖包是否已安装\n" status_msg += "• Python版本是否兼容\n\n" status_msg += "点击 '帮助' → '检查Pipeline状态' 查看详细信息" QMessageBox.warning(self, "Pipeline模块警告", status_msg) else: # 如果pipeline可用,只在状态栏显示 self.statusBar().showMessage("Pipeline模块: 正常加载", 5000) # 显示5秒 def show_pipeline_status(self): """显示Pipeline状态""" status_text = "Pipeline模块状态检查\n\n" if PIPELINE_AVAILABLE: status_text += "[OK] Pipeline模块状态: 正常\n\n" status_text += "详细诊断信息:\n" else: status_text += "[ERROR] Pipeline模块状态: 不可用\n\n" status_text += "详细诊断信息:\n" for info in PIPELINE_ERROR_INFO: status_text += info + "\n" # 添加使用建议 status_text += "\n" + "="*50 + "\n" if PIPELINE_AVAILABLE: status_text += "[SUCCESS] Pipeline模块已成功加载,可以正常使用所有功能!\n" else: status_text += "[WARNING] Pipeline模块无法加载,功能将受到限制\n" status_text += "建议解决方案:\n" status_text += "1. 检查项目文件结构是否完整\n" status_text += "2. 安装所有必需的依赖包\n" status_text += "3. 确认Python版本兼容性\n" status_text += "4. 查看控制台输出获取更多详细信息\n" # 创建消息框 msg_box = QMessageBox(self) msg_box.setWindowTitle("Pipeline状态检查") msg_box.setText(status_text) # 根据状态设置图标 if PIPELINE_AVAILABLE: msg_box.setIcon(QMessageBox.Information) else: msg_box.setIcon(QMessageBox.Warning) # 设置详细文本(如果有的话) if PIPELINE_ERROR_INFO: detailed_text = "\n".join(PIPELINE_ERROR_INFO) msg_box.setDetailedText(detailed_text) msg_box.setStandardButtons(QMessageBox.Ok) msg_box.exec_() def show_about(self): """显示关于对话框""" QMessageBox.about( self, "关于", "水质参数反演分析系统 v1.0\n\n" "一个完整的水质参数反演工作流程工具\n\n" "功能包括:\n" "- 水域掩膜生成\n" "- 耀斑检测与去除\n" "- 光谱提取\n" "- 机器学习建模\n" "- 水质参数预测\n" "- 可视化分析\n\n" "公司:北京依锐思遥感技术有限公司\n" "地址:北京市海淀区清河安宁庄东路18号5号楼二层205\n" "电话:010-51292601\n" "邮箱:hanshanlong@iris-rs.cn\n" ) def run_full_pipeline(self): """运行完整流程""" if not PIPELINE_AVAILABLE: QMessageBox.critical( self, "错误", "无法导入pipeline模块,请确保water_quality_inversion_pipeline_GUI.py文件存在!" ) return # 验证配置 config = self.get_current_config() # 基本验证 if not config['step1'].get('mask_path'): QMessageBox.warning(self, "警告", "请先配置步骤1的掩膜文件!") # 找到第一个可选的步骤项 for i in range(self.step_list.count()): item = self.step_list.item(i) if item.data(Qt.UserRole) == 'step1': self.step_list.setCurrentRow(i) break return # 确认执行 reply = QMessageBox.question( self, "确认", "是否开始执行完整流程?\n\n这可能需要较长时间,请确保配置正确。", QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: return # 创建pipeline实例 work_dir = getattr(self, 'work_dir', './work_dir') self.log_message(f"初始化pipeline,工作目录: {work_dir}", "info") # 准备实际运行配置(排除未启用的步骤) worker_config = copy.deepcopy(config) step5_5_cfg = worker_config.get('step5_5') if step5_5_cfg: enabled = step5_5_cfg.pop('enabled', True) if not enabled: worker_config.pop('step5_5', None) # 工作线程内创建 Pipeline,避免主线程阻塞及 Qt5Agg 子线程绘图卡死 self.worker = WorkerThread(work_dir, worker_config, mode='full') self.worker.log_message.connect(self.log_message, Qt.QueuedConnection) self.worker.progress_update.connect(self.update_progress, Qt.QueuedConnection) self.worker.finished.connect(self.on_pipeline_finished, Qt.QueuedConnection) # 更新UI状态 self.run_all_btn.setEnabled(False) self.stop_btn.setEnabled(True) self.progress_bar.setValue(0) # 启动线程 self.worker.start() self.log_message("="*50, "info") self.log_message("开始执行完整流程...", "info") self.log_message("="*50, "info") def stop_pipeline(self): """停止流程""" if self.worker and self.worker.isRunning(): reply = QMessageBox.question( self, "确认", "是否停止当前流程?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: self.worker.stop() self.log_message("用户取消执行", "warning") self.run_all_btn.setEnabled(True) self.stop_btn.setEnabled(False) def on_pipeline_finished(self, success, message): """流程完成""" self.run_all_btn.setEnabled(True) self.stop_btn.setEnabled(False) if success: self.progress_bar.setValue(100) self.log_message("="*50, "info") self.log_message("流程执行完成!", "info") self.log_message("="*50, "info") QMessageBox.information(self, "完成", "流程执行成功!\n\n请查看工作目录中的结果文件。") else: self.log_message("="*50, "error") self.log_message(f"流程执行失败: {message}", "error") self.log_message("="*50, "error") QMessageBox.critical(self, "失败", f"流程执行失败:\n\n{message[:200]}") def run_single_step(self, step_name, config): """运行单个步骤""" if not PIPELINE_AVAILABLE: QMessageBox.critical( self, "错误", "无法导入pipeline模块,请确保water_quality_inversion_pipeline_GUI.py文件存在!" ) return # 创建pipeline实例 work_dir = getattr(self, 'work_dir', './work_dir') self.log_message(f"初始化pipeline,工作目录: {work_dir}", "info") self.worker = WorkerThread(work_dir, config, mode='single_step', step_name=step_name) self.worker.log_message.connect(self.log_message, Qt.QueuedConnection) self.worker.progress_update.connect(self.update_progress, Qt.QueuedConnection) self.worker.finished.connect(self.on_pipeline_finished, Qt.QueuedConnection) # 更新UI状态 self.run_all_btn.setEnabled(False) self.stop_btn.setEnabled(True) self.progress_bar.setValue(0) # 启动线程 self.worker.start() self.log_message("="*50, "info") self.log_message(f"开始独立运行步骤 {step_name}...", "info") self.log_message("="*50, "info") def update_progress(self, percentage, message): """更新进度""" self.progress_bar.setValue(percentage) self.statusBar().showMessage(message) def log_message(self, message, level='info'): """记录日志""" timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') # 设置颜色 if level == 'error': color = 'red' elif level == 'warning': color = 'orange' else: color = 'black' # 添加到日志 formatted_msg = f'[{timestamp}] {message}' self.log_text.append(formatted_msg) # 自动滚动到底部 cursor = self.log_text.textCursor() cursor.movePosition(QTextCursor.End) self.log_text.setTextCursor(cursor) def clear_log(self): """清空日志""" self.log_text.clear() self.log_message("日志已清空", "info") def toggle_training_data_mode(self, checked): """切换训练数据模式""" self.has_training_data = checked self.update_ui_for_training_mode() mode_text = "有训练数据" if checked else "无训练数据" self.log_message(f"切换到{mode_text}模式", "info") self.statusBar().showMessage(f"当前模式: {mode_text}") # 更新按钮文本 self.training_mode_action.setText("有训练数据模式" if checked else "无训练数据模式") def update_banner_image(self): """更新横幅图片 - 完全跟随窗口等比缩放,填满可用宽度""" if not hasattr(self, 'banner_pixmap') or self.banner_pixmap.isNull(): return # 获取可用宽度(考虑工具栏边距),跟随窗口实时变化 available_width = max(200, self.width() - 60) # 先根据可用宽度计算目标高度(严格 5.25:1) target_height = int(available_width / 5.25) # 限制最小高度 if target_height < 38: target_height = 38 available_width = int(38 * 5.25) # 计算图片目标尺寸(保持 5.25:1 比例) target_width = available_width # 设置固定尺寸,确保标签严格填满整个区域 self.banner_label.setFixedSize(target_width, target_height) # 等比缩放到目标尺寸,填满整个区域(允许轻微裁剪) scaled_pixmap = self.banner_pixmap.scaled( target_width, target_height, Qt.KeepAspectRatioByExpanding, # 保持比例,填满区域,允许裁剪超出部分 Qt.SmoothTransformation ) self.banner_label.setPixmap(scaled_pixmap) def resizeEvent(self, event): """窗口大小改变事件 - 实时更新横幅图片等比缩放""" super().resizeEvent(event) # 直接调用,不使用定时器延迟(或缩短到 10ms) self.update_banner_image() def update_ui_for_training_mode(self): """根据训练数据模式更新UI状态""" # 需要禁用的步骤ID(对应无训练数据模式下需要禁用的步骤) disabled_step_ids = ['step4', 'step5', 'step5_5', 'step6', 'step6_5', 'step6_75'] # 更新标签页的启用/禁用状态 step_id_to_tab = { 'step1': 0, 'step2': 1, 'step3': 2, 'step4': 3, 'step5': 4, 'step5_5': 5, 'step6': 6, 'step6_5': 7, 'step6_75': 8, 'step7': 9, 'step8': 10, 'step8_5': 11, 'step8_75': 12, 'step9': 13, 'step9_viz': 14 } for step_id in disabled_step_ids: if step_id in step_id_to_tab: tab_index = step_id_to_tab[step_id] if tab_index < self.step_stack.count(): self.step_stack.setTabEnabled(tab_index, self.has_training_data) # 同时更新导航列表的启用状态 for i in range(self.step_list.count()): item = self.step_list.item(i) item_data = item.data(Qt.UserRole) # 跳过阶段标题和分隔符 if item_data == "stage_header" or item_data is None: continue # 检查步骤是否在禁用列表中 if item_data in disabled_step_ids: if not self.has_training_data: item.setFlags(item.flags() & ~Qt.ItemIsEnabled) item.setForeground(QColor(128, 128, 128)) # 灰色 else: item.setFlags(item.flags() | Qt.ItemIsEnabled) item.setForeground(QColor(ModernStylesheet.COLORS.get('text_secondary', '#666666'))) # 原始颜色 def main(): """主函数""" app = QApplication(sys.argv) # 设置应用信息 app.setApplicationName("水质参数反演分析系统") app.setOrganizationName("WaterQuality") # 创建主窗口 window = WaterQualityGUI() window.show() sys.exit(app.exec_()) if __name__ == "__main__": #冻结,只显示1个exe # multiprocessing.freeze_support() main()