sam31server 전환, 라멘 파이프라인 정리, 문서 추가

- sam31server를 SAM3.1 서버로 전환 (x-anylabeling01 대체)
- detect_raamen.py: B/C 분류 기반 라멘형 전철주 검출 파이프라인 정비
- sam3_everything_explore.py: Discovery Sweep 탐색 모드 정리
- detect_all_objects.py: 타일 검출 개선
- docs/railway-client-guide.html: 서버·도구·파이프라인 전체 가이드 추가
- tools 추가: detect_control_box, group_ramen_poles, render_everything_by_label, render_label_polygons, debug_vh

Closes #1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-06-02 10:11:52 +09:00
parent ccba1266b5
commit 4c15d5ff5d
10 changed files with 2125 additions and 290 deletions

View File

@@ -0,0 +1,664 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Railway Client — 사용 가이드</title>
<style>
:root {
--bg: #0f1117; --bg2: #1a1d27; --bg3: #22263a;
--border: #2e3354; --accent: #4f8ef7; --accent2: #7c3aed;
--green: #22c55e; --yellow: #f59e0b; --red: #ef4444;
--text: #e2e8f0; --muted: #8892a4; --code-bg: #12151f;
--orange: #f97316;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; line-height: 1.6; }
a { color: var(--accent); text-decoration: none; }
nav { background: var(--bg2); border-bottom: 1px solid var(--border); padding: 0 2rem; display: flex; align-items: center; gap: 2rem; height: 52px; position: sticky; top: 0; z-index: 100; }
nav .brand { font-weight: 700; font-size: 1rem; color: var(--accent); letter-spacing: .05em; }
nav a { color: var(--muted); font-size: .85rem; }
nav a:hover { color: var(--text); }
.layout { display: flex; min-height: calc(100vh - 52px); }
aside { width: 250px; flex-shrink: 0; background: var(--bg2); border-right: 1px solid var(--border); padding: 1.5rem 1rem; position: sticky; top: 52px; height: calc(100vh - 52px); overflow-y: auto; }
aside h4 { font-size: .68rem; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); margin-bottom: .6rem; margin-top: 1.2rem; }
aside h4:first-child { margin-top: 0; }
aside ul { list-style: none; }
aside ul li a { display: block; padding: .28rem .6rem; border-radius: 5px; font-size: .82rem; color: var(--muted); }
aside ul li a:hover { background: var(--bg3); color: var(--text); }
aside ul li a.priority { color: var(--orange); font-weight: 600; }
main { flex: 1; padding: 2.5rem 3rem; max-width: 980px; }
h1 { font-size: 1.9rem; font-weight: 700; margin-bottom: .4rem; }
h2 { font-size: 1.3rem; font-weight: 600; margin: 2.5rem 0 1rem; padding-bottom: .4rem; border-bottom: 1px solid var(--border); color: var(--accent); }
h2.priority-h2 { color: var(--orange); border-color: #7c3310; }
h3 { font-size: 1rem; font-weight: 600; margin: 1.5rem 0 .6rem; color: #c4cfe8; }
p { margin-bottom: .8rem; color: #c4cfe8; font-size: .93rem; }
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .72rem; font-weight: 600; letter-spacing: .04em; margin-right: .3rem; }
.badge-orange { background: #431407; color: #fed7aa; }
.badge-blue { background: #1e3a6e; color: #93c5fd; }
.badge-green { background: #14532d; color: #86efac; }
pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.2rem; overflow-x: auto; margin: .8rem 0 1.2rem; font-size: .82rem; line-height: 1.7; }
code { font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace; }
p code, li code, td code { background: var(--code-bg); border: 1px solid var(--border); border-radius: 3px; padding: .1em .4em; font-size: .82em; color: #a5b4fc; }
table { width: 100%; border-collapse: collapse; font-size: .85rem; margin: .8rem 0 1.4rem; }
th { background: var(--bg3); color: var(--muted); text-align: left; padding: .55rem .8rem; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; border-bottom: 1px solid var(--border); }
td { padding: .5rem .8rem; border-bottom: 1px solid #1e2235; vertical-align: top; }
tr:hover td { background: #161929; }
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; margin: 1rem 0; }
.card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 1.1rem 1.2rem; }
.card.highlight { border-color: var(--orange); }
.card h4 { font-size: .9rem; font-weight: 600; margin-bottom: .4rem; }
.card p { font-size: .82rem; color: var(--muted); margin: 0; }
.step { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
.step-num { flex-shrink: 0; width: 28px; height: 28px; background: var(--accent); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .8rem; font-weight: 700; color: #fff; margin-top: .15rem; }
.step-num.orange { background: var(--orange); }
.step-body { flex: 1; }
.step-body h4 { font-size: .92rem; font-weight: 600; margin-bottom: .3rem; }
.step-body p { font-size: .85rem; color: var(--muted); margin: 0 0 .4rem; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: .4rem; }
.dot-green { background: var(--green); }
.swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; vertical-align: middle; margin-right: .4rem; border: 1px solid rgba(255,255,255,.15); }
.alert { border-radius: 7px; padding: .8rem 1rem; margin: .8rem 0; font-size: .85rem; border-left: 3px solid; }
.alert-info { background: #0c1a3a; border-color: var(--accent); color: #93c5fd; }
.alert-warn { background: #2a1a00; border-color: var(--yellow); color: #fde68a; }
.alert-ok { background: #0a2e1a; border-color: var(--green); color: #86efac; }
.alert-pri { background: #2a1000; border-color: var(--orange); color: #fed7aa; }
.pipeline-row { display: flex; align-items: center; gap: .6rem; margin: 1rem 0; flex-wrap: wrap; }
.pipe-box { background: var(--bg3); border: 1px solid var(--border); border-radius: 6px; padding: .5rem .9rem; font-size: .85rem; font-weight: 600; }
.pipe-box.highlight { border-color: var(--orange); color: var(--orange); }
.pipe-arrow { color: var(--muted); font-size: 1.1rem; }
.subtitle { color: var(--muted); font-size: .9rem; margin-bottom: 1.5rem; }
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
ul.body-list { padding-left: 1.4rem; margin-bottom: .8rem; }
ul.body-list li { font-size: .87rem; color: #c4cfe8; margin-bottom: .25rem; }
.cb-legend { display: flex; gap: 1.5rem; margin: .8rem 0; }
.cb-item { display: flex; align-items: center; gap: .5rem; font-size: .87rem; }
.cb-box { width: 32px; height: 32px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 1rem; }
</style>
</head>
<body>
<nav>
<span class="brand">⚡ Railway Client</span>
<a href="#raamen">🔴 라멘 검출</a>
<a href="#everything">탐색 모드</a>
<a href="#server">서버</a>
<a href="#tools">전체 도구</a>
<a href="#categories">카테고리</a>
</nav>
<div class="layout">
<aside>
<h4>🔴 최우선 파이프라인</h4>
<ul>
<li><a href="#raamen" class="priority">라멘형 전철주 검출</a></li>
<li><a href="#raamen-pipeline" class="priority">2단계 파이프라인</a></li>
<li><a href="#raamen-cb" class="priority">B/C 분류 설명</a></li>
<li><a href="#raamen-args">파라미터 상세</a></li>
</ul>
<h4>탐색 모드</h4>
<ul>
<li><a href="#everything">Everything 모드 개요</a></li>
<li><a href="#everything-usage">사용법</a></li>
<li><a href="#everything-prompt">Discovery Prompt</a></li>
<li><a href="#everything-output">출력 해석</a></li>
</ul>
<h4>서버 (sam31server)</h4>
<ul>
<li><a href="#server-setup">설치 요구사항</a></li>
<li><a href="#server-start">서버 시작</a></li>
<li><a href="#server-api">API 엔드포인트</a></li>
</ul>
<h4>클라이언트 도구</h4>
<ul>
<li><a href="#tool-detect-all">detect_all_objects</a></li>
<li><a href="#tool-autolabel">sam3_autolabel</a></li>
<li><a href="#tool-batch">sam3_batch_label</a></li>
<li><a href="#tool-yoloworld">yoloworld_sam3_pipeline</a></li>
<li><a href="#tool-video">video_sam3_segment</a></li>
<li><a href="#tool-render">렌더링 도구</a></li>
</ul>
<h4>설정</h4>
<ul>
<li><a href="#categories">카테고리 (railway_zone.json)</a></li>
<li><a href="#prompts">프롬프트 파일</a></li>
</ul>
<h4>시스템</h4>
<ul>
<li><a href="#overview">아키텍처 개요</a></li>
<li><a href="#quickstart">빠른 시작</a></li>
<li><a href="#troubleshoot">트러블슈팅</a></li>
</ul>
</aside>
<main>
<!-- ══════════════════════════════════════════════
1. 라멘형 전철주 검출 — 최우선
══════════════════════════════════════════════ -->
<section id="raamen">
<h1>Railway Client 사용 가이드</h1>
<p class="subtitle">드론 항공 이미지 기반 철도 시설물 자동 검출 파이프라인</p>
<div class="alert alert-ok">
<strong>서버 상태:</strong> <span class="dot dot-green"></span>sam31server SAM 3.1 → localhost:8000 구동 중
</div>
<h2 class="priority-h2" id="raamen">🔴 라멘형(門) 전철주 검출 파이프라인</h2>
<span class="badge badge-orange">최우선 기능</span>
<p>드론 사선 촬영 이미지에서 門자형 라멘 구조 전철주를 검출한다. SAM3.1로 폴리곤을 추출한 후 소실점(Vanishing Point) 기반 기하학 분석으로 C(기둥)/B(빔)를 분류, 라멘 그룹을 판정한다.</p>
<div id="raamen-cb">
<h3>B / C 접두어 — 분류 기준</h3>
<div class="cb-legend">
<div class="cb-item">
<div class="cb-box" style="background:#1a3a6e; color:#93c5fd; border:2px solid #3b82f6;">C</div>
<div>
<strong>Column (기둥)</strong><br>
<span style="color:var(--muted); font-size:.82rem;">소실점 방향으로 향하는 수직·사선 폴리곤.<br>각도 임계값(<code>--c-thresh</code>, 기본 20°) 이내.</span>
</div>
</div>
<div class="cb-item">
<div class="cb-box" style="background:#3a1a00; color:#fed7aa; border:2px solid #f97316;">B</div>
<div>
<strong>Beam (빔)</strong><br>
<span style="color:var(--muted); font-size:.82rem;">수평으로 뻗은 폴리곤.<br>소실점 방향에서 크게 벗어난 것.</span>
</div>
</div>
</div>
<div class="alert alert-info">
라멘 구조 판정: <strong>B 1개 + 그 아래 C 2개 이상</strong> → 門자형 라멘으로 분류. 출력 이미지에 B/C 접두어 + 그룹 번호로 표시.
</div>
</div>
<div id="raamen-pipeline">
<h3>2단계 파이프라인</h3>
<div class="pipeline-row">
<div class="pipe-box">① detect_all_objects.py</div>
<span class="pipe-arrow"></span>
<div class="pipe-box" style="color:var(--muted)">JSON annotation<br><small>(catenary_pole polygons)</small></div>
<span class="pipe-arrow"></span>
<div class="pipe-box highlight">② detect_raamen.py</div>
<span class="pipe-arrow"></span>
<div class="pipe-box" style="color:var(--muted)">B/C 분류<br>라멘 그룹 판정</div>
</div>
</div>
<h3>Step 1 — 전철주 폴리곤 추출</h3>
<pre><code>cd D:\MYCLAUDE_PROJECT\railway-client
.venv\Scripts\python.exe tools/detect_all_objects.py `
--input "data/역사구간/1.회덕역/DJI_20260306100900_0034.JPG" `
--categories configs/railway_zone.json `
--tiles all `
--cols 4 --rows 3 --overlap 0.10 --workers 2</code></pre>
<p>출력: <code>output/detect/DJI_20260306100900_0034.json</code> — X-AnyLabeling 호환 annotation</p>
<h3>Step 2 — 라멘 구조 분석</h3>
<pre><code>.venv\Scripts\python.exe tools/detect_raamen.py `
--image "data/역사구간/1.회덕역/DJI_20260306100900_0034.JPG" `
--label "output/detect/DJI_20260306100900_0034.json" `
--output "output/raamen/DJI_20260306100900_0034_raamen.png"</code></pre>
<p>출력: 이미지 위에 C/B 라벨 + 라멘 그룹 번호 오버레이</p>
<div id="raamen-args">
<h3>detect_raamen.py 파라미터</h3>
<table>
<tr><th>파라미터</th><th>기본값</th><th>설명</th></tr>
<tr><td><code>--image</code></td><td>필수</td><td>원본 이미지 경로</td></tr>
<tr><td><code>--label</code></td><td>필수</td><td>JSON annotation 파일 (detect_all_objects 출력)</td></tr>
<tr><td><code>--output</code></td><td>자동 생성</td><td>결과 이미지 저장 경로</td></tr>
<tr><td><code>--class-names</code></td><td><code>catenary_pole</code></td><td>분석할 클래스 이름 (쉼표 구분)</td></tr>
<tr><td><code>--class-ids</code></td><td>없음</td><td>클래스 ID로 필터링 (대안)</td></tr>
<tr><td><code>--epsilon</code></td><td>4.0</td><td>폴리곤 단순화 강도 (높을수록 단순)</td></tr>
<tr><td><code>--c-thresh</code></td><td>20.0°</td><td>C(기둥) 판정 각도 임계값</td></tr>
<tr><td><code>--b-max-diff</code></td><td>75.0°</td><td>B(빔) 최대 각도 편차</td></tr>
<tr><td><code>--margin</code></td><td>30px</td><td>근접성 그룹핑 거리</td></tr>
</table>
</div>
<h3>4단계 내부 처리 흐름</h3>
<table>
<tr><th>Phase</th><th>처리 내용</th></tr>
<tr><td>Phase 1</td><td>폴리곤 단순화(approxPolyDP) + 소실점(Vanishing Point) 계산</td></tr>
<tr><td>Phase 2</td><td>동적 C/B 분류 — 소실점 기반 기대 각도와 비교</td></tr>
<tr><td>Phase 3</td><td>근접성 기반 그룹핑 — B 앵커 기준으로 아래 C 탐색</td></tr>
<tr><td>Phase 4</td><td>라멘 구조 판정 + 가림(occlusion) 예외 처리</td></tr>
</table>
</section>
<!-- ══════════════════════════════════════════════
2. Everything 탐색 모드
══════════════════════════════════════════════ -->
<section id="everything">
<h2>Everything 탐색 모드 (Discovery Sweep)</h2>
<p>새 지역·새 구간을 처음 분석할 때 사용. SAM3.1 텍스트 grounding 방식이므로 완전 무프롬프트는 불가 — 대신 광범위한 <strong>Discovery Prompt</strong>로 이미지에 존재하는 모든 객체를 일괄 검출하고 라벨 빈도를 집계한다.</p>
<div class="alert alert-info">
<strong>용도:</strong> 어떤 객체가 이 이미지에 얼마나 나오는지 파악 → 이후 <code>detect_all_objects.py</code> 의 카테고리·conf 튜닝에 활용.
</div>
<div id="everything-usage">
<h3>사용법</h3>
<pre><code>cd D:\MYCLAUDE_PROJECT\railway-client
# 기본 (8×6 타일)
.venv\Scripts\python.exe tools/sam3_everything_explore.py `
--input "data/역사구간/1.회덕역/DJI_20260306100900_0034.JPG"
# 타일 수 조정 (고해상도 드론 이미지)
.venv\Scripts\python.exe tools/sam3_everything_explore.py `
--input "data/역사구간/..." `
--cols 8 --rows 6 --conf 0.10 --workers 4
# 특정 영역(ROI)만 탐색
.venv\Scripts\python.exe tools/sam3_everything_explore.py `
--input "..." `
--zone 1000 500 3000 2500
# Discovery Prompt에 추가 키워드 삽입
.venv\Scripts\python.exe tools/sam3_everything_explore.py `
--input "..." `
--prompt-extra "signal box, relay cabinet"</code></pre>
</div>
<h3>파라미터</h3>
<table>
<tr><th>파라미터</th><th>기본값</th><th>설명</th></tr>
<tr><td><code>--input</code></td><td>필수</td><td>이미지 파일 경로</td></tr>
<tr><td><code>--cols</code></td><td>8</td><td>가로 타일 분할 수</td></tr>
<tr><td><code>--rows</code></td><td>6</td><td>세로 타일 분할 수</td></tr>
<tr><td><code>--overlap</code></td><td>0.10</td><td>타일 겹침 비율</td></tr>
<tr><td><code>--conf</code></td><td>0.10</td><td>신뢰도 임계값 (탐색 모드라 낮게 설정)</td></tr>
<tr><td><code>--workers</code></td><td>4</td><td>병렬 처리 스레드 수</td></tr>
<tr><td><code>--nms</code></td><td>0.40</td><td>NMS IoU 임계값</td></tr>
<tr><td><code>--zone</code></td><td>없음</td><td>ROI 좌표 (X1 Y1 X2 Y2) — 관심 영역만 처리</td></tr>
<tr><td><code>--prompt-extra</code></td><td>없음</td><td>Discovery Prompt에 추가할 키워드</td></tr>
</table>
<div id="everything-prompt">
<h3>Discovery Prompt (내장)</h3>
<p>다음 키워드 묶음이 자동으로 SAM3.1에 전달된다:</p>
<pre><code>railroad track, railway rail,
catenary pole, overhead line pole, electric pole,
overhead wire, catenary wire, power line cable,
railway sleeper, concrete tie,
guardrail, highway barrier, road fence,
bridge, viaduct, overpass,
vegetation, tree, bush, grass,
building, structure, roof, wall,
vehicle, car, truck,
road, asphalt, pavement,
slope, embankment, retaining wall,
noise barrier, sound wall,
signal, sign board,
small dark object on ballast, small square metal box on ground,
control box on ballast, gray square lid on gravel,
flat metal cover on ground, bright square object on gravel</code></pre>
<p><code>--prompt-extra</code>로 추가 키워드를 붙일 수 있다.</p>
</div>
<div id="everything-output">
<h3>출력 해석</h3>
<pre><code>output/everything/
└── DJI_20260306100900_0034_everything.jpg ← 모든 segment 오버레이
콘솔 출력 (라벨 빈도 집계):
catenary pole : 47
railway rail : 31
overhead wire : 28
concrete tie : 19
control box on ballast: 8
...</code></pre>
<div class="alert alert-info">
<strong>활용:</strong> 빈도 상위 라벨 → <code>railway_zone.json</code>의 프롬프트에 추가하거나 conf 조정. 빈도 0인 카테고리 → 해당 이미지에 없는 객체.
</div>
<h3>탐색 후 타겟 검출로 전환</h3>
<div class="pipeline-row">
<div class="pipe-box highlight">sam3_everything_explore</div>
<span class="pipe-arrow"></span>
<div class="pipe-box" style="color:var(--muted)">라벨 빈도 확인<br>프롬프트 튜닝</div>
<span class="pipe-arrow"></span>
<div class="pipe-box">detect_all_objects</div>
<span class="pipe-arrow"></span>
<div class="pipe-box">detect_raamen (선택)</div>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════
3. 서버
══════════════════════════════════════════════ -->
<section id="server-setup">
<h2>서버 설정 (sam31server)</h2>
<h3 id="overview">시스템 아키텍처</h3>
<pre><code>┌─────────────────────────────────┐
│ sam31server │
│ FastAPI app/main.py :8000 │
│ GET /health │
│ POST /v1/predict │
│ Model: SAM 3.1 Multiplex (CUDA)│
└──────────────┬──────────────────┘
│ HTTP localhost:8000
┌──────────────▼──────────────────┐
│ railway-client tools │
│ detect_all_objects.py │ ─┐
│ detect_raamen.py ◀────────┘ │ 라멘 파이프라인
│ sam3_everything_explore.py │
│ sam3_autolabel.py ... │
└─────────────────────────────────┘</code></pre>
<h3>요구사항</h3>
<table>
<tr><th>항목</th><th>버전</th></tr>
<tr><td>Python</td><td>3.12</td></tr>
<tr><td>PyTorch</td><td>2.10.0+cu126 (CUDA 필수)</td></tr>
<tr><td>triton-windows</td><td>3.6.0.post25</td></tr>
<tr><td>FastAPI</td><td>≥ 0.115.0</td></tr>
<tr><td>SAM 3.1 모델</td><td>HuggingFace 자동 다운로드 (최초 1회)</td></tr>
</table>
<h3>디렉토리 구조</h3>
<pre><code>sam31server/
├── sam3/ ← SAM 3.1 모델 라이브러리
├── app/
│ ├── main.py ← FastAPI 진입점
│ ├── api/predict.py ← POST /v1/predict
│ ├── api/health.py ← GET /health
│ ├── models/segment_anything_3.py
│ ├── core/registry.py
│ └── tasks/inference.py
├── configs/
│ ├── server.yaml ← 포트·동시성
│ ├── models.yaml ← enabled: segment_anything_3
│ └── auto_labeling/segment_anything_3.yaml
├── bpe_simple_vocab_16e6.txt.gz
└── start_server.bat</code></pre>
</section>
<section id="server-start">
<h2>서버 시작</h2>
<div class="step">
<div class="step-num">1</div>
<div class="step-body">
<h4>sam31server 디렉토리에서 실행</h4>
<pre><code>cd D:\MYCLAUDE_PROJECT\sam31server
# 배치파일
start_server.bat
# 또는 직접
.venv\Scripts\python.exe -m app.main
# 또는 uvicorn 직접 (Windows 이벤트루프 이슈 우회)
.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000</code></pre>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-body">
<h4>정상 구동 확인</h4>
<pre><code>INFO | Loading [segment_anything_3] (Segment Anything 3.1)...
INFO | SAM3 model loaded successfully
INFO | Uvicorn running on http://0.0.0.0:8000</code></pre>
</div>
</div>
</section>
<section id="server-api">
<h2>API 엔드포인트</h2>
<h3>POST /v1/predict</h3>
<pre><code>{
"model": "segment_anything_3",
"image": "&lt;base64 JPEG&gt;",
"params": {
"text_prompt": "catenary pole, overhead line pole",
"conf_threshold": 0.25,
"show_masks": true,
"show_boxes": false
}
}</code></pre>
<p>응답:</p>
<pre><code>{
"data": {
"shapes": [
{ "label": "catenary pole", "shape_type": "polygon",
"points": [[x,y], ...], "score": 0.87 }
]
}
}</code></pre>
<table>
<tr><th>params 키</th><th>설명</th></tr>
<tr><td><code>text_prompt</code></td><td>텍스트 grounding (쉼표로 다중 객체)</td></tr>
<tr><td><code>marks</code></td><td>rectangle / point 시각 프롬프트</td></tr>
<tr><td><code>conf_threshold</code></td><td>신뢰도 임계값</td></tr>
<tr><td><code>show_masks</code></td><td>polygon 반환 (true 권장)</td></tr>
<tr><td><code>show_boxes</code></td><td>bounding box 반환</td></tr>
</table>
</section>
<!-- ══════════════════════════════════════════════
4. 전체 도구
══════════════════════════════════════════════ -->
<section id="tools">
<h2>클라이언트 도구 전체 목록</h2>
<table>
<tr><th>스크립트</th><th>기능</th><th>출력</th><th>우선순위</th></tr>
<tr style="background:#1a0e00;">
<td><code>detect_all_objects.py</code></td>
<td>타일 분할 + 다중 카테고리 검출 + NMS</td>
<td>PNG + JSON</td>
<td><span class="badge badge-orange">핵심</span></td>
</tr>
<tr style="background:#1a0e00;">
<td><code>detect_raamen.py</code></td>
<td>門형 라멘 전철주 B/C 분류 + 그룹 판정</td>
<td>PNG</td>
<td><span class="badge badge-orange">핵심</span></td>
</tr>
<tr style="background:#0c1a0c;">
<td><code>sam3_everything_explore.py</code></td>
<td>Discovery Sweep — 전체 객체 탐색 + 빈도 집계</td>
<td>PNG + 통계</td>
<td><span class="badge badge-green">탐색</span></td>
</tr>
<tr><td><code>sam3_autolabel.py</code></td><td>전철주 + 레일 zone 기반 자동 라벨링</td><td>JSON</td><td></td></tr>
<tr><td><code>sam3_batch_label.py</code></td><td>폴더 배치 → X-AnyLabeling JSON</td><td>JSON</td><td></td></tr>
<tr><td><code>yoloworld_sam3_pipeline.py</code></td><td>YOLO-World bbox → SAM3 polygon</td><td>JSON+PNG</td><td></td></tr>
<tr><td><code>video_sam3_segment.py</code></td><td>영상 프레임 추출 → 세그멘테이션</td><td>JSON/frame</td><td></td></tr>
<tr><td><code>render_everything_by_label.py</code></td><td>라벨별 색상 렌더링</td><td>PNG</td><td></td></tr>
<tr><td><code>render_label_polygons.py</code></td><td>polygon 오버레이</td><td>PNG</td><td></td></tr>
<tr><td><code>sam3_segment_everything.py</code></td><td>이미지 전체 세그멘테이션</td><td>PNG</td><td></td></tr>
<tr><td><code>railway_pipeline.py</code></td><td>전체 파이프라인 통합</td><td>종합</td><td></td></tr>
</table>
</section>
<section id="tool-detect-all">
<h2>detect_all_objects.py</h2>
<pre><code>.venv\Scripts\python.exe tools/detect_all_objects.py `
--input "data/역사구간/..." `
--categories configs/railway_zone.json `
--tiles all `
--cols 4 --rows 3 --overlap 0.10 --workers 2</code></pre>
<table>
<tr><th>파라미터</th><th>기본값</th><th>설명</th></tr>
<tr><td><code>--input</code></td><td>필수</td><td>이미지 또는 폴더</td></tr>
<tr><td><code>--categories</code></td><td>railway_zone.json</td><td>카테고리 설정 파일</td></tr>
<tr><td><code>--tiles</code></td><td>all</td><td><code>9-24</code>, <code>1,5,9</code>, <code>all</code></td></tr>
<tr><td><code>--cols / --rows</code></td><td>8 / 6</td><td>타일 분할 수</td></tr>
<tr><td><code>--overlap</code></td><td>0.10</td><td>타일 겹침 비율</td></tr>
<tr><td><code>--workers</code></td><td>4</td><td>병렬 스레드</td></tr>
<tr><td><code>--conf</code></td><td>카테고리별</td><td>전역 신뢰도 오버라이드</td></tr>
</table>
<p>출력: <code>output/detect/이미지명_detected.png</code> + <code>.json</code></p>
</section>
<section id="tool-autolabel">
<h2>sam3_autolabel.py</h2>
<pre><code>.venv\Scripts\python.exe tools/sam3_autolabel.py `
--input data/역사구간/1.회덕역/ `
--output output/labels/</code></pre>
</section>
<section id="tool-batch">
<h2>sam3_batch_label.py</h2>
<pre><code>.venv\Scripts\python.exe tools/sam3_batch_label.py `
--input data/역사구간/ `
--prompt "railway catenary pole" `
--output output/batch_labels/ `
--workers 8</code></pre>
</section>
<section id="tool-yoloworld">
<h2>yoloworld_sam3_pipeline.py</h2>
<pre><code>.venv\Scripts\python.exe tools/yoloworld_sam3_pipeline.py `
--input data/역사구간/ `
--output output/labeled/</code></pre>
</section>
<section id="tool-video">
<h2>video_sam3_segment.py</h2>
<pre><code>.venv\Scripts\python.exe tools/video_sam3_segment.py</code></pre>
<p>스크립트 상단에서 영상 경로·프롬프트·프레임 추출 간격 직접 수정.</p>
</section>
<section id="tool-render">
<h2>렌더링 도구</h2>
<pre><code># 라벨별 색상 렌더링
.venv\Scripts\python.exe tools/render_everything_by_label.py `
--input output/labels/image.json --image data/.../image.JPG
# polygon 오버레이
.venv\Scripts\python.exe tools/render_label_polygons.py `
--input output/labels/ --image data/.../</code></pre>
</section>
<!-- ══════════════════════════════════════════════
5. 카테고리
══════════════════════════════════════════════ -->
<section id="categories">
<h2>카테고리 설정 — railway_zone.json</h2>
<p>경로: <code>configs/railway_zone.json</code></p>
<table>
<tr><th>이름</th><th>한국어</th><th>색상</th><th>conf</th><th>우선순위</th></tr>
<tr><td><code>control_box</code></td><td>컨트롤박스</td><td><span class="swatch" style="background:rgb(255,255,0)"></span>노랑</td><td>0.15</td><td>2</td></tr>
<tr><td><code>vehicle</code></td><td>차량</td><td><span class="swatch" style="background:#fff"></span>흰색</td><td>0.25</td><td>2</td></tr>
<tr><td><code>catenary_pole</code></td><td>전철주</td><td><span class="swatch" style="background:rgb(255,0,255)"></span>마젠타</td><td>0.25</td><td>3</td></tr>
<tr><td><code>railway</code></td><td>철도 레일</td><td><span class="swatch" style="background:rgb(0,0,255)"></span>파랑</td><td>0.25</td><td>4</td></tr>
<tr><td><code>fence</code></td><td>팬스/울타리</td><td><span class="swatch" style="background:rgb(0,255,0)"></span>초록</td><td>0.50</td><td>5</td></tr>
<tr><td><code>sleeper</code></td><td>침목</td><td><span class="swatch" style="background:rgb(0,128,255)"></span>하늘</td><td>0.20</td><td>6</td></tr>
<tr><td><code>ballast</code></td><td>자갈도상</td><td><span class="swatch" style="background:rgb(30,50,100)"></span>남색</td><td>0.20</td><td>7</td></tr>
<tr><td><code>bracket</code></td><td>브라켓/암</td><td><span class="swatch" style="background:rgb(0,80,200)"></span>청색</td><td>0.20</td><td>8</td></tr>
<tr><td><code>bridge</code></td><td>교량/교각</td><td><span class="swatch" style="background:rgb(0,165,255)"></span>주황하늘</td><td>0.25</td><td>8</td></tr>
<tr><td><code>building</code></td><td>건물</td><td><span class="swatch" style="background:rgb(50,50,255)"></span>청보라</td><td>0.25</td><td>8</td></tr>
<tr><td><code>retaining_wall</code></td><td>방음벽/옹벽</td><td><span class="swatch" style="background:rgb(60,180,60)"></span>연두</td><td>0.25</td><td>9</td></tr>
<tr><td><code>culvert</code></td><td>암거/소교량</td><td><span class="swatch" style="background:rgb(180,60,180)"></span>보라</td><td>0.20</td><td>9</td></tr>
<tr><td><code>service_road</code></td><td>유지보수 도로</td><td><span class="swatch" style="background:rgb(80,120,160)"></span>회청</td><td>0.30</td><td>10</td></tr>
<tr><td><code>farmland</code></td><td>농지</td><td><span class="swatch" style="background:rgb(50,200,50)"></span>밝은초록</td><td>0.25</td><td>11</td></tr>
<tr><td><code>vegetation</code></td><td>식생</td><td><span class="swatch" style="background:rgb(0,120,0)"></span>짙은초록</td><td>0.25</td><td>12</td></tr>
</table>
<div class="alert alert-info">
<strong>우선순위(priority):</strong> 낮을수록 cross-class NMS에서 우선 보존. 우선순위 2 = 최우선(컨트롤박스·차량), 12 = 최하위(식생).
</div>
</section>
<section id="prompts">
<h2>프롬프트 파일</h2>
<p>일부 도구는 <code>prompts/</code> 폴더의 텍스트 파일을 사용:</p>
<pre><code>prompts/
├── pole.txt ← 전철주 검출용 프롬프트
├── rail.txt ← 레일 검출용 프롬프트
├── bracket.txt ← 브라켓 검출용
└── sleeper.txt ← 침목 검출용</code></pre>
</section>
<!-- ══════════════════════════════════════════════
6. 빠른 시작 / 트러블슈팅
══════════════════════════════════════════════ -->
<section id="quickstart">
<h2>빠른 시작 — 단일 이미지 전체 파이프라인</h2>
<div class="step">
<div class="step-num orange">1</div>
<div class="step-body">
<h4>SAM3.1 서버 시작 (터미널 A)</h4>
<pre><code>cd D:\MYCLAUDE_PROJECT\sam31server
.venv\Scripts\python.exe -m app.main</code></pre>
</div>
</div>
<div class="step">
<div class="step-num orange">2</div>
<div class="step-body">
<h4>[선택] 탐색 모드로 객체 파악 (터미널 B)</h4>
<pre><code>cd D:\MYCLAUDE_PROJECT\railway-client
.venv\Scripts\python.exe tools/sam3_everything_explore.py `
--input "data/역사구간/1.회덕역/.../DJI_20260306100900_0034.JPG" `
--cols 4 --rows 3</code></pre>
</div>
</div>
<div class="step">
<div class="step-num orange">3</div>
<div class="step-body">
<h4>전철주 검출 + JSON annotation 생성</h4>
<pre><code>.venv\Scripts\python.exe tools/detect_all_objects.py `
--input "data/역사구간/1.회덕역/.../DJI_20260306100900_0034.JPG" `
--categories configs/railway_zone.json `
--tiles all --cols 4 --rows 3 --overlap 0.10 --workers 2</code></pre>
</div>
</div>
<div class="step">
<div class="step-num orange">4</div>
<div class="step-body">
<h4>라멘 구조 분석</h4>
<pre><code>.venv\Scripts\python.exe tools/detect_raamen.py `
--image "data/역사구간/1.회덕역/.../DJI_20260306100900_0034.JPG" `
--label "output/detect/DJI_20260306100900_0034.json" `
--output "output/raamen/DJI_20260306100900_0034_raamen.png"</code></pre>
</div>
</div>
</section>
<section id="troubleshoot">
<h2>트러블슈팅</h2>
<table>
<tr><th>오류</th><th>원인</th><th>해결</th></tr>
<tr><td><code>Connection refused :8000</code></td><td>서버 미실행</td><td>sam31server에서 서버 시작</td></tr>
<tr><td><code>Torch not compiled with CUDA</code></td><td>CPU 버전 torch</td><td><code>pip install torch==2.10.0+cu126 --index-url .../cu126</code></td></tr>
<tr><td><code>No module named 'triton'</code></td><td>triton-windows 미설치</td><td><code>pip install triton-windows==3.6.0.post25</code></td></tr>
<tr><td><code>No module named 'decord'</code></td><td>decord 미설치</td><td><code>pip install decord</code></td></tr>
<tr><td>라멘 검출 없음</td><td>catenary_pole conf 너무 높음</td><td>Step 1에서 <code>--conf 0.10</code> 로 낮춤</td></tr>
<tr><td>C/B 분류 오류</td><td>소실점 계산 불안정</td><td><code>--c-thresh</code> 조정 (기본 20°, 높이면 C 기준 완화)</td></tr>
<tr><td>Windows 이벤트루프 경고</td><td>Python 3.12 + uvicorn</td><td><code>python -m uvicorn app.main:app --host 0.0.0.0 --port 8000</code></td></tr>
</table>
<hr>
<p style="color:var(--muted); font-size:.8rem; text-align:center;">Railway Client Guide · SAM 3.1 + FastAPI · 2026-06</p>
</section>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,280 @@
# 철도 디지털 트윈 — AI 라벨링 파이프라인 진행 현황
> 기준일: 2026-05-22
> 프로젝트: `d:\MYCLAUDE_PROJECT\x-anylabeling01`
> 목표: 드론 부감 이미지에서 철도 지장물 자동 탐지 → YOLOv26 학습 데이터 구축 → 디지털 트윈
---
## 1. 탐지 대상 (지장물 카테고리)
| 카테고리 | 한글 | 상태 |
|---|---|---|
| raamen (ラーメン) | 라멘형 전철주 | ✅ 완료 |
| pole | 전철주 일반 | ✅ 완료 |
| rail | 레일 | ✅ 완료 |
| sleeper | 침목 | ✅ 완료 |
| control_box | 컨트롤박스 (소형 금속 박스) | 🔄 진행 중 |
---
## 2. 완료된 작업
### 2-1. 라멘형 전철주 검출 (`tools/detect_raamen.py`)
- **방식**: SAM3.1 텍스트 grounding + VP(소실점) 기반 H/V 분류
- **입력**: 드론 고해상도 이미지 (.JPG), 선택적으로 `--json` (detect_all_objects.py 결과 재사용)
- **출력**: AnyLabeling JSON (폴리곤 + H/V 라벨)
- **핵심 알고리즘**:
- 이미지를 타일 분할 → SAM3.1 병렬 호출
- VP(소실점) 다중 시드 + 반복 정제로 수직(V) / 수평(H) 분류
- H-max-diff 필터로 수평 빔과 수직 기둥 구분
- Cross-NMS로 중복 제거
- **최근 커밋**: `923c396` (VP 다중 시드 + 반복 정제로 V/H 분류 정확도 향상)
### 2-2. 전체 객체 검출 UI (`tools/web_ui.py`)
- FastAPI + uvicorn 기반 웹 UI
- 이미지 선택 → 타일/카테고리 설정 → `detect_all_objects.py` 실행 → 결과 프리뷰
- **개선 사항** (이번 세션):
- 마우스 휠 줌 + 드래그 팬 추가
- `--save-labels``--save-json` 수정 (txt 금지, JSON만)
- `--debug` 플래그 제거 (존재하지 않는 옵션)
- 프리뷰 해상도 1200 → 2000px
### 2-3. 전체 객체 검출 (`tools/detect_all_objects.py`)
- SAM3.1 텍스트 grounding, 카테고리별 타일 검출
- `configs/railway_zone.json` 카테고리 설정 사용
- **출력 경로 개선**: `output/detect/{이미지명}/tiles{N}_{카테고리}_{번호}.jpg`
- `--save-json` 옵션으로 AnyLabeling JSON 저장
### 2-4. SAM3 Everything 탐색 (`tools/sam3_everything_explore.py`)
- 38개 항목 광역 프롬프트로 이미지 전체 탐색
- 타일 병렬 처리 + NMS
- JSON 라벨 통계 출력 (text_prompt 후보 발굴용)
- `--prompt-extra` 옵션으로 추가 어휘 주입 가능
- **테스트 결과** (DJI_20260306113838_0004.JPG, 8×6 타일):
- 총 7,111개 세그먼트 검출
- control_box 관련 라벨 1,740개 매칭
- **문제**: 정밀도 ~1-2%, false positive 폭증 → SAM3 텍스트 grounding 한계 확인
---
## 3. 핵심 결정 사항
### 3-1. YOLOv26 단독 전환 (2026-05-19)
**배경**: SAM3.1 텍스트 grounding으로 control_box 탐지 시도 → 완전 실패
| 시도 | 결과 |
|---|---|
| 고립 프롬프트 ("railway control box, electrical cabinet...") | 0 → 0 검출 |
| conf 0.20 → 0.10 낮춤 | 0 → 0 검출 |
| 16×12 세밀한 타일 그리드 | 0 → 0 검출 |
| 인접 4개 타일 수동 실험 | 0 → 0 검출 |
| 38개 광역 프롬프트 | 1,740개 매칭, 정밀도 ~1-2% |
**결론**: SAM3.1은 부감 드론 시점 소형 객체 텍스트 grounding에 부적합
**전환**: **YOLOv26 학습 데이터셋 구축 → fine-tune → 추론** 경로
**준비 완료**:
- `yolo26n.pt`, `yolo26n-seg.pt` 사전학습 모델 (프로젝트 루트)
- ultralytics 26.x 설치 완료
- X-AnyLabeling-Server YOLO endpoint 연동 완료
### 3-2. SAM3 활용 방식 변경
- **폐기**: SAM3 텍스트 grounding → 단독 검출기
- **유지**: SAM3 everything 모드 → 후보 bbox 생성기 (인간 라벨링 보조)
- **신규**: YOLOv26 fine-tune → production 검출기
---
## 4. 현재 진행 중: control_box 라벨링 파이프라인
### 4-1. 전략 (부트스트랩)
```
SAM3 everything → 1,740개 후보 bbox
labeling_server.py (인간 투표)
MIN_VOTES=3, TRUE_RATIO=0.6 필터
YOLO 학습용 txt 라벨
YOLOv26 fine-tune (yolo26n.pt)
production 검출기
```
### 4-2. 라벨링 서버 (`tools/labeling_server.py`) — v2
**실행 명령:**
```bash
python tools/labeling_server.py \
--json "data/역사이미지/slope/DJI_20260306113838_0004_everything.json" \
--reset
```
**브라우저**: `http://서버IP:7001`
**UI 방식** (v2 — 전체 이미지 오버레이):
- 전체 드론 이미지 표시 (최대 3000px)
- SAM3 후보 1,740개를 색상 bbox로 오버레이
- 🟡 노랑: 미투표
- 🟢 초록: 컨트롤박스 YES
- 🔴 빨강: 아님 NO
- ⬜ 회색: 타인 투표 완료
- 마우스 휠 줌 / 드래그 이동 / F키 맞춤
- bbox 클릭 → YES/NO 즉시 저장 (SQLite)
- 호버 → 라벨명 + 점수 툴팁
**집계 기준**:
- `MIN_VOTES = 3` (3인 이상 투표)
- `TRUE_RATIO = 0.6` (60% 이상 YES)
**YOLO 내보내기**: `POST /api/export``labels/yolo_export/*.txt`
**DB**: `labels/labeling.db` (SQLite, 로컬)
- `candidates` 테이블: json_idx, label, score, bbox, image_path
- `votes` 테이블: candidate_id, user, vote, ts (UNIQUE 제약 — 1인 1표)
### 4-3. 현재 데이터
| 항목 | 값 |
|---|---|
| 입력 이미지 | `data/역사이미지/slope/DJI_20260306113838_0004.JPG` |
| everything JSON | `DJI_20260306113838_0004_everything.json` |
| 전체 세그먼트 | 7,111개 |
| control_box 후보 | 1,740개 |
| 라벨링 진행 | 미시작 (서버 실행 대기) |
---
## 5. 다음 할 일 (TODO)
### 즉시
- [ ] `labeling_server.py` 배포 → 직원 20명 라벨링 시작
- [ ] 추가 이미지 `_everything.json` 생성 (현재 1장 → 더 많이 필요)
### SAM3 Everything 프롬프트 재설계 (⚠️ 중요)
- **문제**: 현재 DISCOVERY_PROMPT 38개 항목이 control_box를 제대로 분리 못함
- **방향**: 더 구체적인 시각적 특징 기반 프롬프트 필요
- 기존: `"small square box", "compact trackside junction box"` 등 → FP 폭증
- 새 방향 검토 필요:
- 크기/형태 명시: `"small gray square metal lid on ground"`, `"square dark gray lid flush with ballast"`
- 재질/색상 특징: `"weathered gray metal surface"`, `"flat square cover"`
- 부감 시점 특화: `"top-down view of small electrical enclosure"`
### 학습 후
- [ ] 50-100개 이상 확정 라벨 → `yolo train` 실행
```bash
yolo train model=yolo26n.pt data=configs/control_box.yaml epochs=100 imgsz=640
```
- [ ] 검출 성능 검증 → 미달 시 데이터 추가 수집
---
## 6. 설정 파일
### `configs/railway_zone.json` (control_box 항목)
```json
{
"name": "control_box",
"name_kr": "컨트롤박스",
"prompt": "small square gray metal box beside rail, compact trackside junction box, small near-square electrical enclosure on the ground, small cube-shaped equipment box next to track",
"conf": 0.15,
"priority": 2
}
```
### SAM3 Everything DISCOVERY_PROMPT 현재 상태 (재설계 필요)
```
"railroad track, railway rail,
catenary pole, overhead line pole, electric pole,
overhead wire, catenary wire, power line cable,
railway sleeper, concrete tie,
guardrail, highway barrier, road fence,
bridge, viaduct, overpass,
vegetation, tree, bush, grass,
building, structure, roof, wall,
vehicle, car, truck,
road, asphalt, pavement,
slope, embankment, retaining wall,
noise barrier, sound wall,
signal, sign board"
```
→ control_box 관련 항목 부재 → everything 탐색에서 누락됨
---
## 7. 기술 제약 / 주의사항
- **CLI 명령어**: bash 한 종류만 사용 (PowerShell 혼용 금지)
- **라벨 저장**: `--save-json` 옵션만 사용 (txt 금지)
- **detect_all_objects.py**: `--workers 8` 항상 명시
- **SAM3 서버**: `python.exe -m app.main` (X-AnyLabeling-Server 디렉토리에서 실행, localhost:8000)
- **GDINO**: 폐기됨. SAM3.1 텍스트 프롬프트 (`params.text_prompt`) 직접 사용
- **YOLOv26**: ultralytics 26.x, 모델 파일 `yolo26n.pt` / `yolo26n-seg.pt` (프로젝트 루트)
---
## 8. 주요 파일 구조
```
x-anylabeling01/
├── tools/
│ ├── detect_all_objects.py # 메인 검출 도구 (SAM3 타일 검출)
│ ├── detect_raamen.py # 라멘형 전철주 전용 검출
│ ├── web_ui.py # 웹 UI (FastAPI, port:8001)
│ ├── labeling_server.py # CAPTCHA 라벨링 서버 (port:7001) ← 신규 v2
│ ├── sam3_everything_explore.py # SAM3 전체 탐색 (프롬프트 발굴용) ← 개선 필요
│ ├── sam3_segment_everything.py # SAM3 포인트 그리드 세그멘테이션
│ └── post_merge_poles.py # 전철주 병합 후처리
├── configs/
│ └── railway_zone.json # 카테고리별 프롬프트/conf/priority
├── labels/
│ ├── labeling.db # 라벨링 투표 SQLite DB
│ └── yolo_export/ # YOLO txt 내보내기 결과
├── output/
│ └── detect/{이미지명}/ # 검출 결과 이미지 (타일/카테고리별)
├── yolo26n.pt # YOLOv26 nano 사전학습 모델
└── yolo26n-seg.pt # YOLOv26 nano segmentation 모델
```
---
## 9. 다음 세션 시작 프롬프트
```
철도 디지털 트윈 프로젝트 (x-anylabeling01) 이어서 진행합니다.
[현재 상태]
- detect_raamen.py (라멘형 전철주) 검출 완료
- detect_all_objects.py (다중 카테고리 SAM3 검출) 완료
- web_ui.py 개선 완료 (줌/팬, --save-json, 폴더 구조)
- labeling_server.py v2 완료 (전체 이미지 + bbox 오버레이, 클릭 투표)
[오늘 할 일: SAM3 Everything 프롬프트 재설계]
tools/sam3_everything_explore.py의 DISCOVERY_PROMPT를 재설계해야 합니다.
현재 문제:
- 기존 38개 항목 광역 프롬프트로 DJI_20260306113838_0004.JPG 처리 시
- 7,111개 세그먼트, control_box 관련 1,740개 매칭 → 정밀도 ~1-2%
- control_box (소형 정사각형 금속 박스, 자갈도상 옆 지면에 놓인 것)가 제대로 분리 안 됨
목표:
- control_box를 효과적으로 탐지할 수 있는 SAM3 텍스트 프롬프트 발굴
- 혹은 SAM3 everything → 후보 bbox 1,740개에서 더 정밀하게 필터링하는 방법 개선
- 최종적으로는 YOLOv26 학습 데이터 50-100개 확보
[기술 제약]
- CLI는 bash만 사용 (PowerShell 혼용 금지)
- 라벨 저장은 --save-json만 (txt 금지)
- SAM3 서버: python.exe -m app.main (X-AnyLabeling-Server에서, localhost:8000)
- detect_all_objects.py 실행 시 --workers 8 항상 명시
```

66
tools/debug_vh.py Normal file
View File

@@ -0,0 +1,66 @@
"""debug_vh.py — catenary_pole V/H 분류 디버그 시각화."""
import argparse
import json
import sys
import cv2
import numpy as np
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from group_ramen_poles import _poly_orient
COLOR = {
'V': (255, 80, 0),
'H': (0, 80, 255),
'?': (160, 160, 160),
'?_ambiguous': (0, 255, 0),
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--label", required=True, help="AnyLabeling JSON")
ap.add_argument("--image", required=True, help="원본 이미지")
ap.add_argument("--output", required=True, help="출력 JPG")
args = ap.parse_args()
data = json.loads(Path(args.label).read_text(encoding="utf-8"))
iH, iW = data["imageHeight"], data["imageWidth"]
buf = np.fromfile(args.image, dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
H, W = img.shape[:2]
font_sc = max(1.0, min(W, H) / 4000)
thick = max(2, int(font_sc * 2))
for full_idx, s in enumerate(data["shapes"]):
if s.get("label") != "catenary_pole":
continue
pts = np.array(s["points"], dtype=np.int32)
o = _poly_orient(s["points"], iH, iW)
col = COLOR.get(o, (160, 160, 160))
ov = img.copy()
cv2.fillPoly(ov, [pts], col)
cv2.addWeighted(ov, 0.30, img, 0.70, 0, img)
cv2.polylines(img, [pts], True, col, 3)
cx, cy = int(pts[:, 0].mean()), int(pts[:, 1].mean())
label = f"{full_idx}{o}"
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_sc, thick)
tx, ty = cx - tw // 2, cy + th // 2
cv2.rectangle(img, (tx-3, ty-th-4), (tx+tw+3, ty+4), (0, 0, 0), -1)
cv2.putText(img, label, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX, font_sc, (255,255,255), thick+1, cv2.LINE_AA)
cv2.putText(img, label, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX, font_sc, col, thick, cv2.LINE_AA)
h, w = img.shape[:2]
scale = min(1.0, 4096 / max(h, w))
if scale < 1.0:
img = cv2.resize(img, (int(w * scale), int(h * scale)))
out = Path(args.output)
out.parent.mkdir(parents=True, exist_ok=True)
cv2.imencode(out.suffix, img)[1].tofile(str(out))
print(f"saved: {out}")
if __name__ == "__main__":
main()

View File

@@ -161,104 +161,6 @@ def nms_shapes(shapes: list, iou_thresh: float = 0.4) -> list:
return _nms_core(shapes, iou_thresh)
def _poly_orient(points: list, H: int, W: int) -> str: # post_merge_poles.py에서도 사용
"""폴리곤 장축 방향 판별 (render_skeleton_overlay.py 동일 로직).
V: 장축이 이미지 중심에서 방사형 방향과 정렬 (cos_sim > 0.7) → 세로 기둥
H: 장축이 radial 직교 방향 → 수평 빔
?: aspect ratio < 1.3 으로 판별 불가
"""
pts = np.array(points, dtype=np.float32)
rect = cv2.minAreaRect(pts)
(rx, ry), (rw, rh), angle = rect
if min(rw, rh) < 1:
return '?'
ar = max(rw, rh) / min(rw, rh)
if ar < 1.3:
return '?'
long_angle_deg = angle if rw >= rh else angle + 90
lx = float(np.cos(np.radians(long_angle_deg)))
ly = float(np.sin(np.radians(long_angle_deg)))
img_cx, img_cy = W / 2.0, H / 2.0
rdx, rdy = rx - img_cx, ry - img_cy
radial_norm = (rdx ** 2 + rdy ** 2) ** 0.5
if radial_norm < 1:
return '?'
rdx, rdy = rdx / radial_norm, rdy / radial_norm
cos_sim = abs(lx * rdx + ly * rdy)
return 'V' if cos_sim > 0.7 else 'H'
def merge_nonramen_poles(shapes: list, H: int, W: int,
x_overlap_thresh: float = 0.30,
y_gap_thresh: int = 150) -> list:
"""타일 경계 분할된 전철주 병합 — V+V 조합만 허용.
_poly_orient로 각 폴리곤 V/H 분류.
두 폴리곤 모두 V(세로 기둥)이고 공간 기준 충족 시만 병합.
H(수평 빔) 포함 쌍 = 라멘 관련 조각 → 병합 건너뜀.
"""
if len(shapes) <= 1:
return shapes
orients = [_poly_orient(s["points"], H, W) for s in shapes]
v_count = sum(1 for o in orients if o == 'V')
h_count = sum(1 for o in orients if o == 'H')
print(f" [orient] V={v_count}, H={h_count}, ?={len(orients)-v_count-h_count}")
def get_bbox(s):
xs = [p[0] for p in s["points"]]; ys = [p[1] for p in s["points"]]
return min(xs), min(ys), max(xs), max(ys)
def x_overlap_ratio(b1, b2):
ox = min(b1[2], b2[2]) - max(b1[0], b2[0])
ux = max(b1[2], b2[2]) - min(b1[0], b2[0])
return ox / ux if ux > 0 else 0.0
def y_gap(b1, b2):
return max(0.0, max(b1[1], b2[1]) - min(b1[3], b2[3]))
def merge_two(s1, s2):
mask = np.zeros((H, W), dtype=np.uint8)
for s in (s1, s2):
cv2.fillPoly(mask, [np.array(s["points"], dtype=np.int32)], 255)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return s1
c = max(contours, key=cv2.contourArea)
eps = 0.002 * cv2.arcLength(c, True)
approx = cv2.approxPolyDP(c, eps, True)
merged = dict(s1)
merged["points"] = [[float(p[0][0]), float(p[0][1])] for p in approx]
merged["score"] = max(float(s1.get("score", 0)), float(s2.get("score", 0)))
return merged
merged_flags = [False] * len(shapes)
result = []
merged_count = 0
for i in range(len(shapes)):
if merged_flags[i]:
continue
cur = shapes[i]
cur_ori = orients[i]
cb = get_bbox(cur)
for j in range(i + 1, len(shapes)):
if merged_flags[j]:
continue
if cur_ori != 'V' or orients[j] != 'V':
continue # 둘 다 V가 아니면 병합 안 함
jb = get_bbox(shapes[j])
if x_overlap_ratio(cb, jb) >= x_overlap_thresh and y_gap(cb, jb) <= y_gap_thresh:
cur = merge_two(cur, shapes[j])
cur_ori = 'V'
cb = get_bbox(cur)
merged_flags[j] = True
merged_count += 1
result.append(cur)
print(f" [merge] 병합={merged_count}")
return result
def cross_class_nms(buckets: list, categories: list, iou_thresh: float) -> list:
"""클래스 간 NMS: 동일 영역에 다른 클래스가 중복 검출될 때 우선순위 높은 쪽 보존.
@@ -508,9 +410,9 @@ def main():
else:
tile_tag = args.tiles.replace(",", "_").replace("-", "to")
cat_tag = Path(args.categories).stem if args.categories else "default"
out_dir = Path("output") / "detect" / img_path.stem
out_dir = Path("output") / "detect"
out_dir.mkdir(parents=True, exist_ok=True)
base_name = f"tiles{tile_tag}_{cat_tag}"
base_name = f"{img_path.stem}_tiles{tile_tag}_{cat_tag}"
n = 1
while True:
out_path = out_dir / f"{base_name}_{n:03d}.jpg"

123
tools/detect_control_box.py Normal file
View File

@@ -0,0 +1,123 @@
"""
SAM3.1 control_box 단일 이미지 검출 + 결과 저장.
사용:
python tools/detect_control_box.py --input <image_path> [--conf 0.05] [--output <path>]
"""
import argparse
import base64
import json
from collections import Counter
from pathlib import Path
import cv2
import numpy as np
import requests
SAM3_SERVER = "http://localhost:8000"
PROMPT = (
"small dark object on ballast, small box on ballast, "
"metal cover on ground, small bright object on gravel, "
"square lid on ground, control box"
)
PALETTE = [
(0, 80, 255), (0, 200, 0), (255, 100, 0),
(180, 0, 255), (0, 220, 220), (255, 180, 0),
]
def detect(img_path: Path, conf: float) -> list:
buf = np.fromfile(str(img_path), dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if img is None:
raise FileNotFoundError(img_path)
_, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 95])
b64 = base64.b64encode(enc).decode()
r = requests.post(f"{SAM3_SERVER}/v1/predict", json={
"model": "segment_anything_3",
"image": b64,
"params": {
"text_prompt": PROMPT,
"conf_threshold": conf,
"show_masks": True,
"show_boxes": False,
},
}, timeout=120)
r.raise_for_status()
shapes = r.json().get("data", {}).get("shapes", [])
return img, [s if isinstance(s, dict) else s.dict() for s in shapes]
def render(img, shapes, out_path: Path):
all_labels = sorted(set(s.get("label", "") for s in shapes))
lc = {l: PALETTE[i % len(PALETTE)] for i, l in enumerate(all_labels)}
canvas = img.copy()
font = cv2.FONT_HERSHEY_SIMPLEX
for s in shapes:
if s.get("shape_type") != "polygon":
continue
pts = np.array(s["points"], dtype=np.int32)
color = lc.get(s.get("label", ""), (128, 128, 128))
ov = canvas.copy()
cv2.fillPoly(ov, [pts], color)
cv2.addWeighted(ov, 0.20, canvas, 0.80, 0, canvas)
cv2.polylines(canvas, [pts], True, color, 1)
cx = int(np.mean([p[0] for p in s["points"]]))
cy = int(np.mean([p[1] for p in s["points"]]))
short = (s.get("label", "")
.replace("small ", "")
.replace(" on ballast", "")
.replace(" on ground", "")
.replace(" on gravel", ""))
cv2.putText(canvas, short, (cx, cy), font, 0.35, color, 1, cv2.LINE_AA)
y = 20
for lbl, color in sorted(lc.items()):
cnt = sum(1 for s in shapes if s.get("label") == lbl)
cv2.rectangle(canvas, (10, y - 12), (22, y), color, -1)
cv2.putText(canvas, f"{lbl} ({cnt})", (26, y), font, 0.40, color, 1, cv2.LINE_AA)
y += 16
out_path.parent.mkdir(parents=True, exist_ok=True)
cv2.imencode(".png", canvas)[1].tofile(str(out_path))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--input", required=True, type=Path)
ap.add_argument("--output", default=None, type=Path)
ap.add_argument("--conf", type=float, default=0.05)
ap.add_argument("--save-json", action="store_true")
args = ap.parse_args()
out = args.output or args.input.with_name(args.input.stem + "_detected.png")
print(f"input : {args.input}")
print(f"output: {out}")
print("detecting...")
img, shapes = detect(args.input, args.conf)
render(img, shapes, out)
counter = Counter(s.get("label", "") for s in shapes)
print(f"total : {len(shapes)}")
for lbl, cnt in counter.most_common():
print(f" {lbl}: {cnt}")
if args.save_json:
jpath = out.with_suffix(".json")
jpath.write_text(json.dumps({
"source": str(args.input),
"total": len(shapes),
"label_counts": dict(counter),
"shapes": [{"label": s.get("label",""), "score": s.get("score",0),
"points": s.get("points",[])} for s in shapes],
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"json : {jpath}")
print("done.")
if __name__ == "__main__":
main()

View File

@@ -4,14 +4,14 @@
파이프라인:
Phase 1: 폴리곤 단순화(approxPolyDP) + 소실점(Vanishing Point) 계산
Phase 2: 동적 V/H 분류 (소실점 기반 기대 각도)
Phase 3: 근접성 기반 그룹핑 (H 앵커 → 아래 V 탐색)
Phase 2: 동적 C/B 분류 (소실점 기반 기대 각도) — C=기둥(Column), B=빔(Beam)
Phase 3: 근접성 기반 그룹핑 (B 앵커 → 아래 C 탐색)
Phase 4: 라멘 구조 판정 + 예외(가림) 처리
사용:
python tools/detect_raamen.py \
--image <path> --label <path> --output <path> \
[--class-ids 1] [--epsilon 4.0] [--v-thresh 20.0]
[--class-ids 1] [--epsilon 4.0] [--c-thresh 20.0]
"""
import argparse
import numpy as np
@@ -110,9 +110,8 @@ def compute_vanishing_point(polys):
return float(vp[0]), float(vp[1])
def _estimate_vp_iterative(polys, seed_indices, v_thresh, h_max_diff, vp_min_len,
x_horiz_thresh=10.0, max_iter=6):
"""초기 후보에서 반복 정제 VP 추정. Returns (vp_x, vp_y, n_v, orients, adiffs)."""
def _estimate_vp_iterative(polys, seed_indices, c_thresh, b_max_diff, vp_min_len, max_iter=6):
"""초기 후보에서 반복 정제 VP 추정. Returns (vp_x, vp_y, n_c, orients, adiffs)."""
n = len(polys)
orients = ['?'] * n
adiffs = [90.0] * n
@@ -121,64 +120,144 @@ def _estimate_vp_iterative(polys, seed_indices, v_thresh, h_max_diff, vp_min_len
vp_x, vp_y = compute_vanishing_point([polys[i] for i in seed_indices])
for _ in range(max_iter):
for i, pts in enumerate(polys):
orients[i], adiffs[i] = classify_vh(pts, vp_x, vp_y, v_thresh, h_max_diff,
x_horiz_thresh)
v_cands = [i for i in range(n)
if orients[i] == 'V' and _long_axis_angle(polys[i])[3] > vp_min_len]
if len(v_cands) < 3:
orients[i], adiffs[i] = classify_cb(pts, vp_x, vp_y, c_thresh, b_max_diff)
c_cands = [i for i in range(n)
if orients[i] == 'C' and _long_axis_angle(polys[i])[3] > vp_min_len]
if len(c_cands) < 3:
break
nx, ny = compute_vanishing_point([polys[i] for i in v_cands])
nx, ny = compute_vanishing_point([polys[i] for i in c_cands])
shift = ((nx - vp_x) ** 2 + (ny - vp_y) ** 2) ** 0.5
vp_x, vp_y = nx, ny
if shift < 5.0:
for i, pts in enumerate(polys):
orients[i], adiffs[i] = classify_vh(pts, vp_x, vp_y, v_thresh, h_max_diff,
x_horiz_thresh)
orients[i], adiffs[i] = classify_cb(pts, vp_x, vp_y, c_thresh, b_max_diff)
break
return vp_x, vp_y, orients.count('V'), orients, adiffs
return vp_x, vp_y, orients.count('C'), orients, adiffs
# ── Phase 2: 동적 V/H 분류 ──────────────────────────────────────────────
# ── Phase 2: 동적 C/B 분류 ──────────────────────────────────────────────
def classify_vh(pts, vp_x, vp_y, v_thresh, h_max_diff=75.0, x_horiz_thresh=10.0):
def classify_cb(pts, vp_x, vp_y, c_thresh, b_max_diff=75.0):
"""
소실점 기준 V/H 분류.
- 이미지 절대 수평(X축 ±x_horiz_thresh°) AND AR≥4 → '?' (레일·전선 등)
- diff < v_thresh V (기둥)
- v_thresh ≤ diff < h_max_diff → H (빔)
- diff ≥ h_max_diff → '?'
소실점 기준 C/B 분류. C=기둥(Column), B=빔(Beam).
- diff < c_thresh → C (기둥)
- c_thresh ≤ diff < b_max_diffB ()
- diff ≥ b_max_diff '?' (레일/전선 등 비라멘 수평 구조물)
Returns: (orient, angle_diff_deg)
orient = 'C' | 'B' | '?'
"""
long_angle_deg, cx, cy, long_side, short_side = _long_axis_angle(pts)
if short_side < 1 or long_side / short_side < 1.3:
return '?', 90.0
# 절대 수평 제외: X축 ±x_horiz_thresh° 이내 + AR≥4 (레일·전선 등 가느다란 수평체)
abs_from_horiz = long_angle_deg % 180.0
if abs_from_horiz > 90.0:
abs_from_horiz = 180.0 - abs_from_horiz
if abs_from_horiz < x_horiz_thresh and long_side / short_side >= 4.0:
return '?', 90.0
# VP 기준 상대 각도 분류
# 기대 수직 각도: 폴리곤 중심 → 소실점 방향
exp_angle = np.degrees(np.arctan2(vp_y - cy, vp_x - cx))
# 장축은 방향 무관 → 0~90° 범위로 정규화
diff = abs(long_angle_deg - exp_angle) % 180.0
if diff > 90.0:
diff = 180.0 - diff
if diff < v_thresh:
return 'V', diff
if diff < h_max_diff:
return 'H', diff
if diff < c_thresh:
return 'C', diff
if diff < b_max_diff:
return 'B', diff
return '?', diff
# ── Phase 3: 폴리곤 접촉/교차 기반 그룹핑 ───────────────────────────────
def _poly_union(polys_list):
"""여러 폴리곤의 rasterization union 외곽 contour 반환 (실제 union shape)."""
all_pts = np.vstack(polys_list)
x0 = int(all_pts[:, 0].min()) - 1
y0 = int(all_pts[:, 1].min()) - 1
x1 = int(all_pts[:, 0].max()) + 2
y1 = int(all_pts[:, 1].max()) + 2
w, h = x1 - x0, y1 - y0
mask = np.zeros((h, w), dtype=np.uint8)
off = np.array([[x0, y0]], dtype=np.float32)
for pts in polys_list:
cv2.fillPoly(mask, [(pts - off).astype(np.int32)], 255)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return all_pts.astype(np.float32)
cnt = max(contours, key=cv2.contourArea).reshape(-1, 2).astype(np.float32)
return cnt + [x0, y0]
def _polys_intersect(pts_i, pts_j, bbox_i, bbox_j):
"""두 폴리곤의 실제 픽셀 교차 여부 확인 (rasterization 기반)."""
ax0, ay0, ax1, ay1 = bbox_i
bx0, by0, bx1, by1 = bbox_j
ix0 = max(int(ax0), int(bx0))
iy0 = max(int(ay0), int(by0))
ix1 = min(int(ax1), int(bx1))
iy1 = min(int(ay1), int(by1))
if ix0 >= ix1 or iy0 >= iy1:
return False
w, h = ix1 - ix0 + 1, iy1 - iy0 + 1
off = np.array([[ix0, iy0]], dtype=np.float32)
mi = np.zeros((h, w), dtype=np.uint8)
mj = np.zeros((h, w), dtype=np.uint8)
cv2.fillPoly(mi, [(pts_i - off).astype(np.int32)], 1)
cv2.fillPoly(mj, [(pts_j - off).astype(np.int32)], 1)
return bool(np.any(mi & mj))
def remove_beam_center_small(polys, orients, poly_abs_idx,
center_ratio=0.30, area_ratio=0.15, min_beam_len=100):
"""
B(빔) 폴리곤 장축의 중앙 center_ratio 구간에 위치한 소형 폴리곤 제거.
소형 기준: 해당 B 면적의 area_ratio 미만.
"""
n = len(polys)
remove_set = set()
for i in range(n):
if orients[i] != 'B':
continue
la, bcx, bcy, long_side, short_side = _long_axis_angle(polys[i])
if long_side < min_beam_len:
continue
b_area = cv2.contourArea(polys[i].astype(np.int32))
if b_area <= 0:
continue
angle_rad = np.radians(la)
ux, uy = np.cos(angle_rad), np.sin(angle_rad)
pts_b = polys[i]
proj_b = pts_b[:, 0] * ux + pts_b[:, 1] * uy
proj_center = (proj_b.min() + proj_b.max()) / 2.0
half = (proj_b.max() - proj_b.min()) * center_ratio / 2.0
for j in range(n):
if j == i or j in remove_set:
continue
if orients[j] not in ('B', '?'): # C(기둥)은 절대 제거 안 함
continue
j_area = cv2.contourArea(polys[j].astype(np.int32))
if j_area >= b_area * area_ratio:
continue
jcx = float(polys[j][:, 0].mean())
jcy = float(polys[j][:, 1].mean())
proj_j = jcx * ux + jcy * uy
if proj_center - half <= proj_j <= proj_center + half:
remove_set.add(j)
keep = [i for i in range(n) if i not in remove_set]
if remove_set:
print(f" [B 중앙부 소형 제거] {len(remove_set)}개 idx={sorted(remove_set)}")
polys = [polys[i] for i in keep]
orients = [orients[i] for i in keep]
poly_abs_idx = [poly_abs_idx[i] for i in keep]
return polys, orients, poly_abs_idx, keep
def connectivity_groups(polys, orients, margin=30):
"""
폴리곤 bbox가 margin px 이내로 닿거나 교차하면 같은 그룹 (H/V 구분 없음).
Union-Find로 연결된 폴리곤들을 묶은 뒤, 각 그룹 내에서 H/V 목록 분리.
Returns: list of {'id': int, 'H': [idx,...], 'V': [idx,...]}
폴리곤 bbox가 margin px 이내로 닿거나 교차하면 같은 그룹 (B/C 구분 없음).
Union-Find로 연결된 폴리곤들을 묶은 뒤, 각 그룹 내에서 B/C 목록 분리.
Returns: list of {'id': int, 'B': [idx,...], 'C': [idx,...]}
"""
n = len(polys)
parent = list(range(n))
@@ -217,14 +296,14 @@ def connectivity_groups(polys, orients, margin=30):
groups = []
for gid, members in enumerate(comp.values(), 1):
h_list = sorted(i for i in members if orients[i] == 'H')
v_list = sorted(i for i in members if orients[i] == 'V')
groups.append({'id': gid, 'H': h_list, 'V': v_list})
b_list = sorted(i for i in members if orients[i] == 'B')
c_list = sorted(i for i in members if orients[i] == 'C')
groups.append({'id': gid, 'B': b_list, 'C': c_list})
# 면적 내림차순으로 ID 재부여 (큰 그룹이 G1)
for g in groups:
g['area'] = sum(cv2.contourArea(polys[i].astype(np.int32))
for i in g['H'] + g['V'])
for i in g['B'] + g['C'])
groups.sort(key=lambda x: x['area'], reverse=True)
for gid, g in enumerate(groups, 1):
g['id'] = gid
@@ -232,6 +311,92 @@ def connectivity_groups(polys, orients, margin=30):
return groups
def reclassify_center_groups(groups, polys, W, center_ratio=0.2):
"""
RAAMEN_CENTER 후보 그룹 (B 없음, C 2개 이상, 이미지 중앙):
가장 아래(+Y 최대) C 폴리곤만 C(기둥)로 유지, 나머지 C → B(빔)로 재분류.
"""
for g in groups:
if g['B'] or len(g['C']) < 2:
continue
all_c_pts = np.vstack([polys[i] for i in g['C']])
gcx = float(all_c_pts[:, 0].mean())
if abs(gcx - W / 2) >= W * center_ratio:
continue
bottom_c = max(g['C'], key=lambda i: float(polys[i][:, 1].mean()))
g['B'] = sorted(i for i in g['C'] if i != bottom_c)
g['C'] = [bottom_c]
return groups
def merge_intersecting_same_type(groups, polys, poly_abs_idx):
"""
각 그룹 내에서 교차하는 동일 타입(B↔B, C↔C) 폴리곤들을 convex hull로 병합.
교차하는 폴리곤 클러스터 → 하나의 합성 폴리곤으로 대체.
Returns: (groups, extended_polys, extended_abs_idx)
- extended_polys: 원본 + 새 병합 폴리곤 (append 방식, 원본 인덱스 유지)
- 병합된 폴리곤의 abs_idx는 원본 인덱스 리스트를 저장 (추적용)
"""
from collections import defaultdict
ext_polys = list(polys)
ext_abs_idx = list(poly_abs_idx)
for g in groups:
for key in ('B', 'C'):
members = g[key]
if len(members) <= 1:
continue
n = len(members)
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
bboxes_m = [(ext_polys[members[k]][:, 0].min(), ext_polys[members[k]][:, 1].min(),
ext_polys[members[k]][:, 0].max(), ext_polys[members[k]][:, 1].max())
for k in range(n)]
for a in range(n):
for b in range(a + 1, n):
if _polys_intersect(ext_polys[members[a]], ext_polys[members[b]],
bboxes_m[a], bboxes_m[b]):
union(a, b)
clusters = defaultdict(list)
for k in range(n):
clusters[find(k)].append(members[k])
new_members = []
for cluster in clusters.values():
if len(cluster) == 1:
new_members.append(cluster[0])
else:
cluster_polys = [ext_polys[i] for i in cluster]
union_pts = _poly_union(cluster_polys)
new_idx = len(ext_polys)
ext_polys.append(union_pts)
flat = []
for i in cluster:
v = ext_abs_idx[i]
flat.extend(v) if isinstance(v, list) else flat.append(v)
ext_abs_idx.append(sorted(flat))
new_members.append(new_idx)
g[key] = sorted(new_members)
return groups, ext_polys, ext_abs_idx
# ── Phase 4: 라멘 구조 판정 ─────────────────────────────────────────────
def _cluster_polys(indices, polys, margin=60):
@@ -272,57 +437,75 @@ def _cluster_polys(indices, polys, margin=60):
def judge_raamen(group, polys, W, center_ratio=0.2, v_cluster_margin=60):
"""
라멘 구조 판정.
- 빔 기준: 그룹 내 가장 큰 H 폴리곤
- 기둥 수: V 폴리곤을 근접 클러스터링한 클러스터 수
- 빔 x 범위(±50%) 밖의 V 클러스터는 잡폴리곤으로 무시
- 빔 기준: 그룹 내 가장 큰 B 폴리곤
- 기둥 수: C 폴리곤을 근접 클러스터링한 클러스터 수
- 빔 x 범위(±50%) 밖의 C 클러스터는 잡폴리곤으로 무시
Returns: ('RAAMEN' | 'RAAMEN_OCCLUDED' | 'PARTIAL' | '', n_poles)
"""
hs, vs = group['H'], group['V']
bs, cs = group['B'], group['C']
# H 없음: 중앙 영역이면 V/H 분류 자체가 신뢰 불가 (기둥·빔 각도 수렴)
# → 2개 이상의 V 폴리곤이 중앙에 모여 있으면 RAAMEN_CENTER 처리
if not hs:
if len(vs) >= 2:
all_v_pts = np.vstack([polys[i] for i in vs])
gcx = float(all_v_pts[:, 0].mean())
# B 없음: 중앙 영역이면 C/B 분류 자체가 신뢰 불가 (기둥·빔 각도 수렴)
# → 2개 이상의 C 폴리곤이 중앙에 모여 있으면 RAAMEN_CENTER 처리
if not bs:
if len(cs) >= 2:
all_c_pts = np.vstack([polys[i] for i in cs])
gcx = float(all_c_pts[:, 0].mean())
if abs(gcx - W / 2) < W * center_ratio:
return 'RAAMEN_CENTER', len(vs)
return 'RAAMEN_CENTER', len(cs)
return '', 0
# 큰 H 폴리곤 최대 2개를 빔 기준으로 사용 (2nd가 1st 면적의 50% 이상이면 포함)
h_by_area = sorted(hs, key=lambda i: cv2.contourArea(polys[i].astype(np.int32)), reverse=True)
main_hs = [h_by_area[0]]
if len(h_by_area) > 1:
a0 = cv2.contourArea(polys[h_by_area[0]].astype(np.int32))
a1 = cv2.contourArea(polys[h_by_area[1]].astype(np.int32))
# 큰 B 폴리곤 최대 2개를 빔 기준으로 사용 (2nd가 1st 면적의 50% 이상이면 포함)
b_by_area = sorted(bs, key=lambda i: cv2.contourArea(polys[i].astype(np.int32)), reverse=True)
main_bs = [b_by_area[0]]
if len(b_by_area) > 1:
a0 = cv2.contourArea(polys[b_by_area[0]].astype(np.int32))
a1 = cv2.contourArea(polys[b_by_area[1]].astype(np.int32))
if a1 >= a0 * 0.5:
main_hs.append(h_by_area[1])
hx0 = int(min(polys[i][:, 0].min() for i in main_hs))
hx1 = int(max(polys[i][:, 0].max() for i in main_hs))
main_bs.append(b_by_area[1])
# 이미지 중앙 여부 판정 (X축 기반 유지)
hx0 = int(min(polys[i][:, 0].min() for i in main_bs))
hx1 = int(max(polys[i][:, 0].max() for i in main_bs))
hcx = (hx0 + hx1) / 2.0
is_center = abs(hcx - W / 2) < W * center_ratio
span = hx1 - hx0
# V 폴리곤 클러스터링 → 기둥 단위
pole_clusters = _cluster_polys(vs, polys, margin=v_cluster_margin)
# 빔 장축(major axis) 방향으로 투영 — 대각 빔도 정확하게 span 계산
la, _, _, _, _ = _long_axis_angle(polys[main_bs[0]])
angle_rad = np.radians(la)
ux, uy = np.cos(angle_rad), np.sin(angle_rad)
beam_pts = np.vstack([polys[i] for i in main_bs])
proj_beam = beam_pts[:, 0] * ux + beam_pts[:, 1] * uy
proj_min, proj_max = float(proj_beam.min()), float(proj_beam.max())
span = proj_max - proj_min
# 기둥은 빔 양 끝단(좌 35% / 우 35%)에만 존재. 중앙부 클러스터는 부속물로 제외.
x_tol = max(span * 0.5, 50)
left_zone = hx0 + span * 0.35 # 좌끝단 경계
right_zone = hx1 - span * 0.35 # 우끝단 경계
valid_cxs = []
# C 폴리곤 클러스터링 → 기둥 단위
pole_clusters = _cluster_polys(cs, polys, margin=v_cluster_margin)
# 기둥은 빔 양 끝단(좌 35% / 우 35%)에만 존재 (장축 투영 기준)
p_tol = max(span * 0.5, 50)
left_zone = proj_min + span * 0.35
right_zone = proj_max - span * 0.35
valid_projs = []
for cluster in pole_clusters:
ccx = float(np.mean([polys[i][:, 0].mean() for i in cluster]))
in_range = hx0 - x_tol <= ccx <= hx1 + x_tol
in_end_zone = ccx <= left_zone or ccx >= right_zone
# 멤버별 투영값 계산
member_projs = [polys[i][:, 0].mean() * ux + polys[i][:, 1].mean() * uy
for i in cluster]
proj_c = float(np.mean(member_projs)) # 중심값 (range 체크)
proj_lo = min(member_projs) # 가장 왼쪽 끝
proj_hi = max(member_projs) # 가장 오른쪽 끝
in_range = proj_min - p_tol <= proj_c <= proj_max + p_tol
# 클러스터 안 멤버 중 하나라도 끝단에 있으면 유효 (43C 같은 중간 노이즈가 흡수돼도 기둥 인식)
in_end_zone = proj_lo <= left_zone or proj_hi >= right_zone
if in_range and in_end_zone:
valid_cxs.append(ccx)
# 끝단 대표값: 왼쪽 끝단이면 proj_lo, 오른쪽 끝단이면 proj_hi
rep = proj_lo if proj_lo <= left_zone else proj_hi
valid_projs.append(rep)
n_poles = len(valid_cxs)
n_poles = len(valid_projs)
if n_poles >= 2:
lcx, rcx = min(valid_cxs), max(valid_cxs)
if lcx <= hx0 + span * 0.4 and rcx >= hx1 - span * 0.4:
lp, rp = min(valid_projs), max(valid_projs)
if lp <= proj_min + span * 0.4 and rp >= proj_max - span * 0.4:
return 'RAAMEN', n_poles
return 'PARTIAL', n_poles
@@ -342,45 +525,54 @@ def _merge_poly_hull(indices, polys):
def group_detail(group, polys, W, center_ratio=0.2, v_cluster_margin=60):
"""
라멘 그룹의 세부 구성 분석.
Returns dict: main_h, junk_h, valid_pole_clusters, attach_clusters
Returns dict: main_b, junk_b, valid_pole_clusters, attach_clusters
"""
hs, vs = group['H'], group['V']
if not hs:
return {'main_h': None, 'junk_h': [], 'valid_pole_clusters': [], 'attach_clusters': []}
bs, cs = group['B'], group['C']
if not bs:
return {'main_b': None, 'junk_b': [], 'valid_pole_clusters': [], 'attach_clusters': []}
h_by_area = sorted(hs, key=lambda i: cv2.contourArea(polys[i].astype(np.int32)), reverse=True)
main_hs = [h_by_area[0]]
if len(h_by_area) > 1:
a0 = cv2.contourArea(polys[h_by_area[0]].astype(np.int32))
a1 = cv2.contourArea(polys[h_by_area[1]].astype(np.int32))
b_by_area = sorted(bs, key=lambda i: cv2.contourArea(polys[i].astype(np.int32)), reverse=True)
main_bs = [b_by_area[0]]
if len(b_by_area) > 1:
a0 = cv2.contourArea(polys[b_by_area[0]].astype(np.int32))
a1 = cv2.contourArea(polys[b_by_area[1]].astype(np.int32))
if a1 >= a0 * 0.5:
main_hs.append(h_by_area[1])
main_h = main_hs[0] # JSON 출력용 대표 빔
junk_h = [i for i in hs if i not in main_hs]
main_bs.append(b_by_area[1])
main_b = main_bs[0] # JSON 출력용 대표 빔
junk_b = [i for i in bs if i not in main_bs]
hx0 = int(min(polys[i][:, 0].min() for i in main_hs))
hx1 = int(max(polys[i][:, 0].max() for i in main_hs))
span = hx1 - hx0
# 빔 장축(major axis) 투영 기반 span 계산
la, _, _, _, _ = _long_axis_angle(polys[main_bs[0]])
angle_rad = np.radians(la)
ux, uy = np.cos(angle_rad), np.sin(angle_rad)
beam_pts = np.vstack([polys[i] for i in main_bs])
proj_beam = beam_pts[:, 0] * ux + beam_pts[:, 1] * uy
proj_min, proj_max = float(proj_beam.min()), float(proj_beam.max())
span = proj_max - proj_min
pole_clusters = _cluster_polys(vs, polys, margin=v_cluster_margin)
x_tol = max(span * 0.5, 50)
left_zone = hx0 + span * 0.35
right_zone = hx1 - span * 0.35
pole_clusters = _cluster_polys(cs, polys, margin=v_cluster_margin)
p_tol = max(span * 0.5, 50)
left_zone = proj_min + span * 0.35
right_zone = proj_max - span * 0.35
valid_pole_clusters, attach_clusters = [], []
for cluster in pole_clusters:
ccx = float(np.mean([polys[i][:, 0].mean() for i in cluster]))
in_range = hx0 - x_tol <= ccx <= hx1 + x_tol
in_end_zone = ccx <= left_zone or ccx >= right_zone
member_projs = [polys[i][:, 0].mean() * ux + polys[i][:, 1].mean() * uy
for i in cluster]
proj_c = float(np.mean(member_projs))
proj_lo = min(member_projs)
proj_hi = max(member_projs)
in_range = proj_min - p_tol <= proj_c <= proj_max + p_tol
in_end_zone = proj_lo <= left_zone or proj_hi >= right_zone
if in_range and in_end_zone:
valid_pole_clusters.append(cluster)
elif in_range:
attach_clusters.append(cluster)
return {
'main_h': main_h,
'main_hs': main_hs,
'junk_h': junk_h,
'main_b': main_b,
'main_bs': main_bs,
'junk_b': junk_b,
'valid_pole_clusters': valid_pole_clusters,
'attach_clusters': attach_clusters,
}
@@ -388,14 +580,14 @@ def group_detail(group, polys, W, center_ratio=0.2, v_cluster_margin=60):
# ── 시각화 상수 ─────────────────────────────────────────────────────────
_VH_COLOR = {
'V': (255, 80, 0), # 주황 (수직 기둥)
'H': ( 0, 80, 255), # 파란 (수평 빔)
_CB_COLOR = {
'C': (255, 80, 0), # 주황 (기둥 Column)
'B': ( 0, 80, 255), # 파란 (빔 Beam)
'?': (140, 140, 140), # 회색 (미분류)
}
_RAAMEN_COLOR = {
'RAAMEN': ( 0, 255, 0), # 초록
'RAAMEN_CENTER': ( 0, 255, 255), # 노랑 (중앙 영역, H/V 분류 불신뢰)
'RAAMEN_CENTER': ( 0, 255, 255), # 노랑 (중앙 영역, C/B 분류 불신뢰)
'RAAMEN_OCCLUDED': ( 0, 165, 255), # 주황 (가림/부분 검출)
'PARTIAL': (128, 128, 128), # 회색
}
@@ -404,9 +596,8 @@ _RAAMEN_COLOR = {
# ── 메인 렌더링 ─────────────────────────────────────────────────────────
def render(image_path, label_path, output_path, args,
class_ids=None, class_names=None, epsilon=4.0, v_thresh=20.0,
h_max_diff=75.0, vp_min_ar=2.5, vp_min_len=80.0, vp_outer_ratio=0.2,
x_horiz_thresh=10.0):
class_ids=None, class_names=None, epsilon=4.0, c_thresh=20.0,
b_max_diff=75.0, vp_min_ar=2.5, vp_min_len=80.0, vp_outer_ratio=0.2):
buf = np.fromfile(str(image_path), dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
@@ -414,12 +605,27 @@ def render(image_path, label_path, output_path, args,
# ── Phase 1 ──────────────────────────────────────────────────────────
raw_polys, poly_abs_idx = load_polygons(label_path, W, H, class_ids, class_names)
polys = [smooth_polygon(p, epsilon) for p in raw_polys]
print(f" {len(polys)}개 폴리곤 파싱 (epsilon={epsilon})")
polys_all = [smooth_polygon(p, epsilon) for p in raw_polys]
# 가로:세로 > 4:1 → 전철주 아님 (레일·전선 등), 제거
keep = []
skipped = []
for i, pts in enumerate(polys_all):
bw = pts[:, 0].max() - pts[:, 0].min()
bh = pts[:, 1].max() - pts[:, 1].min()
if bh > 0 and bw / bh > 4.0:
skipped.append(poly_abs_idx[i])
else:
keep.append(i)
polys = [polys_all[i] for i in keep]
poly_abs_idx = [poly_abs_idx[i] for i in keep]
print(f" {len(polys_all)}개 파싱 → {len(skipped)}개 가로형 필터 제거 → {len(polys)}개 처리")
if skipped:
print(f" 제거 shape idx: {skipped}")
img_cx, img_cy = W / 2.0, H / 2.0
# ── Phase 1+2: 두 가지 VP 시드 방식으로 시도 → V 폴리곤이 더 많은 VP 채택 ──
# ── Phase 1+2: 두 가지 VP 시드 방식으로 시도 → C 폴리곤이 더 많은 VP 채택 ──
elong_idx = []
for i, pts in enumerate(polys):
la, cx, cy, long_side, short_side = _long_axis_angle(pts)
@@ -444,32 +650,38 @@ def render(image_path, label_path, output_path, args,
if dist > min(W, H) * vp_outer_ratio and _radial_cos_sim(polys[i], img_cx, img_cy) > 0.5:
seeds_B.append(i)
# 두 시드 모두 시도 → V가 더 많이 나오는 VP 채택
# 두 시드 모두 시도 → C가 더 많이 나오는 VP 채택
best_vp_x, best_vp_y = img_cx, -H * 3.0
best_n_v = 0
best_n_c = 0
orients, adiffs = ['?'] * len(polys), [90.0] * len(polys)
for label, seeds in [('지배각', seeds_A), ('radial', seeds_B)]:
if len(seeds) < 2:
continue
vx, vy, nv, ors, ads = _estimate_vp_iterative(
polys, seeds, v_thresh, h_max_diff, vp_min_len, x_horiz_thresh)
print(f" VP [{label}]: ({vx:.0f}, {vy:.0f}) V={nv}")
if nv > best_n_v:
best_vp_x, best_vp_y, best_n_v = vx, vy, nv
vx, vy, nc, ors, ads = _estimate_vp_iterative(
polys, seeds, c_thresh, b_max_diff, vp_min_len)
print(f" VP [{label}]: ({vx:.0f}, {vy:.0f}) C={nc}")
if nc > best_n_c:
best_vp_x, best_vp_y, best_n_c = vx, vy, nc
orients, adiffs = ors, ads
vp_x, vp_y = best_vp_x, best_vp_y
print(f" → 채택 VP: ({vp_x:.1f}, {vp_y:.1f})")
print(f"\n [V/H 분류] threshold=±{v_thresh}° h_max=±{h_max_diff}°")
print(f"\n [C/B 분류] threshold=±{c_thresh}° b_max=±{b_max_diff}°")
for i in range(len(polys)):
print(f" poly {i:>2d}: {orients[i]} diff={adiffs[i]:.1f}°")
print(f" V:{orients.count('V')} H:{orients.count('H')} ?:{orients.count('?')}")
print(f" C:{orients.count('C')} B:{orients.count('B')} ?:{orients.count('?')}")
# B 장축 중앙부 소형 폴리곤 제거 (Phase 3 전)
polys, orients, poly_abs_idx, beam_keep = remove_beam_center_small(polys, orients, poly_abs_idx)
old_to_new = {old: new for new, old in enumerate(beam_keep)}
seeds_A = [old_to_new[i] for i in seeds_A if i in old_to_new]
seeds_B = [old_to_new[i] for i in seeds_B if i in old_to_new]
# ── Phase 3 ──────────────────────────────────────────────────────────
groups = connectivity_groups(polys, orients, margin=args.margin)
print(f"\n [그룹핑] 연결 컴포넌트 {len(groups)}개 (margin={args.margin}px)")
for g in groups:
print(f" G{g['id']}: H={g['H']} V={g['V']}")
print(f" G{g['id']}: B={g['B']} C={g['C']}")
# ── Phase 4 ──────────────────────────────────────────────────────────
print(f"\n [라멘 판정]")
@@ -478,14 +690,50 @@ def render(image_path, label_path, output_path, args,
g['verdict'] = verdict
g['n_poles'] = n_poles
pole_str = f"{n_poles}poles" if n_poles else "-"
print(f" G{g['id']}: H={g['H']} V={g['V']}{verdict or '-':18s} ({pole_str})")
print(f" G{g['id']}: B={g['B']} C={g['C']}{verdict or '-':18s} ({pole_str})")
# RAAMEN_CENTER: 최하단(+Y 최대) C만 기둥으로 유지, 나머지 C → B (판정 유지)
for g in groups:
if g['verdict'] != 'RAAMEN_CENTER' or len(g['C']) < 2:
continue
bottom_c = max(g['C'], key=lambda i: float(polys[i][:, 1].mean()))
for i in g['C']:
if i != bottom_c:
orients[i] = 'B'
g['B'] = sorted(g['B'] + [i for i in g['C'] if i != bottom_c])
g['C'] = [bottom_c]
print(f" G{g['id']} [CENTER 재분류]: B={g['B']} C={g['C']}")
# 그룹 내 동일 타입 교차 폴리곤 병합 (B↔B, C↔C, 폴리곤 교차 기준)
in_group_before = set()
for g in groups:
in_group_before.update(g['B'] + g['C'])
orig_poly_n = len(polys)
groups, polys, poly_abs_idx = merge_intersecting_same_type(groups, polys, poly_abs_idx)
# 새 병합 폴리곤의 orient 확장
new_orient_map = {}
for g in groups:
for key in ('B', 'C'):
for idx in g[key]:
if idx >= orig_poly_n:
new_orient_map[idx] = key
for i in range(orig_poly_n, len(polys)):
orients.append(new_orient_map.get(i, '?'))
# 병합으로 흡수된 원본 폴리곤 인덱스 (시각화에서 제외)
in_group_after = set()
for g in groups:
in_group_after.update(g['B'] + g['C'])
merged_away = {i for i in in_group_before if i not in in_group_after}
valid_items = [g for g in groups if g['verdict']]
valid_items.sort(key=lambda x: x['area'], reverse=True)
print(f"\n [최종 라멘 객체] {len(valid_items)}개 (면적순)")
for g in valid_items:
print(f" G{g['id']}: H={g['H']} V={g['V']}{g['verdict']:18s} ({g['n_poles']}poles) Area={g['area']:,.0f}")
print(f" G{g['id']}: B={g['B']} C={g['C']}{g['verdict']:18s} ({g['n_poles']}poles) Area={g['area']:,.0f}")
# 최소 면적 필터링
if args.min_group_area > 0:
@@ -493,37 +741,23 @@ def render(image_path, label_path, output_path, args,
valid_items = [g for g in valid_items if g['area'] >= args.min_group_area]
print(f" [필터] 최소 면적 {args.min_group_area} 미만 제거: {before}{len(valid_items)}")
# 모든 그룹: 그룹 내 최하단 꼭짓점 포함 폴리곤이 H이면 → V로 재분류
for g in valid_items:
all_idxs = g['H'] + g['V']
bottom_vi = max(all_idxs, key=lambda i: polys[i][:, 1].max())
if bottom_vi in g['H']:
g['H'].remove(bottom_vi)
g['V'].append(bottom_vi)
orients[bottom_vi] = 'V'
# RAAMEN_CENTER (H 없는 그룹): 최하단=V, 나머지=H로 display 재조정
if not g['H']:
for vi in g['V']:
orients[vi] = 'V' if vi == bottom_vi else 'H'
# ── 시각화 ───────────────────────────────────────────────────────────
# 1. 폴리곤 V/H 색상 반투명 오버레이
# 1. 폴리곤 C/B 색상 반투명 오버레이 (fill + outline) — 병합 흡수된 원본은 제외
for i, (pts, orient) in enumerate(zip(polys, orients)):
color = _VH_COLOR[orient]
if i in merged_away:
continue
color = _CB_COLOR[orient]
pts_i = pts.astype(np.int32)
ov = img.copy()
cv2.fillPoly(ov, [pts_i], color)
cv2.addWeighted(ov, 0.25, img, 0.75, 0, img)
cv2.polylines(img, [pts_i], True, color, 2)
cx, cy = int(pts[:, 0].mean()), int(pts[:, 1].mean())
lbl = f"{i}{orient}"
cv2.putText(img, lbl, (cx, cy), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 4)
cv2.putText(img, lbl, (cx, cy), cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, 2)
# 2. VP 시드 폴리곤에 청록 테두리 (채택된 시드셋 재구성)
for i in seeds_A + [i for i in seeds_B if i not in seeds_A]:
cv2.polylines(img, [polys[i].astype(np.int32)], True, (0, 220, 220), 3)
if i not in merged_away:
cv2.polylines(img, [polys[i].astype(np.int32)], True, (0, 220, 220), 3)
# 3. 소실점 표시 (이미지 내부: 원, 외부: 방향 화살표)
vp_ix, vp_iy = int(vp_x), int(vp_y)
@@ -546,7 +780,7 @@ def render(image_path, label_path, output_path, args,
for g in valid_items:
verdict = g['verdict']
color = _RAAMEN_COLOR[verdict]
all_idx = g['H'] + g['V']
all_idx = g['B'] + g['C']
all_pts = np.vstack([polys[i] for i in all_idx]).astype(np.int32)
x0 = all_pts[:, 0].min() - 15; y0 = all_pts[:, 1].min() - 15
x1 = all_pts[:, 0].max() + 15; y1 = all_pts[:, 1].max() + 15
@@ -557,6 +791,16 @@ def render(image_path, label_path, output_path, args,
cv2.putText(img, lbl, (x0, y0 - 10),
cv2.FONT_HERSHEY_SIMPLEX, 1.2, color, 2)
# 5. 폴리곤 라벨 — 모든 그래픽 최상단에 그리기 (병합 흡수된 원본 제외)
for i, (pts, orient) in enumerate(zip(polys, orients)):
if i in merged_away:
continue
color = _CB_COLOR[orient]
cx, cy = int(pts[:, 0].mean()), int(pts[:, 1].mean())
lbl = f"{i}{orient}"
cv2.putText(img, lbl, (cx, cy), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 4)
cv2.putText(img, lbl, (cx, cy), cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, 2)
# 이미지 저장
scale = min(1.0, 4096 / max(H, W))
if scale < 1.0:
@@ -580,27 +824,52 @@ def render(image_path, label_path, output_path, args,
desc_base = f"G{gid} {verdict}"
def abs_ids(rel_list):
"""상대 폴리곤 인덱스 → 절대 JSON shapes[] 인덱스 변환."""
return sorted(poly_abs_idx[i] for i in rel_list)
"""상대 폴리곤 인덱스 → 절대 JSON shapes[] 인덱스 변환. 병합 폴리곤은 list."""
result = []
for i in rel_list:
v = poly_abs_idx[i]
if isinstance(v, list):
result.extend(v)
else:
result.append(v)
return sorted(result)
if not g['H']:
# RAAMEN_CENTER: 최하단 꼭짓점 포함 폴리곤 = 기둥, 나머지 = 빔
bottom_vi = max(g['V'], key=lambda i: polys[i][:, 1].max())
for vi in g['V']:
label = "raamen_pole" if vi == bottom_vi else "raamen_beam"
shapes.append(_shape(label,
[[float(p[0]), float(p[1])] for p in polys[vi]],
gid, f"{desc_base} shape#{poly_abs_idx[vi]}"))
else:
# 일반 RAAMEN: V = 기둥, H = 빔
for vi in g['V']:
shapes.append(_shape("raamen_pole",
[[float(p[0]), float(p[1])] for p in polys[vi]],
gid, f"{desc_base} pole shape#{poly_abs_idx[vi]}"))
for hi in g['H']:
shapes.append(_shape("raamen_beam",
[[float(p[0]), float(p[1])] for p in polys[hi]],
gid, f"{desc_base} beam shape#{poly_abs_idx[hi]}"))
if not g['B']:
# RAAMEN_CENTER: C 폴리곤만 있는 중앙 영역 그룹
for ci in g['C']:
shapes.append(_shape("raamen_center",
[[float(p[0]), float(p[1])] for p in polys[ci]],
gid, f"{desc_base} shape#{poly_abs_idx[ci]}"))
continue
det = group_detail(g, polys, W)
# 주 빔 (1~2개: 면적 기준 상위, 2nd ≥ 50% 조건 충족 시 포함)
for bi, mb in enumerate(det['main_bs'], 1):
label = "raamen_beam" if bi == 1 else "raamen_beam2"
shapes.append(_shape(label,
_merge_poly_hull([mb], polys),
gid, f"{desc_base} beam{bi} shape#{poly_abs_idx[mb]}"))
# 잡 B 폴리곤 클러스터 (브라켓 등 부속)
if det['junk_b']:
junk_clusters = _cluster_polys(det['junk_b'], polys)
for jc in junk_clusters:
shapes.append(_shape("raamen_beam_sub",
_merge_poly_hull(jc, polys),
gid, f"{desc_base} beam_sub{abs_ids(jc)}"))
# 유효 기둥 클러스터 (끝단)
for pi, cluster in enumerate(det['valid_pole_clusters'], 1):
shapes.append(_shape("raamen_pole",
_merge_poly_hull(cluster, polys),
gid, f"{desc_base} pole{pi}{abs_ids(cluster)}"))
# 중앙 부속물 클러스터 (기둥 아님)
for cluster in det['attach_clusters']:
shapes.append(_shape("raamen_pole_attach",
_merge_poly_hull(cluster, polys),
gid, f"{desc_base} attach{abs_ids(cluster)}"))
anylabel_json = {
"version": "3.3.9",
@@ -616,24 +885,77 @@ def render(image_path, label_path, output_path, args,
encoding="utf-8")
print(f"{json_path}")
# ── 원본 폴리곤 분류 JSON 저장 ───────────────────────────────────────
# 입력 catenary_pole 폴리곤마다 그룹/타입 레이블 부여
# 그룹 소속: catenary_pole_B / catenary_pole_C (group_id=G번호)
# 그룹 미소속: catenary_pole (원본 그대로)
abs_to_info = {} # abs_idx → (group_id, verdict, 'B'|'C')
for g in groups:
v = g.get('verdict', '')
for type_char, members in (('B', g['B']), ('C', g['C'])):
for idx in members:
av = poly_abs_idx[idx]
for a in (av if isinstance(av, list) else [av]):
abs_to_info[a] = (g['id'], v, type_char)
input_data = json.loads(label_path.read_text(encoding="utf-8"))
input_shapes = input_data.get("shapes", [])
cls_shapes = []
for abs_idx, s in enumerate(input_shapes):
if class_names and s.get("label", "") not in class_names:
continue
if abs_idx in abs_to_info:
gid, verdict, type_char = abs_to_info[abs_idx]
lbl = f"catenary_pole_{type_char}"
desc = f"G{gid} {verdict}"
gid_out = gid
else:
lbl = s.get("label", "catenary_pole")
desc = s.get("description", "")
gid_out = None
cls_shapes.append({
"label": lbl,
"score": s.get("score"),
"points": s.get("points", []),
"group_id": gid_out,
"description": desc,
"shape_type": s.get("shape_type", "polygon"),
"flags": s.get("flags"),
})
cls_json = {
"version": input_data.get("version", "3.3.9"),
"flags": input_data.get("flags", {}),
"shapes": cls_shapes,
"imagePath": input_data.get("imagePath", image_path.name),
"imageData": None,
"imageHeight": H,
"imageWidth": W,
}
cls_path = output_path.parent / (label_path.stem + "_classified.json")
cls_path.write_text(json.dumps(cls_json, ensure_ascii=False, indent=2),
encoding="utf-8")
print(f"{cls_path} ({len(cls_shapes)}개 폴리곤)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--image", required=True)
ap.add_argument("--label", required=True)
ap.add_argument("--output", required=True)
ap.add_argument("--output", default=None,
help="출력 jpg 경로 (기본: output/raamen/{이미지명}/{이미지명}_raamen_NNN.jpg)")
ap.add_argument("--class-ids", default="",
help="포함할 클래스 ID, 콤마 구분 (.txt 전용)")
ap.add_argument("--class-names", default="catenary_pole",
help="포함할 클래스 이름, 콤마 구분 (.json 전용, 기본: 'catenary_pole')")
ap.add_argument("--epsilon", type=float, default=4.0,
help="approxPolyDP epsilon (기본 4.0px)")
ap.add_argument("--v-thresh", type=float, default=20.0,
help="V/H 분류 각도 임계값 degrees (기본 20°)")
ap.add_argument("--h-max-diff", type=float, default=75.0,
help="H(빔) 최대 각도 diff; 이 이상은 레일/전선 등으로 제외 (기본 75°)")
ap.add_argument("--x-horiz-thresh", type=float, default=10.0,
help="X축 절대 수평 제외 임계값 degrees; AR≥4 AND 이 각도 이내 → 제외 (기본 10°)")
ap.add_argument("--c-thresh", type=float, default=20.0,
help="C/B 분류 각도 임계값 degrees (기본 20°)")
ap.add_argument("--b-max-diff", type=float, default=75.0,
help="B(빔) 최대 각도 diff; 이 이상은 레일/전선 등으로 제외 (기본 75°)")
ap.add_argument("--margin", type=int, default=30,
help="폴리곤 접촉 판정 margin px (기본 30)")
ap.add_argument("--min-group-area", type=float, default=0,
@@ -645,21 +967,23 @@ def main():
class_names = ({x.strip() for x in args.class_names.split(',') if x.strip()}
if args.class_names else None)
out = Path(args.output)
folder = out.parent / out.stem # e.g. output/0004_test
if folder.exists():
if args.output:
out_path = Path(args.output)
else:
img_stem = Path(args.image).stem
out_dir = Path("output") / "raamen" / img_stem
out_dir.mkdir(parents=True, exist_ok=True)
n = 1
while (out.parent / f"{out.stem}_{n}").exists():
while True:
out_path = out_dir / f"{img_stem}_raamen_{n:03d}.jpg"
if not out_path.exists():
break
n += 1
folder = out.parent / f"{out.stem}_{n}"
folder.mkdir(parents=True, exist_ok=True)
out = folder / out.name # e.g. output/0004_test/0004_test.jpg
print(f" [출력 폴더] {folder}")
render(Path(args.image), Path(args.label), out, args,
render(Path(args.image), Path(args.label), out_path, args,
class_ids=class_ids, class_names=class_names,
epsilon=args.epsilon, v_thresh=args.v_thresh,
h_max_diff=args.h_max_diff, x_horiz_thresh=args.x_horiz_thresh)
epsilon=args.epsilon, c_thresh=args.c_thresh,
b_max_diff=args.b_max_diff)
if __name__ == "__main__":

221
tools/group_ramen_poles.py Normal file
View File

@@ -0,0 +1,221 @@
"""group_ramen_poles.py — catenary_pole 라멘 그룹 검출 및 group_id 할당.
H 빔(수평) + 인접 V 기둥(수직) 쌍을 찾아 동일 group_id 부여.
결과 JSON은 post_merge_poles.py가 group_id 없는 V만 병합할 수 있도록 선행 실행.
Usage:
python tools/group_ramen_poles.py INPUT.json [--inplace]
python tools/group_ramen_poles.py INPUT.json --x-overlap 0.15 --max-dist 400
"""
import argparse
import json
import sys
from pathlib import Path
import cv2
import numpy as np
def _poly_orient(points, H, W, cos_high=0.75, cos_low=0.45, debug=False):
"""V/H/?_ambiguous 판별.
cos_sim > cos_high → V (명확 기둥)
cos_sim < cos_low → H (명확 빔)
그 사이 또는 중앙 근처 → ?_ambiguous (후처리에서 Y 최대=V 판별)
"""
pts = np.array(points, dtype=np.float32)
rect = cv2.minAreaRect(pts)
(rx, ry), (rw, rh), angle = rect
if min(rw, rh) < 1:
if debug: print(f" → ? (min_side<1)")
return '?'
ar = max(rw, rh) / min(rw, rh)
if ar < 1.3:
if debug: print(f" → ? (ar={ar:.2f}<1.3)")
return '?'
rdx, rdy = rx - W / 2.0, ry - H / 2.0
radial_norm = (rdx ** 2 + rdy ** 2) ** 0.5
center_thresh = (H ** 2 + W ** 2) ** 0.5 * 0.15
if radial_norm < center_thresh:
if debug: print(f" → ?_ambiguous (center, norm={radial_norm:.0f}<{center_thresh:.0f})")
return '?_ambiguous'
long_angle_deg = angle if rw >= rh else angle + 90
lx = float(np.cos(np.radians(long_angle_deg)))
ly = float(np.sin(np.radians(long_angle_deg)))
cos_sim = abs(lx * rdx / radial_norm + ly * rdy / radial_norm)
if cos_sim > cos_high:
orient = 'V'
elif cos_sim < cos_low:
orient = 'H'
else:
orient = '?_ambiguous'
if debug:
bw = pts[:, 0].max() - pts[:, 0].min()
bh = pts[:, 1].max() - pts[:, 1].min()
print(f"{orient} (ar={ar:.2f} cos_sim={cos_sim:.3f} "
f"bbox={int(bw)}x{int(bh)})")
return orient
def _fix_ambiguous_orients(indices, orients, shapes):
"""?_ambiguous 폴리곤: x-range 겹치는 그룹 내 Y 최대(가장 아래)=V, 나머지=H.
기둥 하단은 지면에 박혀 더 아래쪽(y 최대), 빔은 상단에 설치되어 위쪽.
"""
amb_ids = [i for i in indices if orients[i] == '?_ambiguous']
if not amb_ids:
return
bboxes = {i: _get_bbox(shapes[i]["points"]) for i in amb_ids}
assigned = set()
for i in amb_ids:
if i in assigned:
continue
bi = bboxes[i]
group = [i]
for j in amb_ids:
if j == i or j in assigned:
continue
bj = bboxes[j]
if bi[0] <= bj[2] and bj[0] <= bi[2]: # x-range 겹침
group.append(j)
bottom = max(group, key=lambda k: bboxes[k][3]) # y_max 최대 = 가장 아래 = V
for k in group:
orients[k] = 'V' if k == bottom else 'H'
assigned.add(k)
print(f" [ambiguous fix] 그룹{group}: "
f"V={bottom}, H={[k for k in group if k != bottom]}")
def _get_bbox(points):
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return min(xs), min(ys), max(xs), max(ys)
def _y_gap(b1, b2):
return max(0.0, max(b1[1], b2[1]) - min(b1[3], b2[3]))
def detect_ramen_groups(shapes, iH, iW, label="catenary_pole",
x_overlap_thresh=0.20, max_pole_dist=300,
cos_high=0.75, cos_low=0.45):
"""V/H 판별 → H빔 anchor로 인접 V 매칭 → 라멘 group_id 할당.
반환: [(group_id, [h_indices], [v_indices]), ...]
"""
pole_indices = [i for i, s in enumerate(shapes) if s.get("label") == label]
for i in pole_indices:
shapes[i]["group_id"] = None
if not pole_indices:
return []
# Step 1: V/H 판별
print(f" [orient] 전체 {len(pole_indices)}개 전철주 판별:")
orients = {}
for i in pole_indices:
print(f" shape[{i}]", end="")
orients[i] = _poly_orient(shapes[i]["points"], iH, iW,
cos_high=cos_high, cos_low=cos_low, debug=True)
_fix_ambiguous_orients(pole_indices, orients, shapes)
h_indices = [i for i in pole_indices if orients[i] == 'H']
v_indices = [i for i in pole_indices if orients[i] == 'V']
print(f" V={len(v_indices)}, H={len(h_indices)}, "
f"?={sum(1 for o in orients.values() if o not in ('V', 'H'))}")
# Step 2: H빔 anchor → 인접 V 매칭
used_v = set()
raw_groups = []
for hi in h_indices:
hb = _get_bbox(shapes[hi]["points"])
hx0, hy0, hx1, hy1 = hb
h_width = max(hx1 - hx0, 1)
matched_v = []
for vi in v_indices:
if vi in used_v:
continue
vb = _get_bbox(shapes[vi]["points"])
vcx = (vb[0] + vb[2]) / 2.0
margin = x_overlap_thresh * h_width
if (hx0 - margin) <= vcx <= (hx1 + margin) and _y_gap(hb, vb) <= max_pole_dist:
matched_v.append(vi)
if matched_v:
raw_groups.append(([hi], matched_v))
used_v.update(matched_v)
# Step 3: group_id 할당
existing_gids = [s.get("group_id") for s in shapes if isinstance(s.get("group_id"), int)]
next_gid = (max(existing_gids) + 1) if existing_gids else 1
result = []
for h_list, v_list in raw_groups:
gid = next_gid
next_gid += 1
for i in h_list + v_list:
shapes[i]["group_id"] = gid
result.append((gid, h_list, v_list))
print(f" → 라멘 group_id={gid}: H{h_list} V{v_list}")
return result
def main():
ap = argparse.ArgumentParser()
ap.add_argument("input", help="AnyLabeling JSON 파일")
ap.add_argument("--inplace", action="store_true", help="원본 덮어쓰기")
ap.add_argument("--output", default=None)
ap.add_argument("--label", default="catenary_pole")
ap.add_argument("--x-overlap", type=float, default=0.20,
help="H 빔 너비 기준 x 여유 비율 (기본 0.20)")
ap.add_argument("--max-dist", type=int, default=300,
help="H-V y 간격 최대값 px (기본 300)")
ap.add_argument("--cos-high", type=float, default=0.75,
help="cos_sim V 판별 상한 (기본 0.75)")
ap.add_argument("--cos-low", type=float, default=0.45,
help="cos_sim H 판별 하한 (기본 0.45)")
args = ap.parse_args()
src = Path(args.input)
if not src.exists():
print(f"파일 없음: {src}", file=sys.stderr)
sys.exit(1)
data = json.loads(src.read_text(encoding="utf-8"))
shapes = data.get("shapes", [])
iW = data.get("imageWidth", 0)
iH = data.get("imageHeight", 0)
if iW == 0 or iH == 0:
print("imageWidth/imageHeight 없음", file=sys.stderr)
sys.exit(1)
groups = detect_ramen_groups(shapes, iH, iW, args.label, args.x_overlap, args.max_dist,
args.cos_high, args.cos_low)
print(f"\n라멘 그룹 {len(groups)}개 검출:")
for gid, h_list, v_list in groups:
print(f" group_id={gid}: H{h_list} V{v_list}")
poles = [s for s in shapes if s.get("label") == args.label]
ungrouped_v = [
s for s in poles
if s.get("group_id") is None and _poly_orient(s["points"], iH, iW) == 'V'
]
print(f"그룹 미할당 V 기둥 (병합 대상): {len(ungrouped_v)}")
data["shapes"] = shapes
if args.inplace:
dst = src
elif args.output:
dst = Path(args.output)
else:
dst = src.with_stem(src.stem + "_grouped")
dst.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"저장: {dst}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,146 @@
"""everything.json을 레이블별로 분리해 bbox를 원본 이미지에 표시.
사용:
python tools/render_everything_by_label.py \
--image data/역사이미지/slope/DJI_20260306113838_0004_tophalf.jpg \
--json output/raamen/DJI_20260306113838_0004_tophalf_everything.json \
--output output/everything_by_label \
[--scale 0.3] \
[--label small_dark_object_on_ballast ...] \
[--rail-offset 200] # railroad_track/railway_rail bbox + N픽셀 안쪽 필터
"""
import argparse
import json
import re
import cv2
import numpy as np
from pathlib import Path
RAIL_LABELS = {"railroad track", "railway rail"}
def safe_name(label: str) -> str:
return re.sub(r"[^\w]", "_", label)
def build_rail_zone(segments: list, offset: int, img_h: int, img_w: int):
"""railroad_track/railway_rail 세그먼트 합산 bbox + offset → (x1,y1,x2,y2)."""
xs1, ys1, xs2, ys2 = [], [], [], []
for seg in segments:
if seg.get("label", "").strip().lower() in RAIL_LABELS:
bx1, by1, bx2, by2 = seg["bbox"]
xs1.append(bx1); ys1.append(by1)
xs2.append(bx2); ys2.append(by2)
if not xs1:
return None
return (
max(0, int(min(xs1)) - offset),
max(0, int(min(ys1)) - offset),
min(img_w, int(max(xs2)) + offset),
min(img_h, int(max(ys2)) + offset),
)
def in_zone(seg_bbox, zone):
"""세그먼트 bbox 중심이 zone 안에 있으면 True."""
bx1, by1, bx2, by2 = seg_bbox
cx, cy = (bx1 + bx2) / 2, (by1 + by2) / 2
zx1, zy1, zx2, zy2 = zone
return zx1 <= cx <= zx2 and zy1 <= cy <= zy2
def render_label(img_full, segments, label: str, output_path: Path, scale: float,
zone=None):
h, w = img_full.shape[:2]
canvas = cv2.resize(img_full, (int(w * scale), int(h * scale)),
interpolation=cv2.INTER_AREA)
color = (0, 80, 255)
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = max(0.4, scale * 2.0)
thickness = max(1, int(scale * 5))
if zone:
zx1, zy1, zx2, zy2 = [int(v * scale) for v in zone]
cv2.rectangle(canvas, (zx1, zy1), (zx2, zy2), (0, 255, 0), 2)
for seg in segments:
if seg.get("points"):
pts = np.array([[int(x * scale), int(y * scale)] for x, y in seg["points"]], dtype=np.int32)
overlay = canvas.copy()
cv2.fillPoly(overlay, [pts], color)
cv2.addWeighted(overlay, 0.25, canvas, 0.75, 0, canvas)
cv2.polylines(canvas, [pts], True, color, thickness)
else:
x1, y1, x2, y2 = [int(v * scale) for v in seg["bbox"]]
cv2.rectangle(canvas, (x1, y1), (x2, y2), color, thickness)
header = f"{label} [{len(segments)}]"
cv2.putText(canvas, header, (10, 40), font, font_scale * 1.2,
(0, 220, 0), max(1, int(scale * 3)) + 1)
output_path.parent.mkdir(parents=True, exist_ok=True)
ret, buf = cv2.imencode(".jpg", canvas, [cv2.IMWRITE_JPEG_QUALITY, 88])
if not ret:
raise RuntimeError("JPEG 인코딩 실패")
output_path.write_bytes(buf.tobytes())
print(f" {label:40s} {len(segments):5d}개 → {output_path.name}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--image", required=True, type=Path)
ap.add_argument("--json", required=True, type=Path)
ap.add_argument("--output", required=True, type=Path)
ap.add_argument("--scale", type=float, default=0.25)
ap.add_argument("--label", nargs="*", default=None)
ap.add_argument("--rail-offset", type=int, default=0,
help="railroad_track/railway_rail 합산 bbox + N픽셀 zone 필터 (0=비활성)")
ap.add_argument("--zone", type=int, nargs=4, metavar=("X1","Y1","X2","Y2"), default=None,
help="수동 zone 지정 (--rail-offset보다 우선)")
args = ap.parse_args()
buf = np.fromfile(str(args.image), dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if img is None:
raise FileNotFoundError(f"이미지 읽기 실패: {args.image}")
img_h, img_w = img.shape[:2]
with open(args.json, encoding="utf-8") as f:
data = json.load(f)
groups: dict[str, list] = {}
for seg in data["segments"]:
groups.setdefault(seg["label"], []).append(seg)
zone = None
if args.zone:
zone = tuple(args.zone)
print(f"수동 zone: {zone}")
elif args.rail_offset > 0:
zone = build_rail_zone(data["segments"], args.rail_offset, img_h, img_w)
if zone:
print(f"레일 zone (offset={args.rail_offset}px): {zone}")
else:
print("경고: railroad_track/railway_rail 세그먼트 없음 → zone 필터 미적용")
target_labels = args.label if args.label else sorted(groups)
print(f"원본: {img_w}x{img_h} scale={args.scale}")
print(f"레이블 {len(target_labels)}개 렌더링 → {args.output}/")
for lbl in target_labels:
if lbl not in groups:
print(f" [skip] {lbl} - no segments")
continue
segs = sorted(groups[lbl], key=lambda s: s["score"], reverse=True)
if zone:
segs = [s for s in segs if in_zone(s["bbox"], zone)]
fname = f"{safe_name(lbl)}.jpg"
render_label(img, segs, lbl, args.output / fname, args.scale, zone=zone)
print("완료.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,83 @@
"""LabelMe JSON에서 특정 레이블 폴리곤만 원본 이미지에 표시.
사용:
python tools/render_label_polygons.py \
--image data/역사구간/.../DJI_20260306100900_0034.JPG \
--json output/detect/DJI_20260306100900_0034/tiles1to24_railway_zone_001.json \
--label catenary_pole \
--output output/detect/DJI_20260306100900_0034/catenary_pole_only.jpg
# 여러 레이블:
python tools/render_label_polygons.py ... --label catenary_pole bracket
# 레이블 생략 시 전체 표시:
python tools/render_label_polygons.py ... --json foo.json --image foo.jpg
"""
import argparse
import json
import cv2
import numpy as np
from pathlib import Path
COLORS = [
(0, 0, 255), # red
(0, 200, 0), # green
(255, 100, 0), # blue-orange
(0, 200, 255), # yellow
(200, 0, 200), # magenta
(0, 165, 255), # orange
(255, 0, 100), # pink-blue
(100, 255, 100),# light green
]
def render(image_path: Path, json_path: Path, output_path: Path, labels: list[str] | None):
buf = np.fromfile(str(image_path), dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if img is None:
raise FileNotFoundError(f"이미지 읽기 실패: {image_path}")
with open(json_path, encoding="utf-8") as f:
data = json.load(f)
shapes = data.get("shapes", [])
if labels:
shapes = [s for s in shapes if s["label"] in labels]
# 레이블별 색상 매핑
label_set = sorted({s["label"] for s in shapes})
color_map = {lbl: COLORS[i % len(COLORS)] for i, lbl in enumerate(label_set)}
for s in shapes:
pts = np.array(s["points"], dtype=np.int32).reshape((-1, 1, 2))
color = color_map[s["label"]]
cv2.polylines(img, [pts], isClosed=True, color=color, thickness=8)
x, y = int(s["points"][0][0]), int(s["points"][0][1])
score = s.get("score")
text = f"{s['label']} {score:.2f}" if score is not None else s["label"]
cv2.putText(img, text, (x, max(y - 10, 20)),
cv2.FONT_HERSHEY_SIMPLEX, 2.5, color, 5)
output_path.parent.mkdir(parents=True, exist_ok=True)
ret, buf_out = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
if not ret:
raise RuntimeError("JPEG 인코딩 실패")
output_path.write_bytes(buf_out.tobytes())
print(f"저장: {output_path} ({len(shapes)}개 폴리곤)")
def main():
p = argparse.ArgumentParser(description="LabelMe JSON 폴리곤 시각화")
p.add_argument("--image", required=True, type=Path)
p.add_argument("--json", required=True, type=Path)
p.add_argument("--output", required=True, type=Path)
p.add_argument("--label", nargs="*", default=None,
help="표시할 레이블 (생략 시 전체)")
args = p.parse_args()
render(args.image, args.json, args.output, args.label)
if __name__ == "__main__":
main()

View File

@@ -53,7 +53,12 @@ DISCOVERY_PROMPT = (
"road, asphalt, pavement, "
"slope, embankment, retaining wall, "
"noise barrier, sound wall, "
"signal, sign board"
"signal, sign board, "
"small dark object on ballast, small dark object on railway, "
"small square metal box on ground, control box on ballast, "
"gray square lid on gravel, flat metal cover on ground, "
"small bright object on ballast, small white box on ballast, "
"small gray box on ground, bright square object on gravel"
)
@@ -72,7 +77,7 @@ def sam3_everything(tile_bgr: np.ndarray, conf: float, prompt: str = DISCOVERY_P
},
}
try:
r = requests.post(f"{SAM3_SERVER}/v1/predict", json=payload, timeout=120)
r = requests.post(f"{SAM3_SERVER}/v1/predict", json=payload, timeout=300)
r.raise_for_status()
resp = r.json()
if not resp.get("success"):
@@ -119,14 +124,23 @@ def nms_shapes(shapes: list, iou_thresh: float = 0.4) -> list:
# ── 타일 분할 + 병렬 검출 ─────────────────────────────────────────────────────
def detect_everything_tiled(image_bgr, cols, rows, overlap, conf, workers, prompt):
def detect_everything_tiled(image_bgr, cols, rows, overlap, conf, workers, prompt,
zone=None):
"""zone=(x1,y1,x2,y2) 지정 시 겹치는 타일만 처리."""
H, W = image_bgr.shape[:2]
base_w = W / cols
base_h = H / rows
pad_x = int(base_w * overlap)
pad_y = int(base_h * overlap)
def overlaps_zone(tx0, ty0, tx1, ty1):
if zone is None:
return True
zx1, zy1, zx2, zy2 = zone
return tx0 < zx2 and tx1 > zx1 and ty0 < zy2 and ty1 > zy1
tiles = []
skipped = 0
for r in range(rows):
for c in range(cols):
idx = r * cols + c + 1
@@ -134,9 +148,14 @@ def detect_everything_tiled(image_bgr, cols, rows, overlap, conf, workers, promp
x1 = min(W, int((c + 1) * base_w) + pad_x)
y0 = max(0, int(r * base_h) - pad_y)
y1 = min(H, int((r + 1) * base_h) + pad_y)
tiles.append((idx, x0, y0, x1, y1))
if overlaps_zone(x0, y0, x1, y1):
tiles.append((idx, x0, y0, x1, y1))
else:
skipped += 1
total = len(tiles)
total = len(tiles)
if skipped:
print(f"zone 필터: {skipped}타일 스킵, {total}타일 처리")
done = [0]
all_shapes = []
@@ -227,6 +246,8 @@ def main():
ap.add_argument("--workers", type=int, default=4, help="병렬 스레드 수 (기본 4)")
ap.add_argument("--nms", type=float, default=0.40, help="NMS IoU 임계값 (기본 0.40)")
ap.add_argument("--prompt-extra", default="", help="DISCOVERY_PROMPT 뒤에 추가할 어휘 (콤마 구분)")
ap.add_argument("--zone", type=int, nargs=4, metavar=("X1","Y1","X2","Y2"), default=None,
help="처리 zone 제한 (이 범위와 겹치는 타일만 처리)")
args = ap.parse_args()
prompt = DISCOVERY_PROMPT + (", " + args.prompt_extra.strip(", ") if args.prompt_extra.strip() else "")
@@ -248,10 +269,14 @@ def main():
print(f" · {item.strip()}")
print()
zone = tuple(args.zone) if args.zone else None
if zone:
print(f"zone 제한: x={zone[0]}~{zone[2]} y={zone[1]}~{zone[3]}\n")
t0 = time.time()
shapes = detect_everything_tiled(
image_bgr, args.cols, args.rows, args.overlap,
args.conf, args.workers, prompt
args.conf, args.workers, prompt, zone=zone
)
print(f"검출 {len(shapes)}개 → NMS(iou={args.nms})...")
shapes = nms_shapes(shapes, iou_thresh=args.nms)
@@ -279,7 +304,8 @@ def main():
)),
"segments": [
{"label": s.get("label",""), "score": s.get("score",0),
"bbox": list(_bbox(s["points"]))}
"bbox": list(_bbox(s["points"])),
"points": s["points"]}
for s in shapes
]
}