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)统一视觉。
281 lines
11 KiB
Python
281 lines
11 KiB
Python
# -*- 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,
|
||
} |