Skip to content

Preprocessing (layer 2)

pipeline

preprocess(): from the parsed frames to a recording ready for analysis (layer 2).

Fixed order of the steps (notebooks/05_preprocessing_choices.ipynb): 1. time in seconds, pixel copy with the device "no data" values as NaN; 2. frames with missing pixel data filled and excluded; 3. respiratory rate from the spectrum, cardiac step (MDN with a heart rate given by the user, otherwise only a moving average); 4. breath detection on the global signal (sum of all pixels); 5. quality flags and exclusions of each breath; 6. lung mask from the valid breaths, regions of interest, regional signals; 7. auxiliary signals (pressures, flow, volume, device markers) with "no data" as NaN, in the same units for every vendor (cmH2O, L/s, mL); 8. airway pressures of each breath: PEEP, plateau, driving pressure (notebooks/06_peep_trial_segmentation.ipynb); 9. inspiratory tidal volume of each breath, from the ventilator curves.

Every step writes what it did in EITRecording.log (saved with the results) and in the Python logger fasteit.preprocessing.pipeline (technical log, configured by the application, not here).

recording_time

recording_time(raw: ReconstructedFrameData) -> np.ndarray

Time of each frame in seconds from the start of the recording.

Draeger .bin timestamps are a fraction of the day: they are converted to seconds and a recording that crosses midnight is unwrapped. Other vendors already give seconds.

pixel_values

pixel_values(raw: ReconstructedFrameData) -> np.ndarray

Float copy of the pixels, with the Draeger "no data" values as NaN.

The parsed pixels are never modified (they can be a read-only memory map). Timpel pixels already have NaN from the parser.

preprocess

preprocess(raw: ReconstructedFrameData, config: PreprocessingConfig | None = None, exclusions: Sequence[Segment] = (), context: RecordingContext | None = None) -> EITRecording

Preprocess one recording: cardiac step, breaths, quality, lung mask, regions.

Same inputs give the same output, so an application only needs to store the inputs (file, config, exclusions, context).

Parameters:

Name Type Description Default
raw ReconstructedFrameData

Parsed recording with pixels (Draeger .bin or Timpel).

required
config PreprocessingConfig | None

Parameters; defaults of PreprocessingConfig if None.

None
exclusions Sequence[Segment]

Parts to exclude chosen by the user (source="manual").

()
context RecordingContext | None

Patient, position, protocol and notes.

None

Returns:

Type Description
EITRecording

The recording ready for analysis.

Raises:

Type Description
ValueError

if the input is unusable, no breath is found, or no valid breath is left for the lung mask.

cardiac_filter

Cardiac filtering of the pixel signals.

Policy (notebooks/05_preprocessing_choices.ipynb, section 5c): - heart rate given by the user -> MDN filter; - no heart rate -> only a light moving average, the cardiac component is NOT removed.

All functions work along axis 0 (time). Rates are in Hz here; the configuration uses breaths or beats per minute, and the caller converts with SECONDS_PER_MINUTE. Parameters have no defaults here on purpose: they live only in fasteit.config.PreprocessingConfig.

Valid for adult recordings only (respiratory rate 6-60/min); not for neonates, paediatric patients or high-frequency oscillation.

moving_average_window

moving_average_window(fs: float, seconds: float) -> int

Number of samples of a centred moving average lasting seconds.

The number is odd so that the window is centred on each sample, so the actual duration can differ a little from seconds (0.1 s gives 5 samples at 50 Hz, i.e. 0.1 s, but 3 samples at 20 Hz, i.e. 0.15 s).

Parameters:

Name Type Description Default
fs float

Sampling frequency in Hz.

required
seconds float

Window duration in seconds.

required

Returns:

Type Description
int

Odd number of samples, at least 1.

moving_average

moving_average(signals: ndarray, fs: float, seconds: float) -> np.ndarray

Centred moving average along the time axis (axis 0).

Fallback used when no heart rate is available (section 5c of the evidence notebook). Same idea as the MATLAB smooth() of the original analysis scripts, but with the width in seconds. It is a smoothing, not a cardiac filter: a 100/min cardiac oscillation keeps about 96 % of its amplitude at 50 Hz and about 91 % at 20 Hz with a 0.1 s window.

Parameters:

Name Type Description Default
signals ndarray

Array with time on axis 0, e.g. pixels of shape (N, 32, 32) or a 1-D global signal of shape (N,).

required
fs float

Sampling frequency in Hz.

required
seconds float

Window duration in seconds.

required

Returns:

Type Description
ndarray

Array of the same shape as signals. At the edges the first and last

ndarray

sample are repeated, so the length does not change.

Raises:

Type Description
ValueError

if fs is not positive or a signal has NaN at some frames only.

respiratory_rate

respiratory_rate(global_signal: ndarray, fs: float, min_bpm: float, max_bpm: float, window_seconds: float) -> float

Respiratory rate (Hz) from the power spectrum of the global signal.

Same method as the respiratory part of eitprocessing RateDetection (v1.8.8; Somhorst et al., JOSS 2026;11(117):8179, DOI: 10.21105/joss.08179): Welch spectrum (Hann window, 50 % overlap, mean removed), highest peak between min_bpm and max_bpm, refined with a parabola through the peak and its two neighbours.

