格式统一

This commit is contained in:
duxin
2026-07-01 13:19:46 +08:00
parent 8de73db80e
commit 1611001a72
13 changed files with 348 additions and 116 deletions

View File

@ -51,12 +51,28 @@ class EventBus:
"""发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。
迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。
2026-07-01 修复:捕获 RuntimeError(sip 野指针异常)防止已删除的 C++ 对象回调崩溃。
"""
with self._lock:
snapshot = list(self._subscribers.get(event_name, []))
for callback in snapshot:
try:
callback(data)
except RuntimeError:
# ★ 野指针保护:C++ 对象已删除时 sip 抛出 RuntimeError,静默跳过。
cb_name = getattr(callback, '__name__', None) or repr(callback)
err_msg = (
f"[EventBus] 事件 '{event_name}' 的订阅者 {cb_name!r} C++ 对象已销毁,跳过。\n"
+ traceback.format_exc()
)
if self._error_logger:
try:
self._error_logger(err_msg)
except Exception:
print(err_msg, file=sys.stderr, flush=True)
else:
print(err_msg, file=sys.stderr, flush=True)
except Exception:
cb_name = getattr(callback, '__name__', None) or repr(callback)
err_msg = (

View File

@ -537,17 +537,28 @@ class PipelineExecutor(QObject):
def _check_worker_health(self):
"""看门狗巡检:每 5 秒触发一次。
2026-07-01 修复:优先尝试优雅停止再 fallback terminate()
判定逻辑:
- Worker 已不在运行(自然结束/被强杀) → 停掉看门狗
- Worker 仍在运行 + 上次进度距今 > 600 秒 → 判定为假死/死锁,
强制 terminate() 并通过 _on_finished(False, ...) 汇流解 UI,
让被卡死的「独立运行此步骤」按钮恢复可用。
先尝试 WorkerThread.stop() 优雅停止(5 秒超时),
若优雅停止也超时则 fallback 到 terminate()
"""
import time
if self._worker and self._worker.isRunning():
if time.time() - self._last_progress_time > 600:
self._log_message("[错误] 后台任务响应超时(超过10分钟无响应),强制终止...", "error")
self._worker.terminate()
self._log_message("[错误] 后台任务响应超时(超过10分钟无响应),尝试优雅停止...", "error")
# 先尝试通过 cancel + wait 优雅停止
if hasattr(self._worker, 'stop'):
try:
self._worker.stop()
except Exception:
pass
# 如果优雅停止后线程仍在运行,再 fallback 到 terminate()
if self._worker.isRunning():
self._log_message("[错误] 优雅停止无效,执行强制终止(terminate)...", "error")
self._worker.terminate()
self._on_finished(False, "后台任务响应超时或发生底层段错误,已强制终止。")
else:
# Worker 已退出(自然结束 / 已 terminate),关闭看门狗

View File

@ -304,11 +304,11 @@ 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 永远误报缺失项。
2026-07-01 修复(变量命名混乱 + 缺少 step4_sampling CSV 预检):
- 将变量名从旧 PIPELINE_STEPS 编号改为 PANEL_REGISTRY step_id
- 新增 step4_sampling 采样点 CSV 文件的显式预检
- 将 _TAB_INDEX_MAP 提升到顶级并与 STEP_TAB_MAP 合并使用
- _cfg() 映射表保留用于翻译旧 PIPELINE_STEPS step_id → 新 PANEL_REGISTRY step_id
拓扑预判逻辑:
1. 按 pipeline 顺序遍历所有 enabled=True 的步骤,收集其 produces 列表,
@ -318,17 +318,12 @@ class PreflightDialog(QDialog):
- 若 key 在 dynamically_produced_keys 中 → OK(前置步骤会生成)
- 否则 → MissingItem(真正缺失)
3. 智能免检规则:
- formula_csv_path:底层的完全可选参数,任何情况下都免检。
- step5 boundary_path:若 step1 enabled 或 config 中有 water_mask_path,
则信任 panel/底层的自动推导机制,不拦截。
- step14 boundary_shp_path:若 step1 enabled,信任 panel 的自动回填,
不拦截。
关键阻断项(is_critical=True):step1 img_path 缺失。
- formula_csv_path:底层完全可选参数,任何情况下都免检。
- boundary_path / boundary_shp_path:若 step1 enabled,信任 panel/底层自动推导。
"""
items: List[MissingItem] = []
# ── ★ PIPELINE_STEPS step_id → PANEL_REGISTRY step_id(config 的 key)──
# ── PIPELINE_STEPS step_id → PANEL_REGISTRY step_id ──
_PIPELINE_TO_CONFIG_KEY: Dict[str, str] = {
'step1': 'step1',
'step2': 'step2',
@ -345,64 +340,12 @@ class PreflightDialog(QDialog):
'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, {})
def _cfg(old_step_id: str) -> dict:
"""将旧 PIPELINE_STEPS step_id 翻译为新 PANEL_REGISTRY step_id 后取 config。"""
panel_step_id = _PIPELINE_TO_CONFIG_KEY.get(old_step_id, old_step_id)
return config.get(panel_step_id, {})
step1_cfg = _cfg('step1')
step1_enabled = step1_cfg.get('enabled', False)
# ── ★ 构建「动态产物集合」:按 pipeline 顺序收集所有 enabled 步骤的 produces ──
dynamically_produced_keys: Set[str] = set()
enabled_step_ids: Set[str] = set()
for step_spec in PIPELINE_STEPS:
step_cfg = _cfg(step_spec.step_id)
if not step_cfg.get('enabled', True):
continue
enabled_step_ids.add(step_spec.step_id)
dynamically_produced_keys.update(step_spec.produces)
# ── step1 img_path(阻断性)───────────────────────────────
img_path = step1_cfg.get('img_path')
if not img_path:
items.append(MissingItem(
step_id="step1", step_name="水域掩膜",
reason="缺少参考影像路径 → 请在「阶段一」中填写「参考影像」",
panel_tab_index=0, is_critical=True
))
elif not os.path.isfile(img_path):
items.append(MissingItem(
step_id="step1", step_name="水域掩膜",
reason=f"参考影像文件不存在:{img_path}",
panel_tab_index=0, is_critical=True
))
# ── 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="step5_clean", step_name="数据清洗",
reason="请在「数据清洗」中填写「实测水质数据 CSV」",
panel_tab_index=3
))
elif not os.path.isfile(csv_path):
items.append(MissingItem(
step_id="step5_clean", step_name="数据清洗",
reason=f"实测水质数据文件不存在:{csv_path}",
panel_tab_index=3
))
# ── step12 formula_csv_path(绝对免检:底层完全可选)────────
# formula_csv_path 在底层 CustomRegressionPredictor 中不传即可运行,
# 只影响日志输出,不阻断任何功能。此处不做任何检查。
# ── ★ DAG-aware 检查:遍历 enabled 步骤的 required_input_files ──
PURE_EXTERNAL_INPUT_KEYS: Set[str] = {'img_path', 'csv_path'}
# ── TAB / STEP NAME 映射(基于 PANEL_REGISTRY step_id)──
_TAB_INDEX_MAP: Dict[str, int] = {
"step1": 0, "step2": 1, "step3": 2,
"step4_sampling": 3, "step5_clean": 4, "step6_feature": 5,
@ -419,37 +362,99 @@ class PreflightDialog(QDialog):
"step12_viz": "可视化展示", "step13_report": "报告生成",
}
# ── step1 img_path(阻断性)──
step1_cfg = _cfg('step1')
img_path = step1_cfg.get('img_path')
if not img_path:
items.append(MissingItem(
step_id="step1", step_name="水域掩膜",
reason="缺少参考影像路径 → 请在「水域掩膜」中填写「参考影像」",
panel_tab_index=0, is_critical=True
))
elif not os.path.isfile(img_path):
items.append(MissingItem(
step_id="step1", step_name="水域掩膜",
reason=f"参考影像文件不存在:{img_path}",
panel_tab_index=0, is_critical=True
))
# ── step5_clean csv_path(纯外部输入,必须手动提供)──
step5_clean_cfg = _cfg('step4') # 旧 step4 → 新 step5_clean
if step5_clean_cfg.get('enabled', True):
csv_path = step5_clean_cfg.get('csv_path')
if not csv_path:
items.append(MissingItem(
step_id="step5_clean", step_name="数据清洗",
reason="请在「数据清洗」中填写「实测水质数据 CSV」",
panel_tab_index=_TAB_INDEX_MAP.get('step5_clean', 4)
))
elif not os.path.isfile(csv_path):
items.append(MissingItem(
step_id="step5_clean", step_name="数据清洗",
reason=f"实测水质数据文件不存在:{csv_path}",
panel_tab_index=_TAB_INDEX_MAP.get('step5_clean', 4)
))
# ── step4_sampling csv_path(采样点 CSV,2026-07-01 新增缺失检查)──
step4_sampling_cfg = _cfg('step10') # 旧 step10 → 新 step4_sampling
if step4_sampling_cfg.get('enabled', True):
sampling_csv = step4_sampling_cfg.get('csv_path')
if not sampling_csv:
items.append(MissingItem(
step_id="step4_sampling", step_name="采样点布设",
reason="请在「采样点布设」中填写「实测水质数据 CSV」",
panel_tab_index=_TAB_INDEX_MAP.get('step4_sampling', 3)
))
elif not os.path.isfile(sampling_csv):
items.append(MissingItem(
step_id="step4_sampling", step_name="采样点布设",
reason=f"实测水质数据文件不存在:{sampling_csv}",
panel_tab_index=_TAB_INDEX_MAP.get('step4_sampling', 3)
))
# ── 构建「动态产物集合」:按 pipeline 顺序收集所有 enabled 步骤的 produces ──
dynamically_produced_keys: Set[str] = set()
enabled_step_ids: Set[str] = set()
for step_spec in PIPELINE_STEPS:
step_cfg = _cfg(step_spec.step_id)
if not step_cfg.get('enabled', True):
continue
enabled_step_ids.add(step_spec.step_id)
dynamically_produced_keys.update(step_spec.produces)
# ── DAG-aware 检查:遍历 enabled 步骤的 required_input_files ──
PURE_EXTERNAL_INPUT_KEYS: Set[str] = {'img_path', 'csv_path'}
for step_spec in PIPELINE_STEPS:
if step_spec.step_id not in enabled_step_ids:
continue
step_cfg = _cfg(step_spec.step_id) # ★ 通过映射表翻译 step_id
# ★ 将 PIPELINE_STEPS step_id 翻译为 PANEL_REGISTRY 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)
step_cfg = _cfg(step_spec.step_id)
for req_key in step_spec.required_input_files:
# ★★★ 高优先级硬编码白名单 ★★★
# 当检测到需求为边界文件时,只要 step1 有填影像(代表有基础,底层能自动推导),直接放行
# 边界文件:只要 step1 有影像输入就信任 panel/底层自动推导
if req_key in ('boundary_path', 'boundary_shp_path'):
_step1_cfg = _cfg('step1')
if _step1_cfg.get('img_path') or _step1_cfg.get('enabled', True):
continue # 直接跳过,不判定为缺失
if step1_cfg.get('img_path') or step1_cfg.get('enabled', True):
continue
if req_key in PURE_EXTERNAL_INPUT_KEYS:
continue
continue # 已在上面显式检查
if req_key == 'formula_csv_path':
continue # ★ 底层完全可选,赦免
continue # 底层完全可选
if req_key == 'boundary_path' and panel_step_id == 'step6_feature':
continue # ★ step1 执行则 panel/底层自动推导,赦免
continue # step1 执行则底层自动推导
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)
if cfg_val and os.path.isfile(cfg_val):
continue
if cfg_val and os.path.isdir(cfg_val):
continue
if req_key in dynamically_produced_keys:
continue # ★ 前置步骤会生成,拓扑预判通过
continue # 前置步骤会生成
items.append(MissingItem(
step_id=panel_step_id,
step_name=step_name,

View File

@ -94,7 +94,7 @@ class VisualizationWorkerThread(QThread):
return
hyperspectral_path = str(hyperspectral_files[0])
csv_files = []
processed_dir = wp / "4_processed_data"
processed_dir = wp / "5_Data_Cleaning"
if processed_dir.exists():
csv_files = list(processed_dir.glob("*.csv"))
if not csv_files:
@ -320,7 +320,7 @@ class VisualizationWorkerThread(QThread):
if hyperspectral_files:
hyperspectral_path = str(hyperspectral_files[0])
csv_files = []
processed_dir = wp / "4_processed_data"
processed_dir = wp / "5_Data_Cleaning"
if processed_dir.exists():
csv_files = list(processed_dir.glob("*.csv"))
if not csv_files:

View File

@ -257,11 +257,25 @@ class WorkerThread(QThread):
- 整个 run() 方法体包裹在单一 try/except 中
- 任何未预期的异常都会被捕获并通过 finished 信号回报主线程
- 确保前端永远不会面对"静默死亡"的后台线程
2026-07-01 GDAL 线程安全加固:
- GDAL_NUM_THREADS=1:禁用 GDAL 内部多线程,防止与 QThread 冲突导致 0xC0000005
- gdal.AllRegister():在子线程中重新注册 GDAL 驱动
"""
import os
os.environ['GDAL_FILENAME_IS_UTF8'] = 'YES'
os.environ['SHAPE_ENCODING'] = 'UTF-8'
# ★ GDAL 线程安全配置(2026-07-01):
# 在子线程中禁用 GDAL 内部多线程,防止多个 QThread 并发调用 GDAL C API 时
# 触发 0xC0000005 段错误(GDAL 部分驱动非线程安全)。
try:
from osgeo import gdal
gdal.SetConfigOption('GDAL_NUM_THREADS', '1')
gdal.AllRegister()
except Exception:
pass
mpl_prev = None
try:
# ★ 终端即时反馈
@ -466,11 +480,11 @@ class WorkerThread(QThread):
)
# ── 步骤8_ml_train(ML 建模) ──
step7_cfg = config.get('step8_ml_train', {})
step7_csv = step7_cfg.get('training_csv_path')
if step7_csv and not os.path.isfile(step7_csv):
step8_cfg = config.get('step8_ml_train', {})
step8_csv = step8_cfg.get('training_csv_path')
if step8_csv and not os.path.isfile(step8_csv):
errors.append(
f"步骤 8_ml_train(ML 建模):训练光谱文件不存在:\n {step7_csv}\n"
f"步骤 8_ml_train(ML 建模):训练光谱文件不存在:\n {step8_csv}\n"
" → 请确认步骤 5_clean 已成功运行并生成了训练光谱。"
)

