Skip to content

Commit 49d0bc5

Browse files
committed
add option to oversample wci images
1 parent 7f81722 commit 49d0bc5

File tree

4 files changed

+151
-22
lines changed

4 files changed

+151
-22
lines changed

python/themachinethatgoesping/pingprocessing/watercolumn/image/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# modules
44

55
# functions
6-
from .make_wci import make_wci, make_wci_dual_head, make_wci_stack, make_beam_sample_image
6+
from .make_wci import make_wci, make_wci_dual_head, make_wci_stack, make_beam_sample_image, downsample_wci
77
from .imagebuilder import *
88

99
#from themachinethatgoesping.pingprocessing_cppy.watercolumn.image import make_wci as make_wci_cppy

python/themachinethatgoesping/pingprocessing/watercolumn/image/imagebuilder.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from themachinethatgoesping.pingprocessing.core.progress import get_progress_iterator
33

44
#subpackage imports
5-
from .make_wci import make_wci, make_wci_dual_head, make_wci_stack, make_beam_sample_image
5+
from .make_wci import make_wci, make_wci_dual_head, make_wci_stack, make_beam_sample_image, downsample_wci
66

77
class ImageBuilder:
88

@@ -12,6 +12,8 @@ def __init__(
1212
horizontal_pixels,
1313
wci_render = 'linear',
1414
progress = False,
15+
oversampling = 1,
16+
oversampling_mode = 'linear_mean',
1517
**kwargs):
1618

