@@ -87,6 +87,11 @@ def __init__(
8787
8888 # Value offset (applied to all data values)
8989 self ._offset = 0.0
90+
91+ # Oversampling settings
92+ self ._x_oversampling = 1
93+ self ._y_oversampling = 1
94+ self ._oversampling_mode = "linear_mean" # "linear_mean" or "db_mean"
9095
9196 def _init_ping_params_from_backend (self ):
9297 """Initialize ping parameters from backend's pre-computed values."""
@@ -674,6 +679,103 @@ def get_column(self, nr):
674679 """Get column data for a ping from backend."""
675680 return self ._backend .get_column (nr )
676681
682+ # =========================================================================
683+ # Oversampling configuration
684+ # =========================================================================
685+
686+ def set_oversampling (self , x_oversampling = 1 , y_oversampling = 1 , mode = "linear_mean" ):
687+ """Configure oversampling for image building.
688+
689+ When oversampling > 1, build_image() will request a higher-resolution
690+ image from the backend (max_steps * oversampling) and then block-average
691+ it down to the original output resolution. This reduces aliasing artifacts
692+ from nearest-neighbor sampling.
693+
694+ Args:
695+ x_oversampling: Integer oversampling factor for X axis (pings). Default 1.
696+ y_oversampling: Integer oversampling factor for Y axis (samples). Default 1.
697+ mode: Averaging mode for block downsampling.
698+ - 'linear_mean': Convert dB to linear (power(10, 0.1*v)), average,
699+ convert back (10*log10). Correct for dB-domain data.
700+ - 'db_mean': Average directly in dB domain (geometric mean in
701+ linear domain). Faster but less physically correct.
702+ """
703+ if x_oversampling < 1 or y_oversampling < 1 :
704+ raise ValueError ("Oversampling factors must be >= 1" )
705+ if mode not in ("linear_mean" , "db_mean" ):
706+ raise ValueError (f"Invalid oversampling mode '{ mode } '. Use 'linear_mean' or 'db_mean'." )
707+
708+ self ._x_oversampling = int (x_oversampling )
709+ self ._y_oversampling = int (y_oversampling )
710+ self ._oversampling_mode = mode
711+ # Force reinit so coordinate system recomputes with new oversampling
712+ self ._coord_system ._initialized = False
713+
714+ @property
715+ def _has_oversampling (self ):
716+ """Whether any oversampling is active."""
717+ return self ._x_oversampling > 1 or self ._y_oversampling > 1
718+
719+ def _downsample_image (self , oversampled_image , target_nx , target_ny ):
720+ """Block-average an oversampled image down to target resolution.
721+
722+ Computes block sizes dynamically from the actual oversampled image
723+ shape vs target shape. This handles cases where the oversampled grid
724+ was clamped to native resolution (fewer pixels than requested).
725+
726+ Args:
727+ oversampled_image: Array of shape (os_nx, os_ny) with oversampled data.
728+ target_nx: Desired number of X pixels in output.
729+ target_ny: Desired number of Y pixels in output.
730+
731+ Returns:
732+ Array of shape (target_nx, target_ny) with block-averaged data.
733+ """
734+ os_nx , os_ny = oversampled_image .shape
735+
736+ # If oversampled image is same size as target, no averaging needed
737+ if os_nx == target_nx and os_ny == target_ny :
738+ return oversampled_image
739+
740+ # Compute actual block sizes from image dimensions
741+ bx = os_nx // target_nx
742+ by = os_ny // target_ny
743+
744+ # Clamp to at least 1
745+ bx = max (bx , 1 )
746+ by = max (by , 1 )
747+
748+ # Usable pixels = target * block_size (trim remainder)
749+ actual_nx = min (target_nx , os_nx // bx )
750+ actual_ny = min (target_ny , os_ny // by )
751+
752+ if actual_nx == 0 or actual_ny == 0 :
753+ return np .full ((target_nx , target_ny ), np .nan , dtype = np .float32 )
754+
755+ # Reshape into blocks: (actual_nx, bx, actual_ny, by)
756+ blocked = oversampled_image [:actual_nx * bx , :actual_ny * by ].reshape (
757+ actual_nx , bx , actual_ny , by
758+ )
759+
760+ if self ._oversampling_mode == "linear_mean" :
761+ # dB → linear → nanmean → dB
762+ # Use float64 for precision in power conversion
763+ with np .errstate (invalid = 'ignore' ):
764+ linear = np .power (10.0 , np .float64 (blocked ) * 0.1 )
765+ mean_linear = np .nanmean (linear , axis = (1 , 3 ))
766+ result = (10.0 * np .log10 (mean_linear )).astype (np .float32 )
767+ else :
768+ # db_mean: average directly in dB domain
769+ result = np .nanmean (blocked , axis = (1 , 3 )).astype (np .float32 )
770+
771+ # If target is larger than what we computed (edge case), pad with NaN
772+ if actual_nx < target_nx or actual_ny < target_ny :
773+ padded = np .full ((target_nx , target_ny ), np .nan , dtype = np .float32 )
774+ padded [:actual_nx , :actual_ny ] = result
775+ return padded
776+
777+ return result
778+
677779 # =========================================================================
678780 # Image building
679781 # =========================================================================
@@ -684,6 +786,9 @@ def build_image(self, progress=None):
684786 Uses the backend's get_image() method with affine indexing for efficiency.
685787 Backends can override get_image() for vectorized implementations (e.g., Zarr/Dask).
686788
789+ When oversampling is configured (via set_oversampling()), requests a higher-
790+ resolution image and block-averages it down for anti-aliasing.
791+
687792 Args:
688793 progress: Optional progress bar or None (not currently used).
689794
@@ -694,16 +799,29 @@ def build_image(self, progress=None):
694799 self .reinit ()
695800 cs = self ._coord_system
696801
697- # Create image request with affine parameters
698- request = cs .make_image_request ()
699-
700- # Use backend's get_image() method (may be overridden for Dask/Zarr)
701- # Backend returns (nx, ny) - ping, sample
702- image = self ._backend .get_image (request )
703-
704- # Apply offset if set
705- if self ._offset != 0.0 :
706- image = image + self ._offset
802+ if self ._has_oversampling :
803+ # Oversampled path: request larger image, then block-average down
804+ target_nx = len (cs .feature_mapper .get_feature_values ("X coordinate" ))
805+ target_ny = len (cs .y_coordinates )
806+
807+ request = cs .make_oversampled_image_request (
808+ x_oversampling = self ._x_oversampling ,
809+ y_oversampling = self ._y_oversampling ,
810+ )
811+ oversampled_image = self ._backend .get_image (request )
812+
813+ # Apply offset before averaging (offset is additive, order doesn't matter)
814+ if self ._offset != 0.0 :
815+ oversampled_image = oversampled_image + self ._offset
816+
817+ image = self ._downsample_image (oversampled_image , target_nx , target_ny )
818+ else :
819+ # Standard path: no oversampling
820+ request = cs .make_image_request ()
821+ image = self ._backend .get_image (request )
822+
823+ if self ._offset != 0.0 :
824+ image = image + self ._offset
707825
708826 extent = deepcopy (cs .x_extent )
709827 extent .extend (cs .y_extent )
@@ -716,6 +834,9 @@ def build_image_and_layer_image(self, progress=None):
716834 Uses fast vectorized get_image() for the main echogram when no main_layer
717835 is set. Falls back to per-column iteration only for layer processing.
718836
837+ Note: Oversampling is applied to the main echogram image only.
838+ Layer images are built at native resolution (per-column iteration).
839+
719840 Returns:
720841 Tuple of (image, layer_image, extent).
721842 """
@@ -726,10 +847,23 @@ def build_image_and_layer_image(self, progress=None):
726847
727848 # Fast path: use vectorized get_image for main echogram if no main_layer
728849 if self .main_layer is None :
729- request = cs .make_image_request ()
730- image = self ._backend .get_image (request )
850+ if self ._has_oversampling :
851+ request = cs .make_oversampled_image_request (
852+ x_oversampling = self ._x_oversampling ,
853+ y_oversampling = self ._y_oversampling ,
854+ )
855+ oversampled_image = self ._backend .get_image (request )
856+ if self ._offset != 0.0 :
857+ oversampled_image = oversampled_image + self ._offset
858+ image = self ._downsample_image (oversampled_image , nx , ny )
859+ else :
860+ request = cs .make_image_request ()
861+ image = self ._backend .get_image (request )
862+ if self ._offset != 0.0 :
863+ image = image + self ._offset
731864 else :
732865 # Slow path: need per-column iteration for main_layer
866+ # (oversampling not supported for main_layer path)
733867 image = np .full ((nx , ny ), np .nan , dtype = np .float32 )
734868 image_indices , wci_indices = self .get_x_indices ()
735869 for image_index , wci_index in zip (image_indices , wci_indices ):
@@ -738,8 +872,10 @@ def build_image_and_layer_image(self, progress=None):
738872 y1 , y2 = self .main_layer .get_y_indices (wci_index )
739873 if y1 is not None and len (y1 ) > 0 :
740874 image [image_index , y1 ] = wci [y2 ]
875+ if self ._offset != 0.0 :
876+ image = image + self ._offset
741877
742- # Build layer image (requires per-column iteration)
878+ # Build layer image (requires per-column iteration, no oversampling )
743879 layer_image = np .full ((nx , ny ), np .nan , dtype = np .float32 )
744880 if len (self .layers ) > 0 :
745881 image_indices , wci_indices = self .get_x_indices ()
@@ -753,9 +889,7 @@ def build_image_and_layer_image(self, progress=None):
753889 if y1_layer is not None and len (y1_layer ) > 0 :
754890 layer_image [image_index , y1_layer ] = wci [y2_layer ]
755891
756- # Apply offset if set
757892 if self ._offset != 0.0 :
758- image = image + self ._offset
759893 layer_image = layer_image + self ._offset
760894
761895 extent = deepcopy (cs .x_extent )
@@ -769,6 +903,9 @@ def build_image_and_layer_images(self, progress=None):
769903 Uses fast vectorized get_image() for the main echogram when no main_layer
770904 is set. Falls back to per-column iteration only for layer processing.
771905
906+ Note: Oversampling is applied to the main echogram image only.
907+ Layer images are built at native resolution (per-column iteration).
908+
772909 Returns:
773910 Tuple of (image, layer_images_dict, extent).
774911 """
@@ -779,10 +916,23 @@ def build_image_and_layer_images(self, progress=None):
779916
780917 # Fast path: use vectorized get_image for main echogram if no main_layer
781918 if self .main_layer is None :
782- request = cs .make_image_request ()
783- image = self ._backend .get_image (request )
919+ if self ._has_oversampling :
920+ request = cs .make_oversampled_image_request (
921+ x_oversampling = self ._x_oversampling ,
922+ y_oversampling = self ._y_oversampling ,
923+ )
924+ oversampled_image = self ._backend .get_image (request )
925+ if self ._offset != 0.0 :
926+ oversampled_image = oversampled_image + self ._offset
927+ image = self ._downsample_image (oversampled_image , nx , ny )
928+ else :
929+ request = cs .make_image_request ()
930+ image = self ._backend .get_image (request )
931+ if self ._offset != 0.0 :
932+ image = image + self ._offset
784933 else :
785934 # Slow path: need per-column iteration for main_layer
935+ # (oversampling not supported for main_layer path)
786936 image = np .full ((nx , ny ), np .nan , dtype = np .float32 )
787937 image_indices , wci_indices = self .get_x_indices ()
788938 for image_index , wci_index in zip (image_indices , wci_indices ):
@@ -791,8 +941,10 @@ def build_image_and_layer_images(self, progress=None):
791941 y1 , y2 = self .main_layer .get_y_indices (wci_index )
792942 if y1 is not None and len (y1 ) > 0 :
793943 image [image_index , y1 ] = wci [y2 ]
944+ if self ._offset != 0.0 :
945+ image = image + self ._offset
794946
795- # Build layer images (requires per-column iteration)
947+ # Build layer images (requires per-column iteration, no oversampling )
796948 layer_images = {}
797949 for key in self .layers .keys ():
798950 layer_images [key ] = np .full ((nx , ny ), np .nan , dtype = np .float32 )
@@ -809,9 +961,7 @@ def build_image_and_layer_images(self, progress=None):
809961 if y1_layer is not None and len (y1_layer ) > 0 :
810962 layer_images [key ][image_index , y1_layer ] = wci [y2_layer ]
811963
812- # Apply offset if set
813964 if self ._offset != 0.0 :
814- image = image + self ._offset
815965 for key in layer_images :
816966 layer_images [key ] = layer_images [key ] + self ._offset
817967
0 commit comments