格式统一

This commit is contained in:
duxin
2026-07-01 09:57:27 +08:00
parent c793ea2204
commit a3c20d3e49
37 changed files with 2286 additions and 1978 deletions

View File

@ -92,6 +92,14 @@ class ConfigManager(QObject):
)
return
# ★ 验证 JSON 结构:必须是 dict 类型
if not isinstance(config, dict):
QMessageBox.critical(
self.parent(), "配置格式错误",
f"配置文件格式不正确(期望 JSON 对象,实际为 {type(config).__name__})。\n请确认选择了正确的配置文件。"
)
return
# 回填已加载面板
loaded_count = 0
for step_id, panel in self._panel_factory.get_loaded_panels().items():
@ -99,8 +107,11 @@ class ConfigManager(QObject):
try:
panel.set_config(config[step_id])
loaded_count += 1
except Exception:
pass
except Exception as e:
global_event_bus.publish('LogMessage', {
'message': f'[警告] 面板 {step_id} 加载配置失败: {e}',
'level': 'warning',
})
global_event_bus.publish('LogMessage', {
'message': f'已加载配置: {file_path}(回填 {loaded_count} 个面板)',
@ -156,6 +167,10 @@ class ConfigManager(QObject):
if hasattr(panel, 'get_config'):
try:
config[step_id] = panel.get_config()
except Exception:
except Exception as e:
config[step_id] = {}
global_event_bus.publish('LogMessage', {
'message': f'[警告] 面板 {step_id} 获取配置失败: {e}',
'level': 'warning',
})
return config

View File

@ -16,12 +16,17 @@ PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus
- sip.isdeleted() 保护:面板销毁后回调自动跳过,避免 C++ 野指针 Segfault
- 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径
- 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录
- ★ panel.destroyed 信号自动取消订阅(2026-06-30)
- ★ weakref 防止闭包阻止 panel GC(2026-06-30)
2026-06-30 修复:
- 添加 sip.isdeleted(panel) 检查,防止面板删除后访问 C++ 对象导致崩溃
- 连接 panel.destroyed 信号自动清理订阅,彻底杜绝回调泄漏
- 使用 weakref.ref 包装回调中的 panel 引用,防止 GC 阻塞
"""
import os
import weakref
import sip
from src.gui.core.event_bus import global_event_bus
@ -48,8 +53,18 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies):
return
panel_id = id(panel)
if panel_id not in _subscription_registry:
_subscription_registry[panel_id] = []
# ★ 若之前已订阅过,先清理旧订阅(防止重复订阅泄漏)
if panel_id in _subscription_registry:
unsubscribe_panel_from_dependencies(panel)
_subscription_registry[panel_id] = []
# ★ 面板销毁时自动清理订阅,杜绝闭包泄漏
if hasattr(panel, 'destroyed'):
try:
panel.destroyed.connect(lambda obj=None: _on_panel_destroyed(panel_id))
except Exception:
pass
for _input_field, (dep_step, output_type, source_panel_attr) in dependencies.items():
callback = _make_callback(panel, dep_step, output_type, _input_field)
@ -57,6 +72,13 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies):
_subscription_registry[panel_id].append(('OutputUpdated', callback))
def _on_panel_destroyed(panel_id):
"""panel.destroyed 信号回调:清理该面板的所有订阅。"""
subscriptions = _subscription_registry.pop(panel_id, [])
for event_name, callback in subscriptions:
global_event_bus.unsubscribe(event_name, callback)
def unsubscribe_panel_from_dependencies(panel):
"""取消面板的所有依赖订阅。应在面板销毁前调用,防止野指针回调。
@ -72,13 +94,20 @@ def unsubscribe_panel_from_dependencies(panel):
def _make_callback(panel, dep_step, output_type, target_widget_name):
"""为单个依赖项创建事件回调。返回闭包函数。
使用工厂函数确保每个回调的闭包变量独立绑定。
使用 weakref 持有 panel 引用,防止闭包阻止 panel GC。
同时检查 panel 和 widget 的 C++ 对象是否存活。
"""
panel_ref = weakref.ref(panel)
def callback(data):
p = panel_ref()
if p is None:
return
# ★ 防野指针:面板底层 C++ 对象已销毁时直接跳过
try:
if sip.isdeleted(panel):
if sip.isdeleted(p):
return
except Exception:
return
@ -88,10 +117,17 @@ def _make_callback(panel, dep_step, output_type, target_widget_name):
if data.get('output_type') != output_type:
return
widget = getattr(panel, target_widget_name, None)
widget = getattr(p, target_widget_name, None)
if widget is None:
return
# ★ 检查 widget 自身的 C++ 对象是否存活
try:
if sip.isdeleted(widget):
return
except Exception:
return
current = ''
try:
if hasattr(widget, 'get_path'):

View File

@ -14,6 +14,10 @@
from PyQt5.QtCore import QObject
from PyQt5.QtWidgets import QMessageBox
# ★ 集中版本号常量
APP_VERSION = "V1.2"
APP_NAME = "MegaCube-Water Quality"
class DialogService(QObject):
"""对话框服务。
@ -53,7 +57,7 @@ class DialogService(QObject):
"""显示"关于"对话框。"""
QMessageBox.about(
self.parent(), "关于",
"MegaCube-Water Quality V1.2\n\n"
f"{APP_NAME} {APP_VERSION}\n\n"
"一个完整的水质参数反演工作流程工具\n\n"
"公司:北京依锐思遥感技术有限公司\n"
"地址:北京市海淀区清河安宁庄东路18号5号楼二层205\n"

View File

@ -11,16 +11,19 @@
- 新增 unsubscribe() 方法,允许面板销毁时清理订阅,防止内存泄漏和野指针回调
"""
import sys
import threading
import traceback
from collections import defaultdict
from typing import Any, Callable, Dict, List, Optional
class EventBus:
"""发布-订阅事件总线"""
"""发布-订阅事件总线(线程安全)。"""
def __init__(self):
self._subscribers: Dict[str, List[Callable]] = defaultdict(list)
self._lock = threading.Lock()
# 异常日志回调(可选注入,供 LogManager 使用)
self._error_logger: Optional[Callable[[str], None]] = None
@ -30,38 +33,42 @@ class EventBus:
def subscribe(self, event_name: str, callback: Callable[[dict], None]):
"""订阅事件。callback 接收一个 dict 作为事件数据。"""
if callback not in self._subscribers[event_name]:
self._subscribers[event_name].append(callback)
with self._lock:
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]
with self._lock:
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]):
"""发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。
迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。
"""
for callback in list(self._subscribers.get(event_name, [])):
with self._lock:
snapshot = list(self._subscribers.get(event_name, []))
for callback in snapshot:
try:
callback(data)
except Exception:
cb_name = getattr(callback, '__name__', None) or repr(callback)
err_msg = (
f"[EventBus] 事件 '{event_name}' 的订阅者 {callback.__name__!r} 抛出异常:\n"
f"[EventBus] 事件 '{event_name}' 的订阅者 {cb_name!r} 抛出异常:\n"
+ traceback.format_exc()
)
if self._error_logger:
try:
self._error_logger(err_msg)
except Exception:
pass
print(err_msg, file=sys.stderr, flush=True)
else:
import sys
print(err_msg, file=sys.stderr, flush=True)

