feat(new-arch):主窗口全功能增强(图标系统 + 全链路参数同步 + 服务输出统一解析 + Step12 分类浏览)

1. main_view.py:图标系统 + 全链路参数自动传导
   - 新增 _res() 解析项目根的相对路径,PyInstaller 打包后兼容 sys._MEIPASS。
   - 新增 QListWidgetItem / QMessageBox 导入,左侧导航列表支持右键菜单 + 错误弹窗。
   - ROUTES 12 条全部新增 icon 字段("1.png" 等),侧边栏显示业务图标。
   - 新增 step_outputs 缓存机制:每个 step 完成后把 output_path 写入 self.step_outputs。
   - 新增 _sync_dependencies() 同步函数 + _safe_set_config() 包装器,
     按依赖图把上游产物推给下游 view:
       step1 → step6 water_mask_path
       step3 → step4 / step6 / step10 deglint_img_path / bsq_path
       step4 → step9 sampling_csv_path
       step5 → step6 csv_path
       step6 → step7 / step8 training_csv_path
       step8 → step9 models_dir(父目录)
       step9 → step11 prediction_csv_dir / prediction_csv_path(双推)
       step10 → step11 geotiff_dir / geotiff_path(双推)

2. services/step1-13:统一输出解析器集成
   - 新增 src/new/services/_output_resolver.py,提供 resolve_output_dir /
     copy_to_user_path / get_user_output_path / is_user_specified 四个共享工具。
   - 每个 service 把原有的私有 _resolve_xxx_dir 改为调用 resolve_output_dir,
     强制执行"用户优先"规则(用户指定 output_path 时用其父目录,否则用 work_dir/<subdir>)。
   - 用户指定文件名 vs 底层硬编码文件名的"事后劫持"通过 copy_to_user_path 完成
     (覆盖 step2、step4、step7、step8 等底层 step 不接受 output_path 关键字的步骤)。

