Skip to content

Commit a8eacac

Browse files
authored
Merge pull request #30 from CU-ESIIL/codex/ensure-rap-tiles-code-is-working
Add RAP tile mosaic script
2 parents ee072ff + 1e00a99 commit a8eacac

File tree

2 files changed

+139
-0
lines changed

2 files changed

+139
-0
lines changed

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ colorama>=0.4
3232
regex>=2022.4.24
3333
requests>=2.26
3434
mkdocs-no-sitemap-plugin>=0.0.1
35+
36+
numpy
37+
pillow
38+
matplotlib

scripts/rap_tiles_mosaic.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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

Comments
 (0)