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:
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
# 全局单例
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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
|
||||
@ -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 生效
|
||||
|
||||
Reference in New Issue
Block a user