"""Pure rendering and export helpers for satellite imagery analysis.""" from __future__ import annotations import csv import html import json import math from collections import defaultdict from pathlib import Path from typing import Iterable import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageOps MAX_OUTPUT_SIDE = 2048 LAND_COVER_PALETTE: dict[str, tuple[int, int, int]] = { "background": (45, 45, 45), "bareland": (210, 180, 140), "bare land": (210, 180, 140), "grass": (142, 202, 108), "pavement": (166, 166, 166), "road": (92, 92, 92), "tree": (34, 139, 34), "water": (45, 125, 210), "cropland": (236, 215, 91), "building": (218, 73, 73), } LULC_DISPLAY_NAMES = { "annualcrop": "Annual crop", "forest": "Forest", "herbaceousvegetation": "Herbaceous vegetation", "highway": "Highway", "industrial": "Industrial", "pasture": "Pasture", "permanentcrop": "Permanent crop", "residential": "Residential", "river": "River", "sealake": "Sea / lake", } def normalize_label(label: str) -> str: return label.lower().replace("_", " ").strip() def display_lulc_label(label: str) -> str: """Convert EuroSAT model labels into compact report labels.""" key = "".join(character for character in label.lower() if character.isalnum()) return LULC_DISPLAY_NAMES.get(key, label.replace("_", " ").strip().title()) def confidence_tier(probability: float) -> str: if probability >= 0.80: return "High" if probability >= 0.55: return "Moderate" return "Low" def normalized_entropy(probabilities: Iterable[float]) -> float: """Return Shannon entropy normalized to 0–1 for model ambiguity.""" values = [max(0.0, float(value)) for value in probabilities] total = sum(values) if not values or total <= 0.0 or len(values) == 1: return 0.0 normalized = [value / total for value in values if value > 0.0] entropy = -sum(value * math.log(value) for value in normalized) return entropy / math.log(len(values)) def build_lulc_table( probabilities: Iterable[float], id2label: dict[int, str], top_k: int = 5, ) -> list[list[object]]: ranked = sorted( enumerate(float(value) for value in probabilities), key=lambda item: item[1], reverse=True, )[: max(1, int(top_k))] return [ [rank, display_lulc_label(id2label.get(class_id, f"class_{class_id}")), round(score * 100, 2), confidence_tier(score)] for rank, (class_id, score) in enumerate(ranked, start=1) ] def render_lulc_assessment(rows: list[list[object]], entropy: float) -> str: """Render an accessible probability profile and uncertainty note.""" if not rows: return "
No classification result.
" top_probability = float(rows[0][2]) bars = "".join( "
{}
{:.2f}%
".format( html.escape(str(row[1])), float(row[2]), float(row[2]) ) for row in rows ) ambiguity = "low" if entropy < 0.35 else "moderate" if entropy < 0.65 else "high" return ( "
" f"
SCENE-LEVEL LULC

{html.escape(str(rows[0][1]))}

" f"

{top_probability:.2f}% top-class confidence · " f"{ambiguity} ambiguity (normalized entropy {entropy:.2f})

{bars}" "

A whole-scene EuroSAT label, not a cadastral or planning designation.

