Compare commits

...

2 Commits

Author SHA1 Message Date
DXC
61e06dd789 步骤十三页面修改 2026-06-29 15:15:09 +08:00
DXC
e121b02e34 Step8/9 UX: feature_start 改 QComboBox + 多源 CSV 优先回退 2026-06-29 14:40:39 +08:00
4 changed files with 618 additions and 99 deletions

View File

@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
"""
Smoke test: step8_ml_train_panel + step9_ml_predict_panel 的新 CSV→QComboBox 联动
& update_from_config 多源文件优先回退 逻辑。
不真正 import 整个 panel(避免 osgeo 重依赖),只 import 类本身,验证:
1) Step8MlTrainPanel.feature_start 变成 QComboBox
2) Step8._on_training_csv_changed 正确读 CSV 表头 + 默认选中 374.285
3) Step8._resolve_training_csv_from_workdir 优先级(Step7 > 10_WaterIndex > Step6 > Step7 兜底)
4) Step8.get_config / set_config / get_training_params 用 QComboBox API 不崩
5) Step9._resolve_latest_wqi_test_csv 找到正确的 WQI 测试集
6) Step9.update_from_config 优先级(WQI CSV > Step4 raw)
用 offscreen 模式避免弹窗,完整可重复跑。
"""
import os
import sys
import tempfile
from pathlib import Path
# 避免 panel 顶部 osgeo 导入阻塞
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# 让 src 目录可 import
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import pandas as pd
import numpy as np
from PyQt5.QtWidgets import QApplication
# 必须先 create QApplication 才能 new widget
app = QApplication.instance() or QApplication(sys.argv)
# ============================================================
# 准备 fixture:模拟工作目录
# ============================================================
def make_fake_csv(path, n_cols_50_band, extra_wqi_cols=None, n_rows=10):
"""生成模拟 CSV:50 个纯数字波段列 + 任意额外 WQI 列。"""
band_cols = [374.285 + i for i in range(n_cols_50_band)]
# 浮点列名会被 pandas 自动转成 374.285 / 374.285001 / ...
cols = band_cols + (extra_wqi_cols or [])
data = np.random.RandomState(42).rand(n_rows, len(cols))
df = pd.DataFrame(data, columns=cols)
df.to_csv(path, index=False)
return df
with tempfile.TemporaryDirectory() as tmp:
work_dir = tmp
print(f"[fixture] work_dir = {work_dir}")
# 1) Step 6 原始光谱
os.makedirs(os.path.join(work_dir, "6_Spectral_Feature_Extraction"), exist_ok=True)
step6_csv = os.path.join(work_dir, "6_Spectral_Feature_Extraction", "training_spectra.csv")
make_fake_csv(step6_csv, 50)
# 2) Step 7 WQI 增强版
os.makedirs(os.path.join(work_dir, "7_Water_Quality_Indices"), exist_ok=True)
step7_csv = os.path.join(work_dir, "7_Water_Quality_Indices", "training_spectra_indices.csv")
wqi_names = [f"WQI_Chla_{i}" for i in range(10)] + [f"WQI_TP_{i}" for i in range(5)]
make_fake_csv(step7_csv, 50, wqi_names)
# 3) 10_WaterIndex_CSV 下的 training csv(较旧 mtime)
os.makedirs(os.path.join(work_dir, "10_WaterIndex_CSV"), exist_ok=True)
step10_csv = os.path.join(work_dir, "10_WaterIndex_CSV", "training_watercolor_summary.csv")
make_fake_csv(step10_csv, 50, wqi_names)
# 把 mtime 调成 3 年前,确认 7_Water_Quality_Indices 那个更新
old_time = (pd.Timestamp.now() - pd.Timedelta(days=365 * 3)).timestamp()
os.utime(step10_csv, (old_time, old_time))
# 4) Step 4 raw sampling
os.makedirs(os.path.join(work_dir, "4_sampling"), exist_ok=True)
step4_csv = os.path.join(work_dir, "4_sampling", "sampling_spectra.csv")
make_fake_csv(step4_csv, 50)
# 5) WQI 测试集(10_WaterIndex_CSV 下另一个 mtime 较新的 csv)
wqi_test_csv = os.path.join(work_dir, "10_WaterIndex_CSV", "sampling_watercolor_summary.csv")
make_fake_csv(wqi_test_csv, 50, wqi_names)
# 设为 1 小时前(mtime 比 step7_csv 新)
new_time = (pd.Timestamp.now() - pd.Timedelta(hours=1)).timestamp()
os.utime(wqi_test_csv, (new_time, new_time))
# 6) 9_ML_Prediction 输出目录
os.makedirs(os.path.join(work_dir, "9_ML_Prediction"), exist_ok=True)
# ============================================================
# 测试 1:Step8 加载 + QComboBox 化验证
# ============================================================
from src.gui.panels.step8_ml_train_panel import Step8MlTrainPanel
from PyQt5.QtWidgets import QComboBox
panel8 = Step8MlTrainPanel()
assert isinstance(panel8.feature_start, QComboBox), \
f"FAIL: feature_start 应为 QComboBox,实际为 {type(panel8.feature_start)}"
print(f"[OK] Step8.feature_start 类型: {type(panel8.feature_start).__name__}")
# ============================================================
# 测试 2:CSV 变更槽 — 模拟用户选 WQI CSV,自动填表头
# ============================================================
panel8._on_training_csv_changed(step7_csv)
assert panel8.feature_start.count() > 0
head_text = panel8.feature_start.currentText()
print(f"[OK] Step8 选中 step7_csv 后,feature_start.currentText() = {head_text!r}")
# 默认选中:第一个纯数字波段列 "374.285"
assert head_text.replace('.', '').lstrip('-').isdigit(), \
f"FAIL: 默认应选数字波段,实际 = {head_text!r}"
print(f"[OK] 默认选中数字波段列: {head_text!r}")
# 验证前 50 列已填充
n_items = panel8.feature_start.count()
assert n_items >= 50, f"FAIL: 应至少 50 项,实际 {n_items}"
print(f"[OK] Step8.feature_start 填充了 {n_items} 个候选列(前 50 列)")
# ============================================================
# 测试 3:CSV 不存在时,combo 回到占位项
# ============================================================
panel8._on_training_csv_changed("/nonexistent/path.csv")
assert panel8.feature_start.count() == 1
assert "请先选择训练 CSV" in panel8.feature_start.itemText(0)
print(f"[OK] Step8 CSV 不存在时,combo 回退到占位项: {panel8.feature_start.itemText(0)!r}")
# ============================================================
# 测试 4:get_config / set_config / get_training_params 用 QComboBox API 不崩
# ============================================================
panel8._on_training_csv_changed(step7_csv)
cfg = panel8.get_config()
assert 'feature_start_column' in cfg
assert isinstance(cfg['feature_start_column'], str)
assert cfg['feature_start_column'].replace('.', '').lstrip('-').isdigit()
print(f"[OK] Step8.get_config()['feature_start_column'] = {cfg['feature_start_column']!r}")
# set_config 试一下(先选个不存在的列名,应该走兜底 addItem 分支)
panel8.set_config({'feature_start_column': '999.999_unknown'})
# 重新触发 CSV 加载,验证回放不污染
panel8._on_training_csv_changed(step7_csv)
panel8.set_config({'feature_start_column': '374.285'})
assert panel8.feature_start.currentText() == '374.285'
print(f"[OK] Step8.set_config() 正确把 374.285 选中")
# get_training_params
params = panel8.get_training_params()
assert 'feature_start' in params
assert isinstance(params['feature_start'], float)
print(f"[OK] Step8.get_training_params()['feature_start'] = {params['feature_start']}")
# ============================================================
# 测试 5:_resolve_training_csv_from_workdir 优先级
# ============================================================
panel8.work_dir = work_dir
resolved = panel8._resolve_training_csv_from_workdir()
expected = step7_csv.replace('\\', '/')
assert resolved == expected, f"FAIL: 应优先 step7, 实际 {resolved!r}, 期望 {expected!r}"
print(f"[OK] Step8._resolve_training_csv_from_workdir() = {resolved}")
print(f" 优先级 1: Step 7 WQI 增强版 ✓")
# 模拟用户没跑过 Step 7(删掉 step7_csv 目录)— 但 10_WaterIndex_CSV 还在
import shutil
shutil.rmtree(os.path.join(work_dir, "7_Water_Quality_Indices"))
resolved = panel8._resolve_training_csv_from_workdir()
# 用户明确提到 10_WaterIndex_CSV 目录下的结果是有效来源,应作为第二优先级
expected_step10 = str(Path(step10_csv)).replace('\\', '/')
assert resolved == expected_step10, \
f"FAIL: 应取 10_WaterIndex_CSV/*training*, 实际 {resolved!r}, 期望 {expected_step10!r}"
print(f"[OK] Step 7 缺省时,回退到 10_WaterIndex_CSV/*training*: {resolved}")
print(f" 优先级 2: 10_WaterIndex_CSV/*training* ✓")
# 再删 10_WaterIndex_CSV 才回退到 Step 6
shutil.rmtree(os.path.join(work_dir, "10_WaterIndex_CSV"))
resolved = panel8._resolve_training_csv_from_workdir()
assert resolved == step6_csv.replace('\\', '/'), \
f"FAIL: 应回退 step6, 实际 {resolved!r}, 期望 {step6_csv!r}"
print(f"[OK] Step 7 / 10_WaterIndex_CSV 都缺省时,回退到 Step 6: {resolved}")
print(f" 优先级 3: Step 6 原始光谱 ✓")
# ============================================================
# 测试 6:Step9 加载
# ============================================================
from src.gui.panels.step9_ml_predict_panel import Step9MlPredictPanel
panel9 = Step9MlPredictPanel()
print(f"[OK] Step9MlPredictPanel 实例化成功")
# 重建 10_WaterIndex_CSV(WQI 测试集 fixture,test 5 中被删了)
os.makedirs(os.path.join(work_dir, "10_WaterIndex_CSV"), exist_ok=True)
wqi_test_csv = os.path.join(work_dir, "10_WaterIndex_CSV", "sampling_watercolor_summary.csv")
make_fake_csv(wqi_test_csv, 50, wqi_names)
new_time = (pd.Timestamp.now() - pd.Timedelta(hours=1)).timestamp()
os.utime(wqi_test_csv, (new_time, new_time))
# ============================================================
# 测试 7:Step9._resolve_latest_wqi_test_csv 优先 10_WaterIndex_CSV
# ============================================================
panel9.work_dir = work_dir
resolved = panel9._resolve_latest_wqi_test_csv()
# 应该是 sampling_watercolor_summary.csv(mtime 较新)
expected = wqi_test_csv.replace('\\', '/')
assert resolved == expected, \
f"FAIL: 应取 mtime 最新的 wqi 测试集, 实际 {resolved!r}, 期望 {expected!r}"
print(f"[OK] Step9._resolve_latest_wqi_test_csv() = {resolved}")
print(f" 优先级:10_WaterIndex_CSV/sampling_watercolor_summary.csv ✓")
# ============================================================
# 测试 8:Step9.update_from_config 优先级:WQI CSV > Step4 raw
# ============================================================
# 注:Step 4 回退路径依赖 main_window._panel_factory(offline 测试无 main_window,
# 故仅验证 WQI CSV 优先级主路径;Step 4 路径与原代码同源,逻辑没变)
# 有 WQI 时优先 WQI
os.makedirs(os.path.join(work_dir, "10_WaterIndex_CSV"), exist_ok=True)
wqi_test_csv2 = os.path.join(work_dir, "10_WaterIndex_CSV", "sampling_watercolor_summary.csv")
make_fake_csv(wqi_test_csv2, 50, wqi_names)
panel9.update_from_config(work_dir=work_dir, pipeline=None)
path = panel9.sampling_csv_file.get_path()
assert wqi_test_csv2.replace('\\', '/') in path, \
f"FAIL: 应优先 WQI CSV, 实际 {path!r}"
print(f"[OK] Step9.update_from_config() 优先 WQI CSV: {path}")
# ============================================================
# 测试 9:work_dir 不存在时,所有 helper 安全返回
# ============================================================
panel9.work_dir = ""
assert panel9._resolve_latest_wqi_test_csv() == ""
print(f"[OK] work_dir 空时 _resolve_latest_wqi_test_csv 安全返回空串")
print("\n" + "=" * 60)
print("ALL SMOKE TESTS PASSED")
print("=" * 60)

