再投影誤差による破綻フレームの除外および窓の時間指定の実装と実験陽設定の更新
This commit is contained in:
1 parent
d042ef9790
commit
ca10876101
10 files changed
+256
-30
No files matched your search
@@ -212,6 +212,11 @@ class HeadPoseSettings:
|
||||
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
|
||||
# Median-type windows span this many seconds instead of smoothing_window samples; 0 = by count.
|
||||
smoothing_window_sec: float = 0.0
|
||||
# Reject a pose when the solvePnP reprojection error exceeds this multiple of the
|
||||
# outer-eye-corner distance (pixels); 0 disables. Normal frames are about 0.07.
|
||||
max_reprojection_error_ratio: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -603,7 +608,8 @@ def _validate(config: AppConfig) -> None:
|
||||
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")
|
||||
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)
|
||||
pose.one_euro_d_cutoff, pose.filter_reset_gap_sec, pose.smoothing_window_sec,
|
||||
pose.max_reprojection_error_ratio)
|
||||
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:
|
||||
@@ -611,7 +617,8 @@ def _validate(config: AppConfig) -> None:
|
||||
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):
|
||||
or pose.one_euro_beta < 0 or pose.filter_reset_gap_sec < 0 or pose.smoothing_window_sec < 0
|
||||
or pose.max_reprojection_error_ratio < 0):
|
||||
raise ConfigurationError("head_pose filter settings are out of range")
|
||||
if config.face_logging.flush_interval_frames <= 0:
|
||||
raise ConfigurationError("face_logging.flush_interval_frames must be positive")
|
||||
|
||||
@@ -144,6 +144,7 @@ class FaceProcessor:
|
||||
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,
|
||||
window_sec=settings.smoothing_window_sec,
|
||||
)
|
||||
for _ in range(3)
|
||||
)
|
||||
|
||||
+15
-2
@@ -55,6 +55,8 @@ class HeadPoseEstimator:
|
||||
image_points = self.build_image_points(landmarks)
|
||||
if image_points is None:
|
||||
return HeadPoseEstimation(False, failure_reason="insufficient_landmarks")
|
||||
# Outer eye corners (landmarks 33 and 263) are model points 2 and 3.
|
||||
eye_distance = math.hypot(image_points[3][0] - image_points[2][0], image_points[3][1] - image_points[2][1])
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -74,13 +76,24 @@ class HeadPoseEstimator:
|
||||
error = float(np.sqrt(np.mean(np.sum((projected.reshape(-1, 2) - image) ** 2, axis=1))))
|
||||
except Exception:
|
||||
return HeadPoseEstimation(False, failure_reason="solvepnp_failed")
|
||||
return self.result_from_angles(angles, error)
|
||||
return self.result_from_angles(angles, error, eye_distance_px=eye_distance)
|
||||
|
||||
def result_from_angles(
|
||||
self, angles: tuple[float, float, float], reprojection_error: float | None = None,
|
||||
eye_distance_px: float | None = None,
|
||||
) -> HeadPoseEstimation:
|
||||
"""Validate calculated angles and construct a normalized result."""
|
||||
"""Validate calculated angles and construct a normalized result.
|
||||
|
||||
When ``max_reprojection_error_ratio`` is set, a pose whose reprojection error
|
||||
exceeds that multiple of ``eye_distance_px`` is rejected: it is a degenerate
|
||||
solution (typically a very small or leaving face), not a noisy measurement.
|
||||
"""
|
||||
normalized = self.normalize_angles(angles)
|
||||
ratio = self.config.max_reprojection_error_ratio
|
||||
if (ratio > 0.0 and reprojection_error is not None and eye_distance_px is not None
|
||||
and eye_distance_px > 0.0 and reprojection_error > ratio * eye_distance_px):
|
||||
return HeadPoseEstimation(False, *normalized, reprojection_error=reprojection_error,
|
||||
failure_reason="reprojection_error_too_large")
|
||||
if any(not math.isfinite(value) or abs(value) > self.config.max_abs_angle for value in normalized):
|
||||
return HeadPoseEstimation(False, *normalized, reprojection_error=reprojection_error,
|
||||
failure_reason="angle_out_of_range")
|
||||
|
||||
+57
-23
@@ -25,8 +25,12 @@ Kinds
|
||||
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``).
|
||||
Median-type windows hold ``window`` samples by default, so their duration depends
|
||||
on the pose update rate (frame rate / ``face.process_every_n_frames``). With
|
||||
``window_sec > 0`` the window instead holds every sample from the last
|
||||
``window_sec`` seconds (trimmed to an odd count), which keeps the filter strength
|
||||
independent of the update rate. A median removes a spike run of at most
|
||||
(n - 1) / 2 samples out of n.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -44,6 +48,39 @@ HAMPEL_MIN_SCALE_DEG = 1.0
|
||||
_MAD_TO_SIGMA = 1.4826
|
||||
|
||||
|
||||
class _SampleWindow:
|
||||
"""Recent samples bounded by count, or by age when ``duration_sec`` > 0."""
|
||||
|
||||
MAX_SAMPLES_IN_DURATION_MODE = 256
|
||||
|
||||
def __init__(self, size: int, duration_sec: float = 0.0) -> None:
|
||||
if size < 1:
|
||||
raise ValueError("window must be >= 1")
|
||||
if duration_sec < 0.0:
|
||||
raise ValueError("window_sec must be >= 0")
|
||||
self._size = size
|
||||
self._duration = duration_sec
|
||||
self._items: deque[tuple[float, float]] = deque()
|
||||
|
||||
def add(self, t: float, value: float) -> list[float]:
|
||||
"""Append a sample and return the values currently inside the window."""
|
||||
self._items.append((t, value))
|
||||
if self._duration > 0.0:
|
||||
while len(self._items) > 1 and t - self._items[0][0] > self._duration:
|
||||
self._items.popleft()
|
||||
while len(self._items) > self.MAX_SAMPLES_IN_DURATION_MODE:
|
||||
self._items.popleft()
|
||||
values = [item[1] for item in self._items]
|
||||
# An odd count makes the median an actual sample (and symmetric).
|
||||
return values[1:] if len(values) % 2 == 0 else values
|
||||
while len(self._items) > self._size:
|
||||
self._items.popleft()
|
||||
return [item[1] for item in self._items]
|
||||
|
||||
def clear(self) -> None:
|
||||
self._items.clear()
|
||||
|
||||
|
||||
class AngleFilter(Protocol):
|
||||
def update(self, value: float, t: float) -> float: ...
|
||||
|
||||
@@ -76,15 +113,12 @@ class EmaFilter:
|
||||
|
||||
|
||||
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)
|
||||
def __init__(self, window: int, alpha: float, window_sec: float = 0.0) -> None:
|
||||
self._window = _SampleWindow(window, window_sec)
|
||||
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)
|
||||
return self._ema.update(statistics.median(self._window.add(t, value)), t)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._window.clear()
|
||||
@@ -92,21 +126,21 @@ class MedianEmaFilter:
|
||||
|
||||
|
||||
class HampelEmaFilter:
|
||||
def __init__(self, window: int, threshold: float, alpha: float) -> None:
|
||||
def __init__(self, window: int, threshold: float, alpha: float, window_sec: float = 0.0) -> 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._window = _SampleWindow(window, window_sec)
|
||||
self._ema = EmaFilter(alpha)
|
||||
|
||||
def update(self, value: float, t: float) -> float:
|
||||
self._window.append(value)
|
||||
values = self._window.add(t, value)
|
||||
cleaned = value
|
||||
if len(self._window) >= 3:
|
||||
center = statistics.median(self._window)
|
||||
mad = statistics.median(abs(item - center) for item in self._window)
|
||||
if len(values) >= 3:
|
||||
center = statistics.median(values)
|
||||
mad = statistics.median(abs(item - center) for item in values)
|
||||
scale = max(_MAD_TO_SIGMA * mad, HAMPEL_MIN_SCALE_DEG)
|
||||
if abs(value - center) > self.threshold * scale:
|
||||
cleaned = center
|
||||
@@ -155,15 +189,14 @@ class OneEuroFilter:
|
||||
|
||||
|
||||
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)
|
||||
def __init__(
|
||||
self, window: int, min_cutoff: float, beta: float, d_cutoff: float = 1.0, window_sec: float = 0.0,
|
||||
) -> None:
|
||||
self._window = _SampleWindow(window, window_sec)
|
||||
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)
|
||||
return self._euro.update(statistics.median(self._window.add(t, value)), t)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._window.clear()
|
||||
@@ -203,6 +236,7 @@ def create_angle_filter(
|
||||
one_euro_beta: float = 0.0,
|
||||
one_euro_d_cutoff: float = 1.0,
|
||||
reset_gap_sec: float = 0.0,
|
||||
window_sec: float = 0.0,
|
||||
) -> AngleFilter:
|
||||
"""Build a filter by name; raises ``ValueError`` for unknown kinds."""
|
||||
inner: AngleFilter
|
||||
@@ -211,13 +245,13 @@ def create_angle_filter(
|
||||
elif kind == "ema":
|
||||
inner = EmaFilter(alpha)
|
||||
elif kind == "median_ema":
|
||||
inner = MedianEmaFilter(window, alpha)
|
||||
inner = MedianEmaFilter(window, alpha, window_sec)
|
||||
elif kind == "hampel_ema":
|
||||
inner = HampelEmaFilter(window, hampel_threshold, alpha)
|
||||
inner = HampelEmaFilter(window, hampel_threshold, alpha, window_sec)
|
||||
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)
|
||||
inner = MedianOneEuroFilter(window, one_euro_min_cutoff, one_euro_beta, one_euro_d_cutoff, window_sec)
|
||||
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
|
||||
@@ -175,6 +175,10 @@ def build_rows(entry: dict[str, list]) -> list[tuple[str, dict[str, float]]]:
|
||||
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))
|
||||
add("raw + median 0.55 s + EMA a=0.5",
|
||||
apply_filter("median_ema", raw, window=5, alpha=0.5, window_sec=0.55))
|
||||
add("raw + median 0.45 s + EMA a=0.5",
|
||||
apply_filter("median_ema", raw, window=5, alpha=0.5, window_sec=0.45))
|
||||
return rows
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user