티스토리 뷰
목차
프로덕션 모델을 라벨러로: 자동 라벨링과 데이터 누수 차단 | RF-DETR 파인튜닝 (1)
model.train() 한 줄 뒤: AdamW와 손실함수, 헝가리안 매칭 | RF-DETR 파인튜닝 (2)

들어가며
1부에서는 데이터셋을 만들었고, 2부에서는 그 데이터로 RF-DETR(Roboflow Detection Transformer) 모델을 파인튜닝했다.
이번 글은 학습이 끝난 모델을 실제 서비스에 배포하는 과정을 다룬다.
학습은 끝이 아니며, 모델을 내보내고 배포하는 단계에는 고유한 함정이 있다.
학습 환경과 서비스 환경이 서로 다르기 때문에,
둘을 잇는 표준 형식인 ONNX(Open Neural Network Exchange, 개방형 신경망 교환 형식)가 필요하다.
이 글은 ONNX 변환, 클래스 채널의 함정, 전처리 일치, FP16 붕괴의 네 단계로 구성된다.
핵심은 채널 하나의 어긋남이나 정밀도 한 끗의 선택이 잘 학습된 모델을 무력화할 수 있다는 점이다.
본문의 코드는 실제 export_onnx.py와 서비스 추론 코드인 detector.py에서 발췌했다.
학습이 끝나도 배포는 끝나지 않는다: ONNX 변환
학습 환경과 서비스 환경이 다르다

학습은 rfdetr 라이브러리가 요구하는 최신 파이토치(PyTorch) 환경에서 진행된다.
반면 서비스 추론 환경은 그보다 낮은 파이토치 버전을 쓰며, 함부로 올릴 수 없다.
따라서 학습으로 얻은 체크포인트(.pth) 파일을 서비스 환경에서 그대로 불러오기 어렵다.
실제로 서비스의 detector.py는 rfdetr 패키지에 의존하지 않고 onnxruntime(ONNX 추론 엔진)만으로 동작하도록 설계되어 있다.
그러므로 학습한 모델은 서비스 환경이 실행할 수 있는 이식 가능한 형식으로 변환되어야 한다.
이 변환의 다리 역할을 하는 것이 ONNX다.
ONNX란 무엇인가

ONNX는 학습 프레임워크에 종속되지 않는 방식으로 모델을 표현하는 개방형 표준이다.
2017년 페이스북과 마이크로소프트의 협업으로 공개되었다.
ONNX는 모델을 계산 그래프(computation graph, 연산은 노드, 텐서는 간선으로 표현)로 담으며,
학습 루프나 그래디언트 없이 순수한 수치 가중치만 포함한다.
이 덕분에 모델은 학습 프레임워크와 무관하게 저장하고 실행할 수 있는 독립적인 산출물이 된다.
이 산출물은 ONNX Runtime이 실행하며, CUDA나 TensorRT 같은
실행 프로바이더(execution provider, 하드웨어별 실행 백엔드)를 붙일 수 있다.
결국 최신 환경에서 학습한 모델을 낮은 버전의 서비스에서 ONNX를 통해 실행할 수 있게 된다.
model.export와 onnx-simplifier로 그래프를 단순화한다
export_onnx.py는 학습된 체크포인트를 불러와 ONNX로 내보낸다.
from rfdetr import RFDETRMedium
model = RFDETRMedium(
num_classes=2,
pretrain_weights=str(args.checkpoint), # 학습된 best.pth
)
model.export(output_dir=str(EXPORTS_DIR), simplify=True)
export 호출은 학습된 가중치를 ONNX 계산 그래프로 변환한다.
simplify=True 옵션은 onnx-simplifier(ONNX 그래프 단순화 도구)를 실행한다.
onnx-simplifier는 전체 계산 그래프를 추론해 중복 연산자를 그 상수 출력으로 대체하는데, 이를 상수 폴딩(constant folding)이라 한다.
이 과정은 그래프를 더 작고 깨끗하게 만들어 추론 속도를 높인다.
산출물은 inference_model.onnx 파일로 저장된다.
내보낸 모델은 반드시 검증한다
내보낸 ONNX가 올바른지 확인하지 않고 배포하면 조용한 오류로 이어진다.
verify_onnx 함수는 모델의 메타데이터와 입출력 형태를 먼저 점검한다.
model = onnx.load(str(onnx_path))
logger.info(f"ONNX IR version: {model.ir_version}")
for out in model.graph.output:
dims = [d.dim_value for d in out.type.tensor_type.shape.dim]
logger.info(f"Output: {out.name} {dims}")
이어 그래프에 저장된 가중치(initializer)에서 총 파라미터 수를 세어 모델 규모를 확인한다.
그다음 샘플 이미지로 실제 추론을 돌려 끝에서 끝까지 정상 동작하는지 검증한다.
이때 가장 중요한 검사는 출력 채널 수를 확인하는 것으로, 이는 다음 섹션에서 다룬다.
서비스 코드는 rfdetr에 의존하지 않는다

