Files
Aislo/0_old/backend/app/analyzer.py
T

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",
}