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>
516 lines
16 KiB
Python
516 lines
16 KiB
Python
"""DXF 도면에서 뷰 영역(평면도/정면도/측면도/단면도) 자동 검출.
|
||
|
||
작동 원리:
|
||
1. DXF의 TEXT/MTEXT에서 뷰 라벨 패턴 매칭 ("평면도", "정면도" 등)
|
||
2. 라벨 주변의 사각형 프레임(closed LWPOLYLINE 4점) 검출
|
||
3. 라벨-사각형 매칭 (라벨이 사각형 안 또는 바로 위/아래)
|
||
4. 각 사각형 안의 지오메트리를 해당 뷰에 할당
|
||
|
||
사용법:
|
||
from view_detector import detect_view_regions
|
||
views = detect_view_regions("plan.dxf")
|
||
for v in views:
|
||
print(f"{v.view_type}: {v.label_text} ({len(v.shapes)} shapes)")
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import math
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
import ezdxf
|
||
|
||
from dxf_geometry import (
|
||
extract_structural_geometry,
|
||
GeometryResult,
|
||
Shape,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 뷰 타입 라벨 패턴
|
||
# ---------------------------------------------------------------------------
|
||
|
||
VIEW_LABEL_PATTERNS = {
|
||
"plan": [
|
||
r"평\s*면\s*도",
|
||
r"平\s*面\s*[圖図]",
|
||
r"\bPLAN\s*VIEW\b",
|
||
r"^PLAN$",
|
||
r"\bTOP\s*VIEW\b",
|
||
],
|
||
"front": [
|
||
r"정\s*면\s*도",
|
||
r"正\s*面\s*[圖図]",
|
||
r"\bFRONT\s*(?:VIEW|ELEVATION)\b",
|
||
r"\bELEVATION\b(?!\s*\.)", # "ELEVATION" but not "EL."
|
||
],
|
||
"side": [
|
||
r"측\s*면\s*도",
|
||
r"側\s*面\s*[圖図]",
|
||
r"\bSIDE\s*(?:VIEW|ELEVATION)\b",
|
||
],
|
||
"rear": [
|
||
r"배\s*면\s*도",
|
||
r"背\s*面\s*[圖図]",
|
||
r"\bREAR\s*VIEW\b",
|
||
r"\bBACK\s*VIEW\b",
|
||
],
|
||
"bottom": [
|
||
r"저\s*면\s*도",
|
||
r"底\s*面\s*[圖図]",
|
||
r"\bBOTTOM\s*VIEW\b",
|
||
],
|
||
"section": [
|
||
r"단\s*면\s*도",
|
||
r"斷\s*面\s*[圖図]",
|
||
r"\bSECTION\b",
|
||
r"\bSEC\.\s*[A-Z]",
|
||
r"[A-Z]\s*-\s*[A-Z]\s*단면",
|
||
],
|
||
"detail": [
|
||
r"상\s*세\s*도",
|
||
r"詳\s*細\s*[圖図]",
|
||
r"\bDETAIL\b",
|
||
],
|
||
"elevation_generic": [
|
||
r"입\s*면\s*도",
|
||
r"立\s*面\s*[圖図]",
|
||
],
|
||
"longitudinal": [
|
||
r"종\s*단\s*면\s*도",
|
||
r"종\s*단\s*면",
|
||
r"\bLONGITUDINAL\b",
|
||
],
|
||
"cross_section": [
|
||
r"횡\s*단\s*면\s*도",
|
||
r"횡\s*단\s*면",
|
||
r"\bCROSS\s*SECTION\b",
|
||
],
|
||
}
|
||
|
||
# 축척(S=1:N) 패턴
|
||
SCALE_PATTERN = re.compile(r"S\s*=?\s*1\s*[::]\s*(\d+)", re.IGNORECASE)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 데이터 구조
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@dataclass
|
||
class ViewRegion:
|
||
"""검출된 뷰 영역 하나."""
|
||
view_type: str # "plan" | "front" | "side" | ...
|
||
label_text: str # 원문 라벨
|
||
label_pos: tuple # (x, y) in m
|
||
bounds: tuple # (xmin, ymin, xmax, ymax) in m
|
||
shapes: list # 이 뷰 안의 Shape 목록
|
||
scale_hint: str = "" # "1:100" 등
|
||
scale_value: int | None = None # 100 (축척 분모)
|
||
has_frame: bool = True # 사각형 프레임이 명시적으로 있는지
|
||
|
||
@property
|
||
def view_type_ko(self) -> str:
|
||
mapping = {
|
||
"plan": "평면도", "front": "정면도", "side": "측면도",
|
||
"rear": "배면도", "bottom": "저면도", "section": "단면도",
|
||
"detail": "상세도", "elevation_generic": "입면도",
|
||
"longitudinal": "종단면도", "cross_section": "횡단면도",
|
||
}
|
||
return mapping.get(self.view_type, self.view_type)
|
||
|
||
@property
|
||
def width(self) -> float:
|
||
return self.bounds[2] - self.bounds[0]
|
||
|
||
@property
|
||
def height(self) -> float:
|
||
return self.bounds[3] - self.bounds[1]
|
||
|
||
@property
|
||
def center(self) -> tuple:
|
||
return ((self.bounds[0] + self.bounds[2]) / 2,
|
||
(self.bounds[1] + self.bounds[3]) / 2)
|
||
|
||
def get_local_shapes(self) -> list:
|
||
"""뷰 내 지오메트리를 뷰 좌표(bbox 좌하단을 원점)로 변환한 복사본."""
|
||
ox, oy = self.bounds[0], self.bounds[1]
|
||
out = []
|
||
for s in self.shapes:
|
||
new_pts = [(p[0] - ox, p[1] - oy) for p in s.points]
|
||
new_shape = Shape(
|
||
kind=s.kind, layer=s.layer, points=new_pts,
|
||
closed=s.closed, extra=dict(s.extra),
|
||
)
|
||
out.append(new_shape)
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 라벨 검출
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def classify_view_label(text: str) -> str | None:
|
||
"""텍스트가 뷰 라벨인지 확인하고 타입 반환."""
|
||
# 매우 긴 텍스트는 라벨이 아닐 가능성 (NOTES 등)
|
||
if len(text) > 30:
|
||
return None
|
||
|
||
for view_type, patterns in VIEW_LABEL_PATTERNS.items():
|
||
for pat in patterns:
|
||
if re.search(pat, text, re.IGNORECASE):
|
||
return view_type
|
||
return None
|
||
|
||
|
||
def extract_scale(text: str) -> tuple[str, int | None]:
|
||
"""텍스트에서 축척 추출 (e.g., 'S=1:100')."""
|
||
m = SCALE_PATTERN.search(text)
|
||
if m:
|
||
return f"1:{m.group(1)}", int(m.group(1))
|
||
return "", None
|
||
|
||
|
||
def collect_view_labels(dxf_path: str, unit_scale: float) -> list[dict]:
|
||
"""DXF의 TEXT/MTEXT 중 뷰 라벨을 모두 수집.
|
||
|
||
Returns:
|
||
[{"text": str, "view_type": str, "pos": (x, y),
|
||
"scale_hint": str, "scale_value": int}, ...]
|
||
"""
|
||
doc = ezdxf.readfile(dxf_path)
|
||
msp = doc.modelspace()
|
||
|
||
labels = []
|
||
# 축척이 라벨 바로 아래에 따로 배치된 경우 대비해서 모든 TEXT 저장
|
||
all_texts = []
|
||
|
||
for e in msp:
|
||
et = e.dxftype()
|
||
if et not in ("TEXT", "MTEXT"):
|
||
continue
|
||
try:
|
||
txt = e.dxf.text if et == "TEXT" else (e.text or "")
|
||
txt = txt.strip()
|
||
if not txt:
|
||
continue
|
||
pos = e.dxf.insert
|
||
x = pos.x * unit_scale
|
||
y = pos.y * unit_scale
|
||
all_texts.append({"text": txt, "pos": (x, y)})
|
||
except Exception:
|
||
continue
|
||
|
||
for item in all_texts:
|
||
txt = item["text"]
|
||
view_type = classify_view_label(txt)
|
||
if view_type is None:
|
||
continue
|
||
|
||
# 축척: 라벨 텍스트 안에서 찾기
|
||
scale_hint, scale_val = extract_scale(txt)
|
||
|
||
# 축척이 없으면 근처 텍스트에서 찾기 (30m 이내 아래쪽)
|
||
if not scale_hint:
|
||
lx, ly = item["pos"]
|
||
for other in all_texts:
|
||
ox, oy = other["pos"]
|
||
if abs(ox - lx) < 15.0 and -15.0 < (oy - ly) < -0.5:
|
||
# 라벨 바로 아래 15m 이내
|
||
sh, sv = extract_scale(other["text"])
|
||
if sh:
|
||
scale_hint, scale_val = sh, sv
|
||
break
|
||
|
||
labels.append({
|
||
"text": txt,
|
||
"view_type": view_type,
|
||
"pos": item["pos"],
|
||
"scale_hint": scale_hint,
|
||
"scale_value": scale_val,
|
||
})
|
||
|
||
return labels
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 사각형 프레임 검출
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def is_rectangle(shape: Shape, tolerance: float = 0.08) -> bool:
|
||
"""Shape가 축정렬 사각형인지 확인."""
|
||
if shape.kind != "polyline" or not shape.closed:
|
||
return False
|
||
|
||
pts = shape.points
|
||
# 끝점 중복 제거
|
||
if len(pts) >= 2 and abs(pts[0][0] - pts[-1][0]) < 1e-6 and abs(pts[0][1] - pts[-1][1]) < 1e-6:
|
||
pts = pts[:-1]
|
||
|
||
if len(pts) != 4:
|
||
return False
|
||
|
||
# 각 엣지가 수평 또는 수직에 가까운지
|
||
for i in range(4):
|
||
p1 = pts[i]
|
||
p2 = pts[(i + 1) % 4]
|
||
dx = abs(p2[0] - p1[0])
|
||
dy = abs(p2[1] - p1[1])
|
||
seg_len = max(dx, dy, 1e-6)
|
||
if not (dx / seg_len < tolerance or dy / seg_len < tolerance):
|
||
return False
|
||
|
||
return True
|
||
|
||
|
||
def detect_rectangles(geom: GeometryResult,
|
||
min_area: float = 10.0,
|
||
max_area_ratio: float = 0.9) -> list[Shape]:
|
||
"""GeometryResult에서 축정렬 사각형들을 검출.
|
||
|
||
Args:
|
||
min_area: 최소 면적 (m²) — 너무 작은 건 무시
|
||
max_area_ratio: 전체 bbox 대비 최대 면적 비율 — 제목블록/시트경계 제외
|
||
"""
|
||
if not geom.shapes:
|
||
return []
|
||
|
||
total_area = max(
|
||
(geom.total_bounds[2] - geom.total_bounds[0]) *
|
||
(geom.total_bounds[3] - geom.total_bounds[1]),
|
||
1.0,
|
||
)
|
||
max_area = total_area * max_area_ratio
|
||
|
||
rectangles = []
|
||
for s in geom.closed_shapes:
|
||
if not is_rectangle(s):
|
||
continue
|
||
if s.area < min_area or s.area > max_area:
|
||
continue
|
||
rectangles.append(s)
|
||
|
||
return rectangles
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 라벨 ↔ 사각형 매칭
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _point_in_bbox(pt: tuple, bbox: tuple, margin: float = 0.0) -> bool:
|
||
return (bbox[0] - margin <= pt[0] <= bbox[2] + margin and
|
||
bbox[1] - margin <= pt[1] <= bbox[3] + margin)
|
||
|
||
|
||
def _dist_point_to_bbox(pt: tuple, bbox: tuple) -> float:
|
||
"""점과 bbox 사이의 최소 거리."""
|
||
dx = max(bbox[0] - pt[0], 0, pt[0] - bbox[2])
|
||
dy = max(bbox[1] - pt[1], 0, pt[1] - bbox[3])
|
||
return math.sqrt(dx * dx + dy * dy)
|
||
|
||
|
||
def match_label_to_rectangle(label_pos: tuple, rectangles: list[Shape],
|
||
max_distance: float = 30.0) -> Shape | None:
|
||
"""라벨 위치에 가장 적합한 사각형을 찾기.
|
||
|
||
우선순위:
|
||
1. 라벨이 사각형 안쪽
|
||
2. 라벨이 사각형 바로 아래 (한국 토목도면 관례: 그림 아래 제목)
|
||
3. 가장 가까운 사각형 (max_distance 이내)
|
||
"""
|
||
# 1) 내부
|
||
inside = [r for r in rectangles if _point_in_bbox(label_pos, r.bbox)]
|
||
if inside:
|
||
return min(inside, key=lambda r: r.area) # 가장 작은 것 (내부 중 중첩된 경우)
|
||
|
||
# 2) 바로 아래 (라벨 Y < 사각형 Y_min)
|
||
below_candidates = []
|
||
for r in rectangles:
|
||
b = r.bbox
|
||
dy = b[1] - label_pos[1] # 사각형 아래쪽 가장자리 - 라벨 Y
|
||
# 라벨이 사각형 아래에 있고, X 범위 안에 있음
|
||
if -2.0 < dy < max_distance and b[0] - 5 < label_pos[0] < b[2] + 5:
|
||
below_candidates.append((dy, r))
|
||
if below_candidates:
|
||
below_candidates.sort(key=lambda x: x[0])
|
||
return below_candidates[0][1]
|
||
|
||
# 3) 바로 위 (뒷산 관례 등)
|
||
above_candidates = []
|
||
for r in rectangles:
|
||
b = r.bbox
|
||
dy = label_pos[1] - b[3] # 라벨 Y - 사각형 위쪽 가장자리
|
||
if -2.0 < dy < max_distance and b[0] - 5 < label_pos[0] < b[2] + 5:
|
||
above_candidates.append((dy, r))
|
||
if above_candidates:
|
||
above_candidates.sort(key=lambda x: x[0])
|
||
return above_candidates[0][1]
|
||
|
||
# 4) 가장 가까운 것 (폴백)
|
||
dists = [(_dist_point_to_bbox(label_pos, r.bbox), r) for r in rectangles]
|
||
dists = [(d, r) for d, r in dists if d < max_distance]
|
||
if dists:
|
||
dists.sort(key=lambda x: x[0])
|
||
return dists[0][1]
|
||
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 라벨만 있는 경우: 라벨 주변 영역 추정 (프레임 없는 경우)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def estimate_region_without_frame(label_pos: tuple, all_labels: list[dict],
|
||
geom_bounds: tuple) -> tuple:
|
||
"""프레임 없이 라벨만 있을 때, 라벨 주변의 합리적 영역을 추정.
|
||
|
||
다른 라벨들의 위치를 경계로 사용.
|
||
"""
|
||
lx, ly = label_pos
|
||
|
||
# 현재 라벨과 다른 모든 라벨의 위치
|
||
others = [l["pos"] for l in all_labels
|
||
if abs(l["pos"][0] - lx) > 0.01 or abs(l["pos"][1] - ly) > 0.01]
|
||
|
||
# 기본: 전체 bbox 기준 (default_w/h 사용 안 함 — 라벨 간 분할만 적용)
|
||
total_w = geom_bounds[2] - geom_bounds[0]
|
||
total_h = geom_bounds[3] - geom_bounds[1]
|
||
|
||
# 이웃 라벨과의 중간 지점까지를 경계로
|
||
x_min = geom_bounds[0]
|
||
x_max = geom_bounds[2]
|
||
y_min = geom_bounds[1]
|
||
y_max = geom_bounds[3]
|
||
|
||
for ox, oy in others:
|
||
# 좌우
|
||
if abs(oy - ly) < total_h * 0.3:
|
||
if ox < lx and (lx + ox) / 2 > x_min:
|
||
x_min = (lx + ox) / 2
|
||
elif ox > lx and (lx + ox) / 2 < x_max:
|
||
x_max = (lx + ox) / 2
|
||
# 상하
|
||
if abs(ox - lx) < total_w * 0.3:
|
||
if oy < ly and (ly + oy) / 2 > y_min:
|
||
y_min = (ly + oy) / 2
|
||
elif oy > ly and (ly + oy) / 2 < y_max:
|
||
y_max = (ly + oy) / 2
|
||
|
||
# 라벨 자체는 영역 아래쪽이라 가정 (라벨 위가 뷰)
|
||
return (x_min, y_min, x_max, y_max)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 메인 검출 함수
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def detect_view_regions(dxf_path: str) -> list[ViewRegion]:
|
||
"""DXF에서 뷰 영역들을 검출.
|
||
|
||
Returns:
|
||
ViewRegion 목록 (검출 순서 = 뷰 타입 우선순위)
|
||
"""
|
||
# 지오메트리 + 단위 추출
|
||
geom = extract_structural_geometry(dxf_path)
|
||
|
||
# 라벨 수집
|
||
labels = collect_view_labels(dxf_path, geom.unit_scale)
|
||
|
||
if not labels:
|
||
return []
|
||
|
||
# 사각형 검출
|
||
rectangles = detect_rectangles(geom)
|
||
|
||
# 각 라벨을 사각형에 매칭
|
||
regions = []
|
||
used_rectangles = set()
|
||
|
||
for lbl in labels:
|
||
rect = match_label_to_rectangle(lbl["pos"], rectangles)
|
||
|
||
if rect is not None and id(rect) not in used_rectangles:
|
||
# 프레임 기반 영역
|
||
used_rectangles.add(id(rect))
|
||
bounds = rect.bbox
|
||
has_frame = True
|
||
else:
|
||
# 프레임 없음 → 주변 추정
|
||
bounds = estimate_region_without_frame(
|
||
lbl["pos"], labels, geom.total_bounds
|
||
)
|
||
has_frame = False
|
||
|
||
# 해당 영역 안의 지오메트리 수집 (프레임 자체는 제외)
|
||
inside_shapes = []
|
||
for s in geom.shapes:
|
||
if rect is not None and s is rect:
|
||
continue # 프레임 자체 제외
|
||
# Shape bbox 중심이 영역 안에 있으면 포함
|
||
cx = (s.bbox[0] + s.bbox[2]) / 2
|
||
cy = (s.bbox[1] + s.bbox[3]) / 2
|
||
if _point_in_bbox((cx, cy), bounds, margin=1.0):
|
||
inside_shapes.append(s)
|
||
|
||
regions.append(ViewRegion(
|
||
view_type=lbl["view_type"],
|
||
label_text=lbl["text"],
|
||
label_pos=lbl["pos"],
|
||
bounds=bounds,
|
||
shapes=inside_shapes,
|
||
scale_hint=lbl["scale_hint"],
|
||
scale_value=lbl["scale_value"],
|
||
has_frame=has_frame,
|
||
))
|
||
|
||
return regions
|
||
|
||
|
||
def detect_views_multi(dxf_paths: list[str]) -> list[ViewRegion]:
|
||
"""여러 DXF의 뷰를 모두 검출."""
|
||
all_views = []
|
||
for p in dxf_paths:
|
||
try:
|
||
views = detect_view_regions(p)
|
||
for v in views:
|
||
v.label_text = f"[{Path(p).stem[:20]}] {v.label_text}"
|
||
all_views.extend(views)
|
||
except Exception as e:
|
||
print(f" 뷰 검출 실패 ({p}): {e}")
|
||
return all_views
|
||
|
||
|
||
def get_view_by_type(views: list[ViewRegion], view_type: str) -> ViewRegion | None:
|
||
"""타입으로 뷰 검색. 같은 타입이 여러 개면 첫 번째."""
|
||
for v in views:
|
||
if v.view_type == view_type:
|
||
return v
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 테스트
|
||
# ---------------------------------------------------------------------------
|
||
|
||
if __name__ == "__main__":
|
||
import glob
|
||
|
||
samples = sorted(glob.glob("SAMPLE_CAD/*.dxf"))
|
||
for p in samples:
|
||
print(f"\n=== {Path(p).name} ===")
|
||
try:
|
||
views = detect_view_regions(p)
|
||
if not views:
|
||
print(" (뷰 라벨 검출 안 됨)")
|
||
continue
|
||
for v in views:
|
||
frame = "프레임" if v.has_frame else "추정"
|
||
scale = f" S={v.scale_hint}" if v.scale_hint else ""
|
||
print(f" [{v.view_type_ko}] \"{v.label_text[:40]}\" "
|
||
f"({frame}{scale}, {len(v.shapes)}개 shape, "
|
||
f"bbox {v.width:.1f}×{v.height:.1f}m)")
|
||
except Exception as e:
|
||
print(f" 오류: {e}")
|