格式统一

This commit is contained in:
duxin
2026-07-01 17:33:52 +08:00
parent 1824d62d10
commit 385e5915af
5 changed files with 253 additions and 49 deletions

View File

@ -164,6 +164,24 @@ class LogManager(QObject):
if self._log_text is not None: if self._log_text is not None:
self._log_text.clear() 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 @property
def progress_bar(self) -> QProgressBar: def progress_bar(self) -> QProgressBar:
return self._progress_bar return self._progress_bar

View File

@ -17,6 +17,7 @@
import os import os
import sys import sys
from pathlib import Path
from PyQt5.QtWidgets import QWidget, QTabWidget, QScrollArea, QSpinBox, QDoubleSpinBox, QComboBox from PyQt5.QtWidgets import QWidget, QTabWidget, QScrollArea, QSpinBox, QDoubleSpinBox, QComboBox
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
@ -301,6 +302,16 @@ class PanelFactory:
if not os.path.exists(absolute_path): if not os.path.exists(absolute_path):
continue 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', { global_event_bus.publish('OutputUpdated', {
'step_id': dep_step, 'step_id': dep_step,
'output_type': output_type, 'output_type': output_type,

View File

@ -69,22 +69,72 @@ class Step11MapBatchThread(QThread):
except Exception: except Exception:
mpl_prev = None mpl_prev = None
try: try:
from src.core.steps.mapping_step import MappingStep from src.postprocessing.map import ContentMapper
n = len(self.csv_paths) 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): for i, csv_p in enumerate(self.csv_paths):
if self._cancelled: if self._cancelled:
self.log_message.emit("专题图批量任务已被用户取消", "warning") self.log_message.emit("专题图批量任务已被用户取消", "warning")
break break
self.progress.emit(i + 1, n) self.progress.emit(i + 1, n)
self.log_message.emit(f"专题图 [{i + 1}/{n}] {csv_p}", "info") 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) stem = Path(csv_p).stem
if self.output_dir_optional: output_file = (
stem = Path(csv_p).stem str(Path(self.output_dir_optional) / f'{stem}_distribution.png')
kw["output_image_path"] = str(Path(self.output_dir_optional) / f"{stem}_distribution.png") if self.output_dir_optional
else: else None
kw["output_image_path"] = None )
MappingStep.generate_distribution_map(**kw) # 已存在则跳过(兼容 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) self.finished_ok.emit(n)
except Exception as e: except Exception as e:
self.failed.emit(f"{e}\n{traceback.format_exc()}") self.failed.emit(f"{e}\n{traceback.format_exc()}")
@ -534,6 +584,7 @@ class Step11MapPanel(QWidget):
# ── 智能自动路由:预测 CSV 目录 ── # ── 智能自动路由:预测 CSV 目录 ──
if self.work_dir: if self.work_dir:
from src.gui.core.event_bus import global_event_bus
wd = self.work_dir wd = self.work_dir
done = False done = False
@ -542,10 +593,18 @@ class Step11MapPanel(QWidget):
pred_dir = resolve_subdir(wd, cand_dir_key) pred_dir = resolve_subdir(wd, cand_dir_key)
if pred_dir and os.path.isdir(pred_dir): if pred_dir and os.path.isdir(pred_dir):
csvs = list(Path(pred_dir).glob("*.csv")) 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: if csvs:
self.prediction_csv_dir_edit.setText(pred_dir) self.prediction_csv_dir_edit.setText(pred_dir)
self.batch_mode_combo.setCurrentIndex(1) self.batch_mode_combo.setCurrentIndex(1)
done = True done = True
global_event_bus.publish('LogMessage', {
'message': f'[Step11 自动路由] ✓ 使用 Step9 预测结果目录 ({len(csvs)} 个 CSV)',
'level': 'info',
})
break break
# Priority 2 (Fallback): Step 10 水色指数输出目录 # Priority 2 (Fallback): Step 10 水色指数输出目录
@ -554,12 +613,26 @@ class Step11MapPanel(QWidget):
wc_dir = resolve_subdir(wd, cand_dir_key) wc_dir = resolve_subdir(wd, cand_dir_key)
if wc_dir and os.path.isdir(wc_dir): if wc_dir and os.path.isdir(wc_dir):
csvs = list(Path(wc_dir).glob("*.csv")) 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: if csvs:
self.prediction_csv_dir_edit.setText(wc_dir) self.prediction_csv_dir_edit.setText(wc_dir)
self.batch_mode_combo.setCurrentIndex(1) self.batch_mode_combo.setCurrentIndex(1)
done = True done = True
global_event_bus.publish('LogMessage', {
'message': f'[Step11 自动路由] ✓ 使用 Step10 水色指数目录 ({len(csvs)} 个 CSV)',
'level': 'info',
})
break break
if not done:
global_event_bus.publish('LogMessage', {
'message': '[Step11 自动路由] ⚠ 未找到任何有效 CSV 目录(Step9 和 Step10 均为空或不存在)',
'level': 'warning',
})
# GeoTIFF 目录:指向 step10 水色指数输出 # GeoTIFF 目录:指向 step10 水色指数输出
geotiff_dir = resolve_subdir(wd, 'watercolor') geotiff_dir = resolve_subdir(wd, 'watercolor')
if geotiff_dir and os.path.isdir(geotiff_dir) and not self.geotiff_dir_edit.text().strip(): if geotiff_dir and os.path.isdir(geotiff_dir) and not self.geotiff_dir_edit.text().strip():

View File

@ -503,8 +503,21 @@ class WaterQualityGUI(QMainWindow):
try: try:
# 1. 触发懒加载生成面板 # 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 被后台任务异常永久锁定,强制撬开! # 🚨 核心防卡死补丁:如果目标 Tab 被后台任务异常永久锁定,强制撬开!
if not self._tab_widget.isTabEnabled(tab_index): if not self._tab_widget.isTabEnabled(tab_index):
self._log_manager.info(f"检测到 {item_data} 处于异常锁定状态,已执行强制解锁。") self._log_manager.info(f"检测到 {item_data} 处于异常锁定状态,已执行强制解锁。")

View File

@ -2761,12 +2761,85 @@ class ContentMapper:
print(f" NoData={nodata_value}, 有效像元: {int(valid_mask.sum())}/{grid_content.size}") print(f" NoData={nodata_value}, 有效像元: {int(valid_mask.sum())}/{grid_content.size}")
return output_tif_path 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', def process_data(self, csv_file, shp_file=None, output_file='content_map.png',
resolution=100, show_sample_points=False, base_map_tif=None, resolution=100, show_sample_points=False, base_map_tif=None,
use_distance_diffusion=True, max_diffusion_distance=None, use_distance_diffusion=True, max_diffusion_distance=None,
diffusion_power=2, diffusion_n_neighbors=15, cmap=None, diffusion_power=2, diffusion_n_neighbors=15, cmap=None,
expand_ratio=0.05, expand_ratio=0.05,
output_format='tif'): output_format='tif',
shared_context=None):
""" """
主处理函数 主处理函数
@ -2778,56 +2851,72 @@ class ContentMapper:
CSV文件路径 CSV文件路径
shp_file : str, optional shp_file : str, optional
水域掩膜/边界文件路径(.shp / .dat / .bsq / .tif 等)。 水域掩膜/边界文件路径(.shp / .dat / .bsq / .tif 等)。
★★★ None 时跳过所有边界相关逻辑,插值仅基于采样点自然扩展 ★★★ shared_context : tuple, optional (2026-07-01 批量优化)
base_map_tif : str, optional 由 prepare_shared_context() 返回的 (grid_xx, grid_yy, mask, bounds, boundary_gdf)。
TIF正射底图文件路径。如果提供,将在水域掩膜外显示底图 提供时跳过 读边界/边缘外扩/建网格/算掩膜,直入 Kriging 插值阶段。
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'(渲染图)
""" """
try: try:
# 自动识别参数名称并获取colormap # 自动识别参数名称并获取colormap
if cmap is None: if cmap is None:
param_name = self._extract_param_name(csv_file) param_name = self._extract_param_name(csv_file)
cmap = self._get_colormap(param_name) cmap = self._get_colormap(param_name)
else:
print(f"使用指定的颜色映射: {cmap}")
# 读取采样点数据 # 读取采样点数据
points_gdf = self.read_csv_data(csv_file) points_gdf = self.read_csv_data(csv_file)
# ── Plan C: shp_file=None 时跳过所有水域掩膜逻辑 ─────────── # ── ★ 快速通道:复用预计算的共享上下文 ──
if shp_file is None: if shared_context is not None:
print("[Plan C] shp_file=None,跳过水域掩膜读取,插值不依赖边界约束") grid_xx, grid_yy, mask, bounds, boundary_gdf = shared_context
boundary_gdf = None # ③ 仍需边缘扩展(值相关),但跳过 ②④⑥
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: 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 = self._expand_edge_points( points_gdf, boundary_gdf, resolution=resolution,
points_gdf, boundary_gdf, resolution=resolution, expand_ratio=expand_ratio expand_ratio=expand_ratio
) )
# 创建插值网格(boundary_gdf=None 时纯采样点插值,无掩膜裁剪) grid_xx, grid_yy, grid_content, bounds = self.create_interpolation_grid(
grid_xx, grid_yy, grid_content, bounds = self.create_interpolation_grid( points_gdf, boundary_gdf, resolution,
points_gdf, boundary_gdf, resolution, expand_ratio=expand_ratio,
expand_ratio=expand_ratio, use_distance_diffusion=use_distance_diffusion,
use_distance_diffusion=use_distance_diffusion, max_diffusion_distance=max_diffusion_distance,
max_diffusion_distance=max_diffusion_distance, diffusion_power=diffusion_power,
diffusion_power=diffusion_power, diffusion_n_neighbors=diffusion_n_neighbors
diffusion_n_neighbors=diffusion_n_neighbors )
)
# ── 按 output_format 分发落盘方式 ─────────────────────────── # ── 按 output_format 分发落盘方式 ───────────────────────────
if output_format == 'tif': if output_format == 'tif':