"""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())
|