#!/usr/bin/env python # -*- coding: utf-8 -*- """ 采样点地图生成模块 - 在高光谱假彩色影像上标注采样点 """ import numpy as np import pandas as pd import matplotlib.pyplot as plt from pathlib import Path from typing import Optional, Tuple, List, Dict, Union import warnings from matplotlib.patches import FancyArrowPatch import matplotlib.patheffects as path_effects # 性能优化配置 plt.rcParams['agg.path.chunksize'] = 10000 plt.rcParams['path.simplify'] = True plt.rcParams['path.simplify_threshold'] = 0.1 try: from osgeo import gdal, osr GDAL_AVAILABLE = True except ImportError: GDAL_AVAILABLE = False print("警告: GDAL未安装,地理坐标转换功能可能无法正常工作") class SamplingPointMap: def __init__(self, output_dir: str = "./point_maps", fast_mode: bool = False): 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 warnings.filterwarnings('ignore', category=UserWarning) 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: if not GDAL_AVAILABLE: raise ImportError("GDAL未安装,无法处理地理坐标转换") print(f"正在生成采样点地图...{' (快速模式)' if self.fast_mode else ''}") 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 ) 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]: dataset = gdal.Open(hyperspectral_path) if dataset is None: raise ValueError(f"无法打开高光谱影像: {hyperspectral_path}") width = dataset.RasterXSize height = dataset.RasterYSize band_count = dataset.RasterCount if rgb_bands is None: if band_count >= 3: try: from src.utils.util import find_band_number rgb_bands = [ find_band_number(650.0, hyperspectral_path), find_band_number(550.0, hyperspectral_path), find_band_number(460.0, hyperspectral_path) ] 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] if downsample and (width > 2000 or height > 2000): print(f" ⚠ 下采样暂被禁用,使用原始分辨率: {width}x{height}") sample_factor = 1 else: sample_factor = 1 rgb_data = [] for band_idx in rgb_bands: band = dataset.GetRasterBand(band_idx + 1) band_data = band.ReadAsArray().astype(np.float32) rgb_data.append(band_data) if len(rgb_data) == 3: image_array = np.stack(rgb_data, axis=2) else: image_array = np.stack([rgb_data[0]] * 3, axis=2) geotransform = dataset.GetGeoTransform() projection = dataset.GetProjection() dataset = None return image_array, geotransform, projection, width, height, sample_factor def _read_sampling_points(self, csv_path: str) -> pd.DataFrame: """智能读取采样点,自动识别模糊列名,允许UTM坐标,自动修复颠倒坐标""" df = pd.read_csv(csv_path) if len(df.columns) < 2: raise ValueError("CSV文件至少需要两列(经度、纬度 或 X、Y)") # 智能子串匹配 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) if len(df) > 0: lat_max = df['latitude'].abs().max() lon_max = df['longitude'].abs().max() # 智能对调:如果纬度 > 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: if image_array.shape[2] != 3: if len(image_array.shape) == 2 or image_array.shape[2] == 1: image_array = np.stack([image_array] * 3, axis=2) if len(image_array.shape) == 2 else np.repeat( image_array, 3, axis=2) 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) p_low = np.percentile(valid_data, min_percent) p_high = np.percentile(valid_data, max_percent) if p_high - p_low < 1e-8: 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) 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_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) 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))] pixel_coords = [] gt = geotransform needs_transform = projection and ("PROJCS" in projection or "GEOGCS" in projection) # 智能判定是否为 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) transform = None if needs_transform and is_wgs84 and GDAL_AVAILABLE: try: src_srs = osr.SpatialReference() src_srs.ImportFromEPSG(4326) dst_srs = osr.SpatialReference() dst_srs.ImportFromWkt(projection) transform = osr.CoordinateTransformation(src_srs, dst_srs) except Exception as e: transform = None elif not is_wgs84: print(" ℹ 采样点为投影坐标(UTM),跳过WGS84投影转换,直接使用放射变换映射") for _, row in sampling_points.iterrows(): lon, lat = float(row['longitude']), float(row['latitude']) if transform is not None: try: 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, y = (lon - gt[0]) / gt[1], (lat - gt[3]) / gt[5] if sample_factor > 1: x, y = x / sample_factor, y / sample_factor 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): 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) ax.imshow(rgb_image, interpolation='nearest' if self.fast_mode else 'bilinear') if pixel_coords: 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_scale_bar and geotransform is not None: self._add_scale_bar(ax, geotransform, width, height) if show_legend: 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': 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'): 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)) 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) 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 pixel_size_x = abs(geotransform[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 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))