It is the mean rate of the whole signal, not of single breaths. In the evidence notebook (section 4) the largest difference from the device breath markers was 1.7 breaths/min over 11 recordings (rates 10-42/min). With a 30 s window the spectral bins are 2/min apart before the refinement.

Parameters:

Name Type Description Default
global_signal ndarray

1-D signal without NaN, e.g. the sum of all pixels per frame.

required
fs float

Sampling frequency in Hz.

required
min_bpm float

Lowest respiratory rate searched, breaths/min.

required
max_bpm float

Highest respiratory rate searched, breaths/min.

required
window_seconds float

Welch window; shortened to the signal length if needed.

required

Returns:

Type Description
float

Respiratory rate in Hz (multiply by 60 for breaths/min). A peak on the edge of

float

the range is returned unrefined; it usually means a slow drift of the signal,

float

not a real respiratory rate.

Raises:

Type Description
ValueError

if the input is not usable (non-positive fs, min_bpm not below max_bpm, NaN in the signal, flat signal, or signal shorter than two cycles of min_bpm).

mdn_filter

mdn_filter(signals: ndarray, fs: float, respiratory_rate_hz: float, heart_rate_hz: float, notch_half_width_hz: float, upper_limit_hz: float, order: int) -> np.ndarray

Remove the cardiac component with a multiple digital notch (MDN) filter.

Wisse et al., "Improved filtering methods to suppress cardiovascular contamination in EIT recordings", Physiol Meas 2024, DOI: 10.1088/1361-6579/ad46e3. Implementation follows eitprocessing MDNFilter (v1.8.8):

  1. one band-stop (Butterworth, zero phase) around each cardiac harmonic k * heart_rate ± notch_half_width, up to upper_limit_hz;
  2. the first band-stop never starts below (respiratory_rate + heart_rate) / 2, so that it does not cut the breathing;
  3. a low-pass at upper_limit_hz removes what is left above it.

Zero-phase filtering (forward and backward) doubles the effective order of each filter. Apply it to the whole recording, not to short pieces: narrow band-stops of this order have transients of several seconds at both ends.

The heart rate comes from the user (section 5c of the evidence notebook). It is a snapshot: a heart rate drifting more than about ±10/min during the recording, or an arrhythmia such as atrial fibrillation, is not removed by fixed notches. A heart rate above upper_limit_hz + notch_half_width_hz (about 230/min) gets no notch, only the low-pass.

Parameters:

Name Type Description Default
signals ndarray

Array with time on axis 0 (pixels or a 1-D signal).

required
fs float

Sampling frequency in Hz.

required
respiratory_rate_hz float

Respiratory rate in Hz.

required
heart_rate_hz float

Heart rate in Hz.

required
notch_half_width_hz float

Half width of each band-stop, in Hz.

required
upper_limit_hz float

Highest frequency filtered, in Hz.

required
order int

Butterworth order of each band-stop and of the low-pass.

required

Returns:

Type Description
ndarray

Filtered array of the same shape as signals.

Raises:

Type Description
ValueError

if a rate is not positive, the heart rate is not above the respiratory rate, fs is too low for upper_limit_hz, the signal is too short for the filter, or it has NaN at some frames only.

heart_rate_warnings

heart_rate_warnings(heart_rate_bpm: float, respiratory_rate_bpm: float, low_bpm: float, high_bpm: float, margin_bpm: float) -> list[str]

Warnings about a heart rate entered by the user, before the MDN filter.

The heart rate is not checked against the data (it is never estimated, section 5c of the evidence notebook), so only two plausibility checks are made: - outside low_bpm-high_bpm (adult range of eitprocessing RateDetection), e.g. a typing error; - within margin_bpm of 1, 2 or 3 times the respiratory rate: the cardiac notches then fall on the breathing or its harmonics and can change the shape of the breaths (section 5b).

Parameters:

Name Type Description Default
heart_rate_bpm float

Heart rate given by the user, beats/min.

required
respiratory_rate_bpm float

Respiratory rate from the spectrum, breaths/min.

required
low_bpm float

Lowest plausible heart rate.

required
high_bpm float

Highest plausible heart rate.

required
margin_bpm float

Distance from a multiple of the respiratory rate below which a warning is given.

required

Returns:

Type Description
list[str]

List of warning messages, empty if none.

exclusions

Parts of a recording excluded from the analysis.

Exclusions come from the user (source="manual") or from the device (source="device": frames with a timing error or with missing pixel data). They are widened by a margin, because the forward-backward filters spread an artifact over the neighbouring samples, and a breath that touches an exclusion is marked excluded: it stays in the breath table (fastEIT design decision D5).

Frames with missing pixel data are also filled with the nearest valid frame, so that the filters can run on a continuous signal; the filled frames are excluded.

frames_to_segments

frames_to_segments(time: ndarray, bad_frames: ndarray, fs: float, label: str) -> list[Segment]

One device exclusion per run of consecutive bad frames.

Parameters:

Name Type Description Default
time ndarray

Time of each frame in seconds from the start of the recording.

required
bad_frames ndarray

Boolean array, True for the frames to exclude.

required
fs float

Sampling frequency in Hz; a run ends one sample after its last frame, so that a single bad frame gives a segment of 1/fs.

