perf(전처리): 343-1 같은 지형 파일 다시 올림 = 전처리 결과 살림 · 다시 돌 때 점군 안 풂 · 도엽 병합 · 국가 GIS · 타일 기억 열쇠
- B03 재올림 — 지형 파일이 source_identity.json 과 같으면 B04 폴더 · 지표면 DB 행 남기고 단계 표시만 되돌림 - 확인 창 「처음부터 다시」 따로 고름(fresh_start) · 409 응답에 preprocess_kept - B04 다시 돎 — 점 수 · 평균 Z 를 structured_stats.json 에 · xyz 는 쓸 때만 풂 - 도엽 병합 열쇠 = 도엽 번호 목록 · 국가 GIS 열쇠 = 범위 · 좌표계 - 지도 타일 메모리 기억 열쇠에 mtime · 크기 - 시험 test_343_1_preprocess_keep 13건 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
@@ -57,11 +57,14 @@ export interface UploadStatusResponse {
|
||||
*/
|
||||
export class ReplaceOutputsConfirmRequired extends Error {
|
||||
readonly targets: string[];
|
||||
/** 지형 파일이 그대로라 전처리 결과를 살리는지 — 「처음부터 다시」를 따로 묻는다. */
|
||||
readonly preprocessKept: boolean;
|
||||
|
||||
constructor(message: string, targets: string[]) {
|
||||
constructor(message: string, targets: string[], preprocessKept = false) {
|
||||
super(message);
|
||||
this.name = "ReplaceOutputsConfirmRequired";
|
||||
this.targets = targets;
|
||||
this.preprocessKept = preprocessKept;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +73,14 @@ async function readJsonOrThrow<T>(response: Response): Promise<T> {
|
||||
message?: string;
|
||||
confirm?: string;
|
||||
targets?: string[];
|
||||
preprocess_kept?: boolean;
|
||||
};
|
||||
if (!response.ok) {
|
||||
if (response.status === 409 && payload.confirm === "replace_outputs") {
|
||||
throw new ReplaceOutputsConfirmRequired(
|
||||
payload.message ?? "이미 만들어 둔 결과가 지워집니다.",
|
||||
payload.targets ?? [],
|
||||
payload.preprocess_kept === true,
|
||||
);
|
||||
}
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
@@ -115,6 +120,7 @@ export async function createUploadSession(
|
||||
completeUpload = false,
|
||||
lasFree = false,
|
||||
confirmReplace = false,
|
||||
freshStart = false,
|
||||
): Promise<ChunkSessionCreateResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
|
||||
method: "POST",
|
||||
@@ -128,6 +134,7 @@ export async function createUploadSession(
|
||||
complete_upload: completeUpload,
|
||||
las_free: lasFree,
|
||||
confirm_replace: confirmReplace,
|
||||
fresh_start: freshStart,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
|
||||
@@ -160,6 +167,7 @@ export async function finalizeUploadSession(
|
||||
fingerprint?: string | null,
|
||||
lasFree = false,
|
||||
confirmReplace = false,
|
||||
freshStart = false,
|
||||
): Promise<FileUploadResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
|
||||
method: "POST",
|
||||
@@ -172,6 +180,7 @@ export async function finalizeUploadSession(
|
||||
fingerprint: fingerprint ?? null,
|
||||
las_free: lasFree,
|
||||
confirm_replace: confirmReplace,
|
||||
fresh_start: freshStart,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
|
||||
@@ -113,6 +113,7 @@ async def create_project_upload_session(
|
||||
project_id,
|
||||
payload.las_free,
|
||||
payload.confirm_replace,
|
||||
payload.fresh_start,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -309,6 +310,7 @@ async def finalize_project_upload(
|
||||
project_id,
|
||||
payload.las_free,
|
||||
payload.confirm_replace,
|
||||
payload.fresh_start,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
|
||||
@@ -29,7 +29,7 @@ from B03_FileInput.B03_FileInput_Schema import (
|
||||
)
|
||||
from common_util.common_util_initial_snapshot import clear_designing, discard_initial_snapshot
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_project_reset import purge_project_outputs
|
||||
from common_util.common_util_project_reset import PREPROCESS_STAGE_DIR, purge_project_outputs
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import (
|
||||
complete_stage,
|
||||
@@ -61,8 +61,10 @@ class OutputsWouldBeDiscarded(Exception):
|
||||
로 다시 들어온다. 자동 절차는 이 예외를 만들지 않는다 — 부를 때 확인을 켜 준다.
|
||||
"""
|
||||
|
||||
def __init__(self, targets: list[str]) -> None:
|
||||
def __init__(self, targets: list[str], preprocess_kept: bool = False) -> None:
|
||||
self.targets = targets
|
||||
# 지형 파일이 그대로라 전처리 결과를 살리는지 — 화면이 「처음부터 다시」를 따로 묻는다.
|
||||
self.preprocess_kept = preprocess_kept
|
||||
super().__init__("설계 산출물이 있어 확인이 필요합니다.")
|
||||
|
||||
|
||||
@@ -78,14 +80,17 @@ def _confirm_replace_response(exc: "OutputsWouldBeDiscarded") -> JSONResponse:
|
||||
"아래가 지워지고 **되돌릴 수 없습니다**: " + " · ".join(exc.targets)
|
||||
),
|
||||
"targets": exc.targets,
|
||||
"preprocess_kept": exc.preprocess_kept,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def describe_existing_outputs(project_root: Path) -> list[str]:
|
||||
def describe_existing_outputs(project_root: Path, keep_preprocess: bool = False) -> list[str]:
|
||||
"""새 자료를 받으면 **지워질 것**의 이름을 사람 말로 늘어놓는다. 없으면 빈 목록."""
|
||||
targets: list[str] = []
|
||||
for stage, label in _OUTPUT_STAGE_LABELS:
|
||||
if keep_preprocess and stage == PREPROCESS_STAGE_DIR:
|
||||
continue
|
||||
stage_root = project_root / stage
|
||||
if not stage_root.is_dir():
|
||||
continue
|
||||
@@ -201,16 +206,37 @@ async def _already_uploaded(
|
||||
)
|
||||
|
||||
|
||||
def preprocess_input_unchanged(project_root: Path, terrain_paths: list[Path]) -> bool:
|
||||
"""지형 파일이 마지막 전처리 때와 같은지 — `processed/source_identity.json` 과 대조.
|
||||
|
||||
같은 지문(크기 + 앞·가운데·끝) 파일은 전송을 건너뛰어 파일이 그대로 남는다 —
|
||||
이름·크기·수정시각이 전처리가 적어 둔 값과 같으면 같은 입력이다.
|
||||
"""
|
||||
if not terrain_paths:
|
||||
return False
|
||||
from B04_PreProcess.B04_PreProcess_Engine import _source_identity
|
||||
|
||||
identity_path = project_root / PREPROCESS_STAGE_DIR / "processed" / "source_identity.json"
|
||||
try:
|
||||
stored = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||
return stored == _source_identity(terrain_paths)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
async def _complete_file_input_if_ready(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
las_free: bool = False,
|
||||
confirm_replace: bool = True,
|
||||
fresh_start: bool = False,
|
||||
) -> int:
|
||||
"""새 자료로 갈아 끼운다. `confirm_replace=False` 면 지울 것이 있을 때 멈추고 묻는다.
|
||||
|
||||
기본이 `True` 인 까닭 — 자동 절차(체인·스크립트)는 물음에 걸리면 안 된다.
|
||||
사람이 올리는 갈래(라우터)만 `False` 로 불러 확인을 받는다(2026-09-08 사용자 지시).
|
||||
지형 파일이 그대로면 전처리 결과는 살리고 단계 표시만 되돌린다 —
|
||||
`fresh_start=True`(확인 창의 「처음부터 다시」)면 전처리까지 지운다.
|
||||
"""
|
||||
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
|
||||
connection, project_id
|
||||
@@ -234,14 +260,15 @@ async def _complete_file_input_if_ready(
|
||||
gap_message = merge_gap_error(terrain_paths)
|
||||
if gap_message:
|
||||
raise ValueError(gap_message)
|
||||
keep_preprocess = not fresh_start and preprocess_input_unchanged(project_root, terrain_paths)
|
||||
# ⚠ 여기서부터는 되돌릴 수 없다 — 아래 셋이 파일까지 지운다. 지울 것이 있으면 먼저 묻는다.
|
||||
if not confirm_replace:
|
||||
targets = describe_existing_outputs(project_root)
|
||||
targets = describe_existing_outputs(project_root, keep_preprocess)
|
||||
if targets:
|
||||
raise OutputsWouldBeDiscarded(targets)
|
||||
raise OutputsWouldBeDiscarded(targets, keep_preprocess)
|
||||
clear_designing(project_root)
|
||||
discard_initial_snapshot(project_root)
|
||||
await purge_project_outputs(connection, str(project_id), project_root)
|
||||
await purge_project_outputs(connection, str(project_id), project_root, keep_preprocess)
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await reset_stages_after_input_change(cursor, str(project_id))
|
||||
await complete_stage(cursor, str(project_id), 0)
|
||||
|
||||
@@ -65,6 +65,8 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
|
||||
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
|
||||
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
|
||||
confirm_replace: bool = False
|
||||
# 확인 창의 「처음부터 다시」 — 지형 파일이 그대로여도 전처리 결과까지 지운다.
|
||||
fresh_start: bool = False
|
||||
|
||||
|
||||
class ChunkSessionCreateResponse(BaseModel):
|
||||
@@ -109,6 +111,8 @@ class UploadFinalizeRequest(BaseModel):
|
||||
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
|
||||
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
|
||||
confirm_replace: bool = False
|
||||
# 확인 창의 「처음부터 다시」 — 지형 파일이 그대로여도 전처리 결과까지 지운다.
|
||||
fresh_start: bool = False
|
||||
|
||||
|
||||
class UploadStatusResponse(BaseModel):
|
||||
|
||||
@@ -83,9 +83,11 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi
|
||||
* 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시).
|
||||
* 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다.
|
||||
*/
|
||||
async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>): Promise<T> {
|
||||
async function askReplaceOutputs<T>(
|
||||
run: (confirmReplace: boolean, freshStart: boolean) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await run(false);
|
||||
return await run(false, false);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error;
|
||||
const detail = error.targets.length
|
||||
@@ -99,7 +101,16 @@ async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>
|
||||
되돌릴 수 없습니다. 계속할까요?`,
|
||||
);
|
||||
if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다.");
|
||||
return await run(true);
|
||||
// 지형 파일이 그대로면 전처리 결과는 살린다 — 「처음부터 다시」는 여기서 따로 고른다.
|
||||
const freshStart =
|
||||
error.preprocessKept &&
|
||||
window.confirm(
|
||||
`같은 지형 파일이라 전처리 결과(지표면)는 살려 다시 씁니다.
|
||||
|
||||
전처리까지 처음부터 다시 할까요?
|
||||
[확인] 처음부터 다시 · [취소] 전처리 결과 살림`,
|
||||
);
|
||||
return await run(true, freshStart);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +134,7 @@ export async function uploadOneFile(
|
||||
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
|
||||
let session = state.uploadSessionId;
|
||||
if (!session) {
|
||||
const created = await askReplaceOutputs((confirmReplace) =>
|
||||
const created = await askReplaceOutputs((confirmReplace, freshStart) =>
|
||||
createUploadSession(
|
||||
projectId,
|
||||
file,
|
||||
@@ -132,6 +143,7 @@ export async function uploadOneFile(
|
||||
completeUpload,
|
||||
lasFree,
|
||||
confirmReplace,
|
||||
freshStart,
|
||||
),
|
||||
);
|
||||
if (created.already_uploaded) {
|
||||
@@ -184,7 +196,7 @@ export async function uploadOneFile(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await askReplaceOutputs((confirmReplace) =>
|
||||
const response = await askReplaceOutputs((confirmReplace, freshStart) =>
|
||||
finalizeUploadSession(
|
||||
projectId,
|
||||
session,
|
||||
@@ -193,6 +205,7 @@ export async function uploadOneFile(
|
||||
fingerprint,
|
||||
lasFree,
|
||||
confirmReplace,
|
||||
freshStart,
|
||||
),
|
||||
);
|
||||
localStorage.removeItem(storageKey);
|
||||
|
||||
@@ -55,6 +55,87 @@ def _source_identity(las_paths: list[Path]) -> dict[str, Any]:
|
||||
return files[0] if len(files) == 1 else {"files": files}
|
||||
|
||||
|
||||
class _LazyXyz:
|
||||
"""structured.npz 의 xyz — 처음 값을 쓸 때만 푼다. 길이는 저장해 둔 점 수로 답한다."""
|
||||
|
||||
def __init__(self, structured: Any, count: int, array: np.ndarray | None = None) -> None:
|
||||
self._structured = structured
|
||||
self._count = count
|
||||
self._array = array
|
||||
|
||||
def _load(self) -> np.ndarray:
|
||||
if self._array is None:
|
||||
self._array = self._structured["xyz"]
|
||||
return self._array
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self._count
|
||||
|
||||
def __getitem__(self, key: Any) -> Any:
|
||||
return self._load()[key]
|
||||
|
||||
def __array__(self, dtype: Any = None, copy: Any = None) -> np.ndarray:
|
||||
return np.asarray(self._load(), dtype=dtype)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._load(), name)
|
||||
|
||||
|
||||
class _LazyStructured(dict):
|
||||
"""구조화 결과 — 열쇠마다 처음 쓸 때만 푼다(저장본이 다 맞는 다시 돎은 점군을 안 풂)."""
|
||||
|
||||
def __init__(self, structured: Any, xyz: _LazyXyz) -> None:
|
||||
super().__init__()
|
||||
self._structured = structured
|
||||
self["xyz"] = xyz
|
||||
|
||||
# 옛 dict 와 같은 열쇠만 보인다 — 필터가 열쇠 유무로 갈리는 일이 없게.
|
||||
_KEYS = ("classification",)
|
||||
|
||||
def __missing__(self, key: str) -> Any:
|
||||
if key not in self._KEYS:
|
||||
raise KeyError(key)
|
||||
value = self._structured[key]
|
||||
self[key] = value
|
||||
return value
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return dict.__contains__(self, key) or key in self._KEYS
|
||||
|
||||
|
||||
def _structured_stats(structured: Any, structured_path: Path) -> tuple[dict[str, Any], Any]:
|
||||
"""점 수 · 평균 Z 를 `structured_stats.json` 에서 읽는다 — 없거나 낡으면 한 번 풀어 적는다.
|
||||
|
||||
돌려주는 둘째 값 = 이번에 푼 xyz(없으면 None) — 같은 부름 안에서 다시 풀지 않게.
|
||||
"""
|
||||
stats_path = structured_path.parent / "structured_stats.json"
|
||||
stat = structured_path.stat()
|
||||
stamp = {"size_bytes": int(stat.st_size), "mtime_ns": int(stat.st_mtime_ns)}
|
||||
try:
|
||||
stored = json.loads(stats_path.read_text(encoding="utf-8"))
|
||||
if stored.get("structured") == stamp:
|
||||
return stored, None
|
||||
except (OSError, ValueError, AttributeError):
|
||||
pass
|
||||
xyz = structured["xyz"]
|
||||
count = int(len(xyz))
|
||||
stats = {
|
||||
"structured": stamp,
|
||||
"point_count": count,
|
||||
"mean_z": float(np.mean(xyz[:, 2])) if count else None,
|
||||
}
|
||||
atomic_write_json(stats_path, stats)
|
||||
return stats, xyz
|
||||
|
||||
|
||||
def _key_matches(key_path: Path, key: Any) -> bool:
|
||||
"""결과 옆 열쇠 파일이 지금 입력과 같은지."""
|
||||
try:
|
||||
return json.loads(key_path.read_text(encoding="utf-8")) == key
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _relative_to_project(project_root: Path, path: Path) -> str:
|
||||
"""프로젝트 루트 기준 posix 상대 경로 문자열."""
|
||||
return path.relative_to(project_root).as_posix()
|
||||
@@ -179,13 +260,14 @@ def run_surface_analysis(
|
||||
_report(10, "structurize", "구조화 캐시 재사용")
|
||||
logger.info("B04 구조화 캐시 재사용: %s", structured_path.name)
|
||||
with np.load(structured_path) as structured:
|
||||
xyz = structured["xyz"]
|
||||
# 점 수 · 평균 Z 는 저장값 — 저장본이 다 맞으면 점군(841 MB 압축)을 풀지 않는다.
|
||||
stored_stats, loaded_xyz = _structured_stats(structured, structured_path)
|
||||
bounds = structured["bounds"]
|
||||
total_points = int(len(xyz))
|
||||
total_points = int(stored_stats["point_count"])
|
||||
stats = {
|
||||
"min_z": float(bounds[2, 0]),
|
||||
"max_z": float(bounds[2, 1]),
|
||||
"mean_z": float(np.mean(xyz[:, 2])) if total_points else None,
|
||||
"mean_z": stored_stats["mean_z"],
|
||||
}
|
||||
bounds_dict = {
|
||||
"x_min": float(bounds[0, 0]),
|
||||
@@ -193,7 +275,8 @@ def run_surface_analysis(
|
||||
"y_min": float(bounds[1, 0]),
|
||||
"y_max": float(bounds[1, 1]),
|
||||
}
|
||||
data = {"xyz": xyz, "bounds": bounds, "classification": structured["classification"]}
|
||||
data = _LazyStructured(structured, _LazyXyz(structured, total_points, loaded_xyz))
|
||||
data["bounds"] = bounds
|
||||
|
||||
# 2. 지면 필터 실행 — mask_{filter}.npy 영구 캐시 우선 재사용 (PLAN A-1)
|
||||
_report(40, "ground_filter", "지면 필터 적용 중")
|
||||
@@ -386,14 +469,21 @@ def download_geodata(
|
||||
|
||||
# 등고선은 국가 gpkg 추가 시점이 전처리보다 늦을 수 있으므로 개별로 존재 여부를 확인한다.
|
||||
contour_geojson = processed_dir / "등고선_bounds.geojson"
|
||||
# 열쇠 = 받은 범위 · 좌표계 — 노선이 바뀌어 범위가 달라지면 다시 받는다.
|
||||
gis_key_path = processed_dir / "gis_vectors.key.json"
|
||||
gis_key = json.loads(
|
||||
json.dumps({"epsg": project_epsg, "bounds": bounds_dict_for_download}, default=float)
|
||||
)
|
||||
if (
|
||||
rebuild
|
||||
or not any(processed_dir.glob("*_bounds.geojson"))
|
||||
or not contour_geojson.exists()
|
||||
or not _key_matches(gis_key_path, gis_key)
|
||||
):
|
||||
try:
|
||||
step_started = time.monotonic()
|
||||
download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir)
|
||||
atomic_write_json(gis_key_path, gis_key)
|
||||
logger.info(
|
||||
"B04 국가 GIS 벡터 다운로드 완료 (%.1fs)", time.monotonic() - step_started
|
||||
)
|
||||
@@ -433,9 +523,13 @@ def download_geodata(
|
||||
# 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함).
|
||||
prune_sheets(sheet_store, sheet_grid)
|
||||
|
||||
# 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 (기존 산출물 있으면 스킵)
|
||||
# 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록)가 같으면 스킵
|
||||
merge_key_path = processed_dir / "sheet_merge.key.json"
|
||||
merge_key = {"sheets": sorted(str(item) for item in sheet_result["available"])}
|
||||
if sheet_result["available"] and (
|
||||
rebuild or not any(processed_dir.glob("도엽_*.geojson"))
|
||||
rebuild
|
||||
or not any(processed_dir.glob("도엽_*.geojson"))
|
||||
or not _key_matches(merge_key_path, merge_key)
|
||||
):
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import (
|
||||
parse_and_merge_sheets,
|
||||
@@ -450,6 +544,7 @@ def download_geodata(
|
||||
{k: v["count"] for k, v in merged.items()},
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
atomic_write_json(merge_key_path, merge_key)
|
||||
except Exception as exc:
|
||||
logger.warning("B04 수치지형도 도엽 확보 실패: %s", exc)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -18,7 +18,7 @@ try:
|
||||
except ImportError:
|
||||
mapbox_vector_tile = None
|
||||
|
||||
# 메모리 캐시: {project_id}_{layer_name}: GeoDataFrame
|
||||
# 메모리 캐시: {project_id}_{layer_name}: ((mtime_ns, 크기), GeoDataFrame)
|
||||
_gdf_cache: dict[str, Any] = {}
|
||||
|
||||
|
||||
@@ -41,9 +41,13 @@ def get_tile_bbox(xtile: int, ytile: int, zoom: int) -> tuple[float, float, floa
|
||||
def load_geojson_cached(filepath: Path, cache_key: str) -> Any:
|
||||
"""GeoJSON 파일을 한 번 읽으면 메모리에 GeoDataFrame으로 캐싱합니다."""
|
||||
global _gdf_cache
|
||||
if cache_key not in _gdf_cache:
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"File not found: {filepath}")
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"File not found: {filepath}")
|
||||
# 열쇠에 수정시각·크기 — 전처리를 다시 돌려 파일이 바뀌면 옛 타일을 버린다.
|
||||
stat = filepath.stat()
|
||||
stamp = (stat.st_mtime_ns, stat.st_size)
|
||||
cached = _gdf_cache.get(cache_key)
|
||||
if cached is None or cached[0] != stamp:
|
||||
if gpd is None:
|
||||
raise ImportError("geopandas가 설치되어 있지 않습니다.")
|
||||
gdf = gpd.read_file(filepath)
|
||||
@@ -51,8 +55,8 @@ def load_geojson_cached(filepath: Path, cache_key: str) -> Any:
|
||||
gdf.set_crs(epsg=4326, inplace=True)
|
||||
if gdf.crs.to_epsg() != 4326:
|
||||
gdf = gdf.to_crs(epsg=4326)
|
||||
_gdf_cache[cache_key] = gdf
|
||||
return _gdf_cache[cache_key]
|
||||
_gdf_cache[cache_key] = (stamp, gdf)
|
||||
return _gdf_cache[cache_key][1]
|
||||
|
||||
|
||||
def generate_mvt_tile(
|
||||
|
||||
@@ -41,10 +41,17 @@ _PROJECT_OUTPUT_TABLES = (
|
||||
)
|
||||
|
||||
|
||||
def purge_output_folders(project_root: Path) -> list[str]:
|
||||
# 같은 지형 파일을 다시 올렸을 때 살리는 것 — 전처리 폴더와 그 DB 행.
|
||||
PREPROCESS_STAGE_DIR = "B04_PreProcess"
|
||||
_PREPROCESS_TABLES = frozenset({"surface_models", "processed_point_cloud"})
|
||||
|
||||
|
||||
def purge_output_folders(project_root: Path, keep_preprocess: bool = False) -> list[str]:
|
||||
"""단계별 산출물 폴더를 비운다. 지운 폴더 이름을 돌려준다."""
|
||||
removed: list[str] = []
|
||||
for name in OUTPUT_STAGE_DIRS:
|
||||
if keep_preprocess and name == PREPROCESS_STAGE_DIR:
|
||||
continue
|
||||
target = project_root / name
|
||||
if not target.exists():
|
||||
continue
|
||||
@@ -54,7 +61,9 @@ def purge_output_folders(project_root: Path) -> list[str]:
|
||||
return removed
|
||||
|
||||
|
||||
async def purge_output_records(connection: aiomysql.Connection, project_id: str) -> dict[str, int]:
|
||||
async def purge_output_records(
|
||||
connection: aiomysql.Connection, project_id: str, keep_preprocess: bool = False
|
||||
) -> dict[str, int]:
|
||||
"""산출물 DB 레코드를 지운다. 파일만 지우면 화면은 "자료가 있다"고 착각한다.
|
||||
|
||||
호출부가 트랜잭션을 관리한다(여기서 커밋하지 않는다).
|
||||
@@ -75,6 +84,8 @@ async def purge_output_records(connection: aiomysql.Connection, project_id: str)
|
||||
)
|
||||
deleted["route_statistics"] = cursor.rowcount
|
||||
for table in _PROJECT_OUTPUT_TABLES:
|
||||
if keep_preprocess and table in _PREPROCESS_TABLES:
|
||||
continue
|
||||
await cursor.execute(f"DELETE FROM {table} WHERE project_id = %s", (project_id,))
|
||||
deleted[table] = cursor.rowcount
|
||||
await cursor.execute("DELETE FROM routes WHERE project_id = %s", (project_id,))
|
||||
@@ -83,14 +94,21 @@ async def purge_output_records(connection: aiomysql.Connection, project_id: str)
|
||||
|
||||
|
||||
async def purge_project_outputs(
|
||||
connection: aiomysql.Connection, project_id: str, project_root: Path
|
||||
connection: aiomysql.Connection,
|
||||
project_id: str,
|
||||
project_root: Path,
|
||||
keep_preprocess: bool = False,
|
||||
) -> None:
|
||||
"""입력 자료 교체 시 옛 계산 결과(파일 + DB)를 함께 지운다."""
|
||||
removed = purge_output_folders(project_root)
|
||||
deleted = await purge_output_records(connection, project_id)
|
||||
"""입력 자료 교체 시 옛 계산 결과(파일 + DB)를 함께 지운다.
|
||||
|
||||
`keep_preprocess` — 지형 파일이 그대로면 전처리(B04) 결과는 살린다.
|
||||
"""
|
||||
removed = purge_output_folders(project_root, keep_preprocess)
|
||||
deleted = await purge_output_records(connection, project_id, keep_preprocess)
|
||||
logger.info(
|
||||
"입력 자료 교체 — 옛 산출물 정리: project_id=%s 폴더=%s 레코드=%s",
|
||||
"입력 자료 교체 — 옛 산출물 정리: project_id=%s 전처리 살림=%s 폴더=%s 레코드=%s",
|
||||
project_id,
|
||||
keep_preprocess,
|
||||
",".join(removed) or "없음",
|
||||
{key: value for key, value in deleted.items() if value},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""343-1 같은 파일 다시 올림 = 전처리 결과 살림 · 점군 안 풂 · 도엽 병합 · 타일 기억 열쇠.
|
||||
|
||||
서버 함수만 시험 — 실제 재올림은 하지 않음(완료 메일).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
OutputsWouldBeDiscarded,
|
||||
_confirm_replace_response,
|
||||
describe_existing_outputs,
|
||||
preprocess_input_unchanged,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine import (
|
||||
_key_matches,
|
||||
_LazyStructured,
|
||||
_LazyXyz,
|
||||
_source_identity,
|
||||
_structured_stats,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import summarize_masks
|
||||
from common_util.common_util_project_reset import purge_output_folders, purge_output_records
|
||||
|
||||
|
||||
def _project(tmp_path: Path) -> tuple[Path, Path]:
|
||||
las = tmp_path / "B03_FileInput" / "input" / "a.las"
|
||||
las.parent.mkdir(parents=True)
|
||||
las.write_bytes(b"LASF" + b"0" * 100)
|
||||
processed = tmp_path / "B04_PreProcess" / "processed"
|
||||
processed.mkdir(parents=True)
|
||||
(processed / "source_identity.json").write_text(
|
||||
json.dumps(_source_identity([las])), encoding="utf-8"
|
||||
)
|
||||
return tmp_path, las
|
||||
|
||||
|
||||
def test_같은_지형_파일이면_전처리_살림(tmp_path: Path) -> None:
|
||||
root, las = _project(tmp_path)
|
||||
assert preprocess_input_unchanged(root, [las]) is True
|
||||
|
||||
|
||||
def test_지형_파일이_바뀌면_살리지_않음(tmp_path: Path) -> None:
|
||||
root, las = _project(tmp_path)
|
||||
stat = las.stat()
|
||||
os.utime(las, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000))
|
||||
assert preprocess_input_unchanged(root, [las]) is False
|
||||
assert preprocess_input_unchanged(root, []) is False
|
||||
|
||||
|
||||
def test_전처리_기록이_없으면_살리지_않음(tmp_path: Path) -> None:
|
||||
root, las = _project(tmp_path)
|
||||
(root / "B04_PreProcess" / "processed" / "source_identity.json").unlink()
|
||||
assert preprocess_input_unchanged(root, [las]) is False
|
||||
|
||||
|
||||
def test_살리면_확인_창_목록에서_전처리_빠짐(tmp_path: Path) -> None:
|
||||
root, _ = _project(tmp_path)
|
||||
(root / "B05_Profile").mkdir()
|
||||
(root / "B05_Profile" / "route.json").write_text("{}", encoding="utf-8")
|
||||
assert "지표면·전처리 결과" in describe_existing_outputs(root)
|
||||
kept = describe_existing_outputs(root, keep_preprocess=True)
|
||||
assert "지표면·전처리 결과" not in kept
|
||||
assert "노선·종단 설계" in kept
|
||||
|
||||
|
||||
def test_확인_응답에_전처리_살림_표시() -> None:
|
||||
body = json.loads(_confirm_replace_response(OutputsWouldBeDiscarded(["횡단 설계"], True)).body)
|
||||
assert body["preprocess_kept"] is True
|
||||
body = json.loads(_confirm_replace_response(OutputsWouldBeDiscarded(["횡단 설계"])).body)
|
||||
assert body["preprocess_kept"] is False
|
||||
|
||||
|
||||
def test_폴더_지우기는_전처리만_남김(tmp_path: Path) -> None:
|
||||
root, _ = _project(tmp_path)
|
||||
(root / "B05_Profile").mkdir()
|
||||
(root / "B05_Profile" / "route.json").write_text("{}", encoding="utf-8")
|
||||
removed = purge_output_folders(root, keep_preprocess=True)
|
||||
assert "B04_PreProcess" not in removed
|
||||
assert (root / "B04_PreProcess" / "processed" / "source_identity.json").is_file()
|
||||
assert not (root / "B05_Profile" / "route.json").exists()
|
||||
assert "B04_PreProcess" in purge_output_folders(root)
|
||||
assert not (root / "B04_PreProcess" / "processed").exists()
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, log: list[str]) -> None:
|
||||
self.log = log
|
||||
self.rowcount = 0
|
||||
|
||||
async def __aenter__(self) -> "_Cursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, sql: str, _params: object = None) -> None:
|
||||
self.log.append(sql)
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self) -> None:
|
||||
self.log: list[str] = []
|
||||
|
||||
def cursor(self) -> _Cursor:
|
||||
return _Cursor(self.log)
|
||||
|
||||
|
||||
def test_DB_지우기는_지표면_행을_남김() -> None:
|
||||
connection = _Connection()
|
||||
asyncio.run(purge_output_records(connection, "p", keep_preprocess=True)) # type: ignore[arg-type]
|
||||
joined = "\n".join(connection.log)
|
||||
assert "surface_models" not in joined
|
||||
assert "processed_point_cloud" not in joined
|
||||
assert "DELETE FROM routes" in joined
|
||||
connection = _Connection()
|
||||
asyncio.run(purge_output_records(connection, "p")) # type: ignore[arg-type]
|
||||
assert "surface_models" in "\n".join(connection.log)
|
||||
|
||||
|
||||
def test_점_수_평균Z_저장값_두번째는_안_풂(tmp_path: Path) -> None:
|
||||
path = tmp_path / "structured.npz"
|
||||
xyz = np.array([[0, 0, 1], [1, 1, 3]], dtype=np.float32)
|
||||
np.savez_compressed(path, xyz=xyz, bounds=np.zeros((3, 2)))
|
||||
with np.load(path) as structured:
|
||||
stats, loaded = _structured_stats(structured, path)
|
||||
assert stats["point_count"] == 2 and stats["mean_z"] == 2.0
|
||||
assert loaded is not None
|
||||
with np.load(path) as structured:
|
||||
stats, loaded = _structured_stats(structured, path)
|
||||
assert loaded is None and stats["point_count"] == 2
|
||||
# 구조화가 다시 돌면(파일이 바뀌면) 다시 셈
|
||||
np.savez_compressed(path, xyz=np.zeros((5, 3), np.float32), bounds=np.zeros((3, 2)))
|
||||
with np.load(path) as structured:
|
||||
stats, loaded = _structured_stats(structured, path)
|
||||
assert stats["point_count"] == 5 and loaded is not None
|
||||
|
||||
|
||||
class _NoXyz(dict):
|
||||
files = ["xyz", "bounds", "classification"]
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
if key == "xyz":
|
||||
raise AssertionError("점군을 풀면 안 됨")
|
||||
return dict.__getitem__(self, key)
|
||||
|
||||
|
||||
def test_저장본이_맞으면_점군을_풀지_않음() -> None:
|
||||
source = _NoXyz(classification=np.zeros(3, np.uint8), bounds=np.zeros((3, 2)))
|
||||
data = _LazyStructured(source, _LazyXyz(source, 3))
|
||||
summary = summarize_masks(data, {"csf": np.array([True, False, True])})
|
||||
assert summary["csf"]["total_point_count"] == 3
|
||||
assert "classification" in data and "rgb" not in data
|
||||
assert len(data["classification"]) == 3
|
||||
|
||||
|
||||
def test_점군은_쓸_때_풂() -> None:
|
||||
xyz = np.arange(9, dtype=np.float32).reshape(3, 3)
|
||||
lazy = _LazyXyz({"xyz": xyz}, 3)
|
||||
assert lazy.shape == (3, 3) and lazy.ndim == 2
|
||||
assert np.asarray(lazy, dtype=np.float64)[2, 2] == 8.0
|
||||
assert lazy[1, 0] == 3.0
|
||||
|
||||
|
||||
def test_결과_옆_열쇠_대조(tmp_path: Path) -> None:
|
||||
key_path = tmp_path / "sheet_merge.key.json"
|
||||
key = {"sheets": ["37612001", "37612002"]}
|
||||
assert _key_matches(key_path, key) is False
|
||||
key_path.write_text(json.dumps(key), encoding="utf-8")
|
||||
assert _key_matches(key_path, key) is True
|
||||
assert _key_matches(key_path, {"sheets": ["37612001"]}) is False
|
||||
|
||||
|
||||
def test_지도_타일_기억은_파일이_바뀌면_다시_읽음(tmp_path: Path) -> None:
|
||||
from B04_PreProcess.B04_PreProcess_Engine_MvtHelper import load_geojson_cached
|
||||
|
||||
path = tmp_path / "등고선_bounds.geojson"
|
||||
|
||||
def write(count: int) -> None:
|
||||
features = [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"h": index},
|
||||
"geometry": {"type": "Point", "coordinates": [127.0 + index * 0.01, 37.0]},
|
||||
}
|
||||
for index in range(count)
|
||||
]
|
||||
path.write_text(
|
||||
json.dumps({"type": "FeatureCollection", "features": features}), encoding="utf-8"
|
||||
)
|
||||
|
||||
write(1)
|
||||
assert len(load_geojson_cached(path, "t343_contour")) == 1
|
||||
assert len(load_geojson_cached(path, "t343_contour")) == 1
|
||||
write(3)
|
||||
stat = path.stat()
|
||||
os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000))
|
||||
assert len(load_geojson_cached(path, "t343_contour")) == 3
|
||||
|
||||
|
||||
def test_다시_돌면_같은_결과_점군_안_풂(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
"""작은 LAS 로 전처리 두 번 — 두 번째는 저장본만 읽고 점군 xyz 를 풀지 않음."""
|
||||
import laspy
|
||||
|
||||
import B04_PreProcess.B04_PreProcess_Engine as engine
|
||||
import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet
|
||||
|
||||
rng = np.random.default_rng(1)
|
||||
count = 4000
|
||||
x = rng.uniform(200000, 200060, count)
|
||||
y = rng.uniform(500000, 500060, count)
|
||||
header = laspy.LasHeader(point_format=3, version="1.2")
|
||||
header.offsets = [200000, 500000, 0]
|
||||
header.scales = [0.01, 0.01, 0.01]
|
||||
las_data = laspy.LasData(header)
|
||||
las_data.x, las_data.y, las_data.z = x, y, 100 + 0.1 * x - 0.1 * 200000
|
||||
las_data.classification = np.full(count, 2, np.uint8)
|
||||
las_path = tmp_path / "B03_FileInput" / "input" / "t.las"
|
||||
las_path.parent.mkdir(parents=True)
|
||||
las_data.write(las_path)
|
||||
|
||||
monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: None)
|
||||
monkeypatch.setattr(sheet, "build_sheet_surface_from_route", lambda *a, **k: [])
|
||||
first = engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"])
|
||||
|
||||
real_lazy_load = engine._LazyXyz._load
|
||||
calls: list[int] = []
|
||||
|
||||
def counting_load(self): # type: ignore[no-untyped-def]
|
||||
calls.append(1)
|
||||
return real_lazy_load(self)
|
||||
|
||||
monkeypatch.setattr(engine._LazyXyz, "_load", counting_load)
|
||||
second = engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"])
|
||||
assert first["models"] and calls == []
|
||||
assert second["processed"] == first["processed"]
|
||||
assert second["ground_summary"] == first["ground_summary"]
|
||||
assert [m["model_file_path"] for m in second["models"]] == [
|
||||
m["model_file_path"] for m in first["models"]
|
||||
]
|
||||
Reference in New Issue
Block a user