Diameter analysis¶
Diameter analysis measures vessel width over time on a line-scan kymograph ROI. Rows are time and columns are distance across the vessel. Diameter is reported in microns when the file provides physical calibration.
This notebook walks through:
- Load an
AcqImageand select a rectangular ROI. - Understand the detection parameters (many options, grouped by method).
- Run diameter analysis with the threshold_width method (fewer parameters).
- Plot diameter versus time and overlay detected edges on the kymograph.
- Briefly compare with the gradient_edges method.
Two detection methods¶
- threshold_width — threshold a spatial intensity profile to find the two vessel edges. Fewer method-specific parameters, so we feature it in depth.
- gradient_edges — locate edges from intensity gradients with optional motion gating. More parameters; we use it only for a quick comparison.
There is no built-in dual-method accept/reject check like heart rate. Use the CloudScope GUI to explore parameters, then fix a subset for batch processing.
Where this fits in CloudScope¶
Diameter has many detection parameters. The CloudScope GUI is the practical place to explore polarity, thresholding, and (for gradient edges) motion-gating settings on one file before committing to a parameter set.
Once settled, apply the same fixed parameters across an AcqImageList so diameter traces stay comparable across files and ROIs.
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
pio.renderers.default = "notebook"
from acqstore.acq_image.analysis import DiameterAnalysis
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 demo-small, which includes a diameter ROI on channel 0, ROI 2. Replace ensure_sample(...) with a local folder path when adapting this notebook.
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.
channel_indices = acq.images.channel_indices
roi_ids = acq.rois.get_roi_ids()
CHANNEL = channel_indices[0]
ROI_ID = 2 # diameter ROI on this sample file (one of roi_ids)
print("file:", acq.name)
print("channels:", channel_indices, "roi_ids:", roi_ids)
print("channel:", CHANNEL, "roi:", ROI_ID)
file: 20251030_A106_0002.oir channels: [0] roi_ids: [1, 2, 3, 4] channel: 0 roi: 2
Detection parameters¶
The full schema is large. Each parameter is tagged with the methods it applies to: all methods (shared), threshold_width, or gradient_edges. The table below lists every parameter; see the methods column for that grouping.
DiameterAnalysis.get_detection_schema_dataframe()
| display_name | type | default | choices | unit | editable | visible | methods | description | |
|---|---|---|---|---|---|---|---|---|---|
| name | |||||||||
| window_rows_odd | Window Rows (Odd) | int | 5 | None | None | True | True | (threshold_width, gradient_edges) | Odd number of time rows aggregated into each s... |
| stride | Stride | int | 1 | None | None | True | True | (threshold_width, gradient_edges) | Center-row increment between successive measur... |
| binning_method | Binning Method | enum | mean | (mean, median) | None | True | True | (threshold_width, gradient_edges) | Window reducer across rows before edge detection. |
| polarity | Polarity | enum | bright_on_dark | (bright_on_dark, dark_on_bright) | None | True | True | (threshold_width, gradient_edges) | Intensity polarity; dark_on_bright inverts the... |
| diameter_method | Detection Method | enum | threshold_width | (threshold_width, gradient_edges) | None | True | True | None | Core detector implementation. |
| post_filter_kernel_size | Post-Filter Kernel Size | int | 3 | None | None | True | True | None | Odd median kernel applied to diameter after de... |
| threshold_mode | Threshold Mode | enum | half_max | (half_max, absolute) | None | True | True | (threshold_width,) | Threshold rule for threshold_width. |
| threshold_value | Threshold Value | float | 0.0 | None | None | True | True | (threshold_width,) | Absolute threshold when threshold_mode='absolu... |
| gradient_sigma | Gradient Sigma | float | 1.5 | None | None | True | True | (gradient_edges,) | Gaussian smoothing sigma for gradient edge fin... |
| gradient_kernel | Gradient Kernel | enum | central_diff | (central_diff,) | None | True | True | (gradient_edges,) | Derivative kernel for gradient_edges. |
| gradient_min_edge_strength | Min Edge Strength | float | 0.02 | None | None | True | True | (gradient_edges,) | Minimum derivative magnitude for a confident e... |
| enable_motion_gating | Enable Motion Gating | bool | True | None | None | True | True | (gradient_edges,) | Apply frame-to-frame motion constraints for gr... |
| max_edge_shift_um | Max Edge Shift (um) | float | 2.0 | None | None | True | True | (gradient_edges,) | Maximum allowed per-frame left/right edge shift. |
| max_diameter_change_um | Max Diameter Change (um) | float | 2.0 | None | None | True | True | (gradient_edges,) | Maximum allowed per-frame diameter jump. |
| max_center_shift_um | Max Center Shift (um) | float | 2.0 | None | None | True | True | (gradient_edges,) | Maximum allowed per-frame center shift. |
threshold_width parameters (in depth)¶
Shared parameters (used by both methods):
window_rows_odd— odd number of time rows aggregated into each spatial profile (more rows = smoother, less time resolution).stride— center-row increment between successive measurements along time.binning_method—meanormedianreducer across the window rows.polarity—bright_on_darkordark_on_bright(invert the profile for dark vessels).diameter_method— selectsthreshold_widthorgradient_edges.post_filter_kernel_size— odd median kernel applied to diameter after detection (post-detection smoothing).
threshold_width only:
threshold_mode—half_max(threshold at half the profile peak) orabsolute.threshold_value— the absolute threshold used whenthreshold_mode='absolute'.
That is just two method-specific knobs, which is why threshold_width is a good first method to learn. gradient_edges adds seven more parameters (gradient_sigma, gradient_min_edge_strength, motion-gating limits, ...) that are best explored in the GUI.
Choose parameters and run (threshold_width)¶
We set two detection parameters — the method (diameter_method='threshold_width') and the window size (window_rows_odd=5) — and run them in one call with create_and_run. Any parameter we do not set keeps its default. Diameter analysis uses threads, which is notebook-safe, and takes roughly 10 seconds on this sample ROI.
detection_params = {"diameter_method": "threshold_width", "window_rows_odd": 5}
diameter_thresh = acq.analysis_set.create_and_run(
DiameterAnalysis,
channel=CHANNEL,
roi_id=ROI_ID,
detection_params=detection_params,
replace_existing=True,
)
print("summary:")
for key, value in diameter_thresh.result.summary.items():
print(f" {key}: {value}")
summary: num_rows: 30000 diameter_um_mean: 0.17859041581471 diameter_um_median: 0.17691366071138104 diameter_um_cv: 0.1495512510035226 qc_score_mean: 0.5724033333333333 num_qc_edge_violations: 25184 num_qc_diameter_violations: 0 num_qc_center_violations: 0
Diameter versus time¶
get_plot_data() prefers the filtered diameter column (diameter_um_filt) when present, otherwise the raw diameter_um.
plot_data = diameter_thresh.get_plot_data()
fig = go.Figure(
go.Scatter(x=plot_data.x, y=plot_data.y, mode="lines", name=plot_data.series_name)
)
fig.update_layout(
title=f"Diameter (threshold_width) - ROI {ROI_ID}",
xaxis_title=plot_data.x_label,
yaxis_title=plot_data.y_label,
margin={"l": 60, "r": 20, "t": 50, "b": 50},
)
fig.show()
Kymograph ROI with detected edges¶
Edge overlays from get_overlay_traces() are in ROI-local physical coordinates: x = time (s), y = distance (um). We draw the kymograph as a heatmap (time on the y-axis, distance on the x-axis) and overlay the left/right edges. The image is subsampled along time for a responsive plot (the full ROI can be tens of thousands of rows).
roi_image = acq.get_roi_image(channel=CHANNEL, roi_id=ROI_ID)
step_y, step_x = acq.get_image_physical_units()
# Subsample along time for a responsive Plotly heatmap.
max_display_rows = 2000
row_stride = max(1, roi_image.shape[0] // max_display_rows)
img_ds = roi_image[::row_stride, :]
time_axis = np.arange(img_ds.shape[0], dtype=float) * step_y * row_stride
space_axis = np.arange(img_ds.shape[1], dtype=float) * step_x
fig = go.Figure()
fig.add_trace(
go.Heatmap(z=img_ds, x=space_axis, y=time_axis, colorscale="Gray", showscale=False)
)
for trace in diameter_thresh.get_overlay_traces():
fig.add_trace(
go.Scatter(x=trace.y, y=trace.x, mode="lines", name=trace.name, line={"width": 1.5})
)
fig.update_layout(
title=f"Kymograph ROI {ROI_ID} with diameter edges (threshold_width)",
xaxis_title="distance (um)",
yaxis_title="time (s)",
yaxis={"autorange": "reversed"},
margin={"l": 60, "r": 20, "t": 50, "b": 50},
)
fig.show()
Compare with gradient_edges¶
Run the alternative method on the same ROI with its defaults and overlay both diameter traces. The two methods should broadly agree; large or systematic differences are a cue to review the parameter set or method choice for your data.
diameter_grad = acq.analysis_set.create_and_run(
DiameterAnalysis,
channel=CHANNEL,
roi_id=ROI_ID,
detection_params={"diameter_method": "gradient_edges"},
replace_existing=True,
)
thresh_plot = diameter_thresh.get_plot_data()
grad_plot = diameter_grad.get_plot_data()
fig = go.Figure()
fig.add_trace(go.Scatter(x=thresh_plot.x, y=thresh_plot.y, mode="lines", name="threshold_width"))
fig.add_trace(go.Scatter(x=grad_plot.x, y=grad_plot.y, mode="lines", name="gradient_edges"))
fig.update_layout(
title=f"Diameter method comparison - ROI {ROI_ID}",
xaxis_title="Time (s)",
yaxis_title="Diameter (um)",
margin={"l": 60, "r": 20, "t": 50, "b": 50},
)
fig.show()
print("threshold mean um:", float(np.nanmean(diameter_thresh.result.table["diameter_um"])))
print("gradient mean um:", float(np.nanmean(diameter_grad.result.table["diameter_um"])))
threshold mean um: 0.1779019389842972 gradient mean um: 0.13156215002743268
Result table¶
The diameter table includes edge positions, QC flags, and filtered diameter columns. A useful subset is shown here.
cols = [
"time_s", "diameter_um", "diameter_um_filt",
"left_edge_um", "right_edge_um", "qc_score",
]
diameter_thresh.result.table[cols].head(10)
| time_s | diameter_um | diameter_um_filt | left_edge_um | right_edge_um | qc_score | |
|---|---|---|---|---|---|---|
| 0 | 0.000000 | 0.182621 | 0.176914 | 0.045655 | 0.228276 | 0.85 |
| 1 | 0.000535 | 0.171207 | 0.176914 | 0.057069 | 0.228276 | 0.85 |
| 2 | 0.001070 | NaN | NaN | 0.114138 | NaN | 0.50 |
| 3 | 0.001605 | NaN | NaN | 0.114138 | NaN | 0.50 |
| 4 | 0.002140 | NaN | NaN | 0.171207 | NaN | 0.50 |
| 5 | 0.002675 | NaN | NaN | NaN | NaN | 0.35 |
| 6 | 0.003210 | NaN | NaN | NaN | NaN | 0.40 |
| 7 | 0.003745 | NaN | NaN | NaN | 0.068483 | 0.65 |
| 8 | 0.004280 | NaN | NaN | NaN | 0.068483 | 0.65 |
| 9 | 0.004815 | NaN | NaN | NaN | 0.091310 | 0.50 |
Save and load results¶
Calling acq.save() writes the analysis to two files next to the recording: a JSON file with the detection parameters and summary statistics, and a CSV file with the per-time table (edge positions and QC columns). A later session can load these files 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(
DiameterAnalysis, channel=CHANNEL, roi_id=ROI_ID
)
print("loaded summary:", loaded_analysis.result.summary)
loaded summary: {'diameter_um_cv': 0.4045401188768176, 'diameter_um_mean': 0.1336666481763689, 'diameter_um_median': 0.136965414744295, 'num_qc_center_violations': 0, 'num_qc_diameter_violations': 0, 'num_qc_edge_violations': 29857, 'num_rows': 30000, 'qc_score_mean': 0.46263166666666666}
Takeaways¶
- Diameter measures vessel width over time on a kymograph ROI.
- threshold_width has fewer method-specific parameters than gradient_edges; tune the shared parameters first, then pick a method.
- Use the GUI to explore gradient_edges motion-gating parameters before batch runs.
- Overlay traces (
get_overlay_traces) help visually QC edge tracking on the kymograph. - Fix one parameter set across an
AcqImageListfor comparable batch results.