Compare commits

...

6 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
wsy182 b45a8e2e85 Add audio generation config, refactor rep_announcer
- AudioConfig now includes rep_max_count and rep_audio_dir
- app/audio/generate.py uses config instead of hardcoded constants
- RepAnnouncer rewrote with pre-generated audio cache
- Supports Windows winsound, macOS afplay, Linux paplay/aplay
- Pin requirements back to mediapipe==0.10.21 with numpy<2
2026-06-10 11:42:40 +08:00
wsy182 1f6c3f3de8 refactor(vision): 优化姿态关键点检测器的初始化逻辑
- 移除未使用的 threading 和 time 模块导入
- 统一委托类型的使用,避免硬编码委托类型
- 简化 GPU 和 CPU 委托的创建流程
- 修复委托类型传递的一致性问题
2026-06-10 11:26:39 +08:00
wsy182 37b85cd683 chore(deps): 更新依赖包版本
- 将 numpy 版本从 >=1.26,<2 更新为 >=2.4.6
- 将 mediapipe 版本从 ==0.10.21 更新为 ==0.10.35
- 保持其他依赖包版本不变
- 确保依赖版本兼容性
2026-06-10 10:35:45 +08:00
14 changed files with 848 additions and 73 deletions
+2
View File
@@ -2,3 +2,5 @@
.idea/ .idea/
__pycache__/ __pycache__/
logs/ logs/
resources/
+227
View File
@@ -0,0 +1,227 @@
# app/audio/generate.py
from __future__ import annotations
import platform
import shutil
import subprocess
import wave
from pathlib import Path
from loguru import logger
def generate_rep_audio_files(
*,
max_count: int,
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 文件存在。
默认生成到:
resources/audio/reps/0.aiff # macOS
resources/audio/reps/0.wav # Windows / Linux
...
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, suffix=suffix).exists()
]
if not missing_counts:
logger.info("Rep audio files already prepared: {}", output_dir)
else:
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("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(
*,
counts: list[int],
output_dir: Path,
rate: int,
) -> None:
"""macOS 使用 say 命令生成 wav。"""
if platform.system().lower() != "darwin":
raise RuntimeError("say command is only available on macOS")
if shutil.which("say") is None:
raise RuntimeError("macOS say command not found")
for count in counts:
audio_file = _audio_path(output_dir, count, suffix=".aiff")
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(
*,
counts: list[int],
output_dir: Path,
rate: int,
) -> None:
"""Windows / Linux 使用 pyttsx3 生成 wav。"""
try:
import pyttsx3
except Exception as exc:
raise RuntimeError(f"pyttsx3 unavailable: {exc}") from exc
engine = pyttsx3.init()
engine.setProperty("rate", rate)
engine.setProperty("volume", 1.0)
for count in counts:
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, *, 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)
+138 -50
View File
@@ -1,90 +1,178 @@
from __future__ import annotations from __future__ import annotations
import queue import queue
import shutil
import subprocess import subprocess
import sys import sys
import threading import threading
from typing import Any import time
from pathlib import Path
from loguru import logger from loguru import logger
class RepAnnouncer:
"""运动次数语音播报器"""
def __init__(self, *, enabled: bool = True, rate: int = 185, volume: float = 1.0) -> None: class RepAnnouncer:
"""初始化TTS引擎(macOS用say,其他系统用pyttsx3""" """运动次数语音播报器:读取预生成的音频文件直接播放"""
def __init__(
self,
*,
enabled: bool = True,
max_count: int = 200,
audio_dir: str | Path = "resources/audio/reps",
) -> None:
self.enabled = enabled self.enabled = enabled
self.rate = rate self.max_count = max_count
self.volume = volume self.audio_dir = Path(audio_dir)
self._queue: queue.Queue[str | 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._use_macos_say = False
self._current_process: subprocess.Popen | None = None self._current_process: subprocess.Popen | None = None
self._closed = False
self._play_lock = threading.Lock()
self._platform = sys.platform
self._direct_playback = self._platform.startswith("win")
if self.enabled: if self.enabled:
self._start() self._start()
def announce_count(self, count: int) -> None: def announce_count(self, count: int) -> None:
"""将次数放入队列进行异步语音播报""" """将次数放入队列,后台线程播放对应音频"""
if not self.enabled or count <= 0: if not self.enabled or self._closed:
return return
while True: 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: try:
self._queue.get_nowait() self._play(audio_file)
except queue.Empty: logger.info(
break "Rep audio submitted immediately: count={}, submit_ms={:.1f}",
self._queue.put(str(count)) 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, requested_at))
logger.info("Rep audio queued: count={}", count)
def close(self) -> None: def close(self) -> None:
"""停止播报线程并释放资源""" """停止播报线程并释放资源"""
if not self.enabled: if not self.enabled or self._closed:
return return
self._closed = True
self._queue.put(None) self._queue.put(None)
if self._thread is not None: if self._thread is not None:
self._thread.join(timeout=1.0) self._thread.join(timeout=1.0)
if self._current_process is not None and self._current_process.poll() is None:
self._current_process.terminate() self._stop_current_playback()
logger.info("Rep announcer closed")
def _start(self) -> None: def _start(self) -> None:
"""根据平台初始化TTS引擎并启动后台播报线程""" """启动后台播报线程"""
if sys.platform == "darwin": self.audio_dir.mkdir(parents=True, exist_ok=True)
self._use_macos_say = True
logger.info("Rep announcer initialized with macOS say")
else:
try:
import pyttsx3
self._engine = pyttsx3.init() if self._direct_playback:
self._engine.setProperty("rate", self.rate) import winsound
self._engine.setProperty("volume", self.volume)
logger.info("Rep announcer initialized with pyttsx3") logger.info("Rep announcer initialized in direct Windows mode, audio_dir={}", self.audio_dir)
except Exception as exc: return
self.enabled = False
logger.warning("Rep announcer disabled, pyttsx3 unavailable: {}", exc)
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() self._thread.start()
logger.info("Rep announcer initialized in queued mode, audio_dir={}", self.audio_dir)
def _run(self) -> None: def _run(self) -> None:
"""后台线程:从队列读取文本并调用TTS播放""" """后台线程:从队列取次数,播放对应音频文件"""
while True: while True:
text = self._queue.get() item = self._queue.get()
if text is None: if item 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:
if self._use_macos_say: self._play(audio_file)
if self._current_process is not None and self._current_process.poll() is None: logger.info(
self._current_process.terminate() "Rep audio submitted from queue: count={}, queue_ms={:.1f}",
self._current_process = subprocess.Popen( count,
["say", "-r", str(self.rate), text], (time.perf_counter() - requested_at) * 1000,
stdout=subprocess.DEVNULL, )
stderr=subprocess.DEVNULL,
)
elif self._engine is not None:
self._engine.say(text)
self._engine.runAndWait()
except Exception as exc: except Exception as exc:
logger.warning("Failed to announce rep count {}: {}", text, exc) logger.warning("Failed to play rep count {}: {}", count, exc)
def _play(self, audio_file: Path) -> None:
"""播放音频文件(平台自适应)"""
with self._play_lock:
self._stop_current_playback()
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
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._platform.startswith("win"):
try:
import winsound
winsound.PlaySound(None, winsound.SND_PURGE)
except Exception:
pass
return
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._platform == "darwin" else ".wav"
return self.audio_dir / f"{count}{suffix}"
def _clear_pending_counts(self) -> None:
"""清空队列中等待播放的次数,避免语音堆积"""
while True:
try:
item = self._queue.get_nowait()
if item is None:
self._queue.put(None)
return
except queue.Empty:
return
+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
+8 -4
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import threading import platform
import time
from typing import Callable from typing import Callable
import mediapipe as mp import mediapipe as mp
@@ -29,15 +28,20 @@ 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(PoseLandmarker.Delegate.GPU) self._landmarker = self._create(self.delegate, result_callback)
logger.info("MediaPipe PoseLandmarker initialized with GPU delegate") logger.info("MediaPipe PoseLandmarker initialized with GPU delegate")
return return
except Exception as exc: except Exception as exc:
logger.warning("MediaPipe GPU delegate unavailable, falling back to CPU: {}", exc) logger.warning("MediaPipe GPU delegate unavailable, falling back to CPU: {}", exc)
self.delegate = BaseOptions.Delegate.CPU self.delegate = BaseOptions.Delegate.CPU
self._landmarker = self._create(PoseLandmarker.Delegate.CPU, result_callback) self._landmarker = self._create(self.delegate, result_callback)
logger.info("MediaPipe PoseLandmarker initialized with CPU delegate") logger.info("MediaPipe PoseLandmarker initialized with CPU delegate")
def _create(self, delegate, result_callback=None): def _create(self, delegate, result_callback=None):
+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
+9 -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"
@@ -22,6 +25,11 @@ audio:
rep_announcer_enabled: true rep_announcer_enabled: true
rep_announcer_rate: 185 rep_announcer_rate: 185
rep_announcer_volume: 1.0 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: logging:
dir: logs dir: logs
+16 -1
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
@@ -46,6 +49,18 @@ class AudioConfig:
rep_announcer_enabled: bool = True rep_announcer_enabled: bool = True
rep_announcer_rate: int = 185 rep_announcer_rate: int = 185
rep_announcer_volume: float = 1.0 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 / "resources" / "audio" / "reps"
@dataclass @dataclass
+1 -1
View File
@@ -1,6 +1,6 @@
aiortc>=1.9.0 aiortc>=1.9.0
websockets>=13.0 websockets>=13.0
opencv-contrib-python>=4.13.0.92 opencv-contrib-python>=4.10.0
numpy>=1.26,<2 numpy>=1.26,<2
loguru>=0.7.0 loguru>=0.7.0
mediapipe==0.10.21 mediapipe==0.10.21
+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