Files
s-canvas/tile_downloader.py

148 lines
5.8 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.
"""XYZ 타일 서버에서 BBOX 영역 타일을 다운로드해 하나의 이미지로 합성.
지원:
- 일반 XYZ 템플릿 (`{x}/{y}/{z}`, 선택적 `{s}` 서브도메인)
- 줌 자동 하향 조정 (타일 수 상한 400)
- BBOX에 맞게 크롭 (타일 경계 ≠ 실제 BBOX) + 최종 resize
사용 예:
from tile_downloader import download_xyz_tiles
img = download_xyz_tiles(
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
37.5, 129.0, 37.6, 129.1,
zoom=17, log_fn=print,
)
"""
from __future__ import annotations
import io
import math
import random
from typing import Callable
import requests
from PIL import Image
_DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
}
_SUBDOMAINS = ("0", "1", "2", "3")
def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
"""위경도 → WMTS 타일 좌표 (좌상단 기준)."""
lat_rad = math.radians(lat)
n = 2 ** zoom
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n)
return x, y
def tile_to_latlon(x: int, y: int, zoom: int) -> tuple[float, float]:
"""타일 좌표 → 위경도 (타일 좌상단 모서리)."""
n = 2 ** zoom
lon = x / n * 360.0 - 180.0
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * y / n)))
return math.degrees(lat_rad), lon
def _auto_zoom(min_lat: float, min_lon: float, max_lat: float, max_lon: float,
start_zoom: int, max_tiles: int = 400) -> int:
"""타일 수가 max_tiles 이하가 되는 최대 zoom 반환."""
for z in range(start_zoom, 10, -1):
x_min, y_min = latlon_to_tile(max_lat, min_lon, z)
x_max, y_max = latlon_to_tile(min_lat, max_lon, z)
if (x_max - x_min + 1) * (y_max - y_min + 1) <= max_tiles:
return z
return 11 # 하한
def download_xyz_tiles(url_template: str,
min_lat: float, min_lon: float,
max_lat: float, max_lon: float,
zoom: int = 17,
final_size: int = 2048,
timeout_s: float = 10.0,
log_fn: Callable[[str], None] = print) -> Image.Image:
"""XYZ 타일 서버에서 BBOX 영역 타일을 다운로드·합성해 PIL Image 반환.
Args:
url_template: `{x}`, `{y}`, `{z}`, (선택) `{s}` 플레이스홀더 URL
min_lat/min_lon/max_lat/max_lon: WGS84 bounds
zoom: 시작 zoom (타일 수 과다 시 자동 하향)
final_size: 최종 결과 이미지 한 변 픽셀
timeout_s: per-tile HTTP timeout
log_fn: 진행 로그 callback
Returns:
PIL.Image (RGB, final_size × final_size)
Raises:
ValueError: 타일을 하나도 받지 못한 경우
"""
zoom = _auto_zoom(min_lat, min_lon, max_lat, max_lon, zoom)
x_min, y_min = latlon_to_tile(max_lat, min_lon, zoom)
x_max, y_max = latlon_to_tile(min_lat, max_lon, zoom)
cols = x_max - x_min + 1
rows = y_max - y_min + 1
log_fn(f"줌 레벨: {zoom}, 타일 그리드: {cols}x{rows} ({cols * rows}장)")
tile_size = 256
merged = Image.new("RGB", (cols * tile_size, rows * tile_size))
ok = 0
fail = 0
first_fail_logged = False
for ty in range(y_min, y_max + 1):
for tx in range(x_min, x_max + 1):
s = random.choice(_SUBDOMAINS)
url = (url_template
.replace("{x}", str(tx))
.replace("{y}", str(ty))
.replace("{z}", str(zoom))
.replace("{s}", s))
try:
resp = requests.get(url, headers=_DEFAULT_HEADERS, timeout=timeout_s)
if resp.status_code == 200 and len(resp.content) > 500:
tile_img = Image.open(io.BytesIO(resp.content)).convert("RGB")
merged.paste(tile_img,
((tx - x_min) * tile_size, (ty - y_min) * tile_size))
ok += 1
else:
fail += 1
if not first_fail_logged:
ct = resp.headers.get("Content-Type", "없음")
log_fn(f" 타일 실패 [{resp.status_code}] Content-Type: {ct}, 크기: {len(resp.content)}B")
first_fail_logged = True
except requests.exceptions.RequestException as e:
fail += 1
if not first_fail_logged:
log_fn(f" 네트워크 오류: {e}")
first_fail_logged = True
log_fn(f"타일 다운로드: 성공 {ok}장, 실패 {fail}")
if ok == 0:
raise ValueError("타일을 하나도 받지 못했습니다. 네트워크 연결을 확인하세요.")
# BBOX 크롭 (타일 경계 ≠ 실제 BBOX)
grid_lat_max, grid_lon_min = tile_to_latlon(x_min, y_min, zoom)
grid_lat_min, grid_lon_max = tile_to_latlon(x_max + 1, y_max + 1, zoom)
img_w = cols * tile_size
img_h = rows * tile_size
crop_left = int((min_lon - grid_lon_min) / (grid_lon_max - grid_lon_min) * img_w)
crop_right = int((max_lon - grid_lon_min) / (grid_lon_max - grid_lon_min) * img_w)
crop_top = int((grid_lat_max - max_lat) / (grid_lat_max - grid_lat_min) * img_h)
crop_bottom = int((grid_lat_max - min_lat) / (grid_lat_max - grid_lat_min) * img_h)
crop_left = max(0, min(crop_left, img_w - 1))
crop_right = max(crop_left + 1, min(crop_right, img_w))
crop_top = max(0, min(crop_top, img_h - 1))
crop_bottom = max(crop_top + 1, min(crop_bottom, img_h))
cropped = merged.crop((crop_left, crop_top, crop_right, crop_bottom))
return cropped.resize((final_size, final_size), Image.LANCZOS)