required
label str

Why the frames are excluded, e.g. "timing_error".

required

Returns:

Type Description
list[Segment]

List of segments with source="device".

missing_frames

missing_frames(pixels: ndarray) -> np.ndarray

Frames where some pixel has no value (NaN or inf).

A pixel that has no value in the whole recording (e.g. outside the belt) does not make a frame missing: it is simply not part of the image.

Parameters:

Name Type Description Default
pixels ndarray

Array (N, rows, cols).

required

Returns:

Type Description
ndarray

Boolean array (N,).

fill_missing_frames

fill_missing_frames(pixels: ndarray, missing: ndarray) -> np.ndarray

Replace the missing frames with the nearest valid frame.

Same rule as breaths.fill_with_nearest, applied to whole frames.

Parameters:

Name Type Description Default
pixels ndarray

Array (N, rows, cols).

required
missing ndarray

Boolean array (N,) from missing_frames.

required

Returns:

Type Description
ndarray

A copy of pixels.

Raises:

Type Description
ValueError

if every frame is missing.

widen

widen(segments: list[Segment], margin: float, duration: float) -> list[Segment]

Each segment made longer by margin seconds on both sides.

The result is clipped to the recording, from 0 to duration seconds.

Raises:

Type Description
ValueError

if a segment starts after the end of the recording.

merge_overlapping

merge_overlapping(segments: list[Segment]) -> list[Segment]

Join the segments of the same source that overlap or touch.

Manual and device segments are never joined, so a manual exclusion stays apart from the device ones (both are widened by the margin before). The labels of joined segments are listed together, e.g. "artifact, movement"; the parameters are those of the first segment.

Returns:

Type Description
list[Segment]

Segments sorted by start time.

breaths_in_segments

breaths_in_segments(breath_starts: ndarray, breath_ends: ndarray, segments: list[Segment]) -> tuple[np.ndarray, list[str | None]]

Which breaths touch an exclusion, and why.

Parameters:

Name Type Description Default
breath_starts ndarray

Start time of each breath, in seconds.

required
breath_ends ndarray

End time of each breath, in seconds.

required
segments list[Segment]

Exclusions.

required

Returns:

Type Description
ndarray

excluded: boolean array (n_breaths,); labels: for each breath the

list[str | None]

labels of the exclusions it touches, e.g. "artifact" ("unlabelled"

tuple[ndarray, list[str | None]]

if none has a label), or None if the breath is not excluded.

breaths

Breath detection on the global impedance signal.

Reimplementation of eitprocessing BreathDetection (v1.8.8; Somhorst et al., JOSS 2026;11(117):8179, DOI: 10.21105/joss.08179). It gives the same breaths as eitprocessing on 11 regular real recordings (notebooks/05_preprocessing_choices.ipynb, section 8) and on synthetic signals with a spike and with small breaths. This shows equivalence with eitprocessing, not accuracy against a gold standard.

One change: breaths close to invalid data are flagged, not removed.

A breath is a triplet of frame indices: start (valley), peak, end (next valley). Impedance is assumed to rise with inspiration (as in the Draeger .bin pixels).

Limitations (to keep in mind when reading Ti, Te and the respiratory rate): - the valley is the minimum of impedance, i.e. of lung volume, not the start of the inspiratory effort or the ventilator trigger; the peak includes any inspiratory pause. Timings are impedance-based and not interchangeable with the ventilator values; - patient-ventilator asynchrony is not detected: an ineffective effort is usually not a peak, and two inspirations without a valley between them become one breath; - the 15 s baseline follows slow EELI drift, but after a sudden EELI step (PEEP change, recruitment) the breaths within about 15 s of the step are unreliable.

invalid_samples

invalid_samples(signal: ndarray, percentile: float, multiplier: float) -> np.ndarray

Indices of the gross outliers of the signal (step 1).

A sample is invalid when it is below mean - multiplier * (mean - low) or above mean + multiplier * (high - mean), where low and high are the percentile and 100 - percentile percentiles of the signal. With the defaults (5, 4) only gross outliers are caught: artefacts such as a disconnection spike, but also extreme manoeuvres such as a sustained inflation or a large recruitment step, which are physiological.

Parameters:

Name Type Description Default
signal ndarray

1-D global signal.

required
percentile float

Lower percentile; the upper one is 100 - percentile.

required
multiplier float

How many times the mean-to-percentile distance is allowed.

required

Returns:

Type Description
ndarray

Sorted indices of the invalid samples (empty if none).

fill_with_nearest

fill_with_nearest(signal: ndarray, missing: ndarray) -> np.ndarray

Replace the samples at missing with the nearest valid sample (step 1).

In a run of missing samples the first len(run) // 2 samples take the value just before the run and the others the value just after it (so the middle sample of an odd run takes the value after). A run at the start or at the end of the signal takes the only valid neighbour.

Parameters:

Name Type Description Default
signal ndarray

1-D signal.

required
missing ndarray

Indices of the samples to replace.

required

Returns:

Type Description
ndarray

A copy of signal without the missing values.

Raises:

Type Description
ValueError

if every sample is missing.

local_baseline

local_baseline(signal: ndarray, fs: float, seconds: float) -> np.ndarray

Slow moving average of the signal, used as a local threshold (step 2).

