sidescanpy.export.geometric_window#

Geometric (along-track x across-track meters) windowed export.

Resamples a bounded region directly at an explicit (resolution_m, size_px), centered on a given real-world point — a survey-local frame (no CRS/UTM projection, unlike export.georef_window), using the per-sample across_track_m that processing/geometric.py::compute_geometric_ranges already attaches, and the per-ping cumulative along-track distance from processing.navigation.along_track_distance_m.

2026 rshomberg@uri.edu

Contents

extract_geometric_window

Resample a size_px x size_px square region, centered on (center_along_m, center_across_m), at exactly resolution_m meters/pixel on both axes.

extract_geometric_window_binned

Same contract as extract_geometric_window() (same params, same (grid, meta) shape, same meta keys, drop-in interchangeable with locate_point()) but resamples via scipy.stats.binned_statistic_2d instead of a hand-written cKDTree.query_ball_point loop over every output cell.

extract_geometric_window_griddata

Same contract as extract_geometric_window()/ extract_geometric_window_binned() (same params, same (grid, meta) shape, same meta keys, drop-in interchangeable with locate_point()) but resamples via scipy.interpolate.griddata -- full scattered-data interpolation across the whole requested grid, not a radius search (KDTree) or strict rectangle membership (binned).

interpolate_scattered

Interpolate scattered samples at query points with scipy.interpolate.griddata.

locate_point

Translate a real-world (along_m, across_m) point into pixel (row, col) coordinates within a grid produced by extract_geometric_window(), using that call's own returned meta — for marking a different point than the one the window was centered on (e.g. a second target sharing the same frame).

sidescanpy.export.geometric_window.extract_geometric_window(channel_ds, size_px, *, resolution_m, center_along_m, center_across_m, channel_idx=0, backscatter_var='backscatter_intensity', search_radius_m=None, along_search_radius_m=None, across_search_radius_m=None, avoid_edge_gaps=False, along_resolution_m=None, across_resolution_m=None, along_size_px=None, across_size_px=None)#

Resample a size_px x size_px square region, centered on (center_along_m, center_across_m), at exactly resolution_m meters/pixel on both axes.

Both axes always share the same resolution_m and size_px by construction — no derived-from-extent resolution, no separate resize step, so the result is never stretched relative to the real world (a previous version computed resolution_m from the larger of the window’s own along/across extents but still forced both axes to size_px samples — the smaller-extent axis came out visibly stretched; fixed by making resolution and size the only inputs, extent purely a consequence of them). This is still the default — along_resolution_m/across_resolution_m/along_size_px/ across_size_px below are opt-in, and every existing caller that doesn’t pass them gets byte-identical square-pixel output.

channel_idx=None pools samples from both channels into the same point cloud instead of one side only — unlike the raw-pixel export.windows primitives, this needs no explicit mirroring: across_track_m is already a real-world coordinate (port negative, starboard positive), so both sides combine correctly by construction, giving a single image spanning the full swath width with the nadir gap naturally in the middle (wherever across_track_m is NaN/near zero on both sides).

Parameters:
channel_dsxr.Dataset

Already ping-sliced to a neighborhood comfortably containing the requested size_px * resolution_m extent around the center — this function does not itself bound how much source data it searches; a much wider slice than needed just costs more KDTree build time for no benefit.

size_pxint

Output side length in pixels.

resolution_mfloat

Meters per pixel, both axes. Pick small (near the channel’s own sample interval) for a maximum-detail close-up; pick real_swath_width_m / size_px to fit an entire swath width (both channels) into the frame.

center_along_m, center_across_mfloat

Real-world center of the requested window, in the same along-track-distance / across_track_m units the survey’s own per-sample geometry uses (e.g. a target’s own position, read directly from across_track_m at its ping/sample/channel).

channel_idxint or None, default 0

Restrict to one physical channel (0=port, 1=starboard), or None to pool both.

search_radius_mfloat, optional