View File

@ -46,6 +46,7 @@ class WorkspaceInitializer(QObject):
super().__init__(parent)
self._panel_factory = panel_factory
self._work_dir: Optional[str] = None
self._previous_work_dir: Optional[str] = None # ★ 2026-07-01:追踪目录变更
# 工作空间管理器(文件扫描、路径发现)
self._workspace_manager = WorkspaceManager()
@ -68,6 +69,7 @@ class WorkspaceInitializer(QObject):
@work_dir.setter
def work_dir(self, value: str):
if value and value != self._work_dir:
self._previous_work_dir = self._work_dir # ★ 保存旧目录用于清理残留路径
self._work_dir = value
global_event_bus.publish('WorkspaceChanged', {'work_dir': value})
@ -137,14 +139,18 @@ class WorkspaceInitializer(QObject):
'level': 'info',
})
# 自动填充 step1 输出路径
self._auto_fill_output_paths()
# 自动填充 step1 输出路径(已禁用:no-op,保留调用点以备日后启用)
# self._auto_fill_output_paths()
def set_work_directory(self):
"""手动设置工作目录(菜单触发)。"""
"""手动设置工作目录(菜单触发)。
2026-07-01 修复:切换目录后自动触发完整清理+扫描+回填,
确保所有面板的残留旧路径被清除并替换为新目录的路径。
"""
dir_path = QFileDialog.getExistingDirectory(self.parent(), "选择工作目录")
if dir_path:
self.work_dir = dir_path
self.work_dir = dir_path # ★ setter 中保存 _previous_work_dir + 发布 WorkspaceChanged
global_event_bus.publish('LogMessage', {
'message': f'工作目录已设置: {dir_path}',
'level': 'info',
@ -156,6 +162,9 @@ class WorkspaceInitializer(QObject):
if panel and hasattr(panel, 'set_work_dir'):
panel.set_work_dir(dir_path)
# ★ 关键修复:切换目录后强制清理旧路径 + 重新扫描回填
self.auto_populate_all()
def open_work_directory(self):
"""在资源管理器中打开工作目录(跨平台)。"""
import subprocess
@ -179,6 +188,7 @@ class WorkspaceInitializer(QObject):
"""扫描工作目录并触发事件总线自动填充所有步骤的输入路径。
流程:
0. 若工作目录已变更,先清理指向旧目录的残留输出路径
1. WorkspaceManager.scan_work_directory_for_files() 扫描磁盘
2. WorkspaceManager 内部自动发布 OutputUpdated 事件
3. 各面板通过 DependencySubscriber 自动接收并填充
@ -194,6 +204,10 @@ class WorkspaceInitializer(QObject):
QMessageBox.warning(self.parent(), "警告", f"工作目录不存在: {work_dir}\n请先设置正确的工作目录。")
return
# ★ 2026-07-01:若目录已变更,先清除指向旧目录的残留路径
if self._previous_work_dir and self._previous_work_dir != work_dir:
self._clear_stale_output_paths(self._previous_work_dir)
# WorkspaceManager 扫描 → 内部自动发布 OutputUpdated 事件
self._workspace_manager.scan_work_directory_for_files(work_path)
@ -262,12 +276,65 @@ class WorkspaceInitializer(QObject):
# 内部辅助
# ═══════════════════════════════════════════════════════════
def _auto_fill_output_paths(self):
"""【已禁用】禁止在工作目录刚选定后瞎拼凑假路径。
def _clear_stale_output_paths(self, old_work_dir: str):
"""清除所有面板中指向旧工作目录的输出路径(2026-07-01)。
历史行为:曾经在此处调用 step1_panel.update_work_directory(self._work_dir) 自动回填输出路径。
现已清空为 no-op,原因:刚选定目录时任何"自动推断"的子目录都是幽灵路径,
当用户切换工作目录时,面板中自动回填的输出路径(如 water_mask_out.dat)
仍指向旧目录。由于各 panel 的 update_from_config() 有"非空不覆盖"保护,
这些残留路径不会被自动更新。此方法遍历所有已加载面板,
清除以旧 work_dir 开头的输出路径,确保后续 auto_populate_all() 能重新填充。
安全设计:
- 仅清除以旧 work_dir 开头的路径(保留用户手动指定的外部路径)
- 使用 os.path.normpath 归一化比较,兼容正反斜杠混用
"""
norm_old = os.path.normpath(old_work_dir).replace('\\', '/')
if not norm_old:
return
cleared_count = 0
for step_id, panel in self._panel_factory.get_loaded_panels().items():
# 遍历面板中常见的输出/路径控件
for attr_name in ('output_file', 'output_path', 'output_dir',
'models_dir_file', 'sampling_csv_file',
'training_csv_file', 'prediction_csv_dir_edit',
'boundary_file', 'geotiff_dir_edit'):
widget = getattr(panel, attr_name, None)
if widget is None:
continue
# 获取当前路径(兼容 FileSelectWidget 和 QLineEdit)
current = ''
if hasattr(widget, 'get_path'):
current = (widget.get_path() or '').strip()
elif hasattr(widget, 'text'):
current = (widget.text() or '').strip()
if not current:
continue
# 归一化判断:路径是否指向旧 work_dir
norm_current = os.path.normpath(current).replace('\\', '/')
if norm_current.startswith(norm_old + '/'):
# 清除旧目录残留
if hasattr(widget, 'set_path'):
widget.set_path('')
elif hasattr(widget, 'setText'):
widget.setText('')
cleared_count += 1
if cleared_count > 0:
global_event_bus.publish('LogMessage', {
'message': f'✓ 已清理 {cleared_count} 个指向旧工作目录的残留路径',
'level': 'info',
})
def _auto_fill_output_paths(self):
"""【已禁用 — 2026-06-30 起永久禁用】
历史行为:曾经在此处调用 step1_panel.update_work_directory(self._work_dir)
自动回填输出路径。现已清空为 no-op。
原因:刚选定目录时任何"自动推断"的子目录都是幽灵路径,
会污染后续面板的 input field,让用户误以为已经填好了实际却指向不存在的目录。
真实填充时机交由面板自身的 update_from_config + 用户手动指定。
调用点已在 run() 中注释掉,此方法仅保留定义供外部存档引用。
"""
pass