hoangthiencm commited on
Commit
5d6d869
·
verified ·
1 Parent(s): 8bdd1a0

Update baigiang.py

Browse files
Files changed (1) hide show
  1. baigiang.py +409 -219
baigiang.py CHANGED
@@ -1,242 +1,432 @@
 
 
 
 
1
  import json
2
- import google.generativeai as genai
3
- import re # Thêm thư viện Regex để xử lý chuỗi
4
-
5
- # --- CẤU HÌNH HEADLESS ---
6
- import matplotlib
7
- matplotlib.use('Agg')
8
- import matplotlib.pyplot as plt
9
-
10
- from pptx import Presentation
11
- from pptx.util import Inches, Pt
12
- from pptx.dml.color import RGBColor
13
- from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
14
- from pptx.enum.shapes import MSO_SHAPE
15
- from pptx.oxml import parse_xml
16
- from io import BytesIO
17
-
18
- # --- CẤU HÌNH ---
19
- THEME_COLOR = RGBColor(0, 112, 192)
20
- TEXT_COLOR = RGBColor(30, 30, 30)
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
- # Xóa các ký tự thừa nếu AI lỡ thêm vào
27
- clean_latex = latex_str.replace("`", "").strip()
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
- print(f"Lỗi render math: {e}")
44
- return None
 
 
45
 
46
- # --- HÀM 2: GỬI GEMINI (PROMPT CHẶT CHẼ HƠN) ---
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
- prompt = f"""
52
- Đóng vai trò giáo viên {subject} lớp {grade}. Soạn bài giảng slide ({duration} phút).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- QUY TẮC TUYỆT ĐỐI VỀ CÔNG THỨC TOÁN (BẮT BUỘC TUÂN THỦ):
55
- 1. KHÔNG VIẾT CÔNG THỨC CHUNG DÒNG VỚI VĂN BẢN.
56
- 2. Muốn viết công thức, PHẢI XUỐNG DÒNG RIÊNG.
57
- 3. Bắt đầu dòng công thức bằng `[MATH]`.
58
 
59
- SAI: "Ta phương trình [MATH] x^2 + 1 = 0"
60
- ĐÚNG:
61
- "Ta phương trình sau:"
62
- "[MATH] x^2 + 1 = 0"
63
 
64
- QUY TẮC BỐ CỤC CHỐNG TRÀN:
65
- - Mỗi slide tối đa 4 ý chính.
66
- - Mỗi ý viết NGẮN GỌN (tối đa 15 từ). KHÔNG viết đoạn văn dài.
 
 
 
67
 
68
- Output JSON:
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
- for index, key in enumerate(api_keys):
89
- key = key.strip()
90
- if not key: continue
91
- print(f"🔄 Thử Key {index+1}...")
92
- try:
93
- genai.configure(api_key=key)
94
- model = genai.GenerativeModel(MODEL_NAME)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
- request_contents = [prompt]
97
- request_contents.extend(images_data)
 
 
 
 
 
 
98
 
99
- response = model.generate_content(request_contents)
100
- txt = response.text
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
- if "```json" in txt:
103
- txt = txt.split("```json")[1].split("```")[0]
104
- elif "```" in txt:
105
- txt = txt.split("```")[1].split("```")[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
 
107
- print(f"✅ Key {index+1} OK")
108
- return json.loads(txt.strip())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
- except Exception as e:
111
- print(f"❌ Lỗi Key {index+1}: {e}")
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
- bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), Inches(10), Inches(7.5))
166
- bg.fill.solid()
167
- bg.fill.fore_color.rgb = THEME_COLOR
 
168
 
169
- tb = slide.shapes.add_textbox(Inches(0.5), Inches(2.5), Inches(9), Inches(2.5))
170
- p = tb.text_frame.paragraphs[0]
171
- p.text = slide_info.get("title", "").upper()
172
- p.font.size = Pt(48)
173
- p.font.bold = True
174
- p.font.color.rgb = RGBColor(255,255,255)
175
- p.alignment = PP_ALIGN.CENTER
176
 
177
- tb2 = slide.shapes.add_textbox(Inches(1), Inches(5), Inches(8), Inches(1))
178
- p2 = tb2.text_frame.paragraphs[0]
179
- p2.text = slide_info.get("subtitle", "")
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
- # Điều chỉnh vị trí bắt đầu và khoảng cách
190
- current_top = Inches(1.4)
191
- left_margin = Inches(0.5)
192
 
193
- content_list = slide_info.get("content", [])
194
- if isinstance(content_list, list):
195
- for item in content_list:
196
- # Kiểm tra [MATH] (Linh hoạt hơn chút: strip khoảng trắng)
197
- clean_item = item.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
- if clean_item.startswith("[MATH]"):
200
- latex_code = clean_item.replace("[MATH]", "").strip()
201
- img_stream = render_math_to_image(latex_code)
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
- else:
209
- # Chèn Text thường
210
- # Tính chiều cao ước lượng dựa trên độ dài text
211
- text_height = Inches(0.6)
212
- if len(clean_item) > 80: text_height = Inches(1.0) # Nếu dài quá thì ô to hơn
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
- tf_hl = hl_box.text_frame
233
- p_hl = tf_hl.paragraphs[0]
234
- p_hl.text = "💡 " + slide_info["highlight"]
235
- p_hl.font.size = Pt(18)
236
- p_hl.font.color.rgb = RGBColor(100, 100, 0)
237
- p_hl.alignment = PP_ALIGN.CENTER
238
-
239
- output = BytesIO()
240
- prs.save(output)
241
- output.seek(0)
242
- return output
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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Ử Ả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)