View File

@ -99,110 +99,186 @@ class Step13ReportPanel(QWidget):
self.init_ui()
def init_ui(self):
# 1. 确保全局主题类被导入
from src.gui.styles import ModernStylesheet
layout = QVBoxLayout()
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(20)
# 重新定义全局绝美样式
common_lineedit_css = "QLineEdit { padding: 4px 8px; border: 1px solid #C0C0C0; border-radius: 4px; min-height: 24px; background-color: #FFFFFF; }"
common_btn_css = """
QPushButton { background-color: #F8F9FA; border: 1px solid #D1D5DB; border-radius: 4px; padding: 4px 12px; color: #374151; }
QPushButton:hover { background-color: #F3F4F6; border-color: #9CA3AF; }
"""
# 【核心黑科技】:定义标准行闭包,强制统一 120px 标签和 10px 间距
def create_standard_row(label_text, widget, btn=None):
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(10)
label = QLabel(label_text)
label.setMinimumWidth(120)
label.setMaximumWidth(120)
row.addWidget(label)
row.addWidget(widget, 1)
if btn:
row.addWidget(btn)
return row
intro = QLabel(
"根据工作目录下的可视化结果(14_visualization 等)生成 Word 分析报告。"
"💡 提示:根据工作目录下的可视化结果(14_visualization 等)自动生成 Word 分析报告。\n"
"需已存在可视化图表;AI 分析通过 Ollama 或 Minimax 调用云端/本地服务。"
)
intro.setWordWrap(True)
intro.setStyleSheet(
f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};"
)
intro.setStyleSheet(f"""
QLabel {{
color: {ModernStylesheet.COLORS.get('primary', '#0078D7')};
background-color: {ModernStylesheet.COLORS.get('selected', '#E5F3FF')};
border: 1px solid {ModernStylesheet.COLORS.get('border_light', '#D1D5DB')};
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 8px;
}}
""")
layout.addWidget(intro)
# ── 路径 ──────────────────────────────────────────────────────────────
path_group = QGroupBox("路径")
path_form = QFormLayout()
# ==========================================
# 卡片 1:报告路径配置
# ==========================================
path_group = QGroupBox("📁 报告路径配置")
path_layout = QVBoxLayout()
path_layout.setSpacing(16)
path_layout.setContentsMargins(20, 24, 20, 20)
wd_row = QHBoxLayout()
# 工作目录 (只读)
self.work_dir_edit = QLineEdit()
self.work_dir_edit.setPlaceholderText("流程工作目录(含 14_visualization)…")
self.work_dir_edit.setReadOnly(True)
wd_browse = QPushButton("浏览…")
wd_browse.clicked.connect(self.browse_work_dir)
wd_browse.setVisible(False)
sync_btn = QPushButton("同步主窗口工作目录")
sync_btn.clicked.connect(self.sync_work_dir_from_main)
sync_btn.setVisible(False)
wd_row.addWidget(self.work_dir_edit, 1)
wd_row.addWidget(wd_browse)
wd_row.addWidget(sync_btn)
path_form.addRow("工作目录:", wd_row)
self.work_dir_edit.setStyleSheet(common_lineedit_css)
path_layout.addLayout(create_standard_row("工作目录:", self.work_dir_edit))
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.output_dir_edit.setStyleSheet(common_lineedit_css)
out_browse = QPushButton("浏览...")
out_browse.setStyleSheet(common_btn_css)
out_browse.setMaximumWidth(80)
out_browse.clicked.connect(self.browse_output_dir)
path_layout.addLayout(create_standard_row("报告输出目录:", self.output_dir_edit, out_browse))
# 报告标题
self.report_title_edit = QLineEdit()
self.report_title_edit.setText("水质参数反演分析报告")
path_form.addRow("报告标题:", self.report_title_edit)
self.report_title_edit.setStyleSheet(common_lineedit_css)
path_layout.addLayout(create_standard_row("报告标题:", self.report_title_edit))
path_group.setLayout(path_form)
path_group.setLayout(path_layout)
layout.addWidget(path_group)
# ── AI 分析 ───────────────────────────────────────────────────────────
ai_group = QGroupBox("AI 分析")
# ==========================================
# 卡片 2:AI 分析配置
# ==========================================
ai_group = QGroupBox("🤖 AI 分析配置")
ai_layout = QVBoxLayout()
ai_layout.setSpacing(16)
ai_layout.setContentsMargins(20, 24, 20, 20)
# 启用复选框,采用占位符顶齐 120px
check_row = QHBoxLayout()
check_row.setContentsMargins(0, 0, 0, 0)
check_row.setSpacing(10)
placeholder = QLabel("")
placeholder.setMinimumWidth(120)
placeholder.setMaximumWidth(120)
check_row.addWidget(placeholder)
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)
self.enable_ai_cb.setChecked(os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"})
check_row.addWidget(self.enable_ai_cb)
check_row.addStretch()
ai_layout.addLayout(check_row)
# 只读提示行:当前引擎 + 配置按钮
# AI 引擎状态行
ai_status_row = QHBoxLayout()
ai_status_row.addWidget(QLabel("当前 AI 引擎:"))
ai_status_row.setContentsMargins(0, 0, 0, 0)
ai_status_row.setSpacing(10)
engine_label = QLabel("当前 AI 引擎:")
engine_label.setMinimumWidth(120)
engine_label.setMaximumWidth(120)
ai_status_row.addWidget(engine_label)
self._ai_label = QLabel()
self._ai_label.setStyleSheet("color: #0078d4; font-weight: bold;")
self._ai_label.setStyleSheet("color: #0078D7; font-weight: bold; font-size: 13px;")
ai_status_row.addWidget(self._ai_label)
ai_status_row.addStretch(1)
open_settings_btn = QPushButton("高级配置...")
open_settings_btn.setStyleSheet(common_btn_css)
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_generate_clicked)
btn_row.addWidget(self.generate_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
# ==========================================
# 卡片 3:输出与执行
# ==========================================
execute_group = QGroupBox("🚀 输出与执行")
execute_layout = QVBoxLayout()
execute_layout.setSpacing(12)
execute_layout.setContentsMargins(20, 24, 20, 20)
# ── 进度条 + 状态文案 ────────────────────────────────────────────────
progress_row = QHBoxLayout()
self.progress_label = QLabel("就绪")
self.progress_label.setMinimumWidth(220)
progress_row.addWidget(self.progress_label)
# 进度状态呈现(使用独一无二的 ID 选择器,彻底击碎并无视任何全局白块背景样式!)
self.progress_label = QLabel("⏳ 准备就绪")
self.progress_label.setObjectName("step13_progress_lbl")
self.progress_label.setStyleSheet("""
QLabel#step13_progress_lbl {
background-color: transparent;
background: transparent;
border: none;
color: #4A5568;
font-size: 13px;
font-weight: 500;
}
""")
execute_layout.addWidget(self.progress_label)
# 现代极简细线条进度条
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
self.progress_bar.setTextVisible(True)
progress_row.addWidget(self.progress_bar, 1)
layout.addLayout(progress_row)
self.progress_bar.setTextVisible(False) # 【核心】关闭原生文字,彻底干掉 Windows 丑陋白框!
self.progress_bar.setFixedHeight(8) # 压低高度,高级感拉满
self.progress_bar.setStyleSheet("""
QProgressBar { border: none; border-radius: 4px; background-color: #E2E8F0; }
QProgressBar::chunk { background-color: #0078D7; border-radius: 4px; }
""")
execute_layout.addWidget(self.progress_bar)
action_layout = QHBoxLayout()
action_layout.addStretch()
self.generate_btn = QPushButton("独立运行步骤")
self.generate_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("primary"))
self.generate_btn.setMinimumWidth(140)
self.generate_btn.clicked.connect(self.on_generate_clicked)
action_layout.addWidget(self.generate_btn)
execute_layout.addLayout(action_layout)
execute_group.setLayout(execute_layout)
layout.addWidget(execute_group)
layout.addStretch()
self.setLayout(layout)
# 刷新引擎提示文字
self._refresh_ai_label()
# 初次构建时尝试同步主窗口工作目录
self._auto_pull_work_dir()
def _refresh_ai_label(self):
@ -215,6 +291,11 @@ class Step13ReportPanel(QWidget):
def _open_ai_settings(self):
"""弹出全局 AI 设置对话框,保存后刷新提示标签。"""
dlg = AISettingsDialog(self)
# 【强力注入】强制将配置弹窗内的标签与编组框背景抹为透明,全面恢复高级灰度质感!
dlg.setStyleSheet("""
QLabel { background-color: transparent; background: transparent; border: none; }
QGroupBox { background-color: transparent; }
""")
if dlg.exec_() == dlg.Accepted:
self._refresh_ai_label()
@ -333,21 +414,22 @@ class Step13ReportPanel(QWidget):
print(f"[{level}] {msg}")
def _on_progress(self, pct: int, text: str):
"""接收 ReportWorkerThread.progress(int, str) —— 主线程槽。"""
"""接收后台进度的槽函数 —— 主线程。"""
self.progress_bar.setValue(int(pct))
self.progress_label.setText(text or f"{pct}%")
# 将百分比和文案完美融合进透明标签
self.progress_label.setText(f"⏳ {text} ({pct}%)" if text else f"⏳ 正在生成... ({pct}%)")
def _on_finished(self, path: str):
"""报告生成成功 —— 主线程槽。"""
self.progress_bar.setValue(100)
self.progress_label.setText("完成")
self.progress_label.setText("✅ 分析报告生成成功!")
self.generate_btn.setEnabled(True)
QMessageBox.information(self, "完成", f"报告已生成:\n{path}")
self._forward_log(f"Word 报告已保存: {path}", "info")
def _on_error(self, err: str):
"""报告生成异常 —— 主线程槽。"""
self.progress_label.setText("失败")
self.progress_label.setText("❌ 报告生成失败")
self.generate_btn.setEnabled(True)
QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}")
self._forward_log(err, "error")

