Spaces:
Sleeping
Sleeping
| # 這是 2025-11-05 備份版本 app_v1.py | |
| import os, math, time, tempfile | |
| import cv2 | |
| import numpy as np | |
| import mediapipe as mp | |
| import gradio as gr | |
| import pandas as pd | |
| # ------------------------- | |
| # 參數(可在 UI 即時調整) | |
| # ------------------------- | |
| DEFAULT_PASS_THRESHOLD = 0.50 # 總體通過門檻 | |
| DEFAULT_DESK_Y_RATIO = 0.75 # 桌面線(影像高度比例,愈大愈寬鬆) | |
| PROFILE_NAME = "v1" | |
| # MediaPipe 基本物件 | |
| mp_drawing = mp.solutions.drawing_utils | |
| mp_pose = mp.solutions.pose | |
| # ------------------------- | |
| # 幫助函式 | |
| # ------------------------- | |
| def angle_to_vertical(p1, p2): | |
| """ 計算 p1->p2 相對『垂直向上』的夾角(度) """ | |
| vx, vy = p2[0] - p1[0], p2[1] - p1[1] | |
| # 垂直向上單位向量 (0,-1) | |
| dot = (vx * 0) + (vy * -1) | |
| mag = (math.hypot(vx, vy) * 1.0) + 1e-6 | |
| cosang = max(-1.0, min(1.0, dot / mag)) | |
| ang = math.degrees(math.acos(cosang)) | |
| return ang | |
| def safe_get(lms, idx): | |
| try: | |
| lm = lms[idx] | |
| if lm.visibility is not None and lm.visibility < 0.5: | |
| return None | |
| return lm | |
| except Exception: | |
| return None | |
| # ------------------------- | |
| # 單支影片分析(核心) | |
| # ------------------------- | |
| def analyze_one_file( | |
| video_file_path: str, | |
| pass_threshold: float, | |
| desk_y_ratio: float, | |
| ref_mode: str, # "desk" 或 "waist" | |
| ): | |
| """ | |
| 回傳: | |
| - output_path: 標註後影片路徑 | |
| - single_md : 單檔 Markdown 報告 | |
| - stats : 彙整用數據(dict) | |
| """ | |
| filename = os.path.basename(video_file_path) | |
| cap = cv2.VideoCapture(video_file_path) | |
| if not cap.isOpened(): | |
| return None, f"❌ 無法開啟影片:{filename}", None | |
| fps = cap.get(cv2.CAP_PROP_FPS) or 30 | |
| total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) | |
| width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) | |
| height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) | |
| duration_s = total_frames / fps if fps > 0 else 0.0 | |
| # 輸出影片 | |
| fourcc = cv2.VideoWriter_fourcc(*"mp4v") | |
| output_path = os.path.join(tempfile.gettempdir(), f"annot_{int(time.time())}_{filename}") | |
| out = cv2.VideoWriter(output_path, fourcc, fps, (width, height)) | |
| # 統計 | |
| effective = 0 | |
| correct_frames = 0 | |
| surface_ok_frames = 0 # 桌面以上/腰部以上 幀數 | |
| finger_ok_frames = 0 # 指尖朝上 幀數 | |
| # 桌面線 (desk mode) | |
| desk_y = int(height * desk_y_ratio) | |
| # MediaPipe Pose | |
| with mp_pose.Pose( | |
| static_image_mode=False, | |
| model_complexity=1, | |
| enable_segmentation=False, | |
| min_detection_confidence=0.5, | |
| min_tracking_confidence=0.5, | |
| ) as pose: | |
| fi = 0 | |
| while True: | |
| ret, frame = cap.read() | |
| if not ret: | |
| break | |
| fi += 1 | |
| rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) | |
| res = pose.process(rgb) | |
| annotated = frame.copy() | |
| # 畫桌面線或後續用的腰部線(等算出) | |
| if ref_mode == "desk": | |
| cv2.line(annotated, (0, desk_y), (width, desk_y), (0, 200, 255), 2) | |
| above_surface = None | |
| finger_up = None | |
| if res.pose_landmarks: | |
| lms = res.pose_landmarks.landmark | |
| # 取得關鍵點 | |
| lwrist = safe_get(lms, mp_pose.PoseLandmark.LEFT_WRIST.value) | |
| rwrist = safe_get(lms, mp_pose.PoseLandmark.RIGHT_WRIST.value) | |
| lelbow = safe_get(lms, mp_pose.PoseLandmark.LEFT_ELBOW.value) | |
| relbow = safe_get(lms, mp_pose.PoseLandmark.RIGHT_ELBOW.value) | |
| lindex = safe_get(lms, mp_pose.PoseLandmark.LEFT_INDEX.value) | |
| rindex = safe_get(lms, mp_pose.PoseLandmark.RIGHT_INDEX.value) | |
| need_wrist = (lwrist is not None and rwrist is not None) | |
| # ---- 參考線:桌面 or 腰部 ---- | |
| if ref_mode == "desk": | |
| if need_wrist: | |
| ly, ry = int(lwrist.y * height), int(rwrist.y * height) | |
| above_surface = (ly < desk_y and ry < desk_y) | |
| else: # "waist" | |
| lhip = safe_get(lms, mp_pose.PoseLandmark.LEFT_HIP.value) | |
| rhip = safe_get(lms, mp_pose.PoseLandmark.RIGHT_HIP.value) | |
| if need_wrist and lhip is not None and rhip is not None: | |
| hip_y_pix = int(((lhip.y + rhip.y) / 2.0) * height) | |
| # 畫腰部線 | |
| cv2.line(annotated, (0, hip_y_pix), (width, hip_y_pix), (255, 180, 0), 2) | |
| ly, ry = int(lwrist.y * height), int(rwrist.y * height) | |
| # 略放寬:手腕必須在腰部線之上 3% 高度 | |
| margin = int(0.03 * height) | |
| above_surface = (ly < hip_y_pix - margin and ry < hip_y_pix - margin) | |
| # ---- 指尖朝上 / 手臂近似直立 ---- | |
| up_count = 0 | |
| up_need = 0 | |
| if lwrist is not None and lindex is not None: | |
| up_need += 1 | |
| w = (lwrist.x * width, lwrist.y * height) | |
| i = (lindex.x * width, lindex.y * height) | |
| ang = angle_to_vertical(w, i) # 越小越接近垂直向上 | |
| # index 明顯在手腕上方 + 角度 < 35° | |
| if i[1] < w[1] - 0.02 * height and ang < 35: | |
| up_count += 1 | |
| if rwrist is not None and rindex is not None: | |
| up_need += 1 | |
| w = (rwrist.x * width, rwrist.y * height) | |
| i = (rindex.x * width, rindex.y * height) | |
| ang = angle_to_vertical(w, i) | |
| if i[1] < w[1] - 0.02 * height and ang < 35: | |
| up_count += 1 | |
| # 兩手都有點到才算有效(你也可改成 只要一手有效) | |
| if up_need == 2 and above_surface is not None: | |
| effective += 1 | |
| finger_up = (up_count == 2) | |
| if finger_up: | |
| finger_ok_frames += 1 | |
| if above_surface: | |
| surface_ok_frames += 1 | |
| if above_surface and finger_up: | |
| correct_frames += 1 | |
| # 畫點與骨架 | |
| mp_drawing.draw_landmarks( | |
| annotated, res.pose_landmarks, mp_pose.POSE_CONNECTIONS, | |
| landmark_drawing_spec=mp_drawing.DrawingSpec(color=(0, 0, 255), thickness=2, circle_radius=2), | |
| connection_drawing_spec=mp_drawing.DrawingSpec(color=(255, 255, 255), thickness=2, circle_radius=2), | |
| ) | |
| # 視覺化狀態條 | |
| status_text = [] | |
| if above_surface is True: | |
| status_text.append("表面✓") | |
| elif above_surface is False: | |
| status_text.append("表面✗") | |
| if finger_up is True: | |
| status_text.append("指尖↑✓") | |
| elif finger_up is False: | |
| status_text.append("指尖↑✗") | |
| if status_text: | |
| cv2.rectangle(annotated, (10, 10), (190, 46), (0, 0, 0), -1) | |
| cv2.putText(annotated, " ".join(status_text), (16, 36), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2, cv2.LINE_AA) | |
| out.write(annotated) | |
| cap.release() | |
| out.release() | |
| # ---- 統計輸出 ---- | |
| surface_rate = (surface_ok_frames / effective) if effective > 0 else 0.0 | |
| finger_rate = (finger_ok_frames / effective) if effective > 0 else 0.0 | |
| overall_rate = (correct_frames / effective) if effective > 0 else 0.0 | |
| passed = (overall_rate >= pass_threshold) | |
| ref_label = "桌面以上" if ref_mode == "desk" else "腰部以上" | |
| single_md = ( | |
| f"📁 檔案名稱:{filename}\n" | |
| f"{'✅ 通過(姿勢正確)' if passed else '❌ 未通過(姿勢不正確)'}\n" | |
| f"逐幀正確率:{overall_rate*100:.2f}%\n" | |
| f"(有效幀數:{effective}/{total_frames})\n" | |
| f"{ref_label} 幀率:{surface_rate*100:.2f}%、指尖朝上幀率:{finger_rate*100:.2f}%\n" | |
| f"🔧 FPS:{int(fps)},🧮 總幀數:{total_frames},⏱️ 長度:約 {duration_s:.2f} 秒\n" | |
| f"⚙️ 設定檔:{PROFILE_NAME}(門檻 {pass_threshold*100:.0f}%、桌面線 {desk_y_ratio:.2f}、參考線 {ref_label})\n" | |
| ) | |
| stats = { | |
| "filename": filename, | |
| "passed": passed, | |
| "overall_rate": overall_rate, | |
| "surface_rate": surface_rate, | |
| "finger_rate": finger_rate, | |
| "effective": effective, | |
| "total": total_frames, | |
| "fps": int(fps), | |
| "duration_s": duration_s, | |
| "profile": PROFILE_NAME, | |
| "pass_threshold": pass_threshold, | |
| "desk_y_ratio": desk_y_ratio, | |
| "ref_mode": ref_mode, | |
| } | |
| return output_path, single_md, stats | |
| # ------------------------- | |
| # 單一檔包裝 | |
| # ------------------------- | |
| def run_single(file, pass_threshold, desk_y_ratio, ref_choice): | |
| if not file: | |
| return None, "請先上傳一支 MP4。" | |
| ref_mode = "desk" if ref_choice == "桌面以上" else "waist" | |
| fp = file if isinstance(file, str) else file.name | |
| return analyze_one_file(fp, pass_threshold, desk_y_ratio, ref_mode)[:2] | |
| # ------------------------- | |
| # 多檔包裝(含彙整表 & CSV) | |
| # ------------------------- | |
| def run_batch(files, pass_threshold, desk_y_ratio, ref_choice): | |
| if not files: | |
| return [], "尚未上傳影片。", None | |
| ref_mode = "desk" if ref_choice == "桌面以上" else "waist" | |
| rows = [] | |
| gallery_items = [] | |
| for f in files: | |
| fp = f if isinstance(f, str) else f.name | |
| out_path, single_md, st = analyze_one_file(fp, pass_threshold, desk_y_ratio, ref_mode) | |
| caption = f"{st['filename']}|{'通過' if st['passed'] else '未通過'}|{st['overall_rate']*100:.1f}%" | |
| gallery_items.append((out_path, caption)) | |
| rows.append({ | |
| "檔案": st["filename"], | |
| "結果": "✅" if st["passed"] else "❌", | |
| "整體正確率(%)": round(st["overall_rate"]*100, 2), | |
| f"{ref_choice}幀率(%)": round(st["surface_rate"]*100, 2), | |
| "指尖朝上幀率(%)": round(st["finger_rate"]*100, 2), | |
| "有效幀/總幀": f"{st['effective']}/{st['total']}", | |
| "FPS": st["fps"], | |
| "長度(秒)": round(st["duration_s"], 2), | |
| "設定檔": st["profile"], | |
| "門檻(%)": round(st["pass_threshold"]*100, 0), | |
| "桌面線y": round(st["desk_y_ratio"], 2), | |
| "參考線": ref_choice, | |
| }) | |
| df = pd.DataFrame(rows) | |
| md_header = "| 檔案 | 結果 | 整體正確率(%) | " + ref_choice + "幀率(%) | 指尖朝上幀率(%) | 有效幀/總幀 | FPS | 長度(秒) | 設定檔 | 門檻(%) | 桌面線y | 參考線 |\n" | |
| md_header += "|---|:--:|---:|---:|---:|---:|---:|---:|:--:|---:|---:|:--:|\n" | |
| md_body = "\n".join( | |
| f"| {r['檔案']} | {r['結果']} | {r['整體正確率(%)']:.2f} | {r[f'{ref_choice}幀率(%)']:.2f} | " | |
| f"{r['指尖朝上幀率(%)']:.2f} | {r['有效幀/總幀']} | {r['FPS']} | {r['長度(秒)']:.2f} | " | |
| f"{r['設定檔']} | {int(r['門檻(%)'])} | {r['桌面線y']:.2f} | {r['參考線']} |" | |
| for _, r in df.iterrows() | |
| ) | |
| summary_md = "### 📊 動作通過率彙整表\n" + md_header + md_body | |
| csv_path = os.path.join(tempfile.gettempdir(), f"glove_summary_{int(time.time())}.csv") | |
| df.to_csv(csv_path, index=False, encoding="utf-8-sig") | |
| return gallery_items, summary_md, csv_path | |
| # ------------------------- | |
| # Gradio 介面 | |
| # ------------------------- | |
| with gr.Blocks(title="無菌手套姿勢檢測系統") as demo: | |
| gr.Markdown("上傳 MP4,由 AI 檢測:① **手在桌面/腰部以上**(可切換)② **雙手直立、指尖朝上**。") | |
| with gr.Row(): | |
| mode = gr.Radio(choices=["單一影片", "多部影片"], value="單一影片", label="模式") | |
| ref_choice = gr.Radio(choices=["桌面以上", "腰部以上"], value="桌面以上", label="參考線") | |
| with gr.Accordion("檢測參數(可即時調整)", open=False): | |
| pass_slider = gr.Slider(0.10, 0.95, value=DEFAULT_PASS_THRESHOLD, step=0.01, label="通過門檻(整體正確率)") | |
| desk_slider = gr.Slider(0.60, 0.90, value=DEFAULT_DESK_Y_RATIO, step=0.01, label="桌面線 y 比例(愈大愈寬鬆;只在「桌面以上」時生效)") | |
| # 單一 | |
| single_box = gr.Group(visible=True) | |
| with single_box: | |
| in_file = gr.File(label="上傳 1 支 MP4", file_types=[".mp4"]) | |
| btn_single = gr.Button("執行單檔分析", variant="primary") | |
| out_video = gr.Video(label="分析結果影片") | |
| out_md = gr.Markdown(label="單檔報告") | |
| # 多部 | |
| batch_box = gr.Group(visible=False) | |
| with batch_box: | |
| in_files = gr.Files(label="上傳多支 MP4", file_types=[".mp4"]) | |
| btn_batch = gr.Button("執行多檔分析(產生彙整表)", variant="primary") | |
| out_gallery = gr.Gallery(label="分析結果影片(可逐支播放)", columns=2, height=480) | |
| out_summary_md = gr.Markdown(label="彙整表") | |
| out_csv = gr.File(label="下載彙整 CSV") | |
| def switch_mode(m): | |
| return (gr.update(visible=(m == "單一影片")), | |
| gr.update(visible=(m == "多部影片"))) | |
| mode.change(switch_mode, inputs=mode, outputs=[single_box, batch_box]) | |
| btn_single.click(run_single, | |
| inputs=[in_file, pass_slider, desk_slider, ref_choice], | |
| outputs=[out_video, out_md]) | |
| btn_batch.click(run_batch, | |
| inputs=[in_files, pass_slider, desk_slider, ref_choice], | |
| outputs=[out_gallery, out_summary_md, out_csv]) | |
| demo.launch() | |