feat(B03): 파일 카드 미리보기 확대 — 전폭·고정 높이, 라이다 점 그림·GeoTIFF 썸네일
- 미리보기를 카드 한 행 전폭·높이 120px 고정으로 키움. - 라이다: 분류 통계를 훑는 그 길에 XY 를 성기게 주워 탑뷰 점 그림(상한 5천 점, 파일 재열람 없음). 분류가 없는 파일은 앞 3청크만 봄. - GeoTIFF: 오버뷰가 있을 때만 128px 흑백 썸네일(2~98 백분위 대비), 없으면 종전 범위 사각형. - 부속 파일(.shx·.dbf·.cpg·.prj·.tfw)은 그림 대신 구분되는 값 표시 — 머리글만 읽는 가벼운 분석기 추가. - 값이 없는 완료 카드는 「보여 줄 값이 없음」 한 줄. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -243,6 +243,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
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),
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
|
||||
/** 업로드·분석이 끝난 카드인지 — 끝난 카드에만 「값 없음」 안내를 보인다. */
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user