测试修改
This commit is contained in:
@ -1,14 +1,24 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
依赖订阅混入模块
|
||||
依赖订阅混入模块(架构解耦版)
|
||||
|
||||
新规则:dependencies 字典的键名 = 下游目标控件的真实属性名,
|
||||
第三元素(source_attr)= 上游源控件的真实属性名。
|
||||
|
||||
提供 subscribe_panel_to_dependencies() 函数,让步骤面板根据
|
||||
PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus
|
||||
订阅 OutputUpdated 事件。当上游步骤产出落地时,面板自动将路径
|
||||
填入对应的 FileSelectWidget,无需主窗口手工传导。
|
||||
|
||||
内含:
|
||||
- 自动识别下游 widget(按 dict 键名查找)
|
||||
- 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径
|
||||
- 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from src.gui.core.event_bus import global_event_bus
|
||||
|
||||
|
||||
@ -19,18 +29,21 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies):
|
||||
匹配时,自动将路径填入面板对应的 FileSelectWidget。
|
||||
|
||||
Args:
|
||||
panel: 步骤面板实例(QWidget 子类)
|
||||
panel: 步骤面板实例(QWidget 子类),即下游目标面板
|
||||
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:
|
||||
return
|
||||
|
||||
for _input_field, (dep_step, output_type, panel_attr) in dependencies.items():
|
||||
_make_subscription(panel, dep_step, output_type, panel_attr)
|
||||
# 注意:我们使用字典的键名 (_input_field) 作为唯一的下游目标框名查找依据
|
||||
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):
|
||||
@ -39,7 +52,7 @@ def _make_subscription(panel, dep_step, output_type, panel_attr):
|
||||
if data.get('output_type') != output_type:
|
||||
return
|
||||
|
||||
widget = getattr(panel, panel_attr, None)
|
||||
widget = getattr(panel, target_widget_name, None)
|
||||
if widget is None:
|
||||
return
|
||||
|
||||
@ -56,6 +69,10 @@ def _make_subscription(panel, dep_step, output_type, panel_attr):
|
||||
if not path:
|
||||
return
|
||||
|
||||
# 智能转换:如果要的是目录,但来的是文件,自动截取父目录
|
||||
if 'dir' in target_widget_name.lower() and os.path.isfile(path):
|
||||
path = os.path.dirname(path)
|
||||
|
||||
if hasattr(widget, 'set_path'):
|
||||
widget.set_path(path)
|
||||
elif hasattr(widget, 'setText'):
|
||||
|
||||
@ -169,11 +169,12 @@ class PanelFactory:
|
||||
if placeholder is not None and self._tab_widget is not None:
|
||||
tab_title = self._tab_widget.tabText(tab_index)
|
||||
tab_icon = self._tab_widget.tabIcon(tab_index)
|
||||
current_active = self._tab_widget.currentIndex() # 记住当前正在看的 Tab
|
||||
self._tab_widget.blockSignals(True)
|
||||
try:
|
||||
self._tab_widget.removeTab(tab_index)
|
||||
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:
|
||||
self._tab_widget.blockSignals(False)
|
||||
|
||||
@ -226,33 +227,40 @@ class PanelFactory:
|
||||
self._replay_live_panel_inputs()
|
||||
|
||||
def _replay_live_panel_inputs(self):
|
||||
"""遍历 PANEL_REGISTRY 依赖声明,从已加载面板实时读取属性值。
|
||||
"""遍历 PANEL_REGISTRY 依赖声明,从已加载面板实时读取属性值并强制广播。
|
||||
|
||||
若源面板已实例化,读取其 widget 的当前值并发布为 OutputUpdated,
|
||||
确保懒加载面板能收到全局输入(如 Step1.img_file → reference_img)。
|
||||
架构解耦(2026-06-22):第三个元素 source_attr 现在明确代表上游控件的真实名字,
|
||||
不再混用语义。回放端仅依赖 source_attr 在 SOURCE 面板上能命中 widget。
|
||||
"""
|
||||
from src.gui.core.event_bus import global_event_bus
|
||||
for entry in self._registry:
|
||||
deps = entry.get('dependencies')
|
||||
if not deps:
|
||||
continue
|
||||
for _input_field, (dep_step, output_type, panel_attr) in deps.items():
|
||||
if not deps: continue
|
||||
|
||||
# 第三个元素 source_attr 现在明确代表上游控件的真实名字
|
||||
for target_field, (dep_step, output_type, source_attr) in deps.items():
|
||||
src_panel = self._panels.get(dep_step)
|
||||
if src_panel is None:
|
||||
continue
|
||||
widget = getattr(src_panel, panel_attr, None)
|
||||
if widget is None:
|
||||
continue
|
||||
path = ''
|
||||
if src_panel is None: continue
|
||||
|
||||
widget = getattr(src_panel, source_attr, None)
|
||||
if widget is None: continue
|
||||
|
||||
path = ""
|
||||
if hasattr(widget, 'get_path'):
|
||||
path = widget.get_path().strip()
|
||||
elif hasattr(widget, 'text'):
|
||||
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', {
|
||||
'step_id': dep_step,
|
||||
'output_type': output_type,
|
||||
'path': path,
|
||||
'path': absolute_path,
|
||||
})
|
||||
|
||||
def _get_current_work_dir(self):
|
||||
|
||||
@ -48,14 +48,13 @@ PANEL_REGISTRY = [
|
||||
'icon': '2.png',
|
||||
'stage': '阶段一:影像预处理',
|
||||
'display_name': '2. 耀斑区域识别',
|
||||
# 对账修复(2026-06-18):
|
||||
# - img_path: 来源 step1.img_file、目标 step2.img_file ✓ 两端均存在
|
||||
# - water_mask_path: 原 panel_attr='water_mask_file' 在 step1 panel 不存在(断链)
|
||||
# → 改为 step1 真实控件名 'mask_file'(step1 默认现有掩膜输入模式)
|
||||
# → NDWI 模式由 step2.update_from_config 自补足,不依赖 EventBus 链路
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
|
||||
# 第三元素 source_attr = 上游源控件真实属性名(panel_factory 回放端使用)
|
||||
'dependencies': {
|
||||
'img_path': ('step1', 'reference_img', 'img_file'),
|
||||
'water_mask_path': ('step1', 'water_mask', 'mask_file'),
|
||||
# 目标框: self.img_file ← 上游 step1.img_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,
|
||||
},
|
||||
@ -66,14 +65,12 @@ PANEL_REGISTRY = [
|
||||
'icon': '3.png',
|
||||
'stage': '阶段一:影像预处理',
|
||||
'display_name': '3. 耀斑去除与修复',
|
||||
# 对账修复(2026-06-18):
|
||||
# - 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 自补足
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
|
||||
'dependencies': {
|
||||
'img_path': ('step1', 'reference_img', 'img_file'),
|
||||
'water_mask': ('step1', 'water_mask', 'mask_file'),
|
||||
# 目标框: self.img_file ← 上游 step1.img_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,
|
||||
},
|
||||
@ -88,14 +85,13 @@ PANEL_REGISTRY = [
|
||||
'icon': '4.png',
|
||||
'stage': '阶段二:样本数据准备',
|
||||
'display_name': '4. 采样点布设',
|
||||
# 对账修复(2026-06-18):
|
||||
# - deglint_img_path: 原 panel_attr='deglint_img_file' 在 step3 panel 不存在(断链)
|
||||
# → step3 输出 widget 真实名为 'output_file'(deglint_image.bsq)
|
||||
# - water_mask_path: 原 'water_mask_file' 在 step1 panel 不存在
|
||||
# → 改为 step1 真实控件名 'mask_file'
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
|
||||
# 第三元素 source_attr = 上游源控件真实属性名(panel_factory 回放端使用)
|
||||
'dependencies': {
|
||||
'deglint_img_path': ('step3', 'deglint_image', 'output_file'),
|
||||
'water_mask_path': ('step1', 'water_mask', 'mask_file'),
|
||||
# 目标框: self.deglint_img_file ← 上游 step3.output_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,
|
||||
},
|
||||
@ -117,18 +113,16 @@ PANEL_REGISTRY = [
|
||||
'icon': '6.png',
|
||||
'stage': '阶段二:样本数据准备',
|
||||
'display_name': '6. 光谱特征提取',
|
||||
# 对账修复(2026-06-18):
|
||||
# - 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 有,无修改
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
|
||||
'dependencies': {
|
||||
'deglint_img_path': ('step3', 'deglint_image', 'output_file'),
|
||||
'csv_path': ('step5_clean', 'processed_data', 'csv_file'),
|
||||
'boundary_mask_path': ('step1', 'water_mask', 'mask_file'),
|
||||
'glint_mask_path': ('step2', 'glint_mask', 'glint_mask_file'),
|
||||
# 目标框: self.deglint_img_file ← 上游 step3.output_file
|
||||
'deglint_img_file': ('step3', 'deglint_image', 'output_file'),
|
||||
# 目标框: self.csv_file ← 上游 step5_clean.csv_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,
|
||||
},
|
||||
@ -139,18 +133,11 @@ PANEL_REGISTRY = [
|
||||
'icon': '7.png',
|
||||
'stage': '阶段二:样本数据准备',
|
||||
'display_name': '7. 水质指数计算',
|
||||
# 对账修复(2026-06-18):
|
||||
# - training_csv_path:
|
||||
# 原 ('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'(双源对账一致)
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
|
||||
# 第三元素 source_attr = 上游源控件真实属性名
|
||||
'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,
|
||||
},
|
||||
@ -165,13 +152,9 @@ PANEL_REGISTRY = [
|
||||
'icon': '8.png',
|
||||
'stage': '阶段三:模型构建与训练',
|
||||
'display_name': '8. 机器学习建模',
|
||||
# 对账修复(2026-06-18):
|
||||
# - 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
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
|
||||
'dependencies': {
|
||||
# 目标框: self.training_csv_file ← 上游 step7_index.training_data_widget
|
||||
'training_csv_file': ('step7_index', 'training_spectra_indices', 'training_data_widget'),
|
||||
},
|
||||
'constructor_kwargs': None,
|
||||
@ -187,14 +170,11 @@ PANEL_REGISTRY = [
|
||||
'icon': '10.png',
|
||||
'stage': '阶段四:预测与成果输出',
|
||||
'display_name': '9. 机器学习预测',
|
||||
# 对账修复(2026-06-18):
|
||||
# - models_dir:
|
||||
# 原 ('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
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
|
||||
# 第三元素 source_attr = 上游源控件真实属性名
|
||||
'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,
|
||||
},
|
||||
@ -205,13 +185,12 @@ PANEL_REGISTRY = [
|
||||
'icon': '10.png',
|
||||
'stage': '阶段四:预测与成果输出',
|
||||
'display_name': '10. 水色指数反演',
|
||||
# 对账修复(2026-06-18):
|
||||
# - bsq_file:
|
||||
# 原 ('step3', 'deglint_image', 'bsq_file')
|
||||
# source panel_attr='bsq_file' 在 step3 panel 不存在(断链)
|
||||
# → step3 输出(deglint_image.bsq)真实控件为 'output_file'
|
||||
# target panel_attr='bsq_file' ✓ step10 panel 有 self.bsq_file
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
|
||||
# 第三元素 source_attr = 上游源控件真实属性名
|
||||
# step10 panel 仅有 bsq_file / hdr_file / output_dir,没有 water_mask widget,
|
||||
# 故删除 water_mask 依赖,避免悬挂回调
|
||||
'dependencies': {
|
||||
# 目标框: self.bsq_file ← 上游 step3.output_file
|
||||
'bsq_file': ('step3', 'deglint_image', 'output_file'),
|
||||
},
|
||||
'constructor_kwargs': None,
|
||||
@ -223,21 +202,14 @@ PANEL_REGISTRY = [
|
||||
'icon': '10.png',
|
||||
'stage': '阶段四:预测与成果输出',
|
||||
'display_name': '11. 专题图生成',
|
||||
# 对账修复(2026-06-18):
|
||||
# - 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
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
|
||||
'dependencies': {
|
||||
# 目标框: self.prediction_csv_dir_edit ← 上游 step9_ml_predict.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'),
|
||||
# 目标框: self.boundary_file ← 上游 step1.mask_file
|
||||
'boundary_file': ('step1', 'water_mask', 'mask_file'),
|
||||
},
|
||||
'constructor_kwargs': None,
|
||||
},
|
||||
|
||||
@ -30,7 +30,7 @@ import traceback
|
||||
from pathlib import Path
|
||||
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 src.gui.core.event_bus import global_event_bus
|
||||
@ -58,6 +58,11 @@ class PipelineExecutor(QObject):
|
||||
self._workspace_initializer = workspace_initializer
|
||||
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)
|
||||
|
||||
@ -303,6 +308,11 @@ class PipelineExecutor(QObject):
|
||||
global_event_bus.publish('LogMessage', {'message': '开始执行完整流程...', '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()
|
||||
|
||||
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'})
|
||||
|
||||
# ★ 启动看门狗:worker.start() 之后立刻开始监控(180s 超时)
|
||||
import time
|
||||
self._last_progress_time = time.time()
|
||||
self._watchdog_timer.start(5000) # 每 5 秒巡检一次
|
||||
|
||||
self._worker.start()
|
||||
|
||||
def stop_pipeline(self):
|
||||
@ -460,7 +475,12 @@ class PipelineExecutor(QObject):
|
||||
})
|
||||
|
||||
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', {
|
||||
'percentage': percentage,
|
||||
'message': message,
|
||||
@ -482,11 +502,40 @@ class PipelineExecutor(QObject):
|
||||
|
||||
主窗口订阅此事件,恢复按钮状态并弹窗。
|
||||
"""
|
||||
# ★ 停止看门狗:Worker 已正常收口,看门狗可以下班
|
||||
self._watchdog_timer.stop()
|
||||
|
||||
global_event_bus.publish('PipelineFinished', {
|
||||
'success': success,
|
||||
'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,
|
||||
})
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 内部辅助
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@ -14,8 +14,8 @@ import numpy as np
|
||||
|
||||
|
||||
def _viz_training_spectra_csv_path(work_path: Path) -> Path:
|
||||
"""可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤5输出一致)。"""
|
||||
return work_path / "5_training_spectra" / "training_spectra.csv"
|
||||
"""可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤6输出一致)。"""
|
||||
return work_path / "6_Spectral_Feature_Extraction" / "training_spectra.csv"
|
||||
|
||||
|
||||
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()
|
||||
models_dir = (self.extra.get("models_dir") or "").strip()
|
||||
if not training_csv_path or not Path(training_csv_path).is_file():
|
||||
self.failed.emit("训练光谱 CSV 无效或不存在,请确认已选择步骤5输出的文件。")
|
||||
self.failed.emit("训练光谱 CSV 无效或不存在,请确认已选择步骤6输出的文件。")
|
||||
return
|
||||
if not models_dir or not Path(models_dir).is_dir():
|
||||
self.failed.emit("模型目录无效或不存在,请确认步骤6已生成 7_Supervised_Model_Training 下的参数子文件夹。")
|
||||
@ -213,7 +213,7 @@ class VisualizationWorkerThread(QThread):
|
||||
if training_csv_path:
|
||||
training_csv = Path(training_csv_path)
|
||||
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 training_csv.is_file():
|
||||
|
||||
@ -241,13 +241,11 @@ class WorkspaceInitializer(QObject):
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
step1_panel = self._panel_factory.get_panel('step1')
|
||||
if step1_panel:
|
||||
step1_panel.update_work_directory(self._work_dir)
|
||||
pass
|
||||
|
||||
@ -22,39 +22,29 @@ from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
# 用户口语编号 / 业务别名 → main_window 上真实属性名的映射
|
||||
# 这是"张冠李戴"修复的核心——之前代码写的 step11_panel 实际不存在,
|
||||
# 真实存在的属性见 water_quality_gui.py:1891-1928
|
||||
# 用户口语编号 / 业务别名 → PANEL_REGISTRY 中真实 step_id 的映射
|
||||
# 这是"张冠李戴"修复的核心——main_window 上已不再直接挂载 panel 属性,
|
||||
# 所有面板都通过 _panel_factory.get_panel(step_id) 懒加载访问。
|
||||
STEP_DATA_SOURCE = {
|
||||
# 数据流 step 编号(用户口语) → main_window 真实属性
|
||||
'step5_clean_output': 'step5_clean_panel',
|
||||
'step7_index_output': 'step7_index_panel',
|
||||
'step8_ml_train_output': 'step8_ml_train_panel',
|
||||
'step8_5_non_empirical': 'step8_non_empirical_panel', # 之前写错成 step11_panel
|
||||
'step9_ml_predict_output': 'step9_ml_predict_panel',
|
||||
'step10_watercolor_output': 'step10_watercolor_panel',
|
||||
'step11_ml_prediction': 'step9_ml_predict_panel', # 主流程 step11 = ML 预测
|
||||
'step12_regression_prediction': 'step8_non_empirical_panel', # 主流程 step12 = 非经验预测
|
||||
'step13_custom_regression': 'step13_report_panel', # 占位(自定义回归本身没有专属 panel)
|
||||
'sampling_csv': 'step4_sampling_panel',
|
||||
'training_spectra_csv': 'step5_clean_panel',
|
||||
'indices_csv': 'step7_index_panel',
|
||||
'models_dir': 'step8_ml_train_panel',
|
||||
'watercolor_dir': 'step10_watercolor_panel',
|
||||
'prediction_csv_dir': 'step9_ml_predict_panel', # 默认从 ML 预测读
|
||||
# 数据流 step 编号(用户口语) → PANEL_REGISTRY 中的 step_id
|
||||
'step5_clean_output': 'step5_clean',
|
||||
'step7_index_output': 'step7_index',
|
||||
'step8_ml_train_output': 'step8_ml_train',
|
||||
'step8_5_non_empirical': 'step8_ml_train',
|
||||
'step9_ml_predict_output': 'step9_ml_predict',
|
||||
'step10_watercolor_output': 'step10_watercolor',
|
||||
'step11_ml_prediction': 'step9_ml_predict', # 主流程 step11 = ML 预测
|
||||
'step12_regression_prediction': 'step8_ml_train', # 主流程 step12 = 非经验预测
|
||||
'step13_custom_regression': 'step13_report', # 自定义回归借用 step13 报告面板
|
||||
'sampling_csv': 'step4_sampling',
|
||||
'training_spectra_csv': 'step5_clean',
|
||||
'indices_csv': 'step7_index',
|
||||
'models_dir': 'step8_ml_train',
|
||||
'watercolor_dir': 'step10_watercolor',
|
||||
'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:
|
||||
"""统一从 widget 读 path(兼容 FileSelectWidget / QLineEdit / 字符串)。"""
|
||||
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'):
|
||||
"""根据业务 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:
|
||||
widget 对象 or None(找不到时返回 None,调用方需自行兜底)
|
||||
"""
|
||||
attr_name = STEP_DATA_SOURCE.get(step_key)
|
||||
if attr_name is None:
|
||||
step_id = STEP_DATA_SOURCE.get(step_key)
|
||||
if not step_id:
|
||||
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 = {
|
||||
|
||||
@ -489,146 +489,43 @@ class Step11MapPanel(QWidget):
|
||||
self.output_dir.set_path(str(p.parent))
|
||||
|
||||
def update_from_config(self, work_dir=None, pipeline=None):
|
||||
"""从全局配置自动填充预测结果目录
|
||||
if work_dir:
|
||||
self.work_dir = work_dir
|
||||
|
||||
main_window = self.window()
|
||||
factory = getattr(main_window, '_panel_factory', None) if main_window else None
|
||||
if not factory: return
|
||||
|
||||
优先使用 Step8(机器学习预测)的输出目录作为待预测 CSV 目录;
|
||||
其次回退到 Step8.5(回归预测)或 Step8.75(自定义回归预测)的输出目录。
|
||||
# 1. 安全抓取 Step 9 的预测 CSV 目录
|
||||
step9_panel = factory.get_panel('step9_ml_predict')
|
||||
if step9_panel and hasattr(step9_panel, 'output_file'):
|
||||
path = step9_panel.output_file.get_path()
|
||||
if path:
|
||||
self.prediction_csv_dir_edit.setText(path)
|
||||
self.mode_folder_rb.setChecked(True)
|
||||
|
||||
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
|
||||
# 2. 安全抓取 Step 1 的真实掩膜文件(彻底拒绝瞎猜 roi.shp)
|
||||
step1_panel = factory.get_panel('step1')
|
||||
if step1_panel:
|
||||
use_ndwi = step1_panel.use_ndwi_radio.isChecked()
|
||||
# 根据用户在第1步的选择,拿真实的输出掩膜或导入的掩膜
|
||||
if use_ndwi and hasattr(step1_panel, 'output_file'):
|
||||
path = step1_panel.output_file.get_path()
|
||||
elif not use_ndwi and hasattr(step1_panel, 'mask_file'):
|
||||
path = step1_panel.mask_file.get_path()
|
||||
else:
|
||||
self.work_dir = None
|
||||
path = ""
|
||||
|
||||
existing = self.boundary_file.get_path()
|
||||
if path and not existing:
|
||||
self.boundary_file.set_path(path)
|
||||
|
||||
main_window = self.window()
|
||||
if not main_window:
|
||||
return
|
||||
|
||||
# 1. 优先:从 Step9(机器学习预测)读输出目录,9_ML_Prediction 子目录
|
||||
# 修复张冠李戴:原 main_window.step11_prediction_panel 不存在,真实属性是 step9_ml_predict_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:
|
||||
# 提取父目录后追加 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 4.5. 自动探测 Step1 水体掩膜(修复张冠李戴:原仅找 roi.shp,找不到时未尝试 1_water_mask)
|
||||
# 优先调用 main_window.pipeline.get_step_output_dir('step1')(数据真实来源)
|
||||
# 兜底走 resolve_subdir('water_mask') → <work_dir>/1_water_mask
|
||||
# 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()
|
||||
if not existing_boundary and water_mask_dir and os.path.isdir(water_mask_dir):
|
||||
# 优先 .shp(geopandas 读矢量最稳),其次 .dat
|
||||
mask_candidates = (
|
||||
sorted(Path(water_mask_dir).glob("*.shp"))
|
||||
+ sorted(Path(water_mask_dir).glob("*.dat"))
|
||||
)
|
||||
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.shp,geopandas.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 输出的水色指数 GeoTIFF(GeoTIFF 渲染模式)
|
||||
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()
|
||||
# 3. 生成第 11 步的绝对输出目录 (杜绝保存到相对路径)
|
||||
if hasattr(self, 'work_dir') and self.work_dir:
|
||||
import os
|
||||
out_dir = os.path.join(self.work_dir, "14_visualization").replace('\\', '/')
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
self.output_dir.set_path(out_dir)
|
||||
|
||||
def browse_output_dir(self):
|
||||
"""浏览输出目录"""
|
||||
@ -683,13 +580,10 @@ class Step11MapPanel(QWidget):
|
||||
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
|
||||
# 获取顶层主窗口(用于弹窗或直接调用)
|
||||
main_win = self.window()
|
||||
# 修正:将后面代码中所有的 parent 替换为 main_win
|
||||
parent = main_win
|
||||
|
||||
if self.mode_folder_rb.isChecked():
|
||||
# -------- CSV 插值批量 --------
|
||||
@ -827,22 +721,23 @@ class Step11MapPanel(QWidget):
|
||||
return
|
||||
|
||||
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):
|
||||
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")
|
||||
main_win = self.window()
|
||||
if main_win and hasattr(main_win, "log_message"):
|
||||
main_win.log_message(f"专题图批量完成,共 {n} 个文件。", "info")
|
||||
|
||||
def _on_step10_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")
|
||||
main_win = self.window()
|
||||
if main_win and hasattr(main_win, "log_message"):
|
||||
main_win.log_message(err, "error")
|
||||
|
||||
@ -36,12 +36,12 @@ PIPELINE_AVAILABLE = True
|
||||
|
||||
|
||||
def _viz_training_spectra_csv_path(work_path: Path) -> Path:
|
||||
"""可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤5输出一致)。
|
||||
"""可视化光谱/统计及模型散点图使用的训练光谱表路径(与步骤6输出一致)。
|
||||
|
||||
注意:步骤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]:
|
||||
@ -242,7 +242,7 @@ class VisualizationWorkerThread(QThread):
|
||||
if training_csv_path:
|
||||
training_csv = Path(training_csv_path)
|
||||
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 training_csv.is_file():
|
||||
@ -1697,9 +1697,9 @@ class Step12VizPanel(QWidget):
|
||||
}
|
||||
main_window = self.window()
|
||||
factory = getattr(main_window, '_panel_factory', None) if main_window else None
|
||||
step5_panel = factory.get_panel('step5_clean') if factory else None
|
||||
if step5_panel and getattr(step5_panel, 'output_file', None):
|
||||
_resolved_csv = step5_panel.output_file.get_path()
|
||||
step6_panel = factory.get_panel('step6_feature') if factory else None
|
||||
if step6_panel and getattr(step6_panel, 'output_file', None):
|
||||
_resolved_csv = step6_panel.output_file.get_path()
|
||||
if _resolved_csv:
|
||||
extra["training_csv_path"] = _resolved_csv
|
||||
step8_panel = factory.get_panel('step8_ml_train') if factory else None
|
||||
@ -1721,17 +1721,17 @@ class Step12VizPanel(QWidget):
|
||||
try:
|
||||
main_window = self.window()
|
||||
factory = getattr(main_window, '_panel_factory', None) if main_window else None
|
||||
step5_panel = factory.get_panel('step5_clean') if factory else None
|
||||
if step5_panel and getattr(step5_panel, 'output_file', None) and step5_panel.output_file.get_path():
|
||||
training_spectra_csv = Path(step5_panel.output_file.get_path())
|
||||
step6_panel = factory.get_panel('step6_feature') if factory else None
|
||||
if step6_panel and getattr(step6_panel, 'output_file', None) and step6_panel.output_file.get_path():
|
||||
training_spectra_csv = Path(step6_panel.output_file.get_path())
|
||||
else:
|
||||
training_spectra_csv = _viz_training_spectra_csv_path(work_path)
|
||||
if chart_type == 'scatter':
|
||||
if not training_spectra_csv.is_file():
|
||||
QMessageBox.warning(
|
||||
self, "警告",
|
||||
"未找到 5_training_spectra\\training_spectra.csv。\n"
|
||||
"请先执行步骤5(光谱特征提取)生成该文件。",
|
||||
"未找到 6_Spectral_Feature_Extraction\\training_spectra.csv。\n"
|
||||
"请先执行步骤6(光谱特征提取)生成该文件。",
|
||||
)
|
||||
return
|
||||
training_csv = training_spectra_csv
|
||||
@ -1751,8 +1751,8 @@ class Step12VizPanel(QWidget):
|
||||
if not training_spectra_csv.is_file():
|
||||
QMessageBox.warning(
|
||||
self, "警告",
|
||||
"未找到 5_training_spectra\\training_spectra.csv。\n"
|
||||
"光谱分析固定使用该文件,请先执行步骤5(光谱特征提取)。",
|
||||
"未找到 6_Spectral_Feature_Extraction\\training_spectra.csv。\n"
|
||||
"光谱分析固定使用该文件,请先执行步骤6(光谱特征提取)。",
|
||||
)
|
||||
return
|
||||
csv_file = training_spectra_csv
|
||||
@ -1775,8 +1775,8 @@ class Step12VizPanel(QWidget):
|
||||
if not training_spectra_csv.is_file():
|
||||
QMessageBox.warning(
|
||||
self, "警告",
|
||||
"未找到 5_training_spectra\\training_spectra.csv。\n"
|
||||
"统计分析固定使用该文件,请先执行步骤5(光谱特征提取)。",
|
||||
"未找到 6_Spectral_Feature_Extraction\\training_spectra.csv。\n"
|
||||
"统计分析固定使用该文件,请先执行步骤6(光谱特征提取)。",
|
||||
)
|
||||
return
|
||||
csv_file = training_spectra_csv
|
||||
|
||||
@ -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对象")
|
||||
@ -20,18 +20,23 @@ from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSettings
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
|
||||
QLabel, QCheckBox, QPushButton, QLineEdit,
|
||||
QMessageBox, QFileDialog,
|
||||
QMessageBox, QFileDialog, QProgressBar,
|
||||
)
|
||||
|
||||
from src.gui.styles import ModernStylesheet
|
||||
from src.gui.dialogs import AISettingsDialog, AI_SETTINGS_ORG, AI_SETTINGS_APP
|
||||
|
||||
|
||||
class ReportGenerateThread(QThread):
|
||||
"""后台生成 Word 报告(避免阻塞 UI)。"""
|
||||
finished_ok = pyqtSignal(str)
|
||||
failed = pyqtSignal(str)
|
||||
log_message = pyqtSignal(str, str)
|
||||
class ReportWorkerThread(QThread):
|
||||
"""后台生成 Word 报告(避免阻塞 UI)。
|
||||
三信号协议:
|
||||
- progress(int, 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):
|
||||
super().__init__()
|
||||
@ -44,7 +49,6 @@ class ReportGenerateThread(QThread):
|
||||
try:
|
||||
from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig
|
||||
|
||||
# 唯一数据源:直接从 QSettings 读取 AI 配置
|
||||
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
|
||||
provider = s.value("ai_provider", "minimax", type=str)
|
||||
timeout = int(s.value("timeout_s", 120, type=int))
|
||||
@ -68,10 +72,6 @@ class ReportGenerateThread(QThread):
|
||||
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(
|
||||
work_dir=self.work_dir,
|
||||
output_dir=self.output_dir,
|
||||
@ -80,10 +80,11 @@ class ReportGenerateThread(QThread):
|
||||
out_path = gen.generate_report(
|
||||
work_dir=self.work_dir,
|
||||
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:
|
||||
self.failed.emit(f"{e}\n{traceback.format_exc()}")
|
||||
self.error.emit(f"{e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
class Step13ReportPanel(QWidget):
|
||||
@ -117,11 +118,14 @@ class Step13ReportPanel(QWidget):
|
||||
|
||||
wd_row = QHBoxLayout()
|
||||
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.clicked.connect(self.browse_work_dir)
|
||||
wd_browse.setVisible(False)
|
||||
sync_btn = QPushButton("同步主窗口工作目录")
|
||||
sync_btn.clicked.connect(self.sync_work_dir_from_main)
|
||||
sync_btn.setVisible(False)
|
||||
wd_row.addWidget(self.work_dir_edit, 1)
|
||||
wd_row.addWidget(wd_browse)
|
||||
wd_row.addWidget(sync_btn)
|
||||
@ -179,12 +183,27 @@ class Step13ReportPanel(QWidget):
|
||||
btn_row.addStretch()
|
||||
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()
|
||||
self.setLayout(layout)
|
||||
|
||||
# 刷新引擎提示文字
|
||||
self._refresh_ai_label()
|
||||
|
||||
# 初次构建时尝试同步主窗口工作目录
|
||||
self._auto_pull_work_dir()
|
||||
|
||||
def _refresh_ai_label(self):
|
||||
"""从 QSettings 读取当前 Provider 并更新只读标签。"""
|
||||
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
|
||||
@ -229,6 +248,28 @@ class Step13ReportPanel(QWidget):
|
||||
if 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 同步到本面板。
|
||||
解耦手动 browse:work_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):
|
||||
"""返回路径和标题配置(AI 配置不由本面板持有)。"""
|
||||
return {
|
||||
@ -271,14 +312,15 @@ class Step13ReportPanel(QWidget):
|
||||
title = self.report_title_edit.text().strip() or "水质参数反演分析报告"
|
||||
enable_ai = self.enable_ai_cb.isChecked()
|
||||
|
||||
# 重置进度条并禁用按钮
|
||||
self.generate_btn.setEnabled(False)
|
||||
self._report_thread = ReportGenerateThread(wd, out, title, enable_ai)
|
||||
self._report_thread.log_message.connect(self._forward_log, Qt.QueuedConnection)
|
||||
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.finished.connect(
|
||||
lambda: self.generate_btn.setEnabled(True), Qt.QueuedConnection
|
||||
)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_label.setText("正在准备生成…")
|
||||
|
||||
self._report_thread = ReportWorkerThread(wd, out, title, enable_ai)
|
||||
self._report_thread.progress.connect(self._on_progress, 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._forward_log("已开始生成 Word 报告…", "info")
|
||||
|
||||
@ -289,10 +331,22 @@ class Step13ReportPanel(QWidget):
|
||||
else:
|
||||
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}")
|
||||
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]}")
|
||||
self._forward_log(err, "error")
|
||||
@ -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.shp,geopandas.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 输出的水色指数 GeoTIFF(GeoTIFF 渲染模式)
|
||||
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")
|
||||
@ -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)
|
||||
@ -100,13 +100,11 @@ class Step8MlTrainPanel(QWidget):
|
||||
self.create_ml_page()
|
||||
layout.addWidget(self.ml_page)
|
||||
|
||||
# 输出文件路径
|
||||
# 输出文件路径 (改为文件夹模式)
|
||||
self.output_path = FileSelectWidget(
|
||||
"输出文件:",
|
||||
"CSV Files (*.csv);;All Files (*.*)",
|
||||
mode="save"
|
||||
"模型输出目录:",
|
||||
"Directories"
|
||||
)
|
||||
self.output_path.line_edit.setPlaceholderText("自动生成,或手动指定输出文件路径...")
|
||||
self.output_path.browse_btn.clicked.disconnect()
|
||||
self.output_path.browse_btn.clicked.connect(self.browse_output_path)
|
||||
layout.addWidget(self.output_path)
|
||||
@ -276,28 +274,12 @@ class Step8MlTrainPanel(QWidget):
|
||||
return ""
|
||||
|
||||
def browse_output_path(self):
|
||||
"""浏览输出文件路径(保存对话框)"""
|
||||
current = self.output_path.get_path().strip()
|
||||
if current:
|
||||
initial_dir = os.path.dirname(current)
|
||||
initial_file = os.path.basename(current)
|
||||
else:
|
||||
initial_dir = ""
|
||||
initial_file = ""
|
||||
|
||||
if not initial_dir or not os.path.isdir(initial_dir):
|
||||
# 默认定位到 indices 目录
|
||||
work_dir = self._get_default_work_dir()
|
||||
initial_dir = 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)
|
||||
"""浏览输出模型目录"""
|
||||
work_dir = getattr(self, 'work_dir', "")
|
||||
initial_dir = os.path.join(work_dir, '8_Machine_Learning_Models') if work_dir else ""
|
||||
dir_path = QFileDialog.getExistingDirectory(self, "选择模型输出目录", initial_dir)
|
||||
if dir_path:
|
||||
self.output_path.set_path(dir_path)
|
||||
|
||||
def get_config(self):
|
||||
"""获取配置"""
|
||||
@ -381,20 +363,12 @@ class Step8MlTrainPanel(QWidget):
|
||||
if step6_training_csv:
|
||||
self.training_csv_file.set_path(step6_training_csv)
|
||||
|
||||
# 2. 自动填充输出文件路径(基于工作目录和输入文件名)
|
||||
# 输入是 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
|
||||
# 2. 自动填充输出目录为 8_Machine_Learning_Models
|
||||
if self.work_dir:
|
||||
indices_dir = resolve_subdir(self.work_dir, 'indices')
|
||||
os.makedirs(indices_dir, exist_ok=True)
|
||||
training_csv = self.training_csv_file.get_path()
|
||||
if training_csv:
|
||||
basename = os.path.splitext(os.path.basename(training_csv))[0]
|
||||
output_file = f"{basename}_indices.csv"
|
||||
else:
|
||||
output_file = "training_spectra_indices.csv"
|
||||
output_path = os.path.join(indices_dir, output_file).replace('\\', '/')
|
||||
self.output_path.set_path(output_path)
|
||||
import os
|
||||
models_dir = os.path.join(self.work_dir, "8_Machine_Learning_Models").replace('\\', '/')
|
||||
os.makedirs(models_dir, exist_ok=True)
|
||||
self.output_path.set_path(models_dir)
|
||||
else:
|
||||
self.output_path.set_path("")
|
||||
|
||||
|
||||
@ -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)
|
||||
@ -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):
|
||||
"""步骤8:QAA 物理反演(非经验模型)"""
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
title = QLabel("步骤8:QAA 物理反演(非经验模型)")
|
||||
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()),
|
||||
}
|
||||
@ -307,62 +307,30 @@ class Step9MlPredictPanel(QWidget):
|
||||
return result
|
||||
|
||||
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
|
||||
main_window = self.window()
|
||||
factory = getattr(main_window, '_panel_factory', None) if main_window else None
|
||||
if not factory: return
|
||||
|
||||
if work_dir:
|
||||
self.work_dir = work_dir
|
||||
elif hasattr(self, 'work_dir') and self.work_dir:
|
||||
pass
|
||||
else:
|
||||
self.work_dir = None
|
||||
# 1. 拿第 4 步的采样光谱
|
||||
step4_panel = factory.get_panel('step4_sampling')
|
||||
if step4_panel and hasattr(step4_panel, 'output_file'):
|
||||
path = step4_panel.output_file.get_path()
|
||||
if path: self.sampling_csv_file.set_path(path)
|
||||
|
||||
main_window = self.window()
|
||||
# 2. 拿第 8 步的模型目录
|
||||
step8_panel = factory.get_panel('step8_ml_train')
|
||||
if step8_panel and hasattr(step8_panel, 'output_path'):
|
||||
path = step8_panel.output_path.get_path()
|
||||
if path: self.models_dir_file.set_path(path)
|
||||
|
||||
# 1. 尝试从 Step4(采样点布设)读取全湖采样点 CSV 路径
|
||||
if main_window and hasattr(main_window, 'step4_sampling_panel'):
|
||||
step4_widget = getattr(main_window.step4_sampling_panel, 'output_file', None)
|
||||
step4_output_path = ""
|
||||
if hasattr(step4_widget, 'get_path'):
|
||||
step4_output_path = step4_widget.get_path() or ""
|
||||
elif hasattr(step4_widget, 'text'):
|
||||
step4_output_path = step4_widget.text() or ""
|
||||
|
||||
if step4_output_path:
|
||||
if not os.path.isabs(step4_output_path):
|
||||
step4_output_path = os.path.join(self.work_dir or '', step4_output_path).replace('\\', '/')
|
||||
existing = self.sampling_csv_file.get_path()
|
||||
if not existing or not existing.strip():
|
||||
self.sampling_csv_file.set_path(step4_output_path)
|
||||
|
||||
# 2. 尝试从 Step8(监督建模)读取模型目录(修复张冠李戴:原代码 main_window.step9_panel 不存在)
|
||||
step8_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 step8_models_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()
|
||||
# 3. 生成第 9 步的输出目录
|
||||
if hasattr(self, 'work_dir') and self.work_dir:
|
||||
import os
|
||||
out_dir = os.path.join(self.work_dir, "9_ML_Prediction").replace('\\', '/')
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
self.output_file.set_path(out_dir)
|
||||
|
||||
def _get_default_work_dir(self):
|
||||
"""获取 work_dir,优先用 panel 自身缓存的,否则尝试从主窗口取"""
|
||||
|
||||
@ -458,52 +458,65 @@ class WaterQualityGUI(QMainWindow):
|
||||
# ================================================================
|
||||
|
||||
def _on_step_list_changed(self, index):
|
||||
"""左侧导航 → 右侧 Tab 单向路由(Tab 头部已隐藏,无反向同步)。"""
|
||||
if index < 0:
|
||||
return
|
||||
from PyQt5.QtCore import Qt
|
||||
if index < 0: return
|
||||
item = self._step_list.item(index)
|
||||
if not item:
|
||||
return
|
||||
if not item: return
|
||||
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
|
||||
|
||||
if item_data in (None, "stage_header"): return # 跳过阶段标题
|
||||
|
||||
from src.gui.core.panel_registry import get_tab_index, PANEL_REGISTRY
|
||||
tab_index = get_tab_index(item_data)
|
||||
if tab_index < 0:
|
||||
return
|
||||
if tab_index < 0: return
|
||||
|
||||
try:
|
||||
# 先触发懒加载再切 Tab,避免 removeTab/insertTab 与导航事件重叠
|
||||
# 1. 触发懒加载生成面板
|
||||
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详见终端日志。")
|
||||
|
||||
# 🚨 核心防卡死补丁:如果目标 Tab 被后台任务异常永久锁定,强制撬开!
|
||||
if not self._tab_widget.isTabEnabled(tab_index):
|
||||
self._log_manager.info(f"检测到 {item_data} 处于异常锁定状态,已执行强制解锁。")
|
||||
self._tab_widget.setTabEnabled(tab_index, True)
|
||||
|
||||
# ====== 终极状态回滚机制 ======
|
||||
self._step_list.blockSignals(True)
|
||||
# 【新增修复】:在跳之前,再核实一遍要去的 tab_index 和 item_data 的 step_id 是否吻合!
|
||||
# 如果因为删除了死文件导致索引错位,这里强行用真实 step_id 再找一遍!
|
||||
try:
|
||||
current_tab_idx = self._tab_widget.currentIndex()
|
||||
from src.gui.core.panel_registry import PANEL_REGISTRY
|
||||
if 0 <= current_tab_idx < len(PANEL_REGISTRY):
|
||||
correct_step_id = PANEL_REGISTRY[current_tab_idx]['step_id']
|
||||
for i in range(self._step_list.count()):
|
||||
item_node = self._step_list.item(i)
|
||||
if item_node and item_node.data(Qt.UserRole) == correct_step_id:
|
||||
self._step_list.setCurrentRow(i)
|
||||
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:
|
||||
pass
|
||||
finally:
|
||||
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()):
|
||||
list_item = self._step_list.item(i)
|
||||
if list_item and list_item.data(Qt.UserRole) == current_step_id:
|
||||
self._step_list.setCurrentRow(i)
|
||||
break
|
||||
self._step_list.blockSignals(False)
|
||||
|
||||
return
|
||||
|
||||
# 动态更新上一步/下一步按钮可用状态
|
||||
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)
|
||||
|
||||
except Exception as e:
|
||||
self._log_manager.error(f"页面跳转失败: {str(e)}")
|
||||
|
||||
def _find_prev_step_row(self, current_row):
|
||||
"""从 current_row 向上遍历,跳过 stage_header 和空分隔符,返回上一个有效 step 的行号。"""
|
||||
|
||||
Reference in New Issue
Block a user