测试修改

This commit is contained in:
DXC
2026-06-23 14:39:54 +08:00
parent c4aa246c95
commit b8d263e494
21 changed files with 595 additions and 2706 deletions

View File

@ -32,13 +32,22 @@ class Step9MlPredictHandler(BaseStepHandler):
models_dir = config.get('models_dir') or str(context.models_dir) models_dir = config.get('models_dir') or str(context.models_dir)
# 【硬编码路径清除】优先读取前端 config 中的 output_path / output_dir
# 绝不允许私自拼接 11_12_13_predictions 覆盖用户在前端填写的路径。
# 前端 step9_ml_predict_panel.get_config() 用的是 'output_path' 这个 key。
output_dir = (
config.get('output_path')
or config.get('output_dir')
or str(context.prediction_dir / "9_ML_Prediction")
)
try: try:
result = PredictionStep.predict_water_quality( result = PredictionStep.predict_water_quality(
sampling_csv_path=sampling_csv_path, sampling_csv_path=sampling_csv_path,
models_dir=models_dir, models_dir=models_dir,
metric=config.get('metric', 'test_r2'), metric=config.get('metric', 'test_r2'),
prediction_column=config.get('prediction_column', 'prediction'), prediction_column=config.get('prediction_column', 'prediction'),
output_dir=str(context.prediction_dir / "9_ML_Prediction"), output_dir=output_dir,
_report_generator=context.report_generator, _report_generator=context.report_generator,
_external_model=config.get('_external_model'), _external_model=config.get('_external_model'),
_external_model_path=config.get('_external_model_path'), _external_model_path=config.get('_external_model_path'),

View File

@ -1,14 +1,24 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
依赖订阅混入模块 依赖订阅混入模块(架构解耦版)
新规则dependencies 字典的键名 = 下游目标控件的真实属性名,
第三元素source_attr= 上游源控件的真实属性名。
提供 subscribe_panel_to_dependencies() 函数,让步骤面板根据 提供 subscribe_panel_to_dependencies() 函数,让步骤面板根据
PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus
订阅 OutputUpdated 事件。当上游步骤产出落地时,面板自动将路径 订阅 OutputUpdated 事件。当上游步骤产出落地时,面板自动将路径
填入对应的 FileSelectWidget无需主窗口手工传导。 填入对应的 FileSelectWidget无需主窗口手工传导。
内含:
- 自动识别下游 widget按 dict 键名查找)
- 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径
- 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录
""" """
import os
from src.gui.core.event_bus import global_event_bus from src.gui.core.event_bus import global_event_bus
@ -19,18 +29,21 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies):
匹配时,自动将路径填入面板对应的 FileSelectWidget。 匹配时,自动将路径填入面板对应的 FileSelectWidget。
Args: Args:
panel: 步骤面板实例QWidget 子类) panel: 步骤面板实例QWidget 子类),即下游目标面板
step_id: 当前面板的 step_id仅用于日志非匹配键 step_id: 当前面板的 step_id仅用于日志非匹配键
dependencies: dict, {input_field: (dep_step, output_type, panel_attr)} dependencies: dict, {target_field: (dep_step, output_type, source_attr)}
target_field 必须等于 panel 上某个控件的属性名
source_attr 仅作回放端使用(本函数不直接依赖)
""" """
if not dependencies: if not dependencies:
return return
for _input_field, (dep_step, output_type, panel_attr) in dependencies.items(): # 注意:我们使用字典的键名 (_input_field) 作为唯一的下游目标框名查找依据
_make_subscription(panel, dep_step, output_type, panel_attr) for _input_field, (dep_step, output_type, source_panel_attr) in dependencies.items():
_make_subscription(panel, dep_step, output_type, _input_field)
def _make_subscription(panel, dep_step, output_type, panel_attr): def _make_subscription(panel, dep_step, output_type, target_widget_name):
"""为单个依赖项创建事件订阅。使用工厂函数避免闭包变量延迟绑定。""" """为单个依赖项创建事件订阅。使用工厂函数避免闭包变量延迟绑定。"""
def callback(data): def callback(data):
@ -39,7 +52,7 @@ def _make_subscription(panel, dep_step, output_type, panel_attr):
if data.get('output_type') != output_type: if data.get('output_type') != output_type:
return return
widget = getattr(panel, panel_attr, None) widget = getattr(panel, target_widget_name, None)
if widget is None: if widget is None:
return return
@ -56,6 +69,10 @@ def _make_subscription(panel, dep_step, output_type, panel_attr):
if not path: if not path:
return return
# 智能转换:如果要的是目录,但来的是文件,自动截取父目录
if 'dir' in target_widget_name.lower() and os.path.isfile(path):
path = os.path.dirname(path)
if hasattr(widget, 'set_path'): if hasattr(widget, 'set_path'):
widget.set_path(path) widget.set_path(path)
elif hasattr(widget, 'setText'): elif hasattr(widget, 'setText'):

View File

@ -169,11 +169,12 @@ class PanelFactory:
if placeholder is not None and self._tab_widget is not None: if placeholder is not None and self._tab_widget is not None:
tab_title = self._tab_widget.tabText(tab_index) tab_title = self._tab_widget.tabText(tab_index)
tab_icon = self._tab_widget.tabIcon(tab_index) tab_icon = self._tab_widget.tabIcon(tab_index)
current_active = self._tab_widget.currentIndex() # 记住当前正在看的 Tab
self._tab_widget.blockSignals(True) self._tab_widget.blockSignals(True)
try: try:
self._tab_widget.removeTab(tab_index) self._tab_widget.removeTab(tab_index)
self._tab_widget.insertTab(tab_index, scroll, tab_icon, tab_title) self._tab_widget.insertTab(tab_index, scroll, tab_icon, tab_title)
self._tab_widget.setCurrentIndex(tab_index) self._tab_widget.setCurrentIndex(current_active) # 恢复原来的 Tab严禁后台预加载引发跳页
finally: finally:
self._tab_widget.blockSignals(False) self._tab_widget.blockSignals(False)
@ -226,33 +227,40 @@ class PanelFactory:
self._replay_live_panel_inputs() self._replay_live_panel_inputs()
def _replay_live_panel_inputs(self): def _replay_live_panel_inputs(self):
"""遍历 PANEL_REGISTRY 依赖声明,从已加载面板实时读取属性值。 """遍历 PANEL_REGISTRY 依赖声明,从已加载面板实时读取属性值并强制广播
若源面板已实例化,读取其 widget 的当前值并发布为 OutputUpdated 架构解耦2026-06-22第三个元素 source_attr 现在明确代表上游控件的真实名字
确保懒加载面板能收到全局输入(如 Step1.img_file → reference_img 不再混用语义。回放端仅依赖 source_attr 在 SOURCE 面板上能命中 widget
""" """
from src.gui.core.event_bus import global_event_bus
for entry in self._registry: for entry in self._registry:
deps = entry.get('dependencies') deps = entry.get('dependencies')
if not deps: if not deps: continue
continue
for _input_field, (dep_step, output_type, panel_attr) in deps.items(): # 第三个元素 source_attr 现在明确代表上游控件的真实名字
for target_field, (dep_step, output_type, source_attr) in deps.items():
src_panel = self._panels.get(dep_step) src_panel = self._panels.get(dep_step)
if src_panel is None: if src_panel is None: continue
continue
widget = getattr(src_panel, panel_attr, None) widget = getattr(src_panel, source_attr, None)
if widget is None: if widget is None: continue
continue
path = '' path = ""
if hasattr(widget, 'get_path'): if hasattr(widget, 'get_path'):
path = widget.get_path().strip() path = widget.get_path().strip()
elif hasattr(widget, 'text'): elif hasattr(widget, 'text'):
path = widget.text().strip() path = widget.text().strip()
if not path:
continue if not path: continue
# 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失
import os
absolute_path = os.path.abspath(path).replace('\\', '/')
global_event_bus.publish('OutputUpdated', { global_event_bus.publish('OutputUpdated', {
'step_id': dep_step, 'step_id': dep_step,
'output_type': output_type, 'output_type': output_type,
'path': path, 'path': absolute_path,
}) })
def _get_current_work_dir(self): def _get_current_work_dir(self):

View File

@ -48,14 +48,13 @@ PANEL_REGISTRY = [
'icon': '2.png', 'icon': '2.png',
'stage': '阶段一:影像预处理', 'stage': '阶段一:影像预处理',
'display_name': '2. 耀斑区域识别', 'display_name': '2. 耀斑区域识别',
# 对账修复2026-06-18 # 架构解耦2026-06-22dict 键名 = 下游目标控件真实属性名;
# - img_path: 来源 step1.img_file、目标 step2.img_file ✓ 两端均存在 # 第三元素 source_attr = 上游源控件真实属性名panel_factory 回放端使用)
# - water_mask_path: 原 panel_attr='water_mask_file' 在 step1 panel 不存在(断链)
# → 改为 step1 真实控件名 'mask_file'step1 默认现有掩膜输入模式)
# → NDWI 模式由 step2.update_from_config 自补足,不依赖 EventBus 链路
'dependencies': { 'dependencies': {
'img_path': ('step1', 'reference_img', 'img_file'), # 目标框: self.img_file ← 上游 step1.img_file
'water_mask_path': ('step1', 'water_mask', 'mask_file'), 'img_file': ('step1', 'reference_img', 'img_file'),
# 目标框: self.water_mask_file ← 上游 step1.mask_file
'water_mask_file': ('step1', 'water_mask', 'mask_file'),
}, },
'constructor_kwargs': None, 'constructor_kwargs': None,
}, },
@ -66,14 +65,12 @@ PANEL_REGISTRY = [
'icon': '3.png', 'icon': '3.png',
'stage': '阶段一:影像预处理', 'stage': '阶段一:影像预处理',
'display_name': '3. 耀斑去除与修复', 'display_name': '3. 耀斑去除与修复',
# 对账修复2026-06-18 # 架构解耦2026-06-22dict 键名 = 下游目标控件真实属性名
# - img_path: 来源 step1.img_file、目标 step3.img_file ✓ 两端均存在
# - water_mask: 原 panel_attr='water_mask_file' 在 step1 panel 不存在(断链)
# → 改为 step1 真实控件名 'mask_file'
# → NDWI 模式由 step3.update_from_config 自补足
'dependencies': { 'dependencies': {
'img_path': ('step1', 'reference_img', 'img_file'), # 目标框: self.img_file ← 上游 step1.img_file
'water_mask': ('step1', 'water_mask', 'mask_file'), 'img_file': ('step1', 'reference_img', 'img_file'),
# 目标框: self.water_mask_file ← 上游 step1.mask_file
'water_mask_file': ('step1', 'water_mask', 'mask_file'),
}, },
'constructor_kwargs': None, 'constructor_kwargs': None,
}, },
@ -88,14 +85,13 @@ PANEL_REGISTRY = [
'icon': '4.png', 'icon': '4.png',
'stage': '阶段二:样本数据准备', 'stage': '阶段二:样本数据准备',
'display_name': '4. 采样点布设', 'display_name': '4. 采样点布设',
# 对账修复2026-06-18 # 架构解耦2026-06-22dict 键名 = 下游目标控件真实属性名;
# - deglint_img_path: 原 panel_attr='deglint_img_file' 在 step3 panel 不存在(断链 # 第三元素 source_attr = 上游源控件真实属性名panel_factory 回放端使用
# → step3 输出 widget 真实名为 'output_file'deglint_image.bsq
# - water_mask_path: 原 'water_mask_file' 在 step1 panel 不存在
# → 改为 step1 真实控件名 'mask_file'
'dependencies': { 'dependencies': {
'deglint_img_path': ('step3', 'deglint_image', 'output_file'), # 目标框: self.deglint_img_file ← 上游 step3.output_file
'water_mask_path': ('step1', 'water_mask', 'mask_file'), 'deglint_img_file': ('step3', 'deglint_image', 'output_file'),
# 目标框: self.water_mask_file ← 上游 step1.mask_file
'water_mask_file': ('step1', 'water_mask', 'mask_file'),
}, },
'constructor_kwargs': None, 'constructor_kwargs': None,
}, },
@ -117,18 +113,16 @@ PANEL_REGISTRY = [
'icon': '6.png', 'icon': '6.png',
'stage': '阶段二:样本数据准备', 'stage': '阶段二:样本数据准备',
'display_name': '6. 光谱特征提取', 'display_name': '6. 光谱特征提取',
# 对账修复2026-06-18 # 架构解耦2026-06-22dict 键名 = 下游目标控件真实属性名
# - deglint_img_path: 原 'deglint_img_file' 在 step3 panel 不存在(断链)
# → 改为 step3 输出 widget 'output_file'
# - csv_path: 原 'csv_file' ✓ step5 panel 有 self.csv_file无修改
# - boundary_mask_path: 原 'water_mask_file' 在 step1 panel 不存在
# → 改为 step1 真实控件名 'mask_file'
# - glint_mask_path: 原 'glint_mask_file' ✓ step2 panel 有,无修改
'dependencies': { 'dependencies': {
'deglint_img_path': ('step3', 'deglint_image', 'output_file'), # 目标框: self.deglint_img_file ← 上游 step3.output_file
'csv_path': ('step5_clean', 'processed_data', 'csv_file'), 'deglint_img_file': ('step3', 'deglint_image', 'output_file'),
'boundary_mask_path': ('step1', 'water_mask', 'mask_file'), # 目标框: self.csv_file ← 上游 step5_clean.csv_file
'glint_mask_path': ('step2', 'glint_mask', 'glint_mask_file'), 'csv_file': ('step5_clean', 'processed_data', 'csv_file'),
# 目标框: self.water_mask_file ← 上游 step1.mask_file
'water_mask_file': ('step1', 'water_mask', 'mask_file'),
# 目标框: self.glint_mask_file ← 上游 step2.output_file
'glint_mask_file': ('step2', 'glint_mask', 'output_file'),
}, },
'constructor_kwargs': None, 'constructor_kwargs': None,
}, },
@ -139,18 +133,11 @@ PANEL_REGISTRY = [
'icon': '7.png', 'icon': '7.png',
'stage': '阶段二:样本数据准备', 'stage': '阶段二:样本数据准备',
'display_name': '7. 水质指数计算', 'display_name': '7. 水质指数计算',
# 对账修复2026-06-18 # 架构解耦2026-06-22dict 键名 = 下游目标控件真实属性名;
# - training_csv_path: # 第三元素 source_attr = 上游源控件真实属性名
# 原 ('step6_feature', 'training_spectra', 'training_data_widget')
# output_type='training_spectra' 仅作为 EventBus 事件标签,不匹配 step6 输出
# panel_attr='training_data_widget' 已是 step7 view 真实控件 ✓
# 改 output_type='output_file'step6 输出 file widget 名为 output_file
# ⚠️ 用户原文要求 panel_attr='csv_file',但 step7 无 csv_file widget
# (只有 training_data_widget 与 formula_csv_widget
# 实际改用 step7 真实控件 'training_data_widget'
# source panel_attr 取 step6 的 'output_file'(双源对账一致)
'dependencies': { 'dependencies': {
'training_csv_path': ('step6_feature', 'output_file', 'training_data_widget'), # 目标框: self.training_data_widget ← 上游 step6_feature.output_file
'training_data_widget': ('step6_feature', 'output_file', 'output_file'),
}, },
'constructor_kwargs': None, 'constructor_kwargs': None,
}, },
@ -165,13 +152,9 @@ PANEL_REGISTRY = [
'icon': '8.png', 'icon': '8.png',
'stage': '阶段三:模型构建与训练', 'stage': '阶段三:模型构建与训练',
'display_name': '8. 机器学习建模', 'display_name': '8. 机器学习建模',
# 对账修复2026-06-18 # 架构解耦2026-06-22dict 键名 = 下游目标控件真实属性名
# - training_csv_file:
# 原 ('step7_index', 'training_spectra_indices', 'training_csv_file')
# source panel_attr='training_csv_file' 在 step7 view 不存在(断链)
# → step7 view 输出(指数计算后 CSV真实控件为 'training_data_widget'
# target panel_attr='training_csv_file' ✓ step8 panel 有 self.training_csv_file
'dependencies': { 'dependencies': {
# 目标框: self.training_csv_file ← 上游 step7_index.training_data_widget
'training_csv_file': ('step7_index', 'training_spectra_indices', 'training_data_widget'), 'training_csv_file': ('step7_index', 'training_spectra_indices', 'training_data_widget'),
}, },
'constructor_kwargs': None, 'constructor_kwargs': None,
@ -187,14 +170,11 @@ PANEL_REGISTRY = [
'icon': '10.png', 'icon': '10.png',
'stage': '阶段四:预测与成果输出', 'stage': '阶段四:预测与成果输出',
'display_name': '9. 机器学习预测', 'display_name': '9. 机器学习预测',
# 对账修复2026-06-18 # 架构解耦2026-06-22dict 键名 = 下游目标控件真实属性名;
# - models_dir: # 第三元素 source_attr = 上游源控件真实属性名
# 原 ('step8_ml_train', 'Supervised_Model_Training', 'models_dir_file')
# source panel_attr='models_dir_file' 在 step8 panel 不存在(断链)
# → step8 模型输出真实控件为 'output_path'
# target panel_attr='models_dir_file' ✓ step9 panel 有 self.models_dir_file
'dependencies': { 'dependencies': {
'models_dir': ('step8_ml_train', 'Supervised_Model_Training', 'output_path'), # 目标框: self.models_dir_file ← 上游 step8_ml_train.output_path
'models_dir_file': ('step8_ml_train', 'Supervised_Model_Training', 'output_path'),
}, },
'constructor_kwargs': None, 'constructor_kwargs': None,
}, },
@ -205,13 +185,12 @@ PANEL_REGISTRY = [
'icon': '10.png', 'icon': '10.png',
'stage': '阶段四:预测与成果输出', 'stage': '阶段四:预测与成果输出',
'display_name': '10. 水色指数反演', 'display_name': '10. 水色指数反演',
# 对账修复2026-06-18 # 架构解耦2026-06-22dict 键名 = 下游目标控件真实属性名;
# - bsq_file: # 第三元素 source_attr = 上游源控件真实属性名
# 原 ('step3', 'deglint_image', 'bsq_file') # step10 panel 仅有 bsq_file / hdr_file / output_dir没有 water_mask widget
# source panel_attr='bsq_file' 在 step3 panel 不存在(断链) # 故删除 water_mask 依赖,避免悬挂回调
# → step3 输出deglint_image.bsq真实控件为 'output_file'
# target panel_attr='bsq_file' ✓ step10 panel 有 self.bsq_file
'dependencies': { 'dependencies': {
# 目标框: self.bsq_file ← 上游 step3.output_file
'bsq_file': ('step3', 'deglint_image', 'output_file'), 'bsq_file': ('step3', 'deglint_image', 'output_file'),
}, },
'constructor_kwargs': None, 'constructor_kwargs': None,
@ -223,21 +202,14 @@ PANEL_REGISTRY = [
'icon': '10.png', 'icon': '10.png',
'stage': '阶段四:预测与成果输出', 'stage': '阶段四:预测与成果输出',
'display_name': '11. 专题图生成', 'display_name': '11. 专题图生成',
# 对账修复2026-06-18 # 架构解耦2026-06-22dict 键名 = 下游目标控件真实属性名
# - prediction_csv_dir_edit:
# 原 ('step9_ml_predict', '9_ML_Prediction', 'prediction_csv_dir_edit')
# source panel_attr='prediction_csv_dir_edit' 在 step9 panel 不存在(断链)
# → step9 输出prediction.csv真实控件为 'output_file'
# target panel_attr='prediction_csv_dir_edit' ✓ step11 panel 有该 widget
# ⚠️ 语义注记target 是「目录」QLineEdit但 source 是「单文件」,下游需手动指定目录
# - geotiff_dir_edit:
# 原 ('step10_watercolor', 'WaterIndex_Images', 'geotiff_dir_edit')
# source panel_attr='geotiff_dir_edit' 在 step10 panel 不存在(断链)
# → step10 输出GeoTIFF 目录)真实控件为 'output_dir'
# target panel_attr='geotiff_dir_edit' ✓ step11 panel 有该 widget
'dependencies': { 'dependencies': {
# 目标框: self.prediction_csv_dir_edit ← 上游 step9_ml_predict.output_file
'prediction_csv_dir_edit': ('step9_ml_predict', '9_ML_Prediction', 'output_file'), 'prediction_csv_dir_edit': ('step9_ml_predict', '9_ML_Prediction', 'output_file'),
# 目标框: self.geotiff_dir_edit ← 上游 step10_watercolor.output_dir
'geotiff_dir_edit': ('step10_watercolor', 'WaterIndex_Images', 'output_dir'), 'geotiff_dir_edit': ('step10_watercolor', 'WaterIndex_Images', 'output_dir'),
# 目标框: self.boundary_file ← 上游 step1.mask_file
'boundary_file': ('step1', 'water_mask', 'mask_file'),
}, },
'constructor_kwargs': None, 'constructor_kwargs': None,
}, },

