채점 서비스
채점 서비스
채점 서비스(grading-svc)는 시험 지 이미지에서 수험생의 답안을 자동으로 인식·채점하는 OMR(Optical Mark Recognition) 서비스입니다. OpenCV 기반의 이미지 처리 파이프라인으로 답안지를 분석하고, 등급과 총점을 산출합니다. RabbitMQ 소비자로 동작하며, lumie-backend가 grading.omr-request 큐에 작업을 발행하면 처리 후 grading.omr.callback 라우팅 키로 콜백합니다.
기술 스택
| 라이브러리 | 버전 | 용도 |
|---|---|---|
| OpenCV | 4.8.1.78 | 이미지 전처리, 마킹 감지 |
| NumPy | 1.24.3 | 행렬 연산, 밀도 분석 |
| Pillow | 10.3.0 | 이미지 포맷 변환 |
| FastAPI | 0.115.0 | HTTP API 서버 |
| Uvicorn | 0.30.0 | ASGI 서버 |
| aio_pika | — | RabbitMQ 비동기 소비자 |
| httpx | 0.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.py의 universal_process가 처리합니다. grading 소비자는 build_grade_handler 콜백을 주입하여 사용합니다. 에러 발생 시 메시지는 설정된 횟수만큼 재시도 후 DLQ로 이동합니다.
이미지 처리 파이프라인 (OpenCV)
1단계: 이미지 크기 조정
입력 이미지를 2480×3508px (A4 300dpi) 기준으로 리사이징합니다.
2단계: 기울기 보정 (Deskew)
상단 바코드 영역의 검은색 사각형들을 감지하여 이미지의 기울기를 자동으로 보정합니다.
- 상단 15% 영역에서 검은색 사각형 23개를 감지
- 좌측 5개와 우측 5개 사각형의 중심점을 계산
- 두 점을 잇는 직선의 기울기를 구해 회전 각도 결정
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_HOST | localhost | RabbitMQ 호스트 |
RABBITMQ_PORT | 5672 | RabbitMQ 포트 |
RABBITMQ_QUEUE | grading.omr-request | 소비 큐 이름 |
RABBITMQ_PREFETCH_COUNT | 3 | 동시 처리 메시지 수 |
MINIO_ENDPOINT | localhost:9000 | MinIO 엔드포인트 |
MINIO_ACCESS_KEY | — (필수) | MinIO 접근 키 |
MINIO_SECRET_KEY | — (필수) | MinIO 비밀 키 |
MINIO_BUCKET | lumie | MinIO 버킷 |
LUMIE_BACKEND_URL | — (필수) | 백엔드 콜백 URL |
LUMIE_INTERNAL_HMAC_SECRET | — (필수) | HMAC 서명 비밀키 |
REDIS_HOST | redis.lumie-cache.svc | Redis 호스트 |
REDIS_ENABLED | true | Redis 중복 방지 활성화 |
Kubernetes 리소스
resources:
requests:
memory: "1Gi"
cpu: "100m"
limits:
memory: "2Gi"
cpu: "1000m"
이미지 처리는 CPU 집약적이므로, 배치 채점 시 CPU 한도(1000m)까지 사용될 수 있습니다.
헬스체크
GET /health
{ "status": "healthy" }