├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── VERSION ├── dicom-ecg-plot ├── ecg ├── __init__.py ├── ecg.pot ├── ecg.py ├── en_US.po ├── i18n.py └── it_IT.po ├── ecgconfig.py ├── images └── logo.png ├── locale ├── en_US │ └── LC_MESSAGES │ │ └── ecg.mo └── it_IT │ └── LC_MESSAGES │ └── ecg.mo ├── sample_files ├── anonymous_ecg.dcm ├── anonymous_ecg_3x4.png ├── anonymous_ecg_3x4_1.pdf └── anonymous_ecg_3x4_1.png └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Mr Developer 30 | .mr.developer.cfg 31 | .project 32 | .pydevproject 33 | 34 | .* 35 | include/ 36 | local/ 37 | man/ 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Marco De Benedetto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include VERSION 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [logo]: https://raw.github.com/marcodebe/dicomecg_convert/master/images/logo.png 2 | ![ECG Dicom Convert][logo] 3 | 4 | # Dicom ECG plot 5 | A python tool to plot Dicom ECG. 6 | 7 | The DICOM file can also be specified as `studyUID seriesUID objectUID` and 8 | retrieved from your WADO server. 9 | 10 | Github repository: [here](https://github.com/marcodebe/dicomecg_convert) 11 | 12 | **THE PROGRAM IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, BUT WITHOUT ANY WARRANTY OF ANY KIND.** 13 | 14 | 15 | ## Online demo 16 | **[demo site](https://ecg.galliera.it)** 17 | You can convert your own DICOM files or use preloaded sample files from different modality models. 18 | 19 | ## Install 20 | ```bash 21 | python3 -m venv ecg 22 | . ecg/bin/activate 23 | pip install dicom-ecg-plot 24 | ``` 25 | 26 | ## Usage of `dicom-ecg-plot` tool 27 | ```bash 28 | dicom-ecg-plot [--layout=LAYOUT] [--output=FILE|--format=FMT] --minor-grid 29 | dicom-ecg-plot [--layout=LAYOUT] [--output=FILE|--format=FMT] --minor-grid 30 | dicom-ecg-plot --help 31 | ``` 32 | Examples: 33 | ```bash 34 | dicom-ecg-plot anonymous_ecg.dcm -o anonymous_ecg.pdf 35 | dicom-ecg-plot anonymous_ecg.dcm --layout 6x2 --output anonymous_ecg.png 36 | dicom-ecg-plot anonymous_ecg.dcm --format svg > anonymous_ecg.svg 37 | ``` 38 | 39 | The input can be a (dicom ecg) file or the triplet `studyUID, seriesUID, 40 | objectUID`. In the latter case dicom file is downloaded via 41 | [WADO](http://medical.nema.org/Dicom/2011/11_18pu.pdf). 42 | 43 | If `--output` is given the ouput format is deduced from the extension of the `FILE`. 44 | If the output file is not given `--format` must be defined. 45 | Supported output formats are: eps, jpeg, jpg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff. 46 | 47 | By default the 5mm grid is drawn, `--minor-grid` add the minor grid (1mm). 48 | 49 | The signals are filtered using a lowpass (40 Hz) 50 | [butterworth filter](http://en.wikipedia.org/wiki/Butterworth_filter) 51 | of order 2. 52 | 53 | `LAYOUT` can be one of: 3x4\_1 (that is 3 rows for 4 columns plus 1 row), 3x4, 6x2, 12x1 (default: 3x4_1). 54 | New layouts can be defined adding the corresponding matrix in LAYOUT dictionary in `config.py`. 55 | 56 | 57 | 58 | ## References 59 | * http://medical.nema.org/Dicom/supps/sup30_lb.pdf 60 | * http://dicomlookup.com/html/03_03PU.html#LinkTarget_229354 61 | * http://libir.tmu.edu.tw/bitstream/987654321/21661/1/B09.pdf 62 | * [Mortara ECG Conformance Statement](http://www.mortara.com/fileadmin/user_upload/global/Products/Healthcare/DICOM/ELI%20Electrocardiographs%20DICOM%20Conformance%20Statement.pdf) 63 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.3.4 2 | -------------------------------------------------------------------------------- /dicom-ecg-plot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ECG Conversion Tool 4 | 5 | Usage: 6 | dicom-ecg-plot [--layout=LAYOUT] [--output=FILE|--format=FMT] [--minor-grid] [--interpretation] 7 | dicom-ecg-plot [--layout=LAYOUT] [--output=FILE|--format=FMT] [--minor-grid] [--interpretation] 8 | dicom-ecg-plot --help 9 | 10 | Options: 11 | -h, --help This help. 12 | Input dicom file. 13 | studyUID seriesUID objectUID 14 | UIDs for WADO download. 15 | -l LAYOUT --layout=LAYOUT Layout [default: 3x4_1]. 16 | -o FILE --output=FILE Output file (format deduced by extension). 17 | -f FMT --format=FMT Output format. 18 | --minor-grid Draw minor axis grid (1mm). 19 | --interpretation Show "Automated ECG interpretation" 20 | 21 | Valid layouts are: 3x4_1, 3x4, 12x1 22 | 23 | The output format is deduced from the extension of the filename, if present, or 24 | from --format option when filename is not specified. 25 | 26 | Valid formats: eps, jpeg, jpg, pdf, pgf, png, ps, raw, rgba, svg, svgz, 27 | tif, tiff. 28 | """ 29 | from ecg import ECG 30 | from docopt import docopt 31 | from io import BytesIO 32 | import sys 33 | 34 | 35 | def convert(source, layout, outformat, outputfile, 36 | minor_axis=False, interpretation=False): 37 | 38 | ecg = ECG(source) 39 | ecg.draw(layout, 10, minor_axis, interpretation=interpretation) 40 | return ecg.save(outformat=outformat, outputfile=outputfile) 41 | 42 | 43 | if __name__ == '__main__': 44 | 45 | arguments = docopt(__doc__) 46 | inputfile = arguments[''] 47 | stu = arguments[''] 48 | ser = arguments[''] 49 | obj = arguments[''] 50 | outputfile = arguments['--output'] 51 | outformat = arguments['--format'] 52 | layout = arguments['--layout'] 53 | minor_axis = arguments['--minor-grid'] 54 | interpretation = arguments['--interpretation'] 55 | 56 | if inputfile: 57 | source = BytesIO(open(inputfile, mode='rb').read()) 58 | else: 59 | source = {'stu': stu, 'ser': ser, 'obj': obj} 60 | 61 | output = convert(source, layout, outformat, outputfile, 62 | minor_axis, interpretation) 63 | 64 | if output: 65 | try: 66 | sys.stdout.buffer.write(output) 67 | except AttributeError: 68 | sys.stdout.write(output) 69 | -------------------------------------------------------------------------------- /ecg/__init__.py: -------------------------------------------------------------------------------- 1 | from .ecg import ECG, i18n 2 | -------------------------------------------------------------------------------- /ecg/ecg.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR Marco De Benedetto 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: dicomecg_convert\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2013-10-10 14:44+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=CHARSET\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: locale.py:5 21 | msgid "Ventr. Freq." 22 | msgstr "" 23 | 24 | #: locale.py:6 25 | msgid "PR Interval" 26 | msgstr "" 27 | 28 | #: locale.py:7 29 | msgid "QRS Duration" 30 | msgstr "" 31 | 32 | #: locale.py:8 33 | msgid "QT/QTc" 34 | msgstr "" 35 | 36 | #: locale.py:9 37 | msgid "P-R-T Axis" 38 | msgstr "" 39 | 40 | #: locale.py:10 41 | msgid "Pat. ID" 42 | msgstr "" 43 | 44 | #: locale.py:11 45 | msgid "sex" 46 | msgstr "" 47 | 48 | #: locale.py:12 49 | msgid "birthdate" 50 | msgstr "" 51 | 52 | #: locale.py:13 53 | msgid "year old" 54 | msgstr "" 55 | 56 | #: locale.py:14 57 | msgid "total time" 58 | msgstr "" 59 | 60 | #: locale.py:15 61 | msgid "sample freq." 62 | msgstr "" 63 | -------------------------------------------------------------------------------- /ecg/ecg.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ECG (waveform) Dicom module 3 | 4 | Read and plot images from DICOM ECG waveforms. 5 | """ 6 | 7 | """ 8 | The MIT License (MIT) 9 | 10 | Copyright (c) 2013 Marco De Benedetto 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in 20 | all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | THE SOFTWARE. 29 | """ 30 | import numpy as np 31 | import pydicom as dicom 32 | import struct 33 | import io 34 | import os 35 | import requests 36 | from . import i18n 37 | import re 38 | from datetime import datetime 39 | from matplotlib import use 40 | from scipy.signal import butter, lfilter 41 | 42 | # python2 fails if DISPLAY is not defined with: 43 | # _tkinter.TclError: no display name and no $DISPLAY environment variable 44 | if os.environ.get('DISPLAY', '') == '': 45 | use('Agg') 46 | 47 | from matplotlib import pylab as plt 48 | 49 | try: 50 | from ecgconfig import WADOSERVER, LAYOUT, INSTITUTION 51 | except ImportError: 52 | WADOSERVER = "http://example.com" 53 | LAYOUT = {'3x4_1': [[0, 3, 6, 9], 54 | [1, 4, 7, 10], 55 | [2, 5, 8, 11], 56 | [1]], 57 | '3x4': [[0, 3, 6, 9], 58 | [1, 4, 7, 10], 59 | [2, 5, 8, 11]], 60 | '6x2': [[0, 6], 61 | [1, 7], 62 | [2, 8], 63 | [3, 9], 64 | [4, 10], 65 | [5, 11]], 66 | '12x1': [[0], 67 | [1], 68 | [2], 69 | [3], 70 | [4], 71 | [5], 72 | [6], 73 | [7], 74 | [8], 75 | [9], 76 | [10], 77 | [11]]} 78 | 79 | # If INSTITUTION is set to None the value of the tag InstitutionName is 80 | # used 81 | 82 | INSTITUTION = None 83 | 84 | __author__ = "Marco De Benedetto and Simone Ferretti" 85 | __license__ = "MIT" 86 | __credits__ = ["Marco De Benedetto", "Simone Ferretti", "Francesco Formisano"] 87 | __email__ = "debe@galliera.it" 88 | 89 | 90 | def butter_lowpass(highcut, sampfreq, order): 91 | """Supporting function. 92 | 93 | Prepare some data and actually call the scipy butter function. 94 | """ 95 | 96 | nyquist_freq = .5 * sampfreq 97 | high = highcut / nyquist_freq 98 | num, denom = butter(order, high, btype='lowpass') 99 | return num, denom 100 | 101 | 102 | def butter_lowpass_filter(data, highcut, sampfreq, order): 103 | """Apply the Butterworth lowpass filter to the DICOM waveform. 104 | 105 | @param data: the waveform data. 106 | @param highcut: the frequencies from which apply the cut. 107 | @param sampfreq: the sampling frequency. 108 | @param order: the filter order. 109 | """ 110 | 111 | num, denom = butter_lowpass(highcut, sampfreq, order=order) 112 | return lfilter(num, denom, data) 113 | 114 | 115 | class ECG(object): 116 | """The class representing the ECG object 117 | """ 118 | 119 | paper_w, paper_h = 297.0, 210.0 120 | 121 | # Dimensions in mm of plot area 122 | width = 250.0 123 | height = 170.0 124 | margin_left = margin_right = .5 * (paper_w - width) 125 | margin_bottom = 10.0 126 | 127 | # Normalized in [0, 1] 128 | left = margin_left / paper_w 129 | right = left + width / paper_w 130 | bottom = margin_bottom / paper_h 131 | top = bottom + height / paper_h 132 | 133 | def __init__(self, source): 134 | """The ECG class constructor. 135 | 136 | @param source: the ECG source, it could be a filename, a buffer 137 | or a dict of study, serie, object info (to query 138 | a WADO server). 139 | @type source: C{str} or C{dict}. 140 | """ 141 | 142 | def err(msg): 143 | raise Exception 144 | 145 | def wadoget(stu, ser, obj): 146 | """Query the WADO server. 147 | 148 | @return: a buffer containing the DICOM object (the WADO response). 149 | @rtype: C{cStringIO.StringIO}. 150 | """ 151 | payload = { 152 | 'requestType': 'WADO', 153 | 'contentType': 'application/dicom', 154 | 'studyUID': stu, 155 | 'seriesUID': ser, 156 | 'objectUID': obj 157 | } 158 | headers = {'content-type': 'application/json'} 159 | 160 | resp = requests.get(WADOSERVER, params=payload, headers=headers) 161 | return io.BytesIO(resp.content) 162 | 163 | if isinstance(source, dict): 164 | # dictionary of stu, ser, obj 165 | if set(source.keys()) == set(('stu', 'ser', 'obj')): 166 | inputdata = wadoget(**source) 167 | else: 168 | err("source must be a dictionary of stu, ser and obj") 169 | elif isinstance(source, str) or getattr(source, 'getvalue'): 170 | # it is a filename or a (StringIO or cStringIO buffer) 171 | inputdata = source 172 | else: 173 | # What is it? 174 | err("'source' must be a path/to/file.ext string\n" + 175 | "or a dictionary of stu, ser and obj") 176 | 177 | try: 178 | self.dicom = dicom.read_file(inputdata) 179 | """@ivar: the dicom object.""" 180 | except dicom.filereader.InvalidDicomError as err: 181 | raise ECGReadFileError(err) 182 | 183 | sequence_item = self.dicom.WaveformSequence[0] 184 | 185 | assert (sequence_item.WaveformSampleInterpretation == 'SS') 186 | assert (sequence_item.WaveformBitsAllocated == 16) 187 | 188 | self.channel_definitions = sequence_item.ChannelDefinitionSequence 189 | self.wavewform_data = sequence_item.WaveformData 190 | self.channels_no = sequence_item.NumberOfWaveformChannels 191 | self.samples = sequence_item.NumberOfWaveformSamples 192 | self.sampling_frequency = sequence_item.SamplingFrequency 193 | self.duration = self.samples / self.sampling_frequency 194 | self.mm_s = self.width / self.duration 195 | self.signals = self._signals() 196 | self.fig, self.axis = self.create_figure() 197 | 198 | def __del__(self): 199 | """ 200 | Figures created through the pyplot interface 201 | (`matplotlib.pyplot.figure`) are retained until explicitly 202 | closed and may consume too much memory. 203 | """ 204 | 205 | plt.cla() 206 | plt.clf() 207 | plt.close() 208 | 209 | def create_figure(self): 210 | """Prepare figure and axes""" 211 | 212 | # Init figure and axes 213 | fig, axes = plt.subplots() 214 | 215 | fig.subplots_adjust(left=self.left, right=self.right, top=self.top, 216 | bottom=self.bottom) 217 | 218 | axes.set_ylim([0, self.height]) 219 | 220 | # We want to plot N values, where N=number of samples 221 | axes.set_xlim([0, self.samples - 1]) 222 | return fig, axes 223 | 224 | def _signals(self): 225 | """ 226 | Retrieve the signals from the DICOM WaveformData object. 227 | 228 | sequence_item := dicom.dataset.FileDataset.WaveformData[n] 229 | 230 | @return: a list of signals. 231 | @rtype: C{list} 232 | """ 233 | 234 | factor = np.zeros(self.channels_no) + 1 235 | baseln = np.zeros(self.channels_no) 236 | units = [] 237 | for idx in range(self.channels_no): 238 | definition = self.channel_definitions[idx] 239 | 240 | assert (definition.WaveformBitsStored == 16) 241 | 242 | if definition.get('ChannelSensitivity'): 243 | factor[idx] = ( 244 | float(definition.ChannelSensitivity) * 245 | float(definition.ChannelSensitivityCorrectionFactor) 246 | ) 247 | 248 | if definition.get('ChannelBaseline'): 249 | baseln[idx] = float(definition.get('ChannelBaseline')) 250 | 251 | units.append( 252 | definition.ChannelSensitivityUnitsSequence[0].CodeValue 253 | ) 254 | 255 | unpack_fmt = '<%dh' % (len(self.wavewform_data) / 2) 256 | unpacked_waveform_data = struct.unpack(unpack_fmt, self.wavewform_data) 257 | signals = np.asarray( 258 | unpacked_waveform_data, 259 | dtype=np.float32).reshape( 260 | self.samples, 261 | self.channels_no).transpose() 262 | 263 | for channel in range(self.channels_no): 264 | signals[channel] = ( 265 | (signals[channel] + baseln[channel]) * factor[channel] 266 | ) 267 | 268 | high = 40.0 269 | 270 | # conversion factor to obtain millivolts values 271 | millivolts = {'uV': 1000.0, 'mV': 1.0} 272 | 273 | for i, signal in enumerate(signals): 274 | signals[i] = butter_lowpass_filter( 275 | np.asarray(signal), 276 | high, 277 | self.sampling_frequency, 278 | order=2 279 | ) / millivolts[units[i]] 280 | 281 | return signals 282 | 283 | def draw_grid(self, minor_axis): 284 | """Draw the grid in the ecg plotting area.""" 285 | 286 | if minor_axis: 287 | self.axis.xaxis.set_minor_locator( 288 | plt.LinearLocator(int(self.width + 1)) 289 | ) 290 | self.axis.yaxis.set_minor_locator( 291 | plt.LinearLocator(int(self.height + 1)) 292 | ) 293 | 294 | self.axis.xaxis.set_major_locator( 295 | plt.LinearLocator(int(self.width / 5 + 1)) 296 | ) 297 | self.axis.yaxis.set_major_locator( 298 | plt.LinearLocator(int(self.height / 5 + 1)) 299 | ) 300 | 301 | color = {'minor': '#ff5333', 'major': '#d43d1a'} 302 | linewidth = {'minor': .1, 'major': .2} 303 | 304 | for axe in 'x', 'y': 305 | for which in 'major', 'minor': 306 | self.axis.grid( 307 | which=which, 308 | axis=axe, 309 | linestyle='-', 310 | linewidth=linewidth[which], 311 | color=color[which] 312 | ) 313 | 314 | self.axis.tick_params( 315 | which=which, 316 | axis=axe, 317 | color=color[which], 318 | bottom=False, 319 | top=False, 320 | left=False, 321 | right=False 322 | ) 323 | 324 | self.axis.set_xticklabels([]) 325 | self.axis.set_yticklabels([]) 326 | 327 | def legend(self): 328 | """A string containing the legend. 329 | 330 | Auxiliary function for the print_info method. 331 | """ 332 | 333 | if not hasattr(self.dicom, 'WaveformAnnotationSequence'): 334 | return '' 335 | 336 | ecgdata = {} 337 | for was in self.dicom.WaveformAnnotationSequence: 338 | if was.get('ConceptNameCodeSequence'): 339 | cncs = was.ConceptNameCodeSequence[0] 340 | if cncs.CodeMeaning in ( 341 | 'QT Interval', 342 | 'QTc Interval', 343 | 'RR Interval', 344 | 'VRate', 345 | 'QRS Duration', 346 | 'QRS Axis', 347 | 'T Axis', 348 | 'P Axis', 349 | 'PR Interval' 350 | ): 351 | ecgdata[cncs.CodeMeaning] = str(was.NumericValue) 352 | 353 | # If VRate is not defined we calculate ventricular rate from 354 | # RR interval 355 | try: 356 | vrate = float(ecgdata.get('VRate')) 357 | except (TypeError, ValueError): 358 | try: 359 | vrate = "%.1f" % ( 360 | 60.0 / self.duration * 361 | self.samples / float(ecgdata.get('RR Interval')) 362 | ) 363 | except (TypeError, ValueError, ZeroDivisionError): 364 | vrate = "(unknown)" 365 | 366 | ret_str = "%s: %s BPM\n" % (i18n.ventr_freq, vrate) 367 | ret_str_tmpl = "%s: %s ms\n%s: %s ms\n%s: %s/%s ms\n%s: %s %s %s" 368 | 369 | ret_str += ret_str_tmpl % ( 370 | i18n.pr_interval, 371 | ecgdata.get('PR Interval', ''), 372 | i18n.qrs_duration, 373 | ecgdata.get('QRS Duration', ''), 374 | i18n.qt_qtc, 375 | ecgdata.get('QT Interval', ''), 376 | ecgdata.get('QTc Interval', ''), 377 | i18n.prt_axis, 378 | ecgdata.get('P Axis', ''), 379 | ecgdata.get('QRS Axis', ''), 380 | ecgdata.get('T Axis', '') 381 | ) 382 | 383 | return ret_str 384 | 385 | def interpretation(self): 386 | """Return the string representing the automatic interpretation 387 | of the study. 388 | """ 389 | 390 | if not hasattr(self.dicom, 'WaveformAnnotationSequence'): 391 | return '' 392 | 393 | ret_str = '' 394 | for note in self.dicom.WaveformAnnotationSequence: 395 | if hasattr(note, 'UnformattedTextValue'): 396 | if note.UnformattedTextValue: 397 | ret_str = "%s\n%s" % ( 398 | ret_str, 399 | note.UnformattedTextValue 400 | ) 401 | 402 | return ret_str 403 | 404 | def print_info(self, interpretation): 405 | """Print info about the patient and about the ecg signals. 406 | """ 407 | 408 | try: 409 | pat_surname, pat_firstname = str(self.dicom.PatientName).split('^') 410 | except ValueError: 411 | pat_surname = str(self.dicom.PatientName) 412 | pat_firstname = '' 413 | 414 | pat_name = ' '.join((pat_surname, pat_firstname.title())) 415 | pat_age = self.dicom.get('PatientAge', '').strip('Y') 416 | 417 | pat_id = self.dicom.PatientID 418 | pat_sex = self.dicom.PatientSex 419 | try: 420 | pat_bdate = datetime.strptime( 421 | self.dicom.PatientBirthDate, '%Y%m%d').strftime("%e %b %Y") 422 | except ValueError: 423 | pat_bdate = "" 424 | 425 | # Strip microseconds from acquisition date 426 | regexp = r"\.\d+$" 427 | acquisition_date_no_micro = re.sub( 428 | regexp, '', self.dicom.AcquisitionDateTime) 429 | 430 | try: 431 | acquisition_date = datetime.strftime( 432 | datetime.strptime( 433 | acquisition_date_no_micro, '%Y%m%d%H%M%S'), 434 | '%d %b %Y %H:%M' 435 | ) 436 | except ValueError: 437 | acquisition_date = "" 438 | 439 | info = "%s\n%s: %s\n%s: %s\n%s: %s (%s %s)\n%s: %s" % ( 440 | pat_name, 441 | i18n.pat_id, 442 | pat_id, 443 | i18n.pat_sex, 444 | pat_sex, 445 | i18n.pat_bdate, 446 | pat_bdate, 447 | pat_age, 448 | i18n.pat_age, 449 | i18n.acquisition_date, 450 | acquisition_date 451 | ) 452 | 453 | plt.figtext(0.08, 0.87, info, fontsize=8) 454 | 455 | plt.figtext(0.30, 0.87, self.legend(), fontsize=8) 456 | 457 | if interpretation: 458 | plt.figtext(0.45, 0.87, self.interpretation(), fontsize=8) 459 | 460 | info = "%s: %s s %s: %s Hz" % ( 461 | i18n.duration, self.duration, 462 | i18n.sampling_frequency, 463 | self.sampling_frequency 464 | ) 465 | 466 | plt.figtext(0.08, 0.025, info, fontsize=8) 467 | 468 | info = INSTITUTION 469 | if not info: 470 | info = self.dicom.get('InstitutionName', '') 471 | 472 | plt.figtext(0.38, 0.025, info, fontsize=8) 473 | 474 | # TODO: the lowpass filter 0.05-40 Hz will have to became a parameter 475 | info = "%s mm/s %s mm/mV 0.05-40 Hz" % (self.mm_s, self.mm_mv) 476 | plt.figtext(0.76, 0.025, info, fontsize=8) 477 | 478 | def save(self, outputfile=None, outformat=None): 479 | """Save the plot result either on a file or on a output buffer, 480 | depending on the input params. 481 | 482 | @param outputfile: the output filename. 483 | @param outformat: the ouput file format. 484 | """ 485 | 486 | def _save(output): 487 | plt.savefig( 488 | output, dpi=300, format=outformat, 489 | orientation='landscape' 490 | ) 491 | 492 | if outputfile: 493 | _save(outputfile) 494 | else: 495 | output = io.BytesIO() 496 | _save(output) 497 | return output.getvalue() 498 | 499 | def plot(self, layoutid, mm_mv): 500 | """Plot the ecg signals inside the plotting area. 501 | Possible layout choice are: 502 | * 12x1 (one signal per line) 503 | * 6x2 (6 rows 2 columns) 504 | * 3x4 (4 signal chunk per line) 505 | * 3x4_1 (4 signal chunk per line. on the last line 506 | is drawn a complete signal) 507 | * ... and much much more 508 | 509 | The general rule is that the layout list is formed 510 | by as much lists as the number of lines we want to draw into the 511 | plotting area, each one containing the number of the signal chunk 512 | we want to plot in that line. 513 | 514 | @param layoutid: the desired layout 515 | @type layoutid: C{list} of C{list} 516 | """ 517 | 518 | self.mm_mv = mm_mv 519 | 520 | layout = LAYOUT[layoutid] 521 | rows = len(layout) 522 | 523 | for numrow, row in enumerate(layout): 524 | 525 | columns = len(row) 526 | row_height = self.height / rows 527 | 528 | # Horizontal shift for lead labels and separators 529 | h_delta = self.samples / columns 530 | 531 | # Vertical shift of the origin 532 | v_delta = round( 533 | self.height * (1.0 - 1.0 / (rows * 2)) - 534 | numrow * (self.height / rows) 535 | ) 536 | 537 | # Let's shift the origin on a multiple of 5 mm 538 | v_delta = (v_delta + 2.5) - (v_delta + 2.5) % 5 539 | 540 | # Lenght of a signal chunk 541 | chunk_size = int(self.samples / len(row)) 542 | for numcol, signum in enumerate(row): 543 | left = numcol * chunk_size 544 | right = (1 + numcol) * chunk_size 545 | 546 | # The signal chunk, vertical shifted and 547 | # scaled by mm/mV factor 548 | signal = v_delta + mm_mv * self.signals[signum][left:right] 549 | self.axis.plot( 550 | list(range(left, right)), 551 | signal, 552 | clip_on=False, 553 | linewidth=0.6, 554 | color='black', 555 | zorder=10) 556 | 557 | cseq = self.channel_definitions[signum].ChannelSourceSequence 558 | meaning = cseq[0].CodeMeaning.replace( 559 | 'Lead', '').replace('(Einthoven)', '') 560 | 561 | h = h_delta * numcol 562 | v = v_delta + row_height / 2.6 563 | plt.plot( 564 | [h, h], 565 | [v - 3, v], 566 | lw=.6, 567 | color='black', 568 | zorder=50 569 | ) 570 | 571 | self.axis.text( 572 | h + 40, 573 | v_delta + row_height / 3, 574 | meaning, 575 | zorder=50, 576 | fontsize=8 577 | ) 578 | 579 | # A4 size in inches 580 | self.fig.set_size_inches(11.69, 8.27) 581 | 582 | def draw(self, layoutid, mm_mv=10.0, minor_axis=False, interpretation=False): 583 | """Draw grid, info and signals""" 584 | 585 | self.draw_grid(minor_axis) 586 | self.plot(layoutid, mm_mv) 587 | self.print_info(interpretation) 588 | 589 | 590 | class ECGReadFileError(dicom.filereader.InvalidDicomError): 591 | pass 592 | -------------------------------------------------------------------------------- /ecg/en_US.po: -------------------------------------------------------------------------------- 1 | # English translations for dicomecg_convert package. 2 | # Copyright (C) 2013 Marco De Benedetto 3 | # This file is distributed under the same license as the dicomecg_convert package. 4 | # Simone Ferretti , 2013. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: dicomecg_convert\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2013-10-10 14:44+0200\n" 11 | "PO-Revision-Date: 2013-10-10 14:45+0200\n" 12 | "Last-Translator: Simone Ferretti \n" 13 | "Language-Team: English\n" 14 | "Language: en_US\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=ASCII\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: locale.py:5 21 | msgid "Ventr. Freq." 22 | msgstr "Ventr. Freq." 23 | 24 | #: locale.py:6 25 | msgid "PR Interval" 26 | msgstr "PR Interval" 27 | 28 | #: locale.py:7 29 | msgid "QRS Duration" 30 | msgstr "QRS Duration" 31 | 32 | #: locale.py:8 33 | msgid "QT/QTc" 34 | msgstr "QT/QTc" 35 | 36 | #: locale.py:9 37 | msgid "P-R-T Axis" 38 | msgstr "P-R-T Axis" 39 | 40 | #: locale.py:10 41 | msgid "Pat. ID" 42 | msgstr "Pat. ID" 43 | 44 | #: locale.py:11 45 | msgid "sex" 46 | msgstr "sex" 47 | 48 | #: locale.py:12 49 | msgid "birthdate" 50 | msgstr "birthdate" 51 | 52 | #: locale.py:13 53 | msgid "year old" 54 | msgstr "year old" 55 | 56 | #: locale.py:14 57 | msgid "total time" 58 | msgstr "total time" 59 | 60 | #: locale.py:15 61 | msgid "sample freq." 62 | msgstr "sample freq." 63 | 64 | #: locale.py:16 65 | msgid "acquisition date" 66 | msgstr "study date" 67 | 68 | -------------------------------------------------------------------------------- /ecg/i18n.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import locale 4 | import gettext 5 | 6 | APP_NAME = "ecg" 7 | LOCAL_PATH = 'locale/' 8 | 9 | def get_lang(): 10 | langs = [] 11 | lc, encoding = locale.getdefaultlocale() 12 | 13 | if (lc): 14 | langs = [lc] 15 | 16 | language = os.environ.get('LANGUAGE', None) 17 | if (language): 18 | langs += language.split(":") 19 | 20 | langs += ["it_IT", "en_US"] 21 | 22 | return langs 23 | 24 | gettext.bindtextdomain(APP_NAME, LOCAL_PATH) 25 | gettext.textdomain(APP_NAME) 26 | 27 | lang = gettext.translation(APP_NAME, LOCAL_PATH, 28 | languages=get_lang(), fallback=True) 29 | 30 | _ = lang.gettext 31 | 32 | # Down here the translated words 33 | ventr_freq = _('Ventr. Freq.') 34 | pr_interval = _('PR Interval') 35 | qrs_duration = _('QRS Duration') 36 | qt_qtc = _('QT/QTc') 37 | prt_axis = _('P-R-T Axis') 38 | pat_id = _('Pat. ID') 39 | pat_sex = _('sex') 40 | pat_bdate = _('birthdate') 41 | pat_age = _('year old') 42 | duration = _('total time') 43 | sampling_frequency = _('sample freq.') 44 | acquisition_date = _('acquisition date') 45 | -------------------------------------------------------------------------------- /ecg/it_IT.po: -------------------------------------------------------------------------------- 1 | # Italian translations for dicomecg_convert package 2 | # Traduzioni italiane per il pacchetto dicomecg_convert.. 3 | # Copyright (C) 2013 Marco De Benedetto 4 | # This file is distributed under the same license as the dicomecg_convert package. 5 | # Simone Ferretti , 2013. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: dicomecg_convert\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2013-10-10 14:44+0200\n" 12 | "PO-Revision-Date: 2013-10-10 16:46+0200\n" 13 | "Last-Translator: Simone Ferretti \n" 14 | "Language-Team: Italian\n" 15 | "Language: it\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=ISO-8859-1\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: locale.py:5 22 | msgid "Ventr. Freq." 23 | msgstr "Freq. Ventr." 24 | 25 | #: locale.py:6 26 | msgid "PR Interval" 27 | msgstr "Intervallo PR" 28 | 29 | #: locale.py:7 30 | msgid "QRS Duration" 31 | msgstr "Durata QRS" 32 | 33 | #: locale.py:8 34 | msgid "QT/QTc" 35 | msgstr "QT/QTc" 36 | 37 | #: locale.py:9 38 | msgid "P-R-T Axis" 39 | msgstr "Assi P-R-T" 40 | 41 | #: locale.py:10 42 | msgid "Pat. ID" 43 | msgstr "ID Paz." 44 | 45 | #: locale.py:11 46 | msgid "sex" 47 | msgstr "sesso" 48 | 49 | #: locale.py:12 50 | msgid "birthdate" 51 | msgstr "data di nascita" 52 | 53 | #: locale.py:13 54 | msgid "year old" 55 | msgstr "anni" 56 | 57 | #: locale.py:14 58 | msgid "total time" 59 | msgstr "tempo totale" 60 | 61 | #: locale.py:15 62 | msgid "sample freq." 63 | msgstr "freq. campionamento" 64 | 65 | #: locale.py:16 66 | msgid "acquisition date" 67 | msgstr "data esame" 68 | -------------------------------------------------------------------------------- /ecgconfig.py: -------------------------------------------------------------------------------- 1 | WADOSERVER = "http://example.com/" 2 | 3 | LAYOUT = {'3x4_1': [[0, 3, 6, 9], 4 | [1, 4, 7, 10], 5 | [2, 5, 8, 11], 6 | [1]], 7 | '3x4': [[0, 3, 6, 9], 8 | [1, 4, 7, 10], 9 | [2, 5, 8, 11]], 10 | '6x2': [[0, 6], 11 | [1, 7], 12 | [2, 8], 13 | [3, 9], 14 | [4, 10], 15 | [5, 11]], 16 | '12x1': [[0], 17 | [1], 18 | [2], 19 | [3], 20 | [4], 21 | [5], 22 | [6], 23 | [7], 24 | [8], 25 | [9], 26 | [10], 27 | [11]]} 28 | 29 | # If INSTITUTION is set to None the value of the tag InstitutionName is used 30 | INSTITUTION = None 31 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodebe/dicom-ecg-plot/308df8eb596e8793e63078856a28af8b419006e3/images/logo.png -------------------------------------------------------------------------------- /locale/en_US/LC_MESSAGES/ecg.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodebe/dicom-ecg-plot/308df8eb596e8793e63078856a28af8b419006e3/locale/en_US/LC_MESSAGES/ecg.mo -------------------------------------------------------------------------------- /locale/it_IT/LC_MESSAGES/ecg.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodebe/dicom-ecg-plot/308df8eb596e8793e63078856a28af8b419006e3/locale/it_IT/LC_MESSAGES/ecg.mo -------------------------------------------------------------------------------- /sample_files/anonymous_ecg.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodebe/dicom-ecg-plot/308df8eb596e8793e63078856a28af8b419006e3/sample_files/anonymous_ecg.dcm -------------------------------------------------------------------------------- /sample_files/anonymous_ecg_3x4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodebe/dicom-ecg-plot/308df8eb596e8793e63078856a28af8b419006e3/sample_files/anonymous_ecg_3x4.png -------------------------------------------------------------------------------- /sample_files/anonymous_ecg_3x4_1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodebe/dicom-ecg-plot/308df8eb596e8793e63078856a28af8b419006e3/sample_files/anonymous_ecg_3x4_1.pdf -------------------------------------------------------------------------------- /sample_files/anonymous_ecg_3x4_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcodebe/dicom-ecg-plot/308df8eb596e8793e63078856a28af8b419006e3/sample_files/anonymous_ecg_3x4_1.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import setuptools 3 | 4 | 5 | with open("README.md", "r") as fh: 6 | long_description = fh.read() 7 | 8 | with open("VERSION", "r") as fh: 9 | version = fh.read().strip('\n') 10 | 11 | 12 | setuptools.setup( 13 | name='dicom-ecg-plot', 14 | version=version, 15 | description='Plot Dicom ECG Waveforms', 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | author='Marco De Benedetto', 19 | author_email='debe@galliera.it', 20 | url='https://github.com/marcodebe/dicomecg_convert', 21 | packages=setuptools.find_packages(), 22 | scripts=['dicom-ecg-plot'], 23 | install_requires=[ 24 | 'pydicom>=1.0.1', 25 | 'numpy', 26 | 'matplotlib', 27 | 'scipy', 28 | 'docopt', 29 | 'requests', 30 | ], 31 | classifiers=[ 32 | 'License :: OSI Approved :: MIT License', 33 | 'Programming Language :: Python :: 2.7', 34 | 'Programming Language :: Python :: 3', 35 | 'Topic :: Scientific/Engineering :: Medical Science Apps.', 36 | 'Intended Audience :: Healthcare Industry', 37 | ], 38 | ) 39 | --------------------------------------------------------------------------------