Spaces:
Sleeping
Sleeping
Upload inference.py with huggingface_hub
Browse files- inference.py +65 -48
inference.py
CHANGED
|
@@ -110,48 +110,40 @@ def calculate_bpm(y, sr):
|
|
| 110 |
"""
|
| 111 |
Extract BPM and cardiac cycle count from a PCG recording.
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
Key clinical considerations:
|
| 114 |
- Each cardiac cycle produces TWO sounds: S1 (lub) and S2 (dup)
|
| 115 |
-
-
|
| 116 |
-
-
|
| 117 |
-
- Valid canine heart rate range: 40β
|
| 118 |
"""
|
| 119 |
try:
|
| 120 |
# ββ 1. RMS Noise Gate ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 121 |
-
# Skip analysis if the signal is too quiet (microphone not placed yet)
|
| 122 |
rms = np.sqrt(np.mean(y ** 2))
|
| 123 |
if rms < 0.01:
|
| 124 |
return 0, 0, np.array([])
|
| 125 |
|
| 126 |
# ββ 2. Multi-scale Hilbert Envelope βββββββββββββββββββββββββββββββββββ
|
| 127 |
-
# Compute the analytic envelope, then smooth at TWO scales:
|
| 128 |
-
# - Fine scale (40ms): remove HF noise within each sound
|
| 129 |
-
# - Coarse scale (80ms): merge S1+S2 but keep fast beats separable
|
| 130 |
-
# (was 120ms β too wide for tachycardic cases, merged adjacent beats)
|
| 131 |
envelope = np.abs(scipy.signal.hilbert(y))
|
| 132 |
|
| 133 |
-
fine_len = int(0.
|
| 134 |
-
coarse_len = int(0.
|
| 135 |
|
| 136 |
fine_env = scipy.signal.savgol_filter(envelope, fine_len, polyorder=2)
|
| 137 |
coarse_env = scipy.signal.savgol_filter(fine_env, coarse_len, polyorder=2)
|
| 138 |
coarse_env = np.clip(coarse_env, 0, None)
|
| 139 |
|
| 140 |
# ββ 3. Adaptive Height Threshold ββββββββββββββββββββββββββββββββββββββ
|
| 141 |
-
# Height = 35% of the 90th percentile of the coarse envelope.
|
| 142 |
-
# Slightly lower than before (was 40%) to catch fast, lower-amplitude beats.
|
| 143 |
v90 = np.percentile(coarse_env, 90)
|
| 144 |
-
height = v90 * 0.
|
| 145 |
-
|
| 146 |
-
# ββ 4. Two-Pass Peak Detection ββββββββββββββββββββββββββββββββββββββββ
|
| 147 |
-
# PASS 1: Detect ALL candidate peaks with a short minimum distance.
|
| 148 |
-
# At 240 BPM β cycle = 250ms. Use 200ms minimum to catch everything.
|
| 149 |
-
min_dist_samp = int(0.20 * sr) # 200ms β supports up to 300 BPM
|
| 150 |
|
| 151 |
-
#
|
| 152 |
-
#
|
| 153 |
-
|
| 154 |
-
prominence = v90 * 0.
|
| 155 |
|
| 156 |
peaks, props = scipy.signal.find_peaks(
|
| 157 |
coarse_env,
|
|
@@ -160,7 +152,7 @@ def calculate_bpm(y, sr):
|
|
| 160 |
prominence=prominence,
|
| 161 |
)
|
| 162 |
|
| 163 |
-
#
|
| 164 |
if len(peaks) < 2:
|
| 165 |
peaks, _ = scipy.signal.find_peaks(
|
| 166 |
coarse_env,
|
|
@@ -170,38 +162,63 @@ def calculate_bpm(y, sr):
|
|
| 170 |
if len(peaks) < 2:
|
| 171 |
return 0, 0, np.array([])
|
| 172 |
|
| 173 |
-
#
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
intervals = np.diff(peaks)
|
| 180 |
-
med_interval = np.median(intervals)
|
| 181 |
-
# Refractory = 40% of median interval (NOT a fixed 400-500ms)
|
| 182 |
-
# At 60 BPM: med=44100, refractory=17640 (400ms) β same as before
|
| 183 |
-
# At 180 BPM: med=14700, refractory=5880 (133ms) β catches fast beats
|
| 184 |
-
refractory = int(med_interval * 0.40)
|
| 185 |
-
refractory = max(refractory, int(0.12 * sr)) # floor: 120ms (absolute minimum)
|
| 186 |
-
|
| 187 |
-
clean_peaks = [peaks[0]]
|
| 188 |
-
for pk in peaks[1:]:
|
| 189 |
-
if pk - clean_peaks[-1] >= refractory:
|
| 190 |
-
clean_peaks.append(pk)
|
| 191 |
-
peaks = np.array(clean_peaks)
|
| 192 |
|
|
|
|
| 193 |
if len(peaks) < 2:
|
| 194 |
return 0, 0, peaks
|
| 195 |
|
| 196 |
-
#
|
| 197 |
-
# Use median of inter-beat intervals (robust to occasional missed beats)
|
| 198 |
intervals = np.diff(peaks)
|
| 199 |
median_interval = np.median(intervals)
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
-
# Clamp to physiological canine range (40β
|
| 203 |
-
|
| 204 |
-
bpm = int(max(40, min(300, bpm)))
|
| 205 |
return bpm, len(peaks), peaks
|
| 206 |
|
| 207 |
except Exception as e:
|
|
|
|
| 110 |
"""
|
| 111 |
Extract BPM and cardiac cycle count from a PCG recording.
|
| 112 |
|
| 113 |
+
Uses TWO complementary methods:
|
| 114 |
+
1. Envelope peak detection β reliable for normal/slow rates, provides peak positions
|
| 115 |
+
2. Autocorrelation β robust at ALL heart rates, used to validate/correct BPM
|
| 116 |
+
|
| 117 |
Key clinical considerations:
|
| 118 |
- Each cardiac cycle produces TWO sounds: S1 (lub) and S2 (dup)
|
| 119 |
+
- Peak detection with refractory window detects only S1 peaks
|
| 120 |
+
- Autocorrelation finds the dominant cycle period (S1-to-S1) naturally
|
| 121 |
+
- Valid canine heart rate range: 40β250 BPM
|
| 122 |
"""
|
| 123 |
try:
|
| 124 |
# ββ 1. RMS Noise Gate ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 125 |
rms = np.sqrt(np.mean(y ** 2))
|
| 126 |
if rms < 0.01:
|
| 127 |
return 0, 0, np.array([])
|
| 128 |
|
| 129 |
# ββ 2. Multi-scale Hilbert Envelope βββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
envelope = np.abs(scipy.signal.hilbert(y))
|
| 131 |
|
| 132 |
+
fine_len = int(0.05 * sr) | 1 # 50 ms, must be odd
|
| 133 |
+
coarse_len = int(0.12 * sr) | 1 # 120 ms, must be odd
|
| 134 |
|
| 135 |
fine_env = scipy.signal.savgol_filter(envelope, fine_len, polyorder=2)
|
| 136 |
coarse_env = scipy.signal.savgol_filter(fine_env, coarse_len, polyorder=2)
|
| 137 |
coarse_env = np.clip(coarse_env, 0, None)
|
| 138 |
|
| 139 |
# ββ 3. Adaptive Height Threshold ββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
| 140 |
v90 = np.percentile(coarse_env, 90)
|
| 141 |
+
height = v90 * 0.40
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
+
# ββ 4. Peak Detection (proven parameters for normal/slow rates) βββββββ
|
| 144 |
+
min_dist_sec = 0.50 # 500 ms β max 120 BPM via peaks
|
| 145 |
+
min_dist_samp = int(min_dist_sec * sr)
|
| 146 |
+
prominence = v90 * 0.30
|
| 147 |
|
| 148 |
peaks, props = scipy.signal.find_peaks(
|
| 149 |
coarse_env,
|
|
|
|
| 152 |
prominence=prominence,
|
| 153 |
)
|
| 154 |
|
| 155 |
+
# Fallback for quiet recordings
|
| 156 |
if len(peaks) < 2:
|
| 157 |
peaks, _ = scipy.signal.find_peaks(
|
| 158 |
coarse_env,
|
|
|
|
| 162 |
if len(peaks) < 2:
|
| 163 |
return 0, 0, np.array([])
|
| 164 |
|
| 165 |
+
# Post-detection refractory check
|
| 166 |
+
refractory = int(0.40 * sr)
|
| 167 |
+
clean_peaks = [peaks[0]]
|
| 168 |
+
for pk in peaks[1:]:
|
| 169 |
+
if pk - clean_peaks[-1] >= refractory:
|
| 170 |
+
clean_peaks.append(pk)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
+
peaks = np.array(clean_peaks)
|
| 173 |
if len(peaks) < 2:
|
| 174 |
return 0, 0, peaks
|
| 175 |
|
| 176 |
+
# Peak-based BPM
|
|
|
|
| 177 |
intervals = np.diff(peaks)
|
| 178 |
median_interval = np.median(intervals)
|
| 179 |
+
peak_bpm = (60.0 * sr) / median_interval
|
| 180 |
+
|
| 181 |
+
# ββ 5. Autocorrelation BPM (robust at all heart rates) ββββββββββββββββ
|
| 182 |
+
# Autocorrelation finds the dominant periodicity in the envelope.
|
| 183 |
+
# The full cardiac cycle (S1+S2+pause) repeats, so the autocorrelation
|
| 184 |
+
# peak corresponds to the S1-to-S1 interval β regardless of heart rate.
|
| 185 |
+
acorr_bpm = 0
|
| 186 |
+
try:
|
| 187 |
+
# Normalize envelope for autocorrelation
|
| 188 |
+
env_norm = coarse_env - np.mean(coarse_env)
|
| 189 |
+
autocorr = np.correlate(env_norm, env_norm, mode='full')
|
| 190 |
+
autocorr = autocorr[len(autocorr) // 2:] # keep positive lags only
|
| 191 |
+
autocorr = autocorr / autocorr[0] # normalize
|
| 192 |
+
|
| 193 |
+
# Search for dominant peak in physiological range:
|
| 194 |
+
# 40 BPM β 1.5s period, 250 BPM β 0.24s period
|
| 195 |
+
min_lag = int(0.24 * sr) # 250 BPM
|
| 196 |
+
max_lag = int(1.5 * sr) # 40 BPM
|
| 197 |
+
|
| 198 |
+
if max_lag <= len(autocorr):
|
| 199 |
+
search_region = autocorr[min_lag:max_lag]
|
| 200 |
+
if len(search_region) > 0:
|
| 201 |
+
acorr_peaks, _ = scipy.signal.find_peaks(search_region, prominence=0.1)
|
| 202 |
+
if len(acorr_peaks) > 0:
|
| 203 |
+
# First prominent peak = fundamental cardiac cycle period
|
| 204 |
+
best_lag = acorr_peaks[0] + min_lag
|
| 205 |
+
acorr_bpm = (60.0 * sr) / best_lag
|
| 206 |
+
except Exception:
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
# ββ 6. Choose best BPM estimate βββββββββββββββββββββββββββββββββββββββ
|
| 210 |
+
# Peak detection is reliable for normal rates but caps at ~120 BPM.
|
| 211 |
+
# Autocorrelation works at all rates. Use autocorrelation when it
|
| 212 |
+
# detects a meaningfully faster rate (suggesting tachycardia that
|
| 213 |
+
# peak detection is missing).
|
| 214 |
+
if acorr_bpm > 0 and acorr_bpm > peak_bpm * 1.3:
|
| 215 |
+
# Autocorrelation found significantly faster rate β tachycardia
|
| 216 |
+
bpm = acorr_bpm
|
| 217 |
+
else:
|
| 218 |
+
bpm = peak_bpm
|
| 219 |
|
| 220 |
+
# Clamp to physiological canine range (40β250 BPM)
|
| 221 |
+
bpm = int(max(40, min(250, bpm)))
|
|
|
|
| 222 |
return bpm, len(peaks), peaks
|
| 223 |
|
| 224 |
except Exception as e:
|