diff --git a/docs/railway-client-guide.html b/docs/railway-client-guide.html new file mode 100644 index 0000000..9986689 --- /dev/null +++ b/docs/railway-client-guide.html @@ -0,0 +1,664 @@ + + + + + +Railway Client — 사용 가이드 + + + + + + +
+ + +
+ + +
+

Railway Client 사용 가이드

+

드론 항공 이미지 기반 철도 시설물 자동 검출 파이프라인

+ +
+ 서버 상태: sam31server SAM 3.1 → localhost:8000 구동 중 +
+ +

🔴 라멘형(門) 전철주 검출 파이프라인

+ 최우선 기능 + +

드론 사선 촬영 이미지에서 門자형 라멘 구조 전철주를 검출한다. SAM3.1로 폴리곤을 추출한 후 소실점(Vanishing Point) 기반 기하학 분석으로 C(기둥)/B(빔)를 분류, 라멘 그룹을 판정한다.

+ +
+

B / C 접두어 — 분류 기준

+
+
+
C
+
+ Column (기둥)
+ 소실점 방향으로 향하는 수직·사선 폴리곤.
각도 임계값(--c-thresh, 기본 20°) 이내.
+
+
+
+
B
+
+ Beam (빔)
+ 수평으로 뻗은 폴리곤.
소실점 방향에서 크게 벗어난 것.
+
+
+
+
+ 라멘 구조 판정: B 1개 + 그 아래 C 2개 이상 → 門자형 라멘으로 분류. 출력 이미지에 B/C 접두어 + 그룹 번호로 표시. +
+
+ +
+

2단계 파이프라인

+
+
① detect_all_objects.py
+ +
JSON annotation
(catenary_pole polygons)
+ +
② detect_raamen.py
+ +
B/C 분류
라멘 그룹 판정
+
+
+ +

Step 1 — 전철주 폴리곤 추출

+
cd D:\MYCLAUDE_PROJECT\railway-client
+
+.venv\Scripts\python.exe tools/detect_all_objects.py `
+  --input "data/역사구간/1.회덕역/DJI_20260306100900_0034.JPG" `
+  --categories configs/railway_zone.json `
+  --tiles all `
+  --cols 4 --rows 3 --overlap 0.10 --workers 2
+ +

출력: output/detect/DJI_20260306100900_0034.json — X-AnyLabeling 호환 annotation

+ +

Step 2 — 라멘 구조 분석

+
.venv\Scripts\python.exe tools/detect_raamen.py `
+  --image "data/역사구간/1.회덕역/DJI_20260306100900_0034.JPG" `
+  --label "output/detect/DJI_20260306100900_0034.json" `
+  --output "output/raamen/DJI_20260306100900_0034_raamen.png"
+ +

출력: 이미지 위에 C/B 라벨 + 라멘 그룹 번호 오버레이

+ +
+

detect_raamen.py 파라미터

+ + + + + + + + + + + +
파라미터기본값설명
--image필수원본 이미지 경로
--label필수JSON annotation 파일 (detect_all_objects 출력)
--output자동 생성결과 이미지 저장 경로
--class-namescatenary_pole분석할 클래스 이름 (쉼표 구분)
--class-ids없음클래스 ID로 필터링 (대안)
--epsilon4.0폴리곤 단순화 강도 (높을수록 단순)
--c-thresh20.0°C(기둥) 판정 각도 임계값
--b-max-diff75.0°B(빔) 최대 각도 편차
--margin30px근접성 그룹핑 거리
+
+ +

4단계 내부 처리 흐름

+ + + + + + +
Phase처리 내용
Phase 1폴리곤 단순화(approxPolyDP) + 소실점(Vanishing Point) 계산
Phase 2동적 C/B 분류 — 소실점 기반 기대 각도와 비교
Phase 3근접성 기반 그룹핑 — B 앵커 기준으로 아래 C 탐색
Phase 4라멘 구조 판정 + 가림(occlusion) 예외 처리
+
+ + +
+

Everything 탐색 모드 (Discovery Sweep)

+ +

새 지역·새 구간을 처음 분석할 때 사용. SAM3.1 텍스트 grounding 방식이므로 완전 무프롬프트는 불가 — 대신 광범위한 Discovery Prompt로 이미지에 존재하는 모든 객체를 일괄 검출하고 라벨 빈도를 집계한다.

+ +
+ 용도: 어떤 객체가 이 이미지에 얼마나 나오는지 파악 → 이후 detect_all_objects.py 의 카테고리·conf 튜닝에 활용. +
+ +
+

사용법

+
cd D:\MYCLAUDE_PROJECT\railway-client
+
+# 기본 (8×6 타일)
+.venv\Scripts\python.exe tools/sam3_everything_explore.py `
+  --input "data/역사구간/1.회덕역/DJI_20260306100900_0034.JPG"
+
+# 타일 수 조정 (고해상도 드론 이미지)
+.venv\Scripts\python.exe tools/sam3_everything_explore.py `
+  --input "data/역사구간/..." `
+  --cols 8 --rows 6 --conf 0.10 --workers 4
+
+# 특정 영역(ROI)만 탐색
+.venv\Scripts\python.exe tools/sam3_everything_explore.py `
+  --input "..." `
+  --zone 1000 500 3000 2500
+
+# Discovery Prompt에 추가 키워드 삽입
+.venv\Scripts\python.exe tools/sam3_everything_explore.py `
+  --input "..." `
+  --prompt-extra "signal box, relay cabinet"
+
+ +

파라미터

+ + + + + + + + + + + +
파라미터기본값설명
--input필수이미지 파일 경로
--cols8가로 타일 분할 수
--rows6세로 타일 분할 수
--overlap0.10타일 겹침 비율
--conf0.10신뢰도 임계값 (탐색 모드라 낮게 설정)
--workers4병렬 처리 스레드 수
--nms0.40NMS IoU 임계값
--zone없음ROI 좌표 (X1 Y1 X2 Y2) — 관심 영역만 처리
--prompt-extra없음Discovery Prompt에 추가할 키워드
+ +
+

Discovery Prompt (내장)

+

다음 키워드 묶음이 자동으로 SAM3.1에 전달된다:

+
railroad track, railway rail,
+catenary pole, overhead line pole, electric pole,
+overhead wire, catenary wire, power line cable,
+railway sleeper, concrete tie,
+guardrail, highway barrier, road fence,
+bridge, viaduct, overpass,
+vegetation, tree, bush, grass,
+building, structure, roof, wall,
+vehicle, car, truck,
+road, asphalt, pavement,
+slope, embankment, retaining wall,
+noise barrier, sound wall,
+signal, sign board,
+small dark object on ballast, small square metal box on ground,
+control box on ballast, gray square lid on gravel,
+flat metal cover on ground, bright square object on gravel
+

--prompt-extra로 추가 키워드를 붙일 수 있다.

+
+ +
+

출력 해석

+
output/everything/
+└── DJI_20260306100900_0034_everything.jpg   ← 모든 segment 오버레이
+
+콘솔 출력 (라벨 빈도 집계):
+  catenary pole         : 47
+  railway rail          : 31
+  overhead wire         : 28
+  concrete tie          : 19
+  control box on ballast:  8
+  ...
+
+ 활용: 빈도 상위 라벨 → railway_zone.json의 프롬프트에 추가하거나 conf 조정. 빈도 0인 카테고리 → 해당 이미지에 없는 객체. +
+ +

탐색 후 타겟 검출로 전환

+
+
sam3_everything_explore
+ +
라벨 빈도 확인
프롬프트 튜닝
+ +
detect_all_objects
+ +
detect_raamen (선택)
+
+
+
+ + +
+

서버 설정 (sam31server)

+ +

시스템 아키텍처

+
┌─────────────────────────────────┐
+│        sam31server              │
+│  FastAPI  app/main.py :8000     │
+│  GET  /health                   │
+│  POST /v1/predict               │
+│  Model: SAM 3.1 Multiplex (CUDA)│
+└──────────────┬──────────────────┘
+               │ HTTP localhost:8000
+┌──────────────▼──────────────────┐
+│       railway-client tools      │
+│  detect_all_objects.py          │  ─┐
+│  detect_raamen.py       ◀────────┘  │ 라멘 파이프라인
+│  sam3_everything_explore.py     │
+│  sam3_autolabel.py  ...         │
+└─────────────────────────────────┘
+ +

요구사항

+ + + + + + + +
항목버전
Python3.12
PyTorch2.10.0+cu126 (CUDA 필수)
triton-windows3.6.0.post25
FastAPI≥ 0.115.0
SAM 3.1 모델HuggingFace 자동 다운로드 (최초 1회)
+ +

디렉토리 구조

+
sam31server/
+├── sam3/                    ← SAM 3.1 모델 라이브러리
+├── app/
+│   ├── main.py              ← FastAPI 진입점
+│   ├── api/predict.py       ← POST /v1/predict
+│   ├── api/health.py        ← GET /health
+│   ├── models/segment_anything_3.py
+│   ├── core/registry.py
+│   └── tasks/inference.py
+├── configs/
+│   ├── server.yaml          ← 포트·동시성
+│   ├── models.yaml          ← enabled: segment_anything_3
+│   └── auto_labeling/segment_anything_3.yaml
+├── bpe_simple_vocab_16e6.txt.gz
+└── start_server.bat
+
+ +
+

서버 시작

+ +
+
1
+
+

sam31server 디렉토리에서 실행

+
cd D:\MYCLAUDE_PROJECT\sam31server
+
+# 배치파일
+start_server.bat
+
+# 또는 직접
+.venv\Scripts\python.exe -m app.main
+
+# 또는 uvicorn 직접 (Windows 이벤트루프 이슈 우회)
+.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000
+
+
+
+
2
+
+

정상 구동 확인

+
INFO | Loading [segment_anything_3] (Segment Anything 3.1)...
+INFO | SAM3 model loaded successfully
+INFO | Uvicorn running on http://0.0.0.0:8000
+
+
+
+ +
+

API 엔드포인트

+ +

POST /v1/predict

+
{
+  "model": "segment_anything_3",
+  "image": "<base64 JPEG>",
+  "params": {
+    "text_prompt": "catenary pole, overhead line pole",
+    "conf_threshold": 0.25,
+    "show_masks": true,
+    "show_boxes": false
+  }
+}
+

응답:

+
{
+  "data": {
+    "shapes": [
+      { "label": "catenary pole", "shape_type": "polygon",
+        "points": [[x,y], ...], "score": 0.87 }
+    ]
+  }
+}
+ + + + + + + + +
params 키설명
text_prompt텍스트 grounding (쉼표로 다중 객체)
marksrectangle / point 시각 프롬프트
conf_threshold신뢰도 임계값
show_maskspolygon 반환 (true 권장)
show_boxesbounding box 반환
+
+ + +
+

클라이언트 도구 전체 목록

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
스크립트기능출력우선순위
detect_all_objects.py타일 분할 + 다중 카테고리 검출 + NMSPNG + JSON핵심
detect_raamen.py門형 라멘 전철주 B/C 분류 + 그룹 판정PNG핵심
sam3_everything_explore.pyDiscovery Sweep — 전체 객체 탐색 + 빈도 집계PNG + 통계탐색
sam3_autolabel.py전철주 + 레일 zone 기반 자동 라벨링JSON
sam3_batch_label.py폴더 배치 → X-AnyLabeling JSONJSON
yoloworld_sam3_pipeline.pyYOLO-World bbox → SAM3 polygonJSON+PNG
video_sam3_segment.py영상 프레임 추출 → 세그멘테이션JSON/frame
render_everything_by_label.py라벨별 색상 렌더링PNG
render_label_polygons.pypolygon 오버레이PNG
sam3_segment_everything.py이미지 전체 세그멘테이션PNG
railway_pipeline.py전체 파이프라인 통합종합
+
+ +
+

detect_all_objects.py

+
.venv\Scripts\python.exe tools/detect_all_objects.py `
+  --input  "data/역사구간/..." `
+  --categories configs/railway_zone.json `
+  --tiles  all `
+  --cols 4 --rows 3 --overlap 0.10 --workers 2
+ + + + + + + + + + +
파라미터기본값설명
--input필수이미지 또는 폴더
--categoriesrailway_zone.json카테고리 설정 파일
--tilesall9-24, 1,5,9, all
--cols / --rows8 / 6타일 분할 수
--overlap0.10타일 겹침 비율
--workers4병렬 스레드
--conf카테고리별전역 신뢰도 오버라이드
+

출력: output/detect/이미지명_detected.png + .json

+
+ +
+

sam3_autolabel.py

+
.venv\Scripts\python.exe tools/sam3_autolabel.py `
+  --input data/역사구간/1.회덕역/ `
+  --output output/labels/
+
+ +
+

sam3_batch_label.py

+
.venv\Scripts\python.exe tools/sam3_batch_label.py `
+  --input data/역사구간/ `
+  --prompt "railway catenary pole" `
+  --output output/batch_labels/ `
+  --workers 8
+
+ +
+

yoloworld_sam3_pipeline.py

+
.venv\Scripts\python.exe tools/yoloworld_sam3_pipeline.py `
+  --input data/역사구간/ `
+  --output output/labeled/
+
+ +
+

video_sam3_segment.py

+
.venv\Scripts\python.exe tools/video_sam3_segment.py
+

스크립트 상단에서 영상 경로·프롬프트·프레임 추출 간격 직접 수정.

+
+ +
+

렌더링 도구

+
# 라벨별 색상 렌더링
+.venv\Scripts\python.exe tools/render_everything_by_label.py `
+  --input output/labels/image.json --image data/.../image.JPG
+
+# polygon 오버레이
+.venv\Scripts\python.exe tools/render_label_polygons.py `
+  --input output/labels/ --image data/.../
+
+ + +
+

카테고리 설정 — railway_zone.json

+

경로: configs/railway_zone.json