Weighted with a Blackman window, centred, with the first and last value repeated at the edges. The window has an odd number of samples and is shortened to fit a signal shorter than seconds.

Parameters:

Name Type Description Default
signal ndarray

1-D signal.

required
fs float

Sampling frequency in Hz.

required
seconds float

Window width in seconds.

required

Returns:

Type Description
ndarray

Array of the same length as signal.

peaks_and_valleys

peaks_and_valleys(signal: ndarray, baseline: ndarray, fs: float, min_duration: float) -> tuple[np.ndarray, np.ndarray]

Candidate peaks above the baseline and valleys below it (step 3).

Parameters:

Name Type Description Default
signal ndarray

1-D signal.

required
baseline ndarray

Local threshold of the same length (local_baseline).

required
fs float

Sampling frequency in Hz.

required
min_duration float

Minimum time in seconds between two peaks and between two valleys.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Indices of the peaks and indices of the valleys.

trim_edges

trim_edges(signal: ndarray, peaks: ndarray, valleys: ndarray, baseline: ndarray) -> tuple[np.ndarray, np.ndarray]

Drop the incomplete breaths at the start and end of the signal (step 4).

The first valley is dropped if the signal before it never rises above the baseline (the recording started in the middle of an expiration), and the same for the last valley. Then only the peaks between the first and the last valley are kept.

Parameters:

Name Type Description Default
signal ndarray

1-D signal.

required
peaks ndarray

Candidate peak indices.

required
valleys ndarray

Candidate valley indices, never the first or last sample (true for the output of peaks_and_valleys).

required
baseline ndarray

Local threshold of the same length as signal.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Peaks and valleys without the edge cases (both empty if no breath is left).

drop_doubles

drop_doubles(signal: ndarray, peaks: ndarray, valleys: ndarray) -> tuple[np.ndarray, np.ndarray]

Keep one peak between two valleys and one valley between two peaks (step 5).

Walking from the first valley: if two valleys have no peak between them, the higher valley is dropped; if two valleys have more than one peak between them, the lower of the first two peaks is dropped. Repeat until every pair of consecutive valleys holds exactly one peak.

Parameters:

Name Type Description Default
signal ndarray

1-D signal.

required
peaks ndarray

Peak indices.

required
valleys ndarray

Valley indices.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Peaks and valleys, alternating.

drop_low_amplitude

drop_low_amplitude(signal: ndarray, peaks: ndarray, valleys: ndarray, fraction: float) -> tuple[np.ndarray, np.ndarray]

Merge peaks that are too small to be a breath into the breath around them (step 6).

The amplitude of a peak is the mean of its rise from the valley before and its fall to the valley after. Peaks below fraction times the median amplitude are removed, then drop_doubles keeps the lower of the two valleys left next to each other, so the small peak becomes part of a longer breath.

Parameters:

Name Type Description Default
signal ndarray

1-D signal.

required
peaks ndarray

Peak indices, alternating with valleys (one more valley than peaks).

required
valleys ndarray

Valley indices.

required
fraction float

Fraction of the median amplitude below which a peak is removed.

required

Returns:

Type Description
tuple[ndarray, ndarray]

Peaks and valleys, alternating.

Raises:

Type Description
ValueError

if there is not exactly one more valley than peaks.

flag_near_invalid

flag_near_invalid(breaths: ndarray, invalid: ndarray, margin: int) -> np.ndarray

Which breaths are close to an invalid sample (step 8).

Same window as eitprocessing: from start - margin (included) to end + margin (excluded), like a Python slice. eitprocessing removes these breaths; fastEIT keeps them with a flag.

Parameters:

Name Type Description Default
breaths ndarray

Integer array of shape (n_breaths, 3): start, peak, end frame.

required
invalid ndarray

Indices of the invalid samples (invalid_samples).

required
margin int

Margin in samples.

required

Returns:

Type Description
ndarray

Boolean array of length n_breaths.

detect_breaths

detect_breaths(signal: ndarray, fs: float, invalid_percentile: float, invalid_multiplier: float, baseline_seconds: float, min_duration_seconds: float, low_amplitude_fraction: float, invalid_margin_seconds: float) -> tuple[np.ndarray, np.ndarray]

Find the breaths in the global signal (steps 1-8 of eitprocessing BreathDetection).

  1. invalid samples are replaced with the nearest valid value;
  2. a slow Blackman moving average is the local baseline;
  3. peaks above it and valleys below it, at least min_duration_seconds apart;
  4. incomplete breaths at the edges are dropped;
  5. one peak between two valleys, one valley between two peaks;
  6. too small peaks are merged into the breath around them;
  7. breaths = valley, peak, next valley;
  8. breaths closer than invalid_margin_seconds to invalid data are flagged (eitprocessing removes them).

Parameters:

Name Type Description Default
signal ndarray

1-D global signal without NaN (after the cardiac filter).

required
fs float

Sampling frequency in Hz.

required
invalid_percentile float

See invalid_samples.

required
invalid_multiplier float

See invalid_samples.

required
baseline_seconds float

Width of the local baseline.

required
min_duration_seconds float

Minimum time between two peaks and between two valleys.

required
low_amplitude_fraction float

See drop_low_amplitude.

required
invalid_margin_seconds float

