feat: 단계 비교 뷰어 + factory structure 분리 + gap 스윕

make_stage_viewer.py 신규. 같은 사진 위에서 검출 -> claim -> 병합을
갈아끼워 본다. 사진은 그대로 두고 폴리곤만 바뀌므로 무엇이 달라졌는지
바로 보인다. 바뀐 폴리곤(claim 재배정, 2개 이상 병합)은 빨갛게 강조하고,
커서를 올리면 라벨과 원본 픽셀 좌표, 바뀐 이유를 띄운다.

factory structure 를 [building] 에서 뺐다. 공장 구조물이 일반 건물에
흡수되면 병합 후 되돌릴 수 없다. 집합 밖 라벨은 merge_labels.py 가
그대로 통과시키므로 독립 클래스로 남는다.
16장 실측 24개. 부작용으로 building 이 466 -> 490 으로 늘었다 —
전에는 factory structure 폴리곤이 건물 사이 다리 노릇을 해 여러 건물을
한 덩어리로 잇고 있었다. 라벨 14 -> 15종, 폴리곤 6,370 -> 6,418.

gap 스윕(사진 4장, 폴리곤 3,280): 0px 2,081 · 2px 2,032 · 3px 2,014 ·
8px 1,954 · 16px 1,801 · 32px 1,574. 꺾이는 지점이 없다. 1~3px 차이는
폴리곤 2.4%, 무라벨 0.13pp 로 노이즈 수준이라 2px 를 유지한다.
과다 병합은 16px 부터 보인다 (vehicle 240 -> 229, 32px 에서 209).

