静止時のノイズ対処のためyaw値フィルタ機能を追加

This commit is contained in:
agomizi1cm committed 2026-10-02 12:17:53 +09:00
1 parent 90be3018a1
commit 4635608411
8 files changed
+552 -18

No files matched your search

+8 -1
View File
@@ -88,9 +88,16 @@ head_pose:
enabled: true enabled: true
method: solvepnp method: solvepnp
smoothing_enabled: true smoothing_enabled: true
# none | ema | median_ema | hampel_ema | one_euro | median_one_euro (see src/yaw_filters.py)
smoothing_method: ema smoothing_method: ema
ema_alpha: 0.4 ema_alpha: 0.4 # also used by median_ema / hampel_ema
max_abs_angle: 120.0 max_abs_angle: 120.0
smoothing_window: 5 # samples; median_ema / hampel_ema / median_one_euro
hampel_threshold: 3.0 # robust sigmas; hampel_ema
one_euro_min_cutoff: 1.0 # Hz; one_euro / median_one_euro
one_euro_beta: 0.0 # keep ~0 unless a median precedes it (e.g. 0.02)
one_euro_d_cutoff: 1.0
filter_reset_gap_sec: 0.0 # reset filter state after a pose gap longer than this; 0 = never
face_logging: face_logging:
enabled: true enabled: true
log_frame_faces: true log_frame_faces: true
+63
View File
@@ -0,0 +1,63 @@
# 頭部角度(Yaw)の平滑化フィルタと静止時ゆらぎ
静止していてもYawが変化する問題に対する調査記録と、選択可能なフィルタの説明。
## 設定(`config/default.yaml` の `head_pose`)
| キー | 既定値 | 内容 |
|---|---|---|
| `smoothing_enabled` | true | false で平滑化なし(`none` と同じ) |
| `smoothing_method` | `ema` | `none` / `ema` / `median_ema` / `hampel_ema` / `one_euro` / `median_one_euro` |
| `ema_alpha` | 0.4 | EMA係数。`median_ema`・`hampel_ema` でも使用 |
| `smoothing_window` | 5 | 窓幅(サンプル数)。窓の時間幅は姿勢更新レートに依存する |
| `hampel_threshold` | 3.0 | Hampel判定のしきい値(頑健σの倍数) |
| `one_euro_min_cutoff` / `one_euro_beta` / `one_euro_d_cutoff` | 1.0 / 0.0 / 1.0 | One Euro Filter のパラメータ |
| `filter_reset_gap_sec` | 0.0 | 姿勢サンプルがこれより離れたらフィルタ状態をリセット(0で無効) |
既定値は従来動作(`ema`, α=0.4)と同じ。新キーはYAMLに書かなくてもよい(既定値が使われる)。
フィルタ本体は `src/yaw_filters.py`。
## 計測と比較の手順
1. 正面静止で30〜60秒ログを取る(`data/logs/face/*_face_frames.csv`。`raw_yaw` 列が生値)。
2. `python -m src.yaw_noise_report <csv> --track-id N --t-start 4 --t-end 44 --csv out.csv`
3. 各フィルタのSD・最大変動幅・フレーム間ジッタを比較する。
遅れは静止データからは測れないので、階段・ランプ入力のシミュレーションまたは実際の振り向き試行で確認する。
## 計測結果(2026-10-01, 研究室, 正面・3m・静止, 4〜44秒, n=714, 約17.8Hz, 1280x720)
| フィルタ | SD | 最大変動幅 | 階段90%遅れ | ランプ遅れ |
|---|---:|---:|---:|---:|
| 生Yaw | 6.27° | 49.6° | 0 | 0 |
| ema 0.4(従来) | 3.77° | 28.1° | 0.22秒 | 0.06秒 |
| median5 + ema 0.4 | 1.92° | 17.1° | 0.34秒 | 0.17秒 |
| median5 + one_euro(1.0, β=0.02) | 1.96° | 20.0° | 0.22秒 | 0.11秒 |
| hampel w7 k3 + ema 0.4 | 2.17° | 21.9° | 0.39秒 | - |
- 遅れは、サンプル間隔0.056秒で、0→30°のステップ入力が27°に達するまでの時間(ランプは60°/sで増加する入力に対する遅れ)。
- 1か所・1距離・1人の静止データによる比較。実際の振り向き中の挙動は未検証。
- 現行(ema 0.4)の出力は、静止していても3σ=11.3°、最大変動幅28°あり、`subtle`(10°)・`weak`(20°)のしきい値に達しうる。
## 分かったこと
- 生Yawの分散の大部分は、フレームごとの独立なノイズと、単発の+側スパイク(約5%)による。
中央値系フィルタ(median / Hampel)が単発スパイクに有効。
- ROI・bboxのフレーム間ゆらぎは0.3〜0.5px程度で、原因ではない。
- 再投影誤差はスパイクと相関せず(外れ値フレーム1.59px、その他1.58px)、除外基準には使えない。
- 顔が小さい(3mで目尻間距離が約24px)ため、ランドマーク0.5px程度の誤差で、Yawの標準偏差は4〜5°程度になる
(シミュレーションで確認。実測の6.3°と同水準)。ROIを拡大しても元画素数は増えない。
→ 精度を上げるには、距離を縮める・解像度を上げる・望遠にするなど、顔を大きく映す対策が最も効く。
- `useExtrinsicGuess`(前フレームの解を初期値にする)はシミュレーション上で効果なし。実装していない。
- 焦点距離の仮定(現行はフレーム幅=1280px、実際は約800px相当と推定)は、Yawへの影響が1°未満。
## 注意点(要確認)
- **Yawの符号が反転している可能性が高い。** `src/head_pose.py` の `MODEL_POINTS` はYを下向きに直した一方で
zの符号は据え置かれ、目・口が鼻先より手前にある(奥行きが反転した)顔になっている。
シミュレーションでは、実際の頭部回転に対して推定Yawの符号が反転した(真値+15°→推定-15°、大きさは保たれる)。
`turn_detection.signage_yaw_direction` が実測に合わせて設定されていれば結果は一貫するが、
「正のYaw=物理的にどちら向きか」を前提にした解釈・報告では注意が必要。
モデルを直す場合は符号が変わるので、`signage_yaw_direction` と過去ログの扱いも同時に見直すこと。
- 正面を向いた静止時の平均が約-19.3°だった。立ち位置は画像中心から約31px(約1.4°)しか離れておらず、
位置では説明できない。カメラのレンズでなくプレビュー画面を見ていた可能性などを、レンズに目印を付けて再測定して確認する。
- 一部の設定値(`median_one_euro` の β など)は、この静止データのノイズ規模に合わせたもの。距離が変わったら再評価する。
+29 -4
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict, dataclass, fields from dataclasses import MISSING, asdict, dataclass, fields
from pathlib import Path from pathlib import Path
from typing import Any, Mapping, TypeVar from typing import Any, Mapping, TypeVar
@@ -10,6 +10,7 @@ import yaml
from .exceptions import ConfigurationError from .exceptions import ConfigurationError
from .models import PixelRegion, PixelTriggerLine from .models import PixelRegion, PixelTriggerLine
from .yaw_filters import FILTER_KINDS
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -198,9 +199,19 @@ class HeadPoseSettings:
enabled: bool enabled: bool
method: str method: str
smoothing_enabled: bool smoothing_enabled: bool
# none | ema | median_ema | hampel_ema | one_euro | median_one_euro (see src/yaw_filters.py).
smoothing_method: str smoothing_method: str
# EMA coefficient; also used by median_ema and hampel_ema.
ema_alpha: float ema_alpha: float
max_abs_angle: float max_abs_angle: float
# Fields below are optional in YAML; the defaults reproduce the original behaviour.
smoothing_window: int = 5
hampel_threshold: float = 3.0
one_euro_min_cutoff: float = 1.0
one_euro_beta: float = 0.0
one_euro_d_cutoff: float = 1.0
# Reset filter state when pose samples are further apart than this [s]; 0 disables.
filter_reset_gap_sec: float = 0.0
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -499,7 +510,9 @@ def _make(cls: type[T], values: Any, section: str) -> T:
if not isinstance(values, Mapping): if not isinstance(values, Mapping):
raise ConfigurationError(f"'{section}' must be a mapping") raise ConfigurationError(f"'{section}' must be a mapping")
expected = {field.name for field in fields(cls)} expected = {field.name for field in fields(cls)}
missing = expected - values.keys() required = {field.name for field in fields(cls)
if field.default is MISSING and field.default_factory is MISSING}
missing = required - values.keys()
unknown = values.keys() - expected unknown = values.keys() - expected
if missing: if missing:
raise ConfigurationError(f"Missing settings in '{section}': {sorted(missing)}") raise ConfigurationError(f"Missing settings in '{section}': {sorted(missing)}")
@@ -584,10 +597,22 @@ def _validate(config: AppConfig) -> None:
if not 0.0 <= face.min_detection_confidence <= 1.0 or not 0.0 <= face.min_tracking_confidence <= 1.0: if not 0.0 <= face.min_detection_confidence <= 1.0 or not 0.0 <= face.min_tracking_confidence <= 1.0:
raise ConfigurationError("face confidence thresholds must be in [0, 1]") raise ConfigurationError("face confidence thresholds must be in [0, 1]")
pose = config.head_pose pose = config.head_pose
if pose.method != "solvepnp" or pose.smoothing_method != "ema": if pose.method != "solvepnp" or pose.smoothing_method not in FILTER_KINDS:
raise ConfigurationError("head_pose method must be solvepnp and smoothing_method must be ema") raise ConfigurationError(
f"head_pose method must be solvepnp and smoothing_method one of {list(FILTER_KINDS)}")
if not 0.0 < pose.ema_alpha <= 1.0 or pose.max_abs_angle <= 0: if not 0.0 < pose.ema_alpha <= 1.0 or pose.max_abs_angle <= 0:
raise ConfigurationError("head_pose EMA alpha and maximum angle are invalid") raise ConfigurationError("head_pose EMA alpha and maximum angle are invalid")
numeric = (pose.smoothing_window, pose.hampel_threshold, pose.one_euro_min_cutoff, pose.one_euro_beta,
pose.one_euro_d_cutoff, pose.filter_reset_gap_sec)
if any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in numeric):
raise ConfigurationError("head_pose filter settings must be numeric")
if not isinstance(pose.smoothing_window, int) or pose.smoothing_window < 1:
raise ConfigurationError("head_pose.smoothing_window must be a positive integer")
if pose.smoothing_method == "hampel_ema" and pose.smoothing_window < 3:
raise ConfigurationError("head_pose.smoothing_window must be >= 3 for hampel_ema")
if (pose.hampel_threshold <= 0 or pose.one_euro_min_cutoff <= 0 or pose.one_euro_d_cutoff <= 0
or pose.one_euro_beta < 0 or pose.filter_reset_gap_sec < 0):
raise ConfigurationError("head_pose filter settings are out of range")
if config.face_logging.flush_interval_frames <= 0: if config.face_logging.flush_interval_frames <= 0:
raise ConfigurationError("face_logging.flush_interval_frames must be positive") raise ConfigurationError("face_logging.flush_interval_frames must be positive")
display_bools = (config.display.show_face_roi, config.display.show_head_pose, display_bools = (config.display.show_face_roi, config.display.show_head_pose,
+30 -13
View File
@@ -12,6 +12,7 @@ from .exceptions import FaceProcessingError
from .face_geometry import create_face_roi from .face_geometry import create_face_roi
from .head_pose import HeadPoseEstimator from .head_pose import HeadPoseEstimator
from .models import FaceLandmark, FaceTrackState, FrameInfo, HeadPoseResult, TrackedPerson from .models import FaceLandmark, FaceTrackState, FrameInfo, HeadPoseResult, TrackedPerson
from .yaw_filters import AngleFilter, create_angle_filter
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -24,7 +25,7 @@ class FaceProcessor:
self.head_pose_config = head_pose_config self.head_pose_config = head_pose_config
self.estimator = HeadPoseEstimator(head_pose_config) self.estimator = HeadPoseEstimator(head_pose_config)
self._mesh: Any = None self._mesh: Any = None
self._smoothed: dict[int, tuple[float, float, float]] = {} self._filters: dict[int, tuple[AngleFilter, AngleFilter, AngleFilter]] = {}
if config.enabled: if config.enabled:
self._initialize() self._initialize()
@@ -96,7 +97,9 @@ class FaceProcessor:
raw_pitch=estimation.pitch, raw_yaw=estimation.yaw, raw_roll=estimation.roll, raw_pitch=estimation.pitch, raw_yaw=estimation.yaw, raw_roll=estimation.roll,
reprojection_error=estimation.reprojection_error, interocular_px=interocular, reprojection_error=estimation.reprojection_error, interocular_px=interocular,
) )
pitch, yaw, roll = self._smooth(person.track_id, estimation.pitch, estimation.yaw, estimation.roll) pitch, yaw, roll = self._smooth(
person.track_id, estimation.pitch, estimation.yaw, estimation.roll, info.elapsed_time_sec,
)
return HeadPoseResult( return HeadPoseResult(
person.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec, person.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
True, True, pitch, yaw, roll, len(landmarks), roi, roi.confidence, "", tuple(landmarks), True, True, pitch, yaw, roll, len(landmarks), roi, roi.confidence, "", tuple(landmarks),
@@ -131,19 +134,33 @@ class FaceProcessor:
for index, point in enumerate(raw_landmarks) for index, point in enumerate(raw_landmarks)
] ]
def _new_filters(self) -> tuple[AngleFilter, AngleFilter, AngleFilter]:
"""One independent filter per angle (pitch, yaw, roll), built from head_pose settings."""
settings = self.head_pose_config
kind = settings.smoothing_method if settings.smoothing_enabled else "none"
return tuple( # type: ignore[return-value]
create_angle_filter(
kind, alpha=settings.ema_alpha, window=settings.smoothing_window,
hampel_threshold=settings.hampel_threshold,
one_euro_min_cutoff=settings.one_euro_min_cutoff, one_euro_beta=settings.one_euro_beta,
one_euro_d_cutoff=settings.one_euro_d_cutoff, reset_gap_sec=settings.filter_reset_gap_sec,
)
for _ in range(3)
)
def _smooth( def _smooth(
self, track_id: int, pitch: float | None, yaw: float | None, roll: float | None, self, track_id: int, pitch: float | None, yaw: float | None, roll: float | None,
elapsed_time_sec: float,
) -> tuple[float, float, float]: ) -> tuple[float, float, float]:
assert pitch is not None and yaw is not None and roll is not None assert pitch is not None and yaw is not None and roll is not None
current = (pitch, yaw, roll) filters = self._filters.get(track_id)
if not self.head_pose_config.smoothing_enabled or track_id not in self._smoothed: if filters is None:
smoothed = current filters = self._filters[track_id] = self._new_filters()
else: smoothed = tuple(
alpha = self.head_pose_config.ema_alpha float(angle_filter.update(value, elapsed_time_sec))
previous = self._smoothed[track_id] for angle_filter, value in zip(filters, (pitch, yaw, roll))
smoothed = tuple(alpha * value + (1.0 - alpha) * old for value, old in zip(current, previous)) )
self._smoothed[track_id] = smoothed return smoothed # type: ignore[return-value]
return smoothed
@staticmethod @staticmethod
def _failure(track_id: int, info: FrameInfo, reason: str, roi: Any = None) -> HeadPoseResult: def _failure(track_id: int, info: FrameInfo, reason: str, roi: Any = None) -> HeadPoseResult:
@@ -151,13 +168,13 @@ class FaceProcessor:
False, False, None, None, None, 0, roi, None, reason) False, False, None, None, None, 0, roi, None, reason)
def reset(self) -> None: def reset(self) -> None:
self._smoothed.clear() self._filters.clear()
def close(self) -> None: def close(self) -> None:
if self._mesh is not None: if self._mesh is not None:
self._mesh.close() self._mesh.close()
self._mesh = None self._mesh = None
self._smoothed.clear() self._filters.clear()
class FaceTrackManager: class FaceTrackManager:
+223
View File
@@ -0,0 +1,223 @@
"""Causal, dependency-free smoothing filters for head-pose angles.
All filters share one interface so they can be swapped from configuration::
f = create_angle_filter("median_ema", alpha=0.4, window=5)
smoothed = f.update(raw_angle_deg, elapsed_time_sec)
``update`` must be called once per valid sample in time order. ``reset`` forgets
all state (call it when a track is lost). Every filter is causal, so each adds
some delay; see the per-class notes. Delay and noise should be judged together
(see ``src.yaw_noise_report``).
Kinds
-----
``none`` pass-through.
``ema`` exponential moving average, per sample (the original behaviour).
``median_ema`` sliding median over ``window`` samples, then EMA. Removes
isolated spikes; step delay is about (window - 1) / 2 samples.
``hampel_ema`` Hampel identifier: a sample further than ``hampel_threshold``
robust sigmas from the window median is replaced by that median,
everything else passes unchanged, then EMA. Keeps small real
changes sharper than a plain median.
``one_euro`` One Euro Filter (time based). Beware: with very noisy input the
speed term inflates the cutoff, so keep ``beta`` near 0 or
put a median in front (``median_one_euro``).
``median_one_euro`` sliding median, then One Euro Filter.
Window sizes are counted in samples, so their duration depends on the pose update
rate (frame rate / ``face.process_every_n_frames``).
"""
from __future__ import annotations
import math
import statistics
from collections import deque
from typing import Protocol
FILTER_KINDS: tuple[str, ...] = ("none", "ema", "median_ema", "hampel_ema", "one_euro", "median_one_euro")
# Floor for the robust sigma used by the Hampel identifier [deg]; avoids flagging
# every sample as an outlier when the window is nearly constant.
HAMPEL_MIN_SCALE_DEG = 1.0
_MAD_TO_SIGMA = 1.4826
class AngleFilter(Protocol):
def update(self, value: float, t: float) -> float: ...
def reset(self) -> None: ...
class PassThroughFilter:
def update(self, value: float, t: float) -> float:
return value
def reset(self) -> None:
return None
class EmaFilter:
"""``y = alpha * x + (1 - alpha) * y_prev``; the first sample passes through."""
def __init__(self, alpha: float) -> None:
if not 0.0 < alpha <= 1.0:
raise ValueError("alpha must be in (0, 1]")
self.alpha = alpha
self._state: float | None = None
def update(self, value: float, t: float) -> float:
self._state = value if self._state is None else self.alpha * value + (1.0 - self.alpha) * self._state
return self._state
def reset(self) -> None:
self._state = None
class MedianEmaFilter:
def __init__(self, window: int, alpha: float) -> None:
if window < 1:
raise ValueError("window must be >= 1")
self._window: deque[float] = deque(maxlen=window)
self._ema = EmaFilter(alpha)
def update(self, value: float, t: float) -> float:
self._window.append(value)
return self._ema.update(statistics.median(self._window), t)
def reset(self) -> None:
self._window.clear()
self._ema.reset()
class HampelEmaFilter:
def __init__(self, window: int, threshold: float, alpha: float) -> None:
if window < 3:
raise ValueError("hampel window must be >= 3")
if threshold <= 0.0:
raise ValueError("hampel threshold must be positive")
self.threshold = threshold
self._window: deque[float] = deque(maxlen=window)
self._ema = EmaFilter(alpha)
def update(self, value: float, t: float) -> float:
self._window.append(value)
cleaned = value
if len(self._window) >= 3:
center = statistics.median(self._window)
mad = statistics.median(abs(item - center) for item in self._window)
scale = max(_MAD_TO_SIGMA * mad, HAMPEL_MIN_SCALE_DEG)
if abs(value - center) > self.threshold * scale:
cleaned = center
return self._ema.update(cleaned, t)
def reset(self) -> None:
self._window.clear()
self._ema.reset()
class OneEuroFilter:
"""One Euro Filter (Casiez et al., 2012) driven by real timestamps [s]."""
def __init__(self, min_cutoff: float, beta: float, d_cutoff: float = 1.0) -> None:
if min_cutoff <= 0.0 or d_cutoff <= 0.0 or beta < 0.0:
raise ValueError("min_cutoff and d_cutoff must be > 0 and beta >= 0")
self.min_cutoff = min_cutoff
self.beta = beta
self.d_cutoff = d_cutoff
self._x: float | None = None
self._dx = 0.0
self._t = 0.0
@staticmethod
def _alpha(dt: float, cutoff: float) -> float:
tau = 1.0 / (2.0 * math.pi * cutoff)
return 1.0 / (1.0 + tau / dt)
def update(self, value: float, t: float) -> float:
if self._x is None:
self._x, self._t, self._dx = value, t, 0.0
return value
dt = max(t - self._t, 1e-6)
self._t = t
raw_dx = (value - self._x) / dt
a_d = self._alpha(dt, self.d_cutoff)
self._dx = a_d * raw_dx + (1.0 - a_d) * self._dx
cutoff = self.min_cutoff + self.beta * abs(self._dx)
a = self._alpha(dt, cutoff)
self._x = a * value + (1.0 - a) * self._x
return self._x
def reset(self) -> None:
self._x = None
self._dx = 0.0
class MedianOneEuroFilter:
def __init__(self, window: int, min_cutoff: float, beta: float, d_cutoff: float = 1.0) -> None:
if window < 1:
raise ValueError("window must be >= 1")
self._window: deque[float] = deque(maxlen=window)
self._euro = OneEuroFilter(min_cutoff, beta, d_cutoff)
def update(self, value: float, t: float) -> float:
self._window.append(value)
return self._euro.update(statistics.median(self._window), t)
def reset(self) -> None:
self._window.clear()
self._euro.reset()
class GapResettingFilter:
"""Reset the wrapped filter when samples are more than ``max_gap_sec`` apart.
Prevents stale state from blending into the first samples after the face was
lost. ``max_gap_sec <= 0`` disables the behaviour.
"""
def __init__(self, inner: AngleFilter, max_gap_sec: float) -> None:
self._inner = inner
self._max_gap = max_gap_sec
self._last_t: float | None = None
def update(self, value: float, t: float) -> float:
if self._max_gap > 0.0 and self._last_t is not None and t - self._last_t > self._max_gap:
self._inner.reset()
self._last_t = t
return self._inner.update(value, t)
def reset(self) -> None:
self._inner.reset()
self._last_t = None
def create_angle_filter(
kind: str,
*,
alpha: float = 0.4,
window: int = 5,
hampel_threshold: float = 3.0,
one_euro_min_cutoff: float = 1.0,
one_euro_beta: float = 0.0,
one_euro_d_cutoff: float = 1.0,
reset_gap_sec: float = 0.0,
) -> AngleFilter:
"""Build a filter by name; raises ``ValueError`` for unknown kinds."""
inner: AngleFilter
if kind == "none":
inner = PassThroughFilter()
elif kind == "ema":
inner = EmaFilter(alpha)
elif kind == "median_ema":
inner = MedianEmaFilter(window, alpha)
elif kind == "hampel_ema":
inner = HampelEmaFilter(window, hampel_threshold, alpha)
elif kind == "one_euro":
inner = OneEuroFilter(one_euro_min_cutoff, one_euro_beta, one_euro_d_cutoff)
elif kind == "median_one_euro":
inner = MedianOneEuroFilter(window, one_euro_min_cutoff, one_euro_beta, one_euro_d_cutoff)
else:
raise ValueError(f"unknown smoothing filter {kind!r}; expected one of {FILTER_KINDS}")
return GapResettingFilter(inner, reset_gap_sec) if reset_gap_sec > 0.0 else inner
+16
View File
@@ -21,6 +21,8 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Callable, Sequence from typing import Callable, Sequence
from .yaw_filters import create_angle_filter
Sample = tuple[float, float] # (elapsed_time_sec, yaw_deg) Sample = tuple[float, float] # (elapsed_time_sec, yaw_deg)
@@ -140,6 +142,12 @@ def load_tracks(
return tracks return tracks
def apply_filter(kind: str, samples: Sequence[Sample], **params: float) -> list[float]:
"""Run a ``src.yaw_filters`` filter over (time, yaw) samples."""
angle_filter = create_angle_filter(kind, **params) # type: ignore[arg-type]
return [angle_filter.update(value, t) for t, value in samples]
def build_rows(entry: dict[str, list]) -> list[tuple[str, dict[str, float]]]: def build_rows(entry: dict[str, list]) -> list[tuple[str, dict[str, float]]]:
"""Statistics rows: logged yaw, raw yaw, and offline filters applied to raw yaw.""" """Statistics rows: logged yaw, raw yaw, and offline filters applied to raw yaw."""
rows: list[tuple[str, dict[str, float]]] = [] rows: list[tuple[str, dict[str, float]]] = []
@@ -159,6 +167,14 @@ def build_rows(entry: dict[str, list]) -> list[tuple[str, dict[str, float]]]:
add("raw + median3 + EMA a=0.4", median_then_ema(values, 0.4)) add("raw + median3 + EMA a=0.4", median_then_ema(values, 0.4))
add("raw + OneEuro mincut=1.0 beta=0.0", one_euro(raw, 1.0, 0.0)) add("raw + OneEuro mincut=1.0 beta=0.0", one_euro(raw, 1.0, 0.0))
add("raw + OneEuro mincut=0.5 beta=0.01", one_euro(raw, 0.5, 0.01)) add("raw + OneEuro mincut=0.5 beta=0.01", one_euro(raw, 0.5, 0.01))
add("raw + median5 + EMA a=0.4", apply_filter("median_ema", raw, window=5, alpha=0.4))
add("raw + median5 + EMA a=0.3", apply_filter("median_ema", raw, window=5, alpha=0.3))
add("raw + Hampel w7 k3 + EMA a=0.4",
apply_filter("hampel_ema", raw, window=7, hampel_threshold=3.0, alpha=0.4))
add("raw + median5 + OneEuro mincut=1.0 beta=0.02",
apply_filter("median_one_euro", raw, window=5, one_euro_min_cutoff=1.0, one_euro_beta=0.02))
add("raw + median5 + OneEuro mincut=1.5 beta=0.0",
apply_filter("median_one_euro", raw, window=5, one_euro_min_cutoff=1.5, one_euro_beta=0.0))
return rows return rows
+104
View File
@@ -0,0 +1,104 @@
from pathlib import Path
from typing import Any
import pytest
from src.config_loader import HeadPoseSettings, _make, load_config
from src.exceptions import ConfigurationError
from src.face_processor import FaceProcessor
from src.yaw_filters import FILTER_KINDS
DEFAULT = Path(__file__).parents[1] / "config" / "default.yaml"
def build(head_pose: dict[str, Any] | None = None):
overrides: dict[str, Any] = {"face": {"enabled": False}} # no MediaPipe needed
if head_pose is not None:
overrides["head_pose"] = head_pose
return load_config(DEFAULT, None, overrides)
def processor(head_pose: dict[str, Any] | None = None) -> FaceProcessor:
config = build(head_pose)
return FaceProcessor(config.face, config.head_pose)
def test_defaults_reproduce_the_original_ema() -> None:
settings = build().head_pose
assert settings.smoothing_method == "ema" and settings.smoothing_enabled
assert settings.filter_reset_gap_sec == 0.0
proc = processor()
first = proc._smooth(1, 0.0, 10.0, 1.0, 0.0)
second = proc._smooth(1, 10.0, 20.0, 3.0, 0.05)
assert first == pytest.approx((0.0, 10.0, 1.0))
alpha = settings.ema_alpha
assert second == pytest.approx((alpha * 10.0, alpha * 20.0 + (1 - alpha) * 10.0, alpha * 3.0 + (1 - alpha) * 1.0))
def test_smoothing_disabled_passes_values_through() -> None:
proc = processor({"smoothing_enabled": False})
assert proc._smooth(1, 1.0, 2.0, 3.0, 0.0) == (1.0, 2.0, 3.0)
assert proc._smooth(1, 9.0, 8.0, 7.0, 0.1) == (9.0, 8.0, 7.0)
@pytest.mark.parametrize("kind", FILTER_KINDS)
def test_every_filter_kind_loads_and_runs(kind: str) -> None:
proc = processor({"smoothing_method": kind})
for index in range(10):
pitch, yaw, roll = proc._smooth(1, 1.0, -20.0, 0.5, index * 0.05)
assert (pitch, yaw, roll) == pytest.approx((1.0, -20.0, 0.5))
@pytest.mark.parametrize("kind", ["median_ema", "hampel_ema", "median_one_euro"])
def test_spike_is_suppressed_by_robust_filters(kind: str) -> None:
proc = processor({"smoothing_method": kind, "smoothing_window": 5})
outputs = [proc._smooth(1, 0.0, 25.0 if index == 10 else -20.0, 0.0, index * 0.056)[1] for index in range(21)]
assert max(abs(value + 20.0) for value in outputs) < 1.0
legacy = processor()
plain = [legacy._smooth(1, 0.0, 25.0 if index == 10 else -20.0, 0.0, index * 0.056)[1] for index in range(21)]
assert max(abs(value + 20.0) for value in plain) > 10.0
def test_tracks_are_independent_and_reset_clears_state() -> None:
proc = processor()
proc._smooth(1, 0.0, 0.0, 0.0, 0.0)
assert proc._smooth(2, 0.0, 30.0, 0.0, 0.0)[1] == 30.0 # track 2 starts fresh
proc.reset()
assert proc._smooth(1, 0.0, 30.0, 0.0, 1.0)[1] == 30.0
def test_gap_reset_drops_stale_state() -> None:
proc = processor({"filter_reset_gap_sec": 0.5})
proc._smooth(1, 0.0, 0.0, 0.0, 0.0)
assert proc._smooth(1, 0.0, 40.0, 0.0, 3.0)[1] == 40.0
no_reset = processor()
no_reset._smooth(1, 0.0, 0.0, 0.0, 0.0)
assert no_reset._smooth(1, 0.0, 40.0, 0.0, 3.0)[1] == pytest.approx(16.0)
def test_new_keys_are_optional_in_yaml_sections() -> None:
legacy_section = {"enabled": True, "method": "solvepnp", "smoothing_enabled": True,
"smoothing_method": "ema", "ema_alpha": 0.4, "max_abs_angle": 120.0}
settings = _make(HeadPoseSettings, legacy_section, "head_pose")
assert settings.smoothing_window == 5 and settings.one_euro_beta == 0.0
with pytest.raises(ConfigurationError):
_make(HeadPoseSettings, {**legacy_section, "not_a_setting": 1}, "head_pose")
with pytest.raises(ConfigurationError):
_make(HeadPoseSettings, {key: value for key, value in legacy_section.items() if key != "method"}, "head_pose")
@pytest.mark.parametrize("override", [
{"smoothing_method": "kalman"},
{"smoothing_window": 0},
{"smoothing_window": 2.5},
{"smoothing_method": "hampel_ema", "smoothing_window": 2},
{"hampel_threshold": 0},
{"one_euro_min_cutoff": 0},
{"one_euro_d_cutoff": -1},
{"one_euro_beta": -0.1},
{"filter_reset_gap_sec": -1},
{"smoothing_window": True},
])
def test_invalid_filter_settings_are_rejected(override: dict[str, Any]) -> None:
with pytest.raises(ConfigurationError):
build(override)
+79
View File
@@ -0,0 +1,79 @@
import statistics
import pytest
from src.yaw_filters import FILTER_KINDS, create_angle_filter
def _run(kind: str, values: list[float], dt: float = 0.056, **params: float) -> list[float]:
f = create_angle_filter(kind, **params)
return [f.update(v, i * dt) for i, v in enumerate(values)]
def test_none_and_ema_basics() -> None:
assert _run("none", [1.0, 5.0]) == [1.0, 5.0]
assert _run("ema", [1.0, 5.0], alpha=1.0) == [1.0, 5.0]
out = _run("ema", [0.0, 10.0], alpha=0.4)
assert out == pytest.approx([0.0, 4.0])
@pytest.mark.parametrize("kind", [k for k in FILTER_KINDS])
def test_constant_signal_is_preserved(kind: str) -> None:
out = _run(kind, [7.0] * 20)
assert out == pytest.approx([7.0] * 20)
@pytest.mark.parametrize("kind", ["median_ema", "hampel_ema", "median_one_euro"])
def test_isolated_spike_is_suppressed(kind: str) -> None:
values = [-20.0] * 10 + [25.0] + [-20.0] * 10
out = _run(kind, values, alpha=0.5, window=5)
assert max(abs(v + 20.0) for v in out) < 1.0
plain = _run("ema", values, alpha=0.5)
assert max(abs(v + 20.0) for v in plain) > 10.0
@pytest.mark.parametrize("kind", ["median_ema", "hampel_ema", "one_euro", "median_one_euro"])
def test_step_is_followed(kind: str) -> None:
out = _run(kind, [0.0] * 20 + [30.0] * 60, alpha=0.4, window=5, one_euro_min_cutoff=1.0, one_euro_beta=0.0)
assert out[19] == pytest.approx(0.0, abs=1e-6)
assert out[-1] == pytest.approx(30.0, abs=0.5)
assert out[20] < 30.0 # causal: cannot jump immediately
def test_hampel_passes_small_noise_unchanged_before_ema() -> None:
f = create_angle_filter("hampel_ema", alpha=1.0, window=5, hampel_threshold=3.0)
values = [0.0, 0.5, -0.5, 0.4, -0.4, 0.3]
assert [f.update(v, i * 0.05) for i, v in enumerate(values)] == pytest.approx(values)
def test_noise_reduction_on_alternating_input() -> None:
noisy = [0.0, 6.0] * 40
raw_sd = statistics.pstdev(noisy)
for kind in ("ema", "median_ema", "hampel_ema", "one_euro", "median_one_euro"):
out = _run(kind, noisy, alpha=0.3, window=5)
assert statistics.pstdev(out[10:]) < raw_sd
def test_gap_resets_state() -> None:
f = create_angle_filter("ema", alpha=0.1, reset_gap_sec=0.5)
f.update(0.0, 0.0)
f.update(0.0, 0.05)
assert f.update(40.0, 2.0) == 40.0 # gap > 0.5 s -> first sample passes through
g = create_angle_filter("ema", alpha=0.1, reset_gap_sec=0.0)
g.update(0.0, 0.0)
assert g.update(40.0, 2.0) == pytest.approx(4.0)
def test_reset_and_validation() -> None:
f = create_angle_filter("median_ema", alpha=0.5, window=3, reset_gap_sec=1.0)
f.update(10.0, 0.0)
f.reset()
assert f.update(0.0, 0.1) == 0.0
with pytest.raises(ValueError):
create_angle_filter("kalman")
with pytest.raises(ValueError):
create_angle_filter("ema", alpha=0.0)
with pytest.raises(ValueError):
create_angle_filter("hampel_ema", window=2)
with pytest.raises(ValueError):
create_angle_filter("one_euro", one_euro_min_cutoff=0.0)