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/ .idea/
__pycache__/ __pycache__/
logs/ logs/
resources/
+147 -44
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import platform import platform
import shutil import shutil
import subprocess import subprocess
import wave
from pathlib import Path from pathlib import Path
from loguru import logger from loguru import logger
@@ -15,54 +16,65 @@ def generate_rep_audio_files(
rate: int, rate: int,
output_dir: Path, output_dir: Path,
overwrite: bool = False, overwrite: bool = False,
trim_leading_silence: bool = True,
trim_silence_threshold: int = 500,
trim_silence_padding_ms: int = 20,
) -> None: ) -> None:
""" """
确保 0~max_count 的运动次数语音 wav 文件存在。 确保 0~max_count 的运动次数语音 wav 文件存在。
默认生成到: 默认生成到:
app/audio/reps/0.wav resources/audio/reps/0.aiff # macOS
app/audio/reps/1.wav 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) output_dir.mkdir(parents=True, exist_ok=True)
system = platform.system().lower()
suffix = ".aiff" if system == "darwin" else ".wav"
missing_counts = [ missing_counts = [
count count
for count in range(0, max_count + 1) 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: if not missing_counts:
logger.info("Rep audio files already prepared: {}", output_dir) 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: else:
_generate_with_pyttsx3( logger.info(
counts=missing_counts, "Preparing rep audio files, system={}, count={}, output_dir={}",
output_dir=output_dir, system,
rate=rate, 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( def _generate_with_macos_say(
@@ -79,22 +91,27 @@ def _generate_with_macos_say(
raise RuntimeError("macOS say command not found") raise RuntimeError("macOS say command not found")
for count in counts: for count in counts:
audio_file = _audio_path(output_dir, count) audio_file = _audio_path(output_dir, count, suffix=".aiff")
subprocess.run( try:
[ subprocess.run(
"say", [
"-r", "say",
str(rate), "-r",
"--file-format=WAVE", str(rate),
"-o", "--file-format=AIFF",
str(audio_file), "-o",
str(count), str(audio_file),
], str(count),
stdout=subprocess.DEVNULL, ],
stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
check=True, 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( def _generate_with_pyttsx3(
@@ -114,11 +131,97 @@ def _generate_with_pyttsx3(
engine.setProperty("volume", 1.0) engine.setProperty("volume", 1.0)
for count in counts: 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.save_to_file(str(count), str(audio_file))
engine.runAndWait() engine.runAndWait()
def _audio_path(output_dir: Path, count: int) -> Path: def _audio_path(output_dir: Path, count: int, *, suffix: str) -> Path:
return output_dir / f"{count}.wav" 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 from __future__ import annotations
import os
import queue import queue
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading import threading
import time
from pathlib import Path from pathlib import Path
from typing import Any
from loguru import logger from loguru import logger
class RepAnnouncer: class RepAnnouncer:
"""运动次数语音播报器:预生成 0~200 音频文件,运行时直接播放""" """运动次数语音播报器:读取预生成的音频文件直接播放"""
def __init__( def __init__(
self, self,
*, *,
enabled: bool = True, enabled: bool = True,
rate: int = 185,
volume: float = 1.0,
max_count: int = 200, max_count: int = 200,
cache_dir: str | Path = "runtime/tts_cache/reps", audio_dir: str | Path = "resources/audio/reps",
) -> None: ) -> None:
self.enabled = enabled self.enabled = enabled
self.rate = rate
self.volume = volume
self.max_count = max_count 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._thread: threading.Thread | None = None
self._engine: Any | None = None
self._current_process: subprocess.Popen | None = None self._current_process: subprocess.Popen | None = None
self._closed = False self._closed = False
self._play_lock = threading.Lock()
self._use_macos_say = sys.platform == "darwin" self._platform = sys.platform
self._use_windows_winsound = sys.platform.startswith("win") self._direct_playback = self._platform.startswith("win")
if self.enabled: if self.enabled:
self._start() self._start()
@@ -46,13 +41,29 @@ class RepAnnouncer:
"""将次数放入队列,后台线程播放对应音频""" """将次数放入队列,后台线程播放对应音频"""
if not self.enabled or self._closed: if not self.enabled or self._closed:
return return
if count <= 0 or count > self.max_count: if count <= 0 or count > self.max_count:
return 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._clear_pending_counts()
self._queue.put(count) self._queue.put((count, requested_at))
logger.info("Rep audio queued: count={}", count)
def close(self) -> None: def close(self) -> None:
"""停止播报线程并释放资源""" """停止播报线程并释放资源"""
@@ -66,145 +77,78 @@ class RepAnnouncer:
self._thread.join(timeout=1.0) self._thread.join(timeout=1.0)
self._stop_current_playback() self._stop_current_playback()
logger.info("Rep announcer closed") logger.info("Rep announcer closed")
def _start(self) -> None: def _start(self) -> None:
"""初始化并预生成语音缓存""" """启动后台播报线程"""
self.cache_dir.mkdir(parents=True, exist_ok=True) self.audio_dir.mkdir(parents=True, exist_ok=True)
try: if self._direct_playback:
self._prepare_audio_cache() import winsound
except Exception as exc:
self.enabled = False logger.info("Rep announcer initialized in direct Windows mode, audio_dir={}", self.audio_dir)
logger.warning("Rep announcer disabled, failed to prepare audio cache: {}", exc)
return return
self._thread = threading.Thread( self._thread = threading.Thread(target=self._run, name="RepAnnouncer", daemon=True)
target=self._run,
name="RepAnnouncer",
daemon=True,
)
self._thread.start() self._thread.start()
logger.info( logger.info("Rep announcer initialized in queued mode, audio_dir={}", self.audio_dir)
"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")
def _run(self) -> None: def _run(self) -> None:
"""后台线程:只负责播放已经生成好的音频文件""" """后台线程:从队列取次数,播放对应音频文件"""
while True: while True:
count = self._queue.get() item = self._queue.get()
if item is None:
if count is None:
return 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: try:
audio_file = self._audio_path(count) self._play(audio_file)
logger.info(
if not audio_file.exists(): "Rep audio submitted from queue: count={}, queue_ms={:.1f}",
logger.warning("Rep audio file missing: {}", audio_file) count,
continue (time.perf_counter() - requested_at) * 1000,
)
self._play_audio(audio_file)
except Exception as exc: except Exception as exc:
logger.warning("Failed to play rep count {}: {}", count, exc) logger.warning("Failed to play rep count {}: {}", count, exc)
def _play_audio(self, audio_file: Path) -> None: def _play(self, audio_file: Path) -> None:
"""根据平台播放音频""" """播放音频文件(平台自适应)"""
self._stop_current_playback() with self._play_lock:
self._stop_current_playback()
if self._use_macos_say: if self._platform == "darwin":
self._current_process = subprocess.Popen( self._current_process = subprocess.Popen(
["afplay", str(audio_file)], ["afplay", str(audio_file)],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
return elif self._platform.startswith("win"):
import winsound
if self._use_windows_winsound: winsound.PlaySound(
import winsound str(audio_file),
winsound.SND_FILENAME | winsound.SND_ASYNC | winsound.SND_NODEFAULT,
# SND_ASYNC 表示异步播放;PURGE 会被 _stop_current_playback 调用中断 )
winsound.PlaySound(str(audio_file), winsound.SND_FILENAME | winsound.SND_ASYNC) else:
return player = shutil.which("paplay") or shutil.which("aplay")
if player is None:
# Linux:优先 paplay,其次 aplay logger.warning("No audio player found")
player = shutil.which("paplay") or shutil.which("aplay") return
if player is None: self._current_process = subprocess.Popen(
logger.warning("No audio player found, expected paplay or aplay") [player, str(audio_file)],
return stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
self._current_process = subprocess.Popen( )
[player, str(audio_file)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _stop_current_playback(self) -> None: def _stop_current_playback(self) -> None:
"""中断当前正在播放的声音""" """中断当前正在播放的声音"""
if self._use_windows_winsound: if self._platform.startswith("win"):
try: try:
import winsound import winsound
@@ -215,13 +159,12 @@ class RepAnnouncer:
if self._current_process is not None and self._current_process.poll() is None: if self._current_process is not None and self._current_process.poll() is None:
self._current_process.terminate() self._current_process.terminate()
self._current_process = None self._current_process = None
def _audio_path(self, count: int) -> Path: def _audio_path(self, count: int) -> Path:
"""获取某个次数对应的音频文件路径""" """获取某个次数对应的音频文件路径"""
suffix = ".aiff" if self._use_macos_say else ".wav" suffix = ".aiff" if self._platform == "darwin" else ".wav"
return self.cache_dir / f"{count}{suffix}" return self.audio_dir / f"{count}{suffix}"
def _clear_pending_counts(self) -> None: def _clear_pending_counts(self) -> None:
"""清空队列中等待播放的次数,避免语音堆积""" """清空队列中等待播放的次数,避免语音堆积"""
@@ -229,7 +172,6 @@ class RepAnnouncer:
try: try:
item = self._queue.get_nowait() item = self._queue.get_nowait()
if item is None: if item is None:
# close 信号不要吞掉
self._queue.put(None) self._queue.put(None)
return return
except queue.Empty: 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 app.diagnostics.crash_handler import enable_crash_handler
from configs.load import config from configs.load import config
from app.audio.generate import generate_rep_audio_files
def startup() -> None: def startup() -> None:
"""应用启动初始化:开启崩溃日志和日志系统""" """应用启动初始化:开启崩溃日志和日志系统"""
enable_crash_handler(config.logging.dir_path) enable_crash_handler(config.logging.dir_path)
from app.core.logging import setup_logging from app.core.logging import setup_logging
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._result_event = threading.Event()
self._inflight = False self._inflight = False
self._inflight_started_at = 0.0 self._inflight_started_at = 0.0
self.last_timing: dict[str, float | bool] = {}
def on_result(pose_result, _image, _timestamp_ms): def on_result(pose_result, _image, _timestamp_ms):
with self._result_lock: 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]: 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) timestamp_ms = self._normalize_timestamp(timestamp_ms)
normalize_done = time.perf_counter()
with self._result_lock: with self._result_lock:
if self._inflight and time.monotonic() - self._inflight_started_at > 0.5: if self._inflight and time.monotonic() - self._inflight_started_at > 0.5:
@@ -90,9 +93,11 @@ class DeadBugDetector:
if should_submit: if should_submit:
self._inflight = True self._inflight = True
self._inflight_started_at = time.monotonic() self._inflight_started_at = time.monotonic()
lock_done = time.perf_counter()
if should_submit: if should_submit:
rgba_frame = bgr_to_rgba(bgr_frame) rgba_frame = bgr_to_rgba(bgr_frame)
convert_done = time.perf_counter()
mp_image = mp.Image(image_format=mp.ImageFormat.SRGBA, data=rgba_frame) mp_image = mp.Image(image_format=mp.ImageFormat.SRGBA, data=rgba_frame)
self._result_event.clear() self._result_event.clear()
try: try:
@@ -102,14 +107,23 @@ class DeadBugDetector:
self._inflight = False self._inflight = False
self._inflight_started_at = 0.0 self._inflight_started_at = 0.0
raise raise
submit_done = time.perf_counter()
self._result_event.wait(timeout=0.08) 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: with self._result_lock:
pose_result = self._latest_result pose_result = self._latest_result
result_read_done = time.perf_counter()
annotated = bgr_frame.copy() annotated = bgr_frame.copy()
copy_done = time.perf_counter()
if pose_result is None or not pose_result.pose_landmarks: if pose_result is None or not pose_result.pose_landmarks:
self._state.mark_no_pose()
result = DeadBugResult( result = DeadBugResult(
rep_count=self._state.rep_count, rep_count=self._state.rep_count,
phase=DeadBugPhase.NO_POSE, phase=DeadBugPhase.NO_POSE,
@@ -119,12 +133,25 @@ class DeadBugDetector:
metrics=None, metrics=None,
) )
draw_status_overlay(annotated, result) 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 return annotated, result
landmarks = [Point(lm.x, lm.y, lm.z, getattr(lm, "visibility", 1.0)) for lm in pose_result.pose_landmarks[0]] 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) draw_landmarks(annotated, landmarks, REQUIRED_LANDMARKS, visibility_threshold=self.visibility_threshold)
if not has_required_visibility(landmarks, REQUIRED_LANDMARKS, self.visibility_threshold): if not has_required_visibility(landmarks, REQUIRED_LANDMARKS, self.visibility_threshold):
self._state.mark_no_pose()
result = DeadBugResult( result = DeadBugResult(
rep_count=self._state.rep_count, rep_count=self._state.rep_count,
phase=DeadBugPhase.NO_POSE, phase=DeadBugPhase.NO_POSE,
@@ -134,6 +161,18 @@ class DeadBugDetector:
metrics=None, metrics=None,
) )
draw_status_overlay(annotated, result) 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 return annotated, result
raw = calculate_metrics( raw = calculate_metrics(
@@ -167,8 +206,46 @@ class DeadBugDetector:
result = self._state.update(metrics) result = self._state.update(metrics)
draw_status_overlay(annotated, result) 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 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: def _normalize_timestamp(self, timestamp_ms: int) -> int:
"""确保时间戳严格递增(MediaPipe要求)""" """确保时间戳严格递增(MediaPipe要求)"""
if timestamp_ms <= self._last_timestamp_ms: 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: def detect_diagonal_extension(metrics: DeadBugMetrics) -> str | None:
"""检测是否存在对角伸展(左臂+右腿 或 右臂+左腿""" """检测对角伸展(腿部只允许单侧伸展,手臂允许准备位上举带来的识别重叠"""
if metrics.left_leg_extended and metrics.right_leg_extended: if metrics.left_leg_extended and metrics.right_leg_extended:
return None return None
@@ -21,7 +21,7 @@ def detect_diagonal_extension(metrics: DeadBugMetrics) -> str | None:
def is_ready_position(metrics: DeadBugMetrics) -> bool: def is_ready_position(metrics: DeadBugMetrics) -> bool:
"""判断是否处于准备姿态(膝弯曲且四肢未伸展)""" """判断是否处于准备姿态(膝弯曲且双腿未伸展dead bug 准备位允许手臂上举"""
knees_bent = metrics.left_knee_angle <= 140 and metrics.right_knee_angle <= 140 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 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.rules import detect_diagonal_extension, is_ready_position
from app.exercises.dead_bug.types import DeadBugMetrics, DeadBugPhase, DeadBugResult 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: class DeadBugStateMachine:
"""死虫式动作状态机:管理READY/EXTENDING/NEED_RESET/NO_POSE状态转换""" """死虫式动作状态机:管理READY/EXTENDING/NEED_RESET/NO_POSE状态转换"""
@@ -17,11 +24,26 @@ class DeadBugStateMachine:
self._candidate_side: str | None = None self._candidate_side: str | None = None
self._candidate_frames = 0 self._candidate_frames = 0
self._reset_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: def update(self, metrics: DeadBugMetrics) -> DeadBugResult:
"""根据传入指标更新状态机并返回本次结果""" """根据传入指标更新状态机并返回本次结果"""
side = detect_diagonal_extension(metrics) self._update_knee_trends(metrics)
ready = is_ready_position(metrics)
side = self._detect_motion_side(metrics)
ready = self._is_stable_ready(metrics)
if side is None: if side is None:
self._candidate_side = None self._candidate_side = None
@@ -33,13 +55,16 @@ class DeadBugStateMachine:
self._candidate_frames = 1 self._candidate_frames = 1
if self.phase in (DeadBugPhase.READY, DeadBugPhase.NO_POSE): 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: if self._candidate_frames >= self.extension_confirm_frames and side is not None:
self.phase = DeadBugPhase.EXTENDING self.phase = DeadBugPhase.EXTENDING
self.active_side = side self.active_side = side
self._reset_frames = 0 self._reset_frames = 0
elif self.phase == DeadBugPhase.EXTENDING: elif self.phase == DeadBugPhase.EXTENDING:
if side == self.active_side: if ready or self._active_knee_retracting():
self.phase = DeadBugPhase.NEED_RESET self.phase = DeadBugPhase.NEED_RESET
self._reset_frames = 1 if ready else 0
elif self.phase == DeadBugPhase.NEED_RESET: elif self.phase == DeadBugPhase.NEED_RESET:
if ready: if ready:
self._reset_frames += 1 self._reset_frames += 1
@@ -54,21 +79,106 @@ class DeadBugStateMachine:
self._reset_frames = 0 self._reset_frames = 0
feedback = list(metrics.feedback) 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") feedback.append("Extend opposite arm and leg only")
if ready: if ready:
feedback.append("Ready position") feedback.append("Ready position")
elif side == "left_arm_right_leg": elif display_side == "left_arm_right_leg":
feedback.append("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") 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( return DeadBugResult(
rep_count=self.rep_count, rep_count=self.rep_count,
phase=self.phase, phase=self.phase,
side=side, side=display_side,
is_standard=is_standard, is_standard=is_standard,
feedback=feedback[:3], feedback=feedback[:3],
metrics=metrics, 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 from __future__ import annotations
import platform
from typing import Callable from typing import Callable
import mediapipe as mp import mediapipe as mp
@@ -27,6 +28,11 @@ class PoseLandmarkerWrapper:
if prefer_gpu: if prefer_gpu:
try: 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.delegate = BaseOptions.Delegate.GPU
self._landmarker = self._create(self.delegate, result_callback) self._landmarker = self._create(self.delegate, result_callback)
logger.info("MediaPipe PoseLandmarker initialized with GPU delegate") logger.info("MediaPipe PoseLandmarker initialized with GPU delegate")
+119 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time
import cv2 import cv2
from aiortc.mediastreams import MediaStreamError 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})" 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: class VideoReceiver:
"""视频轨道接收与运动检测流水线""" """视频轨道接收与运动检测流水线"""
@@ -33,7 +67,21 @@ class VideoReceiver:
async def run(self) -> None: 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 frame_count = 0
processed_count = 0 processed_count = 0
@@ -46,31 +94,51 @@ class VideoReceiver:
) )
announcer = RepAnnouncer( announcer = RepAnnouncer(
enabled=config.audio.rep_announcer_enabled, enabled=config.audio.rep_announcer_enabled,
rate=config.audio.rep_announcer_rate, max_count=config.audio.rep_max_count,
volume=config.audio.rep_announcer_volume, audio_dir=config.audio.resolved_audio_dir,
) )
last_announced_rep = 0 last_announced_rep = 0
last_pose_result = None last_pose_result = None
last_annotated = None last_annotated = None
perf = _new_perf_window()
try: try:
while True: while True:
loop_started = time.perf_counter()
frame = await self._track.recv() frame = await self._track.recv()
frame_count += 1 frame_count += 1
recv_done = time.perf_counter()
raw_img = frame.to_ndarray(format="bgr24") 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 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: if frame_count % config.video.process_every_n_frames == 0 or last_pose_result is None:
detect_started = time.perf_counter()
processed_count += 1 processed_count += 1
last_annotated, last_pose_result = detector.process_frame(raw_img, timestamp_ms) 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: if last_pose_result.rep_count > last_announced_rep:
last_announced_rep = last_pose_result.rep_count last_announced_rep = last_pose_result.rep_count
announce_started = time.perf_counter()
announcer.announce_count(last_announced_rep) 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 display_img = last_annotated if last_annotated is not None else raw_img
show_started = time.perf_counter()
show_frame(display_img) show_frame(display_img)
show_done = time.perf_counter()
if frame_count % 100 == 0: if frame_count % log_every_n_frames == 0:
logger.info( logger.info(
"Received {} frames, processed={}, raw_shape={}, reps={}, phase={}, feedback={}, {}", "Received {} frames, processed={}, raw_shape={}, reps={}, phase={}, feedback={}, {}",
frame_count, frame_count,
@@ -82,6 +150,53 @@ class VideoReceiver:
_format_pose_debug(last_pose_result) if last_pose_result is not None else "metrics=None", _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(): if is_esc_pressed():
logger.info("ESC pressed, closing display") logger.info("ESC pressed, closing display")
break break
+7 -1
View File
@@ -7,7 +7,10 @@ server:
max_ws_size: 10485760 # 10 MB max_ws_size: 10485760 # 10 MB
video: 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: model:
path: "./pose_models/pose_landmarker_full.task" path: "./pose_models/pose_landmarker_full.task"
@@ -24,6 +27,9 @@ audio:
rep_announcer_volume: 1.0 rep_announcer_volume: 1.0
rep_max_count: 200 # 预生成语音文件的最大次数 rep_max_count: 200 # 预生成语音文件的最大次数
rep_audio_dir: "" # 空 = 自动使用 app/audio/reps rep_audio_dir: "" # 空 = 自动使用 app/audio/reps
trim_leading_silence: true
trim_silence_threshold: 500
trim_silence_padding_ms: 20
logging: logging:
dir: logs dir: logs
+8 -2
View File
@@ -15,7 +15,10 @@ class ServerConfig:
@dataclass @dataclass
class VideoConfig: 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 @dataclass
@@ -48,13 +51,16 @@ class AudioConfig:
rep_announcer_volume: float = 1.0 rep_announcer_volume: float = 1.0
rep_max_count: int = 200 rep_max_count: int = 200
rep_audio_dir: str = "" rep_audio_dir: str = ""
trim_leading_silence: bool = True
trim_silence_threshold: int = 500
trim_silence_padding_ms: int = 20
@property @property
def resolved_audio_dir(self) -> Path: def resolved_audio_dir(self) -> Path:
"""返回语音文件目录的绝对路径""" """返回语音文件目录的绝对路径"""
if self.rep_audio_dir: if self.rep_audio_dir:
return Path(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 @dataclass
+33
View File
@@ -49,6 +49,28 @@ class TestDeadBugRules:
) )
assert detect_diagonal_extension(metrics) == "left_arm_right_leg" 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): def test_is_ready_position(self):
"""测试:膝盖弯曲且四肢收缩应识别为准备姿态""" """测试:膝盖弯曲且四肢收缩应识别为准备姿态"""
metrics = DeadBugMetrics( metrics = DeadBugMetrics(
@@ -60,6 +82,17 @@ class TestDeadBugRules:
) )
assert is_ready_position(metrics) 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): def test_is_not_ready_legs_extended(self):
"""测试:腿部伸展时不识别为准备姿态""" """测试:腿部伸展时不识别为准备姿态"""
metrics = DeadBugMetrics( metrics = DeadBugMetrics(
+85
View File
@@ -26,6 +26,36 @@ class TestDeadBugStateMachine:
feedback=[], 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): def test_initial_state(self):
"""测试:状态机初始化后应为READY且计数为0""" """测试:状态机初始化后应为READY且计数为0"""
sm = DeadBugStateMachine() sm = DeadBugStateMachine()
@@ -46,3 +76,58 @@ class TestDeadBugStateMachine:
assert sm.phase == DeadBugPhase.READY assert sm.phase == DeadBugPhase.READY
sm.update(self._extended_left()) sm.update(self._extended_left())
assert sm.phase == DeadBugPhase.EXTENDING 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