Isotropic KDTree query radius (same in both directions); defaults to 1.5 * resolution_m. A survey whose along-track position updates at a much lower rate than its ping rate (real case: a Klein SDF survey whose GPS fixes only every ~8 pings, jumping ~2m each time) has real gaps between along-track fixes wider than a resolution chosen from the average distance/ping can bridge at the default radius — the result grid shows periodic empty stripes at exactly the fix-to-fix interval, since most cells’ nearest real point sits outside 1.5 * resolution_m. Passing a wider radius (e.g. the real fix-to-fix jump distance) bridges those gaps without also needing a coarser (more zoomed-out) resolution_m. Ignored if either of the two params below is given.

along_search_radius_m, across_search_radius_mfloat, optional

Independent along-track / across-track search radii — an ellipse instead of a circle, in real (along_m, across_m) space. Each axis is rescaled by its own radius before the cKDTree is built/queried (then queried with a unit ball), which is exactly equivalent to an elliptical search with no custom distance metric needed. Default (None) for either falls back to search_radius_m (or its own default) — fully backward compatible with every existing caller.

Real motivation (2026-09-28): a swath’s along-track (ping spacing) and across-track (sample interval) native resolutions can differ by an order of magnitude or more. The single isotropic search_radius_m this function used to have had to be wide enough to bridge real along-track gaps — which meant every cell’s average also pooled far more across-track real detail than the data’s own resolution supports, regardless of how fine resolution_m was set. A caller requesting a finer resolution_m for more real detail got more pixels of an equally over-smoothed image, not more detail — confirmed directly on real data as the cause of a “chips are blurry” report. Passing a narrow across_search_radius_m (sized to the real sample interval) alongside a wide along_search_radius_m (still sized to bridge real ping gaps) fixes this: real across-track texture is preserved instead of averaged away, without reintroducing along-track gaps.

avoid_edge_gapsbool, default False

When True and the requested center is close enough to the edge of this channel’s own recorded extent that the window would otherwise include genuine no-data cells (e.g. a target near the max-range edge of the swath), shift the center inward just enough to keep the full window inside real data instead – same tradeoff extract_chip already makes for raw-pixel windows near an array edge. The target itself is then no longer necessarily at the window’s exact center; locate_point against the original requested point still reports its true (now off-center) position correctly. Default False keeps the window exactly centered on the requested point regardless (this function’s original, unconditional behavior) – opt in per caller, since not every use of this function is a target-crop where “no data” is worse than “off-center”.

along_resolution_m, across_resolution_mfloat, optional

Independent along-track / across-track meters-per-pixel — real, non-square output pixels. Default (None) for either falls back to resolution_m — fully backward compatible, every existing caller gets exactly the prior square-pixel behavior.

Real motivation (2026-09-29): even with anisotropic search radii (along_search_radius_m/across_search_radius_m above), a single shared resolution_m still forces the SAME pixel density on both axes. On a survey whose along-track native resolution is much coarser than across-track (ping spacing an order of magnitude or more wider than sample interval — one real survey measured at ~16:1), the along-track search radius floor (tied to real ping-to-ping gaps, independent of resolution_m — see along_search_radius_m above) ends up wide relative to a fine shared pixel spacing, visible in the output as elliptical averaging footprints — confirmed directly, real user report (“you can clearly see the ellipses which appear wide… that is not an issue w/ the radiometric”). No amount of tuning a single shared resolution_m/search radius eliminates this, because a plain pixel-space resize (which is what “radiometric” looks like) never averages a real neighborhood at all, so it has no footprint to show — it isn’t solving the same problem. Setting along_resolution_m coarser than across_resolution_m (close to the real ping spacing) lets each axis’s pixel size match its OWN real resolving power, eliminating the visible-footprint artifact without averaging away real across-track detail that IS there to resolve.

along_size_px, across_size_pxint, optional