Margin around invalid samples for the flag.

required

Returns:

Type Description
ndarray

breaths: integer array of shape (n_breaths, 3) with the start, peak and end

ndarray

frame of each breath; near_invalid: boolean array of length n_breaths.

Raises:

Type Description
ValueError

if fs is not positive, the signal is not a non-empty 1-D array or it contains NaN or inf.

breath_quality

Quality flags of the detected breaths.

Flags mark a breath, they never remove it: the clinician decides (fastEIT design decision D5). Thresholds and the evidence for them are in notebooks/05_preprocessing_choices.ipynb, section 8; they are fastEIT choices, not taken from a paper.

All measures come from the global impedance signal, so Ti and Ttot are impedance-based (valley to peak, valley to valley) and not the ventilator values; the amplitude is in arbitrary impedance units, not a volume.

breath_measures

breath_measures(signal: ndarray, fs: float, breaths: ndarray) -> pd.DataFrame

Duration, Ti/Ttot and amplitude of each breath.

The amplitude is the mean of the rise from the start valley to the peak and the fall from the peak to the end valley, as in eitprocessing BreathDetection (v1.8.8; Somhorst et al., JOSS 2026;11(117):8179, DOI: 10.21105/joss.08179).

Parameters:

Name Type Description Default
signal ndarray

1-D global signal used for the breath detection.

required
fs float

Sampling frequency in Hz.

required
breaths ndarray

Integer array of shape (n_breaths, 3): start, peak, end frame.

required

Returns:

Type Description
DataFrame

DataFrame with one row per breath and columns duration_s (Ttot),

DataFrame

ti_ratio (Ti/Ttot) and amplitude (units of the signal). A breath of

DataFrame

zero duration has ti_ratio NaN.

Raises:

Type Description
ValueError

if breaths is not an array of shape (n_breaths, 3).

ratio_to_local_median

ratio_to_local_median(values: ndarray, window_breaths: int, min_breaths: int) -> np.ndarray

Each value divided by the median of the breaths around it.

The window is centred on the breath and includes it. At the start and end of the recording the window is shorter; with fewer than min_breaths values the ratio is NaN, so no outlier flag can be set there.

Parameters:

Name Type Description Default
values ndarray

One value per breath, in time order (e.g. amplitudes).

required
window_breaths int

Number of breaths in the window (odd, e.g. 21).

required
min_breaths int

Fewest breaths needed to compute the median.

required

Returns:

Type Description
ndarray

Array of ratios, same length as values.

quality_flags

quality_flags(signal: ndarray, fs: float, breaths: ndarray, near_invalid: ndarray, short_seconds: float, long_seconds: float, ti_ratio_low: float, ti_ratio_high: float, window_breaths: int, min_breaths: int, amplitude_ratio_low: float, amplitude_ratio_high: float, duration_ratio_high: float) -> pd.DataFrame

One true/false column per quality check, one row per breath.

  • flag_short / flag_long: duration below short_seconds or above long_seconds;
  • flag_suspect_ti_ratio: Ti/Ttot outside ti_ratio_low-ti_ratio_high;
  • flag_amplitude_outlier: amplitude below amplitude_ratio_low or above amplitude_ratio_high times the median of the surrounding breaths;
  • flag_duration_outlier: duration above duration_ratio_high times the median of the surrounding breaths;
  • flag_invalid_data: close to invalid data (near_invalid of detect_breaths).

On 946 breaths of 11 regular recordings these rules flagged 9 Ti/Ttot, 1 amplitude and 3 duration outliers, all visible events (section 8 of the evidence notebook).

Parameters:

Name Type Description Default
signal ndarray

1-D global signal used for the breath detection.

required
fs float

Sampling frequency in Hz.

required
breaths ndarray

Integer array (n_breaths, 3) from detect_breaths.

required
near_invalid ndarray

Boolean array (n_breaths,) from detect_breaths.

required
short_seconds float

Shortest duration in seconds.

required
long_seconds float

Longest duration in seconds.

required
ti_ratio_low float

Lowest Ti/Ttot.

required
ti_ratio_high float

Highest Ti/Ttot.

required
window_breaths int

Window of ratio_to_local_median, in breaths.

required
min_breaths int

Fewest breaths in that window.

required
amplitude_ratio_low float

Lowest amplitude as a ratio to the local median.

required
amplitude_ratio_high float

Highest amplitude as a ratio to the local median.

required
duration_ratio_high float

Longest duration as a ratio to the local median.

required

Returns:

Type Description
DataFrame

DataFrame of booleans, one column per flag. The pipeline adds whether the

DataFrame

breath is excluded and is_valid (no flag and not excluded).

Raises:

Type Description
ValueError

if breaths and near_invalid have different lengths.

lung_mask

Functional lung mask with the watershed method.

Reimplementation of eitprocessing WatershedLungspace (v1.8.8; Somhorst et al., JOSS 2026;11(117):8179, DOI: 10.21105/joss.08179), with the pixel amplitude in its "phase shift" mode (PixelBreath) and the small-region filter FilterROIBySize. Checked to give the same masks as eitprocessing on 11 real recordings when every breath is used (notebooks/05_preprocessing_choices.ipynb, sections 7b and 9).

