罗广辉
16 hours ago 7cc239cee1a9af4e2e8a0f3d5b7a00a074b17214
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
"""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