File size: 17,235 Bytes
73ad6e3
 
0c9453b
 
 
 
 
 
56b1f30
 
 
 
 
 
 
f514b35
73ad6e3
0c9453b
73ad6e3
f514b35
06821a4
f514b35
 
06821a4
f514b35
73ad6e3
 
f514b35
6f41042
f514b35
73ad6e3
 
 
06821a4
73ad6e3
 
0a2df43
73ad6e3
 
0c9453b
73ad6e3
 
 
 
 
 
 
 
d3ce673
56b1f30
73ad6e3
 
 
56b1f30
4233a6b
73ad6e3
 
 
8920fec
0c9453b
73ad6e3
 
 
 
 
 
8920fec
 
06821a4
73ad6e3
 
468456e
73ad6e3
 
 
 
 
 
 
06821a4
73ad6e3
 
 
0c9453b
 
73ad6e3
 
 
 
 
d3ce673
 
73ad6e3
0c9453b
73ad6e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3ce673
73ad6e3
 
 
 
 
 
 
 
06821a4
 
 
 
 
 
73ad6e3
 
 
6f41042
73ad6e3
 
 
6f41042
 
 
 
73ad6e3
 
 
 
6f41042
73ad6e3
 
4233a6b
73ad6e3
 
56b1f30
06821a4
73ad6e3
 
 
 
 
 
 
 
 
 
4233a6b
7da3e28
73ad6e3
 
 
 
 
 
 
06821a4
 
 
 
 
 
 
73ad6e3
 
 
 
 
 
 
 
 
7da3e28
73ad6e3
 
 
 
 
 
 
4233a6b
73ad6e3
 
 
 
 
 
 
 
 
 
 
 
 
d3ce673
73ad6e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4233a6b
73ad6e3
 
06821a4
 
 
 
 
 
 
56b1f30
73ad6e3
4233a6b
73ad6e3
 
 
 
56b1f30
73ad6e3
 
 
4233a6b
73ad6e3
 
4233a6b
73ad6e3
 
 
0a2df43
73ad6e3
 
 
d3ce673
73ad6e3
 
0c9453b
 
73ad6e3
 
 
 
 
 
 
4233a6b
c04289b
73ad6e3
 
 
 
 
 
 
4233a6b
 
73ad6e3
 
6f41042
73ad6e3
 
 
7da3e28
73ad6e3
 
 
 
6f41042
0c9453b
 
 
 
 
73ad6e3
4233a6b
 
73ad6e3
 
 
 
 
4233a6b
 
73ad6e3
06821a4
73ad6e3
0c9453b
06821a4
6f41042
73ad6e3
 
 
d3ce673
73ad6e3
0c9453b
73ad6e3
 
 
 
 
 
 
 
 
 
 
 
71a246d
73ad6e3
 
 
 
 
 
 
 
 
 
 
 
 
 
06821a4
 
63abf7b
06821a4
 
 
 
 
 
 
 
 
 
63abf7b
 
0c9453b
06821a4
0c9453b
73ad6e3
 
bc37b4b
63abf7b
73ad6e3
0c9453b
63abf7b
73ad6e3
63abf7b
73ad6e3
06821a4
73ad6e3
 
 
 
63abf7b
73ad6e3
 
 
 
 
0c9453b
73ad6e3
 
 
 
 
 
 
 
 
0c9453b
d3ce673
 
6f41042
73ad6e3
 
 
 
 
 
 
 
 
 
 
0a2df43
06821a4
f514b35
73ad6e3
0c9453b
73ad6e3
 
 
 
 
 
 
 
 
 
 
 
4233a6b
73ad6e3
 
 
 
 
 
0c9453b
73ad6e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c9453b
73ad6e3
 
 
 
c10e38f
73ad6e3
6f41042
73ad6e3
 
 
 
 
 
ede07d3
73ad6e3
0c9453b
73ad6e3
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
# app.py — 無菌手套姿勢檢測系統(F-segment 偵測)複賽正式版
# 說明:
# 1) 結合 Mediapipe Pose + Hands / 自動腰線偵測,並檢測雙手「腰部以上」與「指尖朝上」比例
# 2) AND 規則:腰部以上比例 >= PASS_TH_POS 且 指尖朝上比例 >= PASS_TH_UP 才算通過
# 3) 本版 UI 採「正式評圖版」精簡介面:
#    a. 上傳影片區塊
#    b. 統整結果表 + CSV 下載
#    c. 三個可展開的說明區塊(標註資訊 / 系統限制 / 未來改善)

