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).
잔여 무라벨이 논밭 둑·도로 법면·밭 가장자리 잡초였다. 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/전체)
폴리곤
폴리곤%
면적%
+
병합 후 폴리곤
줄어든 비율
병합 후 면적%
+
+
vegetation
6 / 7
1,336
29.1%
19.27%
+
771
42%
19.04%
+
+
building
17 / 21
732
16.0%
11.27%
+
157
79%
9.58%
+
+
structure
4 / 4
589
12.8%
0.48%
+
464
21%
0.61%
+
+
ground
4 / 4
439
9.6%
6.62%
+
257
41%
7.55%
+
+
road
6 / 7
393
8.6%
18.45%
+
106
73%
18.76%
+
+
fence
4 / 5
315
6.9%
0.76%
+
286
9%
1.02%
+
+
vehicle
12 / 14
217
4.7%
0.51%
+
189
13%
0.70%
+
+
stored material
8 / 9
205
4.5%
0.82%
+
168
18%
1.14%
+
+
farmland
7 / 8
196
4.3%
38.33%
+
75
62%
36.63%
+
+
slope
2 / 4
54
1.2%
3.25%
+
54
0%
4.62%
+
+
없음
2 / 2
50
1.1%
0.00%
+
50
0%
0.01%
+
+
retaining wall
3 / 3
42
0.9%
0.17%
+
41
2%
0.23%
+
+
water
4 / 6
18
0.4%
0.08%
+
18
0%
0.11%
+
+
+
병합 후 면적%는 병합 후 총검출면적 기준. 겹침을 빼지 않은 다각형 넓이 합이다.
+
카테고리 · 프롬프트별 검출
+
+
프롬프트
카테고리
집합
폴리곤
사진
면적%
중앙 면적 px
점수 중앙
+
building
General Categories
building
50
7
5.99%
69,036
0.567
+
building rooftop
General Categories
building
62
5
1.55%
19,679
0.176
+
building facade
General Categories
building
11
3
0.22%
71,390
0.198
+
residential house
General Categories
building
0
-
0.00%
0
0.000
+
warehouse
General Categories
building
5
3
0.04%
58,328
0.135
+
storage container
General Categories
building
3
1
0.02%
16,436
0.138
+
factory structure
General Categories
building
8
4
0.63%
148,268
0.182
+
asphalt road
General Categories
road
72
8
7.11%
48,342
0.307
+
concrete pavement
General Categories
road
129
8
6.13%
73,574
0.165
+
parking lot
General Categories
road
32
6
3.54%
165,576
0.412
+
pedestrian sidewalk
General Categories
road
56
8
0.99%
58,756
0.410
+
crosswalk
General Categories
road
95
7
0.57%
4,814
0.193
+
bare ground
General Categories
ground
156
8
1.59%
16,780
0.147
+
dirt field
General Categories
ground
78
7
2.69%
49,051
0.220
+
grass lawn
General Categories
vegetation
30
8
1.61%
56,942
0.452
+
tree canopy
General Categories
vegetation
74
8
2.08%
55,690
0.553
+
dense forest
General Categories
vegetation
17
7
7.30%
2,051,820
0.880
+
green hedge
General Categories
vegetation
97
8
2.24%
41,963
0.264
+
crop field
Farmland
farmland
57
7
16.71%
603,950
0.844
+
farmland
Farmland
farmland
33
7
13.18%
92,805
0.141
+
plowed field
Farmland
farmland
0
-
0.00%
0
0.000
+
bare soil field
Farmland
farmland
30
6
1.32%
56,641
0.175
+
rice paddy
Farmland
farmland
1
1
0.01%
95,906
0.120
+
vegetable field
Farmland
farmland
17
6
0.77%
93,252
0.316
+
blue roof
Roofs & Structures by Color
building
63
6
1.03%
18,058
0.653
+
red roof
Roofs & Structures by Color
building
15
2
0.04%
2,623
0.168
+
green roof
Roofs & Structures by Color
building
17
5
0.31%
63,992
0.814
+
gray roof
Roofs & Structures by Color
building
47
7
0.23%
8,548
0.203
+
dark gray roof
Roofs & Structures by Color
building
46
7
0.23%
10,421
0.249
+
black roof
Roofs & Structures by Color
building
105
6
0.13%
3,830
0.225
+
white roof
Roofs & Structures by Color
building
209
8
0.34%
4,600
0.246
+
silver metal roof
Roofs & Structures by Color
building
66
5
0.37%
16,170
0.164
+
orange roof
Roofs & Structures by Color
building
4
3
0.00%
5,993
0.161
+
brown roof
Roofs & Structures by Color
building
18
6
0.14%
20,397
0.444
+
yellow roof
Roofs & Structures by Color
building
3
1
0.01%
19,504
0.168
+
white vehicle
Vehicles & Transportation by Color
vehicle
70
8
0.20%
16,285
0.829
+
black vehicle
Vehicles & Transportation by Color
vehicle
70
7
0.13%
6,948
0.225
+
silver vehicle
Vehicles & Transportation by Color
vehicle
10
6
0.02%
15,046
0.892
+
gray vehicle
Vehicles & Transportation by Color
vehicle
11
6
0.04%
22,787
0.822
+
red vehicle
Vehicles & Transportation by Color
vehicle
11
6
0.01%
4,667
0.243
+
blue vehicle
Vehicles & Transportation by Color
vehicle
9
5
0.01%
3,253
0.245
+
yellow vehicle
Vehicles & Transportation by Color
vehicle
2
1
0.00%
7,746
0.209
+
orange vehicle
Vehicles & Transportation by Color
vehicle
15
5
0.02%
4,692
0.230
+
green vehicle
Vehicles & Transportation by Color
vehicle
5
2
0.01%
3,682
0.330
+
white cargo truck
Vehicles & Transportation by Color
vehicle
7
2
0.04%
32,135
0.889
+
blue cargo truck
Vehicles & Transportation by Color
vehicle
2
2
0.01%
33,156
0.821
+
commercial bus
Vehicles & Transportation by Color
vehicle
0
-
0.00%
0
0.000
+
tractor
Vehicles & Transportation by Color
vehicle
0
-
0.00%
0
0.000
+
excavator
Vehicles & Transportation by Color
vehicle
5
4
0.02%
16,793
0.653
+
blue plastic drum
Industrial Materials, Storage & Objects by Color
stored material
2
2
0.00%
1,293
0.171
+
orange plastic barrel
Industrial Materials, Storage & Objects by Color
stored material
23
3
0.01%
906
0.138
+
yellow plastic container
Industrial Materials, Storage & Objects by Color
stored material
44
6
0.02%
1,798
0.274
+
white industrial tank
Industrial Materials, Storage & Objects by Color
stored material
28
2
0.01%
1,644
0.137
+
metallic storage tank
Industrial Materials, Storage & Objects by Color
stored material
0
-
0.00%
0
0.000
+
blue waterproof tarp
Industrial Materials, Storage & Objects by Color
stored material
58
5
0.17%
7,392
0.319
+
green waterproof tarp
Industrial Materials, Storage & Objects by Color
stored material
19
4
0.52%
12,532
0.454
+
black protective sheet
Industrial Materials, Storage & Objects by Color
stored material
13
4
0.03%
12,431
0.156
+
white canvas canopy
Industrial Materials, Storage & Objects by Color
stored material
18
5
0.06%
5,492
0.255
+
plastic greenhouse
Industrial Materials, Storage & Objects by Color
building
0
-
0.00%
0
0.000
+
vinyl greenhouse tunnel
Industrial Materials, Storage & Objects by Color
building
0
-
0.00%
0
0.000
+
greenhouse roof
Industrial Materials, Storage & Objects by Color
building
0
-
0.00%
0
0.000
+
gray metal fence
Industrial Materials, Storage & Objects by Color
fence
19
6
0.05%
8,574
0.226
+
green wire fence
Industrial Materials, Storage & Objects by Color
fence
0
-
0.00%
0
0.000
+
red roadside barrier
Industrial Materials, Storage & Objects by Color
fence
7
1
0.00%
1,040
0.154
+
yellow safety cone
Industrial Materials, Storage & Objects by Color
없음
5
3
0.00%
1,948
0.354
+
orange traffic cone
Industrial Materials, Storage & Objects by Color
없음
45
3
0.00%
294
0.123
+
retaining wall
Road Infrastructure
retaining wall
2
1
0.01%
22,414
0.288
+
concrete retaining wall
Road Infrastructure
retaining wall
38
8
0.15%
15,786
0.169
+
gabion wall
Road Infrastructure
retaining wall
2
2
0.01%
47,682
0.684
+
guardrail
Road Infrastructure
fence
27
8
0.08%
15,065
0.285
+
metal guardrail
Road Infrastructure
fence
262
8
0.62%
5,790
0.179
+
road embankment slope
Road Infrastructure
slope
0
-
0.00%
0
0.000
+
cut slope
Road Infrastructure
slope
19
6
0.51%
62,166
0.146
+
riprap slope
Road Infrastructure
slope
0
-
0.00%
0
0.000
+
bridge
Road Infrastructure
road
0
-
0.00%
0
0.000
+
culvert
Road Infrastructure
structure
5
2
0.00%
1,027
0.107
+
utility pole
Road Infrastructure
structure
298
8
0.28%
1,992
0.212
+
streetlight pole
Road Infrastructure
structure
158
8
0.07%
1,681
0.242
+
road sign
Road Infrastructure
structure
128
8
0.14%
2,531
0.247
+
water surface
Water
water
0
-
0.00%
0
0.000
+
pond
Water
water
1
1
0.00%
13,309
0.726
+
reservoir
Water
water
0
-
0.00%
0
0.000
+
stream
Water
water
8
3
0.05%
28,498
0.333
+
irrigation canal
Water
water
4
2
0.02%
27,393
0.304
+
drainage ditch
Water
water
5
4
0.01%
4,654
0.144
+
overgrown weeds
Boundary & Filler
vegetation
531
8
2.66%
4,321
0.167
+
shrub
Boundary & Filler
vegetation
587
8
3.37%
6,584
0.198
+
grass covered embankment
Boundary & Filler
slope
35
6
2.74%
166,603
0.422
+
earth bank
Boundary & Filler
ground
4
2
0.00%
4,505
0.145
+
dirt path
Boundary & Filler
ground
201
8
2.33%
25,354
0.254
+
ripening rice field
Ripening & Bare Field
farmland
20
5
6.08%
1,491,018
0.957
+
yellow grass field
Ripening & Bare Field
vegetation
0
-
0.00%
0
0.000
+
harvested field
Ripening & Bare Field
farmland
38
6
0.27%
20,705
0.136
+
gravel shoulder
Ripening & Bare Field
road
9
6
0.12%
50,588
0.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('
')
+ 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'
{esc(r["photo"].replace("_multi.json", ""))}
'
+ f'
{r["segments"]:,}
'
+ f'
{u:.1f}%
'
+ f'
5 else "#1a7f4b"}">
')
+ A('
')
+
+ 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('
집합별 합계
'
+ '
집합
프롬프트(검출>0/전체)
폴리곤
폴리곤%
'
+ '
면적%
')
+ 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'
{esc(g)}
{v["used"]} / {v["prompts"]}
'
+ f'
{v["poly"]:,}
{100 * v["poly"] / tot_poly:.1f}%
'
+ f'
{share:.2f}%
')
+ 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'
{n:,}
{drop:.0f}%
'
+ f'
{100 * aarea.get(g, 0.0) / atot:.2f}%
')
+ A(f'
'
+ f'
')
+ A('
')
+ if af:
+ A('
병합 후 면적%는 병합 후 총검출면적 기준. '
+ '겹침을 빼지 않은 다각형 넓이 합이다.
')
+
+ cats = sorted({r["category"] for r in rows})
+ groups = sorted({r["group"] for r in rows})
+ A('
카테고리 · 프롬프트별 검출
')
+ A('
'
+ ''
+ ''
+ ''
+ ''
+ '
')
+ A('
'
+ '
프롬프트
카테고리
집합
폴리곤
사진
'
+ '
면적%
중앙 면적 px
점수 중앙
')
+ 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'
{esc(r["prompt"])}
{esc(r["category"])}
'
+ f'
{esc(r["group"])}
'
+ f'
{r["polygons"]:,}
'
+ f'
{r["photos"] or dash}
'
+ f'
'
+ f'{100 * r["area_share"]:.2f}%
'
+ f'
'
+ f'{r["area_median_px"]:,.0f}
'
+ f'
'
+ f'{r["score_median"]:.3f}
')
+ A('
')
+
+ 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()