dicomwf 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ Copyright 2025 Inria
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
dicomwf-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: dicomwf
3
+ Version: 0.3.0
4
+ Summary: A Python library for creating DICOM EEG waveform files
5
+ Author: Clément Chesnin
6
+ Author-email: Evgenia Kartsaki <evgenia.kartsaki@inria.fr>, Thomas Prampart <thomas.prampart@inria.fr>, Michael Kain <michael.kain@inria.fr>, Eric Poiseau <eric.poiseau@inria.fr>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://gitlab.inria.fr/openvibe/dicomwf
9
+ Project-URL: Repository, https://gitlab.inria.fr/openvibe/dicomwf
10
+ Keywords: dicom,eeg,waveform,medical,imaging
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Healthcare Industry
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Development Status :: 5 - Production/Stable
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
19
+ Classifier: Topic :: Scientific/Engineering :: Physics
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: ==3.11.*
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE.md
24
+ Requires-Dist: numpy>=1.20.0
25
+ Requires-Dist: pydicom>=2.3.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
28
+ Requires-Dist: mne>=1.11.0; extra == "dev"
29
+ Requires-Dist: build>=1.4.0; extra == "dev"
30
+ Requires-Dist: twine>=6.0.0; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # dicomwf
34
+
35
+ A Python library for integrating waveforms in DICOM files.
36
+
37
+ ## Features
38
+
39
+ Supported waveforms IODs:
40
+ - RoutineScalpEEG
41
+
42
+ ## Installation
43
+
44
+ ### User Installation
45
+
46
+ Install directly from PyPI:
47
+
48
+ ```bash
49
+ pip install dicomwf
50
+ ```
51
+
52
+ ### Developer Installation
53
+
54
+ 1. Clone the repository:
55
+
56
+ ```bash
57
+ git clone https://gitlab.inria.fr/openvibe/dicomwf.git
58
+ cd dicomwf
59
+ ```
60
+
61
+ 2. Create and activate a virtual environment:
62
+
63
+ ```bash
64
+ python -m venv .venv
65
+
66
+ # On Windows
67
+ .venv\Scripts\activate
68
+
69
+ # On Unix/macOS
70
+ source .venv/bin/activate
71
+ ```
72
+
73
+ 3. Install in editable mode with development dependencies:
74
+
75
+ ```bash
76
+ pip install -e ".[dev]"
77
+ ```
78
+
79
+ This installs the package in editable mode along with development tools.
80
+
81
+ ## Quick Start
82
+
83
+ We will use the EEG data in the edf format from the [EEGBCI dataset](https://doi.org/10.1109/TBME.2004.827072).
84
+
85
+ ```python
86
+ import numpy as np
87
+ from dicomwf import *
88
+ from urllib.request import urlretrieve
89
+ import mne
90
+
91
+ # Load EEG data
92
+ url = "https://physionet.org/files/eegmmidb/1.0.0/S001/S001R01.edf?download"
93
+ raw_fname = "S001R01.edf"
94
+ urlretrieve(url, raw_fname)
95
+
96
+ raw = mne.io.read_raw_edf(raw_fname, preload=True)
97
+
98
+ # Create DICOM EEG
99
+ dicom_eeg_data = RoutineScalpEEGData(
100
+ patient=Patient(name="Doe^John", birth_date="20250101", sex="M"),
101
+ study=GeneralStudy(id="1",date="19940423", time="101601.934000", accession_number="1"),
102
+ equipment=GeneralEquipment(
103
+ manufacturer="PyDICOM",
104
+ manufacturer_model_name="PyDICOM",
105
+ device_serial_number="1",
106
+ software_versions="1.0",
107
+ ),
108
+ waveform_identification=WaveformIdentification(),
109
+ waveform=Waveform(
110
+ channel_count=raw.info["nchan"],
111
+ sampling_frequency=raw.info["sfreq"],
112
+ channel_names=raw.ch_names,
113
+ data=np.transpose(raw.get_data()),
114
+ ),
115
+ )
116
+
117
+ dicom_eeg = RoutineScalpEEG(uid_root="1.2.3.4.5.", rse_data=dicom_eeg_data)
118
+ dicom_eeg.save_as("output.dcm")
119
+ ```
120
+
121
+ ## Dependencies
122
+
123
+ **Required:**
124
+ - numpy >= 1.20.0
125
+ - pydicom >= 2.3.0
126
+
127
+ **Optional (for development):**
128
+ - pytest >= 7.0.0
129
+ - mne>=1.11.0
130
+
131
+ ## Running Tests
132
+
133
+ ```bash
134
+ pytest
135
+ ```
136
+
137
+ ## License
138
+
139
+ This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
140
+
141
+ ## Authors
142
+
143
+ - Evgenia Kartsaki
144
+ - Thomas Prampart
145
+ - Michael Kain
146
+ - Eric Poiseau
147
+ - Clement Chesnin
148
+
149
+ ## Data Sources
150
+ This project uses the following data for unit testing:
151
+ * **[EEG Motor Movement/Imagery Dataset]**: Sourced from [https://physionet.org/content/eegmmidb/1.0.0/S001/].
152
+ * **License**: This data is licensed under the [Open Data Commons Attribution License (ODC-By) v1.0](https://physionet.org/content/eegmmidb/view-license/1.0.0/).
153
+
154
+ ## Contributing
155
+
156
+ Contributions are welcome! Please feel free to submit a Pull Request.
@@ -0,0 +1,124 @@
1
+ # dicomwf
2
+
3
+ A Python library for integrating waveforms in DICOM files.
4
+
5
+ ## Features
6
+
7
+ Supported waveforms IODs:
8
+ - RoutineScalpEEG
9
+
10
+ ## Installation
11
+
12
+ ### User Installation
13
+
14
+ Install directly from PyPI:
15
+
16
+ ```bash
17
+ pip install dicomwf
18
+ ```
19
+
20
+ ### Developer Installation
21
+
22
+ 1. Clone the repository:
23
+
24
+ ```bash
25
+ git clone https://gitlab.inria.fr/openvibe/dicomwf.git
26
+ cd dicomwf
27
+ ```
28
+
29
+ 2. Create and activate a virtual environment:
30
+
31
+ ```bash
32
+ python -m venv .venv
33
+
34
+ # On Windows
35
+ .venv\Scripts\activate
36
+
37
+ # On Unix/macOS
38
+ source .venv/bin/activate
39
+ ```
40
+
41
+ 3. Install in editable mode with development dependencies:
42
+
43
+ ```bash
44
+ pip install -e ".[dev]"
45
+ ```
46
+
47
+ This installs the package in editable mode along with development tools.
48
+
49
+ ## Quick Start
50
+
51
+ We will use the EEG data in the edf format from the [EEGBCI dataset](https://doi.org/10.1109/TBME.2004.827072).
52
+
53
+ ```python
54
+ import numpy as np
55
+ from dicomwf import *
56
+ from urllib.request import urlretrieve
57
+ import mne
58
+
59
+ # Load EEG data
60
+ url = "https://physionet.org/files/eegmmidb/1.0.0/S001/S001R01.edf?download"
61
+ raw_fname = "S001R01.edf"
62
+ urlretrieve(url, raw_fname)
63
+
64
+ raw = mne.io.read_raw_edf(raw_fname, preload=True)
65
+
66
+ # Create DICOM EEG
67
+ dicom_eeg_data = RoutineScalpEEGData(
68
+ patient=Patient(name="Doe^John", birth_date="20250101", sex="M"),
69
+ study=GeneralStudy(id="1",date="19940423", time="101601.934000", accession_number="1"),
70
+ equipment=GeneralEquipment(
71
+ manufacturer="PyDICOM",
72
+ manufacturer_model_name="PyDICOM",
73
+ device_serial_number="1",
74
+ software_versions="1.0",
75
+ ),
76
+ waveform_identification=WaveformIdentification(),
77
+ waveform=Waveform(
78
+ channel_count=raw.info["nchan"],
79
+ sampling_frequency=raw.info["sfreq"],
80
+ channel_names=raw.ch_names,
81
+ data=np.transpose(raw.get_data()),
82
+ ),
83
+ )
84
+
85
+ dicom_eeg = RoutineScalpEEG(uid_root="1.2.3.4.5.", rse_data=dicom_eeg_data)
86
+ dicom_eeg.save_as("output.dcm")
87
+ ```
88
+
89
+ ## Dependencies
90
+
91
+ **Required:**
92
+ - numpy >= 1.20.0
93
+ - pydicom >= 2.3.0
94
+
95
+ **Optional (for development):**
96
+ - pytest >= 7.0.0
97
+ - mne>=1.11.0
98
+
99
+ ## Running Tests
100
+
101
+ ```bash
102
+ pytest
103
+ ```
104
+
105
+ ## License
106
+
107
+ This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
108
+
109
+ ## Authors
110
+
111
+ - Evgenia Kartsaki
112
+ - Thomas Prampart
113
+ - Michael Kain
114
+ - Eric Poiseau
115
+ - Clement Chesnin
116
+
117
+ ## Data Sources
118
+ This project uses the following data for unit testing:
119
+ * **[EEG Motor Movement/Imagery Dataset]**: Sourced from [https://physionet.org/content/eegmmidb/1.0.0/S001/].
120
+ * **License**: This data is licensed under the [Open Data Commons Attribution License (ODC-By) v1.0](https://physionet.org/content/eegmmidb/view-license/1.0.0/).
121
+
122
+ ## Contributing
123
+
124
+ Contributions are welcome! Please feel free to submit a Pull Request.
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dicomwf"
7
+ version = "0.3.0" # will be replaced by CI
8
+ description = "A Python library for creating DICOM EEG waveform files"
9
+ readme = "README.md"
10
+ requires-python = "==3.11.*"
11
+ license = "MIT"
12
+ license-files = ["LICENSE.md"]
13
+ authors = [
14
+ {name = "Evgenia Kartsaki", email = "evgenia.kartsaki@inria.fr"},
15
+ {name = "Thomas Prampart", email = "thomas.prampart@inria.fr"},
16
+ {name = "Michael Kain", email = "michael.kain@inria.fr"},
17
+ {name = "Eric Poiseau", email = "eric.poiseau@inria.fr"},
18
+ {name = "Clément Chesnin"},
19
+ ]
20
+ keywords = ["dicom", "eeg", "waveform", "medical", "imaging"]
21
+ classifiers = [
22
+ "Intended Audience :: Developers",
23
+ "Intended Audience :: Healthcare Industry",
24
+ "Intended Audience :: Science/Research",
25
+ "Development Status :: 5 - Production/Stable",
26
+ "Programming Language :: Python",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Operating System :: OS Independent",
29
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
30
+ "Topic :: Scientific/Engineering :: Physics",
31
+ "Topic :: Software Development :: Libraries"
32
+ ]
33
+
34
+ dependencies = [
35
+ "numpy>=1.20.0",
36
+ "pydicom>=2.3.0",
37
+ ]
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest>=7.0.0",
42
+ "mne>=1.11.0",
43
+ "build>=1.4.0",
44
+ "twine>=6.0.0",
45
+ ]
46
+
47
+ [project.urls]
48
+ Homepage = "https://gitlab.inria.fr/openvibe/dicomwf"
49
+ Repository = "https://gitlab.inria.fr/openvibe/dicomwf"
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["src"]
53
+
54
+ [tool.setuptools.package-data]
55
+ dicomwf = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ from .routinescalp import RoutineScalpEEG
2
+ from .data import (
3
+ RoutineScalpEEGData,
4
+ GeneralEquipment,
5
+ Patient,
6
+ GeneralStudy,
7
+ Waveform,
8
+ WaveformIdentification,
9
+ )
10
+
11
+ __all__ = [
12
+ "RoutineScalpEEG",
13
+ "RoutineScalpEEGData",
14
+ "GeneralEquipment",
15
+ "Patient",
16
+ "GeneralStudy",
17
+ "Waveform",
18
+ "WaveformIdentification",
19
+ ]
20
+
@@ -0,0 +1,116 @@
1
+ """Data classes for DICOM Routine Scalp EEG waveform storage.
2
+
3
+ Defines the structured data containers used to populate a DICOM
4
+ Routine Scalp Electroencephalogram (EEG) Waveform object.
5
+ """
6
+
7
+ import datetime
8
+ from dataclasses import dataclass
9
+
10
+ import numpy as np
11
+
12
+
13
+ @dataclass
14
+ class Patient:
15
+ """DICOM Patient module.
16
+
17
+ Attributes:
18
+ name: Patient's full name, using '^' as a separator (e.g. 'Doe^John') (DICOM keyword PatientName).
19
+ birth_date: Birth date (DICOM keyword PatientBirthDate) in DA format (YYYYMMDD).
20
+ sex: Patient sex — ``M``, ``F``, or ``O`` (DICOM keyword PatientSex).
21
+ id: Patient primary identifier. Defaults to ``name`` if not provided (DICOM keyword PatientID).
22
+ """
23
+
24
+ name: str
25
+ birth_date: str
26
+ sex: str
27
+ id: str | None = None
28
+
29
+ def __post_init__(self):
30
+ if self.id is None:
31
+ self.id = self.name
32
+
33
+
34
+ @dataclass
35
+ class GeneralStudy:
36
+ """DICOM General Study module.
37
+
38
+ Attributes:
39
+ id: Study identifier (DICOM keyword StudyID).
40
+ date: Date the study started (DICOM keyword StudyDate) in DICOM DA format (YYYYMMDD).
41
+ time: Time the study started (DICOM keyword StudyTime) in DICOM TM format (HHMMSS.FFFFFF).
42
+ accession_number: A departmental Information System generated number that identifies the Imaging Service Request (DICOM keyword AccessionNumber).
43
+ """
44
+
45
+ id: str
46
+ date: str
47
+ time: str
48
+ accession_number: str
49
+
50
+
51
+ @dataclass
52
+ class GeneralEquipment:
53
+ """DICOM Enhanced General Equipment module.
54
+
55
+ Attributes:
56
+ manufacturer: Equipment manufacturer name (DICOM keyword Manufacturer).
57
+ manufacturer_model_name: Manufacturer's model name of the equipment (DICOM keyword ManufacturerModelName).
58
+ device_serial_number: Manufacturer's serial number of the equipment (DICOM keyword DeviceSerialNumber).
59
+ software_versions: Manufacturer's designation of software version of the equipment (DICOM keyword SoftwareVersions).
60
+ """
61
+
62
+ manufacturer: str
63
+ manufacturer_model_name: str
64
+ device_serial_number: str
65
+ software_versions: str
66
+
67
+
68
+ @dataclass
69
+ class WaveformIdentification:
70
+ """DICOM Waveform Identification module.
71
+
72
+ Attributes:
73
+ content_date_time: Date/time the content was created.
74
+ acquisition_date_time: Date/time that the acquisition of data that resulted in this waveform started (DICOM keyword AcquisitionDateTime).
75
+ instance_number: A number that identifies this waveform (DICOM keyword InstanceNumber). Defaults to "1".
76
+ """
77
+
78
+ content_date_time: datetime.datetime = datetime.datetime.now()
79
+ acquisition_date_time: datetime.datetime = datetime.datetime.now()
80
+ instance_number: str = "1"
81
+
82
+
83
+ @dataclass
84
+ class Waveform:
85
+ """EEG waveform data and metadata. Not a direct mapping to a DICOM module, but used to populate the Waveform Sequence and Channel Definition Sequence.
86
+
87
+ Attributes:
88
+ channel_count: Number of EEG channels.
89
+ sampling_frequency: Sampling frequency in Hz.
90
+ channel_names: Ordered list of channel labels.
91
+ data: NumPy array of shape ``(samples, channels)`` holding the signal values.
92
+ """
93
+
94
+ channel_count: int
95
+ sampling_frequency: float
96
+ channel_names: list[str]
97
+ data: np.ndarray
98
+
99
+
100
+ @dataclass
101
+ class RoutineScalpEEGData:
102
+ """Aggregates all data needed to build a Routine Scalp EEG DICOM object.
103
+
104
+ Attributes:
105
+ patient: Patient module.
106
+ study: Study module.
107
+ equipment: Equipment module.
108
+ waveform_identification: Waveform Identification module.
109
+ waveform: The EEG signal data and channel metadata.
110
+ """
111
+
112
+ patient: Patient
113
+ study: GeneralStudy
114
+ equipment: GeneralEquipment
115
+ waveform_identification: WaveformIdentification
116
+ waveform: Waveform
@@ -0,0 +1,233 @@
1
+ """Builder for DICOM Routine Scalp EEG Waveform Storage objects.
2
+
3
+ Constructs a complete DICOM dataset conforming to the Routine Scalp
4
+ Electroencephalogram Waveform IOD (SOP Class 1.2.840.10008.5.1.4.1.1.9.1.2).
5
+ """
6
+
7
+ import numpy as np
8
+ import pydicom
9
+ from pydicom.dataset import Dataset, FileMetaDataset
10
+ from pydicom.uid import UID, ExplicitVRLittleEndian, ImplicitVRLittleEndian
11
+ from typing import Tuple
12
+
13
+ from dicomwf.data import (
14
+ Patient,
15
+ GeneralStudy,
16
+ GeneralEquipment,
17
+ WaveformIdentification,
18
+ Waveform,
19
+ RoutineScalpEEGData,
20
+ )
21
+
22
+
23
+ class RoutineScalpEEG:
24
+ """Builds and saves a DICOM Routine Scalp EEG Waveform dataset.
25
+
26
+ The constructor populates every required DICOM module from a
27
+ ``RoutineScalpEEGData`` instance. Call ``save_as`` to write the
28
+ result to disk.
29
+
30
+ Attributes:
31
+ ds: A DICOM dataset as a mutable mapping of DICOM Data Elements.
32
+ sop_class_uid: SOP Class UID for Routine Scalp EEG Waveform Storage.
33
+ uid_root: Organisation UID root used to derive all generated UIDs.
34
+ sop_instance_uid: Unique identifier for this SOP instance.
35
+ implementation_class_uid: Implementation Class UID written to file meta.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ uid_root: str,
41
+ rse_data: RoutineScalpEEGData,
42
+ ):
43
+ """Initialise the dataset and populate all DICOM modules.
44
+
45
+ Args:
46
+ uid_root: Organisation-specific UID prefix used when generating
47
+ DICOM UIDs (Study, Series, SOP Instance).
48
+ rse_data: Structured container holding patient, study, equipment,
49
+ waveform identification, and waveform data.
50
+ """
51
+ self.ds = Dataset()
52
+ self.sop_class_uid = pydicom.uid.RoutineScalpElectroencephalogramWaveformStorage
53
+ self.uid_root = uid_root
54
+ self.sop_instance_uid = pydicom.uid.generate_uid(prefix=self.uid_root)
55
+ self.implementation_class_uid = UID("1.2.3.4")
56
+
57
+ self._build_metadata()
58
+ self._build_patient(rse_data.patient)
59
+ self._build_study(rse_data.study)
60
+ self._build_series()
61
+ self._build_enhanced_general_equipment(rse_data.equipment)
62
+ self._build_waveform_identification(rse_data.waveform_identification)
63
+ self._build_waveform(rse_data.waveform)
64
+
65
+ def _build_metadata(self):
66
+ """Populate the DICOM File Meta Information header."""
67
+ file_meta = FileMetaDataset()
68
+ file_meta.MediaStorageSOPClassUID = self.sop_class_uid
69
+ file_meta.MediaStorageSOPInstanceUID = self.sop_instance_uid
70
+ file_meta.ImplementationClassUID = self.implementation_class_uid
71
+ file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
72
+ self.ds.file_meta = file_meta
73
+
74
+ def _build_patient(self, patient: Patient):
75
+ """Populate the Patient module attributes."""
76
+ self.ds.PatientName = patient.name
77
+ self.ds.PatientID = patient.id
78
+ self.ds.PatientBirthDate = patient.birth_date
79
+ self.ds.PatientSex = patient.sex
80
+
81
+ def _build_study(self, study: GeneralStudy):
82
+ """Populate the General Study module attributes."""
83
+ self.ds.StudyDate = study.date
84
+ self.ds.StudyTime = study.time
85
+ self.ds.AccessionNumber = study.accession_number
86
+ self.ds.ReferringPhysicianName = ""
87
+ self.ds.StudyInstanceUID = pydicom.uid.generate_uid(prefix=self.uid_root)
88
+ self.ds.StudyID = study.id
89
+
90
+ def _build_series(self):
91
+ """Populate the General Series module (modality fixed to EEG)."""
92
+ self.ds.Modality = "EEG"
93
+ self.ds.SeriesInstanceUID = pydicom.uid.generate_uid(prefix=self.uid_root)
94
+ self.ds.SeriesNumber = "1"
95
+
96
+ def _build_enhanced_general_equipment(self, equipment: GeneralEquipment):
97
+ """Populate the Enhanced General Equipment module attributes."""
98
+ self.ds.Manufacturer = equipment.manufacturer
99
+ self.ds.ManufacturerModelName = equipment.manufacturer_model_name
100
+ self.ds.DeviceSerialNumber = equipment.device_serial_number
101
+ self.ds.SoftwareVersions = equipment.software_versions
102
+
103
+ def _build_waveform_identification(self, identification: WaveformIdentification):
104
+ """Populate the Waveform Identification module (content and acquisition timestamps)."""
105
+ self.ds.ContentDate = identification.content_date_time.strftime("%Y%m%d")
106
+ self.ds.ContentTime = identification.content_date_time.strftime("%H%M%S.%f")
107
+ self.ds.AcquisitionDateTime = identification.acquisition_date_time.strftime(
108
+ "%Y%m%d%H%M%S.%f"
109
+ )
110
+ self.ds.InstanceNumber = identification.instance_number
111
+
112
+ def _build_channel_definition_sequence(
113
+ self,
114
+ channel_name: str,
115
+ factor: float,
116
+ offset: float,
117
+ ):
118
+ """Build a single Channel Definition Sequence item.
119
+
120
+ Args:
121
+ channel_name: Human-readable label for the channel (stored as
122
+ ``CodeMeaning`` in the Channel Source Sequence).
123
+ factor: Scaling factor (``ChannelSensitivity``) used to convert
124
+ stored integer values back to real-world units.
125
+ offset: Baseline offset (``ChannelBaseline``) added after scaling.
126
+
127
+ Returns:
128
+ A ``pydicom.Dataset`` representing one channel definition.
129
+ """
130
+ channel_definition = Dataset()
131
+ channel_definition.ChannelSampleSkew = "0"
132
+ channel_definition.ChannelSensitivity = f"{factor:.10g}"
133
+ channel_definition.ChannelBaseline = f"{offset:.10g}"
134
+ channel_definition.WaveformBitsStored = 64
135
+ channel_definition.ChannelSourceSequence = [Dataset()]
136
+ source = channel_definition.ChannelSourceSequence[0]
137
+ source.CodeValue = "1.0"
138
+ source.CodingSchemeDesignator = "PYDICOM"
139
+ source.CodingSchemeVersion = "1.0"
140
+ source.CodeMeaning = channel_name
141
+
142
+ return channel_definition
143
+
144
+ def quantize_to_int64(self, data: np.ndarray) -> Tuple[np.ndarray, np.float64, np.float64]:
145
+ """Linearly quantize floating-point waveform data to signed 64-bit integers.
146
+
147
+ The input range ``[min, max]`` is mapped to ``[0, 2**31 - 1]`` so
148
+ the original values can be reconstructed as
149
+ ``value = stored * factor + offset``.
150
+
151
+ Args:
152
+ data: Array of floating-point samples to quantize.
153
+
154
+ Returns:
155
+ A tuple of ``(quantized_data, factor, offset)`` where
156
+ *quantized_data* is an ``int64`` C-contiguous array, *factor*
157
+ is the scaling multiplier, and *offset* is the baseline value.
158
+ """
159
+ min, max = data.min(), data.max()
160
+
161
+ offset = min
162
+
163
+ target_range = 2**31 - 1
164
+ factor = (max - min) / target_range if min != max else 1.0
165
+
166
+ quantized_data = np.round((data - offset) / factor).astype(np.int64).copy(order="C")
167
+ return quantized_data, factor, offset
168
+
169
+ def _build_waveform_sequence(
170
+ self,
171
+ channel_count: int,
172
+ sampling_frequency: float,
173
+ channel_names: list[str],
174
+ data: np.ndarray,
175
+ ):
176
+ """Build a single Waveform Sequence item containing all channels.
177
+
178
+ The floating-point signal data is quantized to ``int64`` via
179
+ ``quantize_to_int64`` and a ``ChannelDefinitionSequence`` is
180
+ created with the corresponding sensitivity/baseline values.
181
+
182
+ Args:
183
+ channel_count: Number of channels in the waveform.
184
+ sampling_frequency: Sampling frequency in Hz.
185
+ channel_names: Ordered list of channel labels.
186
+ data: Array of shape ``(samples, channels)`` with signal values.
187
+
188
+ Returns:
189
+ A ``pydicom.Dataset`` representing one Waveform Sequence item.
190
+ """
191
+ new = Dataset()
192
+ new.WaveformOriginality = "ORIGINAL"
193
+ new.NumberOfWaveformChannels = channel_count
194
+ new.NumberOfWaveformSamples = data.shape[0]
195
+ new.SamplingFrequency = sampling_frequency
196
+
197
+ quantized_data, factor, offset = self.quantize_to_int64(data)
198
+
199
+ new.ChannelDefinitionSequence = []
200
+ for i in range(channel_count):
201
+ new.ChannelDefinitionSequence.append(
202
+ self._build_channel_definition_sequence(channel_names[i], factor, offset)
203
+ )
204
+
205
+ new.WaveformBitsAllocated = 64
206
+ new.WaveformSampleInterpretation = "SV"
207
+ new.WaveformData = quantized_data
208
+
209
+ return new
210
+
211
+ def _build_waveform(
212
+ self,
213
+ waveform: Waveform,
214
+ ):
215
+ """Populate the SOP Common module and the Waveform Sequence."""
216
+ self.ds.SOPInstanceUID = self.sop_instance_uid
217
+ self.ds.SOPClassUID = self.sop_class_uid
218
+ self.ds.WaveformSequence = [
219
+ self._build_waveform_sequence(
220
+ waveform.channel_count,
221
+ waveform.sampling_frequency,
222
+ waveform.channel_names,
223
+ waveform.data,
224
+ )
225
+ ]
226
+
227
+ def save_as(self, filename: str):
228
+ """Write the DICOM dataset to a file.
229
+
230
+ Args:
231
+ filename: Destination file path.
232
+ """
233
+ self.ds.save_as(filename, enforce_file_format=True)
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: dicomwf
3
+ Version: 0.3.0
4
+ Summary: A Python library for creating DICOM EEG waveform files
5
+ Author: Clément Chesnin
6
+ Author-email: Evgenia Kartsaki <evgenia.kartsaki@inria.fr>, Thomas Prampart <thomas.prampart@inria.fr>, Michael Kain <michael.kain@inria.fr>, Eric Poiseau <eric.poiseau@inria.fr>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://gitlab.inria.fr/openvibe/dicomwf
9
+ Project-URL: Repository, https://gitlab.inria.fr/openvibe/dicomwf
10
+ Keywords: dicom,eeg,waveform,medical,imaging
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Healthcare Industry
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Development Status :: 5 - Production/Stable
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
19
+ Classifier: Topic :: Scientific/Engineering :: Physics
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: ==3.11.*
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE.md
24
+ Requires-Dist: numpy>=1.20.0
25
+ Requires-Dist: pydicom>=2.3.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
28
+ Requires-Dist: mne>=1.11.0; extra == "dev"
29
+ Requires-Dist: build>=1.4.0; extra == "dev"
30
+ Requires-Dist: twine>=6.0.0; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # dicomwf
34
+
35
+ A Python library for integrating waveforms in DICOM files.
36
+
37
+ ## Features
38
+
39
+ Supported waveforms IODs:
40
+ - RoutineScalpEEG
41
+
42
+ ## Installation
43
+
44
+ ### User Installation
45
+
46
+ Install directly from PyPI:
47
+
48
+ ```bash
49
+ pip install dicomwf
50
+ ```
51
+
52
+ ### Developer Installation
53
+
54
+ 1. Clone the repository:
55
+
56
+ ```bash
57
+ git clone https://gitlab.inria.fr/openvibe/dicomwf.git
58
+ cd dicomwf
59
+ ```
60
+
61
+ 2. Create and activate a virtual environment:
62
+
63
+ ```bash
64
+ python -m venv .venv
65
+
66
+ # On Windows
67
+ .venv\Scripts\activate
68
+
69
+ # On Unix/macOS
70
+ source .venv/bin/activate
71
+ ```
72
+
73
+ 3. Install in editable mode with development dependencies:
74
+
75
+ ```bash
76
+ pip install -e ".[dev]"
77
+ ```
78
+
79
+ This installs the package in editable mode along with development tools.
80
+
81
+ ## Quick Start
82
+
83
+ We will use the EEG data in the edf format from the [EEGBCI dataset](https://doi.org/10.1109/TBME.2004.827072).
84
+
85
+ ```python
86
+ import numpy as np
87
+ from dicomwf import *
88
+ from urllib.request import urlretrieve
89
+ import mne
90
+
91
+ # Load EEG data
92
+ url = "https://physionet.org/files/eegmmidb/1.0.0/S001/S001R01.edf?download"
93
+ raw_fname = "S001R01.edf"
94
+ urlretrieve(url, raw_fname)
95
+
96
+ raw = mne.io.read_raw_edf(raw_fname, preload=True)
97
+
98
+ # Create DICOM EEG
99
+ dicom_eeg_data = RoutineScalpEEGData(
100
+ patient=Patient(name="Doe^John", birth_date="20250101", sex="M"),
101
+ study=GeneralStudy(id="1",date="19940423", time="101601.934000", accession_number="1"),
102
+ equipment=GeneralEquipment(
103
+ manufacturer="PyDICOM",
104
+ manufacturer_model_name="PyDICOM",
105
+ device_serial_number="1",
106
+ software_versions="1.0",
107
+ ),
108
+ waveform_identification=WaveformIdentification(),
109
+ waveform=Waveform(
110
+ channel_count=raw.info["nchan"],
111
+ sampling_frequency=raw.info["sfreq"],
112
+ channel_names=raw.ch_names,
113
+ data=np.transpose(raw.get_data()),
114
+ ),
115
+ )
116
+
117
+ dicom_eeg = RoutineScalpEEG(uid_root="1.2.3.4.5.", rse_data=dicom_eeg_data)
118
+ dicom_eeg.save_as("output.dcm")
119
+ ```
120
+
121
+ ## Dependencies
122
+
123
+ **Required:**
124
+ - numpy >= 1.20.0
125
+ - pydicom >= 2.3.0
126
+
127
+ **Optional (for development):**
128
+ - pytest >= 7.0.0
129
+ - mne>=1.11.0
130
+
131
+ ## Running Tests
132
+
133
+ ```bash
134
+ pytest
135
+ ```
136
+
137
+ ## License
138
+
139
+ This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
140
+
141
+ ## Authors
142
+
143
+ - Evgenia Kartsaki
144
+ - Thomas Prampart
145
+ - Michael Kain
146
+ - Eric Poiseau
147
+ - Clement Chesnin
148
+
149
+ ## Data Sources
150
+ This project uses the following data for unit testing:
151
+ * **[EEG Motor Movement/Imagery Dataset]**: Sourced from [https://physionet.org/content/eegmmidb/1.0.0/S001/].
152
+ * **License**: This data is licensed under the [Open Data Commons Attribution License (ODC-By) v1.0](https://physionet.org/content/eegmmidb/view-license/1.0.0/).
153
+
154
+ ## Contributing
155
+
156
+ Contributions are welcome! Please feel free to submit a Pull Request.
@@ -0,0 +1,13 @@
1
+ LICENSE.md
2
+ README.md
3
+ pyproject.toml
4
+ src/dicomwf/__init__.py
5
+ src/dicomwf/data.py
6
+ src/dicomwf/routinescalp.py
7
+ src/dicomwf.egg-info/PKG-INFO
8
+ src/dicomwf.egg-info/SOURCES.txt
9
+ src/dicomwf.egg-info/dependency_links.txt
10
+ src/dicomwf.egg-info/requires.txt
11
+ src/dicomwf.egg-info/top_level.txt
12
+ tests/test_routinescalp.py
13
+ tests/test_routinescalp_unit.py
@@ -0,0 +1,8 @@
1
+ numpy>=1.20.0
2
+ pydicom>=2.3.0
3
+
4
+ [dev]
5
+ pytest>=7.0.0
6
+ mne>=1.11.0
7
+ build>=1.4.0
8
+ twine>=6.0.0
@@ -0,0 +1 @@
1
+ dicomwf
@@ -0,0 +1,78 @@
1
+ import pytest
2
+
3
+ import mne
4
+ import numpy as np
5
+ import pydicom
6
+
7
+ from dicomwf import *
8
+ from urllib.request import urlretrieve
9
+
10
+ def test_routinescalp():
11
+
12
+ # Load EEG data
13
+ url = "https://physionet.org/files/eegmmidb/1.0.0/S001/S001R01.edf?download"
14
+ raw_fname = "S001R01.edf"
15
+ urlretrieve(url, raw_fname)
16
+
17
+ raw = mne.io.read_raw_edf(raw_fname, preload=True)
18
+
19
+ # Create a DICOM EEG
20
+ dicom_eeg_data = RoutineScalpEEGData(
21
+ patient=Patient(name="John^Doe", birth_date="20250101", sex="M"),
22
+ study=GeneralStudy(
23
+ date="19940423", time="101601.934000", accession_number="1", id="847542"
24
+ ),
25
+ equipment=GeneralEquipment(
26
+ manufacturer="PyDICOM",
27
+ manufacturer_model_name="PyDICOM",
28
+ device_serial_number="1",
29
+ software_versions="1.0",
30
+ ),
31
+ waveform_identification=WaveformIdentification(),
32
+ waveform=Waveform(
33
+ channel_count=raw.info["nchan"],
34
+ sampling_frequency=raw.info["sfreq"],
35
+ channel_names=raw.ch_names,
36
+ data=np.transpose(raw.get_data()),
37
+ ),
38
+ )
39
+
40
+ dicom_eeg = RoutineScalpEEG(uid_root="1.2.3.4.5.", rse_data=dicom_eeg_data)
41
+ dicom_eeg.save_as("toto.dcm")
42
+
43
+ pydicom_eeg = pydicom.dcmread("toto.dcm")
44
+ assert pydicom_eeg.Modality == "EEG"
45
+ assert pydicom_eeg.PatientName == "John^Doe"
46
+ assert pydicom_eeg.PatientBirthDate == "20250101"
47
+ assert pydicom_eeg.PatientSex == "M"
48
+ assert pydicom_eeg.PatientID == "John^Doe"
49
+ assert pydicom_eeg.AccessionNumber == "1"
50
+ assert pydicom_eeg.StudyDate == "19940423"
51
+ assert pydicom_eeg.StudyTime == "101601.934000"
52
+ assert pydicom_eeg.StudyID == "847542"
53
+ assert pydicom_eeg.Manufacturer == "PyDICOM"
54
+ assert pydicom_eeg.ManufacturerModelName == "PyDICOM"
55
+ assert pydicom_eeg.DeviceSerialNumber == "1"
56
+ assert pydicom_eeg.SoftwareVersions == "1.0"
57
+
58
+ assert len(pydicom_eeg.WaveformSequence) == 1
59
+ waveform = pydicom_eeg.WaveformSequence[0]
60
+ assert waveform.NumberOfWaveformChannels == raw.info["nchan"]
61
+ assert waveform.SamplingFrequency == raw.info["sfreq"]
62
+
63
+
64
+ assert len(waveform.ChannelDefinitionSequence) == raw.info["nchan"]
65
+ factor = waveform.ChannelDefinitionSequence[0].ChannelSensitivity
66
+ offset = waveform.ChannelDefinitionSequence[0].ChannelBaseline
67
+
68
+ for i in range(raw.info["nchan"]):
69
+ assert waveform.ChannelDefinitionSequence[i].ChannelSourceSequence[0].CodeMeaning == raw.ch_names[i]
70
+
71
+ print(len(waveform.WaveformData))
72
+
73
+ data = np.frombuffer(waveform.WaveformData, dtype=np.int64)
74
+ assert len(data) == raw.info["nchan"] * raw.n_times
75
+
76
+ data = np.reshape(data, (raw.n_times, raw.info["nchan"]))
77
+ data = np.transpose(data * factor + offset)
78
+ assert np.allclose(data, raw.get_data(), atol=1e-6)
@@ -0,0 +1,154 @@
1
+ import tempfile
2
+ import os
3
+
4
+ import pytest
5
+ import numpy as np
6
+ import pydicom
7
+ from pydicom.uid import ExplicitVRLittleEndian
8
+
9
+ from dicomwf.routinescalp import RoutineScalpEEG
10
+ from dicomwf.data import Patient
11
+
12
+
13
+ class TestRoutineScalpEEG:
14
+
15
+ def test_init(self, sample_rse_data):
16
+ # Test uid_root is stored
17
+ eeg = RoutineScalpEEG(uid_root="9.8.7.6.5.", rse_data=sample_rse_data)
18
+ assert eeg.uid_root == "9.8.7.6.5."
19
+
20
+ # Test sop_instance_uid uses root
21
+ assert eeg.sop_instance_uid.startswith("9.8.7.6.5.")
22
+
23
+ # Test dataset is created
24
+ assert eeg.ds is not None
25
+
26
+ def test_build_metadata(self, routine_scalp_eeg):
27
+ assert hasattr(routine_scalp_eeg.ds, "file_meta")
28
+ assert routine_scalp_eeg.ds.file_meta is not None
29
+ assert routine_scalp_eeg.ds.file_meta.MediaStorageSOPClassUID == pydicom.uid.RoutineScalpElectroencephalogramWaveformStorage
30
+ assert routine_scalp_eeg.ds.file_meta.MediaStorageSOPInstanceUID == routine_scalp_eeg.sop_instance_uid
31
+ assert routine_scalp_eeg.sop_instance_uid.startswith("1.2.3.4.5.")
32
+ assert routine_scalp_eeg.ds.file_meta.ImplementationClassUID == "1.2.3.4"
33
+ assert routine_scalp_eeg.ds.file_meta.TransferSyntaxUID == ExplicitVRLittleEndian
34
+
35
+ def test_build_patient(self, routine_scalp_eeg):
36
+ assert routine_scalp_eeg.ds.PatientName == "Test^Patient"
37
+ assert routine_scalp_eeg.ds.PatientID == "PAT001"
38
+ assert routine_scalp_eeg.ds.PatientBirthDate == "19900101"
39
+ assert routine_scalp_eeg.ds.PatientSex == "F"
40
+
41
+ # Test patient id defaults to name
42
+ patient = Patient(name="Default^Name", birth_date="20000101", sex="M")
43
+ assert patient.id == "Default^Name"
44
+
45
+ def test_build_study(self, routine_scalp_eeg):
46
+ assert routine_scalp_eeg.ds.StudyDate == "20240115"
47
+ assert routine_scalp_eeg.ds.StudyTime == "143022.000000"
48
+ assert routine_scalp_eeg.ds.AccessionNumber == "ACC123"
49
+ assert routine_scalp_eeg.ds.ReferringPhysicianName == ""
50
+ assert routine_scalp_eeg.ds.StudyInstanceUID.startswith("1.2.3.4.5.")
51
+ assert routine_scalp_eeg.ds.StudyID == "847542"
52
+
53
+ def test_build_series(self, routine_scalp_eeg):
54
+ assert routine_scalp_eeg.ds.Modality == "EEG"
55
+ assert routine_scalp_eeg.ds.SeriesInstanceUID.startswith("1.2.3.4.5.")
56
+ assert routine_scalp_eeg.ds.SeriesNumber == "1"
57
+
58
+ def test_build_enhanced_general_equipment(self, routine_scalp_eeg):
59
+ assert routine_scalp_eeg.ds.Manufacturer == "TestManufacturer"
60
+ assert routine_scalp_eeg.ds.ManufacturerModelName == "TestModel"
61
+ assert routine_scalp_eeg.ds.DeviceSerialNumber == "SN12345"
62
+ assert routine_scalp_eeg.ds.SoftwareVersions == "2.0.1"
63
+
64
+ def test_build_waveform_identification(self, routine_scalp_eeg):
65
+ assert routine_scalp_eeg.ds.ContentDate == "20240115"
66
+ assert routine_scalp_eeg.ds.ContentTime == "143022.123456"
67
+ assert routine_scalp_eeg.ds.AcquisitionDateTime == "20240115142500.000000"
68
+ assert routine_scalp_eeg.ds.InstanceNumber == "5"
69
+
70
+ def test_build_channel_definition_sequence(self, routine_scalp_eeg):
71
+ channel_def = routine_scalp_eeg._build_channel_definition_sequence("TestChannel", 1.5, 0.5)
72
+
73
+ assert channel_def.ChannelSampleSkew == "0"
74
+ assert float(channel_def.ChannelSensitivity) == 1.5
75
+ assert float(channel_def.ChannelBaseline) == 0.5
76
+ # Verify DS values fit within 16 character limit
77
+ assert len(str(channel_def.ChannelSensitivity)) <= 16
78
+ assert len(str(channel_def.ChannelBaseline)) <= 16
79
+ assert channel_def.WaveformBitsStored == 64
80
+
81
+ # Test channel source sequence
82
+ assert len(channel_def.ChannelSourceSequence) == 1
83
+ source = channel_def.ChannelSourceSequence[0]
84
+ assert source.CodeValue == "1.0"
85
+ assert source.CodingSchemeDesignator == "PYDICOM"
86
+ assert source.CodingSchemeVersion == "1.0"
87
+ assert source.CodeMeaning == "TestChannel"
88
+
89
+ def test_quantize_to_int64(self, routine_scalp_eeg):
90
+ # Basic quantization
91
+ data = np.array([[0.0, 1.0], [0.5, 0.75]])
92
+ quantized, factor, offset = routine_scalp_eeg.quantize_to_int64(data)
93
+ assert quantized.dtype == np.int64
94
+ assert offset == 0.0
95
+ assert factor == 1.0 / (2**31 - 1)
96
+
97
+ # Shape preservation
98
+ data = np.random.randn(100, 5)
99
+ quantized, factor, offset = routine_scalp_eeg.quantize_to_int64(data)
100
+ assert quantized.shape == data.shape
101
+
102
+ # Constant data (factor should be 1.0)
103
+ data = np.ones((10, 3)) * 5.0
104
+ quantized, factor, offset = routine_scalp_eeg.quantize_to_int64(data)
105
+ assert factor == 1.0
106
+ assert offset == 5.0
107
+ assert np.all(quantized == 0)
108
+
109
+ # Range mapping
110
+ data = np.array([[-100.0], [100.0]])
111
+ quantized, factor, offset = routine_scalp_eeg.quantize_to_int64(data)
112
+ assert offset == -100.0
113
+ assert factor == pytest.approx(0.0000000931323, abs=1e-13)
114
+ assert quantized[0, 0] == 0
115
+ assert quantized[1, 0] == 2**31 - 1
116
+
117
+ # C-order output
118
+ data = np.array([[1.0, 2.0], [3.0, 4.0]], order='F')
119
+ quantized, _, _ = routine_scalp_eeg.quantize_to_int64(data)
120
+ assert quantized.flags['C_CONTIGUOUS']
121
+
122
+ def test_build_waveform_sequence(self, routine_scalp_eeg):
123
+ data = np.random.randn(150, 3)
124
+ seq = routine_scalp_eeg._build_waveform_sequence(3, 512.0, ["Ch1", "Ch2", "Ch3"], data)
125
+
126
+ assert seq.WaveformOriginality == "ORIGINAL"
127
+ assert seq.NumberOfWaveformChannels == 3
128
+ assert seq.NumberOfWaveformSamples == 150
129
+ assert seq.SamplingFrequency == 512.0
130
+ assert len(seq.ChannelDefinitionSequence) == 3
131
+ assert seq.WaveformBitsAllocated == 64
132
+ assert seq.WaveformSampleInterpretation == "SV"
133
+
134
+ def test_build_waveform(self, routine_scalp_eeg):
135
+ assert routine_scalp_eeg.ds.SOPInstanceUID == routine_scalp_eeg.sop_instance_uid
136
+ assert routine_scalp_eeg.ds.SOPClassUID == pydicom.uid.RoutineScalpElectroencephalogramWaveformStorage
137
+ assert hasattr(routine_scalp_eeg.ds, "WaveformSequence")
138
+ assert len(routine_scalp_eeg.ds.WaveformSequence) == 1
139
+
140
+ def test_save_as(self, routine_scalp_eeg):
141
+ with tempfile.TemporaryDirectory() as tmpdir:
142
+ filepath = os.path.join(tmpdir, "test_output.dcm")
143
+ routine_scalp_eeg.save_as(filepath)
144
+
145
+ assert os.path.exists(filepath)
146
+
147
+ # Read back and verify
148
+ ds = pydicom.dcmread(filepath)
149
+ assert ds.Modality == "EEG"
150
+ assert ds.PatientName == "Test^Patient"
151
+
152
+ # Verify it's a valid DICOM file
153
+ assert hasattr(ds, "file_meta")
154
+ assert ds.file_meta.TransferSyntaxUID == ExplicitVRLittleEndian