|
7 | 7 | :date "2025-11-21" |
8 | 8 | :tags [:dsp :signal-processing :biometrics :camera-ppg :heart-rate] |
9 | 9 | :draft true}}} |
10 | | -(ns dsp.mths) |
| 10 | +(ns dsp.mths |
| 11 | + (:require [scicloj.kindly.v4.kind :as kind])) |
11 | 12 |
|
12 | 13 | ;; ## Introduction |
13 | 14 | ;; |
|
308 | 309 | ;; |
309 | 310 | ;; Let's walk through the complete pipeline for one subject: |
310 | 311 |
|
311 | | -;; (let [;; Frequency range for heart rate (0.65-4 Hz = 39-240 bpm) |
312 | | -;; [low-cutoff high-cutoff] [0.65 4] |
313 | | - |
314 | | -;; ;; Window parameters for spectral analysis |
315 | | -;; window-size 10 ; seconds |
316 | | -;; window-samples (* sampling-rate window-size) ; 300 samples at 30 Hz |
317 | | - |
318 | | -;; ;; Create Hanning window to reduce spectral leakage |
319 | | -;; hanning (-> window-samples |
320 | | -;; Hanning. |
321 | | -;; .getWindow |
322 | | -;; dtype/as-reader) |
323 | | - |
324 | | -;; ;; Overlap parameters |
325 | | -;; overlap-fraction 0.05 ; 5% overlap between windows |
326 | | -;; hop (int (* overlap-fraction window-samples)) |
327 | | - |
328 | | -;; ;; Select subject to analyze |
329 | | -;; subject 51 |
330 | | -;; sig (signal subject) |
331 | | -;; n-samples (tc/row-count sig) |
332 | | - |
333 | | -;; ;; Apply preprocessing pipeline: DC removal → bandpass → standardization |
334 | | -;; std (-> sig |
335 | | -;; (tc/update-columns [:R :G :B] |
336 | | -;; #(-> % |
337 | | -;; remove-dc |
338 | | -;; (bandpass-filter {:fs sampling-rate |
339 | | -;; :order 4 |
340 | | -;; :low-cutoff low-cutoff |
341 | | -;; :high-cutoff high-cutoff}) |
342 | | -;; stats/standardize)) |
343 | | -;; (tc/select-columns [:R :G :B]) |
344 | | -;; ds-tensor/dataset->tensor) |
345 | | - |
346 | | -;; ;; Calculate how many windows we can extract |
347 | | -;; n-windows (-> n-samples |
348 | | -;; (- window-samples) |
349 | | -;; (quot hop)) |
350 | | - |
351 | | -;; ;; Create sliding windows: [time × windows × channels] |
352 | | -;; windows-shape [window-samples |
353 | | -;; n-windows |
354 | | -;; 3] |
355 | | -;; windows (tensor/compute-tensor |
356 | | -;; windows-shape |
357 | | -;; (fn [i j k] |
358 | | -;; ;; Extract sample at time i, window j, channel k |
359 | | -;; (std (+ (* j hop) i) |
360 | | -;; k)) |
361 | | -;; :float32) |
362 | | - |
363 | | -;; ;; Apply Hanning window to each segment |
364 | | -;; hanninged-windows (tensor/compute-tensor |
365 | | -;; windows-shape |
366 | | -;; (fn [i j k] |
367 | | -;; (* (windows i j k) |
368 | | -;; (hanning i))) |
369 | | -;; :float32) |
370 | | - |
371 | | -;; ;; Compute power spectrum for each window |
372 | | -;; power-spectrum (-> hanninged-windows |
373 | | -;; ;; Rearrange to [windows × channels × time] |
374 | | -;; (tensor/transpose [1 2 0]) |
375 | | -;; (tensor/slice 2) |
376 | | -;; ;; Apply FFT to each window |
377 | | -;; (->> (mapv (fn [window] |
378 | | -;; (let [fft (-> window |
379 | | -;; double-array |
380 | | -;; DiscreteFourier.)] |
381 | | -;; (.transform fft) |
382 | | -;; ;; Get magnitude spectrum (power) |
383 | | -;; (.getMagnitude fft true))))) |
384 | | -;; tensor/->tensor |
385 | | -;; ;; Reshape to [windows × channels × frequencies] |
386 | | -;; (#(tensor/reshape % |
387 | | -;; [n-windows |
388 | | -;; 3 |
389 | | -;; (-> % first count)])) |
390 | | -;; ;; Transpose to [frequencies × windows × channels] for plotting |
391 | | -;; (tensor/transpose [2 0 1]))] |
392 | | -;; ;; ### Visualizations |
393 | | -;; ;; |
394 | | -;; ;; We'll create several visualizations to understand the signal processing pipeline: |
395 | | - |
396 | | -;; [(-> windows |
397 | | -;; (dfn/* 100) |
398 | | -;; plotly/imshow |
399 | | -;; (kind/hiccup |
400 | | -;; [:div |
401 | | -;; [:h4 "1. Raw Windowed Signal (after preprocessing)"] |
402 | | -;; [:p "Each row is a time sample, showing how the signal varies across windows (columns) and channels."]])) |
403 | | - |
404 | | -;; (-> hanninged-windows |
405 | | -;; (dfn/* 100) |
406 | | -;; plotly/imshow |
407 | | -;; (kind/hiccup |
408 | | -;; [:div |
409 | | -;; [:h4 "2. After Hanning Window"] |
410 | | -;; [:p "Notice how the edges of each window are tapered to zero. This reduces spectral leakage in the FFT."]])) |
411 | | - |
412 | | -;; (-> power-spectrum |
413 | | -;; plotly/imshow |
414 | | -;; (kind/hiccup |
415 | | -;; [:div |
416 | | -;; [:h4 "3. Power Spectrum"] |
417 | | -;; [:p "Each row is a frequency bin. Bright bands indicate strong frequency components. " |
418 | | -;; "The dominant frequency (brightest band in the 0.65-4 Hz range) corresponds to the heart rate."]])) |
419 | | - |
420 | | -;; (-> power-spectrum |
421 | | -;; (tensor/slice 1) |
422 | | -;; (->> (map (fn [s] |
423 | | -;; (-> s |
424 | | -;; ds-tensor/tensor->dataset |
425 | | -;; (tc/rename-columns [:R :G :B]) |
426 | | -;; plot-signal)))) |
427 | | -;; (cons (kind/md "### 4. Power Spectrum Time Series\n\nEach plot shows how the power spectrum evolves over time for one channel.")))]) |
| 312 | +(let [;; Frequency range for heart rate (0.65-4 Hz = 39-240 bpm) |
| 313 | + [low-cutoff high-cutoff] [0.65 4] |
| 314 | + |
| 315 | + ;; Window parameters for spectral analysis |
| 316 | + window-size 10 ; seconds |
| 317 | + window-samples (* sampling-rate window-size) ; 300 samples at 30 Hz |
| 318 | + |
| 319 | + ;; Create Hanning window to reduce spectral leakage |
| 320 | + hanning (-> window-samples |
| 321 | + Hanning. |
| 322 | + .getWindow |
| 323 | + dtype/as-reader) |
| 324 | + |
| 325 | + ;; Overlap parameters |
| 326 | + overlap-fraction 0.05 ; 5% overlap between windows |
| 327 | + hop (int (* overlap-fraction window-samples)) |
| 328 | + |
| 329 | + ;; Select subject to analyze |
| 330 | + subject 51 |
| 331 | + sig (signal subject) |
| 332 | + n-samples (tc/row-count sig) |
| 333 | + |
| 334 | + ;; Apply preprocessing pipeline: DC removal → bandpass → standardization |
| 335 | + std (-> sig |
| 336 | + (tc/update-columns [:R :G :B] |
| 337 | + #(-> % |
| 338 | + remove-dc |
| 339 | + (bandpass-filter {:fs sampling-rate |
| 340 | + :order 4 |
| 341 | + :low-cutoff low-cutoff |
| 342 | + :high-cutoff high-cutoff}) |
| 343 | + stats/standardize)) |
| 344 | + (tc/select-columns [:R :G :B]) |
| 345 | + ds-tensor/dataset->tensor) |
| 346 | + |
| 347 | + ;; Calculate how many windows we can extract |
| 348 | + n-windows (-> n-samples |
| 349 | + (- window-samples) |
| 350 | + (quot hop)) |
| 351 | + |
| 352 | + ;; Create sliding windows: [time × windows × channels] |
| 353 | + windows-shape [window-samples |
| 354 | + n-windows |
| 355 | + 3] |
| 356 | + windows (tensor/compute-tensor |
| 357 | + windows-shape |
| 358 | + (fn [i j k] |
| 359 | + ;; Extract sample at time i, window j, channel k |
| 360 | + (std (+ (* j hop) i) |
| 361 | + k)) |
| 362 | + :float32) |
| 363 | + |
| 364 | + ;; Apply Hanning window to each segment |
| 365 | + hanninged-windows (tensor/compute-tensor |
| 366 | + windows-shape |
| 367 | + (fn [i j k] |
| 368 | + (* (windows i j k) |
| 369 | + (hanning i))) |
| 370 | + :float32) |
| 371 | + |
| 372 | + ;; Compute power spectrum for each window |
| 373 | + power-spectrum (-> hanninged-windows |
| 374 | + ;; Rearrange to [windows × channels × time] |
| 375 | + (tensor/transpose [1 2 0]) |
| 376 | + (tensor/slice 2) |
| 377 | + ;; Apply FFT to each window |
| 378 | + (->> (mapv (fn [window] |
| 379 | + (let [fft (-> window |
| 380 | + double-array |
| 381 | + DiscreteFourier.)] |
| 382 | + (.transform fft) |
| 383 | + ;; Get magnitude spectrum (power) |
| 384 | + (.getMagnitude fft true))))) |
| 385 | + tensor/->tensor |
| 386 | + ;; Reshape to [windows × channels × frequencies] |
| 387 | + (#(tensor/reshape % |
| 388 | + [n-windows |
| 389 | + 3 |
| 390 | + (-> % first count)])) |
| 391 | + ;; Transpose to [frequencies × windows × channels] for plotting |
| 392 | + (tensor/transpose [2 0 1]))] |
| 393 | + ;; ### Visualizations |
| 394 | + ;; |
| 395 | + ;; We'll create several visualizations to understand the signal processing pipeline: |
| 396 | + |
| 397 | + (kind/fragment |
| 398 | + [(kind/hiccup |
| 399 | + [:div |
| 400 | + [:h4 "1. Raw Windowed Signal (after preprocessing)"] |
| 401 | + [:p "Each row is a time sample, showing how the signal varies across windows (columns) and channels."]]) |
| 402 | + |
| 403 | + (-> windows |
| 404 | + (dfn/* 100) |
| 405 | + plotly/imshow) |
| 406 | + |
| 407 | + (kind/hiccup |
| 408 | + [:div |
| 409 | + [:h4 "2. After Hanning Window"] |
| 410 | + [:p "Notice how the edges of each window are tapered to zero. This reduces spectral leakage in the FFT."]]) |
| 411 | + |
| 412 | + (-> hanninged-windows |
| 413 | + (dfn/* 100) |
| 414 | + plotly/imshow) |
| 415 | + |
| 416 | + (kind/hiccup |
| 417 | + [:div |
| 418 | + [:h4 "3. Power Spectrum"] |
| 419 | + [:p "Each row is a frequency bin. Bright bands indicate strong frequency components. " |
| 420 | + "The dominant frequency (brightest band in the 0.65-4 Hz range) corresponds to the heart rate."]]) |
| 421 | + |
| 422 | + (-> power-spectrum |
| 423 | + plotly/imshow) |
| 424 | + |
| 425 | + (kind/md "### 4. Power Spectrum Time Series\n\nEach plot shows how the power spectrum evolves over time.") |
| 426 | + |
| 427 | + (-> power-spectrum |
| 428 | + (tensor/slice 1) |
| 429 | + (->> (map (fn [s] |
| 430 | + (-> s |
| 431 | + ds-tensor/tensor->dataset |
| 432 | + (tc/rename-columns [:R :G :B]) |
| 433 | + plot-signal))) |
| 434 | + (into [:div {:style {:max-height "600px" |
| 435 | + :overflow-y "auto"}}]) |
| 436 | + kind/hiccup))])) |
428 | 437 |
|
429 | 438 |
|
430 | 439 |
|
0 commit comments