"""Exp 4 Phase 2 — training-crop exporter (DOGIS 2025 imagery + supervision rasters). Samples residential Douglas County parcels in LiDAR-tile-aligned clusters and, for each parcel, exports one training crop: images/{id}.jpg L21 (~5.6 cm/px) DOGIS mosaic, CHIP_M meters square, centered on the parcel centroid rasters/{id}_parcel.png 0=bg, 120=neighbor parcels, 255=the parcel rasters/{id}_road.png street-centerline buffer (255 inside) rasters/{id}_lidar.png RGB: R=building (class 6), G=ground (class 2), B=height-above-ground (0.1 m units, clipped 25.5 m) at RASTER_DOWN-pixel cells on the SAME crop grid meta/{id}.json provenance + geotransform (Web-Mercator) manifest.csv one row per crop (append-only; resume skips done ids) All supervision is pre-rasterized into the crop's own pixel grid, so the SAM-3 labeler downstream needs NO geo dependencies (torch/transformers/PIL/numpy only). Eval contamination guard: every address in EXCLUSION_FILES is geocoded (cached) and any parcel within EXCLUDE_RADIUS_M of one is never sampled. Runs on the Windows dev box or the Mac — needs the repo installed (`pip install -e ".[api]"`) plus `data/ne_parcels/ne_parcels.gpkg` (private HF dataset TempuraML/ne-parcels). Unattended, idempotent, resumable; per-stage errors are recorded and the run always ends with a REPORT block. python scripts/exp4/export_training_crops.py --out data/exp4/crops \ --n-crops 50 --per-cluster 17 --prefer-cached-tiles --seed 42 """ from __future__ import annotations import argparse import csv import io import json import math import re import time import traceback from pathlib import Path import numpy as np import requests from PIL import Image, ImageDraw from pyproj import Transformer from scipy import ndimage from shapely.geometry import box as shapely_box from shapely.ops import transform as shapely_transform from lawn_estimator.sources.lidar import DouglasS3LidarSource REPO_ROOT = Path(__file__).resolve().parents[2] SERVICE = "https://dcgis.org/server/rest/services/2025_Douglas_County_NE_Imagery/MapServer" GEOCODER = "https://dcgis.org/server/rest/services/vector/Address_Points/FeatureServer/0/query" STREETS = "https://dcgis.org/server/rest/services/vector/Street_Centerlines/FeatureServer/0/query" PARCELS_GPKG = REPO_ROOT / "data" / "ne_parcels" / "ne_parcels.gpkg" # DOGIS 2025 service fullExtent (EPSG:3857) — the hard coverage boundary. IMAGERY_EXTENT = (-10740413.9352, 5040042.0973, -10671890.2482, 5071228.3064) TILE_SIZE = 256 LEVEL = 21 # ~5.6 cm/px ground at this latitude (probe-verified native detail) CHIP_M = 96.0 # ground meters per crop side RASTER_DOWN = 12 # supervision-raster cell = 12 crop px (~0.67 ground m) ROAD_HALF_WIDTH_M = 5.5 # ground meters — generous centerline buffer prior EXCLUDE_RADIUS_M = 120.0 # no training crop this close to an eval/QA address MIN_SPACING_M = 60.0 # between sampled parcel centroids within a cluster EARTH = 20037508.342787 EXCLUSION_FILES = [ REPO_ROOT / "data" / "evals" / "addresses" / "exp5_green_reclaim.csv", REPO_ROOT / "data" / "lawn_care_schedule.csv", REPO_ROOT / "data" / "regression" / "douglas_qa.csv", ] # BuildingYear strata (targets enforced across clusters): established canopy-heavy, # mid-vintage, and new construction (dormant-turf-rich — the exp5 failure domain). YEAR_BUCKETS = [(0, 1985), (1985, 2013), (2013, 3000)] TO_3857 = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True) TO_4326 = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True) # ---------------------------------------------------------------- mercator math def merc_to_pixel(mx: float, my: float, level: int) -> tuple[float, float]: world = TILE_SIZE * 2.0 ** level px = (mx + EARTH) / (2 * EARTH) * world py = (EARTH - my) / (2 * EARTH) * world return px, py def ground_mpp(lat: float, level: int) -> float: return (2 * EARTH / (TILE_SIZE * 2.0 ** level)) * math.cos(math.radians(lat)) def fetch_tile(session: requests.Session, z: int, row: int, col: int, retries: int = 3) -> Image.Image: url = f"{SERVICE}/tile/{z}/{row}/{col}" last = None for attempt in range(retries): try: r = session.get(url, timeout=30) if r.status_code == 200 and r.headers.get("Content-Type", "").startswith("image"): return Image.open(io.BytesIO(r.content)).convert("RGB") last = f"HTTP {r.status_code}" except requests.RequestException as e: last = repr(e) time.sleep(1.5 * (attempt + 1)) raise RuntimeError(f"tile {z}/{row}/{col}: {last}") def fetch_chip_3857(session: requests.Session, bbox: tuple[float, float, float, float]) -> Image.Image: """Mosaic covering an EPSG:3857 bbox at LEVEL, cropped exactly to it.""" x0, y0 = merc_to_pixel(bbox[0], bbox[3], LEVEL) # top-left x1, y1 = merc_to_pixel(bbox[2], bbox[1], LEVEL) # bottom-right x0, y0, x1, y1 = int(x0), int(y0), int(x1), int(y1) c0, c1, r0, r1 = x0 // TILE_SIZE, x1 // TILE_SIZE, y0 // TILE_SIZE, y1 // TILE_SIZE mosaic = Image.new("RGB", ((c1 - c0 + 1) * TILE_SIZE, (r1 - r0 + 1) * TILE_SIZE)) for row in range(r0, r1 + 1): for col in range(c0, c1 + 1): mosaic.paste(fetch_tile(session, LEVEL, row, col), ((col - c0) * TILE_SIZE, (row - r0) * TILE_SIZE)) return mosaic.crop((x0 - c0 * TILE_SIZE, y0 - r0 * TILE_SIZE, x1 - c0 * TILE_SIZE, y1 - r0 * TILE_SIZE)) # ------------------------------------------------------------------ exclusions def load_exclusions(session: requests.Session, cache_path: Path) -> list[tuple[float, float]]: """Geocode every eval/QA address once (cached). Returns EPSG:3857 points.""" addresses: set[str] = set() for f in EXCLUSION_FILES: text = f.read_text(encoding="utf-8", errors="replace") for m in re.finditer(r'"?([0-9]+ [^,"]+)(?:,| in )\s*(?:Omaha|Elkhorn|Bennington|Valley|Waterloo|Bellevue|Papillion|Gretna|La Vista|Springfield)', text): addresses.add(m.group(1).strip().upper()) cache: dict = json.loads(cache_path.read_text()) if cache_path.exists() else {} pts = [] for addr in sorted(addresses): if addr not in cache: try: r = session.get(GEOCODER, params={ "where": f"FULLADDR LIKE '{addr}%'", "outFields": "FULLADDR", "returnGeometry": "true", "outSR": "3857", "f": "json"}, timeout=30) feats = r.json().get("features") or [] cache[addr] = [feats[0]["geometry"]["x"], feats[0]["geometry"]["y"]] if feats else None except Exception: cache[addr] = None cache_path.write_text(json.dumps(cache, indent=1)) if cache[addr]: pts.append(tuple(cache[addr])) misses = [a for a in sorted(addresses) if not cache.get(a)] print(f"exclusions: {len(pts)} geocoded, {len(misses)} not in Douglas Address_Points " f"(Sarpy/bad addresses — outside the sampling universe anyway): {misses}") return pts # ------------------------------------------------------------------- sampling def residential_parcels(bbox_3857: tuple): import geopandas as gpd g = gpd.read_file(PARCELS_GPKG, bbox=bbox_3857) if g.empty: return g try: g["year"] = g["BuildingYear"].replace("", np.nan).astype(float) except Exception: g["year"] = np.nan has_building = (g["ImpSF"].fillna(0) > 400) | (g["Improvements_Value"].fillna(0) > 20000) size_ok = g["GIS_Acres"].fillna(0).between(0.07, 0.7) # Residential only — calibrated on pilot parcels (Douglas): single-family homes # carry assessor Classification_Code prefix "0101"; the commercial lot that # leaked through the building+size proxy was "0103…". residential = g["Classification_Code"].fillna("").str.startswith("0101") return g[has_building & size_ok & residential & g.geometry.notna()].copy() def merc_m(ground_m: float, lat: float) -> float: return ground_m / math.cos(math.radians(lat)) # -------------------------------------------------------------------- rasters def rasterize_polys(polys_px: list[list[tuple[float, float]]], values: list[int], size: int) -> Image.Image: img = Image.new("L", (size, size), 0) d = ImageDraw.Draw(img) for pts, v in zip(polys_px, values, strict=True): if len(pts) >= 3: d.polygon(pts, fill=v) return img def lidar_raster(points: np.ndarray, bbox: tuple, cells: int, cell_merc: float) -> Image.Image: """RGB supervision raster on the crop grid: R=building, G=ground, B=HAG*10.""" x0, y0, x1, y1 = bbox # 3857; y grows north, raster row 0 = north edge sel = (points["X"] >= x0) & (points["X"] < x1) & (points["Y"] > y0) & (points["Y"] <= y1) p = points[sel] out = np.zeros((cells, cells, 3), dtype=np.uint8) if len(p) == 0: return Image.fromarray(out) ci = np.clip(((p["X"] - x0) / cell_merc).astype(int), 0, cells - 1) ri = np.clip(((y1 - p["Y"]) / cell_merc).astype(int), 0, cells - 1) building = np.zeros((cells, cells), dtype=bool) ground = np.zeros((cells, cells), dtype=bool) np.logical_or.at(building, (ri[p["Classification"] == 6], ci[p["Classification"] == 6]), True) np.logical_or.at(ground, (ri[p["Classification"] == 2], ci[p["Classification"] == 2]), True) building = ndimage.binary_closing(building, iterations=1) ground = ndimage.binary_closing(ground, iterations=1) zmax = np.full((cells, cells), -np.inf) np.maximum.at(zmax, (ri, ci), p["Z"]) gmin = np.full((cells, cells), np.inf) g = p[p["Classification"] == 2] np.minimum.at(gmin, (np.clip(((y1 - g["Y"]) / cell_merc).astype(int), 0, cells - 1), np.clip(((g["X"] - x0) / cell_merc).astype(int), 0, cells - 1)), g["Z"]) have_g = np.isfinite(gmin) if have_g.any(): _, idx = ndimage.distance_transform_edt(~have_g, return_indices=True) gfill = gmin[idx[0], idx[1]] hag = np.where(np.isfinite(zmax), np.clip(zmax - gfill, 0, 25.5), 0) else: hag = np.zeros((cells, cells)) out[..., 0] = building * 255 out[..., 1] = ground * 255 out[..., 2] = (hag * 10).astype(np.uint8) return Image.fromarray(out) def fetch_roads(session: requests.Session, bbox: tuple) -> list[list[tuple[float, float]]]: r = session.get(STREETS, params={ "geometry": json.dumps({"xmin": bbox[0], "ymin": bbox[1], "xmax": bbox[2], "ymax": bbox[3], "spatialReference": {"wkid": 3857}}), "geometryType": "esriGeometryEnvelope", "spatialRel": "esriSpatialRelIntersects", "outFields": "OBJECTID", "returnGeometry": "true", "outSR": "3857", "f": "json"}, timeout=60) r.raise_for_status() paths = [] for f in r.json().get("features", []): for path in (f.get("geometry") or {}).get("paths", []): paths.append([(x, y) for x, y in path]) return paths def road_raster(paths: list, bbox: tuple, size_px: int, mpp_merc: float, lat: float) -> Image.Image: img = Image.new("L", (size_px, size_px), 0) d = ImageDraw.Draw(img) width_px = max(2, int(2 * merc_m(ROAD_HALF_WIDTH_M, lat) / mpp_merc)) x0, _, _, y1 = bbox for path in paths: pts = [((x - x0) / mpp_merc, (y1 - y) / mpp_merc) for x, y in path] if len(pts) >= 2: d.line(pts, fill=255, width=width_px) return img # ------------------------------------------------------------------------ main def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--out", default=str(REPO_ROOT / "data" / "exp4" / "crops")) ap.add_argument("--n-crops", type=int, default=50) ap.add_argument("--per-cluster", type=int, default=17) ap.add_argument("--seed", type=int, default=42) ap.add_argument("--prefer-cached-tiles", action="store_true", help="sample only LiDAR tiles already in data/lidar (fast pilot)") ap.add_argument("--purge-lidar", action="store_true", help="delete LiDAR tiles downloaded per cluster after use " "(pre-existing cached tiles are kept; caps disk at ~1 cluster)") args = ap.parse_args() out = Path(args.out) for sub in ("images", "rasters", "meta"): (out / sub).mkdir(parents=True, exist_ok=True) manifest_path = out / "manifest.csv" done_ids = set() if manifest_path.exists(): with open(manifest_path, newline="", encoding="utf-8") as f: done_ids = {row["id"] for row in csv.DictReader(f)} print("=" * 66) print("CROP EXPORT REPORT BEGIN — copy everything down to REPORT END") print("=" * 66) print(f"out: {out} | target: {args.n_crops} crops | per-cluster: {args.per_cluster} " f"| seed: {args.seed} | already done: {len(done_ids)}") rng = np.random.default_rng(args.seed) session = requests.Session() session.headers["User-Agent"] = "lawn-estimator-exp4-export/1.0" report = {"clusters": 0, "crops": 0, "skipped_done": 0, "failures": 0} try: exclusions = load_exclusions(session, out / "exclusions_geocoded.json") except Exception: print("EXCLUSION LOAD FAILED — refusing to sample without the contamination guard") print(traceback.format_exc()) print("REPORT END") return excl = np.array(exclusions) if exclusions else np.zeros((0, 2)) lidar_src = DouglasS3LidarSource(session=session) tiles = lidar_src._load_index().to_crs("EPSG:3857") ext = shapely_box(*IMAGERY_EXTENT).buffer(-500) # stay inside imagery coverage tiles = tiles[tiles.geometry.within(ext)] if args.prefer_cached_tiles: cached = {p.stem for p in (REPO_ROOT / "data" / "lidar").glob("*.las")} tiles = tiles[tiles["NAME"].isin(cached)] print(f"cached-tile mode: {len(tiles)} candidate tiles") order = rng.permutation(len(tiles)) bucket_counts = {i: 0 for i in range(len(YEAR_BUCKETS))} per_bucket_target = math.ceil(args.n_crops / args.per_cluster / len(YEAR_BUCKETS)) for tix in order: if report["crops"] + report["skipped_done"] >= args.n_crops: break tile = tiles.iloc[tix] try: inner = tile.geometry.buffer(-merc_m(CHIP_M / 2 + 10, 41.26)) if inner.is_empty: continue parcels = residential_parcels(tile.geometry.bounds) if len(parcels) < args.per_cluster: continue parcels = parcels[parcels.geometry.centroid.within(inner)] if len(parcels) < args.per_cluster: continue med = np.nanmedian(parcels["year"]) if np.isfinite(parcels["year"]).any() else 1990 bucket = next(i for i, (a, b) in enumerate(YEAR_BUCKETS) if a <= med < b) if bucket_counts[bucket] >= per_bucket_target and not args.prefer_cached_tiles: continue # LiDAR once per cluster (pass the shrunk tile so neighbors don't download) las_before = {p.name for p in (REPO_ROOT / "data" / "lidar").glob("*.las")} geom_wgs84 = shapely_transform(TO_4326.transform, tile.geometry.buffer(-1)) points = lidar_src.points_for_geometry(geom_wgs84, "EPSG:3857") if len(points) == 0: print(f"cluster {tile['NAME']}: no LiDAR points — skipped") continue roads_all = fetch_roads(session, tile.geometry.bounds) # spaced random sample of parcels, exclusion-guarded chosen = [] cents = parcels.geometry.centroid for pix in rng.permutation(len(parcels)): c = cents.iloc[pix] if excl.shape[0] and np.min(np.hypot(excl[:, 0] - c.x, excl[:, 1] - c.y)) \ < merc_m(EXCLUDE_RADIUS_M, 41.26): continue if any(c.distance(cents.iloc[j]) < merc_m(MIN_SPACING_M, 41.26) for j in chosen): continue chosen.append(pix) if len(chosen) >= args.per_cluster: break made = 0 for pix in chosen: row = parcels.iloc[pix] pid = str(row.get("Parcel_ID") or row.get("State_PID") or f"{tile['NAME']}_{pix}") crop_id = f"{tile['NAME']}_{pid}".replace("/", "_").replace(" ", "") if crop_id in done_ids: report["skipped_done"] += 1 continue try: c = cents.iloc[pix] lat = TO_4326.transform(c.x, c.y)[1] half = merc_m(CHIP_M / 2, lat) bbox = (c.x - half, c.y - half, c.x + half, c.y + half) chip = fetch_chip_3857(session, bbox) size = chip.size[0] chip = chip.crop((0, 0, size, size)) # enforce square mpp_merc = 2 * half / size def to_px(geom, bbox=bbox, mpp_merc=mpp_merc): # statewide gpkg is MultiPolygon Z — coords are (x, y, z) ext_pts = list(geom.exterior.coords) if geom.geom_type == "Polygon" \ else [pt for part in geom.geoms for pt in part.exterior.coords] return [((pt[0] - bbox[0]) / mpp_merc, (bbox[3] - pt[1]) / mpp_merc) for pt in ext_pts] neighbors = parcels[parcels.geometry.intersects(shapely_box(*bbox))] polys, vals = [], [] for _, nb in neighbors.iterrows(): if str(nb.get("Parcel_ID")) != str(row.get("Parcel_ID")): polys.append(to_px(nb.geometry)) vals.append(120) polys.append(to_px(row.geometry)) vals.append(255) rasterize_polys(polys, vals, size).save(out / "rasters" / f"{crop_id}_parcel.png") road_raster(roads_all, bbox, size, mpp_merc, lat).save( out / "rasters" / f"{crop_id}_road.png") cells = max(1, size // RASTER_DOWN) lidar_raster(points, bbox, cells, 2 * half / cells).save( out / "rasters" / f"{crop_id}_lidar.png") chip.save(out / "images" / f"{crop_id}.jpg", quality=92) meta = { "id": crop_id, "parcel_id": pid, "situs": str(row.get("Situs_Address", "")), "year_built": None if not np.isfinite(row["year"]) else int(row["year"]), "acres": float(row.get("GIS_Acres") or 0), "bbox_3857": list(bbox), "level": LEVEL, "size_px": size, "mpp_merc": mpp_merc, "ground_mpp": ground_mpp(lat, LEVEL), "chip_m": CHIP_M, "raster_down": RASTER_DOWN, "lidar_cells": cells, "cluster": str(tile["NAME"]), "year_bucket": bucket, } (out / "meta" / f"{crop_id}.json").write_text(json.dumps(meta, indent=1)) new_file = not manifest_path.exists() with open(manifest_path, "a", newline="", encoding="utf-8") as f: w = csv.writer(f) if new_file: w.writerow(["id", "cluster", "situs", "year_built", "acres", "bucket"]) w.writerow([crop_id, tile["NAME"], meta["situs"], meta["year_built"], round(meta["acres"], 3), bucket]) done_ids.add(crop_id) made += 1 report["crops"] += 1 if report["crops"] + report["skipped_done"] >= args.n_crops: break except Exception: report["failures"] += 1 print(f"crop {crop_id}: FAILED") print(traceback.format_exc()) if args.purge_lidar: del points # release the mmap/array before unlinking on Windows for p in (REPO_ROOT / "data" / "lidar").glob("*.las"): if p.name not in las_before: try: p.unlink() except OSError as e: print(f" purge failed for {p.name}: {e}") if made: bucket_counts[bucket] += 1 report["clusters"] += 1 print(f"cluster {tile['NAME']}: +{made} crops (median year {med:.0f}, bucket {bucket})") except Exception: report["failures"] += 1 print(f"cluster {tile.get('NAME', '?')}: FAILED") print(traceback.format_exc()) print(f"\nsummary: {json.dumps(report)} | bucket_clusters: {bucket_counts}") print(f"manifest: {manifest_path} ({len(done_ids)} total crops)") print("=" * 66) print("REPORT END") print("=" * 66) if __name__ == "__main__": main()