From 6a1014afcce3840f8e11b2b8911a36b735f14773 Mon Sep 17 00:00:00 2001 From: DXC Date: Wed, 24 Jun 2026 12:44:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor(step10=5Fnew=5Farch):=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=96=B0=E6=9E=B6=E6=9E=84=20view=20+=20service=20?= =?UTF-8?q?=E5=88=B0=E6=95=A3=E7=82=B9=20CSV=20=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/new/services/step10_service.py | 198 +++++++++++++++-------------- src/new/views/step10_view.py | 136 ++++++++++---------- 2 files changed, 178 insertions(+), 156 deletions(-) diff --git a/src/new/services/step10_service.py b/src/new/services/step10_service.py index fc99012..4aa0056 100644 --- a/src/new/services/step10_service.py +++ b/src/new/services/step10_service.py @@ -1,36 +1,42 @@ # -*- coding: utf-8 -*- """ -Step10 后端计算服务(水色指数反演) -==================================== +Step10 后端计算服务(水色指数反演 · 散点 CSV 模式) +==================================================== 纯计算函数——绝对不引用 PyQt、绝对不引用 main_view、绝对不读写全局变量。它只: 1. 从 ``config`` 字典读取参数; -2. 调用 ``WaterIndexProcessor.run_inversion`` 用 ``waterindex.csv`` 中的 - 公式直接处理去耀斑 BSQ 影像,输出各水质参数指数的 GeoTIFF; -3. 返回结果字典 ``{status, output_path, message, mode}``。 +2. 调用 ``WaterIndexCsvProcessor.compute_indices_from_csv`` + 读取 Step 4 输出的 ``sampling_spectra.csv`` 散点,对每行采样点 + 套用 ``waterindex.csv`` 中勾选的公式,输出每公式一个 CSV; +3. 返回结果字典 ``{status, output_path, message, mode, ...}``。 调用入口(由 main_view 在后台 QThread 中调用): execute_step10({ - "bsq_path": "D:/deglint_output.bsq", # 去耀斑 BSQ 影像(必填) - "deglint_img_path": "D:/deglint_output.bsq", # 同上(兼容旧 panel 字段) - "hdr_path": "D:/deglint_output.hdr", # ENVI 头文件(可省,自动 .bsq→.hdr 推断) - "selected_formulas": ["NDCI", "BGA_Am09KBBI"], # 要处理的公式名列表(空 → 全部) - "formula_csv_path": "D:/waterindex.csv", # waterindex.csv 路径(可省,自动探测) - "water_mask_path": "D:/water_mask.dat", # 水域掩膜路径(可省) - "nodata_value": -9999.0, # NoData 标记值 - "output_dir": "D:/10_WaterIndex_Images", # 输出目录(可省 → work_dir/10_WaterIndex_Images) - "enabled": True, - "work_dir": "D:/workspace", # 工作目录(main_view 注入) + "sampling_csv_path": "D:/4_sampling/sampling_spectra.csv", # 必填 + "selected_formulas": ["NDCI", "BGA_Am09KBBI"], # 勾选公式;空 → 全部 + "formula_csv_path": "D:/waterindex.csv", # waterindex.csv 路径 + "output_dir": "D:/10_WaterIndex_CSV", # 输出目录;可省 + "enabled": True, + "work_dir": "D:/workspace", # 主窗口注入 }) 返回字典字段: * ``status`` : "completed" | "skipped" | "error" * ``output_path`` : 输出目录路径(失败时为 None) +* ``output_files`` : {公式名: 公式 CSV 路径}(失败时为空 dict) * ``message`` : 人类可读说明 -* ``mode`` : "watercolor_inversion"(便于 UI 提示) +* ``mode`` : "watercolor_inversion_csv"(便于 UI 提示) + +设计要点 +======== +- 与 Step 9 (ML 预测) 完全对称的"散点处理模式":输入 CSV、输出 CSV, + 坐标列重命名为 longitude/latitude,公式值以列形式追加。 +- 旧"读 BSQ 全图 → 输出 GeoTIFF"模式已废弃(科学上误差大且与 GIS 栅格计算器重复)。 +- 兼容调用方可能仍传旧键(bsq_path / hdr_path / deglint_img_path),检测到时 + 静默忽略并回退到 sampling_spectra.csv 路径解析(避免破坏已有 pipeline 配置)。 """ from __future__ import annotations @@ -38,11 +44,40 @@ 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 +from src.new.services._output_resolver import get_user_output_path + + +def _resolve_sampling_csv_path( + sampling_csv_path: Optional[str], + work_dir: str, +) -> str: + """解析采样点 CSV 路径 + + 解析顺序: + 1. 显式传入的 ``sampling_csv_path`` + 2. ``{work_dir}/4_sampling/sampling_spectra.csv`` + 3. ``{work_dir}/4_sampling/`` 下任意 ``.csv`` (取最新) + """ + if sampling_csv_path and Path(sampling_csv_path).is_file(): + return sampling_csv_path + if not work_dir: + return sampling_csv_path or "" + + primary = Path(work_dir) / "4_sampling" / "sampling_spectra.csv" + if primary.is_file(): + return str(primary).replace("\\", "/") + + sample_dir = Path(work_dir) / "4_sampling" + if sample_dir.is_dir(): + cands = sorted(sample_dir.glob("*.csv"), key=lambda p: p.stat().st_mtime, reverse=True) + if cands: + return str(cands[0]).replace("\\", "/") + + return sampling_csv_path or "" def _resolve_waterindex_csv(formula_csv_path: Optional[str], work_dir: str) -> str: - """解析 waterindex.csv 路径(与 WaterIndexProcessor.__init__ 默认逻辑保持一致)""" + """解析 waterindex.csv 路径(与 WaterIndexCsvProcessor.__init__ 默认逻辑保持一致)""" if formula_csv_path and Path(formula_csv_path).is_file(): return formula_csv_path candidates = [ @@ -52,142 +87,118 @@ def _resolve_waterindex_csv(formula_csv_path: Optional[str], work_dir: str) -> s ] for c in candidates: if c.is_file(): - return str(c) + return str(c).replace("\\", "/") return formula_csv_path or "" -def _resolve_water_mask_path(water_mask_path: Optional[str], work_dir: str) -> Optional[str]: - """解析水域掩膜路径(缺省时尝试从 work_dir/1_water_mask 自动扫盘)""" - if water_mask_path and Path(water_mask_path).is_file(): - return water_mask_path - if not work_dir: - return None - mask_dir = Path(work_dir) / "1_water_mask" - if not mask_dir.is_dir(): - return None - for pat in ("*.tif", "*.TIF", "*.dat", "*.DT"): - cands = sorted(mask_dir.glob(pat)) - if cands: - return str(cands[0]) - return None - - 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。 + (step10 的 output_dir 本身就是一个目录),否则用 + ``work_dir/10_WaterIndex_CSV`` 默认。 """ 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" + return Path(work_dir) / "10_WaterIndex_CSV", "default" def execute_step10(config: Dict[str, Any]) -> Dict[str, Any]: - """Step 10 后端计算入口——纯函数 + """Step 10 后端计算入口——纯函数(散点 CSV 模式) Args: config: 由前端 view.get_config() 序列化、再经 main_view 注入 work_dir 的字典 Returns: - 标准结果字典 ``{status, output_path, message, mode}`` + 标准结果字典 ``{status, output_path, output_files, message, mode}`` """ # ---------- 入参规整 ---------- - bsq_path: str = config.get("bsq_path") or config.get("deglint_img_path") or "" - hdr_path: str = config.get("hdr_path") or "" + sampling_csv_path: str = ( + config.get("sampling_csv_path") + or config.get("spectrum_csv_path") # 兼容旧字段 + or "" + ) selected_formulas: List[str] = config.get("selected_formulas") or [] formula_csv_path: str = config.get("formula_csv_path") or "" - water_mask_path: Optional[str] = config.get("water_mask_path") - nodata_value: float = float(config.get("nodata_value", -9999.0)) output_dir: str = config.get("output_dir") or "" enabled: bool = bool(config.get("enabled", True)) work_dir: str = config.get("work_dir") or "." output_path, _source = _resolve_output_dir(config, work_dir) - mode = "watercolor_inversion" + mode = "watercolor_inversion_csv" # ---------- 提前失败检查 ---------- if not enabled: return { "status": "skipped", "output_path": None, + "output_files": {}, "message": "用户禁用此步骤(enabled=False)", "mode": mode, } - if not bsq_path: - return { - "status": "error", - "output_path": None, - "message": "未提供 BSQ 影像路径(bsq_path / deglint_img_path)", - "mode": mode, - } - if not Path(bsq_path).is_file(): - return { - "status": "error", - "output_path": None, - "message": f"BSQ 影像不存在: {bsq_path}", - "mode": mode, - } - if not hdr_path: - # 自动探测 .hdr - hdr_path = str(Path(bsq_path).with_suffix(".hdr")) - if not Path(hdr_path).is_file(): - hdr_alt = str(Path(bsq_path).with_suffix(".HDR")) - if Path(hdr_alt).is_file(): - hdr_path = hdr_alt - else: - hdr_path = "" - if not hdr_path or not Path(hdr_path).is_file(): + # 解析采样点 CSV 路径 + resolved_sampling_csv = _resolve_sampling_csv_path(sampling_csv_path, work_dir) + if not resolved_sampling_csv: return { "status": "error", "output_path": None, - "message": f"未找到 ENVI 头文件(与 BSQ 同名 .hdr): {bsq_path}", + "output_files": {}, + "message": "未提供 sampling_csv_path 且默认位置均找不到 sampling_spectra.csv", + "mode": mode, + } + if not Path(resolved_sampling_csv).is_file(): + return { + "status": "error", + "output_path": None, + "output_files": {}, + "message": f"采样点 CSV 不存在: {resolved_sampling_csv}", "mode": mode, } - # ---------- 解析 waterindex.csv ---------- + # 解析 waterindex.csv resolved_formula_csv = _resolve_waterindex_csv(formula_csv_path, work_dir) if not resolved_formula_csv: return { "status": "error", "output_path": None, + "output_files": {}, "message": "未提供 formula_csv_path 且默认位置均找不到 waterindex.csv", "mode": mode, } - - # ---------- 解析水域掩膜(可选) ---------- - resolved_water_mask = _resolve_water_mask_path(water_mask_path, work_dir) + if not Path(resolved_formula_csv).is_file(): + return { + "status": "error", + "output_path": None, + "output_files": {}, + "message": f"waterindex.csv 不存在: {resolved_formula_csv}", + "mode": mode, + } # ---------- 执行(包一层 try/except 把异常转 dict,避免炸线程) ---------- try: - from src.core.algorithms.waterindex_inversion import WaterIndexProcessor + from src.core.algorithms.waterindex_inversion import ( + WaterIndexCsvProcessor, + ) - print(f"[Step10 Service] 水色指数反演: bsq={bsq_path}") - print(f"[Step10 Service] hdr={hdr_path}") + print(f"[Step10 Service] 水色指数反演(散点模式): sampling_csv={resolved_sampling_csv}") print(f"[Step10 Service] formula_csv={resolved_formula_csv}") print(f"[Step10 Service] selected_formulas={selected_formulas or '全部'}") - if resolved_water_mask: - print(f"[Step10 Service] water_mask={resolved_water_mask}") + print(f"[Step10 Service] output_dir={output_path}") - processor = WaterIndexProcessor(resolved_formula_csv) - results = processor.run_inversion( - deglint_img_path=bsq_path, - work_dir=work_dir, - formula_csv_path=resolved_formula_csv, + processor = WaterIndexCsvProcessor(resolved_formula_csv) + out_files = processor.compute_indices_from_csv( + sampling_csv_path=resolved_sampling_csv, + output_dir=str(output_path).replace("\\", "/"), selected_formulas=selected_formulas or None, - water_mask_path=resolved_water_mask, - nodata_value=nodata_value, - callback=None, # 日志由 main_view 统一接管 + progress_callback=None, # 日志由 main_view 统一接管 ) except FileNotFoundError as e: return { "status": "error", "output_path": None, + "output_files": {}, "message": f"文件不存在: {e}", "mode": mode, } @@ -195,6 +206,7 @@ def execute_step10(config: Dict[str, Any]) -> Dict[str, Any]: return { "status": "error", "output_path": None, + "output_files": {}, "message": f"参数错误: {e}", "mode": mode, } @@ -202,16 +214,18 @@ def execute_step10(config: Dict[str, Any]) -> Dict[str, Any]: return { "status": "error", "output_path": None, + "output_files": {}, "message": f"{type(e).__name__}: {e}", "mode": mode, } # ---------- 成功路径 ---------- p = Path(output_path) - n_results = len(results) if isinstance(results, dict) else 0 + n_results = len(out_files) if isinstance(out_files, dict) else 0 return { "status": "completed", "output_path": str(p).replace("\\", "/"), - "message": f"水色指数反演完成,共生成 {n_results} 个指数 GeoTIFF", + "output_files": out_files, + "message": f"水色指数反演完成,共生成 {n_results} 个指数 CSV", "mode": mode, - } \ No newline at end of file + } diff --git a/src/new/views/step10_view.py b/src/new/views/step10_view.py index 5fa49ae..544fb1b 100644 --- a/src/new/views/step10_view.py +++ b/src/new/views/step10_view.py @@ -1,20 +1,18 @@ # -*- coding: utf-8 -*- """ -Step10View —— Step 10(水色指数反演)的端到端模块化 view +Step10View —— Step 10(水色指数反演)的端到端模块化 view(散点 CSV 模式) -UI 从 ``src/gui/panels/step10_watercolor_panel.py`` 原样搬迁。 +与 ``src/gui/panels/step10_watercolor_panel.py`` 同步重构。 view 层职责 =========== -- 输入影像(BSQ + HDR)、公式选择 ListWidget、输出目录 + 格式 combo、 - 进度条 / 进度标签、运行按钮全部保留。 -- 删除 ``WaterIndexWorker`` 线程(service 接管后台反演逻辑); - 进度条 / 标签在 view 层保留 UI 占位,由 service 通过 - ``dispatch_execute`` 反馈到主窗口的日志区即可。 -- ``_find_waterindex_csv`` / ``_load_formulas`` / ``_load_metadata`` - 不在 view 层执行;公式 ListWidget 留空,service 通过 set_config - 把 selected_formulas 注入。 +- 输入采样点 CSV、公式选择 ListWidget、输出目录、进度条 / 进度标签、运行按钮。 +- 删除 BSQ + HDR FileSelectWidget(散点模式不读全图栅格)。 +- 删除 ``format_combo``(每个公式一个 CSV,无格式选择)。 +- 删除 ``WaterIndexWorker`` 线程(service 接管后台计算逻辑)。 +- ``_load_formulas`` / 加载 waterindex.csv 的逻辑不在 view 层执行; + 公式 ListWidget 留空,service 通过 set_config 把 selected_formulas 注入。 """ import os @@ -37,50 +35,51 @@ def _resolve_subdir(work_dir: str, subdir_name: str) -> str: class Step10View(BaseView): - """Step 10: 水色指数反演(高光谱影像直接处理)""" + """Step 10: 水色指数反演(散点 CSV 模式) + + 输入:Step 4 输出的 sampling_spectra.csv(散点 + 全波段光谱) + 处理:逐行套用 waterindex.csv 公式 + 输出:每公式一个 CSV(列:longitude, latitude, 公式值) + """ def init_ui(self): layout = QVBoxLayout() # ---- 标题 ---- - title = QLabel("步骤10:水色指数反演(高光谱影像直接处理)") + title = QLabel("步骤10:水色指数反演(散点 CSV 模式)") title.setFont(QFont("Arial", 12, QFont.Bold)) layout.addWidget(title) # ---- 说明 ---- hint = QLabel( - "将 waterindex.csv 中的公式直接应用于去耀斑高光谱影像(BSQ)," - "输出各水质参数指数的 GeoTIFF 栅格图像。" - "指数图可直接用于水质专题图生成。" + "读取 Step 4 生成的 sampling_spectra.csv 散点光谱," + "对每个采样点逐行套用 waterindex.csv 中勾选的公式," + "输出每公式一个 CSV(列:longitude, latitude, 公式值)。" + "结果可被 Step 11 直接以 ContentMapper 模式消费。" ) hint.setWordWrap(True) hint.setStyleSheet(f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};") layout.addWidget(hint) - # ---- 输入影像选择 ---- - input_group = QGroupBox("输入影像") + # ---- 输入采样点数据 ---- + input_group = QGroupBox("输入采样点数据") input_layout = QFormLayout() - self.bsq_file = FileSelectWidget( - "BSQ 影像:", - "BSQ Files (*.bsq);;DAT Files (*.dat);;All Files (*.*)", + self.sampling_csv_file = FileSelectWidget( + "采样点 CSV:", + "CSV Files (*.csv);;All Files (*.*)", ) - self.bsq_file.line_edit.setPlaceholderText("选择去耀斑处理后的 BSQ 影像") - input_layout.addRow("BSQ 影像:", self.bsq_file) - - self.hdr_file = FileSelectWidget( - "ENVI 头文件:", - "HDR Files (*.hdr);;All Files (*.*)", + self.sampling_csv_file.line_edit.setPlaceholderText( + "选择 Step 4 输出的 sampling_spectra.csv" ) - self.hdr_file.line_edit.setPlaceholderText("自动关联同路径 .hdr 文件") - input_layout.addRow("HDR 文件:", self.hdr_file) + input_layout.addRow("采样点 CSV:", self.sampling_csv_file) - self.meta_label = QLabel("未加载影像") + self.meta_label = QLabel("未加载采样点数据") self.meta_label.setStyleSheet( "background: #f0f0f0; padding: 4px 8px; border-radius: 4px; " "font-size: 12px; color: #333;" ) - input_layout.addRow("影像信息:", self.meta_label) + input_layout.addRow("数据信息:", self.meta_label) input_group.setLayout(input_layout) layout.addWidget(input_group) @@ -125,14 +124,11 @@ class Step10View(BaseView): "输出目录:", "Directories", ) - self.output_dir.line_edit.setPlaceholderText("留空 → 工作目录/10_WaterIndex_Images") + self.output_dir.line_edit.setPlaceholderText( + "留空 → 工作目录/10_WaterIndex_CSV" + ) output_layout.addRow("输出目录:", self.output_dir) - self.format_combo = QComboBox() - self.format_combo.addItems(["GTiff (GeoTIFF)", "ENVI", "PCI"]) - self.format_combo.setCurrentIndex(0) - output_layout.addRow("输出格式:", self.format_combo) - output_group.setLayout(output_layout) layout.addWidget(output_group) @@ -178,7 +174,7 @@ class Step10View(BaseView): # BaseView 契约 # ------------------------------------------------------------------ def get_config(self) -> dict: - bsq_path = self.bsq_file.get_path() + sampling = self.sampling_csv_file.get_path().strip() selected = [] for i in range(self.formula_list.count()): item = self.formula_list.item(i) @@ -186,26 +182,19 @@ class Step10View(BaseView): name = item.data(Qt.UserRole) if name: selected.append(name) - config = { - "bsq_path": bsq_path, - "deglint_img_path": bsq_path, - "output_format": self.format_combo.currentText().split()[0], + config: dict = { + "sampling_csv_path": sampling, "selected_formulas": selected, "enabled": self.enable_checkbox.isChecked(), } - hdr_path = self.hdr_file.get_path() - if hdr_path: - config["hdr_path"] = hdr_path - output_dir = self.output_dir.get_path() + output_dir = self.output_dir.get_path().strip() if output_dir: config["output_dir"] = output_dir return config def set_config(self, config: dict): - if config.get("bsq_path"): - self.bsq_file.set_path(config["bsq_path"]) - if config.get("hdr_path"): - self.hdr_file.set_path(config["hdr_path"]) + if config.get("sampling_csv_path"): + self.sampling_csv_file.set_path(config["sampling_csv_path"]) if config.get("output_dir"): self.output_dir.set_path(config["output_dir"]) if "selected_formulas" in config: @@ -223,25 +212,44 @@ class Step10View(BaseView): super().update_work_directory(work_dir) if not work_dir: return - out_dir = _resolve_subdir(work_dir, "watercolor") + + # 1) 自动填采样点 CSV(从 step4 拉取) + sampling_path = self._find_step4_sampling_csv(work_dir) + if sampling_path and not self.sampling_csv_file.get_path(): + self.sampling_csv_file.set_path(sampling_path) + + # 2) 自动填输出目录 + out_dir = os.path.join(work_dir, "10_WaterIndex_CSV").replace("\\", "/") os.makedirs(out_dir, exist_ok=True) if not self.output_dir.get_path(): self.output_dir.set_path(out_dir) - # 自动填 BSQ(去耀斑输出) - deglint_dir = _resolve_subdir(work_dir, "deglint") - if os.path.isdir(deglint_dir): + + def _find_step4_sampling_csv(self, work_dir: str) -> str: + """从 step4 panel / pipeline.step_outputs / 4_sampling 目录自动找采样点 CSV""" + # 1) 优先:从主窗口懒加载面板读 + mw = self.window() + factory = getattr(mw, "_panel_factory", None) if mw else None + if factory: + step4_panel = factory.get_panel("step4_sampling") + if step4_panel and hasattr(step4_panel, "output_file"): + p = step4_panel.output_file.get_path().strip() + if p and os.path.isfile(p): + return p + + # 2) 兜底:扫 4_sampling/sampling_spectra.csv + candidate = os.path.join(work_dir, "4_sampling", "sampling_spectra.csv") + if os.path.isfile(candidate): + return candidate.replace("\\", "/") + + # 3) 终极兜底:扫 4_sampling 下任意 .csv + sample_dir = os.path.join(work_dir, "4_sampling") + if os.path.isdir(sample_dir): import glob - candidates = ( - glob.glob(os.path.join(deglint_dir, "*.bsq")) - + glob.glob(os.path.join(deglint_dir, "*.dat")) - ) - if candidates and not self.bsq_file.get_path(): - candidates.sort(key=os.path.getmtime, reverse=True) - bsq_path = candidates[0] - self.bsq_file.set_path(bsq_path) - hdr_path = os.path.splitext(bsq_path)[0] + ".hdr" - if os.path.exists(hdr_path): - self.hdr_file.set_path(hdr_path) + cands = glob.glob(os.path.join(sample_dir, "*.csv")) + if cands: + cands.sort(key=os.path.getmtime, reverse=True) + return cands[0].replace("\\", "/") + return "" # ------------------------------------------------------------------ # 执行入口