From c46f78e69d873ce316e95708a8ae611dd637aceb Mon Sep 17 00:00:00 2001 From: duxin Date: Mon, 6 Jul 2026 15:57:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E7=9A=84=20handler=20=E6=96=87=E4=BB=B6=20+=20IDW=20=E6=8F=92?= =?UTF-8?q?=E5=80=BC=E9=80=80=E5=8C=96=E6=A3=80=E6=B5=8B=20+=20V1=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handlers/step10_watercolor_handler.py | 99 + src/core/handlers/step11_map_handler.py | 268 +++ src/gui/_legacy/__init__.py | 4 + src/gui/_legacy/water_quality_gui.py | 1598 +++++++++++++++++ src/postprocessing/map.py | 259 +-- 5 files changed, 2101 insertions(+), 127 deletions(-) create mode 100644 src/core/handlers/step10_watercolor_handler.py create mode 100644 src/core/handlers/step11_map_handler.py create mode 100644 src/gui/_legacy/__init__.py create mode 100644 src/gui/_legacy/water_quality_gui.py diff --git a/src/core/handlers/step10_watercolor_handler.py b/src/core/handlers/step10_watercolor_handler.py new file mode 100644 index 0000000..0512d76 --- /dev/null +++ b/src/core/handlers/step10_watercolor_handler.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Step10 处理器:水色指数反演(CSV 散点模式) + +2026-06-30 新建:替代原先错误路由的 step10_qaa(QAA 物理反演)。 +使用 WaterIndexCsvProcessor 对采样点 CSV 逐行计算选定的水质指数公式, +每个公式输出一个独立 CSV 文件(longitude, latitude, <公式值>)。 +""" + +import os +import time +from typing import Any, Dict, Optional + +from src.core.handlers.base import BaseStepHandler, PipelineContext + + +class Step10WatercolorHandler(BaseStepHandler): + """步骤10:水色指数反演(CSV 散点模式)。 + + 对应 config key: 'step10_watercolor' + 委托类: WaterIndexCsvProcessor + """ + + step_key = 'step10_watercolor' + + def execute(self, context: PipelineContext, config: dict) -> Dict[str, Any]: + from src.core.algorithms.waterindex_inversion.csv_processor import ( + WaterIndexCsvProcessor, + ) + + step_start_time = time.time() + + # ── 解析参数 ── + sampling_csv_path = config.get('sampling_csv_path', '') + if not sampling_csv_path or not os.path.isfile(sampling_csv_path): + msg = f'采样点 CSV 不存在或路径为空: {sampling_csv_path}' + context.notify('step10_watercolor', 'error', msg) + return {'error': msg} + + output_dir = config.get('output_dir', '') + if not output_dir: + output_dir = os.path.join(str(context.work_dir), '10_WaterIndex_CSV') + + # 显式兜底:只有非空 list 才启用过滤,杜绝 falsy 空列表透传 + raw_selected = config.get('selected_formulas') + selected_formulas: Optional[list] = ( + raw_selected if isinstance(raw_selected, list) and len(raw_selected) > 0 else None + ) + wavelength_offset = float(config.get('wavelength_offset', 0)) + waterindex_csv = config.get('waterindex_csv', '') + + if not waterindex_csv or not os.path.isfile(waterindex_csv): + msg = f'waterindex.csv 不存在(前端未传入或路径无效): {waterindex_csv!r}' + context.notify('step10_watercolor', 'error', msg) + return {'error': msg} + + # ── 执行 ── + try: + processor = WaterIndexCsvProcessor(waterindex_csv) + + from src.gui.core.event_bus import global_event_bus + + def _progress_callback(msg: str, pct: float): + context.notify('step10_watercolor', 'info', f'[{pct:.0f}%] {msg}') + global_event_bus.publish('ProgressUpdate', { + 'percentage': int(pct), + 'message': f'Step10 水色指数: {msg}', + }) + + out_files = processor.compute_indices_from_csv( + sampling_csv_path=sampling_csv_path, + output_dir=output_dir, + selected_formulas=selected_formulas, + progress_callback=_progress_callback, + wavelength_offset=wavelength_offset, + ) + + step_end_time = time.time() + context.record_step_time( + '步骤10: 水色指数反演', step_start_time, step_end_time + ) + context.notify( + 'step10_watercolor', 'completed', + f'水色指数反演完成,共生成 {len(out_files)} 个 CSV' + ) + + return { + 'output_dir': output_dir, + 'output_files': out_files, + } + + except Exception as e: + step_end_time = time.time() + context.record_step_time( + '步骤10: 水色指数反演', step_start_time, step_end_time, + status='failed', error=str(e) + ) + raise diff --git a/src/core/handlers/step11_map_handler.py b/src/core/handlers/step11_map_handler.py new file mode 100644 index 0000000..43a1d54 --- /dev/null +++ b/src/core/handlers/step11_map_handler.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Step11 处理器:专题图生成(CSV 插值 → 分布图 PNG) + +2026-06-30:统一到 Pipeline 体系。 + - 预矢量化:将 .dat 栅格掩膜转一次为 .shp,避免 63 个 CSV 各自矢量化 1765 万像元。 + - 串行处理:GDAL 不具有线程安全性,多线程并发 Open/Polygonize 导致 0xC0000005。 + 预矢量化后每个 CSV 处理时间大幅缩短,串行速度已可接受。 +""" + +import os +import shutil +import time +from pathlib import Path +from typing import Any, Dict, List, Tuple + +from src.core.handlers.base import BaseStepHandler, PipelineContext + + +def _process_one_map(csv_path: str, base_kwargs: dict, output_dir: str + ) -> Tuple[str, str]: + """处理单个 CSV → 分布图 TIF(子进程内执行)。 + + 2026-07-01:支持 shared_context 快速通道。 + 当 base_kwargs 包含 'shared_context' 时,绕过 MappingStep, + 直接用 ContentMapper.process_data(shared_context=ctx) 跳过重复的边界/网格/掩膜计算。 + """ + import matplotlib + try: + matplotlib.use('Agg', force=True) + except Exception: + pass + + stem = Path(csv_path).stem + output_image_path = str(Path(output_dir) / f'{stem}_distribution.png') + + shared_context = base_kwargs.get('shared_context') + + if shared_context is not None: + # ★ 快速通道:直接调 ContentMapper,复用预计算的网格/掩膜 + from src.postprocessing.map import ContentMapper + mapper = ContentMapper( + input_crs=base_kwargs.get('input_crs', 'EPSG:32651'), + output_crs=base_kwargs.get('output_crs', base_kwargs.get('input_crs', 'EPSG:32651')), + ) + result_path = mapper.process_data( + csv_file=csv_path, + shp_file=base_kwargs.get('boundary_shp_path'), + output_file=output_image_path, + resolution=float(base_kwargs.get('resolution', 10.0)), + output_format='tif', + shared_context=shared_context, + ) + return result_path, csv_path + + # 原有路径(无共享上下文时) + kw = dict(base_kwargs) + kw.pop('shared_context', None) # 防止透传给 MappingStep + kw['prediction_csv_path'] = csv_path + kw['output_image_path'] = output_image_path + + from src.core.steps.mapping_step import MappingStep + result_path = MappingStep.generate_distribution_map(**kw) + return result_path, csv_path + + +def _pre_vectorize_boundary(boundary_path: str, work_dir: str) -> str: + """将栅格掩膜预转换为矢量 SHP(只做一次),返回 SHP 路径。""" + src = Path(boundary_path) + if not src.exists(): + return boundary_path + if src.suffix.lower() in ('.shp', '.geojson'): + return boundary_path + + cache_dir = Path(work_dir) / '.step11_cache' + cache_dir.mkdir(parents=True, exist_ok=True) + shp_out = cache_dir / f'{src.stem}_vectorized.shp' + if shp_out.exists(): + return str(shp_out) + + from osgeo import gdal, ogr, osr + gdal.UseExceptions() + + ds = gdal.Open(str(src)) + if ds is None: + return boundary_path + + band = ds.GetRasterBand(1) + drv = ogr.GetDriverByName('ESRI Shapefile') + if drv is None: + ds = None + return boundary_path + + for ext in ('.shp', '.shx', '.dbf', '.prj'): + p = cache_dir / f'{src.stem}_vectorized{ext}' + if p.exists(): + p.unlink() + + out_ds = drv.CreateDataSource(str(shp_out)) + srs = osr.SpatialReference() + proj = ds.GetProjection() + if proj: + srs.ImportFromWkt(proj) + else: + srs.ImportFromEPSG(32651) + + layer = out_ds.CreateLayer('boundary', srs, ogr.wkbMultiPolygon) + gdal.Polygonize(band, band, layer, 0, [], callback=None) + + out_ds = None + ds = None + + if shp_out.exists(): + return str(shp_out) + return boundary_path + + +class Step11MapHandler(BaseStepHandler): + """步骤11:专题图生成(预矢量化 + 串行)。 + + 对应 config key: 'step11_map' + """ + + step_key = 'step11_map' + + def execute(self, context: PipelineContext, config: dict) -> Dict[str, Any]: + step_start_time = time.time() + + prediction_csv_path = config.get('prediction_csv_path', '') + prediction_csv_dir = config.get('prediction_csv_dir', '') + batch_mode = config.get('batch_mode', False) + boundary_shp_path = config.get('boundary_shp_path', '') or '' + output_dir = config.get('output_dir', '') or str(context.visualization_dir) + output_image_path = config.get('output_image_path', '') + + # ── 收集 CSV ── + csv_paths: List[str] = [] + if prediction_csv_path and os.path.isfile(prediction_csv_path): + csv_paths = [prediction_csv_path] + elif batch_mode and prediction_csv_dir and os.path.isdir(prediction_csv_dir): + import glob + csv_paths = sorted(glob.glob(os.path.join(prediction_csv_dir, '*.csv'))) + + if not csv_paths: + context.notify('step11_map', 'error', '没有找到预测 CSV 文件') + return {'error': '没有找到预测 CSV 文件'} + + # ── ★ 预矢量化边界(仅一次)── + resolved_boundary = boundary_shp_path + if boundary_shp_path and os.path.isfile(boundary_shp_path): + try: + context.notify('step11_map', 'info', '正在预矢量化水域边界(仅一次)…') + resolved_boundary = _pre_vectorize_boundary( + boundary_shp_path, str(context.work_dir) + ) + context.notify('step11_map', 'info', + f'边界预处理完成,开始生成专题图…') + except Exception as e: + context.notify('step11_map', 'warning', + f'边界预处理失败,使用原始路径: {e}') + + base_kwargs = { + 'boundary_shp_path': resolved_boundary or None, + 'resolution': float(config.get('resolution', 10.0)), + 'input_crs': config.get('input_crs', 'EPSG:32651'), + 'output_crs': config.get('output_crs', config.get('input_crs', 'EPSG:32651')), + 'show_sample_points': bool(config.get('show_sample_points', False)), + 'use_distance_diffusion': bool(config.get('use_distance_diffusion', False)), + } + if output_image_path and len(csv_paths) == 1: + base_kwargs['output_image_path'] = output_image_path + + os.makedirs(output_dir, exist_ok=True) + + total = len(csv_paths) + from src.gui.core.event_bus import global_event_bus + + context.notify('step11_map', 'info', f'专题图开始({total} 个 CSV)') + global_event_bus.publish('ProgressUpdate', { + 'percentage': 0, 'message': f'Step11 专题图: 0/{total}', + }) + + # ── ★ 2026-07-01:主进程预计算共享空间上下文 ── + # 63 个 CSV 坐标一致,边界/网格/掩膜只算一次,塞入 base_kwargs + # 子进程通过 shared_context 复用,跳过重复的 ②④⑥ (~1.2s/CSV) + if total > 1 and resolved_boundary: + try: + context.notify('step11_map', 'info', + '正在预计算共享空间上下文(边界+网格+掩膜,仅一次)…') + from src.postprocessing.map import ContentMapper + pre_mapper = ContentMapper( + input_crs=base_kwargs['input_crs'], + output_crs=base_kwargs['output_crs'], + ) + shared_ctx = pre_mapper.prepare_shared_context( + sample_csv=csv_paths[0], + shp_file=resolved_boundary, + resolution=float(base_kwargs['resolution']), + expand_ratio=0.05, + ) + base_kwargs['shared_context'] = shared_ctx + context.notify('step11_map', 'info', + f'✓ 共享上下文已就绪,后续 {total} 个 CSV 将直接复用') + except Exception as e: + context.notify('step11_map', 'warning', + f'共享上下文预计算失败: {e},回退逐个处理') + + # ── 多进程并行(GDAL 线程不安全,但进程隔离下安全)── + generated: List[str] = [] + errors: Dict[str, str] = {} + + import multiprocessing + from concurrent.futures import ProcessPoolExecutor, as_completed + + # 留出 1-2 个核心保证电脑不卡死 + max_workers = max(1, multiprocessing.cpu_count() - 2) + context.notify('step11_map', 'info', f'使用 {max_workers} 个进程并行生成') + + with ProcessPoolExecutor(max_workers=max_workers) as executor: + future_to_csv = { + executor.submit(_process_one_map, csv_p, base_kwargs, output_dir): csv_p + for csv_p in csv_paths + } + done_count = 0 + for future in as_completed(future_to_csv): + csv_p = future_to_csv[future] + done_count += 1 + try: + result_path, _ = future.result() + generated.append(result_path) + except Exception as e: + errors[csv_p] = str(e) + context.notify('step11_map', 'warning', + f'专题图 FAIL: {Path(csv_p).name} — {e}') + + pct = int(done_count / total * 100) + global_event_bus.publish('ProgressUpdate', { + 'percentage': pct, + 'message': f'Step11 专题图: {done_count}/{total}', + }) + if done_count % max(1, total // 10) == 0 or done_count == total: + context.notify('step11_map', 'info', + f'专题图 [{done_count}/{total}]') + + step_end_time = time.time() + elapsed = step_end_time - step_start_time + context.record_step_time('步骤11: 专题图生成', step_start_time, step_end_time) + + result_msg = f'专题图完成:{len(generated)}/{total} 张({elapsed:.1f}s)' + if errors: + result_msg += f',{len(errors)} 个失败' + context.notify('step11_map', 'completed', result_msg) + + # 清理临时缓存 + try: + cache_dir = Path(str(context.work_dir)) / '.step11_cache' + if cache_dir.exists(): + shutil.rmtree(cache_dir, ignore_errors=True) + except Exception: + pass + + return { + 'generated': generated, + 'errors': errors, + 'output_dir': output_dir, + 'elapsed_seconds': elapsed, + } diff --git a/src/gui/_legacy/__init__.py b/src/gui/_legacy/__init__.py new file mode 100644 index 0000000..07f079e --- /dev/null +++ b/src/gui/_legacy/__init__.py @@ -0,0 +1,4 @@ +# _legacy — 旧版 V1 代码归档 +# 2026-07-01:water_quality_gui.py 是原始单体架构(~2000 行上帝类), +# 已被 water_quality_gui_v2.py(纯壳 + Manager 模式)取代。 +# 保留此文件仅供历史参考,不应被任何活跃代码引用。 diff --git a/src/gui/_legacy/water_quality_gui.py b/src/gui/_legacy/water_quality_gui.py new file mode 100644 index 0000000..a9f5ab7 --- /dev/null +++ b/src/gui/_legacy/water_quality_gui.py @@ -0,0 +1,1598 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +水质参数反演分析系统 - 图形用户界面 +GUI for Water Quality Inversion Pipeline +""" + +# ============================================================================== +# 🚀 终极防御:必须在全宇宙第一行强制载入 GDAL 底层 DLL,绝对杜绝 0xC0000005 内存崩溃 +# ============================================================================== +import osgeo +from osgeo import gdal, ogr + +import os +import json +import copy +import sys +import traceback +from pathlib import Path +from datetime import datetime +from typing import Dict, Optional, List, Union +import numpy as np +import pandas as pd +import multiprocessing +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QLabel, QLineEdit, QComboBox, QCheckBox, QSpinBox, + QDoubleSpinBox, QFileDialog, QTextEdit, QProgressBar, QMessageBox, + QScrollArea, QGroupBox, QTabWidget, QSplitter, QListWidget, + QListWidgetItem, QFrame, QGridLayout, QFormLayout, QSizePolicy, QDialog, + QStackedWidget, QTableView, QHeaderView, QAbstractItemView, + QRadioButton, QButtonGroup, QToolBar, QTreeWidget, QTreeWidgetItem, + QInputDialog, +) +from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer, QAbstractTableModel, QSize +from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPalette, QColor, QPixmap + +import sys +import traceback +import multiprocessing +import ctypes + +# ============================================================================== +# 🚀 终极防御置顶:在载入任何自定义面板、样式或子模块之前,强制提前创建 QApplication! +# 彻底杜绝 import 时期载入类属性 (如 QFont/QIcon/QPixmap) 触发的 QWidget 崩溃 +# ============================================================================== +if multiprocessing.current_process().name == 'MainProcess': + if not QApplication.instance(): + try: + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + except Exception: + pass + _global_app = QApplication(sys.argv) + +# 👇 全局异常钩子(保持不变) +def get_resource_path(relative_path: str) -> str: + """获取资源的绝对路径,适配 PyInstaller 打包环境。 + 打包后资源位于 sys._MEIPASS(解压临时目录),开发环境则基于 __file__ 向上三级。 + """ + if hasattr(sys, '_MEIPASS'): + return os.path.join(sys._MEIPASS, relative_path) + return os.path.abspath( + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), relative_path) + ) + + +def global_exception_handler(exc_type, exc_value, exc_traceback): + err_lines = traceback.format_exception(exc_type, exc_value, exc_traceback) + err_msg = "".join(err_lines) + dump_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "crash_dump.txt") + try: + with open(dump_path, "a", encoding="utf-8") as f: + f.write(f"\n{'='*60}\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]\n") + f.write(err_msg) + except Exception: + pass + try: + from PyQt5.QtWidgets import QMessageBox + from PyQt5.QtCore import Qt + msg = ( + "【严重错误 - 程序即将退出】\n\n" + "错误类型: {}\n\n" + "错误信息: {}\n\n" + "详细信息已写入:\n{}".format( + exc_type.__name__, + str(exc_value), + dump_path, + ) + ) + QMessageBox.critical(None, "程序崩溃", msg) + except Exception: + pass + +# 挂载全局异常钩子,阻止 PyQt 静默闪退 +sys.excepthook = global_exception_handler + +# 导入样式模块 - 兼容开发环境和 PyInstaller 打包 +try: + from styles import ModernStylesheet +except ImportError: + try: + from src.gui.styles import ModernStylesheet + except ImportError: + import sys + import os + current_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.abspath(os.path.join(current_dir, '..', '..')) + if project_root not in sys.path: + sys.path.insert(0, project_root) + from src.gui.styles import ModernStylesheet + +# 导入自定义组件 +from src.gui.components.custom_widgets import FileSelectWidget +from src.gui.components.chart_dialogs import ChartViewerDialog, ChartBrowserDialog, InteractiveViewerDialog, PandasTableModel +from src.gui.components.image_viewer_components import ImageCategoryTree, ImageViewerWidget + +# 导入面板注册中心(数据驱动,替代硬编码面板导入) +from src.gui.core.panel_registry import ( + PANEL_REGISTRY, + build_step_dependencies, + build_stage_groups, + get_tab_index, + get_step_id_by_tab_index, + get_entry, +) +from src.gui.core.event_bus import global_event_bus +from src.gui.core.dependency_subscriber import subscribe_panel_to_dependencies +from src.gui.dialogs import BandConfirmDialog, AISettingsDialog + +# Pipeline 核心异常(用于预检弹窗) +from src.core.pipeline.runner import PipelineHalt + +# Matplotlib相关导入 (推迟并加入底层防爆保护) +import matplotlib +try: + # 确保只在主线程且安全的环境下绑定后端 + matplotlib.use('Qt5Agg', force=False) +except Exception: + pass +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from matplotlib.figure import Figure +import matplotlib.pyplot as plt + +# 后台线程与 Pipeline 状态(由 core/worker_thread.py 提供) +from src.gui.core.worker_thread import ( + WorkerThread, + PIPELINE_AVAILABLE, + PIPELINE_ERROR_INFO, + check_pipeline_dependencies, + diagnose_pipeline_import_error, +) +# 预检交互对话框 +from src.gui.core.preflight_dialog import PreflightDialog +from src.gui.core.pipeline_mode_dialog import PipelineModeDialog +from src.gui.core.viz_thread import VisualizationWorkerThread, _viz_training_spectra_csv_path +from src.core.workspace_manager import WorkspaceManager + +class WaterQualityGUI(QMainWindow): + """水质参数反演分析系统主窗口""" + + def __init__(self): + # 1. 🚀 强制设置任务栏图标(解决任务栏图标默认是 Python 黄蓝图标的问题) + # 为当前进程设置独立的 AppUserModelID + my_appid = u'mycompany.megacube.waterquality.v1' + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_appid) + + super().__init__() + + # 2. 设置窗口图标(指向你的 .ico 文件) + icon_path = get_resource_path("data/icons-1/uitubiao.ico") + self.setWindowIcon(QIcon(icon_path)) + + self.pipeline = None + self.worker = None + self.config_file = None + self.work_dir = None # 工作目录 + + # 训练数据模式状态 + self.has_training_data = True # 默认有训练数据 + + # 工作空间管理器(文件扫描、路径发现、配置裁剪) + self.workspace_manager = WorkspaceManager() + + # 面板实例字典(step_id → panel instance),由 create_content_area 填充 + self._panels = {} + + # 从注册表构建步骤依赖关系 + self.step_dependencies = build_step_dependencies() + + self.init_ui() + self.apply_stylesheet() + self._disable_wheel_for_all_spinboxes() + + # 延迟调用工作目录选择对话框,确保主界面已完全渲染 + # 100ms 延迟足以让 GUI 事件循环启动并显示主窗口 + QTimer.singleShot(100, self.init_workspace) + + def get_icon_path(self, icon_filename): + """获取图标文件的完整路径(统一使用 get_resource_path)。""" + return get_resource_path(f"data/icons/{icon_filename}") + + def _disable_wheel_for_all_spinboxes(self): + """ + 遍历所有子控件,为 QSpinBox/QDoubleSpinBox/QComboBox 禁用滚轮事件 + 防止滚动页面时意外改变数值 + """ + from PyQt5.QtCore import Qt + + # 找到所有数值输入控件 + for spinbox in self.findChildren(QSpinBox): + spinbox.setFocusPolicy(Qt.StrongFocus) # 只有聚焦时才响应滚轮 + spinbox.wheelEvent = lambda event, sb=spinbox: None # 完全禁用滚轮 + + for spinbox in self.findChildren(QDoubleSpinBox): + spinbox.setFocusPolicy(Qt.StrongFocus) + spinbox.wheelEvent = lambda event, sb=spinbox: None + + for combobox in self.findChildren(QComboBox): + combobox.setFocusPolicy(Qt.StrongFocus) + combobox.wheelEvent = lambda event, cb=combobox: None + + def init_workspace(self): + """ + 初始化工作空间:弹出对话框选择工作目录 + 此方法通过 QTimer 延迟调用,确保主界面已完全渲染后再弹出对话框 + 如果用户取消或关闭,则退出程序 + """ + from PyQt5.QtWidgets import QMessageBox + + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Information) + msg_box.setWindowTitle("选择工作目录") + msg_box.setText("欢迎使用Mega Water!\n\n请选择工作目录来保存所有分析结果。") + msg_box.setInformativeText("工作目录将用于存储:\n• 水域掩膜文件\n• 耀斑检测结果\n• 模型训练数据\n• 预测结果与分布图\n\n点击'确定'选择目录") + msg_box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + msg_box.setDefaultButton(QMessageBox.Ok) + + result = msg_box.exec_() + + if result == QMessageBox.Cancel: + QMessageBox.warning(None, "取消操作", "未选择工作目录,程序将退出。") + sys.exit(0) + + # 弹出目录选择对话框 + work_dir = QFileDialog.getExistingDirectory( + self, # 使用 self 作为父窗口,而不是 None + "选择工作目录", + "", + QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks + ) + + if not work_dir: + QMessageBox.critical(self, "错误", "必须选择工作目录才能使用系统!\n程序即将退出。") + sys.exit(0) + + self.work_dir = work_dir + print(f"✓ 已选择工作目录: {self.work_dir}") + + # 选择完成后,自动填充输出路径 + self._auto_fill_output_paths() + + def _auto_fill_output_paths(self): + """ + 根据工作目录自动填充各步骤的输出路径 + 注意:Step1 的输出路径由 update_work_directory() 根据模式自动控制 + """ + if not self.work_dir: + return + + # Step1: 只传递工作目录引用,不直接填充路径 + # 路径填充由 Step1Panel 根据单选按钮状态自动控制 + step1_panel = self._panels.get('step1') + if step1_panel: + step1_panel.update_work_directory(self.work_dir) + + def init_ui(self): + """初始化UI""" + self.setWindowTitle("MegaCube-Water Quality ‌V1.2") + + # 获取屏幕可用区域(排除任务栏) + screen_geometry = QApplication.primaryScreen().availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + + # 初始尺寸:宽度固定 800,高度占满屏幕 + window_width = 1200 + window_height = screen_height + # 仅设置初始大小,不锁定 + self.resize(window_width, window_height) + + # 计算水平居中、垂直贴顶的位置 + x = (screen_width - window_width) // 2 + y = 0 + self.move(x, y) + + # 可选:设置最小尺寸,防止用户缩得太小 + self.setMinimumSize(600, 400) + + # 创建自定义标题栏(包含Logo和菜单栏) + self.create_title_bar() + + # 创建横幅区域 + self.create_banner_widget() + + # 创建中央部件 + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # 主布局 + main_layout = QHBoxLayout() + + # 创建左侧导航栏 + self.create_navigation() + main_layout.addWidget(self.nav_widget, 1) + + # 创建右侧内容区 + self.create_content_area() + main_layout.addWidget(self.content_widget, 4) + + central_widget.setLayout(main_layout) + + # 创建状态栏 + self.statusBar().showMessage("就绪") + + def create_title_bar(self): + """创建自定义标题栏(Logo和菜单栏挨着且同等宽度)""" + # 创建标题栏容器 + title_widget = QWidget() + title_layout = QHBoxLayout() + title_layout.setContentsMargins(8, 4, 8, 4) + title_layout.setSpacing(0) # 让Logo和菜单栏紧挨着 + + # Logo部分(左侧,增加宽度) + logo_label = QLabel() + logo_label.setFixedSize(180, 48) # 增加Logo宽度,使其和菜单栏视觉平衡 + logo_label.setAlignment(Qt.AlignCenter) + logo_label.setStyleSheet(""" + QLabel { + background-color: #f8f9fa; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + """) + + # 设置Logo图片路径 + logo_path = get_resource_path("data/icons/logo.png") + logo_pixmap = QPixmap(logo_path) + + if not logo_pixmap.isNull(): + # 按高度缩放图片,保持宽高比,让Logo更显眼 + scaled_pixmap = logo_pixmap.scaledToHeight(38, Qt.SmoothTransformation) + logo_label.setPixmap(scaled_pixmap) + else: + # 如果图片加载失败,显示占位符 + logo_label.setText("Logo") + logo_label.setStyleSheet(""" + QLabel { + background-color: #f8f9fa; + color: #333; + font-size: 14px; + font-weight: bold; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + """) + + title_layout.addWidget(logo_label) + + # 菜单栏(紧挨着Logo右侧) + menubar = self.menuBar() + menubar.setStyleSheet(""" + QMenuBar { + background-color: #f8f9fa; + border: none; + padding: 4px 8px; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } + QMenuBar::item { + padding: 6px 12px; + background-color: transparent; + font-size: 13px; + } + QMenuBar::item:selected { + background-color: #e6f0ff; + border-radius: 3px; + } + """) + + # 文件菜单 + file_menu = menubar.addMenu("文件") + + new_action = file_menu.addAction("新建配置") + new_action.triggered.connect(self.new_config) + + open_action = file_menu.addAction("打开配置") + open_action.triggered.connect(self.load_config_dialog) + + save_action = file_menu.addAction("保存配置") + save_action.triggered.connect(self.save_config_dialog) + + file_menu.addSeparator() + + exit_action = file_menu.addAction("退出") + exit_action.triggered.connect(self.close) + + # 工具菜单 + tools_menu = menubar.addMenu("工具") + + work_dir_action = tools_menu.addAction("设置工作目录") + work_dir_action.triggered.connect(self.set_work_directory) + + open_dir_action = tools_menu.addAction("打开工作目录") + open_dir_action.triggered.connect(self.open_work_directory) + + tools_menu.addSeparator() + + ai_config_action = tools_menu.addAction("AI 引擎配置...") + ai_config_action.triggered.connect(self._show_ai_settings) + + tools_menu.addSeparator() + + # 添加自动填充功能 + auto_fill_action = tools_menu.addAction("自动填充所有输入路径") + auto_fill_action.triggered.connect(self.auto_populate_all_steps) + auto_fill_action.setToolTip("根据工作目录中的文件自动填充各步骤的输入路径") + + # 在工具菜单中添加训练数据模式切换按钮 + self.training_mode_action = tools_menu.addAction("有训练数据模式") + self.training_mode_action.setCheckable(True) + self.training_mode_action.setChecked(True) # 默认有训练数据模式 + self.training_mode_action.triggered.connect(self.toggle_training_data_mode) + + # 帮助菜单 + help_menu = menubar.addMenu("帮助") + + pipeline_status_action = help_menu.addAction("检查Pipeline状态") + pipeline_status_action.triggered.connect(self.show_pipeline_status) + + help_menu.addSeparator() + + about_action = help_menu.addAction("关于") + about_action.triggered.connect(self.show_about) + + title_layout.addWidget(menubar) + title_widget.setLayout(title_layout) + + # 设置整体标题栏样式 + title_widget.setStyleSheet(""" + QWidget { + background-color: #f8f9fa; + border-bottom: 1px solid #d0d0d0; + } + """) + + # 将标题栏添加到窗口顶部 + self.setMenuWidget(title_widget) + + + def create_banner_widget(self): + """创建横幅区域 - 支持自适应等比缩放""" + # 横幅标题文字(方便后续直接修改版本号) + self._APP_TITLE = "MegaCube-Water Quality ‌V1.2" + + # 创建横幅容器 + banner_widget = QWidget() + banner_layout = QHBoxLayout() + banner_layout.setContentsMargins(0, 0, 0, 0) + banner_layout.setSpacing(0) + + # ===== 底图层 ===== + self.banner_label = QLabel() + self.banner_label.setMinimumHeight(140) + self.banner_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.banner_label.setScaledContents(False) + self.banner_label.setStyleSheet("margin: 0px; padding: 0px; border: none;") + + # 强制 banner_widget 展开填充 toolbar 全部宽度(清除 addWidget 的默认居中行为) + banner_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + banner_widget.setMinimumWidth(0) # 确保可以被 layout 压缩/扩展到任意宽度 + banner_widget.setStyleSheet("margin: 0px; padding: 0px; border: none;") + + # 纯净底图路径(无水印文字) + banner_path = get_resource_path("data/icons/Mega Water 1.0.jpg") + self.banner_pixmap = QPixmap(banner_path) + + if not self.banner_pixmap.isNull(): + QTimer.singleShot(50, self.update_banner_image) + else: + self.banner_label.setText("背景图加载失败") + self.banner_label.setStyleSheet(""" + QLabel { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #0078d4, stop:1 #00a0e9); + color: white; font-size: 26px; font-weight: bold; + border-bottom: 3px solid #005a9e; + } + """) + + banner_layout.addWidget(self.banner_label) + + # ===== 文字叠加层 ===== + # 注意这里:第二个参数改成了 self.banner_label,直接附着在底图上! + self.banner_title_label = QLabel(self._APP_TITLE, self.banner_label) + self.banner_title_label.setStyleSheet(""" + QLabel { + background: transparent; + color: white; + font-size: 48px; /* 显著增大字号 */ + font-weight: normal; /* 取消粗体,还原原图的优雅感 */ + font-family: "Times New Roman", "Georgia", "STZhongsong", serif; /* 衬线字体家族 */ + letter-spacing: 1px; + } + """) + self.banner_title_label.setAttribute(Qt.WA_TransparentForMouseEvents) # 鼠标穿透 + self.banner_title_label.show() # 第一道保险:强制现身 + self.banner_title_label.raise_() # 第二道保险:强制图层置顶 + + banner_widget.setLayout(banner_layout) + + # 将横幅添加到窗口顶部(在标题栏下方) + banner_toolbar = QToolBar() + banner_toolbar.setMovable(False) + banner_toolbar.setFloatable(False) + banner_toolbar.addWidget(banner_widget) + banner_toolbar.setContentsMargins(0, 0, 0, 0) # 清除工具栏布局的边距 + banner_toolbar.setStyleSheet(""" + QToolBar { + background-color: white; + border: none; + border-bottom: 1px solid #ddd; + padding: 0px; + margin: 0px; + spacing: 0px; + } + QToolBar QWidget { + margin: 0px; + padding: 0px; + } + QToolBar > QWidget { + margin: 0px; + padding: 0px; + } + """) + + self.addToolBar(Qt.TopToolBarArea, banner_toolbar) + self.banner_widget = banner_toolbar + + def create_navigation(self): + """创建左侧导航栏""" + self.nav_widget = QWidget() + nav_layout = QVBoxLayout() + nav_layout.setContentsMargins(10, 15, 10, 15) + nav_layout.setSpacing(10) + + # 标题 + title = QLabel("流程步骤") + title.setFont(QFont("Arial", 13, QFont.Bold)) + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet(f"color: {ModernStylesheet.COLORS['text_primary']}; padding: 10px;") + nav_layout.addWidget(title) + + # 步骤列表 - 分层结构 + self.step_list = QListWidget() + self.step_list.setStyleSheet(ModernStylesheet.get_sidebar_stylesheet()) + + # 从注册表动态构建阶段分组 + self.process_stages = build_stage_groups() + + # 存储步骤映射 + self.step_name_map = {} + + # 添加分组项到列表 + stage_names = list(self.process_stages.keys()) + for stage_idx, (stage_name, steps) in enumerate(self.process_stages.items()): + # 添加阶段标题项(可视化分组) + stage_item = QListWidgetItem(stage_name) + stage_font = QFont("Arial", 11, QFont.Bold) + stage_font.setBold(True) + stage_item.setFont(stage_font) + stage_item.setForeground(QColor("#0078D4")) + stage_item.setFlags(stage_item.flags() & ~Qt.ItemIsSelectable) + stage_item.setFlags(stage_item.flags() & ~Qt.ItemIsEnabled) + stage_item.setData(Qt.UserRole, "stage_header") + self.step_list.addItem(stage_item) + + # 添加该阶段的所有步骤 + for step_id, step_display in steps: + item = QListWidgetItem(f" {step_display}") + item.setData(Qt.UserRole, step_id) + + self.step_name_map[step_display] = step_id + + # 设置步骤项的样式 + step_font = QFont("Arial", 10) + item.setFont(step_font) + item.setForeground(QColor(ModernStylesheet.COLORS.get('text_secondary', '#666666'))) + self.step_list.addItem(item) + + # 在阶段间添加分隔符 + if stage_idx < len(stage_names) - 1: + separator_item = QListWidgetItem("") + separator_item.setFlags(separator_item.flags() & ~Qt.ItemIsSelectable) + separator_item.setFlags(separator_item.flags() & ~Qt.ItemIsEnabled) + self.step_list.addItem(separator_item) + + self.step_list.currentRowChanged.connect(self.on_step_changed) + nav_layout.addWidget(self.step_list) + + # 控制按钮 + btn_layout = QVBoxLayout() + btn_layout.setSpacing(8) + + self.run_all_btn = QPushButton("> 运行完整流程") + self.run_all_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) + self.run_all_btn.setMinimumHeight(35) + self.run_all_btn.clicked.connect(self.run_full_pipeline) + btn_layout.addWidget(self.run_all_btn) + + self.stop_btn = QPushButton("⏹ 停止") + self.stop_btn.setEnabled(False) + self.stop_btn.setMinimumHeight(35) + self.stop_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('danger')) + self.stop_btn.clicked.connect(self.stop_pipeline) + btn_layout.addWidget(self.stop_btn) + + nav_layout.addLayout(btn_layout) + + self.nav_widget.setLayout(nav_layout) + self.nav_widget.setMaximumWidth(280) + self.nav_widget.setStyleSheet(f"background-color: {ModernStylesheet.COLORS['panel_bg']}; border-right: 1px solid {ModernStylesheet.COLORS['border_light']};") + + + def create_content_area(self): + """创建右侧内容区""" + self.content_widget = QWidget() + self.content_widget.setStyleSheet(f"background-color: {ModernStylesheet.COLORS['main_bg']};") + content_layout = QVBoxLayout() + content_layout.setContentsMargins(15, 15, 15, 15) + content_layout.setSpacing(10) + + # 创建步骤面板容器 + self.step_stack = QTabWidget() + self.step_stack.setTabPosition(QTabWidget.North) + self.step_stack.setTabsClosable(False) + self.step_stack.setStyleSheet(ModernStylesheet.get_main_stylesheet()) + + # 添加各步骤面板(从注册表动态实例化) + for entry in PANEL_REGISTRY: + step_id = entry['step_id'] + cls = entry['class_ref'] + title = entry['title'] + icon_name = entry['icon'] + kwargs = entry.get('constructor_kwargs') + + # 处理特殊构造参数(如 Step13ReportPanel 需要 main_window=self) + if kwargs: + resolved_kwargs = {} + for k in kwargs: + if k == 'main_window': + resolved_kwargs[k] = self + panel = cls(**resolved_kwargs) + else: + panel = cls() + + self._panels[step_id] = panel + self.step_stack.addTab( + self.create_scroll_area(panel), + QIcon(self.get_icon_path(icon_name)), + title + ) + + # ★ 事件驱动:面板根据注册表依赖自动订阅 OutputUpdated 事件 + deps = entry.get('dependencies') + if deps: + subscribe_panel_to_dependencies(panel, step_id, deps) + + # 连接Tab切换信号,实现双向同步(必须在step_stack创建后) + self.step_stack.currentChanged.connect(self.on_tab_changed) + + content_layout.addWidget(self.step_stack, 3) + + # 日志区域 + log_group = QGroupBox("执行日志") + log_group.setStyleSheet(f""" + QGroupBox {{ + background-color: {ModernStylesheet.COLORS['panel_bg']}; + border: 1px solid {ModernStylesheet.COLORS['border_light']}; + border-radius: 5px; + margin-top: 8px; + padding-top: 15px; + padding-left: 9px; + padding-right: 9px; + padding-bottom: 9px; + }} + QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 0 5px; + font-weight: bold; + color: {ModernStylesheet.COLORS['text_primary']}; + }} + """) + log_layout = QVBoxLayout() + log_layout.setContentsMargins(5, 5, 5, 5) + + self.log_text = QTextEdit() + self.log_text.setReadOnly(True) + self.log_text.setMaximumHeight(200) + self.log_text.setStyleSheet(f""" + QTextEdit {{ + background-color: {ModernStylesheet.COLORS['panel_bg']}; + color: {ModernStylesheet.COLORS['text_primary']}; + border: 1px solid {ModernStylesheet.COLORS['border']}; + border-radius: 4px; + padding: 5px; + font-family: 'Courier New', monospace; + font-size: 10px; + }} + """) + log_layout.addWidget(self.log_text) + + log_btn_layout = QHBoxLayout() + clear_log_btn = QPushButton("清空日志") + clear_log_btn.setMaximumWidth(100) + clear_log_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal')) + clear_log_btn.clicked.connect(self.clear_log) + log_btn_layout.addWidget(clear_log_btn) + log_btn_layout.addStretch() + log_layout.addLayout(log_btn_layout) + + log_group.setLayout(log_layout) + content_layout.addWidget(log_group, 1) + + # 进度条 + progress_group = QGroupBox("执行进度") + progress_group.setStyleSheet(f""" + QGroupBox {{ + background-color: {ModernStylesheet.COLORS['panel_bg']}; + border: 1px solid {ModernStylesheet.COLORS['border_light']}; + border-radius: 5px; + margin-top: 8px; + padding-top: 10px; + padding-left: 9px; + padding-right: 9px; + padding-bottom: 9px; + }} + QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 0 5px; + font-weight: bold; + color: {ModernStylesheet.COLORS['text_primary']}; + }} + """) + progress_layout = QVBoxLayout() + progress_layout.setContentsMargins(5, 5, 5, 5) + + self.progress_bar = QProgressBar() + self.progress_bar.setValue(0) + self.progress_bar.setStyleSheet(f""" + QProgressBar {{ + background-color: {ModernStylesheet.COLORS['panel_bg']}; + border: 1px solid {ModernStylesheet.COLORS['border']}; + border-radius: 4px; + padding: 2px; + text-align: center; + height: 20px; + }} + QProgressBar::chunk {{ + background-color: {ModernStylesheet.COLORS['success']}; + border-radius: 3px; + }} + """) + progress_layout.addWidget(self.progress_bar) + progress_group.setLayout(progress_layout) + content_layout.addWidget(progress_group, 0) + + self.content_widget.setLayout(content_layout) + + # 初始化训练数据模式UI状态 + self.update_ui_for_training_mode() + + # 为步骤面板添加自动填充功能 + self.add_auto_fill_buttons_to_panels() + + # 显示pipeline状态 + self.show_pipeline_status_on_startup() + + def create_scroll_area(self, widget): + """创建滚动区域""" + scroll = QScrollArea() + scroll.setWidget(widget) + scroll.setWidgetResizable(True) + return scroll + + def on_step_changed(self, index): + """步骤切换 - 处理分层列表结构""" + if index < 0: + return + + # 获取选中项 + item = self.step_list.item(index) + if not item: + return + + # 检查是否是可选中的步骤项 + item_data = item.data(Qt.UserRole) + if item_data == "stage_header" or item_data is None: + # 是阶段标题或分隔符,不切换 + return + + # 根据步骤ID查找对应的tab索引(从注册表动态映射) + tab_index = get_tab_index(item_data) + if tab_index >= 0: + self.step_stack.setCurrentIndex(tab_index) + + def on_tab_changed(self, index): + """Tab页面切换时同步更新左侧步骤列表""" + if index < 0: + return + + # Tab索引到步骤ID的反向映射(从注册表动态获取) + target_step_id = get_step_id_by_tab_index(index) + if target_step_id is None: + return + + # 在step_list中查找对应的步骤项 + for row in range(self.step_list.count()): + item = self.step_list.item(row) + if not item: + continue + + item_data = item.data(Qt.UserRole) + if item_data == target_step_id: + self.step_list.setCurrentRow(row) + break + + # 面板自动填充:从注册表获取面板实例 + panel = self._panels.get(target_step_id) + if panel and hasattr(panel, 'update_from_config'): + panel.update_from_config(work_dir=self.work_dir, pipeline=self.pipeline) + + def apply_stylesheet(self): + """应用样式表 - 应用现代化设计风格""" + # 应用主样式表 + self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) + + def new_config(self): + """新建配置""" + reply = QMessageBox.question( + self, "新建配置", + "是否清空当前配置?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + # 重置所有面板 + self.log_message("已清空配置", "info") + + def load_config_dialog(self): + """加载配置对话框""" + file_path, _ = QFileDialog.getOpenFileName( + self, "加载配置", "", "JSON Files (*.json);;All Files (*.*)" + ) + if file_path: + self.load_config(file_path) + + def load_config(self, file_path): + """加载配置""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + # 应用配置到各面板(动态遍历,仅调用有 set_config 的面板) + for step_id, panel in self._panels.items(): + if step_id in config and hasattr(panel, 'set_config'): + panel.set_config(config[step_id]) + + self.config_file = file_path + self.log_message(f"已加载配置: {file_path}", "info") + QMessageBox.information(self, "成功", "配置加载成功!") + except Exception as e: + self.log_message(f"加载配置失败: {str(e)}", "error") + QMessageBox.critical(self, "错误", f"加载配置失败:\n{str(e)}") + + def save_config_dialog(self): + """保存配置对话框""" + file_path, _ = QFileDialog.getSaveFileName( + self, "保存配置", "config.json", "JSON Files (*.json);;All Files (*.*)" + ) + if file_path: + self.save_config(file_path) + + def save_config(self, file_path): + """保存配置""" + try: + config = self.get_current_config() + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=4, ensure_ascii=False) + + self.config_file = file_path + self.log_message(f"已保存配置: {file_path}", "info") + QMessageBox.information(self, "成功", "配置保存成功!") + except Exception as e: + self.log_message(f"保存配置失败: {str(e)}", "error") + QMessageBox.critical(self, "错误", f"保存配置失败:\n{str(e)}") + + def get_current_config(self): + """获取当前配置(动态遍历所有面板,仅收集有 get_config 的面板)""" + config = {} + for step_id, panel in self._panels.items(): + if hasattr(panel, 'get_config'): + config[step_id] = panel.get_config() + return config + + def auto_populate_step_inputs(self, step_id): + """自动填充指定步骤的输入路径(事件总线已接管,保留接口兼容)。""" + return 0 + + def get_step_panel(self, step_id): + """根据步骤ID获取对应的面板对象(从动态注册表查找)""" + return self._panels.get(step_id) + + def auto_populate_all_steps(self): + """扫描工作目录并触发事件总线自动填充所有步骤的输入路径。""" + work_dir = getattr(self, 'work_dir', './work_dir') + work_path = Path(work_dir) + + if not work_path.exists(): + QMessageBox.warning(self, "警告", f"工作目录不存在: {work_dir}\n请先设置正确的工作目录。") + return + + # 扫描工作目录 → WorkspaceManager 发布 OutputUpdated 事件 → 面板自动填充 + self.workspace_manager.scan_work_directory_for_files(work_path) + + # 补充发布 reference_img(step1 的输入影像,非 step1 产出但下游依赖它) + step1_panel = self._panels.get('step1') + if step1_panel and hasattr(step1_panel, 'img_file'): + ref_img = step1_panel.img_file.get_path() + if ref_img: + global_event_bus.publish('OutputUpdated', { + 'step_id': 'step1', + 'output_type': 'reference_img', + 'path': ref_img, + }) + + self.log_message("✓ 工作目录扫描完成,事件总线已通知所有面板自动填充", "info") + QMessageBox.information(self, "完成", "工作目录扫描完成!\n各步骤输入路径已自动填充。") + + def add_auto_fill_buttons_to_panels(self): + """为各个步骤面板添加自动填充按钮(动态遍历所有面板)""" + for step_id, panel in self._panels.items(): + if not panel: + continue + # 为面板添加自动填充方法 + def create_auto_fill_method(sid): + def auto_fill_inputs(): + self.auto_populate_step_inputs(sid) + return auto_fill_inputs + + panel.auto_fill_inputs = create_auto_fill_method(step_id) + + # 尝试添加自动填充按钮到面板(如果面板支持的话) + self.try_add_auto_fill_button(panel) + + def try_add_auto_fill_button(self, panel): + """尝试为面板添加自动填充按钮""" + try: + # 查找面板中的运行按钮,在其旁边添加自动填充按钮 + if hasattr(panel, 'run_btn') and hasattr(panel, 'auto_fill_inputs'): + run_btn = panel.run_btn + + # 检查是否已经有自动填充按钮了 + if hasattr(panel, 'auto_fill_btn'): + return + + # 创建自动填充按钮 + auto_fill_btn = QPushButton("🔄 自动填充") + auto_fill_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal')) + auto_fill_btn.setToolTip("从工作目录自动填充输入路径") + auto_fill_btn.clicked.connect(panel.auto_fill_inputs) + auto_fill_btn.setMaximumWidth(120) + + # 获取运行按钮的父布局 + parent_layout = run_btn.parent().layout() + if parent_layout: + # 找到运行按钮在布局中的位置 + for i in range(parent_layout.count()): + if parent_layout.itemAt(i).widget() == run_btn: + # 创建水平布局容器 + btn_container = QWidget() + btn_layout = QHBoxLayout(btn_container) + btn_layout.setContentsMargins(0, 0, 0, 0) + + # 从原布局中移除运行按钮 + parent_layout.removeWidget(run_btn) + + # 添加按钮到新的水平布局 + btn_layout.addWidget(auto_fill_btn) + btn_layout.addWidget(run_btn) + + # 将按钮容器添加到原位置 + parent_layout.insertWidget(i, btn_container) + + panel.auto_fill_btn = auto_fill_btn + break + except Exception as e: + # 如果添加失败,静默忽略 + pass + + def set_work_directory(self): + """设置工作目录""" + dir_path = QFileDialog.getExistingDirectory(self, "选择工作目录") + if dir_path: + self.work_dir = dir_path + self.log_message(f"工作目录已设置: {dir_path}", "info") + self.statusBar().showMessage(f"工作目录: {dir_path}") + + # 同步到可视化/报告面板 + for sid in ('step12_viz', 'step13_report'): + panel = self._panels.get(sid) + if panel and hasattr(panel, 'set_work_dir'): + panel.set_work_dir(dir_path) + + def open_work_directory(self): + """打开工作目录""" + work_dir = getattr(self, 'work_dir', './work_dir') + if os.path.exists(work_dir): + os.startfile(work_dir) + else: + QMessageBox.warning(self, "警告", "工作目录不存在!") + + def show_pipeline_status_on_startup(self): + """启动时显示Pipeline状态""" + if not PIPELINE_AVAILABLE: + # 如果pipeline不可用,显示警告信息 + status_msg = "[WARNING] Pipeline模块无法加载\n\n" + status_msg += "系统功能将受到限制,建议检查以下问题:\n" + status_msg += "• 项目文件结构是否完整\n" + status_msg += "• Python依赖包是否已安装\n" + status_msg += "• Python版本是否兼容\n\n" + status_msg += "点击 '帮助' → '检查Pipeline状态' 查看详细信息" + + QMessageBox.warning(self, "Pipeline模块警告", status_msg) + else: + # 如果pipeline可用,只在状态栏显示 + self.statusBar().showMessage("Pipeline模块: 正常加载", 5000) # 显示5秒 + + def show_pipeline_status(self): + """显示Pipeline状态""" + status_text = "Pipeline模块状态检查\n\n" + + if PIPELINE_AVAILABLE: + status_text += "[OK] Pipeline模块状态: 正常\n\n" + status_text += "详细诊断信息:\n" + else: + status_text += "[ERROR] Pipeline模块状态: 不可用\n\n" + status_text += "详细诊断信息:\n" + + for info in PIPELINE_ERROR_INFO: + status_text += info + "\n" + + # 添加使用建议 + status_text += "\n" + "="*50 + "\n" + if PIPELINE_AVAILABLE: + status_text += "[SUCCESS] Pipeline模块已成功加载,可以正常使用所有功能!\n" + else: + status_text += "[WARNING] Pipeline模块无法加载,功能将受到限制\n" + status_text += "建议解决方案:\n" + status_text += "1. 检查项目文件结构是否完整\n" + status_text += "2. 安装所有必需的依赖包\n" + status_text += "3. 确认Python版本兼容性\n" + status_text += "4. 查看控制台输出获取更多详细信息\n" + + # 创建消息框 + msg_box = QMessageBox(self) + msg_box.setWindowTitle("Pipeline状态检查") + msg_box.setText(status_text) + + # 根据状态设置图标 + if PIPELINE_AVAILABLE: + msg_box.setIcon(QMessageBox.Information) + else: + msg_box.setIcon(QMessageBox.Warning) + + # 设置详细文本(如果有的话) + if PIPELINE_ERROR_INFO: + detailed_text = "\n".join(PIPELINE_ERROR_INFO) + msg_box.setDetailedText(detailed_text) + + msg_box.setStandardButtons(QMessageBox.Ok) + msg_box.exec_() + + def show_about(self): + """显示关于对话框""" + QMessageBox.about( + self, "关于", + "MegaCube-Water Quality ‌V1.2\n\n" + "一个完整的水质参数反演工作流程工具\n\n" + "功能包括:\n" + "- 水域掩膜生成\n" + "- 耀斑检测与去除\n" + "- 光谱提取\n" + "- 机器学习建模\n" + "- 水质参数预测\n" + "- 可视化分析\n\n" + "公司:北京依锐思遥感技术有限公司\n" + "地址:北京市海淀区清河安宁庄东路18号5号楼二层205\n" + "电话:010-51292601\n" + "邮箱:hanshanlong@iris-rs.cn\n" + ) + + def _show_ai_settings(self): + """弹出 AI 引擎配置对话框。""" + dlg = AISettingsDialog(self) + dlg.exec_() + + def _precheck_step3_bands(self) -> bool: + """步骤 3 波段越界预检(主线程同步执行,避多线程弹窗坑) + + 读取 step1 影像的 RasterCount,校验 step3 面板当前方法下所有波段索引 + (nir_lower/nir_upper/nir_band/oxy_band/lower_oxy/upper_oxy/hedley_nir_band) + 是否越界。若越界,弹 BandConfirmDialog(60s 倒计时)让用户调整或取消。 + + Returns: + True: 预检通过或已自动调整,run_full_pipeline 继续 + False: 用户点"取消运行",run_full_pipeline 应 return + """ + # 1) 取 step1 影像路径 + step3 配置 + enabled 标志 + try: + step1_panel = self._panels.get('step1') + step3_panel = self._panels.get('step3') + img_path = step1_panel.img_file.get_path() if step1_panel else None + step3_cfg = step3_panel.get_config() if step3_panel else None + step3_enabled = step3_panel.enable_checkbox.isChecked() if step3_panel else False + except Exception as e: + self.log_message(f"⚠ step3 波段预检:读取面板状态失败 - {e}", "warning") + return True # 失败不阻断(防御性:放行比误杀好) + + # 早退条件:step3 禁用 / 无 img_path / 无 cfg + if not step3_enabled: + return True + if not img_path or not os.path.isfile(img_path): + self.log_message("⚠ step3 波段预检:未找到参考影像,跳过", "info") + return True + if not step3_cfg: + return True + + # 2) 读 RasterCount(gdal 头信息读取,毫秒级不卡 UI) + try: + dataset = gdal.Open(img_path) + if dataset is None: + self.log_message(f"⚠ step3 波段预检:gdal 无法打开影像 {img_path}", "warning") + return True + max_band = dataset.RasterCount + dataset = None + except Exception as e: + self.log_message(f"⚠ step3 波段预检:读取 RasterCount 失败 - {e}", "warning") + return True + + if max_band <= 0: + return True + + # 3) 不同方法对应不同的波段字段(cfg_key, panel_attr, 推荐值, 标签) + method = step3_cfg.get('method', 'goodman') + if method == 'goodman': + band_fields = [ + ('nir_lower', 'nir_lower', 65, 'NIR下波段'), + ('nir_upper', 'nir_upper', 91, 'NIR上波段'), + ] + elif method == 'kutser': + band_fields = [ + ('oxy_band', 'oxy_band', 38, '氧吸收波段'), + ('lower_oxy', 'lower_oxy', 36, '下氧吸收波段'), + ('upper_oxy', 'upper_oxy', 49, '上氧吸收波段'), + ('nir_band', 'nir_band', 47, 'NIR波段'), + ] + elif method == 'hedley': + band_fields = [ + ('hedley_nir_band', 'hedley_nir_band', 47, 'NIR波段'), + ] + else: # sugar 无波段索引 + return True + + # 4) 逐字段检查;遇到第一个越界就弹窗(用户处理完继续检查下一个) + for cfg_key, panel_attr, recommended, label in band_fields: + requested = step3_cfg.get(cfg_key) + if requested is None or requested <= max_band: + continue # 没设 / 没越界 + + self.log_message( + f"⚠ step3 波段越界:{label}={requested} > 影像波段数 {max_band}", + "warning", + ) + + dlg = BandConfirmDialog( + self, + requested_band=requested, + max_band=max_band, + recommended_band=recommended, + method_label=label, + ) + result = dlg.exec_() + if result == QDialog.Rejected: + self.log_message("✗ 用户取消运行(step3 波段越界未解决)", "warning") + return False + + new_band = dlg.selected_band() + try: + spin = getattr(step3_panel, panel_attr) + spin.setValue(new_band) + except AttributeError: + self.log_message(f"⚠ step3 panel 缺控件 {panel_attr},跳过回写", "warning") + continue + + self.log_message( + f"✓ {label}:{requested} → {new_band}(影像最多 {max_band} 波段)", + "info", + ) + + return True + + def run_full_pipeline(self): + """运行完整流程""" + if not PIPELINE_AVAILABLE: + QMessageBox.critical( + self, "错误", + "无法导入 Pipeline 模块,请检查 src/core/handlers/ 目录是否完整!" + ) + return + + # ── 0) 强制获取 work_dir(禁止依赖外部或全局变量) ── + work_dir = getattr(self, 'work_dir', None) + if not work_dir: + QMessageBox.warning(self, "警告", "未选择工作目录,请先设置工作目录。") + return + + # ── 1) 运行前智能预检与自动回填(硬盘已有产物自动跳过) ── + work_path = Path(work_dir) + self.log_message("正在进行运行前环境预检与自动扫描...", "info") + self.workspace_manager.scan_work_directory_for_files(work_path) + self.auto_populate_all_steps() + self.log_message("✓ 预检完成:已扫描工作目录并自动回填已落盘的产物", "info") + + # ── 1.5) step3 波段越界预检(60s 倒计时弹窗,主线程同步,避开多线程弹窗坑) ── + if not self._precheck_step3_bands(): + return # 用户点"取消运行" + + # ── 1.6) ★ 全流程模式选择弹窗 ── + mode_dlg = PipelineModeDialog(main_window=self, parent=self) + if mode_dlg.exec() != QDialog.Accepted: + return # 用户点"取消" + selected_mode = mode_dlg.selected_mode + self.log_message(f"[模式选择] 选定模式: {'训练新模型' if selected_mode == 'training' else '使用已有模型直接预测'}", "info") + + # ── 2) 刷新配置(拿到自动填充后的"满血版" config) ── + config = self.get_current_config() + + # ── 2.1) ★ 根据模式动态裁剪配置 ── + if selected_mode == "prediction_only": + config = self.workspace_manager.prune_config_for_prediction_mode(config) + self.log_message("[模式选择] 已裁剪训练相关步骤(step4/5/7/8),进入仅预测模式", "info") + + # ── 3) ★ 一次性全预检 + 用户交互式决策 ── + missing_items = PreflightDialog.build_missing_items(config) + if missing_items: + critical_items = [it for it in missing_items if it.is_critical] + if critical_items: + lines = "\n".join(f" - [{it.step_name}] {it.reason}" for it in critical_items) + QMessageBox.critical( + self, "预检失败(阻断性错误)", + f"以下为阻断性缺失,流程无法启动:\n\n{lines}\n\n" + "请填写后重新运行。" + ) + return + dialog = PreflightDialog(missing_items, parent=self) + if dialog.exec() != QDialog.Accepted: + return + result = dialog.get_result() + if result is None: + return + action, *payload = result + if action == "fill": + _, step_id, tab_index = result + self.step_stack.setCurrentIndex(tab_index) + self.log_message(f"[预检] 用户选择填写 {step_id},已切换到对应面板。", "info") + return + skip_list: List[str] = payload[0] if payload else [] + if skip_list: + self.log_message(f"[预检] 用户强制跳过 {len(skip_list)} 个步骤: {skip_list}", "info") + else: + skip_list = [] + + # ── 4) 确认执行 + reply = QMessageBox.question( + self, "确认", + "是否开始执行完整流程?\n\n这可能需要较长时间,请确保配置正确。", + QMessageBox.Yes | QMessageBox.No + ) + + if reply != QMessageBox.Yes: + return + + # 创建pipeline实例 + self.log_message(f"初始化pipeline,工作目录: {work_dir}", "info") + + # 准备实际运行配置(排除未启用的步骤) + worker_config = copy.deepcopy(config) + step6_cfg = worker_config.get('step6_feature') + if step6_cfg: + enabled = step6_cfg.pop('enabled', True) + if not enabled: + worker_config.pop('step6_feature', None) + + # 工作线程内创建 Pipeline,避免主线程阻塞及 Qt5Agg 子线程绘图卡死 + self.worker = WorkerThread(work_dir, worker_config, mode='full', skip_list=skip_list) + self.worker.log_message.connect(self.log_message, Qt.QueuedConnection) + self.worker.progress_update.connect(self.update_progress, Qt.QueuedConnection) + self.worker.step_completed.connect(self.on_step_completed, Qt.QueuedConnection) + self.worker.finished.connect(self.on_pipeline_finished, Qt.QueuedConnection) + + # 更新UI状态 + self.run_all_btn.setEnabled(False) + self.stop_btn.setEnabled(True) + self.progress_bar.setValue(0) + + # 启动线程 + self.worker.start() + self.log_message("="*50, "info") + self.log_message("开始执行完整流程...", "info") + self.log_message("="*50, "info") + + def stop_pipeline(self): + """停止流程""" + if self.worker and self.worker.isRunning(): + reply = QMessageBox.question( + self, "确认", + "是否停止当前流程?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + self.worker.stop() + self.log_message("用户取消执行", "warning") + self.run_all_btn.setEnabled(True) + self.stop_btn.setEnabled(False) + + def on_pipeline_finished(self, success, message): + """流程完成""" + self.run_all_btn.setEnabled(True) + self.stop_btn.setEnabled(False) + + if success: + self.progress_bar.setValue(100) + self.log_message("="*50, "info") + self.log_message("流程执行完成!", "info") + self.log_message("="*50, "info") + QMessageBox.information(self, "完成", "流程执行成功!\n\n请查看工作目录中的结果文件。") + else: + self.log_message("="*50, "error") + self.log_message(f"流程执行失败: {message}", "error") + self.log_message("="*50, "error") + QMessageBox.critical(self, "失败", f"流程执行失败:\n\n{message[:200]}") + + def on_step_completed(self, step_name, success, message): + """步骤完成回调:记录输出路径,WorkspaceManager 自动发布 EventBus 事件。""" + if not success: + return + + work_dir = getattr(self, 'work_dir', './work_dir') + work_path = Path(work_dir) + + if step_name not in self.workspace_manager.step_outputs: + self.workspace_manager.step_outputs[step_name] = {} + + # WorkspaceManager.update_step_outputs 内部会发布 OutputUpdated 事件 + # 下游面板通过 DependencySubscriber 自动接收并填充 + self.workspace_manager.update_step_outputs(step_name, work_path) + + def run_single_step(self, step_name, config): + """运行单个步骤""" + if not PIPELINE_AVAILABLE: + QMessageBox.critical( + self, "错误", + "无法导入 Pipeline 模块,请检查 src/core/handlers/ 目录是否完整!" + ) + return + + # 创建pipeline实例 + work_dir = getattr(self, 'work_dir', './work_dir') + self.log_message(f"初始化pipeline,工作目录: {work_dir}", "info") + + self.worker = WorkerThread(work_dir, config, mode='single_step', step_name=step_name) + self.worker.log_message.connect(self.log_message, Qt.QueuedConnection) + self.worker.progress_update.connect(self.update_progress, Qt.QueuedConnection) + self.worker.step_completed.connect(self.on_step_completed, Qt.QueuedConnection) + self.worker.finished.connect(self.on_pipeline_finished, Qt.QueuedConnection) + + # 更新UI状态 + self.run_all_btn.setEnabled(False) + self.stop_btn.setEnabled(True) + self.progress_bar.setValue(0) + + # 启动线程 + self.worker.start() + self.log_message("="*50, "info") + self.log_message(f"开始独立运行步骤 {step_name}...", "info") + self.log_message("="*50, "info") + + def update_progress(self, percentage, message): + """更新进度""" + self.progress_bar.setValue(percentage) + self.statusBar().showMessage(message) + + def log_message(self, message, level='info'): + """记录日志""" + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + # 设置颜色 + if level == 'error': + color = 'red' + elif level == 'warning': + color = 'orange' + else: + color = 'black' + + # 添加到日志 + formatted_msg = f'[{timestamp}] {message}' + self.log_text.append(formatted_msg) + + # 自动滚动到底部 + cursor = self.log_text.textCursor() + cursor.movePosition(QTextCursor.End) + self.log_text.setTextCursor(cursor) + + def clear_log(self): + """清空日志""" + self.log_text.clear() + self.log_message("日志已清空", "info") + + def toggle_training_data_mode(self, checked): + """切换训练数据模式""" + self.has_training_data = checked + self.update_ui_for_training_mode() + + mode_text = "有训练数据" if checked else "无训练数据" + self.log_message(f"切换到{mode_text}模式", "info") + self.statusBar().showMessage(f"当前模式: {mode_text}") + + # 更新按钮文本 + self.training_mode_action.setText("有训练数据模式" if checked else "无训练数据模式") + + def update_banner_image(self): + """更新横幅图片 - 固定高度 + 居中裁剪(类似 CSS object-fit: cover)""" + if not hasattr(self, 'banner_pixmap') or self.banner_pixmap.isNull(): + return + + TARGET_HEIGHT = 140 + target_width = self.width() + + # 手动计算 Cover 缩放比例:取宽/高各自所需比例的最大值, + # 确保缩放后图片的宽 ≥ target_width 且高 ≥ TARGET_HEIGHT + orig_w = self.banner_pixmap.width() + orig_h = self.banner_pixmap.height() + scale_factor = max(target_width / orig_w, TARGET_HEIGHT / orig_h) + new_w = int(orig_w * scale_factor) + new_h = int(orig_h * scale_factor) + + scaled_pixmap = self.banner_pixmap.scaled( + new_w, new_h, + Qt.IgnoreAspectRatio, + Qt.SmoothTransformation + ) + + # 居中裁剪,截取中间核心区域 + crop_x = (new_w - target_width) // 2 + crop_y = (new_h - TARGET_HEIGHT) // 2 + final_pixmap = scaled_pixmap.copy(crop_x, crop_y, target_width, TARGET_HEIGHT) + + self.banner_label.setFixedHeight(TARGET_HEIGHT) + self.banner_label.setFixedWidth(target_width) # 强制宽度填满容器 + self.banner_label.setPixmap(final_pixmap) + self.banner_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + + # 文字叠加层:绝对定位,保持垂直居中 + if hasattr(self, 'banner_title_label'): + title_x = 160 + title_y = max(0, (TARGET_HEIGHT - 60) // 2) + self.banner_title_label.move(title_x, title_y) + self.banner_title_label.resize(target_width - title_x - 20, 60) + + def resizeEvent(self, event): + """窗口大小改变事件 - 实时更新横幅图片等比缩放""" + super().resizeEvent(event) + # 直接调用,不使用定时器延迟(或缩短到 10ms) + self.update_banner_image() + + def update_ui_for_training_mode(self): + """根据训练数据模式更新UI状态""" + # 需要禁用的步骤ID(对应无训练数据模式下需要禁用的步骤) + disabled_step_ids = ['step4_sampling', 'step5_clean', 'step6_feature', 'step7_index', 'step9_ml_predict'] + + # 更新标签页的启用/禁用状态(从注册表动态获取 Tab 索引) + for step_id in disabled_step_ids: + tab_index = get_tab_index(step_id) + if tab_index >= 0 and tab_index < self.step_stack.count(): + self.step_stack.setTabEnabled(tab_index, self.has_training_data) + + # 同时更新导航列表的启用状态 + for i in range(self.step_list.count()): + item = self.step_list.item(i) + item_data = item.data(Qt.UserRole) + + # 跳过阶段标题和分隔符 + if item_data == "stage_header" or item_data is None: + continue + + # 检查步骤是否在禁用列表中 + if item_data in disabled_step_ids: + if not self.has_training_data: + item.setFlags(item.flags() & ~Qt.ItemIsEnabled) + item.setForeground(QColor(128, 128, 128)) # 灰色 + else: + item.setFlags(item.flags() | Qt.ItemIsEnabled) + item.setForeground(QColor(ModernStylesheet.COLORS.get('text_secondary', '#666666'))) # 原始颜色 + + +# ============================================================ +# 主启动逻辑 +# ============================================================ +def main(): + """主函数""" + import sys + import multiprocessing + from PyQt5.QtWidgets import QApplication + + # 1. 多进程 Fork 环境隔离 + if multiprocessing.current_process().name != 'MainProcess': + sys.exit(0) + + # 2. 🚀 终极防御:必须在全宇宙第一行强制创建 QApplication 实例! + # 绝对杜绝任何后续验签模块或弹窗过早调用 QWidget 导致的崩溃 + app = QApplication.instance() + if not app: + app = QApplication(sys.argv) + + app.setApplicationName("Mega Water") + app.setOrganizationName("WaterQuality") + + # 3. 安全载入离线授权验证拦截 + try: + from src.auth.license_manager import verify_license + from src.auth.license_dialog import LicenseDialog + except ImportError: + import os + _current_dir = os.path.dirname(os.path.abspath(__file__)) + _project_root = os.path.abspath(os.path.join(_current_dir, '..', '..')) + if _project_root not in sys.path: + sys.path.insert(0, _project_root) + from src.auth.license_manager import verify_license + from src.auth.license_dialog import LicenseDialog + + _is_license_valid, _license_msg = verify_license() + if not _is_license_valid: + _dialog = LicenseDialog() + _dialog.exec_() + sys.exit(0) + + # 4. 授权通过,正常载入主程序主界面 + window = WaterQualityGUI() + window.show() + + sys.exit(app.exec_()) + + +# ============================================================================== +# 全宇宙最底部程序入口 +# ============================================================================== +if __name__ == "__main__": + import sys + import multiprocessing + + # 1. 极其强硬的底层防御: + # 如果当前进程明确是 PyInstaller 派生的后台计算子进程,强行静默退出! + # 彻底绕过多进程钩子在尝试解包 sys.argv 时引发的 ValueError 崩溃 + if multiprocessing.current_process().name != 'MainProcess': + sys.exit(0) + + # 2. 安全调用 freeze_support(带防爆气囊) + try: + multiprocessing.freeze_support() + except Exception: + pass # 哪怕底层钩子参数解包失败,也强行保住主进程平稳过关 + + # 3. 正常拉起主业务逻辑 + main() + diff --git a/src/postprocessing/map.py b/src/postprocessing/map.py index 977b282..1b0658b 100644 --- a/src/postprocessing/map.py +++ b/src/postprocessing/map.py @@ -556,7 +556,14 @@ class ContentMapper: return grid_content def _perform_interpolation(self, points, values, grid_xx, grid_yy): - """执行空间插值""" + """三级降级插值策略:Kriging → IDW → 最近邻 + + 2026-07-01 重构: + - Kriging 优先(自动拟合球形变异函数,不强制 nugget) + - Kriging 退化检测:若结果标准差接近 0(纯色图),自动回退 IDW + - IDW 作为首选回退:无需拟合变异函数,不会产生纯色图 + - scipy linear/nearest 作为最后兜底 + """ print(f"插值输入检查:") print(f" - 数据点数量: {len(points)}") print(f" - 数据值范围: {values.min():.4f} - {values.max():.4f}") @@ -573,149 +580,145 @@ class ContentMapper: if len(points) < 3: raise ValueError(f"有效数据点不足3个(当前:{len(points)}个)") - # 优先使用Kriging插值 + # ── 策略 0:值域极窄时直接跳过 Kriging ── + value_range = float(values.max() - values.min()) + value_std = float(np.std(values)) + + # ═══════════════════════════════════════════════════════════ + # 策略 1:Kriging(自动拟合球形变异函数) + # ═══════════════════════════════════════════════════════════ + kriging_degraded = False if PYKRIGE_AVAILABLE: try: - print("正在使用Kriging插值(半变异函数模型,块金值=100%)...") + print("正在使用 Kriging 插值(球形模型,自动拟合 nugget)...") grid_x = grid_xx[0, :] grid_y = grid_yy[:, 0] ok = OrdinaryKriging( points[:, 0], points[:, 1], values, variogram_model='spherical', verbose=False, - enable_plotting=False + enable_plotting=False, ) - # ★ 局部邻域 Kriging:只参考最近的 15 个点,避免万阶矩阵求逆 - z, _ = ok.execute('grid', grid_x, grid_y, backend='loop', n_closest_points=15) + z, ss = ok.execute('grid', grid_x, grid_y, backend='loop', n_closest_points=15) grid_content = np.array(z) - valid_count = np.sum(~np.isnan(grid_content)) - print(f"Kriging插值成功,有效点数: {valid_count} / {grid_content.size}") - if valid_count > 0: - return grid_content - else: - print("警告:Kriging插值结果为空,将回退到其他插值方法") - except Exception as e: - print(f"Kriging插值失败: {e},将回退到其他插值方法") - else: - print("警告:pykrige未安装,无法使用Kriging插值,将使用其他插值方法") + valid_mask = ~np.isnan(grid_content) + valid_count = int(np.sum(valid_mask)) + if valid_count > 0: + # ★ 退化检测:若插值结果标准差 < 原始数据标准差的 5%,判定为纯色图 + kriging_std = float(np.nanstd(grid_content)) + degradation_ratio = kriging_std / max(value_std, 1e-12) + print(f"Kriging 完成: 有效点={valid_count}/{grid_content.size}, " + f"输出std={kriging_std:.6f}, 退化比={degradation_ratio:.3f}") + + if degradation_ratio < 0.05 and value_range > 1e-8: + print(f"⚠ Kriging 严重退化(输出 std/输入 std={degradation_ratio:.3f}<5%)," + f"判定为纯色图,回退 IDW") + kriging_degraded = True + else: + print(f"Kriging 通过退化检测,直接使用") + return grid_content + else: + print("Kriging 结果全为 NaN,回退") + kriging_degraded = True + except Exception as e: + print(f"Kriging 失败: {e}") + kriging_degraded = True + else: + print("pykrige 未安装,跳过 Kriging") + kriging_degraded = True + + # ═══════════════════════════════════════════════════════════ + # 策略 2:IDW(反距离权重)— 不需要拟合变异函数,绝不纯色 + # ═══════════════════════════════════════════════════════════ + if kriging_degraded: + try: + print("正在使用 IDW 插值(反距离权重, power=2, neighbors=15)...") + grid_content = self._idw_interpolation( + points, values, grid_xx, grid_yy, + power=2, n_neighbors=min(15, len(points)), + ) + valid_count = int(np.sum(~np.isnan(grid_content))) + if valid_count > 0: + idw_std = float(np.nanstd(grid_content)) + print(f"IDW 完成: 有效点={valid_count}/{grid_content.size}, 输出std={idw_std:.6f}") + if idw_std > 0: + return grid_content + else: + print("IDW std=0(所有输入值完全相同),结果可用") + return grid_content + else: + print("IDW 结果全为 NaN,回退 scipy 插值") + except Exception as e: + print(f"IDW 失败: {e},回退 scipy 插值") + + # ═══════════════════════════════════════════════════════════ + # 策略 3:scipy 线性插值 + 最近邻填充(最终兜底) + # ═══════════════════════════════════════════════════════════ try: - # 首先尝试使用线性插值 - print("正在尝试线性插值...") + print("正在尝试 scipy 线性插值...") grid_content = griddata( points, values, (grid_xx, grid_yy), method='linear', fill_value=np.nan ) - - # 检查线性插值结果 - valid_linear = ~np.isnan(grid_content) - valid_count = np.sum(valid_linear) - print(f"线性插值结果:有效点数 {valid_count} / {grid_content.size}") + valid_count = int(np.sum(~np.isnan(grid_content))) + print(f"线性插值: 有效点={valid_count}/{grid_content.size}") if valid_count > 0: - print(f"线性插值成功,有效区域覆盖率: {valid_count / grid_content.size * 100:.1f}%") - - # 如果有NaN值,用最近邻插值填充 - nan_count = np.sum(np.isnan(grid_content)) + nan_count = int(np.sum(np.isnan(grid_content))) if nan_count > 0: - print(f"正在用最近邻插值填充 {nan_count} 个缺失值...") + print(f"用最近邻填充 {nan_count} 个 NaN...") grid_nearest = griddata( - points, values, (grid_xx, grid_yy), - method='nearest' + points, values, (grid_xx, grid_yy), method='nearest' ) - # 只填充线性插值的NaN区域 - nan_mask = np.isnan(grid_content) - grid_content[nan_mask] = grid_nearest[nan_mask] - print("缺失值填充完成") - - # 最终检查 - final_valid = ~np.isnan(grid_content) - print(f"最终有效点数: {np.sum(final_valid)} / {grid_content.size}") - + grid_content[np.isnan(grid_content)] = grid_nearest[np.isnan(grid_content)] return grid_content - else: - print("线性插值失败,尝试最近邻插值...") - except Exception as e: print(f"线性插值失败: {e}") - print("尝试最近邻插值...") - try: - # 使用最近邻插值作为备选方案 - print("执行最近邻插值...") - grid_content = griddata( - points, values, (grid_xx, grid_yy), - method='nearest' - ) + # ═══════════════════════════════════════════════════════════ + # 策略 4:最近邻(绝对兜底) + # ═══════════════════════════════════════════════════════════ + print("执行最近邻插值(最终兜底)...") + grid_content = griddata( + points, values, (grid_xx, grid_yy), method='nearest' + ) + if np.sum(~np.isnan(grid_content)) == 0: + raise ValueError("所有插值方法均失败") + return grid_content - valid_count = np.sum(~np.isnan(grid_content)) - print(f"最近邻插值成功,有效点数: {valid_count}") + @staticmethod + def _idw_interpolation(points, values, grid_xx, grid_yy, + power=2, n_neighbors=15): + """IDW(反距离权重)插值 — 不需拟合模型,绝不产生纯色图。 - if valid_count == 0: - raise ValueError("最近邻插值也没有产生有效结果") + Parameters: + points: (N, 2) 采样点坐标 + values: (N,) 采样点值 + grid_xx, grid_yy: meshgrid 网格 + power: 距离衰减幂参数(默认 2) + n_neighbors: 每个网格点参考的最近邻数量 + """ + from scipy.spatial import cKDTree + grid_shape = grid_xx.shape + grid_flat = np.column_stack((grid_xx.ravel(), grid_yy.ravel())) + values_flat = values.ravel() - return grid_content - - except Exception as e: - print(f"最近邻插值也失败: {e}") - - # 对于地理坐标系,尝试更简单的方法 - if self.output_crs == 'EPSG:4326': - print("地理坐标系检测到,尝试简化插值...") - try: - # 创建一个基于距离的简单插值 - grid_content = np.full(grid_xx.shape, np.nan) - - # 为每个网格点找到最近的数据点 - for i in range(grid_xx.shape[0]): - for j in range(grid_xx.shape[1]): - grid_x, grid_y = grid_xx[i, j], grid_yy[i, j] - - # 计算到所有数据点的距离 - distances = np.sqrt((points[:, 0] - grid_x) ** 2 + (points[:, 1] - grid_y) ** 2) - nearest_idx = np.argmin(distances) - - # 如果距离不是太远,就使用该值 - if distances[nearest_idx] < (grid_xx.max() - grid_xx.min()) * 0.1: # 10%的范围内 - grid_content[i, j] = values[nearest_idx] - - valid_count = np.sum(~np.isnan(grid_content)) - print(f"简化插值完成,有效点数: {valid_count}") - - if valid_count > 0: - return grid_content - else: - raise ValueError("简化插值也没有产生有效结果") - - except Exception as e3: - print(f"简化插值失败: {e3}") - - print("尝试立方插值作为最后手段...") - try: - # 最后尝试立方插值 - grid_content = griddata( - points, values, (grid_xx, grid_yy), - method='cubic', fill_value=np.nan - ) - - # 如果立方插值有NaN,用最近邻填充 - if np.any(np.isnan(grid_content)): - print("用最近邻插值填充立方插值的NaN值...") - grid_nearest = griddata( - points, values, (grid_xx, grid_yy), - method='nearest' - ) - nan_mask = np.isnan(grid_content) - grid_content[nan_mask] = grid_nearest[nan_mask] - - valid_count = np.sum(~np.isnan(grid_content)) - print(f"立方插值成功,有效点数: {valid_count}") - return grid_content - - except Exception as e4: - print(f"立方插值也失败: {e4}") - print(f"所有插值方法都失败") - raise ValueError("无法完成空间插值,请检查数据点的分布和数值") + tree = cKDTree(points) + k = min(n_neighbors, len(points)) + distances, indices = tree.query(grid_flat, k=k) + # 防止距离为 0 的除零 + distances = np.maximum(distances, 1e-12) + weights = 1.0 / (distances ** power) + # 归一化权重 + weights /= weights.sum(axis=1, keepdims=True) + # 加权求和 + neighbor_vals = values_flat[indices] if k == 1 else values_flat[indices] + if k == 1: + result = neighbor_vals + else: + result = np.sum(weights * neighbor_vals, axis=1) + return result.reshape(grid_shape) def read_csv_data(self, csv_file, uncertainty_col=None): """ @@ -2514,28 +2517,30 @@ class ContentMapper: safe_h = min(float(figsize[1]), _max_inch) fig, ax = plt.subplots(figsize=(safe_w, safe_h)) - # 计算有效值统计(2σ 标准差拉伸,排除长尾异常值干扰) - valid = array[~np.isnan(array)] + # 1. 明确排除 NaN 以及 nodata_value(与函数参数保持一致) + nodata_val = nodata_value + valid = array[(~np.isnan(array)) & (array != nodata_val)] + if valid.size == 0: raise ValueError("GeoTIFF 中没有有效数据(全部为 NoData)") - mean_val = float(np.nanmean(array)) - std_val = float(np.nanstd(array)) - vmin = max(float(np.nanmin(array)), mean_val - 2 * std_val) - vmax = min(float(np.nanmax(array)), mean_val + 2 * std_val) + # 2. 改用更鲁棒的 2%-98% 百分位拉伸(抗偏态分布) + vmin = float(np.percentile(valid, 2)) + vmax = float(np.percentile(valid, 98)) - if (vmax - vmin) < 1e-9: - center = mean_val + if (vmax - vmin) < 1e-9: # 防退化:区间过窄 → 取中心 ±1% + center = vmin exp = max(abs(center) * 0.01, 1e-9) vmin = center - exp vmax = center + exp - print(f"[visualize_raster] 2σ 拉伸: vmin={vmin:.4f}, vmax={vmax:.4f}," - f"mean={mean_val:.4f}, std={std_val:.4f},有效像元: {valid.size}/{array.size}") + print(f"[visualize_raster] P2-P98 拉伸: vmin={vmin:.4f}, vmax={vmax:.4f}," + f"有效像元: {valid.size}/{array.size}") # ── 栅格绘图 ───────────────────────────────────────────────── # 使用 masked array:NaN 区域自动不显示 - masked_data = np.ma.masked_invalid(array) + # 不仅要屏蔽 NaN,如果 array 中还有 nodata_value,也必须 mask 掉,否则出图时背景会被渲染 + masked_data = np.ma.masked_where((np.isnan(array)) | (array == nodata_val), array) # 【核心修复2】废弃错误的坐标映射逻辑。 # 直接使用原生的 imshow,明确告知 matplotlib 第0行在最上方(origin='upper')