The mask is the VENTILATED lung, not the anatomical lung: a collapsed region with a TIV below the threshold is outside it.

Steps: 1. TIV map: pixel value at the end of inspiration of the global signal minus the value at its start, mean over the breaths; 2. amplitude map: the same, but on each pixel's own timing, found by cross-correlation with the global signal (it catches pixels that inflate late, e.g. pendelluft); 3. threshold: a fraction of the maximum of the TIV map, applied to both maps; 4. watershed: the amplitude map is split into regions around its local maxima; a region is kept if its maximum is inside the TIV mask. Mask = kept regions inside the amplitude mask; 5. islands smaller than a minimum size are removed.

The watershed assumes smooth images, with high amplitude in the centre of the lung and low amplitude at its edges, as reconstructed EIT images are.

tiv_per_breath

tiv_per_breath(pixels: ndarray, breaths: ndarray) -> np.ndarray

Tidal impedance variation of each pixel at the timing of the global signal.

Parameters:

Name Type Description Default
pixels ndarray

Array (N, rows, cols).

required
breaths ndarray

Integer array (n_breaths, 3): start, peak, end frame.

required

Returns:

Name Type Description
Array (n_breaths, rows, cols)

pixel at the peak minus pixel at the start.

pixel_lag

pixel_lag(global_detrended: ndarray, pixel_detrended: ndarray, max_lag: float) -> int | None

Lag between the global signal and a pixel, in samples.

The cross-correlation peak closest to zero lag within ± max_lag (the higher one if two are equally close), as in eitprocessing PixelBreath. The lag is minus the delay of the pixel: a pixel that inflates 10 samples after the global signal gives -10, and its own peaks are at global_peaks - lag.

Parameters:

Name Type Description Default
global_detrended ndarray

Global signal without its linear trend.

required
pixel_detrended ndarray

Pixel signal without its linear trend.

required
max_lag float

Largest lag searched, in samples.

required

Returns:

Type Description
int | None

The lag, or None if the cross-correlation has no clear positive peak.

amplitude_per_breath

amplitude_per_breath(pixels: ndarray, global_signal: ndarray, breaths: ndarray) -> np.ndarray

Amplitude of each pixel in each breath, on the pixel's own timing.

For each pixel: the global peaks are moved to the pixel's own timing (pixel_lag); between two shifted peaks the pixel minimum is its start of inspiration, between two minima its maximum is its peak; amplitude = peak - start. As in eitprocessing, the first and last breath have no value, a breath whose shifted peak falls outside the recording or whose order is wrong is skipped, and a pixel that loses more than MAX_SKIPPED_FRACTION of its breaths is left out.

Parameters:

Name Type Description Default
pixels ndarray

Array (N, rows, cols); a pixel is either NaN in every frame or in none.

required
global_signal ndarray

1-D signal used for the breath detection.

required
breaths ndarray

Integer array (n_breaths, 3), consecutive breaths.

required

Returns:

Type Description
ndarray

Array (n_breaths, rows, cols), NaN where there is no amplitude.

watershed_mask

watershed_mask(pixels: ndarray, global_signal: ndarray, breaths: ndarray, use_breath: ndarray, threshold_fraction: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]

Lung mask from the TIV and amplitude maps (steps 1-4).

TIV and amplitude are computed on all breaths, because the pixel timing needs consecutive breaths, and averaged only over the breaths in use_breath that have both a TIV and an amplitude (e.g. the valid breaths). With every breath used the result is the same as eitprocessing WatershedLungspace.

Parameters:

Name Type Description Default
pixels ndarray

Array (N, rows, cols), after the cardiac filter.

required
global_signal ndarray

1-D signal used for the breath detection.

required
breaths ndarray

Integer array (n_breaths, 3) from detect_breaths.

required
use_breath ndarray

Boolean array (n_breaths,): breaths to average over.

required
threshold_fraction float

Fraction of the maximum TIV (0.15 in eitprocessing).

required

Returns:

Type Description
ndarray

mask (rows, cols) bool, mean tiv_map and mean amplitude_map. The

ndarray

first and last breath are never in the means (they have no amplitude); a

ndarray

pixel without data is NaN in the maps.

Raises:

Type Description
ValueError

if no breath can be used, the TIV map has no positive maximum (inverted signal), or no amplitude peak lies inside the TIV mask.

remove_small_regions

remove_small_regions(mask: ndarray, min_pixels: int) -> np.ndarray

Remove the islands of the mask smaller than min_pixels (step 5).

Pixels belong to the same island when they share a side (4-connectivity), as in eitprocessing FilterROIBySize.

regions

Regions of interest inside the lung mask.

Orientation of Draeger images (PulmoVista 500 instructions for use): seen from the feet, like a CT slice. Row 0 is ventral; column 0 is the patient's RIGHT, column 31 the patient's left. Consistent with the power at the heart rate per pixel (notebooks/05_preprocessing_choices.ipynb, section 9). Verified on Draeger only.

Regions (section 9): - ventral/dorsal halves and four layers are cut on the vertical extent of the lung (first to last row of the mask), not on the image, so "dorsal" is always the dorsal part of that lung wherever the lungs sit in the image. Same approach as the createSummersROIs.m script of the original MATLAB analysis; the published source of this approach is still to be found; - right/left halves are split at the image midline (between columns 15 and 16), the sternum-spine axis with the belt centred on the patient; - quadrants = ventral/dorsal x right/left; - every region is intersected with the lung mask.

