File size: 6,869 Bytes
a9cfcc5
 
 
d0a1de7
a9cfcc5
e11aa98
d0a1de7
982b503
d0a1de7
982b503
 
 
 
 
a9cfcc5
01d2cba
a9cfcc5
 
 
 
 
d0a1de7
a9cfcc5
 
 
d0a1de7
a9cfcc5
 
01d2cba
a9cfcc5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0a1de7
 
 
 
174dfc7
d0a1de7
 
a9cfcc5
d0a1de7
a9cfcc5
b3d2821
d0a1de7
a9cfcc5
 
d0a1de7
a9cfcc5
 
d0a1de7
 
 
 
 
7673dc1
d0a1de7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b3d2821
d0a1de7
 
 
 
b3d2821
 
 
 
d0a1de7
b3d2821
a9cfcc5
 
 
 
d0a1de7
 
 
 
 
a9cfcc5
d0a1de7
 
a9cfcc5
 
d0a1de7
 
 
 
 
 
 
 
 
a9cfcc5
d0a1de7
 
 
 
 
a9cfcc5
 
 
d0a1de7
 
01d2cba
a9cfcc5
 
01d2cba
a9cfcc5
 
 
d0a1de7
 
 
a9cfcc5
 
 
 
 
d0a1de7
a9cfcc5
 
 
d0a1de7
a9cfcc5
 
 
 
 
 
 
 
d0a1de7
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
import gradio as gr
import fitz  # PyMuPDF
import json
import numpy as np
from PIL import Image, ImageDraw
from mistralai.client import Mistral
from paddleocr import PaddleOCR
import paddle

# 1. 強制關閉 Paddle 的 oneDNN CPU 加速引擎,徹底避開 Hugging Face 環境的底層 Bug
paddle.set_flags({'FLAGS_use_onednn': False})

# 2. 初始化 PaddleOCR,拿掉方向分類器(考卷字體都是正的,不用它反而跑得更快、更穩!)
ocr = PaddleOCR(lang="ch")

