Spectrum Plot System#
This page documents the internals of WISER’s spectrum-plot subsystem: the plot widgets that render spectra, the Region-of-Interest (ROI) model that defines which pixels a spectrum is drawn from, how a spectrum is actually computed from raster data, and how those pieces are wired together through application state. It is a guide for developers reading, debugging, or extending any part of this pipeline. Please keep it up to date.
Overview#
Three cooperating layers turn raster pixels into a plotted line:
Model layer (
src/wiser/raster/) —RegionOfInterestandSelectiondescribe a set of pixels; the functions inspectrum.pyaggregate those pixels’ band values into a single spectrum; theSpectrumclass hierarchy wraps the result behind a common, lazily-evaluated interface.Application state (
src/wiser/gui/app_state.py) —ApplicationStateowns the “active spectrum”, the list of “collected” spectra, and loaded spectral libraries. It broadcasts changes through Qt signals. No widget holds a direct reference to another; they communicate only through these signals.GUI layer (
src/wiser/gui/spectrum_plot.py) —SpectrumPlotGenericis a reusable matplotlib-backed plot widget;SpectrumPlotsubclasses it and binds it toApplicationState.
The central idea: ROIs and pixel clicks produce Spectrum objects; app state
broadcasts them via signals; the plot widget renders them with matplotlib.
flowchart TB
subgraph Model["Model layer (wiser/raster)"]
ROI["RegionOfInterest / Selection"]
CALC["spectrum.py calc_* functions"]
SPEC["Spectrum objects"]
end
subgraph State["App state"]
AS["ApplicationState (signals)"]
end
subgraph GUI["GUI layer (wiser/gui)"]
PLOT["SpectrumPlot / SpectrumPlotGeneric"]
end
ROI --> CALC --> SPEC
SPEC --> AS
AS -- "signals" --> PLOT
Plot widget class hierarchy#
All of the following live in
src/wiser/gui/spectrum_plot.py. The two widget
classes are SpectrumPlotGeneric (the reusable base) and SpectrumPlot (the
app-bound subclass); the rest are small helpers each responsible for one drawing
or UI concern.
classDiagram
direction TB
class QWidget["QWidget (Qt)"]
class SpectrumPlotGeneric {
spectrum_plot.py :492
+_collected_spectra : List~Spectrum~
+_spectrum_display_info : Dict
+closed : Signal
+add_collected_spectrum()
+set_title() / set_legend()
+set_x_range() / set_y_range()
+_add_spectrum_to_plot()
+_draw_spectra()
}
class SpectrumPlot {
spectrum_plot.py :1679
+_dataset : RasterDataSet
+_on_active_spectrum_changed()
+_on_collected_spectra_changed()
+_on_spectral_library_added()
+_on_collect_spectrum()
}
QWidget <|-- SpectrumPlotGeneric : subclass
SpectrumPlotGeneric <|-- SpectrumPlot : subclass
The helper classes the plot composes:
classDiagram
direction TB
class SpectrumPlotGeneric {
spectrum_plot.py :492
}
class SpectrumPlotCanvas {
spectrum_plot.py :124
matplotlib FigureCanvas + context menu
}
class SpectrumDisplayInfo {
spectrum_plot.py :151
one matplotlib Line2D per spectrum
+generate_plot()
+remove_plot()
}
class SpectrumPointDisplayInfo {
spectrum_plot.py :281
selected-point crosshair + label
+generate_plot()
}
class SpectrumPlotDatasetChooser {
spectrum_plot.py :427
toolbar dataset dropdown
}
SpectrumPlotGeneric --> SpectrumPlotCanvas : contains
SpectrumPlotGeneric --> SpectrumDisplayInfo : one per spectrum
SpectrumPlotGeneric --> SpectrumPointDisplayInfo : zero or one (selection)
SpectrumPlot --> SpectrumPlotDatasetChooser : contains (toolbar)
Plotting library. The widget uses matplotlib with the Qt5Agg backend.
Each visible spectrum is a matplotlib Line2D managed by a SpectrumDisplayInfo
(:151); the X-axis shows wavelengths when the spectra carry them, and falls
back to band index otherwise. Display configuration (title, legend placement,
font sizes, axis ranges, tick intervals, selection-marker style, default
area-average mode) is driven by SpectrumPlotConfigDialog
(src/wiser/gui/spectrum_plot_config.py:15).
SpectrumPlot vs SpectrumPlotGeneric#
This is the most important distinction in the subsystem, and the most commonly misunderstood.
A common misconception is that SpectrumPlotGeneric is fully decoupled from
application state. It is not — both classes receive an app_state in their
constructor. The real difference is the data source for collected spectra, as
the in-code comment at spectrum_plot.py:557-559 states:
This class gets its collected spectra from a list while the child class
SpectrumPlotgets its collected spectrum from app_state.
SpectrumPlotGenericreads from its own_collected_spectra: List[Spectrum], populated manually by callingadd_collected_spectrum(spectrum)(:1366). It connects to no app-state signals.SpectrumPlotconnects toApplicationStatesignals in its constructor (:1694-1700) and syncs automatically: the active spectrum via_on_active_spectrum_changed(:1901), the collected list via_on_collected_spectra_changed(:1959), and spectral libraries via_on_spectral_library_added(:1835) /_on_spectral_library_removed.
Aspect |
|
|
|---|---|---|
Collected-spectra source |
own |
|
Active spectrum |
none |
synced from |
App-state signal wiring |
none |
|
Dataset-chooser toolbar |
no |
yes ( |
Collect-spectrum / load-library actions |
no |
yes |
Spectral libraries |
not shown |
loaded, shown, hidden |
Plot rendering, axis/tick/font/legend config, mouse selection |
yes (defined here) |
inherited |
When to use which#
Use
SpectrumPlotGenericwhen you have your own set ofSpectrumobjects and want a plot window without app-state integration — e.g. a Tools-menu plugin or a results dialog that compares a target against library matches.Use
SpectrumPlotfor the main application’s live spectrum window, where the plot must follow the user’s active spectrum, collected spectra, and loaded libraries automatically.
Using SpectrumPlotGeneric#
Instantiate it directly, add Spectrum objects, and apply any display config via
the setters. A real example is the spectral-computation results dialog in
src/wiser/gui/generic_spectral_tool.py (around :897):
plot_widget = SpectrumPlotGeneric(
app_state=self._app_state,
parent=self.parent(),
)
layout.addWidget(plot_widget)
# Push spectra in directly — no signals involved.
plot_widget.add_collected_spectrum(target)
for rec in rows[:5]:
plot_widget.add_collected_spectrum(rec["ref_obj"])
Configuration setters available from the base class include set_title(),
set_legend(), set_x_range() / set_y_range(), set_x_label() /
set_y_label(), the tick-interval setters, and set_font_size() /
set_font_name().
Region of Interest (ROI) model#
An ROI is a composite of one or more Selections, so a single named region can
mix rectangles, polygons, and loose pixels. The aggregation in
get_all_pixels() deduplicates across selections, so overlapping selections
never double-count a pixel.
classDiagram
direction TB
class RegionOfInterest {
raster/roi.py :12
+_selections : List~Selection~
+name / color / id
+get_all_pixels() Set
+get_bounding_box()
}
class Selection {
raster/selection.py :31
+get_type() SelectionType
+get_all_pixels()
}
class SelectionType {
<<enumeration>>
SINGLE_PIXEL
MULTI_PIXEL
RECTANGLE
POLYGON
PREDICATE
}
RegionOfInterest --> Selection : contains many
Selection --> SelectionType : tagged by
Each concrete Selection subtype implements get_all_pixels() to yield its
pixel coordinates; RegionOfInterest.get_all_pixels() unions them into a single
deduplicated set of (x, y) tuples.
Spectrum class hierarchy#
A Spectrum is the common, lazily-evaluated wrapper that the plot consumes. The
abstract base defines the interface (get_spectrum(), get_wavelengths(),
get_bad_bands(), num_bands(), color/name/id). Dataset-derived spectra defer
the actual computation until the first get_spectrum() call and cache the result.
classDiagram
direction TB
class Spectrum {
raster/spectrum.py :175
<<abstract>>
+get_spectrum() ndarray
+get_wavelengths()
+get_bad_bands()
+num_bands()
}
class RasterDataSetSpectrum {
raster/spectrum.py :487
<<abstract>>
+_spectrum (cached, lazy)
+_calculate_spectrum()
+_reset_internal_state()
}
class ROIAverageSpectrum {
raster/spectrum.py :706
+set_avg_mode()
}
class SpectrumAtPoint {
raster/spectrum.py :629
single pixel / area average
}
class NumPyArraySpectrum {
raster/spectrum.py :336
wraps a precomputed array
}
Spectrum <|-- RasterDataSetSpectrum : subclass
Spectrum <|-- NumPyArraySpectrum : subclass
RasterDataSetSpectrum <|-- ROIAverageSpectrum : subclass
RasterDataSetSpectrum <|-- SpectrumAtPoint : subclass
Lazy evaluation. RasterDataSetSpectrum.get_spectrum() computes _spectrum
only on first access by calling the subclass’s _calculate_spectrum(), then
caches it. Changing configuration (e.g. ROIAverageSpectrum.set_avg_mode() or a
SpectrumAtPoint area change) calls _reset_internal_state(), which invalidates
the cache so the next get_spectrum() recomputes. This means constructing a
spectrum is cheap; the cost is paid the first time it is actually plotted.
For example, ROIAverageSpectrum._calculate_spectrum() (:733) simply delegates
to the calculation pipeline below:
def _calculate_spectrum(self):
self._spectrum = calc_roi_spectrum(self._dataset, self._roi, self._avg_mode)
How spectra are calculated#
The aggregation math lives in
src/wiser/raster/spectrum.py. A spectrum over multiple
pixels is the per-band mean or median across those pixels, selected by
SpectrumAverageMode (MEAN or MEDIAN). Both reductions are NaN-aware
(np.nanmean / np.nanmedian, :96-108), so bad bands and data-ignore values
(stored as NaN) are excluded without any explicit masking.
The performance-sensitive part is reading the pixels. Accessing a raster pixel
by pixel is slow, so calc_spectrum_fast (:50, wrapped by calc_roi_spectrum
at :163) converts the ROI into a small number of rectangular blocks and reads
each block in one call:
flowchart TB
A["ROI"] --> B["create_raster_from_roi() — binary mask"]
B --> C["RLE rectangle-pack along X axis"]
B --> D["RLE rectangle-pack along Y axis"]
C --> E["pick the axis with fewer rectangles"]
D --> E
E --> F["read each block: get_all_bands_at_rect()"]
F --> G["stack pixel spectra"]
G --> H["np.nanmean / np.nanmedian per band"]
H --> I["1-D spectrum array"]
The run-length-encoding (RLE) rectangle packing is implemented in
src/wiser/raster/roi_utils.py
(raster_to_combined_rectangles_x_axis / _y_axis); the code packs along both
axes and keeps whichever produces fewer rectangles. For a plain rectangular
region there is a simpler single-block path, calc_rect_spectrum (:113), which
reads the whole rectangle at once and reduces over the spatial dimensions.
Threading note. These calculations run synchronously on the Qt event loop. For very large ROIs the first
get_spectrum()can briefly block the UI; there is no background worker for spectrum computation today.
End-to-end data flow#
Putting it together, here is the path from “user asks for an ROI’s average spectrum” to “a line appears on the plot”:
flowchart TB
A["User: 'Show ROI average spectrum' (RasterPane)"]
B["_on_show_roi_avg_spectrum() — rasterpane.py:1792"]
C["build ROIAverageSpectrum(dataset, roi)"]
D["app_state.set_active_spectrum() — app_state.py:776"]
E["emit active_spectrum_changed"]
F["SpectrumPlot._on_active_spectrum_changed() — :1901"]
G["spectrum.get_spectrum() — lazy calc_roi_spectrum()"]
H["SpectrumDisplayInfo.generate_plot()"]
I["matplotlib Line2D drawn (wavelengths or band index)"]
A --> B --> C --> D --> E --> F --> G --> H --> I
The pixel-click path is analogous: clicking a pixel in a raster view builds a
SpectrumAtPoint (single pixel, or an area average around it) and likewise calls
set_active_spectrum(), so it flows through the same signal and rendering steps.
“Collecting” the active spectrum (_on_collect_spectrum, :1947) promotes it
into the collected list, which emits collected_spectra_changed and adds a
second, persistent line.
Key signals reference#
SpectrumPlot listens to these ApplicationState signals
(src/wiser/gui/app_state.py):
Signal |
Fired when |
Plot handler |
|---|---|---|
|
active spectrum set/cleared ( |
|
|
a spectrum is added to / removed from the collected list |
|
|
a spectral library is loaded |
|
|
a spectral library is unloaded |
|
|
a dataset is closed |
|
The plot widget itself emits one signal: closed (spectrum_plot.py:497),
fired when the plot window is closed so the rest of the app can react.