Skip to content

Add zea.inverse: recover channel data from beamformed images - #511

Draft
sankethvedula wants to merge 20 commits into
tue-bmd:mainfrom
sankethvedula:feature/inverse-beamforming
Draft

Add zea.inverse: recover channel data from beamformed images#511
sankethvedula wants to merge 20 commits into
tue-bmd:mainfrom
sankethvedula:feature/inverse-beamforming

Conversation

@sankethvedula

@sankethvedula sankethvedula commented Jul 16, 2026

Copy link
Copy Markdown

What changes were made?

This PR adds zea.inverse, a new subpackage that inverts the DAS beamformer: given a post-beamformed image, it recovers the pre-beamformed channel data by expressing beamforming as a differentiable linear operator and solving a least-squares problem, optionally regularized with a point-scatterer prior.

  • zea/inverse/operators.pyDASOperator: the DAS beamformer as a linear channel-data → image map, built as a regular zea.Pipeline (PatchedGrid([TOFCorrection(fnum_window_fn), DelayAndSum()])), so it shares zea's delay model (incl. lens correction) with everything else; grid-pixel patching (a num_patches knob) bounds memory, and the adjoint comes for free via autodiff. ScattererSimulator: a time-domain point-scatterer forward model driven by the scan's stored two-way waveforms, consistent with the beamformer's delay model.
  • zea/inverse/solvers.py — matrix-free cgls and linear_adjoint (adjoint of a linear operator as the gradient of ⟨A(x), y⟩ via zea.backend.autograd.AutoGrad).
  • zea/inverse/seeding.pyseed_scatterers: samples scatterer positions from the image envelope.
  • zea/inverse/inversion.py — high-level drivers: invert_direct (minimum-norm pseudo-inverse) and invert_scatterers (scatterer prior with optional joint Adam refinement).
  • Supporting changes: TOFCorrection accepts an fnum_window_fn (default unchanged); the frequency-domain simulator can run with measured waveforms via get_measured_pulse_spectrum_fn + a pulse_spectrum_fn override on simulate_rf/Simulate; a legacy-loader fix (float tx_waveform_indices, jaxus-style lens fields, files with only two-way waveforms); zea.inverse registered as a lazy submodule and in the docs.

