"""Local, pinned satellite building instance model and global tile merging.""" from __future__ import annotations import hashlib import json from dataclasses import dataclass from functools import lru_cache from pathlib import Path import numpy as np import rasterio from scipy import ndimage from PIL import Image from rasterio.windows import Window from satquery_engine.services.ingestion import _starts from satquery_engine.services.radiometry import rgb_unit_data MODEL_ID = "merve/rf-detr-seg-satellite-buildings" REVISION = "05b80dce9a57701724ad6a0fc052827c8b724257" MODEL_SHA256 = "f254c1400f780f7ea72a6bc588a2150bf8b4d8845ded7269655e26a275f41ac7" TILE = 512 STRIDE = 384 THRESHOLD = .5 MIN_INSTANCE_PIXELS = 16 @dataclass(frozen=True) class RfBuildingSession: model: object processor: object checkpoint_sha256: str @lru_cache(maxsize=1) def load_rf_building(path: str) -> RfBuildingSession: from transformers import AutoImageProcessor, RfDetrForInstanceSegmentation directory = Path(path) state = directory / "model.safetensors" if not state.is_file(): raise ValueError("The satellite building instance model is unavailable.") with state.open("rb") as stream: digest = hashlib.file_digest(stream, "sha256").hexdigest() if digest != MODEL_SHA256: raise ValueError("Satellite building model identity could not be verified.") config = json.loads((directory / "config.json").read_text()) processor_config = json.loads((directory / "preprocessor_config.json").read_text()) if config.get("model_type") != "rf_detr" or config.get("id2label") != {"0": "building"}: raise ValueError("Unexpected satellite building model classes or architecture.") if (processor_config.get("image_processor_type") != "RfDetrImageProcessor" or processor_config.get("size") != {"height": 432, "width": 432} or not processor_config.get("do_rescale") or not processor_config.get("do_normalize")): raise ValueError("Unexpected satellite building preprocessing contract.") processor = AutoImageProcessor.from_pretrained(directory, local_files_only=True) from satquery_engine.models.device import torch_device model = RfDetrForInstanceSegmentation.from_pretrained(directory, local_files_only=True).eval().to(torch_device()) if model.config.id2label != {0: "building"}: raise ValueError("Satellite building model does not predict the building class.") return RfBuildingSession(model, processor, digest) def _overlap(candidate, accepted): cy, cx, cmask, _ = candidate[:4] ay, ax, amask, _ = accepted[:4] top, left = max(cy, ay), max(cx, ax) bottom, right = min(cy + cmask.shape[0], ay + amask.shape[0]), min(cx + cmask.shape[1], ax + amask.shape[1]) if bottom <= top or right <= left: return 0.0, 0.0 a = cmask[top-cy:bottom-cy, left-cx:right-cx] b = amask[top-ay:bottom-ay, left-ax:right-ax] intersection = int(np.count_nonzero(a & b)) if not intersection: return 0.0, 0.0 a_size, b_size = int(cmask.sum()), int(amask.sum()) return intersection / (a_size + b_size - intersection), intersection / min(a_size, b_size) def merge_instance_tiles(candidates, shape, valid): """Count only global surviving instances after score-ordered duplicate removal.""" labels = np.zeros(shape, dtype="int32") confidence = np.zeros(shape, dtype="float32") kept = [] duplicates = 0 # Complete interior predictions take precedence over roofs cut by an # internal tile edge. Confidence alone can otherwise preserve half a roof. for candidate in sorted(candidates, key=lambda item: (bool(item[4]) if len(item) > 4 else False, -item[3])): iou, containment = (0.0, 0.0) for previous in kept: current_iou, current_containment = _overlap(candidate, previous) iou, containment = max(iou, current_iou), max(containment, current_containment) if iou >= .5 or containment >= .8: duplicates += 1 continue y, x, mask, score = candidate[:4] area = labels[y:y+mask.shape[0], x:x+mask.shape[1]] supported = mask & valid[y:y+mask.shape[0], x:x+mask.shape[1]] free = supported & (area == 0) # Several accepted roofs can jointly cover a duplicate candidate even # when no single pair reaches the overlap threshold. Never count the # small leftover strip of such a candidate as another building. if free.sum() < MIN_INSTANCE_PIXELS or free.sum() < .5 * supported.sum(): duplicates += 1 continue components, count = ndimage.label(free) sizes = np.bincount(components.ravel()) minimum = max(MIN_INSTANCE_PIXELS, int(np.ceil(sizes[1:].max() * .02))) keep = sizes >= minimum keep[0] = False free = keep[components] if not free.any(): duplicates += 1 continue ident = len(kept) + 1 area[free] = ident confidence[y:y+mask.shape[0], x:x+mask.shape[1]][free] = score kept.append((y, x, free.copy(), score)) return labels, confidence, duplicates def detect_rf_instances(path, session, scene_valid, progress=None): import torch with rasterio.open(path) as source: height, width = source.height, source.width positions = [(y, x) for y in _starts(height, TILE, STRIDE) for x in _starts(width, TILE, STRIDE)] candidates = [] tiles = [] for index, (y, x) in enumerate(positions): th, tw = min(TILE, height-y), min(TILE, width-x) rgb, good, audit = rgb_unit_data(source, window=Window(x, y, tw, th), scene_valid=scene_valid) image = Image.fromarray(np.moveaxis((rgb * 255).round().clip(0, 255).astype("uint8"), 0, -1)) from satquery_engine.models.device import torch_device inputs = session.processor(images=image, return_tensors="pt") inputs = {key: value.to(torch_device()) for key, value in inputs.items()} with torch.inference_mode(): output = session.model(**inputs) result = session.processor.post_process_instance_segmentation( output, threshold=THRESHOLD, target_sizes=[(th, tw)] )[0] segmentation = result["segmentation"].cpu().numpy() if segmentation.shape != (th, tw): raise ValueError("Satellite building instance grid does not match its source tile.") for item in result["segments_info"]: if item.get("label_id") != 0 or not np.isfinite(item.get("score", np.nan)): raise ValueError("Satellite model returned an invalid class or confidence.") score = float(item["score"]) if not 0 <= score <= 1: raise ValueError("Satellite model returned an invalid confidence.") mask = (segmentation == item["id"]) & good points = np.argwhere(mask) if not len(points): continue top, left = points.min(axis=0) bottom, right = points.max(axis=0) + 1 clipped = ((top == 0 and y > 0) or (left == 0 and x > 0) or (bottom == th and y + th < height) or (right == tw and x + tw < width)) candidates.append((y+int(top), x+int(left), mask[top:bottom, left:right].copy(), score, clipped)) tiles.append({"tile_id": index, "x_offset": x, "y_offset": y, "width": tw, "height": th, "valid_pixels": int(good.sum()), "candidate_instances": len(result["segments_info"])}) if progress: progress(f"Detecting buildings: tile {index+1} of {len(positions)}") labels, confidence, duplicate_count = merge_instance_tiles(candidates, (height, width), scene_valid) return labels, confidence, duplicate_count, tiles, audit