#!/usr/bin/env python # -*- coding: utf-8 -*- """ Step7 视图 - 水质光谱指数计算 (完美对齐卡片化重构版) """ import os import sys import pandas as pd from pathlib import Path from PyQt5.QtWidgets import ( QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QLabel, QPushButton, QMessageBox, QListWidget, QListWidgetItem, QSizePolicy, QWidget ) from PyQt5.QtCore import Qt from src.gui.core.event_bus import global_event_bus from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet class Step7InversionPanel(QWidget): """步骤7:水质光谱指数计算""" # --- 保持原有的类级别映射表不变 --- _formula_type_map = { 'NDWI': 'ratio', 'MNDWI': 'ratio', 'AWEI': 'ratio', 'AWEInsh': 'ratio', 'NDTI': 'ratio', 'WRI': 'ratio', 'NDSS': 'ratio', 'NDSSI': 'ratio', 'NDVI': 'ratio', 'EVI': 'ratio', 'SABI': 'ratio', 'WAVI': 'ratio', 'FAI': 'ratio', 'FAI2': 'ratio', 'FAI3': 'ratio', 'FAI4': 'ratio', 'KIV': 'ratio', 'MCI': 'ratio', 'NDBI': 'ratio', 'CYA': 'ratio', 'Chla_3B': 'concentration', 'Chla_2B': 'concentration', 'Chla_2B_sim': 'concentration', 'Chla_4B': 'concentration', 'Chla_NDCI': 'concentration', 'Chla_NDI': 'concentration', 'Chla_Peak': 'concentration', 'Chla_SABG': 'concentration', 'Chla_MCI': 'concentration', 'Chla_FAI': 'concentration', 'TP_1': 'concentration', 'TP_2': 'concentration', 'TP_3': 'concentration', 'TP_4': 'concentration', 'TP_5': 'concentration', 'TP_6': 'concentration', 'TN_1': 'concentration', 'TN_2': 'concentration', 'TN_3': 'concentration', 'TN_4': 'concentration', 'NH3_1': 'concentration', 'NH3_2': 'concentration', 'NH3_3': 'concentration', 'NH3_4': 'concentration', 'COD_1': 'concentration', 'COD_2': 'concentration', 'COD_3': 'concentration', 'COD_4': 'concentration' } _formula_color_map = { 'ratio': ModernStylesheet.COLORS['panel_bg'], # 比值型用白色底 'concentration': ModernStylesheet.COLORS['primary_light'] # 浓度型用浅蓝底 } _formula_coef_map = { 'Chla_3B': [25.0456, 116.8924], 'Chla_2B': [5.8753, 56.4025], 'Chla_2B_sim': [38.16, 21.36], 'Chla_4B': [27.79, 137.9], 'Chla_NDCI': [14.039, 86.115, 194.325], 'Chla_NDI': [1.321, 23.94], 'Chla_Peak': [3.614, 5.097], 'Chla_SABG': [10.2, 5.6], 'Chla_MCI': [0.5, 0.2], 'Chla_FAI': [0.8, 0.1], 'TP_1': [0.1, 0.05], 'TP_2': [0.2, 0.1], 'TP_3': [0.15, 0.08], 'TP_4': [0.12, 0.06], 'TP_5': [0.18, 0.09], 'TP_6': [0.25, 0.12], 'TN_1': [0.5, 0.2], 'TN_2': [0.6, 0.3], 'TN_3': [0.45, 0.15], 'TN_4': [0.55, 0.25], 'NH3_1': [0.05, 0.02], 'NH3_2': [0.06, 0.03], 'NH3_3': [0.04, 0.015], 'NH3_4': [0.07, 0.035], 'COD_1': [10.0, 5.0], 'COD_2': [12.0, 6.0], 'COD_3': [8.5, 4.0], 'COD_4': [11.0, 5.5], } def __init__(self, parent=None): self._formula_df = None super().__init__(parent) self.init_ui() def init_ui(self): # 注入全局样式 self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) # 主布局:增加四周留白(24px)和呼吸间距(20px) main_layout = QVBoxLayout(self) 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) step7_hint = QLabel("💡 提示: 本步骤将根据内置公式库,为训练集样本自动计算常见的水质光谱指数。") step7_hint.setWordWrap(True) step7_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(step7_hint) self.formula_file = FileSelectWidget( "内置公式库源:", "CSV Files (*.csv);;All Files (*.*)" ) self.formula_file.line_edit.setReadOnly(True) self.formula_file.label.setMinimumWidth(120) # 智能查找自带的公式库文件(兼容开发 + PyInstaller 打包) builtin_csv = self._resolve_builtin_csv() if builtin_csv: self.formula_file.set_path(builtin_csv) self.training_data_widget = FileSelectWidget( "输入样本数据:", "CSV Files (*.csv);;All Files (*.*)" ) self.training_data_widget.label.setMinimumWidth(120) input_layout.addWidget(self.formula_file) input_layout.addWidget(self.training_data_widget) input_group.setLayout(input_layout) main_layout.addWidget(input_group) # ========================================== # 卡片 2:指数计算配置 # ========================================== params_group = QGroupBox("⚙️ 指数计算配置") params_layout = QVBoxLayout() params_layout.setSpacing(16) params_layout.setContentsMargins(20, 24, 20, 20) # 辅助布局:公式选择列表 list_row = QHBoxLayout() list_row.setContentsMargins(0, 0, 0, 0) list_label = QLabel("待计算的水质指数:") list_label.setMinimumWidth(120) list_label.setAlignment(Qt.AlignTop | Qt.AlignLeft) right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(8) # 按钮工具栏 btn_layout = QHBoxLayout() btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(10) # 👉 [新增] 增加按钮之间的水平间距,不再挤在一起 self.btn_select_all = QPushButton("全选") self.btn_select_none = QPushButton("清空") self.btn_select_ratio = QPushButton("仅比值型") self.btn_select_conc = QPushButton("仅浓度型") self.btn_reload = QPushButton("重新加载库") for btn in [self.btn_select_all, self.btn_select_none, self.btn_select_ratio, self.btn_select_conc, self.btn_reload]: base_style = ModernStylesheet.get_button_stylesheet('normal') # 👉 [修改] 追加左右内边距 (padding-left/right),保证无论文字长短都不会被截断 btn.setStyleSheet(base_style + "\nQPushButton { padding-left: 14px; padding-right: 14px; }") # 👉 [修改] 高度从 28 稍微调高到 30,让中文字体上下有更好的呼吸感 btn.setFixedHeight(30) # 👉 [新增] 设置尺寸策略:高度固定,宽度根据文字内容自动撑开 btn.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) btn_layout.addWidget(btn) btn_layout.addStretch() right_layout.addLayout(btn_layout) # 列表框 (去掉 setSelectionMode,改为默认行为) self.formula_list = QListWidget() self.formula_list.setMinimumHeight(150) # 用样式表去掉默认的边框和丑陋的焦点框,增加 hover 和复选框大小调整 self.formula_list.setStyleSheet(f""" QListWidget {{ border: 1px solid {ModernStylesheet.COLORS['border']}; border-radius: {ModernStylesheet.VARS['radius_md']}; background-color: {ModernStylesheet.COLORS['panel_bg']}; outline: none; }} QListWidget::item {{ padding: 4px 8px; /* 稍微减小上下间距,给复选框留空间 */ border-bottom: 1px solid {ModernStylesheet.COLORS['border_light']}; }} QListWidget::item:hover {{ background-color: {ModernStylesheet.COLORS['hover']}; }} /* 放大复选框并拉开间距 */ QListWidget::indicator {{ width: 16px; height: 16px; margin-right: 6px; }} """) right_layout.addWidget(self.formula_list) list_row.addWidget(list_label) list_row.addWidget(right_panel) params_layout.addLayout(list_row) 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) # 这里不需要输出文件选择框,因为服务层会直接覆盖原 CSV,但保留执行按钮 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.btn_select_all.clicked.connect(self._select_all_formulas) self.btn_select_none.clicked.connect(self._clear_formula_selection) self.btn_select_ratio.clicked.connect(self._select_ratio_formulas) self.btn_select_conc.clicked.connect(self._select_conc_formulas) self.btn_reload.clicked.connect(self._load_formulas_from_csv) # 初始加载 self._load_formulas_from_csv() def _resolve_builtin_csv(self): """探测内置 waterindex.csv 的实际位置。 按优先级探测 4 个候选路径(兼容开发 + PyInstaller 打包): 1. sys._MEIPASS/_internal/model/ (PyInstaller onedir 带 _MEIPASS) 2. sys._MEIPASS/model/ (PyInstaller onefile 或 onedir) 3. /../model/ (开发模式真实位置:src/gui/model/) 4. /../../model/ (项目根 model/,兼容旧布局) """ panel_dir = os.path.dirname(os.path.abspath(__file__)) candidates = [ os.path.join(panel_dir, '..', 'model', 'waterindex.csv'), os.path.join(panel_dir, '..', '..', 'model', 'waterindex.csv'), ] if hasattr(sys, '_MEIPASS'): meipass = sys._MEIPASS candidates.insert(0, os.path.join(meipass, '_internal', 'model', 'waterindex.csv')) candidates.insert(0, os.path.join(meipass, 'model', 'waterindex.csv')) for p in candidates: norm = os.path.normpath(p).replace('\\', '/') if os.path.exists(norm): return norm return None def get_config(self) -> dict: """收集当前界面的配置字典""" # 只筛选出打勾状态为 Checked 的项 selected_names = [ self.formula_list.item(i).text() for i in range(self.formula_list.count()) if self.formula_list.item(i).checkState() == Qt.Checked ] config = { 'training_csv_path': self.training_data_widget.get_path(), 'formula_csv_file': self.formula_file.get_path(), 'formula_names': selected_names, 'enabled': True # 默认启用 } return config def set_config(self, config: dict): """根据传入字典恢复界面状态""" if 'training_csv_path' in config: self.training_data_widget.set_path(config['training_csv_path']) if 'formula_csv_file' in config: self.formula_file.set_path(config['formula_csv_file']) # 恢复勾选状态 if 'formula_names' in config: target_names = set(config['formula_names']) self.formula_list.blockSignals(True) for i in range(self.formula_list.count()): item = self.formula_list.item(i) state = Qt.Checked if item.text() in target_names else Qt.Unchecked item.setCheckState(state) self.formula_list.blockSignals(False) def _load_formulas_from_csv(self): """解析公式 CSV 文件并填充列表框""" csv_path = self.formula_file.get_path() if not csv_path or not os.path.exists(csv_path): QMessageBox.warning(self, "警告", "未找到内置的水质指数公式文件!") return try: df = pd.read_csv(csv_path, encoding='utf-8') self._formula_df = df self.formula_list.blockSignals(True) self.formula_list.clear() target_col = None for col in ['Index_Name', 'Formula_Name']: if col in df.columns: target_col = col break if not target_col: QMessageBox.warning(self, "错误", f"CSV 文件格式不正确,列名应为 'Index_Name' 或 'Formula_Name'。当前列为: {list(df.columns)}") self.formula_list.blockSignals(False) return for idx_name in df[target_col]: item = QListWidgetItem(idx_name) # 开启复选框功能,并设置为默认选中 item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(Qt.Checked) ftype = self._formula_type_map.get(idx_name, 'ratio') # 简化底色判断,保留类型的色彩提示 bg_color = Qt.transparent if ftype == 'ratio' else Qt.GlobalColor.cyan item.setBackground(bg_color) self.formula_list.addItem(item) self.formula_list.blockSignals(False) except Exception as e: QMessageBox.critical(self, "加载错误", f"加载公式文件时发生错误:\n{str(e)}") def _select_all_formulas(self): for i in range(self.formula_list.count()): self.formula_list.item(i).setCheckState(Qt.Checked) def _clear_formula_selection(self): for i in range(self.formula_list.count()): self.formula_list.item(i).setCheckState(Qt.Unchecked) def _select_ratio_formulas(self): for i in range(self.formula_list.count()): item = self.formula_list.item(i) ftype = self._formula_type_map.get(item.text(), 'ratio') item.setCheckState(Qt.Checked if ftype == 'ratio' else Qt.Unchecked) def _select_conc_formulas(self): for i in range(self.formula_list.count()): item = self.formula_list.item(i) ftype = self._formula_type_map.get(item.text(), 'ratio') item.setCheckState(Qt.Checked if ftype == 'concentration' else Qt.Unchecked) def _on_run_single_clicked(self): """点击独立运行按钮:包装参数并通过 EventBus 派发任务""" training_path = self.training_data_widget.get_path() if not training_path: QMessageBox.warning(self, "输入错误", "请先选择输入的样本数据 (CSV)!") return # 检查是否有任何一项处于打勾状态 has_checked = any( self.formula_list.item(i).checkState() == Qt.Checked for i in range(self.formula_list.count()) ) if not has_checked: QMessageBox.warning(self, "输入错误", "请至少勾选一种待计算的水质指数!") return config = self.get_config() # 兼容旧架构的数据流,外层包装为 {'step_id': config} payload = { 'step_name': 'step7_index', 'config': {'step7_index': config} } global_event_bus.publish('RequestRunSingleStep', payload) def update_from_config(self, work_dir=None, pipeline=None): """从全局配置/Pipeline 同步工作目录。 step6 的训练数据已由 PANEL_REGISTRY 的 dependencies 自动通过 set_config 注入到 self.training_data_widget;此处仅缓存 work_dir, 不重复拉取,避免与 panel_factory 注入路径冲突。 """ if work_dir: self.work_dir = work_dir elif hasattr(self, 'work_dir') and self.work_dir: pass else: self.work_dir = None