status 에서 틀린 이슈 하나를 지웠다. "콘 검출이 버려진다"는 서술은
사실이 아니다 — passthrough 로 독립 클래스로 남는다. 검출 0 프롬프트
목록도 v1 기준 8개에서 확정 설정 16장 기준 12개로 고쳤다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
minsung
2026-09-03 13:19:23 +09:00
parent 833816061b
commit 988049e832
4 changed files with 403 additions and 36 deletions
+5 -1
View File
@@ -11,13 +11,17 @@
# 그래서 tree canopy와 dense forest, building과 residential house는 # 그래서 tree canopy와 dense forest, building과 residential house는
# 일부러 같은 그룹에 둔다 — 2D에서 갈라봐야 중복 마스크만 나온다. # 일부러 같은 그룹에 둔다 — 2D에서 갈라봐야 중복 마스크만 나온다.
# factory structure 는 일부러 어느 집합에도 넣지 않는다 (2026-09-02).
# merge_labels.py 는 집합 밖 라벨을 그대로 통과시키므로 독립 클래스로 남는다.
# [building] 에 두면 공장 구조물이 일반 건물에 흡수돼 병합 후 되돌릴 수 없다.
# 16장 실측 86개.
# [building] # [building]
building building
building rooftop building rooftop
building facade building facade
residential house residential house
warehouse warehouse
factory structure
storage container storage container
blue roof blue roof
red roof red roof
+19 -10
View File
@@ -6,11 +6,12 @@
"epsilon_px": 1.5, "epsilon_px": 1.5,
"기준 런": "output/sam3/tune_t64 (확정 설정 8장) / 기준선은 output/sam3/YYX_run 69장", "기준 런": "output/sam3/tune_t64 (확정 설정 8장) / 기준선은 output/sam3/YYX_run 69장",
"프롬프트 파일": "prompts/discovery_v4.txt (94개). 기준선은 discovery_v1.txt (85개)", "프롬프트 파일": "prompts/discovery_v4.txt (94개). 기준선은 discovery_v1.txt (85개)",
"집합 정의": "configs/merge_groups_v4.txt (12집합). 기준선은 merge_groups.txt", "집합 정의": "configs/merge_groups_v4.txt (11집합 + 집합 밖 3종: factory structure, 콘 2종)",
"타일": "6×4 = 24개, overlap 10%, 타일 크기 1365×1365. 기준선은 4×3 (2048×1820)", "타일": "6×4 = 24개, overlap 10%, 타일 크기 1365×1365. 기준선은 4×3 (2048×1820)",
"검출 설정": "conf 0.10 · NMS iou 0.40 · 같은 라벨 병합 gap 8px · --wide-in-tiles. 기준선 conf 0.25, 통짜 분리", "검출 설정": "conf 0.10 · NMS iou 0.40 · 같은 라벨 병합 gap 8px · --wide-in-tiles. 기준선 conf 0.25, 통짜 분리",
"공정 보기 사진": "확정 설정 8장 (기준선 무라벨 3.5~52.1% 분포에서 고름)", "공정 보기 사진": "확정 설정 8장 (기준선 무라벨 3.5~52.1% 분포에서 고름)",
"확정 후보 설정": "prompts/discovery_v4.txt (94개) · configs/merge_groups_v4.txt · --wide-in-tiles · --cols 6 --rows 4 · --conf 0.10 · NMS 0.40 · --merge (gap 8px) · 병합 gap 2px" "확정 후보 설정": "prompts/discovery_v4.txt (94개) · configs/merge_groups_v4.txt · --wide-in-tiles · --cols 6 --rows 4 · --conf 0.10 · NMS 0.40 · --merge (gap 8px) · 병합 gap 2px",
"claim 규칙": "configs/claim_rules.txt — vehicle : 0.5 : 지붕 11종 + 방수포/보호시트"
}, },
"steps": [ "steps": [
{ {
@@ -81,9 +82,21 @@
}, },
{ {
"id": "M3", "id": "M3",
"name": "gap 값 조정", "name": "gap 값 조정 — 2px 유지",
"state": "todo", "state": "done",
"detail": "외곽선 인접 판정 1~3 px. 파일럿 기준 2 px 로 시작." "detail": "사진 4장(폴리곤 3,280)으로 gap 0~32px 스윕. 꺾이는 지점이 없는 매끄러운 곡선이다. 폴리곤 0px 2,081 · 2px 2,032 · 3px 2,014 · 8px 1,954 · 16px 1,801 · 32px 1,574. 무라벨은 4.60% → 3.75% 로 거의 안 움직인다. 1~3px 구간 차이는 폴리곤 2.4%, 무라벨 0.13pp 로 노이즈 수준이라 2px 를 유지한다. 과다 병합은 16px 부터 보인다(vehicle 240→229, 32px 에서 209). 콘은 gap 32px 에서도 65개 그대로 — 집합 밖이라 병합을 안 탄다. 소요는 사진당 약 5초(gap 2px)."
},
{
"id": "M4",
"name": "단계 비교 뷰어",
"state": "done",
"detail": "tools/make_stage_viewer.py 신규. 같은 사진에서 검출 → claim → 병합을 갈아끼우고 바뀐 폴리곤만 빨갛게 강조한다. claim 은 563개 재배정 (white roof→white vehicle 194 등 지붕 오검을 차량으로), 병합은 1,033개가 둘 이상 합쳐진 것. 폴리곤 11,169 → 11,169 → 6,370."
},
{
"id": "M5",
"name": "factory structure 를 [building] 에서 분리",
"state": "done",
"detail": "사용자 지적. 공장 구조물이 일반 건물에 흡수되면 병합 후 되돌릴 수 없다. 집합 밖으로 빼면 passthrough 로 독립 클래스가 된다. 16장 실측 24개. 부작용으로 building 이 466 → 490 으로 늘었다 — 전에는 factory structure 폴리곤이 건물 사이 다리 노릇을 해 여러 건물을 한 덩어리로 잇고 있었다. 라벨 14 → 15종, 폴리곤 6,370 → 6,418."
} }
], ],
"issues": [ "issues": [
@@ -101,11 +114,7 @@
}, },
{ {
"state": "open", "state": "open",
"text": "orange traffic cone / yellow safety cone 은 어느 집합에도 없어 검출 52 폴리곤이 버려진다. [cone] 그룹이 주석 처리돼 있음 — 나란히 놓인 콘이 한 덩어리로 융합된다는 이유." "text": "확정 설정 16장에서 검출 0 인 프롬프트 12개: residential house, plowed field, commercial bus, plastic greenhouse, vinyl greenhouse tunnel, greenhouse roof, green wire fence, road embankment slope, riprap slope, bridge, water surface, yellow grass field. 16장은 69장의 23% 라 대상이 없었을 뿐일 수 있다 (bridge 는 다리 있는 사진에서만 나온다). 지워도 94→82개라 배치 청크가 6으로 그대로여서 속도 이득이 없다 — 78개 이하로 줄여야 5청크가 된다. 69장 전체를 돌린 뒤 판단할 것."
},
{
"state": "open",
"text": "검출 0 인 프롬프트 8개 (plowed field, rice paddy, vinyl greenhouse tunnel, gabion wall, road embankment slope, riprap slope, water surface, reservoir) — 유지할지 뺄지 미정."
}, },
{ {
"state": "rejected", "state": "rejected",
+27 -25
View File
@@ -55,11 +55,11 @@ border-radius:6px;padding:5px 8px;font:inherit;font-size:13px}
<div class="wrap"> <div class="wrap">
<h1>SAM 3.1 후처리 — 검출 커버리지 · 집합 병합</h1> <h1>SAM 3.1 후처리 — 검출 커버리지 · 집합 병합</h1>
<p class="sub">1단계는 미검출을 없애는 것이다. 지표는 &#x27;무라벨 화면%&#x27; — 마스크 합집합으로 재서 겹침을 뺀 값. 목표 5% 이하, 8장 평균 3.6% 로 달성. 2단계는 집합으로 묶고 외곽선이 gap px 이내로 인접한 폴리곤을 합치는 것.</p> <p class="sub">1단계는 미검출을 없애는 것이다. 지표는 &#x27;무라벨 화면%&#x27; — 마스크 합집합으로 재서 겹침을 뺀 값. 목표 5% 이하, 8장 평균 3.6% 로 달성. 2단계는 집합으로 묶고 외곽선이 gap px 이내로 인접한 폴리곤을 합치는 것.</p>
<p class="sub" style="font-size:12px">생성 2026-09-02 09:19 · 집계 <code>tune_t64</code></p> <p class="sub" style="font-size:12px">생성 2026-09-03 13:19 · 집계 <code>tune_t64</code></p>
<h2>실측</h2><div class="cards"> <h2>실측</h2><div class="cards">
<div class="card"><div class="k">사진</div><div class="v">16</div></div> <div class="card"><div class="k">사진</div><div class="v">16</div></div>
<div class="card"><div class="k">폴리곤</div><div class="v">11,169</div></div> <div class="card"><div class="k">폴리곤</div><div class="v">11,169</div></div>
<div class="card"><div class="k">병합 후 폴리곤</div><div class="v">6,370 (-43%)</div></div> <div class="card"><div class="k">병합 후 폴리곤</div><div class="v">6,418 (-43%)</div></div>
<div class="card"><div class="k">라벨 종류</div><div class="v">82 / 94</div></div> <div class="card"><div class="k">라벨 종류</div><div class="v">82 / 94</div></div>
<div class="card"><div class="k">집합</div><div class="v">12</div></div> <div class="card"><div class="k">집합</div><div class="v">12</div></div>
<div class="card"><div class="k">검출 면적 / 화면</div><div class="v">184.7%</div></div> <div class="card"><div class="k">검출 면적 / 화면</div><div class="v">184.7%</div></div>
@@ -92,16 +92,16 @@ border-radius:6px;padding:5px 8px;font:inherit;font-size:13px}
<tr><td>DJI_20250805164753_0423</td><td data-v="762">762</td><td data-v="0.521">0.5%</td><td><div class="bar"><i style="width:0.5%;background:#1a7f4b"></i></div></td></tr> <tr><td>DJI_20250805164753_0423</td><td data-v="762">762</td><td data-v="0.521">0.5%</td><td><div class="bar"><i style="width:0.5%;background:#1a7f4b"></i></div></td></tr>
</tbody></table></div> </tbody></table></div>
<h2>공정 보기 — 같은 사진, 단계별</h2> <h2>공정 보기 — 같은 사진, 단계별</h2>
<div class="flow"><b>SAM 검출 11,169 폴리곤</b><span>(82 라벨)</span><span class="ar">→ 집합 병합 →</span><b>6,370 폴리곤</b><span>(14 집합)</span><span>· 사진 16장 기준</span></div> <div class="flow"><b>SAM 검출 11,169 폴리곤</b><span>(82 라벨)</span><span class="ar">→ 집합 병합 →</span><b>6,418 폴리곤</b><span>(15 집합)</span><span>· 사진 16장 기준</span></div>
<div class="tabs"> <div class="tabs">
<button class="tab" role="tab" data-i="0" aria-selected="true">1. SAM 검출 — 확정 설정 (16장)</button> <button class="tab" role="tab" data-i="0" aria-selected="true">단계 비교 (검출 → claim → 병합)</button>
<button class="tab" role="tab" data-i="1" aria-selected="false">2. 집합 병합 후 (gap 2px)</button> <button class="tab" role="tab" data-i="1" aria-selected="false">검출만 보기 (라벨 82종)</button>
<button class="tab" role="tab" data-i="2" aria-selected="false">참고: 기준선 검출 (conf 0.25, 4×3)</button> <button class="tab" role="tab" data-i="2" aria-selected="false">병합만 보기</button>
</div> </div>
<div class="stage" data-i="0"><iframe src="theater_v4/stage1_detect.html" loading="lazy" title="stage0"></iframe></div> <div class="stage" data-i="0"><iframe src="theater_v4/stages.html" loading="lazy" title="stage0"></iframe></div>
<div class="stage" data-i="1" hidden><iframe src="theater_v4/stage2_merged.html" loading="lazy" title="stage1"></iframe></div> <div class="stage" data-i="1" hidden><iframe src="theater_v4/stage1_detect.html" loading="lazy" title="stage1"></iframe></div>
<div class="stage" data-i="2" hidden><iframe src="theater/stage1_detect.html" loading="lazy" title="stage2"></iframe></div> <div class="stage" data-i="2" hidden><iframe src="theater_v4/stage2_merged.html" loading="lazy" title="stage2"></iframe></div>
<p class="sub" style="font-size:12px">왼쪽 목록에서 라벨을 켜고 끈다. 휠 확대, 드래그 이동. 새 창: <a href="theater_v4/stage1_detect.html">1. SAM 검출 — 확정 설정 (16장)</a> · <a href="theater_v4/stage2_merged.html">2. 집합 병합 후 (gap 2px)</a> · <a href="theater/stage1_detect.html">참고: 기준선 검출 (conf 0.25, 4×3)</a></p> <p class="sub" style="font-size:12px">왼쪽 목록에서 라벨을 켜고 끈다. 휠 확대, 드래그 이동. 새 창: <a href="theater_v4/stages.html">단계 비교 (검출 → claim → 병합)</a> · <a href="theater_v4/stage1_detect.html">검출만 보기 (라벨 82종)</a> · <a href="theater_v4/stage2_merged.html">병합만 보기</a></p>
<h2>단계</h2> <h2>단계</h2>
<div class="step"><span class="id">M1</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>프롬프트별 검출 집계</div><div class="d">tools/prompt_stats.py. YYX_run 실측: 폴리곤 13,580 · 라벨 77종.</div></div></div> <div class="step"><span class="id">M1</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>프롬프트별 검출 집계</div><div class="d">tools/prompt_stats.py. YYX_run 실측: 폴리곤 13,580 · 라벨 77종.</div></div></div>
<div class="step"><span class="id">M1b</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>공정 보기(검출 → 병합) 뷰어 연결</div><div class="d">tools/make_viewer.py 로 같은 사진 3장을 단계별로 굽고 모니터에 붙였다. docs/pilot/theater/.</div></div></div> <div class="step"><span class="id">M1b</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>공정 보기(검출 → 병합) 뷰어 연결</div><div class="d">tools/make_viewer.py 로 같은 사진 3장을 단계별로 굽고 모니터에 붙였다. docs/pilot/theater/.</div></div></div>
@@ -114,53 +114,54 @@ border-radius:6px;padding:5px 8px;font:inherit;font-size:13px}
<div class="step"><span class="id">M1i</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>타일 4×3 → 6×4 (목표 달성)</div><div class="d">conf 0.10 · v4 프롬프트 · --wide-in-tiles 와 함께. 4장 실측 무라벨: 0654 5.0% (기준선 52.1) · 0484 3.0% (38.7) · 0079 4.4% (27.1) · 0006 5.0% (19.4). 평균 34.3% → 4.35%. 비용 466~616초/장, 평균 523초 = 기준선 140초의 3.7배. 69장 재검출 시 약 10시간.</div></div></div> <div class="step"><span class="id">M1i</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>타일 4×3 → 6×4 (목표 달성)</div><div class="d">conf 0.10 · v4 프롬프트 · --wide-in-tiles 와 함께. 4장 실측 무라벨: 0654 5.0% (기준선 52.1) · 0484 3.0% (38.7) · 0079 4.4% (27.1) · 0006 5.0% (19.4). 평균 34.3% → 4.35%. 비용 466~616초/장, 평균 523초 = 기준선 140초의 3.7배. 69장 재검출 시 약 10시간.</div></div></div>
<div class="step"><span class="id">M1j</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>확정 설정 16장 검증</div><div class="d">16장 실측 무라벨(conf 0.10): 최소 0.5%(0423) ~ 최대 6.4%(0581), 평균 3.44%. 15/16 장이 5% 이하. 같은 16장 기준선 평균 19.9%. 소요 466~907초/장(평균 585초). 예상이 틀린 곳: 물 많은 사진이 위험할 것으로 봤으나 0423 이 0.5% 로 최저였다 — water surface/reservoir 검출이 0이어도 다른 프롬프트가 그 면적을 덮는다.</div></div></div> <div class="step"><span class="id">M1j</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>확정 설정 16장 검증</div><div class="d">16장 실측 무라벨(conf 0.10): 최소 0.5%(0423) ~ 최대 6.4%(0581), 평균 3.44%. 15/16 장이 5% 이하. 같은 16장 기준선 평균 19.9%. 소요 466~907초/장(평균 585초). 예상이 틀린 곳: 물 많은 사진이 위험할 것으로 봤으나 0423 이 0.5% 로 최저였다 — water surface/reservoir 검출이 0이어도 다른 프롬프트가 그 면적을 덮는다.</div></div></div>
<div class="step"><span class="id">M2</span><span class="badge" style="color:#a12a2a;background:#fbe6e6">막힘</span><div><div>집합 정의 조정 — 보류</div><div class="d">교통시설물을 가르는 안(configs/merge_groups_v5.txt)을 만들고 16장으로 확인했다: structure 1,079 → utility pole 608 / streetlight pole 372 / road sign 358 / culvert 9, fence 702 → guardrail 668 / fence 46 / roadside barrier 17. 라벨 14 → 19종. 사용자 지시로 보류하고 v4 로 되돌렸다. 세부 세그멘테이션은 후순위.</div></div></div> <div class="step"><span class="id">M2</span><span class="badge" style="color:#a12a2a;background:#fbe6e6">막힘</span><div><div>집합 정의 조정 — 보류</div><div class="d">교통시설물을 가르는 안(configs/merge_groups_v5.txt)을 만들고 16장으로 확인했다: structure 1,079 → utility pole 608 / streetlight pole 372 / road sign 358 / culvert 9, fence 702 → guardrail 668 / fence 46 / roadside barrier 17. 라벨 14 → 19종. 사용자 지시로 보류하고 v4 로 되돌렸다. 세부 세그멘테이션은 후순위.</div></div></div>
<div class="step"><span class="id">M3</span><span class="badge" style="color:#5a5f66;background:#eceef0">대기</span><div><div>gap 값 조정</div><div class="d">외곽선 인접 판정 1~3 px. 파일럿 기준 2 px 로 시작.</div></div></div> <div class="step"><span class="id">M3</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>gap 값 조정 — 2px 유지</div><div class="d">사진 4장(폴리곤 3,280)으로 gap 0~32px 스윕. 꺾이는 지점이 없는 매끄러운 곡선이다. 폴리곤 0px 2,081 · 2px 2,032 · 3px 2,014 · 8px 1,954 · 16px 1,801 · 32px 1,574. 무라벨은 4.60% → 3.75% 로 거의 안 움직인다. 1~3px 구간 차이는 폴리곤 2.4%, 무라벨 0.13pp 로 노이즈 수준이라 2px 를 유지한다. 과다 병합은 16px 부터 보인다(vehicle 240→229, 32px 에서 209). 콘은 gap 32px 에서도 65개 그대로 — 집합 밖이라 병합을 안 탄다. 소요는 사진당 약 5초(gap 2px).</div></div></div>
<div class="step"><span class="id">M4</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>단계 비교 뷰어</div><div class="d">tools/make_stage_viewer.py 신규. 같은 사진에서 검출 → claim → 병합을 갈아끼우고 바뀐 폴리곤만 빨갛게 강조한다. claim 은 563개 재배정 (white roof→white vehicle 194 등 지붕 오검을 차량으로), 병합은 1,033개가 둘 이상 합쳐진 것. 폴리곤 11,169 → 11,169 → 6,370.</div></div></div>
<div class="step"><span class="id">M5</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>factory structure 를 [building] 에서 분리</div><div class="d">사용자 지적. 공장 구조물이 일반 건물에 흡수되면 병합 후 되돌릴 수 없다. 집합 밖으로 빼면 passthrough 로 독립 클래스가 된다. 16장 실측 24개. 부작용으로 building 이 466 → 490 으로 늘었다 — 전에는 factory structure 폴리곤이 건물 사이 다리 노릇을 해 여러 건물을 한 덩어리로 잇고 있었다. 라벨 14 → 15종, 폴리곤 6,370 → 6,418.</div></div></div>
<h2>이슈 · 기각된 시도</h2> <h2>이슈 · 기각된 시도</h2>
<div class="issue"><span class="badge" style="color:#1a7f4b;background:#e3f5ea">해결</span><div>&#x27;집합 없는 검출은 버려진다&#x27;는 앞선 판단은 틀렸다. merge_labels.py 의 merge_by_group 은 집합에 없는 라벨을 passthrough 로 그대로 통과시킨다. 16장 병합 결과에 orange traffic cone 114 · yellow safety cone 6 이 독립 클래스로 살아 있다. 콘을 [cone] 으로 묶으면 오히려 나란한 콘이 gap 2px 에서 한 덩어리가 되어 개수를 잃는다 — 지금처럼 집합 밖에 두는 게 맞다.</div></div> <div class="issue"><span class="badge" style="color:#1a7f4b;background:#e3f5ea">해결</span><div>&#x27;집합 없는 검출은 버려진다&#x27;는 앞선 판단은 틀렸다. merge_labels.py 의 merge_by_group 은 집합에 없는 라벨을 passthrough 로 그대로 통과시킨다. 16장 병합 결과에 orange traffic cone 114 · yellow safety cone 6 이 독립 클래스로 살아 있다. 콘을 [cone] 으로 묶으면 오히려 나란한 콘이 gap 2px 에서 한 덩어리가 되어 개수를 잃는다 — 지금처럼 집합 밖에 두는 게 맞다.</div></div>
<div class="issue"><span class="badge" style="color:#1a7f4b;background:#e3f5ea">해결</span><div>통짜(1×1) 패스는 대상 하나에 인스턴스가 하나만 남는다 — 도로 한쪽 차로, 논 한 필지만 잡힘. --wide-in-tiles 로 해결(측정치는 M1e).</div></div> <div class="issue"><span class="badge" style="color:#1a7f4b;background:#e3f5ea">해결</span><div>통짜(1×1) 패스는 대상 하나에 인스턴스가 하나만 남는다 — 도로 한쪽 차로, 논 한 필지만 잡힘. --wide-in-tiles 로 해결(측정치는 M1e).</div></div>
<div class="issue"><span class="badge" style="color:#a12a2a;background:#fbe6e6">미해결</span><div>0581 무라벨 6.4% — 16장 중 유일한 5% 초과. 원인 확인함: 큰 구멍이 없다. 구멍 1,554개 중 최대가 0.83%, 상위 6개 합이 2.15% 뿐이고 나머지 4.25%가 작은 조각 1,548개로 흩어져 있다. 자재 야적장·건물 밀집 구역이라 폴리곤이 1,079개 나왔는데도 객체 사이 얇은 틈이 누적된 것. 0654 유형(큰 필지 통째 누락)과 다르다. 프롬프트로는 못 줄인다 — 후처리(폴리곤 팽창·구멍 메우기) 영역.</div></div> <div class="issue"><span class="badge" style="color:#a12a2a;background:#fbe6e6">미해결</span><div>0581 무라벨 6.4% — 16장 중 유일한 5% 초과. 원인 확인함: 큰 구멍이 없다. 구멍 1,554개 중 최대가 0.83%, 상위 6개 합이 2.15% 뿐이고 나머지 4.25%가 작은 조각 1,548개로 흩어져 있다. 자재 야적장·건물 밀집 구역이라 폴리곤이 1,079개 나왔는데도 객체 사이 얇은 틈이 누적된 것. 0654 유형(큰 필지 통째 누락)과 다르다. 프롬프트로는 못 줄인다 — 후처리(폴리곤 팽창·구멍 메우기) 영역.</div></div>
<div class="issue"><span class="badge" style="color:#a12a2a;background:#fbe6e6">미해결</span><div>orange traffic cone / yellow safety cone 은 어느 집합에도 없어 검출 52 폴리곤이 버려진다. [cone] 그룹이 주석 처리돼 있음 — 나란히 놓인 콘이 한 덩어리로 융합된다는 이유.</div></div> <div class="issue"><span class="badge" style="color:#a12a2a;background:#fbe6e6">미해결</span><div>확정 설정 16장에서 검출 0 인 프롬프트 12개: residential house, plowed field, commercial bus, plastic greenhouse, vinyl greenhouse tunnel, greenhouse roof, green wire fence, road embankment slope, riprap slope, bridge, water surface, yellow grass field. 16장은 69장의 23% 라 대상이 없었을 뿐일 수 있다 (bridge 는 다리 있는 사진에서만 나온다). 지워도 94→82개라 배치 청크가 6으로 그대로여서 속도 이득이 없다 — 78개 이하로 줄여야 5청크가 된다. 69장 전체를 돌린 뒤 판단할 것.</div></div>
<div class="issue"><span class="badge" style="color:#a12a2a;background:#fbe6e6">미해결</span><div>검출 0 인 프롬프트 8개 (plowed field, rice paddy, vinyl greenhouse tunnel, gabion wall, road embankment slope, riprap slope, water surface, reservoir) — 유지할지 뺄지 미정.</div></div>
<div class="issue"><span class="badge" style="color:#5a5f66;background:#eceef0">기각</span><div>프롬프트 색 특정성 A/B (car/sedan/suv/van/pickup truck) — 폴리곤 +26% 인데 마스크 면적은 +0.002~0.068%p 뿐, 신규 마스크의 90~99%가 기존과 겹침.</div></div> <div class="issue"><span class="badge" style="color:#5a5f66;background:#eceef0">기각</span><div>프롬프트 색 특정성 A/B (car/sedan/suv/van/pickup truck) — 폴리곤 +26% 인데 마스크 면적은 +0.002~0.068%p 뿐, 신규 마스크의 90~99%가 기존과 겹침.</div></div>
<div class="issue"><span class="badge" style="color:#5a5f66;background:#eceef0">기각</span><div>정사영상으로 차량 검증 — 경사 사진(2025-08-05)과 정사영상(250922)이 다른 비행이라 주차 차량 위치가 다르다.</div></div> <div class="issue"><span class="badge" style="color:#5a5f66;background:#eceef0">기각</span><div>정사영상으로 차량 검증 — 경사 사진(2025-08-05)과 정사영상(250922)이 다른 비행이라 주차 차량 위치가 다르다.</div></div>
<h2>집합별 합계</h2><div class="scroll"><table><thead><tr><th>집합</th><th>프롬프트(검출>0/전체)</th><th>폴리곤</th><th>폴리곤%</th><th>면적%</th> <h2>집합별 합계</h2><div class="scroll"><table><thead><tr><th>집합</th><th>프롬프트(검출>0/전체)</th><th>폴리곤</th><th>폴리곤%</th><th>면적%</th>
<th>병합 후 폴리곤</th><th>줄어든 비율</th><th>병합 후 면적%</th> <th>병합 후 폴리곤</th><th>줄어든 비율</th><th>병합 후 면적%</th>
<th></th></tr></thead><tbody> <th></th></tr></thead><tbody>
<tr><td>vegetation</td><td>6 / 7</td><td>3,035</td><td>27.2%</td><td>16.63%</td> <tr><td>vegetation</td><td>6 / 7</td><td>3,035</td><td>27.2%</td><td>16.63%</td>
<td>1,766</td><td class="d-up">42%</td><td>16.28%</td> <td>1,766</td><td class="d-up">42%</td><td>16.09%</td>
<td><div class="bar"><i style="width:16.6%"></i></div></td></tr> <td><div class="bar"><i style="width:16.6%"></i></div></td></tr>
<tr><td>building</td><td>17 / 21</td><td>2,218</td><td>19.9%</td><td>15.18%</td> <tr><td>building</td><td>17 / 21</td><td>2,218</td><td>19.9%</td><td>15.18%</td>
<td>466</td><td class="d-up">79%</td><td>12.97%</td> <td>490</td><td class="d-up">78%</td><td>12.57%</td>
<td><div class="bar"><i style="width:15.2%"></i></div></td></tr> <td><div class="bar"><i style="width:15.2%"></i></div></td></tr>
<tr><td>structure</td><td>4 / 4</td><td>1,347</td><td>12.1%</td><td>0.46%</td> <tr><td>structure</td><td>4 / 4</td><td>1,347</td><td>12.1%</td><td>0.46%</td>
<td>1,079</td><td class="d-up">20%</td><td>0.57%</td> <td>1,079</td><td class="d-up">20%</td><td>0.57%</td>
<td><div class="bar"><i style="width:0.5%"></i></div></td></tr> <td><div class="bar"><i style="width:0.5%"></i></div></td></tr>
<tr><td>ground</td><td>4 / 4</td><td>939</td><td>8.4%</td><td>7.71%</td> <tr><td>ground</td><td>4 / 4</td><td>939</td><td>8.4%</td><td>7.71%</td>
<td>573</td><td class="d-up">39%</td><td>9.09%</td> <td>573</td><td class="d-up">39%</td><td>8.99%</td>
<td><div class="bar"><i style="width:7.7%"></i></div></td></tr> <td><div class="bar"><i style="width:7.7%"></i></div></td></tr>
<tr><td>road</td><td>6 / 7</td><td>839</td><td>7.5%</td><td>25.09%</td> <tr><td>road</td><td>6 / 7</td><td>839</td><td>7.5%</td><td>25.09%</td>
<td>247</td><td class="d-up">71%</td><td>25.72%</td> <td>247</td><td class="d-up">71%</td><td>25.41%</td>
<td><div class="bar"><i style="width:25.1%"></i></div></td></tr> <td><div class="bar"><i style="width:25.1%"></i></div></td></tr>
<tr><td>fence</td><td>4 / 5</td><td>768</td><td>6.9%</td><td>0.90%</td> <tr><td>fence</td><td>4 / 5</td><td>768</td><td>6.9%</td><td>0.90%</td>
<td>702</td><td class="d-up">9%</td><td>1.20%</td> <td>702</td><td class="d-up">9%</td><td>1.18%</td>
<td><div class="bar"><i style="width:0.9%"></i></div></td></tr> <td><div class="bar"><i style="width:0.9%"></i></div></td></tr>
<tr><td>vehicle</td><td>13 / 14</td><td>663</td><td>5.9%</td><td>0.80%</td> <tr><td>vehicle</td><td>13 / 14</td><td>663</td><td>5.9%</td><td>0.80%</td>
<td>576</td><td class="d-up">13%</td><td>1.08%</td> <td>576</td><td class="d-up">13%</td><td>1.06%</td>
<td><div class="bar"><i style="width:0.8%"></i></div></td></tr> <td><div class="bar"><i style="width:0.8%"></i></div></td></tr>
<tr><td>stored material</td><td>9 / 9</td><td>536</td><td>4.8%</td><td>0.75%</td> <tr><td>stored material</td><td>9 / 9</td><td>536</td><td>4.8%</td><td>0.75%</td>
<td>450</td><td class="d-up">16%</td><td>1.03%</td> <td>450</td><td class="d-up">16%</td><td>1.02%</td>
<td><div class="bar"><i style="width:0.7%"></i></div></td></tr> <td><div class="bar"><i style="width:0.7%"></i></div></td></tr>
<tr><td>farmland</td><td>7 / 8</td><td>460</td><td>4.1%</td><td>29.24%</td> <tr><td>farmland</td><td>7 / 8</td><td>460</td><td>4.1%</td><td>29.24%</td>
<td>156</td><td class="d-up">66%</td><td>27.59%</td> <td>156</td><td class="d-up">66%</td><td>27.27%</td>
<td><div class="bar"><i style="width:29.2%"></i></div></td></tr> <td><div class="bar"><i style="width:29.2%"></i></div></td></tr>
<tr><td>없음</td><td>2 / 2</td><td>120</td><td>1.1%</td><td>0.00%</td> <tr><td>없음</td><td>2 / 2</td><td>120</td><td>1.1%</td><td>0.00%</td>
<td>120</td><td class="">0%</td><td>0.01%</td> <td>144</td><td class="">-20%</td><td>1.43%</td>
<td><div class="bar"><i style="width:0.0%"></i></div></td></tr> <td><div class="bar"><i style="width:0.0%"></i></div></td></tr>
<tr><td>retaining wall</td><td>3 / 3</td><td>100</td><td>0.9%</td><td>0.28%</td> <tr><td>retaining wall</td><td>3 / 3</td><td>100</td><td>0.9%</td><td>0.28%</td>
<td>99</td><td class="d-up">1%</td><td>0.39%</td> <td>99</td><td class="d-up">1%</td><td>0.38%</td>
<td><div class="bar"><i style="width:0.3%"></i></div></td></tr> <td><div class="bar"><i style="width:0.3%"></i></div></td></tr>
<tr><td>slope</td><td>2 / 4</td><td>86</td><td>0.8%</td><td>2.73%</td> <tr><td>slope</td><td>2 / 4</td><td>86</td><td>0.8%</td><td>2.73%</td>
<td>85</td><td class="d-up">1%</td><td>3.79%</td> <td>85</td><td class="d-up">1%</td><td>3.75%</td>
<td><div class="bar"><i style="width:2.7%"></i></div></td></tr> <td><div class="bar"><i style="width:2.7%"></i></div></td></tr>
<tr><td>water</td><td>5 / 6</td><td>58</td><td>0.5%</td><td>0.23%</td> <tr><td>water</td><td>5 / 6</td><td>58</td><td>0.5%</td><td>0.23%</td>
<td>51</td><td class="d-up">12%</td><td>0.28%</td> <td>51</td><td class="d-up">12%</td><td>0.28%</td>
@@ -270,11 +271,12 @@ border-radius:6px;padding:5px 8px;font:inherit;font-size:13px}
<b>epsilon_px</b><span><code>1.5</code></span> <b>epsilon_px</b><span><code>1.5</code></span>
<b>기준 런</b><span><code>output/sam3/tune_t64 (확정 설정 8장) / 기준선은 output/sam3/YYX_run 69장</code></span> <b>기준 런</b><span><code>output/sam3/tune_t64 (확정 설정 8장) / 기준선은 output/sam3/YYX_run 69장</code></span>
<b>프롬프트 파일</b><span><code>prompts/discovery_v4.txt (94개). 기준선은 discovery_v1.txt (85개)</code></span> <b>프롬프트 파일</b><span><code>prompts/discovery_v4.txt (94개). 기준선은 discovery_v1.txt (85개)</code></span>
<b>집합 정의</b><span><code>configs/merge_groups_v4.txt (12집합). 기준선은 merge_groups.txt</code></span> <b>집합 정의</b><span><code>configs/merge_groups_v4.txt (11집합 + 집합 밖 3종: factory structure, 콘 2종)</code></span>
<b>타일</b><span><code>6×4 = 24개, overlap 10%, 타일 크기 1365×1365. 기준선은 4×3 (2048×1820)</code></span> <b>타일</b><span><code>6×4 = 24개, overlap 10%, 타일 크기 1365×1365. 기준선은 4×3 (2048×1820)</code></span>
<b>검출 설정</b><span><code>conf 0.10 · NMS iou 0.40 · 같은 라벨 병합 gap 8px · --wide-in-tiles. 기준선 conf 0.25, 통짜 분리</code></span> <b>검출 설정</b><span><code>conf 0.10 · NMS iou 0.40 · 같은 라벨 병합 gap 8px · --wide-in-tiles. 기준선 conf 0.25, 통짜 분리</code></span>
<b>공정 보기 사진</b><span><code>확정 설정 8장 (기준선 무라벨 3.5~52.1% 분포에서 고름)</code></span> <b>공정 보기 사진</b><span><code>확정 설정 8장 (기준선 무라벨 3.5~52.1% 분포에서 고름)</code></span>
<b>확정 후보 설정</b><span><code>prompts/discovery_v4.txt (94개) · configs/merge_groups_v4.txt · --wide-in-tiles · --cols 6 --rows 4 · --conf 0.10 · NMS 0.40 · --merge (gap 8px) · 병합 gap 2px</code></span> <b>확정 후보 설정</b><span><code>prompts/discovery_v4.txt (94개) · configs/merge_groups_v4.txt · --wide-in-tiles · --cols 6 --rows 4 · --conf 0.10 · NMS 0.40 · --merge (gap 8px) · 병합 gap 2px</code></span>
<b>claim 규칙</b><span><code>configs/claim_rules.txt — vehicle : 0.5 : 지붕 11종 + 방수포/보호시트</code></span>
</div> </div>
</div> </div>
<script> <script>
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python
"""후처리 단계를 같은 사진 위에서 갈아끼워 비교하는 뷰어.
make_viewer.py 는 한 벌의 결과를 본다. 이건 여러 단계(검출 → claim → 병합)를
한 화면에서 전환한다. 사진은 그대로 두고 폴리곤만 바뀌므로 무엇이 달라졌는지
눈으로 바로 잡힌다.
바뀐 것만 보기: claim 단계는 라벨이 재배정된 폴리곤(claimed_from), 병합 단계는
둘 이상이 합쳐진 폴리곤(merged_from 길이 2 이상)을 강조한다.
python tools/make_stage_viewer.py \
--stage "1. 검출=output/sam3/tune_t64/*_multi.json" \
--stage "2. claim=output/sam3/tune_t64/*_multi_claimed.json" \
--stage "3. 병합=output/sam3/tune_t64/*_multi_merged.json" \
--output docs/pilot/theater_v4/stages.html
"""
import argparse
import colorsys
import glob
import hashlib
import json
import os
import re
from collections import Counter, OrderedDict
import cv2
import numpy as np
def stem_of(path):
"""DJI_..._multi_merged.json → DJI_... (단계 접미사를 벗긴다)"""
s = os.path.basename(path)
return re.sub(r"_multi(_[a-z0-9]+)?\.json$", "", s)
def color_for(label):
h = int(hashlib.md5(label.encode("utf-8")).hexdigest()[:8], 16)
r, g, b = colorsys.hsv_to_rgb((h % 360) / 360, 0.62, 0.98)
return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
HTML = """<title>__TITLE__</title>
<style>
:root{--bg:#0d0f10;--fg:#e6e9ea;--dim:#8b969b;--line:#2c3336;--card:#171c1e;--accent:#4da3ff}
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 -apple-system,
"Segoe UI","Malgun Gothic",sans-serif;height:100vh;display:flex;overflow:hidden}
#side{width:250px;flex:none;background:var(--card);border-right:1px solid var(--line);
display:flex;flex-direction:column;overflow:hidden}
#side h1{font-size:13px;margin:0;padding:11px 13px;border-bottom:1px solid var(--line)}
.pager{display:flex;gap:5px;padding:9px 11px;border-bottom:1px solid var(--line)}
.pager select{flex:1;background:#0f1416;color:var(--fg);border:1px solid var(--line);
border-radius:4px;padding:4px;font-size:12px}
button{background:#232a2d;color:var(--fg);border:1px solid var(--line);border-radius:4px;
padding:4px 9px;cursor:pointer;font:inherit;font-size:12px}
button:hover{background:#2e3639}
.stages{padding:10px 11px;border-bottom:1px solid var(--line);display:flex;
flex-direction:column;gap:5px}
.stages button{text-align:left;padding:7px 10px;font-size:13px}
.stages button[aria-selected="true"]{background:var(--accent);border-color:var(--accent);
color:#04121f;font-weight:700}
.stages .n{float:right;opacity:.75;font-variant-numeric:tabular-nums}
.opts{padding:9px 11px;border-bottom:1px solid var(--line);display:flex;
flex-direction:column;gap:5px;color:var(--dim)}
.opts label{display:flex;gap:7px;align-items:center;cursor:pointer}
#legend{overflow-y:auto;padding:7px 11px;flex:1}
#legend .row{display:flex;gap:7px;align-items:center;padding:2px 0}
#legend .sw{width:11px;height:11px;border-radius:2px;flex:none}
#legend .lb{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#legend .ct{color:var(--dim);font-variant-numeric:tabular-nums}
#view{flex:1;position:relative;overflow:hidden;background:#000}
canvas{position:absolute;top:0;left:0}
#hud{position:absolute;left:12px;top:12px;background:#000b;padding:6px 11px;
border-radius:5px;font-size:12px;line-height:1.7;pointer-events:none}
#hud b{color:var(--accent)}
#hint{position:absolute;right:12px;bottom:12px;background:#000b;padding:5px 9px;
border-radius:5px;font-size:11px;color:var(--dim)}
#pick{position:absolute;left:12px;bottom:12px;background:#000c;padding:6px 11px;
border-radius:5px;font-size:12px;line-height:1.6;pointer-events:none;max-width:60%}
#pick .dim{color:var(--dim)}
</style>
<div id="side">
<h1>__TITLE__</h1>
<div class="pager">
<button onclick="go(-1)">◀</button>
<select id="sel" onchange="setPage(+this.value)"></select>
<button onclick="go(1)">▶</button>
</div>
<div class="stages" id="stages"></div>
<div class="opts">
<label><input type="checkbox" id="fill" checked> 면 채우기</label>
<label><input type="checkbox" id="only"> 바뀐 것만</label>
<label><input type="checkbox" id="dim"> 사진 어둡게</label>
<button onclick="fit()">화면 맞춤</button>
</div>
<div id="legend"></div>
</div>
<div id="view"><canvas id="cv"></canvas>
<div id="hud"></div>
<div id="pick"></div>
<div id="hint">1~9 단계 · ←→ 사진 · 휠 확대 · 드래그 이동 · 폴리곤 위에 커서를 올리면 정보</div>
</div>
<script>
const DATA = __DATA__;
const img = new Image();
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const view = document.getElementById('view');
let page = 0, stage = 0, scale = 1, ox = 0, oy = 0;
function curr() { return DATA.pages[page].stages[stage]; }
function changed(s) { return s.c === 1; }
function buildStages() {
const box = document.getElementById('stages');
box.innerHTML = '';
DATA.stages.forEach((name, i) => {
const b = document.createElement('button');
const st = DATA.pages[page].stages[i];
b.innerHTML = name + `<span class="n">${st ? st.p.length.toLocaleString() : '-'}</span>`;
b.setAttribute('aria-selected', i === stage);
b.onclick = () => { stage = i; buildStages(); draw(); };
box.appendChild(b);
});
}
function buildLegend() {
const st = curr(), c = {};
for (const s of st.p) c[s.l] = (c[s.l] || 0) + 1;
const el = document.getElementById('legend');
el.innerHTML = '';
Object.entries(c).sort((a, b) => b[1] - a[1]).forEach(([lb, n]) => {
const d = document.createElement('div');
d.className = 'row';
d.innerHTML = `<span class="sw" style="background:${DATA.colors[lb] || '#888'}"></span>`
+ `<span class="lb">${lb}</span><span class="ct">${n.toLocaleString()}</span>`;
el.appendChild(d);
});
}
function draw() {
const st = curr();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.drawImage(img, ox, oy, img.width * scale, img.height * scale);
if (document.getElementById('dim').checked) {
ctx.fillStyle = 'rgba(0,0,0,.55)';
ctx.fillRect(ox, oy, img.width * scale, img.height * scale);
}
const fill = document.getElementById('fill').checked;
const only = document.getElementById('only').checked;
let nch = 0;
for (const s of st.p) {
const ch = changed(s);
if (ch) nch++;
if (only && !ch) continue;
const col = DATA.colors[s.l] || '#888';
ctx.beginPath();
s.p.forEach((pt, i) => {
const x = ox + pt[0] * scale, y = oy + pt[1] * scale;
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
});
ctx.closePath();
if (fill) { ctx.fillStyle = col + (ch ? 'aa' : '55'); ctx.fill(); }
ctx.strokeStyle = ch ? '#ff3b30' : col;
ctx.lineWidth = ch ? 2.5 : 1.2;
ctx.stroke();
}
document.getElementById('hud').innerHTML =
`${DATA.pages[page].name}<br>단계 <b>${DATA.stages[stage]}</b> · 폴리곤 <b>${st.p.length.toLocaleString()}</b>`
+ (nch ? ` · 바뀐 것 <b>${nch.toLocaleString()}</b>` : '');
}
function fit() {
scale = Math.min(view.clientWidth / img.width, view.clientHeight / img.height);
ox = (view.clientWidth - img.width * scale) / 2;
oy = (view.clientHeight - img.height * scale) / 2;
draw();
}
function setPage(i) {
page = (i + DATA.pages.length) % DATA.pages.length;
document.getElementById('sel').value = page;
img.onload = () => { buildStages(); buildLegend(); fit(); };
img.src = DATA.pages[page].img;
}
function go(d) { setPage(page + d); }
function resize() { cv.width = view.clientWidth; cv.height = view.clientHeight; draw(); }
window.addEventListener('resize', () => { resize(); fit(); });
view.addEventListener('wheel', e => {
e.preventDefault();
const f = e.deltaY < 0 ? 1.15 : 1 / 1.15;
ox = e.offsetX - (e.offsetX - ox) * f;
oy = e.offsetY - (e.offsetY - oy) * f;
scale *= f; draw();
}, { passive: false });
let drag = null;
view.addEventListener('mousedown', e => drag = [e.clientX - ox, e.clientY - oy]);
window.addEventListener('mouseup', () => drag = null);
window.addEventListener('mousemove', e => {
if (drag) { ox = e.clientX - drag[0]; oy = e.clientY - drag[1]; draw(); return; }
hover(e);
});
function inside(pt, poly) {
let hit = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i], [xj, yj] = poly[j];
if ((yi > pt[1]) !== (yj > pt[1])
&& pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi) hit = !hit;
}
return hit;
}
function area(poly) {
let a = 0;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++)
a += poly[j][0] * poly[i][1] - poly[i][0] * poly[j][1];
return Math.abs(a) / 2;
}
function hover(e) {
const r = view.getBoundingClientRect();
const x = (e.clientX - r.left - ox) / scale, y = (e.clientY - r.top - oy) / scale;
const st = curr();
let best = null, bestA = Infinity;
for (const s of st.p) {
if (!inside([x, y], s.p)) continue;
const a = area(s.p);
if (a < bestA) { best = s; bestA = a; } // 가장 작은 것 = 가장 위
}
const el = document.getElementById('pick');
if (!best) { el.innerHTML = `<span class="dim">커서 (${Math.round(x)}, ${Math.round(y)})</span>`; return; }
let why = '';
if (best.f) why = `<br><span class="dim">원래</span> ${best.f} <span class="dim">→ 뺏김</span>`;
else if (best.m.length) why = `<br><span class="dim">합쳐진 것</span> ${best.m.join(' + ')}`;
el.innerHTML = `<b style="color:${DATA.colors[best.l] || '#888'}">${best.l}</b>`
+ ` <span class="dim">(${Math.round(x)}, ${Math.round(y)})</span>${why}`;
}
window.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft') go(-1);
else if (e.key === 'ArrowRight') go(1);
else if (/^[1-9]$/.test(e.key)) {
const i = +e.key - 1;
if (i < DATA.stages.length) { stage = i; buildStages(); buildLegend(); draw(); }
}
});
['fill', 'only', 'dim'].forEach(id =>
document.getElementById(id).onchange = draw);
const sel = document.getElementById('sel');
DATA.pages.forEach((p, i) => {
const o = document.createElement('option');
o.value = i; o.textContent = `${i + 1}/${DATA.pages.length} ${p.name}`;
sel.appendChild(o);
});
resize();
setPage(0);
</script>"""
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--stage", action="append", required=True, metavar="이름=글롭",
help="단계 하나. 순서대로 여러 번 준다")
ap.add_argument("--output", required=True)
ap.add_argument("--images", help="원본 이미지 폴더 (JSON 의 source_image 가 없을 때)")
ap.add_argument("--max-size", type=int, default=2000)
ap.add_argument("--title", default=None)
args = ap.parse_args()
stages = []
for spec in args.stage:
if "=" not in spec:
raise SystemExit(f"--stage 형식은 '이름=글롭': {spec}")
name, pat = spec.split("=", 1)
files = {stem_of(p): p for p in sorted(glob.glob(pat))}
if not files:
raise SystemExit(f"단계 '{name}' 에 해당하는 JSON 없음: {pat}")
stages.append((name, files))
common = set(stages[0][1])
for _, f in stages[1:]:
common &= set(f)
if not common:
raise SystemExit("모든 단계에 공통으로 있는 사진이 없다")
stems = sorted(common)
print(f"단계 {len(stages)}개 · 공통 사진 {len(stems)}")
out_html = os.path.abspath(args.output)
os.makedirs(os.path.dirname(out_html), exist_ok=True)
pages, colors, counts = [], {}, Counter()
for stem in stems:
img_name = stem + "_view.jpg"
img_path = os.path.join(os.path.dirname(out_html), img_name)
if not os.path.isfile(img_path):
with open(stages[0][1][stem], encoding="utf-8") as fh:
src = json.load(fh).get("source_image")
if (not src or not os.path.isfile(src)) and args.images:
for ext in (".JPG", ".jpg", ".png"):
cand = os.path.join(args.images, stem + ext)
if os.path.isfile(cand):
src = cand
break
if not src or not os.path.isfile(src):
raise SystemExit(f"원본 이미지를 못 찾았다: {stem} (--images 로 지정)")
im = cv2.imdecode(np.fromfile(src, dtype=np.uint8), cv2.IMREAD_COLOR)
hh, ww = im.shape[:2]
k = min(1.0, args.max_size / max(hh, ww))
if k < 1.0:
im = cv2.resize(im, (int(ww * k), int(hh * k)), interpolation=cv2.INTER_AREA)
cv2.imencode(".jpg", im, [cv2.IMWRITE_JPEG_QUALITY, 86])[1].tofile(img_path)
print(f" 이미지 생성 {img_name}")
# 좌표 축척은 원본 기준이므로 이미지 크기에서 되계산한다
im = cv2.imdecode(np.fromfile(img_path, dtype=np.uint8), cv2.IMREAD_COLOR)
vh, vw = im.shape[:2]
page_stages = []
for name, files in stages:
with open(files[stem], encoding="utf-8") as fh:
segs = json.load(fh)["segments"]
if not page_stages: # 첫 단계에서 원본 해상도를 잡는다
mx = max((p[0] for s in segs for p in s["points"]), default=vw)
my = max((p[1] for s in segs for p in s["points"]), default=vh)
k = min(vw / max(mx, 1), vh / max(my, 1))
items = []
for s in segs:
lb = s.get("label", "")
colors.setdefault(lb, color_for(lb))
counts[lb] += 1
ch = 1 if (s.get("claimed_from")
or len(s.get("merged_from", []) or []) > 1) else 0
src = s.get("merged_from") or []
items.append({"l": lb, "c": ch,
"f": s.get("claimed_from") or "",
"m": src if len(src) > 1 else [],
"p": [[round(p[0] * k, 1), round(p[1] * k, 1)]
for p in s["points"]]})
page_stages.append({"p": items})
pages.append({"name": stem, "img": img_name, "stages": page_stages})
title = args.title or os.path.splitext(os.path.basename(out_html))[0]
payload = {"stages": [n for n, _ in stages], "pages": pages, "colors": colors}
with open(out_html, "w", encoding="utf-8") as fh:
fh.write(HTML.replace("__TITLE__", title)
.replace("__DATA__", json.dumps(payload, ensure_ascii=False)))
tot = [sum(len(p["stages"][i]["p"]) for p in pages) for i in range(len(stages))]
print(f"뷰어: {args.output}")
for (name, _), n in zip(stages, tot):
print(f" {name:<16}{n:>8,} 폴리곤")
if __name__ == "__main__":
main()