+ + + + + + + + + + + + + + + + + +
이름한국어색상conf우선순위
control_box컨트롤박스노랑0.152
vehicle차량흰색0.252
catenary_pole전철주마젠타0.253
railway철도 레일파랑0.254
fence팬스/울타리초록0.505
sleeper침목하늘0.206
ballast자갈도상남색0.207
bracket브라켓/암청색0.208
bridge교량/교각주황하늘0.258
building건물청보라0.258
retaining_wall방음벽/옹벽연두0.259
culvert암거/소교량보라0.209
service_road유지보수 도로회청0.3010
farmland농지밝은초록0.2511
vegetation식생짙은초록0.2512
+
+ 우선순위(priority): 낮을수록 cross-class NMS에서 우선 보존. 우선순위 2 = 최우선(컨트롤박스·차량), 12 = 최하위(식생). +
+
+ +
+

프롬프트 파일

+

일부 도구는 prompts/ 폴더의 텍스트 파일을 사용:

+
prompts/
+├── pole.txt      ← 전철주 검출용 프롬프트
+├── rail.txt      ← 레일 검출용 프롬프트
+├── bracket.txt   ← 브라켓 검출용
+└── sleeper.txt   ← 침목 검출용
+
+ + +
+

빠른 시작 — 단일 이미지 전체 파이프라인

+ +
+
1
+
+

SAM3.1 서버 시작 (터미널 A)

+
cd D:\MYCLAUDE_PROJECT\sam31server
+.venv\Scripts\python.exe -m app.main
+
+
+
+
2
+
+

[선택] 탐색 모드로 객체 파악 (터미널 B)

+
cd D:\MYCLAUDE_PROJECT\railway-client
+.venv\Scripts\python.exe tools/sam3_everything_explore.py `
+  --input "data/역사구간/1.회덕역/.../DJI_20260306100900_0034.JPG" `
+  --cols 4 --rows 3
+
+
+
+
3
+
+

전철주 검출 + JSON annotation 생성

+
.venv\Scripts\python.exe tools/detect_all_objects.py `
+  --input "data/역사구간/1.회덕역/.../DJI_20260306100900_0034.JPG" `
+  --categories configs/railway_zone.json `
+  --tiles all --cols 4 --rows 3 --overlap 0.10 --workers 2
+
+
+
+
4
+
+

라멘 구조 분석

+
.venv\Scripts\python.exe tools/detect_raamen.py `
+  --image "data/역사구간/1.회덕역/.../DJI_20260306100900_0034.JPG" `
+  --label "output/detect/DJI_20260306100900_0034.json" `
+  --output "output/raamen/DJI_20260306100900_0034_raamen.png"
+
+
+
+ +
+

트러블슈팅

+ + + + + + + + + +
오류원인해결
Connection refused :8000서버 미실행sam31server에서 서버 시작
Torch not compiled with CUDACPU 버전 torchpip install torch==2.10.0+cu126 --index-url .../cu126
No module named 'triton'triton-windows 미설치pip install triton-windows==3.6.0.post25
No module named 'decord'decord 미설치pip install decord
라멘 검출 없음catenary_pole conf 너무 높음Step 1에서 --conf 0.10 로 낮춤
C/B 분류 오류소실점 계산 불안정--c-thresh 조정 (기본 20°, 높이면 C 기준 완화)
Windows 이벤트루프 경고Python 3.12 + uvicornpython -m uvicorn app.main:app --host 0.0.0.0 --port 8000
+ +
+

Railway Client Guide · SAM 3.1 + FastAPI · 2026-06

