# -*- coding: utf-8 -*- """ Step12 后端计算服务(数据可视化——散点/光谱/箱线/掩膜缩略/采样地图) ================================================================ 纯计算函数——绝对不引用 PyQt、绝对不引用 main_view。它只: 1. 从 ``config`` 字典读取 ``generate_*`` 5 个开关; 2. 按开关依次调用 ``src/core/visualization`` 或 ``src/postprocessing.visualization_reports`` 中的独立生成函数; 3. 每个子任务独立 try/except 隔离,单个失败不影响其它; 4. 返回结果字典 ``{status, output_path, message, mode}``。 调用入口(由 main_view 在后台 QThread 中调用): execute_step12({ "work_dir": "D:/workspace", # 工作目录(必填) "img_dir": "D:/workspace/9_ML_Prediction", # 图像目录(可省,自动推断) "generate_scatter": True, # 模型评估散点图 "generate_spectrum": True, # 光谱曲线图 "generate_boxplots": True, # 箱线图 "generate_glint_previews": True, # 掩膜/耀斑缩略图 "generate_sampling_maps": True, # 采样点地图 "models_dir": "D:/8_Supervised_Model_Training", # 散点图依赖(可省,自动推断) "training_csv_path": "D:/7_Water_Quality_Indices/training_with_indices.csv", # 光谱/箱线依赖(可省,自动推断) "output_dir": "D:/14_visualization", # 输出目录(可省 → work_dir/14_visualization) "enabled": True, }) 返回字典字段: * ``status`` : "completed" | "skipped" | "error" * ``output_path`` : 输出目录路径(失败时为 None) * ``message`` : 人类可读说明 * ``mode`` : "viz_generate"(便于 UI 提示) 设计取舍 -------- - 旧 panel 把所有图嵌入 matplotlib 画布;service 端只生成 PNG 文件, 完全离线——view 层不再嵌入任何 chart widget。 - 单个可视化失败时 ``results[sub] = {"status": "error", ...}``, 但外层 status 仍然 = "completed"(局部失败不等于全部失败)。 - 完全没有可用数据(连 work_dir 都不存在)时才返回 status="error"。 """ 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(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: """自动推断模型目录""" if models_dir and Path(models_dir).is_dir(): return models_dir cand = Path(work_dir) / "8_Supervised_Model_Training" return str(cand) if cand.is_dir() else (models_dir or "") def _resolve_training_csv(work_dir: str, training_csv_path: str | None) -> str: """自动推断训练 CSV""" if training_csv_path and Path(training_csv_path).is_file(): return training_csv_path for sub in ( "7_Water_Quality_Indices", "6_Spectral_Feature_Extraction", "4_processed_data", "visualization", ): d = Path(work_dir) / sub if not d.is_dir(): continue cands = sorted(d.glob("*.csv")) if cands: return str(cands[0]) return training_csv_path or "" def _try_scatter(work_dir: str, output_dir: Path) -> Dict[str, Any]: """生成模型评估散点图(依赖 models_dir + training_csv_path)""" from src.core.visualization.scatter_plot import generate_model_scatter_plots models_dir = _resolve_models_dir(work_dir, None) training_csv = _resolve_training_csv(work_dir, None) if not models_dir or not Path(models_dir).is_dir(): raise FileNotFoundError(f"模型目录不存在: {models_dir or '(自动推断失败)'}") if not training_csv or not Path(training_csv).is_file(): raise FileNotFoundError(f"训练 CSV 不存在: {training_csv or '(自动推断失败)'}") out_sub = output_dir / "scatter_plots" paths = generate_model_scatter_plots( models_dir=models_dir, training_csv_path=training_csv, output_dir=str(out_sub), ) return {"status": "completed", "count": len(paths), "output_dir": str(out_sub)} def _try_spectrum(work_dir: str, output_dir: Path) -> Dict[str, Any]: """生成光谱曲线对比图(依赖 training CSV)""" from src.core.visualization.spectrum_plot import generate_spectrum_comparison_plots training_csv = _resolve_training_csv(work_dir, None) if not training_csv or not Path(training_csv).is_file(): raise FileNotFoundError(f"训练 CSV 不存在: {training_csv or '(自动推断失败)'}") out_sub = output_dir / "spectrum_plots" paths = generate_spectrum_comparison_plots( csv_path=training_csv, output_dir=str(out_sub), ) return {"status": "completed", "count": len(paths), "output_dir": str(out_sub)} def _try_boxplots(work_dir: str, output_dir: Path) -> Dict[str, Any]: """生成水质参数箱型图(依赖 training CSV)""" from src.core.visualization.boxplot import generate_boxplots training_csv = _resolve_training_csv(work_dir, None) if not training_csv or not Path(training_csv).is_file(): raise FileNotFoundError(f"训练 CSV 不存在: {training_csv or '(自动推断失败)'}") out_sub = output_dir / "boxplots" paths = generate_boxplots( csv_path=training_csv, output_dir=str(out_sub), ) return {"status": "completed", "count": len(paths), "output_dir": str(out_sub)} def _try_glint_previews(work_dir: str, output_dir: Path) -> Dict[str, Any]: """生成耀斑分析影像预览图""" from src.postprocessing.visualization_reports import ReportGenerator rg = ReportGenerator(output_dir=str(output_dir)) paths = rg.generate_glint_deglint_previews( work_dir=work_dir, output_subdir="glint_deglint_previews", generate_glint=True, generate_deglint=True, ) return {"status": "completed", "count": len(paths), "output_dir": str(output_dir / "glint_deglint_previews")} def _try_sampling_maps(work_dir: str, output_dir: Path) -> Dict[str, Any]: """生成采样点地图""" from src.postprocessing.visualization_reports import ReportGenerator rg = ReportGenerator(output_dir=str(output_dir)) p = rg.generate_sampling_point_map(output_subdir="sampling_maps") return {"status": "completed" if p else "error", "path": p, "output_dir": str(output_dir / "sampling_maps")} def execute_step12(config: Dict[str, Any]) -> Dict[str, Any]: """Step 12 后端计算入口——纯函数""" work_dir: str = config.get("work_dir") or "" img_dir: str = config.get("img_dir") or "" enabled: bool = bool(config.get("enabled", True)) output_dir: str = config.get("output_dir") or "" # 5 个开关:缺省默认 True(与旧 panel 行为一致) gen_scatter = bool(config.get("generate_scatter", True)) gen_spectrum = bool(config.get("generate_spectrum", True)) gen_boxplots = bool(config.get("generate_boxplots", True)) gen_glint = bool(config.get("generate_glint_previews", True)) gen_sampling = bool(config.get("generate_sampling_maps", True)) output_path, _source = _resolve_output_dir(config, work_dir) mode = "viz_generate" # ---------- 提前失败检查 ---------- if not enabled: return { "status": "skipped", "output_path": None, "message": "用户禁用此步骤(enabled=False)", "mode": mode, } if not work_dir: return { "status": "error", "output_path": None, "message": "未提供工作目录(work_dir)", "mode": mode, } if not Path(work_dir).is_dir(): return { "status": "error", "output_path": None, "message": f"工作目录不存在: {work_dir}", "mode": mode, } output_path.mkdir(parents=True, exist_ok=True) # img_dir 仅作提示用,不强制校验 _ = img_dir # ---------- 逐项执行(独立 try/except 隔离) ---------- results: Dict[str, Dict[str, Any]] = {} n_ok = 0 n_skip = 0 n_err = 0 tasks = [] if gen_scatter: tasks.append(("scatter", _try_scatter)) if gen_spectrum: tasks.append(("spectrum", _try_spectrum)) if gen_boxplots: tasks.append(("boxplots", _try_boxplots)) if gen_glint: tasks.append(("glint_previews", _try_glint_previews)) if gen_sampling: tasks.append(("sampling_maps", _try_sampling_maps)) if not tasks: return { "status": "completed", "output_path": str(output_path).replace("\\", "/"), "message": "无可视化任务(5 个开关全部 False)", "mode": mode, } print(f"[Step12 Service] 工作目录: {work_dir}") print(f"[Step12 Service] 输出目录: {output_path}") print(f"[Step12 Service] 子任务: {[n for n, _ in tasks]}") for name, fn in tasks: try: r = fn(work_dir, output_path) results[name] = r if r.get("status") == "completed": n_ok += 1 print(f" ✅ {name}: count={r.get('count', r.get('path', '?'))}") else: n_err += 1 print(f" ⚠ {name}: status={r.get('status')}") except FileNotFoundError as e: results[name] = {"status": "skipped", "reason": str(e)} n_skip += 1 print(f" ↻ {name}: 跳过(缺数据): {e}") except Exception as e: # noqa: BLE001 results[name] = {"status": "error", "message": f"{type(e).__name__}: {e}"} n_err += 1 print(f" ❌ {name}: {type(e).__name__}: {e}") # ---------- 汇总 ---------- # 局部失败不算全局失败——只有当所有任务都缺数据时才算 skipped if n_ok == 0 and n_err == 0: return { "status": "skipped", "output_path": None, "message": f"全部 {n_skip} 个子任务缺数据被跳过(models_dir/training_csv 等前置产物不存在)", "mode": mode, } return { "status": "completed", "output_path": str(output_path).replace("\\", "/"), "message": ( f"可视化完成:成功 {n_ok} / 失败 {n_err} / 跳过 {n_skip};" f"目录: {output_path.name or output_path}" ), "mode": mode, }