Author SHA1 Message Date
minsung 988049e832 @
feat: 단계 비교 뷰어 + factory structure 분리 + gap 스윕

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-09-03 13:19:23 +09:00
minsung 833816061b @
docs: 집합 조정(M2) 보류 기록 + 반입 목록 실측

M2 는 교통시설물을 가르는 안까지 만들고 보류했다. 16장 실측으로
structure 1,079 -> utility pole 608 / streetlight pole 372 /
road sign 358 / culvert 9, fence 702 -> guardrail 668 / fence 46 /
roadside barrier 17 로 갈라지는 것을 확인했다 (라벨 14 -> 19종).
사용자 지시로 v4 로 되돌렸고 v5 는 보류 표시와 함께 남긴다.
culvert 9개, roadside barrier 17개는 16장 기준 표본이 부족하다.

정정: "집합 없는 검출은 버려진다"는 앞선 판단은 틀렸다.
merge_labels.py 의 merge_by_group 은 집합에 없는 라벨을 passthrough 로
그대로 통과시킨다. 16장 병합 결과에 orange traffic cone 114 개와
yellow safety cone 6 개가 독립 클래스로 살아 있다. [cone] 으로 묶으면
오히려 나란히 선 콘이 gap 2px 에서 한 덩어리가 되어 개수를 잃는다.

envsetting.md 에 반입 목록을 실측 크기로 정리했다:
  git 으로 오는 것 (복사 금지)
  손으로 복사할 것  체크포인트 3.3GB / BPE 1.4MB / JPEG 69장 1.2GB
  복사하면 안 되는 것  venv(절대경로), output/, theater 뷰어 JPEG 55MB

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-09-02 09:30:15 +09:00
minsung b754694ef8 @
docs: 확정 설정 16장 검증 + 4090 이관 지시서

16장 실측 무라벨(conf 0.10): 0.5% ~ 6.4%, 평균 3.44%.
15/16 장이 목표 5% 이하. 같은 16장 기준선 평균은 19.9%.
소요 466~907초/장 (평균 585초).

예상이 틀린 곳: 물 많은 사진이 위험할 것으로 봤으나 0423 이 0.5% 로
16장 중 최저였다. water surface / reservoir 프롬프트가 검출 0 이어도
다른 프롬프트가 그 면적을 덮는다.

유일한 5% 초과인 0581(6.4%)은 큰 구멍이 없다. 구멍 1,554개 중 최대가
0.83%, 상위 6개 합이 2.15% 뿐이고 나머지 4.25%가 작은 조각 1,548개로
흩어져 있다. 자재 야적장·건물 밀집 구역이라 폴리곤이 1,079개 나왔는데도
객체 사이 얇은 틈이 누적된 것이다. 0654 유형(큰 필지 통째 누락)과 다르고
프롬프트로는 못 줄인다.

docs/envsetting.md: RTX 4090 PC 로 옮기는 작업 지시 프롬프트.
git 이 안 실어 나르는 3가지(체크포인트, 원본 JPEG, venv)와
브랜치 체크아웃 누락 시 증상을 명시했다.

