Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| from ultralytics import YOLO | |
| from PIL import Image | |
| import time, os, random, glob, csv, torch | |
| from datetime import datetime | |
| from zoneinfo import ZoneInfo | |
| TZ_TAIPEI = ZoneInfo("Asia/Taipei") | |
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" | |
| USE_HALF = DEVICE == "cuda" | |
| model = YOLO("best.pt") | |
| CLASS_NAMES = {0: "NML", 1: "SCK"} | |
| SAMPLE_DIR = "sample_images" | |
| CSV_PATH = "session_records.csv" | |
| DETAIL_CSV_PATH = "session_details.csv" | |
| GAME_ROUNDS = 12 | |
| ROUND_OPTS = [6, 12, 18, 36] | |
| CSV_FIELDS = [ | |
| "session_id", "datetime", "age_range", "background", | |
| "has_pig_experience", "has_pathology_course", "total_rounds", | |
| "user_accuracy", "user_correct", "user_avg_time_sec", | |
| "user_TP", "user_TN", "user_FP", "user_FN", "user_sensitivity", "user_specificity", | |
| "ai_accuracy", "ai_correct", "ai_avg_time_ms", | |
| "ai_TP", "ai_TN", "ai_FP", "ai_FN", "ai_sensitivity", "ai_specificity", | |
| ] | |
| DETAIL_CSV_FIELDS = [ | |
| "session_id", "datetime", "round_no", | |
| "image_path", "image_filename", "true_label", | |
| "user_answer", "user_correct", "user_time_sec", | |
| "ai_answer", "ai_correct", "ai_time_ms", | |
| "nml_prob", "sck_prob", | |
| ] | |
| def ensure_csv(): | |
| if not os.path.exists(CSV_PATH): | |
| with open(CSV_PATH, "w", newline="", encoding="utf-8-sig") as f: | |
| csv.DictWriter(f, fieldnames=CSV_FIELDS).writeheader() | |
| if not os.path.exists(DETAIL_CSV_PATH): | |
| with open(DETAIL_CSV_PATH, "w", newline="", encoding="utf-8-sig") as f: | |
| csv.DictWriter(f, fieldnames=DETAIL_CSV_FIELDS).writeheader() | |
| ensure_csv() | |
| def _warmup_model(): | |
| try: | |
| dummy = Image.new("RGB", (640, 640), color=(128, 128, 128)) | |
| model.predict(source=dummy, imgsz=640, verbose=False) | |
| print(f"[Warmup] 模型預熱完成(device={DEVICE})") | |
| except Exception as e: | |
| print(f"[Warmup] 模型預熱失敗:{e}") | |
| _warmup_model() | |
| _IMAGE_CACHE: dict = {} | |
| def _preload_images(): | |
| paths = [] | |
| for ext in ["*.jpg", "*.jpeg", "*.png", "*.webp"]: | |
| paths.extend(glob.glob(os.path.join(SAMPLE_DIR, "**", ext), recursive=True)) | |
| for p in paths: | |
| try: | |
| _IMAGE_CACHE[p] = Image.open(p).convert("RGB") | |
| except Exception: | |
| pass | |
| print(f"[Preload] 已預載 {len(_IMAGE_CACHE)} 張圖片") | |
| _preload_images() | |
| def _load_image(path: str) -> Image.Image: | |
| if path in _IMAGE_CACHE: | |
| return _IMAGE_CACHE[path].copy() | |
| img = Image.open(path).convert("RGB") | |
| _IMAGE_CACHE[path] = img | |
| return img.copy() | |
| # ══════════════════════════════════════════════════════════════ | |
| # HTML / JS | |
| # ══════════════════════════════════════════════════════════════ | |
| GAME_JS = """ | |
| <div id="_zoom_modal" | |
| style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh; | |
| background:rgba(0,0,0,0.9);z-index:10000; | |
| align-items:center;justify-content:center;"> | |
| <img id="_zoom_img" | |
| style="max-width:95vw;max-height:90vh;object-fit:contain;border-radius:4px;"> | |
| <button id="_zoom_close" | |
| style="position:absolute;top:16px;right:20px;font-size:1rem;font-weight:600; | |
| color:#fff;background:rgba(255,255,255,0.15); | |
| border:1px solid rgba(255,255,255,0.4); | |
| border-radius:6px;padding:6px 16px;cursor:pointer;"> | |
| 關閉 | |
| </button> | |
| </div> | |
| <div id="_zoom_btn_wrap" style="text-align:center;margin-top:6px;display:none;"> | |
| <button id="_zoom_open_btn" | |
| style="padding:6px 22px;font-size:0.85rem;cursor:pointer; | |
| border:1px solid #6366f1;border-radius:6px; | |
| background:transparent;color:#6366f1; | |
| -webkit-tap-highlight-color:transparent;"> | |
| 放大檢視 | |
| </button> | |
| </div> | |
| <script> | |
| (function(){ | |
| var modal=document.getElementById('_zoom_modal'), | |
| zimg=document.getElementById('_zoom_img'), | |
| closeB=document.getElementById('_zoom_close'), | |
| openB=document.getElementById('_zoom_open_btn'), | |
| btnWrap=document.getElementById('_zoom_btn_wrap'); | |
| function getGameImg(){var c=document.getElementById('game_img');return c?c.querySelector('img'):null;} | |
| function openZoom(){var img=getGameImg();if(!img||!img.src)return;zimg.src=img.src;modal.style.display='flex';} | |
| function closeZoom(){modal.style.display='none';} | |
| openB.addEventListener('click',openZoom); | |
| closeB.addEventListener('click',closeZoom); | |
| modal.addEventListener('click',function(e){if(e.target===modal)closeZoom();}); | |
| document.addEventListener('keydown',function(e){if(e.key==='Escape')closeZoom();}); | |
| function checkImg(){ | |
| var img=getGameImg(); | |
| btnWrap.style.display=(img&&img.src&&img.naturalWidth>0)?'block':'none'; | |
| setTimeout(checkImg,600); | |
| } | |
| setTimeout(checkImg,1000); | |
| document.addEventListener('click',function(e){ | |
| if(!e.target.closest('.game-btn'))return; | |
| var img=document.querySelector('#game_img img'); | |
| if(img&&img.src&&img.naturalWidth>0){ | |
| img.style.transition='opacity 0.1s'; | |
| img.style.opacity='0'; | |
| } | |
| },true); | |
| function watchGameImg(){ | |
| var img=document.querySelector('#game_img img'); | |
| if(!img){setTimeout(watchGameImg,700);return;} | |
| new MutationObserver(function(){img.style.opacity='';img.style.transition='';}) | |
| .observe(img,{attributes:true,attributeFilter:['src']}); | |
| } | |
| setTimeout(watchGameImg,1500); | |
| function setupMag(){ | |
| var gw=document.getElementById('game_img'); | |
| if(!gw){setTimeout(setupMag,800);return;} | |
| var mag=document.getElementById('_mag'); | |
| if(!mag){ | |
| mag=document.createElement('div');mag.id='_mag'; | |
| mag.style.cssText='position:fixed;width:180px;height:180px;border-radius:50%;' | |
| +'border:3px solid #6366f1;pointer-events:none;display:none;z-index:9999;' | |
| +'background-repeat:no-repeat;box-shadow:0 0 0 3px white,0 6px 28px rgba(0,0,0,0.55);'; | |
| document.body.appendChild(mag); | |
| } | |
| gw.addEventListener('mousemove',function(e){ | |
| var img=gw.querySelector('img'); | |
| if(!img||!img.complete||!img.src||img.naturalWidth===0){mag.style.display='none';return;} | |
| var r=img.getBoundingClientRect(),x=e.clientX-r.left,y=e.clientY-r.top; | |
| if(x<0||y<0||x>r.width||y>r.height){mag.style.display='none';return;} | |
| var z=10,hw=90; | |
| mag.style.display='block'; | |
| mag.style.left=(e.clientX+26)+'px';mag.style.top=(e.clientY-hw)+'px'; | |
| mag.style.backgroundImage="url('"+img.src+"')"; | |
| mag.style.backgroundSize=(r.width*z)+'px '+(r.height*z)+'px'; | |
| mag.style.backgroundPosition='-'+(x*z-hw)+'px -'+(y*z-hw)+'px'; | |
| }); | |
| gw.addEventListener('mouseleave',function(){mag.style.display='none';}); | |
| } | |
| setTimeout(setupMag,1200); | |
| })(); | |
| </script> | |
| """ | |
| # ══════════════════════════════════════════════════════════════ | |
| # 知識庫 | |
| # ══════════════════════════════════════════════════════════════ | |
| SCK_KNOWLEDGE = ( | |
| "---\n" | |
| "### 病原概述\n" | |
| "| 病原 | 主要疾病 | 好發豬齡 |\n" | |
| "|------|---------|----------|\n" | |
| "| *Mycoplasma hyopneumoniae* | 豬黴漿菌肺炎(EP) | 保育至育成豬(6-20 週齡) |\n" | |
| "| *Mycoplasma hyorhinis* | 多發性漿膜炎、關節炎 | 3-10 週齡仔豬 |\n" | |
| "| *Mycoplasma hyosynoviae* | 急性非化膿性關節炎 | 12-24 週齡育成豬 |\n\n" | |
| "> 本模型主要針對 *M. hyopneumoniae* 所引發的肺部病變進行影像辨識。\n\n" | |
| "---\n" | |
| "### 臨床症狀\n" | |
| "- **早期**:乾性、非生產性慢性咳嗽(俗稱「乾咳」),尤以運動後或清晨最明顯\n" | |
| "- **中後期**:呼吸費力、腹式呼吸、生長遲滯、FCR 惡化\n" | |
| "- **外觀**:精神尚可,體溫多正常(單純感染時),皮膚無明顯出血點\n" | |
| "- **肺臟病變(剖檢)**:雙側腹葉及心葉對稱性紫紅色至灰色實質化病灶,邊界清楚\n\n" | |
| "**混合感染(PRDC)常見組合:**\n" | |
| "- 合併 *Pasteurella multocida*:急性出血性肺炎,高燒、猝死\n" | |
| "- 合併 PRRSV:大面積肺實質化,死亡率顯著上升\n" | |
| "- 合併 *Haemophilus parasuis*:胸膜炎、心包炎\n\n" | |
| "---\n" | |
| "### 診斷方法\n" | |
| "| 方法 | 說明 | 優缺點 |\n" | |
| "|------|------|--------|\n" | |
| "| **臨床觀察** | 慢性乾咳 + 生長遲滯 | 快速但特異性低 |\n" | |
| "| **X 光 / 超音波** | 肺葉實質化影像 | 非侵入性,適合活體監測 |\n" | |
| "| **剖檢病理** | 肺葉病變評分(Lung Lesion Score) | 黃金標準,用於屠宰監測 |\n" | |
| "| **PCR / qPCR** | 鼻拭子、支氣管肺泡灌洗液(BAL) | 靈敏度高、早期確診首選 |\n" | |
| "| **ELISA 血清學** | 偵測抗體(感染後 3-4 週陽轉) | 適合豬群流行病學調查 |\n\n" | |
| "---\n" | |
| "### 經濟損失參考\n" | |
| "| 損失項目 | 估計影響 |\n" | |
| "|---------|----------|\n" | |
| "| 日增重(ADG) | 降低 **9-16%** |\n" | |
| "| 飼料轉換率(FCR) | 惡化 **14-20%** |\n" | |
| "| 達市場體重天數 | 延長 **9-25 天** |\n" | |
| "| 屠宰肺臟廢棄率 | 受感染豬場可達 **30-70%** |\n\n" | |
| "---\n" | |
| "### 資源與通報\n" | |
| "| 單位 | 聯絡方式 |\n" | |
| "|------|----------|\n" | |
| "| 農業部動植物防疫檢疫署 | (02) 2343-1401 |\n" | |
| "| 屏科大動物疾病診斷中心 | https://dcads.npust.edu.tw |\n" | |
| "| 中華民國獸醫師公會全國聯合會 | (02) 7724-4525 |\n" | |
| ) | |
| _MI_ARCH_MAIN = ( | |
| "## 模型架構\n\n" | |
| "| 項目 | 內容 |\n|------|------|\n" | |
| "| **模型系列** | YOLOv8x-cls(Ultralytics YOLOv8 Extra-Large 分類版) |\n" | |
| "| **任務類型** | 影像分類(Image Classification) |\n" | |
| "| **輸入尺寸** | 640 x 640 pixels,RGB 3 通道 |\n" | |
| "| **輸出類別數** | 2(NML 無黴漿菌 / SCK 黴漿菌感染) |\n" | |
| "| **模型參數量** | 56,144,402(約 5,600 萬參數) |\n" | |
| "| **權重精度** | Float16(半精度,檔案約 112 MB) |\n" | |
| "| **Backbone** | CSPDarknet + C2f + Bottleneck 堆疊結構 |\n" | |
| "| **Classification Head** | AdaptiveAvgPool2d(1) → Linear(1280 → 2) |\n" | |
| ) | |
| _MI_ARCH_GLOSS = ( | |
| "| 名詞 | 說明 |\n|------|------|\n" | |
| "| **影像分類** | 給定一張影像,輸出其所屬類別的機器學習任務 |\n" | |
| "| **Backbone** | 負責從影像中提取特徵的主幹網路 |\n" | |
| "| **CSPDarknet** | Cross Stage Partial Network,減少計算量同時保留梯度流動的主幹設計 |\n" | |
| "| **C2f** | YOLOv8 特有的特徵融合模組,結合多層殘差連接以提升特徵表達力 |\n" | |
| "| **Bottleneck** | 用 1x1 卷積先降維再升維,在保留資訊的前提下降低計算成本 |\n" | |
| "| **Classification Head** | 分類頭,位於網路末端,將特徵圖轉換為各類別的機率分數 |\n" | |
| "| **AdaptiveAvgPool2d** | 自適應平均池化,將任意空間尺寸的特徵圖壓縮為固定大小(1x1) |\n" | |
| "| **Float16(半精度)** | 使用 16 位元浮點數儲存權重,縮減模型檔案大小並加速 GPU 推論 |\n" | |
| ) | |
| _MI_PRE_MAIN = ( | |
| "---\n## 前處理流程\n\n" | |
| "模型推論時自動套用以下 transform pipeline(與訓練期間一致):\n\n" | |
| "```\n輸入圖片\n→ Resize(640, bilinear, antialias)\n→ CenterCrop(640x640)\n" | |
| "→ ToTensor()(像素值縮放至 0-1)\n→ Normalize(mean=[0,0,0], std=[1,1,1])\n```\n\n" | |
| "> 注意:Normalize 參數 mean=0 / std=1 表示此步驟不改變數值分布,實際縮放由 ToTensor() 完成。\n" | |
| ) | |
| _MI_PRE_GLOSS = ( | |
| "| 名詞 | 說明 |\n|------|------|\n" | |
| "| **Transform Pipeline** | 一系列依序執行的影像前處理步驟,確保輸入格式符合模型預期 |\n" | |
| "| **Resize** | 將圖片的最短邊縮放至指定尺寸,同時維持長寬比 |\n" | |
| "| **CenterCrop** | 從影像中心裁切出固定大小的區域,去除邊緣雜訊 |\n" | |
| "| **Bilinear Interpolation** | 雙線性內插法,縮放時對鄰近 4 個像素進行加權平均 |\n" | |
| "| **Antialias** | 抗鋸齒處理,縮小影像時先做平滑濾波,防止高頻細節產生偽影 |\n" | |
| "| **ToTensor** | 將像素值從 uint8 [0,255] 轉換為 float32 [0.0,1.0] |\n" | |
| "| **Normalize** | 對各通道套用 (x - mean) / std |\n" | |
| ) | |
| _MI_TRAIN_MAIN = ( | |
| "---\n## 訓練設定\n\n" | |
| "| 超參數 | 數值 |\n|--------|------|\n" | |
| "| **預訓練權重** | yolov8x-cls.pt(ImageNet 預訓練) |\n" | |
| "| **目標訓練輪數** | 300 epochs |\n" | |
| "| **批次大小(Batch Size)** | 8 |\n" | |
| "| **訓練裝置** | GPU(NVIDIA GeForce RTX 5090) |\n" | |
| "| **優化器** | Auto(自動選擇) |\n" | |
| "| **初始學習率(lr0)** | 0.01 |\n" | |
| "| **最終學習率(lrf)** | 0.01 |\n" | |
| "| **動量(Momentum)** | 0.937 |\n" | |
| "| **權重衰減(Weight Decay)** | 0.0005 |\n" | |
| "| **Warmup Epochs** | 3 |\n" | |
| "| **混合精度訓練(AMP)** | 啟用 |\n" | |
| "| **隨機種子** | 0(Deterministic) |\n\n" | |
| "**資料增強設定:** 平移 0.1 | 縮放 0.5 | 左右翻轉 0.5 | Random Erasing 0.4\n" | |
| ) | |
| _MI_TRAIN_GLOSS = ( | |
| "| 名詞 | 說明 |\n|------|------|\n" | |
| "| **預訓練權重** | 在大型資料集上預先訓練好的模型參數,作為微調起點以加速收斂 |\n" | |
| "| **Epoch** | 訓練資料集被完整瀏覽一遍稱為一個 epoch |\n" | |
| "| **Batch Size** | 每次更新梯度時一次送入模型的影像數量 |\n" | |
| "| **學習率** | 控制每次梯度更新的步伐大小 |\n" | |
| "| **動量** | 在梯度更新時加入前一步方向的慣性,有助於跨越局部極小值 |\n" | |
| "| **權重衰減** | L2 正則化,對較大的權重施加懲罰,防止過擬合 |\n" | |
| "| **Warmup** | 訓練初期使用較小學習率逐步升溫,避免初始階段不穩定 |\n" | |
| "| **AMP** | 自動混合精度訓練,部分運算用 Float16 加速 |\n" | |
| "| **資料增強** | 對訓練影像進行隨機變換,擴增樣本多樣性以提升泛化能力 |\n" | |
| "| **Random Erasing** | 隨機在影像上遮蔽矩形區塊,迫使模型學習更全局的特徵 |\n" | |
| "| **Deterministic** | 固定隨機種子使訓練結果可重現 |\n" | |
| ) | |
| _MI_PERF_MAIN = ( | |
| "---\n## 全資料集重測性能\n\n" | |
| "> 以下數據來自模型訓練完成後,以**全體 236 張影像**重新進行推論的實測結果," | |
| "使用硬體為 **MSI Raider A18 HX A9W**。\n\n" | |
| "### 測試硬體規格\n\n" | |
| "| 項目 | 規格 |\n|------|------|\n" | |
| "| **機型** | MSI Raider A18 HX A9W |\n" | |
| "| **CPU** | AMD Ryzen 9 9955HX3D(16C/32T,Zen 5,2.5-5.4 GHz) |\n" | |
| "| **GPU** | NVIDIA GeForce RTX 5090 Laptop,24 GB GDDR7 |\n" | |
| "| **RAM** | 96 GB DDR5 5600 MHz |\n" | |
| "| **儲存** | 2 TB NVMe PCIe Gen 5x4 |\n" | |
| "| **作業系統** | Windows 11 |\n\n" | |
| "---\n### 混淆矩陣(全資料集 236 張)\n\n" | |
| "NML 118 張、SCK 118 張(均衡分布)。\n\n" | |
| "| | 預測 NML | 預測 SCK |\n|:---:|:---:|:---:|\n" | |
| "| **實際 NML** | TN = **115** | FP = **3** |\n" | |
| "| **實際 SCK** | FN = **0** | TP = **118** |\n\n" | |
| "> 三筆誤判均為 NML 誤判為 SCK(FP),無任何 SCK 漏判(FN = 0),在傳染病篩檢情境下屬最佳錯誤方向。\n\n" | |
| "---\n### 性能指標彙整\n\n" | |
| "| 指標 | 公式 | 數值 |\n|------|------|:---:|\n" | |
| "| **Accuracy** | (TP+TN) / N | **98.73%** |\n" | |
| "| **Precision** | TP / (TP+FP) | **97.52%** |\n" | |
| "| **Recall / Sensitivity** | TP / (TP+FN) | **100.00%** |\n" | |
| "| **Specificity** | TN / (TN+FP) | **97.46%** |\n" | |
| "| **F1 Score**(b=1) | 2·P·R / (P+R) | **98.74%** |\n" | |
| "| **F0.5 Score**(b=0.5) | 1.25·P·R / (0.25·P+R) | **98.00%** |\n" | |
| "| **F2 Score**(b=2) | 5·P·R / (4·P+R) | **99.49%** |\n\n" | |
| "---\n### 推論速度分析\n\n" | |
| "| 項目 | 數值 |\n|------|------|\n" | |
| "| **穩定推論速度(預熱後)** | **7.1 - 8.2 ms / 張** |\n" | |
| "| **典型中位推論時間** | **約 7.5 ms / 張** |\n" | |
| "| **初次推論(含 GPU 預熱)** | 10.0 - 11.3 ms |\n" | |
| "| **等效最高吞吐量** | 大於 120 張 / 秒 |\n\n" | |
| "---\n### 驗證集 vs 全資料集重測比較\n\n" | |
| "| 指標 | 驗證集(36 筆)| 全資料集(236 筆)|\n|------|:---:|:---:|\n" | |
| "| **Accuracy** | 97.22% | **98.73%** |\n" | |
| "| **Sensitivity** | 94.44-100% | **100.00%** |\n" | |
| "| **Specificity** | 94.44-100% | **97.46%** |\n" | |
| "| **FN(漏診)** | 0-1 | **0** |\n\n" | |
| "> 全資料集重測包含訓練資料,反映模型記憶上限而非泛化能力;驗證集指標(97.22%)才是泛化能力的適當基準。\n" | |
| ) | |
| _MI_PERF_GLOSS = ( | |
| "| 名詞 | 說明 |\n|------|------|\n" | |
| "| **混淆矩陣** | 以 2x2 表格呈現預測與真實標籤的交叉分布 |\n" | |
| "| **TP(True Positive)** | 真陽性:實際 SCK,預測也為 SCK(正確偵測感染) |\n" | |
| "| **TN(True Negative)** | 真陰性:實際 NML,預測也為 NML(正確排除感染) |\n" | |
| "| **FP(False Positive)** | 偽陽性:實際 NML,誤判為 SCK(過度診斷) |\n" | |
| "| **FN(False Negative)** | 偽陰性:實際 SCK,誤判為 NML(漏診,風險最高) |\n" | |
| "| **Precision** | 預測為 SCK 中真正是 SCK 的比例;反映過度診斷率 |\n" | |
| "| **Recall / Sensitivity** | 所有 SCK 中被正確偵測的比例;反映漏診率 |\n" | |
| "| **Specificity** | 所有 NML 中被正確排除的比例 |\n" | |
| "| **F1 Score** | Precision 與 Recall 的調和平均(b=1) |\n" | |
| "| **GPU 預熱** | 首次推論時 GPU 需初始化 CUDA context,導致前幾張耗時較長 |\n" | |
| "| **吞吐量** | 單位時間內可處理的影像數量 |\n" | |
| ) | |
| _MI_VERSION = ( | |
| "---\n## 模型版本資訊\n\n" | |
| "| 項目 | 內容 |\n|------|------|\n" | |
| "| **產生時間** | 2026-03-05 10:09:48(UTC+8) |\n" | |
| "| **Ultralytics 版本** | 8.3.175 |\n" | |
| "| **授權** | AGPL-3.0 |\n\n" | |
| "---\n## 使用限制與注意事項\n\n" | |
| "- 本模型**僅針對豬肺臟腹側及背側影像**進行黴漿菌感染辨識,不適用於其他動物或其他影像類型\n" | |
| "- NML 類別表示「**未偵測到黴漿菌感染特徵**」,不代表該豬隻完全健康\n" | |
| "- 驗證集樣本數較小(36 筆),實際大規模部署前建議擴充測試資料\n" | |
| "- 當信心指數接近 50% 時,建議結合臨床症狀綜合判斷\n" | |
| "- 推論速度數據基於 RTX 5090 Laptop GPU;其他硬體速度將有所不同\n" | |
| "- 本系統僅作為輔助參考工具,不取代執業獸醫師的專業診斷\n" | |
| ) | |
| # ══════════════════════════════════════════════════════════════ | |
| # Core utilities | |
| # ══════════════════════════════════════════════════════════════ | |
| def infer(image: Image.Image): | |
| t0 = time.time() | |
| res = model.predict(source=image, imgsz=640, verbose=False)[0] | |
| ms = (time.time() - t0) * 1000 | |
| if res.probs is None: | |
| return None, 0.0, 0.0, ms | |
| p = res.probs.data.tolist() | |
| return CLASS_NAMES[int(res.probs.top1)], p[0], p[1], ms | |
| def label_from_path(p: str): | |
| u = p.upper().replace("\\", "/") | |
| if "/NML/" in u: | |
| return "NML" | |
| if "/SCK/" in u: | |
| return "SCK" | |
| return None | |
| def get_samples(): | |
| paths = list(_IMAGE_CACHE.keys()) | |
| random.shuffle(paths) | |
| return paths | |
| def get_representative_images(): | |
| nml_path = os.path.join(SAMPLE_DIR, "NML", "nml_01.png") | |
| sck_path = os.path.join(SAMPLE_DIR, "SCK", "sck_01.png") | |
| nml_img = _load_image(nml_path) if os.path.exists(nml_path) else None | |
| sck_img = _load_image(sck_path) if os.path.exists(sck_path) else None | |
| return nml_img, sck_img | |
| def calc_metrics(tp, tn, fp, fn): | |
| t = tp + tn + fp + fn | |
| return ( | |
| (tp + tn) / t if t > 0 else 0, | |
| tp / (tp + fn) if tp + fn > 0 else 0, | |
| tn / (tn + fp) if tn + fp > 0 else 0, | |
| ) | |
| def class_label(cls): | |
| return "無黴漿菌(NML)" if cls == "NML" else "黴漿菌感染(SCK)" | |
| def pred_md(pred, p_nml, p_sck, ms, true_label=None): | |
| conf = p_nml if pred == "NML" else p_sck | |
| b_n = "|" * int(p_nml * 20) + "." * (20 - int(p_nml * 20)) | |
| b_s = "|" * int(p_sck * 20) + "." * (20 - int(p_sck * 20)) | |
| md = "## 判定結果:" + class_label(pred) + "\n\n" | |
| md += "> 信心指數 **" + f"{conf:.1%}" + "** | 推論時間 **" + f"{ms:.0f}" + " ms**\n\n" | |
| md += "| 類別 | 機率 | 視覺化 |\n|:---|:---:|:---|\n" | |
| md += "| NML 無黴漿菌 | `" + f"{p_nml:.1%}" + "` | `" + b_n + "` |\n" | |
| md += "| SCK 黴漿菌感染 | `" + f"{p_sck:.1%}" + "` | `" + b_s + "` |\n" | |
| if true_label is not None: | |
| ok = (pred == true_label) | |
| md += "\n---\n\n**真值驗證**\n\n| 項目 | 結果 |\n|:---|:---:|\n" | |
| md += "| 系統判斷 | " + class_label(pred) + " |\n" | |
| md += "| 真實標籤 | " + class_label(true_label) + " |\n" | |
| md += "| 驗證結果 | " + ("判斷正確" if ok else "判斷錯誤") + " |\n" | |
| if pred == "SCK": | |
| md += "\n---\n請參閱「管理指南」分頁取得詳細防治資訊。" | |
| return md | |
| # ══════════════════════════════════════════════════════════════ | |
| # Game utilities | |
| # ══════════════════════════════════════════════════════════════ | |
| INIT_GAME = { | |
| "queue": [], | |
| "idx": 0, | |
| "results": [], | |
| "round_start": 0.0, | |
| "active": False, | |
| "total_rounds": GAME_ROUNDS, | |
| } | |
| def prog_md(idx, total=GAME_ROUNDS): | |
| disp = min(total, 18) | |
| dots = "".join("■" if i < idx else ("▶" if i == idx else "□") for i in range(disp)) | |
| suffix = "...+" + str(total - disp) if total > disp else "" | |
| return dots + suffix + " `" + str(idx) + " / " + str(total) + " 完成`" | |
| _TH = "style='padding:8px 14px;border:1px solid var(--border-color-primary,#e5e7eb);background:var(--background-fill-secondary,#f9fafb);font-weight:600;white-space:nowrap;'" | |
| _TD = "style='padding:8px 14px;border:1px solid var(--border-color-primary,#e5e7eb);white-space:nowrap;text-align:center;'" | |
| def _tbl(headers, rows): | |
| h = "".join(f"<th {_TH}>{c}</th>" for c in headers) | |
| body = "" | |
| for row in rows: | |
| cells = "".join(f"<td {_TD}>{c}</td>" for c in row) | |
| body += f"<tr>{cells}</tr>" | |
| return ( | |
| "<div style='width:100%;overflow-x:auto;margin:12px 0'>" | |
| f"<table style='width:100%;border-collapse:collapse;font-size:0.92rem'>" | |
| f"<thead><tr>{h}</tr></thead><tbody>{body}</tbody></table></div>" | |
| ) | |
| def game_final_html(results): | |
| labeled = [r for r in results if r["true"]] | |
| n, nl = len(results), len(labeled) | |
| if n == 0: | |
| return "" | |
| u_cor = sum(1 for r in labeled if r["user"] == r["true"]) | |
| a_cor = sum(1 for r in labeled if r["ai"] == r["true"]) | |
| u_avg = sum(r["u_t"] for r in results) / n | |
| a_avg = sum(r["a_t"] for r in results) / n / 1000 | |
| u_TP = sum(1 for r in labeled if r["true"] == "SCK" and r["user"] == "SCK") | |
| u_TN = sum(1 for r in labeled if r["true"] == "NML" and r["user"] == "NML") | |
| u_FP = sum(1 for r in labeled if r["true"] == "NML" and r["user"] == "SCK") | |
| u_FN = sum(1 for r in labeled if r["true"] == "SCK" and r["user"] == "NML") | |
| a_TP = sum(1 for r in labeled if r["true"] == "SCK" and r["ai"] == "SCK") | |
| a_TN = sum(1 for r in labeled if r["true"] == "NML" and r["ai"] == "NML") | |
| a_FP = sum(1 for r in labeled if r["true"] == "NML" and r["ai"] == "SCK") | |
| a_FN = sum(1 for r in labeled if r["true"] == "SCK" and r["ai"] == "NML") | |
| u_acc, u_sens, u_spec = calc_metrics(u_TP, u_TN, u_FP, u_FN) | |
| a_acc, a_sens, a_spec = calc_metrics(a_TP, a_TN, a_FP, a_FN) | |
| winner = "受試者獲勝" if u_acc > a_acc else ("平手" if u_acc == a_acc else "AI 獲勝") | |
| summary_rows = [] | |
| if nl > 0: | |
| summary_rows = [ | |
| ["正確率", f"<strong>{u_acc:.0%}</strong>({u_cor}/{nl})", f"<strong>{a_acc:.0%}</strong>({a_cor}/{nl})"], | |
| ["敏感度", f"{u_sens:.0%}", f"{a_sens:.0%}"], | |
| ["特異度", f"{u_spec:.0%}", f"{a_spec:.0%}"], | |
| ["TP/TN/FP/FN", f"{u_TP}/{u_TN}/{u_FP}/{u_FN}", f"{a_TP}/{a_TN}/{a_FP}/{a_FN}"], | |
| ["平均耗時", f"{u_avg:.2f} 秒", f"{a_avg:.3f} 秒"], | |
| ] | |
| summary_tbl = _tbl(["指標", "受試者", "AI"], summary_rows) | |
| detail_rows = [] | |
| for r in results: | |
| tl = r["true"] or "不明" | |
| u_ok = "正確" if r["user"] == r["true"] else ("錯誤" if r["true"] else "不明") | |
| a_ok = "正確" if r["ai"] == r["true"] else ("錯誤" if r["true"] else "不明") | |
| u_color = "#16a34a" if u_ok == "正確" else ("#dc2626" if u_ok == "錯誤" else "#6b7280") | |
| a_color = "#16a34a" if a_ok == "正確" else ("#dc2626" if a_ok == "錯誤" else "#6b7280") | |
| detail_rows.append([ | |
| str(r["round"]), | |
| tl, | |
| f"<span style='color:{u_color}'>{r['user']} {u_ok}</span>", | |
| f"<span style='color:{a_color}'>{r['ai']} {a_ok}</span>", | |
| f"{r['u_t']:.2f}", | |
| f"{r['a_t']/1000:.3f}", | |
| ]) | |
| detail_tbl = _tbl(["回合", "真值", "受試者", "AI", "受試者(秒)", "AI(秒)"], detail_rows) | |
| return ( | |
| f"<h2 style='margin:8px 0'>挑戰完成 結果:{winner}</h2>" | |
| f"<h3 style='margin:16px 0 4px'>總結比較</h3>{summary_tbl}" | |
| f"<h3 style='margin:16px 0 4px'>逐回合明細</h3>{detail_tbl}" | |
| ) | |
| def game_round_image(gs, choice): | |
| results = gs.get("results", []) | |
| if not results or not choice: | |
| return None, "" | |
| idx = int(choice.replace("回合 ", "")) - 1 | |
| if idx < 0 or idx >= len(results): | |
| return None, "" | |
| r = results[idx] | |
| img = _load_image(r["path"]) | |
| tl = r["true"] or "不明" | |
| u_ok = "正確" if r["user"] == r["true"] else ("錯誤" if r["true"] else "不明") | |
| a_ok = "正確" if r["ai"] == r["true"] else ("錯誤" if r["true"] else "不明") | |
| info = ( | |
| "**回合 " + str(r["round"]) + "** | 真值:" + tl + "\n\n" | |
| "- 受試者:" + r["user"] + " " + u_ok + " 耗時 " + f"{r['u_t']:.3f}" + " 秒\n" | |
| "- AI:" + r["ai"] + " " + a_ok + " 耗時 " + f"{r['a_t']/1000:.3f}" + " 秒" | |
| ) | |
| return img, info | |
| def _write_detail_csv(session_id, dt_str, results): | |
| ensure_csv() | |
| with open(DETAIL_CSV_PATH, "a", newline="", encoding="utf-8-sig") as f: | |
| writer = csv.DictWriter(f, fieldnames=DETAIL_CSV_FIELDS) | |
| for r in results: | |
| true_label = r.get("true", "") | |
| user_answer = r.get("user", "") | |
| ai_answer = r.get("ai", "") | |
| row = { | |
| "session_id": session_id, | |
| "datetime": dt_str, | |
| "round_no": r.get("round", ""), | |
| "image_path": r.get("path", ""), | |
| "image_filename": r.get("filename", ""), | |
| "true_label": true_label, | |
| "user_answer": user_answer, | |
| "user_correct": int(user_answer == true_label) if true_label else "", | |
| "user_time_sec": round(r.get("u_t", 0), 3), | |
| "ai_answer": ai_answer, | |
| "ai_correct": int(ai_answer == true_label) if true_label else "", | |
| "ai_time_ms": round(r.get("a_t", 0), 1), | |
| "nml_prob": round(r.get("p_nml", 0), 4), | |
| "sck_prob": round(r.get("p_sck", 0), 4), | |
| } | |
| writer.writerow(row) | |
| def _write_csv(gs, profile): | |
| res = gs.get("results", []) | |
| if not res: | |
| return None, "" | |
| labeled = [r for r in res if r["true"]] | |
| n = len(res) | |
| u_cor = sum(1 for r in labeled if r["user"] == r["true"]) | |
| a_cor = sum(1 for r in labeled if r["ai"] == r["true"]) | |
| u_TP = sum(1 for r in labeled if r["true"] == "SCK" and r["user"] == "SCK") | |
| u_TN = sum(1 for r in labeled if r["true"] == "NML" and r["user"] == "NML") | |
| u_FP = sum(1 for r in labeled if r["true"] == "NML" and r["user"] == "SCK") | |
| u_FN = sum(1 for r in labeled if r["true"] == "SCK" and r["user"] == "NML") | |
| a_TP = sum(1 for r in labeled if r["true"] == "SCK" and r["ai"] == "SCK") | |
| a_TN = sum(1 for r in labeled if r["true"] == "NML" and r["ai"] == "NML") | |
| a_FP = sum(1 for r in labeled if r["true"] == "NML" and r["ai"] == "SCK") | |
| a_FN = sum(1 for r in labeled if r["true"] == "SCK" and r["ai"] == "NML") | |
| u_acc, u_sens, u_spec = calc_metrics(u_TP, u_TN, u_FP, u_FN) | |
| a_acc, a_sens, a_spec = calc_metrics(a_TP, a_TN, a_FP, a_FN) | |
| now = datetime.now(TZ_TAIPEI) | |
| sid = now.strftime("%Y%m%d_%H%M%S") | |
| dt_str = now.strftime("%Y-%m-%d %H:%M:%S") | |
| row = { | |
| "session_id": sid, | |
| "datetime": dt_str, | |
| "age_range": profile.get("age_range", ""), | |
| "background": profile.get("background", ""), | |
| "has_pig_experience": profile.get("pig_exp", ""), | |
| "has_pathology_course": profile.get("pathology", ""), | |
| "total_rounds": n, | |
| "user_accuracy": round(u_acc, 4), | |
| "user_correct": u_cor, | |
| "user_avg_time_sec": round(sum(r["u_t"] for r in res) / n, 3), | |
| "user_TP": u_TP, | |
| "user_TN": u_TN, | |
| "user_FP": u_FP, | |
| "user_FN": u_FN, | |
| "user_sensitivity": round(u_sens, 4), | |
| "user_specificity": round(u_spec, 4), | |
| "ai_accuracy": round(a_acc, 4), | |
| "ai_correct": a_cor, | |
| "ai_avg_time_ms": round(sum(r["a_t"] for r in res) / n, 1), | |
| "ai_TP": a_TP, | |
| "ai_TN": a_TN, | |
| "ai_FP": a_FP, | |
| "ai_FN": a_FN, | |
| "ai_sensitivity": round(a_sens, 4), | |
| "ai_specificity": round(a_spec, 4), | |
| } | |
| ensure_csv() | |
| with open(CSV_PATH, "a", newline="", encoding="utf-8-sig") as f: | |
| csv.DictWriter(f, fieldnames=CSV_FIELDS).writerow(row) | |
| _write_detail_csv(sid, dt_str, res) | |
| return CSV_PATH, "紀錄已自動儲存 Session ID:" + sid | |
| def _ret(gs, img, prompt, pidx, results_html, nml_on, sck_on, | |
| dl=None, smsg="", round_choices=None): | |
| total = gs.get("total_rounds", GAME_ROUNDS) if gs else GAME_ROUNDS | |
| return ( | |
| gs, img, prompt, prog_md(pidx, total), results_html, | |
| gr.update(interactive=nml_on), gr.update(interactive=sck_on), | |
| dl, smsg, | |
| gr.update(choices=round_choices or [], value=None, interactive=bool(round_choices)) | |
| ) | |
| def profile_submit(age, bg, pig_exp, pathology): | |
| if not all([age, bg, pig_exp, pathology]): | |
| return {}, "請填寫所有欄位" | |
| p = { | |
| "submitted": True, | |
| "age_range": age, | |
| "background": bg, | |
| "pig_exp": pig_exp, | |
| "pathology": pathology | |
| } | |
| return p, "已儲存 " + age + " " + bg + " 豬病經驗:" + pig_exp + " 病理學課程:" + pathology | |
| def game_start(spaths, profile, _gs, n_rounds): | |
| n_rounds = int(n_rounds) if n_rounds else GAME_ROUNDS | |
| if not profile.get("submitted"): | |
| return _ret(dict(INIT_GAME), None, "請先展開上方「使用者背景」並填寫完畢", 0, "", False, False) | |
| if not spaths: | |
| return _ret(dict(INIT_GAME), None, "尚無示例圖片", 0, "", False, False) | |
| sel = random.sample(spaths, n_rounds) if len(spaths) >= n_rounds else random.choices(spaths, k=n_rounds) | |
| q = [{"path": p, "true": label_from_path(p)} for p in sel] | |
| gs = { | |
| "queue": q, | |
| "idx": 0, | |
| "results": [], | |
| "round_start": time.time(), | |
| "active": True, | |
| "total_rounds": n_rounds | |
| } | |
| return _ret( | |
| gs, | |
| _load_image(q[0]["path"]), | |
| "回合 **1 / " + str(n_rounds) + "** 請判斷這張影像", | |
| 0, "", True, True | |
| ) | |
| def game_answer_prepare(): | |
| return ( | |
| None, | |
| "**AI 判讀中,請稍候...**\n\n> 系統正在分析影像,結果即將顯示。", | |
| gr.update(interactive=False), | |
| gr.update(interactive=False) | |
| ) | |
| def game_answer(choice, gs, profile): | |
| if not gs.get("active"): | |
| return _ret(gs, None, "請先按「開始挑戰」", 0, "", False, False) | |
| u_t = time.time() - gs["round_start"] | |
| idx = gs["idx"] | |
| item = gs["queue"][idx] | |
| n_rounds = gs.get("total_rounds", GAME_ROUNDS) | |
| img = _load_image(item["path"]) | |
| pred, p_nml, p_sck, a_t = infer(img) | |
| if pred is None: | |
| pred = "NML" | |
| p_nml, p_sck = 0.0, 0.0 | |
| r = { | |
| "round": idx + 1, | |
| "true": item["true"], | |
| "user": choice, | |
| "ai": pred, | |
| "u_t": u_t, | |
| "a_t": a_t, | |
| "path": item["path"], | |
| "filename": os.path.basename(item["path"]), | |
| "p_nml": p_nml, | |
| "p_sck": p_sck, | |
| } | |
| ngs = {**gs, "results": gs["results"] + [r], "idx": idx + 1} | |
| if ngs["idx"] >= n_rounds: | |
| ngs["active"] = False | |
| dl, smsg = _write_csv(ngs, profile) | |
| choices = ["回合 " + str(i + 1) for i in range(n_rounds)] | |
| return _ret( | |
| ngs, None, | |
| "**" + str(n_rounds) + " 回合完成。** 紀錄已自動儲存。", | |
| n_rounds, game_final_html(ngs["results"]), | |
| False, False, dl, smsg, choices | |
| ) | |
| ni = ngs["idx"] | |
| ngs["round_start"] = time.time() | |
| return _ret( | |
| ngs, | |
| _load_image(ngs["queue"][ni]["path"]), | |
| "回合 **" + str(ni + 1) + " / " + str(n_rounds) + "** 請判斷這張影像", | |
| ni, "", True, True | |
| ) | |
| def game_reset(_gs): | |
| return _ret(dict(INIT_GAME), None, "按「開始挑戰」開始", 0, "", False, False) | |
| def tab1_analyze(img): | |
| if img is None: | |
| return "請先上傳圖片" | |
| pred, p_nml, p_sck, ms = infer(img) | |
| return "模型推論失敗" if pred is None else pred_md(pred, p_nml, p_sck, ms) | |
| def tab2_load(): | |
| paths = get_samples() | |
| if not paths: | |
| return [], "尚無示例圖片,請建立 sample_images/NML/ 與 SCK/ 資料夾並上傳圖片", paths | |
| return paths, "共 **" + str(len(paths)) + "** 張示例圖片(已隨機排序) 點擊縮圖選取,或按「隨機選一張」", paths | |
| def tab2_random_and_analyze(spaths): | |
| if not spaths: | |
| return None, "尚無示例圖片", "已隨機選取一張圖片" | |
| p = random.choice(spaths) | |
| img = _load_image(p) | |
| pred, p_nml, p_sck, ms = infer(img) | |
| result = "模型推論失敗" if pred is None else pred_md(pred, p_nml, p_sck, ms, label_from_path(p)) | |
| return img, result, "已隨機選取並分析完成" | |
| def tab2_select_and_analyze(spaths, evt: gr.SelectData): | |
| if not spaths or evt.index >= len(spaths): | |
| return None, "" | |
| p = spaths[evt.index] | |
| img = _load_image(p) | |
| pred, p_nml, p_sck, ms = infer(img) | |
| result = "模型推論失敗" if pred is None else pred_md(pred, p_nml, p_sck, ms, label_from_path(p)) | |
| return img, result | |
| # ══════════════════════════════════════════════════════════════ | |
| # CSS | |
| # ══════════════════════════════════════════════════════════════ | |
| css = """ | |
| *, *::before, *::after { box-sizing: border-box !important; } | |
| html { overflow-x: hidden !important; overflow-y: scroll !important; max-width: 100vw !important; } | |
| body { overflow-x: hidden !important; max-width: 100vw !important; } | |
| .gradio-container { | |
| max-width: 1080px !important; margin: 0 auto !important; | |
| overflow-x: hidden !important; width: 100% !important; | |
| } | |
| footer { display: none !important; } | |
| /* 隱藏 HF 頁面元素 */ | |
| #hf-navbar, | |
| .hf-navbar, | |
| header.svelte-1ied0k4, | |
| nav[aria-label="Main navigation"], | |
| .main-header, | |
| [data-testid="hf-header"], | |
| .svelte-1rtl2t4, | |
| a[href*="huggingface.co"], | |
| a[href*="hf.co"], | |
| .share-button, | |
| [data-testid="share-btn"], | |
| .built-with { display: none !important; } | |
| .tabs > .tabitem { min-height: 80vh !important; } | |
| .tab-nav, [role="tablist"] { | |
| display: flex !important; flex-wrap: nowrap !important; | |
| overflow-x: auto !important; -webkit-overflow-scrolling: touch !important; | |
| scrollbar-width: none !important; | |
| } | |
| .tab-nav::-webkit-scrollbar, [role="tablist"]::-webkit-scrollbar { display: none !important; } | |
| .tab-nav > *, [role="tablist"] > * { white-space: nowrap !important; flex-shrink: 0 !important; } | |
| /* ── Markdown 表格:全寬、正常換行 ── */ | |
| .gradio-markdown table, [class*="prose"] table, [class*="markdown"] table { | |
| width: 100% !important; | |
| border-collapse: collapse !important; | |
| table-layout: auto !important; | |
| font-size: 0.9rem !important; | |
| } | |
| .gradio-markdown th, [class*="prose"] th, [class*="markdown"] th { | |
| background: var(--background-fill-secondary, #f9fafb) !important; | |
| padding: 8px 12px !important; | |
| border: 1px solid var(--border-color-primary, #e5e7eb) !important; | |
| font-weight: 600 !important; | |
| white-space: nowrap !important; | |
| } | |
| .gradio-markdown td, [class*="prose"] td, [class*="markdown"] td { | |
| padding: 7px 12px !important; | |
| border: 1px solid var(--border-color-primary, #e5e7eb) !important; | |
| word-break: break-word !important; | |
| white-space: normal !important; | |
| } | |
| /* 數值欄(第2欄以後)保持不換行 */ | |
| .gradio-markdown td:not(:first-child), | |
| [class*="prose"] td:not(:first-child), | |
| [class*="markdown"] td:not(:first-child) { | |
| white-space: nowrap !important; | |
| } | |
| .game-btn { | |
| background: var(--button-secondary-background-fill) !important; | |
| border: 1px solid var(--button-secondary-border-color) !important; | |
| color: var(--button-secondary-text-color) !important; | |
| font-size: 1rem !important; | |
| } | |
| .game-btn:hover:not([disabled]) { filter: brightness(0.95) !important; } | |
| @media (max-width: 768px) { | |
| .gradio-container { padding: 0 10px !important; } | |
| .gradio-row, .gr-row, | |
| [class*="gap-"][class*="flex"]:not(.tab-nav):not([role="tablist"]) { | |
| flex-direction: column !important; align-items: stretch !important; flex-wrap: wrap !important; | |
| } | |
| .gradio-row > *, .gr-row > * { | |
| width: 100% !important; min-width: 0 !important; | |
| max-width: 100% !important; flex: 0 0 100% !important; | |
| } | |
| .gradio-container * { max-width: 100% !important; } | |
| p, li, span, h1, h2, h3, h4, blockquote { | |
| overflow-wrap: break-word !important; word-break: break-word !important; | |
| } | |
| img { max-width: 100% !important; height: auto !important; } | |
| .gradio-image img { max-height: 260px !important; object-fit: contain !important; width: 100% !important; } | |
| .grid-wrap { grid-template-columns: repeat(3, 1fr) !important; } | |
| /* 手機上表格橫向捲動 */ | |
| .gradio-markdown table, [class*="prose"] table, [class*="markdown"] table { | |
| display: block !important; overflow-x: auto !important; | |
| -webkit-overflow-scrolling: touch !important; font-size: 0.75rem !important; | |
| } | |
| .gradio-markdown th, [class*="prose"] th, [class*="markdown"] th, | |
| .gradio-markdown td, [class*="prose"] td, [class*="markdown"] td { | |
| white-space: nowrap !important; padding: 5px 8px !important; | |
| } | |
| pre { overflow-x: auto !important; white-space: pre-wrap !important; | |
| word-break: break-all !important; font-size: 0.72rem !important; max-width: 100% !important; } | |
| code { font-size: 0.72rem !important; word-break: break-all !important; } | |
| .game-btn { font-size: 0.78rem !important; padding: 8px 4px !important; | |
| white-space: normal !important; word-break: break-word !important; line-height: 1.35 !important; } | |
| h1 { font-size: 1.15rem !important; } | |
| h2 { font-size: 1.0rem !important; } | |
| h3 { font-size: 0.9rem !important; } | |
| .tab-nav button, [role="tablist"] button { font-size: 0.72rem !important; padding: 6px 10px !important; } | |
| .gradio-dropdown, .gradio-radio, select { width: 100% !important; } | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| .gradio-markdown p, .gradio-markdown li, .gradio-markdown td, .gradio-markdown th, | |
| [class*="prose"] p, [class*="prose"] li, | |
| [class*="markdown"] p, [class*="markdown"] li, | |
| [class*="markdown"] td, [class*="markdown"] th { | |
| color: var(--body-text-color, #d1d5db) !important; | |
| } | |
| blockquote { border-left-color: #6366f1 !important; color: var(--body-text-color, #d1d5db) !important; } | |
| pre, code { background-color: rgba(255,255,255,0.07) !important; color: #e5e7eb !important; } | |
| } | |
| """ | |
| # ══════════════════════════════════════════════════════════════ | |
| # UI | |
| # ══════════════════════════════════════════════════════════════ | |
| with gr.Blocks(title="豬隻黴漿菌健康分類系統", css=css) as demo: | |
| spaths_state = gr.State([]) | |
| gr.HTML(""" | |
| <div style="text-align:center;padding:1rem 0 0.4rem;max-width:100%;overflow:hidden"> | |
| <h1 style="font-size:clamp(1.1rem,5vw,1.75rem);font-weight:700;margin:0; | |
| overflow-wrap:break-word;word-break:break-word"> | |
| 豬隻黴漿菌健康分類系統 | |
| </h1> | |
| <p style="color:#6b7280;margin:0.3rem 0 0; | |
| font-size:clamp(0.7rem,3vw,0.9rem); | |
| overflow-wrap:break-word;word-break:break-word;line-height:1.6"> | |
| YOLOv8x-cls · NML 無黴漿菌 / SCK 黴漿菌感染<br> | |
| Top-1 Accuracy <strong>97.22%</strong> · 56M 參數 | |
| </p> | |
| </div> | |
| """) | |
| with gr.Tabs(): | |
| # ══ Tab 1: 管理指南 ════════════════════════════════════ | |
| with gr.Tab("管理指南"): | |
| gr.Markdown( | |
| "# 豬黴漿菌感染(*Mycoplasma hyopneumoniae*)健康管理指南\n" | |
| "> 本資訊供豬場營運者及獸醫師專業參考,實際診斷與治療請諮詢執業獸醫師。" | |
| ) | |
| gr.Markdown( | |
| "## 影像判斷基準與視覺特徵\n" | |
| "本模型以**豬肺臟腹側及背側影像**為輸入," | |
| "學習區分無黴漿菌感染(NML)與黴漿菌感染(SCK)的肺部病變特徵。" | |
| ) | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=1, min_width=200): | |
| gr.Markdown( | |
| "### NML — 無黴漿菌感染肺臟\n" | |
| "**外觀特徵**\n" | |
| "- 整體顏色均勻,呈**淡粉紅色至紅色**\n" | |
| "- 肺葉表面**光滑平整**,無明顯凹陷或硬塊\n" | |
| "- 各葉間邊界清晰,質地柔軟有彈性\n" | |
| "- 無異常滲出液、無纖維素沉積\n\n" | |
| "**關鍵判讀指標**\n" | |
| "- 無灰白色或紫紅色實質化區域\n" | |
| "- 無胸膜黏連或增厚\n" | |
| "- 無壞死斑\n" | |
| ) | |
| t4_nml_img = gr.Image(type="pil", label="無黴漿菌感染代表圖(NML)", | |
| interactive=False, height=240) | |
| with gr.Column(scale=1, min_width=200): | |
| gr.Markdown( | |
| "### SCK — 黴漿菌感染肺臟\n" | |
| "**外觀特徵**\n" | |
| "- 腹葉出現**對稱性灰紅色至紫紅色實質化病灶**\n" | |
| "- 病灶區域質地**增硬**(肝變化),失去正常彈性\n" | |
| "- 病灶邊界較清楚,呈「**地圖狀**」分布\n" | |
| "- 嚴重時病灶可蔓延至心葉與膈葉\n\n" | |
| "**關鍵判讀指標**\n" | |
| "- 腹葉腹側出現灰色或暗紅色實質化\n" | |
| "- 受損面積可佔整體肺臟 5-60%\n" | |
| "- 可能伴隨肺葉間及胸膜輕度纖維素沉積\n" | |
| ) | |
| t4_sck_img = gr.Image(type="pil", label="黴漿菌感染代表圖(SCK)", | |
| interactive=False, height=240) | |
| gr.Markdown( | |
| "> **判讀提示**:模型對腹葉腹側的灰色實質化區域最為敏感。" | |
| "若病灶較輕微(小於 5% 肺面積),模型信心指數可能接近 50%," | |
| "建議結合臨床症狀綜合判斷。\n\n---" | |
| ) | |
| gr.Markdown(SCK_KNOWLEDGE) | |
| # ══ Tab 2: 模型資訊 ════════════════════════════════════ | |
| with gr.Tab("模型資訊"): | |
| gr.Markdown("# 模型特性與性能說明") | |
| gr.Markdown(_MI_ARCH_MAIN) | |
| with gr.Accordion("本段名詞解釋", open=False): | |
| gr.Markdown(_MI_ARCH_GLOSS) | |
| gr.Markdown(_MI_PRE_MAIN) | |
| with gr.Accordion("本段名詞解釋", open=False): | |
| gr.Markdown(_MI_PRE_GLOSS) | |
| gr.Markdown(_MI_TRAIN_MAIN) | |
| with gr.Accordion("本段名詞解釋", open=False): | |
| gr.Markdown(_MI_TRAIN_GLOSS) | |
| gr.Markdown(_MI_PERF_MAIN) | |
| with gr.Accordion("本段名詞解釋", open=False): | |
| gr.Markdown(_MI_PERF_GLOSS) | |
| gr.Markdown(_MI_VERSION) | |
| # ══ Tab 3: 圖片分析 ════════════════════════════════════ | |
| with gr.Tab("圖片分析"): | |
| gr.Markdown("上傳圖片後按「開始分析」,系統將回傳分類結果與各類別機率。") | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=1, min_width=200): | |
| t1_img = gr.Image(type="pil", label="上傳圖片", height=300) | |
| t1_btn = gr.Button("開始分析", variant="primary", size="lg") | |
| with gr.Column(scale=1, min_width=200): | |
| t1_out = gr.Markdown("請在左側上傳圖片") | |
| t1_btn.click(tab1_analyze, t1_img, t1_out) | |
| # ══ Tab 4: 圖片庫 ══════════════════════════════════════ | |
| with gr.Tab("圖片庫"): | |
| gr.Markdown("點擊縮圖或按「隨機選一張」,系統將立即進行分析並顯示判斷結果與真值驗證。") | |
| t2_status = gr.Markdown() | |
| t2_rand_btn = gr.Button("隨機選一張並分析", variant="primary", scale=0) | |
| t2_gallery = gr.Gallery(label=None, show_label=False, | |
| columns=6, height=200, object_fit="cover", allow_preview=False) | |
| gr.Markdown("---") | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=1, min_width=200): | |
| t2_sel = gr.Image(type="pil", label="選取圖片", height=260, visible=False) | |
| with gr.Column(scale=1, min_width=200): | |
| t2_out = gr.Markdown("從上方點擊縮圖或按「隨機選一張並分析」") | |
| t2_rand_btn.click( | |
| tab2_random_and_analyze, | |
| spaths_state, | |
| [t2_sel, t2_out, t2_status] | |
| ).then(lambda: gr.update(visible=True), outputs=t2_sel) | |
| t2_gallery.select( | |
| tab2_select_and_analyze, | |
| spaths_state, | |
| [t2_sel, t2_out] | |
| ).then(lambda: gr.update(visible=True), outputs=t2_sel) | |
| # ══ Tab 5: 人機挑戰 ════════════════════════════════════ | |
| with gr.Tab("人機挑戰"): | |
| gs_state = gr.State(dict(INIT_GAME)) | |
| profile_state = gr.State({}) | |
| gr.HTML(GAME_JS) | |
| with gr.Accordion("使用者背景(開始前必填)", open=True): | |
| gr.Markdown("填寫後按「確認」,系統將連同遊戲成果一起自動記錄於 CSV。") | |
| with gr.Row(): | |
| t3_age = gr.Dropdown(label="年齡範圍", scale=1, | |
| choices=["18歲以下", "18-21歲","22-25歲","26-29歲","30-34歲","35-39歲","40-44歲","45-49歲","50歲以上"]) | |
| t3_bg = gr.Dropdown(label="職業背景", scale=2, | |
| choices=["一般民眾","豬場工作人員(非獸醫師)","畜牧相關研究人員", | |
| "病理/豬病獸醫師","其他動物別獸醫師","獸醫系學生","其他"]) | |
| with gr.Row(): | |
| t3_pig = gr.Dropdown(label="是否參與過豬隻解剖", choices=["有","無"], scale=1) | |
| t3_path = gr.Dropdown(label="修習過動物病理學/豬病學課程", choices=["是","否"], scale=1) | |
| with gr.Row(): | |
| with gr.Column(scale=1, min_width=160): | |
| t3_prof_btn = gr.Button("確認背景資料", variant="primary") | |
| with gr.Column(scale=3): | |
| t3_prof_msg = gr.Markdown() | |
| gr.Markdown("---") | |
| gr.Markdown( | |
| "### 挑戰規則\n" | |
| "選擇回合數後按「開始挑戰」,系統將從圖庫中完全隨機抽取指定數量的圖片," | |
| "每張計時作答。完成後自動顯示受試者與 AI 的正確率、敏感度、特異度及逐回合明細。" | |
| ) | |
| t3_prog = gr.Markdown(prog_md(0)) | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=3, min_width=200): | |
| t3_img = gr.Image(type="pil", label=None, show_label=False, | |
| height=300, elem_id="game_img") | |
| with gr.Column(scale=2, min_width=200): | |
| t3_prompt = gr.Markdown("填寫背景資料後,選擇回合數並按「開始挑戰」") | |
| with gr.Row(): | |
| t3_nml = gr.Button("無黴漿菌(NML)", variant="secondary", | |
| interactive=False, size="lg", scale=1, | |
| elem_classes=["game-btn"]) | |
| t3_sck = gr.Button("黴漿菌感染(SCK)", variant="secondary", | |
| interactive=False, size="lg", scale=1, | |
| elem_classes=["game-btn"]) | |
| t3_rounds = gr.Radio(label="回合數選擇", choices=ROUND_OPTS, value=GAME_ROUNDS, | |
| info="完全隨機抽取,NML / SCK 比例不固定") | |
| with gr.Row(): | |
| t3_start = gr.Button("開始挑戰", variant="primary", scale=1) | |
| t3_reset = gr.Button("重置", scale=1) | |
| t3_results = gr.HTML("完成挑戰後,比較結果將顯示於此") | |
| gr.Markdown("---") | |
| gr.Markdown("### 回合圖片檢視") | |
| gr.Markdown("遊戲結束後,可從下拉選單選擇回合查看對應圖片與判斷詳情。") | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=1, min_width=200): | |
| t3_round_sel = gr.Dropdown(label="選擇回合", choices=[], interactive=False) | |
| t3_round_info = gr.Markdown() | |
| with gr.Column(scale=2, min_width=200): | |
| t3_round_img = gr.Image(type="pil", label="回合圖片", height=260, interactive=False) | |
| gr.Markdown("---") | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| t3_save_msg = gr.Markdown() | |
| with gr.Column(scale=1, min_width=160): | |
| t3_dl = gr.File(label="下載 CSV", visible=False) | |
| GOUT = [gs_state, t3_img, t3_prompt, t3_prog, | |
| t3_results, t3_nml, t3_sck, t3_dl, t3_save_msg, t3_round_sel] | |
| PREP_OUT = [t3_img, t3_prompt, t3_nml, t3_sck] | |
| t3_prof_btn.click( | |
| profile_submit, | |
| [t3_age, t3_bg, t3_pig, t3_path], | |
| [profile_state, t3_prof_msg] | |
| ) | |
| t3_start.click( | |
| game_start, | |
| [spaths_state, profile_state, gs_state, t3_rounds], | |
| GOUT | |
| ) | |
| t3_nml.click(game_answer_prepare, inputs=[], outputs=PREP_OUT).then( | |
| lambda gs, p: game_answer("NML", gs, p), | |
| inputs=[gs_state, profile_state], outputs=GOUT | |
| ) | |
| t3_sck.click(game_answer_prepare, inputs=[], outputs=PREP_OUT).then( | |
| lambda gs, p: game_answer("SCK", gs, p), | |
| inputs=[gs_state, profile_state], outputs=GOUT | |
| ) | |
| t3_reset.click(game_reset, gs_state, GOUT) | |
| t3_dl.change(lambda p: gr.update(visible=p is not None), t3_dl, t3_dl) | |
| t3_round_sel.change(game_round_image, [gs_state, t3_round_sel], | |
| [t3_round_img, t3_round_info]) | |
| demo.load(tab2_load, outputs=[t2_gallery, t2_status, spaths_state]) | |
| demo.load(get_representative_images, outputs=[t4_nml_img, t4_sck_img]) | |
| if __name__ == "__main__": | |
| demo.launch(theme=gr.themes.Soft(), css=css) |