diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py index 50319d5f..67336adb 100644 --- a/B03_FileInput/B03_FileInput_Engine_Analyze.py +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -1,6 +1,8 @@ """B03 원본 입력 파일 메타데이터 분석.""" +import base64 import csv +import io import logging import math import re @@ -11,6 +13,7 @@ from typing import Any import laspy import numpy as np import rasterio +from PIL import Image from pyproj import CRS logger = logging.getLogger(__name__) @@ -136,6 +139,11 @@ def _prepare_prj_wkt(text: str) -> tuple[str, list[str]]: return _CUSTOM_VERTICAL_AUTHORITY_PATTERN.sub("", text), codes +# 카드 미리보기 점 그림의 점 수 상한과, 분류가 없는 파일에서 볼 청크 수 (2026-09-04). +_LAS_PREVIEW_MAX_POINTS = 5000 +_LAS_PREVIEW_MAX_CHUNKS = 3 + + def analyze_las_metadata(path: str | Path) -> dict[str, Any]: """LAS/LAZ 헤더와 분류 통계를 메모리에 전체 적재하지 않고 분석한다.""" source = Path(path) @@ -170,6 +178,22 @@ def analyze_las_metadata(path: str | Path) -> dict[str, Any]: "has_return_number": "return_number" in dimension_names, } + # 카드 미리보기용 탑뷰 점 그림 (2026-09-04 사용자 지시). + # 분류 통계를 훑는 **그 길에** XY 를 성기게 주워 둔다 — 파일을 다시 읽지 않으므로 + # 업로드 시간이 늘지 않는다. 분류가 없어 훑지 않는 파일만 앞 몇 청크를 본다. + preview_points: list[list[int]] = [] + stride = max(1, point_count // _LAS_PREVIEW_MAX_POINTS) if point_count else 1 + + def _collect(chunk: Any) -> None: + if len(preview_points) >= _LAS_PREVIEW_MAX_POINTS: + return + xs = np.asarray(chunk.x, dtype=np.float64)[::stride] + ys = np.asarray(chunk.y, dtype=np.float64)[::stride] + room = _LAS_PREVIEW_MAX_POINTS - len(preview_points) + # 카드 안 작은 그림이라 1m 눈금이면 충분하다 — 정수로 줄여 저장 용량을 아낀다. + for x, y in zip(xs[:room].tolist(), ys[:room].tolist(), strict=True): + preview_points.append([round(x), round(y)]) + if metadata["has_classification"] and point_count > 0: classification_counts: dict[int, int] = {} for chunk in las_file.chunk_iterator(500_000): @@ -179,9 +203,22 @@ def analyze_las_metadata(path: str | Path) -> dict[str, Any]: ) for value, count in zip(values.tolist(), counts.tolist(), strict=True): classification_counts[value] = classification_counts.get(value, 0) + count + _collect(chunk) metadata["classification_summary"] = { str(key): value for key, value in sorted(classification_counts.items()) } + elif point_count > 0: + # 분류가 없으면 전 점을 훑을 이유가 없다 — 앞 몇 청크만 보고 끝낸다. + for index, chunk in enumerate(las_file.chunk_iterator(500_000)): + _collect(chunk) + if ( + index + 1 >= _LAS_PREVIEW_MAX_CHUNKS + or len(preview_points) >= _LAS_PREVIEW_MAX_POINTS + ): + break + + if preview_points: + metadata["preview_points"] = preview_points return metadata @@ -247,6 +284,43 @@ def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]: } +# 카드 미리보기 썸네일 한 변 크기(px) — 128 이면 카드 폭에서 충분히 읽힌다 (2026-09-04). +_TIF_THUMBNAIL_PX = 128 + + +def _geotiff_thumbnail(dataset: Any) -> str | None: + """저해상 흑백 썸네일을 data URL(PNG) 로 만든다. 만들 수 없으면 None. + + 오버뷰(피라미드)가 있는 파일만 대상으로 한다 — 오버뷰가 없으면 원본을 훑어 축소해야 + 해서 업로드가 눈에 띄게 느려진다(사용자 제약 「오래 걸리면 안 됨」). + """ + try: + if not any(dataset.overviews(index) for index in dataset.indexes): + return None + band = dataset.read( + 1, + out_shape=(_TIF_THUMBNAIL_PX, _TIF_THUMBNAIL_PX), + masked=True, + ) + finite = band.compressed() + if finite.size == 0: + return None + low = float(np.percentile(finite, 2)) + high = float(np.percentile(finite, 98)) + if high <= low: + return None + # 2~98 백분위로 늘려 대비를 준다 — DEM 은 값 폭이 좁아 그냥 펴면 밋밋하다. + scaled = np.clip((band.filled(low) - low) / (high - low), 0.0, 1.0) + grey = (scaled * 255).astype(np.uint8) + image = Image.fromarray(grey, mode="L") + buffer = io.BytesIO() + image.save(buffer, format="PNG", optimize=True) + return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") + except Exception as exc: # 썸네일은 곁가지다 — 실패해도 분석을 막지 않는다. + logger.warning("GeoTIFF 썸네일 생성 실패: %s", exc) + return None + + def analyze_tif_metadata(path: str | Path) -> dict[str, Any]: """TIF/GeoTIFF 데이터셋의 공간 및 밴드 메타데이터를 분석한다.""" source = Path(path) @@ -261,6 +335,9 @@ def analyze_tif_metadata(path: str | Path) -> dict[str, Any]: bounds = dataset.bounds return { "file": source.name, + # 카드 미리보기용 흑백 썸네일 (2026-09-04 사용자 지시). + # **오버뷰가 있을 때만** 만든다 — 없는 큰 파일을 축소 읽으면 수 초가 걸린다. + "preview_thumbnail": _geotiff_thumbnail(dataset), "width": int(dataset.width), "height": int(dataset.height), "count": int(dataset.count), @@ -390,6 +467,58 @@ def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]: } +def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]: + """DBF 머리글만 읽어 레코드 수·속성 수를 낸다 (2026-09-04 카드 표시용). + + 머리글 32바이트가 전부라 파일 크기와 무관하게 즉시 끝난다. + """ + source = Path(path) + head = source.read_bytes()[:32] + metadata: dict[str, Any] = { + "file": source.name, + "extension": "dbf", + "size_bytes": source.stat().st_size, + } + if len(head) >= 12: + record_count = int.from_bytes(head[4:8], "little") + header_length = int.from_bytes(head[8:10], "little") + metadata["record_count"] = record_count + # 머리글 = 32바이트 고정 + 속성마다 32바이트 + 끝 표시 1바이트. + metadata["field_count"] = max(0, (header_length - 33) // 32) + return metadata + + +def analyze_shx_metadata(path: str | Path) -> dict[str, Any]: + """SHX 머리글에서 도형 개수를 센다 (2026-09-04 카드 표시용). + + SHX 는 도형마다 8바이트 색인이 한 줄씩이라 파일 길이로 개수가 나온다. + """ + source = Path(path) + head = source.read_bytes()[:100] + metadata: dict[str, Any] = { + "file": source.name, + "extension": "shx", + "size_bytes": source.stat().st_size, + } + if len(head) >= 28: + # 24~27바이트: 파일 길이(16비트 워드 단위, 빅엔디안). + words = int.from_bytes(head[24:28], "big") + metadata["shape_count"] = max(0, (words * 2 - 100) // 8) + return metadata + + +def analyze_cpg_metadata(path: str | Path) -> dict[str, Any]: + """CPG 는 인코딩 이름 한 줄이 전부다 (2026-09-04 카드 표시용).""" + source = Path(path) + text = source.read_text(encoding="utf-8", errors="replace").strip() + return { + "file": source.name, + "extension": "cpg", + "size_bytes": source.stat().st_size, + "encoding": text or None, + } + + def analyze_input_metadata(path: str | Path) -> dict[str, Any]: """입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다.""" source = Path(path) @@ -406,6 +535,13 @@ def analyze_input_metadata(path: str | Path) -> dict[str, Any]: return analyze_prj_metadata(source) if extension == ".tfw": return analyze_tfw_metadata(source) + # 부속 파일도 카드에 「구분되는 값」을 보여 준다 (2026-09-04 사용자 지시). + if extension == ".dbf": + return analyze_dbf_metadata(source) + if extension == ".shx": + return analyze_shx_metadata(source) + if extension == ".cpg": + return analyze_cpg_metadata(source) if extension in {".tif", ".tiff"}: return analyze_tif_metadata(source) return { diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 281ecb26..6a431bef 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -243,6 +243,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const terrain = terrainCoverage(); renderSlotPreview(preview, { metadata: state.serverUploaded?.metadata, + // 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시). + completed: Boolean(state.serverUploaded), terrainExtent: ROUTE_SLOTS.includes(slot) ? terrain.extent : null, terrainCrs: terrain.crs, crsFallback: readCrsLabel(slots.get("route_prj")?.serverUploaded?.metadata), diff --git a/B03_FileInput/B03_FileInput_UI_Preview.ts b/B03_FileInput/B03_FileInput_UI_Preview.ts index 4c893c10..6c60af26 100644 --- a/B03_FileInput/B03_FileInput_UI_Preview.ts +++ b/B03_FileInput/B03_FileInput_UI_Preview.ts @@ -21,9 +21,11 @@ export interface PreviewExtent { } const SVG_NS = "http://www.w3.org/2000/svg"; -const VIEW_WIDTH = 100; -const VIEW_HEIGHT = 60; -const PADDING = 4; +// 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가 파일마다 +// 들쭉날쭉하지 않게. 실제 폭은 CSS 가 100% 로 늘리고, 이 값들은 좌표계 기준일 뿐이다. +const VIEW_WIDTH = 320; +const VIEW_HEIGHT = 120; +const PADDING = 6; function finite(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; @@ -75,6 +77,63 @@ export function readPreviewPath( return parts.length ? parts : null; } +/** 라이다 분석기가 훑는 길에 주워 둔 탑뷰 점(없으면 null). */ +export function readPreviewPoints( + metadata: Record | undefined, +): number[][] | null { + const raw = metadata?.preview_points; + if (!Array.isArray(raw) || raw.length === 0) return null; + const points: number[][] = []; + for (const point of raw) { + if (!Array.isArray(point)) continue; + const x = finite(point[0]); + const y = finite(point[1]); + if (x !== null && y !== null) points.push([x, y]); + } + return points.length ? points : null; +} + +/** GeoTIFF 저해상 썸네일(data URL). 오버뷰가 없는 파일은 만들지 않아 null 이다. */ +export function readThumbnail(metadata: Record | undefined): string | null { + const raw = metadata?.preview_thumbnail; + return typeof raw === "string" && raw.startsWith("data:image/") ? raw : null; +} + +/** + * 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **한눈에 구분되는 값**을 뽑는다 + * (2026-09-04 사용자 지시). 그림 대신 이 값을 큰 글씨로 보인다. + */ +export function readFacts(metadata: Record | undefined): string[] { + if (!metadata) return []; + const facts: string[] = []; + const number = (value: unknown): number | null => finite(value); + const text = (value: unknown): string | null => + typeof value === "string" && value.trim() ? value.trim() : null; + + const shapes = number(metadata.shape_count); + if (shapes !== null) facts.push(`도형 ${shapes.toLocaleString()}개`); + const records = number(metadata.record_count); + if (records !== null) facts.push(`레코드 ${records.toLocaleString()}개`); + const fields = number(metadata.field_count); + if (fields !== null) facts.push(`속성 ${fields}개`); + const encoding = text(metadata.encoding); + if (encoding) facts.push(`인코딩 ${encoding}`); + const pixelX = number(metadata.pixel_size_x); + const pixelY = number(metadata.pixel_size_y); + if (pixelX !== null && pixelY !== null) { + facts.push(`픽셀 ${Math.abs(pixelX)}×${Math.abs(pixelY)} m`); + const rotated = + (number(metadata.rotation_x) ?? 0) !== 0 || (number(metadata.rotation_y) ?? 0) !== 0; + facts.push(rotated ? "회전 있음" : "회전 없음"); + } + // PRJ 는 좌표계 이름이 곧 구분값이다. + const name = text(metadata.name); + const epsgLabel = readCrsLabel(metadata); + if (name) facts.push(epsgLabel ? `${name} (EPSG:${epsgLabel})` : name); + else if (epsgLabel && facts.length === 0) facts.push(`EPSG:${epsgLabel}`); + return facts.slice(0, 2); +} + /** * 좌표계 코드(EPSG 숫자). 서로 다르면 범위 대조를 하지 않는다. * 분석기마다 숫자(`5176`)로도, 문자열(`"EPSG:5176"`)로도 온다 — 숫자만 뽑아 맞춘다. @@ -113,6 +172,8 @@ function projector(extent: PreviewExtent): (x: number, y: number) => [number, nu export interface PreviewOptions { metadata?: Record; + /** 업로드·분석이 끝난 카드인지 — 끝난 카드에만 「값 없음」 안내를 보인다. */ + completed?: boolean; /** 지형 자료 범위 — 계획노선 카드에서 「범위 안인가」를 대조할 때만 쓴다. */ terrainExtent?: PreviewExtent | null; /** 지형 자료 좌표계 — 노선과 다르면 대조를 생략한다(재투영은 하지 않는다). */ @@ -133,9 +194,47 @@ export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): v host.removeAttribute("title"); const extent = readExtent(options.metadata); - if (!extent) return; + if (!extent) { + // 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로 보인다 (2026-09-04 사용자 지시). + const facts = readFacts(options.metadata); + if (facts.length === 0) { + if (!options.completed) return; + const empty = document.createElement("div"); + empty.className = "b03-file__preview-empty"; + empty.textContent = "보여 줄 값이 없음"; + host.append(empty); + host.classList.add("is-visible"); + return; + } + const box = document.createElement("div"); + box.className = "b03-file__preview-facts"; + facts.forEach((fact, index) => { + const line = document.createElement("span"); + line.className = index === 0 ? "b03-file__preview-fact" : "b03-file__preview-fact-sub"; + line.textContent = fact; + box.append(line); + }); + host.title = facts.join(" · "); + host.append(box); + host.classList.add("is-visible"); + return; + } + + // GeoTIFF 썸네일이 있으면 그림 그대로 보인다(오버뷰가 있는 파일만 만들어진다). + const thumbnail = readThumbnail(options.metadata); + if (thumbnail) { + const image = document.createElement("img"); + image.className = "b03-file__preview-thumb"; + image.src = thumbnail; + image.alt = L("B03_File_Preview_Extent"); + host.title = L("B03_File_Preview_Extent"); + host.append(image); + host.classList.add("is-visible"); + return; + } const path = readPreviewPath(options.metadata); + const cloud = readPreviewPoints(options.metadata); const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("viewBox", `0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`); svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); @@ -154,6 +253,22 @@ export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): v svg.append(rect); let label = L("B03_File_Preview_Extent"); + // 라이다 탑뷰 점 그림 — 분석기가 훑는 길에 주워 둔 XY 를 그대로 찍는다. + if (cloud) { + label = `점 ${cloud.length.toLocaleString()}개 (솎은 탑뷰)`; + const dots = document.createElementNS(SVG_NS, "path"); + dots.setAttribute("class", "b03-file__preview-cloud"); + dots.setAttribute( + "d", + cloud + .map(([x, y]) => { + const [sx, sy] = project(x, y); + return `M${sx.toFixed(1)} ${sy.toFixed(1)}h0.7`; + }) + .join(""), + ); + svg.append(dots); + } if (path) { label = L("B03_File_Preview_Route"); for (const part of path) { diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index b0e1e9ef..254f9634 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -603,14 +603,68 @@ margin-top: 6px; } +/* 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가 + 파일마다 들쭉날쭉하지 않게. 그림·썸네일·값 표시가 모두 같은 상자를 쓴다. */ .b03-file__preview.is-visible { - display: block; + display: flex; + grid-column: 1 / -1; + align-items: center; + justify-content: center; + width: 100%; + height: 120px; + overflow: hidden; + border: 1px solid var(--color-border, #dcdce6); + border-radius: var(--radius-cards, 8px); + background: var(--color-surface, #fff); } .b03-file__preview svg { display: block; width: 100%; - height: 60px; + height: 100%; +} + +/* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */ +.b03-file__preview-cloud { + fill: none; + stroke: var(--color-text-muted, #5a5a72); + stroke-linecap: round; + stroke-opacity: 0.55; + stroke-width: 0.7; +} + +/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. */ +.b03-file__preview-thumb { + max-width: 100%; + max-height: 100%; + image-rendering: pixelated; + object-fit: contain; +} + +/* 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로. */ +.b03-file__preview-facts { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px; + text-align: center; +} + +.b03-file__preview-fact { + color: var(--color-text, #1c1c28); + font-size: var(--text-body, 15px); + font-weight: 600; +} + +.b03-file__preview-fact-sub { + color: var(--color-text-muted, #5a5a72); + font-size: var(--text-caption, 12px); +} + +.b03-file__preview-empty { + color: var(--color-text-muted, #8a8aa0); + font-size: var(--text-caption, 12px); } .b03-file__preview-extent {