Skip to content

Commit 7f81722

Browse files
committed
echogrambuidler: allow oversampling for better plotting (mean of oversampled image)
1 parent ec8a465 commit 7f81722

2 files changed

Lines changed: 260 additions & 20 deletions

File tree

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,3 +950,93 @@ def make_image_request(self) -> EchogramImageRequest:
950950
max_sample_indices=max_sample_idx,
951951
fill_value=np.nan,
952952
)
953+
954+
def make_oversampled_image_request(
955+
self, x_oversampling: int = 1, y_oversampling: int = 1
956+
) -> "EchogramImageRequest":
957+
"""Create an oversampled image request for anti-aliased downsampling.
958+
959+
Generates a request with x_oversampling × y_oversampling more pixels
960+
than the current coordinate system grid. The oversampled grid spans
961+
the same physical extent, so block-averaging the result back to the
962+
original resolution produces anti-aliased output.
963+
964+
The oversampled Y coordinates are computed by subdividing each original
965+
Y pixel into y_oversampling sub-pixels. Similarly for X.
966+
967+
Args:
968+
x_oversampling: Integer factor for X axis oversampling.
969+
y_oversampling: Integer factor for Y axis oversampling.
970+
971+
Returns:
972+
EchogramImageRequest with oversampled dimensions.
973+
"""
974+
self.reinit()
975+
976+
# --- Y oversampling ---
977+
y_coords = np.asarray(self.y_coordinates, dtype=np.float32)
978+
ny_target = len(y_coords)
979+
980+
if y_oversampling > 1:
981+
# Clamp: don't oversample beyond the native sample resolution.
982+
# The maximum useful ny is the largest sample count across all pings.
983+
# Beyond that, multiple oversampled Y pixels map to the same sample
984+
# via nearest-neighbor rounding — redundant reads with no benefit.
985+
max_native_ny = int(np.nanmax(self.max_number_of_samples))
986+
ny_os = min(ny_target * y_oversampling, max(max_native_ny, ny_target))
987+
# Ensure ny_os is a multiple of ny_target for clean block averaging
988+
ny_os = max(ny_target, (ny_os // ny_target) * ny_target)
989+
y_min = float(y_coords[0])
990+
y_max = float(y_coords[-1])
991+
y_coords_os = np.linspace(y_min, y_max, ny_os, dtype=np.float32)
992+
else:
993+
y_coords_os = y_coords
994+
ny_os = ny_target
995+
996+
# --- X oversampling ---
997+
x_coords = np.array(self.feature_mapper.get_feature_values("X coordinate"))
998+
nx_target = len(x_coords)
999+
1000+
if x_oversampling > 1:
1001+
# Clamp: don't create more X grid points than source pings.
1002+
# Beyond n_pings, adjacent oversampled pixels map to the same ping.
1003+
nx_os = min(nx_target * x_oversampling, max(self._n_pings, nx_target))
1004+
# Ensure nx_os is a multiple of nx_target for clean block averaging
1005+
nx_os = max(nx_target, (nx_os // nx_target) * nx_target)
1006+
x_min = float(x_coords[0])
1007+
x_max = float(x_coords[-1])
1008+
x_coords_os = np.linspace(x_min, x_max, nx_os)
1009+
else:
1010+
x_coords_os = x_coords
1011+
nx_os = nx_target
1012+
1013+
# --- Build ping indexer for oversampled X grid ---
1014+
vec_x_val = np.array(self.feature_mapper.get_feature_values(self.x_axis_name))
1015+
1016+
# Map oversampled x coords to nearest feature value
1017+
wci_index_os = self.feature_mapper.feature_to_index(
1018+
self.x_axis_name, x_coords_os,
1019+
mp_cores=self.mp_cores
1020+
)
1021+
1022+
# Validity check: distance to nearest source ping
1023+
delta_x_os = np.abs(vec_x_val[wci_index_os] - x_coords_os)
1024+
1025+
ping_indexer_os = np.full(nx_os, -1, dtype=np.int64)
1026+
valid_os = delta_x_os < self.x_interpolation_limit
1027+
ping_indexer_os[valid_os] = np.asarray(wci_index_os, dtype=np.int64)[valid_os]
1028+
1029+
# --- Affine params (per-ping, unchanged) ---
1030+
affine_a, affine_b = self._estimate_affine_y_to_sample()
1031+
max_sample_idx = self.max_number_of_samples.astype(np.int64) + 1
1032+
1033+
return EchogramImageRequest(
1034+
nx=nx_os,
1035+
ny=ny_os,
1036+
y_coordinates=y_coords_os,
1037+
ping_indexer=ping_indexer_os,
1038+
affine_a=affine_a,
1039+
affine_b=affine_b,
1040+
max_sample_indices=max_sample_idx,
1041+
fill_value=np.nan,
1042+
)

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

Lines changed: 170 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)