Heart rate analysis¶
Heart rate analysis estimates a periodic "heartbeat" frequency from a velocity time-series. In CloudScope the velocity series comes from a radon_velocity analysis run on a kymograph ROI, and heart rate is reported in beats-per-minute (bpm) and Hz (bpm = 60 x Hz).
This notebook walks through the full workflow:
- Load an
AcqImagefrom a sample file. - Select a channel/ROI and obtain its velocity analysis (the input to heart rate).
- Show and explain the heart rate detection parameters.
- Choose parameters and run heart rate analysis.
- Make interactive Plotly diagnostic plots.
The key idea: run two estimators and accept only if they agree¶
There is no single "correct" way to find a dominant frequency in a noisy, irregularly-sampled signal, so heart rate analysis computes the answer two independent ways:
- Lomb-Scargle periodogram — works directly on the (possibly gappy / unevenly sampled) velocity samples.
- Welch power spectral density (PSD) — works on a band-pass filtered, gap-interpolated, evenly-resampled version of the signal.
If both methods land on the same frequency, that is strong evidence the heart rate is real and we accept it. If they disagree wildly (e.g. one says 308 bpm and the other says 389 bpm), that usually means one method locked onto noise, a harmonic, or an artifact — so the result is flagged for rejection rather than silently trusted.
This is why we always run both methods in one pass: the agreement (or disagreement) between them is the quality-control signal.
Where this fits in CloudScope¶
This notebook uses the acqstore scripting API directly. In everyday use you would typically explore detection parameters interactively in the CloudScope GUI, which makes it fast to try parameter values on a single file/channel/ROI and see the result immediately.
Once you have settled on a parameter set, a powerful feature of AcqImageList is that you can fix one set of detection parameters and run the analysis across an arbitrarily large number of files. Using a single fixed parameter set is good scientific practice: it keeps results comparable across files, channels, and ROIs. The companion Heart Rate Batch Analysis notebook demonstrates that batch workflow.
import numpy as np
import pandas as pd
import plotly.graph_objects as go
# Render Plotly figures inline in Jupyter. The "notebook" renderer embeds
# plotly.js in the output so figures display offline. Use "notebook_connected"
# for a smaller file that loads plotly.js from a CDN (needs internet). Without
# this, fig.show() can silently produce no output.
import plotly.io as pio
pio.renderers.default = "notebook"
from acqstore.acq_image.analysis import RadonVelocityAnalysis
from acqstore.acq_image.analysis import HeartRateAnalysis
# Plotly diagnostic helpers (live in acqstore, return plotly figures).
from acqstore.acq_image.analysis.heart_rate_analysis.plotting.plotly_plots import (
plot_summary_plotly,
plot_segment_series_plotly,
)
from acqstore.acq_image import AcqImage
from acqstore.acq_image import AcqImageList
from acqstore.sample_data import ensure_sample
Load a sample file¶
We use the reproducible demo-small sample dataset. Replace ensure_sample(...) with a local folder path when adapting this notebook to your own .oir, .czi, .tif, or .ome.zarr data. The sample already contains saved radon_velocity analyses, so we can reuse them as the heart rate input.
sample_folder = ensure_sample("demo-small")
acq_list = AcqImageList(str(sample_folder))
acq = acq_list.get_files()[0]
# Be explicit: list the available channels and ROIs, then pick from them later.
channel_indices = acq.images.channel_indices
roi_ids = acq.rois.get_roi_ids()
print("file:", acq.name)
print("channels:", channel_indices)
print("roi_ids:", roi_ids)
file: 20251030_A106_0002.oir channels: [0] roi_ids: [1, 2, 3, 4]
Step 1 - velocity analysis is the input¶
Heart rate analysis is seeded by a radon_velocity analysis for the same (channel, roi_id). It reads the parent velocity through the generic get_plot_data() API, so heart rate never depends on Radon-specific table columns.
This file ships with two ROIs that already have a saved velocity analysis on channel 0:
- ROI 3 — the two heart rate methods will agree (an accept case).
- ROI 1 — the two methods will disagree (a reject case).
The helper below reuses a saved velocity analysis when present and only re-runs Radon if needed (re-running is slow). It prints the sampling diagnostics that matter for heart rate (the number of valid samples and the effective sample rate), shows the single Radon velocity parameter, and plots the velocity-versus-time series that becomes the heart rate input.
CHANNEL = channel_indices[0]
ACCEPT_ROI = 3 # methods agree -> accept
REJECT_ROI = 1 # methods disagree -> reject
def get_velocity(acq_image, *, channel, roi_id):
"""Return a velocity analysis with plot data, running Radon only if needed."""
vel = acq_image.analysis_set.get_or_create(
RadonVelocityAnalysis.analysis_name, channel=channel, roi_id=roi_id
)
if vel.get_plot_data() is None:
# No saved velocity for this selection: run it fresh (slow).
acq_image.analysis_set.run_analysis(vel.key)
return vel
def velocity_diagnostics(vel):
"""Print sampling diagnostics for a velocity series."""
pd_ = vel.get_plot_data()
t = np.asarray(pd_.x, dtype=float)
v = np.asarray(pd_.y, dtype=float)
finite = np.isfinite(t) & np.isfinite(v)
n_valid = int(np.sum(finite))
print(f"n_total = {t.size}, n_valid = {n_valid} (heart-rate core needs >= 256)")
if n_valid >= 2:
dt = np.diff(t[finite])
dt = dt[np.isfinite(dt) & (dt > 0)]
fs = 1.0 / float(np.median(dt))
print(f"effective sample rate ~= {fs:.1f} Hz (Nyquist {fs / 2:.1f} Hz)")
print(f"time span = {t[finite].min():.2f} .. {t[finite].max():.2f} s")
# Radon velocity has a single detection parameter.
print("Radon velocity parameters:")
for field in RadonVelocityAnalysis.get_detection_schema():
print(f" {field.name}: default={field.default!r} choices={field.choices}")
velocity = get_velocity(acq, channel=CHANNEL, roi_id=ACCEPT_ROI)
velocity_diagnostics(velocity)
# Plot the velocity-versus-time series that feeds heart rate analysis.
vel_plot = velocity.get_plot_data()
vel_fig = go.Figure(go.Scatter(x=vel_plot.x, y=vel_plot.y, mode="lines", name="velocity"))
vel_fig.update_layout(
title=f"Velocity vs time (heart rate input) - ROI {ACCEPT_ROI}",
xaxis_title="time (s)",
yaxis_title="velocity",
margin={"l": 60, "r": 20, "t": 50, "b": 50},
)
vel_fig.show()
Radon velocity parameters: window_width: default=64 choices=(16, 64, 128) n_total = 1872, n_valid = 1872 (heart-rate core needs >= 256) effective sample rate ~= 116.8 Hz (Nyquist 58.4 Hz) time span = 0.02 .. 16.03 s
Step 2 - heart rate detection parameters¶
Every analysis exposes a typed detection schema describing its tunable parameters, their defaults, types, and units. The table below lists the full heart rate schema.
HeartRateAnalysis.get_detection_schema_dataframe()
| type | default | unit | description | |
|---|---|---|---|---|
| name | ||||
| bpm_min | float | 240.0 | bpm | Lower heart-rate bound of the analysis band. |
| bpm_max | float | 600.0 | bpm | Upper heart-rate bound of the analysis band. |
| use_abs | bool | True | NaN | Analyze absolute velocity instead of signed ve... |
| outlier_k_mad | float | 4.0 | NaN | MAD winsorization factor applied during prepro... |
| lomb_n_freq | int | 512 | NaN | Number of frequencies in the Lomb-Scargle grid. |
| interp_max_gap_sec | float | 0.05 | s | Maximum NaN gap interpolated for the Welch path. |
| bandpass_order | int | 3 | NaN | Butterworth band-pass order for the Welch path. |
| nperseg_sec | float | 2.0 | s | Welch PSD segment duration. |
| edge_margin_hz | float | -1.0 | Hz | Edge margin in Hz for edge flagging. Use -1.0 ... |
| peak_half_width_hz | float | 0.5 | Hz | Half-width around the peak used for band conce... |
| agree_tol_bpm | float | 30.0 | bpm | Maximum Lomb-vs-Welch bpm delta considered agr... |
| do_segments | bool | False | NaN | Compute a compact windowed segment summary. |
| seg_win_sec | float | 6.0 | s | Segment window length when segments are computed. |
| seg_step_sec | float | 1.0 | s | Segment window step when segments are computed. |
| seg_min_valid_frac | float | 0.5 | NaN | Minimum finite-sample fraction required per se... |
What the important parameters do¶
Search band — bpm_min / bpm_max define the frequency window the estimators are allowed to pick from. The default 240-600 bpm equals 4-10 Hz, a sensible window for small-animal heart rates. Peaks outside this band are ignored, which keeps both methods from locking onto slow drifts or high-frequency noise.
Agreement tolerance — agree_tol_bpm (default 30) is the heart of the accept/reject logic. After both methods report a bpm, the result is accepted only if
abs(lomb_bpm - welch_bpm) <= agree_tol_bpm
Otherwise the rollup status becomes method_disagree and the result should be reviewed/rejected.
Preprocessing — use_abs analyzes flow magnitude; outlier_k_mad winsorizes spikes; interp_max_gap_sec, bandpass_order, and nperseg_sec shape the evenly-resampled signal that Welch consumes; lomb_n_freq sets the Lomb grid resolution.
Quality flags — edge_margin_hz (-1.0 = auto) flags a peak sitting suspiciously close to a band edge, and peak_half_width_hz drives a "band concentration" metric (how peaky vs. spread-out the spectrum is).
Optional segments — do_segments plus seg_win_sec / seg_step_sec / seg_min_valid_frac compute heart rate in a sliding window so you can see how it varies over time. It is off by default; we enable it later for a QC plot.
Step 3 - choose parameters¶
We start from the schema defaults and adjust a few values for this dataset: keep the default 240-600 bpm band, keep the 30 bpm agreement tolerance, and turn on segment computation so we can plot heart rate over time later.
params = HeartRateAnalysis.get_default_detection_params()
params["bpm_min"] = 240.0
params["bpm_max"] = 600.0
params["agree_tol_bpm"] = 30.0
params["do_segments"] = True
params
{'bpm_min': 240.0,
'bpm_max': 600.0,
'use_abs': True,
'outlier_k_mad': 4.0,
'lomb_n_freq': 512,
'interp_max_gap_sec': 0.05,
'bandpass_order': 3,
'nperseg_sec': 2.0,
'edge_margin_hz': -1.0,
'peak_half_width_hz': 0.5,
'agree_tol_bpm': 30.0,
'do_segments': True,
'seg_win_sec': 6.0,
'seg_step_sec': 1.0,
'seg_min_valid_frac': 0.5}
Step 4 - run heart rate (an accept case)¶
We run heart rate on ROI 3, where the two methods agree, with create_and_run(). Both estimators run in a single pass and the result is stored as a compact JSON summary (there is no CSV table for heart rate).
hr_accept = acq.analysis_set.create_and_run(
HeartRateAnalysis,
channel=CHANNEL,
roi_id=ACCEPT_ROI,
detection_params=params,
replace_existing=True,
)
summary = hr_accept.result.summary
lomb_bpm = summary["lomb"]["bpm"]
welch_bpm = summary["welch"]["bpm"]
print(f"Lomb-Scargle : {lomb_bpm:.1f} bpm ({summary['lomb']['f_hz']:.3f} Hz)")
print(f"Welch PSD : {welch_bpm:.1f} bpm ({summary['welch']['f_hz']:.3f} Hz)")
print(f"status : {summary['status']}")
Lomb-Scargle : 440.1 bpm (7.335 Hz) Welch PSD : 419.3 bpm (6.989 Hz) status : ok
Reading the agreement / status¶
The summary carries an agreement block and a rollup status:
agreement.abs_delta_bpm— absolute difference between the two methods.agreement.agree_ok—Truewhenabs_delta_bpm <= agree_tol_bpm.status—"ok"when the methods agree,"method_disagree"when they don't, or an error/insufficiency code (e.g."insufficient_valid","no_peak_welch") when a method could not produce an estimate.
The verdict is simple: accept when the methods agree, reject / review otherwise.
def verdict(summary):
"""Print a human-readable accept/reject verdict from a heart rate summary."""
agree = summary.get("agreement")
status = summary.get("status")
if agree is None:
print(f"REVIEW: a method produced no estimate (status={status!r}).")
return
accepted = bool(agree["agree_ok"]) and status == "ok"
label = "ACCEPT" if accepted else "REJECT / REVIEW"
print(
f"{label}: lomb={summary['lomb']['bpm']:.1f} bpm vs "
f"welch={summary['welch']['bpm']:.1f} bpm | "
f"|delta|={agree['abs_delta_bpm']:.1f} bpm "
f"(tol {agree['agree_tol_bpm']:.0f}) | status={status!r}"
)
verdict(summary)
ACCEPT: lomb=440.1 bpm vs welch=419.3 bpm | |delta|=20.7 bpm (tol 30) | status='ok'
Step 5 - Plotly diagnostics (accept case)¶
plot_summary_plotly returns a three-panel interactive figure:
- Velocity preprocessing — raw vs. cleaned/interpolated/band-passed traces.
- Welch PSD — with the detected peak and a QC annotation.
- Lomb-Scargle periodogram — with its detected peak and QC annotation.
The Welch and Lomb panels share the same frequency x-axis so you can compare their peaks directly.
When the methods agree, the two peaks line up at the same frequency.
vel_plot = velocity.get_plot_data()
fig = plot_summary_plotly(
vel_plot.x,
vel_plot.y,
params=hr_accept.detection_params,
title=f"Heart rate diagnostics - ROI {ACCEPT_ROI} (accept)",
)
fig.show()
A reject case: when the two methods disagree¶
Now run the exact same analysis on ROI 1 of the same file. Here the Lomb-Scargle and Welch estimators land on different frequencies, the status becomes method_disagree, and the verdict flags the result for rejection. In the plot, the two peak markers no longer line up — a clear visual cue that this heart rate should not be trusted.
velocity_reject = get_velocity(acq, channel=CHANNEL, roi_id=REJECT_ROI)
hr_reject = acq.analysis_set.create_and_run(
HeartRateAnalysis,
channel=CHANNEL,
roi_id=REJECT_ROI,
detection_params=params,
replace_existing=True,
)
verdict(hr_reject.result.summary)
vel_plot_reject = velocity_reject.get_plot_data()
fig = plot_summary_plotly(
vel_plot_reject.x,
vel_plot_reject.y,
params=hr_reject.detection_params,
title=f"Heart rate diagnostics - ROI {REJECT_ROI} (reject: methods disagree)",
)
fig.show()
REJECT / REVIEW: lomb=308.3 bpm vs welch=389.4 bpm | |delta|=81.1 bpm (tol 30) | status='method_disagree'
Sanity check with a known ground truth¶
Real data never tells you the "true" answer, so it helps to confirm the estimators on a synthetic velocity signal with a heart rate we choose. Below we build a pulsatile signal at exactly 360 bpm (6 Hz) plus noise, then run both estimators directly via the core API (estimate_heart_rate_global). Both should report ~360 bpm and agree, and the Plotly summary should show both peaks at 6 Hz.
# These two helpers are only needed for this synthetic ground-truth demo, so we
# import them here rather than at the top of the notebook.
from acqstore.acq_image.analysis.heart_rate_analysis.heart_rate_core import (
estimate_heart_rate_global,
)
from acqstore.acq_image.analysis.heart_rate_analysis.heart_rate_params import (
normalize_heart_rate_detection_params,
)
rng = np.random.default_rng(0)
fs = 120.0 # samples per second
duration = 16.0 # seconds
f0 = 6.0 # 6 Hz = 360 bpm ground truth
t_syn = np.arange(0.0, duration, 1.0 / fs)
v_syn = 1.0 + 0.6 * np.sin(2.0 * np.pi * f0 * t_syn) + 0.15 * rng.standard_normal(t_syn.size)
core_kwargs = normalize_heart_rate_detection_params(params).to_core_kwargs()
lomb_est, _ = estimate_heart_rate_global(t_syn, v_syn, method="lombscargle", **core_kwargs)
welch_est, _ = estimate_heart_rate_global(t_syn, v_syn, method="welch", **core_kwargs)
print(f"ground truth : {f0 * 60:.1f} bpm ({f0:.2f} Hz)")
print(f"lomb : {lomb_est.bpm:.1f} bpm")
print(f"welch : {welch_est.bpm:.1f} bpm")
fig = plot_summary_plotly(
t_syn,
v_syn,
params=params,
title=f"Synthetic heart rate - ground truth {f0 * 60:.0f} bpm",
)
fig.show()
ground truth : 360.0 bpm (6.00 Hz) lomb : 359.8 bpm welch : 360.0 bpm
Heart rate over time (segment series)¶
Because we set do_segments=True, we can also plot heart rate in a sliding window. A stable, roughly-flat segment series is another sign of a trustworthy recording; large jumps suggest motion artifacts or signal dropout in part of the trace.
fig = plot_segment_series_plotly(
vel_plot.x,
vel_plot.y,
params=hr_accept.detection_params,
title=f"Segment heart rate - ROI {ACCEPT_ROI}",
)
fig.show()
Save and load results¶
Heart rate results are stored in the AcqImage JSON file next to the recording: the detection parameters and the compact summary dict. There is no separate CSV table. Call acq.save() to write the updated analysis, then open the recording again with AcqImage(...) to load it back without re-running the analysis, as shown below.
# Save the analysis to files next to the recording.
acq.save()
# Open the recording again and load the saved analysis back from those files.
# We look it up by type, channel, and ROI, so we do not need the object from above.
reloaded = AcqImage(acq.path)
loaded_analysis = reloaded.analysis_set.get_analysis(
HeartRateAnalysis, channel=CHANNEL, roi_id=ACCEPT_ROI
)
print("loaded summary:", loaded_analysis.result.summary)
Takeaways¶
- Heart rate is computed from a
radon_velocityseries for the same channel/ROI. - Two estimators — Lomb-Scargle and Welch — run together every time.
- Accept when they agree (
abs_delta_bpm <= agree_tol_bpm,status == "ok"); reject / review when they disagree (status == "method_disagree") or a method fails. - Running both methods in one pass is what makes that automatic quality-control check possible.
- Once you have settled on a parameter set, use
AcqImageListto run the same analysis across many files with identical parameters — see the Heart Rate Batch Analysis notebook.