From 28bee391965fee24974f5c5fdaf29923b05ff86a Mon Sep 17 00:00:00 2001 From: minsung Date: Tue, 1 Sep 2026 20:04:45 +0900 Subject: [PATCH] =?UTF-8?q?@=20feat:=20SAM=203.1=20=EA=B2=80=EC=B6=9C=20?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=ED=8A=9C=EB=8B=9D=20?= =?UTF-8?q?=E2=80=94=20=EB=AC=B4=EB=9D=BC=EB=B2=A8=2016%=20=E2=86=92=203.6?= =?UTF-8?q?%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2D 검출 단계에서 미검출을 없애는 작업. 지표는 "무라벨 화면%" — 마스크 합집합으로 재서 겹침을 뺀 값이다. 다각형 넓이 단순 합(105%)은 겹침 때문에 미검출을 못 잡아낸다. 원인은 프롬프트가 아니라 통짜 패스였다. wide_v1.txt 의 21개 프롬프트는 8192x5460 을 1x1 로 넣어 대상 하나에 인스턴스가 하나만 살아남았다. 도로는 한쪽 차로만, 논은 한 필지만 잡혔다. BlockYYX 실측 (사진 8장): 기준선 무라벨 평균 21.6% (69장 전체로는 16.1%, 최악 52.1%) 튜닝후 무라벨 평균 3.6% (최대 5.0%) 기여도 (0654 기준): 통짜 -> 타일 패스 -36.7pp conf 0.25 -> 0.10 -5.4pp 타일 4x3 -> 6x4 -4.6pp 프롬프트 추가 9개 -0.5pp <- 거의 기여 없음 비용은 140 -> 548초/장 (3.9배). sam3_multi_prompt.py: --wide-in-tiles 플래그 추가. 통짜 프롬프트를 타일 패스에서도 돌린다. 겹치는 결과는 NMS 가 지운다. 신규 도구: coverage_stats.py 마스크 합집합으로 무라벨% 측정 prompt_stats.py 카테고리-프롬프트별 검출 집계 make_merge_monitor.py 모니터 HTML 생성 (수치는 집계 JSON 에서만) vote_labels.py 에 --rescue-pred 추가 (투표에 졌지만 표가 있고 모델도 동의하는 면을 되돌린다). 3D 작업이 다른 담당으로 넘어가 중단된 상태. Co-Authored-By: Claude Opus 5 (1M context) @ --- configs/merge_groups_v3.txt | 147 +++++++++++++++ configs/merge_groups_v4.txt | 151 +++++++++++++++ configs/sam_merge_status.json | 111 +++++++++++ docs/pilot/sam_merge.html | 296 +++++++++++++++++++++++++++++ prompts/discovery_v3.txt | 128 +++++++++++++ prompts/discovery_v4.txt | 136 ++++++++++++++ prompts/wide_v2_road_tiled.txt | 24 +++ tools/coverage_stats.py | 118 ++++++++++++ tools/make_merge_monitor.py | 331 +++++++++++++++++++++++++++++++++ tools/prompt_stats.py | 189 +++++++++++++++++++ tools/sam3_multi_prompt.py | 168 +++++++++++------ tools/vote_labels.py | 208 +++++++++++++++++++++ 12 files changed, 1947 insertions(+), 60 deletions(-) create mode 100644 configs/merge_groups_v3.txt create mode 100644 configs/merge_groups_v4.txt create mode 100644 configs/sam_merge_status.json create mode 100644 docs/pilot/sam_merge.html create mode 100644 prompts/discovery_v3.txt create mode 100644 prompts/discovery_v4.txt create mode 100644 prompts/wide_v2_road_tiled.txt create mode 100644 tools/coverage_stats.py create mode 100644 tools/make_merge_monitor.py create mode 100644 tools/prompt_stats.py create mode 100644 tools/vote_labels.py diff --git a/configs/merge_groups_v3.txt b/configs/merge_groups_v3.txt new file mode 100644 index 0000000..be6225c --- /dev/null +++ b/configs/merge_groups_v3.txt @@ -0,0 +1,147 @@ +# 병합 그룹 정의 — 대괄호 안이 병합 후 대표 라벨. +# 같은 그룹에 속한 라벨끼리 외곽선이 --gap px 이내로 인접하면 하나로 합친다. +# 어느 그룹에도 없는 라벨은 건드리지 않는다. +# +# 여기서 나누지 않는 구분이 있다. SAM은 사진만 보므로 미터도 규모도 모른다. +# 아래는 2D가 아니라 3D 메시에서 갈라야 정확하다: +# 낮은수목 / 수목 CSF 지면 위 높이 +# 수목 / 숲 [vegetation] 연결성분 면적 +# 지형 / 경사면 면 법선 경사각 +# 건물 / 빌딩 [building] 연결성분 면적·높이 +# 그래서 tree canopy와 dense forest, building과 residential house는 +# 일부러 같은 그룹에 둔다 — 2D에서 갈라봐야 중복 마스크만 나온다. + +# [building] +building +building rooftop +building facade +residential house +warehouse +factory structure +storage container +blue roof +red roof +green roof +gray roof +dark gray roof +black roof +white roof +silver metal roof +orange roof +brown roof +yellow roof +greenhouse roof +plastic greenhouse +vinyl greenhouse tunnel + +# [road] +asphalt road +concrete pavement +parking lot +pedestrian sidewalk +crosswalk +bridge + +# 아래는 비활성 — discovery_v1.txt에서 프롬프트를 통째로 껐다. +# 최종 목표가 지면 대 나머지라 차선은 도로면의 일부지 별도 클래스가 아니다. +# 검출량 1위였지만(583개 중 277개) 전부 [road] 안에서 버려지고, +# 논밭 이랑을 yellow center dividing line으로 오인하기까지 했다. +# [road marking] +# road lane marking +# yellow center dividing line +# white solid lane marking +# white dashed lane marking +# white directional arrow marking +# yellow parking stall line +# white parking stall line +# blue handicap parking space +# pink pedestrian safety marking +# green bike lane marking + +# [vegetation] +overgrown weeds +shrub +tree canopy +dense forest +green hedge +grass lawn + +# [farmland] +crop field +farmland +plowed field +bare soil field +rice paddy +vegetable field + +# [ground] +bare ground +dirt field +earth bank +dirt path + +# [stored material] +blue plastic drum +orange plastic barrel +yellow plastic container +white industrial tank +metallic storage tank +blue waterproof tarp +green waterproof tarp +black protective sheet +white canvas canopy + +# [retaining wall] +retaining wall +concrete retaining wall +gabion wall + +# [slope] +grass covered embankment +road embankment slope +cut slope +riprap slope + +# [water] +water surface +pond +reservoir +stream +irrigation canal +drainage ditch + +# [structure] +utility pole +streetlight pole +road sign +culvert + +# [fence] +gray metal fence +green wire fence +red roadside barrier +guardrail +metal guardrail + +# [vehicle] +white vehicle +black vehicle +silver vehicle +gray vehicle +red vehicle +blue vehicle +yellow vehicle +orange vehicle +green vehicle +white cargo truck +blue cargo truck +yellow school bus +commercial bus +tractor +farm tractor +excavator + +# 아래는 기본 비활성 — 나란히 놓인 콘이 한 덩어리로 융합된다. +# [cone] +# yellow safety cone +# orange traffic cone diff --git a/configs/merge_groups_v4.txt b/configs/merge_groups_v4.txt new file mode 100644 index 0000000..aad5e71 --- /dev/null +++ b/configs/merge_groups_v4.txt @@ -0,0 +1,151 @@ +# 병합 그룹 정의 — 대괄호 안이 병합 후 대표 라벨. +# 같은 그룹에 속한 라벨끼리 외곽선이 --gap px 이내로 인접하면 하나로 합친다. +# 어느 그룹에도 없는 라벨은 건드리지 않는다. +# +# 여기서 나누지 않는 구분이 있다. SAM은 사진만 보므로 미터도 규모도 모른다. +# 아래는 2D가 아니라 3D 메시에서 갈라야 정확하다: +# 낮은수목 / 수목 CSF 지면 위 높이 +# 수목 / 숲 [vegetation] 연결성분 면적 +# 지형 / 경사면 면 법선 경사각 +# 건물 / 빌딩 [building] 연결성분 면적·높이 +# 그래서 tree canopy와 dense forest, building과 residential house는 +# 일부러 같은 그룹에 둔다 — 2D에서 갈라봐야 중복 마스크만 나온다. + +# [building] +building +building rooftop +building facade +residential house +warehouse +factory structure +storage container +blue roof +red roof +green roof +gray roof +dark gray roof +black roof +white roof +silver metal roof +orange roof +brown roof +yellow roof +greenhouse roof +plastic greenhouse +vinyl greenhouse tunnel + +# [road] +gravel shoulder +asphalt road +concrete pavement +parking lot +pedestrian sidewalk +crosswalk +bridge + +# 아래는 비활성 — discovery_v1.txt에서 프롬프트를 통째로 껐다. +# 최종 목표가 지면 대 나머지라 차선은 도로면의 일부지 별도 클래스가 아니다. +# 검출량 1위였지만(583개 중 277개) 전부 [road] 안에서 버려지고, +# 논밭 이랑을 yellow center dividing line으로 오인하기까지 했다. +# [road marking] +# road lane marking +# yellow center dividing line +# white solid lane marking +# white dashed lane marking +# white directional arrow marking +# yellow parking stall line +# white parking stall line +# blue handicap parking space +# pink pedestrian safety marking +# green bike lane marking + +# [vegetation] +yellow grass field +overgrown weeds +shrub +tree canopy +dense forest +green hedge +grass lawn + +# [farmland] +ripening rice field +harvested field +crop field +farmland +plowed field +bare soil field +rice paddy +vegetable field + +# [ground] +bare ground +dirt field +earth bank +dirt path + +# [stored material] +blue plastic drum +orange plastic barrel +yellow plastic container +white industrial tank +metallic storage tank +blue waterproof tarp +green waterproof tarp +black protective sheet +white canvas canopy + +# [retaining wall] +retaining wall +concrete retaining wall +gabion wall + +# [slope] +grass covered embankment +road embankment slope +cut slope +riprap slope + +# [water] +water surface +pond +reservoir +stream +irrigation canal +drainage ditch + +# [structure] +utility pole +streetlight pole +road sign +culvert + +# [fence] +gray metal fence +green wire fence +red roadside barrier +guardrail +metal guardrail + +# [vehicle] +white vehicle +black vehicle +silver vehicle +gray vehicle +red vehicle +blue vehicle +yellow vehicle +orange vehicle +green vehicle +white cargo truck +blue cargo truck +yellow school bus +commercial bus +tractor +farm tractor +excavator + +# 아래는 기본 비활성 — 나란히 놓인 콘이 한 덩어리로 융합된다. +# [cone] +# yellow safety cone +# orange traffic cone diff --git a/configs/sam_merge_status.json b/configs/sam_merge_status.json new file mode 100644 index 0000000..a1652d0 --- /dev/null +++ b/configs/sam_merge_status.json @@ -0,0 +1,111 @@ +{ + "title": "SAM 3.1 후처리 — 검출 커버리지 · 집합 병합", + "note": "1단계는 미검출을 없애는 것이다. 지표는 '무라벨 화면%' — 마스크 합집합으로 재서 겹침을 뺀 값. 목표 5% 이하, 8장 평균 3.6% 로 달성. 2단계는 집합으로 묶고 외곽선이 gap px 이내로 인접한 폴리곤을 합치는 것.", + "params": { + "gap_px": 2, + "epsilon_px": 1.5, + "기준 런": "output/sam3/tune_t64 (확정 설정 8장) / 기준선은 output/sam3/YYX_run 69장", + "프롬프트 파일": "prompts/discovery_v4.txt (94개). 기준선은 discovery_v1.txt (85개)", + "집합 정의": "configs/merge_groups_v4.txt (12집합). 기준선은 merge_groups.txt", + "타일": "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, 통짜 분리", + "공정 보기 사진": "확정 설정 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" + }, + "steps": [ + { + "id": "M1", + "name": "프롬프트별 검출 집계", + "state": "done", + "detail": "tools/prompt_stats.py. YYX_run 실측: 폴리곤 13,580 · 라벨 77종." + }, + { + "id": "M1b", + "name": "공정 보기(검출 → 병합) 뷰어 연결", + "state": "done", + "detail": "tools/make_viewer.py 로 같은 사진 3장을 단계별로 굽고 모니터에 붙였다. docs/pilot/theater/." + }, + { + "id": "M1c", + "name": "무라벨 지표 확립", + "state": "done", + "detail": "tools/coverage_stats.py. YYX_run 69장 실측: 무라벨 평균 16.1% · 중앙 15.0% · 최악 52.1%(0654). 덮음 95% 이상은 2장뿐." + }, + { + "id": "M1d", + "name": "통짜 패스 원인 규명", + "state": "done", + "detail": "wide_v1.txt 의 21개(asphalt road, crop field, farmland, tree canopy 등)는 8192×5460 을 1×1 로 넣어 대상당 인스턴스가 1개만 살아남았다. 0006 에서 asphalt road 가 서쪽 차로만 덮음(1개, score 0.910)." + }, + { + "id": "M1e", + "name": "--wide-in-tiles 시험", + "state": "done", + "detail": "통짜 프롬프트를 타일 패스에도 돌린다. 0654 무라벨 52.1→15.4%, 0006 19.4→12.2%. 비용 140→231초/장 (+65%)." + }, + { + "id": "M1f", + "name": "경계 띠 프롬프트 추가 (v3)", + "state": "done", + "detail": "잔여 무라벨이 논밭 둑·도로 법면·밭 가장자리 잡초였다. overgrown weeds / shrub / grass covered embankment / earth bank / dirt path 5개 추가. 효과 작음 — 0654 15.4→14.9%, 0006 12.2→11.9%." + }, + { + "id": "M1g", + "name": "conf 스윕", + "state": "done", + "detail": "conf 0.10 으로 한 번 뽑고 점수 문턱을 오프라인으로 쓸었다. 0006: 0.25에서 9.3% → 0.10에서 5.8%. 0654: 13.1% → 9.5%. conf 0.03 은 마스크 폭증으로 37분에 1장도 못 끝내 중단." + }, + { + "id": "M1h", + "name": "v4 프롬프트 (익은 논·갓길)", + "state": "done", + "detail": "ripening rice field / yellow grass field / harvested field / gravel shoulder 추가. 효과 없음 — 0006 5.8→5.7%, 0654 9.5→9.6%. 프롬프트 추가는 포화." + }, + { + "id": "M1i", + "name": "타일 4×3 → 6×4 (목표 달성)", + "state": "done", + "detail": "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시간." + }, + { + "id": "M1j", + "name": "확정 설정 8장 검증", + "state": "done", + "detail": "8장 실측 무라벨(conf 0.10): 0654 5.0 · 0006 5.0 · 0653 4.7 · 0079 4.4 · 0484 3.0 · 0274 2.9 · 0321 2.5 · 0475 1.5 → 평균 3.6%. 같은 8장 기준선은 평균 21.8%. 소요 466~662초/장(평균 548초). 중간에 PC 다운으로 2장 유실, 재개함." + }, + { + "id": "M2", + "name": "집합 정의 조정", + "state": "todo", + "detail": "튜닝 검출 결과(라벨 79종)를 보고 집합을 다시 짠다. 1차는 Claude, 이후 사용자." + }, + { + "id": "M3", + "name": "gap 값 조정", + "state": "todo", + "detail": "외곽선 인접 판정 1~3 px. 파일럿 기준 2 px 로 시작." + } + ], + "issues": [ + { + "state": "closed", + "text": "통짜(1×1) 패스는 대상 하나에 인스턴스가 하나만 남는다 — 도로 한쪽 차로, 논 한 필지만 잡힘. --wide-in-tiles 로 해결(측정치는 M1e)." + }, + { + "state": "open", + "text": "orange traffic cone / yellow safety cone 은 어느 집합에도 없어 검출 52 폴리곤이 버려진다. [cone] 그룹이 주석 처리돼 있음 — 나란히 놓인 콘이 한 덩어리로 융합된다는 이유." + }, + { + "state": "open", + "text": "검출 0 인 프롬프트 8개 (plowed field, rice paddy, vinyl greenhouse tunnel, gabion wall, road embankment slope, riprap slope, water surface, reservoir) — 유지할지 뺄지 미정." + }, + { + "state": "rejected", + "text": "프롬프트 색 특정성 A/B (car/sedan/suv/van/pickup truck) — 폴리곤 +26% 인데 마스크 면적은 +0.002~0.068%p 뿐, 신규 마스크의 90~99%가 기존과 겹침." + }, + { + "state": "rejected", + "text": "정사영상으로 차량 검증 — 경사 사진(2025-08-05)과 정사영상(250922)이 다른 비행이라 주차 차량 위치가 다르다." + } + ] +} \ No newline at end of file diff --git a/docs/pilot/sam_merge.html b/docs/pilot/sam_merge.html new file mode 100644 index 0000000..0c905df --- /dev/null +++ b/docs/pilot/sam_merge.html @@ -0,0 +1,296 @@ +SAM 3.1 후처리 — 검출 커버리지 · 집합 병합 + +
+

