|
| 1 | +import math |
| 2 | +from io import BytesIO |
| 3 | +from typing import Tuple |
| 4 | + |
| 5 | +import cartopy.crs as ccrs |
| 6 | +import cartopy.feature as cfeature |
| 7 | +import matplotlib.pyplot as plt |
| 8 | +import requests |
| 9 | +from PIL import Image |
| 10 | + |
| 11 | +from ..constants import EARTH_RADIUS |
| 12 | + |
| 13 | +EARTH_RADIUS_M = EARTH_RADIUS * 1000 # Convert km to m |
| 14 | + |
| 15 | + |
| 16 | +def deg2num(lat: float, lon: float, zoom: int) -> Tuple[int, int]: |
| 17 | + """ |
| 18 | + Converts geographic coordinates to tile numbers for a given zoom level. |
| 19 | +
|
| 20 | + Parameters |
| 21 | + ---------- |
| 22 | + lat : float |
| 23 | + Latitude in degrees. |
| 24 | + lon : float |
| 25 | + Longitude in degrees. |
| 26 | + zoom : int |
| 27 | + Zoom level. |
| 28 | +
|
| 29 | + Returns |
| 30 | + ------- |
| 31 | + xtile : int |
| 32 | + Tile number in x-direction. |
| 33 | + ytile : int |
| 34 | + Tile number in y-direction. |
| 35 | + """ |
| 36 | + |
| 37 | + lat_rad = math.radians(lat) |
| 38 | + n = 2.0**zoom |
| 39 | + xtile = int((lon + 180.0) / 360.0 * n) |
| 40 | + ytile = int( |
| 41 | + (1.0 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi) / 2.0 * n |
| 42 | + ) |
| 43 | + |
| 44 | + return xtile, ytile |
| 45 | + |
| 46 | + |
| 47 | +def num2deg(xtile: int, ytile: int, zoom: int) -> Tuple[float, float]: |
| 48 | + """ |
| 49 | + Converts tile numbers back to geographic coordinates. |
| 50 | +
|
| 51 | + Parameters |
| 52 | + ---------- |
| 53 | + xtile : int |
| 54 | + Tile number in x-direction. |
| 55 | + ytile : int |
| 56 | + Tile number in y-direction. |
| 57 | + zoom : int |
| 58 | + Zoom level. |
| 59 | +
|
| 60 | + Returns |
| 61 | + ------- |
| 62 | + lat : float |
| 63 | + Latitude in degrees. |
| 64 | + lon : float |
| 65 | + Longitude in degrees. |
| 66 | + """ |
| 67 | + |
| 68 | + n = 2.0**zoom |
| 69 | + lon = xtile / n * 360.0 - 180.0 |
| 70 | + lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n))) |
| 71 | + lat = math.degrees(lat_rad) |
| 72 | + |
| 73 | + return lat, lon |
| 74 | + |
| 75 | + |
| 76 | +def lonlat_to_webmercator(lon: float, lat: float) -> Tuple[float, float]: |
| 77 | + """ |
| 78 | + Converts lon/lat to Web Mercator projection coordinates in meters. |
| 79 | +
|
| 80 | + Parameters |
| 81 | + ---------- |
| 82 | + lon : float |
| 83 | + Longitude in degrees. |
| 84 | + lat : float |
| 85 | + Latitude in degrees. |
| 86 | +
|
| 87 | + Returns |
| 88 | + ------- |
| 89 | + x : float |
| 90 | + X coordinate in meters. |
| 91 | + y : float |
| 92 | + Y coordinate in meters. |
| 93 | + """ |
| 94 | + |
| 95 | + x = EARTH_RADIUS_M * math.radians(lon) |
| 96 | + y = EARTH_RADIUS_M * math.log(math.tan((math.pi / 4) + math.radians(lat) / 2)) |
| 97 | + |
| 98 | + return x, y |
| 99 | + |
| 100 | + |
| 101 | +def tile_bounds_meters( |
| 102 | + x_start: int, y_start: int, x_end: int, y_end: int, zoom: int |
| 103 | +) -> Tuple[float, float, float, float]: |
| 104 | + """ |
| 105 | + Computes the bounding box of the tile region in Web Mercator meters. |
| 106 | +
|
| 107 | + Returns |
| 108 | + ------- |
| 109 | + xmin, ymin, xmax, ymax : float |
| 110 | + Bounding box in meters (Web Mercator projection). |
| 111 | + """ |
| 112 | + |
| 113 | + lat1, lon1 = num2deg(x_start, y_start, zoom) |
| 114 | + lat2, lon2 = num2deg(x_end + 1, y_end + 1, zoom) |
| 115 | + x1, y1 = lonlat_to_webmercator(lon1, lat2) |
| 116 | + x2, y2 = lonlat_to_webmercator(lon2, lat1) |
| 117 | + |
| 118 | + return x1, y1, x2, y2 |
| 119 | + |
| 120 | + |
| 121 | +def calculate_zoom( |
| 122 | + lon_min: float, lon_max: float, display_width_px: int = 1024, tile_size: int = 256 |
| 123 | +) -> int: |
| 124 | + """ |
| 125 | + Automatically estimates an appropriate zoom level for the bounding box. |
| 126 | +
|
| 127 | + Returns |
| 128 | + ------- |
| 129 | + zoom : int |
| 130 | + Estimated zoom level. |
| 131 | + """ |
| 132 | + |
| 133 | + WORLD_MAP_WIDTH = 2 * math.pi * EARTH_RADIUS_M |
| 134 | + x1, _ = lonlat_to_webmercator(lon_min, 0) |
| 135 | + x2, _ = lonlat_to_webmercator(lon_max, 0) |
| 136 | + region_width_m = abs(x2 - x1) |
| 137 | + meters_per_pixel_desired = region_width_m / display_width_px |
| 138 | + zoom = math.log2(WORLD_MAP_WIDTH / (tile_size * meters_per_pixel_desired)) |
| 139 | + |
| 140 | + return int(round(zoom)) |
| 141 | + |
| 142 | + |
| 143 | +def get_cartopy_scale(zoom: int) -> str: |
| 144 | + """ |
| 145 | + Select appropriate cartopy feature scale based on zoom level. |
| 146 | +
|
| 147 | + Parameters |
| 148 | + ---------- |
| 149 | + zoom : int |
| 150 | + Web Mercator zoom level. |
| 151 | +
|
| 152 | + Returns |
| 153 | + ------- |
| 154 | + scale : str |
| 155 | + One of '110m', '50m', or '10m'. |
| 156 | + """ |
| 157 | + |
| 158 | + if zoom >= 9: |
| 159 | + return "10m" |
| 160 | + elif zoom >= 6: |
| 161 | + return "50m" |
| 162 | + else: |
| 163 | + return "110m" |
| 164 | + |
| 165 | + |
| 166 | +def plot_usgs_raster_map( |
| 167 | + lat_min: float, |
| 168 | + lat_max: float, |
| 169 | + lon_min: float, |
| 170 | + lon_max: float, |
| 171 | + zoom: int = None, |
| 172 | + verbose: bool = True, |
| 173 | + mask_ocean: bool = False, |
| 174 | + add_features: bool = True, |
| 175 | + display_width_px: int = 1024, |
| 176 | +) -> Tuple[plt.Figure, plt.Axes]: |
| 177 | + """ |
| 178 | + Downloads and displays a USGS raster map for the given bounding box. |
| 179 | +
|
| 180 | + Parameters |
| 181 | + ---------- |
| 182 | + lat_min : float |
| 183 | + Minimum latitude of the region. |
| 184 | + lat_max : float |
| 185 | + Maximum latitude of the region. |
| 186 | + lon_min : float |
| 187 | + Minimum longitude of the region. |
| 188 | + lon_max : float |
| 189 | + Maximum longitude of the region. |
| 190 | + display_width_px : int, optional |
| 191 | + Approximate pixel width for display (default is 1024). |
| 192 | + """ |
| 193 | + |
| 194 | + tile_size = 256 |
| 195 | + if zoom is None: |
| 196 | + zoom = calculate_zoom(lon_min, lon_max, display_width_px, tile_size) |
| 197 | + if verbose: |
| 198 | + print(f"Auto-selected zoom level: {zoom}") |
| 199 | + |
| 200 | + x_start, y_start = deg2num(lat_max, lon_min, zoom) |
| 201 | + x_end, y_end = deg2num(lat_min, lon_max, zoom) |
| 202 | + width = x_end - x_start + 1 |
| 203 | + height = y_end - y_start + 1 |
| 204 | + |
| 205 | + map_img = Image.new("RGB", (width * tile_size, height * tile_size)) |
| 206 | + tile_url = "https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}" |
| 207 | + |
| 208 | + for x in range(x_start, x_end + 1): |
| 209 | + for y in range(y_start, y_end + 1): |
| 210 | + url = tile_url.format(z=zoom, x=x, y=y) |
| 211 | + try: |
| 212 | + response = requests.get(url, timeout=10) |
| 213 | + tile = Image.open(BytesIO(response.content)) |
| 214 | + map_img.paste( |
| 215 | + tile, ((x - x_start) * tile_size, (y - y_start) * tile_size) |
| 216 | + ) |
| 217 | + except Exception as e: |
| 218 | + print(f"Error fetching tile {x},{y}: {e}") |
| 219 | + |
| 220 | + xmin, ymin, xmax, ymax = tile_bounds_meters(x_start, y_start, x_end, y_end, zoom) |
| 221 | + |
| 222 | + crs_tiles = ccrs.Mercator.GOOGLE |
| 223 | + fig = plt.figure(figsize=(12, 8)) |
| 224 | + ax = plt.axes(projection=crs_tiles) |
| 225 | + ax.set_extent([xmin, xmax, ymin, ymax], crs=crs_tiles) |
| 226 | + |
| 227 | + ax.imshow( |
| 228 | + map_img, origin="upper", extent=[xmin, xmax, ymin, ymax], transform=crs_tiles |
| 229 | + ) |
| 230 | + scale = get_cartopy_scale(zoom) |
| 231 | + if verbose: |
| 232 | + print(f"Using Cartopy scale: {scale}") |
| 233 | + |
| 234 | + if add_features: |
| 235 | + ax.add_feature(cfeature.BORDERS.with_scale(scale), linewidth=0.8) |
| 236 | + ax.add_feature(cfeature.COASTLINE.with_scale(scale)) |
| 237 | + ax.add_feature(cfeature.STATES.with_scale(scale), linewidth=0.5) |
| 238 | + |
| 239 | + if mask_ocean: |
| 240 | + ax.add_feature(cfeature.OCEAN.with_scale(scale), facecolor="w", zorder=3) |
| 241 | + |
| 242 | + return fig, ax |
| 243 | + |
| 244 | + |
| 245 | +if __name__ == "__main__": |
| 246 | + lat_min, lat_max = 35.406, 54.372 |
| 247 | + lon_min, lon_max = -13.148, 12.325 |
| 248 | + fig, ax = plot_usgs_raster_map( |
| 249 | + lat_min, |
| 250 | + lat_max, |
| 251 | + lon_min, |
| 252 | + lon_max, |
| 253 | + zoom=None, |
| 254 | + verbose=True, |
| 255 | + add_features=False, |
| 256 | + mask_ocean=True, |
| 257 | + ) |
| 258 | + fig.show() |
0 commit comments