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 = """ """ # ══════════════════════════════════════════════════════════════ # 知識庫 # ══════════════════════════════════════════════════════════════ 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"{c}" for c in headers) body = "" for row in rows: cells = "".join(f"{c}" for c in row) body += f"{cells}" return ( "
" f"" f"{h}{body}
" ) 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"{u_acc:.0%}({u_cor}/{nl})", f"{a_acc:.0%}({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"{r['user']} {u_ok}", f"{r['ai']} {a_ok}", f"{r['u_t']:.2f}", f"{r['a_t']/1000:.3f}", ]) detail_tbl = _tbl(["回合", "真值", "受試者", "AI", "受試者(秒)", "AI(秒)"], detail_rows) return ( f"

挑戰完成 結果:{winner}

" f"

總結比較

{summary_tbl}" f"

逐回合明細

{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("""

豬隻黴漿菌健康分類系統

YOLOv8x-cls  ·  NML 無黴漿菌 / SCK 黴漿菌感染
Top-1 Accuracy 97.22%  ·  56M 參數

""") 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)