Files
s-canvas/detail_parser.py
HYUNJUNGLEE b9342f6726 Import S-CANVAS source + iter=1~7 lint cleanup
S-CANVAS (Saman Corp.) — DXF + DEM + AI 기반 3D 조감도 생성 엔진.
~24k LOC Python (scanvas_maker.py 7072 LOC GUI + 구조물 파서/빌더 다수).

이 커밋은 7-iter cleanup이 적용된 상태로 import:
- F821 8 + B023 6: 비동기 lambda + except/loop 변수 캡처 NameError
  (Py3.13에서 reproduce 확인된 진짜 버그)
- RUF012 4 + RUF013 1: ClassVar / implicit Optional 명시화
- F811/B905/B904/F401/F841/W293/F541/UP/SIM/RUF/PLR 700+ cleanup/modernization

신규 파일:
- ruff.toml: target=py313, Korean unicode/저자 스타일/도메인 복잡도 무력화
- requirements-py313.txt: pyproj>=3.7, scipy>=1.14, numpy>=2.0.2 (Py3.13 wheel)
- .gitignore: gcp-key.json, 캐시, 백업, 생성 이미지 제외

검증: ruff 0 errors, py_compile 0 errors, import 33/33 OK on Py3.13.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:29:08 +09:00

552 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""S-CANVAS 구조물 상세도면 DXF 치수 파서.
상세도면(단면도/상세도)에서 TEXT, MTEXT, DIMENSION, ATTRIB 엔티티를 분석하여
구조물의 높이, 폭, 두께, 계획고, 관경, 사면경사 등 설계 치수를 자동 추출한다.
사용법:
parser = DetailParser()
result = parser.parse(dxf_path)
# result.dimensions → [ParsedDimension(...), ...]
# result.summary() → {"height": 5.0, "width": 3.0, ...}
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
import ezdxf
import numpy as np
# ---------------------------------------------------------------------------
# 데이터 클래스
# ---------------------------------------------------------------------------
@dataclass
class ParsedDimension:
"""파싱된 개별 치수 항목."""
param: str # height, width, thickness, elevation, diameter, slope, length, ...
value: float # 수치 값 (미터 단위로 정규화)
raw_text: str # 원본 텍스트
source: str # "text" | "mtext" | "dimension" | "attrib"
layer: str # DXF 레이어명
position: tuple # (x, y) 좌표
confidence: float # 0.0 ~ 1.0 신뢰도
unit: str = "m" # 단위 (m, mm)
secondary: float | None = None # 보조값 (slope의 경우 비율 등)
@dataclass
class ParseResult:
"""전체 파싱 결과."""
dxf_path: str
dimensions: list[ParsedDimension] = field(default_factory=list)
layer_names: list[str] = field(default_factory=list)
entity_summary: dict = field(default_factory=dict) # {entity_type: count}
def summary(self) -> dict:
"""파라미터별 최고 신뢰도 값을 딕셔너리로 반환.
Returns:
{"height": 5.0, "width": 3.0, "elevation": 85.0, ...}
"""
best: dict[str, ParsedDimension] = {}
for d in self.dimensions:
if d.param not in best or d.confidence > best[d.param].confidence:
best[d.param] = d
return {k: v.value for k, v in best.items()}
def by_param(self, param: str) -> list[ParsedDimension]:
"""특정 파라미터의 모든 파싱 결과를 신뢰도 내림차순으로 반환."""
return sorted(
[d for d in self.dimensions if d.param == param],
key=lambda d: -d.confidence,
)
def all_params(self) -> list[str]:
"""발견된 모든 파라미터 종류."""
return sorted(set(d.param for d in self.dimensions))
# ---------------------------------------------------------------------------
# 패턴 정의
# ---------------------------------------------------------------------------
# AutoCAD 특수문자: %%C = Φ (파이), %%D = ° (도), %%P = ± (플마)
_AUTOCAD_PHI = r"%%[cC]"
# 토목 도면 치수 패턴 (한글/영문 혼용)
_PATTERNS: list[tuple[str, str, re.Pattern, float]] = [
# (param, description, compiled_pattern, base_confidence)
# --- 계획고 (elevation) ---
("elevation", "EL.xxx.xx",
re.compile(r"EL\.?\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE), 0.95),
("elevation", "표고 xxx.x",
re.compile(r"(?:표고|계획고|설계고)\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE), 0.90),
# --- 높이 (height) ---
("height", "H=xxx",
re.compile(r"[Hh]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
("height", "높이=xxx",
re.compile(r"(?:높이|전고|벽고|壁高)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
("height", "h xxx.x m",
re.compile(r"(?:^|\s)[Hh]\s+(\d+\.?\d*)\s*[mM](?:\s|$)"), 0.75),
# --- 폭 (width) ---
("width", "W=xxx 또는 B=xxx",
re.compile(r"[WwBb]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
("width", "폭=xxx",
re.compile(r"(?:폭|너비|幅|底幅|천단폭)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
# --- 두께 (thickness) ---
("thickness", "T=xxx 또는 t=xxx",
re.compile(r"[Tt]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.85),
("thickness", "두께=xxx",
re.compile(r"(?:두께|벽두께|슬래브두께)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
# --- 길이 (length) ---
("length", "L=xxx",
re.compile(r"[Ll]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.85),
("length", "길이=xxx 또는 연장=xxx",
re.compile(r"(?:길이|연장|延長|총연장)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
# --- 관경/직경 (diameter) ---
("diameter", "%%Cxxx (AutoCAD Φ기호)",
re.compile(r"%%[cC]\s*(\d+\.?\d*)"), 0.95),
("diameter", "D=xxx 또는 Φxxx",
re.compile(r"(?:[Dd]|[ΦφΦ])\s*[=:]?\s*(\d+\.?\d*)\s*(?:mm|MM)?"), 0.85),
("diameter", "관경=xxx",
re.compile(r"(?:관경|내경|외경|직경)\s*[=:]?\s*(\d+\.?\d*)\s*(?:mm|MM|[mM])?"), 0.90),
# --- 사면 경사 (slope) ---
("slope", "1:N.N",
re.compile(r"(\d+)\s*:\s*(\d+\.?\d*)"), 0.90),
# --- 박스/U형 단면 (composite: width × height) ---
("_box", "BOX W×H",
re.compile(r"BOX\s*(\d+\.?\d*)\s*[xX×]\s*(\d+\.?\d*)", re.IGNORECASE), 0.95),
("_uchannel", "U W×H",
re.compile(r"U\s*(\d+\.?\d*)\s*[xX×]\s*(\d+\.?\d*)", re.IGNORECASE), 0.95),
# --- 반경 (radius) ---
("radius", "R=xxx",
re.compile(r"[Rr]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.80),
# --- 간격 (spacing) ---
("spacing", "@xxx",
re.compile(r"@\s*(\d+\.?\d*)"), 0.80),
# --- 근입깊이 (embedment depth) ---
("embedment", "근입=xxx",
re.compile(r"(?:근입|근입깊이|매입깊이)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
]
# ---------------------------------------------------------------------------
# 메인 파서
# ---------------------------------------------------------------------------
class DetailParser:
"""구조물 상세도면 DXF에서 설계 치수를 추출하는 파서."""
def __init__(self, unit_threshold_mm: float = 50.0):
"""
Args:
unit_threshold_mm: 이 값보다 큰 수치는 mm 단위로 간주하여 m로 변환.
관경(%%C300 등)은 별도 처리.
"""
self.unit_threshold = unit_threshold_mm
def parse(self, dxf_path: str | Path) -> ParseResult:
"""DXF 파일에서 치수를 파싱.
Args:
dxf_path: DXF 파일 경로
Returns:
ParseResult 객체
"""
dxf_path = Path(dxf_path)
doc = ezdxf.readfile(str(dxf_path))
msp = doc.modelspace()
result = ParseResult(dxf_path=str(dxf_path))
result.layer_names = [layer.dxf.name for layer in doc.layers]
# 엔티티 요약 수집
for entity in msp:
et = entity.dxftype()
result.entity_summary[et] = result.entity_summary.get(et, 0) + 1
# 1) TEXT / MTEXT 파싱
self._parse_text_entities(msp, result)
# 2) DIMENSION 엔티티 파싱
self._parse_dimension_entities(msp, result)
# 3) INSERT 블록 내 ATTRIB 파싱
self._parse_attrib_entities(msp, result)
return result
# ----- TEXT / MTEXT -----
def _parse_text_entities(self, msp, result: ParseResult):
"""TEXT, MTEXT 엔티티에서 패턴 매칭으로 치수 추출."""
for entity in msp:
etype = entity.dxftype()
if etype not in ("TEXT", "MTEXT"):
continue
try:
txt = entity.dxf.text.strip() if etype == "TEXT" else (entity.text or "").strip()
except Exception:
continue
if not txt:
continue
layer = entity.dxf.layer
try:
pos = entity.dxf.insert
position = (pos.x, pos.y)
except Exception:
position = (0.0, 0.0)
source = etype.lower()
self._match_patterns(txt, source, layer, position, result)
def _match_patterns(self, txt: str, source: str, layer: str,
position: tuple, result: ParseResult):
"""텍스트에 대해 모든 패턴을 매칭하고 결과를 추가."""
for param, _desc, pattern, base_conf in _PATTERNS:
m = pattern.search(txt)
if not m:
continue
groups = m.groups()
# --- 복합 치수 (BOX, U형) → width + height 분리 ---
if param == "_box":
w, h = float(groups[0]), float(groups[1])
w, w_unit = self._normalize_unit(w, param="width")
h, h_unit = self._normalize_unit(h, param="height")
result.dimensions.append(ParsedDimension(
param="width", value=w, raw_text=txt, source=source,
layer=layer, position=position, confidence=base_conf,
unit=w_unit,
))
result.dimensions.append(ParsedDimension(
param="height", value=h, raw_text=txt, source=source,
layer=layer, position=position, confidence=base_conf,
unit=h_unit,
))
return # 복합 패턴 매칭 시 개별 패턴 중복 방지
if param == "_uchannel":
w, h = float(groups[0]), float(groups[1])
w, w_unit = self._normalize_unit(w, param="width")
h, h_unit = self._normalize_unit(h, param="height")
result.dimensions.append(ParsedDimension(
param="width", value=w, raw_text=txt, source=source,
layer=layer, position=position, confidence=base_conf,
unit=w_unit,
))
result.dimensions.append(ParsedDimension(
param="height", value=h, raw_text=txt, source=source,
layer=layer, position=position, confidence=base_conf,
unit=h_unit,
))
return
# --- slope (1:N) → 특수 처리 ---
if param == "slope":
v1, v2 = float(groups[0]), float(groups[1])
# 사면 경사비가 아닌 좌표 구분자(218,200)와 혼동 방지
# 일반적인 경사비: 1:0.3 ~ 1:5.0
if v1 <= 2 and 0.1 <= v2 <= 10.0:
result.dimensions.append(ParsedDimension(
param="slope", value=v2, raw_text=txt, source=source,
layer=layer, position=position, confidence=base_conf,
unit="ratio", secondary=v1,
))
return # slope는 항상 여기서 종료 (다른 패턴과 중복 방지)
# --- 일반 단일값 ---
value = float(groups[0])
# 관경은 항상 mm 단위
if param == "diameter":
if value > self.unit_threshold:
value /= 1000.0
unit = "mm→m"
else:
unit = "m"
else:
value, unit = self._normalize_unit(value, param)
result.dimensions.append(ParsedDimension(
param=param, value=value, raw_text=txt, source=source,
layer=layer, position=position, confidence=base_conf,
unit=unit,
))
# ----- DIMENSION 엔티티 -----
def _parse_dimension_entities(self, msp, result: ParseResult):
"""DIMENSION 엔티티에서 치수 추출.
DIMENSION 엔티티는 defpoint 좌표와 actual_measurement를 직접 제공하므로
가장 신뢰도가 높다.
"""
for entity in msp:
if entity.dxftype() != "DIMENSION":
continue
layer = entity.dxf.layer
try:
# 실제 측정값 (ezdxf가 계산)
measurement = entity.dxf.get("actual_measurement", None)
if measurement is None:
continue
measurement = float(measurement)
if measurement <= 0:
continue
# 치수 방향 판별 (수직 → 높이, 수평 → 폭)
param = self._classify_dimension_direction(entity)
# 텍스트 오버라이드 확인
text_override = entity.dxf.get("text", "")
raw_text = text_override if text_override else f"[DIM]{measurement:.3f}"
# 위치: defpoint 중간점
try:
dp1 = entity.dxf.defpoint
dp2 = entity.dxf.defpoint2 if entity.dxf.hasattr("defpoint2") else dp1
position = ((dp1.x + dp2.x) / 2, (dp1.y + dp2.y) / 2)
except Exception:
position = (0.0, 0.0)
value, unit = self._normalize_unit(measurement, param)
result.dimensions.append(ParsedDimension(
param=param, value=value, raw_text=raw_text,
source="dimension", layer=layer, position=position,
confidence=0.95, unit=unit,
))
except Exception:
continue
def _classify_dimension_direction(self, dim_entity) -> str:
"""DIMENSION 엔티티의 방향을 분석하여 파라미터 종류를 추론.
수직(Y 차이 > X 차이) → height
수평(X 차이 > Y 차이) → width
"""
try:
dp1 = dim_entity.dxf.defpoint
dp2 = dim_entity.dxf.defpoint2 if dim_entity.dxf.hasattr("defpoint2") else dp1
dx = abs(dp2.x - dp1.x)
dy = abs(dp2.y - dp1.y)
if dy > dx * 1.5:
return "height"
elif dx > dy * 1.5:
return "width"
else:
return "length" # 대각선이면 길이로 분류
except Exception:
return "length"
# ----- ATTRIB (블록 속성) -----
def _parse_attrib_entities(self, msp, result: ParseResult):
"""INSERT 블록의 ATTRIB에서 치수 추출."""
# 속성 태그명 → 파라미터 매핑
tag_map = {
"HEIGHT": "height", "H": "height", "높이": "height",
"WIDTH": "width", "W": "width", "": "width", "B": "width",
"THICKNESS": "thickness", "T": "thickness", "두께": "thickness",
"ELEVATION": "elevation", "EL": "elevation", "표고": "elevation",
"DIAMETER": "diameter", "DIA": "diameter", "D": "diameter", "관경": "diameter",
"LENGTH": "length", "L": "length", "길이": "length", "연장": "length",
}
for entity in msp:
if entity.dxftype() != "INSERT":
continue
try:
attribs = list(entity.attribs) if hasattr(entity, "attribs") else []
except Exception:
continue
layer = entity.dxf.layer
try:
pos = entity.dxf.insert
position = (pos.x, pos.y)
except Exception:
position = (0.0, 0.0)
for attrib in attribs:
try:
tag = attrib.dxf.tag.strip().upper()
text = attrib.dxf.text.strip()
param = tag_map.get(tag)
if not param:
continue
# 숫자 추출
m = re.search(r"(\d+\.?\d*)", text)
if not m:
continue
value = float(m.group(1))
value, unit = self._normalize_unit(value, param)
result.dimensions.append(ParsedDimension(
param=param, value=value, raw_text=f"{tag}={text}",
source="attrib", layer=layer, position=position,
confidence=0.85, unit=unit,
))
except Exception:
continue
# ----- 유틸리티 -----
def _normalize_unit(self, value: float, param: str) -> tuple[float, str]:
"""값의 단위를 판단하여 미터로 정규화.
토목 도면 관례:
- 관경: 보통 mm (300, 600, 1000 등)
- 높이/폭/두께: 보통 m (0.5 ~ 30 정도)
- 계획고(EL): 항상 m (해발 기준)
"""
if param == "elevation":
# 계획고는 항상 m 단위 (음수 가능, 큰 값 가능)
return value, "m"
if param == "diameter":
if value > self.unit_threshold:
return value / 1000.0, "mm→m"
return value, "m"
# spacing(@간격)은 보통 mm
if param == "spacing":
if value > self.unit_threshold:
return value / 1000.0, "mm→m"
return value, "m"
# 일반 치수: 50 이상이면 mm로 간주
if value > self.unit_threshold:
return value / 1000.0, "mm→m"
return value, "m"
# ---------------------------------------------------------------------------
# 공간 연결: 텍스트 치수를 인근 지오메트리에 매칭
# ---------------------------------------------------------------------------
def associate_dimensions_to_structures(
parse_result: ParseResult,
structure_positions: dict[str, tuple[float, float]],
max_distance: float = 50.0,
) -> dict[str, list[ParsedDimension]]:
"""파싱된 치수를 인근 구조물에 공간적으로 연결.
Args:
parse_result: DetailParser.parse() 결과
structure_positions: {"구조물명": (x, y), ...} 구조물 중심 좌표
max_distance: 최대 연결 거리 (m)
Returns:
{"구조물명": [ParsedDimension, ...], ...}
"""
if not parse_result.dimensions or not structure_positions:
return {}
# 구조물 좌표 배열
names = list(structure_positions.keys())
positions = np.array([structure_positions[n] for n in names])
associated: dict[str, list[ParsedDimension]] = {n: [] for n in names}
for dim in parse_result.dimensions:
dim_pos = np.array(dim.position)
dists = np.linalg.norm(positions - dim_pos, axis=1)
min_idx = int(np.argmin(dists))
if dists[min_idx] <= max_distance:
associated[names[min_idx]].append(dim)
return associated
# ---------------------------------------------------------------------------
# 치수 → structure params 매핑
# ---------------------------------------------------------------------------
def dimensions_to_structure_params(
dimensions: list[ParsedDimension],
) -> dict:
"""파싱된 치수 목록을 structure_v1.yaml 호환 파라미터 딕셔너리로 변환.
Returns:
{"height": 5.0, "width": 3.0, "z_offset": -2.0, ...}
"""
# 파라미터별 최고 신뢰도 값 선택
best: dict[str, ParsedDimension] = {}
for d in dimensions:
if d.param not in best or d.confidence > best[d.param].confidence:
best[d.param] = d
params: dict = {}
if "height" in best:
params["height"] = best["height"].value
if "width" in best:
params["width"] = best["width"].value
if "thickness" in best:
params["thickness"] = best["thickness"].value
if "diameter" in best:
params["diameter"] = best["diameter"].value
if "length" in best:
params["length"] = best["length"].value
if "elevation" in best:
params["elevation"] = best["elevation"].value
if "slope" in best:
params["slope_ratio"] = best["slope"].value
if "embedment" in best:
params["embedment"] = best["embedment"].value
if "radius" in best:
params["radius"] = best["radius"].value
return params