Skip to content

Commit ec0081c

Browse files
[doc] marimo examples (#57)
* Add marimo to dependencies * Add conversion of plane_wave_compound.py * Markdown for the marimo plane-wave-compounding app * gitignore marimo * plane-wave-compound rename * Fix lint * Add README * More excludes and marimo stuff * Add README png * Add Doppler example and fix some linting * Fix trailing whitespace * Make some updates --------- Co-authored-by: Charles <c@g.com>
1 parent 6360856 commit ec0081c

File tree

10 files changed

+875
-1
lines changed

10 files changed

+875
-1
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ build
88
# uv
99
.python-version
1010

11+
# marimo
12+
marimo/_static/
13+
marimo/_lsp/
14+
.marimo.toml
15+
__marimo__
16+
1117
# Vscode
1218
.vscode/
1319
*.code-workspace

BENCHMARKS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ Anecdotally, kernel-duration seems to hit a pareto-optimum at >70% memory+comput
115115

116116
## Scaling Performance
117117

118+
(computational-complexity)=
118119
### Computational Complexity
119120

120121
The beamforming algorithm scales as:

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ check: ## Checks the code
6262
@uv run ty check src
6363
@echo "🚀 Checking for obsolete dependencies: Running deptry"
6464
@uv run deptry .
65+
@echo "🚀 Checking marimo notebooks"
66+
@uv run marimo check marimo
6567

6668
.PHONY: test
6769
test: ## Runs Python tests

assets/marimo_parameters.png

703 KB
Loading

examples/plane_wave_compound.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@
192192
im = ax.imshow(
193193
bmode_db.T,
194194
cmap="gray", # Clinical grayscale colormap
195-
vmin=-40, # 50 dB dynamic range
195+
vmin=-40, # 40 dB dynamic range
196196
vmax=0, # Normalized to maximum
197197
extent=extent, # Physical coordinates in mm
198198
aspect="equal", # Preserve spatial relationships

marimo/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
title: Readme
3+
marimo-version: 0.18.4
4+
---
5+
6+
## marimo example
7+
8+
This folder contains interactive examples that can be run with [marimo](https://docs.marimo.io/), an open-source reactive Python notebook.
9+
10+
For example, you can run with:
11+
```bash
12+
# cd mach
13+
uv run marimo run marimo/plane_wave_compound.py
14+
```
15+
16+
And then open up the associated web-browser to adjust beamforming parameters.
17+
18+
![Beamforming parameters UI](../../assets/marimo_parameters.png)

marimo/doppler.py

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
# /// script
2+
# requires-python = ">=3.13"
3+
# dependencies = [
4+
# "marimo",
5+
# "pyzmq",
6+
# ]
7+
# ///
8+
9+
import marimo
10+
11+
__generated_with = "0.18.4"
12+
app = marimo.App()
13+
14+
15+
@app.cell
16+
def _(mo):
17+
mo.md(r"""
18+
# Interactive Power Doppler Imaging
19+
20+
This example demonstrates GPU-accelerated power Doppler beamforming using a rotating disk
21+
phantom dataset from [PyMUST](https://www.biomecardio.com/MUST/index.html).
22+
23+
## Dataset
24+
25+
- **128-element linear array** at 5 MHz center frequency
26+
- **32 temporal frames** at 10 kHz PRF
27+
- **Rotating disk phantom** creating controlled Doppler shifts
28+
29+
## Interactive Parameters
30+
31+
Adjust the sliders on the left to explore how different parameters affect power Doppler imaging:
32+
33+
- **F-number**: Controls aperture size and focal characteristics
34+
- **Speed of Sound**: Adjust for different tissue types
35+
- **Tukey Alpha**: Apodization window control
36+
- **Channel Selection**: Sub-aperture experiments
37+
- **Frame Selection**: Temporal window for Doppler analysis
38+
- **Dynamic Range**: Optimize display contrast
39+
40+
---
41+
""")
42+
return
43+
44+
45+
@app.cell
46+
def _():
47+
# Import Required Libraries
48+
49+
import marimo as mo
50+
import matplotlib.pyplot as plt
51+
import numpy as np
52+
from einops import rearrange
53+
54+
# Import mach modules
55+
from mach import wavefront
56+
from mach._vis import db_zero
57+
from mach.io.must import (
58+
download_pymust_doppler_data,
59+
extract_pymust_params,
60+
linear_probe_positions,
61+
scan_grid,
62+
)
63+
from mach.kernel import beamform
64+
65+
# Check for PyMUST dependency
66+
try:
67+
import pymust
68+
except ImportError as err:
69+
raise ImportError("⚠️ PyMUST is currently required for RF-to-IQ demodulation.") from err
70+
71+
# Convenience constant
72+
MM_PER_METER = 1000
73+
return (
74+
MM_PER_METER,
75+
beamform,
76+
db_zero,
77+
download_pymust_doppler_data,
78+
extract_pymust_params,
79+
linear_probe_positions,
80+
mo,
81+
np,
82+
plt,
83+
pymust,
84+
rearrange,
85+
scan_grid,
86+
wavefront,
87+
)
88+
89+
90+
@app.cell
91+
def _(download_pymust_doppler_data, extract_pymust_params):
92+
# Load PyMUST data (cached after first download)
93+
mat_data = download_pymust_doppler_data()
94+
params = extract_pymust_params(mat_data)
95+
return mat_data, params
96+
97+
98+
@app.cell
99+
def _(mat_data, params, pymust):
100+
# Convert RF to IQ format
101+
rf_data = mat_data["RF"].astype(float)
102+
iq_data = pymust.rf2iq(rf_data, params)
103+
return (iq_data,)
104+
105+
106+
@app.cell
107+
def _(linear_probe_positions, np, params, scan_grid):
108+
# Set up imaging geometry
109+
element_positions = linear_probe_positions(params["Nelements"], params["pitch"])
110+
111+
# Create 2D imaging grid (±12.5 mm lateral, 10-35 mm depth)
112+
x = np.linspace(-12.5e-3, 12.5e-3, num=251, endpoint=True)
113+
y = np.array([0.0])
114+
z = np.linspace(10e-3, 35e-3, num=251, endpoint=True)
115+
grid_points = scan_grid(x, y, z)
116+
return element_positions, grid_points, x, z
117+
118+
119+
@app.cell
120+
def _(grid_points, np, params, wavefront):
121+
# Compute transmit delays for 0° plane wave
122+
wavefront_arrivals_s = (
123+
wavefront.plane(
124+
origin_m=np.array([0, 0, 0]),
125+
points_m=grid_points,
126+
direction=np.array([0, 0, 1]),
127+
)
128+
/ params["c"]
129+
)
130+
return (wavefront_arrivals_s,)
131+
132+
133+
@app.cell
134+
def _(iq_data, np, rearrange):
135+
# Prepare data and extract dimensions
136+
iq_data_reordered = np.ascontiguousarray(
137+
rearrange(iq_data, "samples elements frames -> elements samples frames"), dtype=np.complex64
138+
)
139+
140+
# Extract dimensions for slider ranges
141+
n_total_channels = iq_data_reordered.shape[0]
142+
n_total_frames = iq_data_reordered.shape[2]
143+
return iq_data_reordered, n_total_channels, n_total_frames
144+
145+
146+
@app.cell
147+
def _(mo, n_total_channels, n_total_frames):
148+
# Interactive Controls
149+
# --------------------
150+
# Create sliders for interactive parameter adjustment
151+
152+
f_number = mo.ui.slider(start=0.5, stop=4.0, value=1.7, step=0.1, label="F-number", show_value=True)
153+
154+
sound_speed = mo.ui.slider(
155+
start=1400, stop=1600, value=1540, step=10, label="Speed of Sound (m/s)", show_value=True
156+
)
157+
158+
tukey_alpha = mo.ui.slider(
159+
start=0.0, stop=1.0, value=0.0, step=0.1, label="Tukey Alpha", show_value=True
160+
)
161+
162+
channel_range = mo.ui.range_slider(
163+
start=0, stop=n_total_channels, value=[0, n_total_channels], step=1, label="Channel Range", show_value=True
164+
)
165+
166+
frame_range = mo.ui.range_slider(
167+
start=0, stop=n_total_frames, value=[0, n_total_frames], step=1, label="Frame Range", show_value=True
168+
)
169+
170+
vmin_db = mo.ui.slider(start=-80, stop=-10, value=-40, step=5, label="Dynamic Range (dB)", show_value=True)
171+
return (
172+
channel_range,
173+
f_number,
174+
frame_range,
175+
sound_speed,
176+
tukey_alpha,
177+
vmin_db,
178+
)
179+
180+
181+
@app.cell
182+
def _(
183+
MM_PER_METER,
184+
beamform,
185+
channel_range,
186+
db_zero,
187+
element_positions,
188+
f_number,
189+
frame_range,
190+
grid_points,
191+
iq_data_reordered,
192+
mo,
193+
np,
194+
params,
195+
plt,
196+
sound_speed,
197+
tukey_alpha,
198+
vmin_db,
199+
wavefront_arrivals_s,
200+
x,
201+
z,
202+
):
203+
# Interactive Visualization
204+
# -------------------------
205+
# This cell reactively updates when any slider changes
206+
207+
# Extract slider values
208+
ch_start, ch_end = channel_range.value
209+
fr_start, fr_end = frame_range.value
210+
211+
# Slice data by channel and frame selection
212+
sliced_iq_data = iq_data_reordered[ch_start:ch_end, :, fr_start:fr_end]
213+
sliced_rx_coords = element_positions[ch_start:ch_end, :]
214+
215+
# Beamform with current parameters
216+
result = beamform(
217+
channel_data=sliced_iq_data,
218+
rx_coords_m=sliced_rx_coords,
219+
scan_coords_m=grid_points,
220+
tx_wave_arrivals_s=wavefront_arrivals_s,
221+
f_number=f_number.value,
222+
rx_start_s=float(params["t0"]),
223+
sampling_freq_hz=float(params["fs"]),
224+
sound_speed_m_s=sound_speed.value,
225+
modulation_freq_hz=float(params["fc"]),
226+
tukey_alpha=tukey_alpha.value,
227+
)
228+
229+
# Compute power Doppler from selected frames
230+
power_doppler = np.square(np.abs(result)).sum(axis=-1)
231+
power_doppler_2d = power_doppler.reshape(len(x), len(z))
232+
power_doppler_db = db_zero(power_doppler_2d)
233+
234+
# Create the plot
235+
fig, ax = plt.subplots(figsize=(8, 8), dpi=150)
236+
237+
extent = [
238+
x.min() * MM_PER_METER,
239+
x.max() * MM_PER_METER,
240+
z.max() * MM_PER_METER,
241+
z.min() * MM_PER_METER,
242+
]
243+
244+
im = ax.imshow(
245+
power_doppler_db.T,
246+
cmap="hot",
247+
vmax=0,
248+
vmin=vmin_db.value,
249+
extent=extent,
250+
aspect="equal",
251+
origin="upper",
252+
)
253+
254+
n_chs = ch_end - ch_start
255+
n_frs = fr_end - fr_start
256+
257+
ax.set_title(
258+
f"Power Doppler: {n_chs} Channels, {n_frs} Frames\n"
259+
f"F-number: {f_number.value:.1f}, SoS: {sound_speed.value} m/s, Tukey α: {tukey_alpha.value:.1f}",
260+
fontsize=12,
261+
)
262+
ax.set_xlabel("Lateral [mm]", fontsize=11)
263+
ax.set_ylabel("Depth [mm]", fontsize=11)
264+
265+
cbar = plt.colorbar(im, ax=ax, shrink=0.8)
266+
cbar.set_label("Magnitude [dB]", fontsize=11)
267+
268+
plt.close(fig)
269+
270+
# Create control panel
271+
controls = mo.vstack([
272+
mo.md("### Parameters"),
273+
sound_speed,
274+
tukey_alpha,
275+
mo.md("### Receive Aperture"),
276+
channel_range,
277+
f_number,
278+
mo.md("### Temporal Selection"),
279+
frame_range,
280+
mo.md("### Display"),
281+
vmin_db,
282+
])
283+
284+
# Display side-by-side layout
285+
layout = mo.hstack([controls, fig], widths=[1, 3])
286+
layout
287+
return
288+
289+
290+
if __name__ == "__main__":
291+
app.run()

0 commit comments

Comments
 (0)