测试修改

This commit is contained in:
DXC
2026-06-23 14:39:54 +08:00
parent c4aa246c95
commit b8d263e494
21 changed files with 595 additions and 2706 deletions

View File

@ -1,14 +1,24 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
依赖订阅混入模块
依赖订阅混入模块(架构解耦版)
新规则:dependencies 字典的键名 = 下游目标控件的真实属性名,
第三元素(source_attr)= 上游源控件的真实属性名。
提供 subscribe_panel_to_dependencies() 函数,让步骤面板根据
PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus
订阅 OutputUpdated 事件。当上游步骤产出落地时,面板自动将路径
填入对应的 FileSelectWidget,无需主窗口手工传导。
内含:
- 自动识别下游 widget(按 dict 键名查找)
- 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径
- 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录
"""
import os
from src.gui.core.event_bus import global_event_bus
@ -19,18 +29,21 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies):
匹配时,自动将路径填入面板对应的 FileSelectWidget。
Args:
panel: 步骤面板实例(QWidget 子类)
panel: 步骤面板实例(QWidget 子类),即下游目标面板
step_id: 当前面板的 step_id(仅用于日志,非匹配键)
dependencies: dict, {input_field: (dep_step, output_type, panel_attr)}
dependencies: dict, {target_field: (dep_step, output_type, source_attr)}
target_field 必须等于 panel 上某个控件的属性名
source_attr 仅作回放端使用(本函数不直接依赖)
"""
if not dependencies:
return
for _input_field, (dep_step, output_type, panel_attr) in dependencies.items():
_make_subscription(panel, dep_step, output_type, panel_attr)
# 注意:我们使用字典的键名 (_input_field) 作为唯一的下游目标框名查找依据
for _input_field, (dep_step, output_type, source_panel_attr) in dependencies.items():
_make_subscription(panel, dep_step, output_type, _input_field)
def _make_subscription(panel, dep_step, output_type, panel_attr):
def _make_subscription(panel, dep_step, output_type, target_widget_name):
"""为单个依赖项创建事件订阅。使用工厂函数避免闭包变量延迟绑定。"""
def callback(data):
@ -39,7 +52,7 @@ def _make_subscription(panel, dep_step, output_type, panel_attr):
if data.get('output_type') != output_type:
return
widget = getattr(panel, panel_attr, None)
widget = getattr(panel, target_widget_name, None)
if widget is None:
return
@ -56,6 +69,10 @@ def _make_subscription(panel, dep_step, output_type, panel_attr):
if not path:
return
# 智能转换:如果要的是目录,但来的是文件,自动截取父目录
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'):

View File

