Zum Hauptinhalt springen

채점 서비스

채점 서비스

채점 서비스(grading-svc)는 시험지 이미지에서 수험생의 답안을 자동으로 인식·채점하는 OMR(Optical Mark Recognition) 서비스입니다. OpenCV 기반의 이미지 처리 파이프라인으로 답안지를 분석하고, 등급과 총점을 산출합니다. RabbitMQ 소비자로 동작하며, lumie-backend가 grading.omr-request 큐에 작업을 발행하면 처리 후 grading.omr.callback 라우팅 키로 콜백합니다.

기술 스택

라이브러리버전용도
OpenCV4.8.1.78이미지 전처리, 마킹 감지
NumPy1.24.3행렬 연산, 밀도 분석
Pillow10.3.0이미지 포맷 변환
FastAPI0.115.0HTTP API 서버
Uvicorn0.30.0ASGI 서버
aio_pikaRabbitMQ 비동기 소비자
httpx0.27.0백엔드 콜백 HTTP 통신
redis (aioredis)중복 처리 방지 (Redis Deduper)

CPU 집약적인 연산(이미지 처리, 바코드 디코딩)은 FastAPI의 스레드 풀(asyncio.to_thread)에서 실행되어 이벤트 루프를 블로킹하지 않습니다.

RabbitMQ 메시지 계약

인바운드 큐: grading.omr-request

백엔드에서 발행하는 채점 요청 메시지 형식입니다.

class OMRCommand(BaseModel):
jobId: int
examId: int
tenantSlug: str
imageKey: str # MinIO 오브젝트 키
imageIndex: int # 이 이미지의 배치 내 순서 (0-based)
totalImages: int # 배치 전체 이미지 수
schemaVersion: int # 기본값 1

아웃바운드 라우팅 키: grading.omr.callback

성공·실패 모두 동일한 라우팅 키로 백엔드에 콜백합니다.

성공 페이로드

class SuccessCallbackPayload(BaseModel):
jobId: int
examId: int
tenantSlug: str
imageKey: str
imageIndex: int
totalImages: int
success: bool = True
error: None = None
phoneNumber: str
totalScore: int
grade: int
results: list[QuestionResult]

실패 페이로드

class FailureCallbackPayload(BaseModel):
jobId: int | None
examId: int | None
tenantSlug: str | None
imageKey: str | None
imageIndex: int | None
totalImages: int | None
success: bool = False
error: str
phoneNumber: None = None
totalScore: int = 0
grade: int = 0
results: None = None

처리 파이프라인

libs.common.mq.universal_process

RabbitMQ DLQ, 재시도, 콜백 생명주기는 libs/common/mq.pyuniversal_process가 처리합니다. grading 소비자는 build_grade_handler 콜백을 주입하여 사용합니다. 에러 발생 시 메시지는 설정된 횟수만큼 재시도 후 DLQ로 이동합니다.

이미지 처리 파이프라인 (OpenCV)

1단계: 이미지 크기 조정

입력 이미지를 2480×3508px (A4 300dpi) 기준으로 리사이징합니다.

2단계: 기울기 보정 (Deskew)

상단 바코드 영역의 검은색 사각형들을 감지하여 이미지의 기울기를 자동으로 보정합니다.

  1. 상단 15% 영역에서 검은색 사각형 23개를 감지
  2. 좌측 5개와 우측 5개 사각형의 중심점을 계산
  3. 두 점을 잇는 직선의 기울기를 구해 회전 각도 결정
  4. cv2.warpAffine으로 이미지 회전

3단계: 이미지 전처리

처리 항목설명
가우시안 블러노이즈 제거 (3×3 커널)
감마 보정밝기 곡선 조정 (γ=0.7)
CLAHE지역적 히스토그램 균일화로 대비 향상
언샤프 마스킹경계 선명화 (amount=0.8)

4단계: 바코드 감지

이미지 상단에서 검은색 사각형들을 감지하여 X축 좌표 기준점을 설정합니다. 23개의 사각형이 감지되어야 정상적인 답안지로 판정됩니다.

5단계: 마킹 밀도 분석

각 버블 영역의 픽셀 밀도를 계산하여 마킹 여부를 판단합니다.

def calculate_marking_density(img, x, y, width=30, height=60):
region = img[y1:y2, x1:x2]
avg_darkness = 255 - np.mean(region)
dark_ratio = np.sum(region < 180) / total_pixels
medium_dark_ratio = np.sum(region < 160) / total_pixels
very_dark_ratio = np.sum(region < 120) / total_pixels
density_score = (avg_darkness / 255.0) * 0.2 + \
dark_ratio * 0.2 + \
medium_dark_ratio * 0.4 + \
very_dark_ratio * 0.2
return density_score
구분밀도 임계값
전화번호 버블0.17
답안 버블0.20

6단계: 전화번호 추출

수험생 식별 영역에서 8개의 버블 그룹(각 그룹당 0~9 숫자)을 분석하여 8자리 전화번호를 추출합니다.

7단계: 답안 선택 추출

45문항 × 5지선다 구조의 답안 영역을 분석합니다.

  • 1~20번: 9~13번 사각형 기준, 좌측 영역
  • 21~40번: 14~18번 사각형 기준, 중앙 영역
  • 41~45번: 19~23번 사각형 기준, 우측 영역

각 문항에서 마킹된 선택지를 식별하며, 복수 마킹이나 미마킹 시 "무효"로 처리됩니다.

8단계: 등급·총점 계산

정답과 선택 답안을 비교하여 배점에 따라 총점을 계산하고, 절대평가 기준에 따라 1~9등급을 산출합니다.

점수 구간등급
90점 이상1등급
80~89점2등급
70~79점3등급
60~69점4등급
50~59점5등급
40~49점6등급
30~39점7등급
20~29점8등급
20점 미만9등급

환경 변수

변수기본값설명
RABBITMQ_HOSTlocalhostRabbitMQ 호스트
RABBITMQ_PORT5672RabbitMQ 포트
RABBITMQ_QUEUEgrading.omr-request소비 큐 이름
RABBITMQ_PREFETCH_COUNT3동시 처리 메시지 수
MINIO_ENDPOINTlocalhost:9000MinIO 엔드포인트
MINIO_ACCESS_KEY— (필수)MinIO 접근 키
MINIO_SECRET_KEY— (필수)MinIO 비밀 키
MINIO_BUCKETlumieMinIO 버킷
LUMIE_BACKEND_URL— (필수)백엔드 콜백 URL
LUMIE_INTERNAL_HMAC_SECRET— (필수)HMAC 서명 비밀키
REDIS_HOSTredis.lumie-cache.svcRedis 호스트
REDIS_ENABLEDtrueRedis 중복 방지 활성화

Kubernetes 리소스

resources:
requests:
memory: "1Gi"
cpu: "100m"
limits:
memory: "2Gi"
cpu: "1000m"

이미지 처리는 CPU 집약적이므로, 배치 채점 시 CPU 한도(1000m)까지 사용될 수 있습니다.

헬스체크

GET /health
{ "status": "healthy" }

관련 문서