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>
148 lines
5.8 KiB
Python
148 lines
5.8 KiB
Python
"""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)
|