@@ -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