Files
WQ_GUI/src/gui/panels/step7_inversion_panel.py
2026-06-26 16:43:28 +08:00

426 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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. <panel_dir>/../model/ (开发模式真实位置:src/gui/model/)
4. <panel_dir>/../../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