diff --git a/src/new/main_view.py b/src/new/main_view.py index 7b77171..b09d11d 100644 --- a/src/new/main_view.py +++ b/src/new/main_view.py @@ -35,23 +35,34 @@ MainView —— 端到端模块化新架构的路由与调度壳(主窗口) 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, QMainWindow, - QPushButton, QSplitter, QStackedWidget, QTextEdit, QVBoxLayout, - QWidget, + 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 @@ -63,6 +74,7 @@ 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", @@ -72,6 +84,7 @@ ROUTES = [ { "id": "step2", "name": "2. 耀斑检测", + "icon": "2.png", "view_module": "src.new.views.step2_view", "view_class": "Step2View", "service_module": "src.new.services.step2_service", @@ -80,6 +93,7 @@ ROUTES = [ { "id": "step3", "name": "3. 耀斑去除", + "icon": "3.png", "view_module": "src.new.views.step3_view", "view_class": "Step3View", "service_module": "src.new.services.step3_service", @@ -88,6 +102,7 @@ ROUTES = [ { "id": "step4", "name": "4. 采样点布设", + "icon": "4.png", "view_module": "src.new.views.step4_view", "view_class": "Step4View", "service_module": "src.new.services.step4_service", @@ -96,6 +111,7 @@ ROUTES = [ { "id": "step5", "name": "5. 数据清洗", + "icon": "5.png", "view_module": "src.new.views.step5_view", "view_class": "Step5View", "service_module": "src.new.services.step5_service", @@ -104,6 +120,7 @@ ROUTES = [ { "id": "step6", "name": "6. 光谱特征", + "icon": "6.png", "view_module": "src.new.views.step6_view", "view_class": "Step6View", "service_module": "src.new.services.step6_service", @@ -112,6 +129,7 @@ ROUTES = [ { "id": "step7", "name": "7. 水质光谱指数", + "icon": "7.png", "view_module": "src.new.views.step7_view", "view_class": "Step7View", "service_module": "src.new.services.step7_service", @@ -120,14 +138,17 @@ ROUTES = [ { "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 是 bug;data/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", @@ -136,14 +157,17 @@ ROUTES = [ { "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.png;Step11/12/13 暂时复用 9.png(11 个 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", @@ -152,6 +176,7 @@ ROUTES = [ { "id": "step12", "name": "12. 可视化", + "icon": "9.png", "view_module": "src.new.views.step12_view", "view_class": "Step12View", "service_module": "src.new.services.step12_service", @@ -160,6 +185,7 @@ ROUTES = [ { "id": "step13", "name": "13. 报告生成", + "icon": "9.png", "view_module": "src.new.views.step13_view", "view_class": "Step13View", "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", +) + + # ========================================================================= # TaskWorker:QThread 后台执行器(三信号协议) # ========================================================================= @@ -224,11 +287,20 @@ class MainView(QMainWindow): 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() @@ -245,8 +317,29 @@ class MainView(QMainWindow): 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;") @@ -259,6 +352,27 @@ class MainView(QMainWindow): outer.addLayout(toolbar) + # ----- 顶部 MegaCube Banner(CSS 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) @@ -291,7 +405,23 @@ class MainView(QMainWindow): view = self._instantiate_view(route) self._views[route["id"]] = 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: self.nav_list.setCurrentRow(0) @@ -339,6 +469,10 @@ class MainView(QMainWindow): 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"): @@ -352,8 +486,10 @@ class MainView(QMainWindow): 1. 查路由表; 2. 注入 ``work_dir``(避免 view 重复感知); - 3. 启动 ``TaskWorker`` 在子线程运行 service; - 4. 三信号路由:log_msg → 日志框;finished → 状态分支日志; + 3. **全局输入广播**——把本次 config_dict 中的公共输入 key 累计到 + ``self.global_inputs``,再推送给所有 view(实现"一处导入,全局共享"); + 4. 启动 ``TaskWorker`` 在子线程运行 service; + 5. 三信号路由:log_msg → 日志框;finished → 状态分支日志; error → 未预期异常写日志 + traceback。 """ 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: 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 @@ -378,8 +517,13 @@ class MainView(QMainWindow): self.worker = TaskWorker(service_func, config_dict) self.worker.log_msg.connect(self._log) - self.worker.finished.connect(self._on_step_done) - self.worker.error.connect(self._on_step_error) + # 用 lambda 注入 step_id:finished/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): @@ -392,9 +536,13 @@ class MainView(QMainWindow): traceback.print_exc() return None - def _on_step_done(self, result: dict): + 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.information;status=error → warning。 + 注:service 内部 try/except 转换的 status='error' 也走这里; 这与 error 信号(worker 自身崩溃)语义不同。 """ @@ -403,8 +551,22 @@ class MainView(QMainWindow): 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": @@ -414,10 +576,170 @@ class MainView(QMainWindow): tb = result.get("traceback") if tb: self._log(tb) + QMessageBox.warning( + self, + "执行失败", + f"{message or '当前步骤执行失败'}\n\n请查看底部日志获取 traceback。", + ) - def _on_step_error(self, msg: str): - """TaskWorker error 信号回调——worker 自身捕获的未预期异常""" - self._log(f"[Worker✗] 未预期异常:\n{msg}") + 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 字典,按依赖图把每步产出推给下游 + + 推送关系(按用户原意 + 各 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_config;view 缺失 / 无该方法 / 抛异常时降级日志 + + 返回 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] 已清空全局输入缓存") # ------------------------------------------------------------------ # 日志:所有日志统一走这里 diff --git a/src/new/services/step10_service.py b/src/new/services/step10_service.py index 657bda7..fc99012 100644 --- a/src/new/services/step10_service.py +++ b/src/new/services/step10_service.py @@ -38,6 +38,8 @@ from __future__ import annotations from pathlib import Path 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: """解析 waterindex.csv 路径(与 WaterIndexProcessor.__init__ 默认逻辑保持一致)""" @@ -70,11 +72,19 @@ def _resolve_water_mask_path(water_mask_path: Optional[str], work_dir: str) -> O return None -def _resolve_output_dir(output_dir: Optional[str], work_dir: str) -> Path: - """根据 output_dir / work_dir 计算水色指数反演结果输出目录""" - if output_dir: - return Path(output_dir) - return Path(work_dir) / "10_WaterIndex_Images" +def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]: + """根据 output_dir / work_dir 计算水色指数反演结果输出目录 + + 使用共享解析器强制执行"用户优先"规则——用户指定 output_dir 时直接用其值 + (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]: @@ -97,7 +107,7 @@ def execute_step10(config: Dict[str, Any]) -> Dict[str, Any]: enabled: bool = bool(config.get("enabled", True)) 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" # ---------- 提前失败检查 ---------- diff --git a/src/new/services/step11_service.py b/src/new/services/step11_service.py index 4ef1cbd..1c8af7b 100644 --- a/src/new/services/step11_service.py +++ b/src/new/services/step11_service.py @@ -51,12 +51,22 @@ from __future__ import annotations from pathlib import Path 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 计算专题图输出目录""" - if output_dir: - return Path(output_dir) - return Path(work_dir) / "11_Thematic_Map" + +def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]: + """根据 output_dir / work_dir 计算专题图输出目录 + + 使用共享解析器强制执行"用户优先"规则——用户指定 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]: @@ -209,7 +219,7 @@ def execute_step11(config: Dict[str, Any]) -> Dict[str, Any]: output_dir: str = config.get("output_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" # ---------- 提前失败检查 ---------- diff --git a/src/new/services/step12_service.py b/src/new/services/step12_service.py index 484843b..fb7f188 100644 --- a/src/new/services/step12_service.py +++ b/src/new/services/step12_service.py @@ -48,11 +48,22 @@ from __future__ import annotations from pathlib import Path 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: - return Path(output_dir) - return Path(work_dir) / "14_visualization" + +def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]: + """根据 output_dir / work_dir 计算可视化输出目录 + + 使用共享解析器强制执行"用户优先"规则——用户指定 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: @@ -170,7 +181,7 @@ def execute_step12(config: Dict[str, Any]) -> Dict[str, Any]: gen_glint = bool(config.get("generate_glint_previews", 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" # ---------- 提前失败检查 ---------- diff --git a/src/new/services/step13_service.py b/src/new/services/step13_service.py index e4d6252..40c17f3 100644 --- a/src/new/services/step13_service.py +++ b/src/new/services/step13_service.py @@ -53,12 +53,22 @@ import os from pathlib import Path 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 报告输出目录""" - if output_dir: - return Path(output_dir) - return Path(work_dir) / "14_visualization" + +def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]: + """根据 output_dir / work_dir 计算 Word 报告输出目录 + + 使用共享解析器强制执行"用户优先"规则——用户指定 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: @@ -117,7 +127,7 @@ def execute_step13(config: Dict[str, Any]) -> Dict[str, Any]: ).strip() 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" # ---------- 提前失败检查 ---------- diff --git a/src/new/services/step1_service.py b/src/new/services/step1_service.py index ec1495b..b5cdf33 100644 --- a/src/new/services/step1_service.py +++ b/src/new/services/step1_service.py @@ -35,6 +35,7 @@ from pathlib import Path from typing import Any, Dict 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: diff --git a/src/new/services/step2_service.py b/src/new/services/step2_service.py index 5c98f47..9d1df34 100644 --- a/src/new/services/step2_service.py +++ b/src/new/services/step2_service.py @@ -39,13 +39,21 @@ from pathlib import Path from typing import Any, Dict 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: - """根据 output_path / work_dir 计算耀斑检测输出目录""" - if output_path: - return Path(output_path).parent - return Path(work_dir) / "2_Glint_Detection" +def _resolve_glint_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]: + """根据 output_path / work_dir 计算耀斑检测输出目录 + + 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时用其父目录, + 否则用 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: @@ -77,7 +85,7 @@ def execute_step2(config: Dict[str, Any]) -> Dict[str, Any]: output_path = config.get("output_path") 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: @@ -103,13 +111,14 @@ def execute_step2(config: Dict[str, Any]) -> Dict[str, Any]: } # ---------- 构建底层 kwargs(可选字段:None 一律不传,让底层走默认) ---------- + # 注意:GlintDetectionStep.run 不接受 output_path 关键字——它只接收 glint_dir; + # 用户指定的文件名将通过下文的 copy_to_user_path 事后劫持拷贝。 kwargs: Dict[str, Any] = { "img_path": img_path, "glint_wave": glint_wave, "method": method, "water_mask_path": water_mask_path, "glint_dir": glint_dir, - "output_path": output_path, "callback": None, # 日志由 main_view 统一接管 } @@ -162,6 +171,15 @@ def execute_step2(config: Dict[str, Any]) -> Dict[str, Any]: "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 { "status": "completed", "output_path": str(p).replace("\\", "/"), diff --git a/src/new/services/step3_service.py b/src/new/services/step3_service.py index 9e21a1d..729038b 100644 --- a/src/new/services/step3_service.py +++ b/src/new/services/step3_service.py @@ -43,14 +43,23 @@ from pathlib import Path from typing import Any, Dict 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]: - """根据 output_path / work_dir 计算 (deglint_dir, water_mask_dir)""" - if output_path: - deglint_dir = Path(output_path).parent - else: - deglint_dir = Path(work_dir) / "3_Deglint" +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/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" 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") 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: @@ -150,6 +159,8 @@ def execute_step3(config: Dict[str, Any]) -> Dict[str, Any]: } # ---------- 构建底层 kwargs ---------- + # 注意:GlintRemovalStep.run 不接受 output_path 关键字——它只接收 deglint_dir; + # 用户指定的文件名将通过下文的 copy_to_user_path 事后劫持拷贝。 method_kwargs = _build_method_kwargs(method, config) kwargs: Dict[str, Any] = { "img_path": img_path, @@ -159,7 +170,6 @@ def execute_step3(config: Dict[str, Any]) -> Dict[str, Any]: "interpolation_method": interpolation_method, "deglint_dir": deglint_dir, "water_mask_dir": water_mask_dir, - "output_path": output_path, "callback": None, # 日志由 main_view 统一接管 } kwargs.update(method_kwargs) @@ -199,6 +209,16 @@ def execute_step3(config: Dict[str, Any]) -> Dict[str, Any]: "mode": method, } + # ---------- 事后劫持:用户指定文件名 vs 底层硬编码文件名 ---------- + # 旧版 GlintRemovalStep.run 只接受 deglint_dir 不接受确切文件名; + # 用户浏览指定的 .bsq 文件名被底层忽略(同时 .hdr 头文件也按硬编码名生成)。 + # 这里事后把 result_path 拷贝/重命名到 user_path,copy_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 { "status": "completed", "output_path": str(p).replace("\\", "/"), diff --git a/src/new/services/step4_service.py b/src/new/services/step4_service.py index 078613e..0bfb4f2 100644 --- a/src/new/services/step4_service.py +++ b/src/new/services/step4_service.py @@ -38,13 +38,21 @@ from pathlib import Path from typing import Any, Dict 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: - """根据 output_path / work_dir 计算采样点输出目录""" - if output_path: - return Path(output_path).parent - return Path(work_dir) / "4_Sampling" +def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]: + """根据 output_path / work_dir 计算采样点输出目录 + + 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时用其父目录, + 否则用 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]: @@ -67,7 +75,7 @@ def execute_step4(config: Dict[str, Any]) -> Dict[str, Any]: output_path = config.get("output_path") 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" # ---------- 提前失败检查 ---------- @@ -138,6 +146,14 @@ def execute_step4(config: Dict[str, Any]) -> Dict[str, Any]: "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 { "status": "completed", "output_path": str(p).replace("\\", "/"), diff --git a/src/new/services/step5_service.py b/src/new/services/step5_service.py index 57fc086..0bedb4e 100644 --- a/src/new/services/step5_service.py +++ b/src/new/services/step5_service.py @@ -33,13 +33,21 @@ from pathlib import Path from typing import Any, Dict 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: - """根据 output_path / work_dir 计算清洗后 CSV 输出目录""" - if output_path: - return Path(output_path).parent - return Path(work_dir) / "5_Data_Cleaning" +def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]: + """根据 output_path / work_dir 计算清洗后 CSV 输出目录 + + 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时用其父目录, + 否则用 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]: @@ -57,7 +65,7 @@ def execute_step5(config: Dict[str, Any]) -> Dict[str, Any]: output_path = config.get("output_path") 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" # ---------- 提前失败检查 ---------- @@ -128,6 +136,14 @@ def execute_step5(config: Dict[str, Any]) -> Dict[str, Any]: "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 { "status": "completed", "output_path": str(p).replace("\\", "/"), diff --git a/src/new/services/step6_service.py b/src/new/services/step6_service.py index 12e6064..22f08ad 100644 --- a/src/new/services/step6_service.py +++ b/src/new/services/step6_service.py @@ -39,13 +39,23 @@ from pathlib import Path from typing import Any, Dict 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: - """根据 output_path / work_dir 计算 training_spectra.csv 输出目录""" - if output_path: - return Path(output_path).parent - return Path(work_dir) / "6_Spectral_Feature_Extraction" +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/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]: @@ -69,7 +79,7 @@ def execute_step6(config: Dict[str, Any]) -> Dict[str, Any]: output_path = config.get("output_path") 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" # ---------- 提前失败检查 ---------- @@ -163,6 +173,14 @@ def execute_step6(config: Dict[str, Any]) -> Dict[str, Any]: "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 { "status": "completed", "output_path": str(p).replace("\\", "/"), diff --git a/src/new/services/step7_service.py b/src/new/services/step7_service.py index 8068061..5173690 100644 --- a/src/new/services/step7_service.py +++ b/src/new/services/step7_service.py @@ -35,13 +35,19 @@ from pathlib import Path from typing import Any, Dict, List, Optional 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: - """根据 output_file / work_dir 计算 indices CSV 输出目录""" - if output_file: - return Path(output_file).parent - return Path(work_dir) / "7_Water_Quality_Indices" +def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]: + """根据 output_file / output_path / work_dir 计算 indices CSV 输出目录 + + 使用共享解析器强制执行"用户优先"规则——step7 旧 panel 字段是 output_file, + 新架构保留 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]: @@ -61,7 +67,7 @@ def execute_step7(config: Dict[str, Any]) -> Dict[str, Any]: output_file: Optional[str] = config.get("output_file") 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" # ---------- 提前失败检查 ---------- diff --git a/src/new/services/step8_service.py b/src/new/services/step8_service.py index 05350a9..3e5e1f5 100644 --- a/src/new/services/step8_service.py +++ b/src/new/services/step8_service.py @@ -38,13 +38,23 @@ from pathlib import Path from typing import Any, Dict, List, Optional 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: - """根据 output_path / work_dir 计算模型保存目录""" - if output_path: - return Path(output_path) - return Path(work_dir) / "8_Supervised_Model_Training" +def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]: + """根据 output_path / work_dir 计算模型保存目录 + + 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时直接用其值 + (因为 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]: @@ -67,7 +77,7 @@ def execute_step8(config: Dict[str, Any]) -> Dict[str, Any]: output_path: Optional[str] = config.get("output_path") 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" # ---------- 提前失败检查 ---------- diff --git a/src/new/services/step9_service.py b/src/new/services/step9_service.py index 669fc5c..8fa7caa 100644 --- a/src/new/services/step9_service.py +++ b/src/new/services/step9_service.py @@ -41,14 +41,22 @@ from pathlib import Path from typing import Any, Dict 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: - """根据 output_path / work_dir 计算预测结果输出目录""" - if output_path: - return Path(output_path) - # 与旧 pipeline 一致:prediction_dir / 9_ML_Prediction - return Path(work_dir) / "prediction" / "9_ML_Prediction" +def _resolve_output_dir(config: Dict[str, Any], work_dir: str) -> tuple[Path, str]: + """根据 output_path / work_dir 计算预测结果输出目录 + + 使用共享解析器强制执行"用户优先"规则——用户指定 output_path 时直接用其值 + (step9 的 output_path 本身就是一个目录,非文件),否则用 work_dir 默认路径。 + + 注意: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]: @@ -69,7 +77,7 @@ def execute_step9(config: Dict[str, Any]) -> Dict[str, Any]: output_path: str = config.get("output_path") 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" # ---------- 提前失败检查 ---------- diff --git a/src/new/views/step12_view.py b/src/new/views/step12_view.py index 22d1413..2e96f52 100644 --- a/src/new/views/step12_view.py +++ b/src/new/views/step12_view.py @@ -1,19 +1,18 @@ # -*- 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 / - VisualizationWorkerThread / PandasTableModel / ChartBrowserDialog** 等子控件; - 这些是「运行时画图」所需的复杂组件,由 service + 独立图片查看器窗口接管。 -- **保留可视化配置 checkbox 组**(5 个:散点/光谱/箱线/掩膜缩略/采样地图)—— - 这是用户实际可控的业务选项。 -- **保留工作目录 / 图像目录选择 + 扫描 / 生成全部按钮**——给用户「 - 「一键触发可视化」的入口;具体生成逻辑由 service 接管。 +所有渲染**纯本地化**,不触发任何 service;service 端只负责生成图像 +到工作目录,view 端负责即时浏览。 """ import os @@ -25,6 +24,7 @@ from PyQt5.QtWidgets import ( QPushButton, QVBoxLayout, QWidget, ) +from src.gui.components.image_widgets import ImageCategoryTree, ImageViewerWidget from src.gui.styles import ModernStylesheet 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): - """Step 12: 可视化(精简版)""" + """Step 12: 可视化""" def init_ui(self): main_layout = QHBoxLayout() @@ -110,13 +110,16 @@ class Step12View(BaseView): self.gen_all_btn = QPushButton("🚀 生成全部") self.gen_all_btn.setToolTip("生成所有类型的可视化图表") 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) config_layout.addWidget(self.gen_all_btn) 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) config_layout.addWidget(self.scan_btn) @@ -127,20 +130,22 @@ class Step12View(BaseView): left_panel.setMaximumWidth(350) main_layout.addWidget(left_panel, 0) - # ===== 右侧占位 ===== + # ===== 右侧:分类树 + 图片查看器(旧版高级家具) ===== right_panel = QWidget() right_layout = QVBoxLayout() right_layout.setContentsMargins(0, 0, 0, 0) - placeholder = QLabel( - "可视化结果由 service 端生成,\n" - "生成完成后可在主窗口日志区查看文件路径。\n" - "(精简版 view 不内嵌 matplotlib 画布。)" - ) - placeholder.setAlignment(Qt.AlignCenter | Qt.AlignVCenter) - placeholder.setStyleSheet( - "color: #999; font-size: 13px; padding: 40px;" - ) - right_layout.addWidget(placeholder, 1) + right_layout.setSpacing(6) + + # 顶部:图像分类树(按类别组织:模型评估/光谱分析/统计图表/处理结果/含量分布图) + self.image_tree = ImageCategoryTree() + self.image_tree.setMaximumHeight(280) + self.image_tree.currentItemChanged.connect(self._on_tree_selection) + right_layout.addWidget(self.image_tree) + + # 底部:图片查看器(支持滚轮缩放、工具栏、1:1/适应窗口/保存) + self.image_viewer = ImageViewerWidget() + right_layout.addWidget(self.image_viewer, 1) + right_panel.setLayout(right_layout) main_layout.addWidget(right_panel, 1) @@ -225,7 +230,17 @@ class Step12View(BaseView): self.dispatch_execute("step12", self.get_config()) def _on_scan_clicked(self): - # 扫描仅刷新工作目录和图像目录的显示;不实际派发到 service + """扫描工作目录下的图像文件并加载到分类树(不派发 service)""" work_dir = self.work_dir_edit.text() - if work_dir and os.path.isdir(work_dir): - self.img_dir_edit.setText(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) + + 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)