760 lines
28 KiB
Python
760 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import random
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import laspy
|
|
import numpy as np
|
|
import rasterio
|
|
from PIL import Image
|
|
from pyproj import CRS
|
|
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
SAMPLE_DIR = ROOT_DIR / "samples" / "step1_scan"
|
|
SAMPLE_STORAGE_DIR = ROOT_DIR / "storage" / "projects" / "sample"
|
|
ANALYSIS_PATH = SAMPLE_STORAGE_DIR / "processed" / "analysis.json"
|
|
PREVIEW_PATH = SAMPLE_STORAGE_DIR / "processed" / "preview.png"
|
|
LAS_PREVIEW_PATH = SAMPLE_STORAGE_DIR / "processed" / "las-preview.png"
|
|
LAS_POINTS_SAMPLE_PATH = SAMPLE_STORAGE_DIR / "processed" / "las-points-sample.json"
|
|
|
|
REQUIRED_FILES = {
|
|
"point_cloud": ["cloud_merged.las", "cloud_merged.laz"],
|
|
"projection": ["result.prj"],
|
|
"world_file": ["result.tfw"],
|
|
}
|
|
|
|
OPTIONAL_FILES = {
|
|
"raster": ["result.tif", "result.tiff"],
|
|
"explanation": ["explanation.md"],
|
|
}
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def file_checksum(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def file_info(path: Path, role: str, required: bool) -> dict[str, Any]:
|
|
exists = path.exists()
|
|
return {
|
|
"role": role,
|
|
"name": path.name,
|
|
"path": str(path.relative_to(ROOT_DIR)) if exists else str(path),
|
|
"exists": exists,
|
|
"required": required,
|
|
"size_bytes": path.stat().st_size if exists else None,
|
|
"sha256": file_checksum(path) if exists else None,
|
|
}
|
|
|
|
|
|
def find_first_existing(base_dir: Path, candidates: list[str]) -> Path:
|
|
for candidate in candidates:
|
|
path = base_dir / candidate
|
|
if path.exists():
|
|
return path
|
|
return base_dir / candidates[0]
|
|
|
|
|
|
def collect_project_files() -> list[dict[str, Any]]:
|
|
files: list[dict[str, Any]] = []
|
|
for role, candidates in REQUIRED_FILES.items():
|
|
files.append(file_info(find_first_existing(SAMPLE_DIR, candidates), role, True))
|
|
for role, candidates in OPTIONAL_FILES.items():
|
|
files.append(file_info(find_first_existing(SAMPLE_DIR, candidates), role, False))
|
|
return files
|
|
|
|
|
|
def analyze_las(path: Path, las_preview_path: Path | None = None) -> dict[str, Any]:
|
|
with laspy.open(path) as las_file:
|
|
header = las_file.header
|
|
point_count = int(header.point_count)
|
|
point_format = header.point_format
|
|
crs = header.parse_crs()
|
|
|
|
metadata: dict[str, Any] = {
|
|
"file": path.name,
|
|
"version": f"{header.version.major}.{header.version.minor}",
|
|
"point_format": {
|
|
"id": point_format.id,
|
|
"dimensions": list(point_format.dimension_names),
|
|
},
|
|
"point_count": point_count,
|
|
"bounds": {
|
|
"x": [float(header.mins[0]), float(header.maxs[0])],
|
|
"y": [float(header.mins[1]), float(header.maxs[1])],
|
|
"z": [float(header.mins[2]), float(header.maxs[2])],
|
|
},
|
|
"scale": [float(value) for value in header.scales],
|
|
"offset": [float(value) for value in header.offsets],
|
|
"has_crs": crs is not None,
|
|
"crs": crs.to_string() if crs else None,
|
|
"has_classification": "classification" in point_format.dimension_names,
|
|
"has_rgb": all(name in point_format.dimension_names for name in ("red", "green", "blue")),
|
|
"has_intensity": "intensity" in point_format.dimension_names,
|
|
"has_return_number": "return_number" in point_format.dimension_names,
|
|
}
|
|
|
|
if metadata["has_classification"] and point_count > 0:
|
|
cls_counts: dict[int, int] = {}
|
|
for chunk in las_file.chunk_iterator(500_000):
|
|
vals, cnts = np.unique(
|
|
np.asarray(chunk.classification, dtype=np.uint8), return_counts=True
|
|
)
|
|
for v, c in zip(vals.tolist(), cnts.tolist(), strict=True):
|
|
cls_counts[v] = cls_counts.get(v, 0) + c
|
|
metadata["classification_sample"] = {
|
|
str(k): v for k, v in sorted(cls_counts.items())
|
|
}
|
|
|
|
if point_count > 0:
|
|
preview_out = las_preview_path if las_preview_path is not None else LAS_PREVIEW_PATH
|
|
metadata["preview"] = create_las_preview(path, preview_out)
|
|
|
|
return metadata
|
|
|
|
|
|
def analyze_prj(path: Path) -> dict[str, Any]:
|
|
text = path.read_text(encoding="utf-8", errors="replace").strip()
|
|
result: dict[str, Any] = {
|
|
"file": path.name,
|
|
"text_preview": text[:500],
|
|
"epsg": None,
|
|
"name": None,
|
|
"is_valid": False,
|
|
}
|
|
|
|
try:
|
|
crs = CRS.from_wkt(text)
|
|
result.update(
|
|
{
|
|
"epsg": crs.to_epsg(),
|
|
"name": crs.name,
|
|
"is_valid": True,
|
|
"authority": crs.to_authority(),
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
result["error"] = str(exc)
|
|
|
|
return result
|
|
|
|
|
|
def analyze_tfw(path: Path) -> dict[str, Any]:
|
|
values = [
|
|
float(line.strip())
|
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
if line.strip()
|
|
]
|
|
return {
|
|
"file": path.name,
|
|
"values": values,
|
|
"pixel_size_x": values[0] if len(values) > 0 else None,
|
|
"rotation_y": values[1] if len(values) > 1 else None,
|
|
"rotation_x": values[2] if len(values) > 2 else None,
|
|
"pixel_size_y": values[3] if len(values) > 3 else None,
|
|
"origin_x": values[4] if len(values) > 4 else None,
|
|
"origin_y": values[5] if len(values) > 5 else None,
|
|
"is_valid": len(values) == 6,
|
|
}
|
|
|
|
|
|
def analyze_tif(path: Path) -> dict[str, Any]:
|
|
with rasterio.open(path) as dataset:
|
|
crs = dataset.crs
|
|
bounds = dataset.bounds
|
|
return {
|
|
"file": path.name,
|
|
"width": dataset.width,
|
|
"height": dataset.height,
|
|
"count": dataset.count,
|
|
"dtypes": list(dataset.dtypes),
|
|
"nodata": dataset.nodata,
|
|
"crs": crs.to_string() if crs else None,
|
|
"epsg": crs.to_epsg() if crs else None,
|
|
"bounds": {
|
|
"left": bounds.left,
|
|
"bottom": bounds.bottom,
|
|
"right": bounds.right,
|
|
"top": bounds.top,
|
|
},
|
|
"transform": list(dataset.transform)[:6],
|
|
"resolution": list(dataset.res),
|
|
"likely_type": "dem" if dataset.count == 1 else "image",
|
|
}
|
|
|
|
|
|
def create_tif_preview(path: Path, output_path: Path) -> dict[str, Any]:
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with rasterio.open(path) as dataset:
|
|
if dataset.count >= 3:
|
|
indexes = [1, 2, 3]
|
|
data = dataset.read(indexes, out_shape=(3, 512, 512), masked=True)
|
|
image_data = np.moveaxis(data.filled(0), 0, -1).astype("float32")
|
|
for channel in range(3):
|
|
band = image_data[:, :, channel]
|
|
minimum = np.percentile(band, 2)
|
|
maximum = np.percentile(band, 98)
|
|
image_data[:, :, channel] = normalize_band(band, minimum, maximum)
|
|
image = Image.fromarray(image_data.astype("uint8"), "RGB")
|
|
else:
|
|
data = dataset.read(1, out_shape=(512, 512), masked=True).filled(np.nan)
|
|
finite = data[np.isfinite(data)]
|
|
if finite.size:
|
|
minimum = np.percentile(finite, 2)
|
|
maximum = np.percentile(finite, 98)
|
|
else:
|
|
minimum = 0
|
|
maximum = 1
|
|
gray = normalize_band(data, minimum, maximum)
|
|
image = Image.fromarray(gray.astype("uint8"), "L").convert("RGB")
|
|
|
|
image.save(output_path)
|
|
return {
|
|
"path": str(output_path.relative_to(ROOT_DIR)),
|
|
"width": image.width,
|
|
"height": image.height,
|
|
}
|
|
|
|
|
|
def create_las_preview(path: Path, output_path: Path) -> dict[str, Any]:
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with laspy.open(path) as las_file:
|
|
header = las_file.header
|
|
total_points = int(las_file.header.point_count)
|
|
if total_points == 0:
|
|
Image.new("RGB", (512, 512), "#f2f5f8").save(output_path)
|
|
return {
|
|
"path": str(output_path.relative_to(ROOT_DIR)),
|
|
"width": 512,
|
|
"height": 512,
|
|
"source_point_count": 0,
|
|
"sample_point_count": 0,
|
|
"plotted_point_count": 0,
|
|
"color_by": "height",
|
|
"rendering": "all_points_chunked",
|
|
}
|
|
|
|
width = 900
|
|
height = 640
|
|
padding = 18
|
|
image = np.full((height, width, 3), 245, dtype=np.uint8)
|
|
|
|
x_min, x_max = float(header.mins[0]), float(header.maxs[0])
|
|
y_min, y_max = float(header.mins[1]), float(header.maxs[1])
|
|
z_min, z_max = float(header.mins[2]), float(header.maxs[2])
|
|
|
|
x_span = max(x_max - x_min, 1e-9)
|
|
y_span = max(y_max - y_min, 1e-9)
|
|
draw_width = width - padding * 2
|
|
draw_height = height - padding * 2
|
|
|
|
plotted_count = 0
|
|
with laspy.open(path) as las_file:
|
|
for points in las_file.chunk_iterator(1_000_000):
|
|
x = np.asarray(points.x)
|
|
y = np.asarray(points.y)
|
|
z = np.asarray(points.z)
|
|
px = ((x - x_min) / x_span * (draw_width - 1) + padding).astype(np.int32)
|
|
py = (height - padding - 1 - ((y - y_min) / y_span * (draw_height - 1))).astype(np.int32)
|
|
colors = height_colormap(z, z_min, z_max)
|
|
|
|
valid = (px >= 0) & (px < width) & (py >= 0) & (py < height)
|
|
image[py[valid], px[valid]] = colors[valid]
|
|
plotted_count += int(np.count_nonzero(valid))
|
|
|
|
preview = Image.fromarray(image, "RGB")
|
|
preview.save(output_path)
|
|
return {
|
|
"path": str(output_path.relative_to(ROOT_DIR)),
|
|
"width": width,
|
|
"height": height,
|
|
"source_point_count": total_points,
|
|
"sample_point_count": plotted_count,
|
|
"plotted_point_count": plotted_count,
|
|
"color_by": "height",
|
|
"rendering": "all_points_chunked",
|
|
"bounds": {
|
|
"x": [x_min, x_max],
|
|
"y": [y_min, y_max],
|
|
"z": [z_min, z_max],
|
|
},
|
|
}
|
|
|
|
|
|
def height_colormap(z: np.ndarray, minimum: float, maximum: float) -> np.ndarray:
|
|
if maximum <= minimum:
|
|
normalized = np.zeros_like(z, dtype=np.float32)
|
|
else:
|
|
normalized = np.clip((z - minimum) / (maximum - minimum), 0, 1)
|
|
|
|
stops = np.array(
|
|
[
|
|
[36, 86, 128],
|
|
[67, 137, 112],
|
|
[158, 173, 92],
|
|
[214, 174, 88],
|
|
[149, 86, 67],
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
scaled = normalized * (len(stops) - 1)
|
|
lower = np.floor(scaled).astype(np.int32)
|
|
upper = np.clip(lower + 1, 0, len(stops) - 1)
|
|
ratio = (scaled - lower)[:, None]
|
|
colors = stops[lower] * (1 - ratio) + stops[upper] * ratio
|
|
return colors.astype(np.uint8)
|
|
|
|
|
|
def sample_las_points(
|
|
path: Path,
|
|
output_path: Path = LAS_POINTS_SAMPLE_PATH,
|
|
sample_ratio: float = 1.0,
|
|
max_points: int = 10_000_000,
|
|
) -> dict[str, Any]:
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
rng = random.Random(42)
|
|
|
|
with laspy.open(path) as las_file:
|
|
header = las_file.header
|
|
total_points = int(header.point_count)
|
|
target_count = min(max_points, max(1, int(total_points * sample_ratio)))
|
|
bounds = {
|
|
"x": [float(header.mins[0]), float(header.maxs[0])],
|
|
"y": [float(header.mins[1]), float(header.maxs[1])],
|
|
"z": [float(header.mins[2]), float(header.maxs[2])],
|
|
}
|
|
|
|
reservoir: list[list] = [] # [x, y, z, cls]
|
|
seen = 0
|
|
for chunk in las_file.chunk_iterator(250_000):
|
|
xs = np.asarray(chunk.x)
|
|
ys = np.asarray(chunk.y)
|
|
zs = np.asarray(chunk.z)
|
|
clss = np.asarray(chunk.classification, dtype=np.uint8)
|
|
for x, y, z, cls in zip(xs, ys, zs, clss, strict=True):
|
|
point = [round(float(x), 3), round(float(y), 3), round(float(z), 3), int(cls)]
|
|
if len(reservoir) < target_count:
|
|
reservoir.append(point)
|
|
else:
|
|
index = rng.randint(0, seen)
|
|
if index < target_count:
|
|
reservoir[index] = point
|
|
seen += 1
|
|
|
|
points_only = [[p[0], p[1], p[2]] for p in reservoir]
|
|
classifications = [p[3] for p in reservoir]
|
|
|
|
result = {
|
|
"source_point_count": total_points,
|
|
"requested_ratio": sample_ratio,
|
|
"target_point_count": target_count,
|
|
"sample_point_count": len(reservoir),
|
|
"sampling": "random_reservoir_seed_42",
|
|
"browser_limit_applied": int(total_points * sample_ratio) > max_points,
|
|
"bounds": bounds,
|
|
"points": points_only,
|
|
"classifications": classifications,
|
|
}
|
|
output_path.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
|
|
return result
|
|
|
|
|
|
def load_or_create_las_points_sample() -> dict[str, Any]:
|
|
if LAS_POINTS_SAMPLE_PATH.exists():
|
|
cached = json.loads(LAS_POINTS_SAMPLE_PATH.read_text(encoding="utf-8"))
|
|
if (
|
|
cached.get("target_point_count") == 10_000_000
|
|
and cached.get("requested_ratio") == 1.0
|
|
and "classifications" in cached
|
|
):
|
|
return cached
|
|
|
|
las_path = find_first_existing(SAMPLE_DIR, REQUIRED_FILES["point_cloud"])
|
|
if not las_path.exists():
|
|
return {
|
|
"source_point_count": 0,
|
|
"sample_point_count": 0,
|
|
"bounds": None,
|
|
"points": [],
|
|
"error": "LAS file is not available.",
|
|
}
|
|
return sample_las_points(las_path)
|
|
|
|
|
|
def normalize_band(data: np.ndarray, minimum: float, maximum: float) -> np.ndarray:
|
|
if maximum <= minimum:
|
|
return np.zeros_like(data, dtype="uint8")
|
|
normalized = (data - minimum) / (maximum - minimum)
|
|
normalized = np.nan_to_num(normalized, nan=0.0, posinf=1.0, neginf=0.0)
|
|
return (np.clip(normalized, 0, 1) * 255).astype("uint8")
|
|
|
|
|
|
def build_warnings(files: list[dict[str, Any]], analysis: dict[str, Any]) -> list[dict[str, str]]:
|
|
warnings: list[dict[str, str]] = []
|
|
for item in files:
|
|
if item["required"] and not item["exists"]:
|
|
warnings.append(
|
|
{
|
|
"code": "missing_required_file",
|
|
"message": f"필수 파일이 없습니다: {item['name']}",
|
|
}
|
|
)
|
|
|
|
prj = analysis.get("prj")
|
|
if prj and not prj.get("is_valid"):
|
|
warnings.append(
|
|
{
|
|
"code": "invalid_prj",
|
|
"message": "좌표계 파일을 해석하지 못했습니다. 임시 좌표계로 보기 전용 표시가 필요합니다.",
|
|
}
|
|
)
|
|
|
|
las = analysis.get("las")
|
|
if las and not las.get("has_crs"):
|
|
warnings.append(
|
|
{
|
|
"code": "las_without_crs",
|
|
"message": "LAS 파일 내부 좌표계 정보가 없습니다. PRJ 기준 좌표계 확인이 필요합니다.",
|
|
}
|
|
)
|
|
|
|
return warnings
|
|
|
|
|
|
def analyze_sample_project() -> dict[str, Any]:
|
|
SAMPLE_STORAGE_DIR.joinpath("processed").mkdir(parents=True, exist_ok=True)
|
|
files = collect_project_files()
|
|
analysis: dict[str, Any] = {}
|
|
|
|
las_path = find_first_existing(SAMPLE_DIR, REQUIRED_FILES["point_cloud"])
|
|
prj_path = find_first_existing(SAMPLE_DIR, REQUIRED_FILES["projection"])
|
|
tfw_path = find_first_existing(SAMPLE_DIR, REQUIRED_FILES["world_file"])
|
|
tif_path = find_first_existing(SAMPLE_DIR, OPTIONAL_FILES["raster"])
|
|
|
|
try:
|
|
if las_path.exists():
|
|
analysis["las"] = analyze_las(las_path)
|
|
if prj_path.exists():
|
|
analysis["prj"] = analyze_prj(prj_path)
|
|
if tfw_path.exists():
|
|
analysis["tfw"] = analyze_tfw(tfw_path)
|
|
if tif_path.exists():
|
|
analysis["tif"] = analyze_tif(tif_path)
|
|
analysis["preview"] = create_tif_preview(tif_path, PREVIEW_PATH)
|
|
except Exception as exc:
|
|
analysis["error"] = {
|
|
"code": "analysis_failed",
|
|
"message": str(exc),
|
|
}
|
|
|
|
result = {
|
|
"project": {
|
|
"id": "sample",
|
|
"name": "개발용 샘플 프로젝트",
|
|
"source": str(SAMPLE_DIR.relative_to(ROOT_DIR)),
|
|
},
|
|
"created_at": utc_now(),
|
|
"status": "failed" if "error" in analysis else "completed",
|
|
"files": files,
|
|
"analysis": analysis,
|
|
"warnings": build_warnings(files, analysis),
|
|
"phase2_candidates": [
|
|
{
|
|
"type": "preview_image",
|
|
"path": str(PREVIEW_PATH.relative_to(ROOT_DIR)),
|
|
"ready": PREVIEW_PATH.exists(),
|
|
},
|
|
{
|
|
"type": "las_top_view_preview",
|
|
"path": str(LAS_PREVIEW_PATH.relative_to(ROOT_DIR)),
|
|
"ready": LAS_PREVIEW_PATH.exists(),
|
|
},
|
|
{
|
|
"type": "terrain_or_tiles",
|
|
"path": None,
|
|
"ready": False,
|
|
"note": "Phase 2에서 DEM/terrain tiles/3D Tiles 변환 후보를 결정한다.",
|
|
},
|
|
],
|
|
}
|
|
|
|
ANALYSIS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
ANALYSIS_PATH.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return result
|
|
|
|
|
|
def load_analysis() -> dict[str, Any] | None:
|
|
if not ANALYSIS_PATH.exists():
|
|
return None
|
|
return json.loads(ANALYSIS_PATH.read_text(encoding="utf-8"))
|
|
|
|
|
|
# ---- Dynamic project support ----
|
|
|
|
|
|
def get_project_source_dir(project_id: str) -> Path:
|
|
if project_id == "sample":
|
|
return SAMPLE_DIR
|
|
return ROOT_DIR / "storage" / "projects" / project_id / "raw"
|
|
|
|
|
|
def get_project_storage_dir(project_id: str) -> Path:
|
|
if project_id == "sample":
|
|
return SAMPLE_STORAGE_DIR
|
|
return ROOT_DIR / "storage" / "projects" / project_id
|
|
|
|
|
|
def find_las_in_dir(source_dir: Path) -> Path:
|
|
for candidate in REQUIRED_FILES["point_cloud"]:
|
|
path = source_dir / candidate
|
|
if path.exists():
|
|
return path
|
|
for ext in ("*.las", "*.laz"):
|
|
matches = list(source_dir.glob(ext))
|
|
if matches:
|
|
return matches[0]
|
|
return source_dir / REQUIRED_FILES["point_cloud"][0]
|
|
|
|
|
|
def load_or_create_las_points_for_project(project_id: str) -> dict[str, Any]:
|
|
storage_dir = get_project_storage_dir(project_id)
|
|
processed_dir = storage_dir / "processed"
|
|
all_points_path = processed_dir / "all-points.json"
|
|
if all_points_path.exists():
|
|
cached = json.loads(all_points_path.read_text(encoding="utf-8"))
|
|
if "classifications" in cached:
|
|
return cached
|
|
points_path = processed_dir / "las-points-sample.json"
|
|
if points_path.exists():
|
|
cached = json.loads(points_path.read_text(encoding="utf-8"))
|
|
if (
|
|
cached.get("target_point_count") == 10_000_000
|
|
and cached.get("requested_ratio") == 1.0
|
|
and "classifications" in cached
|
|
):
|
|
return cached
|
|
source_dir = get_project_source_dir(project_id)
|
|
las_path = find_las_in_dir(source_dir)
|
|
if not las_path.exists():
|
|
return {"source_point_count": 0, "sample_point_count": 0, "bounds": None, "points": [], "error": "LAS 파일을 찾을 수 없습니다."}
|
|
return sample_las_points(las_path, output_path=points_path)
|
|
|
|
|
|
def analyze_project(project_id: str) -> dict[str, Any]:
|
|
source_dir = get_project_source_dir(project_id)
|
|
storage_dir = get_project_storage_dir(project_id)
|
|
processed_dir = storage_dir / "processed"
|
|
processed_dir.mkdir(parents=True, exist_ok=True)
|
|
analysis_path = processed_dir / "analysis.json"
|
|
preview_path = processed_dir / "preview.png"
|
|
las_preview_path = processed_dir / "las-preview.png"
|
|
|
|
files: list[dict[str, Any]] = []
|
|
for role, candidates in REQUIRED_FILES.items():
|
|
path = find_first_existing(source_dir, candidates)
|
|
files.append(file_info(path, role, True))
|
|
for role, candidates in OPTIONAL_FILES.items():
|
|
path = find_first_existing(source_dir, candidates)
|
|
files.append(file_info(path, role, False))
|
|
|
|
las_path = find_las_in_dir(source_dir)
|
|
prj_path = find_first_existing(source_dir, REQUIRED_FILES["projection"])
|
|
tfw_path = find_first_existing(source_dir, REQUIRED_FILES["world_file"])
|
|
tif_path = find_first_existing(source_dir, OPTIONAL_FILES["raster"])
|
|
|
|
analysis: dict[str, Any] = {}
|
|
try:
|
|
if las_path.exists():
|
|
analysis["las"] = analyze_las(las_path, las_preview_path)
|
|
if prj_path.exists():
|
|
analysis["prj"] = analyze_prj(prj_path)
|
|
if tfw_path.exists():
|
|
analysis["tfw"] = analyze_tfw(tfw_path)
|
|
if tif_path.exists():
|
|
analysis["tif"] = analyze_tif(tif_path)
|
|
analysis["preview"] = create_tif_preview(tif_path, preview_path)
|
|
except Exception as exc:
|
|
analysis["error"] = {"code": "analysis_failed", "message": str(exc)}
|
|
|
|
result = {
|
|
"project": {
|
|
"id": project_id,
|
|
"name": "개발용 샘플 프로젝트" if project_id == "sample" else "업로드 프로젝트",
|
|
"source": str(source_dir.relative_to(ROOT_DIR)),
|
|
},
|
|
"created_at": utc_now(),
|
|
"status": "failed" if "error" in analysis else "completed",
|
|
"files": files,
|
|
"analysis": analysis,
|
|
"warnings": build_warnings(files, analysis),
|
|
"phase2_candidates": [],
|
|
}
|
|
analysis_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return result
|
|
|
|
|
|
def load_analysis_for_project(project_id: str) -> dict[str, Any] | None:
|
|
if project_id == "sample":
|
|
return load_analysis()
|
|
analysis_path = get_project_storage_dir(project_id) / "processed" / "analysis.json"
|
|
if not analysis_path.exists():
|
|
return None
|
|
return json.loads(analysis_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
_GROUND_CELL_SIZE = 2.0 # meters: grid resolution for min-Z surface
|
|
_GROUND_HEIGHT_THRESH = 1.5 # meters: max height above local min to keep as ground
|
|
|
|
|
|
def create_ground_filter_cache(project_id: str) -> dict[str, Any]:
|
|
"""Grid min-Z 지면 필터: 2m 격자로 최저점 표면을 구성하고 1.5m 이내 포인트만 지면으로 분류.
|
|
|
|
단일 반사(single-return) 스캔에도 동작하며 last-return에 의존하지 않는다.
|
|
PMF(Progressive Morphological Filter) 단순화 버전.
|
|
"""
|
|
storage_dir = get_project_storage_dir(project_id)
|
|
processed_dir = storage_dir / "processed"
|
|
processed_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
all_points_path = processed_dir / "all-points.json"
|
|
ground_points_path = processed_dir / "ground-points.json"
|
|
|
|
source_dir = get_project_source_dir(project_id)
|
|
las_path = find_las_in_dir(source_dir)
|
|
if not las_path.exists():
|
|
raise FileNotFoundError(f"LAS 파일을 찾을 수 없습니다: {project_id}")
|
|
|
|
max_points = 5_000_000
|
|
rng_np = np.random.default_rng(42)
|
|
|
|
# 헤더에서 공간 범위 획득
|
|
with laspy.open(las_path) as las_file:
|
|
header = las_file.header
|
|
total_points = int(header.point_count)
|
|
x_min_g = float(header.mins[0])
|
|
y_min_g = float(header.mins[1])
|
|
x_max_g = float(header.maxs[0])
|
|
y_max_g = float(header.maxs[1])
|
|
z_min_g = float(header.mins[2])
|
|
bounds = {
|
|
"x": [x_min_g, x_max_g],
|
|
"y": [y_min_g, y_max_g],
|
|
"z": [z_min_g, float(header.maxs[2])],
|
|
}
|
|
|
|
cs = _GROUND_CELL_SIZE
|
|
grid_w = int(np.ceil((x_max_g - x_min_g) / cs)) + 2
|
|
grid_h = int(np.ceil((y_max_g - y_min_g) / cs)) + 2
|
|
min_z_grid = np.full((grid_h, grid_w), np.inf, dtype=np.float32)
|
|
|
|
# Pass 1: 격자별 최저 Z 계산
|
|
with laspy.open(las_path) as las_file:
|
|
for chunk in las_file.chunk_iterator(500_000):
|
|
xs = np.asarray(chunk.x, dtype=np.float32)
|
|
ys = np.asarray(chunk.y, dtype=np.float32)
|
|
zs = np.asarray(chunk.z, dtype=np.float32)
|
|
gx = np.clip(((xs - x_min_g) / cs).astype(np.int32), 0, grid_w - 1)
|
|
gy = np.clip(((ys - y_min_g) / cs).astype(np.int32), 0, grid_h - 1)
|
|
np.minimum.at(min_z_grid, (gy, gx), zs)
|
|
|
|
# 비어있는 셀은 전체 최저 Z로 대체 (보수적 처리: 해당 셀 포인트가 지면에 가깝다면 통과)
|
|
min_z_grid[min_z_grid == np.inf] = z_min_g
|
|
|
|
# 선택적: scipy로 3×3 최솟값 확산 (설치된 경우)
|
|
try:
|
|
from scipy.ndimage import minimum_filter as _mf
|
|
min_z_grid = _mf(min_z_grid, size=3).astype(np.float32)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Pass 2: 높이 기준 지면 포인트 수집
|
|
ground_chunks: list[np.ndarray] = []
|
|
ground_total = 0
|
|
|
|
with laspy.open(las_path) as las_file:
|
|
for chunk in las_file.chunk_iterator(500_000):
|
|
xs = np.asarray(chunk.x, dtype=np.float32)
|
|
ys = np.asarray(chunk.y, dtype=np.float32)
|
|
zs = np.asarray(chunk.z, dtype=np.float32)
|
|
gx = np.clip(((xs - x_min_g) / cs).astype(np.int32), 0, grid_w - 1)
|
|
gy = np.clip(((ys - y_min_g) / cs).astype(np.int32), 0, grid_h - 1)
|
|
height_above = zs - min_z_grid[gy, gx]
|
|
mask = (height_above >= 0.0) & (height_above <= _GROUND_HEIGHT_THRESH)
|
|
if mask.any():
|
|
ground_chunks.append(np.stack([xs[mask], ys[mask], zs[mask]], axis=1))
|
|
ground_total += int(mask.sum())
|
|
|
|
if ground_chunks:
|
|
ground_all = np.concatenate(ground_chunks)
|
|
if len(ground_all) > max_points:
|
|
indices = rng_np.choice(len(ground_all), max_points, replace=False)
|
|
ground_sample = ground_all[indices]
|
|
else:
|
|
ground_sample = ground_all
|
|
rounded = np.round(ground_sample, 3)
|
|
ground_points_list: list[list[float]] = rounded.tolist()
|
|
else:
|
|
ground_points_list = []
|
|
|
|
# all-points: 기존 샘플 파일 재사용, 없으면 새로 생성
|
|
existing_sample = processed_dir / "las-points-sample.json"
|
|
if existing_sample.exists():
|
|
all_result = json.loads(existing_sample.read_text(encoding="utf-8"))
|
|
else:
|
|
all_result = sample_las_points(las_path, output_path=existing_sample)
|
|
all_points_path.write_text(json.dumps(all_result, ensure_ascii=False), encoding="utf-8")
|
|
|
|
ground_result = {
|
|
"source_point_count": ground_total,
|
|
"requested_ratio": 1.0,
|
|
"target_point_count": min(max_points, ground_total),
|
|
"sample_point_count": len(ground_points_list),
|
|
"sampling": "last_return_then_random",
|
|
"browser_limit_applied": ground_total > max_points,
|
|
"filter": "last_return",
|
|
"bounds": bounds,
|
|
"points": ground_points_list,
|
|
}
|
|
ground_points_path.write_text(json.dumps(ground_result, ensure_ascii=False), encoding="utf-8")
|
|
|
|
return {
|
|
"status": "done",
|
|
"total_points": total_points,
|
|
"ground_points": ground_total,
|
|
"removed_points": total_points - ground_total,
|
|
"filter_method": f"grid_min_z (cell={_GROUND_CELL_SIZE}m, thresh={_GROUND_HEIGHT_THRESH}m)",
|
|
}
|
|
|
|
|
|
def confirm_sample_release() -> dict[str, Any]:
|
|
analysis = load_analysis() or analyze_sample_project()
|
|
release_dir = SAMPLE_STORAGE_DIR / "exports"
|
|
release_dir.mkdir(parents=True, exist_ok=True)
|
|
release_id = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
release_path = release_dir / f"release-{release_id}.json"
|
|
release = {
|
|
"release_id": release_id,
|
|
"created_at": utc_now(),
|
|
"source_analysis": str(ANALYSIS_PATH.relative_to(ROOT_DIR)),
|
|
"data": analysis,
|
|
}
|
|
release_path.write_text(json.dumps(release, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return {
|
|
"release_id": release_id,
|
|
"path": str(release_path.relative_to(ROOT_DIR)),
|
|
"status": "created",
|
|
}
|