Stretch Builder#
A contrast stretch maps raw band values onto display brightness. The Stretch
Builder is the dialog where the user designs those mappings per channel, and the
StretchBase hierarchy is the data model that captures them and applies them
during rendering. This page covers both halves and the joint multi-band path
used by the decorrelation stretch.
This builds on the Rendering Pipeline overview; read that first for how stretches fit into the larger data → image flow.
Overview#
Stretches always operate on normalized data — float values in [0, 1]
produced by RasterDataSet.get_band_data_normalized(). Keeping every stretch in
the same normalized domain is what makes them composable and dataset-agnostic.
GUI:
StretchBuilderDialog,ChannelStretchWidget,StretchConfigWidget(src/wiser/gui/stretch_builder.py)Model:
StretchBaseand subclasses (src/wiser/raster/stretch.py)Applied by:
RasterView.update_display_image()/make_channel_image()/_render_joint_channels()(src/wiser/gui/rasterview.py)
A stretch can be built from two parts: an optional conditioner (a nonlinear
pre-shaping step such as square-root or log) followed by a main stretch
(linear, histogram-equalize, or decorrelation). The two are bound together with
StretchComposite.
The Stretch Model#
File: src/wiser/raster/stretch.py
Every stretch derives from StretchBase and shares one contract:
Member |
Purpose |
|---|---|
|
Mutate the 2-D normalized array |
|
|
|
Joint entry point: mutate an |
|
Decompose into |
|
Value identity, used as part of render-cache keys. |
classDiagram
direction TB
class StretchBase {
stretch.py
+apply(a)
+requires_all_bands() bool
+apply_multi(bands)
+get_stretches()
+get_hash_tuple()
}
class StretchLinear {
+_lower, _upper
+_slope, _offset
+set_bounds(lower, upper)
}
class StretchHistEqualize {
+_cdf, _histo_edges
}
class StretchSquareRoot {
conditioner: sqrt(a)
}
class StretchLog2 {
conditioner: log2(a+1)
}
class StretchDecorrelation {
requires_all_bands = True
+apply_multi(bands)
}
class StretchComposite {
+_first, _second
+apply(a) = first then second
}
StretchBase <|-- StretchLinear
StretchBase <|-- StretchHistEqualize
StretchBase <|-- StretchSquareRoot
StretchBase <|-- StretchLog2
StretchBase <|-- StretchDecorrelation
StretchComposite o-- StretchBase : first (conditioner)
StretchComposite o-- StretchBase : second (main stretch)
Note:
StretchCompositeis not a subclass ofStretchBase; it is a wrapper that holds two stretches and exposes the sameapply()/get_stretches()/get_hash_tuple()methods (duck typing). Itsget_stretches()returns[first, second], which is exactly what the renderer and joint-stretch detection inspect.
The stretch types#
Class |
Kind |
What it does |
|---|---|---|
|
main |
Linear remap of |
|
main |
Histogram equalization via the CDF of the supplied histogram ( |
|
conditioner |
|
|
conditioner |
|
|
joint main |
Cross-band decorrelation; |
Most stretches have a sibling ...UsingNumba jitclass (e.g.
StretchLinearUsingNumba) selected at runtime for large arrays — see the Numba
Dispatch section below.
Conditioner + stretch composition#
The renderer never assumes a single stretch. For each channel it calls
stretch.get_stretches() and receives [first, second]:
A bare stretch returns
[self, None].A conditioned stretch is a
StretchComposite(conditioner, main)and returns[conditioner, main].
make_channel_image() then applies first then second, clips to [0, 1],
and scales to uint8. This is why a square-root or log conditioner can be
combined freely with any main stretch.
The Stretch Builder Dialog#
File: src/wiser/gui/stretch_builder.py
Purpose: Interactive UI for designing the stretch on each display channel and emitting the result.
Class |
Role |
|---|---|
|
Top-level dialog; coordinates one |
|
Per-channel histogram display with draggable low/high bounds. |
|
Stretch-type and conditioner radio selection, plus quick 2.5% / 5% linear presets. |
StretchType and ConditionerType (src/wiser/raster/stretch.py)
enumerate the UI choices: NO_STRETCH, LINEAR_STRETCH, EQUALIZE_STRETCH,
DECORRELATION_STRETCH; and NO_CONDITIONER, SQRT_CONDITIONER,
LOG_CONDITIONER.
Lifecycle: the dialog is shown via show(dataset, display_bands, stretches).
It loads each band’s normalized data, computes (or looks up) a histogram from the
histogram cache, and seeds the controls from any existing
stretches. As the user drags bounds or changes type/conditioner, it constructs
fresh stretch objects and emits:
stretch_changed = Signal(int, tuple, list) # (dataset_id, display_bands, stretches)
Emissions are gated by an _enable_stretch_changed_events flag so that
programmatic setup (loading existing state, linking sliders) does not fire
spurious updates.
For how stretch_changed reaches every RasterView, see the “How Changes Reach
the Screen” section of the Rendering Pipeline.
In short: App stores the stretches in ApplicationState (keyed per
(ds_id, band_index)) and re-emits a state-level stretch_changed, which
RasterPane turns into RasterView.set_stretches() calls.
Applying Stretches During Rendering#
Per-band path#
For each dirty channel, RasterView’s update_display_image() calls:
stretches = self._stretches[i].get_stretches() # [conditioner, main]
new_data = make_channel_image(band_data, stretches[0], stretches[1])
make_channel_image() applies the two stretches in order, clips to [0, 1],
and returns uint8.
Joint path (decorrelation)#
Some stretches are inherently cross-band. RasterView._detect_joint_stretch()
walks the three channels and, for each, splits its stretch into
(conditioner, main) and checks main.requires_all_bands(). If all three
channels agree on the same joint stretch (by value equality), the renderer takes
the joint path; if they disagree, it logs a warning and falls back to per-band.
_render_joint_channels() then runs in three phases
(src/wiser/gui/rasterview.py):
Gather + condition — collect all bands into one
(H, W, 3)float32 buffer, applying each channel’s conditioner in place. Masked (data-ignore) pixels are zeroed so conditioners stay in their valid domain.Joint compute (cached) — if
_joint_render_cachematches the current(dataset_id, bands, conditioner_signature)key, reuse it; otherwise calljoint_stretch.apply_multi(bands)and cache the result.Scale + restore — clip each channel to
[0, 1], scale touint8, and restore masks.
StretchDecorrelation.apply_multi() delegates the heavy math to decor_numba
(src/wiser/raster/decorrelation_stretch.py),
then per-band-normalizes the result back into [0, 1].
flowchart TD
DETECT{"_detect_joint_stretch()<br/>all 3 channels agree?"}
PB["Per-band:<br/>make_channel_image() x3"]
G["Phase 1: gather (H,W,3)<br/>+ per-band conditioners"]
JC{"_joint_render_cache<br/>matches key?"}
REUSE["reuse cached joint result"]
COMPUTE["apply_multi() (decor_numba)<br/>+ cache result"]
S["Phase 3: clip, scale uint8,<br/>restore masks"]
DETECT -->|no / disagree| PB
DETECT -->|yes| G
G --> JC
JC -->|hit| REUSE
JC -->|miss| COMPUTE
REUSE --> S
COMPUTE --> S
Numba Dispatch#
The pixel-level helpers (make_channel_image, make_rgb_image) and the stretch
classes each have a pure-Python implementation and a numba-compiled variant. The
numba version is used only when an array exceeds ARRAY_NUMBA_THRESHOLD
(src/wiser/raster/utils.py); below that, JIT warm-up costs
outweigh the benefit and the Python path is used. The numba wrappers also
convert float64 inputs to float32 for speed and memory. StretchDecorrelation
has no jitclass variant (a jitclass cannot hold the Python references it needs);
its numba acceleration lives inside decor_numba instead.