#!/usr/bin/env python # -*- coding: utf-8 -*- """ Step8 面板 - QAA 物理反演(非经验模型) """ import os import sys # 路径归一化 helper(与 pipeline.get_step_output_dir 互为表里) _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) from _step_path_resolver import resolve_subdir from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QComboBox, QCheckBox, QPushButton, QFileDialog, QMessageBox, ) from PyQt5.QtGui import QFont from PyQt5.QtCore import Qt from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet from src.utils.water_owt_config import ( get_all_lake_names, get_lake_config, get_lambda_0, get_default_lake, ) from src.core.algorithms.qaa import QAABaselineSolver class Step8QAAPanel(QWidget): """步骤8:QAA 物理反演(非经验模型)""" def __init__(self, parent=None): super().__init__(parent) self.init_ui() def init_ui(self): layout = QVBoxLayout() title = QLabel("步骤8:QAA 物理反演(非经验模型)") title.setFont(QFont("Arial", 12, QFont.Bold)) layout.addWidget(title) # 光谱 CSV 文件输入 self.spectrum_csv_file = FileSelectWidget( "光谱 CSV 文件:", "CSV Files (*.csv);;All Files (*.*)" ) self.spectrum_csv_file.line_edit.setPlaceholderText( "选择实测光谱或采样点光谱 CSV(含波长列)" ) layout.addWidget(self.spectrum_csv_file) # 水域类型选择 lake_group = QGroupBox("水域类型配置") lake_layout = QFormLayout() self.lake_combo = QComboBox() lake_names = get_all_lake_names() self.lake_combo.addItems(lake_names) default_lake = get_default_lake() if default_lake in lake_names: self.lake_combo.setCurrentText(default_lake) self.lake_combo.currentTextChanged.connect(self._on_lake_changed) lake_layout.addRow("水域选择:", self.lake_combo) # 参考波长显示 self.lambda_0_label = QLabel() self.lambda_0_label.setStyleSheet( f"color: {ModernStylesheet.COLORS['accent']}; " f"font-weight: bold;" ) lake_layout.addRow("参考波长 λ₀:", self.lambda_0_label) # 算法提示 self.hint_label = QLabel() self.hint_label.setWordWrap(True) self.hint_label.setStyleSheet( f"color: {ModernStylesheet.COLORS['text_secondary']}; " "font-size: 12px;" ) lake_layout.addRow("算法提示:", self.hint_label) lake_group.setLayout(lake_layout) layout.addWidget(lake_group) # 输出路径 self.output_path = FileSelectWidget( "输出文件:", "CSV Files (*.csv);;All Files (*.*)", mode="save" ) self.output_path.line_edit.setPlaceholderText( "自动生成到 8_QAA_Inversion,或手动指定..." ) self.output_path.browse_btn.clicked.disconnect() self.output_path.browse_btn.clicked.connect(self.browse_output_path) layout.addWidget(self.output_path) # 启用步骤 self.enable_checkbox = QCheckBox("启用此步骤") self.enable_checkbox.setChecked(False) layout.addWidget(self.enable_checkbox) # 独立运行按钮 self.run_btn = QPushButton("执行 QAA 反演") self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) self.run_btn.clicked.connect(self._on_run_single_clicked) layout.addWidget(self.run_btn) layout.addStretch() self.setLayout(layout) self._on_lake_changed(self.lake_combo.currentText()) def _on_lake_changed(self, lake_name: str): """当用户切换水域时更新显示""" cfg = get_lake_config(lake_name) if cfg: self.lambda_0_label.setText( f"{cfg['lambda_0']} nm({cfg['qaa_version']})" ) self.hint_label.setText(cfg.get('notes', '')) else: self.lambda_0_label.setText("—") self.hint_label.setText("") def _get_default_work_dir(self) -> str: """获取 work_dir,优先用 panel 自身缓存的,否则尝试从主窗口取""" if hasattr(self, 'work_dir') and self.work_dir: return str(self.work_dir) mw = self.window() if mw and hasattr(mw, 'work_dir') and mw.work_dir: return str(mw.work_dir) return "" def browse_output_path(self): """浏览输出文件路径""" current = self.output_path.get_path().strip() if current: initial_dir = os.path.dirname(current) initial_file = os.path.basename(current) else: initial_dir = "" initial_file = "" if not initial_dir or not os.path.isdir(initial_dir): work_dir = self._get_default_work_dir() initial_dir = resolve_subdir(work_dir, 'qaa_inversion') if work_dir else "" if initial_dir and not os.path.isdir(initial_dir): os.makedirs(initial_dir, exist_ok=True) file_path, _ = QFileDialog.getSaveFileName( self, "保存输出文件", os.path.join(initial_dir, initial_file) if initial_file else initial_dir, "CSV Files (*.csv);;All Files (*.*)" ) if file_path: self.output_path.set_path(file_path) def get_config(self) -> dict: """获取面板配置""" config = { 'lake_name': self.lake_combo.currentText(), 'lambda_0': get_lambda_0(self.lake_combo.currentText()), 'spectrum_csv_path': self.spectrum_csv_file.get_path(), } output_path = self.output_path.get_path() if output_path: config['output_path'] = output_path return config def set_config(self, config: dict): """设置面板配置""" if 'lake_name' in config: lake_name = config['lake_name'] idx = self.lake_combo.findText(lake_name) if idx >= 0: self.lake_combo.setCurrentIndex(idx) if 'spectrum_csv_path' in config: self.spectrum_csv_file.set_path(config['spectrum_csv_path']) if 'output_path' in config: self.output_path.set_path(config['output_path']) def update_from_config(self, work_dir=None, pipeline=None): """从全局配置自动填充训练数据和输出路径""" if work_dir: self.work_dir = work_dir elif hasattr(self, 'work_dir') and self.work_dir: pass else: self.work_dir = None main_window = self.window() if main_window and hasattr(main_window, 'step5_panel'): step5_output = main_window.step5_panel.output_file.get_path() if step5_output: if not os.path.isabs(step5_output): step5_output = os.path.join(self.work_dir or '', step5_output).replace('\\', '/') self.spectrum_csv_file.set_path(step5_output) if self.work_dir: qaa_dir = resolve_subdir(self.work_dir, 'qaa_inversion') os.makedirs(qaa_dir, exist_ok=True) output_path = os.path.join(qaa_dir, "a_lambda_results.csv").replace('\\', '/') self.output_path.set_path(output_path) else: self.output_path.set_path("") def _on_run_single_clicked(self): """通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。""" from src.gui.core.event_bus import global_event_bus spectrum_path = self.spectrum_csv_file.get_path() if not spectrum_path: QMessageBox.warning(self, "输入错误", "请选择光谱 CSV 文件!") return config = {'step8_qaa': self.get_config()} global_event_bus.publish('RequestRunSingleStep', { 'step_name': 'step8_qaa', 'config': config, }) def run_step(self): """独立运行 QAA 反演(旧版 parent 链上溯方式,保留兼容)。""" spectrum_path = self.spectrum_csv_file.get_path() if not spectrum_path: QMessageBox.warning(self, "输入错误", "请选择光谱 CSV 文件!") return main_window = self.window() if hasattr(main_window, 'run_single_step'): config = {'step8_qaa': self.get_config()} main_window.run_single_step('step8_qaa', config) else: self._run_qaa_direct() def _run_qaa_direct(self): """直接执行 QAA 反演(不依赖主窗口流水线)""" spectrum_path = self.spectrum_csv_file.get_path() output_path = self.output_path.get_path() lake_name = self.lake_combo.currentText() lambda_0 = get_lambda_0(lake_name) if not output_path: work_dir = self._get_default_work_dir() qaa_dir = resolve_subdir(work_dir, 'qaa_inversion') if work_dir else "" if qaa_dir and not os.path.isdir(qaa_dir): os.makedirs(qaa_dir, exist_ok=True) output_path = os.path.join(qaa_dir, "a_lambda_results.csv").replace('\\', '/') try: import numpy as np import pandas as pd df = pd.read_csv(spectrum_path, encoding="utf-8-sig") col_names = df.columns.tolist() wavelength_col_idx = None for i, col in enumerate(col_names): try: float(col) wavelength_col_idx = i break except (ValueError, TypeError): pass if wavelength_col_idx is None: QMessageBox.warning( self, "解析错误", "无法从 CSV 列名中识别波长信息,请确保列名包含数值型波长(nm)" ) return wavelengths = np.array( [float(c) for c in col_names[wavelength_col_idx:]], dtype=np.float64 ) data_matrix = df.iloc[:, wavelength_col_idx:].values.astype(np.float64) if data_matrix.ndim == 1: data_matrix = data_matrix[np.newaxis, :] solver = QAABaselineSolver() raw_result = solver.run_inversion(wavelengths, data_matrix, lambda_0) # run_inversion 返回:单样本 → dict,多样本 → list[dict] if isinstance(raw_result, list): sample_results = raw_result aw_0 = raw_result[0].get('aw_0', 0) bbw_0 = raw_result[0].get('bbw_0', 0) else: sample_results = [raw_result] aw_0 = raw_result.get('aw_0', 0) bbw_0 = raw_result.get('bbw_0', 0) rows_out = [] for i, sample_result in enumerate(sample_results): wl_arr = wavelengths a_arr = sample_result['a_lambda'] bb_arr = sample_result['bb_lambda'] for j, wl in enumerate(wl_arr): rows_out.append({ 'sample_id': f"sample_{i}", 'Wavelength': wl, 'a_lambda': a_arr[j], 'bb_lambda': bb_arr[j], }) result_df = pd.DataFrame(rows_out) os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) result_df.to_csv(output_path, index=False, float_format='%.8f') QMessageBox.information( self, "执行成功", f"QAA 反演完成!\n" f"水域: {lake_name}\n" f"参考波长 λ₀: {lambda_0} nm\n" f"λ₀ 处 aw: {aw_0:.6f} m⁻¹\n" f"λ₀ 处 bbw: {bbw_0:.6f} m⁻¹\n" f"结果已保存到:\n{output_path}" ) except Exception as e: QMessageBox.critical(self, "执行错误", f"QAA 反演失败:\n{str(e)}") def get_training_params(self) -> dict: """获取反演参数""" return { 'pipeline_type': 'qaa_non_empirical', 'lake_name': self.lake_combo.currentText(), 'lambda_0': get_lambda_0(self.lake_combo.currentText()), }