배포의 목표는 학습 라이브러리 없이도 도는 가벼운 추론기다.
detector.py의 RFDETROnnxDetector는 rfdetr 패키지를 전혀 import 하지 않는다.
대신 onnxruntime과 OpenCV(영상 처리 라이브러리), NumPy(수치 연산 라이브러리)만으로 추론을 수행한다.
이 분리 덕분에 서비스 환경은 학습용 의존성의 버전 문제에서 자유롭다.
추론기는 ONNX 파일 경로와 임계값만 받아 초기화되고, 단일 프레임을 받아 검출 결과를 돌려주는 단순한 인터페이스를 가진다.
또한 여러 요청이 동시에 들어와도 안전하도록 추론 구간을 잠금(lock)으로 보호한다.
채널 하나가 어긋나면 클래스가 밀린다
내보낸 모델은 무엇을 출력하는가
ONNX 모델은 하나의 입력과 두 개의 출력을 가진다.
dets, labels = session.run(None, {"input": tensor})
logger.info(f"dets shape: {dets.shape}") # [1, 300, 4]
logger.info(f"labels shape: {labels.shape}") # [1, 300, 3]
dets는 [1, 300, 4] 형태로, 300개의 박스를 정규화된 cxcywh(중심 x, 중심 y, 너비, 높이) 좌표로 담는다.
labels는 [1, 300, 3] 형태로, 300개 쿼리 각각에 대한 원시 로짓(raw logit, 확률로 변환되기 전의 점수)을 담는다.
여기서 300은 2부에서 설명한 쿼리(query, 검출 후보 슬롯) 수다.
박스는 0에서 1로 정규화된 값이고 로짓은 아직 확률이 아니므로, 뒤에서 sigmoid를 거쳐 점수로 바뀐다.
헤드는 클래스 수보다 하나 많은 채널을 낸다
우리는 num_classes=2로 학습했지만 labels의 채널 수는 3이다.
labels_output = model.graph.output[1]
labels_dims = [d.dim_value for d in labels_output.type.tensor_type.shape.dim]
expected_classes = 3 # person + ball + background
if labels_dims[-1] != expected_classes:
logger.error(f"labels 출력 클래스 수 불일치: {labels_dims[-1]} (기대값: 3)")
return False
rfdetr이 1부터 시작하는(1-indexed) COCO 데이터셋 관례에 따라 헤드를 num_classes+1 채널로 초기화하기 때문이다.
그 결과 채널은 ch0이 person, ch1이 ball, ch2가 background(no-object, 객체 없음)로 구성된다.
만약 채널이 2개라고 가정하면 클래스 인덱스가 밀려 person과 ball의 해석이 어긋난다.
그래서 verify_onnx는 labels의 마지막 차원이 3인지 명시적으로 검사해 이 함정을 사전에 잡는다.
추론 시 background 채널을 argmax에서 걸러낸다
추론에서는 먼저 로짓에 sigmoid를 적용해 채널별 점수를 얻는다.
scores_all = 1.0 / (1.0 + np.exp(-np.clip(logits, -500, 500)))
class_ids = scores_all.argmax(axis=1)
max_scores = scores_all.max(axis=1)
valid_class_mask = class_ids != 2 # background(ch=2) 제외
mask = (max_scores >= 0.5) & valid_class_mask
argmax(최댓값의 위치를 찾는 연산)로 각 쿼리의 예측 클래스를 정한다.
이때 ch2인 background로 분류된 쿼리는 반드시 제외해야 한다.
즉 person(0)이나 ball(1)이면서 점수가 임계값 이상인 검출만 남긴다.
background를 걸러내지 않으면 객체가 없는 자리까지 검출로 잡혀 거짓 양성이 쏟아진다.
모델의 클래스 ID를 다운스트림 코드에 맞게 다시 매핑한다
모델이 내는 내부 ID(person=0, ball=1)는 그 출력을 소비하는 후속 코드가 기대하는 ID와 다르다.
여기서 후속 코드란 검출 결과를 받아 처리하는 부분을 가리키며, detector.py 주석에서는 이를 다운스트림(downstream)이라 부른다.
detector.py는 이 다운스트림 코드와의 호환을 위해 COCO 80-class 기준의 ID(person=0, ball=32)로 다시 매핑한다.
class RFDETROnnxDetector:
person_id = 0
ball_id = 32 # 하류 코드 호환 ID (COCO 80-class 기준)
# __init__ 인자: person_class_id=0, ball_class_id=1 (모델 내부 ID)
def infer(self, img):
# ... sigmoid, argmax, threshold 후 ...
mapped = np.full(len(filtered_classes), -1, dtype=np.float32)
mapped[filtered_classes == self._model_person_id] = self.person_id # 0 → 0
mapped[filtered_classes == self._model_ball_id] = self.ball_id # 1 → 32
즉 모델의 ball(1)을 다운스트림 코드가 쓰는 COCO 80-class의 ball(32)로 변환한다.
기존 다운스트림 코드가 COCO 80-class 인덱스를 전제로 작성되었기 때문에 이 매핑이 필요하다.
이 재매핑을 빠뜨리면 ball 검출이 다운스트림에서 엉뚱한 클래스로 읽힌다.
모델 출력 ID와 다운스트림의 소비 ID라는 두 층의 존재가 이 단계의 핵심 함정이다.
전처리가 다르면 추론이 무너진다
학습과 추론의 전처리는 반드시 같아야 한다

