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 Class | UID | Use Case |
|---|---|---|
| 12-lead ECG | 1.2.840.10008.5.1.4.1.1.9.1.1 | Standard 12-lead ECG |
| General ECG | 1.2.840.10008.5.1.4.1.1.9.1.2 | Other ECG formats |
| Ambulatory ECG | 1.2.840.10008.5.1.4.1.1.9.1.3 | Holter monitoring |
| Hemodynamic | 1.2.840.10008.5.1.4.1.1.9.2.1 | Blood pressure, etc. |
| Cardiac Electrophysiology | 1.2.840.10008.5.1.4.1.1.9.3.1 | EP studies |
| Basic Voice Audio | 1.2.840.10008.5.1.4.1.1.9.4.1 | Voice recordings |
| General Audio | 1.2.840.10008.5.1.4.1.1.9.4.2 | Other audio |
| Arterial Pulse | 1.2.840.10008.5.1.4.1.1.9.5.1 | Pulse waveforms |
| Respiratory | 1.2.840.10008.5.1.4.1.1.9.6.1 | Respiratory 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
| Tag | Name | Description |
|---|---|---|
| (5400,0100) | Waveform Sequence | Contains waveform data |
| (003A,0005) | Number of Waveform Channels | Channels per multiplex group |
| (003A,0010) | Number of Waveform Samples | Samples per channel |
| (003A,001A) | Sampling Frequency | Samples per second (Hz) |
| (5400,1004) | Waveform Bits Allocated | 8 or 16 bits per sample |
| (5400,1006) | Waveform Sample Interpretation | SS (signed), US (unsigned) |
| (5400,1010) | Waveform Data | Actual 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.