Independent output row / column counts. Default (None) for either falls back to size_px. Combined with along_resolution_m/across_resolution_m, this is what actually makes the output array non-square in shape, not just in real-world meters-per-pixel — e.g. along_size_px=20 at along_resolution_m=0.4 covers the same real 8m as across_size_px=320 at across_resolution_m=0.025, and the returned array is genuinely (20, 320), not square. A caller displaying this gets correct proportions for free from most plotting libraries’ default aspect handling (e.g. matplotlib imshow’s aspect="equal", driven by array shape alone, with no extent needed) — it is not a stretched square image, it is a genuinely shorter/narrower real array.

Returns:
(grid, meta)

grid : (along_size_px, across_size_px) array (both default to size_px, reproducing the prior square shape unless overridden). NaN where no sample fell within the search radius of a cell. meta : dict with center_row/center_col (the requested center’s own pixel position in grid — always the exact middle, since the grid is built centered on it by construction), the resolved along_min/across_min bounds, along_resolution_m/across_resolution_m (the resolved per-axis values, always present even when isotropic), and resolution_m — a legacy key kept for backward compatibility, equal to along_resolution_m; new code should read the per-axis keys instead, since this key is ambiguous once the two differ.

Raises:
ValueError

If no finite across_track_m/backscatter samples exist in channel_ds at all (e.g. entirely water column).

sidescanpy.export.geometric_window.extract_geometric_window_binned(channel_ds, size_px, *, resolution_m, center_along_m, center_across_m, channel_idx=0, backscatter_var='backscatter_intensity', avoid_edge_gaps=False, along_resolution_m=None, across_resolution_m=None, along_size_px=None, across_size_px=None)#

Same contract as extract_geometric_window() (same params, same (grid, meta) shape, same meta keys, drop-in interchangeable with locate_point()) but resamples via scipy.stats.binned_statistic_2d instead of a hand-written cKDTree.query_ball_point loop over every output cell.

2026-09-29, real user request, following this file’s own established-libraries-first convention (see the “Rules” section of AGENTS.md): once per-axis pixel resolution existed (along_resolution_m/across_resolution_m above), each output cell is really just “the mean of every real sample whose (along, across) position falls inside this cell’s rectangle” — an ordinary 2D histogram-style binning, not a radius search. scipy already ships exactly that as a single vectorized call, so a custom per-cell loop wasn’t buying anything.

Not a superset of the KDTree version — a genuinely different resampling philosophy, not just a faster implementation of the same one:

  • No search_radius_m/along_search_radius_m/ across_search_radius_m — a point either falls inside a cell’s exact rectangle or it doesn’t, there is no overlap/gap-bridging radius to tune. A cell with zero real samples inside its own rectangle is NaN, even if a real sample sits just across the boundary in the neighboring cell. This means a coarse along_resolution_m chosen to bridge real ping-to-ping gaps (the KDTree version’s along_search_radius_m role) still works here — a wide-enough bin naturally contains the next ping’s point — but an along_resolution_m finer than the real ping-to-ping spacing will show real empty cells between pings instead of the KDTree version’s radius-bridged fill.

  • Strictly faster on real data (one vectorized scipy call instead of one Python-level query_ball_point call per output pixel), but not chosen for speed — chosen because “one real neighborhood per pixel, no radius tuning” is a more direct match for what along_resolution_m/across_resolution_m were already designed to express once they exist per-axis. See export/geometric_window.py’s module tests (TestExtractGeometricWindowBinned) for a real-data comparison against the KDTree version at matching resolution.

Raises:
ValueError

If no finite across_track_m/backscatter samples exist in channel_ds at all (e.g. entirely water column).

sidescanpy.export.geometric_window.extract_geometric_window_griddata(channel_ds, size_px, *, resolution_m, center_along_m, center_across_m, channel_idx=0, backscatter_var='backscatter_intensity', avoid_edge_gaps=False, along_resolution_m=None, across_resolution_m=None, along_size_px=None, across_size_px=None, interp_method='linear', max_fill_distance_m=None)#

