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