import os
import cv2
import numpy as np
import pandas as pd
import tempfile
import gradio as gr

# -----------------------------------------------------------------------------
# Mediapipe 相關設定(若無法匯入,則自動降級為僅顯示錯誤)
# -----------------------------------------------------------------------------
_HAS_MP = True
_HAS_POSE = True
try:
    import mediapipe as mp

    mp_hands = mp.solutions.hands
    mp_pose = mp.solutions.pose
except Exception:
    _HAS_MP = False
    _HAS_POSE = False

# -----------------------------------------------------------------------------
# 全域參數:可視為本系統的「版本設定」
# -----------------------------------------------------------------------------
MAX_LONG_SIDE = 960  # 影像長邊縮放上限(避免太大)

# 自動腰線預設
DEFAULT_WAIST_RATIO = 0.65
CLAMP_LOW = 0.58       # 自動腰線 y 下限
CLAMP_HIGH = 0.72      # 自動腰線 y 上限
WAIST_DELTA_Y = -0.05  # 自動腰線 Δy 微調(負值代表往上移)

# 判定門檻(本次複賽設定)
PASS_TH_POS = 50       # 腰部門檻 (%)
PASS_TH_UP = 40        # 指尖門檻 (%)

# 其他固定設定
TAIL_MODE = "全片"     # 本版一律分析全片
TAIL_SECONDS = 3.0     # 保留參數(若未來要開啟尾段模式可用)

COLS = [
    "檔案名稱", "檢測結果", "整體正確率(%)", "腰部以上(%)", "指尖朝上(%)",
    "通過幀/有效幀", "有效幀/總幀", "FPS", "長度(秒)",
    "門檻設定", "判斷區間", "腰線 y", "腰線來源"
]

# -----------------------------------------------------------------------------
# 工具函式
# -----------------------------------------------------------------------------
def _to_path(f):
    """把 gr.File / dict / 字串 轉成實際檔案路徑。"""
    if isinstance(f, str):
        return f
    if hasattr(f, "name"):
        return f.name
    if isinstance(f, dict) and "name" in f:
        return f["name"]
    return str(f)


def _resize_keep_ar(frame, max_long=MAX_LONG_SIDE):
    """依長邊等比例縮放,避免影像過大。"""
    h, w = frame.shape[:2]
    long_side = max(h, w)
    if long_side <= max_long:
        return frame
    scale = max_long / long_side
    nh, nw = int(h * scale), int(w * scale)
    return cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_AREA)


def _is_index_up(lms, h):
    """
    判斷單手的食指是否「明顯朝上」:
    - 利用 PIP→TIP 向量的角度
    - 並考慮 tip 與 wrist 的高度差
    """
    tip = lms[mp_hands.HandLandmark.INDEX_FINGER_TIP.value]
    pip = lms[mp_hands.HandLandmark.INDEX_FINGER_PIP.value]
    wrist = lms[mp_hands.HandLandmark.WRIST.value]

    dy = (pip.y - tip.y) * h
    dx = abs(pip.x - tip.x) * h
    angle_deg = np.degrees(np.arctan2(dy, dx))
    height_diff = (wrist.y - tip.y) * h

    return (angle_deg > 25) and (height_diff > 40)