Same contract as extract_geometric_window()/ extract_geometric_window_binned() (same params, same (grid, meta) shape, same meta keys, drop-in interchangeable with locate_point()) but resamples via scipy.interpolate.griddata – full scattered-data interpolation across the whole requested grid, not a radius search (KDTree) or strict rectangle membership (binned).

2026-09-29, real user request/direction: an established-library resampling option with no NaN gaps by construction – unlike extract_geometric_window_binned(), every output cell gets a value regardless of how sparse the real along-track sampling is relative to resolution_m, because griddata interpolates (and, for the small edge fraction outside the real data’s convex hull, nearest-neighbor-extrapolates – see below) rather than reporting “no real sample here.”

This is a genuinely different tradeoff than the other two functions, not a strict improvement — say so plainly: at a resolution much finer than the real along-track sampling, the large majority of a griddata window’s pixels are constructed by interpolation, not measured by the sensor. Real-data check (two real targets, resolution_m=0.03m, ~60-66% of cells with no real sample at that resolution): griddata never shows a gap, but most of what fills those gaps is triangulated from real points several real ping-widths away, not a resolved local measurement. Compare extract_geometric_window() (zero fabrication, real radius averaging) and extract_geometric_window_binned() (zero fabrication, real gaps shown honestly).

interp_method : "linear" (default), "cubic", or "nearest" – passed straight through to scipy.interpolate. griddata’s own method. "linear"/"cubic" leave NaN outside the real data’s convex hull (no point to triangulate against there); those cells are automatically filled with a second, method="nearest" pass so this function never returns a gap regardless of interp_method – "nearest" itself is already gap-free after the first pass. max_fill_distance_m : cells farther than this from every real sample are NaN – outside the recorded swath (past max range, the nadir gap, the excluded channel of a single-channel window), where either pass above would otherwise extend the nearest edge sample into smeared wedges/streaks of invented seafloor (seen on chips wider than the swath). None (default) derives it from the data: 1.5x the larger of the 95th-percentile along-track ping gap, the median across-track sample step, and the output resolution – so gaps between real pings/samples still interpolate. np.inf restores the old never-NaN behavior.

Raises:
ValueError

If no finite across_track_m/backscatter samples exist in channel_ds at all (e.g. entirely water column).

sidescanpy.export.geometric_window.interpolate_scattered(points, values, query_points, *, interp_method='linear', max_fill_distance_m=inf)#

Interpolate scattered samples at query points with scipy.interpolate.griddata.

The resampler behind the "griddata" windows, geometric and georeferenced. Query points outside the samples’ convex hull get the nearest sample’s value; query points farther than max_fill_distance_m from every sample are NaN, so the edge of the recorded swath (and the nadir gap) is not extended into invented seabed. Coordinates are shifted to the query points’ centre first, so projected (UTM) coordinates triangulate as accurately as local ones.

Parameters:
pointsnp.ndarray

(n, 2) sample positions in meters (any planar frame).

valuesnp.ndarray

(n,) finite sample values.

query_pointsnp.ndarray

(m, 2) positions to interpolate at, same frame.

interp_method{“linear”, “cubic”, “nearest”}, default “linear”

griddata’s method.

max_fill_distance_mfloat, default inf

See above. Only samples within twice this distance of the query points’ bounding box are triangulated, which keeps a small window fast when points covers a large neighborhood.

Returns:
np.ndarray

(m,) float64.

sidescanpy.export.geometric_window.locate_point(along_m, across_m, meta)#

Translate a real-world (along_m, across_m) point into pixel (row, col) coordinates within a grid produced by extract_geometric_window(), using that call’s own returned meta — for marking a different point than the one the window was centered on (e.g. a second target sharing the same frame).

Reads meta’s per-axis along_resolution_m/ across_resolution_m (not the legacy resolution_m key) — correct for both the isotropic/square case (where they’re equal anyway) and a real anisotropic-pixel window.