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>
This commit is contained in:
2026-05-08 10:29:08 +09:00
parent 53d8b53c2f
commit b9342f6726
92 changed files with 3413501 additions and 0 deletions

515
view_detector.py Normal file
View File

@@ -0,0 +1,515 @@
"""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}")