"""CPU ChangeStar demo for two-date imagery with inspectable raster/vector outputs.""" from __future__ import annotations import argparse import json import math import platform import time from datetime import UTC, datetime from pathlib import Path from typing import Any import cv2 import numpy as np import rasterio from affine import Affine from PIL import Image from rasterio.features import shapes from rasterio.transform import rowcol from shapely.geometry import shape as shapely_shape MODEL_NAME = "s1_s1c1_vitb" DEFAULT_THRESHOLD = 0.5 DEFAULT_TILE_SIZE = 512 DEFAULT_OVERLAP = 64 DEFAULT_MAX_DIMENSION = 1024 DEFAULT_PROCESSING_MODE = "auto" AUTO_MAX_DIMENSION = 0 MIN_THRESHOLD = 0.01 MAX_THRESHOLD = 0.99 MIN_MAX_DIMENSION = 512 MAX_MAX_DIMENSION = 4096 PROCESSING_MODES = {"auto", "image", "geotiff"} def _to_uint8(data: np.ndarray) -> np.ndarray: if data.dtype == np.uint8: return data if np.issubdtype(data.dtype, np.integer): info = np.iinfo(data.dtype) return np.clip(data.astype(np.float32) / max(1, info.max) * 255.0, 0, 255).astype(np.uint8) values = data.astype(np.float32) if float(np.nanmax(values)) <= 1.0: values = values * 255.0 return np.clip(values, 0, 255).astype(np.uint8) def _read_rgb(path: Path, processing_mode: str = DEFAULT_PROCESSING_MODE) -> tuple[np.ndarray, dict[str, Any]]: if processing_mode not in PROCESSING_MODES: raise ValueError(f"processing_mode must be one of {sorted(PROCESSING_MODES)}") if processing_mode in {"auto", "geotiff"} and path.suffix.lower() in {".tif", ".tiff"}: try: with rasterio.open(path) as dataset: has_georeference = dataset.crs is not None and not dataset.transform.is_identity if has_georeference: if dataset.count < 3: raise ValueError(f"GeoTIFF must contain at least 3 bands: {path.name}") data = _to_uint8(dataset.read([1, 2, 3])) image = np.transpose(data, (1, 2, 0)) return image, { "processing_mode": "geotiff", "georeferenced": True, "crs": dataset.crs.to_string(), "transform": dataset.transform, "pixel_size": [abs(float(dataset.transform.a)), abs(float(dataset.transform.e))], "grid_basis": [float(dataset.transform.a), float(dataset.transform.b), float(dataset.transform.d), float(dataset.transform.e)], } if processing_mode == "geotiff": raise ValueError(f"GeoTIFF has no valid CRS and affine transform: {path.name}") except rasterio.errors.RasterioIOError as exc: if processing_mode == "geotiff": raise ValueError(f"Unable to read GeoTIFF {path.name}: {exc}") from exc with Image.open(path) as image: return np.asarray(image.convert("RGB")), { "processing_mode": "image", "georeferenced": False, "crs": None, "transform": None, "pixel_size": None, } def _register_after(before: np.ndarray, after: np.ndarray) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]: """Register the second image using ORB homography, returning a valid-pixel mask.""" height, width = before.shape[:2] scale = min(1.0, 1200.0 / max(height, width)) small_before = cv2.resize(before, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) small_after = cv2.resize(after, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) gray_before = cv2.cvtColor(small_before, cv2.COLOR_RGB2GRAY) gray_after = cv2.cvtColor(small_after, cv2.COLOR_RGB2GRAY) orb = cv2.ORB_create(nfeatures=4000, fastThreshold=7) key_before, desc_before = orb.detectAndCompute(gray_before, None) key_after, desc_after = orb.detectAndCompute(gray_after, None) details: dict[str, Any] = {"method": "identity", "matches": 0, "inliers": 0, "inlier_ratio": 0.0} matrix = np.eye(3, dtype=np.float32) if desc_before is not None and desc_after is not None and len(key_before) >= 8 and len(key_after) >= 8: matcher = cv2.BFMatcher(cv2.NORM_HAMMING) pairs = matcher.knnMatch(desc_after, desc_before, k=2) good = [a for a, b in pairs if a.distance < 0.75 * b.distance] if len(good) >= 8: src = np.float32([key_after[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) dst = np.float32([key_before[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) candidate, mask = cv2.findHomography(src, dst, cv2.RANSAC, 5.0) if candidate is not None and mask is not None and int(mask.sum()) >= 8: matrix = candidate.astype(np.float32) inliers = int(mask.sum()) details = { "method": "orb_homography", "matches": len(good), "inliers": inliers, "inlier_ratio": round(inliers / len(good), 4), } registered = cv2.warpPerspective(after, matrix, (width, height), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT) valid = cv2.warpPerspective(np.full((after.shape[0], after.shape[1]), 255, dtype=np.uint8), matrix, (width, height), flags=cv2.INTER_NEAREST, borderMode=cv2.BORDER_CONSTANT) details["valid_ratio"] = round(float((valid > 0).mean()), 6) details["matrix"] = [[round(float(value), 8) for value in row] for row in matrix] return registered, valid > 0, details def _resize_pair(before: np.ndarray, after: np.ndarray, valid: np.ndarray, max_dimension: int, *, native_resolution: bool = False) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]: height, width = before.shape[:2] scale = 1.0 if native_resolution else min(1.0, float(max_dimension) / max(height, width)) out_width = width if native_resolution else max(32, int(round(width * scale / 32) * 32)) out_height = height if native_resolution else max(32, int(round(height * scale / 32) * 32)) # Keep a stable 32-pixel multiple for the model while documenting the actual scale. resized_before = cv2.resize(before, (out_width, out_height), interpolation=cv2.INTER_AREA) resized_after = cv2.resize(after, (out_width, out_height), interpolation=cv2.INTER_AREA) resized_valid = cv2.resize(valid.astype(np.uint8), (out_width, out_height), interpolation=cv2.INTER_NEAREST) > 0 return resized_before, resized_after, resized_valid, { "original_width": width, "original_height": height, "processed_width": out_width, "processed_height": out_height, "scale_x": round(width / out_width, 8), "scale_y": round(height / out_height, 8), "native_resolution": native_resolution, } def _reproject_rgb_to_grid(path: Path, destination_shape: tuple[int, int], destination_transform: Affine, destination_crs: str) -> np.ndarray: """Read the second GeoTIFF onto the first image's grid without changing either source file.""" from rasterio.warp import Resampling, reproject height, width = destination_shape aligned = np.zeros((3, height, width), dtype=np.uint8) with rasterio.open(path) as source: for band in range(3): source_band = _to_uint8(source.read(band + 1)) reproject( source=source_band, destination=aligned[band], src_transform=source.transform, src_crs=source.crs, dst_transform=destination_transform, dst_crs=destination_crs, resampling=Resampling.bilinear, ) return np.transpose(aligned, (1, 2, 0)) def _write_rgb_geotiff(path: Path, image: np.ndarray, transform: Affine, crs: str | None) -> None: with rasterio.open(path, "w", driver="GTiff", height=image.shape[0], width=image.shape[1], count=3, dtype="uint8", transform=transform, crs=crs, compress="lzw") as dst: dst.write(np.transpose(image, (2, 0, 1))) def _write_raster(path: Path, data: np.ndarray, transform: Affine, dtype: str, crs: str | None = None) -> None: with rasterio.open(path, "w", driver="GTiff", height=data.shape[0], width=data.shape[1], count=1, dtype=dtype, transform=transform, crs=crs, compress="lzw") as dst: dst.write(data.astype(dtype), 1) def _write_overlay(path: Path, before: np.ndarray, after: np.ndarray, mask: np.ndarray) -> None: left = before.copy() right = after.copy() red = np.zeros_like(right) red[..., 0] = 255 right[mask] = (right[mask].astype(np.float32) * 0.45 + red[mask].astype(np.float32) * 0.55).astype(np.uint8) separator = np.full((before.shape[0], 8, 3), 235, dtype=np.uint8) Image.fromarray(np.concatenate([left, separator, right], axis=1)).save(path, quality=92) def _feature_summary(mask: np.ndarray, probability: np.ndarray, transform: Affine, georeferenced: bool) -> list[dict[str, Any]]: features: list[dict[str, Any]] = [] for index, (geometry, value) in enumerate(shapes(mask.astype(np.uint8), transform=transform), start=1): if int(value) != 255: continue polygon = shapely_shape(geometry) coords = np.asarray(polygon.exterior.coords) rows, cols = rowcol(transform, coords[:, 0], coords[:, 1]) x = np.clip(np.asarray(cols), 0, mask.shape[1] - 1) y = np.clip(np.asarray(rows), 0, mask.shape[0] - 1) sample = probability[y, x] item: dict[str, Any] = { "feature_id": index, "area_pixels": round(float(polygon.area / (abs(transform.a * transform.e))), 3), "mean_probability": round(float(sample.mean()) if sample.size else 0.0, 6), "max_probability": round(float(sample.max()) if sample.size else 0.0, 6), } if georeferenced: item["area_map_units"] = round(float(polygon.area), 6) item["bounds_map"] = [round(float(v), 6) for v in polygon.bounds] else: item["bounds_pixel"] = [round(float(v), 3) for v in polygon.bounds] features.append(item) return features def _enrich_vector(vector_path: Path, features: list[dict[str, Any]], preserve_crs: bool = False) -> None: import geopandas as gpd if vector_path.is_file(): vector = gpd.read_file(vector_path) for key in ("feature_id", "area_pixels", "area_map_units", "mean_probability", "max_probability", "bounds_pixel", "bounds_map"): vector[key] = [item.get(key) for item in features[: len(vector)]] vector.to_file(vector_path, driver="GeoJSON") payload = json.loads(vector_path.read_text(encoding="utf-8")) if not preserve_crs: payload.pop("crs", None) vector_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") else: vector_path.write_text(json.dumps({"type": "FeatureCollection", "features": []}, indent=2), encoding="utf-8") def run_change_detection(before_path: Path, after_path: Path, output_dir: Path, *, processed_dir: Path | None = None, model_name: str = MODEL_NAME, threshold: float = DEFAULT_THRESHOLD, tile_size: int = DEFAULT_TILE_SIZE, overlap: int = DEFAULT_OVERLAP, max_dimension: int = AUTO_MAX_DIMENSION, processing_mode: str = DEFAULT_PROCESSING_MODE) -> dict[str, Any]: started = time.perf_counter() if not math.isfinite(threshold) or not MIN_THRESHOLD <= threshold <= MAX_THRESHOLD: raise ValueError(f"threshold must be between {MIN_THRESHOLD} and {MAX_THRESHOLD}") if processing_mode not in PROCESSING_MODES: raise ValueError(f"processing_mode must be one of {sorted(PROCESSING_MODES)}") if not isinstance(max_dimension, int) or max_dimension != AUTO_MAX_DIMENSION and not MIN_MAX_DIMENSION <= max_dimension <= MAX_MAX_DIMENSION: raise ValueError(f"max_dimension must be 0 or between {MIN_MAX_DIMENSION} and {MAX_MAX_DIMENSION}") before, before_info = _read_rgb(before_path, processing_mode) after, after_info = _read_rgb(after_path, processing_mode) actual_mode = "geotiff" if before_info["georeferenced"] and after_info["georeferenced"] else "image" if before_info["georeferenced"] != after_info["georeferenced"]: raise ValueError("Both inputs must use the same processing mode and georeferencing.") if processing_mode == "geotiff" and actual_mode != "geotiff": raise ValueError("GeoTIFF mode requires both inputs to have a valid CRS and affine transform.") if actual_mode == "geotiff" and before_info["crs"] != after_info["crs"]: raise ValueError("GeoTIFF inputs must use the same CRS.") grid_alignment: dict[str, Any] = {"required": False, "method": "none"} if actual_mode == "geotiff": before_transform = before_info["transform"] after_transform = after_info["transform"] same_grid = before.shape[:2] == after.shape[:2] and np.allclose( [before_transform.a, before_transform.b, before_transform.c, before_transform.d, before_transform.e, before_transform.f], [after_transform.a, after_transform.b, after_transform.c, after_transform.d, after_transform.e, after_transform.f], rtol=1e-6, atol=1e-9, ) if not same_grid: source_shape = [int(after.shape[0]), int(after.shape[1])] after = _reproject_rgb_to_grid(after_path, before.shape[:2], before_transform, before_info["crs"]) grid_alignment = { "required": True, "method": "rasterio.reproject_bilinear", "source_shape": source_shape, "destination_shape": [int(before.shape[0]), int(before.shape[1])], "destination_crs": before_info["crs"], } if before.shape[:2] != after.shape[:2]: raise ValueError(f"Input dimensions must match before={before.shape[:2]} after={after.shape[:2]}") effective_max_dimension = max_dimension if effective_max_dimension == AUTO_MAX_DIMENSION: effective_max_dimension = 0 if actual_mode == "geotiff" else DEFAULT_MAX_DIMENSION output_dir.mkdir(parents=True, exist_ok=False) work_dir = processed_dir or output_dir if processed_dir is not None: processed_dir.mkdir(parents=True, exist_ok=False) registered, valid, registration = _register_after(before, after) native_resolution = actual_mode == "geotiff" and effective_max_dimension == AUTO_MAX_DIMENSION before_small, after_small, valid_small, resize_details = _resize_pair(before, registered, valid, effective_max_dimension, native_resolution=native_resolution) source_transform = before_info["transform"] if actual_mode == "geotiff" else Affine(1, 0, 0, 0, -1, before.shape[0]) transform = source_transform if native_resolution else source_transform * Affine.scale(resize_details["scale_x"], resize_details["scale_y"]) output_crs = before_info["crs"] if actual_mode == "geotiff" else None _write_rgb_geotiff(work_dir / "before.tif", before_small, transform, output_crs) _write_rgb_geotiff(work_dir / "after_registered.tif", after_small, transform, output_crs) from geoai import ChangeStarDetection, masks_to_vector detector = ChangeStarDetection(model_name=model_name, device="cpu") result = detector.predict(str(work_dir / "before.tif"), str(work_dir / "after_registered.tif"), tile_size=tile_size, overlap=overlap, threshold=threshold) probability = np.asarray(result["change_prob"], dtype=np.float32) raw_mask = ((probability >= threshold) & valid_small).astype(np.uint8) * 255 # Keep model output intact in raw_mask, then apply a small component filter for presentation. cleaned = raw_mask.copy() count, labels, stats, _ = cv2.connectedComponentsWithStats((cleaned > 0).astype(np.uint8), connectivity=8) min_area = max(16, int(cleaned.size * 0.00002)) for component in range(1, count): if int(stats[component, cv2.CC_STAT_AREA]) < min_area: cleaned[labels == component] = 0 _write_raster(output_dir / "change_probability.tif", probability, transform, "float32", output_crs) _write_raster(output_dir / "change_mask_raw.tif", raw_mask, transform, "uint8", output_crs) _write_raster(output_dir / "change_mask.tif", cleaned, transform, "uint8", output_crs) _write_overlay(output_dir / "change_overlay.jpg", before_small, after_small, cleaned > 0) vector_path = output_dir / "changes.geojson" vector = masks_to_vector(str(output_dir / "change_mask.tif"), str(vector_path), simplify_tolerance=1.0, mask_threshold=0.5, min_object_area=min_area) features = _feature_summary(cleaned, probability, transform, actual_mode == "geotiff") _enrich_vector(vector_path, features, preserve_crs=actual_mode == "geotiff") (output_dir / "change_features.json").write_text(json.dumps({"features": features}, ensure_ascii=False, indent=2), encoding="utf-8") limitations = [ "GeoTIFF 输入保留 CRS 和地图坐标;若两期网格不同,会在处理副本中将第二期双线性重投影到第一期网格,原始 TIFF 不会被改写。" if actual_mode == "geotiff" else "输入 JPG/PNG 或无 CRS TIFF 没有有效 CRS,GeoTIFF/GeoJSON 坐标是像素换算坐标,不是米或经纬度。", "ChangeStar 权重训练于 Changen2/S1 建筑变化数据;当前近景边坡照片不在其验证分布内。", "没有人工变化真值,不报告 precision、recall、IoU,也不输出变化类型或工程告警。", "ORB 配准只用于工作流演示;生产使用需要正射校正、同 GSD 和独立配准质量验收。", ] metadata: dict[str, Any] = { "schema_version": 1, "capability": "00-change-detection", "classification": "A", "geoai_version": "0.42.0", "method": "geoai.ChangeStarDetection + geoai.masks_to_vector", "model": model_name, "device": "cpu", "processing_mode": actual_mode, "requested_processing_mode": processing_mode, "thresholds": {"change_probability": threshold, "minimum_component_pixels": min_area}, "tile_size": tile_size, "overlap": overlap, "max_dimension": max_dimension, "effective_max_dimension": effective_max_dimension, "input_count": 2, "processed_images": 2, "input_files": [before_path.name, after_path.name], "created_at": datetime.now(UTC).isoformat(), "input_shape": [int(before.shape[0]), int(before.shape[1])], "processed_shape": [int(before_small.shape[0]), int(before_small.shape[1])], "registration": registration, "grid_alignment": grid_alignment, "resize": resize_details, "valid_pixel_ratio": round(float(valid_small.mean()), 6), "raw_changed_pixels": int((raw_mask > 0).sum()), "changed_pixels": int((cleaned > 0).sum()), "changed_pixel_ratio": round(float((cleaned > 0).mean()), 6), "vector_feature_count": len(vector), "georeferenced": actual_mode == "geotiff", "coordinate_basis": "source_crs_map_coordinates" if actual_mode == "geotiff" else "pixel_coordinates_north_up_transform_y_from_image_bottom", "crs": output_crs, "elapsed_seconds": round(time.perf_counter() - started, 3), "python": platform.python_version(), "platform": platform.platform(), "artifacts": { "probability_raster": "change_probability.tif", "raw_mask_raster": "change_mask_raw.tif", "mask_raster": "change_mask.tif", "overlay": "change_overlay.jpg", "vector": "changes.geojson", "features": "change_features.json", }, "limitations": limitations, "licenses": { "geoai-py": "MIT", "torchange": "Apache-2.0", "ever-beta": "PyPI metadata indicates rights reserved; review required", "model_weights": "CC BY-NC-SA 4.0 (non-commercial; source EVER-Z/Changen2-ChangeStar1x256)", "user_images": "user-provided; authorization not verified", }, } (output_dir / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") return metadata def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Run CPU ChangeStar change detection on a pair of images.") parser.add_argument("--before", type=Path, required=True) parser.add_argument("--after", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--processed-output", type=Path) parser.add_argument("--model", default=MODEL_NAME) parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD) parser.add_argument("--tile-size", type=int, default=DEFAULT_TILE_SIZE) parser.add_argument("--overlap", type=int, default=DEFAULT_OVERLAP) parser.add_argument("--max-dimension", type=int, default=AUTO_MAX_DIMENSION, help="Long-edge cap in pixels; 0 keeps a valid GeoTIFF at native resolution and uses 1024 for ordinary images.") parser.add_argument("--processing-mode", choices=sorted(PROCESSING_MODES), default=DEFAULT_PROCESSING_MODE) return parser if __name__ == "__main__": args = build_parser().parse_args() try: print(json.dumps(run_change_detection(args.before, args.after, args.output, processed_dir=args.processed_output, model_name=args.model, threshold=args.threshold, tile_size=args.tile_size, overlap=args.overlap, max_dimension=args.max_dimension, processing_mode=args.processing_mode), ensure_ascii=False, indent=2)) except (FileNotFoundError, ValueError, RuntimeError) as exc: raise SystemExit(f"变化检测失败: {exc}") from exc