Skip to content

Revisit pixel grid definitions and geometry - #679

Draft
wesselvannierop wants to merge 41 commits into
mainfrom
grid-geometry-definitions
Draft

wesselvannierop wants to merge 41 commits into
mainfrom
grid-geometry-definitions

Conversation

@wesselvannierop

@wesselvannierop wesselvannierop commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

This PR fixes the various names we have been using for angles and coordinate systems, and sticks to one convention. Also adds an interactive beamforming grid viewer, which shows visually how all the variables relate.

Also derives the default pixel grid from the probe and transmit geometry, so polar sectors, their apex and the cartesian width follow what the transmits actually insonify, and settles the angle names.

Fixes #669 and #47 (angle naming); touches #547 (acquisition-dependent defaults).

Changes

  • grid_apex (new, derived) replaces distance_to_apex: the point the polar grid fans out from, i.e. where steered focused axes meet, a diverging wave's virtual source, or a curved array's centre of curvature.
  • Default polar_limits cover the region each transmit insonifies at zlims[1], replacing the fixed 15 % margin. New polar_margin sets a fixed margin instead.
  • Default xlims, grid_size_x, extent follow the sector or insonified width (λ/4 along the outer arc), and scan conversion fills extent.
  • Scanline grids follow the transmit angles (in the x-z plane) from grid_apex; unsupported transmit/grid combinations raise a ValueError.
  • Default f_number is derived from the element's −6 dB directivity (0, no receive mask, for typical phased arrays). This changes pixel values, not the grid.
  • Angle names are the same from the grid through scan conversion, in 2D and 3D: rlims (radius from grid_apex), polar_limits (atan(x/z)), and in 3D one more, elevation_limits (atan(y/z)). θ and φ only name the transmit angles as the file format stores them: spherical polar_angles and azimuth_angles, physics convention.
  • Default elevation_limits (and so the default ylims) come from the transmits' elevation angle atan(tanθ·sinφ), instead of the range of φ. New elevation_margin sets a fixed margin, like polar_margin.
  • Verasonics converter (fix): polar_angles and azimuth_angles are now the spherical transmit angles, converted from TX.Steer. azimuth_angles used to hold the elevation steering angle alpha, so Steer = (0, 10°) was read as θ = 0, φ = 10°, which points straight down; it is now θ = 10°, φ = 90°. Data with alpha = 0 converts as before; files already converted from elevation-steered acquisitions need to be reconverted (details).

Breaking / deprecated

  • rho_range / theta_range / phi_range → rlims / polar_limits / elevation_limits. Deprecated aliases kept. The polar/cartesian helpers now measure angles toward +x, like the rest of zea (theta_range still gives the old images).
  • frustum_convert_* → frustum_to_xyz / xyz_to_frustum / frustum_to_xz / xz_to_frustum, and plot_frustum_vertices' phi_ / theta_ / rho_ planes and styles → elevation_ / polar_ / radius_. Deprecated aliases kept.
  • azimuth_limits → elevation_limits (deprecated alias). It already set ylims as an elevation angle, so explicit values keep their effect; the derived default changes (see above).
  • ScanConvert outputs are renamed without aliases (rho_range / theta_range / phi_range / d_rho / d_theta / d_phi → rlims / polar_limits / elevation_limits / dr / d_polar / d_elevation), so code reading the old keys breaks.
  • Default grids and the default f_number change, so beamformed images change.

Overlaps with #673: this branch copies its is_plane_wave / is_focused / is_diverging helpers.

Before / after grids

Curved, 1 plane wave

main gave polar_limits [0°, 0°], so a zero-width, empty image. An unsteered plane wave on a curved array spreads along the element normals, so the sector is now the array's own angular span.


Phased, scanline, virtual apex

main displayed lines fired at ±45° at ±58.5°, so every target was misplaced. Now each column follows its transmit axis and fans out from where the axes meet, 9.4 mm behind the array. The targets now land within about 0.8 mm, one pixel of the coarse 33-line image.


Phased, plane waves

Steered plane waves cover a trapezoid: as wide as the array at the top, with edges at the steepest steering angle. The sector now runs through the array's ends at ±20°, from a virtual apex 26 mm back, so the shallow region under the array's ends is no longer cut off.


Phased, diverging waves

The old ±39° was the steering range plus a fixed 15 %, which cut off about 18 mm per side at 100 mm depth. The limits now follow the rays from each virtual source through the array's ends. The extra targets only show with the new f_number, so both changes are needed.


