felixkky commited on
Commit
54ad522
·
verified ·
1 Parent(s): ed03f96

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +610 -260
app.py CHANGED
@@ -1,310 +1,660 @@
 
 
 
 
1
  import os
2
- import sys
3
  import shutil
4
  import subprocess
5
- import json
6
- import copy
7
- import re
8
- from collections import Counter # 🚀 導入計數器進行多數決投票
9
- import gradio as gr
 
 
 
 
10
  import cv2
 
11
  import pandas as pd
12
 
13
  # =======================================================
14
- # 🚀 核心修復:Gradio 官方 OpenAPI Schema 解析崩潰布林值補丁
15
  # =======================================================
16
  try:
17
  import gradio_client.utils
18
- orig_get_type = gradio_client.utils.get_type
19
- orig_json_schema_to_python_type = gradio_client.utils._json_schema_to_python_type
20
 
21
- def patched_get_type(schema, *args, **kwargs):
 
 
 
 
 
22
  if isinstance(schema, bool):
23
  return "Any"
24
- return orig_get_type(schema, *args, **kwargs)
25
 
26
- def patched_json_schema_to_python_type(schema, *args, **kwargs):
 
 
27
  if isinstance(schema, bool):
28
  return "Any"
29
- return orig_json_schema_to_python_type(schema, *args, **kwargs)
30
 
31
- gradio_client.utils.get_type = patched_get_type
32
- gradio_client.utils._json_schema_to_python_type = patched_json_schema_to_python_type
33
- print("【系統提示】成功動態植入 Gradio 核心 Schema 容錯補丁,已免疫 bool 迭代錯誤。")
34
- except Exception as patch_e:
35
- print(f"【系統提示】嘗試套用 Gradio 補丁時發生異常(若未崩潰可忽略): {patch_e}")
36
- # =======================================================
37
-
38
- OMR_DIR = "OMRChecker"
39
- MOCK_DIR = os.path.abspath("mock_libs")
40
 
41
  # =======================================================
42
- # 在硬碟建立實體偽裝庫,並透過 PYTHONPATH 注入子程序 (驅逐 screeninfo 報錯)
43
  # =======================================================
44
- os.makedirs(MOCK_DIR, exist_ok=True)
45
- with open(os.path.join(MOCK_DIR, "screeninfo.py"), "w", encoding="utf-8") as f:
46
- f.write('''\
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  class ScreenInfoError(Exception):
48
  pass
 
49
  class FakeMonitor:
50
  width = 1920
51
  height = 1080
 
 
52
  def get_monitors():
53
  return [FakeMonitor()]
54
- ''')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
- # 1. 啟動時檢查並自動克隆 OMRChecker 官方專案
57
- if not os.path.exists(OMR_DIR):
58
- print("正在從 GitHub 複製 OMRChecker 核心專案...")
59
- subprocess.run(["git", "clone", "https://github.com/udayraj123/OMRChecker.git"], check=True)
60
- subprocess.run([sys.executable, "-m", "pip", "install", "-r", f"{OMR_DIR}/requirements.txt"], check=True)
61
 
 
 
62
 
63
- def process_omr(image_file, template_content=None):
64
- if image_file is None:
65
- return None, None, "錯誤:請務必上傳答案卡圖片。"
66
 
67
- # 🚀 偵測上傳圖片的實際解析度
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  try:
69
- img = cv2.imread(image_file)
70
- if img is not None:
71
- img_h, img_w, _ = img.shape
72
- dimension_info = f"【圖片載入成功】解析度: {img_w} x {img_h} px\n"
73
- else:
74
- dimension_info = "【圖片載入失敗】無法讀取圖片。\n"
75
- except Exception as e:
76
- dimension_info = f"【圖片讀取錯誤】{str(e)}\n"
77
-
78
- current_dir = os.path.dirname(__file__)
79
-
80
- # 🚀 智能降級機制:若 API 或網頁端未傳入 JSON 配置,主動撈取本地目錄的 template*.json
81
- if not template_content or str(template_content).strip() == "":
82
- for file in os.listdir(current_dir):
83
- if file.lower().startswith("template") and file.lower().endswith(".json"):
84
- json_src = os.path.join(current_dir, file)
85
- try:
86
- with open(json_src, "r", encoding="utf-8") as f:
87
- template_content = f.read()
88
- print(f"【系統提示】未偵測到傳入的 JSON 配置,已自動載入本地配置: {file}")
89
- break
90
- except Exception:
91
- pass
92
-
93
- if not template_content:
94
- return None, None, f"{dimension_info}錯誤:找不到有效的 Template JSON 配置。\n請提供 JSON 內容或在後端存放 template.json"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  try:
97
- base_template_dict = json.loads(template_content)
98
- except Exception as e:
99
- return None, None, f"{dimension_info}錯誤:Template JSON 格式不正確。\n{str(e)}"
100
-
101
- base_bubble_w, base_bubble_h = base_template_dict.get("bubbleDimensions", [60, 30])
102
-
103
- # =======================================================
104
- # 🚀 定義多重採樣策略 (Multi-Sampling)
105
- # =======================================================
106
- sensitivity_profiles = [
107
- {"name": "Level 1: 標���掃描 (嚴謹判斷防雜訊)", "shrink_w": 0, "shrink_h": 0, "levels_high": 0.9},
108
- {"name": "Level 2: 增強靈敏度 (適應單橫線筆跡)", "shrink_w": 12, "shrink_h": 8, "levels_high": 0.75},
109
- {"name": "Level 3: 極限靈敏度 (適應極淡筆跡)", "shrink_w": 20, "shrink_h": 14, "levels_high": 0.55}
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  ]
111
 
112
- job_dir = os.path.join(OMR_DIR, "inputs", "hf_space_job")
113
- images_dir = os.path.join(job_dir, "images")
114
- outputs_dir = os.path.join(OMR_DIR, "outputs")
115
-
116
- all_scans_results = []
117
- accumulated_logs = f"--- 🚀 開始執行多重採樣與決策融合 (Ensemble Voting) ---\n"
118
-
119
- # 🚀 確保對齊用的 template.jpg 基準圖存在
120
- template_img_found = False
121
- for file in os.listdir(current_dir):
122
- if file.lower().startswith("template") and file.lower().endswith((".jpg", ".jpeg", ".png")):
123
- template_img_src = os.path.join(current_dir, file)
124
- template_img_found = True
125
- break
126
-
127
- # 執行三輪完整掃描以收集樣本進行投票
128
- for profile in sensitivity_profiles:
129
- accumulated_logs += f"\n👉 執行採樣: [{profile['name']}]...\n"
130
-
131
- if os.path.exists(job_dir):
132
- shutil.rmtree(job_dir)
133
- os.makedirs(images_dir, exist_ok=True)
134
- if os.path.exists(outputs_dir):
135
- shutil.rmtree(outputs_dir)
136
-
137
- # 複製當前處理的學生考卷
138
- shutil.copy(image_file, os.path.join(images_dir, "sheet.jpg"))
139
-
140
- # 複製對齊用的基準圖
141
- if template_img_found:
142
- shutil.copy(template_img_src, os.path.join(job_dir, "template.jpg"))
143
-
144
- # 動態微調氣泡大小與 Levels 閥值
145
- current_template = copy.deepcopy(base_template_dict)
146
- new_w = max(10, base_bubble_w - profile['shrink_w'])
147
- new_h = max(10, base_bubble_h - profile['shrink_h'])
148
- current_template["bubbleDimensions"] = [new_w, new_h]
149
-
150
- if "preProcessors" in current_template:
151
- for pp in current_template["preProcessors"]:
152
- if pp.get("name") == "Levels" and "options" in pp:
153
- pp["options"]["high"] = profile["levels_high"]
154
-
155
- # 寫入本次靈敏度的臨時配置
156
- with open(os.path.join(job_dir, "template.json"), "w", encoding="utf-8") as f:
157
- json.dump(current_template, f)
158
-
159
- with open(os.path.join(OMR_DIR, "inputs", "template.json"), "w", encoding="utf-8") as f:
160
- json.dump(current_template, f)
161
-
162
- # 強迫啟用高等級視覺化劃記圈圈圖渲染 (show_image_level = 3)
163
- config_path = os.path.join(OMR_DIR, "config.json")
164
- if os.path.exists(config_path):
165
- try:
166
- with open(config_path, "r", encoding="utf-8") as f:
167
- config_data = json.load(f)
168
- config_data["show_image_level"] = 3
169
- with open(config_path, "w", encoding="utf-8") as f:
170
- json.dump(config_data, f, indent=4)
171
- except Exception as e:
172
- print(f"自動調整 config.json 失敗: {e}")
173
-
174
- # 配置環境變數,注入外部 mock_libs 實體偽裝庫
175
- current_env = os.environ.copy()
176
- current_env["PYTHONPATH"] = MOCK_DIR + os.pathsep + current_env.get("PYTHONPATH", "")
177
-
178
- result = subprocess.run(
179
- [sys.executable, "main.py", "-i", "inputs/hf_space_job"],
180
- cwd=OMR_DIR, capture_output=True, text=True, env=current_env
181
- )
182
 
183
- current_csv = None
184
- current_img = None
185
- if os.path.exists(outputs_dir):
186
- for root, dirs, files in os.walk(outputs_dir):
187
- for file in files:
188
- full_path = os.path.join(root, file)
189
- if file.endswith(".csv"):
190
- current_csv = full_path
191
- elif file.endswith((".jpg", ".jpeg", ".png")):
192
- if "checkedomrs" in root.lower():
193
- current_img = full_path
194
- elif current_img is None:
195
- current_img = full_path
196
-
197
- curr_answers = {i: "—" for i in range(1, 37)}
198
- full_log = (result.stdout or "") + "\n" + (result.stderr or "")
199
-
200
- # 嘗試解析本輪 CSV 答案
201
- has_csv_data = False
202
- if current_csv and os.path.exists(current_csv):
203
- try:
204
- df = pd.read_csv(current_csv)
205
- if not df.empty:
206
- norm_cols = {str(col).strip().lower(): col for col in df.columns}
207
- for i in range(1, 37):
208
- col_key = f"q{i}"
209
- if col_key in norm_cols:
210
- val = df.iloc[0][norm_cols[col_key]]
211
- if not pd.isna(val) and str(val).strip() != "":
212
- curr_answers[i] = str(val).strip().upper()
213
- has_csv_data = True
214
- except:
215
- pass
216
-
217
- # 若無實體 CSV,降級啟動文本正則撈取日誌答案
218
- if not has_csv_data:
219
- matches = re.findall(r'(?i)(?:q(?:uestion)?|"q(?:uestion)?")\s*0*([1-9][0-9]*)\b[^A-E]*?\b([A-E])\b', full_log)
220
- for q_str, ans in matches:
221
- q_num = int(q_str)
222
- if 1 <= q_num <= 36:
223
- curr_answers[q_num] = ans.upper()
224
-
225
- if list(curr_answers.values()).count("") > 25:
226
- for line in full_log.splitlines():
227
- t_matches = re.findall(r'\b0*([1-9][0-9]*)\b\s*[|:]\s*\b([A-E])\b', line)
228
- for q_str, ans in t_matches:
229
- q_num = int(q_str)
230
- if 1 <= q_num <= 36:
231
- curr_answers[q_num] = ans.upper()
232
-
233
- valid_count = sum(1 for v in curr_answers.values() if v != "—")
234
- all_scans_results.append({
235
- "profile_name": profile["name"],
236
- "answers": curr_answers,
237
- "csv": current_csv,
238
- "img": current_img,
239
- "valid_count": valid_count
240
- })
241
- accumulated_logs += f" ✅ 本輪辨識出有效題數: {valid_count}/36\n"
242
-
243
- # =======================================================
244
- # 🧠 啟動決策融合與投票機制 (Majority Voting)
245
- # =======================================================
246
- accumulated_logs += "\n--- 🧠 啟動決策融合 (Majority Voting) ---\n"
247
- final_extracted_answers = {i: "—" for i in range(1, 37)}
248
-
249
- for i in range(1, 37):
250
- votes = [scan["answers"][i] for scan in all_scans_results if scan["answers"][i] != "—"]
251
-
252
- if not votes:
253
- final_extracted_answers[i] = "—"
254
- else:
255
- vote_counts = Counter(votes)
256
- best_ans, count = vote_counts.most_common(1)[0]
257
- final_extracted_answers[i] = best_ans
258
-
259
- if len(vote_counts) > 1:
260
- accumulated_logs += f" ⚠️ 發現分歧 [q{i:02d}]: 投票分佈 {dict(vote_counts)} 👉 系統決策為: {best_ans}\n"
261
-
262
- final_valid_count = sum(1 for v in final_extracted_answers.values() if v != "—")
263
- accumulated_logs += f"\n🎯 融合後最終有效題數: {final_valid_count}/36\n"
264
-
265
- # =======================================================
266
- # 🎯 產出最終排版 (融合答案摘要與核心 Log精準對齊 3 個 API 輸出)
267
- # =======================================================
268
- summary_lines = [f"### 📊 學生選擇題最佳答案摘要 (經多重採樣與投票融合)\n```text"]
269
- current_row = []
270
- for i in range(1, 37):
271
- current_row.append(f"q{i:02d}: {final_extracted_answers[i]}")
272
- if len(current_row) == 4:
273
- summary_lines.append(" | ".join(current_row))
274
- current_row = []
275
- if current_row:
276
- summary_lines.append(" | ".join(current_row))
277
- summary_lines.append("```\n💡 *本結果已經過三種不同靈敏度交叉比對與多數決演算法驗證,準確度已達最大化。*")
278
-
279
- answers_summary = "\n".join(summary_lines)
280
-
281
- # 🚀 將摘要文字置頂,與詳細底層日誌結合成單一文字區塊
282
- final_combined_logs = f"{dimension_info}\n{answers_summary}\n\n========================================\n⚙️ 詳細後台決策與執行日誌 (Logs):\n========================================\n{accumulated_logs}"
283
-
284
- # 挑選單次表現最好、搜集題數最高的一組實體檔案回傳
285
- best_single_scan = max(all_scans_results, key=lambda x: x["valid_count"])
286
-
287
- # 🎯 嚴格遵循 3 個元素的 tuple 回傳: [0]=CSV路徑, [1]=圖片路徑, [2]=文字日誌
288
- return best_single_scan["csv"], best_single_scan["img"], final_combined_logs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
 
290
 
291
- # =======================================================
292
- # 建立符合 2 輸入、3 輸出規格的 Gradio 介面
293
- # =======================================================
294
  interface = gr.Interface(
295
  fn=process_omr,
296
  inputs=[
297
  gr.Image(type="filepath", label="1. 上傳 OMR 答案卡圖片 (JPG/PNG)"),
298
- gr.Textbox(lines=6, label="2. [選填] Template JSON 配置 (網頁手動時可不填)", placeholder="留空時系統會自動套用雲端後端的 template.json")
 
 
 
 
299
  ],
300
  outputs=[
301
- gr.File(label="下載辨識結果 (CSV)"), # 對應 API 輸出的 [0]
302
- gr.Image(label="視覺化劃記檢視圖片"), # 對應 API 輸出的 [1]
303
- gr.Textbox(label="系統執行日誌 (Logs)", lines=15) # 對應 API 輸出的 [2] (內含 4 欄答案摘要 + 投票軌跡)
304
  ],
305
  title="DSE OMR 雲端辨識系統 API 後端",
306
- description="本系統基於 OMRChecker 內核驅動。採用「多重採樣與多數決投票 (Ensemble Voting)」演算法,在背景以三種靈敏度掃描試卷,排除橡皮擦雜訊並拯救淡筆跡。完美對齊 2 輸入、3 輸出之自動化 API 規格。"
 
 
 
307
  )
308
 
309
  if __name__ == "__main__":
310
- interface.launch(server_name="0.0.0.0", server_port=7860)
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
  import os
6
+ import re
7
  import shutil
8
  import subprocess
9
+ import sys
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from collections import Counter
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
  import cv2
19
+ import gradio as gr
20
  import pandas as pd
21
 
22
  # =======================================================
23
+ # Gradio 4.44.x OpenAPI schema compatibility patch
24
  # =======================================================
25
  try:
26
  import gradio_client.utils
 
 
27
 
28
+ _orig_get_type = gradio_client.utils.get_type
29
+ _orig_json_schema_to_python_type = (
30
+ gradio_client.utils._json_schema_to_python_type
31
+ )
32
+
33
+ def _patched_get_type(schema: Any, *args: Any, **kwargs: Any) -> Any:
34
  if isinstance(schema, bool):
35
  return "Any"
36
+ return _orig_get_type(schema, *args, **kwargs)
37
 
38
+ def _patched_json_schema_to_python_type(
39
+ schema: Any, *args: Any, **kwargs: Any
40
+ ) -> Any:
41
  if isinstance(schema, bool):
42
  return "Any"
43
+ return _orig_json_schema_to_python_type(schema, *args, **kwargs)
44
 
45
+ gradio_client.utils.get_type = _patched_get_type
46
+ gradio_client.utils._json_schema_to_python_type = (
47
+ _patched_json_schema_to_python_type
48
+ )
49
+ print("【系統提示】Gradio Schema 容錯補丁已套用。")
50
+ except Exception as patch_error:
51
+ print(f"【系統提示】Gradio 補丁未套用:{patch_error}")
 
 
52
 
53
  # =======================================================
54
+ # Paths and repository settings
55
  # =======================================================
56
+ ROOT_DIR = Path(__file__).resolve().parent
57
+ OMR_DIR = ROOT_DIR / "OMRChecker"
58
+ MOCK_DIR = ROOT_DIR / "mock_libs"
59
+ GENERATED_DIR = ROOT_DIR / "generated"
60
+
61
+ OMR_REPO_URL = "https://github.com/Udayraj123/OMRChecker.git"
62
+ OMR_REPO_BRANCH = "master"
63
+
64
+ # OMRChecker is CPU-heavy and writes multiple files. Serialize requests so
65
+ # two students cannot overwrite each other's temporary outputs.
66
+ PROCESS_LOCK = threading.Lock()
67
+ REPO_LOCK = threading.Lock()
68
+
69
+ ALLOWED_ANSWERS = {"A", "B", "C", "D"}
70
+
71
+ SENSITIVITY_PROFILES = [
72
+ {
73
+ "name": "Level 1:標準掃描",
74
+ "shrink_w": 0,
75
+ "shrink_h": 0,
76
+ "levels_high": 0.90,
77
+ },
78
+ {
79
+ "name": "Level 2:增強靈敏度",
80
+ "shrink_w": 12,
81
+ "shrink_h": 8,
82
+ "levels_high": 0.75,
83
+ },
84
+ {
85
+ "name": "Level 3:淡筆跡模式",
86
+ "shrink_w": 20,
87
+ "shrink_h": 14,
88
+ "levels_high": 0.55,
89
+ },
90
+ ]
91
+
92
+
93
+ def _prepare_headless_helpers() -> None:
94
+ """Create modules injected into the OMRChecker subprocess.
95
+
96
+ OMRChecker imports screeninfo and may call OpenCV GUI functions. Hugging
97
+ Face Spaces is headless, so both behaviours need safe fallbacks.
98
+ """
99
+
100
+ MOCK_DIR.mkdir(parents=True, exist_ok=True)
101
+
102
+ (MOCK_DIR / "screeninfo.py").write_text(
103
+ """
104
  class ScreenInfoError(Exception):
105
  pass
106
+
107
  class FakeMonitor:
108
  width = 1920
109
  height = 1080
110
+
111
+
112
  def get_monitors():
113
  return [FakeMonitor()]
114
+ """.strip()
115
+ + "\n",
116
+ encoding="utf-8",
117
+ )
118
+
119
+ # Python imports sitecustomize automatically when it is available on
120
+ # PYTHONPATH. This prevents cv2.imshow/namedWindow from crashing in the
121
+ # headless Space even if a debug path is accidentally reached.
122
+ (MOCK_DIR / "sitecustomize.py").write_text(
123
+ """
124
+ try:
125
+ import cv2
126
+
127
+ cv2.imshow = lambda *args, **kwargs: None
128
+ cv2.namedWindow = lambda *args, **kwargs: None
129
+ cv2.moveWindow = lambda *args, **kwargs: None
130
+ cv2.destroyAllWindows = lambda *args, **kwargs: None
131
+ cv2.getWindowProperty = lambda *args, **kwargs: 1.0
132
+ cv2.waitKey = lambda *args, **kwargs: ord('q')
133
+ except Exception:
134
+ pass
135
+ """.strip()
136
+ + "\n",
137
+ encoding="utf-8",
138
+ )
139
 
 
 
 
 
 
140
 
141
+ _prepare_headless_helpers()
142
+ GENERATED_DIR.mkdir(parents=True, exist_ok=True)
143
 
 
 
 
144
 
145
+ def _valid_omr_checkout(path: Path) -> bool:
146
+ return (
147
+ path.is_dir()
148
+ and (path / "main.py").is_file()
149
+ and (path / "src").is_dir()
150
+ )
151
+
152
+
153
+ def ensure_omrchecker() -> None:
154
+ """Clone OMRChecker lazily and recover from incomplete checkouts.
155
+
156
+ Dependencies are intentionally *not* installed here. Hugging Face installs
157
+ requirements.txt during the build stage; runtime pip installation caused
158
+ the original Space crash.
159
+ """
160
+
161
+ if _valid_omr_checkout(OMR_DIR):
162
+ return
163
+
164
+ with REPO_LOCK:
165
+ if _valid_omr_checkout(OMR_DIR):
166
+ return
167
+
168
+ if OMR_DIR.exists():
169
+ shutil.rmtree(OMR_DIR, ignore_errors=True)
170
+
171
+ temporary_checkout = ROOT_DIR / f"OMRChecker.clone.{uuid.uuid4().hex}"
172
+
173
+ command = [
174
+ "git",
175
+ "clone",
176
+ "--depth",
177
+ "1",
178
+ "--branch",
179
+ OMR_REPO_BRANCH,
180
+ OMR_REPO_URL,
181
+ str(temporary_checkout),
182
+ ]
183
+
184
+ result = subprocess.run(
185
+ command,
186
+ cwd=ROOT_DIR,
187
+ capture_output=True,
188
+ text=True,
189
+ timeout=180,
190
+ )
191
+
192
+ if result.returncode != 0:
193
+ shutil.rmtree(temporary_checkout, ignore_errors=True)
194
+ raise RuntimeError(
195
+ "未能下載 OMRChecker 核心。\n"
196
+ + (result.stderr or result.stdout or "Git clone failed.")[-2000:]
197
+ )
198
+
199
+ if not _valid_omr_checkout(temporary_checkout):
200
+ shutil.rmtree(temporary_checkout, ignore_errors=True)
201
+ raise RuntimeError(
202
+ "OMRChecker 下載完成,但缺少 main.py 或 src 目錄。"
203
+ )
204
+
205
+ temporary_checkout.replace(OMR_DIR)
206
+ print("【系統提示】OMRChecker 核心已準備完成。")
207
+
208
+
209
+ def _find_local_file(prefix: str, extensions: set[str]) -> Path | None:
210
+ for path in sorted(ROOT_DIR.iterdir()):
211
+ if (
212
+ path.is_file()
213
+ and path.name.lower().startswith(prefix.lower())
214
+ and path.suffix.lower() in extensions
215
+ ):
216
+ return path
217
+ return None
218
+
219
+
220
+ def _load_template_content(template_content: str | None) -> tuple[dict[str, Any], Path]:
221
+ if template_content and str(template_content).strip():
222
+ try:
223
+ return json.loads(str(template_content)), ROOT_DIR
224
+ except json.JSONDecodeError as error:
225
+ raise ValueError(f"Template JSON 格式不正確:{error}") from error
226
+
227
+ template_path = _find_local_file("template", {".json"})
228
+ if template_path is None:
229
+ raise FileNotFoundError(
230
+ "找不到 template.json。請把 Template JSON 放在 Space 根目錄。"
231
+ )
232
+
233
  try:
234
+ return json.loads(template_path.read_text(encoding="utf-8")), template_path.parent
235
+ except json.JSONDecodeError as error:
236
+ raise ValueError(f"{template_path.name} JSON 格式不正確:{error}") from error
237
+
238
+
239
+ def _expand_question_label(label: Any) -> list[int]:
240
+ text = str(label).strip()
241
+
242
+ range_match = re.fullmatch(r"q(\d+)\.\.(\d+)", text, flags=re.IGNORECASE)
243
+ if range_match:
244
+ start, end = map(int, range_match.groups())
245
+ step = 1 if end >= start else -1
246
+ return list(range(start, end + step, step))
247
+
248
+ single_match = re.fullmatch(r"q0*(\d+)", text, flags=re.IGNORECASE)
249
+ if single_match:
250
+ return [int(single_match.group(1))]
251
+
252
+ return []
253
+
254
+
255
+ def _question_numbers_from_template(template: dict[str, Any]) -> list[int]:
256
+ numbers: set[int] = set()
257
+
258
+ for block in (template.get("fieldBlocks") or {}).values():
259
+ if not isinstance(block, dict):
260
+ continue
261
+ for label in block.get("fieldLabels") or []:
262
+ numbers.update(_expand_question_label(label))
263
+
264
+ if not numbers:
265
+ raise ValueError(
266
+ "Template JSON 沒有可辨識的 q1、q2 或 q1..100 題號。"
267
+ )
268
+
269
+ return sorted(numbers)
270
+
271
+
272
+ def _normalize_answer(value: Any) -> str:
273
+ if value is None or (isinstance(value, float) and pd.isna(value)):
274
+ return "—"
275
+
276
+ answer = str(value).strip().upper()
277
+ return answer if answer in ALLOWED_ANSWERS else "—"
278
+
279
+
280
+ def _apply_profile(template: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]:
281
+ current = copy.deepcopy(template)
282
+ base_width, base_height = current.get("bubbleDimensions", [60, 30])
283
+
284
+ current["bubbleDimensions"] = [
285
+ max(10, int(base_width) - int(profile["shrink_w"])),
286
+ max(10, int(base_height) - int(profile["shrink_h"])),
287
+ ]
288
+
289
+ for processor in current.get("preProcessors") or []:
290
+ if not isinstance(processor, dict):
291
+ continue
292
+ if processor.get("name") == "Levels":
293
+ processor.setdefault("options", {})["high"] = profile["levels_high"]
294
+
295
+ return current
296
+
297
+
298
+ def _alignment_reference_names(template: dict[str, Any]) -> set[str]:
299
+ references: set[str] = set()
300
+
301
+ for processor in template.get("preProcessors") or []:
302
+ if not isinstance(processor, dict):
303
+ continue
304
+ options = processor.get("options") or {}
305
+ for key in ("reference", "relativePath"):
306
+ value = options.get(key)
307
+ if isinstance(value, str) and value.strip():
308
+ references.add(Path(value).name)
309
+
310
+ return references
311
+
312
+
313
+ def _copy_alignment_assets(template: dict[str, Any], destination: Path) -> None:
314
+ reference_names = _alignment_reference_names(template)
315
+
316
+ # The provided DSE template uses template.jpg. Also support any explicit
317
+ # reference filename that exists in the Space root.
318
+ for reference_name in reference_names:
319
+ direct = ROOT_DIR / reference_name
320
+ source = direct if direct.is_file() else None
321
+
322
+ if source is None and reference_name.lower().startswith("template"):
323
+ source = _find_local_file("template", {".jpg", ".jpeg", ".png"})
324
+
325
+ if source is None:
326
+ raise FileNotFoundError(
327
+ f"Template 需要對齊參考圖 {reference_name},但 Space 根目錄找不到它。"
328
+ )
329
+
330
+ shutil.copy2(source, destination / reference_name)
331
+
332
+
333
+ def _write_headless_config(destination: Path) -> None:
334
+ config = {
335
+ "outputs": {
336
+ "show_image_level": 0,
337
+ "save_image_level": 3,
338
+ "colored_outputs_enabled": True,
339
+ "save_detections": True,
340
+ }
341
+ }
342
+ (destination / "config.json").write_text(
343
+ json.dumps(config, ensure_ascii=False, indent=2),
344
+ encoding="utf-8",
345
+ )
346
+
347
+
348
+ def _find_output_file(output_dir: Path, suffixes: set[str], prefer: str = "") -> Path | None:
349
+ candidates = [
350
+ path
351
+ for path in output_dir.rglob("*")
352
+ if path.is_file() and path.suffix.lower() in suffixes
353
+ ]
354
+
355
+ if not candidates:
356
+ return None
357
+
358
+ if prefer:
359
+ preferred = [path for path in candidates if prefer.lower() in str(path).lower()]
360
+ if preferred:
361
+ candidates = preferred
362
+
363
+ return max(candidates, key=lambda path: (path.stat().st_mtime, path.stat().st_size))
364
+
365
+
366
+ def _parse_csv_answers(csv_path: Path | None, question_numbers: list[int]) -> dict[int, str]:
367
+ answers = {number: "—" for number in question_numbers}
368
+
369
+ if csv_path is None or not csv_path.is_file():
370
+ return answers
371
 
372
  try:
373
+ dataframe = pd.read_csv(csv_path)
374
+ except Exception:
375
+ return answers
376
+
377
+ if dataframe.empty:
378
+ return answers
379
+
380
+ normalized_columns = {
381
+ str(column).strip().lower(): column for column in dataframe.columns
382
+ }
383
+
384
+ for number in question_numbers:
385
+ column = normalized_columns.get(f"q{number}")
386
+ if column is not None:
387
+ answers[number] = _normalize_answer(dataframe.iloc[0][column])
388
+
389
+ return answers
390
+
391
+
392
+ def _parse_log_answers(text: str, question_numbers: list[int]) -> dict[int, str]:
393
+ allowed_numbers = set(question_numbers)
394
+ answers = {number: "—" for number in question_numbers}
395
+
396
+ patterns = [
397
+ r"(?i)\bq(?:uestion)?\s*0*(\d+)\s*[:=]\s*([A-D])\b",
398
+ r"(?i)\b0*(\d+)\s*[|:]\s*([A-D])\b",
399
  ]
400
 
401
+ for pattern in patterns:
402
+ for question_text, answer in re.findall(pattern, text or ""):
403
+ number = int(question_text)
404
+ if number in allowed_numbers:
405
+ answers[number] = answer.upper()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
+ return answers
408
+
409
+
410
+ def _merge_answer_sources(primary: dict[int, str], fallback: dict[int, str]) -> dict[int, str]:
411
+ merged = dict(primary)
412
+ for number, answer in fallback.items():
413
+ if merged.get(number, "") == "—" and answer != "—":
414
+ merged[number] = answer
415
+ return merged
416
+
417
+
418
+ def _cleanup_old_generated_files(max_age_seconds: int = 7200) -> None:
419
+ now = time.time()
420
+ for path in GENERATED_DIR.iterdir():
421
+ try:
422
+ if now - path.stat().st_mtime > max_age_seconds:
423
+ if path.is_dir():
424
+ shutil.rmtree(path, ignore_errors=True)
425
+ else:
426
+ path.unlink(missing_ok=True)
427
+ except OSError:
428
+ pass
429
+
430
+
431
+ def _format_answer_summary(final_answers: dict[int, str]) -> str:
432
+ lines = [
433
+ "### 📊 多重採樣投票結果",
434
+ "```text",
435
+ ]
436
+
437
+ row: list[str] = []
438
+ for number in sorted(final_answers):
439
+ row.append(f"q{number:02d}: {final_answers[number]}")
440
+ if len(row) == 4:
441
+ lines.append(" | ".join(row))
442
+ row = []
443
+
444
+ if row:
445
+ lines.append(" | ".join(row))
446
+
447
+ lines.extend(
448
+ [
449
+ "```",
450
+ "💡 請在前端逐題人工核對;投票結果不是免校對的正式答案。",
451
+ ]
452
+ )
453
+ return "\n".join(lines)
454
+
455
+
456
+ def process_omr(image_file: str | None, template_content: str | None = None):
457
+ if image_file is None:
458
+ return None, None, "錯誤:請先上傳答案卡圖片。"
459
+
460
+ with PROCESS_LOCK:
461
+ request_root: Path | None = None
462
+
463
+ try:
464
+ ensure_omrchecker()
465
+ _cleanup_old_generated_files()
466
+
467
+ image = cv2.imread(str(image_file))
468
+ if image is None:
469
+ raise ValueError("OpenCV 無法讀取上傳圖片。")
470
+
471
+ image_height, image_width = image.shape[:2]
472
+ dimension_info = (
473
+ f"【圖片載入成功】解析度:{image_width} × {image_height} px"
474
+ )
475
+
476
+ base_template, _ = _load_template_content(template_content)
477
+ question_numbers = _question_numbers_from_template(base_template)
478
+ question_count = len(question_numbers)
479
+
480
+ request_id = uuid.uuid4().hex
481
+ request_root = Path(
482
+ tempfile.mkdtemp(prefix=f"hf_omr_{request_id}_", dir=str(ROOT_DIR))
483
+ )
484
+ result_dir = GENERATED_DIR / request_id
485
+ result_dir.mkdir(parents=True, exist_ok=True)
486
+
487
+ scans: list[dict[str, Any]] = []
488
+ logs = [
489
+ dimension_info,
490
+ f"【Template】題號範圍:q{question_numbers[0]}–q{question_numbers[-1]} {question_count} 題",
491
+ "--- 開始三輪多重採樣 ---",
492
+ ]
493
+
494
+ for index, profile in enumerate(SENSITIVITY_PROFILES, start=1):
495
+ profile_dir = request_root / f"profile_{index}"
496
+ images_dir = profile_dir / "images"
497
+ output_dir = request_root / f"output_{index}"
498
+
499
+ images_dir.mkdir(parents=True, exist_ok=True)
500
+ output_dir.mkdir(parents=True, exist_ok=True)
501
+
502
+ current_template = _apply_profile(base_template, profile)
503
+ (profile_dir / "template.json").write_text(
504
+ json.dumps(current_template, ensure_ascii=False, indent=2),
505
+ encoding="utf-8",
506
+ )
507
+ _write_headless_config(profile_dir)
508
+ _copy_alignment_assets(current_template, profile_dir)
509
+
510
+ shutil.copy2(image_file, images_dir / "sheet.jpg")
511
+
512
+ environment = os.environ.copy()
513
+ environment["PYTHONPATH"] = (
514
+ str(MOCK_DIR)
515
+ + os.pathsep
516
+ + environment.get("PYTHONPATH", "")
517
+ )
518
+ environment["OMR_CHECKER_CONTAINER"] = "true"
519
+ environment["MPLBACKEND"] = "Agg"
520
+
521
+ command = [
522
+ sys.executable,
523
+ "main.py",
524
+ "-i",
525
+ str(profile_dir),
526
+ "-o",
527
+ str(output_dir),
528
+ ]
529
+
530
+ result = subprocess.run(
531
+ command,
532
+ cwd=OMR_DIR,
533
+ capture_output=True,
534
+ text=True,
535
+ env=environment,
536
+ timeout=180,
537
+ )
538
+
539
+ full_log = (result.stdout or "") + "\n" + (result.stderr or "")
540
+ csv_path = _find_output_file(output_dir, {".csv"})
541
+ image_path = _find_output_file(
542
+ output_dir,
543
+ {".jpg", ".jpeg", ".png"},
544
+ prefer="CheckedOMRs",
545
+ )
546
+
547
+ csv_answers = _parse_csv_answers(csv_path, question_numbers)
548
+ log_answers = _parse_log_answers(full_log, question_numbers)
549
+ answers = _merge_answer_sources(csv_answers, log_answers)
550
+ valid_count = sum(answer != "—" for answer in answers.values())
551
+
552
+ logs.append(
553
+ f"{profile['name']}:returncode={result.returncode},辨識 {valid_count}/{question_count} 題"
554
+ )
555
+
556
+ if result.returncode != 0:
557
+ logs.append("本輪錯誤摘要:" + full_log[-1500:].replace("\n", " | "))
558
+
559
+ scans.append(
560
+ {
561
+ "profile": profile["name"],
562
+ "answers": answers,
563
+ "valid_count": valid_count,
564
+ "csv": csv_path,
565
+ "image": image_path,
566
+ }
567
+ )
568
+
569
+ if not scans:
570
+ raise RuntimeError("沒有完成任何 OMR 掃描。")
571
+
572
+ final_answers: dict[int, str] = {}
573
+ for number in question_numbers:
574
+ votes = [
575
+ scan["answers"][number]
576
+ for scan in scans
577
+ if scan["answers"][number] != "—"
578
+ ]
579
+
580
+ if not votes:
581
+ final_answers[number] = "—"
582
+ continue
583
+
584
+ vote_counts = Counter(votes)
585
+ final_answer = vote_counts.most_common(1)[0][0]
586
+ final_answers[number] = final_answer
587
+
588
+ if len(vote_counts) > 1:
589
+ logs.append(
590
+ f"q{number} 分歧:{dict(vote_counts)} → {final_answer}"
591
+ )
592
+
593
+ final_valid_count = sum(answer != "—" for answer in final_answers.values())
594
+ logs.append(
595
+ f"融合後辨識:{final_valid_count}/{question_count} 題;空白 {question_count - final_valid_count} 題。"
596
+ )
597
+
598
+ # Return a CSV that matches the fused answers, rather than a raw
599
+ # CSV from only one sensitivity profile.
600
+ fused_csv = result_dir / "omr_fused_answers.csv"
601
+ pd.DataFrame(
602
+ [{f"q{number}": final_answers[number] for number in question_numbers}]
603
+ ).to_csv(fused_csv, index=False, encoding="utf-8-sig")
604
+
605
+ best_scan = max(scans, key=lambda item: item["valid_count"])
606
+ checked_image: Path | None = None
607
+ if best_scan["image"] and Path(best_scan["image"]).is_file():
608
+ source_image = Path(best_scan["image"])
609
+ checked_image = result_dir / (
610
+ "checked_omr" + source_image.suffix.lower()
611
+ )
612
+ shutil.copy2(source_image, checked_image)
613
+
614
+ combined_log = (
615
+ _format_answer_summary(final_answers)
616
+ + "\n\n========================================\n"
617
+ + "⚙️ 執行摘要\n"
618
+ + "========================================\n"
619
+ + "\n".join(logs)
620
+ )
621
+
622
+ return (
623
+ str(fused_csv),
624
+ str(checked_image) if checked_image else None,
625
+ combined_log,
626
+ )
627
+
628
+ except subprocess.TimeoutExpired:
629
+ return None, None, "錯誤:OMRChecker 單輪處理超過 180 秒,已中止。"
630
+ except Exception as error:
631
+ return None, None, f"錯誤:{type(error).__name__}: {error}"
632
+ finally:
633
+ if request_root is not None:
634
+ shutil.rmtree(request_root, ignore_errors=True)
635
 
636
 
 
 
 
637
  interface = gr.Interface(
638
  fn=process_omr,
639
  inputs=[
640
  gr.Image(type="filepath", label="1. 上傳 OMR 答案卡圖片 (JPG/PNG)"),
641
+ gr.Textbox(
642
+ lines=6,
643
+ label="2. [選填] Template JSON 配置",
644
+ placeholder="留空時自動使用 Space 根目錄的 template.json",
645
+ ),
646
  ],
647
  outputs=[
648
+ gr.File(label="下載融合辨識結果 (CSV)"),
649
+ gr.Image(label="視覺化劃記檢視圖片"),
650
+ gr.Textbox(label="辨識答案與執行摘要", lines=18),
651
  ],
652
  title="DSE OMR 雲端辨識系統 API 後端",
653
+ description=(
654
+ "以 OMRChecker 執行三輪靈敏度掃描及多數決。"
655
+ "正式提交前仍須由學生在前端人工核對。"
656
+ ),
657
  )
658
 
659
  if __name__ == "__main__":
660
+ interface.launch(server_name="0.0.0.0", server_port=7860)