Files
xtrip/scripts/fetch-reference-maps.py
T
Stephen 415103a284 build: 切换包管理器至 pnpm 并新增西安参考底图工具与资源
- 移除 npm 锁文件 package-lock.json 与本地 node_modules 目录
- 配置 pnpm 工作区,允许 esbuild 的安装期构建脚本
- 添加 scripts/fetch-reference-maps.py 用于抓取拼接高德地图瓦片生成参考底图
- 生成两张西安参考底图并保存至 public/assets/maps/ 目录
- 更新 CHANGELOG.md 记录版本变更历史
2026-10-19 00:19:40 +08:00

176 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
抓取高德地图瓦片并拼接为西安参考底图。
文件用途:
- 生成两张高清参考地图(仅作绘制参照与人工核对,不参与游戏运行时加载):
1) 西安全域概览(z12,覆盖游戏 viewBox 对应的真实地理范围);
2) 主城区高清图(z14,覆盖城墙 / 大雁塔 / 曲江 / 大明宫一带)。
核心职责:
1. 由经纬度 bbox 计算指定 zoom 下的瓦片行列范围(Web 墨卡托);
2. 下载高德矢量路网瓦片(style=8,中文标注),失败重试并以灰块占位;
3. 用 PIL 拼接瓦片并在底部加版权署名条("底图数据 © 高德地图")。
主要流程:
python3 scripts/fetch-reference-maps.py
→ 依次抓取两组配置 → 拼接 → 输出到 public/assets/maps/
"""
import io
import math
import time
import urllib.request
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
# 项目根目录(脚本位于 scripts/ 下)。
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# 瓦片输出目录。
OUT_DIR = PROJECT_ROOT / "public" / "assets" / "maps"
# 高德瓦片服务(style=8 标准矢量路网图,中文标注)。
TILE_URL = (
"https://webrd0{sub}.is.autonavi.com/appmaptile"
"?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}"
)
# 请求 UA(遵守瓦片服务礼仪)。
UA = "XianTravelGame/0.1 (local reference fetching; contact: dev)"
# 瓦片尺寸(像素)。
TILE_SIZE = 256
# 单瓦片下载失败重试次数。
MAX_RETRY = 3
def deg2tile(lon: float, lat: float, zoom: int) -> tuple[int, int]:
"""经纬度转 Web 墨卡托瓦片坐标。
Args:
lon: 经度(度)。
lat: 纬度(度)。
zoom: 缩放级别。
Returns:
(x, y) 瓦片行列号(左上角所属瓦片)。
"""
n = 2 ** zoom
x = int((lon + 180.0) / 360.0 * n)
lat_rad = math.radians(lat)
y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return x, y
def fetch_tile(zoom: int, x: int, y: int) -> Image.Image | None:
"""下载单张瓦片(子域名轮转与重试)。
高德路网瓦片子域名为 webrd01~webrd04;重试时轮换子域名以规避单点故障。
Args:
zoom: 缩放级别。
x: 瓦片列号。
y: 瓦片行号。
Returns:
成功返回 PIL 图像,最终失败返回 None。
"""
for attempt in range(MAX_RETRY):
sub = (x + y + attempt) % 4 + 1 # 轮转 1-4 子域名,重试时换域。
url = TILE_URL.format(sub=sub, x=x, y=y, z=zoom)
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=10) as resp:
data = resp.read()
return Image.open(io.BytesIO(data)).convert("RGB")
except Exception:
time.sleep(0.5)
return None
def stitch(bbox: dict, zoom: int, out_name: str) -> Path:
"""按 bbox 抓取并拼接整图。
Args:
bbox: {"west": float, "east": float, "south": float, "north": float}。
zoom: 缩放级别。
out_name: 输出文件名。
Returns:
输出文件路径。
"""
x0, y0 = deg2tile(bbox["west"], bbox["north"], zoom)
x1, y1 = deg2tile(bbox["east"], bbox["south"], zoom)
cols, rows = x1 - x0 + 1, y1 - y0 + 1
total = cols * rows
print(f"[{out_name}] z{zoom} 瓦片 {cols}×{rows}({total} 张),范围 x{x0}-{x1} y{y0}-{y1}")
canvas = Image.new("RGB", (cols * TILE_SIZE, rows * TILE_SIZE), "#e8e0d0")
failed = 0
for row in range(rows):
for col in range(cols):
img = fetch_tile(zoom, x0 + col, y0 + row)
if img is None:
failed += 1
draw = ImageDraw.Draw(canvas)
left = col * TILE_SIZE
top = row * TILE_SIZE
draw.rectangle(
[left, top, left + TILE_SIZE - 1, top + TILE_SIZE - 1],
fill="#d0d0d0",
)
else:
canvas.paste(img, (col * TILE_SIZE, row * TILE_SIZE))
time.sleep(0.08) # 温和限速。
if failed:
print(f"[{out_name}] 警告:{failed}/{total} 张瓦片下载失败(已用灰块占位)")
add_attribution(canvas)
out_path = OUT_DIR / out_name
canvas.save(out_path, optimize=True)
print(f"[{out_name}] 已保存 {canvas.width}×{canvas.height} → {out_path}")
return out_path
def add_attribution(canvas: Image.Image) -> None:
"""在拼接图底部叠加版权署名条。
Args:
canvas: 待标注的拼接图。
"""
bar_h = 34
draw = ImageDraw.Draw(canvas)
draw.rectangle([0, canvas.height - bar_h, canvas.width, canvas.height], fill=(255, 253, 247))
text = "底图数据 © 高德地图 | 仅作项目内部绘制参考,禁止商用分发"
font = None
for candidate in ("PingFang.ttc", "Hiragino Sans GB.ttc", "Arial Unicode.ttf"):
try:
font = ImageFont.truetype(candidate, 16)
break
except OSError:
continue
if font is None:
font = ImageFont.load_default()
draw.text((12, canvas.height - bar_h + 8), text, fill=(107, 78, 46), font=font)
def main() -> None:
"""入口:依次生成全域概览与主城区高清图。"""
OUT_DIR.mkdir(parents=True, exist_ok=True)
# 全域概览(z12):对应游戏 viewBox 的真实地理范围(西至沣河,东至兵马俑)。
stitch(
bbox={"west": 108.82, "east": 109.31, "south": 34.14, "north": 34.42},
zoom=12,
out_name="xian-reference-full-z12.png",
)
# 主城区高清(z14):城墙 / 回民街 / 大明宫 / 小雁塔 / 陕历博 / 大雁塔 / 曲江。
stitch(
bbox={"west": 108.87, "east": 109.04, "south": 34.19, "north": 34.33},
zoom=14,
out_name="xian-reference-city-z14.png",
)
if __name__ == "__main__":
main()