satquery-api / satquery_engine /services /spatial_outputs.py
SM737's picture
Upload folder using huggingface_hub (part 4)
a358495 verified
Raw History Blame Contribute Delete
7.22 kB
"""One mask -> geometry -> statistics -> visualization path for all specialists."""
import json
from pathlib import Path
import numpy as np
import rasterio
from rasterio.features import shapes
from rasterio.transform import Affine
from shapely.geometry import shape, mapping, box
from shapely import make_valid
from shapely.ops import transform as transform_geom, unary_union
from pyproj import Transformer
from PIL import Image, ImageDraw
from satquery_engine.services.raster import _area_square_meters, render_preview
def export_float_raster(values, source, destination):
with rasterio.open(source) as src:
grid=src.transform @ Affine.scale(src.width/values.shape[1],src.height/values.shape[0])
with rasterio.open(destination,"w",driver="GTiff",height=values.shape[0],width=values.shape[1],count=1,
dtype="float32",crs=src.crs,transform=grid,nodata=np.nan,compress="deflate") as dst:
dst.write(values.astype("float32"),1)
return destination
def export_labels(labels, source: Path, output: Path, name: str, scores=None, transform=None, color=(0, 200, 230), numbered=False, valid_mask=None):
labels=np.asarray(labels)
if labels.ndim != 2 or not np.issubdtype(labels.dtype,np.integer) or np.any(labels<0):
raise ValueError("Spatial export requires nonnegative integer instance labels.")
if valid_mask is not None and np.any((labels>0)&~valid_mask):
raise ValueError("Selected features include invalid pixels.")
output.mkdir(parents=True, exist_ok=True)
with rasterio.open(source) as src:
crs = src.crs
grid = transform or src.transform @ Affine.scale(src.width / labels.shape[1], src.height / labels.shape[0])
profile = dict(driver="GTiff", width=labels.shape[1], height=labels.shape[0], count=1,
dtype="int32", transform=grid, crs=crs, compress="deflate")
features = []
world = Transformer.from_crs(crs, "EPSG:4326", always_xy=True) if crs else None
grouped={}
for pixel_geom, value in shapes(labels.astype("int32"), mask=labels > 0, transform=Affine.identity()):
grouped.setdefault(int(value),[]).append(shape(pixel_geom))
for value, parts in sorted(grouped.items()):
pixel_polygon=make_valid(unary_union(parts))
# Use 0.5-px tolerance so buildings whose rasterized footprint touches the
# image boundary (a common, valid case) are not silently rejected. The
# half-pixel margin covers float-precision overhang from Shapely's vectorizer.
_tol = 0.5
image_box = box(-_tol, -_tol, labels.shape[1] + _tol, labels.shape[0] + _tol)
if pixel_polygon.is_empty or pixel_polygon.area<=0 or not image_box.covers(pixel_polygon):
raise ValueError("Invalid or out-of-image evidence geometry; export blocked.")
pixel_geom=mapping(pixel_polygon)
native = transform_geom(lambda x, y, z=None: (grid.a * x + grid.b * y + grid.c, grid.d * x + grid.e * y + grid.f), pixel_polygon)
area = _area_square_meters(mapping(native), crs)
geom = transform_geom(world.transform, native) if world else pixel_polygon
if not geom.is_valid or not np.isfinite(geom.bounds).all():
raise ValueError("Invalid geographic evidence geometry; export blocked.")
if crs and (area is None or not np.isfinite(area) or area<=0):
raise ValueError("Invalid measured area; export blocked.")
features.append({"type": "Feature", "id": int(value), "geometry": mapping(geom), "properties": {
"instance_id": int(value), "kind": name, "area_m2": area, "area_pixels": float(pixel_polygon.area),
"marker": list(geom.representative_point().coords[0]), "bbox": list(geom.bounds),
"pixel_geometry": pixel_geom, "native_geometry": mapping(native), "native_crs": str(crs) if crs else None,
"score": float(scores[int(value)]) if scores is not None and int(value) in scores else None}})
collection = {"type": "FeatureCollection", "features": features, "properties": {
"crs": "EPSG:4326" if crs else None, "coordinate_space": "geographic" if crs else "pixel",
"source_crs": str(crs) if crs else None, "transform": list(grid)[:6], "producer": name}}
geojson = output / f"{name}.geojson"
geojson.write_text(json.dumps(collection, allow_nan=False), encoding="utf-8")
raster = output / f"{name}_labels.tif"
with rasterio.open(raster, "w", **profile) as dst:
dst.write(labels.astype("int32"), 1)
if valid_mask is not None: dst.write_mask(valid_mask.astype("uint8")*255)
mask = labels > 0
rgba = np.zeros((*mask.shape, 4), dtype="uint8")
rgba[mask] = [*color, 85 if name == "buildings" else 130]
mask_path = output / f"{name}_mask.png"
Image.fromarray(rgba).save(mask_path)
preview_path = output / f"{name}_original.png"
render_preview(source, preview_path, max_size=1200)
preview = Image.open(preview_path).convert("RGBA")
overlay = Image.alpha_composite(preview, Image.fromarray(rgba).resize(preview.size, Image.Resampling.NEAREST))
if name == "buildings":
edges = np.zeros(labels.shape, dtype=bool)
edges[1:] |= labels[1:] != labels[:-1]
edges[:,1:] |= labels[:,1:] != labels[:,:-1]
edges &= labels > 0
border = np.zeros((*labels.shape,4),dtype='uint8'); border[edges] = [255,255,255,230]
overlay = Image.alpha_composite(overlay, Image.fromarray(border).resize(preview.size, Image.Resampling.NEAREST))
draw = ImageDraw.Draw(overlay)
from scipy import ndimage
for ident, region in enumerate(ndimage.find_objects(labels) if numbered else [], 1):
if region is None: continue
ys,xs = np.where(labels[region] == ident)
if len(xs) < 20: continue
ys += region[0].start; xs += region[1].start
nearest = np.argmin((ys-ys.mean())**2 + (xs-xs.mean())**2)
xy = (int(xs[nearest]*preview.width/labels.shape[1]),int(ys[nearest]*preview.height/labels.shape[0]))
draw.text(xy,str(ident),fill='white',stroke_width=1,stroke_fill='black')
if preview.width >= 32 and preview.height >= 28:
draw.rectangle((5,5,min(preview.width-5,255),27),fill=(0,0,0,200))
draw.text((10,10),f"Building footprints: {len(features)} (estimated)",fill="white")
overlay_path = output / f"{name}_overlay.png"
overlay.convert("RGB").save(overlay_path)
# Allow 1-pixel float tolerance per instance: Shapely's polygon area for a
# raster-derived polygon can differ from np.sum(mask) by a small epsilon.
_area_tolerance = max(1, len(features))
if abs(sum(f["properties"]["area_pixels"] for f in features) - mask.sum()) > _area_tolerance:
raise ValueError("Mask and polygon areas disagree; evidence withheld.")
return {"features": features, "region_count": len(features), "selected_pixels": int(mask.sum()),
"geometry_validated":True,"mask_polygon_agree":True,
"area_m2": sum(f["properties"]["area_m2"] or 0 for f in features) if crs else None,
"paths": [geojson, raster, mask_path, overlay_path, preview_path]}