" ) def build_analysis_summary( lulc_rows: list[list[object]], entropy: float, land_cover_rows: list[list[object]], detection_rows: list[list[object]], elapsed_seconds: float, ) -> str: lulc_name = str(lulc_rows[0][1]) if lulc_rows else "Unavailable" lulc_confidence = float(lulc_rows[0][2]) if lulc_rows else 0.0 cover_name = str(land_cover_rows[0][1]) if land_cover_rows else "Unavailable" cover_share = float(land_cover_rows[0][3]) if land_cover_rows else 0.0 object_count = sum(int(row[1]) for row in detection_rows) return f"""
Scene LULC{html.escape(lulc_name)}{lulc_confidence:.1f}% confidence · entropy {entropy:.2f}
Dominant cover{html.escape(cover_name)}{cover_share:.1f}% of processed pixels
Detected objects{object_count}{len(detection_rows)} represented object classes
Analysis time{elapsed_seconds:.1f}sclassification + segmentation + detection
""" def write_lulc_csv(path: Path, rows: Iterable[Iterable[object]]) -> None: with path.open("w", newline="", encoding="utf-8") as handle: writer = csv.writer(handle) writer.writerow(["rank", "class", "probability_percent", "confidence_tier"]) writer.writerows(rows) def write_json(path: Path, payload: object) -> None: path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") def fallback_color(class_id: int) -> tuple[int, int, int]: return ( int((67 * class_id + 41) % 190 + 35), int((97 * class_id + 73) % 190 + 35), int((43 * class_id + 109) % 190 + 35), ) def class_color(class_id: int, label: str) -> tuple[int, int, int]: return LAND_COVER_PALETTE.get(normalize_label(label), fallback_color(class_id)) def resize_for_inference( image: Image.Image, max_side: int = MAX_OUTPUT_SIDE, ) -> Image.Image: """Normalize orientation/RGB and bound memory while preserving aspect ratio.""" prepared = ImageOps.exif_transpose(image).convert("RGB") width, height = prepared.size longest = max(width, height) if longest <= max_side: return prepared scale = max_side / longest size = (max(1, round(width * scale)), max(1, round(height * scale))) resampling = getattr(Image, "Resampling", Image) return prepared.resize(size, resampling.LANCZOS) def render_segmentation( image: Image.Image, class_map: np.ndarray, id2label: dict[int, str], opacity: float, ) -> tuple[Image.Image, Image.Image]: """Return a land-cover overlay and a categorical color mask.""" height, width = class_map.shape color_array = np.zeros((height, width, 3), dtype=np.uint8) for class_id in np.unique(class_map): label = id2label.get(int(class_id), f"class_{int(class_id)}") color_array[class_map == class_id] = class_color(int(class_id), label) base = np.asarray(image.resize((width, height)), dtype=np.float32) overlay = ( base * (1.0 - opacity) + color_array.astype(np.float32) * opacity ).astype(np.uint8) boundaries = np.zeros((height, width), dtype=bool) boundaries[1:, :] |= class_map[1:, :] != class_map[:-1, :] boundaries[:, 1:] |= class_map[:, 1:] != class_map[:, :-1] overlay[boundaries] = (255, 255, 255) return Image.fromarray(overlay), Image.fromarray(color_array) def build_class_table( class_map: np.ndarray, id2label: dict[int, str], min_share_percent: float = 0.0, ) -> list[list[object]]: class_ids, counts = np.unique(class_map, return_counts=True) total_pixels = int(class_map.size) rows: list[list[object]] = [] for class_id, count in zip(class_ids, counts): share = 100.0 * int(count) / total_pixels if share < min_share_percent: continue label = id2label.get(int(class_id), f"class_{int(class_id)}") color = class_color(int(class_id), label) rows.append( [ int(class_id), label, int(count), round(share, 2), "#{:02X}{:02X}{:02X}".format(*color), ] ) rows.sort(key=lambda row: float(row[3]), reverse=True) return rows def _load_font(size: int) -> ImageFont.ImageFont: candidates = ( "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/System/Library/Fonts/Helvetica.ttc", ) for candidate in candidates: try: return ImageFont.truetype(candidate, size) except OSError: continue return ImageFont.load_default() def render_detections( image: Image.Image, detections: Iterable[dict[str, object]], ) -> Image.Image: rendered = image.convert("RGB").copy() draw = ImageDraw.Draw(rendered) short_side = min(rendered.size) line_width = max(2, round(short_side / 320)) font = _load_font(max(12, min(24, round(short_side / 55)))) for detection in sorted( detections, key=lambda item: float(item["confidence"]), reverse=True, ): class_id = int(detection["class_id"]) color = fallback_color(class_id) box = tuple(float(detection[key]) for key in ("x1", "y1", "x2", "y2")) draw.rectangle(box, outline=color, width=line_width) label = f"{detection['class_name']} {float(detection['confidence']):.2f}" text_box = draw.textbbox((0, 0), label, font=font) text_width = text_box[2] - text_box[0] + 8 text_height = text_box[3] - text_box[1] + 8 left = max(0.0, min(box[0], rendered.width - text_width)) top = max(0.0, box[1] - text_height) background = (left, top, left + text_width, top + text_height) draw.rectangle(background, fill=color) draw.text((left + 4, top + 4), label, fill="white", font=font) return rendered def build_detection_summary( detections: Iterable[dict[str, object]], ) -> list[list[object]]: grouped: dict[str, list[float]] = defaultdict(list) for detection in detections: grouped[str(detection["class_name"])].append(float(detection["confidence"])) rows = [ [name, len(scores), round(sum(scores) / len(scores), 3), round(max(scores), 3)] for name, scores in grouped.items() ] rows.sort(key=lambda row: (-int(row[1]), str(row[0]))) return rows def build_detection_table( detections: Iterable[dict[str, object]], image_size: tuple[int, int], ) -> list[list[object]]: width, height = image_size rows: list[list[object]] = [] for index, detection in enumerate(detections, start=1): x1, y1, x2, y2 = (float(detection[key]) for key in ("x1", "y1", "x2", "y2")) rows.append( [ index, str(detection["class_name"]), round(float(detection["confidence"]), 3), round(x1, 1), round(y1, 1), round(x2, 1), round(y2, 1), round((x2 - x1) * (y2 - y1), 1), round(((x1 + x2) / 2) / width, 5), round(((y1 + y2) / 2) / height, 5), ] ) return rows def write_class_csv(path: Path, rows: Iterable[Iterable[object]]) -> None: with path.open("w", newline="", encoding="utf-8") as handle: writer = csv.writer(handle) writer.writerow(["class_id", "class_name", "pixels", "share_percent", "color"]) writer.writerows(rows) def write_detection_csv(path: Path, rows: Iterable[Iterable[object]]) -> None: with path.open("w", newline="", encoding="utf-8") as handle: writer = csv.writer(handle) writer.writerow( [ "object_id", "class", "confidence", "x1", "y1", "x2", "y2", "area_pixels", "center_x_normalized", "center_y_normalized", ] ) writer.writerows(rows) def write_pixel_geojson( path: Path, detections: Iterable[dict[str, object]], image_size: tuple[int, int], ) -> None: """Write boxes as polygons in image-pixel coordinates, not geographic CRS.""" width, height = image_size features = [] for index, detection in enumerate(detections, start=1): x1, y1, x2, y2 = (float(detection[key]) for key in ("x1", "y1", "x2", "y2")) features.append( { "type": "Feature", "id": index, "properties": { "class": str(detection["class_name"]), "class_id": int(detection["class_id"]), "confidence": round(float(detection["confidence"]), 6), }, "geometry": { "type": "Polygon", "coordinates": [[[x1, y1], [x2, y1], [x2, y2], [x1, y2], [x1, y1]]], }, } ) collection = { "type": "FeatureCollection", "name": "satellite_detections_pixel_coordinates", "properties": { "coordinate_system": "image_pixels", "origin": "top_left", "image_width": width, "image_height": height, }, "features": features, } path.write_text(json.dumps(collection, indent=2), encoding="utf-8")