perf(전처리 화면): 343-3 B04 다시 열기 받는 양 — 보관함 저장 실패 고침 · 점군 고정 표본 이진 · 꺼진 층은 켤 때 · 남은 층 ETag · 등고선 주소 한 벌
- b_asset_cache — 쓰기 끝 = 트랜잭션 완료(용량 초과 취소 잡음) · 실패 한 줄 경고(까닭당 한 번) · onblocked · onversionchange · 열기 실패 null 안 기억 · 지우기는 색인 열쇠 · 앞머리 범위(값 안 읽음)
- 점군 미리보기 — 전처리 때 고정 표본 이진(preview_points_{필터}.bin · 원천 지문 같으면 건너뜀) · GET point-cloud/binary(ETag) · 화면은 fetchCachedBytes(IndexedDB) + decodePointPreview(py 짝)
- B04 지도 — 켜진 층만 처음에 받음 · 꺼진 층은 켤 때 · 같은 층 겹쳐 받기 막음
- geojson(지적도 · 행정구역 · 등고선) 파일 그대로 cached_file_response(ETag) · 등고선 단순화본은 원본이 새로우면 다시 만듦
- 등고선 주소 contourAssetUrl 하나(B04 · 미리 받기) — B05 두 곳은 sub5 몫
- 시험 test_343_3_browser_cache 13건 + helper_343_3_asset_cache.cjs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
@@ -8,8 +8,8 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B04_PreProcess.B04_PreProcess_Engine import (
|
||||
@@ -22,6 +22,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import (
|
||||
project_epsg_from_prj,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
|
||||
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview, sample_points
|
||||
from B04_PreProcess.B04_PreProcess_Repository import (
|
||||
clear_confirmed_surface_models,
|
||||
get_input_file,
|
||||
@@ -63,6 +64,7 @@ from B04_PreProcess.B04_PreProcess_Schema import (
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection
|
||||
from common_util.common_util_auth import require_system_admin
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
get_surface_confirmation_params,
|
||||
@@ -77,7 +79,6 @@ from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
|
||||
POINT_CLOUD_SAMPLE_LIMIT = 500_000
|
||||
|
||||
# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내
|
||||
# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04).
|
||||
@@ -298,43 +299,70 @@ async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListRespo
|
||||
)
|
||||
|
||||
|
||||
async def _point_cloud_source(project_id: UUID, filter: str | None) -> Path | JSONResponse:
|
||||
"""미리보기 원천 npz — 원본(structured) 또는 필터 지면점 저장본(낡으면 다시 만듦)."""
|
||||
async with get_db_pool().acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
|
||||
if not structured_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
|
||||
)
|
||||
if filter is None:
|
||||
return structured_path
|
||||
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
|
||||
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
|
||||
if filter not in available_filters():
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
|
||||
)
|
||||
source_path = structured_path.parent / f"ground_points_{filter}.npz"
|
||||
cache_is_current = False
|
||||
if source_path.is_file():
|
||||
with np.load(source_path) as cached:
|
||||
cache_is_current = (
|
||||
"cache_version" in cached
|
||||
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
|
||||
)
|
||||
if not cache_is_current:
|
||||
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
|
||||
return source_path
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/point-cloud/binary", response_model=None)
|
||||
async def get_surface_point_cloud_binary(
|
||||
project_id: UUID, request: Request, filter: str | None = None
|
||||
) -> Response:
|
||||
"""점군 미리보기 고정 표본 이진(ETag) — 전처리 때 만든 파일 · 없거나 낡으면 한 번 만듦."""
|
||||
try:
|
||||
source_path = await _point_cloud_source(project_id, filter)
|
||||
if isinstance(source_path, JSONResponse):
|
||||
return source_path
|
||||
target = await asyncio.to_thread(ensure_point_preview, source_path, filter)
|
||||
return cached_file_response(request, target, "application/octet-stream")
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B04 포인트클라우드 이진 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "포인트클라우드 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse)
|
||||
async def get_surface_point_cloud(
|
||||
project_id: UUID,
|
||||
filter: str | None = None,
|
||||
) -> SurfacePointCloudSampleResponse | JSONResponse:
|
||||
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다."""
|
||||
pool = get_db_pool()
|
||||
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다(JSON · 화면은 이진 길을 씀)."""
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
|
||||
if not structured_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
|
||||
)
|
||||
|
||||
source_path = structured_path
|
||||
if filter is not None:
|
||||
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
|
||||
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
|
||||
if filter not in available_filters():
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
|
||||
)
|
||||
source_path = structured_path.parent / f"ground_points_{filter}.npz"
|
||||
cache_is_current = False
|
||||
if source_path.is_file():
|
||||
with np.load(source_path) as cached:
|
||||
cache_is_current = (
|
||||
"cache_version" in cached
|
||||
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
|
||||
)
|
||||
if not cache_is_current:
|
||||
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
|
||||
source_path = await _point_cloud_source(project_id, filter)
|
||||
if isinstance(source_path, JSONResponse):
|
||||
return source_path
|
||||
|
||||
with np.load(source_path) as structured:
|
||||
xyz = np.asarray(structured["xyz"], dtype=np.float32)
|
||||
@@ -342,22 +370,8 @@ async def get_surface_point_cloud(
|
||||
point_count = (
|
||||
int(structured["point_count"]) if "point_count" in structured else int(len(xyz))
|
||||
)
|
||||
source_count = int(len(xyz))
|
||||
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||
rng = np.random.default_rng(20260710)
|
||||
indexes = rng.choice(source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
|
||||
sample = xyz[indexes]
|
||||
else:
|
||||
sample = xyz
|
||||
|
||||
rgb_sample = None
|
||||
if "rgb" in structured:
|
||||
rgb_arr = np.asarray(structured["rgb"])
|
||||
if rgb_arr.ndim > 0 and len(rgb_arr) == source_count:
|
||||
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||
rgb_sample = rgb_arr[indexes]
|
||||
else:
|
||||
rgb_sample = rgb_arr
|
||||
rgb = np.asarray(structured["rgb"]) if "rgb" in structured else None
|
||||
sample, rgb_sample = sample_points(xyz, rgb)
|
||||
|
||||
return SurfacePointCloudSampleResponse(
|
||||
project_id=str(project_id),
|
||||
|
||||
Reference in New Issue
Block a user