From f24aa4f5556fa031f2e2c50f6f4f352dee6ded26 Mon Sep 17 00:00:00 2001 From: DXC Date: Fri, 8 May 2026 14:21:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20PyQt=200xC0000409=20?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=EF=BC=9A=E4=BF=AE=E5=A4=8D=20window=20?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=E5=91=BD=E5=90=8D=E5=86=B2=E7=AA=81=E3=80=81?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=BC=82=E5=B8=B8=E9=92=A9=E5=AD=90=E3=80=81?= =?UTF-8?q?=E5=8F=AF=E8=A7=86=E5=8C=96=E9=9D=A2=E6=9D=BF=E5=81=A5=E5=A3=AE?= =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/panels/step6_5_panel.py | 80 +++-- src/gui/panels/visualization_panel.py | 417 ++++++++++++++++++-------- src/gui/water_quality_gui.py | 12 + 3 files changed, 353 insertions(+), 156 deletions(-) diff --git a/src/gui/panels/step6_5_panel.py b/src/gui/panels/step6_5_panel.py index 4ea1b6d..6eddab7 100644 --- a/src/gui/panels/step6_5_panel.py +++ b/src/gui/panels/step6_5_panel.py @@ -102,11 +102,11 @@ class Step6_5Panel(QWidget): self.spectral_start_col.setValue(1) params_layout.addRow("光谱起始列索引:", self.spectral_start_col) - # 窗口大小 - self.window = QSpinBox() - self.window.setRange(1, 20) - self.window.setValue(5) - params_layout.addRow("窗口大小:", self.window) + # 窗口大小 (变量名已修正,避免覆盖 QWidget.window) + self.window_size_spinbox = QSpinBox() + self.window_size_spinbox.setRange(1, 20) + self.window_size_spinbox.setValue(5) + params_layout.addRow("窗口大小:", self.window_size_spinbox) params_group.setLayout(params_layout) layout.addWidget(params_group) @@ -160,7 +160,7 @@ class Step6_5Panel(QWidget): 'algorithms': selected_algorithms, 'value_cols': value_cols, 'spectral_start_col': self.spectral_start_col.value(), - 'window': self.window.value(), + 'window': self.window_size_spinbox.value(), 'enabled': self.enable_checkbox.isChecked() } @@ -205,7 +205,7 @@ class Step6_5Panel(QWidget): self.spectral_start_col.setValue(config['spectral_start_col']) if 'window' in config: - self.window.setValue(config['window']) + self.window_size_spinbox.setValue(config['window']) if 'output_dir' in config: self.output_dir.set_path(config['output_dir']) if 'csv_path' in config: @@ -218,38 +218,54 @@ class Step6_5Panel(QWidget): work_dir: 工作目录路径 pipeline: Pipeline 实例(未使用,保留接口兼容性) """ - if work_dir: - self.work_dir = work_dir - elif hasattr(self, 'work_dir') and self.work_dir: - pass - else: - self.work_dir = None + try: + import traceback - # 1. 尝试从 Step5 界面读取训练光谱 CSV 路径 - main_window = self.window() - if main_window and hasattr(main_window, 'step5_panel'): - step5_output_path = main_window.step5_panel.output_file.get_path() - if step5_output_path: - # 若为相对路径,使用 work_dir 合成为绝对路径 - if not os.path.isabs(step5_output_path): - step5_output_path = os.path.join(self.work_dir or '', step5_output_path).replace('\\', '/') - existing = self.training_csv_file.get_path() - if not existing or not existing.strip(): - self.training_csv_file.set_path(step5_output_path) + if work_dir: + self.work_dir = work_dir + elif hasattr(self, 'work_dir') and self.work_dir: + pass + else: + self.work_dir = None - # 2. 自动填充输出目录(8_Regression_Modeling) - if self.work_dir: - output_dir = os.path.join(self.work_dir, "8_Regression_Modeling") - os.makedirs(output_dir, exist_ok=True) - existing_out = self.output_dir.get_path() - if not existing_out or not existing_out.strip(): - self.output_dir.set_path(output_dir) + # 借用父组件的 window() 方法,安全绕过当前类的命名冲突 + parent_widget = self.parentWidget() + main_window = parent_widget.window() if parent_widget else None + if main_window and hasattr(main_window, 'step5_panel'): + 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: + # 若为相对路径,使用 work_dir 合成为绝对路径 + if not os.path.isabs(step5_output_path): + step5_output_path = os.path.join(self.work_dir or '', step5_output_path).replace('\\', '/') + existing = self.training_csv_file.get_path() + if not existing or not existing.strip(): + self.training_csv_file.set_path(step5_output_path) + + # 2. 自动填充输出目录(8_Regression_Modeling) + if self.work_dir: + output_dir = os.path.join(self.work_dir, "8_Regression_Modeling") + os.makedirs(output_dir, exist_ok=True) + existing_out = self.output_dir.get_path() + if not existing_out or not existing_out.strip(): + 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): """获取 work_dir,优先用 panel 自身缓存的,否则尝试从主窗口取""" if hasattr(self, 'work_dir') and 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: return str(mw.work_dir) return "" diff --git a/src/gui/panels/visualization_panel.py b/src/gui/panels/visualization_panel.py index 96c179b..e01f0dc 100644 --- a/src/gui/panels/visualization_panel.py +++ b/src/gui/panels/visualization_panel.py @@ -493,17 +493,9 @@ class ChartViewerDialog(QDialog): class ImageCategoryTree(QTreeWidget): - """图像分类目录树 - 按类别组织图像文件""" + """图像分类目录树 - 按真实物理文件夹结构组织图像文件""" - CATEGORIES = [ - ("模型评估", ["scatter", "regression", "validation", "r2", "rmse"], "📊"), - ("光谱分析", ["spectrum", "spectral", "band", "wavelength"], "📈"), - ("统计图表", ["boxplot", "histogram", "heatmap", "statistics", "stats"], "📉"), - ("处理结果", ["mask", "glint", "deglint", "preview", "overlay", "water_mask"], "🖼️"), - ("含量分布图", [], "📁"), - ] - - # 文件名中文翻译映射 + # 文件名中文翻译映射(key: 文件名前缀 → 中文显示名) NAME_MAPPING = { "hsi_preview": "高光谱影像预览", "hsi_original": "原始高光谱影像", @@ -537,14 +529,52 @@ class ImageCategoryTree(QTreeWidget): "error_analysis": "误差分析图", "rmse": "RMSE评估图", "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): super().__init__(parent) + self._dir_node_map: dict = {} # 目录路径字符串 → QTreeWidgetItem + self._work_path: Optional[Path] = None self.setHeaderLabel("图像目录") self.setMaximumWidth(300) self.setMinimumWidth(250) - self.setup_categories() self.setStyleSheet(""" QTreeWidget { border: 1px solid #ddd; @@ -564,96 +594,211 @@ 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): """清除所有图像项""" - for category_item in self.category_items.values(): - while category_item.childCount() > 0: - category_item.removeChild(category_item.child(0)) + try: + self.invisibleRootItem().takeChildren() + 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): - """添加图像到对应的类别(带中文名称翻译)""" - if display_name is None: - # 使用翻译映射,查询不到则用原文件名 - file_base = file_path.stem - display_name = self.NAME_MAPPING.get(file_base, file_base) + def _translate_dir_name(self, dir_name: str) -> str: + """翻译目录名为中文""" + return self.DIR_MAPPING.get(dir_name, dir_name) - category = self._determine_category(file_path.name) - category_item = self.category_items.get(category, self.category_items["含量分布图"]) + def _translate_filename(self, filename: str) -> str: + """翻译文件名为中文(动态替换后缀片段)""" + # 依次替换常见后缀模式 + 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.setToolTip(0, str(file_path)) 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): - """扫描目录中的所有图像文件(深度递归扫描)""" - self.clear_all_images() + """扫描目录中的所有图像文件(深度递归扫描)—— 按真实物理目录结构挂载""" + try: + if not work_dir: + print("可视化面板:工作目录为空,跳过扫描") + return - work_path = Path(work_dir) - if not work_path.exists(): + 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 - image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp'] + try: + image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp'] - # 扫描根目录列表(按优先级) - scan_roots: List[Path] = [ - work_path / "14_visualization", - work_path / "1_water_mask", - work_path / "9_water_quality_prediction", - work_path / "10_feature_construction", - ] + # 拓宽扫描根目录列表(新增多个遗漏目录) + scan_roots: List[Path] = [ + self._work_path / "14_visualization", + self._work_path / "11_12_13_predictions", + self._work_path / "8_Regression_Modeling", + 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()] - if not scan_roots: - scan_roots.append(work_path) + # 只保留存在的目录,并补充工作根目录作为兜底 + scan_roots = [p for p in scan_roots if p.is_dir()] + if not scan_roots: + scan_roots.append(self._work_path) - seen_norm: set = set() - image_files: List[Path] = [] - for root in scan_roots: - for ext in image_extensions: - # 使用 rglob 进行深度递归扫描 - for p in root.rglob(ext): - key = os.path.normcase(os.path.normpath(str(p.resolve()))) - if key in seen_norm: - continue - seen_norm.add(key) - image_files.append(p) + seen_norm: set = set() + image_files: List[Path] = [] + for root in scan_roots: + for ext in image_extensions: + for p in root.rglob(ext): + key = os.path.normcase(os.path.normpath(str(p.resolve()))) + if key in seen_norm: + continue + seen_norm.add(key) + image_files.append(p) - for img_file in sorted(image_files): - if img_file.name.startswith('.') or 'thumb' in img_file.name.lower(): - continue - self.add_image(img_file) + for img_file in sorted(image_files): + if img_file.name.startswith('.') or 'thumb' in img_file.name.lower(): + continue + self.add_image_by_dir(img_file, self._work_path) - for category_name, item in self.category_items.items(): - count = item.childCount() - if count > 0: - for cat_name, _, icon in self.CATEGORIES: - if cat_name == category_name: - item.setText(0, f"{icon} {category_name} ({count})") - break + # 更新目录节点计数 + 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() + name = item.text(0) + if count > 0 and f"({count})" not in name: + # 从目录名中提取显示名并附加计数 + display = name.strip() + 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]: """获取当前选中的图像路径""" @@ -1295,54 +1440,78 @@ class VisualizationPanel(QWidget): 4. {work_dir}/14_visualization(可视化目录) 5. {work_dir}(工作目录根) """ - if work_dir: - self.work_dir = work_dir - self.work_dir_edit.setText(str(work_dir)) - elif not self.work_dir: - return + try: + if work_dir: + self.work_dir = work_dir + self.work_dir_edit.setText(str(work_dir)) + elif not self.work_dir: + return - work_path = Path(self.work_dir) - pred_dir = work_path / "11_12_13_predictions" + work_path = Path(self.work_dir) + pred_dir = work_path / "11_12_13_predictions" - # 按优先级寻找存在的目录 - candidates = [ - pred_dir / "Machine_Learning_Prediction", - pred_dir / "Non_Empirical_Prediction", - pred_dir / "Custom_Regression_Prediction", - work_path / "14_visualization", - work_path, - ] - detected_dir = None - for candidate in candidates: - if candidate.exists() and candidate.is_dir(): - detected_dir = candidate - break + # 按优先级寻找存在的目录 + candidates = [ + pred_dir / "Machine_Learning_Prediction", + pred_dir / "Non_Empirical_Prediction", + pred_dir / "Custom_Regression_Prediction", + work_path / "14_visualization", + work_path, + ] + detected_dir = None + for candidate in candidates: + if candidate.exists() and candidate.is_dir(): + detected_dir = candidate + break - if detected_dir: - detected_str = str(detected_dir) - self.img_dir_edit.setText(detected_str) - self.image_tree.scan_directory(detected_str) - else: - # 无预测目录时扫描整个工作目录 - self.image_tree.scan_directory(self.work_dir) + if detected_dir: + detected_str = str(detected_dir) + self.img_dir_edit.setText(detected_str) + self.image_tree.scan_directory(detected_str) + else: + # 无预测目录时扫描整个工作目录 + self.image_tree.scan_directory(self.work_dir) - # 自动触发加载第一张图像 - 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): - """从目录树中加载第一张图像到右侧查看器""" - tree = self.image_tree - if tree is None: - return - for category_item in tree.category_items.values(): - for i in range(category_item.childCount()): - child = category_item.child(i) - data = child.data(0, Qt.UserRole) - if data and data.get("type") == "image": - img_path = data.get("path") - if img_path and Path(img_path).exists(): - self.image_viewer.load_image(img_path) - return + """自动加载树状图中的第一张有效图片(兼容物理目录层级结构)""" + try: + tree = getattr(self, 'image_tree', None) + if not tree: + return + + from PyQt5.QtCore import Qt + + def find_first_image(item): + # 检查当前节点是否是图片节点 + data = item.data(0, Qt.UserRole) + if isinstance(data, dict) and data.get("type") == "image": + 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 + except Exception as e: + import traceback + print(f"自动加载首张图片失败: {e}") + traceback.print_exc() def scan_work_directory(self): """扫描工作目录中的图像文件""" diff --git a/src/gui/water_quality_gui.py b/src/gui/water_quality_gui.py index 0f61f86..09f487c 100644 --- a/src/gui/water_quality_gui.py +++ b/src/gui/water_quality_gui.py @@ -29,6 +29,18 @@ from PyQt5.QtWidgets import ( from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer, QAbstractTableModel, QSize 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 打包 try: from styles import ModernStylesheet