2026-04-08 15:25:08 +08:00
|
|
|
|
#!/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
|
|
|
|
|
|
|
|
|
|
|
|
# 性能优化配置
|
2026-06-25 15:50:02 +08:00
|
|
|
|
plt.rcParams['agg.path.chunksize'] = 10000
|
2026-04-08 15:25:08 +08:00
|
|
|
|
plt.rcParams['path.simplify'] = True
|
|
|
|
|
|
plt.rcParams['path.simplify_threshold'] = 0.1
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from osgeo import gdal, osr
|
2026-06-25 15:50:02 +08:00
|
|
|
|
|
2026-04-08 15:25:08 +08:00
|
|
|
|
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')
|
|
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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:
|
2026-04-08 15:25:08 +08:00
|
|
|
|
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(
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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
|
2026-04-08 15:25:08 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
print(f"采样点地图已保存: {output_path}")
|
|
|
|
|
|
return str(output_path)
|
|
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
def _read_hyperspectral(self, hyperspectral_path: str, rgb_bands: Optional[List[int]] = None,
|
|
|
|
|
|
downsample: bool = False) -> Tuple[np.ndarray, tuple, str, int, int]:
|
2026-04-08 15:25:08 +08:00
|
|
|
|
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 = [
|
2026-06-25 15:50:02 +08:00
|
|
|
|
find_band_number(650.0, hyperspectral_path),
|
|
|
|
|
|
find_band_number(550.0, hyperspectral_path),
|
|
|
|
|
|
find_band_number(460.0, hyperspectral_path)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
]
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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))]
|
2026-04-08 15:25:08 +08:00
|
|
|
|
else:
|
|
|
|
|
|
rgb_bands = [0, 0, 0]
|
|
|
|
|
|
|
|
|
|
|
|
if downsample and (width > 2000 or height > 2000):
|
2026-06-25 15:50:02 +08:00
|
|
|
|
print(f" ⚠ 下采样暂被禁用,使用原始分辨率: {width}x{height}")
|
2026-04-08 15:25:08 +08:00
|
|
|
|
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:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
image_array = np.stack([rgb_data[0]] * 3, axis=2)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
geotransform = dataset.GetGeoTransform()
|
|
|
|
|
|
projection = dataset.GetProjection()
|
|
|
|
|
|
dataset = None
|
|
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
return image_array, geotransform, projection, width, height, sample_factor
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
def _read_sampling_points(self, csv_path: str) -> pd.DataFrame:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
"""智能读取采样点,自动识别模糊列名,允许UTM坐标,自动修复颠倒坐标"""
|
2026-04-08 15:25:08 +08:00
|
|
|
|
df = pd.read_csv(csv_path)
|
|
|
|
|
|
if len(df.columns) < 2:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
raise ValueError("CSV文件至少需要两列(经度、纬度 或 X、Y)")
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
# 智能子串匹配
|
|
|
|
|
|
lat_aliases = ['lat', 'y', '纬']
|
|
|
|
|
|
lon_aliases = ['lon', 'lng', 'x', '经']
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
lat_col = None
|
|
|
|
|
|
lon_col = None
|
|
|
|
|
|
cols_lower = {c: str(c).strip().lower() for c in df.columns}
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
# 兜底:取前两列,默认列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
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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)} 个有效采样点 (不再拦截越界拦截)")
|
2026-04-08 15:25:08 +08:00
|
|
|
|
return df
|
|
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
def _create_false_color_image(self, image_array: np.ndarray, rgb_bands: Optional[List[int]] = None) -> np.ndarray:
|
2026-04-08 15:25:08 +08:00
|
|
|
|
if image_array.shape[2] != 3:
|
|
|
|
|
|
if len(image_array.shape) == 2 or image_array.shape[2] == 1:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
image_array = np.stack([image_array] * 3, axis=2) if len(image_array.shape) == 2 else np.repeat(
|
|
|
|
|
|
image_array, 3, axis=2)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
def simple_linear_stretch(data, min_percent=1, max_percent=99):
|
|
|
|
|
|
valid_data = data[np.isfinite(data)]
|
2026-06-25 15:50:02 +08:00
|
|
|
|
if len(valid_data) == 0: return np.zeros_like(data, dtype=np.float32)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
p_low = np.percentile(valid_data, min_percent)
|
|
|
|
|
|
p_high = np.percentile(valid_data, max_percent)
|
|
|
|
|
|
if p_high - p_low < 1e-8:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
r_stretched = simple_linear_stretch(image_array[:, :, 0])
|
|
|
|
|
|
g_stretched = simple_linear_stretch(image_array[:, :, 1])
|
|
|
|
|
|
b_stretched = simple_linear_stretch(image_array[:, :, 2])
|
2026-06-25 15:50:02 +08:00
|
|
|
|
rgb_image = np.nan_to_num(np.stack([r_stretched, g_stretched, b_stretched], axis=2), nan=0.0)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
rgb_image = np.clip(rgb_image, 0.0, 1.0)
|
2026-06-25 15:50:02 +08:00
|
|
|
|
return (rgb_image * 255).astype(np.uint8)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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]]:
|
2026-04-08 15:25:08 +08:00
|
|
|
|
if geotransform is None or len(sampling_points) == 0:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
return [(width / 2, height / 2) for _ in range(len(sampling_points))]
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
pixel_coords = []
|
|
|
|
|
|
gt = geotransform
|
2026-06-25 15:50:02 +08:00
|
|
|
|
needs_transform = projection and ("PROJCS" in projection or "GEOGCS" in projection)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
# 智能判定是否为 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)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
transform = None
|
2026-06-25 15:50:02 +08:00
|
|
|
|
if needs_transform and is_wgs84 and GDAL_AVAILABLE:
|
2026-04-08 15:25:08 +08:00
|
|
|
|
try:
|
|
|
|
|
|
src_srs = osr.SpatialReference()
|
2026-06-25 15:50:02 +08:00
|
|
|
|
src_srs.ImportFromEPSG(4326)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
dst_srs = osr.SpatialReference()
|
|
|
|
|
|
dst_srs.ImportFromWkt(projection)
|
|
|
|
|
|
transform = osr.CoordinateTransformation(src_srs, dst_srs)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
transform = None
|
2026-06-25 15:50:02 +08:00
|
|
|
|
elif not is_wgs84:
|
|
|
|
|
|
print(" ℹ 采样点为投影坐标(UTM),跳过WGS84投影转换,直接使用放射变换映射")
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
for _, row in sampling_points.iterrows():
|
2026-06-25 15:50:02 +08:00
|
|
|
|
lon, lat = float(row['longitude']), float(row['latitude'])
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
if transform is not None:
|
|
|
|
|
|
try:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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
|
2026-04-08 15:25:08 +08:00
|
|
|
|
else:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
x, y = (lon - gt[0]) / gt[1], (lat - gt[3]) / gt[5]
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
if sample_factor > 1:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
x, y = x / sample_factor, y / sample_factor
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
pixel_coords.append((max(0, min(x, width - 1)), max(0, min(y, height - 1))))
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
return pixel_coords
|
|
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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):
|
2026-04-08 15:25:08 +08:00
|
|
|
|
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:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
if show_legend:
|
2026-06-25 15:50:02 +08:00
|
|
|
|
ax.plot([], [], 'o', color=point_color, markersize=8, label=f'采样点 (n={len(sampling_points)})')
|
2026-04-08 15:25:08 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
save_kwargs = {'dpi': min(dpi, 180) if self.fast_mode else dpi, 'bbox_inches': 'tight', 'pad_inches': 0.05,
|
|
|
|
|
|
'facecolor': 'white'}
|
2026-04-08 15:25:08 +08:00
|
|
|
|
plt.savefig(output_path, **save_kwargs)
|
|
|
|
|
|
plt.close(fig)
|
|
|
|
|
|
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
ax.add_patch(arrow)
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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',
|
2026-04-08 15:25:08 +08:00
|
|
|
|
path_effects=[path_effects.withStroke(linewidth=3, foreground=outline_color)])
|
|
|
|
|
|
|
|
|
|
|
|
def _add_scale_bar(self, ax, geotransform: tuple, width: int, height: int):
|
2026-06-25 15:50:02 +08:00
|
|
|
|
if geotransform is None: return
|
2026-04-08 15:25:08 +08:00
|
|
|
|
pixel_size_x = abs(geotransform[1])
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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)
|
2026-04-08 15:25:08 +08:00
|
|
|
|
scale_pixels = int(scale_meters / pixel_size_x)
|
2026-06-25 15:50:02 +08:00
|
|
|
|
bar_x, bar_y = width * 0.08, height * 0.92
|
2026-04-08 15:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-06-25 15:50:02 +08:00
|
|
|
|
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))
|