# -----------------------------------------------------------------------------
# 自動腰線估計:Pose / Wrist percentile
# -----------------------------------------------------------------------------
def _auto_waist_ratio_pose(video_path, sample_frames=40,
                           clamp_low=CLAMP_LOW, clamp_high=CLAMP_HIGH):
    """以 Pose hip 平均位置估計腰線比例 y。"""
    if not _HAS_POSE:
        return None

    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        return None

    total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
    if total <= 0:
        cap.release()
        return None

    step = max(1, total // sample_frames)
    ys = []

    with mp_pose.Pose(
        model_complexity=0,
        enable_segmentation=False,
        min_detection_confidence=0.5,
        min_tracking_confidence=0.5
    ) as pose:
        idx = 0
        while idx < total and len(ys) < sample_frames:
            cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
            ok, frame = cap.read()
            if not ok:
                break
            frame = _resize_keep_ar(frame)
            rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            res = pose.process(rgb)
            if res.pose_landmarks:
                lms = res.pose_landmarks.landmark
                hip_y = (lms[23].y + lms[24].y) / 2.0
                ys.append(hip_y)
            idx += step

    cap.release()
    if not ys:
        return None

    p = float(np.median(ys)) + WAIST_DELTA_Y
    return float(min(clamp_high, max(clamp_low, p)))


def _auto_waist_ratio_hand(video_path, sample_frames=40,
                           percentile=85,
                           clamp_low=CLAMP_LOW, clamp_high=CLAMP_HIGH):
    """以手腕高度的分位數估計腰線比例 y(作為 Pose 的備援)。"""
    if not _HAS_MP:
        return DEFAULT_WAIST_RATIO

    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        return DEFAULT_WAIST_RATIO

    total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
    if total <= 0:
        cap.release()
        return DEFAULT_WAIST_RATIO

    step = max(1, total // sample_frames)
    ys = []

    with mp_hands.Hands(
        static_image_mode=False,
        max_num_hands=2,
        model_complexity=0,
        min_detection_confidence=0.5,
        min_tracking_confidence=0.5
    ) as hands:
        idx = 0
        while idx < total and len(ys) < sample_frames:
            cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
            ok, frame = cap.read()
            if not ok:
                break
            frame = _resize_keep_ar(frame)
            rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            res = hands.process(rgb)
            if res.multi_hand_landmarks:
                wrist_ys = [
                    lm.landmark[mp_hands.HandLandmark.WRIST.value].y
                    for lm in res.multi_hand_landmarks
                ]
                if wrist_ys:
                    ys.append(max(wrist_ys))
            idx += step

    cap.release()
    if not ys:
        return DEFAULT_WAIST_RATIO

    p = float(np.percentile(ys, percentile)) + WAIST_DELTA_Y
    return float(min(clamp_high, max(clamp_low, p)))

# -----------------------------------------------------------------------------
# 單一影片分析
# -----------------------------------------------------------------------------
def analyze_one_video(video_path):
    """分析單一影片,回傳一列結果。"""
    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        return [
            os.path.basename(video_path), "未通過",
            0, 0, 0,
            "0/0", "0/0", 0, 0,
            f"{PASS_TH_POS}/{PASS_TH_UP}", "-",
            "-", "開啟失敗"
        ]

    fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
    duration_s = (total_frames / fps) if total_frames > 0 else 0.0

    # 本版設定只分析全片
    start = 0
    span = "全片"
    cap.set(cv2.CAP_PROP_POS_FRAMES, start)

    # 決定腰線位置
    used_y_ratio = DEFAULT_WAIST_RATIO
    waist_source = "預設值"

    # 先嘗試 Pose,自動腰線
    auto_pose = _auto_waist_ratio_pose(video_path)
    if auto_pose is not None:
        used_y_ratio = auto_pose
        waist_source = "Pose"
    else:
        # 若 Pose 不可靠,改採 Wrist percentile
        used_y_ratio = _auto_waist_ratio_hand(video_path)
        waist_source = "手腕分位數"

    hands = None
    if _HAS_MP:
        hands = mp_hands.Hands(
            static_image_mode=False,
            max_num_hands=2,
            model_complexity=0,
            min_detection_confidence=0.5,
            min_tracking_confidence=0.5
        )

    valid = ok_pos = ok_up = ok_both = 0

    while True:
        ok, frame = cap.read()
        if not ok:
            break

        frame = _resize_keep_ar(frame)
        h, w = frame.shape[:2]
        y_ref = int(h * used_y_ratio)

        this_pos = this_up = False
        detected = False

        if _HAS_MP and hands:
            rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            res = hands.process(rgb)
            if res.multi_hand_landmarks:
                detected = True
                pos_flags = []
                up_flags = []
                for lmset in res.multi_hand_landmarks:
                    lms = lmset.landmark
                    wrist = lms[mp_hands.HandLandmark.WRIST.value]
                    pos_flags.append((wrist.y * h) <= y_ref)
                    up_flags.append(_is_index_up(lms, h))
                # 要求雙手皆符合(若只偵測到一手,就以那一手為準)
                if len(pos_flags) >= 2:
                    this_pos = all(pos_flags[:2])
                    this_up = all(up_flags[:2])
                else:
                    this_pos = pos_flags[0]
                    this_up = up_flags[0]

        if detected:
            valid += 1
            if this_pos:
                ok_pos += 1
            if this_up:
                ok_up += 1
            if this_pos and this_up:
                ok_both += 1

    cap.release()
    if hands:
        hands.close()

    if valid == 0:
        above_ratio = up_ratio = acc = 0.0
        passed_valid = "0/0"
    else:
        above_ratio = (ok_pos / valid) * 100.0
        up_ratio = (ok_up / valid) * 100.0
        acc = (ok_both / valid) * 100.0
        passed_valid = f"{ok_both}/{valid}"

    result_str = (
        "通過"
        if (above_ratio >= PASS_TH_POS and up_ratio >= PASS_TH_UP)
        else "未通過"
    )
    valid_total = f"{valid}/{total_frames}"

    return [
        os.path.basename(video_path), result_str,
        round(acc, 2), round(above_ratio, 2), round(up_ratio, 2),
        passed_valid, valid_total, int(round(fps)), round(duration_s, 2),
        f"{int(PASS_TH_POS)}/{int(PASS_TH_UP)}",
        span, round(float(used_y_ratio), 3), waist_source
    ]

# -----------------------------------------------------------------------------
# 多支影片分析核心 + 統整摘要
# -----------------------------------------------------------------------------
def run_core(files, state):
    """實際進行分析並回傳 df 和 CSV 路徑。"""
    if not files:
        df = pd.DataFrame([], columns=COLS)
        tmp_path = None
        return None, df, [], tmp_path

    rows = []
    for f in files:
        path = _to_path(f)
        if os.path.exists(path):
            rows.append(analyze_one_video(path))

    state_new = (state or []) + rows
    df = pd.DataFrame(state_new, columns=COLS)

    tmp = tempfile.NamedTemporaryFile(suffix=".csv", delete=False)
    df.to_csv(tmp.name, index=False, encoding="utf-8-sig")

    return files, df, state_new, tmp.name


def build_summary_text(df: pd.DataFrame) -> str:
    """產生較完整的統整摘要。"""
    if df is None or df.empty:
        return "尚未進行分析。"

    total = len(df)
    passed = (df["檢測結果"] == "通過").sum()
    failed = total - passed
    pass_rate = (passed / total * 100.0) if total > 0 else 0.0

    mean_acc = df["整體正確率(%)"].mean()
    mean_pos = df["腰部以上(%)"].mean()
    mean_up = df["指尖朝上(%)"].mean()

    best_row = df.loc[df["整體正確率(%)"].idxmax()]
    worst_row = df.loc[df["整體正確率(%)"].idxmin()]

    text = f"""### 📌 統整摘要
- 本次分析 **{total} 支影片**,其中 **{passed} 支通過、{failed} 支未通過**(通過率約 **{pass_rate:.1f}%**)。
- 平均整體正確率:約 **{mean_acc:.1f}%**。
- 平均腰部以上比例:約 **{mean_pos:.1f}%**;平均指尖朝上比例:約 **{mean_up:.1f}%**。
- 最佳表現影片:**{best_row['檔案名稱']}**(整體正確率 **{best_row['整體正確率(%)']:.1f}%**)。
- 需要特別留意的影片:**{worst_row['檔案名稱']}**(整體正確率 **{worst_row['整體正確率(%)']:.1f}%**)。
- 目前門檻設定:**腰部門檻 {PASS_TH_POS:.0f}%、指尖門檻 {PASS_TH_UP:.0f}%**,採 **AND 規則**(兩者皆需達標)。
- 腰線來源:優先使用 **Pose 腰點中位數自動估計**,必要時改採 **手腕高度分位數** 作為備援,並限制在安全範圍內。
"""
    return text


def run_with_status(files, state):
    """供按鈕呼叫:更新狀態列 + DataFrame + CSV + 摘要。"""
    # 進入分析狀態
    status_text = "🟠 系統正在分析影片,請稍候…"

    files2, df, state2, csv_path = run_core(files, state)
    summary_text = build_summary_text(df)

    # 分析完成狀態
    status_text = "🟢 分析完成,可以檢視表格並下載 CSV 報表。"

    return status_text, files2, df, state2, csv_path, summary_text


def do_clear(state):
    """清除按鈕:重置所有顯示。"""
    df = pd.DataFrame([], columns=COLS)
    status_text = "🟢 系統就緒,請上傳影片後按下「分析」。"
    return status_text, None, df, [], None, "尚未進行分析。"

# -----------------------------------------------------------------------------
# Gradio UI 建構
# -----------------------------------------------------------------------------
with gr.Blocks(
    title="無菌手套姿勢檢測系統(F-segment 偵測)",
    css="""
    #status-text {
        color: #1565c0;      /* 深藍色狀態列 */
        font-weight: 600;
        padding: 4px 0;
    }
    """
) as demo:

    # 標題
    gr.Markdown("# 👑 醫療技術AI王")
    gr.Markdown("## 🧤 無菌手套姿勢檢測系統(F-segment 偵測)")

    status_md = gr.Markdown(
        "🟢 系統就緒,請上傳影片後按下「分析」。",
        elem_id="status-text"
    )

    # 上傳區
    files = gr.File(
        label="上傳檢測影片(mp4,可多支)",
        file_types=[".mp4"],
        file_count="multiple"
    )

    # 按鈕列
    with gr.Row():
        btn_run = gr.Button("分析", variant="primary", scale=1)
        btn_csv = gr.DownloadButton("下載 CSV 報表", visible=False, scale=1)
        btn_clear = gr.Button("清除", variant="secondary", scale=1)

    # 狀態儲存
    table_state = gr.State([])

    # 結果表格
    table = gr.Dataframe(
        headers=COLS,
        label="📊 手套穿戴通過率彙整表",
        interactive=False,
        wrap=True
    )

    # 統整摘要
    summary = gr.Markdown("尚未進行分析。")

    # 額外說明面板(可收合)
    with gr.Accordion("📘 本次標註資訊(可展開)", open=False):
        gr.Markdown("""
### 🔍 本次標註
- **標準片(應通過)**:FV3、FV4、FV5、FV6  
- **錯片(應不通過)**:WFV6-2、WFV6  

### 🟡 邊界案例說明
- **FV5**  
  - 臨床判定為「動作正確」,屬標準片。  
  - 目前版本採取較保守的腰線與門檻設定,在自動腰線估計偏高的情況下,  
    會被系統判為「未通過」,屬於安全邊界上的 **false negative(誤殺)**。  
  - 此設計反映系統的取捨:**寧可多擋一支可疑影片,也不輕易放過可能錯誤的動作。**
""")

    with gr.Accordion("⚠️ 系統限制說明(可展開)", open=False):
        gr.Markdown("""
### 📌 系統目前限制
- 受拍攝角度與距離影響:腰線位置與手部高度皆為 2D 投影,視角改變可能影響判斷。  
- 光線與背景:低光源、強反光或背景雜訊會影響 Mediapipe 標記的穩定度。  
- 動作抖動:快速移動或 motion blur 會降低有效幀比例,使通過率偏低。  
""")

    with gr.Accordion("🚀 未來改善方向(可展開)", open=False):
        gr.Markdown("""
### 🌱 系統優化方向
- 引入 YOLO / 其他手部偵測模型,提升各種光線與遮擋情境下的穩定度。  
- 加入 temporal smoothing(時序平滑),降低單一錯誤幀對結果的影響。  
- 導入影像前處理(亮度 / 對比度校正)以改善暗色環境的偵測品質。  
- 若資料量充足,可進一步訓練專用的 F-segment 深度學習模型。  
""")

    # 事件綁定:顯示 Gradio 內建 minimal 進度條即可
    btn_run.click(
        fn=run_with_status,
        inputs=[files, table_state],
        outputs=[status_md, files, table, table_state, btn_csv, summary],
        show_progress="minimal"
    )

    btn_clear.click(
        fn=do_clear,
        inputs=[table_state],
        outputs=[status_md, files, table, table_state, btn_csv, summary],
        show_progress="hidden"
    )

# -----------------------------------------------------------------------------
# 啟動(本地測試用;在 HuggingFace 會由 Spaces 自動呼叫 demo)
# -----------------------------------------------------------------------------
if __name__ == "__main__":
    demo.launch(server_name="0.0.0.0", server_port=7860)