fix: 全局UX修复与Step4交互可视化重构

=== 自动填入过于激进(幽灵路径级联)===
- 所有面板 update_from_config 移除 os.makedirs(),目录创建留给 pipeline 执行
- 输出路径仅 widget 为空时填入默认值,不覆盖用户已选
- 输入路径从上游读取后添加 os.path.exists() 检查,阻断幽灵路径级联
- panel_factory._replay_live_panel_inputs 广播前校验文件确实存在
- step10 update_from_config 添加 os.path.isfile() 存在性检查
- 清理 step8/9/11 中冗余局部 import os(修复 UnboundLocalError)

=== 输出目录缺失 ===
- step7 新增 output_file FileSelectWidget,默认路径 7_Water_Quality_Indices/
- step9 output_file 从文件模式改为目录模式 (Directories)

=== 空目录自动创建 ===
- step12 _setup_prediction_output_dirs 移除 mkdir() 调用,改为只读日志

=== 过期依赖与缺失 import ===
- panel_registry Step10 依赖 bsq_file→sampling_csv_file(匹配 CSV 模式重构)
- step7 添加缺失的 import pandas as pd(修复 NameError)

=== 导航与 UI 一致性 ===
- water_quality_gui_v2 新增 _select_first_nav_item(),启动时默认选中第一项
- step1 输出卡片对齐 step8 风格
- step8 补充缺失的样式表和统一边距

=== Step4 交互式光谱探针重构 ===
- 左右分栏 QSplitter 布局:左侧控制区 + 右侧 Matplotlib 视图
- 1x2 子图:ax1 散点图 + ax2 光谱曲线
- Hover 悬停 Annotation 显示坐标,Click 点击高亮+绘制光谱
- NavigationToolbar2QT 工具栏(保存/缩放/平移)
- 自动检测坐标列和波段列,完善异常处理
This commit is contained in:
duxin
2026-06-30 09:38:27 +08:00
parent e337f01312
commit 48d17ef0ca
20 changed files with 910 additions and 201 deletions

View File

@ -567,17 +567,22 @@ class Step10WatercolorPanel(QWidget):
)
# 2. 回退:直接读 step4_sampling panel 的 output_file widget
# 2026-06-30:panel widget 可能含幽灵占位路径,仅当文件确实存在时才采纳
if not sampling_path and main_window:
step4_widget = getattr(main_window, 'step4_sampling', None)
if step4_widget and hasattr(step4_widget, 'output_file'):
sampling_path = step4_widget.output_file.get_path()
else:
candidate = step4_widget.output_file.get_path()
if candidate and os.path.isfile(candidate):
sampling_path = candidate
if not sampling_path:
# 通过 _panel_factory 懒加载查找
factory = getattr(main_window, '_panel_factory', None)
if factory:
step4_panel = factory.get_panel('step4_sampling')
if step4_panel and hasattr(step4_panel, 'output_file'):
sampling_path = step4_panel.output_file.get_path()
candidate = step4_panel.output_file.get_path()
if candidate and os.path.isfile(candidate):
sampling_path = candidate
# 3. 终极回退:扫描 work_dir/4_sampling/sampling_spectra.csv
if not sampling_path and self.work_dir:
@ -593,14 +598,12 @@ class Step10WatercolorPanel(QWidget):
).replace('\\', '/')
self.sampling_csv_file.set_path(sampling_path)
# 自动填入输出目录(默认 work_dir/10_WaterIndex_CSV/)
if self.work_dir:
# 自动填入输出目录(仅在为空时填入默认路径,不创建目录)
if self.work_dir and not self.output_dir.get_path():
out_dir = os.path.join(
self.work_dir, '10_WaterIndex_CSV'
).replace('\\', '/')
os.makedirs(out_dir, exist_ok=True)
if not self.output_dir.get_path():
self.output_dir.set_path(out_dir)
self.output_dir.set_path(out_dir)
def _on_run_single_clicked(self):
"""通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。"""

View File

@ -482,35 +482,32 @@ class Step11MapPanel(QWidget):
factory = getattr(main_window, '_panel_factory', None) if main_window else None
if not factory: return
# 1. 安全抓取 Step 9 的预测 CSV 目录
# 1. 安全抓取 Step 9 的预测 CSV 目录(仅当目录确实存在)
step9_panel = factory.get_panel('step9_ml_predict')
if step9_panel and hasattr(step9_panel, 'output_file'):
path = step9_panel.output_file.get_path()
if path:
if path and os.path.isdir(path):
self.prediction_csv_dir_edit.setText(path)
self.batch_mode_combo.setCurrentIndex(1)
# 2. 安全抓取 Step 1 的真实掩膜文件(彻底拒绝瞎猜 roi.shp)
# 2. 安全抓取 Step 1 的真实掩膜文件(仅当文件确实存在)
step1_panel = factory.get_panel('step1')
if step1_panel:
use_ndwi = step1_panel.use_ndwi_radio.isChecked()
# 根据用户在第1步的选择,拿真实的输出掩膜或导入的掩膜
if use_ndwi and hasattr(step1_panel, 'output_file'):
path = step1_panel.output_file.get_path()
elif not use_ndwi and hasattr(step1_panel, 'mask_file'):
path = step1_panel.mask_file.get_path()
else:
path = ""
existing = self.boundary_file.get_path()
if path and not existing:
if path and not existing and os.path.exists(path):
self.boundary_file.set_path(path)
# 3. 生成第 11 步的绝对输出目录 (杜绝保存到相对路径)
if hasattr(self, 'work_dir') and self.work_dir:
import os
# 3. 生成第 11 步的输出目录(仅在为空时填入默认路径,不创建目录)
if hasattr(self, 'work_dir') and self.work_dir and not self.output_dir.get_path():
out_dir = os.path.join(self.work_dir, "14_visualization").replace('\\', '/')
os.makedirs(out_dir, exist_ok=True)
self.output_dir.set_path(out_dir)
def browse_output_dir(self):

