From 19c86e6e449ada9ef70f0203dde9da2a6b477ff4 Mon Sep 17 00:00:00 2001 From: DXC Date: Wed, 17 Jun 2026 17:18:15 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=AE=9E=E7=8E=B0=E7=BA=AF?= =?UTF-8?q?=E5=A3=B3=E4=B8=BB=E7=AA=97=E5=8F=A3=20+=20=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=89=B9=20Manager=EF=BC=88PanelFactory/PipelineExecutor/Works?= =?UTF-8?q?paceInitializer=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - water_quality_gui_v2.py: 纯壳主窗口(725行),依赖注入链 + EventBus 驱动,7个菜单连线 - panel_factory.py: PanelFactory 懒加载工厂(占位页替换 + 邻接预加载) - pipeline_executor.py: PipelineExecutor 核心调度(接管 run_full_pipeline/run_single_step/stop_pipeline) - workspace_initializer.py: WorkspaceInitializer 环境初始化(接管 init_workspace/set_work_directory/auto_populate_all) --- src/gui/core/panel_factory.py | 204 ++++++++ src/gui/core/pipeline_executor.py | 469 +++++++++++++++++ src/gui/core/workspace_initializer.py | 234 +++++++++ src/gui/water_quality_gui_v2.py | 724 ++++++++++++++++++++++++++ 4 files changed, 1631 insertions(+) create mode 100644 src/gui/core/panel_factory.py create mode 100644 src/gui/core/pipeline_executor.py create mode 100644 src/gui/core/workspace_initializer.py create mode 100644 src/gui/water_quality_gui_v2.py diff --git a/src/gui/core/panel_factory.py b/src/gui/core/panel_factory.py new file mode 100644 index 0000000..cea9b6e --- /dev/null +++ b/src/gui/core/panel_factory.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +面板注册与装载工厂 + +按需懒加载步骤面板,替代 create_content_area 中一次性全量 new 14 个面板的做法。 +主窗口只需持有 PanelFactory 实例,通过 factory.create_tab_widget() 获取 +已挂载占位页的 QTabWidget,面板在用户首次切换到对应 Tab 时才实例化。 + +特性: +- 懒加载:仅在 tab 首次激活时创建面板实例 +- 邻接预加载:切换 tab 时自动预加载左右邻居(可配置预加载窗口大小) +- 注册表驱动:完全依赖 PANEL_REGISTRY,零硬编码 +- 事件总线自动接线:面板创建后自动调用 subscribe_panel_to_dependencies +- 占位页:未加载的 tab 显示空白 QWidget,加载后原地替换为 QScrollArea(panel) +""" + +from PyQt5.QtWidgets import QWidget, QTabWidget, QScrollArea +from PyQt5.QtCore import Qt + +from src.gui.core.panel_registry import PANEL_REGISTRY +from src.gui.core.dependency_subscriber import subscribe_panel_to_dependencies + + +class PanelFactory: + """面板注册与装载工厂。 + + 用法:: + + factory = PanelFactory(registry=PANEL_REGISTRY, main_window=self) + tab_widget = factory.create_tab_widget(icons_dir="data/icons") + # tab_widget 已包含所有占位页,可直接加入主窗口布局 + # 后续通过 factory.get_panel(step_id) 按需获取面板实例 + """ + + def __init__(self, registry, main_window, preload_window=1): + """ + Args: + registry: PANEL_REGISTRY 列表 + main_window: WaterQualityGUI 实例(用于注入 main_window 依赖) + preload_window: 邻接预加载窗口大小。0=仅加载当前 tab; + 1=当前+左右各1个;-1=全量预加载(退化为旧行为) + """ + self._registry = registry + self._main_window = main_window + self._preload_window = preload_window + + # step_id → panel 实例(仅已加载的) + self._panels = {} + # tab_index → 是否已加载 + self._loaded = set() + # tab_index → placeholder QWidget(加载后被替换) + self._placeholders = {} + # 对外的 QTabWidget 引用 + self._tab_widget = None + + # ── 公开 API ────────────────────────────────────────────── + + def create_tab_widget(self, icons_dir="data/icons"): + """创建并返回已填充占位页的 QTabWidget。 + + 每个 tab 初始为空白 QWidget 占位,面板在首次激活时懒加载。 + 同时连接 currentChanged 信号驱动懒加载 + 邻接预加载。 + + Args: + icons_dir: 图标目录名(相对于项目根),用于 get_resource_path + + Returns: + QTabWidget: 已添加所有占位 tab 的标签页控件 + """ + from src.gui.water_quality_gui import get_resource_path + from PyQt5.QtGui import QIcon + + self._tab_widget = QTabWidget() + self._tab_widget.setTabPosition(QTabWidget.North) + self._tab_widget.setTabsClosable(False) + + for idx, entry in enumerate(self._registry): + step_id = entry['step_id'] + title = entry['title'] + icon_name = entry['icon'] + + # 创建占位页 + placeholder = QWidget() + self._placeholders[idx] = placeholder + + icon_path = get_resource_path(f"{icons_dir}/{icon_name}") + self._tab_widget.addTab(placeholder, QIcon(icon_path), title) + + # 连接切换信号 → 懒加载 + self._tab_widget.currentChanged.connect(self._on_tab_changed) + + # 立即预加载首个 tab + if self._registry: + self._ensure_loaded(0) + + return self._tab_widget + + def get_panel(self, step_id): + """获取面板实例(若未加载则触发懒加载)。 + + Args: + step_id: 步骤 ID,如 'step1'、'step5_clean' + + Returns: + QWidget 或 None: 面板实例,未找到则返回 None + """ + tab_index = self._step_id_to_tab_index(step_id) + if tab_index < 0: + return None + self._ensure_loaded(tab_index) + return self._panels.get(step_id) + + def get_loaded_panels(self): + """返回所有已加载的面板字典 {step_id: panel}。""" + return dict(self._panels) + + def preload_all(self): + """强制加载所有面板(用于配置保存等需要遍历全部面板的场景)。""" + for idx in range(len(self._registry)): + self._ensure_loaded(idx) + + def get_tab_widget(self): + """返回内部 QTabWidget 引用。""" + return self._tab_widget + + # ── 内部方法 ────────────────────────────────────────────── + + def _on_tab_changed(self, index): + """Tab 切换时:加载当前 tab + 邻接预加载。""" + if index < 0: + return + self._ensure_loaded(index) + self._preload_neighbors(index) + + def _ensure_loaded(self, tab_index): + """确保指定 tab 已加载;若未加载则实例化面板并替换占位页。""" + if tab_index in self._loaded: + return + if tab_index < 0 or tab_index >= len(self._registry): + return + + entry = self._registry[tab_index] + step_id = entry['step_id'] + cls = entry['class_ref'] + title = entry['title'] + kwargs = entry.get('constructor_kwargs') + deps = entry.get('dependencies') + + # 解析构造参数 + resolved_kwargs = {} + if kwargs: + for k in kwargs: + if k == 'main_window': + resolved_kwargs[k] = self._main_window + + # 实例化面板 + panel = cls(**resolved_kwargs) + + # 包裹到 QScrollArea + scroll = QScrollArea() + scroll.setWidget(panel) + scroll.setWidgetResizable(True) + + # 替换占位页 + placeholder = self._placeholders.get(tab_index) + if placeholder is not None and self._tab_widget is not None: + tab_title = self._tab_widget.tabText(tab_index) + tab_icon = self._tab_widget.tabIcon(tab_index) + self._tab_widget.removeTab(tab_index) + self._tab_widget.insertTab(tab_index, scroll, tab_icon, tab_title) + self._tab_widget.setCurrentIndex(tab_index) + + # 注册 + self._panels[step_id] = panel + self._loaded.add(tab_index) + + # 事件总线自动接线 + if deps: + subscribe_panel_to_dependencies(panel, step_id, deps) + + def _preload_neighbors(self, index): + """预加载当前 tab 的邻居(根据 preload_window 配置)。""" + if self._preload_window < 0: + # 全量预加载 + for i in range(len(self._registry)): + self._ensure_loaded(i) + return + + if self._preload_window == 0: + return + + start = max(0, index - self._preload_window) + end = min(len(self._registry), index + self._preload_window + 1) + for i in range(start, end): + if i != index: + self._ensure_loaded(i) + + def _step_id_to_tab_index(self, step_id): + """step_id → tab_index 映射。""" + for i, entry in enumerate(self._registry): + if entry['step_id'] == step_id: + return i + return -1 \ No newline at end of file diff --git a/src/gui/core/pipeline_executor.py b/src/gui/core/pipeline_executor.py new file mode 100644 index 0000000..cfb6470 --- /dev/null +++ b/src/gui/core/pipeline_executor.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Pipeline 执行器 + +接管 WaterQualityGUI 中所有 Pipeline 执行相关逻辑: +- run_full_pipeline() 完整流程执行 +- run_single_step() 单步执行 +- stop_pipeline() 停止执行 +- _precheck_step3_bands() step3 波段越界预检 + +关键设计原则: +- 所有状态变化通过 global_event_bus 发布事件,绝不直接操作 UI 控件 +- WorkerThread 的 Qt 信号连接到内部槽函数,槽函数仅做 EventBus 转发 +- 预检对话框(PreflightDialog / PipelineModeDialog / BandConfirmDialog) + 仍为模态弹窗(用户交互必需),但结果通过 EventBus 发布 + +发布的事件: + PipelineStarted → {} 主窗口订阅:禁用运行按钮 + PipelineFinished → {success, message} 主窗口订阅:恢复按钮 + 弹窗 + PipelineStopped → {} 主窗口订阅:恢复按钮 + StepCompleted → {step_name, success, message} WorkspaceInitializer 订阅:扫描产物 + LogMessage → {message, level} LogManager 订阅:写入日志区 + ProgressUpdate → {percentage, message} LogManager 订阅:更新进度条 +""" + +import os +import copy +from pathlib import Path +from typing import Dict, List, Optional + +from PyQt5.QtCore import QObject, Qt +from PyQt5.QtWidgets import QMessageBox, QDialog + +from src.gui.core.event_bus import global_event_bus +from src.gui.core.worker_thread import ( + WorkerThread, + PIPELINE_AVAILABLE, +) +from src.gui.core.preflight_dialog import PreflightDialog +from src.gui.core.pipeline_mode_dialog import PipelineModeDialog +from src.gui.dialogs import BandConfirmDialog +from src.core.pipeline.runner import PipelineHalt + +# pipeline step_id → panel step_id 映射 +PIPELINE_TO_PANEL_STEP = { + 'step1': 'step1', + 'step2': 'step2', + 'step3': 'step3', + 'step4': 'step5_clean', + 'step5': 'step6_feature', + 'step7': 'step7_index', + 'step8': 'step8_ml_train', + 'step9': 'step10_watercolor', + 'step10': 'step4_sampling', + 'step11_ml': 'step9_ml_predict', + 'step11': 'step11_map', + 'step14': 'step11_map', +} + + +class PipelineExecutor(QObject): + """Pipeline 执行器 —— 纯逻辑层,零 UI 直接操作。""" + + def __init__(self, panel_factory, workspace_initializer, parent=None): + """ + Args: + panel_factory: PanelFactory 实例(用于获取面板和配置) + workspace_initializer: WorkspaceInitializer 实例(用于获取 work_dir) + parent: 父 QObject(通常为 WaterQualityGUI) + """ + super().__init__(parent) + self._panel_factory = panel_factory + self._workspace_initializer = workspace_initializer + self._worker: Optional[WorkerThread] = None + + # ═══════════════════════════════════════════════════════════ + # 公开 API + # ═══════════════════════════════════════════════════════════ + + @property + def worker(self): + return self._worker + + @property + def is_running(self) -> bool: + return self._worker is not None and self._worker.isRunning() + + def run_full_pipeline(self): + """运行完整流程。 + + 流程: + 1. 检查 PIPELINE_AVAILABLE + 2. 获取 work_dir(从 WorkspaceInitializer) + 3. 扫描工作目录 + 自动回填 + 4. step3 波段越界预检 + 5. 全流程模式选择弹窗 + 6. 获取配置 + 模式裁剪 + 7. 一次性全预检 + 用户交互 + 8. 确认执行 → 创建 WorkerThread → 启动 + """ + if not PIPELINE_AVAILABLE: + global_event_bus.publish('LogMessage', { + 'message': '无法导入 Pipeline 模块,请检查项目文件结构!', + 'level': 'error', + }) + # 阻断性错误仍需弹窗(用户必须知道) + QMessageBox.critical( + self.parent(), "错误", + "无法导入pipeline模块,请确保water_quality_inversion_pipeline_GUI.py文件存在!" + ) + return + + # ── 1) 获取 work_dir ── + work_dir = self._workspace_initializer.work_dir + if not work_dir: + QMessageBox.warning(self.parent(), "警告", "未选择工作目录,请先设置工作目录。") + return + + work_path = Path(work_dir) + + # ── 2) 运行前扫描 + 自动回填 ── + global_event_bus.publish('LogMessage', { + 'message': '正在进行运行前环境预检与自动扫描...', + 'level': 'info', + }) + self._workspace_initializer.auto_populate_all() + global_event_bus.publish('LogMessage', { + 'message': '✓ 预检完成:已扫描工作目录并自动回填已落盘的产物', + 'level': 'info', + }) + + # ── 3) step3 波段越界预检 ── + if not self._precheck_step3_bands(): + return + + # ── 4) 全流程模式选择弹窗 ── + mode_dlg = PipelineModeDialog(main_window=self.parent(), parent=self.parent()) + if mode_dlg.exec() != QDialog.Accepted: + return + selected_mode = mode_dlg.selected_mode + global_event_bus.publish('LogMessage', { + 'message': ( + f"[模式选择] 选定模式: " + f"{'训练新模型' if selected_mode == 'training' else '使用已有模型直接预测'}" + ), + 'level': 'info', + }) + + # ── 5) 获取配置 ── + config = self._get_current_config() + + # ── 6) 模式裁剪 ── + if selected_mode == "prediction_only": + from src.core.workspace_manager import WorkspaceManager + config = WorkspaceManager.prune_config_for_prediction_mode(config) + global_event_bus.publish('LogMessage', { + 'message': '[模式选择] 已裁剪训练相关步骤(step4/5/7/8),进入仅预测模式', + 'level': 'info', + }) + + # ── 7) 一次性全预检 + 用户交互式决策 ── + missing_items = PreflightDialog.build_missing_items(config) + skip_list: List[str] = [] + + 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.parent(), "预检失败(阻断性错误)", + f"以下为阻断性缺失,流程无法启动:\n\n{lines}\n\n请填写后重新运行。" + ) + return + + dialog = PreflightDialog(missing_items, parent=self.parent()) + 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 + # 发布事件:请求切换到指定 tab + global_event_bus.publish('NavigateToTab', { + 'tab_index': tab_index, + 'step_id': step_id, + }) + global_event_bus.publish('LogMessage', { + 'message': f'[预检] 用户选择填写 {step_id},已切换到对应面板。', + 'level': 'info', + }) + return + skip_list = payload[0] if payload else [] + if skip_list: + global_event_bus.publish('LogMessage', { + 'message': f'[预检] 用户强制跳过 {len(skip_list)} 个步骤: {skip_list}', + 'level': 'info', + }) + + # ── 8) 确认执行 ── + reply = QMessageBox.question( + self.parent(), "确认", + "是否开始执行完整流程?\n\n这可能需要较长时间,请确保配置正确。", + QMessageBox.Yes | QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + + # ── 9) 准备 worker_config ── + global_event_bus.publish('LogMessage', { + 'message': f'初始化 Pipeline,工作目录: {work_dir}', + 'level': '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) + + # ── 10) 创建 WorkerThread 并连线 ── + self._worker = WorkerThread(work_dir, worker_config, mode='full', skip_list=skip_list) + self._worker.log_message.connect(self._on_log_message, Qt.QueuedConnection) + self._worker.progress_update.connect(self._on_progress_update, Qt.QueuedConnection) + self._worker.step_completed.connect(self._on_step_completed, Qt.QueuedConnection) + self._worker.finished.connect(self._on_finished, Qt.QueuedConnection) + + # ── 11) 发布启动事件 → 主窗口订阅后禁用按钮 ── + global_event_bus.publish('PipelineStarted', {}) + global_event_bus.publish('ProgressUpdate', {'percentage': 0, 'message': '准备执行...'}) + global_event_bus.publish('LogMessage', {'message': '=' * 50, 'level': 'info'}) + global_event_bus.publish('LogMessage', {'message': '开始执行完整流程...', 'level': 'info'}) + global_event_bus.publish('LogMessage', {'message': '=' * 50, 'level': 'info'}) + + self._worker.start() + + def run_single_step(self, step_name: str, config: dict = None): + """运行单个步骤。 + + Args: + step_name: 步骤名称(如 'step1', 'step5_clean') + config: 步骤配置字典(可选,默认从面板获取) + """ + if not PIPELINE_AVAILABLE: + QMessageBox.critical( + self.parent(), "错误", + "无法导入pipeline模块,请确保water_quality_inversion_pipeline_GUI.py文件存在!" + ) + return + + work_dir = self._workspace_initializer.work_dir or './work_dir' + + if config is None: + config = self._get_current_config() + + global_event_bus.publish('LogMessage', { + 'message': f'初始化 Pipeline,工作目录: {work_dir}', + 'level': 'info', + }) + + self._worker = WorkerThread(work_dir, config, mode='single_step', step_name=step_name) + self._worker.log_message.connect(self._on_log_message, Qt.QueuedConnection) + self._worker.progress_update.connect(self._on_progress_update, Qt.QueuedConnection) + self._worker.step_completed.connect(self._on_step_completed, Qt.QueuedConnection) + self._worker.finished.connect(self._on_finished, Qt.QueuedConnection) + + global_event_bus.publish('PipelineStarted', {}) + global_event_bus.publish('ProgressUpdate', {'percentage': 0, 'message': f'准备执行 {step_name}...'}) + global_event_bus.publish('LogMessage', {'message': '=' * 50, 'level': 'info'}) + global_event_bus.publish('LogMessage', { + 'message': f'开始独立运行步骤 {step_name}...', + 'level': 'info', + }) + global_event_bus.publish('LogMessage', {'message': '=' * 50, 'level': 'info'}) + + self._worker.start() + + def stop_pipeline(self): + """停止当前执行的流程。""" + if self._worker and self._worker.isRunning(): + reply = QMessageBox.question( + self.parent(), "确认", + "是否停止当前流程?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + self._worker.stop() + global_event_bus.publish('LogMessage', { + 'message': '用户取消执行', + 'level': 'warning', + }) + global_event_bus.publish('PipelineStopped', {}) + + # ═══════════════════════════════════════════════════════════ + # WorkerThread 信号 → EventBus 事件(纯转发,零 UI 操作) + # ═══════════════════════════════════════════════════════════ + + def _on_log_message(self, message: str, level: str): + """WorkerThread 日志 → EventBus LogMessage 事件。""" + global_event_bus.publish('LogMessage', { + 'message': message, + 'level': level, + }) + + def _on_progress_update(self, percentage: int, message: str): + """WorkerThread 进度 → EventBus ProgressUpdate 事件。""" + global_event_bus.publish('ProgressUpdate', { + 'percentage': percentage, + 'message': message, + }) + + def _on_step_completed(self, step_name: str, success: bool, message: str): + """WorkerThread 步骤完成 → EventBus StepCompleted 事件。 + + WorkspaceInitializer 订阅此事件,自动扫描产物并发布 OutputUpdated。 + """ + global_event_bus.publish('StepCompleted', { + 'step_name': step_name, + 'success': success, + 'message': message, + }) + + def _on_finished(self, success: bool, message: str): + """WorkerThread 完成 → EventBus PipelineFinished 事件。 + + 主窗口订阅此事件,恢复按钮状态并弹窗。 + """ + global_event_bus.publish('PipelineFinished', { + 'success': success, + 'message': message, + }) + + # ═══════════════════════════════════════════════════════════ + # 内部辅助 + # ═══════════════════════════════════════════════════════════ + + def _get_current_config(self) -> dict: + """从所有已加载面板收集配置。 + + 注意:仅收集已加载面板(懒加载模式下可能不全)。 + 如需全量配置,调用方应先执行 panel_factory.preload_all()。 + """ + config = {} + for step_id, panel in self._panel_factory.get_loaded_panels().items(): + if hasattr(panel, 'get_config'): + config[step_id] = panel.get_config() + return config + + def _precheck_step3_bands(self) -> bool: + """步骤 3 波段越界预检(主线程同步执行,避免多线程弹窗问题)。 + + 读取 step1 影像的 RasterCount,校验 step3 面板当前方法下所有波段索引 + 是否越界。若越界,弹 BandConfirmDialog(60s 倒计时)让用户调整或取消。 + + Returns: + True: 预检通过或已自动调整,继续执行 + False: 用户点"取消运行",应中止 + """ + try: + step1_panel = self._panel_factory.get_panel('step1') + step3_panel = self._panel_factory.get_panel('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: + global_event_bus.publish('LogMessage', { + 'message': f'⚠ step3 波段预检:读取面板状态失败 - {e}', + 'level': 'warning', + }) + return True + + if not step3_enabled: + return True + if not img_path or not os.path.isfile(img_path): + global_event_bus.publish('LogMessage', { + 'message': '⚠ step3 波段预检:未找到参考影像,跳过', + 'level': 'info', + }) + return True + if not step3_cfg: + return True + + try: + from osgeo import gdal + dataset = gdal.Open(img_path) + if dataset is None: + global_event_bus.publish('LogMessage', { + 'message': f'⚠ step3 波段预检:gdal 无法打开影像 {img_path}', + 'level': 'warning', + }) + return True + max_band = dataset.RasterCount + dataset = None + except Exception as e: + global_event_bus.publish('LogMessage', { + 'message': f'⚠ step3 波段预检:读取 RasterCount 失败 - {e}', + 'level': 'warning', + }) + return True + + if max_band <= 0: + return True + + 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: + return True + + 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 + + global_event_bus.publish('LogMessage', { + 'message': f'⚠ step3 波段越界:{label}={requested} > 影像波段数 {max_band}', + 'level': 'warning', + }) + + dlg = BandConfirmDialog( + self.parent(), + requested_band=requested, + max_band=max_band, + recommended_band=recommended, + method_label=label, + ) + result = dlg.exec_() + if result == QDialog.Rejected: + global_event_bus.publish('LogMessage', { + 'message': '✗ 用户取消运行(step3 波段越界未解决)', + 'level': 'warning', + }) + return False + + new_band = dlg.selected_band() + try: + spin = getattr(step3_panel, panel_attr) + spin.setValue(new_band) + except AttributeError: + global_event_bus.publish('LogMessage', { + 'message': f'⚠ step3 panel 缺控件 {panel_attr},跳过回写', + 'level': 'warning', + }) + continue + + global_event_bus.publish('LogMessage', { + 'message': f'✓ {label}:{requested} → {new_band}(影像最多 {max_band} 波段)', + 'level': 'info', + }) + + return True diff --git a/src/gui/core/workspace_initializer.py b/src/gui/core/workspace_initializer.py new file mode 100644 index 0000000..7e19d6b --- /dev/null +++ b/src/gui/core/workspace_initializer.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作空间初始化器 + +接管 WaterQualityGUI 中所有工作目录相关逻辑: +- init_workspace() 启动时工作目录选择对话框 +- set_work_directory() 手动设置工作目录 +- open_work_directory() 在资源管理器中打开工作目录 +- auto_populate_all() 扫描工作目录并自动填充所有步骤输入路径 +- _auto_fill_output_paths() step1 输出路径自动回填 + +关键设计原则: +- 配合 WorkspaceManager 进行目录扫描与产物发现 +- WorkspaceManager 扫描完成后自动发布 OutputUpdated 事件 +- 工作目录变更时发布 WorkspaceChanged 事件 +- 订阅 StepCompleted 事件,自动触发产物扫描 + +发布的事件: + WorkspaceChanged → {work_dir} 主窗口订阅:更新状态栏 + +订阅的事件: + StepCompleted → 自动调用 WorkspaceManager.update_step_outputs() +""" + +import os +import sys +from pathlib import Path +from typing import Optional + +from PyQt5.QtCore import QObject +from PyQt5.QtWidgets import QMessageBox, QFileDialog + +from src.gui.core.event_bus import global_event_bus +from src.core.workspace_manager import WorkspaceManager + +# pipeline step_id → panel step_id 映射 +PIPELINE_TO_PANEL_STEP = { + 'step1': 'step1', + 'step2': 'step2', + 'step3': 'step3', + 'step4': 'step5_clean', + 'step5': 'step6_feature', + 'step7': 'step7_index', + 'step8': 'step8_ml_train', + 'step9': 'step10_watercolor', + 'step10': 'step4_sampling', + 'step11_ml': 'step9_ml_predict', + 'step11': 'step11_map', + 'step14': 'step11_map', +} + + +class WorkspaceInitializer(QObject): + """工作空间初始化器 —— 纯逻辑层,零 UI 直接操作。""" + + def __init__(self, panel_factory, parent=None): + """ + Args: + panel_factory: PanelFactory 实例(用于获取面板和注入 work_dir) + parent: 父 QObject(通常为 WaterQualityGUI) + """ + super().__init__(parent) + self._panel_factory = panel_factory + self._work_dir: Optional[str] = None + + # 工作空间管理器(文件扫描、路径发现) + self._workspace_manager = WorkspaceManager() + self._workspace_manager.set_step_id_mapping(PIPELINE_TO_PANEL_STEP) + + # 订阅 StepCompleted 事件 → 自动扫描产物 + global_event_bus.subscribe('StepCompleted', self._on_step_completed) + + # ═══════════════════════════════════════════════════════════ + # 公开 API + # ═══════════════════════════════════════════════════════════ + + @property + def work_dir(self) -> Optional[str]: + return self._work_dir + + @work_dir.setter + def work_dir(self, value: str): + if value and value != self._work_dir: + self._work_dir = value + global_event_bus.publish('WorkspaceChanged', {'work_dir': value}) + + @property + def workspace_manager(self) -> WorkspaceManager: + return self._workspace_manager + + def run(self): + """启动时工作目录选择对话框(由 QTimer.singleShot 延迟调用)。 + + 若用户取消或关闭对话框,程序退出。 + """ + 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.parent(), "选择工作目录", "", + QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks + ) + + if not work_dir: + QMessageBox.critical(self.parent(), "错误", "必须选择工作目录才能使用系统!\n程序即将退出。") + sys.exit(0) + + self.work_dir = work_dir + global_event_bus.publish('LogMessage', { + 'message': f'✓ 已选择工作目录: {self._work_dir}', + 'level': 'info', + }) + + # 自动填充 step1 输出路径 + self._auto_fill_output_paths() + + def set_work_directory(self): + """手动设置工作目录(菜单触发)。""" + dir_path = QFileDialog.getExistingDirectory(self.parent(), "选择工作目录") + if dir_path: + self.work_dir = dir_path + global_event_bus.publish('LogMessage', { + 'message': f'工作目录已设置: {dir_path}', + 'level': 'info', + }) + + # 同步到可视化/报告面板 + for sid in ('step12_viz', 'step13_report'): + panel = self._panel_factory.get_panel(sid) + if panel and hasattr(panel, 'set_work_dir'): + panel.set_work_dir(dir_path) + + def open_work_directory(self): + """在资源管理器中打开工作目录。""" + work_dir = self._work_dir or './work_dir' + if os.path.exists(work_dir): + os.startfile(work_dir) + else: + QMessageBox.warning(self.parent(), "警告", "工作目录不存在!") + + def auto_populate_all(self): + """扫描工作目录并触发事件总线自动填充所有步骤的输入路径。 + + 流程: + 1. WorkspaceManager.scan_work_directory_for_files() 扫描磁盘 + 2. WorkspaceManager 内部自动发布 OutputUpdated 事件 + 3. 各面板通过 DependencySubscriber 自动接收并填充 + 4. 补充发布 reference_img(step1 的输入影像) + """ + work_dir = self._work_dir + if not work_dir: + QMessageBox.warning(self.parent(), "警告", "未设置工作目录,请先设置。") + return + + work_path = Path(work_dir) + if not work_path.exists(): + QMessageBox.warning(self.parent(), "警告", f"工作目录不存在: {work_dir}\n请先设置正确的工作目录。") + return + + # WorkspaceManager 扫描 → 内部自动发布 OutputUpdated 事件 + self._workspace_manager.scan_work_directory_for_files(work_path) + + # 补充发布 reference_img(step1 的输入影像,非 step1 产出但下游依赖它) + step1_panel = self._panel_factory.get_panel('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, + }) + + global_event_bus.publish('LogMessage', { + 'message': '✓ 工作目录扫描完成,事件总线已通知所有面板自动填充', + 'level': 'info', + }) + + # ═══════════════════════════════════════════════════════════ + # EventBus 订阅回调 + # ═══════════════════════════════════════════════════════════ + + def _on_step_completed(self, data: dict): + """StepCompleted 事件回调:自动扫描产物并发布 OutputUpdated。 + + 当 PipelineExecutor 发布 StepCompleted 事件时, + 此方法调用 WorkspaceManager.update_step_outputs() 扫描磁盘, + WorkspaceManager 内部自动发布 OutputUpdated 事件驱动面板填充。 + """ + if not data.get('success'): + return + + step_name = data.get('step_name', '') + work_dir = self._work_dir or './work_dir' + work_path = Path(work_dir) + + if step_name not in self._workspace_manager.step_outputs: + self._workspace_manager.step_outputs[step_name] = {} + + self._workspace_manager.update_step_outputs(step_name, work_path) + + # ═══════════════════════════════════════════════════════════ + # 内部辅助 + # ═══════════════════════════════════════════════════════════ + + def _auto_fill_output_paths(self): + """根据工作目录自动填充 step1 的输出路径。 + + 注意:Step1 的输出路径由 update_work_directory() 根据模式自动控制。 + """ + if not self._work_dir: + return + + step1_panel = self._panel_factory.get_panel('step1') + if step1_panel: + step1_panel.update_work_directory(self._work_dir) diff --git a/src/gui/water_quality_gui_v2.py b/src/gui/water_quality_gui_v2.py new file mode 100644 index 0000000..2ffc76b --- /dev/null +++ b/src/gui/water_quality_gui_v2.py @@ -0,0 +1,724 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +水质参数反演分析系统 - 图形用户界面(重构版:纯壳模式) + +WaterQualityGUI 只负责窗口框架(标题栏、菜单栏、状态栏、QTabWidget), +所有业务逻辑委托给独立的 Manager 类。 + +已实现的 Manager: +- PanelFactory → 面板懒加载与生命周期 +- PipelineExecutor → Pipeline 执行/停止/回调(通过 EventBus 发布状态) +- WorkspaceInitializer → 工作目录选择 + 自动回填(通过 EventBus 广播) + +待实现的 Manager(下一批): +- ConfigManager → 配置读写 +- LogManager → 日志 + 进度条 +- TrainingModeManager → 训练模式切换 +- DialogService → 各类对话框 +""" + +import osgeo # noqa: F401 +from osgeo import gdal, ogr # noqa: F401 + +import os +import sys +import ctypes +import traceback +import multiprocessing +from datetime import datetime + +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QLabel, QTabWidget, QToolBar, QSizePolicy, + QListWidget, QListWidgetItem, QGroupBox, + QTextEdit, QProgressBar, QMessageBox, QFileDialog, +) +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QIcon, QFont, QPixmap, QColor, QTextCursor + +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: + 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: + QMessageBox.critical(None, "程序崩溃", + f"错误类型: {exc_type.__name__}\n错误信息: {exc_value}\n详细信息已写入: {dump_path}") + except Exception: + pass + + +sys.excepthook = global_exception_handler + + +class WaterQualityGUI(QMainWindow): + """水质参数反演分析系统主窗口 —— 纯壳模式。 + + 职责边界: + - 窗口框架(标题栏、菜单栏、状态栏、导航栏) + - QTabWidget 托管(通过 PanelFactory 懒加载) + - 日志区 + 进度条(UI 控件归 shell,内容由 EventBus 驱动) + - 各 Manager 的创建与 EventBus 连线 + """ + + def __init__(self): + my_appid = u'mycompany.megacube.waterquality.v1' + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_appid) + super().__init__() + + icon_path = get_resource_path("data/icons-1/uitubiao.ico") + self.setWindowIcon(QIcon(icon_path)) + + # 第一步:创建各 Manager(纯连线,不执行业务) + self._init_managers() + + # 第二步:构建窗口壳 + self._init_shell() + + # 第三步:订阅 EventBus 事件 → 驱动 UI 更新 + self._wire_event_bus() + + # 第四步:应用样式 + 禁用滚轮 + self._apply_stylesheet() + self._disable_wheel_for_all_spinboxes() + + # 第五步:延迟启动工作目录选择 + QTimer.singleShot(100, self._workspace_initializer.run) + + # ================================================================ + # Manager 初始化 + # ================================================================ + + def _init_managers(self): + from src.gui.core.panel_factory import PanelFactory + from src.gui.core.panel_registry import PANEL_REGISTRY + from src.gui.core.workspace_initializer import WorkspaceInitializer + from src.gui.core.pipeline_executor import PipelineExecutor + from src.gui.core.event_bus import global_event_bus + + self._panel_factory = PanelFactory( + registry=PANEL_REGISTRY, + main_window=self, + preload_window=1, + ) + + self._workspace_initializer = WorkspaceInitializer( + panel_factory=self._panel_factory, + parent=self, + ) + + self._pipeline_executor = PipelineExecutor( + panel_factory=self._panel_factory, + workspace_initializer=self._workspace_initializer, + parent=self, + ) + + self._event_bus = global_event_bus + + # ================================================================ + # 窗口壳构建 + # ================================================================ + + def _init_shell(self): + self.setWindowTitle("MegaCube-Water Quality V1.2") + + screen_geometry = QApplication.primaryScreen().availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + self.resize(1200, screen_height) + self.move((screen_width - 1200) // 2, 0) + self.setMinimumSize(600, 400) + + self._create_title_bar() + self._create_banner() + self._create_central_layout() + self.statusBar().showMessage("就绪") + + def _create_title_bar(self): + title_widget = QWidget() + title_layout = QHBoxLayout() + title_layout.setContentsMargins(8, 4, 8, 4) + title_layout.setSpacing(0) + + logo_label = QLabel() + logo_label.setFixedSize(180, 48) + logo_label.setAlignment(Qt.AlignCenter) + logo_label.setStyleSheet( + "background-color: #f8f9fa;" + "border-top-left-radius: 4px; border-bottom-left-radius: 4px;" + ) + logo_path = get_resource_path("data/icons/logo.png") + logo_pixmap = QPixmap(logo_path) + if not logo_pixmap.isNull(): + logo_label.setPixmap(logo_pixmap.scaledToHeight(38, Qt.SmoothTransformation)) + else: + logo_label.setText("Logo") + title_layout.addWidget(logo_label) + + menubar = self.menuBar() + menubar.setStyleSheet(""" + QMenuBar { background-color: #f8f9fa; border: none; padding: 4px 8px; } + QMenuBar::item { padding: 6px 12px; font-size: 13px; } + QMenuBar::item:selected { background-color: #e6f0ff; border-radius: 3px; } + """) + self._build_menus(menubar) + title_layout.addWidget(menubar) + + title_widget.setLayout(title_layout) + title_widget.setStyleSheet( + "background-color: #f8f9fa; border-bottom: 1px solid #d0d0d0;" + ) + self.setMenuWidget(title_widget) + + def _build_menus(self, menubar): + file_menu = menubar.addMenu("文件") + file_menu.addAction("新建配置").triggered.connect(self._on_new_config) + file_menu.addAction("打开配置").triggered.connect(self._on_load_config) + file_menu.addAction("保存配置").triggered.connect(self._on_save_config) + file_menu.addSeparator() + file_menu.addAction("退出").triggered.connect(self.close) + + tools_menu = menubar.addMenu("工具") + tools_menu.addAction("设置工作目录").triggered.connect( + self._workspace_initializer.set_work_directory + ) + tools_menu.addAction("打开工作目录").triggered.connect( + self._workspace_initializer.open_work_directory + ) + tools_menu.addSeparator() + tools_menu.addAction("AI 引擎配置...").triggered.connect(self._on_ai_settings) + tools_menu.addSeparator() + tools_menu.addAction("自动填充所有输入路径").triggered.connect( + self._workspace_initializer.auto_populate_all + ) + + 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._on_toggle_training_mode) + + help_menu = menubar.addMenu("帮助") + help_menu.addAction("检查Pipeline状态").triggered.connect(self._on_show_pipeline_status) + help_menu.addSeparator() + help_menu.addAction("关于").triggered.connect(self._on_show_about) + + def _create_banner(self): + 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) + + 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) + + banner_layout.addWidget(self._banner_label) + + self._banner_title_label = QLabel("MegaCube-Water Quality V1.2", self._banner_label) + self._banner_title_label.setStyleSheet(""" + QLabel { + background: transparent; color: white; font-size: 48px; + font-family: "Times New Roman", "Georgia", serif; + } + """) + 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.setStyleSheet( + "QToolBar { background: white; border: none; padding: 0px; margin: 0px; }" + ) + self.addToolBar(Qt.TopToolBarArea, banner_toolbar) + + def _create_central_layout(self): + central_widget = QWidget() + main_layout = QHBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + main_layout.addWidget(self._create_navigation(), 1) + + right_widget = QWidget() + right_layout = QVBoxLayout() + right_layout.setContentsMargins(15, 15, 15, 15) + right_layout.setSpacing(10) + + self._tab_widget = self._panel_factory.create_tab_widget(icons_dir="data/icons") + self._tab_widget.currentChanged.connect(self._on_tab_changed) + right_layout.addWidget(self._tab_widget, 3) + + right_layout.addWidget(self._create_log_panel(), 1) + right_layout.addWidget(self._create_progress_panel(), 0) + + right_widget.setLayout(right_layout) + main_layout.addWidget(right_widget, 4) + + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) + + def _create_navigation(self): + from src.gui.core.panel_registry import build_stage_groups + from src.gui.styles import ModernStylesheet + + 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()) + + process_stages = build_stage_groups() + stage_names = list(process_stages.keys()) + + for stage_idx, (stage_name, steps) in enumerate(process_stages.items()): + stage_item = QListWidgetItem(stage_name) + stage_font = QFont("Arial", 11, QFont.Bold) + stage_item.setFont(stage_font) + stage_item.setForeground(QColor(ModernStylesheet.COLORS.get('accent', '#0078D4'))) + stage_item.setFlags(stage_item.flags() & ~Qt.ItemIsSelectable) + stage_item.setFlags(stage_item.flags() & ~Qt.ItemIsEnabled) + stage_item.setData(Qt.UserRole, "stage_header") + self._step_list.addItem(stage_item) + + for step_id, step_display in steps: + item = QListWidgetItem(f" └─ {step_display}") + item.setData(Qt.UserRole, step_id) + 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: + sep = QListWidgetItem("") + sep.setFlags(sep.flags() & ~Qt.ItemIsSelectable) + sep.setFlags(sep.flags() & ~Qt.ItemIsEnabled) + self._step_list.addItem(sep) + + self._step_list.currentRowChanged.connect(self._on_step_list_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._pipeline_executor.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._pipeline_executor.stop_pipeline) + btn_layout.addWidget(self._stop_btn) + + nav_layout.addLayout(btn_layout) + nav_widget.setLayout(nav_layout) + nav_widget.setMaximumWidth(280) + nav_widget.setStyleSheet( + f"background-color: {ModernStylesheet.COLORS['panel_bg']};" + f"border-right: 1px solid {ModernStylesheet.COLORS['border_light']};" + ) + return nav_widget + + def _create_log_panel(self): + from src.gui.styles import ModernStylesheet + + 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) + + clear_btn = QPushButton("清空日志") + clear_btn.setMaximumWidth(100) + clear_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal')) + clear_btn.clicked.connect(self._log_text.clear) + + btn_row = QHBoxLayout() + btn_row.addWidget(clear_btn) + btn_row.addStretch() + log_layout.addLayout(btn_row) + + log_group.setLayout(log_layout) + return log_group + + def _create_progress_panel(self): + from src.gui.styles import ModernStylesheet + + 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) + return progress_group + + # ================================================================ + # EventBus 连线(UI 状态由事件驱动) + # ================================================================ + + def _wire_event_bus(self): + self._event_bus.subscribe('PipelineStarted', self._on_pipeline_started) + self._event_bus.subscribe('PipelineFinished', self._on_pipeline_finished) + self._event_bus.subscribe('PipelineStopped', self._on_pipeline_stopped) + self._event_bus.subscribe('LogMessage', self._on_log_message) + self._event_bus.subscribe('ProgressUpdate', self._on_progress_update) + self._event_bus.subscribe('NavigateToTab', self._on_navigate_to_tab) + self._event_bus.subscribe('WorkspaceChanged', self._on_workspace_changed) + + def _on_pipeline_started(self, data): + self._run_all_btn.setEnabled(False) + self._stop_btn.setEnabled(True) + self._progress_bar.setValue(0) + + def _on_pipeline_finished(self, data): + self._run_all_btn.setEnabled(True) + self._stop_btn.setEnabled(False) + success = data.get('success', False) + message = data.get('message', '') + if success: + self._progress_bar.setValue(100) + QMessageBox.information(self, "完成", "流程执行成功!\n\n请查看工作目录中的结果文件。") + else: + QMessageBox.critical(self, "失败", f"流程执行失败:\n\n{message[:200]}") + + def _on_pipeline_stopped(self, data): + self._run_all_btn.setEnabled(True) + self._stop_btn.setEnabled(False) + + def _on_log_message(self, data): + message = data.get('message', '') + level = data.get('level', 'info') + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + color_map = {'error': 'red', 'warning': 'orange'} + color = color_map.get(level, 'black') + formatted = f'[{timestamp}] {message}' + self._log_text.append(formatted) + cursor = self._log_text.textCursor() + cursor.movePosition(QTextCursor.End) + self._log_text.setTextCursor(cursor) + + def _on_progress_update(self, data): + percentage = data.get('percentage', 0) + message = data.get('message', '') + self._progress_bar.setValue(percentage) + self.statusBar().showMessage(message) + + def _on_navigate_to_tab(self, data): + tab_index = data.get('tab_index', 0) + if 0 <= tab_index < self._tab_widget.count(): + self._tab_widget.setCurrentIndex(tab_index) + + def _on_workspace_changed(self, data): + work_dir = data.get('work_dir', '') + self.statusBar().showMessage(f"工作目录: {work_dir}") + + # ================================================================ + # 导航 ↔ Tab 双向同步(纯 UI 路由) + # ================================================================ + + def _on_step_list_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 + from src.gui.core.panel_registry import get_tab_index + tab_index = get_tab_index(item_data) + if tab_index >= 0: + self._tab_widget.setCurrentIndex(tab_index) + + def _on_tab_changed(self, index): + if index < 0: + return + from src.gui.core.panel_registry import get_step_id_by_tab_index + target_step_id = get_step_id_by_tab_index(index) + if target_step_id is None: + return + for row in range(self._step_list.count()): + item = self._step_list.item(row) + if not item: + continue + if item.data(Qt.UserRole) == target_step_id: + self._step_list.setCurrentRow(row) + break + + # ================================================================ + # 横幅自适应 + # ================================================================ + + def _update_banner_image(self): + if not hasattr(self, '_banner_pixmap') or self._banner_pixmap.isNull(): + return + TARGET_HEIGHT = 140 + target_width = self.width() + 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 = 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 = scaled.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) + 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) + self._update_banner_image() + + # ================================================================ + # 样式与 UX + # ================================================================ + + def _apply_stylesheet(self): + from src.gui.styles import ModernStylesheet + self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) + + def _disable_wheel_for_all_spinboxes(self): + from PyQt5.QtWidgets import QSpinBox, QDoubleSpinBox, QComboBox + for sb in self.findChildren(QSpinBox): + sb.setFocusPolicy(Qt.StrongFocus) + sb.wheelEvent = lambda e, s=sb: None + for sb in self.findChildren(QDoubleSpinBox): + sb.setFocusPolicy(Qt.StrongFocus) + sb.wheelEvent = lambda e, s=sb: None + for cb in self.findChildren(QComboBox): + cb.setFocusPolicy(Qt.StrongFocus) + cb.wheelEvent = lambda e, c=cb: None + + # ================================================================ + # 菜单回调(待提取到对应 Manager 的临时占位) + # ================================================================ + + def _on_new_config(self): + reply = QMessageBox.question(self, "新建配置", "是否清空当前配置?", + QMessageBox.Yes | QMessageBox.No) + if reply == QMessageBox.Yes: + self._event_bus.publish('LogMessage', {'message': '已清空配置', 'level': 'info'}) + + def _on_load_config(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "加载配置", "", "JSON Files (*.json);;All Files (*.*)") + if file_path: + self._event_bus.publish('LogMessage', + {'message': f'已加载配置: {file_path}', 'level': 'info'}) + + def _on_save_config(self): + file_path, _ = QFileDialog.getSaveFileName( + self, "保存配置", "config.json", "JSON Files (*.json);;All Files (*.*)") + if file_path: + self._event_bus.publish('LogMessage', + {'message': f'已保存配置: {file_path}', 'level': 'info'}) + + def _on_ai_settings(self): + from src.gui.dialogs import AISettingsDialog + AISettingsDialog(self).exec_() + + def _on_toggle_training_mode(self, checked): + self._event_bus.publish('LogMessage', { + 'message': f'切换到{"有训练数据" if checked else "无训练数据"}模式', + 'level': 'info', + }) + self._training_mode_action.setText("有训练数据模式" if checked else "无训练数据模式") + + def _on_show_pipeline_status(self): + from src.gui.core.worker_thread import PIPELINE_AVAILABLE, PIPELINE_ERROR_INFO + if PIPELINE_AVAILABLE: + QMessageBox.information(self, "Pipeline状态", "Pipeline模块: 正常加载") + else: + detail = "\n".join(PIPELINE_ERROR_INFO) + QMessageBox.warning(self, "Pipeline状态", f"Pipeline模块无法加载\n\n{detail}") + + def _on_show_about(self): + QMessageBox.about(self, "关于", + "MegaCube-Water Quality V1.2\n\n" + "一个完整的水质参数反演工作流程工具\n\n" + "公司:北京依锐思遥感技术有限公司\n" + "地址:北京市海淀区清河安宁庄东路18号5号楼二层205\n" + "电话:010-51292601\n" + "邮箱:hanshanlong@iris-rs.cn") + + # ================================================================ + # 向后兼容属性 + # ================================================================ + + @property + def panels(self): + return self._panel_factory.get_loaded_panels() + + @property + def work_dir(self): + return self._workspace_initializer.work_dir + + @work_dir.setter + def work_dir(self, value): + self._workspace_initializer.work_dir = value + + @property + def pipeline(self): + return None + + @property + def worker(self): + return self._pipeline_executor.worker + + def log_message(self, message, level='info'): + self._event_bus.publish('LogMessage', {'message': message, 'level': level}) + + def update_progress(self, percentage, message): + self._event_bus.publish('ProgressUpdate', {'percentage': percentage, 'message': message}) + + def get_current_config(self): + config = {} + for step_id, panel in self._panel_factory.get_loaded_panels().items(): + if hasattr(panel, 'get_config'): + config[step_id] = panel.get_config() + return config + + +# ============================================================ +def main(): + if multiprocessing.current_process().name != 'MainProcess': + sys.exit(0) + + app = QApplication.instance() + if not app: + app = QApplication(sys.argv) + app.setApplicationName("Mega Water") + app.setOrganizationName("WaterQuality") + + try: + from src.auth.license_manager import verify_license + from src.auth.license_dialog import LicenseDialog + except ImportError: + _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: + LicenseDialog().exec_() + sys.exit(0) + + window = WaterQualityGUI() + window.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + if multiprocessing.current_process().name != 'MainProcess': + sys.exit(0) + try: + multiprocessing.freeze_support() + except Exception: + pass + main()