Phased, focused lines from a virtual apex

The old 15 % margin added 13.5° per side that no beam reached. The grid now fans out from where the lines come from: a virtual apex 9.4 mm behind the array. On main, the 705 columns sampled the deepest row at only ~λ/2; the column count now follows the arc (λ/4). The grid change alone finds the same targets; the gain comes from the new f_number.


Curved, focused lines along normals

The sector now ends at the outermost lines (±30°). The 8 targets not shown lie at 31–37°, in the outer tail of the outermost beams, 8–17 dB below the interior. Set polar_margin to include that tail.


Linear, plane waves (cartesian)

The old width came from an f-number-1 receive cone, regardless of the transmits. It now follows where the ±10° plane waves reach. The two targets dropped were at −44 dB on main, at noise level.


Curved, docs figure (explicit sector)

The column count now comes from the arc length at the deepest row (λ/4) instead of an unrelated xlims. That removes about 20 % oversampling.

- Updated compute_scan_convert_2d_coordinates and scan_convert_2d functions to replace distance_to_apex with grid_apex and extent parameters.
- Introduced a helper function _deprecated_distance_to_apex to handle legacy distance_to_apex usage and log deprecation warnings.
- Modified Parameters class to derive grid_apex from probe geometry and transmit origins, removing distance_to_apex as a parameter.
- Adjusted related methods and documentation to reflect the new parameter structure and clarify the role of grid_apex in polar grid calculations.
- Updated fit_curved_probe_radius to align with the new grid_apex definition.
- Renamed rho_range to rlims and theta_range to polar_limits for 2D scan conversion, matching the beamforming grid's names. In Parameters, scan conversion now reads the grid's own polar_limits instead of a copy.
- Renamed the 3D frustum angles to lateral_tilt_limits (atan(x/z)) and elevation_tilt_limits (atan(y/z)), since they are not spherical polar and azimuth angles. scan_convert falls back to polar_limits for the lateral tilt.
- Kept rho_range, theta_range and phi_range as deprecated aliases in the display functions, plot_frustum_vertices, the ScanConvert op and Parameters.
- Parameters.coordinates_2d now treats only an explicit rlims as an external polar image.
- Renamed the ScanConvert output keys to the new names.
- Updated configs, notebooks, the pixel grid explorer and figure script, and added an angle and radius naming table to the config docs.
…rray

- transmit_apex_distance now finds an apex only from steered focused transmits. Steered plane and diverging waves do not fan out from their origin, so their axes no longer place grid_apex at the array middle.
- On a flat array without a transmit apex, polar_limits is measured from the virtual apex where the outermost edge ray, traced back from its array end, crosses the central axis, which is where grid_apex places it. The edge rays' start points now also bound the sector, so it covers the insonified region from the array down instead of only at zlims[1].
- Updated the tests: steered plane waves now give a sector along their outer edges, and steered diverging waves define no apex at the array middle.
- Ported the new apex rule for steered plane and diverging waves to the explorer and rebuilt the docs page.
- The explorer now fills the window on wide screens: only the sidebar scrolls, and the figure is sized to the space left.
- Trimmed the presets (no scanline grids, no single diverging wave). The phased docs figure now spreads its transmit origins over the array and uses the default zlims.
- Removed the static pixel grid SVGs and their figure script; the config docs embed the explorer instead.
- Scanline polar_limits follow the transmit order; uneven angles raise
- grid_apex uses the apex the derived polar_limits are seen from
- Deprecated scan conversion inputs override the derived values in ScanConvert
- Overridden rlims/polar_limits drop the derived grid_apex and extent
- distance_to_apex keeps its positional slot; grid_apex/extent are keyword-only
- A distance_to_apex parameter is ignored with a deprecation warning
- Cache the transmit apex, edge rays and derived sector; skip private properties
- Move deprecated_alias to zea.internal.utils
Build docs/_static/pixel-grid-explorer.html from docs/explorer in a
builder-inited hook instead of committing it, so it cannot go stale.
This adds about a second to the docs build.
…tests

Copies the helpers from #673 into zea.beamform.geometry and uses them in the
grid geometry, transmit selection, scanline check and focal-region warning.
@wesselvannierop wesselvannierop added enhancement New feature or request ultrasound Improvements regarding ultrasound reconstruction pipeline labels Nov 2, 2026
A scanline grid's polar_limits are its transmit angles, so grid_apex should not
take the apex the derived sector is seen from. Matches the pixel grid explorer.
@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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

