diff --git a/.gitignore b/.gitignore index b9c9a15..f1fa532 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ docs/build # Build -build/ \ No newline at end of file +build/ + +# VSCode +.vscode \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d257cc8..0d9ef72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ -FROM python:onbuild -RUN pip install nose -RUN python setup.py install -RUN nosetests -v -RUN mritopng \ No newline at end of file +FROM python:3.6 +COPY . /app +WORKDIR /app +RUN pip install -r requirements.txt +RUN python setup.py install +RUN nosetests +RUN mritopng --help \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c57cbbd --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +Convert DICOM Files to PNG +=========================== + +[![CircleCI](https://circleci.com/gh/danishm/mritopng.svg?style=shield)](https://circleci.com/gh/danishm/mritopng) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +*Important Changes* + - **8/26/2018** - Ability to apply auto-contrast to the converted images + +Introduction +------------ +A simple python module to make it easy to batch convert a binary DICOM file, which is usually an output from +an MRI scan to a PNG image. + +The MRI scanning facilities typically hand you a CD containing your MRI scans. This CD will typically not contain +any image files in traditional formats that can be opened up by your default image viewing program. The CD contains +a list of DICOM files, which can only be viewed by the included viewer, which is mostly only supported on a Windows machine. + +This module should help you convert all the DICOM based scans to PNG files. This tool can be used as a command line tools as well as a library in your python code + +Installation +------------ + +To have known to work dependencies use beforehand:: + + pip install -r requirements.txt + +`mritopng` comes with a `setup.py` script to use with distutils. After unpacking the distribution, `cd` into the directory and execute the command:: + + python setup.py install + + +This will install two things + + * The `mritopng` module will be installed; `import mritopng` will allow you to use it + * A command line utility called `mritopng` which can be used from the console + +Quick Start +----------- +`mritopng` will install a command line utility that can be used to convert individual DICOM files or folders + +### Getting Help + +``` +$ mritopng --help +usage: mritopng [-h] [-f] [-c] dicom_path png_path + +Convert a dicom MRI file to png. To conver a whole folder recursivly, use the +-f option + +positional arguments: + dicom_path Full path to the mri file + png_path Full path to the generated png file + +optional arguments: + -h, --help show this help message and exit + -f, --folder Convert a whole folder instead of a single file + -c, --auto-contrast Apply contrast after converting default image +``` + +### Convert Single File + +```sh +# Converts the file /DICOM/SCAN1 to a file called output.png, +# while applying auto contrast +$ mritopng --auto-contrast /DICOM/SCAN1 output.png +``` + +**Note:** If file `output.png` already exists, it will be overwritten + +### Convert Folder Tree + +The utility can also be used to convert a whole folder recursively by using the `-f` option:: + +```sh +# Takes all the files in /DICOM, converts the files to png and +# puts them in the /PNG folder with the same structure as /DICOM. +$ mritopng -f /DICOM /PNG +``` + +**Note:** + - Existing top level folder will NOT be over-written e.g. the example above will fail of the folder `/PNG` already exists + - The tool will try to convert as many files as it can, skipping the ones that it can't + +Using it as a Library +--------------------- + +It's pretty easy to get up and running with `mritopng` in your own project + +```py +import mritopng + +# Convert a single file with auto-contrast +mritopng.convert_file('/home/user/DICOM/SCAN1', '/home/user/output.png', auto_contrast=True) + +# Convert a whole folder recursively +mritopng.convert_folder('/home/user/DICOM/', '/home/user/PNG/') +``` diff --git a/README.rst b/README.rst deleted file mode 100644 index 39ec40b..0000000 --- a/README.rst +++ /dev/null @@ -1,57 +0,0 @@ -`mritopng` - Convert DICOM Files to PNG -======================================= - -|Build Status| |License|_ - -Introduction ------------- -A simple python module to make it easy to batch convert a binary DICOM file, which is usually an output from -an MRI scan to a PNG image. - -The MRI scanning facilities typically hand you a CD containing your MRI scans. This CD will typically not contain -any image files in traditional formats that can be opened up by your default image viewing program. The CD contains -a list of DICOM files, which can only be viewed by the included viewer, which is mostly only supported on a Windows machine. - -This module should help you convert all the DICOM based scans to PNG files. This tool can be used as a command line tools as well as a library in your python code - -Quick Start ------------ -:code:`mritopng` will install a command line utility that can be used to convert individual DICOM files:: - - $ mritopng /DICOM/SCAN1 output.png - -The utility can also be used to convert a whole folder recursively by using the :code:`-f` option:: - - $ mritopng -f /DICOM /PNG - -It's pretty easy to get up and running with :code:`mritopng` in your own project :: - - import mritopng - - # Convert a since file - mritopng.convert_file('/home/user/DICOM/SCAN1', '/home/user/output.png') - - # Convert a whole folder recursively - mritopng.convert_folder('/home/user/DICOM/', '/home/user/PNG/') - -Installation ------------- - -To have known to work dependencies use beforehand:: - - pip install -r requirements.txt - -:code:`mritopng` comes with a :code:`setup.py` script to use with distutils. After unpacking the distribution, `cd` into the -directory and execute the command:: - - python setup.py install - - -This will install two things - - * The :code:`mritopng` module will be installed; :code:`import mritopng` will allow you to use it - * A command line utility called :code:`mritopng` which can be used from the console - -.. |Build Status| image:: https://circleci.com/gh/danishm/mritopng.svg?style=shield&circle-token=:circle-token=fdde06fc18401432d1cd84538a88678dd81584ad -.. |License| image:: https://img.shields.io/badge/License-MIT-yellow.svg -.. _License: https://opensource.org/licenses/MIT` \ No newline at end of file diff --git a/circle.yml b/circle.yml index d308b90..47eda97 100644 --- a/circle.yml +++ b/circle.yml @@ -1,3 +1,29 @@ -machine: - post: - - pyenv global 2.7.12 3.4.4 \ No newline at end of file +version: 2 + +jobs: + + build: + working_directory: ~/mritopng + docker: + - image: circleci/python:3.6.1 + steps: + - checkout + + - run: + name: Setup Python Environment + command: | + python3 -m venv venv + . venv/bin/activate + pip install -r requirements.txt + + - run: + name: Run Tests + command: | + . venv/bin/activate + nosetests --with-xunit --xunit-file=build/test/test-results.xml + + - store_test_results: + path: ~/mritopng/build/test + + - store_artifacts: + path: ~/mritopng/build/test diff --git a/mritopng/__init__.py b/mritopng/__init__.py index f81675b..9be1df2 100644 --- a/mritopng/__init__.py +++ b/mritopng/__init__.py @@ -2,14 +2,26 @@ import png import pydicom import numpy as np +from .models import GrayscaleImage +from .contrast import auto_contrast -def mri_to_png(mri_file, png_file): +def mri_to_png(mri_file, png_file, do_auto_contrast=False): """ Function to convert from a DICOM image to png @param mri_file: An opened file like object to read te dicom data @param png_file: An opened file like object to write the png data """ + image_2d = extract_grayscale_image(mri_file) + + if do_auto_contrast: + image_2d = auto_contrast(image_2d) + + # Writing the PNG file + w = png.Writer(image_2d.width, image_2d.height, greyscale=True) + w.write(png_file, image_2d.image) + +def extract_grayscale_image(mri_file): # Extracting data from the mri file plan = pydicom.read_file(mri_file) shape = plan.pixel_array.shape @@ -23,12 +35,10 @@ def mri_to_png(mri_file, png_file): #Convert to uint image_2d_scaled = np.uint8(image_2d_scaled) - # Writing the PNG file - w = png.Writer(shape[1], shape[0], greyscale=True) - w.write(png_file, image_2d_scaled) + return GrayscaleImage(image_2d_scaled, shape[1], shape[0]) -def convert_file(mri_file_path, png_file_path): +def convert_file(mri_file_path, png_file_path, auto_contrast=False): """ Function to convert an MRI binary file to a PNG image file. @@ -38,21 +48,22 @@ def convert_file(mri_file_path, png_file_path): # Making sure that the mri file exists if not os.path.exists(mri_file_path): - raise Exception('File "%s" does not exists' % mri_file_path) + raise Exception('Source file "%s" does not exists' % mri_file_path) # Making sure the png file does not exist if os.path.exists(png_file_path): - raise Exception('File "%s" already exists' % png_file_path) + print('Removing existing output file %s' % png_file_path) + os.remove(png_file_path) mri_file = open(mri_file_path, 'rb') png_file = open(png_file_path, 'wb') - mri_to_png(mri_file, png_file) + mri_to_png(mri_file, png_file, auto_contrast) png_file.close() -def convert_folder(mri_folder, png_folder): +def convert_folder(mri_folder, png_folder, auto_contrast=False): """ Convert all MRI files in a folder to png files in a destination folder """ @@ -77,7 +88,7 @@ def convert_folder(mri_folder, png_folder): try: # Convert the actual file - convert_file(mri_file_path, png_file_path) + convert_file(mri_file_path, png_file_path, auto_contrast) print('SUCCESS: %s --> %s' % (mri_file_path, png_file_path)) except Exception as e: print('FAIL: %s --> %s : %s' % (mri_file_path, png_file_path, e)) diff --git a/mritopng/__main__.py b/mritopng/__main__.py index 7479646..bc5d15c 100644 --- a/mritopng/__main__.py +++ b/mritopng/__main__.py @@ -7,16 +7,18 @@ def main(): - parser = argparse.ArgumentParser(description="Convert a dicom MRI file to png") - parser.add_argument('-f', action='store_true') + parser = argparse.ArgumentParser(description="Convert a dicom MRI file to png. To conver a whole folder recursivly, use the -f option") + parser.add_argument('-f', '--folder', action='store_true', help='Convert a whole folder instead of a single file') + parser.add_argument('-c', '--auto-contrast', help='Apply contrast after converting default image', action="store_true") parser.add_argument('dicom_path', help='Full path to the mri file') parser.add_argument('png_path', help='Full path to the generated png file') args = parser.parse_args() - if args.f: - convert_folder(args.dicom_path, args.png_path) + print('Arguments: %s', args) + if args.folder: + convert_folder(args.dicom_path, args.png_path, args.auto_contrast) else: - convert_file(args.dicom_path, args.png_path) + convert_file(args.dicom_path, args.png_path, args.auto_contrast) if __name__ == '__main__': main() diff --git a/mritopng/contrast.py b/mritopng/contrast.py new file mode 100644 index 0000000..ef26955 --- /dev/null +++ b/mritopng/contrast.py @@ -0,0 +1,42 @@ +import numpy as np +from .models import GrayscaleImage + +def histogram(image): + + hist = dict() + + # Initialize dict + for shade in range(0, 256): + hist[shade] = 0 + + for index, val in np.ndenumerate(image.image): + hist[val] += 1 + + return hist + + +def shade_at_percentile(hist, percentile): + + n = sum(hist.values()) + cumulative_sum = 0.0 + for shade in range(0, 256): + cumulative_sum += hist[shade] + if cumulative_sum/n >= percentile: + return shade + + return None + +def auto_contrast(image): + """ Apply auto contrast to an image using + https://stackoverflow.com/questions/9744255/instagram-lux-effect/9761841#9761841 + """ + hist = histogram(image) + p5 = shade_at_percentile(hist, .01) + p95 = shade_at_percentile(hist, .99) + a = 255.0/(p95 + p5) + b = -1.0 * a * p5 + + result = (image.image.astype(float) * a) + b + result = result.clip(0, 255.0) + + return GrayscaleImage(np.uint8(result), image.width, image.height) \ No newline at end of file diff --git a/mritopng/models.py b/mritopng/models.py new file mode 100644 index 0000000..ce2dec0 --- /dev/null +++ b/mritopng/models.py @@ -0,0 +1,9 @@ +class GrayscaleImage(object): + + def __init__(self, image, width, height): + self.image = image + self.width = width + self.height = height + + def __str__(self): + return '[%dx%d]' % (self.width, self.height) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 50e2130..17a1672 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -numpy==1.14.1 -pydicom==1.0.2 -pypng==0.0.18 +numpy>=1.14.1 +pydicom==1.0.2 +pypng==0.0.18 +nose diff --git a/setup.py b/setup.py index 15dc09f..dc21daa 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ def readme(): setup( name='mritopng', - version='2.1', + version='2.2', description='Easily convert MRI filed based on the DICOM format to a PNG image', long_description=readme(), classifiers=[ diff --git a/tests/data/expected/auto-contrast/000012.dcm.png b/tests/data/expected/auto-contrast/000012.dcm.png new file mode 100644 index 0000000..30ac4b6 Binary files /dev/null and b/tests/data/expected/auto-contrast/000012.dcm.png differ diff --git a/tests/data/expected/auto-contrast/000017.dcm.png b/tests/data/expected/auto-contrast/000017.dcm.png new file mode 100644 index 0000000..523be70 Binary files /dev/null and b/tests/data/expected/auto-contrast/000017.dcm.png differ diff --git a/tests/data/expected/auto-contrast/dicom1.png b/tests/data/expected/auto-contrast/dicom1.png new file mode 100644 index 0000000..3be42fb Binary files /dev/null and b/tests/data/expected/auto-contrast/dicom1.png differ diff --git a/tests/test_mritopng.py b/tests/test_mritopng.py index 2329a0a..7ddec79 100644 --- a/tests/test_mritopng.py +++ b/tests/test_mritopng.py @@ -1,12 +1,21 @@ """Tests for mritopng""" import os +import sys import uuid -import tempfile +import shutil import filecmp +import tempfile import unittest import mritopng +import traceback +import numpy as np +from mritopng import contrast + +test_out_path = os.path.abspath(os.path.join(os.path.dirname( __file__ ), '..', 'build', 'test')) +shutil.rmtree(test_out_path, ignore_errors=True) +os.makedirs(test_out_path) class TestMRIToPNG(unittest.TestCase): """ Basic tests for mritopng """ @@ -20,7 +29,7 @@ def test_convert_file(self): curr_path = os.path.dirname(os.path.realpath(__file__)) sample_path = os.path.join(curr_path, 'data', 'samples', 'dicom1') expected_path = os.path.join(curr_path, 'data', 'expected', 'dicom1.png') - actual_path = os.path.join(tempfile.gettempdir(), '%s.%s' % (uuid.uuid4(), "png")) + actual_path = os.path.join(test_out_path, 'dicom1.png') print('Actual File Path: %s' % actual_path) @@ -44,7 +53,7 @@ def test_convert_file_with_negative_values(self): sample_path = os.path.join(curr_path, 'data', 'samples', case) expected_path = os.path.join(curr_path, 'data', 'expected', case + '.png') - actual_path = os.path.join(tempfile.gettempdir(), '%s.%s' % (uuid.uuid4(), "png")) + actual_path = os.path.join(test_out_path, case + '.png') print('Actual File Path: %s' % actual_path) @@ -56,3 +65,66 @@ def test_convert_file_with_negative_values(self): self.assertTrue(filecmp.cmp(actual_path, expected_path), 'PNG generated from dicom1 does not match the expected version') + + def test_convert_file_auto_contrast(self): + cases = ['dicom1', '000012.dcm', '000017.dcm'] + curr_path = os.path.dirname(os.path.realpath(__file__)) + os.makedirs(os.path.join(test_out_path, 'auto-contrast')) + + for case in cases: + + sample_path = os.path.join(curr_path, 'data', 'samples', case) + expected_path = os.path.join(curr_path, 'data', 'expected', 'auto-contrast', case + '.png') + actual_path = os.path.join(test_out_path, 'auto-contrast', case + '.png') + + print('Actual File Path: %s' % actual_path) + + # Try the file conversion + try: + print('>>> Here') + mritopng.convert_file(sample_path, actual_path, auto_contrast=True) + print('<<<') + except Exception as err: + traceback.print_exc(file=sys.stdout) + self.fail('%s' % err) + + self.assertTrue(filecmp.cmp(actual_path, expected_path), + 'PNG generated from dicom1 does not match the expected version') + + + def test_contrast_histogram(self): + curr_path = os.path.dirname(os.path.realpath(__file__)) + sample_path = os.path.join(curr_path, 'data', 'samples', '000017.dcm') + image = mritopng.extract_grayscale_image(sample_path) + histogram = contrast.histogram(image) + + for shade in histogram: + print('%d\t%d' % (shade, histogram[shade])) + + a = contrast.shade_at_percentile(histogram, 0.05) + b = contrast.shade_at_percentile(histogram, 0.95) + print("a = %d" % a) + print("b = %d" % b) + # raise Exception("test failed") + + def test_auto_contrast(self): + image_2d = np.array([ + [0, 0, 5, 5], + [5, 5, 5, 5], + [5, 10, 10, 5], + [5, 5, 5, 1] + ]) + + image = mritopng.GrayscaleImage(image_2d, 4, 4) + + result = contrast.auto_contrast(image) + + expected = np.array([ + [0, 0, 127, 127], + [127, 127, 127, 127], + [127, 255, 255, 127], + [127, 127, 127, 25] + ]) + + if not np.array_equal(result.image, expected): + raise Exception("Expected:\n%s\n\nActual:\n%s\n"%(expected, result.image)) \ No newline at end of file