diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index 0139e66d..83bcd7b1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -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"], diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py index de370354..ca95774f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py @@ -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: """저장된 배경 지도가 필요한 범위를 이미 덮고 있는가. diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py index 892b25b1..aae948ad 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py @@ -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매를 합친 목록(중복 제거, 순서 유지). diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py index 23f98c64..2746cb87 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py @@ -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)) diff --git a/config/config_system.py b/config/config_system.py index a2e356e1..90a58a4a 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -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", "")