views/* + main_router.py:13 step 路由壳(QListWidget+QStackedWidget+TaskWorker 后台执行器)
This commit is contained in:
328
src/new/main_router.py
Normal file
328
src/new/main_router.py
Normal file
@ -0,0 +1,328 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
MainRouter —— 端到端模块化新架构的路由与调度壳
|
||||
=================================================
|
||||
|
||||
职责
|
||||
----
|
||||
1. **路由**:根据 ``ROUTES`` 表动态加载每个 step 的 view(前端皮囊)
|
||||
和 service(后端大脑),左侧 ``QListWidget`` 导航,右侧
|
||||
``QStackedWidget`` 承载 view。
|
||||
2. **解耦通讯**:view 不直接持有 main_router 引用;按钮点击通过
|
||||
``BaseView.dispatch_execute`` 沿父链回到本类 ``run_single_step``。
|
||||
3. **后台执行**:内置 ``TaskWorker(QThread)``,把 service 调用丢进
|
||||
子线程,避免阻塞 UI。
|
||||
4. **日志** :底部 ``QTextEdit`` 集中输出路由与 service 结果。
|
||||
|
||||
运行方式
|
||||
--------
|
||||
::
|
||||
|
||||
# 方式 A:作为模块运行(推荐)
|
||||
cd D:/111/office/ZHLduijie/1.WQ/WQ_GUI
|
||||
python -m src.new.main_router
|
||||
|
||||
# 方式 B:直接脚本运行
|
||||
python src/new/main_router.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.QtWidgets import (
|
||||
QFileDialog, QHBoxLayout, QLabel, QListWidget, QMainWindow,
|
||||
QPushButton, QSplitter, QStackedWidget, QTextEdit, QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
# 让 ``python src/new/main_router.py`` 直接运行时也能 import ``src.xxx``
|
||||
_HERE = Path(__file__).resolve().parent # .../src/new
|
||||
_SRC_ROOT = _HERE.parent # .../src
|
||||
if str(_SRC_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC_ROOT))
|
||||
|
||||
from src.gui.styles import ModernStylesheet
|
||||
from src.new.views.placeholder_view import PlaceholderView
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 路由注册表:每条路由对应一对 view + service
|
||||
# =========================================================================
|
||||
ROUTES = [
|
||||
{
|
||||
"id": "step1",
|
||||
"name": "1. 水域掩膜",
|
||||
"view_module": "src.new.views.step1_view",
|
||||
"view_class": "Step1View",
|
||||
"service_module": "src.new.services.step1_service",
|
||||
"service_func": "execute_step1",
|
||||
},
|
||||
# -------- 占位路由(step2-step13 尚未迁移) --------
|
||||
{"id": "step2", "name": "2. 数据准备", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step3", "name": "3. 耀斑检测", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step4", "name": "4. 耀斑去除", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step5", "name": "5. 水色指数反演", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step6", "name": "6. 指数波段匹配", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step7", "name": "7. 水质参数计算", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step8", "name": "8. ML 建模训练", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step9", "name": "9. 模型校正", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step10", "name": "10. 浓度反演", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step11", "name": "11. 空间克里金", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step12", "name": "12. 制图渲染", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
{"id": "step13", "name": "13. 结果导出", "view_module": "_placeholder", "view_class": "_placeholder", "service_module": "src.new.services.placeholder_service", "service_func": "execute_placeholder"},
|
||||
]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TaskWorker:QThread 后台执行器
|
||||
# =========================================================================
|
||||
class TaskWorker(QThread):
|
||||
"""后台执行 ``service_func(config)`` 并把结果 emit 回主线程
|
||||
|
||||
为什么用 QThread 而不是 Thread:service_func 可能涉及 rasterio /
|
||||
matplotlib 等原生扩展,主线程与子线程的 Qt 事件循环隔离更稳。
|
||||
"""
|
||||
finished_with_result = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, service_func, config: dict):
|
||||
super().__init__()
|
||||
self.service_func = service_func
|
||||
self.config = config
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
result = self.service_func(self.config)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result = {
|
||||
"status": "error",
|
||||
"output_path": None,
|
||||
"message": f"{type(e).__name__}: {e}",
|
||||
"traceback": traceback.format_exc(),
|
||||
}
|
||||
self.finished_with_result.emit(result)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# MainRouter:QMainWindow 路由壳
|
||||
# =========================================================================
|
||||
class MainRouter(QMainWindow):
|
||||
"""新架构的轻量路由主窗口"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.work_dir: str = ""
|
||||
self._views: dict = {}
|
||||
self._route_map: dict = {}
|
||||
self.worker: TaskWorker | None = None
|
||||
|
||||
self.setWindowTitle("WQ_GUI 新架构 · 端到端模块化路由壳")
|
||||
self.resize(1280, 800)
|
||||
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
|
||||
|
||||
self._build_ui()
|
||||
self._build_routes()
|
||||
|
||||
self._log("[Boot] MainRouter 初始化完成")
|
||||
self._log(f"[Boot] 已注册 {len(ROUTES)} 条路由(step1 真实 / step2-step13 占位)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UI 布局:左侧导航 + 右侧 stacked + 底部日志
|
||||
# ------------------------------------------------------------------
|
||||
def _build_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
|
||||
outer = QVBoxLayout(central)
|
||||
outer.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
# ----- 顶部工具条:工作目录 + 设置按钮 -----
|
||||
toolbar = QHBoxLayout()
|
||||
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)
|
||||
|
||||
# ----- 中部:左导航 + 右 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)
|
||||
self.nav_list.addItem(route["name"])
|
||||
|
||||
if ROUTES:
|
||||
self.nav_list.setCurrentRow(0)
|
||||
|
||||
def _instantiate_view(self, route: dict) -> QWidget:
|
||||
"""根据路由表实例化 view:step1 走真实类,其余走 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}")
|
||||
|
||||
# 推送给所有 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. 启动 ``TaskWorker`` 在子线程运行 service;
|
||||
4. 完成后 emit 回主线程并写日志。
|
||||
"""
|
||||
route = self._route_map.get(step_id)
|
||||
if route is None:
|
||||
self._log(f"[Router] 未知 step_id: {step_id}")
|
||||
return
|
||||
|
||||
# 注入 work_dir(仅当 view 未带且 main_router 已配置时)
|
||||
if self.work_dir and "work_dir" not in config_dict:
|
||||
config_dict = {**config_dict, "work_dir": self.work_dir}
|
||||
|
||||
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.finished_with_result.connect(self._on_step_done)
|
||||
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, result: dict):
|
||||
"""TaskWorker 完成回调——按 status 分支写日志"""
|
||||
status = result.get("status", "unknown")
|
||||
message = result.get("message", "")
|
||||
output_path = result.get("output_path")
|
||||
mode = result.get("mode", "")
|
||||
|
||||
if status == "completed":
|
||||
self._log(f"[Service✓] {message}(mode={mode}, output={output_path})")
|
||||
elif status == "skipped":
|
||||
self._log(f"[Service↻] {message}(已复用历史结果)")
|
||||
elif status == "not_implemented":
|
||||
self._log(f"[Service…] {message}")
|
||||
else: # error
|
||||
self._log(f"[Service✗] {message}(mode={mode or 'n/a'})")
|
||||
tb = result.get("traceback")
|
||||
if tb:
|
||||
self._log(tb)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 日志:所有日志统一走这里
|
||||
# ------------------------------------------------------------------
|
||||
def _log(self, msg: str):
|
||||
# 控制台 + 底部日志框
|
||||
print(msg)
|
||||
self.log_text.append(msg)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 入口
|
||||
# =========================================================================
|
||||
def main():
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
app = QApplication(sys.argv)
|
||||
win = MainRouter()
|
||||
win.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
src/new/views/__init__.py
Normal file
5
src/new/views/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""views —— 独立前端视图包
|
||||
|
||||
每个 step 对应一个继承 ``BaseView`` 的 PyQt 组件。
|
||||
"""
|
||||
77
src/new/views/placeholder_view.py
Normal file
77
src/new/views/placeholder_view.py
Normal file
@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PlaceholderView —— 通用占位视图(用于尚未迁移的步骤)
|
||||
|
||||
仅显示 step 名称与"待实现"提示,仍遵循 BaseView 契约:
|
||||
|
||||
* ``get_config()`` 返回空 dict(service 端据此走占位分支)
|
||||
* 点击"执行"按钮仍会触发 ``dispatch_execute``,便于在 main_router
|
||||
中验证路由链路完整通畅
|
||||
"""
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from src.gui.styles import ModernStylesheet
|
||||
from src.new.core.base_view import BaseView
|
||||
|
||||
|
||||
class PlaceholderView(BaseView):
|
||||
"""占位视图:显示 step_id/标题,绿色按钮触发 dispatch_execute"""
|
||||
|
||||
def __init__(self, step_id: str, title: str, parent=None):
|
||||
# 先缓存元数据再 super,避免 init_ui 阶段访问不到
|
||||
self.step_id = step_id
|
||||
self.title = title
|
||||
super().__init__(parent)
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
header = QLabel(f"🚧 {self.title}({self.step_id})")
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
header.setStyleSheet("""
|
||||
QLabel {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
padding: 20px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(header)
|
||||
|
||||
hint = QLabel(
|
||||
"此步骤的 View 与 Service 尚未迁移到端到端模块化新架构。\n"
|
||||
"按钮仍可点击以验证 main_router 路由链路完整性。"
|
||||
)
|
||||
hint.setAlignment(Qt.AlignCenter)
|
||||
hint.setWordWrap(True)
|
||||
hint.setStyleSheet("""
|
||||
QLabel {
|
||||
color: #0055D4;
|
||||
font-size: 13px;
|
||||
background-color: #FFF8DC;
|
||||
border: 2px dashed #FFA500;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 8px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(hint)
|
||||
|
||||
self.run_btn = QPushButton(f"执行 {self.title}")
|
||||
self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success"))
|
||||
self.run_btn.clicked.connect(self._on_run_clicked)
|
||||
layout.addWidget(self.run_btn)
|
||||
|
||||
layout.addStretch()
|
||||
self.setLayout(layout)
|
||||
|
||||
def _on_run_clicked(self):
|
||||
self.dispatch_execute(self.step_id, self.get_config())
|
||||
|
||||
def get_config(self) -> dict:
|
||||
return {"_placeholder": True, "step_id": self.step_id}
|
||||
|
||||
def set_config(self, config: dict):
|
||||
return None
|
||||
284
src/new/views/step1_view.py
Normal file
284
src/new/views/step1_view.py
Normal file
@ -0,0 +1,284 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Step1View —— Step 1(水域掩膜生成)的前端视图(端到端模块化)
|
||||
|
||||
设计原则
|
||||
========
|
||||
|
||||
1. **UI 完全移植**:控件、布局、单选样式、信号槽、文件过滤器、阈值参数
|
||||
全部从 ``src/gui/panels/step1_panel.py`` 原样搬迁,业务行为保持一致。
|
||||
2. **零业务逻辑**:本文件 **绝不** import 任何 ``src/core/``、``src/services/``
|
||||
算法模块;**绝不** 调用 Pipeline;唯一跨层出口是底部按钮的
|
||||
``self.dispatch_execute("step1", self.get_config())``。
|
||||
3. **契约对齐**:继承 ``BaseView``;实现 ``init_ui / get_config / set_config``;
|
||||
通过 ``update_work_directory`` 在 NDWI 模式下自动填充输出路径。
|
||||
|
||||
调用链(自顶向下)::
|
||||
|
||||
Step1View._on_run_clicked
|
||||
→ BaseView.dispatch_execute("step1", config)
|
||||
→ MainRouter.run_single_step(step_id, config)
|
||||
→ TaskWorker → services.step1_service.execute_step1(config)
|
||||
→ 返回结果 dict → MainRouter 日志区
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
QCheckBox, QDoubleSpinBox, QGroupBox, QHBoxLayout,
|
||||
QLabel, QPushButton, QRadioButton, QVBoxLayout,
|
||||
)
|
||||
|
||||
from src.gui.components.custom_widgets import FileSelectWidget
|
||||
from src.gui.styles import ModernStylesheet
|
||||
from src.new.core.base_view import BaseView
|
||||
|
||||
|
||||
# QRadioButton 统一样式(实心选中点)——与旧 panel 完全一致
|
||||
_RADIO_STYLE = """
|
||||
QRadioButton::indicator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #999;
|
||||
}
|
||||
QRadioButton::indicator:checked {
|
||||
background-color: #0078D7;
|
||||
border: 2px solid #0078D7;
|
||||
}
|
||||
QRadioButton::indicator:unchecked {
|
||||
background-color: white;
|
||||
border: 2px solid #999;
|
||||
}
|
||||
QRadioButton::indicator:hover {
|
||||
border: 2px solid #0078D7;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Step1View(BaseView):
|
||||
"""Step 1: 水域掩膜生成 —— 纯 View,所有跨层通讯走 dispatch_execute"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UI 构建(与旧 step1_panel.init_ui 字节级一致,仅按钮文案与回调不同)
|
||||
# ------------------------------------------------------------------
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# ---------- 掩膜生成方式 ----------
|
||||
method_group = QGroupBox("掩膜生成方式")
|
||||
method_layout = QVBoxLayout()
|
||||
|
||||
self.use_existing_radio = QRadioButton("使用现有掩膜文件")
|
||||
self.use_existing_radio.setChecked(True)
|
||||
method_layout.addWidget(self.use_existing_radio)
|
||||
|
||||
self.use_ndwi_radio = QRadioButton("使用NDWI自动生成")
|
||||
method_layout.addWidget(self.use_ndwi_radio)
|
||||
|
||||
self.use_existing_radio.setStyleSheet(_RADIO_STYLE)
|
||||
self.use_ndwi_radio.setStyleSheet(_RADIO_STYLE)
|
||||
|
||||
method_group.setLayout(method_layout)
|
||||
layout.addWidget(method_group)
|
||||
|
||||
# ---------- 掩膜文件 ----------
|
||||
self.mask_file = FileSelectWidget(
|
||||
"掩膜文件:",
|
||||
"Shapefiles (*.shp);;Raster Files (*.dat *.tif);;All Files (*.*)"
|
||||
)
|
||||
layout.addWidget(self.mask_file)
|
||||
|
||||
# ---------- 参考影像 ----------
|
||||
self.img_file = FileSelectWidget(
|
||||
"参考影像:",
|
||||
"Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
|
||||
)
|
||||
layout.addWidget(self.img_file)
|
||||
|
||||
# ---------- NDWI 参数 ----------
|
||||
self.ndwi_group = QGroupBox("NDWI参数设置")
|
||||
ndwi_layout = QVBoxLayout()
|
||||
|
||||
threshold_layout = QHBoxLayout()
|
||||
threshold_layout.addWidget(QLabel("NDWI阈值:"))
|
||||
self.ndwi_threshold = QDoubleSpinBox()
|
||||
self.ndwi_threshold.setRange(0.0, 1.0)
|
||||
self.ndwi_threshold.setSingleStep(0.05)
|
||||
self.ndwi_threshold.setValue(0.4)
|
||||
self.ndwi_threshold.setDecimals(2)
|
||||
threshold_layout.addWidget(self.ndwi_threshold)
|
||||
threshold_layout.addStretch()
|
||||
ndwi_layout.addLayout(threshold_layout)
|
||||
|
||||
self.ndwi_group.setLayout(ndwi_layout)
|
||||
layout.addWidget(self.ndwi_group)
|
||||
|
||||
# ---------- 输出掩膜路径(save 模式) ----------
|
||||
self.output_file = FileSelectWidget(
|
||||
"输出掩膜:",
|
||||
"Mask Files (*.dat *.tif);;All Files (*.*)",
|
||||
mode="save",
|
||||
)
|
||||
self.output_file.line_edit.setPlaceholderText("water_mask.dat")
|
||||
layout.addWidget(self.output_file)
|
||||
|
||||
# ---------- 提示信息(Info Alert 样式) ----------
|
||||
hint = QLabel(
|
||||
"💡 提示: 如果掩膜文件是Shapefile(.shp),需要提供参考影像用于栅格化;"
|
||||
"如果使用NDWI自动生成,只需要提供参考影像"
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
hint.setStyleSheet("""
|
||||
QLabel {
|
||||
color: #0055D4;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
background-color: #E8F4FF;
|
||||
border: 2px solid #0055D4;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(hint)
|
||||
|
||||
# ---------- 启用步骤 ----------
|
||||
self.enable_checkbox = QCheckBox("启用此步骤")
|
||||
self.enable_checkbox.setChecked(True)
|
||||
layout.addWidget(self.enable_checkbox)
|
||||
|
||||
# ---------- 执行按钮(绿色 success 样式,仅作视图层入口) ----------
|
||||
self.run_btn = QPushButton("执行 Step 1: 水域掩膜")
|
||||
self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet("success"))
|
||||
self.run_btn.clicked.connect(self._on_run_clicked)
|
||||
layout.addWidget(self.run_btn)
|
||||
|
||||
# ---------- 信号联动 ----------
|
||||
self.use_existing_radio.toggled.connect(self.update_ui_state)
|
||||
self.use_ndwi_radio.toggled.connect(self.update_ui_state)
|
||||
|
||||
layout.addStretch()
|
||||
self.setLayout(layout)
|
||||
|
||||
# 初始 UI 状态
|
||||
self.update_ui_state()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UI 状态联动(与旧 panel.update_ui_state 同语义)
|
||||
# ------------------------------------------------------------------
|
||||
def update_ui_state(self):
|
||||
"""根据单选状态显示/隐藏 NDWI 参数与输出路径"""
|
||||
use_ndwi = self.use_ndwi_radio.isChecked()
|
||||
|
||||
if use_ndwi:
|
||||
self.mask_file.setVisible(False)
|
||||
self.ndwi_group.setVisible(True)
|
||||
self.output_file.setVisible(True)
|
||||
|
||||
# 主窗口已推送过 work_dir 才填,避免 init_ui 阶段空指针
|
||||
if self.work_dir:
|
||||
self._auto_fill_output_path()
|
||||
else:
|
||||
self.mask_file.setVisible(True)
|
||||
self.ndwi_group.setVisible(False)
|
||||
self.output_file.setVisible(False)
|
||||
|
||||
# 参考影像在两种模式下都显示
|
||||
self.img_file.setVisible(True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 工作目录推送——主窗口变更 work_dir 时调用
|
||||
# ------------------------------------------------------------------
|
||||
def update_work_directory(self, work_dir):
|
||||
"""主窗口推送工作目录变更;NDWI 模式下自动填充输出路径
|
||||
|
||||
重写基类以叠加"自动填路径"行为,再 super 缓存保持一致。
|
||||
"""
|
||||
super().update_work_directory(work_dir)
|
||||
|
||||
if work_dir and self.use_ndwi_radio.isChecked():
|
||||
self._auto_fill_output_path()
|
||||
|
||||
def _auto_fill_output_path(self):
|
||||
"""NDWI 模式下根据 work_dir 推导默认输出路径(统一正斜杠)
|
||||
|
||||
view 层内联的轻量推导——不依赖旧 ``_step_path_resolver``:
|
||||
output_dir = work_dir/1_water_mask/
|
||||
default_output = output_dir/water_mask_out.dat
|
||||
"""
|
||||
if not self.work_dir:
|
||||
return
|
||||
|
||||
output_dir = os.path.join(self.work_dir, "1_water_mask")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
default_output_path = os.path.join(output_dir, "water_mask_out.dat").replace("\\", "/")
|
||||
self.output_file.set_path(default_output_path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseView 契约:get_config / set_config
|
||||
# ------------------------------------------------------------------
|
||||
def get_config(self) -> dict:
|
||||
"""读取当前 UI 状态——返回干净字典供 dispatch_execute 携带
|
||||
|
||||
字段:
|
||||
mask_path : str | None 现有掩膜文件路径;NDWI 模式下为 None
|
||||
use_ndwi : bool 是否走 NDWI 自动生成
|
||||
ndwi_threshold : float NDWI 阈值
|
||||
img_path : str 参考影像路径(非空才包含)
|
||||
output_path : str | None NDWI 模式下的输出路径;现有掩膜模式下为 None
|
||||
"""
|
||||
use_ndwi = self.use_ndwi_radio.isChecked()
|
||||
|
||||
config = {
|
||||
"mask_path": None if use_ndwi else self.mask_file.get_path(),
|
||||
"use_ndwi": use_ndwi,
|
||||
"ndwi_threshold": self.ndwi_threshold.value(),
|
||||
}
|
||||
|
||||
# 参考影像:非空才写入,避免下游合并时被空字符串覆盖
|
||||
img_path = self.img_file.get_path()
|
||||
if img_path:
|
||||
config["img_path"] = img_path
|
||||
|
||||
if use_ndwi:
|
||||
output_path = self.output_file.get_path()
|
||||
if output_path:
|
||||
config["output_path"] = output_path
|
||||
else:
|
||||
# 现有掩膜模式下不传递 output_path,避免底层错误尝试保存文件
|
||||
config["output_path"] = None
|
||||
|
||||
return config
|
||||
|
||||
def set_config(self, config: dict):
|
||||
"""根据 config 恢复 UI 状态——None / 空值字段一律跳过"""
|
||||
if config.get("mask_path"):
|
||||
self.mask_file.set_path(config["mask_path"])
|
||||
if config.get("img_path"):
|
||||
self.img_file.set_path(config["img_path"])
|
||||
if config.get("output_path"):
|
||||
self.output_file.set_path(config["output_path"])
|
||||
if "use_ndwi" in config:
|
||||
if config["use_ndwi"]:
|
||||
self.use_ndwi_radio.setChecked(True)
|
||||
else:
|
||||
self.use_existing_radio.setChecked(True)
|
||||
if "ndwi_threshold" in config:
|
||||
self.ndwi_threshold.setValue(config["ndwi_threshold"])
|
||||
|
||||
self.update_ui_state()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 执行入口:仅触发 dispatch_execute——无任何业务校验/算法调用
|
||||
# ------------------------------------------------------------------
|
||||
def _on_run_clicked(self):
|
||||
"""绿色按钮:纯 View 层入口,把当前 UI 配置打包交给主窗口
|
||||
|
||||
所有跨层通讯一律走 ``dispatch_execute``:
|
||||
|
||||
Step1View → 沿父链上溯 → MainRouter.run_single_step(step_id, config)
|
||||
"""
|
||||
self.dispatch_execute("step1", self.get_config())
|
||||
Reference in New Issue
Block a user