mahmoud611 commited on
Commit
d00057b
Β·
verified Β·
1 Parent(s): 9a45a7e

Upload inference.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. 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
- - Naive peak detection counts both β†’ doubles the BPM (the main bug fixed here)
116
- - Fix: use envelope smoothing + long refractory window to detect only S1 peaks
117
- - Valid canine heart rate range: 40–180 BPM
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.04 * sr) | 1 # 40 ms, must be odd
134
- coarse_len = int(0.08 * sr) | 1 # 80 ms, must be odd
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.35
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
- # Prominence filter: each detected peak must stand at least 25% of
152
- # the local envelope height above its neighbours β†’ eliminates noise
153
- # but preserves rapid S1 peaks (was 30%, too aggressive for tachy).
154
- prominence = v90 * 0.25
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
- # ── 5. Fallback for Quiet / Far Recordings ────────────────────────────
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
- # ── 6. Adaptive S2 Rejection ──────────────────────────────────────────
174
- # Instead of a fixed refractory window, use the MEDIAN interval to
175
- # adaptively reject S2 peaks. S2 is typically 60-70% of the cycle
176
- # length after S1. Any peak within 40% of the median interval is
177
- # likely S2 and should be removed.
178
- if len(peaks) >= 3:
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
- # ── 7. BPM Calculation ────────────────────────────────────────────────
197
- # Use median of inter-beat intervals (robust to occasional missed beats)
198
  intervals = np.diff(peaks)
199
  median_interval = np.median(intervals)
200
- bpm = (60.0 * sr) / median_interval
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
- # Clamp to physiological canine range (40–300 BPM)
203
- # Small breeds and SVT can reach 240+ BPM
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: