Dicom to weird animated GIF via Python

Every now and then, having some coding skills turns out to be useful in the most unexpected ways.

In support of a creative project, I need to be able to convert some image files. But these aren’t your normal, run-of-the-mill snapshots. They are images captured during CT and MRI scans.

This being France, your medical data is regarded as your personal property. Most times when you have a scan – and I’ve had a few now – you’re sent home with a CD containing all the captured images. And these were the images I wanted to exploit.

The problem is that they’re not your regular Jpegs or Tiffs. That would be too simple. They are in a format called Dicom (Digital Imaging and Communications in Medicine). Most image manipulation software has absolutely no idea what to do with them, so converting isn’t a simple task.

The CDs provided by the clinic or hospital usually include software that allows you to view the images. But it’s Windows-only, feels like it was written in the 1990s and doesn’t allow for exporting to other formats.

Online help

There are plenty of websites that will handle conversions for you, but these present two problems.

The first is that I have thousands of the buggers that I want to convert. Uploading individual images or even reasonable size batches would take forever.

The second – and in my view far more significant issue – is that Dicom images have large amounts of metadata embedded in them. In fact, Dicom files are sometimes used as much for the metadata as for the image. And this is personally identifiable information (PII), including such items as name, date of birth and sensitive things relating to medical conditions. Was I really going to trust some random website with this information? Absolutely not.

Off to the library

And then I thought, I bet there’s a Python library for handling Dicom files that will allow me to convert all the images programmatically. And of course there is.

The library is pydicam and it has a couple of dependencies – pillow (for image manipultion) and numpy (for managing the range of pixel values). So my first step was:

python3 -m pip install pydicam
python3 -m pip install pillow
python3 -m pip install numpy

Your mileage may vary in terms of how you install Python packages, but I’m sure you’ll know what to do.

I have all the Dicom files in a subfolder called ‘dicom’ (I put a lot of thought into that name). The code expects this folder to be in the location where the code is running.

And this is the code. Please note that I do not claim to be an elegant or even competent programmer. But this code works for me:

#!/usr/bin/env python

import os
import numpy as np
import pydicom
from PIL import Image # PIL is provided by pillow
from pathlib import Path

dicom_dir = 'dicom'

# Interate through the files inside the dicom directory
for _, _, filenames in os.walk(dicom_dir):
  for filename in filenames:
  filepath = Path(os.path.join(dicom_dir, filename))
  if filepath.is_file() and not filepath.name.startswith('.'):
    print(filename)

    # Create a dicom file object
    ds = pydicom.dcmread(filepath)

    # Get the image data from the file
    img_data = ds.pixel_array.astype(float)
    if img_data.max() != 0: # we found image data
      # Dicom image pixels have values that aren't necessarily
      # right for us. Ensure the pixel values all lie in the
      # range 0-255
      scaled_img_data = (np.maximum(img_data, 0) / img_data.max()) * 255
      scaled_img_data = np.uint8(scaled_img_data)
      # Create an image object with this data
      output_image = Image.fromarray(scaled_img_data)

      # Now output the new images in three formats
      for fmt in ['JPG', 'PNG', 'GIF']:
        conv_subdir = os.path.join(dicom_dir, fmt)
        conv_file = filename + '.' + fmt.lower()
        # Following line creates the sub-subdir if needed
        Path(conv_subdir).mkdir(exist_ok=True)
        output_image.save(os.path.join(conv_subdir, conv_file))

print("Done.")

This code creates three sub-directories in the dicom directory – GIF, JPG and PNG – and creates image files of the appropriate formats in each. And that’s all that’s needed to get scary images of your latest scan to send to friends and family.

I hope most of the code is self-explanatory, or explained by the comments, but I’ll highlight a couple of things.

We iterate through the files in the dircom directory (lines 12 and 13). Each file is checked to see that it is indeed a file (and not a directory) and that its name does not start with ‘.’ – ie, that it’s not a hidden file. That’s the extent of the checking, so it’s important that the dicom directory contains only Dicom files and not other file types.

We set up a Dicom file object, read the file into it and then extract the pixel data. This is then ‘scaled’ so that it’s in the range 0-255 (Dicom pixels can have much bigger values). Then we create an image object from the scaled pixels and write it out in various formats.

We now have our images. Lovely.

But I didn’t want to stop there. Oh no.

Getting animated

My real goal was always to stitch together the GIF versions of the files into an animated GIF. Things are so much more disturbing when animated.

Again, there are plenty of services online that allow you to upload a bunch of images and which will return an animated GIF. But we’re already on a roll with Python, so let’s do it ourselves.

For this, we’ll need another tool – gifsicle. It’s a highly versatile program, but here we’ll be using it to crunch down the animated GIFs into sizes appropriate for using on a website.

I’m on a Mac, so all I needed to do was type brew install gifsicle in the terminal and I was off to the races. See the gifsicle page for information about installing if you’re on another OS or don’t use Homebrew. There’s also lots more info on the gifsicle man page.

The following script grabs the images from the GIF subdir and smooshes them together into an animated GIF file.

#!/usr/bin/env python3

from PIL import Image
import glob
import contextlib
import subprocess

gifdir = 'dicom/GIF'

# use exit stack to automatically close opened images
with contextlib.ExitStack() as stack:
  # load images
  imgs = (stack.enter_context(Image.open(f)) for f in sorted(glob.glob(gifdir + '/*.gif')))
  # extract first image from iterator
  img = next(imgs)
  # save the animated GIF
  img.save(fp='movie.gif', format='GIF', append_images=imgs, save_all=True, duration=75, loop=0)

# Now create a compressed version of the file using gifsicle.
subprocess.run(['gifsicle', '-O2', '--lossy=200', 'movie.gif', '-o', 'movie-optimised.gif'])
print('Done.')

I’ll confess that a chunk of this code came from somewhere on the Internet, but I forget exactly where. I’ll mention a couple of specific details, though.

The img.save(...) command on line 17 includes a couple of important options. The duration=75 option controls how long each frame is displayed, and it’s worth playing around with this. And loop=0 means the animation loops forever.

In the subprocess.run(...) call, the --lossy=200 parameter control the amount of compression by setting the maximum number of artefacts you want to allow. Again, this is something with which you might want to experiment. Setting this at 200 is quite extreme, but I do want reasonably small file sizes and am actually fine with compression artefacts – in fact, they’re quite desirable for the particular project I’m working on.

I’m still playing with this code and the images, but the results are encouraging, if occasionally disturbing. It’s also ignited an interest in using code to manipulate photographs, so this is something we might come back to.

1 thought on “Dicom to weird animated GIF via Python

  1. TX Broswell

    Love the visuals.Emotionally powerful, visually unsettling and compellingly unlike 99% all other examples of giflife. As such they fit the criteria for creative visual work that is worth doing. No idea what the lines of code even mean. That’s what makes you a perfect creative collaborator at the silentmuseum.org

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.