Download map_utils.py from build-small-hackathon/OSINTAgentTool: direct link, hf CLI and curl.
- Browser
- Download file 9.3 kB
-
https://huggingface.co/spaces/build-small-hackathon/OSINTAgentTool/resolve/main/map_utils.py
- Command line
-
hf download hf://spaces/build-small-hackathon/OSINTAgentTool/map_utils.py
-
curl -L -o map_utils.py https://huggingface.co/spaces/build-small-hackathon/OSINTAgentTool/resolve/main/map_utils.py
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}" | |