철도 파이프라인 설계

드론 촬영 이미지에서 선로를 탐지하고 중심선을 추출하여 DB에 저장하는 파이프라인을 설계한다. 목적에 따라 SAM 단독 또는 SAM+YOLO 반복 학습 파이프라인을 선택한다.

항목내용
최종 목표드론 이미지 → 선로 탐지 → 중심선 좌표 → DB 저장
핵심 모델SAM 3 (세그멘테이션), YOLO-Seg (반복 학습 시)
중심선 추출스켈레톤화 (CV 알고리즘, AI 아님)
저장 형식GeoJSON / PostGIS

문서 탐색


목적별 파이프라인 선택

flowchart TD
    A[데이터가 주기적으로<br/>계속 들어오는가?] -->|YES| B{실시간 필요?}
    A -->|NO 1~2회 처리| C[파이프라인 A<br/>SAM 단독 배치]

    B -->|YES| D[파이프라인 C<br/>SAM→YOLO-Seg 학습→엣지 배포]
    B -->|NO 배치 처리| E[파이프라인 B<br/>SAM+YOLO 반복 학습]

파이프라인 비교

파이프라인 A파이프라인 B파이프라인 C
이름SAM 단독 배치SAM+YOLO 반복 학습SAM→YOLO 실시간
처리 방식배치 (1회성)배치 (반복)실시간
학습 필요없음YOLO 학습 반복YOLO 학습 1회+
준비 시간즉시1차: 수일, 이후 자동화수일~수주
처리 속도~30ms/장2차부터 ~2ms/장~2ms/장
정확도 변화항상 동일사이클마다 향상SAM 라벨 품질에 의존
GPU 요구16GB+ (추론)8GB+ (학습+추론)학습: 8GB+, 배포: 2GB
적합 시나리오1회 조사, 소규모월별 정기 점검드론 실시간 추적

파이프라인 A: SAM 단독 배치 — 중심선 추출

쌓인 데이터를 한 번에 처리하여 중심선 좌표를 DB에 저장하는 가장 단순한 파이프라인이다. 실시간이 불필요하면 YOLO 학습 과정이 전부 필요 없다.

전체 흐름

flowchart TD
    A[드론 촬영 이미지<br/>배치 수집] --> B[SAM 3 세그멘테이션<br/>텍스트: railway rail]
    B --> C[마스크 후처리<br/>모폴로지 연산]
    C --> D[스켈레톤화<br/>1px 중심선 추출]
    D --> E[좌표 추출 + 스무딩<br/>Douglas-Peucker 간소화]
    E --> F[픽셀→GPS 변환<br/>EXIF + GSD 계산]
    F --> G[DB 저장<br/>GeoJSON / PostGIS]

단계별 상세

단계작업도구출력
1선로 세그멘테이션SAM 3 (PCS 텍스트 프롬프트)이진 마스크
2마스크 후처리OpenCV 모폴로지 (Open + Close)정제된 마스크
3스켈레톤화scikit-image skeletonize1px 중심선
4좌표 추출OpenCV findContours + approxPolyDP폴리라인 좌표
5GPS 변환EXIF GPS + GSD 계산위경도 좌표
6DB 저장GeoJSON / PostGIS폴리라인 데이터

SAM 세그멘테이션

from ultralytics import SAM
 
model = SAM("sam3_large.pt")
results = model("drone_image.jpg", texts=["railway rail"])
 
mask = results[0].masks.data[0].cpu().numpy()
conf = results[0].masks.conf[0].item()

텍스트 프롬프트 설계: 영어로 작성하는 것이 성능에 유리하다.

프롬프트기대 결과비고
"railway rail"레일(궤조) 자체가장 정확
"railway track"선로 전체 (레일+침목+자갈)넓은 범위
"rail"레일 또는 난간 등 모호피해야 할 프롬프트

마스크 후처리 + 스켈레톤화

import cv2
import numpy as np
from skimage.morphology import skeletonize
 