def process_document(file, api_key, progress=gr.Progress()):
    if not api_key:
        return None, "錯誤:請提供 Mistral API 金鑰。"
    if not file:
        return None, "錯誤:請上傳檔案。"

    progress(0.05, desc="初始化 Mistral 客戶端...")
    client = Mistral(api_key=api_key)
    images = []
    
    # --- 1. 處理檔案上傳 ---
    file_name = file.name.lower()
    try:
        progress(0.1, desc="正在讀取與轉換圖片...")
        if file_name.endswith('.pdf'):
            doc = fitz.open(file.name)
            for i in range(len(doc)):
                page = doc.load_page(i)
                pix = page.get_pixmap(dpi=150)
                img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
                images.append(img)
        else:
            img = Image.open(file.name).convert("RGB")
            images.append(img)
    except Exception as e:
        return None, f"檔案讀取錯誤:{str(e)}"

    drawn_images = []
    all_json_results = []

    # --- 2. 準備給 Mistral 的語意判斷 Prompt ---
    prompt_template = """
    你是一個考卷批改助手。以下是一份透過 OCR 掃描出來的考卷內容資料,包含了「印刷體的考卷題目」以及「學生手寫的回答」。
    請透過閱讀上下文的邏輯,幫我過濾掉所有的題目,**只保留「學生的回答」**。

    請以嚴格的 JSON 格式輸出,回傳一個名為 "student_answer_ids" 的陣列,裡面包含你認為是「學生回答」的 ID。
    格式範例:
    {
      "student_answer_ids": [3, 5, 8, 12]
    }

    以下是 OCR 提取的原始資料:
    """

    # --- 3. 逐頁處理 ---
    for page_num, img in enumerate(images):
        try:
            # === Step A: 專門 OCR 提取所有文字與座標 ===
            progress(0.3, desc=f"第 {page_num + 1} 頁 [Step 1/2]:PaddleOCR 精準掃描中...")
            
            # 將 PIL Image 轉為 numpy array 供 PaddleOCR 使用
            img_np = np.array(img)
            ocr_results = ocr.ocr(img_np)
            
            # 整理 OCR 結果,賦予每個文字框一個 ID
            extracted_data = []
            if ocr_results and ocr_results[0]:
                for idx, line in enumerate(ocr_results[0]):
                    box = line[0]      # 座標 [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
                    text = line[1][0]  # 文字內容
                    extracted_data.append({
                        "id": idx,
                        "text": text,
                        "box": box
                    })
            
            if not extracted_data:
                all_json_results.append({f"Page {page_num + 1}": "未偵測到任何文字"})
                drawn_images.append(img)
                continue

            # 建立一份只包含 id 和 text 的輕量資料給 LLM(不給座標以節省 Token)
            llm_input_data = [{"id": item["id"], "text": item["text"]} for item in extracted_data]
            
            # === Step B: Mistral Large 語意過濾 ===
            progress(0.7, desc=f"第 {page_num + 1} 頁 [Step 2/2]:Mistral 語意分析過濾中...")
            
            response = client.chat.complete(
                model="mistral-large-latest",
                messages=[
                    {
                        "role": "user",
                        "content": prompt_template + json.dumps(llm_input_data, ensure_ascii=False)
                    }
                ],
                response_format={"type": "json_object"}
            )
            
            llm_result_text = response.choices[0].message.content
            # 清理潛在的 Markdown 標記
            clean_content = llm_result_text.replace("```json", "").replace("```", "").strip()
            answer_data = json.loads(clean_content)
            student_ids = answer_data.get("student_answer_ids", [])
            
            # === Step C: 整合資料並畫框 ===
            progress(0.9, desc=f"第 {page_num + 1} 頁:正在繪製學生答案的邊界框...")
            draw = ImageDraw.Draw(img)
            
            final_page_result = {"answers": []}
            
            for item in extracted_data:
                if item["id"] in student_ids:
                    # 記錄到最終結果
                    final_page_result["answers"].append({
                        "text": item["text"],
                        "box": item["box"]
                    })
                    
                    # 畫多邊形紅框 (PaddleOCR 輸出的是四個角的確切座標)
                    polygon = [tuple(point) for point in item["box"]]
                    draw.polygon(polygon, outline="red", width=3)
            
            all_json_results.append({f"Page {page_num + 1}": final_page_result})
            drawn_images.append(img)
            
        except Exception as e:
            error_msg = f"處理發生錯誤: {str(e)}"
            print(error_msg)
            all_json_results.append({f"Page {page_num + 1} Error": error_msg})
            drawn_images.append(img)

    progress(1.0, desc="處理完成!")
    return drawn_images, json.dumps(all_json_results, ensure_ascii=False, indent=2)

# --- Gradio 介面設計 ---
with gr.Blocks(title="考卷手寫答案偵測 (OCR + LLM)") as demo:
    gr.Markdown("## 📝 考卷手寫答案偵測 (PaddleOCR + Mistral-large)")
    gr.Markdown("**業界標準架構:** 底層由 `PaddleOCR` 負責精準抓字與計算座標,高層由 `Mistral-large` 判讀上下文語意,自動過濾掉題目,只框出學生的回答!")
    
    with gr.Row():
        with gr.Column():
            api_key_input = gr.Textbox(label="Mistral API Key", type="password")
            file_input = gr.File(label="上傳 PDF 或圖片", file_types=[".pdf", ".jpg", ".png", ".jpeg"])
            submit_btn = gr.Button("開始分析與畫框", variant="primary")
            
        with gr.Column():
            output_gallery = gr.Gallery(label="畫框結果預覽", columns=1, height="auto")
            output_json = gr.JSON(label="偵測結果與完美座標 (JSON)")

    submit_btn.click(
        fn=process_document,
        inputs=[file_input, api_key_input],
        outputs=[output_gallery, output_json]
    )

if __name__ == "__main__":
    demo.launch()