모델은 특정 방식으로 전처리된 입력으로 학습되었다.
추론에서 전처리가 달라지면 입력 분포가 어긋나 예측 품질이 떨어진다.
실제로 사전학습 모델은 학습 때와 다른 정규화를 쓰면 정확도가 조용히 10~30% 떨어지는 것으로 보고된다.
그래서 detector.py의 전처리는 학습 때와 정확히 같아야 한다.
export_onnx.py의 검증 코드도 이 점을 의식해 detector.py와 동일한 전처리를 의도적으로 사용한다.
전처리는 BGR을 RGB로 변환, 576 리사이즈, ImageNet 정규화, 채널 우선(CHW) 배열의 네 단계로 이뤄진다.
576 리사이즈와 ImageNet 정규화
전처리 전체는 detector.py의 _preprocess에 담겨 있다.
_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(1, 1, 3)
_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(1, 1, 3)
def _preprocess(self, img):
orig_h, orig_w = img.shape[:2]
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # BGR → RGB
resized = cv2.resize(rgb, (self.input_size, self.input_size)) # 576×576
normalized = (resized.astype(np.float32) / 255.0 - self._MEAN) / self._STD
tensor = normalized.transpose(2, 0, 1)[np.newaxis] # HWC → CHW, 배치 축 추가
return tensor, orig_w, orig_h
모델 입력이 576×576이므로 모든 프레임을 그 크기로 리사이즈한다.
OpenCV는 이미지를 BGR 순서로 읽으므로, 모델이 기대하는 RGB 순서로 채널을 바꾼다.
픽셀을 0에서 1 범위로 나눈 뒤 ImageNet 평균과 표준편차로 정규화한다.
여기서 mean [0.485, 0.456, 0.406]과 std [0.229, 0.224, 0.225]는 백본인 DINOv2가 사전학습에 사용한 표준값이며,
다른 값을 쓰면 사전학습 백본과 어긋난다.
sigmoid 오버플로를 clip으로 막는다
sigmoid는 1/(1+exp(-x)) 형태로, 입력이 크게 음수이면 exp 항이 매우 커진다.
logits_clipped = np.clip(logits, -500, 500)
scores_all = 1.0 / (1.0 + np.exp(-logits_clipped))
np.exp에 아주 큰 수가 들어가면 결과가 무한대(inf)로 넘쳐 오버플로가 발생한다.
그래서 로짓을 -500에서 500 사이로 잘라(clip) 지수의 크기를 제한한다.
sigmoid는 어차피 양 끝에서 포화하므로, 이 클리핑은 수학적 결과를 바꾸지 않는 수치 안전장치다.
이 장치가 없으면 극단적인 로짓이 무한대(inf)나 숫자가 아닌 값(NaN, Not a Number) 점수를 만들어 이후 계산을 오염시킬 수 있다.
cxcywh 박스를 xyxy로 바꾸고 원본 크기로 되돌린다
모델은 박스를 정규화된 cxcywh(중심 좌표와 크기, 0에서 1)로 출력한다.
@staticmethod
def _cxcywh_to_xyxy(boxes):
cx, cy, w, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
return np.stack([cx - w/2, cy - h/2, cx + w/2, cy + h/2], axis=1)
# infer() 내부
boxes_xyxy = self._cxcywh_to_xyxy(filtered_boxes)
boxes_xyxy[:, [0, 2]] *= orig_w # x 좌표를 원본 너비로
boxes_xyxy[:, [1, 3]] *= orig_h # y 좌표를 원본 높이로
소비 코드는 보통 픽셀 단위의 xyxy(좌상단과 우하단 모서리) 좌표를 원한다.
변환식은 x1=cx-w/2, y1=cy-h/2, x2=cx+w/2, y2=cy+h/2로 중심과 크기를 모서리로 바꾼다.
그다음 정규화 좌표에 원본 너비와 높이를 곱해 576 공간에서 원본 프레임 좌표로 되돌린다.
원본 크기로 되돌리는 이 과정을 빠뜨리면 박스가 엉뚱한 좌표 공간에 놓여 화면과 어긋난다.
FP16은 백본을 붕괴시킨다: 정밀도의 함정
FP16과 FP32란 무엇인가

