From 5d6d7bbb839e906650531cf6d5fe3b3eada2d10e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:18:52 +0900 Subject: [PATCH] =?UTF-8?q?perf(D03):=200=EC=B0=A8=EA=B3=A1=20=EB=AC=B4?= =?UTF-8?q?=EA=B1=B0=EC=9A=B4=20=EC=85=88=20=EC=9D=BC=EA=BE=BC=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EA=B8=89=EA=B2=BD=EC=82=AC=20=EA=B0=92=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=20=EB=B0=94=EC=9D=B4=EB=84=88=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=B9=A0=ED=95=98=EA=B8=B0=20toDataURL=20(PLAN=20616-?= =?UTF-8?q?3=20=C2=B7=20616-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0차곡 길 = 창고 선으로 바로(스레드) · 셈 필요하면 서버 본체 밖 일꾼 · 응답 = json.dumps 바로(큰 선 묶음 jsonable_encoder 건넘) - 급경사 = PNG 바이트 + 머리 X-Plan-Raster(칸 수 · 범위) · Content-Encoding identity(PNG 다시 gzip 안 함) · 음영기복 · 산사태 = JSON 그대로 - Api fetchPlanRaster 급경사만 바이너리(blob 주소 · slopeValues 가 다 읽고 놓음) - 칠하기 = canvas.toDataURL(toBlob 한가 작업 기다림 최대 1 s 없앰) Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 16 ++++++++++-- D03_PlanAnalysis/D03_PlanAnalysis_Layers.py | 6 +++++ D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 26 +++++++++++++------ .../D03_PlanAnalysis_UI_SlopeBands.ts | 14 ++++------ 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 6ebd7774b..0026c5d0c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -212,10 +212,22 @@ export const fetchPlanSheetTile = ( ): Promise => request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`); -export const fetchPlanRaster = ( +/** 그림 겹 — 급경사 = PNG 바이트 + 머리 `X-Plan-Raster`(616-4 · `image` = blob 주소 · 다 읽으면 + * `slopeValues` 가 놓음) · 그 밖 = JSON(base64 그림). */ +export const fetchPlanRaster = async ( projectId: string, kind: "slope" | "hillshade" | "landslide", -): Promise => request(`${base(projectId)}/layers/raster?kind=${kind}`); +): Promise => { + const path = `${base(projectId)}/layers/raster?kind=${kind}`; + if (kind !== "slope") return request(path); + const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { detail?: unknown }; + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + const meta = JSON.parse(response.headers.get("X-Plan-Raster") ?? "{}") as PlanRaster; + return { ...meta, image: URL.createObjectURL(await response.blob()) }; +}; /** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */ export interface PlanZones extends GeoJsonCollection { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 3cc402d0e..b943400a0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -281,6 +281,12 @@ def slope_layer( return value +def raster_bytes(value: dict[str, Any]) -> tuple[bytes, dict[str, Any]]: + """저장본 값 그림(base64 PNG 글) → (PNG 바이트, 나머지 칸) — 바이너리 전송(616-4).""" + meta = {k: v for k, v in value.items() if k != "image"} + return base64.b64decode(value["image"].split(",", 1)[1]), meta + + def lonlat_corners(box: Any, epsg: int) -> list[list[float]]: """TM 상자 → 경위도 네 귀퉁이(왼쪽 아래부터 돎) — GL 바탕 그림 자리(586).""" from common_util.common_util_crs import tm_to_lonlat diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index a9b501632..d42ce2119 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -399,8 +399,9 @@ async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response: @router.get("/{project_id}/plan/layers/raster", response_model=None) -async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any]: - """급경사 기울기 % 값 그림 + 1 % 칸 수(603) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · +async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any] | Response: + """급경사 기울기 % 값 그림 + 1 % 칸 수(603 · PNG 바이트 + 머리 `X-Plan-Raster` 616-4) · + 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · 산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본.""" if kind == "landslide": root, epsg, box = await _layer_root(project_id) @@ -422,6 +423,10 @@ async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, ) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error + if kind == "slope": + png, meta = layers.raster_bytes(value) + head = {"X-Plan-Raster": json.dumps({"kind": kind, **meta}), "Content-Encoding": "identity"} + return Response(png, media_type="image/png", headers=head) # PNG = 다시 압축 안 함 return {"status": "success", "kind": kind, **value} @@ -479,18 +484,23 @@ async def plan_layer_zero_order( project_id: UUID, min_m2: float = zero_order.ZERO_ORDER_MIN_M2, max_m2: float = zero_order.ZERO_ORDER_MAX_M2, -) -> dict[str, Any]: - """0차곡 선(지형 추정 · 위 집수 `min_m2` ~ `max_m2`) — 격자 지문 · 임계 같으면 저장본.""" +) -> Response: + """0차곡 선(지형 추정 · 위 집수 `min_m2` ~ `max_m2`) — 창고 도엽 선 · 다른 임계는 저장본 · + 창고에 없어 셈이 필요하면 서버 본체 밖 일꾼(616-3 · 다른 겹 요청을 안 막음).""" if not 0 < min_m2 < max_m2: raise HTTPException(status_code=400, detail="0 < min_m2 < max_m2 여야 합니다.") root, epsg, _ = await _layer_root(project_id) + args = (root, terrain.models_dir_of(root), epsg, min_m2, max_m2) + pool = zero_order.heavy_pool() try: - value = await asyncio.to_thread( - zero_order.zero_order_layer, root, terrain.models_dir_of(root), epsg, min_m2, max_m2 - ) + value = await asyncio.to_thread(zero_order.zero_order_layer, *args, False) + if value is None: + loop = asyncio.get_running_loop() + value = await loop.run_in_executor(pool, zero_order.zero_order_layer, *args) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error - return {"status": "success", **value} + body = json.dumps({"status": "success", **value}, ensure_ascii=False) # 큰 선 묶음 = 바로 글로 + return Response(body, media_type="application/json") async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[float, float]]: diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts index fb0cb92f1..9c7b0314c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts @@ -35,17 +35,19 @@ export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY)) export const writeSlopeBands = (bands: SlopeBand[]): void => writeByKey(PREF_KEY, JSON.stringify(bands)); -/** 값 그림 → 칸 값(빨강 자리 = 회색 값). */ +/** 값 그림 → 칸 값(빨강 자리 = 회색 값) · 받은 blob 주소는 다 읽었으니 놓음(616-4). */ export function slopeValues(image: HTMLImageElement): ImageData { const canvas = document.createElement("canvas"); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const context = canvas.getContext("2d", { willReadFrequently: true })!; context.drawImage(image, 0, 0); + if (image.src.startsWith("blob:")) URL.revokeObjectURL(image.src); return context.getImageData(0, 0, canvas.width, canvas.height); } -/** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `URL.revokeObjectURL`). */ +/** 범위대로 칠한 그림(주소 = data URL · toBlob 의 한가 작업 기다림 최대 1 s 없앰 616-4 · + * 부르는 쪽 `URL.revokeObjectURL` 은 그대로 둬도 됨). */ export async function paintSlope( values: ImageData, bands: SlopeBand[], @@ -65,13 +67,7 @@ export async function paintSlope( canvas.width = values.width; canvas.height = values.height; canvas.getContext("2d")!.putImageData(out, 0, 0); - const blob = await new Promise((done, fail) => - canvas.toBlob( - (got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))), - "image/png", - ), - ); - const url = URL.createObjectURL(blob); + const url = canvas.toDataURL("image/png"); return { url, image: await loadImage(url) }; }