- Published on
TensorFlow Lite로 YOLOv8 INT8 양자화 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 모바일에서 YOLOv8을 빠르게 돌리려면 TFLite INT8 양자화가 매력적입니다. 하지만 실제로는 변환 단계에서부터 추론 단계까지 오류가 자주 발생합니다. 대표적으로 converter.convert()에서 실패하거나, 변환은 되는데 런타임에서 INT8 텐서가 섞여 크래시가 나거나, 출력이 전부 0에 가깝게 붕괴하는 문제를 겪습니다.
이 글은 YOLOv8을 TFLite INT8로 내릴 때 발생하는 오류를 “왜 생기는지” 기준으로 분류하고, 재현 가능한 해결 절차를 제시합니다. 특히 YOLO 계열에서 자주 등장하는 후처리(NMS)와 동적 shape, 대표 데이터셋(representative dataset) 구성 실수, 연산자(op) 미지원 이슈를 집중적으로 다룹니다.
관련해서 모바일 배포용 양자화 파이프라인 전반은 PT2E+ExecuTorch 양자화로 모바일 배포하기도 함께 참고하면 비교 관점에서 도움이 됩니다.
전체 파이프라인을 먼저 고정하기
YOLOv8은 보통 PyTorch 기반으로 학습합니다. TFLite INT8로 가는 경로는 크게 두 가지입니다.
- PyTorch
->ONNX->TF SavedModel->TFLite - 처음부터 TF/Keras로 재구현된 YOLOv8 계열 모델
->TF SavedModel->TFLite
실무에서는 1번을 많이 택하지만, 변환 체인이 길어질수록 연산자 호환성/shape 추론 문제가 늘어납니다. 어떤 경로든 아래 3가지는 먼저 결정해 두는 게 좋습니다.
- 입력 해상도 고정 여부: 예)
640x640고정 - 후처리(NMS)를 모델 그래프에 포함할지 여부
- 최종 목표: 완전 INT8(입력/출력 포함)인지, 내부만 INT8(입출력 float)인지
완전 INT8은 임베디드에서 가장 빠르지만, 전처리/후처리까지 정수화해야 해서 오류가 더 많이 납니다. 반대로 입출력 float + 내부 INT8은 호환성이 좋아 디버깅이 쉽습니다.
오류 1: 대표 데이터셋이 잘못되어 양자화 스케일이 붕괴
증상
- 변환은 성공하지만 정확도가 급락
- 출력이 거의 0이거나 박스가 전혀 안 나옴
- 특정 클래스만 과검출/미검출
원인
INT8 양자화는 캘리브레이션(대표 데이터셋)으로 activation 범위를 추정합니다. YOLO는 입력 분포(리사이즈, 레터박스, 정규화)가 조금만 바뀌어도 activation이 크게 달라져 스케일이 틀어지기 쉽습니다.
특히 아래 실수가 흔합니다.
- 학습/추론 때는
letterbox인데 대표 데이터셋은 단순resize - RGB/BGR 채널 순서가 다름
0..255입력을 모델이 기대하는데 대표 데이터셋은0..1- 배치 차원/shape가 변환 시점과 다름
해결: 대표 데이터셋을 “실제 전처리”와 동일하게
아래는 TFLite 변환 시 대표 데이터셋을 제공하는 예시입니다. 중요한 포인트는 실제 서비스 전처리와 동일한 경로로 이미지를 만들어야 한다는 점입니다.
import tensorflow as tf
import numpy as np
import cv2
import glob
IMG_SIZE = 640
def letterbox(im, new_shape=640, color=(114, 114, 114)):
h, w = im.shape[:2]
r = min(new_shape / h, new_shape / w)
nh, nw = int(round(h * r)), int(round(w * r))
im_resized = cv2.resize(im, (nw, nh), interpolation=cv2.INTER_LINEAR)
top = (new_shape - nh) // 2
bottom = new_shape - nh - top
left = (new_shape - nw) // 2
right = new_shape - nw - left
out = cv2.copyMakeBorder(im_resized, top, bottom, left, right,
cv2.BORDER_CONSTANT, value=color)
return out
# 모델이 기대하는 입력 스케일에 맞추세요.
# 예: 0..1 float 입력
def representative_dataset(image_dir, max_samples=200):
paths = glob.glob(f"{image_dir}/*.jpg")[:max_samples]
def gen():
for p in paths:
bgr = cv2.imread(p)
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
img = letterbox(rgb, IMG_SIZE)
img = img.astype(np.float32) / 255.0
img = np.expand_dims(img, axis=0) # (1, 640, 640, 3)
yield [img]
return gen
converter = tf.lite.TFLiteConverter.from_saved_model("./saved_model")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset("./calib_images")
체크리스트
- 대표 데이터는 최소 수십 장, 가능하면 200~500장
- 실제 배경/조명/거리 분포가 반영된 이미지
- 서비스 입력 파이프라인과 동일한 정규화/레터박스/채널 순서
오류 2: “완전 INT8” 강제 시 dtype 불일치로 변환 실패
증상
- 변환 시
ValueError또는RuntimeError로 실패 - 에러 메시지에
input type또는inference_type불일치가 포함
원인
완전 INT8을 원하면 입력/출력 dtype을 int8로 고정해야 합니다. 그런데 모델 그래프에 float 연산(특히 후처리)이 남아 있으면, 변환기가 전체를 정수로 내리지 못해 실패합니다.
해결 1: 내부만 INT8(입출력 float)로 먼저 성공시키기
디버깅 단계에서는 아래처럼 입출력은 float으로 두고 내부만 INT8로 만드는 게 훨씬 안정적입니다.
import tensorflow as tf
converter = tf.lite.TFLiteConverter.from_saved_model("./saved_model")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset("./calib_images")
# 입출력은 float32로 유지
converter.inference_input_type = tf.float32
converter.inference_output_type = tf.float32
tflite_model = converter.convert()
open("yolov8_int8_internal.tflite", "wb").write(tflite_model)
이 상태에서 정확도/출력 분포가 정상인지 확인한 뒤, 완전 INT8로 넘어가면 원인 분리가 쉬워집니다.
해결 2: 완전 INT8로 가려면 후처리를 그래프 밖으로 빼기
YOLO의 NMS(Non-Maximum Suppression)는 TFLite에서 INT8로 완전 호환이 어려운 경우가 많습니다. 특히 CombinedNonMaxSuppression 같은 op가 끼면 변환이 까다로워집니다.
권장 전략은 다음입니다.
- 모델은 박스/클래스 로짓까지 출력
- NMS는 앱 코드(또는 별도 커스텀 op)에서 수행
이렇게 하면 모델 그래프가 단순해져 INT8 변환 성공률이 올라갑니다.
오류 3: SELECT_TF_OPS 없이는 변환이 안 됨
증상
- 변환 에러에
Some ops are not supported by the native TFLite runtime가 포함 - 특정 TF 연산이 미지원이라고 나옴
원인
SavedModel 그래프에 TFLite builtin으로 내릴 수 없는 연산이 포함되어 있을 수 있습니다. 변환 체인이 ONNX/TF를 거치면서 불필요한 op가 생기는 경우도 흔합니다.
해결: 우선 변환 성공을 위해 SELECT_TF_OPS를 허용하고, 이후 제거
import tensorflow as tf
converter = tf.lite.TFLiteConverter.from_saved_model("./saved_model")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset("./calib_images")
converter.target_spec.supported_ops = [
tf.lite.OpsSet.TFLITE_BUILTINS_INT8,
tf.lite.OpsSet.SELECT_TF_OPS,
]
# 디버깅 단계에서는 입출력 float로
converter.inference_input_type = tf.float32
converter.inference_output_type = tf.float32
tflite_model = converter.convert()
open("yolov8_mixed_ops.tflite", "wb").write(tflite_model)
SELECT_TF_OPS는 Flex delegate가 필요할 수 있어 모바일 바이너리가 커지고 성능이 떨어질 수 있습니다. 따라서 목표는 다음 순서가 좋습니다.
SELECT_TF_OPS로 일단 성공- 어떤 op 때문에 Flex가 필요한지 식별
- 모델 구조/내보내기(export) 옵션을 바꿔 builtin op만 남기기
오류 4: 동적 shape 때문에 입력 텐서 크기 변경이 실패
증상
- 변환은 성공했는데 런타임에서
resize_tensor_input관련 오류 - 특정 디바이스에서만 크래시
원인
YOLO는 원래 다양한 입력 크기를 받을 수 있지만, TFLite INT8에서는 동적 shape가 발목을 잡는 경우가 많습니다. 특히 대표 데이터셋이 (1, 640, 640, 3)인데 런타임에서 (1, 320, 320, 3)을 넣으면 스케일/제로포인트가 달라져 결과가 망가질 수 있습니다.
해결: 입력 해상도를 고정하고, 모델 export도 고정
- 서비스 입력을
640x640으로 고정 - 변환 전 SavedModel 자체가 고정 shape를 갖도록 export
또한 TFLite Interpreter 사용 시 입력 텐서를 임의로 바꾸기보다, 고정된 입력 shape로만 동작하도록 파이프라인을 단순화하는 게 안전합니다.
오류 5: INT8 입출력에서 전처리/후처리 누락으로 결과가 이상함
증상
- 완전 INT8 모델에서만 결과가 이상
- float 입출력 모델은 정상
원인
완전 INT8에서는 입력도 int8로 넣어야 합니다. 즉, 앱에서 float 이미지를 그대로 넣으면 안 됩니다. 또한 출력도 int8이면 다시 dequantize를 해야 사람이 이해할 수 있는 값이 됩니다.
해결: TFLite quantization 파라미터로 정확히 변환
아래는 Python에서 int8 입력을 만드는 예시입니다. 핵심은 텐서의 scale과 zero_point를 읽어 동일하게 양자화하는 것입니다.
import numpy as np
import tensorflow as tf
interpreter = tf.lite.Interpreter(model_path="yolov8_full_int8.tflite")
interpreter.allocate_tensors()
in_detail = interpreter.get_input_details()[0]
out_detail = interpreter.get_output_details()[0]
in_scale, in_zp = in_detail["quantization"]
out_scale, out_zp = out_detail["quantization"]
# img_f32: (1, 640, 640, 3), float32, 0..1
img_q = np.round(img_f32 / in_scale + in_zp).astype(np.int8)
interpreter.set_tensor(in_detail["index"], img_q)
interpreter.invoke()
out_q = interpreter.get_tensor(out_detail["index"]) # int8
out_f32 = (out_q.astype(np.float32) - out_zp) * out_scale
만약 모델이 여러 출력 텐서를 가진다면 각각의 quantization 파라미터가 다를 수 있으니 출력별로 처리해야 합니다.
오류 6: NMS 포함 모델에서 출력 텐서 구조가 예상과 다름
증상
- 박스 좌표가 뒤죽박죽
- 클래스 점수 축이 바뀐 것처럼 보임
- 후처리 코드가 기존 PyTorch 출력 전제를 깔고 있어서 실패
원인
YOLOv8 export 경로에 따라 출력 텐서 레이아웃이 달라질 수 있습니다. 예를 들어 (1, N, 85) 형태를 기대했는데 실제는 (1, 84, N) 같은 형태로 나오는 식입니다. 또한 TF 경로에서는 sigmoid/softmax 적용 여부가 달라질 수 있습니다.
해결: TFLite 출력 텐서를 먼저 덤프해서 “사실”을 기준으로 후처리 작성
import tensorflow as tf
interpreter = tf.lite.Interpreter(model_path="model.tflite")
interpreter.allocate_tensors()
for i, d in enumerate(interpreter.get_output_details()):
print(i, d["name"], d["shape"], d["dtype"], d.get("quantization"))
이 출력 정보를 기준으로 reshape/transposition을 명시적으로 넣고, 그 다음에 디코딩과 NMS를 적용하세요.
실전 디버깅 루틴: 어디서 깨지는지 30분 안에 좁히기
- FP32 TFLite부터 만든다
- 양자화 없이 TFLite로만 변환해 출력이 PyTorch/TF와 일치하는지 확인
- 내부 INT8 + float 입출력으로 전환
- 대표 데이터셋을 실제 전처리와 동일하게
- 이 단계에서 정확도가 유지되면 캘리브레이션은 대체로 성공
SELECT_TF_OPS를 잠깐 허용
- 변환 자체가 막히는지, 성능/호환성 최적화 단계인지 분리
- 완전 INT8은 마지막에
- 앱에서 quantize/dequantize를 정확히 구현
- 가능하면 NMS는 그래프 밖으로
이런 식으로 단계적으로 접근하면 “원인은 하나인데 증상은 여러 개”인 양자화 디버깅을 훨씬 빠르게 끝낼 수 있습니다.
운영 관점 팁: 변환 파이프라인을 CI에서 고정하기
양자화는 환경(TF 버전, 변환 옵션, 대표 데이터 샘플) 변화에 민감합니다. 변환이 오늘은 되는데 내일은 깨지는 일이 흔합니다. 그래서 아래를 추천합니다.
- Docker로 변환 환경 고정
- 대표 데이터셋 샘플을 리포지토리 또는 아티팩트로 버전 관리
- 변환 산출물에 대해 스모크 테스트(입력 1장, 출력 통계)를 CI에서 수행
CI 속도 최적화는 Docker BuildKit 캐시로 CI 빌드 80% 단축 실전, 캐시가 안 먹는 상황의 진단은 Docker BuildKit 캐시가 안 먹을 때 진단·해결도 함께 보면 좋습니다.
마무리
YOLOv8의 TFLite INT8 양자화 오류는 대개 “대표 데이터셋 전처리 불일치”, “후처리(NMS) 포함으로 인한 op/dtype 혼합”, “동적 shape”, “완전 INT8 입출력 처리 누락” 네 가지 축에서 발생합니다.
가장 안전한 해결 전략은 FP32 TFLite -> 내부 INT8 -> 완전 INT8 순으로 단계를 밟고, NMS는 그래프 밖으로 분리하며, 대표 데이터셋을 서비스 전처리와 1:1로 맞추는 것입니다. 이 루틴을 고정하면 변환 성공률과 재현성이 크게 올라갑니다.