Files
Aislo/B04_PreProcess/B04_PreProcess_Engine_Structurize.py
T
eomsangdonandClaude Opus 5 37b4d41e0b feat(B03): 자료 교체 시 옛 산출물 정리 + 분석 중 업로드 차단 (E2E 결함 3·6 서버측)
사용자 결정(2026-08-08): 새 자료를 올리면 재계산해 덮어쓰고, 화면은 불러올 자료가 없으면
대시보드로 보낸다. 그러려면 자료가 갈리는 순간 옛 계산 결과가 남아 있으면 안 된다.

- common_util_project_reset 신설: 단계별 산출물 폴더(B04~B09)와 산출물 DB 레코드
  (surface_models·routes·route_points·route_statistics·longitudinal_sections·
  cross_sections·structures·quantity_items·outputs·processed_point_cloud)를 함께 지운다.
  **B03_FileInput(업로드 원본)은 지우지 않는다** — 같은 파일인지 가리는 중복 검사가 쓴다.
- 직접 업로드 완료·보관함 연결 양쪽에서 정리를 호출하고, 진행 단계도 1단계 이후를
  NOT_STARTED로 되돌린다(reset_stages_after_input_change).
- is_analysis_running(): 전처리가 도는 중이면 업로드 세션 생성과 보관함 연결을 409로
  막는다. 화면 버튼 잠금은 새로고침·다른 탭으로 우회되므로 서버에도 문을 단다.
- fail_stage 아래 있던 리비전(번호표) 안은 폐기 — 사용자가 "산출물이 없으면 대시보드"
  방식으로 정리했다.

곁들여: os.replace 공유 위반 재시도(replace_with_retry)
  진행률 파일은 서버가 쓰는 동안 화면이 계속 읽어 WinError 5가 났고, 전처리
  structured.npz 교체에서는 같은 이유로 분석이 통째로 죽었다(결함 3의 사망 원인).
  짧게 여러 번 다시 시도하도록 바꿨다.

검증(실서버 f45243b3, 계획노선 CSV 재업로드로 자료 교체):
  전처리 결과 112개 -> 재분석분만, 노선 2->0, 횡단 20->0,
  DB 지표면 15->0 / 노선 1->0 / 횡단 19->0, 업로드 원본 6개는 그대로.
  진행 단계가 초기로 돌아가고 재분석이 자동 시작됨.
  교체 재시도는 읽는 쪽이 파일을 잡고 있는 상황을 만들어 성공 확인.
ruff format·check 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:41:51 +09:00

115 lines
4.5 KiB
Python

"""B04 LAS/LAZ 고속 구조화 엔진."""
import os
import tempfile
from collections.abc import Callable
from pathlib import Path
import laspy
import numpy as np
from common_util.common_util_json import replace_with_retry
from config.config_system import SURFACE_DEFAULT_RGB_VALUE, SURFACE_LAS_CHUNK_SIZE
def structurize_las(
las_path: str | Path,
output_dir: str | Path,
progress_callback: Callable[[int], None] | None = None,
) -> Path:
"""LAS/LAZ 속성을 청크로 읽어 B04 structured.npz로 원자적 저장한다."""
source = Path(las_path)
target_dir = Path(output_dir)
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / "structured.npz"
with laspy.open(source) as las_file:
header = las_file.header
total_points = int(header.point_count)
point_format = header.point_format
dimensions = set(point_format.dimension_names)
has_rgb = {"red", "green", "blue"}.issubset(dimensions)
has_intensity = "intensity" in dimensions
has_returns = {"return_number", "number_of_returns"}.issubset(dimensions)
has_classification = "classification" in dimensions
bounds = np.array(
[
[float(header.mins[0]), float(header.maxs[0])],
[float(header.mins[1]), float(header.maxs[1])],
[float(header.mins[2]), float(header.maxs[2])],
],
dtype=np.float64,
)
xyz = np.empty((total_points, 3), dtype=np.float64)
intensity = np.zeros(total_points, dtype=np.uint16)
rgb = np.full((total_points, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
return_number = np.ones(total_points, dtype=np.uint8)
number_of_returns = np.ones(total_points, dtype=np.uint8)
classification = np.zeros(total_points, dtype=np.uint8)
offset = 0
for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE):
chunk_size = len(chunk)
section = slice(offset, offset + chunk_size)
xyz[section, 0] = np.asarray(chunk.x, dtype=np.float64)
xyz[section, 1] = np.asarray(chunk.y, dtype=np.float64)
xyz[section, 2] = np.asarray(chunk.z, dtype=np.float64)
if has_intensity:
intensity[section] = np.asarray(chunk.intensity, dtype=np.uint16)
if has_rgb:
colors = np.stack(
[
np.asarray(chunk.red, dtype=np.float64),
np.asarray(chunk.green, dtype=np.float64),
np.asarray(chunk.blue, dtype=np.float64),
],
axis=1,
)
if colors.size and float(colors.max()) > 255.0:
colors /= 256.0
rgb[section] = colors.clip(0, 255).astype(np.uint8)
if has_returns:
return_number[section] = np.asarray(chunk.return_number, dtype=np.uint8)
number_of_returns[section] = np.asarray(chunk.number_of_returns, dtype=np.uint8)
if has_classification:
classification[section] = np.asarray(chunk.classification, dtype=np.uint8)
offset += chunk_size
if progress_callback:
progress_callback(int(offset / total_points * 100) if total_points else 100)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=target_dir,
prefix=".structured.",
suffix=".npz.tmp",
delete=False,
) as temporary:
temporary_path = Path(temporary.name)
np.savez_compressed(
temporary,
xyz=xyz,
intensity=intensity,
rgb=rgb,
return_number=return_number,
number_of_returns=number_of_returns,
classification=classification,
bounds=bounds,
total_points=np.array([total_points], dtype=np.int64),
has_rgb=np.array([int(has_rgb)], dtype=np.int8),
)
temporary.flush()
os.fsync(temporary.fileno())
# 이 교체가 윈도우 공유 위반으로 죽으면 WF1 분석 전체가 실패한다 — 재시도한다.
replace_with_retry(temporary_path, target)
temporary_path = None
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
if progress_callback and total_points == 0:
progress_callback(100)
return target