View File

@ -1656,21 +1656,22 @@ class Step12VizPanel(QWidget):
self.image_viewer.load_image(str(image_files[0]))
def _setup_prediction_output_dirs(self, work_path: Path):
"""设置三个预测步骤的默认输出目录"""
"""收集预测输出目录路径信息(不创建目录,仅用于日志/调试)。
2026-06-30 修复:移除 mkdir 调用,不再在未运行 pipeline 时创建空目录。
目录创建统一留给各 pipeline 步骤在实际执行时处理。
"""
try:
base_prediction_dir = Path(resolve_subdir(str(work_path), 'prediction_dir'))
ml_dir = Path(resolve_subdir(str(work_path), 'ml_prediction'))
reg_dir = base_prediction_dir / "Regression_Model_Prediction"
custom_dir = Path(resolve_subdir(str(work_path), 'custom_regression')) / "Custom_Regression_Prediction"
ml_dir.mkdir(parents=True, exist_ok=True)
reg_dir.mkdir(parents=True, exist_ok=True)
custom_dir.mkdir(parents=True, exist_ok=True)
# 旧的 self.step11_ml_panel/step11_panel/step12_panel 在 Step12VizPanel 上不存在,是死代码。
# 三个目录的真实默认值在用户首次浏览 / 自动填充时由各 panel 自己的 _get_default_work_dir 路径产出。
# 这里仅做目录创建 + 提示输出,便于用户在工作目录树中能看到预测输出位置。
print(f"预测输出目录已创建:\n ML: {ml_dir}\n Reg: {reg_dir}\n Custom: {custom_dir}")
# 仅输出信息,不创建目录
existing = [str(d) for d in (ml_dir, reg_dir, custom_dir) if d.is_dir()]
if existing:
print(f"预测输出目录已存在: {existing}")
except Exception as e:
print(f"设置预测输出目录失败: {e}")
print(f"读取预测输出目录信息失败: {e}")
def on_tree_item_clicked(self, item, column):
"""目录树项点击事件"""

View File

@ -114,6 +114,9 @@ class Step1Panel(QWidget):
config_group.setLayout(config_layout)
main_layout.addWidget(config_group)
# ==========================================
# 卡片 3:输出与执行(与 Step8 对齐的现代化卡片风格)
# ==========================================
output_group = QGroupBox("🚀 输出与执行")
output_layout = QVBoxLayout()
output_layout.setSpacing(16)
@ -124,11 +127,10 @@ class Step1Panel(QWidget):
"Mask Files (*.dat *.tif);;All Files (*.*)",
mode="save"
)
self.output_file.label.setMinimumWidth(100)
self.output_file.line_edit.setPlaceholderText("water_mask.dat")
output_layout.addWidget(self.output_file)
# 完美对齐的底部按钮栏(已移除多余的启用步骤选项)
# 完美对齐的底部按钮栏
action_layout = QHBoxLayout()
action_layout.addStretch()
@ -140,6 +142,8 @@ class Step1Panel(QWidget):
output_layout.addLayout(action_layout)
output_group.setLayout(output_layout)
# 将打包好的输出卡片添加到主 layout 中
main_layout.addWidget(output_group)
self.use_existing_radio.toggled.connect(self.update_ui_state)
@ -164,10 +168,12 @@ class Step1Panel(QWidget):
self._auto_fill_output_path()
def _auto_fill_output_path(self):
"""仅在输出框为空时填入默认路径;不创建目录(留给 pipeline 执行时创建)。"""
if not hasattr(self, 'work_dir') or not self.work_dir:
return
if self.output_file.get_path():
return # 用户已手动指定,不覆盖
output_dir = resolve_subdir(self.work_dir, 'water_mask')
os.makedirs(output_dir, exist_ok=True)
default_output_path = os.path.join(output_dir, "water_mask_out.dat").replace('\\', '/')
self.output_file.set_path(default_output_path)

View File

@ -192,13 +192,15 @@ class Step2Panel(QWidget):
if mask_path:
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)
# 仅当上游文件确实存在时才自动填入(防止幽灵路径级联扩散)
if os.path.exists(mask_path):
self.water_mask_file.set_path(mask_path)
if self.work_dir:
output_dir = resolve_subdir(self.work_dir, '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)
if not self.output_file.get_path():
output_dir = resolve_subdir(self.work_dir, 'glint_detection')
default_output_path = os.path.join(output_dir, "severe_glint_area.dat").replace('\\', '/')
self.output_file.set_path(default_output_path)
else:
self.output_file.set_path("")

View File

@ -317,13 +317,15 @@ class Step3Panel(QWidget):
if mask_path:
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)
# 仅当上游文件确实存在时才自动填入(防止幽灵路径级联扩散)
if os.path.exists(mask_path):
self.water_mask_file.set_path(mask_path)
if self.work_dir:
output_dir = resolve_subdir(self.work_dir, '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)
if not self.output_file.get_path():
output_dir = resolve_subdir(self.work_dir, 'deglint')
default_output_path = os.path.join(output_dir, "deglint_image.bsq").replace('\\', '/')
self.output_file.set_path(default_output_path)
else:
self.output_file.set_path("")

View File

@ -1,13 +1,22 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step4 面板 - 采样点布设 (已移除“启用此步骤”)
Step4 面板 - 采样点布设(内嵌交互式光谱探针视图)
2026-06-30 重构:
- 左右分栏布局 (QSplitter):左侧控制区 + 右侧嵌入式 Matplotlib 视图
- 1×2 子图:ax1 散点图 + ax2 光谱曲线
- Hover 悬停显示坐标提示,Click 点击绘制该点光谱曲线
- NavigationToolbar2QT 工具栏自带保存/缩放/平移
"""
import os
import sys
from pathlib import Path
import numpy as np
import pandas as pd
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
@ -16,9 +25,16 @@ from _step_path_resolver import resolve_subdir
from PyQt5.QtCore import QTimer, Qt
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QPushButton, QSpinBox, QMessageBox, QLabel, QFrame
QPushButton, QSpinBox, QCheckBox, QMessageBox, QLabel, QFrame,
QSplitter, QSizePolicy,
)
from matplotlib.backends.backend_qt5agg import (
FigureCanvasQTAgg as FigureCanvas,
NavigationToolbar2QT as NavigationToolbar,
)
from matplotlib.figure import Figure
from src.gui.components.custom_widgets import FileSelectWidget
from src.gui.dialogs import SamplingViewerDialog
from src.gui.styles import ModernStylesheet
@ -27,15 +43,39 @@ from src.gui.styles import ModernStylesheet
class Step4SamplingPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# 交互状态
self._df = None
self._x_col = None
self._y_col = None
self._band_cols = []
self._scatter = None
self._highlight_idx = None
self._annot = None
self._cid_hover = None
self._cid_click = None
self._last_render_path = None
self.init_ui()
# ═══════════════════════════════════════════════════════════════
# UI 构建
# ═══════════════════════════════════════════════════════════════
def init_ui(self):
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
main_layout = QVBoxLayout()
main_layout.setContentsMargins(24, 24, 24, 24)
main_layout.setSpacing(20)
# ── 顶层:水平分栏 (QSplitter) ──
splitter = QSplitter(Qt.Horizontal)
splitter.setChildrenCollapsible(False)
# ═══════════════════════════════════════════════
# 左侧:控制区(原封保留原有三张卡片)
# ═══════════════════════════════════════════════
left_widget = QWidget()
left_layout = QVBoxLayout()
left_layout.setContentsMargins(24, 24, 12, 24)
left_layout.setSpacing(20)
# --- 卡片 1:输入数据 ---
input_group = QGroupBox("📁 输入数据")
input_layout = QVBoxLayout()
input_layout.setSpacing(16)
@ -52,8 +92,9 @@ class Step4SamplingPanel(QWidget):
input_layout.addWidget(self.deglint_img_file)
input_layout.addWidget(self.water_mask_file)
input_group.setLayout(input_layout)
main_layout.addWidget(input_group)
left_layout.addWidget(input_group)
# --- 卡片 2:采样参数 ---
params_group = QGroupBox("⚙️ 采样参数")
params_layout = QFormLayout()
params_layout.setSpacing(16)
@ -80,14 +121,14 @@ class Step4SamplingPanel(QWidget):
self.chunk_size.setMinimumWidth(120)
params_layout.addRow("内存处理块大小:", self.chunk_size)
from PyQt5.QtWidgets import QCheckBox
self.use_adaptive_sampling = QCheckBox("启用自适应边缘采样")
self.use_adaptive_sampling.setChecked(True)
params_layout.addRow("智能模式:", self.use_adaptive_sampling)
params_group.setLayout(params_layout)
main_layout.addWidget(params_group)
left_layout.addWidget(params_group)
# --- 卡片 3:输出与执行 ---
output_group = QGroupBox("🚀 输出与执行")
output_layout = QVBoxLayout()
output_layout.setSpacing(16)
@ -104,33 +145,104 @@ class Step4SamplingPanel(QWidget):
action_layout = QHBoxLayout()
action_layout.addStretch()
self.preview_btn = QPushButton("交互式预览采样点")
self.preview_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal'))
self.preview_btn.setEnabled(False)
self.preview_btn.setMinimumWidth(160)
self.preview_btn.clicked.connect(self._open_sampling_viewer)
self.refresh_btn = QPushButton("🔄 刷新视图")
self.refresh_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal'))
self.refresh_btn.setEnabled(False)
self.refresh_btn.setMinimumWidth(140)
self.refresh_btn.setToolTip("重新加载 CSV 并渲染采样点散点图")
self.refresh_btn.clicked.connect(self._on_refresh_clicked)
self.run_btn = QPushButton("独立运行步骤")
self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary'))
self.run_btn.setMinimumWidth(140)
self.run_btn.clicked.connect(self._on_run_single_clicked)
action_layout.addWidget(self.preview_btn)
action_layout.addWidget(self.refresh_btn)
action_layout.addWidget(self.run_btn)
output_layout.addLayout(action_layout)
output_group.setLayout(output_layout)
main_layout.addWidget(output_group)
left_layout.addWidget(output_group)
main_layout.addStretch()
self.setLayout(main_layout)
left_layout.addStretch()
left_widget.setLayout(left_layout)
left_widget.setMinimumWidth(360)
# ═══════════════════════════════════════════════
# 右侧:可视化区(嵌入式 Matplotlib 视图)
# ═══════════════════════════════════════════════
right_widget = QWidget()
right_layout = QVBoxLayout()
right_layout.setContentsMargins(12, 24, 24, 24)
right_layout.setSpacing(0)
viz_group = QGroupBox("📊 采样点交互式探索")
viz_layout = QVBoxLayout()
viz_layout.setContentsMargins(8, 20, 8, 8)
viz_layout.setSpacing(0)
# Matplotlib 画布(1×2 子图:散点图 + 光谱曲线)
self._fig = Figure(figsize=(9, 5))
self._ax_scatter = self._fig.add_subplot(121)
self._ax_spectrum = self._fig.add_subplot(122)
self._canvas = FigureCanvas(self._fig)
self._canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# 设置子图初始状态
self._ax_scatter.set_title("采样点分布", fontsize=11, fontweight='bold')
self._ax_scatter.set_xlabel("X 坐标")
self._ax_scatter.set_ylabel("Y 坐标")
self._ax_scatter.text(0.5, 0.5, "等待采样数据生成...\n\n请先配置参数并运行步骤\n或选择已有的 CSV 后刷新",
ha='center', va='center', transform=self._ax_scatter.transAxes,
fontsize=12, color='#888888')
self._ax_scatter.grid(False)
self._ax_spectrum.set_title("光谱曲线", fontsize=11, fontweight='bold')
self._ax_spectrum.set_xlabel("波长 (nm)")
self._ax_spectrum.set_ylabel("反射率")
self._ax_spectrum.text(0.5, 0.5, "点击左侧散点\n查看光谱曲线",
ha='center', va='center', transform=self._ax_spectrum.transAxes,
fontsize=12, color='#888888')
self._ax_spectrum.grid(False)
self._fig.tight_layout(pad=2.0)
# 工具栏(自带保存/缩放/平移)
self._toolbar = NavigationToolbar(self._canvas, self)
viz_layout.addWidget(self._toolbar)
viz_layout.addWidget(self._canvas)
viz_group.setLayout(viz_layout)
right_layout.addWidget(viz_group)
right_widget.setLayout(right_layout)
# ── 组装分栏 ──
splitter.addWidget(left_widget)
splitter.addWidget(right_widget)
splitter.setSizes([420, 680]) # 初始比例 ≈ 38:62
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 2)
top_layout = QHBoxLayout()
top_layout.setContentsMargins(0, 0, 0, 0)
top_layout.addWidget(splitter)
self.setLayout(top_layout)
# ── 事件绑定 ──
self._cid_hover = self._canvas.mpl_connect('motion_notify_event', self._on_hover)
self._cid_click = self._canvas.mpl_connect('button_press_event', self._on_click)
# ── 定时器:降低频率,仅用于自动发现新生成的 CSV ──
self._status_timer = QTimer(self)
self._status_timer.timeout.connect(self._check_csv_exists)
self._status_timer.start(2000)
self._status_timer.timeout.connect(self._check_csv_and_auto_render)
self._status_timer.start(5000)
self.output_file.line_edit.textChanged.connect(self._on_output_changed)
# ═══════════════════════════════════════════════════════════════
# 配置读写(保持不变)
# ═══════════════════════════════════════════════════════════════
def get_config(self):
config = {
'interval': self.interval.value(),
@ -182,7 +294,8 @@ class Step4SamplingPanel(QWidget):
if deglint_path:
if not os.path.isabs(deglint_path):
deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/')
self.deglint_img_file.set_path(deglint_path)
if os.path.exists(deglint_path):
self.deglint_img_file.set_path(deglint_path)
water_mask_path = None
if pipeline and hasattr(pipeline, 'step_outputs'):
@ -215,14 +328,22 @@ class Step4SamplingPanel(QWidget):
if water_mask_path:
if not os.path.isabs(water_mask_path):
water_mask_path = os.path.join(self.work_dir or '', water_mask_path).replace('\\', '/')
self.water_mask_file.set_path(water_mask_path)
if os.path.exists(water_mask_path):
self.water_mask_file.set_path(water_mask_path)
if self.work_dir:
if self.work_dir and not self.output_file.get_path():
output_path = resolve_subdir(self.work_dir, 'sampling_csv_path')
os.makedirs(os.path.dirname(output_path), exist_ok=True)
self.output_file.set_path(output_path.replace('\\', '/'))
self._check_csv_exists()
# 若 CSV 已存在,尝试自动渲染
csv_path = self.output_file.get_path()
if csv_path and os.path.isfile(csv_path):
self._render_inline_plot()
# ═══════════════════════════════════════════════════════════════
# 执行
# ═══════════════════════════════════════════════════════════════
def _on_run_single_clicked(self):
from src.gui.core.event_bus import global_event_bus
@ -236,20 +357,354 @@ class Step4SamplingPanel(QWidget):
'config': config,
})
# ═══════════════════════════════════════════════════════════════
# CSV 状态检测
# ═══════════════════════════════════════════════════════════════
def _check_csv_exists(self):
csv_path = self.output_file.get_path()
enabled = bool(csv_path and os.path.isabs(csv_path) and os.path.exists(csv_path))
self.preview_btn.setEnabled(enabled)
self.refresh_btn.setEnabled(enabled)
return enabled
def _on_output_changed(self, _text=None):
self._check_csv_exists()
def _check_csv_and_auto_render(self):
"""定时器回调:检测到新 CSV 出现时自动渲染一次。"""
csv_path = self.output_file.get_path()
if csv_path and os.path.isfile(csv_path):
if csv_path != self._last_render_path:
self.refresh_btn.setEnabled(True)
self._render_inline_plot()
def _on_refresh_clicked(self):
"""手动点击刷新按钮。"""
csv_path = self.output_file.get_path()
if not csv_path or not os.path.exists(csv_path):
QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。")
return
self._render_inline_plot()
# ═══════════════════════════════════════════════════════════════
# 核心渲染
# ═══════════════════════════════════════════════════════════════
def _detect_coordinate_columns(self, df: pd.DataFrame):
"""检测坐标列,返回 (x_col, y_col) 或 (None, None)。
优先级:pixel_x/pixel_y → longitude/latitude → lon/lat →
X/Y → UTM_X/UTM_Y → 任何含 'x'/'y' 关键字的列
"""
cols_lower = {c.lower(): c for c in df.columns}
priority_pairs = [
('pixel_x', 'pixel_y'),
('longitude', 'latitude'),
('lon', 'lat'),
('x', 'y'),
('utm_x', 'utm_y'),
]
for x_key, y_key in priority_pairs:
if x_key in cols_lower and y_key in cols_lower:
return cols_lower[x_key], cols_lower[y_key]
# 最后尝试:找名字中含 x / y 的数值列
x_candidates = [c for c in df.columns if 'x' in c.lower() and pd.api.types.is_numeric_dtype(df[c])]
y_candidates = [c for c in df.columns if 'y' in c.lower() and pd.api.types.is_numeric_dtype(df[c])]
if x_candidates and y_candidates:
return x_candidates[0], y_candidates[0]
return None, None
def _detect_band_columns(self, df: pd.DataFrame):
"""检测光谱波段列(纯数字列名,值域在 200–3000 nm 之间)。
优先使用列名可解析为 float 且在波长范围内的列;
否则回退到位置索引(跳过坐标列和已知元数据列)。
"""
band_cols = []
for col in df.columns:
try:
val = float(str(col).strip())
if 200.0 <= val <= 3000.0:
band_cols.append(col)
except (ValueError, TypeError):
continue
if band_cols:
# 按波长数值排序
band_cols.sort(key=lambda c: float(str(c).strip()))
return band_cols
# 回退:跳过坐标列和已知元数据列,取数值列
skip_keywords = ('x', 'y', 'lon', 'lat', 'utm', 'id', 'sample', 'index', 'pixel')
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
if self._x_col:
numeric_cols = [c for c in numeric_cols if c != self._x_col]
if self._y_col:
numeric_cols = [c for c in numeric_cols if c != self._y_col]
return [c for c in numeric_cols if not any(k in c.lower() for k in skip_keywords)]
def _render_inline_plot(self):
"""读取 CSV → 检测坐标/波段列 → 绘制 1×2 子图。"""
csv_path = self.output_file.get_path()
if not csv_path or not os.path.isfile(csv_path):
self._show_empty_state("等待采样数据生成...\n\n请先配置参数并运行步骤")
return
# 读取数据
try:
df = pd.read_csv(csv_path)
except Exception as e:
self._show_empty_state(f"读取 CSV 失败:\n{str(e)[:200]}")
return
if df.empty:
self._show_empty_state("CSV 文件为空")
return
# 检测坐标列
x_col, y_col = self._detect_coordinate_columns(df)
if x_col is None or y_col is None:
self._show_empty_state(
"缺少坐标列\n\n"
f"可用列: {', '.join(str(c) for c in df.columns[:15])}\n"
"期望: pixel_x/pixel_y, longitude/latitude, X/Y 等"
)
return
# 检测光谱列
band_cols = self._detect_band_columns(df)
if not band_cols:
# 仅显示散点图,光谱子图留空
pass
# 缓存
self._df = df
self._x_col = x_col
self._y_col = y_col
self._band_cols = band_cols
self._highlight_idx = None
self._last_render_path = csv_path
# 清除 Annotation
if self._annot is not None:
try:
self._annot.remove()
except Exception:
pass
self._annot = None
# ── 绘制 ax1:散点图 ──
self._ax_scatter.clear()
x = df[x_col].values
y = df[y_col].values
self._scatter = self._ax_scatter.scatter(
x, y,
c='#0078D7', alpha=0.7, edgecolors='white',
linewidth=0.5, s=40, picker=True, zorder=3
)
self._ax_scatter.set_xlabel(str(x_col), fontsize=10)
self._ax_scatter.set_ylabel(str(y_col), fontsize=10)
self._ax_scatter.set_title(f"采样点分布 (共 {len(df)} 个点)", fontsize=11, fontweight='bold')
self._ax_scatter.grid(True, alpha=0.3, linestyle='--')
self._ax_scatter.set_facecolor('#F8F9FA')
# ── 绘制 ax2:光谱曲线(初始状态)──
self._ax_spectrum.clear()
if band_cols:
self._ax_spectrum.set_title("光谱曲线(点击左侧散点查看)", fontsize=11, fontweight='bold')
self._ax_spectrum.set_xlabel("波长 (nm)", fontsize=10)
self._ax_spectrum.set_ylabel("反射率", fontsize=10)
self._ax_spectrum.grid(True, alpha=0.3, linestyle='--')
self._ax_spectrum.text(0.5, 0.5, "点击左侧散点\n查看光谱曲线",
ha='center', va='center', transform=self._ax_spectrum.transAxes,
fontsize=12, color='#999999')
else:
self._ax_spectrum.text(0.5, 0.5, "缺少光谱数据列",
ha='center', va='center', transform=self._ax_spectrum.transAxes,
fontsize=12, color='#999999')
self._fig.tight_layout(pad=2.0)
self._canvas.draw()
def _show_empty_state(self, message: str):
"""在右侧画布显示提示信息。"""
self._ax_scatter.clear()
self._ax_scatter.set_title("采样点分布", fontsize=11, fontweight='bold')
self._ax_scatter.text(0.5, 0.5, message, ha='center', va='center',
transform=self._ax_scatter.transAxes,
fontsize=12, color='#888888')
self._ax_scatter.grid(False)
self._ax_spectrum.clear()
self._ax_spectrum.set_title("光谱曲线", fontsize=11, fontweight='bold')
self._ax_spectrum.text(0.5, 0.5, "等待数据...",
ha='center', va='center', transform=self._ax_spectrum.transAxes,
fontsize=12, color='#888888')
self._ax_spectrum.grid(False)
self._fig.tight_layout(pad=2.0)
self._canvas.draw()
self._df = None
self._last_render_path = None
# ═══════════════════════════════════════════════════════════════
# 交互事件 (Hover & Click)
# ═══════════════════════════════════════════════════════════════
def _on_hover(self, event):
"""悬停:在散点旁显示坐标 + ID 提示框。"""
if event.inaxes != self._ax_scatter:
# 鼠标离开 ax1 → 隐藏 annotation
if self._annot is not None:
try:
self._annot.set_visible(False)
self._canvas.draw_idle()
except Exception:
pass
return
if self._df is None or self._scatter is None:
return
# 检测是否悬停在散点上
contains, info = self._scatter.contains(event)
if not contains or info is None or 'ind' not in info or len(info['ind']) == 0:
if self._annot is not None:
try:
self._annot.set_visible(False)
self._canvas.draw_idle()
except Exception:
pass
return
idx = info['ind'][0]
row = self._df.iloc[idx]
x_val = row[self._x_col]
y_val = row[self._y_col]
# 创建或更新 Annotation
text = f"#{idx}\n({x_val:.4f}, {y_val:.4f})"
if self._annot is None:
self._annot = self._ax_scatter.annotate(
text,
xy=(x_val, y_val),
xytext=(12, 12),
textcoords='offset points',
bbox=dict(boxstyle='round,pad=0.4', facecolor='#FFFFFF',
edgecolor='#0078D7', alpha=0.9),
fontsize=9,
zorder=10,
)
else:
self._annot.xy = (x_val, y_val)
self._annot.set_text(text)
self._annot.set_visible(True)
self._canvas.draw_idle()
def _on_click(self, event):
"""点击:高亮散点 + 绘制光谱曲线。"""
if event.inaxes != self._ax_scatter:
return
if self._df is None or self._scatter is None:
return
# 检测是否点击在散点上
contains, info = self._scatter.contains(event)
if not contains or info is None or 'ind' not in info or len(info['ind']) == 0:
return
idx = info['ind'][0]
row = self._df.iloc[idx]
self._highlight_idx = idx
# ── 高亮选中点 ──
x = self._df[self._x_col].values
y = self._df[self._y_col].values
colors = ['#0078D7'] * len(self._df)
sizes = [40] * len(self._df)
colors[idx] = '#E74C3C'
sizes[idx] = 80
self._ax_scatter.clear()
self._scatter = self._ax_scatter.scatter(
x, y, c=colors, s=sizes,
alpha=0.7, edgecolors='white', linewidth=0.5,
picker=True, zorder=3
)
# 将选中点提升到顶层
self._ax_scatter.scatter(
[x[idx]], [y[idx]],
c='#E74C3C', s=100,
alpha=0.9, edgecolors='white', linewidth=1.5,
zorder=5
)
self._ax_scatter.set_xlabel(str(self._x_col), fontsize=10)
self._ax_scatter.set_ylabel(str(self._y_col), fontsize=10)
self._ax_scatter.set_title(f"采样点分布 (共 {len(self._df)} 个点)", fontsize=11, fontweight='bold')
self._ax_scatter.grid(True, alpha=0.3, linestyle='--')
self._ax_scatter.set_facecolor('#F8F9FA')
# ── 绘制光谱曲线 ──
self._ax_spectrum.clear()
if self._band_cols:
wavelengths = []
reflectance = []
for col in self._band_cols:
try:
wl = float(str(col).strip())
val = row[col]
if pd.notna(val):
wavelengths.append(wl)
reflectance.append(float(val))
except (ValueError, TypeError):
continue
if wavelengths:
# 按波长排序
pairs = sorted(zip(wavelengths, reflectance), key=lambda p: p[0])
wavelengths, reflectance = zip(*pairs) if pairs else ([], [])
self._ax_spectrum.plot(
wavelengths, reflectance,
color='#0078D7', lw=1.5, marker='.', markersize=3, alpha=0.8
)
self._ax_spectrum.fill_between(wavelengths, reflectance, alpha=0.1, color='#0078D7')
self._ax_spectrum.set_xlabel("波长 (nm)", fontsize=10)
self._ax_spectrum.set_ylabel("反射率", fontsize=10)
self._ax_spectrum.set_title(f"样本 #{idx} 的光谱曲线 ({len(wavelengths)} 个波段)",
fontsize=11, fontweight='bold')
self._ax_spectrum.grid(True, alpha=0.3, linestyle='--')
else:
self._ax_spectrum.text(0.5, 0.5, "该样本无有效光谱数据",
ha='center', va='center',
transform=self._ax_spectrum.transAxes,
fontsize=12, color='#999999')
else:
self._ax_spectrum.text(0.5, 0.5, "缺少光谱波段列\n无法绘制光谱",
ha='center', va='center',
transform=self._ax_spectrum.transAxes,
fontsize=12, color='#999999')
self._fig.tight_layout(pad=2.0)
self._canvas.draw()
# ═══════════════════════════════════════════════════════════════
# 旧版弹窗查看器(保留,供外部调用)
# ═══════════════════════════════════════════════════════════════
def _open_sampling_viewer(self):
"""打开独立的 SamplingViewerDialog 弹窗(保留兼容)。"""
csv_path = self.output_file.get_path()
if not csv_path or not os.path.exists(csv_path):
QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。")
return
dialog = SamplingViewerDialog(csv_path, self)
dialog.exec_()
self._check_csv_exists()
self._check_csv_exists()

View File

@ -190,12 +190,11 @@ class Step5CleanPanel(QWidget):
else:
self.work_dir = None
if self.work_dir:
if self.work_dir and not self.output_file.get_path():
output_dir = resolve_subdir(self.work_dir, '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)
else:
elif not self.work_dir:
self.output_file.set_path("")
def _on_run_single_clicked(self):

View File

@ -234,14 +234,16 @@ class Step6FeaturePanel(QWidget):
if mask_path:
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)
if os.path.exists(mask_path):
self.water_mask_file.set_path(mask_path)
if hasattr(main_window, 'step2_panel'):
glint_path = main_window.step2_panel.output_file.get_path()
if glint_path:
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)
if os.path.exists(glint_path):
self.glint_mask_file.set_path(glint_path)
deglint_path = None
if pipeline and hasattr(pipeline, 'step_outputs'):
@ -273,15 +275,14 @@ class Step6FeaturePanel(QWidget):
if not os.path.isabs(deglint_path):
deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/')
existing_deglint = self.deglint_img_file.get_path()
if not existing_deglint or not existing_deglint.strip():
if (not existing_deglint or not existing_deglint.strip()) and os.path.exists(deglint_path):
self.deglint_img_file.set_path(deglint_path)
if self.work_dir:
if self.work_dir and not self.output_file.get_path():
output_dir = resolve_subdir(self.work_dir, 'spectral_feature')
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:
elif not self.work_dir:
self.output_file.set_path("")
if main_window and hasattr(main_window, 'step5_clean_panel'):
@ -292,7 +293,7 @@ class Step6FeaturePanel(QWidget):
self.work_dir or '', step5_clean_output_path
).replace('\\', '/')
existing_csv = self.csv_file.get_path()
if not existing_csv or not existing_csv.strip():
if (not existing_csv or not existing_csv.strip()) and os.path.exists(step5_clean_output_path):
self.csv_file.set_path(step5_clean_output_path)
def _on_run_single_clicked(self):

View File

@ -9,6 +9,8 @@ import sys
import csv
from pathlib import Path
import pandas as pd
from PyQt5.QtWidgets import (
QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QLabel, QPushButton, QMessageBox, QListWidget,
@ -263,7 +265,15 @@ class Step7InversionPanel(QWidget):
output_layout.setSpacing(16)
output_layout.setContentsMargins(20, 24, 20, 20)
# 这里不需要输出文件选择框,因为服务层会直接覆盖原 CSV,但保留执行按钮
self.output_file = FileSelectWidget(
"结果保存至:",
"CSV Files (*.csv);;All Files (*.*)",
mode="save"
)
self.output_file.label.setMinimumWidth(120)
self.output_file.line_edit.setPlaceholderText("training_spectra_indices.csv")
output_layout.addWidget(self.output_file)
action_layout = QHBoxLayout()
action_layout.addStretch()
@ -328,6 +338,9 @@ class Step7InversionPanel(QWidget):
'formula_names': selected_names,
'enabled': True # 默认启用
}
output_path = self.output_file.get_path()
if output_path:
config['output_path'] = output_path
return config
def set_config(self, config: dict):
@ -347,6 +360,9 @@ class Step7InversionPanel(QWidget):
item.setCheckState(state)
self.formula_list.blockSignals(False)
if 'output_path' in config:
self.output_file.set_path(config['output_path'])
def _load_formulas_from_csv(self):
"""解析公式 CSV 文件并填充列表框"""
csv_path = self.formula_file.get_path()
@ -439,7 +455,7 @@ class Step7InversionPanel(QWidget):
"""从全局配置/Pipeline 同步工作目录。
step6 的训练数据已由 PANEL_REGISTRY 的 dependencies 自动通过 set_config
注入到 self.training_data_widget;此处仅缓存 work_dir,
注入到 self.training_data_widget;此处仅缓存 work_dir 并填入默认输出路径,
不重复拉取,避免与 panel_factory 注入路径冲突。
"""
if work_dir:
@ -448,3 +464,11 @@ class Step7InversionPanel(QWidget):
pass
else:
self.work_dir = None
# 仅在输出框为空时填入默认路径(不创建目录,留给 pipeline 执行时创建)
if self.work_dir and not self.output_file.get_path():
output_dir = os.path.join(self.work_dir, "7_Water_Quality_Indices")
default_path = os.path.join(output_dir, "training_spectra_indices.csv").replace('\\', '/')
self.output_file.set_path(default_path)
elif not self.work_dir:
self.output_file.set_path("")

View File

@ -85,10 +85,11 @@ class Step8MlTrainPanel(QWidget):
self.init_ui()
def init_ui(self):
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
layout = QVBoxLayout()
# 标题
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(20)
# 训练数据文件(用于独立运行)
self.training_csv_file = FileSelectWidget(
@ -547,13 +548,11 @@ class Step8MlTrainPanel(QWidget):
if candidate:
self.training_csv_file.set_path(candidate)
# 2. 自动填充输出目录为 8_Machine_Learning_Models
if self.work_dir:
import os
# 2. 自动填充输出目录(仅在为空时填入默认路径,不创建目录)
if self.work_dir and not self.output_path.get_path():
models_dir = os.path.join(self.work_dir, "8_Machine_Learning_Models").replace('\\', '/')
os.makedirs(models_dir, exist_ok=True)
self.output_path.set_path(models_dir)
else:
elif not self.work_dir:
self.output_path.set_path("")
def _on_run_single_clicked(self):

View File

@ -183,11 +183,13 @@ class Step9MlPredictPanel(QWidget):
output_layout.setSpacing(16)
output_layout.setContentsMargins(20, 24, 20, 20)
# 输出文件路径
# 输出目录路径(目录模式:模型预测结果为多个 CSV 文件,存放到目录中)
self.output_file = FileSelectWidget(
"输出路径:",
"CSV Files (*.csv);;All Files (*.*)"
"输出目录:",
"Directories"
)
self.output_file.browse_btn.clicked.disconnect()
self.output_file.browse_btn.clicked.connect(self._browse_output_dir)
output_layout.addWidget(self.output_file)
# 完美对齐的底部按钮栏(已彻底移除多余的启用复选框)
@ -400,31 +402,28 @@ class Step9MlPredictPanel(QWidget):
factory = getattr(main_window, '_panel_factory', None) if main_window else None
# 1. 智能挑选采样 CSV:优先"含 WQI 指数的测试集"(防止特征维度与训练时不匹配)
# 修复目标:用户在 Step 8 用 95 维 (50+45 WQI) 训练 → Step 9 默认读 50 维 raw sampling
# 时 inference_batch.preprocess_spectra 会触发"自动特征补全"逻辑;但若用户已经在
# Step 10/手工把指数算到 CSV 里了,应该直接用那个文件(少走内存补全、避免 band 列顺序漂移)
wqi_test_csv = self._resolve_latest_wqi_test_csv()
if wqi_test_csv:
self.sampling_csv_file.set_path(wqi_test_csv)
elif factory:
# 兜底:拿第 4 步的纯原始采样光谱(旧行为保留)
# 兜底:拿第 4 步的纯原始采样光谱(仅当文件确实存在)
step4_panel = factory.get_panel('step4_sampling')
if step4_panel and hasattr(step4_panel, 'output_file'):
path = step4_panel.output_file.get_path()
if path: self.sampling_csv_file.set_path(path)
if path and os.path.exists(path):
self.sampling_csv_file.set_path(path)
# 2. 拿第 8 步的模型目录
# 2. 拿第 8 步的模型目录(仅当目录确实存在)
if factory:
step8_panel = factory.get_panel('step8_ml_train')
if step8_panel and hasattr(step8_panel, 'output_path'):
path = step8_panel.output_path.get_path()
if path: self.models_dir_file.set_path(path)
if path and os.path.isdir(path):
self.models_dir_file.set_path(path)
# 3. 生成第 9 步的输出目录
if hasattr(self, 'work_dir') and self.work_dir:
import os
# 3. 生成第 9 步的输出目录(仅在为空时填入默认路径,不创建目录)
if hasattr(self, 'work_dir') and self.work_dir and not self.output_file.get_path():
out_dir = os.path.join(self.work_dir, "9_ML_Prediction").replace('\\', '/')
os.makedirs(out_dir, exist_ok=True)
self.output_file.set_path(out_dir)
def _get_default_work_dir(self):
@ -445,6 +444,15 @@ class Step9MlPredictPanel(QWidget):
if dir_path:
self.models_dir_file.set_path(dir_path)
def _browse_output_dir(self):
"""浏览预测输出目录"""
default = self._get_default_work_dir()
if default:
default = os.path.join(default, '9_ML_Prediction')
dir_path = QFileDialog.getExistingDirectory(self, "选择预测输出目录", default)
if dir_path:
self.output_file.set_path(dir_path)
def get_config(self):
"""获取配置"""
config = {