Files
s-canvas/view_detector.py

521 lines
17 KiB
Python
Raw 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.
"""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, field
from pathlib import Path
from typing import Optional
import ezdxf
import numpy as np
from dxf_geometry import (
extract_structural_geometry,
GeometryResult,
Shape,
is_excluded_layer,
)
# ---------------------------------------------------------------------------
# 뷰 타입 라벨 패턴
# ---------------------------------------------------------------------------
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: Optional[int] = 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) -> Optional[str]:
"""텍스트가 뷰 라벨인지 확인하고 타입 반환."""
# 매우 긴 텍스트는 라벨이 아닐 가능성 (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, Optional[int]]:
"""텍스트에서 축척 추출 (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) -> Optional[Shape]:
"""라벨 위치에 가장 적합한 사각형을 찾기.
우선순위:
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의 1/2 정도
total_w = geom_bounds[2] - geom_bounds[0]
total_h = geom_bounds[3] - geom_bounds[1]
default_w = total_w * 0.5
default_h = total_h * 0.5
# 이웃 라벨과의 중간 지점까지를 경계로
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) -> Optional[ViewRegion]:
"""타입으로 뷰 검색. 같은 타입이 여러 개면 첫 번째."""
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}")