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:
664
docs/railway-client-guide.html
Normal file
664
docs/railway-client-guide.html
Normal 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": "<base64 JPEG>",
|
||||
"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>
|
||||
280
docs/진행현황_2026-05-22.md
Normal file
280
docs/진행현황_2026-05-22.md
Normal 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
66
tools/debug_vh.py
Normal 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()
|
||||
@@ -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
123
tools/detect_control_box.py
Normal 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()
|
||||
@@ -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_diff → B (빔)
|
||||
- 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
221
tools/group_ramen_poles.py
Normal 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()
|
||||
146
tools/render_everything_by_label.py
Normal file
146
tools/render_everything_by_label.py
Normal 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()
|
||||
83
tools/render_label_polygons.py
Normal file
83
tools/render_label_polygons.py
Normal 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()
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user