diff --git a/src/gui/core/log_manager.py b/src/gui/core/log_manager.py index c523aea..5b70857 100644 --- a/src/gui/core/log_manager.py +++ b/src/gui/core/log_manager.py @@ -164,6 +164,24 @@ class LogManager(QObject): if self._log_text is not None: self._log_text.clear() + def info(self, message: str): + """便捷方法:发布 info 级别日志。""" + global_event_bus.publish('LogMessage', { + 'message': message, 'level': 'info', + }) + + def warning(self, message: str): + """便捷方法:发布 warning 级别日志。""" + global_event_bus.publish('LogMessage', { + 'message': message, 'level': 'warning', + }) + + def error(self, message: str): + """便捷方法:发布 error 级别日志。""" + global_event_bus.publish('LogMessage', { + 'message': message, 'level': 'error', + }) + @property def progress_bar(self) -> QProgressBar: return self._progress_bar diff --git a/src/gui/core/panel_factory.py b/src/gui/core/panel_factory.py index 594c2b2..eefc711 100644 --- a/src/gui/core/panel_factory.py +++ b/src/gui/core/panel_factory.py @@ -17,6 +17,7 @@ import os import sys +from pathlib import Path from PyQt5.QtWidgets import QWidget, QTabWidget, QScrollArea, QSpinBox, QDoubleSpinBox, QComboBox from PyQt5.QtCore import Qt @@ -301,6 +302,16 @@ class PanelFactory: if not os.path.exists(absolute_path): continue + # ★ 2026-07-01 加强:若是目录,必须非空(至少含 1 个文件), + # 防止 PipelineContext 预创建的空目录(如 9_ML_Prediction)被当作有效产出广播 + if os.path.isdir(absolute_path): + try: + has_content = any(True for _ in Path(absolute_path).iterdir()) + except (OSError, PermissionError): + has_content = False + if not has_content: + continue + global_event_bus.publish('OutputUpdated', { 'step_id': dep_step, 'output_type': output_type, diff --git a/src/gui/panels/step11_map_panel.py b/src/gui/panels/step11_map_panel.py index 7639a46..9274705 100644 --- a/src/gui/panels/step11_map_panel.py +++ b/src/gui/panels/step11_map_panel.py @@ -69,22 +69,72 @@ class Step11MapBatchThread(QThread): except Exception: mpl_prev = None try: - from src.core.steps.mapping_step import MappingStep + from src.postprocessing.map import ContentMapper + n = len(self.csv_paths) + if n == 0: + self.finished_ok.emit(0) + return + + boundary_shp = self.step10_kwargs.get('boundary_shp_path') + input_crs = self.step10_kwargs.get('input_crs', 'EPSG:32651') + output_crs = self.step10_kwargs.get('output_crs', input_crs) + resolution = float(self.step10_kwargs.get('resolution', 30)) + + # ── ★ 2026-07-01:QThread 内预计算共享空间上下文 ── + # 63 个 CSV 坐标一致,边界/网格/掩膜只算一次 + mapper = ContentMapper(input_crs=input_crs, output_crs=output_crs) + shared_ctx = None + if n > 1 and boundary_shp: + try: + shared_ctx = mapper.prepare_shared_context( + sample_csv=self.csv_paths[0], + shp_file=boundary_shp, + resolution=resolution, + ) + self.log_message.emit( + f"[共享上下文] 空间基准预计算完成,后续 {n} 个 CSV 复用", "info" + ) + except Exception as e: + self.log_message.emit( + f"[警告] 共享上下文失败: {e},回退逐个处理", "warning" + ) + + # ── 批量处理 ── for i, csv_p in enumerate(self.csv_paths): if self._cancelled: self.log_message.emit("专题图批量任务已被用户取消", "warning") break self.progress.emit(i + 1, n) self.log_message.emit(f"专题图 [{i + 1}/{n}] {csv_p}", "info") - kw = {**self.step10_kwargs, "prediction_csv_path": csv_p} - kw.pop("skip_dependency_check", None) - if self.output_dir_optional: - stem = Path(csv_p).stem - kw["output_image_path"] = str(Path(self.output_dir_optional) / f"{stem}_distribution.png") - else: - kw["output_image_path"] = None - MappingStep.generate_distribution_map(**kw) + + stem = Path(csv_p).stem + output_file = ( + str(Path(self.output_dir_optional) / f'{stem}_distribution.png') + if self.output_dir_optional + else None + ) + # 已存在则跳过(兼容 tif 重定向后的文件名) + if output_file and ( + Path(output_file).exists() + or Path(output_file).with_suffix('.tif').exists() + ): + self.log_message.emit(f" → 跳过(已存在)", "info") + continue + + try: + mapper.process_data( + csv_file=csv_p, + shp_file=boundary_shp, + output_file=output_file, + resolution=resolution, + output_format='tif', + shared_context=shared_ctx, + ) + except Exception as e: + self.log_message.emit(f" → 失败: {e}", "error") + continue + self.finished_ok.emit(n) except Exception as e: self.failed.emit(f"{e}\n{traceback.format_exc()}") @@ -534,6 +584,7 @@ class Step11MapPanel(QWidget): # ── 智能自动路由:预测 CSV 目录 ── if self.work_dir: + from src.gui.core.event_bus import global_event_bus wd = self.work_dir done = False @@ -542,10 +593,18 @@ class Step11MapPanel(QWidget): pred_dir = resolve_subdir(wd, cand_dir_key) if pred_dir and os.path.isdir(pred_dir): csvs = list(Path(pred_dir).glob("*.csv")) + global_event_bus.publish('LogMessage', { + 'message': f'[Step11 自动路由] 检查 Step9 目录: {pred_dir} → CSV 数量: {len(csvs)}', + 'level': 'info', + }) if csvs: self.prediction_csv_dir_edit.setText(pred_dir) self.batch_mode_combo.setCurrentIndex(1) done = True + global_event_bus.publish('LogMessage', { + 'message': f'[Step11 自动路由] ✓ 使用 Step9 预测结果目录 ({len(csvs)} 个 CSV)', + 'level': 'info', + }) break # Priority 2 (Fallback): Step 10 水色指数输出目录 @@ -554,12 +613,26 @@ class Step11MapPanel(QWidget): wc_dir = resolve_subdir(wd, cand_dir_key) if wc_dir and os.path.isdir(wc_dir): csvs = list(Path(wc_dir).glob("*.csv")) + global_event_bus.publish('LogMessage', { + 'message': f'[Step11 自动路由] 检查 Step10 目录: {wc_dir} → CSV 数量: {len(csvs)}', + 'level': 'info', + }) if csvs: self.prediction_csv_dir_edit.setText(wc_dir) self.batch_mode_combo.setCurrentIndex(1) done = True + global_event_bus.publish('LogMessage', { + 'message': f'[Step11 自动路由] ✓ 使用 Step10 水色指数目录 ({len(csvs)} 个 CSV)', + 'level': 'info', + }) break + if not done: + global_event_bus.publish('LogMessage', { + 'message': '[Step11 自动路由] ⚠ 未找到任何有效 CSV 目录(Step9 和 Step10 均为空或不存在)', + 'level': 'warning', + }) + # GeoTIFF 目录:指向 step10 水色指数输出 geotiff_dir = resolve_subdir(wd, 'watercolor') if geotiff_dir and os.path.isdir(geotiff_dir) and not self.geotiff_dir_edit.text().strip(): diff --git a/src/gui/water_quality_gui_v2.py b/src/gui/water_quality_gui_v2.py index 533e2f3..bef2f1a 100644 --- a/src/gui/water_quality_gui_v2.py +++ b/src/gui/water_quality_gui_v2.py @@ -503,8 +503,21 @@ class WaterQualityGUI(QMainWindow): try: # 1. 触发懒加载生成面板 - self._panel_factory.get_panel(item_data) - + panel = self._panel_factory.get_panel(item_data) + + # ★ 2026-07-01:每次切页时刷新面板的自动路由 + # 面板首次加载时 _replay_state_to_panel 会调 update_from_config, + # 但再次切回时 get_panel() 直接返回已有实例,不会重扫文件系统。 + # 此处显式调用确保 Step11 等面板始终基于最新磁盘状态做文件夹自动导入。 + if panel is not None and hasattr(panel, 'update_from_config'): + try: + panel.update_from_config( + work_dir=self._workspace_initializer.work_dir, + pipeline=None, + ) + except Exception: + pass + # 🚨 核心防卡死补丁:如果目标 Tab 被后台任务异常永久锁定,强制撬开! if not self._tab_widget.isTabEnabled(tab_index): self._log_manager.info(f"检测到 {item_data} 处于异常锁定状态,已执行强制解锁。") diff --git a/src/postprocessing/map.py b/src/postprocessing/map.py index 23cc780..977b282 100644 --- a/src/postprocessing/map.py +++ b/src/postprocessing/map.py @@ -2761,12 +2761,85 @@ class ContentMapper: print(f" NoData={nodata_value}, 有效像元: {int(valid_mask.sum())}/{grid_content.size}") return output_tif_path + # ═══════════════════════════════════════════════════════════════ + # ★ 2026-07-01:共享空间上下文 — 63 个 CSV 只算一次网格/掩膜 + # ═══════════════════════════════════════════════════════════════ + + def prepare_shared_context(self, sample_csv: str, shp_file=None, + resolution=100, expand_ratio=0.05): + """从首个 CSV 预计算所有子进程共用的空间基准数据。 + + 63 个水色指数 CSV 坐标完全一致,以下数据只算一次: + - boundary_gdf (水域边界) + - grid_xx, grid_yy (插值网格) + - mask (水域掩膜布尔矩阵) + - bounds (空间范围) + + 子进程直接从 shared_context 解包复用,跳过 ②③④⑥,直入 Kriging。 + + Returns: + tuple: (grid_xx, grid_yy, mask, bounds, boundary_gdf) + """ + print(f"[共享上下文] 从 {Path(sample_csv).name} 预计算空间基准...") + + # ② 读边界(只此一次) + if shp_file is None: + boundary_gdf = None + else: + boundary_gdf = self.read_boundary_shapefile(shp_file) + + # ③ 边缘外扩(只此一次)—— 需要读第一个CSV获取坐标结构 + points_gdf = self.read_csv_data(sample_csv) + points_gdf = self._expand_edge_points( + points_gdf, boundary_gdf, resolution=resolution, expand_ratio=expand_ratio + ) + + # ④ 计算网格几何 + if boundary_gdf is None: + pts = np.column_stack((points_gdf['proj_x'], points_gdf['proj_y'])) + minx, maxx = pts[:, 0].min(), pts[:, 0].max() + miny, maxy = pts[:, 1].min(), pts[:, 1].max() + else: + bnd = boundary_gdf.total_bounds + minx, miny, maxx, maxy = bnd + + width = maxx - minx + height = maxy - miny + minx -= width * expand_ratio + maxx += width * expand_ratio + miny -= height * expand_ratio + maxy += height * expand_ratio + + res = resolution / 111000.0 if self.output_crs == 'EPSG:4326' else resolution + nx = max(int(width / res), 100) + ny = max(int(height / res), 100) + grid_x = np.linspace(minx, maxx, nx) + grid_y = np.linspace(miny, maxy, ny) + grid_xx, grid_yy = np.meshgrid(grid_x, grid_y) + bounds = np.array([minx, miny, maxx, maxy]) + + print(f"[共享上下文] 网格: {nx}×{ny} = {nx*ny} 点") + + # ⑥ 水域掩膜布尔矩阵(只此一次) + mask = None + if boundary_gdf is not None: + mask_pts = np.column_stack((grid_xx.ravel(), grid_yy.ravel())) + mask_gdf = gpd.GeoDataFrame( + geometry=[Point(x, y) for x, y in mask_pts], crs=self.output_crs + ) + mask = mask_gdf.within(boundary_gdf.unary_union).values.reshape(grid_xx.shape) + print(f"[共享上下文] 水域掩膜: {int(mask.sum())}/{mask.size} 点在水域内") + + return (grid_xx, grid_yy, mask, bounds, boundary_gdf) + + def process_data(self, csv_file, shp_file=None, output_file='content_map.png', resolution=100, show_sample_points=False, base_map_tif=None, use_distance_diffusion=True, max_diffusion_distance=None, diffusion_power=2, diffusion_n_neighbors=15, cmap=None, expand_ratio=0.05, - output_format='tif'): + output_format='tif', + shared_context=None): """ 主处理函数 @@ -2778,56 +2851,72 @@ class ContentMapper: CSV文件路径 shp_file : str, optional 水域掩膜/边界文件路径(.shp / .dat / .bsq / .tif 等)。 - ★★★ None 时跳过所有边界相关逻辑,插值仅基于采样点自然扩展 ★★★ - base_map_tif : str, optional - TIF正射底图文件路径。如果提供,将在水域掩膜外显示底图 - use_distance_diffusion : bool, default=True - 是否使用距离扩散方法填充边界空白区域(shp_file=None 时无效) - max_diffusion_distance : float, optional - 最大扩散距离(单位与坐标相同)。如果为None,自动计算为网格分辨率的5倍 - diffusion_power : float, default=2 - 距离扩散的IDW幂参数,值越大,距离衰减越快 - diffusion_n_neighbors : int, default=15 - 距离扩散使用的最近邻点数 - cmap : str, optional - 颜色映射。如果为None,将从CSV文件名或内容中自动识别参数并选择对应的colormap - expand_ratio : float, default=0.05 - 边界外扩比例(5%),用于从采样点范围外扩出图像边界 - output_format : str, default='tif' - 输出格式:'tif'(GeoTIFF)或 'png'(渲染图) + shared_context : tuple, optional (2026-07-01 批量优化) + 由 prepare_shared_context() 返回的 (grid_xx, grid_yy, mask, bounds, boundary_gdf)。 + 提供时跳过 读边界/边缘外扩/建网格/算掩膜,直入 Kriging 插值阶段。 + ... (其他参数同上) """ try: # 自动识别参数名称并获取colormap if cmap is None: param_name = self._extract_param_name(csv_file) cmap = self._get_colormap(param_name) - else: - print(f"使用指定的颜色映射: {cmap}") # 读取采样点数据 points_gdf = self.read_csv_data(csv_file) - # ── Plan C: shp_file=None 时跳过所有水域掩膜逻辑 ─────────── - if shp_file is None: - print("[Plan C] shp_file=None,跳过水域掩膜读取,插值不依赖边界约束") - boundary_gdf = None + # ── ★ 快速通道:复用预计算的共享上下文 ── + if shared_context is not None: + grid_xx, grid_yy, mask, bounds, boundary_gdf = shared_context + # ③ 仍需边缘扩展(值相关),但跳过 ②④⑥ + points_gdf = self._expand_edge_points( + points_gdf, boundary_gdf, resolution=resolution, + expand_ratio=expand_ratio + ) + # ⑤ 直接用共享网格执行 Kriging + pts = np.column_stack((points_gdf['proj_x'], points_gdf['proj_y'])) + vals = points_gdf['content'].values + grid_content = self._perform_interpolation(pts, vals, grid_xx, grid_yy) + # ⑥ 复用共享掩膜裁剪 + if mask is not None: + grid_content[~mask] = np.nan + # 边界内 NaN 填充 + nan_mask = np.isnan(grid_content) + within_nan = nan_mask & mask + if np.any(within_nan): + valid_m = ~nan_mask & mask + if np.sum(valid_m) > 0: + v_pts = np.column_stack((grid_xx[valid_m], grid_yy[valid_m])) + v_vals = grid_content[valid_m] + n_pts = np.column_stack((grid_xx[within_nan], grid_yy[within_nan])) + try: + from scipy.interpolate import griddata + grid_content[within_nan] = griddata( + v_pts, v_vals, n_pts, method='nearest' + ) + except Exception: + grid_content[within_nan] = np.nanmean(grid_content[mask]) else: - boundary_gdf = self.read_boundary_shapefile(shp_file) + # ── 原有完整流程(单图模式)────────── + if shp_file is None: + print("[Plan C] shp_file=None,跳过水域掩膜读取") + boundary_gdf = None + else: + boundary_gdf = self.read_boundary_shapefile(shp_file) - # 对边缘采样点进行外扩处理(boundary_gdf=None 时基于采样点自身范围外扩) - points_gdf = self._expand_edge_points( - points_gdf, boundary_gdf, resolution=resolution, expand_ratio=expand_ratio - ) + points_gdf = self._expand_edge_points( + points_gdf, boundary_gdf, resolution=resolution, + expand_ratio=expand_ratio + ) - # 创建插值网格(boundary_gdf=None 时纯采样点插值,无掩膜裁剪) - grid_xx, grid_yy, grid_content, bounds = self.create_interpolation_grid( - points_gdf, boundary_gdf, resolution, - expand_ratio=expand_ratio, - use_distance_diffusion=use_distance_diffusion, - max_diffusion_distance=max_diffusion_distance, - diffusion_power=diffusion_power, - diffusion_n_neighbors=diffusion_n_neighbors - ) + grid_xx, grid_yy, grid_content, bounds = self.create_interpolation_grid( + points_gdf, boundary_gdf, resolution, + expand_ratio=expand_ratio, + use_distance_diffusion=use_distance_diffusion, + max_diffusion_distance=max_diffusion_distance, + diffusion_power=diffusion_power, + diffusion_n_neighbors=diffusion_n_neighbors + ) # ── 按 output_format 分发落盘方式 ─────────────────────────── if output_format == 'tif':