View File

@ -30,7 +30,7 @@ import traceback
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from PyQt5.QtCore import QObject, Qt from PyQt5.QtCore import QObject, Qt, QTimer
from PyQt5.QtWidgets import QMessageBox, QDialog from PyQt5.QtWidgets import QMessageBox, QDialog
from src.gui.core.event_bus import global_event_bus from src.gui.core.event_bus import global_event_bus
@ -58,6 +58,11 @@ class PipelineExecutor(QObject):
self._workspace_initializer = workspace_initializer self._workspace_initializer = workspace_initializer
self._worker: Optional[WorkerThread] = None self._worker: Optional[WorkerThread] = None
# ★ 防卡死看门狗:监控 WorkerThread 是否在合理时间内推进进度
self._watchdog_timer = QTimer()
self._watchdog_timer.timeout.connect(self._check_worker_health)
self._last_progress_time = 0.0
# 订阅面板发出的单步执行请求(解耦面板与执行器) # 订阅面板发出的单步执行请求(解耦面板与执行器)
global_event_bus.subscribe('RequestRunSingleStep', self._on_request_run_single_step) global_event_bus.subscribe('RequestRunSingleStep', self._on_request_run_single_step)
@ -303,6 +308,11 @@ class PipelineExecutor(QObject):
global_event_bus.publish('LogMessage', {'message': '开始执行完整流程...', 'level': 'info'}) global_event_bus.publish('LogMessage', {'message': '开始执行完整流程...', 'level': 'info'})
global_event_bus.publish('LogMessage', {'message': '=' * 50, 'level': 'info'}) global_event_bus.publish('LogMessage', {'message': '=' * 50, 'level': 'info'})
# ★ 启动看门狗worker.start() 之后立刻开始监控180s 超时)
import time
self._last_progress_time = time.time()
self._watchdog_timer.start(5000) # 每 5 秒巡检一次
self._worker.start() self._worker.start()
def run_single_step(self, step_name: str, config: dict = None): def run_single_step(self, step_name: str, config: dict = None):
@ -375,6 +385,11 @@ class PipelineExecutor(QObject):
}) })
global_event_bus.publish('LogMessage', {'message': '=' * 50, 'level': 'info'}) global_event_bus.publish('LogMessage', {'message': '=' * 50, 'level': 'info'})
# ★ 启动看门狗worker.start() 之后立刻开始监控180s 超时)
import time
self._last_progress_time = time.time()
self._watchdog_timer.start(5000) # 每 5 秒巡检一次
self._worker.start() self._worker.start()
def stop_pipeline(self): def stop_pipeline(self):
@ -460,7 +475,12 @@ class PipelineExecutor(QObject):
}) })
def _on_progress_update(self, percentage: int, message: str): def _on_progress_update(self, percentage: int, message: str):
"""WorkerThread 进度 → EventBus ProgressUpdate 事件。""" """WorkerThread 进度 → EventBus ProgressUpdate 事件。
同步重置看门狗计时器:每次收到进度更新就视为 Worker 还活着。
"""
import time
self._last_progress_time = time.time()
global_event_bus.publish('ProgressUpdate', { global_event_bus.publish('ProgressUpdate', {
'percentage': percentage, 'percentage': percentage,
'message': message, 'message': message,
@ -482,11 +502,40 @@ class PipelineExecutor(QObject):
主窗口订阅此事件,恢复按钮状态并弹窗。 主窗口订阅此事件,恢复按钮状态并弹窗。
""" """
# ★ 停止看门狗Worker 已正常收口,看门狗可以下班
self._watchdog_timer.stop()
global_event_bus.publish('PipelineFinished', { global_event_bus.publish('PipelineFinished', {
'success': success, 'success': success,
'message': message, 'message': message,
}) })
def _check_worker_health(self):
"""看门狗巡检:每 5 秒触发一次。
判定逻辑:
- Worker 已不在运行(自然结束/被强杀) → 停掉看门狗
- Worker 仍在运行 + 上次进度距今 > 180 秒 → 判定为假死/死锁,
强制 terminate() 并通过 _on_finished(False, ...) 汇流解 UI
让被卡死的「独立运行此步骤」按钮恢复可用。
"""
import time
if self._worker and self._worker.isRunning():
if time.time() - self._last_progress_time > 180:
self._log_message("[错误] 后台任务响应超时超过3分钟无响应强制终止...", "error")
self._worker.terminate()
self._on_finished(False, "后台任务响应超时或发生底层段错误,已强制终止。")
else:
# Worker 已退出(自然结束 / 已 terminate关闭看门狗
self._watchdog_timer.stop()
def _log_message(self, message: str, level: str = "info"):
"""看门狗专用的轻量日志通道(直接走 EventBus不绕过转发槽"""
global_event_bus.publish('LogMessage', {
'message': message,
'level': level,
})
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# 内部辅助 # 内部辅助
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════

View File

@ -14,8 +14,8 @@ import numpy as np
def _viz_training_spectra_csv_path(work_path: Path) -> Path: def _viz_training_spectra_csv_path(work_path: Path) -> Path:
"""可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤5输出一致)。""" """可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤6输出一致)。"""
return work_path / "5_training_spectra" / "training_spectra.csv" return work_path / "6_Spectral_Feature_Extraction" / "training_spectra.csv"
def _viz_infer_wavelength_start_column(df) -> Union[str, int]: def _viz_infer_wavelength_start_column(df) -> Union[str, int]:
@ -194,7 +194,7 @@ class VisualizationWorkerThread(QThread):
training_csv_path = (self.extra.get("training_csv_path") or "").strip() training_csv_path = (self.extra.get("training_csv_path") or "").strip()
models_dir = (self.extra.get("models_dir") or "").strip() models_dir = (self.extra.get("models_dir") or "").strip()
if not training_csv_path or not Path(training_csv_path).is_file(): if not training_csv_path or not Path(training_csv_path).is_file():
self.failed.emit("训练光谱 CSV 无效或不存在,请确认已选择步骤5输出的文件。") self.failed.emit("训练光谱 CSV 无效或不存在,请确认已选择步骤6输出的文件。")
return return
if not models_dir or not Path(models_dir).is_dir(): if not models_dir or not Path(models_dir).is_dir():
self.failed.emit("模型目录无效或不存在请确认步骤6已生成 7_Supervised_Model_Training 下的参数子文件夹。") self.failed.emit("模型目录无效或不存在请确认步骤6已生成 7_Supervised_Model_Training 下的参数子文件夹。")
@ -213,7 +213,7 @@ class VisualizationWorkerThread(QThread):
if training_csv_path: if training_csv_path:
training_csv = Path(training_csv_path) training_csv = Path(training_csv_path)
else: else:
training_csv = wp / "5_training_spectra" / "training_spectra.csv" training_csv = wp / "6_Spectral_Feature_Extraction" / "training_spectra.csv"
if self.extra.get("gen_scatter"): if self.extra.get("gen_scatter"):
if training_csv.is_file(): if training_csv.is_file():

View File

@ -241,13 +241,11 @@ class WorkspaceInitializer(QObject):
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
def _auto_fill_output_paths(self): def _auto_fill_output_paths(self):
"""根据工作目录自动填充 step1 的输出路径。 """【已禁用】禁止在工作目录刚选定后瞎拼凑假路径。
注意Step1 的输出路径由 update_work_directory() 根据模式自动控制 历史行为:曾经在此处调用 step1_panel.update_work_directory(self._work_dir) 自动回填输出路径
现已清空为 no-op原因刚选定目录时任何"自动推断"的子目录都是幽灵路径,
会污染后续面板的 input field让用户误以为已经填好了实际却指向不存在的目录。
真实填充时机交由面板自身的 update_from_config + 用户手动指定。
""" """
if not self._work_dir: pass
return
step1_panel = self._panel_factory.get_panel('step1')
if step1_panel:
step1_panel.update_work_directory(self._work_dir)

View File

@ -22,39 +22,29 @@ from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
# 用户口语编号 / 业务别名 → main_window 上真实属性名的映射 # 用户口语编号 / 业务别名 → PANEL_REGISTRY 中真实 step_id 的映射
# 这是"张冠李戴"修复的核心——之前代码写的 step11_panel 实际不存在 # 这是"张冠李戴"修复的核心——main_window 上已不再直接挂载 panel 属性
# 真实存在的属性见 water_quality_gui.py:1891-1928 # 所有面板都通过 _panel_factory.get_panel(step_id) 懒加载访问。
STEP_DATA_SOURCE = { STEP_DATA_SOURCE = {
# 数据流 step 编号(用户口语) → main_window 真实属性 # 数据流 step 编号(用户口语) → PANEL_REGISTRY 中的 step_id
'step5_clean_output': 'step5_clean_panel', 'step5_clean_output': 'step5_clean',
'step7_index_output': 'step7_index_panel', 'step7_index_output': 'step7_index',
'step8_ml_train_output': 'step8_ml_train_panel', 'step8_ml_train_output': 'step8_ml_train',
'step8_5_non_empirical': 'step8_non_empirical_panel', # 之前写错成 step11_panel 'step8_5_non_empirical': 'step8_ml_train',
'step9_ml_predict_output': 'step9_ml_predict_panel', 'step9_ml_predict_output': 'step9_ml_predict',
'step10_watercolor_output': 'step10_watercolor_panel', 'step10_watercolor_output': 'step10_watercolor',
'step11_ml_prediction': 'step9_ml_predict_panel', # 主流程 step11 = ML 预测 'step11_ml_prediction': 'step9_ml_predict', # 主流程 step11 = ML 预测
'step12_regression_prediction': 'step8_non_empirical_panel', # 主流程 step12 = 非经验预测 'step12_regression_prediction': 'step8_ml_train', # 主流程 step12 = 非经验预测
'step13_custom_regression': 'step13_report_panel', # 占位(自定义回归本身没有专属 panel 'step13_custom_regression': 'step13_report', # 自定义回归借用 step13 报告面板
'sampling_csv': 'step4_sampling_panel', 'sampling_csv': 'step4_sampling',
'training_spectra_csv': 'step5_clean_panel', 'training_spectra_csv': 'step5_clean',
'indices_csv': 'step7_index_panel', 'indices_csv': 'step7_index',
'models_dir': 'step8_ml_train_panel', 'models_dir': 'step8_ml_train',
'watercolor_dir': 'step10_watercolor_panel', 'watercolor_dir': 'step10_watercolor',
'prediction_csv_dir': 'step9_ml_predict_panel', # 默认从 ML 预测读 'prediction_csv_dir': 'step9_ml_predict', # 默认从 ML 预测读
} }
def _get_widget(main_window, attr_name: str, widget_attr: str = 'output_file'):
"""从 main_window.<attr_name> 取出指定子组件,失败时返回 None。"""
if main_window is None:
return None
panel = getattr(main_window, attr_name, None)
if panel is None:
return None
return getattr(panel, widget_attr, None)
def _read_widget_path(widget) -> str: def _read_widget_path(widget) -> str:
"""统一从 widget 读 path兼容 FileSelectWidget / QLineEdit / 字符串)。""" """统一从 widget 读 path兼容 FileSelectWidget / QLineEdit / 字符串)。"""
if widget is None: if widget is None:
@ -75,15 +65,30 @@ def _read_widget_path(widget) -> str:
def resolve_step_widget(main_window, step_key: str, widget_attr: str = 'output_file'): def resolve_step_widget(main_window, step_key: str, widget_attr: str = 'output_file'):
"""根据业务 step_key 解析出正确的 widget消除张冠李戴 """通过 panel_factory 访问对应面板,并获取其实际的输入/输出控件
解析顺序:
1. STEP_DATA_SOURCE[step_key] 找到真实 step_id
2. main_window._panel_factory.get_panel(step_id) 懒加载拿到面板
3. getattr(panel, widget_attr) 取出真实控件
Returns: Returns:
widget 对象 or None找不到时返回 None调用方需自行兜底 widget 对象 or None找不到时返回 None调用方需自行兜底
""" """
attr_name = STEP_DATA_SOURCE.get(step_key) step_id = STEP_DATA_SOURCE.get(step_key)
if attr_name is None: if not step_id:
return None return None
return _get_widget(main_window, attr_name, widget_attr)
# 从主窗口获取工厂,再由工厂通过 step_id 拿出真实面板
factory = getattr(main_window, '_panel_factory', None)
if not factory:
return None
panel = factory.get_panel(step_id)
if not panel:
return None
return getattr(panel, widget_attr, None)
_FALLBACK_DIR_TABLE = { _FALLBACK_DIR_TABLE = {

View File

@ -489,146 +489,43 @@ class Step11MapPanel(QWidget):
self.output_dir.set_path(str(p.parent)) self.output_dir.set_path(str(p.parent))
def update_from_config(self, work_dir=None, pipeline=None): def update_from_config(self, work_dir=None, pipeline=None):
"""从全局配置自动填充预测结果目录
优先使用 Step8机器学习预测的输出目录作为待预测 CSV 目录;
其次回退到 Step8.5(回归预测)或 Step8.75(自定义回归预测)的输出目录。
Args:
work_dir: 工作目录路径
pipeline: Pipeline 实例(未使用,保留接口兼容性)
"""
try:
import traceback
if work_dir: if work_dir:
self.work_dir = work_dir self.work_dir = work_dir
elif hasattr(self, 'work_dir') and self.work_dir:
pass
else:
self.work_dir = None
main_window = self.window() main_window = self.window()
if not main_window: factory = getattr(main_window, '_panel_factory', None) if main_window else None
return if not factory: return
# 1. 优先:从 Step9机器学习预测读输出目录9_ML_Prediction 子目录 # 1. 安全抓取 Step 9 的预测 CSV 目录
# 修复张冠李戴:原 main_window.step11_prediction_panel 不存在,真实属性是 step9_ml_predict_panel step9_panel = factory.get_panel('step9_ml_predict')
pred_dir = None if step9_panel and hasattr(step9_panel, 'output_file'):
step10_output = get_step_output_path( path = step9_panel.output_file.get_path()
main_window, 'step11_ml_prediction', work_dir=self.work_dir, if path:
widget_attr='output_file', fallback_key='step9_ml_predict', self.prediction_csv_dir_edit.setText(path)
)
if step10_output:
# 提取父目录后追加 9_ML_Prediction最底层真实子目录
base_pred_dir = str(Path(step10_output).parent)
ml_pred_dir = Path(base_pred_dir) / "9_ML_Prediction"
pred_dir = str(ml_pred_dir) if ml_pred_dir.exists() else base_pred_dir
# 2. 备选:从 Step8非经验预测读输出目录
# 修复张冠李戴:原 main_window.step11_panel 不存在,真实属性是 step8_non_empirical_panel
if not pred_dir:
step8_5_output = get_step_output_path(
main_window, 'step12_regression_prediction', work_dir=self.work_dir,
widget_attr='output_file', fallback_key='step8_ml_train',
)
if step8_5_output:
pred_dir = str(Path(step8_5_output).parent)
# 3. 备选:从 Step13 panel自定义回归读输出目录
# 修复张冠李戴:原 main_window.step12_panel 不存在;自定义回归 panel 是 step13_panel 类main_window 上无此名)
if not pred_dir:
step8_75_output = get_step_output_path(
main_window, 'step13_custom_regression', work_dir=self.work_dir,
widget_attr='output_dir_widget', fallback_key='custom_regression',
)
if step8_75_output:
pred_dir = step8_75_output
# 自动填入"预测CSV目录"(文件夹批量模式)
if pred_dir:
existing_dir = (self.prediction_csv_dir_edit.text() or "").strip()
if not existing_dir:
self.prediction_csv_dir_edit.setText(pred_dir)
# 切换到文件夹批量模式
self.mode_folder_rb.setChecked(True) self.mode_folder_rb.setChecked(True)
# 4. 自动填充输出目录14_visualization # 2. 安全抓取 Step 1 的真实掩膜文件(彻底拒绝瞎猜 roi.shp
if self.work_dir: step1_panel = factory.get_panel('step1')
output_dir = resolve_subdir(self.work_dir, 'visualization') if step1_panel:
os.makedirs(output_dir, exist_ok=True) use_ndwi = step1_panel.use_ndwi_radio.isChecked()
existing_out = self.output_dir.get_path() # 根据用户在第1步的选择拿真实的输出掩膜或导入的掩膜
if not existing_out or not existing_out.strip(): if use_ndwi and hasattr(step1_panel, 'output_file'):
self.output_dir.set_path(output_dir) 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 = ""
# 4.5. 自动探测 Step1 水体掩膜(修复张冠李戴:原仅找 roi.shp找不到时未尝试 1_water_mask existing = self.boundary_file.get_path()
# 优先调用 main_window.pipeline.get_step_output_dir('step1')(数据真实来源) if path and not existing:
# 兜底走 resolve_subdir('water_mask') → <work_dir>/1_water_mask self.boundary_file.set_path(path)
# Step1 典型产物water_mask_from_ndwi.dat、water_mask_from_shp.dat、xxx.shp
if self.work_dir:
water_mask_dir = None
pipeline = None
try:
_win = self.window()
if _win is not None:
pipeline = getattr(_win, 'pipeline', None)
except Exception:
pipeline = None
if pipeline is not None and hasattr(pipeline, 'get_step_output_dir'):
try:
water_mask_dir = pipeline.get_step_output_dir('step1')
except Exception as e:
print(f"⚠️ [step11_map_panel] pipeline.get_step_output_dir('step1') 失败: {e}")
water_mask_dir = None
if not water_mask_dir:
water_mask_dir = resolve_subdir(self.work_dir, 'water_mask')
existing_boundary = (self.boundary_file.get_path() or "").strip() # 3. 生成第 11 步的绝对输出目录 (杜绝保存到相对路径)
if not existing_boundary and water_mask_dir and os.path.isdir(water_mask_dir): if hasattr(self, 'work_dir') and self.work_dir:
# 优先 .shpgeopandas 读矢量最稳),其次 .dat import os
mask_candidates = ( out_dir = os.path.join(self.work_dir, "14_visualization").replace('\\', '/')
sorted(Path(water_mask_dir).glob("*.shp")) os.makedirs(out_dir, exist_ok=True)
+ sorted(Path(water_mask_dir).glob("*.dat")) self.output_dir.set_path(out_dir)
)
if mask_candidates:
self.boundary_file.set_path(str(mask_candidates[0]))
print(f"✅ [step11_map_panel] 自动从 Step1 掩膜目录填入: {mask_candidates[0]}")
# 5. 自动探测原始矢量边界文件(.shp作为专题图底图
# 优先回溯 input-test/roi.shpgeopandas.read_file 仅支持矢量格式
if self.work_dir:
possible_shp = None
candidates = [
Path(self.work_dir).parent / "input-test" / "roi.shp",
Path(self.work_dir) / "roi.shp",
Path(self.work_dir).parent / "roi.shp",
]
for candidate in candidates:
if candidate.exists() and candidate.suffix.lower() == ".shp":
possible_shp = candidate
break
existing_boundary = (self.boundary_file.get_path() or "").strip()
if not existing_boundary and possible_shp:
self.boundary_file.set_path(str(possible_shp))
elif not existing_boundary:
self.boundary_file.set_path("")
print("⚠️ 提示:专题图生成模块需传入标准矢量边界文件 (.shp),请手动选择。")
# 6. 自动探测 Step 8 输出的水色指数 GeoTIFFGeoTIFF 渲染模式)
step10_out_dir = Path(self.work_dir) / "10_WaterIndex_Images" if self.work_dir else None
if step10_out_dir and step10_out_dir.is_dir():
# GeoTIFF 批量模式:填充目录供批量渲染
if not (self.geotiff_dir_edit.text() or "").strip():
self.geotiff_dir_edit.setText(str(step10_out_dir))
# GeoTIFF 单文件模式:默认选中第一个
tif_files = sorted(step10_out_dir.glob("*.tif"))
if tif_files and not (self.geotiff_file.get_path() or "").strip():
self.geotiff_file.set_path(str(tif_files[0]))
except Exception as e:
import traceback
print(f"{self.__class__.__name__}】自动填充失败,跳过: {e}")
traceback.print_exc()
def browse_output_dir(self): def browse_output_dir(self):
"""浏览输出目录""" """浏览输出目录"""
@ -683,13 +580,10 @@ class Step11MapPanel(QWidget):
QMessageBox.warning(self, "输入验证失败", "边界文件不存在") QMessageBox.warning(self, "输入验证失败", "边界文件不存在")
return return
parent = self.parent() # 获取顶层主窗口(用于弹窗或直接调用)
while parent and not hasattr(parent, 'run_single_step'): main_win = self.window()
parent = parent.parent() # 修正:将后面代码中所有的 parent 替换为 main_win
parent = main_win
if not parent or not hasattr(parent, 'run_single_step'):
QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
return
if self.mode_folder_rb.isChecked(): if self.mode_folder_rb.isChecked():
# -------- CSV 插值批量 -------- # -------- CSV 插值批量 --------
@ -827,22 +721,23 @@ class Step11MapPanel(QWidget):
return return
config = self.get_config() config = self.get_config()
parent.run_single_step('step11_map', {'step11_map': config}) # V2 架构:通过事件总线发送执行请求,彻底解耦
from src.gui.core.event_bus import global_event_bus
global_event_bus.publish('RequestRunSingleStep', {
'step_name': 'step11_map',
'config': {'step11_map': config},
})
def _on_step10_batch_ok(self, n: int): def _on_step10_batch_ok(self, n: int):
self.progress_bar.setVisible(False) self.progress_bar.setVisible(False)
QMessageBox.information(self, "完成", f"已批量生成 {n} 个分布图。") QMessageBox.information(self, "完成", f"已批量生成 {n} 个分布图。")
parent = self.parent() main_win = self.window()
while parent and not hasattr(parent, "log_message"): if main_win and hasattr(main_win, "log_message"):
parent = parent.parent() main_win.log_message(f"专题图批量完成,共 {n} 个文件。", "info")
if parent and hasattr(parent, "log_message"):
parent.log_message(f"专题图批量完成,共 {n} 个文件。", "info")
def _on_step10_batch_fail(self, err: str): def _on_step10_batch_fail(self, err: str):
self.progress_bar.setVisible(False) self.progress_bar.setVisible(False)
QMessageBox.critical(self, "失败", f"批量生成中断:\n{err[:900]}") QMessageBox.critical(self, "失败", f"批量生成中断:\n{err[:900]}")
parent = self.parent() main_win = self.window()
while parent and not hasattr(parent, "log_message"): if main_win and hasattr(main_win, "log_message"):
parent = parent.parent() main_win.log_message(err, "error")
if parent and hasattr(parent, "log_message"):
parent.log_message(err, "error")

View File

@ -36,12 +36,12 @@ PIPELINE_AVAILABLE = True
def _viz_training_spectra_csv_path(work_path: Path) -> Path: def _viz_training_spectra_csv_path(work_path: Path) -> Path:
"""可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤5输出一致)。 """可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤6输出一致)。
注意步骤5.5水质指数计算执行后会覆盖此文件为94维增强版本 注意步骤5.5水质指数计算执行后会覆盖此文件为94维增强版本
因此下游步骤无需任何修改,直接读取此路径即可。 因此下游步骤无需任何修改,直接读取此路径即可。
""" """
return work_path / "5_training_spectra" / "training_spectra.csv" return work_path / "6_Spectral_Feature_Extraction" / "training_spectra.csv"
def _viz_infer_wavelength_start_column(df: pd.DataFrame) -> Union[str, int]: def _viz_infer_wavelength_start_column(df: pd.DataFrame) -> Union[str, int]:
@ -242,7 +242,7 @@ class VisualizationWorkerThread(QThread):
if training_csv_path: if training_csv_path:
training_csv = Path(training_csv_path) training_csv = Path(training_csv_path)
else: else:
training_csv = wp / "5_training_spectra" / "training_spectra.csv" training_csv = wp / "6_Spectral_Feature_Extraction" / "training_spectra.csv"
if self.extra.get("gen_scatter"): if self.extra.get("gen_scatter"):
if training_csv.is_file(): if training_csv.is_file():
@ -1697,9 +1697,9 @@ class Step12VizPanel(QWidget):
} }
main_window = self.window() main_window = self.window()
factory = getattr(main_window, '_panel_factory', None) if main_window else None factory = getattr(main_window, '_panel_factory', None) if main_window else None
step5_panel = factory.get_panel('step5_clean') if factory else None step6_panel = factory.get_panel('step6_feature') if factory else None
if step5_panel and getattr(step5_panel, 'output_file', None): if step6_panel and getattr(step6_panel, 'output_file', None):
_resolved_csv = step5_panel.output_file.get_path() _resolved_csv = step6_panel.output_file.get_path()
if _resolved_csv: if _resolved_csv:
extra["training_csv_path"] = _resolved_csv extra["training_csv_path"] = _resolved_csv
step8_panel = factory.get_panel('step8_ml_train') if factory else None step8_panel = factory.get_panel('step8_ml_train') if factory else None
@ -1721,17 +1721,17 @@ class Step12VizPanel(QWidget):
try: try:
main_window = self.window() main_window = self.window()
factory = getattr(main_window, '_panel_factory', None) if main_window else None factory = getattr(main_window, '_panel_factory', None) if main_window else None
step5_panel = factory.get_panel('step5_clean') if factory else None step6_panel = factory.get_panel('step6_feature') if factory else None
if step5_panel and getattr(step5_panel, 'output_file', None) and step5_panel.output_file.get_path(): if step6_panel and getattr(step6_panel, 'output_file', None) and step6_panel.output_file.get_path():
training_spectra_csv = Path(step5_panel.output_file.get_path()) training_spectra_csv = Path(step6_panel.output_file.get_path())
else: else:
training_spectra_csv = _viz_training_spectra_csv_path(work_path) training_spectra_csv = _viz_training_spectra_csv_path(work_path)
if chart_type == 'scatter': if chart_type == 'scatter':
if not training_spectra_csv.is_file(): if not training_spectra_csv.is_file():
QMessageBox.warning( QMessageBox.warning(
self, "警告", self, "警告",
"未找到 5_training_spectra\\training_spectra.csv。\n" "未找到 6_Spectral_Feature_Extraction\\training_spectra.csv。\n"
"请先执行步骤5(光谱特征提取)生成该文件。", "请先执行步骤6(光谱特征提取)生成该文件。",
) )
return return
training_csv = training_spectra_csv training_csv = training_spectra_csv
@ -1751,8 +1751,8 @@ class Step12VizPanel(QWidget):
if not training_spectra_csv.is_file(): if not training_spectra_csv.is_file():
QMessageBox.warning( QMessageBox.warning(
self, "警告", self, "警告",
"未找到 5_training_spectra\\training_spectra.csv。\n" "未找到 6_Spectral_Feature_Extraction\\training_spectra.csv。\n"
"光谱分析固定使用该文件,请先执行步骤5(光谱特征提取)。", "光谱分析固定使用该文件,请先执行步骤6(光谱特征提取)。",
) )
return return
csv_file = training_spectra_csv csv_file = training_spectra_csv
@ -1775,8 +1775,8 @@ class Step12VizPanel(QWidget):
if not training_spectra_csv.is_file(): if not training_spectra_csv.is_file():
QMessageBox.warning( QMessageBox.warning(
self, "警告", self, "警告",
"未找到 5_training_spectra\\training_spectra.csv。\n" "未找到 6_Spectral_Feature_Extraction\\training_spectra.csv。\n"
"统计分析固定使用该文件,请先执行步骤5(光谱特征提取)。", "统计分析固定使用该文件,请先执行步骤6(光谱特征提取)。",
) )
return return
csv_file = training_spectra_csv csv_file = training_spectra_csv

View File

@ -1,225 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step12 面板 - 自定义回归预测
"""
import os
import sys
from pathlib import Path
# 路径归一化 helper与 pipeline.get_step_output_dir 互为表里)
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import get_step_output_path, resolve_step_widget, resolve_subdir
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QGroupBox,
QPushButton, QCheckBox, QMessageBox, QFileDialog,
)
from src.gui.components.custom_widgets import FileSelectWidget
from src.gui.styles import ModernStylesheet
class Step12Panel(QWidget):
"""步骤12自定义回归预测"""
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
# 采样光谱CSV文件选择
self.sampling_csv_file = FileSelectWidget(
"采样光谱CSV:",
"CSV Files (*.csv);;All Files (*.*)"
)
layout.addWidget(self.sampling_csv_file)
# 自定义回归模型目录选择13_Custom_Regression
self.regression_models_dir = FileSelectWidget(
"回归模型目录:",
"Directories;;All Files (*.*)"
)
self.regression_models_dir.label.setText("回归模型目录:")
self.regression_models_dir.browse_btn.clicked.disconnect()
self.regression_models_dir.browse_btn.clicked.connect(self.browse_regression_models_dir)
self.regression_models_dir.set_path("") # 路径由 update_from_config 根据 work_dir 自动填充
layout.addWidget(self.regression_models_dir)
# 公式CSV文件选择用于查找index_formula
self.formula_csv_file = FileSelectWidget(
"公式CSV文件:",
"CSV Files (*.csv);;All Files (*.*)"
)
self.formula_csv_file.label.setText("公式CSV文件:")
layout.addWidget(self.formula_csv_file)
# 输出目录选择
self.output_dir_widget = FileSelectWidget(
"输出目录:",
"Directories;;All Files (*.*)"
)
self.output_dir_widget.label.setText("输出目录:")
self.output_dir_widget.browse_btn.clicked.disconnect()
self.output_dir_widget.browse_btn.clicked.connect(self.browse_output_dir)
self.output_dir_widget.line_edit.setPlaceholderText("留空使用默认prediction目录")
layout.addWidget(self.output_dir_widget)
# 启用步骤
self.enable_checkbox = QCheckBox("启用此步骤")
self.enable_checkbox.setChecked(True)
layout.addWidget(self.enable_checkbox)
# 独立运行按钮
self.run_button = QPushButton("独立运行此步骤")
self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
self.run_button.clicked.connect(self.run_step)
layout.addWidget(self.run_button)
layout.addStretch()
self.setLayout(layout)
def update_from_config(self, work_dir=None, pipeline=None):
"""从全局配置自动填充采样光谱和自定义回归模型目录
Args:
work_dir: 工作目录路径
pipeline: Pipeline 实例(未使用,保留接口兼容性)
"""
try:
import traceback
if work_dir:
self.work_dir = work_dir
elif hasattr(self, 'work_dir') and self.work_dir:
pass
else:
self.work_dir = None
main_window = self.window()
# 1. 尝试从 Step7水质光谱指数界面读取全湖采样点 CSV 路径
# 修复张冠李戴:原 main_window.step7_panel 不存在,真实属性是 step7_index_panel
step7_output_path = get_step_output_path(
main_window, 'sampling_csv', work_dir=self.work_dir,
widget_attr='output_file', fallback_key='step7_index',
)
if step7_output_path:
existing = self.sampling_csv_file.get_path()
if not existing or not existing.strip():
self.sampling_csv_file.set_path(step7_output_path)
# 2. 尝试从 Step8非经验回归/自定义回归源)读取模型目录
# 修复张冠李戴:原 main_window.step12_panel 不存在;按代码原意是 step9 的 output_dir
step9_models_dir = get_step_output_path(
main_window, 'models_dir', work_dir=self.work_dir,
widget_attr='output_dir', fallback_key='step8_ml_train',
)
if step9_models_dir:
existing_models = self.regression_models_dir.get_path()
if not existing_models or not existing_models.strip():
self.regression_models_dir.set_path(step9_models_dir)
# 3. 自动填充回归模型目录(如果 step9 未提供)
if self.work_dir:
models_dir = self.regression_models_dir.get_path().strip()
if not models_dir:
default_models_dir = resolve_subdir(self.work_dir, 'custom_regression')
self.regression_models_dir.set_path(default_models_dir)
# 4. 自动填充输出目录(自定义回归预测目录)
if self.work_dir:
output_dir = os.path.join(resolve_subdir(self.work_dir, 'custom_regression'), "Custom_Regression_Prediction")
os.makedirs(output_dir, exist_ok=True)
existing_out = self.output_dir_widget.get_path()
if not existing_out or not existing_out.strip():
self.output_dir_widget.set_path(output_dir)
except Exception as e:
import traceback
print(f"{self.__class__.__name__}】自动填充失败,跳过: {e}")
traceback.print_exc()
def _get_default_work_dir(self):
"""获取 work_dir优先用 panel 自身缓存的,否则尝试从主窗口取"""
if hasattr(self, 'work_dir') and self.work_dir:
return str(self.work_dir)
mw = self.window()
if mw and hasattr(mw, 'work_dir') and mw.work_dir:
return str(mw.work_dir)
return ""
def browse_regression_models_dir(self):
"""浏览回归模型目录"""
default = self._get_default_work_dir()
if default:
default = resolve_subdir(default, 'custom_regression')
dir_path = QFileDialog.getExistingDirectory(self, "选择回归模型目录", default)
if dir_path:
self.regression_models_dir.set_path(dir_path)
def browse_output_dir(self):
"""浏览输出目录"""
default = self._get_default_work_dir()
if default:
default = os.path.join(default, "13_Custom_Regression/Custom_Regression_Prediction")
dir_path = QFileDialog.getExistingDirectory(self, "选择输出目录", default)
if dir_path:
self.output_dir_widget.set_path(dir_path)
def get_config(self):
"""获取配置"""
config = {
'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
regression_models_dir = self.regression_models_dir.get_path()
if regression_models_dir:
config['custom_regression_dir'] = regression_models_dir
formula_csv_path = self.formula_csv_file.get_path()
if formula_csv_path:
config['formula_csv_path'] = formula_csv_path
output_dir = self.output_dir_widget.get_path()
if output_dir:
config['output_dir'] = output_dir
return config
def set_config(self, config):
"""设置配置"""
if 'sampling_csv_path' in config:
self.sampling_csv_file.set_path(config['sampling_csv_path'])
if 'custom_regression_dir' in config:
self.regression_models_dir.set_path(config['custom_regression_dir'])
if 'formula_csv_path' in config:
self.formula_csv_file.set_path(config['formula_csv_path'])
if 'output_dir' in config:
self.output_dir_widget.set_path(config['output_dir'])
if 'enabled' in config:
self.enable_checkbox.setChecked(config['enabled'])
def run_step(self):
"""独立运行步骤12"""
sampling_csv_path = self.sampling_csv_file.get_path()
if not sampling_csv_path:
QMessageBox.warning(self, "输入错误", "请选择采样光谱CSV文件")
return
regression_models_dir = self.regression_models_dir.get_path()
if not regression_models_dir:
QMessageBox.warning(self, "输入错误", "请选择回归模型目录!")
return
config = self.get_config()
parent = self.parent()
while parent and not hasattr(parent, 'run_single_step'):
parent = parent.parent()
if parent and hasattr(parent, 'run_single_step'):
parent.run_single_step('step13_report', {'step13_report': config})
else:
QMessageBox.critical(self, "错误", "无法找到父级GUI对象")

View File

@ -20,18 +20,23 @@ from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSettings
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QLabel, QCheckBox, QPushButton, QLineEdit, QLabel, QCheckBox, QPushButton, QLineEdit,
QMessageBox, QFileDialog, QMessageBox, QFileDialog, QProgressBar,
) )
from src.gui.styles import ModernStylesheet from src.gui.styles import ModernStylesheet
from src.gui.dialogs import AISettingsDialog, AI_SETTINGS_ORG, AI_SETTINGS_APP from src.gui.dialogs import AISettingsDialog, AI_SETTINGS_ORG, AI_SETTINGS_APP
class ReportGenerateThread(QThread): class ReportWorkerThread(QThread):
"""后台生成 Word 报告(避免阻塞 UI""" """后台生成 Word 报告(避免阻塞 UI
finished_ok = pyqtSignal(str) 三信号协议:
failed = pyqtSignal(str) - progress(int, str):报告进度百分比 + 当前文案
log_message = pyqtSignal(str, str) - finished(str):生成的报告绝对路径
- error(str):异常信息(含 traceback
"""
progress = pyqtSignal(int, str)
finished = pyqtSignal(str)
error = pyqtSignal(str)
def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, enable_ai: bool): def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, enable_ai: bool):
super().__init__() super().__init__()
@ -44,7 +49,6 @@ class ReportGenerateThread(QThread):
try: try:
from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig
# 唯一数据源:直接从 QSettings 读取 AI 配置
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = s.value("ai_provider", "minimax", type=str) provider = s.value("ai_provider", "minimax", type=str)
timeout = int(s.value("timeout_s", 120, type=int)) timeout = int(s.value("timeout_s", 120, type=int))
@ -68,10 +72,6 @@ class ReportGenerateThread(QThread):
enable_ai_analysis=self.enable_ai, enable_ai_analysis=self.enable_ai,
) )
self.log_message.emit(
f"报告生成:工作目录={self.work_dir}AI={'' if self.enable_ai else ''}Provider={provider}",
"info",
)
gen = WaterQualityReportGenerator( gen = WaterQualityReportGenerator(
work_dir=self.work_dir, work_dir=self.work_dir,
output_dir=self.output_dir, output_dir=self.output_dir,
@ -80,10 +80,11 @@ class ReportGenerateThread(QThread):
out_path = gen.generate_report( out_path = gen.generate_report(
work_dir=self.work_dir, work_dir=self.work_dir,
report_title=self.report_title or "水质参数反演分析报告", report_title=self.report_title or "水质参数反演分析报告",
on_progress=lambda pct, text: self.progress.emit(int(pct), str(text)),
) )
self.finished_ok.emit(str(out_path)) self.finished.emit(str(out_path))
except Exception as e: except Exception as e:
self.failed.emit(f"{e}\n{traceback.format_exc()}") self.error.emit(f"{e}\n{traceback.format_exc()}")
class Step13ReportPanel(QWidget): class Step13ReportPanel(QWidget):
@ -117,11 +118,14 @@ class Step13ReportPanel(QWidget):
wd_row = QHBoxLayout() wd_row = QHBoxLayout()
self.work_dir_edit = QLineEdit() self.work_dir_edit = QLineEdit()
self.work_dir_edit.setPlaceholderText("选择流程工作目录(含 14_visualization") self.work_dir_edit.setPlaceholderText("流程工作目录(含 14_visualization")
self.work_dir_edit.setReadOnly(True)
wd_browse = QPushButton("浏览…") wd_browse = QPushButton("浏览…")
wd_browse.clicked.connect(self.browse_work_dir) wd_browse.clicked.connect(self.browse_work_dir)
wd_browse.setVisible(False)
sync_btn = QPushButton("同步主窗口工作目录") sync_btn = QPushButton("同步主窗口工作目录")
sync_btn.clicked.connect(self.sync_work_dir_from_main) 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(self.work_dir_edit, 1)
wd_row.addWidget(wd_browse) wd_row.addWidget(wd_browse)
wd_row.addWidget(sync_btn) wd_row.addWidget(sync_btn)
@ -179,12 +183,27 @@ class Step13ReportPanel(QWidget):
btn_row.addStretch() btn_row.addStretch()
layout.addLayout(btn_row) layout.addLayout(btn_row)
# ── 进度条 + 状态文案 ────────────────────────────────────────────────
progress_row = QHBoxLayout()
self.progress_label = QLabel("就绪")
self.progress_label.setMinimumWidth(220)
progress_row.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)
layout.addStretch() layout.addStretch()
self.setLayout(layout) self.setLayout(layout)
# 刷新引擎提示文字 # 刷新引擎提示文字
self._refresh_ai_label() self._refresh_ai_label()
# 初次构建时尝试同步主窗口工作目录
self._auto_pull_work_dir()
def _refresh_ai_label(self): def _refresh_ai_label(self):
"""从 QSettings 读取当前 Provider 并更新只读标签。""" """从 QSettings 读取当前 Provider 并更新只读标签。"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP) s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
@ -229,6 +248,28 @@ class Step13ReportPanel(QWidget):
if work_dir: if work_dir:
self.work_dir_edit.setText(str(work_dir)) self.work_dir_edit.setText(str(work_dir))
def _auto_pull_work_dir(self):
"""从主窗口自动同步工作目录到 work_dir_edit无需用户操作"""
mw = self.main_window
if mw is not None and getattr(mw, "work_dir", None):
wd = str(mw.work_dir)
cur = self.work_dir_edit.text().strip()
if wd and wd != cur:
self.work_dir_edit.setText(wd)
def update_from_config(self, work_dir=None, pipeline=None):
"""切入面板时由主窗口统一调用,把当前 work_dir 同步到本面板。
解耦手动 browsework_dir_edit 已 ReadOnly外部只能通过此入口更新。
"""
if work_dir:
self.work_dir_edit.setText(str(work_dir))
self._auto_pull_work_dir()
def showEvent(self, event):
"""Tab 切换到本面板时再次兜底同步一次(应对 init_ui 时尚未绑定 main_window 的场景)。"""
super().showEvent(event)
self._auto_pull_work_dir()
def get_config(self): def get_config(self):
"""返回路径和标题配置AI 配置不由本面板持有)。""" """返回路径和标题配置AI 配置不由本面板持有)。"""
return { return {
@ -271,14 +312,15 @@ class Step13ReportPanel(QWidget):
title = self.report_title_edit.text().strip() or "水质参数反演分析报告" title = self.report_title_edit.text().strip() or "水质参数反演分析报告"
enable_ai = self.enable_ai_cb.isChecked() enable_ai = self.enable_ai_cb.isChecked()
# 重置进度条并禁用按钮
self.generate_btn.setEnabled(False) self.generate_btn.setEnabled(False)
self._report_thread = ReportGenerateThread(wd, out, title, enable_ai) self.progress_bar.setValue(0)
self._report_thread.log_message.connect(self._forward_log, Qt.QueuedConnection) self.progress_label.setText("正在准备生成…")
self._report_thread.finished_ok.connect(self._on_report_ok, Qt.QueuedConnection)
self._report_thread.failed.connect(self._on_report_fail, Qt.QueuedConnection) self._report_thread = ReportWorkerThread(wd, out, title, enable_ai)
self._report_thread.finished.connect( self._report_thread.progress.connect(self._on_progress, Qt.QueuedConnection)
lambda: self.generate_btn.setEnabled(True), Qt.QueuedConnection self._report_thread.finished.connect(self._on_finished, Qt.QueuedConnection)
) self._report_thread.error.connect(self._on_error, Qt.QueuedConnection)
self._report_thread.start() self._report_thread.start()
self._forward_log("已开始生成 Word 报告…", "info") self._forward_log("已开始生成 Word 报告…", "info")
@ -289,10 +331,22 @@ class Step13ReportPanel(QWidget):
else: else:
print(f"[{level}] {msg}") print(f"[{level}] {msg}")
def _on_report_ok(self, path: str): 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}%")
def _on_finished(self, path: str):
"""报告生成成功 —— 主线程槽。"""
self.progress_bar.setValue(100)
self.progress_label.setText("完成")
self.generate_btn.setEnabled(True)
QMessageBox.information(self, "完成", f"报告已生成:\n{path}") QMessageBox.information(self, "完成", f"报告已生成:\n{path}")
self._forward_log(f"Word 报告已保存: {path}", "info") self._forward_log(f"Word 报告已保存: {path}", "info")
def _on_report_fail(self, err: str): def _on_error(self, err: str):
"""报告生成异常 —— 主线程槽。"""
self.progress_label.setText("失败")
self.generate_btn.setEnabled(True)
QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}") QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}")
self._forward_log(err, "error") self._forward_log(err, "error")

View File

@ -1,814 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step14 面板 - 分布图生成
"""
import os
import sys
import traceback
from pathlib import Path
from typing import List, Optional
# 路径归一化 helper与 pipeline.get_step_output_dir 互为表里)
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import get_step_output_path, resolve_step_widget
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QGroupBox, QFormLayout, QHBoxLayout,
QLabel, QCheckBox, QPushButton, QLineEdit, QDoubleSpinBox,
QRadioButton, QButtonGroup, QMessageBox, QFileDialog, QComboBox,
QProgressBar,
)
from src.gui.components.custom_widgets import FileSelectWidget
from src.gui.styles import ModernStylesheet
PIPELINE_AVAILABLE = True
class Step14BatchThread(QThread):
"""专题图:按文件夹内多个预测 CSV 批量生成分布图。"""
finished_ok = pyqtSignal(int)
failed = pyqtSignal(str)
log_message = pyqtSignal(str, str)
progress = pyqtSignal(int, int) # (current, total)
def __init__(self, work_dir: str, csv_paths: List[str], step14_kwargs: dict, output_dir_optional: Optional[str]):
super().__init__()
self.work_dir = work_dir
self.csv_paths = csv_paths
self.step14_kwargs = step14_kwargs
self.output_dir_optional = (output_dir_optional or "").strip() or None
def run(self):
mpl_prev = None
try:
import matplotlib
mpl_prev = matplotlib.get_backend()
except Exception:
pass
try:
import matplotlib.pyplot as plt
plt.switch_backend("Agg")
except Exception:
mpl_prev = None
try:
from src.core.steps.mapping_step import MappingStep
n = len(self.csv_paths)
for i, csv_p in enumerate(self.csv_paths):
self.progress.emit(i + 1, n)
self.log_message.emit(f"专题图 [{i + 1}/{n}] {csv_p}", "info")
kw = {**self.step14_kwargs, "prediction_csv_path": csv_p}
kw.pop("skip_dependency_check", None)
if self.output_dir_optional:
stem = Path(csv_p).stem
kw["output_image_path"] = str(Path(self.output_dir_optional) / f"{stem}_distribution.png")
else:
kw["output_image_path"] = None
MappingStep.generate_distribution_map(**kw)
self.finished_ok.emit(n)
except Exception as e:
self.failed.emit(f"{e}\n{traceback.format_exc()}")
finally:
if mpl_prev:
try:
import matplotlib.pyplot as plt
plt.switch_backend(mpl_prev)
except Exception:
pass
class Step14GeoTIFFBatchThread(QThread):
"""GeoTIFF 批量渲染:遍历文件夹下所有 .tif/.bsq 逐一渲染成分布图 PNG。"""
finished_ok = pyqtSignal(int)
failed = pyqtSignal(str)
log_message = pyqtSignal(str, str)
progress = pyqtSignal(int, int) # (current, total)
def __init__(
self,
tif_paths: List[str],
output_dir: str,
boundary_shp_path: Optional[str],
input_crs: str,
output_crs: str,
):
super().__init__()
self.tif_paths = tif_paths
self.output_dir = output_dir
self.boundary_shp_path = boundary_shp_path
self.input_crs = input_crs
self.output_crs = output_crs
def run(self):
mpl_prev = None
try:
import matplotlib
mpl_prev = matplotlib.get_backend()
except Exception:
pass
try:
import matplotlib.pyplot as plt
plt.switch_backend("Agg")
except Exception:
mpl_prev = None
try:
from src.postprocessing.map import ContentMapper
mapper = ContentMapper()
n = len(self.tif_paths)
for i, tif_path in enumerate(self.tif_paths):
self.progress.emit(i + 1, n)
tif_stem = Path(tif_path).stem
chinese_name = mapper._get_chinese_title(tif_stem)
output_png = str(Path(self.output_dir) / f"{chinese_name}_专题图.png")
self.log_message.emit(f"GeoTIFF 渲染 [{i + 1}/{n}] {tif_stem}", "info")
try:
mapper.visualize_raster(
raster_tif_path=tif_path,
output_file=output_png,
boundary_shp_path=self.boundary_shp_path,
nodata_value=-9999.0,
figsize=(14, 10),
alpha=0.9,
)
except Exception as vis_err:
self.log_message.emit(f" ⚠️ 渲染失败,跳过: {vis_err}", "warning")
continue
self.finished_ok.emit(n)
except Exception as e:
self.failed.emit(f"{e}\n{traceback.format_exc()}")
finally:
if mpl_prev:
try:
import matplotlib.pyplot as plt
plt.switch_backend(mpl_prev)
except Exception:
pass
class Step14Panel(QWidget):
"""步骤14分布图生成"""
def __init__(self, parent=None):
super().__init__(parent)
self._batch_thread = None
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
hint = QLabel(
"独立运行:可选「单个 CSV」或「文件夹批量」扫描目录下所有 .csv"
"GeoTIFF 栅格模式下亦支持批量渲染步骤8输出的所有水色指数 GeoTIFF 文件。"
)
hint.setWordWrap(True)
hint.setStyleSheet(
f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};"
)
layout.addWidget(hint)
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)
# ---------- 渲染模式选择器CSV vs GeoTIFF ----------
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)
self.render_mode_combo.currentTextChanged.connect(self._toggle_input_mode)
render_row.addWidget(self.render_mode_combo)
render_row.addStretch()
layout.addLayout(render_row)
# ---------- 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;
}
"""
self.mode_single_rb.setStyleSheet(radio_style)
self.mode_folder_rb.setStyleSheet(radio_style)
self.prediction_csv_file = FileSelectWidget(
"预测结果CSV:",
"CSV Files (*.csv);;All Files (*.*)"
)
layout.addWidget(self.prediction_csv_file)
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 = QWidget()
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("选择步骤8输出的水色指数 GeoTIFF 文件…")
self.geotiff_file.setVisible(False)
layout.addWidget(self.geotiff_file)
# ---------- GeoTIFF 文件夹批量选择器GeoTIFF + 文件夹模式时显示) ----------
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 = QWidget()
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")
self.output_dir.browse_btn.clicked.disconnect()
self.output_dir.browse_btn.clicked.connect(self.browse_output_dir)
layout.addWidget(self.output_dir)
# 启用步骤
self.enable_checkbox = QCheckBox("启用此步骤")
self.enable_checkbox.setChecked(True)
layout.addWidget(self.enable_checkbox)
# 独立运行按钮
self.run_button = QPushButton("独立运行此步骤")
self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
self.run_button.clicked.connect(self.run_step)
layout.addWidget(self.run_button)
# 批量渲染进度条
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.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) # 默认选中"单个 CSV"
self._toggle_input_mode() # 根据默认值设置初始显示状态
def _toggle_input_mode(self):
"""槽函数:根据渲染模式和输入模式动态显示/隐藏对应的输入组件。"""
geotiff_mode = self.render_mode_combo.currentText() == "GeoTIFF 栅格模式"
folder_mode = self.mode_folder_rb.isChecked()
# CSV 插值模式
if not geotiff_mode:
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)
# GeoTIFF 栅格模式
else:
self.prediction_csv_file.setVisible(False)
self._folder_row_widget.setVisible(False)
self.recursive_csv_cb.setVisible(False)
# GeoTIFF + 文件夹批量 → 显示文件夹选择器;否则 → 显示单文件选择器
self.geotiff_file.setVisible(not folder_mode)
self._geotiff_dir_widget.setVisible(folder_mode)
def _get_default_work_dir(self):
"""获取 work_dir优先用 panel 自身缓存的,否则尝试从主窗口取"""
if hasattr(self, 'work_dir') and self.work_dir:
return str(self.work_dir)
mw = self.window()
if mw and hasattr(mw, 'work_dir') and mw.work_dir:
return str(mw.work_dir)
return ""
def browse_prediction_csv_dir(self):
default = self._get_default_work_dir()
if default:
default = resolve_subdir(default, 'prediction_dir')
d = QFileDialog.getExistingDirectory(self, "选择预测结果 CSV 所在文件夹", default)
if d:
self.prediction_csv_dir_edit.setText(d)
def _collect_csv_paths_from_folder(self) -> List[str]:
folder = (self.prediction_csv_dir_edit.text() or "").strip()
if not folder or not os.path.isdir(folder):
return []
root = Path(folder)
if self.recursive_csv_cb.isChecked():
files = sorted(root.rglob("*.csv"))
else:
files = sorted(root.glob("*.csv"))
return [str(p) for p in files if p.is_file()]
def browse_geotiff_dir(self):
"""浏览 GeoTIFF 文件夹(批量模式)"""
default = self._get_default_work_dir()
if default:
default = resolve_subdir(default, 'watercolor')
d = QFileDialog.getExistingDirectory(
self, "选择水色指数 GeoTIFF 文件夹", default
)
if d:
self.geotiff_dir_edit.setText(d)
def _collect_tif_paths_from_folder(self) -> List[str]:
"""扫描所选文件夹,收集所有 .tif 和 .bsq 文件路径"""
folder = (self.geotiff_dir_edit.text() or "").strip()
if not folder or not os.path.isdir(folder):
return []
root = Path(folder)
tif_files = sorted(root.glob("*.tif"))
bsq_files = sorted(root.glob("*.bsq"))
return [str(p) for p in tif_files + bsq_files if p.is_file()]
def _step14_base_pipeline_kwargs(self) -> dict:
return {
'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(),
}
def get_config(self):
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 = {
'step14_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(),
}
out_dir = (self.output_dir.get_path() or "").strip()
if not folder_mode and pred_csv and out_dir:
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):
mode = config.get('step14_batch_mode', 'single')
if mode == 'folder':
self.mode_folder_rb.setChecked(True)
else:
self.mode_single_rb.setChecked(True)
render_mode = config.get('render_mode', 'CSV 插值模式')
idx = self.render_mode_combo.findText(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 'prediction_csv_path' in config and config['prediction_csv_path']:
self.prediction_csv_file.set_path(str(config['prediction_csv_path']))
if 'geotiff_path' in config and config['geotiff_path']:
self.geotiff_file.set_path(str(config['geotiff_path']))
if 'geotiff_dir' in config and config['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 'output_dir' in config and config['output_dir']:
self.output_dir.set_path(str(config['output_dir']))
elif config.get('output_image_path'):
p = Path(str(config['output_image_path']))
if p.parent and str(p.parent) != '.':
self.output_dir.set_path(str(p.parent))
def update_from_config(self, work_dir=None, pipeline=None):
"""从全局配置自动填充预测结果目录
优先使用 Step8机器学习预测的输出目录作为待预测 CSV 目录;
其次回退到 Step8.5(回归预测)或 Step8.75(自定义回归预测)的输出目录。
Args:
work_dir: 工作目录路径
pipeline: Pipeline 实例(未使用,保留接口兼容性)
"""
try:
import traceback
if work_dir:
self.work_dir = work_dir
elif hasattr(self, 'work_dir') and self.work_dir:
pass
else:
self.work_dir = None
main_window = self.window()
if not main_window:
return
# 1. 优先:从 Step9机器学习预测读输出目录9_ML_Prediction 子目录
# 修复张冠李戴:原 main_window.step11_prediction_panel 不存在
pred_dir = None
step10_output = get_step_output_path(
main_window, 'step11_ml_prediction', work_dir=self.work_dir,
widget_attr='output_file', fallback_key='step9_ml_predict',
)
if step10_output:
base_pred_dir = str(Path(step10_output).parent)
ml_pred_dir = Path(base_pred_dir) / "9_ML_Prediction"
pred_dir = str(ml_pred_dir) if ml_pred_dir.exists() else base_pred_dir
# 2. 备选:从 Step8非经验预测读输出目录
# 修复张冠李戴:原 main_window.step11_panel 不存在
if not pred_dir:
step8_5_output = get_step_output_path(
main_window, 'step12_regression_prediction', work_dir=self.work_dir,
widget_attr='output_file', fallback_key='step8_ml_train',
)
if step8_5_output:
pred_dir = str(Path(step8_5_output).parent)
# 3. 备选:从 Step13 panel自定义回归读输出目录
# 修复张冠李戴:原 main_window.step12_panel 不存在
if not pred_dir:
step8_75_output = get_step_output_path(
main_window, 'step13_custom_regression', work_dir=self.work_dir,
widget_attr='output_dir_widget', fallback_key='custom_regression',
)
if step8_75_output:
pred_dir = step8_75_output
# 自动填入"预测CSV目录"(文件夹批量模式)
if pred_dir:
existing_dir = (self.prediction_csv_dir_edit.text() or "").strip()
if not existing_dir:
self.prediction_csv_dir_edit.setText(pred_dir)
# 切换到文件夹批量模式
self.mode_folder_rb.setChecked(True)
# 4. 自动填充输出目录14_visualization
if self.work_dir:
output_dir = resolve_subdir(self.work_dir, 'visualization')
os.makedirs(output_dir, exist_ok=True)
existing_out = self.output_dir.get_path()
if not existing_out or not existing_out.strip():
self.output_dir.set_path(output_dir)
# 5. 自动探测原始矢量边界文件(.shp作为专题图底图
# 优先回溯 input-test/roi.shpgeopandas.read_file 仅支持矢量格式
if self.work_dir:
possible_shp = None
candidates = [
Path(self.work_dir).parent / "input-test" / "roi.shp",
Path(self.work_dir) / "roi.shp",
Path(self.work_dir).parent / "roi.shp",
]
for candidate in candidates:
if candidate.exists() and candidate.suffix.lower() == ".shp":
possible_shp = candidate
break
existing_boundary = (self.boundary_file.get_path() or "").strip()
if not existing_boundary and possible_shp:
self.boundary_file.set_path(str(possible_shp))
elif not existing_boundary:
self.boundary_file.set_path("")
print("⚠️ 提示:专题图生成模块需传入标准矢量边界文件 (.shp),请手动选择。")
# 6. 自动探测 Step 8 输出的水色指数 GeoTIFFGeoTIFF 渲染模式)
step10_out_dir = Path(self.work_dir) / "10_WaterIndex_Images" if self.work_dir else None
if step10_out_dir and step10_out_dir.is_dir():
# GeoTIFF 批量模式:填充目录供批量渲染
if not (self.geotiff_dir_edit.text() or "").strip():
self.geotiff_dir_edit.setText(str(step10_out_dir))
# GeoTIFF 单文件模式:默认选中第一个
tif_files = sorted(step10_out_dir.glob("*.tif"))
if tif_files and not (self.geotiff_file.get_path() or "").strip():
self.geotiff_file.set_path(str(tif_files[0]))
except Exception as e:
import traceback
print(f"{self.__class__.__name__}】自动填充失败,跳过: {e}")
traceback.print_exc()
def browse_output_dir(self):
"""浏览输出目录"""
default = self._get_default_work_dir()
if default:
default = resolve_subdir(default, 'visualization')
dir_path = QFileDialog.getExistingDirectory(self, "选择输出分布图目录", default)
if dir_path:
self.output_dir.set_path(dir_path)
def _start_batch_run(self, csv_list, work_dir, base_kw, out_dir_opt, parent):
"""封装 CSV 批量启动逻辑,统一处理信号连接和进度条"""
self.run_button.setEnabled(False)
self.progress_bar.setVisible(True)
self.progress_bar.setValue(0)
self._batch_thread = Step14BatchThread(work_dir, csv_list, base_kw, out_dir_opt)
main_win = parent
def _batch_log(msg, lvl):
if hasattr(main_win, "log_message"):
main_win.log_message(msg, lvl)
def _on_progress(cur, total):
if total > 0:
self.progress_bar.setMaximum(total)
self.progress_bar.setValue(cur)
self.progress_bar.setFormat(f"{cur}/{total} 张 (%p%)")
self._batch_thread.log_message.connect(_batch_log, Qt.QueuedConnection)
self._batch_thread.progress.connect(_on_progress, Qt.QueuedConnection)
self._batch_thread.finished_ok.connect(self._on_step14_batch_ok, Qt.QueuedConnection)
self._batch_thread.failed.connect(self._on_step14_batch_fail, Qt.QueuedConnection)
self._batch_thread.finished.connect(
lambda: (self.run_button.setEnabled(True), self.progress_bar.setVisible(False)),
Qt.QueuedConnection,
)
self._batch_thread.start()
if hasattr(parent, "log_message"):
parent.log_message(f"专题图批量:共 {len(csv_list)} 个 CSV工作目录 {work_dir}", "info")
def run_step(self):
"""独立运行步骤14"""
if self._batch_thread and self._batch_thread.isRunning():
QMessageBox.information(self, "提示", "批量任务正在运行,请稍候。")
return
boundary_shp_path = self.boundary_file.get_path()
if not boundary_shp_path:
QMessageBox.warning(self, "输入验证失败", "请选择边界文件")
return
if not os.path.exists(boundary_shp_path):
QMessageBox.warning(self, "输入验证失败", "边界文件不存在")
return
parent = self.parent()
while parent and not hasattr(parent, 'run_single_step'):
parent = parent.parent()
if not parent or not hasattr(parent, 'run_single_step'):
QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
return
if self.mode_folder_rb.isChecked():
# -------- CSV 插值批量 --------
if self.render_mode_combo.currentText() != "GeoTIFF 栅格模式":
csv_list = self._collect_csv_paths_from_folder()
if not csv_list:
QMessageBox.warning(
self,
"输入验证失败",
"所选文件夹中未找到 .csv 文件,或目录无效。\n"
"可勾选「包含子文件夹」以递归扫描。",
)
return
if not PIPELINE_AVAILABLE:
QMessageBox.critical(self, "错误", "Pipeline 模块不可用,无法批量生成专题图。")
return
work_dir = getattr(parent, "work_dir", None) or "./work_dir"
work_dir = str(work_dir)
base_kw = self._step14_base_pipeline_kwargs()
out_dir_opt = (self.output_dir.get_path() or "").strip() or None
self._start_batch_run(csv_list, work_dir, base_kw, out_dir_opt, parent)
return
# -------- GeoTIFF 栅格批量 --------
tif_list = self._collect_tif_paths_from_folder()
if not tif_list:
QMessageBox.warning(
self,
"输入验证失败",
"所选文件夹中未找到 .tif / .bsq 文件,\n"
"请确认目录包含步骤8输出的水色指数 GeoTIFF 文件。",
)
return
out_dir = (self.output_dir.get_path() or "").strip()
if not out_dir:
out_dir = resolve_subdir(self._get_default_work_dir(), 'visualization')
os.makedirs(out_dir, exist_ok=True)
self.run_button.setEnabled(False)
self.progress_bar.setVisible(True)
self.progress_bar.setValue(0)
self._batch_thread = Step14GeoTIFFBatchThread(
tif_paths=tif_list,
output_dir=out_dir,
boundary_shp_path=boundary_shp_path,
input_crs=self.input_crs.text(),
output_crs=self.output_crs.text(),
)
main_win = parent
def _batch_log(msg, lvl):
if hasattr(main_win, "log_message"):
main_win.log_message(msg, lvl)
def _on_progress(cur, total):
if total > 0:
pct = int(cur / total * 100)
self.progress_bar.setMaximum(total)
self.progress_bar.setValue(cur)
self.progress_bar.setFormat(f"{cur}/{total} 张 (%p%)")
self._batch_thread.log_message.connect(_batch_log, Qt.QueuedConnection)
self._batch_thread.progress.connect(_on_progress, Qt.QueuedConnection)
self._batch_thread.finished_ok.connect(self._on_step14_batch_ok, Qt.QueuedConnection)
self._batch_thread.failed.connect(self._on_step14_batch_fail, Qt.QueuedConnection)
self._batch_thread.finished.connect(
lambda: (self.run_button.setEnabled(True), self.progress_bar.setVisible(False)),
Qt.QueuedConnection,
)
self._batch_thread.start()
if hasattr(parent, "log_message"):
parent.log_message(f"GeoTIFF 批量渲染:共 {len(tif_list)} 个文件 → {out_dir}", "info")
return
# -------- GeoTIFF 栅格单文件模式 --------
if self.render_mode_combo.currentText() == "GeoTIFF 栅格模式":
geotiff_path = (self.geotiff_file.get_path() or "").strip()
if not geotiff_path:
QMessageBox.warning(self, "输入验证失败", "请选择水色指数 GeoTIFF 文件")
return
if not os.path.isfile(geotiff_path):
QMessageBox.warning(self, "输入验证失败", f"GeoTIFF 文件不存在:\n{geotiff_path}")
return
boundary_shp_path = self.boundary_file.get_path()
input_crs = self.input_crs.text()
output_crs = self.output_crs.text()
# 构造输出路径
out_dir = (self.output_dir.get_path() or "").strip()
if not out_dir:
out_dir = resolve_subdir(self._get_default_work_dir(), 'visualization')
os.makedirs(out_dir, exist_ok=True)
tif_stem = Path(geotiff_path).stem
chinese_name = mapper._get_chinese_title(tif_stem)
output_png = os.path.join(out_dir, f"{chinese_name}_专题图.png")
self.run_button.setEnabled(False)
try:
from src.postprocessing.map import ContentMapper
mapper = ContentMapper()
result_path = mapper.visualize_raster(
raster_tif_path=geotiff_path,
output_file=output_png,
boundary_shp_path=boundary_shp_path if boundary_shp_path else None,
nodata_value=-9999.0,
figsize=(14, 10),
alpha=0.9,
)
self.run_button.setEnabled(True)
QMessageBox.information(
self, "完成",
f"GeoTIFF 栅格渲染完成!\n{result_path}"
)
if hasattr(parent, "log_message"):
parent.log_message(f"Step14 GeoTIFF 渲染完成 → {result_path}", "info")
except Exception as e:
self.run_button.setEnabled(True)
QMessageBox.critical(self, "渲染失败", f"{e}\n{traceback.format_exc()[:500]}")
if hasattr(parent, "log_message"):
parent.log_message(str(e), "error")
return
prediction_csv_path = (self.prediction_csv_file.get_path() or "").strip()
if not prediction_csv_path:
QMessageBox.warning(
self,
"输入验证失败",
"请选择「预测结果 CSV」文件或切换到「文件夹批量」。",
)
return
if not os.path.isfile(prediction_csv_path):
QMessageBox.warning(self, "输入验证失败", "预测结果 CSV 不存在或不是文件")
return
config = self.get_config()
parent.run_single_step('step14', {'step14': config})
def _on_step14_batch_ok(self, n: int):
self.progress_bar.setVisible(False)
QMessageBox.information(self, "完成", f"已批量生成 {n} 个分布图。")
parent = self.parent()
while parent and not hasattr(parent, "log_message"):
parent = parent.parent()
if parent and hasattr(parent, "log_message"):
parent.log_message(f"专题图批量完成,共 {n} 个文件。", "info")
def _on_step14_batch_fail(self, err: str):
self.progress_bar.setVisible(False)
QMessageBox.critical(self, "失败", f"批量生成中断:\n{err[:900]}")
parent = self.parent()
while parent and not hasattr(parent, "log_message"):
parent = parent.parent()
if parent and hasattr(parent, "log_message"):
parent.log_message(err, "error")

View File

@ -1,326 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step7 面板 - 水质指数计算
"""
import os
import sys
import pandas as pd
from pathlib import Path
from typing import Dict, List, Optional
# 路径归一化 helper与 pipeline.get_step_output_dir 互为表里)
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import resolve_subdir
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QGroupBox, QGridLayout,
QHBoxLayout, QLabel, QCheckBox, QPushButton, QMessageBox,
QScrollArea, QListWidget, QListWidgetItem, QAbstractItemView,
QRadioButton, QButtonGroup
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QColor, QBrush, QFont
from src.gui.components.custom_widgets import FileSelectWidget
from src.gui.styles import ModernStylesheet
def get_resource_path(relative_path: str) -> str:
"""适配开发与 PyInstaller 环境的路径获取逻辑。"""
if hasattr(sys, '_MEIPASS'):
internal = os.path.join(sys._MEIPASS, '_internal', relative_path)
if os.path.exists(internal):
return internal
return os.path.join(sys._MEIPASS, relative_path)
exe_dir = os.path.dirname(sys.executable)
internal = os.path.join(exe_dir, '_internal', relative_path)
if os.path.exists(internal):
return internal
base_dir = Path(__file__).resolve().parent.parent / "model"
return str(base_dir / os.path.basename(relative_path))
class Step7IndexPanel(QWidget):
COLOR_RATIO = QColor(255, 255, 255)
COLOR_CONCENTRATION = QColor(220, 240, 255)
COLOR_HEADER = QColor(245, 245, 245)
def __init__(self, parent=None):
super().__init__(parent)
self.index_checkboxes: Dict[str, QListWidgetItem] = {}
self.work_dir: Optional[str] = None
self.builtin_formula_path = get_resource_path("waterindex.csv")
self._formula_type_map: Dict[str, str] = {}
self._formula_color_map: Dict[str, QColor] = {}
self._formula_coef_map: Dict[str, List[float]] = {}
self.init_ui()
self._auto_load_formulas()
def init_ui(self):
main_layout = QVBoxLayout()
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(10)
# 1. 公式配置源 (只读)
path_group = QGroupBox("公式配置源 (内置)")
path_layout = QVBoxLayout()
self.formula_csv_widget = FileSelectWidget("内置CSV路径:", "CSV Files (*.csv)")
self.formula_csv_widget.set_path(self.builtin_formula_path)
self.formula_csv_widget.set_read_only(True)
self.formula_csv_widget.line_edit.setStyleSheet("background-color: #f0f0f0; color: #666;")
path_layout.addWidget(self.formula_csv_widget)
path_group.setLayout(path_layout)
main_layout.addWidget(path_group)
# 2. 训练数据输入
input_group = QGroupBox("输入样本数据")
input_layout = QVBoxLayout()
self.training_data_widget = FileSelectWidget("特征提取CSV:", "CSV Files (*.csv)")
input_layout.addWidget(self.training_data_widget)
input_group.setLayout(input_layout)
main_layout.addWidget(input_group)
# 3. 公式选择区 (分组 ListWidget)
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.select_all_btn.clicked.connect(self.select_all_formulas)
self.deselect_all_btn.clicked.connect(self.deselect_all_formulas)
self.select_ratio_btn.clicked.connect(self._select_ratio_only)
self.select_conc_btn.clicked.connect(self._select_conc_only)
btn_layout.addWidget(self.select_all_btn)
btn_layout.addWidget(self.deselect_all_btn)
btn_layout.addWidget(self.select_ratio_btn)
btn_layout.addWidget(self.select_conc_btn)
btn_layout.addStretch()
self.refresh_button = QPushButton("重新加载")
self.refresh_button.clicked.connect(lambda: self.refresh_formulas(silent=False))
btn_layout.addWidget(self.refresh_button)
formula_outer_layout.addLayout(btn_layout)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setMinimumHeight(280)
self.scroll_content = QWidget()
self.formula_layout = QVBoxLayout(self.scroll_content)
self.formula_layout.setContentsMargins(4, 4, 4, 4)
self.formula_layout.setSpacing(2)
self.formula_layout.setAlignment(Qt.AlignTop)
self.formula_list = QListWidget()
self.formula_list.setSelectionMode(QAbstractItemView.MultiSelection)
self.formula_list.setMinimumHeight(300)
self.formula_list.itemChanged.connect(self._on_item_changed)
self.formula_layout.addWidget(self.formula_list)
scroll.setWidget(self.scroll_content)
formula_outer_layout.addWidget(scroll)
self.formula_group.setLayout(formula_outer_layout)
main_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)
main_layout.addWidget(output_group)
# 5. 运行按钮
self.run_button = QPushButton("立即执行计算")
self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
self.run_button.setMinimumHeight(40)
self.run_button.clicked.connect(self.run_step)
main_layout.addWidget(self.run_button)
self.setLayout(main_layout)
def _on_item_changed(self, item: QListWidgetItem):
if item.checkState() == Qt.Checked:
bg_color = self.COLOR_RATIO
for name, ref_item in self.index_checkboxes.items():
if ref_item is item:
bg_color = self._formula_color_map.get(name, self.COLOR_RATIO)
break
item.setBackground(QBrush(bg_color))
else:
item.setBackground(QBrush(self.COLOR_RATIO))
def _auto_load_formulas(self):
if os.path.exists(self.builtin_formula_path):
self.refresh_formulas(silent=True)
else:
print(f"DEBUG: 自动加载失败,路径不存在: {self.builtin_formula_path}")
def refresh_formulas(self, silent=False):
path = self.builtin_formula_path
if not os.path.exists(path):
if not silent:
QMessageBox.warning(self, "错误", f"找不到内置公式文件:\n{path}")
return
try:
df = None
for enc in ('utf-8', 'gbk', 'utf-8-sig'):
try:
df = pd.read_csv(path, encoding=enc)
if 'Formula_Name' in df.columns:
break
except Exception:
continue
if df is None or 'Formula_Name' not in df.columns:
if not silent:
QMessageBox.critical(self, "错误", "CSV缺少 'Formula_Name'")
return
self._formula_type_map.clear()
self._formula_coef_map.clear()
for _, row in df.iterrows():
name = str(row['Formula_Name']).strip()
if not name:
continue
ftype = str(row.get('Formula_Type', 'ratio')).strip().lower()
self._formula_type_map[name] = ftype
# Parse Coefficient for concentration formulas
coef_str = str(row.get('Coefficient', '')).strip()
if coef_str:
try:
coeffs = [float(c.strip()) for c in coef_str.split(',') if c.strip()]
self._formula_coef_map[name] = coeffs
except Exception:
self._formula_coef_map[name] = []
else:
self._formula_coef_map[name] = []
self.formula_list.clear()
self.index_checkboxes.clear()
self._formula_color_map.clear()
for name, ftype in self._formula_type_map.items():
item = QListWidgetItem(name, self.formula_list)
item.setCheckState(Qt.Checked)
if ftype == 'concentration':
bg_color = QColor(220, 240, 255)
else:
bg_color = self.COLOR_RATIO
self._formula_color_map[name] = bg_color
item.setBackground(QBrush(bg_color))
self.index_checkboxes[name] = item
self.formula_list.adjustSize()
print(f"✅ 加载 {len(self.index_checkboxes)} 个公式")
except Exception as e:
if not silent:
QMessageBox.critical(self, "加载失败", f"原因: {str(e)}")
def _select_ratio_only(self):
for name, item in self.index_checkboxes.items():
ftype = self._formula_type_map.get(name, 'ratio')
item.setCheckState(Qt.Checked if ftype == 'ratio' else Qt.Unchecked)
def _select_conc_only(self):
for name, item in self.index_checkboxes.items():
ftype = self._formula_type_map.get(name, 'ratio')
item.setCheckState(Qt.Checked if ftype == 'concentration' else Qt.Unchecked)
def select_all_formulas(self):
for item in self.index_checkboxes.values():
item.setCheckState(Qt.Checked)
def deselect_all_formulas(self):
for item in self.index_checkboxes.values():
item.setCheckState(Qt.Unchecked)
def get_config(self) -> dict:
"""获取配置"""
selected = [
name for name, item in self.index_checkboxes.items()
if item.checkState() == Qt.Checked
]
config = {
'training_csv_path': self.training_data_widget.get_path(),
'formula_csv_file': self.builtin_formula_path,
'formula_names': selected,
'enabled': self.enable_checkbox.isChecked(),
'output_mode': 0,
}
work_dir = self._get_work_dir()
if work_dir:
track_a_dir = resolve_subdir(work_dir, 'indices')
os.makedirs(track_a_dir, exist_ok=True)
config['output_file'] = os.path.join(track_a_dir, "training_spectra_indices.csv").replace('\\', '/')
return config
def set_config(self, config: Dict):
if 'training_csv_path' in config:
self.training_data_widget.set_path(config['training_csv_path'])
if 'formula_names' in config:
sel = set(config['formula_names'])
for name, item in self.index_checkboxes.items():
item.setCheckState(Qt.Checked if name in sel else Qt.Unchecked)
self.enable_checkbox.setChecked(config.get('enabled', True))
def update_from_config(self, work_dir=None, pipeline=None):
if work_dir:
self.work_dir = work_dir
main = self.window()
if hasattr(main, 'step6_panel'):
p5 = main.step6_panel.output_file.get_path()
if p5:
if not os.path.isabs(p5):
p5 = os.path.join(self.work_dir or '', p5)
p5 = p5.replace('\\', '/')
self.training_data_widget.set_path(p5)
def _get_work_dir(self) -> Optional[str]:
if self.work_dir:
return self.work_dir
main = self.window()
if hasattr(main, 'work_dir') and main.work_dir:
return main.work_dir
return None
def run_step(self):
"""独立运行步骤7 (通过标准的 Worker 路由下发)"""
config = self.get_config()
if not config['enabled']:
QMessageBox.information(self, "提示", "已禁用计算流程(启用计算流程未勾选)")
return
training_path = config.get('training_csv_path')
if not training_path or not os.path.exists(training_path):
QMessageBox.warning(self, "提示", "请先选择输入特征提取CSV文件")
return
if not config.get('formula_names'):
QMessageBox.warning(self, "提示", "请至少勾选一个公式")
return
main_window = self.window()
if hasattr(main_window, 'run_single_step'):
pipeline_config = {'step7_index': config}
main_window.run_single_step('step7_index', pipeline_config)

View File

@ -100,13 +100,11 @@ class Step8MlTrainPanel(QWidget):
self.create_ml_page() self.create_ml_page()
layout.addWidget(self.ml_page) layout.addWidget(self.ml_page)
# 输出文件路径 # 输出文件路径 (改为文件夹模式)
self.output_path = FileSelectWidget( self.output_path = FileSelectWidget(
"输出文件:", "模型输出目录:",
"CSV Files (*.csv);;All Files (*.*)", "Directories"
mode="save"
) )
self.output_path.line_edit.setPlaceholderText("自动生成,或手动指定输出文件路径...")
self.output_path.browse_btn.clicked.disconnect() self.output_path.browse_btn.clicked.disconnect()
self.output_path.browse_btn.clicked.connect(self.browse_output_path) self.output_path.browse_btn.clicked.connect(self.browse_output_path)
layout.addWidget(self.output_path) layout.addWidget(self.output_path)
@ -276,28 +274,12 @@ class Step8MlTrainPanel(QWidget):
return "" return ""
def browse_output_path(self): def browse_output_path(self):
"""浏览输出文件路径(保存对话框)""" """浏览输出模型目录"""
current = self.output_path.get_path().strip() work_dir = getattr(self, 'work_dir', "")
if current: initial_dir = os.path.join(work_dir, '8_Machine_Learning_Models') if work_dir else ""
initial_dir = os.path.dirname(current) dir_path = QFileDialog.getExistingDirectory(self, "选择模型输出目录", initial_dir)
initial_file = os.path.basename(current) if dir_path:
else: self.output_path.set_path(dir_path)
initial_dir = ""
initial_file = ""
if not initial_dir or not os.path.isdir(initial_dir):
# 默认定位到 indices 目录
work_dir = self._get_default_work_dir()
initial_dir = resolve_subdir(work_dir, 'indices') if work_dir else ""
if initial_dir and not os.path.isdir(initial_dir):
os.makedirs(initial_dir, exist_ok=True)
file_path, _ = QFileDialog.getSaveFileName(
self, "保存输出文件", os.path.join(initial_dir, initial_file) if initial_file else initial_dir,
"CSV Files (*.csv);;All Files (*.*)"
)
if file_path:
self.output_path.set_path(file_path)
def get_config(self): def get_config(self):
"""获取配置""" """获取配置"""
@ -381,20 +363,12 @@ class Step8MlTrainPanel(QWidget):
if step6_training_csv: if step6_training_csv:
self.training_csv_file.set_path(step6_training_csv) self.training_csv_file.set_path(step6_training_csv)
# 2. 自动填充输出文件路径(基于工作目录和输入文件名) # 2. 自动填充输出目录为 8_Machine_Learning_Models
# 输入是 training_spectra.csv → 输出 {work_dir}/7_Water_Quality_Indices/training_spectra_indices.csv
# 输入是 sampling_spectra.csv → 输出 {work_dir}/7_Water_Quality_Indices/sampling_spectra_indices.csv
if self.work_dir: if self.work_dir:
indices_dir = resolve_subdir(self.work_dir, 'indices') import os
os.makedirs(indices_dir, exist_ok=True) models_dir = os.path.join(self.work_dir, "8_Machine_Learning_Models").replace('\\', '/')
training_csv = self.training_csv_file.get_path() os.makedirs(models_dir, exist_ok=True)
if training_csv: self.output_path.set_path(models_dir)
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)
else: else:
self.output_path.set_path("") self.output_path.set_path("")

View File

@ -1,312 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step8 面板 - 非经验统计回归建模
"""
import os
import sys
from pathlib import Path
# 路径归一化 helper与 pipeline.get_step_output_dir 互为表里)
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import get_step_output_path, resolve_step_widget, resolve_subdir
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout,
QHBoxLayout, QLabel, QCheckBox, QSpinBox, QPushButton,
QFileDialog, QMessageBox,
)
from src.gui.components.custom_widgets import FileSelectWidget
from src.gui.styles import ModernStylesheet
class Step8NonEmpiricalPanel(QWidget):
"""步骤8非经验统计回归建模"""
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
# 标题
# 训练数据文件(用于独立运行)
self.training_csv_file = FileSelectWidget(
"训练数据CSV:",
"CSV Files (*.csv);;All Files (*.*)"
)
layout.addWidget(self.training_csv_file)
# 参数设置
params_group = QGroupBox("模型参数")
params_layout = QFormLayout()
# 预处理方法
self.preproc_checkboxes = {}
preproc_group = QGroupBox("预处理方法 (可多选)")
preproc_layout = QVBoxLayout()
preproc_grid = QGridLayout()
preproc_methods = ['None', 'MMS', 'SS', 'SNV', 'MA', 'SG', 'MSC', 'D1', 'D2', 'DT', 'CT']
for i, method in enumerate(preproc_methods):
checkbox = QCheckBox(method)
checkbox.setChecked(True)
self.preproc_checkboxes[method] = checkbox
preproc_grid.addWidget(checkbox, i // 4, i % 4)
button_layout = QHBoxLayout()
select_all_btn = QPushButton("全选")
deselect_all_btn = QPushButton("全不选")
select_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, True))
deselect_all_btn.clicked.connect(lambda: self._toggle_checkboxes(self.preproc_checkboxes, False))
button_layout.addWidget(select_all_btn)
button_layout.addWidget(deselect_all_btn)
button_layout.addStretch()
preproc_layout.addLayout(preproc_grid)
preproc_layout.addLayout(button_layout)
preproc_group.setLayout(preproc_layout)
params_layout.addRow(preproc_group)
# 算法选择(可多选)
self.algorithm_inputs = {}
algorithms_widget = QWidget()
algorithms_layout = QVBoxLayout()
algorithms_layout.setContentsMargins(0, 0, 0, 0)
algorithms_layout.setSpacing(4)
algorithm_list = ['chl_a', 'nh3', 'mno4', 'tn', 'tp', 'tss']
for algorithm in algorithm_list:
row_widget = QWidget()
row_layout = QHBoxLayout()
row_layout.setContentsMargins(0, 0, 0, 0)
checkbox = QCheckBox(algorithm)
checkbox.setChecked(True)
spinbox = QSpinBox()
spinbox.setRange(0, 500)
spinbox.setValue(0)
spinbox.setMaximumWidth(90)
row_layout.addWidget(checkbox)
row_layout.addWidget(QLabel("对应值列索引:"))
row_layout.addWidget(spinbox)
row_layout.addStretch()
row_widget.setLayout(row_layout)
algorithms_layout.addWidget(row_widget)
self.algorithm_inputs[algorithm] = (checkbox, spinbox)
algorithms_widget.setLayout(algorithms_layout)
params_layout.addRow("非经验算法选择:", algorithms_widget)
# 光谱起始列
self.spectral_start_col = QSpinBox()
self.spectral_start_col.setRange(0, 100)
self.spectral_start_col.setValue(1)
params_layout.addRow("光谱起始列索引:", self.spectral_start_col)
# 窗口大小 (变量名已修正,避免覆盖 QWidget.window)
self.window_size_spinbox = QSpinBox()
self.window_size_spinbox.setRange(1, 20)
self.window_size_spinbox.setValue(5)
params_layout.addRow("窗口大小:", self.window_size_spinbox)
params_group.setLayout(params_layout)
layout.addWidget(params_group)
# 输出文件路径
self.output_dir = FileSelectWidget(
"输出模型目录:",
"Directories;;All Files (*.*)"
)
self.output_dir.line_edit.setPlaceholderText("8_Regression_Modeling")
self.output_dir.browse_btn.clicked.disconnect()
self.output_dir.browse_btn.clicked.connect(self.browse_output_dir)
layout.addWidget(self.output_dir)
# 启用步骤
self.enable_checkbox = QCheckBox("启用此步骤")
self.enable_checkbox.setChecked(True)
layout.addWidget(self.enable_checkbox)
# 独立运行按钮
self.run_button = QPushButton("独立运行此步骤")
self.run_button.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
self.run_button.clicked.connect(self.run_step)
layout.addWidget(self.run_button)
layout.addStretch()
self.setLayout(layout)
def get_config(self):
"""获取配置"""
selected_algorithms = [
name for name, (checkbox, _) in self.algorithm_inputs.items()
if checkbox.isChecked()
]
if not selected_algorithms:
selected_algorithms = list(self.algorithm_inputs.keys())
value_cols = {
name: spinbox.value()
for name, (_, spinbox) in self.algorithm_inputs.items()
if name in selected_algorithms
}
preprocessing_methods = [
method for method, checkbox in self.preproc_checkboxes.items()
if checkbox.isChecked()
] or ['None']
config = {
'preprocessing_methods': preprocessing_methods,
'algorithms': selected_algorithms,
'value_cols': value_cols,
'spectral_start_col': self.spectral_start_col.value(),
'window': self.window_size_spinbox.value(),
'enabled': self.enable_checkbox.isChecked()
}
output_dir = self.output_dir.get_path()
if not output_dir:
main_window = self.parent().window()
if hasattr(main_window, 'work_dir') and main_window.work_dir:
output_dir = str(Path(main_window.work_dir) / "8_Regression_Modeling")
else:
output_dir = str(Path.cwd() / "8_Regression_Modeling")
config['output_dir'] = output_dir
training_csv_path = self.training_csv_file.get_path()
if training_csv_path:
config['csv_path'] = training_csv_path
return config
def set_config(self, config):
"""设置配置"""
if 'preprocessing_methods' in config:
methods = config['preprocessing_methods']
for method, checkbox in self.preproc_checkboxes.items():
checkbox.setChecked(method in methods)
if 'algorithms' in config:
algorithm_values = config['algorithms']
for algorithm, (checkbox, spinbox) in self.algorithm_inputs.items():
checkbox.setChecked(algorithm in algorithm_values)
if 'value_cols' in config:
value_cols = config['value_cols']
if isinstance(value_cols, dict):
for algorithm, (_, spinbox) in self.algorithm_inputs.items():
if algorithm in value_cols:
spinbox.setValue(value_cols[algorithm])
else:
for _, spinbox in self.algorithm_inputs.values():
spinbox.setValue(value_cols)
if 'spectral_start_col' in config:
self.spectral_start_col.setValue(config['spectral_start_col'])
if 'window' in config:
self.window_size_spinbox.setValue(config['window'])
if 'output_dir' in config:
self.output_dir.set_path(config['output_dir'])
if 'csv_path' in config:
self.training_csv_file.set_path(config['csv_path'])
def update_from_config(self, work_dir=None, pipeline=None):
"""从全局配置自动填充训练数据和输出路径
Args:
work_dir: 工作目录路径
pipeline: Pipeline 实例(未使用,保留接口兼容性)
"""
try:
import traceback
if work_dir:
self.work_dir = work_dir
elif hasattr(self, 'work_dir') and self.work_dir:
pass
else:
self.work_dir = None
# 借用父组件的 window() 方法,安全绕过当前类的命名冲突
parent_widget = self.parentWidget()
main_window = parent_widget.window() if parent_widget else None
# 1. 强制读 Step 6 的 training_spectra.csv光谱特征提取结果
# 修复张冠李戴:原链路 STEP_DATA_SOURCE['training_spectra_csv'] → step5_clean_panel
# 错误地指向 Step 5 的 processed_data.csv纯清洗数据不含光谱特征
# 实际非经验建模需要的特征数据来自 Step 6 的 6_Spectral_Feature_Extraction/training_spectra.csv
existing_csv = self.training_csv_file.get_path()
if not existing_csv or not existing_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)
# 2. 自动填充输出目录8_Regression_Modeling
if self.work_dir:
output_dir = resolve_subdir(self.work_dir, 'regression_modeling')
os.makedirs(output_dir, exist_ok=True)
existing_out = self.output_dir.get_path()
if not existing_out or not existing_out.strip():
self.output_dir.set_path(output_dir)
except Exception as e:
import traceback
print(f"{self.__class__.__name__}】自动填充失败,跳过: {e}")
traceback.print_exc()
def _get_default_work_dir(self):
"""获取 work_dir优先用 panel 自身缓存的,否则尝试从主窗口取"""
if hasattr(self, 'work_dir') and self.work_dir:
return str(self.work_dir)
# 借用父组件的 window() 方法,安全绕过当前类的命名冲突
parent_widget = self.parentWidget()
mw = parent_widget.window() if parent_widget else None
if mw and hasattr(mw, 'work_dir') and mw.work_dir:
return str(mw.work_dir)
return ""
def browse_output_dir(self):
"""浏览输出目录"""
default = self._get_default_work_dir()
if default:
default = resolve_subdir(default, 'regression_modeling')
dir_path = QFileDialog.getExistingDirectory(self, "选择输出模型目录", default)
if dir_path:
self.output_dir.set_path(dir_path)
def run_step(self):
"""独立运行步骤8"""
training_csv_path = self.training_csv_file.get_path()
if not training_csv_path:
QMessageBox.warning(self, "输入错误", "请选择训练数据CSV文件")
return
if not os.path.exists(training_csv_path):
QMessageBox.warning(self, "输入错误", "训练数据CSV文件不存在")
return
config = self.get_config()
parent = self.parent()
while parent and not hasattr(parent, 'run_single_step'):
parent = parent.parent()
if parent and hasattr(parent, 'run_single_step'):
parent.run_single_step('step8_non_empirical_modeling', {'step8_non_empirical_modeling': config})
else:
QMessageBox.critical(self, "错误", "无法找到父级GUI对象")
def _toggle_checkboxes(self, checkboxes_dict, checked):
"""统一设置预处理checkbox状态"""
for checkbox in checkboxes_dict.values():
checkbox.setChecked(checked)

View File

@ -1,337 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step8 面板 - QAA 物理反演(非经验模型)
"""
import os
import sys
# 路径归一化 helper与 pipeline.get_step_output_dir 互为表里)
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import resolve_subdir
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout,
QHBoxLayout, QLabel, QLineEdit, QComboBox, QCheckBox,
QPushButton, QFileDialog, QMessageBox,
)
from PyQt5.QtGui import QFont
from PyQt5.QtCore import Qt
from src.gui.components.custom_widgets import FileSelectWidget
from src.gui.styles import ModernStylesheet
from src.utils.water_owt_config import (
get_all_lake_names,
get_lake_config,
get_lambda_0,
get_default_lake,
)
from src.core.algorithms.qaa import QAABaselineSolver
class Step8QAAPanel(QWidget):
"""步骤8QAA 物理反演(非经验模型)"""
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
title = QLabel("步骤8QAA 物理反演(非经验模型)")
title.setFont(QFont("Arial", 12, QFont.Bold))
layout.addWidget(title)
# 光谱 CSV 文件输入
self.spectrum_csv_file = FileSelectWidget(
"光谱 CSV 文件:",
"CSV Files (*.csv);;All Files (*.*)"
)
self.spectrum_csv_file.line_edit.setPlaceholderText(
"选择实测光谱或采样点光谱 CSV含波长列"
)
layout.addWidget(self.spectrum_csv_file)
# 水域类型选择
lake_group = QGroupBox("水域类型配置")
lake_layout = QFormLayout()
self.lake_combo = QComboBox()
lake_names = get_all_lake_names()
self.lake_combo.addItems(lake_names)
default_lake = get_default_lake()
if default_lake in lake_names:
self.lake_combo.setCurrentText(default_lake)
self.lake_combo.currentTextChanged.connect(self._on_lake_changed)
lake_layout.addRow("水域选择:", self.lake_combo)
# 参考波长显示
self.lambda_0_label = QLabel()
self.lambda_0_label.setStyleSheet(
f"color: {ModernStylesheet.COLORS['accent']}; "
f"font-weight: bold;"
)
lake_layout.addRow("参考波长 λ₀:", self.lambda_0_label)
# 算法提示
self.hint_label = QLabel()
self.hint_label.setWordWrap(True)
self.hint_label.setStyleSheet(
f"color: {ModernStylesheet.COLORS['text_secondary']}; "
"font-size: 12px;"
)
lake_layout.addRow("算法提示:", self.hint_label)
lake_group.setLayout(lake_layout)
layout.addWidget(lake_group)
# 输出路径
self.output_path = FileSelectWidget(
"输出文件:",
"CSV Files (*.csv);;All Files (*.*)",
mode="save"
)
self.output_path.line_edit.setPlaceholderText(
"自动生成到 8_QAA_Inversion或手动指定..."
)
self.output_path.browse_btn.clicked.disconnect()
self.output_path.browse_btn.clicked.connect(self.browse_output_path)
layout.addWidget(self.output_path)
# 启用步骤
self.enable_checkbox = QCheckBox("启用此步骤")
self.enable_checkbox.setChecked(False)
layout.addWidget(self.enable_checkbox)
# 独立运行按钮
self.run_btn = QPushButton("执行 QAA 反演")
self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
self.run_btn.clicked.connect(self._on_run_single_clicked)
layout.addWidget(self.run_btn)
layout.addStretch()
self.setLayout(layout)
self._on_lake_changed(self.lake_combo.currentText())
def _on_lake_changed(self, lake_name: str):
"""当用户切换水域时更新显示"""
cfg = get_lake_config(lake_name)
if cfg:
self.lambda_0_label.setText(
f"{cfg['lambda_0']} nm{cfg['qaa_version']}"
)
self.hint_label.setText(cfg.get('notes', ''))
else:
self.lambda_0_label.setText("")
self.hint_label.setText("")
def _get_default_work_dir(self) -> str:
"""获取 work_dir优先用 panel 自身缓存的,否则尝试从主窗口取"""
if hasattr(self, 'work_dir') and self.work_dir:
return str(self.work_dir)
mw = self.window()
if mw and hasattr(mw, 'work_dir') and mw.work_dir:
return str(mw.work_dir)
return ""
def browse_output_path(self):
"""浏览输出文件路径"""
current = self.output_path.get_path().strip()
if current:
initial_dir = os.path.dirname(current)
initial_file = os.path.basename(current)
else:
initial_dir = ""
initial_file = ""
if not initial_dir or not os.path.isdir(initial_dir):
work_dir = self._get_default_work_dir()
initial_dir = resolve_subdir(work_dir, 'qaa_inversion') if work_dir else ""
if initial_dir and not os.path.isdir(initial_dir):
os.makedirs(initial_dir, exist_ok=True)
file_path, _ = QFileDialog.getSaveFileName(
self, "保存输出文件",
os.path.join(initial_dir, initial_file) if initial_file else initial_dir,
"CSV Files (*.csv);;All Files (*.*)"
)
if file_path:
self.output_path.set_path(file_path)
def get_config(self) -> dict:
"""获取面板配置"""
config = {
'lake_name': self.lake_combo.currentText(),
'lambda_0': get_lambda_0(self.lake_combo.currentText()),
'spectrum_csv_path': self.spectrum_csv_file.get_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 'lake_name' in config:
lake_name = config['lake_name']
idx = self.lake_combo.findText(lake_name)
if idx >= 0:
self.lake_combo.setCurrentIndex(idx)
if 'spectrum_csv_path' in config:
self.spectrum_csv_file.set_path(config['spectrum_csv_path'])
if 'output_path' in config:
self.output_path.set_path(config['output_path'])
def update_from_config(self, work_dir=None, pipeline=None):
"""从全局配置自动填充训练数据和输出路径"""
if work_dir:
self.work_dir = work_dir
elif hasattr(self, 'work_dir') and self.work_dir:
pass
else:
self.work_dir = None
main_window = self.window()
if main_window and hasattr(main_window, 'step5_panel'):
step5_output = main_window.step5_panel.output_file.get_path()
if step5_output:
if not os.path.isabs(step5_output):
step5_output = os.path.join(self.work_dir or '', step5_output).replace('\\', '/')
self.spectrum_csv_file.set_path(step5_output)
if self.work_dir:
qaa_dir = resolve_subdir(self.work_dir, 'qaa_inversion')
os.makedirs(qaa_dir, exist_ok=True)
output_path = os.path.join(qaa_dir, "a_lambda_results.csv").replace('\\', '/')
self.output_path.set_path(output_path)
else:
self.output_path.set_path("")
def _on_run_single_clicked(self):
"""通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor"""
from src.gui.core.event_bus import global_event_bus
spectrum_path = self.spectrum_csv_file.get_path()
if not spectrum_path:
QMessageBox.warning(self, "输入错误", "请选择光谱 CSV 文件!")
return
config = {'step8_qaa': self.get_config()}
global_event_bus.publish('RequestRunSingleStep', {
'step_name': 'step8_qaa',
'config': config,
})
def run_step(self):
"""独立运行 QAA 反演(旧版 parent 链上溯方式,保留兼容)。"""
spectrum_path = self.spectrum_csv_file.get_path()
if not spectrum_path:
QMessageBox.warning(self, "输入错误", "请选择光谱 CSV 文件!")
return
main_window = self.window()
if hasattr(main_window, 'run_single_step'):
config = {'step8_qaa': self.get_config()}
main_window.run_single_step('step8_qaa', config)
else:
self._run_qaa_direct()
def _run_qaa_direct(self):
"""直接执行 QAA 反演(不依赖主窗口流水线)"""
spectrum_path = self.spectrum_csv_file.get_path()
output_path = self.output_path.get_path()
lake_name = self.lake_combo.currentText()
lambda_0 = get_lambda_0(lake_name)
if not output_path:
work_dir = self._get_default_work_dir()
qaa_dir = resolve_subdir(work_dir, 'qaa_inversion') if work_dir else ""
if qaa_dir and not os.path.isdir(qaa_dir):
os.makedirs(qaa_dir, exist_ok=True)
output_path = os.path.join(qaa_dir, "a_lambda_results.csv").replace('\\', '/')
try:
import numpy as np
import pandas as pd
df = pd.read_csv(spectrum_path, encoding="utf-8-sig")
col_names = df.columns.tolist()
wavelength_col_idx = None
for i, col in enumerate(col_names):
try:
float(col)
wavelength_col_idx = i
break
except (ValueError, TypeError):
pass
if wavelength_col_idx is None:
QMessageBox.warning(
self, "解析错误",
"无法从 CSV 列名中识别波长信息请确保列名包含数值型波长nm"
)
return
wavelengths = np.array(
[float(c) for c in col_names[wavelength_col_idx:]], dtype=np.float64
)
data_matrix = df.iloc[:, wavelength_col_idx:].values.astype(np.float64)
if data_matrix.ndim == 1:
data_matrix = data_matrix[np.newaxis, :]
solver = QAABaselineSolver()
raw_result = solver.run_inversion(wavelengths, data_matrix, lambda_0)
# run_inversion 返回:单样本 → dict多样本 → list[dict]
if isinstance(raw_result, list):
sample_results = raw_result
aw_0 = raw_result[0].get('aw_0', 0)
bbw_0 = raw_result[0].get('bbw_0', 0)
else:
sample_results = [raw_result]
aw_0 = raw_result.get('aw_0', 0)
bbw_0 = raw_result.get('bbw_0', 0)
rows_out = []
for i, sample_result in enumerate(sample_results):
wl_arr = wavelengths
a_arr = sample_result['a_lambda']
bb_arr = sample_result['bb_lambda']
for j, wl in enumerate(wl_arr):
rows_out.append({
'sample_id': f"sample_{i}",
'Wavelength': wl,
'a_lambda': a_arr[j],
'bb_lambda': bb_arr[j],
})
result_df = pd.DataFrame(rows_out)
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
result_df.to_csv(output_path, index=False, float_format='%.8f')
QMessageBox.information(
self, "执行成功",
f"QAA 反演完成!\n"
f"水域: {lake_name}\n"
f"参考波长 λ₀: {lambda_0} nm\n"
f"λ₀ 处 aw: {aw_0:.6f} m⁻¹\n"
f"λ₀ 处 bbw: {bbw_0:.6f} m⁻¹\n"
f"结果已保存到:\n{output_path}"
)
except Exception as e:
QMessageBox.critical(self, "执行错误", f"QAA 反演失败:\n{str(e)}")
def get_training_params(self) -> dict:
"""获取反演参数"""
return {
'pipeline_type': 'qaa_non_empirical',
'lake_name': self.lake_combo.currentText(),
'lambda_0': get_lambda_0(self.lake_combo.currentText()),
}

View File

@ -307,62 +307,30 @@ class Step9MlPredictPanel(QWidget):
return result return result
def update_from_config(self, work_dir=None, pipeline=None): def update_from_config(self, work_dir=None, pipeline=None):
"""从全局配置自动填充采样光谱和模型目录 if work_dir: self.work_dir = work_dir
Args:
work_dir: 工作目录路径
pipeline: Pipeline 实例(未使用,保留接口兼容性)
"""
try:
import traceback
if work_dir:
self.work_dir = work_dir
elif hasattr(self, 'work_dir') and self.work_dir:
pass
else:
self.work_dir = None
main_window = self.window() main_window = self.window()
factory = getattr(main_window, '_panel_factory', None) if main_window else None
if not factory: return
# 1. 尝试从 Step4采样点布设读取全湖采样点 CSV 路径 # 1. 拿第 4 步的采样光谱
if main_window and hasattr(main_window, 'step4_sampling_panel'): step4_panel = factory.get_panel('step4_sampling')
step4_widget = getattr(main_window.step4_sampling_panel, 'output_file', None) if step4_panel and hasattr(step4_panel, 'output_file'):
step4_output_path = "" path = step4_panel.output_file.get_path()
if hasattr(step4_widget, 'get_path'): if path: self.sampling_csv_file.set_path(path)
step4_output_path = step4_widget.get_path() or ""
elif hasattr(step4_widget, 'text'):
step4_output_path = step4_widget.text() or ""
if step4_output_path: # 2. 拿第 8 步的模型目录
if not os.path.isabs(step4_output_path): step8_panel = factory.get_panel('step8_ml_train')
step4_output_path = os.path.join(self.work_dir or '', step4_output_path).replace('\\', '/') if step8_panel and hasattr(step8_panel, 'output_path'):
existing = self.sampling_csv_file.get_path() path = step8_panel.output_path.get_path()
if not existing or not existing.strip(): if path: self.models_dir_file.set_path(path)
self.sampling_csv_file.set_path(step4_output_path)
# 2. 尝试从 Step8监督建模读取模型目录修复张冠李戴原代码 main_window.step9_panel 不存在) # 3. 生成第 9 步的输出目录
step8_models_dir = get_step_output_path( if hasattr(self, 'work_dir') and self.work_dir:
main_window, 'models_dir', work_dir=self.work_dir, import os
widget_attr='output_dir', fallback_key='step8_ml_train', out_dir = os.path.join(self.work_dir, "9_ML_Prediction").replace('\\', '/')
) os.makedirs(out_dir, exist_ok=True)
if step8_models_dir: self.output_file.set_path(out_dir)
existing_models = self.models_dir_file.get_path()
if not existing_models or not existing_models.strip():
self.models_dir_file.set_path(step8_models_dir)
# 3. 自动填充输出路径(机器学习预测目录,归属 step9 → 9_ML_Prediction
# 注9_ML_Prediction 是 prediction_dir 的子目录,用本地约定
if self.work_dir:
output_dir = resolve_subdir(self.work_dir, 'ml_prediction')
os.makedirs(output_dir, exist_ok=True)
existing_out = self.output_file.get_path()
if not existing_out or not existing_out.strip():
self.output_file.set_path(output_dir)
except Exception as e:
import traceback
print(f"{self.__class__.__name__}】自动填充失败,跳过: {e}")
traceback.print_exc()
def _get_default_work_dir(self): def _get_default_work_dir(self):
"""获取 work_dir优先用 panel 自身缓存的,否则尝试从主窗口取""" """获取 work_dir优先用 panel 自身缓存的,否则尝试从主窗口取"""

View File

@ -458,52 +458,65 @@ class WaterQualityGUI(QMainWindow):
# ================================================================ # ================================================================
def _on_step_list_changed(self, index): def _on_step_list_changed(self, index):
"""左侧导航 → 右侧 Tab 单向路由Tab 头部已隐藏,无反向同步)。""" from PyQt5.QtCore import Qt
if index < 0: if index < 0: return
return
item = self._step_list.item(index) item = self._step_list.item(index)
if not item: if not item: return
return
item_data = item.data(Qt.UserRole) item_data = item.data(Qt.UserRole)
if item_data == "stage_header" or item_data is None:
return
from src.gui.core.panel_registry import get_tab_index
tab_index = get_tab_index(item_data)
if tab_index < 0:
return
try:
# 先触发懒加载再切 Tab避免 removeTab/insertTab 与导航事件重叠
self._panel_factory.get_panel(item_data)
self._tab_widget.setCurrentIndex(tab_index)
except Exception as e:
import traceback
traceback.print_exc()
from PyQt5.QtWidgets import QMessageBox
QMessageBox.critical(self, "面板加载失败",
f"加载页面时发生严重错误:\n{e}\n\n详见终端日志。")
# ====== 终极状态回滚机制 ====== if item_data in (None, "stage_header"): return # 跳过阶段标题
self._step_list.blockSignals(True)
from src.gui.core.panel_registry import get_tab_index, PANEL_REGISTRY
tab_index = get_tab_index(item_data)
if tab_index < 0: return
try: try:
current_tab_idx = self._tab_widget.currentIndex() # 1. 触发懒加载生成面板
from src.gui.core.panel_registry import PANEL_REGISTRY self._panel_factory.get_panel(item_data)
if 0 <= current_tab_idx < len(PANEL_REGISTRY):
correct_step_id = PANEL_REGISTRY[current_tab_idx]['step_id'] # 🚨 核心防卡死补丁:如果目标 Tab 被后台任务异常永久锁定,强制撬开!
if not self._tab_widget.isTabEnabled(tab_index):
self._log_manager.info(f"检测到 {item_data} 处于异常锁定状态,已执行强制解锁。")
self._tab_widget.setTabEnabled(tab_index, True)
# 【新增修复】:在跳之前,再核实一遍要去的 tab_index 和 item_data 的 step_id 是否吻合!
# 如果因为删除了死文件导致索引错位,这里强行用真实 step_id 再找一遍!
try:
self._panel_factory.get_panel(item_data)
# 遍历右侧所有已加载的 Tab找到属于当前 step_id 的那个正确的 Tab 索引
for i in range(self._tab_widget.count()):
scroll_area = self._tab_widget.widget(i)
if scroll_area and hasattr(scroll_area, 'widget'):
real_panel = scroll_area.widget()
# 如果这个 panel 就是我们要找的 panel强制更新 tab_index
if real_panel == self._panel_factory.get_panel(item_data):
tab_index = i
break
except Exception as e:
self._log_manager.error(f"页面实例化失败: {str(e)}")
return
# 2. 强制使用注册表的固定索引进行跳转
self._tab_widget.setCurrentIndex(tab_index)
# 【新增修复】:每次切页时,强制重播所有面板的输入值!
# 原理:打破 preload_window 造成的时序差,确保目标页面能拿到源页面最新填写的值
if hasattr(self, '_panel_factory'):
self._panel_factory._replay_live_panel_inputs()
# 3. 防撕裂回弹机制:如果由于某种原因没跳过去,把左边的蓝条强行拽回当前真实页面
if self._tab_widget.currentIndex() != tab_index:
self._step_list.blockSignals(True)
current_step_id = PANEL_REGISTRY[self._tab_widget.currentIndex()]['step_id']
for i in range(self._step_list.count()): for i in range(self._step_list.count()):
item_node = self._step_list.item(i) list_item = self._step_list.item(i)
if item_node and item_node.data(Qt.UserRole) == correct_step_id: if list_item and list_item.data(Qt.UserRole) == current_step_id:
self._step_list.setCurrentRow(i) self._step_list.setCurrentRow(i)
break break
except Exception:
pass
finally:
self._step_list.blockSignals(False) self._step_list.blockSignals(False)
return except Exception as e:
self._log_manager.error(f"页面跳转失败: {str(e)}")
# 动态更新上一步/下一步按钮可用状态
self._prev_btn.setEnabled(self._find_prev_step_row(index) is not None)
self._next_btn.setEnabled(self._find_next_step_row(index) is not None)
def _find_prev_step_row(self, current_row): def _find_prev_step_row(self, current_row):
"""从 current_row 向上遍历,跳过 stage_header 和空分隔符,返回上一个有效 step 的行号。""" """从 current_row 向上遍历,跳过 stage_header 和空分隔符,返回上一个有效 step 的行号。"""

View File

@ -35,11 +35,12 @@ import pandas as pd
class _SimpleProgress: class _SimpleProgress:
"""无依赖进度条(控制台单行刷新)。""" """无依赖进度条(控制台单行刷新,支持 Qt 回调上递)。"""
def __init__(self, total: int, desc: str = ""): def __init__(self, total: int, desc: str = "", on_step=None):
self.total = max(1, int(total)) self.total = max(1, int(total))
self.desc = desc self.desc = desc
self.on_step = on_step
self.n = 0 self.n = 0
self._render() self._render()
@ -47,6 +48,10 @@ class _SimpleProgress:
self.n = min(self.total, self.n + int(step)) self.n = min(self.total, self.n + int(step))
self._render() self._render()
def set_description(self, text: str):
"""动态更新进度描述文案(用于按段切换分析对象)。"""
self.desc = text
def close(self): def close(self):
# 换行,避免覆盖后续输出 # 换行,避免覆盖后续输出
print() print()
@ -58,6 +63,11 @@ class _SimpleProgress:
bar = "" * filled + "·" * (bar_len - filled) bar = "" * filled + "·" * (bar_len - filled)
prefix = f"{self.desc} " if self.desc else "" prefix = f"{self.desc} " if self.desc else ""
print(f"\r{prefix}[{bar}] {self.n}/{self.total} ({pct}%)", end="", flush=True) print(f"\r{prefix}[{bar}] {self.n}/{self.total} ({pct}%)", end="", flush=True)
if self.on_step:
try:
self.on_step(pct, self.desc)
except Exception:
pass
@dataclass @dataclass
@ -210,98 +220,17 @@ class WaterQualityReportGenerator:
} }
# 每个参数对应的图片顺序统一5张图模式 # 每个参数对应的图片顺序统一5张图模式
params_list = ["Chlorophyll", "COD", "DO", "PH", "Temperature",
"spCond", "Turbidity", "TDS", "Cl-", "NO3-N",
"NH3-N", "BGA", "TT"]
self.parameter_images = { self.parameter_images = {
"Chlorophyll": [ param: [
"Chlorophyll_histogram.png", f"{param}_histogram.png",
"Chlorophyll_spectrum_comparison.png", f"{param}_spectrum_comparison.png",
"Chlorophyll_scatter_with_confidence.png", f"{param}_scatter_true_vs_pred.png",
"Chlorophyll_boxplot.png", f"{param}_boxplot.png",
"Chlorophyll_distribution.png" f"{param}_distribution_enhanced.png"
], ] for param in params_list
"COD": [
"COD_histogram.png",
"COD_spectrum_comparison.png",
"COD_scatter_with_confidence.png",
"COD_boxplot.png",
"COD_distribution.png"
],
"DO": [
"DO_histogram.png",
"DO_spectrum_comparison.png",
"DO_scatter_with_confidence.png",
"DO_boxplot.png",
"DO_distribution.png"
],
"PH": [
"PH_histogram.png",
"PH_spectrum_comparison.png",
"PH_scatter_with_confidence.png",
"PH_boxplot.png",
"PH_distribution.png"
],
"Temperature": [
"Temperature_histogram.png",
"Temperature_spectrum_comparison.png",
"Temperature_scatter_with_confidence.png",
"Temperature_boxplot.png",
"Temperature_distribution.png"
],
"spCond": [
"spCond_histogram.png",
"spCond_spectrum_comparison.png",
"spCond_scatter_with_confidence.png",
"spCond_boxplot.png",
"spCond_distribution.png"
],
"Turbidity": [
"Turbidity_histogram.png",
"Turbidity_spectrum_comparison.png",
"Turbidity_scatter_with_confidence.png",
"Turbidity_boxplot.png",
"Turbidity_distribution.png"
],
"TDS": [
"TDS_histogram.png",
"TDS_spectrum_comparison.png",
"TDS_scatter_with_confidence.png",
"TDS_boxplot.png",
"TDS_distribution.png"
],
"Cl-": [
"Cl-histogram.png",
"Cl-spectrum_comparison.png",
"Cl-scatter_with_confidence.png",
"Cl-boxplot.png",
"Cl-distribution.png"
],
"NO3-N": [
"NO3-N_histogram.png",
"NO3-N_spectrum_comparison.png",
"NO3-N_scatter_with_confidence.png",
"NO3-N_boxplot.png",
"NO3-N_distribution.png"
],
"NH3-N": [
"NH3-N_histogram.png",
"NH3-N_spectrum_comparison.png",
"NH3-N_scatter_with_confidence.png",
"NH3-N_boxplot.png",
"NH3-N_distribution.png"
],
"BGA": [
"BGA_histogram.png",
"BGA_spectrum_comparison.png",
"BGA_scatter_with_confidence.png",
"BGA_boxplot.png",
"BGA_distribution.png"
],
"TT": [
"TT_histogram.png",
"TT_spectrum_comparison.png",
"TT_scatter_with_confidence.png",
"TT_boxplot.png",
"TT_distribution.png"
]
} }
def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None: def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None:
@ -397,6 +326,7 @@ class WaterQualityReportGenerator:
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"model": self.minimax_text_model, "model": self.minimax_text_model,
"max_tokens": 4096,
"messages": [ "messages": [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
@ -447,6 +377,7 @@ class WaterQualityReportGenerator:
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"model": self.minimax_vision_model, "model": self.minimax_vision_model,
"max_tokens": 4096,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
@ -518,123 +449,90 @@ class WaterQualityReportGenerator:
return self._ollama_chat(model, system_prompt, user_prompt, image_path) return self._ollama_chat(model, system_prompt, user_prompt, image_path)
def _get_prompt_for_image(self, image_type: str, param: str, figure_num: int) -> Dict[str, str]: def _get_prompt_for_image(self, image_type: str, param: str, figure_num: int) -> Dict[str, str]:
"""按图片类型返回 system/user 提示词,带防幻觉约束。""" """按图片类型返回 system/user 提示词,注入水质遥感专家级约束。"""
system = ( system = (
"你是一位水质遥感与机器学习建模专家\n" "你是一位资深的水环境遥感与水生态学专家。现需为一份高光谱水质参数反演报告撰写专业分析\n"
"研究背景:我们利用高光谱影像数据,结合机器学习算法对研究区的水质参数进行了空间反演,并生成了以下图表。" "【绝对禁忌】:严禁写“看图说话”式的废话(如“曲线先升后降”、“柱子集中在中间”)。\n"
"现需要撰写自动化分析报告,请严格按照“图表类型→分析重点”的对应关系进行描述。\n\n" "【核心规范】:\n"
"分析要求:\n" "1. 必须结合【水色光学机理】和【水环境地学意义】进行解释。\n"
"1. 请严格基于图片中可见信息进行分析,禁止编造不存在的数值、区域名称、采样时间或结论\n" "2. 提及波长时,必须解释其对应的物理/生化意义(如叶绿素红光吸收谷、悬浮物散射峰、水体吸收特性等)\n"
"2. 如果图片无法支撑某项判断,必须明确写“根据本图无法判断”\n" "3. 分析浓度数值时,必须结合自然水体的常规背景值或富营养化状态进行定性评价(如“处于清洁水平”或“存在水华风险”)\n"
"3. 不允许引用图片之外的背景知识来补全细节" "4. 严格基于图中可见的规律,不编造图中没有的具体坐标或日期"
) )
# 为每种图表类型单独定义:分析要点 + 结论聚焦
type_specs = { type_specs = {
"histogram": { "histogram": {
"analysis": ( "analysis": (
"分析要点:\n" "分析要点:\n"
"- 分布形态:是左偏、右偏还是对称?是否存在多峰?\n" f"- 结合自然水体中 {param} 的常规阈值,评估该水域当前的整体水平(清洁、轻度污染或富营养化)。\n"
"- 集中范围:数据主要集中在哪个区间?(参照横轴和纵轴柱高)\n" "- 从生态学角度解释这种数值分布形态(如多峰分布可能暗示存在多个不同性质的污染源或水团交汇)。\n"
"- 离群值:是否有明显孤立于主体分布的小柱,位于何处?\n" "- 关注极端离群值,指出其可能代表的局部异常环境事件。"
"- 若图中包含拟合曲线,描述其形状(正态、指数等)。"
),
"conclusion": (
"结论应聚焦于:该参数的分布形态(如左偏/右偏/对称)、主要集中区间、是否存在极端离群值。"
"用一句话概括数据分布的核心特征,不推测成因。"
), ),
"conclusion": "结论应聚焦:该水质参数的整体健康水平评估及主要生态风险提示。",
}, },
"spectrum_comparison": { "spectrum_comparison": {
"analysis": ( "analysis": (
"分析要点:\n" "分析要点:\n"
"- 多条曲线的整体趋势是否一致?\n" f"- 结合 {param} 的固有光学特性重点分析400-900nm区间内的特征波段响应如吸收谷、反射峰、双峰效应等\n"
"- 在哪些波段(参照横轴波长位置)出现明显分离?\n" "- 对比不同浓度组别的光谱差异,说明浓度变化是如何改变水体对光吸收和后向散射规律的。\n"
"- 是否存在系统性的整体偏移(一条曲线全程高于另一条)?\n" "- 指出对该参数反演最具区分度的关键波段区间,验证模型的物理可解释性。"
"- 图中是否有阴影或误差带表示置信区间?若有,描述其范围。"
),
"conclusion": (
"结论应聚焦于:各光谱曲线的整体一致性、关键差异波段、是否存在系统性偏移。"
"用一句话概括光谱对比的主要特征,不推测物理原因。"
), ),
"conclusion": "结论应聚焦:浓度梯度引起的光谱响应规律及其对应的光学机制验证。",
}, },
"scatter_with_confidence": { "scatter_with_confidence": {
"analysis": ( "analysis": (
"分析要点:\n" "分析要点:\n"
"- 点云整体是否沿1:1线对角线分布\n" "- 评估机器学习反演模型在该参数上的鲁棒性。点云对1:1线的贴合度反映了反演精度。\n"
"- 点云在低值区/高值区是否存在系统性偏离(如整体偏上/偏下)?\n" "- 重点分析在极低值区或极高值区是否存在系统性高估/低估(这是水色遥感的常见难点,如高浓度下的光谱饱和效应)。\n"
"- 置信带(若存在)覆盖了多少点?是否所有点都在置信带内?\n" "- 结合置信带宽度,说明模型在不同浓度区间的预测不确定性。"
"- 是否有明显离群点(远离主体点云)?"
),
"conclusion": (
"结论应聚焦于模型预测精度点云与1:1线贴合程度、偏差方向、置信带覆盖情况。"
"用一句话评价模型性能,不推测误差来源。"
), ),
"conclusion": "结论应聚焦:反演模型的整体精度表现、局限性及可靠的浓度预测区间。",
}, },
"boxplot": { "boxplot": {
"analysis": ( "analysis": (
"分析要点:\n" "分析要点:\n"
"- 中位数(箱体中间线)的位置\n" "- 结合中位数和四分位距,分析不同类别(或区域)间水质差异的显著性\n"
"- 四分位间距(箱体高度)反映的离散程度\n" "- 解释离散程度大(箱体长)可能代表的强烈时空异质性\n"
"- whisker的长度是否超出1.5倍IQR的离群点用圆点/星号标示)。\n" "- 指出箱线图上下的离群点,探讨其作为局部水质突变信号的价值。"
"- 若多个箱线图并排,比较各组的中心趋势和离散度。"
),
"conclusion": (
"结论应聚焦于:各组的中心趋势(中位数)、离散程度(四分位距)、是否存在离群点。"
"用一句话概括数据分布的统计特征,若有多组则简述对比。"
), ),
"conclusion": "结论应聚焦:核心对比趋势及数据整体的时空变异特征。",
}, },
"distribution": { "distribution": {
"analysis": ( "analysis": (
"分析要点:\n" "分析要点:\n"
"- 高值区域:位于图中的哪个方位(如东北部、中部偏西、东南沿岸等)?呈现何种形状(斑块状、条带状、片状)?\n" f"- 分析 {param} 高值区与低值区的空间异质性特征。\n"
"- 低值区域:位置及形态\n" "- 推断污染/物质来源类型:高值区呈斑块状/点状(通常提示点源排放或局部水华),还是呈沿岸带状/梯度扩散(通常提示面源径流或水动力扩散)\n"
"- 梯度变化:是否存在明显的从某方位向另一方位递减或递增的趋势?\n" "- 结合常见水动力学特征,简述物质可能的输移趋势。"
"- 聚集特征:高值区是否成片聚集,还是零星散布?\n"
"注意:仅使用方位描述位置(如上、下、左、右、中心、边缘、沿岸等),禁止使用具体经纬度坐标或地名。"
),
"conclusion": (
"结论应聚焦于:高值区与低值区的空间方位、聚集形态、主要梯度方向。"
"用一句话概括空间分布格局,不推测污染源或成因。"
), ),
"conclusion": "结论应聚焦:水质参数的空间格局特征及其指示的宏观环境动力学过程。",
}, },
"correlation_heatmap": { "correlation_heatmap": {
"analysis": ( "analysis": (
"分析要点:\n" "分析要点:\n"
"- 各变量对之间的相关性强度:颜色深浅对应的相关系数大小(参照图例)\n" "- 挖掘关键水质参数间的生物地球化学联系。如叶绿素与总氮/总磷的正相关提示营养盐驱动,与浊度的正相关提示藻类为主导的悬浮物等\n"
"- 正相关与负相关:红色/蓝色分别代表正负(根据图例),描述主要的高正相关对和高负相关对\n" "- 识别拮抗作用(强负相关),并解释其潜在的生化机制(如高浊度遮蔽光照导致叶绿素降低)\n"
"- 若图中包含数值标注可提及范围如“大多数相关系数介于0.6~0.8”),但不得编造具体数字。\n" "- 基于相关性聚类,推断水体中的核心主导污染因子群。"
"- 若单元格颜色过于接近难以区分,则写“根据本图无法判断具体相关性强弱”。"
),
"conclusion": (
"结论应聚焦于:变量间相关性的整体强弱水平、最主要的正负相关对。"
"用一句话概括相关性矩阵的核心特征,不推测因果关系。"
), ),
"conclusion": "结论应聚焦:水质指标间的核心协同/拮抗机制及水环境的主要驱动力。",
}, },
} }
# 默认规格(如果类型未定义)
default_spec = { default_spec = {
"analysis": "重点:概括图中主要信息,列出可见的轴标签、图例、数据特征。", "analysis": "结合水环境遥感原理,深入解读图中展现的数据分布或空间格局特征。",
"conclusion": "结论应基于可见信息,概括图中主要趋势或数据特征,不添加外部知识", "conclusion": "结论应聚焦:该图表传递的核心水质遥感科学结论",
} }
spec = type_specs.get(image_type, default_spec) spec = type_specs.get(image_type, default_spec)
analysis_part = spec["analysis"]
conclusion_part = spec["conclusion"]
common_output = (
"输出格式:\n"
"请结合坐标轴、图例、曲线、点云、颜色条等可见元素,描述数据特征(如分布形态、对比关系、空间位置等),引用图中具体元素但不编造数值。"
"随后用一句话总结该图揭示的主要趋势或数据质量。总结必须严格基于前文描述的可见信息,不得引入图中未呈现的外部知识、推测原因或隐含假设。"
"若信息不足以得出明确结论,则写“根据本图无法得出明确结论”。"
"要求:直接输出分析内容,不要使用“第一段”“第二段”等标记,两段之间不要留空行。")
user = ( user = (
f"图号:图{figure_num}\n" f"图号:图{figure_num}\n"
f"参数:{param}\n" f"当前分析参数:{param}\n"
f"图类型:{image_type}\n\n" f"类型:{image_type}\n\n"
f"{analysis_part}\n\n" "【专业要求】:\n"
f"{common_output}" f"{spec['analysis']}\n\n"
"【输出格式】:\n"
"直接输出一段不要分段150~300字的专业分析。前半部分描述关键数据现象并深挖其光学或生态机制最后用一句“总之…”作为全文的科学性总结。\n"
f"【最终落脚点要求】:{spec['conclusion']}\n"
) )
return {"system": system, "user": user} return {"system": system, "user": user}
@ -735,13 +633,20 @@ class WaterQualityReportGenerator:
self._save_ai_cache(cache) self._save_ai_cache(cache)
return text return text
def _create_progress(self, total: int, desc: str = "进度"): def _create_progress(self, total: int, desc: str = "进度", on_step=None):
"""创建进度条:优先 tqdm否则使用简单进度条。""" """创建进度条:优先 tqdm否则使用简单进度条。
Args:
on_step: 可选回调,签名 on_step(percent: int, text: str)。用于驱动 Qt QProgressBar / QThread 进度信号。
"""
# 如果有 UI 回调需求,强制使用自带的 _SimpleProgress防止 tqdm 吞掉信号
if on_step is not None:
return _SimpleProgress(total=total, desc=desc, on_step=on_step)
try: try:
from tqdm import tqdm # type: ignore from tqdm import tqdm # type: ignore
return tqdm(total=total, desc=desc, unit="", ncols=90) return tqdm(total=total, desc=desc, unit="", ncols=90)
except Exception: except Exception:
return _SimpleProgress(total=total, desc=desc) return _SimpleProgress(total=total, desc=desc, on_step=on_step)
def _analyze_statistics(self, stats_data: List[Dict[str, Any]], param_names: List[str]) -> str: def _analyze_statistics(self, stats_data: List[Dict[str, Any]], param_names: List[str]) -> str:
"""对水质参数统计数据进行 AI 分析""" """对水质参数统计数据进行 AI 分析"""
@ -772,10 +677,15 @@ class WaterQualityReportGenerator:
work_dir: str = None, work_dir: str = None,
parameters: List[str] = None, parameters: List[str] = None,
report_title: str = "水质参数反演分析报告", report_title: str = "水质参数反演分析报告",
output_path: Optional[str] = None) -> str: output_path: Optional[str] = None,
on_progress=None) -> str:
""" """
生成 Word 报告 - 所有数据均来自工作目录work_dir 生成 Word 报告 - 所有数据均来自工作目录work_dir
可视化图片、统计数据等均从 work_dir/14_visualization 和 work_dir/4_processed_data 中读取 可视化图片、统计数据等均从 work_dir/14_visualization 和 work_dir/4_processed_data 中读取
Args:
on_progress: 可选回调,签名 on_progress(percent: int, text: str)。
会在进度更新时被调用,用于驱动 Qt QProgressBar/QThread 信号。
""" """
# 设置工作目录(整个流程的核心) # 设置工作目录(整个流程的核心)
if work_dir is not None: if work_dir is not None:
@ -805,7 +715,7 @@ class WaterQualityReportGenerator:
# 进度条(按“图片处理 + 汇总”计步) # 进度条(按“图片处理 + 汇总”计步)
total_images = sum(len(self.parameter_images.get(p, [])) for p in parameters) total_images = sum(len(self.parameter_images.get(p, [])) for p in parameters)
total_steps = total_images + 1 + 1 # +1 相关性热力图(尝试一次),+1 综合总结 total_steps = total_images + 1 + 1 # +1 相关性热力图(尝试一次),+1 综合总结
progress = self._create_progress(total=total_steps, desc="生成Word报告") progress = self._create_progress(total=total_steps, desc="生成Word报告", on_step=on_progress)
# 创建文档 # 创建文档
doc = Document() doc = Document()
@ -847,6 +757,7 @@ class WaterQualityReportGenerator:
base_section_num = 5 base_section_num = 5
last_param_section_num = base_section_num + len(parameters) - 1 last_param_section_num = base_section_num + len(parameters) - 1
for section_num, param in enumerate(parameters, base_section_num): for section_num, param in enumerate(parameters, base_section_num):
progress.set_description(f"正在分析 {param} 数据 ({section_num - base_section_num + 1}/{len(parameters)})")
figure_counter = self._add_parameter_section( figure_counter = self._add_parameter_section(
doc, doc,
param, param,
@ -873,15 +784,19 @@ class WaterQualityReportGenerator:
] ]
) )
system = ( system = (
"你是一位水质遥感与报告撰写专家。" "你是一位水环境管理决策专家与遥感首席科学家。现需根据前面生成的各参数逐图分析文本,提炼出一份执行摘要级别的综合结论。\n"
"只能基于提供的“逐图分析文本”做总结,禁止引入任何外部事实或猜测" "必须具备宏观视角,能够将离散的参数分析整合成对该水域整体健康状况的系统性诊断"
"若信息不足,必须明确说明“根据现有分析无法判断”。"
) )
user = ( user = (
"以下是逐图分析文本,请给出报告级别的综合总结,要求:\n" "以下是各个水质参数的详尽逐图分析文本,请基于此撰写一份最终的综合分析总结。\n"
"- 150~300字中文\n" "【内容结构需包含】:\n"
"- 结构:总体概况 / 主要异常或热点 / 参数间关系(如有)/ 建议关注点\n" "1. 整体水质评估(如营养状态、主要污染程度)。\n"
"- 不要编造具体数值、地名、日期\n\n" "2. 关键时空热点与驱动因子(最需关注的高值区域及核心主导参数)。\n"
"3. 遥感反演模型可靠性综合评价。\n"
"4. 宏观水环境管理与保护建议。\n"
"【⚠️强制要求】:\n"
"- 总结的字数必须严格控制在 300 到 450 字之间!\n"
"- 必须输出完整的结尾标点符号,绝不允许出现话说一半突然截断的情况!高度精炼,切勿啰嗦。\n\n"
f"{analyses_text}" f"{analyses_text}"
) )
summary_text = self._ai_chat(self.ollama_text_model, system, user, image_path=None) summary_text = self._ai_chat(self.ollama_text_model, system, user, image_path=None)
@ -959,30 +874,47 @@ class WaterQualityReportGenerator:
for i, img_name in enumerate(image_list): for i, img_name in enumerate(image_list):
figure_num = start_figure_num + i figure_num = start_figure_num + i
# 选择子文件夹
if "boxplot" in img_name.lower():
sub_dir = vis_dir / "boxplots"
title_key = "boxplot"
elif "scatter" in img_name.lower() or "confidence" in img_name.lower():
sub_dir = vis_dir / "scatter_plots"
title_key = "scatter_with_confidence"
elif "histogram" in img_name.lower():
sub_dir = vis_dir
title_key = "histogram"
elif "spectrum" in img_name.lower():
sub_dir = vis_dir
title_key = "spectrum_comparison"
elif "distribution" in img_name.lower():
sub_dir = vis_dir
title_key = "distribution"
else:
sub_dir = vis_dir
title_key = "histogram"
img_path = sub_dir / img_name # 选择子文件夹与动态寻址
if "boxplot" in img_name.lower():
title_key = "boxplot"
img_path = vis_dir / img_name
elif "scatter" in img_name.lower() or "pred" in img_name.lower() or "confidence" in img_name.lower():
title_key = "scatter_with_confidence"
img_path = vis_dir / "scatter_plots" / img_name
if not img_path.exists(): if not img_path.exists():
img_path = vis_dir / img_name img_path = vis_dir / img_name
elif "histogram" in img_name.lower():
title_key = "histogram"
img_path = vis_dir / img_name
elif "spectrum" in img_name.lower():
title_key = "spectrum_comparison"
img_path = vis_dir / img_name
elif "distribution" in img_name.lower():
title_key = "distribution"
# 颜色地图由预测步骤生成,开启全盘指索
search_dirs = [
vis_dir.parent / "9_water_quality_prediction",
vis_dir.parent / "11_12_13_predictions",
vis_dir.parent / "9_Concentration" / "charts",
vis_dir.parent / "9_Concentration",
vis_dir
]
found_map = False
for s_dir in search_dirs:
if s_dir.exists():
candidates = list(s_dir.glob(f"*{param}*.png")) + list(s_dir.glob(f"*{param}*.jpg"))
# 剔除掉属于其他类型的图
candidates = [c for c in candidates if not any(x in c.name.lower() for x in ("scatter", "histogram", "spectrum", "boxplot", "preview"))]
if candidates:
img_path = candidates[0]
found_map = True
break
if not found_map:
img_path = vis_dir / img_name
else:
title_key = "histogram"
img_path = vis_dir / img_name
if img_path.exists(): if img_path.exists():
param_cn = param.replace("Chlorophyll", "叶绿素").replace("NO3-N", "硝酸盐氮").replace("NH3-N", "氨氮") param_cn = param.replace("Chlorophyll", "叶绿素").replace("NO3-N", "硝酸盐氮").replace("NH3-N", "氨氮")
cn_title = title_map.get(title_key, "分析图") cn_title = title_map.get(title_key, "分析图")
@ -1245,7 +1177,7 @@ class WaterQualityReportGenerator:
vis_dir = self.visualization_dir vis_dir = self.visualization_dir
# 0. 航线规划图 # 0. 航线规划图
flight_path_img_path = work_dir_path / "14_visualization" / "flight_maps" flight_path_img_path = work_dir_path / "14_visualization" / "flight_paths"
h3 = doc.add_heading("航线规划:", level=3) h3 = doc.add_heading("航线规划:", level=3)
self._style_heading(h3, level=3) self._style_heading(h3, level=3)
@ -1636,7 +1568,7 @@ class WaterQualityReportGenerator:
# 从工作目录的4_processed_data文件夹查找CSV文件 # 从工作目录的4_processed_data文件夹查找CSV文件
work_dir_path = vis_dir.parent work_dir_path = vis_dir.parent
processed_data_dir = work_dir_path / "4_processed_data" processed_data_dir = work_dir_path / "5_Data_Cleaning"
if not processed_data_dir.exists(): if not processed_data_dir.exists():
doc.add_paragraph(f"未找到数据处理目录: {processed_data_dir}") doc.add_paragraph(f"未找到数据处理目录: {processed_data_dir}")

View File

@ -328,31 +328,40 @@ class WaterQualityVisualization:
plt.close() plt.close()
output_paths['boxplot'] = str(boxplot_path) output_paths['boxplot'] = str(boxplot_path)
# 2. 直方图 (每个水质参数列)
# 2. 直方图与单参数箱线图 (每个水质参数列)
for col in numeric_cols: for col in numeric_cols:
fig, ax = plt.subplots(figsize=(10, 6))
data = df[col].dropna() data = df[col].dropna()
if len(data) > 0:
# === 直方图生成 ===
fig, ax = plt.subplots(figsize=(10, 6))
if len(data) > 1: if len(data) > 1:
ax.hist(data, bins=30, edgecolor='black', alpha=0.7, color='skyblue') ax.hist(data, bins=30, edgecolor='black', alpha=0.7, color='skyblue')
ax.set_xlabel(f'{col} 数值', fontsize=12, fontweight='bold') ax.set_xlabel(f'{col} 数值', fontsize=12, fontweight='bold')
ax.set_ylabel('频数', fontsize=12, fontweight='bold') ax.set_ylabel('频数', fontsize=12, fontweight='bold')
ax.set_title(f'{col} 分布直方图', fontsize=14, fontweight='bold') ax.set_title(f'{col} 分布直方图', fontsize=14, fontweight='bold')
ax.grid(True, alpha=0.3, axis='y') ax.grid(True, alpha=0.3, axis='y')
# 添加统计信息
mean_val = data.mean() mean_val = data.mean()
std_val = data.std() if len(data) > 1 else 0
ax.axvline(mean_val, color='red', linestyle='--', linewidth=2, label=f'均值: {mean_val:.4f}') ax.axvline(mean_val, color='red', linestyle='--', linewidth=2, label=f'均值: {mean_val:.4f}')
ax.legend() ax.legend()
plt.tight_layout() plt.tight_layout()
safe_name = "".join(c for c in col if c.isalnum() or c in ('-', '_', '.')) safe_name = "".join(c for c in col if c.isalnum() or c in ('-', '_', '.'))
hist_path = output_dir / f"{safe_name}_histogram.png" hist_path = output_dir / f"{safe_name}_histogram.png"
plt.savefig(hist_path, dpi=300, bbox_inches='tight') plt.savefig(hist_path, dpi=300, bbox_inches='tight')
plt.close() plt.close(fig)
output_paths[f'histogram_{col}'] = str(hist_path) output_paths[f'histogram_{col}'] = str(hist_path)
# === 新增:单参数箱线图生成 ===
fig_box, ax_box = plt.subplots(figsize=(6, 8))
ax_box.boxplot([data], labels=[col])
ax_box.set_ylabel('数值', fontsize=12, fontweight='bold')
ax_box.set_title(f'{col} 箱线图', fontsize=14, fontweight='bold')
ax_box.grid(True, alpha=0.3, axis='y')
plt.tight_layout()
box_path = output_dir / f"{safe_name}_boxplot.png"
plt.savefig(box_path, dpi=300, bbox_inches='tight')
plt.close(fig_box)
output_paths[f'boxplot_{col}'] = str(box_path)
# 3. 相关性热力图 # 3. 相关性热力图
if len(numeric_cols) >= 2: if len(numeric_cols) >= 2:
corr_matrix = df[numeric_cols].corr() corr_matrix = df[numeric_cols].corr()