Note
Go to the end to download the full example code.
Comparing results across sonars and file formats#

Recordings from different sonars, written in different file formats,
become the same data model, so one piece of code processes and compares
them all. Each line here is read with
read_and_convert(), TVG-corrected with
apply_tvg() and plotted with
plot_waterfall(); only the file name and format
string change. What still differs between the panels (texture, contrast,
speckle) comes from the sonar, its frequency and the seabed, not from
the processing.
The last part checks the readers against each other: one EdgeTech line was written both as JSF and as XTF, and the two give identical samples.
Data: JNCC Wight-Barfleur Reef and mid-Irish Sea (Open Government Licence v3.0) and USGS Grand Bay (U.S. public domain); see Example data.
Usage: python examples/gallery/plot_compare_sonars_and_formats.py [output_dir]
# 2026 rshomberg@uri.edu
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from sidescanpy import datasets
from sidescanpy.io import read_and_convert
from sidescanpy.log import configure_logging
from sidescanpy.processing import apply_tvg, compute_geometric_ranges
from sidescanpy.viz import plot_waterfall
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
out.mkdir(parents=True, exist_ok=True)
configure_logging("INFO")
From left: EdgeTech (JSF written by Discover), Benthos SIS-1624 (XTF written by Isis) and Klein (XTF written by SonarPro), each in dB against slant range.
lines = [
("jncc_wight_barfleur_20130321174522.jsf", "jsf", "hz_218740", "EdgeTech JSF (Discover)"),
("jncc_mid_irish_sea_MIS11_0.xtf", "xtf", "hz_100000", "Benthos XTF (Isis)"),
("usgs_grand_bay_150602205800.xtf", "xtf", "hz_100000", "Klein XTF (SonarPro)"),
]
fig, axes = plt.subplots(1, 3, figsize=(15, 5), constrained_layout=True)
shown = []
for ax, (name, fmt, freq_key, label) in zip(axes, lines):
survey = read_and_convert([datasets.fetch(name)], fmt=fmt)
survey = apply_tvg(survey)
ds = compute_geometric_ranges(survey.get_frequency(freq_key).dataset)
# apply_tvg returns dB; average samples to about 1000 per side for display.
ds = ds.coarsen(sample=max(1, ds.sizes["sample"] // 1000), boundary="trim").mean()
shown.append(ds)
plot_waterfall(ds, x_axis="slant_range", vmin_pct=2, vmax_pct=99.5, ax=ax,
title=f"{label}, {freq_key}")
fig.savefig(out / "compare_sonars_and_formats.png", dpi=150)

21:10:51 WARNING sidescanpy.io.jsf.reader: jncc_wight_barfleur_20130321174522.jsf: skipped 910 message(s) of unsupported type(s) (type: count) 2000: 910
21:10:51 WARNING sidescanpy.io.jsf.converter: 1 incomplete ping(s) detected; repair_mode='nan_pad'
21:10:51 WARNING sidescanpy.processing.sample_axis: 287 of 287 pings have invalid sound_vel_ms (NaN or non-positive); falling back to 1500.0 m/s
21:10:51 WARNING sidescanpy.processing.geometric: 0 of 287 pings have NaN altitude_m (their ground range is NaN) and 287 have altitude_m <= 0 (ground range = slant range); the 'bottom_track' step can fill them from the first bottom return
The same pings, written by EdgeTech Discover once as JSF and once as XTF. The two readers return identical samples.
jsf = read_and_convert([datasets.fetch("jncc_wight_barfleur_20130321174522.jsf")], fmt="jsf")
xtf = read_and_convert([datasets.fetch("jncc_wight_barfleur_20130321174522H.xtf")], fmt="xtf")
a = jsf.get_frequency("hz_521000").dataset["backscatter_intensity"]
b = xtf.get_frequency(sorted(xtf.frequencies)[0]).dataset["backscatter_intensity"]
n_ping = min(a.sizes["ping"], b.sizes["ping"])
n_sample = min(a.sizes["sample"], b.sizes["sample"])
a = a.isel(ping=slice(0, n_ping), sample=slice(0, n_sample)).values
b = b.isel(ping=slice(0, n_ping), sample=slice(0, n_sample)).values
print(f"JSF vs XTF, {n_ping} pings: max |difference| = {np.nanmax(np.abs(a - b)):.3g}")
21:10:59 WARNING sidescanpy.io.jsf.reader: jncc_wight_barfleur_20130321174522.jsf: skipped 910 message(s) of unsupported type(s) (type: count) 2000: 910
21:10:59 WARNING sidescanpy.io.jsf.converter: 1 incomplete ping(s) detected; repair_mode='nan_pad'
JSF vs XTF, 288 pings: max |difference| = 0
Total running time of the script: (0 minutes 8.277 seconds)