Limit: these regions are relative to the VENTILATED lung. If the dorsal lung collapses, its pixels leave the mask and "dorsal" becomes the dorsal half of what is still ventilated, so the ventral/dorsal split hides part of a ventral shift of ventilation. Indices meant to show a shift of ventilation (centre of ventilation, dorsal fraction, silent spaces) must be computed in image coordinates, not on these regions.

Not the same as ReconstructedFrameData.roi_signals (layer 1), which are four fixed strips of 8 image rows each.

lung_regions

lung_regions(mask: ndarray) -> dict[str, np.ndarray]

All the regions of interest of a lung mask.

Parameters:

Name Type Description Default
mask ndarray

Boolean lung mask (rows, cols), row 0 ventral, column 0 patient right.

required

Returns:

Type Description
dict[str, ndarray]

Dict of boolean masks, each inside mask: ventral, dorsal,

dict[str, ndarray]

right, left, layer_1 (most ventral) to layer_4 (most

dict[str, ndarray]

dorsal), ventral_right, ventral_left, dorsal_right,

dict[str, ndarray]

dorsal_left.

Raises:

Type Description
ValueError

if the mask is empty.

auxiliary

Auxiliary signals recorded with the images: pressures, flow, volume, device markers.

The parsers keep them as they are in the file (raw.aux_signals): for Draeger .bin the channels that were not available hold "no data" values (-1000, or a bit pattern read as about -3.4e38) instead of NaN, and each vendor has its own units. Here they are cleaned once, so the next layers read every vendor in the same way: "no data" as NaN and the same units, pressures in cmH2O, flow in L/s, volumes in mL.

The signals are not filtered and not filled: a missing value stays NaN.

common_unit

common_unit(name: str, unit: str | None) -> tuple[str | None, float]

Common unit of a channel and the factor that converts it from its parser unit.

Parameters:

Name Type Description Default
name str

Channel name, e.g. "airway_pressure_pod".

required
unit str | None

Unit given by the parser (None if unknown).

required

Returns:

Type Description
tuple[str | None, float]

The common unit and the factor; unknown or other units are kept (factor 1).

auxiliary_signals

auxiliary_signals(raw: ReconstructedFrameData) -> tuple[pd.DataFrame, dict[str, str | None]]

One column per auxiliary channel that has data, one row per frame, and its unit.

  • Draeger "no data" values become NaN (Timpel channels are already NaN from the parser);
  • channels without any value are left out (e.g. Medibus when no ventilator was connected);
  • units become common to every vendor: pressures in cmH2O (Draeger gives mbar), flow in L/s, volumes in mL (Timpel gives L), and the same for the values derived from them (e.g. compliance in mL/cmH2O); other units are kept;
  • Draeger min_max_flag (+1 end of inspiration, -1 end of expiration, 0 neither) and event_marker are added; Timpel min_flag, max_flag and qrs_flag are already among its channels.

Rows are frame numbers, the same as start_idx, peak_idx and end_idx of the breath table. The units of the parsers are in fasteit.parsers.draeger.bin.draeger_dtypes and timpel_dtypes.

Parameters:

Name Type Description Default
raw ReconstructedFrameData

Parsed recording; never modified.

required

Returns:

Type Description
DataFrame

DataFrame with N rows (empty columns if there are no auxiliary signals), and

dict[str, str | None]

the unit of each column ("" for markers and flags, None if unknown).

breath_pressures

Airway pressures of each breath: PEEP, plateau and driving pressure.

Read on the airway pressure curve (cmH2O), all from the same channel so that a constant offset of the sensor cancels in the driving pressure (notebooks/06_peep_trial_segmentation.ipynb, sections 2 and 4). The PEEP of a breath is the end-expiratory pressure at the airway opening: the PEEP applied by the ventilator, without intrinsic PEEP (that would need an end-expiratory occlusion), so it is a lower bound of the total PEEP; with intrinsic PEEP the driving pressure is overestimated and compliance underestimated. A breath whose pressure is missing (NaN) near its start gets NaN.

inspiration_onsets

inspiration_onsets(paw: ndarray, fs: float, eit_starts: ndarray) -> np.ndarray

Start of inspiration on the pressure curve, near the start of each EIT breath.

The steepest rise of the pressure is searched from 1 s before to 0.5 s after the start of the EIT breath; the start of inspiration is the last minimum of the pressure in the 0.5 s before it. The EIT valley falls within about 0.1 s of it, but there the pressure already rises quickly (notebook 06, section 2).

Parameters:

Name Type Description Default
paw ndarray

Airway pressure, one value per frame.

required
fs float

Sampling frequency in Hz.

required
eit_starts ndarray

Frame of the start of each EIT breath (breaths.start_idx).

required

Returns:

Type Description
ndarray

Frame of the start of inspiration of each breath; -1 where the pressure is

ndarray

missing in the search window.

pressure_before

pressure_before(paw: ndarray, fs: float, frames: ndarray, seconds: float) -> np.ndarray

Median pressure in the seconds before each frame (the PEEP of a breath).

Parameters:

Name Type Description Default
paw ndarray

Airway pressure, one value per frame.

required
fs float

Sampling frequency in Hz.

required
frames ndarray

Frames, e.g. the starts of inspiration (-1 = unknown).

required
seconds float

Length of the reading.

required

Returns:

Type Description
ndarray

One median per frame; NaN if the frame is unknown, too close to the start of the

ndarray

recording, or the pressure is missing in the reading.

plateau_pressures

plateau_pressures(paw: ndarray, fs: float, onsets: ndarray, peeps: ndarray, window: tuple[float, float]) -> np.ndarray

Plateau pressure of each breath, read at the end of the end-inspiratory hold.

Between the start of inspiration of a breath and the next one: after the highest part of the breath (the peak in volume control, the flat pressure in pressure control), the start of expiration is the steepest fall of the pressure in the 0.3 s before it comes back within 2 of the next PEEP; the plateau is the median pressure from window[0] to window[1] seconds before it (notebook 06, section 4).

A valid plateau needs at least 0.5 s between the highest part and the expiration: an end-inspiratory hold with zero flow in volume control (continuous pause, or an inspiratory hold done by hand), or the flat inspiratory pressure in pressure control, which is a plateau only if the flow is zero at the end of inspiration. Otherwise the plateau is NaN: the reading would fall on the relaxation after the peak, and the peak pressure is never used instead because it includes the resistive pressure.

Parameters:

Name Type Description Default
paw ndarray

Airway pressure, one value per frame.

required
fs float

Sampling frequency in Hz.

required
onsets ndarray

Start of inspiration of each breath (inspiration_onsets).

required
peeps ndarray

PEEP of each breath (pressure_before at the onsets).

required
window tuple[float, float]

(start, end) of the reading, in seconds before the start of expiration.

required

Returns:

Type Description
ndarray

One plateau per breath; NaN for the last breath (no next breath), for unknown

ndarray

onsets, for breaths with missing pressure and where no hold is found.

tidal_volume

Tidal volume of each breath from the ventilator curves (mL).

The inspiratory tidal volume: in volume control it is the volume delivered, the one set on the ventilator. It is read on the volume curve, or on the flow curve by integrating the inspiratory flow, in a window around the inspiration of each EIT breath. It is the volume measured by the ventilator: with a leak (non-invasive ventilation, a leaking cuff) it includes the leak, and without compensation of the compressible volume of the circuit it includes that too.

Tested on synthetic curves only: the recordings available have no volume or flow curve. Inspiratory flow is taken as positive, as recorded by the ventilators.

inspiration_windows

inspiration_windows(starts: ndarray, peaks: ndarray, ends: ndarray, n_frames: int) -> tuple[np.ndarray, np.ndarray]

Frames around the inspiration of each breath, from the EIT breath.

From half an expiration before the start of the breath (but not before the middle of the expiration of the breath before) to the middle of its own expiration. The window holds the whole inspiration even when the EIT valley falls a little after the start of the inspiratory flow (within about 0.1 s in notebooks/06_peep_trial_segmentation.ipynb, section 2), and nothing of the inspirations before and after it, also after a long inspiratory hold.

Parameters:

Name Type Description Default
starts ndarray

Frame of the start of each breath.

required
peaks ndarray

Frame of the peak of each breath.

required
ends ndarray

Frame of the end of each breath.

required
n_frames int

Number of frames of the recording.

required

Returns:

Type Description
tuple[ndarray, ndarray]

First frame and frame after the last of each window.

volumes_from_volume_curve

volumes_from_volume_curve(volume: ndarray, first: ndarray, after_last: ndarray) -> np.ndarray

Inspiratory tidal volume on a volume curve (mL).

The highest volume in the window minus the lowest one before it, so that it works whether or not the ventilator sets the volume back to zero at each breath. If it does, and more volume came out than went in during the breath before (e.g. the first breaths after a decrease of PEEP, as the lung empties), the difference is added: the lowest value is then the end of that expiration.

Parameters:

Name Type Description Default
volume ndarray

Volume curve in mL, one value per frame.

required
first ndarray

First frame of the window of each breath (inspiration_windows).

required
after_last ndarray

Frame after the last of each window.

required

Returns:

Type Description
ndarray

One volume per breath; NaN if the volume is missing in the window.

volumes_from_flow_curve

volumes_from_flow_curve(flow: ndarray, fs: float, first: ndarray, after_last: ndarray) -> np.ndarray

Inspiratory tidal volume on a flow curve (mL): integral of the inspiratory flow.

The flow is integrated over the inspiration: the run of positive flow around its highest value in the window. Noise and cardiac oscillations around zero flow (end-inspiratory pause, end of expiration) are left out, unlike a sum of all the positive flow of the window. A zero offset of the flow sensor adds offset x duration of the inspiration (e.g. 0.01 L/s over 1 s = 10 mL).

Parameters:

Name Type Description Default
flow ndarray

Flow curve in L/s, one value per frame, inspiration positive.

required
fs float

Sampling frequency in Hz.

required
first ndarray

First frame of the window of each breath (inspiration_windows).

required
after_last ndarray

Frame after the last of each window.

required

Returns:

Type Description
ndarray

One volume per breath; NaN if the flow is missing in the window, 0 if it is

ndarray

never positive.