Repository: mne-tools/mne-python

Issue:
ENH: Allow list-of-label in source_band_induced_power
[mne.minimum_norm.source_band_induced_power](https://mne.tools/dev/generated/mne.minimum_norm.source_band_induced_power.html) has a `label` param, but in principle it should be possible to return results for multiple labels. Looking at the algorithm, our implementation appears to be:

1. Compute inverse operator `K` restricted to a label:

   https://github.com/mne-tools/mne-python/blob/6a701d39b24c337c59295b38d88fd7e5ebaa25ef/mne/minimum_norm/time_frequency.py#L372

2. Compute PSDs for each epoch in parallel:

   https://github.com/mne-tools/mne-python/blob/6a701d39b24c337c59295b38d88fd7e5ebaa25ef/mne/minimum_norm/time_frequency.py#L396-L397

   Within each parallel split, take the sum across each epochs:

   https://github.com/mne-tools/mne-python/blob/9547c139c051f45440690c0b72da4c399b5a9368/mne/minimum_norm/time_frequency.py#L292-L294

3. Take the sum across parallel-splits-of-epochs:

   https://github.com/mne-tools/mne-python/blob/6a701d39b24c337c59295b38d88fd7e5ebaa25ef/mne/minimum_norm/time_frequency.py#L412-L413

4. Compute PLV if requested (only used by `source_induced_power`, never `source_band_induced_power`), noise normalize (i.e., multiply each vertex by some value), baseline correct, return `power` and `plv`.

So I think we should in principle be able to add support for list-of-labels for `source_band_induced_power` and probably also `source_induced_power` when `return_plv=False` by doing the following:

1. Document the shape of the ndarray that is currently returned (something like `(n_vertices, n_freqs, n_times)`)
2. Add `return_plv=True` to `source_induced_power` (the way it acts currently)
3. Fix documentation of `source_induced_power` to note that it returns both `power` and `plv` (when `return_plv=True`) and add return shapes
4. Fix documentation to use `(baseline_mode)s)` or whatever rather than repeat all options.
5. Add some small test that actually checks our `source_band_induced_power` values (e.g., `assert_allclose(phase_lock[some_indices], [[some_array_values]])`). This will be useful for all following changes.
6. Move `noise_norm` inside the parallel function.
7. Add support for list-of-label by computing a new `label_op` where `label_op @ K` averages across vertices to convert the `(n_vertices, n_channels)` operator `K` to be shape `(n_labels, n_channels`). This should only be allowed when `with_plv`/`return_plv=False`, which is always the case for `source_band_induced_power` and will now optionally be possible for `source_band_power` by step (3) above.
8. Add test that `label=label` with a `.mean(axis=0)` gives the same result as `label=[label]` when `baseline_mode` is `None` or `mean`, and hopefully sufficiently similar for other modes (shouldn't have a huge impact).

For point (8), it would be nice if list-of-label with one element `[label]` returned an identical to the `label=label` (single label) in all cases, but it wouldn't with these changes. The baseline correction in the list-of-label case will be done *after* the power is averaged within the label, whereas in the `label=label` case (like in our [existing example](https://mne.tools/dev/auto_examples/time_frequency/source_label_time_frequency.html#sphx-glr-auto-examples-time-frequency-source-label-time-frequency-py)) the averaging is done over *already baseline-corrected vertex values* within the label. I'm not certain which way is better, but 1) I doubt it makes much difference and 2) I think in principle averaging first is probably better / higher SNR, as averaging across vertices within a label first should in principle increase SNR before something like a z-transform, if used, could otherwise amplify noisy values. But either way I think if we explain the difference clearly in `Notes` I think we're okay.


Discussion:

Resolving patch:
diff --git a/examples/time_frequency/source_label_time_frequency.py b/examples/time_frequency/source_label_time_frequency.py
--- a/examples/time_frequency/source_label_time_frequency.py
+++ b/examples/time_frequency/source_label_time_frequency.py
@@ -35,8 +35,8 @@
 meg_path = data_path / "MEG" / "sample"
 raw_fname = meg_path / "sample_audvis_raw.fif"
 fname_inv = meg_path / "sample_audvis-meg-oct-6-meg-inv.fif"
-label_name = "Aud-rh"
-fname_label = meg_path / "labels" / f"{label_name}.label"
+label_names = ["Aud-lh", "Aud-rh"]
+fname_labels = [meg_path / "labels" / f"{ln}.label" for ln in label_names]
 
 tmin, tmax, event_id = -0.2, 0.5, 2
 
@@ -70,7 +70,8 @@
 # Compute a source estimate per frequency band including and excluding the
 # evoked response
 freqs = np.arange(7, 30, 2)  # define frequencies of interest
-label = mne.read_label(fname_label)
+labels = [mne.read_label(fl) for fl in fname_labels]
+label = labels[0]
 n_cycles = freqs / 3.0  # different number of cycle per frequency
 
 # subtract the evoked response in order to exclude evoked activity
@@ -122,3 +123,44 @@
     )
     ax.set(xlabel="Time (s)", ylabel="Frequency (Hz)", title=f"ITC ({title})")
     fig.colorbar(ax.images[0], ax=axes[ii])
+
+# %%
+
+##############################################################################
+# In the example above, we averaged power across vertices after calculating
+# power because we provided a single label for power calculation and therefore
+# power of all sources within the single label were returned separately. When
+# we provide a list of labels, power is averaged across sources within each
+# label automatically. With a list of labels, averaging is performed before
+# rescaling, so choose a baseline method appropriately.
+
+
+# Get power from multiple labels
+multi_label_power = source_induced_power(
+    epochs,
+    inverse_operator,
+    freqs,
+    labels,
+    baseline=(-0.1, 0),
+    baseline_mode="mean",
+    n_cycles=n_cycles,
+    n_jobs=None,
+    return_plv=False,
+)
+
+# visually compare evoked power in left and right auditory regions
+fig, axes = plt.subplots(ncols=2, layout="constrained")
+for l_idx, l_power in enumerate(multi_label_power):
+    ax = axes[l_idx]
+    ax.imshow(
+        l_power,
+        extent=[epochs.times[0], epochs.times[-1], freqs[0], freqs[-1]],
+        aspect="auto",
+        origin="lower",
+        vmin=multi_label_power.min(),
+        vmax=multi_label_power.max(),
+        cmap="RdBu_r",
+    )
+    title = f"{labels[l_idx].hemi.upper()} Evoked Power"
+    ax.set(xlabel="Time (s)", ylabel="Frequency (Hz)", title=title)
+    fig.colorbar(ax.images[0], ax=ax)
diff --git a/mne/minimum_norm/time_frequency.py b/mne/minimum_norm/time_frequency.py
--- a/mne/minimum_norm/time_frequency.py
+++ b/mne/minimum_norm/time_frequency.py
@@ -12,6 +12,7 @@
 from ..event import make_fixed_length_events
 from ..evoked import EvokedArray
 from ..fixes import _safe_svd
+from ..label import BiHemiLabel, Label
 from ..parallel import parallel_func
 from ..source_estimate import _make_stc
 from ..time_frequency.multitaper import (
@@ -21,7 +22,7 @@
     _psd_from_mt_adaptive,
 )
 from ..time_frequency.tfr import cwt, morlet
-from ..utils import ProgressBar, _check_option, logger, verbose
+from ..utils import ProgressBar, _check_option, _pl, _validate_type, logger, verbose
 from .inverse import (
     INVERSE_METHODS,
     _assemble_kernel,
@@ -33,6 +34,72 @@
 )
 
 
+def _restrict_K_to_lbls(labels, K, noise_norm, vertno, pick_ori):
+    """Use labels to choose desired sources in the kernel."""
+    verts_to_use = [[], []]
+    # create mask for K by compiling original vertices from vertno in labels
+    for ii in range(len(labels)):
+        lab = labels[ii]
+        # handle BiHemi labels; ok so long as no overlap w/ single hemi labels
+        if lab.hemi == "both":
+            l_verts = np.intersect1d(vertno[0], lab.lh.vertices)
+            r_verts = np.intersect1d(vertno[1], lab.rh.vertices)  # output sorted
+            verts_to_use[0] += list(l_verts)
+            verts_to_use[1] += list(r_verts)
+        else:
+            hidx = 0 if lab.hemi == "lh" else 1
+            verts = np.intersect1d(vertno[hidx], lab.vertices)
+            verts_to_use[hidx] += list(verts)
+
+    # check that we don't have overlapping vertices in our labels
+    for ii in range(2):
+        if len(np.unique(verts_to_use[ii])) != len(verts_to_use[ii]):
+            raise RuntimeError(
+                "Labels cannot have overlapping vertices. "
+                "Please select labels with unique vertices "
+                "and try again."
+            )
+
+    # turn original vertex numbers from vertno into indices for K
+    K_mask = np.searchsorted(vertno[0], verts_to_use[0])
+    r_kmask = np.searchsorted(vertno[1], verts_to_use[1]) + len(vertno[0])
+    K_mask = np.hstack((K_mask, r_kmask))
+
+    # record which original vertices are at each index in out_K
+    hemis = ("lh", "rh")
+    ki_keys = [
+        (hemis[hi], verts_to_use[hi][ii])
+        for hi in range(2)
+        for ii in range(len(verts_to_use[hi]))
+    ]
+    ki_vals = list(range(len(K_mask)))
+    k_idxs = dict(zip(ki_keys, ki_vals))
+
+    # mask K, handling the orientation issue
+    len_allverts = len(vertno[0]) + len(vertno[1])
+    if len(K) == len_allverts:
+        assert pick_ori == "normal"
+        out_K = K[K_mask]
+    else:
+        # here, K = [x0, y0, z0, x1, y1, z1 ...]
+        # we need to drop x, y and z of unused vertices
+        assert not pick_ori == "normal", pick_ori
+        assert len(K) == 3 * len_allverts, (len(K), len_allverts)
+        out_len = len(K_mask) * 3
+        out_K = K[0:out_len]  # get the correct-shaped array
+        for di in range(3):
+            K_pick = K[di::3]
+            out_K[di::3] = K_pick[K_mask]  # set correct values for out
+
+    out_vertno = verts_to_use
+    if noise_norm is not None:
+        out_nn = noise_norm[K_mask]
+    else:
+        out_nn = None
+
+    return out_K, out_nn, out_vertno, k_idxs
+
+
 def _prepare_source_params(
     inst,
     inverse_operator,
@@ -64,9 +131,26 @@ def _prepare_source_params(
     #   This does all the data transformations to compute the weights for the
     #   eigenleads
     #
-    K, noise_norm, vertno, _ = _assemble_kernel(
-        inv, label, method, pick_ori, use_cps=use_cps
-    )
+    # K shape: (3 x n_sources, n_channels) or (n_sources, n_channels)
+    # noise_norm shape: (n_sources, 1)
+    # vertno: [lh_verts, rh_verts]
+
+    k_idxs = None
+    if not isinstance(label, (Label, BiHemiLabel)):
+        whole_K, whole_noise_norm, whole_vertno, _ = _assemble_kernel(
+            inv, None, method, pick_ori, use_cps=use_cps
+        )
+        if isinstance(label, list):
+            K, noise_norm, vertno, k_idxs = _restrict_K_to_lbls(
+                label, whole_K, whole_noise_norm, whole_vertno, pick_ori
+            )
+        else:
+            assert not label
+            K, noise_norm, vertno = whole_K, whole_noise_norm, whole_vertno
+    elif isinstance(label, (Label, BiHemiLabel)):
+        K, noise_norm, vertno, _ = _assemble_kernel(
+            inv, label, method, pick_ori, use_cps=use_cps
+        )
 
     if pca:
         U, s, Vh = _safe_svd(K, full_matrices=False)
@@ -78,7 +162,7 @@ def _prepare_source_params(
         Vh = None
     is_free_ori = inverse_operator["source_ori"] == FIFF.FIFFV_MNE_FREE_ORI
 
-    return K, sel, Vh, vertno, is_free_ori, noise_norm
+    return K, sel, Vh, vertno, is_free_ori, noise_norm, k_idxs
 
 
 @verbose
@@ -114,8 +198,9 @@ def source_band_induced_power(
         The inverse operator.
     bands : dict
         Example : bands = dict(alpha=[8, 9]).
-    label : Label
-        Restricts the source estimates to a given label.
+    label : Label | list of Label
+        Restricts the source estimates to a given label or list of labels. If
+        labels are provided in a list, power will be averaged over vertices.
     lambda2 : float
         The regularization parameter of the minimum norm.
     method : "MNE" | "dSPM" | "sLORETA" | "eLORETA"
@@ -170,7 +255,10 @@ def source_band_induced_power(
     Returns
     -------
     stcs : dict of SourceEstimate (or VolSourceEstimate)
-        The estimated source space induced power estimates.
+        The estimated source space induced power estimates in shape
+        (n_vertices, n_frequencies, n_samples) if label=None or label=label.
+        For lists of one or more labels, the induced power estimate has shape
+        (n_labels, n_frequencies, n_samples).
     """  # noqa: E501
     _check_option("method", method, INVERSE_METHODS)
 
@@ -262,6 +350,7 @@ def _compute_pow_plv(
     with_plv,
     pick_ori,
     decim,
+    noise_norm=None,
     verbose=None,
 ):
     """Aux function for induced power and PLV."""
@@ -292,6 +381,9 @@ def _compute_pow_plv(
         if with_plv:
             plv += plv_e
 
+    if noise_norm is not None:
+        power *= noise_norm[:, :, np.newaxis] ** 2
+
     return power, plv
 
 
@@ -345,6 +437,41 @@ def _single_epoch_tfr(
     return tfr_e, plv_e
 
 
+def _get_label_power(power, labels, vertno, k_idxs):
+    """Average power across vertices in labels."""
+    (_, ps1, ps2) = power.shape
+    # construct out array with correct shape
+    out_power = np.zeros(shape=(len(labels), ps1, ps2))
+
+    # for each label, compile list of vertices we want
+    for li in np.arange(len(labels)):
+        lab = labels[li]
+        hemis = ("lh", "rh")
+        all_vnums = [[], []]
+        if lab.hemi == "both":
+            all_vnums[0] = np.intersect1d(lab.lh.vertices, vertno[0])
+            all_vnums[1] = np.intersect1d(lab.rh.vertices, vertno[1])
+        else:
+            assert lab.hemi == "lh" or lab.hemi == "rh"
+            h_id = 0 if lab.hemi == "lh" else 1
+            all_vnums[h_id] = np.intersect1d(vertno[h_id], lab.vertices)
+
+        verts = [(hemis[hi], vn) for hi in range(2) for vn in all_vnums[hi]]
+
+        # restrict power to relevant vertices in label
+        lab_mask = np.array([False] * len(power))
+        for vert in verts:
+            lab_mask[k_idxs[vert]] = True  # k_idxs[vert] gives power row index
+        lab_power = power[lab_mask]  # only pass through rows we want
+        assert lab_power.shape == (len(verts), ps1, ps2)
+
+        # set correct out values for label
+        out_power[li, :, :] = np.mean(lab_power, axis=0)
+
+    assert out_power.shape == (len(labels), ps1, ps2)
+    return out_power
+
+
 @verbose
 def _source_induced_power(
     epochs,
@@ -368,8 +495,29 @@ def _source_induced_power(
     verbose=None,
 ):
     """Aux function for source induced power."""
+    if label:
+        _validate_type(
+            label,
+            types=(Label, BiHemiLabel, list, tuple, None),
+            type_name=("Label or BiHemiLabel", "list of labels", "None"),
+        )
+        if isinstance(label, (list, tuple)):
+            for item in label:
+                _validate_type(
+                    item,
+                    types=(Label, BiHemiLabel),
+                    type_name=("Label or BiHemiLabel"),
+                )
+            if len(label) > 1 and with_plv:
+                raise RuntimeError(
+                    "Phase-locking value cannot be calculated "
+                    "when averaging induced power within "
+                    "labels. Please set `with_plv` to False, pass a "
+                    "single `label=label`, or set `label=None`."
+                )
+
     epochs_data = epochs.get_data()
-    K, sel, Vh, vertno, is_free_ori, noise_norm = _prepare_source_params(
+    K, sel, Vh, vertno, is_free_ori, noise_norm, k_id = _prepare_source_params(
         inst=epochs,
         inverse_operator=inverse_operator,
         label=label,
@@ -406,12 +554,26 @@ def _source_induced_power(
             with_power=True,
             pick_ori=pick_ori,
             decim=decim,
+            noise_norm=noise_norm,
         )
         for data in np.array_split(epochs_data, n_jobs)
     )
-    power = sum(o[0] for o in out)
+    power = sum(o[0] for o in out)  # power shape: (n_verts, n_freqs, n_samps)
     power /= len(epochs_data)  # average power over epochs
 
+    if isinstance(label, (Label, BiHemiLabel)):
+        logger.info(
+            f"Outputting power for {len(power)} vertices in label {label.name}."
+        )
+    elif isinstance(label, list):
+        power = _get_label_power(power, label, vertno, k_id)
+        logger.info(
+            "Averaging induced power across vertices within labels "
+            f"for {len(label)} label{_pl(label)}."
+        )
+    else:
+        assert not label
+
     if with_plv:
         plv = sum(o[1] for o in out)
         plv = np.abs(plv)
@@ -419,9 +581,6 @@ def _source_induced_power(
     else:
         plv = None
 
-    if noise_norm is not None:
-        power *= noise_norm[:, :, np.newaxis] ** 2
-
     return power, plv, vertno
 
 
@@ -442,6 +601,8 @@ def source_induced_power(
     baseline_mode="logratio",
     pca=True,
     n_jobs=None,
+    *,
+    return_plv=True,
     zero_mean=False,
     prepared=False,
     method_params=None,
@@ -460,8 +621,10 @@ def source_induced_power(
         The inverse operator.
     freqs : array
         Array of frequencies of interest.
-    label : Label
-        Restricts the source estimates to a given label.
+    label : Label | list of Label
+        Restricts the source estimates to a given label or list of labels. If
+        labels are provided in a list, power will be averaged over vertices within each
+        label.
     lambda2 : float
         The regularization parameter of the minimum norm.
     method : "MNE" | "dSPM" | "sLORETA" | "eLORETA"
@@ -506,6 +669,10 @@ def source_induced_power(
         the time-frequency transforms. It reduces the computation times
         e.g. with a dataset that was maxfiltered (true dim is 64).
     %(n_jobs)s
+    return_plv : bool
+        If True, return the phase-locking value array. Else, only return power.
+
+        .. versionadded:: 1.6
     zero_mean : bool
         Make sure the wavelets are zero mean.
     prepared : bool
@@ -520,7 +687,12 @@ def source_induced_power(
     Returns
     -------
     power : array
-        The induced power.
+        The induced power array with shape (n_sources, n_freqs, n_samples) if
+        label=None or label=label. For lists of one or more labels, the induced
+        power estimate has shape (n_labels, n_frequencies, n_samples).
+    plv : array
+        The phase-locking value array with shape (n_sources, n_freqs,
+        n_samples). Only returned if ``return_plv=True``.
     """  # noqa: E501
     _check_option("method", method, INVERSE_METHODS)
     _check_ori(pick_ori, inverse_operator["source_ori"], inverse_operator["src"])
@@ -539,6 +711,7 @@ def source_induced_power(
         pick_ori=pick_ori,
         pca=pca,
         n_jobs=n_jobs,
+        with_plv=return_plv,
         method_params=method_params,
         zero_mean=zero_mean,
         prepared=prepared,
@@ -547,7 +720,9 @@ def source_induced_power(
 
     # Run baseline correction
     power = rescale(power, epochs.times[::decim], baseline, baseline_mode, copy=False)
-    return power, plv
+
+    outs = (power, plv) if return_plv else power
+    return outs
 
 
 @verbose
@@ -761,7 +936,17 @@ def _compute_source_psd_epochs(
     """Generate compute_source_psd_epochs."""
     logger.info("Considering frequencies %g ... %g Hz" % (fmin, fmax))
 
-    K, sel, Vh, vertno, is_free_ori, noise_norm = _prepare_source_params(
+    if label:
+        # TODO: add multi-label support
+        # since `_prepare_source_params` can handle a list of labels now,
+        # multi-label support should be within reach for psd calc as well
+        _validate_type(
+            label,
+            types=(Label, BiHemiLabel, None),
+            type_name=("Label or BiHemiLabel", "None"),
+        )
+
+    K, sel, Vh, vertno, is_free_ori, noise_norm, _ = _prepare_source_params(
         inst=epochs,
         inverse_operator=inverse_operator,
         label=label,


Test patch:
diff --git a/mne/commands/tests/test_commands.py b/mne/commands/tests/test_commands.py
--- a/mne/commands/tests/test_commands.py
+++ b/mne/commands/tests/test_commands.py
@@ -319,6 +319,7 @@ def test_watershed_bem(tmp_path):
 @testing.requires_testing_data
 def test_flash_bem(tmp_path):
     """Test mne flash_bem."""
+    pytest.importorskip("nibabel")
     check_usage(mne_flash_bem, force_help=True)
     # Copy necessary files to tempdir
     tempdir = Path(str(tmp_path))
diff --git a/mne/minimum_norm/tests/test_time_frequency.py b/mne/minimum_norm/tests/test_time_frequency.py
--- a/mne/minimum_norm/tests/test_time_frequency.py
+++ b/mne/minimum_norm/tests/test_time_frequency.py
@@ -6,7 +6,7 @@
 from mne._fiff.constants import FIFF
 from mne.datasets import testing
 from mne.io import read_raw_fif
-from mne.label import read_label
+from mne.label import BiHemiLabel, read_label
 from mne.minimum_norm import (
     INVERSE_METHODS,
     apply_inverse_epochs,
@@ -27,6 +27,7 @@
 )
 fname_data = data_path / "MEG" / "sample" / "sample_audvis_trunc_raw.fif"
 fname_label = data_path / "MEG" / "sample" / "labels" / "Aud-lh.label"
+fname_label2 = data_path / "MEG" / "sample" / "labels" / "Aud-rh.label"
 
 
 @testing.requires_testing_data
@@ -129,9 +130,141 @@ def test_tfr_with_inverse_operator(method):
         method=method,
         prepared=True,
     )
+    assert power.shape == phase_lock.shape
     assert np.all(phase_lock > 0)
     assert np.all(phase_lock <= 1)
     assert 5 < np.max(power) < 7
+    # fairly precise spot check that our values match what we had on 2023/09/28
+    if method != "eLORETA":
+        # check phase-lock using arbitrary index value since pl max is 1
+        assert_allclose(phase_lock[1, 0, 0], 0.576, rtol=1e-3)
+        # check power
+        max_inds = np.unravel_index(np.argmax(power), power.shape)
+        assert_allclose(max_inds, [0, 11, 135])
+        assert_allclose(power[max_inds], 6.05, rtol=1e-3)
+
+
+@testing.requires_testing_data
+def test_tfr_multi_label():
+    """Test multi-label functionality."""
+    tmin, tmax, event_id = -0.2, 0.5, 1
+
+    # Setup for reading the raw data
+    raw = read_raw_fif(fname_data)
+    events = find_events(raw, stim_channel="STI 014")
+    inv = read_inverse_operator(fname_inv)
+    inv = prepare_inverse_operator(inv, nave=1, lambda2=1.0 / 9.0, method="dSPM")
+
+    raw.info["bads"] += ["MEG 2443", "EEG 053"]  # bads + 2 more
+
+    # picks MEG gradiometers
+    picks = pick_types(
+        raw.info, meg=True, eeg=False, eog=True, stim=False, exclude="bads"
+    )
+
+    # Load condition 1
+    event_id = 1
+    epochs = Epochs(
+        raw,
+        events[:3],  # take 3 events to keep the computation time low
+        event_id,
+        tmin,
+        tmax,
+        picks=picks,
+        baseline=(None, 0),
+        reject=dict(grad=4000e-13, eog=150e-6),
+        preload=True,
+    )
+
+    freqs = np.arange(7, 30, 2)
+
+    n_times = len(epochs.times)
+    n_freqs = len(freqs)
+
+    # prepare labels
+    label = read_label(fname_label)  # lh Aud
+    label2 = read_label(fname_label2)  # rh Aud
+    labels = [label, label2]
+    bad_lab = label.copy()
+    bad_lab.vertices = np.hstack((label.vertices, [2121]))  # add 1 unique vert
+    bad_lbls = [label, bad_lab]
+    nverts_lh = len(np.intersect1d(inv["src"][0]["vertno"], label.vertices))
+    nverts_rh = len(np.intersect1d(inv["src"][1]["vertno"], label2.vertices))
+    assert nverts_lh + 1 == nverts_rh == 3
+
+    # prepare instances of BiHemiLabel
+    fname_lvis = data_path / "MEG" / "sample" / "labels" / "Vis-lh.label"
+    fname_rvis = data_path / "MEG" / "sample" / "labels" / "Vis-rh.label"
+    lvis = read_label(fname_lvis)
+    rvis = read_label(fname_rvis)
+    bihl = BiHemiLabel(lh=label, rh=label2)  # auditory labels
+    bihl.name = "Aud"
+    bihl2 = BiHemiLabel(lh=lvis, rh=rvis)  # visual labels
+    bihl2.name = "Vis"
+    bihls = [bihl, bihl2]
+    bad_bihl = BiHemiLabel(lh=bad_lab, rh=rvis)  # 1 unique vert on lh, rh ok
+    bad_bihls = [bihl, bad_bihl]
+    print("BiHemi label verts:", bihl.lh.vertices.shape, bihl.rh.vertices.shape)
+
+    # check error handling
+    sip_kwargs = dict(
+        baseline=(-0.1, 0),
+        baseline_mode="mean",
+        n_cycles=2,
+        n_jobs=None,
+        return_plv=False,
+        method="dSPM",
+        prepared=True,
+    )
+    # label input errors
+    with pytest.raises(TypeError, match="Label or BiHemi"):
+        source_induced_power(epochs, inv, freqs, label="bad_input", **sip_kwargs)
+    with pytest.raises(TypeError, match="Label or BiHemi"):
+        source_induced_power(
+            epochs, inv, freqs, label=[label, "bad_input"], **sip_kwargs
+        )
+
+    # error handling for multi-label and plv
+    sip_kwargs_bad = sip_kwargs.copy()
+    sip_kwargs_bad["return_plv"] = True
+    with pytest.raises(RuntimeError, match="value cannot be calculated"):
+        source_induced_power(epochs, inv, freqs, labels, **sip_kwargs_bad)
+
+    # check multi-label handling
+    label_sets = dict(Label=(labels, bad_lbls), BiHemi=(bihls, bad_bihls))
+    for ltype, lab_set in label_sets.items():
+        n_verts = nverts_lh if ltype == "Label" else nverts_lh + nverts_rh
+        # check overlapping verts error handling
+        with pytest.raises(RuntimeError, match="overlapping vertices"):
+            source_induced_power(epochs, inv, freqs, lab_set[1], **sip_kwargs)
+
+        # TODO someday, eliminate both levels of this nested for-loop and use
+        # pytest.mark.parametrize, but not unless/until the data IO and the loading /
+        # preparing of the inverse operator have been made into fixtures (the overhead
+        # of those operations makes it a bad idea to parametrize now)
+        for ori in (None, "normal"):  # check loose and normal orientations
+            sip_kwargs.update(pick_ori=ori)
+            lbl = lab_set[0][0]
+
+            # check label=Label vs label=[Label]
+            no_list_pow = source_induced_power(
+                epochs, inv, freqs, label=lbl, **sip_kwargs
+            )
+            assert no_list_pow.shape == (n_verts, n_freqs, n_times)
+
+            list_pow = source_induced_power(
+                epochs, inv, freqs, label=[lbl], **sip_kwargs
+            )
+            assert list_pow.shape == (1, n_freqs, n_times)
+
+            nlp_ave = np.mean(no_list_pow, axis=0)
+            assert_allclose(nlp_ave, list_pow[0], rtol=1e-3)
+
+            # check label=[Label1, Label2]
+            multi_lab_pow = source_induced_power(
+                epochs, inv, freqs, label=lab_set[0], **sip_kwargs
+            )
+            assert multi_lab_pow.shape == (2, n_freqs, n_times)
 
 
 @testing.requires_testing_data
@@ -205,6 +338,7 @@ def test_source_psd_epochs(method):
     raw = read_raw_fif(fname_data)
     inverse_operator = read_inverse_operator(fname_inv)
     label = read_label(fname_label)
+    label2 = read_label(fname_label2)
 
     event_id, tmin, tmax = 1, -0.2, 0.5
     lambda2 = 1.0 / 9.0
@@ -242,6 +376,7 @@ def test_source_psd_epochs(method):
     inv = prepare_inverse_operator(
         inverse_operator, nave=1, lambda2=1.0 / 9.0, method="dSPM"
     )
+
     # return list
     stc_psd = compute_source_psd_epochs(
         one_epochs,
@@ -311,3 +446,7 @@ def test_source_psd_epochs(method):
             return_generator=False,
             prepared=True,
         )
+
+    # check error handling for label
+    with pytest.raises(TypeError, match="Label or BiHemi"):
+        compute_source_psd_epochs(one_epochs, inv, label=[label, label2])
diff --git a/mne/source_space/tests/test_source_space.py b/mne/source_space/tests/test_source_space.py
--- a/mne/source_space/tests/test_source_space.py
+++ b/mne/source_space/tests/test_source_space.py
@@ -386,6 +386,7 @@ def test_other_volume_source_spaces(tmp_path):
     """Test setting up other volume source spaces."""
     # these are split off because they require the MNE tools, and
     # Travis doesn't seem to like them
+    pytest.importorskip("nibabel")
 
     # let's try the spherical one (no bem or surf supplied)
     temp_name = tmp_path / "temp-src.fif"
@@ -562,6 +563,7 @@ def test_setup_source_space(tmp_path):
 @pytest.mark.parametrize("spacing", [2, 7])
 def test_setup_source_space_spacing(tmp_path, spacing, monkeypatch):
     """Test setting up surface source spaces using a given spacing."""
+    pytest.importorskip("nibabel")
     copytree(subjects_dir / "sample", tmp_path / "sample")
     args = [] if spacing == 7 else ["--spacing", str(spacing)]
     monkeypatch.setenv("SUBJECTS_DIR", str(tmp_path))
diff --git a/mne/tests/test_dipole.py b/mne/tests/test_dipole.py
--- a/mne/tests/test_dipole.py
+++ b/mne/tests/test_dipole.py
@@ -101,6 +101,7 @@ def test_io_dipoles(tmp_path):
 @testing.requires_testing_data
 def test_dipole_fitting_ctf():
     """Test dipole fitting with CTF data."""
+    pytest.importorskip("nibabel")
     raw_ctf = read_raw_ctf(fname_ctf).set_eeg_reference(projection=True)
     events = make_fixed_length_events(raw_ctf, 1)
     evoked = Epochs(raw_ctf, events, 1, 0, 0, baseline=None).average()
@@ -125,6 +126,7 @@ def test_dipole_fitting_ctf():
 @requires_mne
 def test_dipole_fitting(tmp_path):
     """Test dipole fitting."""
+    pytest.importorskip("nibabel")
     amp = 100e-9
     rng = np.random.RandomState(0)
     fname_dtemp = tmp_path / "test.dip"
diff --git a/mne/tests/test_surface.py b/mne/tests/test_surface.py
--- a/mne/tests/test_surface.py
+++ b/mne/tests/test_surface.py
@@ -196,6 +196,7 @@ def test_decimate_surface_vtk(n_tri):
 @requires_freesurfer("mris_sphere")
 def test_decimate_surface_sphere():
     """Test sphere mode of decimation."""
+    pytest.importorskip("nibabel")
     rr, tris = _tessellate_sphere(3)
     assert len(rr) == 66
     assert len(tris) == 128