"""DOGIS 2025 imagery export probe — Exp 4 Phase 2, step 1. Validates the Douglas County 2025 imagery MapServer as the training-crop source BEFORE anything scales: fetches tile mosaics over known parcels at zoom levels 19-22, measures effective GSD / sharpness / detail gain per level (to find the native resolution ceiling), greenness (leaf-off check), and JPEG quality. Self-contained: no repo imports. Runs identically on the Windows dev box and the owner's Mac. Everything the analysis needs is printed between the REPORT BEGIN/END banners — copy that whole block back. Each stage catches and records its own error, so partial failures still produce a report. Setup on a fresh Mac (Terminal): python3 -m venv ~/lawn-train && source ~/lawn-train/bin/activate pip install requests pillow numpy python dogis_export_probe.py [--out ./dogis_probe_out] Idempotent: chips already on disk are not re-fetched. """ from __future__ import annotations import argparse import io import json import math import os import platform import time import traceback import numpy as np import requests from PIL import Image 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" TILE_SIZE = 256 LEVELS = [19, 20, 21, 22] CHIP_M = 80.0 # ~80 m square around the address point — covers a residential parcel + ROW # Known-difficult parcels from the accuracy experiments (all Douglas County). # Fallback coords baked in so a geocoder outage doesn't kill the probe. ADDRESSES = { "8571 Young St": (41.332665, -96.046617), # exp5 new-construction turf "14052 Hartman Ave": (41.310482, -96.134419), # canonical QA address "7617 Grover St": (41.227234, -96.030871), # shadowed turf strips (Phase 1 regression) "17531 Madison St": (41.193864, -96.189410), # street-edge band turf "1623 N 75th Ave": (41.274847, -96.028857), # deep setback / ROW-to-curb case } EARTH = 20037508.342787 # Web-Mercator half-circumference (m) def geocode(session: requests.Session, address: str) -> tuple[float, float]: r = session.get(GEOCODER, params={ "where": f"FULLADDR LIKE '{address.upper()}%'", "outFields": "FULLADDR", "returnGeometry": "true", "outSR": "4326", "f": "json", }, timeout=30) r.raise_for_status() feats = r.json().get("features") or [] if not feats: raise ValueError(f"no address point for {address!r}") g = feats[0]["geometry"] return g["y"], g["x"] def tile_xy(lat: float, lon: float, z: int) -> tuple[float, float]: """Fractional (col, row) at zoom z.""" n = 2.0 ** z x = (lon + 180.0) / 360.0 * n siny = math.sin(math.radians(lat)) y = (0.5 - math.log((1 + siny) / (1 - siny)) / (4 * math.pi)) * n return x, y def meters_per_pixel(lat: float, z: int) -> float: return (2 * EARTH / (TILE_SIZE * 2.0 ** z)) * 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} {r.headers.get('Content-Type')}" except requests.RequestException as e: # transient gov-server 5xx / resets last = repr(e) time.sleep(1.5 * (attempt + 1)) raise RuntimeError(f"tile {z}/{row}/{col}: {last}") def fetch_chip(session: requests.Session, lat: float, lon: float, z: int, chip_m: float = CHIP_M) -> Image.Image: """Mosaic of cached tiles covering chip_m meters square centered on (lat, lon).""" mpp = meters_per_pixel(lat, z) half_px = chip_m / 2 / mpp cx, cy = tile_xy(lat, lon, z) px_c, py_c = cx * TILE_SIZE, cy * TILE_SIZE # global pixel coords x0, y0 = int(px_c - half_px), int(py_c - half_px) x1, y1 = int(px_c + half_px), int(py_c + half_px) c0, c1 = x0 // TILE_SIZE, x1 // TILE_SIZE r0, r1 = 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, z, 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)) def laplacian_var(img: Image.Image) -> float: """Sharpness proxy: variance of a 4-neighbor Laplacian on the gray channel.""" a = np.asarray(img.convert("L"), dtype=np.float64) lap = (-4 * a[1:-1, 1:-1] + a[:-2, 1:-1] + a[2:, 1:-1] + a[1:-1, :-2] + a[1:-1, 2:]) return float(lap.var()) def stats(img: Image.Image) -> dict: a = np.asarray(img, dtype=np.float64) r, g, b = a[..., 0], a[..., 1], a[..., 2] exg = 2 * g - r - b # excess-green return { "size": list(img.size), "mean_rgb": [round(float(c.mean()), 1) for c in (r, g, b)], "brightness_std": round(float(a.mean(axis=2).std()), 1), "green_frac_exg20": round(float((exg > 20).mean()), 3), "laplacian_var": round(laplacian_var(img), 1), "blank_frac": round(float((a.mean(axis=2) < 5).mean()), 4), } def detail_gain(fine: Image.Image, coarse: Image.Image) -> float: """Sharpness of the real fine chip vs the coarse chip bicubic-upsampled to the same size. Directional only — JPEG artifacts inflate it, so a high ratio is necessary but not sufficient; the settled verdict came from visual inspection (2026-07-17: real detail through L22, see docs/phase2-findings.md).""" up = coarse.resize(fine.size, Image.BICUBIC) lv_up = laplacian_var(up) return round(laplacian_var(fine) / lv_up, 2) if lv_up > 0 else float("nan") def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--out", default="./dogis_probe_out") args = ap.parse_args() os.makedirs(args.out, exist_ok=True) print("=" * 66) print("DOGIS PROBE REPORT BEGIN — copy everything down to REPORT END") print("=" * 66) print(f"machine: {platform.machine()} | {platform.platform()}") print(f"python: {platform.python_version()} | numpy: {np.__version__}") print(f"service: {SERVICE}") print(f"levels: {LEVELS} | chip: {CHIP_M:.0f} m | out: {os.path.abspath(args.out)}") session = requests.Session() session.headers["User-Agent"] = "lawn-estimator-exp4-probe/1.0" report: dict = {} try: meta = session.get(SERVICE, params={"f": "json"}, timeout=30).json() print(f"service ok: caps={meta.get('capabilities')} " f"maxLOD={max(l['level'] for l in meta['tileInfo']['lods'])} " f"format={meta['tileInfo'].get('format')}") except Exception: print("SERVICE METADATA FAILED (continuing — tiles may still work):") print(traceback.format_exc()) for address, fallback in ADDRESSES.items(): entry: dict = {} report[address] = entry print(f"\n--- {address} ---") try: try: lat, lon = geocode(session, address) src = "geocoded" except Exception as e: if fallback is None: raise lat, lon = fallback src = f"fallback coords (geocode failed: {e})" entry["latlon"] = [round(lat, 6), round(lon, 6)] print(f"location: {lat:.6f}, {lon:.6f} ({src})") chips: dict[int, Image.Image] = {} for z in LEVELS: slug = address.lower().replace(" ", "_") path = os.path.join(args.out, f"{slug}_L{z}.png") t0 = time.perf_counter() if os.path.exists(path): chips[z] = Image.open(path).convert("RGB") fetched = "cached" else: chips[z] = fetch_chip(session, lat, lon, z) chips[z].save(path) fetched = f"{time.perf_counter() - t0:.1f}s" s = stats(chips[z]) s["mpp_cm"] = round(meters_per_pixel(lat, z) * 100, 1) s["fetch"] = fetched entry[f"L{z}"] = s print(f"L{z}: {json.dumps(s)}") gains = {} for zc, zf in zip(LEVELS, LEVELS[1:]): gains[f"L{zf}_vs_L{zc}"] = detail_gain(chips[zf], chips[zc]) entry["detail_gain"] = gains print(f"detail gain (>=1.3 => real detail at finer level): {json.dumps(gains)}") except Exception: entry["error"] = "FAILED" print(f"{address}: FAILED") print(traceback.format_exc()) ok = [a for a, e in report.items() if "error" not in e] print(f"\nsummary: {len(ok)}/{len(ADDRESSES)} addresses ok") greens = [e[f"L{LEVELS[-2]}"]["green_frac_exg20"] for e in report.values() if f"L{LEVELS[-2]}" in e] if greens: print(f"green fraction (ExG>20) across chips: min {min(greens):.2f} " f"max {max(greens):.2f} — low values = leaf-off/dormant (expected for 2025 flight)") print("=" * 66) print("REPORT END") print("=" * 66) if __name__ == "__main__": main()