View File

@ -14,10 +14,12 @@ if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from src.gui.panels._step_path_resolver import get_step_output_path, resolve_step_widget, resolve_subdir
import pandas as pd
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout,
QHBoxLayout, QLabel, QLineEdit, QSpinBox, QCheckBox,
QPushButton, QFileDialog, QMessageBox, QSizePolicy,
QPushButton, QFileDialog, QMessageBox, QSizePolicy, QComboBox,
)
from PyQt5.QtCore import Qt
@ -95,6 +97,9 @@ class Step8MlTrainPanel(QWidget):
)
layout.addWidget(self.training_csv_file)
# 训练 CSV 选定后,自动刷新"特征起始列"下拉框的候选列
self.training_csv_file.line_edit.textChanged.connect(self._on_training_csv_changed)
# 机器学习模型页面
self.ml_page = QWidget()
self.create_ml_page()
@ -144,19 +149,22 @@ class Step8MlTrainPanel(QWidget):
params_group = QGroupBox("训练参数")
params_layout = QFormLayout()
self.feature_start = QLineEdit()
self.feature_start.setText("374.285004")
self.feature_start = QComboBox()
self.feature_start.setMinimumWidth(180)
self.feature_start.setStyleSheet("""
QComboBox {
padding: 4px 8px;
border: 1px solid #C0C0C0;
border-radius: 4px;
min-height: 24px;
}
""")
# 初始空表:实际候选列由 _on_training_csv_changed 在用户选定 CSV 后填充;
# 此处先放一个占位项,防止未选 CSV 时下拉框完全空白(极端空状态视觉异常)
self.feature_start.addItem("(请先选择训练 CSV)", "")
self.feature_start.setCurrentIndex(0)
params_layout.addRow("特征起始列:", self.feature_start)
# 特征起始列名提示:用记事本打开 training_spectra.csv 确认首个波长的精确表头
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)
@ -318,6 +326,133 @@ class Step8MlTrainPanel(QWidget):
return str(mw.work_dir)
return ""
def _on_training_csv_changed(self, csv_path: str):
"""训练 CSV 变更槽:自动读表头(前 50 列)填充 self.feature_start 下拉框。
触发场景:
- 用户点"浏览..."选了新 CSV
- 上游 panel_factory / set_config 注入路径
默认选中规则:
1) 优先 '374.285' 纯数字波段列(光谱起点;容忍 .285 / .285004 / .2850000001 等浮点变体)
2) 兜底:列表里第一个纯数字波段列
3) 仍找不到:保留占位项 "(请先选择训练 CSV)" 不动
注:nrows=1 即可读到全部表头,无需 load 整个文件
"""
# 清空旧候选,保留占位策略
self.feature_start.blockSignals(True)
try:
self.feature_start.clear()
if not csv_path or not os.path.isfile(csv_path):
self.feature_start.addItem("(请先选择训练 CSV)", "")
self.feature_start.setCurrentIndex(0)
return
# 读取前 50 列表头
try:
df_head = pd.read_csv(csv_path, nrows=0)
except Exception as e:
print(f"[Step8] 读取 CSV 表头失败: {e}")
self.feature_start.addItem("(CSV 读取失败)", "")
self.feature_start.setCurrentIndex(0)
return
all_cols = list(df_head.columns)
head_cols = all_cols[:50]
if not head_cols:
self.feature_start.addItem("(CSV 无列)", "")
self.feature_start.setCurrentIndex(0)
return
# 填充前 50 列到下拉框
for col in head_cols:
self.feature_start.addItem(str(col), str(col))
# 默认选中规则:纯数字波段列(纯数字字符串视为波长)
default_idx = self._find_default_band_index(head_cols)
if default_idx is not None:
self.feature_start.setCurrentIndex(default_idx)
else:
self.feature_start.setCurrentIndex(0)
finally:
self.feature_start.blockSignals(False)
def _find_default_band_index(self, columns):
"""在前 50 列中找到默认要选中的波段列索引。
优先级:
1) 列名以 '374.285' 开头(覆盖 374.285 / 374.285004 / 374.2850001)
2) 列名是纯数字(如 '443')— 极少数仪器用整数波长
3) 第一个能被 float() 解析的纯数字列
"""
# 1) 374.285 前缀
for i, col in enumerate(columns):
if str(col).startswith("374.285"):
return i
# 2) 纯数字列(无小数点)
for i, col in enumerate(columns):
if str(col).replace('.', '').lstrip('-').isdigit() and '.' not in str(col):
return i
# 3) 任意可被 float 解析的列
for i, col in enumerate(columns):
try:
float(str(col))
return i
except (ValueError, TypeError):
continue
return None
def _resolve_training_csv_from_workdir(self):
"""根据工作目录智能挑选训练 CSV 路径。
优先级(从高到低):
1) 7_Water_Quality_Indices/training_spectra_indices.csv(Step 7 WQI 增强版)
2) 10_WaterIndex_CSV/*training* / *training*indices*.csv(用户自定义带指数汇总)
3) 6_Spectral_Feature_Extraction/training_spectra.csv(Step 6 原始特征)
4) 7_Water_Quality_Indices/ 下任意 *training*.csv
"""
work_dir = self._get_default_work_dir()
if not work_dir:
return ""
from pathlib import Path
wd = Path(work_dir)
# 1) Step 7 输出的训练 WQI 增强版
step7_csv = wd / "7_Water_Quality_Indices" / "training_spectra_indices.csv"
if step7_csv.is_file():
return str(step7_csv).replace('\\', '/')
# 2) 10_WaterIndex_CSV 下任何带 "training" 关键词的 csv(用户在 Step 10 跑过训练集)
idx_dir = wd / "10_WaterIndex_CSV"
if idx_dir.is_dir():
candidates = sorted(
idx_dir.glob("*training*.csv"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if candidates:
return str(candidates[0]).replace('\\', '/')
# 3) Step 6 原始光谱特征
step6_csv = wd / "6_Spectral_Feature_Extraction" / "training_spectra.csv"
if step6_csv.is_file():
return str(step6_csv).replace('\\', '/')
# 4) Step 7 目录下任何 training*.csv(兜底)
step7_dir = wd / "7_Water_Quality_Indices"
if step7_dir.is_dir():
candidates = sorted(
step7_dir.glob("*training*.csv"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if candidates:
return str(candidates[0]).replace('\\', '/')
return ""
def browse_output_path(self):
"""浏览输出模型目录"""
work_dir = getattr(self, 'work_dir', "")
@ -342,7 +477,8 @@ class Step8MlTrainPanel(QWidget):
]
config = {
'feature_start_column': self.feature_start.text(),
# QComboBox 适配:currentText() 拿用户选的列名(与原 QLineEdit.text() 语义等价)
'feature_start_column': self.feature_start.currentText(),
'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'],
@ -359,7 +495,15 @@ class Step8MlTrainPanel(QWidget):
def set_config(self, config):
"""设置配置"""
if 'feature_start_column' in config:
self.feature_start.setText(str(config['feature_start_column']))
# QComboBox 适配:先 findText 匹配候选;找不到时把值原样塞进去(兜底)
target = str(config['feature_start_column'])
idx = self.feature_start.findText(target)
if idx >= 0:
self.feature_start.setCurrentIndex(idx)
else:
# 配置回放时 CSV 可能尚未选定,候选列表为空;先插一项保留语义
self.feature_start.addItem(target, target)
self.feature_start.setCurrentIndex(self.feature_start.count() - 1)
if 'cv_folds' in config:
self.cv_folds.setValue(config['cv_folds'])
if 'preprocessing_methods' in config:
@ -393,20 +537,15 @@ class Step8MlTrainPanel(QWidget):
else:
self.work_dir = None
# 1. 强制读 Step 6 的 training_spectra.csv(光谱特征提取结果)
# 修复张冠李戴:原链路 STEP_DATA_SOURCE['training_spectra_csv'] → step5_clean_panel
# 错误地指向了 Step 5 的 processed_data.csv(纯清洗数据,不含光谱特征),
# 实际 ML 训练需要的特征数据来自 Step 6 的 6_Spectral_Feature_Extraction/training_spectra.csv
main_window = self.window()
# 1. 智能挑选训练 CSV(不再"强制"读 Step 6,而是优先 WQI 增强版)
# 优先级:Step 7 WQI > 10_WaterIndex_CSV/*training* > Step 6 原始光谱 > Step 7 兜底
# 修复目标:用户跑过 Step 7 后再回到 Step 8,UI 默认应指向带指数的训练集,
# 否则训练好的模型有 95 维(50 波段 + 45 WQI),下次回放变成只 50 维训练,特征维数错位。
existing_training_csv = self.training_csv_file.get_path()
if not existing_training_csv or not existing_training_csv.strip():
if self.work_dir:
step6_dir = resolve_subdir(self.work_dir, 'spectral_feature')
step6_training_csv = os.path.join(
step6_dir, 'training_spectra.csv'
).replace('\\', '/')
if step6_training_csv:
self.training_csv_file.set_path(step6_training_csv)
candidate = self._resolve_training_csv_from_workdir()
if candidate:
self.training_csv_file.set_path(candidate)
# 2. 自动填充输出目录为 8_Machine_Learning_Models
if self.work_dir:
@ -448,7 +587,9 @@ class Step8MlTrainPanel(QWidget):
"""获取模型训练参数"""
return {
'pipeline_type': 'machine_learning',
'feature_start': float(self.feature_start.text()),
# QComboBox 适配:currentText() 取列名;下拉项里就是"374.285004" 等纯数字波段名
# (与原 QLineEdit 中 "374.285004" 字符串保持完全一致,后端 float() 解析不变)
'feature_start': float(self.feature_start.currentText()),
'cv_folds': self.cv_folds.value(),
'preprocess_methods': [method for method, cb in self.preproc_checkboxes.items() if cb.isChecked()],
'model_types': [model for model, cb in self.model_checkboxes.items() if cb.isChecked()],

View File

@ -8,6 +8,8 @@ import os
import sys
from pathlib import Path
import pandas as pd
# 路径归一化 helper(与 pipeline.get_step_output_dir 互为表里)
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
@ -333,24 +335,90 @@ class Step9MlPredictPanel(QWidget):
result[name] = self.external_models_dict[name]
return result
def _resolve_latest_wqi_test_csv(self):
"""在工作目录中智能挑选"最新生成的、含 WQI 指数的测试集 CSV"。
返回:找到则返回文件路径字符串;找不到返回 ""。
搜索策略(按优先级递减,命中即返回):
1) 10_WaterIndex_CSV/*.csv — Step 10 输出目录(用户在 Step 10 跑过的产品)
2) 7_Water_Quality_Indices/*sampling*.csv / *test*.csv — 用户手动对采样点算过 WQI
3) work_dir 下任何 *indices*.csv / *wqi*.csv(不区分大小写)
4) work_dir 下任何 > 60 列的 csv(启发式:50 波段 + > 10 WQI 指数列)
5) 兜底空串,调用方回退到 Step 4 sampling_spectra.csv
多个候选时按 mtime 倒序选"最新生成的"。
"""
work_dir = self._get_default_work_dir()
if not work_dir:
return ""
wd = Path(work_dir)
found = []
# 1) 10_WaterIndex_CSV 下所有 csv(Step 10 输出)
idx_dir = wd / "10_WaterIndex_CSV"
if idx_dir.is_dir():
found.extend(idx_dir.glob("*.csv"))
# 2) 7_Water_Quality_Indices 下与采样/测试相关的 csv
qa_dir = wd / "7_Water_Quality_Indices"
if qa_dir.is_dir():
for pattern in ("*sampling*.csv", "*test*.csv", "*predict*.csv"):
found.extend(qa_dir.glob(pattern))
# 3) work_dir 直接子树下含 indices/wqi 关键词的 csv
for keyword in ("*indices*.csv", "*wqi*.csv", "*WQI*.csv"):
found.extend(wd.rglob(keyword))
# 4) 启发式:> 60 列的 csv(50 波段 + 至少 10 个指数)
try:
for csv in wd.rglob("*.csv"):
if csv in found:
continue
try:
head = pd.read_csv(csv, nrows=0)
if head.shape[1] > 60:
found.append(csv)
except Exception:
pass # 读取失败就跳过,不影响其它候选
except Exception:
pass
if not found:
return ""
# 去重 + 按 mtime 倒序排
uniq = {p.resolve(): p for p in found}.values()
sorted_paths = sorted(uniq, key=lambda p: p.stat().st_mtime, reverse=True)
return str(sorted_paths[0]).replace('\\', '/')
def update_from_config(self, work_dir=None, pipeline=None):
if work_dir: self.work_dir = work_dir
main_window = self.window()
factory = getattr(main_window, '_panel_factory', None) if main_window else None
if not factory: return
# 1. 拿第 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)
# 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 步的纯原始采样光谱(旧行为保留)
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)
# 2. 拿第 8 步的模型目录
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 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)
# 3. 生成第 9 步的输出目录
if hasattr(self, 'work_dir') and self.work_dir: