Hammad712 commited on
Commit
6c63e71
Β·
verified Β·
1 Parent(s): 6707be0

Update services.py

Browse files
Files changed (1) hide show
  1. services.py +399 -0
services.py CHANGED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import cv2
3
+ from PIL import Image, ImageEnhance
4
+ from io import BytesIO
5
+ from pdf2image import convert_from_path
6
+ import json
7
+
8
+ from app.gapi_client import get_genai_client
9
+ from app.utils import extract_json_from_output
10
+
11
+ # Global GenAI client
12
+ CLIENT = None
13
+
14
+ def init_genai(api_key: str):
15
+ """
16
+ Initialize the global GenAI client with the provided API key.
17
+ """
18
+ global CLIENT
19
+ CLIENT = get_genai_client(api_key)
20
+
21
+
22
+ def parse_all_answers(image_input: Image.Image) -> str:
23
+ """
24
+ Extracts answers from a full answer-sheet image using Gemini.
25
+ Returns the raw JSON string from the model.
26
+ """
27
+ output_format = '''
28
+ Answer in the following JSON format. Do not write anything else:
29
+ {
30
+ "Paper name": {"name": "<paper Alphabet>"},
31
+ "Answers": {
32
+ "1": "<option or text>",
33
+ "2": "<option or text>",
34
+ "3": "<option or text>",
35
+ "4": "<option or text>",
36
+ "5": "<option or text>",
37
+ "6": "<option or text>",
38
+ "7": "<option or text>",
39
+ "8": "<option or text>",
40
+ "9": "<option or text>",
41
+ "10": "<option or text>",
42
+ "11": "<option or text>",
43
+ "12": "<option or text>",
44
+ "13": "<option or text>",
45
+ "14": "<option or text>",
46
+ "15": "<option or text>",
47
+ "16": "<option or text>",
48
+ "17": "<option or text>",
49
+ "18": "<option or text>",
50
+ "19": "<option or text>",
51
+ "20": "<option or text>",
52
+ "21": "<free text answer>",
53
+ "22": "<free text answer>",
54
+ "23": "<free text answer>",
55
+ "24": "<free text answer>",
56
+ "25": "<free text answer>"
57
+ }
58
+ }
59
+ '''
60
+ prompt = f"""
61
+ You are an assistant that extracts answers from an image.
62
+ Write only the Alphabet(A,B,C,D,E,F) of the paper in the \"Paper name\" field.
63
+ The image is a screenshot of an answer sheet containing 25 questions.
64
+ For questions 1 to 20, the answers are multiple-choice selections.
65
+ For questions 21 to 25, the answers are free-text responses.
66
+ Extract the answer for each question (1 to 25) and provide the result in JSON using the format below:
67
+ {output_format}
68
+ """
69
+ response = CLIENT.models.generate_content(
70
+ model="gemini-2.0-flash",
71
+ contents=[prompt, image_input]
72
+ )
73
+ return response.text
74
+
75
+
76
+ def preprocess_pdf_last_page(image: Image.Image) -> Image.Image:
77
+ """
78
+ Preprocesses the last page PIL image:
79
+ - Convert to OpenCV BGR
80
+ - Mask vertical region
81
+ - Crop to mask
82
+ - Unsharp mask sharpen
83
+ - Enhance with PIL
84
+ """
85
+ # Convert to BGR
86
+ img_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
87
+ h, w = img_cv.shape[:2]
88
+
89
+ # Mask
90
+ mask = np.zeros((h, w), dtype="uint8")
91
+ top, bottom = int(h * 0.14), int(h * 0.73)
92
+ cv2.rectangle(mask, (0, top), (w, h - bottom), 255, -1)
93
+ masked = cv2.bitwise_and(img_cv, img_cv, mask=mask)
94
+
95
+ # Crop
96
+ coords = cv2.findNonZero(mask)
97
+ x, y, cw, ch = cv2.boundingRect(coords)
98
+ cropped = masked[y:y+ch, x:x+cw]
99
+
100
+ # Sharpen
101
+ blurred = cv2.GaussianBlur(cropped, (0, 0), sigmaX=3)
102
+ sharpened = cv2.addWeighted(cropped, 1.5, blurred, -0.5, 0)
103
+
104
+ # PIL enhancements
105
+ pil2 = Image.fromarray(cv2.cvtColor(sharpened, cv2.COLOR_BGR2RGB))
106
+ pil2 = ImageEnhance.Sharpness(pil2).enhance(1.3)
107
+ pil2 = ImageEnhance.Contrast(pil2).enhance(1.4)
108
+ pil2 = ImageEnhance.Brightness(pil2).enhance(1.1)
109
+ return pil2
110
+
111
+
112
+ def parse_info_with_gemini(pil_img: Image.Image) -> dict:
113
+ """
114
+ Calls Gemini on a header image to extract candidate info fields.
115
+ """
116
+ output_format = '''
117
+ Answer in the following JSON format. Do not write anything else:
118
+ {
119
+ "Candidate Info": {
120
+ "Paper": "<paper>",
121
+ "Level": "<level>",
122
+ "Candidate Name": "<name>",
123
+ "Candidate Number": "<number>",
124
+ "School": "<school>",
125
+ "Country": "<country>",
126
+ "grade level": "<grade level>",
127
+ "Date": "<date>"
128
+ }
129
+ }
130
+ '''
131
+ prompt = f"""
132
+ You are a helper that accurately reads a sharpened exam header image and extracts exactly these fields:
133
+ β€’ Paper (e.g. \"B\")
134
+ β€’ Level (e.g. \"MIDDLE PRIMARY\")
135
+ β€’ Candidate Name
136
+ β€’ Candidate Number
137
+ β€’ School
138
+ β€’ Country
139
+ β€’ grade level
140
+ β€’ Date (with time)
141
+ Return **only** valid JSON in this format:
142
+ {output_format}
143
+ """
144
+ response = CLIENT.models.generate_content(
145
+ model="gemini-2.0-flash",
146
+ contents=[prompt, pil_img]
147
+ )
148
+ return extract_json_from_output(response.text)
149
+
150
+
151
+ def extract_candidate_data(image: Image.Image) -> dict:
152
+ """
153
+ Preprocess last page and parse candidate info.
154
+ """
155
+ prepped = preprocess_pdf_last_page(image)
156
+ info = parse_info_with_gemini(prepped)
157
+ return info
158
+
159
+
160
+ def parse_mcq_answers(pil_image: Image.Image) -> str:
161
+ """
162
+ Extracts MCQ answers 1–10 from an image.
163
+ """
164
+ output_format = '''
165
+ Answer in the following JSON format. Do not write anything else:
166
+ {
167
+ "Answers": {
168
+ "1": "<option>",
169
+ "2": "<option>",
170
+ "3": "<option>",
171
+ "4": "<option>",
172
+ "5": "<option>",
173
+ "6": "<option>",
174
+ "7": "<option>",
175
+ "8": "<option>",
176
+ "9": "<option>",
177
+ "10": "<option>"
178
+ }
179
+ }
180
+ '''
181
+ prompt = f"""
182
+ You are an assistant that extracts MCQ answers from an image.
183
+ The image is a screenshot of a 10-question multiple-choice answer sheet.
184
+ Extract which option is marked for each question (1–10) and provide the answers in JSON:
185
+ {output_format}
186
+ """
187
+ response = CLIENT.models.generate_content(
188
+ model="gemini-2.0-flash",
189
+ contents=[prompt, pil_image]
190
+ )
191
+ return response.text
192
+
193
+
194
+ def get_mcqs1st(pil_image: Image.Image) -> dict:
195
+ """
196
+ Mask, crop, enhance, and parse MCQs 1–10.
197
+ """
198
+ img_cv = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
199
+ h, w = img_cv.shape[:2]
200
+ mask = np.zeros((h, w), dtype="uint8")
201
+ top, bot, right = int(h*0.30), int(h*0.44), int(w*0.35)
202
+ cv2.rectangle(mask, (0, top), (right, h-bot), 255, -1)
203
+ masked = cv2.bitwise_and(img_cv, img_cv, mask=mask)
204
+ coords = cv2.findNonZero(mask)
205
+ x, y, cw, ch = cv2.boundingRect(coords)
206
+ cropped = masked[y:y+ch, x:x+cw]
207
+ blur = cv2.GaussianBlur(cropped, (0,0), sigmaX=3)
208
+ sharp = cv2.addWeighted(cropped, 1.5, blur, -0.5, 0)
209
+ pil_sh = Image.fromarray(cv2.cvtColor(sharp, cv2.COLOR_BGR2RGB))
210
+ pil_sh = ImageEnhance.Sharpness(pil_sh).enhance(1.3)
211
+ pil_sh = ImageEnhance.Contrast(pil_sh).enhance(1.4)
212
+ final = ImageEnhance.Brightness(pil_sh).enhance(1.1)
213
+ raw = parse_mcq_answers(final)
214
+ return extract_json_from_output(raw)
215
+
216
+
217
+ def parse_mcq_answers_11_20(pil_image: Image.Image) -> str:
218
+ """
219
+ Extracts MCQ answers 11–20 from an image.
220
+ """
221
+ output_format = '''
222
+ Answer in the following JSON format. Do not write anything else:
223
+ {
224
+ "Answers": {
225
+ "11": "<option>",
226
+ "12": "<option>",
227
+ "13": "<option>",
228
+ "14": "<option>",
229
+ "15": "<option>",
230
+ "16": "<option>",
231
+ "17": "<option>",
232
+ "18": "<option>",
233
+ "19": "<option>",
234
+ "20": "<option>"
235
+ }
236
+ }
237
+ '''
238
+ prompt = f"""
239
+ You are an assistant that extracts MCQ answers from an image.
240
+ The image is a screenshot of questions 11–20.
241
+ Extract the marked option for each and return JSON:
242
+ {output_format}
243
+ """
244
+ response = CLIENT.models.generate_content(
245
+ model="gemini-2.0-flash",
246
+ contents=[prompt, pil_image]
247
+ )
248
+ return response.text
249
+
250
+
251
+ def get_mcqs2nd(pil_image: Image.Image) -> dict:
252
+ """
253
+ Mask, crop, enhance, and parse MCQs 11–20.
254
+ """
255
+ img_cv = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
256
+ h, w = img_cv.shape[:2]
257
+ mask = np.zeros((h, w), dtype="uint8")
258
+ top, bottom, right = int(h*0.56), int(h*0.21), int(w*0.35)
259
+ cv2.rectangle(mask, (0, top), (right, h-bottom), 255, -1)
260
+ masked = cv2.bitwise_and(img_cv, img_cv, mask=mask)
261
+ coords = cv2.findNonZero(mask)
262
+ x, y, cw, ch = cv2.boundingRect(coords)
263
+ cropped = masked[y:y+ch, x:x+cw]
264
+ blurred = cv2.GaussianBlur(cropped, (0,0), sigmaX=3)
265
+ sharp = cv2.addWeighted(cropped, 1.5, blurred, -0.5, 0)
266
+ pil_sharp = Image.fromarray(cv2.cvtColor(sharp, cv2.COLOR_BGR2RGB))
267
+ pil_sharp = ImageEnhance.Sharpness(pil_sharp).enhance(1.3)
268
+ pil_sharp = ImageEnhance.Contrast(pil_sharp).enhance(1.4)
269
+ final_pil = ImageEnhance.Brightness(pil_sharp).enhance(1.1)
270
+ raw = parse_mcq_answers_11_20(final_pil)
271
+ return extract_json_from_output(raw)
272
+
273
+
274
+ def parse_text_answers(pil_image: Image.Image) -> str:
275
+ """
276
+ Extracts free-text answers 21–25 from an image.
277
+ """
278
+ output_format = '''
279
+ Answer in the following JSON format. Do not write anything else:
280
+ {
281
+ "Answers": {
282
+ "21": "<text>",
283
+ "22": "<text>",
284
+ "23": "<text>",
285
+ "24": "<text>",
286
+ "25": "<text>"
287
+ }
288
+ }
289
+ '''
290
+ prompt = f"""
291
+ You are an assistant that extracts free-text answers from an image.
292
+ The image shows answers to questions 21–25.
293
+ Extract the text for each and return JSON:
294
+ {output_format}
295
+ """
296
+ response = CLIENT.models.generate_content(
297
+ model="gemini-2.0-flash",
298
+ contents=[prompt, pil_image]
299
+ )
300
+ return response.text
301
+
302
+
303
+ def get_answer(pil_image: Image.Image) -> dict:
304
+ """
305
+ Mask, crop, enhance, and parse free-text 21–25.
306
+ """
307
+ img_cv = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
308
+ h, w = img_cv.shape[:2]
309
+ mask = np.zeros((h, w), dtype="uint8")
310
+ top, bottom = int(h*0.31), int(h*0.31)
311
+ left, right = int(w*0.35), int(w*0.66)
312
+ cv2.rectangle(mask, (left, top), (right, h-bottom), 255, -1)
313
+ masked = cv2.bitwise_and(img_cv, img_cv, mask=mask)
314
+ coords = cv2.findNonZero(mask)
315
+ x, y, cw, ch = cv2.boundingRect(coords)
316
+ cropped = masked[y:y+ch, x:x+cw]
317
+ blurred = cv2.GaussianBlur(cropped, (0,0), sigmaX=3)
318
+ sharp = cv2.addWeighted(cropped, 1.5, blurred, -0.5, 0)
319
+ pil_sharp = Image.fromarray(cv2.cvtColor(sharp, cv2.COLOR_BGR2RGB))
320
+ pil_sharp = ImageEnhance.Sharpness(pil_sharp).enhance(1.3)
321
+ pil_sharp = ImageEnhance.Contrast(pil_sharp).enhance(1.4)
322
+ final_pil = ImageEnhance.Brightness(pil_sharp).enhance(1.1)
323
+ raw = parse_text_answers(final_pil)
324
+ return extract_json_from_output(raw)
325
+
326
+
327
+ def infer_page(pil_image: Image.Image) -> dict:
328
+ """
329
+ Full pipeline for a single exam page.
330
+ """
331
+ student_info = extract_candidate_data(pil_image)
332
+ mcq1 = get_mcqs1st(pil_image) or {}
333
+ mcq2 = get_mcqs2nd(pil_image) or {}
334
+ free_txt = get_answer(pil_image) or {}
335
+ all_answers = {**mcq1.get("Answers", {}), **mcq2.get("Answers", {}), **free_txt.get("Answers", {})}
336
+ return {"Candidate Info": student_info.get("Candidate Info", {}), "Answers": all_answers}
337
+
338
+
339
+ def infer_all_pages(pdf_path: str) -> dict:
340
+ """
341
+ Processes every page in the PDF and infers student data.
342
+ """
343
+ results = {}
344
+ pages = convert_from_path(pdf_path)
345
+ for idx, page in enumerate(pages, start=1):
346
+ data = infer_page(page)
347
+ info = data.get("Candidate Info", {})
348
+ key = info.get("Candidate Number") or f"Page_{idx}"
349
+ if data.get("Answers"):
350
+ results[key] = data
351
+ return results
352
+
353
+
354
+ def load_answer_key(pdf_path: str) -> dict:
355
+ """
356
+ Parses the official answer-key PDF into a dict of paper->answers.
357
+ """
358
+ images = convert_from_path(pdf_path)
359
+ key_dict = {}
360
+ for page in images:
361
+ raw = parse_all_answers(page)
362
+ parsed = extract_json_from_output(raw)
363
+ name = parsed.get("Paper name", {}).get("name")
364
+ key_dict[name] = parsed.get("Answers", {})
365
+ return key_dict
366
+
367
+
368
+ def grade_page(student_page_data: dict, answer_key_dict: dict) -> dict:
369
+ """
370
+ Grades a single student page against the loaded key.
371
+ """
372
+ paper = student_page_data.get("Candidate Info", {}).get("Paper")
373
+ correct = answer_key_dict.get(paper, {})
374
+ student_ans = student_page_data.get("Answers", {})
375
+ total_q = len(correct)
376
+ correct_count = 0
377
+ detailed = {}
378
+ for q, key_ans in correct.items():
379
+ stud_ans = student_ans.get(q, "")
380
+ is_corr = str(stud_ans).strip().upper() == str(key_ans).strip().upper()
381
+ if is_corr:
382
+ correct_count += 1
383
+ detailed[q] = {"Correct Answer": key_ans, "Student Answer": stud_ans, "Is Correct": is_corr}
384
+ percentage = round(correct_count/total_q*100, 2) if total_q else 0.0
385
+ return {"Candidate Info": student_page_data.get("Candidate Info", {}), "Total Marks": correct_count, "Total Questions": total_q, "Percentage": percentage, "Detailed Results": detailed}
386
+
387
+
388
+ def grade_all_students(answer_key_pdf: str, student_pdf: str, out_json: str = "results.json") -> dict:
389
+ """
390
+ Loads key, infers all students, grades them, and writes JSON.
391
+ """
392
+ key_dict = load_answer_key(answer_key_pdf)
393
+ students = infer_all_pages(student_pdf)
394
+ results = {}
395
+ for cand, data in students.items():
396
+ results[cand] = grade_page(data, key_dict)
397
+ with open(out_json, "w") as f:
398
+ json.dump(results, f, indent=2)
399
+ return results