| """ |
| 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)" |
| } |
|
|
| |
| _W = 900 |
| _H = 540 |
| _PAD = 48 |
|
|
| |
| _BG = (240, 244, 250) |
| _LAND = (225, 232, 220) |
| _BORDER = (180, 40, 40) |
| _BORDER2 = (255, 255, 255) |
| _GRID_COLOR = (210, 215, 220) |
| _LABEL_COLOR = (30, 30, 30) |
| _ATTR_BG = (255, 255, 255) |
| _ATTR_FG = (100, 100, 100) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| 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) |
| 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 |
| ] |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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 |
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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." |
|
|
| |
| 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}" |
|
|
| |
| 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." |
|
|
| |
| 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 |
|
|
| |
| try: |
| canvas = Image.new("RGB", (_W, _H), _BG) |
| draw = ImageDraw.Draw(canvas) |
|
|
| |
| _draw_grid(draw, lon_min, lat_min, lon_max, lat_max) |
|
|
| |
| _draw_geojson(draw, geojson, lon_min, lat_min, lon_max, lat_max) |
|
|
| |
| _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}" |
|
|