@ -169,11 +169,12 @@ class PanelFactory:
if placeholder is not None and self._tab_widget is not None:
tab_title = self._tab_widget.tabText(tab_index)
tab_icon = self._tab_widget.tabIcon(tab_index)
current_active = self._tab_widget.currentIndex() # 记住当前正在看的 Tab
self._tab_widget.blockSignals(True)
try:
self._tab_widget.removeTab(tab_index)
self._tab_widget.insertTab(tab_index, scroll, tab_icon, tab_title)
self._tab_widget.setCurrentIndex(tab_index)
self._tab_widget.setCurrentIndex(current_active) # 恢复原来的 Tab,严禁后台预加载引发跳页!
finally:
self._tab_widget.blockSignals(False)
@ -226,33 +227,40 @@ class PanelFactory:
self._replay_live_panel_inputs()
def _replay_live_panel_inputs(self):
"""遍历 PANEL_REGISTRY 依赖声明,从已加载面板实时读取属性值。
"""遍历 PANEL_REGISTRY 依赖声明,从已加载面板实时读取属性值并强制广播。
若源面板已实例化,读取其 widget 的当前值并发布为 OutputUpdated,
确保懒加载面板能收到全局输入(如 Step1.img_file → reference_img)。
架构解耦(2026-06-22):第三个元素 source_attr 现在明确代表上游控件的真实名字,
不再混用语义。回放端仅依赖 source_attr 在 SOURCE 面板上能命中 widget。
"""
from src.gui.core.event_bus import global_event_bus
for entry in self._registry:
deps = entry.get('dependencies')
if not deps:
continue
for _input_field, (dep_step, output_type, panel_attr) in deps.items():
if not deps: continue
# 第三个元素 source_attr 现在明确代表上游控件的真实名字
for target_field, (dep_step, output_type, source_attr) in deps.items():
src_panel = self._panels.get(dep_step)
if src_panel is None:
continue
widget = getattr(src_panel, panel_attr, None)
if widget is None:
continue
path = ''
if src_panel is None: continue
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
if not path: continue
# 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失
import os
absolute_path = os.path.abspath(path).replace('\\', '/')
global_event_bus.publish('OutputUpdated', {
'step_id': dep_step,
'output_type': output_type,
'path': path,
'path': absolute_path,
})
def _get_current_work_dir(self):

View File

@ -48,14 +48,13 @@ PANEL_REGISTRY = [
'icon': '2.png',
'stage': '阶段一:影像预处理',
'display_name': '2. 耀斑区域识别',
# 对账修复(2026-06-18):
# - img_path: 来源 step1.img_file、目标 step2.img_file ✓ 两端均存在
# - water_mask_path: 原 panel_attr='water_mask_file' 在 step1 panel 不存在(断链)
# → 改为 step1 真实控件名 'mask_file'(step1 默认现有掩膜输入模式)
# → NDWI 模式由 step2.update_from_config 自补足,不依赖 EventBus 链路
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
# 第三元素 source_attr = 上游源控件真实属性名(panel_factory 回放端使用)
'dependencies': {
'img_path': ('step1', 'reference_img', 'img_file'),
'water_mask_path': ('step1', 'water_mask', 'mask_file'),
# 目标框: self.img_file ← 上游 step1.img_file
'img_file': ('step1', 'reference_img', 'img_file'),
# 目标框: self.water_mask_file ← 上游 step1.mask_file
'water_mask_file': ('step1', 'water_mask', 'mask_file'),
},
'constructor_kwargs': None,
},
@ -66,14 +65,12 @@ PANEL_REGISTRY = [
'icon': '3.png',
'stage': '阶段一:影像预处理',
'display_name': '3. 耀斑去除与修复',
# 对账修复(2026-06-18):
# - img_path: 来源 step1.img_file、目标 step3.img_file ✓ 两端均存在
# - water_mask: 原 panel_attr='water_mask_file' 在 step1 panel 不存在(断链)
# → 改为 step1 真实控件名 'mask_file'
# → NDWI 模式由 step3.update_from_config 自补足
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
'dependencies': {
'img_path': ('step1', 'reference_img', 'img_file'),
'water_mask': ('step1', 'water_mask', 'mask_file'),
# 目标框: self.img_file ← 上游 step1.img_file
'img_file': ('step1', 'reference_img', 'img_file'),
# 目标框: self.water_mask_file ← 上游 step1.mask_file
'water_mask_file': ('step1', 'water_mask', 'mask_file'),
},
'constructor_kwargs': None,
},
@ -88,14 +85,13 @@ PANEL_REGISTRY = [
'icon': '4.png',
'stage': '阶段二:样本数据准备',
'display_name': '4. 采样点布设',
# 对账修复(2026-06-18):
# - deglint_img_path: 原 panel_attr='deglint_img_file' 在 step3 panel 不存在(断链)
# → step3 输出 widget 真实名为 'output_file'(deglint_image.bsq)
# - water_mask_path: 原 'water_mask_file' 在 step1 panel 不存在
# → 改为 step1 真实控件名 'mask_file'
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
# 第三元素 source_attr = 上游源控件真实属性名(panel_factory 回放端使用)
'dependencies': {
'deglint_img_path': ('step3', 'deglint_image', 'output_file'),
'water_mask_path': ('step1', 'water_mask', 'mask_file'),
# 目标框: self.deglint_img_file ← 上游 step3.output_file
'deglint_img_file': ('step3', 'deglint_image', 'output_file'),
# 目标框: self.water_mask_file ← 上游 step1.mask_file
'water_mask_file': ('step1', 'water_mask', 'mask_file'),
},
'constructor_kwargs': None,
},
@ -117,18 +113,16 @@ PANEL_REGISTRY = [
'icon': '6.png',
'stage': '阶段二:样本数据准备',
'display_name': '6. 光谱特征提取',
# 对账修复(2026-06-18):
# - deglint_img_path: 原 'deglint_img_file' 在 step3 panel 不存在(断链)
# → 改为 step3 输出 widget 'output_file'
# - csv_path: 原 'csv_file' ✓ step5 panel 有 self.csv_file,无修改
# - boundary_mask_path: 原 'water_mask_file' 在 step1 panel 不存在
# → 改为 step1 真实控件名 'mask_file'
# - glint_mask_path: 原 'glint_mask_file' ✓ step2 panel 有,无修改
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
'dependencies': {
'deglint_img_path': ('step3', 'deglint_image', 'output_file'),
'csv_path': ('step5_clean', 'processed_data', 'csv_file'),
'boundary_mask_path': ('step1', 'water_mask', 'mask_file'),
'glint_mask_path': ('step2', 'glint_mask', 'glint_mask_file'),
# 目标框: self.deglint_img_file ← 上游 step3.output_file
'deglint_img_file': ('step3', 'deglint_image', 'output_file'),
# 目标框: self.csv_file ← 上游 step5_clean.csv_file
'csv_file': ('step5_clean', 'processed_data', 'csv_file'),
# 目标框: self.water_mask_file ← 上游 step1.mask_file
'water_mask_file': ('step1', 'water_mask', 'mask_file'),
# 目标框: self.glint_mask_file ← 上游 step2.output_file
'glint_mask_file': ('step2', 'glint_mask', 'output_file'),
},
'constructor_kwargs': None,
},
@ -139,18 +133,11 @@ PANEL_REGISTRY = [
'icon': '7.png',
'stage': '阶段二:样本数据准备',
'display_name': '7. 水质指数计算',
# 对账修复(2026-06-18):
# - training_csv_path:
# 原 ('step6_feature', 'training_spectra', 'training_data_widget')
# output_type='training_spectra' 仅作为 EventBus 事件标签,不匹配 step6 输出
# panel_attr='training_data_widget' 已是 step7 view 真实控件 ✓
# 改 output_type='output_file'(step6 输出 file widget 名为 output_file)
# ⚠️ 用户原文要求 panel_attr='csv_file',但 step7 无 csv_file widget
# (只有 training_data_widget 与 formula_csv_widget),
# 实际改用 step7 真实控件 'training_data_widget',
# source panel_attr 取 step6 的 'output_file'(双源对账一致)
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
# 第三元素 source_attr = 上游源控件真实属性名
'dependencies': {
'training_csv_path': ('step6_feature', 'output_file', 'training_data_widget'),
# 目标框: self.training_data_widget ← 上游 step6_feature.output_file
'training_data_widget': ('step6_feature', 'output_file', 'output_file'),
},
'constructor_kwargs': None,
},
@ -165,13 +152,9 @@ PANEL_REGISTRY = [
'icon': '8.png',
'stage': '阶段三:模型构建与训练',
'display_name': '8. 机器学习建模',
# 对账修复(2026-06-18):
# - training_csv_file:
# 原 ('step7_index', 'training_spectra_indices', 'training_csv_file')
# source panel_attr='training_csv_file' 在 step7 view 不存在(断链)
# → step7 view 输出(指数计算后 CSV)真实控件为 'training_data_widget'
# target panel_attr='training_csv_file' ✓ step8 panel 有 self.training_csv_file
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
'dependencies': {
# 目标框: self.training_csv_file ← 上游 step7_index.training_data_widget
'training_csv_file': ('step7_index', 'training_spectra_indices', 'training_data_widget'),
},
'constructor_kwargs': None,
@ -187,14 +170,11 @@ PANEL_REGISTRY = [
'icon': '10.png',
'stage': '阶段四:预测与成果输出',
'display_name': '9. 机器学习预测',
# 对账修复(2026-06-18):
# - models_dir:
# 原 ('step8_ml_train', 'Supervised_Model_Training', 'models_dir_file')
# source panel_attr='models_dir_file' 在 step8 panel 不存在(断链)
# → step8 模型输出真实控件为 'output_path'
# target panel_attr='models_dir_file' ✓ step9 panel 有 self.models_dir_file
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
# 第三元素 source_attr = 上游源控件真实属性名
'dependencies': {
'models_dir': ('step8_ml_train', 'Supervised_Model_Training', 'output_path'),
# 目标框: self.models_dir_file ← 上游 step8_ml_train.output_path
'models_dir_file': ('step8_ml_train', 'Supervised_Model_Training', 'output_path'),
},
'constructor_kwargs': None,
},
@ -205,13 +185,12 @@ PANEL_REGISTRY = [
'icon': '10.png',
'stage': '阶段四:预测与成果输出',
'display_name': '10. 水色指数反演',
# 对账修复(2026-06-18):
# - bsq_file:
# 原 ('step3', 'deglint_image', 'bsq_file')
# source panel_attr='bsq_file' 在 step3 panel 不存在(断链)
# → step3 输出(deglint_image.bsq)真实控件为 'output_file'
# target panel_attr='bsq_file' ✓ step10 panel 有 self.bsq_file
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
# 第三元素 source_attr = 上游源控件真实属性名
# step10 panel 仅有 bsq_file / hdr_file / output_dir,没有 water_mask widget,
# 故删除 water_mask 依赖,避免悬挂回调
'dependencies': {
# 目标框: self.bsq_file ← 上游 step3.output_file
'bsq_file': ('step3', 'deglint_image', 'output_file'),
},
'constructor_kwargs': None,
@ -223,21 +202,14 @@ PANEL_REGISTRY = [
'icon': '10.png',
'stage': '阶段四:预测与成果输出',
'display_name': '11. 专题图生成',
# 对账修复(2026-06-18):
# - prediction_csv_dir_edit:
# 原 ('step9_ml_predict', '9_ML_Prediction', 'prediction_csv_dir_edit')
# source panel_attr='prediction_csv_dir_edit' 在 step9 panel 不存在(断链)
# → step9 输出(prediction.csv)真实控件为 'output_file'
# target panel_attr='prediction_csv_dir_edit' ✓ step11 panel 有该 widget
# ⚠️ 语义注记:target 是「目录」(QLineEdit)但 source 是「单文件」,下游需手动指定目录
# - geotiff_dir_edit:
# 原 ('step10_watercolor', 'WaterIndex_Images', 'geotiff_dir_edit')
# source panel_attr='geotiff_dir_edit' 在 step10 panel 不存在(断链)
# → step10 输出(GeoTIFF 目录)真实控件为 'output_dir'
# target panel_attr='geotiff_dir_edit' ✓ step11 panel 有该 widget
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
'dependencies': {
# 目标框: self.prediction_csv_dir_edit ← 上游 step9_ml_predict.output_file
'prediction_csv_dir_edit': ('step9_ml_predict', '9_ML_Prediction', 'output_file'),
# 目标框: self.geotiff_dir_edit ← 上游 step10_watercolor.output_dir
'geotiff_dir_edit': ('step10_watercolor', 'WaterIndex_Images', 'output_dir'),
# 目标框: self.boundary_file ← 上游 step1.mask_file
'boundary_file': ('step1', 'water_mask', 'mask_file'),
},
'constructor_kwargs': None,
},

View File

@ -30,7 +30,7 @@ import traceback
from pathlib import Path
from typing import Dict, List, Optional
from PyQt5.QtCore import QObject, Qt
from PyQt5.QtCore import QObject, Qt, QTimer
from PyQt5.QtWidgets import QMessageBox, QDialog
from src.gui.core.event_bus import global_event_bus
@ -58,6 +58,11 @@ class PipelineExecutor(QObject):
self._workspace_initializer = workspace_initializer
self._worker: Optional[WorkerThread] = None
# ★ 防卡死看门狗:监控 WorkerThread 是否在合理时间内推进进度
self._watchdog_timer = QTimer()
self._watchdog_timer.timeout.connect(self._check_worker_health)
self._last_progress_time = 0.0
# 订阅面板发出的单步执行请求(解耦面板与执行器)
global_event_bus.subscribe('RequestRunSingleStep', self._on_request_run_single_step)
@ -303,6 +308,11 @@ class PipelineExecutor(QObject):
global_event_bus.publish('LogMessage', {'message': '开始执行完整流程...', 'level': 'info'})
global_event_bus.publish('LogMessage', {'message': '=' * 50, 'level': 'info'})
# ★ 启动看门狗:worker.start() 之后立刻开始监控(180s 超时)
import time
self._last_progress_time = time.time()
self._watchdog_timer.start(5000) # 每 5 秒巡检一次
self._worker.start()
def run_single_step(self, step_name: str, config: dict = None):
@ -375,6 +385,11 @@ class PipelineExecutor(QObject):
})
global_event_bus.publish('LogMessage', {'message': '=' * 50, 'level': 'info'})
# ★ 启动看门狗:worker.start() 之后立刻开始监控(180s 超时)
import time
self._last_progress_time = time.time()
self._watchdog_timer.start(5000) # 每 5 秒巡检一次
self._worker.start()
def stop_pipeline(self):
@ -460,7 +475,12 @@ class PipelineExecutor(QObject):
})
def _on_progress_update(self, percentage: int, message: str):
"""WorkerThread 进度 → EventBus ProgressUpdate 事件。"""
"""WorkerThread 进度 → EventBus ProgressUpdate 事件。
同步重置看门狗计时器:每次收到进度更新就视为 Worker 还活着。
"""
import time
self._last_progress_time = time.time()
global_event_bus.publish('ProgressUpdate', {
'percentage': percentage,
'message': message,
@ -482,11 +502,40 @@ class PipelineExecutor(QObject):
主窗口订阅此事件,恢复按钮状态并弹窗。
"""
# ★ 停止看门狗:Worker 已正常收口,看门狗可以下班
self._watchdog_timer.stop()
global_event_bus.publish('PipelineFinished', {
'success': success,
'message': message,
})
def _check_worker_health(self):
"""看门狗巡检:每 5 秒触发一次。
判定逻辑:
- Worker 已不在运行(自然结束/被强杀) → 停掉看门狗
- Worker 仍在运行 + 上次进度距今 > 180 秒 → 判定为假死/死锁,
强制 terminate() 并通过 _on_finished(False, ...) 汇流解 UI,
让被卡死的「独立运行此步骤」按钮恢复可用。
"""
import time
if self._worker and self._worker.isRunning():
if time.time() - self._last_progress_time > 180:
self._log_message("[错误] 后台任务响应超时(超过3分钟无响应),强制终止...", "error")
self._worker.terminate()
self._on_finished(False, "后台任务响应超时或发生底层段错误,已强制终止。")
else:
# Worker 已退出(自然结束 / 已 terminate),关闭看门狗
self._watchdog_timer.stop()
def _log_message(self, message: str, level: str = "info"):
"""看门狗专用的轻量日志通道(直接走 EventBus,不绕过转发槽)。"""
global_event_bus.publish('LogMessage', {
'message': message,
'level': level,
})
# ═══════════════════════════════════════════════════════════
# 内部辅助
# ═══════════════════════════════════════════════════════════

