数据层(panel_registry.py):13 个 step 从「阶段一/二/三/四」重组为四个模块 模块一 影像预处理(step 1-3) 模块二 特征工程与数据(step 4-7) 模块三 模型训练与反演(step 8-10) 模块四 制图与成果汇编(step 11-13) step_id 顺序、tab_index、面板绑定、Tab 路由全部保持零变化 文本层(water_quality_gui.py / v2):移除 └─ 字符前缀 样式层(styles.py:get_sidebar_stylesheet):扁平无框 + 蓝色高亮主题 - 容器 QListWidget 无框化(border: none / outline: none / 透明背景) - 步骤项 padding 8px 6px + margin 2px 8px + border-radius 4px - hover 极浅蓝灰 #F0F4F8;selected 饱和蓝 #0078D4 + 白字 #FFFFFF - 分类头(stage_header):!enabled 选择器锁定 → 蓝色 #0078D4 + 加粗 + 上下间距 Python 代码侧:stage_item.setForeground 硬编码 #0078D4、stage_font.setBold(True) 作为 QSS 失效兜底 + 代码意图自解释 末尾迭代:四个模块名移除 [ ] 中括号(极简风)
1599 lines
64 KiB
Python
1599 lines
64 KiB
Python
#!/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'<span style="color: {color};">[{timestamp}] {message}</span>'
|
||
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()
|
||
|