#!/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()