diff --git a/src/gui/core/panel_registry.py b/src/gui/core/panel_registry.py index 86b20b4..b30fdee 100644 --- a/src/gui/core/panel_registry.py +++ b/src/gui/core/panel_registry.py @@ -18,7 +18,7 @@ from src.gui.panels.step3_panel import Step3Panel from src.gui.panels.step4_sampling_panel import Step4SamplingPanel from src.gui.panels.step5_clean_panel import Step5CleanPanel from src.gui.panels.step6_feature_panel import Step6FeaturePanel -from src.new.views.step7_view import Step7View +from src.gui.panels.step7_inversion_panel import Step7InversionPanel from src.gui.panels.step8_ml_train_panel import Step8MlTrainPanel from src.gui.panels.step9_ml_predict_panel import Step9MlPredictPanel from src.gui.panels.step10_watercolor_panel import Step10WatercolorPanel @@ -128,7 +128,7 @@ PANEL_REGISTRY = [ }, { 'step_id': 'step7_index', - 'class_ref': Step7View, + 'class_ref': Step7InversionPanel, 'title': '水质光谱指数计算', 'icon': '7.png', 'stage': '模块二 特征工程与数据', diff --git a/src/gui/panels/step7_inversion_panel.py b/src/gui/panels/step7_inversion_panel.py new file mode 100644 index 0000000..501dfe1 --- /dev/null +++ b/src/gui/panels/step7_inversion_panel.py @@ -0,0 +1,425 @@ +#!/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