Compare commits

..

3 Commits

Author SHA1 Message Date
wsy182 08b6543b79 perf(video): 优化视频处理性能监控和音频播放
- 添加视频处理性能计时和统计功能
- 实现帧处理时间监控和慢帧警告
- 添加音频文件静音修剪功能
- 优化Windows平台音频播放实现
- 调整默认日志输出频率减少冗余信息
- 修复MediaPipe GPU委托在Windows上的兼容性问题
2026-06-15 23:13:36 +08:00
wsy182 6dee2a2ff3 feat(exercise): 优化死虫式训练姿态检测算法
- 调整视频处理频率从每帧处理改为每2帧处理
- 添加膝角趋势平滑算法减少单帧抖动误判
- 改进对角伸展检测逻辑支持准备位手臂上举
- 优化状态机确保严格回到准备姿态才计数
- 添加姿态丢失时的候选帧清理机制
- 更新音频文件生成路径至resources目录
- 改进macOS音频生成使用AIFF格式提高质量
- 添加详细的帧处理日志输出间隔配置
2026-06-10 22:57:35 +08:00
wsy182 ea0c007441 Clean up RepAnnouncer: remove TTS code, play pre-generated audio only
- RepAnnouncer now only plays audio files, no TTS generation
- Removed pyttsx3 dependency, rate/volume params from constructor
- Audio generation delegated to app/audio/generate.py (called at startup)
- Default audio dir changed to resources/audio/reps
- Added resources/ to .gitignore
2026-06-10 11:51:05 +08:00
13 changed files with 697 additions and 201 deletions
+2
View File
@@ -2,3 +2,5 @@
.idea/
__pycache__/
logs/
resources/
+147 -44
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import platform
import shutil
import subprocess
import wave
from pathlib import Path
from loguru import logger
@@ -15,54 +16,65 @@ def generate_rep_audio_files(
rate: int,
output_dir: Path,
overwrite: bool = False,
trim_leading_silence: bool = True,
trim_silence_threshold: int = 500,
trim_silence_padding_ms: int = 20,
) -> None:
"""
确保 0~max_count 的运动次数语音 wav 文件存在。
默认生成到:
app/audio/reps/0.wav
app/audio/reps/1.wav
resources/audio/reps/0.aiff # macOS
resources/audio/reps/0.wav # Windows / Linux
...
app/audio/reps/200.wav
resources/audio/reps/200.aiff 或 200.wav
服务启动时调用一次即可。
"""
output_dir.mkdir(parents=True, exist_ok=True)
system = platform.system().lower()
suffix = ".aiff" if system == "darwin" else ".wav"
missing_counts = [
count
for count in range(0, max_count + 1)
if overwrite or not _audio_path(output_dir, count).exists()
if overwrite or not _audio_path(output_dir, count, suffix=suffix).exists()
]
if not missing_counts:
logger.info("Rep audio files already prepared: {}", output_dir)
return
system = platform.system().lower()
logger.info(
"Preparing rep audio files, system={}, count={}, output_dir={}",
system,
len(missing_counts),
output_dir,
)
if system == "darwin":
_generate_with_macos_say(
counts=missing_counts,
output_dir=output_dir,
rate=rate,
)
else:
_generate_with_pyttsx3(
counts=missing_counts,
output_dir=output_dir,
rate=rate,
logger.info(
"Preparing rep audio files, system={}, count={}, output_dir={}",
system,
len(missing_counts),
output_dir,
)
logger.info("Rep audio files prepared: {}", output_dir)
if system == "darwin":
_generate_with_macos_say(
counts=missing_counts,
output_dir=output_dir,
rate=rate,
)
else:
_generate_with_pyttsx3(
counts=missing_counts,
output_dir=output_dir,
rate=rate,
)
logger.info("Rep audio files prepared: {}", output_dir)
if trim_leading_silence and suffix == ".wav":
_trim_leading_silence_files(
counts=list(range(0, max_count + 1)),
output_dir=output_dir,
suffix=suffix,
threshold=trim_silence_threshold,
padding_ms=trim_silence_padding_ms,
)
def _generate_with_macos_say(
@@ -79,22 +91,27 @@ def _generate_with_macos_say(
raise RuntimeError("macOS say command not found")
for count in counts:
audio_file = _audio_path(output_dir, count)
audio_file = _audio_path(output_dir, count, suffix=".aiff")
subprocess.run(
[
"say",
"-r",
str(rate),
"--file-format=WAVE",
"-o",
str(audio_file),
str(count),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
try:
subprocess.run(
[
"say",
"-r",
str(rate),
"--file-format=AIFF",
"-o",
str(audio_file),
str(count),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
check=True,
)
except subprocess.CalledProcessError as exc:
message = exc.stderr.strip() or f"exit status {exc.returncode}"
raise RuntimeError(f"Failed to generate {audio_file}: {message}") from exc
def _generate_with_pyttsx3(
@@ -114,11 +131,97 @@ def _generate_with_pyttsx3(
engine.setProperty("volume", 1.0)
for count in counts:
audio_file = _audio_path(output_dir, count)
audio_file = _audio_path(output_dir, count, suffix=".wav")
engine.save_to_file(str(count), str(audio_file))
engine.runAndWait()
def _audio_path(output_dir: Path, count: int) -> Path:
return output_dir / f"{count}.wav"
def _audio_path(output_dir: Path, count: int, *, suffix: str) -> Path:
return output_dir / f"{count}{suffix}"
def _trim_leading_silence_files(
*,
counts: list[int],
output_dir: Path,
suffix: str,
threshold: int,
padding_ms: int,
) -> None:
trimmed = 0
total_removed_ms = 0.0
for count in counts:
audio_file = _audio_path(output_dir, count, suffix=suffix)
if not audio_file.exists():
continue
removed_ms = _trim_leading_silence(audio_file, threshold=threshold, padding_ms=padding_ms)
if removed_ms > 0:
trimmed += 1
total_removed_ms += removed_ms
logger.info(
"Rep audio leading silence trim complete: files_trimmed={}, total_removed_ms={:.1f}, threshold={}, padding_ms={}",
trimmed,
total_removed_ms,
threshold,
padding_ms,
)
def _trim_leading_silence(audio_file: Path, *, threshold: int, padding_ms: int) -> float:
with wave.open(str(audio_file), "rb") as reader:
params = reader.getparams()
frames = reader.readframes(params.nframes)
frame_size = params.sampwidth * params.nchannels
if params.nframes <= 0 or frame_size <= 0:
return 0.0
chunk_frames = max(1, params.framerate // 100)
leading_frames = 0
offset = 0
chunk_size = chunk_frames * frame_size
while offset < len(frames):
chunk = frames[offset : offset + chunk_size]
if _pcm_rms(chunk, params.sampwidth) > threshold:
break
chunk_frame_count = len(chunk) // frame_size
leading_frames += chunk_frame_count
offset += chunk_size
padding_frames = int(params.framerate * max(0, padding_ms) / 1000)
remove_frames = max(0, leading_frames - padding_frames)
if remove_frames <= 0:
return 0.0
start = min(len(frames), remove_frames * frame_size)
trimmed_frames = frames[start:]
if not trimmed_frames:
return 0.0
with wave.open(str(audio_file), "wb") as writer:
writer.setparams(params)
writer.writeframes(trimmed_frames)
return remove_frames / params.framerate * 1000
def _pcm_rms(chunk: bytes, sample_width: int) -> float:
if not chunk:
return 0.0
if sample_width == 2:
sample_count = len(chunk) // 2
if sample_count == 0:
return 0.0
total = 0
for i in range(0, sample_count * 2, 2):
sample = int.from_bytes(chunk[i : i + 2], "little", signed=True)
total += sample * sample
return (total / sample_count) ** 0.5
peak = max(abs(byte - 128) for byte in chunk)
return float(peak)
+79 -137
View File
@@ -1,43 +1,38 @@
from __future__ import annotations
import os
import queue
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any
from loguru import logger
class RepAnnouncer:
"""运动次数语音播报器:预生成 0~200 音频文件,运行时直接播放"""
"""运动次数语音播报器:读取预生成的音频文件直接播放"""
def __init__(
self,
*,
enabled: bool = True,
rate: int = 185,
volume: float = 1.0,
max_count: int = 200,
cache_dir: str | Path = "runtime/tts_cache/reps",
audio_dir: str | Path = "resources/audio/reps",
) -> None:
self.enabled = enabled
self.rate = rate
self.volume = volume
self.max_count = max_count
self.cache_dir = Path(cache_dir)
self.audio_dir = Path(audio_dir)
self._queue: queue.Queue[int | None] = queue.Queue()
self._queue: queue.Queue[tuple[int, float] | None] = queue.Queue()
self._thread: threading.Thread | None = None
self._engine: Any | None = None
self._current_process: subprocess.Popen | None = None
self._closed = False
self._play_lock = threading.Lock()
self._use_macos_say = sys.platform == "darwin"
self._use_windows_winsound = sys.platform.startswith("win")
self._platform = sys.platform
self._direct_playback = self._platform.startswith("win")
if self.enabled:
self._start()
@@ -46,13 +41,29 @@ class RepAnnouncer:
"""将次数放入队列,后台线程播放对应音频"""
if not self.enabled or self._closed:
return
if count <= 0 or count > self.max_count:
return
# 保留“只播最新一次”的策略,避免语音堆积
requested_at = time.perf_counter()
if self._direct_playback:
audio_file = self._audio_path(count)
if not audio_file.exists():
logger.warning("Rep audio file missing: {}", audio_file)
return
try:
self._play(audio_file)
logger.info(
"Rep audio submitted immediately: count={}, submit_ms={:.1f}",
count,
(time.perf_counter() - requested_at) * 1000,
)
except Exception as exc:
logger.warning("Failed to play rep count {}: {}", count, exc)
return
self._clear_pending_counts()
self._queue.put(count)
self._queue.put((count, requested_at))
logger.info("Rep audio queued: count={}", count)
def close(self) -> None:
"""停止播报线程并释放资源"""
@@ -66,145 +77,78 @@ class RepAnnouncer:
self._thread.join(timeout=1.0)
self._stop_current_playback()
logger.info("Rep announcer closed")
def _start(self) -> None:
"""初始化并预生成语音缓存"""
self.cache_dir.mkdir(parents=True, exist_ok=True)
"""启动后台播报线程"""
self.audio_dir.mkdir(parents=True, exist_ok=True)
try:
self._prepare_audio_cache()
except Exception as exc:
self.enabled = False
logger.warning("Rep announcer disabled, failed to prepare audio cache: {}", exc)
if self._direct_playback:
import winsound
logger.info("Rep announcer initialized in direct Windows mode, audio_dir={}", self.audio_dir)
return
self._thread = threading.Thread(
target=self._run,
name="RepAnnouncer",
daemon=True,
)
self._thread = threading.Thread(target=self._run, name="RepAnnouncer", daemon=True)
self._thread.start()
logger.info(
"Rep announcer initialized with audio cache, platform={}, max_count={}, cache_dir={}",
sys.platform,
self.max_count,
self.cache_dir,
)
def _prepare_audio_cache(self) -> None:
"""生成 0~max_count 的语音文件,只生成缺失文件"""
if self._use_macos_say:
self._prepare_macos_say_cache()
else:
self._prepare_pyttsx3_cache()
def _prepare_macos_say_cache(self) -> None:
"""macOS: 使用 say 预生成 aiff 文件"""
if shutil.which("say") is None:
raise RuntimeError("macOS say command not found")
for count in range(0, self.max_count + 1):
audio_file = self._audio_path(count)
if audio_file.exists():
continue
subprocess.run(
[
"say",
"-r",
str(self.rate),
"-o",
str(audio_file),
str(count),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
logger.info("macOS say audio cache prepared")
def _prepare_pyttsx3_cache(self) -> None:
"""非 macOS: 使用 pyttsx3 预生成 wav 文件"""
try:
import pyttsx3
except Exception as exc:
raise RuntimeError(f"pyttsx3 unavailable: {exc}") from exc
self._engine = pyttsx3.init()
self._engine.setProperty("rate", self.rate)
self._engine.setProperty("volume", self.volume)
need_generate = False
for count in range(0, self.max_count + 1):
if not self._audio_path(count).exists():
need_generate = True
self._engine.save_to_file(str(count), str(self._audio_path(count)))
if need_generate:
self._engine.runAndWait()
logger.info("pyttsx3 audio cache prepared")
logger.info("Rep announcer initialized in queued mode, audio_dir={}", self.audio_dir)
def _run(self) -> None:
"""后台线程:只负责播放已经生成好的音频文件"""
"""后台线程:从队列取次数,播放对应音频文件"""
while True:
count = self._queue.get()
if count is None:
item = self._queue.get()
if item is None:
return
count, requested_at = item
audio_file = self._audio_path(count)
if not audio_file.exists():
logger.warning("Rep audio file missing: {}", audio_file)
continue
try:
audio_file = self._audio_path(count)
if not audio_file.exists():
logger.warning("Rep audio file missing: {}", audio_file)
continue
self._play_audio(audio_file)
self._play(audio_file)
logger.info(
"Rep audio submitted from queue: count={}, queue_ms={:.1f}",
count,
(time.perf_counter() - requested_at) * 1000,
)
except Exception as exc:
logger.warning("Failed to play rep count {}: {}", count, exc)
def _play_audio(self, audio_file: Path) -> None:
"""根据平台播放音频"""
self._stop_current_playback()
def _play(self, audio_file: Path) -> None:
"""播放音频文件(平台自适应)"""
with self._play_lock:
self._stop_current_playback()
if self._use_macos_say:
self._current_process = subprocess.Popen(
["afplay", str(audio_file)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return
if self._platform == "darwin":
self._current_process = subprocess.Popen(
["afplay", str(audio_file)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
elif self._platform.startswith("win"):
import winsound
if self._use_windows_winsound:
import winsound
# SND_ASYNC 表示异步播放;PURGE 会被 _stop_current_playback 调用中断
winsound.PlaySound(str(audio_file), winsound.SND_FILENAME | winsound.SND_ASYNC)
return
# Linux:优先 paplay,其次 aplay
player = shutil.which("paplay") or shutil.which("aplay")
if player is None:
logger.warning("No audio player found, expected paplay or aplay")
return
self._current_process = subprocess.Popen(
[player, str(audio_file)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
winsound.PlaySound(
str(audio_file),
winsound.SND_FILENAME | winsound.SND_ASYNC | winsound.SND_NODEFAULT,
)
else:
player = shutil.which("paplay") or shutil.which("aplay")
if player is None:
logger.warning("No audio player found")
return
self._current_process = subprocess.Popen(
[player, str(audio_file)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _stop_current_playback(self) -> None:
"""中断当前正在播放的声音"""
if self._use_windows_winsound:
if self._platform.startswith("win"):
try:
import winsound
@@ -215,13 +159,12 @@ class RepAnnouncer:
if self._current_process is not None and self._current_process.poll() is None:
self._current_process.terminate()
self._current_process = None
def _audio_path(self, count: int) -> Path:
"""获取某个次数对应的音频文件路径"""
suffix = ".aiff" if self._use_macos_say else ".wav"
return self.cache_dir / f"{count}{suffix}"
suffix = ".aiff" if self._platform == "darwin" else ".wav"
return self.audio_dir / f"{count}{suffix}"
def _clear_pending_counts(self) -> None:
"""清空队列中等待播放的次数,避免语音堆积"""
@@ -229,7 +172,6 @@ class RepAnnouncer:
try:
item = self._queue.get_nowait()
if item is None:
# close 信号不要吞掉
self._queue.put(None)
return
except queue.Empty:
+12 -1
View File
@@ -2,10 +2,21 @@ from __future__ import annotations
from app.diagnostics.crash_handler import enable_crash_handler
from configs.load import config
from app.audio.generate import generate_rep_audio_files
def startup() -> None:
"""应用启动初始化:开启崩溃日志和日志系统"""
enable_crash_handler(config.logging.dir_path)
from app.core.logging import setup_logging
setup_logging()
# 生成运动次数语音文件
generate_rep_audio_files(
max_count=config.audio.rep_max_count,
rate=config.audio.rep_announcer_rate,
output_dir=config.audio.resolved_audio_dir,
overwrite=False,
trim_leading_silence=config.audio.trim_leading_silence,
trim_silence_threshold=config.audio.trim_silence_threshold,
trim_silence_padding_ms=config.audio.trim_silence_padding_ms,
)
+77
View File
@@ -52,6 +52,7 @@ class DeadBugDetector:
self._result_event = threading.Event()
self._inflight = False
self._inflight_started_at = 0.0
self.last_timing: dict[str, float | bool] = {}
def on_result(pose_result, _image, _timestamp_ms):
with self._result_lock:
@@ -79,7 +80,9 @@ class DeadBugDetector:
def process_frame(self, bgr_frame: np.ndarray, timestamp_ms: int) -> tuple[np.ndarray, DeadBugResult]:
"""处理单帧:姿态检测、指标计算、状态机更新、可视化叠加"""
total_started = time.perf_counter()
timestamp_ms = self._normalize_timestamp(timestamp_ms)
normalize_done = time.perf_counter()
with self._result_lock:
if self._inflight and time.monotonic() - self._inflight_started_at > 0.5:
@@ -90,9 +93,11 @@ class DeadBugDetector:
if should_submit:
self._inflight = True
self._inflight_started_at = time.monotonic()
lock_done = time.perf_counter()
if should_submit:
rgba_frame = bgr_to_rgba(bgr_frame)
convert_done = time.perf_counter()
mp_image = mp.Image(image_format=mp.ImageFormat.SRGBA, data=rgba_frame)
self._result_event.clear()
try:
@@ -102,14 +107,23 @@ class DeadBugDetector:
self._inflight = False
self._inflight_started_at = 0.0
raise
submit_done = time.perf_counter()
self._result_event.wait(timeout=0.08)
wait_done = time.perf_counter()
else:
convert_done = lock_done
submit_done = lock_done
wait_done = lock_done
with self._result_lock:
pose_result = self._latest_result
result_read_done = time.perf_counter()
annotated = bgr_frame.copy()
copy_done = time.perf_counter()
if pose_result is None or not pose_result.pose_landmarks:
self._state.mark_no_pose()
result = DeadBugResult(
rep_count=self._state.rep_count,
phase=DeadBugPhase.NO_POSE,
@@ -119,12 +133,25 @@ class DeadBugDetector:
metrics=None,
)
draw_status_overlay(annotated, result)
self._record_timing(
total_started,
normalize_done,
lock_done,
convert_done,
submit_done,
wait_done,
result_read_done,
copy_done,
time.perf_counter(),
should_submit,
)
return annotated, result
landmarks = [Point(lm.x, lm.y, lm.z, getattr(lm, "visibility", 1.0)) for lm in pose_result.pose_landmarks[0]]
draw_landmarks(annotated, landmarks, REQUIRED_LANDMARKS, visibility_threshold=self.visibility_threshold)
if not has_required_visibility(landmarks, REQUIRED_LANDMARKS, self.visibility_threshold):
self._state.mark_no_pose()
result = DeadBugResult(
rep_count=self._state.rep_count,
phase=DeadBugPhase.NO_POSE,
@@ -134,6 +161,18 @@ class DeadBugDetector:
metrics=None,
)
draw_status_overlay(annotated, result)
self._record_timing(
total_started,
normalize_done,
lock_done,
convert_done,
submit_done,
wait_done,
result_read_done,
copy_done,
time.perf_counter(),
should_submit,
)
return annotated, result
raw = calculate_metrics(
@@ -167,8 +206,46 @@ class DeadBugDetector:
result = self._state.update(metrics)
draw_status_overlay(annotated, result)
self._record_timing(
total_started,
normalize_done,
lock_done,
convert_done,
submit_done,
wait_done,
result_read_done,
copy_done,
time.perf_counter(),
should_submit,
)
return annotated, result
def _record_timing(
self,
total_started: float,
normalize_done: float,
lock_done: float,
convert_done: float,
submit_done: float,
wait_done: float,
result_read_done: float,
copy_done: float,
finished: float,
submitted: bool,
) -> None:
self.last_timing = {
"total_ms": (finished - total_started) * 1000,
"timestamp_ms": (normalize_done - total_started) * 1000,
"lock_ms": (lock_done - normalize_done) * 1000,
"convert_ms": (convert_done - lock_done) * 1000,
"submit_ms": (submit_done - convert_done) * 1000,
"wait_ms": (wait_done - submit_done) * 1000,
"result_read_ms": (result_read_done - wait_done) * 1000,
"copy_ms": (copy_done - result_read_done) * 1000,
"postprocess_draw_ms": (finished - copy_done) * 1000,
"submitted": submitted,
}
def _normalize_timestamp(self, timestamp_ms: int) -> int:
"""确保时间戳严格递增(MediaPipe要求)"""
if timestamp_ms <= self._last_timestamp_ms:
+3 -3
View File
@@ -9,7 +9,7 @@ def has_required_visibility(landmarks: list[Point], required_indices: tuple[int,
def detect_diagonal_extension(metrics: DeadBugMetrics) -> str | None:
"""检测是否存在对角伸展(左臂+右腿 或 右臂+左腿"""
"""检测对角伸展(腿部只允许单侧伸展,手臂允许准备位上举带来的识别重叠"""
if metrics.left_leg_extended and metrics.right_leg_extended:
return None
@@ -21,7 +21,7 @@ def detect_diagonal_extension(metrics: DeadBugMetrics) -> str | None:
def is_ready_position(metrics: DeadBugMetrics) -> bool:
"""判断是否处于准备姿态(膝弯曲且四肢未伸展)"""
"""判断是否处于准备姿态(膝弯曲且双腿未伸展dead bug 准备位允许手臂上举"""
knees_bent = metrics.left_knee_angle <= 140 and metrics.right_knee_angle <= 140
legs_not_extended = not metrics.left_leg_extended and not metrics.right_leg_extended
return knees_bent and legs_not_extended and detect_diagonal_extension(metrics) is None
return knees_bent and legs_not_extended
+118 -8
View File
@@ -3,6 +3,13 @@ from __future__ import annotations
from app.exercises.dead_bug.rules import detect_diagonal_extension, is_ready_position
from app.exercises.dead_bug.types import DeadBugMetrics, DeadBugPhase, DeadBugResult
_SMOOTHING_ALPHA = 0.45
_TREND_DELTA_DEGREES = 1.5
_SIDE_ANGLE_MARGIN = 10.0
_EXTENSION_START_ANGLE = 125.0
_EXTENSION_PEAK_ANGLE = 150.0
_READY_KNEE_ANGLE = 140.0
class DeadBugStateMachine:
"""死虫式动作状态机:管理READY/EXTENDING/NEED_RESET/NO_POSE状态转换"""
@@ -17,11 +24,26 @@ class DeadBugStateMachine:
self._candidate_side: str | None = None
self._candidate_frames = 0
self._reset_frames = 0
self._smooth_left_knee_angle: float | None = None
self._smooth_right_knee_angle: float | None = None
self._left_knee_delta = 0.0
self._right_knee_delta = 0.0
def mark_no_pose(self) -> None:
"""姿态丢失时清掉候选帧;已确认的半程动作保留,等待重新可见后完成回收。"""
if self.phase == DeadBugPhase.READY:
self.phase = DeadBugPhase.NO_POSE
self.active_side = None
self._candidate_side = None
self._candidate_frames = 0
self._reset_frames = 0
def update(self, metrics: DeadBugMetrics) -> DeadBugResult:
"""根据传入指标更新状态机并返回本次结果"""
side = detect_diagonal_extension(metrics)
ready = is_ready_position(metrics)
self._update_knee_trends(metrics)
side = self._detect_motion_side(metrics)
ready = self._is_stable_ready(metrics)
if side is None:
self._candidate_side = None
@@ -33,13 +55,16 @@ class DeadBugStateMachine:
self._candidate_frames = 1
if self.phase in (DeadBugPhase.READY, DeadBugPhase.NO_POSE):
if ready:
self.phase = DeadBugPhase.READY
if self._candidate_frames >= self.extension_confirm_frames and side is not None:
self.phase = DeadBugPhase.EXTENDING
self.active_side = side
self._reset_frames = 0
elif self.phase == DeadBugPhase.EXTENDING:
if side == self.active_side:
if ready or self._active_knee_retracting():
self.phase = DeadBugPhase.NEED_RESET
self._reset_frames = 1 if ready else 0
elif self.phase == DeadBugPhase.NEED_RESET:
if ready:
self._reset_frames += 1
@@ -54,21 +79,106 @@ class DeadBugStateMachine:
self._reset_frames = 0
feedback = list(metrics.feedback)
if side is None and not ready:
display_side = detect_diagonal_extension(metrics)
if display_side is None and not ready:
feedback.append("Extend opposite arm and leg only")
if ready:
feedback.append("Ready position")
elif side == "left_arm_right_leg":
elif display_side == "left_arm_right_leg":
feedback.append("Left arm + right leg")
elif side == "right_arm_left_leg":
elif display_side == "right_arm_left_leg":
feedback.append("Right arm + left leg")
is_standard = side is not None and not metrics.feedback
is_standard = display_side is not None and not metrics.feedback
return DeadBugResult(
rep_count=self.rep_count,
phase=self.phase,
side=side,
side=display_side,
is_standard=is_standard,
feedback=feedback[:3],
metrics=metrics,
)
def _update_knee_trends(self, metrics: DeadBugMetrics) -> None:
"""更新平滑膝角和本帧变化量,用连续趋势抵消单帧抖动。"""
previous_left = self._smooth_left_knee_angle
previous_right = self._smooth_right_knee_angle
if previous_left is None:
self._smooth_left_knee_angle = metrics.left_knee_angle
self._left_knee_delta = 0.0
else:
self._smooth_left_knee_angle = (
_SMOOTHING_ALPHA * metrics.left_knee_angle
+ (1.0 - _SMOOTHING_ALPHA) * previous_left
)
self._left_knee_delta = self._smooth_left_knee_angle - previous_left
if previous_right is None:
self._smooth_right_knee_angle = metrics.right_knee_angle
self._right_knee_delta = 0.0
else:
self._smooth_right_knee_angle = (
_SMOOTHING_ALPHA * metrics.right_knee_angle
+ (1.0 - _SMOOTHING_ALPHA) * previous_right
)
self._right_knee_delta = self._smooth_right_knee_angle - previous_right
def _detect_motion_side(self, metrics: DeadBugMetrics) -> str | None:
"""基于膝角趋势推断正在伸展的对角侧,手臂只作为辅助校验。"""
raw_side = detect_diagonal_extension(metrics)
if raw_side is not None and self._side_has_extension_motion(raw_side):
return raw_side
left = self._smooth_left_knee_angle
right = self._smooth_right_knee_angle
if left is None or right is None:
return raw_side
both_legs_high = left >= _EXTENSION_PEAK_ANGLE and right >= _EXTENSION_PEAK_ANGLE
if both_legs_high and abs(left - right) < _SIDE_ANGLE_MARGIN:
return None
if (
right >= _EXTENSION_START_ANGLE
and right - left >= _SIDE_ANGLE_MARGIN
and (self._right_knee_delta >= _TREND_DELTA_DEGREES or right >= _EXTENSION_PEAK_ANGLE)
):
return "left_arm_right_leg"
if (
left >= _EXTENSION_START_ANGLE
and left - right >= _SIDE_ANGLE_MARGIN
and (self._left_knee_delta >= _TREND_DELTA_DEGREES or left >= _EXTENSION_PEAK_ANGLE)
):
return "right_arm_left_leg"
return raw_side
def _side_has_extension_motion(self, side: str) -> bool:
"""确认对应腿处于伸展区或仍在伸展趋势中。"""
if side == "left_arm_right_leg":
angle = self._smooth_right_knee_angle
delta = self._right_knee_delta
else:
angle = self._smooth_left_knee_angle
delta = self._left_knee_delta
if angle is None:
return True
return angle >= _EXTENSION_PEAK_ANGLE or (
angle >= _EXTENSION_START_ANGLE and delta >= _TREND_DELTA_DEGREES
)
def _is_stable_ready(self, metrics: DeadBugMetrics) -> bool:
"""准备位需要双腿回到屈膝区域;使用平滑膝角避免单帧阈值跳变。"""
left = self._smooth_left_knee_angle
right = self._smooth_right_knee_angle
if left is None or right is None:
return is_ready_position(metrics)
return left <= _READY_KNEE_ANGLE and right <= _READY_KNEE_ANGLE
def _active_knee_retracting(self) -> bool:
"""确认已伸展的那条腿开始回收。"""
if self.active_side == "left_arm_right_leg":
return self._right_knee_delta <= -_TREND_DELTA_DEGREES
if self.active_side == "right_arm_left_leg":
return self._left_knee_delta <= -_TREND_DELTA_DEGREES
return False
+6
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import platform
from typing import Callable
import mediapipe as mp
@@ -27,6 +28,11 @@ class PoseLandmarkerWrapper:
if prefer_gpu:
try:
if platform.system() == "Windows":
logger.warning(
"MediaPipe GPU delegate requested, but MediaPipe Tasks Python does not support GPU delegate on Windows; "
"Intel Iris Xe cannot be used by this backend and CPU fallback is expected"
)
self.delegate = BaseOptions.Delegate.GPU
self._landmarker = self._create(self.delegate, result_callback)
logger.info("MediaPipe PoseLandmarker initialized with GPU delegate")
+119 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import time
import cv2
from aiortc.mediastreams import MediaStreamError
@@ -25,6 +26,39 @@ def _format_pose_debug(pose_result) -> str:
f"ll={metrics.left_leg_extended}, rl={metrics.right_leg_extended})"
)
def _new_perf_window() -> dict:
return {
"frames": 0,
"processed": 0,
"loop_ms": 0.0,
"to_ndarray_ms": 0.0,
"detect_ms": 0.0,
"show_ms": 0.0,
"max_loop_ms": 0.0,
"max_detect_ms": 0.0,
"detector": {},
}
def _add_detector_timing(perf: dict, timing: dict[str, float | bool]) -> None:
detector = perf["detector"]
for key, value in timing.items():
if key == "submitted":
detector[key] = detector.get(key, 0) + (1 if value else 0)
continue
value = float(value)
detector[key] = detector.get(key, 0.0) + value
max_key = f"max_{key}"
detector[max_key] = max(detector.get(max_key, 0.0), value)
def _avg(perf: dict, key: str, denominator: int) -> float:
if denominator <= 0:
return 0.0
return perf.get(key, 0.0) / denominator
class VideoReceiver:
"""视频轨道接收与运动检测流水线"""
@@ -33,7 +67,21 @@ class VideoReceiver:
async def run(self) -> None:
"""持续接收视频帧并进行姿态检测、渲染和语音播报"""
logger.info("Start receiving video frames, process_every_n={}", config.video.process_every_n_frames)
log_every_n_frames = max(1, config.video.log_every_n_frames)
perf_log_every_n_frames = max(1, config.video.perf_log_every_n_frames)
slow_frame_ms = max(0.0, config.video.slow_frame_ms)
logger.info(
"Start receiving video frames, process_every_n={}, log_every_n={}, perf_log_every_n={}, slow_frame_ms={}",
config.video.process_every_n_frames,
log_every_n_frames,
perf_log_every_n_frames,
slow_frame_ms,
)
logger.info(
"OpenCV OpenCL status: have_opencl={}, use_opencl={}",
cv2.ocl.haveOpenCL(),
cv2.ocl.useOpenCL(),
)
frame_count = 0
processed_count = 0
@@ -46,31 +94,51 @@ class VideoReceiver:
)
announcer = RepAnnouncer(
enabled=config.audio.rep_announcer_enabled,
rate=config.audio.rep_announcer_rate,
volume=config.audio.rep_announcer_volume,
max_count=config.audio.rep_max_count,
audio_dir=config.audio.resolved_audio_dir,
)
last_announced_rep = 0
last_pose_result = None
last_annotated = None
perf = _new_perf_window()
try:
while True:
loop_started = time.perf_counter()
frame = await self._track.recv()
frame_count += 1
recv_done = time.perf_counter()
raw_img = frame.to_ndarray(format="bgr24")
ndarray_done = time.perf_counter()
timestamp_ms = int(frame.time * 1000) if frame.time is not None else frame_count * 33
detect_ms = 0.0
if frame_count % config.video.process_every_n_frames == 0 or last_pose_result is None:
detect_started = time.perf_counter()
processed_count += 1
last_annotated, last_pose_result = detector.process_frame(raw_img, timestamp_ms)
detect_ms = (time.perf_counter() - detect_started) * 1000
perf["processed"] += 1
perf["detect_ms"] += detect_ms
perf["max_detect_ms"] = max(perf["max_detect_ms"], detect_ms)
_add_detector_timing(perf, detector.last_timing)
if last_pose_result.rep_count > last_announced_rep:
last_announced_rep = last_pose_result.rep_count
announce_started = time.perf_counter()
announcer.announce_count(last_announced_rep)
logger.info(
"Rep completed and audio requested: count={}, frame={}, announce_call_ms={:.1f}",
last_announced_rep,
frame_count,
(time.perf_counter() - announce_started) * 1000,
)
display_img = last_annotated if last_annotated is not None else raw_img
show_started = time.perf_counter()
show_frame(display_img)
show_done = time.perf_counter()
if frame_count % 100 == 0:
if frame_count % log_every_n_frames == 0:
logger.info(
"Received {} frames, processed={}, raw_shape={}, reps={}, phase={}, feedback={}, {}",
frame_count,
@@ -82,6 +150,53 @@ class VideoReceiver:
_format_pose_debug(last_pose_result) if last_pose_result is not None else "metrics=None",
)
loop_ms = (show_done - loop_started) * 1000
to_ndarray_ms = (ndarray_done - recv_done) * 1000
show_ms = (show_done - show_started) * 1000
perf["frames"] += 1
perf["loop_ms"] += loop_ms
perf["to_ndarray_ms"] += to_ndarray_ms
perf["show_ms"] += show_ms
perf["max_loop_ms"] = max(perf["max_loop_ms"], loop_ms)
if slow_frame_ms and loop_ms >= slow_frame_ms:
logger.warning(
"Slow video frame: frame={}, loop_ms={:.1f}, detect_ms={:.1f}, to_ndarray_ms={:.1f}, show_ms={:.1f}, shape={}",
frame_count,
loop_ms,
detect_ms,
to_ndarray_ms,
show_ms,
raw_img.shape,
)
if frame_count % perf_log_every_n_frames == 0:
frames = perf["frames"]
processed = perf["processed"]
detector_perf = perf["detector"]
logger.info(
"Perf window: frames={}, processed={}, avg_loop_ms={:.1f}, max_loop_ms={:.1f}, avg_to_ndarray_ms={:.1f}, "
"avg_detect_ms={:.1f}, max_detect_ms={:.1f}, avg_show_ms={:.1f}, detector_avg_total_ms={:.1f}, "
"detector_max_total_ms={:.1f}, detector_avg_wait_ms={:.1f}, detector_max_wait_ms={:.1f}, "
"detector_avg_convert_ms={:.1f}, detector_avg_postprocess_draw_ms={:.1f}, detector_submitted={}",
frames,
processed,
_avg(perf, "loop_ms", frames),
perf["max_loop_ms"],
_avg(perf, "to_ndarray_ms", frames),
_avg(perf, "detect_ms", processed),
perf["max_detect_ms"],
_avg(perf, "show_ms", frames),
_avg(detector_perf, "total_ms", processed),
detector_perf.get("max_total_ms", 0.0),
_avg(detector_perf, "wait_ms", processed),
detector_perf.get("max_wait_ms", 0.0),
_avg(detector_perf, "convert_ms", processed),
_avg(detector_perf, "postprocess_draw_ms", processed),
detector_perf.get("submitted", 0),
)
perf = _new_perf_window()
if is_esc_pressed():
logger.info("ESC pressed, closing display")
break
+7 -1
View File
@@ -7,7 +7,10 @@ server:
max_ws_size: 10485760 # 10 MB
video:
process_every_n_frames: 1
process_every_n_frames: 2
log_every_n_frames: 30
perf_log_every_n_frames: 30
slow_frame_ms: 100
model:
path: "./pose_models/pose_landmarker_full.task"
@@ -24,6 +27,9 @@ audio:
rep_announcer_volume: 1.0
rep_max_count: 200 # 预生成语音文件的最大次数
rep_audio_dir: "" # 空 = 自动使用 app/audio/reps
trim_leading_silence: true
trim_silence_threshold: 500
trim_silence_padding_ms: 20
logging:
dir: logs
+8 -2
View File
@@ -15,7 +15,10 @@ class ServerConfig:
@dataclass
class VideoConfig:
"""视频帧处理配置"""
process_every_n_frames: int = 1
process_every_n_frames: int = 2
log_every_n_frames: int = 30
perf_log_every_n_frames: int = 30
slow_frame_ms: float = 100.0
@dataclass
@@ -48,13 +51,16 @@ class AudioConfig:
rep_announcer_volume: float = 1.0
rep_max_count: int = 200
rep_audio_dir: str = ""
trim_leading_silence: bool = True
trim_silence_threshold: int = 500
trim_silence_padding_ms: int = 20
@property
def resolved_audio_dir(self) -> Path:
"""返回语音文件目录的绝对路径"""
if self.rep_audio_dir:
return Path(self.rep_audio_dir)
return Path(__file__).resolve().parent.parent / "app" / "audio" / "reps"
return Path(__file__).resolve().parent.parent / "resources" / "audio" / "reps"
@dataclass
+33
View File
@@ -49,6 +49,28 @@ class TestDeadBugRules:
)
assert detect_diagonal_extension(metrics) == "left_arm_right_leg"
def test_detect_diagonal_extension_allows_ready_arm_overlap(self):
"""测试:准备位双臂上举时,单腿对侧伸展仍应识别"""
metrics = DeadBugMetrics(
left_arm_extended=True, right_arm_extended=True,
left_leg_extended=False, right_leg_extended=True,
left_elbow_angle=160, right_elbow_angle=160,
left_knee_angle=90, right_knee_angle=160,
feedback=[],
)
assert detect_diagonal_extension(metrics) == "left_arm_right_leg"
def test_detect_diagonal_extension_rejects_both_legs(self):
"""测试:双腿同时伸展不应识别为可计数对角伸展"""
metrics = DeadBugMetrics(
left_arm_extended=True, right_arm_extended=True,
left_leg_extended=True, right_leg_extended=True,
left_elbow_angle=160, right_elbow_angle=160,
left_knee_angle=160, right_knee_angle=160,
feedback=[],
)
assert detect_diagonal_extension(metrics) is None
def test_is_ready_position(self):
"""测试:膝盖弯曲且四肢收缩应识别为准备姿态"""
metrics = DeadBugMetrics(
@@ -60,6 +82,17 @@ class TestDeadBugRules:
)
assert is_ready_position(metrics)
def test_is_ready_allows_arms_extended(self):
"""测试:dead bug 准备位允许双臂上举"""
metrics = DeadBugMetrics(
left_arm_extended=True, right_arm_extended=True,
left_leg_extended=False, right_leg_extended=False,
left_elbow_angle=160, right_elbow_angle=160,
left_knee_angle=100, right_knee_angle=100,
feedback=[],
)
assert is_ready_position(metrics)
def test_is_not_ready_legs_extended(self):
"""测试:腿部伸展时不识别为准备姿态"""
metrics = DeadBugMetrics(
+85
View File
@@ -26,6 +26,36 @@ class TestDeadBugStateMachine:
feedback=[],
)
def _both_legs_extended(self) -> DeadBugMetrics:
"""构建双腿同时伸展的非标准姿态"""
return DeadBugMetrics(
left_arm_extended=True, right_arm_extended=True,
left_leg_extended=True, right_leg_extended=True,
left_elbow_angle=160, right_elbow_angle=160,
left_knee_angle=160, right_knee_angle=160,
feedback=[],
)
def _arms_extended_ready_legs(self) -> DeadBugMetrics:
"""构建腿已收回但手臂未收回的姿态"""
return DeadBugMetrics(
left_arm_extended=True, right_arm_extended=True,
left_leg_extended=False, right_leg_extended=False,
left_elbow_angle=160, right_elbow_angle=160,
left_knee_angle=100, right_knee_angle=100,
feedback=[],
)
def _right_knee_angle(self, angle: float) -> DeadBugMetrics:
"""构建右膝角连续变化但伸展布尔值尚未稳定的姿态"""
return DeadBugMetrics(
left_arm_extended=True, right_arm_extended=True,
left_leg_extended=False, right_leg_extended=False,
left_elbow_angle=160, right_elbow_angle=160,
left_knee_angle=90, right_knee_angle=angle,
feedback=[],
)
def test_initial_state(self):
"""测试:状态机初始化后应为READY且计数为0"""
sm = DeadBugStateMachine()
@@ -46,3 +76,58 @@ class TestDeadBugStateMachine:
assert sm.phase == DeadBugPhase.READY
sm.update(self._extended_left())
assert sm.phase == DeadBugPhase.EXTENDING
def test_confirm_extension_from_knee_angle_trend(self):
"""测试:膝角连续上升时,不依赖单帧伸展布尔值也能确认伸展"""
sm = DeadBugStateMachine(extension_confirm_frames=2, reset_confirm_frames=2)
sm.update(self._right_knee_angle(100))
sm.update(self._right_knee_angle(130))
assert sm.phase == DeadBugPhase.READY
sm.update(self._right_knee_angle(145))
sm.update(self._right_knee_angle(150))
assert sm.phase == DeadBugPhase.EXTENDING
def test_full_rep_counts_once_after_strict_reset(self):
"""测试:确认伸展后,只有严格回到准备姿态才计一次"""
sm = DeadBugStateMachine(extension_confirm_frames=2, reset_confirm_frames=2)
sm.update(self._extended_left())
sm.update(self._extended_left())
assert sm.phase == DeadBugPhase.EXTENDING
sm.update(self._arms_extended_ready_legs())
assert sm.rep_count == 0
assert sm.phase == DeadBugPhase.NEED_RESET
sm.update(self._ready_metrics())
result = sm.update(self._ready_metrics())
assert result.rep_count == 1
assert sm.phase == DeadBugPhase.READY
result = sm.update(self._ready_metrics())
assert result.rep_count == 1
def test_both_legs_do_not_start_rep(self):
"""测试:双腿同时伸展不进入计数流程"""
sm = DeadBugStateMachine(extension_confirm_frames=2, reset_confirm_frames=2)
sm.update(self._both_legs_extended())
result = sm.update(self._both_legs_extended())
assert result.rep_count == 0
assert sm.phase == DeadBugPhase.READY
def test_no_pose_preserves_confirmed_rep_until_reset(self):
"""测试:已确认伸展后短暂丢姿态,回到准备位仍能完成计数"""
sm = DeadBugStateMachine(extension_confirm_frames=2, reset_confirm_frames=2)
sm.update(self._extended_left())
sm.update(self._extended_left())
assert sm.phase == DeadBugPhase.EXTENDING
sm.mark_no_pose()
sm.update(self._ready_metrics())
result = sm.update(self._ready_metrics())
assert result.rep_count == 1
assert sm.phase == DeadBugPhase.READY