SatelliteVisionToolkit / satellite_utils.py
Mingze's picture
Upgrade to professional LULC workbench
edf7ac3 verified
Raw
History Blame Contribute Delete
13.3 kB
"""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 "<div class='assessment-card'>No classification result.</div>"
top_probability = float(rows[0][2])
bars = "".join(
"<div class='prob-row'><span>{}</span><div class='prob-track'><i style='width:{:.2f}%'></i></div><b>{:.2f}%</b></div>".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 (
"<div class='assessment-card'>"
f"<div class='eyebrow'>SCENE-LEVEL LULC</div><h2>{html.escape(str(rows[0][1]))}</h2>"
f"<p><strong>{top_probability:.2f}%</strong> top-class confidence · "
f"{ambiguity} ambiguity (normalized entropy {entropy:.2f})</p>{bars}"
"<p class='micro-note'>A whole-scene EuroSAT label, not a cadastral or planning designation.</p></div>"
)
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"""
<div class="summary-grid">
<div class="metric-card"><span>Scene LULC</span><strong>{html.escape(lulc_name)}</strong><small>{lulc_confidence:.1f}% confidence · entropy {entropy:.2f}</small></div>
<div class="metric-card"><span>Dominant cover</span><strong>{html.escape(cover_name)}</strong><small>{cover_share:.1f}% of processed pixels</small></div>
<div class="metric-card"><span>Detected objects</span><strong>{object_count}</strong><small>{len(detection_rows)} represented object classes</small></div>
<div class="metric-card"><span>Analysis time</span><strong>{elapsed_seconds:.1f}s</strong><small>classification + segmentation + detection</small></div>
</div>
"""
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")