SAM 3.1 후처리 — 검출 커버리지 · 집합 병합

+

1단계는 미검출을 없애는 것이다. 지표는 '무라벨 화면%' — 마스크 합집합으로 재서 겹침을 뺀 값. 목표 5% 이하, 8장 평균 3.6% 로 달성. 2단계는 집합으로 묶고 외곽선이 gap px 이내로 인접한 폴리곤을 합치는 것.

+

생성 2026-09-01 18:49 · 집계 tune_t64

+

실측

+
사진
8
+
폴리곤
4,586
+
병합 후 폴리곤
2,636 (-43%)
+
라벨 종류
79 / 94
+
집합
12
+
검출 면적 / 화면
182.1%
+
집합 없는 폴리곤
50
+
+

미검출 — 무라벨 화면%

+

마스크 합집합 기준(겹침 제거), 축척 1/4. 목표 5% 이하.

+
+
무라벨 평균
3.6%
+
무라벨 중앙
4.4%
+
최악
5.0%
+
5% 이하 사진
7 / 8
+
+
+ + + + + + + + +
사진폴리곤무라벨%
DJI_20250805165516_06543635.0%
DJI_20250805163523_00069235.0%
DJI_20250805165514_06533774.7%
DJI_20250805163720_00794434.4%
DJI_20250805164932_04846063.0%
DJI_20250805164304_02748732.9%
DJI_20250805164457_03217142.5%
DJI_20250805164917_04752871.5%
+

공정 보기 — 같은 사진, 단계별

+
SAM 검출 4,586 폴리곤(79 라벨)→ 집합 병합 →2,636 폴리곤(14 집합)· 사진 8장 기준
+
+ + + +
+
+ + +

왼쪽 목록에서 라벨을 켜고 끈다. 휠 확대, 드래그 이동. 새 창: 1. SAM 검출 — 확정 설정 · 2. 집합 병합 후 (gap 2px) · 참고: 기준선 검출 (conf 0.25, 4×3)

+

단계

+
M1완료
프롬프트별 검출 집계
tools/prompt_stats.py. YYX_run 실측: 폴리곤 13,580 · 라벨 77종.
+
M1b완료
공정 보기(검출 → 병합) 뷰어 연결
tools/make_viewer.py 로 같은 사진 3장을 단계별로 굽고 모니터에 붙였다. docs/pilot/theater/.
+
M1c완료
무라벨 지표 확립
tools/coverage_stats.py. YYX_run 69장 실측: 무라벨 평균 16.1% · 중앙 15.0% · 최악 52.1%(0654). 덮음 95% 이상은 2장뿐.
+
M1d완료
통짜 패스 원인 규명
wide_v1.txt 의 21개(asphalt road, crop field, farmland, tree canopy 등)는 8192×5460 을 1×1 로 넣어 대상당 인스턴스가 1개만 살아남았다. 0006 에서 asphalt road 가 서쪽 차로만 덮음(1개, score 0.910).
+
M1e완료
--wide-in-tiles 시험
통짜 프롬프트를 타일 패스에도 돌린다. 0654 무라벨 52.1→15.4%, 0006 19.4→12.2%. 비용 140→231초/장 (+65%).
+
M1f완료
경계 띠 프롬프트 추가 (v3)
잔여 무라벨이 논밭 둑·도로 법면·밭 가장자리 잡초였다. overgrown weeds / shrub / grass covered embankment / earth bank / dirt path 5개 추가. 효과 작음 — 0654 15.4→14.9%, 0006 12.2→11.9%.
+
M1g완료
conf 스윕
conf 0.10 으로 한 번 뽑고 점수 문턱을 오프라인으로 쓸었다. 0006: 0.25에서 9.3% → 0.10에서 5.8%. 0654: 13.1% → 9.5%. conf 0.03 은 마스크 폭증으로 37분에 1장도 못 끝내 중단.
+
M1h완료
v4 프롬프트 (익은 논·갓길)
ripening rice field / yellow grass field / harvested field / gravel shoulder 추가. 효과 없음 — 0006 5.8→5.7%, 0654 9.5→9.6%. 프롬프트 추가는 포화.
+
M1i완료
타일 4×3 → 6×4 (목표 달성)
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시간.
+
M1j완료
확정 설정 8장 검증
8장 실측 무라벨(conf 0.10): 0654 5.0 · 0006 5.0 · 0653 4.7 · 0079 4.4 · 0484 3.0 · 0274 2.9 · 0321 2.5 · 0475 1.5 → 평균 3.6%. 같은 8장 기준선은 평균 21.8%. 소요 466~662초/장(평균 548초). 중간에 PC 다운으로 2장 유실, 재개함.
+
M2대기
집합 정의 조정
튜닝 검출 결과(라벨 79종)를 보고 집합을 다시 짠다. 1차는 Claude, 이후 사용자.
+
M3대기
gap 값 조정
외곽선 인접 판정 1~3 px. 파일럿 기준 2 px 로 시작.
+

