Spaces:
Running
Running
Update baigiang.py
Browse files- baigiang.py +409 -219
baigiang.py
CHANGED
|
@@ -1,242 +1,432 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
-
import
|
| 3 |
-
import
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
import
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
BG_COLOR = RGBColor(255, 255, 255)
|
| 22 |
-
|
| 23 |
-
# --- HÀM 1: RENDER CÔNG THỨC TOÁN THÀNH ẢNH ---
|
| 24 |
-
def render_math_to_image(latex_str):
|
| 25 |
try:
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
fig = plt.figure(figsize=(8, 0.8), dpi=200) # Giảm chiều cao xuống 0.8 để gọn hơn
|
| 30 |
-
fig.patch.set_alpha(0)
|
| 31 |
-
|
| 32 |
-
# Vẽ công thức
|
| 33 |
-
# Dùng r"..." để tránh lỗi escape character
|
| 34 |
-
text_obj = fig.text(0.01, 0.5, f"${clean_latex}$", fontsize=22,
|
| 35 |
-
ha='left', va='center', color='#333333', fontname='DejaVu Sans')
|
| 36 |
-
|
| 37 |
-
img_buffer = BytesIO()
|
| 38 |
-
plt.savefig(img_buffer, format='png', bbox_inches='tight', pad_inches=0.05)
|
| 39 |
-
plt.close(fig)
|
| 40 |
-
img_buffer.seek(0)
|
| 41 |
-
return img_buffer
|
| 42 |
except Exception as e:
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
# --- HÀM
|
| 47 |
-
async def generate_lesson_plan(api_keys: list, images_data: list, subject: str, grade: str, duration: int, style: str):
|
| 48 |
-
if not api_keys: raise ValueError("Thiếu API Key")
|
| 49 |
-
MODEL_NAME = "gemini-2.5-flash"
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
3. Bắt đầu dòng công thức bằng `[MATH]`.
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
{{
|
| 70 |
-
"title": "TÊN BÀI HỌC",
|
| 71 |
-
"slides": [
|
| 72 |
-
{{ "layout": "title", "title": "TÊN BÀI", "subtitle": "GV AI | Lớp {grade}" }},
|
| 73 |
-
{{
|
| 74 |
-
"layout": "content",
|
| 75 |
-
"title": "1. Tiêu đề mục",
|
| 76 |
-
"content": [
|
| 77 |
-
"Định nghĩa tập hợp là:",
|
| 78 |
-
"[MATH] A = \\{{1; 2; 3\\}}",
|
| 79 |
-
"Ký hiệu thuộc là:",
|
| 80 |
-
"[MATH] x \\in A"
|
| 81 |
-
],
|
| 82 |
-
"highlight": "Ghi nhớ"
|
| 83 |
-
}}
|
| 84 |
-
]
|
| 85 |
-
}}
|
| 86 |
-
"""
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
if "429" in str(e) or "quota" in str(e).lower(): continue
|
| 113 |
-
continue
|
| 114 |
-
|
| 115 |
-
return {"title": "Lỗi", "slides": [{"layout": "content", "title": "Lỗi Hệ Thống", "content": ["Tất cả API Key đều hết hạn."]}]}
|
| 116 |
-
|
| 117 |
-
# --- HÀM 3: SLIDE TRANSITION ---
|
| 118 |
-
def set_slide_transition(slide):
|
| 119 |
-
xml = """
|
| 120 |
-
<p:transition xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" spd="med">
|
| 121 |
-
<p:fade/>
|
| 122 |
-
</p:transition>
|
| 123 |
-
"""
|
| 124 |
-
transition_element = parse_xml(xml)
|
| 125 |
-
found_cSld = False
|
| 126 |
-
for i, child in enumerate(slide.element):
|
| 127 |
-
if "cSld" in child.tag:
|
| 128 |
-
slide.element.insert(i + 1, transition_element)
|
| 129 |
-
found_cSld = True
|
| 130 |
-
break
|
| 131 |
-
if not found_cSld:
|
| 132 |
-
slide.element.append(transition_element)
|
| 133 |
-
|
| 134 |
-
def add_header(slide, title_text, slide_num):
|
| 135 |
-
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), Inches(10), Inches(1.2))
|
| 136 |
-
shape.fill.solid()
|
| 137 |
-
shape.fill.fore_color.rgb = THEME_COLOR
|
| 138 |
-
shape.line.fill.background()
|
| 139 |
-
|
| 140 |
-
tf = shape.text_frame
|
| 141 |
-
tf.text = title_text.upper()
|
| 142 |
-
p = tf.paragraphs[0]
|
| 143 |
-
p.font.bold = True
|
| 144 |
-
p.font.size = Pt(30) # Giảm xíu cho gọn
|
| 145 |
-
p.font.color.rgb = RGBColor(255,255,255)
|
| 146 |
-
p.alignment = PP_ALIGN.LEFT
|
| 147 |
-
p.margin_left = Inches(0.5)
|
| 148 |
-
|
| 149 |
-
footer = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(9), Inches(7), Inches(1), Inches(0.5))
|
| 150 |
-
footer.fill.background()
|
| 151 |
-
footer.line.fill.background()
|
| 152 |
-
footer.text_frame.text = str(slide_num)
|
| 153 |
-
footer.text_frame.paragraphs[0].alignment = PP_ALIGN.RIGHT
|
| 154 |
-
footer.text_frame.paragraphs[0].font.color.rgb = RGBColor(150,150,150)
|
| 155 |
-
|
| 156 |
-
# --- HÀM CHÍNH: TẠO FILE PPTX ---
|
| 157 |
-
def create_pptx_file(slide_data):
|
| 158 |
-
prs = Presentation()
|
| 159 |
-
|
| 160 |
-
for idx, slide_info in enumerate(slide_data.get("slides", [])):
|
| 161 |
-
if slide_info.get("layout") == "title":
|
| 162 |
-
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
| 163 |
-
set_slide_transition(slide)
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
p2.font.size = Pt(24)
|
| 181 |
-
p2.font.color.rgb = RGBColor(230,230,230)
|
| 182 |
-
p2.alignment = PP_ALIGN.CENTER
|
| 183 |
-
|
| 184 |
-
else:
|
| 185 |
-
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
| 186 |
-
set_slide_transition(slide)
|
| 187 |
-
add_header(slide, slide_info.get("title", ""), idx + 1)
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
if img_stream:
|
| 204 |
-
# Chèn ảnh công thức
|
| 205 |
-
pic = slide.shapes.add_picture(img_stream, left_margin + Inches(0.5), current_top, height=Inches(0.6))
|
| 206 |
-
current_top += Inches(0.8) # Dịch xuống ít hơn (0.8 inch)
|
| 207 |
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
tb = slide.shapes.add_textbox(left_margin, current_top, Inches(9), text_height)
|
| 215 |
-
p = tb.text_frame.paragraphs[0]
|
| 216 |
-
p.text = "• " + clean_item
|
| 217 |
-
p.font.size = Pt(24) # GIẢM CỠ CHỮ XUỐNG 24pt
|
| 218 |
-
p.font.name = "Arial"
|
| 219 |
-
p.font.color.rgb = TEXT_COLOR
|
| 220 |
-
p.line_spacing = 1.0 # Giãn dòng đơn
|
| 221 |
-
|
| 222 |
-
# Cộng dồn vị trí dựa trên chiều cao thực tế
|
| 223 |
-
current_top += text_height + Inches(0.1)
|
| 224 |
-
|
| 225 |
-
if "highlight" in slide_info and slide_info["highlight"]:
|
| 226 |
-
# Đẩy highlight xuống sát đáy
|
| 227 |
-
hl_box = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(1), Inches(6.5), Inches(8), Inches(0.8))
|
| 228 |
-
hl_box.fill.solid()
|
| 229 |
-
hl_box.fill.fore_color.rgb = RGBColor(255, 255, 204)
|
| 230 |
-
hl_box.line.color.rgb = RGBColor(255, 204, 0)
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
import imutils
|
| 4 |
+
from imutils.contours import sort_contours
|
| 5 |
import json
|
| 6 |
+
import base64
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
import uuid
|
| 10 |
+
from fastapi import APIRouter, File, UploadFile, Form
|
| 11 |
+
from supabase import create_client, Client
|
| 12 |
+
|
| 13 |
+
# Cấu hình Log
|
| 14 |
+
logging.basicConfig(level=logging.INFO)
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/api/omr", tags=["ChamThiOMR"])
|
| 18 |
+
|
| 19 |
+
# --- KẾT NỐI SUPABASE ---
|
| 20 |
+
SUPABASE_URL = os.environ.get("SUPABASE_URL")
|
| 21 |
+
SUPABASE_KEY = os.environ.get("SUPABASE_KEY")
|
| 22 |
+
supabase: Client = None
|
| 23 |
+
|
| 24 |
+
if SUPABASE_URL and SUPABASE_KEY:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
try:
|
| 26 |
+
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 27 |
+
logger.info("✅ OMR Module: Đã kết nối Supabase")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
except Exception as e:
|
| 29 |
+
logger.error(f"❌ OMR Module: Lỗi kết nối Supabase: {e}")
|
| 30 |
+
|
| 31 |
+
# --- BIẾN TOÀN CỤC (CACHE RAM) ---
|
| 32 |
+
sessions = {}
|
| 33 |
|
| 34 |
+
# --- CÁC HÀM XỬ LÝ ẢNH ---
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
+
def order_points(pts):
|
| 37 |
+
"""Sắp xếp 4 điểm góc theo thứ tự: trên-trái, trên-phải, dưới-phải, dưới-trái"""
|
| 38 |
+
rect = np.zeros((4, 2), dtype="float32")
|
| 39 |
+
s = pts.sum(axis=1)
|
| 40 |
+
rect[0] = pts[np.argmin(s)]
|
| 41 |
+
rect[2] = pts[np.argmax(s)]
|
| 42 |
+
diff = np.diff(pts, axis=1)
|
| 43 |
+
rect[1] = pts[np.argmin(diff)]
|
| 44 |
+
rect[3] = pts[np.argmax(diff)]
|
| 45 |
+
return rect
|
| 46 |
+
|
| 47 |
+
def four_point_transform(image, pts):
|
| 48 |
+
"""Biến đổi phối cảnh (warp) để làm phẳng tờ phiếu"""
|
| 49 |
+
rect = order_points(pts)
|
| 50 |
+
(tl, tr, br, bl) = rect
|
| 51 |
+
maxWidth = max(int(np.linalg.norm(br - bl)), int(np.linalg.norm(tr - tl)))
|
| 52 |
+
maxHeight = max(int(np.linalg.norm(tr - br)), int(np.linalg.norm(tl - bl)))
|
| 53 |
+
dst = np.array([[0, 0],[maxWidth - 1, 0],[maxWidth - 1, maxHeight - 1],[0, maxHeight - 1]], dtype="float32")
|
| 54 |
+
M = cv2.getPerspectiveTransform(rect, dst)
|
| 55 |
+
return cv2.warpPerspective(image, M, (maxWidth, maxHeight))
|
| 56 |
+
|
| 57 |
+
def read_bubbles(roi, cols, rows, draw_on_me=None, offset=(0,0), bubble_thresh=100):
|
| 58 |
+
"""
|
| 59 |
+
Hàm đọc bong bóng đa năng.
|
| 60 |
+
roi: Vùng ảnh chứa bong bóng
|
| 61 |
+
cols: Số lượng cột dọc cần đọc
|
| 62 |
+
rows: Số lượng hàng ngang (số bong bóng trong 1 cột)
|
| 63 |
+
bubble_thresh: Ngưỡng pixel (đã giảm xuống 100 để nhạy hơn)
|
| 64 |
+
"""
|
| 65 |
+
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
|
| 66 |
+
# Dùng Threshold Otsu để tách đen trắng
|
| 67 |
+
thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
|
| 68 |
|
| 69 |
+
cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 70 |
+
cnts = imutils.grab_contours(cnts)
|
| 71 |
+
bubbles = []
|
|
|
|
| 72 |
|
| 73 |
+
h_img, w_img = roi.shape[:2]
|
| 74 |
+
# Kích thước tối thiểu của bong bóng (để lọc nhiễu)
|
| 75 |
+
# [TWEAK] Giảm kích thước tối thiểu để bắt được bong bóng nhỏ
|
| 76 |
+
min_w = w_img // (cols * 6)
|
| 77 |
|
| 78 |
+
for c in cnts:
|
| 79 |
+
(x, y, w, h) = cv2.boundingRect(c)
|
| 80 |
+
ar = w / float(h)
|
| 81 |
+
# [TWEAK] Nới lỏng tỷ lệ khung hình (0.5 - 1.5) để chấp nhận ô bị méo
|
| 82 |
+
if w >= min_w and 0.5 <= ar <= 1.5:
|
| 83 |
+
bubbles.append(c)
|
| 84 |
|
| 85 |
+
if not bubbles: return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
+
try:
|
| 88 |
+
# 1. Sắp xếp tất cả bong bóng từ TRÁI sang PHẢI để phân thành các cột
|
| 89 |
+
bubbles = sort_contours(bubbles, method="left-to-right")[0]
|
| 90 |
+
columns = []
|
| 91 |
+
current_col = []
|
| 92 |
+
prev_x = -1000
|
| 93 |
+
|
| 94 |
+
# Thuật toán gom nhóm các bong bóng thẳng hàng dọc thành cột
|
| 95 |
+
for c in bubbles:
|
| 96 |
+
(x, y, w, h) = cv2.boundingRect(c)
|
| 97 |
+
# Nếu khoảng cách x nhảy vọt -> sang cột mới
|
| 98 |
+
if x - prev_x > w:
|
| 99 |
+
if current_col:
|
| 100 |
+
# Sắp xếp cột hiện tại từ TRÊN xuống DƯỚI (0 -> 9)
|
| 101 |
+
current_col = sort_contours(current_col, method="top-to-bottom")[0]
|
| 102 |
+
columns.append(current_col)
|
| 103 |
+
current_col = [c]
|
| 104 |
+
prev_x = x + w/2 # Cập nhật tâm x mới
|
| 105 |
+
else:
|
| 106 |
+
current_col.append(c)
|
| 107 |
+
|
| 108 |
+
if current_col:
|
| 109 |
+
current_col = sort_contours(current_col, method="top-to-bottom")[0]
|
| 110 |
+
columns.append(current_col)
|
| 111 |
|
| 112 |
+
# Chỉ lấy đúng số cột cần thiết (tránh nhiễu bên ngoài)
|
| 113 |
+
if len(columns) > cols: columns = columns[:cols]
|
| 114 |
+
|
| 115 |
+
result_str = ""
|
| 116 |
+
|
| 117 |
+
for col in columns:
|
| 118 |
+
filled_idx = -1
|
| 119 |
+
max_pixel = 0
|
| 120 |
|
| 121 |
+
# Duyệt từng bong bóng trong cột (0-9)
|
| 122 |
+
for i, c in enumerate(col):
|
| 123 |
+
mask = np.zeros(thresh.shape, dtype="uint8")
|
| 124 |
+
cv2.drawContours(mask, [c], -1, 255, -1)
|
| 125 |
+
mask = cv2.bitwise_and(thresh, thresh, mask=mask)
|
| 126 |
+
total = cv2.countNonZero(mask) # Đếm số pixel trắng (đã tô)
|
| 127 |
+
|
| 128 |
+
# Debug: Vẽ khung xanh quanh bong bóng tìm thấy
|
| 129 |
+
if draw_on_me is not None:
|
| 130 |
+
(x, y, w, h) = cv2.boundingRect(c)
|
| 131 |
+
cv2.rectangle(draw_on_me, (x+offset[0], y+offset[1]), (x+w+offset[0], y+h+offset[1]), (0, 255, 0), 1)
|
| 132 |
+
|
| 133 |
+
if total > max_pixel:
|
| 134 |
+
max_pixel = total
|
| 135 |
+
filled_idx = i
|
| 136 |
|
| 137 |
+
# Kiểm tra ngưỡng tô (threshold)
|
| 138 |
+
if max_pixel > bubble_thresh:
|
| 139 |
+
result_str += str(filled_idx) if filled_idx < 10 else "?"
|
| 140 |
+
# Debug: Vẽ chấm đỏ vào ô được chọn
|
| 141 |
+
if draw_on_me is not None and filled_idx != -1 and filled_idx < len(col):
|
| 142 |
+
c = col[filled_idx]
|
| 143 |
+
(x, y, w, h) = cv2.boundingRect(c)
|
| 144 |
+
cv2.circle(draw_on_me, (int(x+w/2+offset[0]), int(y+h/2+offset[1])), int(w/2), (0, 0, 255), 3)
|
| 145 |
+
else:
|
| 146 |
+
result_str += "?" # Không tô hoặc tô quá mờ
|
| 147 |
+
|
| 148 |
+
return result_str
|
| 149 |
+
|
| 150 |
+
except Exception as e:
|
| 151 |
+
logger.error(f"Sort Error: {e}")
|
| 152 |
+
return None
|
| 153 |
+
|
| 154 |
+
def process_omr(image_bytes, all_keys):
|
| 155 |
+
try:
|
| 156 |
+
nparr = np.frombuffer(image_bytes, np.uint8)
|
| 157 |
+
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 158 |
+
|
| 159 |
+
if image.shape[1] > 1600: image = imutils.resize(image, width=1600)
|
| 160 |
+
|
| 161 |
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
| 162 |
+
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
| 163 |
+
edged = cv2.Canny(blurred, 75, 200)
|
| 164 |
+
|
| 165 |
+
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 166 |
+
cnts = imutils.grab_contours(cnts)
|
| 167 |
+
|
| 168 |
+
docCnt = None
|
| 169 |
+
if len(cnts) > 0:
|
| 170 |
+
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
|
| 171 |
+
for c in cnts:
|
| 172 |
+
peri = cv2.arcLength(c, True)
|
| 173 |
+
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
|
| 174 |
+
if len(approx) == 4:
|
| 175 |
+
docCnt = approx
|
| 176 |
+
break
|
| 177 |
+
|
| 178 |
+
if docCnt is not None:
|
| 179 |
+
warped = four_point_transform(image, docCnt.reshape(4, 2))
|
| 180 |
+
else:
|
| 181 |
+
warped = image
|
| 182 |
+
|
| 183 |
+
h, w = warped.shape[:2]
|
| 184 |
+
draw_img = warped.copy()
|
| 185 |
+
|
| 186 |
+
# === CẤU HÌNH VÙNG CẮT MỚI (UPDATE QUAN TRỌNG) ===
|
| 187 |
+
# Thay vì cắt dọc (Trái/Phải), ta cắt ngang (Trên/Dưới) để phù hợp layout mới
|
| 188 |
+
|
| 189 |
+
# 1. XỬ LÝ ID (SBD + MÃ ĐỀ) - Nằm ở phần trên (Khoảng 18% đến 38% chiều cao trang)
|
| 190 |
+
# ID nằm lệch trái nên ta lấy 70% chiều rộng bên trái
|
| 191 |
+
id_y_start = int(h * 0.18)
|
| 192 |
+
id_y_end = int(h * 0.38)
|
| 193 |
+
id_x_end = int(w * 0.75) # Lấy rộng ra chút để bao trọn cả Mã đề
|
| 194 |
+
|
| 195 |
+
id_roi = warped[id_y_start:id_y_end, 0:id_x_end]
|
| 196 |
+
|
| 197 |
+
# Đọc 10 cột (6 SBD + 4 Mã đề)
|
| 198 |
+
id_result = read_bubbles(id_roi, 10, 10, draw_on_me=draw_img, offset=(0, id_y_start), bubble_thresh=100)
|
| 199 |
+
|
| 200 |
+
sbd = "AI_READ"
|
| 201 |
+
code = "DEFAULT"
|
| 202 |
+
|
| 203 |
+
if id_result and len(id_result) >= 6:
|
| 204 |
+
sbd = id_result[:6].replace("?", "")
|
| 205 |
+
if len(id_result) >= 10:
|
| 206 |
+
code = id_result[6:10].replace("?", "")
|
| 207 |
+
elif len(id_result) >= 9:
|
| 208 |
+
code = id_result[6:].replace("?", "")
|
| 209 |
+
|
| 210 |
+
# 2. XỬ LÝ CÂU HỎI - Nằm ở phần dưới (Từ 38% trở xuống)
|
| 211 |
+
# Quét toàn bộ chiều rộng (0 đến w) vì câu hỏi dàn đều 4 cột
|
| 212 |
+
ans_y_start = int(h * 0.38)
|
| 213 |
+
ans_roi_y = ans_y_start # Lưu mốc Y để vẽ debug cho đúng
|
| 214 |
+
|
| 215 |
+
# Logic chọn Key
|
| 216 |
+
avail_codes = list(all_keys.keys())
|
| 217 |
+
if code not in avail_codes and avail_codes:
|
| 218 |
+
if "DEFAULT" in avail_codes: code = "DEFAULT"
|
| 219 |
+
else: code = str(avail_codes[0])
|
| 220 |
|
| 221 |
+
key_data = all_keys.get(code, {})
|
| 222 |
+
total_q = len(key_data)
|
| 223 |
+
|
| 224 |
+
num_cols_page = 4 if total_q > 30 else 3
|
| 225 |
+
|
| 226 |
+
# Tính chiều rộng mỗi cột câu hỏi (Chia đều toàn bộ chiều rộng trang)
|
| 227 |
+
# Trừ đi lề trái phải một chút (khoảng 5% mỗi bên)
|
| 228 |
+
margin_x = int(w * 0.05)
|
| 229 |
+
eff_w = w - (2 * margin_x)
|
| 230 |
+
col_width = eff_w // num_cols_page
|
| 231 |
+
|
| 232 |
+
score = 0
|
| 233 |
+
correct_count = 0
|
| 234 |
+
|
| 235 |
+
for c_idx in range(num_cols_page):
|
| 236 |
+
c_x_start = margin_x + c_idx * col_width
|
| 237 |
|
| 238 |
+
# Cắt vùng ảnh của cột câu hỏi (Lấy hết chiều cao còn lại)
|
| 239 |
+
c_roi = warped[ans_y_start:h-50, c_x_start : c_x_start + col_width]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
+
gray_c = cv2.cvtColor(c_roi, cv2.COLOR_BGR2GRAY)
|
| 242 |
+
thresh_c = cv2.threshold(gray_c, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
|
| 243 |
+
cnts_c = cv2.findContours(thresh_c, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 244 |
+
cnts_c = imutils.grab_contours(cnts_c)
|
| 245 |
|
| 246 |
+
bubbles_c = []
|
| 247 |
+
for b in cnts_c:
|
| 248 |
+
(bx, by, bw, bh) = cv2.boundingRect(b)
|
| 249 |
+
# Lọc kích thước bong bóng
|
| 250 |
+
if bw > 10 and bw < 60: bubbles_c.append(b)
|
| 251 |
+
|
| 252 |
+
if not bubbles_c: continue
|
| 253 |
|
| 254 |
+
try:
|
| 255 |
+
bubbles_c = sort_contours(bubbles_c, method="top-to-bottom")[0]
|
| 256 |
+
except: continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
+
rows = []
|
| 259 |
+
temp_row = []
|
| 260 |
+
prev_y = -1000
|
| 261 |
|
| 262 |
+
for b in bubbles_c:
|
| 263 |
+
(bx, by, bw, bh) = cv2.boundingRect(b)
|
| 264 |
+
if by - prev_y > bh * 0.8:
|
| 265 |
+
if temp_row:
|
| 266 |
+
if len(temp_row) == 4:
|
| 267 |
+
temp_row = sort_contours(temp_row, method="left-to-right")[0]
|
| 268 |
+
rows.append(temp_row)
|
| 269 |
+
temp_row = [b]
|
| 270 |
+
prev_y = by + bh/2
|
| 271 |
+
else:
|
| 272 |
+
temp_row.append(b)
|
| 273 |
+
if len(temp_row) == 4:
|
| 274 |
+
temp_row = sort_contours(temp_row, method="left-to-right")[0]
|
| 275 |
+
rows.append(temp_row)
|
| 276 |
+
|
| 277 |
+
for r_idx, row in enumerate(rows):
|
| 278 |
+
questions_per_col = 10
|
| 279 |
+
q_num = c_idx * questions_per_col + r_idx + 1
|
| 280 |
+
|
| 281 |
+
if q_num > total_q: break
|
| 282 |
+
|
| 283 |
+
filled_opt = -1
|
| 284 |
+
max_p = 0
|
| 285 |
+
|
| 286 |
+
for o_idx, b in enumerate(row):
|
| 287 |
+
mask = np.zeros(thresh_c.shape, dtype="uint8")
|
| 288 |
+
cv2.drawContours(mask, [b], -1, 255, -1)
|
| 289 |
+
mask = cv2.bitwise_and(thresh_c, thresh_c, mask=mask)
|
| 290 |
+
total = cv2.countNonZero(mask)
|
| 291 |
|
| 292 |
+
# Debug khung
|
| 293 |
+
(bx, by, bw, bh) = cv2.boundingRect(b)
|
| 294 |
+
gx, gy = c_x_start + bx, ans_roi_y + by
|
| 295 |
+
cv2.rectangle(draw_img, (gx, gy), (gx+bw, gy+bh), (0, 255, 0), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
+
if total > max_p:
|
| 298 |
+
max_p = total
|
| 299 |
+
filled_opt = o_idx
|
| 300 |
+
|
| 301 |
+
correct_char = key_data.get(q_num, key_data.get(str(q_num)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
+
if correct_char:
|
| 304 |
+
correct_idx = ord(correct_char) - 65
|
| 305 |
+
|
| 306 |
+
# [TWEAK] Giảm ngưỡng xác nhận tô xuống 100
|
| 307 |
+
if max_p > 100:
|
| 308 |
+
b = row[filled_opt]
|
| 309 |
+
(bx, by, bw, bh) = cv2.boundingRect(b)
|
| 310 |
+
gx, gy = c_x_start + bx, ans_roi_y + by
|
| 311 |
+
|
| 312 |
+
if filled_opt == correct_idx:
|
| 313 |
+
correct_count += 1
|
| 314 |
+
cv2.circle(draw_img, (gx+bw//2, gy+bh//2), bw//2, (0, 255, 0), -1)
|
| 315 |
+
else:
|
| 316 |
+
cv2.circle(draw_img, (gx+bw//2, gy+bh//2), bw//2, (0, 0, 255), -1)
|
| 317 |
+
|
| 318 |
+
if correct_idx < len(row):
|
| 319 |
+
b_correct = row[correct_idx]
|
| 320 |
+
(bx_c, by_c, bw_c, bh_c) = cv2.boundingRect(b_correct)
|
| 321 |
+
gx_c, gy_c = c_x_start + bx_c, ans_roi_y + by_c
|
| 322 |
+
cv2.circle(draw_img, (gx_c+bw_c//2, gy_c+bh_c//2), 5, (255, 0, 0), -1)
|
| 323 |
+
|
| 324 |
+
if total_q > 0:
|
| 325 |
+
score = round((correct_count / total_q) * 10, 2)
|
| 326 |
+
|
| 327 |
+
cv2.rectangle(draw_img, (0, 0), (w, 100), (255, 255, 255), -1)
|
| 328 |
+
cv2.putText(draw_img, f"SBD: {sbd} | CODE: {code}", (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0,0,0), 2)
|
| 329 |
+
cv2.putText(draw_img, f"DIEM: {score} ({correct_count}/{total_q})", (20, 90), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0,0,255), 3)
|
| 330 |
+
|
| 331 |
+
_, buffer = cv2.imencode('.jpg', draw_img)
|
| 332 |
+
b64 = base64.b64encode(buffer).decode('utf-8')
|
| 333 |
+
|
| 334 |
+
return {
|
| 335 |
+
"student_id": sbd, "exam_code": code, "score": score,
|
| 336 |
+
"correct_count": correct_count, "total_questions": total_q,
|
| 337 |
+
"wrong_count": total_q - correct_count, "image_base64": b64,
|
| 338 |
+
"status": "success"
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
except Exception as e:
|
| 342 |
+
logger.error(f"OMR Error: {str(e)}")
|
| 343 |
+
import traceback
|
| 344 |
+
traceback.print_exc()
|
| 345 |
+
return {"score": 0, "error": str(e)}
|
| 346 |
+
|
| 347 |
+
# --- DB HELPERS & API (GIỮ NGUYÊN) ---
|
| 348 |
+
def save_result_to_db(data, filename, session_id):
|
| 349 |
+
if not supabase: return
|
| 350 |
+
try:
|
| 351 |
+
payload = {
|
| 352 |
+
"session_id": session_id,
|
| 353 |
+
"student_id": data.get("student_id"),
|
| 354 |
+
"exam_code": data.get("exam_code"),
|
| 355 |
+
"score": data.get("score"),
|
| 356 |
+
"correct_count": data.get("correct_count"),
|
| 357 |
+
"wrong_count": data.get("wrong_count"),
|
| 358 |
+
"total_questions": data.get("total_questions"),
|
| 359 |
+
"filename": filename
|
| 360 |
+
}
|
| 361 |
+
supabase.table("omr_results").insert(payload).execute()
|
| 362 |
+
except Exception as e:
|
| 363 |
+
logger.error(f"DB Save Error: {e}")
|
| 364 |
+
|
| 365 |
+
def get_key_from_db(session_id):
|
| 366 |
+
if not supabase: return {}
|
| 367 |
+
try:
|
| 368 |
+
res = supabase.table("omr_sessions").select("answer_key_json").eq("session_id", session_id).execute()
|
| 369 |
+
if res.data:
|
| 370 |
+
return json.loads(res.data[0]['answer_key_json'])
|
| 371 |
+
except: pass
|
| 372 |
+
return {}
|
| 373 |
+
|
| 374 |
+
@router.post("/create_session")
|
| 375 |
+
async def create_session(name: str = Form(...), answer_key_json: str = Form(...)):
|
| 376 |
+
session_id = str(uuid.uuid4())[:8].upper()
|
| 377 |
+
try: all_keys = json.loads(answer_key_json)
|
| 378 |
+
except: all_keys = {}
|
| 379 |
+
sessions[session_id] = {"keys": all_keys, "results": []}
|
| 380 |
+
if supabase:
|
| 381 |
+
try:
|
| 382 |
+
supabase.table("omr_sessions").insert({
|
| 383 |
+
"session_id": session_id, "session_name": name, "answer_key_json": answer_key_json
|
| 384 |
+
}).execute()
|
| 385 |
+
except Exception as e:
|
| 386 |
+
logger.error(f"Create Session DB Error: {e}")
|
| 387 |
+
return {"session_id": session_id, "name": name}
|
| 388 |
+
|
| 389 |
+
@router.post("/grade")
|
| 390 |
+
async def grade_exam(file: UploadFile = File(...), answer_key_json: str = Form(...)):
|
| 391 |
+
try: all_keys = json.loads(answer_key_json)
|
| 392 |
+
except: return {"error": "JSON Error"}
|
| 393 |
+
content = await file.read()
|
| 394 |
+
result = process_omr(content, all_keys)
|
| 395 |
+
save_result_to_db(result, file.filename, "Direct_Upload")
|
| 396 |
+
return result
|
| 397 |
+
|
| 398 |
+
@router.post("/grade_mobile")
|
| 399 |
+
async def grade_mobile(session_id: str = Form(...), file: UploadFile = File(...)):
|
| 400 |
+
if session_id not in sessions:
|
| 401 |
+
db_keys = get_key_from_db(session_id)
|
| 402 |
+
if db_keys: sessions[session_id] = {"keys": db_keys, "results": []}
|
| 403 |
+
else: return {"error": "Session not found"}
|
| 404 |
+
content = await file.read()
|
| 405 |
+
result = process_omr(content, sessions[session_id]["keys"])
|
| 406 |
+
result["filename"] = file.filename
|
| 407 |
+
sessions[session_id]["results"].append(result)
|
| 408 |
+
save_result_to_db(result, file.filename, session_id)
|
| 409 |
+
return result
|
| 410 |
+
|
| 411 |
+
@router.get("/poll_results/{session_id}")
|
| 412 |
+
async def poll_results(session_id: str):
|
| 413 |
+
if session_id not in sessions: return []
|
| 414 |
+
res = sessions[session_id]["results"]
|
| 415 |
+
sessions[session_id]["results"] = []
|
| 416 |
+
return res
|
| 417 |
+
|
| 418 |
+
@router.get("/history")
|
| 419 |
+
def get_history():
|
| 420 |
+
if not supabase: return []
|
| 421 |
+
try: return supabase.table("omr_sessions").select("*").order("created_at", desc=True).execute().data
|
| 422 |
+
except: return []
|
| 423 |
+
|
| 424 |
+
@router.get("/history/{session_id}")
|
| 425 |
+
def get_history_detail(session_id: str):
|
| 426 |
+
if not supabase: return []
|
| 427 |
+
try: return supabase.table("omr_results").select("*").eq("session_id", session_id).order("created_at", desc=True).execute().data
|
| 428 |
+
except: return []
|
| 429 |
+
|
| 430 |
+
@router.post("/init_session")
|
| 431 |
+
async def init_session(answer_key_json: str = Form(...)):
|
| 432 |
+
return await create_session(name="Quick Session", answer_key_json=answer_key_json)
|