Spaces:
Build error
Build error
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
| 1 |
# app.py — 無菌手套姿勢檢測系統(F-segment 偵測)複賽正式版
|
| 2 |
# 說明:
|
| 3 |
-
#
|
| 4 |
-
#
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
-
#
|
| 8 |
-
#
|
| 9 |
-
# 4) 三個可展開的說明面板(標註資訊/系統限制/未來改善)
|
| 10 |
|
| 11 |
import os
|
| 12 |
import cv2
|
|
@@ -16,7 +15,7 @@ import tempfile
|
|
| 16 |
import gradio as gr
|
| 17 |
|
| 18 |
# -----------------------------------------------------------------------------
|
| 19 |
-
# Mediapipe 相關設定(若無法匯入,則自動降級為僅
|
| 20 |
# -----------------------------------------------------------------------------
|
| 21 |
_HAS_MP = True
|
| 22 |
_HAS_POSE = True
|
|
@@ -38,7 +37,7 @@ MAX_LONG_SIDE = 960 # 影像長邊縮放上限(避免太大)
|
|
| 38 |
DEFAULT_WAIST_RATIO = 0.65
|
| 39 |
CLAMP_LOW = 0.58 # 自動腰線 y 下限
|
| 40 |
CLAMP_HIGH = 0.72 # 自動腰線 y 上限
|
| 41 |
-
WAIST_DELTA_Y = -0.05 #
|
| 42 |
|
| 43 |
# 判定門檻(本次複賽設定)
|
| 44 |
PASS_TH_POS = 50 # 腰部門檻 (%)
|
|
@@ -58,7 +57,7 @@ COLS = [
|
|
| 58 |
# 工具函式
|
| 59 |
# -----------------------------------------------------------------------------
|
| 60 |
def _to_path(f):
|
| 61 |
-
"""
|
| 62 |
if isinstance(f, str):
|
| 63 |
return f
|
| 64 |
if hasattr(f, "name"):
|
|
@@ -82,8 +81,8 @@ def _resize_keep_ar(frame, max_long=MAX_LONG_SIDE):
|
|
| 82 |
def _is_index_up(lms, h):
|
| 83 |
"""
|
| 84 |
判斷單手的食指是否「明顯朝上」:
|
| 85 |
-
- 利用 PIP→TIP 向量的角度
|
| 86 |
-
- 並考慮 tip 與 wrist 的高度差
|
| 87 |
"""
|
| 88 |
tip = lms[mp_hands.HandLandmark.INDEX_FINGER_TIP.value]
|
| 89 |
pip = lms[mp_hands.HandLandmark.INDEX_FINGER_PIP.value]
|
|
@@ -92,9 +91,8 @@ def _is_index_up(lms, h):
|
|
| 92 |
dy = (pip.y - tip.y) * h
|
| 93 |
dx = abs(pip.x - tip.x) * h
|
| 94 |
angle_deg = np.degrees(np.arctan2(dy, dx))
|
| 95 |
-
height_diff = (wrist.y - tip.y) * h
|
| 96 |
|
| 97 |
-
# 角度大於 25 度,且 tip 明顯高於 wrist,才算「朝上」
|
| 98 |
return (angle_deg > 25) and (height_diff > 40)
|
| 99 |
|
| 100 |
# -----------------------------------------------------------------------------
|
|
@@ -135,7 +133,6 @@ def _auto_waist_ratio_pose(video_path, sample_frames=40,
|
|
| 135 |
res = pose.process(rgb)
|
| 136 |
if res.pose_landmarks:
|
| 137 |
lms = res.pose_landmarks.landmark
|
| 138 |
-
# 23 / 24 為左右 hip
|
| 139 |
hip_y = (lms[23].y + lms[24].y) / 2.0
|
| 140 |
ys.append(hip_y)
|
| 141 |
idx += step
|
|
@@ -271,9 +268,8 @@ def analyze_one_video(video_path):
|
|
| 271 |
for lmset in res.multi_hand_landmarks:
|
| 272 |
lms = lmset.landmark
|
| 273 |
wrist = lms[mp_hands.HandLandmark.WRIST.value]
|
| 274 |
-
pos_flags.append((wrist.y * h) <= y_ref)
|
| 275 |
-
up_flags.append(_is_index_up(lms, h))
|
| 276 |
-
|
| 277 |
# 要求雙手皆符合(若只偵測到一手,就以那一手為準)
|
| 278 |
if len(pos_flags) >= 2:
|
| 279 |
this_pos = all(pos_flags[:2])
|
|
@@ -304,7 +300,11 @@ def analyze_one_video(video_path):
|
|
| 304 |
acc = (ok_both / valid) * 100.0
|
| 305 |
passed_valid = f"{ok_both}/{valid}"
|
| 306 |
|
| 307 |
-
result_str =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
valid_total = f"{valid}/{total_frames}"
|
| 309 |
|
| 310 |
return [
|
|
@@ -318,7 +318,7 @@ def analyze_one_video(video_path):
|
|
| 318 |
# -----------------------------------------------------------------------------
|
| 319 |
# 多支影片分析核心 + 統整摘要
|
| 320 |
# -----------------------------------------------------------------------------
|
| 321 |
-
def run_core(files, state)
|
| 322 |
"""實際進行分析並回傳 df 和 CSV 路徑。"""
|
| 323 |
if not files:
|
| 324 |
df = pd.DataFrame([], columns=COLS)
|
|
@@ -326,18 +326,11 @@ def run_core(files, state)):
|
|
| 326 |
return None, df, [], tmp_path
|
| 327 |
|
| 328 |
rows = []
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
for i, f in enumerate(files):
|
| 332 |
-
if total > 0:
|
| 333 |
-
|
| 334 |
path = _to_path(f)
|
| 335 |
if os.path.exists(path):
|
| 336 |
rows.append(analyze_one_video(path))
|
| 337 |
|
| 338 |
-
# 分析全部完成
|
| 339 |
-
|
| 340 |
-
|
| 341 |
state_new = (state or []) + rows
|
| 342 |
df = pd.DataFrame(state_new, columns=COLS)
|
| 343 |
|
|
@@ -376,13 +369,15 @@ def build_summary_text(df: pd.DataFrame) -> str:
|
|
| 376 |
return text
|
| 377 |
|
| 378 |
|
| 379 |
-
def run_with_status(files, state)
|
| 380 |
"""供按鈕呼叫:更新狀態列 + DataFrame + CSV + 摘要。"""
|
|
|
|
| 381 |
status_text = "🟠 系統正在分析影片,請稍候…"
|
| 382 |
|
| 383 |
files2, df, state2, csv_path = run_core(files, state)
|
| 384 |
summary_text = build_summary_text(df)
|
| 385 |
|
|
|
|
| 386 |
status_text = "🟢 分析完成,可以檢視表格並下載 CSV 報表。"
|
| 387 |
|
| 388 |
return status_text, files2, df, state2, csv_path, summary_text
|
|
@@ -398,7 +393,7 @@ def do_clear(state):
|
|
| 398 |
# Gradio UI 建構
|
| 399 |
# -----------------------------------------------------------------------------
|
| 400 |
with gr.Blocks(
|
| 401 |
-
title="
|
| 402 |
css="""
|
| 403 |
#status-text {
|
| 404 |
color: #1565c0; /* 深藍色狀態列 */
|
|
@@ -408,7 +403,7 @@ with gr.Blocks(
|
|
| 408 |
"""
|
| 409 |
) as demo:
|
| 410 |
|
| 411 |
-
#
|
| 412 |
gr.Markdown("# 👑 醫療技術AI王")
|
| 413 |
gr.Markdown("## 🧤 無菌手套姿勢檢測系統(F-segment 偵測)")
|
| 414 |
|
|
@@ -427,7 +422,7 @@ with gr.Blocks(
|
|
| 427 |
# 按鈕列
|
| 428 |
with gr.Row():
|
| 429 |
btn_run = gr.Button("分析", variant="primary", scale=1)
|
| 430 |
-
btn_csv = gr.DownloadButton("下載 CSV 報表",
|
| 431 |
btn_clear = gr.Button("清除", variant="secondary", scale=1)
|
| 432 |
|
| 433 |
# 狀態儲存
|
|
@@ -447,7 +442,7 @@ with gr.Blocks(
|
|
| 447 |
# 額外說明面板(可收合)
|
| 448 |
with gr.Accordion("📘 本次標註資訊(可展開)", open=False):
|
| 449 |
gr.Markdown("""
|
| 450 |
-
### 🔍 本次標
|
| 451 |
- **標準片(應通過)**:FV3、FV4、FV5、FV6
|
| 452 |
- **錯片(應不通過)**:WFV6-2、WFV6
|
| 453 |
|
|
@@ -476,7 +471,7 @@ with gr.Blocks(
|
|
| 476 |
- 若資料量充足,可進一步訓練專用的 F-segment 深度學習模型。
|
| 477 |
""")
|
| 478 |
|
| 479 |
-
# 事件綁定:
|
| 480 |
btn_run.click(
|
| 481 |
fn=run_with_status,
|
| 482 |
inputs=[files, table_state],
|
|
@@ -492,7 +487,7 @@ with gr.Blocks(
|
|
| 492 |
)
|
| 493 |
|
| 494 |
# -----------------------------------------------------------------------------
|
| 495 |
-
# 啟動
|
| 496 |
# -----------------------------------------------------------------------------
|
| 497 |
if __name__ == "__main__":
|
| 498 |
demo.launch(server_name="0.0.0.0", server_port=7860)
|
|
|
|
| 1 |
# app.py — 無菌手套姿勢檢測系統(F-segment 偵測)複賽正式版
|
| 2 |
# 說明:
|
| 3 |
+
# 1) 結合 Mediapipe Pose + Hands / 自動腰線偵測,並檢測雙手「腰部以上」與「指尖朝上」比例
|
| 4 |
+
# 2) AND 規則:腰部以上比例 >= PASS_TH_POS 且 指尖朝上比例 >= PASS_TH_UP 才算通過
|
| 5 |
+
# 3) 本版 UI 採「正式評圖版」精簡介面:
|
| 6 |
+
# a. 上傳影片區塊
|
| 7 |
+
# b. 統整結果表 + CSV 下載
|
| 8 |
+
# c. 三個可展開的說明區塊(標註資訊 / 系統限制 / 未來改善)
|
|
|
|
| 9 |
|
| 10 |
import os
|
| 11 |
import cv2
|
|
|
|
| 15 |
import gradio as gr
|
| 16 |
|
| 17 |
# -----------------------------------------------------------------------------
|
| 18 |
+
# Mediapipe 相關設定(若無法匯入,則自動降級為僅顯示錯誤)
|
| 19 |
# -----------------------------------------------------------------------------
|
| 20 |
_HAS_MP = True
|
| 21 |
_HAS_POSE = True
|
|
|
|
| 37 |
DEFAULT_WAIST_RATIO = 0.65
|
| 38 |
CLAMP_LOW = 0.58 # 自動腰線 y 下限
|
| 39 |
CLAMP_HIGH = 0.72 # 自動腰線 y 上限
|
| 40 |
+
WAIST_DELTA_Y = -0.05 # 自動腰線 Δy 微調(負值代表往上移)
|
| 41 |
|
| 42 |
# 判定門檻(本次複賽設定)
|
| 43 |
PASS_TH_POS = 50 # 腰部門檻 (%)
|
|
|
|
| 57 |
# 工具函式
|
| 58 |
# -----------------------------------------------------------------------------
|
| 59 |
def _to_path(f):
|
| 60 |
+
"""把 gr.File / dict / 字串 轉成實際檔案路徑。"""
|
| 61 |
if isinstance(f, str):
|
| 62 |
return f
|
| 63 |
if hasattr(f, "name"):
|
|
|
|
| 81 |
def _is_index_up(lms, h):
|
| 82 |
"""
|
| 83 |
判斷單手的食指是否「明顯朝上」:
|
| 84 |
+
- 利用 PIP→TIP 向量的角度
|
| 85 |
+
- 並考慮 tip 與 wrist 的高度差
|
| 86 |
"""
|
| 87 |
tip = lms[mp_hands.HandLandmark.INDEX_FINGER_TIP.value]
|
| 88 |
pip = lms[mp_hands.HandLandmark.INDEX_FINGER_PIP.value]
|
|
|
|
| 91 |
dy = (pip.y - tip.y) * h
|
| 92 |
dx = abs(pip.x - tip.x) * h
|
| 93 |
angle_deg = np.degrees(np.arctan2(dy, dx))
|
| 94 |
+
height_diff = (wrist.y - tip.y) * h
|
| 95 |
|
|
|
|
| 96 |
return (angle_deg > 25) and (height_diff > 40)
|
| 97 |
|
| 98 |
# -----------------------------------------------------------------------------
|
|
|
|
| 133 |
res = pose.process(rgb)
|
| 134 |
if res.pose_landmarks:
|
| 135 |
lms = res.pose_landmarks.landmark
|
|
|
|
| 136 |
hip_y = (lms[23].y + lms[24].y) / 2.0
|
| 137 |
ys.append(hip_y)
|
| 138 |
idx += step
|
|
|
|
| 268 |
for lmset in res.multi_hand_landmarks:
|
| 269 |
lms = lmset.landmark
|
| 270 |
wrist = lms[mp_hands.HandLandmark.WRIST.value]
|
| 271 |
+
pos_flags.append((wrist.y * h) <= y_ref)
|
| 272 |
+
up_flags.append(_is_index_up(lms, h))
|
|
|
|
| 273 |
# 要求雙手皆符合(若只偵測到一手,就以那一手為準)
|
| 274 |
if len(pos_flags) >= 2:
|
| 275 |
this_pos = all(pos_flags[:2])
|
|
|
|
| 300 |
acc = (ok_both / valid) * 100.0
|
| 301 |
passed_valid = f"{ok_both}/{valid}"
|
| 302 |
|
| 303 |
+
result_str = (
|
| 304 |
+
"通過"
|
| 305 |
+
if (above_ratio >= PASS_TH_POS and up_ratio >= PASS_TH_UP)
|
| 306 |
+
else "未通過"
|
| 307 |
+
)
|
| 308 |
valid_total = f"{valid}/{total_frames}"
|
| 309 |
|
| 310 |
return [
|
|
|
|
| 318 |
# -----------------------------------------------------------------------------
|
| 319 |
# 多支影片分析核心 + 統整摘要
|
| 320 |
# -----------------------------------------------------------------------------
|
| 321 |
+
def run_core(files, state):
|
| 322 |
"""實際進行分析並回傳 df 和 CSV 路徑。"""
|
| 323 |
if not files:
|
| 324 |
df = pd.DataFrame([], columns=COLS)
|
|
|
|
| 326 |
return None, df, [], tmp_path
|
| 327 |
|
| 328 |
rows = []
|
| 329 |
+
for f in files:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
path = _to_path(f)
|
| 331 |
if os.path.exists(path):
|
| 332 |
rows.append(analyze_one_video(path))
|
| 333 |
|
|
|
|
|
|
|
|
|
|
| 334 |
state_new = (state or []) + rows
|
| 335 |
df = pd.DataFrame(state_new, columns=COLS)
|
| 336 |
|
|
|
|
| 369 |
return text
|
| 370 |
|
| 371 |
|
| 372 |
+
def run_with_status(files, state):
|
| 373 |
"""供按鈕呼叫:更新狀態列 + DataFrame + CSV + 摘要。"""
|
| 374 |
+
# 進入分析狀態
|
| 375 |
status_text = "🟠 系統正在分析影片,請稍候…"
|
| 376 |
|
| 377 |
files2, df, state2, csv_path = run_core(files, state)
|
| 378 |
summary_text = build_summary_text(df)
|
| 379 |
|
| 380 |
+
# 分析完成狀態
|
| 381 |
status_text = "🟢 分析完成,可以檢視表格並下載 CSV 報表。"
|
| 382 |
|
| 383 |
return status_text, files2, df, state2, csv_path, summary_text
|
|
|
|
| 393 |
# Gradio UI 建構
|
| 394 |
# -----------------------------------------------------------------------------
|
| 395 |
with gr.Blocks(
|
| 396 |
+
title="無菌手套姿勢檢測系統(F-segment 偵測)",
|
| 397 |
css="""
|
| 398 |
#status-text {
|
| 399 |
color: #1565c0; /* 深藍色狀態列 */
|
|
|
|
| 403 |
"""
|
| 404 |
) as demo:
|
| 405 |
|
| 406 |
+
# 標題
|
| 407 |
gr.Markdown("# 👑 醫療技術AI王")
|
| 408 |
gr.Markdown("## 🧤 無菌手套姿勢檢測系統(F-segment 偵測)")
|
| 409 |
|
|
|
|
| 422 |
# 按鈕列
|
| 423 |
with gr.Row():
|
| 424 |
btn_run = gr.Button("分析", variant="primary", scale=1)
|
| 425 |
+
btn_csv = gr.DownloadButton("下載 CSV 報表", visible=False, scale=1)
|
| 426 |
btn_clear = gr.Button("清除", variant="secondary", scale=1)
|
| 427 |
|
| 428 |
# 狀態儲存
|
|
|
|
| 442 |
# 額外說明面板(可收合)
|
| 443 |
with gr.Accordion("📘 本次標註資訊(可展開)", open=False):
|
| 444 |
gr.Markdown("""
|
| 445 |
+
### 🔍 本次標註
|
| 446 |
- **標準片(應通過)**:FV3、FV4、FV5、FV6
|
| 447 |
- **錯片(應不通過)**:WFV6-2、WFV6
|
| 448 |
|
|
|
|
| 471 |
- 若資料量充足,可進一步訓練專用的 F-segment 深度學習模型。
|
| 472 |
""")
|
| 473 |
|
| 474 |
+
# 事件綁定:顯示 Gradio 內建 minimal 進度條即可
|
| 475 |
btn_run.click(
|
| 476 |
fn=run_with_status,
|
| 477 |
inputs=[files, table_state],
|
|
|
|
| 487 |
)
|
| 488 |
|
| 489 |
# -----------------------------------------------------------------------------
|
| 490 |
+
# 啟動(本地測試用;在 HuggingFace 會由 Spaces 自動呼叫 demo)
|
| 491 |
# -----------------------------------------------------------------------------
|
| 492 |
if __name__ == "__main__":
|
| 493 |
demo.launch(server_name="0.0.0.0", server_port=7860)
|