Files
WQ_GUI/src/new/main_view.py
DXC b3a6855881 fix: 补齐 Step6 缺失的水体与耀斑掩膜自动传导链路
MainView._sync_dependencies 此前未推送以下两条链路,导致用户
每次进入 Step 6 都必须手动选水体/耀斑掩膜文件:

1. step1 → step6 (boundary_path)
   Step 1 在 NDWI 模式下输出 water_mask_out.dat → 经 boundary_path
   推给 Step 6 的 self.water_mask_file。
   注意:Step 6 接收键名是 boundary_path(历史遗留别名),不是
   water_mask_path。

2. step2 → step6 (glint_mask_path)
   Step 2 输出 severe_glint_area.dat → 经 glint_mask_path 推给
   Step 6 的 self.glint_mask_file。

同时精简了 _sync_dependencies 的 docstring(去掉逐条推送关系列表,
改为一行摘要)和若干块内注释。

其它方法 / 字段 / 类结构未改动。
2026-06-17 13:41:50 +08:00

757 lines
30 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.

# -*- coding: utf-8 -*-
"""
MainView —— 端到端模块化新架构的路由与调度壳(主窗口)
=========================================================
职责
----
1. **路由**:根据 ``ROUTES`` 表动态加载每个 step 的 view前端皮囊
和 service后端大脑左侧 ``QListWidget`` 导航,右侧
``QStackedWidget`` 承载 view。
2. **解耦通讯**view 不直接持有 main_view 引用;按钮点击通过
``BaseView.dispatch_execute`` 沿父链回到本类 ``run_single_step``。
3. **后台执行**:内置 ``TaskWorker(QThread)``(三信号协议:
``log_msg / finished / error``),把 service 调用丢进子线程,
避免阻塞 UI。
4. **日志** :底部 ``QTextEdit`` 集中输出路由与 service 结果。
本文件由原 ``src/new/main_router.py`` 迁移而来,类名 ``MainRouter`` 改名为
``MainView`` 以贴合"视图壳"语义,同时按用户原意把 ``TaskWorker`` 从单信号
``finished_with_result``)升级为三信号协议。
运行方式
--------
::
# 方式 A作为模块运行推荐
cd D:/111/office/ZHLduijie/1.WQ/WQ_GUI
python -m src.new.main_view
# 方式 B直接脚本运行
python src/new/main_view.py
"""
from __future__ import annotations
import importlib
import os
import sys
import traceback
from pathlib import Path
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtWidgets import (
QFileDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMainWindow, QMessageBox, QPushButton, QSplitter, QStackedWidget,
QTextEdit, QVBoxLayout, QWidget,
)
# 让 ``python src/new/main_view.py`` 直接运行时也能 import ``src.xxx``
_HERE = Path(__file__).resolve().parent # .../src/new
_SRC_ROOT = _HERE.parent # .../src
_PROJECT_ROOT = _SRC_ROOT.parent # .../WQ_GUI项目根用于解析 data/icons/
if str(_SRC_ROOT) not in sys.path:
sys.path.insert(0, str(_SRC_ROOT))
def _res(rel: str) -> str:
"""解析项目根的相对路径PyInstaller 打包后兼容 sys._MEIPASS。"""
if hasattr(sys, "_MEIPASS"):
return os.path.join(sys._MEIPASS, rel)
return str(_PROJECT_ROOT / rel)
from src.gui.styles import ModernStylesheet
from src.new.views.placeholder_view import PlaceholderView
# =========================================================================
# 路由注册表:每条路由对应一对 view + service
# =========================================================================
ROUTES = [
{
"id": "step1",
"name": "1. 水域掩膜",
"icon": "1.png",
"view_module": "src.new.views.step1_view",
"view_class": "Step1View",
"service_module": "src.new.services.step1_service",
"service_func": "execute_step1",
},
# -------- 业务路由(前 4 个预处理步骤已迁移到真实 service --------
{
"id": "step2",
"name": "2. 耀斑检测",
"icon": "2.png",
"view_module": "src.new.views.step2_view",
"view_class": "Step2View",
"service_module": "src.new.services.step2_service",
"service_func": "execute_step2",
},
{
"id": "step3",
"name": "3. 耀斑去除",
"icon": "3.png",
"view_module": "src.new.views.step3_view",
"view_class": "Step3View",
"service_module": "src.new.services.step3_service",
"service_func": "execute_step3",
},
{
"id": "step4",
"name": "4. 采样点布设",
"icon": "4.png",
"view_module": "src.new.views.step4_view",
"view_class": "Step4View",
"service_module": "src.new.services.step4_service",
"service_func": "execute_step4",
},
{
"id": "step5",
"name": "5. 数据清洗",
"icon": "5.png",
"view_module": "src.new.views.step5_view",
"view_class": "Step5View",
"service_module": "src.new.services.step5_service",
"service_func": "execute_step5",
},
{
"id": "step6",
"name": "6. 光谱特征",
"icon": "6.png",
"view_module": "src.new.views.step6_view",
"view_class": "Step6View",
"service_module": "src.new.services.step6_service",
"service_func": "execute_step6",
},
{
"id": "step7",
"name": "7. 水质光谱指数",
"icon": "7.png",
"view_module": "src.new.views.step7_view",
"view_class": "Step7View",
"service_module": "src.new.services.step7_service",
"service_func": "execute_step7",
},
{
"id": "step8",
"name": "8. 机器学习建模",
"icon": "8.png",
"view_module": "src.new.views.step8_view",
"view_class": "Step8View",
"service_module": "src.new.services.step8_service",
"service_func": "execute_step8",
},
{
# 旧版共用 10.png 是 bugdata/icons/ 里有 11.png 一直未引用,正好给"预测"用
"id": "step9",
"name": "9. 机器学习预测",
"icon": "11.png",
"view_module": "src.new.views.step9_view",
"view_class": "Step9View",
"service_module": "src.new.services.step9_service",
"service_func": "execute_step9",
},
{
"id": "step10",
"name": "10. 水色指数反演",
"icon": "10.png",
"view_module": "src.new.views.step10_view",
"view_class": "Step10View",
"service_module": "src.new.services.step10_service",
"service_func": "execute_step10",
},
{
# data/icons/ 没有 12.png/13.pngStep11/12/13 暂时复用 9.png11 个 png 对 13 个 step 必然有共用)
"id": "step11",
"name": "11. 专题图生成",
"icon": "9.png",
"view_module": "src.new.views.step11_view",
"view_class": "Step11View",
"service_module": "src.new.services.step11_service",
"service_func": "execute_step11",
},
{
"id": "step12",
"name": "12. 可视化",
"icon": "9.png",
"view_module": "src.new.views.step12_view",
"view_class": "Step12View",
"service_module": "src.new.services.step12_service",
"service_func": "execute_step12",
},
{
"id": "step13",
"name": "13. 报告生成",
"icon": "9.png",
"view_module": "src.new.views.step13_view",
"view_class": "Step13View",
"service_module": "src.new.services.step13_service",
"service_func": "execute_step13",
},
]
# =========================================================================
# 全局输入参数:用户在任意 step 导图后,广播给所有 view 自动填充
# =========================================================================
# 这些是各 view 关心的"上游产物 / 用户导入文件"key。任何一次 run_single_step
# 都会把 config_dict 中出现的非空值更新进 self.global_inputs并全量广播。
# view.set_config 已实现"非空才覆盖"(避免回填空串覆盖上游有效值),
# 所以直接全量广播是安全的。
GLOBAL_INPUT_KEYS: tuple[str, ...] = (
# step1 入口
"img_path", # 原始 BSQ/DAT/TIF 影像
"mask_path", # 现有水域掩膜shp / dat / tif
# step2/3 输入
"water_mask_path", # step1 产出
# step3/4/6/10 输入
"deglint_img_path", # step3 产出
"bsq_path", # step10 用,等价 deglint_img_path
"hdr_path", # ENVI 头文件
# step4 输入
"glint_mask_path", # step2 耀斑掩膜
# step5/6 输入
"csv_path", # step5 产出
"boundary_path", # step6 水域掩膜别名
# step7/8 输入
"training_csv_path", # step6 产出
# step9 输入
"sampling_csv_path", # step4 产出
"models_dir", # step8 产出
# step10 输入
"formula_csv_path", # waterindex.csv
# step11 输入
"prediction_csv_path",
"prediction_csv_dir",
"geotiff_path",
"geotiff_dir",
)
# =========================================================================
# TaskWorkerQThread 后台执行器(三信号协议)
# =========================================================================
class TaskWorker(QThread):
"""后台执行 ``service_func(config)`` 并通过三信号协议回报主线程
信号协议
--------
* ``log_msg(str)`` —— 启动 / 完成 / 异常等关键节点的人类可读日志
* ``finished(dict)`` —— service 正常返回(含 service 内部 try/except
转成的 error 状态 dict
* ``error(str)`` —— worker 自身捕获的未预期异常(含 traceback
与 finished 中的 status='error' 语义不同error 表示 worker
完全崩溃,无法产出任何结果 dict。
为什么用 QThread 而不是 Threadservice_func 可能涉及 rasterio /
matplotlib 等原生扩展,主线程与子线程的 Qt 事件循环隔离更稳。
"""
log_msg = pyqtSignal(str)
finished = pyqtSignal(dict)
error = pyqtSignal(str)
def __init__(self, service_func, config: dict):
super().__init__()
self.service_func = service_func
self.config = config
def run(self):
func_name = getattr(self.service_func, "__name__", "<anonymous>")
self.log_msg.emit(f"[Worker] 启动 {func_name}")
try:
result = self.service_func(self.config)
except Exception as e: # noqa: BLE001 —— worker 兜底捕获所有未预期异常
tb = traceback.format_exc()
self.log_msg.emit(f"[Worker] 异常 {type(e).__name__}: {e}")
self.error.emit(f"{type(e).__name__}: {e}\n{tb}")
return
status = result.get("status", "unknown") if isinstance(result, dict) else "unknown"
self.log_msg.emit(f"[Worker] 完成status={status}")
self.finished.emit(result)
# =========================================================================
# MainViewQMainWindow 路由壳
# =========================================================================
class MainView(QMainWindow):
"""新架构的轻量路由主窗口"""
def __init__(self):
super().__init__()
self.work_dir: str = ""
self._views: dict = {}
self._route_map: dict = {}
self.worker: TaskWorker | None = None
# 各 step 的产出路径缓存(声明式路径流转的中枢)
self.step_outputs: dict = {}
# 全局输入参数广播:用户在任何 step 导图后,其他 step 自动同步
self.global_inputs: dict = {}
self.setWindowTitle("WQ_GUI 新架构 · 端到端模块化路由壳")
self.resize(1280, 800)
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
# 设置窗口图标(旧版 water_quality_gui.py L1339-1340 用 uitubiao.ico
ico_path = _res("data/icons-1/uitubiao.ico")
if Path(ico_path).is_file():
self.setWindowIcon(QIcon(ico_path))
self._build_ui()
self._build_routes()
self._log("[Boot] MainView 初始化完成")
self._log(f"[Boot] 已注册 {len(ROUTES)} 条路由13 个 view 和 service 已全部打通真实链路!)")
# ------------------------------------------------------------------
# UI 布局:左侧导航 + 右侧 stacked + 底部日志
# ------------------------------------------------------------------
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
outer = QVBoxLayout(central)
outer.setContentsMargins(8, 8, 8, 8)
# ----- 顶部工具条logo + 项目名 + 工作目录 + 设置按钮 -----
toolbar = QHBoxLayout()
# Logo旧版 water_quality_gui.py L1569 用 logo.png
logo_path = _res("data/icons/logo.png")
if Path(logo_path).is_file():
logo_pixmap = QPixmap(logo_path)
if not logo_pixmap.isNull():
logo_pixmap = logo_pixmap.scaledToHeight(
36, Qt.SmoothTransformation
)
logo_label = QLabel()
logo_label.setPixmap(logo_pixmap)
toolbar.addWidget(logo_label)
# 项目名(紧贴 logo 右侧)
title_label = QLabel("WQ_GUI · 新架构")
title_label.setStyleSheet(
"font-size: 14px; font-weight: bold; color: #0055D4;"
)
toolbar.addWidget(title_label)
toolbar.addSpacing(20)
toolbar.addWidget(QLabel("工作目录:"))
self.work_dir_label = QLabel("(未设置)")
self.work_dir_label.setStyleSheet("color: #0055D4; font-weight: bold;")
toolbar.addWidget(self.work_dir_label, 1)
self.set_workdir_btn = QPushButton("设置工作目录...")
self.set_workdir_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("normal"))
self.set_workdir_btn.clicked.connect(self._on_set_workdir)
toolbar.addWidget(self.set_workdir_btn)
outer.addLayout(toolbar)
# ----- 顶部 MegaCube BannerCSS border-image 平铺拉伸 + 居中文字) -----
banner_label = QLabel()
banner_label.setMinimumHeight(100)
banner_label.setMaximumHeight(100)
banner_label.setAlignment(Qt.AlignCenter)
# 无条件显示文字border-image 不会覆盖前景文字)
banner_label.setText("MegaCube-Water Quality V1.2")
banner_path = _res("data/icons/Mega Water 1.0.jpg").replace('\\', '/')
if Path(banner_path).is_file():
banner_label.setStyleSheet(
f"border-image: url('{banner_path}') 0 0 0 0 stretch stretch;"
"color: white; font-size: 38px; font-weight: bold; font-family: 'Microsoft YaHei';"
)
else:
banner_label.setStyleSheet(
"background: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:0, "
"stop:0 #00a0e9, stop:1 #005a9e);"
"color: white; font-size: 38px; font-weight: bold; font-family: 'Microsoft YaHei';"
)
outer.addWidget(banner_label)
# ----- 中部:左导航 + 右 stacked用 QSplitter 可拖动) -----
splitter = QSplitter(Qt.Horizontal)
self.nav_list = QListWidget()
self.nav_list.setStyleSheet(ModernStylesheet.get_sidebar_stylesheet())
self.nav_list.setFixedWidth(200)
self.nav_list.currentRowChanged.connect(self._on_nav_changed)
splitter.addWidget(self.nav_list)
self.stacked = QStackedWidget()
splitter.addWidget(self.stacked)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
outer.addWidget(splitter, 1)
# ----- 底部日志 -----
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setStyleSheet("background-color: #1E1E1E; color: #DCDCDC; font-family: Consolas, monospace;")
self.log_text.setFixedHeight(180)
outer.addWidget(self.log_text)
# ------------------------------------------------------------------
# 路由注册:实例化 view、填充 nav_list 与 stacked
# ------------------------------------------------------------------
def _build_routes(self):
for route in ROUTES:
self._route_map[route["id"]] = route
view = self._instantiate_view(route)
self._views[route["id"]] = view
self.stacked.addWidget(view)
# 加载图标ROUTES[icon] 字段);找不到图标时退化为无图标 item
icon_filename = route.get("icon")
if icon_filename:
icon_full_path = _res(f"data/icons/{icon_filename}")
if Path(icon_full_path).is_file():
item = QListWidgetItem(QIcon(icon_full_path), route["name"])
else:
self._log(
f"[Router] 图标缺失 {icon_full_path}{route['id']} 用纯文字"
)
item = QListWidgetItem(route["name"])
else:
item = QListWidgetItem(route["name"])
# 统一图标尺寸(避免不同 png 拉伸不一致)
item.setSizeHint(item.sizeHint())
self.nav_list.addItem(item)
if ROUTES:
self.nav_list.setCurrentRow(0)
def _instantiate_view(self, route: dict) -> QWidget:
"""根据路由表实例化 viewstep1 走真实类,其余走 PlaceholderView"""
if route["view_module"] != "_placeholder":
try:
module = importlib.import_module(route["view_module"])
cls = getattr(module, route["view_class"])
return cls(self.stacked)
except Exception as e: # noqa: BLE001
# 真实 view 加载失败时降级为占位,并在日志区报错
self._log(f"[Router] 加载真实 view 失败 ({route['id']}): {e}")
traceback.print_exc()
# 占位 view直接构造
return PlaceholderView(route["id"], route["name"], self.stacked)
# ------------------------------------------------------------------
# 导航切换
# ------------------------------------------------------------------
def _on_nav_changed(self, row: int):
if row < 0 or row >= len(ROUTES):
return
route = ROUTES[row]
view = self._views[route["id"]]
self.stacked.setCurrentWidget(view)
self._log(f"[Nav] 切换到 {route['id']}{route['name']}")
# ------------------------------------------------------------------
# 工作目录推送:设置后广播给所有 view
# ------------------------------------------------------------------
def _on_set_workdir(self):
d = QFileDialog.getExistingDirectory(self, "选择工作目录", self.work_dir or "")
if not d:
return
self.set_work_dir(d)
def set_work_dir(self, work_dir: str):
"""设置工作目录并广播给所有 view"""
if not work_dir:
return
self.work_dir = work_dir.replace("\\", "/")
self.work_dir_label.setText(self.work_dir)
self._log(f"[Router] 工作目录已设置: {self.work_dir}")
# 工作目录变更 → 清空全局输入缓存(旧路径可能已失效)
if self.global_inputs:
self.clear_global_inputs()
# 推送给所有 view让它们按需自动填充路径
for view in self._views.values():
if hasattr(view, "update_from_config"):
view.update_from_config(self.work_dir, pipeline=None)
# ------------------------------------------------------------------
# 核心调度run_single_step被 view.dispatch_execute 调用)
# ------------------------------------------------------------------
def run_single_step(self, step_id: str, config_dict: dict):
"""从 view 接收 ``(step_id, config)`` → 调度 service 后台执行
1. 查路由表;
2. 注入 ``work_dir``(避免 view 重复感知);
3. **全局输入广播**——把本次 config_dict 中的公共输入 key 累计到
``self.global_inputs``,再推送给所有 view实现"一处导入,全局共享"
4. 启动 ``TaskWorker`` 在子线程运行 service
5. 三信号路由log_msg → 日志框finished → 状态分支日志;
error → 未预期异常写日志 + traceback。
"""
route = self._route_map.get(step_id)
if route is None:
self._log(f"[Router] 未知 step_id: {step_id}")
return
# 注入 work_dir仅当 view 未带且 main_view 已配置时)
if self.work_dir and "work_dir" not in config_dict:
config_dict = {**config_dict, "work_dir": self.work_dir}
# 全局输入广播:累计 + 推送给所有 view
self._broadcast_global_inputs(config_dict)
service_func = self._load_callable(route["service_module"], route["service_func"])
if service_func is None:
return
self._log(f"[Router] 收到 {step_id} 请求,配置: {config_dict}")
# 启动后台线程(覆盖旧 worker 防泄漏)
if self.worker and self.worker.isRunning():
self._log("[Router] 上一个任务尚未结束,丢弃新请求")
return
self.worker = TaskWorker(service_func, config_dict)
self.worker.log_msg.connect(self._log)
# 用 lambda 注入 step_idfinished/error 都拿得到自己服务的是哪一步
self.worker.finished.connect(
lambda result, sid=step_id: self._on_step_done(sid, result)
)
self.worker.error.connect(
lambda msg, sid=step_id: self._on_step_error(sid, msg)
)
self.worker.start()
def _load_callable(self, module_name: str, attr_name: str):
"""动态加载 ``module_name.attr_name``,失败时写日志并返回 None"""
try:
module = importlib.import_module(module_name)
return getattr(module, attr_name)
except Exception as e: # noqa: BLE001
self._log(f"[Router] 加载 service 失败 ({module_name}.{attr_name}): {e}")
traceback.print_exc()
return None
def _on_step_done(self, step_id: str, result: dict):
"""TaskWorker finished 回调——按 status 分支写日志
1. 缓存 output_path 到 self.step_outputs
2. 触发 _sync_dependencies 把产出路径推给所有下游 view
3. status=completed → QMessageBox.informationstatus=error → warning。
service 内部 try/except 转换的 status='error' 也走这里;
这与 error 信号worker 自身崩溃)语义不同。
"""
status = result.get("status", "unknown")
message = result.get("message", "")
output_path = result.get("output_path")
mode = result.get("mode", "")
# 1. 缓存路径 + 触发声明式下游推送(即使 error 也要缓存,让用户看到已失败)
if output_path:
self.step_outputs[step_id] = str(output_path)
self._log(f"[Cache] {step_id} → step_outputs['{step_id}'] = {output_path}")
self._sync_dependencies()
else:
self._log(f"[Cache] {step_id} 无 output_path跳过 step_outputs 缓存")
# 2. 日志分支 + 弹窗提示
if status == "completed":
self._log(f"[Service✓] {message}mode={mode}, output={output_path}")
QMessageBox.information(
self,
"执行完成",
f"{message or '当前步骤已完成!'}\n\n产出路径:\n{output_path or '(无)'}",
)
elif status == "skipped":
self._log(f"[Service↻] {message}(已复用历史结果)")
elif status == "not_implemented":
self._log(f"[Service…] {message}")
else: # errorservice 内部已转 dict
self._log(f"[Service✗] {message}mode={mode or 'n/a'}")
tb = result.get("traceback")
if tb:
self._log(tb)
QMessageBox.warning(
self,
"执行失败",
f"{message or '当前步骤执行失败'}\n\n请查看底部日志获取 traceback。",
)
def _on_step_error(self, step_id: str, msg: str):
"""TaskWorker error 信号回调——worker 自身捕获的未预期异常
worker 自身崩溃service_func 抛出未捕获异常)走这里;
与 finished 中 status='error' 语义不同error 信号表示 worker
完全无法产出结果 dict。
"""
self._log(f"[Worker✗] {step_id} 未预期异常:\n{msg}")
QMessageBox.critical(
self,
"Worker 异常",
f"步骤 {step_id} 后台执行崩溃:\n\n{msg[:500]}",
)
# ------------------------------------------------------------------
# 声明式路径流转:用 set_config 把上游产出推给所有下游 view
# ------------------------------------------------------------------
def _sync_dependencies(self):
"""遍历 self.step_outputs 字典,按依赖图把每步产出推给下游"""
# ----- step1 → step2/3/4 (water_mask_path) 及 step6 (boundary_path) -----
s1 = self.step_outputs.get("step1")
if s1:
for sid in ("step2", "step3", "step4"):
self._safe_set_config(sid, {"water_mask_path": s1})
# 补齐 step1 到 step6 的水体掩膜链路
self._safe_set_config("step6", {"boundary_path": s1})
# ----- step2 → step6 (glint_mask_path) -----
s2 = self.step_outputs.get("step2")
if s2:
# 补齐 step2 到 step6 的耀斑掩膜链路
self._safe_set_config("step6", {"glint_mask_path": s2})
# ----- step3 → step4/6 (deglint_img_path) / step10 (bsq_path) -----
s3 = self.step_outputs.get("step3")
if s3:
self._safe_set_config("step4", {"deglint_img_path": s3})
self._safe_set_config("step6", {"deglint_img_path": s3})
self._safe_set_config("step10", {"bsq_path": s3})
# ----- step4 → step9 (sampling_csv_path) -----
s4 = self.step_outputs.get("step4")
if s4:
self._safe_set_config("step9", {"sampling_csv_path": s4})
# ----- step5 → step6 (csv_path) -----
s5 = self.step_outputs.get("step5")
if s5:
self._safe_set_config("step6", {"csv_path": s5})
# ----- step6 → step7/step8 (training_csv_path) -----
s6 = self.step_outputs.get("step6")
if s6:
self._safe_set_config("step7", {"training_csv_path": s6})
self._safe_set_config("step8", {"training_csv_path": s6})
# ----- step8 → step9 (models_dir: 父目录) -----
s8 = self.step_outputs.get("step8")
if s8:
s8_dir = str(Path(s8).parent).replace("\\", "/")
self._safe_set_config("step9", {"models_dir": s8_dir})
# ----- step9 → step11 (prediction_csv_dir / prediction_csv_path) -----
s9 = self.step_outputs.get("step9")
if s9:
s9_dir = str(Path(s9).parent).replace("\\", "/")
self._safe_set_config("step11", {"prediction_csv_dir": s9_dir})
self._safe_set_config("step11", {"prediction_csv_path": s9})
# ----- step10 → step11 (geotiff_dir / geotiff_path) -----
s10 = self.step_outputs.get("step10")
if s10:
s10_dir = str(Path(s10).parent).replace("\\", "/")
self._safe_set_config("step11", {"geotiff_dir": s10_dir})
self._safe_set_config("step11", {"geotiff_path": s10})
self._log(f"[Sync] 全局依赖同步完成step_outputs keys: {list(self.step_outputs.keys())}")
def _safe_set_config(self, step_id: str, config: dict):
"""安全调用 view.set_configview 缺失 / 无该方法 / 抛异常时降级日志
返回 True 表示推送成功False 表示降级跳过,便于上层做计数。
"""
view = self._views.get(step_id)
if view is None:
return False
if not hasattr(view, "set_config"):
self._log(f"[Sync] {step_id} 没有 set_config跳过推送 {list(config.keys())}")
return False
try:
view.set_config(config)
self._log(f"[Sync] → {step_id}.set_config({config})")
return True
except Exception as e: # noqa: BLE001 —— 单步推送失败不影响整体 sync
self._log(f"[Sync] 推送 {step_id} 失败: {e}")
traceback.print_exc()
return False
# ------------------------------------------------------------------
# 全局输入参数广播:用户在一处导图 → 所有 view 自动同步
# ------------------------------------------------------------------
def _broadcast_global_inputs(self, config_dict: dict):
"""把本次 config_dict 中的公共输入 key 累计到 self.global_inputs
再把整个 global_inputs 推给所有 view实现"一处导入,全局共享")。
设计要点:
1. **非空才覆盖**——空字符串/None 不会污染已存在的有效值(防止下游
view 用空串覆盖掉上游刚刚推送的有效路径)。
2. **全量广播是安全的**——各 view.set_config 已实现 "if key in config"
守卫,未识别的 key 直接忽略,互不干扰。
3. **顺序无关**——多次 run_single_step 都会持续累计 self.global_inputs
直到用户主动切换工作目录或清理。
"""
updated_keys: list[str] = []
for key in GLOBAL_INPUT_KEYS:
val = config_dict.get(key)
if val: # 非空才覆盖None / "" 一律跳过)
self.global_inputs[key] = val
updated_keys.append(key)
if updated_keys:
self._log(
f"[Global] 本次更新 {len(updated_keys)} 个全局输入: {updated_keys}"
)
# 推送给所有 view
if not self._views:
return
n_ok, n_fail = 0, 0
for step_id, view in self._views.items():
if not hasattr(view, "set_config"):
continue
try:
view.set_config(self.global_inputs)
n_ok += 1
except Exception as e: # noqa: BLE001 —— 单 view 失败不影响其它
n_fail += 1
self._log(f"[Global] {step_id} 推送失败: {e}")
self._log(
f"[Global] 广播完成: ok={n_ok} fail={n_fail} "
f"keys={list(self.global_inputs.keys())}"
)
def clear_global_inputs(self):
"""清空全局输入缓存(工作目录切换 / 用户重置时调用)"""
self.global_inputs.clear()
self._log("[Global] 已清空全局输入缓存")
# ------------------------------------------------------------------
# 日志:所有日志统一走这里
# ------------------------------------------------------------------
def _log(self, msg: str):
# 控制台 + 底部日志框
print(msg)
self.log_text.append(msg)
# =========================================================================
# 入口
# =========================================================================
def main():
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
win = MainView()
win.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()