FP32(단정밀도, 32비트 부동소수점, 32-bit Floating Point)는 지수 8비트를 써서 약 7자리의 십진 정밀도를 가진다.
FP16(반정밀도, 16비트 부동소수점, 16-bit Floating Point)은 지수 5비트와 가수 10비트로 이뤄진다.
FP16은 메모리를 절반으로 줄이고 텐서 코어(Tensor Core)에서 연산을 빠르게 하므로 추론에서 매력적으로 보인다.
그러나 FP16의 최댓값은 65,504이고, 약 6e-5보다 작은 값은 0으로 사라진다(언더플로).
따라서 FP16은 FP32보다 표현 가능한 값의 범위(dynamic range)가 훨씬 좁다.
이 좁은 범위가 바로 문제의 근원이다.
실측 데이터: FP16에서 모델이 무력화된다

같은 모델을 두 정밀도로 추론해 결과를 비교했다.
FP32에서는 mAP@.5:.95가 0.9588이고 검출이 5,198개였다.
FP16에서는 mAP@.5:.95가 0.0042로 떨어지고 검출이 42개에 그쳤다.
여기서 mAP(mean Average Precision, 평균 정밀도의 평균)는 검출 정확도를 종합한 지표다.
즉 FP16은 성능을 조금 떨어뜨리는 수준이 아니라 모델을 사실상 무력화한다.
이는 미세한 정확도 하락이 아니라 치명적 실패다.
원인은 백본 LayerNorm의 수치 불안정이다

원인은 백본인 DINOv2의 LayerNorm(레이어 정규화)에 있다.
LayerNorm과 어텐션(attention)은 FP16에서 수치적으로 취약한 대표적 연산으로 알려져 있다.
깊은 층에서 활성값이 크게 자라면 LayerNorm의 분산 계산이 FP16 최댓값을 넘어 무한대가 되고, 이것이 NaN으로 전파된다.
특징 추출 단계에서 NaN이 생기면 이후 검출 전체가 무너진다.
이는 재학습으로 고칠 수 없는 아키텍처 수준의 문제다.
그래서 이 모델은 FP16으로는 정상적으로 실행될 수 없다.
FP32로 배포하고 성능은 충분하다
따라서 배포는 FP32로만 진행한다.
trt_cache_dir = str(Path(onnx_path).parent / "trt_cache_fp32")
providers = [
("TensorrtExecutionProvider", {
"device_id": 0,
"trt_fp16_enable": False, # FP16 비활성화
"trt_engine_cache_enable": True,
"trt_engine_cache_path": trt_cache_dir,
}),
("CUDAExecutionProvider", {"device_id": 0}), # TensorRT 미지원 시 fallback
]
self.session = ort.InferenceSession(str(onnx_path), providers=providers)
TensorRT(NVIDIA의 추론 최적화 엔진)를 우선 실행 프로바이더로 쓰고, 지원되지 않는 연산은 CUDA로 넘긴다.
trt_fp16_enable을 False로 두고 엔진 캐시도 FP32 전용 디렉토리에 저장한다.
FP32는 A2000에서 프레임당 약 26밀리 초(초당 약 38 프레임)로 동작해 서비스에 충분한 속도다.
그러므로 굳이 필요하지도 않은 속도 이득을 위해 FP16의 붕괴 위험을 감수할 이유가 없다.
나가며

