Files
s-canvas/tile_downloader.py
HYUNJUNGLEE b9342f6726 Import S-CANVAS source + iter=1~7 lint cleanup
S-CANVAS (Saman Corp.) — DXF + DEM + AI 기반 3D 조감도 생성 엔진.
~24k LOC Python (scanvas_maker.py 7072 LOC GUI + 구조물 파서/빌더 다수).

이 커밋은 7-iter cleanup이 적용된 상태로 import:
- F821 8 + B023 6: 비동기 lambda + except/loop 변수 캡처 NameError
  (Py3.13에서 reproduce 확인된 진짜 버그)
- RUF012 4 + RUF013 1: ClassVar / implicit Optional 명시화
- F811/B905/B904/F401/F841/W293/F541/UP/SIM/RUF/PLR 700+ cleanup/modernization

신규 파일:
- ruff.toml: target=py313, Korean unicode/저자 스타일/도메인 복잡도 무력화
- requirements-py313.txt: pyproj>=3.7, scipy>=1.14, numpy>=2.0.2 (Py3.13 wheel)
- .gitignore: gcp-key.json, 캐시, 백업, 생성 이미지 제외

검증: ruff 0 errors, py_compile 0 errors, import 33/33 OK on Py3.13.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:29:08 +09:00

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 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)