3. views/step12_view.py:恢复 ImageCategoryTree + ImageViewerWidget 高级组件
   - 删掉精简版占位 Label,挂回旧版的 ImageCategoryTree(按"模型评估/光谱分析/
     统计图表/处理结果/含量分布图"五类自动归类工作目录下的图像文件)。
   - 挂回 ImageViewerWidget(滚轮缩放 0.1x-5x + 50ms 防抖 + FastTransformation/
     SmoothTransformation 智能切换 + Ctrl+Wheel + 工具栏)。
   - 扫描按钮接通 image_tree.scan_directory(),选中节点即时加载到 image_viewer。
   - 按钮样式切换为 ModernStylesheet(success/primary)统一视觉。
This commit is contained in:
DXC
2026-06-17 13:28:58 +08:00
parent 9cb3c8ed0d
commit 6a962f5e8f
15 changed files with 607 additions and 116 deletions

View File

@ -35,23 +35,34 @@ MainView —— 端到端模块化新架构的路由与调度壳(主窗口)
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import os
import sys import sys
import traceback import traceback
from pathlib import Path from pathlib import Path
from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QFileDialog, QHBoxLayout, QLabel, QListWidget, QMainWindow, QFileDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QPushButton, QSplitter, QStackedWidget, QTextEdit, QVBoxLayout, QMainWindow, QMessageBox, QPushButton, QSplitter, QStackedWidget,
QWidget, QTextEdit, QVBoxLayout, QWidget,
) )
# 让 ``python src/new/main_view.py`` 直接运行时也能 import ``src.xxx`` # 让 ``python src/new/main_view.py`` 直接运行时也能 import ``src.xxx``
_HERE = Path(__file__).resolve().parent # .../src/new _HERE = Path(__file__).resolve().parent # .../src/new
_SRC_ROOT = _HERE.parent # .../src _SRC_ROOT = _HERE.parent # .../src
_PROJECT_ROOT = _SRC_ROOT.parent # .../WQ_GUI项目根用于解析 data/icons/
if str(_SRC_ROOT) not in sys.path: if str(_SRC_ROOT) not in sys.path:
sys.path.insert(0, str(_SRC_ROOT)) 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.gui.styles import ModernStylesheet
from src.new.views.placeholder_view import PlaceholderView from src.new.views.placeholder_view import PlaceholderView
@ -63,6 +74,7 @@ ROUTES = [
{ {
"id": "step1", "id": "step1",
"name": "1. 水域掩膜", "name": "1. 水域掩膜",
"icon": "1.png",
"view_module": "src.new.views.step1_view", "view_module": "src.new.views.step1_view",
"view_class": "Step1View", "view_class": "Step1View",
"service_module": "src.new.services.step1_service", "service_module": "src.new.services.step1_service",
@ -72,6 +84,7 @@ ROUTES = [
{ {
"id": "step2", "id": "step2",
"name": "2. 耀斑检测", "name": "2. 耀斑检测",
"icon": "2.png",
"view_module": "src.new.views.step2_view", "view_module": "src.new.views.step2_view",
"view_class": "Step2View", "view_class": "Step2View",
"service_module": "src.new.services.step2_service", "service_module": "src.new.services.step2_service",
@ -80,6 +93,7 @@ ROUTES = [
{ {
"id": "step3", "id": "step3",
"name": "3. 耀斑去除", "name": "3. 耀斑去除",
"icon": "3.png",
"view_module": "src.new.views.step3_view", "view_module": "src.new.views.step3_view",
"view_class": "Step3View", "view_class": "Step3View",
"service_module": "src.new.services.step3_service", "service_module": "src.new.services.step3_service",
@ -88,6 +102,7 @@ ROUTES = [
{ {
"id": "step4", "id": "step4",
"name": "4. 采样点布设", "name": "4. 采样点布设",
"icon": "4.png",
"view_module": "src.new.views.step4_view", "view_module": "src.new.views.step4_view",
"view_class": "Step4View", "view_class": "Step4View",
"service_module": "src.new.services.step4_service", "service_module": "src.new.services.step4_service",
@ -96,6 +111,7 @@ ROUTES = [
{ {
"id": "step5", "id": "step5",
"name": "5. 数据清洗", "name": "5. 数据清洗",
"icon": "5.png",
"view_module": "src.new.views.step5_view", "view_module": "src.new.views.step5_view",
"view_class": "Step5View", "view_class": "Step5View",
"service_module": "src.new.services.step5_service", "service_module": "src.new.services.step5_service",
@ -104,6 +120,7 @@ ROUTES = [
{ {
"id": "step6", "id": "step6",
"name": "6. 光谱特征", "name": "6. 光谱特征",
"icon": "6.png",
"view_module": "src.new.views.step6_view", "view_module": "src.new.views.step6_view",
"view_class": "Step6View", "view_class": "Step6View",
"service_module": "src.new.services.step6_service", "service_module": "src.new.services.step6_service",
@ -112,6 +129,7 @@ ROUTES = [
{ {
"id": "step7", "id": "step7",
"name": "7. 水质光谱指数", "name": "7. 水质光谱指数",
"icon": "7.png",
"view_module": "src.new.views.step7_view", "view_module": "src.new.views.step7_view",
"view_class": "Step7View", "view_class": "Step7View",
"service_module": "src.new.services.step7_service", "service_module": "src.new.services.step7_service",
@ -120,14 +138,17 @@ ROUTES = [
{ {
"id": "step8", "id": "step8",
"name": "8. 机器学习建模", "name": "8. 机器学习建模",
"icon": "8.png",
"view_module": "src.new.views.step8_view", "view_module": "src.new.views.step8_view",
"view_class": "Step8View", "view_class": "Step8View",
"service_module": "src.new.services.step8_service", "service_module": "src.new.services.step8_service",
"service_func": "execute_step8", "service_func": "execute_step8",
}, },
{ {
# 旧版共用 10.png 是 bugdata/icons/ 里有 11.png 一直未引用,正好给"预测"用
"id": "step9", "id": "step9",
"name": "9. 机器学习预测", "name": "9. 机器学习预测",
"icon": "11.png",
"view_module": "src.new.views.step9_view", "view_module": "src.new.views.step9_view",
"view_class": "Step9View", "view_class": "Step9View",
"service_module": "src.new.services.step9_service", "service_module": "src.new.services.step9_service",
@ -136,14 +157,17 @@ ROUTES = [
{ {
"id": "step10", "id": "step10",
"name": "10. 水色指数反演", "name": "10. 水色指数反演",
"icon": "10.png",
"view_module": "src.new.views.step10_view", "view_module": "src.new.views.step10_view",
"view_class": "Step10View", "view_class": "Step10View",
"service_module": "src.new.services.step10_service", "service_module": "src.new.services.step10_service",
"service_func": "execute_step10", "service_func": "execute_step10",
}, },
{ {
# data/icons/ 没有 12.png/13.pngStep11/12/13 暂时复用 9.png11 个 png 对 13 个 step 必然有共用)
"id": "step11", "id": "step11",
"name": "11. 专题图生成", "name": "11. 专题图生成",
"icon": "9.png",
"view_module": "src.new.views.step11_view", "view_module": "src.new.views.step11_view",
"view_class": "Step11View", "view_class": "Step11View",
"service_module": "src.new.services.step11_service", "service_module": "src.new.services.step11_service",
@ -152,6 +176,7 @@ ROUTES = [
{ {
"id": "step12", "id": "step12",
"name": "12. 可视化", "name": "12. 可视化",
"icon": "9.png",
"view_module": "src.new.views.step12_view", "view_module": "src.new.views.step12_view",
"view_class": "Step12View", "view_class": "Step12View",
"service_module": "src.new.services.step12_service", "service_module": "src.new.services.step12_service",
@ -160,6 +185,7 @@ ROUTES = [
{ {
"id": "step13", "id": "step13",
"name": "13. 报告生成", "name": "13. 报告生成",
"icon": "9.png",
"view_module": "src.new.views.step13_view", "view_module": "src.new.views.step13_view",
"view_class": "Step13View", "view_class": "Step13View",
"service_module": "src.new.services.step13_service", "service_module": "src.new.services.step13_service",
@ -168,6 +194,43 @@ ROUTES = [
] ]
# =========================================================================
# 全局输入参数:用户在任意 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 后台执行器(三信号协议) # TaskWorkerQThread 后台执行器(三信号协议)
# ========================================================================= # =========================================================================
@ -224,11 +287,20 @@ class MainView(QMainWindow):
self._views: dict = {} self._views: dict = {}
self._route_map: dict = {} self._route_map: dict = {}
self.worker: TaskWorker | None = None self.worker: TaskWorker | None = None
# 各 step 的产出路径缓存(声明式路径流转的中枢)
self.step_outputs: dict = {}
# 全局输入参数广播:用户在任何 step 导图后,其他 step 自动同步
self.global_inputs: dict = {}
self.setWindowTitle("WQ_GUI 新架构 · 端到端模块化路由壳") self.setWindowTitle("WQ_GUI 新架构 · 端到端模块化路由壳")
self.resize(1280, 800) self.resize(1280, 800)
self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) 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_ui()
self._build_routes() self._build_routes()
@ -245,8 +317,29 @@ class MainView(QMainWindow):
outer = QVBoxLayout(central) outer = QVBoxLayout(central)
outer.setContentsMargins(8, 8, 8, 8) outer.setContentsMargins(8, 8, 8, 8)
# ----- 顶部工具条:工作目录 + 设置按钮 ----- # ----- 顶部工具条:logo + 项目名 + 工作目录 + 设置按钮 -----
toolbar = QHBoxLayout() 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("工作目录:")) toolbar.addWidget(QLabel("工作目录:"))
self.work_dir_label = QLabel("(未设置)") self.work_dir_label = QLabel("(未设置)")
self.work_dir_label.setStyleSheet("color: #0055D4; font-weight: bold;") self.work_dir_label.setStyleSheet("color: #0055D4; font-weight: bold;")
@ -259,6 +352,27 @@ class MainView(QMainWindow):
outer.addLayout(toolbar) 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 可拖动) ----- # ----- 中部:左导航 + 右 stacked用 QSplitter 可拖动) -----
splitter = QSplitter(Qt.Horizontal) splitter = QSplitter(Qt.Horizontal)
@ -291,7 +405,23 @@ class MainView(QMainWindow):
view = self._instantiate_view(route) view = self._instantiate_view(route)
self._views[route["id"]] = view self._views[route["id"]] = view
self.stacked.addWidget(view) self.stacked.addWidget(view)
self.nav_list.addItem(route["name"])
# 加载图标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: if ROUTES:
self.nav_list.setCurrentRow(0) self.nav_list.setCurrentRow(0)
@ -339,6 +469,10 @@ class MainView(QMainWindow):
self.work_dir_label.setText(self.work_dir) self.work_dir_label.setText(self.work_dir)
self._log(f"[Router] 工作目录已设置: {self.work_dir}") self._log(f"[Router] 工作目录已设置: {self.work_dir}")
# 工作目录变更 → 清空全局输入缓存(旧路径可能已失效)
if self.global_inputs:
self.clear_global_inputs()
# 推送给所有 view让它们按需自动填充路径 # 推送给所有 view让它们按需自动填充路径
for view in self._views.values(): for view in self._views.values():
if hasattr(view, "update_from_config"): if hasattr(view, "update_from_config"):
@ -352,8 +486,10 @@ class MainView(QMainWindow):
1. 查路由表; 1. 查路由表;
2. 注入 ``work_dir``(避免 view 重复感知); 2. 注入 ``work_dir``(避免 view 重复感知);
3. 启动 ``TaskWorker`` 在子线程运行 service 3. **全局输入广播**——把本次 config_dict 中的公共输入 key 累计到
4. 三信号路由log_msg → 日志框finished → 状态分支日志 ``self.global_inputs``,再推送给所有 view实现"一处导入,全局共享"
4. 启动 ``TaskWorker`` 在子线程运行 service
5. 三信号路由log_msg → 日志框finished → 状态分支日志;
error → 未预期异常写日志 + traceback。 error → 未预期异常写日志 + traceback。
""" """
route = self._route_map.get(step_id) route = self._route_map.get(step_id)
@ -365,6 +501,9 @@ class MainView(QMainWindow):
if self.work_dir and "work_dir" not in config_dict: if self.work_dir and "work_dir" not in config_dict:
config_dict = {**config_dict, "work_dir": self.work_dir} 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"]) service_func = self._load_callable(route["service_module"], route["service_func"])
if service_func is None: if service_func is None:
return return
@ -378,8 +517,13 @@ class MainView(QMainWindow):
self.worker = TaskWorker(service_func, config_dict) self.worker = TaskWorker(service_func, config_dict)
self.worker.log_msg.connect(self._log) self.worker.log_msg.connect(self._log)
self.worker.finished.connect(self._on_step_done) # 用 lambda 注入 step_idfinished/error 都拿得到自己服务的是哪一步
self.worker.error.connect(self._on_step_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() self.worker.start()
def _load_callable(self, module_name: str, attr_name: str): def _load_callable(self, module_name: str, attr_name: str):
@ -392,9 +536,13 @@ class MainView(QMainWindow):
traceback.print_exc() traceback.print_exc()
return None return None
def _on_step_done(self, result: dict): def _on_step_done(self, step_id: str, result: dict):
"""TaskWorker finished 回调——按 status 分支写日志 """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' 也走这里; service 内部 try/except 转换的 status='error' 也走这里;
这与 error 信号worker 自身崩溃)语义不同。 这与 error 信号worker 自身崩溃)语义不同。
""" """
@ -403,8 +551,22 @@ class MainView(QMainWindow):
output_path = result.get("output_path") output_path = result.get("output_path")
mode = result.get("mode", "") 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": if status == "completed":
self._log(f"[Service✓] {message}mode={mode}, output={output_path}") 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": elif status == "skipped":
self._log(f"[Service↻] {message}(已复用历史结果)") self._log(f"[Service↻] {message}(已复用历史结果)")
elif status == "not_implemented": elif status == "not_implemented":
@ -414,10 +576,170 @@ class MainView(QMainWindow):
tb = result.get("traceback") tb = result.get("traceback")
if tb: if tb:
self._log(tb) self._log(tb)
QMessageBox.warning(
self,
"执行失败",
f"{message or '当前步骤执行失败'}\n\n请查看底部日志获取 traceback。",
)
def _on_step_error(self, msg: str): def _on_step_error(self, step_id: str, msg: str):
"""TaskWorker error 信号回调——worker 自身捕获的未预期异常""" """TaskWorker error 信号回调——worker 自身捕获的未预期异常
self._log(f"[Worker✗] 未预期异常:\n{msg}")
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 字典,按依赖图把每步产出推给下游
推送关系(按用户原意 + 各 view set_config 实际接受 key 校对):
step1 (水域掩膜) → step2/step3/step4 的 water_mask_path
step3 (耀斑去除) → step4 的 deglint_img_path
+ step6 的 deglint_img_path
+ step10 的 bsq_path
step4 (采样点布设) → step9 的 sampling_csv_path
step5 (数据清洗) → step6 的 csv_path
step6 (光谱特征) → step7/step8 的 training_csv_path
step8 (机器学习建模)→ step9 的 models_dir父目录
step9 (机器学习预测)→ step11 的 prediction_csv_dir父目录
step10 (水色指数) → step11 的 geotiff_dir父目录
每个推送都用 _safe_set_config 包装view 缺失 / 没有 set_config
/ set_config 抛异常都不会让 sync 链路中断。
"""
# ----- step1 → step2/3/4 (water_mask_path) -----
s1 = self.step_outputs.get("step1")
if s1:
for sid in ("step2", "step3", "step4"):
self._safe_set_config(sid, {"water_mask_path": s1})
# ----- step3 → step4 (deglint_img_path) / step6 / 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: 父目录,因为 step8 产物是模型目录里的若干 .joblib) -----
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: 父目录step9 输出到 ml_prediction/) -----
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})
# 同时推 single 模式入口,避免用户在 folder 模式下还要切回
self._safe_set_config("step11", {"prediction_csv_path": s9})
# ----- step10 → step11 (geotiff_dir: 父目录step10 输出到 watercolor/) -----
s10 = self.step_outputs.get("step10")
if s10:
s10_dir = str(Path(s10).parent).replace("\\", "/")
self._safe_set_config("step11", {"geotiff_dir": s10_dir})
# 同时推 single 模式入口
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] 已清空全局输入缓存")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 日志:所有日志统一走这里 # 日志:所有日志统一走这里

View File

@ -38,6 +38,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from src.new.services._output_resolver import get_user_output_path, is_user_specified, resolve_output_dir
def _resolve_waterindex_csv(formula_csv_path: Optional[str], work_dir: str) -> str: def _resolve_waterindex_csv(formula_csv_path: Optional[str], work_dir: str) -> str:
"""解析 waterindex.csv 路径(与 WaterIndexProcessor.__init__ 默认逻辑保持一致)""" """解析 waterindex.csv 路径(与 WaterIndexProcessor.__init__ 默认逻辑保持一致)"""
@ -70,11 +72,19 @@ def _resolve_water_mask_path(water_mask_path: Optional[str], work_dir: str) -> O
return None return None
def _resolve_output_dir(output_dir: Optional[str], work_dir: str) -> Path: def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
"""根据 output_dir / work_dir 计算水色指数反演结果输出目录""" """根据 output_dir / work_dir 计算水色指数反演结果输出目录
if output_dir:
return Path(output_dir) 使用共享解析器强制执行"用户优先"规则——用户指定 output_dir 时直接用其值
return Path(work_dir) / "10_WaterIndex_Images" step10 的 output_dir 本身就是一个目录),否则用 work_dir/10_WaterIndex_Images 默认。
注意step10 与其他步骤不同——output_dir 直接表示目录而非文件路径,
所以使用 Path(user_path) 而非 .parent。
"""
user_path = get_user_output_path(config, "output_dir", "output_path")
if user_path:
return Path(user_path), "user"
return Path(work_dir) / "10_WaterIndex_Images", "default"
def execute_step10(config: Dict[str, Any]) -> Dict[str, Any]: def execute_step10(config: Dict[str, Any]) -> Dict[str, Any]:
@ -97,7 +107,7 @@ def execute_step10(config: Dict[str, Any]) -> Dict[str, Any]:
enabled: bool = bool(config.get("enabled", True)) enabled: bool = bool(config.get("enabled", True))
work_dir: str = config.get("work_dir") or "." work_dir: str = config.get("work_dir") or "."
output_path = _resolve_output_dir(output_dir, work_dir) output_path, _source = _resolve_output_dir(config, work_dir)
mode = "watercolor_inversion" mode = "watercolor_inversion"
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------

View File

@ -51,12 +51,22 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from src.new.services._output_resolver import get_user_output_path, is_user_specified, resolve_output_dir
def _resolve_output_dir(output_dir: Optional[str], work_dir: str) -> Path:
"""根据 output_dir / work_dir 计算专题图输出目录""" def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
if output_dir: """根据 output_dir / work_dir 计算专题图输出目录
return Path(output_dir)
return Path(work_dir) / "11_Thematic_Map" 使用共享解析器强制执行"用户优先"规则——用户指定 output_dir 时直接用其值
step11 的 output_dir 本身就是一个目录),否则用 work_dir/11_Thematic_Map 默认。
注意step11 与其他步骤不同——output_dir 直接表示目录而非文件路径,
所以使用 Path(user_path) 而非 .parent。
"""
user_path = get_user_output_path(config, "output_dir", "output_path")
if user_path:
return Path(user_path), "user"
return Path(work_dir) / "11_Thematic_Map", "default"
def _resolve_csv_paths(config: Dict[str, Any], work_dir: str) -> List[Path]: def _resolve_csv_paths(config: Dict[str, Any], work_dir: str) -> List[Path]:
@ -209,7 +219,7 @@ def execute_step11(config: Dict[str, Any]) -> Dict[str, Any]:
output_dir: str = config.get("output_dir") or "" output_dir: str = config.get("output_dir") or ""
work_dir: str = config.get("work_dir") or "." work_dir: str = config.get("work_dir") or "."
output_path = _resolve_output_dir(output_dir, work_dir) output_path, _source = _resolve_output_dir(config, work_dir)
mode = "make_thematic_map" mode = "make_thematic_map"
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------

View File

@ -48,11 +48,22 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from src.new.services._output_resolver import get_user_output_path, is_user_specified, resolve_output_dir
def _resolve_output_dir(output_dir: str | None, work_dir: str) -> Path:
if output_dir: def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
return Path(output_dir) """根据 output_dir / work_dir 计算可视化输出目录
return Path(work_dir) / "14_visualization"
使用共享解析器强制执行"用户优先"规则——用户指定 output_dir 时直接用其值
step12 的 output_dir 本身就是一个目录),否则用 work_dir/14_visualization 默认。
注意step12 与其他步骤不同——output_dir 直接表示目录而非文件路径,
所以使用 Path(user_path) 而非 .parent。
"""
user_path = get_user_output_path(config, "output_dir", "output_path")
if user_path:
return Path(user_path), "user"
return Path(work_dir) / "14_visualization", "default"
def _resolve_models_dir(work_dir: str, models_dir: str | None) -> str: def _resolve_models_dir(work_dir: str, models_dir: str | None) -> str:
@ -170,7 +181,7 @@ def execute_step12(config: Dict[str, Any]) -> Dict[str, Any]:
gen_glint = bool(config.get("generate_glint_previews", True)) gen_glint = bool(config.get("generate_glint_previews", True))
gen_sampling = bool(config.get("generate_sampling_maps", True)) gen_sampling = bool(config.get("generate_sampling_maps", True))
output_path = _resolve_output_dir(output_dir, work_dir) output_path, _source = _resolve_output_dir(config, work_dir)
mode = "viz_generate" mode = "viz_generate"
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------

View File

@ -53,12 +53,22 @@ import os
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from src.new.services._output_resolver import get_user_output_path, is_user_specified, resolve_output_dir
def _resolve_output_dir(output_dir: Optional[str], work_dir: str) -> Path:
"""根据 output_dir / work_dir 计算 Word 报告输出目录""" def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
if output_dir: """根据 output_dir / work_dir 计算 Word 报告输出目录
return Path(output_dir)
return Path(work_dir) / "14_visualization" 使用共享解析器强制执行"用户优先"规则——用户指定 output_dir 时直接用其值
step13 的 output_dir 本身就是一个目录),否则用 work_dir/14_visualization 默认。
注意step13 与其他步骤不同——output_dir 直接表示目录而非文件路径,
所以使用 Path(user_path) 而非 .parent。
"""
user_path = get_user_output_path(config, "output_dir", "output_path")
if user_path:
return Path(user_path), "user"
return Path(work_dir) / "14_visualization", "default"
def _apply_ai_env(config: Dict[str, Any]) -> None: def _apply_ai_env(config: Dict[str, Any]) -> None:
@ -117,7 +127,7 @@ def execute_step13(config: Dict[str, Any]) -> Dict[str, Any]:
).strip() ).strip()
enabled: bool = bool(config.get("enabled", True)) enabled: bool = bool(config.get("enabled", True))
output_path = _resolve_output_dir(output_dir, work_dir) output_path, _source = _resolve_output_dir(config, work_dir)
mode = "generate_word_report" mode = "generate_word_report"
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------

View File

@ -35,6 +35,7 @@ from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from src.core.steps.water_mask_step import WaterMaskStep from src.core.steps.water_mask_step import WaterMaskStep
from src.new.services._output_resolver import get_user_output_path, is_user_specified
def _resolve_mode(config: Dict[str, Any]) -> str: def _resolve_mode(config: Dict[str, Any]) -> str:

View File

@ -39,13 +39,21 @@ from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from src.core.steps.glint_detection_step import GlintDetectionStep from src.core.steps.glint_detection_step import GlintDetectionStep
from src.new.services._output_resolver import (
copy_to_user_path,
get_user_output_path,
is_user_specified,
resolve_output_dir,
)
def _resolve_glint_dir(output_path: str | None, work_dir: str) -> Path: def _resolve_glint_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
"""根据 output_path / work_dir 计算耀斑检测输出目录""" """根据 output_path / work_dir 计算耀斑检测输出目录
if output_path:
return Path(output_path).parent 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时用其父目录,
return Path(work_dir) / "2_Glint_Detection" 否则用 work_dir/2_Glint_Detection 默认。
"""
return resolve_output_dir(config, work_dir, "2_Glint_Detection", "output_path", "output_dir")
def _clean_int_param(value: Any, default: int = 0) -> int | None: def _clean_int_param(value: Any, default: int = 0) -> int | None:
@ -77,7 +85,7 @@ def execute_step2(config: Dict[str, Any]) -> Dict[str, Any]:
output_path = config.get("output_path") output_path = config.get("output_path")
work_dir = config.get("work_dir") or "." work_dir = config.get("work_dir") or "."
glint_dir = _resolve_glint_dir(output_path, work_dir) glint_dir, _source = _resolve_glint_dir(config, work_dir)
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------
if not enabled: if not enabled:
@ -103,13 +111,14 @@ def execute_step2(config: Dict[str, Any]) -> Dict[str, Any]:
} }
# ---------- 构建底层 kwargs可选字段None 一律不传,让底层走默认) ---------- # ---------- 构建底层 kwargs可选字段None 一律不传,让底层走默认) ----------
# 注意GlintDetectionStep.run 不接受 output_path 关键字——它只接收 glint_dir
# 用户指定的文件名将通过下文的 copy_to_user_path 事后劫持拷贝。
kwargs: Dict[str, Any] = { kwargs: Dict[str, Any] = {
"img_path": img_path, "img_path": img_path,
"glint_wave": glint_wave, "glint_wave": glint_wave,
"method": method, "method": method,
"water_mask_path": water_mask_path, "water_mask_path": water_mask_path,
"glint_dir": glint_dir, "glint_dir": glint_dir,
"output_path": output_path,
"callback": None, # 日志由 main_view 统一接管 "callback": None, # 日志由 main_view 统一接管
} }
@ -162,6 +171,15 @@ def execute_step2(config: Dict[str, Any]) -> Dict[str, Any]:
"mode": method, "mode": method,
} }
# ---------- 事后劫持:用户指定文件名 vs 底层硬编码文件名 ----------
# 旧版 GlintDetectionStep.run 只接受 glint_dir 不接受确切文件名,
# 所以用户浏览时输入的 output_path 在算法内部被忽略;这里事后把
# 硬编码的 result_path 拷贝/重命名到 user_path。
user_path = config.get("output_path")
if user_path:
result_path = copy_to_user_path(result_path, user_path)
p = Path(result_path) # 同步刷新 p 给最后的 return 用
return { return {
"status": "completed", "status": "completed",
"output_path": str(p).replace("\\", "/"), "output_path": str(p).replace("\\", "/"),

View File

@ -43,14 +43,23 @@ from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from src.core.steps.glint_removal_step import GlintRemovalStep from src.core.steps.glint_removal_step import GlintRemovalStep
from src.new.services._output_resolver import (
copy_to_user_path,
get_user_output_path,
is_user_specified,
resolve_output_dir,
)
def _resolve_dirs(output_path: str | None, work_dir: str) -> tuple[Path, Path]: def _resolve_dirs(config: Dict[str, Any], work_dir: str) -> tuple[Path, Path]:
"""根据 output_path / work_dir 计算 (deglint_dir, water_mask_dir)""" """根据 output_path / work_dir 计算 (deglint_dir, water_mask_dir)
if output_path:
deglint_dir = Path(output_path).parent 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时用其父目录,
else: 否则用 work_dir/3_Deglint 默认。
deglint_dir = Path(work_dir) / "3_Deglint" """
deglint_dir, _source = resolve_output_dir(
config, work_dir, "3_Deglint", "output_path", "output_dir"
)
water_mask_dir = Path(work_dir) / "1_water_mask" water_mask_dir = Path(work_dir) / "1_water_mask"
return deglint_dir, water_mask_dir return deglint_dir, water_mask_dir
@ -124,7 +133,7 @@ def execute_step3(config: Dict[str, Any]) -> Dict[str, Any]:
output_path = config.get("output_path") output_path = config.get("output_path")
work_dir = config.get("work_dir") or "." work_dir = config.get("work_dir") or "."
deglint_dir, water_mask_dir = _resolve_dirs(output_path, work_dir) deglint_dir, water_mask_dir = _resolve_dirs(config, work_dir)
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------
if not enabled: if not enabled:
@ -150,6 +159,8 @@ def execute_step3(config: Dict[str, Any]) -> Dict[str, Any]:
} }
# ---------- 构建底层 kwargs ---------- # ---------- 构建底层 kwargs ----------
# 注意GlintRemovalStep.run 不接受 output_path 关键字——它只接收 deglint_dir
# 用户指定的文件名将通过下文的 copy_to_user_path 事后劫持拷贝。
method_kwargs = _build_method_kwargs(method, config) method_kwargs = _build_method_kwargs(method, config)
kwargs: Dict[str, Any] = { kwargs: Dict[str, Any] = {
"img_path": img_path, "img_path": img_path,
@ -159,7 +170,6 @@ def execute_step3(config: Dict[str, Any]) -> Dict[str, Any]:
"interpolation_method": interpolation_method, "interpolation_method": interpolation_method,
"deglint_dir": deglint_dir, "deglint_dir": deglint_dir,
"water_mask_dir": water_mask_dir, "water_mask_dir": water_mask_dir,
"output_path": output_path,
"callback": None, # 日志由 main_view 统一接管 "callback": None, # 日志由 main_view 统一接管
} }
kwargs.update(method_kwargs) kwargs.update(method_kwargs)
@ -199,6 +209,16 @@ def execute_step3(config: Dict[str, Any]) -> Dict[str, Any]:
"mode": method, "mode": method,
} }
# ---------- 事后劫持:用户指定文件名 vs 底层硬编码文件名 ----------
# 旧版 GlintRemovalStep.run 只接受 deglint_dir 不接受确切文件名;
# 用户浏览指定的 .bsq 文件名被底层忽略(同时 .hdr 头文件也按硬编码名生成)。
# 这里事后把 result_path 拷贝/重命名到 user_pathcopy_to_user_path
# 会自动处理 .hdr / .HDR 伴随文件。
user_path = config.get("output_path")
if user_path:
result_path = copy_to_user_path(result_path, user_path)
p = Path(result_path)
return { return {
"status": "completed", "status": "completed",
"output_path": str(p).replace("\\", "/"), "output_path": str(p).replace("\\", "/"),

View File

@ -38,13 +38,21 @@ from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from src.core.steps.prediction_step import PredictionStep from src.core.steps.prediction_step import PredictionStep
from src.new.services._output_resolver import (
copy_to_user_path,
get_user_output_path,
is_user_specified,
resolve_output_dir,
)
def _resolve_output_dir(output_path: str | None, work_dir: str) -> Path: def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
"""根据 output_path / work_dir 计算采样点输出目录""" """根据 output_path / work_dir 计算采样点输出目录
if output_path:
return Path(output_path).parent 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时用其父目录,
return Path(work_dir) / "4_Sampling" 否则用 work_dir/4_Sampling 默认。
"""
return resolve_output_dir(config, work_dir, "4_Sampling", "output_path", "output_dir")
def execute_step4(config: Dict[str, Any]) -> Dict[str, Any]: def execute_step4(config: Dict[str, Any]) -> Dict[str, Any]:
@ -67,7 +75,7 @@ def execute_step4(config: Dict[str, Any]) -> Dict[str, Any]:
output_path = config.get("output_path") output_path = config.get("output_path")
work_dir = config.get("work_dir") or "." work_dir = config.get("work_dir") or "."
output_dir = _resolve_output_dir(output_path, work_dir) output_dir, _source = _resolve_output_dir(config, work_dir)
mode = "adaptive" if use_adaptive_sampling else "fixed" mode = "adaptive" if use_adaptive_sampling else "fixed"
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------
@ -138,6 +146,14 @@ def execute_step4(config: Dict[str, Any]) -> Dict[str, Any]:
"mode": mode, "mode": mode,
} }
# ---------- 事后劫持:用户指定文件名 vs 底层硬编码文件名 ----------
# PredictionStep.generate_sampling_points 内部按 sampling_spectra.csv 硬编码;
# 用户浏览指定的文件名在算法内部被忽略。
user_path = config.get("output_path")
if user_path:
result_path = copy_to_user_path(result_path, user_path)
p = Path(result_path)
return { return {
"status": "completed", "status": "completed",
"output_path": str(p).replace("\\", "/"), "output_path": str(p).replace("\\", "/"),

View File

@ -33,13 +33,21 @@ from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from src.core.steps.data_preparation_step import DataPreparationStep from src.core.steps.data_preparation_step import DataPreparationStep
from src.new.services._output_resolver import (
copy_to_user_path,
get_user_output_path,
is_user_specified,
resolve_output_dir,
)
def _resolve_output_dir(output_path: str | None, work_dir: str) -> Path: def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
"""根据 output_path / work_dir 计算清洗后 CSV 输出目录""" """根据 output_path / work_dir 计算清洗后 CSV 输出目录
if output_path:
return Path(output_path).parent 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时用其父目录,
return Path(work_dir) / "5_Data_Cleaning" 否则用 work_dir/5_Data_Cleaning 默认。
"""
return resolve_output_dir(config, work_dir, "5_Data_Cleaning", "output_path", "output_dir")
def execute_step5(config: Dict[str, Any]) -> Dict[str, Any]: def execute_step5(config: Dict[str, Any]) -> Dict[str, Any]:
@ -57,7 +65,7 @@ def execute_step5(config: Dict[str, Any]) -> Dict[str, Any]:
output_path = config.get("output_path") output_path = config.get("output_path")
work_dir = config.get("work_dir") or "." work_dir = config.get("work_dir") or "."
output_dir = _resolve_output_dir(output_path, work_dir) output_dir, _source = _resolve_output_dir(config, work_dir)
mode = "csv_clean" mode = "csv_clean"
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------
@ -128,6 +136,14 @@ def execute_step5(config: Dict[str, Any]) -> Dict[str, Any]:
"mode": mode, "mode": mode,
} }
# ---------- 事后劫持:用户指定文件名 vs 底层硬编码文件名 ----------
# process_csv 内部硬编码输出文件名 processed_data.csv
# 用户浏览指定的文件名(无论是 .csv 还是别的格式)被底层忽略。
user_path = config.get("output_path")
if user_path:
result_path = copy_to_user_path(result_path, user_path)
p = Path(result_path)
return { return {
"status": "completed", "status": "completed",
"output_path": str(p).replace("\\", "/"), "output_path": str(p).replace("\\", "/"),

View File

@ -39,13 +39,23 @@ from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from src.core.steps.data_preparation_step import DataPreparationStep from src.core.steps.data_preparation_step import DataPreparationStep
from src.new.services._output_resolver import (
copy_to_user_path,
get_user_output_path,
is_user_specified,
resolve_output_dir,
)
def _resolve_output_dir(output_path: str | None, work_dir: str) -> Path: def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
"""根据 output_path / work_dir 计算 training_spectra.csv 输出目录""" """根据 output_path / work_dir 计算 training_spectra.csv 输出目录
if output_path:
return Path(output_path).parent 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时用其父目录,
return Path(work_dir) / "6_Spectral_Feature_Extraction" 否则用 work_dir/6_Spectral_Feature_Extraction 默认。
"""
return resolve_output_dir(
config, work_dir, "6_Spectral_Feature_Extraction", "output_path", "output_dir"
)
def execute_step6(config: Dict[str, Any]) -> Dict[str, Any]: def execute_step6(config: Dict[str, Any]) -> Dict[str, Any]:
@ -69,7 +79,7 @@ def execute_step6(config: Dict[str, Any]) -> Dict[str, Any]:
output_path = config.get("output_path") output_path = config.get("output_path")
work_dir = config.get("work_dir") or "." work_dir = config.get("work_dir") or "."
output_dir = _resolve_output_dir(output_path, work_dir) output_dir, _source = _resolve_output_dir(config, work_dir)
mode = "extract_spectra" mode = "extract_spectra"
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------
@ -163,6 +173,14 @@ def execute_step6(config: Dict[str, Any]) -> Dict[str, Any]:
"mode": mode, "mode": mode,
} }
# ---------- 事后劫持:用户指定文件名 vs 底层硬编码文件名 ----------
# extract_training_spectra 内部硬编码输出文件名 training_spectra.csv
# 用户浏览指定的文件名在算法内部被忽略。
user_path = config.get("output_path")
if user_path:
result_path = copy_to_user_path(result_path, user_path)
p = Path(result_path)
return { return {
"status": "completed", "status": "completed",
"output_path": str(p).replace("\\", "/"), "output_path": str(p).replace("\\", "/"),

View File

@ -35,13 +35,19 @@ from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from src.core.steps.data_preparation_step import DataPreparationStep from src.core.steps.data_preparation_step import DataPreparationStep
from src.new.services._output_resolver import get_user_output_path, is_user_specified, resolve_output_dir
def _resolve_output_dir(output_file: str | None, work_dir: str) -> Path: def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
"""根据 output_file / work_dir 计算 indices CSV 输出目录""" """根据 output_file / output_path / work_dir 计算 indices CSV 输出目录
if output_file:
return Path(output_file).parent 使用共享解析器强制执行"用户优先"规则——step7 旧 panel 字段是 output_file
return Path(work_dir) / "7_Water_Quality_Indices" 新架构保留 output_path 兼容性,按优先级回退。
"""
return resolve_output_dir(
config, work_dir, "7_Water_Quality_Indices",
"output_file", "output_path", "output_dir",
)
def execute_step7(config: Dict[str, Any]) -> Dict[str, Any]: def execute_step7(config: Dict[str, Any]) -> Dict[str, Any]:
@ -61,7 +67,7 @@ def execute_step7(config: Dict[str, Any]) -> Dict[str, Any]:
output_file: Optional[str] = config.get("output_file") output_file: Optional[str] = config.get("output_file")
work_dir: str = config.get("work_dir") or "." work_dir: str = config.get("work_dir") or "."
output_dir = _resolve_output_dir(output_file, work_dir) output_dir, _source = _resolve_output_dir(config, work_dir)
mode = "calc_indices" mode = "calc_indices"
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------

View File

@ -38,13 +38,23 @@ from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from src.core.steps.modeling_step import ModelingStep from src.core.steps.modeling_step import ModelingStep
from src.new.services._output_resolver import get_user_output_path, is_user_specified, resolve_output_dir
def _resolve_output_dir(output_path: str | None, work_dir: str) -> Path: def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
"""根据 output_path / work_dir 计算模型保存目录""" """根据 output_path / work_dir 计算模型保存目录
if output_path:
return Path(output_path) 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时直接用其值
return Path(work_dir) / "8_Supervised_Model_Training" (因为 step8 的 output_path 是一个目录,不是文件),
否则用 work_dir/8_Supervised_Model_Training 默认。
注意step8 与其他步骤不同——output_path 直接表示目录而非文件路径,
所以使用 Path(user_path) 而非 .parent。
"""
user_path = get_user_output_path(config, "output_path", "output_dir")
if user_path:
return Path(user_path), "user"
return Path(work_dir) / "8_Supervised_Model_Training", "default"
def execute_step8(config: Dict[str, Any]) -> Dict[str, Any]: def execute_step8(config: Dict[str, Any]) -> Dict[str, Any]:
@ -67,7 +77,7 @@ def execute_step8(config: Dict[str, Any]) -> Dict[str, Any]:
output_path: Optional[str] = config.get("output_path") output_path: Optional[str] = config.get("output_path")
work_dir: str = config.get("work_dir") or "." work_dir: str = config.get("work_dir") or "."
output_dir = _resolve_output_dir(output_path, work_dir) output_dir, _source = _resolve_output_dir(config, work_dir)
mode = "train_ml" mode = "train_ml"
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------

View File

@ -41,14 +41,22 @@ from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from src.core.steps.prediction_step import PredictionStep from src.core.steps.prediction_step import PredictionStep
from src.new.services._output_resolver import get_user_output_path, is_user_specified, resolve_output_dir
def _resolve_output_dir(output_path: str | None, work_dir: str) -> Path: def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]:
"""根据 output_path / work_dir 计算预测结果输出目录""" """根据 output_path / work_dir 计算预测结果输出目录
if output_path:
return Path(output_path) 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时直接用其值
# 与旧 pipeline 一致prediction_dir / 9_ML_Prediction step9 的 output_path 本身就是一个目录,非文件),否则用 work_dir 默认路径。
return Path(work_dir) / "prediction" / "9_ML_Prediction"
注意step9 与其他步骤不同——output_path 直接表示目录而非文件路径,
所以使用 Path(user_path) 而非 .parent。
"""
user_path = get_user_output_path(config, "output_path", "output_dir")
if user_path:
return Path(user_path), "user"
return Path(work_dir) / "prediction" / "9_ML_Prediction", "default"
def execute_step9(config: Dict[str, Any]) -> Dict[str, Any]: def execute_step9(config: Dict[str, Any]) -> Dict[str, Any]:
@ -69,7 +77,7 @@ def execute_step9(config: Dict[str, Any]) -> Dict[str, Any]:
output_path: str = config.get("output_path") output_path: str = config.get("output_path")
work_dir: str = config.get("work_dir") or "." work_dir: str = config.get("work_dir") or "."
output_dir = _resolve_output_dir(output_path, work_dir) output_dir, _source = _resolve_output_dir(config, work_dir)
mode = "predict_ml" mode = "predict_ml"
# ---------- 提前失败检查 ---------- # ---------- 提前失败检查 ----------

View File

@ -1,19 +1,18 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Step12View —— Step 12可视化的端到端模块化 view(精简版) Step12View —— Step 12可视化的端到端模块化 view
UI 从 ``src/gui/panels/step12_viz_panel.py``1882 行巨型)做大幅精简。 UI 在精简版基础上挂回旧版的 ``ImageCategoryTree`` 和 ``ImageViewerWidget``
两个高级组件:
精简原则 - **ImageCategoryTree** —— 按"模型评估/光谱分析/统计图表/处理结果/
======== 含量分布图"五类自动归类工作目录下的图像文件,用户点击文件即可预览。
- **ImageViewerWidget** —— 支持滚轮缩放0.1x-5x+ 50ms 防抖 +
FastTransformation/SmoothTransformation 智能切换 + Ctrl+Wheel +
鼠标工具栏(缩放/适应窗口/1:1/保存)。
- **删除全部 matplotlib / ImageViewerWidget / ChartViewerDialog / 所有渲染**纯本地化**,不触发任何 serviceservice 端只负责生成图像
VisualizationWorkerThread / PandasTableModel / ChartBrowserDialog** 等子控件; 到工作目录view 端负责即时浏览。
这些是「运行时画图」所需的复杂组件,由 service + 独立图片查看器窗口接管。
- **保留可视化配置 checkbox 组**5 个:散点/光谱/箱线/掩膜缩略/采样地图)——
这是用户实际可控的业务选项。
- **保留工作目录 / 图像目录选择 + 扫描 / 生成全部按钮**——给用户「
「一键触发可视化」的入口;具体生成逻辑由 service 接管。
""" """
import os import os
@ -25,6 +24,7 @@ from PyQt5.QtWidgets import (
QPushButton, QVBoxLayout, QWidget, QPushButton, QVBoxLayout, QWidget,
) )
from src.gui.components.image_widgets import ImageCategoryTree, ImageViewerWidget
from src.gui.styles import ModernStylesheet from src.gui.styles import ModernStylesheet
from src.new.core.base_view import BaseView from src.new.core.base_view import BaseView
@ -34,7 +34,7 @@ def _resolve_subdir(work_dir: str, subdir_name: str) -> str:
class Step12View(BaseView): class Step12View(BaseView):
"""Step 12: 可视化(精简版)""" """Step 12: 可视化"""
def init_ui(self): def init_ui(self):
main_layout = QHBoxLayout() main_layout = QHBoxLayout()
@ -110,13 +110,16 @@ class Step12View(BaseView):
self.gen_all_btn = QPushButton("🚀 生成全部") self.gen_all_btn = QPushButton("🚀 生成全部")
self.gen_all_btn.setToolTip("生成所有类型的可视化图表") self.gen_all_btn.setToolTip("生成所有类型的可视化图表")
self.gen_all_btn.setStyleSheet( self.gen_all_btn.setStyleSheet(
"background-color: #4CAF50; color: white; font-weight: bold;" ModernStylesheet.get_button_stylesheet("success")
) )
self.gen_all_btn.clicked.connect(self._on_run_clicked) self.gen_all_btn.clicked.connect(self._on_run_clicked)
config_layout.addWidget(self.gen_all_btn) config_layout.addWidget(self.gen_all_btn)
self.scan_btn = QPushButton("📁 扫描目录") self.scan_btn = QPushButton("📁 扫描目录")
self.scan_btn.setToolTip("扫描工作目录中的图像文件") self.scan_btn.setToolTip("扫描工作目录中的图像文件,加载到下方分类树")
self.scan_btn.setStyleSheet(
ModernStylesheet.get_button_stylesheet("primary")
)
self.scan_btn.clicked.connect(self._on_scan_clicked) self.scan_btn.clicked.connect(self._on_scan_clicked)
config_layout.addWidget(self.scan_btn) config_layout.addWidget(self.scan_btn)
@ -127,20 +130,22 @@ class Step12View(BaseView):
left_panel.setMaximumWidth(350) left_panel.setMaximumWidth(350)
main_layout.addWidget(left_panel, 0) main_layout.addWidget(left_panel, 0)
# ===== 右侧占位 ===== # ===== 右侧:分类树 + 图片查看器(旧版高级家具) =====
right_panel = QWidget() right_panel = QWidget()
right_layout = QVBoxLayout() right_layout = QVBoxLayout()
right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setContentsMargins(0, 0, 0, 0)
placeholder = QLabel( right_layout.setSpacing(6)
"可视化结果由 service 端生成,\n"
"生成完成后可在主窗口日志区查看文件路径。\n" # 顶部:图像分类树(按类别组织:模型评估/光谱分析/统计图表/处理结果/含量分布图)
"(精简版 view 不内嵌 matplotlib 画布。)" self.image_tree = ImageCategoryTree()
) self.image_tree.setMaximumHeight(280)
placeholder.setAlignment(Qt.AlignCenter | Qt.AlignVCenter) self.image_tree.currentItemChanged.connect(self._on_tree_selection)
placeholder.setStyleSheet( right_layout.addWidget(self.image_tree)
"color: #999; font-size: 13px; padding: 40px;"
) # 底部图片查看器支持滚轮缩放、工具栏、1:1/适应窗口/保存)
right_layout.addWidget(placeholder, 1) self.image_viewer = ImageViewerWidget()
right_layout.addWidget(self.image_viewer, 1)
right_panel.setLayout(right_layout) right_panel.setLayout(right_layout)
main_layout.addWidget(right_panel, 1) main_layout.addWidget(right_panel, 1)
@ -225,7 +230,17 @@ class Step12View(BaseView):
self.dispatch_execute("step12", self.get_config()) self.dispatch_execute("step12", self.get_config())
def _on_scan_clicked(self): def _on_scan_clicked(self):
# 扫描仅刷新工作目录和图像目录的显示;不实际派发 service """扫描工作目录下的图像文件并加载到分类树(不派发 service"""
work_dir = self.work_dir_edit.text() work_dir = self.work_dir_edit.text()
if work_dir and os.path.isdir(work_dir): if not work_dir or not os.path.isdir(work_dir):
return
self.image_tree.scan_directory(work_dir)
self.img_dir_edit.setText(work_dir) self.img_dir_edit.setText(work_dir)
def _on_tree_selection(self, current, _previous):
"""分类树选中项 → 图片查看器即时预览"""
if current is None:
return
path = self.image_tree.get_selected_image_path()
if path and os.path.isfile(path):
self.image_viewer.load_image(path)