Preprocessing

The preprocessing package provides utilities for preparing and transforming HD-sEMG signals before analysis. Currently, it focuses on differential signal computation, which is a common preprocessing step in EMG analysis.


Differential Analysis

The package provides functionality to compute differential signals from HD-sEMG channels, with optional filtering. This is particularly useful for reducing common-mode noise and enhancing local muscle activity detection.

API Reference

def to_differential(
    mats: list[np.ndarray],
    sr: float,
    f: dict[str, float]
) -> tuple[list[np.ndarray], list[np.ndarray]]:
    """Compute differential channels with optional filtering."""

Parameters

  • mats (list[np.ndarray]):

    • List of 2D arrays containing EMG signals
    • Each array shape: (j, T), where:
      • j: number of channels
      • T: number of time samples
    • Type: float
  • sr (float):

    • Sampling rate in Hz
    • Used for the bandpass filter if applied
  • f (dict[str, float]):

    • Filter parameters dictionary with keys:
      • 'n': filter order (int)
      • 'low': lower cutoff frequency in Hz
      • 'up': upper cutoff frequency in Hz

Returns

  • dmat (list[np.ndarray]):

    • List of filtered differential signals
    • Each array shape: (j-1, T)
    • Bandpass filtered using specified parameters
  • dmat_no_filter (list[np.ndarray]):

    • List of unfiltered differential signals
    • Each array shape: (j-1, T)
    • Raw differences between adjacent channels

Example Usage

import numpy as np
from hdsemg_shared.preprocessing.differential import to_differential

# Create sample EMG data (2 matrices, 8 channels each, 1000 samples)
emg1 = np.random.randn(8, 1000)
emg2 = np.random.randn(8, 1000)
mats = [emg1, emg2]

# Define filter parameters
filter_params = {
    'n': 4,      # 4th order Butterworth
    'low': 20,   # 20 Hz highpass
    'up': 450    # 450 Hz lowpass
}

# Compute differential signals
diff_filtered, diff_raw = to_differential(
    mats=mats,
    sr=2000,     # 2kHz sampling rate
    f=filter_params
)

# Results:
# - diff_filtered[0].shape == (7, 1000)  # Filtered differentials from emg1
# - diff_filtered[1].shape == (7, 1000)  # Filtered differentials from emg2
# - diff_raw[0].shape == (7, 1000)       # Raw differentials from emg1
# - diff_raw[1].shape == (7, 1000)       # Raw differentials from emg2

Implementation Details

The differential computation: 1. Takes adjacent pairs of channels (j and j+1) 2. Computes their difference (channel_j+1 - channel_j) 3. Optionally applies a bandpass filter to the differential signals 4. Returns both filtered and unfiltered versions

This results in j-1 differential channels for j input channels.

Best Practices

  1. Signal Preparation:
  2. Ensure input signals are properly scaled
  3. Remove DC offset if present
  4. Check for artifacts or bad channels before computing differentials

  5. Filter Parameter Selection:

  6. Use standard EMG frequency bands (20-450 Hz typical)
  7. Match filter order to your signal quality needs
  8. Consider edge effects in filtered signals

  9. Channel Ordering:

  10. Ensure channels are ordered correctly spatially
  11. Verify channel alignment matches your electrode layout
  12. Document channel arrangement for reproducibility

Common Use Cases

  • Reducing common-mode noise
  • Enhancing local muscle activity detection
  • Preprocessing for motor unit decomposition
  • Improving spatial resolution of EMG recordings

Grid Mapping

hdsemg_shared.preprocessing.grid_map turns a flat channel matrix into HDsEMG grid columns. It only selects and orders — no filtering, no differentiation.

The emg_map format

Follows openhdemg's custom_sorting_order: a list of lists (or a 2-D array) where the outer index is the grid column and the inner index is the row, holding base-0 channel numbers, with np.nan at positions that carry no electrode or were excluded.

emg_map = [[0, 1,  2,  3     ],   # col0
           [4, 5,  6,  7     ],   # col1
           [8, 9, 10, np.nan]]    # col2, one gap

API Reference

map_to_columns(emg_channels, emg_map)
  • emg_channels — rows are channels, columns are samples. May hold more channels than the map refers to (force and path references, for example). EMGFile.data is samples-by-channels, so pass its transpose.
  • returns a list of nCols matrices, each nRows x nSamples, in map order. A NaN map entry becomes an all-NaN row.

Raises ValueError on a ragged map, an entry that is not a valid channel number, or a channel used more than once.

emg_map_from_indices(emg_indices, rows, cols, order='column')

Builds a map from a Grid's flat emg_indices plus rows/cols. order='column' (default) means the channels run down the first column before moving to the next — this matches MATLAB's mat2grid. order='row' transposes that.

Grids with an unwired position — HD08MM1305 has 64 electrodes in 5x13 = 65 positions — cannot be described this way and raise. Pass an explicit map with NaN at the empty position instead; openhdemg's sort_rawemg and hdsemg-select's electrode display grid both produce one.

Example Usage

import numpy as np
from hdsemg_shared.fileio.file_io import EMGFile
from hdsemg_shared.preprocessing.grid_map import emg_map_from_indices, map_to_columns
from hdsemg_shared.preprocessing.differential import to_differential

emg_file = EMGFile.load("recording.otb+")
grid = emg_file.grids[0]

emg_map = emg_map_from_indices(grid.emg_indices, grid.rows, grid.cols)
columns = map_to_columns(emg_file.data.T, emg_map)

dmat, dmat_no_filter = to_differential(columns, emg_file.sampling_frequency,
                                       {"n": 2, "low": 20.0, "up": 450.0})

hdsemg_shared.global_parameters.global_amplitude takes the emg_map directly and does the mapping and the MP/SD/DD differentiation itself.

Source Code

The implementations can be found in src/hdsemg_shared/preprocessing/ — differential.py and grid_map.py. The code includes type hints and detailed docstrings for easy integration with development tools.


API Documentation

to_differential(mats, sr, f)

Exact Python replica of MATLAB toDifferential.

Parameters

mats : list of ndarray k matrices, each shape (j, T) where j = number of monopolar EMG channels and T = samples. sr : float Sampling rate [Hz]. f : dict Filter options – must contain keys 'n' : even Butterworth order (MATLAB style) 'low' : lower cut-off frequency [Hz] 'up' : upper cut-off frequency [Hz]

Returns

dmat : list of ndarray Filtered single-differential signals, each (j-1, T). dmatNoF : list of ndarray Unfiltered single-differential signals, each (j-1, T).

Source code in hdsemg_shared/preprocessing/differential.py
def to_differential(
    mats: List[np.ndarray],
    sr: Union[int, float],
    f: Dict[str, Union[int, float]],
) -> Tuple[List[np.ndarray], List[np.ndarray]]:
    """
    Exact Python replica of MATLAB `toDifferential`.

    Parameters
    ----------
    mats : list of ndarray
        `k` matrices, each shape (j, T) where j = number of monopolar
        EMG channels and T = samples.
    sr : float
        Sampling rate [Hz].
    f : dict
        Filter options – must contain keys
            'n'   : even Butterworth order (MATLAB style)
            'low' : lower cut-off frequency [Hz]
            'up'  : upper cut-off frequency [Hz]

    Returns
    -------
    dmat        : list of ndarray
        Filtered single-differential signals, each (j-1, T).
    dmatNoF     : list of ndarray
        Unfiltered single-differential signals, each (j-1, T).
    """

    # --------------- 1. sanity checks ----------
    if not mats:
        raise ValueError("`mats` must contain at least one matrix.")
    first_rows = mats[0].shape[0]
    if first_rows < 2:
        raise ValueError("Each matrix must have at least two rows (channels).")

    order = int(f["n"])
    fcl   = float(f["low"])
    fch   = float(f["up"])

    if order < 2 or order % 2:
        raise ValueError("Filter order 'n' must be an even integer ≥ 2.")

    dmat: List[np.ndarray]       = [None] * len(mats)
    dmatNoF: List[np.ndarray]    = [None] * len(mats)

    # --------------- 2. loop -----------------------
    for k, mat in enumerate(mats):
        mat = np.asarray(mat, dtype=np.float64)
        if mat.ndim != 2:
            raise ValueError("Each element of `mats` must be 2-D (channels × samples).")
        rows, T = mat.shape
        if rows != first_rows:
            raise ValueError("All matrices must have the same number of rows as mats[0].")

        diff_raw  = np.empty((rows - 1, T), dtype=np.float64)
        diff_filt = np.empty_like(diff_raw)

        for j in range(rows - 1):
            delta = mat[j + 1, :] - mat[j, :]
            diff_raw[j, :]  = delta
            diff_filt[j, :] = bandpass_filter(delta, order, fcl, fch, sr)

        dmat[k]       = diff_filt
        dmatNoF[k]    = diff_raw

    return dmat, dmatNoF

GRID_MAP: turning a channel matrix into HDsEMG grid columns

An emg_map says which channel of a recording sits at which electrode position. The format follows openhdemg's custom_sorting_order: a list of lists (or a 2-D array) where the OUTER index is the grid COLUMN and the INNER index is the ROW, holding base-0 channel numbers, with NaN at positions that carry no electrode or were excluded.

emg_map = [[0, 1,  2,  3     ],   # col0
           [4, 5,  6,  7     ],   # col1
           [8, 9, 10, np.nan]]    # col2, one gap

Nothing here filters or differentiates - it only selects and orders.

ADDITION of the Python implementation, no MATLAB equivalent (MATLAB's mat2grid takes gridSize from the caller's input structure).

(c) H Penasso. Written for hdsemg-shared by Claude Opus 5, 2026-08-19, from the MATLAB conventions of Global-HDsEMG-Analysis.

emg_map_from_indices(emg_indices, rows, cols, order='column')

EMG_MAP_FROM_INDICES builds an emg_map from a flat list of channel numbers

emg_map = emg_map_from_indices(grid.emg_indices, grid.rows, grid.cols)

INPUT emg_indices ... sequence of base-0 channel numbers, in acquisition order, exactly rows*cols of them [] rows ... number of electrode rows of the grid [] cols ... number of electrode columns of the grid [] order ... char, 'column' (default) if the channels run down the first column before moving to the next, 'row' if they run along the first row []

OUTPUT emg_map ... 2-D array, cols-by-rows, base-0 channel numbers, ready for map_to_columns []

RAISES ValueError ... if rows or cols are not positive, if order is unknown, or if len(emg_indices) != rows*cols

INFO ... grids with an unwired position, e.g. HD08MM1305 with 64 electrodes in 5x13 = 65 positions, cannot be described this way. Pass an explicit map with NaN at the empty position instead - openhdemg's sort_rawemg and hdsemg-select's electrode display grid both produce one

Source code in hdsemg_shared/preprocessing/grid_map.py
def emg_map_from_indices(emg_indices, rows, cols, order = 'column'):
    """
    EMG_MAP_FROM_INDICES builds an emg_map from a flat list of channel numbers

      emg_map = emg_map_from_indices(grid.emg_indices, grid.rows, grid.cols)

      INPUT
        emg_indices ... sequence of base-0 channel numbers, in acquisition
                         order, exactly rows*cols of them []
        rows        ... number of electrode rows of the grid []
        cols        ... number of electrode columns of the grid []
        order       ... char, 'column' (default) if the channels run down the
                         first column before moving to the next, 'row' if
                         they run along the first row []

      OUTPUT
        emg_map     ... 2-D array, cols-by-rows, base-0 channel numbers, ready
                         for map_to_columns []

      RAISES
        ValueError ... if rows or cols are not positive, if order is unknown,
                        or if len(emg_indices) != rows*cols

      *INFO* ... grids with an unwired position, e.g. HD08MM1305 with 64
                  electrodes in 5x13 = 65 positions, cannot be described this
                  way. Pass an explicit map with NaN at the empty position
                  instead - openhdemg's sort_rawemg and hdsemg-select's
                  electrode display grid both produce one
    """
    if rows < 1 or cols < 1:
        raise ValueError(f"rows and cols must be positive, got {rows} and {cols}.")
    if order not in ('column', 'row'):
        raise ValueError(f"order must be 'column' or 'row', got {order!r}.")

    idx = np.asarray(emg_indices, dtype=np.float64).ravel()
    if idx.size != rows * cols:
        raise ValueError(
            f"emg_indices holds {idx.size} channels but rows*cols is {rows * cols}. "
            "A grid with an unwired position needs an explicit map with NaN there."
        )

    if order == 'column':
        return idx.reshape(cols, rows)

    # Acquisition ran along the rows, so reshape row-wise and transpose
    return idx.reshape(rows, cols).T

map_to_columns(emg_channels, emg_map)

MAP_TO_COLUMNS splits a channel matrix into one matrix per grid column

columns = map_to_columns(emg.T, emg_map)

INPUT emg_channels ... matrix, each row contains one channel's signal over time (rows = channels, columns = samples). May hold more channels than the map refers to, e.g. force or path references [xV] emg_map ... list of lists or 2-D array, outer index = grid column, inner index = row, entries are base-0 channel numbers into emg_channels, NaN where no electrode sits []

OUTPUT columns ... list of nCols matrices, each nRows-by-nSamples, in map order. A NaN map entry becomes an all-NaN row [xV]

RAISES ValueError ... if the map is ragged or not 2-D, if an entry is not a valid row index of emg_channels, or if a channel is used more than once

INFO ... EMGFile.data is samples-by-channels, so pass its transpose INFO ... excluding a channel means putting NaN in its map position; global_amplitude then drops it, matching the MATLAB 'INFO NaN channels are ignored'

Source code in hdsemg_shared/preprocessing/grid_map.py
def map_to_columns(emg_channels, emg_map):
    """
    MAP_TO_COLUMNS splits a channel matrix into one matrix per grid column

      columns = map_to_columns(emg.T, emg_map)

      INPUT
        emg_channels ... matrix, each row contains one channel's signal over
                          time (rows = channels, columns = samples). May hold
                          more channels than the map refers to, e.g. force or
                          path references [xV]
        emg_map      ... list of lists or 2-D array, outer index = grid
                          column, inner index = row, entries are base-0
                          channel numbers into emg_channels, NaN where no
                          electrode sits []

      OUTPUT
        columns      ... list of nCols matrices, each nRows-by-nSamples, in
                          map order. A NaN map entry becomes an all-NaN row
                          [xV]

      RAISES
        ValueError ... if the map is ragged or not 2-D, if an entry is not a
                        valid row index of emg_channels, or if a channel is
                        used more than once

      *INFO* ... EMGFile.data is samples-by-channels, so pass its transpose
      *INFO* ... excluding a channel means putting NaN in its map position;
                  global_amplitude then drops it, matching the MATLAB
                  '*INFO* NaN channels are ignored'
    """
    emg = np.asarray(emg_channels, dtype=np.float64)
    if emg.ndim != 2:
        raise ValueError(f"emg_channels must be 2-D (channels x samples), got {emg.ndim}-D.")

    grid = _as_map(emg_map)
    _check_indices(grid, emg.shape[0])

    # Build one matrix per column, NaN rows where the map has a gap
    columns = []
    for col in grid:
        block = np.full((col.size, emg.shape[1]), np.nan, dtype=np.float64)
        wired = ~np.isnan(col)
        block[wired] = emg[col[wired].astype(int)]
        columns.append(block)
    return columns