View File

@ -16,6 +16,7 @@
ProgressUpdate → {percentage, message} 更新进度条
"""
import html
from datetime import datetime
from PyQt5.QtCore import QObject
@ -27,6 +28,8 @@ from PyQt5.QtWidgets import (
from src.gui.core.event_bus import global_event_bus
_MAX_LOG_LINES = 2000 # 日志行数上限,防止长时间运行内存耗尽
class LogManager(QObject):
"""日志与进度管理器。
@ -48,6 +51,15 @@ class LogManager(QObject):
global_event_bus.subscribe('LogMessage', self._on_log_message)
global_event_bus.subscribe('ProgressUpdate', self._on_progress_update)
# ★ 父控件销毁时自动取消订阅,防止回调泄漏
if parent is not None:
parent.destroyed.connect(self._cleanup)
def _cleanup(self):
"""清理 EventBus 订阅。"""
global_event_bus.unsubscribe('LogMessage', self._on_log_message)
global_event_bus.unsubscribe('ProgressUpdate', self._on_progress_update)
# ═══════════════════════════════════════════════════════════
# 公开 API
# ═══════════════════════════════════════════════════════════
@ -168,13 +180,23 @@ class LogManager(QObject):
"""LogMessage 事件回调:写入日志区。"""
if self._log_text is None:
return
if not isinstance(data, dict):
return
message = data.get('message', '')
level = data.get('level', 'info')
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# ★ HTML 转义:防止日志消息中的 < > & 破坏 HTML 渲染
safe_message = html.escape(str(message))
color_map = {'error': 'red', 'warning': 'orange'}
color = color_map.get(level, 'black')
formatted = f'<span style="color: {color};">[{timestamp}] {message}</span>'
formatted = f'<span style="color: {color};">[{timestamp}] {safe_message}</span>'
self._log_text.append(formatted)
# ★ 日志行数上限:防止长时间运行内存耗尽
if self._log_text.document().blockCount() > _MAX_LOG_LINES + 100:
self._log_text.clear()
self._log_text.append(
f'<span style="color: gray;">[日志已自动清空,达到 {_MAX_LOG_LINES} 行上限]</span>'
)
cursor = self._log_text.textCursor()
cursor.movePosition(QTextCursor.End)
self._log_text.setTextCursor(cursor)