1719
self.pings = pings
@@ -24,24 +26,29 @@ def __init__(
2426
else:
2527
self.beam_sample_view = False
2628
self.progress = progress
29+
self.oversampling = max(1, int(oversampling))
30+
self.oversampling_mode = oversampling_mode
2731

28-
# if isinstance(self.pings, dict):
29-
# self.dual_head = True
30-
# else:
31-
# self.dual_head = False
32-
33-
def update_args(self, wci_render = 'linear', **kwargs):
32+
def update_args(self, wci_render = 'linear', oversampling = None, oversampling_mode = None, **kwargs):
3433
if wci_render == 'beamsample':
3534
self.beam_sample_view = True
3635
else:
3736
self.beam_sample_view = False
37+
if oversampling is not None:
38+
self.oversampling = max(1, int(oversampling))
39+
if oversampling_mode is not None:
40+
self.oversampling_mode = oversampling_mode
3841
self.default_args.update(kwargs)
3942

4043
def build(self, index, stack = 1, stack_step = 1, **kwargs):
4144

4245
_kwargs = self.default_args.copy()
4346
_kwargs.update(kwargs)
44-
47+
48+
# Apply oversampling: multiply horizontal_pixels
49+
effective_oversampling = self.oversampling
50+
if effective_oversampling > 1 and not self.beam_sample_view:
51+
_kwargs["horizontal_pixels"] = _kwargs["horizontal_pixels"] * effective_oversampling
4552

4653
if stack > 1:
4754
max_index = index+stack
@@ -50,17 +57,21 @@ def build(self, index, stack = 1, stack_step = 1, **kwargs):
5057

5158
stack_pings = self.pings[index:max_index:stack_step]
5259

53-
return make_wci_stack(
60+
wci, extent = make_wci_stack(
5461
stack_pings,
5562
progress=self.progress,
5663
**_kwargs)
57-
58-
if self.beam_sample_view:
59-
return make_beam_sample_image(
64+
elif self.beam_sample_view:
65+
wci, extent = make_beam_sample_image(
6066
self.pings[index],
6167
**_kwargs)
62-
63-
64-
return make_wci_dual_head(
65-
self.pings[index],
66-
**_kwargs)
68+
else:
69+
wci, extent = make_wci_dual_head(
70+
self.pings[index],
71+
**_kwargs)
72+
73+
# Downsample if oversampling was applied
74+
if effective_oversampling > 1 and not self.beam_sample_view:
75+
wci, extent = downsample_wci(wci, extent, effective_oversampling, mode=self.oversampling_mode)
76+
77+
return wci, extent

python/themachinethatgoesping/pingprocessing/watercolumn/image/make_wci.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,62 @@
1212
# themachinethatgoesping.pingtools/watercolumn imports
1313
import themachinethatgoesping.pingprocessing.watercolumn.helper as wchelper
1414

15+
16+
def downsample_wci(wci, extent, factor, mode="linear_mean"):
17+
"""Downsample a WCI image by block averaging.
18+
19+
Reduces the resolution of a water column image by averaging blocks of
20+
pixels. Useful as a post-processing step after building an oversampled
21+
image (with higher horizontal_pixels) for anti-aliasing.
22+
23+
Args:
24+
wci: 2D numpy array of shape (ny, nz) with dB values.
25+
extent: Tuple/list of (ymin, ymax, zmax, zmin) — physical bounds.
26+
factor: Integer downsampling factor (same for both axes).
27+
The image dimensions are divided by this factor.
28+
mode: Averaging mode.
29+
- 'linear_mean': Convert dB to linear power (10^(0.1*v)),
30+
average, convert back (10*log10). Correct for dB data.
31+
- 'db_mean': Average directly in dB domain. Faster.
32+
33+
Returns:
34+
Tuple of (downsampled_wci, extent). Extent is unchanged since
35+
the physical bounds remain the same.
36+
"""
37+
factor = int(factor)
38+
if factor <= 1:
39+
return wci, tuple(extent)
40+
41+
if mode not in ("linear_mean", "db_mean"):
42+
raise ValueError(f"Invalid mode '{mode}'. Use 'linear_mean' or 'db_mean'.")
43+
44+
ny, nz = wci.shape
45+
46+
# If image is smaller than one block, return as-is
47+
if ny < factor or nz < factor:
48+
return wci, tuple(extent)
49+
50+
# Trim to exact multiple of factor
51+
usable_ny = (ny // factor) * factor
52+
usable_nz = (nz // factor) * factor
53+
trimmed = wci[:usable_ny, :usable_nz]
54+
55+
target_ny = usable_ny // factor
56+
target_nz = usable_nz // factor
57+
58+
# Reshape into blocks: (target_ny, factor, target_nz, factor)
59+
blocked = trimmed.reshape(target_ny, factor, target_nz, factor)
60+
61+
if mode == "linear_mean":
62+
with np.errstate(invalid='ignore'):
63+
linear = np.power(10.0, np.float64(blocked) * 0.1)
64+
mean_linear = np.nanmean(linear, axis=(1, 3))
65+
result = (10.0 * np.log10(mean_linear)).astype(np.float32)
66+
else:
67+
result = np.nanmean(blocked, axis=(1, 3)).astype(np.float32)
68+
69+
return result, tuple(extent)
70+
1571
def is_iterable(obj):
1672
try:
1773
iter(obj)

python/themachinethatgoesping/pingprocessing/widgets/wciviewer_pyqtgraph2.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -514,11 +514,23 @@ def _build_ui(self) -> None:
514514
value=self.args_imagebuilder["horizontal_pixels"],
515515
layout=ipywidgets.Layout(width='200px'),
516516
)
517+
self.w_oversampling = ipywidgets.Dropdown(
518+
description="oversample",
519+
options=[1, 2, 3, 4],
520+
value=1,
521+
layout=ipywidgets.Layout(width='140px'),
522+
)
523+
self.w_oversampling_mode = ipywidgets.Dropdown(
524+
description="avg",
525+
options=["linear_mean", "db_mean"],
526+
value="linear_mean",
527+
layout=ipywidgets.Layout(width='170px'),
528+
)
517529

518530
# Observe global controls for rebuild
519531
for widget in [self.w_stack, self.w_stack_step, self.w_mp_cores,
520532
self.w_stack_linear, self.w_wci_value, self.w_wci_render,
521-
self.w_horizontal_pixels]:
533+
self.w_horizontal_pixels, self.w_oversampling, self.w_oversampling_mode]:
522534
widget.observe(self._on_global_param_change, names="value")
523535

524536
# Fix/unfix view buttons
@@ -600,10 +612,21 @@ def _build_ui(self) -> None:
600612
)
601613
self.w_video_format = ipywidgets.Dropdown(
602614
description="format",
603-
options=["mp4", "gif", "webm", "avi"],
615+
options=["mp4", "gif", "avif", "webm", "avi"],
604616
value="mp4",
605617
layout=ipywidgets.Layout(width='130px'),
606618
)
619+
self.w_video_quality = ipywidgets.IntSlider(
620+
value=50,
621+
min=1,
622+
max=100,
623+
step=1,
624+
description="quality",
625+
tooltip="Compression quality for AVIF (1=smallest, 100=best)",
626+
layout=ipywidgets.Layout(width='200px'),
627+
)
628+
self.w_video_quality.layout.display = 'none' # hidden unless avif selected
629+
self.w_video_format.observe(self._on_video_format_change, names='value')
607630
self.w_video_filename = ipywidgets.Text(
608631
value="wci_video",
609632
description="filename",
@@ -760,7 +783,7 @@ def _assemble_layout(self) -> None:
760783
tab_render = ipywidgets.VBox([
761784
ipywidgets.HBox([self.w_vmin, self.w_vmax]),
762785
ipywidgets.HBox([self.w_wci_value, self.w_wci_render]),
763-
ipywidgets.HBox([self.w_horizontal_pixels]),
786+
ipywidgets.HBox([self.w_horizontal_pixels, self.w_oversampling, self.w_oversampling_mode]),
764787
ipywidgets.HBox([self.w_time_sync, self.w_crosshair, self.w_time_warning]),
765788
])
766789

@@ -780,7 +803,7 @@ def _assemble_layout(self) -> None:
780803

781804
# Tab 5: Video export
782805
tab_video = ipywidgets.VBox([
783-
ipywidgets.HBox([self.w_video_frames, self.w_video_fps, self.w_video_format]),
806+
ipywidgets.HBox([self.w_video_frames, self.w_video_fps, self.w_video_format, self.w_video_quality]),
784807
ipywidgets.HBox([self.w_video_filename, self.w_video_ping_time, self.w_video_live, self.w_export_video]),
785808
self.w_video_status,
786809
])
@@ -821,6 +844,13 @@ def _assemble_layout(self) -> None:
821844
self.output,
822845
])
823846

847+
def _on_video_format_change(self, change: Dict[str, Any]) -> None:
848+
"""Show/hide the quality slider based on selected format."""
849+
if change['new'] == 'avif':
850+
self.w_video_quality.layout.display = None
851+
else:
852+
self.w_video_quality.layout.display = 'none'
853+
824854
def _on_layout_change(self, change: Dict[str, Any]) -> None:
825855
"""Handle grid layout change."""
826856
new_rows, new_cols = change['new']
@@ -1099,6 +1129,8 @@ def _sync_builder_args(self) -> None:
10991129
self.args_imagebuilder["wci_render"] = self.w_wci_render.value
11001130
self.args_imagebuilder["horizontal_pixels"] = self.w_horizontal_pixels.value
11011131
self.args_imagebuilder["mp_cores"] = self.w_mp_cores.value
1132+
self.args_imagebuilder["oversampling"] = self.w_oversampling.value
1133+
self.args_imagebuilder["oversampling_mode"] = self.w_oversampling_mode.value
11021134

11031135
# Update all slot imagebuilders
11041136
for slot in self.slots:
@@ -1413,6 +1445,36 @@ def _export_video(self, _event: Any = None) -> None:
14131445
imageio.mimsave(filename, frames, duration=durations)
14141446
else:
14151447
imageio.mimsave(filename, frames, duration=1.0/video_fps)
1448+
elif fmt == "avif":
1449+
# Use pillow-avif-plugin for animated AVIF export
1450+
try:
1451+
import pillow_avif # noqa: F401 – registers .avif with Pillow
1452+
except ImportError:
1453+
self.w_video_status.value = "Error: pip install pillow-avif-plugin"
1454+
return
1455+
try:
1456+
from PIL import Image
1457+
pil_frames = [Image.fromarray(f) for f in frames]
1458+
quality = self.w_video_quality.value
1459+
1460+
if use_ping_time and durations:
1461+
duration_ms = [int(d * 1000) for d in durations]
1462+
while len(duration_ms) < len(pil_frames):
1463+
duration_ms.append(int(1000 / video_fps))
1464+
else:
1465+
duration_ms = int(1000 / video_fps)
1466+
1467+
pil_frames[0].save(
1468+
filename,
1469+
save_all=True,
1470+
append_images=pil_frames[1:],
1471+
duration=duration_ms,
1472+
loop=0,
1473+
quality=quality,
1474+
)
1475+
except Exception as e:
1476+
self.w_video_status.value = f"AVIF error: {e}"
1477+
return
14161478
else:
14171479
# For video formats (mp4, avi, webm), use imageio with ffmpeg plugin
14181480
try:

0 commit comments

Comments
 (0)