def mask_to_centerline(mask):
    """SAM 마스크 → 중심선 좌표 추출"""
    mask_uint8 = (mask * 255).astype(np.uint8)
 
    # 1. 모폴로지: 노이즈 제거 + 끊김 연결
    k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
    k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
    refined = cv2.morphologyEx(mask_uint8, cv2.MORPH_OPEN, k_open)
    refined = cv2.morphologyEx(refined, cv2.MORPH_CLOSE, k_close)
 
    # 2. 스켈레톤화 (Zhang 방식, 가지 적음)
    binary = (refined > 127).astype(np.uint8)
    skeleton = skeletonize(binary).astype(np.uint8) * 255
 
    # 3. 좌표 추출 + 간소화
    contours, _ = cv2.findContours(skeleton, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
    if not contours:
        return None
    largest = max(contours, key=cv2.contourArea)
    simplified = cv2.approxPolyDP(largest, epsilon=2.0, closed=False)
 
    return simplified.squeeze()
모폴로지 연산효과
Opening (열림)작은 노이즈 점 제거
Closing (닫힘)끊긴 선로 영역 연결
면적 필터링전체 1% 미만 소영역 제거

스켈레톤 알고리즘 비교

알고리즘함수특징추천
Zhangskeletonize(mask)빠름, 가지 적음선로에 적합
Leeskeletonize(mask, method='lee')미세 구조 보존복잡한 형태
Medial Axismedial_axis(mask, return_distance=True)폭 정보 포함분기점 탐지

GPS 변환 + DB 저장

import math, json
 
def pixel_to_gps(px, py, center_gps, gsd_m, img_shape, heading=0):
    """픽셀 좌표 → GPS 변환"""
    h, w = img_shape
    dx = (px - w/2) * gsd_m
    dy = (h/2 - py) * gsd_m
    rad = math.radians(heading)
    east = dx * math.cos(rad) - dy * math.sin(rad)
    north = dx * math.sin(rad) + dy * math.cos(rad)
    lat, lon = center_gps
    return lat + north/111320, lon + east/(111320 * math.cos(math.radians(lat)))
 
# GeoJSON LineString으로 저장
feature = {
    "type": "Feature",
    "geometry": {"type": "LineString", "coordinates": [[lon, lat] for lat, lon in gps_points]},
    "properties": {"source_image": "drone_001.jpg", "confidence": 0.92}
}
비행 고도GSD선로 폭(1435mm) 표현
30m~0.8 cm/px~179 픽셀
50m~1.4 cm/px~102 픽셀
100m~2.7 cm/px~53 픽셀
150m~4.1 cm/px~35 픽셀

PostGIS 테이블 설계

CREATE TABLE railway_centerlines (
    id              SERIAL PRIMARY KEY,
    geom            GEOMETRY(LineString, 4326),
    source_image    VARCHAR(255),
    confidence      FLOAT,
    drone_altitude  FLOAT,
    gsd             FLOAT,
    extracted_at    TIMESTAMP DEFAULT NOW(),
    verified        BOOLEAN DEFAULT FALSE
);
 
CREATE INDEX idx_centerlines_geom ON railway_centerlines USING GIST (geom);

파이프라인 B: SAM+YOLO 반복 학습

데이터가 주기적으로 들어올 때, 사이클마다 정확도를 높여가는 파이프라인이다. SAM은 학습하지 않으므로 YOLO를 통해서만 데이터 축적 효과를 얻을 수 있다.

SAM은 왜 학습하지 않는가?

SAM은 Meta가 11억 개 마스크 + 1,100만 장 이미지로 이미 학습을 완료한 추론 전용(inference-only) 모델이다. 10만 장을 처리해도 모델 자체는 변하지 않는다.

배치처리량SAM 모델 상태결과 정확도
1차 (1,000장)1,000장sam3_large.pt (변화 없음)85%
5차 (5,000장)누적 5,000장sam3_large.pt (변화 없음)85% (동일)
10차 (10,000장)누적 10,000장sam3_large.pt (변화 없음)85% (동일)

반복 학습 아키텍처

flowchart TD
    subgraph "1차 사이클"
        A[드론 이미지 배치 1] --> B[SAM 3 자동 라벨링]
        B --> C[사람 검수]
        C --> D[YOLO-Seg 학습 v1]
    end

    subgraph "2차+ 사이클"
        E[드론 이미지 배치 N] --> F[YOLO vN 추론]
        F --> G{신뢰도?}
        G -->|높음| H[자동 확정]
        G -->|낮음| I[SAM 보정 + 사람 검수]
        H --> J[학습 데이터 추가]
        I --> J
        J --> K[YOLO 재학습 vN+1]
    end

    D --> F


Teacher-Student 구조

역할모델크기속도용도
Teacher (선생님)SAM 33.4GB~30ms/장라벨 자동 생성 (1차만)
Student (학생)YOLO11-Seg12MB~2ms/장실제 추론 (2차부터)

Autodistill 구현

from autodistill_grounded_sam2 import GroundedSAM2
from autodistill.detection import CaptionOntology
from autodistill_yolov8 import YOLOv8
 
# Teacher: SAM이 자동 라벨링
ontology = CaptionOntology({"railway rail": "rail", "railroad tie": "sleeper"})
teacher = GroundedSAM2(ontology=ontology)
teacher.label(input_folder="./drone_images/", output_folder="./auto_labeled/")
 
# Student: YOLO가 학습
student = YOLOv8("yolov8n-seg.pt")
student.train("./auto_labeled/data.yaml", epochs=100)

사이클별 성능 변화

사이클SAM 역할YOLO 역할사람 검수 비율mAP@0.5 (예상)
1차전체 라벨링없음100% → 30%65~75%
2차불확실한 것만 보조1차 추론20~30%75~82%
3차거의 안 씀대부분 처리10~15%82~87%
5차+특수 케이스만주역5% 미만87~92%

Active Learning — 불확실한 것만 검수

def active_learning_select(model, images, threshold=0.6):
    """신뢰도 낮은 이미지만 선별"""
    auto_pass, need_review, rejected = [], [], []
 
    for img in images:
        results = model(str(img), verbose=False)
        if results[0].boxes is None:
            rejected.append(img); continue
 
        min_conf = results[0].boxes.conf.min().item()
        if min_conf >= 0.7:   auto_pass.append(img)
        elif min_conf >= 0.4: need_review.append(img)
        else:                 rejected.append(img)
 
    return auto_pass, need_review, rejected
신뢰도처리비율
≥ 0.7자동 확정80~90%
0.4~0.7사람 검수10~20%
< 0.4제거 또는 SAM 재처리~5%

재학습 트리거

트리거기준
mAP 하락기존 대비 3%+ 하락
신뢰도 하락배치 평균 confidence 10%+ 하락
데이터 분포 변화새 환경 (계절/날씨/시간대)
누적 데이터량이전 학습 대비 30%+ 증가

품질 검증 체계

SAM이든 YOLO든, 자동 결과를 100% 사람이 검수하지 않는다. 자동 필터링 + 샘플 검수 조합으로 처리한다.

flowchart TD
    A[모델 출력<br/>마스크 + 신뢰도] --> B{자동 필터링}
    B -->|통과| C[자동 사용<br/>80~90%]
    B -->|의심| D[사람 확인<br/>10~20%]
    B -->|실패| E[제거 ~5%]

    C --> F[중심선 추출 → DB]
    D -->|OK| F
    D -->|수정| G[마스크 보정] --> F


자동 검증 규칙

검증 항목기준의미
신뢰도≥ 0.7 통과 / 0.4~0.7 검토 / < 0.4 제거모델 자체 확신도
최소 면적전체 이미지의 1% 이상너무 작으면 선로 아님
최대 면적전체 이미지의 50% 미만너무 크면 배경 포함
종횡비3:1 이상 길쭉선로는 길고 좁은 형태
중심선 곡률급격한 꺾임 없음선로 곡선은 완만함
중심선 길이이미지 대각선의 10% 이상너무 짧으면 의미 없음

고해상도 이미지 — SAHI 타일링

드론 4K~8K 이미지는 SAM 기본 처리 크기(1024px)보다 크다. SAHI로 타일 분할 후 처리한다.

from sahi import AutoDetectionModel
from sahi.predict import get_sliced_prediction
 
model = AutoDetectionModel.from_pretrained(model_type="sam3", model_path="sam3_large.pt")
result = get_sliced_prediction(
    image="high_res_drone.jpg", detection_model=model,
    slice_height=640, slice_width=640,
    overlap_height_ratio=0.2, overlap_width_ratio=0.2,
)
원본 해상도추천 타일생성 타일 수
1920x1080 (FHD)타일링 불필요1
3840x2160 (4K)640x640~24
7680x4320 (8K)1280x1280~28

하드웨어 요구사항

작업최소권장
SAM 3 자동 라벨링/추론VRAM 8GBVRAM 16GB+
YOLO-Seg 학습 (nano)VRAM 4GBVRAM 8GB+
YOLO-Seg 학습 (medium)VRAM 8GBVRAM 16GB+
Active Learning 추론VRAM 2GBVRAM 4GB+
라이브러리버전용도
ultralytics8.3.237+SAM 3 / YOLO 통합
opencv-python4.8+마스크 후처리, 좌표 추출
scikit-image0.22+스켈레톤화
shapely2.0+지오메트리 연산
sahi0.11+고해상도 타일링

관련 연구

연구학회/출처핵심 내용
SAM-RoadCVPR 2024 Workshop (Best Paper)SAM → 도로 마스크 → 벡터화 그래프, SOTA 대비 40배 빠름
SAM-Road+CVPR 2025위성 이미지 글로벌 스케일 도로 추출
FRA 선로 중심선 추적미국 연방철도청드론 자동 선로 추적 비행용 실시간 중심선 탐지
rail_markingGitHub 오픈소스DeepLab 세그멘테이션 → 레일 마스크 → 중심선
CSX 드론 검사FlytBase 사례13개 사이트, DJI M350 RTK, 엣지 ML 처리

문서 탐색


참고 자료