From 3c4d4081a47185ecc9f03f0a4aa5c5d2b0ecd804 Mon Sep 17 00:00:00 2001 From: DXC Date: Thu, 11 Jun 2026 11:13:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor(gui):=20=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=BA=8F=E5=8F=B7=20step4-11=EF=BC=8C?= =?UTF-8?q?=E9=87=87=E6=A0=B7=E7=82=B9=E5=B8=83=E8=AE=BE=E7=A7=BB=E8=87=B3?= =?UTF-8?q?=20step4=EF=BC=8CML=20=E5=BB=BA=E6=A8=A1=E7=A7=BB=E8=87=B3=20st?= =?UTF-8?q?ep9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/pipeline/context.py | 9 +- src/core/pipeline/runner.py | 28 +- src/gui/core/preflight_dialog.py | 14 +- src/gui/core/worker_thread.py | 7 +- ...{step11_ml_panel.py => step10_ml_panel.py} | 66 +- src/gui/panels/step11_panel.py | 2 +- src/gui/panels/step4_panel.py | 185 ----- ...tep10_panel.py => step4_sampling_panel.py} | 20 +- src/gui/panels/step5_panel.py | 236 +++--- src/gui/panels/step6_panel.py | 570 +++++-------- src/gui/panels/step7_panel.py | 748 +++++++++--------- src/gui/panels/step8_panel.py | 729 +++++++++-------- src/gui/panels/step8_waterindex_panel.py | 8 +- src/gui/panels/step9_concentration_panel.py | 8 +- src/gui/panels/step9_panel.py | 690 ++++++++-------- src/gui/water_quality_gui.py | 157 ++-- 16 files changed, 1538 insertions(+), 1939 deletions(-) rename src/gui/panels/{step11_ml_panel.py => step10_ml_panel.py} (87%) delete mode 100644 src/gui/panels/step4_panel.py rename src/gui/panels/{step10_panel.py => step4_sampling_panel.py} (96%) diff --git a/src/core/pipeline/context.py b/src/core/pipeline/context.py index bebee3d..4fd77ce 100644 --- a/src/core/pipeline/context.py +++ b/src/core/pipeline/context.py @@ -20,13 +20,16 @@ from typing import Any, Dict, List, Optional, Set # ============================================================ STEP_MAP_OLD_TO_NEW: Dict[str, str] = { - "step5_5": "step8", + "step5_5": "step7", "step6_5": "step8_non_empirical_modeling", "step6_75": "step9", "step8_5": "step11", - "step8_75": "step12", - "step7": "step10", + "step7": "step8", + "step8": "step7", "step9": "step14", + "step10": "step4", + "step11_ml": "step10", + "step11": "step11", } STEP_MAP_NEW_TO_OLD: Dict[str, str] = {v: k for k, v in STEP_MAP_OLD_TO_NEW.items()} diff --git a/src/core/pipeline/runner.py b/src/core/pipeline/runner.py index 5cf54f5..288e434 100644 --- a/src/core/pipeline/runner.py +++ b/src/core/pipeline/runner.py @@ -115,14 +115,14 @@ PIPELINE_STEPS: List[StepSpec] = [ description="实测样本点光谱提取", ), StepSpec( - step_id="step8", method_name="step8_water_quality_indices", + step_id="step7", method_name="step7_water_quality_indices", requires=["training_csv_path"], produces=["indices_path", "trad_indices_dir"], required_input_files=["training_csv_path"], output_file="{work_dir}/6_water_quality_indices/training_spectra_indices.csv", - description="水质光谱指数计算(双轨输出:A轨宽表 + B轨单文件)", + description="水质参数指数计算(双轨输出:A轨宽表 + B轨单文件)", ), StepSpec( - step_id="step7", method_name="step7_ml_modeling", + step_id="step8", method_name="step8_ml_modeling", requires=["training_csv_path"], produces=["models_dir"], required_input_files=["training_csv_path"], output_file="{work_dir}/7_Supervised_Model_Training/best_models.pkl", @@ -138,18 +138,17 @@ PIPELINE_STEPS: List[StepSpec] = [ description="非经验统计回归", ), StepSpec( - step_id="step9", method_name="step9_custom_regression", - requires=["indices_path"], produces=["models_dir"], - parameter_map={"indices_path": "csv_path"}, - required_input_files=["indices_path"], - output_file="{work_dir}/9_Custom_Regression_Modeling/custom_regression_models.pkl", - description="自定义回归分析", + step_id="step9", method_name="step9_watercolor_inversion", + requires=["deglint_img_path", "water_mask_path"], produces=["watercolor_index_dir"], + required_input_files=["deglint_img_path"], + output_file="{work_dir}/9_WaterColor_Index_Images", + description="水色指数反演(BSQ 影像直接处理)", ), StepSpec( step_id="step10", method_name="step10_sampling", requires=["deglint_img_path", "water_mask_path"], produces=["sampling_csv_path"], required_input_files=["deglint_img_path", "water_mask_path"], - output_file="{work_dir}/10_sampling/sampling_spectra.csv", + output_file="{work_dir}/4_sampling/sampling_spectra.csv", description="整景密集采样点生成 + 光谱提取", ), StepSpec( @@ -167,15 +166,6 @@ PIPELINE_STEPS: List[StepSpec] = [ output_file="{work_dir}/11_12_13_predictions/non_empirical_predictions", description="非经验模型预测", ), - StepSpec( - step_id="step12", method_name="step12_custom_regression_prediction", - requires=["sampling_csv_path", "models_dir", "formula_csv_path"], - produces=["prediction_dir"], - parameter_map={"models_dir": "custom_regression_dir"}, - required_input_files=["sampling_csv_path", "models_dir", "formula_csv_path"], - output_file="{work_dir}/11_12_13_predictions/custom_regression_predictions", - description="自定义回归预测", - ), StepSpec( step_id="step14", method_name="step14_distribution_map", requires=["prediction_csv_path", "boundary_shp_path"], diff --git a/src/gui/core/preflight_dialog.py b/src/gui/core/preflight_dialog.py index c13830c..32cfc4e 100644 --- a/src/gui/core/preflight_dialog.py +++ b/src/gui/core/preflight_dialog.py @@ -59,14 +59,14 @@ class PreflightDialog(QDialog): "step3": ("耀斑去除", 2), "step4": ("数据清洗", 3), "step5": ("特征构建", 4), - "step8": ("水质指数", 5), - "step7": ("监督建模", 6), + "step7": ("水质指数", 5), + "step8": ("监督建模", 6), "step8_non_empirical_modeling": ("回归建模", 7), - "step9": ("自定义回归建模", 8), - "step10": ("采样点布设", 9), - "step11_ml": ("监督预测", 10), - "step11": ("回归预测", 11), - "step12": ("自定义回归预测", 12), + "step9": ("水色指数反演", 8), + "step9_concentration": ("浓度反演", 9), + "step10": ("采样点布设", 10), + "step11_ml": ("监督预测", 11), + "step11": ("回归预测", 12), "step14": ("专题图生成", 13), } diff --git a/src/gui/core/worker_thread.py b/src/gui/core/worker_thread.py index caf4661..074530c 100644 --- a/src/gui/core/worker_thread.py +++ b/src/gui/core/worker_thread.py @@ -325,16 +325,15 @@ class WorkerThread(QThread): 'step3': 'step3_remove_glint', 'step4': 'step4_process_csv', 'step5': 'step5_extract_training_spectra', - 'step6': 'step6_water_quality_indices', - 'step7': 'step7_ml_modeling', + 'step7': 'step7_water_quality_indices', + 'step8': 'step8_ml_modeling', 'step8_non_empirical_modeling': 'step8_non_empirical_modeling', 'step8_qaa': 'step8_qaa_inversion', + 'step9': 'step9_watercolor_inversion', 'step9_concentration': 'step9_concentration_inversion', - 'step9': 'step9_custom_regression', 'step10': 'step10_sampling', 'step11_ml': 'step11_ml_prediction', 'step11': 'step11_non_empirical_prediction', - 'step12': 'step12_custom_regression_prediction', 'step14': 'step14_distribution_map' } diff --git a/src/gui/panels/step11_ml_panel.py b/src/gui/panels/step10_ml_panel.py similarity index 87% rename from src/gui/panels/step11_ml_panel.py rename to src/gui/panels/step10_ml_panel.py index 8881f1e..3fdbd0d 100644 --- a/src/gui/panels/step11_ml_panel.py +++ b/src/gui/panels/step10_ml_panel.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Step8 面板 - 机器学习预测 +Step11 面板 - 机器学习预测 """ import os @@ -19,7 +19,7 @@ from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet -class Step11MlPanel(QWidget): +class Step10MlPanel(QWidget): """步骤11:机器学习预测""" def __init__(self, parent=None): super().__init__(parent) @@ -190,7 +190,7 @@ class Step11MlPanel(QWidget): """浏览模型母文件夹,自动扫描子目录中的 .joblib 文件""" default = self._get_default_work_dir() if default: - default = os.path.join(default, "7_Supervised_Model_Training") + default = os.path.join(default, "9_supervised_modeling") dir_path = QFileDialog.getExistingDirectory( self, "选择模型母文件夹", @@ -216,7 +216,6 @@ class Step11MlPanel(QWidget): ] if not joblib_files: continue - # 每个子目录只取第一个 .joblib 文件(与 batch 逻辑一致) joblib_path = joblib_files[0].path try: loaded = joblib.load(joblib_path) @@ -319,43 +318,41 @@ class Step11MlPanel(QWidget): main_window = self.window() - # 1. 尝试从 Step7 界面读取全湖采样点 CSV 路径 - if main_window and hasattr(main_window, 'step10_panel'): - step7_widget = getattr(main_window.step10_panel, 'output_file', None) - step7_output_path = "" - if hasattr(step7_widget, 'get_path'): - step7_output_path = step7_widget.get_path() or "" - elif hasattr(step7_widget, 'text'): - step7_output_path = step7_widget.text() or "" + # 1. 尝试从 Step4(采样点布设)读取全湖采样点 CSV 路径 + if main_window and hasattr(main_window, 'step4_sampling_panel'): + step4_widget = getattr(main_window.step4_sampling_panel, 'output_file', None) + step4_output_path = "" + if hasattr(step4_widget, 'get_path'): + step4_output_path = step4_widget.get_path() or "" + elif hasattr(step4_widget, 'text'): + step4_output_path = step4_widget.text() or "" - if step7_output_path: - # 若为相对路径,使用 work_dir 合成为绝对路径 - if not os.path.isabs(step7_output_path): - step7_output_path = os.path.join(self.work_dir or '', step7_output_path).replace('\\', '/') + if step4_output_path: + if not os.path.isabs(step4_output_path): + step4_output_path = os.path.join(self.work_dir or '', step4_output_path).replace('\\', '/') existing = self.sampling_csv_file.get_path() if not existing or not existing.strip(): - self.sampling_csv_file.set_path(step7_output_path) + self.sampling_csv_file.set_path(step4_output_path) - # 2. 尝试从 Step6 界面读取监督模型目录 - if main_window and hasattr(main_window, 'step7_panel'): - step6_widget = getattr(main_window.step7_panel, 'output_dir', None) - step6_models_dir = "" - if hasattr(step6_widget, 'get_path'): - step6_models_dir = step6_widget.get_path() or "" - elif hasattr(step6_widget, 'text'): - step6_models_dir = step6_widget.text() or "" + # 2. 尝试从 Step9(监督建模)读取模型目录 + if main_window and hasattr(main_window, 'step9_panel'): + step9_widget = getattr(main_window.step9_panel, 'output_dir', None) + step9_models_dir = "" + if hasattr(step9_widget, 'get_path'): + step9_models_dir = step9_widget.get_path() or "" + elif hasattr(step9_widget, 'text'): + step9_models_dir = step9_widget.text() or "" - if step6_models_dir: - # 若为相对路径,使用 work_dir 合成为绝对路径 - if not os.path.isabs(step6_models_dir): - step6_models_dir = os.path.join(self.work_dir or '', step6_models_dir).replace('\\', '/') + if step9_models_dir: + if not os.path.isabs(step9_models_dir): + step9_models_dir = os.path.join(self.work_dir or '', step9_models_dir).replace('\\', '/') existing_models = self.models_dir_file.get_path() if not existing_models or not existing_models.strip(): - self.models_dir_file.set_path(step6_models_dir) + self.models_dir_file.set_path(step9_models_dir) # 3. 自动填充输出路径(机器学习预测目录) if self.work_dir: - output_dir = os.path.join(self.work_dir, "11_12_13_predictions/Machine_Learning_Prediction") + output_dir = os.path.join(self.work_dir, "11_ml_prediction") os.makedirs(output_dir, exist_ok=True) existing_out = self.output_file.get_path() if not existing_out or not existing_out.strip(): @@ -378,7 +375,7 @@ class Step11MlPanel(QWidget): """浏览模型目录""" default = self._get_default_work_dir() if default: - default = os.path.join(default, "7_Supervised_Model_Training") + default = os.path.join(default, "9_supervised_modeling") dir_path = QFileDialog.getExistingDirectory(self, "选择模型目录", default) if dir_path: self.models_dir_file.set_path(dir_path) @@ -416,7 +413,7 @@ class Step11MlPanel(QWidget): self.output_file.set_path(config['output_path']) def run_step(self): - """独立运行步骤8""" + """独立运行步骤11""" sampling_csv_path = self.sampling_csv_file.get_path() if not sampling_csv_path: QMessageBox.warning(self, "输入错误", "请选择采样光谱CSV文件!") @@ -431,7 +428,6 @@ class Step11MlPanel(QWidget): "请先点击「浏览...」按钮选择模型母文件夹!", ) return - # 只传递用户勾选的模型 checked_dict = self._get_checked_models_dict() if not checked_dict: QMessageBox.warning( @@ -459,4 +455,4 @@ class Step11MlPanel(QWidget): main_window = self.window() if hasattr(main_window, 'run_single_step'): config = {'step11_ml': self.get_config()} - main_window.run_single_step('step11_ml', config) + main_window.run_single_step('step11_ml', config) \ No newline at end of file diff --git a/src/gui/panels/step11_panel.py b/src/gui/panels/step11_panel.py index ce78fb9..7f7ac7c 100644 --- a/src/gui/panels/step11_panel.py +++ b/src/gui/panels/step11_panel.py @@ -17,7 +17,7 @@ from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet -class Step11Panel(QWidget): +class Step11NonEmpiricalPanel(QWidget): """步骤11:非经验模型预测""" def __init__(self, parent=None): super().__init__(parent) diff --git a/src/gui/panels/step4_panel.py b/src/gui/panels/step4_panel.py deleted file mode 100644 index cda8fe5..0000000 --- a/src/gui/panels/step4_panel.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -Step4 面板 - 数据预处理 -""" - -import os - -import pandas as pd -from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QGroupBox, QHBoxLayout, QLabel, - QSpinBox, QPushButton, QCheckBox, QTableView, - QAbstractItemView, QHeaderView, QMessageBox, -) -from PyQt5.QtCore import Qt - -from src.gui.components.custom_widgets import FileSelectWidget -from src.gui.styles import ModernStylesheet - - -class Step4Panel(QWidget): - """步骤4:数据预处理""" - def __init__(self, parent=None): - super().__init__(parent) - self.init_ui() - - def init_ui(self): - layout = QVBoxLayout() - - # 标题 - - # CSV文件 - self.csv_file = FileSelectWidget( - "水质参数文件:", - "CSV Files (*.csv);;All Files (*.*)" - ) - layout.addWidget(self.csv_file) - - hint = QLabel("提示: 处理CSV文件,筛选剔除异常值") - hint.setStyleSheet("color: #666; font-size: 10px;") - layout.addWidget(hint) - - preview_group = QGroupBox("CSV数据预览") - preview_layout = QVBoxLayout() - - controls_layout = QHBoxLayout() - controls_layout.addWidget(QLabel("预览行数:")) - self.preview_rows_spin = QSpinBox() - self.preview_rows_spin.setRange(1, 200) - self.preview_rows_spin.setValue(10) - controls_layout.addWidget(self.preview_rows_spin) - self.preview_btn = QPushButton("刷新预览") - self.preview_btn.clicked.connect(self.load_csv_preview) - controls_layout.addWidget(self.preview_btn) - controls_layout.addStretch() - - self.preview_table = QTableView() - self.preview_table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.preview_table.setSelectionBehavior(QAbstractItemView.SelectRows) - self.preview_table.setSelectionMode(QAbstractItemView.SingleSelection) - self.preview_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) - self.preview_table.verticalHeader().setVisible(False) - self.preview_table.setMinimumHeight(200) - - self.preview_status_label = QLabel("请选择CSV文件并点击刷新预览") - self.preview_status_label.setStyleSheet("color: #666; font-size: 11px;") - - preview_layout.addLayout(controls_layout) - preview_layout.addWidget(self.preview_table) - preview_layout.addWidget(self.preview_status_label) - preview_group.setLayout(preview_layout) - layout.addWidget(preview_group) - - # 输出文件路径 - self.output_file = FileSelectWidget( - "输出处理后CSV:", - "CSV Files (*.csv);;All Files (*.*)" - ) - self.output_file.line_edit.setPlaceholderText("processed_data.csv") - layout.addWidget(self.output_file) - - # 启用步骤 - self.enable_checkbox = QCheckBox("启用此步骤") - self.enable_checkbox.setChecked(True) - layout.addWidget(self.enable_checkbox) - - # 独立运行按钮 - self.run_btn = QPushButton("独立运行此步骤") - self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) - self.run_btn.clicked.connect(self.run_step) - layout.addWidget(self.run_btn) - - layout.addStretch() - self.setLayout(layout) - self.reset_preview() - - def get_config(self): - """获取配置""" - config = { - 'csv_path': self.csv_file.get_path(), - } - output_path = self.output_file.get_path() - if output_path: - config['output_path'] = output_path - return config - - def set_config(self, config): - """设置配置""" - if 'csv_path' in config: - self.csv_file.set_path(config['csv_path']) - self.load_csv_preview() - if 'output_path' in config: - self.output_file.set_path(config['output_path']) - - def update_from_config(self, work_dir=None, pipeline=None): - """从全局配置自动填充输出路径 - - Args: - work_dir: 工作目录路径 - pipeline: Pipeline 实例(未使用,保留接口兼容性) - """ - if work_dir: - self.work_dir = work_dir - elif hasattr(self, 'work_dir') and self.work_dir: - pass - else: - self.work_dir = None - - if self.work_dir: - output_dir = os.path.join(self.work_dir, "4_processed_data") - os.makedirs(output_dir, exist_ok=True) - default_output_path = os.path.join(output_dir, "processed_data.csv").replace('\\', '/') - self.output_file.set_path(default_output_path) - else: - self.output_file.set_path("") - - def run_step(self): - """独立运行步骤4""" - # 验证输入 - csv_path = self.csv_file.get_path() - if not csv_path: - QMessageBox.warning(self, "输入错误", "请选择水质参数文件!") - return - - # 获取主窗口并运行步骤 - main_window = self.window() - if hasattr(main_window, 'run_single_step'): - config = {'step4': self.get_config()} - main_window.run_single_step('step4', config) - - def reset_preview(self, message="请选择CSV文件并点击刷新预览"): - """重置预览表格""" - from src.gui.water_quality_gui import PandasTableModel - empty_model = PandasTableModel(pd.DataFrame()) - self.preview_table.setModel(empty_model) - self.preview_status_label.setText(message) - - def load_csv_preview(self): - """加载CSV预览数据""" - from src.gui.water_quality_gui import PandasTableModel - csv_path = self.csv_file.get_path() - if not csv_path: - self.reset_preview("请先选择CSV文件") - return - if not os.path.exists(csv_path): - self.reset_preview("文件不存在,请检查路径") - return - - try: - rows_to_preview = max(1, self.preview_rows_spin.value()) - # dtype=object 确保所有列以字符串读取,避免空值/混合类型导致 dtype 报错 - df = pd.read_csv(csv_path, nrows=rows_to_preview, dtype=object) - # fillna 在 PandasTableModel.__init__ 中已执行,此处再次防御性处理 - df = df.fillna('') - if df.empty: - self.reset_preview("CSV文件为空") - return - - model = PandasTableModel(df) - self.preview_table.setModel(model) - self.preview_status_label.setText( - f"预览 {len(df)} 行,{len(df.columns)} 列(总行数可能更多)" - ) - except Exception as exc: - self.reset_preview(f"加载失败: {exc}") diff --git a/src/gui/panels/step10_panel.py b/src/gui/panels/step4_sampling_panel.py similarity index 96% rename from src/gui/panels/step10_panel.py rename to src/gui/panels/step4_sampling_panel.py index 5e6150d..d62cb70 100644 --- a/src/gui/panels/step10_panel.py +++ b/src/gui/panels/step4_sampling_panel.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Step10 面板 - 采样点生成 +Step4 面板 - 采样点布设 """ import os @@ -16,8 +16,8 @@ from src.gui.dialogs import SamplingViewerDialog from src.gui.styles import ModernStylesheet -class Step10Panel(QWidget): - """步骤10:采样点生成""" +class Step4SamplingPanel(QWidget): + """步骤4:采样点布设""" def __init__(self, parent=None): super().__init__(parent) self.init_ui() @@ -71,7 +71,7 @@ class Step10Panel(QWidget): "输出采样点:", "CSV Files (*.csv);;All Files (*.*)" ) - self.output_file.line_edit.setPlaceholderText("sampling_points.csv") + self.output_file.line_edit.setPlaceholderText("sampling_spectra.csv") layout.addWidget(self.output_file) # 启用步骤 @@ -207,7 +207,7 @@ class Step10Panel(QWidget): # 3. 自动填充输出路径(绝对路径) if self.work_dir: - output_path = os.path.join(self.work_dir, "10_sampling", "sampling_spectra.csv") + output_path = os.path.join(self.work_dir, "4_sampling", "sampling_spectra.csv") os.makedirs(os.path.dirname(output_path), exist_ok=True) self.output_file.set_path(output_path.replace('\\', '/')) @@ -215,7 +215,7 @@ class Step10Panel(QWidget): self._check_csv_exists() def run_step(self): - """独立运行步骤10""" + """独立运行步骤4""" deglint_img_path = self.deglint_img_file.get_path() if not deglint_img_path: QMessageBox.warning(self, "输入错误", "请选择去耀斑影像文件!") @@ -223,8 +223,8 @@ class Step10Panel(QWidget): main_window = self.window() if hasattr(main_window, 'run_single_step'): - config = {'step10': self.get_config()} - main_window.run_single_step('step10', config) + config = {'step4': self.get_config()} + main_window.run_single_step('step4', config) def _check_csv_exists(self): """检查 output csv 是否存在,驱动预览按钮启停""" @@ -243,10 +243,10 @@ class Step10Panel(QWidget): if not csv_path or not os.path.exists(csv_path): QMessageBox.warning( self, "文件不存在", - f"采样点 CSV 文件不存在:{csv_path}\n请先运行步骤10生成数据。" + f"采样点 CSV 文件不存在:{csv_path}\n请先运行步骤4生成数据。" ) return dialog = SamplingViewerDialog(csv_path, self) dialog.exec_() # 弹窗关闭后再次检查状态(可能文件被覆盖等) - self._check_csv_exists() + self._check_csv_exists() \ No newline at end of file diff --git a/src/gui/panels/step5_panel.py b/src/gui/panels/step5_panel.py index edc1e94..5c3fbf4 100644 --- a/src/gui/panels/step5_panel.py +++ b/src/gui/panels/step5_panel.py @@ -1,16 +1,17 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Step5 面板 - 光谱提取 +Step4 面板 - 数据预处理 """ import os +import pandas as pd from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QGroupBox, QFormLayout, QLabel, - QSpinBox, QPushButton, QCheckBox, QMessageBox, + QWidget, QVBoxLayout, QGroupBox, QHBoxLayout, QLabel, + QSpinBox, QPushButton, QCheckBox, QTableView, + QAbstractItemView, QHeaderView, QMessageBox, ) -from PyQt5.QtGui import QFont from PyQt5.QtCore import Qt from src.gui.components.custom_widgets import FileSelectWidget @@ -18,7 +19,7 @@ from src.gui.styles import ModernStylesheet class Step5Panel(QWidget): - """步骤5:光谱提取""" + """步骤5:数据清洗""" def __init__(self, parent=None): super().__init__(parent) self.init_ui() @@ -27,67 +28,55 @@ class Step5Panel(QWidget): layout = QVBoxLayout() # 标题 - title = QLabel("步骤5:训练样本光谱提取") - title.setFont(QFont("Arial", 12, QFont.Bold)) - layout.addWidget(title) - # 去耀斑影像文件(用于独立运行) - self.deglint_img_file = FileSelectWidget( - "去耀斑影像:", - "Image Files (*.bsq *.dat *.tif);;All Files (*.*)" - ) - layout.addWidget(self.deglint_img_file) - - # 处理后的CSV文件(用于独立运行) + # CSV文件 self.csv_file = FileSelectWidget( - "处理后CSV:", + "水质参数文件:", "CSV Files (*.csv);;All Files (*.*)" ) layout.addWidget(self.csv_file) - # 水体掩膜文件(可选,用于独立运行) - self.water_mask_file = FileSelectWidget( - "水体掩膜:", - "Mask Files (*.dat *.tif);;All Files (*.*)" - ) - self.water_mask_file.line_edit.setPlaceholderText("可选,如不选择则自动生成") - layout.addWidget(self.water_mask_file) + hint = QLabel("提示: 处理CSV文件,筛选剔除异常值") + hint.setStyleSheet("color: #666; font-size: 10px;") + layout.addWidget(hint) - self.glint_mask_file = FileSelectWidget( - "耀斑掩膜:", - "Mask Files (*.dat *.tif);;All Files (*.*)" - ) - layout.addWidget(self.glint_mask_file) - step5_glint_hint = QLabel( - "提示:独立运行本步骤时必须选择耀斑掩膜(通常为步骤2输出的 severe_glint_area.dat),用于在采样时避开耀斑像元。" - ) - step5_glint_hint.setWordWrap(True) - step5_glint_hint.setStyleSheet("color: #666; font-size: 10px;") - layout.addWidget(step5_glint_hint) + preview_group = QGroupBox("CSV数据预览") + preview_layout = QVBoxLayout() - # 参数设置 - params_group = QGroupBox("提取参数") - params_layout = QFormLayout() + controls_layout = QHBoxLayout() + controls_layout.addWidget(QLabel("预览行数:")) + self.preview_rows_spin = QSpinBox() + self.preview_rows_spin.setRange(1, 200) + self.preview_rows_spin.setValue(10) + controls_layout.addWidget(self.preview_rows_spin) + self.preview_btn = QPushButton("刷新预览") + self.preview_btn.clicked.connect(self.load_csv_preview) + controls_layout.addWidget(self.preview_btn) + controls_layout.addStretch() - self.radius = QSpinBox() - self.radius.setRange(1, 50) - self.radius.setValue(5) - params_layout.addRow("采样半径(像素):", self.radius) + self.preview_table = QTableView() + self.preview_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.preview_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.preview_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.preview_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.preview_table.verticalHeader().setVisible(False) + self.preview_table.setMinimumHeight(200) - self.source_epsg = QSpinBox() - self.source_epsg.setRange(1000, 99999) - self.source_epsg.setValue(4326) - params_layout.addRow("源坐标系EPSG:", self.source_epsg) + self.preview_status_label = QLabel("请选择CSV文件并点击刷新预览") + self.preview_status_label.setStyleSheet("color: #666; font-size: 11px;") - params_group.setLayout(params_layout) - layout.addWidget(params_group) + preview_layout.addLayout(controls_layout) + preview_layout.addWidget(self.preview_table) + preview_layout.addWidget(self.preview_status_label) + preview_group.setLayout(preview_layout) + layout.addWidget(preview_group) # 输出文件路径 self.output_file = FileSelectWidget( - "输出训练数据:", + "输出处理后CSV:", "CSV Files (*.csv);;All Files (*.*)" ) - self.output_file.line_edit.setPlaceholderText("training_spectra.csv") + self.output_file.line_edit.setPlaceholderText("processed_data.csv") layout.addWidget(self.output_file) # 启用步骤 @@ -103,54 +92,33 @@ class Step5Panel(QWidget): layout.addStretch() self.setLayout(layout) - # 信号连接:影像文件路径变化时动态更新波段范围 + self.reset_preview() def get_config(self): """获取配置""" config = { - 'radius': self.radius.value(), - 'source_epsg': self.source_epsg.value(), + 'csv_path': self.csv_file.get_path(), } - # 添加独立运行所需的文件路径 - 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 - # 注意:step5_extract_training_spectra 不接受 output_path / training_csv_path - # 参数,输出路径由 pipeline 内部根据 training_spectra_dir 自动生成。 + output_path = self.output_file.get_path() + if output_path: + config['output_path'] = output_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']) + self.load_csv_preview() + if 'output_path' in config: + self.output_file.set_path(config['output_path']) def update_from_config(self, work_dir=None, pipeline=None): - """从全局配置/Pipeline 或 Step1Panel 自动填充路径,实现上下游数据流转 + """从全局配置自动填充输出路径 Args: work_dir: 工作目录路径 - pipeline: Pipeline 实例,用于获取步骤1生成的水域掩膜路径 + pipeline: Pipeline 实例(未使用,保留接口兼容性) """ - # 保存工作目录引用 if work_dir: self.work_dir = work_dir elif hasattr(self, 'work_dir') and self.work_dir: @@ -158,82 +126,60 @@ class Step5Panel(QWidget): 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 - - # 2. 如果 Pipeline 中没有,则尝试直接从 Step1 界面读取 - main_window = self.window() - if not mask_path and hasattr(main_window, 'step1_panel'): - if main_window.step1_panel.use_ndwi_radio.isChecked(): - mask_path = main_window.step1_panel.output_file.get_path() - else: - mask_path = main_window.step1_panel.mask_file.get_path() - # 若为相对路径,使用 work_dir 合成为绝对路径 - if mask_path and not os.path.isabs(mask_path): - mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') - - # 填充水体掩膜路径 - if mask_path: - # 若为相对路径,使用 work_dir 合成为绝对路径 - if not os.path.isabs(mask_path): - mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') - self.water_mask_file.set_path(mask_path) - - # 3. 尝试从 Step2 界面读取耀斑掩膜路径 - main_window = self.window() - if hasattr(main_window, 'step2_panel'): - glint_path = main_window.step2_panel.output_file.get_path() - if glint_path: - # 若为相对路径,使用 work_dir 合成为绝对路径 - if not os.path.isabs(glint_path): - glint_path = os.path.join(self.work_dir or '', glint_path).replace('\\', '/') - self.glint_mask_file.set_path(glint_path) - - # 4. 自动填充输出路径(基于工作目录) if self.work_dir: - output_dir = os.path.join(self.work_dir, "5_training_spectra") + output_dir = os.path.join(self.work_dir, "4_processed_data") os.makedirs(output_dir, exist_ok=True) - default_output_path = os.path.join(output_dir, "training_spectra.csv").replace('\\', '/') + default_output_path = os.path.join(output_dir, "processed_data.csv").replace('\\', '/') self.output_file.set_path(default_output_path) else: self.output_file.set_path("") - # 5. 尝试从 Step4 界面读取已处理的水质参数 CSV 路径,自动填入本面板 - main_window = self.window() - if main_window and hasattr(main_window, 'step4_panel'): - step4_output_path = main_window.step4_panel.output_file.get_path() - if step4_output_path: - # 若为相对路径,使用 work_dir 合成为绝对路径 - if not os.path.isabs(step4_output_path): - step4_output_path = os.path.join(self.work_dir or '', step4_output_path).replace('\\', '/') - existing_csv = self.csv_file.get_path() - if not existing_csv or not existing_csv.strip(): - self.csv_file.set_path(step4_output_path) - def run_step(self): - """独立运行步骤5""" + """独立运行步骤4""" # 验证输入 - 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)。", - ) + QMessageBox.warning(self, "输入错误", "请选择水质参数文件!") return # 获取主窗口并运行步骤 main_window = self.window() if hasattr(main_window, 'run_single_step'): - config = {'step5': self.get_config()} - main_window.run_single_step('step5', config) + config = {'step4': self.get_config()} + main_window.run_single_step('step4', config) + + def reset_preview(self, message="请选择CSV文件并点击刷新预览"): + """重置预览表格""" + from src.gui.water_quality_gui import PandasTableModel + empty_model = PandasTableModel(pd.DataFrame()) + self.preview_table.setModel(empty_model) + self.preview_status_label.setText(message) + + def load_csv_preview(self): + """加载CSV预览数据""" + from src.gui.water_quality_gui import PandasTableModel + csv_path = self.csv_file.get_path() + if not csv_path: + self.reset_preview("请先选择CSV文件") + return + if not os.path.exists(csv_path): + self.reset_preview("文件不存在,请检查路径") + return + + try: + rows_to_preview = max(1, self.preview_rows_spin.value()) + # dtype=object 确保所有列以字符串读取,避免空值/混合类型导致 dtype 报错 + df = pd.read_csv(csv_path, nrows=rows_to_preview, dtype=object) + # fillna 在 PandasTableModel.__init__ 中已执行,此处再次防御性处理 + df = df.fillna('') + if df.empty: + self.reset_preview("CSV文件为空") + return + + model = PandasTableModel(df) + self.preview_table.setModel(model) + self.preview_status_label.setText( + f"预览 {len(df)} 行,{len(df.columns)} 列(总行数可能更多)" + ) + except Exception as exc: + self.reset_preview(f"加载失败: {exc}") diff --git a/src/gui/panels/step6_panel.py b/src/gui/panels/step6_panel.py index 6afde8d..2c910fe 100644 --- a/src/gui/panels/step6_panel.py +++ b/src/gui/panels/step6_panel.py @@ -1,423 +1,239 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Step5 面板 - 光谱提取 +""" + import os -import sys -import pandas as pd -import numpy as np -from pathlib import Path -from typing import Dict, List, Optional, Tuple from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QGroupBox, QGridLayout, - QHBoxLayout, QLabel, QCheckBox, QPushButton, QMessageBox, - QScrollArea, QListWidget, QListWidgetItem, QAbstractItemView, - QRadioButton, QButtonGroup + QWidget, QVBoxLayout, QGroupBox, QFormLayout, QLabel, + QSpinBox, QPushButton, QCheckBox, QMessageBox, ) +from PyQt5.QtGui import QFont from PyQt5.QtCore import Qt -from PyQt5.QtGui import QColor, QBrush, QFont from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet -def get_resource_path(relative_path: str) -> str: - """适配开发与 PyInstaller 环境的路径获取逻辑。""" - if hasattr(sys, '_MEIPASS'): - internal = os.path.join(sys._MEIPASS, '_internal', relative_path) - if os.path.exists(internal): - return internal - return os.path.join(sys._MEIPASS, relative_path) - - exe_dir = os.path.dirname(sys.executable) - internal = os.path.join(exe_dir, '_internal', relative_path) - if os.path.exists(internal): - return internal - - base_dir = Path(__file__).resolve().parent.parent / "model" - return str(base_dir / os.path.basename(relative_path)) - - class Step6Panel(QWidget): - COLOR_RATIO = QColor(255, 255, 255) - COLOR_CONCENTRATION = QColor(220, 240, 255) - COLOR_HEADER = QColor(245, 245, 245) - + """步骤6:光谱特征""" def __init__(self, parent=None): super().__init__(parent) - self.index_checkboxes: Dict[str, QListWidgetItem] = {} - self.work_dir: Optional[str] = None - self.builtin_formula_path = get_resource_path("waterindex.csv") - self._formula_type_map: Dict[str, str] = {} - self._formula_color_map: Dict[str, QColor] = {} - self._formula_coef_map: Dict[str, List[float]] = {} - self.init_ui() - self._auto_load_formulas() def init_ui(self): - main_layout = QVBoxLayout() - main_layout.setContentsMargins(20, 20, 20, 20) - main_layout.setSpacing(10) + layout = QVBoxLayout() - # 1. 公式配置源 (只读) - path_group = QGroupBox("公式配置源 (内置)") - path_layout = QVBoxLayout() - self.formula_csv_widget = FileSelectWidget("内置CSV路径:", "CSV Files (*.csv)") - self.formula_csv_widget.set_path(self.builtin_formula_path) - self.formula_csv_widget.set_read_only(True) - self.formula_csv_widget.line_edit.setStyleSheet("background-color: #f0f0f0; color: #666;") - path_layout.addWidget(self.formula_csv_widget) - path_group.setLayout(path_layout) - main_layout.addWidget(path_group) + # 标题 + title = QLabel("步骤5:训练样本光谱提取") + title.setFont(QFont("Arial", 12, QFont.Bold)) + layout.addWidget(title) - # 2. 训练数据输入 - input_group = QGroupBox("输入样本数据") - input_layout = QVBoxLayout() - self.training_data_widget = FileSelectWidget("特征提取CSV:", "CSV Files (*.csv)") - input_layout.addWidget(self.training_data_widget) - input_group.setLayout(input_layout) - main_layout.addWidget(input_group) + # 去耀斑影像文件(用于独立运行) + self.deglint_img_file = FileSelectWidget( + "去耀斑影像:", + "Image Files (*.bsq *.dat *.tif);;All Files (*.*)" + ) + layout.addWidget(self.deglint_img_file) - # 3. 公式选择区 (分组 ListWidget) - self.formula_group = QGroupBox("待计算水质指数勾选") - formula_outer_layout = QVBoxLayout() + # 处理后的CSV文件(用于独立运行) + self.csv_file = FileSelectWidget( + "处理后CSV:", + "CSV Files (*.csv);;All Files (*.*)" + ) + layout.addWidget(self.csv_file) - btn_layout = QHBoxLayout() - self.select_all_btn = QPushButton("全选") - self.deselect_all_btn = QPushButton("清空") - self.select_ratio_btn = QPushButton("仅选比值型") - self.select_conc_btn = QPushButton("仅选浓度型") - self.select_all_btn.clicked.connect(self.select_all_formulas) - self.deselect_all_btn.clicked.connect(self.deselect_all_formulas) - self.select_ratio_btn.clicked.connect(self._select_ratio_only) - self.select_conc_btn.clicked.connect(self._select_conc_only) - btn_layout.addWidget(self.select_all_btn) - btn_layout.addWidget(self.deselect_all_btn) - btn_layout.addWidget(self.select_ratio_btn) - btn_layout.addWidget(self.select_conc_btn) - btn_layout.addStretch() + # 水体掩膜文件(可选,用于独立运行) + self.water_mask_file = FileSelectWidget( + "水体掩膜:", + "Mask Files (*.dat *.tif);;All Files (*.*)" + ) + self.water_mask_file.line_edit.setPlaceholderText("可选,如不选择则自动生成") + layout.addWidget(self.water_mask_file) - self.refresh_button = QPushButton("重新加载") - self.refresh_button.clicked.connect(lambda: self.refresh_formulas(silent=False)) - btn_layout.addWidget(self.refresh_button) + self.glint_mask_file = FileSelectWidget( + "耀斑掩膜:", + "Mask Files (*.dat *.tif);;All Files (*.*)" + ) + layout.addWidget(self.glint_mask_file) + step5_glint_hint = QLabel( + "提示:独立运行本步骤时必须选择耀斑掩膜(通常为步骤2输出的 severe_glint_area.dat),用于在采样时避开耀斑像元。" + ) + step5_glint_hint.setWordWrap(True) + step5_glint_hint.setStyleSheet("color: #666; font-size: 10px;") + layout.addWidget(step5_glint_hint) - formula_outer_layout.addLayout(btn_layout) + # 参数设置 + params_group = QGroupBox("提取参数") + params_layout = QFormLayout() - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setMinimumHeight(280) - self.scroll_content = QWidget() - self.formula_layout = QVBoxLayout(self.scroll_content) - self.formula_layout.setContentsMargins(4, 4, 4, 4) - self.formula_layout.setSpacing(2) - self.formula_layout.setAlignment(Qt.AlignTop) + self.radius = QSpinBox() + self.radius.setRange(1, 50) + self.radius.setValue(5) + params_layout.addRow("采样半径(像素):", self.radius) - self.formula_list = QListWidget() - self.formula_list.setSelectionMode(QAbstractItemView.MultiSelection) - self.formula_list.itemChanged.connect(self._on_item_changed) - self.formula_layout.addWidget(self.formula_list) + self.source_epsg = QSpinBox() + self.source_epsg.setRange(1000, 99999) + self.source_epsg.setValue(4326) + params_layout.addRow("源坐标系EPSG:", self.source_epsg) - scroll.setWidget(self.scroll_content) - formula_outer_layout.addWidget(scroll) + params_group.setLayout(params_layout) + layout.addWidget(params_group) - self.formula_group.setLayout(formula_outer_layout) - main_layout.addWidget(self.formula_group) + # 输出文件路径 + self.output_file = FileSelectWidget( + "输出训练数据:", + "CSV Files (*.csv);;All Files (*.*)" + ) + self.output_file.line_edit.setPlaceholderText("training_spectra.csv") + layout.addWidget(self.output_file) - # 4. 输出选项 - output_group = QGroupBox("输出模式") - output_layout = QVBoxLayout() - - mode_layout = QHBoxLayout() - self.mode_group = QButtonGroup() - self.radio_both = QRadioButton("两者皆出") - self.radio_wide = QRadioButton("仅宽表") - self.radio_single = QRadioButton("仅单文件") - self.mode_group.addButton(self.radio_both, 0) - self.mode_group.addButton(self.radio_wide, 1) - self.mode_group.addButton(self.radio_single, 2) - self.radio_both.setChecked(True) - mode_layout.addWidget(self.radio_both) - mode_layout.addWidget(self.radio_wide) - mode_layout.addWidget(self.radio_single) - mode_layout.addStretch() - output_layout.addLayout(mode_layout) - - self.enable_checkbox = QCheckBox("启用计算流程") + # 启用步骤 + self.enable_checkbox = QCheckBox("启用此步骤") self.enable_checkbox.setChecked(True) - output_layout.addWidget(self.enable_checkbox) + layout.addWidget(self.enable_checkbox) - output_group.setLayout(output_layout) - main_layout.addWidget(output_group) + # 独立运行按钮 + self.run_btn = QPushButton("独立运行此步骤") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) + self.run_btn.clicked.connect(self.run_step) + layout.addWidget(self.run_btn) - # 5. 运行按钮 - self.run_button = QPushButton("立即执行计算") - self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) - self.run_button.setMinimumHeight(40) - self.run_button.clicked.connect(self.run_step) - main_layout.addWidget(self.run_button) + layout.addStretch() + self.setLayout(layout) + # 信号连接:影像文件路径变化时动态更新波段范围 - self.setLayout(main_layout) - - def _on_item_changed(self, item: QListWidgetItem): - if item.checkState() == Qt.Checked: - bg_color = self.COLOR_RATIO - for name, ref_item in self.index_checkboxes.items(): - if ref_item is item: - bg_color = self._formula_color_map.get(name, self.COLOR_RATIO) - break - item.setBackground(QBrush(bg_color)) - else: - item.setBackground(QBrush(self.COLOR_RATIO)) - - def _auto_load_formulas(self): - if os.path.exists(self.builtin_formula_path): - self.refresh_formulas(silent=True) - else: - print(f"DEBUG: 自动加载失败,路径不存在: {self.builtin_formula_path}") - - def refresh_formulas(self, silent=False): - path = self.builtin_formula_path - if not os.path.exists(path): - if not silent: - QMessageBox.warning(self, "错误", f"找不到内置公式文件:\n{path}") - return - - try: - df = None - for enc in ('utf-8', 'gbk', 'utf-8-sig'): - try: - df = pd.read_csv(path, encoding=enc) - if 'Formula_Name' in df.columns: - break - except Exception: - continue - - if df is None or 'Formula_Name' not in df.columns: - if not silent: - QMessageBox.critical(self, "错误", "CSV缺少 'Formula_Name' 列") - return - - self._formula_type_map.clear() - self._formula_coef_map.clear() - for _, row in df.iterrows(): - name = str(row['Formula_Name']).strip() - if not name: - continue - ftype = str(row.get('Formula_Type', 'ratio')).strip().lower() - self._formula_type_map[name] = ftype - - # Parse Coefficient for concentration formulas - coef_str = str(row.get('Coefficient', '')).strip() - if coef_str: - try: - coeffs = [float(c.strip()) for c in coef_str.split(',') if c.strip()] - self._formula_coef_map[name] = coeffs - except Exception: - self._formula_coef_map[name] = [] - else: - self._formula_coef_map[name] = [] - - self.formula_list.clear() - self.index_checkboxes.clear() - - self._formula_color_map.clear() - for name, ftype in self._formula_type_map.items(): - item = QListWidgetItem(name, self.formula_list) - item.setCheckState(Qt.Checked) - if ftype == 'concentration': - bg_color = QColor(220, 240, 255) - else: - bg_color = self.COLOR_RATIO - self._formula_color_map[name] = bg_color - item.setBackground(QBrush(bg_color)) - self.index_checkboxes[name] = item - - self.formula_list.adjustSize() - print(f"✅ 加载 {len(self.index_checkboxes)} 个公式") - - except Exception as e: - if not silent: - QMessageBox.critical(self, "加载失败", f"原因: {str(e)}") - - def _select_ratio_only(self): - for name, item in self.index_checkboxes.items(): - ftype = self._formula_type_map.get(name, 'ratio') - item.setCheckState(Qt.Checked if ftype == 'ratio' else Qt.Unchecked) - - def _select_conc_only(self): - for name, item in self.index_checkboxes.items(): - ftype = self._formula_type_map.get(name, 'ratio') - item.setCheckState(Qt.Checked if ftype == 'concentration' else Qt.Unchecked) - - def select_all_formulas(self): - for item in self.index_checkboxes.values(): - item.setCheckState(Qt.Checked) - - def deselect_all_formulas(self): - for item in self.index_checkboxes.values(): - item.setCheckState(Qt.Unchecked) - - def get_config(self) -> Dict: - selected = [ - name for name, item in self.index_checkboxes.items() - if item.checkState() == Qt.Checked - ] - # Build coefficient dict for selected formulas - formula_coefficients = { - name: self._formula_coef_map.get(name, []) - for name in selected - } - return { - 'training_csv_path': self.training_data_widget.get_path(), - 'formula_csv_file': self.builtin_formula_path, - 'formula_names': selected, - 'formula_coefficients': formula_coefficients, - 'enabled': self.enable_checkbox.isChecked(), - 'output_mode': self.mode_group.checkedId(), + 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 + # 注意:step5_extract_training_spectra 不接受 output_path / training_csv_path + # 参数,输出路径由 pipeline 内部根据 training_spectra_dir 自动生成。 + 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_names' in config: - sel = set(config['formula_names']) - for name, item in self.index_checkboxes.items(): - item.setCheckState(Qt.Checked if name in sel else Qt.Unchecked) - self.enable_checkbox.setChecked(config.get('enabled', True)) - if 'output_mode' in config: - btn = self.mode_group.button(config['output_mode']) - if btn: - btn.setChecked(True) + 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 或 Step1Panel 自动填充路径,实现上下游数据流转 + + Args: + work_dir: 工作目录路径 + pipeline: Pipeline 实例,用于获取步骤1生成的水域掩膜路径 + """ + # 保存工作目录引用 if work_dir: self.work_dir = work_dir - main = self.window() - if hasattr(main, 'step5_panel'): - p5 = main.step5_panel.output_file.get_path() - if p5: - if not os.path.isabs(p5): - p5 = os.path.join(self.work_dir or '', p5) - p5 = p5.replace('\\', '/') - self.training_data_widget.set_path(p5) + elif hasattr(self, 'work_dir') and self.work_dir: + pass + else: + self.work_dir = None - def _get_work_dir(self) -> Optional[str]: + # 1. 尝试从 Pipeline 获取水体掩膜路径 + mask_path = None + if pipeline and hasattr(pipeline, 'water_mask_path') and pipeline.water_mask_path: + mask_path = pipeline.water_mask_path + + # 2. 如果 Pipeline 中没有,则尝试直接从 Step1 界面读取 + main_window = self.window() + if not mask_path and hasattr(main_window, 'step1_panel'): + if main_window.step1_panel.use_ndwi_radio.isChecked(): + mask_path = main_window.step1_panel.output_file.get_path() + else: + mask_path = main_window.step1_panel.mask_file.get_path() + # 若为相对路径,使用 work_dir 合成为绝对路径 + if mask_path and not os.path.isabs(mask_path): + mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') + + # 填充水体掩膜路径 + if mask_path: + # 若为相对路径,使用 work_dir 合成为绝对路径 + if not os.path.isabs(mask_path): + mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/') + self.water_mask_file.set_path(mask_path) + + # 3. 尝试从 Step2 界面读取耀斑掩膜路径 + main_window = self.window() + if hasattr(main_window, 'step2_panel'): + glint_path = main_window.step2_panel.output_file.get_path() + if glint_path: + # 若为相对路径,使用 work_dir 合成为绝对路径 + if not os.path.isabs(glint_path): + glint_path = os.path.join(self.work_dir or '', glint_path).replace('\\', '/') + self.glint_mask_file.set_path(glint_path) + + # 4. 自动填充输出路径(基于工作目录) if self.work_dir: - return self.work_dir - main = self.window() - if hasattr(main, 'work_dir') and main.work_dir: - return main.work_dir - return None + output_dir = os.path.join(self.work_dir, "5_training_spectra") + os.makedirs(output_dir, exist_ok=True) + default_output_path = os.path.join(output_dir, "training_spectra.csv").replace('\\', '/') + self.output_file.set_path(default_output_path) + else: + self.output_file.set_path("") - def _get_coord_cols(self, df: pd.DataFrame) -> Tuple[str, str]: - coord_candidates = ['lon', 'lng', 'longitude', '经度', 'x', 'lon_utm', 'utm_x', 'pixel_x'] - lat_candidates = ['lat', 'latitude', '纬度', 'y', 'lat_utm', 'utm_y', 'pixel_y'] - - x_col, y_col = None, None - for col in df.columns: - cl = col.lower() - if x_col is None and any(c in cl for c in coord_candidates): - x_col = col - if y_col is None and any(c in cl for c in lat_candidates): - y_col = col - - if x_col is None and len(df.columns) >= 2: - x_col = df.columns[0] - if y_col is None and len(df.columns) >= 2: - y_col = df.columns[1] - - return x_col or 'x_coord', y_col or 'y_coord' + # 5. 尝试从 Step4 界面读取已处理的水质参数 CSV 路径,自动填入本面板 + main_window = self.window() + if main_window and hasattr(main_window, 'step5_panel'): + step4_output_path = main_window.step5_panel.output_file.get_path() + if step4_output_path: + # 若为相对路径,使用 work_dir 合成为绝对路径 + if not os.path.isabs(step4_output_path): + step4_output_path = os.path.join(self.work_dir or '', step4_output_path).replace('\\', '/') + existing_csv = self.csv_file.get_path() + if not existing_csv or not existing_csv.strip(): + self.csv_file.set_path(step4_output_path) def run_step(self): - config = self.get_config() - - if not config['enabled']: - QMessageBox.information(self, "提示", "已禁用计算流程(启用计算流程未勾选)") + """独立运行步骤5""" + # 验证输入 + 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 - - training_path = config['training_csv_path'] - if not training_path or not os.path.exists(training_path): - QMessageBox.warning(self, "提示", "请先选择输入特征提取CSV文件") + if not csv_path: + QMessageBox.warning(self, "输入错误", "请选择处理后的CSV文件!") return - - formula_names = config['formula_names'] - if not formula_names: - QMessageBox.warning(self, "提示", "请至少勾选一个公式") - return - - output_mode = config['output_mode'] - - try: - from src.core.steps.data_preparation_step import DataPreparationStep - - spec_df = pd.read_csv(training_path) - x_col, y_col = self._get_coord_cols(spec_df) - - # 构建 formula_csv_path(使用内置 waterindex.csv) - formula_csv_path = self.builtin_formula_path - if not formula_csv_path or not os.path.exists(formula_csv_path): - # 尝试从 src/gui/model/ 目录找 - possible_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'gui', 'model', 'waterindex.csv') - if os.path.exists(possible_path): - formula_csv_path = possible_path - - work_dir = self._get_work_dir() - - # 调用 DataPreparationStep 的静态方法计算水质指数(宽表输出) - indices_csv_path = DataPreparationStep.calculate_water_quality_indices( - training_csv_path=training_path, - formula_csv_file=formula_csv_path, - formula_names=formula_names, - output_file=None, # 不在此处指定输出,由下面的双轨输出逻辑接管 - enabled=True, - output_dir=work_dir if work_dir else os.getcwd(), + if not self.glint_mask_file.get_path(): + QMessageBox.warning( + self, + "输入错误", + "独立运行光谱特征提取时,必须选择耀斑掩膜文件。\n\n" + "请提供与去耀斑影像对应的耀斑二值掩膜(一般为步骤2输出的 severe_glint_area.dat)。", ) + return - # 读取计算结果(宽表) - if indices_csv_path and os.path.exists(indices_csv_path): - output_df = pd.read_csv(indices_csv_path) - else: - output_df = spec_df # fallback - - track_a_path = None - track_b_dir = None - - if output_mode in (0, 1): - track_a_dir = os.path.join(work_dir, "6_water_quality_indices") if work_dir else "6_water_quality_indices" - os.makedirs(track_a_dir, exist_ok=True) - track_a_path = os.path.join(track_a_dir, "training_spectra_indices.csv") - - if output_mode in (0, 2): - track_b_dir = os.path.join(work_dir, "11_12_13_predictions", "Traditional_Indices") if work_dir else "11_12_13_predictions/Traditional_Indices" - os.makedirs(track_b_dir, exist_ok=True) - - saved = [] - if output_mode in (0, 1): - output_df.to_csv(track_a_path, index=False, float_format='%.6f') - saved.append(f"宽表: {track_a_path}") - - if output_mode in (0, 2): - coord_x = spec_df[x_col].values if x_col in spec_df.columns else np.arange(len(spec_df)) - coord_y = spec_df[y_col].values if y_col in spec_df.columns else np.zeros(len(spec_df)) - - for formula_name in formula_names: - if formula_name not in output_df.columns: - continue - single_df = pd.DataFrame({ - 'x_coord': coord_x, - 'y_coord': coord_y, - 'value': output_df[formula_name].values, - }) - safe_name = formula_name.replace('/', '_').replace(' ', '_') - out_path = os.path.join(track_b_dir, f"{safe_name}_prediction.csv") - single_df.to_csv(out_path, index=False, float_format='%.6f') - saved.append(f"单文件目录: {track_b_dir}") - - QMessageBox.information( - self, "计算完成", - f"已保存 {len(saved)} 个输出目标:\n" + "\n".join(saved) - ) - - except ImportError as e: - QMessageBox.critical(self, "依赖错误", f"无法导入模块:\n{e}") - except Exception as e: - import traceback - QMessageBox.critical(self, "计算失败", f"原因: {str(e)}\n{traceback.format_exc()}") \ No newline at end of file + # 获取主窗口并运行步骤 + main_window = self.window() + if hasattr(main_window, 'run_single_step'): + config = {'step5': self.get_config()} + main_window.run_single_step('step5', config) diff --git a/src/gui/panels/step7_panel.py b/src/gui/panels/step7_panel.py index e049fce..30e3564 100644 --- a/src/gui/panels/step7_panel.py +++ b/src/gui/panels/step7_panel.py @@ -1,415 +1,423 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -Step7 面板 - 机器学习建模 -""" - import os +import sys +import pandas as pd +import numpy as np +from pathlib import Path +from typing import Dict, List, Optional, Tuple from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout, - QHBoxLayout, QLabel, QLineEdit, QSpinBox, QCheckBox, - QPushButton, QFileDialog, QMessageBox, + QWidget, QVBoxLayout, QGroupBox, QGridLayout, + QHBoxLayout, QLabel, QCheckBox, QPushButton, QMessageBox, + QScrollArea, QListWidget, QListWidgetItem, QAbstractItemView, + QRadioButton, QButtonGroup ) from PyQt5.QtCore import Qt +from PyQt5.QtGui import QColor, QBrush, QFont from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet -# ============================================================ -# 中文映射表(内部键名 -> 显示文本) -# ============================================================ +def get_resource_path(relative_path: str) -> str: + """适配开发与 PyInstaller 环境的路径获取逻辑。""" + if hasattr(sys, '_MEIPASS'): + internal = os.path.join(sys._MEIPASS, '_internal', relative_path) + if os.path.exists(internal): + return internal + return os.path.join(sys._MEIPASS, relative_path) -# 预处理方法:内部键 -> 显示文本 -PREPROC_CHINESE = { - 'None': '无 (None)', - 'MMS': '最小-最大归一化 (MMS)', - 'SS': '标度化 (SS)', - 'SNV': '标准正态变换 (SNV)', - 'MA': '移动平均 (MA)', - 'SG': 'Savitzky-Golay (SG)', - 'MSC': '多元散射校正 (MSC)', - 'D1': '一阶导数 (D1)', - 'D2': '二阶导数 (D2)', - 'DT': '去趋势 (DT)', - 'CT': '中心化 (CT)', -} + exe_dir = os.path.dirname(sys.executable) + internal = os.path.join(exe_dir, '_internal', relative_path) + if os.path.exists(internal): + return internal -# 模型类型:内部键 -> 显示文本 -MODEL_CHINESE = { - # 线性模型 - 'LinearRegression': '多元线性回归 (MLR)', - 'Ridge': '岭回归 (Ridge)', - 'Lasso': '套索回归 (Lasso)', - 'ElasticNet': '弹性网络 (ElasticNet)', - 'PLS': '偏最小二乘 (PLSR)', - # 树模型 - 'DecisionTree': '决策树 (CART)', - 'RF': '随机森林 (RF)', - 'ExtraTrees': '极端随机树 (ET)', - 'XGBoost': '极值梯度提升 (XGBoost)', - 'LightGBM': '轻量梯度提升 (LightGBM)', - 'CatBoost': '类别梯度提升 (CatBoost)', - # 集成学习 - 'GradientBoosting': '梯度提升树 (GBDT)', - 'AdaBoost': '自适应提升 (AdaBoost)', - # 其他模型 - 'SVR': '支持向量回归 (SVR)', - 'KNN': 'K近邻回归 (KNN)', - 'MLP': '多层感知机 (BP神经网络)', -} - -# 数据划分方法:内部键 -> 显示文本 -SPLIT_CHINESE = { - 'spxy': 'SPXY 算法 (考量X-Y空间)', - 'ks': 'KS 算法 (考量X空间)', - 'random': '随机划分 (Random)', -} + base_dir = Path(__file__).resolve().parent.parent / "model" + return str(base_dir / os.path.basename(relative_path)) class Step7Panel(QWidget): - """步骤7:机器学习建模""" + COLOR_RATIO = QColor(255, 255, 255) + COLOR_CONCENTRATION = QColor(220, 240, 255) + COLOR_HEADER = QColor(245, 245, 245) + def __init__(self, parent=None): super().__init__(parent) + self.index_checkboxes: Dict[str, QListWidgetItem] = {} + self.work_dir: Optional[str] = None + self.builtin_formula_path = get_resource_path("waterindex.csv") + self._formula_type_map: Dict[str, str] = {} + self._formula_color_map: Dict[str, QColor] = {} + self._formula_coef_map: Dict[str, List[float]] = {} + self.init_ui() + self._auto_load_formulas() def init_ui(self): - layout = QVBoxLayout() + main_layout = QVBoxLayout() + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(10) - # 标题 + # 1. 公式配置源 (只读) + path_group = QGroupBox("公式配置源 (内置)") + path_layout = QVBoxLayout() + self.formula_csv_widget = FileSelectWidget("内置CSV路径:", "CSV Files (*.csv)") + self.formula_csv_widget.set_path(self.builtin_formula_path) + self.formula_csv_widget.set_read_only(True) + self.formula_csv_widget.line_edit.setStyleSheet("background-color: #f0f0f0; color: #666;") + path_layout.addWidget(self.formula_csv_widget) + path_group.setLayout(path_layout) + main_layout.addWidget(path_group) + # 2. 训练数据输入 + input_group = QGroupBox("输入样本数据") + input_layout = QVBoxLayout() + self.training_data_widget = FileSelectWidget("特征提取CSV:", "CSV Files (*.csv)") + input_layout.addWidget(self.training_data_widget) + input_group.setLayout(input_layout) + main_layout.addWidget(input_group) - # 训练数据文件(用于独立运行) - self.training_csv_file = FileSelectWidget( - "训练数据:", - "CSV Files (*.csv);;All Files (*.*)" - ) - layout.addWidget(self.training_csv_file) + # 3. 公式选择区 (分组 ListWidget) + self.formula_group = QGroupBox("待计算水质指数勾选") + formula_outer_layout = QVBoxLayout() - # 机器学习模型页面 - self.ml_page = QWidget() - self.create_ml_page() - layout.addWidget(self.ml_page) + btn_layout = QHBoxLayout() + self.select_all_btn = QPushButton("全选") + self.deselect_all_btn = QPushButton("清空") + self.select_ratio_btn = QPushButton("仅选比值型") + self.select_conc_btn = QPushButton("仅选浓度型") + self.select_all_btn.clicked.connect(self.select_all_formulas) + self.deselect_all_btn.clicked.connect(self.deselect_all_formulas) + self.select_ratio_btn.clicked.connect(self._select_ratio_only) + self.select_conc_btn.clicked.connect(self._select_conc_only) + btn_layout.addWidget(self.select_all_btn) + btn_layout.addWidget(self.deselect_all_btn) + btn_layout.addWidget(self.select_ratio_btn) + btn_layout.addWidget(self.select_conc_btn) + btn_layout.addStretch() - # 输出文件路径 - self.output_path = FileSelectWidget( - "输出文件:", - "CSV Files (*.csv);;All Files (*.*)", - mode="save" - ) - self.output_path.line_edit.setPlaceholderText("自动生成,或手动指定输出文件路径...") - self.output_path.browse_btn.clicked.disconnect() - self.output_path.browse_btn.clicked.connect(self.browse_output_path) - layout.addWidget(self.output_path) + self.refresh_button = QPushButton("重新加载") + self.refresh_button.clicked.connect(lambda: self.refresh_formulas(silent=False)) + btn_layout.addWidget(self.refresh_button) - # 启用步骤 - self.enable_checkbox = QCheckBox("启用此步骤") - self.enable_checkbox.setChecked(False) - layout.addWidget(self.enable_checkbox) + formula_outer_layout.addLayout(btn_layout) - # 独立运行按钮 - self.run_btn = QPushButton("独立运行此步骤") - self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) - self.run_btn.clicked.connect(self.run_step) - layout.addWidget(self.run_btn) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setMinimumHeight(280) + self.scroll_content = QWidget() + self.formula_layout = QVBoxLayout(self.scroll_content) + self.formula_layout.setContentsMargins(4, 4, 4, 4) + self.formula_layout.setSpacing(2) + self.formula_layout.setAlignment(Qt.AlignTop) - layout.addStretch() - self.setLayout(layout) + self.formula_list = QListWidget() + self.formula_list.setSelectionMode(QAbstractItemView.MultiSelection) + self.formula_list.itemChanged.connect(self._on_item_changed) + self.formula_layout.addWidget(self.formula_list) - def create_ml_page(self): - """创建机器学习模型页面""" - layout = QVBoxLayout() + scroll.setWidget(self.scroll_content) + formula_outer_layout.addWidget(scroll) - # 参数设置 - params_group = QGroupBox("训练参数") - params_layout = QFormLayout() + self.formula_group.setLayout(formula_outer_layout) + main_layout.addWidget(self.formula_group) - self.feature_start = QLineEdit() - self.feature_start.setText("374.285004") - params_layout.addRow("特征起始列:", self.feature_start) + # 4. 输出选项 + output_group = QGroupBox("输出模式") + output_layout = QVBoxLayout() - self.cv_folds = QSpinBox() - self.cv_folds.setRange(2, 10) - self.cv_folds.setValue(3) - params_layout.addRow("交叉验证折数:", self.cv_folds) + mode_layout = QHBoxLayout() + self.mode_group = QButtonGroup() + self.radio_both = QRadioButton("两者皆出") + self.radio_wide = QRadioButton("仅宽表") + self.radio_single = QRadioButton("仅单文件") + self.mode_group.addButton(self.radio_both, 0) + self.mode_group.addButton(self.radio_wide, 1) + self.mode_group.addButton(self.radio_single, 2) + self.radio_both.setChecked(True) + mode_layout.addWidget(self.radio_both) + mode_layout.addWidget(self.radio_wide) + mode_layout.addWidget(self.radio_single) + mode_layout.addStretch() + output_layout.addLayout(mode_layout) - params_group.setLayout(params_layout) - layout.addWidget(params_group) + self.enable_checkbox = QCheckBox("启用计算流程") + self.enable_checkbox.setChecked(True) + output_layout.addWidget(self.enable_checkbox) - # 预处理方法 - 多选 - preproc_group = QGroupBox("预处理方法 (可多选)") - preproc_layout = QVBoxLayout() + output_group.setLayout(output_layout) + main_layout.addWidget(output_group) - preproc_grid = QGridLayout() - self.preproc_checkboxes = {} - preproc_methods = ['None', 'MMS', 'SS', 'SNV', 'MA', 'SG', 'MSC', 'D1', 'D2', 'DT', 'CT'] + # 5. 运行按钮 + self.run_button = QPushButton("立即执行计算") + self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) + self.run_button.setMinimumHeight(40) + self.run_button.clicked.connect(self.run_step) + main_layout.addWidget(self.run_button) - for i, method in enumerate(preproc_methods): - checkbox = QCheckBox(PREPROC_CHINESE.get(method, method)) - checkbox.setChecked(False) - self.preproc_checkboxes[method] = checkbox - preproc_grid.addWidget(checkbox, i // 4, i % 4) + self.setLayout(main_layout) - button_layout = QHBoxLayout() - select_all_btn = QPushButton("全选") - deselect_all_btn = QPushButton("全不选") - select_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, True)) - deselect_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, False)) - button_layout.addWidget(select_all_btn) - button_layout.addWidget(deselect_all_btn) - button_layout.addStretch() - - preproc_layout.addLayout(preproc_grid) - preproc_layout.addLayout(button_layout) - preproc_group.setLayout(preproc_layout) - layout.addWidget(preproc_group) - - # 模型选择 - 多选 - model_group = QGroupBox("模型类型 (可多选)") - model_layout = QVBoxLayout() - - model_grid = QGridLayout() - self.model_checkboxes = {} - - model_groups = [ - ("【线性模型】", ['LinearRegression', 'Ridge', 'Lasso', 'ElasticNet', 'PLS']), - ("【树模型】", ['DecisionTree', 'RF', 'ExtraTrees', 'XGBoost', 'LightGBM', 'CatBoost']), - ("【集成学习】", ['GradientBoosting', 'AdaBoost']), - ("【其他模型】", ['SVR', 'KNN', 'MLP']) - ] - - row = 0 - for group_name, models in model_groups: - group_label = QLabel(f"{group_name}") - group_label.setStyleSheet( - f"background-color: {ModernStylesheet.COLORS['hover']}; " - f"padding: 5px; border: 1px solid {ModernStylesheet.COLORS['border_light']}; " - f"border-radius: 3px;" - ) - model_grid.addWidget(group_label, row, 0, 1, 4) - row += 1 - - for i, model in enumerate(models): - checkbox = QCheckBox(MODEL_CHINESE.get(model, model)) - checkbox.setChecked(False) - self.model_checkboxes[model] = checkbox - model_grid.addWidget(checkbox, row, i % 4) - if (i + 1) % 4 == 0: - row += 1 - - row += 1 - - model_button_layout = QHBoxLayout() - model_select_all = QPushButton("全选") - model_deselect_all = QPushButton("全不选") - model_select_all.clicked.connect(lambda: self._toggle_checkboxes(self.model_checkboxes, True)) - model_deselect_all.clicked.connect(lambda: self._toggle_checkboxes(self.model_checkboxes, False)) - model_button_layout.addWidget(model_select_all) - model_button_layout.addWidget(model_deselect_all) - model_button_layout.addStretch() - - model_layout.addLayout(model_grid) - model_layout.addLayout(model_button_layout) - model_group.setLayout(model_layout) - layout.addWidget(model_group) - - # 数据划分方法 - 多选 - split_group = QGroupBox("数据划分方法 (可多选)") - split_layout = QVBoxLayout() - - split_grid = QGridLayout() - self.split_checkboxes = {} - split_methods = ['spxy', 'ks', 'random'] - - for i, method in enumerate(split_methods): - checkbox = QCheckBox(SPLIT_CHINESE.get(method, method)) - checkbox.setChecked(False) - self.split_checkboxes[method] = checkbox - split_grid.addWidget(checkbox, 0, i) - - split_button_layout = QHBoxLayout() - split_select_all = QPushButton("全选") - split_deselect_all = QPushButton("全不选") - split_select_all.clicked.connect(lambda: self._toggle_checkboxes(self.split_checkboxes, True)) - split_deselect_all.clicked.connect(lambda: self._toggle_checkboxes(self.split_checkboxes, False)) - split_button_layout.addWidget(split_select_all) - split_button_layout.addWidget(split_deselect_all) - split_button_layout.addStretch() - - split_layout.addLayout(split_grid) - split_layout.addLayout(split_button_layout) - split_group.setLayout(split_layout) - layout.addWidget(split_group) - - self.ml_page.setLayout(layout) - - def _toggle_checkboxes(self, checkboxes_dict, checked): - """统一设置checkbox状态""" - for checkbox in checkboxes_dict.values(): - checkbox.setChecked(checked) - - def _get_default_work_dir(self): - """获取 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) + def _on_item_changed(self, item: QListWidgetItem): + if item.checkState() == Qt.Checked: + bg_color = self.COLOR_RATIO + for name, ref_item in self.index_checkboxes.items(): + if ref_item is item: + bg_color = self._formula_color_map.get(name, self.COLOR_RATIO) + break + item.setBackground(QBrush(bg_color)) else: - initial_dir = "" - initial_file = "" + item.setBackground(QBrush(self.COLOR_RATIO)) - if not initial_dir or not os.path.isdir(initial_dir): - # 默认定位到 indices 目录 - work_dir = self._get_default_work_dir() - initial_dir = os.path.join(work_dir, "6_water_quality_indices") 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): - """获取配置""" - preprocessing_methods = [ - method for method, checkbox in self.preproc_checkboxes.items() - if checkbox.isChecked() - ] - model_names = [ - model for model, checkbox in self.model_checkboxes.items() - if checkbox.isChecked() - ] - split_methods = [ - method for method, checkbox in self.split_checkboxes.items() - if checkbox.isChecked() - ] - - config = { - 'feature_start_column': self.feature_start.text(), - 'preprocessing_methods': preprocessing_methods if preprocessing_methods else ['None'], - 'model_names': model_names if model_names else ['SVR'], - 'split_methods': split_methods if split_methods else ['random'], - 'cv_folds': self.cv_folds.value() - } - training_csv_path = self.training_csv_file.get_path() - if training_csv_path: - config['training_csv_path'] = training_csv_path - output_path = self.output_path.get_path() - if output_path: - config['output_path'] = output_path - return config - - def set_config(self, config): - """设置配置""" - if 'feature_start_column' in config: - self.feature_start.setText(str(config['feature_start_column'])) - if 'cv_folds' in config: - self.cv_folds.setValue(config['cv_folds']) - if 'preprocessing_methods' in config: - methods = config['preprocessing_methods'] - for method, checkbox in self.preproc_checkboxes.items(): - checkbox.setChecked(method in methods) - if 'model_names' in config: - models = config['model_names'] - for model, checkbox in self.model_checkboxes.items(): - checkbox.setChecked(model in models) - if 'split_methods' in config: - methods = config['split_methods'] - for method, checkbox in self.split_checkboxes.items(): - checkbox.setChecked(method in methods) - if 'training_csv_path' in config: - self.training_csv_file.set_path(config['training_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): - """从全局配置自动填充训练数据和输出路径 - - Args: - work_dir: 工作目录路径 - pipeline: Pipeline 实例(未使用,保留接口兼容性) - """ - if work_dir: - self.work_dir = work_dir - elif hasattr(self, 'work_dir') and self.work_dir: - pass + def _auto_load_formulas(self): + if os.path.exists(self.builtin_formula_path): + self.refresh_formulas(silent=True) else: - self.work_dir = None + print(f"DEBUG: 自动加载失败,路径不存在: {self.builtin_formula_path}") - # 1. 尝试从 Step5 界面读取训练数据路径,并确保为绝对路径 - main_window = self.window() - if hasattr(main_window, 'step5_panel'): - # 优先直接从 Step5 的输出 widget 读取 - step5_output = main_window.step5_panel.output_file.get_path() - if step5_output: - # 若为相对路径,使用 work_dir 合成为绝对路径 - if not os.path.isabs(step5_output): - step5_output = os.path.join(self.work_dir or '', step5_output).replace('\\', '/') - self.training_csv_file.set_path(step5_output) - elif hasattr(main_window, 'step5_panel') and hasattr(main_window.step5_panel, 'get_config'): - # 回退:从 Step5 的 config 字典中查找可能的键名 - step5_cfg = main_window.step5_panel.get_config() - step5_csv = ( - step5_cfg.get('training_csv_path') - or step5_cfg.get('output_file') - or step5_cfg.get('csv_path') - or step5_cfg.get('output_csv') - ) - if step5_csv: - # 若为相对路径,使用 work_dir 合成为绝对路径 - if not os.path.isabs(step5_csv): - step5_csv = os.path.join(self.work_dir or '', step5_csv).replace('\\', '/') - self.training_csv_file.set_path(step5_csv) - - # 2. 自动填充输出文件路径(基于工作目录和输入文件名) - # 输入是 training_spectra.csv → 输出 {work_dir}/6_water_quality_indices/training_spectra_indices.csv - # 输入是 sampling_spectra.csv → 输出 {work_dir}/6_water_quality_indices/sampling_spectra_indices.csv - if self.work_dir: - indices_dir = os.path.join(self.work_dir, "6_water_quality_indices") - os.makedirs(indices_dir, exist_ok=True) - training_csv = self.training_csv_file.get_path() - if training_csv: - basename = os.path.splitext(os.path.basename(training_csv))[0] - output_file = f"{basename}_indices.csv" - else: - output_file = "water_quality_indices.csv" - output_path = os.path.join(indices_dir, output_file).replace('\\', '/') - self.output_path.set_path(output_path) - else: - self.output_path.set_path("") - - def run_step(self): - """独立运行步骤7""" - training_csv_path = self.training_csv_file.get_path() - if not training_csv_path: - QMessageBox.warning(self, "输入错误", "请选择训练数据CSV文件!") + def refresh_formulas(self, silent=False): + path = self.builtin_formula_path + if not os.path.exists(path): + if not silent: + QMessageBox.warning(self, "错误", f"找不到内置公式文件:\n{path}") return - main_window = self.window() - if hasattr(main_window, 'run_single_step'): - config = {'step7': self.get_config()} - main_window.run_single_step('step7', config) + try: + df = None + for enc in ('utf-8', 'gbk', 'utf-8-sig'): + try: + df = pd.read_csv(path, encoding=enc) + if 'Formula_Name' in df.columns: + break + except Exception: + continue - def get_training_params(self): - """获取模型训练参数""" - return { - 'pipeline_type': 'machine_learning', - 'feature_start': float(self.feature_start.text()), - 'cv_folds': self.cv_folds.value(), - 'preprocess_methods': [method for method, cb in self.preproc_checkboxes.items() if cb.isChecked()], - 'model_types': [model for model, cb in self.model_checkboxes.items() if cb.isChecked()], - 'split_methods': [method for method, cb in self.split_checkboxes.items() if cb.isChecked()] + if df is None or 'Formula_Name' not in df.columns: + if not silent: + QMessageBox.critical(self, "错误", "CSV缺少 'Formula_Name' 列") + return + + self._formula_type_map.clear() + self._formula_coef_map.clear() + for _, row in df.iterrows(): + name = str(row['Formula_Name']).strip() + if not name: + continue + ftype = str(row.get('Formula_Type', 'ratio')).strip().lower() + self._formula_type_map[name] = ftype + + # Parse Coefficient for concentration formulas + coef_str = str(row.get('Coefficient', '')).strip() + if coef_str: + try: + coeffs = [float(c.strip()) for c in coef_str.split(',') if c.strip()] + self._formula_coef_map[name] = coeffs + except Exception: + self._formula_coef_map[name] = [] + else: + self._formula_coef_map[name] = [] + + self.formula_list.clear() + self.index_checkboxes.clear() + + self._formula_color_map.clear() + for name, ftype in self._formula_type_map.items(): + item = QListWidgetItem(name, self.formula_list) + item.setCheckState(Qt.Checked) + if ftype == 'concentration': + bg_color = QColor(220, 240, 255) + else: + bg_color = self.COLOR_RATIO + self._formula_color_map[name] = bg_color + item.setBackground(QBrush(bg_color)) + self.index_checkboxes[name] = item + + self.formula_list.adjustSize() + print(f"✅ 加载 {len(self.index_checkboxes)} 个公式") + + except Exception as e: + if not silent: + QMessageBox.critical(self, "加载失败", f"原因: {str(e)}") + + def _select_ratio_only(self): + for name, item in self.index_checkboxes.items(): + ftype = self._formula_type_map.get(name, 'ratio') + item.setCheckState(Qt.Checked if ftype == 'ratio' else Qt.Unchecked) + + def _select_conc_only(self): + for name, item in self.index_checkboxes.items(): + ftype = self._formula_type_map.get(name, 'ratio') + item.setCheckState(Qt.Checked if ftype == 'concentration' else Qt.Unchecked) + + def select_all_formulas(self): + for item in self.index_checkboxes.values(): + item.setCheckState(Qt.Checked) + + def deselect_all_formulas(self): + for item in self.index_checkboxes.values(): + item.setCheckState(Qt.Unchecked) + + def get_config(self) -> Dict: + selected = [ + name for name, item in self.index_checkboxes.items() + if item.checkState() == Qt.Checked + ] + # Build coefficient dict for selected formulas + formula_coefficients = { + name: self._formula_coef_map.get(name, []) + for name in selected } + return { + 'training_csv_path': self.training_data_widget.get_path(), + 'formula_csv_file': self.builtin_formula_path, + 'formula_names': selected, + 'formula_coefficients': formula_coefficients, + 'enabled': self.enable_checkbox.isChecked(), + 'output_mode': self.mode_group.checkedId(), + } + + def set_config(self, config: Dict): + if 'training_csv_path' in config: + self.training_data_widget.set_path(config['training_csv_path']) + if 'formula_names' in config: + sel = set(config['formula_names']) + for name, item in self.index_checkboxes.items(): + item.setCheckState(Qt.Checked if name in sel else Qt.Unchecked) + self.enable_checkbox.setChecked(config.get('enabled', True)) + if 'output_mode' in config: + btn = self.mode_group.button(config['output_mode']) + if btn: + btn.setChecked(True) + + def update_from_config(self, work_dir=None, pipeline=None): + if work_dir: + self.work_dir = work_dir + main = self.window() + if hasattr(main, 'step6_panel'): + p5 = main.step6_panel.output_file.get_path() + if p5: + if not os.path.isabs(p5): + p5 = os.path.join(self.work_dir or '', p5) + p5 = p5.replace('\\', '/') + self.training_data_widget.set_path(p5) + + def _get_work_dir(self) -> Optional[str]: + if self.work_dir: + return self.work_dir + main = self.window() + if hasattr(main, 'work_dir') and main.work_dir: + return main.work_dir + return None + + def _get_coord_cols(self, df: pd.DataFrame) -> Tuple[str, str]: + coord_candidates = ['lon', 'lng', 'longitude', '经度', 'x', 'lon_utm', 'utm_x', 'pixel_x'] + lat_candidates = ['lat', 'latitude', '纬度', 'y', 'lat_utm', 'utm_y', 'pixel_y'] + + x_col, y_col = None, None + for col in df.columns: + cl = col.lower() + if x_col is None and any(c in cl for c in coord_candidates): + x_col = col + if y_col is None and any(c in cl for c in lat_candidates): + y_col = col + + if x_col is None and len(df.columns) >= 2: + x_col = df.columns[0] + if y_col is None and len(df.columns) >= 2: + y_col = df.columns[1] + + return x_col or 'x_coord', y_col or 'y_coord' + + def run_step(self): + config = self.get_config() + + if not config['enabled']: + QMessageBox.information(self, "提示", "已禁用计算流程(启用计算流程未勾选)") + return + + training_path = config['training_csv_path'] + if not training_path or not os.path.exists(training_path): + QMessageBox.warning(self, "提示", "请先选择输入特征提取CSV文件") + return + + formula_names = config['formula_names'] + if not formula_names: + QMessageBox.warning(self, "提示", "请至少勾选一个公式") + return + + output_mode = config['output_mode'] + + try: + from src.core.steps.data_preparation_step import DataPreparationStep + + spec_df = pd.read_csv(training_path) + x_col, y_col = self._get_coord_cols(spec_df) + + # 构建 formula_csv_path(使用内置 waterindex.csv) + formula_csv_path = self.builtin_formula_path + if not formula_csv_path or not os.path.exists(formula_csv_path): + # 尝试从 src/gui/model/ 目录找 + possible_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'gui', 'model', 'waterindex.csv') + if os.path.exists(possible_path): + formula_csv_path = possible_path + + work_dir = self._get_work_dir() + + # 调用 DataPreparationStep 的静态方法计算水质指数(宽表输出) + indices_csv_path = DataPreparationStep.calculate_water_quality_indices( + training_csv_path=training_path, + formula_csv_file=formula_csv_path, + formula_names=formula_names, + output_file=None, # 不在此处指定输出,由下面的双轨输出逻辑接管 + enabled=True, + output_dir=work_dir if work_dir else os.getcwd(), + ) + + # 读取计算结果(宽表) + if indices_csv_path and os.path.exists(indices_csv_path): + output_df = pd.read_csv(indices_csv_path) + else: + output_df = spec_df # fallback + + track_a_path = None + track_b_dir = None + + if output_mode in (0, 1): + track_a_dir = os.path.join(work_dir, "6_water_quality_indices") if work_dir else "6_water_quality_indices" + os.makedirs(track_a_dir, exist_ok=True) + track_a_path = os.path.join(track_a_dir, "training_spectra_indices.csv") + + if output_mode in (0, 2): + track_b_dir = os.path.join(work_dir, "11_12_13_predictions", "Traditional_Indices") if work_dir else "11_12_13_predictions/Traditional_Indices" + os.makedirs(track_b_dir, exist_ok=True) + + saved = [] + if output_mode in (0, 1): + output_df.to_csv(track_a_path, index=False, float_format='%.6f') + saved.append(f"宽表: {track_a_path}") + + if output_mode in (0, 2): + coord_x = spec_df[x_col].values if x_col in spec_df.columns else np.arange(len(spec_df)) + coord_y = spec_df[y_col].values if y_col in spec_df.columns else np.zeros(len(spec_df)) + + for formula_name in formula_names: + if formula_name not in output_df.columns: + continue + single_df = pd.DataFrame({ + 'x_coord': coord_x, + 'y_coord': coord_y, + 'value': output_df[formula_name].values, + }) + safe_name = formula_name.replace('/', '_').replace(' ', '_') + out_path = os.path.join(track_b_dir, f"{safe_name}_prediction.csv") + single_df.to_csv(out_path, index=False, float_format='%.6f') + saved.append(f"单文件目录: {track_b_dir}") + + QMessageBox.information( + self, "计算完成", + f"已保存 {len(saved)} 个输出目标:\n" + "\n".join(saved) + ) + + except ImportError as e: + QMessageBox.critical(self, "依赖错误", f"无法导入模块:\n{e}") + except Exception as e: + import traceback + QMessageBox.critical(self, "计算失败", f"原因: {str(e)}\n{traceback.format_exc()}") \ No newline at end of file diff --git a/src/gui/panels/step8_panel.py b/src/gui/panels/step8_panel.py index 092f59b..e4932ba 100644 --- a/src/gui/panels/step8_panel.py +++ b/src/gui/panels/step8_panel.py @@ -1,424 +1,415 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Step7 面板 - 机器学习建模 +""" + import os -import sys -import pandas as pd -import numpy as np -from pathlib import Path -from typing import Dict, List, Optional, Tuple from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QGroupBox, QGridLayout, - QHBoxLayout, QLabel, QCheckBox, QPushButton, QMessageBox, - QScrollArea, QListWidget, QListWidgetItem, QAbstractItemView, - QRadioButton, QButtonGroup + QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout, + QHBoxLayout, QLabel, QLineEdit, QSpinBox, QCheckBox, + QPushButton, QFileDialog, QMessageBox, ) from PyQt5.QtCore import Qt -from PyQt5.QtGui import QColor, QBrush, QFont from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet -def get_resource_path(relative_path: str) -> str: - """适配开发与 PyInstaller 环境的路径获取逻辑。""" - if hasattr(sys, '_MEIPASS'): - internal = os.path.join(sys._MEIPASS, '_internal', relative_path) - if os.path.exists(internal): - return internal - return os.path.join(sys._MEIPASS, relative_path) +# ============================================================ +# 中文映射表(内部键名 -> 显示文本) +# ============================================================ - exe_dir = os.path.dirname(sys.executable) - internal = os.path.join(exe_dir, '_internal', relative_path) - if os.path.exists(internal): - return internal +# 预处理方法:内部键 -> 显示文本 +PREPROC_CHINESE = { + 'None': '无 (None)', + 'MMS': '最小-最大归一化 (MMS)', + 'SS': '标度化 (SS)', + 'SNV': '标准正态变换 (SNV)', + 'MA': '移动平均 (MA)', + 'SG': 'Savitzky-Golay (SG)', + 'MSC': '多元散射校正 (MSC)', + 'D1': '一阶导数 (D1)', + 'D2': '二阶导数 (D2)', + 'DT': '去趋势 (DT)', + 'CT': '中心化 (CT)', +} - base_dir = Path(__file__).resolve().parent.parent / "model" - return str(base_dir / os.path.basename(relative_path)) +# 模型类型:内部键 -> 显示文本 +MODEL_CHINESE = { + # 线性模型 + 'LinearRegression': '多元线性回归 (MLR)', + 'Ridge': '岭回归 (Ridge)', + 'Lasso': '套索回归 (Lasso)', + 'ElasticNet': '弹性网络 (ElasticNet)', + 'PLS': '偏最小二乘 (PLSR)', + # 树模型 + 'DecisionTree': '决策树 (CART)', + 'RF': '随机森林 (RF)', + 'ExtraTrees': '极端随机树 (ET)', + 'XGBoost': '极值梯度提升 (XGBoost)', + 'LightGBM': '轻量梯度提升 (LightGBM)', + 'CatBoost': '类别梯度提升 (CatBoost)', + # 集成学习 + 'GradientBoosting': '梯度提升树 (GBDT)', + 'AdaBoost': '自适应提升 (AdaBoost)', + # 其他模型 + 'SVR': '支持向量回归 (SVR)', + 'KNN': 'K近邻回归 (KNN)', + 'MLP': '多层感知机 (BP神经网络)', +} + +# 数据划分方法:内部键 -> 显示文本 +SPLIT_CHINESE = { + 'spxy': 'SPXY 算法 (考量X-Y空间)', + 'ks': 'KS 算法 (考量X空间)', + 'random': '随机划分 (Random)', +} class Step8Panel(QWidget): - COLOR_RATIO = QColor(255, 255, 255) - COLOR_CONCENTRATION = QColor(220, 240, 255) - COLOR_HEADER = QColor(245, 245, 245) - + """步骤8:水质参数指数计算""" def __init__(self, parent=None): super().__init__(parent) - self.index_checkboxes: Dict[str, QListWidgetItem] = {} - self.work_dir: Optional[str] = None - self.builtin_formula_path = get_resource_path("waterindex.csv") - self._formula_type_map: Dict[str, str] = {} - self._formula_color_map: Dict[str, QColor] = {} - self._formula_coef_map: Dict[str, List[float]] = {} - self.init_ui() - self._auto_load_formulas() def init_ui(self): - main_layout = QVBoxLayout() - main_layout.setContentsMargins(20, 20, 20, 20) - main_layout.setSpacing(10) + layout = QVBoxLayout() - # 1. 公式配置源 (只读) - path_group = QGroupBox("公式配置源 (内置)") - path_layout = QVBoxLayout() - self.formula_csv_widget = FileSelectWidget("内置CSV路径:", "CSV Files (*.csv)") - self.formula_csv_widget.set_path(self.builtin_formula_path) - self.formula_csv_widget.set_read_only(True) - self.formula_csv_widget.line_edit.setStyleSheet("background-color: #f0f0f0; color: #666;") - path_layout.addWidget(self.formula_csv_widget) - path_group.setLayout(path_layout) - main_layout.addWidget(path_group) + # 标题 - # 2. 训练数据输入 - input_group = QGroupBox("输入样本数据") - input_layout = QVBoxLayout() - self.training_data_widget = FileSelectWidget("特征提取CSV:", "CSV Files (*.csv)") - input_layout.addWidget(self.training_data_widget) - input_group.setLayout(input_layout) - main_layout.addWidget(input_group) - # 3. 公式选择区 (分组 ListWidget) - self.formula_group = QGroupBox("待计算水质指数勾选") - formula_outer_layout = QVBoxLayout() + # 训练数据文件(用于独立运行) + self.training_csv_file = FileSelectWidget( + "训练数据:", + "CSV Files (*.csv);;All Files (*.*)" + ) + layout.addWidget(self.training_csv_file) - btn_layout = QHBoxLayout() - self.select_all_btn = QPushButton("全选") - self.deselect_all_btn = QPushButton("清空") - self.select_ratio_btn = QPushButton("仅选比值型") - self.select_conc_btn = QPushButton("仅选浓度型") - self.select_all_btn.clicked.connect(self.select_all_formulas) - self.deselect_all_btn.clicked.connect(self.deselect_all_formulas) - self.select_ratio_btn.clicked.connect(self._select_ratio_only) - self.select_conc_btn.clicked.connect(self._select_conc_only) - btn_layout.addWidget(self.select_all_btn) - btn_layout.addWidget(self.deselect_all_btn) - btn_layout.addWidget(self.select_ratio_btn) - btn_layout.addWidget(self.select_conc_btn) - btn_layout.addStretch() + # 机器学习模型页面 + self.ml_page = QWidget() + self.create_ml_page() + layout.addWidget(self.ml_page) - self.refresh_button = QPushButton("重新加载") - self.refresh_button.clicked.connect(lambda: self.refresh_formulas(silent=False)) - btn_layout.addWidget(self.refresh_button) + # 输出文件路径 + self.output_path = FileSelectWidget( + "输出文件:", + "CSV Files (*.csv);;All Files (*.*)", + mode="save" + ) + self.output_path.line_edit.setPlaceholderText("自动生成,或手动指定输出文件路径...") + self.output_path.browse_btn.clicked.disconnect() + self.output_path.browse_btn.clicked.connect(self.browse_output_path) + layout.addWidget(self.output_path) - formula_outer_layout.addLayout(btn_layout) + # 启用步骤 + self.enable_checkbox = QCheckBox("启用此步骤") + self.enable_checkbox.setChecked(False) + layout.addWidget(self.enable_checkbox) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setMinimumHeight(280) - self.scroll_content = QWidget() - self.formula_layout = QVBoxLayout(self.scroll_content) - self.formula_layout.setContentsMargins(4, 4, 4, 4) - self.formula_layout.setSpacing(2) - self.formula_layout.setAlignment(Qt.AlignTop) + # 独立运行按钮 + self.run_btn = QPushButton("独立运行此步骤") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) + self.run_btn.clicked.connect(self.run_step) + layout.addWidget(self.run_btn) - self.formula_list = QListWidget() - self.formula_list.setSelectionMode(QAbstractItemView.MultiSelection) - self.formula_list.itemChanged.connect(self._on_item_changed) - self.formula_layout.addWidget(self.formula_list) + layout.addStretch() + self.setLayout(layout) - scroll.setWidget(self.scroll_content) - formula_outer_layout.addWidget(scroll) + def create_ml_page(self): + """创建机器学习模型页面""" + layout = QVBoxLayout() - self.formula_group.setLayout(formula_outer_layout) - main_layout.addWidget(self.formula_group) + # 参数设置 + params_group = QGroupBox("训练参数") + params_layout = QFormLayout() - # 4. 输出选项 - output_group = QGroupBox("输出模式") - output_layout = QVBoxLayout() + self.feature_start = QLineEdit() + self.feature_start.setText("374.285004") + params_layout.addRow("特征起始列:", self.feature_start) - mode_layout = QHBoxLayout() - self.mode_group = QButtonGroup() - self.radio_both = QRadioButton("两者皆出") - self.radio_wide = QRadioButton("仅宽表") - self.radio_single = QRadioButton("仅单文件") - self.mode_group.addButton(self.radio_both, 0) - self.mode_group.addButton(self.radio_wide, 1) - self.mode_group.addButton(self.radio_single, 2) - self.radio_both.setChecked(True) - mode_layout.addWidget(self.radio_both) - mode_layout.addWidget(self.radio_wide) - mode_layout.addWidget(self.radio_single) - mode_layout.addStretch() - output_layout.addLayout(mode_layout) + self.cv_folds = QSpinBox() + self.cv_folds.setRange(2, 10) + self.cv_folds.setValue(3) + params_layout.addRow("交叉验证折数:", self.cv_folds) - self.enable_checkbox = QCheckBox("启用计算流程") - self.enable_checkbox.setChecked(True) - output_layout.addWidget(self.enable_checkbox) + params_group.setLayout(params_layout) + layout.addWidget(params_group) - output_group.setLayout(output_layout) - main_layout.addWidget(output_group) + # 预处理方法 - 多选 + preproc_group = QGroupBox("预处理方法 (可多选)") + preproc_layout = QVBoxLayout() - # 5. 运行按钮 - self.run_button = QPushButton("立即执行计算") - self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) - self.run_button.setMinimumHeight(40) - self.run_button.clicked.connect(self.run_step) - main_layout.addWidget(self.run_button) + preproc_grid = QGridLayout() + self.preproc_checkboxes = {} + preproc_methods = ['None', 'MMS', 'SS', 'SNV', 'MA', 'SG', 'MSC', 'D1', 'D2', 'DT', 'CT'] - self.setLayout(main_layout) + for i, method in enumerate(preproc_methods): + checkbox = QCheckBox(PREPROC_CHINESE.get(method, method)) + checkbox.setChecked(False) + self.preproc_checkboxes[method] = checkbox + preproc_grid.addWidget(checkbox, i // 4, i % 4) - def _on_item_changed(self, item: QListWidgetItem): - if item.checkState() == Qt.Checked: - bg_color = self.COLOR_RATIO - for name, ref_item in self.index_checkboxes.items(): - if ref_item is item: - bg_color = self._formula_color_map.get(name, self.COLOR_RATIO) - break - item.setBackground(QBrush(bg_color)) - else: - item.setBackground(QBrush(self.COLOR_RATIO)) + button_layout = QHBoxLayout() + select_all_btn = QPushButton("全选") + deselect_all_btn = QPushButton("全不选") + select_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, True)) + deselect_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, False)) + button_layout.addWidget(select_all_btn) + button_layout.addWidget(deselect_all_btn) + button_layout.addStretch() - def _auto_load_formulas(self): - if os.path.exists(self.builtin_formula_path): - self.refresh_formulas(silent=True) - else: - print(f"DEBUG: 自动加载失败,路径不存在: {self.builtin_formula_path}") + preproc_layout.addLayout(preproc_grid) + preproc_layout.addLayout(button_layout) + preproc_group.setLayout(preproc_layout) + layout.addWidget(preproc_group) - def refresh_formulas(self, silent=False): - path = self.builtin_formula_path - if not os.path.exists(path): - if not silent: - QMessageBox.warning(self, "错误", f"找不到内置公式文件:\n{path}") - return + # 模型选择 - 多选 + model_group = QGroupBox("模型类型 (可多选)") + model_layout = QVBoxLayout() - try: - df = None - for enc in ('utf-8', 'gbk', 'utf-8-sig'): - try: - df = pd.read_csv(path, encoding=enc) - if 'Formula_Name' in df.columns: - break - except Exception: - continue + model_grid = QGridLayout() + self.model_checkboxes = {} - if df is None or 'Formula_Name' not in df.columns: - if not silent: - QMessageBox.critical(self, "错误", "CSV缺少 'Formula_Name' 列") - return - - self._formula_type_map.clear() - self._formula_coef_map.clear() - for _, row in df.iterrows(): - name = str(row['Formula_Name']).strip() - if not name: - continue - ftype = str(row.get('Formula_Type', 'ratio')).strip().lower() - self._formula_type_map[name] = ftype - - # Parse Coefficient for concentration formulas - coef_str = str(row.get('Coefficient', '')).strip() - if coef_str: - try: - coeffs = [float(c.strip()) for c in coef_str.split(',') if c.strip()] - self._formula_coef_map[name] = coeffs - except Exception: - self._formula_coef_map[name] = [] - else: - self._formula_coef_map[name] = [] - - self.formula_list.clear() - self.index_checkboxes.clear() - - self._formula_color_map.clear() - for name, ftype in self._formula_type_map.items(): - item = QListWidgetItem(name, self.formula_list) - item.setCheckState(Qt.Checked) - if ftype == 'concentration': - bg_color = QColor(220, 240, 255) - else: - bg_color = self.COLOR_RATIO - self._formula_color_map[name] = bg_color - item.setBackground(QBrush(bg_color)) - self.index_checkboxes[name] = item - - self.formula_list.adjustSize() - print(f"✅ 加载 {len(self.index_checkboxes)} 个公式") - - except Exception as e: - if not silent: - QMessageBox.critical(self, "加载失败", f"原因: {str(e)}") - - def _select_ratio_only(self): - for name, item in self.index_checkboxes.items(): - ftype = self._formula_type_map.get(name, 'ratio') - item.setCheckState(Qt.Checked if ftype == 'ratio' else Qt.Unchecked) - - def _select_conc_only(self): - for name, item in self.index_checkboxes.items(): - ftype = self._formula_type_map.get(name, 'ratio') - item.setCheckState(Qt.Checked if ftype == 'concentration' else Qt.Unchecked) - - def select_all_formulas(self): - for item in self.index_checkboxes.values(): - item.setCheckState(Qt.Checked) - - def deselect_all_formulas(self): - for item in self.index_checkboxes.values(): - item.setCheckState(Qt.Unchecked) - - def get_config(self) -> Dict: - selected = [ - name for name, item in self.index_checkboxes.items() - if item.checkState() == Qt.Checked + model_groups = [ + ("【线性模型】", ['LinearRegression', 'Ridge', 'Lasso', 'ElasticNet', 'PLS']), + ("【树模型】", ['DecisionTree', 'RF', 'ExtraTrees', 'XGBoost', 'LightGBM', 'CatBoost']), + ("【集成学习】", ['GradientBoosting', 'AdaBoost']), + ("【其他模型】", ['SVR', 'KNN', 'MLP']) ] - # Build coefficient dict for selected formulas - formula_coefficients = { - name: self._formula_coef_map.get(name, []) - for name in selected - } - return { - 'training_csv_path': self.training_data_widget.get_path(), - 'formula_csv_file': self.builtin_formula_path, - 'formula_names': selected, - 'formula_coefficients': formula_coefficients, - 'enabled': self.enable_checkbox.isChecked(), - 'output_mode': self.mode_group.checkedId(), - } - def set_config(self, config: Dict): + row = 0 + for group_name, models in model_groups: + group_label = QLabel(f"{group_name}") + group_label.setStyleSheet( + f"background-color: {ModernStylesheet.COLORS['hover']}; " + f"padding: 5px; border: 1px solid {ModernStylesheet.COLORS['border_light']}; " + f"border-radius: 3px;" + ) + model_grid.addWidget(group_label, row, 0, 1, 4) + row += 1 + + for i, model in enumerate(models): + checkbox = QCheckBox(MODEL_CHINESE.get(model, model)) + checkbox.setChecked(False) + self.model_checkboxes[model] = checkbox + model_grid.addWidget(checkbox, row, i % 4) + if (i + 1) % 4 == 0: + row += 1 + + row += 1 + + model_button_layout = QHBoxLayout() + model_select_all = QPushButton("全选") + model_deselect_all = QPushButton("全不选") + model_select_all.clicked.connect(lambda: self._toggle_checkboxes(self.model_checkboxes, True)) + model_deselect_all.clicked.connect(lambda: self._toggle_checkboxes(self.model_checkboxes, False)) + model_button_layout.addWidget(model_select_all) + model_button_layout.addWidget(model_deselect_all) + model_button_layout.addStretch() + + model_layout.addLayout(model_grid) + model_layout.addLayout(model_button_layout) + model_group.setLayout(model_layout) + layout.addWidget(model_group) + + # 数据划分方法 - 多选 + split_group = QGroupBox("数据划分方法 (可多选)") + split_layout = QVBoxLayout() + + split_grid = QGridLayout() + self.split_checkboxes = {} + split_methods = ['spxy', 'ks', 'random'] + + for i, method in enumerate(split_methods): + checkbox = QCheckBox(SPLIT_CHINESE.get(method, method)) + checkbox.setChecked(False) + self.split_checkboxes[method] = checkbox + split_grid.addWidget(checkbox, 0, i) + + split_button_layout = QHBoxLayout() + split_select_all = QPushButton("全选") + split_deselect_all = QPushButton("全不选") + split_select_all.clicked.connect(lambda: self._toggle_checkboxes(self.split_checkboxes, True)) + split_deselect_all.clicked.connect(lambda: self._toggle_checkboxes(self.split_checkboxes, False)) + split_button_layout.addWidget(split_select_all) + split_button_layout.addWidget(split_deselect_all) + split_button_layout.addStretch() + + split_layout.addLayout(split_grid) + split_layout.addLayout(split_button_layout) + split_group.setLayout(split_layout) + layout.addWidget(split_group) + + self.ml_page.setLayout(layout) + + def _toggle_checkboxes(self, checkboxes_dict, checked): + """统一设置checkbox状态""" + for checkbox in checkboxes_dict.values(): + checkbox.setChecked(checked) + + def _get_default_work_dir(self): + """获取 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): + # 默认定位到 indices 目录 + work_dir = self._get_default_work_dir() + initial_dir = os.path.join(work_dir, "6_water_quality_indices") 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): + """获取配置""" + preprocessing_methods = [ + method for method, checkbox in self.preproc_checkboxes.items() + if checkbox.isChecked() + ] + model_names = [ + model for model, checkbox in self.model_checkboxes.items() + if checkbox.isChecked() + ] + split_methods = [ + method for method, checkbox in self.split_checkboxes.items() + if checkbox.isChecked() + ] + + config = { + 'feature_start_column': self.feature_start.text(), + 'preprocessing_methods': preprocessing_methods if preprocessing_methods else ['None'], + 'model_names': model_names if model_names else ['SVR'], + 'split_methods': split_methods if split_methods else ['random'], + 'cv_folds': self.cv_folds.value() + } + training_csv_path = self.training_csv_file.get_path() + if training_csv_path: + config['training_csv_path'] = training_csv_path + output_path = self.output_path.get_path() + if output_path: + config['output_path'] = output_path + return config + + def set_config(self, config): + """设置配置""" + if 'feature_start_column' in config: + self.feature_start.setText(str(config['feature_start_column'])) + if 'cv_folds' in config: + self.cv_folds.setValue(config['cv_folds']) + if 'preprocessing_methods' in config: + methods = config['preprocessing_methods'] + for method, checkbox in self.preproc_checkboxes.items(): + checkbox.setChecked(method in methods) + if 'model_names' in config: + models = config['model_names'] + for model, checkbox in self.model_checkboxes.items(): + checkbox.setChecked(model in models) + if 'split_methods' in config: + methods = config['split_methods'] + for method, checkbox in self.split_checkboxes.items(): + checkbox.setChecked(method in methods) if 'training_csv_path' in config: - self.training_data_widget.set_path(config['training_csv_path']) - if 'formula_names' in config: - sel = set(config['formula_names']) - for name, item in self.index_checkboxes.items(): - item.setCheckState(Qt.Checked if name in sel else Qt.Unchecked) - self.enable_checkbox.setChecked(config.get('enabled', True)) - if 'output_mode' in config: - btn = self.mode_group.button(config['output_mode']) - if btn: - btn.setChecked(True) + self.training_csv_file.set_path(config['training_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): + """从全局配置自动填充训练数据和输出路径 + + Args: + work_dir: 工作目录路径 + pipeline: Pipeline 实例(未使用,保留接口兼容性) + """ if work_dir: self.work_dir = work_dir - main = self.window() - if hasattr(main, 'step5_panel'): - p5 = main.step5_panel.output_file.get_path() - if p5: - if not os.path.isabs(p5): - p5 = os.path.join(self.work_dir or '', p5) - p5 = p5.replace('\\', '/') - self.training_data_widget.set_path(p5) + elif hasattr(self, 'work_dir') and self.work_dir: + pass + else: + self.work_dir = None - def _get_work_dir(self) -> Optional[str]: + # 1. 尝试从 Step6 界面读取训练数据路径,并确保为绝对路径 + main_window = self.window() + if hasattr(main_window, 'step6_panel'): + # 优先直接从 Step6 的输出 widget 读取 + step5_output = main_window.step6_panel.output_file.get_path() + if step5_output: + # 若为相对路径,使用 work_dir 合成为绝对路径 + if not os.path.isabs(step5_output): + step5_output = os.path.join(self.work_dir or '', step5_output).replace('\\', '/') + self.training_csv_file.set_path(step5_output) + elif hasattr(main_window, 'step6_panel') and hasattr(main_window.step6_panel, 'get_config'): + # 回退:从 Step6 的 config 字典中查找可能的键名 + step6_cfg = main_window.step6_panel.get_config() + step6_csv = ( + step6_cfg.get('training_csv_path') + or step6_cfg.get('output_file') + or step6_cfg.get('csv_path') + or step6_cfg.get('output_csv') + ) + if step6_csv: + # 若为相对路径,使用 work_dir 合成为绝对路径 + if not os.path.isabs(step6_csv): + step6_csv = os.path.join(self.work_dir or '', step6_csv).replace('\\', '/') + self.training_csv_file.set_path(step6_csv) + + # 2. 自动填充输出文件路径(基于工作目录和输入文件名) + # 输入是 training_spectra.csv → 输出 {work_dir}/6_water_quality_indices/training_spectra_indices.csv + # 输入是 sampling_spectra.csv → 输出 {work_dir}/6_water_quality_indices/sampling_spectra_indices.csv if self.work_dir: - return self.work_dir - main = self.window() - if hasattr(main, 'work_dir') and main.work_dir: - return main.work_dir - return None - - def _get_coord_cols(self, df: pd.DataFrame) -> Tuple[str, str]: - coord_candidates = ['lon', 'lng', 'longitude', '经度', 'x', 'lon_utm', 'utm_x', 'pixel_x'] - lat_candidates = ['lat', 'latitude', '纬度', 'y', 'lat_utm', 'utm_y', 'pixel_y'] - - x_col, y_col = None, None - for col in df.columns: - cl = col.lower() - if x_col is None and any(c in cl for c in coord_candidates): - x_col = col - if y_col is None and any(c in cl for c in lat_candidates): - y_col = col - - if x_col is None and len(df.columns) >= 2: - x_col = df.columns[0] - if y_col is None and len(df.columns) >= 2: - y_col = df.columns[1] - - return x_col or 'x_coord', y_col or 'y_coord' + indices_dir = os.path.join(self.work_dir, "6_water_quality_indices") + os.makedirs(indices_dir, exist_ok=True) + training_csv = self.training_csv_file.get_path() + if training_csv: + basename = os.path.splitext(os.path.basename(training_csv))[0] + output_file = f"{basename}_indices.csv" + else: + output_file = "water_quality_indices.csv" + output_path = os.path.join(indices_dir, output_file).replace('\\', '/') + self.output_path.set_path(output_path) + else: + self.output_path.set_path("") def run_step(self): - config = self.get_config() - - if not config['enabled']: - QMessageBox.information(self, "提示", "已禁用计算流程(启用计算流程未勾选)") + """独立运行步骤7""" + training_csv_path = self.training_csv_file.get_path() + if not training_csv_path: + QMessageBox.warning(self, "输入错误", "请选择训练数据CSV文件!") return - training_path = config['training_csv_path'] - if not training_path or not os.path.exists(training_path): - QMessageBox.warning(self, "提示", "请先选择输入特征提取CSV文件") - return + main_window = self.window() + if hasattr(main_window, 'run_single_step'): + config = {'step7': self.get_config()} + main_window.run_single_step('step7', config) - formula_names = config['formula_names'] - if not formula_names: - QMessageBox.warning(self, "提示", "请至少勾选一个公式") - return - - output_mode = config['output_mode'] - - try: - from src.core.steps.data_preparation_step import DataPreparationStep - - spec_df = pd.read_csv(training_path) - x_col, y_col = self._get_coord_cols(spec_df) - - # 构建 formula_csv_path(使用内置 waterindex.csv) - import os - formula_csv_path = self.builtin_formula_path - if not formula_csv_path or not os.path.exists(formula_csv_path): - # 尝试从 src/gui/model/ 目录找 - possible_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'gui', 'model', 'waterindex.csv') - if os.path.exists(possible_path): - formula_csv_path = possible_path - - work_dir = self._get_work_dir() - - # 调用 DataPreparationStep 的静态方法计算水质指数(宽表输出) - indices_csv_path = DataPreparationStep.calculate_water_quality_indices( - training_csv_path=training_path, - formula_csv_file=formula_csv_path, - formula_names=formula_names, - output_file=None, # 不在此处指定输出,由下面的双轨输出逻辑接管 - enabled=True, - output_dir=work_dir if work_dir else os.getcwd(), - ) - - # 读取计算结果(宽表) - if indices_csv_path and os.path.exists(indices_csv_path): - output_df = pd.read_csv(indices_csv_path) - else: - output_df = spec_df # fallback - - track_a_path = None - track_b_dir = None - - if output_mode in (0, 1): - track_a_dir = os.path.join(work_dir, "6_water_quality_indices") if work_dir else "6_water_quality_indices" - os.makedirs(track_a_dir, exist_ok=True) - track_a_path = os.path.join(track_a_dir, "training_spectra_indices.csv") - - if output_mode in (0, 2): - track_b_dir = os.path.join(work_dir, "11_12_13_predictions", "Traditional_Indices") if work_dir else "11_12_13_predictions/Traditional_Indices" - os.makedirs(track_b_dir, exist_ok=True) - - saved = [] - if output_mode in (0, 1): - output_df.to_csv(track_a_path, index=False, float_format='%.6f') - saved.append(f"宽表: {track_a_path}") - - if output_mode in (0, 2): - coord_x = spec_df[x_col].values if x_col in spec_df.columns else np.arange(len(spec_df)) - coord_y = spec_df[y_col].values if y_col in spec_df.columns else np.zeros(len(spec_df)) - - for formula_name in formula_names: - if formula_name not in output_df.columns: - continue - single_df = pd.DataFrame({ - 'x_coord': coord_x, - 'y_coord': coord_y, - 'value': output_df[formula_name].values, - }) - safe_name = formula_name.replace('/', '_').replace(' ', '_') - out_path = os.path.join(track_b_dir, f"{safe_name}_prediction.csv") - single_df.to_csv(out_path, index=False, float_format='%.6f') - saved.append(f"单文件目录: {track_b_dir}") - - QMessageBox.information( - self, "计算完成", - f"已保存 {len(saved)} 个输出目标:\n" + "\n".join(saved) - ) - - except ImportError as e: - QMessageBox.critical(self, "依赖错误", f"无法导入模块:\n{e}") - except Exception as e: - import traceback - QMessageBox.critical(self, "计算失败", f"原因: {str(e)}\n{traceback.format_exc()}") \ No newline at end of file + def get_training_params(self): + """获取模型训练参数""" + return { + 'pipeline_type': 'machine_learning', + 'feature_start': float(self.feature_start.text()), + 'cv_folds': self.cv_folds.value(), + 'preprocess_methods': [method for method, cb in self.preproc_checkboxes.items() if cb.isChecked()], + 'model_types': [model for model, cb in self.model_checkboxes.items() if cb.isChecked()], + 'split_methods': [method for method, cb in self.split_checkboxes.items() if cb.isChecked()] + } diff --git a/src/gui/panels/step8_waterindex_panel.py b/src/gui/panels/step8_waterindex_panel.py index 55dabdf..c3d8fc7 100644 --- a/src/gui/panels/step8_waterindex_panel.py +++ b/src/gui/panels/step8_waterindex_panel.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Step8 面板 - 水色指数反演(直接处理去耀斑 BSQ 影像) +Step9 面板 - 水色指数反演(直接处理去耀斑 BSQ 影像) 将 waterindex.csv 中的公式直接应用于去耀斑高光谱影像, 输出各水质参数指数的 GeoTIFF 栅格图像。 @@ -98,8 +98,8 @@ class WaterIndexWorker(QThread): self.progress.emit(msg, pct) -class Step8WaterIndexPanel(QWidget): - """步骤8:水色指数反演(直接处理 BSQ 影像)""" +class Step9WaterColorPanel(QWidget): + """步骤9:水色指数反演(直接处理 BSQ 影像)""" def __init__(self, parent=None): super().__init__(parent) @@ -115,7 +115,7 @@ class Step8WaterIndexPanel(QWidget): layout = QVBoxLayout() # ---- 标题 ---- - title = QLabel("步骤8:水色指数反演(高光谱影像直接处理)") + title = QLabel("步骤9:水色指数反演(高光谱影像直接处理)") title.setFont(QFont("Arial", 12, QFont.Bold)) layout.addWidget(title) diff --git a/src/gui/panels/step9_concentration_panel.py b/src/gui/panels/step9_concentration_panel.py index 74f64b7..4d150b2 100644 --- a/src/gui/panels/step9_concentration_panel.py +++ b/src/gui/panels/step9_concentration_panel.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Step9 面板 - 浓度反演(基于 QAA 物理反演的二次反演) +Step10 面板 - 浓度反演(基于 QAA 物理反演的二次反演) """ import os @@ -18,8 +18,8 @@ from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet -class Step9ConcentrationPanel(QWidget): - """步骤9:浓度反演(物理模型二次反演)""" +class Step10ConcentrationPanel(QWidget): + """步骤10:浓度反演(物理模型二次反演)""" def __init__(self, parent=None): super().__init__(parent) self.init_ui() @@ -27,7 +27,7 @@ class Step9ConcentrationPanel(QWidget): def init_ui(self): layout = QVBoxLayout() - title = QLabel("步骤9:浓度反演(物理模型二次反演)") + title = QLabel("步骤10:浓度反演(物理模型二次反演)") title.setFont(QFont("Arial", 12, QFont.Bold)) layout.addWidget(title) diff --git a/src/gui/panels/step9_panel.py b/src/gui/panels/step9_panel.py index c49f158..cc87fc8 100644 --- a/src/gui/panels/step9_panel.py +++ b/src/gui/panels/step9_panel.py @@ -1,400 +1,424 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Step9 面板 - 自定义回归分析 +Step9 面板 - 机器学习建模 """ import os -from typing import Dict - +import sys import pandas as pd +import numpy as np +from pathlib import Path +from typing import Dict, List, Optional, Tuple + from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout, - QHBoxLayout, QLabel, QLineEdit, QCheckBox, QPushButton, - QScrollArea, QMessageBox, + QWidget, QVBoxLayout, QGroupBox, QGridLayout, + QHBoxLayout, QLabel, QCheckBox, QPushButton, QMessageBox, + QScrollArea, QListWidget, QListWidgetItem, QAbstractItemView, + QRadioButton, QButtonGroup ) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QColor, QBrush, QFont from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet +def get_resource_path(relative_path: str) -> str: + """适配开发与 PyInstaller 环境的路径获取逻辑。""" + if hasattr(sys, '_MEIPASS'): + internal = os.path.join(sys._MEIPASS, '_internal', relative_path) + if os.path.exists(internal): + return internal + return os.path.join(sys._MEIPASS, relative_path) + + exe_dir = os.path.dirname(sys.executable) + internal = os.path.join(exe_dir, '_internal', relative_path) + if os.path.exists(internal): + return internal + + base_dir = Path(__file__).resolve().parent.parent / "model" + return str(base_dir / os.path.basename(relative_path)) + + class Step9Panel(QWidget): - """步骤9:自定义回归分析""" + """步骤9:机器学习建模""" + COLOR_RATIO = QColor(255, 255, 255) + COLOR_CONCENTRATION = QColor(220, 240, 255) + COLOR_HEADER = QColor(245, 245, 245) + def __init__(self, parent=None): super().__init__(parent) - self.x_column_checkboxes: Dict[str, QCheckBox] = {} - self.y_column_checkboxes: Dict[str, QCheckBox] = {} - self.method_checkboxes: Dict[str, QCheckBox] = {} - self.csv_columns = [] + self.index_checkboxes: Dict[str, QListWidgetItem] = {} + self.work_dir: Optional[str] = None + self.builtin_formula_path = get_resource_path("waterindex.csv") + self._formula_type_map: Dict[str, str] = {} + self._formula_color_map: Dict[str, QColor] = {} + self._formula_coef_map: Dict[str, List[float]] = {} + self.init_ui() + self._auto_load_formulas() def init_ui(self): - layout = QVBoxLayout() + main_layout = QVBoxLayout() + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(10) - hint = QLabel("指定自变量与因变量列,批量尝试不同回归方法") - hint.setStyleSheet("color: #666; font-size: 11px;") - layout.addWidget(hint) + # 1. 公式配置源 (只读) + path_group = QGroupBox("公式配置源 (内置)") + path_layout = QVBoxLayout() + self.formula_csv_widget = FileSelectWidget("内置CSV路径:", "CSV Files (*.csv)") + self.formula_csv_widget.set_path(self.builtin_formula_path) + self.formula_csv_widget.set_read_only(True) + self.formula_csv_widget.line_edit.setStyleSheet("background-color: #f0f0f0; color: #666;") + path_layout.addWidget(self.formula_csv_widget) + path_group.setLayout(path_layout) + main_layout.addWidget(path_group) - # CSV文件选择 - csv_group = QGroupBox("数据文件") - csv_layout = QVBoxLayout() + # 2. 训练数据输入 + input_group = QGroupBox("输入样本数据") + input_layout = QVBoxLayout() + self.training_data_widget = FileSelectWidget("特征提取CSV:", "CSV Files (*.csv)") + input_layout.addWidget(self.training_data_widget) + input_group.setLayout(input_layout) + main_layout.addWidget(input_group) - self.csv_file = FileSelectWidget( - "输入CSV文件:", - "CSV Files (*.csv);;All Files (*.*)" - ) - self.csv_file.line_edit.textChanged.connect(self.on_csv_file_changed) - csv_layout.addWidget(self.csv_file) + # 3. 公式选择区 (分组 ListWidget) + self.formula_group = QGroupBox("待计算水质指数勾选") + formula_outer_layout = QVBoxLayout() - self.refresh_btn = QPushButton("刷新列信息") - self.refresh_btn.clicked.connect(self.refresh_csv_columns) - csv_layout.addWidget(self.refresh_btn) + btn_layout = QHBoxLayout() + self.select_all_btn = QPushButton("全选") + self.deselect_all_btn = QPushButton("清空") + self.select_ratio_btn = QPushButton("仅选比值型") + self.select_conc_btn = QPushButton("仅选浓度型") + self.select_all_btn.clicked.connect(self.select_all_formulas) + self.deselect_all_btn.clicked.connect(self.deselect_all_formulas) + self.select_ratio_btn.clicked.connect(self._select_ratio_only) + self.select_conc_btn.clicked.connect(self._select_conc_only) + btn_layout.addWidget(self.select_all_btn) + btn_layout.addWidget(self.deselect_all_btn) + btn_layout.addWidget(self.select_ratio_btn) + btn_layout.addWidget(self.select_conc_btn) + btn_layout.addStretch() - csv_group.setLayout(csv_layout) - layout.addWidget(csv_group) + self.refresh_button = QPushButton("重新加载") + self.refresh_button.clicked.connect(lambda: self.refresh_formulas(silent=False)) + btn_layout.addWidget(self.refresh_button) - # 自变量选择 - x_group = QGroupBox("自变量列选择 (可多选)") - x_layout = QVBoxLayout() + formula_outer_layout.addLayout(btn_layout) - x_scroll = QScrollArea() - x_scroll.setWidgetResizable(True) - x_scroll.setMinimumHeight(250) - x_scroll.setMaximumHeight(350) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setMinimumHeight(280) + self.scroll_content = QWidget() + self.formula_layout = QVBoxLayout(self.scroll_content) + self.formula_layout.setContentsMargins(4, 4, 4, 4) + self.formula_layout.setSpacing(2) + self.formula_layout.setAlignment(Qt.AlignTop) - x_widget = QWidget() - self.x_columns_layout = QGridLayout() - x_widget.setLayout(self.x_columns_layout) + self.formula_list = QListWidget() + self.formula_list.setSelectionMode(QAbstractItemView.MultiSelection) + self.formula_list.itemChanged.connect(self._on_item_changed) + self.formula_layout.addWidget(self.formula_list) - x_scroll.setWidget(x_widget) - x_layout.addWidget(x_scroll) + scroll.setWidget(self.scroll_content) + formula_outer_layout.addWidget(scroll) - x_btn_layout = QHBoxLayout() - self.x_select_all = QPushButton("全选") - self.x_deselect_all = QPushButton("全不选") - self.x_select_all.clicked.connect(lambda: self.toggle_checkboxes(self.x_column_checkboxes, True)) - self.x_deselect_all.clicked.connect(lambda: self.toggle_checkboxes(self.x_column_checkboxes, False)) - x_btn_layout.addWidget(self.x_select_all) - x_btn_layout.addWidget(self.x_deselect_all) - x_btn_layout.addStretch() - x_layout.addLayout(x_btn_layout) + self.formula_group.setLayout(formula_outer_layout) + main_layout.addWidget(self.formula_group) - x_group.setLayout(x_layout) - layout.addWidget(x_group) + # 4. 输出选项 + output_group = QGroupBox("输出模式") + output_layout = QVBoxLayout() - # 因变量选择 - y_group = QGroupBox("因变量列选择 (可多选)") - y_layout = QVBoxLayout() + mode_layout = QHBoxLayout() + self.mode_group = QButtonGroup() + self.radio_both = QRadioButton("两者皆出") + self.radio_wide = QRadioButton("仅宽表") + self.radio_single = QRadioButton("仅单文件") + self.mode_group.addButton(self.radio_both, 0) + self.mode_group.addButton(self.radio_wide, 1) + self.mode_group.addButton(self.radio_single, 2) + self.radio_both.setChecked(True) + mode_layout.addWidget(self.radio_both) + mode_layout.addWidget(self.radio_wide) + mode_layout.addWidget(self.radio_single) + mode_layout.addStretch() + output_layout.addLayout(mode_layout) - y_scroll = QScrollArea() - y_scroll.setWidgetResizable(True) - y_scroll.setMinimumHeight(200) - y_scroll.setMaximumHeight(300) - - y_widget = QWidget() - self.y_columns_layout = QGridLayout() - y_widget.setLayout(self.y_columns_layout) - - y_scroll.setWidget(y_widget) - y_layout.addWidget(y_scroll) - - y_btn_layout = QHBoxLayout() - self.y_select_all = QPushButton("全选") - self.y_deselect_all = QPushButton("全不选") - self.y_select_all.clicked.connect(lambda: self.toggle_checkboxes(self.y_column_checkboxes, True)) - self.y_deselect_all.clicked.connect(lambda: self.toggle_checkboxes(self.y_column_checkboxes, False)) - y_btn_layout.addWidget(self.y_select_all) - y_btn_layout.addWidget(self.y_deselect_all) - y_btn_layout.addStretch() - y_layout.addLayout(y_btn_layout) - - y_group.setLayout(y_layout) - layout.addWidget(y_group) - - # 回归方法选择 - method_group = QGroupBox("回归方法选择 (可多选)") - method_layout = QVBoxLayout() - - method_grid = QGridLayout() - regression_methods = [ - 'linear', 'exponential', 'power', 'logarithmic', - 'polynomial', 'hyperbolic', 'sigmoidal' - ] - - for i, method in enumerate(regression_methods): - checkbox = QCheckBox(method) - if method in ['linear', 'exponential', 'power', 'logarithmic']: - checkbox.setChecked(True) - self.method_checkboxes[method] = checkbox - method_grid.addWidget(checkbox, i // 3, i % 3) - - method_layout.addLayout(method_grid) - - method_btn_layout = QHBoxLayout() - self.method_select_all = QPushButton("全选") - self.method_deselect_all = QPushButton("全不选") - self.method_select_all.clicked.connect(lambda: self.toggle_checkboxes(self.method_checkboxes, True)) - self.method_deselect_all.clicked.connect(lambda: self.toggle_checkboxes(self.method_checkboxes, False)) - method_btn_layout.addWidget(self.method_select_all) - method_btn_layout.addWidget(self.method_deselect_all) - method_btn_layout.addStretch() - method_layout.addLayout(method_btn_layout) - - method_group.setLayout(method_layout) - layout.addWidget(method_group) - - # 输出目录 - output_group = QGroupBox("输出设置") - output_layout = QFormLayout() - - self.output_dir = QLineEdit() - self.output_dir.setText("") # 路径由 update_from_config 根据 work_dir 自动填充 - output_layout.addRow("输出目录名:", self.output_dir) + self.enable_checkbox = QCheckBox("启用计算流程") + self.enable_checkbox.setChecked(True) + output_layout.addWidget(self.enable_checkbox) output_group.setLayout(output_layout) - layout.addWidget(output_group) + main_layout.addWidget(output_group) - # 启用步骤 - self.enable_checkbox = QCheckBox("启用此步骤") - self.enable_checkbox.setChecked(True) - layout.addWidget(self.enable_checkbox) - - # 独立运行按钮 - self.run_button = QPushButton("独立运行此步骤") + # 5. 运行按钮 + self.run_button = QPushButton("立即执行计算") self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) + self.run_button.setMinimumHeight(40) self.run_button.clicked.connect(self.run_step) - layout.addWidget(self.run_button) + main_layout.addWidget(self.run_button) - layout.addStretch() - self.setLayout(layout) + self.setLayout(main_layout) - def toggle_checkboxes(self, checkboxes_dict, checked): - """统一设置checkbox状态""" - for checkbox in checkboxes_dict.values(): - checkbox.setChecked(checked) + def _on_item_changed(self, item: QListWidgetItem): + if item.checkState() == Qt.Checked: + bg_color = self.COLOR_RATIO + for name, ref_item in self.index_checkboxes.items(): + if ref_item is item: + bg_color = self._formula_color_map.get(name, self.COLOR_RATIO) + break + item.setBackground(QBrush(bg_color)) + else: + item.setBackground(QBrush(self.COLOR_RATIO)) - def on_csv_file_changed(self): - """CSV文件改变时自动刷新列信息""" - self.refresh_csv_columns() + def _auto_load_formulas(self): + if os.path.exists(self.builtin_formula_path): + self.refresh_formulas(silent=True) + else: + print(f"DEBUG: 自动加载失败,路径不存在: {self.builtin_formula_path}") - def refresh_csv_columns(self): - """刷新CSV文件的列信息""" - csv_path = self.csv_file.get_path() - if not csv_path or not os.path.exists(csv_path): - self.csv_columns = [] - self.update_column_widgets() + def refresh_formulas(self, silent=False): + path = self.builtin_formula_path + if not os.path.exists(path): + if not silent: + QMessageBox.warning(self, "错误", f"找不到内置公式文件:\n{path}") return try: - df = pd.read_csv(csv_path, nrows=0) - self.csv_columns = list(df.columns) - self.update_column_widgets() + df = None + for enc in ('utf-8', 'gbk', 'utf-8-sig'): + try: + df = pd.read_csv(path, encoding=enc) + if 'Formula_Name' in df.columns: + break + except Exception: + continue + + if df is None or 'Formula_Name' not in df.columns: + if not silent: + QMessageBox.critical(self, "错误", "CSV缺少 'Formula_Name' 列") + return + + self._formula_type_map.clear() + self._formula_coef_map.clear() + for _, row in df.iterrows(): + name = str(row['Formula_Name']).strip() + if not name: + continue + ftype = str(row.get('Formula_Type', 'ratio')).strip().lower() + self._formula_type_map[name] = ftype + + coef_str = str(row.get('Coefficient', '')).strip() + if coef_str: + try: + coeffs = [float(c.strip()) for c in coef_str.split(',') if c.strip()] + self._formula_coef_map[name] = coeffs + except Exception: + self._formula_coef_map[name] = [] + else: + self._formula_coef_map[name] = [] + + self.formula_list.clear() + self.index_checkboxes.clear() + + self._formula_color_map.clear() + for name, ftype in self._formula_type_map.items(): + item = QListWidgetItem(name, self.formula_list) + item.setCheckState(Qt.Checked) + if ftype == 'concentration': + bg_color = QColor(220, 240, 255) + else: + bg_color = self.COLOR_RATIO + self._formula_color_map[name] = bg_color + item.setBackground(QBrush(bg_color)) + self.index_checkboxes[name] = item + + self.formula_list.adjustSize() + print(f"✅ 加载 {len(self.index_checkboxes)} 个公式") + except Exception as e: - self.csv_columns = [] - self.update_column_widgets() - print(f"读取CSV列信息失败: {e}") + if not silent: + QMessageBox.critical(self, "加载失败", f"原因: {str(e)}") - def update_column_widgets(self): - """更新列选择组件""" - for checkbox in self.x_column_checkboxes.values(): - checkbox.setParent(None) - self.x_column_checkboxes.clear() + def _select_ratio_only(self): + for name, item in self.index_checkboxes.items(): + ftype = self._formula_type_map.get(name, 'ratio') + item.setCheckState(Qt.Checked if ftype == 'ratio' else Qt.Unchecked) - for checkbox in self.y_column_checkboxes.values(): - checkbox.setParent(None) - self.y_column_checkboxes.clear() + def _select_conc_only(self): + for name, item in self.index_checkboxes.items(): + ftype = self._formula_type_map.get(name, 'ratio') + item.setCheckState(Qt.Checked if ftype == 'concentration' else Qt.Unchecked) - if not self.csv_columns: - return + def select_all_formulas(self): + for item in self.index_checkboxes.values(): + item.setCheckState(Qt.Checked) - for i, col in enumerate(self.csv_columns): - checkbox = QCheckBox(col) - if any(keyword in col.lower() for keyword in ['index', 'ratio', 'normalized', 'nd', 'b']): - checkbox.setChecked(True) - self.x_column_checkboxes[col] = checkbox - self.x_columns_layout.addWidget(checkbox, i // 3, i % 3) + def deselect_all_formulas(self): + for item in self.index_checkboxes.values(): + item.setCheckState(Qt.Unchecked) - for i, col in enumerate(self.csv_columns): - checkbox = QCheckBox(col) - if any(keyword in col.lower() for keyword in ['chl', 'tn', 'tp', 'turbidity', 'do', 'ph', 'conductivity']): - checkbox.setChecked(True) - self.y_column_checkboxes[col] = checkbox - self.y_columns_layout.addWidget(checkbox, i // 2, i % 2) - - self.x_columns_layout.update() - self.y_columns_layout.update() - - def get_config(self): - selected_x_columns = [ - col for col, checkbox in self.x_column_checkboxes.items() - if checkbox.isChecked() + def get_config(self) -> Dict: + selected = [ + name for name, item in self.index_checkboxes.items() + if item.checkState() == Qt.Checked ] - selected_y_columns = [ - col for col, checkbox in self.y_column_checkboxes.items() - if checkbox.isChecked() - ] - selected_methods = [ - method for method, checkbox in self.method_checkboxes.items() - if checkbox.isChecked() - ] - if not selected_methods: - selected_methods = 'all' - + formula_coefficients = { + name: self._formula_coef_map.get(name, []) + for name in selected + } return { - 'csv_path': self.csv_file.get_path() or None, - 'x_columns': selected_x_columns, - 'y_columns': selected_y_columns, - 'methods': selected_methods, - 'output_dir': self.output_dir.text().strip() or None, - 'enabled': self.enable_checkbox.isChecked() + 'training_csv_path': self.training_data_widget.get_path(), + 'formula_csv_file': self.builtin_formula_path, + 'formula_names': selected, + 'formula_coefficients': formula_coefficients, + 'enabled': self.enable_checkbox.isChecked(), + 'output_mode': self.mode_group.checkedId(), } - def set_config(self, config): - if 'csv_path' in config: - self.csv_file.set_path(config['csv_path']) - self.refresh_csv_columns() - - if 'x_columns' in config: - selected_x = set(config['x_columns']) if isinstance(config['x_columns'], list) else set() - for col, checkbox in self.x_column_checkboxes.items(): - checkbox.setChecked(col in selected_x) - - if 'y_columns' in config: - selected_y = set(config['y_columns']) if isinstance(config['y_columns'], list) else set() - for col, checkbox in self.y_column_checkboxes.items(): - checkbox.setChecked(col in selected_y) - - if 'methods' in config: - methods = config['methods'] - if isinstance(methods, list): - selected_methods = set(methods) - elif methods == 'all': - selected_methods = set(self.method_checkboxes.keys()) - else: - selected_methods = set() - for method, checkbox in self.method_checkboxes.items(): - checkbox.setChecked(method in selected_methods) - - if 'output_dir' in config: - self.output_dir.setText(config['output_dir'] or "9_Custom_Regression_Modeling") - if 'enabled' in config: - self.enable_checkbox.setChecked(config['enabled']) + def set_config(self, config: Dict): + if 'training_csv_path' in config: + self.training_data_widget.set_path(config['training_csv_path']) + if 'formula_names' in config: + sel = set(config['formula_names']) + for name, item in self.index_checkboxes.items(): + item.setCheckState(Qt.Checked if name in sel else Qt.Unchecked) + self.enable_checkbox.setChecked(config.get('enabled', True)) + if 'output_mode' in config: + btn = self.mode_group.button(config['output_mode']) + if btn: + btn.setChecked(True) def update_from_config(self, work_dir=None, pipeline=None): - """从全局配置自动填充训练数据和输出路径 + if work_dir: + self.work_dir = work_dir + main = self.window() + if hasattr(main, 'step5_panel'): + p5 = main.step5_panel.output_file.get_path() + if p5: + if not os.path.isabs(p5): + p5 = os.path.join(self.work_dir or '', p5) + p5 = p5.replace('\\', '/') + self.training_data_widget.set_path(p5) - Args: - work_dir: 工作目录路径 - pipeline: Pipeline 实例(未使用,保留接口兼容性) - """ - try: - import traceback + def _get_work_dir(self) -> Optional[str]: + if self.work_dir: + return self.work_dir + main = self.window() + if hasattr(main, 'work_dir') and main.work_dir: + return main.work_dir + return None - if work_dir: - self.work_dir = work_dir - elif hasattr(self, 'work_dir') and self.work_dir: - pass - else: - self.work_dir = None + def _get_coord_cols(self, df: pd.DataFrame) -> Tuple[str, str]: + coord_candidates = ['lon', 'lng', 'longitude', '经度', 'x', 'lon_utm', 'utm_x', 'pixel_x'] + lat_candidates = ['lat', 'latitude', '纬度', 'y', 'lat_utc', 'utm_y', 'pixel_y'] - # 1. 尝试从 Step8 界面读取训练光谱 CSV 路径 - main_window = self.window() - if main_window and hasattr(main_window, 'step8_panel'): - step8_widget = main_window.step8_panel.training_data_widget - step8_output_path = "" - if hasattr(step8_widget, 'get_path'): - step8_output_path = step8_widget.get_path() or "" + x_col, y_col = None, None + for col in df.columns: + cl = col.lower() + if x_col is None and any(c in cl for c in coord_candidates): + x_col = col + if y_col is None and any(c in cl for c in lat_candidates): + y_col = col - if step8_output_path: - if not os.path.isabs(step8_output_path): - step8_output_path = os.path.join(self.work_dir or '', step8_output_path).replace('\\', '/') - existing = self.csv_file.get_path() - if not existing or not existing.strip(): - self.csv_file.set_path(step8_output_path) + if x_col is None and len(df.columns) >= 2: + x_col = df.columns[0] + if y_col is None and len(df.columns) >= 2: + y_col = df.columns[1] - # 1.2 尝试从 pipeline 读取 Step 8 宽表 indices_path(优先级最高) - if pipeline and hasattr(pipeline, 'indices_path') and pipeline.indices_path: - step8_indices_path = pipeline.indices_path - if not os.path.isabs(step8_indices_path): - step8_indices_path = os.path.join(self.work_dir or '', step8_indices_path).replace('\\', '/') - current_path = self.csv_file.get_path() - if not current_path or not current_path.strip(): - self.csv_file.set_path(step8_indices_path) - print(f"✅ 从pipeline.indices_path回填Step8产出: {step8_indices_path}") - - # 1.5 自动探测并回填 Step 8 双轨输出的 Traditional_Indices 目录 - if self.work_dir: - trad_indices_dir = os.path.join( - self.work_dir, "11_12_13_predictions", "Traditional_Indices" - ) - if os.path.isdir(trad_indices_dir): - csv_files = [ - f for f in os.listdir(trad_indices_dir) - if f.lower().endswith('.csv') - ] - if csv_files: - csv_files.sort() - first_csv = os.path.join(trad_indices_dir, csv_files[0]) - existing = self.csv_file.get_path() - if not existing or not existing.strip(): - self.csv_file.set_path(first_csv) - self.refresh_csv_columns() - print(f"✅ 自动探测到 Traditional_Indices 目录,加载首个CSV: {csv_files[0]}") - - # 2. 自动填充输出目录(9_Custom_Regression_Modeling) - if self.work_dir: - output_dir = os.path.join(self.work_dir, "9_Custom_Regression_Modeling") - os.makedirs(output_dir, exist_ok=True) - existing_out = self.output_dir.text().strip() - if not existing_out: - self.output_dir.setText(output_dir) - except Exception as e: - import traceback - print(f"【{self.__class__.__name__}】自动填充失败,跳过: {e}") - traceback.print_exc() + return x_col or 'x_coord', y_col or 'y_coord' def run_step(self): - """独立运行步骤9""" - csv_path = self.csv_file.get_path() - - if not csv_path: - QMessageBox.warning(self, "输入验证失败", "请选择输入CSV文件") - return - if not os.path.exists(csv_path): - QMessageBox.warning(self, "输入验证失败", "输入CSV文件不存在") - return - - selected_x_columns = [ - col for col, checkbox in self.x_column_checkboxes.items() - if checkbox.isChecked() - ] - if not selected_x_columns: - QMessageBox.warning(self, "输入验证失败", "请至少选择一个自变量列") - return - - selected_y_columns = [ - col for col, checkbox in self.y_column_checkboxes.items() - if checkbox.isChecked() - ] - if not selected_y_columns: - QMessageBox.warning(self, "输入验证失败", "请至少选择一个因变量列") - return - - selected_methods = [ - method for method, checkbox in self.method_checkboxes.items() - if checkbox.isChecked() - ] - if not selected_methods: - QMessageBox.warning(self, "输入验证失败", "请至少选择一种回归方法") - return - config = self.get_config() - parent = self.parent() - while parent and not hasattr(parent, 'run_single_step'): - parent = parent.parent() + if not config['enabled']: + QMessageBox.information(self, "提示", "已禁用计算流程(启用计算流程未勾选)") + return - if parent and hasattr(parent, 'run_single_step'): - parent.run_single_step('step9', {'step9': config}) - else: - QMessageBox.critical(self, "错误", "无法找到父级GUI对象") + training_path = config['training_csv_path'] + if not training_path or not os.path.exists(training_path): + QMessageBox.warning(self, "提示", "请先选择输入特征提取CSV文件") + return + + formula_names = config['formula_names'] + if not formula_names: + QMessageBox.warning(self, "提示", "请至少勾选一个公式") + return + + output_mode = config['output_mode'] + + try: + from src.core.steps.data_preparation_step import DataPreparationStep + + spec_df = pd.read_csv(training_path) + x_col, y_col = self._get_coord_cols(spec_df) + + formula_csv_path = self.builtin_formula_path + if not formula_csv_path or not os.path.exists(formula_csv_path): + possible_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'gui', 'model', 'waterindex.csv') + if os.path.exists(possible_path): + formula_csv_path = possible_path + + work_dir = self._get_work_dir() + + indices_csv_path = DataPreparationStep.calculate_water_quality_indices( + training_csv_path=training_path, + formula_csv_file=formula_csv_path, + formula_names=formula_names, + output_file=None, + enabled=True, + output_dir=work_dir if work_dir else os.getcwd(), + ) + + if indices_csv_path and os.path.exists(indices_csv_path): + output_df = pd.read_csv(indices_csv_path) + else: + output_df = spec_df + + track_a_path = None + track_b_dir = None + + if output_mode in (0, 1): + track_a_dir = os.path.join(work_dir, "9_supervised_modeling") if work_dir else "9_supervised_modeling" + os.makedirs(track_a_dir, exist_ok=True) + track_a_path = os.path.join(track_a_dir, "training_spectra_indices.csv") + + if output_mode in (0, 2): + track_b_dir = os.path.join(work_dir, "11_12_13_predictions", "Traditional_Indices") if work_dir else "11_12_13_predictions/Traditional_Indices" + os.makedirs(track_b_dir, exist_ok=True) + + saved = [] + if output_mode in (0, 1): + output_df.to_csv(track_a_path, index=False, float_format='%.6f') + saved.append(f"宽表: {track_a_path}") + + if output_mode in (0, 2): + coord_x = spec_df[x_col].values if x_col in spec_df.columns else np.arange(len(spec_df)) + coord_y = spec_df[y_col].values if y_col in spec_df.columns else np.zeros(len(spec_df)) + + for formula_name in formula_names: + if formula_name not in output_df.columns: + continue + single_df = pd.DataFrame({ + 'x_coord': coord_x, + 'y_coord': coord_y, + 'value': output_df[formula_name].values, + }) + safe_name = formula_name.replace('/', '_').replace(' ', '_') + out_path = os.path.join(track_b_dir, f"{safe_name}_prediction.csv") + single_df.to_csv(out_path, index=False, float_format='%.6f') + saved.append(f"单文件目录: {track_b_dir}") + + QMessageBox.information( + self, "计算完成", + f"已保存 {len(saved)} 个输出目标:\n" + "\n".join(saved) + ) + + except ImportError as e: + QMessageBox.critical(self, "依赖错误", f"无法导入模块:\n{e}") + except Exception as e: + import traceback + QMessageBox.critical(self, "计算失败", f"原因: {str(e)}\n{traceback.format_exc()}") \ No newline at end of file diff --git a/src/gui/water_quality_gui.py b/src/gui/water_quality_gui.py index ea58e6a..22ab3dc 100644 --- a/src/gui/water_quality_gui.py +++ b/src/gui/water_quality_gui.py @@ -117,15 +117,17 @@ from src.gui.components.custom_widgets import FileSelectWidget from src.gui.panels.step1_panel import Step1Panel from src.gui.panels.step2_panel import Step2Panel from src.gui.panels.step3_panel import Step3Panel -from src.gui.panels.step4_panel import Step4Panel -from src.gui.panels.step5_panel import Step5Panel -from src.gui.panels.step6_panel import Step6Panel # was step8_panel -from src.gui.panels.step7_panel import Step7Panel # was step6_panel -from src.gui.panels.step8_waterindex_panel import Step8WaterIndexPanel # 水色指数反演 -from src.gui.panels.step9_concentration_panel import Step9ConcentrationPanel # 浓度反演 -from src.gui.panels.step10_panel import Step10Panel # was step7_panel -from src.gui.panels.step11_ml_panel import Step11MlPanel # ML prediction (step11_ml) -from src.gui.panels.step14_panel import Step14Panel # was step9_panel +from src.gui.panels.step4_sampling_panel import Step4SamplingPanel # 采样点布设(原step10→新step4) +from src.gui.panels.step5_panel import Step5Panel # 数据清洗(原step4→新step5) +from src.gui.panels.step6_panel import Step6Panel # 光谱特征(原step5→新step6) +from src.gui.panels.step7_panel import Step7Panel # 水质光谱指数(原step6→新step7) +from src.gui.panels.step8_panel import Step8Panel # 水质参数指数(原step7→新step8) +from src.gui.panels.step8_waterindex_panel import Step9WaterColorPanel # 水色指数反演 +from src.gui.panels.step9_concentration_panel import Step10ConcentrationPanel # 浓度反演 +from src.gui.panels.step9_panel import Step9Panel # 机器学习建模(原step8→新step9) +from src.gui.panels.step10_ml_panel import Step10MlPanel # 机器学习预测(原step11_ml→新step10) +from src.gui.panels.step11_panel import Step11NonEmpiricalPanel # 非经验模型预测 +from src.gui.panels.step14_panel import Step14Panel from src.gui.dialogs import BandConfirmDialog, AISettingsDialog from src.gui.panels.visualization_panel import VisualizationPanel from src.gui.panels.report_generation_panel import ReportGenerationPanel @@ -1846,23 +1848,22 @@ class WaterQualityGUI(QMainWindow): ("step3", "3. 耀斑去除与修复"), ], "阶段二:样本数据准备 ": [ - ("step4", "4. 数据标准化处理"), - ("step5", "5. 光谱特征提取"), - ("step6", "6. 水质参数指数计算"), + ("step4", "4. 采样点布设"), + ("step5", "5. 数据清洗"), + ("step6", "6. 光谱特征"), + ("step7", "7. 水质光谱指数计算"), + ("step8", "8. 水质参数指数计算"), ], "阶段三:模型构建与训练": [ - ("step7", "7. 机器学习模型训练"), - ("step8_non_empirical_modeling", "8. 回归模型训练"), - ("step9", "9. 自定义回归模型训练"), + ("step9", "9. 机器学习建模"), + ("step8_non_empirical_modeling", "8b. 回归模型训练"), ], "阶段四:预测与成果输出 ": [ - ("step10", "10. 采样点布设"), - ("step11_ml", "11. 机器学习预测"), - ("step11", "12. 回归预测"), - ("step12", "13. 自定义回归预测"), - ("step14", "14. 专题图生成"), - ("step9_viz", "15. 可视化分析"), - ("step_report", "16. 分析报告生成"), + ("step10", "10. 机器学习预测"), + ("step11", "11. 回归预测"), + ("step14", "12. 专题图生成"), + ("step9_viz", "13. 可视化分析"), + ("step_report", "14. 分析报告生成"), ] } @@ -1882,7 +1883,7 @@ class WaterQualityGUI(QMainWindow): self.step_list.addItem(stage_item) # 添加该阶段的所有步骤 - HIDDEN_STEP_IDS = {"step8_non_empirical_modeling", "step9", "step11", "step12"} + HIDDEN_STEP_IDS = {"step8_non_empirical_modeling"} for step_id, step_display in steps: if step_id in HIDDEN_STEP_IDS: continue @@ -1956,29 +1957,35 @@ class WaterQualityGUI(QMainWindow): self.step3_panel = Step3Panel() self.step_stack.addTab(self.create_scroll_area(self.step3_panel), QIcon(self.get_icon_path("3.png")), "耀斑去除") - self.step4_panel = Step4Panel() - self.step_stack.addTab(self.create_scroll_area(self.step4_panel), QIcon(self.get_icon_path("4.png")), "数据清洗") + self.step4_panel = Step4SamplingPanel() + self.step_stack.addTab(self.create_scroll_area(self.step4_panel), QIcon(self.get_icon_path("4.png")), "采样点布设") self.step5_panel = Step5Panel() - self.step_stack.addTab(self.create_scroll_area(self.step5_panel), QIcon(self.get_icon_path("5.png")), "特征构建") + self.step_stack.addTab(self.create_scroll_area(self.step5_panel), QIcon(self.get_icon_path("5.png")), "数据清洗") self.step6_panel = Step6Panel() - self.step_stack.addTab(self.create_scroll_area(self.step6_panel), QIcon(self.get_icon_path("6.png")), "水质光谱指数计算") + self.step_stack.addTab(self.create_scroll_area(self.step6_panel), QIcon(self.get_icon_path("6.png")), "光谱特征") self.step7_panel = Step7Panel() - self.step_stack.addTab(self.create_scroll_area(self.step7_panel), QIcon(self.get_icon_path("7.png")), "监督建模") + self.step_stack.addTab(self.create_scroll_area(self.step7_panel), QIcon(self.get_icon_path("7.png")), "水质光谱指数计算") - self.step8_waterindex_panel = Step8WaterIndexPanel() - self.step_stack.addTab(self.create_scroll_area(self.step8_waterindex_panel), QIcon(self.get_icon_path("6.png")), "水色指数反演") + self.step8_panel = Step8Panel() + self.step_stack.addTab(self.create_scroll_area(self.step8_panel), QIcon(self.get_icon_path("7.png")), "水质参数指数计算") - self.step9_concentration_panel = Step9ConcentrationPanel() - self.step_stack.addTab(self.create_scroll_area(self.step9_concentration_panel), QIcon(self.get_icon_path("6.png")), "浓度反演") + self.step9_panel = Step9Panel() + self.step_stack.addTab(self.create_scroll_area(self.step9_panel), QIcon(self.get_icon_path("8.png")), "机器学习建模") - self.step10_panel = Step10Panel() - self.step_stack.addTab(self.create_scroll_area(self.step10_panel), QIcon(self.get_icon_path("7.png")), "采样点布设") + self.step8_waterindex_panel = Step9WaterColorPanel() + self.step_stack.addTab(self.create_scroll_area(self.step8_waterindex_panel), QIcon(self.get_icon_path("8.png")), "水色指数反演") - self.step11_ml_panel = Step11MlPanel() # ML prediction panel (step11_ml) - self.step_stack.addTab(self.create_scroll_area(self.step11_ml_panel), QIcon(self.get_icon_path("8.png")), "监督预测") + self.step9_concentration_panel = Step10ConcentrationPanel() + self.step_stack.addTab(self.create_scroll_area(self.step9_concentration_panel), QIcon(self.get_icon_path("9.png")), "浓度反演") + + self.step10_ml_panel = Step10MlPanel() + self.step_stack.addTab(self.create_scroll_area(self.step10_ml_panel), QIcon(self.get_icon_path("10.png")), "机器学习预测") + + self.step11_non_empirical_panel = Step11NonEmpiricalPanel() + self.step_stack.addTab(self.create_scroll_area(self.step11_non_empirical_panel), QIcon(self.get_icon_path("11.png")), "回归预测") self.step14_panel = Step14Panel() self.step_stack.addTab(self.create_scroll_area(self.step14_panel), QIcon(self.get_icon_path("10.png")), "专题图生成") @@ -2133,12 +2140,12 @@ class WaterQualityGUI(QMainWindow): 'step5': 4, 'step6': 5, 'step7': 6, - 'step8_non_empirical_modeling': 7, + 'step8': 7, 'step9': 8, - 'step10': 9, - 'step11_ml': 10, - 'step11': 11, - 'step12': 12, + 'step8_non_empirical_modeling': 9, + 'step9_concentration': 10, + 'step10': 11, + 'step11': 12, 'step14': 13, 'step9_viz': 14, 'step_report': 15, @@ -2164,12 +2171,12 @@ class WaterQualityGUI(QMainWindow): 4: 'step5', 5: 'step6', 6: 'step7', - 7: 'step8_non_empirical_modeling', + 7: 'step8', 8: 'step9', - 9: 'step10', - 10: 'step11_ml', - 11: 'step11', - 12: 'step12', + 9: 'step8_non_empirical_modeling', + 10: 'step9_concentration', + 11: 'step10', + 12: 'step11', 13: 'step14', 14: 'step9_viz', 15: 'step_report', @@ -2199,44 +2206,48 @@ class WaterQualityGUI(QMainWindow): elif index == 2: self.step3_panel.update_from_config(work_dir=self.work_dir) - # Step4 切换时自动填充输出路径 + # Step4(采样点布设)切换时自动填充输出路径 elif index == 3: self.step4_panel.update_from_config(work_dir=self.work_dir) - # Step5 切换时自动填充数据流转路径 + # Step5(数据清洗)切换时自动填充数据流转路径 elif index == 4: self.step5_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) - # Step6(水质光谱指数)切换时自动填充输出路径 + # Step6(光谱特征)切换时自动填充输出路径 elif index == 5: self.step6_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) - # Step7(监督建模)切换时自动填充训练数据和输出路径 + # Step7(水质光谱指数计算)切换时自动填充水质参数 CSV elif index == 6: self.step7_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) - # Step8 水色指数反演切换时自动填充光谱数据和输出路径 + # Step8(水质参数指数计算)切换时自动填充水质参数 CSV elif index == 7: + self.step8_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) + + # Step9(机器学习建模)切换时自动填充训练数据和输出路径 + elif index == 8: + self.step9_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) + + # Step8b(水色指数反演)切换时自动填充光谱数据和输出路径 + elif index == 9: self.step8_waterindex_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) - # Step9 浓度反演切换时自动填充 QAA 结果和输出路径 - elif index == 8: + # Step10(浓度反演)切换时自动填充 QAA 结果和输出路径 + elif index == 10: self.step9_concentration_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) - # Step10(采样点布设)切换时自动填充掩膜和输出路径 - elif index == 9: - self.step10_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) - # Step11(机器学习预测)切换时自动填充采样光谱和模型目录 - elif index == 10: - self.step11_ml_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) + elif index == 11: + self.step10_ml_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) # Step14(专题图生成)切换时自动填充预测结果目录 - elif index == 11: + elif index == 13: self.step14_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) # 可视化分析面板切换时自动推断图像目录并加载目录树 - elif index == 12: + elif index == 14: self.viz_panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) def apply_stylesheet(self): @@ -2285,9 +2296,9 @@ class WaterQualityGUI(QMainWindow): if 'step7' in config: self.step7_panel.set_config(config['step7']) if 'step10' in config: - self.step10_panel.set_config(config['step10']) + self.step4_panel.set_config(config['step10']) if 'step11_ml' in config: - self.step11_ml_panel.set_config(config['step11_ml']) + self.step10_ml_panel.set_config(config['step11_ml']) if 'step14' in config: self.step14_panel.set_config(config['step14']) if 'visualization' in config: @@ -2334,8 +2345,8 @@ class WaterQualityGUI(QMainWindow): 'step5': self.step5_panel.get_config(), 'step6': self.step6_panel.get_config(), 'step7': self.step7_panel.get_config(), - 'step10': self.step10_panel.get_config(), - 'step11_ml': self.step11_ml_panel.get_config(), + 'step10': self.step4_panel.get_config(), + 'step11_ml': self.step10_ml_panel.get_config(), 'step14': self.step14_panel.get_config(), 'visualization': self.viz_panel.get_config(), 'report_generation': self.report_panel.get_config(), @@ -2389,8 +2400,8 @@ class WaterQualityGUI(QMainWindow): 'step5': self.step5_panel, 'step6': self.step6_panel, 'step7': self.step7_panel, - 'step10': self.step10_panel, - 'step11_ml': self.step11_ml_panel, + 'step10': self.step4_panel, + 'step11_ml': self.step10_ml_panel, 'step14': self.step14_panel, } return panel_map.get(step_id) @@ -2591,8 +2602,8 @@ class WaterQualityGUI(QMainWindow): ('step5', self.step5_panel), ('step6', self.step6_panel), ('step7', self.step7_panel), - ('step10', self.step10_panel), - ('step11_ml', self.step11_ml_panel), + ('step10', self.step4_panel), + ('step11_ml', self.step10_ml_panel), ('step14', self.step14_panel) ] @@ -3219,14 +3230,14 @@ class WaterQualityGUI(QMainWindow): def update_ui_for_training_mode(self): """根据训练数据模式更新UI状态""" # 需要禁用的步骤ID(对应无训练数据模式下需要禁用的步骤) - disabled_step_ids = ['step4', 'step5', 'step6', 'step7', 'step8_non_empirical_modeling', 'step9'] + disabled_step_ids = ['step4', 'step5', 'step6', 'step7', 'step8', 'step8_non_empirical_modeling', 'step9'] # 更新标签页的启用/禁用状态 step_id_to_tab = { 'step1': 0, 'step2': 1, 'step3': 2, 'step4': 3, - 'step5': 4, 'step6': 5, 'step7': 6, 'step8_non_empirical_modeling': 7, - 'step9': 8, 'step10': 9, 'step11_ml': 10, 'step11': 11, - 'step12': 12, 'step14': 13, 'step9_viz': 14 + 'step5': 4, 'step6': 5, 'step7': 6, 'step8': 7, + 'step9': 8, 'step8_non_empirical_modeling': 9, 'step9_concentration': 10, + 'step10': 11, 'step11': 12, 'step14': 13, 'step9_viz': 14 } for step_id in disabled_step_ids: