Real-time spectrum waterfalls are one of the most useful visualization tools when you are exploring a local RF environment. This tutorial walks through a compact, practical architecture for a Python-based waterfall that reads complex IQ from an RTL-SDR class device, computes short-time FFTs, and renders a continuously updating waterfall display with pyqtgraph. The goal is a friendly, low-latency viewer you can extend for logging, detector callbacks, or automated scanning.

Why this approach works

The pieces are simple and well supported: acquire complex IQ from an RTL2832U-based dongle using pyrtlsdr, compute FFTs with NumPy, and render using pyqtgraph ImageView or ImageItem. pyrtlsdr gives a straightforward Python interface to librtlsdr and supports both blocking reads and asynchronous streaming, which we use to keep the GUI responsive. Use NumPy FFT routines for windowed transforms and use pyqtgraph setImage operations to update the waterfall quickly.

High level architecture

  • Acquisition thread or async stream: read blocks of complex IQ from the SDR without blocking the GUI. pyrtlsdr supports standard reads and streaming helpers.
  • Processing: apply a window, perform an FFT, convert to power in dB, shift the spectrum to center frequency ordering, and scale for display using sensible min/max clip values. Use np.fft and fftshift for frequency alignment.
  • Display: maintain a rolling 2D numpy array as the waterfall buffer and push updates into pyqtgraph’s ImageView or ImageItem using setImage. ImageView supports fast interactive display of 2D image stacks and setImage accepts numpy ndarrays directly.

Required packages

Install the usual stack with pip or your package manager:

  • pyrtlsdr (Python wrapper for librtlsdr).
  • numpy
  • scipy (optional: window functions)
  • pyqt5 or pyqt6 / PySide2 as your Qt binding
  • pyqtgraph

Basic parameters to tune

  • sample_rate: the SDR sample rate in samples per second. Typical RTL-SDR values are 1e6 to 2.4e6.
  • fft_size: number of points for the short-time FFT. Larger values give finer frequency resolution but increase processing cost and latency.
  • overlap: percentage of overlap between successive FFT frames. Common values are 0.5 to 0.75.
  • waterfall_height: number of rows to keep in the display buffer. This sets how many past frames are visible.

Practical code (single-file example)

Below is a compact, runnable example. It uses a worker thread to pull samples and the Qt main thread to render. It is written for clarity rather than maximum throughput. If you need more speed, consider using pyfftw, numba, or a C extension for the FFTs.

from PyQt5 import QtWidgets, QtCore import pyqtgraph as pg import numpy as np from rtlsdr import RtlSdr import scipy.signal as signal import time

class SDRWorker(QtCore.QObject): new_row = QtCore.pyqtSignal(np.ndarray)

def __init__(self, center_freq=100e6, sample_rate=2.4e6, fft_size=1024, gain='auto'):
    super().__init__()
    self.sdr = RtlSdr()
    self.sdr.center_freq = center_freq
    self.sdr.sample_rate = sample_rate
    self.sdr.gain = gain
    self.fft_size = fft_size
    self.window = np.hanning(fft_size)
    self.running = False

def start(self):
    self.running = True
    # Read blocks in a loop; read_samples is simple and portable.
    # For lower latency consider the async stream API in pyrtlsdr.
    while self.running:
        samples = self.sdr.read_samples(self.fft_size)
        # Apply window, compute FFT and convert to dB
        spec = np.fft.fftshift(np.fft.fft(samples * self.window, n=self.fft_size))
        power = 20*np.log10(np.abs(spec) + 1e-12)
        # Optional: normalize or clip
        power = np.clip(power, -120, -10)
        # Send as a real-valued float32 row for display
        self.new_row.emit(power.astype(np.float32))
        # Small sleep to avoid CPU spin; tune as needed
        time.sleep(0.01)

def stop(self):
    self.running = False
    try:
        self.sdr.close()
    except Exception:
        pass

class WaterfallApp(QtWidgets.QMainWindow): def init(self): super().init() self.setWindowTitle(‘Realtime Waterfall’) self.img_view = pg.ImageView() self.setCentralWidget(self.img_view)

    # Config
    self.fft_size = 1024
    self.waterfall_height = 400
    self.width = self.fft_size

    # Rolling buffer initialized low
    self.waterfall = np.full((self.waterfall_height, self.width), -120.0, dtype=np.float32)

    # Create worker and thread
    self.worker = SDRWorker(center_freq=100e6, sample_rate=2.4e6, fft_size=self.fft_size)
    self.thread = QtCore.QThread()
    self.worker.moveToThread(self.thread)
    self.worker.new_row.connect(self.on_new_row)
    self.thread.started.connect(self.worker.start)
    self.thread.start()

    # Set colormap and display properties
    cmap = pg.colormap.get('viridis')
    self.img_view.setColorMap(cmap)
    self.img_view.ui.histogram.hide()
    self.img_view.setImage(self.waterfall, autoLevels=False)

def on_new_row(self, row):
    # row is length fft_size, center-ordered by fftshift
    self.waterfall = np.roll(self.waterfall, -1, axis=0)
    # Insert new row at bottom. Optionally convert to display orientation.
    self.waterfall[-1, :] = row
    # Update display; setImage with autoLevels False is faster for streaming
    self.img_view.setImage(self.waterfall, autoLevels=False)

def closeEvent(self, ev):
    self.worker.stop()
    self.thread.quit()
    self.thread.wait()
    super().closeEvent(ev)

if name == ‘main’: import sys app = QtWidgets.QApplication(sys.argv) win = WaterfallApp() win.show() sys.exit(app.exec_())

Notes on this implementation

  • FFT ordering: the example uses fft and fftshift to place negative frequencies on the left and positive on the right. That makes a centered waterfall around center_freq. Use np.fft.fft (complex input) and np.fft.fftshift for the proper ordering.
  • Rolling buffer: np.roll is simple but moves memory. For higher performance allocate a prefilled buffer and maintain an index pointer to avoid copies.
  • Windowing: Hann or Hamming windows reduce spectral leakage. Adjust depending on whether you want peak amplitude accuracy or cleaner spectral lines.
  • Asynchronous streaming: pyrtlsdr supports streaming APIs and other helpers. For production you may prefer the async stream callback to reduce read latency and get consistent block timing.
  • Display: pyqtgraph ImageView exposes setImage which accepts a 2D numpy array. Disabling autoLevels and controlling contrast explicitly keeps frame-to-frame rendering stable and faster. For more advanced rendering use ImageItem and update only the underlying image data array.

Performance tips

  • FFT acceleration: pyfftw or pocketfft backends can reduce CPU usage for large FFTs. NumPy uses efficient FFT libraries but specialized backends may be faster on some systems.
  • Reduce GUI overhead: update the display at 10 to 30 frames per second rather than every FFT frame and batch multiple FFTs into one row if needed.
  • Use a worker thread or process for acquisition and DSP. Doing heavy work in the GUI thread will block Qt event handling and produce a frozen window.

Sweep-style logging vs real-time waterfall

Tools like rtl_power perform wideband sweeps to build a long-term power map by tuning the device across a frequency range. That approach is complementary to the real-time waterfall described here. Sweeps are good for wideband coverage and long-term occupancy maps, but they are not the same as a continuous baseband waterfall centered on a single frequency.

Safety and legal reminder

This tutorial only reads signals. Do not transmit without proper authorization. Many jurisdictions restrict intentional RF emissions and the use of certain bands. Follow local laws and spectrum rules.

Wrap up

A compact Python waterfall combines pyrtlsdr acquisition, NumPy FFTs, and pyqtgraph display for a capable, extendable viewer. Start with the simple implementation above, then incrementally improve performance with alternative FFT backends, lower-copy buffering, and asynchronous streaming as your use case demands. The cited project docs are the best source for device-specific details and advanced streaming APIs.