fix: 全局UX修复与Step4交互可视化重构

=== 自动填入过于激进(幽灵路径级联)===
- 所有面板 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 工具栏(保存/缩放/平移)
- 自动检测坐标列和波段列,完善异常处理
This commit is contained in:
duxin
2026-06-30 09:38:27 +08:00
parent e337f01312
commit 48d17ef0ca
20 changed files with 910 additions and 201 deletions

View File

@ -183,26 +183,35 @@ class WorkspaceManager:
for file_path in subdir_path.rglob('*'): for file_path in subdir_path.rglob('*'):
if file_path.is_file(): if file_path.is_file():
file_name = file_path.name.lower() file_name = file_path.name.lower()
file_stem = file_path.stem.lower()
for step_id in step_ids: for step_id in step_ids:
if step_id not in discovered_outputs: if step_id not in discovered_outputs:
discovered_outputs[step_id] = {} discovered_outputs[step_id] = {}
if 'water_mask' in file_name and step_id == 'step1': # 2026-06-30 加强匹配:用更精确的边界匹配替代简单的子串包含
if self._is_scientific_mask(file_path): # 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) discovered_outputs[step_id]['water_mask'] = str(file_path)
elif 'glint' in file_name and 'mask' in file_name and step_id == 'step2': # glint_mask 匹配:必须同时含 glint 且文件名主体以 mask 或 area 结尾
if self._is_scientific_mask(file_path): 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) 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) 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) 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) 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) 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) 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']: elif file_name.endswith('.csv') and step_id in ['step9_ml_predict', 'step11_map', 'step12_viz']:
discovered_outputs[step_id]['predictions'] = str(file_path) discovered_outputs[step_id]['predictions'] = str(file_path)
@ -255,13 +264,16 @@ class WorkspaceManager:
def prune_config_for_prediction_mode(config: dict) -> dict: def prune_config_for_prediction_mode(config: dict) -> dict:
"""Prediction-only 模式:禁用训练相关步骤,保留预测和成图步骤。 """Prediction-only 模式:禁用训练相关步骤,保留预测和成图步骤。
2026-06-30 修复:步骤 ID 从旧的 PIPELINE_STEPS 体系改为 PANEL_REGISTRY 体系,
确保与 get_current_config() 返回的 key 一致,避免 enabled: False 写入无效 key。
被禁用的 step dict 中统一写入 'enabled': False, 被禁用的 step dict 中统一写入 'enabled': False,
这些配置最终传给 PipelineRunner,Runner 会跳过它们。 这些配置最终传给 PipelineRunner,Runner 会跳过它们。
同时,被跳过的步骤的 required_input_files 在 build_missing_items 同时,被跳过的步骤的 required_input_files 在 build_missing_items
中不会被检查,从而自然规避了"CSV 缺失"等训练模式下的误报。 中不会被检查,从而自然规避了"CSV 缺失"等训练模式下的误报。
Args: Args:
config: 完整配置字典(来自 get_current_config) config: 完整配置字典(来自 get_current_config,key 为 PANEL_REGISTRY step_id)
Returns: Returns:
裁剪后的 config(深拷贝,原 config 不被修改) 裁剪后的 config(深拷贝,原 config 不被修改)
@ -269,12 +281,11 @@ class WorkspaceManager:
cfg = copy.deepcopy(config) cfg = copy.deepcopy(config)
training_steps = [ training_steps = [
"step4", "step4_sampling",
"step5", "step5_clean",
"step7", "step6_feature",
"step6", "step7_index",
"step8_non_empirical_modeling", "step8_ml_train",
"step9",
] ]
for step_id in training_steps: for step_id in training_steps:
step_cfg = cfg.setdefault(step_id, {}) step_cfg = cfg.setdefault(step_id, {})

View File

@ -13,15 +13,24 @@ PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus
内含: 内含:
- 自动识别下游 widget(按 dict 键名查找) - 自动识别下游 widget(按 dict 键名查找)
- sip.isdeleted() 保护:面板销毁后回调自动跳过,避免 C++ 野指针 Segfault
- 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径 - 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径
- 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录 - 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录
2026-06-30 修复:
- 添加 sip.isdeleted(panel) 检查,防止面板删除后访问 C++ 对象导致崩溃
""" """
import os import os
import sip
from src.gui.core.event_bus import global_event_bus 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): 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: if not dependencies:
return 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(): 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): def callback(data):
# ★ 防野指针:面板底层 C++ 对象已销毁时直接跳过
try:
if sip.isdeleted(panel):
return
except Exception:
return
if data.get('step_id') != dep_step: if data.get('step_id') != dep_step:
return return
if data.get('output_type') != output_type: if data.get('output_type') != output_type:
@ -57,10 +93,14 @@ def _make_subscription(panel, dep_step, output_type, target_widget_name):
return return
current = '' current = ''
if hasattr(widget, 'get_path'): try:
current = widget.get_path().strip() if hasattr(widget, 'get_path'):
elif hasattr(widget, 'text'): current = widget.get_path().strip()
current = widget.text().strip() elif hasattr(widget, 'text'):
current = widget.text().strip()
except RuntimeError:
# C++ 对象已被删除
return
if current: if current:
return 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): if 'dir' in target_widget_name.lower() and os.path.isfile(path):
path = os.path.dirname(path) path = os.path.dirname(path)
if hasattr(widget, 'set_path'): try:
widget.set_path(path) if hasattr(widget, 'set_path'):
elif hasattr(widget, 'setText'): widget.set_path(path)
widget.setText(path) elif hasattr(widget, 'setText'):
widget.setText(path)
except RuntimeError:
pass # C++ 对象已被删除
global_event_bus.subscribe('OutputUpdated', callback) return callback

View File