docs/pilot/theater_v4/ (뷰어 JPEG 55MB)는 커밋하지 않았다.
tools/make_viewer.py 로 다시 구울 수 있다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-09-01 22:37:01 +09:00
minsung 28bee39196 @
feat: SAM 3.1 검출 커버리지 튜닝 — 무라벨 16% → 3.6%

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) <noreply@anthropic.com>
@
2026-09-01 20:04:45 +09:00
15 changed files with 2755 additions and 60 deletions
+147
View File
@@ -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
+155
View File
@@ -0,0 +1,155 @@
# 병합 그룹 정의 — 대괄호 안이 병합 후 대표 라벨.
# 같은 그룹에 속한 라벨끼리 외곽선이 --gap px 이내로 인접하면 하나로 합친다.
# 어느 그룹에도 없는 라벨은 건드리지 않는다.
#
# 여기서 나누지 않는 구분이 있다. SAM은 사진만 보므로 미터도 규모도 모른다.
# 아래는 2D가 아니라 3D 메시에서 갈라야 정확하다:
# 낮은수목 / 수목 CSF 지면 위 높이
# 수목 / 숲 [vegetation] 연결성분 면적
# 지형 / 경사면 면 법선 경사각
# 건물 / 빌딩 [building] 연결성분 면적·높이
# 그래서 tree canopy와 dense forest, building과 residential house는
# 일부러 같은 그룹에 둔다 — 2D에서 갈라봐야 중복 마스크만 나온다.
# factory structure 는 일부러 어느 집합에도 넣지 않는다 (2026-09-02).
# merge_labels.py 는 집합 밖 라벨을 그대로 통과시키므로 독립 클래스로 남는다.
# [building] 에 두면 공장 구조물이 일반 건물에 흡수돼 병합 후 되돌릴 수 없다.
# 16장 실측 86개.
# [building]
building
building rooftop
building facade
residential house
warehouse
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
+180
View File
@@ -0,0 +1,180 @@
# ==========================================================
# 보류 (2026-09-02). 세부 세그멘테이션은 후순위로 미뤘다.
# 현재 쓰는 정의는 configs/merge_groups_v4.txt 다.
#
# 이 파일은 교통시설물을 뭉치지 않고 가른 안이다. 16장 실측으로
# structure 1,079 -> utility pole 608 / streetlight pole 372 /
# road sign 358 / culvert 9, fence 702 -> guardrail 668 /
# fence 46 / roadside barrier 17 로 갈라지는 것까지 확인했다.
# 라벨 14종 -> 19종, 폴리곤 6,370 -> 6,667.
#
# 다시 쓸 때 주의: culvert 9개, roadside barrier 17개는 16장 기준
# 표본이 너무 적다. 69장 전체를 돌린 뒤 클래스로 쓸지 판단해라.
# ==========================================================
# 병합 그룹 정의 — 대괄호 안이 병합 후 대표 라벨.
# 같은 그룹에 속한 라벨끼리 외곽선이 --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]
utility pole
# [streetlight pole]
streetlight pole
# [road sign]
road sign
# [structure]
culvert
# [fence]
gray metal fence
green wire fence
# [guardrail]
guardrail
metal guardrail
# [roadside barrier]
red roadside barrier
# [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
# 콘은 일부러 어느 집합에도 넣지 않는다.
# merge_labels.py 는 집합에 없는 라벨을 건드리지 않고 그대로 통과시킨다
# (merge_by_group 의 passthrough). 그래서 콘은 버려지지 않고 독립 클래스로 남는다.
# [cone] 으로 묶으면 나란히 선 콘이 gap 2px 에서 한 덩어리가 되어 개수를 잃는다.
# 16장 실측: orange traffic cone 114 · yellow safety cone 6 이 그대로 살아 있다.
+128
View File
@@ -0,0 +1,128 @@
{
"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 (11집합 + 집합 밖 3종: factory structure, 콘 2종)",
"타일": "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",
"claim 규칙": "configs/claim_rules.txt — vehicle : 0.5 : 지붕 11종 + 방수포/보호시트"
},
"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": "확정 설정 16장 검증",
"state": "done",
"detail": "16장 실측 무라벨(conf 0.10): 최소 0.5%(0423) ~ 최대 6.4%(0581), 평균 3.44%. 15/16 장이 5% 이하. 같은 16장 기준선 평균 19.9%. 소요 466~907초/장(평균 585초). 예상이 틀린 곳: 물 많은 사진이 위험할 것으로 봤으나 0423 이 0.5% 로 최저였다 — water surface/reservoir 검출이 0이어도 다른 프롬프트가 그 면적을 덮는다."
},
{
"id": "M2",
"name": "집합 정의 조정 — 보류",
"state": "blocked",
"detail": "교통시설물을 가르는 안(configs/merge_groups_v5.txt)을 만들고 16장으로 확인했다: structure 1,079 → utility pole 608 / streetlight pole 372 / road sign 358 / culvert 9, fence 702 → guardrail 668 / fence 46 / roadside barrier 17. 라벨 14 → 19종. 사용자 지시로 보류하고 v4 로 되돌렸다. 세부 세그멘테이션은 후순위."
},
{
"id": "M3",
"name": "gap 값 조정 — 2px 유지",
"state": "done",
"detail": "사진 4장(폴리곤 3,280)으로 gap 0~32px 스윕. 꺾이는 지점이 없는 매끄러운 곡선이다. 폴리곤 0px 2,081 · 2px 2,032 · 3px 2,014 · 8px 1,954 · 16px 1,801 · 32px 1,574. 무라벨은 4.60% → 3.75% 로 거의 안 움직인다. 1~3px 구간 차이는 폴리곤 2.4%, 무라벨 0.13pp 로 노이즈 수준이라 2px 를 유지한다. 과다 병합은 16px 부터 보인다(vehicle 240→229, 32px 에서 209). 콘은 gap 32px 에서도 65개 그대로 — 집합 밖이라 병합을 안 탄다. 소요는 사진당 약 5초(gap 2px)."
},
{
"id": "M4",
"name": "단계 비교 뷰어",
"state": "done",
"detail": "tools/make_stage_viewer.py 신규. 같은 사진에서 검출 → claim → 병합을 갈아끼우고 바뀐 폴리곤만 빨갛게 강조한다. claim 은 563개 재배정 (white roof→white vehicle 194 등 지붕 오검을 차량으로), 병합은 1,033개가 둘 이상 합쳐진 것. 폴리곤 11,169 → 11,169 → 6,370."
},
{
"id": "M5",
"name": "factory structure 를 [building] 에서 분리",
"state": "done",
"detail": "사용자 지적. 공장 구조물이 일반 건물에 흡수되면 병합 후 되돌릴 수 없다. 집합 밖으로 빼면 passthrough 로 독립 클래스가 된다. 16장 실측 24개. 부작용으로 building 이 466 → 490 으로 늘었다 — 전에는 factory structure 폴리곤이 건물 사이 다리 노릇을 해 여러 건물을 한 덩어리로 잇고 있었다. 라벨 14 → 15종, 폴리곤 6,370 → 6,418."
}
],
"issues": [
{
"state": "closed",
"text": "'집합 없는 검출은 버려진다'는 앞선 판단은 틀렸다. merge_labels.py 의 merge_by_group 은 집합에 없는 라벨을 passthrough 로 그대로 통과시킨다. 16장 병합 결과에 orange traffic cone 114 · yellow safety cone 6 이 독립 클래스로 살아 있다. 콘을 [cone] 으로 묶으면 오히려 나란한 콘이 gap 2px 에서 한 덩어리가 되어 개수를 잃는다 — 지금처럼 집합 밖에 두는 게 맞다."
},
{
"state": "closed",
"text": "통짜(1×1) 패스는 대상 하나에 인스턴스가 하나만 남는다 — 도로 한쪽 차로, 논 한 필지만 잡힘. --wide-in-tiles 로 해결(측정치는 M1e)."
},
{
"state": "open",
"text": "0581 무라벨 6.4% — 16장 중 유일한 5% 초과. 원인 확인함: 큰 구멍이 없다. 구멍 1,554개 중 최대가 0.83%, 상위 6개 합이 2.15% 뿐이고 나머지 4.25%가 작은 조각 1,548개로 흩어져 있다. 자재 야적장·건물 밀집 구역이라 폴리곤이 1,079개 나왔는데도 객체 사이 얇은 틈이 누적된 것. 0654 유형(큰 필지 통째 누락)과 다르다. 프롬프트로는 못 줄인다 — 후처리(폴리곤 팽창·구멍 메우기) 영역."
},
{
"state": "open",
"text": "확정 설정 16장에서 검출 0 인 프롬프트 12개: residential house, plowed field, commercial bus, plastic greenhouse, vinyl greenhouse tunnel, greenhouse roof, green wire fence, road embankment slope, riprap slope, bridge, water surface, yellow grass field. 16장은 69장의 23% 라 대상이 없었을 뿐일 수 있다 (bridge 는 다리 있는 사진에서만 나온다). 지워도 94→82개라 배치 청크가 6으로 그대로여서 속도 이득이 없다 — 78개 이하로 줄여야 5청크가 된다. 69장 전체를 돌린 뒤 판단할 것."
},
{
"state": "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)이 다른 비행이라 주차 차량 위치가 다르다."
}
]
}
+243
View File
@@ -0,0 +1,243 @@
# 다른 PC(RTX 4090)에 SAM 3.1 검출 환경 세우기 — 작업 지시 프롬프트
이 문서는 그대로 복사해서 새 PC 의 Claude Code(또는 사람)에게 주는 **작업 지시서**다.
아래 `---` 사이를 통째로 붙여넣으면 된다.
측정 근거: RTX 3060 12GB 에서 사진 1장 548초. 4090 24GB 추정 80~180초.
자세한 내역은 `docs/pilot/sam_merge.html` 의 단계 기록을 봐라.
---
## 지시
RTX 4090 이 달린 이 PC 에 드론 사진 SAM 3.1 검출 환경을 세워라.
목적은 사진 1장을 튜닝된 설정으로 검출해 3060 대비 실제 배속을 재는 것이다.
전체 블록 실행은 그 측정 뒤에 결정한다.
### 전제
- OS: Windows (원래 환경과 같음). Linux 면 경로 구분자만 바꿔라.
- GPU: RTX 4090 24GB. `nvidia-smi` 로 드라이버가 올라와 있는지 먼저 확인해라.
### 반입 목록 (실측 크기)
**git 으로 오는 것 — 복사하지 마라**
| 경로 | 리포 |
|---|---|
| `prompts/discovery_v4.txt` (94개 프롬프트) | railway-client, `sam31-coverage-tuning` 브랜치 |
| `prompts/wide_v1.txt` | 〃 |
| `configs/merge_groups_v4.txt` (현재 활성 집합 정의) | 〃 |
| `tools/*.py` 전부 | 〃 |
| `sam3/` 패키지 | sam31server |
| `configs/auto_labeling/segment_anything_3.yaml` | 〃 |
`merge_groups_v5.txt` 도 리포에 있지만 **보류 상태다. 쓰지 마라.** 활성은 v4 다.
**손으로 복사해야 하는 것 — git 에 없다**
| 항목 | 실측 크기 | 원본 위치 | 왜 git 에 없나 |
|---|---|---|---|
| SAM3.1 체크포인트 | **3.3 GB** | `C:\Users\<사용자>\.cache\huggingface\hub\models--facebook--sam3.1\` | `.gitignore``*.pt` 제외 |
| BPE 사전 `bpe_simple_vocab_16e6.txt.gz` | 1.4 MB | `sam31server\bpe_simple_vocab_16e6.txt.gz` | `.gitignore``*.gz` 제외 |
| 원본 JPEG `stage_BlockYYX\` 69장 | **1.2 GB** | `railway-client\output\sam3\stage_BlockYYX\` | `.gitignore:42``output/` 제외 |
체크포인트는 복사 대신 HuggingFace 에서 새로 받아도 된다
(`huggingface-cli download facebook/sam3.1`). 회선이 느리면 복사가 빠르다.
**복사하면 안 되는 것**
| 항목 | 이유 |
|---|---|
| `.venv\` | 절대경로가 박혀 있어 다른 PC 에서 깨진다. 새로 만들어라 |
| `output\` 의 나머지 (`tune_t64` 등) | 이쪽 PC 의 산출물이다. 4090 은 새로 만든다 |
| `docs\pilot\theater*\` | 뷰어 JPEG 55MB. `make_viewer.py` 로 다시 굽는다 |
### 1. 리포 2개 clone
두 리포를 **형제 폴더로** 두어라. `sam3_multi_prompt.py` 가 기본값으로
`../sam31server` 를 찾는다 (환경변수 `SAM31SERVER_DIR` 로 바꿀 수 있다).
```
D:\MYCLAUDE_PROJECT\
├── railway-client\
└── sam31server\
```
```bash
git clone https://gitea.hmac.kr/kimminsung/railway-client.git
git clone https://gitea.hmac.kr/kimminsung/sam31server.git
cd railway-client
git checkout sam31-coverage-tuning
```
**브랜치 체크아웃을 빠뜨리지 마라.** `main` 에는 튜닝된 프롬프트
(`prompts/discovery_v4.txt`)와 집합 정의(`configs/merge_groups_v4.txt`),
그리고 `--wide-in-tiles` 플래그가 없다.
`sam31server` 는 FastAPI 서버지만 **서버를 띄울 필요는 없다.**
검출 스크립트는 그 안의 `sam3` 패키지를 `sys.path` 로 직접 import 한다
(`tools/sam3_multi_prompt.py:28-33`). `app/` 은 안 쓴다.
### 2. Python 환경
venv 를 **복사하지 마라.** 절대경로가 박혀 있어 깨진다. 새로 만들어라.
원본 환경 (참고용, 실측):
- Python 3.12.8
- torch 2.10.0+cu126 (CUDA 12.6)
```bash
cd sam31server
python -m venv .venv
.venv\Scripts\activate
# torch 는 CUDA 버전에 맞춰 먼저 설치해라 (requirements.txt 의 torch>=2.4.0 보다 우선)
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu126
pip install -r requirements.txt
```
`sam3` 는 pip 설치 대상이 아니다 (site-packages 에 없고 .pth 도 없다).
폴더가 있으면 된다. 의존성만 깔면 끝이다.
설치 확인:
```bash
.venv\Scripts\python.exe -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0))"
```
`True``NVIDIA GeForce RTX 4090` 이 나와야 한다.
### 3. 체크포인트 + BPE 사전 (git 에 없음, 실측 3.3GB + 1.4MB)
`.gitignore``*.pt`, `*.gz` 를 뺀다.
필요한 것:
- `sam3.1_multiplex.pt` — HuggingFace `facebook/sam3.1` (캐시 폴더 전체 3.3GB)
- `bpe_simple_vocab_16e6.txt.gz` (1.4MB)
받는 방법 둘 중 하나:
1. HuggingFace 에서 새로 받는다 (`huggingface-cli download facebook/sam3.1`)
2. 소스 PC 의 HF 캐시를 복사한다
(`C:\Users\<사용자>\.cache\huggingface\hub\models--facebook--sam3.1\...`)
경로 지정은 셋 중 하나 (앞이 우선):
1. CLI 인자 `--checkpoint` / `--bpe`
2. 환경변수 `SAM3_CHECKPOINT` / `SAM3_BPE`
3. `sam31server/configs/auto_labeling/segment_anything_3.yaml`
`params.model_path` / `params.bpe_path`
3번 파일에는 **원래 PC 의 절대경로가 적혀 있다.** 그대로 쓰면 파일을 못 찾는다.
환경변수로 덮거나 이 파일을 고쳐라.
### 4. 원본 사진 (git 에 없음, 실측 1.2GB)
`.gitignore:42``output/` 을 통째로 뺀다.
소스 PC 에서 복사해라:
```
railway-client/output/sam3/stage_BlockYYX/ JPEG 69장, 장당 14~23MB
```
SMB 공유, rsync, 외장 디스크 아무거나. 방법은 상관없다.
### 5. 시험 실행 — 사진 1장
```bash
set SAM31SERVER_DIR=D:\MYCLAUDE_PROJECT\sam31server
set SAM3_CHECKPOINT=<체크포인트 절대경로>
set SAM3_BPE=<BPE 절대경로>
cd D:\MYCLAUDE_PROJECT\railway-client
..\sam31server\.venv\Scripts\python.exe tools\sam3_multi_prompt.py ^
--input output\sam3\stage_BlockYYX\DJI_20250805165516_0654.JPG ^
--output output\sam3\gpu4090_test\DJI_20250805165516_0654_multi.json ^
--prompts prompts\discovery_v4.txt ^
--wide-prompts prompts\wide_v1.txt --wide-in-tiles ^
--cols 6 --rows 4 --conf 0.10 --merge ^
--batch 32
```
`--batch 32` 는 24GB 라서 가능하다. 3060 은 VRAM 때문에 16 이 한계였다.
OOM 이 나면 24 → 16 으로 낮춰라.
**비교 기준 (3060 실측, 같은 사진 0654):**
| 구간 | 3060 |
|---|---|
| 타일 패스 (24타일 × 6청크 = 144 forward) | 410초 |
| 통짜 패스 | 43초 |
| 모델 로딩 + NMS + 병합 + 저장 | 약 105초 |
| **합계** | **466~662초 (10장 평균 548초)** |
로그에 찍히는 `전체 1장 완료 — N초` 와 타일별 시간을 그대로 보고해라.
### 6. 결과 검증
무라벨(미검출) 지표를 재라. GPU 불필요:
```bash
python tools\coverage_stats.py ^
--run-dir output\sam3\gpu4090_test ^
--groups configs\merge_groups_v4.txt ^
--size 8192,5460
```
0654 는 3060 에서 **무라벨 5.0%** 가 나왔다. 4090 에서도 5% 근처여야 한다.
GPU 아키텍처가 달라 비트 단위로 같지는 않지만 소수점 아래에서만 달라야 한다.
**1%p 넘게 벌어지면 설정이 다른 것이니 멈추고 보고해라.**
### 7. 전체 블록 실행 (측정 뒤 지시 받고 할 것)
`--input` 을 **폴더**로, `--outdir` 로 주면 모델을 한 번만 로딩한다.
장별로 프로세스를 새로 띄우면 사진마다 60~90초를 로딩에 버린다.
```bash
..\sam31server\.venv\Scripts\python.exe tools\sam3_multi_prompt.py ^
--input output\sam3\stage_BlockYYX --outdir output\sam3\YYX_v4 ^
--prompts prompts\discovery_v4.txt ^
--wide-prompts prompts\wide_v1.txt --wide-in-tiles ^
--cols 6 --rows 4 --conf 0.10 --merge --batch 32
```
끝나면 집합 병합:
```bash
python tools\merge_labels.py --json <각 _multi.json> --groups configs\merge_groups_v4.txt --gap 2
```
### 8. 결과 회수
산출 JSON 만 가져오면 된다 — 장당 2~5MB, 69장 약 300MB.
집계·병합·뷰어 생성은 GPU 없이 원래 PC 에서 돌린다.
### 하지 말 것
- venv 복사 (절대경로 깨짐)
- `main` 브랜치로 실행 (튜닝 설정 없음)
- FastAPI 서버 기동 (이 작업에 불필요)
- 설정값 임의 변경. `--cols 6 --rows 4 --conf 0.10 --wide-in-tiles` 넷이
무라벨 16% → 3.6% 를 만든 조합이다. 하나라도 바꾸면 품질이 달라진다.
- 측정 안 한 수치 보고. 추정이면 추정이라고 써라.
---
## 부록: 이 설정이 나온 근거
BlockYYX 사진 8~16장 실측. 지표는 "무라벨 화면%" — 마스크 합집합으로 재서
겹침을 뺀 값이다.
| 단계 | 0654 | 0006 |
|---|---|---|
| 기준선 (conf 0.25, 타일 4×3, 통짜 분리) | 52.1% | 19.4% |
| `--wide-in-tiles` | 15.4% | 12.2% |
| 경계 띠 프롬프트 +5 (v3) | 14.9% | 11.9% |
| conf 0.25 → 0.10 | 9.5% | 5.8% |
| 프롬프트 +4 (v4) | 9.6% | 5.7% |
| 타일 4×3 → 6×4 | **5.0%** | **5.0%** |
기여도: 통짜→타일 36.7pp · conf 5.4pp · 타일 세분화 4.6pp ·
프롬프트 추가 0.5pp. **프롬프트 추가는 거의 기여하지 않았다.**
원인은 통짜(1×1) 패스였다. `wide_v1.txt` 의 21개 프롬프트는 8192×5460 을
한 장으로 넣어 대상 하나에 인스턴스가 하나만 살아남았다 — 도로는 한쪽 차로만,
논은 한 필지만 잡혔다.
+308
View File
@@ -0,0 +1,308 @@
<title>SAM 3.1 후처리 — 검출 커버리지 · 집합 병합</title>
<style>
:root{--bg:#fbfbfa;--fg:#1d1f21;--dim:#6b7076;--line:#e2e4e6;--card:#fff;--accent:#2f6f9f}
:root:not([data-theme="light"]){}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
--bg:#16181a;--fg:#e6e8ea;--dim:#9aa1a8;--line:#2c3034;--card:#1d2023;--accent:#79b8e8}}
:root[data-theme="dark"]{--bg:#16181a;--fg:#e6e8ea;--dim:#9aa1a8;--line:#2c3034;--card:#1d2023;--accent:#79b8e8}
body{background:var(--bg);color:var(--fg);font:14px/1.55 -apple-system,"Segoe UI",
"Malgun Gothic",sans-serif;margin:0;padding:28px 22px 60px}
.wrap{max-width:1180px;margin:0 auto}
h1{font-size:21px;margin:0 0 4px}
h2{font-size:15px;margin:30px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--line)}
.sub{color:var(--dim);margin:0 0 2px}
.badge{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;
font-weight:600;white-space:nowrap}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:11px 13px}
.card .k{color:var(--dim);font-size:12px}
.card .v{font-size:19px;font-weight:600;margin-top:2px}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{padding:5px 9px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2),
th:nth-child(3),td:nth-child(3){text-align:left}
th{background:var(--card);position:sticky;top:0;cursor:pointer;font-weight:600;
color:var(--dim);border-bottom:2px solid var(--line)}
tbody tr:hover{background:var(--card)}
tr.zero td{color:var(--dim)}
tr.nogroup td:nth-child(3){color:#c0392b;font-weight:600}
.scroll{overflow-x:auto;max-height:640px;overflow-y:auto;border:1px solid var(--line);
border-radius:8px}
.bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;min-width:60px}
.bar i{display:block;height:100%;background:var(--accent)}
.step{display:flex;gap:11px;align-items:flex-start;padding:9px 0;border-bottom:1px solid var(--line)}
.step .id{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dim);min-width:30px}
.step .d{color:var(--dim);font-size:13px}
.issue{padding:9px 0;border-bottom:1px solid var(--line);display:flex;gap:11px}
.par{display:grid;grid-template-columns:max-content 1fr;gap:3px 16px;font-size:13px}
.par b{color:var(--dim);font-weight:500}
code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
.controls{display:flex;gap:10px;flex-wrap:wrap;margin:0 0 10px}
.tabs{display:flex;gap:6px;margin:0 0 10px;flex-wrap:wrap}
.tab{background:var(--card);border:1px solid var(--line);border-radius:6px;
padding:6px 13px;cursor:pointer;font:inherit;font-size:13px;color:var(--fg)}
.tab[aria-selected="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
.stage{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--card)}
.stage iframe{display:block;width:100%;height:620px;border:0}
.flow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px;
color:var(--dim);font-size:13px}
.flow b{color:var(--fg);font-weight:600}
.flow .ar{color:var(--accent);font-weight:700}
.d-up{color:#1a7f4b}.d-dn{color:#c0392b}
select,input{background:var(--card);color:var(--fg);border:1px solid var(--line);
border-radius:6px;padding:5px 8px;font:inherit;font-size:13px}
</style>
<div class="wrap">
<h1>SAM 3.1 후처리 — 검출 커버리지 · 집합 병합</h1>
<p class="sub">1단계는 미검출을 없애는 것이다. 지표는 &#x27;무라벨 화면%&#x27; — 마스크 합집합으로 재서 겹침을 뺀 값. 목표 5% 이하, 8장 평균 3.6% 로 달성. 2단계는 집합으로 묶고 외곽선이 gap px 이내로 인접한 폴리곤을 합치는 것.</p>
<p class="sub" style="font-size:12px">생성 2026-09-03 13:19 · 집계 <code>tune_t64</code></p>
<h2>실측</h2><div class="cards">
<div class="card"><div class="k">사진</div><div class="v">16</div></div>
<div class="card"><div class="k">폴리곤</div><div class="v">11,169</div></div>
<div class="card"><div class="k">병합 후 폴리곤</div><div class="v">6,418 (-43%)</div></div>
<div class="card"><div class="k">라벨 종류</div><div class="v">82 / 94</div></div>
<div class="card"><div class="k">집합</div><div class="v">12</div></div>
<div class="card"><div class="k">검출 면적 / 화면</div><div class="v">184.7%</div></div>
<div class="card"><div class="k">집합 없는 폴리곤</div><div class="v">120</div></div>
</div>
<h2>미검출 — 무라벨 화면%</h2>
<p class="sub" style="font-size:12px">마스크 합집합 기준(겹침 제거), 축척 1/4. 목표 5% 이하.</p>
<div class="cards">
<div class="card"><div class="k">무라벨 평균</div><div class="v">3.5%</div></div>
<div class="card"><div class="k">무라벨 중앙</div><div class="v">3.3%</div></div>
<div class="card"><div class="k">최악</div><div class="v">6.4%</div></div>
<div class="card"><div class="k">5% 이하 사진</div><div class="v">13 / 16</div></div>
</div>
<div class="scroll" style="max-height:280px;margin-top:10px"><table><thead><tr><th>사진</th><th>폴리곤</th><th>무라벨%</th><th></th></tr></thead><tbody>
<tr><td>DJI_20250805165320_0581</td><td data-v="1079">1,079</td><td data-v="6.435">6.4%</td><td><div class="bar"><i style="width:6.4%;background:#c0392b"></i></div></td></tr>
<tr><td>DJI_20250805163524_0007</td><td data-v="970">970</td><td data-v="5.134">5.1%</td><td><div class="bar"><i style="width:5.1%;background:#c0392b"></i></div></td></tr>
<tr><td>DJI_20250805165516_0654</td><td data-v="363">363</td><td data-v="5.038">5.0%</td><td><div class="bar"><i style="width:5.0%;background:#c0392b"></i></div></td></tr>
<tr><td>DJI_20250805163523_0006</td><td data-v="923">923</td><td data-v="4.953">5.0%</td><td><div class="bar"><i style="width:5.0%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805165514_0653</td><td data-v="377">377</td><td data-v="4.719">4.7%</td><td><div class="bar"><i style="width:4.7%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805163720_0079</td><td data-v="443">443</td><td data-v="4.425">4.4%</td><td><div class="bar"><i style="width:4.4%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805164537_0346</td><td data-v="1020">1,020</td><td data-v="3.582">3.6%</td><td><div class="bar"><i style="width:3.6%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805164555_0357</td><td data-v="915">915</td><td data-v="3.331">3.3%</td><td><div class="bar"><i style="width:3.3%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805164932_0484</td><td data-v="606">606</td><td data-v="3.028">3.0%</td><td><div class="bar"><i style="width:3.0%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805164750_0421</td><td data-v="826">826</td><td data-v="3.018">3.0%</td><td><div class="bar"><i style="width:3.0%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805164304_0274</td><td data-v="873">873</td><td data-v="2.917">2.9%</td><td><div class="bar"><i style="width:2.9%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805164457_0321</td><td data-v="714">714</td><td data-v="2.525">2.5%</td><td><div class="bar"><i style="width:2.5%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805164931_0483</td><td data-v="489">489</td><td data-v="2.488">2.5%</td><td><div class="bar"><i style="width:2.5%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805164739_0414</td><td data-v="522">522</td><td data-v="1.713">1.7%</td><td><div class="bar"><i style="width:1.7%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805164917_0475</td><td data-v="287">287</td><td data-v="1.526">1.5%</td><td><div class="bar"><i style="width:1.5%;background:#1a7f4b"></i></div></td></tr>
<tr><td>DJI_20250805164753_0423</td><td data-v="762">762</td><td data-v="0.521">0.5%</td><td><div class="bar"><i style="width:0.5%;background:#1a7f4b"></i></div></td></tr>
</tbody></table></div>
<h2>공정 보기 — 같은 사진, 단계별</h2>
<div class="flow"><b>SAM 검출 11,169 폴리곤</b><span>(82 라벨)</span><span class="ar">→ 집합 병합 →</span><b>6,418 폴리곤</b><span>(15 집합)</span><span>· 사진 16장 기준</span></div>
<div class="tabs">
<button class="tab" role="tab" data-i="0" aria-selected="true">단계 비교 (검출 → claim → 병합)</button>
<button class="tab" role="tab" data-i="1" aria-selected="false">검출만 보기 (라벨 82종)</button>
<button class="tab" role="tab" data-i="2" aria-selected="false">병합만 보기</button>
</div>
<div class="stage" data-i="0"><iframe src="theater_v4/stages.html" loading="lazy" title="stage0"></iframe></div>
<div class="stage" data-i="1" hidden><iframe src="theater_v4/stage1_detect.html" loading="lazy" title="stage1"></iframe></div>
<div class="stage" data-i="2" hidden><iframe src="theater_v4/stage2_merged.html" loading="lazy" title="stage2"></iframe></div>
<p class="sub" style="font-size:12px">왼쪽 목록에서 라벨을 켜고 끈다. 휠 확대, 드래그 이동. 새 창: <a href="theater_v4/stages.html">단계 비교 (검출 → claim → 병합)</a> · <a href="theater_v4/stage1_detect.html">검출만 보기 (라벨 82종)</a> · <a href="theater_v4/stage2_merged.html">병합만 보기</a></p>
<h2>단계</h2>
<div class="step"><span class="id">M1</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>프롬프트별 검출 집계</div><div class="d">tools/prompt_stats.py. YYX_run 실측: 폴리곤 13,580 · 라벨 77종.</div></div></div>
<div class="step"><span class="id">M1b</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>공정 보기(검출 → 병합) 뷰어 연결</div><div class="d">tools/make_viewer.py 로 같은 사진 3장을 단계별로 굽고 모니터에 붙였다. docs/pilot/theater/.</div></div></div>
<div class="step"><span class="id">M1c</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>무라벨 지표 확립</div><div class="d">tools/coverage_stats.py. YYX_run 69장 실측: 무라벨 평균 16.1% · 중앙 15.0% · 최악 52.1%(0654). 덮음 95% 이상은 2장뿐.</div></div></div>
<div class="step"><span class="id">M1d</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>통짜 패스 원인 규명</div><div class="d">wide_v1.txt 의 21개(asphalt road, crop field, farmland, tree canopy 등)는 8192×5460 을 1×1 로 넣어 대상당 인스턴스가 1개만 살아남았다. 0006 에서 asphalt road 가 서쪽 차로만 덮음(1개, score 0.910).</div></div></div>
<div class="step"><span class="id">M1e</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>--wide-in-tiles 시험</div><div class="d">통짜 프롬프트를 타일 패스에도 돌린다. 0654 무라벨 52.1→15.4%, 0006 19.4→12.2%. 비용 140→231초/장 (+65%).</div></div></div>
<div class="step"><span class="id">M1f</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>경계 띠 프롬프트 추가 (v3)</div><div class="d">잔여 무라벨이 논밭 둑·도로 법면·밭 가장자리 잡초였다. overgrown weeds / shrub / grass covered embankment / earth bank / dirt path 5개 추가. 효과 작음 — 0654 15.4→14.9%, 0006 12.2→11.9%.</div></div></div>
<div class="step"><span class="id">M1g</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>conf 스윕</div><div class="d">conf 0.10 으로 한 번 뽑고 점수 문턱을 오프라인으로 쓸었다. 0006: 0.25에서 9.3% → 0.10에서 5.8%. 0654: 13.1% → 9.5%. conf 0.03 은 마스크 폭증으로 37분에 1장도 못 끝내 중단.</div></div></div>
<div class="step"><span class="id">M1h</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>v4 프롬프트 (익은 논·갓길)</div><div class="d">ripening rice field / yellow grass field / harvested field / gravel shoulder 추가. 효과 없음 — 0006 5.8→5.7%, 0654 9.5→9.6%. 프롬프트 추가는 포화.</div></div></div>
<div class="step"><span class="id">M1i</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>타일 4×3 → 6×4 (목표 달성)</div><div class="d">conf 0.10 · v4 프롬프트 · --wide-in-tiles 와 함께. 4장 실측 무라벨: 0654 5.0% (기준선 52.1) · 0484 3.0% (38.7) · 0079 4.4% (27.1) · 0006 5.0% (19.4). 평균 34.3% → 4.35%. 비용 466~616초/장, 평균 523초 = 기준선 140초의 3.7배. 69장 재검출 시 약 10시간.</div></div></div>
<div class="step"><span class="id">M1j</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>확정 설정 16장 검증</div><div class="d">16장 실측 무라벨(conf 0.10): 최소 0.5%(0423) ~ 최대 6.4%(0581), 평균 3.44%. 15/16 장이 5% 이하. 같은 16장 기준선 평균 19.9%. 소요 466~907초/장(평균 585초). 예상이 틀린 곳: 물 많은 사진이 위험할 것으로 봤으나 0423 이 0.5% 로 최저였다 — water surface/reservoir 검출이 0이어도 다른 프롬프트가 그 면적을 덮는다.</div></div></div>
<div class="step"><span class="id">M2</span><span class="badge" style="color:#a12a2a;background:#fbe6e6">막힘</span><div><div>집합 정의 조정 — 보류</div><div class="d">교통시설물을 가르는 안(configs/merge_groups_v5.txt)을 만들고 16장으로 확인했다: structure 1,079 → utility pole 608 / streetlight pole 372 / road sign 358 / culvert 9, fence 702 → guardrail 668 / fence 46 / roadside barrier 17. 라벨 14 → 19종. 사용자 지시로 보류하고 v4 로 되돌렸다. 세부 세그멘테이션은 후순위.</div></div></div>
<div class="step"><span class="id">M3</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>gap 값 조정 — 2px 유지</div><div class="d">사진 4장(폴리곤 3,280)으로 gap 0~32px 스윕. 꺾이는 지점이 없는 매끄러운 곡선이다. 폴리곤 0px 2,081 · 2px 2,032 · 3px 2,014 · 8px 1,954 · 16px 1,801 · 32px 1,574. 무라벨은 4.60% → 3.75% 로 거의 안 움직인다. 1~3px 구간 차이는 폴리곤 2.4%, 무라벨 0.13pp 로 노이즈 수준이라 2px 를 유지한다. 과다 병합은 16px 부터 보인다(vehicle 240→229, 32px 에서 209). 콘은 gap 32px 에서도 65개 그대로 — 집합 밖이라 병합을 안 탄다. 소요는 사진당 약 5초(gap 2px).</div></div></div>
<div class="step"><span class="id">M4</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>단계 비교 뷰어</div><div class="d">tools/make_stage_viewer.py 신규. 같은 사진에서 검출 → claim → 병합을 갈아끼우고 바뀐 폴리곤만 빨갛게 강조한다. claim 은 563개 재배정 (white roof→white vehicle 194 등 지붕 오검을 차량으로), 병합은 1,033개가 둘 이상 합쳐진 것. 폴리곤 11,169 → 11,169 → 6,370.</div></div></div>
<div class="step"><span class="id">M5</span><span class="badge" style="color:#1a7f4b;background:#e3f5ea">완료</span><div><div>factory structure 를 [building] 에서 분리</div><div class="d">사용자 지적. 공장 구조물이 일반 건물에 흡수되면 병합 후 되돌릴 수 없다. 집합 밖으로 빼면 passthrough 로 독립 클래스가 된다. 16장 실측 24개. 부작용으로 building 이 466 → 490 으로 늘었다 — 전에는 factory structure 폴리곤이 건물 사이 다리 노릇을 해 여러 건물을 한 덩어리로 잇고 있었다. 라벨 14 → 15종, 폴리곤 6,370 → 6,418.</div></div></div>
<h2>이슈 · 기각된 시도</h2>
<div class="issue"><span class="badge" style="color:#1a7f4b;background:#e3f5ea">해결</span><div>&#x27;집합 없는 검출은 버려진다&#x27;는 앞선 판단은 틀렸다. merge_labels.py 의 merge_by_group 은 집합에 없는 라벨을 passthrough 로 그대로 통과시킨다. 16장 병합 결과에 orange traffic cone 114 · yellow safety cone 6 이 독립 클래스로 살아 있다. 콘을 [cone] 으로 묶으면 오히려 나란한 콘이 gap 2px 에서 한 덩어리가 되어 개수를 잃는다 — 지금처럼 집합 밖에 두는 게 맞다.</div></div>
<div class="issue"><span class="badge" style="color:#1a7f4b;background:#e3f5ea">해결</span><div>통짜(1×1) 패스는 대상 하나에 인스턴스가 하나만 남는다 — 도로 한쪽 차로, 논 한 필지만 잡힘. --wide-in-tiles 로 해결(측정치는 M1e).</div></div>
<div class="issue"><span class="badge" style="color:#a12a2a;background:#fbe6e6">미해결</span><div>0581 무라벨 6.4% — 16장 중 유일한 5% 초과. 원인 확인함: 큰 구멍이 없다. 구멍 1,554개 중 최대가 0.83%, 상위 6개 합이 2.15% 뿐이고 나머지 4.25%가 작은 조각 1,548개로 흩어져 있다. 자재 야적장·건물 밀집 구역이라 폴리곤이 1,079개 나왔는데도 객체 사이 얇은 틈이 누적된 것. 0654 유형(큰 필지 통째 누락)과 다르다. 프롬프트로는 못 줄인다 — 후처리(폴리곤 팽창·구멍 메우기) 영역.</div></div>
<div class="issue"><span class="badge" style="color:#a12a2a;background:#fbe6e6">미해결</span><div>확정 설정 16장에서 검출 0 인 프롬프트 12개: residential house, plowed field, commercial bus, plastic greenhouse, vinyl greenhouse tunnel, greenhouse roof, green wire fence, road embankment slope, riprap slope, bridge, water surface, yellow grass field. 16장은 69장의 23% 라 대상이 없었을 뿐일 수 있다 (bridge 는 다리 있는 사진에서만 나온다). 지워도 94→82개라 배치 청크가 6으로 그대로여서 속도 이득이 없다 — 78개 이하로 줄여야 5청크가 된다. 69장 전체를 돌린 뒤 판단할 것.</div></div>
<div class="issue"><span class="badge" style="color:#5a5f66;background:#eceef0">기각</span><div>프롬프트 색 특정성 A/B (car/sedan/suv/van/pickup truck) — 폴리곤 +26% 인데 마스크 면적은 +0.002~0.068%p 뿐, 신규 마스크의 90~99%가 기존과 겹침.</div></div>
<div class="issue"><span class="badge" style="color:#5a5f66;background:#eceef0">기각</span><div>정사영상으로 차량 검증 — 경사 사진(2025-08-05)과 정사영상(250922)이 다른 비행이라 주차 차량 위치가 다르다.</div></div>
<h2>집합별 합계</h2><div class="scroll"><table><thead><tr><th>집합</th><th>프롬프트(검출>0/전체)</th><th>폴리곤</th><th>폴리곤%</th><th>면적%</th>
<th>병합 후 폴리곤</th><th>줄어든 비율</th><th>병합 후 면적%</th>
<th></th></tr></thead><tbody>
<tr><td>vegetation</td><td>6 / 7</td><td>3,035</td><td>27.2%</td><td>16.63%</td>
<td>1,766</td><td class="d-up">42%</td><td>16.09%</td>
<td><div class="bar"><i style="width:16.6%"></i></div></td></tr>
<tr><td>building</td><td>17 / 21</td><td>2,218</td><td>19.9%</td><td>15.18%</td>
<td>490</td><td class="d-up">78%</td><td>12.57%</td>
<td><div class="bar"><i style="width:15.2%"></i></div></td></tr>
<tr><td>structure</td><td>4 / 4</td><td>1,347</td><td>12.1%</td><td>0.46%</td>
<td>1,079</td><td class="d-up">20%</td><td>0.57%</td>
<td><div class="bar"><i style="width:0.5%"></i></div></td></tr>
<tr><td>ground</td><td>4 / 4</td><td>939</td><td>8.4%</td><td>7.71%</td>
<td>573</td><td class="d-up">39%</td><td>8.99%</td>
<td><div class="bar"><i style="width:7.7%"></i></div></td></tr>
<tr><td>road</td><td>6 / 7</td><td>839</td><td>7.5%</td><td>25.09%</td>
<td>247</td><td class="d-up">71%</td><td>25.41%</td>
<td><div class="bar"><i style="width:25.1%"></i></div></td></tr>
<tr><td>fence</td><td>4 / 5</td><td>768</td><td>6.9%</td><td>0.90%</td>
<td>702</td><td class="d-up">9%</td><td>1.18%</td>
<td><div class="bar"><i style="width:0.9%"></i></div></td></tr>
<tr><td>vehicle</td><td>13 / 14</td><td>663</td><td>5.9%</td><td>0.80%</td>
<td>576</td><td class="d-up">13%</td><td>1.06%</td>
<td><div class="bar"><i style="width:0.8%"></i></div></td></tr>
<tr><td>stored material</td><td>9 / 9</td><td>536</td><td>4.8%</td><td>0.75%</td>
<td>450</td><td class="d-up">16%</td><td>1.02%</td>
<td><div class="bar"><i style="width:0.7%"></i></div></td></tr>
<tr><td>farmland</td><td>7 / 8</td><td>460</td><td>4.1%</td><td>29.24%</td>
<td>156</td><td class="d-up">66%</td><td>27.27%</td>
<td><div class="bar"><i style="width:29.2%"></i></div></td></tr>
<tr><td>없음</td><td>2 / 2</td><td>120</td><td>1.1%</td><td>0.00%</td>
<td>144</td><td class="">-20%</td><td>1.43%</td>
<td><div class="bar"><i style="width:0.0%"></i></div></td></tr>
<tr><td>retaining wall</td><td>3 / 3</td><td>100</td><td>0.9%</td><td>0.28%</td>
<td>99</td><td class="d-up">1%</td><td>0.38%</td>
<td><div class="bar"><i style="width:0.3%"></i></div></td></tr>
<tr><td>slope</td><td>2 / 4</td><td>86</td><td>0.8%</td><td>2.73%</td>
<td>85</td><td class="d-up">1%</td><td>3.75%</td>
<td><div class="bar"><i style="width:2.7%"></i></div></td></tr>
<tr><td>water</td><td>5 / 6</td><td>58</td><td>0.5%</td><td>0.23%</td>
<td>51</td><td class="d-up">12%</td><td>0.28%</td>
<td><div class="bar"><i style="width:0.2%"></i></div></td></tr>
</tbody></table></div>
<p class="sub" style="font-size:12px">병합 후 면적%는 병합 후 총검출면적 기준. 겹침을 빼지 않은 다각형 넓이 합이다.</p>
<h2>카테고리 · 프롬프트별 검출</h2>
<div class="controls"><select id="fcat"><option value="">카테고리 전체</option><option>Boundary &amp; Filler</option><option>Farmland</option><option>General Categories</option><option>Industrial Materials, Storage &amp; Objects by Color</option><option>Ripening &amp; Bare Field</option><option>Road Infrastructure</option><option>Roofs &amp; Structures by Color</option><option>Vehicles &amp; Transportation by Color</option><option>Water</option></select><select id="fgrp"><option value="">집합 전체</option><option>building</option><option>farmland</option><option>fence</option><option>ground</option><option>retaining wall</option><option>road</option><option>slope</option><option>stored material</option><option>structure</option><option>vegetation</option><option>vehicle</option><option>water</option><option>없음</option></select><input id="fq" placeholder="프롬프트 검색"><label style="color:var(--dim)"><input type="checkbox" id="fzero"> 검출 0 만</label></div>
<div class="scroll"><table id="t"><thead><tr><th>프롬프트</th><th>카테고리</th><th>집합</th><th>폴리곤</th><th>사진</th><th>면적%</th><th>중앙 면적 px</th><th>점수 중앙</th></tr></thead><tbody>
<tr data-cat="General Categories" data-grp="building"><td>building</td><td>General Categories</td><td>building</td><td data-v="160">160</td><td data-v="15">15</td><td data-v="0.07781232">7.78%</td><td data-v="77476.8">77,477</td><td data-v="0.4456">0.446</td></tr>
<tr data-cat="General Categories" data-grp="building"><td>building rooftop</td><td>General Categories</td><td>building</td><td data-v="159">159</td><td data-v="12">12</td><td data-v="0.02030119">2.03%</td><td data-v="27486.0">27,486</td><td data-v="0.1983">0.198</td></tr>
<tr data-cat="General Categories" data-grp="building"><td>building facade</td><td>General Categories</td><td>building</td><td data-v="36">36</td><td data-v="8">8</td><td data-v="0.00321796">0.32%</td><td data-v="67333.5">67,334</td><td data-v="0.1627">0.163</td></tr>
<tr class="zero" data-cat="General Categories" data-grp="building"><td>residential house</td><td>General Categories</td><td>building</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr data-cat="General Categories" data-grp="building"><td>warehouse</td><td>General Categories</td><td>building</td><td data-v="15">15</td><td data-v="8">8</td><td data-v="0.00131459">0.13%</td><td data-v="91419.0">91,419</td><td data-v="0.1356">0.136</td></tr>
<tr data-cat="General Categories" data-grp="building"><td>storage container</td><td>General Categories</td><td>building</td><td data-v="86">86</td><td data-v="5">5</td><td data-v="0.00105981">0.11%</td><td data-v="5593.2">5,593</td><td data-v="0.1590">0.159</td></tr>
<tr data-cat="General Categories" data-grp="building"><td>factory structure</td><td>General Categories</td><td>building</td><td data-v="24">24</td><td data-v="9">9</td><td data-v="0.01036028">1.04%</td><td data-v="232985.2">232,985</td><td data-v="0.2278">0.228</td></tr>
<tr data-cat="General Categories" data-grp="road"><td>asphalt road</td><td>General Categories</td><td>road</td><td data-v="148">148</td><td data-v="16">16</td><td data-v="0.07837162">7.84%</td><td data-v="51795.8">51,796</td><td data-v="0.2607">0.261</td></tr>
<tr data-cat="General Categories" data-grp="road"><td>concrete pavement</td><td>General Categories</td><td>road</td><td data-v="279">279</td><td data-v="16">16</td><td data-v="0.07276611">7.28%</td><td data-v="50809.5">50,810</td><td data-v="0.1657">0.166</td></tr>
<tr data-cat="General Categories" data-grp="road"><td>parking lot</td><td>General Categories</td><td>road</td><td data-v="79">79</td><td data-v="13">13</td><td data-v="0.08050929">8.05%</td><td data-v="168694.5">168,694</td><td data-v="0.4431">0.443</td></tr>
<tr data-cat="General Categories" data-grp="road"><td>pedestrian sidewalk</td><td>General Categories</td><td>road</td><td data-v="118">118</td><td data-v="16">16</td><td data-v="0.01098084">1.10%</td><td data-v="61354.2">61,354</td><td data-v="0.4539">0.454</td></tr>
<tr data-cat="General Categories" data-grp="road"><td>crosswalk</td><td>General Categories</td><td>road</td><td data-v="193">193</td><td data-v="12">12</td><td data-v="0.00667974">0.67%</td><td data-v="7348.5">7,348</td><td data-v="0.2230">0.223</td></tr>
<tr data-cat="General Categories" data-grp="ground"><td>bare ground</td><td>General Categories</td><td>ground</td><td data-v="379">379</td><td data-v="16">16</td><td data-v="0.01435555">1.44%</td><td data-v="11594.5">11,594</td><td data-v="0.1424">0.142</td></tr>
<tr data-cat="General Categories" data-grp="ground"><td>dirt field</td><td>General Categories</td><td>ground</td><td data-v="165">165</td><td data-v="15">15</td><td data-v="0.04128724">4.13%</td><td data-v="58397.5">58,398</td><td data-v="0.2319">0.232</td></tr>
<tr data-cat="General Categories" data-grp="vegetation"><td>grass lawn</td><td>General Categories</td><td>vegetation</td><td data-v="83">83</td><td data-v="16">16</td><td data-v="0.02137669">2.14%</td><td data-v="52884.0">52,884</td><td data-v="0.3523">0.352</td></tr>
<tr data-cat="General Categories" data-grp="vegetation"><td>tree canopy</td><td>General Categories</td><td>vegetation</td><td data-v="128">128</td><td data-v="16">16</td><td data-v="0.01665810">1.67%</td><td data-v="55470.5">55,470</td><td data-v="0.5031">0.503</td></tr>
<tr data-cat="General Categories" data-grp="vegetation"><td>dense forest</td><td>General Categories</td><td>vegetation</td><td data-v="29">29</td><td data-v="12">12</td><td data-v="0.04650714">4.65%</td><td data-v="1293221.5">1,293,222</td><td data-v="0.7821">0.782</td></tr>
<tr data-cat="General Categories" data-grp="vegetation"><td>green hedge</td><td>General Categories</td><td>vegetation</td><td data-v="252">252</td><td data-v="16">16</td><td data-v="0.02497443">2.50%</td><td data-v="37935.0">37,935</td><td data-v="0.2556">0.256</td></tr>
<tr data-cat="Farmland" data-grp="farmland"><td>crop field</td><td>Farmland</td><td>farmland</td><td data-v="112">112</td><td data-v="15">15</td><td data-v="0.12624180">12.62%</td><td data-v="361167.2">361,167</td><td data-v="0.7590">0.759</td></tr>
<tr data-cat="Farmland" data-grp="farmland"><td>farmland</td><td>Farmland</td><td>farmland</td><td data-v="77">77</td><td data-v="15">15</td><td data-v="0.09702145">9.70%</td><td data-v="73523.0">73,523</td><td data-v="0.1343">0.134</td></tr>
<tr class="zero" data-cat="Farmland" data-grp="farmland"><td>plowed field</td><td>Farmland</td><td>farmland</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr data-cat="Farmland" data-grp="farmland"><td>bare soil field</td><td>Farmland</td><td>farmland</td><td data-v="71">71</td><td data-v="12">12</td><td data-v="0.01008127">1.01%</td><td data-v="21693.0">21,693</td><td data-v="0.1709">0.171</td></tr>
<tr data-cat="Farmland" data-grp="farmland"><td>rice paddy</td><td>Farmland</td><td>farmland</td><td data-v="2">2</td><td data-v="2">2</td><td data-v="0.00022162">0.02%</td><td data-v="146495.0">146,495</td><td data-v="0.1150">0.115</td></tr>
<tr data-cat="Farmland" data-grp="farmland"><td>vegetable field</td><td>Farmland</td><td>farmland</td><td data-v="53">53</td><td data-v="13">13</td><td data-v="0.01650198">1.65%</td><td data-v="108924.0">108,924</td><td data-v="0.2973">0.297</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>blue roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="194">194</td><td data-v="12">12</td><td data-v="0.01329944">1.33%</td><td data-v="17519.8">17,520</td><td data-v="0.5919">0.592</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>red roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="73">73</td><td data-v="7">7</td><td data-v="0.00046298">0.05%</td><td data-v="2929.0">2,929</td><td data-v="0.2086">0.209</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>green roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="51">51</td><td data-v="11">11</td><td data-v="0.00414770">0.41%</td><td data-v="47352.5">47,352</td><td data-v="0.7417">0.742</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>gray roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="130">130</td><td data-v="14">14</td><td data-v="0.00284384">0.28%</td><td data-v="8339.8">8,340</td><td data-v="0.2075">0.207</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>dark gray roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="106">106</td><td data-v="14">14</td><td data-v="0.00223156">0.22%</td><td data-v="9017.2">9,017</td><td data-v="0.2273">0.227</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>black roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="285">285</td><td data-v="14">14</td><td data-v="0.00240389">0.24%</td><td data-v="4436.0">4,436</td><td data-v="0.2084">0.208</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>white roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="590">590</td><td data-v="16">16</td><td data-v="0.00552140">0.55%</td><td data-v="4830.8">4,831</td><td data-v="0.2437">0.244</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>silver metal roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="212">212</td><td data-v="12">12</td><td data-v="0.00485095">0.49%</td><td data-v="10739.8">10,740</td><td data-v="0.1825">0.182</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>orange roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="33">33</td><td data-v="7">7</td><td data-v="0.00069414">0.07%</td><td data-v="3407.0">3,407</td><td data-v="0.1886">0.189</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>brown roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="60">60</td><td data-v="13">13</td><td data-v="0.00120870">0.12%</td><td data-v="10086.0">10,086</td><td data-v="0.2119">0.212</td></tr>
<tr data-cat="Roofs &amp; Structures by Color" data-grp="building"><td>yellow roof</td><td>Roofs &amp; Structures by Color</td><td>building</td><td data-v="4">4</td><td data-v="2">2</td><td data-v="0.00003148">0.00%</td><td data-v="10609.8">10,610</td><td data-v="0.1536">0.154</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>white vehicle</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="237">237</td><td data-v="16">16</td><td data-v="0.00338687">0.34%</td><td data-v="16916.0">16,916</td><td data-v="0.7617">0.762</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>black vehicle</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="203">203</td><td data-v="15">15</td><td data-v="0.00180003">0.18%</td><td data-v="5644.0">5,644</td><td data-v="0.1868">0.187</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>silver vehicle</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="25">25</td><td data-v="14">14</td><td data-v="0.00026839">0.03%</td><td data-v="14451.5">14,452</td><td data-v="0.8810">0.881</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>gray vehicle</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="33">33</td><td data-v="13">13</td><td data-v="0.00048906">0.05%</td><td data-v="20154.0">20,154</td><td data-v="0.8044">0.804</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>red vehicle</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="48">48</td><td data-v="11">11</td><td data-v="0.00040837">0.04%</td><td data-v="6041.2">6,041</td><td data-v="0.2329">0.233</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>blue vehicle</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="28">28</td><td data-v="11">11</td><td data-v="0.00020255">0.02%</td><td data-v="3788.5">3,788</td><td data-v="0.2381">0.238</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>yellow vehicle</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="17">17</td><td data-v="9">9</td><td data-v="0.00010850">0.01%</td><td data-v="6313.5">6,314</td><td data-v="0.3195">0.319</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>orange vehicle</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="25">25</td><td data-v="10">10</td><td data-v="0.00019264">0.02%</td><td data-v="6282.0">6,282</td><td data-v="0.2296">0.230</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>green vehicle</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="8">8</td><td data-v="5">5</td><td data-v="0.00005021">0.01%</td><td data-v="7088.8">7,089</td><td data-v="0.3677">0.368</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>white cargo truck</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="27">27</td><td data-v="6">6</td><td data-v="0.00091902">0.09%</td><td data-v="41477.5">41,478</td><td data-v="0.8888">0.889</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>blue cargo truck</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="2">2</td><td data-v="2">2</td><td data-v="0.00005016">0.01%</td><td data-v="33156.5">33,156</td><td data-v="0.8211">0.821</td></tr>
<tr class="zero" data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>commercial bus</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>tractor</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="3">3</td><td data-v="2">2</td><td data-v="0.00004634">0.00%</td><td data-v="21901.0">21,901</td><td data-v="0.1648">0.165</td></tr>
<tr data-cat="Vehicles &amp; Transportation by Color" data-grp="vehicle"><td>excavator</td><td>Vehicles &amp; Transportation by Color</td><td>vehicle</td><td data-v="7">7</td><td data-v="6">6</td><td data-v="0.00009973">0.01%</td><td data-v="14172.0">14,172</td><td data-v="0.4863">0.486</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="stored material"><td>blue plastic drum</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>stored material</td><td data-v="22">22</td><td data-v="6">6</td><td data-v="0.00019449">0.02%</td><td data-v="1395.5">1,396</td><td data-v="0.1420">0.142</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="stored material"><td>orange plastic barrel</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>stored material</td><td data-v="53">53</td><td data-v="9">9</td><td data-v="0.00008698">0.01%</td><td data-v="1405.0">1,405</td><td data-v="0.1450">0.145</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="stored material"><td>yellow plastic container</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>stored material</td><td data-v="123">123</td><td data-v="12">12</td><td data-v="0.00027882">0.03%</td><td data-v="1620.0">1,620</td><td data-v="0.2151">0.215</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="stored material"><td>white industrial tank</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>stored material</td><td data-v="42">42</td><td data-v="6">6</td><td data-v="0.00018354">0.02%</td><td data-v="2405.5">2,406</td><td data-v="0.1566">0.157</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="stored material"><td>metallic storage tank</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>stored material</td><td data-v="1">1</td><td data-v="1">1</td><td data-v="0.00002943">0.00%</td><td data-v="38908.5">38,908</td><td data-v="0.2450">0.245</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="stored material"><td>blue waterproof tarp</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>stored material</td><td data-v="111">111</td><td data-v="9">9</td><td data-v="0.00151746">0.15%</td><td data-v="7970.5">7,970</td><td data-v="0.3540">0.354</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="stored material"><td>green waterproof tarp</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>stored material</td><td data-v="65">65</td><td data-v="8">8</td><td data-v="0.00296029">0.30%</td><td data-v="7204.5">7,204</td><td data-v="0.3242">0.324</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="stored material"><td>black protective sheet</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>stored material</td><td data-v="55">55</td><td data-v="9">9</td><td data-v="0.00133438">0.13%</td><td data-v="11098.0">11,098</td><td data-v="0.1801">0.180</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="stored material"><td>white canvas canopy</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>stored material</td><td data-v="64">64</td><td data-v="11">11</td><td data-v="0.00091031">0.09%</td><td data-v="4894.0">4,894</td><td data-v="0.2169">0.217</td></tr>
<tr class="zero" data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="building"><td>plastic greenhouse</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>building</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr class="zero" data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="building"><td>vinyl greenhouse tunnel</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>building</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr class="zero" data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="building"><td>greenhouse roof</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>building</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="fence"><td>gray metal fence</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>fence</td><td data-v="46">46</td><td data-v="14">14</td><td data-v="0.00061087">0.06%</td><td data-v="9764.0">9,764</td><td data-v="0.1678">0.168</td></tr>
<tr class="zero" data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="fence"><td>green wire fence</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>fence</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="fence"><td>red roadside barrier</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>fence</td><td data-v="17">17</td><td data-v="2">2</td><td data-v="0.00001570">0.00%</td><td data-v="742.0">742</td><td data-v="0.1420">0.142</td></tr>
<tr class="nogroup" data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="없음"><td>yellow safety cone</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>없음</td><td data-v="6">6</td><td data-v="4">4</td><td data-v="0.00000716">0.00%</td><td data-v="1676.5">1,676</td><td data-v="0.2962">0.296</td></tr>
<tr class="nogroup" data-cat="Industrial Materials, Storage &amp; Objects by Color" data-grp="없음"><td>orange traffic cone</td><td>Industrial Materials, Storage &amp; Objects by Color</td><td>없음</td><td data-v="114">114</td><td data-v="8">8</td><td data-v="0.00003171">0.00%</td><td data-v="303.5">304</td><td data-v="0.1416">0.142</td></tr>
<tr data-cat="Road Infrastructure" data-grp="retaining wall"><td>retaining wall</td><td>Road Infrastructure</td><td>retaining wall</td><td data-v="6">6</td><td data-v="3">3</td><td data-v="0.00080077">0.08%</td><td data-v="147961.2">147,961</td><td data-v="0.3340">0.334</td></tr>
<tr data-cat="Road Infrastructure" data-grp="retaining wall"><td>concrete retaining wall</td><td>Road Infrastructure</td><td>retaining wall</td><td data-v="92">92</td><td data-v="14">14</td><td data-v="0.00194630">0.19%</td><td data-v="14316.0">14,316</td><td data-v="0.1693">0.169</td></tr>
<tr data-cat="Road Infrastructure" data-grp="retaining wall"><td>gabion wall</td><td>Road Infrastructure</td><td>retaining wall</td><td data-v="2">2</td><td data-v="2">2</td><td data-v="0.00007213">0.01%</td><td data-v="47682.5">47,682</td><td data-v="0.6845">0.684</td></tr>
<tr data-cat="Road Infrastructure" data-grp="fence"><td>guardrail</td><td>Road Infrastructure</td><td>fence</td><td data-v="47">47</td><td data-v="13">13</td><td data-v="0.00086195">0.09%</td><td data-v="18291.5">18,292</td><td data-v="0.2035">0.204</td></tr>
<tr data-cat="Road Infrastructure" data-grp="fence"><td>metal guardrail</td><td>Road Infrastructure</td><td>fence</td><td data-v="658">658</td><td data-v="16">16</td><td data-v="0.00747726">0.75%</td><td data-v="5725.8">5,726</td><td data-v="0.1700">0.170</td></tr>
<tr class="zero" data-cat="Road Infrastructure" data-grp="slope"><td>road embankment slope</td><td>Road Infrastructure</td><td>slope</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr data-cat="Road Infrastructure" data-grp="slope"><td>cut slope</td><td>Road Infrastructure</td><td>slope</td><td data-v="25">25</td><td data-v="9">9</td><td data-v="0.00294042">0.29%</td><td data-v="59922.0">59,922</td><td data-v="0.1410">0.141</td></tr>
<tr class="zero" data-cat="Road Infrastructure" data-grp="slope"><td>riprap slope</td><td>Road Infrastructure</td><td>slope</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr class="zero" data-cat="Road Infrastructure" data-grp="road"><td>bridge</td><td>Road Infrastructure</td><td>road</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr data-cat="Road Infrastructure" data-grp="structure"><td>culvert</td><td>Road Infrastructure</td><td>structure</td><td data-v="9">9</td><td data-v="3">3</td><td data-v="0.00000804">0.00%</td><td data-v="1195.5">1,196</td><td data-v="0.1054">0.105</td></tr>
<tr data-cat="Road Infrastructure" data-grp="structure"><td>utility pole</td><td>Road Infrastructure</td><td>structure</td><td data-v="608">608</td><td data-v="16">16</td><td data-v="0.00234281">0.23%</td><td data-v="1668.5">1,668</td><td data-v="0.2006">0.201</td></tr>
<tr data-cat="Road Infrastructure" data-grp="structure"><td>streetlight pole</td><td>Road Infrastructure</td><td>structure</td><td data-v="372">372</td><td data-v="16">16</td><td data-v="0.00089454">0.09%</td><td data-v="1817.8">1,818</td><td data-v="0.2200">0.220</td></tr>
<tr data-cat="Road Infrastructure" data-grp="structure"><td>road sign</td><td>Road Infrastructure</td><td>structure</td><td data-v="358">358</td><td data-v="16">16</td><td data-v="0.00135648">0.14%</td><td data-v="2304.8">2,305</td><td data-v="0.2210">0.221</td></tr>
<tr class="zero" data-cat="Water" data-grp="water"><td>water surface</td><td>Water</td><td>water</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr data-cat="Water" data-grp="water"><td>pond</td><td>Water</td><td>water</td><td data-v="9">9</td><td data-v="4">4</td><td data-v="0.00056034">0.06%</td><td data-v="49909.0">49,909</td><td data-v="0.4575">0.458</td></tr>
<tr data-cat="Water" data-grp="water"><td>reservoir</td><td>Water</td><td>water</td><td data-v="1">1</td><td data-v="1">1</td><td data-v="0.00032781">0.03%</td><td data-v="433374.0">433,374</td><td data-v="0.3949">0.395</td></tr>
<tr data-cat="Water" data-grp="water"><td>stream</td><td>Water</td><td>water</td><td data-v="20">20</td><td data-v="6">6</td><td data-v="0.00088044">0.09%</td><td data-v="38496.2">38,496</td><td data-v="0.3507">0.351</td></tr>
<tr data-cat="Water" data-grp="water"><td>irrigation canal</td><td>Water</td><td>water</td><td data-v="22">22</td><td data-v="6">6</td><td data-v="0.00045042">0.05%</td><td data-v="18064.8">18,065</td><td data-v="0.2014">0.201</td></tr>
<tr data-cat="Water" data-grp="water"><td>drainage ditch</td><td>Water</td><td>water</td><td data-v="6">6</td><td data-v="5">5</td><td data-v="0.00006412">0.01%</td><td data-v="6352.5">6,352</td><td data-v="0.1434">0.143</td></tr>
<tr data-cat="Boundary &amp; Filler" data-grp="vegetation"><td>overgrown weeds</td><td>Boundary &amp; Filler</td><td>vegetation</td><td data-v="1190">1,190</td><td data-v="16">16</td><td data-v="0.02730425">2.73%</td><td data-v="4305.5">4,306</td><td data-v="0.1722">0.172</td></tr>
<tr data-cat="Boundary &amp; Filler" data-grp="vegetation"><td>shrub</td><td>Boundary &amp; Filler</td><td>vegetation</td><td data-v="1353">1,353</td><td data-v="16">16</td><td data-v="0.02944237">2.94%</td><td data-v="6433.0">6,433</td><td data-v="0.2057">0.206</td></tr>
<tr data-cat="Boundary &amp; Filler" data-grp="slope"><td>grass covered embankment</td><td>Boundary &amp; Filler</td><td>slope</td><td data-v="61">61</td><td data-v="11">11</td><td data-v="0.02437004">2.44%</td><td data-v="156737.0">156,737</td><td data-v="0.4462">0.446</td></tr>
<tr data-cat="Boundary &amp; Filler" data-grp="ground"><td>earth bank</td><td>Boundary &amp; Filler</td><td>ground</td><td data-v="6">6</td><td data-v="3">3</td><td data-v="0.00010854">0.01%</td><td data-v="4643.0">4,643</td><td data-v="0.1147">0.115</td></tr>
<tr data-cat="Boundary &amp; Filler" data-grp="ground"><td>dirt path</td><td>Boundary &amp; Filler</td><td>ground</td><td data-v="389">389</td><td data-v="16">16</td><td data-v="0.02134826">2.13%</td><td data-v="23083.5">23,084</td><td data-v="0.2458">0.246</td></tr>
<tr data-cat="Ripening &amp; Bare Field" data-grp="farmland"><td>ripening rice field</td><td>Ripening &amp; Bare Field</td><td>farmland</td><td data-v="27">27</td><td data-v="10">10</td><td data-v="0.03871443">3.87%</td><td data-v="1393140.0">1,393,140</td><td data-v="0.9576">0.958</td></tr>
<tr class="zero" data-cat="Ripening &amp; Bare Field" data-grp="vegetation"><td>yellow grass field</td><td>Ripening &amp; Bare Field</td><td>vegetation</td><td data-v="0">0</td><td data-v="0">-</td><td data-v="0.00000000">0.00%</td><td data-v="0.0">0</td><td data-v="0.0000">0.000</td></tr>
<tr data-cat="Ripening &amp; Bare Field" data-grp="farmland"><td>harvested field</td><td>Ripening &amp; Bare Field</td><td>farmland</td><td data-v="118">118</td><td data-v="14">14</td><td data-v="0.00360974">0.36%</td><td data-v="15127.0">15,127</td><td data-v="0.1392">0.139</td></tr>
<tr data-cat="Ripening &amp; Bare Field" data-grp="road"><td>gravel shoulder</td><td>Ripening &amp; Bare Field</td><td>road</td><td data-v="22">22</td><td data-v="12">12</td><td data-v="0.00163846">0.16%</td><td data-v="55625.5">55,626</td><td data-v="0.1586">0.159</td></tr>
</tbody></table></div>
<h2>설정</h2><div class="par">
<b>gap_px</b><span><code>2</code></span>
<b>epsilon_px</b><span><code>1.5</code></span>
<b>기준 런</b><span><code>output/sam3/tune_t64 (확정 설정 8장) / 기준선은 output/sam3/YYX_run 69장</code></span>
<b>프롬프트 파일</b><span><code>prompts/discovery_v4.txt (94개). 기준선은 discovery_v1.txt (85개)</code></span>
<b>집합 정의</b><span><code>configs/merge_groups_v4.txt (11집합 + 집합 밖 3종: factory structure, 콘 2종)</code></span>
<b>타일</b><span><code>6×4 = 24개, overlap 10%, 타일 크기 1365×1365. 기준선은 4×3 (2048×1820)</code></span>
<b>검출 설정</b><span><code>conf 0.10 · NMS iou 0.40 · 같은 라벨 병합 gap 8px · --wide-in-tiles. 기준선 conf 0.25, 통짜 분리</code></span>
<b>공정 보기 사진</b><span><code>확정 설정 8장 (기준선 무라벨 3.5~52.1% 분포에서 고름)</code></span>
<b>확정 후보 설정</b><span><code>prompts/discovery_v4.txt (94개) · configs/merge_groups_v4.txt · --wide-in-tiles · --cols 6 --rows 4 · --conf 0.10 · NMS 0.40 · --merge (gap 8px) · 병합 gap 2px</code></span>
<b>claim 규칙</b><span><code>configs/claim_rules.txt — vehicle : 0.5 : 지붕 11종 + 방수포/보호시트</code></span>
</div>
</div>
<script>
const tb=document.querySelector('#t tbody');
document.querySelectorAll('#t th').forEach((th,i)=>{th.onclick=()=>{
const rows=[...tb.rows], asc=th.dataset.asc!=='1';
th.dataset.asc=asc?'1':'0';
rows.sort((a,b)=>{
const x=a.cells[i].dataset.v, y=b.cells[i].dataset.v;
if(x!==undefined&&y!==undefined) return (asc?1:-1)*(parseFloat(x)-parseFloat(y));
return (asc?1:-1)*a.cells[i].textContent.localeCompare(b.cells[i].textContent,'ko');
});
rows.forEach(r=>tb.appendChild(r));
};});
function apply(){
const c=fcat.value,g=fgrp.value,q=fq.value.toLowerCase(),z=fzero.checked;
[...tb.rows].forEach(r=>{
const ok=(!c||r.dataset.cat===c)&&(!g||r.dataset.grp===g)
&&(!q||r.cells[0].textContent.toLowerCase().includes(q))
&&(!z||r.classList.contains('zero'));
r.hidden=!ok;
});
}
[fcat,fgrp,fzero].forEach(e=>e.onchange=apply); fq.oninput=apply;
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
document.querySelectorAll('.tab').forEach(x=>x.setAttribute('aria-selected',x===b));
document.querySelectorAll('.stage').forEach(s=>s.hidden=s.dataset.i!==b.dataset.i);
});
</script>
+128
View File
@@ -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
+136
View File
@@ -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
+24
View File
@@ -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
+118
View File
@@ -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()
+331
View File
@@ -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'<span class="badge" style="color:{fg};background:{bg}">'
f'{esc(label)}</span>')
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'<title>{esc(sv["title"])}</title>')
A("""<style>
:root{--bg:#fbfbfa;--fg:#1d1f21;--dim:#6b7076;--line:#e2e4e6;--card:#fff;--accent:#2f6f9f}
:root:not([data-theme="light"]){}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
--bg:#16181a;--fg:#e6e8ea;--dim:#9aa1a8;--line:#2c3034;--card:#1d2023;--accent:#79b8e8}}
:root[data-theme="dark"]{--bg:#16181a;--fg:#e6e8ea;--dim:#9aa1a8;--line:#2c3034;--card:#1d2023;--accent:#79b8e8}
body{background:var(--bg);color:var(--fg);font:14px/1.55 -apple-system,"Segoe UI",
"Malgun Gothic",sans-serif;margin:0;padding:28px 22px 60px}
.wrap{max-width:1180px;margin:0 auto}
h1{font-size:21px;margin:0 0 4px}
h2{font-size:15px;margin:30px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--line)}
.sub{color:var(--dim);margin:0 0 2px}
.badge{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;
font-weight:600;white-space:nowrap}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:11px 13px}
.card .k{color:var(--dim);font-size:12px}
.card .v{font-size:19px;font-weight:600;margin-top:2px}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{padding:5px 9px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2),
th:nth-child(3),td:nth-child(3){text-align:left}
th{background:var(--card);position:sticky;top:0;cursor:pointer;font-weight:600;
color:var(--dim);border-bottom:2px solid var(--line)}
tbody tr:hover{background:var(--card)}
tr.zero td{color:var(--dim)}
tr.nogroup td:nth-child(3){color:#c0392b;font-weight:600}
.scroll{overflow-x:auto;max-height:640px;overflow-y:auto;border:1px solid var(--line);
border-radius:8px}
.bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;min-width:60px}
.bar i{display:block;height:100%;background:var(--accent)}
.step{display:flex;gap:11px;align-items:flex-start;padding:9px 0;border-bottom:1px solid var(--line)}
.step .id{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dim);min-width:30px}
.step .d{color:var(--dim);font-size:13px}
.issue{padding:9px 0;border-bottom:1px solid var(--line);display:flex;gap:11px}
.par{display:grid;grid-template-columns:max-content 1fr;gap:3px 16px;font-size:13px}
.par b{color:var(--dim);font-weight:500}
code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
.controls{display:flex;gap:10px;flex-wrap:wrap;margin:0 0 10px}
.tabs{display:flex;gap:6px;margin:0 0 10px;flex-wrap:wrap}
.tab{background:var(--card);border:1px solid var(--line);border-radius:6px;
padding:6px 13px;cursor:pointer;font:inherit;font-size:13px;color:var(--fg)}
.tab[aria-selected="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
.stage{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--card)}
.stage iframe{display:block;width:100%;height:620px;border:0}
.flow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px;
color:var(--dim);font-size:13px}
.flow b{color:var(--fg);font-weight:600}
.flow .ar{color:var(--accent);font-weight:700}
.d-up{color:#1a7f4b}.d-dn{color:#c0392b}
select,input{background:var(--card);color:var(--fg);border:1px solid var(--line);
border-radius:6px;padding:5px 8px;font:inherit;font-size:13px}
</style>""")
A('<div class="wrap">')
A(f'<h1>{esc(sv["title"])}</h1>')
A(f'<p class="sub">{esc(sv.get("note", ""))}</p>')
A(f'<p class="sub" style="font-size:12px">생성 {time.strftime("%Y-%m-%d %H:%M")} · '
f'집계 <code>{esc(os.path.basename(st["run_dir"]))}</code></p>')
A('<h2>실측</h2><div class="cards">')
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'<div class="card"><div class="k">{esc(k)}</div><div class="v">{esc(v)}</div></div>')
A('</div>')
if cv:
per = sorted(cv["per_photo"], key=lambda r: r["covered"])
un = [100 * (1 - r["covered"]) for r in per]
A('<h2>미검출 — 무라벨 화면%</h2>')
A(f'<p class="sub" style="font-size:12px">마스크 합집합 기준(겹침 제거), '
f'축척 1/{1 / cv["scale"]:.0f}. 목표 5% 이하.</p>')
A('<div class="cards">')
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'<div class="card"><div class="k">{esc(k)}</div>'
f'<div class="v">{esc(v)}</div></div>')
A('</div>')
A('<div class="scroll" style="max-height:280px;margin-top:10px"><table><thead><tr>'
'<th>사진</th><th>폴리곤</th><th>무라벨%</th><th></th></tr></thead><tbody>')
for r in per:
u = 100 * (1 - r["covered"])
A(f'<tr><td>{esc(r["photo"].replace("_multi.json", ""))}</td>'
f'<td data-v="{r["segments"]}">{r["segments"]:,}</td>'
f'<td data-v="{u:.3f}">{u:.1f}%</td>'
f'<td><div class="bar"><i style="width:{min(u, 100):.1f}%;'
f'background:{"#c0392b" if u > 5 else "#1a7f4b"}"></i></div></td></tr>')
A('</tbody></table></div>')
if stages:
A('<h2>공정 보기 — 같은 사진, 단계별</h2>')
if af:
A(f'<div class="flow"><b>SAM 검출 {st["total_polygons"]:,} 폴리곤</b>'
f'<span>({sum(1 for r in st["rows"] if r["polygons"])} 라벨)</span>'
f'<span class="ar">→ 집합 병합 →</span>'
f'<b>{af["total_polygons"]:,} 폴리곤</b>'
f'<span>({sum(1 for r in af["rows"] if r["polygons"])} 집합)</span>'
f'<span>· 사진 {st["photos"]}장 기준</span></div>')
A('<div class="tabs">')
for i, (name, _) in enumerate(stages):
A(f'<button class="tab" role="tab" data-i="{i}" '
f'aria-selected="{"true" if i == 0 else "false"}">{esc(name)}</button>')
A('</div>')
for i, (_, path) in enumerate(stages):
A(f'<div class="stage" data-i="{i}"{"" if i == 0 else " hidden"}>'
f'<iframe src="{esc(path)}" loading="lazy" title="stage{i}"></iframe></div>')
A('<p class="sub" style="font-size:12px">왼쪽 목록에서 라벨을 켜고 끈다. '
'휠 확대, 드래그 이동. 새 창: '
+ " · ".join(f'<a href="{esc(p)}">{esc(n)}</a>' for n, p in stages) + '</p>')
A('<h2>단계</h2>')
for s in sv.get("steps", []):
A(f'<div class="step"><span class="id">{esc(s["id"])}</span>{badge(s["state"])}'
f'<div><div>{esc(s["name"])}</div>'
f'<div class="d">{esc(s.get("detail", ""))}</div></div></div>')
A('<h2>이슈 · 기각된 시도</h2>')
for i in sv.get("issues", []):
A(f'<div class="issue">{badge(i["state"])}<div>{esc(i["text"])}</div></div>')
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('<h2>집합별 합계</h2><div class="scroll"><table><thead><tr>'
'<th>집합</th><th>프롬프트(검출>0/전체)</th><th>폴리곤</th><th>폴리곤%</th>'
'<th>면적%</th>')
if af:
A('<th>병합 후 폴리곤</th><th>줄어든 비율</th><th>병합 후 면적%</th>')
A('<th></th></tr></thead><tbody>')
for g, v in sorted(gsum.items(), key=lambda t: -t[1]["poly"]):
share = 100 * v["area"] / tot_area
A(f'<tr><td>{esc(g)}</td><td>{v["used"]} / {v["prompts"]}</td>'
f'<td>{v["poly"]:,}</td><td>{100 * v["poly"] / tot_poly:.1f}%</td>'
f'<td>{share:.2f}%</td>')
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'<td>{n:,}</td><td class="{cls}">{drop:.0f}%</td>'
f'<td>{100 * aarea.get(g, 0.0) / atot:.2f}%</td>')
A(f'<td><div class="bar"><i style="width:{min(share, 100):.1f}%"></i></div>'
f'</td></tr>')
A('</tbody></table></div>')
if af:
A('<p class="sub" style="font-size:12px">병합 후 면적%는 병합 후 총검출면적 기준. '
'겹침을 빼지 않은 다각형 넓이 합이다.</p>')
cats = sorted({r["category"] for r in rows})
groups = sorted({r["group"] for r in rows})
A('<h2>카테고리 · 프롬프트별 검출</h2>')
A('<div class="controls">'
'<select id="fcat"><option value="">카테고리 전체</option>'
+ "".join(f'<option>{esc(c)}</option>' for c in cats) + '</select>'
'<select id="fgrp"><option value="">집합 전체</option>'
+ "".join(f'<option>{esc(g)}</option>' for g in groups) + '</select>'
'<input id="fq" placeholder="프롬프트 검색">'
'<label style="color:var(--dim)"><input type="checkbox" id="fzero"> 검출 0 만</label>'
'</div>')
A('<div class="scroll"><table id="t"><thead><tr>'
'<th>프롬프트</th><th>카테고리</th><th>집합</th><th>폴리곤</th><th>사진</th>'
'<th>면적%</th><th>중앙 면적 px</th><th>점수 중앙</th></tr></thead><tbody>')
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'<tr{c} data-cat="{esc(r["category"])}" data-grp="{esc(r["group"])}">'
f'<td>{esc(r["prompt"])}</td><td>{esc(r["category"])}</td>'
f'<td>{esc(r["group"])}</td>'
f'<td data-v="{r["polygons"]}">{r["polygons"]:,}</td>'
f'<td data-v="{r["photos"]}">{r["photos"] or dash}</td>'
f'<td data-v="{r["area_share"]:.8f}">'
f'{100 * r["area_share"]:.2f}%</td>'
f'<td data-v="{r["area_median_px"]:.1f}">'
f'{r["area_median_px"]:,.0f}</td>'
f'<td data-v="{r["score_median"]:.4f}">'
f'{r["score_median"]:.3f}</td></tr>')
A('</tbody></table></div>')
if sv.get("params"):
A('<h2>설정</h2><div class="par">')
for k, v in sv["params"].items():
A(f'<b>{esc(k)}</b><span><code>{esc(v)}</code></span>')
A('</div>')
A('</div>')
A("""<script>
const tb=document.querySelector('#t tbody');
document.querySelectorAll('#t th').forEach((th,i)=>{th.onclick=()=>{
const rows=[...tb.rows], asc=th.dataset.asc!=='1';
th.dataset.asc=asc?'1':'0';
rows.sort((a,b)=>{
const x=a.cells[i].dataset.v, y=b.cells[i].dataset.v;
if(x!==undefined&&y!==undefined) return (asc?1:-1)*(parseFloat(x)-parseFloat(y));
return (asc?1:-1)*a.cells[i].textContent.localeCompare(b.cells[i].textContent,'ko');
});
rows.forEach(r=>tb.appendChild(r));
};});
function apply(){
const c=fcat.value,g=fgrp.value,q=fq.value.toLowerCase(),z=fzero.checked;
[...tb.rows].forEach(r=>{
const ok=(!c||r.dataset.cat===c)&&(!g||r.dataset.grp===g)
&&(!q||r.cells[0].textContent.toLowerCase().includes(q))
&&(!z||r.classList.contains('zero'));
r.hidden=!ok;
});
}
[fcat,fgrp,fzero].forEach(e=>e.onchange=apply); fq.oninput=apply;
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
document.querySelectorAll('.tab').forEach(x=>x.setAttribute('aria-selected',x===b));
document.querySelectorAll('.stage').forEach(s=>s.hidden=s.dataset.i!==b.dataset.i);
});
</script>""")
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()
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python
"""후처리 단계를 같은 사진 위에서 갈아끼워 비교하는 뷰어.
make_viewer.py 는 한 벌의 결과를 본다. 이건 여러 단계(검출 → claim → 병합)를
한 화면에서 전환한다. 사진은 그대로 두고 폴리곤만 바뀌므로 무엇이 달라졌는지
눈으로 바로 잡힌다.
바뀐 것만 보기: claim 단계는 라벨이 재배정된 폴리곤(claimed_from), 병합 단계는
둘 이상이 합쳐진 폴리곤(merged_from 길이 2 이상)을 강조한다.
python tools/make_stage_viewer.py \
--stage "1. 검출=output/sam3/tune_t64/*_multi.json" \
--stage "2. claim=output/sam3/tune_t64/*_multi_claimed.json" \
--stage "3. 병합=output/sam3/tune_t64/*_multi_merged.json" \
--output docs/pilot/theater_v4/stages.html
"""
import argparse
import colorsys
import glob
import hashlib
import json
import os
import re
from collections import Counter, OrderedDict
import cv2
import numpy as np
def stem_of(path):
"""DJI_..._multi_merged.json → DJI_... (단계 접미사를 벗긴다)"""
s = os.path.basename(path)
return re.sub(r"_multi(_[a-z0-9]+)?\.json$", "", s)
def color_for(label):
h = int(hashlib.md5(label.encode("utf-8")).hexdigest()[:8], 16)
r, g, b = colorsys.hsv_to_rgb((h % 360) / 360, 0.62, 0.98)
return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
HTML = """<title>__TITLE__</title>
<style>
:root{--bg:#0d0f10;--fg:#e6e9ea;--dim:#8b969b;--line:#2c3336;--card:#171c1e;--accent:#4da3ff}
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 -apple-system,
"Segoe UI","Malgun Gothic",sans-serif;height:100vh;display:flex;overflow:hidden}
#side{width:250px;flex:none;background:var(--card);border-right:1px solid var(--line);
display:flex;flex-direction:column;overflow:hidden}
#side h1{font-size:13px;margin:0;padding:11px 13px;border-bottom:1px solid var(--line)}
.pager{display:flex;gap:5px;padding:9px 11px;border-bottom:1px solid var(--line)}
.pager select{flex:1;background:#0f1416;color:var(--fg);border:1px solid var(--line);
border-radius:4px;padding:4px;font-size:12px}
button{background:#232a2d;color:var(--fg);border:1px solid var(--line);border-radius:4px;
padding:4px 9px;cursor:pointer;font:inherit;font-size:12px}
button:hover{background:#2e3639}
.stages{padding:10px 11px;border-bottom:1px solid var(--line);display:flex;
flex-direction:column;gap:5px}
.stages button{text-align:left;padding:7px 10px;font-size:13px}
.stages button[aria-selected="true"]{background:var(--accent);border-color:var(--accent);
color:#04121f;font-weight:700}
.stages .n{float:right;opacity:.75;font-variant-numeric:tabular-nums}
.opts{padding:9px 11px;border-bottom:1px solid var(--line);display:flex;
flex-direction:column;gap:5px;color:var(--dim)}
.opts label{display:flex;gap:7px;align-items:center;cursor:pointer}
#legend{overflow-y:auto;padding:7px 11px;flex:1}
#legend .row{display:flex;gap:7px;align-items:center;padding:2px 0}
#legend .sw{width:11px;height:11px;border-radius:2px;flex:none}
#legend .lb{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#legend .ct{color:var(--dim);font-variant-numeric:tabular-nums}
#view{flex:1;position:relative;overflow:hidden;background:#000}
canvas{position:absolute;top:0;left:0}
#hud{position:absolute;left:12px;top:12px;background:#000b;padding:6px 11px;
border-radius:5px;font-size:12px;line-height:1.7;pointer-events:none}
#hud b{color:var(--accent)}
#hint{position:absolute;right:12px;bottom:12px;background:#000b;padding:5px 9px;
border-radius:5px;font-size:11px;color:var(--dim)}
#pick{position:absolute;left:12px;bottom:12px;background:#000c;padding:6px 11px;
border-radius:5px;font-size:12px;line-height:1.6;pointer-events:none;max-width:60%}
#pick .dim{color:var(--dim)}
</style>
<div id="side">
<h1>__TITLE__</h1>
<div class="pager">
<button onclick="go(-1)">◀</button>
<select id="sel" onchange="setPage(+this.value)"></select>
<button onclick="go(1)">▶</button>
</div>
<div class="stages" id="stages"></div>
<div class="opts">
<label><input type="checkbox" id="fill" checked> 면 채우기</label>
<label><input type="checkbox" id="only"> 바뀐 것만</label>
<label><input type="checkbox" id="dim"> 사진 어둡게</label>
<button onclick="fit()">화면 맞춤</button>
</div>
<div id="legend"></div>
</div>
<div id="view"><canvas id="cv"></canvas>
<div id="hud"></div>
<div id="pick"></div>
<div id="hint">1~9 단계 · ←→ 사진 · 휠 확대 · 드래그 이동 · 폴리곤 위에 커서를 올리면 정보</div>
</div>
<script>
const DATA = __DATA__;
const img = new Image();
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const view = document.getElementById('view');
let page = 0, stage = 0, scale = 1, ox = 0, oy = 0;
function curr() { return DATA.pages[page].stages[stage]; }
function changed(s) { return s.c === 1; }
function buildStages() {
const box = document.getElementById('stages');
box.innerHTML = '';
DATA.stages.forEach((name, i) => {
const b = document.createElement('button');
const st = DATA.pages[page].stages[i];
b.innerHTML = name + `<span class="n">${st ? st.p.length.toLocaleString() : '-'}</span>`;
b.setAttribute('aria-selected', i === stage);
b.onclick = () => { stage = i; buildStages(); draw(); };
box.appendChild(b);
});
}
function buildLegend() {
const st = curr(), c = {};
for (const s of st.p) c[s.l] = (c[s.l] || 0) + 1;
const el = document.getElementById('legend');
el.innerHTML = '';
Object.entries(c).sort((a, b) => b[1] - a[1]).forEach(([lb, n]) => {
const d = document.createElement('div');
d.className = 'row';
d.innerHTML = `<span class="sw" style="background:${DATA.colors[lb] || '#888'}"></span>`
+ `<span class="lb">${lb}</span><span class="ct">${n.toLocaleString()}</span>`;
el.appendChild(d);
});
}
function draw() {
const st = curr();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.drawImage(img, ox, oy, img.width * scale, img.height * scale);
if (document.getElementById('dim').checked) {
ctx.fillStyle = 'rgba(0,0,0,.55)';
ctx.fillRect(ox, oy, img.width * scale, img.height * scale);
}
const fill = document.getElementById('fill').checked;
const only = document.getElementById('only').checked;
let nch = 0;
for (const s of st.p) {
const ch = changed(s);
if (ch) nch++;
if (only && !ch) continue;
const col = DATA.colors[s.l] || '#888';
ctx.beginPath();
s.p.forEach((pt, i) => {
const x = ox + pt[0] * scale, y = oy + pt[1] * scale;
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
});
ctx.closePath();
if (fill) { ctx.fillStyle = col + (ch ? 'aa' : '55'); ctx.fill(); }
ctx.strokeStyle = ch ? '#ff3b30' : col;
ctx.lineWidth = ch ? 2.5 : 1.2;
ctx.stroke();
}
document.getElementById('hud').innerHTML =
`${DATA.pages[page].name}<br>단계 <b>${DATA.stages[stage]}</b> · 폴리곤 <b>${st.p.length.toLocaleString()}</b>`
+ (nch ? ` · 바뀐 것 <b>${nch.toLocaleString()}</b>` : '');
}
function fit() {
scale = Math.min(view.clientWidth / img.width, view.clientHeight / img.height);
ox = (view.clientWidth - img.width * scale) / 2;
oy = (view.clientHeight - img.height * scale) / 2;
draw();
}
function setPage(i) {
page = (i + DATA.pages.length) % DATA.pages.length;
document.getElementById('sel').value = page;
img.onload = () => { buildStages(); buildLegend(); fit(); };
img.src = DATA.pages[page].img;
}
function go(d) { setPage(page + d); }
function resize() { cv.width = view.clientWidth; cv.height = view.clientHeight; draw(); }
window.addEventListener('resize', () => { resize(); fit(); });
view.addEventListener('wheel', e => {
e.preventDefault();
const f = e.deltaY < 0 ? 1.15 : 1 / 1.15;
ox = e.offsetX - (e.offsetX - ox) * f;
oy = e.offsetY - (e.offsetY - oy) * f;
scale *= f; draw();
}, { passive: false });
let drag = null;
view.addEventListener('mousedown', e => drag = [e.clientX - ox, e.clientY - oy]);
window.addEventListener('mouseup', () => drag = null);
window.addEventListener('mousemove', e => {
if (drag) { ox = e.clientX - drag[0]; oy = e.clientY - drag[1]; draw(); return; }
hover(e);
});
function inside(pt, poly) {
let hit = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i], [xj, yj] = poly[j];
if ((yi > pt[1]) !== (yj > pt[1])
&& pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi) hit = !hit;
}
return hit;
}
function area(poly) {
let a = 0;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++)
a += poly[j][0] * poly[i][1] - poly[i][0] * poly[j][1];
return Math.abs(a) / 2;
}
function hover(e) {
const r = view.getBoundingClientRect();
const x = (e.clientX - r.left - ox) / scale, y = (e.clientY - r.top - oy) / scale;
const st = curr();
let best = null, bestA = Infinity;
for (const s of st.p) {
if (!inside([x, y], s.p)) continue;
const a = area(s.p);
if (a < bestA) { best = s; bestA = a; } // 가장 작은 것 = 가장 위
}
const el = document.getElementById('pick');
if (!best) { el.innerHTML = `<span class="dim">커서 (${Math.round(x)}, ${Math.round(y)})</span>`; return; }
let why = '';
if (best.f) why = `<br><span class="dim">원래</span> ${best.f} <span class="dim">→ 뺏김</span>`;
else if (best.m.length) why = `<br><span class="dim">합쳐진 것</span> ${best.m.join(' + ')}`;
el.innerHTML = `<b style="color:${DATA.colors[best.l] || '#888'}">${best.l}</b>`
+ ` <span class="dim">(${Math.round(x)}, ${Math.round(y)})</span>${why}`;
}
window.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft') go(-1);
else if (e.key === 'ArrowRight') go(1);
else if (/^[1-9]$/.test(e.key)) {
const i = +e.key - 1;
if (i < DATA.stages.length) { stage = i; buildStages(); buildLegend(); draw(); }
}
});
['fill', 'only', 'dim'].forEach(id =>
document.getElementById(id).onchange = draw);
const sel = document.getElementById('sel');
DATA.pages.forEach((p, i) => {
const o = document.createElement('option');
o.value = i; o.textContent = `${i + 1}/${DATA.pages.length} ${p.name}`;
sel.appendChild(o);
});
resize();
setPage(0);
</script>"""
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--stage", action="append", required=True, metavar="이름=글롭",
help="단계 하나. 순서대로 여러 번 준다")
ap.add_argument("--output", required=True)
ap.add_argument("--images", help="원본 이미지 폴더 (JSON 의 source_image 가 없을 때)")
ap.add_argument("--max-size", type=int, default=2000)
ap.add_argument("--title", default=None)
args = ap.parse_args()
stages = []
for spec in args.stage:
if "=" not in spec:
raise SystemExit(f"--stage 형식은 '이름=글롭': {spec}")
name, pat = spec.split("=", 1)
files = {stem_of(p): p for p in sorted(glob.glob(pat))}
if not files:
raise SystemExit(f"단계 '{name}' 에 해당하는 JSON 없음: {pat}")
stages.append((name, files))
common = set(stages[0][1])
for _, f in stages[1:]:
common &= set(f)
if not common:
raise SystemExit("모든 단계에 공통으로 있는 사진이 없다")
stems = sorted(common)
print(f"단계 {len(stages)}개 · 공통 사진 {len(stems)}")
out_html = os.path.abspath(args.output)
os.makedirs(os.path.dirname(out_html), exist_ok=True)
pages, colors, counts = [], {}, Counter()
for stem in stems:
img_name = stem + "_view.jpg"
img_path = os.path.join(os.path.dirname(out_html), img_name)
if not os.path.isfile(img_path):
with open(stages[0][1][stem], encoding="utf-8") as fh:
src = json.load(fh).get("source_image")
if (not src or not os.path.isfile(src)) and args.images:
for ext in (".JPG", ".jpg", ".png"):
cand = os.path.join(args.images, stem + ext)
if os.path.isfile(cand):
src = cand
break
if not src or not os.path.isfile(src):
raise SystemExit(f"원본 이미지를 못 찾았다: {stem} (--images 로 지정)")
im = cv2.imdecode(np.fromfile(src, dtype=np.uint8), cv2.IMREAD_COLOR)
hh, ww = im.shape[:2]
k = min(1.0, args.max_size / max(hh, ww))
if k < 1.0:
im = cv2.resize(im, (int(ww * k), int(hh * k)), interpolation=cv2.INTER_AREA)
cv2.imencode(".jpg", im, [cv2.IMWRITE_JPEG_QUALITY, 86])[1].tofile(img_path)
print(f" 이미지 생성 {img_name}")
# 좌표 축척은 원본 기준이므로 이미지 크기에서 되계산한다
im = cv2.imdecode(np.fromfile(img_path, dtype=np.uint8), cv2.IMREAD_COLOR)
vh, vw = im.shape[:2]
page_stages = []
for name, files in stages:
with open(files[stem], encoding="utf-8") as fh:
segs = json.load(fh)["segments"]
if not page_stages: # 첫 단계에서 원본 해상도를 잡는다
mx = max((p[0] for s in segs for p in s["points"]), default=vw)
my = max((p[1] for s in segs for p in s["points"]), default=vh)
k = min(vw / max(mx, 1), vh / max(my, 1))
items = []
for s in segs:
lb = s.get("label", "")
colors.setdefault(lb, color_for(lb))
counts[lb] += 1
ch = 1 if (s.get("claimed_from")
or len(s.get("merged_from", []) or []) > 1) else 0
src = s.get("merged_from") or []
items.append({"l": lb, "c": ch,
"f": s.get("claimed_from") or "",
"m": src if len(src) > 1 else [],
"p": [[round(p[0] * k, 1), round(p[1] * k, 1)]
for p in s["points"]]})
page_stages.append({"p": items})
pages.append({"name": stem, "img": img_name, "stages": page_stages})
title = args.title or os.path.splitext(os.path.basename(out_html))[0]
payload = {"stages": [n for n, _ in stages], "pages": pages, "colors": colors}
with open(out_html, "w", encoding="utf-8") as fh:
fh.write(HTML.replace("__TITLE__", title)
.replace("__DATA__", json.dumps(payload, ensure_ascii=False)))
tot = [sum(len(p["stages"][i]["p"]) for p in pages) for i in range(len(stages))]
print(f"뷰어: {args.output}")
for (name, _), n in zip(stages, tot):
print(f" {name:<16}{n:>8,} 폴리곤")
if __name__ == "__main__":
main()
+189
View File
@@ -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()
+108 -60
View File
@@ -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
+208
View File
@@ -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()