View File

@ -14,8 +14,8 @@ import numpy as np
def _viz_training_spectra_csv_path(work_path: Path) -> Path:
"""可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤5输出一致)。"""
return work_path / "5_training_spectra" / "training_spectra.csv"
"""可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤6输出一致)。"""
return work_path / "6_Spectral_Feature_Extraction" / "training_spectra.csv"
def _viz_infer_wavelength_start_column(df) -> Union[str, int]:
@ -194,7 +194,7 @@ class VisualizationWorkerThread(QThread):
training_csv_path = (self.extra.get("training_csv_path") or "").strip()
models_dir = (self.extra.get("models_dir") or "").strip()
if not training_csv_path or not Path(training_csv_path).is_file():
self.failed.emit("训练光谱 CSV 无效或不存在,请确认已选择步骤5输出的文件。")
self.failed.emit("训练光谱 CSV 无效或不存在,请确认已选择步骤6输出的文件。")
return
if not models_dir or not Path(models_dir).is_dir():
self.failed.emit("模型目录无效或不存在,请确认步骤6已生成 7_Supervised_Model_Training 下的参数子文件夹。")
@ -213,7 +213,7 @@ class VisualizationWorkerThread(QThread):
if training_csv_path:
training_csv = Path(training_csv_path)
else:
training_csv = wp / "5_training_spectra" / "training_spectra.csv"
training_csv = wp / "6_Spectral_Feature_Extraction" / "training_spectra.csv"
if self.extra.get("gen_scatter"):
if training_csv.is_file():

View File

@ -241,13 +241,11 @@ class WorkspaceInitializer(QObject):
# ═══════════════════════════════════════════════════════════
def _auto_fill_output_paths(self):
"""根据工作目录自动填充 step1 的输出路径。
"""【已禁用】禁止在工作目录刚选定后瞎拼凑假路径。
注意:Step1 的输出路径由 update_work_directory() 根据模式自动控制。
历史行为:曾经在此处调用 step1_panel.update_work_directory(self._work_dir) 自动回填输出路径。
现已清空为 no-op,原因:刚选定目录时任何"自动推断"的子目录都是幽灵路径,
会污染后续面板的 input field,让用户误以为已经填好了实际却指向不存在的目录。
真实填充时机交由面板自身的 update_from_config + 用户手动指定。
"""
if not self._work_dir:
return
step1_panel = self._panel_factory.get_panel('step1')
if step1_panel:
step1_panel.update_work_directory(self._work_dir)
pass