From 84f0f6058fe70857c775f80aaaec020fc111f9d6 Mon Sep 17 00:00:00 2001 From: DXC Date: Wed, 17 Jun 2026 08:58:17 +0800 Subject: [PATCH] =?UTF-8?q?views/step2-view13=EF=BC=9A12=20=E4=B8=AA?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=20view=20=E8=BF=81=E7=A7=BB=E5=AE=8C?= =?UTF-8?q?=E6=88=90=EF=BC=88=E7=BB=A7=E6=89=BF=20BaseView=EF=BC=8C?= =?UTF-8?q?=E7=BA=AF=20UI=EF=BC=8Cservice=20=E4=BB=8D=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/new/views/step10_view.py | 250 ++++++++++++++++++++++ src/new/views/step11_view.py | 389 +++++++++++++++++++++++++++++++++++ src/new/views/step12_view.py | 231 +++++++++++++++++++++ src/new/views/step13_view.py | 229 +++++++++++++++++++++ src/new/views/step2_view.py | 170 +++++++++++++++ src/new/views/step3_view.py | 260 +++++++++++++++++++++++ src/new/views/step4_view.py | 136 ++++++++++++ src/new/views/step5_view.py | 120 +++++++++++ src/new/views/step6_view.py | 154 ++++++++++++++ src/new/views/step7_view.py | 163 +++++++++++++++ src/new/views/step8_view.py | 320 ++++++++++++++++++++++++++++ src/new/views/step9_view.py | 252 +++++++++++++++++++++++ 12 files changed, 2674 insertions(+) create mode 100644 src/new/views/step10_view.py create mode 100644 src/new/views/step11_view.py create mode 100644 src/new/views/step12_view.py create mode 100644 src/new/views/step13_view.py create mode 100644 src/new/views/step2_view.py create mode 100644 src/new/views/step3_view.py create mode 100644 src/new/views/step4_view.py create mode 100644 src/new/views/step5_view.py create mode 100644 src/new/views/step6_view.py create mode 100644 src/new/views/step7_view.py create mode 100644 src/new/views/step8_view.py create mode 100644 src/new/views/step9_view.py diff --git a/src/new/views/step10_view.py b/src/new/views/step10_view.py new file mode 100644 index 0000000..5fa49ae --- /dev/null +++ b/src/new/views/step10_view.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +""" +Step10View —— Step 10(水色指数反演)的端到端模块化 view + +UI 从 ``src/gui/panels/step10_watercolor_panel.py`` 原样搬迁。 + +view 层职责 +=========== + +- 输入影像(BSQ + HDR)、公式选择 ListWidget、输出目录 + 格式 combo、 + 进度条 / 进度标签、运行按钮全部保留。 +- 删除 ``WaterIndexWorker`` 线程(service 接管后台反演逻辑); + 进度条 / 标签在 view 层保留 UI 占位,由 service 通过 + ``dispatch_execute`` 反馈到主窗口的日志区即可。 +- ``_find_waterindex_csv`` / ``_load_formulas`` / ``_load_metadata`` + 不在 view 层执行;公式 ListWidget 留空,service 通过 set_config + 把 selected_formulas 注入。 +""" + +import os + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QAbstractItemView, QCheckBox, QComboBox, QFormLayout, QGridLayout, + QGroupBox, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, + QProgressBar, QPushButton, QVBoxLayout, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step10View(BaseView): + """Step 10: 水色指数反演(高光谱影像直接处理)""" + + def init_ui(self): + layout = QVBoxLayout() + + # ---- 标题 ---- + title = QLabel("步骤10:水色指数反演(高光谱影像直接处理)") + title.setFont(QFont("Arial", 12, QFont.Bold)) + layout.addWidget(title) + + # ---- 说明 ---- + hint = QLabel( + "将 waterindex.csv 中的公式直接应用于去耀斑高光谱影像(BSQ)," + "输出各水质参数指数的 GeoTIFF 栅格图像。" + "指数图可直接用于水质专题图生成。" + ) + hint.setWordWrap(True) + hint.setStyleSheet(f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};") + layout.addWidget(hint) + + # ---- 输入影像选择 ---- + input_group = QGroupBox("输入影像") + input_layout = QFormLayout() + + self.bsq_file = FileSelectWidget( + "BSQ 影像:", + "BSQ Files (*.bsq);;DAT Files (*.dat);;All Files (*.*)", + ) + self.bsq_file.line_edit.setPlaceholderText("选择去耀斑处理后的 BSQ 影像") + input_layout.addRow("BSQ 影像:", self.bsq_file) + + self.hdr_file = FileSelectWidget( + "ENVI 头文件:", + "HDR Files (*.hdr);;All Files (*.*)", + ) + self.hdr_file.line_edit.setPlaceholderText("自动关联同路径 .hdr 文件") + input_layout.addRow("HDR 文件:", self.hdr_file) + + self.meta_label = QLabel("未加载影像") + self.meta_label.setStyleSheet( + "background: #f0f0f0; padding: 4px 8px; border-radius: 4px; " + "font-size: 12px; color: #333;" + ) + input_layout.addRow("影像信息:", self.meta_label) + + input_group.setLayout(input_layout) + layout.addWidget(input_group) + + # ---- 公式选择 ---- + formula_group = QGroupBox("公式选择") + formula_layout = QGridLayout() + + formula_layout.addWidget(QLabel("按类别筛选:"), 0, 0) + self.category_combo = QComboBox() + # view 层不实际加载 CSV;类别下拉由 service 通过 set_config 注入 + self.category_combo.setEnabled(False) + formula_layout.addWidget(self.category_combo, 0, 1, 1, 2) + + select_btn_layout = QHBoxLayout() + self.select_all_btn = QPushButton("全选") + self.deselect_all_btn = QPushButton("取消全选") + self.select_all_btn.setMaximumWidth(80) + self.deselect_all_btn.setMaximumWidth(80) + self.select_all_btn.clicked.connect(self._select_all) + self.deselect_all_btn.clicked.connect(self._deselect_all) + select_btn_layout.addWidget(self.select_all_btn) + select_btn_layout.addWidget(self.deselect_all_btn) + select_btn_layout.addStretch() + formula_layout.addLayout(select_btn_layout, 0, 3) + + self.formula_list = QListWidget() + self.formula_list.setSelectionMode(QAbstractItemView.MultiSelection) + self.formula_list.setMinimumHeight(200) + # view 层不实际加公式;service 通过 set_config 注入 + self.formula_list.blockSignals(True) + formula_layout.addWidget(self.formula_list, 1, 0, 1, 4) + + formula_group.setLayout(formula_layout) + layout.addWidget(formula_group) + + # ---- 输出设置 ---- + output_group = QGroupBox("输出设置") + output_layout = QFormLayout() + + self.output_dir = FileSelectWidget( + "输出目录:", + "Directories", + ) + self.output_dir.line_edit.setPlaceholderText("留空 → 工作目录/10_WaterIndex_Images") + output_layout.addRow("输出目录:", self.output_dir) + + self.format_combo = QComboBox() + self.format_combo.addItems(["GTiff (GeoTIFF)", "ENVI", "PCI"]) + self.format_combo.setCurrentIndex(0) + output_layout.addRow("输出格式:", self.format_combo) + + output_group.setLayout(output_layout) + layout.addWidget(output_group) + + # ---- 进度显示 ---- + self.progress_bar = QProgressBar() + self.progress_bar.setMinimum(0) + self.progress_bar.setMaximum(100) + self.progress_bar.setValue(0) + self.progress_bar.setTextVisible(True) + layout.addWidget(self.progress_bar) + + self.progress_label = QLabel("") + self.progress_label.setStyleSheet("font-size: 11px; color: #666;") + layout.addWidget(self.progress_label) + + # ---- 启用 & 运行 ---- + 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._on_run_clicked) + layout.addWidget(self.run_btn) + + layout.addStretch() + self.setLayout(layout) + + # ------------------------------------------------------------------ + # 公式勾选辅助 + # ------------------------------------------------------------------ + def _select_all(self): + for i in range(self.formula_list.count()): + item = self.formula_list.item(i) + item.setCheckState(Qt.Checked) + + def _deselect_all(self): + for i in range(self.formula_list.count()): + item = self.formula_list.item(i) + item.setCheckState(Qt.Unchecked) + + # ------------------------------------------------------------------ + # BaseView 契约 + # ------------------------------------------------------------------ + def get_config(self) -> dict: + bsq_path = self.bsq_file.get_path() + selected = [] + for i in range(self.formula_list.count()): + item = self.formula_list.item(i) + if item.checkState() == Qt.Checked: + name = item.data(Qt.UserRole) + if name: + selected.append(name) + config = { + "bsq_path": bsq_path, + "deglint_img_path": bsq_path, + "output_format": self.format_combo.currentText().split()[0], + "selected_formulas": selected, + "enabled": self.enable_checkbox.isChecked(), + } + hdr_path = self.hdr_file.get_path() + if hdr_path: + config["hdr_path"] = hdr_path + output_dir = self.output_dir.get_path() + if output_dir: + config["output_dir"] = output_dir + return config + + def set_config(self, config: dict): + if config.get("bsq_path"): + self.bsq_file.set_path(config["bsq_path"]) + if config.get("hdr_path"): + self.hdr_file.set_path(config["hdr_path"]) + if config.get("output_dir"): + self.output_dir.set_path(config["output_dir"]) + if "selected_formulas" in config: + self.formula_list.blockSignals(True) + names = set(config["selected_formulas"]) + for i in range(self.formula_list.count()): + item = self.formula_list.item(i) + name = item.data(Qt.UserRole) + item.setCheckState(Qt.Checked if name in names else Qt.Unchecked) + self.formula_list.blockSignals(False) + if "enabled" in config: + self.enable_checkbox.setChecked(config["enabled"]) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if not work_dir: + return + out_dir = _resolve_subdir(work_dir, "watercolor") + os.makedirs(out_dir, exist_ok=True) + if not self.output_dir.get_path(): + self.output_dir.set_path(out_dir) + # 自动填 BSQ(去耀斑输出) + deglint_dir = _resolve_subdir(work_dir, "deglint") + if os.path.isdir(deglint_dir): + import glob + candidates = ( + glob.glob(os.path.join(deglint_dir, "*.bsq")) + + glob.glob(os.path.join(deglint_dir, "*.dat")) + ) + if candidates and not self.bsq_file.get_path(): + candidates.sort(key=os.path.getmtime, reverse=True) + bsq_path = candidates[0] + self.bsq_file.set_path(bsq_path) + hdr_path = os.path.splitext(bsq_path)[0] + ".hdr" + if os.path.exists(hdr_path): + self.hdr_file.set_path(hdr_path) + + # ------------------------------------------------------------------ + # 执行入口 + # ------------------------------------------------------------------ + def _on_run_clicked(self): + self.dispatch_execute("step10", self.get_config()) diff --git a/src/new/views/step11_view.py b/src/new/views/step11_view.py new file mode 100644 index 0000000..3299cfc --- /dev/null +++ b/src/new/views/step11_view.py @@ -0,0 +1,389 @@ +# -*- coding: utf-8 -*- +""" +Step11View —— Step 11(专题图生成)的端到端模块化 view + +UI 从 ``src/gui/panels/step11_map_panel.py`` 原样搬迁。 + +view 层职责 +=========== + +- 渲染模式(CSV 插值模式 / GeoTIFF 栅格模式)combo + 单文件/文件夹批量 + RadioButton + 4 个文件输入控件 + 参数组 + 输出目录全部保留。 +- ``_toggle_input_mode`` 槽函数保留:根据 (render_mode, folder_mode) + 动态显示/隐藏对应的输入组件。 +- ``Step11MapBatchThread`` / ``Step11GeoTIFFBatchThread`` 删除(service 接管); + 进度条 UI 占位保留。 +""" + +import os + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QButtonGroup, QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QProgressBar, QPushButton, + QRadioButton, QVBoxLayout, QWidget, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +# RadioButton 美化样式(选中状态为方形实心块,贴合主界面风格) +RADIO_STYLE = """ + QRadioButton { + font-size: 14px; + spacing: 8px; + color: #333333; + } + QRadioButton::indicator { + width: 16px; + height: 16px; + border: 2px solid #999999; + border-radius: 3px; + background-color: white; + } + QRadioButton::indicator:checked { + border: 2px solid #0078d4; + background-color: #0078d4; + image: none; + } + QRadioButton::indicator:hover { + border: 2px solid #005a9e; + } +""" + + +class Step11View(BaseView): + """Step 11: 专题图生成""" + + def init_ui(self): + layout = QVBoxLayout() + + title = QLabel("步骤11:专题图生成") + title.setFont(QFont("Arial", 12, QFont.Bold)) + layout.addWidget(title) + + hint = QLabel( + "独立运行:可选「单个 CSV」或「文件夹批量」(扫描目录下所有 .csv)。" + "GeoTIFF 栅格模式下,亦支持批量渲染步骤10输出的所有水色指数 GeoTIFF 文件。" + ) + hint.setWordWrap(True) + hint.setStyleSheet( + f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};" + ) + layout.addWidget(hint) + + # 单 CSV / 文件夹批量 RadioButton + mode_row = QHBoxLayout() + self.mode_single_rb = QRadioButton("单个 CSV 文件") + self.mode_folder_rb = QRadioButton("文件夹批量") + self._mode_group = QButtonGroup(self) + self._mode_group.addButton(self.mode_single_rb, 0) + self._mode_group.addButton(self.mode_folder_rb, 1) + mode_row.addWidget(self.mode_single_rb) + mode_row.addWidget(self.mode_folder_rb) + mode_row.addStretch() + layout.addLayout(mode_row) + + for rb in (self.mode_single_rb, self.mode_folder_rb): + rb.setStyleSheet(RADIO_STYLE) + + # 渲染模式 combo + render_row = QHBoxLayout() + render_row.addWidget(QLabel("渲染模式:")) + self.render_mode_combo = QComboBox() + self.render_mode_combo.addItems(["CSV 插值模式", "GeoTIFF 栅格模式"]) + self.render_mode_combo.setMinimumWidth(180) + render_row.addWidget(self.render_mode_combo) + render_row.addStretch() + layout.addLayout(render_row) + + # 预测结果 CSV(单文件) + self.prediction_csv_file = FileSelectWidget( + "预测结果CSV:", + "CSV Files (*.csv);;All Files (*.*)", + ) + layout.addWidget(self.prediction_csv_file) + + # 预测 CSV 文件夹(批量) + self._folder_row_widget = QWidget() + folder_row = QHBoxLayout() + self.prediction_csv_dir_label = QLabel("预测CSV目录:") + self.prediction_csv_dir_label.setMinimumWidth(120) + self.prediction_csv_dir_edit = QLineEdit() + self.prediction_csv_dir_edit.setPlaceholderText("选择含多个预测结果 CSV 的文件夹…") + pred_dir_btn = QPushButton("浏览…") + pred_dir_btn.setMaximumWidth(80) + pred_dir_btn.clicked.connect(self.browse_prediction_csv_dir) + folder_row.addWidget(self.prediction_csv_dir_label) + folder_row.addWidget(self.prediction_csv_dir_edit, 1) + folder_row.addWidget(pred_dir_btn) + self._folder_row_widget.setLayout(folder_row) + layout.addWidget(self._folder_row_widget) + + # 水色指数 GeoTIFF(单文件) + self.geotiff_file = FileSelectWidget( + "水色指数 GeoTIFF:", + "GeoTIFF Files (*.tif);;All Files (*.*)", + ) + self.geotiff_file.line_edit.setPlaceholderText( + "选择步骤10输出的水色指数 GeoTIFF 文件…" + ) + self.geotiff_file.setVisible(False) + layout.addWidget(self.geotiff_file) + + # 水色指数目录(批量) + self._geotiff_dir_widget = QWidget() + geotiff_dir_row = QHBoxLayout() + self.geotiff_dir_label = QLabel("水色指数目录:") + self.geotiff_dir_label.setMinimumWidth(120) + self.geotiff_dir_edit = QLineEdit() + self.geotiff_dir_edit.setPlaceholderText( + "选择 10_WaterIndex_Images 文件夹(批量渲染)…" + ) + geotiff_dir_btn = QPushButton("浏览…") + geotiff_dir_btn.setMaximumWidth(80) + geotiff_dir_btn.clicked.connect(self.browse_geotiff_dir) + geotiff_dir_row.addWidget(self.geotiff_dir_label) + geotiff_dir_row.addWidget(self.geotiff_dir_edit, 1) + geotiff_dir_row.addWidget(geotiff_dir_btn) + self._geotiff_dir_widget.setLayout(geotiff_dir_row) + self._geotiff_dir_widget.setVisible(False) + layout.addWidget(self._geotiff_dir_widget) + + self.recursive_csv_cb = QCheckBox("包含子文件夹(递归扫描 *.csv)") + layout.addWidget(self.recursive_csv_cb) + + # 边界文件 + self.boundary_file = FileSelectWidget( + "边界文件:", + "Shapefiles (*.shp);;All Files (*.*)", + ) + layout.addWidget(self.boundary_file) + + # 参数设置 + params_group = QGroupBox("生成参数") + params_layout = QFormLayout() + + self.resolution = QDoubleSpinBox() + self.resolution.setRange(1, 1000) + self.resolution.setValue(30) + params_layout.addRow("分辨率(米):", self.resolution) + + self.input_crs = QLineEdit() + self.input_crs.setText("EPSG:32651") + params_layout.addRow("输入坐标系:", self.input_crs) + + self.output_crs = QLineEdit() + self.output_crs.setText("EPSG:4326") + params_layout.addRow("输出坐标系:", self.output_crs) + + self.show_points = QCheckBox("显示采样点") + params_layout.addRow("", self.show_points) + + self.use_diffusion = QCheckBox("启用距离扩散") + self.use_diffusion.setChecked(True) + params_layout.addRow("", self.use_diffusion) + + params_group.setLayout(params_layout) + layout.addWidget(params_group) + + # 输出目录 + self.output_dir = FileSelectWidget( + "输出分布图目录:", + "Directories;;All Files (*.*)", + ) + self.output_dir.line_edit.setPlaceholderText("留空→工作目录/14_visualization") + layout.addWidget(self.output_dir) + + # 启用步骤 + 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._on_run_clicked) + layout.addWidget(self.run_btn) + + # 批量渲染进度条 + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + self.progress_bar.setMinimum(0) + self.progress_bar.setMaximum(100) + self.progress_bar.setValue(0) + layout.addWidget(self.progress_bar) + + layout.addStretch() + self.setLayout(layout) + + # 信号绑定 + self.render_mode_combo.currentTextChanged.connect(self._toggle_input_mode) + self.mode_single_rb.toggled.connect(self._toggle_input_mode) + self.mode_folder_rb.toggled.connect(self._toggle_input_mode) + self.mode_single_rb.setChecked(True) + self._toggle_input_mode() + + # ------------------------------------------------------------------ + # 槽函数 + # ------------------------------------------------------------------ + def _toggle_input_mode(self): + """根据渲染模式和输入模式动态显示/隐藏对应的输入组件""" + geotiff_mode = self.render_mode_combo.currentText() == "GeoTIFF 栅格模式" + folder_mode = self.mode_folder_rb.isChecked() + + if not geotiff_mode: + # CSV 插值模式 + self.prediction_csv_file.setVisible(not folder_mode) + self._folder_row_widget.setVisible(folder_mode) + self.recursive_csv_cb.setVisible(folder_mode) + self.geotiff_file.setVisible(False) + self._geotiff_dir_widget.setVisible(False) + else: + # GeoTIFF 栅格模式 + self.prediction_csv_file.setVisible(False) + self._folder_row_widget.setVisible(False) + self.recursive_csv_cb.setVisible(False) + self.geotiff_file.setVisible(not folder_mode) + self._geotiff_dir_widget.setVisible(folder_mode) + + def browse_prediction_csv_dir(self): + d = self._browse_dir( + "选择预测结果 CSV 所在文件夹", + self._get_default_work_dir() and _resolve_subdir( + self._get_default_work_dir(), "prediction_dir" + ), + ) + if d: + self.prediction_csv_dir_edit.setText(d) + + def browse_geotiff_dir(self): + d = self._browse_dir( + "选择水色指数 GeoTIFF 文件夹", + self._get_default_work_dir() and _resolve_subdir( + self._get_default_work_dir(), "watercolor" + ), + ) + if d: + self.geotiff_dir_edit.setText(d) + + def _browse_dir(self, title, default_dir): + from PyQt5.QtWidgets import QFileDialog + return QFileDialog.getExistingDirectory(self, title, default_dir or "") + + def _get_default_work_dir(self) -> str: + if hasattr(self, "work_dir") and self.work_dir: + return str(self.work_dir) + return "" + + # ------------------------------------------------------------------ + # BaseView 契约 + # ------------------------------------------------------------------ + def get_config(self) -> dict: + pred_csv = (self.prediction_csv_file.get_path() or "").strip() + folder_mode = self.mode_folder_rb.isChecked() + pred_dir = (self.prediction_csv_dir_edit.text() or "").strip() + geotiff_path = (self.geotiff_file.get_path() or "").strip() + config = { + "step10_batch_mode": "folder" if folder_mode else "single", + "render_mode": self.render_mode_combo.currentText(), + "prediction_csv_dir": pred_dir if pred_dir else None, + "recursive_csv_scan": self.recursive_csv_cb.isChecked(), + "prediction_csv_path": None if folder_mode else (pred_csv if pred_csv else None), + "geotiff_path": geotiff_path if geotiff_path else None, + "geotiff_dir": (self.geotiff_dir_edit.text() or "").strip() or None, + "boundary_shp_path": self.boundary_file.get_path(), + "resolution": self.resolution.value(), + "input_crs": self.input_crs.text(), + "output_crs": self.output_crs.text(), + "show_sample_points": self.show_points.isChecked(), + "use_distance_diffusion": self.use_diffusion.isChecked(), + "enabled": self.enable_checkbox.isChecked(), + } + out_dir = (self.output_dir.get_path() or "").strip() + if not folder_mode and pred_csv and out_dir: + from pathlib import Path + stem = Path(pred_csv).stem + config["output_image_path"] = str(Path(out_dir) / f"{stem}_distribution.png") + else: + config["output_image_path"] = None + return config + + def set_config(self, config: dict): + mode = config.get("step10_batch_mode", "single") + if mode == "folder": + self.mode_folder_rb.setChecked(True) + else: + self.mode_single_rb.setChecked(True) + if "render_mode" in config: + idx = self.render_mode_combo.findText(config["render_mode"]) + if idx >= 0: + self.render_mode_combo.setCurrentIndex(idx) + if config.get("prediction_csv_dir"): + self.prediction_csv_dir_edit.setText(str(config["prediction_csv_dir"])) + if "recursive_csv_scan" in config: + self.recursive_csv_cb.setChecked(bool(config["recursive_csv_scan"])) + if config.get("prediction_csv_path"): + self.prediction_csv_file.set_path(str(config["prediction_csv_path"])) + if config.get("geotiff_path"): + self.geotiff_file.set_path(str(config["geotiff_path"])) + if config.get("geotiff_dir"): + self.geotiff_dir_edit.setText(str(config["geotiff_dir"])) + if "boundary_shp_path" in config: + self.boundary_file.set_path(config["boundary_shp_path"]) + if "resolution" in config: + self.resolution.setValue(config["resolution"]) + if "input_crs" in config: + self.input_crs.setText(config["input_crs"]) + if "output_crs" in config: + self.output_crs.setText(config["output_crs"]) + if "show_sample_points" in config: + self.show_points.setChecked(config["show_sample_points"]) + if "use_distance_diffusion" in config: + self.use_diffusion.setChecked(config["use_distance_diffusion"]) + if config.get("output_dir"): + self.output_dir.set_path(str(config["output_dir"])) + elif config.get("output_image_path"): + from pathlib import Path + p = Path(str(config["output_image_path"])) + if p.parent and str(p.parent) != ".": + self.output_dir.set_path(str(p.parent)) + if "enabled" in config: + self.enable_checkbox.setChecked(config["enabled"]) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if not work_dir: + return + output_dir = _resolve_subdir(work_dir, "visualization") + os.makedirs(output_dir, exist_ok=True) + if not self.output_dir.get_path(): + self.output_dir.set_path(output_dir) + # 自动填预测 CSV 目录(9_ML_Prediction) + ml_pred_dir = os.path.join(_resolve_subdir(work_dir, "9_ML_Prediction")).replace("\\", "/") + if os.path.isdir(ml_pred_dir) and not self.prediction_csv_dir_edit.text(): + self.prediction_csv_dir_edit.setText(ml_pred_dir) + self.mode_folder_rb.setChecked(True) + # 自动填边界文件 + water_mask_dir = _resolve_subdir(work_dir, "water_mask") + if os.path.isdir(water_mask_dir) and not self.boundary_file.get_path(): + from pathlib import Path + candidates = ( + sorted(Path(water_mask_dir).glob("*.shp")) + + sorted(Path(water_mask_dir).glob("*.dat")) + ) + if candidates: + self.boundary_file.set_path(str(candidates[0])) + + # ------------------------------------------------------------------ + # 执行入口 + # ------------------------------------------------------------------ + def _on_run_clicked(self): + self.dispatch_execute("step11", self.get_config()) diff --git a/src/new/views/step12_view.py b/src/new/views/step12_view.py new file mode 100644 index 0000000..22d1413 --- /dev/null +++ b/src/new/views/step12_view.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +""" +Step12View —— Step 12(可视化)的端到端模块化 view(精简版) + +UI 从 ``src/gui/panels/step12_viz_panel.py``(1882 行巨型)做大幅精简。 + +精简原则 +======== + +- **删除全部 matplotlib / ImageViewerWidget / ChartViewerDialog / + VisualizationWorkerThread / PandasTableModel / ChartBrowserDialog** 等子控件; + 这些是「运行时画图」所需的复杂组件,由 service + 独立图片查看器窗口接管。 +- **保留可视化配置 checkbox 组**(5 个:散点/光谱/箱线/掩膜缩略/采样地图)—— + 这是用户实际可控的业务选项。 +- **保留工作目录 / 图像目录选择 + 扫描 / 生成全部按钮**——给用户「 + 「一键触发可视化」的入口;具体生成逻辑由 service 接管。 +""" + +import os + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QCheckBox, QFrame, QGroupBox, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QVBoxLayout, QWidget, +) + +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step12View(BaseView): + """Step 12: 可视化(精简版)""" + + def init_ui(self): + main_layout = QHBoxLayout() + main_layout.setSpacing(10) + main_layout.setContentsMargins(10, 10, 10, 10) + + # ===== 左侧面板 ===== + left_panel = QWidget() + left_layout = QVBoxLayout() + left_layout.setContentsMargins(0, 0, 0, 0) + + title = QLabel("步骤12:可视化") + title.setFont(QFont("Arial", 12, QFont.Bold)) + left_layout.addWidget(title) + + # 工作目录选择 + dir_group = QGroupBox("工作目录") + dir_layout = QHBoxLayout() + self.work_dir_edit = QLineEdit() + self.work_dir_edit.setPlaceholderText("选择工作目录...") + self.work_dir_edit.setReadOnly(True) + dir_browse_btn = QPushButton("浏览") + dir_browse_btn.clicked.connect(self.browse_work_dir) + dir_layout.addWidget(self.work_dir_edit, 1) + dir_layout.addWidget(dir_browse_btn) + dir_group.setLayout(dir_layout) + left_layout.addWidget(dir_group) + + # 图像目录(自动填充) + img_dir_group = QGroupBox("图像目录(预测结果)") + img_dir_layout = QHBoxLayout() + self.img_dir_edit = QLineEdit() + self.img_dir_edit.setPlaceholderText("预测结果目录(自动填充)…") + self.img_dir_edit.setReadOnly(True) + img_dir_browse_btn = QPushButton("浏览") + img_dir_browse_btn.clicked.connect(self.browse_img_dir) + img_dir_layout.addWidget(self.img_dir_edit, 1) + img_dir_layout.addWidget(img_dir_browse_btn) + img_dir_group.setLayout(img_dir_layout) + left_layout.addWidget(img_dir_group) + + # 可视化配置 + config_group = QGroupBox("可视化配置") + config_layout = QVBoxLayout() + + self.gen_scatter = QCheckBox("模型评估散点图") + self.gen_scatter.setChecked(True) + config_layout.addWidget(self.gen_scatter) + + self.gen_spectrum = QCheckBox("光谱曲线图") + self.gen_spectrum.setChecked(True) + config_layout.addWidget(self.gen_spectrum) + + self.gen_boxplots = QCheckBox("统计图表(箱线图)") + self.gen_boxplots.setChecked(True) + config_layout.addWidget(self.gen_boxplots) + + self.gen_mask_glint = QCheckBox("掩膜和耀斑缩略图") + self.gen_mask_glint.setChecked(True) + config_layout.addWidget(self.gen_mask_glint) + + self.gen_sampling_map = QCheckBox("采样点地图") + self.gen_sampling_map.setChecked(True) + config_layout.addWidget(self.gen_sampling_map) + + config_layout.addSpacing(10) + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setStyleSheet("color: #ddd;") + config_layout.addWidget(line) + config_layout.addSpacing(10) + + self.gen_all_btn = QPushButton("🚀 生成全部") + self.gen_all_btn.setToolTip("生成所有类型的可视化图表") + self.gen_all_btn.setStyleSheet( + "background-color: #4CAF50; color: white; font-weight: bold;" + ) + self.gen_all_btn.clicked.connect(self._on_run_clicked) + config_layout.addWidget(self.gen_all_btn) + + self.scan_btn = QPushButton("📁 扫描目录") + self.scan_btn.setToolTip("扫描工作目录中的图像文件") + self.scan_btn.clicked.connect(self._on_scan_clicked) + config_layout.addWidget(self.scan_btn) + + config_group.setLayout(config_layout) + left_layout.addWidget(config_group) + + left_panel.setLayout(left_layout) + left_panel.setMaximumWidth(350) + main_layout.addWidget(left_panel, 0) + + # ===== 右侧占位 ===== + right_panel = QWidget() + right_layout = QVBoxLayout() + right_layout.setContentsMargins(0, 0, 0, 0) + placeholder = QLabel( + "可视化结果由 service 端生成,\n" + "生成完成后可在主窗口日志区查看文件路径。\n" + "(精简版 view 不内嵌 matplotlib 画布。)" + ) + placeholder.setAlignment(Qt.AlignCenter | Qt.AlignVCenter) + placeholder.setStyleSheet( + "color: #999; font-size: 13px; padding: 40px;" + ) + right_layout.addWidget(placeholder, 1) + right_panel.setLayout(right_layout) + main_layout.addWidget(right_panel, 1) + + self.setLayout(main_layout) + + # ------------------------------------------------------------------ + # 槽函数 + # ------------------------------------------------------------------ + def browse_work_dir(self): + from PyQt5.QtWidgets import QFileDialog + default = self._get_default_work_dir() + dir_path = QFileDialog.getExistingDirectory(self, "选择工作目录", default) + if dir_path: + self.work_dir = dir_path + self.work_dir_edit.setText(dir_path) + + def browse_img_dir(self): + from PyQt5.QtWidgets import QFileDialog + default = self._get_default_work_dir() + dir_path = QFileDialog.getExistingDirectory(self, "选择图像目录", default) + if dir_path: + self.img_dir_edit.setText(dir_path) + + def _get_default_work_dir(self) -> str: + if hasattr(self, "work_dir") and self.work_dir: + return str(self.work_dir) + return "" + + # ------------------------------------------------------------------ + # BaseView 契约 + # ------------------------------------------------------------------ + def get_config(self) -> dict: + return { + "work_dir": self.work_dir_edit.text(), + "img_dir": self.img_dir_edit.text(), + "generate_scatter": self.gen_scatter.isChecked(), + "generate_spectrum": self.gen_spectrum.isChecked(), + "generate_boxplots": self.gen_boxplots.isChecked(), + "generate_glint_previews": self.gen_mask_glint.isChecked(), + "generate_sampling_maps": self.gen_sampling_map.isChecked(), + "enabled": True, + } + + def set_config(self, config: dict): + if not config: + return + if config.get("work_dir"): + self.work_dir_edit.setText(str(config["work_dir"])) + self.work_dir = str(config["work_dir"]) + if config.get("img_dir"): + self.img_dir_edit.setText(str(config["img_dir"])) + if "generate_scatter" in config: + self.gen_scatter.setChecked(config["generate_scatter"]) + if "generate_boxplots" in config: + self.gen_boxplots.setChecked(config["generate_boxplots"]) + if "generate_spectrum" in config: + self.gen_spectrum.setChecked(config["generate_spectrum"]) + if "generate_glint_previews" in config: + self.gen_mask_glint.setChecked(config["generate_glint_previews"]) + if "generate_sampling_maps" in config: + self.gen_sampling_map.setChecked(config.get("generate_sampling_maps", True)) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if not work_dir: + return + self.work_dir = work_dir + self.work_dir_edit.setText(str(work_dir)) + # 自动填图像目录 + for sub in ("prediction_dir", "visualization"): + candidate = _resolve_subdir(work_dir, sub) + if os.path.isdir(candidate): + self.img_dir_edit.setText(candidate) + break + else: + self.img_dir_edit.setText(work_dir) + + # ------------------------------------------------------------------ + # 执行入口 + # ------------------------------------------------------------------ + def _on_run_clicked(self): + self.dispatch_execute("step12", self.get_config()) + + def _on_scan_clicked(self): + # 扫描仅刷新工作目录和图像目录的显示;不实际派发到 service + work_dir = self.work_dir_edit.text() + if work_dir and os.path.isdir(work_dir): + self.img_dir_edit.setText(work_dir) diff --git a/src/new/views/step13_view.py b/src/new/views/step13_view.py new file mode 100644 index 0000000..8a79f64 --- /dev/null +++ b/src/new/views/step13_view.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +""" +Step13View —— Step 13(报告生成)的端到端模块化 view + +UI 从 ``src/gui/panels/step13_report_panel.py`` 原样搬迁。 + +view 层职责 +=========== + +- 路径组(工作目录 / 输出目录 / 报告标题)+ AI 分析开关 + 高级配置入口 + + 当前 AI 引擎只读标签全部保留。 +- ``ReportGenerateThread`` 删除(service 接管后台生成); + AI 配置(Provider / API Key / 模型 / 超时)由 service 通过 QSettings + 直接读取,view 不持有任何 AI 状态。 +- ``AISettingsDialog`` 调用入口保留(main_view 可以在状态栏或工具栏 + 提供全局入口;本 view 仅暴露 button 让用户即时调出配置)。 +""" + +import os + +from PyQt5.QtCore import QSettings +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QCheckBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QVBoxLayout, +) + +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +# 与 src.gui.dialogs.AISettingsDialog 保持一致 +AI_SETTINGS_ORG = "WQ_GUI" +AI_SETTINGS_APP = "AI" + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step13View(BaseView): + """Step 13: 分析报告生成""" + + def init_ui(self): + layout = QVBoxLayout() + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + + title = QLabel("步骤13:分析报告生成") + title.setFont(QFont("Arial", 12, QFont.Bold)) + layout.addWidget(title) + + intro = QLabel( + "根据工作目录下的可视化结果(14_visualization 等)生成 Word 分析报告。" + "需已存在可视化图表;AI 分析通过 Ollama 或 Minimax 调用云端/本地服务。" + ) + intro.setWordWrap(True) + intro.setStyleSheet( + f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};" + ) + layout.addWidget(intro) + + # ── 路径 ────────────────────────────────────────────────────────────── + path_group = QGroupBox("路径") + path_form = QFormLayout() + + wd_row = QHBoxLayout() + self.work_dir_edit = QLineEdit() + self.work_dir_edit.setPlaceholderText( + "选择流程工作目录(含 14_visualization)…" + ) + wd_browse = QPushButton("浏览…") + wd_browse.clicked.connect(self.browse_work_dir) + sync_btn = QPushButton("同步主窗口工作目录") + sync_btn.clicked.connect(self._sync_work_dir) + wd_row.addWidget(self.work_dir_edit, 1) + wd_row.addWidget(wd_browse) + wd_row.addWidget(sync_btn) + path_form.addRow("工作目录:", wd_row) + + out_row = QHBoxLayout() + self.output_dir_edit = QLineEdit() + self.output_dir_edit.setPlaceholderText( + "留空则保存到 工作目录/14_visualization" + ) + out_browse = QPushButton("浏览…") + out_browse.clicked.connect(self.browse_output_dir) + out_row.addWidget(self.output_dir_edit, 1) + out_row.addWidget(out_browse) + path_form.addRow("报告输出目录:", out_row) + + self.report_title_edit = QLineEdit() + self.report_title_edit.setText("水质参数反演分析报告") + path_form.addRow("报告标题:", self.report_title_edit) + + path_group.setLayout(path_form) + layout.addWidget(path_group) + + # ── AI 分析 ─────────────────────────────────────────────────────────── + ai_group = QGroupBox("AI 分析") + ai_layout = QVBoxLayout() + + self.enable_ai_cb = QCheckBox("启用 AI 图表解读与综合总结") + self.enable_ai_cb.setChecked( + os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"} + ) + ai_layout.addWidget(self.enable_ai_cb) + + ai_status_row = QHBoxLayout() + ai_status_row.addWidget(QLabel("当前 AI 引擎:")) + self._ai_label = QLabel() + self._ai_label.setStyleSheet("color: #0078d4; font-weight: bold;") + ai_status_row.addWidget(self._ai_label) + ai_status_row.addStretch(1) + open_settings_btn = QPushButton("高级配置...") + open_settings_btn.clicked.connect(self._open_ai_settings) + ai_status_row.addWidget(open_settings_btn) + ai_layout.addLayout(ai_status_row) + + ai_group.setLayout(ai_layout) + layout.addWidget(ai_group) + + # ── 按钮 ────────────────────────────────────────────────────────────── + btn_row = QHBoxLayout() + self.generate_btn = QPushButton("生成 Word 报告") + self.generate_btn.setStyleSheet( + ModernStylesheet.get_button_stylesheet("success") + ) + self.generate_btn.clicked.connect(self._on_run_clicked) + btn_row.addWidget(self.generate_btn) + btn_row.addStretch() + layout.addLayout(btn_row) + + layout.addStretch() + self.setLayout(layout) + + # 刷新引擎提示 + self._refresh_ai_label() + + # ------------------------------------------------------------------ + # 槽函数 + # ------------------------------------------------------------------ + def _refresh_ai_label(self): + s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) + provider = s.value("ai_provider", "minimax", type=str) + label_map = {"ollama": "Ollama (本地)", "minimax": "Minimax (云端)"} + self._ai_label.setText(label_map.get(provider, provider)) + + def _open_ai_settings(self): + # AISettingsDialog 仍是旧 GUI 路径下的全局组件; + # 弹出对话框失败时降级为提示(service 重写时可全局替换入口) + try: + from src.gui.dialogs import AISettingsDialog + dlg = AISettingsDialog(self) + if dlg.exec_(): + self._refresh_ai_label() + except Exception as e: + print(f"[step13] AISettingsDialog 不可用: {e}") + + def _sync_work_dir(self): + mw = self.parent() + if mw is not None and getattr(mw, "work_dir", None): + self.work_dir_edit.setText(str(mw.work_dir)) + else: + print("[step13] 主窗口尚未设置工作目录。") + + def browse_work_dir(self): + from PyQt5.QtWidgets import QFileDialog + default = self._get_default_work_dir() + d = QFileDialog.getExistingDirectory(self, "选择工作目录", default) + if d: + self.work_dir_edit.setText(d) + + def browse_output_dir(self): + from PyQt5.QtWidgets import QFileDialog + default = self._get_default_work_dir() + if default: + default = _resolve_subdir(default, "visualization") + d = QFileDialog.getExistingDirectory(self, "选择报告输出目录", default) + if d: + self.output_dir_edit.setText(d) + + def _get_default_work_dir(self) -> str: + if hasattr(self, "work_dir") and self.work_dir: + return str(self.work_dir) + mw = self.parent() + if mw and hasattr(mw, "work_dir") and mw.work_dir: + return str(mw.work_dir) + return "" + + # ------------------------------------------------------------------ + # BaseView 契约 + # ------------------------------------------------------------------ + def get_config(self) -> dict: + return { + "work_dir": self.work_dir_edit.text().strip() or None, + "output_dir": self.output_dir_edit.text().strip() or None, + "report_title": self.report_title_edit.text().strip() or "水质参数反演分析报告", + "enable_ai_analysis": self.enable_ai_cb.isChecked(), + "enabled": True, + } + + def set_config(self, config: dict): + if not config: + return + if config.get("work_dir"): + self.work_dir_edit.setText(str(config["work_dir"])) + if "output_dir" in config: + self.output_dir_edit.setText(str(config["output_dir"] or "")) + if config.get("report_title"): + self.report_title_edit.setText(str(config["report_title"])) + if "enable_ai_analysis" in config: + self.enable_ai_cb.setChecked(bool(config["enable_ai_analysis"])) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if not work_dir: + return + self.work_dir_edit.setText(work_dir) + # 默认输出目录 = 工作目录/14_visualization + if not self.output_dir_edit.text().strip(): + out_dir = _resolve_subdir(work_dir, "visualization") + self.output_dir_edit.setText(out_dir) + + # ------------------------------------------------------------------ + # 执行入口 + # ------------------------------------------------------------------ + def _on_run_clicked(self): + self.dispatch_execute("step13", self.get_config()) diff --git a/src/new/views/step2_view.py b/src/new/views/step2_view.py new file mode 100644 index 0000000..16baea4 --- /dev/null +++ b/src/new/views/step2_view.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +""" +Step2View —— Step 2(耀斑区域识别)的端到端模块化 view + +设计原则 +======== + +1. **UI 完整搬迁**:从 ``src/gui/panels/step2_panel.py`` 原样搬迁 init_ui 全部控件。 +2. **零业务逻辑**:本文件绝不 import 任何 ``src/core/``、``src/services/`` 算法; + 唯一跨层出口是底部按钮的 ``self.dispatch_execute("step2", self.get_config())``。 +3. **BaseView 契约**:继承 ``BaseView``;实现 ``init_ui / get_config / set_config``; + ``update_work_directory`` 保留 work_dir 自动填输出路径的行为。 + +调用链(自顶向下):: + + Step2View._on_run_clicked + → BaseView.dispatch_execute("step2", config) + → MainView.run_single_step(step_id, config) + → TaskWorker → services.placeholder_service.execute_placeholder(config) + → 返回结果 dict → MainView 日志区 +""" + +import os + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, + QPushButton, QSpinBox, QVBoxLayout, QWidget, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + """view 内联的子目录解析(替代旧 ``_step_path_resolver.resolve_subdir``)""" + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step2View(BaseView): + """Step 2: 耀斑区域识别 —— 纯 View,所有跨层通讯走 dispatch_execute""" + + def init_ui(self): + layout = QVBoxLayout() + + # 影像文件 + self.img_file = FileSelectWidget( + "影像文件:", + "Image Files (*.bsq *.dat *.tif);;All Files (*.*)", + ) + layout.addWidget(self.img_file) + + # 水域掩膜文件(可选,用于独立运行) + self.water_mask_file = FileSelectWidget( + "水域掩膜:", + "Mask Files (*.dat *.tif);;All Files (*.*)", + ) + layout.addWidget(self.water_mask_file) + + # 参数设置 + params_group = QGroupBox("检测参数") + params_layout = QFormLayout() + + self.glint_wave = QDoubleSpinBox() + self.glint_wave.setRange(300, 1000) + self.glint_wave.setValue(750.0) + self.glint_wave.setSuffix(" nm") + params_layout.addRow("耀斑检测波长:", self.glint_wave) + + self.method = QComboBox() + for text, data in [("Otsu 阈值法", "otsu"), ("Z-Score 方法", "zscore"), + ("百分位数法", "percentile"), ("IQR 四分位距法", "iqr"), + ("自适应阈值法", "adaptive"), ("多波段综合法", "multi_band")]: + self.method.addItem(text, data) + params_layout.addRow("检测方法:", self.method) + + self.max_area = QSpinBox() + self.max_area.setRange(0, 100000) + self.max_area.setValue(50) + self.max_area.setSpecialValueText("不过滤") + params_layout.addRow("最大连通域面积:", self.max_area) + + self.buffer_size = QSpinBox() + self.buffer_size.setRange(0, 200) + self.buffer_size.setValue(10) + self.buffer_size.setSpecialValueText("不设置") + params_layout.addRow("岸边缓冲区大小:", self.buffer_size) + + params_group.setLayout(params_layout) + layout.addWidget(params_group) + + # 输出文件路径 + self.output_file = FileSelectWidget( + "输出耀斑掩膜:", + "Mask Files (*.dat *.tif);;All Files (*.*)", + ) + layout.addWidget(self.output_file) + + # 启用步骤 + self.enable_checkbox = QCheckBox("启用此步骤") + self.enable_checkbox.setChecked(True) + layout.addWidget(self.enable_checkbox) + + # 执行按钮(绿色 success 样式) + self.run_btn = QPushButton("执行 Step 2: 耀斑区域识别") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success")) + self.run_btn.clicked.connect(self._on_run_clicked) + layout.addWidget(self.run_btn) + + layout.addStretch() + self.setLayout(layout) + + # ------------------------------------------------------------------ + # BaseView 契约 + # ------------------------------------------------------------------ + def get_config(self) -> dict: + """读取当前 UI 状态——返回干净字典供 dispatch_execute 携带""" + config = { + "img_path": self.img_file.get_path(), + "glint_wave": self.glint_wave.value(), + "method": self.method.currentData(), + "enabled": self.enable_checkbox.isChecked(), + } + if self.max_area.value() > 0: + config["max_area"] = self.max_area.value() + if self.buffer_size.value() > 0: + config["buffer_size"] = self.buffer_size.value() + water_mask_path = self.water_mask_file.get_path() + if water_mask_path: + config["water_mask_path"] = water_mask_path + output_path = self.output_file.get_path() + if output_path: + config["output_path"] = output_path + return config + + def set_config(self, config: dict): + """根据 config 恢复 UI 状态——None/空值字段一律跳过""" + if config.get("img_path"): + self.img_file.set_path(config["img_path"]) + if "glint_wave" in config: + self.glint_wave.setValue(config["glint_wave"]) + if config.get("method"): + idx = self.method.findData(config["method"]) + if idx >= 0: + self.method.setCurrentIndex(idx) + if "max_area" in config: + self.max_area.setValue(config["max_area"]) + if "buffer_size" in config: + self.buffer_size.setValue(config["buffer_size"]) + if config.get("water_mask_path"): + self.water_mask_file.set_path(config["water_mask_path"]) + if config.get("output_path"): + self.output_file.set_path(config["output_path"]) + + def update_work_directory(self, work_dir: str): + """主窗口推送工作目录变更——自动填输出路径(2_Glint_Detection)""" + super().update_work_directory(work_dir) + if work_dir: + output_dir = _resolve_subdir(work_dir, "2_Glint_Detection") + os.makedirs(output_dir, exist_ok=True) + default_output_path = os.path.join(output_dir, "severe_glint_area.dat").replace("\\", "/") + self.output_file.set_path(default_output_path) + + # ------------------------------------------------------------------ + # 执行入口 + # ------------------------------------------------------------------ + def _on_run_clicked(self): + """绿色按钮:纯 View 层入口,把当前 UI 配置打包交给主窗口""" + self.dispatch_execute("step2", self.get_config()) diff --git a/src/new/views/step3_view.py b/src/new/views/step3_view.py new file mode 100644 index 0000000..c642b2a --- /dev/null +++ b/src/new/views/step3_view.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +""" +Step3View —— Step 3(耀斑去除)的端到端模块化 view + +UI 从 ``src/gui/panels/step3_panel.py`` 原样搬迁;含 4 种去耀斑方法 +(Goodman / Kutser / Hedley / SUGAR)的参数组切换。 +""" + +import os + +from PyQt5.QtWidgets import ( + QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, + QLabel, QLineEdit, QPushButton, QSpinBox, QVBoxLayout, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step3View(BaseView): + """Step 3: 耀斑去除""" + + def init_ui(self): + layout = QVBoxLayout() + + # 影像文件 + self.img_file = FileSelectWidget( + "影像文件:", + "Image Files (*.bsq *.dat *.tif);;All Files (*.*)", + ) + layout.addWidget(self.img_file) + + # 水域掩膜/边界 + self.water_mask_file = FileSelectWidget( + "水域掩膜/边界:", + "Mask/Boundary (*.dat *.tif *.shp);;All Files (*.*)", + ) + layout.addWidget(self.water_mask_file) + + hint = QLabel( + "提示:独立运行本步骤时必须选择水域掩膜或边界(与影像同区域的 .dat/.tif 掩膜,或 .shp 矢量)。" + ) + hint.setWordWrap(True) + hint.setStyleSheet("color: #666; font-size: 10px;") + layout.addWidget(hint) + + # 方法选择 + method_group = QGroupBox("去耀斑方法") + method_layout = QVBoxLayout() + self.method = QComboBox() + for text, data in [("Goodman方法", "goodman"), ("Kutser方法", "kutser"), + ("Hedley方法", "hedley"), ("SUGAR算法", "sugar")]: + self.method.addItem(text, data) + self.method.currentIndexChanged.connect(self._on_method_changed) + method_layout.addWidget(self.method) + method_group.setLayout(method_layout) + layout.addWidget(method_group) + + # Goodman 参数组 + self.goodman_group = QGroupBox("Goodman方法参数") + goodman_layout = QFormLayout() + self.nir_lower = QSpinBox() + self.nir_lower.setRange(0, 200) + self.nir_lower.setValue(65) + goodman_layout.addRow("NIR下波段索引:", self.nir_lower) + self.nir_upper = QSpinBox() + self.nir_upper.setRange(0, 200) + self.nir_upper.setValue(91) + goodman_layout.addRow("NIR上波段索引:", self.nir_upper) + self.goodman_a = QDoubleSpinBox() + self.goodman_a.setDecimals(6) + self.goodman_a.setRange(0, 1) + self.goodman_a.setValue(0.000019) + goodman_layout.addRow("参数A:", self.goodman_a) + self.goodman_b = QDoubleSpinBox() + self.goodman_b.setDecimals(2) + self.goodman_b.setRange(0, 1) + self.goodman_b.setValue(0.1) + goodman_layout.addRow("参数B:", self.goodman_b) + self.goodman_group.setLayout(goodman_layout) + layout.addWidget(self.goodman_group) + + # Kutser 参数组 + self.kutser_group = QGroupBox("Kutser方法参数") + kutser_layout = QFormLayout() + self.oxy_band = QSpinBox() + self.oxy_band.setRange(0, 200) + self.oxy_band.setValue(38) + kutser_layout.addRow("氧吸收波段索引:", self.oxy_band) + self.lower_oxy = QSpinBox() + self.lower_oxy.setRange(0, 200) + self.lower_oxy.setValue(36) + kutser_layout.addRow("下氧吸收波段索引:", self.lower_oxy) + self.upper_oxy = QSpinBox() + self.upper_oxy.setRange(0, 200) + self.upper_oxy.setValue(49) + kutser_layout.addRow("上氧吸收波段索引:", self.upper_oxy) + self.nir_band = QSpinBox() + self.nir_band.setRange(0, 200) + self.nir_band.setValue(47) + kutser_layout.addRow("NIR波段索引:", self.nir_band) + self.kutser_group.setLayout(kutser_layout) + self.kutser_group.setVisible(False) + layout.addWidget(self.kutser_group) + + # Hedley 参数组 + self.hedley_group = QGroupBox("Hedley方法参数") + hedley_layout = QFormLayout() + self.hedley_nir_band = QSpinBox() + self.hedley_nir_band.setRange(0, 200) + self.hedley_nir_band.setValue(47) + hedley_layout.addRow("NIR波段索引:", self.hedley_nir_band) + self.hedley_group.setLayout(hedley_layout) + self.hedley_group.setVisible(False) + layout.addWidget(self.hedley_group) + + # SUGAR 参数组 + self.sugar_group = QGroupBox("SUGAR方法参数") + sugar_layout = QFormLayout() + self.sugar_iter = QSpinBox() + self.sugar_iter.setRange(1, 20) + self.sugar_iter.setValue(3) + sugar_layout.addRow("迭代次数:", self.sugar_iter) + self.sugar_sigma = QDoubleSpinBox() + self.sugar_sigma.setDecimals(2) + self.sugar_sigma.setRange(0.1, 10) + self.sugar_sigma.setValue(1.0) + sugar_layout.addRow("LoG平滑σ:", self.sugar_sigma) + self.sugar_estimate_background = QCheckBox() + self.sugar_estimate_background.setChecked(True) + sugar_layout.addRow("估计背景光谱:", self.sugar_estimate_background) + self.sugar_glint_mask_method = QComboBox() + self.sugar_glint_mask_method.addItems(["cdf", "otsu"]) + sugar_layout.addRow("耀斑掩膜方法:", self.sugar_glint_mask_method) + self.sugar_termination_thresh = QDoubleSpinBox() + self.sugar_termination_thresh.setDecimals(2) + self.sugar_termination_thresh.setRange(1, 100) + self.sugar_termination_thresh.setValue(20.0) + sugar_layout.addRow("终止阈值:", self.sugar_termination_thresh) + self.sugar_bounds = QLineEdit() + self.sugar_bounds.setText("[(1, 2)]") + sugar_layout.addRow("优化边界:", self.sugar_bounds) + self.sugar_group.setLayout(sugar_layout) + self.sugar_group.setVisible(False) + layout.addWidget(self.sugar_group) + + # 插值选项 + interp_group = QGroupBox("0值像素插值") + interp_layout = QFormLayout() + self.interpolate_zeros = QCheckBox("启用插值") + interp_layout.addRow("", self.interpolate_zeros) + self.interp_method = QComboBox() + for text, data in [("最近邻插值", "nearest"), ("双线性插值", "bilinear"), + ("样条插值", "spline"), ("克里金插值", "kriging")]: + self.interp_method.addItem(text, data) + self.interp_method.setCurrentIndex(1) + interp_layout.addRow("插值方法:", self.interp_method) + interp_group.setLayout(interp_layout) + layout.addWidget(interp_group) + + # 输出文件路径 + self.output_file = FileSelectWidget( + "输出影像:", + "Image Files (*.bsq *.dat *.tif);;All Files (*.*)", + ) + self.output_file.line_edit.setPlaceholderText("deglint_image.bsq") + layout.addWidget(self.output_file) + + # 启用步骤 + self.enable_checkbox = QCheckBox("启用此步骤") + self.enable_checkbox.setChecked(True) + layout.addWidget(self.enable_checkbox) + + # 执行按钮 + self.run_btn = QPushButton("执行 Step 3: 耀斑去除") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success")) + self.run_btn.clicked.connect(self._on_run_clicked) + layout.addWidget(self.run_btn) + + layout.addStretch() + self.setLayout(layout) + + def _on_method_changed(self, index): + """方法切换时显示对应参数组""" + method_id = self.method.currentData() + self.goodman_group.setVisible(method_id == "goodman") + self.kutser_group.setVisible(method_id == "kutser") + self.hedley_group.setVisible(method_id == "hedley") + self.sugar_group.setVisible(method_id == "sugar") + + def get_config(self) -> dict: + config = { + "img_path": self.img_file.get_path(), + "method": self.method.currentData(), + "enabled": self.enable_checkbox.isChecked(), + "interpolate_zeros": self.interpolate_zeros.isChecked(), + "interpolation_method": self.interp_method.currentData(), + } + water_mask_path = self.water_mask_file.get_path() + if water_mask_path: + config["water_mask_path"] = water_mask_path + output_path = self.output_file.get_path() + if output_path: + config["output_path"] = output_path + + method = self.method.currentData() + if method == "goodman": + config["nir_lower"] = self.nir_lower.value() + config["nir_upper"] = self.nir_upper.value() + config["goodman_A"] = self.goodman_a.value() + config["goodman_B"] = self.goodman_b.value() + elif method == "kutser": + config["oxy_band"] = self.oxy_band.value() + config["lower_oxy"] = self.lower_oxy.value() + config["upper_oxy"] = self.upper_oxy.value() + config["nir_band"] = self.nir_band.value() + elif method == "hedley": + config["hedley_nir_band"] = self.hedley_nir_band.value() + elif method == "sugar": + config["sugar_iter"] = self.sugar_iter.value() + config["sugar_sigma"] = self.sugar_sigma.value() + config["sugar_estimate_background"] = self.sugar_estimate_background.isChecked() + config["sugar_glint_mask_method"] = self.sugar_glint_mask_method.currentText() + config["sugar_termination_thresh"] = self.sugar_termination_thresh.value() + try: + import ast + config["sugar_bounds"] = ast.literal_eval(self.sugar_bounds.text()) + except Exception: + config["sugar_bounds"] = [(1, 2)] + return config + + def set_config(self, config: dict): + if config.get("img_path"): + self.img_file.set_path(config["img_path"]) + if config.get("water_mask_path"): + self.water_mask_file.set_path(config["water_mask_path"]) + if config.get("output_path"): + self.output_file.set_path(config["output_path"]) + if config.get("method"): + idx = self.method.findData(config["method"]) + if idx >= 0: + self.method.setCurrentIndex(idx) + if "enabled" in config: + self.enable_checkbox.setChecked(config["enabled"]) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if work_dir: + output_dir = _resolve_subdir(work_dir, "3_Deglint") + os.makedirs(output_dir, exist_ok=True) + default_output_path = os.path.join(output_dir, "deglint_image.bsq").replace("\\", "/") + self.output_file.set_path(default_output_path) + + def _on_run_clicked(self): + self.dispatch_execute("step3", self.get_config()) diff --git a/src/new/views/step4_view.py b/src/new/views/step4_view.py new file mode 100644 index 0000000..eeeb1cc --- /dev/null +++ b/src/new/views/step4_view.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +""" +Step4View —— Step 4(采样点布设)的端到端模块化 view + +UI 从 ``src/gui/panels/step4_sampling_panel.py`` 原样搬迁。 +注:原 panel 中的 QTimer 心跳检查(``_check_csv_exists``)和交互式预览按钮 +在 view 层留接口位,运行时实际由 main_view 的 dispatch_execute 路由到 service +触发状态更新(后续 service 迁移时再补全交互逻辑)。 +""" + +import os + +from PyQt5.QtWidgets import ( + QCheckBox, QFormLayout, QGroupBox, QPushButton, QSpinBox, QVBoxLayout, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step4View(BaseView): + """Step 4: 采样点布设""" + + def init_ui(self): + layout = QVBoxLayout() + + # 去耀斑影像文件(用于独立运行) + self.deglint_img_file = FileSelectWidget( + "去耀斑影像:", + "Image Files (*.bsq *.dat *.tif);;All Files (*.*)", + ) + layout.addWidget(self.deglint_img_file) + + # 水域掩膜文件(可选,用于独立运行) + self.water_mask_file = FileSelectWidget( + "水域掩膜:", + "Mask Files (*.dat *.tif);;All Files (*.*)", + ) + layout.addWidget(self.water_mask_file) + + # 参数设置 + params_group = QGroupBox("采样参数") + params_layout = QFormLayout() + + self.interval = QSpinBox() + self.interval.setRange(10, 500) + self.interval.setValue(50) + params_layout.addRow("采样点间隔(像素):", self.interval) + + self.sample_radius = QSpinBox() + self.sample_radius.setRange(1, 50) + self.sample_radius.setValue(5) + params_layout.addRow("采样半径(像素):", self.sample_radius) + + self.chunk_size = QSpinBox() + self.chunk_size.setRange(100, 10000) + self.chunk_size.setValue(1000) + params_layout.addRow("处理块大小:", self.chunk_size) + + self.use_adaptive_sampling = QCheckBox("启用自适应采样") + self.use_adaptive_sampling.setChecked(True) + params_layout.addRow("采样模式:", self.use_adaptive_sampling) + + params_group.setLayout(params_layout) + layout.addWidget(params_group) + + # 输出文件路径 + self.output_file = FileSelectWidget( + "输出采样点:", + "CSV Files (*.csv);;All Files (*.*)", + ) + self.output_file.line_edit.setPlaceholderText("sampling_spectra.csv") + layout.addWidget(self.output_file) + + # 启用步骤 + self.enable_checkbox = QCheckBox("启用此步骤") + self.enable_checkbox.setChecked(True) + layout.addWidget(self.enable_checkbox) + + # 执行按钮 + self.run_btn = QPushButton("执行 Step 4: 采样点布设") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success")) + self.run_btn.clicked.connect(self._on_run_clicked) + layout.addWidget(self.run_btn) + + layout.addStretch() + self.setLayout(layout) + + def get_config(self) -> dict: + config = { + "interval": self.interval.value(), + "sample_radius": self.sample_radius.value(), + "chunk_size": self.chunk_size.value(), + "use_adaptive_sampling": self.use_adaptive_sampling.isChecked(), + "enabled": self.enable_checkbox.isChecked(), + } + deglint_img_path = self.deglint_img_file.get_path() + if deglint_img_path: + config["deglint_img_path"] = deglint_img_path + water_mask_path = self.water_mask_file.get_path() + if water_mask_path: + config["water_mask_path"] = water_mask_path + output_path = self.output_file.get_path() + if output_path: + config["output_path"] = output_path + return config + + def set_config(self, config: dict): + if "interval" in config: + self.interval.setValue(config["interval"]) + if "sample_radius" in config: + self.sample_radius.setValue(config["sample_radius"]) + if "chunk_size" in config: + self.chunk_size.setValue(config["chunk_size"]) + if "use_adaptive_sampling" in config: + self.use_adaptive_sampling.setChecked(config["use_adaptive_sampling"]) + if config.get("deglint_img_path"): + self.deglint_img_file.set_path(config["deglint_img_path"]) + if config.get("water_mask_path"): + self.water_mask_file.set_path(config["water_mask_path"]) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if work_dir: + output_dir = _resolve_subdir(work_dir, "4_Sampling") + os.makedirs(output_dir, exist_ok=True) + default_output_path = os.path.join(output_dir, "sampling_spectra.csv").replace("\\", "/") + self.output_file.set_path(default_output_path) + + def _on_run_clicked(self): + self.dispatch_execute("step4", self.get_config()) diff --git a/src/new/views/step5_view.py b/src/new/views/step5_view.py new file mode 100644 index 0000000..73fd0d5 --- /dev/null +++ b/src/new/views/step5_view.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +""" +Step5View —— Step 5(数据清洗)的端到端模块化 view + +UI 从 ``src/gui/panels/step5_clean_panel.py`` 原样搬迁;CSV 预览表格在 +view 层保留静态占位(运行时由 main_view 控制实际刷新逻辑)。 +""" + +import os + +from PyQt5.QtWidgets import ( + QAbstractItemView, QCheckBox, QGroupBox, QHBoxLayout, QHeaderView, + QLabel, QPushButton, QSpinBox, QTableView, QVBoxLayout, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step5View(BaseView): + """Step 5: 数据清洗""" + + 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) + + # CSV 数据预览(静态占位) + 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.setEnabled(False) # 后续 service 迁移时再启用 + 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("执行 Step 5: 数据清洗") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success")) + self.run_btn.clicked.connect(self._on_run_clicked) + layout.addWidget(self.run_btn) + + layout.addStretch() + self.setLayout(layout) + + def get_config(self) -> dict: + config = { + "csv_path": self.csv_file.get_path(), + "enabled": self.enable_checkbox.isChecked(), + } + output_path = self.output_file.get_path() + if output_path: + config["output_path"] = output_path + return config + + def set_config(self, config: dict): + if config.get("csv_path"): + self.csv_file.set_path(config["csv_path"]) + if config.get("output_path"): + self.output_file.set_path(config["output_path"]) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if work_dir: + output_dir = _resolve_subdir(work_dir, "5_Data_Cleaning") + 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) + + def _on_run_clicked(self): + self.dispatch_execute("step5", self.get_config()) diff --git a/src/new/views/step6_view.py b/src/new/views/step6_view.py new file mode 100644 index 0000000..a643cf9 --- /dev/null +++ b/src/new/views/step6_view.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +""" +Step6View —— Step 6(光谱特征提取)的端到端模块化 view + +UI 从 ``src/gui/panels/step6_feature_panel.py`` 原样搬迁。 +""" + +import os + +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QCheckBox, QFormLayout, QGroupBox, QLabel, QPushButton, QSpinBox, + QVBoxLayout, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step6View(BaseView): + """Step 6: 光谱特征提取""" + + def init_ui(self): + layout = QVBoxLayout() + + # 标题 + title = QLabel("步骤6:光谱特征提取") + 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文件(用于独立运行) + 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) + + # 耀斑掩膜文件 + self.glint_mask_file = FileSelectWidget( + "耀斑掩膜:", + "Mask Files (*.dat *.tif);;All Files (*.*)", + ) + layout.addWidget(self.glint_mask_file) + + glint_hint = QLabel( + "提示:独立运行本步骤时必须选择耀斑掩膜(通常为步骤2输出的 severe_glint_area.dat),用于在采样时避开耀斑像元。" + ) + glint_hint.setWordWrap(True) + glint_hint.setStyleSheet("color: #666; font-size: 10px;") + layout.addWidget(glint_hint) + + # 参数设置 + params_group = QGroupBox("提取参数") + params_layout = QFormLayout() + + self.radius = QSpinBox() + self.radius.setRange(1, 50) + self.radius.setValue(5) + params_layout.addRow("采样半径(像素):", self.radius) + + self.source_epsg = QSpinBox() + self.source_epsg.setRange(1000, 99999) + self.source_epsg.setValue(4326) + params_layout.addRow("源坐标系EPSG:", self.source_epsg) + + params_group.setLayout(params_layout) + layout.addWidget(params_group) + + # 输出文件路径 + self.output_file = FileSelectWidget( + "输出训练数据:", + "CSV Files (*.csv);;All Files (*.*)", + ) + self.output_file.line_edit.setPlaceholderText("training_spectra.csv") + layout.addWidget(self.output_file) + + # 启用步骤 + self.enable_checkbox = QCheckBox("启用此步骤") + self.enable_checkbox.setChecked(True) + layout.addWidget(self.enable_checkbox) + + # 执行按钮 + self.run_btn = QPushButton("执行 Step 6: 光谱特征提取") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success")) + self.run_btn.clicked.connect(self._on_run_clicked) + layout.addWidget(self.run_btn) + + layout.addStretch() + self.setLayout(layout) + + def get_config(self) -> dict: + config = { + "radius": self.radius.value(), + "source_epsg": self.source_epsg.value(), + "enabled": self.enable_checkbox.isChecked(), + } + 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 + return config + + def set_config(self, config: dict): + if "radius" in config: + self.radius.setValue(config["radius"]) + if "source_epsg" in config: + self.source_epsg.setValue(config["source_epsg"]) + if config.get("deglint_img_path"): + self.deglint_img_file.set_path(config["deglint_img_path"]) + if config.get("csv_path"): + self.csv_file.set_path(config["csv_path"]) + if config.get("boundary_path"): + self.water_mask_file.set_path(config["boundary_path"]) + if config.get("glint_mask_path"): + self.glint_mask_file.set_path(config["glint_mask_path"]) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if work_dir: + output_dir = _resolve_subdir(work_dir, "6_Feature_Extraction") + 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) + + def _on_run_clicked(self): + self.dispatch_execute("step6", self.get_config()) diff --git a/src/new/views/step7_view.py b/src/new/views/step7_view.py new file mode 100644 index 0000000..48c69ff --- /dev/null +++ b/src/new/views/step7_view.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +""" +Step7View —— Step 7(水质光谱指数计算)的端到端模块化 view + +UI 从 ``src/gui/panels/step7_index_panel.py`` 原样搬迁。 + +view 层职责 +=========== + +- 公式 ListWidget 仅做占位(默认空),由 service 在真正执行时通过 + ``set_config({"formula_names": [...])}`` 把勾选项回填。 +- 内置 ``waterindex.csv`` 路径只读展示,不在 view 层做实际加载; + CSV 解析、formula_type / color / coef 三张映射表都属于 service。 +- 全选 / 仅选比值型 / 仅选浓度型按钮的 ``itemChanged`` 信号 + 在 view 层禁用(``self.formula_list.blockSignals(True)``), + 避免无数据时触发空回调。 +""" + +import os + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QCheckBox, QGroupBox, QHBoxLayout, QLabel, QListWidget, + QListWidgetItem, QPushButton, QVBoxLayout, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step7View(BaseView): + """Step 7: 水质光谱指数计算""" + + def init_ui(self): + layout = QVBoxLayout() + + # 标题 + title = QLabel("步骤7:水质光谱指数计算") + title.setFont(QFont("Arial", 12, QFont.Bold)) + layout.addWidget(title) + + # 1. 公式配置源(只读) + path_group = QGroupBox("公式配置源 (内置)") + path_layout = QVBoxLayout() + self.formula_csv_widget = FileSelectWidget("内置CSV路径:", "CSV Files (*.csv)") + 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) + 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) + layout.addWidget(input_group) + + # 3. 公式选择区 + self.formula_group = QGroupBox("待计算水质指数勾选") + formula_outer_layout = QVBoxLayout() + + btn_layout = QHBoxLayout() + self.select_all_btn = QPushButton("全选") + self.deselect_all_btn = QPushButton("清空") + self.select_ratio_btn = QPushButton("仅选比值型") + self.select_conc_btn = QPushButton("仅选浓度型") + self.refresh_button = QPushButton("重新加载") + # 注意:按钮在 view 层仅做占位 —— 真正生效需要 service 加载 CSV 后回填 + for btn in (self.select_all_btn, self.deselect_all_btn, + self.select_ratio_btn, self.select_conc_btn, self.refresh_button): + btn.setEnabled(False) + 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() + btn_layout.addWidget(self.refresh_button) + formula_outer_layout.addLayout(btn_layout) + + self.formula_list = QListWidget() + self.formula_list.setSelectionMode(QListWidget.MultiSelection) + # view 层不需要 itemChanged 副作用;service 接管时再启用 + self.formula_list.blockSignals(True) + formula_outer_layout.addWidget(self.formula_list) + + self.formula_group.setLayout(formula_outer_layout) + layout.addWidget(self.formula_group) + + # 4. 执行设置 + output_group = QGroupBox("执行设置") + output_layout = QVBoxLayout() + + self.enable_checkbox = QCheckBox("启用计算流程") + self.enable_checkbox.setChecked(True) + output_layout.addWidget(self.enable_checkbox) + + output_group.setLayout(output_layout) + layout.addWidget(output_group) + + # 5. 运行按钮 + self.run_btn = QPushButton("立即执行计算") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success")) + self.run_btn.setMinimumHeight(40) + self.run_btn.clicked.connect(self._on_run_clicked) + layout.addWidget(self.run_btn) + + layout.addStretch() + self.setLayout(layout) + + # ------------------------------------------------------------------ + # BaseView 契约 + # ------------------------------------------------------------------ + def get_config(self) -> dict: + """读取当前 UI 状态——formula_names 由 ListWidget 勾选项收集""" + selected = [] + for index in range(self.formula_list.count()): + item = self.formula_list.item(index) + if item.checkState() == Qt.Checked: + selected.append(item.text()) + + config = { + "training_csv_path": self.training_data_widget.get_path(), + "formula_names": selected, + "enabled": self.enable_checkbox.isChecked(), + "output_mode": 0, + } + return config + + def set_config(self, config: dict): + """根据 config 恢复 UI 状态——formula_names 直接回填 ListWidget""" + if config.get("training_csv_path"): + self.training_data_widget.set_path(config["training_csv_path"]) + if "formula_names" in config: + # 用 blockSignals 避免 setCheckState 触发副作用 + self.formula_list.blockSignals(True) + sel = set(config["formula_names"]) + for index in range(self.formula_list.count()): + item = self.formula_list.item(index) + item.setCheckState(Qt.Checked if item.text() in sel else Qt.Unchecked) + self.formula_list.blockSignals(False) + if "enabled" in config: + self.enable_checkbox.setChecked(config["enabled"]) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if work_dir: + # step7 panel 在自身 get_config 里会根据 work_dir 拼 indices/ 路径 + # —— view 层不主动 set_path,留给 dispatch 后的 service 决定 + pass + + # ------------------------------------------------------------------ + # 执行入口 + # ------------------------------------------------------------------ + def _on_run_clicked(self): + self.dispatch_execute("step7", self.get_config()) diff --git a/src/new/views/step8_view.py b/src/new/views/step8_view.py new file mode 100644 index 0000000..9f9e5bf --- /dev/null +++ b/src/new/views/step8_view.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +""" +Step8View —— Step 8(机器学习建模)的端到端模块化 view + +UI 从 ``src/gui/panels/step8_ml_train_panel.py`` 原样搬迁。 + +view 层职责 +=========== + +- 3 个 checkbox 组(预处理 / 模型类型 / 数据划分)原样保留 + (PREPROC_CHINESE / MODEL_CHINESE / SPLIT_CHINESE 三个中文映射表)。 +- get_config 收集三组勾选,组装成 service 能直接消费的字典。 +- set_config 把勾选项回填到对应 checkbox。 +""" + +import os + +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QCheckBox, QFormLayout, QGridLayout, QGroupBox, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QSpinBox, QVBoxLayout, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +# ============================================================ +# 中文映射表(内部键名 -> 显示文本) +# ============================================================ +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)", +} + +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)", +} + +PREPROC_METHODS = ["None", "MMS", "SS", "SNV", "MA", "SG", "MSC", "D1", "D2", "DT", "CT"] +MODEL_GROUPS = [ + ("【线性模型】", ["LinearRegression", "Ridge", "Lasso", "ElasticNet", "PLS"]), + ("【树模型】", ["DecisionTree", "RF", "ExtraTrees", "XGBoost", "LightGBM", "CatBoost"]), + ("【集成学习】", ["GradientBoosting", "AdaBoost"]), + ("【其他模型】", ["SVR", "KNN", "MLP"]), +] +SPLIT_METHODS = ["spxy", "ks", "random"] + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step8View(BaseView): + """Step 8: 机器学习建模""" + + def init_ui(self): + layout = QVBoxLayout() + + title = QLabel("步骤8:机器学习建模") + title.setFont(QFont("Arial", 12, QFont.Bold)) + layout.addWidget(title) + + # 训练数据文件(用于独立运行) + self.training_csv_file = FileSelectWidget( + "训练数据:", + "CSV Files (*.csv);;All Files (*.*)", + ) + layout.addWidget(self.training_csv_file) + + # 训练参数 + params_group = QGroupBox("训练参数") + params_layout = QFormLayout() + + self.feature_start = QLineEdit() + self.feature_start.setText("374.285004") + params_layout.addRow("特征起始列:", self.feature_start) + + feature_start_hint = QLabel( + "提示:请使用记事本打开 training_spectra.csv 确认首个波长的精确表头名称" + "(如 374.285 或 374.285004)并在此填入,避免因浮点精度差异导致列名匹配失败。" + ) + feature_start_hint.setWordWrap(True) + feature_start_hint.setStyleSheet("color: #666; font-size: 10px;") + params_layout.addRow(feature_start_hint) + + self.cv_folds = QSpinBox() + self.cv_folds.setRange(2, 10) + self.cv_folds.setValue(3) + params_layout.addRow("交叉验证折数:", self.cv_folds) + + params_group.setLayout(params_layout) + layout.addWidget(params_group) + + # 预处理方法 - 多选 + preproc_group, self.preproc_checkboxes = self._build_checkbox_group( + "预处理方法 (可多选)", PREPROC_METHODS, PREPROC_CHINESE, columns=4, + ) + layout.addWidget(preproc_group) + + # 模型类型 - 多选(带分组标题) + model_group, self.model_checkboxes = self._build_model_group() + layout.addWidget(model_group) + + # 数据划分方法 - 多选 + split_group, self.split_checkboxes = self._build_checkbox_group( + "数据划分方法 (可多选)", SPLIT_METHODS, SPLIT_CHINESE, columns=3, + ) + layout.addWidget(split_group) + + # 输出文件路径 + self.output_path = FileSelectWidget( + "输出文件:", + "CSV Files (*.csv);;All Files (*.*)", + ) + self.output_path.line_edit.setPlaceholderText("自动生成,或手动指定输出文件路径...") + layout.addWidget(self.output_path) + + # 启用步骤 + self.enable_checkbox = QCheckBox("启用此步骤") + self.enable_checkbox.setChecked(False) + layout.addWidget(self.enable_checkbox) + + # 独立运行按钮 + self.run_btn = QPushButton("独立运行此步骤") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success")) + self.run_btn.clicked.connect(self._on_run_clicked) + layout.addWidget(self.run_btn) + + layout.addStretch() + self.setLayout(layout) + + # ------------------------------------------------------------------ + # 控件构造 helpers + # ------------------------------------------------------------------ + def _build_checkbox_group(self, title, methods, chinese_map, columns): + """构造一个 QGroupBox(多选 checkbox + 全选/全不选 按钮)""" + group = QGroupBox(title) + outer_layout = QVBoxLayout() + + grid = QGridLayout() + checkboxes = {} + for i, method in enumerate(methods): + checkbox = QCheckBox(chinese_map.get(method, method)) + checkbox.setChecked(False) + checkboxes[method] = checkbox + grid.addWidget(checkbox, i // columns, i % columns) + + button_layout = QHBoxLayout() + select_all_btn = QPushButton("全选") + deselect_all_btn = QPushButton("全不选") + select_all_btn.clicked.connect(lambda: self._toggle_checkboxes(checkboxes, True)) + deselect_all_btn.clicked.connect(lambda: self._toggle_checkboxes(checkboxes, False)) + button_layout.addWidget(select_all_btn) + button_layout.addWidget(deselect_all_btn) + button_layout.addStretch() + + outer_layout.addLayout(grid) + outer_layout.addLayout(button_layout) + group.setLayout(outer_layout) + return group, checkboxes + + def _build_model_group(self): + """构造带分组标题的「模型类型」QGroupBox""" + group = QGroupBox("模型类型 (可多选)") + outer_layout = QVBoxLayout() + + grid = QGridLayout() + checkboxes = {} + + 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;" + ) + 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) + checkboxes[model] = checkbox + grid.addWidget(checkbox, row, i % 4) + if (i + 1) % 4 == 0: + row += 1 + + row += 1 + + button_layout = QHBoxLayout() + select_all_btn = QPushButton("全选") + deselect_all_btn = QPushButton("全不选") + select_all_btn.clicked.connect(lambda: self._toggle_checkboxes(checkboxes, True)) + deselect_all_btn.clicked.connect(lambda: self._toggle_checkboxes(checkboxes, False)) + button_layout.addWidget(select_all_btn) + button_layout.addWidget(deselect_all_btn) + button_layout.addStretch() + + outer_layout.addLayout(grid) + outer_layout.addLayout(button_layout) + group.setLayout(outer_layout) + return group, checkboxes + + def _toggle_checkboxes(self, checkboxes_dict, checked): + for checkbox in checkboxes_dict.values(): + checkbox.setChecked(checked) + + # ------------------------------------------------------------------ + # BaseView 契约 + # ------------------------------------------------------------------ + def get_config(self) -> dict: + preprocessing_methods = [ + method for method, cb in self.preproc_checkboxes.items() if cb.isChecked() + ] + model_names = [ + 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() + ] + + 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(), + "enabled": self.enable_checkbox.isChecked(), + } + 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: dict): + 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, cb in self.preproc_checkboxes.items(): + cb.setChecked(method in methods) + if "model_names" in config: + models = config["model_names"] + for model, cb in self.model_checkboxes.items(): + cb.setChecked(model in models) + if "split_methods" in config: + methods = config["split_methods"] + for method, cb in self.split_checkboxes.items(): + cb.setChecked(method in methods) + if config.get("training_csv_path"): + self.training_csv_file.set_path(config["training_csv_path"]) + if config.get("output_path"): + self.output_path.set_path(config["output_path"]) + if "enabled" in config: + self.enable_checkbox.setChecked(config["enabled"]) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if not work_dir: + return + # 自动填训练数据 + step6_dir = _resolve_subdir(work_dir, "spectral_feature") + step6_training_csv = os.path.join(step6_dir, "training_spectra.csv").replace("\\", "/") + if not self.training_csv_file.get_path(): + self.training_csv_file.set_path(step6_training_csv) + # 自动填输出路径 + indices_dir = _resolve_subdir(work_dir, "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 = "training_spectra_indices.csv" + output_path = os.path.join(indices_dir, output_file).replace("\\", "/") + self.output_path.set_path(output_path) + + # ------------------------------------------------------------------ + # 执行入口 + # ------------------------------------------------------------------ + def _on_run_clicked(self): + self.dispatch_execute("step8", self.get_config()) diff --git a/src/new/views/step9_view.py b/src/new/views/step9_view.py new file mode 100644 index 0000000..b0c3d28 --- /dev/null +++ b/src/new/views/step9_view.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +""" +Step9View —— Step 9(机器学习预测)的端到端模块化 view + +UI 从 ``src/gui/panels/step9_ml_predict_panel.py`` 原样搬迁。 + +view 层职责 +=========== + +- 单选按钮组(使用当前训练模型 / 导入本地预训练模型)的切换逻辑保留。 +- 外部模型 QListWidget 占位保留:view 层不实际调用 joblib.load 扫描, + 模型文件路径与勾选状态由 service 通过 set_config 注入。 +- 当用户从「使用当前训练模型」切到「导入本地预训练模型」时, + 显示外部模型相关控件;反之隐藏。 +""" + +import os + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QAbstractItemView, QButtonGroup, QCheckBox, QComboBox, QFormLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, + QPushButton, QRadioButton, QVBoxLayout, +) + +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.styles import ModernStylesheet +from src.new.core.base_view import BaseView + + +def _resolve_subdir(work_dir: str, subdir_name: str) -> str: + return os.path.join(work_dir, subdir_name).replace("\\", "/") + + +class Step9View(BaseView): + """Step 9: 机器学习预测""" + + def init_ui(self): + layout = QVBoxLayout() + + title = QLabel("步骤9:机器学习预测") + title.setFont(QFont("Arial", 12, QFont.Bold)) + layout.addWidget(title) + + # -------- 模型来源选择(单选按钮组) -------- + source_group = QGroupBox("模型来源") + source_layout = QVBoxLayout() + + self.use_trained_model = QRadioButton("使用当前训练流程的模型") + self.use_external_model = QRadioButton("导入本地预训练模型 (.joblib)") + self.use_trained_model.setChecked(True) + + # 用 QButtonGroup 强制互斥 + self._source_button_group = QButtonGroup(self) + self._source_button_group.addButton(self.use_trained_model) + self._source_button_group.addButton(self.use_external_model) + + self.use_trained_model.toggled.connect(self._on_model_source_changed) + self.use_external_model.toggled.connect(self._on_model_source_changed) + + source_layout.addWidget(self.use_trained_model) + source_layout.addWidget(self.use_external_model) + source_group.setLayout(source_layout) + layout.addWidget(source_group) + + # -------- 外部模型文件选择(条件显示) -------- + self.external_model_widget = FileSelectWidget( + "模型母文件夹:", + "Directories", + ) + self.external_model_widget.setVisible(False) + layout.addWidget(self.external_model_widget) + + # -------- 已扫描模型列表(条件显示) -------- + self.model_list_group = QGroupBox("选择参与预测的模型") + self.model_list_group.setVisible(False) + model_list_layout = QVBoxLayout() + + self.model_list = QListWidget() + self.model_list.setMaximumHeight(130) + self.model_list.setSelectionMode(QAbstractItemView.NoSelection) + model_list_layout.addWidget(self.model_list) + + btn_row = QHBoxLayout() + self.btn_select_all = QPushButton("全选") + self.btn_select_all.setMaximumWidth(80) + self.btn_select_none = QPushButton("全不选") + self.btn_select_none.setMaximumWidth(80) + self.btn_select_all.clicked.connect(self._select_all_models) + self.btn_select_none.clicked.connect(self._select_none_models) + btn_row.addWidget(self.btn_select_all) + btn_row.addWidget(self.btn_select_none) + btn_row.addStretch() + model_list_layout.addLayout(btn_row) + + self.model_list_group.setLayout(model_list_layout) + layout.addWidget(self.model_list_group) + + # -------- 采样光谱CSV文件(用于独立运行)-------- + self.sampling_csv_file = FileSelectWidget( + "采样光谱CSV:", + "CSV Files (*.csv);;All Files (*.*)", + ) + layout.addWidget(self.sampling_csv_file) + + # 模型目录(用于独立运行) + self.models_dir_file = FileSelectWidget( + "模型目录:", + "Directories;;All Files (*.*)", + ) + layout.addWidget(self.models_dir_file) + + # 参数设置 + params_group = QGroupBox("预测参数") + params_layout = QFormLayout() + + self.metric = QComboBox() + self.metric.addItems(["test_r2", "test_rmse", "test_mae"]) + params_layout.addRow("模型选择指标:", self.metric) + + self.prediction_column = QLineEdit() + self.prediction_column.setText("prediction") + params_layout.addRow("预测列名:", self.prediction_column) + + params_group.setLayout(params_layout) + layout.addWidget(params_group) + + # 输出路径 + self.output_file = FileSelectWidget( + "输出路径:", + "CSV Files (*.csv);;All Files (*.*)", + ) + 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._on_run_clicked) + layout.addWidget(self.run_btn) + + layout.addStretch() + self.setLayout(layout) + + # ------------------------------------------------------------------ + # 单选切换 + # ------------------------------------------------------------------ + def _on_model_source_changed(self, checked: bool): + if not checked: + return + is_external = self.use_external_model.isChecked() + self.external_model_widget.setVisible(is_external) + self.model_list_group.setVisible(is_external) + + def _select_all_models(self): + for i in range(self.model_list.count()): + self.model_list.item(i).setCheckState(Qt.Checked) + + def _select_none_models(self): + for i in range(self.model_list.count()): + self.model_list.item(i).setCheckState(Qt.Unchecked) + + # ------------------------------------------------------------------ + # BaseView 契约 + # ------------------------------------------------------------------ + def get_config(self) -> dict: + config = { + "metric": self.metric.currentText(), + "prediction_column": self.prediction_column.text(), + "use_external_model": self.use_external_model.isChecked(), + "enabled": self.enable_checkbox.isChecked(), + } + sampling_csv_path = self.sampling_csv_file.get_path() + if sampling_csv_path: + config["sampling_csv_path"] = sampling_csv_path + models_dir = self.models_dir_file.get_path() + if models_dir: + config["models_dir"] = models_dir + output_path = self.output_file.get_path() + if output_path: + config["output_path"] = output_path + + # 外部模型模式:把 ListWidget 勾选名单 + 母文件夹路径一起带上 + if self.use_external_model.isChecked(): + selected = [] + for i in range(self.model_list.count()): + item = self.model_list.item(i) + if item.checkState() == Qt.Checked: + selected.append(item.text()) + if selected: + config["external_model_names"] = selected + external_dir = self.external_model_widget.get_path() + if external_dir: + config["external_model_dir"] = external_dir + return config + + def set_config(self, config: dict): + if "metric" in config: + idx = self.metric.findText(config["metric"]) + if idx >= 0: + self.metric.setCurrentIndex(idx) + if "prediction_column" in config: + self.prediction_column.setText(config["prediction_column"]) + if config.get("sampling_csv_path"): + self.sampling_csv_file.set_path(config["sampling_csv_path"]) + if config.get("models_dir"): + self.models_dir_file.set_path(config["models_dir"]) + if config.get("output_path"): + self.output_file.set_path(config["output_path"]) + if "use_external_model" in config: + self.use_external_model.setChecked(config["use_external_model"]) + # 外部模型:把名字回填到 ListWidget + if "external_model_names" in config and config["external_model_names"]: + self.model_list.clear() + for name in config["external_model_names"]: + item = QListWidgetItem(name) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked) + self.model_list.addItem(item) + if "external_model_dir" in config: + self.external_model_widget.set_path(config["external_model_dir"]) + if "enabled" in config: + self.enable_checkbox.setChecked(config["enabled"]) + + def update_work_directory(self, work_dir: str): + super().update_work_directory(work_dir) + if not work_dir: + return + output_dir = _resolve_subdir(work_dir, "ml_prediction") + os.makedirs(output_dir, exist_ok=True) + if not self.output_file.get_path(): + self.output_file.set_path(output_dir) + # 自动填采样光谱 CSV + step4_dir = _resolve_subdir(work_dir, "4_Sampling") + sampling_csv = os.path.join(step4_dir, "sampling_spectra.csv").replace("\\", "/") + if not self.sampling_csv_file.get_path(): + self.sampling_csv_file.set_path(sampling_csv) + # 自动填模型目录 + step8_models_dir = _resolve_subdir(work_dir, "models") + if not self.models_dir_file.get_path(): + self.models_dir_file.set_path(step8_models_dir) + + # ------------------------------------------------------------------ + # 执行入口 + # ------------------------------------------------------------------ + def _on_run_clicked(self): + self.dispatch_execute("step9", self.get_config())