Spaces:
Sleeping
Sleeping
YoungjaeDev
Claude
commited on
Commit
·
0ea4706
1
Parent(s):
f50f28a
feat(batch): 배치 추론 및 스마트 클립 추출 구현 (Issue #77, #78, #82)
Browse filesIssue #77 - 배치 Pose/ST-GCN 추론:
- PoseEstimator.extract_batch(): 다중 프레임 GPU 배치 추론
- STGCNClassifier.predict_batch(): 다중 윈도우 배치 예측
- 기존 단일 추론 API 완전 호환
Issue #78 - ProcessPoolExecutor 병렬 시각화:
- BatchProcessor 클래스: 배치 GPU 추론 + CPU 병렬 시각화 통합
- _visualize_worker(): pickle 가능한 워커 함수
- 프레임 순서 보장 로직 구현
Issue #82 - 스마트 클립 추출:
- 낙상 감지 구간만 클립 추출 (전 1초 + 후 2초)
- 비낙상 시 클립 없이 결과 메시지만 반환
- FFmpeg 인코딩 시간 80%+ 감소 (489프레임 -> 90프레임)
테스트: 15개 단위 테스트 작성 및 통과
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
app.py
CHANGED
|
@@ -314,6 +314,13 @@ def create_probability_graph(
|
|
| 314 |
return fig
|
| 315 |
|
| 316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
# -----------------------------------------------------------------------------
|
| 318 |
# 메인 추론 함수
|
| 319 |
# -----------------------------------------------------------------------------
|
|
@@ -325,7 +332,11 @@ def process_video(
|
|
| 325 |
progress: gr.Progress = gr.Progress()
|
| 326 |
) -> Tuple[Optional[str], Optional[go.Figure], str]:
|
| 327 |
"""
|
| 328 |
-
비디오 처리 및 낙상 감지
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
|
| 330 |
Args:
|
| 331 |
video_path: 입력 비디오 경로
|
|
@@ -334,7 +345,7 @@ def process_video(
|
|
| 334 |
progress: Gradio 진행률 표시
|
| 335 |
|
| 336 |
Returns:
|
| 337 |
-
output_video_path: 결과
|
| 338 |
probability_graph: 확률 그래프
|
| 339 |
result_text: 최종 판정 텍스트
|
| 340 |
"""
|
|
@@ -376,62 +387,104 @@ def process_video(
|
|
| 376 |
f"60초 이내의 비디오를 업로드하세요."
|
| 377 |
)
|
| 378 |
|
| 379 |
-
#
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
| 383 |
-
# Info panel 추가로 높이 80px 증가
|
| 384 |
-
out = cv2.VideoWriter(output_path, fourcc, fps, (width, height + 80))
|
| 385 |
|
| 386 |
-
# 처리 루프
|
| 387 |
frame_idx = 0
|
| 388 |
frame_indices = []
|
| 389 |
probabilities = []
|
| 390 |
-
fall_detected = False
|
| 391 |
max_confidence = 0.0
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
while True:
|
| 394 |
-
# 프레임 읽기
|
| 395 |
with pipeline.profiler.profile('video_read'):
|
| 396 |
ret, frame = cap.read()
|
| 397 |
if not ret:
|
| 398 |
break
|
| 399 |
|
|
|
|
|
|
|
|
|
|
| 400 |
# 프레임 처리
|
| 401 |
vis_frame, info = pipeline.process_frame(frame, frame_idx)
|
| 402 |
|
|
|
|
|
|
|
|
|
|
| 403 |
# 확률 기록
|
| 404 |
if info['confidence'] is not None:
|
| 405 |
frame_indices.append(frame_idx)
|
| 406 |
probabilities.append(info['confidence'])
|
| 407 |
max_confidence = max(max_confidence, info['confidence'])
|
| 408 |
|
| 409 |
-
# 낙상 감지
|
| 410 |
-
if info['alert']:
|
|
|
|
| 411 |
fall_detected = True
|
| 412 |
|
| 413 |
-
# 출력 저장 (프로파일링)
|
| 414 |
-
with pipeline.profiler.profile('video_write'):
|
| 415 |
-
out.write(vis_frame)
|
| 416 |
-
|
| 417 |
frame_idx += 1
|
| 418 |
|
| 419 |
# 진행률 업데이트
|
| 420 |
if frame_idx % 10 == 0:
|
| 421 |
-
progress_val = 0.2 + 0.
|
| 422 |
-
progress(progress_val, desc=f"
|
| 423 |
|
| 424 |
-
# 리소스 해제
|
| 425 |
cap.release()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
out.release()
|
| 427 |
|
| 428 |
# H.264 코덱으로 재인코딩 (브라우저 호환)
|
| 429 |
-
progress(0.9, desc="비디오 인코딩 중...")
|
| 430 |
-
# 보안: NamedTemporaryFile 사용 (CWE-377 방지)
|
| 431 |
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
|
| 432 |
output_h264 = tmp.name
|
| 433 |
-
|
| 434 |
-
# FFmpeg 인코딩 프로파일링
|
| 435 |
with pipeline.profiler.profile('ffmpeg_encode'):
|
| 436 |
subprocess.run(
|
| 437 |
[
|
|
@@ -453,19 +506,18 @@ def process_video(
|
|
| 453 |
else:
|
| 454 |
final_output = output_path # 폴백
|
| 455 |
|
| 456 |
-
# 확률 그래프 생성
|
| 457 |
-
progress(0.95, desc="그래프 생성 중...")
|
| 458 |
-
if frame_indices and probabilities:
|
| 459 |
-
fig = create_probability_graph(frame_indices, probabilities, fall_threshold)
|
| 460 |
-
else:
|
| 461 |
-
fig = None
|
| 462 |
-
|
| 463 |
# 최종 판정
|
| 464 |
progress(1.0, desc="완료!")
|
| 465 |
-
if
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
|
| 470 |
return final_output, fig, result_text
|
| 471 |
|
|
|
|
| 314 |
return fig
|
| 315 |
|
| 316 |
|
| 317 |
+
# -----------------------------------------------------------------------------
|
| 318 |
+
# 스마트 클립 추출 설정 (Issue #82)
|
| 319 |
+
# -----------------------------------------------------------------------------
|
| 320 |
+
CLIP_PRE_FALL_SECONDS = 1.0 # 낙상 전 1초
|
| 321 |
+
CLIP_POST_FALL_SECONDS = 2.0 # 낙상 후 2초
|
| 322 |
+
|
| 323 |
+
|
| 324 |
# -----------------------------------------------------------------------------
|
| 325 |
# 메인 추론 함수
|
| 326 |
# -----------------------------------------------------------------------------
|
|
|
|
| 332 |
progress: gr.Progress = gr.Progress()
|
| 333 |
) -> Tuple[Optional[str], Optional[go.Figure], str]:
|
| 334 |
"""
|
| 335 |
+
비디오 처리 및 낙상 감지 (스마트 클립 추출)
|
| 336 |
+
|
| 337 |
+
Issue #82: 낙상 감지 구간만 클립으로 추출하여 인코딩 시간 대폭 감소
|
| 338 |
+
- 낙상 감지 시: 낙상 전 1초 + 낙상 후 2초 구간만 추출
|
| 339 |
+
- 비낙상 시: 낙상 미감지 메시지 반환
|
| 340 |
|
| 341 |
Args:
|
| 342 |
video_path: 입력 비디오 경로
|
|
|
|
| 345 |
progress: Gradio 진행률 표시
|
| 346 |
|
| 347 |
Returns:
|
| 348 |
+
output_video_path: 결과 클립 경로 (낙상 감지 시) 또는 None (비낙상)
|
| 349 |
probability_graph: 확률 그래프
|
| 350 |
result_text: 최종 판정 텍스트
|
| 351 |
"""
|
|
|
|
| 387 |
f"60초 이내의 비디오를 업로드하세요."
|
| 388 |
)
|
| 389 |
|
| 390 |
+
# 클립 추출을 위한 프레임 수 계산
|
| 391 |
+
pre_fall_frames = int(fps * CLIP_PRE_FALL_SECONDS)
|
| 392 |
+
post_fall_frames = int(fps * CLIP_POST_FALL_SECONDS)
|
|
|
|
|
|
|
|
|
|
| 393 |
|
| 394 |
+
# 처리 루프 - 프레임 버퍼링 + 낙상 감지
|
| 395 |
frame_idx = 0
|
| 396 |
frame_indices = []
|
| 397 |
probabilities = []
|
|
|
|
| 398 |
max_confidence = 0.0
|
| 399 |
|
| 400 |
+
# 낙상 감지 추적
|
| 401 |
+
first_fall_frame = None # 첫 낙상 감지 프레임
|
| 402 |
+
fall_detected = False
|
| 403 |
+
|
| 404 |
+
# 시각화 프레임 버퍼 (클립 추출용)
|
| 405 |
+
vis_frame_buffer = []
|
| 406 |
+
raw_frame_buffer = [] # 원본 프레임 버퍼 (재처리용)
|
| 407 |
+
|
| 408 |
while True:
|
| 409 |
+
# 프레임 읽기
|
| 410 |
with pipeline.profiler.profile('video_read'):
|
| 411 |
ret, frame = cap.read()
|
| 412 |
if not ret:
|
| 413 |
break
|
| 414 |
|
| 415 |
+
# 원본 프레임 버퍼에 저장 (클립 추출에 필요)
|
| 416 |
+
raw_frame_buffer.append(frame.copy())
|
| 417 |
+
|
| 418 |
# 프레임 처리
|
| 419 |
vis_frame, info = pipeline.process_frame(frame, frame_idx)
|
| 420 |
|
| 421 |
+
# 시각화 프레임 버퍼에 저장
|
| 422 |
+
vis_frame_buffer.append(vis_frame)
|
| 423 |
+
|
| 424 |
# 확률 기록
|
| 425 |
if info['confidence'] is not None:
|
| 426 |
frame_indices.append(frame_idx)
|
| 427 |
probabilities.append(info['confidence'])
|
| 428 |
max_confidence = max(max_confidence, info['confidence'])
|
| 429 |
|
| 430 |
+
# 첫 낙상 감지 시점 기록
|
| 431 |
+
if info['alert'] and first_fall_frame is None:
|
| 432 |
+
first_fall_frame = frame_idx
|
| 433 |
fall_detected = True
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
frame_idx += 1
|
| 436 |
|
| 437 |
# 진행률 업데이트
|
| 438 |
if frame_idx % 10 == 0:
|
| 439 |
+
progress_val = 0.2 + 0.6 * (frame_idx / total_frames)
|
| 440 |
+
progress(progress_val, desc=f"분석 중... ({frame_idx}/{total_frames})")
|
| 441 |
|
|
|
|
| 442 |
cap.release()
|
| 443 |
+
|
| 444 |
+
# 확률 그래프 생성 (항상 생성)
|
| 445 |
+
progress(0.85, desc="그래프 생성 중...")
|
| 446 |
+
if frame_indices and probabilities:
|
| 447 |
+
fig = create_probability_graph(frame_indices, probabilities, fall_threshold)
|
| 448 |
+
else:
|
| 449 |
+
fig = None
|
| 450 |
+
|
| 451 |
+
# 낙상 미감지 시 클립 없이 반환
|
| 452 |
+
if not fall_detected or first_fall_frame is None:
|
| 453 |
+
progress(1.0, desc="완료!")
|
| 454 |
+
result_text = (
|
| 455 |
+
f"[Non-Fall] 낙상이 감지되지 않았습니다.\n"
|
| 456 |
+
f"최대 확률: {max_confidence:.1%}\n"
|
| 457 |
+
f"분석 프레임: {total_frames}개"
|
| 458 |
+
)
|
| 459 |
+
return None, fig, result_text
|
| 460 |
+
|
| 461 |
+
# 클립 구간 계산
|
| 462 |
+
clip_start = max(0, first_fall_frame - pre_fall_frames)
|
| 463 |
+
clip_end = min(len(vis_frame_buffer), first_fall_frame + post_fall_frames)
|
| 464 |
+
clip_frames = vis_frame_buffer[clip_start:clip_end]
|
| 465 |
+
|
| 466 |
+
if not clip_frames:
|
| 467 |
+
progress(1.0, desc="완료!")
|
| 468 |
+
return None, fig, "클립 추출에 실패했습니다."
|
| 469 |
+
|
| 470 |
+
# 클립 비디오 생성 (프레임 수 감소로 인코딩 시간 대폭 감소)
|
| 471 |
+
progress(0.9, desc="클립 인코딩 중...")
|
| 472 |
+
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
|
| 473 |
+
output_path = tmp.name
|
| 474 |
+
|
| 475 |
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
| 476 |
+
# Info panel 추가로 높이 80px 증가
|
| 477 |
+
clip_height, clip_width = clip_frames[0].shape[:2]
|
| 478 |
+
out = cv2.VideoWriter(output_path, fourcc, fps, (clip_width, clip_height))
|
| 479 |
+
|
| 480 |
+
for vis_frame in clip_frames:
|
| 481 |
+
out.write(vis_frame)
|
| 482 |
out.release()
|
| 483 |
|
| 484 |
# H.264 코덱으로 재인코딩 (브라우저 호환)
|
|
|
|
|
|
|
| 485 |
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
|
| 486 |
output_h264 = tmp.name
|
| 487 |
+
|
|
|
|
| 488 |
with pipeline.profiler.profile('ffmpeg_encode'):
|
| 489 |
subprocess.run(
|
| 490 |
[
|
|
|
|
| 506 |
else:
|
| 507 |
final_output = output_path # 폴백
|
| 508 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
# 최종 판정
|
| 510 |
progress(1.0, desc="완료!")
|
| 511 |
+
fall_time = first_fall_frame / fps if fps > 0 else 0
|
| 512 |
+
clip_duration = len(clip_frames) / fps if fps > 0 else 0
|
| 513 |
+
result_text = (
|
| 514 |
+
f"[FALL DETECTED] 낙상이 감지되었습니다!\n"
|
| 515 |
+
f"낙상 시점: {fall_time:.2f}초 (프레임 #{first_fall_frame})\n"
|
| 516 |
+
f"최대 확률: {max_confidence:.1%}\n"
|
| 517 |
+
f"클립 길이: {clip_duration:.1f}초 ({len(clip_frames)}프레임)\n"
|
| 518 |
+
f"원본 대비: {len(clip_frames)}/{total_frames}프레임 "
|
| 519 |
+
f"({len(clip_frames)/total_frames*100:.1f}% 인코딩)"
|
| 520 |
+
)
|
| 521 |
|
| 522 |
return final_output, fig, result_text
|
| 523 |
|