修复 PyQt 0xC0000409 崩溃:修复 window 属性命名冲突、全局异常钩子、可视化面板健壮重构

This commit is contained in:
DXC
2026-05-08 14:21:50 +08:00
parent 5af466b2d3
commit f24aa4f555
3 changed files with 353 additions and 156 deletions

View File

@ -102,11 +102,11 @@ class Step6_5Panel(QWidget):
self.spectral_start_col.setValue(1) self.spectral_start_col.setValue(1)
params_layout.addRow("光谱起始列索引:", self.spectral_start_col) params_layout.addRow("光谱起始列索引:", self.spectral_start_col)
# 窗口大小 # 窗口大小 (变量名已修正,避免覆盖 QWidget.window)
self.window = QSpinBox() self.window_size_spinbox = QSpinBox()
self.window.setRange(1, 20) self.window_size_spinbox.setRange(1, 20)
self.window.setValue(5) self.window_size_spinbox.setValue(5)
params_layout.addRow("窗口大小:", self.window) params_layout.addRow("窗口大小:", self.window_size_spinbox)
params_group.setLayout(params_layout) params_group.setLayout(params_layout)
layout.addWidget(params_group) layout.addWidget(params_group)
@ -160,7 +160,7 @@ class Step6_5Panel(QWidget):
'algorithms': selected_algorithms, 'algorithms': selected_algorithms,
'value_cols': value_cols, 'value_cols': value_cols,
'spectral_start_col': self.spectral_start_col.value(), 'spectral_start_col': self.spectral_start_col.value(),
'window': self.window.value(), 'window': self.window_size_spinbox.value(),
'enabled': self.enable_checkbox.isChecked() 'enabled': self.enable_checkbox.isChecked()
} }
@ -205,7 +205,7 @@ class Step6_5Panel(QWidget):
self.spectral_start_col.setValue(config['spectral_start_col']) self.spectral_start_col.setValue(config['spectral_start_col'])
if 'window' in config: if 'window' in config:
self.window.setValue(config['window']) self.window_size_spinbox.setValue(config['window'])
if 'output_dir' in config: if 'output_dir' in config:
self.output_dir.set_path(config['output_dir']) self.output_dir.set_path(config['output_dir'])
if 'csv_path' in config: if 'csv_path' in config:
@ -218,6 +218,9 @@ class Step6_5Panel(QWidget):
work_dir: 工作目录路径 work_dir: 工作目录路径
pipeline: Pipeline 实例(未使用,保留接口兼容性) pipeline: Pipeline 实例(未使用,保留接口兼容性)
""" """
try:
import traceback
if work_dir: if work_dir:
self.work_dir = work_dir self.work_dir = work_dir
elif hasattr(self, 'work_dir') and self.work_dir: elif hasattr(self, 'work_dir') and self.work_dir:
@ -225,10 +228,17 @@ class Step6_5Panel(QWidget):
else: else:
self.work_dir = None self.work_dir = None
# 1. 尝试从 Step5 界面读取训练光谱 CSV 路径 # 借用父组件的 window() 方法,安全绕过当前类的命名冲突
main_window = self.window() parent_widget = self.parentWidget()
main_window = parent_widget.window() if parent_widget else None
if main_window and hasattr(main_window, 'step5_panel'): if main_window and hasattr(main_window, 'step5_panel'):
step5_output_path = main_window.step5_panel.output_file.get_path() step5_widget = getattr(main_window.step5_panel, 'output_file', None)
step5_output_path = ""
if hasattr(step5_widget, 'get_path'):
step5_output_path = step5_widget.get_path() or ""
elif hasattr(step5_widget, 'text'):
step5_output_path = step5_widget.text() or ""
if step5_output_path: if step5_output_path:
# 若为相对路径,使用 work_dir 合成为绝对路径 # 若为相对路径,使用 work_dir 合成为绝对路径
if not os.path.isabs(step5_output_path): if not os.path.isabs(step5_output_path):
@ -244,12 +254,18 @@ class Step6_5Panel(QWidget):
existing_out = self.output_dir.get_path() existing_out = self.output_dir.get_path()
if not existing_out or not existing_out.strip(): if not existing_out or not existing_out.strip():
self.output_dir.set_path(output_dir) self.output_dir.set_path(output_dir)
except Exception as e:
import traceback
print(f"{self.__class__.__name__}】自动填充失败,跳过: {e}")
traceback.print_exc()
def _get_default_work_dir(self): def _get_default_work_dir(self):
"""获取 work_dir优先用 panel 自身缓存的,否则尝试从主窗口取""" """获取 work_dir优先用 panel 自身缓存的,否则尝试从主窗口取"""
if hasattr(self, 'work_dir') and self.work_dir: if hasattr(self, 'work_dir') and self.work_dir:
return str(self.work_dir) return str(self.work_dir)
mw = self.window() # 借用父组件的 window() 方法,安全绕过当前类的命名冲突
parent_widget = self.parentWidget()
mw = parent_widget.window() if parent_widget else None
if mw and hasattr(mw, 'work_dir') and mw.work_dir: if mw and hasattr(mw, 'work_dir') and mw.work_dir:
return str(mw.work_dir) return str(mw.work_dir)
return "" return ""

