diff --git a/pyproject.toml b/pyproject.toml index 786db37..bfef830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,8 +67,7 @@ docs = [ ] [project.scripts] -water-quality-gui = "gui.water_quality_gui:main" -water-quality-pipeline = "core.water_quality_inversion_pipeline:main" +water-quality-gui = "src.gui.water_quality_gui_v2:main" [project.urls] Homepage = "https://github.com/waterquality/water-quality-inversion" diff --git a/scripts/build.bat b/scripts/build.bat index e982d00..e67c158 100644 --- a/scripts/build.bat +++ b/scripts/build.bat @@ -2,12 +2,12 @@ chcp 65001 >nul echo. echo ================================================ -echo 水质参数反演分析系统 - 打包工具 +echo 水质参数反演分析系统 - 打包工具 (V2) echo ================================================ echo. -:: 检查是否在正确目录 -if not exist "src\gui\water_quality_gui.py" ( +:: 检查是否在正确目录 (V2 入口) +if not exist "src\gui\water_quality_gui_v2.py" ( echo [错误] 请在项目根目录下运行此脚本! pause exit /b 1 @@ -22,19 +22,14 @@ python -m pip install -r requirements.txt --quiet python -m pip install pyinstaller --quiet echo [3/4] 开始打包(首次可能需要 5-15 分钟,请耐心等待)... -pyinstaller --clean scripts/water_quality_gui.spec - -echo. -echo [打包提示] 如果仍然出现 "No module named 'styles'",请检查: -echo 1. dist\water_quality_gui\styles.py 是否存在 -echo 2. 是否需要添加 --collect-all styles 参数 +pyinstaller --clean water_quality_app.spec echo. echo [4/4] 打包完成! echo. echo 输出位置: -echo dist\water_quality_gui\water_quality_gui.exe -echo dist\water_quality_gui\_internal\ +echo dist\MegaWater\MegaWater.exe +echo dist\MegaWater\_internal\ echo. echo 建议: echo 1. 将 dist 文件夹整个复制给用户(包含所有依赖) diff --git a/setup.py b/setup.py index dc7533d..9c097ea 100644 --- a/setup.py +++ b/setup.py @@ -68,7 +68,7 @@ setup( }, entry_points={ "console_scripts": [ - "water-quality-gui=gui.water_quality_gui:main", + "water-quality-gui=src.gui.water_quality_gui_v2:main", "water-quality-pipeline=core.water_quality_inversion_pipeline:main", ], }, diff --git a/src/gui/core/panel_factory.py b/src/gui/core/panel_factory.py index eefc711..fa93ef0 100644 --- a/src/gui/core/panel_factory.py +++ b/src/gui/core/panel_factory.py @@ -266,6 +266,13 @@ class PanelFactory: # ★ 重新禁用滚轮:update_from_config 可能动态创建了新的 SpinBox/ComboBox _disable_wheel_for_widget(panel) + def replay_live_panel_inputs(self): + """公开方法:重播所有已加载面板的输入值(供主窗口切页时调用)。 + + 内部委托给 _replay_live_panel_inputs。 + """ + self._replay_live_panel_inputs() + def _replay_live_panel_inputs(self): """遍历 PANEL_REGISTRY 依赖声明,从已加载面板实时读取属性值并强制广播。 diff --git a/src/gui/water_quality_gui.py b/src/gui/water_quality_gui.py deleted file mode 100644 index a9f5ab7..0000000 --- a/src/gui/water_quality_gui.py +++ /dev/null @@ -1,1598 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -水质参数反演分析系统 - 图形用户界面 -GUI for Water Quality Inversion Pipeline -""" - -# ============================================================================== -# 🚀 终极防御:必须在全宇宙第一行强制载入 GDAL 底层 DLL,绝对杜绝 0xC0000005 内存崩溃 -# ============================================================================== -import osgeo -from osgeo import gdal, ogr - -import os -import json -import copy -import sys -import traceback -from pathlib import Path -from datetime import datetime -from typing import Dict, Optional, List, Union -import numpy as np -import pandas as pd -import multiprocessing -from PyQt5.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QPushButton, QLabel, QLineEdit, QComboBox, QCheckBox, QSpinBox, - QDoubleSpinBox, QFileDialog, QTextEdit, QProgressBar, QMessageBox, - QScrollArea, QGroupBox, QTabWidget, QSplitter, QListWidget, - QListWidgetItem, QFrame, QGridLayout, QFormLayout, QSizePolicy, QDialog, - QStackedWidget, QTableView, QHeaderView, QAbstractItemView, - QRadioButton, QButtonGroup, QToolBar, QTreeWidget, QTreeWidgetItem, - QInputDialog, -) -from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer, QAbstractTableModel, QSize -from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPalette, QColor, QPixmap - -import sys -import traceback -import multiprocessing -import ctypes - -# ============================================================================== -# 🚀 终极防御置顶:在载入任何自定义面板、样式或子模块之前,强制提前创建 QApplication! -# 彻底杜绝 import 时期载入类属性 (如 QFont/QIcon/QPixmap) 触发的 QWidget 崩溃 -# ============================================================================== -if multiprocessing.current_process().name == 'MainProcess': - if not QApplication.instance(): - try: - QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) - QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) - except Exception: - pass - _global_app = QApplication(sys.argv) - -# 👇 全局异常钩子(保持不变) -def get_resource_path(relative_path: str) -> str: - """获取资源的绝对路径,适配 PyInstaller 打包环境。 - 打包后资源位于 sys._MEIPASS(解压临时目录),开发环境则基于 __file__ 向上三级。 - """ - if hasattr(sys, '_MEIPASS'): - return os.path.join(sys._MEIPASS, relative_path) - return os.path.abspath( - os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), relative_path) - ) - - -def global_exception_handler(exc_type, exc_value, exc_traceback): - err_lines = traceback.format_exception(exc_type, exc_value, exc_traceback) - err_msg = "".join(err_lines) - dump_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "crash_dump.txt") - try: - with open(dump_path, "a", encoding="utf-8") as f: - f.write(f"\n{'='*60}\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]\n") - f.write(err_msg) - except Exception: - pass - try: - from PyQt5.QtWidgets import QMessageBox - from PyQt5.QtCore import Qt - msg = ( - "【严重错误 - 程序即将退出】\n\n" - "错误类型: {}\n\n" - "错误信息: {}\n\n" - "详细信息已写入:\n{}".format( - exc_type.__name__, - str(exc_value), - dump_path, - ) - ) - QMessageBox.critical(None, "程序崩溃", msg) - except Exception: - pass - -# 挂载全局异常钩子,阻止 PyQt 静默闪退 -sys.excepthook = global_exception_handler - -# 导入样式模块 - 兼容开发环境和 PyInstaller 打包 -try: - from styles import ModernStylesheet -except ImportError: - try: - from src.gui.styles import ModernStylesheet - except ImportError: - import sys - import os - current_dir = os.path.dirname(os.path.abspath(__file__)) - project_root = os.path.abspath(os.path.join(current_dir, '..', '..')) - if project_root not in sys.path: - sys.path.insert(0, project_root) - from src.gui.styles import ModernStylesheet - -# 导入自定义组件 -from src.gui.components.custom_widgets import FileSelectWidget -from src.gui.components.chart_dialogs import ChartViewerDialog, ChartBrowserDialog, InteractiveViewerDialog, PandasTableModel -from src.gui.components.image_viewer_components import ImageCategoryTree, ImageViewerWidget - -# 导入面板注册中心(数据驱动,替代硬编码面板导入) -from src.gui.core.panel_registry import ( - PANEL_REGISTRY, - build_step_dependencies, - build_stage_groups, - get_tab_index, - get_step_id_by_tab_index, - get_entry, -) -from src.gui.core.event_bus import global_event_bus -from src.gui.core.dependency_subscriber import subscribe_panel_to_dependencies -from src.gui.dialogs import BandConfirmDialog, AISettingsDialog - -# Pipeline 核心异常(用于预检弹窗) -from src.core.pipeline.runner import PipelineHalt - -# Matplotlib相关导入 (推迟并加入底层防爆保护) -import matplotlib -try: - # 确保只在主线程且安全的环境下绑定后端 - matplotlib.use('Qt5Agg', force=False) -except Exception: - pass -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar -from matplotlib.figure import Figure -import matplotlib.pyplot as plt - -# 后台线程与 Pipeline 状态(由 core/worker_thread.py 提供) -from src.gui.core.worker_thread import ( - WorkerThread, - PIPELINE_AVAILABLE, - PIPELINE_ERROR_INFO, - check_pipeline_dependencies, - diagnose_pipeline_import_error, -) -# 预检交互对话框 -from src.gui.core.preflight_dialog import PreflightDialog -from src.gui.core.pipeline_mode_dialog import PipelineModeDialog -from src.gui.core.viz_thread import VisualizationWorkerThread, _viz_training_spectra_csv_path -from src.core.workspace_manager import WorkspaceManager - -class WaterQualityGUI(QMainWindow): - """水质参数反演分析系统主窗口""" - - def __init__(self): - # 1. 🚀 强制设置任务栏图标(解决任务栏图标默认是 Python 黄蓝图标的问题) - # 为当前进程设置独立的 AppUserModelID - my_appid = u'mycompany.megacube.waterquality.v1' - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_appid) - - super().__init__() - - # 2. 设置窗口图标(指向你的 .ico 文件) - icon_path = get_resource_path("data/icons-1/uitubiao.ico") - self.setWindowIcon(QIcon(icon_path)) - - self.pipeline = None - self.worker = None - self.config_file = None - self.work_dir = None # 工作目录 - - # 训练数据模式状态 - self.has_training_data = True # 默认有训练数据 - - # 工作空间管理器(文件扫描、路径发现、配置裁剪) - self.workspace_manager = WorkspaceManager() - - # 面板实例字典(step_id → panel instance),由 create_content_area 填充 - self._panels = {} - - # 从注册表构建步骤依赖关系 - self.step_dependencies = build_step_dependencies() - - self.init_ui() - self.apply_stylesheet() - self._disable_wheel_for_all_spinboxes() - - # 延迟调用工作目录选择对话框,确保主界面已完全渲染 - # 100ms 延迟足以让 GUI 事件循环启动并显示主窗口 - QTimer.singleShot(100, self.init_workspace) - - def get_icon_path(self, icon_filename): - """获取图标文件的完整路径(统一使用 get_resource_path)。""" - return get_resource_path(f"data/icons/{icon_filename}") - - def _disable_wheel_for_all_spinboxes(self): - """ - 遍历所有子控件,为 QSpinBox/QDoubleSpinBox/QComboBox 禁用滚轮事件 - 防止滚动页面时意外改变数值 - """ - from PyQt5.QtCore import Qt - - # 找到所有数值输入控件 - for spinbox in self.findChildren(QSpinBox): - spinbox.setFocusPolicy(Qt.StrongFocus) # 只有聚焦时才响应滚轮 - spinbox.wheelEvent = lambda event, sb=spinbox: None # 完全禁用滚轮 - - for spinbox in self.findChildren(QDoubleSpinBox): - spinbox.setFocusPolicy(Qt.StrongFocus) - spinbox.wheelEvent = lambda event, sb=spinbox: None - - for combobox in self.findChildren(QComboBox): - combobox.setFocusPolicy(Qt.StrongFocus) - combobox.wheelEvent = lambda event, cb=combobox: None - - def init_workspace(self): - """ - 初始化工作空间:弹出对话框选择工作目录 - 此方法通过 QTimer 延迟调用,确保主界面已完全渲染后再弹出对话框 - 如果用户取消或关闭,则退出程序 - """ - from PyQt5.QtWidgets import QMessageBox - - msg_box = QMessageBox() - msg_box.setIcon(QMessageBox.Information) - msg_box.setWindowTitle("选择工作目录") - msg_box.setText("欢迎使用Mega Water!\n\n请选择工作目录来保存所有分析结果。") - msg_box.setInformativeText("工作目录将用于存储:\n• 水域掩膜文件\n• 耀斑检测结果\n• 模型训练数据\n• 预测结果与分布图\n\n点击'确定'选择目录") - msg_box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) - msg_box.setDefaultButton(QMessageBox.Ok) - - result = msg_box.exec_() - - if result == QMessageBox.Cancel: - QMessageBox.warning(None, "取消操作", "未选择工作目录,程序将退出。") - sys.exit(0) - - # 弹出目录选择对话框 - work_dir = QFileDialog.getExistingDirectory( - self, # 使用 self 作为父窗口,而不是 None - "选择工作目录", - "", - QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks - ) - - if not work_dir: - QMessageBox.critical(self, "错误", "必须选择工作目录才能使用系统!\n程序即将退出。") - sys.exit(0) - - self.work_dir = work_dir - print(f"✓ 已选择工作目录: {self.work_dir}") - - # 选择完成后,自动填充输出路径 - self._auto_fill_output_paths() - - def _auto_fill_output_paths(self): - """ - 根据工作目录自动填充各步骤的输出路径 - 注意:Step1 的输出路径由 update_work_directory() 根据模式自动控制 - """ - if not self.work_dir: - return - - # Step1: 只传递工作目录引用,不直接填充路径 - # 路径填充由 Step1Panel 根据单选按钮状态自动控制 - step1_panel = self._panels.get('step1') - if step1_panel: - step1_panel.update_work_directory(self.work_dir) - - def init_ui(self): - """初始化UI""" - self.setWindowTitle("MegaCube-Water Quality ‌V1.2") - - # 获取屏幕可用区域(排除任务栏) - screen_geometry = QApplication.primaryScreen().availableGeometry() - screen_width = screen_geometry.width() - screen_height = screen_geometry.height() - - # 初始尺寸:宽度固定 800,高度占满屏幕 - window_width = 1200 - window_height = screen_height - # 仅设置初始大小,不锁定 - self.resize(window_width, window_height) - - # 计算水平居中、垂直贴顶的位置 - x = (screen_width - window_width) // 2 - y = 0 - self.move(x, y) - - # 可选:设置最小尺寸,防止用户缩得太小 - self.setMinimumSize(600, 400) - - # 创建自定义标题栏(包含Logo和菜单栏) - self.create_title_bar() - - # 创建横幅区域 - self.create_banner_widget() - - # 创建中央部件 - central_widget = QWidget() - self.setCentralWidget(central_widget) - - # 主布局 - main_layout = QHBoxLayout() - - # 创建左侧导航栏 - self.create_navigation() - main_layout.addWidget(self.nav_widget, 1) - - # 创建右侧内容区 - self.create_content_area() - main_layout.addWidget(self.content_widget, 4) - - central_widget.setLayout(main_layout) - - # 创建状态栏 - self.statusBar().showMessage("就绪") - - def create_title_bar(self): - """创建自定义标题栏(Logo和菜单栏挨着且同等宽度)""" - # 创建标题栏容器 - title_widget = QWidget() - title_layout = QHBoxLayout() - title_layout.setContentsMargins(8, 4, 8, 4) - title_layout.setSpacing(0) # 让Logo和菜单栏紧挨着 - - # Logo部分(左侧,增加宽度) - logo_label = QLabel() - logo_label.setFixedSize(180, 48) # 增加Logo宽度,使其和菜单栏视觉平衡 - logo_label.setAlignment(Qt.AlignCenter) - logo_label.setStyleSheet(""" - QLabel { - background-color: #f8f9fa; - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - } - """) - - # 设置Logo图片路径 - logo_path = get_resource_path("data/icons/logo.png") - logo_pixmap = QPixmap(logo_path) - - if not logo_pixmap.isNull(): - # 按高度缩放图片,保持宽高比,让Logo更显眼 - scaled_pixmap = logo_pixmap.scaledToHeight(38, Qt.SmoothTransformation) - logo_label.setPixmap(scaled_pixmap) - else: - # 如果图片加载失败,显示占位符 - logo_label.setText("Logo") - logo_label.setStyleSheet(""" - QLabel { - background-color: #f8f9fa; - color: #333; - font-size: 14px; - font-weight: bold; - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - } - """) - - title_layout.addWidget(logo_label) - - # 菜单栏(紧挨着Logo右侧) - menubar = self.menuBar() - menubar.setStyleSheet(""" - QMenuBar { - background-color: #f8f9fa; - border: none; - padding: 4px 8px; - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - } - QMenuBar::item { - padding: 6px 12px; - background-color: transparent; - font-size: 13px; - } - QMenuBar::item:selected { - background-color: #e6f0ff; - border-radius: 3px; - } - """) - - # 文件菜单 - file_menu = menubar.addMenu("文件") - - new_action = file_menu.addAction("新建配置") - new_action.triggered.connect(self.new_config) - - open_action = file_menu.addAction("打开配置") - open_action.triggered.connect(self.load_config_dialog) - - save_action = file_menu.addAction("保存配置") - save_action.triggered.connect(self.save_config_dialog) - - file_menu.addSeparator() - - exit_action = file_menu.addAction("退出") - exit_action.triggered.connect(self.close) - - # 工具菜单 - tools_menu = menubar.addMenu("工具") - - work_dir_action = tools_menu.addAction("设置工作目录") - work_dir_action.triggered.connect(self.set_work_directory) - - open_dir_action = tools_menu.addAction("打开工作目录") - open_dir_action.triggered.connect(self.open_work_directory) - - tools_menu.addSeparator() - - ai_config_action = tools_menu.addAction("AI 引擎配置...") - ai_config_action.triggered.connect(self._show_ai_settings) - - tools_menu.addSeparator() - - # 添加自动填充功能 - auto_fill_action = tools_menu.addAction("自动填充所有输入路径") - auto_fill_action.triggered.connect(self.auto_populate_all_steps) - auto_fill_action.setToolTip("根据工作目录中的文件自动填充各步骤的输入路径") - - # 在工具菜单中添加训练数据模式切换按钮 - self.training_mode_action = tools_menu.addAction("有训练数据模式") - self.training_mode_action.setCheckable(True) - self.training_mode_action.setChecked(True) # 默认有训练数据模式 - self.training_mode_action.triggered.connect(self.toggle_training_data_mode) - - # 帮助菜单 - help_menu = menubar.addMenu("帮助") - - pipeline_status_action = help_menu.addAction("检查Pipeline状态") - pipeline_status_action.triggered.connect(self.show_pipeline_status) - - help_menu.addSeparator() - - about_action = help_menu.addAction("关于") - about_action.triggered.connect(self.show_about) - - title_layout.addWidget(menubar) - title_widget.setLayout(title_layout) - - # 设置整体标题栏样式 - title_widget.setStyleSheet(""" - QWidget { - background-color: #f8f9fa; - border-bottom: 1px solid #d0d0d0; - } - """) - - # 将标题栏添加到窗口顶部 - self.setMenuWidget(title_widget) - - - def create_banner_widget(self): - """创建横幅区域 - 支持自适应等比缩放""" - # 横幅标题文字(方便后续直接修改版本号) - self._APP_TITLE = "MegaCube-Water Quality ‌V1.2" - - # 创建横幅容器 - banner_widget = QWidget() - banner_layout = QHBoxLayout() - banner_layout.setContentsMargins(0, 0, 0, 0) - banner_layout.setSpacing(0) - - # ===== 底图层 ===== - self.banner_label = QLabel() - self.banner_label.setMinimumHeight(140) - self.banner_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.banner_label.setScaledContents(False) - self.banner_label.setStyleSheet("margin: 0px; padding: 0px; border: none;") - - # 强制 banner_widget 展开填充 toolbar 全部宽度(清除 addWidget 的默认居中行为) - banner_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - banner_widget.setMinimumWidth(0) # 确保可以被 layout 压缩/扩展到任意宽度 - banner_widget.setStyleSheet("margin: 0px; padding: 0px; border: none;") - - # 纯净底图路径(无水印文字) - banner_path = get_resource_path("data/icons/Mega Water 1.0.jpg") - self.banner_pixmap = QPixmap(banner_path) - - if not self.banner_pixmap.isNull(): - QTimer.singleShot(50, self.update_banner_image) - else: - self.banner_label.setText("背景图加载失败") - self.banner_label.setStyleSheet(""" - QLabel { - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 #0078d4, stop:1 #00a0e9); - color: white; font-size: 26px; font-weight: bold; - border-bottom: 3px solid #005a9e; - } - """) - - banner_layout.addWidget(self.banner_label) - - # ===== 文字叠加层 ===== - # 注意这里:第二个参数改成了 self.banner_label,直接附着在底图上! - self.banner_title_label = QLabel(self._APP_TITLE, self.banner_label) - self.banner_title_label.setStyleSheet(""" - QLabel { - background: transparent; - color: white; - font-size: 48px; /* 显著增大字号 */ - font-weight: normal; /* 取消粗体,还原原图的优雅感 */ - font-family: "Times New Roman", "Georgia", "STZhongsong", serif; /* 衬线字体家族 */ - letter-spacing: 1px; - } - """) - self.banner_title_label.setAttribute(Qt.WA_TransparentForMouseEvents) # 鼠标穿透 - self.banner_title_label.show() # 第一道保险:强制现身 - self.banner_title_label.raise_() # 第二道保险:强制图层置顶 - - banner_widget.setLayout(banner_layout) - - # 将横幅添加到窗口顶部(在标题栏下方) - banner_toolbar = QToolBar() - banner_toolbar.setMovable(False) - banner_toolbar.setFloatable(False) - banner_toolbar.addWidget(banner_widget) - banner_toolbar.setContentsMargins(0, 0, 0, 0) # 清除工具栏布局的边距 - banner_toolbar.setStyleSheet(""" - QToolBar { - background-color: white; - border: none; - border-bottom: 1px solid #ddd; - padding: 0px; - margin: 0px; - spacing: 0px; - } - QToolBar QWidget { - margin: 0px; - padding: 0px; - } - QToolBar > QWidget { - margin: 0px; - padding: 0px; - } - """) - - self.addToolBar(Qt.TopToolBarArea, banner_toolbar) - self.banner_widget = banner_toolbar - - def create_navigation(self): - """创建左侧导航栏""" - self.nav_widget = QWidget() - nav_layout = QVBoxLayout() - nav_layout.setContentsMargins(10, 15, 10, 15) - nav_layout.setSpacing(10) - - # 标题 - title = QLabel("流程步骤") - title.setFont(QFont("Arial", 13, QFont.Bold)) - title.setAlignment(Qt.AlignCenter) - title.setStyleSheet(f"color: {ModernStylesheet.COLORS['text_primary']}; padding: 10px;") - nav_layout.addWidget(title) - - # 步骤列表 - 分层结构 - self.step_list = QListWidget() - self.step_list.setStyleSheet(ModernStylesheet.get_sidebar_stylesheet()) - - # 从注册表动态构建阶段分组 - self.process_stages = build_stage_groups() - - # 存储步骤映射 - self.step_name_map = {} - - # 添加分组项到列表 - stage_names = list(self.process_stages.keys()) - 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_font.setBold(True) - stage_item.setFont(stage_font) - stage_item.setForeground(QColor("#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(stage_names) - 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()) - - # 添加各步骤面板(从注册表动态实例化) - for entry in PANEL_REGISTRY: - step_id = entry['step_id'] - cls = entry['class_ref'] - title = entry['title'] - icon_name = entry['icon'] - kwargs = entry.get('constructor_kwargs') - - # 处理特殊构造参数(如 Step13ReportPanel 需要 main_window=self) - if kwargs: - resolved_kwargs = {} - for k in kwargs: - if k == 'main_window': - resolved_kwargs[k] = self - panel = cls(**resolved_kwargs) - else: - panel = cls() - - self._panels[step_id] = panel - self.step_stack.addTab( - self.create_scroll_area(panel), - QIcon(self.get_icon_path(icon_name)), - title - ) - - # ★ 事件驱动:面板根据注册表依赖自动订阅 OutputUpdated 事件 - deps = entry.get('dependencies') - if deps: - subscribe_panel_to_dependencies(panel, step_id, deps) - - # 连接Tab切换信号,实现双向同步(必须在step_stack创建后) - self.step_stack.currentChanged.connect(self.on_tab_changed) - - content_layout.addWidget(self.step_stack, 3) - - # 日志区域 - log_group = QGroupBox("执行日志") - log_group.setStyleSheet(f""" - QGroupBox {{ - background-color: {ModernStylesheet.COLORS['panel_bg']}; - border: 1px solid {ModernStylesheet.COLORS['border_light']}; - border-radius: 5px; - margin-top: 8px; - padding-top: 15px; - padding-left: 9px; - padding-right: 9px; - padding-bottom: 9px; - }} - QGroupBox::title {{ - subcontrol-origin: margin; - subcontrol-position: top left; - padding: 0 5px; - font-weight: bold; - color: {ModernStylesheet.COLORS['text_primary']}; - }} - """) - log_layout = QVBoxLayout() - log_layout.setContentsMargins(5, 5, 5, 5) - - self.log_text = QTextEdit() - self.log_text.setReadOnly(True) - self.log_text.setMaximumHeight(200) - self.log_text.setStyleSheet(f""" - QTextEdit {{ - background-color: {ModernStylesheet.COLORS['panel_bg']}; - color: {ModernStylesheet.COLORS['text_primary']}; - border: 1px solid {ModernStylesheet.COLORS['border']}; - border-radius: 4px; - padding: 5px; - font-family: 'Courier New', monospace; - font-size: 10px; - }} - """) - log_layout.addWidget(self.log_text) - - log_btn_layout = QHBoxLayout() - clear_log_btn = QPushButton("清空日志") - clear_log_btn.setMaximumWidth(100) - clear_log_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal')) - clear_log_btn.clicked.connect(self.clear_log) - log_btn_layout.addWidget(clear_log_btn) - log_btn_layout.addStretch() - log_layout.addLayout(log_btn_layout) - - log_group.setLayout(log_layout) - content_layout.addWidget(log_group, 1) - - # 进度条 - progress_group = QGroupBox("执行进度") - progress_group.setStyleSheet(f""" - QGroupBox {{ - background-color: {ModernStylesheet.COLORS['panel_bg']}; - border: 1px solid {ModernStylesheet.COLORS['border_light']}; - border-radius: 5px; - margin-top: 8px; - padding-top: 10px; - padding-left: 9px; - padding-right: 9px; - padding-bottom: 9px; - }} - QGroupBox::title {{ - subcontrol-origin: margin; - subcontrol-position: top left; - padding: 0 5px; - font-weight: bold; - color: {ModernStylesheet.COLORS['text_primary']}; - }} - """) - progress_layout = QVBoxLayout() - progress_layout.setContentsMargins(5, 5, 5, 5) - - self.progress_bar = QProgressBar() - self.progress_bar.setValue(0) - self.progress_bar.setStyleSheet(f""" - QProgressBar {{ - background-color: {ModernStylesheet.COLORS['panel_bg']}; - border: 1px solid {ModernStylesheet.COLORS['border']}; - border-radius: 4px; - padding: 2px; - text-align: center; - height: 20px; - }} - QProgressBar::chunk {{ - background-color: {ModernStylesheet.COLORS['success']}; - border-radius: 3px; - }} - """) - progress_layout.addWidget(self.progress_bar) - progress_group.setLayout(progress_layout) - content_layout.addWidget(progress_group, 0) - - self.content_widget.setLayout(content_layout) - - # 初始化训练数据模式UI状态 - self.update_ui_for_training_mode() - - # 为步骤面板添加自动填充功能 - self.add_auto_fill_buttons_to_panels() - - # 显示pipeline状态 - self.show_pipeline_status_on_startup() - - def create_scroll_area(self, widget): - """创建滚动区域""" - scroll = QScrollArea() - scroll.setWidget(widget) - scroll.setWidgetResizable(True) - return scroll - - def on_step_changed(self, index): - """步骤切换 - 处理分层列表结构""" - if index < 0: - return - - # 获取选中项 - item = self.step_list.item(index) - if not item: - return - - # 检查是否是可选中的步骤项 - item_data = item.data(Qt.UserRole) - if item_data == "stage_header" or item_data is None: - # 是阶段标题或分隔符,不切换 - return - - # 根据步骤ID查找对应的tab索引(从注册表动态映射) - tab_index = get_tab_index(item_data) - if tab_index >= 0: - self.step_stack.setCurrentIndex(tab_index) - - def on_tab_changed(self, index): - """Tab页面切换时同步更新左侧步骤列表""" - if index < 0: - return - - # Tab索引到步骤ID的反向映射(从注册表动态获取) - target_step_id = get_step_id_by_tab_index(index) - if target_step_id is None: - return - - # 在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 - - # 面板自动填充:从注册表获取面板实例 - panel = self._panels.get(target_step_id) - if panel and hasattr(panel, 'update_from_config'): - panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) - - def apply_stylesheet(self): - """应用样式表 - 应用现代化设计风格""" - # 应用主样式表 - self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) - - def new_config(self): - """新建配置""" - reply = QMessageBox.question( - self, "新建配置", - "是否清空当前配置?", - QMessageBox.Yes | QMessageBox.No - ) - if reply == QMessageBox.Yes: - # 重置所有面板 - self.log_message("已清空配置", "info") - - def load_config_dialog(self): - """加载配置对话框""" - file_path, _ = QFileDialog.getOpenFileName( - self, "加载配置", "", "JSON Files (*.json);;All Files (*.*)" - ) - if file_path: - self.load_config(file_path) - - def load_config(self, file_path): - """加载配置""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - # 应用配置到各面板(动态遍历,仅调用有 set_config 的面板) - for step_id, panel in self._panels.items(): - if step_id in config and hasattr(panel, 'set_config'): - panel.set_config(config[step_id]) - - 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): - """获取当前配置(动态遍历所有面板,仅收集有 get_config 的面板)""" - config = {} - for step_id, panel in self._panels.items(): - if hasattr(panel, 'get_config'): - config[step_id] = panel.get_config() - return config - - def auto_populate_step_inputs(self, step_id): - """自动填充指定步骤的输入路径(事件总线已接管,保留接口兼容)。""" - return 0 - - def get_step_panel(self, step_id): - """根据步骤ID获取对应的面板对象(从动态注册表查找)""" - return self._panels.get(step_id) - - def auto_populate_all_steps(self): - """扫描工作目录并触发事件总线自动填充所有步骤的输入路径。""" - work_dir = getattr(self, 'work_dir', './work_dir') - work_path = Path(work_dir) - - if not work_path.exists(): - QMessageBox.warning(self, "警告", f"工作目录不存在: {work_dir}\n请先设置正确的工作目录。") - return - - # 扫描工作目录 → WorkspaceManager 发布 OutputUpdated 事件 → 面板自动填充 - self.workspace_manager.scan_work_directory_for_files(work_path) - - # 补充发布 reference_img(step1 的输入影像,非 step1 产出但下游依赖它) - step1_panel = self._panels.get('step1') - if step1_panel and hasattr(step1_panel, 'img_file'): - ref_img = step1_panel.img_file.get_path() - if ref_img: - global_event_bus.publish('OutputUpdated', { - 'step_id': 'step1', - 'output_type': 'reference_img', - 'path': ref_img, - }) - - self.log_message("✓ 工作目录扫描完成,事件总线已通知所有面板自动填充", "info") - QMessageBox.information(self, "完成", "工作目录扫描完成!\n各步骤输入路径已自动填充。") - - def add_auto_fill_buttons_to_panels(self): - """为各个步骤面板添加自动填充按钮(动态遍历所有面板)""" - for step_id, panel in self._panels.items(): - if not panel: - continue - # 为面板添加自动填充方法 - def create_auto_fill_method(sid): - def auto_fill_inputs(): - self.auto_populate_step_inputs(sid) - return auto_fill_inputs - - panel.auto_fill_inputs = create_auto_fill_method(step_id) - - # 尝试添加自动填充按钮到面板(如果面板支持的话) - self.try_add_auto_fill_button(panel) - - def try_add_auto_fill_button(self, panel): - """尝试为面板添加自动填充按钮""" - try: - # 查找面板中的运行按钮,在其旁边添加自动填充按钮 - if hasattr(panel, 'run_btn') and hasattr(panel, 'auto_fill_inputs'): - run_btn = panel.run_btn - - # 检查是否已经有自动填充按钮了 - if hasattr(panel, 'auto_fill_btn'): - return - - # 创建自动填充按钮 - auto_fill_btn = QPushButton("🔄 自动填充") - auto_fill_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal')) - auto_fill_btn.setToolTip("从工作目录自动填充输入路径") - auto_fill_btn.clicked.connect(panel.auto_fill_inputs) - auto_fill_btn.setMaximumWidth(120) - - # 获取运行按钮的父布局 - parent_layout = run_btn.parent().layout() - if parent_layout: - # 找到运行按钮在布局中的位置 - for i in range(parent_layout.count()): - if parent_layout.itemAt(i).widget() == run_btn: - # 创建水平布局容器 - btn_container = QWidget() - btn_layout = QHBoxLayout(btn_container) - btn_layout.setContentsMargins(0, 0, 0, 0) - - # 从原布局中移除运行按钮 - parent_layout.removeWidget(run_btn) - - # 添加按钮到新的水平布局 - btn_layout.addWidget(auto_fill_btn) - btn_layout.addWidget(run_btn) - - # 将按钮容器添加到原位置 - parent_layout.insertWidget(i, btn_container) - - panel.auto_fill_btn = auto_fill_btn - break - except Exception as e: - # 如果添加失败,静默忽略 - pass - - def set_work_directory(self): - """设置工作目录""" - dir_path = QFileDialog.getExistingDirectory(self, "选择工作目录") - if dir_path: - self.work_dir = dir_path - self.log_message(f"工作目录已设置: {dir_path}", "info") - self.statusBar().showMessage(f"工作目录: {dir_path}") - - # 同步到可视化/报告面板 - for sid in ('step12_viz', 'step13_report'): - panel = self._panels.get(sid) - if panel and hasattr(panel, 'set_work_dir'): - panel.set_work_dir(dir_path) - - def open_work_directory(self): - """打开工作目录""" - work_dir = getattr(self, 'work_dir', './work_dir') - if os.path.exists(work_dir): - os.startfile(work_dir) - else: - QMessageBox.warning(self, "警告", "工作目录不存在!") - - def show_pipeline_status_on_startup(self): - """启动时显示Pipeline状态""" - if not PIPELINE_AVAILABLE: - # 如果pipeline不可用,显示警告信息 - status_msg = "[WARNING] Pipeline模块无法加载\n\n" - status_msg += "系统功能将受到限制,建议检查以下问题:\n" - status_msg += "• 项目文件结构是否完整\n" - status_msg += "• Python依赖包是否已安装\n" - status_msg += "• Python版本是否兼容\n\n" - status_msg += "点击 '帮助' → '检查Pipeline状态' 查看详细信息" - - QMessageBox.warning(self, "Pipeline模块警告", status_msg) - else: - # 如果pipeline可用,只在状态栏显示 - self.statusBar().showMessage("Pipeline模块: 正常加载", 5000) # 显示5秒 - - def show_pipeline_status(self): - """显示Pipeline状态""" - status_text = "Pipeline模块状态检查\n\n" - - if PIPELINE_AVAILABLE: - status_text += "[OK] Pipeline模块状态: 正常\n\n" - status_text += "详细诊断信息:\n" - else: - status_text += "[ERROR] Pipeline模块状态: 不可用\n\n" - status_text += "详细诊断信息:\n" - - for info in PIPELINE_ERROR_INFO: - status_text += info + "\n" - - # 添加使用建议 - status_text += "\n" + "="*50 + "\n" - if PIPELINE_AVAILABLE: - status_text += "[SUCCESS] Pipeline模块已成功加载,可以正常使用所有功能!\n" - else: - status_text += "[WARNING] Pipeline模块无法加载,功能将受到限制\n" - status_text += "建议解决方案:\n" - status_text += "1. 检查项目文件结构是否完整\n" - status_text += "2. 安装所有必需的依赖包\n" - status_text += "3. 确认Python版本兼容性\n" - status_text += "4. 查看控制台输出获取更多详细信息\n" - - # 创建消息框 - msg_box = QMessageBox(self) - msg_box.setWindowTitle("Pipeline状态检查") - msg_box.setText(status_text) - - # 根据状态设置图标 - if PIPELINE_AVAILABLE: - msg_box.setIcon(QMessageBox.Information) - else: - msg_box.setIcon(QMessageBox.Warning) - - # 设置详细文本(如果有的话) - if PIPELINE_ERROR_INFO: - detailed_text = "\n".join(PIPELINE_ERROR_INFO) - msg_box.setDetailedText(detailed_text) - - msg_box.setStandardButtons(QMessageBox.Ok) - msg_box.exec_() - - def show_about(self): - """显示关于对话框""" - QMessageBox.about( - self, "关于", - "MegaCube-Water Quality ‌V1.2\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 _show_ai_settings(self): - """弹出 AI 引擎配置对话框。""" - dlg = AISettingsDialog(self) - dlg.exec_() - - def _precheck_step3_bands(self) -> bool: - """步骤 3 波段越界预检(主线程同步执行,避多线程弹窗坑) - - 读取 step1 影像的 RasterCount,校验 step3 面板当前方法下所有波段索引 - (nir_lower/nir_upper/nir_band/oxy_band/lower_oxy/upper_oxy/hedley_nir_band) - 是否越界。若越界,弹 BandConfirmDialog(60s 倒计时)让用户调整或取消。 - - Returns: - True: 预检通过或已自动调整,run_full_pipeline 继续 - False: 用户点"取消运行",run_full_pipeline 应 return - """ - # 1) 取 step1 影像路径 + step3 配置 + enabled 标志 - try: - step1_panel = self._panels.get('step1') - step3_panel = self._panels.get('step3') - img_path = step1_panel.img_file.get_path() if step1_panel else None - step3_cfg = step3_panel.get_config() if step3_panel else None - step3_enabled = step3_panel.enable_checkbox.isChecked() if step3_panel else False - except Exception as e: - self.log_message(f"⚠ step3 波段预检:读取面板状态失败 - {e}", "warning") - return True # 失败不阻断(防御性:放行比误杀好) - - # 早退条件:step3 禁用 / 无 img_path / 无 cfg - if not step3_enabled: - return True - if not img_path or not os.path.isfile(img_path): - self.log_message("⚠ step3 波段预检:未找到参考影像,跳过", "info") - return True - if not step3_cfg: - return True - - # 2) 读 RasterCount(gdal 头信息读取,毫秒级不卡 UI) - try: - dataset = gdal.Open(img_path) - if dataset is None: - self.log_message(f"⚠ step3 波段预检:gdal 无法打开影像 {img_path}", "warning") - return True - max_band = dataset.RasterCount - dataset = None - except Exception as e: - self.log_message(f"⚠ step3 波段预检:读取 RasterCount 失败 - {e}", "warning") - return True - - if max_band <= 0: - return True - - # 3) 不同方法对应不同的波段字段(cfg_key, panel_attr, 推荐值, 标签) - method = step3_cfg.get('method', 'goodman') - if method == 'goodman': - band_fields = [ - ('nir_lower', 'nir_lower', 65, 'NIR下波段'), - ('nir_upper', 'nir_upper', 91, 'NIR上波段'), - ] - elif method == 'kutser': - band_fields = [ - ('oxy_band', 'oxy_band', 38, '氧吸收波段'), - ('lower_oxy', 'lower_oxy', 36, '下氧吸收波段'), - ('upper_oxy', 'upper_oxy', 49, '上氧吸收波段'), - ('nir_band', 'nir_band', 47, 'NIR波段'), - ] - elif method == 'hedley': - band_fields = [ - ('hedley_nir_band', 'hedley_nir_band', 47, 'NIR波段'), - ] - else: # sugar 无波段索引 - return True - - # 4) 逐字段检查;遇到第一个越界就弹窗(用户处理完继续检查下一个) - for cfg_key, panel_attr, recommended, label in band_fields: - requested = step3_cfg.get(cfg_key) - if requested is None or requested <= max_band: - continue # 没设 / 没越界 - - self.log_message( - f"⚠ step3 波段越界:{label}={requested} > 影像波段数 {max_band}", - "warning", - ) - - dlg = BandConfirmDialog( - self, - requested_band=requested, - max_band=max_band, - recommended_band=recommended, - method_label=label, - ) - result = dlg.exec_() - if result == QDialog.Rejected: - self.log_message("✗ 用户取消运行(step3 波段越界未解决)", "warning") - return False - - new_band = dlg.selected_band() - try: - spin = getattr(step3_panel, panel_attr) - spin.setValue(new_band) - except AttributeError: - self.log_message(f"⚠ step3 panel 缺控件 {panel_attr},跳过回写", "warning") - continue - - self.log_message( - f"✓ {label}:{requested} → {new_band}(影像最多 {max_band} 波段)", - "info", - ) - - return True - - def run_full_pipeline(self): - """运行完整流程""" - if not PIPELINE_AVAILABLE: - QMessageBox.critical( - self, "错误", - "无法导入 Pipeline 模块,请检查 src/core/handlers/ 目录是否完整!" - ) - return - - # ── 0) 强制获取 work_dir(禁止依赖外部或全局变量) ── - work_dir = getattr(self, 'work_dir', None) - if not work_dir: - QMessageBox.warning(self, "警告", "未选择工作目录,请先设置工作目录。") - return - - # ── 1) 运行前智能预检与自动回填(硬盘已有产物自动跳过) ── - work_path = Path(work_dir) - self.log_message("正在进行运行前环境预检与自动扫描...", "info") - self.workspace_manager.scan_work_directory_for_files(work_path) - self.auto_populate_all_steps() - self.log_message("✓ 预检完成:已扫描工作目录并自动回填已落盘的产物", "info") - - # ── 1.5) step3 波段越界预检(60s 倒计时弹窗,主线程同步,避开多线程弹窗坑) ── - if not self._precheck_step3_bands(): - return # 用户点"取消运行" - - # ── 1.6) ★ 全流程模式选择弹窗 ── - mode_dlg = PipelineModeDialog(main_window=self, parent=self) - if mode_dlg.exec() != QDialog.Accepted: - return # 用户点"取消" - selected_mode = mode_dlg.selected_mode - self.log_message(f"[模式选择] 选定模式: {'训练新模型' if selected_mode == 'training' else '使用已有模型直接预测'}", "info") - - # ── 2) 刷新配置(拿到自动填充后的"满血版" config) ── - config = self.get_current_config() - - # ── 2.1) ★ 根据模式动态裁剪配置 ── - if selected_mode == "prediction_only": - config = self.workspace_manager.prune_config_for_prediction_mode(config) - self.log_message("[模式选择] 已裁剪训练相关步骤(step4/5/7/8),进入仅预测模式", "info") - - # ── 3) ★ 一次性全预检 + 用户交互式决策 ── - missing_items = PreflightDialog.build_missing_items(config) - if missing_items: - critical_items = [it for it in missing_items if it.is_critical] - if critical_items: - lines = "\n".join(f" - [{it.step_name}] {it.reason}" for it in critical_items) - QMessageBox.critical( - self, "预检失败(阻断性错误)", - f"以下为阻断性缺失,流程无法启动:\n\n{lines}\n\n" - "请填写后重新运行。" - ) - return - dialog = PreflightDialog(missing_items, parent=self) - if dialog.exec() != QDialog.Accepted: - return - result = dialog.get_result() - if result is None: - return - action, *payload = result - if action == "fill": - _, step_id, tab_index = result - self.step_stack.setCurrentIndex(tab_index) - self.log_message(f"[预检] 用户选择填写 {step_id},已切换到对应面板。", "info") - return - skip_list: List[str] = payload[0] if payload else [] - if skip_list: - self.log_message(f"[预检] 用户强制跳过 {len(skip_list)} 个步骤: {skip_list}", "info") - else: - skip_list = [] - - # ── 4) 确认执行 - reply = QMessageBox.question( - self, "确认", - "是否开始执行完整流程?\n\n这可能需要较长时间,请确保配置正确。", - QMessageBox.Yes | QMessageBox.No - ) - - if reply != QMessageBox.Yes: - return - - # 创建pipeline实例 - self.log_message(f"初始化pipeline,工作目录: {work_dir}", "info") - - # 准备实际运行配置(排除未启用的步骤) - worker_config = copy.deepcopy(config) - step6_cfg = worker_config.get('step6_feature') - if step6_cfg: - enabled = step6_cfg.pop('enabled', True) - if not enabled: - worker_config.pop('step6_feature', None) - - # 工作线程内创建 Pipeline,避免主线程阻塞及 Qt5Agg 子线程绘图卡死 - self.worker = WorkerThread(work_dir, worker_config, mode='full', skip_list=skip_list) - self.worker.log_message.connect(self.log_message, Qt.QueuedConnection) - self.worker.progress_update.connect(self.update_progress, Qt.QueuedConnection) - self.worker.step_completed.connect(self.on_step_completed, Qt.QueuedConnection) - self.worker.finished.connect(self.on_pipeline_finished, Qt.QueuedConnection) - - # 更新UI状态 - self.run_all_btn.setEnabled(False) - self.stop_btn.setEnabled(True) - self.progress_bar.setValue(0) - - # 启动线程 - self.worker.start() - self.log_message("="*50, "info") - self.log_message("开始执行完整流程...", "info") - self.log_message("="*50, "info") - - def stop_pipeline(self): - """停止流程""" - if self.worker and self.worker.isRunning(): - reply = QMessageBox.question( - self, "确认", - "是否停止当前流程?", - QMessageBox.Yes | QMessageBox.No - ) - if reply == QMessageBox.Yes: - self.worker.stop() - self.log_message("用户取消执行", "warning") - self.run_all_btn.setEnabled(True) - self.stop_btn.setEnabled(False) - - def on_pipeline_finished(self, success, message): - """流程完成""" - self.run_all_btn.setEnabled(True) - self.stop_btn.setEnabled(False) - - if success: - self.progress_bar.setValue(100) - self.log_message("="*50, "info") - self.log_message("流程执行完成!", "info") - self.log_message("="*50, "info") - QMessageBox.information(self, "完成", "流程执行成功!\n\n请查看工作目录中的结果文件。") - else: - self.log_message("="*50, "error") - self.log_message(f"流程执行失败: {message}", "error") - self.log_message("="*50, "error") - QMessageBox.critical(self, "失败", f"流程执行失败:\n\n{message[:200]}") - - def on_step_completed(self, step_name, success, message): - """步骤完成回调:记录输出路径,WorkspaceManager 自动发布 EventBus 事件。""" - if not success: - return - - work_dir = getattr(self, 'work_dir', './work_dir') - work_path = Path(work_dir) - - if step_name not in self.workspace_manager.step_outputs: - self.workspace_manager.step_outputs[step_name] = {} - - # WorkspaceManager.update_step_outputs 内部会发布 OutputUpdated 事件 - # 下游面板通过 DependencySubscriber 自动接收并填充 - self.workspace_manager.update_step_outputs(step_name, work_path) - - def run_single_step(self, step_name, config): - """运行单个步骤""" - if not PIPELINE_AVAILABLE: - QMessageBox.critical( - self, "错误", - "无法导入 Pipeline 模块,请检查 src/core/handlers/ 目录是否完整!" - ) - return - - # 创建pipeline实例 - work_dir = getattr(self, 'work_dir', './work_dir') - self.log_message(f"初始化pipeline,工作目录: {work_dir}", "info") - - self.worker = WorkerThread(work_dir, config, mode='single_step', step_name=step_name) - self.worker.log_message.connect(self.log_message, Qt.QueuedConnection) - self.worker.progress_update.connect(self.update_progress, Qt.QueuedConnection) - self.worker.step_completed.connect(self.on_step_completed, Qt.QueuedConnection) - self.worker.finished.connect(self.on_pipeline_finished, Qt.QueuedConnection) - - # 更新UI状态 - self.run_all_btn.setEnabled(False) - self.stop_btn.setEnabled(True) - self.progress_bar.setValue(0) - - # 启动线程 - self.worker.start() - self.log_message("="*50, "info") - self.log_message(f"开始独立运行步骤 {step_name}...", "info") - self.log_message("="*50, "info") - - def update_progress(self, percentage, message): - """更新进度""" - self.progress_bar.setValue(percentage) - self.statusBar().showMessage(message) - - def log_message(self, message, level='info'): - """记录日志""" - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - - # 设置颜色 - if level == 'error': - color = 'red' - elif level == 'warning': - color = 'orange' - else: - color = 'black' - - # 添加到日志 - formatted_msg = f'[{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): - """更新横幅图片 - 固定高度 + 居中裁剪(类似 CSS object-fit: cover)""" - if not hasattr(self, 'banner_pixmap') or self.banner_pixmap.isNull(): - return - - TARGET_HEIGHT = 140 - target_width = self.width() - - # 手动计算 Cover 缩放比例:取宽/高各自所需比例的最大值, - # 确保缩放后图片的宽 ≥ target_width 且高 ≥ TARGET_HEIGHT - orig_w = self.banner_pixmap.width() - orig_h = self.banner_pixmap.height() - scale_factor = max(target_width / orig_w, TARGET_HEIGHT / orig_h) - new_w = int(orig_w * scale_factor) - new_h = int(orig_h * scale_factor) - - scaled_pixmap = self.banner_pixmap.scaled( - new_w, new_h, - Qt.IgnoreAspectRatio, - Qt.SmoothTransformation - ) - - # 居中裁剪,截取中间核心区域 - crop_x = (new_w - target_width) // 2 - crop_y = (new_h - TARGET_HEIGHT) // 2 - final_pixmap = scaled_pixmap.copy(crop_x, crop_y, target_width, TARGET_HEIGHT) - - self.banner_label.setFixedHeight(TARGET_HEIGHT) - self.banner_label.setFixedWidth(target_width) # 强制宽度填满容器 - self.banner_label.setPixmap(final_pixmap) - self.banner_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - - # 文字叠加层:绝对定位,保持垂直居中 - if hasattr(self, 'banner_title_label'): - title_x = 160 - title_y = max(0, (TARGET_HEIGHT - 60) // 2) - self.banner_title_label.move(title_x, title_y) - self.banner_title_label.resize(target_width - title_x - 20, 60) - - def resizeEvent(self, event): - """窗口大小改变事件 - 实时更新横幅图片等比缩放""" - super().resizeEvent(event) - # 直接调用,不使用定时器延迟(或缩短到 10ms) - self.update_banner_image() - - def update_ui_for_training_mode(self): - """根据训练数据模式更新UI状态""" - # 需要禁用的步骤ID(对应无训练数据模式下需要禁用的步骤) - disabled_step_ids = ['step4_sampling', 'step5_clean', 'step6_feature', 'step7_index', 'step9_ml_predict'] - - # 更新标签页的启用/禁用状态(从注册表动态获取 Tab 索引) - for step_id in disabled_step_ids: - tab_index = get_tab_index(step_id) - if tab_index >= 0 and 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(): - """主函数""" - import sys - import multiprocessing - from PyQt5.QtWidgets import QApplication - - # 1. 多进程 Fork 环境隔离 - if multiprocessing.current_process().name != 'MainProcess': - sys.exit(0) - - # 2. 🚀 终极防御:必须在全宇宙第一行强制创建 QApplication 实例! - # 绝对杜绝任何后续验签模块或弹窗过早调用 QWidget 导致的崩溃 - app = QApplication.instance() - if not app: - app = QApplication(sys.argv) - - app.setApplicationName("Mega Water") - app.setOrganizationName("WaterQuality") - - # 3. 安全载入离线授权验证拦截 - try: - from src.auth.license_manager import verify_license - from src.auth.license_dialog import LicenseDialog - except ImportError: - 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.auth.license_manager import verify_license - from src.auth.license_dialog import LicenseDialog - - _is_license_valid, _license_msg = verify_license() - if not _is_license_valid: - _dialog = LicenseDialog() - _dialog.exec_() - sys.exit(0) - - # 4. 授权通过,正常载入主程序主界面 - window = WaterQualityGUI() - window.show() - - sys.exit(app.exec_()) - - -# ============================================================================== -# 全宇宙最底部程序入口 -# ============================================================================== -if __name__ == "__main__": - import sys - import multiprocessing - - # 1. 极其强硬的底层防御: - # 如果当前进程明确是 PyInstaller 派生的后台计算子进程,强行静默退出! - # 彻底绕过多进程钩子在尝试解包 sys.argv 时引发的 ValueError 崩溃 - if multiprocessing.current_process().name != 'MainProcess': - sys.exit(0) - - # 2. 安全调用 freeze_support(带防爆气囊) - try: - multiprocessing.freeze_support() - except Exception: - pass # 哪怕底层钩子参数解包失败,也强行保住主进程平稳过关 - - # 3. 正常拉起主业务逻辑 - main() - diff --git a/src/gui/water_quality_gui_v2.py b/src/gui/water_quality_gui_v2.py index 3743c6b..ee897ff 100644 --- a/src/gui/water_quality_gui_v2.py +++ b/src/gui/water_quality_gui_v2.py @@ -16,9 +16,6 @@ WaterQualityGUI 只负责窗口框架(标题栏、菜单栏、状态栏、QTab - TrainingModeManager → 训练模式切换(发布 TrainingModeChanged 事件) """ -import osgeo # noqa: F401 -from osgeo import gdal, ogr # noqa: F401 - import os import sys import ctypes @@ -450,7 +447,9 @@ class WaterQualityGUI(QMainWindow): self._log_manager.progress_bar.setValue(100) QMessageBox.information(self, "完成", "流程执行成功!\n\n请查看工作目录中的结果文件。") else: - QMessageBox.critical(self, "失败", f"流程执行失败:\n\n{message[:200]}") + QMessageBox.critical(self, "失败", + f"流程执行失败:\n\n{message[:600]}" + + ("\n...(已截断)" if len(message) > 600 else "")) def _on_pipeline_stopped(self, data): self._stop_btn.setEnabled(False) @@ -464,18 +463,20 @@ class WaterQualityGUI(QMainWindow): work_dir = data.get('work_dir', '') self.statusBar().showMessage(f"工作目录: {work_dir}") - # 遍历已加载面板,调用 update_from_config 重建内存级参数和默认输出路径 - for step_id, panel in self._panel_factory.get_loaded_panels().items(): - if not hasattr(panel, 'update_from_config'): - continue - try: - panel.update_from_config(work_dir=work_dir, pipeline=None) - except Exception as e: - # 2026-06-30:不再静默吞异常,至少通过 EventBus 输出日志 - self._event_bus.publish('LogMessage', { - 'message': f'[警告] 面板 {step_id} 的 update_from_config 失败: {e}', - 'level': 'warning', - }) + # 让当前激活的 Tab 立即基于新目录重算默认路径,覆盖掉旧值,绕过非空保护 + from src.gui.core.panel_registry import PANEL_REGISTRY + active_idx = self._tab_widget.currentIndex() + if 0 <= active_idx < len(PANEL_REGISTRY): + active_step_id = PANEL_REGISTRY[active_idx]['step_id'] + panel = self._panel_factory.get_panel(active_step_id) + if panel is not None and hasattr(panel, 'update_from_config'): + try: + panel.update_from_config( + work_dir=self._workspace_initializer.work_dir, + pipeline=None + ) + except Exception: + pass # ================================================================ # 导航 → Tab 单向路由(左侧 List 驱动右侧 Tab,Tab 头部已隐藏) @@ -528,21 +529,16 @@ class WaterQualityGUI(QMainWindow): "如果当前有流程正在运行,请勿修改此页面的参数。" ) - # 【新增修复】:在跳之前,再核实一遍要去的 tab_index 和 item_data 的 step_id 是否吻合! - # 如果因为删除了死文件导致索引错位,这里强行用真实 step_id 再找一遍! + # 【防御】:用已加载 panel 实例反查真实 Tab 索引,防止索引错位 try: - self._panel_factory.get_panel(item_data) - # 遍历右侧所有已加载的 Tab,找到属于当前 step_id 的那个正确的 Tab 索引 for i in range(self._tab_widget.count()): scroll_area = self._tab_widget.widget(i) if scroll_area and hasattr(scroll_area, 'widget'): - real_panel = scroll_area.widget() - # 如果这个 panel 就是我们要找的 panel,强制更新 tab_index - if real_panel == self._panel_factory.get_panel(item_data): + if scroll_area.widget() == panel: tab_index = i break except Exception as e: - self._log_manager.error(f"页面实例化失败: {str(e)}") + self._log_manager.error(f"页面索引校验失败: {str(e)}") return # 2. 强制使用注册表的固定索引进行跳转 @@ -551,7 +547,7 @@ class WaterQualityGUI(QMainWindow): # 【新增修复】:每次切页时,强制重播所有面板的输入值! # 原理:打破 preload_window 造成的时序差,确保目标页面能拿到源页面最新填写的值 if hasattr(self, '_panel_factory'): - self._panel_factory._replay_live_panel_inputs() + self._panel_factory.replay_live_panel_inputs() # 3. 防撕裂回弹机制:如果由于某种原因没跳过去,把左边的蓝条强行拽回当前真实页面 if self._tab_widget.currentIndex() != tab_index: @@ -695,10 +691,6 @@ class WaterQualityGUI(QMainWindow): def work_dir(self, value): self._workspace_initializer.work_dir = value - @property - def pipeline(self): - return None - @property def worker(self): return self._pipeline_executor.worker @@ -718,6 +710,22 @@ def main(): if multiprocessing.current_process().name != 'MainProcess': sys.exit(0) + # ★ GDAL 依赖检查(移至此处以利用 global_exception_handler 展示友好弹窗) + try: + import osgeo # noqa: F401 + from osgeo import gdal, ogr # noqa: F401 + except ImportError: + from PyQt5.QtWidgets import QMessageBox, QApplication as _QA + _qa = _QA.instance() or _QA(sys.argv) + QMessageBox.critical( + None, "依赖缺失", + "GDAL (osgeo) 未安装,程序无法运行。\n\n" + "请运行以下命令安装:\n" + " conda install -c conda-forge gdal\n" + "或访问 https://gdal.org/download.html" + ) + sys.exit(1) + app = QApplication.instance() if not app: app = QApplication(sys.argv)