이슈 · 기각된 시도

+
해결
통짜(1×1) 패스는 대상 하나에 인스턴스가 하나만 남는다 — 도로 한쪽 차로, 논 한 필지만 잡힘. --wide-in-tiles 로 해결(측정치는 M1e).
+
미해결
orange traffic cone / yellow safety cone 은 어느 집합에도 없어 검출 52 폴리곤이 버려진다. [cone] 그룹이 주석 처리돼 있음 — 나란히 놓인 콘이 한 덩어리로 융합된다는 이유.
+
미해결
검출 0 인 프롬프트 8개 (plowed field, rice paddy, vinyl greenhouse tunnel, gabion wall, road embankment slope, riprap slope, water surface, reservoir) — 유지할지 뺄지 미정.
+
기각
프롬프트 색 특정성 A/B (car/sedan/suv/van/pickup truck) — 폴리곤 +26% 인데 마스크 면적은 +0.002~0.068%p 뿐, 신규 마스크의 90~99%가 기존과 겹침.
+
기각
정사영상으로 차량 검증 — 경사 사진(2025-08-05)과 정사영상(250922)이 다른 비행이라 주차 차량 위치가 다르다.
+

집합별 합계

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
집합프롬프트(검출>0/전체)폴리곤폴리곤%면적%병합 후 폴리곤줄어든 비율병합 후 면적%
vegetation6 / 71,33629.1%19.27%77142%19.04%
building17 / 2173216.0%11.27%15779%9.58%
structure4 / 458912.8%0.48%46421%0.61%
ground4 / 44399.6%6.62%25741%7.55%
road6 / 73938.6%18.45%10673%18.76%
fence4 / 53156.9%0.76%2869%1.02%
vehicle12 / 142174.7%0.51%18913%0.70%
stored material8 / 92054.5%0.82%16818%1.14%
farmland7 / 81964.3%38.33%7562%36.63%
slope2 / 4541.2%3.25%540%4.62%
없음2 / 2501.1%0.00%500%0.01%
retaining wall3 / 3420.9%0.17%412%0.23%
water4 / 6180.4%0.08%180%0.11%
+

병합 후 면적%는 병합 후 총검출면적 기준. 겹침을 빼지 않은 다각형 넓이 합이다.

+

카테고리 · 프롬프트별 검출

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
프롬프트카테고리집합폴리곤사진면적%중앙 면적 px점수 중앙
buildingGeneral Categoriesbuilding5075.99%69,0360.567
building rooftopGeneral Categoriesbuilding6251.55%19,6790.176
building facadeGeneral Categoriesbuilding1130.22%71,3900.198
residential houseGeneral Categoriesbuilding0-0.00%00.000
warehouseGeneral Categoriesbuilding530.04%58,3280.135
storage containerGeneral Categoriesbuilding310.02%16,4360.138
factory structureGeneral Categoriesbuilding840.63%148,2680.182
asphalt roadGeneral Categoriesroad7287.11%48,3420.307
concrete pavementGeneral Categoriesroad12986.13%73,5740.165
parking lotGeneral Categoriesroad3263.54%165,5760.412
pedestrian sidewalkGeneral Categoriesroad5680.99%58,7560.410
crosswalkGeneral Categoriesroad9570.57%4,8140.193
bare groundGeneral Categoriesground15681.59%16,7800.147
dirt fieldGeneral Categoriesground7872.69%49,0510.220
grass lawnGeneral Categoriesvegetation3081.61%56,9420.452
tree canopyGeneral Categoriesvegetation7482.08%55,6900.553
dense forestGeneral Categoriesvegetation1777.30%2,051,8200.880
green hedgeGeneral Categoriesvegetation9782.24%41,9630.264
crop fieldFarmlandfarmland57716.71%603,9500.844
farmlandFarmlandfarmland33713.18%92,8050.141
plowed fieldFarmlandfarmland0-0.00%00.000
bare soil fieldFarmlandfarmland3061.32%56,6410.175
rice paddyFarmlandfarmland110.01%95,9060.120
vegetable fieldFarmlandfarmland1760.77%93,2520.316
blue roofRoofs & Structures by Colorbuilding6361.03%18,0580.653
red roofRoofs & Structures by Colorbuilding1520.04%2,6230.168
green roofRoofs & Structures by Colorbuilding1750.31%63,9920.814
gray roofRoofs & Structures by Colorbuilding4770.23%8,5480.203
dark gray roofRoofs & Structures by Colorbuilding4670.23%10,4210.249
black roofRoofs & Structures by Colorbuilding10560.13%3,8300.225
white roofRoofs & Structures by Colorbuilding20980.34%4,6000.246
silver metal roofRoofs & Structures by Colorbuilding6650.37%16,1700.164
orange roofRoofs & Structures by Colorbuilding430.00%5,9930.161
brown roofRoofs & Structures by Colorbuilding1860.14%20,3970.444
yellow roofRoofs & Structures by Colorbuilding310.01%19,5040.168
white vehicleVehicles & Transportation by Colorvehicle7080.20%16,2850.829
black vehicleVehicles & Transportation by Colorvehicle7070.13%6,9480.225
silver vehicleVehicles & Transportation by Colorvehicle1060.02%15,0460.892
gray vehicleVehicles & Transportation by Colorvehicle1160.04%22,7870.822
red vehicleVehicles & Transportation by Colorvehicle1160.01%4,6670.243
blue vehicleVehicles & Transportation by Colorvehicle950.01%3,2530.245
yellow vehicleVehicles & Transportation by Colorvehicle210.00%7,7460.209
orange vehicleVehicles & Transportation by Colorvehicle1550.02%4,6920.230
green vehicleVehicles & Transportation by Colorvehicle520.01%3,6820.330
white cargo truckVehicles & Transportation by Colorvehicle720.04%32,1350.889
blue cargo truckVehicles & Transportation by Colorvehicle220.01%33,1560.821
commercial busVehicles & Transportation by Colorvehicle0-0.00%00.000
tractorVehicles & Transportation by Colorvehicle0-0.00%00.000
excavatorVehicles & Transportation by Colorvehicle540.02%16,7930.653
blue plastic drumIndustrial Materials, Storage & Objects by Colorstored material220.00%1,2930.171
orange plastic barrelIndustrial Materials, Storage & Objects by Colorstored material2330.01%9060.138
yellow plastic containerIndustrial Materials, Storage & Objects by Colorstored material4460.02%1,7980.274
white industrial tankIndustrial Materials, Storage & Objects by Colorstored material2820.01%1,6440.137
metallic storage tankIndustrial Materials, Storage & Objects by Colorstored material0-0.00%00.000
blue waterproof tarpIndustrial Materials, Storage & Objects by Colorstored material5850.17%7,3920.319
green waterproof tarpIndustrial Materials, Storage & Objects by Colorstored material1940.52%12,5320.454
black protective sheetIndustrial Materials, Storage & Objects by Colorstored material1340.03%12,4310.156
white canvas canopyIndustrial Materials, Storage & Objects by Colorstored material1850.06%5,4920.255
plastic greenhouseIndustrial Materials, Storage & Objects by Colorbuilding0-0.00%00.000
vinyl greenhouse tunnelIndustrial Materials, Storage & Objects by Colorbuilding0-0.00%00.000
greenhouse roofIndustrial Materials, Storage & Objects by Colorbuilding0-0.00%00.000
gray metal fenceIndustrial Materials, Storage & Objects by Colorfence1960.05%8,5740.226
green wire fenceIndustrial Materials, Storage & Objects by Colorfence0-0.00%00.000
red roadside barrierIndustrial Materials, Storage & Objects by Colorfence710.00%1,0400.154
yellow safety coneIndustrial Materials, Storage & Objects by Color없음530.00%1,9480.354
orange traffic coneIndustrial Materials, Storage & Objects by Color없음4530.00%2940.123
retaining wallRoad Infrastructureretaining wall210.01%22,4140.288
concrete retaining wallRoad Infrastructureretaining wall3880.15%15,7860.169
gabion wallRoad Infrastructureretaining wall220.01%47,6820.684
guardrailRoad Infrastructurefence2780.08%15,0650.285
metal guardrailRoad Infrastructurefence26280.62%5,7900.179
road embankment slopeRoad Infrastructureslope0-0.00%00.000
cut slopeRoad Infrastructureslope1960.51%62,1660.146
riprap slopeRoad Infrastructureslope0-0.00%00.000
bridgeRoad Infrastructureroad0-0.00%00.000
culvertRoad Infrastructurestructure520.00%1,0270.107
utility poleRoad Infrastructurestructure29880.28%1,9920.212
streetlight poleRoad Infrastructurestructure15880.07%1,6810.242
road signRoad Infrastructurestructure12880.14%2,5310.247
water surfaceWaterwater0-0.00%00.000
pondWaterwater110.00%13,3090.726
reservoirWaterwater0-0.00%00.000
streamWaterwater830.05%28,4980.333
irrigation canalWaterwater420.02%27,3930.304
drainage ditchWaterwater540.01%4,6540.144
overgrown weedsBoundary & Fillervegetation53182.66%4,3210.167
shrubBoundary & Fillervegetation58783.37%6,5840.198
grass covered embankmentBoundary & Fillerslope3562.74%166,6030.422
earth bankBoundary & Fillerground420.00%4,5050.145
dirt pathBoundary & Fillerground20182.33%25,3540.254
ripening rice fieldRipening & Bare Fieldfarmland2056.08%1,491,0180.957
yellow grass fieldRipening & Bare Fieldvegetation0-0.00%00.000
harvested fieldRipening & Bare Fieldfarmland3860.27%20,7050.136
gravel shoulderRipening & Bare Fieldroad960.12%50,5880.179
+

