diff --git a/src/gui/panels/step6_feature_panel.py b/src/gui/panels/step6_feature_panel.py index d8a2c62..043f9a3 100644 --- a/src/gui/panels/step6_feature_panel.py +++ b/src/gui/panels/step6_feature_panel.py @@ -1,22 +1,22 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Step6 面板 - 光谱特征提取 +Step6 面板 - 光谱特征提取 (完美对齐无跳动重构版) """ import os import sys from pathlib import Path -# 路径归一化 helper(与 pipeline.get_step_output_dir 互为表里) +# 路径归一化 helper _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) from src.gui.panels._step_path_resolver import resolve_subdir from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QGroupBox, QFormLayout, QLabel, - QSpinBox, QPushButton, QCheckBox, QMessageBox, + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, + QLabel, QSpinBox, QPushButton, QMessageBox, QSizePolicy ) from PyQt5.QtGui import QFont from PyQt5.QtCore import Qt @@ -27,91 +27,148 @@ from src.gui.styles import ModernStylesheet class Step6FeaturePanel(QWidget): """步骤6:光谱特征提取""" + def __init__(self, parent=None): super().__init__(parent) self.init_ui() def init_ui(self): - layout = QVBoxLayout() + # 1. 注入全局样式系统 + self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) - # 标题 - title = QLabel("步骤6:光谱特征提取") - title.setFont(QFont("Arial", 12, QFont.Bold)) - layout.addWidget(title) + # 主布局:增加四周留白(24px)和模块间的呼吸间距(20px) + main_layout = QVBoxLayout() + main_layout.setContentsMargins(24, 24, 24, 24) + main_layout.setSpacing(20) + + # ========================================== + # 卡片 1:输入数据 + # ========================================== + input_group = QGroupBox("📁 输入数据") + input_layout = QVBoxLayout() + input_layout.setSpacing(16) + input_layout.setContentsMargins(20, 24, 20, 20) + + step6_glint_hint = QLabel( + "💡 提示:独立运行本步骤时必须选择耀斑掩膜(通常为步骤2输出的 severe_glint_area.dat),用于在采样时自动避开耀斑像元。" + ) + step6_glint_hint.setWordWrap(True) + step6_glint_hint.setStyleSheet(f""" + QLabel {{ + color: {ModernStylesheet.COLORS['primary']}; + background-color: {ModernStylesheet.COLORS['selected']}; + border: 1px solid {ModernStylesheet.COLORS['border_light']}; + border-radius: 6px; + padding: 10px 14px; + margin-bottom: 4px; + }} + """) + input_layout.addWidget(step6_glint_hint) - # 去耀斑影像文件(用于独立运行) self.deglint_img_file = FileSelectWidget( "去耀斑影像:", "Image Files (*.bsq *.dat *.tif);;All Files (*.*)" ) - layout.addWidget(self.deglint_img_file) + self.deglint_img_file.label.setMinimumWidth(100) - # 处理后的CSV文件(用于独立运行) self.csv_file = FileSelectWidget( "处理后CSV:", "CSV Files (*.csv);;All Files (*.*)" ) - layout.addWidget(self.csv_file) + self.csv_file.label.setMinimumWidth(100) - # 水体掩膜文件(可选,用于独立运行) self.water_mask_file = FileSelectWidget( "水体掩膜:", "Mask Files (*.dat *.tif);;All Files (*.*)" ) + self.water_mask_file.label.setMinimumWidth(100) self.water_mask_file.line_edit.setPlaceholderText("可选,如不选择则自动生成") - layout.addWidget(self.water_mask_file) self.glint_mask_file = FileSelectWidget( "耀斑掩膜:", "Mask Files (*.dat *.tif);;All Files (*.*)" ) - layout.addWidget(self.glint_mask_file) - step6_glint_hint = QLabel( - "提示:独立运行本步骤时必须选择耀斑掩膜(通常为步骤2输出的 severe_glint_area.dat),用于在采样时避开耀斑像元。" - ) - step6_glint_hint.setWordWrap(True) - step6_glint_hint.setStyleSheet("color: #666; font-size: 10px;") - layout.addWidget(step6_glint_hint) + self.glint_mask_file.label.setMinimumWidth(100) - # 参数设置 - params_group = QGroupBox("提取参数") + input_layout.addWidget(self.deglint_img_file) + input_layout.addWidget(self.csv_file) + input_layout.addWidget(self.water_mask_file) + input_layout.addWidget(self.glint_mask_file) + + input_group.setLayout(input_layout) + main_layout.addWidget(input_group) + + # ========================================== + # 卡片 2:提取参数 + # ========================================== + params_group = QGroupBox("⚙️ 提取参数") params_layout = QFormLayout() + params_layout.setSpacing(16) + params_layout.setContentsMargins(20, 24, 20, 20) self.radius = QSpinBox() self.radius.setRange(1, 50) self.radius.setValue(5) - params_layout.addRow("采样半径(像素):", self.radius) + self.radius.setSuffix(" px") + self.radius.setButtonSymbols(QSpinBox.NoButtons) + self._add_row_with_fixed_label(params_layout, "采样半径:", self.radius) self.source_epsg = QSpinBox() self.source_epsg.setRange(1000, 99999) self.source_epsg.setValue(4326) - params_layout.addRow("源坐标系EPSG:", self.source_epsg) + self.source_epsg.setButtonSymbols(QSpinBox.NoButtons) + self._add_row_with_fixed_label(params_layout, "源坐标系 EPSG:", self.source_epsg) params_group.setLayout(params_layout) - layout.addWidget(params_group) + main_layout.addWidget(params_group) + + # ========================================== + # 卡片 3:输出与执行 + # ========================================== + output_group = QGroupBox("🚀 输出与执行") + output_layout = QVBoxLayout() + output_layout.setSpacing(16) + output_layout.setContentsMargins(20, 24, 20, 20) - # 输出文件路径 self.output_file = FileSelectWidget( - "输出训练数据:", - "CSV Files (*.csv);;All Files (*.*)" + "结果保存至:", + "CSV Files (*.csv);;All Files (*.*)", + mode="save" ) + self.output_file.label.setMinimumWidth(100) self.output_file.line_edit.setPlaceholderText("training_spectra.csv") - layout.addWidget(self.output_file) + output_layout.addWidget(self.output_file) - # 启用步骤 - self.enable_checkbox = QCheckBox("启用此步骤") - self.enable_checkbox.setChecked(True) - layout.addWidget(self.enable_checkbox) + action_layout = QHBoxLayout() + action_layout.addStretch() - # 独立运行按钮 - self.run_btn = QPushButton("独立运行此步骤") - self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) + self.run_btn = QPushButton("独立运行步骤") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary')) + self.run_btn.setMinimumWidth(140) self.run_btn.clicked.connect(self._on_run_single_clicked) - layout.addWidget(self.run_btn) + action_layout.addWidget(self.run_btn) - layout.addStretch() - self.setLayout(layout) - # 信号连接:影像文件路径变化时动态更新波段范围 + output_layout.addLayout(action_layout) + output_group.setLayout(output_layout) + main_layout.addWidget(output_group) + + main_layout.addStretch() + self.setLayout(main_layout) + + def _add_row_with_fixed_label(self, form_layout, label_text, widget): + """辅助方法:创建绝对锁死宽度的对齐行""" + lbl = QLabel(label_text) + lbl.setMinimumWidth(100) + + row_layout = QHBoxLayout() + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.addWidget(lbl) + + if hasattr(widget, 'setSizePolicy'): + widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + row_layout.addWidget(widget) + form_layout.addRow(row_layout) def get_config(self): """获取配置""" @@ -119,21 +176,22 @@ class Step6FeaturePanel(QWidget): 'radius': self.radius.value(), 'source_epsg': self.source_epsg.value(), } - # 添加独立运行所需的文件路径 deglint_img_path = self.deglint_img_file.get_path() if deglint_img_path: config['deglint_img_path'] = deglint_img_path + csv_path = self.csv_file.get_path() if csv_path: config['csv_path'] = csv_path + water_mask_path = self.water_mask_file.get_path() if water_mask_path: config['boundary_path'] = water_mask_path + glint_mask_path = self.glint_mask_file.get_path() if glint_mask_path: config['glint_mask_path'] = glint_mask_path - # 注意:step6_extract_spectra 不接受 output_path / training_csv_path - # 参数,输出路径由 pipeline 内部根据 training_spectra_dir 自动生成。 + return config def set_config(self, config): @@ -152,13 +210,7 @@ class Step6FeaturePanel(QWidget): self.glint_mask_file.set_path(config['glint_mask_path']) def update_from_config(self, work_dir=None, pipeline=None): - """从全局配置/Pipeline 或 Step1Panel 自动填充路径,实现上下游数据流转 - - Args: - work_dir: 工作目录路径 - pipeline: Pipeline 实例,用于获取步骤1生成的水域掩膜路径 - """ - # 保存工作目录引用 + """从全局配置/Pipeline 自动填充路径,实现上下游数据流转""" if work_dir: self.work_dir = work_dir elif hasattr(self, 'work_dir') and self.work_dir: @@ -166,55 +218,46 @@ class Step6FeaturePanel(QWidget): else: self.work_dir = None - # 1. 尝试从 Pipeline 获取水体掩膜路径 mask_path = None if pipeline and hasattr(pipeline, 'water_mask_path') and pipeline.water_mask_path: mask_path = pipeline.water_mask_path - # 2. 如果 Pipeline 中没有,则尝试直接从 Step1 界面读取 main_window = self.window() if not mask_path and hasattr(main_window, 'step1_panel'): if main_window.step1_panel.use_ndwi_radio.isChecked(): mask_path = main_window.step1_panel.output_file.get_path() else: mask_path = main_window.step1_panel.mask_file.get_path() - # 若为相对路径,使用 work_dir 合成为绝对路径 if mask_path and not os.path.isabs(mask_path): mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') - # 填充水体掩膜路径 if mask_path: - # 若为相对路径,使用 work_dir 合成为绝对路径 if not os.path.isabs(mask_path): mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') self.water_mask_file.set_path(mask_path) - # 3. 尝试从 Step2 界面读取耀斑掩膜路径 - main_window = self.window() if hasattr(main_window, 'step2_panel'): glint_path = main_window.step2_panel.output_file.get_path() if glint_path: - # 若为相对路径,使用 work_dir 合成为绝对路径 if not os.path.isabs(glint_path): glint_path = os.path.join(self.work_dir or '', glint_path).replace('\\', '/') self.glint_mask_file.set_path(glint_path) - # 4. 自动填充去耀斑影像路径(上游 Step 3 的输出,修复张冠李戴) deglint_path = None if pipeline and hasattr(pipeline, 'step_outputs'): step3_outputs = getattr(pipeline, 'step_outputs', {}).get('step3', {}) deglint_path = ( - step3_outputs.get('deglint_image') - or step3_outputs.get('output_path') - or step3_outputs.get('output_file') - or step3_outputs.get('deglint_img_path') + step3_outputs.get('deglint_image') + or step3_outputs.get('output_path') + or step3_outputs.get('output_file') + or step3_outputs.get('deglint_img_path') ) - # 回退:从 step3 面板 widget 直接读取(可能是相对路径) + if not deglint_path and hasattr(main_window, 'step3_panel'): step3_widget = getattr(main_window.step3_panel, 'output_file', None) if step3_widget is not None and hasattr(step3_widget, 'get_path'): deglint_path = step3_widget.get_path() or "" - # 兜底:扫描 3_deglint 目录下的 .bsq(优先 goodman,兼容 kutser/插值) + if not deglint_path and self.work_dir: deglint_dir = resolve_subdir(self.work_dir, 'deglint') if os.path.isdir(deglint_dir): @@ -222,20 +265,17 @@ class Step6FeaturePanel(QWidget): f for f in os.listdir(deglint_dir) if f.lower().endswith('.bsq') ] - # 优先匹配 goodman(默认算法),其次按文件名排序保证稳定 bsq_files.sort(key=lambda n: (0 if 'goodman' in n.lower() else 1, n)) if bsq_files: deglint_path = os.path.join(deglint_dir, bsq_files[0]).replace('\\', '/') if deglint_path: - # 若为相对路径,使用 work_dir 合成为绝对路径 if not os.path.isabs(deglint_path): deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/') existing_deglint = self.deglint_img_file.get_path() if not existing_deglint or not existing_deglint.strip(): self.deglint_img_file.set_path(deglint_path) - # 5. 自动填充输出路径(基于工作目录) if self.work_dir: output_dir = resolve_subdir(self.work_dir, 'spectral_feature') os.makedirs(output_dir, exist_ok=True) @@ -244,12 +284,9 @@ class Step6FeaturePanel(QWidget): else: self.output_file.set_path("") - # 6. 尝试从 Step5 Clean 界面读取已处理的清洗后 CSV 路径,自动填入本面板 - main_window = self.window() if main_window and hasattr(main_window, 'step5_clean_panel'): step5_clean_output_path = main_window.step5_clean_panel.output_file.get_path() if step5_clean_output_path: - # 若为相对路径,使用 work_dir 合成为绝对路径 if not os.path.isabs(step5_clean_output_path): step5_clean_output_path = os.path.join( self.work_dir or '', step5_clean_output_path @@ -259,7 +296,6 @@ class Step6FeaturePanel(QWidget): self.csv_file.set_path(step5_clean_output_path) def _on_run_single_clicked(self): - """通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。""" from src.gui.core.event_bus import global_event_bus deglint_img_path = self.deglint_img_file.get_path() @@ -268,7 +304,7 @@ class Step6FeaturePanel(QWidget): QMessageBox.warning(self, "输入错误", "请选择去耀斑影像文件!") return if not csv_path: - QMessageBox.warning(self, "输入错误", "请选择处理后的CSV文件!") + QMessageBox.warning(self, "输入错误", "请选择处理后的 CSV 文件!") return if not self.glint_mask_file.get_path(): QMessageBox.warning( @@ -283,30 +319,4 @@ class Step6FeaturePanel(QWidget): global_event_bus.publish('RequestRunSingleStep', { 'step_name': 'step6_feature', 'config': config, - }) - - def run_step(self): - """独立运行步骤6(旧版 parent 链上溯方式,保留兼容)。""" - # 验证输入 - deglint_img_path = self.deglint_img_file.get_path() - csv_path = self.csv_file.get_path() - if not deglint_img_path: - QMessageBox.warning(self, "输入错误", "请选择去耀斑影像文件!") - return - if not csv_path: - QMessageBox.warning(self, "输入错误", "请选择处理后的CSV文件!") - return - if not self.glint_mask_file.get_path(): - QMessageBox.warning( - self, - "输入错误", - "独立运行光谱特征提取时,必须选择耀斑掩膜文件。\n\n" - "请提供与去耀斑影像对应的耀斑二值掩膜(一般为步骤2输出的 severe_glint_area.dat)。", - ) - return - - # 获取主窗口并运行步骤 - main_window = self.window() - if hasattr(main_window, 'run_single_step'): - config = {'step6_feature': self.get_config()} - main_window.run_single_step('step6_feature', config) + }) \ No newline at end of file