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