설정

+gap_px2 +epsilon_px1.5 +기준 런output/sam3/tune_t64 (확정 설정 8장) / 기준선은 output/sam3/YYX_run 69장 +프롬프트 파일prompts/discovery_v4.txt (94개). 기준선은 discovery_v1.txt (85개) +집합 정의configs/merge_groups_v4.txt (12집합). 기준선은 merge_groups.txt +타일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, 통짜 분리 +공정 보기 사진확정 설정 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 +
+
+ \ No newline at end of file diff --git a/prompts/discovery_v3.txt b/prompts/discovery_v3.txt new file mode 100644 index 0000000..10a513d --- /dev/null +++ b/prompts/discovery_v3.txt @@ -0,0 +1,128 @@ +# SAM 3.1 Segmentation Prompts - Color & Object Combinations + +# [General Categories] +building +building rooftop +building facade +residential house +warehouse +storage container +factory structure +asphalt road +concrete pavement +parking lot +pedestrian sidewalk +# road lane marking 비활성 — 아래 Road Markings 주석 참고 +crosswalk +bare ground +dirt field +grass lawn +tree canopy +dense forest +green hedge +# cast shadow 비활성 — 병합 그룹이 없어 결과가 버려진다 (74개 검출) + +# [Farmland] +crop field +farmland +plowed field +bare soil field +rice paddy +vegetable field + +# [Roofs & Structures by Color] +blue roof +red roof +green roof +gray roof +dark gray roof +black roof +white roof +silver metal roof +orange roof +brown roof +yellow roof + +# [Vehicles & Transportation by Color] +white vehicle +black vehicle +silver vehicle +gray vehicle +red vehicle +blue vehicle +yellow vehicle +orange vehicle +green vehicle +white cargo truck +blue cargo truck +# yellow school bus 비활성 — commercial bus와 같은 [vehicle] 그룹 내 중복 +commercial bus +tractor +# farm tractor 비활성 — tractor와 같은 [vehicle] 그룹 내 중복 +excavator + +# [Industrial Materials, Storage & Objects by Color] +blue plastic drum +orange plastic barrel +yellow plastic container +white industrial tank +metallic storage tank +blue waterproof tarp +green waterproof tarp +black protective sheet +white canvas canopy +plastic greenhouse +vinyl greenhouse tunnel +greenhouse roof +gray metal fence +green wire fence +red roadside barrier +yellow safety cone +orange traffic cone + +# [Road Markings & Surface Features] +# 전부 비활성. 최종 목표가 지면 대 나머지라 차선은 도로면의 일부지 별도 클래스가 +# 아니다. 검출량은 1위인데(583개 중 277개) 전부 [road] 안에서 버려지고, +# 논밭 이랑을 yellow center dividing line으로 오인하기까지 했다. +# road lane marking 도 같은 이유로 위 General Categories에서 비활성. +# yellow center dividing line +# white solid lane marking +# white dashed lane marking +# white directional arrow marking +# yellow parking stall line +# white parking stall line +# blue handicap parking space +# pink pedestrian safety marking +# green bike lane marking + +# [Road Infrastructure] +retaining wall +concrete retaining wall +gabion wall +guardrail +metal guardrail +road embankment slope +cut slope +riprap slope +bridge +culvert +utility pole +streetlight pole +road sign + +# [Water] +water surface +pond +reservoir +stream +irrigation canal +drainage ditch + +# [Boundary & Filler] +# 0654/0484 잔여 무라벨이 전부 경계 띠였다 — 논밭 사이 둑, 도로 법면·갓길, +# 밭 가장자리 잡초. 타일 패스 85→90개라 배치 청크(6개)가 그대로다 = 추가 비용 없음. +overgrown weeds +shrub +grass covered embankment +earth bank +dirt path diff --git a/prompts/discovery_v4.txt b/prompts/discovery_v4.txt new file mode 100644 index 0000000..6bc223a --- /dev/null +++ b/prompts/discovery_v4.txt @@ -0,0 +1,136 @@ +# SAM 3.1 Segmentation Prompts - Color & Object Combinations + +# [General Categories] +building +building rooftop +building facade +residential house +warehouse +storage container +factory structure +asphalt road +concrete pavement +parking lot +pedestrian sidewalk +# road lane marking 비활성 — 아래 Road Markings 주석 참고 +crosswalk +bare ground +dirt field +grass lawn +tree canopy +dense forest +green hedge +# cast shadow 비활성 — 병합 그룹이 없어 결과가 버려진다 (74개 검출) + +# [Farmland] +crop field +farmland +plowed field +bare soil field +rice paddy +vegetable field + +# [Roofs & Structures by Color] +blue roof +red roof +green roof +gray roof +dark gray roof +black roof +white roof +silver metal roof +orange roof +brown roof +yellow roof + +# [Vehicles & Transportation by Color] +white vehicle +black vehicle +silver vehicle +gray vehicle +red vehicle +blue vehicle +yellow vehicle +orange vehicle +green vehicle +white cargo truck +blue cargo truck +# yellow school bus 비활성 — commercial bus와 같은 [vehicle] 그룹 내 중복 +commercial bus +tractor +# farm tractor 비활성 — tractor와 같은 [vehicle] 그룹 내 중복 +excavator + +# [Industrial Materials, Storage & Objects by Color] +blue plastic drum +orange plastic barrel +yellow plastic container +white industrial tank +metallic storage tank +blue waterproof tarp +green waterproof tarp +black protective sheet +white canvas canopy +plastic greenhouse +vinyl greenhouse tunnel +greenhouse roof +gray metal fence +green wire fence +red roadside barrier +yellow safety cone +orange traffic cone + +# [Road Markings & Surface Features] +# 전부 비활성. 최종 목표가 지면 대 나머지라 차선은 도로면의 일부지 별도 클래스가 +# 아니다. 검출량은 1위인데(583개 중 277개) 전부 [road] 안에서 버려지고, +# 논밭 이랑을 yellow center dividing line으로 오인하기까지 했다. +# road lane marking 도 같은 이유로 위 General Categories에서 비활성. +# yellow center dividing line +# white solid lane marking +# white dashed lane marking +# white directional arrow marking +# yellow parking stall line +# white parking stall line +# blue handicap parking space +# pink pedestrian safety marking +# green bike lane marking + +# [Road Infrastructure] +retaining wall +concrete retaining wall +gabion wall +guardrail +metal guardrail +road embankment slope +cut slope +riprap slope +bridge +culvert +utility pole +streetlight pole +road sign + +# [Water] +water surface +pond +reservoir +stream +irrigation canal +drainage ditch + +# [Boundary & Filler] +# 0654/0484 잔여 무라벨이 전부 경계 띠였다 — 논밭 사이 둑, 도로 법면·갓길, +# 밭 가장자리 잡초. 타일 패스 85→90개라 배치 청크(6개)가 그대로다 = 추가 비용 없음. +overgrown weeds +shrub +grass covered embankment +earth bank +dirt path + +# [Ripening & Bare Field] +# 0654 잔여 무라벨의 최대 덩어리(화면 4.62%)가 누렇게 익은 논 필지였다. +# crop field / farmland / rice paddy 로 안 잡힌다 (rice paddy 는 69장 검출 0). +ripening rice field +yellow grass field +harvested field +gravel shoulder diff --git a/prompts/wide_v2_road_tiled.txt b/prompts/wide_v2_road_tiled.txt new file mode 100644 index 0000000..7c822ed --- /dev/null +++ b/prompts/wide_v2_road_tiled.txt @@ -0,0 +1,24 @@ +# 타일보다 큰 대상 — 통짜(1×1)로 검출한다. +# 여기 적힌 라벨은 타일 패스에서 제외된다. + +building +# asphalt road → 타일 패스로 옮김 (통짜에서 차로 한쪽만 잡힘) +concrete pavement +parking lot +bare ground +dirt field +grass lawn +tree canopy +dense forest +green hedge +crop field +farmland +plowed field +bare soil field +rice paddy +vegetable field +water surface +pond +reservoir +road embankment slope +cut slope diff --git a/tools/coverage_stats.py b/tools/coverage_stats.py new file mode 100644 index 0000000..4aa3bb0 --- /dev/null +++ b/tools/coverage_stats.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python +"""검출이 화면을 얼마나 덮는지 잰다 — 미검출을 찾는 단계의 자다. + +prompt_stats.py 의 면적은 다각형 넓이의 단순 합이라 겹치면 부풀고 100%를 넘는다. +여기서는 마스크를 굽고 합집합을 세므로 "안 잡힌 곳"이 그대로 나온다. + + 무라벨% = 100 - (어떤 라벨이든 덮은 화소 / 전체 화소) + +집합별 합집합도 같이 낸다. 집합끼리는 겹칠 수 있으므로 합이 100을 넘을 수 있다. +전체 해상도로 구우면 느리므로 --scale 로 줄여서 잰다 (기본 1/4). +""" +import argparse +import glob +import json +import os +import time + +import cv2 +import numpy as np + + +def load_groups(path): + mapping, cur = {}, None + for line in open(path, encoding="utf-8"): + s = line.strip() + if s.startswith("#"): + b = s.lstrip("#").strip() + cur = b[1:-1].strip() if b.startswith("[") and b.endswith("]") else None + elif s and cur: + mapping[s] = cur + return mapping + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--run-dir", required=True) + ap.add_argument("--pattern", default="*_multi.json") + ap.add_argument("--groups", required=True) + ap.add_argument("--size", required=True, help="원본 영상 'W,H'") + ap.add_argument("--scale", type=float, default=0.25) + ap.add_argument("--out-json") + args = ap.parse_args() + + W, H = (int(x) for x in args.size.split(",")) + k = args.scale + w, h = int(W * k), int(H * k) + grp = load_groups(args.groups) + files = sorted(glob.glob(os.path.join(args.run_dir, args.pattern))) + if not files: + raise FileNotFoundError(f"{args.pattern} 없음: {args.run_dir}") + + reps = sorted(set(grp.values())) + ["없음"] + per_photo, gsum = [], {g: 0.0 for g in reps} + px = w * h + t0 = time.time() + for i, p in enumerate(files, 1): + with open(p, encoding="utf-8") as fh: + segs = json.load(fh)["segments"] + anym = np.zeros((h, w), np.uint8) + masks = {} + for s in segs: + lb = s["label"] + g = lb if lb in grp.values() else grp.get(lb, "없음") + poly = (np.asarray(s["points"], np.float32) * k).astype(np.int32) + cv2.fillPoly(anym, [poly], 255) + m = masks.get(g) + if m is None: + m = masks[g] = np.zeros((h, w), np.uint8) + cv2.fillPoly(m, [poly], 255) + cov = np.count_nonzero(anym) / px + row = { + "photo": os.path.basename(p), + "segments": len(segs), + "covered": cov, + "groups": {g: np.count_nonzero(m) / px for g, m in masks.items()}, + } + for g, v in row["groups"].items(): + gsum[g] = gsum.get(g, 0.0) + v + per_photo.append(row) + print(f" [{i}/{len(files)}] {row['photo']:<44} 덮음 {100 * cov:5.1f}% " + f"· 무라벨 {100 * (1 - cov):5.1f}% · 폴리곤 {len(segs):,}") + + cov = np.array([r["covered"] for r in per_photo]) + print(f"\n사진 {len(files)}장 · {time.time() - t0:.0f}초 · 척도 1/{1 / k:.0f} " + f"({w}x{h})") + print(f"덮음 평균 {100 * cov.mean():.1f}% 중앙 {100 * np.median(cov):.1f}% " + f"최소 {100 * cov.min():.1f}% 최대 {100 * cov.max():.1f}%") + print(f"무라벨 평균 {100 * (1 - cov.mean()):.1f}% " + f"최악 {100 * (1 - cov.min()):.1f}% ({per_photo[int(cov.argmin())]['photo']})") + + print(f"\n집합별 평균 합집합 화면%") + for g, v in sorted(gsum.items(), key=lambda t: -t[1]): + if v: + print(f" {g:<20}{100 * v / len(files):>7.2f}%") + + print(f"\n무라벨 큰 사진 5장") + for r in sorted(per_photo, key=lambda r: r["covered"])[:5]: + print(f" {r['photo']:<44}{100 * (1 - r['covered']):>6.1f}%") + + if args.out_json: + with open(args.out_json, "w", encoding="utf-8") as fh: + json.dump({ + "run_dir": os.path.abspath(args.run_dir), + "pattern": args.pattern, + "scale": k, + "size": [W, H], + "photos": len(files), + "covered_mean": float(cov.mean()), + "covered_min": float(cov.min()), + "group_mean": {g: v / len(files) for g, v in gsum.items() if v}, + "per_photo": per_photo, + }, fh, ensure_ascii=False, indent=2) + print(f"\n저장: {args.out_json}") + + +if __name__ == "__main__": + main() diff --git a/tools/make_merge_monitor.py b/tools/make_merge_monitor.py new file mode 100644 index 0000000..64b501b --- /dev/null +++ b/tools/make_merge_monitor.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python +"""SAM 후처리 작업 모니터 HTML 을 만든다. + +수치는 전부 prompt_stats.py 의 집계 JSON 에서 온다. 이 파일은 숫자를 만들지 +않는다. 단계·이슈·설정 같은 사람이 쓰는 것만 상태 JSON 에서 읽는다. + + python tools/make_merge_monitor.py \ + --stats output/sam3/YYX_run/_prompt_stats.json \ + --status configs/sam_merge_status.json \ + --out docs/pilot/sam_merge.html +""" +import argparse +import html +import json +import os +import time +from collections import defaultdict + +STATE = { + "done": ("완료", "#1a7f4b", "#e3f5ea"), + "running": ("진행", "#8a5a00", "#fdf0d5"), + "blocked": ("막힘", "#a12a2a", "#fbe6e6"), + "todo": ("대기", "#5a5f66", "#eceef0"), + "open": ("미해결", "#a12a2a", "#fbe6e6"), + "closed": ("해결", "#1a7f4b", "#e3f5ea"), + "rejected": ("기각", "#5a5f66", "#eceef0"), +} + + +def esc(s): + return html.escape(str(s)) + + +def badge(state): + label, fg, bg = STATE.get(state, (state, "#5a5f66", "#eceef0")) + return (f'' + f'{esc(label)}') + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--stats", required=True, help="병합 전 집계 JSON") + ap.add_argument("--status", required=True, help="단계·이슈 JSON") + ap.add_argument("--stats-after", help="병합 후 집계 JSON (--pattern '*_merged.json')") + ap.add_argument("--coverage", help="coverage_stats.py 집계 JSON (무라벨 지표)") + ap.add_argument("--theater", nargs="*", default=[], metavar="제목=경로", + help="공정을 보여줄 뷰어 HTML. 경로는 --out 기준 상대경로") + ap.add_argument("--out", required=True) + args = ap.parse_args() + + with open(args.stats, encoding="utf-8") as fh: + st = json.load(fh) + with open(args.status, encoding="utf-8") as fh: + sv = json.load(fh) + af = None + if args.stats_after: + with open(args.stats_after, encoding="utf-8") as fh: + af = json.load(fh) + cv = None + if args.coverage: + with open(args.coverage, encoding="utf-8") as fh: + cv = json.load(fh) + stages = [] + for t in args.theater: + if "=" not in t: + raise SystemExit(f"--theater 형식은 '제목=경로': {t}") + name, path = t.split("=", 1) + if not os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(args.out)), path)): + raise SystemExit(f"뷰어 없음: {path}") + stages.append((name, path)) + + rows = st["rows"] + gsum = defaultdict(lambda: {"poly": 0, "area": 0.0, "prompts": 0, "used": 0}) + for r in rows: + g = gsum[r["group"]] + g["poly"] += r["polygons"] + g["area"] += r["area_px"] + g["prompts"] += 1 + g["used"] += 1 if r["polygons"] else 0 + tot_area = st["total_area_px"] or 1 + tot_poly = st["total_polygons"] or 1 + + parts = [] + A = parts.append + + A(f'{esc(sv["title"])}') + A("""""") + + A('
') + A(f'

