#!/usr/bin/env python # -*- coding: utf-8 -*- """ Step6 面板 - 光谱特征提取 (极致强迫症绝对左对齐版) """ import os from pathlib import Path from src.gui.panels._step_path_resolver import resolve_subdir, scan_work_dir_for_input from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QLabel, QSpinBox, QPushButton, QMessageBox, QSizePolicy ) from PyQt5.QtGui import QFont from PyQt5.QtCore import Qt from src.gui.components.custom_widgets import FileSelectWidget 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): # 1. 注入全局样式系统 self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) # 主布局:增加四周留白(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 (*.*)" ) # FileSelectWidget 内部已经处理了左侧宽度,我们这里强制设为120对齐 self.deglint_img_file.label.setMinimumWidth(120) self.csv_file = FileSelectWidget( "处理后CSV:", "CSV Files (*.csv);;All Files (*.*)" ) self.csv_file.label.setMinimumWidth(120) self.water_mask_file = FileSelectWidget( "水体掩膜:", "Mask Files (*.dat *.tif);;All Files (*.*)" ) self.water_mask_file.label.setMinimumWidth(120) self.water_mask_file.line_edit.setPlaceholderText("可选,如不选择则自动生成") self.glint_mask_file = FileSelectWidget( "耀斑掩膜:", "Mask Files (*.dat *.tif);;All Files (*.*)" ) self.glint_mask_file.label.setMinimumWidth(120) 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 = QVBoxLayout() # 废除 QFormLayout,使用可控的垂直布局 params_layout.setSpacing(16) params_layout.setContentsMargins(20, 24, 20, 20) self.radius = QSpinBox() self.radius.setRange(1, 50) self.radius.setValue(5) self.radius.setButtonSymbols(QSpinBox.NoButtons) self.radius.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) # 强制数字靠左 self._add_aligned_row(params_layout, "采样半径:", self.radius, "px") self.source_epsg = QSpinBox() self.source_epsg.setRange(1000, 99999) self.source_epsg.setValue(4326) self.source_epsg.setButtonSymbols(QSpinBox.NoButtons) self.source_epsg.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) # 强制数字靠左 self._add_aligned_row(params_layout, "源坐标系 EPSG:", self.source_epsg, "") params_group.setLayout(params_layout) 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 (*.*)", mode="save" ) self.output_file.label.setMinimumWidth(120) # 统一 120px 对齐 self.output_file.line_edit.setPlaceholderText("training_spectra.csv") output_layout.addWidget(self.output_file) action_layout = QHBoxLayout() action_layout.addStretch() 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) action_layout.addWidget(self.run_btn) output_layout.addLayout(action_layout) output_group.setLayout(output_layout) main_layout.addWidget(output_group) main_layout.addStretch() self.setLayout(main_layout) def _add_aligned_row(self, parent_layout, label_text, widget, suffix_text): """核心对齐函数:隐形占位符实现绝对边缘对齐,且标签绝对靠左""" row_layout = QHBoxLayout() row_layout.setContentsMargins(0, 0, 0, 0) row_layout.setSpacing(10) # 1. 统一左侧标签宽度为 120px,并强制内容靠左对齐 lbl = QLabel(label_text) lbl.setMinimumWidth(120) lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) # 强制靠左! row_layout.addWidget(lbl) # 2. 允许控件水平拉伸,填充满宽 widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) widget.setStyleSheet("min-height: 28px;") row_layout.addWidget(widget) # 3. 隐形占位符:强制挤压 40px 的空间,让右侧边界绝对整齐 suffix_lbl = QLabel(suffix_text) suffix_lbl.setFixedWidth(40) row_layout.addWidget(suffix_lbl) parent_layout.addLayout(row_layout) def get_config(self): """获取配置""" config = { '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 return config def set_config(self, config): """设置配置""" if 'radius' in config: self.radius.setValue(config['radius']) if 'source_epsg' in config: self.source_epsg.setValue(config['source_epsg']) if 'deglint_img_path' in config: self.deglint_img_file.set_path(config['deglint_img_path']) if 'csv_path' in config: self.csv_file.set_path(config['csv_path']) if 'boundary_path' in config: self.water_mask_file.set_path(config['boundary_path']) if 'glint_mask_path' in config: self.glint_mask_file.set_path(config['glint_mask_path']) def update_from_config(self, work_dir=None, pipeline=None): """从全局配置/Pipeline 自动填充路径,实现上下游数据流转""" if work_dir: self.work_dir = work_dir elif hasattr(self, 'work_dir') and self.work_dir: pass 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 if not mask_path and self.work_dir: mask_path = scan_work_dir_for_input(self.work_dir, 'water_mask') if mask_path and os.path.exists(str(mask_path)): self.water_mask_file.set_path(str(mask_path)) # 2. 耀斑掩膜:文件系统扫描 if self.work_dir: glint_path = scan_work_dir_for_input(self.work_dir, 'glint_mask') if glint_path and os.path.exists(str(glint_path)): self.glint_mask_file.set_path(str(glint_path)) # 3. 去耀斑影像:优先 pipeline.step_outputs,回退文件系统扫描 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') ) if not deglint_path and self.work_dir: deglint_path = scan_work_dir_for_input(self.work_dir, 'deglint_image') if deglint_path and os.path.exists(str(deglint_path)): existing_deglint = self.deglint_img_file.get_path() if (not existing_deglint or not existing_deglint.strip()): self.deglint_img_file.set_path(str(deglint_path)) # 4. 处理后 CSV:文件系统扫描 if self.work_dir: csv_path = scan_work_dir_for_input(self.work_dir, 'processed_data') if csv_path and os.path.exists(str(csv_path)): existing_csv = self.csv_file.get_path() if (not existing_csv or not existing_csv.strip()): self.csv_file.set_path(str(csv_path)) # 5. 输出路径 if self.work_dir and not self.output_file.get_path(): output_dir = resolve_subdir(self.work_dir, 'spectral_feature') default_output_path = os.path.join(output_dir, "training_spectra.csv").replace('\\', '/') self.output_file.set_path(default_output_path) elif not self.work_dir: self.output_file.set_path("") def _on_run_single_clicked(self): from src.gui.core.event_bus import global_event_bus 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 config = {'step6_feature': self.get_config()} global_event_bus.publish('RequestRunSingleStep', { 'step_name': 'step6_feature', 'config': config, })