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

@ -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

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 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)
# 全局单例

View File

@ -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,

View File

@ -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,
},

View File

@ -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

View File

@ -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 生效