shuishen
4 hours ago 2ae460fc4a4c2419cf44329783d49a739e2a04ea
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
"""Run a small, inspectable CPU semantic-mapping baseline.
 
The first capability version deliberately uses deterministic RGB/HSV rules so it
can run without downloading model weights.  GeoAI's ``masks_to_vector`` is used
for class-wise polygon extraction, while the metadata keeps the model boundary
explicit: this is a B capability, not a built-in GeoAI segmentation model.
"""
 
from __future__ import annotations
 
import argparse
import json
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
 
import cv2
import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
from PIL import Image
from rasterio.features import shapes
from rasterio.transform import Affine
from shapely.geometry import shape
 
 
CLASS_INFO = {
    0: {"key": "other", "label": "其他", "color": (80, 80, 80)},
    1: {"key": "vegetation", "label": "植被", "color": (40, 180, 70)},
    2: {"key": "water", "label": "水体", "color": (35, 130, 220)},
    3: {"key": "impervious", "label": "不透水面", "color": (220, 150, 55)},
}
SUPPORTED_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
 
 
def classify_rgb(rgb: np.ndarray) -> np.ndarray:
    """Classify RGB pixels with conservative, explainable colour rules."""
    rgb_f = rgb.astype(np.float32)
    r, g, b = [rgb_f[..., i] for i in range(3)]
    hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
    saturation = hsv[..., 1].astype(np.float32)
    value = hsv[..., 2].astype(np.float32)
    result = np.zeros(rgb.shape[:2], dtype=np.uint8)
 
    green = (g > r * 1.04) & (g > b * 1.04) & (g > 45) & (saturation > 45)
    blue = (b > r * 1.08) & (b > g * 1.02) & (b > 45) & (saturation > 35)
    gray = (np.max(rgb_f, axis=2) - np.min(rgb_f, axis=2) < 38) & (value > 45)
    result[green] = 1
    result[blue & ~green] = 2
    result[gray & ~green & ~blue] = 3
    return result
 
 
def _raster_profile(input_path: Path, width: int, height: int) -> tuple[Affine, str | None, bool]:
    if input_path.suffix.lower() not in {".tif", ".tiff"}:
        return Affine.identity(), None, False
    try:
        with rasterio.open(input_path) as src:
            transform = src.transform
            crs = src.crs.to_string() if src.crs else None
            return transform, crs, bool(src.crs and src.transform != Affine.identity())
    except rasterio.errors.RasterioIOError:
        return Affine.identity(), None, False
 
 
def _geojson_from_mask(mask: np.ndarray, transform: Affine, crs: str | None, class_id: int) -> gpd.GeoDataFrame:
    records: list[dict[str, Any]] = []
    binary = (mask == class_id).astype(np.uint8)
    for geometry, value in shapes(binary, mask=binary.astype(bool), transform=transform):
        if value != 1:
            continue
        polygon = shape(geometry)
        if polygon.area < 100:
            continue
        records.append({"geometry": polygon, "class_id": class_id, "class_key": CLASS_INFO[class_id]["key"], "class_label": CLASS_INFO[class_id]["label"], "pixel_area": float(polygon.area)})
    return gpd.GeoDataFrame(records, geometry="geometry", crs=crs)
 
 
def vectorize_with_geoai(mask: np.ndarray, transform: Affine, crs: str | None, output_path: Path) -> bool:
    """Use GeoAI's class-wise mask utility, then preserve class properties."""
    try:
        from geoai import masks_to_vector
 
        frames: list[gpd.GeoDataFrame] = []
        output_path.parent.mkdir(parents=True, exist_ok=True)
        for class_id in (1, 2, 3):
            temp_path = output_path.with_name(f".{output_path.stem}-{class_id}.tif")
            binary = np.where(mask == class_id, 255, 0).astype(np.uint8)
            with rasterio.open(temp_path, "w", driver="GTiff", height=binary.shape[0], width=binary.shape[1], count=1, dtype="uint8", transform=transform, crs=crs, nodata=0) as dst:
                dst.write(binary, 1)
            try:
                frame = masks_to_vector(str(temp_path), min_object_area=100, simplify_tolerance=1.0)
                if not frame.empty:
                    frame = frame.assign(class_id=class_id, class_key=CLASS_INFO[class_id]["key"], class_label=CLASS_INFO[class_id]["label"])
                    frames.append(frame[["geometry", "class_id", "class_key", "class_label", "confidence"]])
            finally:
                temp_path.unlink(missing_ok=True)
        merged = gpd.GeoDataFrame(pd_concat(frames), geometry="geometry", crs=crs)
        merged.to_file(output_path, driver="GeoJSON")
        return True
    except Exception:
        fallback = gpd.GeoDataFrame(pd_concat([_geojson_from_mask(mask, transform, crs, class_id) for class_id in (1, 2, 3)]), geometry="geometry", crs=crs)
        fallback.to_file(output_path, driver="GeoJSON")
        return False
 
 
