"""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}")