{esc(sv["title"])}

') + A(f'

{esc(sv.get("note", ""))}

') + A(f'

생성 {time.strftime("%Y-%m-%d %H:%M")} · ' + f'집계 {esc(os.path.basename(st["run_dir"]))}

') + + A('

실측

') + cards = [ + ("사진", f'{st["photos"]:,}'), + ("폴리곤", f'{st["total_polygons"]:,}'), + ("라벨 종류", f'{sum(1 for r in rows if r["polygons"]):,} / {len(rows):,}'), + ("집합", f'{sum(1 for g in gsum if g != "없음"):,}'), + ("검출 면적 / 화면", f'{100 * tot_area / st["image_area_px"]:.1f}%' + if st.get("image_area_px") else "-"), + ("집합 없는 폴리곤", + f'{gsum["없음"]["poly"]:,}' if "없음" in gsum else "0"), + ] + if af: + cards.insert(2, ("병합 후 폴리곤", + f'{af["total_polygons"]:,} ' + f'(-{100 * (tot_poly - af["total_polygons"]) / tot_poly:.0f}%)')) + for k, v in cards: + A(f'
{esc(k)}
{esc(v)}
') + A('
') + + if cv: + per = sorted(cv["per_photo"], key=lambda r: r["covered"]) + un = [100 * (1 - r["covered"]) for r in per] + A('

미검출 — 무라벨 화면%

') + A(f'

마스크 합집합 기준(겹침 제거), ' + f'축척 1/{1 / cv["scale"]:.0f}. 목표 5% 이하.

