From 48d17ef0ca3bd8bbca50a3b20ab7de9401a59708 Mon Sep 17 00:00:00 2001 From: duxin Date: Tue, 30 Jun 2026 09:38:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=A8=E5=B1=80UX=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=8EStep4=E4=BA=A4=E4=BA=92=E5=8F=AF=E8=A7=86=E5=8C=96?= =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 自动填入过于激进(幽灵路径级联)=== - 所有面板 update_from_config 移除 os.makedirs(),目录创建留给 pipeline 执行 - 输出路径仅 widget 为空时填入默认值,不覆盖用户已选 - 输入路径从上游读取后添加 os.path.exists() 检查,阻断幽灵路径级联 - panel_factory._replay_live_panel_inputs 广播前校验文件确实存在 - step10 update_from_config 添加 os.path.isfile() 存在性检查 - 清理 step8/9/11 中冗余局部 import os(修复 UnboundLocalError) === 输出目录缺失 === - step7 新增 output_file FileSelectWidget,默认路径 7_Water_Quality_Indices/ - step9 output_file 从文件模式改为目录模式 (Directories) === 空目录自动创建 === - step12 _setup_prediction_output_dirs 移除 mkdir() 调用,改为只读日志 === 过期依赖与缺失 import === - panel_registry Step10 依赖 bsq_file→sampling_csv_file(匹配 CSV 模式重构) - step7 添加缺失的 import pandas as pd(修复 NameError) === 导航与 UI 一致性 === - water_quality_gui_v2 新增 _select_first_nav_item(),启动时默认选中第一项 - step1 输出卡片对齐 step8 风格 - step8 补充缺失的样式表和统一边距 === Step4 交互式光谱探针重构 === - 左右分栏 QSplitter 布局:左侧控制区 + 右侧 Matplotlib 视图 - 1x2 子图:ax1 散点图 + ax2 光谱曲线 - Hover 悬停 Annotation 显示坐标,Click 点击高亮+绘制光谱 - NavigationToolbar2QT 工具栏(保存/缩放/平移) - 自动检测坐标列和波段列,完善异常处理 --- src/core/workspace_manager.py | 43 +- src/gui/core/dependency_subscriber.py | 69 ++- src/gui/core/event_bus.py | 49 ++- src/gui/core/panel_factory.py | 63 ++- src/gui/core/panel_registry.py | 7 +- src/gui/core/preflight_dialog.py | 106 +++-- src/gui/core/worker_thread.py | 48 +- src/gui/panels/step10_watercolor_panel.py | 19 +- src/gui/panels/step11_map_panel.py | 17 +- src/gui/panels/step12_viz_panel.py | 19 +- src/gui/panels/step1_panel.py | 12 +- src/gui/panels/step2_panel.py | 12 +- src/gui/panels/step3_panel.py | 12 +- src/gui/panels/step4_sampling_panel.py | 505 ++++++++++++++++++++-- src/gui/panels/step5_clean_panel.py | 5 +- src/gui/panels/step6_feature_panel.py | 15 +- src/gui/panels/step7_inversion_panel.py | 28 +- src/gui/panels/step8_ml_train_panel.py | 15 +- src/gui/panels/step9_ml_predict_panel.py | 36 +- src/gui/water_quality_gui_v2.py | 31 +- 20 files changed, 910 insertions(+), 201 deletions(-) diff --git a/src/core/workspace_manager.py b/src/core/workspace_manager.py index ac2a9b2..77c220a 100644 --- a/src/core/workspace_manager.py +++ b/src/core/workspace_manager.py @@ -183,26 +183,35 @@ class WorkspaceManager: for file_path in subdir_path.rglob('*'): if file_path.is_file(): file_name = file_path.name.lower() + file_stem = file_path.stem.lower() for step_id in step_ids: if step_id not in discovered_outputs: discovered_outputs[step_id] = {} - if 'water_mask' in file_name and step_id == 'step1': - if self._is_scientific_mask(file_path): + # 2026-06-30 加强匹配:用更精确的边界匹配替代简单的子串包含 + # water_mask 匹配:必须是独立文件名的一部分(如 water_mask_out.dat, water_mask_from_ndwi.dat) + if step_id == 'step1' and self._is_scientific_mask(file_path): + if ('water_mask' in file_stem + and 'glint' not in file_stem): discovered_outputs[step_id]['water_mask'] = str(file_path) - elif 'glint' in file_name and 'mask' in file_name and step_id == 'step2': - if self._is_scientific_mask(file_path): + # glint_mask 匹配:必须同时含 glint 且文件名主体以 mask 或 area 结尾 + elif step_id == 'step2' and self._is_scientific_mask(file_path): + if ('glint' in file_stem and + (file_stem.endswith('mask') or file_stem.endswith('area') + or 'severe_glint' in file_stem)): discovered_outputs[step_id]['glint_mask'] = str(file_path) - elif 'deglint' in file_name and step_id == 'step3': + elif 'deglint' in file_stem and step_id == 'step3': discovered_outputs[step_id]['deglint_image'] = str(file_path) - elif 'processed_data' in file_name and step_id == 'step4_sampling': + elif file_name == 'processed_data.csv' and step_id == 'step5_clean': discovered_outputs[step_id]['processed_data'] = str(file_path) - elif 'training_spectra' in file_name and step_id == 'step5_clean': + elif ('training_spectra' in file_stem and file_path.suffix == '.csv' + and step_id == 'step6_feature'): discovered_outputs[step_id]['training_spectra'] = str(file_path) - elif 'water_quality_indices' in file_name and step_id == 'step6_feature': + elif ('water_quality_indices' in file_stem and file_path.suffix == '.csv' + and step_id == 'step7_index'): discovered_outputs[step_id]['water_indices'] = str(file_path) - elif 'sampling_spectra' in file_name and step_id == 'step4_sampling': + elif file_name == 'sampling_spectra.csv' and step_id == 'step4_sampling': discovered_outputs[step_id]['sampling_points'] = str(file_path) elif file_name.endswith('.csv') and step_id in ['step9_ml_predict', 'step11_map', 'step12_viz']: discovered_outputs[step_id]['predictions'] = str(file_path) @@ -255,13 +264,16 @@ class WorkspaceManager: def prune_config_for_prediction_mode(config: dict) -> dict: """Prediction-only 模式:禁用训练相关步骤,保留预测和成图步骤。 + 2026-06-30 修复:步骤 ID 从旧的 PIPELINE_STEPS 体系改为 PANEL_REGISTRY 体系, + 确保与 get_current_config() 返回的 key 一致,避免 enabled: False 写入无效 key。 + 被禁用的 step dict 中统一写入 'enabled': False, 这些配置最终传给 PipelineRunner,Runner 会跳过它们。 同时,被跳过的步骤的 required_input_files 在 build_missing_items 中不会被检查,从而自然规避了"CSV 缺失"等训练模式下的误报。 Args: - config: 完整配置字典(来自 get_current_config) + config: 完整配置字典(来自 get_current_config,key 为 PANEL_REGISTRY step_id) Returns: 裁剪后的 config(深拷贝,原 config 不被修改) @@ -269,12 +281,11 @@ class WorkspaceManager: cfg = copy.deepcopy(config) training_steps = [ - "step4", - "step5", - "step7", - "step6", - "step8_non_empirical_modeling", - "step9", + "step4_sampling", + "step5_clean", + "step6_feature", + "step7_index", + "step8_ml_train", ] for step_id in training_steps: step_cfg = cfg.setdefault(step_id, {}) diff --git a/src/gui/core/dependency_subscriber.py b/src/gui/core/dependency_subscriber.py index 41063b0..3dd8ba8 100644 --- a/src/gui/core/dependency_subscriber.py +++ b/src/gui/core/dependency_subscriber.py @@ -13,15 +13,24 @@ PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus 内含: - 自动识别下游 widget(按 dict 键名查找) +- sip.isdeleted() 保护:面板销毁后回调自动跳过,避免 C++ 野指针 Segfault - 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径 - 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录 + +2026-06-30 修复: + - 添加 sip.isdeleted(panel) 检查,防止面板删除后访问 C++ 对象导致崩溃 """ import os +import sip from src.gui.core.event_bus import global_event_bus +# 存储订阅回调引用,供 unsubscribe_panel_from_dependencies 使用 +_subscription_registry = {} # {id(panel): [(event_name, callback), ...]} + + def subscribe_panel_to_dependencies(panel, step_id, dependencies): """为面板订阅其依赖的上游步骤产出事件。 @@ -38,15 +47,42 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies): if not dependencies: return - # 注意:我们使用字典的键名 (_input_field) 作为唯一的下游目标框名查找依据 + panel_id = id(panel) + if panel_id not in _subscription_registry: + _subscription_registry[panel_id] = [] + for _input_field, (dep_step, output_type, source_panel_attr) in dependencies.items(): - _make_subscription(panel, dep_step, output_type, _input_field) + callback = _make_callback(panel, dep_step, output_type, _input_field) + global_event_bus.subscribe('OutputUpdated', callback) + _subscription_registry[panel_id].append(('OutputUpdated', callback)) -def _make_subscription(panel, dep_step, output_type, target_widget_name): - """为单个依赖项创建事件订阅。使用工厂函数避免闭包变量延迟绑定。""" +def unsubscribe_panel_from_dependencies(panel): + """取消面板的所有依赖订阅。应在面板销毁前调用,防止野指针回调。 + + Args: + panel: 要取消订阅的步骤面板实例 + """ + panel_id = id(panel) + subscriptions = _subscription_registry.pop(panel_id, []) + for event_name, callback in subscriptions: + global_event_bus.unsubscribe(event_name, callback) + + +def _make_callback(panel, dep_step, output_type, target_widget_name): + """为单个依赖项创建事件回调。返回闭包函数。 + + 使用工厂函数确保每个回调的闭包变量独立绑定。 + """ def callback(data): + # ★ 防野指针:面板底层 C++ 对象已销毁时直接跳过 + try: + if sip.isdeleted(panel): + return + except Exception: + return + if data.get('step_id') != dep_step: return if data.get('output_type') != output_type: @@ -57,10 +93,14 @@ def _make_subscription(panel, dep_step, output_type, target_widget_name): return current = '' - if hasattr(widget, 'get_path'): - current = widget.get_path().strip() - elif hasattr(widget, 'text'): - current = widget.text().strip() + try: + if hasattr(widget, 'get_path'): + current = widget.get_path().strip() + elif hasattr(widget, 'text'): + current = widget.text().strip() + except RuntimeError: + # C++ 对象已被删除 + return if current: return @@ -73,9 +113,12 @@ def _make_subscription(panel, dep_step, output_type, target_widget_name): if 'dir' in target_widget_name.lower() and os.path.isfile(path): path = os.path.dirname(path) - if hasattr(widget, 'set_path'): - widget.set_path(path) - elif hasattr(widget, 'setText'): - widget.setText(path) + try: + if hasattr(widget, 'set_path'): + widget.set_path(path) + elif hasattr(widget, 'setText'): + widget.setText(path) + except RuntimeError: + pass # C++ 对象已被删除 - global_event_bus.subscribe('OutputUpdated', callback) + return callback diff --git a/src/gui/core/event_bus.py b/src/gui/core/event_bus.py index b8f407f..b62a795 100644 --- a/src/gui/core/event_bus.py +++ b/src/gui/core/event_bus.py @@ -3,12 +3,17 @@ """ 轻量级事件总线 -支持 subscribe(event_name, callback) 和 publish(event_name, data), -用于步骤面板间的去中心化参数传导。 +支持 subscribe(event_name, callback)、unsubscribe(event_name, callback) +和 publish(event_name, data),用于步骤面板间的去中心化参数传导。 + +2026-06-30 修复: + - publish() 中的 except Exception: pass 改为 traceback 日志输出(静默吞异常会导致 bug 无法发现) + - 新增 unsubscribe() 方法,允许面板销毁时清理订阅,防止内存泄漏和野指针回调 """ +import traceback from collections import defaultdict -from typing import Any, Callable, Dict, List +from typing import Any, Callable, Dict, List, Optional class EventBus: @@ -16,18 +21,48 @@ class EventBus: def __init__(self): self._subscribers: Dict[str, List[Callable]] = defaultdict(list) + # 异常日志回调(可选注入,供 LogManager 使用) + self._error_logger: Optional[Callable[[str], None]] = None + + def set_error_logger(self, logger: Callable[[str], None]): + """注入异常日志回调。若不设置,异常信息输出到 stderr。""" + self._error_logger = logger def subscribe(self, event_name: str, callback: Callable[[dict], None]): """订阅事件。callback 接收一个 dict 作为事件数据。""" - self._subscribers[event_name].append(callback) + if callback not in self._subscribers[event_name]: + self._subscribers[event_name].append(callback) + + def unsubscribe(self, event_name: str, callback: Callable[[dict], None]): + """取消订阅。面板销毁或不再需要接收事件时调用,防止野指针回调。""" + subs = self._subscribers.get(event_name) + if subs and callback in subs: + subs.remove(callback) + # 清理空列表,避免字典膨胀 + if not subs: + del self._subscribers[event_name] def publish(self, event_name: str, data: Dict[str, Any]): - """发布事件,通知所有订阅者。""" - for callback in self._subscribers.get(event_name, []): + """发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。 + + 迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。 + """ + for callback in list(self._subscribers.get(event_name, [])): try: callback(data) except Exception: - pass + err_msg = ( + f"[EventBus] 事件 '{event_name}' 的订阅者 {callback.__name__!r} 抛出异常:\n" + + traceback.format_exc() + ) + if self._error_logger: + try: + self._error_logger(err_msg) + except Exception: + pass + else: + import sys + print(err_msg, file=sys.stderr, flush=True) # 全局单例 diff --git a/src/gui/core/panel_factory.py b/src/gui/core/panel_factory.py index 55d1ad5..3351c23 100644 --- a/src/gui/core/panel_factory.py +++ b/src/gui/core/panel_factory.py @@ -15,7 +15,10 @@ - 占位页:未加载的 tab 显示空白 QWidget,加载后原地替换为 QScrollArea(panel) """ -from PyQt5.QtWidgets import QWidget, QTabWidget, QScrollArea +import os +import sys + +from PyQt5.QtWidgets import QWidget, QTabWidget, QScrollArea, QSpinBox, QDoubleSpinBox, QComboBox from PyQt5.QtCore import Qt from src.gui.core.panel_registry import PANEL_REGISTRY @@ -23,6 +26,31 @@ from src.gui.core.dependency_subscriber import subscribe_panel_to_dependencies from src.gui.core.event_bus import global_event_bus +def _get_resource_path(relative_path: str) -> str: + """资源路径解析(内联版,避免从已废弃的 water_quality_gui.py 导入)。 + + 2026-06-30:从 water_quality_gui.py 迁移到此处,打破对旧文件的依赖。 + 注意:panel_factory.py 在 src/gui/core/ 下,比 water_quality_gui.py 深一层, + 因此需要 4 次 dirname 才能到达项目根目录。 + """ + 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(os.path.dirname(__file__)))), relative_path) + ) + + +def _disable_wheel_for_widget(widget): + """禁用 widget 及其子控件的滚轮事件(防止误触改值)。 + + 递归处理 QSpinBox / QDoubleSpinBox / QComboBox。 + 2026-06-30:提取为独立函数,方便懒加载面板创建后调用。 + """ + for child in widget.findChildren((QSpinBox, QDoubleSpinBox, QComboBox)): + child.setFocusPolicy(Qt.StrongFocus) + child.wheelEvent = lambda e, w=child: None + + class PanelFactory: """面板注册与装载工厂。 @@ -64,12 +92,11 @@ class PanelFactory: 同时连接 currentChanged 信号驱动懒加载 + 邻接预加载。 Args: - icons_dir: 图标目录名(相对于项目根),用于 get_resource_path + 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() @@ -85,7 +112,7 @@ class PanelFactory: placeholder = QWidget() self._placeholders[idx] = placeholder - icon_path = get_resource_path(f"{icons_dir}/{icon_name}") + icon_path = _get_resource_path(f"{icons_dir}/{icon_name}") self._tab_widget.addTab(placeholder, QIcon(icon_path), title) # 连接切换信号 → 懒加载 @@ -182,6 +209,9 @@ class PanelFactory: self._panels[step_id] = panel self._loaded.add(tab_index) + # ★ 禁用新面板中所有 SpinBox/ComboBox 的滚轮事件(防误触改值) + _disable_wheel_for_widget(panel) + # 事件总线自动接线 if deps: subscribe_panel_to_dependencies(panel, step_id, deps) @@ -207,8 +237,12 @@ class PanelFactory: try: work_dir = self._get_current_work_dir() panel.update_from_config(work_dir=work_dir, pipeline=None) - except Exception: - pass + except Exception as e: + # 2026-06-30:不再静默吞异常 + global_event_bus.publish('LogMessage', { + 'message': f'[警告] 面板 update_from_config 失败: {e}', + 'level': 'warning', + }) # 2. 回放 WorkspaceManager 中已累积的 step_outputs ws_manager = self._get_workspace_manager() @@ -231,8 +265,10 @@ class PanelFactory: 架构解耦(2026-06-22):第三个元素 source_attr 现在明确代表上游控件的真实名字, 不再混用语义。回放端仅依赖 source_attr 在 SOURCE 面板上能命中 widget。 + + 2026-06-30 修复:仅当路径对应的文件/目录确实存在时才广播 OutputUpdated, + 防止面板中幽灵占位路径级联扩散到下游。 """ - from src.gui.core.event_bus import global_event_bus for entry in self._registry: deps = entry.get('dependencies') if not deps: continue @@ -244,19 +280,22 @@ class PanelFactory: widget = getattr(src_panel, source_attr, None) if widget is None: continue - + path = "" if hasattr(widget, 'get_path'): path = widget.get_path().strip() elif hasattr(widget, 'text'): path = widget.text().strip() - + if not path: continue - + # 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失 - import os absolute_path = os.path.abspath(path).replace('\\', '/') - + + # 2026-06-30:仅当文件/目录确实存在时才广播,阻断幽灵路径级联 + if not os.path.exists(absolute_path): + continue + global_event_bus.publish('OutputUpdated', { 'step_id': dep_step, 'output_type': output_type, diff --git a/src/gui/core/panel_registry.py b/src/gui/core/panel_registry.py index b30fdee..e072931 100644 --- a/src/gui/core/panel_registry.py +++ b/src/gui/core/panel_registry.py @@ -187,11 +187,10 @@ PANEL_REGISTRY = [ 'display_name': '10. 水色指数反演', # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名; # 第三元素 source_attr = 上游源控件真实属性名 - # step10 panel 仅有 bsq_file / hdr_file / output_dir,没有 water_mask widget, - # 故删除 water_mask 依赖,避免悬挂回调 + # step10 panel 使用 CSV 散点模式,输入为 Step4 的采样光谱 CSV 'dependencies': { - # 目标框: self.bsq_file ← 上游 step3.output_file - 'bsq_file': ('step3', 'deglint_image', 'output_file'), + # 目标框: self.sampling_csv_file ← 上游 step4_sampling.output_file + 'sampling_csv_file': ('step4_sampling', 'sampling_points', 'output_file'), }, 'constructor_kwargs': None, }, diff --git a/src/gui/core/preflight_dialog.py b/src/gui/core/preflight_dialog.py index 54676d9..05be6ed 100644 --- a/src/gui/core/preflight_dialog.py +++ b/src/gui/core/preflight_dialog.py @@ -53,19 +53,21 @@ class PreflightDialog(QDialog): """ # step_id → (step_name, panel_tab_index) + # 2026-06-30 修正:step_id 改为 PANEL_REGISTRY 格式,与 build_missing_items 输出一致 STEP_TAB_MAP = { - "step1": ("水域掩膜", 0), - "step2": ("耀斑检测", 1), - "step3": ("耀斑去除", 2), - "step4": ("数据清洗", 3), - "step5": ("特征构建", 4), - "step7": ("水质指数", 5), - "step8_non_empirical_modeling": ("回归建模", 7), - "step9": ("水色指数反演", 8), - "step10": ("采样点布设", 10), - "step11_ml": ("监督预测", 11), - "step11": ("回归预测", 12), - "step14": ("专题图生成", 13), + "step1": ("水域掩膜", 0), + "step2": ("耀斑检测", 1), + "step3": ("耀斑去除", 2), + "step4_sampling": ("采样点布设", 3), + "step5_clean": ("数据清洗", 4), + "step6_feature": ("光谱特征提取", 5), + "step7_index": ("水质指数计算", 6), + "step8_ml_train": ("机器学习建模", 7), + "step9_ml_predict": ("机器学习预测", 8), + "step10_watercolor": ("水色指数反演", 9), + "step11_map": ("专题图生成", 10), + "step12_viz": ("可视化展示", 11), + "step13_report": ("报告生成", 12), } def __init__(self, missing_items: List[MissingItem], parent=None): @@ -302,6 +304,12 @@ class PreflightDialog(QDialog): def build_missing_items(config: dict) -> List[MissingItem]: """DAG-aware 预检:从 config 构建缺失项列表。 + 2026-06-30 修复(步骤 ID 错配): + config 的 key 是 PANEL_REGISTRY 的 step_id(如 step4_sampling), + 而 PIPELINE_STEPS 使用另一套 step_id(如 step4)。 + 现通过 _PIPELINE_TO_CONFIG_KEY 映射表统一翻译, + 避免 preflight 永远误报缺失项。 + 拓扑预判逻辑: 1. 按 pipeline 顺序遍历所有 enabled=True 的步骤,收集其 produces 列表, 构建「动态产物集合」dynamically_produced_keys。 @@ -320,7 +328,29 @@ class PreflightDialog(QDialog): """ items: List[MissingItem] = [] - step1_cfg = config.get('step1', {}) + # ── ★ PIPELINE_STEPS step_id → PANEL_REGISTRY step_id(config 的 key)── + _PIPELINE_TO_CONFIG_KEY: Dict[str, str] = { + 'step1': 'step1', + 'step2': 'step2', + 'step3': 'step3', + 'step4': 'step5_clean', + 'step5': 'step6_feature', + 'step7': 'step7_index', + 'step8': 'step8_ml_train', + 'step8_non_empirical_modeling': 'step8_ml_train', + 'step9': 'step10_watercolor', + 'step10': 'step4_sampling', + 'step11_ml': 'step9_ml_predict', + 'step11': 'step9_ml_predict', + 'step14': 'step11_map', + } + + def _cfg(step_id: str) -> dict: + """用映射表解析 config key,兼容新旧两种 step_id 体系。""" + config_key = _PIPELINE_TO_CONFIG_KEY.get(step_id, step_id) + return config.get(config_key, {}) + + step1_cfg = _cfg('step1') step1_enabled = step1_cfg.get('enabled', False) # ── ★ 构建「动态产物集合」:按 pipeline 顺序收集所有 enabled 步骤的 produces ── @@ -328,7 +358,7 @@ class PreflightDialog(QDialog): enabled_step_ids: Set[str] = set() for step_spec in PIPELINE_STEPS: - step_cfg = config.get(step_spec.step_id, {}) + step_cfg = _cfg(step_spec.step_id) if not step_cfg.get('enabled', True): continue enabled_step_ids.add(step_spec.step_id) @@ -349,20 +379,20 @@ class PreflightDialog(QDialog): panel_tab_index=0, is_critical=True )) - # ── step4 csv_path(纯外部输入,必须手动提供)─────────────── - step4_cfg = config.get('step4', {}) + # ── step5_clean csv_path(纯外部输入,必须手动提供)─────────── + step4_cfg = _cfg('step4') # 映射到 step5_clean step4_enabled = step4_cfg.get('enabled', True) if step4_enabled: csv_path = step4_cfg.get('csv_path') if not csv_path: items.append(MissingItem( - step_id="step4", step_name="数据清洗", + step_id="step5_clean", step_name="数据清洗", reason="请在「数据清洗」中填写「实测水质数据 CSV」", panel_tab_index=3 )) elif not os.path.isfile(csv_path): items.append(MissingItem( - step_id="step4", step_name="数据清洗", + step_id="step5_clean", step_name="数据清洗", reason=f"实测水质数据文件不存在:{csv_path}", panel_tab_index=3 )) @@ -374,42 +404,44 @@ class PreflightDialog(QDialog): # ── ★ DAG-aware 检查:遍历 enabled 步骤的 required_input_files ── PURE_EXTERNAL_INPUT_KEYS: Set[str] = {'img_path', 'csv_path'} _TAB_INDEX_MAP: Dict[str, int] = { - "step1": 0, "step2": 1, "step3": 2, "step4": 3, - "step5": 4, "step8": 5, "step7": 6, - "step8_non_empirical_modeling": 7, "step9": 8, - "step10": 9, "step11_ml": 10, "step11": 11, - "step12": 12, "step14": 13, + "step1": 0, "step2": 1, "step3": 2, + "step4_sampling": 3, "step5_clean": 4, "step6_feature": 5, + "step7_index": 6, "step8_ml_train": 7, + "step9_ml_predict": 8, "step10_watercolor": 9, + "step11_map": 10, "step12_viz": 11, "step13_report": 12, } _STEP_NAME_MAP: Dict[str, str] = { "step1": "水域掩膜", "step2": "耀斑检测", "step3": "耀斑去除", - "step4": "数据清洗", "step5": "特征构建", "step8": "水质指数", - "step7": "监督建模", "step8_non_empirical_modeling": "回归建模", - "step9": "自定义回归建模", "step10": "采样点布设", - "step11_ml": "监督预测", "step11": "回归预测", - "step12": "自定义回归预测", "step14": "专题图生成", + "step4_sampling": "采样点布设", "step5_clean": "数据清洗", + "step6_feature": "光谱特征提取", "step7_index": "水质指数计算", + "step8_ml_train": "机器学习建模", "step9_ml_predict": "机器学习预测", + "step10_watercolor": "水色指数反演", "step11_map": "专题图生成", + "step12_viz": "可视化展示", "step13_report": "报告生成", } for step_spec in PIPELINE_STEPS: if step_spec.step_id not in enabled_step_ids: continue - step_cfg = config.get(step_spec.step_id, {}) - tab_idx = _TAB_INDEX_MAP.get(step_spec.step_id, 0) - step_name = _STEP_NAME_MAP.get(step_spec.step_id, step_spec.step_id) + step_cfg = _cfg(step_spec.step_id) # ★ 通过映射表翻译 step_id + # ★ 将 PIPELINE_STEPS step_id 翻译为 PANEL_REGISTRY step_id + panel_step_id = _PIPELINE_TO_CONFIG_KEY.get(step_spec.step_id, step_spec.step_id) + tab_idx = _TAB_INDEX_MAP.get(panel_step_id, 0) + step_name = _STEP_NAME_MAP.get(panel_step_id, panel_step_id) for req_key in step_spec.required_input_files: # ★★★ 高优先级硬编码白名单 ★★★ # 当检测到需求为边界文件时,只要 step1 有填影像(代表有基础,底层能自动推导),直接放行 if req_key in ('boundary_path', 'boundary_shp_path'): - step1_cfg = config.get('step1', {}) - if step1_cfg.get('img_path') or step1_cfg.get('enabled', True): + _step1_cfg = _cfg('step1') + if _step1_cfg.get('img_path') or _step1_cfg.get('enabled', True): continue # 直接跳过,不判定为缺失 if req_key in PURE_EXTERNAL_INPUT_KEYS: continue if req_key == 'formula_csv_path': continue # ★ 底层完全可选,赦免 - if req_key == 'boundary_path' and step_spec.step_id == 'step5': + if req_key == 'boundary_path' and panel_step_id == 'step6_feature': continue # ★ step1 执行则 panel/底层自动推导,赦免 - if req_key == 'boundary_shp_path' and step_spec.step_id == 'step14': + if req_key == 'boundary_shp_path' and panel_step_id == 'step11_map': continue # ★ step1 执行则 panel 自动回填,赦免 cfg_val = step_cfg.get(req_key) if cfg_val and os.path.isfile(cfg_val): @@ -419,11 +451,11 @@ class PreflightDialog(QDialog): if req_key in dynamically_produced_keys: continue # ★ 前置步骤会生成,拓扑预判通过 items.append(MissingItem( - step_id=step_spec.step_id, + step_id=panel_step_id, step_name=step_name, reason=f"缺少必需文件/目录 [{req_key}]", panel_tab_index=tab_idx, - is_critical=(step_spec.step_id == "step1" and req_key == "img_path"), + is_critical=(panel_step_id == "step1" and req_key == "img_path"), )) return items \ No newline at end of file diff --git a/src/gui/core/worker_thread.py b/src/gui/core/worker_thread.py index 5a4dfa6..865157d 100644 --- a/src/gui/core/worker_thread.py +++ b/src/gui/core/worker_thread.py @@ -188,12 +188,20 @@ except Exception as e: # ============================================================================= class WorkerThread(QThread): - """后台工作线程,用于执行耗时任务(在工作线程内创建 Pipeline,避免阻塞 UI)。""" + """后台工作线程,用于执行耗时任务(在工作线程内创建 Pipeline,避免阻塞 UI)。 + + 2026-06-30 修复: + - stop() 不再直接调用 terminate(),改为先请求取消 + 等待线程自然结束 + 超时兜底 + - total_steps 从 config 动态计算,不再硬编码为 9 + """ progress_update = pyqtSignal(int, str) # 进度更新信号 (percentage, message) log_message = pyqtSignal(str, str) # 日志消息信号 (message, level: 'info'/'warning'/'error') step_completed = pyqtSignal(str, bool, str) # 步骤完成信号 (step_name, success, message) finished = pyqtSignal(bool, str) # 完成信号 (success, message) + # 等待线程自然退出的最大秒数(仅在 stop() 中用到) + _GRACEFUL_STOP_TIMEOUT = 5.0 + def __init__(self, work_dir: str, config, mode='full', step_name=None, skip_list=None): super().__init__() self.work_dir = str(work_dir) @@ -205,7 +213,10 @@ class WorkerThread(QThread): self.is_running = True self.current_step = None self.step_count = 0 - self.total_steps = 9 + # 动态计算总步骤数(过滤 skip_list) + self.total_steps = len([ + k for k in self.config.keys() if k not in self.skip_list + ]) if self.mode == 'full' else 1 def pipeline_callback(self, step_name, status, message=""): """Pipeline回调函数,用于接收步骤状态""" @@ -506,6 +517,35 @@ class WorkerThread(QThread): ) def stop(self): - """停止执行""" + """安全停止执行。 + + 优先通过 PipelineScheduler 的取消机制自然终止: + 1. 设置 is_running = False(run() 内各步骤间会检查) + 2. 调用 scheduler.cancel() 请求中断当前步骤 + 3. 等待最多 5 秒让线程自然退出 + 4. 超时后 fallback 到 terminate()(最后手段) + + 2026-06-30 修复:不再直接调用 terminate(), + 避免互斥锁死锁、文件损坏和资源泄漏。 + """ self.is_running = False - self.terminate() + + # 尝试通过 scheduler 的取消机制中断正在执行的步骤 + if self.pipeline is not None: + try: + cancel_fn = getattr(self.pipeline, 'cancel', None) + if callable(cancel_fn): + cancel_fn() + elif hasattr(self.pipeline, 'context'): + ctx = self.pipeline.context + if hasattr(ctx, 'cancel'): + ctx.cancel() + except Exception: + pass + + # 等待线程自然退出 + if self.isRunning(): + if not self.wait(int(self._GRACEFUL_STOP_TIMEOUT * 1000)): + # 超时:线程卡死在不可中断的 I/O 或 C 扩展调用中,此时才 fallback + self.terminate() + self.wait(2000) # 等待 terminate 生效 diff --git a/src/gui/panels/step10_watercolor_panel.py b/src/gui/panels/step10_watercolor_panel.py index 1eb14b6..6f6e255 100644 --- a/src/gui/panels/step10_watercolor_panel.py +++ b/src/gui/panels/step10_watercolor_panel.py @@ -567,17 +567,22 @@ class Step10WatercolorPanel(QWidget): ) # 2. 回退:直接读 step4_sampling panel 的 output_file widget + # 2026-06-30:panel widget 可能含幽灵占位路径,仅当文件确实存在时才采纳 if not sampling_path and main_window: step4_widget = getattr(main_window, 'step4_sampling', None) if step4_widget and hasattr(step4_widget, 'output_file'): - sampling_path = step4_widget.output_file.get_path() - else: + candidate = step4_widget.output_file.get_path() + if candidate and os.path.isfile(candidate): + sampling_path = candidate + if not sampling_path: # 通过 _panel_factory 懒加载查找 factory = getattr(main_window, '_panel_factory', None) if factory: step4_panel = factory.get_panel('step4_sampling') if step4_panel and hasattr(step4_panel, 'output_file'): - sampling_path = step4_panel.output_file.get_path() + candidate = step4_panel.output_file.get_path() + if candidate and os.path.isfile(candidate): + sampling_path = candidate # 3. 终极回退:扫描 work_dir/4_sampling/sampling_spectra.csv if not sampling_path and self.work_dir: @@ -593,14 +598,12 @@ class Step10WatercolorPanel(QWidget): ).replace('\\', '/') self.sampling_csv_file.set_path(sampling_path) - # 自动填入输出目录(默认 work_dir/10_WaterIndex_CSV/) - if self.work_dir: + # 自动填入输出目录(仅在为空时填入默认路径,不创建目录) + if self.work_dir and not self.output_dir.get_path(): out_dir = os.path.join( self.work_dir, '10_WaterIndex_CSV' ).replace('\\', '/') - os.makedirs(out_dir, exist_ok=True) - if not self.output_dir.get_path(): - self.output_dir.set_path(out_dir) + self.output_dir.set_path(out_dir) def _on_run_single_clicked(self): """通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。""" diff --git a/src/gui/panels/step11_map_panel.py b/src/gui/panels/step11_map_panel.py index 35cdd53..045c1ae 100644 --- a/src/gui/panels/step11_map_panel.py +++ b/src/gui/panels/step11_map_panel.py @@ -482,35 +482,32 @@ class Step11MapPanel(QWidget): factory = getattr(main_window, '_panel_factory', None) if main_window else None if not factory: return - # 1. 安全抓取 Step 9 的预测 CSV 目录 + # 1. 安全抓取 Step 9 的预测 CSV 目录(仅当目录确实存在) step9_panel = factory.get_panel('step9_ml_predict') if step9_panel and hasattr(step9_panel, 'output_file'): path = step9_panel.output_file.get_path() - if path: + if path and os.path.isdir(path): self.prediction_csv_dir_edit.setText(path) self.batch_mode_combo.setCurrentIndex(1) - # 2. 安全抓取 Step 1 的真实掩膜文件(彻底拒绝瞎猜 roi.shp) + # 2. 安全抓取 Step 1 的真实掩膜文件(仅当文件确实存在) step1_panel = factory.get_panel('step1') if step1_panel: use_ndwi = step1_panel.use_ndwi_radio.isChecked() - # 根据用户在第1步的选择,拿真实的输出掩膜或导入的掩膜 if use_ndwi and hasattr(step1_panel, 'output_file'): path = step1_panel.output_file.get_path() elif not use_ndwi and hasattr(step1_panel, 'mask_file'): path = step1_panel.mask_file.get_path() else: path = "" - + existing = self.boundary_file.get_path() - if path and not existing: + if path and not existing and os.path.exists(path): self.boundary_file.set_path(path) - # 3. 生成第 11 步的绝对输出目录 (杜绝保存到相对路径) - if hasattr(self, 'work_dir') and self.work_dir: - import os + # 3. 生成第 11 步的输出目录(仅在为空时填入默认路径,不创建目录) + if hasattr(self, 'work_dir') and self.work_dir and not self.output_dir.get_path(): out_dir = os.path.join(self.work_dir, "14_visualization").replace('\\', '/') - os.makedirs(out_dir, exist_ok=True) self.output_dir.set_path(out_dir) def browse_output_dir(self): diff --git a/src/gui/panels/step12_viz_panel.py b/src/gui/panels/step12_viz_panel.py index 3bcc6cb..394e720 100644 --- a/src/gui/panels/step12_viz_panel.py +++ b/src/gui/panels/step12_viz_panel.py @@ -1656,21 +1656,22 @@ class Step12VizPanel(QWidget): self.image_viewer.load_image(str(image_files[0])) def _setup_prediction_output_dirs(self, work_path: Path): - """设置三个预测步骤的默认输出目录""" + """收集预测输出目录路径信息(不创建目录,仅用于日志/调试)。 + + 2026-06-30 修复:移除 mkdir 调用,不再在未运行 pipeline 时创建空目录。 + 目录创建统一留给各 pipeline 步骤在实际执行时处理。 + """ try: base_prediction_dir = Path(resolve_subdir(str(work_path), 'prediction_dir')) ml_dir = Path(resolve_subdir(str(work_path), 'ml_prediction')) reg_dir = base_prediction_dir / "Regression_Model_Prediction" custom_dir = Path(resolve_subdir(str(work_path), 'custom_regression')) / "Custom_Regression_Prediction" - ml_dir.mkdir(parents=True, exist_ok=True) - reg_dir.mkdir(parents=True, exist_ok=True) - custom_dir.mkdir(parents=True, exist_ok=True) - # 旧的 self.step11_ml_panel/step11_panel/step12_panel 在 Step12VizPanel 上不存在,是死代码。 - # 三个目录的真实默认值在用户首次浏览 / 自动填充时由各 panel 自己的 _get_default_work_dir 路径产出。 - # 这里仅做目录创建 + 提示输出,便于用户在工作目录树中能看到预测输出位置。 - print(f"预测输出目录已创建:\n ML: {ml_dir}\n Reg: {reg_dir}\n Custom: {custom_dir}") + # 仅输出信息,不创建目录 + existing = [str(d) for d in (ml_dir, reg_dir, custom_dir) if d.is_dir()] + if existing: + print(f"预测输出目录已存在: {existing}") except Exception as e: - print(f"设置预测输出目录失败: {e}") + print(f"读取预测输出目录信息失败: {e}") def on_tree_item_clicked(self, item, column): """目录树项点击事件""" diff --git a/src/gui/panels/step1_panel.py b/src/gui/panels/step1_panel.py index ebeaddb..900115d 100644 --- a/src/gui/panels/step1_panel.py +++ b/src/gui/panels/step1_panel.py @@ -114,6 +114,9 @@ class Step1Panel(QWidget): config_group.setLayout(config_layout) main_layout.addWidget(config_group) + # ========================================== + # 卡片 3:输出与执行(与 Step8 对齐的现代化卡片风格) + # ========================================== output_group = QGroupBox("🚀 输出与执行") output_layout = QVBoxLayout() output_layout.setSpacing(16) @@ -124,11 +127,10 @@ class Step1Panel(QWidget): "Mask Files (*.dat *.tif);;All Files (*.*)", mode="save" ) - self.output_file.label.setMinimumWidth(100) self.output_file.line_edit.setPlaceholderText("water_mask.dat") output_layout.addWidget(self.output_file) - # 完美对齐的底部按钮栏(已移除多余的启用步骤选项) + # 完美对齐的底部按钮栏 action_layout = QHBoxLayout() action_layout.addStretch() @@ -140,6 +142,8 @@ class Step1Panel(QWidget): output_layout.addLayout(action_layout) output_group.setLayout(output_layout) + + # 将打包好的输出卡片添加到主 layout 中 main_layout.addWidget(output_group) self.use_existing_radio.toggled.connect(self.update_ui_state) @@ -164,10 +168,12 @@ class Step1Panel(QWidget): self._auto_fill_output_path() def _auto_fill_output_path(self): + """仅在输出框为空时填入默认路径;不创建目录(留给 pipeline 执行时创建)。""" if not hasattr(self, 'work_dir') or not self.work_dir: return + if self.output_file.get_path(): + return # 用户已手动指定,不覆盖 output_dir = resolve_subdir(self.work_dir, 'water_mask') - os.makedirs(output_dir, exist_ok=True) default_output_path = os.path.join(output_dir, "water_mask_out.dat").replace('\\', '/') self.output_file.set_path(default_output_path) diff --git a/src/gui/panels/step2_panel.py b/src/gui/panels/step2_panel.py index 7cdd4f4..8023962 100644 --- a/src/gui/panels/step2_panel.py +++ b/src/gui/panels/step2_panel.py @@ -192,13 +192,15 @@ class Step2Panel(QWidget): if mask_path: if not os.path.isabs(mask_path): mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') - self.water_mask_file.set_path(mask_path) + # 仅当上游文件确实存在时才自动填入(防止幽灵路径级联扩散) + if os.path.exists(mask_path): + self.water_mask_file.set_path(mask_path) if self.work_dir: - output_dir = resolve_subdir(self.work_dir, 'glint_detection') - os.makedirs(output_dir, exist_ok=True) - default_output_path = os.path.join(output_dir, "severe_glint_area.dat").replace('\\', '/') - self.output_file.set_path(default_output_path) + if not self.output_file.get_path(): + output_dir = resolve_subdir(self.work_dir, 'glint_detection') + default_output_path = os.path.join(output_dir, "severe_glint_area.dat").replace('\\', '/') + self.output_file.set_path(default_output_path) else: self.output_file.set_path("") diff --git a/src/gui/panels/step3_panel.py b/src/gui/panels/step3_panel.py index ea59ea4..d4047da 100644 --- a/src/gui/panels/step3_panel.py +++ b/src/gui/panels/step3_panel.py @@ -317,13 +317,15 @@ class Step3Panel(QWidget): if mask_path: if not os.path.isabs(mask_path): mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') - self.water_mask_file.set_path(mask_path) + # 仅当上游文件确实存在时才自动填入(防止幽灵路径级联扩散) + if os.path.exists(mask_path): + self.water_mask_file.set_path(mask_path) if self.work_dir: - output_dir = resolve_subdir(self.work_dir, 'deglint') - os.makedirs(output_dir, exist_ok=True) - default_output_path = os.path.join(output_dir, "deglint_image.bsq").replace('\\', '/') - self.output_file.set_path(default_output_path) + if not self.output_file.get_path(): + output_dir = resolve_subdir(self.work_dir, 'deglint') + default_output_path = os.path.join(output_dir, "deglint_image.bsq").replace('\\', '/') + self.output_file.set_path(default_output_path) else: self.output_file.set_path("") diff --git a/src/gui/panels/step4_sampling_panel.py b/src/gui/panels/step4_sampling_panel.py index 69427e1..4603a1c 100644 --- a/src/gui/panels/step4_sampling_panel.py +++ b/src/gui/panels/step4_sampling_panel.py @@ -1,13 +1,22 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Step4 面板 - 采样点布设 (已移除“启用此步骤”) +Step4 面板 - 采样点布设(内嵌交互式光谱探针视图) + +2026-06-30 重构: + - 左右分栏布局 (QSplitter):左侧控制区 + 右侧嵌入式 Matplotlib 视图 + - 1×2 子图:ax1 散点图 + ax2 光谱曲线 + - Hover 悬停显示坐标提示,Click 点击绘制该点光谱曲线 + - NavigationToolbar2QT 工具栏自带保存/缩放/平移 """ import os import sys from pathlib import Path +import numpy as np +import pandas as pd + _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) @@ -16,9 +25,16 @@ from _step_path_resolver import resolve_subdir from PyQt5.QtCore import QTimer, Qt from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, - QPushButton, QSpinBox, QMessageBox, QLabel, QFrame + QPushButton, QSpinBox, QCheckBox, QMessageBox, QLabel, QFrame, + QSplitter, QSizePolicy, ) +from matplotlib.backends.backend_qt5agg import ( + FigureCanvasQTAgg as FigureCanvas, + NavigationToolbar2QT as NavigationToolbar, +) +from matplotlib.figure import Figure + from src.gui.components.custom_widgets import FileSelectWidget from src.gui.dialogs import SamplingViewerDialog from src.gui.styles import ModernStylesheet @@ -27,15 +43,39 @@ from src.gui.styles import ModernStylesheet class Step4SamplingPanel(QWidget): def __init__(self, parent=None): super().__init__(parent) + # 交互状态 + self._df = None + self._x_col = None + self._y_col = None + self._band_cols = [] + self._scatter = None + self._highlight_idx = None + self._annot = None + self._cid_hover = None + self._cid_click = None + self._last_render_path = None self.init_ui() + # ═══════════════════════════════════════════════════════════════ + # UI 构建 + # ═══════════════════════════════════════════════════════════════ + def init_ui(self): self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) - main_layout = QVBoxLayout() - main_layout.setContentsMargins(24, 24, 24, 24) - main_layout.setSpacing(20) + # ── 顶层:水平分栏 (QSplitter) ── + splitter = QSplitter(Qt.Horizontal) + splitter.setChildrenCollapsible(False) + # ═══════════════════════════════════════════════ + # 左侧:控制区(原封保留原有三张卡片) + # ═══════════════════════════════════════════════ + left_widget = QWidget() + left_layout = QVBoxLayout() + left_layout.setContentsMargins(24, 24, 12, 24) + left_layout.setSpacing(20) + + # --- 卡片 1:输入数据 --- input_group = QGroupBox("📁 输入数据") input_layout = QVBoxLayout() input_layout.setSpacing(16) @@ -52,8 +92,9 @@ class Step4SamplingPanel(QWidget): input_layout.addWidget(self.deglint_img_file) input_layout.addWidget(self.water_mask_file) input_group.setLayout(input_layout) - main_layout.addWidget(input_group) + left_layout.addWidget(input_group) + # --- 卡片 2:采样参数 --- params_group = QGroupBox("⚙️ 采样参数") params_layout = QFormLayout() params_layout.setSpacing(16) @@ -80,14 +121,14 @@ class Step4SamplingPanel(QWidget): self.chunk_size.setMinimumWidth(120) params_layout.addRow("内存处理块大小:", self.chunk_size) - from PyQt5.QtWidgets import QCheckBox self.use_adaptive_sampling = QCheckBox("启用自适应边缘采样") self.use_adaptive_sampling.setChecked(True) params_layout.addRow("智能模式:", self.use_adaptive_sampling) params_group.setLayout(params_layout) - main_layout.addWidget(params_group) + left_layout.addWidget(params_group) + # --- 卡片 3:输出与执行 --- output_group = QGroupBox("🚀 输出与执行") output_layout = QVBoxLayout() output_layout.setSpacing(16) @@ -104,33 +145,104 @@ class Step4SamplingPanel(QWidget): action_layout = QHBoxLayout() action_layout.addStretch() - self.preview_btn = QPushButton("交互式预览采样点") - self.preview_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal')) - self.preview_btn.setEnabled(False) - self.preview_btn.setMinimumWidth(160) - self.preview_btn.clicked.connect(self._open_sampling_viewer) + self.refresh_btn = QPushButton("🔄 刷新视图") + self.refresh_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal')) + self.refresh_btn.setEnabled(False) + self.refresh_btn.setMinimumWidth(140) + self.refresh_btn.setToolTip("重新加载 CSV 并渲染采样点散点图") + self.refresh_btn.clicked.connect(self._on_refresh_clicked) self.run_btn = QPushButton("独立运行步骤") self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary')) self.run_btn.setMinimumWidth(140) self.run_btn.clicked.connect(self._on_run_single_clicked) - action_layout.addWidget(self.preview_btn) + action_layout.addWidget(self.refresh_btn) action_layout.addWidget(self.run_btn) output_layout.addLayout(action_layout) output_group.setLayout(output_layout) - main_layout.addWidget(output_group) + left_layout.addWidget(output_group) - main_layout.addStretch() - self.setLayout(main_layout) + left_layout.addStretch() + left_widget.setLayout(left_layout) + left_widget.setMinimumWidth(360) + # ═══════════════════════════════════════════════ + # 右侧:可视化区(嵌入式 Matplotlib 视图) + # ═══════════════════════════════════════════════ + right_widget = QWidget() + right_layout = QVBoxLayout() + right_layout.setContentsMargins(12, 24, 24, 24) + right_layout.setSpacing(0) + + viz_group = QGroupBox("📊 采样点交互式探索") + viz_layout = QVBoxLayout() + viz_layout.setContentsMargins(8, 20, 8, 8) + viz_layout.setSpacing(0) + + # Matplotlib 画布(1×2 子图:散点图 + 光谱曲线) + self._fig = Figure(figsize=(9, 5)) + self._ax_scatter = self._fig.add_subplot(121) + self._ax_spectrum = self._fig.add_subplot(122) + self._canvas = FigureCanvas(self._fig) + self._canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + # 设置子图初始状态 + self._ax_scatter.set_title("采样点分布", fontsize=11, fontweight='bold') + self._ax_scatter.set_xlabel("X 坐标") + self._ax_scatter.set_ylabel("Y 坐标") + self._ax_scatter.text(0.5, 0.5, "等待采样数据生成...\n\n请先配置参数并运行步骤\n或选择已有的 CSV 后刷新", + ha='center', va='center', transform=self._ax_scatter.transAxes, + fontsize=12, color='#888888') + self._ax_scatter.grid(False) + + self._ax_spectrum.set_title("光谱曲线", fontsize=11, fontweight='bold') + self._ax_spectrum.set_xlabel("波长 (nm)") + self._ax_spectrum.set_ylabel("反射率") + self._ax_spectrum.text(0.5, 0.5, "点击左侧散点\n查看光谱曲线", + ha='center', va='center', transform=self._ax_spectrum.transAxes, + fontsize=12, color='#888888') + self._ax_spectrum.grid(False) + + self._fig.tight_layout(pad=2.0) + + # 工具栏(自带保存/缩放/平移) + self._toolbar = NavigationToolbar(self._canvas, self) + viz_layout.addWidget(self._toolbar) + viz_layout.addWidget(self._canvas) + + viz_group.setLayout(viz_layout) + right_layout.addWidget(viz_group) + right_widget.setLayout(right_layout) + + # ── 组装分栏 ── + splitter.addWidget(left_widget) + splitter.addWidget(right_widget) + splitter.setSizes([420, 680]) # 初始比例 ≈ 38:62 + splitter.setStretchFactor(0, 1) + splitter.setStretchFactor(1, 2) + + top_layout = QHBoxLayout() + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.addWidget(splitter) + self.setLayout(top_layout) + + # ── 事件绑定 ── + self._cid_hover = self._canvas.mpl_connect('motion_notify_event', self._on_hover) + self._cid_click = self._canvas.mpl_connect('button_press_event', self._on_click) + + # ── 定时器:降低频率,仅用于自动发现新生成的 CSV ── self._status_timer = QTimer(self) - self._status_timer.timeout.connect(self._check_csv_exists) - self._status_timer.start(2000) + self._status_timer.timeout.connect(self._check_csv_and_auto_render) + self._status_timer.start(5000) self.output_file.line_edit.textChanged.connect(self._on_output_changed) + # ═══════════════════════════════════════════════════════════════ + # 配置读写(保持不变) + # ═══════════════════════════════════════════════════════════════ + def get_config(self): config = { 'interval': self.interval.value(), @@ -182,7 +294,8 @@ class Step4SamplingPanel(QWidget): if deglint_path: if not os.path.isabs(deglint_path): deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/') - self.deglint_img_file.set_path(deglint_path) + if os.path.exists(deglint_path): + self.deglint_img_file.set_path(deglint_path) water_mask_path = None if pipeline and hasattr(pipeline, 'step_outputs'): @@ -215,14 +328,22 @@ class Step4SamplingPanel(QWidget): if water_mask_path: if not os.path.isabs(water_mask_path): water_mask_path = os.path.join(self.work_dir or '', water_mask_path).replace('\\', '/') - self.water_mask_file.set_path(water_mask_path) + if os.path.exists(water_mask_path): + self.water_mask_file.set_path(water_mask_path) - if self.work_dir: + if self.work_dir and not self.output_file.get_path(): output_path = resolve_subdir(self.work_dir, 'sampling_csv_path') - os.makedirs(os.path.dirname(output_path), exist_ok=True) self.output_file.set_path(output_path.replace('\\', '/')) self._check_csv_exists() + # 若 CSV 已存在,尝试自动渲染 + csv_path = self.output_file.get_path() + if csv_path and os.path.isfile(csv_path): + self._render_inline_plot() + + # ═══════════════════════════════════════════════════════════════ + # 执行 + # ═══════════════════════════════════════════════════════════════ def _on_run_single_clicked(self): from src.gui.core.event_bus import global_event_bus @@ -236,20 +357,354 @@ class Step4SamplingPanel(QWidget): 'config': config, }) + # ═══════════════════════════════════════════════════════════════ + # CSV 状态检测 + # ═══════════════════════════════════════════════════════════════ + def _check_csv_exists(self): csv_path = self.output_file.get_path() enabled = bool(csv_path and os.path.isabs(csv_path) and os.path.exists(csv_path)) - self.preview_btn.setEnabled(enabled) + self.refresh_btn.setEnabled(enabled) return enabled def _on_output_changed(self, _text=None): self._check_csv_exists() + def _check_csv_and_auto_render(self): + """定时器回调:检测到新 CSV 出现时自动渲染一次。""" + csv_path = self.output_file.get_path() + if csv_path and os.path.isfile(csv_path): + if csv_path != self._last_render_path: + self.refresh_btn.setEnabled(True) + self._render_inline_plot() + + def _on_refresh_clicked(self): + """手动点击刷新按钮。""" + csv_path = self.output_file.get_path() + if not csv_path or not os.path.exists(csv_path): + QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。") + return + self._render_inline_plot() + + # ═══════════════════════════════════════════════════════════════ + # 核心渲染 + # ═══════════════════════════════════════════════════════════════ + + def _detect_coordinate_columns(self, df: pd.DataFrame): + """检测坐标列,返回 (x_col, y_col) 或 (None, None)。 + + 优先级:pixel_x/pixel_y → longitude/latitude → lon/lat → + X/Y → UTM_X/UTM_Y → 任何含 'x'/'y' 关键字的列 + """ + cols_lower = {c.lower(): c for c in df.columns} + + priority_pairs = [ + ('pixel_x', 'pixel_y'), + ('longitude', 'latitude'), + ('lon', 'lat'), + ('x', 'y'), + ('utm_x', 'utm_y'), + ] + + for x_key, y_key in priority_pairs: + if x_key in cols_lower and y_key in cols_lower: + return cols_lower[x_key], cols_lower[y_key] + + # 最后尝试:找名字中含 x / y 的数值列 + x_candidates = [c for c in df.columns if 'x' in c.lower() and pd.api.types.is_numeric_dtype(df[c])] + y_candidates = [c for c in df.columns if 'y' in c.lower() and pd.api.types.is_numeric_dtype(df[c])] + if x_candidates and y_candidates: + return x_candidates[0], y_candidates[0] + + return None, None + + def _detect_band_columns(self, df: pd.DataFrame): + """检测光谱波段列(纯数字列名,值域在 200–3000 nm 之间)。 + + 优先使用列名可解析为 float 且在波长范围内的列; + 否则回退到位置索引(跳过坐标列和已知元数据列)。 + """ + band_cols = [] + for col in df.columns: + try: + val = float(str(col).strip()) + if 200.0 <= val <= 3000.0: + band_cols.append(col) + except (ValueError, TypeError): + continue + + if band_cols: + # 按波长数值排序 + band_cols.sort(key=lambda c: float(str(c).strip())) + return band_cols + + # 回退:跳过坐标列和已知元数据列,取数值列 + skip_keywords = ('x', 'y', 'lon', 'lat', 'utm', 'id', 'sample', 'index', 'pixel') + numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist() + if self._x_col: + numeric_cols = [c for c in numeric_cols if c != self._x_col] + if self._y_col: + numeric_cols = [c for c in numeric_cols if c != self._y_col] + return [c for c in numeric_cols if not any(k in c.lower() for k in skip_keywords)] + + def _render_inline_plot(self): + """读取 CSV → 检测坐标/波段列 → 绘制 1×2 子图。""" + csv_path = self.output_file.get_path() + if not csv_path or not os.path.isfile(csv_path): + self._show_empty_state("等待采样数据生成...\n\n请先配置参数并运行步骤") + return + + # 读取数据 + try: + df = pd.read_csv(csv_path) + except Exception as e: + self._show_empty_state(f"读取 CSV 失败:\n{str(e)[:200]}") + return + + if df.empty: + self._show_empty_state("CSV 文件为空") + return + + # 检测坐标列 + x_col, y_col = self._detect_coordinate_columns(df) + if x_col is None or y_col is None: + self._show_empty_state( + "缺少坐标列\n\n" + f"可用列: {', '.join(str(c) for c in df.columns[:15])}\n" + "期望: pixel_x/pixel_y, longitude/latitude, X/Y 等" + ) + return + + # 检测光谱列 + band_cols = self._detect_band_columns(df) + if not band_cols: + # 仅显示散点图,光谱子图留空 + pass + + # 缓存 + self._df = df + self._x_col = x_col + self._y_col = y_col + self._band_cols = band_cols + self._highlight_idx = None + self._last_render_path = csv_path + + # 清除 Annotation + if self._annot is not None: + try: + self._annot.remove() + except Exception: + pass + self._annot = None + + # ── 绘制 ax1:散点图 ── + self._ax_scatter.clear() + x = df[x_col].values + y = df[y_col].values + + self._scatter = self._ax_scatter.scatter( + x, y, + c='#0078D7', alpha=0.7, edgecolors='white', + linewidth=0.5, s=40, picker=True, zorder=3 + ) + self._ax_scatter.set_xlabel(str(x_col), fontsize=10) + self._ax_scatter.set_ylabel(str(y_col), fontsize=10) + self._ax_scatter.set_title(f"采样点分布 (共 {len(df)} 个点)", fontsize=11, fontweight='bold') + self._ax_scatter.grid(True, alpha=0.3, linestyle='--') + self._ax_scatter.set_facecolor('#F8F9FA') + + # ── 绘制 ax2:光谱曲线(初始状态)── + self._ax_spectrum.clear() + if band_cols: + self._ax_spectrum.set_title("光谱曲线(点击左侧散点查看)", fontsize=11, fontweight='bold') + self._ax_spectrum.set_xlabel("波长 (nm)", fontsize=10) + self._ax_spectrum.set_ylabel("反射率", fontsize=10) + self._ax_spectrum.grid(True, alpha=0.3, linestyle='--') + self._ax_spectrum.text(0.5, 0.5, "点击左侧散点\n查看光谱曲线", + ha='center', va='center', transform=self._ax_spectrum.transAxes, + fontsize=12, color='#999999') + else: + self._ax_spectrum.text(0.5, 0.5, "缺少光谱数据列", + ha='center', va='center', transform=self._ax_spectrum.transAxes, + fontsize=12, color='#999999') + + self._fig.tight_layout(pad=2.0) + self._canvas.draw() + + def _show_empty_state(self, message: str): + """在右侧画布显示提示信息。""" + self._ax_scatter.clear() + self._ax_scatter.set_title("采样点分布", fontsize=11, fontweight='bold') + self._ax_scatter.text(0.5, 0.5, message, ha='center', va='center', + transform=self._ax_scatter.transAxes, + fontsize=12, color='#888888') + self._ax_scatter.grid(False) + + self._ax_spectrum.clear() + self._ax_spectrum.set_title("光谱曲线", fontsize=11, fontweight='bold') + self._ax_spectrum.text(0.5, 0.5, "等待数据...", + ha='center', va='center', transform=self._ax_spectrum.transAxes, + fontsize=12, color='#888888') + self._ax_spectrum.grid(False) + + self._fig.tight_layout(pad=2.0) + self._canvas.draw() + self._df = None + self._last_render_path = None + + # ═══════════════════════════════════════════════════════════════ + # 交互事件 (Hover & Click) + # ═══════════════════════════════════════════════════════════════ + + def _on_hover(self, event): + """悬停:在散点旁显示坐标 + ID 提示框。""" + if event.inaxes != self._ax_scatter: + # 鼠标离开 ax1 → 隐藏 annotation + if self._annot is not None: + try: + self._annot.set_visible(False) + self._canvas.draw_idle() + except Exception: + pass + return + + if self._df is None or self._scatter is None: + return + + # 检测是否悬停在散点上 + contains, info = self._scatter.contains(event) + if not contains or info is None or 'ind' not in info or len(info['ind']) == 0: + if self._annot is not None: + try: + self._annot.set_visible(False) + self._canvas.draw_idle() + except Exception: + pass + return + + idx = info['ind'][0] + row = self._df.iloc[idx] + x_val = row[self._x_col] + y_val = row[self._y_col] + + # 创建或更新 Annotation + text = f"#{idx}\n({x_val:.4f}, {y_val:.4f})" + if self._annot is None: + self._annot = self._ax_scatter.annotate( + text, + xy=(x_val, y_val), + xytext=(12, 12), + textcoords='offset points', + bbox=dict(boxstyle='round,pad=0.4', facecolor='#FFFFFF', + edgecolor='#0078D7', alpha=0.9), + fontsize=9, + zorder=10, + ) + else: + self._annot.xy = (x_val, y_val) + self._annot.set_text(text) + self._annot.set_visible(True) + + self._canvas.draw_idle() + + def _on_click(self, event): + """点击:高亮散点 + 绘制光谱曲线。""" + if event.inaxes != self._ax_scatter: + return + + if self._df is None or self._scatter is None: + return + + # 检测是否点击在散点上 + contains, info = self._scatter.contains(event) + if not contains or info is None or 'ind' not in info or len(info['ind']) == 0: + return + + idx = info['ind'][0] + row = self._df.iloc[idx] + self._highlight_idx = idx + + # ── 高亮选中点 ── + x = self._df[self._x_col].values + y = self._df[self._y_col].values + + colors = ['#0078D7'] * len(self._df) + sizes = [40] * len(self._df) + colors[idx] = '#E74C3C' + sizes[idx] = 80 + + self._ax_scatter.clear() + self._scatter = self._ax_scatter.scatter( + x, y, c=colors, s=sizes, + alpha=0.7, edgecolors='white', linewidth=0.5, + picker=True, zorder=3 + ) + # 将选中点提升到顶层 + self._ax_scatter.scatter( + [x[idx]], [y[idx]], + c='#E74C3C', s=100, + alpha=0.9, edgecolors='white', linewidth=1.5, + zorder=5 + ) + self._ax_scatter.set_xlabel(str(self._x_col), fontsize=10) + self._ax_scatter.set_ylabel(str(self._y_col), fontsize=10) + self._ax_scatter.set_title(f"采样点分布 (共 {len(self._df)} 个点)", fontsize=11, fontweight='bold') + self._ax_scatter.grid(True, alpha=0.3, linestyle='--') + self._ax_scatter.set_facecolor('#F8F9FA') + + # ── 绘制光谱曲线 ── + self._ax_spectrum.clear() + if self._band_cols: + wavelengths = [] + reflectance = [] + for col in self._band_cols: + try: + wl = float(str(col).strip()) + val = row[col] + if pd.notna(val): + wavelengths.append(wl) + reflectance.append(float(val)) + except (ValueError, TypeError): + continue + + if wavelengths: + # 按波长排序 + pairs = sorted(zip(wavelengths, reflectance), key=lambda p: p[0]) + wavelengths, reflectance = zip(*pairs) if pairs else ([], []) + self._ax_spectrum.plot( + wavelengths, reflectance, + color='#0078D7', lw=1.5, marker='.', markersize=3, alpha=0.8 + ) + self._ax_spectrum.fill_between(wavelengths, reflectance, alpha=0.1, color='#0078D7') + self._ax_spectrum.set_xlabel("波长 (nm)", fontsize=10) + self._ax_spectrum.set_ylabel("反射率", fontsize=10) + self._ax_spectrum.set_title(f"样本 #{idx} 的光谱曲线 ({len(wavelengths)} 个波段)", + fontsize=11, fontweight='bold') + self._ax_spectrum.grid(True, alpha=0.3, linestyle='--') + else: + self._ax_spectrum.text(0.5, 0.5, "该样本无有效光谱数据", + ha='center', va='center', + transform=self._ax_spectrum.transAxes, + fontsize=12, color='#999999') + else: + self._ax_spectrum.text(0.5, 0.5, "缺少光谱波段列\n无法绘制光谱", + ha='center', va='center', + transform=self._ax_spectrum.transAxes, + fontsize=12, color='#999999') + + self._fig.tight_layout(pad=2.0) + self._canvas.draw() + + # ═══════════════════════════════════════════════════════════════ + # 旧版弹窗查看器(保留,供外部调用) + # ═══════════════════════════════════════════════════════════════ + def _open_sampling_viewer(self): + """打开独立的 SamplingViewerDialog 弹窗(保留兼容)。""" csv_path = self.output_file.get_path() if not csv_path or not os.path.exists(csv_path): QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。") return dialog = SamplingViewerDialog(csv_path, self) dialog.exec_() - self._check_csv_exists() \ No newline at end of file + self._check_csv_exists() diff --git a/src/gui/panels/step5_clean_panel.py b/src/gui/panels/step5_clean_panel.py index 619c418..8541852 100644 --- a/src/gui/panels/step5_clean_panel.py +++ b/src/gui/panels/step5_clean_panel.py @@ -190,12 +190,11 @@ class Step5CleanPanel(QWidget): else: self.work_dir = None - if self.work_dir: + if self.work_dir and not self.output_file.get_path(): output_dir = resolve_subdir(self.work_dir, 'data_cleaning') - os.makedirs(output_dir, exist_ok=True) default_output_path = os.path.join(output_dir, "processed_data.csv").replace('\\', '/') self.output_file.set_path(default_output_path) - else: + elif not self.work_dir: self.output_file.set_path("") def _on_run_single_clicked(self): diff --git a/src/gui/panels/step6_feature_panel.py b/src/gui/panels/step6_feature_panel.py index 043f9a3..64402db 100644 --- a/src/gui/panels/step6_feature_panel.py +++ b/src/gui/panels/step6_feature_panel.py @@ -234,14 +234,16 @@ class Step6FeaturePanel(QWidget): if mask_path: if not os.path.isabs(mask_path): mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') - self.water_mask_file.set_path(mask_path) + if os.path.exists(mask_path): + self.water_mask_file.set_path(mask_path) if hasattr(main_window, 'step2_panel'): glint_path = main_window.step2_panel.output_file.get_path() if glint_path: if not os.path.isabs(glint_path): glint_path = os.path.join(self.work_dir or '', glint_path).replace('\\', '/') - self.glint_mask_file.set_path(glint_path) + if os.path.exists(glint_path): + self.glint_mask_file.set_path(glint_path) deglint_path = None if pipeline and hasattr(pipeline, 'step_outputs'): @@ -273,15 +275,14 @@ class Step6FeaturePanel(QWidget): if not os.path.isabs(deglint_path): deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/') existing_deglint = self.deglint_img_file.get_path() - if not existing_deglint or not existing_deglint.strip(): + if (not existing_deglint or not existing_deglint.strip()) and os.path.exists(deglint_path): self.deglint_img_file.set_path(deglint_path) - if self.work_dir: + if self.work_dir and not self.output_file.get_path(): output_dir = resolve_subdir(self.work_dir, 'spectral_feature') - os.makedirs(output_dir, exist_ok=True) default_output_path = os.path.join(output_dir, "training_spectra.csv").replace('\\', '/') self.output_file.set_path(default_output_path) - else: + elif not self.work_dir: self.output_file.set_path("") if main_window and hasattr(main_window, 'step5_clean_panel'): @@ -292,7 +293,7 @@ class Step6FeaturePanel(QWidget): self.work_dir or '', step5_clean_output_path ).replace('\\', '/') existing_csv = self.csv_file.get_path() - if not existing_csv or not existing_csv.strip(): + if (not existing_csv or not existing_csv.strip()) and os.path.exists(step5_clean_output_path): self.csv_file.set_path(step5_clean_output_path) def _on_run_single_clicked(self): diff --git a/src/gui/panels/step7_inversion_panel.py b/src/gui/panels/step7_inversion_panel.py index cd234e3..180ff11 100644 --- a/src/gui/panels/step7_inversion_panel.py +++ b/src/gui/panels/step7_inversion_panel.py @@ -9,6 +9,8 @@ import sys import csv from pathlib import Path +import pandas as pd + from PyQt5.QtWidgets import ( QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QLabel, QPushButton, QMessageBox, QListWidget, @@ -263,7 +265,15 @@ class Step7InversionPanel(QWidget): output_layout.setSpacing(16) output_layout.setContentsMargins(20, 24, 20, 20) - # 这里不需要输出文件选择框,因为服务层会直接覆盖原 CSV,但保留执行按钮 + self.output_file = FileSelectWidget( + "结果保存至:", + "CSV Files (*.csv);;All Files (*.*)", + mode="save" + ) + self.output_file.label.setMinimumWidth(120) + self.output_file.line_edit.setPlaceholderText("training_spectra_indices.csv") + output_layout.addWidget(self.output_file) + action_layout = QHBoxLayout() action_layout.addStretch() @@ -328,6 +338,9 @@ class Step7InversionPanel(QWidget): 'formula_names': selected_names, 'enabled': True # 默认启用 } + output_path = self.output_file.get_path() + if output_path: + config['output_path'] = output_path return config def set_config(self, config: dict): @@ -347,6 +360,9 @@ class Step7InversionPanel(QWidget): item.setCheckState(state) self.formula_list.blockSignals(False) + if 'output_path' in config: + self.output_file.set_path(config['output_path']) + def _load_formulas_from_csv(self): """解析公式 CSV 文件并填充列表框""" csv_path = self.formula_file.get_path() @@ -439,7 +455,7 @@ class Step7InversionPanel(QWidget): """从全局配置/Pipeline 同步工作目录。 step6 的训练数据已由 PANEL_REGISTRY 的 dependencies 自动通过 set_config - 注入到 self.training_data_widget;此处仅缓存 work_dir, + 注入到 self.training_data_widget;此处仅缓存 work_dir 并填入默认输出路径, 不重复拉取,避免与 panel_factory 注入路径冲突。 """ if work_dir: @@ -448,3 +464,11 @@ class Step7InversionPanel(QWidget): pass else: self.work_dir = None + + # 仅在输出框为空时填入默认路径(不创建目录,留给 pipeline 执行时创建) + if self.work_dir and not self.output_file.get_path(): + output_dir = os.path.join(self.work_dir, "7_Water_Quality_Indices") + default_path = os.path.join(output_dir, "training_spectra_indices.csv").replace('\\', '/') + self.output_file.set_path(default_path) + elif not self.work_dir: + self.output_file.set_path("") diff --git a/src/gui/panels/step8_ml_train_panel.py b/src/gui/panels/step8_ml_train_panel.py index bc17fec..17bbece 100644 --- a/src/gui/panels/step8_ml_train_panel.py +++ b/src/gui/panels/step8_ml_train_panel.py @@ -85,10 +85,11 @@ class Step8MlTrainPanel(QWidget): self.init_ui() def init_ui(self): + self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) + layout = QVBoxLayout() - - # 标题 - + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(20) # 训练数据文件(用于独立运行) self.training_csv_file = FileSelectWidget( @@ -547,13 +548,11 @@ class Step8MlTrainPanel(QWidget): if candidate: self.training_csv_file.set_path(candidate) - # 2. 自动填充输出目录为 8_Machine_Learning_Models - if self.work_dir: - import os + # 2. 自动填充输出目录(仅在为空时填入默认路径,不创建目录) + if self.work_dir and not self.output_path.get_path(): models_dir = os.path.join(self.work_dir, "8_Machine_Learning_Models").replace('\\', '/') - os.makedirs(models_dir, exist_ok=True) self.output_path.set_path(models_dir) - else: + elif not self.work_dir: self.output_path.set_path("") def _on_run_single_clicked(self): diff --git a/src/gui/panels/step9_ml_predict_panel.py b/src/gui/panels/step9_ml_predict_panel.py index e320212..a1a91c8 100644 --- a/src/gui/panels/step9_ml_predict_panel.py +++ b/src/gui/panels/step9_ml_predict_panel.py @@ -183,11 +183,13 @@ class Step9MlPredictPanel(QWidget): output_layout.setSpacing(16) output_layout.setContentsMargins(20, 24, 20, 20) - # 输出文件路径 + # 输出目录路径(目录模式:模型预测结果为多个 CSV 文件,存放到目录中) self.output_file = FileSelectWidget( - "输出路径:", - "CSV Files (*.csv);;All Files (*.*)" + "输出目录:", + "Directories" ) + self.output_file.browse_btn.clicked.disconnect() + self.output_file.browse_btn.clicked.connect(self._browse_output_dir) output_layout.addWidget(self.output_file) # 完美对齐的底部按钮栏(已彻底移除多余的启用复选框) @@ -400,31 +402,28 @@ class Step9MlPredictPanel(QWidget): factory = getattr(main_window, '_panel_factory', None) if main_window else None # 1. 智能挑选采样 CSV:优先"含 WQI 指数的测试集"(防止特征维度与训练时不匹配) - # 修复目标:用户在 Step 8 用 95 维 (50+45 WQI) 训练 → Step 9 默认读 50 维 raw sampling - # 时 inference_batch.preprocess_spectra 会触发"自动特征补全"逻辑;但若用户已经在 - # Step 10/手工把指数算到 CSV 里了,应该直接用那个文件(少走内存补全、避免 band 列顺序漂移) wqi_test_csv = self._resolve_latest_wqi_test_csv() if wqi_test_csv: self.sampling_csv_file.set_path(wqi_test_csv) elif factory: - # 兜底:拿第 4 步的纯原始采样光谱(旧行为保留) + # 兜底:拿第 4 步的纯原始采样光谱(仅当文件确实存在) step4_panel = factory.get_panel('step4_sampling') if step4_panel and hasattr(step4_panel, 'output_file'): path = step4_panel.output_file.get_path() - if path: self.sampling_csv_file.set_path(path) + if path and os.path.exists(path): + self.sampling_csv_file.set_path(path) - # 2. 拿第 8 步的模型目录 + # 2. 拿第 8 步的模型目录(仅当目录确实存在) if factory: step8_panel = factory.get_panel('step8_ml_train') if step8_panel and hasattr(step8_panel, 'output_path'): path = step8_panel.output_path.get_path() - if path: self.models_dir_file.set_path(path) + if path and os.path.isdir(path): + self.models_dir_file.set_path(path) - # 3. 生成第 9 步的输出目录 - if hasattr(self, 'work_dir') and self.work_dir: - import os + # 3. 生成第 9 步的输出目录(仅在为空时填入默认路径,不创建目录) + if hasattr(self, 'work_dir') and self.work_dir and not self.output_file.get_path(): out_dir = os.path.join(self.work_dir, "9_ML_Prediction").replace('\\', '/') - os.makedirs(out_dir, exist_ok=True) self.output_file.set_path(out_dir) def _get_default_work_dir(self): @@ -445,6 +444,15 @@ class Step9MlPredictPanel(QWidget): if dir_path: self.models_dir_file.set_path(dir_path) + def _browse_output_dir(self): + """浏览预测输出目录""" + default = self._get_default_work_dir() + if default: + default = os.path.join(default, '9_ML_Prediction') + dir_path = QFileDialog.getExistingDirectory(self, "选择预测输出目录", default) + if dir_path: + self.output_file.set_path(dir_path) + def get_config(self): """获取配置""" config = { diff --git a/src/gui/water_quality_gui_v2.py b/src/gui/water_quality_gui_v2.py index bd7fac0..fb6adfc 100644 --- a/src/gui/water_quality_gui_v2.py +++ b/src/gui/water_quality_gui_v2.py @@ -31,8 +31,9 @@ from PyQt5.QtWidgets import ( QPushButton, QLabel, QTabWidget, QToolBar, QSizePolicy, QListWidget, QListWidgetItem, QGroupBox, QTextEdit, QProgressBar, QMessageBox, QFileDialog, + QSpinBox, QDoubleSpinBox, QComboBox, ) -from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtCore import Qt, QTimer, QSize from PyQt5.QtGui import QIcon, QFont, QPixmap, QColor, QTextCursor if multiprocessing.current_process().name == 'MainProcess': @@ -104,8 +105,11 @@ class WaterQualityGUI(QMainWindow): self._apply_stylesheet() self._disable_wheel_for_all_spinboxes() - # 第五步:延迟启动工作目录选择 - QTimer.singleShot(100, self._workspace_initializer.run) + # 第五步:默认选中第一个步骤(延迟执行,确保导航列表和 Tab 均已就位) + QTimer.singleShot(120, self._select_first_nav_item) + + # 第六步:延迟启动工作目录选择 + QTimer.singleShot(200, self._workspace_initializer.run) # ================================================================ # Manager 初始化 @@ -395,9 +399,8 @@ class WaterQualityGUI(QMainWindow): # 分隔符 (不需要实体占用高度,缩小即可) if stage_idx < len(stage_names) - 1: - from PyQt5.QtCore import QSize # 正确引入 QSize sep = QListWidgetItem("") - sep.setSizeHint(QSize(0, 10)) # 直接使用 QSize + sep.setSizeHint(QSize(0, 10)) sep.setFlags(sep.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEnabled) self._step_list.addItem(sep) @@ -467,15 +470,26 @@ class WaterQualityGUI(QMainWindow): continue try: panel.update_from_config(work_dir=work_dir, pipeline=None) - except Exception: - pass + except Exception as e: + # 2026-06-30:不再静默吞异常,至少通过 EventBus 输出日志 + self._event_bus.publish('LogMessage', { + 'message': f'[警告] 面板 {step_id} 的 update_from_config 失败: {e}', + 'level': 'warning', + }) # ================================================================ # 导航 → Tab 单向路由(左侧 List 驱动右侧 Tab,Tab 头部已隐藏) # ================================================================ + def _select_first_nav_item(self): + """默认选中左侧导航栏的第一个可导航步骤项(跳过阶段标题/分隔符)。""" + for i in range(self._step_list.count()): + item = self._step_list.item(i) + if item and item.data(Qt.UserRole) not in (None, "stage_header"): + self._step_list.setCurrentRow(i) + return + def _on_step_list_changed(self, index): - from PyQt5.QtCore import Qt if index < 0: return item = self._step_list.item(index) if not item: return @@ -609,7 +623,6 @@ class WaterQualityGUI(QMainWindow): 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