""" 图像处理服务 提供 D-InSAR 结果图像处理功能: - 生成可视化图像 - 提取影像 footprint - 加载颜色表 - 生成缩略图 优化策略: - 使用 GDAL 降采样提取 footprint,减少内存占用 - 颜色表使用单例模式缓存 - 支持自动透明边缘裁剪 """ import os import time import json from collections import deque 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 make_edge_dark_transparent( image: Image.Image, *, threshold: int = 6, ) -> Image.Image: """Make edge-connected near-black preview background transparent.""" rgba = image.convert("RGBA") arr = np.array(rgba, dtype=np.uint8, copy=True) if arr.ndim != 3 or arr.shape[2] < 4: return rgba alpha = arr[:, :, 3] dark = (alpha > 0) & (arr[:, :, :3].max(axis=2) <= int(threshold)) if not dark.any(): return rgba h, w = dark.shape edge = np.zeros_like(dark, dtype=bool) edge[0, :] = dark[0, :] edge[h - 1, :] = dark[h - 1, :] edge[:, 0] |= dark[:, 0] edge[:, w - 1] |= dark[:, w - 1] if not edge.any(): return rgba visited = np.zeros_like(dark, dtype=bool) ys, xs = np.where(edge) queue = deque(zip(ys.tolist(), xs.tolist())) visited[ys, xs] = True while queue: y, x = queue.popleft() if y > 0 and dark[y - 1, x] and not visited[y - 1, x]: visited[y - 1, x] = True queue.append((y - 1, x)) if y + 1 < h and dark[y + 1, x] and not visited[y + 1, x]: visited[y + 1, x] = True queue.append((y + 1, x)) if x > 0 and dark[y, x - 1] and not visited[y, x - 1]: visited[y, x - 1] = True queue.append((y, x - 1)) if x + 1 < w and dark[y, x + 1] and not visited[y, x + 1]: visited[y, x + 1] = True queue.append((y, x + 1)) arr[:, :, 3][visited] = 0 return Image.fromarray(arr, "RGBA") @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_rgba: np.ndarray, inverse_h: np.ndarray, bbox: Tuple[float, float, float, float], out_size: Tuple[int, int], ) -> np.ndarray: src_h, src_w = source_rgba.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_rgba.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] ) rgba = 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) rgba = source_rgba[nearest_y, nearest_x] flat = output.reshape(-1, 4) flat[valid_idx] = rgba 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 = ImageService.make_edge_dark_transparent(image) source_rgba = np.asarray(source, dtype=np.uint8) src_h, src_w = source_rgba.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_rgba=source_rgba, 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 = ImageService.make_edge_dark_transparent(image) 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()