def pd_concat(frames: list[gpd.GeoDataFrame]) -> gpd.GeoDataFrame:
    if not frames:
        return gpd.GeoDataFrame({"geometry": []}, geometry="geometry")
    return gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), geometry="geometry", crs=frames[0].crs)
 
 
def process_image(input_path: Path, output_dir: Path) -> dict[str, Any]:
    started = time.perf_counter()
    with Image.open(input_path) as image:
        rgb = np.asarray(image.convert("RGB"))
    height, width = rgb.shape[:2]
    mask = classify_rgb(rgb)
    transform, crs, georeferenced = _raster_profile(input_path, width, height)
    stem = input_path.stem
    mask_path = output_dir / f"{stem}.mask.png"
    overlay_path = output_dir / f"{stem}.overlay.png"
    raster_path = output_dir / f"{stem}.mask.tif"
    vector_path = output_dir / f"{stem}.segments.geojson"
    Image.fromarray(mask).save(mask_path)
    palette = np.zeros((256, 3), dtype=np.uint8)
    for class_id, info in CLASS_INFO.items():
        palette[class_id] = info["color"]
    color_mask = palette[mask]
    overlay = (rgb.astype(np.float32) * 0.52 + color_mask.astype(np.float32) * 0.48).clip(0, 255).astype(np.uint8)
    Image.fromarray(overlay).save(overlay_path, quality=92)
    with rasterio.open(raster_path, "w", driver="GTiff", height=height, width=width, count=1, dtype="uint8", transform=transform, crs=crs, nodata=0) as dst:
        dst.write(mask, 1)
    used_geoai = vectorize_with_geoai(mask, transform, crs, vector_path)
    counts = {info["key"]: int(np.count_nonzero(mask == class_id)) for class_id, info in CLASS_INFO.items()}
    return {"file": input_path.name, "width": width, "height": height, "mask_file": mask_path.name, "overlay_file": overlay_path.name, "raster_file": raster_path.name, "vector_file": vector_path.name, "class_pixel_counts": counts, "georeferenced": georeferenced, "vectorizer": "geoai.masks_to_vector" if used_geoai else "rasterio.features.shapes fallback", "elapsed_seconds": round(time.perf_counter() - started, 3)}
 
 
def collect_inputs(input_path: Path) -> list[Path]:
    if input_path.is_file():
        candidates = [input_path]
    elif input_path.is_dir():
        candidates = sorted(item for item in input_path.iterdir() if item.is_file())
    else:
        raise SystemExit(f"Input path does not exist: {input_path}")
    inputs = [item for item in candidates if item.suffix.lower() in SUPPORTED_SUFFIXES]
    if not inputs:
        raise SystemExit("No JPG, PNG, or GeoTIFF inputs were found.")
    return inputs
 
 
def main() -> int:
    parser = argparse.ArgumentParser(description="Run the CPU semantic-mapping baseline.")
    parser.add_argument("--input", type=Path, default=Path("shared/data/raw/01-object-detection"))
    parser.add_argument("--output", type=Path, default=Path("shared/outputs/02-semantic-mapping"))
    args = parser.parse_args()
    inputs = collect_inputs(args.input)
    if args.output.exists() and any(args.output.iterdir()):
        raise SystemExit(f"Output directory is not empty: {args.output}. Use a new run directory.")
    args.output.mkdir(parents=True, exist_ok=True)
    started = time.perf_counter()
    images = [process_image(item, args.output) for item in inputs]
    metadata = {"capability": "02-semantic-mapping", "classification": "B", "created_at": datetime.now(UTC).isoformat(), "geoai_version": __import__("importlib.metadata").metadata.version("geoai-py"), "method": "deterministic RGB/HSV semantic baseline", "model": "classical-color-baseline", "device": "CPU", "thresholds": {"vegetation_green_ratio": 1.04, "water_blue_ratio": 1.08, "gray_channel_range": 38, "minimum_vector_area_pixels": 100}, "input_dir": args.input.as_posix(), "raw_input_dir": args.input.as_posix(), "input_count": len(images), "processed_images": len(images), "elapsed_seconds": round(time.perf_counter() - started, 3), "images": images, "classes": [info | {"id": class_id} for class_id, info in CLASS_INFO.items()], "limitations": ["这是可解释的颜色规则基线,不是经过训练的通用分割模型。", "普通 JPG/PNG 没有 CRS 时,GeoJSON 使用像素坐标;只有有效 GeoTIFF 地理参考才会保留地理坐标。", "复杂光照、阴影、材质相近区域可能误分;当前没有人工标注,因此不报告精度。"]}
    (args.output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
    print(json.dumps(metadata, ensure_ascii=False, indent=2))
    return 0
 
 
if __name__ == "__main__":
    raise SystemExit(main())