Skip to content

Instantly share code, notes, and snippets.

@cvanelteren
Created April 20, 2026 01:23
Show Gist options
  • Select an option

  • Save cvanelteren/2558f360fffd8f7b3d24600cb0e33277 to your computer and use it in GitHub Desktop.

Select an option

Save cvanelteren/2558f360fffd8f7b3d24600cb0e33277 to your computer and use it in GitHub Desktop.
3D projection of Antarctica with original claimants of the region
# %%
"""
Antarctic Territorial Claims — 3D Relief Map
Renders a tilted 3D disk of Antarctica using real ETOPO 2022 elevation
data from NOAA, with territorial claim overlays and country flags.
Data source: ETOPO 2022 v1 (60-arc-second surface elevation)
https://www.ngdc.noaa.gov/mgg/global/global.html
"""
import cartopy.crs as ccrs
import matplotlib.path as mpath
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import LightSource
from matplotlib.offsetbox import AnnotationBbox, OffsetImage
from mpl_toolkits.mplot3d import proj3d
from PIL import Image, ImageDraw, ImageFont, ImageOps
from scipy.interpolate import RegularGridInterpolator
from scipy.ndimage import gaussian_filter
# ── Configuration ─────────────────────────────────────────────────────
ETOPO_PATH = "/tmp/etopo_antarctic_hires.npz"
FLAG_DIR = "/tmp/flags"
OUTPUT = "south_pole_3d.png"
# Antarctic territorial claims: (lon_start, lon_end, color, country_code, label)
# Use None for label when a country has a split territory (e.g. Australia)
TERRITORIES = [
(-74, -25, "#4e79a7", "ar", "Argentina"),
(45, 136, "#f28e2b", "au", "Australia"),
(142, 160, "#f28e2b", "au", None), # Australia's eastern sector
(-90, -53, "#e15759", "cl", "Chile"),
(136, 142, "#b07aa1", "fr", "France"),
(160, 210, "#59a14f", "nz", "New Zealand"),
(-20, 45, "#76b7b2", "no", "Norway"),
(-80, -20, "#ff9da7", "gb", "United Kingdom"),
]
# Override the default midpoint angle for overlapping claims,
# and control how high + far out each flag floats
FLAG_LAYOUT = {
"ar": {"angle": -30, "z": 0.35, "r": 0.95},
"gb": {"angle": -55, "z": 0.35, "r": 0.95},
"cl": {"angle": -80, "z": 0.35, "r": 0.95},
"no": {"angle": None, "z": 0.35, "r": 0.95},
"au": {"angle": None, "z": 0.35, "r": 0.95},
"fr": {"angle": None, "z": 0.35, "r": 0.95},
"nz": {"angle": None, "z": 0.35, "r": 0.95},
}
# 3D surface parameters
GRID_RADIAL = 350 # radial resolution
GRID_ANGULAR = 500 # angular resolution
HEIGHT_EXAGGERATION = 0.12 # ~50x real scale
VIEW_ELEVATION = 40
VIEW_AZIMUTH = -70
PLATE_CARREE = ccrs.PlateCarree()
def render_polar_map(territories: list) -> np.ndarray:
"""Render a flat South Polar Stereographic map with territory overlays
and return it as an RGBA image array."""
proj = ccrs.SouthPolarStereo()
fig = plt.figure(figsize=(10, 10), dpi=150)
ax = fig.add_axes([0, 0, 1, 1], projection=proj)
ax.set_extent([-180, 180, -90, -50], PLATE_CARREE)
# Circular boundary
theta = np.linspace(0, 2 * np.pi, 100)
verts = np.vstack([np.sin(theta), np.cos(theta)]).T
ax.set_boundary(mpath.Path(verts * 0.5 + 0.5), transform=ax.transAxes)
ax.stock_img()
ax.coastlines(lw=0.8, color="#3a3a3a", zorder=2)
ax.gridlines(draw_labels=False, color="white", alpha=0.15, lw=0.5)
ax.patch.set_visible(False)
ax.set_facecolor("none")
fig.set_facecolor("none")
for lon0, lon1, color, _, _ in territories:
draw_territory_wedge(ax, lon0, lon1, color)
fig.canvas.draw()
img = np.array(fig.canvas.renderer.buffer_rgba())
plt.close(fig)
return img
def draw_territory_wedge(
ax,
lon_start: float,
lon_end: float,
color: str,
lat_inner: float = -89.5,
lat_outer: float = -60,
alpha: float = 0.2,
):
"""Draw a filled wedge between two longitudes from the pole to 60°S."""
n_points = max(int(abs(lon_end - lon_start)) * 2, 10)
lons_arc = np.linspace(lon_start, lon_end, n_points)
lons = np.concatenate([lons_arc, lons_arc[::-1], [lons_arc[0]]])
lats = np.concatenate(
[
np.full_like(lons_arc, lat_outer),
np.full_like(lons_arc, lat_inner),
[lat_outer],
]
)
ax.fill(lons, lats, transform=PLATE_CARREE, color=color, alpha=alpha, zorder=3)
def build_elevation_surface(etopo_path: str, n_r: int, n_t: int):
"""Load ETOPO data and interpolate it onto a polar disk grid.
Returns X, Y, Z_scaled (disk coordinates), R (radius grid),
and the raw grid dimensions for later sampling.
"""
data = np.load(etopo_path)
elev, lat, lon = data["z"], data["lat"], data["lon"]
r_vals = np.linspace(0, 1, n_r)
t_vals = np.linspace(0, 2 * np.pi, n_t)
R, T = np.meshgrid(r_vals, t_vals)
X = R * np.cos(T)
Y = R * np.sin(T)
# Map polar grid → geographic coordinates
# r=0 is the South Pole (-90°), r=1 is the disk edge (-50°)
# Math angle convention: +X is 0°, +Y is 90°
# In SouthPolarStereo: +Y points toward 0° longitude
# So geographic longitude = 90° - math_angle_degrees
lat_grid = -90 + R * 40
lon_grid = ((90 - np.rad2deg(T) + 180) % 360) - 180
interp = RegularGridInterpolator(
(lat, lon),
elev,
method="linear",
bounds_error=False,
fill_value=0,
)
Z = interp(np.column_stack([lat_grid.ravel(), lon_grid.ravel()])).reshape(R.shape)
# Flatten the ocean and apply light smoothing
Z = gaussian_filter(np.maximum(Z, 0), sigma=1)
# Scale height and fade at edges for a clean boundary
z_max = max(Z.max(), 1)
Z_scaled = Z / z_max * HEIGHT_EXAGGERATION
Z_scaled *= np.clip((1 - R) / 0.05, 0, 1)
return X, Y, Z_scaled, R, n_r, n_t
def sample_map_texture(
map_img: np.ndarray, X: np.ndarray, Y: np.ndarray, R: np.ndarray
):
"""Sample the rendered polar map image at each surface point."""
h, w = map_img.shape[:2]
px = ((X + 1) / 2 * (w - 1)).astype(int).clip(0, w - 1)
py = ((1 - (Y + 1) / 2) * (h - 1)).astype(int).clip(0, h - 1)
colors = map_img[py, px, :3] / 255.0
colors[R > 0.98] = 1.0 # white outside the disk
return colors
def longitude_to_disk(lon_deg: float, radius: float) -> tuple[float, float]:
"""Convert geographic longitude + radius to X, Y on the polar disk."""
angle = np.deg2rad(90 - lon_deg)
return radius * np.cos(angle), radius * np.sin(angle)
def sample_surface_z(
Z_scaled, lon_deg: float, radius: float, n_r: int, n_t: int
) -> float:
"""Look up the surface height at a given longitude and radius."""
angle = np.deg2rad(90 - lon_deg) % (2 * np.pi)
ri = int(radius * (n_r - 1))
ti = int(angle / (2 * np.pi) * (n_t - 1)) % n_t
return float(Z_scaled[ti, ri])
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
"""Parse '#rrggbb' to (r, g, b) integers."""
return int(hex_color[1:3], 16), int(hex_color[3:5], 16), int(hex_color[5:7], 16)
def build_flag_badge(flag_code: str, label: str, color: str) -> Image.Image:
"""Create a combined image with a colored label banner above the flag."""
flag = Image.open(f"{FLAG_DIR}/{flag_code}_hires.png").convert("RGBA")
flag = ImageOps.expand(flag, border=4, fill="white")
fw, fh = flag.size
# Measure text
try:
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 52)
except (OSError, IOError):
font = ImageFont.load_default()
tmp = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
text_box = tmp.textbbox((0, 0), label, font=font)
tw = text_box[2] - text_box[0]
th = text_box[3] - text_box[1]
# Size the banner to fit the text or the flag width, whichever is larger
banner_w = max(fw, tw + 40)
banner_h = th + 30
gap = 8
# Assemble: banner on top, flag below
combined = Image.new("RGBA", (banner_w, fh + banner_h + gap), (0, 0, 0, 0))
r, g, b = hex_to_rgb(color)
banner = Image.new("RGBA", (banner_w, banner_h), (r, g, b, 230))
draw = ImageDraw.Draw(banner)
draw.text(
((banner_w - tw) // 2, (banner_h - th) // 2 - 2),
label,
fill=(255, 255, 255, 255),
font=font,
)
combined.paste(banner, (0, 0), banner)
combined.paste(flag, ((banner_w - fw) // 2, banner_h + gap), flag)
# Drop shadow
shadow_px = 2
final = Image.new(
"RGBA",
(combined.width + shadow_px * 2, combined.height + shadow_px * 2),
(0, 0, 0, 0),
)
shadow = Image.new("RGBA", combined.size, (0, 0, 0, 35))
final.paste(shadow, (shadow_px * 2, shadow_px * 2))
final.paste(combined, (0, 0), combined)
return final
def nearest_edge_alignment(flag_fig, anchor_fig) -> tuple[float, float]:
"""Return the box_alignment that places the connector at the
nearest edge center (left, right, top, or bottom) of the flag badge."""
dx = flag_fig[0] - anchor_fig[0]
dy = flag_fig[1] - anchor_fig[1]
if abs(dx) > abs(dy):
return (0.0, 0.5) if dx > 0 else (1.0, 0.5)
return (0.5, 0.0) if dy > 0 else (0.5, 1.0)
def project_3d_to_fig(ax3d, fig, x, y, z):
"""Project a 3D point to figure-fraction coordinates."""
x2, y2, _ = proj3d.proj_transform(x, y, z, ax3d.get_proj())
disp = ax3d.transData.transform((x2, y2))
return fig.transFigure.inverted().transform(disp)
# Step 1: Render the flat polar map as a texture image
map_img = render_polar_map(TERRITORIES)
# Step 2: Build the 3D elevation surface
X, Y, Z_s, R, n_r, n_t = build_elevation_surface(ETOPO_PATH, GRID_RADIAL, GRID_ANGULAR)
facecolors = sample_map_texture(map_img, X, Y, R)
# Step 3: Plot the 3D surface
fig = plt.figure(figsize=(13, 11), facecolor="white")
ax3 = fig.add_axes([-0.05, -0.05, 1.1, 1.1], projection="3d")
ax3.set_facecolor("white")
ax3.view_init(elev=VIEW_ELEVATION, azim=VIEW_AZIMUTH)
ax3.plot_surface(
X,
Y,
Z_s,
facecolors=facecolors,
rstride=1,
cstride=1,
shade=True,
lightsource=LightSource(azdeg=315, altdeg=40),
antialiased=False,
)
ax3.set_xlim(-1.2, 1.2)
ax3.set_ylim(-1.2, 1.2)
ax3.set_zlim(-0.2, 1.0)
ax3.set_box_aspect([1, 1, 0.5])
ax3.set_axis_off()
# Step 4: Add flag badges with connector sticks
fig.canvas.draw() # needed for accurate 3D → 2D projection
placed = set()
for lon0, lon1, color, code, label in TERRITORIES:
if label is None or code in placed:
continue
placed.add(code)
layout = FLAG_LAYOUT.get(code, {})
mid_lon = layout.get("angle") or (lon0 + lon1) / 2
z_offset = layout.get("z", 0.35)
r_flag = layout.get("r", 0.95)
# Anchor: where the stick meets the surface (near the 60°S circle)
r_anchor = 0.75
xa, ya = longitude_to_disk(mid_lon, r_anchor)
za = sample_surface_z(Z_s, mid_lon, r_anchor, n_r, n_t)
# Flag position: further out and floating above
xf, yf = longitude_to_disk(mid_lon, r_flag)
zf = za + z_offset
# Draw connector stick and anchor dot in 3D
ax3.plot([xa, xf], [ya, yf], [za, zf], color=color, lw=1.5, alpha=0.7, zorder=10)
ax3.scatter([xa], [ya], [za], color=color, s=20, zorder=11)
# Project flag position to 2D figure coordinates
flag_fig = project_3d_to_fig(ax3, fig, xf, yf, zf)
flag_fig = np.clip(flag_fig, [0.08, 0.06], [0.92, 0.94])
# Align the badge so the stick touches its nearest edge
anchor_fig = project_3d_to_fig(ax3, fig, xa, ya, za)
alignment = nearest_edge_alignment(flag_fig, anchor_fig)
badge = build_flag_badge(code, label, color)
ab = AnnotationBbox(
OffsetImage(np.array(badge), zoom=0.12),
flag_fig,
xycoords="figure fraction",
frameon=False,
box_alignment=alignment,
)
fig.add_artist(ab)
fig.savefig(OUTPUT, dpi=200, transparent=True, bbox_inches="tight")
print(f"Saved {OUTPUT}")
@cvanelteren
Copy link
Copy Markdown
Author

cvanelteren commented Apr 20, 2026

south_pole_3d

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment