feat(B04): 배경 지도 확보 범위를 기준 도엽 도곽과 동일하게

사용자 지시(2026-08-01): 위성사진은 수치지형도 도엽과 같은 눈금으로 확보한다.
계획노선이 걸치는 기준 도엽만 받고(1매 또는 2~3매), 주변 도엽은 받지 않는다.

- sheets_for_points(): 기준 좌표가 속한 도엽번호만 반환(주변 확장 없음)
- satellite_extent(): 기준 도엽들의 도곽 합집합을 프로젝트 좌표계로 반환.
  도엽을 못 구하면 라이다∪계획노선 범위로 폴백
- 다운로더: 주변 확장 로직 제거. 요청 범위를 덮는 타일만 받되, 한 변 타일 수가
  한도를 넘으면 zoom을 낮춘다(도엽 1매를 zoom 18로 받으면 한 변 19타일 = 4,864px)
- 국가 GIS 벡터는 종전대로 라이다∪계획노선 범위 사용
- 표본 실측: 기준 도엽 1매(37816093) -> 10x13 타일, 2438x3169m, 0.95m/px, 15.1MB,
  실패 타일 0개 (이전 731x853m)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 12:47:20 +09:00
co-authored by Claude Opus 5
parent a17b95f621
commit 5da0fb1cd6
5 changed files with 90 additions and 43 deletions
+7 -4
View File
@@ -235,6 +235,7 @@ def run_surface_analysis(
from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import (
download_extent,
map_meta_covers,
satellite_extent,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors
from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import (
@@ -250,9 +251,11 @@ def run_surface_analysis(
project_epsg = "EPSG:5186"
if prj_path.exists():
project_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
# 배경 지도는 라이다 범위와 계획노선을 합친 범위로 받는다 — 노선이 라이다 범위를
# 벗어나도 배경이 잘리지 않게 한다(2026-08-01 사용자 지시).
# 국가 GIS 벡터는 라이다∪계획노선 범위로 받는다.
bounds_dict_for_download = download_extent(project_root, las_bounds_dict, project_epsg)
# 배경 지도(위성·하이브리드·백지도)는 계획노선이 걸치는 기준 도엽의 도곽 범위로 받는다
# — 수치지형도 도엽과 같은 눈금. 주변 도엽은 받지 않는다(2026-08-01 사용자 지시).
map_bounds_for_download = satellite_extent(project_root, las_bounds_dict, project_epsg)
# VWorld 지도 및 GIS 데이터 저장 위치는 B04_wf1_Surface/processed에 보관.
layers = [
@@ -264,13 +267,13 @@ def run_surface_analysis(
meta_path = processed_dir / f"vworld_{item['layer'].lower()}_meta.json"
# 파일이 있어도 계획노선·여유 셀이 바뀌어 범위를 못 덮으면 다시 받는다.
# (B03 업로드가 부르는 경로는 rebuild=False라, 존재 여부만 보면 영영 갱신되지 않는다.)
if not rebuild and map_meta_covers(meta_path, bounds_dict_for_download):
if not rebuild and map_meta_covers(meta_path, map_bounds_for_download):
continue
try:
step_started = time.monotonic()
download_vworld_satellite_map(
prj_path,
bounds_dict_for_download,
map_bounds_for_download,
processed_dir,
layer_name=item["layer"],
ext=item["ext"],
@@ -82,6 +82,44 @@ def download_extent(
}
def satellite_extent(
project_root: Path,
las_bounds: dict[str, list[float]],
target_epsg: str,
) -> dict[str, list[float]]:
"""배경 지도를 확보할 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽 범위.
수치지형도 도엽과 같은 눈금으로 맞춘다 — 도엽 1매면 1매 크기, 2~3매에 걸치면 그만큼.
주변 도엽은 받지 않는다(2026-08-01 사용자 지시).
도엽 번호를 얻지 못하면 라이다∪계획노선 범위로 되돌아간다.
"""
from pyproj import Transformer
from .B04_wf1_Surface_Engine_MapSheet import sheet5k_to_bounds, sheets_for_points
points = sheet_reference_points_wgs84(project_root, las_bounds, target_epsg)
sheets = sheets_for_points(points)
if not sheets:
return download_extent(project_root, las_bounds, target_epsg)
lon_min = lat_min = float("inf")
lon_max = lat_max = float("-inf")
for sheet_no in sheets:
s_lon_min, s_lat_min, s_lon_max, s_lat_max = sheet5k_to_bounds(sheet_no)
lon_min, lon_max = min(lon_min, s_lon_min), max(lon_max, s_lon_max)
lat_min, lat_max = min(lat_min, s_lat_min), max(lat_max, s_lat_max)
to_target = Transformer.from_crs("EPSG:4326", target_epsg, always_xy=True)
x0, y0 = to_target.transform(lon_min, lat_min)
x1, y1 = to_target.transform(lon_max, lat_max)
logger.info("B04 배경 지도 기준 도엽 %d매: %s", len(sheets), ", ".join(sheets))
return {
"x": [min(x0, x1), max(x0, x1)],
"y": [min(y0, y1), max(y0, y1)],
"z": list(las_bounds.get("z", [0.0, 0.0])),
}
def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool:
"""저장된 배경 지도가 필요한 범위를 이미 덮고 있는가.
@@ -90,6 +90,22 @@ def neighbors_3x3(sheet_no: str) -> list[str]:
return result
def sheets_for_points(points: list[tuple[float, float]]) -> list[str]:
"""기준 좌표들이 속한 도엽번호만(주변 도엽 없음, 중복 제거).
계획노선 시점·종점이 같은 도엽이면 1매, 걸치면 2~3매가 된다.
배경 지도(위성사진)는 이 도엽 범위만 확보한다(2026-08-01 사용자 지시).
"""
ordered: list[str] = []
seen: set[str] = set()
for lat, lon in points:
sheet_no = latlon_to_sheet5k(lat, lon)
if sheet_no not in seen:
seen.add(sheet_no)
ordered.append(sheet_no)
return ordered
def neighbors_for_points(points: list[tuple[float, float]]) -> list[str]:
"""기준 좌표들이 속한 도엽 + 각각의 주변 8매를 합친 목록(중복 제거, 순서 유지).
@@ -19,10 +19,14 @@ try:
VWORLD_API_KEY = getattr(
config_system, "VWORLD_API_KEY", "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
)
SURFACE_MAP_NEIGHBOR_RINGS = getattr(config_system, "SURFACE_MAP_NEIGHBOR_RINGS", 1)
SURFACE_MAP_MAX_TILES_PER_SIDE = getattr(config_system, "SURFACE_MAP_MAX_TILES_PER_SIDE", 12)
SURFACE_MAP_MAX_ZOOM = getattr(config_system, "SURFACE_MAP_MAX_ZOOM", 18)
SURFACE_MAP_MIN_ZOOM = getattr(config_system, "SURFACE_MAP_MIN_ZOOM", 14)
except ImportError:
VWORLD_API_KEY = "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
SURFACE_MAP_NEIGHBOR_RINGS = 1
SURFACE_MAP_MAX_TILES_PER_SIDE = 12
SURFACE_MAP_MAX_ZOOM = 18
SURFACE_MAP_MIN_ZOOM = 14
def get_epsg_from_prj(prj_content: str) -> str:
@@ -98,38 +102,22 @@ def download_vworld_satellite_map(
lon_min, lat_min = transformer.transform(x_min, y_min)
lon_max, lat_max = transformer.transform(x_max, y_max)
# 3. 지도 타일 크기 결정 (ZOOM 18 초고해상도 적용)
zoom = 18
# 영역을 포괄하는 좌상단 타일, 우하단 타일 인덱스 산출
x1, y1 = latlon_to_tile(lat_max, lon_min, zoom)
x2, y2 = latlon_to_tile(lat_min, lon_max, zoom)
# 기준 박스 = 계획노선·기준 좌표를 덮는 한 장. 그 주위로 같은 크기의 박스를 더 받는다.
# rings=1이면 주변 8장이 붙어 3×3 배치가 된다(2026-08-01 사용자 지시).
base_x_start, base_x_end = min(x1, x2), max(x1, x2)
base_y_start, base_y_end = min(y1, y2), max(y1, y2)
box_w = base_x_end - base_x_start + 1
box_h = base_y_end - base_y_start + 1
rings = SURFACE_MAP_NEIGHBOR_RINGS
x_start = base_x_start - box_w * rings
x_end = base_x_end + box_w * rings
y_start = base_y_start - box_h * rings
y_end = base_y_end + box_h * rings
# 너무 많은 타일을 내려받아 IP가 막히는 것을 막는다. 한쪽만 자르면 기준 박스가 화면
# 가장자리로 밀리므로 양쪽에서 균등하게 줄인다.
def _clip(start: int, end: int, limit: int = 15) -> tuple[int, int]:
count = end - start + 1
if count <= limit:
return start, end
over = count - limit
return start + over // 2, end - (over - over // 2)
x_start, x_end = _clip(x_start, x_end)
y_start, y_end = _clip(y_start, y_end)
tile_w = x_end - x_start + 1
tile_h = y_end - y_start + 1
# 3. 요청 범위를 그대로 덮는 타일 범위를 잡는다(주변으로 넓히지 않는다).
# 한 변 타일 수가 한도를 넘으면 zoom을 한 단계씩 낮춘다 — 도엽 1매를 zoom 18로 받으면
# 한 변이 19타일(4,864px)이라 파일이 지나치게 커진다(2026-08-01 사용자 지시).
zoom = SURFACE_MAP_MAX_ZOOM
while True:
x1, y1 = latlon_to_tile(lat_max, lon_min, zoom)
x2, y2 = latlon_to_tile(lat_min, lon_max, zoom)
x_start, x_end = min(x1, x2), max(x1, x2)
y_start, y_end = min(y1, y2), max(y1, y2)
tile_w = x_end - x_start + 1
tile_h = y_end - y_start + 1
if zoom <= SURFACE_MAP_MIN_ZOOM:
break
if max(tile_w, tile_h) <= SURFACE_MAP_MAX_TILES_PER_SIDE:
break
zoom -= 1
# 4. 개별 타일 다운로드 및 이미지 병합
map_img = Image.new("RGBA", (tile_w * 256, tile_h * 256))
+7 -5
View File
@@ -516,12 +516,14 @@ STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sto
MAP_SHEETS_DIRNAME = "map_sheets"
MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json"
# 배경 지도(위성·하이브리드·백지도) 확보 범위 = 기준 박스 + 주변 박스.
# 배경 지도(위성·하이브리드·백지도) 확보 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽.
# 1:5,000 도엽 1매는 약 2.2km × 2.3km다. 주변 도엽은 받지 않는다(2026-08-01 사용자 지시).
#
# 기준 박스 = 계획노선과 기준 좌표를 덮는 한 장. 그 주위로 같은 크기의 박스를 몇 겹 더 받는다.
# 1이면 주변 8장을 더해 3×3 배치가 된다(2026-08-01 사용자 지시).
# 해상도(zoom)는 브이월드 기본 스케일 그대로 두고 범위만 넓힌다 — 화소를 키우는 것이 아니다.
SURFACE_MAP_NEIGHBOR_RINGS = 1
# 도엽 1매를 zoom 18로 받으면 한 변이 19타일(4,864px)이라 파일이 지나치게 커진다.
# 아래 한도 안에 들어오는 가장 선명한 zoom을 자동으로 고른다(브이월드 타일 눈금 그대로).
SURFACE_MAP_MAX_TILES_PER_SIDE = 16
SURFACE_MAP_MAX_ZOOM = 18
SURFACE_MAP_MIN_ZOOM = 14
# 브이월드 지도서비스 도엽 자동 다운로드 — 세션 만료 시 id/pw로 자동 재로그인 (.env)
VWORLD_LOGIN_ID = os.getenv("VWORLD_LOGIN_ID", "")