Converting hand radiograph DICOM to PNG returns white/bright image

Question:

I am converting hand X-rays in DICOM format to PNG format. The code below does this:

import os
import cv2
import pydicom
import numpy as np
from PIL import Image

inputdir = "P:/BoneDataset/DICOM-File/0-RefinedDICOM/"
outdir = 'P:/BoneDataset/DICOM-File/1-ConvertedPics/'

test_list = [f for f in os.listdir(inputdir)]

for f in test_list[:10]:
    ds = pydicom.read_file(inputdir + f) # read dicom image
    img = ds.pixel_array # get image array
    scaled_img = (np.maximum(img,0) / img.max()) * 255.0
    img = scaled_img.astype(np.uint8)
    cv2.imwrite(outdir + f.replace('.dcm','.png'),img)

The image below shows some of the result (Input (DICOM) –> Output (PNG)):

Input 1 –> Output 1

Input 2 –> Output 2

Input 3 –> Output 3

As you can see, I want the output images to look the same as the input X-ray, however, I get black and white output. Is this because of image threshold issue or something wrong with the file?

EDIT 1:

I tried the code suggested below, however it returns the same output as before, not as the same image as the input.

I’ve anonymized the DICOM files, hence you can find the DICOM dataset files HERE. Please use these DICOM files.

Asked By: Bathtub

||

Answers:

As commented, scaling the maximum may not be enough.
We may try "linear stretching", where some low percentile goes to 0, high percentile goes to 255, and levels in between are transformed linearly.

Additional option:
Since many DICOM images have black margins, and white labels, we may want to ignore the minimum and maximum values, when computing the percentiles.

There is no guarantee that the result is going to be the same as the "the input X-ray", but it is probably not going to be black and white.

For testing, I downloaded CASE 1 DICOM samples from here.


Code sample:

import cv2
import pydicom
import numpy as np

def lin_stretch_img(img, low_prc, high_prc, do_ignore_minmax=True):
    """ 
    Apply linear "stretch" - low_prc percentile goes to 0, 
    and high_prc percentile goes to 255.
    The result is clipped to [0, 255] and converted to np.uint8

    Additional feature:
    When computing high and low percentiles, ignore the minimum and maximum intensities (assumed to be outliers).
    """
    # For ignoring the outliers, replace them with the median value
    if do_ignore_minmax:
        tmp_img = img.copy()
        med = np.median(img)  # Compute median
        tmp_img[img == img.min()] = med
        tmp_img[img == img.max()] = med
    else:
        tmp_img = img

    lo, hi = np.percentile(tmp_img, (low_prc, high_prc))  # Example: 1% - Low percentile, 99% - High percentile

    if lo == hi:
        return np.full(img.shape, 128, np.uint8)  # Protection: return gray image if lo = hi.

    stretch_img = (img.astype(float) - lo) * (255/(hi-lo))  # Linear stretch: lo goes to 0, hi to 255.
    stretch_img = stretch_img.clip(0, 255).astype(np.uint8)  # Clip range to [0, 255] and convert to uint8
    return stretch_img


# https://www.visus.com/fileadmin/content/pictures/Downloads/JiveX_DICOME_Viewer/case1.zip
ds = pydicom.read_file('case1_008.dcm') # read dicom image
img = ds.pixel_array # get image array

img = lin_stretch_img(img, 1, 99)  # Apply "linear stretching" (lower percentile 1 goes to 0, and percentile 99 to 255).

cv2.imwrite('case1_008.png', img)

Output of your code:
enter image description here

Output of above sample code:
enter image description here

Output of img = lin_stretch_img(img, 0.01, 99.99) (may give better result):
enter image description here


Update

The polarity of the sample DICOM images is inverted.
The minimum value is intended to be displayed as white, and the maximum as black.

For correcting the polarity, we may execute img = 255-img (after converting to uint8).

Checking if the polarity is inverted:
According to the documentation, if Photometric Interpretation equals ‘MONOCHROME1’, then the polarity is inverted (‘MONOCHROME2’ is not inverted).

MONOCHROME1
Pixel data represent a single monochrome image plane. The minimum sample
value is intended to be displayed as white after any VOI gray scale transformations have been performed. See PS3.4. This value may be used only when Samples per Pixel (0028,0002) has a value of 1. May be used for pixel data in a Native (uncompressed) or Encapsulated (compressed) format.

Inverting polarity if Photometric Interpretation is ‘MONOCHROME1’:

if ds[0x0028, 0x0004].value == 'MONOCHROME1':
    img = 255-img

The documentation also says we have to apply it "after VOI gray scale transformations".

Applying "VOI gray scale transformations" is described here:

img = apply_voi_lut(img, ds, index=0)

Updated code sample:

import cv2
import pydicom
from pydicom.pixel_data_handlers.util import apply_voi_lut
import numpy as np

def lin_stretch_img(img, low_prc, high_prc, do_ignore_minmax=True):
    """ 
    Apply linear "stretch" - low_prc percentile goes to 0, 
    and high_prc percentile goes to 255.
    The result is clipped to [0, 255] and converted to np.uint8

    Additional feature:
    When computing high and low percentiles, ignore the minimum and maximum intensities (assumed to be outliers).
    """
    # For ignoring the outliers, replace them with the median value
    if do_ignore_minmax:
        tmp_img = img.copy()
        med = np.median(img)  # Compute median
        tmp_img[img == img.min()] = med
        tmp_img[img == img.max()] = med
    else:
        tmp_img = img

    lo, hi = np.percentile(tmp_img, (low_prc, high_prc))  # Example: 1% - Low percentile, 99% - High percentile

    if lo == hi:
        return np.full(img.shape, 128, np.uint8)  # Protection: return gray image if lo = hi.

    stretch_img = (img.astype(float) - lo) * (255/(hi-lo))  # Linear stretch: lo goes to 0, hi to 255.
    stretch_img = stretch_img.clip(0, 255).astype(np.uint8)  # Clip range to [0, 255] and convert to uint8
    return stretch_img


# https://www.visus.com/fileadmin/content/pictures/Downloads/JiveX_DICOME_Viewer/case1.zip
ds = pydicom.read_file('1.2.392.200036.9125.9.0.152034855.3288075520.2287343482.dcm') # read dicom image
img = ds.pixel_array # get image array

# https://pydicom.github.io/pydicom/stable/old/working_with_pixel_data.html#voi-lut-or-windowing-operation
# Apply "VOI gray scale transformations":
img = apply_voi_lut(img, ds, index=0)

img = lin_stretch_img(img, 0.1, 99.9)  # Apply "linear stretching" (lower percentile 0.1 goes to 0, and percentile 99.9 to 255).

# https://dicom.innolitics.com/ciods/rt-dose/image-pixel/00280004
if ds[0x0028, 0x0004].value == 'MONOCHROME1':
    img = 255-img  # Invert polarity if Photometric Interpretation is 'MONOCHROME1'

cv2.imwrite('1.2.392.200036.9125.9.0.152034855.3288075520.2287343482.png', img)

Output sample:
enter image description here

Answered By: Rotem