OSINTAgentTool / map_utils.py
Firemedic15's picture
Upload 7 files
90674b6 verified
Raw
History Blame Contribute Delete
9.3 kB
"""
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}"