DICOM Basics using .NET and C# - Waveforms (ECG, EEG)

Introduction

This is part of my series of articles on the DICOM standard. In this tutorial, we'll explore DICOM Waveforms, which provide standardized storage for physiological signals like ECG (electrocardiogram), EEG (electroencephalogram), and other time-series data.

Waveform data is commonly acquired alongside medical images, such as ECG gating for cardiac CT or cardiac MR. DICOM provides specific IODs for storing this data in a standardized format.

Prerequisites

Before you begin, ensure you have the following:

  • A .NET development environment (Visual Studio or Visual Studio Code)
  • The Fellow Oak DICOM library (fo-dicom) installed via NuGet
  • Basic understanding of DICOM concepts from previous tutorials
  • You can find all the code demonstrated in this tutorial on GitHub here

“The rhythm of the body, the melody of the mind, and the harmony of the soul create the symphony of life.” ~ B.K.S. Iyengar

The Theory Behind Waveforms

Medical imaging traditionally meant spatial data: 2D images or 3D volumes. But diagnostic data also includes time-series signals: the ECG tracing during a cardiac CT, the EEG during an fMRI study, the respiratory signal used for gating. DICOM Waveforms extend the standard beyond images to encompass these physiological signals, creating a unified framework for all diagnostic data types.

The multi-channel data model reflects how physiological signals are acquired. A 12-lead ECG records 12 simultaneous signals from different electrode positions. Rather than storing 12 separate files, DICOM Waveforms stores them as a multi-channel recording with explicit channel definitions. Each channel has its own sensitivity, units, and source identification, but all share timing and sampling parameters.

Sampling theory underpins waveform storage. The Nyquist theorem requires sampling at least twice the highest frequency of interest. ECG signals have clinically relevant content up to ~100 Hz, requiring at least 200 Hz sampling - typically 500 Hz is used for adequate resolution. The Sampling Frequency attribute captures this critical parameter, enabling correct temporal reconstruction.

The Channel Definition Sequence provides semantic context for each channel. Using ISO/IEEE 11073 (MDC) codes, each channel identifies what it measures: MDC_ECG_LEAD_I for Lead I, MDC_ECG_LEAD_AVR for aVR. This standardized identification enables automated processing - software can find the right lead by code rather than assuming channel ordering. The sensitivity and units attributes enable conversion from stored integers to physical units (microvolts for ECG).

The correlation with images is clinically important. For cardiac CT, ECG gating determines which cardiac phase each image represents. By storing the gating ECG as a DICOM Waveform linked to the CT study, you maintain the temporal relationship. The R-wave positions in the ECG can be aligned with image acquisition times, enabling retrospective analysis of cardiac motion.

Waveform SOP Classes

DICOM defines several waveform SOP Classes:

SOP ClassUIDUse Case
12-lead ECG1.2.840.10008.5.1.4.1.1.9.1.1Standard 12-lead ECG
General ECG1.2.840.10008.5.1.4.1.1.9.1.2Other ECG formats
Ambulatory ECG1.2.840.10008.5.1.4.1.1.9.1.3Holter monitoring
Hemodynamic1.2.840.10008.5.1.4.1.1.9.2.1Blood pressure, etc.
Cardiac Electrophysiology1.2.840.10008.5.1.4.1.1.9.3.1EP studies
Basic Voice Audio1.2.840.10008.5.1.4.1.1.9.4.1Voice recordings
General Audio1.2.840.10008.5.1.4.1.1.9.4.2Other audio
Arterial Pulse1.2.840.10008.5.1.4.1.1.9.5.1Pulse waveforms
Respiratory1.2.840.10008.5.1.4.1.1.9.6.1Respiratory signals

Waveform Structure

DICOM waveforms use a specific structure:

using System;
using System.Diagnostics;
using FellowOakDicom;

namespace DicomWaveforms
{
    public class Program
    {
        public static void Main(string[] args)
        {
            LogToDebugConsole("=== DICOM Waveforms Demo ===");
            LogToDebugConsole("");

            DemonstrateWaveformStructure();
            DemonstrateChannelDefinitions();
            DemonstrateECGExample();
        }

        private static void DemonstrateWaveformStructure()
        {
            LogToDebugConsole("--- Waveform IOD Structure ---");
            LogToDebugConsole("");

            LogToDebugConsole("Key Components:");
            LogToDebugConsole("");
            LogToDebugConsole("1. Waveform Sequence (5400,0100)");
            LogToDebugConsole("   Contains one or more waveform items");
            LogToDebugConsole("   Each item represents a recording");
            LogToDebugConsole("");

            LogToDebugConsole("2. Within Each Waveform Item:");
            LogToDebugConsole("   - (003A,0005) Number of Channels");
            LogToDebugConsole("   - (003A,0010) Number of Waveform Samples");
            LogToDebugConsole("   - (003A,001A) Sampling Frequency");
            LogToDebugConsole("   - (003A,0020) Multiplex Group Time Offset");
            LogToDebugConsole("   - (5400,1004) Waveform Bits Allocated");
            LogToDebugConsole("   - (5400,1006) Waveform Sample Interpretation");
            LogToDebugConsole("   - (5400,100A) Waveform Padding Value");
            LogToDebugConsole("   - (5400,1010) Waveform Data");
            LogToDebugConsole("");

            LogToDebugConsole("3. Channel Definition Sequence (003A,0200)");
            LogToDebugConsole("   Defines each channel's properties");
            LogToDebugConsole("   One item per channel");
        }

        private static void LogToDebugConsole(string message)
        {
            Debug.WriteLine(message);
        }
    }
}

Key Waveform Attributes

TagNameDescription
(5400,0100)Waveform SequenceContains waveform data
(003A,0005)Number of Waveform ChannelsChannels per multiplex group
(003A,0010)Number of Waveform SamplesSamples per channel
(003A,001A)Sampling FrequencySamples per second (Hz)
(5400,1004)Waveform Bits Allocated8 or 16 bits per sample
(5400,1006)Waveform Sample InterpretationSS (signed), US (unsigned)
(5400,1010)Waveform DataActual sample values

Channel Definition Sequence

Each channel is described by a Channel Definition Sequence item:

private static void DemonstrateChannelDefinitions()
{
    LogToDebugConsole("--- Channel Definition Sequence (003A,0200) ---");
    LogToDebugConsole("");

    LogToDebugConsole("For each channel:");
    LogToDebugConsole("");
    LogToDebugConsole("  (003A,0202) Waveform Channel Number");
    LogToDebugConsole("  (003A,0203) Channel Label (e.g., \"I\", \"II\", \"V1\")");
    LogToDebugConsole("  (003A,0208) Channel Source Sequence");
    LogToDebugConsole("    - Coded concept for channel type");
    LogToDebugConsole("    - MDC codes (ISO/IEEE 11073)");
    LogToDebugConsole("");
    LogToDebugConsole("  (003A,0210) Channel Sensitivity");
    LogToDebugConsole("    - Units per sample unit");
    LogToDebugConsole("    - e.g., 2.5 for 2.5 uV/unit");
    LogToDebugConsole("");
    LogToDebugConsole("  (003A,0211) Channel Sensitivity Units Sequence");
    LogToDebugConsole("    - Unit of measurement");
    LogToDebugConsole("    - e.g., uV (microvolts) for ECG");
    LogToDebugConsole("");
    LogToDebugConsole("  (003A,0212) Channel Sensitivity Correction Factor");
    LogToDebugConsole("  (003A,0213) Channel Baseline");
    LogToDebugConsole("  (003A,0214) Channel Time Skew");
    LogToDebugConsole("  (003A,021A) Waveform Bits Stored");
    LogToDebugConsole("");

    LogToDebugConsole("Channel Source Codes (MDC):");
    LogToDebugConsole("  MDC_ECG_LEAD_I     - ECG Lead I");
    LogToDebugConsole("  MDC_ECG_LEAD_II    - ECG Lead II");
    LogToDebugConsole("  MDC_ECG_LEAD_III   - ECG Lead III");
    LogToDebugConsole("  MDC_ECG_LEAD_AVR   - ECG aVR");
    LogToDebugConsole("  MDC_ECG_LEAD_AVL   - ECG aVL");
    LogToDebugConsole("  MDC_ECG_LEAD_AVF   - ECG aVF");
    LogToDebugConsole("  MDC_ECG_LEAD_V1    - ECG V1");
    LogToDebugConsole("  ... through V6");
}

