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>
This commit is contained in:
2026-05-08 10:29:08 +09:00
parent 53d8b53c2f
commit b9342f6726
92 changed files with 3413501 additions and 0 deletions

147
tile_downloader.py Normal file
View File

@@ -0,0 +1,147 @@
"""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)