@codecov

codecov Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Patch % Lines
zea/parameters.py 91.41% 13 Missing and 7 partials ⚠️
zea/display.py 89.38% 5 Missing and 7 partials ⚠️
docs/explorer/make_reference.py 96.87% 1 Missing and 1 partial ⚠️
zea/data/convert/verasonics.py 87.50% 2 Missing ⚠️
zea/visualize.py 92.00% 1 Missing and 1 partial ⚠️
zea/beamform/geometry.py 98.64% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@wesselvannierop

Copy link
Copy Markdown
Collaborator Author

Still working on fully fixing #47

@tristan-deep tristan-deep added this to the v0.1.10 milestone Nov 2, 2026
…ions

Grid and scan-conversion angles are rlims, polar_limits (atan(x/z)) and, in 3D,
elevation_limits (atan(y/z)), with polar_margin and elevation_margin. Transmit
angles stay spherical (polar_angles, azimuth_angles; physics convention).

- Replace lateral_tilt_limits and elevation_tilt_limits with polar_limits and
  elevation_limits; ScanConvert outputs d_polar and d_elevation.
- Derive elevation_limits (and ylims) from the transmits' elevation angle;
  azimuth_limits becomes a deprecated alias. Add elevation_margin.
- Rename theta_range to polar_limits in the polar/cartesian display helpers.
- Add an angle conventions section to the docs and a names card to the pixel
  grid explorer; clarify the spec descriptions of polar_angles and
  azimuth_angles.
@wesselvannierop

wesselvannierop commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Fixed in this PR (bb5da16): the Verasonics converter stored elevation steering as azimuth_angles.

This PR documents polar_angles / azimuth_angles as spherical θ / φ (physics convention). The Verasonics converter (zea/data/convert/verasonics.py) used to copy TX.Steer[:, 1] (alpha) straight into azimuth_angles. But alpha is Verasonics' elevation steering angle (tilt toward ±y), not a rotation around z.

The converter now converts (theta, alpha) to spherical (θ, φ) with steer_to_spherical. The direction convention comes from fitting the TX.Delay of a Matrix1024 acquisition steered in both angles.

  • Changed: elevation-steered matrix-array acquisitions. For example, Steer = (0, 10°) (10° toward +y) used to be read as θ = 0, φ = 10°, which points straight down. It is now θ = 10°, φ = 90°.
  • Unchanged: data with alpha = 0, which still converts to (theta, 0). That covers all linear, phased and curved array data, and the Open-H-RF 3D files I checked (azimuth_angles = 0 everywhere, steering in x only, confirmed by fitting the t0_delays). So existing data should be fine.
  • Action needed: files already converted from elevation-steered acquisitions keep the wrong angles, so they need to be reconverted.

Open-H-RF is unaffected.

@wesselvannierop wesselvannierop changed the title Derive the default pixel grid from the transmit geometry Revisit pixel grid definitions and geometry Nov 3, 2026
Comment thread docs/source/_spec_ref.rst Resolved
Comment thread docs/source/_spec_ref.rst Resolved
Comment thread docs/source/config.rst Resolved
Comment thread tests/test_pixel_grid_explorer.py Resolved
Comment thread zea/beamform/pixelgrid.py Resolved
Comment thread zea/data/spec.py Resolved
@wesselvannierop wesselvannierop linked an issue Nov 3, 2026 that may be closed by this pull request
The FIELD_METADATA descriptions of polar_angles and azimuth_angles, shown in the
spec reference table, now match the class docstring word for word.
The deprecation warning already names the replacement.
The test was skipped in CI because quickjs, which is archived, was never
installed. It now runs the explorer's JavaScript in Node.js, skipping when node is
missing unless ZEA_REQUIRE_NODE is set. The tests job sets up Node.js 24 and sets
ZEA_REQUIRE_NODE.
Comment thread zea/data/convert/verasonics.py Resolved
The scanline grid, its polar_limits and xlims used the raw spherical polar
angles, while every other default projects them onto the x-z plane. Directions
stored as theta >= 0 with phi = pi then mirrored the scanline columns. They now
use the projected angles too, and the cartesian scanline grid no longer gets
azimuth_angles, since its angles are already projected.
…e explorer

zea uses the first and last transmit angle, since scan conversion places the
columns in transmit order; the explorer used the min and max. The explorer only
generates ascending angles, so what it shows does not change.
…ctions

