From b8d263e494ba9afce20950fd0b77e6aeed6a8bea Mon Sep 17 00:00:00 2001 From: DXC Date: Tue, 23 Jun 2026 14:39:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/handlers/step9_ml_predict.py | 11 +- src/gui/core/dependency_subscriber.py | 31 +- src/gui/core/panel_factory.py | 40 +- src/gui/core/panel_registry.py | 120 ++- src/gui/core/pipeline_executor.py | 53 +- src/gui/core/viz_thread.py | 8 +- src/gui/core/workspace_initializer.py | 14 +- src/gui/panels/_step_path_resolver.py | 71 +- src/gui/panels/step11_map_panel.py | 203 ++--- src/gui/panels/step12_viz_panel.py | 30 +- src/gui/panels/step13_panel.py | 225 ------ src/gui/panels/step13_report_panel.py | 100 ++- src/gui/panels/step14_panel.py | 814 -------------------- src/gui/panels/step7_index_panel.py | 326 -------- src/gui/panels/step8_ml_train_panel.py | 54 +- src/gui/panels/step8_non_empirical_panel.py | 312 -------- src/gui/panels/step8_qaa_panel.py | 337 -------- src/gui/panels/step9_ml_predict_panel.py | 72 +- src/gui/water_quality_gui_v2.py | 87 ++- src/postprocessing/report_word.py | 340 ++++---- src/postprocessing/visualization_reports.py | 53 +- 21 files changed, 595 insertions(+), 2706 deletions(-) delete mode 100644 src/gui/panels/step13_panel.py delete mode 100644 src/gui/panels/step14_panel.py delete mode 100644 src/gui/panels/step7_index_panel.py delete mode 100644 src/gui/panels/step8_non_empirical_panel.py delete mode 100644 src/gui/panels/step8_qaa_panel.py diff --git a/src/core/handlers/step9_ml_predict.py b/src/core/handlers/step9_ml_predict.py index fd4b4cb..0ef3462 100644 --- a/src/core/handlers/step9_ml_predict.py +++ b/src/core/handlers/step9_ml_predict.py @@ -32,13 +32,22 @@ class Step9MlPredictHandler(BaseStepHandler): models_dir = config.get('models_dir') or str(context.models_dir) + # 【硬编码路径清除】优先读取前端 config 中的 output_path / output_dir, + # 绝不允许私自拼接 11_12_13_predictions 覆盖用户在前端填写的路径。 + # 前端 step9_ml_predict_panel.get_config() 用的是 'output_path' 这个 key。 + output_dir = ( + config.get('output_path') + or config.get('output_dir') + or str(context.prediction_dir / "9_ML_Prediction") + ) + try: result = PredictionStep.predict_water_quality( sampling_csv_path=sampling_csv_path, models_dir=models_dir, metric=config.get('metric', 'test_r2'), prediction_column=config.get('prediction_column', 'prediction'), - output_dir=str(context.prediction_dir / "9_ML_Prediction"), + output_dir=output_dir, _report_generator=context.report_generator, _external_model=config.get('_external_model'), _external_model_path=config.get('_external_model_path'), diff --git a/src/gui/core/dependency_subscriber.py b/src/gui/core/dependency_subscriber.py index 2e11612..41063b0 100644 --- a/src/gui/core/dependency_subscriber.py +++ b/src/gui/core/dependency_subscriber.py @@ -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'): diff --git a/src/gui/core/panel_factory.py b/src/gui/core/panel_factory.py index 25189ce..55d1ad5 100644 --- a/src/gui/core/panel_factory.py +++ b/src/gui/core/panel_factory.py @@ -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): diff --git a/src/gui/core/panel_registry.py b/src/gui/core/panel_registry.py index 010574c..7308f8d 100644 --- a/src/gui/core/panel_registry.py +++ b/src/gui/core/panel_registry.py @@ -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, }, diff --git a/src/gui/core/pipeline_executor.py b/src/gui/core/pipeline_executor.py index dc44477..4b3c536 100644 --- a/src/gui/core/pipeline_executor.py +++ b/src/gui/core/pipeline_executor.py @@ -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, + }) + # ═══════════════════════════════════════════════════════════ # 内部辅助 # ═══════════════════════════════════════════════════════════ diff --git a/src/gui/core/viz_thread.py b/src/gui/core/viz_thread.py index fc1ac06..909ea93 100644 --- a/src/gui/core/viz_thread.py +++ b/src/gui/core/viz_thread.py @@ -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(): diff --git a/src/gui/core/workspace_initializer.py b/src/gui/core/workspace_initializer.py index 0092db5..e45cead 100644 --- a/src/gui/core/workspace_initializer.py +++ b/src/gui/core/workspace_initializer.py @@ -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 diff --git a/src/gui/panels/_step_path_resolver.py b/src/gui/panels/_step_path_resolver.py index 4c37cef..a062ce0 100644 --- a/src/gui/panels/_step_path_resolver.py +++ b/src/gui/panels/_step_path_resolver.py @@ -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. 取出指定子组件,失败时返回 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 = { diff --git a/src/gui/panels/step11_map_panel.py b/src/gui/panels/step11_map_panel.py index bfd57d7..fd6629d 100644 --- a/src/gui/panels/step11_map_panel.py +++ b/src/gui/panels/step11_map_panel.py @@ -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') → /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") diff --git a/src/gui/panels/step12_viz_panel.py b/src/gui/panels/step12_viz_panel.py index 14fa2c3..cbe45ff 100644 --- a/src/gui/panels/step12_viz_panel.py +++ b/src/gui/panels/step12_viz_panel.py @@ -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 diff --git a/src/gui/panels/step13_panel.py b/src/gui/panels/step13_panel.py deleted file mode 100644 index 6e3dd5d..0000000 --- a/src/gui/panels/step13_panel.py +++ /dev/null @@ -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对象") diff --git a/src/gui/panels/step13_report_panel.py b/src/gui/panels/step13_report_panel.py index 7b53e27..b160e68 100644 --- a/src/gui/panels/step13_report_panel.py +++ b/src/gui/panels/step13_report_panel.py @@ -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") \ No newline at end of file diff --git a/src/gui/panels/step14_panel.py b/src/gui/panels/step14_panel.py deleted file mode 100644 index 9a8743f..0000000 --- a/src/gui/panels/step14_panel.py +++ /dev/null @@ -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") diff --git a/src/gui/panels/step7_index_panel.py b/src/gui/panels/step7_index_panel.py deleted file mode 100644 index 2807034..0000000 --- a/src/gui/panels/step7_index_panel.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/src/gui/panels/step8_ml_train_panel.py b/src/gui/panels/step8_ml_train_panel.py index 278561a..1bfa13a 100644 --- a/src/gui/panels/step8_ml_train_panel.py +++ b/src/gui/panels/step8_ml_train_panel.py @@ -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("") diff --git a/src/gui/panels/step8_non_empirical_panel.py b/src/gui/panels/step8_non_empirical_panel.py deleted file mode 100644 index 2edfd39..0000000 --- a/src/gui/panels/step8_non_empirical_panel.py +++ /dev/null @@ -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) diff --git a/src/gui/panels/step8_qaa_panel.py b/src/gui/panels/step8_qaa_panel.py deleted file mode 100644 index 47667f0..0000000 --- a/src/gui/panels/step8_qaa_panel.py +++ /dev/null @@ -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()), - } \ No newline at end of file diff --git a/src/gui/panels/step9_ml_predict_panel.py b/src/gui/panels/step9_ml_predict_panel.py index 8f05f83..c76a5b9 100644 --- a/src/gui/panels/step9_ml_predict_panel.py +++ b/src/gui/panels/step9_ml_predict_panel.py @@ -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 自身缓存的,否则尝试从主窗口取""" diff --git a/src/gui/water_quality_gui_v2.py b/src/gui/water_quality_gui_v2.py index b12eab3..5f9e5c4 100644 --- a/src/gui/water_quality_gui_v2.py +++ b/src/gui/water_quality_gui_v2.py @@ -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 的行号。""" diff --git a/src/postprocessing/report_word.py b/src/postprocessing/report_word.py index 09d0266..4803135 100644 --- a/src/postprocessing/report_word.py +++ b/src/postprocessing/report_word.py @@ -35,11 +35,12 @@ import pandas as pd class _SimpleProgress: - """无依赖进度条(控制台单行刷新)。""" + """无依赖进度条(控制台单行刷新,支持 Qt 回调上递)。""" - def __init__(self, total: int, desc: str = ""): + def __init__(self, total: int, desc: str = "", on_step=None): self.total = max(1, int(total)) self.desc = desc + self.on_step = on_step self.n = 0 self._render() @@ -47,6 +48,10 @@ class _SimpleProgress: self.n = min(self.total, self.n + int(step)) self._render() + def set_description(self, text: str): + """动态更新进度描述文案(用于按段切换分析对象)。""" + self.desc = text + def close(self): # 换行,避免覆盖后续输出 print() @@ -58,6 +63,11 @@ class _SimpleProgress: bar = "█" * filled + "·" * (bar_len - filled) prefix = f"{self.desc} " if self.desc else "" print(f"\r{prefix}[{bar}] {self.n}/{self.total} ({pct}%)", end="", flush=True) + if self.on_step: + try: + self.on_step(pct, self.desc) + except Exception: + pass @dataclass @@ -210,98 +220,17 @@ class WaterQualityReportGenerator: } # 每个参数对应的图片顺序(统一5张图模式) + params_list = ["Chlorophyll", "COD", "DO", "PH", "Temperature", + "spCond", "Turbidity", "TDS", "Cl-", "NO3-N", + "NH3-N", "BGA", "TT"] self.parameter_images = { - "Chlorophyll": [ - "Chlorophyll_histogram.png", - "Chlorophyll_spectrum_comparison.png", - "Chlorophyll_scatter_with_confidence.png", - "Chlorophyll_boxplot.png", - "Chlorophyll_distribution.png" - ], - "COD": [ - "COD_histogram.png", - "COD_spectrum_comparison.png", - "COD_scatter_with_confidence.png", - "COD_boxplot.png", - "COD_distribution.png" - ], - "DO": [ - "DO_histogram.png", - "DO_spectrum_comparison.png", - "DO_scatter_with_confidence.png", - "DO_boxplot.png", - "DO_distribution.png" - ], - "PH": [ - "PH_histogram.png", - "PH_spectrum_comparison.png", - "PH_scatter_with_confidence.png", - "PH_boxplot.png", - "PH_distribution.png" - ], - "Temperature": [ - "Temperature_histogram.png", - "Temperature_spectrum_comparison.png", - "Temperature_scatter_with_confidence.png", - "Temperature_boxplot.png", - "Temperature_distribution.png" - ], - "spCond": [ - "spCond_histogram.png", - "spCond_spectrum_comparison.png", - "spCond_scatter_with_confidence.png", - "spCond_boxplot.png", - "spCond_distribution.png" - ], - "Turbidity": [ - "Turbidity_histogram.png", - "Turbidity_spectrum_comparison.png", - "Turbidity_scatter_with_confidence.png", - "Turbidity_boxplot.png", - "Turbidity_distribution.png" - ], - "TDS": [ - "TDS_histogram.png", - "TDS_spectrum_comparison.png", - "TDS_scatter_with_confidence.png", - "TDS_boxplot.png", - "TDS_distribution.png" - ], - "Cl-": [ - "Cl-histogram.png", - "Cl-spectrum_comparison.png", - "Cl-scatter_with_confidence.png", - "Cl-boxplot.png", - "Cl-distribution.png" - ], - "NO3-N": [ - "NO3-N_histogram.png", - "NO3-N_spectrum_comparison.png", - "NO3-N_scatter_with_confidence.png", - "NO3-N_boxplot.png", - "NO3-N_distribution.png" - ], - "NH3-N": [ - "NH3-N_histogram.png", - "NH3-N_spectrum_comparison.png", - "NH3-N_scatter_with_confidence.png", - "NH3-N_boxplot.png", - "NH3-N_distribution.png" - ], - "BGA": [ - "BGA_histogram.png", - "BGA_spectrum_comparison.png", - "BGA_scatter_with_confidence.png", - "BGA_boxplot.png", - "BGA_distribution.png" - ], - "TT": [ - "TT_histogram.png", - "TT_spectrum_comparison.png", - "TT_scatter_with_confidence.png", - "TT_boxplot.png", - "TT_distribution.png" - ] + param: [ + f"{param}_histogram.png", + f"{param}_spectrum_comparison.png", + f"{param}_scatter_true_vs_pred.png", + f"{param}_boxplot.png", + f"{param}_distribution_enhanced.png" + ] for param in params_list } def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None: @@ -397,6 +326,7 @@ class WaterQualityReportGenerator: payload: Dict[str, Any] = { "model": self.minimax_text_model, + "max_tokens": 4096, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, @@ -447,6 +377,7 @@ class WaterQualityReportGenerator: payload: Dict[str, Any] = { "model": self.minimax_vision_model, + "max_tokens": 4096, "messages": [ { "role": "user", @@ -518,123 +449,90 @@ class WaterQualityReportGenerator: return self._ollama_chat(model, system_prompt, user_prompt, image_path) def _get_prompt_for_image(self, image_type: str, param: str, figure_num: int) -> Dict[str, str]: - """按图片类型返回 system/user 提示词,带防幻觉约束。""" + """按图片类型返回 system/user 提示词,注入水质遥感专家级约束。""" system = ( - "你是一位水质遥感与机器学习建模专家。\n" - "研究背景:我们利用高光谱影像数据,结合机器学习算法对研究区的水质参数进行了空间反演,并生成了以下图表。" - "现需要撰写自动化分析报告,请严格按照“图表类型→分析重点”的对应关系进行描述。\n\n" - "分析要求:\n" - "1. 请严格基于图片中可见信息进行分析,禁止编造不存在的数值、区域名称、采样时间或结论。\n" - "2. 如果图片无法支撑某项判断,必须明确写“根据本图无法判断”。\n" - "3. 不允许引用图片之外的背景知识来补全细节。" + "你是一位资深的水环境遥感与水生态学专家。现需为一份高光谱水质参数反演报告撰写专业分析。\n" + "【绝对禁忌】:严禁写“看图说话”式的废话(如“曲线先升后降”、“柱子集中在中间”)。\n" + "【核心规范】:\n" + "1. 必须结合【水色光学机理】和【水环境地学意义】进行解释。\n" + "2. 提及波长时,必须解释其对应的物理/生化意义(如叶绿素红光吸收谷、悬浮物散射峰、水体吸收特性等)。\n" + "3. 分析浓度数值时,必须结合自然水体的常规背景值或富营养化状态进行定性评价(如“处于清洁水平”或“存在水华风险”)。\n" + "4. 严格基于图中可见的规律,不编造图中没有的具体坐标或日期。" ) - # 为每种图表类型单独定义:分析要点 + 结论聚焦 type_specs = { "histogram": { "analysis": ( "分析要点:\n" - "- 分布形态:是左偏、右偏还是对称?是否存在多峰?\n" - "- 集中范围:数据主要集中在哪个区间?(参照横轴和纵轴柱高)\n" - "- 离群值:是否有明显孤立于主体分布的小柱,位于何处?\n" - "- 若图中包含拟合曲线,描述其形状(正态、指数等)。" - ), - "conclusion": ( - "结论应聚焦于:该参数的分布形态(如左偏/右偏/对称)、主要集中区间、是否存在极端离群值。" - "用一句话概括数据分布的核心特征,不推测成因。" + f"- 结合自然水体中 {param} 的常规阈值,评估该水域当前的整体水平(清洁、轻度污染或富营养化)。\n" + "- 从生态学角度解释这种数值分布形态(如多峰分布可能暗示存在多个不同性质的污染源或水团交汇)。\n" + "- 关注极端离群值,指出其可能代表的局部异常环境事件。" ), + "conclusion": "结论应聚焦:该水质参数的整体健康水平评估及主要生态风险提示。", }, "spectrum_comparison": { "analysis": ( "分析要点:\n" - "- 多条曲线的整体趋势是否一致?\n" - "- 在哪些波段(参照横轴波长位置)出现明显分离?\n" - "- 是否存在系统性的整体偏移(一条曲线全程高于另一条)?\n" - "- 图中是否有阴影或误差带表示置信区间?若有,描述其范围。" - ), - "conclusion": ( - "结论应聚焦于:各光谱曲线的整体一致性、关键差异波段、是否存在系统性偏移。" - "用一句话概括光谱对比的主要特征,不推测物理原因。" + f"- 结合 {param} 的固有光学特性,重点分析400-900nm区间内的特征波段响应(如吸收谷、反射峰、双峰效应等)。\n" + "- 对比不同浓度组别的光谱差异,说明浓度变化是如何改变水体对光吸收和后向散射规律的。\n" + "- 指出对该参数反演最具区分度的关键波段区间,验证模型的物理可解释性。" ), + "conclusion": "结论应聚焦:浓度梯度引起的光谱响应规律及其对应的光学机制验证。", }, "scatter_with_confidence": { "analysis": ( "分析要点:\n" - "- 点云整体是否沿1:1线(对角线)分布?\n" - "- 点云在低值区/高值区是否存在系统性偏离(如整体偏上/偏下)?\n" - "- 置信带(若存在)覆盖了多少点?是否所有点都在置信带内?\n" - "- 是否有明显离群点(远离主体点云)?" - ), - "conclusion": ( - "结论应聚焦于:模型预测精度(点云与1:1线贴合程度)、偏差方向、置信带覆盖情况。" - "用一句话评价模型性能,不推测误差来源。" + "- 评估机器学习反演模型在该参数上的鲁棒性。点云对1:1线的贴合度反映了反演精度。\n" + "- 重点分析在极低值区或极高值区是否存在系统性高估/低估(这是水色遥感的常见难点,如高浓度下的光谱饱和效应)。\n" + "- 结合置信带宽度,说明模型在不同浓度区间的预测不确定性。" ), + "conclusion": "结论应聚焦:反演模型的整体精度表现、局限性及可靠的浓度预测区间。", }, "boxplot": { "analysis": ( "分析要点:\n" - "- 中位数(箱体中间线)的位置。\n" - "- 四分位间距(箱体高度)反映的离散程度。\n" - "- 须(whisker)的长度,是否超出1.5倍IQR的离群点(用圆点/星号标示)。\n" - "- 若多个箱线图并排,比较各组的中心趋势和离散度。" - ), - "conclusion": ( - "结论应聚焦于:各组的中心趋势(中位数)、离散程度(四分位距)、是否存在离群点。" - "用一句话概括数据分布的统计特征,若有多组则简述对比。" + "- 结合中位数和四分位距,分析不同类别(或区域)间水质差异的显著性。\n" + "- 解释离散程度大(箱体长)可能代表的强烈时空异质性。\n" + "- 指出箱线图上下的离群点,探讨其作为局部水质突变信号的价值。" ), + "conclusion": "结论应聚焦:核心对比趋势及数据整体的时空变异特征。", }, "distribution": { "analysis": ( "分析要点:\n" - "- 高值区域:位于图中的哪个方位(如东北部、中部偏西、东南沿岸等)?呈现何种形状(斑块状、条带状、片状)?\n" - "- 低值区域:位置及形态。\n" - "- 梯度变化:是否存在明显的从某方位向另一方位递减或递增的趋势?\n" - "- 聚集特征:高值区是否成片聚集,还是零星散布?\n" - "注意:仅使用方位描述位置(如上、下、左、右、中心、边缘、沿岸等),禁止使用具体经纬度坐标或地名。" - ), - "conclusion": ( - "结论应聚焦于:高值区与低值区的空间方位、聚集形态、主要梯度方向。" - "用一句话概括空间分布格局,不推测污染源或成因。" + f"- 分析 {param} 高值区与低值区的空间异质性特征。\n" + "- 推断污染/物质来源类型:高值区呈斑块状/点状(通常提示点源排放或局部水华),还是呈沿岸带状/梯度扩散(通常提示面源径流或水动力扩散)。\n" + "- 结合常见水动力学特征,简述物质可能的输移趋势。" ), + "conclusion": "结论应聚焦:水质参数的空间格局特征及其指示的宏观环境动力学过程。", }, "correlation_heatmap": { "analysis": ( "分析要点:\n" - "- 各变量对之间的相关性强度:颜色深浅对应的相关系数大小(参照图例)。\n" - "- 正相关与负相关:红色/蓝色分别代表正负(根据图例),描述主要的高正相关对和高负相关对。\n" - "- 若图中包含数值标注,可提及范围(如“大多数相关系数介于0.6~0.8”),但不得编造具体数字。\n" - "- 若单元格颜色过于接近难以区分,则写“根据本图无法判断具体相关性强弱”。" - ), - "conclusion": ( - "结论应聚焦于:变量间相关性的整体强弱水平、最主要的正负相关对。" - "用一句话概括相关性矩阵的核心特征,不推测因果关系。" + "- 挖掘关键水质参数间的生物地球化学联系。如叶绿素与总氮/总磷的正相关提示营养盐驱动,与浊度的正相关提示藻类为主导的悬浮物等。\n" + "- 识别拮抗作用(强负相关),并解释其潜在的生化机制(如高浊度遮蔽光照导致叶绿素降低)。\n" + "- 基于相关性聚类,推断水体中的核心主导污染因子群。" ), + "conclusion": "结论应聚焦:水质指标间的核心协同/拮抗机制及水环境的主要驱动力。", }, } - # 默认规格(如果类型未定义) default_spec = { - "analysis": "重点:概括图中主要信息,列出可见的轴标签、图例、数据特征。", - "conclusion": "结论应基于可见信息,概括图中主要趋势或数据特征,不添加外部知识。", + "analysis": "结合水环境遥感原理,深入解读图中展现的数据分布或空间格局特征。", + "conclusion": "结论应聚焦:该图表传递的核心水质遥感科学结论。", } spec = type_specs.get(image_type, default_spec) - analysis_part = spec["analysis"] - conclusion_part = spec["conclusion"] - - common_output = ( - "输出格式:\n" - "请结合坐标轴、图例、曲线、点云、颜色条等可见元素,描述数据特征(如分布形态、对比关系、空间位置等),引用图中具体元素但不编造数值。" - "随后用一句话总结该图揭示的主要趋势或数据质量。总结必须严格基于前文描述的可见信息,不得引入图中未呈现的外部知识、推测原因或隐含假设。" - "若信息不足以得出明确结论,则写“根据本图无法得出明确结论”。" - "要求:直接输出分析内容,不要使用“第一段”“第二段”等标记,两段之间不要留空行。") - + user = ( f"图号:图{figure_num}\n" - f"参数:{param}\n" - f"图类型:{image_type}\n\n" - f"{analysis_part}\n\n" - f"{common_output}" + f"当前分析参数:{param}\n" + f"图表类型:{image_type}\n\n" + "【专业要求】:\n" + f"{spec['analysis']}\n\n" + "【输出格式】:\n" + "直接输出一段(不要分段)150~300字的专业分析。前半部分描述关键数据现象并深挖其光学或生态机制,最后用一句“总之,…”作为全文的科学性总结。\n" + f"【最终落脚点要求】:{spec['conclusion']}\n" ) return {"system": system, "user": user} @@ -735,13 +633,20 @@ class WaterQualityReportGenerator: self._save_ai_cache(cache) return text - def _create_progress(self, total: int, desc: str = "进度"): - """创建进度条:优先 tqdm,否则使用简单进度条。""" + def _create_progress(self, total: int, desc: str = "进度", on_step=None): + """创建进度条:优先 tqdm,否则使用简单进度条。 + + Args: + on_step: 可选回调,签名 on_step(percent: int, text: str)。用于驱动 Qt QProgressBar / QThread 进度信号。 + """ + # 如果有 UI 回调需求,强制使用自带的 _SimpleProgress,防止 tqdm 吞掉信号 + if on_step is not None: + return _SimpleProgress(total=total, desc=desc, on_step=on_step) try: from tqdm import tqdm # type: ignore return tqdm(total=total, desc=desc, unit="步", ncols=90) except Exception: - return _SimpleProgress(total=total, desc=desc) + return _SimpleProgress(total=total, desc=desc, on_step=on_step) def _analyze_statistics(self, stats_data: List[Dict[str, Any]], param_names: List[str]) -> str: """对水质参数统计数据进行 AI 分析""" @@ -768,14 +673,19 @@ class WaterQualityReportGenerator: return self._ai_chat(self.ollama_text_model, system, user, image_path=None) - def generate_report(self, + def generate_report(self, work_dir: str = None, parameters: List[str] = None, report_title: str = "水质参数反演分析报告", - output_path: Optional[str] = None) -> str: + output_path: Optional[str] = None, + on_progress=None) -> str: """ 生成 Word 报告 - 所有数据均来自工作目录(work_dir) 可视化图片、统计数据等均从 work_dir/14_visualization 和 work_dir/4_processed_data 中读取 + + Args: + on_progress: 可选回调,签名 on_progress(percent: int, text: str)。 + 会在进度更新时被调用,用于驱动 Qt QProgressBar/QThread 信号。 """ # 设置工作目录(整个流程的核心) if work_dir is not None: @@ -805,7 +715,7 @@ class WaterQualityReportGenerator: # 进度条(按“图片处理 + 汇总”计步) total_images = sum(len(self.parameter_images.get(p, [])) for p in parameters) total_steps = total_images + 1 + 1 # +1 相关性热力图(尝试一次),+1 综合总结 - progress = self._create_progress(total=total_steps, desc="生成Word报告") + progress = self._create_progress(total=total_steps, desc="生成Word报告", on_step=on_progress) # 创建文档 doc = Document() @@ -847,6 +757,7 @@ class WaterQualityReportGenerator: base_section_num = 5 last_param_section_num = base_section_num + len(parameters) - 1 for section_num, param in enumerate(parameters, base_section_num): + progress.set_description(f"正在分析 {param} 数据 ({section_num - base_section_num + 1}/{len(parameters)})") figure_counter = self._add_parameter_section( doc, param, @@ -873,15 +784,19 @@ class WaterQualityReportGenerator: ] ) system = ( - "你是一位水质遥感与报告撰写专家。" - "只能基于提供的“逐图分析文本”做总结,禁止引入任何外部事实或猜测。" - "若信息不足,必须明确说明“根据现有分析无法判断”。" + "你是一位水环境管理决策专家与遥感首席科学家。现需根据前面生成的各参数逐图分析文本,提炼出一份执行摘要级别的综合结论。\n" + "必须具备宏观视角,能够将离散的参数分析整合成对该水域整体健康状况的系统性诊断。" ) user = ( - "以下是逐图分析文本,请给出报告级别的综合总结,要求:\n" - "- 150~300字中文\n" - "- 结构:总体概况 / 主要异常或热点 / 参数间关系(如有)/ 建议关注点\n" - "- 不要编造具体数值、地名、日期\n\n" + "以下是各个水质参数的详尽逐图分析文本,请基于此撰写一份最终的综合分析总结。\n" + "【内容结构需包含】:\n" + "1. 整体水质评估(如营养状态、主要污染程度)。\n" + "2. 关键时空热点与驱动因子(最需关注的高值区域及核心主导参数)。\n" + "3. 遥感反演模型可靠性综合评价。\n" + "4. 宏观水环境管理与保护建议。\n" + "【⚠️强制要求】:\n" + "- 总结的字数必须严格控制在 300 到 450 字之间!\n" + "- 必须输出完整的结尾标点符号,绝不允许出现话说一半突然截断的情况!高度精炼,切勿啰嗦。\n\n" f"{analyses_text}" ) summary_text = self._ai_chat(self.ollama_text_model, system, user, image_path=None) @@ -959,30 +874,47 @@ class WaterQualityReportGenerator: for i, img_name in enumerate(image_list): figure_num = start_figure_num + i - # 选择子文件夹 + + # 选择子文件夹与动态寻址 if "boxplot" in img_name.lower(): - sub_dir = vis_dir / "boxplots" title_key = "boxplot" - elif "scatter" in img_name.lower() or "confidence" in img_name.lower(): - sub_dir = vis_dir / "scatter_plots" - title_key = "scatter_with_confidence" - elif "histogram" in img_name.lower(): - sub_dir = vis_dir - title_key = "histogram" - elif "spectrum" in img_name.lower(): - sub_dir = vis_dir - title_key = "spectrum_comparison" - elif "distribution" in img_name.lower(): - sub_dir = vis_dir - title_key = "distribution" - else: - sub_dir = vis_dir - title_key = "histogram" - - img_path = sub_dir / img_name - if not img_path.exists(): img_path = vis_dir / img_name - + elif "scatter" in img_name.lower() or "pred" in img_name.lower() or "confidence" in img_name.lower(): + title_key = "scatter_with_confidence" + img_path = vis_dir / "scatter_plots" / img_name + if not img_path.exists(): + img_path = vis_dir / img_name + elif "histogram" in img_name.lower(): + title_key = "histogram" + img_path = vis_dir / img_name + elif "spectrum" in img_name.lower(): + title_key = "spectrum_comparison" + img_path = vis_dir / img_name + elif "distribution" in img_name.lower(): + title_key = "distribution" + # 颜色地图由预测步骤生成,开启全盘指索 + search_dirs = [ + vis_dir.parent / "9_water_quality_prediction", + vis_dir.parent / "11_12_13_predictions", + vis_dir.parent / "9_Concentration" / "charts", + vis_dir.parent / "9_Concentration", + vis_dir + ] + found_map = False + for s_dir in search_dirs: + if s_dir.exists(): + candidates = list(s_dir.glob(f"*{param}*.png")) + list(s_dir.glob(f"*{param}*.jpg")) + # 剔除掉属于其他类型的图 + candidates = [c for c in candidates if not any(x in c.name.lower() for x in ("scatter", "histogram", "spectrum", "boxplot", "preview"))] + if candidates: + img_path = candidates[0] + found_map = True + break + if not found_map: + img_path = vis_dir / img_name + else: + title_key = "histogram" + img_path = vis_dir / img_name if img_path.exists(): param_cn = param.replace("Chlorophyll", "叶绿素").replace("NO3-N", "硝酸盐氮").replace("NH3-N", "氨氮") cn_title = title_map.get(title_key, "分析图") @@ -1245,7 +1177,7 @@ class WaterQualityReportGenerator: vis_dir = self.visualization_dir # 0. 航线规划图 - flight_path_img_path = work_dir_path / "14_visualization" / "flight_maps" + flight_path_img_path = work_dir_path / "14_visualization" / "flight_paths" h3 = doc.add_heading("航线规划:", level=3) self._style_heading(h3, level=3) @@ -1636,7 +1568,7 @@ class WaterQualityReportGenerator: # 从工作目录的4_processed_data文件夹查找CSV文件 work_dir_path = vis_dir.parent - processed_data_dir = work_dir_path / "4_processed_data" + processed_data_dir = work_dir_path / "5_Data_Cleaning" if not processed_data_dir.exists(): doc.add_paragraph(f"未找到数据处理目录: {processed_data_dir}") diff --git a/src/postprocessing/visualization_reports.py b/src/postprocessing/visualization_reports.py index e14fe3c..0011225 100644 --- a/src/postprocessing/visualization_reports.py +++ b/src/postprocessing/visualization_reports.py @@ -328,31 +328,40 @@ class WaterQualityVisualization: plt.close() output_paths['boxplot'] = str(boxplot_path) - # 2. 直方图 (每个水质参数列) + + # 2. 直方图与单参数箱线图 (每个水质参数列) for col in numeric_cols: - fig, ax = plt.subplots(figsize=(10, 6)) data = df[col].dropna() - if len(data) > 1: - ax.hist(data, bins=30, edgecolor='black', alpha=0.7, color='skyblue') - ax.set_xlabel(f'{col} 数值', fontsize=12, fontweight='bold') - ax.set_ylabel('频数', fontsize=12, fontweight='bold') - ax.set_title(f'{col} 分布直方图', fontsize=14, fontweight='bold') - ax.grid(True, alpha=0.3, axis='y') - - # 添加统计信息 - mean_val = data.mean() - std_val = data.std() if len(data) > 1 else 0 - ax.axvline(mean_val, color='red', linestyle='--', linewidth=2, label=f'均值: {mean_val:.4f}') - ax.legend() - - plt.tight_layout() - - safe_name = "".join(c for c in col if c.isalnum() or c in ('-', '_', '.')) - hist_path = output_dir / f"{safe_name}_histogram.png" - plt.savefig(hist_path, dpi=300, bbox_inches='tight') - plt.close() + if len(data) > 0: + # === 直方图生成 === + fig, ax = plt.subplots(figsize=(10, 6)) + if len(data) > 1: + ax.hist(data, bins=30, edgecolor='black', alpha=0.7, color='skyblue') + ax.set_xlabel(f'{col} 数值', fontsize=12, fontweight='bold') + ax.set_ylabel('频数', fontsize=12, fontweight='bold') + ax.set_title(f'{col} 分布直方图', fontsize=14, fontweight='bold') + ax.grid(True, alpha=0.3, axis='y') + mean_val = data.mean() + ax.axvline(mean_val, color='red', linestyle='--', linewidth=2, label=f'均值: {mean_val:.4f}') + ax.legend() + plt.tight_layout() + safe_name = "".join(c for c in col if c.isalnum() or c in ('-', '_', '.')) + hist_path = output_dir / f"{safe_name}_histogram.png" + plt.savefig(hist_path, dpi=300, bbox_inches='tight') + plt.close(fig) output_paths[f'histogram_{col}'] = str(hist_path) - + + # === 新增:单参数箱线图生成 === + fig_box, ax_box = plt.subplots(figsize=(6, 8)) + ax_box.boxplot([data], labels=[col]) + ax_box.set_ylabel('数值', fontsize=12, fontweight='bold') + ax_box.set_title(f'{col} 箱线图', fontsize=14, fontweight='bold') + ax_box.grid(True, alpha=0.3, axis='y') + plt.tight_layout() + box_path = output_dir / f"{safe_name}_boxplot.png" + plt.savefig(box_path, dpi=300, bbox_inches='tight') + plt.close(fig_box) + output_paths[f'boxplot_{col}'] = str(box_path) # 3. 相关性热力图 if len(numeric_cols) >= 2: corr_matrix = df[numeric_cols].corr()