Files
joossam/services/fastapi/main.py
Mayne0213 615fe6e574 INIT(api): add FastAPI application
- Initialize FastAPI project structure
- Add basic API configuration
2025-12-01 14:34:20 +09:00

621 lines
20 KiB
Python

from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Dict, List, Optional
import json
import tempfile
import os
import sys
import cv2
import numpy as np
app = FastAPI(
title="Joossam OMR Grading API",
description="OMR 채점을 위한 FastAPI 서비스",
version="1.0.0"
)
# CORS 설정 - Vercel에서 호스팅되는 프론트엔드 허용
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://joossameng.vercel.app",
"https://*.vercel.app",
"http://localhost:3000",
"http://localhost:3001",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- 상수 및 설정 ---
TARGET_WIDTH = 2480
TARGET_HEIGHT = 3508
# --- 함수 정의 ---
def load_image_from_bytes(image_bytes: bytes):
"""바이트 데이터에서 이미지 로드"""
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("이미지를 읽을 수 없습니다")
return img, img.shape[:2]
def resize_image_to_target(img, target_width=TARGET_WIDTH, target_height=TARGET_HEIGHT):
"""이미지를 타겟 크기로 리사이징 (비율 유지)"""
h, w = img.shape[:2]
if w == target_width and h == target_height:
return img, 1.0, 1.0
scale_x = target_width / w
scale_y = target_height / h
new_width = int(w * scale_x)
new_height = int(h * scale_y)
resized_img = cv2.resize(img, (new_width, new_height), interpolation=cv2.INTER_CUBIC)
return resized_img, scale_x, scale_y
def gamma_correction(img, gamma=0.7):
"""감마 보정으로 밝기 곡선 조정"""
inv_gamma = 1.0 / gamma
table = np.array([((i / 255.0) ** inv_gamma) * 255 for i in np.arange(0, 256)]).astype("uint8")
return cv2.LUT(img, table)
def unsharp_mask(img, kernel_size=(5, 5), sigma=1.0, amount=1.0):
"""언샤프 마스킹으로 경계 선명화"""
if len(img.shape) == 3:
img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(img, kernel_size, sigma)
sharpened = cv2.addWeighted(img, 1.0 + amount, blurred, -amount, 0)
return sharpened
def deskew_image_with_barcodes(img):
"""바코드를 기준으로 이미지 기울기 보정"""
try:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(gray, 180, 255, cv2.THRESH_BINARY_INV)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
img_height, img_width = img.shape[:2]
top_area_threshold = img_height * 0.15
top_rectangles = []
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
if (y < top_area_threshold and
w > 10 and h > 10 and
w < 300 and h < 300):
center_x = x + w // 2
center_y = y + h // 2
top_rectangles.append({'center': (center_x, center_y)})
if len(top_rectangles) < 15:
return img
top_rectangles.sort(key=lambda x: x['center'][1])
top_rectangles = top_rectangles[:23]
top_rectangles.sort(key=lambda x: x['center'][0])
if len(top_rectangles) >= 10:
left_points = top_rectangles[:5]
right_points = top_rectangles[-5:]
left_avg_x = np.mean([p['center'][0] for p in left_points])
left_avg_y = np.mean([p['center'][1] for p in left_points])
right_avg_x = np.mean([p['center'][0] for p in right_points])
right_avg_y = np.mean([p['center'][1] for p in right_points])
delta_y = right_avg_y - left_avg_y
delta_x = right_avg_x - left_avg_x
if delta_x == 0:
return img
angle_rad = np.arctan2(delta_y, delta_x)
angle_deg = np.degrees(angle_rad)
if abs(angle_deg) < 0.3:
return img
if abs(angle_deg) > 10:
return img
center = (img_width // 2, img_height // 2)
rotation_matrix = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
deskewed = cv2.warpAffine(
img, rotation_matrix, (img_width, img_height),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE
)
return deskewed
return img
except Exception:
return img
def preprocess_omr_image(img):
"""OMR 이미지 전처리 강화"""
denoised = cv2.GaussianBlur(img, (3, 3), 0)
gamma_corrected = gamma_correction(denoised, gamma=0.7)
if len(gamma_corrected.shape) == 3:
gray = cv2.cvtColor(gamma_corrected, cv2.COLOR_BGR2GRAY)
else:
gray = gamma_corrected
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(gray)
sharpened = unsharp_mask(enhanced, amount=0.8)
result = cv2.cvtColor(sharpened, cv2.COLOR_GRAY2BGR)
return result
def calculate_marking_density(img, x, y, width=30, height=60):
"""특정 좌표 주변 영역의 마킹 밀도 계산"""
h, w = img.shape[:2]
x1 = max(0, x - width//2)
y1 = max(0, y - height//2)
x2 = min(w, x + width//2)
y2 = min(h, y + height//2)
region = img[y1:y2, x1:x2]
if region.size == 0:
return 0.0
if len(region.shape) == 3:
region = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY)
avg_darkness = 255 - np.mean(region)
dark_pixels = np.sum(region < 180)
total_pixels = region.size
dark_ratio = dark_pixels / total_pixels
medium_dark_pixels = np.sum(region < 160)
medium_dark_ratio = medium_dark_pixels / total_pixels
very_dark_pixels = np.sum(region < 120)
very_dark_ratio = very_dark_pixels / 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
upperValueSquare = 180
def find_top_black_rectangles(img):
"""상단 검은색 사각형들을 찾아서 좌표 반환"""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(gray, upperValueSquare, 255, cv2.THRESH_BINARY_INV)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
top_rectangles = []
img_height = img.shape[0]
top_area_threshold = img_height * 0.15
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
if (y < top_area_threshold and
w > 10 and h > 10 and
w < 300 and h < 300):
center_x = x + w // 2
center_y = y + h // 2
top_rectangles.append({
'center': (center_x, center_y),
'bbox': (x, y, w, h),
'area': w * h
})
top_rectangles.sort(key=lambda x: x['center'][1])
selected_rectangles = top_rectangles[:23]
selected_rectangles.sort(key=lambda x: x['center'][0])
return selected_rectangles
def find_side_black_rectangles(img):
"""좌우측 검은색 사각형들을 찾아서 좌표 반환 (Y축 계산용)"""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(gray, upperValueSquare, 255, cv2.THRESH_BINARY_INV)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
img_height, img_width = img.shape[:2]
left_area_threshold = img_width * 0.15
right_area_start = img_width * 0.85
left_rectangles = []
right_rectangles = []
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
center_x = x + w // 2
center_y = y + h // 2
if (w > 20 and h > 20 and
w < 70 and h < 70):
if center_x < left_area_threshold:
left_rectangles.append({
'center': (center_x, center_y),
'bbox': (x, y, w, h),
'area': w * h
})
elif center_x > right_area_start:
right_rectangles.append({
'center': (center_x, center_y),
'bbox': (x, y, w, h),
'area': w * h
})
left_rectangles.sort(key=lambda x: x['center'][0])
left_selected = left_rectangles[:10]
left_selected.sort(key=lambda x: x['center'][1])
right_rectangles.sort(key=lambda x: x['center'][0])
right_selected = right_rectangles[-20:] if len(right_rectangles) >= 20 else right_rectangles
right_selected.sort(key=lambda x: x['center'][1])
return left_selected, right_selected
def define_phone_positions(img_width, img_height, top_rectangles, left_rectangles):
"""전화번호 전용 위치 계산 (1-8번 사각형, 1-10번 숫자 자리)"""
phone_positions = {}
for rect_index in range(8):
if rect_index < len(top_rectangles):
rect_center_x = top_rectangles[rect_index]['center'][0]
digit_position = rect_index + 1
if len(left_rectangles) >= 10:
phone_positions[digit_position] = {}
for digit in range(10):
if digit < len(left_rectangles):
y = left_rectangles[digit]['center'][1]
phone_positions[digit_position][str(digit)] = (rect_center_x, y)
return phone_positions
def define_answer_positions(img_width, img_height, top_rectangles, left_rectangles, right_rectangles):
"""답안 전용 위치 계산 (9-23번 사각형, 1-45번 문제)"""
positions = {}
for rect_index in range(8, 13):
if rect_index < len(top_rectangles):
rect_center_x = top_rectangles[rect_index]['center'][0]
choice_num = rect_index - 7
if len(right_rectangles) >= 20:
for q in range(1, 21):
if q not in positions:
positions[q] = {}
question_index = q - 1
if question_index < len(right_rectangles):
y = right_rectangles[question_index]['center'][1]
positions[q][str(choice_num)] = (rect_center_x, y)
for rect_index in range(13, 18):
if rect_index < len(top_rectangles):
rect_center_x = top_rectangles[rect_index]['center'][0]
choice_num = rect_index - 12
if len(right_rectangles) >= 20:
for q in range(21, 41):
if q not in positions:
positions[q] = {}
question_index = q - 21
if question_index < len(right_rectangles):
y = right_rectangles[question_index]['center'][1]
positions[q][str(choice_num)] = (rect_center_x, y)
for rect_index in range(18, 23):
if rect_index < len(top_rectangles):
rect_center_x = top_rectangles[rect_index]['center'][0]
choice_num = rect_index - 17
if len(right_rectangles) >= 5:
for q in range(41, 46):
if q not in positions:
positions[q] = {}
question_index = q - 41
if question_index < len(right_rectangles):
y = right_rectangles[question_index]['center'][1]
positions[q][str(choice_num)] = (rect_center_x, y)
return positions
def estimate_phone_number_with_density(img, phone_positions, min_density=0.17):
"""전화번호 추정"""
phone_selected = {}
for digit_pos, digit_choices in phone_positions.items():
if not digit_choices:
phone_selected[digit_pos] = "0"
continue
digit_densities = {}
for digit, coord in digit_choices.items():
x, y = coord
density = calculate_marking_density(img, x, y)
digit_densities[digit] = density
highest_digit, highest_density = max(digit_densities.items(), key=lambda x: x[1])
if highest_density >= min_density:
phone_selected[digit_pos] = highest_digit
else:
phone_selected[digit_pos] = "0"
return phone_selected
def estimate_selected_answers_with_density(img, answer_positions, min_density=0.2):
"""답안 추정"""
selected = {}
for q_num, choices in answer_positions.items():
if not choices:
selected[str(q_num)] = "무효"
continue
choice_densities = {}
for choice, coord in choices.items():
x, y = coord
density = calculate_marking_density(img, x, y)
choice_densities[choice] = density
highest_choice, highest_density = max(choice_densities.items(), key=lambda x: x[1])
if highest_density >= min_density:
selected[str(q_num)] = highest_choice
else:
selected[str(q_num)] = "무효"
return selected
def extract_phone_number(phone_selected):
"""전화번호 8자리 추출"""
phone_digits = []
for i in range(1, 9):
if i in phone_selected:
digit = phone_selected[i]
if digit and digit != "무효":
try:
digit_int = int(digit)
if 0 <= digit_int <= 9:
phone_digits.append(str(digit_int))
else:
phone_digits.append("0")
except ValueError:
phone_digits.append("0")
else:
phone_digits.append("0")
else:
phone_digits.append("0")
phone_number = "".join(phone_digits)
return phone_number
def calculate_total_score(selected_answers, correct_answers, question_scores):
"""총점 계산"""
total = 0
for q_num, correct_answer in correct_answers.items():
if q_num in selected_answers:
student_answer = selected_answers[q_num]
if student_answer == correct_answer:
score = question_scores.get(q_num, 0)
total += score
return total
def calculate_grade(total_score):
"""등급 계산"""
if total_score >= 90:
return 1
elif total_score >= 80:
return 2
elif total_score >= 70:
return 3
elif total_score >= 60:
return 4
elif total_score >= 50:
return 5
elif total_score >= 40:
return 6
elif total_score >= 30:
return 7
elif total_score >= 20:
return 8
else:
return 9
def create_results_array(selected_answers, correct_answers, question_scores, question_types):
"""결과 배열 생성"""
results = []
for q_num in sorted(correct_answers.keys(), key=lambda x: int(x)):
try:
q_num_int = int(q_num)
student_answer = selected_answers.get(q_num, "무효")
correct_answer = correct_answers[q_num]
score = question_scores.get(q_num, 0)
question_type = question_types.get(q_num, "기타")
earned_score = score if student_answer == correct_answer else 0
results.append({
"questionNumber": q_num_int,
"studentAnswer": str(student_answer),
"correctAnswer": correct_answer,
"score": score,
"earnedScore": earned_score,
"questionType": question_type
})
except (ValueError, TypeError):
continue
return results
def grade_omr_from_bytes(image_bytes: bytes, correct_answers: Dict, question_scores: Dict, question_types: Dict):
"""OMR 채점 메인 함수 (바이트 입력)"""
try:
img, (h, w) = load_image_from_bytes(image_bytes)
deskewed_img = deskew_image_with_barcodes(img)
expected_ratio = TARGET_WIDTH / TARGET_HEIGHT
actual_ratio = w / h
resized_img, scale_x, scale_y = resize_image_to_target(deskewed_img)
resized_h, resized_w = resized_img.shape[:2]
preprocessed_img = preprocess_omr_image(resized_img)
top_rectangles = find_top_black_rectangles(resized_img)
left_rectangles, right_rectangles = find_side_black_rectangles(resized_img)
phone_positions = define_phone_positions(resized_w, resized_h, top_rectangles, left_rectangles)
answer_positions = define_answer_positions(resized_w, resized_h, top_rectangles, left_rectangles, right_rectangles)
phone_selected = estimate_phone_number_with_density(preprocessed_img, phone_positions)
phone_number = extract_phone_number(phone_selected)
selected_answers = estimate_selected_answers_with_density(preprocessed_img, answer_positions)
correct_count = 0
for q_num, correct_answer in correct_answers.items():
if q_num in selected_answers:
student_answer = selected_answers[q_num]
if str(student_answer) == str(correct_answer):
correct_count += 1
total_score = calculate_total_score(selected_answers, correct_answers, question_scores)
grade = calculate_grade(total_score)
results = create_results_array(selected_answers, correct_answers, question_scores, question_types)
final_result = {
"totalScore": total_score,
"grade": grade,
"phoneNumber": phone_number,
"results": results,
"imageInfo": {
"originalSize": f"{w}x{h}",
"resizedSize": f"{resized_w}x{resized_h}",
"scaleFactors": {"x": scale_x, "y": scale_y},
"aspectRatio": {"expected": expected_ratio, "actual": actual_ratio}
}
}
return final_result
except Exception as e:
raise HTTPException(status_code=500, detail=f"OMR 채점 실패: {str(e)}")
# API 엔드포인트들
@app.get("/")
async def root():
"""헬스체크 엔드포인트"""
return {"status": "healthy", "message": "Joossam OMR Grading API is running"}
@app.get("/health")
async def health():
"""헬스체크 엔드포인트"""
return {"status": "healthy"}
class GradingRequest(BaseModel):
correct_answers: Dict[str, str]
question_scores: Dict[str, int]
question_types: Dict[str, str]
@app.post("/api/omr/grade")
async def grade_omr(
image: UploadFile = File(...),
correct_answers: str = Form(...),
question_scores: str = Form(...),
question_types: str = Form(...)
):
"""
OMR 채점 API
- image: OMR 이미지 파일
- correct_answers: 정답 JSON (예: {"1": "3", "2": "1", ...})
- question_scores: 문제별 점수 JSON (예: {"1": 2, "2": 2, ...})
- question_types: 문제 유형 JSON (예: {"1": "어휘", "2": "문법", ...})
"""
try:
# JSON 파싱
correct_answers_dict = json.loads(correct_answers)
question_scores_dict = json.loads(question_scores)
question_types_dict = json.loads(question_types)
# 이미지 읽기
image_bytes = await image.read()
# OMR 채점 실행
result = grade_omr_from_bytes(
image_bytes,
correct_answers_dict,
question_scores_dict,
question_types_dict
)
return result
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"JSON 파싱 오류: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"채점 오류: {str(e)}")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)