View File

@ -202,6 +202,8 @@ class PanelFactory:
self._tab_widget.removeTab(tab_index)
self._tab_widget.insertTab(tab_index, scroll, tab_icon, tab_title)
self._tab_widget.setCurrentIndex(current_active) # 恢复原来的 Tab,严禁后台预加载引发跳页!
# ★ 显式释放占位页,防止泄漏
placeholder.deleteLater()
finally:
self._tab_widget.blockSignals(False)
@ -260,6 +262,9 @@ class PanelFactory:
# 3. 实时扫描已加载面板中被依赖的属性(覆盖全局输入如 reference_img)
self._replay_live_panel_inputs()
# ★ 重新禁用滚轮:update_from_config 可能动态创建了新的 SpinBox/ComboBox
_disable_wheel_for_widget(panel)
def _replay_live_panel_inputs(self):
"""遍历 PANEL_REGISTRY 依赖声明,从已加载面板实时读取属性值并强制广播。
@ -290,7 +295,7 @@ class PanelFactory:
if not path: continue
# 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失
absolute_path = os.path.abspath(path).replace('\\', '/')
absolute_path = os.path.normpath(os.path.abspath(path))
# 2026-06-30:仅当文件/目录确实存在时才广播,阻断幽灵路径级联
if not os.path.exists(absolute_path):

View File

@ -167,7 +167,7 @@ PANEL_REGISTRY = [
'step_id': 'step9_ml_predict',
'class_ref': Step9MlPredictPanel,
'title': '机器学习预测',
'icon': '10.png',
'icon': '9.png',
'stage': '模块三 模型训练与反演',
'display_name': '9. 机器学习预测',
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
@ -198,7 +198,7 @@ PANEL_REGISTRY = [
'step_id': 'step11_map',
'class_ref': Step11MapPanel,
'title': '专题图生成',
'icon': '10.png',
'icon': '11.png',
'stage': '模块四 制图与成果汇编',
'display_name': '11. 分布图生成',
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
@ -226,7 +226,7 @@ PANEL_REGISTRY = [
'step_id': 'step13_report',
'class_ref': Step13ReportPanel,
'title': '报告生成',
'icon': '10.png',
'icon': '13.png',
'stage': '模块四 制图与成果汇编',
'display_name': '13. 分析报告生成',
'dependencies': None,

View File

@ -118,6 +118,18 @@ class PipelineExecutor(QObject):
# ★ 终端即时反馈:确保即使 EventBus/日志区未就绪也能看到
print("\n[PipelineExecutor] 收到「运行完整流程」指令,开始执行...")
# ★ 防重入:如果已有 Worker 在运行,拒绝启动
if self.is_running:
global_event_bus.publish('LogMessage', {
'message': '[警告] 后台任务正在运行中,请等待完成或手动停止后再启动新流程。',
'level': 'warning',
})
QMessageBox.warning(
self.parent(), "后台忙碌",
"后台任务正在运行中,无法启动新流程。\n请等待当前任务完成或点击「停止」按钮后再试。"
)
return
if not PIPELINE_AVAILABLE:
global_event_bus.publish('LogMessage', {
'message': '无法导入 Pipeline 模块,请检查项目文件结构!',
@ -340,6 +352,18 @@ class PipelineExecutor(QObject):
)
def _run_single_step_impl(self, step_name: str, config: dict = None):
# ★ 防重入:如果已有 Worker 在运行,拒绝启动
if self.is_running:
global_event_bus.publish('LogMessage', {
'message': '[警告] 后台任务正在运行中,请等待完成或手动停止后再启动新步骤。',
'level': 'warning',
})
QMessageBox.warning(
self.parent(), "后台忙碌",
"后台任务正在运行中,无法启动新步骤。\n请等待当前任务完成或点击「停止」按钮后再试。"
)
return
if not PIPELINE_AVAILABLE:
global_event_bus.publish('LogMessage', {
'message': '无法导入 Pipeline 模块,请检查 src/core/handlers/ 目录是否完整!',

View File

@ -45,6 +45,10 @@ class TrainingModeManager(QObject):
Args:
checked: True=有训练数据模式, False=无训练数据模式
"""
# ★ 防护:相同状态不重复发布事件
if self._training_mode == checked:
return
self._training_mode = checked
global_event_bus.publish('LogMessage', {

View File

@ -73,7 +73,7 @@ class VisualizationWorkerThread(QThread):
wp = Path(self.work_dir)
if self.task == "mask_glint":
from src.postprocessing.visualization_reports import WaterQualityVisualization
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization"))
preview_paths = viz.generate_glint_deglint_previews(
work_dir=str(wp),
output_subdir="glint_deglint_previews",
@ -109,7 +109,7 @@ class VisualizationWorkerThread(QThread):
csv_path = str(csv_files[0])
from src.postprocessing.point_map import SamplingPointMap
map_generator = SamplingPointMap(
output_dir=str(wp / "14_visualization" / "sampling_maps"),
output_dir=str(wp / "12_visualization" / "sampling_maps"),
fast_mode=True,
)
map_path = map_generator.create_sampling_point_map(
@ -134,7 +134,7 @@ class VisualizationWorkerThread(QThread):
)
elif self.task == "spectrum":
from src.postprocessing.visualization_reports import WaterQualityVisualization
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization"))
csv_file = self.extra.get("csv_path")
wl = self.extra.get("wavelength_start_column", "UTM_Y")
n_groups = int(self.extra.get("n_groups", 5))
@ -178,7 +178,7 @@ class VisualizationWorkerThread(QThread):
)
elif self.task == "statistics":
from src.postprocessing.visualization_reports import WaterQualityVisualization
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization"))
csv_file = self.extra.get("csv_path")
param_cols = self.extra.get("param_cols") or []
output_paths = viz.plot_statistical_charts(
@ -206,7 +206,7 @@ class VisualizationWorkerThread(QThread):
self.finished_ok.emit({"task": "scatter", "scatter_paths": scatter_paths or {}})
elif self.task == "generate_all_selected":
from src.postprocessing.visualization_reports import WaterQualityVisualization
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization"))
parts = []
training_csv_path = (self.extra.get("training_csv_path") or "").strip()
@ -333,7 +333,7 @@ class VisualizationWorkerThread(QThread):
csv_path = str(csv_files[0])
from src.postprocessing.point_map import SamplingPointMap
map_generator = SamplingPointMap(
output_dir=str(wp / "14_visualization" / "sampling_maps"),
output_dir=str(wp / "12_visualization" / "sampling_maps"),
fast_mode=True,
)
map_path = map_generator.create_sampling_point_map(

View File

@ -28,8 +28,8 @@ import sys
from pathlib import Path
from typing import Optional
from PyQt5.QtCore import QObject
from PyQt5.QtWidgets import QMessageBox, QFileDialog
from PyQt5.QtCore import QObject, Qt, QThread, QTimer
from PyQt5.QtWidgets import QMessageBox, QFileDialog, QApplication
from src.gui.core.event_bus import global_event_bus
from src.core.workspace_manager import WorkspaceManager
@ -157,10 +157,21 @@ class WorkspaceInitializer(QObject):
panel.set_work_dir(dir_path)
def open_work_directory(self):
"""在资源管理器中打开工作目录。"""
"""在资源管理器中打开工作目录(跨平台)。"""
import subprocess
import platform
work_dir = self._work_dir or './work_dir'
if os.path.exists(work_dir):
os.startfile(work_dir)
try:
system = platform.system()
if system == 'Windows':
os.startfile(work_dir)
elif system == 'Darwin':
subprocess.Popen(['open', work_dir])
else:
subprocess.Popen(['xdg-open', work_dir])
except Exception as e:
QMessageBox.warning(self.parent(), "警告", f"无法打开工作目录: {e}")
else:
QMessageBox.warning(self.parent(), "警告", "工作目录不存在!")
@ -205,8 +216,11 @@ class WorkspaceInitializer(QObject):
continue
try:
panel.update_from_config(work_dir=work_dir, pipeline=None)
except Exception:
pass
except Exception as e:
global_event_bus.publish('LogMessage', {
'message': f'[警告] 面板 {step_id} 自动填充失败: {e}',
'level': 'warning',
})
global_event_bus.publish('LogMessage', {
'message': '✓ 工作目录扫描完成,事件总线已通知所有面板自动填充',
@ -223,7 +237,15 @@ class WorkspaceInitializer(QObject):
当 PipelineExecutor 发布 StepCompleted 事件时,
此方法调用 WorkspaceManager.update_step_outputs() 扫描磁盘,
WorkspaceManager 内部自动发布 OutputUpdated 事件驱动面板填充。
★ 线程安全:若回调发生在非主线程(如 worker 线程),
通过 QTimer.singleShot(0) 编组到主线程再执行。
"""
# 线程安全检查:非主线程时编组到主线程
if QApplication.instance() and QApplication.instance().thread() != QThread.currentThread():
QTimer.singleShot(0, lambda: self._on_step_completed(data))
return
if not data.get('success'):
return