Mycoplasma / app.py
LiweiL's picture
V29.06
32a793a verified
Raw
History Blame Contribute Delete
54.2 kB
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 &nbsp;·&nbsp; NML 無黴漿菌 / SCK 黴漿菌感染<br>
Top-1 Accuracy <strong>97.22%</strong> &nbsp;·&nbsp; 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)