|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Fetch and mosaic RAP tiles from Google Cloud Storage. |
| 3 | +
|
| 4 | +This utility downloads tiled imagery from the USDA Rangeland Analysis |
| 5 | +Platform (RAP) tilesets and stitches them into a single image. |
| 6 | +
|
| 7 | +Example |
| 8 | +------- |
| 9 | + python scripts/rap_tiles_mosaic.py \ |
| 10 | + --vegetation pfg --year 2011 --masked \ |
| 11 | + --bbox -105.9 40.1 -105.3 40.6 --z 10 \ |
| 12 | + --output mosaic.png \ |
| 13 | + --tag demo |
| 14 | +
|
| 15 | +The saved PNG includes metadata tags ``rap``, ``tiles`` and |
| 16 | +``innovation-summit-2025`` plus any ``--tag`` values. |
| 17 | +""" |
| 18 | +from __future__ import annotations |
| 19 | + |
| 20 | +import argparse |
| 21 | +import io |
| 22 | +import math |
| 23 | +from pathlib import Path |
| 24 | + |
| 25 | +import numpy as np |
| 26 | +import requests |
| 27 | +from PIL import Image, PngImagePlugin |
| 28 | + |
| 29 | +try: # optional dependency used only when --show is passed |
| 30 | + import matplotlib.pyplot as plt |
| 31 | +except Exception: # pragma: no cover - matplotlib is optional |
| 32 | + plt = None |
| 33 | + |
| 34 | + |
| 35 | +def lonlat_to_tile(lon: float, lat: float, z: int) -> tuple[int, int]: |
| 36 | + """Convert longitude/latitude to XYZ tile coordinates.""" |
| 37 | + lat_rad = math.radians(lat) |
| 38 | + n = 2.0 ** z |
| 39 | + x = int((lon + 180.0) / 360.0 * n) |
| 40 | + y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) |
| 41 | + return x, y |
| 42 | + |
| 43 | + |
| 44 | +def fetch_tile_png(url: str) -> Image.Image: |
| 45 | + """Download a single PNG tile.""" |
| 46 | + response = requests.get(url, timeout=60) |
| 47 | + response.raise_for_status() |
| 48 | + return Image.open(io.BytesIO(response.content)).convert("RGBA") |
| 49 | + |
| 50 | + |
| 51 | +def rap_tile_mosaic( |
| 52 | + vegetation: str, |
| 53 | + year: int, |
| 54 | + masked: bool, |
| 55 | + bbox: tuple[float, float, float, float], |
| 56 | + z: int, |
| 57 | +) -> np.ndarray: |
| 58 | + """Return a mosaic array covering the supplied bounding box.""" |
| 59 | + west, south, east, north = bbox |
| 60 | + x_min, y_max = lonlat_to_tile(west, south, z) |
| 61 | + x_max, y_min = lonlat_to_tile(east, north, z) |
| 62 | + |
| 63 | + x_range = range(min(x_min, x_max), max(x_min, x_max) + 1) |
| 64 | + y_range = range(min(y_min, y_max), max(y_min, y_max) + 1) |
| 65 | + |
| 66 | + base = "masked" if masked else "unmasked" |
| 67 | + tileset = "usda-rap-tiles-cover-v3" |
| 68 | + |
| 69 | + mosaic = None |
| 70 | + for y in y_range: |
| 71 | + row_imgs = [] |
| 72 | + for x in x_range: |
| 73 | + url = ( |
| 74 | + f"https://storage.googleapis.com/{tileset}/{base}/" |
| 75 | + f"{vegetation}/{year}/{z}/{x}/{y}.png" |
| 76 | + ) |
| 77 | + try: |
| 78 | + row_imgs.append(fetch_tile_png(url)) |
| 79 | + except Exception: |
| 80 | + row_imgs.append(Image.new("RGBA", (256, 256), (0, 0, 0, 0))) |
| 81 | + row = np.hstack([np.array(im) for im in row_imgs]) |
| 82 | + mosaic = row if mosaic is None else np.vstack([mosaic, row]) |
| 83 | + return mosaic |
| 84 | + |
| 85 | + |
| 86 | +def main() -> int: |
| 87 | + parser = argparse.ArgumentParser(description="Fetch RAP tile mosaic.") |
| 88 | + parser.add_argument("--vegetation", default="pfg", help="Vegetation type") |
| 89 | + parser.add_argument("--year", type=int, default=2011, help="Year of data") |
| 90 | + parser.add_argument("--masked", action="store_true", help="Use masked tiles") |
| 91 | + parser.add_argument( |
| 92 | + "--bbox", |
| 93 | + nargs=4, |
| 94 | + type=float, |
| 95 | + metavar=("W", "S", "E", "N"), |
| 96 | + required=True, |
| 97 | + help="Bounding box (west south east north)", |
| 98 | + ) |
| 99 | + parser.add_argument("--z", type=int, default=10, help="Zoom level") |
| 100 | + parser.add_argument("-o", "--output", type=Path, help="Output PNG file") |
| 101 | + parser.add_argument( |
| 102 | + "--tag", |
| 103 | + action="append", |
| 104 | + default=[], |
| 105 | + metavar="TAG", |
| 106 | + help="Additional metadata tag (can repeat)", |
| 107 | + ) |
| 108 | + parser.add_argument( |
| 109 | + "--show", |
| 110 | + action="store_true", |
| 111 | + help="Display result with matplotlib (requires matplotlib)", |
| 112 | + ) |
| 113 | + args = parser.parse_args() |
| 114 | + |
| 115 | + mosaic = rap_tile_mosaic(args.vegetation, args.year, args.masked, tuple(args.bbox), args.z) |
| 116 | + image = Image.fromarray(mosaic) |
| 117 | + if args.output: |
| 118 | + pnginfo = PngImagePlugin.PngInfo() |
| 119 | + tags = ["rap", "tiles", "innovation-summit-2025", *args.tag] |
| 120 | + pnginfo.add_text("tags", ",".join(tags)) |
| 121 | + image.save(args.output, pnginfo=pnginfo) |
| 122 | + if args.show: |
| 123 | + if plt is None: |
| 124 | + raise SystemExit("matplotlib is required for --show") |
| 125 | + plt.figure(figsize=(6, 6)) |
| 126 | + plt.imshow(mosaic) |
| 127 | + plt.axis("off") |
| 128 | + title = f"RAP tiles — {args.vegetation.upper()} {args.year} ({'masked' if args.masked else 'unmasked'})" |
| 129 | + plt.title(title) |
| 130 | + plt.show() |
| 131 | + return 0 |
| 132 | + |
| 133 | + |
| 134 | +if __name__ == "__main__": # pragma: no cover - CLI entry point |
| 135 | + raise SystemExit(main()) |
0 commit comments