测试修改
This commit is contained in:
@ -41,6 +41,8 @@ import os
|
||||
import re
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import numpy as np # P0 修复: 写盘前 inf/-inf 替换与极值截断需要
|
||||
|
||||
|
||||
class WaterIndexCsvProcessor:
|
||||
"""
|
||||
@ -200,6 +202,24 @@ class WaterIndexCsvProcessor:
|
||||
for i, name in enumerate(targets):
|
||||
try:
|
||||
per_idx = results_df[name]
|
||||
# ===== P0 防御: 写盘前清洗(防 Step 11 Kriging / TIN 碎玻璃)=====
|
||||
# 1) inf / -inf → NaN:pandas 默认会把 inf 写成字面 "Infinity",
|
||||
# 下游 ContentMapper 严格按位置读第 3 列时会原样拿到 inf,
|
||||
# Kriging 变差函数被单一 inf 点拉飞、TIN 出现极长退化三角形。
|
||||
# 2) 物理范围截断 ±10:保守大窗,覆盖几乎所有经验公式的合法值域
|
||||
# (NDCI 类 ∈ [-1,1]、比值类 ∈ [0,2]、浓度反演类 ∈ [0, ∞))。
|
||||
# 注:保留所有行(包括 NaN),让下游 Step 11 把它当 missing marker;
|
||||
# dropna 会破坏行号对齐,故不在此调用;如确需剔除 NaN 行,
|
||||
# 在面板 / service 层读取后自行 .dropna(subset=[col])。
|
||||
n_inf = int(np.isinf(per_idx.values).sum())
|
||||
per_idx = (
|
||||
per_idx
|
||||
.replace([np.inf, -np.inf], np.nan)
|
||||
.clip(lower=-10.0, upper=10.0)
|
||||
)
|
||||
if n_inf > 0:
|
||||
print(f"[WaterIndexCsvProcessor] {name}: 替换 {n_inf} 个 inf/-inf → NaN")
|
||||
# ===== P0 防御结束 =====
|
||||
out_df = pd.DataFrame({
|
||||
"longitude": df["longitude"].values,
|
||||
"latitude": df["latitude"].values,
|
||||
|
||||
@ -51,7 +51,10 @@ class Step12KrigingHandler(BaseStepHandler):
|
||||
output_image_path=output_image_path,
|
||||
resolution=config.get('resolution', 30),
|
||||
input_crs=config.get('input_crs', 'EPSG:32651'),
|
||||
output_crs=config.get('output_crs', 'EPSG:4326'),
|
||||
# ★★★ 强制 output_crs = input_crs,避免 ContentMapper 把栅格重投影到 EPSG:4326 ★★★
|
||||
# 旧实现:output_crs=config.get('output_crs', 'EPSG:4326')
|
||||
# 重投影会让栅格和基于投影坐标的掩膜在 visualize_raster 叠加时发生仿射变换撕裂
|
||||
output_crs=config.get('input_crs', 'EPSG:32651'),
|
||||
show_sample_points=config.get('show_sample_points', False),
|
||||
base_map_tif=config.get('base_map_tif'),
|
||||
use_distance_diffusion=config.get('use_distance_diffusion', True),
|
||||
|
||||
@ -16,11 +16,14 @@ class MappingStep:
|
||||
@staticmethod
|
||||
def generate_distribution_map(
|
||||
prediction_csv_path: str,
|
||||
boundary_shp_path: str,
|
||||
boundary_shp_path: Optional[str] = None, # ★★★ Plan C: None = 不依赖水域掩膜 ★★★
|
||||
output_image_path: Optional[str] = None,
|
||||
resolution: float = 30,
|
||||
input_crs: str = "EPSG:32651",
|
||||
output_crs: str = "EPSG:4326",
|
||||
# ★★★ 强制默认 output_crs = input_crs,禁止重投影到 EPSG:4326 ★★★
|
||||
# 历史默认值 'EPSG:4326' 会让 ContentMapper 将插值栅格从投影坐标系转到经纬度坐标系,
|
||||
# 与基于 EPSG:32651 的水域掩膜叠加时发生仿射变换撕裂(栅格错位、坐标轴扭曲)。
|
||||
output_crs: str = "EPSG:32651",
|
||||
show_sample_points: bool = False,
|
||||
base_map_tif: Optional[str] = None,
|
||||
use_distance_diffusion: bool = True,
|
||||
@ -31,13 +34,14 @@ class MappingStep:
|
||||
expand_ratio: float = 0.05,
|
||||
output_dir: Union[str, Path] = "./14_visualization",
|
||||
callback: Optional[Callable] = None,
|
||||
output_format: str = 'tif', # ⭐ 新增:'tif' (默认, 写 GeoTIFF) / 'png'
|
||||
) -> str:
|
||||
"""
|
||||
根据采样点的坐标和反演的实测参数,通过插值方法得到水质参数可视化分布图
|
||||
|
||||
Args:
|
||||
prediction_csv_path: 预测结果CSV文件路径(前两列为经纬度,第三列为预测值)
|
||||
boundary_shp_path: 边界shapefile文件路径
|
||||
boundary_shp_path: 边界/掩膜文件路径(.shp/.dat/.bsq/.tif 等)。None 时跳过水域掩膜约束。
|
||||
output_image_path: 输出图片路径(如果为None,自动生成)
|
||||
resolution: 插值网格分辨率(米)
|
||||
input_crs: 输入坐标系
|
||||
@ -89,6 +93,7 @@ class MappingStep:
|
||||
"diffusion_power": diffusion_power,
|
||||
"diffusion_n_neighbors": diffusion_n_neighbors,
|
||||
"expand_ratio": expand_ratio,
|
||||
"output_format": output_format, # ⭐ 透传给 ContentMapper.process_data
|
||||
}
|
||||
|
||||
optional_kwargs = {
|
||||
|
||||
@ -201,7 +201,7 @@ PANEL_REGISTRY = [
|
||||
'title': '专题图生成',
|
||||
'icon': '10.png',
|
||||
'stage': '模块四 制图与成果汇编',
|
||||
'display_name': '11. 专题图生成',
|
||||
'display_name': '11. 分布图生成',
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
|
||||
'dependencies': {
|
||||
# 目标框: self.prediction_csv_dir_edit ← 上游 step9_ml_predict.output_file
|
||||
|
||||
@ -290,7 +290,10 @@ class Step11MapPanel(QWidget):
|
||||
params_layout.addRow("输入坐标系:", self.input_crs)
|
||||
|
||||
self.output_crs = QLineEdit()
|
||||
self.output_crs.setText("EPSG:4326")
|
||||
# ★★★ 强制默认输出坐标系与输入一致,禁止从 GUI 误改为 EPSG:4326 ★★★
|
||||
# 历史默认值 'EPSG:4326' 会让 ContentMapper 把栅格重投影到经纬度,
|
||||
# 与基于 EPSG:32651 的水域掩膜叠加时发生仿射变换撕裂(栅格错位、坐标轴扭曲)。
|
||||
self.output_crs.setText("EPSG:32651")
|
||||
params_layout.addRow("输出坐标系:", self.output_crs)
|
||||
|
||||
self.show_points = QCheckBox("显示采样点")
|
||||
@ -416,7 +419,9 @@ class Step11MapPanel(QWidget):
|
||||
'boundary_shp_path': self.boundary_file.get_path(),
|
||||
'resolution': self.resolution.value(),
|
||||
'input_crs': self.input_crs.text(),
|
||||
'output_crs': self.output_crs.text(),
|
||||
# ★★★ 强制 output_crs = input_crs,不再信任 GUI 的 output_crs 输入框 ★★★
|
||||
# 否则 ContentMapper 会把栅格重投影到 EPSG:4326,与水域掩膜叠加时撕裂。
|
||||
'output_crs': self.input_crs.text(),
|
||||
'show_sample_points': self.show_points.isChecked(),
|
||||
'use_distance_diffusion': self.use_diffusion.isChecked(),
|
||||
}
|
||||
@ -437,7 +442,8 @@ class Step11MapPanel(QWidget):
|
||||
'boundary_shp_path': self.boundary_file.get_path(),
|
||||
'resolution': self.resolution.value(),
|
||||
'input_crs': self.input_crs.text(),
|
||||
'output_crs': self.output_crs.text(),
|
||||
# ★★★ 强制 output_crs = input_crs,不再信任 GUI 的 output_crs 输入框 ★★★
|
||||
'output_crs': self.input_crs.text(),
|
||||
'show_sample_points': self.show_points.isChecked(),
|
||||
'use_distance_diffusion': self.use_diffusion.isChecked(),
|
||||
}
|
||||
@ -475,8 +481,9 @@ class Step11MapPanel(QWidget):
|
||||
self.resolution.setValue(config['resolution'])
|
||||
if 'input_crs' in config:
|
||||
self.input_crs.setText(config['input_crs'])
|
||||
if 'output_crs' in config:
|
||||
self.output_crs.setText(config['output_crs'])
|
||||
# ★★★ 反灌入时强制 output_crs = input_crs,避免旧 config 中的 EPSG:4326 回填 ★★★
|
||||
if 'output_crs' in config or 'input_crs' in config:
|
||||
self.output_crs.setText(config.get('input_crs') or config.get('output_crs') or 'EPSG:32651')
|
||||
if 'show_sample_points' in config:
|
||||
self.show_points.setChecked(config['show_sample_points'])
|
||||
if 'use_distance_diffusion' in config:
|
||||
@ -573,10 +580,8 @@ class Step11MapPanel(QWidget):
|
||||
return
|
||||
|
||||
boundary_shp_path = self.boundary_file.get_path()
|
||||
if not boundary_shp_path:
|
||||
QMessageBox.warning(self, "输入验证失败", "请选择边界文件")
|
||||
return
|
||||
if not os.path.exists(boundary_shp_path):
|
||||
# ── Plan C: 允许 boundary_shp_path 为空(跳过水域掩膜,纯采样点插值)────
|
||||
if boundary_shp_path and not os.path.exists(boundary_shp_path):
|
||||
QMessageBox.warning(self, "输入验证失败", "边界文件不存在")
|
||||
return
|
||||
|
||||
@ -631,7 +636,8 @@ class Step11MapPanel(QWidget):
|
||||
output_dir=out_dir,
|
||||
boundary_shp_path=boundary_shp_path,
|
||||
input_crs=self.input_crs.text(),
|
||||
output_crs=self.output_crs.text(),
|
||||
# ★★★ 强制 output_crs = input_crs,不再信任 GUI 的 output_crs 输入框 ★★★
|
||||
output_crs=self.input_crs.text(),
|
||||
)
|
||||
main_win = parent
|
||||
|
||||
@ -671,7 +677,8 @@ class Step11MapPanel(QWidget):
|
||||
|
||||
boundary_shp_path = self.boundary_file.get_path()
|
||||
input_crs = self.input_crs.text()
|
||||
output_crs = self.output_crs.text()
|
||||
# ★★★ 强制 output_crs = input_crs,不再信任 GUI 的 output_crs 输入框 ★★★
|
||||
output_crs = self.input_crs.text()
|
||||
|
||||
# 构造输出路径
|
||||
out_dir = (self.output_dir.get_path() or "").strip()
|
||||
|
||||
@ -440,6 +440,62 @@ class VisualizationWorkerThread(QThread):
|
||||
parts.append(f"浓度统计图: 失败({e})")
|
||||
else:
|
||||
parts.append("浓度统计图: 跳过(无浓度CSV)")
|
||||
|
||||
if self.extra.get("gen_distribution_map"):
|
||||
dist_dir = wp / "11_Thematic_Map"
|
||||
out_sub = Path(viz.output_dir) / "distribution_maps"
|
||||
out_sub.mkdir(parents=True, exist_ok=True)
|
||||
n_rendered = 0
|
||||
if dist_dir.exists():
|
||||
import shutil
|
||||
# 1. 拷贝现成的 PNG
|
||||
for png in list(dist_dir.glob("*_distribution.png")) + list(dist_dir.glob("*_专题图.png")):
|
||||
try:
|
||||
shutil.copy2(png, out_sub / png.name)
|
||||
n_rendered += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. 渲染生成的 TIF
|
||||
tif_files = list(dist_dir.glob("*_distribution.tif")) + list(dist_dir.glob("*_kriging.tif"))
|
||||
if tif_files:
|
||||
from src.postprocessing.map import ContentMapper
|
||||
mapper = ContentMapper()
|
||||
# 优先级1:直接使用 Step 1 面板中缓存的外部原始 .shp 绝对路径!
|
||||
boundary_path = self.extra.get("boundary_shp_path")
|
||||
|
||||
# 优先级2:如果没拿到,全局搜索整个工作目录下的 .shp 文件(放宽限制)
|
||||
if not boundary_path:
|
||||
shp_candidates = list(wp.rglob("**/*.shp"))
|
||||
if shp_candidates:
|
||||
boundary_path = str(shp_candidates[0])
|
||||
|
||||
# 优先级3:兜底使用 1_water_mask 下的栅格掩膜
|
||||
if not boundary_path:
|
||||
mask_files = list(wp.rglob("1_water_mask/*"))
|
||||
other_candidates = [f for f in mask_files if f.suffix.lower() in ('.dat', '.bsq', '.tif', '.tiff')]
|
||||
if other_candidates:
|
||||
boundary_path = str(other_candidates[0])
|
||||
if not boundary_path:
|
||||
print(f"[distribution_maps] 未找到水域边界文件,跳过裁剪")
|
||||
for tif in tif_files:
|
||||
dst_png = out_sub / f"{tif.stem}_rendered.png"
|
||||
try:
|
||||
mapper.visualize_raster(
|
||||
raster_tif_path=str(tif),
|
||||
output_file=str(dst_png),
|
||||
boundary_shp_path=boundary_path,
|
||||
nodata_value=-9999.0,
|
||||
figsize=(14, 10),
|
||||
alpha=0.9
|
||||
)
|
||||
n_rendered += 1
|
||||
except Exception as e:
|
||||
print(f"渲染 TIF 失败 {tif.name}: {e}")
|
||||
parts.append(f"空间分布图: {n_rendered} 个")
|
||||
else:
|
||||
parts.append("空间分布图: 跳过(无 11_Thematic_Map 目录)")
|
||||
|
||||
self.finished_ok.emit({"task": "generate_all_selected", "parts": parts})
|
||||
else:
|
||||
self.failed.emit(f"未知可视化任务: {self.task}")
|
||||
@ -574,272 +630,104 @@ class ChartViewerDialog(QDialog):
|
||||
|
||||
|
||||
class ImageCategoryTree(QTreeWidget):
|
||||
"""图像分类目录树 - 按真实物理文件夹结构组织图像文件"""
|
||||
|
||||
# 文件名中文翻译映射(key: 文件名前缀 → 中文显示名)
|
||||
NAME_MAPPING = {
|
||||
"hsi_preview": "高光谱影像预览",
|
||||
"hsi_original": "原始高光谱影像",
|
||||
"hsi_deglint": "去耀斑高光谱影像",
|
||||
"water_mask_overlay": "水域掩膜叠加图",
|
||||
"water_mask": "水域掩膜图",
|
||||
"glint_mask": "耀斑掩膜预览",
|
||||
"glint_overlay": "耀斑叠加对比图",
|
||||
"deglint_comparison": "去耀斑前后对比",
|
||||
"training_spectra": "训练光谱特征",
|
||||
"spectrum_by_param": "参数光谱图",
|
||||
"model_evaluation": "模型评估散点图",
|
||||
"model_scatter": "模型散点图",
|
||||
"regression": "回归分析图",
|
||||
"validation": "验证结果图",
|
||||
"spatial_distribution": "参数空间分布图",
|
||||
"distribution_map": "分布图",
|
||||
"thematic_map": "水质专题图",
|
||||
"water_quality_map": "水质分布图",
|
||||
"prediction_map": "预测结果图",
|
||||
"inversion_map": "反演结果图",
|
||||
"correlation_matrix": "特征相关性矩阵",
|
||||
"feature_correlation": "特征相关性",
|
||||
"sampling_point_map": "采样点分布图",
|
||||
"sampling_points": "采样点图",
|
||||
"point_locations": "采样位置图",
|
||||
"boxplot": "箱线图",
|
||||
"histogram": "直方图",
|
||||
"statistics": "统计图表",
|
||||
"statistical_chart": "统计图",
|
||||
"error_analysis": "误差分析图",
|
||||
"rmse": "RMSE评估图",
|
||||
"r2_score": "R²得分图",
|
||||
"flight": "飞行轨迹图",
|
||||
"path": "轨迹图",
|
||||
"trajectory": "轨迹图",
|
||||
"glint_deglint": "耀斑去耀斑影像",
|
||||
"enhanced": "增强分布图",
|
||||
"content": "含量分布图",
|
||||
"distribution": "分布图",
|
||||
"prediction": "预测图",
|
||||
"inversion": "反演图",
|
||||
"scatter_true_vs_pred": "真值-预测散点图",
|
||||
"true_vs_pred": "真值-预测散点图",
|
||||
"correlation_heatmap": "相关性热力图",
|
||||
"parameter_boxplot": "水质参数箱线图",
|
||||
"spectrum_comparison": "光谱曲线对比图",
|
||||
"scatter": "散点图",
|
||||
}
|
||||
|
||||
# 目录层级中文翻译
|
||||
DIR_MAPPING = {
|
||||
"14_visualization": "统计与分析报表",
|
||||
"1_water_mask": "水域掩膜识别",
|
||||
"2_Glint_Detection": "耀斑区域检测",
|
||||
"3_deglint": "去耀斑影像结果",
|
||||
"5_training_spectra": "训练光谱特征",
|
||||
"8_Regression_Modeling": "回归建模分析",
|
||||
"9_water_quality_prediction": "水质预测结果",
|
||||
"10_feature_construction": "特征构建散点",
|
||||
"11_12_13_predictions": "空间分布专题图",
|
||||
"glint_deglint_previews": "耀斑处理预览",
|
||||
"sampling_maps": "采样点空间分布",
|
||||
"flight_maps": "无人机飞行轨迹",
|
||||
"9_ML_Prediction": "机器学习预测",
|
||||
"Non_Empirical_Prediction": "非经验模型预测",
|
||||
"Custom_Regression_Prediction": "自定义回归预测",
|
||||
"boxplot_dir": "水质参数箱线图",
|
||||
"boxplot": "水质参数箱线图",
|
||||
"output_dir": "输出目录",
|
||||
"8_spatial_inversion": "空间反演",
|
||||
"4_processed_data": "处理数据",
|
||||
"9_Concentration": "物理反演浓度分布",
|
||||
}
|
||||
"""现代化的图像分类目录树 - 支持智能归类、筛选和高颜值样式"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._dir_node_map: dict = {} # 目录路径字符串 → QTreeWidgetItem
|
||||
self._work_path: Optional[Path] = None
|
||||
self.setHeaderLabel("图像目录")
|
||||
self.setMaximumWidth(300)
|
||||
self.setMinimumWidth(250)
|
||||
self._work_path = None
|
||||
self._all_image_files = [] # 缓存所有扫描到的图片路径
|
||||
|
||||
self.setHeaderHidden(True) # 隐藏表头,显得更清爽
|
||||
self.setAlternatingRowColors(True) # 斑马纹交替背景
|
||||
self.setMaximumWidth(340)
|
||||
self.setMinimumWidth(280)
|
||||
|
||||
# 核心:高颜值现代化 CSS 样式
|
||||
self.setStyleSheet("""
|
||||
QTreeWidget {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
background-color: #FFFFFF;
|
||||
alternate-background-color: #F8FAFC;
|
||||
font-family: "Microsoft YaHei", "Segoe UI";
|
||||
font-size: 13px;
|
||||
padding: 4px;
|
||||
}
|
||||
QTreeWidget::item {
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QTreeWidget::item:selected {
|
||||
background-color: #0078D4;
|
||||
color: white;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
margin: 2px 4px;
|
||||
}
|
||||
QTreeWidget::item:hover {
|
||||
background-color: #e3f2fd;
|
||||
background-color: #F1F5F9;
|
||||
}
|
||||
QTreeWidget::item:selected {
|
||||
background-color: #E0F2FE;
|
||||
color: #0369A1;
|
||||
font-weight: bold;
|
||||
}
|
||||
QTreeWidget::branch:has-children:!has-siblings:closed,
|
||||
QTreeWidget::branch:closed:has-children:has-siblings {
|
||||
border-image: none;
|
||||
image: none;
|
||||
}
|
||||
QTreeWidget::branch:open:has-children:!has-siblings,
|
||||
QTreeWidget::branch:open:has-children:has-siblings {
|
||||
border-image: none;
|
||||
image: none;
|
||||
}
|
||||
""")
|
||||
|
||||
def clear_all_images(self):
|
||||
"""清除所有图像项"""
|
||||
try:
|
||||
self.invisibleRootItem().takeChildren()
|
||||
if hasattr(self, '_dir_node_map'):
|
||||
self._dir_node_map.clear()
|
||||
except Exception as e:
|
||||
print(f"清空树状图出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
def _parse_file_info(self, file_path: Path):
|
||||
"""智能解析文件名,提取【水质参数】和【图表类型】"""
|
||||
name_upper = file_path.name.upper()
|
||||
|
||||
def _translate_dir_name(self, dir_name: str) -> str:
|
||||
"""翻译目录名为中文"""
|
||||
return self.DIR_MAPPING.get(dir_name, dir_name)
|
||||
# 1. 提取图表类型
|
||||
chart_type = "其他图表"
|
||||
if "DISTRIBUTION" in name_upper or "专题图" in name_upper or "RENDERED" in name_upper:
|
||||
chart_type = "空间分布图"
|
||||
elif "SCATTER" in name_upper or "散点" in name_upper:
|
||||
chart_type = "模型散点图"
|
||||
elif "SPECTRUM" in name_upper or "光谱" in name_upper:
|
||||
chart_type = "光谱曲线图"
|
||||
elif "HEATMAP" in name_upper or "热力图" in name_upper:
|
||||
chart_type = "相关性热力图"
|
||||
elif "BOXPLOT" in name_upper or "HISTOGRAM" in name_upper or "箱线" in name_upper or "直方" in name_upper:
|
||||
chart_type = "统计箱线图"
|
||||
elif "SAMPLING" in name_upper or "采样" in name_upper:
|
||||
chart_type = "采样点地图"
|
||||
elif "GLINT" in name_upper or "MASK" in name_upper or "PREVIEW" in name_upper:
|
||||
chart_type = "掩膜与预览"
|
||||
|
||||
def _translate_filename(self, filename: str) -> str:
|
||||
# 1. 后缀替换 (图表类型)
|
||||
type_mapping = {
|
||||
'_scatter_true_vs_pred': ' 真值预测散点图',
|
||||
'_spectrum_comparison': ' 光谱曲线对比图',
|
||||
'_spectrum': ' 光谱特征图',
|
||||
'_histogram': ' 分布直方图',
|
||||
'_boxplot_seaborn': ' Seaborn箱线图',
|
||||
'_boxplot': ' 箱线图',
|
||||
'_distribution_enhanced': ' 增强空间分布图',
|
||||
'_distribution': ' 空间分布图',
|
||||
'_sampling_map': ' 采样点地图',
|
||||
'_flight_paths': ' 飞行轨迹图',
|
||||
'_preview': ' 效果预览图',
|
||||
'water_mask_overlay': '水域掩膜叠加图',
|
||||
'hsi_preview': '原始影像预览',
|
||||
'correlation_heatmap': '特征相关性热力图',
|
||||
'parameter_boxplot': '水质参数汇总箱线图',
|
||||
'all_parameters_boxplot': '全参数汇总箱线图',
|
||||
'content_map': '含量分布专题图',
|
||||
'_scatter_with_confidence': ' 置信区间散点图'
|
||||
# 2. 提取参数名
|
||||
param_name = "综合/未分类"
|
||||
# 常见水质参数字典映射
|
||||
params_map = {
|
||||
'CHLOROPHYLL': 'Chlorophyll (叶绿素)', 'CHL_A': 'Chlorophyll (叶绿素)', 'CHLA': 'Chlorophyll (叶绿素)',
|
||||
'COD': 'COD (化学需氧量)', 'DO': 'DO (溶解氧)', 'PH': 'pH',
|
||||
'TEMPERATURE': 'Temperature (温度)', 'SPCOND': 'spCond (电导率)',
|
||||
'TURBIDITY': 'Turbidity (浊度)', 'TDS': 'TDS (总溶解固体)',
|
||||
'CL-': 'Cl- (氯离子)', 'NO3-N': 'NO3-N (硝态氮)', 'NH3-N': 'NH3-N (氨氮)',
|
||||
'BGA': 'BGA (蓝绿藻)', 'TT': 'TT (透明度)'
|
||||
}
|
||||
for key, display_name in params_map.items():
|
||||
if key in name_upper or key.replace('-', '') in name_upper:
|
||||
param_name = display_name
|
||||
break
|
||||
|
||||
name = filename
|
||||
for eng, chn in type_mapping.items():
|
||||
if eng in name:
|
||||
name = name.replace(eng, chn)
|
||||
|
||||
# 2. 常见水质参数前缀替换
|
||||
param_mapping = {
|
||||
'Chlorophyll': '叶绿素', 'Chl_a': '叶绿素a', 'Chla': '叶绿素a',
|
||||
'Turbidity': '浊度', 'Temperature': '温度', 'spCond': '电导率',
|
||||
'COD': '化学需氧量', 'DO': '溶解氧', 'PH': 'pH值', 'TDS': '总溶解固体',
|
||||
'BGA': '蓝绿藻', 'TT': '透明度', 'NH3-N': '氨氮', 'NO3-N': '硝酸盐氮',
|
||||
'glint_severe_glint_area': '重度耀斑区域',
|
||||
'severe_glint_area': '重度耀斑区域',
|
||||
'deglint_goodman': 'Goodman算法去耀斑',
|
||||
'deglint_Goodman': 'Goodman算法去耀斑',
|
||||
'glint_': '耀斑检测_',
|
||||
'deglint_': '耀斑去除_',
|
||||
}
|
||||
|
||||
for eng, chn in param_mapping.items():
|
||||
if name.startswith(eng + ' ') or name.startswith(eng + '_'):
|
||||
name = name.replace(eng, chn, 1)
|
||||
elif eng in name:
|
||||
name = name.replace(eng, chn)
|
||||
|
||||
return name.strip('_')
|
||||
|
||||
def add_image_by_dir(self, file_path: Path, work_path: Path):
|
||||
"""按真实物理目录层级挂载图片节点
|
||||
|
||||
Args:
|
||||
file_path: 图片文件的完整路径
|
||||
work_path: 工作目录根路径
|
||||
"""
|
||||
# 计算相对路径
|
||||
try:
|
||||
rel_path = file_path.relative_to(work_path)
|
||||
except ValueError:
|
||||
rel_path = Path(file_path.name)
|
||||
|
||||
# 分离父目录链和文件名
|
||||
parts = rel_path.parts
|
||||
if len(parts) <= 1:
|
||||
parent_key = "__root__"
|
||||
parent_display = "根目录"
|
||||
else:
|
||||
# 父目录路径(相对于work_path)
|
||||
parent_key = str(Path(*parts[:-1]))
|
||||
# 取最后一层目录名作为显示名
|
||||
parent_display = self._translate_dir_name(parts[-2])
|
||||
|
||||
# 根目录节点特殊处理
|
||||
root_display = self._translate_dir_name(parts[0]) if parts else "根目录"
|
||||
|
||||
# 获取或创建根目录节点
|
||||
if root_display not in self._dir_node_map:
|
||||
root_item = QTreeWidgetItem(self)
|
||||
root_item.setText(0, f"📁 {root_display}")
|
||||
root_item.setData(0, Qt.UserRole, {"type": "root_dir", "path": str(work_path / parts[0])})
|
||||
root_item.setExpanded(True)
|
||||
self._dir_node_map[root_display] = root_item
|
||||
self._dir_node_map[f"__root__{root_display}"] = root_item
|
||||
root_item = self._dir_node_map.get(f"__root__{root_display}")
|
||||
|
||||
if len(parts) > 1:
|
||||
# 获取或创建子目录节点
|
||||
if parent_key not in self._dir_node_map:
|
||||
dir_item = QTreeWidgetItem(root_item)
|
||||
dir_item.setText(0, f" 📂 {parent_display}")
|
||||
dir_item.setData(0, Qt.UserRole, {"type": "sub_dir", "path": str(work_path / parent_key)})
|
||||
dir_item.setExpanded(True)
|
||||
self._dir_node_map[parent_key] = dir_item
|
||||
parent_item = self._dir_node_map[parent_key]
|
||||
else:
|
||||
parent_item = root_item
|
||||
|
||||
# 创建图片节点(根据翻译后的名称分配图标)
|
||||
display_name = self._translate_filename(file_path.stem) + file_path.suffix
|
||||
icon = "🖼️" # 默认
|
||||
if "散点" in display_name:
|
||||
icon = "📊"
|
||||
elif "光谱" in display_name or "曲线" in display_name:
|
||||
icon = "📈"
|
||||
elif "箱线" in display_name or "直方" in display_name:
|
||||
icon = "📉"
|
||||
elif "分布" in display_name or "地图" in display_name or "轨迹" in display_name:
|
||||
icon = "🗺️"
|
||||
image_item = QTreeWidgetItem(parent_item)
|
||||
image_item.setText(0, f" {icon} {display_name}")
|
||||
image_item.setData(0, Qt.UserRole, {"type": "image", "path": str(file_path), "display_name": display_name})
|
||||
image_item.setToolTip(0, str(file_path))
|
||||
|
||||
return image_item
|
||||
return param_name, chart_type
|
||||
|
||||
def scan_directory(self, work_dir: str):
|
||||
"""扫描目录中的所有图像文件(深度递归扫描)—— 按真实物理目录结构挂载"""
|
||||
"""全量扫描文件并缓存(不直接构建树,而是交给 rebuild_tree 渲染)"""
|
||||
try:
|
||||
if not work_dir:
|
||||
print("可视化面板:工作目录为空,跳过扫描")
|
||||
return
|
||||
|
||||
if not work_dir: return
|
||||
self._work_path = Path(work_dir)
|
||||
if not self._work_path.exists(): return
|
||||
|
||||
# 阻塞信号,防止在清空树状图时触发 selected 槽函数导致崩溃
|
||||
# 因为当前类继承自 QTreeWidget,所以 self 本身就是树
|
||||
self.blockSignals(True)
|
||||
self.clear_all_images()
|
||||
self.blockSignals(False)
|
||||
|
||||
if not self._work_path.exists():
|
||||
return
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"可视化面板初始化扫描出错: {e}")
|
||||
traceback.print_exc()
|
||||
# 确保信号锁被解开
|
||||
self.blockSignals(False)
|
||||
return
|
||||
|
||||
try:
|
||||
image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp']
|
||||
|
||||
# 拓宽扫描根目录列表(新增多个遗漏目录)
|
||||
scan_roots: List[Path] = [
|
||||
# 仅扫描用于视觉展示的常规图片格式,屏蔽科学栅格 TIF 以免无法渲染报错
|
||||
image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.bmp']
|
||||
# 扩展扫描路径
|
||||
scan_roots = [
|
||||
Path(resolve_subdir(str(self._work_path), 'visualization')),
|
||||
Path(resolve_subdir(str(self._work_path), 'prediction_dir')),
|
||||
Path(resolve_subdir(str(self._work_path), 'regression_modeling')),
|
||||
@ -850,51 +738,98 @@ class ImageCategoryTree(QTreeWidget):
|
||||
Path(resolve_subdir(str(self._work_path), 'water_mask')),
|
||||
self._work_path / "9_water_quality_prediction",
|
||||
self._work_path / "9_Concentration",
|
||||
self._work_path / "11_Thematic_Map"
|
||||
]
|
||||
|
||||
# 只保留存在的目录,并补充工作根目录作为兜底
|
||||
scan_roots = [p for p in scan_roots if p.is_dir()]
|
||||
if not scan_roots:
|
||||
scan_roots.append(self._work_path)
|
||||
if not scan_roots: scan_roots.append(self._work_path)
|
||||
|
||||
seen_norm = set()
|
||||
self._all_image_files = []
|
||||
|
||||
seen_norm: set = set()
|
||||
image_files: List[Path] = []
|
||||
for root in scan_roots:
|
||||
for ext in image_extensions:
|
||||
for p in root.rglob(ext):
|
||||
key = os.path.normcase(os.path.normpath(str(p.resolve())))
|
||||
if key in seen_norm:
|
||||
continue
|
||||
if key in seen_norm: continue
|
||||
seen_norm.add(key)
|
||||
image_files.append(p)
|
||||
if p.name.startswith('.') or 'thumb' in p.name.lower(): continue
|
||||
self._all_image_files.append(p)
|
||||
|
||||
for img_file in sorted(image_files):
|
||||
if img_file.name.startswith('.') or 'thumb' in img_file.name.lower():
|
||||
continue
|
||||
self.add_image_by_dir(img_file, self._work_path)
|
||||
|
||||
# 更新目录节点计数
|
||||
for key, item in self._dir_node_map.items():
|
||||
if key.startswith("__root__"):
|
||||
continue
|
||||
if item.data(0, Qt.UserRole).get("type") == "sub_dir":
|
||||
count = item.childCount()
|
||||
name = item.text(0)
|
||||
if count > 0 and f"({count})" not in name:
|
||||
# 从目录名中提取显示名并附加计数
|
||||
display = name.strip()
|
||||
item.setText(0, f" 📂 {display} ({count})")
|
||||
self._all_image_files.sort(key=lambda x: x.name)
|
||||
# 默认构建模式
|
||||
self.rebuild_tree(group_mode='parameter', filter_type='all')
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"可视化面板图片挂载出错: {e}")
|
||||
traceback.print_exc()
|
||||
print(f"目录扫描出错: {e}")
|
||||
|
||||
def rebuild_tree(self, group_mode='parameter', filter_type='all'):
|
||||
"""根据下拉框的【模式】和【筛选条件】实时重新构建 UI 树"""
|
||||
self.blockSignals(True)
|
||||
self.clear()
|
||||
root_nodes = {}
|
||||
|
||||
for img_file in self._all_image_files:
|
||||
param, chart_type = self._parse_file_info(img_file)
|
||||
|
||||
# 1. 应用筛选器逻辑
|
||||
if filter_type != 'all' and filter_type != chart_type:
|
||||
continue
|
||||
|
||||
# 2. 决定分组基准
|
||||
if group_mode == 'parameter':
|
||||
group_key = param
|
||||
group_icon = "💧" if param != "综合/未分类" else "📁"
|
||||
display_name = f"[{chart_type}] {img_file.name}"
|
||||
elif group_mode == 'type':
|
||||
group_key = chart_type
|
||||
group_icon = "📊"
|
||||
display_name = f"[{param.split(' ')[0]}] {img_file.name}"
|
||||
else:
|
||||
# 物理文件夹原样模式
|
||||
try:
|
||||
rel_path = img_file.relative_to(self._work_path)
|
||||
group_key = str(rel_path.parent) if len(rel_path.parts) > 1 else "根目录"
|
||||
except:
|
||||
group_key = "其他"
|
||||
group_icon = "📂"
|
||||
display_name = img_file.name
|
||||
|
||||
# 3. 创建父节点
|
||||
if group_key not in root_nodes:
|
||||
root_item = QTreeWidgetItem(self)
|
||||
root_item.setText(0, f"{group_icon} {group_key}")
|
||||
root_item.setExpanded(True)
|
||||
font = root_item.font(0)
|
||||
font.setBold(True)
|
||||
root_item.setFont(0, font)
|
||||
root_item.setData(0, Qt.UserRole, {"type": "root"})
|
||||
root_nodes[group_key] = root_item
|
||||
|
||||
parent_item = root_nodes[group_key]
|
||||
|
||||
# 4. 挂载子节点及专属图标
|
||||
icon = "🖼️"
|
||||
if "散点" in chart_type: icon = "📌"
|
||||
elif "光谱" in chart_type: icon = "📈"
|
||||
elif "分布" in chart_type: icon = "🗺️"
|
||||
elif "箱线" in chart_type: icon = "📉"
|
||||
|
||||
image_item = QTreeWidgetItem(parent_item)
|
||||
image_item.setText(0, f" {icon} {display_name}")
|
||||
image_item.setData(0, Qt.UserRole, {"type": "image", "path": str(img_file)})
|
||||
image_item.setToolTip(0, str(img_file))
|
||||
|
||||
# 统计数量
|
||||
for i in range(self.topLevelItemCount()):
|
||||
root_item = self.topLevelItem(i)
|
||||
count = root_item.childCount()
|
||||
old_text = root_item.text(0)
|
||||
root_item.setText(0, f"{old_text} ({count})")
|
||||
|
||||
self.blockSignals(False)
|
||||
|
||||
def get_selected_image_path(self) -> Optional[str]:
|
||||
"""获取当前选中的图像路径"""
|
||||
selected_item = self.currentItem()
|
||||
if not selected_item:
|
||||
return None
|
||||
|
||||
if not selected_item: return None
|
||||
data = selected_item.data(0, Qt.UserRole)
|
||||
if data and data.get("type") == "image":
|
||||
return data.get("path")
|
||||
@ -1401,17 +1336,19 @@ class Step12VizPanel(QWidget):
|
||||
QMessageBox.critical(self, "错误", f"可视化任务失败:\n{err[:1200]}")
|
||||
|
||||
def init_ui(self):
|
||||
"""初始化UI - 使用左右分栏布局"""
|
||||
"""初始化UI - 使用全新的三列布局(控制参数 | 独立满高目录树 | 图像查看器)"""
|
||||
main_layout = QHBoxLayout()
|
||||
main_layout.setSpacing(10)
|
||||
main_layout.setSpacing(12)
|
||||
main_layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
# ===== 左侧面板 =====
|
||||
left_panel = QWidget()
|
||||
left_layout = QVBoxLayout()
|
||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# ==========================================
|
||||
# 第一列:控制面板(目录选择 + 生成配置)
|
||||
# ==========================================
|
||||
control_panel = QWidget()
|
||||
control_layout = QVBoxLayout()
|
||||
control_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# 工作目录选择
|
||||
# 1. 工作目录选择
|
||||
dir_group = QGroupBox("工作目录")
|
||||
dir_layout = QHBoxLayout()
|
||||
self.work_dir_edit = QLineEdit()
|
||||
@ -1422,9 +1359,9 @@ class Step12VizPanel(QWidget):
|
||||
dir_layout.addWidget(self.work_dir_edit, 1)
|
||||
dir_layout.addWidget(dir_browse_btn)
|
||||
dir_group.setLayout(dir_layout)
|
||||
left_layout.addWidget(dir_group)
|
||||
control_layout.addWidget(dir_group)
|
||||
|
||||
# 图像目录选择(优先指向预测结果目录)
|
||||
# 2. 图像目录选择
|
||||
img_dir_group = QGroupBox("图像目录")
|
||||
img_dir_layout = QHBoxLayout()
|
||||
self.img_dir_edit = QLineEdit()
|
||||
@ -1435,18 +1372,9 @@ class Step12VizPanel(QWidget):
|
||||
img_dir_layout.addWidget(self.img_dir_edit, 1)
|
||||
img_dir_layout.addWidget(img_dir_browse_btn)
|
||||
img_dir_group.setLayout(img_dir_layout)
|
||||
left_layout.addWidget(img_dir_group)
|
||||
control_layout.addWidget(img_dir_group)
|
||||
|
||||
# 图像目录树
|
||||
tree_group = QGroupBox("图像目录")
|
||||
tree_layout = QVBoxLayout()
|
||||
self.image_tree = ImageCategoryTree()
|
||||
self.image_tree.itemClicked.connect(self.on_tree_item_clicked)
|
||||
tree_layout.addWidget(self.image_tree)
|
||||
tree_group.setLayout(tree_layout)
|
||||
left_layout.addWidget(tree_group, 1)
|
||||
|
||||
# 可视化配置
|
||||
# 3. 可视化配置
|
||||
config_group = QGroupBox("可视化配置")
|
||||
config_layout = QVBoxLayout()
|
||||
|
||||
@ -1470,6 +1398,11 @@ class Step12VizPanel(QWidget):
|
||||
self.gen_sampling_map.setChecked(True)
|
||||
config_layout.addWidget(self.gen_sampling_map)
|
||||
|
||||
self.gen_distribution_map = QCheckBox("空间分布图 (Step 11 产物)")
|
||||
self.gen_distribution_map.setChecked(True)
|
||||
self.gen_distribution_map.setToolTip("渲染并汇总 Step 11 生成的 TIF 分布图")
|
||||
config_layout.addWidget(self.gen_distribution_map)
|
||||
|
||||
config_layout.addSpacing(10)
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
@ -1479,23 +1412,73 @@ class Step12VizPanel(QWidget):
|
||||
|
||||
self.gen_all_btn = QPushButton("🚀 生成全部")
|
||||
self.gen_all_btn.setToolTip("生成所有类型的可视化图表")
|
||||
self.gen_all_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
|
||||
self.gen_all_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; padding: 8px; border-radius: 4px;")
|
||||
self.gen_all_btn.clicked.connect(self.generate_all_visualizations)
|
||||
config_layout.addWidget(self.gen_all_btn)
|
||||
|
||||
self.scan_btn = QPushButton("📁 扫描目录")
|
||||
self.scan_btn.setToolTip("扫描工作目录中的图像文件")
|
||||
self.scan_btn.setStyleSheet("padding: 6px; border-radius: 4px;")
|
||||
self.scan_btn.clicked.connect(self.scan_work_directory)
|
||||
config_layout.addWidget(self.scan_btn)
|
||||
|
||||
config_group.setLayout(config_layout)
|
||||
left_layout.addWidget(config_group)
|
||||
control_layout.addWidget(config_group)
|
||||
|
||||
left_panel.setLayout(left_layout)
|
||||
left_panel.setMaximumWidth(350)
|
||||
main_layout.addWidget(left_panel, 0)
|
||||
control_layout.addStretch() # 把控制面板的内容往上顶
|
||||
control_panel.setLayout(control_layout)
|
||||
control_panel.setMaximumWidth(280) # 稍微收窄第一列
|
||||
control_panel.setMinimumWidth(230)
|
||||
main_layout.addWidget(control_panel, 0) # stretch=0,不横向拉伸
|
||||
|
||||
# ===== 右侧面板 =====
|
||||
# ==========================================
|
||||
# 第二列:满高独立的目录树(选图与筛选面板)
|
||||
# ==========================================
|
||||
from PyQt5.QtWidgets import QComboBox
|
||||
tree_panel = QWidget()
|
||||
tree_layout = QVBoxLayout()
|
||||
tree_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
tree_group = QGroupBox("图像浏览与筛选")
|
||||
group_layout = QVBoxLayout()
|
||||
group_layout.setSpacing(8)
|
||||
|
||||
# 添加过滤控制栏
|
||||
filter_layout = QFormLayout()
|
||||
filter_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# 分组模式下拉框
|
||||
self.view_mode_cb = QComboBox()
|
||||
self.view_mode_cb.addItems(["按水质参数归类", "按图表类型归类", "按物理文件夹"])
|
||||
self.view_mode_cb.currentIndexChanged.connect(self.update_image_tree_view)
|
||||
self.view_mode_cb.setStyleSheet("QComboBox { padding: 4px; border-radius: 4px; border: 1px solid #ccc; }")
|
||||
|
||||
# 图表类型筛选下拉框
|
||||
self.chart_filter_cb = QComboBox()
|
||||
self.chart_filter_cb.addItems(["全部图表", "空间分布图", "模型散点图", "光谱曲线图", "统计箱线图", "相关性热力图", "掩膜与预览", "采样点地图"])
|
||||
self.chart_filter_cb.currentIndexChanged.connect(self.update_image_tree_view)
|
||||
self.chart_filter_cb.setStyleSheet("QComboBox { padding: 4px; border-radius: 4px; border: 1px solid #ccc; }")
|
||||
|
||||
filter_layout.addRow("视图模式:", self.view_mode_cb)
|
||||
filter_layout.addRow("类型筛选:", self.chart_filter_cb)
|
||||
group_layout.addLayout(filter_layout)
|
||||
|
||||
# 挂载新版大树
|
||||
self.image_tree = ImageCategoryTree()
|
||||
self.image_tree.itemClicked.connect(self.on_tree_item_clicked)
|
||||
group_layout.addWidget(self.image_tree, 1) # stretch=1 让树垂直填满
|
||||
|
||||
tree_group.setLayout(group_layout)
|
||||
tree_layout.addWidget(tree_group, 1) # stretch=1 让GroupBox垂直填满
|
||||
|
||||
tree_panel.setLayout(tree_layout)
|
||||
tree_panel.setMaximumWidth(320)
|
||||
tree_panel.setMinimumWidth(260)
|
||||
main_layout.addWidget(tree_panel, 0) # stretch=0,不抢占右侧图片的宽度
|
||||
|
||||
# ==========================================
|
||||
# 第三列:图像查看器
|
||||
# ==========================================
|
||||
right_panel = QWidget()
|
||||
right_layout = QVBoxLayout()
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@ -1503,10 +1486,33 @@ class Step12VizPanel(QWidget):
|
||||
self.image_viewer.refresh_btn.clicked.connect(self.scan_work_directory)
|
||||
right_layout.addWidget(self.image_viewer, 1)
|
||||
right_panel.setLayout(right_layout)
|
||||
main_layout.addWidget(right_panel, 1)
|
||||
main_layout.addWidget(right_panel, 1) # stretch=1,右侧画板填满所有剩余宽度
|
||||
|
||||
self.setLayout(main_layout)
|
||||
|
||||
def update_image_tree_view(self):
|
||||
"""响应下拉框改变,重新渲染树状图"""
|
||||
if not hasattr(self, 'image_tree') or not self.image_tree._all_image_files:
|
||||
return
|
||||
|
||||
# 1. 提取当前选中的分组模式
|
||||
mode_idx = self.view_mode_cb.currentIndex()
|
||||
if mode_idx == 0:
|
||||
group_mode = 'parameter'
|
||||
elif mode_idx == 1:
|
||||
group_mode = 'type'
|
||||
else:
|
||||
group_mode = 'folder'
|
||||
|
||||
# 2. 提取当前的图表筛选条件
|
||||
filter_type = self.chart_filter_cb.currentText()
|
||||
if filter_type == "全部图表":
|
||||
filter_type = 'all'
|
||||
|
||||
# 3. 触发重绘
|
||||
self.image_tree.rebuild_tree(group_mode, filter_type)
|
||||
self._load_first_image_from_tree()
|
||||
|
||||
def set_work_dir(self, work_dir):
|
||||
"""设置工作目录"""
|
||||
self.work_dir = work_dir
|
||||
@ -1678,7 +1684,7 @@ class Step12VizPanel(QWidget):
|
||||
return
|
||||
if not (self.gen_scatter.isChecked() or self.gen_spectrum.isChecked() or
|
||||
self.gen_boxplots.isChecked() or self.gen_mask_glint.isChecked() or
|
||||
self.gen_sampling_map.isChecked()):
|
||||
self.gen_sampling_map.isChecked() or self.gen_distribution_map.isChecked()):
|
||||
QMessageBox.information(self, "提示", "请至少勾选一项可视化配置选项以生成图表。")
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
@ -1694,9 +1700,20 @@ class Step12VizPanel(QWidget):
|
||||
"gen_boxplots": self.gen_boxplots.isChecked(),
|
||||
"gen_mask_glint": self.gen_mask_glint.isChecked(),
|
||||
"gen_sampling_map": self.gen_sampling_map.isChecked(),
|
||||
"gen_distribution_map": self.gen_distribution_map.isChecked(),
|
||||
}
|
||||
main_window = self.window()
|
||||
factory = getattr(main_window, '_panel_factory', None) if main_window else None
|
||||
|
||||
# [新增] 直接从 Step 1 面板读取原始 .shp 的绝对路径,突破工作目录限制
|
||||
step1_panel = factory.get_panel('step1_mask') if factory else None
|
||||
if step1_panel:
|
||||
s1_conf = step1_panel.get_config()
|
||||
s1_mask = s1_conf.get('mask_path')
|
||||
# 确保文件存在且是shp格式,存入extra透传给后台线程
|
||||
if s1_mask and Path(s1_mask).is_file() and str(s1_mask).lower().endswith('.shp'):
|
||||
extra["boundary_shp_path"] = str(s1_mask)
|
||||
|
||||
step6_panel = factory.get_panel('step6_feature') if factory else None
|
||||
if step6_panel and getattr(step6_panel, 'output_file', None):
|
||||
_resolved_csv = step6_panel.output_file.get_path()
|
||||
@ -1886,6 +1903,7 @@ class Step12VizPanel(QWidget):
|
||||
'generate_spectrum': self.gen_spectrum.isChecked(),
|
||||
'generate_glint_previews': self.gen_mask_glint.isChecked(),
|
||||
'generate_sampling_maps': self.gen_sampling_map.isChecked(),
|
||||
'generate_distribution_maps': self.gen_distribution_map.isChecked(),
|
||||
'scatter_config': {
|
||||
'metric': 'test_r2', 'feature_start_column': 13,
|
||||
'test_size': 0.2, 'random_state': 42
|
||||
@ -1913,3 +1931,5 @@ class Step12VizPanel(QWidget):
|
||||
self.gen_mask_glint.setChecked(config['generate_glint_previews'])
|
||||
if 'generate_sampling_maps' in config:
|
||||
self.gen_sampling_map.setChecked(config.get('generate_sampling_maps', True))
|
||||
if 'generate_distribution_maps' in config:
|
||||
self.gen_distribution_map.setChecked(config.get('generate_distribution_maps', True))
|
||||
|
||||
@ -166,7 +166,7 @@ ROUTES = [
|
||||
{
|
||||
# data/icons/ 没有 12.png/13.png;Step11/12/13 暂时复用 9.png(11 个 png 对 13 个 step 必然有共用)
|
||||
"id": "step11",
|
||||
"name": "11. 专题图生成",
|
||||
"name": "11. 分布图生成",
|
||||
"icon": "9.png",
|
||||
"view_module": "src.new.views.step11_view",
|
||||
"view_class": "Step11View",
|
||||
|
||||
@ -25,7 +25,8 @@ Step11 后端计算服务(专题图生成 / 克里金插值)
|
||||
"boundary_shp_path": "D:/boundary.shp", # 边界 shp(可选)
|
||||
"resolution": 30.0, # 空间分辨率(米)
|
||||
"input_crs": "EPSG:32651",
|
||||
"output_crs": "EPSG:4326",
|
||||
# ★★★ 强制默认 output_crs = input_crs,禁止从 service 配置误改为 EPSG:4326 ★★★
|
||||
"output_crs": "EPSG:32651",
|
||||
"output_dir": "D:/11_Thematic_Map", # 输出目录
|
||||
"enabled": True,
|
||||
"work_dir": "D:/workspace", # 工作目录
|
||||
|
||||
@ -46,7 +46,7 @@ Step12 后端计算服务(数据可视化——散点/光谱/箱线/掩膜缩
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.new.services._output_resolver import get_user_output_path, is_user_specified, resolve_output_dir
|
||||
|
||||
@ -168,18 +168,115 @@ def _try_sampling_maps(work_dir: str, output_dir: Path) -> Dict[str, Any]:
|
||||
return {"status": "completed" if p else "error", "path": p, "output_dir": str(output_dir / "sampling_maps")}
|
||||
|
||||
|
||||
def _resolve_thematic_map_dir(work_dir: str) -> "Optional[Path]":
|
||||
"""自动推断 Step 11 分布图输出目录
|
||||
|
||||
与 step11_service._resolve_output_dir 的 default 分支完全镜像——
|
||||
用户未指定 output_dir 时默认为 ``work_dir/11_Thematic_Map``。
|
||||
"""
|
||||
if not work_dir:
|
||||
return None
|
||||
cand = Path(work_dir) / "11_Thematic_Map"
|
||||
return cand if cand.is_dir() else None
|
||||
|
||||
|
||||
def _try_distribution_maps(work_dir: str, output_dir: Path) -> Dict[str, Any]:
|
||||
"""渲染/汇集 Step 11 生成的分布图到 14_visualization/distribution_maps
|
||||
|
||||
处理两类产物(独立子步骤,单个失败不影响另一个):
|
||||
|
||||
1. **PNG 直拷** —— ``11_Thematic_Map/*_专题图.png``
|
||||
(Step 11 GeoTIFF 栅格模式的产物,已带外框/图例/采样点,直接复制即可)
|
||||
2. **GeoTIFF 渲染** —— ``11_Thematic_Map/*_kriging.tif``
|
||||
(Step 11 CSV 插值模式的纯 GeoTIFF 产物,调用 ``ContentMapper.visualize_raster``
|
||||
渲染为同风格的 PNG)
|
||||
|
||||
Returns:
|
||||
{"status": "completed"|"skipped", "count": int,
|
||||
"png_copied": int, "tif_rendered": int,
|
||||
"output_dir": str, "details": [{"src", "dst", "kind"}, ...]}
|
||||
"""
|
||||
import shutil
|
||||
|
||||
src_dir = _resolve_thematic_map_dir(work_dir)
|
||||
if src_dir is None:
|
||||
raise FileNotFoundError(
|
||||
f"Step 11 输出目录不存在: {work_dir}/11_Thematic_Map"
|
||||
f"(请先运行 Step 11 生成分布图)"
|
||||
)
|
||||
|
||||
out_sub = output_dir / "distribution_maps"
|
||||
out_sub.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
details: List[Dict[str, str]] = []
|
||||
n_png_copy = 0
|
||||
n_tif_render = 0
|
||||
|
||||
# --- 1. PNG 直拷(GeoTIFF 模式已有产物) ---
|
||||
for src_png in sorted(src_dir.glob("*_专题图.png")):
|
||||
dst_png = out_sub / src_png.name
|
||||
try:
|
||||
shutil.copy2(src_png, dst_png)
|
||||
details.append({"src": str(src_png), "dst": str(dst_png), "kind": "png_copy"})
|
||||
n_png_copy += 1
|
||||
except Exception as copy_err: # noqa: BLE001
|
||||
print(f"[distribution_maps] ⚠ 复制失败 {src_png.name}: {copy_err}")
|
||||
|
||||
# --- 2. GeoTIFF 渲染(CSV 模式纯栅格产物) ---
|
||||
tif_paths = sorted(src_dir.glob("*_kriging.tif"))
|
||||
if tif_paths:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
from src.postprocessing.map import ContentMapper
|
||||
|
||||
mapper = ContentMapper()
|
||||
for tif_path in tif_paths:
|
||||
stem = tif_path.stem
|
||||
chinese_title = mapper._get_chinese_title(stem)
|
||||
dst_png = out_sub / f"{chinese_title}_分布图.png"
|
||||
try:
|
||||
mapper.visualize_raster(
|
||||
raster_tif_path=str(tif_path),
|
||||
output_file=str(dst_png),
|
||||
boundary_shp_path=None,
|
||||
nodata_value=-9999.0,
|
||||
figsize=(14, 10),
|
||||
alpha=0.9,
|
||||
)
|
||||
details.append({"src": str(tif_path), "dst": str(dst_png), "kind": "tif_render"})
|
||||
n_tif_render += 1
|
||||
except Exception as render_err: # noqa: BLE001
|
||||
print(f"[distribution_maps] ⚠ 渲染失败 {tif_path.name}: {render_err}")
|
||||
|
||||
total = n_png_copy + n_tif_render
|
||||
if total == 0:
|
||||
raise FileNotFoundError(
|
||||
f"Step 11 输出目录 {src_dir} 中无 *_专题图.png 也无 *_kriging.tif"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"count": total,
|
||||
"png_copied": n_png_copy,
|
||||
"tif_rendered": n_tif_render,
|
||||
"output_dir": str(out_sub),
|
||||
"details": details,
|
||||
}
|
||||
|
||||
|
||||
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 行为一致)
|
||||
# 6 个开关:缺省默认 True(与旧 panel 行为一致;新增 distribution_maps 用于闭环 Step 11)
|
||||
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))
|
||||
gen_distribution = bool(config.get("generate_distribution_maps", True))
|
||||
|
||||
output_path, _source = _resolve_output_dir(config, work_dir)
|
||||
mode = "viz_generate"
|
||||
@ -228,12 +325,14 @@ def execute_step12(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
tasks.append(("glint_previews", _try_glint_previews))
|
||||
if gen_sampling:
|
||||
tasks.append(("sampling_maps", _try_sampling_maps))
|
||||
if gen_distribution:
|
||||
tasks.append(("distribution_maps", _try_distribution_maps))
|
||||
|
||||
if not tasks:
|
||||
return {
|
||||
"status": "completed",
|
||||
"output_path": str(output_path).replace("\\", "/"),
|
||||
"message": "无可视化任务(5 个开关全部 False)",
|
||||
"message": "无可视化任务(6 个开关全部 False)",
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
|
||||
@ -181,7 +181,10 @@ class Step11View(BaseView):
|
||||
params_layout.addRow("输入坐标系:", self.input_crs)
|
||||
|
||||
self.output_crs = QLineEdit()
|
||||
self.output_crs.setText("EPSG:4326")
|
||||
# ★★★ 强制默认输出坐标系与输入一致,禁止从 GUI 误改为 EPSG:4326 ★★★
|
||||
# 历史默认值 'EPSG:4326' 会让 ContentMapper 把栅格重投影到经纬度,
|
||||
# 与基于 EPSG:32651 的水域掩膜叠加时发生仿射变换撕裂(栅格错位、坐标轴扭曲)。
|
||||
self.output_crs.setText("EPSG:32651")
|
||||
params_layout.addRow("输出坐标系:", self.output_crs)
|
||||
|
||||
self.show_points = QCheckBox("显示采样点")
|
||||
@ -302,7 +305,8 @@ class Step11View(BaseView):
|
||||
"boundary_shp_path": self.boundary_file.get_path(),
|
||||
"resolution": self.resolution.value(),
|
||||
"input_crs": self.input_crs.text(),
|
||||
"output_crs": self.output_crs.text(),
|
||||
# ★★★ 强制 output_crs = input_crs,不再信任 GUI 的 output_crs 输入框 ★★★
|
||||
"output_crs": self.input_crs.text(),
|
||||
"show_sample_points": self.show_points.isChecked(),
|
||||
"use_distance_diffusion": self.use_diffusion.isChecked(),
|
||||
"enabled": self.enable_checkbox.isChecked(),
|
||||
@ -342,8 +346,9 @@ class Step11View(BaseView):
|
||||
self.resolution.setValue(config["resolution"])
|
||||
if "input_crs" in config:
|
||||
self.input_crs.setText(config["input_crs"])
|
||||
if "output_crs" in config:
|
||||
self.output_crs.setText(config["output_crs"])
|
||||
# ★★★ 反灌入时强制 output_crs = input_crs,避免旧 config 中的 EPSG:4326 回填 ★★★
|
||||
if "output_crs" in config or "input_crs" in config:
|
||||
self.output_crs.setText(config.get("input_crs") or config.get("output_crs") or "EPSG:32651")
|
||||
if "show_sample_points" in config:
|
||||
self.show_points.setChecked(config["show_sample_points"])
|
||||
if "use_distance_diffusion" in config:
|
||||
|
||||
@ -100,6 +100,14 @@ class Step12View(BaseView):
|
||||
self.gen_sampling_map.setChecked(True)
|
||||
config_layout.addWidget(self.gen_sampling_map)
|
||||
|
||||
self.gen_distribution_map = QCheckBox("空间分布图(Step 11 产物)")
|
||||
self.gen_distribution_map.setChecked(True)
|
||||
self.gen_distribution_map.setToolTip(
|
||||
"渲染/汇集 Step 11 生成的分布图(PNG 直拷 + GeoTIFF 渲染)"
|
||||
"到 14_visualization/distribution_maps"
|
||||
)
|
||||
config_layout.addWidget(self.gen_distribution_map)
|
||||
|
||||
config_layout.addSpacing(10)
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
@ -186,6 +194,7 @@ class Step12View(BaseView):
|
||||
"generate_boxplots": self.gen_boxplots.isChecked(),
|
||||
"generate_glint_previews": self.gen_mask_glint.isChecked(),
|
||||
"generate_sampling_maps": self.gen_sampling_map.isChecked(),
|
||||
"generate_distribution_maps": self.gen_distribution_map.isChecked(),
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
@ -207,6 +216,8 @@ class Step12View(BaseView):
|
||||
self.gen_mask_glint.setChecked(config["generate_glint_previews"])
|
||||
if "generate_sampling_maps" in config:
|
||||
self.gen_sampling_map.setChecked(config.get("generate_sampling_maps", True))
|
||||
if "generate_distribution_maps" in config:
|
||||
self.gen_distribution_map.setChecked(config.get("generate_distribution_maps", True))
|
||||
|
||||
def update_work_directory(self, work_dir: str):
|
||||
super().update_work_directory(work_dir)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,13 +2,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
采样点地图生成模块 - 在高光谱假彩色影像上标注采样点
|
||||
|
||||
支持功能:
|
||||
1. 读取高光谱影像并生成假彩色RGB图像
|
||||
2. 读取CSV文件中的采样点坐标(前两列为纬度、经度)
|
||||
3. 在影像上标注红色采样点
|
||||
4. 添加指北针、图例和比例尺
|
||||
5. 支持地理坐标系转换
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
@ -21,13 +14,13 @@ from matplotlib.patches import FancyArrowPatch
|
||||
import matplotlib.patheffects as path_effects
|
||||
|
||||
# 性能优化配置
|
||||
plt.rcParams['agg.path.chunksize'] = 10000 # 提高矢量渲染性能
|
||||
plt.rcParams['agg.path.chunksize'] = 10000
|
||||
plt.rcParams['path.simplify'] = True
|
||||
plt.rcParams['path.simplify_threshold'] = 0.1
|
||||
|
||||
# 导入GDAL用于影像读写
|
||||
try:
|
||||
from osgeo import gdal, osr
|
||||
|
||||
GDAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
GDAL_AVAILABLE = False
|
||||
@ -35,26 +28,15 @@ except ImportError:
|
||||
|
||||
|
||||
class SamplingPointMap:
|
||||
"""采样点地图生成类 - 在高光谱假彩色影像上标注采样点"""
|
||||
|
||||
def __init__(self, output_dir: str = "./point_maps", fast_mode: bool = False):
|
||||
"""
|
||||
初始化采样点地图生成器
|
||||
|
||||
Args:
|
||||
output_dir: 输出目录,用于保存生成的地图
|
||||
fast_mode: 是否启用快速模式(降低质量换取速度)
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.fast_mode = fast_mode
|
||||
|
||||
# 设置中文字体
|
||||
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'DejaVu Sans', 'Arial Unicode MS']
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
plt.rcParams['font.size'] = 12
|
||||
|
||||
# 性能优化设置
|
||||
if fast_mode:
|
||||
plt.rcParams['figure.dpi'] = 150
|
||||
plt.rcParams['savefig.dpi'] = 150
|
||||
@ -62,86 +44,45 @@ class SamplingPointMap:
|
||||
else:
|
||||
plt.rcParams['figure.dpi'] = 300
|
||||
plt.rcParams['savefig.dpi'] = 300
|
||||
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
def create_sampling_point_map(self,
|
||||
hyperspectral_path: str,
|
||||
csv_path: str,
|
||||
output_filename: Optional[str] = None,
|
||||
rgb_bands: Optional[List[int]] = None,
|
||||
point_color: str = 'red',
|
||||
point_size: int = 80,
|
||||
point_alpha: float = 0.8,
|
||||
show_north_arrow: bool = True,
|
||||
show_scale_bar: bool = True,
|
||||
show_legend: bool = True,
|
||||
dpi: int = None,
|
||||
downsample: bool = False) -> str:
|
||||
"""
|
||||
创建采样点地图:在高光谱假彩色影像上标注采样点
|
||||
|
||||
Args:
|
||||
hyperspectral_path: 高光谱影像文件路径 (.dat, .bsq, .tif等)
|
||||
csv_path: 采样点CSV文件路径(前两列为纬度、经度)
|
||||
output_filename: 输出文件名(如果为None则自动生成)
|
||||
rgb_bands: 用于RGB合成的三个波段索引 [R, G, B],默认为None自动选择
|
||||
point_color: 采样点颜色
|
||||
point_size: 采样点大小
|
||||
point_alpha: 采样点透明度
|
||||
show_north_arrow: 是否显示指北针
|
||||
show_scale_bar: 是否显示比例尺
|
||||
show_legend: 是否显示图例
|
||||
dpi: 输出图像分辨率(None时使用fast_mode设置)
|
||||
downsample: 是否对图像进行下采样以加快速度(大影像推荐启用)
|
||||
|
||||
Returns:
|
||||
生成的地图文件路径
|
||||
"""
|
||||
def create_sampling_point_map(self, hyperspectral_path: str, csv_path: str,
|
||||
output_filename: Optional[str] = None, rgb_bands: Optional[List[int]] = None,
|
||||
point_color: str = 'red', point_size: int = 80, point_alpha: float = 0.8,
|
||||
show_north_arrow: bool = True, show_scale_bar: bool = True,
|
||||
show_legend: bool = True, dpi: int = None, downsample: bool = False) -> str:
|
||||
if not GDAL_AVAILABLE:
|
||||
raise ImportError("GDAL未安装,无法处理地理坐标转换")
|
||||
|
||||
print(f"正在生成采样点地图...{' (快速模式)' if self.fast_mode else ''}")
|
||||
|
||||
# 读取高光谱影像 - 优化:仅读取需要的RGB波段
|
||||
hyperspectral_img, geotransform, projection, width, height, sample_factor = self._read_hyperspectral(
|
||||
hyperspectral_path, rgb_bands, downsample)
|
||||
|
||||
# 读取采样点
|
||||
sampling_points = self._read_sampling_points(csv_path)
|
||||
|
||||
# 生成假彩色图像 - 应用线性拉伸
|
||||
rgb_image = self._create_false_color_image(hyperspectral_img)
|
||||
|
||||
# 将地理坐标转换为像素坐标 - 支持投影系转换和下采样
|
||||
pixel_coords = self._geo_to_pixel(sampling_points, geotransform, width, height, projection, sample_factor)
|
||||
|
||||
# 创建地图
|
||||
if output_filename is None:
|
||||
csv_name = Path(csv_path).stem
|
||||
hs_name = Path(hyperspectral_path).stem
|
||||
output_filename = f"{hs_name}_{csv_name}_sampling_map.png"
|
||||
|
||||
output_path = self.output_dir / output_filename
|
||||
|
||||
# 使用更优化的绘图设置
|
||||
if dpi is None:
|
||||
dpi = 150 if self.fast_mode else 200
|
||||
|
||||
self._create_map_visualization(
|
||||
rgb_image, pixel_coords, sampling_points,
|
||||
str(output_path), point_color, point_size, point_alpha,
|
||||
show_north_arrow, show_scale_bar, show_legend, dpi,
|
||||
geotransform, width, height, downsample, projection, sample_factor
|
||||
rgb_image, pixel_coords, sampling_points, str(output_path), point_color, point_size, point_alpha,
|
||||
show_north_arrow, show_scale_bar, show_legend, dpi, geotransform, width, height, downsample, projection,
|
||||
sample_factor
|
||||
)
|
||||
|
||||
print(f"采样点地图已保存: {output_path}")
|
||||
return str(output_path)
|
||||
|
||||
def _read_hyperspectral(self, hyperspectral_path: str,
|
||||
rgb_bands: Optional[List[int]] = None,
|
||||
downsample: bool = False) -> Tuple[np.ndarray, tuple, str, int, int]:
|
||||
"""优化版:读取高光谱影像 - 仅读取需要的RGB波段"""
|
||||
def _read_hyperspectral(self, hyperspectral_path: str, rgb_bands: Optional[List[int]] = None,
|
||||
downsample: bool = False) -> Tuple[np.ndarray, tuple, str, int, int]:
|
||||
dataset = gdal.Open(hyperspectral_path)
|
||||
if dataset is None:
|
||||
raise ValueError(f"无法打开高光谱影像: {hyperspectral_path}")
|
||||
@ -150,487 +91,222 @@ class SamplingPointMap:
|
||||
height = dataset.RasterYSize
|
||||
band_count = dataset.RasterCount
|
||||
|
||||
# 确定要读取的波段 - 优先使用指定波长 (650nm, 550nm, 460nm)
|
||||
if rgb_bands is None:
|
||||
if band_count >= 3:
|
||||
try:
|
||||
# 使用find_band_number根据波长查找RGB波段
|
||||
from src.utils.util import find_band_number
|
||||
rgb_bands = [
|
||||
find_band_number(650.0, hyperspectral_path), # Red ~650nm
|
||||
find_band_number(550.0, hyperspectral_path), # Green ~550nm
|
||||
find_band_number(460.0, hyperspectral_path) # Blue ~460nm
|
||||
find_band_number(650.0, hyperspectral_path),
|
||||
find_band_number(550.0, hyperspectral_path),
|
||||
find_band_number(460.0, hyperspectral_path)
|
||||
]
|
||||
print(f" 根据波长选择RGB波段: R={rgb_bands[0]}, G={rgb_bands[1]}, B={rgb_bands[2]}")
|
||||
except Exception as e:
|
||||
print(f" 波长查找失败 ({e}),使用默认索引")
|
||||
# 回退到基于索引的选择
|
||||
rgb_bands = [min(band_count-1, int(band_count*0.25)),
|
||||
min(band_count-1, int(band_count*0.15)),
|
||||
min(band_count-1, int(band_count*0.05))]
|
||||
except Exception:
|
||||
rgb_bands = [min(band_count - 1, int(band_count * 0.25)),
|
||||
min(band_count - 1, int(band_count * 0.15)),
|
||||
min(band_count - 1, int(band_count * 0.05))]
|
||||
else:
|
||||
rgb_bands = [0, 0, 0]
|
||||
|
||||
# 下采样控制 - 用户反馈下采样读取会导致像素值全为0
|
||||
if downsample and (width > 2000 or height > 2000):
|
||||
print(f" ⚠ 下采样暂被禁用(会导致像素值全0),使用原始分辨率: {width}x{height}")
|
||||
print(f" ⚠ 下采样暂被禁用,使用原始分辨率: {width}x{height}")
|
||||
sample_factor = 1
|
||||
target_width = width
|
||||
target_height = height
|
||||
else:
|
||||
sample_factor = 1
|
||||
target_width = width
|
||||
target_height = height
|
||||
|
||||
# 只读取需要的RGB波段(性能关键优化)
|
||||
rgb_data = []
|
||||
for band_idx in rgb_bands:
|
||||
band = dataset.GetRasterBand(band_idx + 1)
|
||||
# 直接使用完整分辨率读取,避免下采样导致像素值为0的问题
|
||||
band_data = band.ReadAsArray().astype(np.float32)
|
||||
rgb_data.append(band_data)
|
||||
|
||||
# 堆叠为RGB图像 (height, width, 3)
|
||||
if len(rgb_data) == 3:
|
||||
image_array = np.stack(rgb_data, axis=2)
|
||||
else:
|
||||
# 如果只有1个波段,复制为RGB
|
||||
image_array = np.stack([rgb_data[0]]*3, axis=2)
|
||||
image_array = np.stack([rgb_data[0]] * 3, axis=2)
|
||||
|
||||
geotransform = dataset.GetGeoTransform()
|
||||
projection = dataset.GetProjection()
|
||||
|
||||
# 释放数据集
|
||||
dataset = None
|
||||
|
||||
# 更新尺寸信息
|
||||
final_width = target_width if sample_factor > 1 else width
|
||||
final_height = target_height if sample_factor > 1 else height
|
||||
|
||||
print(f" 读取影像: {final_width}x{final_height}x{image_array.shape[2]} (RGB)")
|
||||
if projection:
|
||||
proj_type = "投影坐标系" if "PROJCS" in projection else "地理坐标系"
|
||||
print(f" 影像投影: {proj_type}")
|
||||
if sample_factor > 1:
|
||||
print(f" 下采样因子: {sample_factor}")
|
||||
|
||||
return image_array, geotransform, projection, final_width, final_height, sample_factor
|
||||
return image_array, geotransform, projection, width, height, sample_factor
|
||||
|
||||
def _read_sampling_points(self, csv_path: str) -> pd.DataFrame:
|
||||
"""读取采样点CSV文件"""
|
||||
if not Path(csv_path).exists():
|
||||
raise FileNotFoundError(f"CSV文件不存在: {csv_path}")
|
||||
|
||||
"""智能读取采样点,自动识别模糊列名,允许UTM坐标,自动修复颠倒坐标"""
|
||||
df = pd.read_csv(csv_path)
|
||||
|
||||
# 检查前两列是否为纬度和经度
|
||||
if len(df.columns) < 2:
|
||||
raise ValueError("CSV文件至少需要两列(纬度、经度)")
|
||||
raise ValueError("CSV文件至少需要两列(经度、纬度 或 X、Y)")
|
||||
|
||||
# 假设前两列是纬度和经度
|
||||
lat_col = df.columns[0]
|
||||
lon_col = df.columns[1]
|
||||
# 智能子串匹配
|
||||
lat_aliases = ['lat', 'y', '纬']
|
||||
lon_aliases = ['lon', 'lng', 'x', '经']
|
||||
|
||||
lat_col = None
|
||||
lon_col = None
|
||||
cols_lower = {c: str(c).strip().lower() for c in df.columns}
|
||||
|
||||
for c, lc in cols_lower.items():
|
||||
if lat_col is None and any(a in lc for a in lat_aliases):
|
||||
lat_col = c
|
||||
elif lon_col is None and any(a in lc for a in lon_aliases):
|
||||
lon_col = c
|
||||
|
||||
# 兜底:取前两列,默认列0=X(lon), 列1=Y(lat)
|
||||
if lat_col is None or lon_col is None:
|
||||
c0, c1 = df.columns[0], df.columns[1]
|
||||
lon_col, lat_col = c0, c1
|
||||
|
||||
# 重命名列
|
||||
df = df.rename(columns={lat_col: 'latitude', lon_col: 'longitude'})
|
||||
|
||||
# 确保数值类型
|
||||
df['latitude'] = pd.to_numeric(df['latitude'], errors='coerce')
|
||||
df['longitude'] = pd.to_numeric(df['longitude'], errors='coerce')
|
||||
n_nan = int(df[['latitude', 'longitude']].isna().any(axis=1).sum())
|
||||
df = df.dropna(subset=['latitude', 'longitude']).reset_index(drop=True)
|
||||
|
||||
# 删除无效的坐标
|
||||
df = df.dropna(subset=['latitude', 'longitude'])
|
||||
if len(df) > 0:
|
||||
lat_max = df['latitude'].abs().max()
|
||||
lon_max = df['longitude'].abs().max()
|
||||
|
||||
print(f"读取到 {len(df)} 个采样点")
|
||||
# 智能对调:如果纬度 > 90,且经度 <= 90,说明用户把经纬度两列搞反了
|
||||
if lat_max > 90 and lon_max <= 90 and lat_max <= 180:
|
||||
print(" ⚠ 检测到经纬度数值颠倒 (纬度>90, 经度<=90),系统已自动对调坐标列")
|
||||
df['latitude'], df['longitude'] = df['longitude'], df['latitude']
|
||||
# UTM 投影坐标判定:只要数值远大于180,就是米级别的投影系统
|
||||
elif lat_max > 180 or lon_max > 180:
|
||||
print(f" ℹ 检测到坐标值远超180 (X:{lon_max:.1f}, Y:{lat_max:.1f}),判定为投影坐标(UTM)")
|
||||
|
||||
print(f" CSV 列匹配: lat_col='{lat_col}', lon_col='{lon_col}'")
|
||||
if n_nan:
|
||||
print(f" 剔除 {n_nan} 个无效(NaN)行")
|
||||
print(f" 读取到 {len(df)} 个有效采样点 (不再拦截越界拦截)")
|
||||
return df
|
||||
|
||||
def _create_false_color_image(self, image_array: np.ndarray,
|
||||
rgb_bands: Optional[List[int]] = None) -> np.ndarray:
|
||||
"""创建假彩色RGB图像 - 应用线性拉伸和Gamma校正"""
|
||||
# 由于_read_hyperspectral已返回RGB图像,这里仅进行最终处理
|
||||
def _create_false_color_image(self, image_array: np.ndarray, rgb_bands: Optional[List[int]] = None) -> np.ndarray:
|
||||
if image_array.shape[2] != 3:
|
||||
# 确保是3通道
|
||||
if len(image_array.shape) == 2 or image_array.shape[2] == 1:
|
||||
if len(image_array.shape) == 2:
|
||||
image_array = np.stack([image_array]*3, axis=2)
|
||||
else:
|
||||
image_array = np.repeat(image_array, 3, axis=2)
|
||||
image_array = np.stack([image_array] * 3, axis=2) if len(image_array.shape) == 2 else np.repeat(
|
||||
image_array, 3, axis=2)
|
||||
|
||||
print(f" 处理前图像范围: R[{image_array[:,:,0].min():.3f}-{image_array[:,:,0].max():.3f}], "
|
||||
f"G[{image_array[:,:,1].min():.3f}-{image_array[:,:,1].max():.3f}], "
|
||||
f"B[{image_array[:,:,2].min():.3f}-{image_array[:,:,2].max():.3f}]")
|
||||
|
||||
# 增强型线性拉伸 - 解决图像太暗的问题
|
||||
def simple_linear_stretch(data, min_percent=1, max_percent=99):
|
||||
"""增强对比度的线性拉伸"""
|
||||
valid_data = data[np.isfinite(data)]
|
||||
if len(valid_data) == 0:
|
||||
return np.zeros_like(data, dtype=np.float32)
|
||||
|
||||
# 计算百分位数,使用更激进的拉伸 (1%-99%)
|
||||
if len(valid_data) == 0: return np.zeros_like(data, dtype=np.float32)
|
||||
p_low = np.percentile(valid_data, min_percent)
|
||||
p_high = np.percentile(valid_data, max_percent)
|
||||
|
||||
if p_high - p_low < 1e-8:
|
||||
# 如果数据范围太小,使用最小最大值归一化
|
||||
data_min = valid_data.min()
|
||||
data_max = valid_data.max()
|
||||
if data_max > data_min:
|
||||
stretched = (data - data_min) / (data_max - data_min)
|
||||
else:
|
||||
stretched = np.zeros_like(data, dtype=np.float32)
|
||||
else:
|
||||
stretched = (data - p_low) / (p_high - p_low)
|
||||
d_min, d_max = valid_data.min(), valid_data.max()
|
||||
return (data - d_min) / (d_max - d_min) if d_max > d_min else np.zeros_like(data, dtype=np.float32)
|
||||
stretched = (data - p_low) / (p_high - p_low)
|
||||
return np.clip(stretched, 0.0, 1.0)
|
||||
|
||||
# 允许轻微过饱和以增加对比度
|
||||
stretched = np.clip(stretched, 0.0, 1.05)
|
||||
stretched = np.clip(stretched, 0.0, 1.0) # 最终确保在[0,1]
|
||||
return stretched
|
||||
|
||||
# 对每个通道进行拉伸
|
||||
r_stretched = simple_linear_stretch(image_array[:, :, 0])
|
||||
g_stretched = simple_linear_stretch(image_array[:, :, 1])
|
||||
b_stretched = simple_linear_stretch(image_array[:, :, 2])
|
||||
|
||||
# 合成为RGB图像
|
||||
rgb_image = np.stack([r_stretched, g_stretched, b_stretched], axis=2)
|
||||
rgb_image = np.nan_to_num(rgb_image, nan=0.0)
|
||||
|
||||
# 最终确保范围在[0,1],并轻微增强对比度
|
||||
rgb_image = np.nan_to_num(np.stack([r_stretched, g_stretched, b_stretched], axis=2), nan=0.0)
|
||||
rgb_image = np.clip(rgb_image, 0.0, 1.0)
|
||||
return (rgb_image * 255).astype(np.uint8)
|
||||
|
||||
# 可选:Gamma校正增加亮度(解决太暗问题)
|
||||
gamma = 1 # <1会增加亮度
|
||||
rgb_image = np.power(rgb_image, gamma)
|
||||
|
||||
# 映射到0-255范围(uint8),这样imshow显示效果更好
|
||||
rgb_image = (rgb_image * 255).astype(np.uint8)
|
||||
|
||||
print(f" 处理后图像范围: [0-255] (Gamma={gamma})")
|
||||
|
||||
return rgb_image
|
||||
|
||||
def _geo_to_pixel(self, sampling_points: pd.DataFrame,
|
||||
geotransform: tuple, width: int, height: int,
|
||||
projection: str = "", sample_factor: int = 1) -> List[Tuple[float, float]]:
|
||||
"""
|
||||
使用GDAL进行地理坐标到像素坐标的投影变换 - 支持下采样
|
||||
|
||||
原始点位坐标格式: 41.66054612 124.2208338 (WGS84地理坐标: 纬度,经度)
|
||||
高光谱影像通常使用UTM或其他投影坐标系
|
||||
当图像下采样时,sample_factor > 1,需要相应缩放坐标
|
||||
"""
|
||||
def _geo_to_pixel(self, sampling_points: pd.DataFrame, geotransform: tuple, width: int, height: int,
|
||||
projection: str = "", sample_factor: int = 1) -> List[Tuple[float, float]]:
|
||||
if geotransform is None or len(sampling_points) == 0:
|
||||
# 如果没有地理变换信息,使用图像中心
|
||||
return [(width/2, height/2) for _ in range(len(sampling_points))]
|
||||
return [(width / 2, height / 2) for _ in range(len(sampling_points))]
|
||||
|
||||
pixel_coords = []
|
||||
gt = geotransform
|
||||
needs_transform = projection and ("PROJCS" in projection or "GEOGCS" in projection)
|
||||
|
||||
# 检查是否需要投影转换
|
||||
needs_transform = False
|
||||
if projection and ("PROJCS" in projection or "GEOGCS" in projection):
|
||||
needs_transform = True
|
||||
print(f" 检测到影像投影: {projection[:80]}...")
|
||||
# 智能判定是否为 WGS84
|
||||
sample_lon = float(sampling_points['longitude'].iloc[0])
|
||||
sample_lat = float(sampling_points['latitude'].iloc[0])
|
||||
is_wgs84 = (abs(sample_lon) <= 180) and (abs(sample_lat) <= 90)
|
||||
|
||||
# 创建坐标转换对象(WGS84 -> 影像投影)
|
||||
transform = None
|
||||
if needs_transform and GDAL_AVAILABLE:
|
||||
if needs_transform and is_wgs84 and GDAL_AVAILABLE:
|
||||
try:
|
||||
# 源坐标系: WGS84 (EPSG:4326)
|
||||
src_srs = osr.SpatialReference()
|
||||
src_srs.ImportFromEPSG(4326) # WGS84
|
||||
|
||||
# 目标坐标系: 影像的投影
|
||||
src_srs.ImportFromEPSG(4326)
|
||||
dst_srs = osr.SpatialReference()
|
||||
dst_srs.ImportFromWkt(projection)
|
||||
|
||||
# 创建坐标转换
|
||||
transform = osr.CoordinateTransformation(src_srs, dst_srs)
|
||||
print(" ✓ 已创建WGS84到影像投影的坐标转换")
|
||||
except Exception as e:
|
||||
print(f" ⚠ 坐标转换创建失败: {e},使用简化变换")
|
||||
transform = None
|
||||
elif not is_wgs84:
|
||||
print(" ℹ 采样点为投影坐标(UTM),跳过WGS84投影转换,直接使用放射变换映射")
|
||||
|
||||
for _, row in sampling_points.iterrows():
|
||||
lon = float(row['longitude']) # 经度 (WGS84)
|
||||
lat = float(row['latitude']) # 纬度 (WGS84)
|
||||
lon, lat = float(row['longitude']), float(row['latitude'])
|
||||
|
||||
if transform is not None:
|
||||
# 使用GDAL进行投影转换: (经度, 纬度) -> (投影X, 投影Y)
|
||||
try:
|
||||
proj_x, proj_y, _ = transform.TransformPoint(lat, lon)
|
||||
# 再转换为像素坐标
|
||||
x = (proj_x - gt[0]) / gt[1]
|
||||
y = (proj_y - gt[3]) / gt[5]
|
||||
except Exception as e:
|
||||
# 转换失败时回退到直接计算
|
||||
x = (lon - gt[0]) / gt[1]
|
||||
y = (lat - gt[3]) / gt[5]
|
||||
proj_x, proj_y, _ = transform.TransformPoint(lon, lat)
|
||||
x, y = (proj_x - gt[0]) / gt[1], (proj_y - gt[3]) / gt[5]
|
||||
except Exception:
|
||||
x, y = width / 2, height / 2
|
||||
else:
|
||||
# 直接使用仿射变换(坐标系一致的情况)
|
||||
x = (lon - gt[0]) / gt[1]
|
||||
y = (lat - gt[3]) / gt[5]
|
||||
x, y = (lon - gt[0]) / gt[1], (lat - gt[3]) / gt[5]
|
||||
|
||||
# 如果图像进行了下采样,需要相应缩放坐标
|
||||
if sample_factor > 1:
|
||||
x = x / sample_factor
|
||||
y = y / sample_factor
|
||||
x, y = x / sample_factor, y / sample_factor
|
||||
|
||||
# 限制在图像范围内(使用下采样后的尺寸)
|
||||
x = max(0, min(x, width - 1))
|
||||
y = max(0, min(y, height - 1))
|
||||
|
||||
pixel_coords.append((x, y))
|
||||
|
||||
if transform is not None:
|
||||
print(f" ✓ 使用GDAL投影变换处理 {len(pixel_coords)} 个采样点")
|
||||
else:
|
||||
print(f" 使用直接仿射变换处理 {len(pixel_coords)} 个采样点")
|
||||
pixel_coords.append((max(0, min(x, width - 1)), max(0, min(y, height - 1))))
|
||||
|
||||
return pixel_coords
|
||||
|
||||
def _create_map_visualization(self, rgb_image: np.ndarray,
|
||||
pixel_coords: List[Tuple[float, float]],
|
||||
sampling_points: pd.DataFrame,
|
||||
output_path: str,
|
||||
point_color: str,
|
||||
point_size: int,
|
||||
point_alpha: float,
|
||||
show_north_arrow: bool,
|
||||
show_scale_bar: bool,
|
||||
show_legend: bool,
|
||||
dpi: int,
|
||||
geotransform: tuple,
|
||||
width: int,
|
||||
height: int,
|
||||
downsample: bool = False,
|
||||
projection: str = "",
|
||||
sample_factor: int = 1):
|
||||
"""创建地图可视化 - 优化版"""
|
||||
# 使用更小的figure尺寸加快渲染
|
||||
def _create_map_visualization(self, rgb_image: np.ndarray, pixel_coords: List[Tuple[float, float]],
|
||||
sampling_points: pd.DataFrame, output_path: str, point_color: str, point_size: int,
|
||||
point_alpha: float, show_north_arrow: bool, show_scale_bar: bool, show_legend: bool,
|
||||
dpi: int, geotransform: tuple, width: int, height: int, downsample: bool = False,
|
||||
projection: str = "", sample_factor: int = 1):
|
||||
figsize = (10, 8) if self.fast_mode or downsample else (12, 10)
|
||||
fig, ax = plt.subplots(figsize=figsize, dpi=100 if self.fast_mode else 150)
|
||||
|
||||
# 显示假彩色图像 - 现在已经是0-255的uint8格式
|
||||
print(f" 最终图像数据范围: [{rgb_image.min()}, {rgb_image.max()}] (uint8)")
|
||||
ax.imshow(rgb_image, interpolation='nearest' if self.fast_mode else 'bilinear')
|
||||
|
||||
# 绘制采样点 - 优化:使用scatter代替循环plot
|
||||
if pixel_coords:
|
||||
x_coords = [p[0] for p in pixel_coords]
|
||||
y_coords = [p[1] for p in pixel_coords]
|
||||
ax.scatter(x_coords, y_coords, c=point_color, s=point_size,
|
||||
alpha=point_alpha, edgecolors='white', linewidth=1.5)
|
||||
x_coords, y_coords = [p[0] for p in pixel_coords], [p[1] for p in pixel_coords]
|
||||
ax.scatter(x_coords, y_coords, c=point_color, s=point_size, alpha=point_alpha, edgecolors='white',
|
||||
linewidth=1.5)
|
||||
|
||||
# 添加指北针
|
||||
if show_north_arrow:
|
||||
self._add_north_arrow(ax, width, height, position='bottom-left', direction='down')
|
||||
if show_north_arrow: self._add_north_arrow(ax, width, height, position='bottom-left', direction='down')
|
||||
if show_scale_bar and geotransform is not None: self._add_scale_bar(ax, geotransform, width, height)
|
||||
|
||||
# 添加比例尺
|
||||
if show_scale_bar and geotransform is not None:
|
||||
self._add_scale_bar(ax, geotransform, width, height)
|
||||
|
||||
# 添加图例
|
||||
if show_legend:
|
||||
legend_text = f'采样点 (n={len(sampling_points)})'
|
||||
ax.plot([], [], 'o', color=point_color, markersize=8, label=legend_text)
|
||||
ax.plot([], [], 'o', color=point_color, markersize=8, label=f'采样点 (n={len(sampling_points)})')
|
||||
ax.legend(loc='lower right', frameon=True, facecolor='white', edgecolor='gray')
|
||||
|
||||
# 设置标题和标签
|
||||
ax.set_title('高光谱影像采样点分布图', fontsize=16, fontweight='bold', pad=20)
|
||||
|
||||
|
||||
# 隐藏坐标轴刻度
|
||||
ax.set_xticks([])
|
||||
ax.set_yticks([])
|
||||
|
||||
# 添加网格
|
||||
ax.grid(True, alpha=0.2, linestyle='--')
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
# 保存参数 - 避免传递不兼容的参数
|
||||
save_kwargs = {
|
||||
'dpi': dpi,
|
||||
'bbox_inches': 'tight',
|
||||
'pad_inches': 0.05,
|
||||
'facecolor': 'white'
|
||||
}
|
||||
|
||||
# 仅添加matplotlib支持的参数
|
||||
if self.fast_mode:
|
||||
save_kwargs['dpi'] = min(dpi, 180) # 快速模式降低DPI
|
||||
|
||||
save_kwargs = {'dpi': min(dpi, 180) if self.fast_mode else dpi, 'bbox_inches': 'tight', 'pad_inches': 0.05,
|
||||
'facecolor': 'white'}
|
||||
plt.savefig(output_path, **save_kwargs)
|
||||
plt.close(fig)
|
||||
|
||||
def _add_north_arrow(self, ax, width: int, height: int, position='top-right', direction='down',
|
||||
size=0.08, color='white', n_color='white', outline_color='black'):
|
||||
"""
|
||||
添加指北针,可配置位置、方向、大小、颜色。
|
||||
def _add_north_arrow(self, ax, width: int, height: int, position='top-right', direction='down', size=0.08,
|
||||
color='white', n_color='white', outline_color='black'):
|
||||
pos_map = {'top-left': (0.08, 0.88), 'top-right': (0.92, 0.88), 'bottom-left': (0.08, 0.12),
|
||||
'bottom-right': (0.92, 0.12)}
|
||||
arrow_x, arrow_y = width * pos_map.get(position, (0.92, 0.88))[0], height * pos_map.get(position, (0.92, 0.88))[
|
||||
1]
|
||||
dx, dy = {'up': (0, size), 'down': (0, -size), 'left': (-size, 0), 'right': (size, 0)}.get(direction,
|
||||
(0, -size))
|
||||
|
||||
参数:
|
||||
ax: matplotlib Axes对象
|
||||
width, height: 图像宽高(用于相对定位)
|
||||
position: 'top-left', 'top-right', 'bottom-left', 'bottom-right'
|
||||
direction: 'up', 'down', 'left', 'right' 箭头指向
|
||||
size: 箭头长度相对于高度的比例(0.05~0.12)
|
||||
color: 箭头颜色
|
||||
n_color: 'N' 文字颜色
|
||||
outline_color: 文字描边颜色
|
||||
"""
|
||||
# 位置映射(偏移系数)
|
||||
pos_map = {
|
||||
'top-left': (0.08, 0.88),
|
||||
'top-right': (0.92, 0.88),
|
||||
'bottom-left': (0.08, 0.12),
|
||||
'bottom-right': (0.92, 0.12),
|
||||
}
|
||||
arrow_x_ratio, arrow_y_ratio = pos_map.get(position, (0.92, 0.88))
|
||||
arrow_x = width * arrow_x_ratio
|
||||
arrow_y = height * arrow_y_ratio
|
||||
|
||||
# 方向映射(箭头终点偏移)
|
||||
direction_map = {
|
||||
'up': (0, +size),
|
||||
'down': (0, -size),
|
||||
'left': (-size, 0),
|
||||
'right': (+size, 0),
|
||||
}
|
||||
dx, dy = direction_map.get(direction, (0, -size))
|
||||
end_x = arrow_x + dx * width # 注意:dx是比例,乘以宽度/高度保持比例一致
|
||||
end_y = arrow_y + dy * height
|
||||
|
||||
# 箭头绘制
|
||||
arrow = FancyArrowPatch((arrow_x, arrow_y), (end_x, end_y),
|
||||
color=color, linewidth=3,
|
||||
arrowstyle='->', mutation_scale=20)
|
||||
arrow = FancyArrowPatch((arrow_x, arrow_y), (arrow_x + dx * width, arrow_y + dy * height), color=color,
|
||||
linewidth=3, arrowstyle='->', mutation_scale=20)
|
||||
ax.add_patch(arrow)
|
||||
|
||||
# N 文字位置:在箭头尾部或头部?通常放在箭头指向的反方向末端
|
||||
# 这里放在箭头尾部向外偏移一点(便于阅读)
|
||||
# 偏移系数根据方向决定
|
||||
offset_scale = 0.02 # 偏移量比例
|
||||
if direction == 'up':
|
||||
text_x = arrow_x
|
||||
text_y = arrow_y - height * offset_scale # 放在箭头下方
|
||||
elif direction == 'down':
|
||||
text_x = arrow_x
|
||||
text_y = arrow_y + height * offset_scale # 放在箭头上方
|
||||
elif direction == 'left':
|
||||
text_x = arrow_x + width * offset_scale
|
||||
text_y = arrow_y
|
||||
else: # right
|
||||
text_x = arrow_x - width * offset_scale
|
||||
text_y = arrow_y
|
||||
|
||||
ax.text(text_x, text_y, 'N', fontsize=14, fontweight='bold',
|
||||
color=n_color, ha='center', va='center',
|
||||
text_y = arrow_y - height * 0.02 if direction == 'up' else arrow_y + height * 0.02
|
||||
ax.text(arrow_x, text_y, 'N', fontsize=14, fontweight='bold', color=n_color, ha='center', va='center',
|
||||
path_effects=[path_effects.withStroke(linewidth=3, foreground=outline_color)])
|
||||
|
||||
def _add_scale_bar(self, ax, geotransform: tuple, width: int, height: int):
|
||||
"""添加比例尺"""
|
||||
if geotransform is None:
|
||||
return
|
||||
|
||||
# 计算图像实际宽度(米)
|
||||
if geotransform is None: return
|
||||
pixel_size_x = abs(geotransform[1])
|
||||
image_width_meters = width * pixel_size_x
|
||||
|
||||
# 选择合适的比例尺长度(图像宽度的1/4)
|
||||
scale_length_m = image_width_meters / 4
|
||||
scale_length_pixels = width / 4
|
||||
|
||||
# 找到合适的刻度
|
||||
scale_options = [1000, 500, 200, 100, 50, 20, 10, 5, 2, 1]
|
||||
scale_meters = next((s for s in scale_options if s <= scale_length_m), 1)
|
||||
|
||||
scale_length_m = (width * pixel_size_x) / 4
|
||||
scale_meters = next((s for s in [1000, 500, 200, 100, 50, 20, 10, 5, 2, 1] if s <= scale_length_m), 1)
|
||||
scale_pixels = int(scale_meters / pixel_size_x)
|
||||
bar_x, bar_y = width * 0.08, height * 0.92
|
||||
|
||||
# 在左下角添加比例尺
|
||||
bar_x = width * 0.08
|
||||
bar_y = height * 0.92
|
||||
|
||||
# 绘制比例尺线
|
||||
ax.plot([bar_x, bar_x + scale_pixels], [bar_y, bar_y], color='white', linewidth=4)
|
||||
|
||||
# 添加刻度线
|
||||
ax.plot([bar_x, bar_x], [bar_y, bar_y + 8], color='white', linewidth=2)
|
||||
ax.plot([bar_x + scale_pixels, bar_x + scale_pixels], [bar_y, bar_y + 8], color='white', linewidth=2)
|
||||
|
||||
# 添加文字
|
||||
ax.text(bar_x + scale_pixels/2, bar_y , f'{scale_meters} m',
|
||||
fontsize=11, ha='center', va='bottom', fontweight='bold',
|
||||
bbox=dict(facecolor='white', alpha=0.8, edgecolor='none', pad=1))
|
||||
|
||||
def batch_create_maps(self, hyperspectral_path: str,
|
||||
csv_folder: str,
|
||||
output_subdir: str = "sampling_maps",
|
||||
fast_mode: bool = True) -> Dict[str, str]:
|
||||
"""
|
||||
批量创建采样点地图
|
||||
|
||||
Args:
|
||||
hyperspectral_path: 高光谱影像路径
|
||||
csv_folder: 包含多个CSV文件的文件夹
|
||||
output_subdir: 输出子目录
|
||||
|
||||
Returns:
|
||||
生成的地图文件路径字典
|
||||
"""
|
||||
csv_folder_path = Path(csv_folder)
|
||||
if not csv_folder_path.exists():
|
||||
raise FileNotFoundError(f"CSV文件夹不存在: {csv_folder}")
|
||||
|
||||
# 创建输出目录
|
||||
output_dir = self.output_dir / output_subdir
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
map_paths = {}
|
||||
|
||||
# 查找所有CSV文件
|
||||
csv_files = list(csv_folder_path.glob("*.csv"))
|
||||
|
||||
print(f"找到 {len(csv_files)} 个CSV文件,开始批量生成采样点地图... (快速模式: {fast_mode})")
|
||||
|
||||
for csv_file in csv_files:
|
||||
try:
|
||||
output_filename = f"{Path(hyperspectral_path).stem}_{csv_file.stem}_sampling_map.png"
|
||||
map_path = self.create_sampling_point_map(
|
||||
hyperspectral_path=hyperspectral_path,
|
||||
csv_path=str(csv_file),
|
||||
output_filename=output_filename,
|
||||
downsample=True, # 批量模式默认下采样
|
||||
dpi=120 if fast_mode else 200
|
||||
)
|
||||
map_paths[csv_file.name] = map_path
|
||||
print(f"✓ 生成: {csv_file.name}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ 处理 {csv_file.name} 失败: {e}")
|
||||
|
||||
print(f"批量生成完成,共生成 {len(map_paths)} 个采样点地图")
|
||||
return map_paths
|
||||
|
||||
|
||||
# 测试代码
|
||||
if __name__ == "__main__":
|
||||
# 示例用法
|
||||
map_generator = SamplingPointMap(output_dir="./point_maps")
|
||||
|
||||
# 测试代码已禁用,避免直接运行时出错
|
||||
map_generator_fast = SamplingPointMap(output_dir="./point_maps", fast_mode=True)
|
||||
map_path = map_generator_fast.create_sampling_point_map(
|
||||
hyperspectral_path=r"D:\BaiduNetdiskDownload\yaobao\result3.bsq",
|
||||
csv_path=r"E:\code\WQ\pipeline_result\work_dir\4_processed_data\processed_data.csv",
|
||||
downsample=True,
|
||||
dpi=150
|
||||
)
|
||||
print("测试代码已注释,请通过GUI或手动调用使用。")
|
||||
|
||||
print("SamplingPointMap类已创建,可以用于生成带采样点的地图。")
|
||||
print("性能优化功能:")
|
||||
print(" - fast_mode=True: 快速模式 (推荐用于预览)")
|
||||
print(" - downsample=True: 对大影像下采样 (推荐用于>2000x2000影像)")
|
||||
print(" - 使用: SamplingPointMap(fast_mode=True).create_sampling_point_map(...)")
|
||||
ax.text(bar_x + scale_pixels / 2, bar_y, f'{scale_meters} m', fontsize=11, ha='center', va='bottom',
|
||||
fontweight='bold', bbox=dict(facecolor='white', alpha=0.8, edgecolor='none', pad=1))
|
||||
@ -385,7 +385,7 @@ class WaterQualityVisualization:
|
||||
return output_paths
|
||||
|
||||
def plot_distribution_map_enhanced(self, prediction_csv_path: str,
|
||||
boundary_shp_path: str,
|
||||
boundary_shp_path: Optional[str] = None, # ★★★ Plan C: None = 不依赖水域掩膜 ★★★
|
||||
parameter_column: str = 'prediction',
|
||||
output_path: Optional[str] = None,
|
||||
resolution: float = 30,
|
||||
@ -394,12 +394,12 @@ class WaterQualityVisualization:
|
||||
colormap: str = 'viridis') -> str:
|
||||
"""
|
||||
生成增强的含量分布图(彩色填充图)
|
||||
|
||||
这是对step9的增强版本,使用更丰富的颜色映射
|
||||
|
||||
|
||||
★★★ Plan C:boundary_shp_path 可选(None = 不依赖水域掩膜)★★★
|
||||
|
||||
Args:
|
||||
prediction_csv_path: 预测结果CSV文件路径
|
||||
boundary_shp_path: 边界shapefile文件路径
|
||||
boundary_shp_path: 边界/掩膜文件路径。None 时跳过水域掩膜约束。
|
||||
parameter_column: 参数值列名
|
||||
output_path: 输出图片路径
|
||||
resolution: 插值网格分辨率
|
||||
|
||||
@ -99,8 +99,10 @@ class BandMathCalculator:
|
||||
# 【新增安全防护】引入 numpy 命名空间,让 eval 引擎安全识别 nan 与 inf
|
||||
import numpy as np
|
||||
try:
|
||||
# 即使 calc_expression 含有纯字符 nan,也能被 np.nan 安全接管
|
||||
result = eval(calc_expression, {"__builtins__": None}, {"nan": np.nan, "inf": np.inf, "np": np})
|
||||
# 【P0 修复】包 np.errstate 抑制除零 / 无效操作产生的 RuntimeWarning 洪水
|
||||
with np.errstate(divide='ignore', invalid='ignore'):
|
||||
# 即使 calc_expression 含有纯字符 nan,也能被 np.nan 安全接管
|
||||
result = eval(calc_expression, {"__builtins__": None}, {"nan": np.nan, "inf": np.inf, "np": np})
|
||||
except Exception as e:
|
||||
print(f"⚠️ 警告:公式计算异常 ({e}),该点赋值为 nan")
|
||||
result = np.nan
|
||||
|
||||
@ -117,7 +117,9 @@ class WaterQualityIndexCalculator:
|
||||
calc_expr,
|
||||
)
|
||||
try:
|
||||
r = eval(calc_expr, {"__builtins__": None}, {"nan": np.nan, "inf": np.inf, "np": np})
|
||||
# 【P0 修复】包 np.errstate 抑制除零 / 无效操作产生的 RuntimeWarning 洪水
|
||||
with np.errstate(divide='ignore', invalid='ignore'):
|
||||
r = eval(calc_expr, {"__builtins__": None}, {"nan": np.nan, "inf": np.inf, "np": np})
|
||||
except Exception:
|
||||
r = np.nan
|
||||
results.append(r)
|
||||
|
||||
Reference in New Issue
Block a user