sidescanpy.export.image#

Invertible 8-bit display transform.

Percentile-stretch + gamma-correct a backscatter array (any dtype) into an 8-bit grayscale image suitable for training-dataset export, while retaining enough metadata to reconstruct the original array later (lossy — 8-bit quantization is not exactly reversible, but the scale is recoverable). This is a display/export transform, not a physical correction — it has no relationship to processing/radiometric.py’s TVG correction. Run TVG (and geometric/georef, if wanted) first; this module only turns whatever array it’s handed into 8-bit.

Normalization scope (per-image vs. survey-wide vs. dataset-wide) is deliberately a caller decision, not baked in here: pass reference_stats=None (default) for per-image scope, where each array is stretched against its own percentiles, or precompute one ReferenceStats via compute_reference_stats() over every window a wider scope should share and pass it to every to_uint8() call in that scope.

2026 rshomberg@uri.edu

Contents

ReversalMetadata

Enough to invert to_uint8() back to the original array's dtype and approximate scale.

ReferenceStats

Percentile bounds computed once, for reuse across many to_uint8() calls at survey- or dataset-wide normalization scope, instead of each image normalizing against only its own window.

compute_reference_stats

Compute shared percentile bounds over one or more arrays.

to_uint8

Percentile-stretch + gamma-correct array into an 8-bit grayscale image.

to_uint16

16-bit counterpart of to_uint8() — identical percentile stretch and gamma (same bounds, same ReversalMetadata), just quantized to 65536 levels instead of 256.

from_uint8

Reverse to_uint8() (or, given a uint16 image, to_uint16()) back to (an approximation of) the original array's dtype and scale.

NORMALIZATION_SCOPES

"image": each image stretched on its own percentiles; "shared": one stretch for a whole set.

reference_stats_for_scope

Shared stretch for arrays under "shared" scope; None under "image".

save_grayscale_png

Write a single-channel grayscale PNG: 8-bit ("L") or, for uint16 input, 16-bit.

class sidescanpy.export.image.ReversalMetadata(kind, p_min, p_max, gamma, original_max)#

Bases: object

Enough to invert to_uint8() back to the original array’s dtype and approximate scale.

class sidescanpy.export.image.ReferenceStats(p_min, p_max)#

Bases: object

Percentile bounds computed once, for reuse across many to_uint8() calls at survey- or dataset-wide normalization scope, instead of each image normalizing against only its own window.

sidescanpy.export.image.compute_reference_stats(*arrays, pct_low=1.0, pct_high=99.0)#

Compute shared percentile bounds over one or more arrays.

Call once over every window a survey-wide or dataset-wide normalization scope should share, then pass the result to every to_uint8() call in that scope via reference_stats=. Omit entirely for the default per-image scope.

Non-finite values are excluded before the percentile is computed. Mixing arrays of different kinds (e.g. raw counts and TVG-corrected dB) is the caller’s decision, not validated here — only combine arrays it actually makes sense to normalize together.

sidescanpy.export.image.to_uint8(array, *, gamma=1.0, pct_low=1.0, pct_high=99.0, reference_stats=None)#

Percentile-stretch + gamma-correct array into an 8-bit grayscale image.

Parameters:
arraynp.ndarray

uint16, uint32, or float32/float64. uint32 is stretched over non-zero values only (zero treated as background/no-return, matching raw sensor-counts convention); the other two kinds are stretched over all finite values.

gammafloat, default 1.0

Power-law display contrast (normalized ** gamma) — matches this library’s existing display-gamma convention (viz.waterfall.plot_waterfall/viz.gridded, both of which hand gamma straight to matplotlib.colors.PowerNorm, whose own convention is normalized ** gamma), where 1.0 is a no-op. Since gamma > 1 on normalized in [0, 1] pushes values down (darkens midtones/background, compresses toward black), a caller reusing a gamma value tuned against a PowerNorm-based render (e.g. 2.5) gets the same visual direction here — the inverse convention (normalized ** (1/gamma), which brightens instead) was this function’s original, undocumented-as-different implementation and produced washed-out/over-bright output at the same nominal gamma values that darkened contrast correctly elsewhere in this library; see CHANGELOG.md.

pct_low, pct_highfloat, default (1.0, 99.0)

Percentile bounds for the stretch, matching this library’s existing default elsewhere. Ignored when reference_stats is given.

reference_statsReferenceStats, optional

Precomputed bounds (survey-/dataset-wide normalization scope, see compute_reference_stats()). When given, pct_low/ pct_high are ignored — the array is stretched against reference_stats’s bounds instead of its own. Omit for the default per-image scope.

Returns:
(uint8_array, ReversalMetadata)
sidescanpy.export.image.to_uint16(array, *, gamma=1.0, pct_low=1.0, pct_high=99.0, reference_stats=None)#

16-bit counterpart of to_uint8() — identical percentile stretch and gamma (same bounds, same ReversalMetadata), just quantized to 65536 levels instead of 256. For datasets where a model or analyst should see finer backscatter gradations than 8-bit keeps; to_uint16(a)[0] >> 8 equals to_uint8(a)[0] to within one level of rounding.

sidescanpy.export.image.from_uint8(img_8bit, metadata)#

Reverse to_uint8() (or, given a uint16 image, to_uint16()) back to (an approximation of) the original array’s dtype and scale. Lossy — quantization is not exactly reversible.

sidescanpy.export.image.NORMALIZATION_SCOPES = ('image', 'shared')#

"image": each image stretched on its own percentiles; "shared": one stretch for a whole set.

sidescanpy.export.image.reference_stats_for_scope(scope, arrays, pct_low, pct_high)#

Shared stretch for arrays under "shared" scope; None under "image".

Raises:
ValueError

Unknown scope.

sidescanpy.export.image.save_grayscale_png(image, path)#

Write a single-channel grayscale PNG: 8-bit ("L") or, for uint16 input, 16-bit.

Unlike matplotlib.pyplot.imsave (which always writes RGB(A)), the file holds exactly one channel.