Skip to content

Commit 69db31e

Browse files
committed
use affine for axis updates (significant performance upgrade for echoviewer)
1 parent e749fb4 commit 69db31e

File tree

1 file changed

+122
-140
lines changed

1 file changed

+122
-140
lines changed

python/themachinethatgoesping/pingprocessing/watercolumn/echograms/coordinate_system.py

Lines changed: 122 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def __init__(
5454
raise RuntimeError("ERROR[EchogramCoordinateSystem]: n_pings must be > 0")
5555

5656
self._n_pings = n_pings
57-
self.max_number_of_samples = np.asarray(max_number_of_samples)
57+
self.max_number_of_samples = np.asarray(max_number_of_samples, dtype=np.float32)
5858
self.time_zone = time_zone
5959
self.mp_cores = 1
6060

@@ -74,6 +74,12 @@ def __init__(
7474
self.has_ranges = False
7575
self.has_depths = False
7676
self.has_sample_nrs = False
77+
78+
# Precomputed affine coefficients: value = a + b * sample_index
79+
# These are computed once when extents are set
80+
self._affine_sample_to_depth = None # (a, b) arrays
81+
self._affine_sample_to_range = None
82+
self._affine_sample_to_sample_nr = None
7783

7884
# Initialize axis state
7985
self.x_axis_name = None
@@ -83,9 +89,10 @@ def __init__(
8389
self._x_axis_function = None
8490
self._y_axis_function = None
8591
self._initialized = False
86-
87-
# Interpolator lists (per-ping)
88-
self._init_interpolators()
92+
93+
# Current y-axis affine: y_coord = a + b * sample_index (set by set_y_axis_*)
94+
self._affine_sample_to_y = None # (a, b) arrays for current y-axis
95+
self._affine_y_to_sample = None # (a, b) arrays for inverse
8996

9097
@property
9198
def n_pings(self) -> int:
@@ -97,17 +104,35 @@ def initialized(self) -> bool:
97104
"""Whether coordinate system is fully initialized."""
98105
return self._initialized
99106

100-
def _init_interpolators(self):
101-
"""Initialize interpolator lists."""
102-
n = self._n_pings
103-
self.y_coordinate_indice_interpolator = [None] * n
104-
self.y_indice_to_y_coordinate_interpolator = [None] * n
105-
self.depth_to_y_coordinate_interpolator = [None] * n
106-
self.range_to_y_coordinate_interpolator = [None] * n
107-
self.sample_nr_to_y_coordinate_interpolator = [None] * n
108-
self.y_indice_to_depth_interpolator = [None] * n
109-
self.y_indice_to_range_interpolator = [None] * n
110-
self.y_indice_to_sample_nr_interpolator = [None] * n
107+
def _compute_affine_coefficients(self, min_vals: np.ndarray, max_vals: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
108+
"""Compute affine coefficients for: value = a + b * sample_index.
109+
110+
Args:
111+
min_vals: Per-ping minimum values (at sample_index=0).
112+
max_vals: Per-ping maximum values (at sample_index=n_samples-1).
113+
114+
Returns:
115+
Tuple of (a, b) arrays where value = a + b * sample_index.
116+
NaN where invalid (zero samples or min >= max).
117+
"""
118+
n_samples = self.max_number_of_samples # Already float32
119+
120+
# Avoid division by zero
121+
denom = n_samples.copy()
122+
denom[denom == 0] = 1.0
123+
124+
# value = min_val + (max_val - min_val) * sample_index / n_samples
125+
# value = a + b * sample_index
126+
# where a = min_val, b = (max_val - min_val) / n_samples
127+
a = min_vals.astype(np.float32)
128+
b = ((max_vals - min_vals) / denom).astype(np.float32)
129+
130+
# Mark invalid pings
131+
invalid = ~(np.isfinite(min_vals) & np.isfinite(max_vals) & (max_vals > min_vals) & (n_samples > 0))
132+
a[invalid] = np.nan
133+
b[invalid] = np.nan
134+
135+
return a, b
111136

112137
# =========================================================================
113138
# Ping numbers and times
@@ -145,6 +170,8 @@ def set_range_extent(self, min_ranges: np.ndarray, max_ranges: np.ndarray):
145170
self.res_ranges = ((self.max_ranges - self.min_ranges) / self.max_number_of_samples).astype(np.float32)
146171
self.has_ranges = True
147172
self._initialized = False
173+
# Precompute affine: range = a + b * sample_index
174+
self._affine_sample_to_range = self._compute_affine_coefficients(self.min_ranges, self.max_ranges)
148175

149176
def set_depth_extent(self, min_depths: np.ndarray, max_depths: np.ndarray):
150177
"""Set depth extents (per-ping min/max depth in meters)."""
@@ -155,6 +182,8 @@ def set_depth_extent(self, min_depths: np.ndarray, max_depths: np.ndarray):
155182
self.res_depths = ((self.max_depths - self.min_depths) / self.max_number_of_samples).astype(np.float32)
156183
self.has_depths = True
157184
self._initialized = False
185+
# Precompute affine: depth = a + b * sample_index
186+
self._affine_sample_to_depth = self._compute_affine_coefficients(self.min_depths, self.max_depths)
158187

159188
def set_sample_nr_extent(self, min_sample_nrs: np.ndarray, max_sample_nrs: np.ndarray):
160189
"""Set sample number extents (per-ping min/max sample numbers)."""
@@ -165,6 +194,8 @@ def set_sample_nr_extent(self, min_sample_nrs: np.ndarray, max_sample_nrs: np.nd
165194
self.res_sample_nrs = ((self.max_sample_nrs - self.min_sample_nrs) / self.max_number_of_samples).astype(np.float32)
166195
self.has_sample_nrs = True
167196
self._initialized = False
197+
# Precompute affine: sample_nr = a + b * sample_index
198+
self._affine_sample_to_sample_nr = self._compute_affine_coefficients(self.min_sample_nrs, self.max_sample_nrs)
168199

169200
# =========================================================================
170201
# Ping parameters
@@ -229,6 +260,8 @@ def add_ping_param(self, name: str, x_reference: str, y_reference: str,
229260
def get_ping_param(self, name: str, use_x_coordinates: bool = False) -> Tuple[np.ndarray, np.ndarray]:
230261
"""Get a ping parameter's values in current coordinate system.
231262
263+
Uses vectorized affine transforms for speed.
264+
232265
Args:
233266
name: Parameter name.
234267
use_x_coordinates: If True, use current x coordinates instead of all pings.
@@ -248,46 +281,46 @@ def get_ping_param(self, name: str, use_x_coordinates: bool = False) -> Tuple[np
248281
x_indices = np.arange(self._n_pings)
249282
x_coordinates = self.feature_mapper.get_feature_values(self.x_axis_name)
250283

251-
reference, param = self.param[name]
252-
param = np.array(param)[x_indices]
253-
254-
return_param = np.empty(len(param))
255-
return_param.fill(np.nan)
256-
284+
reference, param_all = self.param[name]
285+
param = np.array(param_all)[x_indices]
286+
287+
# Convert param values to sample indices first, then to y coordinates
288+
# param_value → sample_index: sample = (param - a_param) / b_param (inverse of a + b*sample)
289+
# sample_index → y_coord: y = a_y + b_y * sample
290+
291+
# Get affine for param_type → sample_index (inverse of sample → param_type)
257292
match reference:
258293
case "Y indice":
259-
for nr, (indice, p) in enumerate(zip(x_indices, param)):
260-
if np.isfinite(p):
261-
I = self.y_indice_to_y_coordinate_interpolator[indice]
262-
if I is not None:
263-
return_param[nr] = I(p)
264-
294+
# Already sample indices
295+
sample_indices = param
265296
case "Sample number":
266297
assert self.has_sample_nrs, "ERROR: Sample nr values not initialized"
267-
for nr, (indice, p) in enumerate(zip(x_indices, param)):
268-
if np.isfinite(p):
269-
I = self.sample_nr_to_y_coordinate_interpolator[indice]
270-
if I is not None:
271-
return_param[nr] = I(p)
272-
298+
a, b = self._affine_sample_to_sample_nr
299+
# sample_nr = a + b * sample_idx, so sample_idx = (sample_nr - a) / b
300+
a_sel, b_sel = a[x_indices], b[x_indices]
301+
sample_indices = np.where(b_sel != 0, (param - a_sel) / b_sel, np.nan)
273302
case "Depth (m)":
274303
assert self.has_depths, "ERROR: Depths values not initialized"
275-
for nr, (indice, p) in enumerate(zip(x_indices, param)):
276-
if np.isfinite(p):
277-
I = self.depth_to_y_coordinate_interpolator[indice]
278-
if I is not None:
279-
return_param[nr] = I(p)
280-
304+
a, b = self._affine_sample_to_depth
305+
a_sel, b_sel = a[x_indices], b[x_indices]
306+
sample_indices = np.where(b_sel != 0, (param - a_sel) / b_sel, np.nan)
281307
case "Range (m)":
282308
assert self.has_ranges, "ERROR: Ranges values not initialized"
283-
for nr, (indice, p) in enumerate(zip(x_indices, param)):
284-
if np.isfinite(p):
285-
I = self.range_to_y_coordinate_interpolator[indice]
286-
if I is not None:
287-
return_param[nr] = I(p)
288-
309+
a, b = self._affine_sample_to_range
310+
a_sel, b_sel = a[x_indices], b[x_indices]
311+
sample_indices = np.where(b_sel != 0, (param - a_sel) / b_sel, np.nan)
289312
case _:
290313
raise RuntimeError(f"Invalid reference '{reference}'")
314+
315+
# Now convert sample_indices to y coordinates
316+
if self._affine_sample_to_y is None:
317+
return_param = np.full(len(param), np.nan)
318+
else:
319+
a_y, b_y = self._affine_sample_to_y
320+
a_sel, b_sel = a_y[x_indices], b_y[x_indices]
321+
return_param = a_sel + b_sel * sample_indices
322+
# Mask invalid
323+
return_param[~np.isfinite(param)] = np.nan
291324

292325
if self.x_axis_name == "Date time":
293326
x_coordinates = [dt.datetime.fromtimestamp(t, self.time_zone) for t in x_coordinates]
@@ -324,6 +357,9 @@ def _set_y_coordinates(self, name: str, y_coordinates: np.ndarray,
324357
layer_update_callback: Optional[Callable] = None):
325358
"""Set Y coordinates for the display grid.
326359
360+
This is now a fast operation - it only stores the view bounds and
361+
computes affine coefficients vectorized over all pings.
362+
327363
Args:
328364
name: Axis name ('Y indice', 'Sample number', 'Depth (m)', 'Range (m)').
329365
y_coordinates: Array of Y coordinate values.
@@ -336,73 +372,37 @@ def _set_y_coordinates(self, name: str, y_coordinates: np.ndarray,
336372
f"min/max y vectors must have length {n_pings}"
337373

338374
self.y_axis_name = name
339-
self.y_coordinates = y_coordinates
340-
self.y_resolution = y_coordinates[1] - y_coordinates[0]
375+
self.y_coordinates = np.asarray(y_coordinates, dtype=np.float32)
376+
self.y_resolution = float(y_coordinates[1] - y_coordinates[0])
341377
self.y_extent = [
342-
self.y_coordinates[-1] + self.y_resolution / 2,
343-
self.y_coordinates[0] - self.y_resolution / 2,
378+
float(self.y_coordinates[-1]) + self.y_resolution / 2,
379+
float(self.y_coordinates[0]) - self.y_resolution / 2,
344380
]
345-
self.vec_min_y = vec_min_y
346-
self.vec_max_y = vec_max_y
381+
self.vec_min_y = np.asarray(vec_min_y, dtype=np.float32)
382+
self.vec_max_y = np.asarray(vec_max_y, dtype=np.float32)
347383

348384
self.y_gridder = ForwardGridder1D.from_res(
349-
self.y_resolution, self.y_coordinates[0], self.y_coordinates[-1]
385+
self.y_resolution, float(self.y_coordinates[0]), float(self.y_coordinates[-1])
350386
)
351387

388+
# Compute affine coefficients vectorized (no per-ping loop!)
389+
# y_coord = a + b * sample_index where sample_index goes 0 to n_samples-1
390+
# This maps sample 0 → vec_min_y and sample (n_samples-1) → vec_max_y
391+
self._affine_sample_to_y = self._compute_affine_coefficients(self.vec_min_y, self.vec_max_y)
392+
393+
# Also compute inverse: sample_index = (y_coord - a) / b
394+
# Store as (a, b) for: sample = a_inv + b_inv * y
395+
# sample = -a/b + (1/b) * y = a_inv + b_inv * y
396+
a, b = self._affine_sample_to_y
397+
with np.errstate(divide='ignore', invalid='ignore'):
398+
b_inv = np.where(b != 0, 1.0 / b, np.nan).astype(np.float32)
399+
a_inv = np.where(b != 0, -a / b, np.nan).astype(np.float32)
400+
self._affine_y_to_sample = (a_inv, b_inv)
401+
352402
# Call layer update callback if provided
353403
if layer_update_callback is not None:
354404
layer_update_callback()
355405

356-
# Initialize interpolators
357-
self._init_interpolators()
358-
359-
for nr in range(n_pings):
360-
y1, y2 = vec_min_y[nr], vec_max_y[nr]
361-
n_samples = self.max_number_of_samples[nr] + 1
362-
363-
try:
364-
# Skip pings with invalid y values or no samples
365-
if not (np.isfinite(y1) and np.isfinite(y2)):
366-
continue
367-
if y1 >= y2:
368-
continue
369-
if n_samples <= 1:
370-
continue
371-
372-
I = tools.vectorinterpolators.LinearInterpolatorF([y1, y2], [0, n_samples - 1])
373-
self.y_coordinate_indice_interpolator[nr] = I
374-
375-
I = tools.vectorinterpolators.LinearInterpolatorF([0, n_samples - 1], [y1, y2])
376-
self.y_indice_to_y_coordinate_interpolator[nr] = I
377-
378-
if self.has_depths:
379-
d1, d2 = self.min_depths[nr], self.max_depths[nr]
380-
if np.isfinite(d1) and np.isfinite(d2) and d1 < d2:
381-
I = tools.vectorinterpolators.LinearInterpolatorF([d1, d2], [y1, y2])
382-
self.depth_to_y_coordinate_interpolator[nr] = I
383-
I = tools.vectorinterpolators.LinearInterpolatorF([0, n_samples - 1], [d1, d2])
384-
self.y_indice_to_depth_interpolator[nr] = I
385-
386-
if self.has_ranges:
387-
r1, r2 = self.min_ranges[nr], self.max_ranges[nr]
388-
if np.isfinite(r1) and np.isfinite(r2) and r1 < r2:
389-
I = tools.vectorinterpolators.LinearInterpolatorF([r1, r2], [y1, y2])
390-
self.range_to_y_coordinate_interpolator[nr] = I
391-
I = tools.vectorinterpolators.LinearInterpolatorF([0, n_samples - 1], [r1, r2])
392-
self.y_indice_to_range_interpolator[nr] = I
393-
394-
if self.has_sample_nrs:
395-
s1, s2 = self.min_sample_nrs[nr], self.max_sample_nrs[nr]
396-
if np.isfinite(s1) and np.isfinite(s2) and s1 < s2:
397-
I = tools.vectorinterpolators.LinearInterpolatorF([s1, s2], [y1, y2])
398-
self.sample_nr_to_y_coordinate_interpolator[nr] = I
399-
I = tools.vectorinterpolators.LinearInterpolatorF([0, n_samples - 1], [s1, s2])
400-
self.y_indice_to_sample_nr_interpolator[nr] = I
401-
402-
except Exception as e:
403-
message = f"{e}\n- nr {nr}\n- y1 {y1}\n- y2 {y2}\n- n_samples {n_samples}"
404-
raise RuntimeError(message)
405-
406406
def _set_x_coordinates(self, name: str, x_coordinates: np.ndarray, x_interpolation_limit: float):
407407
"""Set X coordinates for the display grid.
408408
@@ -757,21 +757,29 @@ def set_x_axis_date_time(
757757
def get_y_indices(self, wci_nr: int) -> Tuple[np.ndarray, np.ndarray]:
758758
"""Get Y indices mapping image coordinates to data indices.
759759
760+
Uses precomputed affine coefficients for speed.
761+
760762
Args:
761763
wci_nr: Ping/column number.
762764
763765
Returns:
764766
Tuple of (image_indices, data_indices) arrays.
765767
"""
766-
interpolator = self.y_coordinate_indice_interpolator[wci_nr]
767-
if interpolator is None:
768+
if self._affine_y_to_sample is None:
769+
return np.array([], dtype=int), np.array([], dtype=int)
770+
771+
a_inv, b_inv = self._affine_y_to_sample
772+
a, b = a_inv[wci_nr], b_inv[wci_nr]
773+
774+
if not np.isfinite(a) or not np.isfinite(b):
768775
return np.array([], dtype=int), np.array([], dtype=int)
769776

770777
n_samples = int(self.max_number_of_samples[wci_nr]) + 1
771778
y_indices_image = np.arange(len(self.y_coordinates))
772-
y_indices_wci = np.round(interpolator(self.y_coordinates)).astype(int)
779+
# sample_index = a + b * y_coord
780+
y_indices_wci = np.round(a + b * self.y_coordinates).astype(int)
773781

774-
valid_coordinates = np.where(np.logical_and(y_indices_wci >= 0, y_indices_wci < n_samples))[0]
782+
valid_coordinates = np.where((y_indices_wci >= 0) & (y_indices_wci < n_samples))[0]
775783

776784
return y_indices_image[valid_coordinates], y_indices_wci[valid_coordinates]
777785

@@ -831,45 +839,19 @@ def copy_xy_axis_to(self, other: "EchogramCoordinateSystem"):
831839
# =========================================================================
832840

833841
def _estimate_affine_y_to_sample(self) -> Tuple[np.ndarray, np.ndarray]:
834-
"""Estimate affine parameters for y→sample index mapping per ping.
842+
"""Get affine parameters for y→sample index mapping per ping.
835843
836-
The y_coordinate_indice_interpolator is a LinearInterpolator, so
837-
it's exactly: sample_idx = a + b * y
844+
Now just returns the precomputed affine coefficients.
845+
sample_idx = a + b * y
838846
839847
Returns:
840848
Tuple of (a, b) arrays, each shape (n_pings,).
841-
Values are NaN where interpolator is not defined.
849+
Values are NaN where mapping is not defined.
842850
"""
843-
n_pings = self._n_pings
844-
a = np.full(n_pings, np.nan, dtype=np.float32)
845-
b = np.full(n_pings, np.nan, dtype=np.float32)
846-
847-
y = np.asarray(self.y_coordinates, dtype=np.float32)
848-
if y.size < 2:
849-
return a, b
850-
851-
# Pick two well-separated points for numerical stability
852-
y0 = float(y[0])
853-
y1 = float(y[-1])
854-
if y1 == y0:
855-
y1 = y0 + 1.0
856-
857-
for p in range(n_pings):
858-
interpolator = self.y_coordinate_indice_interpolator[p]
859-
if interpolator is None:
860-
continue
861-
862-
# Evaluate at two points to extract slope and intercept
863-
i0 = float(interpolator(y0))
864-
i1 = float(interpolator(y1))
865-
866-
bp = (i1 - i0) / (y1 - y0)
867-
ap = i0 - bp * y0
868-
869-
a[p] = ap
870-
b[p] = bp
871-
872-
return a, b
851+
if self._affine_y_to_sample is None:
852+
n_pings = self._n_pings
853+
return np.full(n_pings, np.nan, dtype=np.float32), np.full(n_pings, np.nan, dtype=np.float32)
854+
return self._affine_y_to_sample
873855

874856
def make_image_request(self) -> EchogramImageRequest:
875857
"""Create a backend-ready request for building the current image.

0 commit comments

Comments
 (0)