auto: 2026-07-26 18:55 (ESD_LAPTOP)
This commit is contained in:
@@ -309,10 +309,9 @@ def run_surface_analysis(
|
||||
(bounds_dict_for_download["y"][0] + bounds_dict_for_download["y"][1]) / 2.0,
|
||||
)
|
||||
step_started = time.monotonic()
|
||||
sheet_result = ensure_sheets(
|
||||
get_project_map_sheets_dir(project_root),
|
||||
neighbors_3x3(latlon_to_sheet5k(center_lat, center_lon)),
|
||||
)
|
||||
sheet_grid = neighbors_3x3(latlon_to_sheet5k(center_lat, center_lon))
|
||||
sheet_store = get_project_map_sheets_dir(project_root)
|
||||
sheet_result = ensure_sheets(sheet_store, sheet_grid)
|
||||
if sheet_result["failed"]:
|
||||
logger.warning("B04 수치지형도 도엽 일부 확보 실패: %s", sheet_result["failed"])
|
||||
logger.info(
|
||||
@@ -320,6 +319,24 @@ def run_surface_analysis(
|
||||
len(sheet_result["available"]),
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
|
||||
# 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 (기존 산출물 있으면 스킵)
|
||||
if sheet_result["available"] and (
|
||||
rebuild or not any(processed_dir.glob("도엽_*.geojson"))
|
||||
):
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetParser import (
|
||||
parse_and_merge_sheets,
|
||||
)
|
||||
|
||||
step_started = time.monotonic()
|
||||
merged = parse_and_merge_sheets(
|
||||
sheet_store, list(sheet_result["available"]), processed_dir
|
||||
)
|
||||
logger.info(
|
||||
"B04 도엽 레이어 병합 완료: %s (%.1fs)",
|
||||
{k: v["count"] for k, v in merged.items()},
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("B04 수치지형도 도엽 확보 실패: %s", exc)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# B04_wf1_Surface_Engine_SheetParser.py
|
||||
# 1:5,000 수치지형도 도엽 zip 파서 + 9매 병합 → 레이어별 GeoJSON 산출.
|
||||
#
|
||||
# 레이어 코드 (보유 도엽 2매 실측 확정, 2026-07-26):
|
||||
# N3L_E0020000 하천중심선(구분: 세류/소하천/지방하천), N3P_F0020000 표고점(수치),
|
||||
# N3L_F0030000 성/절토 비탈면, N3L_F0040000 옹벽·석축, N3P_E0042326 유수방향,
|
||||
# N3P_H0020000 기준점(삼각점/통합기준점, 표고), N3P_H0040000 지명(계곡·부락 등)
|
||||
# 벼랑바위·너덜바위·산산맥은 표본 도엽에 미출현 — 코드 확정 시 SHEET_LAYERS에 추가.
|
||||
#
|
||||
# 좌표계: zip 내부 PRJ는 오라벨 사례가 있어 map_sheets_index.json의 교정 crs를 사용한다.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import geopandas as gpd
|
||||
import pandas as pd
|
||||
|
||||
from .B04_wf1_Surface_Engine_SheetStore import _load_index, get_sheet_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 레이어 키 → (지오메트리 접두사, 지형지물 코드)
|
||||
SHEET_LAYERS: dict[str, tuple[str, str]] = {
|
||||
"하천중심선": ("N3L", "E0020000"),
|
||||
"표고점": ("N3P", "F0020000"),
|
||||
"성절토": ("N3L", "F0030000"),
|
||||
"옹벽석축": ("N3L", "F0040000"),
|
||||
"유수방향": ("N3P", "E0042326"),
|
||||
"기준점": ("N3P", "H0020000"),
|
||||
"지명": ("N3P", "H0040000"),
|
||||
}
|
||||
|
||||
# 산출 파일 접두사 (기존 processed/의 국가 GIS geojson과 구분)
|
||||
_OUTPUT_PREFIX = "도엽"
|
||||
|
||||
|
||||
def _read_layer_from_zip(
|
||||
zip_path: Path, prefix: str, code: str, crs: str
|
||||
) -> gpd.GeoDataFrame | None:
|
||||
"""도엽 zip에서 단일 레이어 SHP를 읽어 WGS84 GeoDataFrame으로 반환. 없으면 None."""
|
||||
member_base = f"{prefix}_{code}"
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = zf.namelist()
|
||||
wanted = {
|
||||
n
|
||||
for n in names
|
||||
if Path(n).stem.upper() == member_base
|
||||
and Path(n).suffix.lower() in (".shp", ".shx", ".dbf", ".prj", ".cpg")
|
||||
}
|
||||
if not any(n.lower().endswith(".shp") for n in wanted):
|
||||
return None
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
for n in wanted:
|
||||
(Path(td) / Path(n).name).write_bytes(zf.read(n))
|
||||
gdf = gpd.read_file(Path(td) / f"{member_base}.shp")
|
||||
|
||||
if gdf.empty:
|
||||
return None
|
||||
# 내부 PRJ 오라벨 대비: 인덱스의 교정 crs로 강제 지정
|
||||
gdf = gdf.set_crs(crs, allow_override=True)
|
||||
return gdf.to_crs("EPSG:4326")
|
||||
|
||||
|
||||
def parse_and_merge_sheets(
|
||||
store_dir: str | Path,
|
||||
sheet_nos: list[str],
|
||||
output_dir: str | Path,
|
||||
layers: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""도엽 여러 매를 레이어별로 병합해 GeoJSON으로 저장한다.
|
||||
|
||||
- 도곽 경계 중복 지물은 UFID 기준 dedup (UFID 없으면 지오메트리 WKB 기준).
|
||||
- 산출: {output_dir}/도엽_{레이어}.geojson (EPSG:4326)
|
||||
- 반환: {레이어: {"count": n, "file": 경로}} (지물 0건 레이어는 제외)
|
||||
"""
|
||||
store = Path(store_dir)
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
index = _load_index(store)["sheets"]
|
||||
target_layers = layers or list(SHEET_LAYERS)
|
||||
|
||||
result: dict[str, dict] = {}
|
||||
for layer_key in target_layers:
|
||||
if layer_key not in SHEET_LAYERS:
|
||||
logger.warning("도엽 파서: 미정의 레이어 요청 무시: %s", layer_key)
|
||||
continue
|
||||
prefix, code = SHEET_LAYERS[layer_key]
|
||||
|
||||
parts: list[gpd.GeoDataFrame] = []
|
||||
for sheet_no in sheet_nos:
|
||||
sheet_no = str(sheet_no)
|
||||
zip_path = get_sheet_path(store, sheet_no)
|
||||
entry = index.get(sheet_no)
|
||||
if zip_path is None or entry is None:
|
||||
logger.warning("도엽 파서: 영구저장소에 없는 도엽 스킵: %s", sheet_no)
|
||||
continue
|
||||
try:
|
||||
gdf = _read_layer_from_zip(zip_path, prefix, code, entry["crs"])
|
||||
except Exception as exc:
|
||||
logger.warning("도엽 파서: %s %s 읽기 실패: %s", sheet_no, layer_key, exc)
|
||||
continue
|
||||
if gdf is None:
|
||||
continue
|
||||
gdf["도엽번호"] = sheet_no
|
||||
parts.append(gdf)
|
||||
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
merged = gpd.GeoDataFrame(pd.concat(parts, ignore_index=True), crs="EPSG:4326")
|
||||
# 도곽 경계에 걸친 지물은 인접 도엽에 중복 수록됨 → UFID 우선 dedup
|
||||
if "UFID" in merged.columns and merged["UFID"].notna().any():
|
||||
merged = merged.drop_duplicates(subset="UFID", keep="first")
|
||||
else:
|
||||
merged = merged.loc[~merged.geometry.to_wkb().duplicated(keep="first")]
|
||||
|
||||
out_path = out / f"{_OUTPUT_PREFIX}_{layer_key}.geojson"
|
||||
merged.to_file(out_path, driver="GeoJSON")
|
||||
result[layer_key] = {"count": int(len(merged)), "file": str(out_path)}
|
||||
logger.info("도엽 병합 완료: %s %d건 → %s", layer_key, len(merged), out_path.name)
|
||||
|
||||
return result
|
||||
@@ -16,6 +16,11 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"])
|
||||
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
|
||||
|
||||
# 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetParser import SHEET_LAYERS # noqa: E402
|
||||
|
||||
_SHEET_GEOJSON_FILES = {f"도엽_{k}": f"도엽_{k}.geojson" for k in SHEET_LAYERS}
|
||||
|
||||
|
||||
# VWorld 메타 API
|
||||
@router.get("/{project_id}/vworld-meta", response_model=None)
|
||||
@@ -105,6 +110,7 @@ async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] |
|
||||
"수계망": "수계망_물줄기_bounds.geojson",
|
||||
"등고선": "등고선_bounds.geojson",
|
||||
"산사태": "산사태위험등급_bounds.geojson",
|
||||
**_SHEET_GEOJSON_FILES,
|
||||
}
|
||||
|
||||
filename = layer_mapping.get(layer)
|
||||
@@ -180,6 +186,7 @@ async def get_vector_tile(project_id: UUID, layer: str, z: int, x: int, y: int)
|
||||
"등고선": "등고선_bounds.geojson",
|
||||
"산사태": "산사태위험등급_bounds.geojson",
|
||||
"임도노선": "임도노선.geojson",
|
||||
**_SHEET_GEOJSON_FILES,
|
||||
}
|
||||
|
||||
filename = layer_mapping.get(layer)
|
||||
|
||||
@@ -35,6 +35,11 @@ const GIS_LAYERS = [
|
||||
"행정구역_시군구",
|
||||
"행정구역_읍면동",
|
||||
"등고선",
|
||||
"도엽_하천중심선",
|
||||
"도엽_표고점",
|
||||
"도엽_성절토",
|
||||
"도엽_옹벽석축",
|
||||
"도엽_유수방향",
|
||||
] as const;
|
||||
type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
|
||||
type GisLayer = (typeof GIS_LAYERS)[number];
|
||||
@@ -46,6 +51,11 @@ const GIS_LAYER_COLORS: Record<GisLayer, string> = {
|
||||
행정구역_시군구: "#7c3aed",
|
||||
행정구역_읍면동: "#22c55e",
|
||||
등고선: "#a16207",
|
||||
도엽_하천중심선: "#2563eb",
|
||||
도엽_표고점: "#334155",
|
||||
도엽_성절토: "#f43f5e",
|
||||
도엽_옹벽석축: "#0f766e",
|
||||
도엽_유수방향: "#0891b2",
|
||||
};
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -169,6 +179,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
행정구역_시군구: L("B04_Surface_Map_Sigungu"),
|
||||
행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"),
|
||||
등고선: L("B04_Surface_Map_Contour"),
|
||||
도엽_하천중심선: L("B04_Surface_Map_SheetStream"),
|
||||
도엽_표고점: L("B04_Surface_Map_SheetElevPoint"),
|
||||
도엽_성절토: L("B04_Surface_Map_SheetCutFill"),
|
||||
도엽_옹벽석축: L("B04_Surface_Map_SheetWall"),
|
||||
도엽_유수방향: L("B04_Surface_Map_SheetFlowDir"),
|
||||
};
|
||||
GIS_LAYERS.forEach((layer) => {
|
||||
gisButtons.append(
|
||||
@@ -269,6 +284,26 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function drawPoint(
|
||||
context: CanvasRenderingContext2D,
|
||||
coordinates: unknown,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
if (
|
||||
!Array.isArray(coordinates) ||
|
||||
typeof coordinates[0] !== "number" ||
|
||||
typeof coordinates[1] !== "number"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const [x, y] = toCanvasPoint(coordinates[0], coordinates[1], width, height);
|
||||
context.beginPath();
|
||||
context.arc(x, y, 2, 0, Math.PI * 2);
|
||||
context.fillStyle = context.strokeStyle;
|
||||
context.fill();
|
||||
}
|
||||
|
||||
function drawGeometry(
|
||||
context: CanvasRenderingContext2D,
|
||||
geometry: GeoJsonGeometry,
|
||||
@@ -277,7 +312,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
): void {
|
||||
const coordinates = geometry.coordinates;
|
||||
if (!Array.isArray(coordinates)) return;
|
||||
if (geometry.type === "LineString") {
|
||||
if (geometry.type === "Point") {
|
||||
drawPoint(context, coordinates, width, height);
|
||||
} else if (geometry.type === "MultiPoint") {
|
||||
coordinates.forEach((point) => drawPoint(context, point, width, height));
|
||||
} else if (geometry.type === "LineString") {
|
||||
drawRing(context, coordinates, width, height, false);
|
||||
} else if (geometry.type === "MultiLineString") {
|
||||
coordinates.forEach((line) => drawRing(context, line, width, height, false));
|
||||
|
||||
@@ -657,6 +657,11 @@ export const ui_locales = {
|
||||
B04_Surface_Map_Sigungu: ["시군구", "District boundary"],
|
||||
B04_Surface_Map_Eupmyeondong: ["읍면동", "Town boundary"],
|
||||
B04_Surface_Map_Contour: ["등고선", "Contour lines"],
|
||||
B04_Surface_Map_SheetStream: ["세류(하천중심선)", "Stream centerline"],
|
||||
B04_Surface_Map_SheetElevPoint: ["표고점", "Spot elevation"],
|
||||
B04_Surface_Map_SheetCutFill: ["성절토", "Cut/fill slope"],
|
||||
B04_Surface_Map_SheetWall: ["옹벽석축", "Retaining wall"],
|
||||
B04_Surface_Map_SheetFlowDir: ["유수방향", "Flow direction"],
|
||||
B04_Surface_Map_Reset: ["보기 초기화", "Reset view"],
|
||||
B04_Surface_Map_ImageAlt: ["VWorld 배경 지도", "VWorld basemap"],
|
||||
B04_Surface_Map_Empty: [
|
||||
|
||||
Reference in New Issue
Block a user