Creating an ECG Waveform

Here's an example of creating a 12-lead ECG:

private static void DemonstrateECGExample()
{
    LogToDebugConsole("--- Creating 12-Lead ECG ---");
    LogToDebugConsole("");

    LogToDebugConsole("Example 12-Lead ECG Structure:");
    LogToDebugConsole("");
    LogToDebugConsole("SOP Class: 1.2.840.10008.5.1.4.1.1.9.1.1 (12-lead ECG)");
    LogToDebugConsole("Modality: ECG");
    LogToDebugConsole("");

    LogToDebugConsole("Waveform Sequence Item:");
    LogToDebugConsole("  Number of Channels: 12");
    LogToDebugConsole("  Number of Samples: 5000 (10 seconds @ 500 Hz)");
    LogToDebugConsole("  Sampling Frequency: 500 Hz");
    LogToDebugConsole("  Bits Allocated: 16");
    LogToDebugConsole("  Sample Interpretation: SS (signed short)");
    LogToDebugConsole("");

    LogToDebugConsole("Channels (12 leads):");
    LogToDebugConsole("  1. Lead I     7. V1");
    LogToDebugConsole("  2. Lead II    8. V2");
    LogToDebugConsole("  3. Lead III   9. V3");
    LogToDebugConsole("  4. aVR       10. V4");
    LogToDebugConsole("  5. aVL       11. V5");
    LogToDebugConsole("  6. aVF       12. V6");
    LogToDebugConsole("");

    LogToDebugConsole("Each channel:");
    LogToDebugConsole("  Sensitivity: 2.5 uV/unit");
    LogToDebugConsole("  Units: uV (microvolts)");
}

Creating Waveform with fo-dicom

Here's how to create a waveform dataset:

public static DicomDataset CreateEcgWaveform()
{
    var dataset = new DicomDataset();

    // SOP Common Module
    dataset.Add(DicomTag.SOPClassUID, DicomUID.TwelveLeadECGWaveformStorage);
    dataset.Add(DicomTag.SOPInstanceUID, DicomUID.Generate());

    // Patient Module
    dataset.Add(DicomTag.PatientName, "Doe^John");
    dataset.Add(DicomTag.PatientID, "PAT123");

    // General Study Module
    dataset.Add(DicomTag.StudyInstanceUID, DicomUID.Generate());
    dataset.Add(DicomTag.StudyDate, DateTime.Now.ToString("yyyyMMdd"));
    dataset.Add(DicomTag.AccessionNumber, "ECG001");

    // General Series Module
    dataset.Add(DicomTag.Modality, "ECG");
    dataset.Add(DicomTag.SeriesInstanceUID, DicomUID.Generate());

    // Waveform Module - Waveform Sequence
    var waveformSeq = new DicomSequence(DicomTag.WaveformSequence);
    var waveformItem = CreateWaveformItem();
    waveformSeq.Items.Add(waveformItem);
    dataset.Add(waveformSeq);

    return dataset;
}

private static DicomDataset CreateWaveformItem()
{
    var item = new DicomDataset();

    // Waveform parameters
    int numChannels = 12;
    int samplingRate = 500;  // Hz
    int durationSeconds = 10;
    int numSamples = samplingRate * durationSeconds;

    item.Add(DicomTag.NumberOfWaveformChannels, (ushort)numChannels);
    item.Add(DicomTag.NumberOfWaveformSamples, (uint)numSamples);
    item.Add(DicomTag.SamplingFrequency, (decimal)samplingRate);
    item.Add(DicomTag.WaveformBitsAllocated, (ushort)16);
    item.Add(DicomTag.WaveformSampleInterpretation, "SS");  // Signed Short

    // Channel Definition Sequence
    var channelSeq = new DicomSequence(DicomTag.ChannelDefinitionSequence);
    string[] leadNames = { "I", "II", "III", "aVR", "aVL", "aVF",
                           "V1", "V2", "V3", "V4", "V5", "V6" };

    for (int i = 0; i < numChannels; i++)
    {
        var channel = CreateChannelDefinition(i + 1, leadNames[i]);
        channelSeq.Items.Add(channel);
    }
    item.Add(channelSeq);

    // Waveform Data - interleaved samples
    // In practice, this would contain actual ECG data
    short[] waveformData = new short[numChannels * numSamples];
    // ... populate with actual ECG samples ...

    byte[] waveformBytes = new byte[waveformData.Length * 2];
    Buffer.BlockCopy(waveformData, 0, waveformBytes, 0, waveformBytes.Length);
    item.Add(new DicomOtherWord(DicomTag.WaveformData, waveformBytes));

    return item;
}