Three tutorials in a new Inversion docs section (docs/source/notebooks/inverse/), ordered from controlled to clinical:

  1. fish_example.ipynb — a realistic synthetic acquisition (Verasonics L11-5v preset, 4× sampling, five plane waves, a two-way pulse shaped by the probe's bandwidth, diffuse tissue speckle, 30 dB receiver noise). Ground truth is known exactly: the pseudo-inverse fits the image at 0.999 while recovering only 0.56 of the channel data; the scatterer prior recovers 0.66.
  2. cirs_example.ipynb — a recorded scan of a simulated CIRS phantom, reproducing the das-inverse study: pseudo-inverse 0.556/1.000 (reference 0.557/1.000), scatterer prior 0.842/0.993 (reference 0.838/0.996).
  3. carotid_example.ipynb — in-vivo carotid (149 single-element transmits): scatterer prior 0.060/0.931 (reference 0.057/0.946) — the honest ceiling: a compounded image constrains speckle statistics, not the channel micro-realization.

The recorded-scan tutorials run 40 CGLS iterations instead of the study's 70: on these ill-posed problems the iteration count acts as the regularization parameter, and 40 captures nearly all of the attainable correlation at a fraction of the runtime (CIRS: 17 s instead of ~8 min). Each tutorial renders a recorded-vs-recovered channel-data GIF, and the carotid one points to zeahub/zea-carotid-2023 for zea-native carotid data (the Zenodo scan is kept for direct comparability with the study).

Why were these changes made?

Inverse beamforming enables working backwards from images to channel data — useful for studying what information beamforming discards, for generating physically consistent channel data from image-domain priors, and as a building block for optimization-based reconstruction. The scatterer-prior inversion follows the off-grid scatterer model of van de Schaft et al., arXiv:2407.02285. Everything is written with keras.ops and runs on all Keras backends.

How were the changes validated?

  • 20+ new unit tests (tests/test_inverse.py, tests/test_simulator.py, legacy-loader regression tests): adjoint identity, operator linearity, CGLS vs lstsq/pinv, simulator echo arrival times, simulator–beamformer consistency, measured-waveform spectra vs FFT, seeding properties, repeated-jit regression, and end-to-end inversions — green on jax, tensorflow and torch.
  • DASOperator verified against the independent jaxus-based DAS implementation of the original study: measured-image correlation 1.000000.
  • The two simulators cross-validate: driven by the same waveform they produce matching B-mode statistics.
  • All three tutorials executed at full scale on H100/L40S GPUs; CI runs them with reduced parameters (the carotid inversion is skipped on CI).
  • Full CI is green: lint, type check, tests (all backends), notebook tests incl. a new inverse group, docs build with -W, doctests, and linkcheck.

How to test the changes

KERAS_BACKEND=jax pytest tests/test_inverse.py tests/test_simulator.py --skip-unavailable-backends

or start with the introductory tutorial: docs/source/notebooks/inverse/fish_example.ipynb.

Summary by CodeRabbit

  • New Features
    • Added inverse beamforming (direct and scatterer-based) to recover channel data from DAS images, including seeded scatterer simulation and optional scatterer refinement.
    • Exposed through zea.inverse, with API autosummaries updated accordingly.
  • Bug Fixes
    • Improved legacy scan loading for waveform index parsing and safer waveform reshaping when legacy fields are absent; lens-related legacy fields are ignored.
  • Documentation
    • Updated inverse documentation and notebook/CI settings; added a Sphinx link-check exclusion and a small doc wording fix.
  • Tests
    • Added legacy regression coverage, extensive end-to-end inversion/inverse-simulation tests, and notebook CI parameterization.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 784ee71e-7c48-4f4f-bf33-c9d5aa79ba47

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The PR adds differentiable inverse beamforming with DAS and scatterer operators, matrix-free solvers, seeding, inversion, refinement, exports, documentation, notebook settings, and tests. It also improves legacy waveform-index parsing and updates a display docstring.

Changes

Inverse beamforming

Layer / File(s) Summary
Public API and linear solvers
zea/__init__.py, zea/inverse/__init__.py, zea/inverse/solvers.py, docs/source/_autosummary/zea.rst
Adds inverse exports, lazy loading, API documentation, autodiff adjoints, and CGLS.
Differentiable forward operators
zea/inverse/operators.py
Adds DAS beamforming and chunked point-scatterer simulation with geometry, directivity, and waveform interpolation.
Scatterer seeding and inversion drivers
zea/inverse/seeding.py, zea/inverse/inversion.py
Adds image-based seeding, direct and scatterer inversion, result packaging, and optional refinement.
Inverse validation and notebook execution
tests/test_inverse.py, tests/test_notebooks.py, docs/source/conf.py
Adds inverse numerical and notebook coverage and configures link checking for the inverse-beamforming reference.

Legacy file loading

Layer / File(s) Summary
Legacy waveform compatibility
zea/data/legacy_file.py, tests/data/test_file.py
Casts waveform indices, handles missing indices, removes lens fields, and adds regression tests.

Documentation cleanup

Layer / File(s) Summary
Display API documentation
zea/display.py
Corrects the to_8bit return-type label.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • tue-bmd/zea#392: Overlaps with the legacy scan parsing and waveform-index handling.
  • tue-bmd/zea#505: Also modifies Sphinx link-check configuration.
  • tue-bmd/zea#510: Directly overlaps with the inverse implementation, exports, tests, and documentation.

Suggested reviewers: tristan-deep

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main addition of the new zea.inverse subpackage for recovering channel data from beamformed images.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tristan-deep tristan-deep added enhancement New feature or request ultrasound Improvements regarding ultrasound reconstruction pipeline labels Jul 16, 2026
@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.53005% with 31 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
zea/inverse/inversion.py 82.05% 8 Missing and 6 partials ⚠️
zea/inverse/operators.py 89.78% 8 Missing and 6 partials ⚠️
zea/inverse/solvers.py 90.90% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
zea/data/legacy_file.py (1)

157-168: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle missing tx_waveform_indices to prevent iteration crashes.

If a legacy file omits tx_waveform_indices (e.g., because waveforms_one_way is already correctly stored as an array of shape (n_tx, n_samples)), tx_waveform_indices evaluates to None. This will cause a TypeError: 'NoneType' object is not iterable when the list comprehension attempts to iterate over it.

Guard the stack operations to ensure they only execute when tx_waveform_indices is present.

🐛 Proposed fix
-    if "waveforms_one_way" in scan_parameters:
+    if "waveforms_one_way" in scan_parameters and tx_waveform_indices is not None:
         waveforms_one_way_list = scan_parameters["waveforms_one_way"]
         scan_parameters["waveforms_one_way"] = np.stack(
             [waveforms_one_way_list[i] for i in tx_waveform_indices]
         )

-    if "waveforms_two_way" in scan_parameters:
+    if "waveforms_two_way" in scan_parameters and tx_waveform_indices is not None:
         waveforms_two_way_list = scan_parameters["waveforms_two_way"]
         scan_parameters["waveforms_two_way"] = np.stack(
             [waveforms_two_way_list[i] for i in tx_waveform_indices]
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@zea/data/legacy_file.py` around lines 157 - 168, Guard the waveforms_one_way
and waveforms_two_way stack operations so they run only when tx_waveform_indices
is present. Preserve the existing behavior when indices are available, and leave
already-correct waveform arrays unchanged when tx_waveform_indices is None.
🧹 Nitpick comments (1)
docs/source/notebooks/pipeline/inverse_beamforming_real_scans_example.ipynb (1)

509-520: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use FunctionTimer for these benchmark timings.

time.time() can miss JAX async execution here, so the reported durations are lower than the actual runtime. zea.utils.FunctionTimer already blocks on completion and uses time.perf_counter() internally.

  • docs/source/notebooks/pipeline/inverse_beamforming_real_scans_example.ipynb#L509-L520
  • docs/source/notebooks/pipeline/inverse_beamforming_real_scans_example.ipynb#L678-L688
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/source/notebooks/pipeline/inverse_beamforming_real_scans_example.ipynb`
around lines 509 - 520, The benchmark timing blocks at
docs/source/notebooks/pipeline/inverse_beamforming_real_scans_example.ipynb
lines 509-520 and 678-688 should use zea.utils.FunctionTimer instead of
time.time(), wrapping the relevant inversion calls so JAX execution is
synchronized and durations use perf_counter(). Update both sites consistently,
preserving the existing result assignments, output labels, and reporting
behavior.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@zea/inverse/inversion.py`:
- Around line 152-160: Update the inversion flow around DASOperator and
seed_scatterers so custom flatgrid configurations cannot seed against the
unrelated parameters.grid. Validate that the operator’s flatgrid and
parameters.grid have matching coordinates, or use an explicitly supplied
matching seeding grid/initial positions; reject mismatches before calling
seed_scatterers while preserving correct behavior for the default grid.

In `@zea/inverse/operators.py`:
- Line 288: Validate the normalized chunk size and magnitudes before the first
_pad_and_chunk call: reject chunk_size <= 0 and empty magnitudes with the
module’s standard validation error rather than allowing a ZeroDivisionError.
Update the initialization logic around self.chunk_size and the related handling
at the referenced later path, preserving valid positive chunk sizes and
non-empty magnitudes.

---

Outside diff comments:
In `@zea/data/legacy_file.py`:
- Around line 157-168: Guard the waveforms_one_way and waveforms_two_way stack
operations so they run only when tx_waveform_indices is present. Preserve the
existing behavior when indices are available, and leave already-correct waveform
arrays unchanged when tx_waveform_indices is None.

---

Nitpick comments:
In `@docs/source/notebooks/pipeline/inverse_beamforming_real_scans_example.ipynb`:
- Around line 509-520: The benchmark timing blocks at
docs/source/notebooks/pipeline/inverse_beamforming_real_scans_example.ipynb
lines 509-520 and 678-688 should use zea.utils.FunctionTimer instead of
time.time(), wrapping the relevant inversion calls so JAX execution is
synchronized and durations use perf_counter(). Update both sites consistently,
preserving the existing result assignments, output labels, and reporting
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: dac933a4-fc41-4f8c-8f01-ebaf7d8d75ee

📥 Commits

Reviewing files that changed from the base of the PR and between 4209c4b and 7db1c08.

⛔ Files ignored due to path filters (2)
  • docs/source/notebooks/pipeline/inverse_beamforming_carotid_prebf.gif is excluded by !**/*.gif
  • docs/source/notebooks/pipeline/inverse_beamforming_cirs_prebf.gif is excluded by !**/*.gif
📒 Files selected for processing (13)
  • docs/source/_autosummary/zea.rst
  • docs/source/notebooks/pipeline/inverse_beamforming_example.ipynb
  • docs/source/notebooks/pipeline/inverse_beamforming_real_scans_example.ipynb
  • tests/data/test_file.py
  • tests/test_inverse.py
  • tests/test_notebooks.py
  • zea/__init__.py
  • zea/data/legacy_file.py
  • zea/inverse/__init__.py
  • zea/inverse/inversion.py
  • zea/inverse/operators.py
  • zea/inverse/seeding.py
  • zea/inverse/solvers.py

Comment thread zea/inverse/inversion.py Outdated
Comment thread zea/inverse/operators.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
zea/inverse/__init__.py (1)

56-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Define an explicit __all__ for the package API.

Without it, wildcard imports expose the implementation submodules as public names and make the export surface depend on internal imports. Restore an explicit list of supported exports, or confirm that this expanded wildcard API is intentional.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@zea/inverse/__init__.py` around lines 56 - 60, Define an explicit __all__ in
the zea.inverse package initializer listing only the supported public symbols,
including the intended inversion functions and classes, operators, seeding
utilities, solvers, and any intentionally public submodules. Exclude
implementation-only imports so wildcard imports remain stable and independent of
internal module imports.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@zea/display.py`:
- Line 24: Update the return-value documentation for the affected display
function to state that it returns either an ndarray or a PIL.Image.Image,
depending on the pillow parameter; preserve the existing output range
description.

---

Nitpick comments:
In `@zea/inverse/__init__.py`:
- Around line 56-60: Define an explicit __all__ in the zea.inverse package
initializer listing only the supported public symbols, including the intended
inversion functions and classes, operators, seeding utilities, solvers, and any
intentionally public submodules. Exclude implementation-only imports so wildcard
imports remain stable and independent of internal module imports.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ec640d25-f901-4541-ba3c-b7d642d7492f

📥 Commits

Reviewing files that changed from the base of the PR and between 7db1c08 and a165f14.

📒 Files selected for processing (7)
  • tests/data/test_file.py
  • tests/test_inverse.py
  • zea/data/legacy_file.py
  • zea/display.py
  • zea/inverse/__init__.py
  • zea/inverse/inversion.py
  • zea/inverse/operators.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • zea/inverse/inversion.py
  • zea/data/legacy_file.py
  • tests/test_inverse.py
  • zea/inverse/operators.py

Comment thread zea/display.py

Returns:
image (ndarray): Output 8 bit image(s) [0, 255].
ndarray: Output 8 bit image(s) [0, 255].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document both possible return types.

Line [24] says the function always returns an ndarray, but the default pillow=True path returns Image.fromarray(...). Document ndarray or PIL.Image.Image, depending on pillow.

Proposed fix
-        ndarray: Output 8 bit image(s) [0, 255].
+        ndarray or PIL.Image.Image: Output 8 bit image(s) [0, 255],
+            depending on the value of ``pillow``.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ndarray: Output 8 bit image(s) [0, 255].
ndarray or PIL.Image.Image: Output 8 bit image(s) [0, 255],
depending on the value of ``pillow``.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@zea/display.py` at line 24, Update the return-value documentation for the
affected display function to state that it returns either an ndarray or a
PIL.Image.Image, depending on the pillow parameter; preserve the existing output
range description.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/data/test_file.py (1)

2460-2479: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Construct the compatibility fixture without the current writer.

This test claims to verify reading an existing non-snake-case file, but FileSpec.save() creates that malformed file. This couples reader compatibility to—and implicitly permits—the writer bypassing the naming restriction. Save a valid fixture, then rename the HDF5 key directly before loading it, as done in test_custom_element_non_snake_case_still_readable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/data/test_file.py` around lines 2460 - 2479, The
test_custom_data_metadata_non_snake_case_still_readable fixture currently uses
FileSpec.save(), so it does not represent a legacy malformed file. Replace the
writer-generated “My Overlay” setup with a valid fixture, then rename the
relevant HDF5 key directly before opening it with File, following the approach
in test_custom_element_non_snake_case_still_readable. Keep the existing read
assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/data/test_file.py`:
- Around line 2460-2479: The
test_custom_data_metadata_non_snake_case_still_readable fixture currently uses
FileSpec.save(), so it does not represent a legacy malformed file. Replace the
writer-generated “My Overlay” setup with a valid fixture, then rename the
relevant HDF5 key directly before opening it with File, following the approach
in test_custom_element_non_snake_case_still_readable. Keep the existing read
assertions unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0d52a803-e630-49f3-9346-12c755dae993

📥 Commits

Reviewing files that changed from the base of the PR and between a165f14 and d464e6d.

📒 Files selected for processing (1)
  • tests/data/test_file.py

@tristan-deep tristan-deep added this to the v0.1.4 milestone Jul 20, 2026
@OisinNolan

OisinNolan commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Hey @sankethvedula nice work! I had a read through the code and had some initial questions / comments, curious what your thoughts are!

  1. I notice that for the DASOperator you built a manual delay and sum pipeline using zea functions like calculate_delays internally rather than using a zea.Pipeline. Was the main motivation for this so that you could use keras.remat inside to avoid OOM errors? I see that the beamforming in DASOperator.forward is defined recursively and sequentially over transmits -- I think this could be parallel given that the outputs of each _beamform_tx are summed together? This would then also apply to the gradients, since the gradient operation is linear. Maybe then you could chunk the calls to _beamform_tx to avoid OOMs rather than needing to use remat?
  2. I like how you framed the scatterer simulator as a regularizer min ||operator(simulate(positions, magnitudes)) - image||^2, cool idea!
  3. Very nice that you added two example notebooks on this -- they might even deserve their own Inversion section in the docs for visibility rather than being under Pipeline. One question I had about this notebook was about the artifacts we see in the fish simulation b-mode -- I noticed that the fish simulation in our other simulation notebook has a similar probe / tx setup but noticeably fewer artifacts. Do you know why that is? Do you think that we could use the frequency-domain zea simulator to regularize the solution in the same way as the simulator you added? I notice in the docstring you wrote:

For a frequency-domain simulator with parametric pulses see
:func:zea.simulator.simulate_rf; this class instead uses the measured
waveforms stored with the scan, which matters when inverting real
acquisitions.

I'm thinking maybe the simplest approach would just be to enable the frequency-domain simulator to use a waveform stored in a scan either? Maybe we'd just need to extent get_pulse_spectrum_fn

  1. In the real-data notebook I noticed there's quite a lot of code dedicated to parsing the file you download -- in case it's useful, we also have carotid files stored here in zea format, which the notebook can load directly from the URL.

sankethvedula and others added 6 commits July 21, 2026 09:46
Some legacy (pre-v0.1.0) jaxus-style files store scan/tx_waveform_indices
as float64; legacy_scan used them directly to index the per-waveform
dataset lists, raising an IndexError. Cast them to int before indexing,
and remove a stray unassigned np.stack(...) line that would raise a
NameError for files that only contain two-way waveforms.

The same files may also carry lens parameters (lens_correction,
lens_thickness, lens_sound_speed) in the scan group; the current format
keeps lens parameters with the probe, so drop them instead of failing
ScanSpec validation with an unexpected-keyword error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New subpackage that inverts the DAS beamformer: given a post-beamformed
image, recover the pre-beamformed channel data by expressing beamforming
as a differentiable linear operator and solving a least-squares problem.

- operators.py: DASOperator wraps the zea.beamform primitives
  (calculate_delays, apply_delays, fnumber_mask) into a linear
  channel-data -> image map with an autodiff adjoint;
  ScattererSimulator is a time-domain point-scatterer forward model
  using the scan's stored two-way waveforms and the same delay model as
  the beamformer. Both iterate over transmits and scatterer chunks with
  ops.scan and keras.remat, so memory under automatic differentiation
  stays bounded for scans with hundreds of transmits.
- solvers.py: matrix-free CGLS and linear_adjoint (adjoint of a linear
  operator as the gradient of <A(x), y> via zea.backend.AutoGrad).
- seeding.py: seed_scatterers samples scatterer positions from the
  image envelope.
- inversion.py: invert_direct (minimum-norm pseudo-inverse; fits the
  image but not the physical channel data) and invert_scatterers
  (scatterer prior regularizing the DAS nullspace, with optional joint
  Adam refinement via zea.backend.optimizer.adam).

Everything is written with keras.ops and runs on all Keras backends.
The scatterer-prior inversion follows the off-grid scatterer model of
van de Schaft et al. (arXiv:2407.02285). The beamforming operator was
verified against an independent jaxus-based DAS implementation on a
simulated CIRS phantom (relative error 4e-4, correlation 1.000000).

Includes tests (adjoint identity, CGLS vs lstsq/pinv, echo arrival
times, seeding, end-to-end inversions) and an introductory example
notebook under docs/source/notebooks/pipeline/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applies zea.inverse to two real acquisitions and reproduces the results
of the standalone das-inverse study it was ported from: a simulated CIRS
point-scatterer phantom (direct pseudo-inverse vs scatterer prior) and
an in-vivo carotid scan (149 single-element transmits, 30k scatterers).
Executed at full scale on an NVIDIA L40S; recovered/reference pre-BF
correlations: CIRS direct 0.560/0.557, CIRS scatterers 0.855/0.838,
carotid 0.059/0.057. Registered in tests/test_notebooks.py with reduced
iteration counts and the carotid section disabled for CI (411 MB
download).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tests (the CI tests job runs with all backends installed):
- Cast operator inputs (grid, positions, waveforms, magnitudes, images)
  to float32: numpy-built parameters arrive as float64, which jax silently
  demotes but tensorflow/torch propagate into dtype errors in the delay
  computation.
- Return the per-transmit channel as both carry and stacked output of the
  simulator's transmit scan: keras's tensorflow scan requires the stacked
  outputs to match the carry's shape and dtype.
- Pass a static length to every ops.scan and compile AD-derived adjoint/
  gradient functions without XLA on tensorflow (via a small helper): XLA
  cannot size the gradient accumulators of the scan's while loop.
None of this changes jax numerics, so the executed example notebooks
remain valid.

Docs (sphinx -W treats warnings as errors):
- Document InversionResult fields with an Args section like other zea
  dataclasses; the Attributes section duplicated autodoc's field entries.
- Drop __all__ from zea/inverse/__init__.py and import the submodules
  instead, matching zea.beamform/zea.data: with
  autosummary_ignore_module_all = False, __all__ suppressed the submodule
  toctree (orphan pages) and documented every re-export twice (ambiguous
  cross-references).
- Fix the Returns section of zea.display.to_8bit to napoleon's
  'type: description' form; the named form made 'image' a type
  cross-reference that InversionResult.image turned ambiguous.

Verified: 19/19 tests green under jax, tensorflow and torch main-process
backends with the cross-backend workers active; docs build green with -W.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- legacy_scan: keep waveforms as-is when tx_waveform_indices is absent
  (files that already store one waveform per transmit) instead of
  failing on iterating None; regression test with array-stored
  waveforms.
- invert_scatterers: reject operators built on a custom flatgrid with a
  clear ValueError — seeding samples positions from parameters.grid, so
  a mismatched grid would silently seed at unrelated coordinates (or
  fail on reshape). Custom grids should seed manually and use
  ScattererSimulator with cgls directly.
- ScattererSimulator: validate chunk_size > 0 at construction and
  non-empty magnitudes in __call__ instead of surfacing a
  ZeroDivisionError from the chunking arithmetic.

19/19 tests green under jax, tensorflow and torch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The inverse beamforming notebooks link the original study's repository
with a repo-root URL, which is not covered by the existing permalink
ignores. GitHub rate-limits anonymous requests from CI runners (the link
checks fine from other hosts), so ignore it like the other non-permalink
GitHub links. This was the remaining test-docs-build failure: docs-build
itself passes since the -W fixes, and a clean docs-build plus full
linkcheck both pass locally at this head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sankethvedula
sankethvedula force-pushed the feature/inverse-beamforming branch from dbc01e6 to f918b97 Compare July 21, 2026 07:50
@tristan-deep tristan-deep modified the milestones: v0.1.4, v0.1.5 Jul 21, 2026
tof_correction accepts fnum_window_fn, but the operation always used
the rect default. Expose it as a constructor argument (default
unchanged) so pipelines can select e.g. a tukey window.
Replace the hand-rolled delays/gather/mask implementation and its
rematerialized transmit scan with the regular zea pipeline:
Pipeline([PatchedGrid([TOFCorrection(fnum_window_fn), DelayAndSum()])]).
The delay model now runs through the exact code path the rest of zea
uses, and memory is bounded by processing the imaging grid in pixel
patches (a num_patches knob on the operator) rather than by
checkpointing transmits. jit_options=None keeps the operator a pure
function, so the inversion drivers still jit and differentiate
matvec/rmatvec as a whole.

Full-scale smoke on a 149-transmit in-vivo scan matches the previous
implementation exactly: measured-image correlation 1.000000 against the
das-inverse reference and identical CGLS correlations (0.568 CIRS,
0.038 carotid), within the same memory budget on a 48 GB L40S.
@tristan-deep
tristan-deep marked this pull request as draft July 21, 2026 08:34
sankethvedula and others added 8 commits July 21, 2026 10:42
Add get_measured_pulse_spectrum_fn, which evaluates the DTFT of a
sampled waveform at arbitrary frequencies, and let simulate_rf (and the
Simulate op) accept a pulse_spectrum_fn override. This drives the
frequency-domain simulator with the waveforms stored in a scan
(parameters.waveforms_two_way) instead of the parametric four-period
pulse, following the same time-zero convention as
zea.inverse.ScattererSimulator (the beamformer adds t_peak).

Cross-validating the two simulators on the same 104-scatterer phantom
with the same two-cycle pulse yields matching B-mode artifact levels
(background within a few dB), confirming they implement consistent
physics once waveform-matched.
A controlled comparison (time- vs frequency-domain simulator, rect vs
Hanning transmit apodization, +-8.6 vs +-5 degree steering) shows the
granular background and lateral streaks in the fish B-mode are side
lobes of compounding only three plane waves with a short two-cycle
pulse: with a four-period pulse the background drops ~5 dB, while
apodization and steering change it by <1 dB. Note this in the notebook
next to the figure; the short pulse is kept because recovering
realistic broadband channel data is the point of the example.
Replace the idealized 64-element/3.125 MHz toy setup with a realistic
plane-wave acquisition: the Verasonics L11-5v probe preset (128
elements, 0.3 mm pitch, 6.25 MHz) at 4x sampling, five steered plane
waves, a two-way waveform shaped by convolving a 1.5-cycle excitation
twice with a Gaussian impulse response matching the probe bandwidth,
diffuse tissue speckle underneath the fish phantom, and receiver noise
at ~30 dB channel SNR. The measured image now reads like a routine
clinical B-mode.

Recovering channel data behind speckle and noise is honestly harder
than in a sterile point-target scene (pseudo-inverse 0.56 vs scatterer
prior 0.66 pre-beamforming correlation); the takeaways relate this to
the point-phantom vs in-vivo gap in the real-scans notebook. The
notebook gains a parameters cell, registered with reduced values in
tests/test_notebooks.py for CI (verified via pytest: 2m45s on CPU).
The adjoint was constructed lazily on first use and cached. When the
first call happened inside a jitted function (invert_direct compiles
the adjoint as rmatvec), the cached closure captured traced values, and
any later jitted inversion on the same operator raised jax's
UnexpectedTracerError. Build it once in __init__ instead — the closure
then always holds concrete arrays — and add a regression test that
runs invert_direct twice on a fresh operator.
The docs environment pins KERAS_BACKEND=numpy (docs/Makefile), and
'make docs-test' in CI executes the docstring examples there. The
linear_adjoint example computes a real gradient through AutoGrad, which
supports only jax/tensorflow/torch, so the final line can never pass in
the docs environment — mark it '# doctest: +SKIP' like the repo's other
backend-dependent examples. The behavior itself stays covered by
tests/test_inverse.py::test_linear_adjoint_matches_matrix_transpose.

This was the actual cause of the test-docs-build CI failures (the
doctest stage runs between docs-build and linkcheck).
Three ordered tutorials under docs/source/notebooks/inversion/, as
suggested in review: the synthetic fish walkthrough, the recorded CIRS
phantom scan, and the in-vivo carotid scan — the last two split out of
the former real-scans notebook, each self-contained and executed at
full scale (CIRS: direct 0.560/1.000 and scatterer prior 0.857/0.996
vs references 0.557/1.000 and 0.838/0.996; carotid: 0.059/0.942 vs
0.057/0.946). The fish tutorial gains a 'breaking the inverse crime'
control that regenerates the data with the frequency-domain simulator
driven by the measured waveform (scatterer prior 0.515 vs 0.659 on
matched-model data); a refresh of its executed outputs with the added
pseudo-inverse baseline follows.

Wires the section into examples.rst and the notebook redirects, adds an
'inversion' group to the notebook-tests matrix, updates the zea.inverse
docstring links, and registers CI parameters (CIRS reduced, carotid and
the frequency-domain control skipped on CI).

Note: the previous push unintentionally carried the half-staged
notebook move (stale index), which orphaned the moved notebook and
broke test collection against a registered-but-deleted notebook — this
commit restores a consistent tree.
Rename the tutorials folder to notebooks/inverse (matching the
zea.inverse subpackage) with short filenames — fish_example,
cirs_example, carotid_example, and matching *_prebf.gif names — and
update badges, cross-links, docs toctrees, redirects, CI matrix group
and notebook registrations accordingly.

Drop the frequency-domain control section from the fish tutorial: the
capability stays in zea.simulator (unit-tested on all backends) and the
cross-simulator evidence is recorded in the PR discussion, but the
chunked per-element simulation ran for hours — too heavy for a
tutorial.

Run the recorded-scan inversions with 40 CGLS iterations instead of the
original study's 70: on these ill-posed problems the iteration count
acts as the regularization parameter, and 40 captures nearly all of the
attainable correlation at a fraction of the runtime (CIRS scatterer
prior 0.842 vs reference 0.838 in 17 s instead of ~8 min; carotid 0.060
vs reference 0.057 in 22 min instead of 43). All three notebooks
re-executed at full scale; the carotid tutorial now also points to the
zea-native zeahub/zea-carotid-2023 dataset (the Zenodo scan is kept for
direct comparability with the das-inverse study).
OisinNolan and others added 4 commits July 23, 2026 10:48
The per-chunk echoes tensor was laid out (chunk, n_ax, n_el) and
contracted over its leading (strided) axis, which XLA lowers to a
transposing reduction — on a memory-bandwidth-bound kernel that roughly
doubles the traffic, and its VJP is worse still. Switch to the
element-major layout (n_el, n_ax, chunk) and contract the contiguous
scatterer axis instead.

Benchmark (H100, carotid scan, 149 transmits, 30k scatterers, chunk
4096, jitted, steady-state): forward 11.76 s -> 7.45 s, AD adjoint
19.47 s -> 7.67 s; a CGLS iteration drops from 31.2 s to 15.1 s. Same
numerics up to float reassociation; all tests pass.
@tristan-deep tristan-deep modified the milestones: v0.1.5, v0.1.6 Aug 11, 2026
@tristan-deep

Copy link
Copy Markdown
Collaborator

Hi @sankethvedula, thanks for this contribution, looks really good!

Could you try to merge the interesting parts into a single example notebook? The multiple notebooks are appreciated as reference material (maybe hosted in a different repository, with zea version pinned at some point), but we try to keep the notebook examples in the zea docs curated. When looking at the difference in your notebooks, it is mostly various datasets, which you can easily show in one notebook I think. I see some broken link formatting in some of the notebooks as well. Lastly, try to follow the format and notebook layout / structure of some of the other notebooks. Besides showing this new inverse feature, the notebooks also serve the role of good zea practices. We'd like to reuse as many tools within the library to keep the notebooks minimal and highlighting the specific thing we want to show (in this case the inverse modeling!) Once you have a single notebook we can help with that as well to clean that up! :)

Good technical comments by @OisinNolan as well. We can host a sample on zeahub (see other notebook examples). Maybe there is already an existing dataset on zeahub you'd like and otherwise we can upload one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request ultrasound Improvements regarding ultrasound reconstruction pipeline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants