Manar55's picture
Deploy a5180b1
82d7501 verified
Raw History Blame Contribute Delete
18.5 kB
from __future__ import annotations
from typing import Any, Dict, List, Tuple
import numpy as np
from scipy import signal as sp_signal
from .utils.logging import get_logger
logger = get_logger(__name__)
# ── Physiological constants ──────────────────────────────────────────
HR_MIN_HZ = 0.67 # 40 BPM
HR_MAX_HZ = 3.0 # 180 BPM
RESP_LOW_HZ = 0.15
RESP_HIGH_HZ = 0.4
MIN_PEAK_SNR = 4.0
def _find_dominant_frequency(
filt: np.ndarray, fs: float
) -> Tuple[float, np.ndarray, np.ndarray, float]:
"""
Return (f0_hz, freqs, power, peak_snr).
f0_hz is 0 if no clear peak above noise floor.
"""
win = np.hanning(len(filt))
x_win = filt * win
freqs = np.fft.rfftfreq(len(x_win), d=1.0 / fs)
power = np.abs(np.fft.rfft(x_win)) ** 2
if power.size == 0:
return 0.0, freqs, power, 0.0
mask = (freqs >= HR_MIN_HZ) & (freqs <= HR_MAX_HZ)
if not mask.any():
return 0.0, freqs, power, 0.0
band_power = power[mask]
peak_val = float(band_power.max())
median_val = float(np.median(band_power))
peak_snr = peak_val / max(median_val, 1e-12)
if peak_snr < MIN_PEAK_SNR:
return 0.0, freqs, power, peak_snr
idx_in_band = np.where(mask)[0]
best = idx_in_band[band_power.argmax()]
f0 = float(freqs[best])
return f0, freqs, power, peak_snr
def _spectral_q_factor(
freqs: np.ndarray, power: np.ndarray, f0_hz: float
) -> float:
"""
Q factor (sharpness) of the spectral peak at f0.
Q = f0 / bandwidth_at_half_max.
Real PPG: Q > 8 (very narrow peak).
Noise/deepfake: Q < 4 (broad or multi-peak).
Returns score in [0, 1].
"""
if f0_hz <= 0 or power.size == 0:
return 0.0
mask = (freqs >= HR_MIN_HZ) & (freqs <= HR_MAX_HZ)
if not mask.any():
return 0.0
band_freqs = freqs[mask]
band_power = power[mask]
peak_val = float(band_power.max())
half_max = peak_val / 2.0
above_half = band_power >= half_max
if not above_half.any():
return 0.0
indices = np.where(above_half)[0]
bw_bins = indices[-1] - indices[0] + 1
if bw_bins == 0:
return 0.0
freq_res = float(band_freqs[1] - band_freqs[0]) if len(band_freqs) > 1 else 1.0
bandwidth = bw_bins * freq_res
if bandwidth < 1e-6:
return 1.0
q = f0_hz / bandwidth
# Q 8-20 = real PPG (score 0.8-1.0), Q 4-8 = borderline (0.3-0.8), Q < 4 = noise (0-0.3)
if q >= 5:
return float(np.clip(0.8 + 0.2 * min((q - 5) / 10.0, 1.0), 0.8, 1.0))
elif q >= 2:
return float(0.3 + 0.5 * (q - 2) / 3.0)
else:
return float(np.clip(q / 2.0 * 0.3, 0.0, 0.3))
def _phase_coherence(
filtered_signals: Dict[str, np.ndarray], fs: float, f0_hz: float
) -> float:
"""
Check if pulse signals have consistent phase relationships between
face regions. In real faces, the pulse wave propagates from forehead
to cheeks with a small time delay (5-30ms). This phase relationship
is consistent across the recording.
Deepfakes have random or zero phase delay because there's no
arterial wave propagation.
At typical video frame rates (30fps), pulse transit time is sub-sample
(5-30ms vs 33ms/frame). Signals are upsampled to >=200Hz for
sufficient temporal resolution.
Returns score in [0, 1].
"""
names = list(filtered_signals.keys())
if len(names) < 2 or f0_hz <= 0:
return 0.0
target_fs = 200.0
upsample_factor = max(1, int(np.ceil(target_fs / fs)))
effective_fs = fs * upsample_factor
min_delay_samples = max(1, int(0.005 * effective_fs))
max_delay_samples = int(0.050 * effective_fs)
phase_scores = []
for i in range(len(names)):
for j in range(i + 1, len(names)):
a = filtered_signals[names[i]]
b = filtered_signals[names[j]]
if len(a) < 10 or len(b) < 10:
continue
n = min(len(a), len(b))
a, b = a[:n], b[:n]
if upsample_factor > 1:
t_orig = np.arange(n)
t_up = np.linspace(0, n - 1, n * upsample_factor)
a = np.interp(t_up, t_orig, a)
b = np.interp(t_up, t_orig, b)
n = len(a)
cc = np.correlate(a - a.mean(), b - b.mean(), mode="full")
denom = np.sqrt(np.sum((a - a.mean())**2) * np.sum((b - b.mean())**2))
if denom < 1e-10:
phase_scores.append(0.0)
continue
cc = cc / denom
center = len(cc) // 2
search_range = slice(
max(0, center - max_delay_samples),
min(len(cc), center + max_delay_samples + 1)
)
cc_segment = cc[search_range]
if len(cc_segment) == 0:
phase_scores.append(0.0)
continue
peak_idx_local = np.argmax(np.abs(cc_segment))
peak_lag = peak_idx_local - (center - max(0, center - max_delay_samples))
peak_corr = float(np.abs(cc_segment[peak_idx_local]))
abs_lag = abs(peak_lag)
if abs_lag < min_delay_samples:
delay_score = 0.3
elif min_delay_samples <= abs_lag <= max_delay_samples:
delay_score = 1.0
else:
delay_score = 0.2
win_samples = int(2.0 * effective_fs)
step = max(1, win_samples // 2)
delays = []
for start in range(0, n - win_samples + 1, step):
seg_a = a[start:start + win_samples]
seg_b = b[start:start + win_samples]
seg_cc = np.correlate(seg_a - seg_a.mean(), seg_b - seg_b.mean(), mode="full")
seg_center = len(seg_cc) // 2
seg_range = slice(
max(0, seg_center - max_delay_samples),
min(len(seg_cc), seg_center + max_delay_samples + 1)
)
seg_cc_seg = seg_cc[seg_range]
if len(seg_cc_seg) > 0:
delays.append(int(np.argmax(np.abs(seg_cc_seg))))
if len(delays) >= 3:
delay_std = float(np.std(delays))
max_acceptable_std = 3.0 * upsample_factor
consistency = float(np.clip(1.0 - delay_std / max_acceptable_std, 0.0, 1.0))
else:
consistency = 0.3
score = peak_corr * delay_score * consistency
phase_scores.append(float(np.clip(score, 0.0, 1.0)))
if not phase_scores:
return 0.0
return float(np.clip(np.mean(phase_scores), 0.0, 1.0))
def _periodicity(filt: np.ndarray, fs: float, f0_hz: float) -> float:
"""
Periodicity via autocorrelation at expected pulse period.
Requires 2nd autocorrelation peak at 2x lag for confirmation.
"""
ac = np.correlate(filt - filt.mean(), filt - filt.mean(), mode="full")
ac = ac[len(ac) // 2:]
if ac.size <= 1 or ac[0] < 1e-10:
return 0.0
ac_norm = ac / ac[0]
if f0_hz > 0:
expected_lag = int(fs / f0_hz)
search_start = max(1, expected_lag - int(0.15 * expected_lag))
search_end = min(len(ac_norm), expected_lag + int(0.15 * expected_lag) + 1)
if search_start < search_end:
peak_val = float(ac_norm[search_start:search_end].max())
lag2_start = max(1, 2 * expected_lag - int(0.15 * expected_lag))
lag2_end = min(len(ac_norm), 2 * expected_lag + int(0.15 * expected_lag) + 1)
if lag2_start < lag2_end and lag2_end <= len(ac_norm):
second_peak = float(ac_norm[lag2_start:lag2_end].max())
return float(np.clip((peak_val + second_peak) / 2.0, 0.0, 1.0))
return float(np.clip(peak_val * 0.7, 0.0, 1.0))
return float(np.clip(ac_norm[1:].max() * 0.5, 0.0, 1.0))
def _harmonic_ratio(freqs: np.ndarray, power: np.ndarray, f0: float) -> float:
if power.size == 0 or f0 <= 0:
return 0.0
f2 = 2.0 * f0
idx_f0 = int(np.argmin(np.abs(freqs - f0)))
idx_f2 = int(np.argmin(np.abs(freqs - f2)))
p_f0 = float(power[idx_f0])
p_f2 = float(power[idx_f2]) if idx_f2 < len(power) else 0.0
return float(p_f2 / max(p_f0, 1e-6))
def _harmonic_score(freqs: np.ndarray, power: np.ndarray, f0: float) -> float:
"""
Real PPG has 2nd harmonic at 2*f0 with 10-50% of fundamental power.
"""
ratio = _harmonic_ratio(freqs, power, f0)
if ratio <= 0.005:
return 0.0
if 0.03 <= ratio <= 0.80:
return 1.0
if ratio < 0.03:
return float(ratio / 0.03)
return float(np.clip(1.0 - (ratio - 0.80) / 0.4, 0.0, 1.0))
def _hr_plausibility(f0_hz: float, peak_snr: float) -> float:
if f0_hz <= 0 or not (HR_MIN_HZ <= f0_hz <= HR_MAX_HZ):
return 0.0
snr_score = float(np.clip((peak_snr - MIN_PEAK_SNR) / 8.0, 0.0, 1.0))
return 0.4 + 0.6 * snr_score
def _spectral_concentration(
freqs: np.ndarray, power: np.ndarray, f0_hz: float, bandwidth_hz: float = 0.30
) -> float:
if power.size == 0 or f0_hz <= 0:
return 0.0
hr_mask = (freqs >= HR_MIN_HZ) & (freqs <= HR_MAX_HZ)
total = float(power[hr_mask].sum()) if hr_mask.any() else float(power.sum())
if total < 1e-12:
return 0.0
band_mask = (freqs >= f0_hz - bandwidth_hz) & (freqs <= f0_hz + bandwidth_hz)
band_power = float(power[band_mask].sum()) if band_mask.any() else 0.0
return float(np.clip(band_power / total, 0.0, 1.0))
def _inter_region_coherence(filtered_signals: Dict[str, np.ndarray]) -> float:
names = list(filtered_signals.keys())
if len(names) < 2:
return 0.0
correlations = []
for i in range(len(names)):
for j in range(i + 1, len(names)):
a = filtered_signals[names[i]]
b = filtered_signals[names[j]]
if len(a) < 4 or len(b) < 4:
continue
n = min(len(a), len(b))
a, b = a[:n], b[:n]
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
correlations.append(0.0)
continue
r = float(np.corrcoef(a, b)[0, 1])
correlations.append(max(0.0, r))
if not correlations:
return 0.0
return float(np.clip(np.mean(correlations), 0.0, 1.0))
def _hrv_proxy(filt: np.ndarray, fs: float, f0_hz: float) -> Tuple[float, float]:
if len(filt) < 10 or f0_hz <= 0:
return 0.0, 0.0
min_dist = max(1, int(0.5 * fs / f0_hz))
sig_std = np.std(filt)
if sig_std < 1e-10:
return 0.0, 0.0
peaks, _ = sp_signal.find_peaks(filt, distance=min_dist, prominence=0.3 * sig_std)
if len(peaks) < 5:
return 0.0, 0.0
ipi = np.diff(peaks) / fs * 1000.0
sdnn = float(np.std(ipi))
mean_ipi = float(np.mean(ipi))
expected_ipi = 1000.0 / f0_hz if f0_hz > 0 else 0.0
if expected_ipi > 0 and abs(mean_ipi - expected_ipi) > 0.4 * expected_ipi:
return sdnn, 0.1
if 20 <= sdnn <= 120:
hrv_score = 1.0
elif sdnn < 5:
hrv_score = 0.15
elif sdnn > 300:
hrv_score = 0.1
elif sdnn < 20:
hrv_score = float(np.clip(sdnn / 20.0, 0.1, 1.0))
else:
hrv_score = float(np.clip(1.0 - (sdnn - 120) / 300.0, 0.1, 1.0))
return sdnn, hrv_score
def _temporal_hr_stability(filt: np.ndarray, fs: float, window_sec: float = 4.0) -> float:
win_samples = int(window_sec * fs)
if len(filt) < win_samples * 2:
return 0.3
step = max(1, win_samples // 2)
hr_estimates: List[float] = []
local_snr_threshold = 3.0
for start in range(0, len(filt) - win_samples + 1, step):
chunk = filt[start:start + win_samples]
win = np.hanning(len(chunk))
freqs = np.fft.rfftfreq(len(chunk), d=1.0 / fs)
power = np.abs(np.fft.rfft(chunk * win)) ** 2
mask = (freqs >= HR_MIN_HZ) & (freqs <= HR_MAX_HZ)
if not mask.any():
continue
band_power = power[mask]
peak_val = float(band_power.max())
median_val = float(np.median(band_power))
if median_val > 0 and peak_val / median_val >= local_snr_threshold:
idx_in_band = np.where(mask)[0]
best = idx_in_band[band_power.argmax()]
hr_estimates.append(float(freqs[best] * 60))
if len(hr_estimates) < 2:
return 0.1
hr_std = float(np.std(hr_estimates))
if 0.5 <= hr_std <= 10.0:
return 1.0
elif hr_std < 0.1:
return 0.3
elif hr_std > 30:
return 0.1
elif hr_std < 0.5:
return float(np.clip(hr_std / 0.5, 0.3, 1.0))
else:
return float(np.clip(1.0 - (hr_std - 10.0) / 30.0, 0.1, 1.0))
def _respiratory_modulation(filt: np.ndarray, fs: float) -> float:
if len(filt) < int(fs * 5):
return 0.3
analytic = sp_signal.hilbert(filt)
envelope = np.abs(analytic)
if np.std(envelope) < 1e-10:
return 0.0
freqs = np.fft.rfftfreq(len(envelope), d=1.0 / fs)
power = np.abs(np.fft.rfft(envelope - envelope.mean())) ** 2
total = float(power.sum())
if total < 1e-12:
return 0.0
resp_mask = (freqs >= RESP_LOW_HZ) & (freqs <= RESP_HIGH_HZ)
if not resp_mask.any():
return 0.0
resp_power = power[resp_mask]
resp_peak = float(resp_power.max())
resp_median = float(np.median(resp_power))
resp_snr = resp_peak / max(resp_median, 1e-12)
if resp_snr < 3.0:
return 0.1
band_ratio = float(resp_power.sum()) / total
return float(np.clip(band_ratio * 3.0, 0.0, 1.0))
# ── Main entry point ─────────────────────────────────────────────────
def compute_features(rppg_metrics: Dict[str, Any]) -> Dict[str, Any]:
fs = float(rppg_metrics["sampling_rate"])
regions = rppg_metrics["regions"]
feat_per_region: Dict[str, Any] = {}
filtered_signals: Dict[str, np.ndarray] = {}
for name, data in regions.items():
filt = np.asarray(data.get("filtered", []), dtype=float)
if filt.size < 4:
feat_per_region[name] = {
"f0_hz": 0.0, "hr_bpm": 0.0, "peak_snr": 0.0,
"periodicity": 0.0, "harmonic_ratio": 0.0,
"harmonic_score": 0.0, "spectral_concentration": 0.0,
"spectral_q": 0.0, "hr_plausible": 0.0,
}
continue
filtered_signals[name] = filt
f0, freqs, power, peak_snr = _find_dominant_frequency(filt, fs)
hr = float(60.0 * f0)
feat_per_region[name] = {
"f0_hz": f0,
"hr_bpm": hr,
"peak_snr": round(peak_snr, 2),
"periodicity": _periodicity(filt, fs, f0),
"harmonic_ratio": _harmonic_ratio(freqs, power, f0),
"harmonic_score": _harmonic_score(freqs, power, f0),
"spectral_concentration": _spectral_concentration(freqs, power, f0),
"spectral_q": _spectral_q_factor(freqs, power, f0),
"hr_plausible": _hr_plausibility(f0, peak_snr),
}
# ── Global features ──────────────────────────────────────────────
hrs = [v["hr_bpm"] for v in feat_per_region.values() if v["hr_bpm"] > 0]
stability = float(np.var(hrs)) if len(hrs) >= 2 else 0.0
coherence = _inter_region_coherence(filtered_signals)
# Average f0 for phase coherence
f0_vals = [v["f0_hz"] for v in feat_per_region.values() if v["f0_hz"] > 0]
avg_f0 = float(np.mean(f0_vals)) if f0_vals else 0.0
phase_coh = _phase_coherence(filtered_signals, fs, avg_f0)
best_region_name = max(
filtered_signals.keys(),
key=lambda k: len(filtered_signals[k]),
default=None,
)
if best_region_name is not None:
best_filt = filtered_signals[best_region_name]
best_f0 = feat_per_region[best_region_name]["f0_hz"]
sdnn_ms, hrv_score = _hrv_proxy(best_filt, fs, best_f0)
temporal_stability = _temporal_hr_stability(best_filt, fs)
respiratory = _respiratory_modulation(best_filt, fs)
else:
sdnn_ms, hrv_score = 0.0, 0.0
temporal_stability = 0.0
respiratory = 0.0
plausibility_scores = [v["hr_plausible"] for v in feat_per_region.values()]
avg_hr_plausibility = float(np.mean(plausibility_scores)) if plausibility_scores else 0.0
scr_scores = [v["spectral_concentration"] for v in feat_per_region.values()]
avg_spectral_concentration = float(np.mean(scr_scores)) if scr_scores else 0.0
periodicity_scores = [v["periodicity"] for v in feat_per_region.values()]
avg_periodicity = float(np.mean(periodicity_scores)) if periodicity_scores else 0.0
harmonic_scores = [v["harmonic_score"] for v in feat_per_region.values()]
avg_harmonic = float(np.mean(harmonic_scores)) if harmonic_scores else 0.0
q_scores = [v["spectral_q"] for v in feat_per_region.values()]
avg_q = float(np.mean(q_scores)) if q_scores else 0.0
liveness = {
"hr_plausibility": avg_hr_plausibility,
"spectral_concentration": avg_spectral_concentration,
"spectral_sharpness": avg_q,
"inter_region_coherence": coherence,
"phase_coherence": phase_coh,
"periodicity": avg_periodicity,
"harmonic_structure": avg_harmonic,
"hrv_score": hrv_score,
"hrv_sdnn_ms": sdnn_ms,
"temporal_hr_stability": temporal_stability,
"respiratory_score": respiratory,
}
logger.info(
f"Liveness features: SCR={avg_spectral_concentration:.3f}, Q={avg_q:.2f}, "
f"coh={coherence:.3f}, phase_coh={phase_coh:.3f}, "
f"HR_plaus={avg_hr_plausibility:.2f}, harmonic={avg_harmonic:.2f}, "
f"periodicity={avg_periodicity:.2f}, HRV={hrv_score:.2f} (SDNN={sdnn_ms:.1f}ms), "
f"temporal={temporal_stability:.2f}, respiratory={respiratory:.2f}"
)
for name, feat in feat_per_region.items():
logger.info(
f" Region {name}: HR={feat['hr_bpm']:.0f}bpm, SNR={feat['peak_snr']:.1f}, "
f"SCR={feat['spectral_concentration']:.3f}, Q={feat['spectral_q']:.2f}, "
f"periodicity={feat['periodicity']:.3f}, harmonic={feat['harmonic_score']:.2f}"
)
return {
"regions": feat_per_region,
"stability": stability,
"liveness": liveness,
}