File size: 9,298 Bytes
90674b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 | """
map_utils.py - Generate a country outline map from Nominatim GeoJSON.
No tile server dependency - draws directly from polygon data.
"""
import io
import math
import tempfile
from typing import Optional, Tuple
import requests
from PIL import Image, ImageDraw, ImageFont
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
_HEADERS = {
"User-Agent": "OSINTThreatAnalyst/1.0 (HuggingFace Space; non-commercial research)"
}
# Map canvas dimensions
_W = 900
_H = 540
_PAD = 48 # padding around the border polygon
# Colors
_BG = (240, 244, 250) # light blue-grey background (ocean-like)
_LAND = (225, 232, 220) # muted green-grey for country fill
_BORDER = (180, 40, 40) # red border line
_BORDER2 = (255, 255, 255) # white inner line for contrast
_GRID_COLOR = (210, 215, 220) # faint grid lines
_LABEL_COLOR = (30, 30, 30) # country name label
_ATTR_BG = (255, 255, 255)
_ATTR_FG = (100, 100, 100)
# ---------------------------------------------------------------------------
# Coordinate projection
# ---------------------------------------------------------------------------
def _project(lon: float, lat: float,
lon_min: float, lat_min: float,
lon_max: float, lat_max: float,
w: int, h: int, pad: int) -> Tuple[int, int]:
"""Equirectangular projection with padding."""
lon_span = lon_max - lon_min or 1e-6
lat_span = lat_max - lat_min or 1e-6
usable_w = w - 2 * pad
usable_h = h - 2 * pad
# Maintain aspect ratio
scale = min(usable_w / lon_span, usable_h / lat_span)
offset_x = pad + (usable_w - lon_span * scale) / 2
offset_y = pad + (usable_h - lat_span * scale) / 2
px = int(offset_x + (lon - lon_min) * scale)
py = int(offset_y + (lat_max - lat) * scale) # Y inverted
return px, py
def _ring_to_pixels(ring, lon_min, lat_min, lon_max, lat_max):
return [
_project(lon, lat, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
for lon, lat in ring
]
# ---------------------------------------------------------------------------
# Drawing helpers
# ---------------------------------------------------------------------------
def _draw_grid(draw: ImageDraw.ImageDraw,
lon_min, lat_min, lon_max, lat_max) -> None:
"""Draw faint lat/lon grid lines."""
lon_span = lon_max - lon_min
lat_span = lat_max - lat_min
step = 10.0
for s in [1, 2, 5, 10, 20, 30]:
if lon_span / s <= 6 and lat_span / s <= 6:
step = float(s)
break
lon_start = math.floor(lon_min / step) * step
lat_start = math.floor(lat_min / step) * step
lon = lon_start
while lon <= lon_max + step:
x1, _ = _project(lon, lat_min, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
x2, _ = _project(lon, lat_max, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
draw.line([(x1, 0), (x2, _H)], fill=_GRID_COLOR, width=1)
lon += step
lat = lat_start
while lat <= lat_max + step:
_, y1 = _project(lon_min, lat, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
_, y2 = _project(lon_max, lat, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
draw.line([(0, y1), (_W, y2)], fill=_GRID_COLOR, width=1)
lat += step
def _draw_geojson(draw: ImageDraw.ImageDraw, geojson: dict,
lon_min, lat_min, lon_max, lat_max) -> None:
"""Fill and stroke the country polygon."""
geom_type = geojson.get("type", "")
coords = geojson.get("coordinates", [])
def draw_ring(ring):
pts = _ring_to_pixels(ring, lon_min, lat_min, lon_max, lat_max)
if len(pts) < 3:
return
draw.polygon(pts, fill=_LAND)
# White inner stroke for contrast, then red outer
draw.line(pts + [pts[0]], fill=_BORDER2, width=3)
draw.line(pts + [pts[0]], fill=_BORDER, width=2)
if geom_type == "Polygon" and coords:
draw_ring(coords[0])
elif geom_type == "MultiPolygon":
for polygon in coords:
if polygon:
draw_ring(polygon[0])
def _add_label(canvas: Image.Image, country: str,
lon_min, lat_min, lon_max, lat_max) -> None:
"""Add country name label at the centroid."""
draw = ImageDraw.Draw(canvas)
cx = (lon_min + lon_max) / 2
cy = (lat_min + lat_max) / 2
px, py = _project(cx, cy, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
# Try to load a font; fall back to default
font = None
for size in [18, 14]:
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size)
break
except Exception:
try:
font = ImageFont.truetype("arial.ttf", size)
break
except Exception:
font = ImageFont.load_default()
break
text = country.upper()
try:
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
except Exception:
tw, th = len(text) * 7, 12
# White shadow box
margin = 4
draw.rectangle(
[px - tw // 2 - margin, py - th // 2 - margin,
px + tw // 2 + margin, py + th // 2 + margin],
fill=(255, 255, 255, 180),
)
draw.text((px - tw // 2, py - th // 2), text, fill=_LABEL_COLOR, font=font)
def _add_attribution(canvas: Image.Image) -> None:
draw = ImageDraw.Draw(canvas)
text = "© OpenStreetMap contributors | Nominatim geocoding"
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
except Exception:
font = ImageFont.load_default()
draw.rectangle([0, _H - 18, _W, _H], fill=_ATTR_BG)
draw.text((4, _H - 15), text, fill=_ATTR_FG, font=font)
def _add_compass(canvas: Image.Image) -> None:
"""Simple N arrow in the bottom-right corner."""
draw = ImageDraw.Draw(canvas)
cx, cy = _W - 30, _H - 40
r = 12
# N arrow
draw.polygon([(cx, cy - r), (cx - 5, cy + 4), (cx + 5, cy + 4)], fill=(60, 60, 60))
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 11)
except Exception:
font = ImageFont.load_default()
draw.text((cx - 4, cy - r - 14), "N", fill=(40, 40, 40), font=font)
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def generate_country_map(country: str) -> Tuple[Optional[str], Optional[str]]:
"""
Generate a country outline map from Nominatim GeoJSON (no tile server).
Returns (path, None) on success or (None, error_message) on failure.
"""
if not country or not country.strip():
return None, "No country specified."
# Nominatim lookup with retry
results = None
last_err = None
import time as _t
for attempt in range(3):
try:
resp = requests.get(
NOMINATIM_URL,
params={
"q": country,
"format": "json",
"limit": 5,
"polygon_geojson": 1,
},
headers=_HEADERS,
timeout=20,
)
resp.raise_for_status()
results = resp.json()
if results:
break
last_err = "No geocoding results returned."
except Exception as e:
last_err = str(e)
if attempt < 2:
_t.sleep(1.5)
if not results:
return None, f"Nominatim lookup failed: {last_err}"
# Pick best result
entry = None
for r in results:
if r.get("type") in ("administrative", "country") or r.get("class") == "boundary":
entry = r
break
if entry is None:
entry = results[0]
bb = entry.get("boundingbox")
if not bb or len(bb) != 4:
return None, "Bounding box not available from geocoder."
try:
south, north, west, east = (float(v) for v in bb)
except Exception as e:
return None, f"Invalid bounding box: {e}"
geojson = entry.get("geojson")
if not geojson:
return None, "No GeoJSON polygon available for this country."
# Add a small margin around the bounding box
margin_lon = (east - west) * 0.12
margin_lat = (north - south) * 0.12
lon_min = west - margin_lon
lon_max = east + margin_lon
lat_min = south - margin_lat
lat_max = north + margin_lat
# Build canvas
try:
canvas = Image.new("RGB", (_W, _H), _BG)
draw = ImageDraw.Draw(canvas)
# Grid
_draw_grid(draw, lon_min, lat_min, lon_max, lat_max)
# Country polygon
_draw_geojson(draw, geojson, lon_min, lat_min, lon_max, lat_max)
# Labels / decorations
_add_label(canvas, country, lon_min, lat_min, lon_max, lat_max)
_add_compass(canvas)
_add_attribution(canvas)
tmp = tempfile.mktemp(suffix=".png")
canvas.save(tmp, format="PNG")
return tmp, None
except Exception as e:
return None, f"Map render failed: {e}"
|