이 단계에서는 모델을 ONNX로 내보내고 검증했으며, num_classes+1 채널과 클래스 ID 재매핑을 처리하고,
전처리를 학습과 일치시키고, FP16을 배제했다.
각 항목은 작은 부주의 하나가 배포를 조용히 망가뜨릴 수 있는 지점이다.
채널의 어긋남과 클래스 재매핑은 잘 학습된 모델도 인터페이스에서 실패할 수 있음을 보여준다.
FP16 붕괴는 정밀도 선택 하나가 모델을 통째로 무력화할 수 있음을 보여준다.
이로써 데이터, 학습, 배포로 이어진 세 편의 시리즈를 마무리한다.
관통하는 교훈은 신뢰할 수 있는 머신러닝이 모델 자체만큼이나 겉보기에 사소한 인터페이스에 달려 있다는 점이다.
참고 자료
- RF-DETR 공식 문서 및 저장소: https://rfdetr.roboflow.com , https://github.com/roboflow/rf-detr
- ONNX 공식 사이트 및 개념 해설: https://onnx.ai , https://www.ultralytics.com/glossary/onnx-open-neural-network-exchange
- ONNX 상호운용성 개요: https://www.splunk.com/en_us/blog/learn/open-neural-network-exchange-onnx.html
- onnx-simplifier 저장소(상수 폴딩): https://github.com/daquexian/onnx-simplifier
- ONNX Runtime 그래프 최적화 및 TensorRT 실행 프로바이더: https://onnxruntime.ai/docs/performance/model-optimizations/graph-optimizations.html , https://onnxruntime.ai/docs/execution-providers/TensorRT-ExecutionProvider.html
- FP16 정밀도와 오버플로·언더플로 개념: https://www.hivenet.com/post/fp16-explained-16-bit-floating-point-precision-in-ai , https://www.emmtrix.com/wiki/Numerical_Precision_in_ONNX_and_AI_Inference
- TensorRT 정밀도 고려사항(FP16 오버플로와 NaN): https://docs.nvidia.com/deeplearning/tensorrt/latest/inference-library/accuracy-considerations.html
- LayerNorm과 어텐션의 FP16 불안정 사례(CogView): https://arxiv.org/pdf/2105.13290
- ImageNet 정규화 상수와 전처리 일치 원칙: https://www.systemoverflow.com/learn/ml-cv-systems/image-preprocessing/normalization-and-input-standardization , https://albumentations.ai/blog/2025/03-the-mystery-of-input-normalization/
- COCO 어노테이션 포맷과 클래스 인덱스: https://docs.cvat.ai/docs/dataset_management/formats/format-coco/
'ML+DL > Vision AI' 카테고리의 다른 글
| 파라미터 두 개로 실시간을 잡는다: One Euro, Savitzky-Golay, 영위상 Butterworth | 삼각측량 후처리 (2) (0) | 2026.07.09 |
|---|---|
| 부드러움과 반응성은 동시에 가질 수 없다: 지터-지연 트레이드오프와 Accel로 떨림 측정하기 | 삼각측량 후처리 (1) (0) | 2026.07.08 |
| 91클래스 vs 2클래스, 파인튜닝은 정말 나았는가 | RF-DETR 파인튜닝 (추가) (1) | 2026.07.03 |
| model.train() 한 줄 뒤: AdamW와 손실함수, 헝가리안 매칭 | RF-DETR 파인튜닝 (2) (0) | 2026.07.01 |
| 프로덕션 모델을 라벨러로: 자동 라벨링과 데이터 누수 차단 | RF-DETR 파인튜닝 (1) (0) | 2026.06.30 |
| 네 대의 IR 카메라로 삼각측량을 해보자-(3) (3) | 2025.07.18 |
- Total
- Today
- Yesterday
- 중남미
- 알고리즘
- 세계여행
- 스프링
- 백준
- 야경
- 남미
- 여행
- 유럽여행
- 딥러닝
- 기술면접
- 파이썬
- 스트림
- 동적계획법
- spring
- Algorithm
- BOJ
- 세모
- 세계일주
- 면접 준비
- 리스트
- 지지
- java
- a6000
- 자바
- 맛집
- RX100M5
- 유럽
- 칼이사
- Python
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |