Spaces:
Running
Running
這裡為您將動態 OpenAPI 容錯補丁、多重採樣與多數決決策融合(Ensemble Voting) 的核心閱卷邏輯,以及支援 API 呼叫的 Gradio 規格(允許傳入 Template JSON) 完美融合。 此版本的設計重點: 介面與功能保持一致:保留了您最核心的 3 輪不同靈敏度(Level 1~3)交叉掃描、分歧偵測、多數決投票與 4 欄式 Markdown 答案摘要輸出。 完美相容 API 呼叫:將 template_content 作為第二個輸入參數,讓 Google Apps Script (GAS) 呼叫時能動態帶入 DSE 考卷的 JSON 結構;若手動在網頁端操作時忘記填寫,程式會自動降級(Fallback)自動撈取當前目錄下的 template*.json 檔案,確保網頁端不用手動貼上 JSON 也能一鍵閱卷! 融合 OpenAPI 容錯補丁:保留了頂部的 Gradio 布林值解析崩潰補丁,確保 API 管道暢通無阻。
Browse files
app.py
CHANGED
|
@@ -3,6 +3,12 @@ import sys
|
|
| 3 |
import shutil
|
| 4 |
import subprocess
|
| 5 |
import json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
# =======================================================
|
| 8 |
# 🚀 核心修復:Gradio 官方 OpenAPI Schema 解析崩潰布林值補丁
|
|
@@ -29,9 +35,6 @@ except Exception as patch_e:
|
|
| 29 |
print(f"【系統提示】嘗試套用 Gradio 補丁時發生異常(若未崩潰可忽略): {patch_e}")
|
| 30 |
# =======================================================
|
| 31 |
|
| 32 |
-
import gradio as gr
|
| 33 |
-
import cv2 # 用於在日誌中回傳圖片解析度
|
| 34 |
-
|
| 35 |
OMR_DIR = "OMRChecker"
|
| 36 |
MOCK_DIR = os.path.abspath("mock_libs")
|
| 37 |
|
|
@@ -43,148 +46,257 @@ with open(os.path.join(MOCK_DIR, "screeninfo.py"), "w", encoding="utf-8") as f:
|
|
| 43 |
f.write('''\
|
| 44 |
class ScreenInfoError(Exception):
|
| 45 |
pass
|
| 46 |
-
|
| 47 |
class FakeMonitor:
|
| 48 |
width = 1920
|
| 49 |
height = 1080
|
| 50 |
-
|
| 51 |
def get_monitors():
|
| 52 |
return [FakeMonitor()]
|
| 53 |
''')
|
| 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 |
-
# 安裝該專案可能缺失的其他微量依賴
|
| 61 |
subprocess.run([sys.executable, "-m", "pip", "install", "-r", f"{OMR_DIR}/requirements.txt"], check=True)
|
| 62 |
|
| 63 |
-
def process_omr(image_file, template_content):
|
| 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"【
|
| 73 |
else:
|
| 74 |
-
dimension_info = "【
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
except Exception as e:
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
-
# 定義 OMRChecker 的標準工作目錄結構
|
| 79 |
job_dir = os.path.join(OMR_DIR, "inputs", "hf_space_job")
|
| 80 |
images_dir = os.path.join(job_dir, "images")
|
| 81 |
outputs_dir = os.path.join(OMR_DIR, "outputs")
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
shutil.rmtree(job_dir)
|
| 86 |
-
os.makedirs(images_dir, exist_ok=True)
|
| 87 |
-
|
| 88 |
-
if os.path.exists(outputs_dir):
|
| 89 |
-
shutil.rmtree(outputs_dir)
|
| 90 |
-
|
| 91 |
-
# 將上傳的圖片複製到 OMR 要求的 inputs/<template_name>/images/ 目錄中
|
| 92 |
-
target_image_path = os.path.join(images_dir, "sheet.jpg")
|
| 93 |
-
shutil.copy(image_file, target_image_path)
|
| 94 |
|
| 95 |
-
# 🚀 確保 template.jpg
|
| 96 |
-
|
| 97 |
-
template_found = False
|
| 98 |
for file in os.listdir(current_dir):
|
| 99 |
if file.lower().startswith("template") and file.lower().endswith((".jpg", ".jpeg", ".png")):
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
print(f"成功複製基準圖: {file}")
|
| 103 |
-
template_found = True
|
| 104 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
#
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
)
|
| 141 |
-
|
| 142 |
-
# 3. 搜尋並收集 OMRChecker 產生的輸出結果 (CSV 與 標記圖片)
|
| 143 |
-
csv_file = None
|
| 144 |
-
output_image = None
|
| 145 |
-
all_detected_files = []
|
| 146 |
|
| 147 |
-
|
| 148 |
-
for root, dirs, files in os.walk(outputs_dir):
|
| 149 |
-
for file in files:
|
| 150 |
-
full_path = os.path.join(root, file)
|
| 151 |
-
all_detected_files.append(full_path)
|
| 152 |
-
|
| 153 |
-
if file.endswith(".csv"):
|
| 154 |
-
if "results" in root.lower() and csv_file is None:
|
| 155 |
-
csv_file = full_path
|
| 156 |
-
elif csv_file is None:
|
| 157 |
-
csv_file = full_path
|
| 158 |
-
|
| 159 |
-
elif file.endswith((".jpg", ".jpeg", ".png")):
|
| 160 |
-
if "checkedomrs" in root.lower():
|
| 161 |
-
output_image = full_path
|
| 162 |
-
elif output_image is None:
|
| 163 |
-
output_image = full_path
|
| 164 |
-
|
| 165 |
-
if all_detected_files:
|
| 166 |
-
file_discovery_log = "【檔案搜尋除錯資訊】系統成功在輸出目錄找到以下檔案:\n" + "\n".join([f" - {f}" for f in all_detected_files])
|
| 167 |
-
else:
|
| 168 |
-
file_discovery_log = "【檔案搜尋除錯資訊】警告:未在輸出目錄中搜集到任何實體檔案!"
|
| 169 |
-
|
| 170 |
-
logs = f"{dimension_info}{file_discovery_log}\n\n--- STDOUT ---\n{result.stdout}\n\n--- STDERR ---\n{result.stderr}"
|
| 171 |
|
| 172 |
-
return
|
| 173 |
|
| 174 |
-
#
|
| 175 |
interface = gr.Interface(
|
| 176 |
fn=process_omr,
|
| 177 |
inputs=[
|
| 178 |
gr.Image(type="filepath", label="1. 上傳 OMR 答案卡圖片 (JPG/PNG)"),
|
| 179 |
-
gr.Textbox(lines=
|
| 180 |
],
|
| 181 |
outputs=[
|
| 182 |
gr.File(label="下載辨識結果 (CSV)"),
|
| 183 |
-
gr.Image(label="視覺化劃記
|
| 184 |
-
gr.
|
|
|
|
| 185 |
],
|
| 186 |
-
title="
|
| 187 |
-
description="本系統
|
| 188 |
)
|
| 189 |
|
| 190 |
if __name__ == "__main__":
|
|
|
|
| 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 解析崩潰布林值補丁
|
|
|
|
| 35 |
print(f"【系統提示】嘗試套用 Gradio 補丁時發生異常(若未崩潰可忽略): {patch_e}")
|
| 36 |
# =======================================================
|
| 37 |
|
|
|
|
|
|
|
|
|
|
| 38 |
OMR_DIR = "OMRChecker"
|
| 39 |
MOCK_DIR = os.path.abspath("mock_libs")
|
| 40 |
|
|
|
|
| 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 |
+
def process_omr(image_file, template_content=None):
|
| 63 |
+
if image_file is None:
|
| 64 |
+
return None, None, "錯誤:請上傳答案卡。", "錯誤:請務必上傳答案卡圖片。"
|
| 65 |
|
| 66 |
# 🚀 偵測上傳圖片的實際解析度
|
| 67 |
try:
|
| 68 |
img = cv2.imread(image_file)
|
| 69 |
if img is not None:
|
| 70 |
img_h, img_w, _ = img.shape
|
| 71 |
+
dimension_info = f"【圖片載入成功】解析度: {img_w} x {img_h} px\n"
|
| 72 |
else:
|
| 73 |
+
dimension_info = "【圖片載入失敗】無法讀取圖片。\n"
|
| 74 |
+
except Exception as e:
|
| 75 |
+
dimension_info = f"【圖片讀取錯誤】{str(e)}\n"
|
| 76 |
+
|
| 77 |
+
current_dir = os.path.dirname(__file__)
|
| 78 |
+
|
| 79 |
+
# 🚀 智能降級機制:如果 API 或網頁端未傳入 template_content,主動撈取本地目錄的 template*.json
|
| 80 |
+
if not template_content or str(template_content).strip() == "":
|
| 81 |
+
for file in os.listdir(current_dir):
|
| 82 |
+
if file.lower().startswith("template") and file.lower().endswith(".json"):
|
| 83 |
+
json_src = os.path.join(current_dir, file)
|
| 84 |
+
try:
|
| 85 |
+
with open(json_src, "r", encoding="utf-8") as f:
|
| 86 |
+
template_content = f.read()
|
| 87 |
+
print(f"【系統提示】未偵測到傳入的 JSON 配置,已自動載入本地配置: {file}")
|
| 88 |
+
break
|
| 89 |
+
except Exception:
|
| 90 |
+
pass
|
| 91 |
+
|
| 92 |
+
if not template_content:
|
| 93 |
+
return None, None, "錯誤:找不到有效的 Template JSON 配置。", "未找到配置檔案。 請提供 JSON 內容或在後端存放 template.json"
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
base_template_dict = json.loads(template_content)
|
| 97 |
except Exception as e:
|
| 98 |
+
return None, None, "錯誤:Template JSON 格式不正確。", str(e)
|
| 99 |
+
|
| 100 |
+
base_bubble_w, base_bubble_h = base_template_dict.get("bubbleDimensions", [60, 30])
|
| 101 |
+
|
| 102 |
+
# =======================================================
|
| 103 |
+
# 🚀 定義多重採樣策略 (Multi-Sampling)
|
| 104 |
+
# =======================================================
|
| 105 |
+
sensitivity_profiles = [
|
| 106 |
+
{"name": "Level 1: 標準掃描 (嚴謹判斷防雜訊)", "shrink_w": 0, "shrink_h": 0, "levels_high": 0.9},
|
| 107 |
+
{"name": "Level 2: 增強靈敏度 (適應單橫線筆跡)", "shrink_w": 12, "shrink_h": 8, "levels_high": 0.75},
|
| 108 |
+
{"name": "Level 3: 極限靈敏度 (適應極淡筆跡)", "shrink_w": 20, "shrink_h": 14, "levels_high": 0.55}
|
| 109 |
+
]
|
| 110 |
|
|
|
|
| 111 |
job_dir = os.path.join(OMR_DIR, "inputs", "hf_space_job")
|
| 112 |
images_dir = os.path.join(job_dir, "images")
|
| 113 |
outputs_dir = os.path.join(OMR_DIR, "outputs")
|
| 114 |
|
| 115 |
+
all_scans_results = []
|
| 116 |
+
accumulated_logs = f"{dimension_info}\n--- 🚀 開始執行多重採樣與決策融合 (Ensemble Voting) ---\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
+
# 🚀 確保 template.jpg 基准圖存在於工作目錄中
|
| 119 |
+
template_img_found = False
|
|
|
|
| 120 |
for file in os.listdir(current_dir):
|
| 121 |
if file.lower().startswith("template") and file.lower().endswith((".jpg", ".jpeg", ".png")):
|
| 122 |
+
template_img_src = os.path.join(current_dir, file)
|
| 123 |
+
template_img_found = True
|
|
|
|
|
|
|
| 124 |
break
|
| 125 |
+
|
| 126 |
+
# 執行三輪完整掃描以收集樣本進行投票
|
| 127 |
+
for profile in sensitivity_profiles:
|
| 128 |
+
accumulated_logs += f"\n👉 執行採樣: [{profile['name']}]...\n"
|
| 129 |
+
|
| 130 |
+
if os.path.exists(job_dir):
|
| 131 |
+
shutil.rmtree(job_dir)
|
| 132 |
+
os.makedirs(images_dir, exist_ok=True)
|
| 133 |
+
if os.path.exists(outputs_dir):
|
| 134 |
+
shutil.rmtree(outputs_dir)
|
| 135 |
+
|
| 136 |
+
# 複製當前處理的學生考卷
|
| 137 |
+
shutil.copy(image_file, os.path.join(images_dir, "sheet.jpg"))
|
| 138 |
+
|
| 139 |
+
# 複製對齊用的基準圖
|
| 140 |
+
if template_img_found:
|
| 141 |
+
shutil.copy(template_img_src, os.path.join(job_dir, "template.jpg"))
|
| 142 |
+
|
| 143 |
+
# 動態調整氣泡大小與 Levels 閥值
|
| 144 |
+
current_template = copy.deepcopy(base_template_dict)
|
| 145 |
+
new_w = max(10, base_bubble_w - profile['shrink_w'])
|
| 146 |
+
new_h = max(10, base_bubble_h - profile['shrink_h'])
|
| 147 |
+
current_template["bubbleDimensions"] = [new_w, new_h]
|
| 148 |
+
|
| 149 |
+
if "preProcessors" in current_template:
|
| 150 |
+
for pp in current_template["preProcessors"]:
|
| 151 |
+
if pp.get("name") == "Levels" and "options" in pp:
|
| 152 |
+
pp["options"]["high"] = profile["levels_high"]
|
| 153 |
+
|
| 154 |
+
# 寫入本次靈敏度的臨時 template.json
|
| 155 |
+
with open(os.path.join(job_dir, "template.json"), "w", encoding="utf-8") as f:
|
| 156 |
+
json.dump(current_template, f)
|
| 157 |
+
|
| 158 |
+
# 保險起見,也在 inputs 根目錄放一份
|
| 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 |
+
# 嘗試解析本輪答案
|
| 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 |
+
if not has_csv_data:
|
| 218 |
+
matches = re.findall(r'(?i)(?:q(?:uestion)?|"q(?:uestion)?")\s*0*([1-9][0-9]*)\b[^A-E]*?\b([A-E])\b', full_log)
|
| 219 |
+
for q_str, ans in matches:
|
| 220 |
+
q_num = int(q_str)
|
| 221 |
+
if 1 <= q_num <= 36:
|
| 222 |
+
curr_answers[q_num] = ans.upper()
|
| 223 |
|
| 224 |
+
if list(curr_answers.values()).count("—") > 25:
|
| 225 |
+
for line in full_log.splitlines():
|
| 226 |
+
t_matches = re.findall(r'\b0*([1-9][0-9]*)\b\s*[|:]\s*\b([A-E])\b', line)
|
| 227 |
+
for q_str, ans in t_matches:
|
| 228 |
+
q_num = int(q_str)
|
| 229 |
+
if 1 <= q_num <= 36:
|
| 230 |
+
curr_answers[q_num] = ans.upper()
|
| 231 |
|
| 232 |
+
valid_count = sum(1 for v in curr_answers.values() if v != "—")
|
| 233 |
+
all_scans_results.append({
|
| 234 |
+
"profile_name": profile["name"],
|
| 235 |
+
"answers": curr_answers,
|
| 236 |
+
"csv": current_csv,
|
| 237 |
+
"img": current_img,
|
| 238 |
+
"valid_count": valid_count
|
| 239 |
+
})
|
| 240 |
+
accumulated_logs += f" ✅ 本輪辨識出有效題數: {valid_count}/36\n"
|
| 241 |
+
|
| 242 |
+
# =======================================================
|
| 243 |
+
# 🚀 啟動決策融合與投票機制 (Decision Fusion)
|
| 244 |
+
# =======================================================
|
| 245 |
+
accumulated_logs += "\n--- 🧠 啟動決策融合 (Majority Voting) ---\n"
|
| 246 |
+
final_extracted_answers = {i: "—" for i in range(1, 37)}
|
| 247 |
+
|
| 248 |
+
for i in range(1, 37):
|
| 249 |
+
votes = [scan["answers"][i] for scan in all_scans_results if scan["answers"][i] != "—"]
|
| 250 |
|
| 251 |
+
if not votes:
|
| 252 |
+
final_extracted_answers[i] = "—"
|
| 253 |
+
else:
|
| 254 |
+
vote_counts = Counter(votes)
|
| 255 |
+
best_ans, count = vote_counts.most_common(1)[0]
|
| 256 |
+
final_extracted_answers[i] = best_ans
|
| 257 |
+
|
| 258 |
+
if len(vote_counts) > 1:
|
| 259 |
+
accumulated_logs += f" ⚠️ 發現分歧 [q{i:02d}]: 投票分佈 {dict(vote_counts)} 👉 系統決策為: {best_ans}\n"
|
| 260 |
+
|
| 261 |
+
final_valid_count = sum(1 for v in final_extracted_answers.values() if v != "—")
|
| 262 |
+
accumulated_logs += f"\n🎯 融合後最終有效題數: {final_valid_count}/36\n"
|
| 263 |
+
|
| 264 |
+
# 為了提供最佳的畫面反饋與 CSV 下載,挑選單次表現最好的一組輸出檔案
|
| 265 |
+
best_single_scan = max(all_scans_results, key=lambda x: x["valid_count"])
|
| 266 |
+
|
| 267 |
+
# =======================================================
|
| 268 |
+
# 產出最終排版
|
| 269 |
+
# =======================================================
|
| 270 |
+
summary_lines = [f"### 📊 學生選擇題最佳答案摘要 (經多重採樣與投票融合)\n```text"]
|
| 271 |
+
current_row = []
|
| 272 |
+
for i in range(1, 37):
|
| 273 |
+
current_row.append(f"q{i:02d}: {final_extracted_answers[i]}")
|
| 274 |
+
if len(current_row) == 4:
|
| 275 |
+
summary_lines.append(" | ".join(current_row))
|
| 276 |
+
current_row = []
|
| 277 |
+
if current_row:
|
| 278 |
+
summary_lines.append(" | ".join(current_row))
|
| 279 |
+
summary_lines.append("```\n💡 *本結果已經過三種不同靈敏度交叉比對與多數決演算法驗證,準確度已達最大化。*")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
+
answers_summary = "\n".join(summary_lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
+
return best_single_scan["csv"], best_single_scan["img"], answers_summary, accumulated_logs
|
| 284 |
|
| 285 |
+
# 建立具有 2 個輸入與 4 個輸出的 Gradio 介面,完美兼顧網頁手動與 API 自動化呼叫
|
| 286 |
interface = gr.Interface(
|
| 287 |
fn=process_omr,
|
| 288 |
inputs=[
|
| 289 |
gr.Image(type="filepath", label="1. 上傳 OMR 答案卡圖片 (JPG/PNG)"),
|
| 290 |
+
gr.Textbox(lines=5, label="2. [選填] Template JSON 配置 (API 呼認自動傳入,網頁手動時可不填)", placeholder="留空時系統會自動套用雲端後端的 template.json")
|
| 291 |
],
|
| 292 |
outputs=[
|
| 293 |
gr.File(label="下載辨識結果 (CSV)"),
|
| 294 |
+
gr.Image(label="最佳視覺化劃記圖 (最高題數)"),
|
| 295 |
+
gr.Code(label="2. 學生答案摘要 (動態投票融合結果)", language="markdown"),
|
| 296 |
+
gr.Textbox(label="3. 決策融合與除錯日誌 (Logs)", lines=12)
|
| 297 |
],
|
| 298 |
+
title="OMRChecker 雲端辨識系統 API (多重決策融合版)",
|
| 299 |
+
description="本系統採用「多重採樣與多數決投票 (Ensemble Voting)」。系統會在背景以三種不同靈敏度掃描試卷,並對每一題進行獨立投票,有效排除橡皮擦痕跡雜訊並拯救極淡筆跡,提供最高容錯率的辨識結果。支援 GAS 經由 API 傳入客製化 JSON 結構。"
|
| 300 |
)
|
| 301 |
|
| 302 |
if __name__ == "__main__":
|