') + A('
') + for k, v in [("무라벨 평균", f'{100 * (1 - cv["covered_mean"]):.1f}%'), + ("무라벨 중앙", f'{sorted(un)[len(un) // 2]:.1f}%'), + ("최악", f'{100 * (1 - cv["covered_min"]):.1f}%'), + ("5% 이하 사진", f'{sum(1 for u in un if u <= 5)} / {len(un)}')]: + A(f'
{esc(k)}
' + f'
{esc(v)}
') + A('
') + A('
' + '') + for r in per: + u = 100 * (1 - r["covered"]) + A(f'' + f'' + f'' + f'') + A('
사진폴리곤무라벨%
{esc(r["photo"].replace("_multi.json", ""))}{r["segments"]:,}{u:.1f}%
5 else "#1a7f4b"}">
') + + if stages: + A('

공정 보기 — 같은 사진, 단계별

') + if af: + A(f'
SAM 검출 {st["total_polygons"]:,} 폴리곤' + f'({sum(1 for r in st["rows"] if r["polygons"])} 라벨)' + f'→ 집합 병합 →' + f'{af["total_polygons"]:,} 폴리곤' + f'({sum(1 for r in af["rows"] if r["polygons"])} 집합)' + f'· 사진 {st["photos"]}장 기준
') + A('
') + for i, (name, _) in enumerate(stages): + A(f'') + A('
') + for i, (_, path) in enumerate(stages): + A(f'
' + f'
') + A('

왼쪽 목록에서 라벨을 켜고 끈다. ' + '휠 확대, 드래그 이동. 새 창: ' + + " · ".join(f'{esc(n)}' for n, p in stages) + '

') + + A('

단계

') + for s in sv.get("steps", []): + A(f'
{esc(s["id"])}{badge(s["state"])}' + f'
{esc(s["name"])}
' + f'
{esc(s.get("detail", ""))}
') + + A('

이슈 · 기각된 시도

') + for i in sv.get("issues", []): + A(f'
{badge(i["state"])}
{esc(i["text"])}
') + + apoly, aarea = defaultdict(int), defaultdict(float) + if af: + for r in af["rows"]: + apoly[r["group"]] += r["polygons"] + aarea[r["group"]] += r["area_px"] + atot = af["total_area_px"] or 1 + + A('

집합별 합계

' + '' + '') + if af: + A('') + A('') + for g, v in sorted(gsum.items(), key=lambda t: -t[1]["poly"]): + share = 100 * v["area"] / tot_area + A(f'' + f'' + f'') + if af: + n = apoly.get(g, 0) + drop = 100 * (v["poly"] - n) / v["poly"] if v["poly"] else 0 + cls = "d-up" if n < v["poly"] else "" + A(f'' + f'') + A(f'') + A('
집합프롬프트(검출>0/전체)폴리곤폴리곤%면적%병합 후 폴리곤줄어든 비율병합 후 면적%
{esc(g)}{v["used"]} / {v["prompts"]}{v["poly"]:,}{100 * v["poly"] / tot_poly:.1f}%{share:.2f}%{n:,}{drop:.0f}%{100 * aarea.get(g, 0.0) / atot:.2f}%
' + f'
') + if af: + A('

병합 후 면적%는 병합 후 총검출면적 기준. ' + '겹침을 빼지 않은 다각형 넓이 합이다.

') + + cats = sorted({r["category"] for r in rows}) + groups = sorted({r["group"] for r in rows}) + A('

카테고리 · 프롬프트별 검출

') + A('
' + '' + '' + '' + '' + '
') + A('
' + '' + '') + for r in rows: + cls = [] + if not r["polygons"]: + cls.append("zero") + if r["group"] == "없음": + cls.append("nogroup") + c = f' class="{" ".join(cls)}"' if cls else "" + dash = "-" + A(f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'') + A('
프롬프트카테고리집합폴리곤사진면적%중앙 면적 px점수 중앙
{esc(r["prompt"])}{esc(r["category"])}{esc(r["group"])}{r["polygons"]:,}{r["photos"] or dash}' + f'{100 * r["area_share"]:.2f}%' + f'{r["area_median_px"]:,.0f}' + f'{r["score_median"]:.3f}
') + + if sv.get("params"): + A('

설정

') + for k, v in sv["params"].items(): + A(f'{esc(k)}{esc(v)}') + A('
') + + A('
') + A("""""") + + os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) + with open(args.out, "w", encoding="utf-8") as fh: + fh.write("\n".join(parts)) + print(f"저장: {args.out} ({os.path.getsize(args.out):,} bytes)") + + +if __name__ == "__main__": + main() diff --git a/tools/prompt_stats.py b/tools/prompt_stats.py new file mode 100644 index 0000000..9030339 --- /dev/null +++ b/tools/prompt_stats.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python +"""SAM 검출 원본(_multi.json)을 프롬프트별로 집계한다. 병합 전 상태를 본다. + +집합(병합 그룹)을 다시 짜려면 먼저 각 프롬프트가 실제로 무엇을 얼마나 +집어냈는지 봐야 한다. 이 도구는 판정하지 않는다 — 센다. + +프롬프트 파일의 `# [카테고리]` 머리글로 프롬프트를 묶고, 병합 그룹 파일에서 +그 프롬프트가 지금 어느 집합에 들어가는지 붙인다. 어느 집합에도 없으면 +'없음'으로 나온다 — 그 검출은 지금 버려지고 있다는 뜻이다. + +면적은 다각형 넓이(shoelace)다. 겹침을 빼지 않으므로 합이 화면보다 클 수 있다. +""" +import argparse +import glob +import json +import os +from collections import OrderedDict, defaultdict + +import numpy as np + + +def load_categories(path): + """프롬프트 파일 -> {프롬프트: 카테고리}, 그리고 비활성(주석) 프롬프트 집합.""" + cat, off, cur = OrderedDict(), OrderedDict(), None + for line in open(path, encoding="utf-8"): + s = line.strip() + if not s: + continue + if s.startswith("#"): + body = s.lstrip("#").strip() + if body.startswith("[") and body.endswith("]"): + cur = body[1:-1].strip() + continue + # '# white roof 비활성 — 이유' 형태의 꺼둔 프롬프트. + # 한글이 섞였으면 프롬프트가 아니라 설명문이다. + name = body.split("비활성")[0].strip().rstrip("—").strip() + if (cur and name and name[0].islower() and len(name.split()) <= 5 + and not any("가" <= ch <= "힣" for ch in name)): + off[name] = cur + continue + if cur: + cat[s] = cur + return cat, off + + +def load_groups(path): + """병합 그룹 파일 -> {라벨: 대표}. 주석 처리된 그룹은 비활성이므로 뺀다.""" + mapping, cur = OrderedDict(), None + for line in open(path, encoding="utf-8"): + s = line.strip() + if not s: + continue + if s.startswith("#"): + body = s.lstrip("#").strip() + cur = body[1:-1].strip() if body.startswith("[") and body.endswith("]") else None + continue + if cur: + mapping[s] = cur + return mapping + + +def poly_area(pts): + a = np.asarray(pts, dtype=np.float64).reshape(-1, 2) + if len(a) < 3: + return 0.0 + x, y = a[:, 0], a[:, 1] + return float(abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) / 2) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--run-dir", required=True, help="검출 JSON 폴더") + ap.add_argument("--pattern", default="*_multi.json", + help="병합 후를 보려면 '*_multi_merged.json'") + ap.add_argument("--prompts", required=True, help="프롬프트 목록 txt") + ap.add_argument("--groups", required=True, help="병합 그룹 txt") + ap.add_argument("--image-size", help="'W,H' — 주면 화면 대비 면적 비율을 낸다") + ap.add_argument("--out-json", help="집계 결과 저장 경로") + args = ap.parse_args() + + cat, off = load_categories(args.prompts) + grp = load_groups(args.groups) + reps = set(grp.values()) + files = sorted(glob.glob(os.path.join(args.run_dir, args.pattern))) + if not files: + raise FileNotFoundError(f"{args.pattern} 없음: {args.run_dir}") + + npoly = defaultdict(int) + nphoto = defaultdict(int) + area = defaultdict(float) + scores = defaultdict(list) + areas = defaultdict(list) + for p in files: + with open(p, encoding="utf-8") as fh: + segs = json.load(fh)["segments"] + here = set() + for s in segs: + lb = s["label"] + a = poly_area(s["points"]) + npoly[lb] += 1 + area[lb] += a + areas[lb].append(a) + scores[lb].append(float(s.get("score", 0))) + here.add(lb) + for lb in here: + nphoto[lb] += 1 + + img_area = None + if args.image_size: + w, h = (float(x) for x in args.image_size.split(",")) + img_area = w * h * len(files) + + total_poly = sum(npoly.values()) + total_area = sum(area.values()) + print(f"사진 {len(files)} · 폴리곤 {total_poly:,} · 라벨 {len(npoly)}종") + if img_area: + print(f"검출 면적 합 {total_area:,.0f} px = 전체 화면의 {100 * total_area / img_area:.1f}% " + f"(겹침 미보정)") + + seen = set(npoly) + rows = [] + order = list(dict.fromkeys(list(cat) + sorted(seen - set(cat)))) + cur_cat = None + print(f"\n{'프롬프트':<28}{'집합':<17}{'폴리곤':>8}{'사진':>7}" + f"{'면적%':>8}{'중앙면적px':>11}{'점수중앙':>9}") + for lb in order: + c = cat.get(lb) or ("(병합 결과)" if lb in reps else "(프롬프트 파일 밖)") + if c != cur_cat: + print(f"-- [{c}]") + cur_cat = c + n = npoly.get(lb, 0) + # 병합 후 파일은 라벨이 곧 집합 대표 이름이다. + g = lb if lb in reps else grp.get(lb, "없음") + row = { + "prompt": lb, "category": c, "group": g, "polygons": n, + "photos": nphoto.get(lb, 0), + "area_px": area.get(lb, 0.0), + "area_share": (area.get(lb, 0.0) / total_area) if total_area else 0.0, + "area_median_px": float(np.median(areas[lb])) if n else 0.0, + "score_median": float(np.median(scores[lb])) if n else 0.0, + } + rows.append(row) + if n == 0: + print(f"{lb:<28}{g:<17}{'0':>8}{'-':>7}{'-':>8}{'-':>11}{'-':>9}") + continue + print(f"{lb:<28}{g:<17}{n:>8,}{row['photos']:>7}" + f"{100 * row['area_share']:>7.2f}%{row['area_median_px']:>11,.0f}" + f"{row['score_median']:>9.3f}") + + if off: + print(f"\n비활성 프롬프트 {len(off)}개: {', '.join(off)}") + + nogroup = [r for r in rows if r["group"] == "없음" and r["polygons"]] + if nogroup: + s = sum(r["polygons"] for r in nogroup) + print(f"\n집합 없는 검출 {s:,} 폴리곤 " + f"({100 * s / total_poly:.1f}%) — 지금 버려진다") + for r in sorted(nogroup, key=lambda r: -r["polygons"]): + print(f" {r['prompt']:<28}{r['polygons']:>8,}") + + print(f"\n집합별 합계") + gsum = defaultdict(lambda: [0, 0.0, 0]) + for r in rows: + v = gsum[r["group"]] + v[0] += r["polygons"] + v[1] += r["area_px"] + v[2] += 1 if r["polygons"] else 0 + print(f"{'집합':<20}{'프롬프트(검출>0)':>16}{'폴리곤':>10}{'면적%':>9}") + for g, (n, a, k) in sorted(gsum.items(), key=lambda t: -t[1][0]): + print(f"{g:<20}{k:>16}{n:>10,}{100 * a / total_area if total_area else 0:>8.2f}%") + + if args.out_json: + with open(args.out_json, "w", encoding="utf-8") as fh: + json.dump({ + "run_dir": os.path.abspath(args.run_dir), + "prompts_file": os.path.abspath(args.prompts), + "groups_file": os.path.abspath(args.groups), + "photos": len(files), + "total_polygons": total_poly, + "total_area_px": total_area, + "image_area_px": img_area, + "rows": rows, + }, fh, ensure_ascii=False, indent=2) + print(f"\n저장: {args.out_json}") + + +if __name__ == "__main__": + main() diff --git a/tools/sam3_multi_prompt.py b/tools/sam3_multi_prompt.py index 90a0972..57f4063 100644 --- a/tools/sam3_multi_prompt.py +++ b/tools/sam3_multi_prompt.py @@ -151,7 +151,7 @@ def predict_tile(model, processor, find_stage_cls, tile_bgr, text_outs, def run_pass(model, processor, find_stage_cls, image_bgr, boxes, captions, - conf, batch, tag): + conf, batch, tag, mask_bytes=6 * 10**8): """타일 목록 전체에 프롬프트 집합을 돌린다. 반환: 전역 좌표 shape 리스트.""" chunks = [captions[i:i + batch] for i in range(0, len(captions), batch)] shapes, t0 = [], time.time() @@ -160,7 +160,8 @@ def run_pass(model, processor, find_stage_cls, image_bgr, boxes, captions, for c in chunks] for i, (x0, y0, x1, y1) in enumerate(boxes, 1): got = predict_tile(model, processor, find_stage_cls, - image_bgr[y0:y1, x0:x1], text_outs, chunks, conf) + image_bgr[y0:y1, x0:x1], text_outs, chunks, conf, + mask_bytes) for s in got: # 전역 좌표로 이동 s["points"] = [[p[0] + x0, p[1] + y0] for p in s["points"]] shapes.extend(got) @@ -174,20 +175,29 @@ def run_pass(model, processor, find_stage_cls, image_bgr, boxes, captions, def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument("--input", required=True) - ap.add_argument("--output", default=None, help="기본: 입력명_multi.jpg") + ap.add_argument("--input", default=None) + ap.add_argument("--output", default=None, help="단일 이미지용 출력 경로") + ap.add_argument("--outdir", default=None, + help="결과 폴더. --input 이 폴더면 필수 — <이름>_multi.jpg/.json 로 저장") ap.add_argument("--prompts", default="prompts/discovery_v1.txt") ap.add_argument("--wide-prompts", default=None, help="타일보다 큰 대상 목록. 여기 적힌 라벨은 타일 패스에서 빼고 " "이미지 전체를 한 장으로 검출한다 (예: prompts/wide_v1.txt)") + ap.add_argument("--wide-in-tiles", action="store_true", + help="통짜 프롬프트를 타일 패스에서도 돌린다. 통짜는 대상 하나에 " + "인스턴스가 하나만 살아남아 도로 한쪽 차로·논 한 필지만 " + "잡히는 일이 있다. 겹치는 결과는 NMS 가 지운다. 느려진다") ap.add_argument("--cols", type=int, default=9) ap.add_argument("--rows", type=int, default=6) ap.add_argument("--overlap", type=float, default=0.10) ap.add_argument("--conf", type=float, default=0.25) ap.add_argument("--nms", type=float, default=0.40) - ap.add_argument("--batch", type=int, default=16, - help="forward 1회에 넣을 최대 프롬프트 수 (기본 16). " - "RTX 3060 12GB 기준 16이 최적 — 32 이상은 VRAM 압박으로 4배 이상 느려짐") + ap.add_argument("--device", default="cuda:0", + help="사용할 GPU (예: cuda:0, cuda:1). --list-gpus 로 확인") + ap.add_argument("--list-gpus", action="store_true", help="GPU 목록만 출력하고 종료") + ap.add_argument("--batch", type=int, default=0, + help="forward 1회에 넣을 최대 프롬프트 수. 0이면 VRAM에서 자동 결정 " + "(12GB→16, 24GB→32). 3060 12GB에서 32는 4배 이상 느려짐") ap.add_argument("--merge", action="store_true", help="같은 라벨 인접 폴리곤 병합") ap.add_argument("--merge-gap", type=int, default=8) ap.add_argument("--checkpoint", default=None, @@ -198,44 +208,67 @@ def main(): help="VRAM 사용 상한 비율. 넘으면 느려지는 대신 OOM 에러 (기본 0.92)") args = ap.parse_args() + if not torch.cuda.is_available(): + raise SystemExit("CUDA 사용 불가. CPU 폴백하지 않는다 — GPU 환경을 확인하라.") + if args.list_gpus: + for i in range(torch.cuda.device_count()): + p = torch.cuda.get_device_properties(i) + print(f" cuda:{i} {p.name} {p.total_memory/2**30:.0f}GB") + return + if not args.input: + raise SystemExit("--input 필요") + idx = torch.device(args.device).index or 0 + if idx >= torch.cuda.device_count(): + raise SystemExit(f"{args.device} 없음 — 장착 GPU {torch.cuda.device_count()}개. " + f"--list-gpus 로 확인하라.") + torch.cuda.set_device(idx) + # model_builder 가 device == "cuda" 문자열을 정확 비교하므로 "cuda:N" 을 주면 + # 모델이 CPU에 남는다. 장치 선택은 set_device 로 하고 문자열은 "cuda" 고정. + device = "cuda" + vram_gb = torch.cuda.get_device_properties(idx).total_memory / 2**30 + if args.batch <= 0: # VRAM에 맞춰 자동 결정 + args.batch = 32 if vram_gb >= 20 else 16 + print(f"GPU cuda:{idx} {torch.cuda.get_device_name(idx)} " + f"{vram_gb:.0f}GB batch={args.batch}") + captions = load_prompts(Path(args.prompts)) if not captions: print(f"프롬프트 없음: {args.prompts}") return - img_path = Path(args.input) - image_bgr = cv2.imdecode(np.fromfile(str(img_path), dtype=np.uint8), - cv2.IMREAD_COLOR) - if image_bgr is None: - print(f"이미지 로드 실패: {img_path}") - return - H, W = image_bgr.shape[:2] + src = Path(args.input) + if src.is_dir(): + images = sorted(p for p in src.iterdir() + if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".tif")) + if not images: + raise SystemExit(f"이미지 없음: {src}") + if not args.outdir: + raise SystemExit("폴더 입력에는 --outdir 필요") + else: + images = [src] wide = load_prompts(Path(args.wide_prompts)) if args.wide_prompts else [] - fine = [c for c in captions if c not in set(wide)] - boxes = tile_boxes(W, H, args.cols, args.rows, args.overlap) + fine = captions if args.wide_in_tiles else [c for c in captions if c not in set(wide)] def nchunk(n): return (n + args.batch - 1) // args.batch - print(f"이미지 : {W}×{H}") - print(f"타일 : {args.cols}×{args.rows}={len(boxes)}개 overlap={args.overlap*100:.0f}%") - print(f"타일 패스 : 프롬프트 {len(fine)}개 → forward {nchunk(len(fine))*len(boxes)}회") + print(f"입력 : {len(images)}장") + print(f"타일 : {args.cols}×{args.rows}={args.cols*args.rows}개 " + f"overlap={args.overlap*100:.0f}%") + print(f"타일 패스 : 프롬프트 {len(fine)}개 → forward " + f"{nchunk(len(fine))*args.cols*args.rows}회/장") if wide: - print(f"통짜 패스 : 프롬프트 {len(wide)}개 → forward {nchunk(len(wide))}회 " - f"(타일보다 큰 대상)") + print(f"통짜 패스 : 프롬프트 {len(wide)}개 → forward {nchunk(len(wide))}회/장") print(f"conf={args.conf} nms={args.nms}\n") from sam3.model_builder import build_sam3_image_model from sam3.model.sam3_image_processor import Sam3Processor from sam3.model.data_misc import FindStage - if not torch.cuda.is_available(): - raise SystemExit("CUDA 사용 불가. CPU 폴백하지 않는다 — GPU 환경을 확인하라.") - device = "cuda" # VRAM 상한을 걸어 드라이버가 시스템 메모리로 폴백(10배 이상 느려짐)하기 전에 # OOM으로 실패하게 만든다 - torch.cuda.set_per_process_memory_fraction(args.vram_fraction) + torch.cuda.set_per_process_memory_fraction(args.vram_fraction, idx) torch.backends.cuda.matmul.allow_tf32 = True torch.backends.cudnn.allow_tf32 = True @@ -246,47 +279,62 @@ def main(): bpe_path=bpe_path, device=device, checkpoint_path=ckpt_path) processor = Sam3Processor(model, confidence_threshold=args.conf, device=device) - t0 = time.time() - all_shapes = run_pass(model, processor, FindStage, image_bgr, boxes, - fine, args.conf, args.batch, "타일") - if wide: - # 타일보다 큰 대상은 이미지 전체를 한 장으로 보고 검출 - all_shapes += run_pass(model, processor, FindStage, image_bgr, - [(0, 0, W, H)], wide, args.conf, args.batch, "통짜") + mask_bytes = int(6 * 10**8 * vram_gb / 12) # VRAM에 비례해 업샘플 청크 조정 + t_all = time.time() + for n, img_path in enumerate(images, 1): + print(f"\n===== [{n}/{len(images)}] {img_path.name} =====") + image_bgr = cv2.imdecode(np.fromfile(str(img_path), dtype=np.uint8), + cv2.IMREAD_COLOR) + if image_bgr is None: + raise SystemExit(f"이미지 로드 실패: {img_path}") + H, W = image_bgr.shape[:2] + boxes = tile_boxes(W, H, args.cols, args.rows, args.overlap) - print(f"\n검출 {len(all_shapes)}개 → NMS(iou={args.nms})...") - all_shapes = nms_shapes(all_shapes, iou_thresh=args.nms) - print(f"NMS 후 {len(all_shapes)}개") + t0 = time.time() + all_shapes = run_pass(model, processor, FindStage, image_bgr, boxes, + fine, args.conf, args.batch, "타일", mask_bytes) + if wide: + # 타일보다 큰 대상은 이미지 전체를 한 장으로 보고 검출 + all_shapes += run_pass(model, processor, FindStage, image_bgr, + [(0, 0, W, H)], wide, args.conf, args.batch, + "통짜", mask_bytes) - if args.merge: - before = len(all_shapes) - all_shapes = merge_adjacent(all_shapes, gap=args.merge_gap) - print(f"병합(gap={args.merge_gap}px) {before} → {len(all_shapes)}개") - print(f"총 {time.time()-t0:.0f}초\n") + print(f" 검출 {len(all_shapes)}개 → NMS(iou={args.nms})...") + all_shapes = nms_shapes(all_shapes, iou_thresh=args.nms) + print(f" NMS 후 {len(all_shapes)}개") + if args.merge: + before = len(all_shapes) + all_shapes = merge_adjacent(all_shapes, gap=args.merge_gap) + print(f" 병합(gap={args.merge_gap}px) {before} → {len(all_shapes)}개") + print(f" {time.time()-t0:.0f}초") - analyze_labels(all_shapes) + vis = draw_everything(image_bgr, all_shapes, args.cols, args.rows) + h, w = vis.shape[:2] + if max(h, w) > 4096: + s = 4096 / max(h, w) + vis = cv2.resize(vis, (int(w * s), int(h * s))) - vis = draw_everything(image_bgr, all_shapes, args.cols, args.rows) - h, w = vis.shape[:2] - if max(h, w) > 4096: - s = 4096 / max(h, w) - vis = cv2.resize(vis, (int(w * s), int(h * s))) + if args.outdir: + out_path = Path(args.outdir) / (img_path.stem + "_multi.jpg") + elif args.output: + out_path = Path(args.output) + else: + out_path = img_path.parent / (img_path.stem + "_multi.jpg") + out_path.parent.mkdir(parents=True, exist_ok=True) + cv2.imencode(".jpg", vis, [cv2.IMWRITE_JPEG_QUALITY, 93])[1].tofile(str(out_path)) - out_path = (Path(args.output) if args.output - else img_path.parent / (img_path.stem + "_multi.jpg")) - out_path.parent.mkdir(parents=True, exist_ok=True) - cv2.imencode(".jpg", vis, [cv2.IMWRITE_JPEG_QUALITY, 93])[1].tofile(str(out_path)) - print(f"\n저장: {out_path}") + json_path = out_path.with_suffix(".json") + json_path.write_text(json.dumps({ + "source_image": str(img_path), + "total_segments": len(all_shapes), + "label_counts": dict(Counter(s.get("label", "") for s in all_shapes)), + "segments": [{"label": s.get("label", ""), "score": s.get("score", 0), + "bbox": list(_bbox(s["points"])), "points": s["points"]} + for s in all_shapes], + }, ensure_ascii=False, indent=2), encoding="utf-8") + print(f" 저장: {out_path.name} / {json_path.name}") - json_path = out_path.with_suffix(".json") - json_path.write_text(json.dumps({ - "total_segments": len(all_shapes), - "label_counts": dict(Counter(s.get("label", "") for s in all_shapes)), - "segments": [{"label": s.get("label", ""), "score": s.get("score", 0), - "bbox": list(_bbox(s["points"])), "points": s["points"]} - for s in all_shapes], - }, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"라벨 데이터: {json_path}") + print(f"\n전체 {len(images)}장 완료 — {time.time()-t_all:.0f}초") if sys.platform == "win32": # 완료 알림음 import winsound diff --git a/tools/vote_labels.py b/tools/vote_labels.py new file mode 100644 index 0000000..08833d7 --- /dev/null +++ b/tools/vote_labels.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python +"""여러 사진의 SAM 마스크를 3D 면에 투표해 면별 라벨을 정한다. + +면 하나가 여러 사진에 보인다 (가림을 뺀 실제 가시 뷰가 중앙 8장). 각 사진에서 +그 면의 중심 픽셀에 어떤 라벨이 있는지 읽어 모으고 다수결한다. + +득표가 갈리거나 뷰가 모자란 면은 강제로 정하지 않고 0(미결정)으로 남긴다. +틀린 라벨보다 없는 라벨이 낫다 — 학습 데이터에서 0은 ignore_index로 빠진다. + +폴리곤을 라벨맵으로 구울 때 순서가 중요하다. 면적 큰 것부터 그려서 작은 것이 +위에 오게 한다. 안 그러면 도로 폴리곤이 그 위의 차량을 덮어버린다. + +한 면이 보이는데 어느 폴리곤에도 안 걸리는 경우가 있다 (SAM 커버리지가 +장당 87%). 그건 기권으로 세지 어느 클래스 표도 아니다. + +작은 것은 이 다수결에서 진다. 도로/건물 폴리곤이 크니 차량 위를 스치는 뷰가 +몇 장만 있어도 득표율이 0.5 아래로 내려간다. 그래서 --rescue-pred 를 주면 +"투표에는 졌지만 그 클래스 표가 있고 학습된 모델도 같은 클래스라고 하는 면"을 +되돌린다. 두 증거가 독립이므로 정밀도를 거의 안 깎고 재현을 올린다. +""" +import argparse +import glob +import json +import os +import time +from collections import OrderedDict + +import cv2 +import numpy as np + + +def log(msg): + print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) + + +def poly_area(pts): + x, y = pts[:, 0], pts[:, 1] + return abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) / 2 + + +def build_label_map(seg_path, W, H, cls_id): + """병합 JSON -> 라벨맵 (int16, 0=없음). 면적 내림차순으로 그린다.""" + with open(seg_path, encoding="utf-8") as fh: + segs = json.load(fh)["segments"] + items = [] + for s in segs: + pts = np.asarray(s["points"], dtype=np.float64).reshape(-1, 2) + if len(pts) < 3: + continue + lab = s["label"] + if lab not in cls_id: + continue + items.append((poly_area(pts), cls_id[lab], pts)) + items.sort(key=lambda t: -t[0]) + lm = np.zeros((H, W), dtype=np.int16) + for _, cid, pts in items: + cv2.fillPoly(lm, [np.round(pts).astype(np.int32)], int(cid)) + return lm, len(items) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--vis-dir", required=True, help="rasterize_visibility.py 출력") + ap.add_argument("--seg-dir", required=True, help="*_merged.json 폴더") + ap.add_argument("--out-dir", required=True) + ap.add_argument("--min-votes", type=int, default=2, + help="이보다 표가 적으면 미결정") + ap.add_argument("--min-ratio", type=float, default=0.5, + help="1위 득표율이 이보다 낮으면 미결정") + ap.add_argument("--rescue-pred", + help="PointVector 예측 npy (면 순서 동일). 주면 구제 규칙을 켠다") + ap.add_argument("--rescue-pred-classes", + help="--rescue-pred 의 클래스 목록 JSON (classes 키 또는 배열)") + ap.add_argument("--rescue-class", default="vehicle", help="구제할 클래스 이름") + ap.add_argument("--rescue-min-votes", type=int, default=1, + help="구제하려면 그 클래스 표가 최소 몇 개여야 하는지") + args = ap.parse_args() + if args.rescue_pred and not args.rescue_pred_classes: + ap.error("--rescue-pred 를 주면 --rescue-pred-classes 도 필요하다") + + os.makedirs(args.out_dir, exist_ok=True) + with open(os.path.join(args.vis_dir, "visibility.json"), encoding="utf-8") as fh: + vmeta = json.load(fh) + n_face = vmeta["tile_faces"] + W, H = vmeta["image_size"] + names = [r["image"] for r in vmeta["per_camera"]] + + vis = np.unpackbits(np.load(os.path.join(args.vis_dir, "visible.npy")), + axis=1, count=n_face).astype(bool) + pu = np.load(os.path.join(args.vis_dir, "pix_u.npy")) + pv = np.load(os.path.join(args.vis_dir, "pix_v.npy")) + log(f"면 {n_face:,} · 카메라 {len(names)} · 영상 {W}x{H}") + + # 클래스 목록은 실제로 등장한 라벨에서 만든다. 지어내지 않는다. + labels = OrderedDict() + seg_files = {} + for n in names: + p = os.path.join(args.seg_dir, os.path.splitext(n)[0] + "_multi_merged.json") + if not os.path.isfile(p): + raise FileNotFoundError(f"병합 JSON 없음: {p}") + seg_files[n] = p + with open(p, encoding="utf-8") as fh: + for s in json.load(fh)["segments"]: + labels.setdefault(s["label"], 0) + labels[s["label"]] += 1 + cls_names = ["미결정"] + sorted(labels, key=lambda k: -labels[k]) + cls_id = {c: i for i, c in enumerate(cls_names) if i > 0} + n_cls = len(cls_names) + log(f"클래스 {n_cls} (0=미결정 포함): {', '.join(cls_names[1:])}") + + votes = np.zeros((n_face, n_cls), dtype=np.int16) # 0열은 안 쓴다 + seen = np.zeros(n_face, dtype=np.int16) + t0 = time.time() + for i, n in enumerate(names): + lm, npoly = build_label_map(seg_files[n], W, H, cls_id) + m = vis[i] + idx = np.flatnonzero(m) + lab = lm[pv[i][idx], pu[i][idx]] + seen[idx] += 1 + hit = lab > 0 + np.add.at(votes, (idx[hit], lab[hit]), 1) + log(f" [{i + 1}/{len(names)}] {n} 폴리곤 {npoly} · 가시면 {len(idx):,} " + f"· 라벨 적중 {int(hit.sum()):,} ({hit.mean():.1%})") + del lm + + votes[:, 0] = 0 + total = votes.sum(axis=1) + best = votes.argmax(axis=1).astype(np.int16) + top = votes.max(axis=1) + with np.errstate(divide="ignore", invalid="ignore"): + ratio = np.where(total > 0, top / np.maximum(total, 1), 0.0) + + pred = np.where((total >= args.min_votes) & (ratio >= args.min_ratio), + best, 0).astype(np.int16) + + rescue = None + if args.rescue_pred: + if args.rescue_class not in cls_id: + raise ValueError(f"투표 클래스에 없다: {args.rescue_class}") + with open(args.rescue_pred_classes, encoding="utf-8") as fh: + j = json.load(fh) + pcls = j["classes"] if isinstance(j, dict) else j + if args.rescue_class not in pcls: + raise ValueError(f"예측 클래스에 없다: {args.rescue_class}") + mp = np.load(args.rescue_pred) + if len(mp) != n_face: + raise ValueError(f"예측 {len(mp):,} != 면 {n_face:,}") + rid = cls_id[args.rescue_class] + take = ((pred != rid) + & (votes[:, rid] >= args.rescue_min_votes) + & (mp == pcls.index(args.rescue_class))) + was = pred[take].copy() + pred[take] = rid + u, c = np.unique(was, return_counts=True) + rescue = {cls_names[k]: int(v) for k, v in sorted(zip(u, c), key=lambda t: -t[1])} + log("") + log(f"구제({args.rescue_class}, 표>={args.rescue_min_votes} & 모델 동의): " + f"{int(take.sum()):,}면") + for k, v in rescue.items(): + log(f" {k} 에서 {v:,}") + + log("") + log(f"투표 {time.time() - t0:.0f}초") + log(f"가시 뷰 0인 면 {int((seen == 0).sum()):,} ({(seen == 0).mean():.2%})") + log(f"보이지만 라벨 표가 0인 면 {int(((seen > 0) & (total == 0)).sum()):,} " + f"({((seen > 0) & (total == 0)).mean():.2%})") + log(f"표는 있으나 기준 미달 {int(((total > 0) & (pred == 0)).sum()):,} " + f"({((total > 0) & (pred == 0)).mean():.2%})") + log("") + cnt = np.bincount(pred, minlength=n_cls) + log(f"{'id':>3} {'클래스':<20} {'면':>10} {'비율':>7} {'평균득표율':>9}") + for c in np.argsort(-cnt): + if cnt[c] == 0: + continue + r = ratio[pred == c].mean() if c > 0 and cnt[c] else float("nan") + log(f"{c:>3} {cls_names[c]:<20} {cnt[c]:>10,} {cnt[c] / n_face:>6.2%} " + f"{'' if c == 0 else f'{r:>8.1%}'}") + + np.save(os.path.join(args.out_dir, "face_label.npy"), pred) + np.save(os.path.join(args.out_dir, "face_votes.npy"), votes) + with open(os.path.join(args.out_dir, "vote.json"), "w", encoding="utf-8") as fh: + json.dump({ + "vis_dir": os.path.abspath(args.vis_dir), + "seg_dir": os.path.abspath(args.seg_dir), + "faces": int(n_face), + "cameras": len(names), + "classes": cls_names, + "min_votes": args.min_votes, + "min_ratio": args.min_ratio, + "faces_no_view": int((seen == 0).sum()), + "faces_no_label_vote": int(((seen > 0) & (total == 0)).sum()), + "faces_below_threshold": int(((total > 0) & (pred == 0)).sum()), + "counts": {cls_names[c]: int(cnt[c]) for c in range(n_cls) if cnt[c]}, + "rescue": None if rescue is None else { + "pred": os.path.abspath(args.rescue_pred), + "cls": args.rescue_class, + "min_votes": args.rescue_min_votes, + "faces": int(sum(rescue.values())), + "from": rescue, + }, + "note": "face_label.npy는 타일 면 인덱스 순서. 0 = 미결정 = ignore_index", + }, fh, indent=2, ensure_ascii=False) + log(f"기록: {args.out_dir}") + + +if __name__ == "__main__": + main()