""" rail_to_dxf.py ============== X-AnyLabeling JSON 어노테이션에서 레일 중심선을 추출하여 Rhino용 DXF로 저장. 사용법: python tools/rail_to_dxf.py [output.dxf] 예시: python tools/rail_to_dxf.py images/rail.json python tools/rail_to_dxf.py images/rail.json output/rail_centerline.dxf 라벨 이름 (X-AnyLabeling에서 Finish 후 입력한 이름): rail, railway_track, track, AUTOLABEL_OBJECT 자동 인식 다른 이름이면 스크립트 하단 TARGET_LABELS 수정 Rhino에서 사용: 1. DXF Import 2. RAIL_CENTERLINE 레이어 선택 3. Sweep2 또는 Rail Sweep으로 레일 단면 적용 """ import json import sys import numpy as np import cv2 from pathlib import Path from skimage.morphology import skeletonize # ─── 설정 ───────────────────────────────────────────────────────────────────── TARGET_LABELS = [ "rail", "railline", "railway_track", "track", "레일", "철로", "AUTOLABEL_OBJECT", ] # line/linestrip 타입은 스켈레톤 불필요 — 직접 DXF 출력 LINE_SHAPE_TYPES = {"line", "linestrip", "lines"} SMOOTH_WINDOW = 15 # 중심선 스무딩 강도 (클수록 부드러움, 0=비활성) DOWNSAMPLE_STEP = 8 # Rhino 폴리라인 포인트 간격 (클수록 포인트 수 감소) DILATION_ITER = 3 # 마스크 팽창 반복 (얇은 레일 마스크 연결 보완) # ────────────────────────────────────────────────────────────────────────────── def polygon_to_mask(points, h, w): mask = np.zeros((h, w), dtype=np.uint8) pts = np.array([[int(p[0]), int(p[1])] for p in points], dtype=np.int32) cv2.fillPoly(mask, [pts], 1) return mask def extract_skeleton(mask): kernel = np.ones((3, 3), np.uint8) dilated = cv2.dilate(mask, kernel, iterations=DILATION_ITER) return skeletonize(dilated > 0).astype(np.uint8) def order_skeleton(skeleton): """스켈레톤 픽셀을 끝점에서 시작해 순서대로 연결.""" ys, xs = np.where(skeleton > 0) if len(xs) == 0: return [] pt_set = set(zip(xs.tolist(), ys.tolist())) def neighbors(pt): x, y = pt return [(x+dx, y+dy) for dx in (-1,0,1) for dy in (-1,0,1) if not (dx==0 and dy==0) and (x+dx, y+dy) in pt_set] # 끝점(이웃 1개) 찾기 → 없으면 임의 시작 endpoints = [p for p in pt_set if len(neighbors(p)) == 1] start = endpoints[0] if endpoints else next(iter(pt_set)) ordered, visited = [start], {start} current = start while True: nbs = [n for n in neighbors(current) if n not in visited] if not nbs: break # 가장 직선에 가까운 방향 우선 선택 if len(ordered) >= 2: dx = current[0] - ordered[-2][0] dy = current[1] - ordered[-2][1] nbs.sort(key=lambda n: -((n[0]-current[0])*dx + (n[1]-current[1])*dy)) current = nbs[0] visited.add(current) ordered.append(current) return ordered def smooth_polyline(points, window): if window < 3 or len(points) < window: return points pts = np.array(points, dtype=float) half = window // 2 out = pts.copy() for i in range(half, len(pts) - half): out[i] = pts[i-half:i+half+1].mean(axis=0) return out.tolist() def downsample(points, step): if step <= 1 or len(points) <= step: return points sampled = points[::step] if list(sampled[-1]) != list(points[-1]): sampled = list(sampled) + [points[-1]] return sampled def to_dxf_coords(points, flip_y=True): """이미지 좌표(Y↓) → DXF 좌표(Y↑)""" if flip_y: return [(float(p[0]), float(-p[1])) for p in points] return [(float(p[0]), float(p[1])) for p in points] def process(json_path: str, dxf_path: str): import ezdxf with open(json_path, encoding="utf-8") as f: data = json.load(f) H = data.get("imageHeight", 1000) W = data.get("imageWidth", 1000) print(f"[이미지] {W} × {H} px") shapes = data.get("shapes", []) targets = [s for s in shapes if s.get("label") in TARGET_LABELS] if not targets: print("⚠ TARGET_LABELS 일치 없음 → 모든 shape 사용") targets = shapes type_counts = {} for s in targets: t = s.get("shape_type", "unknown") type_counts[t] = type_counts.get(t, 0) + 1 print(f"[처리] {len(targets)}개: {type_counts}") doc = ezdxf.new("R2010") msp = doc.modelspace() doc.layers.add("RAIL_CENTERLINE", color=1) # 빨강 — Sweep 경로 doc.layers.add("RAIL_POLYGON", color=3) # 초록 — 원본 마스크 윤곽 for i, shape in enumerate(targets): label = shape.get("label", f"shape_{i}") pts = shape.get("points", []) shape_type = shape.get("shape_type", "polygon") print(f"\n [{i+1}] label={label!r} type={shape_type!r} pts={len(pts)}") if len(pts) < 2: print(" → 포인트 부족, 건너뜀") continue # 중복 끝점 제거 (Polygon 도구로 그린 선: 마지막 점이 앞 점과 거의 동일) if len(pts) >= 3: dedup = [pts[0]] for p in pts[1:]: if abs(p[0]-dedup[-1][0]) > 2 or abs(p[1]-dedup[-1][1]) > 2: dedup.append(p) if len(dedup) < len(pts): print(f" 중복점 제거: {len(pts)}pt → {len(dedup)}pt") pts = dedup # ── LINE 타입: 스켈레톤 없이 직접 DXF 출력 ────── if shape_type in LINE_SHAPE_TYPES or len(pts) == 2: cl_dxf = to_dxf_coords(pts) msp.add_lwpolyline(cl_dxf, dxfattribs={"layer": "RAIL_CENTERLINE"}) print(f" → 라인 직접 출력 ({len(pts)}pt)") continue # ── POLYGON 타입: 마스크 → 스켈레톤 → 중심선 ──── if len(pts) < 3: print(" → 폴리곤 포인트 부족, 건너뜀") continue poly_dxf = to_dxf_coords(pts) poly_dxf.append(poly_dxf[0]) msp.add_lwpolyline(poly_dxf, dxfattribs={"layer": "RAIL_POLYGON"}) mask = polygon_to_mask(pts, H, W) area = int(mask.sum()) print(f" 마스크 면적: {area} px²") if area < 20: print(" → 면적 너무 작음, 건너뜀") continue skel = extract_skeleton(mask) skel_n = int(skel.sum()) print(f" 스켈레톤 픽셀: {skel_n}") if skel_n < 2: print(" → 스켈레톤 생성 실패, 건너뜀") continue ordered = order_skeleton(skel) smoothed = smooth_polyline(ordered, SMOOTH_WINDOW) final_pts = downsample(smoothed, DOWNSAMPLE_STEP) print(f" 중심선 포인트: {len(final_pts)}") if len(final_pts) < 2: continue cl_dxf = to_dxf_coords(final_pts) msp.add_lwpolyline(cl_dxf, dxfattribs={"layer": "RAIL_CENTERLINE"}) Path(dxf_path).parent.mkdir(parents=True, exist_ok=True) doc.saveas(dxf_path) print(f"\n[완료] DXF 저장: {dxf_path}") print(" 레이어: RAIL_CENTERLINE(빨강) = Sweep 경로") print(" RAIL_POLYGON(초록) = 원본 마스크") print("\nRhino 사용법:") print(" 1. File → Import → DXF 선택") print(" 2. RAIL_CENTERLINE 레이어 선택") print(" 3. Rail 단면 커브 그리기 (레일 단면 약 60x60mm)") print(" 4. Sweep1 또는 Sweep2 명령으로 단면 × 중심선 → 3D 레일 생성") if __name__ == "__main__": if len(sys.argv) < 2: print("사용법: python tools/rail_to_dxf.py [output.dxf]") sys.exit(1) json_file = sys.argv[1] dxf_file = sys.argv[2] if len(sys.argv) >= 3 else str(Path(json_file).with_suffix(".dxf")) process(json_file, dxf_file)