+
+ +
+
+ + diff --git a/docs/진행현황_2026-05-22.md b/docs/진행현황_2026-05-22.md new file mode 100644 index 0000000..6bae821 --- /dev/null +++ b/docs/진행현황_2026-05-22.md @@ -0,0 +1,280 @@ +# 철도 디지털 트윈 — AI 라벨링 파이프라인 진행 현황 + +> 기준일: 2026-05-22 +> 프로젝트: `d:\MYCLAUDE_PROJECT\x-anylabeling01` +> 목표: 드론 부감 이미지에서 철도 지장물 자동 탐지 → YOLOv26 학습 데이터 구축 → 디지털 트윈 + +--- + +## 1. 탐지 대상 (지장물 카테고리) + +| 카테고리 | 한글 | 상태 | +|---|---|---| +| raamen (ラーメン) | 라멘형 전철주 | ✅ 완료 | +| pole | 전철주 일반 | ✅ 완료 | +| rail | 레일 | ✅ 완료 | +| sleeper | 침목 | ✅ 완료 | +| control_box | 컨트롤박스 (소형 금속 박스) | 🔄 진행 중 | + +--- + +## 2. 완료된 작업 + +### 2-1. 라멘형 전철주 검출 (`tools/detect_raamen.py`) + +- **방식**: SAM3.1 텍스트 grounding + VP(소실점) 기반 H/V 분류 +- **입력**: 드론 고해상도 이미지 (.JPG), 선택적으로 `--json` (detect_all_objects.py 결과 재사용) +- **출력**: AnyLabeling JSON (폴리곤 + H/V 라벨) +- **핵심 알고리즘**: + - 이미지를 타일 분할 → SAM3.1 병렬 호출 + - VP(소실점) 다중 시드 + 반복 정제로 수직(V) / 수평(H) 분류 + - H-max-diff 필터로 수평 빔과 수직 기둥 구분 + - Cross-NMS로 중복 제거 +- **최근 커밋**: `923c396` (VP 다중 시드 + 반복 정제로 V/H 분류 정확도 향상) + +### 2-2. 전체 객체 검출 UI (`tools/web_ui.py`) + +- FastAPI + uvicorn 기반 웹 UI +- 이미지 선택 → 타일/카테고리 설정 → `detect_all_objects.py` 실행 → 결과 프리뷰 +- **개선 사항** (이번 세션): + - 마우스 휠 줌 + 드래그 팬 추가 + - `--save-labels` → `--save-json` 수정 (txt 금지, JSON만) + - `--debug` 플래그 제거 (존재하지 않는 옵션) + - 프리뷰 해상도 1200 → 2000px + +### 2-3. 전체 객체 검출 (`tools/detect_all_objects.py`) + +- SAM3.1 텍스트 grounding, 카테고리별 타일 검출 +- `configs/railway_zone.json` 카테고리 설정 사용 +- **출력 경로 개선**: `output/detect/{이미지명}/tiles{N}_{카테고리}_{번호}.jpg` +- `--save-json` 옵션으로 AnyLabeling JSON 저장 + +### 2-4. SAM3 Everything 탐색 (`tools/sam3_everything_explore.py`) + +- 38개 항목 광역 프롬프트로 이미지 전체 탐색 +- 타일 병렬 처리 + NMS +- JSON 라벨 통계 출력 (text_prompt 후보 발굴용) +- `--prompt-extra` 옵션으로 추가 어휘 주입 가능 +- **테스트 결과** (DJI_20260306113838_0004.JPG, 8×6 타일): + - 총 7,111개 세그먼트 검출 + - control_box 관련 라벨 1,740개 매칭 + - **문제**: 정밀도 ~1-2%, false positive 폭증 → SAM3 텍스트 grounding 한계 확인 + +--- + +## 3. 핵심 결정 사항 + +### 3-1. YOLOv26 단독 전환 (2026-05-19) + +**배경**: SAM3.1 텍스트 grounding으로 control_box 탐지 시도 → 완전 실패 + +| 시도 | 결과 | +|---|---| +| 고립 프롬프트 ("railway control box, electrical cabinet...") | 0 → 0 검출 | +| conf 0.20 → 0.10 낮춤 | 0 → 0 검출 | +| 16×12 세밀한 타일 그리드 | 0 → 0 검출 | +| 인접 4개 타일 수동 실험 | 0 → 0 검출 | +| 38개 광역 프롬프트 | 1,740개 매칭, 정밀도 ~1-2% | + +**결론**: SAM3.1은 부감 드론 시점 소형 객체 텍스트 grounding에 부적합 + +**전환**: **YOLOv26 학습 데이터셋 구축 → fine-tune → 추론** 경로 + +**준비 완료**: +- `yolo26n.pt`, `yolo26n-seg.pt` 사전학습 모델 (프로젝트 루트) +- ultralytics 26.x 설치 완료 +- X-AnyLabeling-Server YOLO endpoint 연동 완료 + +### 3-2. SAM3 활용 방식 변경 + +- **폐기**: SAM3 텍스트 grounding → 단독 검출기 +- **유지**: SAM3 everything 모드 → 후보 bbox 생성기 (인간 라벨링 보조) +- **신규**: YOLOv26 fine-tune → production 검출기 + +--- + +## 4. 현재 진행 중: control_box 라벨링 파이프라인 + +### 4-1. 전략 (부트스트랩) + +``` +SAM3 everything → 1,740개 후보 bbox + ↓ + labeling_server.py (인간 투표) + ↓ + MIN_VOTES=3, TRUE_RATIO=0.6 필터 + ↓ + YOLO 학습용 txt 라벨 + ↓ + YOLOv26 fine-tune (yolo26n.pt) + ↓ + production 검출기 +``` + +### 4-2. 라벨링 서버 (`tools/labeling_server.py`) — v2 + +**실행 명령:** +```bash +python tools/labeling_server.py \ + --json "data/역사이미지/slope/DJI_20260306113838_0004_everything.json" \ + --reset +``` + +**브라우저**: `http://서버IP:7001` + +**UI 방식** (v2 — 전체 이미지 오버레이): +- 전체 드론 이미지 표시 (최대 3000px) +- SAM3 후보 1,740개를 색상 bbox로 오버레이 + - 🟡 노랑: 미투표 + - 🟢 초록: 컨트롤박스 YES + - 🔴 빨강: 아님 NO + - ⬜ 회색: 타인 투표 완료 +- 마우스 휠 줌 / 드래그 이동 / F키 맞춤 +- bbox 클릭 → YES/NO 즉시 저장 (SQLite) +- 호버 → 라벨명 + 점수 툴팁 + +**집계 기준**: +- `MIN_VOTES = 3` (3인 이상 투표) +- `TRUE_RATIO = 0.6` (60% 이상 YES) + +**YOLO 내보내기**: `POST /api/export` → `labels/yolo_export/*.txt` + +**DB**: `labels/labeling.db` (SQLite, 로컬) +- `candidates` 테이블: json_idx, label, score, bbox, image_path +- `votes` 테이블: candidate_id, user, vote, ts (UNIQUE 제약 — 1인 1표) + +### 4-3. 현재 데이터 + +| 항목 | 값 | +|---|---| +| 입력 이미지 | `data/역사이미지/slope/DJI_20260306113838_0004.JPG` | +| everything JSON | `DJI_20260306113838_0004_everything.json` | +| 전체 세그먼트 | 7,111개 | +| control_box 후보 | 1,740개 | +| 라벨링 진행 | 미시작 (서버 실행 대기) | + +--- + +## 5. 다음 할 일 (TODO) + +### 즉시 +- [ ] `labeling_server.py` 배포 → 직원 20명 라벨링 시작 +- [ ] 추가 이미지 `_everything.json` 생성 (현재 1장 → 더 많이 필요) + +### SAM3 Everything 프롬프트 재설계 (⚠️ 중요) +- **문제**: 현재 DISCOVERY_PROMPT 38개 항목이 control_box를 제대로 분리 못함 +- **방향**: 더 구체적인 시각적 특징 기반 프롬프트 필요 + - 기존: `"small square box", "compact trackside junction box"` 등 → FP 폭증 + - 새 방향 검토 필요: + - 크기/형태 명시: `"small gray square metal lid on ground"`, `"square dark gray lid flush with ballast"` + - 재질/색상 특징: `"weathered gray metal surface"`, `"flat square cover"` + - 부감 시점 특화: `"top-down view of small electrical enclosure"` + +### 학습 후 +- [ ] 50-100개 이상 확정 라벨 → `yolo train` 실행 + ```bash + yolo train model=yolo26n.pt data=configs/control_box.yaml epochs=100 imgsz=640 + ``` +- [ ] 검출 성능 검증 → 미달 시 데이터 추가 수집 + +--- + +## 6. 설정 파일 + +### `configs/railway_zone.json` (control_box 항목) +```json +{ + "name": "control_box", + "name_kr": "컨트롤박스", + "prompt": "small square gray metal box beside rail, compact trackside junction box, small near-square electrical enclosure on the ground, small cube-shaped equipment box next to track", + "conf": 0.15, + "priority": 2 +} +``` + +### SAM3 Everything DISCOVERY_PROMPT 현재 상태 (재설계 필요) +``` +"railroad track, railway rail, +catenary pole, overhead line pole, electric pole, +overhead wire, catenary wire, power line cable, +railway sleeper, concrete tie, +guardrail, highway barrier, road fence, +bridge, viaduct, overpass, +vegetation, tree, bush, grass, +building, structure, roof, wall, +vehicle, car, truck, +road, asphalt, pavement, +slope, embankment, retaining wall, +noise barrier, sound wall, +signal, sign board" +``` +→ control_box 관련 항목 부재 → everything 탐색에서 누락됨 + +--- + +## 7. 기술 제약 / 주의사항 + +- **CLI 명령어**: bash 한 종류만 사용 (PowerShell 혼용 금지) +- **라벨 저장**: `--save-json` 옵션만 사용 (txt 금지) +- **detect_all_objects.py**: `--workers 8` 항상 명시 +- **SAM3 서버**: `python.exe -m app.main` (X-AnyLabeling-Server 디렉토리에서 실행, localhost:8000) +- **GDINO**: 폐기됨. SAM3.1 텍스트 프롬프트 (`params.text_prompt`) 직접 사용 +- **YOLOv26**: ultralytics 26.x, 모델 파일 `yolo26n.pt` / `yolo26n-seg.pt` (프로젝트 루트) + +--- + +## 8. 주요 파일 구조 + +``` +x-anylabeling01/ +├── tools/ +│ ├── detect_all_objects.py # 메인 검출 도구 (SAM3 타일 검출) +│ ├── detect_raamen.py # 라멘형 전철주 전용 검출 +│ ├── web_ui.py # 웹 UI (FastAPI, port:8001) +│ ├── labeling_server.py # CAPTCHA 라벨링 서버 (port:7001) ← 신규 v2 +│ ├── sam3_everything_explore.py # SAM3 전체 탐색 (프롬프트 발굴용) ← 개선 필요 +│ ├── sam3_segment_everything.py # SAM3 포인트 그리드 세그멘테이션 +│ └── post_merge_poles.py # 전철주 병합 후처리 +├── configs/ +│ └── railway_zone.json # 카테고리별 프롬프트/conf/priority +├── labels/ +│ ├── labeling.db # 라벨링 투표 SQLite DB +│ └── yolo_export/ # YOLO txt 내보내기 결과 +├── output/ +│ └── detect/{이미지명}/ # 검출 결과 이미지 (타일/카테고리별) +├── yolo26n.pt # YOLOv26 nano 사전학습 모델 +└── yolo26n-seg.pt # YOLOv26 nano segmentation 모델 +``` + +--- + +## 9. 다음 세션 시작 프롬프트 + +``` +철도 디지털 트윈 프로젝트 (x-anylabeling01) 이어서 진행합니다. + +[현재 상태] +- detect_raamen.py (라멘형 전철주) 검출 완료 +- detect_all_objects.py (다중 카테고리 SAM3 검출) 완료 +- web_ui.py 개선 완료 (줌/팬, --save-json, 폴더 구조) +- labeling_server.py v2 완료 (전체 이미지 + bbox 오버레이, 클릭 투표) + +[오늘 할 일: SAM3 Everything 프롬프트 재설계] +tools/sam3_everything_explore.py의 DISCOVERY_PROMPT를 재설계해야 합니다. + +현재 문제: +- 기존 38개 항목 광역 프롬프트로 DJI_20260306113838_0004.JPG 처리 시 +- 7,111개 세그먼트, control_box 관련 1,740개 매칭 → 정밀도 ~1-2% +- control_box (소형 정사각형 금속 박스, 자갈도상 옆 지면에 놓인 것)가 제대로 분리 안 됨 + +목표: +- control_box를 효과적으로 탐지할 수 있는 SAM3 텍스트 프롬프트 발굴 +- 혹은 SAM3 everything → 후보 bbox 1,740개에서 더 정밀하게 필터링하는 방법 개선 +- 최종적으로는 YOLOv26 학습 데이터 50-100개 확보 + +[기술 제약] +- CLI는 bash만 사용 (PowerShell 혼용 금지) +- 라벨 저장은 --save-json만 (txt 금지) +- SAM3 서버: python.exe -m app.main (X-AnyLabeling-Server에서, localhost:8000) +- detect_all_objects.py 실행 시 --workers 8 항상 명시 +``` diff --git a/tools/debug_vh.py b/tools/debug_vh.py new file mode 100644 index 0000000..26e485e --- /dev/null +++ b/tools/debug_vh.py @@ -0,0 +1,66 @@ +"""debug_vh.py — catenary_pole V/H 분류 디버그 시각화.""" +import argparse +import json +import sys +import cv2 +import numpy as np +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from group_ramen_poles import _poly_orient + +COLOR = { + 'V': (255, 80, 0), + 'H': (0, 80, 255), + '?': (160, 160, 160), + '?_ambiguous': (0, 255, 0), +} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--label", required=True, help="AnyLabeling JSON") + ap.add_argument("--image", required=True, help="원본 이미지") + ap.add_argument("--output", required=True, help="출력 JPG") + args = ap.parse_args() + + data = json.loads(Path(args.label).read_text(encoding="utf-8")) + iH, iW = data["imageHeight"], data["imageWidth"] + + buf = np.fromfile(args.image, dtype=np.uint8) + img = cv2.imdecode(buf, cv2.IMREAD_COLOR) + H, W = img.shape[:2] + font_sc = max(1.0, min(W, H) / 4000) + thick = max(2, int(font_sc * 2)) + + for full_idx, s in enumerate(data["shapes"]): + if s.get("label") != "catenary_pole": + continue + pts = np.array(s["points"], dtype=np.int32) + o = _poly_orient(s["points"], iH, iW) + col = COLOR.get(o, (160, 160, 160)) + ov = img.copy() + cv2.fillPoly(ov, [pts], col) + cv2.addWeighted(ov, 0.30, img, 0.70, 0, img) + cv2.polylines(img, [pts], True, col, 3) + cx, cy = int(pts[:, 0].mean()), int(pts[:, 1].mean()) + label = f"{full_idx}{o}" + (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_sc, thick) + tx, ty = cx - tw // 2, cy + th // 2 + cv2.rectangle(img, (tx-3, ty-th-4), (tx+tw+3, ty+4), (0, 0, 0), -1) + cv2.putText(img, label, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX, font_sc, (255,255,255), thick+1, cv2.LINE_AA) + cv2.putText(img, label, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX, font_sc, col, thick, cv2.LINE_AA) + + h, w = img.shape[:2] + scale = min(1.0, 4096 / max(h, w)) + if scale < 1.0: + img = cv2.resize(img, (int(w * scale), int(h * scale))) + + out = Path(args.output) + out.parent.mkdir(parents=True, exist_ok=True) + cv2.imencode(out.suffix, img)[1].tofile(str(out)) + print(f"saved: {out}") + + +if __name__ == "__main__": + main() diff --git a/tools/detect_all_objects.py b/tools/detect_all_objects.py index 39d3729..e67f065 100644 --- a/tools/detect_all_objects.py +++ b/tools/detect_all_objects.py @@ -161,104 +161,6 @@ def nms_shapes(shapes: list, iou_thresh: float = 0.4) -> list: return _nms_core(shapes, iou_thresh) -def _poly_orient(points: list, H: int, W: int) -> str: # post_merge_poles.py에서도 사용 - """폴리곤 장축 방향 판별 (render_skeleton_overlay.py 동일 로직). - - V: 장축이 이미지 중심에서 방사형 방향과 정렬 (cos_sim > 0.7) → 세로 기둥 - H: 장축이 radial 직교 방향 → 수평 빔 - ?: aspect ratio < 1.3 으로 판별 불가 - """ - pts = np.array(points, dtype=np.float32) - rect = cv2.minAreaRect(pts) - (rx, ry), (rw, rh), angle = rect - if min(rw, rh) < 1: - return '?' - ar = max(rw, rh) / min(rw, rh) - if ar < 1.3: - return '?' - long_angle_deg = angle if rw >= rh else angle + 90 - lx = float(np.cos(np.radians(long_angle_deg))) - ly = float(np.sin(np.radians(long_angle_deg))) - img_cx, img_cy = W / 2.0, H / 2.0 - rdx, rdy = rx - img_cx, ry - img_cy - radial_norm = (rdx ** 2 + rdy ** 2) ** 0.5 - if radial_norm < 1: - return '?' - rdx, rdy = rdx / radial_norm, rdy / radial_norm - cos_sim = abs(lx * rdx + ly * rdy) - return 'V' if cos_sim > 0.7 else 'H' - - -def merge_nonramen_poles(shapes: list, H: int, W: int, - x_overlap_thresh: float = 0.30, - y_gap_thresh: int = 150) -> list: - """타일 경계 분할된 전철주 병합 — V+V 조합만 허용. - - _poly_orient로 각 폴리곤 V/H 분류. - 두 폴리곤 모두 V(세로 기둥)이고 공간 기준 충족 시만 병합. - H(수평 빔) 포함 쌍 = 라멘 관련 조각 → 병합 건너뜀. - """ - if len(shapes) <= 1: - return shapes - - orients = [_poly_orient(s["points"], H, W) for s in shapes] - v_count = sum(1 for o in orients if o == 'V') - h_count = sum(1 for o in orients if o == 'H') - print(f" [orient] V={v_count}, H={h_count}, ?={len(orients)-v_count-h_count}") - - def get_bbox(s): - xs = [p[0] for p in s["points"]]; ys = [p[1] for p in s["points"]] - return min(xs), min(ys), max(xs), max(ys) - - def x_overlap_ratio(b1, b2): - ox = min(b1[2], b2[2]) - max(b1[0], b2[0]) - ux = max(b1[2], b2[2]) - min(b1[0], b2[0]) - return ox / ux if ux > 0 else 0.0 - - def y_gap(b1, b2): - return max(0.0, max(b1[1], b2[1]) - min(b1[3], b2[3])) - - def merge_two(s1, s2): - mask = np.zeros((H, W), dtype=np.uint8) - for s in (s1, s2): - cv2.fillPoly(mask, [np.array(s["points"], dtype=np.int32)], 255) - contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - if not contours: - return s1 - c = max(contours, key=cv2.contourArea) - eps = 0.002 * cv2.arcLength(c, True) - approx = cv2.approxPolyDP(c, eps, True) - merged = dict(s1) - merged["points"] = [[float(p[0][0]), float(p[0][1])] for p in approx] - merged["score"] = max(float(s1.get("score", 0)), float(s2.get("score", 0))) - return merged - - merged_flags = [False] * len(shapes) - result = [] - merged_count = 0 - for i in range(len(shapes)): - if merged_flags[i]: - continue - cur = shapes[i] - cur_ori = orients[i] - cb = get_bbox(cur) - for j in range(i + 1, len(shapes)): - if merged_flags[j]: - continue - if cur_ori != 'V' or orients[j] != 'V': - continue # 둘 다 V가 아니면 병합 안 함 - jb = get_bbox(shapes[j]) - if x_overlap_ratio(cb, jb) >= x_overlap_thresh and y_gap(cb, jb) <= y_gap_thresh: - cur = merge_two(cur, shapes[j]) - cur_ori = 'V' - cb = get_bbox(cur) - merged_flags[j] = True - merged_count += 1 - result.append(cur) - print(f" [merge] 병합={merged_count}쌍") - return result - - def cross_class_nms(buckets: list, categories: list, iou_thresh: float) -> list: """클래스 간 NMS: 동일 영역에 다른 클래스가 중복 검출될 때 우선순위 높은 쪽 보존. @@ -508,9 +410,9 @@ def main(): else: tile_tag = args.tiles.replace(",", "_").replace("-", "to") cat_tag = Path(args.categories).stem if args.categories else "default" - out_dir = Path("output") / "detect" / img_path.stem + out_dir = Path("output") / "detect" out_dir.mkdir(parents=True, exist_ok=True) - base_name = f"tiles{tile_tag}_{cat_tag}" + base_name = f"{img_path.stem}_tiles{tile_tag}_{cat_tag}" n = 1 while True: out_path = out_dir / f"{base_name}_{n:03d}.jpg" diff --git a/tools/detect_control_box.py b/tools/detect_control_box.py new file mode 100644 index 0000000..5edf72f --- /dev/null +++ b/tools/detect_control_box.py @@ -0,0 +1,123 @@ +""" +SAM3.1 control_box 단일 이미지 검출 + 결과 저장. + +사용: + python tools/detect_control_box.py --input [--conf 0.05] [--output ] +""" +import argparse +import base64 +import json +from collections import Counter +from pathlib import Path + +import cv2 +import numpy as np +import requests + +SAM3_SERVER = "http://localhost:8000" +PROMPT = ( + "small dark object on ballast, small box on ballast, " + "metal cover on ground, small bright object on gravel, " + "square lid on ground, control box" +) +PALETTE = [ + (0, 80, 255), (0, 200, 0), (255, 100, 0), + (180, 0, 255), (0, 220, 220), (255, 180, 0), +] + + +def detect(img_path: Path, conf: float) -> list: + buf = np.fromfile(str(img_path), dtype=np.uint8) + img = cv2.imdecode(buf, cv2.IMREAD_COLOR) + if img is None: + raise FileNotFoundError(img_path) + _, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 95]) + b64 = base64.b64encode(enc).decode() + r = requests.post(f"{SAM3_SERVER}/v1/predict", json={ + "model": "segment_anything_3", + "image": b64, + "params": { + "text_prompt": PROMPT, + "conf_threshold": conf, + "show_masks": True, + "show_boxes": False, + }, + }, timeout=120) + r.raise_for_status() + shapes = r.json().get("data", {}).get("shapes", []) + return img, [s if isinstance(s, dict) else s.dict() for s in shapes] + + +def render(img, shapes, out_path: Path): + all_labels = sorted(set(s.get("label", "") for s in shapes)) + lc = {l: PALETTE[i % len(PALETTE)] for i, l in enumerate(all_labels)} + canvas = img.copy() + font = cv2.FONT_HERSHEY_SIMPLEX + + for s in shapes: + if s.get("shape_type") != "polygon": + continue + pts = np.array(s["points"], dtype=np.int32) + color = lc.get(s.get("label", ""), (128, 128, 128)) + ov = canvas.copy() + cv2.fillPoly(ov, [pts], color) + cv2.addWeighted(ov, 0.20, canvas, 0.80, 0, canvas) + cv2.polylines(canvas, [pts], True, color, 1) + cx = int(np.mean([p[0] for p in s["points"]])) + cy = int(np.mean([p[1] for p in s["points"]])) + short = (s.get("label", "") + .replace("small ", "") + .replace(" on ballast", "") + .replace(" on ground", "") + .replace(" on gravel", "")) + cv2.putText(canvas, short, (cx, cy), font, 0.35, color, 1, cv2.LINE_AA) + + y = 20 + for lbl, color in sorted(lc.items()): + cnt = sum(1 for s in shapes if s.get("label") == lbl) + cv2.rectangle(canvas, (10, y - 12), (22, y), color, -1) + cv2.putText(canvas, f"{lbl} ({cnt})", (26, y), font, 0.40, color, 1, cv2.LINE_AA) + y += 16 + + out_path.parent.mkdir(parents=True, exist_ok=True) + cv2.imencode(".png", canvas)[1].tofile(str(out_path)) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--input", required=True, type=Path) + ap.add_argument("--output", default=None, type=Path) + ap.add_argument("--conf", type=float, default=0.05) + ap.add_argument("--save-json", action="store_true") + args = ap.parse_args() + + out = args.output or args.input.with_name(args.input.stem + "_detected.png") + + print(f"input : {args.input}") + print(f"output: {out}") + print("detecting...") + + img, shapes = detect(args.input, args.conf) + render(img, shapes, out) + + counter = Counter(s.get("label", "") for s in shapes) + print(f"total : {len(shapes)}") + for lbl, cnt in counter.most_common(): + print(f" {lbl}: {cnt}") + + if args.save_json: + jpath = out.with_suffix(".json") + jpath.write_text(json.dumps({ + "source": str(args.input), + "total": len(shapes), + "label_counts": dict(counter), + "shapes": [{"label": s.get("label",""), "score": s.get("score",0), + "points": s.get("points",[])} for s in shapes], + }, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"json : {jpath}") + + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tools/detect_raamen.py b/tools/detect_raamen.py index c1a997e..8367cbc 100644 --- a/tools/detect_raamen.py +++ b/tools/detect_raamen.py @@ -4,14 +4,14 @@ 파이프라인: Phase 1: 폴리곤 단순화(approxPolyDP) + 소실점(Vanishing Point) 계산 - Phase 2: 동적 V/H 분류 (소실점 기반 기대 각도) - Phase 3: 근접성 기반 그룹핑 (H 앵커 → 아래 V 탐색) + Phase 2: 동적 C/B 분류 (소실점 기반 기대 각도) — C=기둥(Column), B=빔(Beam) + Phase 3: 근접성 기반 그룹핑 (B 앵커 → 아래 C 탐색) Phase 4: 라멘 구조 판정 + 예외(가림) 처리 사용: python tools/detect_raamen.py \ --image --label --output \ - [--class-ids 1] [--epsilon 4.0] [--v-thresh 20.0] + [--class-ids 1] [--epsilon 4.0] [--c-thresh 20.0] """ import argparse import numpy as np @@ -110,9 +110,8 @@ def compute_vanishing_point(polys): return float(vp[0]), float(vp[1]) -def _estimate_vp_iterative(polys, seed_indices, v_thresh, h_max_diff, vp_min_len, - x_horiz_thresh=10.0, max_iter=6): - """초기 후보에서 반복 정제 VP 추정. Returns (vp_x, vp_y, n_v, orients, adiffs).""" +def _estimate_vp_iterative(polys, seed_indices, c_thresh, b_max_diff, vp_min_len, max_iter=6): + """초기 후보에서 반복 정제 VP 추정. Returns (vp_x, vp_y, n_c, orients, adiffs).""" n = len(polys) orients = ['?'] * n adiffs = [90.0] * n @@ -121,64 +120,144 @@ def _estimate_vp_iterative(polys, seed_indices, v_thresh, h_max_diff, vp_min_len vp_x, vp_y = compute_vanishing_point([polys[i] for i in seed_indices]) for _ in range(max_iter): for i, pts in enumerate(polys): - orients[i], adiffs[i] = classify_vh(pts, vp_x, vp_y, v_thresh, h_max_diff, - x_horiz_thresh) - v_cands = [i for i in range(n) - if orients[i] == 'V' and _long_axis_angle(polys[i])[3] > vp_min_len] - if len(v_cands) < 3: + orients[i], adiffs[i] = classify_cb(pts, vp_x, vp_y, c_thresh, b_max_diff) + c_cands = [i for i in range(n) + if orients[i] == 'C' and _long_axis_angle(polys[i])[3] > vp_min_len] + if len(c_cands) < 3: break - nx, ny = compute_vanishing_point([polys[i] for i in v_cands]) + nx, ny = compute_vanishing_point([polys[i] for i in c_cands]) shift = ((nx - vp_x) ** 2 + (ny - vp_y) ** 2) ** 0.5 vp_x, vp_y = nx, ny if shift < 5.0: for i, pts in enumerate(polys): - orients[i], adiffs[i] = classify_vh(pts, vp_x, vp_y, v_thresh, h_max_diff, - x_horiz_thresh) + orients[i], adiffs[i] = classify_cb(pts, vp_x, vp_y, c_thresh, b_max_diff) break - return vp_x, vp_y, orients.count('V'), orients, adiffs + return vp_x, vp_y, orients.count('C'), orients, adiffs -# ── Phase 2: 동적 V/H 분류 ────────────────────────────────────────────── +# ── Phase 2: 동적 C/B 분류 ────────────────────────────────────────────── -def classify_vh(pts, vp_x, vp_y, v_thresh, h_max_diff=75.0, x_horiz_thresh=10.0): +def classify_cb(pts, vp_x, vp_y, c_thresh, b_max_diff=75.0): """ - 소실점 기준 V/H 분류. - - 이미지 절대 수평(X축 ±x_horiz_thresh°) AND AR≥4 → '?' (레일·전선 등) - - diff < v_thresh → V (기둥) - - v_thresh ≤ diff < h_max_diff → H (빔) - - diff ≥ h_max_diff → '?' + 소실점 기준 C/B 분류. C=기둥(Column), B=빔(Beam). + - diff < c_thresh → C (기둥) + - c_thresh ≤ diff < b_max_diff → B (빔) + - diff ≥ b_max_diff → '?' (레일/전선 등 비라멘 수평 구조물) Returns: (orient, angle_diff_deg) + orient = 'C' | 'B' | '?' """ long_angle_deg, cx, cy, long_side, short_side = _long_axis_angle(pts) if short_side < 1 or long_side / short_side < 1.3: return '?', 90.0 - - # 절대 수평 제외: X축 ±x_horiz_thresh° 이내 + AR≥4 (레일·전선 등 가느다란 수평체) - abs_from_horiz = long_angle_deg % 180.0 - if abs_from_horiz > 90.0: - abs_from_horiz = 180.0 - abs_from_horiz - if abs_from_horiz < x_horiz_thresh and long_side / short_side >= 4.0: - return '?', 90.0 - - # VP 기준 상대 각도 분류 + # 기대 수직 각도: 폴리곤 중심 → 소실점 방향 exp_angle = np.degrees(np.arctan2(vp_y - cy, vp_x - cx)) + # 장축은 방향 무관 → 0~90° 범위로 정규화 diff = abs(long_angle_deg - exp_angle) % 180.0 if diff > 90.0: diff = 180.0 - diff - if diff < v_thresh: - return 'V', diff - if diff < h_max_diff: - return 'H', diff + if diff < c_thresh: + return 'C', diff + if diff < b_max_diff: + return 'B', diff return '?', diff # ── Phase 3: 폴리곤 접촉/교차 기반 그룹핑 ─────────────────────────────── +def _poly_union(polys_list): + """여러 폴리곤의 rasterization union 외곽 contour 반환 (실제 union shape).""" + all_pts = np.vstack(polys_list) + x0 = int(all_pts[:, 0].min()) - 1 + y0 = int(all_pts[:, 1].min()) - 1 + x1 = int(all_pts[:, 0].max()) + 2 + y1 = int(all_pts[:, 1].max()) + 2 + w, h = x1 - x0, y1 - y0 + mask = np.zeros((h, w), dtype=np.uint8) + off = np.array([[x0, y0]], dtype=np.float32) + for pts in polys_list: + cv2.fillPoly(mask, [(pts - off).astype(np.int32)], 255) + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if not contours: + return all_pts.astype(np.float32) + cnt = max(contours, key=cv2.contourArea).reshape(-1, 2).astype(np.float32) + return cnt + [x0, y0] + + +def _polys_intersect(pts_i, pts_j, bbox_i, bbox_j): + """두 폴리곤의 실제 픽셀 교차 여부 확인 (rasterization 기반).""" + ax0, ay0, ax1, ay1 = bbox_i + bx0, by0, bx1, by1 = bbox_j + ix0 = max(int(ax0), int(bx0)) + iy0 = max(int(ay0), int(by0)) + ix1 = min(int(ax1), int(bx1)) + iy1 = min(int(ay1), int(by1)) + if ix0 >= ix1 or iy0 >= iy1: + return False + w, h = ix1 - ix0 + 1, iy1 - iy0 + 1 + off = np.array([[ix0, iy0]], dtype=np.float32) + mi = np.zeros((h, w), dtype=np.uint8) + mj = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(mi, [(pts_i - off).astype(np.int32)], 1) + cv2.fillPoly(mj, [(pts_j - off).astype(np.int32)], 1) + return bool(np.any(mi & mj)) + + +def remove_beam_center_small(polys, orients, poly_abs_idx, + center_ratio=0.30, area_ratio=0.15, min_beam_len=100): + """ + B(빔) 폴리곤 장축의 중앙 center_ratio 구간에 위치한 소형 폴리곤 제거. + 소형 기준: 해당 B 면적의 area_ratio 미만. + """ + n = len(polys) + remove_set = set() + + for i in range(n): + if orients[i] != 'B': + continue + la, bcx, bcy, long_side, short_side = _long_axis_angle(polys[i]) + if long_side < min_beam_len: + continue + b_area = cv2.contourArea(polys[i].astype(np.int32)) + if b_area <= 0: + continue + + angle_rad = np.radians(la) + ux, uy = np.cos(angle_rad), np.sin(angle_rad) + + pts_b = polys[i] + proj_b = pts_b[:, 0] * ux + pts_b[:, 1] * uy + proj_center = (proj_b.min() + proj_b.max()) / 2.0 + half = (proj_b.max() - proj_b.min()) * center_ratio / 2.0 + + for j in range(n): + if j == i or j in remove_set: + continue + if orients[j] not in ('B', '?'): # C(기둥)은 절대 제거 안 함 + continue + j_area = cv2.contourArea(polys[j].astype(np.int32)) + if j_area >= b_area * area_ratio: + continue + jcx = float(polys[j][:, 0].mean()) + jcy = float(polys[j][:, 1].mean()) + proj_j = jcx * ux + jcy * uy + if proj_center - half <= proj_j <= proj_center + half: + remove_set.add(j) + + keep = [i for i in range(n) if i not in remove_set] + if remove_set: + print(f" [B 중앙부 소형 제거] {len(remove_set)}개 idx={sorted(remove_set)}") + polys = [polys[i] for i in keep] + orients = [orients[i] for i in keep] + poly_abs_idx = [poly_abs_idx[i] for i in keep] + + return polys, orients, poly_abs_idx, keep + + def connectivity_groups(polys, orients, margin=30): """ - 폴리곤 bbox가 margin px 이내로 닿거나 교차하면 같은 그룹 (H/V 구분 없음). - Union-Find로 연결된 폴리곤들을 묶은 뒤, 각 그룹 내에서 H/V 목록 분리. - Returns: list of {'id': int, 'H': [idx,...], 'V': [idx,...]} + 폴리곤 bbox가 margin px 이내로 닿거나 교차하면 같은 그룹 (B/C 구분 없음). + Union-Find로 연결된 폴리곤들을 묶은 뒤, 각 그룹 내에서 B/C 목록 분리. + Returns: list of {'id': int, 'B': [idx,...], 'C': [idx,...]} """ n = len(polys) parent = list(range(n)) @@ -217,14 +296,14 @@ def connectivity_groups(polys, orients, margin=30): groups = [] for gid, members in enumerate(comp.values(), 1): - h_list = sorted(i for i in members if orients[i] == 'H') - v_list = sorted(i for i in members if orients[i] == 'V') - groups.append({'id': gid, 'H': h_list, 'V': v_list}) + b_list = sorted(i for i in members if orients[i] == 'B') + c_list = sorted(i for i in members if orients[i] == 'C') + groups.append({'id': gid, 'B': b_list, 'C': c_list}) # 면적 내림차순으로 ID 재부여 (큰 그룹이 G1) for g in groups: g['area'] = sum(cv2.contourArea(polys[i].astype(np.int32)) - for i in g['H'] + g['V']) + for i in g['B'] + g['C']) groups.sort(key=lambda x: x['area'], reverse=True) for gid, g in enumerate(groups, 1): g['id'] = gid @@ -232,6 +311,92 @@ def connectivity_groups(polys, orients, margin=30): return groups +def reclassify_center_groups(groups, polys, W, center_ratio=0.2): + """ + RAAMEN_CENTER 후보 그룹 (B 없음, C 2개 이상, 이미지 중앙): + 가장 아래(+Y 최대) C 폴리곤만 C(기둥)로 유지, 나머지 C → B(빔)로 재분류. + """ + for g in groups: + if g['B'] or len(g['C']) < 2: + continue + all_c_pts = np.vstack([polys[i] for i in g['C']]) + gcx = float(all_c_pts[:, 0].mean()) + if abs(gcx - W / 2) >= W * center_ratio: + continue + bottom_c = max(g['C'], key=lambda i: float(polys[i][:, 1].mean())) + g['B'] = sorted(i for i in g['C'] if i != bottom_c) + g['C'] = [bottom_c] + return groups + + +def merge_intersecting_same_type(groups, polys, poly_abs_idx): + """ + 각 그룹 내에서 교차하는 동일 타입(B↔B, C↔C) 폴리곤들을 convex hull로 병합. + 교차하는 폴리곤 클러스터 → 하나의 합성 폴리곤으로 대체. + Returns: (groups, extended_polys, extended_abs_idx) + - extended_polys: 원본 + 새 병합 폴리곤 (append 방식, 원본 인덱스 유지) + - 병합된 폴리곤의 abs_idx는 원본 인덱스 리스트를 저장 (추적용) + """ + from collections import defaultdict + + ext_polys = list(polys) + ext_abs_idx = list(poly_abs_idx) + + for g in groups: + for key in ('B', 'C'): + members = g[key] + if len(members) <= 1: + continue + + n = len(members) + parent = list(range(n)) + + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(a, b): + ra, rb = find(a), find(b) + if ra != rb: + parent[ra] = rb + + bboxes_m = [(ext_polys[members[k]][:, 0].min(), ext_polys[members[k]][:, 1].min(), + ext_polys[members[k]][:, 0].max(), ext_polys[members[k]][:, 1].max()) + for k in range(n)] + + for a in range(n): + for b in range(a + 1, n): + if _polys_intersect(ext_polys[members[a]], ext_polys[members[b]], + bboxes_m[a], bboxes_m[b]): + union(a, b) + + clusters = defaultdict(list) + for k in range(n): + clusters[find(k)].append(members[k]) + + new_members = [] + for cluster in clusters.values(): + if len(cluster) == 1: + new_members.append(cluster[0]) + else: + cluster_polys = [ext_polys[i] for i in cluster] + union_pts = _poly_union(cluster_polys) + new_idx = len(ext_polys) + ext_polys.append(union_pts) + flat = [] + for i in cluster: + v = ext_abs_idx[i] + flat.extend(v) if isinstance(v, list) else flat.append(v) + ext_abs_idx.append(sorted(flat)) + new_members.append(new_idx) + + g[key] = sorted(new_members) + + return groups, ext_polys, ext_abs_idx + + # ── Phase 4: 라멘 구조 판정 ───────────────────────────────────────────── def _cluster_polys(indices, polys, margin=60): @@ -272,57 +437,75 @@ def _cluster_polys(indices, polys, margin=60): def judge_raamen(group, polys, W, center_ratio=0.2, v_cluster_margin=60): """ 라멘 구조 판정. - - 빔 기준: 그룹 내 가장 큰 H 폴리곤 - - 기둥 수: V 폴리곤을 근접 클러스터링한 클러스터 수 - - 빔 x 범위(±50%) 밖의 V 클러스터는 잡폴리곤으로 무시 + - 빔 기준: 그룹 내 가장 큰 B 폴리곤 + - 기둥 수: C 폴리곤을 근접 클러스터링한 클러스터 수 + - 빔 x 범위(±50%) 밖의 C 클러스터는 잡폴리곤으로 무시 Returns: ('RAAMEN' | 'RAAMEN_OCCLUDED' | 'PARTIAL' | '', n_poles) """ - hs, vs = group['H'], group['V'] + bs, cs = group['B'], group['C'] - # H 없음: 중앙 영역이면 V/H 분류 자체가 신뢰 불가 (기둥·빔 각도 수렴) - # → 2개 이상의 V 폴리곤이 중앙에 모여 있으면 RAAMEN_CENTER 처리 - if not hs: - if len(vs) >= 2: - all_v_pts = np.vstack([polys[i] for i in vs]) - gcx = float(all_v_pts[:, 0].mean()) + # B 없음: 중앙 영역이면 C/B 분류 자체가 신뢰 불가 (기둥·빔 각도 수렴) + # → 2개 이상의 C 폴리곤이 중앙에 모여 있으면 RAAMEN_CENTER 처리 + if not bs: + if len(cs) >= 2: + all_c_pts = np.vstack([polys[i] for i in cs]) + gcx = float(all_c_pts[:, 0].mean()) if abs(gcx - W / 2) < W * center_ratio: - return 'RAAMEN_CENTER', len(vs) + return 'RAAMEN_CENTER', len(cs) return '', 0 - # 큰 H 폴리곤 최대 2개를 빔 기준으로 사용 (2nd가 1st 면적의 50% 이상이면 포함) - h_by_area = sorted(hs, key=lambda i: cv2.contourArea(polys[i].astype(np.int32)), reverse=True) - main_hs = [h_by_area[0]] - if len(h_by_area) > 1: - a0 = cv2.contourArea(polys[h_by_area[0]].astype(np.int32)) - a1 = cv2.contourArea(polys[h_by_area[1]].astype(np.int32)) + # 큰 B 폴리곤 최대 2개를 빔 기준으로 사용 (2nd가 1st 면적의 50% 이상이면 포함) + b_by_area = sorted(bs, key=lambda i: cv2.contourArea(polys[i].astype(np.int32)), reverse=True) + main_bs = [b_by_area[0]] + if len(b_by_area) > 1: + a0 = cv2.contourArea(polys[b_by_area[0]].astype(np.int32)) + a1 = cv2.contourArea(polys[b_by_area[1]].astype(np.int32)) if a1 >= a0 * 0.5: - main_hs.append(h_by_area[1]) - hx0 = int(min(polys[i][:, 0].min() for i in main_hs)) - hx1 = int(max(polys[i][:, 0].max() for i in main_hs)) + main_bs.append(b_by_area[1]) + + # 이미지 중앙 여부 판정 (X축 기반 유지) + hx0 = int(min(polys[i][:, 0].min() for i in main_bs)) + hx1 = int(max(polys[i][:, 0].max() for i in main_bs)) hcx = (hx0 + hx1) / 2.0 is_center = abs(hcx - W / 2) < W * center_ratio - span = hx1 - hx0 - # V 폴리곤 클러스터링 → 기둥 단위 - pole_clusters = _cluster_polys(vs, polys, margin=v_cluster_margin) + # 빔 장축(major axis) 방향으로 투영 — 대각 빔도 정확하게 span 계산 + la, _, _, _, _ = _long_axis_angle(polys[main_bs[0]]) + angle_rad = np.radians(la) + ux, uy = np.cos(angle_rad), np.sin(angle_rad) + beam_pts = np.vstack([polys[i] for i in main_bs]) + proj_beam = beam_pts[:, 0] * ux + beam_pts[:, 1] * uy + proj_min, proj_max = float(proj_beam.min()), float(proj_beam.max()) + span = proj_max - proj_min - # 기둥은 빔 양 끝단(좌 35% / 우 35%)에만 존재. 중앙부 클러스터는 부속물로 제외. - x_tol = max(span * 0.5, 50) - left_zone = hx0 + span * 0.35 # 좌끝단 경계 - right_zone = hx1 - span * 0.35 # 우끝단 경계 - valid_cxs = [] + # C 폴리곤 클러스터링 → 기둥 단위 + pole_clusters = _cluster_polys(cs, polys, margin=v_cluster_margin) + + # 기둥은 빔 양 끝단(좌 35% / 우 35%)에만 존재 (장축 투영 기준) + p_tol = max(span * 0.5, 50) + left_zone = proj_min + span * 0.35 + right_zone = proj_max - span * 0.35 + valid_projs = [] for cluster in pole_clusters: - ccx = float(np.mean([polys[i][:, 0].mean() for i in cluster])) - in_range = hx0 - x_tol <= ccx <= hx1 + x_tol - in_end_zone = ccx <= left_zone or ccx >= right_zone + # 멤버별 투영값 계산 + member_projs = [polys[i][:, 0].mean() * ux + polys[i][:, 1].mean() * uy + for i in cluster] + proj_c = float(np.mean(member_projs)) # 중심값 (range 체크) + proj_lo = min(member_projs) # 가장 왼쪽 끝 + proj_hi = max(member_projs) # 가장 오른쪽 끝 + in_range = proj_min - p_tol <= proj_c <= proj_max + p_tol + # 클러스터 안 멤버 중 하나라도 끝단에 있으면 유효 (43C 같은 중간 노이즈가 흡수돼도 기둥 인식) + in_end_zone = proj_lo <= left_zone or proj_hi >= right_zone if in_range and in_end_zone: - valid_cxs.append(ccx) + # 끝단 대표값: 왼쪽 끝단이면 proj_lo, 오른쪽 끝단이면 proj_hi + rep = proj_lo if proj_lo <= left_zone else proj_hi + valid_projs.append(rep) - n_poles = len(valid_cxs) + n_poles = len(valid_projs) if n_poles >= 2: - lcx, rcx = min(valid_cxs), max(valid_cxs) - if lcx <= hx0 + span * 0.4 and rcx >= hx1 - span * 0.4: + lp, rp = min(valid_projs), max(valid_projs) + if lp <= proj_min + span * 0.4 and rp >= proj_max - span * 0.4: return 'RAAMEN', n_poles return 'PARTIAL', n_poles @@ -342,45 +525,54 @@ def _merge_poly_hull(indices, polys): def group_detail(group, polys, W, center_ratio=0.2, v_cluster_margin=60): """ 라멘 그룹의 세부 구성 분석. - Returns dict: main_h, junk_h, valid_pole_clusters, attach_clusters + Returns dict: main_b, junk_b, valid_pole_clusters, attach_clusters """ - hs, vs = group['H'], group['V'] - if not hs: - return {'main_h': None, 'junk_h': [], 'valid_pole_clusters': [], 'attach_clusters': []} + bs, cs = group['B'], group['C'] + if not bs: + return {'main_b': None, 'junk_b': [], 'valid_pole_clusters': [], 'attach_clusters': []} - h_by_area = sorted(hs, key=lambda i: cv2.contourArea(polys[i].astype(np.int32)), reverse=True) - main_hs = [h_by_area[0]] - if len(h_by_area) > 1: - a0 = cv2.contourArea(polys[h_by_area[0]].astype(np.int32)) - a1 = cv2.contourArea(polys[h_by_area[1]].astype(np.int32)) + b_by_area = sorted(bs, key=lambda i: cv2.contourArea(polys[i].astype(np.int32)), reverse=True) + main_bs = [b_by_area[0]] + if len(b_by_area) > 1: + a0 = cv2.contourArea(polys[b_by_area[0]].astype(np.int32)) + a1 = cv2.contourArea(polys[b_by_area[1]].astype(np.int32)) if a1 >= a0 * 0.5: - main_hs.append(h_by_area[1]) - main_h = main_hs[0] # JSON 출력용 대표 빔 - junk_h = [i for i in hs if i not in main_hs] + main_bs.append(b_by_area[1]) + main_b = main_bs[0] # JSON 출력용 대표 빔 + junk_b = [i for i in bs if i not in main_bs] - hx0 = int(min(polys[i][:, 0].min() for i in main_hs)) - hx1 = int(max(polys[i][:, 0].max() for i in main_hs)) - span = hx1 - hx0 + # 빔 장축(major axis) 투영 기반 span 계산 + la, _, _, _, _ = _long_axis_angle(polys[main_bs[0]]) + angle_rad = np.radians(la) + ux, uy = np.cos(angle_rad), np.sin(angle_rad) + beam_pts = np.vstack([polys[i] for i in main_bs]) + proj_beam = beam_pts[:, 0] * ux + beam_pts[:, 1] * uy + proj_min, proj_max = float(proj_beam.min()), float(proj_beam.max()) + span = proj_max - proj_min - pole_clusters = _cluster_polys(vs, polys, margin=v_cluster_margin) - x_tol = max(span * 0.5, 50) - left_zone = hx0 + span * 0.35 - right_zone = hx1 - span * 0.35 + pole_clusters = _cluster_polys(cs, polys, margin=v_cluster_margin) + p_tol = max(span * 0.5, 50) + left_zone = proj_min + span * 0.35 + right_zone = proj_max - span * 0.35 valid_pole_clusters, attach_clusters = [], [] for cluster in pole_clusters: - ccx = float(np.mean([polys[i][:, 0].mean() for i in cluster])) - in_range = hx0 - x_tol <= ccx <= hx1 + x_tol - in_end_zone = ccx <= left_zone or ccx >= right_zone + member_projs = [polys[i][:, 0].mean() * ux + polys[i][:, 1].mean() * uy + for i in cluster] + proj_c = float(np.mean(member_projs)) + proj_lo = min(member_projs) + proj_hi = max(member_projs) + in_range = proj_min - p_tol <= proj_c <= proj_max + p_tol + in_end_zone = proj_lo <= left_zone or proj_hi >= right_zone if in_range and in_end_zone: valid_pole_clusters.append(cluster) elif in_range: attach_clusters.append(cluster) return { - 'main_h': main_h, - 'main_hs': main_hs, - 'junk_h': junk_h, + 'main_b': main_b, + 'main_bs': main_bs, + 'junk_b': junk_b, 'valid_pole_clusters': valid_pole_clusters, 'attach_clusters': attach_clusters, } @@ -388,14 +580,14 @@ def group_detail(group, polys, W, center_ratio=0.2, v_cluster_margin=60): # ── 시각화 상수 ───────────────────────────────────────────────────────── -_VH_COLOR = { - 'V': (255, 80, 0), # 주황 (수직 기둥) - 'H': ( 0, 80, 255), # 파란 (수평 빔) +_CB_COLOR = { + 'C': (255, 80, 0), # 주황 (기둥 Column) + 'B': ( 0, 80, 255), # 파란 (빔 Beam) '?': (140, 140, 140), # 회색 (미분류) } _RAAMEN_COLOR = { 'RAAMEN': ( 0, 255, 0), # 초록 - 'RAAMEN_CENTER': ( 0, 255, 255), # 노랑 (중앙 영역, H/V 분류 불신뢰) + 'RAAMEN_CENTER': ( 0, 255, 255), # 노랑 (중앙 영역, C/B 분류 불신뢰) 'RAAMEN_OCCLUDED': ( 0, 165, 255), # 주황 (가림/부분 검출) 'PARTIAL': (128, 128, 128), # 회색 } @@ -404,9 +596,8 @@ _RAAMEN_COLOR = { # ── 메인 렌더링 ───────────────────────────────────────────────────────── def render(image_path, label_path, output_path, args, - class_ids=None, class_names=None, epsilon=4.0, v_thresh=20.0, - h_max_diff=75.0, vp_min_ar=2.5, vp_min_len=80.0, vp_outer_ratio=0.2, - x_horiz_thresh=10.0): + class_ids=None, class_names=None, epsilon=4.0, c_thresh=20.0, + b_max_diff=75.0, vp_min_ar=2.5, vp_min_len=80.0, vp_outer_ratio=0.2): buf = np.fromfile(str(image_path), dtype=np.uint8) img = cv2.imdecode(buf, cv2.IMREAD_COLOR) @@ -414,12 +605,27 @@ def render(image_path, label_path, output_path, args, # ── Phase 1 ────────────────────────────────────────────────────────── raw_polys, poly_abs_idx = load_polygons(label_path, W, H, class_ids, class_names) - polys = [smooth_polygon(p, epsilon) for p in raw_polys] - print(f" {len(polys)}개 폴리곤 파싱 (epsilon={epsilon})") + polys_all = [smooth_polygon(p, epsilon) for p in raw_polys] + + # 가로:세로 > 4:1 → 전철주 아님 (레일·전선 등), 제거 + keep = [] + skipped = [] + for i, pts in enumerate(polys_all): + bw = pts[:, 0].max() - pts[:, 0].min() + bh = pts[:, 1].max() - pts[:, 1].min() + if bh > 0 and bw / bh > 4.0: + skipped.append(poly_abs_idx[i]) + else: + keep.append(i) + polys = [polys_all[i] for i in keep] + poly_abs_idx = [poly_abs_idx[i] for i in keep] + print(f" {len(polys_all)}개 파싱 → {len(skipped)}개 가로형 필터 제거 → {len(polys)}개 처리") + if skipped: + print(f" 제거 shape idx: {skipped}") img_cx, img_cy = W / 2.0, H / 2.0 - # ── Phase 1+2: 두 가지 VP 시드 방식으로 시도 → V 폴리곤이 더 많은 VP 채택 ── + # ── Phase 1+2: 두 가지 VP 시드 방식으로 시도 → C 폴리곤이 더 많은 VP 채택 ── elong_idx = [] for i, pts in enumerate(polys): la, cx, cy, long_side, short_side = _long_axis_angle(pts) @@ -444,32 +650,38 @@ def render(image_path, label_path, output_path, args, if dist > min(W, H) * vp_outer_ratio and _radial_cos_sim(polys[i], img_cx, img_cy) > 0.5: seeds_B.append(i) - # 두 시드 모두 시도 → V가 더 많이 나오는 VP 채택 + # 두 시드 모두 시도 → C가 더 많이 나오는 VP 채택 best_vp_x, best_vp_y = img_cx, -H * 3.0 - best_n_v = 0 + best_n_c = 0 orients, adiffs = ['?'] * len(polys), [90.0] * len(polys) for label, seeds in [('지배각', seeds_A), ('radial', seeds_B)]: if len(seeds) < 2: continue - vx, vy, nv, ors, ads = _estimate_vp_iterative( - polys, seeds, v_thresh, h_max_diff, vp_min_len, x_horiz_thresh) - print(f" VP [{label}]: ({vx:.0f}, {vy:.0f}) V={nv}") - if nv > best_n_v: - best_vp_x, best_vp_y, best_n_v = vx, vy, nv + vx, vy, nc, ors, ads = _estimate_vp_iterative( + polys, seeds, c_thresh, b_max_diff, vp_min_len) + print(f" VP [{label}]: ({vx:.0f}, {vy:.0f}) C={nc}") + if nc > best_n_c: + best_vp_x, best_vp_y, best_n_c = vx, vy, nc orients, adiffs = ors, ads vp_x, vp_y = best_vp_x, best_vp_y print(f" → 채택 VP: ({vp_x:.1f}, {vp_y:.1f})") - print(f"\n [V/H 분류] threshold=±{v_thresh}° h_max=±{h_max_diff}°") + print(f"\n [C/B 분류] threshold=±{c_thresh}° b_max=±{b_max_diff}°") for i in range(len(polys)): print(f" poly {i:>2d}: {orients[i]} diff={adiffs[i]:.1f}°") - print(f" V:{orients.count('V')} H:{orients.count('H')} ?:{orients.count('?')}") + print(f" C:{orients.count('C')} B:{orients.count('B')} ?:{orients.count('?')}") + + # B 장축 중앙부 소형 폴리곤 제거 (Phase 3 전) + polys, orients, poly_abs_idx, beam_keep = remove_beam_center_small(polys, orients, poly_abs_idx) + old_to_new = {old: new for new, old in enumerate(beam_keep)} + seeds_A = [old_to_new[i] for i in seeds_A if i in old_to_new] + seeds_B = [old_to_new[i] for i in seeds_B if i in old_to_new] # ── Phase 3 ────────────────────────────────────────────────────────── groups = connectivity_groups(polys, orients, margin=args.margin) print(f"\n [그룹핑] 연결 컴포넌트 {len(groups)}개 (margin={args.margin}px)") for g in groups: - print(f" G{g['id']}: H={g['H']} V={g['V']}") + print(f" G{g['id']}: B={g['B']} C={g['C']}") # ── Phase 4 ────────────────────────────────────────────────────────── print(f"\n [라멘 판정]") @@ -478,14 +690,50 @@ def render(image_path, label_path, output_path, args, g['verdict'] = verdict g['n_poles'] = n_poles pole_str = f"{n_poles}poles" if n_poles else "-" - print(f" G{g['id']}: H={g['H']} V={g['V']} → {verdict or '-':18s} ({pole_str})") + print(f" G{g['id']}: B={g['B']} C={g['C']} → {verdict or '-':18s} ({pole_str})") + + # RAAMEN_CENTER: 최하단(+Y 최대) C만 기둥으로 유지, 나머지 C → B (판정 유지) + for g in groups: + if g['verdict'] != 'RAAMEN_CENTER' or len(g['C']) < 2: + continue + bottom_c = max(g['C'], key=lambda i: float(polys[i][:, 1].mean())) + for i in g['C']: + if i != bottom_c: + orients[i] = 'B' + g['B'] = sorted(g['B'] + [i for i in g['C'] if i != bottom_c]) + g['C'] = [bottom_c] + print(f" G{g['id']} [CENTER 재분류]: B={g['B']} C={g['C']}") + + # 그룹 내 동일 타입 교차 폴리곤 병합 (B↔B, C↔C, 폴리곤 교차 기준) + in_group_before = set() + for g in groups: + in_group_before.update(g['B'] + g['C']) + + orig_poly_n = len(polys) + groups, polys, poly_abs_idx = merge_intersecting_same_type(groups, polys, poly_abs_idx) + + # 새 병합 폴리곤의 orient 확장 + new_orient_map = {} + for g in groups: + for key in ('B', 'C'): + for idx in g[key]: + if idx >= orig_poly_n: + new_orient_map[idx] = key + for i in range(orig_poly_n, len(polys)): + orients.append(new_orient_map.get(i, '?')) + + # 병합으로 흡수된 원본 폴리곤 인덱스 (시각화에서 제외) + in_group_after = set() + for g in groups: + in_group_after.update(g['B'] + g['C']) + merged_away = {i for i in in_group_before if i not in in_group_after} valid_items = [g for g in groups if g['verdict']] valid_items.sort(key=lambda x: x['area'], reverse=True) print(f"\n [최종 라멘 객체] {len(valid_items)}개 (면적순)") for g in valid_items: - print(f" G{g['id']}: H={g['H']} V={g['V']} → {g['verdict']:18s} ({g['n_poles']}poles) Area={g['area']:,.0f}") + print(f" G{g['id']}: B={g['B']} C={g['C']} → {g['verdict']:18s} ({g['n_poles']}poles) Area={g['area']:,.0f}") # 최소 면적 필터링 if args.min_group_area > 0: @@ -493,37 +741,23 @@ def render(image_path, label_path, output_path, args, valid_items = [g for g in valid_items if g['area'] >= args.min_group_area] print(f" [필터] 최소 면적 {args.min_group_area} 미만 제거: {before} → {len(valid_items)}개") - # 모든 그룹: 그룹 내 최하단 꼭짓점 포함 폴리곤이 H이면 → V로 재분류 - for g in valid_items: - all_idxs = g['H'] + g['V'] - bottom_vi = max(all_idxs, key=lambda i: polys[i][:, 1].max()) - if bottom_vi in g['H']: - g['H'].remove(bottom_vi) - g['V'].append(bottom_vi) - orients[bottom_vi] = 'V' - # RAAMEN_CENTER (H 없는 그룹): 최하단=V, 나머지=H로 display 재조정 - if not g['H']: - for vi in g['V']: - orients[vi] = 'V' if vi == bottom_vi else 'H' - # ── 시각화 ─────────────────────────────────────────────────────────── - # 1. 폴리곤 V/H 색상 반투명 오버레이 + # 1. 폴리곤 C/B 색상 반투명 오버레이 (fill + outline) — 병합 흡수된 원본은 제외 for i, (pts, orient) in enumerate(zip(polys, orients)): - color = _VH_COLOR[orient] + if i in merged_away: + continue + color = _CB_COLOR[orient] pts_i = pts.astype(np.int32) ov = img.copy() cv2.fillPoly(ov, [pts_i], color) cv2.addWeighted(ov, 0.25, img, 0.75, 0, img) cv2.polylines(img, [pts_i], True, color, 2) - cx, cy = int(pts[:, 0].mean()), int(pts[:, 1].mean()) - lbl = f"{i}{orient}" - cv2.putText(img, lbl, (cx, cy), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 4) - cv2.putText(img, lbl, (cx, cy), cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, 2) # 2. VP 시드 폴리곤에 청록 테두리 (채택된 시드셋 재구성) for i in seeds_A + [i for i in seeds_B if i not in seeds_A]: - cv2.polylines(img, [polys[i].astype(np.int32)], True, (0, 220, 220), 3) + if i not in merged_away: + cv2.polylines(img, [polys[i].astype(np.int32)], True, (0, 220, 220), 3) # 3. 소실점 표시 (이미지 내부: 원, 외부: 방향 화살표) vp_ix, vp_iy = int(vp_x), int(vp_y) @@ -546,7 +780,7 @@ def render(image_path, label_path, output_path, args, for g in valid_items: verdict = g['verdict'] color = _RAAMEN_COLOR[verdict] - all_idx = g['H'] + g['V'] + all_idx = g['B'] + g['C'] all_pts = np.vstack([polys[i] for i in all_idx]).astype(np.int32) x0 = all_pts[:, 0].min() - 15; y0 = all_pts[:, 1].min() - 15 x1 = all_pts[:, 0].max() + 15; y1 = all_pts[:, 1].max() + 15 @@ -557,6 +791,16 @@ def render(image_path, label_path, output_path, args, cv2.putText(img, lbl, (x0, y0 - 10), cv2.FONT_HERSHEY_SIMPLEX, 1.2, color, 2) + # 5. 폴리곤 라벨 — 모든 그래픽 최상단에 그리기 (병합 흡수된 원본 제외) + for i, (pts, orient) in enumerate(zip(polys, orients)): + if i in merged_away: + continue + color = _CB_COLOR[orient] + cx, cy = int(pts[:, 0].mean()), int(pts[:, 1].mean()) + lbl = f"{i}{orient}" + cv2.putText(img, lbl, (cx, cy), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 4) + cv2.putText(img, lbl, (cx, cy), cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, 2) + # 이미지 저장 scale = min(1.0, 4096 / max(H, W)) if scale < 1.0: @@ -580,27 +824,52 @@ def render(image_path, label_path, output_path, args, desc_base = f"G{gid} {verdict}" def abs_ids(rel_list): - """상대 폴리곤 인덱스 → 절대 JSON shapes[] 인덱스 변환.""" - return sorted(poly_abs_idx[i] for i in rel_list) + """상대 폴리곤 인덱스 → 절대 JSON shapes[] 인덱스 변환. 병합 폴리곤은 list.""" + result = [] + for i in rel_list: + v = poly_abs_idx[i] + if isinstance(v, list): + result.extend(v) + else: + result.append(v) + return sorted(result) - if not g['H']: - # RAAMEN_CENTER: 최하단 꼭짓점 포함 폴리곤 = 기둥, 나머지 = 빔 - bottom_vi = max(g['V'], key=lambda i: polys[i][:, 1].max()) - for vi in g['V']: - label = "raamen_pole" if vi == bottom_vi else "raamen_beam" - shapes.append(_shape(label, - [[float(p[0]), float(p[1])] for p in polys[vi]], - gid, f"{desc_base} shape#{poly_abs_idx[vi]}")) - else: - # 일반 RAAMEN: V = 기둥, H = 빔 - for vi in g['V']: - shapes.append(_shape("raamen_pole", - [[float(p[0]), float(p[1])] for p in polys[vi]], - gid, f"{desc_base} pole shape#{poly_abs_idx[vi]}")) - for hi in g['H']: - shapes.append(_shape("raamen_beam", - [[float(p[0]), float(p[1])] for p in polys[hi]], - gid, f"{desc_base} beam shape#{poly_abs_idx[hi]}")) + if not g['B']: + # RAAMEN_CENTER: C 폴리곤만 있는 중앙 영역 그룹 + for ci in g['C']: + shapes.append(_shape("raamen_center", + [[float(p[0]), float(p[1])] for p in polys[ci]], + gid, f"{desc_base} shape#{poly_abs_idx[ci]}")) + continue + + det = group_detail(g, polys, W) + + # 주 빔 (1~2개: 면적 기준 상위, 2nd ≥ 50% 조건 충족 시 포함) + for bi, mb in enumerate(det['main_bs'], 1): + label = "raamen_beam" if bi == 1 else "raamen_beam2" + shapes.append(_shape(label, + _merge_poly_hull([mb], polys), + gid, f"{desc_base} beam{bi} shape#{poly_abs_idx[mb]}")) + + # 잡 B 폴리곤 클러스터 (브라켓 등 부속) + if det['junk_b']: + junk_clusters = _cluster_polys(det['junk_b'], polys) + for jc in junk_clusters: + shapes.append(_shape("raamen_beam_sub", + _merge_poly_hull(jc, polys), + gid, f"{desc_base} beam_sub{abs_ids(jc)}")) + + # 유효 기둥 클러스터 (끝단) + for pi, cluster in enumerate(det['valid_pole_clusters'], 1): + shapes.append(_shape("raamen_pole", + _merge_poly_hull(cluster, polys), + gid, f"{desc_base} pole{pi}{abs_ids(cluster)}")) + + # 중앙 부속물 클러스터 (기둥 아님) + for cluster in det['attach_clusters']: + shapes.append(_shape("raamen_pole_attach", + _merge_poly_hull(cluster, polys), + gid, f"{desc_base} attach{abs_ids(cluster)}")) anylabel_json = { "version": "3.3.9", @@ -616,24 +885,77 @@ def render(image_path, label_path, output_path, args, encoding="utf-8") print(f" → {json_path}") + # ── 원본 폴리곤 분류 JSON 저장 ─────────────────────────────────────── + # 입력 catenary_pole 폴리곤마다 그룹/타입 레이블 부여 + # 그룹 소속: catenary_pole_B / catenary_pole_C (group_id=G번호) + # 그룹 미소속: catenary_pole (원본 그대로) + + abs_to_info = {} # abs_idx → (group_id, verdict, 'B'|'C') + for g in groups: + v = g.get('verdict', '') + for type_char, members in (('B', g['B']), ('C', g['C'])): + for idx in members: + av = poly_abs_idx[idx] + for a in (av if isinstance(av, list) else [av]): + abs_to_info[a] = (g['id'], v, type_char) + + input_data = json.loads(label_path.read_text(encoding="utf-8")) + input_shapes = input_data.get("shapes", []) + + cls_shapes = [] + for abs_idx, s in enumerate(input_shapes): + if class_names and s.get("label", "") not in class_names: + continue + if abs_idx in abs_to_info: + gid, verdict, type_char = abs_to_info[abs_idx] + lbl = f"catenary_pole_{type_char}" + desc = f"G{gid} {verdict}" + gid_out = gid + else: + lbl = s.get("label", "catenary_pole") + desc = s.get("description", "") + gid_out = None + cls_shapes.append({ + "label": lbl, + "score": s.get("score"), + "points": s.get("points", []), + "group_id": gid_out, + "description": desc, + "shape_type": s.get("shape_type", "polygon"), + "flags": s.get("flags"), + }) + + cls_json = { + "version": input_data.get("version", "3.3.9"), + "flags": input_data.get("flags", {}), + "shapes": cls_shapes, + "imagePath": input_data.get("imagePath", image_path.name), + "imageData": None, + "imageHeight": H, + "imageWidth": W, + } + cls_path = output_path.parent / (label_path.stem + "_classified.json") + cls_path.write_text(json.dumps(cls_json, ensure_ascii=False, indent=2), + encoding="utf-8") + print(f" → {cls_path} ({len(cls_shapes)}개 폴리곤)") + def main(): ap = argparse.ArgumentParser() ap.add_argument("--image", required=True) ap.add_argument("--label", required=True) - ap.add_argument("--output", required=True) + ap.add_argument("--output", default=None, + help="출력 jpg 경로 (기본: output/raamen/{이미지명}/{이미지명}_raamen_NNN.jpg)") ap.add_argument("--class-ids", default="", help="포함할 클래스 ID, 콤마 구분 (.txt 전용)") ap.add_argument("--class-names", default="catenary_pole", help="포함할 클래스 이름, 콤마 구분 (.json 전용, 기본: 'catenary_pole')") ap.add_argument("--epsilon", type=float, default=4.0, help="approxPolyDP epsilon (기본 4.0px)") - ap.add_argument("--v-thresh", type=float, default=20.0, - help="V/H 분류 각도 임계값 degrees (기본 20°)") - ap.add_argument("--h-max-diff", type=float, default=75.0, - help="H(빔) 최대 각도 diff; 이 이상은 레일/전선 등으로 제외 (기본 75°)") - ap.add_argument("--x-horiz-thresh", type=float, default=10.0, - help="X축 절대 수평 제외 임계값 degrees; AR≥4 AND 이 각도 이내 → 제외 (기본 10°)") + ap.add_argument("--c-thresh", type=float, default=20.0, + help="C/B 분류 각도 임계값 degrees (기본 20°)") + ap.add_argument("--b-max-diff", type=float, default=75.0, + help="B(빔) 최대 각도 diff; 이 이상은 레일/전선 등으로 제외 (기본 75°)") ap.add_argument("--margin", type=int, default=30, help="폴리곤 접촉 판정 margin px (기본 30)") ap.add_argument("--min-group-area", type=float, default=0, @@ -645,21 +967,23 @@ def main(): class_names = ({x.strip() for x in args.class_names.split(',') if x.strip()} if args.class_names else None) - out = Path(args.output) - folder = out.parent / out.stem # e.g. output/0004_test - if folder.exists(): + if args.output: + out_path = Path(args.output) + else: + img_stem = Path(args.image).stem + out_dir = Path("output") / "raamen" / img_stem + out_dir.mkdir(parents=True, exist_ok=True) n = 1 - while (out.parent / f"{out.stem}_{n}").exists(): + while True: + out_path = out_dir / f"{img_stem}_raamen_{n:03d}.jpg" + if not out_path.exists(): + break n += 1 - folder = out.parent / f"{out.stem}_{n}" - folder.mkdir(parents=True, exist_ok=True) - out = folder / out.name # e.g. output/0004_test/0004_test.jpg - print(f" [출력 폴더] {folder}") - render(Path(args.image), Path(args.label), out, args, + render(Path(args.image), Path(args.label), out_path, args, class_ids=class_ids, class_names=class_names, - epsilon=args.epsilon, v_thresh=args.v_thresh, - h_max_diff=args.h_max_diff, x_horiz_thresh=args.x_horiz_thresh) + epsilon=args.epsilon, c_thresh=args.c_thresh, + b_max_diff=args.b_max_diff) if __name__ == "__main__": diff --git a/tools/group_ramen_poles.py b/tools/group_ramen_poles.py new file mode 100644 index 0000000..4b652e5 --- /dev/null +++ b/tools/group_ramen_poles.py @@ -0,0 +1,221 @@ +"""group_ramen_poles.py — catenary_pole 라멘 그룹 검출 및 group_id 할당. + +H 빔(수평) + 인접 V 기둥(수직) 쌍을 찾아 동일 group_id 부여. +결과 JSON은 post_merge_poles.py가 group_id 없는 V만 병합할 수 있도록 선행 실행. + +Usage: + python tools/group_ramen_poles.py INPUT.json [--inplace] + python tools/group_ramen_poles.py INPUT.json --x-overlap 0.15 --max-dist 400 +""" +import argparse +import json +import sys +from pathlib import Path + +import cv2 +import numpy as np + + +def _poly_orient(points, H, W, cos_high=0.75, cos_low=0.45, debug=False): + """V/H/?_ambiguous 판별. + + cos_sim > cos_high → V (명확 기둥) + cos_sim < cos_low → H (명확 빔) + 그 사이 또는 중앙 근처 → ?_ambiguous (후처리에서 Y 최대=V 판별) + """ + pts = np.array(points, dtype=np.float32) + rect = cv2.minAreaRect(pts) + (rx, ry), (rw, rh), angle = rect + if min(rw, rh) < 1: + if debug: print(f" → ? (min_side<1)") + return '?' + ar = max(rw, rh) / min(rw, rh) + if ar < 1.3: + if debug: print(f" → ? (ar={ar:.2f}<1.3)") + return '?' + rdx, rdy = rx - W / 2.0, ry - H / 2.0 + radial_norm = (rdx ** 2 + rdy ** 2) ** 0.5 + center_thresh = (H ** 2 + W ** 2) ** 0.5 * 0.15 + if radial_norm < center_thresh: + if debug: print(f" → ?_ambiguous (center, norm={radial_norm:.0f}<{center_thresh:.0f})") + return '?_ambiguous' + long_angle_deg = angle if rw >= rh else angle + 90 + lx = float(np.cos(np.radians(long_angle_deg))) + ly = float(np.sin(np.radians(long_angle_deg))) + cos_sim = abs(lx * rdx / radial_norm + ly * rdy / radial_norm) + if cos_sim > cos_high: + orient = 'V' + elif cos_sim < cos_low: + orient = 'H' + else: + orient = '?_ambiguous' + if debug: + bw = pts[:, 0].max() - pts[:, 0].min() + bh = pts[:, 1].max() - pts[:, 1].min() + print(f" → {orient} (ar={ar:.2f} cos_sim={cos_sim:.3f} " + f"bbox={int(bw)}x{int(bh)})") + return orient + + +def _fix_ambiguous_orients(indices, orients, shapes): + """?_ambiguous 폴리곤: x-range 겹치는 그룹 내 Y 최대(가장 아래)=V, 나머지=H. + + 기둥 하단은 지면에 박혀 더 아래쪽(y 최대), 빔은 상단에 설치되어 위쪽. + """ + amb_ids = [i for i in indices if orients[i] == '?_ambiguous'] + if not amb_ids: + return + bboxes = {i: _get_bbox(shapes[i]["points"]) for i in amb_ids} + assigned = set() + for i in amb_ids: + if i in assigned: + continue + bi = bboxes[i] + group = [i] + for j in amb_ids: + if j == i or j in assigned: + continue + bj = bboxes[j] + if bi[0] <= bj[2] and bj[0] <= bi[2]: # x-range 겹침 + group.append(j) + bottom = max(group, key=lambda k: bboxes[k][3]) # y_max 최대 = 가장 아래 = V + for k in group: + orients[k] = 'V' if k == bottom else 'H' + assigned.add(k) + print(f" [ambiguous fix] 그룹{group}: " + f"V={bottom}, H={[k for k in group if k != bottom]}") + + +def _get_bbox(points): + xs = [p[0] for p in points] + ys = [p[1] for p in points] + return min(xs), min(ys), max(xs), max(ys) + + +def _y_gap(b1, b2): + return max(0.0, max(b1[1], b2[1]) - min(b1[3], b2[3])) + + +def detect_ramen_groups(shapes, iH, iW, label="catenary_pole", + x_overlap_thresh=0.20, max_pole_dist=300, + cos_high=0.75, cos_low=0.45): + """V/H 판별 → H빔 anchor로 인접 V 매칭 → 라멘 group_id 할당. + + 반환: [(group_id, [h_indices], [v_indices]), ...] + """ + pole_indices = [i for i, s in enumerate(shapes) if s.get("label") == label] + + for i in pole_indices: + shapes[i]["group_id"] = None + + if not pole_indices: + return [] + + # Step 1: V/H 판별 + print(f" [orient] 전체 {len(pole_indices)}개 전철주 판별:") + orients = {} + for i in pole_indices: + print(f" shape[{i}]", end="") + orients[i] = _poly_orient(shapes[i]["points"], iH, iW, + cos_high=cos_high, cos_low=cos_low, debug=True) + _fix_ambiguous_orients(pole_indices, orients, shapes) + + h_indices = [i for i in pole_indices if orients[i] == 'H'] + v_indices = [i for i in pole_indices if orients[i] == 'V'] + print(f" V={len(v_indices)}, H={len(h_indices)}, " + f"?={sum(1 for o in orients.values() if o not in ('V', 'H'))}") + + # Step 2: H빔 anchor → 인접 V 매칭 + used_v = set() + raw_groups = [] + for hi in h_indices: + hb = _get_bbox(shapes[hi]["points"]) + hx0, hy0, hx1, hy1 = hb + h_width = max(hx1 - hx0, 1) + matched_v = [] + for vi in v_indices: + if vi in used_v: + continue + vb = _get_bbox(shapes[vi]["points"]) + vcx = (vb[0] + vb[2]) / 2.0 + margin = x_overlap_thresh * h_width + if (hx0 - margin) <= vcx <= (hx1 + margin) and _y_gap(hb, vb) <= max_pole_dist: + matched_v.append(vi) + if matched_v: + raw_groups.append(([hi], matched_v)) + used_v.update(matched_v) + + # Step 3: group_id 할당 + existing_gids = [s.get("group_id") for s in shapes if isinstance(s.get("group_id"), int)] + next_gid = (max(existing_gids) + 1) if existing_gids else 1 + + result = [] + for h_list, v_list in raw_groups: + gid = next_gid + next_gid += 1 + for i in h_list + v_list: + shapes[i]["group_id"] = gid + result.append((gid, h_list, v_list)) + print(f" → 라멘 group_id={gid}: H{h_list} V{v_list}") + + return result + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("input", help="AnyLabeling JSON 파일") + ap.add_argument("--inplace", action="store_true", help="원본 덮어쓰기") + ap.add_argument("--output", default=None) + ap.add_argument("--label", default="catenary_pole") + ap.add_argument("--x-overlap", type=float, default=0.20, + help="H 빔 너비 기준 x 여유 비율 (기본 0.20)") + ap.add_argument("--max-dist", type=int, default=300, + help="H-V y 간격 최대값 px (기본 300)") + ap.add_argument("--cos-high", type=float, default=0.75, + help="cos_sim V 판별 상한 (기본 0.75)") + ap.add_argument("--cos-low", type=float, default=0.45, + help="cos_sim H 판별 하한 (기본 0.45)") + args = ap.parse_args() + + src = Path(args.input) + if not src.exists(): + print(f"파일 없음: {src}", file=sys.stderr) + sys.exit(1) + + data = json.loads(src.read_text(encoding="utf-8")) + shapes = data.get("shapes", []) + iW = data.get("imageWidth", 0) + iH = data.get("imageHeight", 0) + if iW == 0 or iH == 0: + print("imageWidth/imageHeight 없음", file=sys.stderr) + sys.exit(1) + + groups = detect_ramen_groups(shapes, iH, iW, args.label, args.x_overlap, args.max_dist, + args.cos_high, args.cos_low) + + print(f"\n라멘 그룹 {len(groups)}개 검출:") + for gid, h_list, v_list in groups: + print(f" group_id={gid}: H{h_list} V{v_list}") + + poles = [s for s in shapes if s.get("label") == args.label] + ungrouped_v = [ + s for s in poles + if s.get("group_id") is None and _poly_orient(s["points"], iH, iW) == 'V' + ] + print(f"그룹 미할당 V 기둥 (병합 대상): {len(ungrouped_v)}개") + + data["shapes"] = shapes + + if args.inplace: + dst = src + elif args.output: + dst = Path(args.output) + else: + dst = src.with_stem(src.stem + "_grouped") + + dst.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"저장: {dst}") + + +if __name__ == "__main__": + main() diff --git a/tools/render_everything_by_label.py b/tools/render_everything_by_label.py new file mode 100644 index 0000000..3ed729b --- /dev/null +++ b/tools/render_everything_by_label.py @@ -0,0 +1,146 @@ +"""everything.json을 레이블별로 분리해 bbox를 원본 이미지에 표시. + +사용: + python tools/render_everything_by_label.py \ + --image data/역사이미지/slope/DJI_20260306113838_0004_tophalf.jpg \ + --json output/raamen/DJI_20260306113838_0004_tophalf_everything.json \ + --output output/everything_by_label \ + [--scale 0.3] \ + [--label small_dark_object_on_ballast ...] \ + [--rail-offset 200] # railroad_track/railway_rail bbox + N픽셀 안쪽 필터 +""" +import argparse +import json +import re +import cv2 +import numpy as np +from pathlib import Path + +RAIL_LABELS = {"railroad track", "railway rail"} + + +def safe_name(label: str) -> str: + return re.sub(r"[^\w]", "_", label) + + +def build_rail_zone(segments: list, offset: int, img_h: int, img_w: int): + """railroad_track/railway_rail 세그먼트 합산 bbox + offset → (x1,y1,x2,y2).""" + xs1, ys1, xs2, ys2 = [], [], [], [] + for seg in segments: + if seg.get("label", "").strip().lower() in RAIL_LABELS: + bx1, by1, bx2, by2 = seg["bbox"] + xs1.append(bx1); ys1.append(by1) + xs2.append(bx2); ys2.append(by2) + if not xs1: + return None + return ( + max(0, int(min(xs1)) - offset), + max(0, int(min(ys1)) - offset), + min(img_w, int(max(xs2)) + offset), + min(img_h, int(max(ys2)) + offset), + ) + + +def in_zone(seg_bbox, zone): + """세그먼트 bbox 중심이 zone 안에 있으면 True.""" + bx1, by1, bx2, by2 = seg_bbox + cx, cy = (bx1 + bx2) / 2, (by1 + by2) / 2 + zx1, zy1, zx2, zy2 = zone + return zx1 <= cx <= zx2 and zy1 <= cy <= zy2 + + +def render_label(img_full, segments, label: str, output_path: Path, scale: float, + zone=None): + h, w = img_full.shape[:2] + canvas = cv2.resize(img_full, (int(w * scale), int(h * scale)), + interpolation=cv2.INTER_AREA) + + color = (0, 80, 255) + font = cv2.FONT_HERSHEY_SIMPLEX + font_scale = max(0.4, scale * 2.0) + thickness = max(1, int(scale * 5)) + + if zone: + zx1, zy1, zx2, zy2 = [int(v * scale) for v in zone] + cv2.rectangle(canvas, (zx1, zy1), (zx2, zy2), (0, 255, 0), 2) + + for seg in segments: + if seg.get("points"): + pts = np.array([[int(x * scale), int(y * scale)] for x, y in seg["points"]], dtype=np.int32) + overlay = canvas.copy() + cv2.fillPoly(overlay, [pts], color) + cv2.addWeighted(overlay, 0.25, canvas, 0.75, 0, canvas) + cv2.polylines(canvas, [pts], True, color, thickness) + else: + x1, y1, x2, y2 = [int(v * scale) for v in seg["bbox"]] + cv2.rectangle(canvas, (x1, y1), (x2, y2), color, thickness) + + header = f"{label} [{len(segments)}]" + cv2.putText(canvas, header, (10, 40), font, font_scale * 1.2, + (0, 220, 0), max(1, int(scale * 3)) + 1) + + output_path.parent.mkdir(parents=True, exist_ok=True) + ret, buf = cv2.imencode(".jpg", canvas, [cv2.IMWRITE_JPEG_QUALITY, 88]) + if not ret: + raise RuntimeError("JPEG 인코딩 실패") + output_path.write_bytes(buf.tobytes()) + print(f" {label:40s} {len(segments):5d}개 → {output_path.name}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--image", required=True, type=Path) + ap.add_argument("--json", required=True, type=Path) + ap.add_argument("--output", required=True, type=Path) + ap.add_argument("--scale", type=float, default=0.25) + ap.add_argument("--label", nargs="*", default=None) + ap.add_argument("--rail-offset", type=int, default=0, + help="railroad_track/railway_rail 합산 bbox + N픽셀 zone 필터 (0=비활성)") + ap.add_argument("--zone", type=int, nargs=4, metavar=("X1","Y1","X2","Y2"), default=None, + help="수동 zone 지정 (--rail-offset보다 우선)") + args = ap.parse_args() + + buf = np.fromfile(str(args.image), dtype=np.uint8) + img = cv2.imdecode(buf, cv2.IMREAD_COLOR) + if img is None: + raise FileNotFoundError(f"이미지 읽기 실패: {args.image}") + img_h, img_w = img.shape[:2] + + with open(args.json, encoding="utf-8") as f: + data = json.load(f) + + groups: dict[str, list] = {} + for seg in data["segments"]: + groups.setdefault(seg["label"], []).append(seg) + + zone = None + if args.zone: + zone = tuple(args.zone) + print(f"수동 zone: {zone}") + elif args.rail_offset > 0: + zone = build_rail_zone(data["segments"], args.rail_offset, img_h, img_w) + if zone: + print(f"레일 zone (offset={args.rail_offset}px): {zone}") + else: + print("경고: railroad_track/railway_rail 세그먼트 없음 → zone 필터 미적용") + + target_labels = args.label if args.label else sorted(groups) + + print(f"원본: {img_w}x{img_h} scale={args.scale}") + print(f"레이블 {len(target_labels)}개 렌더링 → {args.output}/") + + for lbl in target_labels: + if lbl not in groups: + print(f" [skip] {lbl} - no segments") + continue + segs = sorted(groups[lbl], key=lambda s: s["score"], reverse=True) + if zone: + segs = [s for s in segs if in_zone(s["bbox"], zone)] + fname = f"{safe_name(lbl)}.jpg" + render_label(img, segs, lbl, args.output / fname, args.scale, zone=zone) + + print("완료.") + + +if __name__ == "__main__": + main() diff --git a/tools/render_label_polygons.py b/tools/render_label_polygons.py new file mode 100644 index 0000000..a8bca0f --- /dev/null +++ b/tools/render_label_polygons.py @@ -0,0 +1,83 @@ +"""LabelMe JSON에서 특정 레이블 폴리곤만 원본 이미지에 표시. + +사용: + python tools/render_label_polygons.py \ + --image data/역사구간/.../DJI_20260306100900_0034.JPG \ + --json output/detect/DJI_20260306100900_0034/tiles1to24_railway_zone_001.json \ + --label catenary_pole \ + --output output/detect/DJI_20260306100900_0034/catenary_pole_only.jpg + + # 여러 레이블: + python tools/render_label_polygons.py ... --label catenary_pole bracket + + # 레이블 생략 시 전체 표시: + python tools/render_label_polygons.py ... --json foo.json --image foo.jpg +""" +import argparse +import json +import cv2 +import numpy as np +from pathlib import Path + + +COLORS = [ + (0, 0, 255), # red + (0, 200, 0), # green + (255, 100, 0), # blue-orange + (0, 200, 255), # yellow + (200, 0, 200), # magenta + (0, 165, 255), # orange + (255, 0, 100), # pink-blue + (100, 255, 100),# light green +] + + +def render(image_path: Path, json_path: Path, output_path: Path, labels: list[str] | None): + buf = np.fromfile(str(image_path), dtype=np.uint8) + img = cv2.imdecode(buf, cv2.IMREAD_COLOR) + if img is None: + raise FileNotFoundError(f"이미지 읽기 실패: {image_path}") + + with open(json_path, encoding="utf-8") as f: + data = json.load(f) + + shapes = data.get("shapes", []) + if labels: + shapes = [s for s in shapes if s["label"] in labels] + + # 레이블별 색상 매핑 + label_set = sorted({s["label"] for s in shapes}) + color_map = {lbl: COLORS[i % len(COLORS)] for i, lbl in enumerate(label_set)} + + for s in shapes: + pts = np.array(s["points"], dtype=np.int32).reshape((-1, 1, 2)) + color = color_map[s["label"]] + cv2.polylines(img, [pts], isClosed=True, color=color, thickness=8) + x, y = int(s["points"][0][0]), int(s["points"][0][1]) + score = s.get("score") + text = f"{s['label']} {score:.2f}" if score is not None else s["label"] + cv2.putText(img, text, (x, max(y - 10, 20)), + cv2.FONT_HERSHEY_SIMPLEX, 2.5, color, 5) + + output_path.parent.mkdir(parents=True, exist_ok=True) + ret, buf_out = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85]) + if not ret: + raise RuntimeError("JPEG 인코딩 실패") + output_path.write_bytes(buf_out.tobytes()) + print(f"저장: {output_path} ({len(shapes)}개 폴리곤)") + + +def main(): + p = argparse.ArgumentParser(description="LabelMe JSON 폴리곤 시각화") + p.add_argument("--image", required=True, type=Path) + p.add_argument("--json", required=True, type=Path) + p.add_argument("--output", required=True, type=Path) + p.add_argument("--label", nargs="*", default=None, + help="표시할 레이블 (생략 시 전체)") + args = p.parse_args() + + render(args.image, args.json, args.output, args.label) + + +if __name__ == "__main__": + main() diff --git a/tools/sam3_everything_explore.py b/tools/sam3_everything_explore.py index bae2369..929b7fc 100644 --- a/tools/sam3_everything_explore.py +++ b/tools/sam3_everything_explore.py @@ -53,7 +53,12 @@ DISCOVERY_PROMPT = ( "road, asphalt, pavement, " "slope, embankment, retaining wall, " "noise barrier, sound wall, " - "signal, sign board" + "signal, sign board, " + "small dark object on ballast, small dark object on railway, " + "small square metal box on ground, control box on ballast, " + "gray square lid on gravel, flat metal cover on ground, " + "small bright object on ballast, small white box on ballast, " + "small gray box on ground, bright square object on gravel" ) @@ -72,7 +77,7 @@ def sam3_everything(tile_bgr: np.ndarray, conf: float, prompt: str = DISCOVERY_P }, } try: - r = requests.post(f"{SAM3_SERVER}/v1/predict", json=payload, timeout=120) + r = requests.post(f"{SAM3_SERVER}/v1/predict", json=payload, timeout=300) r.raise_for_status() resp = r.json() if not resp.get("success"): @@ -119,14 +124,23 @@ def nms_shapes(shapes: list, iou_thresh: float = 0.4) -> list: # ── 타일 분할 + 병렬 검출 ───────────────────────────────────────────────────── -def detect_everything_tiled(image_bgr, cols, rows, overlap, conf, workers, prompt): +def detect_everything_tiled(image_bgr, cols, rows, overlap, conf, workers, prompt, + zone=None): + """zone=(x1,y1,x2,y2) 지정 시 겹치는 타일만 처리.""" H, W = image_bgr.shape[:2] base_w = W / cols base_h = H / rows pad_x = int(base_w * overlap) pad_y = int(base_h * overlap) + def overlaps_zone(tx0, ty0, tx1, ty1): + if zone is None: + return True + zx1, zy1, zx2, zy2 = zone + return tx0 < zx2 and tx1 > zx1 and ty0 < zy2 and ty1 > zy1 + tiles = [] + skipped = 0 for r in range(rows): for c in range(cols): idx = r * cols + c + 1 @@ -134,9 +148,14 @@ def detect_everything_tiled(image_bgr, cols, rows, overlap, conf, workers, promp x1 = min(W, int((c + 1) * base_w) + pad_x) y0 = max(0, int(r * base_h) - pad_y) y1 = min(H, int((r + 1) * base_h) + pad_y) - tiles.append((idx, x0, y0, x1, y1)) + if overlaps_zone(x0, y0, x1, y1): + tiles.append((idx, x0, y0, x1, y1)) + else: + skipped += 1 - total = len(tiles) + total = len(tiles) + if skipped: + print(f"zone 필터: {skipped}타일 스킵, {total}타일 처리") done = [0] all_shapes = [] @@ -227,6 +246,8 @@ def main(): ap.add_argument("--workers", type=int, default=4, help="병렬 스레드 수 (기본 4)") ap.add_argument("--nms", type=float, default=0.40, help="NMS IoU 임계값 (기본 0.40)") ap.add_argument("--prompt-extra", default="", help="DISCOVERY_PROMPT 뒤에 추가할 어휘 (콤마 구분)") + ap.add_argument("--zone", type=int, nargs=4, metavar=("X1","Y1","X2","Y2"), default=None, + help="처리 zone 제한 (이 범위와 겹치는 타일만 처리)") args = ap.parse_args() prompt = DISCOVERY_PROMPT + (", " + args.prompt_extra.strip(", ") if args.prompt_extra.strip() else "") @@ -248,10 +269,14 @@ def main(): print(f" · {item.strip()}") print() + zone = tuple(args.zone) if args.zone else None + if zone: + print(f"zone 제한: x={zone[0]}~{zone[2]} y={zone[1]}~{zone[3]}\n") + t0 = time.time() shapes = detect_everything_tiled( image_bgr, args.cols, args.rows, args.overlap, - args.conf, args.workers, prompt + args.conf, args.workers, prompt, zone=zone ) print(f"검출 {len(shapes)}개 → NMS(iou={args.nms})...") shapes = nms_shapes(shapes, iou_thresh=args.nms) @@ -279,7 +304,8 @@ def main(): )), "segments": [ {"label": s.get("label",""), "score": s.get("score",0), - "bbox": list(_bbox(s["points"]))} + "bbox": list(_bbox(s["points"])), + "points": s["points"]} for s in shapes ] }