"""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 collections.abc 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)