생성모델보다 예측모델이 고려해야할 변수가 많은 것 같다. 그래도 오늘 꽤나 삽질한 덕분에 값진 깨달음을 얻었다...!
학습시간 09:00~02:00(당일17H/누적1960H)
웹앱 모델 배포 실습!!
어제에 이어 4번 부터 시작!

어제 웹앱 제작까지 마치고 0~9 숫자를 그려봤는데 모델이 대부분 5로 예측하는 문제가 있었다.
뭐가 문제일까? 양자화가 잘못되어서 성능이 떨어진 걸까?
# 모델 로드
MODEL_PATH = "mnist-12.onnx"
@st.cache_resource
def load_model(model_path):
return ort.InferenceSession(model_path)
session = load_model(MODEL_PATH)
if session:
input_name = session.get_inputs()[0].name
output_name = session.get_outputs()[0].name
mnist-12-qint8.onnx 파일 대신 mnist-12.onnx 파일을 로드해서 확인해 보자. 만약 양자화 이전 파일도 같은 문제가 생긴다면 양자화의 문제는 아닐 것이다.

원본 파일을 로드해도 여전히 똑같은 문제가 생긴다. 그렇다는 건 이미지 전처리 후 모델에 넘기는 과정에서 문제가 발생했다는 뜻이다.
def preprocess_image(image_data):
# RGBA -> Grayscale
img = Image.fromarray(image_data.astype('uint8'), 'RGBA').convert('L')
# 28x28로 리사이즈
img_resized = img.resize((28, 28), Image.Resampling.LANCZOS)
# Numpy 배열로 변환하고 0~1 사이로 정규화
img_array = np.array(img_resized, dtype=np.float32) / 255.0
# 모델 입력 형식에 맞게 차원 확장 (1, 1, 28, 28)
processed = np.expand_dims(img_array, axis=(0, 1))
return processed
기존 이미지 전처리 코드다. 리사이즈, 흑백변환, 정규화, 차원변환 말고 특별한 건 없는데 어디서 문제가 된 걸까...?
애초에 이게 문제가 아닌 건 아니었을까...?

MNIST 원본 데이터셋 이미지를 들여다 봤다.
갑자기 뭔가 번뜩 뇌리를 스쳤다. 데이터셋 배경은 검은색인데, 캔버스 배경은 흰색이다...! 혹시 이게 문제인가!?!?
아무리 정규화를 한다고 한들 모델이 학습한 데이터와 차이가 있을 게 분명하다. 이 가설이 맞다면 캔버스 그림은 학습 데이터에 어긋나기에 예측을 제대로 못하는 게 어느 정도 이해가 된다.
그럼 만약 캔버스 인풋을 MNIST 학습 데이터와 동일하게 검은색 배경에 흰색 숫자로 하면 어떨까?? 제대로 예측할까??

문제를 찾았다. 아주 잘 예측한다. 결국 색상의 문제였던 것이다.
그럼 색상을 어떻게 설정하든 모델 인풋으로 들어갈 때 검정색 배경 & 흰색 숫자로 변경되도록 코드를 수정하면 해결되지 않을까?
def hex_to_rgb(hex_color):
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def preprocess_image(image_data, bg_color_hex):
# 사이드바의 배경색(hex)을 RGB 튜플로 변환
bg_rgb = hex_to_rgb(bg_color_hex)
# 원본 이미지에서 배경색과 다른 픽셀을 모두 찾아서 마스크 생성
# image_data의 RGB 채널(앞의 3개)만 비교
rgb_data = image_data[:, :, :3]
is_drawing_mask = ~np.all(rgb_data == bg_rgb, axis=2)
# 새까만 배경의 새 이미지 생성
new_image = np.zeros((image_data.shape[0], image_data.shape[1]), dtype=np.uint8)
# 마스크를 이용해 그려진 부분만 255(흰색)으로 칠하기
new_image[is_drawing_mask] = 255
# 리사이즈, 정규화, 차원 확장
img_pil = Image.fromarray(new_image)
img_resized = img_pil.resize((28, 28))
img_array = np.array(img_resized, dtype=np.float32) / 255.0
processed = np.expand_dims(img_array, axis=(0, 1))
return processed
전처리 코드를 수정했다.
사이드바에서 선택한 색상 hex 코드를 RGB 채널로 변경 후 이 값과 다른 색상을 모두 찾는다. 즉, 숫자 영역을 찾는다.
이후 검은색 빈 영역을 생성해서 방금 찾은 숫자 영역 값을 전부 255(흰색)으로 변경 후 위에 올린다. 그럼 내가 원하던 검은색 배경 & 흰색 숫자가 된다.
이렇게 하면 모델에 들어가는 인풋은 캔버스 색상에 관계없이 무조건 검은색 배경 & 흰색 숫자가 된다.
한번 실험해 볼까?

색상을 조잡하게 바꾸면서 실험했는데도 94%~100%의 정확도를 보여준다.
아주 좋군!
# 모델 로드
MODEL_PATH = "mnist-12-qint8.onnx"
다시 양자화 qint8 파일을 로드해서 예측을 진행해 보자.
전처리가 근본적인 문제였다면 양자화 파일로도 정상적으로 작동해야 한다.

굿!! 양자화 파일로 해도 흰색 배경 & 검은색 숫자를 제대로 예측한다.
미션 목적이 도커 배포까지 하는 것이었으니 도커파일을 작성해 보자!
# 베이스 이미지 설정
FROM python:3.11-slim
# 작업 디렉토리 설정
WORKDIR /app
# requirements.txt 복사 및 라이브러리 설치
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# 소스 코드 복사
COPY . .
# Streamlit 포트 노출
EXPOSE 8501
# 컨테이너 실행 시 Streamlit 앱 실행
CMD ["streamlit", "run", "app.py"]
일단 Dockerfile에 들어갈 내용을 쭉 적어준다. requirements.txt로 패키지 버전을 맞춰주고, 나머지 파일은 전부 복사!
# requirements.txt
streamlit
streamlit-drawable-canvas
numpy
onnxruntime
Pillow
scipy
requirements.txt에는 간단하게 필요해 보이는 것들만 넣었다.
conda로 패키지 버전을 쭉 뽑아서 넣었더니 용량 5GB가 넘어가더라... 내 소중한 데이터를 여기에 쓸 순 없지...
# 프로젝트 이름
name: 'mnist-project'
services:
# 서비스 이름
app:
# 현재 폴더의 Dockerfile 사용
build: .
# 포트 연결
ports:
- "8501:8501"
# 재시작 프로세스
restart: unless-stopped
매번 container run 하기 귀찮으니 docker-compose.yaml 파일도 만들어 준다.
docker compose up --build
Dockerfile, yaml 파일을 이용해 이미지를 빌드해 주고 곧바로 실행까지 해본다.
docker tag mnist-project-app yoonsnowman/mnist-project-app:1.0
도커헙에 푸쉬하기 전에 tag name을 만들어 주고,
docker push yoonsnowman/mnist-project-app:1.0
도커헙에 push!!

정상적으로 업로드됐다.
마지막으로 잘 실행되나 테스트만 해보자.
docker compose up
compose로 실행!

실행도 되고 모델도 잘 작동한다.
끝!