private static DicomDataset CreateChannelDefinition(int channelNum, string label)
{
    var channel = new DicomDataset();

    channel.Add(DicomTag.WaveformChannelNumber, (ushort)channelNum);
    channel.Add(DicomTag.ChannelLabel, label);
    channel.Add(DicomTag.ChannelSensitivity, 2.5m);  // 2.5 uV per unit

    // Channel Sensitivity Units Sequence
    var unitsSeq = new DicomSequence(DicomTag.ChannelSensitivityUnitsSequence);
    var unitsItem = new DicomDataset();
    unitsItem.Add(DicomTag.CodeValue, "uV");
    unitsItem.Add(DicomTag.CodingSchemeDesignator, "UCUM");
    unitsItem.Add(DicomTag.CodeMeaning, "microvolt");
    unitsSeq.Items.Add(unitsItem);
    channel.Add(unitsSeq);

    return channel;
}

Reading Waveform Data

To read and process waveform data:

public static void ReadWaveform(string filePath)
{
    var file = DicomFile.Open(filePath);
    var dataset = file.Dataset;

    var waveformSeq = dataset.GetSequence(DicomTag.WaveformSequence);
    if (waveformSeq == null) return;

    foreach (var wfItem in waveformSeq.Items)
    {
        var numChannels = wfItem.GetSingleValue<ushort>(DicomTag.NumberOfWaveformChannels);
        var numSamples = wfItem.GetSingleValue<uint>(DicomTag.NumberOfWaveformSamples);
        var samplingFreq = wfItem.GetSingleValue<decimal>(DicomTag.SamplingFrequency);

        LogToDebugConsole($"Channels: {numChannels}");
        LogToDebugConsole($"Samples: {numSamples}");
        LogToDebugConsole($"Sampling Rate: {samplingFreq} Hz");
        LogToDebugConsole($"Duration: {numSamples / samplingFreq} seconds");

        // Read channel definitions
        var channelSeq = wfItem.GetSequence(DicomTag.ChannelDefinitionSequence);
        foreach (var ch in channelSeq.Items)
        {
            var label = ch.GetSingleValueOrDefault(DicomTag.ChannelLabel, "");
            var sensitivity = ch.GetSingleValueOrDefault(DicomTag.ChannelSensitivity, 0m);
            LogToDebugConsole($"  Channel: {label}, Sensitivity: {sensitivity}");
        }
    }
}

Common Use Cases

  • Cardiac CT/MR: ECG gating signals for cardiac imaging
  • Stress Testing: ECG during treadmill stress tests
  • Holter Monitoring: 24-48 hour ambulatory ECG
  • EP Studies: Electrophysiology catheter recordings
  • Neuroimaging: EEG during fMRI or PET
  • Pulmonary Function: Respiratory waveforms

Best Practices

  • Use standard codes: MDC codes for channel sources
  • Document calibration: Include sensitivity and units
  • Adequate sampling: Follow clinical standards for sampling rate
  • Link to images: Reference related imaging studies
  • Include annotations: Use Waveform Annotation Sequence for events

Conclusion

DICOM Waveforms provide a standardized format for storing physiological signals alongside medical images. Understanding the waveform structure enables integration of ECG, EEG, and other time-series data into PACS and clinical workflows.

The most common use case is 12-lead ECG storage, but DICOM supports a wide range of physiological signals. When working with waveforms, pay careful attention to channel definitions, sampling rates, and calibration factors to ensure accurate signal representation.

Please check out the next tutorial in this series where we cover multi-modality DICOM examples.