chore: initialize insar management system v2
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
"""
|
||||
图像处理服务
|
||||
|
||||
提供 D-InSAR 结果图像处理功能:
|
||||
- 生成可视化图像
|
||||
- 提取影像 footprint
|
||||
- 加载颜色表
|
||||
- 生成缩略图
|
||||
|
||||
优化策略:
|
||||
- 使用 GDAL 降采样提取 footprint,减少内存占用
|
||||
- 颜色表使用单例模式缓存
|
||||
- 支持自动透明边缘裁剪
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
from typing import Tuple, Optional, Dict, Any, List
|
||||
from PIL import Image
|
||||
import rasterio
|
||||
from rasterio.enums import Resampling
|
||||
from rasterio.warp import transform as transform_coords, transform_bounds
|
||||
from rasterio.features import shapes
|
||||
from rasterio.transform import Affine
|
||||
import numpy as np
|
||||
import matplotlib.cm as matplotlib_cm
|
||||
import matplotlib.colors as mcolors
|
||||
from shapely.geometry import shape, Polygon, mapping
|
||||
from shapely.ops import unary_union, transform as shapely_transform
|
||||
from pyproj import Transformer
|
||||
|
||||
from ..config import settings
|
||||
|
||||
|
||||
class ImageService:
|
||||
"""
|
||||
图像处理服务
|
||||
|
||||
提供 D-InSAR 结果图像的读取、处理和可视化功能。
|
||||
使用 GDAL 降采样策略优化大数据量图像处理。
|
||||
"""
|
||||
|
||||
# 颜色表缓存(单例模式)
|
||||
_colormap_cache: Optional[Tuple[mcolors.LinearSegmentedColormap, float, float]] = None
|
||||
_colormap_filename: Optional[str] = None
|
||||
|
||||
# 默认缩略图尺寸
|
||||
DEFAULT_THUMBNAIL_SIZE = (
|
||||
settings.DINSAR_THUMBNAIL_MAX_SIZE,
|
||||
settings.DINSAR_THUMBNAIL_MAX_SIZE
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_dinsar_image(
|
||||
file_path: str,
|
||||
auto_stretch: bool = False,
|
||||
max_size: Optional[Tuple[int, int]] = None
|
||||
) -> Image.Image:
|
||||
"""
|
||||
读取 D-InSAR 结果文件,应用颜色表,返回 PIL.Image 对象。
|
||||
|
||||
Args:
|
||||
file_path: 结果文件路径
|
||||
auto_stretch: 是否使用分位数自动拉伸
|
||||
|
||||
Returns:
|
||||
处理后的 PIL.Image 对象
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"结果文件不存在: {file_path}")
|
||||
|
||||
# 加载颜色表
|
||||
try:
|
||||
custom_cmap, vmin, vmax = ImageService.load_colormap()
|
||||
if auto_stretch:
|
||||
vmin, vmax = None, None
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
print(f"错误: 无法加载自定义颜色表, 将回退到默认值. 错误: {e}")
|
||||
try:
|
||||
custom_cmap = mcolors.colormaps.get('viridis')
|
||||
except AttributeError:
|
||||
custom_cmap = matplotlib_cm.get_cmap('viridis')
|
||||
vmin, vmax = None, None
|
||||
|
||||
with rasterio.open(file_path) as dataset:
|
||||
if max_size:
|
||||
max_w, max_h = max_size
|
||||
scale = max(dataset.width / max_w, dataset.height / max_h, 1)
|
||||
out_w = max(1, int(dataset.width / scale))
|
||||
out_h = max(1, int(dataset.height / scale))
|
||||
data = dataset.read(
|
||||
1,
|
||||
out_shape=(out_h, out_w),
|
||||
resampling=Resampling.bilinear
|
||||
).astype(float)
|
||||
mask = dataset.dataset_mask(out_shape=(out_h, out_w))
|
||||
else:
|
||||
data = dataset.read(1).astype(float)
|
||||
mask = dataset.dataset_mask()
|
||||
|
||||
if np.all(mask == 0):
|
||||
img_array = np.zeros((data.shape[0], data.shape[1], 4), dtype=np.uint8)
|
||||
else:
|
||||
valid_data = data[mask == 255]
|
||||
|
||||
if vmin is not None and vmax is not None and vmax > vmin:
|
||||
data_normalized = (data - vmin) / (vmax - vmin)
|
||||
else:
|
||||
p2, p98 = np.nanpercentile(valid_data, (2, 98))
|
||||
if p98 > p2:
|
||||
data_normalized = (data - p2) / (p98 - p2)
|
||||
else:
|
||||
data_normalized = np.zeros_like(data, dtype=float)
|
||||
|
||||
data_normalized = np.clip(data_normalized, 0, 1)
|
||||
colored_data = custom_cmap(data_normalized)
|
||||
|
||||
# 强制 NoData 区域透明
|
||||
colored_data[mask == 0] = (0, 0, 0, 0)
|
||||
img_array = (colored_data * 255).astype(np.uint8)
|
||||
|
||||
return Image.fromarray(img_array)
|
||||
|
||||
@staticmethod
|
||||
def generate_thumbnail(
|
||||
image: Image.Image,
|
||||
max_size: Optional[Tuple[int, int]] = None
|
||||
) -> Image.Image:
|
||||
"""
|
||||
生成缩略图。
|
||||
|
||||
Args:
|
||||
image: 原始 PIL.Image
|
||||
max_size: 最大尺寸 (宽, 高),默认使用 DEFAULT_THUMBNAIL_SIZE
|
||||
|
||||
Returns:
|
||||
缩略图
|
||||
"""
|
||||
if max_size is None:
|
||||
max_size = ImageService.DEFAULT_THUMBNAIL_SIZE
|
||||
|
||||
# 自动裁剪透明边缘
|
||||
bbox = image.getbbox()
|
||||
if bbox:
|
||||
image = image.crop(bbox)
|
||||
|
||||
# 生成缩略图
|
||||
thumb = image.copy()
|
||||
thumb.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
return thumb
|
||||
|
||||
@staticmethod
|
||||
def load_colormap(
|
||||
file_name: str = "qgis_color.txt"
|
||||
) -> Tuple[mcolors.LinearSegmentedColormap, float, float]:
|
||||
"""
|
||||
加载 QGIS 导出的颜色表(带缓存)。
|
||||
|
||||
Args:
|
||||
file_name: 颜色表文件名
|
||||
|
||||
Returns:
|
||||
(colormap, vmin, vmax) 元组
|
||||
"""
|
||||
# 检查缓存
|
||||
if (
|
||||
ImageService._colormap_cache is not None and
|
||||
ImageService._colormap_filename == file_name
|
||||
):
|
||||
return ImageService._colormap_cache
|
||||
|
||||
# 构建文件路径
|
||||
cmap_path = os.path.join(settings.COLORMAPS_DIR, file_name)
|
||||
|
||||
if not os.path.exists(cmap_path):
|
||||
raise FileNotFoundError(f"色彩映射文件未找到: {cmap_path}")
|
||||
|
||||
# 解析颜色表
|
||||
colors_data = []
|
||||
with open(cmap_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or line.startswith('INTERPOLATION'):
|
||||
continue
|
||||
|
||||
parts = line.split(',')
|
||||
try:
|
||||
value = float(parts[0])
|
||||
r, g, b, a = [int(p) for p in parts[1:5]]
|
||||
colors_data.append((value, (r / 255.0, g / 255.0, b / 255.0, a / 255.0)))
|
||||
except (ValueError, IndexError):
|
||||
print(f"警告: 无法解析颜色行: {line}")
|
||||
continue
|
||||
|
||||
if not colors_data:
|
||||
raise ValueError("色彩映射文件中没有有效的颜色数据。")
|
||||
|
||||
# 按值排序
|
||||
colors_data.sort(key=lambda x: x[0])
|
||||
|
||||
vmin = colors_data[0][0]
|
||||
vmax = colors_data[-1][0]
|
||||
|
||||
# 归一化颜色点位置
|
||||
if vmax == vmin:
|
||||
norm_points = [0.0] * len(colors_data)
|
||||
else:
|
||||
norm_points = [(item[0] - vmin) / (vmax - vmin) for item in colors_data]
|
||||
|
||||
colors = [item[1] for item in colors_data]
|
||||
|
||||
# 构建颜色列表
|
||||
cmap_list = []
|
||||
last_pos = -1.0
|
||||
for i, pos in enumerate(norm_points):
|
||||
pos = max(pos, last_pos + 1e-6)
|
||||
cmap_list.append((min(pos, 1.0), colors[i]))
|
||||
last_pos = pos
|
||||
|
||||
custom_cmap = mcolors.LinearSegmentedColormap.from_list('qgis_custom', cmap_list)
|
||||
|
||||
# 更新缓存
|
||||
ImageService._colormap_cache = (custom_cmap, vmin, vmax)
|
||||
ImageService._colormap_filename = file_name
|
||||
|
||||
return custom_cmap, vmin, vmax
|
||||
|
||||
@staticmethod
|
||||
def clear_colormap_cache():
|
||||
"""清除颜色表缓存(用于测试或强制刷新)"""
|
||||
ImageService._colormap_cache = None
|
||||
ImageService._colormap_filename = None
|
||||
|
||||
@staticmethod
|
||||
def extract_footprint(file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
提取 D-InSAR 结果的 footprint(使用 GDAL 降采样策略)。
|
||||
|
||||
极速优化版 V2:借鉴 QGIS/GDAL 降采样策略,仅读取缩略图掩码计算 Footprint。
|
||||
|
||||
Args:
|
||||
file_path: 影像文件路径
|
||||
|
||||
Returns:
|
||||
{
|
||||
"min_lon": float,
|
||||
"min_lat": float,
|
||||
"max_lon": float,
|
||||
"max_lat": float,
|
||||
"coverage_polygon": dict # GeoJSON 格式
|
||||
}
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
with rasterio.open(file_path) as dataset:
|
||||
# 1. 智能降采样读取掩码
|
||||
MAX_SIZE = settings.DINSAR_FOOTPRINT_MAX_SIZE
|
||||
if dataset.width > MAX_SIZE or dataset.height > MAX_SIZE:
|
||||
scale = max(dataset.width, dataset.height) / MAX_SIZE
|
||||
new_width = max(1, int(dataset.width / scale))
|
||||
new_height = max(1, int(dataset.height / scale))
|
||||
mask = dataset.dataset_mask(out_shape=(new_height, new_width))
|
||||
rescale_transform = dataset.transform * Affine.scale(
|
||||
dataset.width / new_width,
|
||||
dataset.height / new_height
|
||||
)
|
||||
else:
|
||||
mask = dataset.dataset_mask()
|
||||
rescale_transform = dataset.transform
|
||||
|
||||
# 2. 提取有效区域多边形
|
||||
mask_shapes = list(shapes(mask, mask=(mask == 255), transform=rescale_transform))
|
||||
|
||||
if not mask_shapes:
|
||||
# 回退到全图范围
|
||||
footprint_poly = Polygon([
|
||||
dataset.transform * (0, 0),
|
||||
dataset.transform * (dataset.width, 0),
|
||||
dataset.transform * (dataset.width, dataset.height),
|
||||
dataset.transform * (0, dataset.height),
|
||||
dataset.transform * (0, 0)
|
||||
])
|
||||
else:
|
||||
# 合并形状并取凸包
|
||||
polys = [shape(s) for s, v in mask_shapes]
|
||||
footprint_poly = unary_union(polys).convex_hull
|
||||
|
||||
if footprint_poly.geom_type == 'Polygon':
|
||||
tolerance = abs(rescale_transform[0]) * 1.5
|
||||
footprint_poly = footprint_poly.simplify(
|
||||
tolerance,
|
||||
preserve_topology=True
|
||||
)
|
||||
|
||||
elapsed = (time.time() - start_time) * 1000
|
||||
print(f" [性能] Footprint 提取耗时: {elapsed:.1f}ms (文件: {os.path.basename(file_path)})")
|
||||
|
||||
# 3. 坐标系转换到 WGS84
|
||||
if dataset.crs and dataset.crs.to_epsg() != 4326:
|
||||
transformer = Transformer.from_crs(
|
||||
dataset.crs,
|
||||
"EPSG:4326",
|
||||
always_xy=True
|
||||
)
|
||||
footprint_poly = shapely_transform(
|
||||
transformer.transform,
|
||||
footprint_poly
|
||||
)
|
||||
|
||||
# 4. 返回紧凑边界框
|
||||
left, bottom, right, top = footprint_poly.bounds
|
||||
|
||||
return {
|
||||
"min_lon": left,
|
||||
"min_lat": bottom,
|
||||
"max_lon": right,
|
||||
"max_lat": top,
|
||||
"coverage_polygon": mapping(footprint_poly)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def save_image_as_webp(
|
||||
image: Image.Image,
|
||||
output_path: str,
|
||||
quality: int = 80
|
||||
) -> None:
|
||||
"""
|
||||
保存图像为 WebP 格式。
|
||||
|
||||
Args:
|
||||
image: PIL.Image 对象
|
||||
output_path: 输出文件路径
|
||||
quality: WebP 质量 (1-100)
|
||||
"""
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
image.save(output_path, format='WEBP', quality=quality)
|
||||
|
||||
@staticmethod
|
||||
def create_cached_image(
|
||||
file_path: str,
|
||||
cache_path: str,
|
||||
thumbnail_size: Optional[Tuple[int, int]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
创建并保存缓存图像。
|
||||
|
||||
Args:
|
||||
file_path: 原始文件路径
|
||||
cache_path: 缓存文件路径
|
||||
thumbnail_size: 缩略图尺寸
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
# 生成图像
|
||||
full_res_img = ImageService.create_dinsar_image(
|
||||
file_path,
|
||||
max_size=thumbnail_size or ImageService.DEFAULT_THUMBNAIL_SIZE
|
||||
)
|
||||
|
||||
# 自动裁剪透明边缘
|
||||
bbox = full_res_img.getbbox()
|
||||
if bbox:
|
||||
full_res_img = full_res_img.crop(bbox)
|
||||
|
||||
# 生成缩略图
|
||||
thumbnail = ImageService.generate_thumbnail(
|
||||
full_res_img,
|
||||
thumbnail_size or ImageService.DEFAULT_THUMBNAIL_SIZE
|
||||
)
|
||||
|
||||
# 保存为 WebP
|
||||
ImageService.save_image_as_webp(thumbnail, cache_path, quality=80)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"创建缓存图像失败: {file_path}, 错误: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _extract_quadrilateral_points(coverage_polygon: Any) -> Optional[np.ndarray]:
|
||||
points: List[Tuple[float, float]] = []
|
||||
if isinstance(coverage_polygon, dict):
|
||||
coordinates = coverage_polygon.get("coordinates") if coverage_polygon else None
|
||||
if isinstance(coordinates, list) and coordinates:
|
||||
first_ring = coordinates[0]
|
||||
if isinstance(first_ring, list):
|
||||
for point in first_ring:
|
||||
if isinstance(point, (list, tuple)) and len(point) >= 2:
|
||||
points.append((float(point[0]), float(point[1])))
|
||||
elif isinstance(coverage_polygon, list):
|
||||
for point in coverage_polygon:
|
||||
if isinstance(point, (list, tuple)) and len(point) >= 2:
|
||||
points.append((float(point[0]), float(point[1])))
|
||||
|
||||
if len(points) < 4:
|
||||
return None
|
||||
|
||||
dedup: List[Tuple[float, float]] = []
|
||||
for lon, lat in points:
|
||||
if not dedup:
|
||||
dedup.append((lon, lat))
|
||||
continue
|
||||
prev_lon, prev_lat = dedup[-1]
|
||||
if abs(prev_lon - lon) < 1e-10 and abs(prev_lat - lat) < 1e-10:
|
||||
continue
|
||||
dedup.append((lon, lat))
|
||||
|
||||
if len(dedup) >= 5:
|
||||
first_lon, first_lat = dedup[0]
|
||||
last_lon, last_lat = dedup[-1]
|
||||
if abs(first_lon - last_lon) < 1e-10 and abs(first_lat - last_lat) < 1e-10:
|
||||
dedup = dedup[:-1]
|
||||
|
||||
if len(dedup) != 4:
|
||||
return None
|
||||
|
||||
return np.asarray(dedup, dtype=np.float64)
|
||||
|
||||
@staticmethod
|
||||
def _extract_source_corner_mapping_points(source_corner_mapping: Any) -> Optional[np.ndarray]:
|
||||
if not isinstance(source_corner_mapping, dict):
|
||||
return None
|
||||
|
||||
ordered_keys = ["bottom_left", "bottom_right", "top_right", "top_left"]
|
||||
points: List[Tuple[float, float]] = []
|
||||
for key in ordered_keys:
|
||||
value = source_corner_mapping.get(key)
|
||||
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||
return None
|
||||
try:
|
||||
points.append((float(value[0]), float(value[1])))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return np.asarray(points, dtype=np.float64)
|
||||
|
||||
@staticmethod
|
||||
def _compute_homography(src_points: np.ndarray, dst_points: np.ndarray) -> Optional[np.ndarray]:
|
||||
if src_points.shape != (4, 2) or dst_points.shape != (4, 2):
|
||||
return None
|
||||
|
||||
matrix_a: List[List[float]] = []
|
||||
matrix_b: List[float] = []
|
||||
for (sx, sy), (dx, dy) in zip(src_points, dst_points):
|
||||
matrix_a.append([sx, sy, 1.0, 0.0, 0.0, 0.0, -dx * sx, -dx * sy])
|
||||
matrix_b.append(float(dx))
|
||||
matrix_a.append([0.0, 0.0, 0.0, sx, sy, 1.0, -dy * sx, -dy * sy])
|
||||
matrix_b.append(float(dy))
|
||||
|
||||
try:
|
||||
solved, _, rank, _ = np.linalg.lstsq(
|
||||
np.asarray(matrix_a, dtype=np.float64),
|
||||
np.asarray(matrix_b, dtype=np.float64),
|
||||
rcond=None,
|
||||
)
|
||||
except np.linalg.LinAlgError:
|
||||
return None
|
||||
|
||||
if rank < 8:
|
||||
return None
|
||||
|
||||
return np.array(
|
||||
[
|
||||
[solved[0], solved[1], solved[2]],
|
||||
[solved[3], solved[4], solved[5]],
|
||||
[solved[6], solved[7], 1.0],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _estimate_geo_canvas_size(
|
||||
bbox: Tuple[float, float, float, float],
|
||||
max_size: Tuple[int, int],
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
min_lon, min_lat, max_lon, max_lat = bbox
|
||||
lon_span = max_lon - min_lon
|
||||
lat_span = max_lat - min_lat
|
||||
if lon_span <= 0 or lat_span <= 0:
|
||||
return None
|
||||
|
||||
mean_lat_rad = np.deg2rad((min_lat + max_lat) * 0.5)
|
||||
x_span_scaled = max(lon_span * max(np.cos(mean_lat_rad), 1e-3), 1e-9)
|
||||
y_span_scaled = max(lat_span, 1e-9)
|
||||
ratio = x_span_scaled / y_span_scaled
|
||||
|
||||
limit = max(64, int(max(max_size[0], max_size[1])))
|
||||
if ratio >= 1:
|
||||
width = limit
|
||||
height = max(64, int(round(width / ratio)))
|
||||
else:
|
||||
height = limit
|
||||
width = max(64, int(round(height * ratio)))
|
||||
return width, height
|
||||
|
||||
@staticmethod
|
||||
def _warp_preview_to_geo_bbox(
|
||||
source_rgb: np.ndarray,
|
||||
inverse_h: np.ndarray,
|
||||
bbox: Tuple[float, float, float, float],
|
||||
out_size: Tuple[int, int],
|
||||
) -> np.ndarray:
|
||||
src_h, src_w = source_rgb.shape[:2]
|
||||
out_w, out_h = out_size
|
||||
min_lon, min_lat, max_lon, max_lat = bbox
|
||||
lon_span = max_lon - min_lon
|
||||
lat_span = max_lat - min_lat
|
||||
|
||||
if src_h < 1 or src_w < 1 or out_h < 1 or out_w < 1:
|
||||
return np.zeros((max(out_h, 1), max(out_w, 1), 4), dtype=np.uint8)
|
||||
|
||||
grid_x, grid_y = np.meshgrid(
|
||||
np.arange(out_w, dtype=np.float64),
|
||||
np.arange(out_h, dtype=np.float64),
|
||||
)
|
||||
lon = min_lon + ((grid_x + 0.5) / out_w) * lon_span
|
||||
lat = max_lat - ((grid_y + 0.5) / out_h) * lat_span
|
||||
|
||||
lon_flat = lon.reshape(-1)
|
||||
lat_flat = lat.reshape(-1)
|
||||
denom = inverse_h[2, 0] * lon_flat + inverse_h[2, 1] * lat_flat + inverse_h[2, 2]
|
||||
valid = np.abs(denom) > 1e-8
|
||||
|
||||
u = np.zeros_like(lon_flat)
|
||||
v = np.zeros_like(lat_flat)
|
||||
u[valid] = (
|
||||
inverse_h[0, 0] * lon_flat[valid]
|
||||
+ inverse_h[0, 1] * lat_flat[valid]
|
||||
+ inverse_h[0, 2]
|
||||
) / denom[valid]
|
||||
v[valid] = (
|
||||
inverse_h[1, 0] * lon_flat[valid]
|
||||
+ inverse_h[1, 1] * lat_flat[valid]
|
||||
+ inverse_h[1, 2]
|
||||
) / denom[valid]
|
||||
|
||||
if src_w >= 2 and src_h >= 2:
|
||||
valid &= (u >= 0) & (u < (src_w - 1)) & (v >= 0) & (v < (src_h - 1))
|
||||
else:
|
||||
valid &= (u >= 0) & (u <= (src_w - 1)) & (v >= 0) & (v <= (src_h - 1))
|
||||
|
||||
output = np.zeros((out_h, out_w, 4), dtype=np.uint8)
|
||||
if not np.any(valid):
|
||||
return output
|
||||
|
||||
valid_idx = np.where(valid)[0]
|
||||
u_valid = u[valid_idx]
|
||||
v_valid = v[valid_idx]
|
||||
|
||||
if src_w >= 2 and src_h >= 2:
|
||||
x0 = np.floor(u_valid).astype(np.int32)
|
||||
y0 = np.floor(v_valid).astype(np.int32)
|
||||
x1 = np.clip(x0 + 1, 0, src_w - 1)
|
||||
y1 = np.clip(y0 + 1, 0, src_h - 1)
|
||||
du = (u_valid - x0).astype(np.float32)
|
||||
dv = (v_valid - y0).astype(np.float32)
|
||||
|
||||
src_float = source_rgb.astype(np.float32, copy=False)
|
||||
s00 = src_float[y0, x0]
|
||||
s10 = src_float[y0, x1]
|
||||
s01 = src_float[y1, x0]
|
||||
s11 = src_float[y1, x1]
|
||||
samples = (
|
||||
s00 * (1 - du)[:, None] * (1 - dv)[:, None]
|
||||
+ s10 * du[:, None] * (1 - dv)[:, None]
|
||||
+ s01 * (1 - du)[:, None] * dv[:, None]
|
||||
+ s11 * du[:, None] * dv[:, None]
|
||||
)
|
||||
rgb = np.clip(samples, 0, 255).astype(np.uint8)
|
||||
else:
|
||||
nearest_x = np.clip(np.round(u_valid).astype(np.int32), 0, src_w - 1)
|
||||
nearest_y = np.clip(np.round(v_valid).astype(np.int32), 0, src_h - 1)
|
||||
rgb = source_rgb[nearest_y, nearest_x]
|
||||
|
||||
flat = output.reshape(-1, 4)
|
||||
flat[valid_idx, :3] = rgb
|
||||
flat[valid_idx, 3] = 255
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def create_geocorrected_radar_cached_image(
|
||||
source_image_path: str,
|
||||
cache_path: str,
|
||||
coverage_polygon: Any,
|
||||
bbox: Tuple[float, float, float, float],
|
||||
source_corner_mapping: Optional[Dict[str, Any]] = None,
|
||||
thumbnail_size: Optional[Tuple[int, int]] = None,
|
||||
quality: Optional[int] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
try:
|
||||
if not os.path.exists(source_image_path):
|
||||
return False, "preview_source_not_found"
|
||||
|
||||
polygon_points = ImageService._extract_source_corner_mapping_points(source_corner_mapping)
|
||||
if polygon_points is None:
|
||||
polygon_points = ImageService._extract_quadrilateral_points(coverage_polygon)
|
||||
if polygon_points is None:
|
||||
return False, "invalid_coverage_polygon"
|
||||
|
||||
max_size = thumbnail_size or (
|
||||
settings.RADAR_THUMBNAIL_MAX_SIZE,
|
||||
settings.RADAR_THUMBNAIL_MAX_SIZE,
|
||||
)
|
||||
out_size = ImageService._estimate_geo_canvas_size(bbox, max_size)
|
||||
if out_size is None:
|
||||
return False, "invalid_bbox"
|
||||
|
||||
with Image.open(source_image_path) as image:
|
||||
source_rgb = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
|
||||
src_h, src_w = source_rgb.shape[:2]
|
||||
if src_h < 1 or src_w < 1:
|
||||
return False, "invalid_source_image_size"
|
||||
|
||||
source_points = np.asarray(
|
||||
[
|
||||
[0.0, float(src_h - 1)],
|
||||
[float(src_w - 1), float(src_h - 1)],
|
||||
[float(src_w - 1), 0.0],
|
||||
[0.0, 0.0],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
homography = ImageService._compute_homography(source_points, polygon_points)
|
||||
if homography is None:
|
||||
return False, "homography_solve_failed"
|
||||
|
||||
try:
|
||||
inverse_h = np.linalg.inv(homography)
|
||||
except np.linalg.LinAlgError:
|
||||
return False, "homography_invert_failed"
|
||||
|
||||
warped_rgba = ImageService._warp_preview_to_geo_bbox(
|
||||
source_rgb=source_rgb,
|
||||
inverse_h=inverse_h,
|
||||
bbox=bbox,
|
||||
out_size=out_size,
|
||||
)
|
||||
target_quality = quality if quality is not None else settings.RADAR_GEO_CACHE_QUALITY
|
||||
output_image = Image.fromarray(warped_rgba, mode="RGBA")
|
||||
ImageService.save_image_as_webp(output_image, cache_path, quality=target_quality)
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@staticmethod
|
||||
def create_radar_cached_image(
|
||||
source_image_path: str,
|
||||
cache_path: str,
|
||||
thumbnail_size: Optional[Tuple[int, int]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
为源雷达数据包中的预览图(jpg/png 等)创建 WebP 缓存。
|
||||
|
||||
Args:
|
||||
source_image_path: 原始预览图路径
|
||||
cache_path: 缓存输出路径
|
||||
thumbnail_size: 缩略图尺寸
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(source_image_path):
|
||||
return False
|
||||
|
||||
max_size = thumbnail_size or (
|
||||
settings.RADAR_THUMBNAIL_MAX_SIZE,
|
||||
settings.RADAR_THUMBNAIL_MAX_SIZE,
|
||||
)
|
||||
|
||||
with Image.open(source_image_path) as image:
|
||||
image = image.convert("RGB")
|
||||
image.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
ImageService.save_image_as_webp(image, cache_path, quality=82)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"创建源影像缓存失败: {source_image_path}, 错误: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 全局服务实例
|
||||
image_service = ImageService()
|
||||
Reference in New Issue
Block a user