amanda-cgu commited on
Commit
2b81852
·
verified ·
1 Parent(s): 1f1871b

backup old version before update

Browse files
Files changed (1) hide show
  1. app_old.py +338 -0
app_old.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 這是 2025-11-05 備份版本 app_v1.py
2
+
3
+
4
+ import os, math, time, tempfile
5
+ import cv2
6
+ import numpy as np
7
+ import mediapipe as mp
8
+ import gradio as gr
9
+ import pandas as pd
10
+
11
+ # -------------------------
12
+ # 參數(可在 UI 即時調整)
13
+ # -------------------------
14
+ DEFAULT_PASS_THRESHOLD = 0.50 # 總體通過門檻
15
+ DEFAULT_DESK_Y_RATIO = 0.75 # 桌面線(影像高度比例,愈大愈寬鬆)
16
+ PROFILE_NAME = "v1"
17
+
18
+ # MediaPipe 基本物件
19
+ mp_drawing = mp.solutions.drawing_utils
20
+ mp_pose = mp.solutions.pose
21
+
22
+ # -------------------------
23
+ # 幫助函式
24
+ # -------------------------
25
+ def angle_to_vertical(p1, p2):
26
+ """ 計算 p1->p2 相對『垂直向上』的夾角(度) """
27
+ vx, vy = p2[0] - p1[0], p2[1] - p1[1]
28
+ # 垂直向上單位向量 (0,-1)
29
+ dot = (vx * 0) + (vy * -1)
30
+ mag = (math.hypot(vx, vy) * 1.0) + 1e-6
31
+ cosang = max(-1.0, min(1.0, dot / mag))
32
+ ang = math.degrees(math.acos(cosang))
33
+ return ang
34
+
35
+ def safe_get(lms, idx):
36
+ try:
37
+ lm = lms[idx]
38
+ if lm.visibility is not None and lm.visibility < 0.5:
39
+ return None
40
+ return lm
41
+ except Exception:
42
+ return None
43
+
44
+ # -------------------------
45
+ # 單支影片分析(核心)
46
+ # -------------------------
47
+ def analyze_one_file(
48
+ video_file_path: str,
49
+ pass_threshold: float,
50
+ desk_y_ratio: float,
51
+ ref_mode: str, # "desk" 或 "waist"
52
+ ):
53
+ """
54
+ 回傳:
55
+ - output_path: 標註後影片路徑
56
+ - single_md : 單檔 Markdown 報告
57
+ - stats : 彙整用數據(dict)
58
+ """
59
+ filename = os.path.basename(video_file_path)
60
+ cap = cv2.VideoCapture(video_file_path)
61
+ if not cap.isOpened():
62
+ return None, f"❌ 無法開啟影片:{filename}", None
63
+
64
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30
65
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
66
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
67
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
68
+ duration_s = total_frames / fps if fps > 0 else 0.0
69
+
70
+ # 輸出影片
71
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
72
+ output_path = os.path.join(tempfile.gettempdir(), f"annot_{int(time.time())}_{filename}")
73
+ out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
74
+
75
+ # 統計
76
+ effective = 0
77
+ correct_frames = 0
78
+ surface_ok_frames = 0 # 桌面以上/腰部以上 幀數
79
+ finger_ok_frames = 0 # 指尖朝上 幀數
80
+
81
+ # 桌面線 (desk mode)
82
+ desk_y = int(height * desk_y_ratio)
83
+
84
+ # MediaPipe Pose
85
+ with mp_pose.Pose(
86
+ static_image_mode=False,
87
+ model_complexity=1,
88
+ enable_segmentation=False,
89
+ min_detection_confidence=0.5,
90
+ min_tracking_confidence=0.5,
91
+ ) as pose:
92
+
93
+ fi = 0
94
+ while True:
95
+ ret, frame = cap.read()
96
+ if not ret:
97
+ break
98
+ fi += 1
99
+
100
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
101
+ res = pose.process(rgb)
102
+ annotated = frame.copy()
103
+
104
+ # 畫桌面線或後續用的腰部線(等算出)
105
+ if ref_mode == "desk":
106
+ cv2.line(annotated, (0, desk_y), (width, desk_y), (0, 200, 255), 2)
107
+
108
+ above_surface = None
109
+ finger_up = None
110
+
111
+ if res.pose_landmarks:
112
+ lms = res.pose_landmarks.landmark
113
+
114
+ # 取得關鍵點
115
+ lwrist = safe_get(lms, mp_pose.PoseLandmark.LEFT_WRIST.value)
116
+ rwrist = safe_get(lms, mp_pose.PoseLandmark.RIGHT_WRIST.value)
117
+ lelbow = safe_get(lms, mp_pose.PoseLandmark.LEFT_ELBOW.value)
118
+ relbow = safe_get(lms, mp_pose.PoseLandmark.RIGHT_ELBOW.value)
119
+ lindex = safe_get(lms, mp_pose.PoseLandmark.LEFT_INDEX.value)
120
+ rindex = safe_get(lms, mp_pose.PoseLandmark.RIGHT_INDEX.value)
121
+
122
+ need_wrist = (lwrist is not None and rwrist is not None)
123
+
124
+ # ---- 參考線:桌面 or 腰部 ----
125
+ if ref_mode == "desk":
126
+ if need_wrist:
127
+ ly, ry = int(lwrist.y * height), int(rwrist.y * height)
128
+ above_surface = (ly < desk_y and ry < desk_y)
129
+ else: # "waist"
130
+ lhip = safe_get(lms, mp_pose.PoseLandmark.LEFT_HIP.value)
131
+ rhip = safe_get(lms, mp_pose.PoseLandmark.RIGHT_HIP.value)
132
+ if need_wrist and lhip is not None and rhip is not None:
133
+ hip_y_pix = int(((lhip.y + rhip.y) / 2.0) * height)
134
+ # 畫腰部線
135
+ cv2.line(annotated, (0, hip_y_pix), (width, hip_y_pix), (255, 180, 0), 2)
136
+ ly, ry = int(lwrist.y * height), int(rwrist.y * height)
137
+ # 略放寬:手腕必須在腰部線之上 3% 高度
138
+ margin = int(0.03 * height)
139
+ above_surface = (ly < hip_y_pix - margin and ry < hip_y_pix - margin)
140
+
141
+ # ---- 指尖朝上 / 手臂近似直立 ----
142
+ up_count = 0
143
+ up_need = 0
144
+
145
+ if lwrist is not None and lindex is not None:
146
+ up_need += 1
147
+ w = (lwrist.x * width, lwrist.y * height)
148
+ i = (lindex.x * width, lindex.y * height)
149
+ ang = angle_to_vertical(w, i) # 越小越接近垂直向上
150
+ # index 明顯在手腕上方 + 角度 < 35°
151
+ if i[1] < w[1] - 0.02 * height and ang < 35:
152
+ up_count += 1
153
+
154
+ if rwrist is not None and rindex is not None:
155
+ up_need += 1
156
+ w = (rwrist.x * width, rwrist.y * height)
157
+ i = (rindex.x * width, rindex.y * height)
158
+ ang = angle_to_vertical(w, i)
159
+ if i[1] < w[1] - 0.02 * height and ang < 35:
160
+ up_count += 1
161
+
162
+ # 兩手都有點到才算有效(你也可改成 只要一手有效)
163
+ if up_need == 2 and above_surface is not None:
164
+ effective += 1
165
+ finger_up = (up_count == 2)
166
+ if finger_up:
167
+ finger_ok_frames += 1
168
+ if above_surface:
169
+ surface_ok_frames += 1
170
+ if above_surface and finger_up:
171
+ correct_frames += 1
172
+
173
+ # 畫點與骨架
174
+ mp_drawing.draw_landmarks(
175
+ annotated, res.pose_landmarks, mp_pose.POSE_CONNECTIONS,
176
+ landmark_drawing_spec=mp_drawing.DrawingSpec(color=(0, 0, 255), thickness=2, circle_radius=2),
177
+ connection_drawing_spec=mp_drawing.DrawingSpec(color=(255, 255, 255), thickness=2, circle_radius=2),
178
+ )
179
+
180
+ # 視覺化狀態條
181
+ status_text = []
182
+ if above_surface is True:
183
+ status_text.append("表面✓")
184
+ elif above_surface is False:
185
+ status_text.append("表面✗")
186
+ if finger_up is True:
187
+ status_text.append("指尖↑✓")
188
+ elif finger_up is False:
189
+ status_text.append("指尖↑✗")
190
+
191
+ if status_text:
192
+ cv2.rectangle(annotated, (10, 10), (190, 46), (0, 0, 0), -1)
193
+ cv2.putText(annotated, " ".join(status_text), (16, 36),
194
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2, cv2.LINE_AA)
195
+
196
+ out.write(annotated)
197
+
198
+ cap.release()
199
+ out.release()
200
+
201
+ # ---- 統計輸出 ----
202
+ surface_rate = (surface_ok_frames / effective) if effective > 0 else 0.0
203
+ finger_rate = (finger_ok_frames / effective) if effective > 0 else 0.0
204
+ overall_rate = (correct_frames / effective) if effective > 0 else 0.0
205
+ passed = (overall_rate >= pass_threshold)
206
+
207
+ ref_label = "桌面以上" if ref_mode == "desk" else "腰部以上"
208
+ single_md = (
209
+ f"📁 檔案名稱:{filename}\n"
210
+ f"{'✅ 通過(姿勢正確)' if passed else '❌ 未通過(姿勢不正確)'}\n"
211
+ f"逐幀正確率:{overall_rate*100:.2f}%\n"
212
+ f"(有效幀數:{effective}/{total_frames})\n"
213
+ f"{ref_label} 幀率:{surface_rate*100:.2f}%、指尖朝上幀率:{finger_rate*100:.2f}%\n"
214
+ f"🔧 FPS:{int(fps)},🧮 總幀數:{total_frames},⏱️ 長度:約 {duration_s:.2f} 秒\n"
215
+ f"⚙️ 設定檔:{PROFILE_NAME}(門檻 {pass_threshold*100:.0f}%、桌面線 {desk_y_ratio:.2f}、參考線 {ref_label})\n"
216
+ )
217
+
218
+ stats = {
219
+ "filename": filename,
220
+ "passed": passed,
221
+ "overall_rate": overall_rate,
222
+ "surface_rate": surface_rate,
223
+ "finger_rate": finger_rate,
224
+ "effective": effective,
225
+ "total": total_frames,
226
+ "fps": int(fps),
227
+ "duration_s": duration_s,
228
+ "profile": PROFILE_NAME,
229
+ "pass_threshold": pass_threshold,
230
+ "desk_y_ratio": desk_y_ratio,
231
+ "ref_mode": ref_mode,
232
+ }
233
+ return output_path, single_md, stats
234
+
235
+ # -------------------------
236
+ # 單一檔包裝
237
+ # -------------------------
238
+ def run_single(file, pass_threshold, desk_y_ratio, ref_choice):
239
+ if not file:
240
+ return None, "請先上傳一支 MP4。"
241
+ ref_mode = "desk" if ref_choice == "桌面以上" else "waist"
242
+ fp = file if isinstance(file, str) else file.name
243
+ return analyze_one_file(fp, pass_threshold, desk_y_ratio, ref_mode)[:2]
244
+
245
+ # -------------------------
246
+ # 多檔包裝(含彙整表 & CSV)
247
+ # -------------------------
248
+ def run_batch(files, pass_threshold, desk_y_ratio, ref_choice):
249
+ if not files:
250
+ return [], "尚未上傳影片。", None
251
+
252
+ ref_mode = "desk" if ref_choice == "桌面以上" else "waist"
253
+ rows = []
254
+ gallery_items = []
255
+
256
+ for f in files:
257
+ fp = f if isinstance(f, str) else f.name
258
+ out_path, single_md, st = analyze_one_file(fp, pass_threshold, desk_y_ratio, ref_mode)
259
+
260
+ caption = f"{st['filename']}|{'通過' if st['passed'] else '未通過'}|{st['overall_rate']*100:.1f}%"
261
+ gallery_items.append((out_path, caption))
262
+
263
+ rows.append({
264
+ "檔案": st["filename"],
265
+ "結果": "✅" if st["passed"] else "❌",
266
+ "整體正確率(%)": round(st["overall_rate"]*100, 2),
267
+ f"{ref_choice}幀率(%)": round(st["surface_rate"]*100, 2),
268
+ "指尖朝上幀率(%)": round(st["finger_rate"]*100, 2),
269
+ "有效幀/總幀": f"{st['effective']}/{st['total']}",
270
+ "FPS": st["fps"],
271
+ "長度(秒)": round(st["duration_s"], 2),
272
+ "設定檔": st["profile"],
273
+ "門檻(%)": round(st["pass_threshold"]*100, 0),
274
+ "桌面線y": round(st["desk_y_ratio"], 2),
275
+ "參考線": ref_choice,
276
+ })
277
+
278
+ df = pd.DataFrame(rows)
279
+ md_header = "| 檔案 | 結果 | 整體正確率(%) | " + ref_choice + "幀率(%) | 指尖朝上幀率(%) | 有效幀/總幀 | FPS | 長度(秒) | 設定檔 | 門檻(%) | 桌面線y | 參考線 |\n"
280
+ md_header += "|---|:--:|---:|---:|---:|---:|---:|---:|:--:|---:|---:|:--:|\n"
281
+ md_body = "\n".join(
282
+ f"| {r['檔案']} | {r['結果']} | {r['整體正確率(%)']:.2f} | {r[f'{ref_choice}幀率(%)']:.2f} | "
283
+ f"{r['指尖朝上幀率(%)']:.2f} | {r['有效幀/總幀']} | {r['FPS']} | {r['長度(秒)']:.2f} | "
284
+ f"{r['設定檔']} | {int(r['門檻(%)'])} | {r['桌面線y']:.2f} | {r['參考線']} |"
285
+ for _, r in df.iterrows()
286
+ )
287
+ summary_md = "### 📊 動作通過率彙整表\n" + md_header + md_body
288
+
289
+ csv_path = os.path.join(tempfile.gettempdir(), f"glove_summary_{int(time.time())}.csv")
290
+ df.to_csv(csv_path, index=False, encoding="utf-8-sig")
291
+
292
+ return gallery_items, summary_md, csv_path
293
+
294
+ # -------------------------
295
+ # Gradio 介面
296
+ # -------------------------
297
+ with gr.Blocks(title="無菌手套姿勢檢測系統") as demo:
298
+ gr.Markdown("上傳 MP4,由 AI 檢測:① **手在桌面/腰部以上**(可切換)② **雙手直立、指尖朝上**。")
299
+
300
+ with gr.Row():
301
+ mode = gr.Radio(choices=["單一影片", "多部影片"], value="單一影片", label="模式")
302
+ ref_choice = gr.Radio(choices=["桌面以上", "腰部以上"], value="桌面以上", label="參考線")
303
+
304
+ with gr.Accordion("檢測參數(可即時調整)", open=False):
305
+ pass_slider = gr.Slider(0.10, 0.95, value=DEFAULT_PASS_THRESHOLD, step=0.01, label="通過門檻(整體正確率)")
306
+ desk_slider = gr.Slider(0.60, 0.90, value=DEFAULT_DESK_Y_RATIO, step=0.01, label="桌面線 y 比例(愈大愈寬鬆;只在「桌面以上」時生效)")
307
+
308
+ # 單一
309
+ single_box = gr.Group(visible=True)
310
+ with single_box:
311
+ in_file = gr.File(label="上傳 1 支 MP4", file_types=[".mp4"])
312
+ btn_single = gr.Button("執行單檔分析", variant="primary")
313
+ out_video = gr.Video(label="分析結果影片")
314
+ out_md = gr.Markdown(label="單檔報告")
315
+
316
+ # 多部
317
+ batch_box = gr.Group(visible=False)
318
+ with batch_box:
319
+ in_files = gr.Files(label="上傳多支 MP4", file_types=[".mp4"])
320
+ btn_batch = gr.Button("執行多檔分析(產生彙整表)", variant="primary")
321
+ out_gallery = gr.Gallery(label="分析結果影片(可逐支播放)", columns=2, height=480)
322
+ out_summary_md = gr.Markdown(label="彙整表")
323
+ out_csv = gr.File(label="下載彙整 CSV")
324
+
325
+ def switch_mode(m):
326
+ return (gr.update(visible=(m == "單一影片")),
327
+ gr.update(visible=(m == "多部影片")))
328
+ mode.change(switch_mode, inputs=mode, outputs=[single_box, batch_box])
329
+
330
+ btn_single.click(run_single,
331
+ inputs=[in_file, pass_slider, desk_slider, ref_choice],
332
+ outputs=[out_video, out_md])
333
+
334
+ btn_batch.click(run_batch,
335
+ inputs=[in_files, pass_slider, desk_slider, ref_choice],
336
+ outputs=[out_gallery, out_summary_md, out_csv])
337
+
338
+ demo.launch()