Files
WQ_GUI/src/gui/water_quality_gui_v2.py
duxin 721370f3e5 fix: 新增 PROJ_DATA 环境变量 + 屏蔽 Fiona 内部旧版 PROJ 数据库
问题: visualize_raster 阶段 fiona 库内部捆绑了老旧的 PROJ 数据库
  (VERSION.MINOR=2),导致读取掩膜时投影错乱 — 底图 51N 被误解析为
  49N,矢量掩膜物理擦除时把所有水体当成陆地,有效像元变成 0/15300。

修复:
  1. 新增 os.environ['PROJ_DATA'] = proj_lib
     Fiona ≥1.4 / Rasterio ≥1.4 / GDAL ≥3.5 / PROJ ≥8 优先读取此变量

  2. PROJ_NETWORK = OFF — 禁用 PROJ 网络下载,避免意外行为

  3. 打印明确日志确认全进程 PROJ 来源统一

效果: 所有空间组件(GDAL / PROJ / Fiona / Rasterio / pyproj)
  强制使用同一套 conda 环境下的 proj.db (v6+),彻底杜绝 fiona 内部
  捆绑的 v2 旧版干扰。
2026-07-07 16:40:51 +08:00

988 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
水质参数反演分析系统 - 图形用户界面(重构版:纯壳模式)
WaterQualityGUI 只负责窗口框架标题栏、菜单栏、状态栏、QTabWidget
所有业务逻辑委托给独立的 Manager 类。
已实现的 Manager
- PanelFactory → 面板懒加载与生命周期
- PipelineExecutor → Pipeline 执行/停止/回调(通过 EventBus 发布状态)
- WorkspaceInitializer → 工作目录选择 + 自动回填(通过 EventBus 广播)
- LogManager → 日志区 + 进度条(内部订阅 LogMessage/ProgressUpdate
- ConfigManager → 配置读写new/load/save/get_current_config
- DialogService → 纯展示类弹窗Pipeline状态/关于/AI设置
- TrainingModeManager → 训练模式切换(发布 TrainingModeChanged 事件)
"""
import os
import sys
# ═══════════════════════════════════════════════════════════════
# 动态推断当前 Conda 环境下的 GDAL/PROJ 数据路径
# 必须在 import gdal / import rasterio 等任何第三方库之前执行
# ═══════════════════════════════════════════════════════════════
def _find_and_set_gdal_env():
"""动态推断并强设 PROJ_LIB 和 GDAL_DATA 环境变量。
解决"C:\\ITRES\\ATK\\app\\GDAL\\projlib"等旧系统 PROJ 安装的干扰:
强制指向与当前 GDAL 版本匹配的 proj.db通常在同 Conda 环境的
Library/share/proj 下),而非系统路径中的过时版本。
搜索策略(按优先级):
1. CONDA_PREFIX + sys.prefix 的 Library/share/ 子目录
2. 搜索 sys.path 找到 osgeo 模块所在位置,回推 Library 路径
3. CONDA_ROOT / base 环境的 Library 路径
4. PATH 中推断的 conda 安装路径
5. 已知的硬编码回退路径
"""
# ── 辅助函数 ──
def _check_proj_db_version(db_path):
"""读取 proj.db 的 SQLite 头,返回主版本号。失败返回 -1。"""
try:
with open(db_path, "rb") as f:
header = f.read(100)
# SQLite 3 文件头: "SQLite format 3\0",之后是页大小等
# DATABASE.LAYOUT.VERSION 在 offset 60 附近的 schema 中,
# 但最简单的是读整个头中 "major" / "minor" 文本
if b"SQLite format 3" not in header:
return -2
# PROJ 在 proj.db 的 metadata 表存储版本,我们直接看文件大小
# 粗略判定:< 2MB → v2-v4, 2~6MB → v5-v6, > 6MB → v7+
size_mb = os.path.getsize(db_path) / (1024 * 1024)
return 6 if size_mb > 3.0 else 2 # v6+ 的 proj.db 通常 > 3MB
except Exception:
return -1
def _find_proj_in_prefix(prefix):
"""在指定前缀下递归搜索 Library/share/proj/proj.db"""
candidates = []
for root, dirs, files in os.walk(prefix):
# 限制深度避免全盘扫描
depth = root.replace(prefix, "").count(os.sep)
if depth > 6:
dirs.clear()
continue
if "proj.db" in files:
db_path = os.path.join(root, "proj.db")
ver = _check_proj_db_version(db_path)
if ver >= 6:
candidates.append((ver, root))
# 跳过明显的非目标目录
dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", "node_modules")]
if candidates:
candidates.sort(key=lambda x: -x[0]) # 最新版本优先
return candidates[0][1]
return None
def _find_gdal_in_prefix(prefix):
"""在指定前缀下查找 Library/share/gdal"""
for sub in ("Library/share/gdal", "share/gdal",
"Library\\share\\gdal", "share\\gdal"):
path = os.path.join(prefix, sub)
if os.path.isdir(path):
for marker in ("gdalvrt.xsd", "pcs.csv", "gcs.csv"):
if os.path.isfile(os.path.join(path, marker)):
return path
return None
# ── 构建搜索前缀 ──
prefixes = []
# 1) CONDA_PREFIX + sys.prefix最优先
for src in (os.environ.get("CONDA_PREFIX", ""), sys.prefix):
if src and src not in prefixes:
prefixes.append(src)
# 2) 从 Python 可执行文件路径回推最可靠python.exe 就在 {prefix}/ 下)
try:
py_exe = sys.executable
py_prefix = os.path.dirname(py_exe) # python.exe 所在目录
if os.path.basename(py_prefix).lower() == "scripts":
# Windows venv: {prefix}/Scripts/python.exe → 前缀是父目录
exe_prefix = os.path.dirname(py_prefix)
else:
exe_prefix = py_prefix
if exe_prefix and exe_prefix not in prefixes:
prefixes.insert(0, exe_prefix) # 插到最前面,最高优先级
except Exception:
pass
# 3) 从 sys.path 中 site-packages 位置回推所有可能的 conda/env 前缀
try:
for sp in sys.path:
if not sp or sp in prefixes:
continue
norm = os.path.normpath(sp)
parts = norm.split(os.sep)
# 检查 sp 是否在 site-packages 下 → 回推到 {prefix}
for marker in ("site-packages", "Lib"):
if marker in parts:
idx = parts.index(marker)
possible = os.sep.join(parts[:idx])
if os.path.isdir(possible) and possible not in prefixes:
prefixes.append(possible)
break
except Exception:
pass
# 3) CONDA_ROOT / base 环境
for key in ("CONDA_ROOT", "MAMBA_ROOT_PREFIX"):
val = os.environ.get(key, "")
if val and val not in prefixes:
prefixes.append(val)
# 4) 从 PATH 推断
for d in os.environ.get("PATH", "").split(os.pathsep):
if not d or d in prefixes:
continue
lower = d.lower()
if any(kw in lower for kw in ("anaconda", "miniconda", "anconda")):
# 找到 conda 根: 去掉 /Scripts 或 /condabin
if os.path.basename(d).lower() in ("scripts", "condabin", "bin"):
root = os.path.dirname(d)
else:
root = d
if root and root not in prefixes:
prefixes.append(root)
# 5) 已知硬编码路径
for p in [
r"D:\111\changyongruanjian\anconda\envs\WQ_GUI",
r"D:\111\changyongruanjian\anconda",
r"C:\ProgramData\anaconda3",
r"C:\ProgramData\miniconda3",
]:
if os.path.isdir(p) and p not in prefixes:
prefixes.append(p)
# ── 额外:扫描 conda 根目录下的 envs/*/Library/share/proj ──
_extra_prefixes = []
for prefix in list(prefixes):
envs_dir = os.path.join(prefix, "envs")
if os.path.isdir(envs_dir):
try:
for entry in os.listdir(envs_dir):
env_path = os.path.join(envs_dir, entry)
if os.path.isdir(env_path) and env_path not in prefixes:
_extra_prefixes.append(env_path)
except Exception:
pass
prefixes.extend(_extra_prefixes)
# 6) pip wheel GDAL: {site-packages}/osgeo/data/proj/proj.db
# (conda-forge GDAL wheel on Windows 有时把 proj.db 放这里)
try:
import site as _site
for _sp in _site.getsitepackages():
pip_proj = os.path.join(_sp, "osgeo", "data", "proj")
if os.path.isfile(os.path.join(pip_proj, "proj.db")):
pip_gdal = os.path.join(_sp, "osgeo", "data", "gdal")
if _sp not in prefixes:
prefixes.append(_sp)
except Exception:
pass
proj_lib = None
gdal_data = None
for prefix in prefixes:
if not proj_lib:
# 先尝试标准子目录(快速路径)
for sub in ("Library/share/proj", "share/proj",
"Library\\share\\proj", "share\\proj"):
db_path = os.path.join(prefix, sub, "proj.db")
if os.path.isfile(db_path) and _check_proj_db_version(db_path) >= 6:
proj_lib = os.path.join(prefix, sub)
break
# 标准路径未找到 → 递归搜索(仅对 conda 环境前缀)
if not proj_lib and (
os.path.basename(prefix) not in ("", "share", "bin", "Scripts")
):
proj_lib = _find_proj_in_prefix(prefix)
if not gdal_data:
gdal_data = _find_gdal_in_prefix(prefix)
if proj_lib and gdal_data:
break
# ── 应用环境变量 ──
if proj_lib:
os.environ["PROJ_LIB"] = proj_lib
# PROJ_DATA: Fiona / Rasterio / GDAL ≥3.5 / PROJ ≥8 优先读取此变量
# 不设置的话 Fiona 会使用自带的旧版 proj.db (VERSION.MINOR=2)
# 导致投影解析错误 → 矢量图层错位 → 水体被错误擦除 → 有效像元为 0
os.environ["PROJ_DATA"] = proj_lib
print(f"[ENV] PROJ_LIB → {proj_lib}")
print(f"[ENV] PROJ_DATA → {proj_lib}")
else:
print("[ENV] ⚠ 未找到兼容的 proj.db (需要 PROJ v6+)PROJ_LIB/PROJ_DATA 保持系统默认")
print(f"[ENV] (已搜索 {len(prefixes)} 个前缀: {[os.path.basename(p) for p in prefixes[:5]]}...)")
print("[ENV] 如果 step11 地图渲染报 PROJ 版本错误,")
print("[ENV] 请手动设置: set PROJ_LIB=<conda环境>\\Library\\share\\proj")
print("[ENV] set PROJ_DATA=<同上>")
if gdal_data:
os.environ["GDAL_DATA"] = gdal_data
print(f"[ENV] GDAL_DATA → {gdal_data}")
else:
print("[ENV] ⚠ 未找到 GDAL data 目录GDAL_DATA 保持系统默认")
print(f"[ENV] (已搜索 {len(prefixes)} 个前缀)")
# ── 屏蔽 Fiona 内部旧版 PROJ 路径 ──
# Fiona wheels 捆绑了古老的 proj.db (VERSION.MINOR=2)
# 即使设置了 PROJ_DATA 仍然可能被某些旧版 Fiona 忽略。
# 以下环境变量作为最后防线强制覆盖。
if proj_lib:
# GDAL ≥3.5 同时检查 PROJ_DATA 和 PROJ_LIB
os.environ["PROJ_NETWORK"] = "OFF" # 禁用 PROJ 网络下载(避免意外行为)
# 记录此信息供诊断
if "PROJ_DATA" in os.environ:
print("[ENV] 已屏蔽 Fiona 内部旧版 PROJ 数据库,全进程统一使用上述 proj.db")
_find_and_set_gdal_env()
import ctypes
import traceback
import multiprocessing
from datetime import datetime
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QTabWidget, QToolBar, QSizePolicy,
QListWidget, QListWidgetItem, QGroupBox,
QTextEdit, QProgressBar, QMessageBox, QFileDialog,
QSpinBox, QDoubleSpinBox, QComboBox,
)
from PyQt5.QtCore import Qt, QTimer, QSize
from PyQt5.QtGui import QIcon, QFont, QPixmap, QColor, QTextCursor
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:
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:
QMessageBox.critical(None, "程序崩溃",
f"错误类型: {exc_type.__name__}\n错误信息: {exc_value}\n详细信息已写入: {dump_path}")
except Exception:
pass
sys.excepthook = global_exception_handler
class WaterQualityGUI(QMainWindow):
"""水质参数反演分析系统主窗口 —— 纯壳模式。
职责边界:
- 窗口框架(标题栏、菜单栏、状态栏、导航栏)
- QTabWidget 托管(通过 PanelFactory 懒加载)
- 日志区 + 进度条UI 控件归 shell内容由 EventBus 驱动)
- 各 Manager 的创建与 EventBus 连线
"""
def __init__(self):
my_appid = u'mycompany.megacube.waterquality.v1'
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_appid)
super().__init__()
icon_path = get_resource_path("data/icons-1/uitubiao.ico")
self.setWindowIcon(QIcon(icon_path))
# 第一步:创建各 Manager纯连线不执行业务
self._init_managers()
# 第二步:构建窗口壳
self._init_shell()
# 第三步:订阅 EventBus 事件 → 驱动 UI 更新
self._wire_event_bus()
# 第四步:应用样式 + 禁用滚轮
self._apply_stylesheet()
self._disable_wheel_for_all_spinboxes()
# 第五步:默认选中第一个步骤(延迟执行,确保导航列表和 Tab 均已就位)
QTimer.singleShot(120, self._select_first_nav_item)
# 第六步:延迟启动工作目录选择
QTimer.singleShot(200, self._workspace_initializer.run)
# ================================================================
# Manager 初始化
# ================================================================
def _init_managers(self):
from src.gui.core.panel_factory import PanelFactory
from src.gui.core.panel_registry import PANEL_REGISTRY
from src.gui.core.workspace_initializer import WorkspaceInitializer
from src.gui.core.pipeline_executor import PipelineExecutor
from src.gui.core.log_manager import LogManager
from src.gui.core.config_manager import ConfigManager
from src.gui.core.dialog_service import DialogService
from src.gui.core.training_mode_manager import TrainingModeManager
from src.gui.core.event_bus import global_event_bus
self._panel_factory = PanelFactory(
registry=PANEL_REGISTRY,
main_window=self,
preload_window=1,
)
self._workspace_initializer = WorkspaceInitializer(
panel_factory=self._panel_factory,
parent=self,
)
self._pipeline_executor = PipelineExecutor(
panel_factory=self._panel_factory,
workspace_initializer=self._workspace_initializer,
parent=self,
)
self._log_manager = LogManager(parent=self)
self._config_manager = ConfigManager(
panel_factory=self._panel_factory,
parent=self,
)
self._dialog_service = DialogService(parent=self)
self._training_mode_manager = TrainingModeManager(parent=self)
self._event_bus = global_event_bus
# ================================================================
# 窗口壳构建
# ================================================================
def _init_shell(self):
self.setWindowTitle("MegaCube-Water Quality V1.2.1")
screen_geometry = QApplication.primaryScreen().availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
self.resize(1200, screen_height)
self.move((screen_width - 1200) // 2, 0)
self.setMinimumSize(600, 400)
self._create_title_bar()
self._create_banner()
self._create_central_layout()
self.statusBar().showMessage("就绪")
def _create_title_bar(self):
title_widget = QWidget()
title_layout = QHBoxLayout()
title_layout.setContentsMargins(8, 4, 8, 4)
title_layout.setSpacing(0)
logo_label = QLabel()
logo_label.setFixedSize(180, 48)
logo_label.setAlignment(Qt.AlignCenter)
logo_label.setStyleSheet(
"background-color: #f8f9fa;"
"border-top-left-radius: 4px; border-bottom-left-radius: 4px;"
)
logo_path = get_resource_path("data/icons/logo.png")
logo_pixmap = QPixmap(logo_path)
if not logo_pixmap.isNull():
logo_label.setPixmap(logo_pixmap.scaledToHeight(38, Qt.SmoothTransformation))
else:
logo_label.setText("Logo")
title_layout.addWidget(logo_label)
menubar = self.menuBar()
menubar.setStyleSheet("""
QMenuBar { background-color: #f8f9fa; border: none; padding: 4px 8px; }
QMenuBar::item { padding: 6px 12px; font-size: 13px; }
QMenuBar::item:selected { background-color: #e6f0ff; border-radius: 3px; }
""")
self._build_menus(menubar)
title_layout.addWidget(menubar)
title_widget.setLayout(title_layout)
title_widget.setStyleSheet(
"background-color: #f8f9fa; border-bottom: 1px solid #d0d0d0;"
)
self.setMenuWidget(title_widget)
def _build_menus(self, menubar):
file_menu = menubar.addMenu("文件")
file_menu.addAction("新建配置").triggered.connect(self._on_new_config)
file_menu.addAction("打开配置").triggered.connect(self._on_load_config)
file_menu.addAction("保存配置").triggered.connect(self._on_save_config)
file_menu.addSeparator()
file_menu.addAction("退出").triggered.connect(self.close)
tools_menu = menubar.addMenu("工具")
tools_menu.addAction("设置工作目录").triggered.connect(
self._workspace_initializer.set_work_directory
)
tools_menu.addAction("打开工作目录").triggered.connect(
self._workspace_initializer.open_work_directory
)
tools_menu.addSeparator()
tools_menu.addAction("AI 引擎配置...").triggered.connect(self._on_ai_settings)
tools_menu.addSeparator()
tools_menu.addAction("自动填充所有输入路径").triggered.connect(
self._workspace_initializer.auto_populate_all
)
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._on_toggle_training_mode)
help_menu = menubar.addMenu("帮助")
help_menu.addAction("检查Pipeline状态").triggered.connect(self._on_show_pipeline_status)
help_menu.addSeparator()
help_menu.addAction("关于").triggered.connect(self._on_show_about)
def _create_banner(self):
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)
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)
banner_layout.addWidget(self._banner_label)
self._banner_title_label = QLabel("MegaCube-Water Quality V1.2.1", self._banner_label)
# 现代化字体设计:去掉老旧的衬线体,换用带字间距的粗体无衬线字体,增加高级感
self._banner_title_label.setStyleSheet("""
QLabel {
background: transparent;
color: #FFFFFF;
font-size: 42px;
font-weight: bold;
font-family: "Microsoft YaHei UI", "Segoe UI", sans-serif;
letter-spacing: 3px;
}
""")
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.setStyleSheet(
"QToolBar { background: white; border: none; padding: 0px; margin: 0px; }"
)
self.addToolBar(Qt.TopToolBarArea, banner_toolbar)
def _create_central_layout(self):
from src.gui.styles import ModernStylesheet
central_widget = QWidget()
main_layout = QHBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(self._create_navigation(), 1)
right_widget = QWidget()
right_layout = QVBoxLayout()
right_layout.setContentsMargins(15, 15, 15, 15)
right_layout.setSpacing(10)
self._tab_widget = self._panel_factory.create_tab_widget(icons_dir="data/icons")
self._tab_widget.tabBar().setVisible(False)
right_layout.addWidget(self._tab_widget, 3)
# 上一步 / 下一步 导航按钮
nav_btn_layout = QHBoxLayout()
nav_btn_layout.setSpacing(12)
self._prev_btn = QPushButton(" 上一步 ")
self._prev_btn.setMinimumHeight(36)
self._prev_btn.setMinimumWidth(100)
# 上一步是次要操作,用 normal 样式
self._prev_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal'))
self._prev_btn.clicked.connect(self._on_prev_clicked)
nav_btn_layout.addWidget(self._prev_btn)
self._next_btn = QPushButton(" 下一步 ")
self._next_btn.setMinimumHeight(36)
self._next_btn.setMinimumWidth(100)
# 下一步是主要操作,用 primary (主题蓝) 样式
self._next_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary'))
self._next_btn.clicked.connect(self._on_next_clicked)
nav_btn_layout.addWidget(self._next_btn)
nav_btn_layout.addStretch()
right_layout.addLayout(nav_btn_layout)
right_layout.addWidget(self._log_manager.create_log_panel(), 1)
right_widget.setLayout(right_layout)
main_layout.addWidget(right_widget, 4)
central_widget.setLayout(main_layout)
self.setCentralWidget(central_widget)
def _create_navigation(self):
from src.gui.core.panel_registry import build_stage_groups
from src.gui.styles import ModernStylesheet
nav_widget = QWidget()
nav_layout = QVBoxLayout()
nav_layout.setContentsMargins(0, 15, 0, 15) # 去掉左右边距,让列表通栏
nav_layout.setSpacing(10)
title = QLabel("流程导航")
title.setFont(QFont("Microsoft YaHei", 14, QFont.Bold))
title.setAlignment(Qt.AlignCenter)
title.setStyleSheet(f"color: {ModernStylesheet.COLORS['primary']}; padding: 10px;")
nav_layout.addWidget(title)
self._step_list = QListWidget()
# 注入纯净的现代侧边栏 QSS
self._step_list.setStyleSheet(f"""
QListWidget {{
background-color: {ModernStylesheet.COLORS['panel_bg']};
border: none;
outline: 0; /* 去掉点击时的虚线框 */
}}
QListWidget::item {{
padding: 12px 16px;
border-left: 4px solid transparent;
color: {ModernStylesheet.COLORS['text_primary']};
}}
QListWidget::item:hover {{
background-color: {ModernStylesheet.COLORS['hover']};
}}
QListWidget::item:selected {{
background-color: {ModernStylesheet.COLORS['selected']};
color: {ModernStylesheet.COLORS['primary']};
font-weight: bold;
border-left: 4px solid {ModernStylesheet.COLORS['primary']};
}}
""")
process_stages = build_stage_groups()
stage_names = list(process_stages.keys())
for stage_idx, (stage_name, steps) in enumerate(process_stages.items()):
# 分类头
stage_item = QListWidgetItem(stage_name)
stage_font = QFont("Microsoft YaHei", 11, QFont.Bold)
stage_item.setFont(stage_font)
stage_item.setForeground(QColor(ModernStylesheet.COLORS['primary']))
stage_item.setBackground(QColor(ModernStylesheet.COLORS['main_bg'])) # 给头加个浅灰底色区隔
stage_item.setFlags(stage_item.flags() & ~Qt.ItemIsSelectable & ~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)
item.setFont(QFont("Microsoft YaHei", 10))
self._step_list.addItem(item)
# 分隔符 (不需要实体占用高度,缩小即可)
if stage_idx < len(stage_names) - 1:
sep = QListWidgetItem("")
sep.setSizeHint(QSize(0, 10))
sep.setFlags(sep.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEnabled)
self._step_list.addItem(sep)
self._step_list.currentRowChanged.connect(self._on_step_list_changed)
nav_layout.addWidget(self._step_list)
# 底部按钮区 (仅保留强制停止)
btn_layout = QVBoxLayout()
btn_layout.setContentsMargins(15, 0, 15, 0)
self._stop_btn = QPushButton("⏹ 强制停止当前任务")
self._stop_btn.setEnabled(False)
self._stop_btn.setMinimumHeight(38)
self._stop_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('danger'))
self._stop_btn.clicked.connect(self._pipeline_executor.stop_pipeline)
btn_layout.addWidget(self._stop_btn)
nav_layout.addLayout(btn_layout)
nav_widget.setLayout(nav_layout)
nav_widget.setMaximumWidth(260)
nav_widget.setStyleSheet(
f"background-color: {ModernStylesheet.COLORS['panel_bg']};"
f"border-right: 1px solid {ModernStylesheet.COLORS['border_light']};"
)
return nav_widget
# ================================================================
# EventBus 连线UI 状态由事件驱动)
# ================================================================
def _wire_event_bus(self):
self._event_bus.subscribe('PipelineStarted', self._on_pipeline_started)
self._event_bus.subscribe('PipelineFinished', self._on_pipeline_finished)
self._event_bus.subscribe('PipelineStopped', self._on_pipeline_stopped)
self._event_bus.subscribe('NavigateToTab', self._on_navigate_to_tab)
self._event_bus.subscribe('WorkspaceChanged', self._on_workspace_changed)
def _on_pipeline_started(self, data):
self._stop_btn.setEnabled(True)
self._log_manager.progress_bar.setValue(0)
def _on_pipeline_finished(self, data):
self._stop_btn.setEnabled(False)
success = data.get('success', False)
message = data.get('message', '')
if success:
self._log_manager.progress_bar.setValue(100)
QMessageBox.information(self, "完成", "流程执行成功!\n\n请查看工作目录中的结果文件。")
else:
QMessageBox.critical(self, "失败",
f"流程执行失败:\n\n{message[:600]}"
+ ("\n...(已截断)" if len(message) > 600 else ""))
def _on_pipeline_stopped(self, data):
self._stop_btn.setEnabled(False)
def _on_navigate_to_tab(self, data):
tab_index = data.get('tab_index', 0)
if 0 <= tab_index < self._tab_widget.count():
self._tab_widget.setCurrentIndex(tab_index)
def _on_workspace_changed(self, data):
work_dir = data.get('work_dir', '')
self.statusBar().showMessage(f"工作目录: {work_dir}")
# 让当前激活的 Tab 立即基于新目录重算默认路径,覆盖掉旧值,绕过非空保护
from src.gui.core.panel_registry import PANEL_REGISTRY
active_idx = self._tab_widget.currentIndex()
if 0 <= active_idx < len(PANEL_REGISTRY):
active_step_id = PANEL_REGISTRY[active_idx]['step_id']
panel = self._panel_factory.get_panel(active_step_id)
if panel is not None and hasattr(panel, 'update_from_config'):
try:
panel.update_from_config(
work_dir=self._workspace_initializer.work_dir,
pipeline=None
)
except Exception:
pass
# ================================================================
# 导航 → Tab 单向路由(左侧 List 驱动右侧 TabTab 头部已隐藏)
# ================================================================
def _select_first_nav_item(self):
"""默认选中左侧导航栏的第一个可导航步骤项(跳过阶段标题/分隔符)。"""
for i in range(self._step_list.count()):
item = self._step_list.item(i)
if item and item.data(Qt.UserRole) not in (None, "stage_header"):
self._step_list.setCurrentRow(i)
return
def _on_step_list_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 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
try:
# 1. 触发懒加载生成面板
panel = self._panel_factory.get_panel(item_data)
# ★ 2026-07-01每次切页时刷新面板的自动路由
# 面板首次加载时 _replay_state_to_panel 会调 update_from_config
# 但再次切回时 get_panel() 直接返回已有实例,不会重扫文件系统。
# 此处显式调用确保 Step11 等面板始终基于最新磁盘状态做文件夹自动导入。
if panel is not None and hasattr(panel, 'update_from_config'):
try:
panel.update_from_config(
work_dir=self._workspace_initializer.work_dir,
pipeline=None,
)
except Exception:
pass
# 🚨 核心防卡死补丁:如果目标 Tab 被后台任务异常永久锁定,强制撬开!
if not self._tab_widget.isTabEnabled(tab_index):
self._log_manager.info(f"检测到 {item_data} 处于异常锁定状态,已执行强制解锁。")
self._tab_widget.setTabEnabled(tab_index, True)
QMessageBox.warning(
self, "页面已解锁",
f"步骤「{item_data}」之前被异常锁定,已自动强制解锁。\n"
"如果当前有流程正在运行,请勿修改此页面的参数。"
)
# 【防御】:用已加载 panel 实例反查真实 Tab 索引,防止索引错位
try:
for i in range(self._tab_widget.count()):
scroll_area = self._tab_widget.widget(i)
if scroll_area and hasattr(scroll_area, 'widget'):
if scroll_area.widget() == panel:
tab_index = i
break
except Exception as e:
self._log_manager.error(f"页面索引校验失败: {str(e)}")
return
# 2. 强制使用注册表的固定索引进行跳转
self._tab_widget.setCurrentIndex(tab_index)
# 【新增修复】:每次切页时,强制重播所有面板的输入值!
# 原理:打破 preload_window 造成的时序差,确保目标页面能拿到源页面最新填写的值
if hasattr(self, '_panel_factory'):
self._panel_factory.replay_live_panel_inputs()
# 3. 防撕裂回弹机制:如果由于某种原因没跳过去,把左边的蓝条强行拽回当前真实页面
if self._tab_widget.currentIndex() != tab_index:
self._step_list.blockSignals(True)
current_step_id = PANEL_REGISTRY[self._tab_widget.currentIndex()]['step_id']
for i in range(self._step_list.count()):
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)
except Exception as e:
self._log_manager.error(f"页面跳转失败: {str(e)}")
def _find_prev_step_row(self, current_row):
"""从 current_row 向上遍历,跳过 stage_header 和空分隔符,返回上一个有效 step 的行号。"""
for i in range(current_row - 1, -1, -1):
item = self._step_list.item(i)
if not item:
continue
data = item.data(Qt.UserRole)
if data and data != "stage_header":
return i
return None
def _find_next_step_row(self, current_row):
"""从 current_row 向下遍历,跳过 stage_header 和空分隔符,返回下一个有效 step 的行号。"""
for i in range(current_row + 1, self._step_list.count()):
item = self._step_list.item(i)
if not item:
continue
data = item.data(Qt.UserRole)
if data and data != "stage_header":
return i
return None
def _on_prev_clicked(self):
"""上一步按钮:跳转到上一个有效步骤。"""
row = self._find_prev_step_row(self._step_list.currentRow())
if row is not None:
self._step_list.setCurrentRow(row)
def _on_next_clicked(self):
"""下一步按钮:跳转到下一个有效步骤。"""
row = self._find_next_step_row(self._step_list.currentRow())
if row is not None:
self._step_list.setCurrentRow(row)
# ================================================================
# 横幅自适应
# ================================================================
def _update_banner_image(self):
if not hasattr(self, '_banner_pixmap') or self._banner_pixmap.isNull():
return
TARGET_HEIGHT = 140
target_width = self.width()
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 = 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 = scaled.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)
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)
self._update_banner_image()
# ================================================================
# 样式与 UX
# ================================================================
def _apply_stylesheet(self):
from src.gui.styles import ModernStylesheet
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
def _disable_wheel_for_all_spinboxes(self):
for sb in self.findChildren(QSpinBox):
sb.setFocusPolicy(Qt.StrongFocus)
sb.wheelEvent = lambda e, s=sb: None
for sb in self.findChildren(QDoubleSpinBox):
sb.setFocusPolicy(Qt.StrongFocus)
sb.wheelEvent = lambda e, s=sb: None
for cb in self.findChildren(QComboBox):
cb.setFocusPolicy(Qt.StrongFocus)
cb.wheelEvent = lambda e, c=cb: None
# ================================================================
# 菜单回调(全部委托给对应 Manager
# ================================================================
def _on_new_config(self):
self._config_manager.new_config()
def _on_load_config(self):
self._config_manager.load_config()
def _on_save_config(self):
self._config_manager.save_config()
def _on_ai_settings(self):
self._dialog_service.show_ai_settings()
def _on_toggle_training_mode(self, checked):
self._training_mode_manager.toggle(checked)
self._training_mode_action.setText(
self._training_mode_manager.get_action_text(checked)
)
def _on_show_pipeline_status(self):
self._dialog_service.show_pipeline_status()
def _on_show_about(self):
self._dialog_service.show_about()
# ================================================================
# 向后兼容属性
# ================================================================
@property
def panels(self):
return self._panel_factory.get_loaded_panels()
@property
def work_dir(self):
return self._workspace_initializer.work_dir
@work_dir.setter
def work_dir(self, value):
self._workspace_initializer.work_dir = value
@property
def worker(self):
return self._pipeline_executor.worker
def log_message(self, message, level='info'):
self._event_bus.publish('LogMessage', {'message': message, 'level': level})
def update_progress(self, percentage, message):
self._event_bus.publish('ProgressUpdate', {'percentage': percentage, 'message': message})
def get_current_config(self):
return self._config_manager.get_current_config()
# ============================================================
def main():
if multiprocessing.current_process().name != 'MainProcess':
sys.exit(0)
# ★ GDAL 依赖检查(移至此处以利用 global_exception_handler 展示友好弹窗)
try:
import osgeo # noqa: F401
from osgeo import gdal, ogr # noqa: F401
except ImportError:
from PyQt5.QtWidgets import QMessageBox, QApplication as _QA
_qa = _QA.instance() or _QA(sys.argv)
QMessageBox.critical(
None, "依赖缺失",
"GDAL (osgeo) 未安装,程序无法运行。\n\n"
"请运行以下命令安装:\n"
" conda install -c conda-forge gdal\n"
"或访问 https://gdal.org/download.html"
)
sys.exit(1)
app = QApplication.instance()
if not app:
app = QApplication(sys.argv)
app.setApplicationName("Mega Water")
app.setOrganizationName("WaterQuality")
try:
from src.auth.license_manager import verify_license
from src.auth.license_dialog import LicenseDialog
except ImportError:
_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:
LicenseDialog().exec_()
sys.exit(0)
window = WaterQualityGUI()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
if multiprocessing.current_process().name != 'MainProcess':
sys.exit(0)
try:
multiprocessing.freeze_support()
except Exception:
pass
main()