@ -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 collections import defaultdict
from typing import Any, Callable, Dict, List from typing import Any, Callable, Dict, List, Optional
class EventBus: class EventBus:
@ -16,18 +21,48 @@ class EventBus:
def __init__(self): def __init__(self):
self._subscribers: Dict[str, List[Callable]] = defaultdict(list) 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]): def subscribe(self, event_name: str, callback: Callable[[dict], None]):
"""订阅事件。callback 接收一个 dict 作为事件数据。""" """订阅事件。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]): def publish(self, event_name: str, data: Dict[str, Any]):
"""发布事件,通知所有订阅者。""" """发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。
for callback in self._subscribers.get(event_name, []):
迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。
"""
for callback in list(self._subscribers.get(event_name, [])):
try: try:
callback(data) callback(data)
except Exception: 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)
# 全局单例 # 全局单例

View File

@ -15,7 +15,10 @@
- 占位页:未加载的 tab 显示空白 QWidget,加载后原地替换为 QScrollArea(panel) - 占位页:未加载的 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 PyQt5.QtCore import Qt
from src.gui.core.panel_registry import PANEL_REGISTRY 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 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: class PanelFactory:
"""面板注册与装载工厂。 """面板注册与装载工厂。
@ -64,12 +92,11 @@ class PanelFactory:
同时连接 currentChanged 信号驱动懒加载 + 邻接预加载。 同时连接 currentChanged 信号驱动懒加载 + 邻接预加载。
Args: Args:
icons_dir: 图标目录名(相对于项目根),用于 get_resource_path icons_dir: 图标目录名(相对于项目根),用于 _get_resource_path
Returns: Returns:
QTabWidget: 已添加所有占位 tab 的标签页控件 QTabWidget: 已添加所有占位 tab 的标签页控件
""" """
from src.gui.water_quality_gui import get_resource_path
from PyQt5.QtGui import QIcon from PyQt5.QtGui import QIcon
self._tab_widget = QTabWidget() self._tab_widget = QTabWidget()
@ -85,7 +112,7 @@ class PanelFactory:
placeholder = QWidget() placeholder = QWidget()
self._placeholders[idx] = placeholder 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) self._tab_widget.addTab(placeholder, QIcon(icon_path), title)
# 连接切换信号 → 懒加载 # 连接切换信号 → 懒加载
@ -182,6 +209,9 @@ class PanelFactory:
self._panels[step_id] = panel self._panels[step_id] = panel
self._loaded.add(tab_index) self._loaded.add(tab_index)
# ★ 禁用新面板中所有 SpinBox/ComboBox 的滚轮事件(防误触改值)
_disable_wheel_for_widget(panel)
# 事件总线自动接线 # 事件总线自动接线
if deps: if deps:
subscribe_panel_to_dependencies(panel, step_id, deps) subscribe_panel_to_dependencies(panel, step_id, deps)
@ -207,8 +237,12 @@ class PanelFactory:
try: try:
work_dir = self._get_current_work_dir() work_dir = self._get_current_work_dir()
panel.update_from_config(work_dir=work_dir, pipeline=None) panel.update_from_config(work_dir=work_dir, pipeline=None)
except Exception: except Exception as e:
pass # 2026-06-30:不再静默吞异常
global_event_bus.publish('LogMessage', {
'message': f'[警告] 面板 update_from_config 失败: {e}',
'level': 'warning',
})
# 2. 回放 WorkspaceManager 中已累积的 step_outputs # 2. 回放 WorkspaceManager 中已累积的 step_outputs
ws_manager = self._get_workspace_manager() ws_manager = self._get_workspace_manager()
@ -231,8 +265,10 @@ class PanelFactory:
架构解耦(2026-06-22):第三个元素 source_attr 现在明确代表上游控件的真实名字, 架构解耦(2026-06-22):第三个元素 source_attr 现在明确代表上游控件的真实名字,
不再混用语义。回放端仅依赖 source_attr 在 SOURCE 面板上能命中 widget。 不再混用语义。回放端仅依赖 source_attr 在 SOURCE 面板上能命中 widget。
2026-06-30 修复:仅当路径对应的文件/目录确实存在时才广播 OutputUpdated,
防止面板中幽灵占位路径级联扩散到下游。
""" """
from src.gui.core.event_bus import global_event_bus
for entry in self._registry: for entry in self._registry:
deps = entry.get('dependencies') deps = entry.get('dependencies')
if not deps: continue if not deps: continue
@ -244,19 +280,22 @@ class PanelFactory:
widget = getattr(src_panel, source_attr, None) widget = getattr(src_panel, source_attr, None)
if widget is None: continue if widget is None: continue
path = "" path = ""
if hasattr(widget, 'get_path'): if hasattr(widget, 'get_path'):
path = widget.get_path().strip() path = widget.get_path().strip()
elif hasattr(widget, 'text'): elif hasattr(widget, 'text'):
path = widget.text().strip() path = widget.text().strip()
if not path: continue if not path: continue
# 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失 # 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失
import os
absolute_path = os.path.abspath(path).replace('\\', '/') absolute_path = os.path.abspath(path).replace('\\', '/')
# 2026-06-30:仅当文件/目录确实存在时才广播,阻断幽灵路径级联
if not os.path.exists(absolute_path):
continue
global_event_bus.publish('OutputUpdated', { global_event_bus.publish('OutputUpdated', {
'step_id': dep_step, 'step_id': dep_step,
'output_type': output_type, 'output_type': output_type,

View File

@ -187,11 +187,10 @@ PANEL_REGISTRY = [
'display_name': '10. 水色指数反演', 'display_name': '10. 水色指数反演',
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名; # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
# 第三元素 source_attr = 上游源控件真实属性名 # 第三元素 source_attr = 上游源控件真实属性名
# step10 panel 仅有 bsq_file / hdr_file / output_dir,没有 water_mask widget, # step10 panel 使用 CSV 散点模式,输入为 Step4 的采样光谱 CSV
# 故删除 water_mask 依赖,避免悬挂回调
'dependencies': { 'dependencies': {
# 目标框: self.bsq_file ← 上游 step3.output_file # 目标框: self.sampling_csv_file ← 上游 step4_sampling.output_file
'bsq_file': ('step3', 'deglint_image', 'output_file'), 'sampling_csv_file': ('step4_sampling', 'sampling_points', 'output_file'),
}, },
'constructor_kwargs': None, 'constructor_kwargs': None,
}, },

View File

@ -53,19 +53,21 @@ class PreflightDialog(QDialog):
""" """
# step_id → (step_name, panel_tab_index) # step_id → (step_name, panel_tab_index)
# 2026-06-30 修正:step_id 改为 PANEL_REGISTRY 格式,与 build_missing_items 输出一致
STEP_TAB_MAP = { STEP_TAB_MAP = {
"step1": ("水域掩膜", 0), "step1": ("水域掩膜", 0),
"step2": ("耀斑检测", 1), "step2": ("耀斑检测", 1),
"step3": ("耀斑去除", 2), "step3": ("耀斑去除", 2),
"step4": ("数据清洗", 3), "step4_sampling": ("采样点布设", 3),
"step5": ("特征构建", 4), "step5_clean": ("数据清洗", 4),
"step7": ("水质指数", 5), "step6_feature": ("光谱特征提取", 5),
"step8_non_empirical_modeling": ("回归建模", 7), "step7_index": ("水质指数计算", 6),
"step9": ("水色指数反演", 8), "step8_ml_train": ("机器学习建模", 7),
"step10": ("采样点布设", 10), "step9_ml_predict": ("机器学习预测", 8),
"step11_ml": ("监督预测", 11), "step10_watercolor": ("水色指数反演", 9),
"step11": ("回归预测", 12), "step11_map": ("专题图生成", 10),
"step14": ("专题图生成", 13), "step12_viz": ("可视化展示", 11),
"step13_report": ("报告生成", 12),
} }
def __init__(self, missing_items: List[MissingItem], parent=None): def __init__(self, missing_items: List[MissingItem], parent=None):
@ -302,6 +304,12 @@ class PreflightDialog(QDialog):
def build_missing_items(config: dict) -> List[MissingItem]: def build_missing_items(config: dict) -> List[MissingItem]:
"""DAG-aware 预检:从 config 构建缺失项列表。 """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 列表, 1. 按 pipeline 顺序遍历所有 enabled=True 的步骤,收集其 produces 列表,
构建「动态产物集合」dynamically_produced_keys。 构建「动态产物集合」dynamically_produced_keys。
@ -320,7 +328,29 @@ class PreflightDialog(QDialog):
""" """
items: List[MissingItem] = [] 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) step1_enabled = step1_cfg.get('enabled', False)
# ── ★ 构建「动态产物集合」:按 pipeline 顺序收集所有 enabled 步骤的 produces ── # ── ★ 构建「动态产物集合」:按 pipeline 顺序收集所有 enabled 步骤的 produces ──
@ -328,7 +358,7 @@ class PreflightDialog(QDialog):
enabled_step_ids: Set[str] = set() enabled_step_ids: Set[str] = set()
for step_spec in PIPELINE_STEPS: 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): if not step_cfg.get('enabled', True):
continue continue
enabled_step_ids.add(step_spec.step_id) enabled_step_ids.add(step_spec.step_id)
@ -349,20 +379,20 @@ class PreflightDialog(QDialog):
panel_tab_index=0, is_critical=True panel_tab_index=0, is_critical=True
)) ))
# ── step4 csv_path(纯外部输入,必须手动提供)─────────────── # ── step5_clean csv_path(纯外部输入,必须手动提供)───────────
step4_cfg = config.get('step4', {}) step4_cfg = _cfg('step4') # 映射到 step5_clean
step4_enabled = step4_cfg.get('enabled', True) step4_enabled = step4_cfg.get('enabled', True)
if step4_enabled: if step4_enabled:
csv_path = step4_cfg.get('csv_path') csv_path = step4_cfg.get('csv_path')
if not csv_path: if not csv_path:
items.append(MissingItem( items.append(MissingItem(
step_id="step4", step_name="数据清洗", step_id="step5_clean", step_name="数据清洗",
reason="请在「数据清洗」中填写「实测水质数据 CSV」", reason="请在「数据清洗」中填写「实测水质数据 CSV」",
panel_tab_index=3 panel_tab_index=3
)) ))
elif not os.path.isfile(csv_path): elif not os.path.isfile(csv_path):
items.append(MissingItem( items.append(MissingItem(
step_id="step4", step_name="数据清洗", step_id="step5_clean", step_name="数据清洗",
reason=f"实测水质数据文件不存在:{csv_path}", reason=f"实测水质数据文件不存在:{csv_path}",
panel_tab_index=3 panel_tab_index=3
)) ))
@ -374,42 +404,44 @@ class PreflightDialog(QDialog):
# ── ★ DAG-aware 检查:遍历 enabled 步骤的 required_input_files ── # ── ★ DAG-aware 检查:遍历 enabled 步骤的 required_input_files ──
PURE_EXTERNAL_INPUT_KEYS: Set[str] = {'img_path', 'csv_path'} PURE_EXTERNAL_INPUT_KEYS: Set[str] = {'img_path', 'csv_path'}
_TAB_INDEX_MAP: Dict[str, int] = { _TAB_INDEX_MAP: Dict[str, int] = {
"step1": 0, "step2": 1, "step3": 2, "step4": 3, "step1": 0, "step2": 1, "step3": 2,
"step5": 4, "step8": 5, "step7": 6, "step4_sampling": 3, "step5_clean": 4, "step6_feature": 5,
"step8_non_empirical_modeling": 7, "step9": 8, "step7_index": 6, "step8_ml_train": 7,
"step10": 9, "step11_ml": 10, "step11": 11, "step9_ml_predict": 8, "step10_watercolor": 9,
"step12": 12, "step14": 13, "step11_map": 10, "step12_viz": 11, "step13_report": 12,
} }
_STEP_NAME_MAP: Dict[str, str] = { _STEP_NAME_MAP: Dict[str, str] = {
"step1": "水域掩膜", "step2": "耀斑检测", "step3": "耀斑去除", "step1": "水域掩膜", "step2": "耀斑检测", "step3": "耀斑去除",
"step4": "数据清洗", "step5": "特征构建", "step8": "水质指数", "step4_sampling": "采样点布设", "step5_clean": "数据清洗",
"step7": "监督建模", "step8_non_empirical_modeling": "回归建模", "step6_feature": "光谱特征提取", "step7_index": "水质指数计算",
"step9": "自定义回归建模", "step10": "采样点布设", "step8_ml_train": "机器学习建模", "step9_ml_predict": "机器学习预测",
"step11_ml": "监督预测", "step11": "回归预测", "step10_watercolor": "水色指数反演", "step11_map": "专题图生成",
"step12": "自定义回归预测", "step14": "专题图生成", "step12_viz": "可视化展示", "step13_report": "报告生成",
} }
for step_spec in PIPELINE_STEPS: for step_spec in PIPELINE_STEPS:
if step_spec.step_id not in enabled_step_ids: if step_spec.step_id not in enabled_step_ids:
continue continue
step_cfg = config.get(step_spec.step_id, {}) step_cfg = _cfg(step_spec.step_id) # ★ 通过映射表翻译 step_id
tab_idx = _TAB_INDEX_MAP.get(step_spec.step_id, 0) # ★ 将 PIPELINE_STEPS step_id 翻译为 PANEL_REGISTRY step_id
step_name = _STEP_NAME_MAP.get(step_spec.step_id, step_spec.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: for req_key in step_spec.required_input_files:
# ★★★ 高优先级硬编码白名单 ★★★ # ★★★ 高优先级硬编码白名单 ★★★
# 当检测到需求为边界文件时,只要 step1 有填影像(代表有基础,底层能自动推导),直接放行 # 当检测到需求为边界文件时,只要 step1 有填影像(代表有基础,底层能自动推导),直接放行
if req_key in ('boundary_path', 'boundary_shp_path'): if req_key in ('boundary_path', 'boundary_shp_path'):
step1_cfg = config.get('step1', {}) _step1_cfg = _cfg('step1')
if step1_cfg.get('img_path') or step1_cfg.get('enabled', True): if _step1_cfg.get('img_path') or _step1_cfg.get('enabled', True):
continue # 直接跳过,不判定为缺失 continue # 直接跳过,不判定为缺失
if req_key in PURE_EXTERNAL_INPUT_KEYS: if req_key in PURE_EXTERNAL_INPUT_KEYS:
continue continue
if req_key == 'formula_csv_path': if req_key == 'formula_csv_path':
continue # ★ 底层完全可选,赦免 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/底层自动推导,赦免 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 自动回填,赦免 continue # ★ step1 执行则 panel 自动回填,赦免
cfg_val = step_cfg.get(req_key) cfg_val = step_cfg.get(req_key)
if cfg_val and os.path.isfile(cfg_val): if cfg_val and os.path.isfile(cfg_val):
@ -419,11 +451,11 @@ class PreflightDialog(QDialog):
if req_key in dynamically_produced_keys: if req_key in dynamically_produced_keys:
continue # ★ 前置步骤会生成,拓扑预判通过 continue # ★ 前置步骤会生成,拓扑预判通过
items.append(MissingItem( items.append(MissingItem(
step_id=step_spec.step_id, step_id=panel_step_id,
step_name=step_name, step_name=step_name,
reason=f"缺少必需文件/目录 [{req_key}]", reason=f"缺少必需文件/目录 [{req_key}]",
panel_tab_index=tab_idx, 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 return items

View File

@ -188,12 +188,20 @@ except Exception as e:
# ============================================================================= # =============================================================================
class WorkerThread(QThread): class WorkerThread(QThread):
"""后台工作线程,用于执行耗时任务(在工作线程内创建 Pipeline,避免阻塞 UI)。""" """后台工作线程,用于执行耗时任务(在工作线程内创建 Pipeline,避免阻塞 UI)。
2026-06-30 修复:
- stop() 不再直接调用 terminate(),改为先请求取消 + 等待线程自然结束 + 超时兜底
- total_steps 从 config 动态计算,不再硬编码为 9
"""
progress_update = pyqtSignal(int, str) # 进度更新信号 (percentage, message) progress_update = pyqtSignal(int, str) # 进度更新信号 (percentage, message)
log_message = pyqtSignal(str, str) # 日志消息信号 (message, level: 'info'/'warning'/'error') log_message = pyqtSignal(str, str) # 日志消息信号 (message, level: 'info'/'warning'/'error')
step_completed = pyqtSignal(str, bool, str) # 步骤完成信号 (step_name, success, message) step_completed = pyqtSignal(str, bool, str) # 步骤完成信号 (step_name, success, message)
finished = pyqtSignal(bool, str) # 完成信号 (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): def __init__(self, work_dir: str, config, mode='full', step_name=None, skip_list=None):
super().__init__() super().__init__()
self.work_dir = str(work_dir) self.work_dir = str(work_dir)
@ -205,7 +213,10 @@ class WorkerThread(QThread):
self.is_running = True self.is_running = True
self.current_step = None self.current_step = None
self.step_count = 0 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=""): def pipeline_callback(self, step_name, status, message=""):
"""Pipeline回调函数,用于接收步骤状态""" """Pipeline回调函数,用于接收步骤状态"""
@ -506,6 +517,35 @@ class WorkerThread(QThread):
) )
def stop(self): 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.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 生效

View File

@ -567,17 +567,22 @@ class Step10WatercolorPanel(QWidget):
) )
# 2. 回退:直接读 step4_sampling panel 的 output_file widget # 2. 回退:直接读 step4_sampling panel 的 output_file widget
# 2026-06-30:panel widget 可能含幽灵占位路径,仅当文件确实存在时才采纳
if not sampling_path and main_window: if not sampling_path and main_window:
step4_widget = getattr(main_window, 'step4_sampling', None) step4_widget = getattr(main_window, 'step4_sampling', None)
if step4_widget and hasattr(step4_widget, 'output_file'): if step4_widget and hasattr(step4_widget, 'output_file'):
sampling_path = step4_widget.output_file.get_path() candidate = step4_widget.output_file.get_path()
else: if candidate and os.path.isfile(candidate):
sampling_path = candidate
if not sampling_path:
# 通过 _panel_factory 懒加载查找 # 通过 _panel_factory 懒加载查找
factory = getattr(main_window, '_panel_factory', None) factory = getattr(main_window, '_panel_factory', None)
if factory: if factory:
step4_panel = factory.get_panel('step4_sampling') step4_panel = factory.get_panel('step4_sampling')
if step4_panel and hasattr(step4_panel, 'output_file'): 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 # 3. 终极回退:扫描 work_dir/4_sampling/sampling_spectra.csv
if not sampling_path and self.work_dir: if not sampling_path and self.work_dir:
@ -593,14 +598,12 @@ class Step10WatercolorPanel(QWidget):
).replace('\\', '/') ).replace('\\', '/')
self.sampling_csv_file.set_path(sampling_path) 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( out_dir = os.path.join(
self.work_dir, '10_WaterIndex_CSV' self.work_dir, '10_WaterIndex_CSV'
).replace('\\', '/') ).replace('\\', '/')
os.makedirs(out_dir, exist_ok=True) self.output_dir.set_path(out_dir)
if not self.output_dir.get_path():
self.output_dir.set_path(out_dir)
def _on_run_single_clicked(self): def _on_run_single_clicked(self):
"""通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。""" """通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。"""

View File

@ -482,35 +482,32 @@ class Step11MapPanel(QWidget):
factory = getattr(main_window, '_panel_factory', None) if main_window else None factory = getattr(main_window, '_panel_factory', None) if main_window else None
if not factory: return if not factory: return
# 1. 安全抓取 Step 9 的预测 CSV 目录 # 1. 安全抓取 Step 9 的预测 CSV 目录(仅当目录确实存在)
step9_panel = factory.get_panel('step9_ml_predict') step9_panel = factory.get_panel('step9_ml_predict')
if step9_panel and hasattr(step9_panel, 'output_file'): if step9_panel and hasattr(step9_panel, 'output_file'):
path = step9_panel.output_file.get_path() path = step9_panel.output_file.get_path()
if path: if path and os.path.isdir(path):
self.prediction_csv_dir_edit.setText(path) self.prediction_csv_dir_edit.setText(path)
self.batch_mode_combo.setCurrentIndex(1) self.batch_mode_combo.setCurrentIndex(1)
# 2. 安全抓取 Step 1 的真实掩膜文件(彻底拒绝瞎猜 roi.shp) # 2. 安全抓取 Step 1 的真实掩膜文件(仅当文件确实存在)
step1_panel = factory.get_panel('step1') step1_panel = factory.get_panel('step1')
if step1_panel: if step1_panel:
use_ndwi = step1_panel.use_ndwi_radio.isChecked() use_ndwi = step1_panel.use_ndwi_radio.isChecked()
# 根据用户在第1步的选择,拿真实的输出掩膜或导入的掩膜
if use_ndwi and hasattr(step1_panel, 'output_file'): if use_ndwi and hasattr(step1_panel, 'output_file'):
path = step1_panel.output_file.get_path() path = step1_panel.output_file.get_path()
elif not use_ndwi and hasattr(step1_panel, 'mask_file'): elif not use_ndwi and hasattr(step1_panel, 'mask_file'):
path = step1_panel.mask_file.get_path() path = step1_panel.mask_file.get_path()
else: else:
path = "" path = ""
existing = self.boundary_file.get_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) self.boundary_file.set_path(path)
# 3. 生成第 11 步的绝对输出目录 (杜绝保存到相对路径) # 3. 生成第 11 步的输出目录(仅在为空时填入默认路径,不创建目录)
if hasattr(self, 'work_dir') and self.work_dir: if hasattr(self, 'work_dir') and self.work_dir and not self.output_dir.get_path():
import os
out_dir = os.path.join(self.work_dir, "14_visualization").replace('\\', '/') 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) self.output_dir.set_path(out_dir)
def browse_output_dir(self): def browse_output_dir(self):

View File

@ -1656,21 +1656,22 @@ class Step12VizPanel(QWidget):
self.image_viewer.load_image(str(image_files[0])) self.image_viewer.load_image(str(image_files[0]))
def _setup_prediction_output_dirs(self, work_path: Path): def _setup_prediction_output_dirs(self, work_path: Path):
"""设置三个预测步骤的默认输出目录""" """收集预测输出目录路径信息(不创建目录,仅用于日志/调试)。
2026-06-30 修复:移除 mkdir 调用,不再在未运行 pipeline 时创建空目录。
目录创建统一留给各 pipeline 步骤在实际执行时处理。
"""
try: try:
base_prediction_dir = Path(resolve_subdir(str(work_path), 'prediction_dir')) base_prediction_dir = Path(resolve_subdir(str(work_path), 'prediction_dir'))
ml_dir = Path(resolve_subdir(str(work_path), 'ml_prediction')) ml_dir = Path(resolve_subdir(str(work_path), 'ml_prediction'))
reg_dir = base_prediction_dir / "Regression_Model_Prediction" reg_dir = base_prediction_dir / "Regression_Model_Prediction"
custom_dir = Path(resolve_subdir(str(work_path), 'custom_regression')) / "Custom_Regression_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) existing = [str(d) for d in (ml_dir, reg_dir, custom_dir) if d.is_dir()]
custom_dir.mkdir(parents=True, exist_ok=True) if existing:
# 旧的 self.step11_ml_panel/step11_panel/step12_panel 在 Step12VizPanel 上不存在,是死代码。 print(f"预测输出目录已存在: {existing}")
# 三个目录的真实默认值在用户首次浏览 / 自动填充时由各 panel 自己的 _get_default_work_dir 路径产出。
# 这里仅做目录创建 + 提示输出,便于用户在工作目录树中能看到预测输出位置。
print(f"预测输出目录已创建:\n ML: {ml_dir}\n Reg: {reg_dir}\n Custom: {custom_dir}")
except Exception as e: except Exception as e:
print(f"设置预测输出目录失败: {e}") print(f"读取预测输出目录信息失败: {e}")
def on_tree_item_clicked(self, item, column): def on_tree_item_clicked(self, item, column):
"""目录树项点击事件""" """目录树项点击事件"""

View File

@ -114,6 +114,9 @@ class Step1Panel(QWidget):
config_group.setLayout(config_layout) config_group.setLayout(config_layout)
main_layout.addWidget(config_group) main_layout.addWidget(config_group)
# ==========================================
# 卡片 3:输出与执行(与 Step8 对齐的现代化卡片风格)
# ==========================================
output_group = QGroupBox("🚀 输出与执行") output_group = QGroupBox("🚀 输出与执行")
output_layout = QVBoxLayout() output_layout = QVBoxLayout()
output_layout.setSpacing(16) output_layout.setSpacing(16)
@ -124,11 +127,10 @@ class Step1Panel(QWidget):
"Mask Files (*.dat *.tif);;All Files (*.*)", "Mask Files (*.dat *.tif);;All Files (*.*)",
mode="save" mode="save"
) )
self.output_file.label.setMinimumWidth(100)
self.output_file.line_edit.setPlaceholderText("water_mask.dat") self.output_file.line_edit.setPlaceholderText("water_mask.dat")
output_layout.addWidget(self.output_file) output_layout.addWidget(self.output_file)
# 完美对齐的底部按钮栏(已移除多余的启用步骤选项) # 完美对齐的底部按钮栏
action_layout = QHBoxLayout() action_layout = QHBoxLayout()
action_layout.addStretch() action_layout.addStretch()
@ -140,6 +142,8 @@ class Step1Panel(QWidget):
output_layout.addLayout(action_layout) output_layout.addLayout(action_layout)
output_group.setLayout(output_layout) output_group.setLayout(output_layout)
# 将打包好的输出卡片添加到主 layout 中
main_layout.addWidget(output_group) main_layout.addWidget(output_group)
self.use_existing_radio.toggled.connect(self.update_ui_state) self.use_existing_radio.toggled.connect(self.update_ui_state)
@ -164,10 +168,12 @@ class Step1Panel(QWidget):
self._auto_fill_output_path() self._auto_fill_output_path()
def _auto_fill_output_path(self): def _auto_fill_output_path(self):
"""仅在输出框为空时填入默认路径;不创建目录(留给 pipeline 执行时创建)。"""
if not hasattr(self, 'work_dir') or not self.work_dir: if not hasattr(self, 'work_dir') or not self.work_dir:
return return
if self.output_file.get_path():
return # 用户已手动指定,不覆盖
output_dir = resolve_subdir(self.work_dir, 'water_mask') 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('\\', '/') default_output_path = os.path.join(output_dir, "water_mask_out.dat").replace('\\', '/')
self.output_file.set_path(default_output_path) self.output_file.set_path(default_output_path)

View File

@ -192,13 +192,15 @@ class Step2Panel(QWidget):
if mask_path: if mask_path:
if not os.path.isabs(mask_path): if not os.path.isabs(mask_path):
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') 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: if self.work_dir:
output_dir = resolve_subdir(self.work_dir, 'glint_detection') if not self.output_file.get_path():
os.makedirs(output_dir, exist_ok=True) output_dir = resolve_subdir(self.work_dir, 'glint_detection')
default_output_path = os.path.join(output_dir, "severe_glint_area.dat").replace('\\', '/') default_output_path = os.path.join(output_dir, "severe_glint_area.dat").replace('\\', '/')
self.output_file.set_path(default_output_path) self.output_file.set_path(default_output_path)
else: else:
self.output_file.set_path("") self.output_file.set_path("")

View File

@ -317,13 +317,15 @@ class Step3Panel(QWidget):
if mask_path: if mask_path:
if not os.path.isabs(mask_path): if not os.path.isabs(mask_path):
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') 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: if self.work_dir:
output_dir = resolve_subdir(self.work_dir, 'deglint') if not self.output_file.get_path():
os.makedirs(output_dir, exist_ok=True) output_dir = resolve_subdir(self.work_dir, 'deglint')
default_output_path = os.path.join(output_dir, "deglint_image.bsq").replace('\\', '/') default_output_path = os.path.join(output_dir, "deglint_image.bsq").replace('\\', '/')
self.output_file.set_path(default_output_path) self.output_file.set_path(default_output_path)
else: else:
self.output_file.set_path("") self.output_file.set_path("")

View File

@ -1,13 +1,22 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Step4 面板 - 采样点布设 (已移除“启用此步骤”) Step4 面板 - 采样点布设(内嵌交互式光谱探针视图)
2026-06-30 重构:
- 左右分栏布局 (QSplitter):左侧控制区 + 右侧嵌入式 Matplotlib 视图
- 1×2 子图:ax1 散点图 + ax2 光谱曲线
- Hover 悬停显示坐标提示,Click 点击绘制该点光谱曲线
- NavigationToolbar2QT 工具栏自带保存/缩放/平移
""" """
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
import numpy as np
import pandas as pd
_HERE = os.path.dirname(os.path.abspath(__file__)) _HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path: if _HERE not in sys.path:
sys.path.insert(0, _HERE) sys.path.insert(0, _HERE)
@ -16,9 +25,16 @@ from _step_path_resolver import resolve_subdir
from PyQt5.QtCore import QTimer, Qt from PyQt5.QtCore import QTimer, Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, 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.components.custom_widgets import FileSelectWidget
from src.gui.dialogs import SamplingViewerDialog from src.gui.dialogs import SamplingViewerDialog
from src.gui.styles import ModernStylesheet from src.gui.styles import ModernStylesheet
@ -27,15 +43,39 @@ from src.gui.styles import ModernStylesheet
class Step4SamplingPanel(QWidget): class Step4SamplingPanel(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) 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() self.init_ui()
# ═══════════════════════════════════════════════════════════════
# UI 构建
# ═══════════════════════════════════════════════════════════════
def init_ui(self): def init_ui(self):
self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
main_layout = QVBoxLayout() # ── 顶层:水平分栏 (QSplitter) ──
main_layout.setContentsMargins(24, 24, 24, 24) splitter = QSplitter(Qt.Horizontal)
main_layout.setSpacing(20) 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_group = QGroupBox("📁 输入数据")
input_layout = QVBoxLayout() input_layout = QVBoxLayout()
input_layout.setSpacing(16) input_layout.setSpacing(16)
@ -52,8 +92,9 @@ class Step4SamplingPanel(QWidget):
input_layout.addWidget(self.deglint_img_file) input_layout.addWidget(self.deglint_img_file)
input_layout.addWidget(self.water_mask_file) input_layout.addWidget(self.water_mask_file)
input_group.setLayout(input_layout) input_group.setLayout(input_layout)
main_layout.addWidget(input_group) left_layout.addWidget(input_group)
# --- 卡片 2:采样参数 ---
params_group = QGroupBox("⚙️ 采样参数") params_group = QGroupBox("⚙️ 采样参数")
params_layout = QFormLayout() params_layout = QFormLayout()
params_layout.setSpacing(16) params_layout.setSpacing(16)
@ -80,14 +121,14 @@ class Step4SamplingPanel(QWidget):
self.chunk_size.setMinimumWidth(120) self.chunk_size.setMinimumWidth(120)
params_layout.addRow("内存处理块大小:", self.chunk_size) params_layout.addRow("内存处理块大小:", self.chunk_size)
from PyQt5.QtWidgets import QCheckBox
self.use_adaptive_sampling = QCheckBox("启用自适应边缘采样") self.use_adaptive_sampling = QCheckBox("启用自适应边缘采样")
self.use_adaptive_sampling.setChecked(True) self.use_adaptive_sampling.setChecked(True)
params_layout.addRow("智能模式:", self.use_adaptive_sampling) params_layout.addRow("智能模式:", self.use_adaptive_sampling)
params_group.setLayout(params_layout) params_group.setLayout(params_layout)
main_layout.addWidget(params_group) left_layout.addWidget(params_group)
# --- 卡片 3:输出与执行 ---
output_group = QGroupBox("🚀 输出与执行") output_group = QGroupBox("🚀 输出与执行")
output_layout = QVBoxLayout() output_layout = QVBoxLayout()
output_layout.setSpacing(16) output_layout.setSpacing(16)
@ -104,33 +145,104 @@ class Step4SamplingPanel(QWidget):
action_layout = QHBoxLayout() action_layout = QHBoxLayout()
action_layout.addStretch() action_layout.addStretch()
self.preview_btn = QPushButton("交互式预览采样点") self.refresh_btn = QPushButton("🔄 刷新视图")
self.preview_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal')) self.refresh_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal'))
self.preview_btn.setEnabled(False) self.refresh_btn.setEnabled(False)
self.preview_btn.setMinimumWidth(160) self.refresh_btn.setMinimumWidth(140)
self.preview_btn.clicked.connect(self._open_sampling_viewer) self.refresh_btn.setToolTip("重新加载 CSV 并渲染采样点散点图")
self.refresh_btn.clicked.connect(self._on_refresh_clicked)
self.run_btn = QPushButton("独立运行步骤") self.run_btn = QPushButton("独立运行步骤")
self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary')) self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary'))
self.run_btn.setMinimumWidth(140) self.run_btn.setMinimumWidth(140)
self.run_btn.clicked.connect(self._on_run_single_clicked) 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) action_layout.addWidget(self.run_btn)
output_layout.addLayout(action_layout) output_layout.addLayout(action_layout)
output_group.setLayout(output_layout) output_group.setLayout(output_layout)
main_layout.addWidget(output_group) left_layout.addWidget(output_group)
main_layout.addStretch() left_layout.addStretch()
self.setLayout(main_layout) 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 = QTimer(self)
self._status_timer.timeout.connect(self._check_csv_exists) self._status_timer.timeout.connect(self._check_csv_and_auto_render)
self._status_timer.start(2000) self._status_timer.start(5000)
self.output_file.line_edit.textChanged.connect(self._on_output_changed) self.output_file.line_edit.textChanged.connect(self._on_output_changed)
# ═══════════════════════════════════════════════════════════════
# 配置读写(保持不变)
# ═══════════════════════════════════════════════════════════════
def get_config(self): def get_config(self):
config = { config = {
'interval': self.interval.value(), 'interval': self.interval.value(),
@ -182,7 +294,8 @@ class Step4SamplingPanel(QWidget):
if deglint_path: if deglint_path:
if not os.path.isabs(deglint_path): if not os.path.isabs(deglint_path):
deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/') 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 water_mask_path = None
if pipeline and hasattr(pipeline, 'step_outputs'): if pipeline and hasattr(pipeline, 'step_outputs'):
@ -215,14 +328,22 @@ class Step4SamplingPanel(QWidget):
if water_mask_path: if water_mask_path:
if not os.path.isabs(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('\\', '/') 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') 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.output_file.set_path(output_path.replace('\\', '/'))
self._check_csv_exists() 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): def _on_run_single_clicked(self):
from src.gui.core.event_bus import global_event_bus from src.gui.core.event_bus import global_event_bus
@ -236,20 +357,354 @@ class Step4SamplingPanel(QWidget):
'config': config, 'config': config,
}) })
# ═══════════════════════════════════════════════════════════════
# CSV 状态检测
# ═══════════════════════════════════════════════════════════════
def _check_csv_exists(self): def _check_csv_exists(self):
csv_path = self.output_file.get_path() csv_path = self.output_file.get_path()
enabled = bool(csv_path and os.path.isabs(csv_path) and os.path.exists(csv_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 return enabled
def _on_output_changed(self, _text=None): def _on_output_changed(self, _text=None):
self._check_csv_exists() 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): def _open_sampling_viewer(self):
"""打开独立的 SamplingViewerDialog 弹窗(保留兼容)。"""
csv_path = self.output_file.get_path() csv_path = self.output_file.get_path()
if not csv_path or not os.path.exists(csv_path): if not csv_path or not os.path.exists(csv_path):
QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。") QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。")
return return
dialog = SamplingViewerDialog(csv_path, self) dialog = SamplingViewerDialog(csv_path, self)
dialog.exec_() dialog.exec_()
self._check_csv_exists() self._check_csv_exists()

View File

@ -190,12 +190,11 @@ class Step5CleanPanel(QWidget):
else: else:
self.work_dir = None 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') 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('\\', '/') default_output_path = os.path.join(output_dir, "processed_data.csv").replace('\\', '/')
self.output_file.set_path(default_output_path) self.output_file.set_path(default_output_path)
else: elif not self.work_dir:
self.output_file.set_path("") self.output_file.set_path("")
def _on_run_single_clicked(self): def _on_run_single_clicked(self):

View File

@ -234,14 +234,16 @@ class Step6FeaturePanel(QWidget):
if mask_path: if mask_path:
if not os.path.isabs(mask_path): if not os.path.isabs(mask_path):
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') 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'): if hasattr(main_window, 'step2_panel'):
glint_path = main_window.step2_panel.output_file.get_path() glint_path = main_window.step2_panel.output_file.get_path()
if glint_path: if glint_path:
if not os.path.isabs(glint_path): if not os.path.isabs(glint_path):
glint_path = os.path.join(self.work_dir or '', glint_path).replace('\\', '/') 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 deglint_path = None
if pipeline and hasattr(pipeline, 'step_outputs'): if pipeline and hasattr(pipeline, 'step_outputs'):
@ -273,15 +275,14 @@ class Step6FeaturePanel(QWidget):
if not os.path.isabs(deglint_path): if not os.path.isabs(deglint_path):
deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/') deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/')
existing_deglint = self.deglint_img_file.get_path() 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) 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') 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('\\', '/') default_output_path = os.path.join(output_dir, "training_spectra.csv").replace('\\', '/')
self.output_file.set_path(default_output_path) self.output_file.set_path(default_output_path)
else: elif not self.work_dir:
self.output_file.set_path("") self.output_file.set_path("")
if main_window and hasattr(main_window, 'step5_clean_panel'): 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 self.work_dir or '', step5_clean_output_path
).replace('\\', '/') ).replace('\\', '/')
existing_csv = self.csv_file.get_path() 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) self.csv_file.set_path(step5_clean_output_path)
def _on_run_single_clicked(self): def _on_run_single_clicked(self):

View File

@ -9,6 +9,8 @@ import sys
import csv import csv
from pathlib import Path from pathlib import Path
import pandas as pd
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QLabel, QPushButton, QMessageBox, QListWidget, QLabel, QPushButton, QMessageBox, QListWidget,
@ -263,7 +265,15 @@ class Step7InversionPanel(QWidget):
output_layout.setSpacing(16) output_layout.setSpacing(16)
output_layout.setContentsMargins(20, 24, 20, 20) 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 = QHBoxLayout()
action_layout.addStretch() action_layout.addStretch()
@ -328,6 +338,9 @@ class Step7InversionPanel(QWidget):
'formula_names': selected_names, 'formula_names': selected_names,
'enabled': True # 默认启用 'enabled': True # 默认启用
} }
output_path = self.output_file.get_path()
if output_path:
config['output_path'] = output_path
return config return config
def set_config(self, config: dict): def set_config(self, config: dict):
@ -347,6 +360,9 @@ class Step7InversionPanel(QWidget):
item.setCheckState(state) item.setCheckState(state)
self.formula_list.blockSignals(False) self.formula_list.blockSignals(False)
if 'output_path' in config:
self.output_file.set_path(config['output_path'])
def _load_formulas_from_csv(self): def _load_formulas_from_csv(self):
"""解析公式 CSV 文件并填充列表框""" """解析公式 CSV 文件并填充列表框"""
csv_path = self.formula_file.get_path() csv_path = self.formula_file.get_path()
@ -439,7 +455,7 @@ class Step7InversionPanel(QWidget):
"""从全局配置/Pipeline 同步工作目录。 """从全局配置/Pipeline 同步工作目录。
step6 的训练数据已由 PANEL_REGISTRY 的 dependencies 自动通过 set_config step6 的训练数据已由 PANEL_REGISTRY 的 dependencies 自动通过 set_config
注入到 self.training_data_widget;此处仅缓存 work_dir, 注入到 self.training_data_widget;此处仅缓存 work_dir 并填入默认输出路径,
不重复拉取,避免与 panel_factory 注入路径冲突。 不重复拉取,避免与 panel_factory 注入路径冲突。
""" """
if work_dir: if work_dir:
@ -448,3 +464,11 @@ class Step7InversionPanel(QWidget):
pass pass
else: else:
self.work_dir = None 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("")

View File

@ -85,10 +85,11 @@ class Step8MlTrainPanel(QWidget):
self.init_ui() self.init_ui()
def init_ui(self): def init_ui(self):
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
layout = QVBoxLayout() layout = QVBoxLayout()
layout.setContentsMargins(24, 24, 24, 24)
# 标题 layout.setSpacing(20)
# 训练数据文件(用于独立运行) # 训练数据文件(用于独立运行)
self.training_csv_file = FileSelectWidget( self.training_csv_file = FileSelectWidget(
@ -547,13 +548,11 @@ class Step8MlTrainPanel(QWidget):
if candidate: if candidate:
self.training_csv_file.set_path(candidate) self.training_csv_file.set_path(candidate)
# 2. 自动填充输出目录为 8_Machine_Learning_Models # 2. 自动填充输出目录(仅在为空时填入默认路径,不创建目录)
if self.work_dir: if self.work_dir and not self.output_path.get_path():
import os
models_dir = os.path.join(self.work_dir, "8_Machine_Learning_Models").replace('\\', '/') 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) self.output_path.set_path(models_dir)
else: elif not self.work_dir:
self.output_path.set_path("") self.output_path.set_path("")
def _on_run_single_clicked(self): def _on_run_single_clicked(self):

View File

@ -183,11 +183,13 @@ class Step9MlPredictPanel(QWidget):
output_layout.setSpacing(16) output_layout.setSpacing(16)
output_layout.setContentsMargins(20, 24, 20, 20) output_layout.setContentsMargins(20, 24, 20, 20)
# 输出文件路径 # 输出目录路径(目录模式:模型预测结果为多个 CSV 文件,存放到目录中)
self.output_file = FileSelectWidget( 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) 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 factory = getattr(main_window, '_panel_factory', None) if main_window else None
# 1. 智能挑选采样 CSV:优先"含 WQI 指数的测试集"(防止特征维度与训练时不匹配) # 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() wqi_test_csv = self._resolve_latest_wqi_test_csv()
if wqi_test_csv: if wqi_test_csv:
self.sampling_csv_file.set_path(wqi_test_csv) self.sampling_csv_file.set_path(wqi_test_csv)
elif factory: elif factory:
# 兜底:拿第 4 步的纯原始采样光谱(旧行为保留) # 兜底:拿第 4 步的纯原始采样光谱(仅当文件确实存在)
step4_panel = factory.get_panel('step4_sampling') step4_panel = factory.get_panel('step4_sampling')
if step4_panel and hasattr(step4_panel, 'output_file'): if step4_panel and hasattr(step4_panel, 'output_file'):
path = step4_panel.output_file.get_path() 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: if factory:
step8_panel = factory.get_panel('step8_ml_train') step8_panel = factory.get_panel('step8_ml_train')
if step8_panel and hasattr(step8_panel, 'output_path'): if step8_panel and hasattr(step8_panel, 'output_path'):
path = step8_panel.output_path.get_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 步的输出目录 # 3. 生成第 9 步的输出目录(仅在为空时填入默认路径,不创建目录)
if hasattr(self, 'work_dir') and self.work_dir: if hasattr(self, 'work_dir') and self.work_dir and not self.output_file.get_path():
import os
out_dir = os.path.join(self.work_dir, "9_ML_Prediction").replace('\\', '/') 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) self.output_file.set_path(out_dir)
def _get_default_work_dir(self): def _get_default_work_dir(self):
@ -445,6 +444,15 @@ class Step9MlPredictPanel(QWidget):
if dir_path: if dir_path:
self.models_dir_file.set_path(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): def get_config(self):
"""获取配置""" """获取配置"""
config = { config = {

View File

@ -31,8 +31,9 @@ from PyQt5.QtWidgets import (
QPushButton, QLabel, QTabWidget, QToolBar, QSizePolicy, QPushButton, QLabel, QTabWidget, QToolBar, QSizePolicy,
QListWidget, QListWidgetItem, QGroupBox, QListWidget, QListWidgetItem, QGroupBox,
QTextEdit, QProgressBar, QMessageBox, QFileDialog, 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 from PyQt5.QtGui import QIcon, QFont, QPixmap, QColor, QTextCursor
if multiprocessing.current_process().name == 'MainProcess': if multiprocessing.current_process().name == 'MainProcess':
@ -104,8 +105,11 @@ class WaterQualityGUI(QMainWindow):
self._apply_stylesheet() self._apply_stylesheet()
self._disable_wheel_for_all_spinboxes() self._disable_wheel_for_all_spinboxes()
# 第五步:延迟启动工作目录选择 # 第五步:默认选中第一个步骤(延迟执行,确保导航列表和 Tab 均已就位)
QTimer.singleShot(100, self._workspace_initializer.run) QTimer.singleShot(120, self._select_first_nav_item)
# 第六步:延迟启动工作目录选择
QTimer.singleShot(200, self._workspace_initializer.run)
# ================================================================ # ================================================================
# Manager 初始化 # Manager 初始化
@ -395,9 +399,8 @@ class WaterQualityGUI(QMainWindow):
# 分隔符 (不需要实体占用高度,缩小即可) # 分隔符 (不需要实体占用高度,缩小即可)
if stage_idx < len(stage_names) - 1: if stage_idx < len(stage_names) - 1:
from PyQt5.QtCore import QSize # 正确引入 QSize
sep = QListWidgetItem("") sep = QListWidgetItem("")
sep.setSizeHint(QSize(0, 10)) # 直接使用 QSize sep.setSizeHint(QSize(0, 10))
sep.setFlags(sep.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEnabled) sep.setFlags(sep.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEnabled)
self._step_list.addItem(sep) self._step_list.addItem(sep)
@ -467,15 +470,26 @@ class WaterQualityGUI(QMainWindow):
continue continue
try: try:
panel.update_from_config(work_dir=work_dir, pipeline=None) panel.update_from_config(work_dir=work_dir, pipeline=None)
except Exception: except Exception as e:
pass # 2026-06-30:不再静默吞异常,至少通过 EventBus 输出日志
self._event_bus.publish('LogMessage', {
'message': f'[警告] 面板 {step_id} 的 update_from_config 失败: {e}',
'level': 'warning',
})
# ================================================================ # ================================================================
# 导航 → Tab 单向路由(左侧 List 驱动右侧 Tab,Tab 头部已隐藏) # 导航 → 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): def _on_step_list_changed(self, index):
from PyQt5.QtCore import Qt
if index < 0: return if index < 0: return
item = self._step_list.item(index) item = self._step_list.item(index)
if not item: return if not item: return
@ -609,7 +623,6 @@ class WaterQualityGUI(QMainWindow):
self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
def _disable_wheel_for_all_spinboxes(self): def _disable_wheel_for_all_spinboxes(self):
from PyQt5.QtWidgets import QSpinBox, QDoubleSpinBox, QComboBox
for sb in self.findChildren(QSpinBox): for sb in self.findChildren(QSpinBox):
sb.setFocusPolicy(Qt.StrongFocus) sb.setFocusPolicy(Qt.StrongFocus)
sb.wheelEvent = lambda e, s=sb: None sb.wheelEvent = lambda e, s=sb: None