View File

@ -493,17 +493,9 @@ class ChartViewerDialog(QDialog):
class ImageCategoryTree(QTreeWidget): class ImageCategoryTree(QTreeWidget):
"""图像分类目录树 - 按类别组织图像文件""" """图像分类目录树 - 按真实物理文件夹结构组织图像文件"""
CATEGORIES = [ # 文件名中文翻译映射key: 文件名前缀 → 中文显示名)
("模型评估", ["scatter", "regression", "validation", "r2", "rmse"], "📊"),
("光谱分析", ["spectrum", "spectral", "band", "wavelength"], "📈"),
("统计图表", ["boxplot", "histogram", "heatmap", "statistics", "stats"], "📉"),
("处理结果", ["mask", "glint", "deglint", "preview", "overlay", "water_mask"], "🖼️"),
("含量分布图", [], "📁"),
]
# 文件名中文翻译映射
NAME_MAPPING = { NAME_MAPPING = {
"hsi_preview": "高光谱影像预览", "hsi_preview": "高光谱影像预览",
"hsi_original": "原始高光谱影像", "hsi_original": "原始高光谱影像",
@ -537,14 +529,52 @@ class ImageCategoryTree(QTreeWidget):
"error_analysis": "误差分析图", "error_analysis": "误差分析图",
"rmse": "RMSE评估图", "rmse": "RMSE评估图",
"r2_score": "R²得分图", "r2_score": "R²得分图",
"flight": "飞行轨迹图",
"path": "轨迹图",
"trajectory": "轨迹图",
"glint_deglint": "耀斑去耀斑影像",
"enhanced": "增强分布图",
"content": "含量分布图",
"distribution": "分布图",
"prediction": "预测图",
"inversion": "反演图",
"scatter_true_vs_pred": "真值-预测散点图",
"true_vs_pred": "真值-预测散点图",
"correlation_heatmap": "相关性热力图",
"parameter_boxplot": "水质参数箱线图",
"spectrum_comparison": "光谱曲线对比图",
"scatter": "散点图",
}
# 目录层级中文翻译
DIR_MAPPING = {
"14_visualization": "可视化产物",
"glint_deglint_previews": "耀斑与去耀斑预览",
"sampling_maps": "采样点地图",
"scatter_plots": "模型评估散点图",
"flight_maps": "飞行轨迹图",
"11_12_13_predictions": "预测结果",
"Machine_Learning_Prediction": "机器学习预测",
"Non_Empirical_Prediction": "非经验模型预测",
"Custom_Regression_Prediction": "自定义回归预测",
"8_Regression_Modeling": "回归建模",
"10_feature_construction": "特征构建",
"5_training_spectra": "训练光谱",
"2_glint": "耀斑分析",
"3_deglint": "去耀斑处理",
"1_water_mask": "水掩膜",
"9_water_quality_prediction": "水质预测",
"8_spatial_inversion": "空间反演",
"4_processed_data": "处理数据",
} }
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._dir_node_map: dict = {} # 目录路径字符串 → QTreeWidgetItem
self._work_path: Optional[Path] = None
self.setHeaderLabel("图像目录") self.setHeaderLabel("图像目录")
self.setMaximumWidth(300) self.setMaximumWidth(300)
self.setMinimumWidth(250) self.setMinimumWidth(250)
self.setup_categories()
self.setStyleSheet(""" self.setStyleSheet("""
QTreeWidget { QTreeWidget {
border: 1px solid #ddd; border: 1px solid #ddd;
@ -564,77 +594,184 @@ class ImageCategoryTree(QTreeWidget):
} }
""") """)
def setup_categories(self):
"""初始化类别节点"""
self.category_items = {}
for category_name, keywords, icon in self.CATEGORIES:
item = QTreeWidgetItem(self)
item.setText(0, f"{icon} {category_name}")
item.setData(0, Qt.UserRole, {"type": "category", "keywords": keywords, "name": category_name})
item.setExpanded(True)
self.category_items[category_name] = item
def clear_all_images(self): def clear_all_images(self):
"""清除所有图像项""" """清除所有图像项"""
for category_item in self.category_items.values(): try:
while category_item.childCount() > 0: self.invisibleRootItem().takeChildren()
category_item.removeChild(category_item.child(0)) if hasattr(self, '_dir_node_map'):
self._dir_node_map.clear()
except Exception as e:
print(f"清空树状图出错: {e}")
import traceback
traceback.print_exc()
def add_image(self, file_path: Path, display_name: str = None): def _translate_dir_name(self, dir_name: str) -> str:
"""添加图像到对应的类别(带中文名称翻译)""" """翻译目录名为中文"""
if display_name is None: return self.DIR_MAPPING.get(dir_name, dir_name)
# 使用翻译映射,查询不到则用原文件名
file_base = file_path.stem
display_name = self.NAME_MAPPING.get(file_base, file_base)
category = self._determine_category(file_path.name) def _translate_filename(self, filename: str) -> str:
category_item = self.category_items.get(category, self.category_items["含量分布图"]) """翻译文件名为中文(动态替换后缀片段)"""
# 依次替换常见后缀模式
replacements = [
("_spectrum_comparison", " 光谱曲线对比图"),
("_scatter_true_vs_pred", " 真值-预测散点图"),
("_true_vs_pred", " 真值-预测散点图"),
("_histogram", " 分布直方图"),
("_boxplot", " 箱线图"),
("_distribution_map", " 分布图"),
("_distribution_enhanced", " 增强分布图"),
("_thematic_map", " 专题图"),
("_water_quality_map", " 水质分布图"),
("_prediction_map", " 预测结果图"),
("_inversion_map", " 反演结果图"),
("_glint_deglint", " 耀斑去耀斑对比"),
("_glint_mask", " 耀斑掩膜"),
("_deglint", " 去耀斑"),
("_mask_overlay", " 掩膜叠加"),
("_content", " 含量"),
("_distribution", " 分布"),
("_prediction", " 预测"),
("_inversion", " 反演"),
("_enhanced", " 增强"),
("_scatter", " 散点图"),
("_boxplot", " 箱线图"),
("_correlation_heatmap", " 相关性热力图"),
("_parameter_boxplot", " 箱线图"),
("_sampling_point", " 采样点"),
("_sampling_points", " 采样点"),
("_flight_path", " 飞行轨迹"),
("_trajectory", " 轨迹"),
]
result = filename
for pattern, replacement in replacements:
result = result.replace(pattern, replacement)
image_item = QTreeWidgetItem(category_item) # 处理参数名(常见水质参数翻译)
image_item.setText(0, f" └─ {display_name}") param_map = {
"Chla": "叶绿素", "COD": "化学需氧量", "TN": "总氮", "TP": "总磷",
"Turbidity": "浊度", "DO": "溶解氧", "pH": "pH值",
"Conductivity": "电导率", "BOD": "生化需氧量", "NH3_N": "氨氮",
}
for eng, chn in param_map.items():
# 在文件名中找到参数名并翻译
if eng.lower() in result.lower():
result = result.replace(eng, chn)
if eng.lower() in result.lower():
result = result.replace(eng.lower(), chn)
# 如果没有任何替换,返回原文件名(去掉扩展名)
if result == filename:
return filename.rsplit(".", 1)[0] if "." in filename else filename
return result
def add_image_by_dir(self, file_path: Path, work_path: Path):
"""按真实物理目录层级挂载图片节点
Args:
file_path: 图片文件的完整路径
work_path: 工作目录根路径
"""
# 计算相对路径
try:
rel_path = file_path.relative_to(work_path)
except ValueError:
rel_path = Path(file_path.name)
# 分离父目录链和文件名
parts = rel_path.parts
if len(parts) <= 1:
parent_key = "__root__"
parent_display = "根目录"
else:
# 父目录路径相对于work_path
parent_key = str(Path(*parts[:-1]))
# 取最后一层目录名作为显示名
parent_display = self._translate_dir_name(parts[-2])
# 根目录节点特殊处理
root_display = self._translate_dir_name(parts[0]) if parts else "根目录"
# 获取或创建根目录节点
if root_display not in self._dir_node_map:
root_item = QTreeWidgetItem(self)
root_item.setText(0, f"📁 {root_display}")
root_item.setData(0, Qt.UserRole, {"type": "root_dir", "path": str(work_path / parts[0])})
root_item.setExpanded(True)
self._dir_node_map[root_display] = root_item
self._dir_node_map[f"__root__{root_display}"] = root_item
root_item = self._dir_node_map.get(f"__root__{root_display}")
if len(parts) > 1:
# 获取或创建子目录节点
if parent_key not in self._dir_node_map:
dir_item = QTreeWidgetItem(root_item)
dir_item.setText(0, f" 📂 {parent_display}")
dir_item.setData(0, Qt.UserRole, {"type": "sub_dir", "path": str(work_path / parent_key)})
dir_item.setExpanded(True)
self._dir_node_map[parent_key] = dir_item
parent_item = self._dir_node_map[parent_key]
else:
parent_item = root_item
# 创建图片节点
display_name = self._translate_filename(file_path.stem) + file_path.suffix
image_item = QTreeWidgetItem(parent_item)
image_item.setText(0, f" 🖼 {display_name}")
image_item.setData(0, Qt.UserRole, {"type": "image", "path": str(file_path), "display_name": display_name}) image_item.setData(0, Qt.UserRole, {"type": "image", "path": str(file_path), "display_name": display_name})
image_item.setToolTip(0, str(file_path)) image_item.setToolTip(0, str(file_path))
return image_item return image_item
def _determine_category(self, filename: str) -> str:
"""根据文件名确定类别"""
filename_lower = filename.lower()
for category_name, keywords, _ in self.CATEGORIES:
if any(keyword in filename_lower for keyword in keywords):
return category_name
return "含量分布图"
def scan_directory(self, work_dir: str): def scan_directory(self, work_dir: str):
"""扫描目录中的所有图像文件(深度递归扫描)""" """扫描目录中的所有图像文件(深度递归扫描)—— 按真实物理目录结构挂载"""
self.clear_all_images() try:
if not work_dir:
work_path = Path(work_dir) print("可视化面板:工作目录为空,跳过扫描")
if not work_path.exists():
return return
self._work_path = Path(work_dir)
# 阻塞信号,防止在清空树状图时触发 selected 槽函数导致崩溃
# 因为当前类继承自 QTreeWidget所以 self 本身就是树
self.blockSignals(True)
self.clear_all_images()
self.blockSignals(False)
if not self._work_path.exists():
return
except Exception as e:
import traceback
print(f"可视化面板初始化扫描出错: {e}")
traceback.print_exc()
# 确保信号锁被解开
self.blockSignals(False)
return
try:
image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp'] image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp']
# 扫描根目录列表(按优先级 # 拓宽扫描根目录列表(新增多个遗漏目录
scan_roots: List[Path] = [ scan_roots: List[Path] = [
work_path / "14_visualization", self._work_path / "14_visualization",
work_path / "1_water_mask", self._work_path / "11_12_13_predictions",
work_path / "9_water_quality_prediction", self._work_path / "8_Regression_Modeling",
work_path / "10_feature_construction", self._work_path / "10_feature_construction",
self._work_path / "5_training_spectra",
self._work_path / "2_glint",
self._work_path / "3_deglint",
self._work_path / "1_water_mask",
self._work_path / "9_water_quality_prediction",
] ]
# 只保留存在的目录,并补充工作根目录作为兜底 # 只保留存在的目录,并补充工作根目录作为兜底
scan_roots = [p for p in scan_roots if p.is_dir()] scan_roots = [p for p in scan_roots if p.is_dir()]
if not scan_roots: if not scan_roots:
scan_roots.append(work_path) scan_roots.append(self._work_path)
seen_norm: set = set() seen_norm: set = set()
image_files: List[Path] = [] image_files: List[Path] = []
for root in scan_roots: for root in scan_roots:
for ext in image_extensions: for ext in image_extensions:
# 使用 rglob 进行深度递归扫描
for p in root.rglob(ext): for p in root.rglob(ext):
key = os.path.normcase(os.path.normpath(str(p.resolve()))) key = os.path.normcase(os.path.normpath(str(p.resolve())))
if key in seen_norm: if key in seen_norm:
@ -645,15 +782,23 @@ class ImageCategoryTree(QTreeWidget):
for img_file in sorted(image_files): for img_file in sorted(image_files):
if img_file.name.startswith('.') or 'thumb' in img_file.name.lower(): if img_file.name.startswith('.') or 'thumb' in img_file.name.lower():
continue continue
self.add_image(img_file) self.add_image_by_dir(img_file, self._work_path)
for category_name, item in self.category_items.items(): # 更新目录节点计数
for key, item in self._dir_node_map.items():
if key.startswith("__root__"):
continue
if item.data(0, Qt.UserRole).get("type") == "sub_dir":
count = item.childCount() count = item.childCount()
if count > 0: name = item.text(0)
for cat_name, _, icon in self.CATEGORIES: if count > 0 and f"({count})" not in name:
if cat_name == category_name: # 从目录名中提取显示名并附加计数
item.setText(0, f"{icon} {category_name} ({count})") display = name.strip()
break item.setText(0, f" 📂 {display} ({count})")
except Exception as e:
import traceback
print(f"可视化面板图片挂载出错: {e}")
traceback.print_exc()
def get_selected_image_path(self) -> Optional[str]: def get_selected_image_path(self) -> Optional[str]:
"""获取当前选中的图像路径""" """获取当前选中的图像路径"""
@ -1295,6 +1440,7 @@ class VisualizationPanel(QWidget):
4. {work_dir}/14_visualization可视化目录 4. {work_dir}/14_visualization可视化目录
5. {work_dir}(工作目录根) 5. {work_dir}(工作目录根)
""" """
try:
if work_dir: if work_dir:
self.work_dir = work_dir self.work_dir = work_dir
self.work_dir_edit.setText(str(work_dir)) self.work_dir_edit.setText(str(work_dir))
@ -1328,21 +1474,44 @@ class VisualizationPanel(QWidget):
# 自动触发加载第一张图像 # 自动触发加载第一张图像
self._load_first_image_from_tree() self._load_first_image_from_tree()
except Exception as e:
import traceback
print(f"可视化面板 update_from_config 出错: {e}")
traceback.print_exc()
def _load_first_image_from_tree(self): def _load_first_image_from_tree(self):
"""从目录树中加载第一张图像到右侧查看器""" """自动加载树状图中的第一张有效图片(兼容物理目录层级结构)"""
tree = self.image_tree try:
if tree is None: tree = getattr(self, 'image_tree', None)
if not tree:
return return
for category_item in tree.category_items.values():
for i in range(category_item.childCount()): from PyQt5.QtCore import Qt
child = category_item.child(i)
data = child.data(0, Qt.UserRole) def find_first_image(item):
if data and data.get("type") == "image": # 检查当前节点是否是图片节点
img_path = data.get("path") data = item.data(0, Qt.UserRole)
if img_path and Path(img_path).exists(): if isinstance(data, dict) and data.get("type") == "image":
self.image_viewer.load_image(img_path) return item
# 如果不是,递归检查所有子节点
for i in range(item.childCount()):
found = find_first_image(item.child(i))
if found:
return found
return None
# 遍历所有顶层节点
for i in range(tree.topLevelItemCount()):
first_img_item = find_first_image(tree.topLevelItem(i))
if first_img_item:
tree.setCurrentItem(first_img_item)
# 主动触发一次点击槽函数,以在右侧渲染图片
self.on_tree_item_clicked(first_img_item, 0)
return return
except Exception as e:
import traceback
print(f"自动加载首张图片失败: {e}")
traceback.print_exc()
def scan_work_directory(self): def scan_work_directory(self):
"""扫描工作目录中的图像文件""" """扫描工作目录中的图像文件"""

View File

@ -29,6 +29,18 @@ from PyQt5.QtWidgets import (
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer, QAbstractTableModel, QSize from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer, QAbstractTableModel, QSize
from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPalette, QColor, QPixmap from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPalette, QColor, QPixmap
import sys
import traceback
def global_exception_handler(exc_type, exc_value, exc_traceback):
print("\n" + "="*50)
print("【严重错误拦截 - PyQt 崩溃死因】")
traceback.print_exception(exc_type, exc_value, exc_traceback)
print("="*50 + "\n")
# 挂载全局异常钩子,阻止 PyQt 静默闪退
sys.excepthook = global_exception_handler
# 导入样式模块 - 兼容开发环境和 PyInstaller 打包 # 导入样式模块 - 兼容开发环境和 PyInstaller 打包
try: try:
from styles import ModernStylesheet from styles import ModernStylesheet