- cartesian_to_polar_matrix, polar_to_cartesian_matrix,
  polar_to_cartesian_coordinates and inverse_scan_convert_2d measured angles
  positive toward -x. polar_limits now measures them toward +x with columns from
  polar_limits[0] to polar_limits[1], as in scan_convert_2d, so the inverse takes
  the same polar_limits as the forward conversion. The deprecated theta_range
  alias keeps its old meaning by mirroring the values.
- frustum_convert_rtp2xyz, rt2xz, xz2rt and xyz2rtp are deprecated in favour of
  frustum_to_xyz, frustum_to_xz, xz_to_frustum and xyz_to_frustum, which take
  radius, polar and elevation, so theta and phi only name the spherical
  transmit angles.
- plot_frustum_vertices takes elevation_plane, polar_plane and radius_plane (and
  their styles); the phi, theta and rho names are deprecated aliases.
- Docstrings name the grid angles atan(x / z) and atan(y / z) instead of theta
  and phi.
- The default scan-conversion resolution uses the absolute angle steps, so
  descending polar_limits (a scanline grid steered from +x to -x) no longer
  give a negative resolution and an empty image.
- prepare_parameters also drops the precomputed coordinates when the limits or
  the resolution are overridden; ScanConvert used them, so the override had no
  effect on the image.
- ScanConvert maps the deprecated distance_to_apex input onto grid_apex instead
  of ignoring it.
- virtual_apex_distance replaces the two copies of the virtual-apex formula in
  grid_apex and _derived_polar_sector, and ignores sectors within
  STEERING_TOLERANCE of 0, which put the apex kilometres behind the array.
- deprecated_distance_to_apex moves to zea.internal.utils, so polar_pixel_grid
  and ScanConvert share it with the display functions.
- The Verasonics converter reads and converts the steering angles once.
- cartesian_to_polar_matrix, polar_to_cartesian_matrix,
  polar_to_cartesian_coordinates and inverse_scan_convert_2d take theta_range
  in the position it used to have, and polar_limits as a keyword only. Code
  that passed the angles by position goes through the mirroring shim with a
  deprecation warning, instead of getting a mirrored image without one.
- get_properties_with_dependencies includes private properties, so a misspelled
  dependency of e.g. _edge_rays is caught when the class is instantiated.
  get_properties still leaves them out, so to_tensor is unchanged.
The derived rlims are measured from grid_apex, so dropping the apex when only
polar_limits or elevation_limits were overridden shifted the depth axis by the
apex distance. Move the override logic into _stale_derived_keys.
Explicit rlims on Parameters describe a polar image of their own, which
Parameters.coordinates_2d already accounts for. When ScanConvert recomputed the
coordinates (e.g. for a resolution override) it still got the grid's apex and
output window, which cropped or shifted the image.
elevation_limits reads azimuth_angles but did not declare it as a dependency,
so setting azimuth_angles after elevation_limits was read left it stale.
The 3D masking and indexing assume ascending polar and elevation limits, so
descending ones masked out the whole volume. Assert ascending limits, like
rlims, instead of claiming either order works.
f_number repeated the 90%-of-pitch rule from _resolve_element_width with a
different pitch estimator. _resolve_element_width now measures the pitch along
the array with element_pitch, which also works for curved arrays, and f_number
uses it.
…an apex

transmit_apex_distance only checked the steered focused axes, so an unsteered
focused transmit off the central axis was accepted, and a polar scanline grid
drew its line on the central axis instead of its own.
ylims read grid_apex unconditionally, so every 2D grid fitted the transmit
apex and derived the polar sector (warning about missing focus distances) for
an elevation term that is zero.
The crop also removes pixels in front of the array at the sector edges, not only
those a virtual apex places behind it. That is intended: zlims[0] is where the
image starts. A test now covers a set zlims[0] for both apex types.
…ions

# Conflicts:
#	zea/beamform/pixelgrid.py
_resolve_element_width measures the pitch with element_pitch since e979240, but
test_pfield_infers_element_width still built its expected width from
Probe.get_pitch. On float32 geometry the two differ by about 4e-7 relative, which
is enough to change the pfield on TensorFlow.
…ions

# Conflicts:
#	docs/source/changelog.rst
#	docs/source/notebooks/data/zea_data_example.ipynb
#	zea/beamform/beamformer.py
#	zea/beamform/pixelgrid.py
#	zea/parameters.py

This branch has not been deployed

No deployments
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.

distance_to_apex is 0 for phased-array sector scans since #542 Clean up naming of angles

2 participants