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:

Output of above sample code:

Output of img = lin_stretch_img(img, 0.01, 99.99)
(may give better result):

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:
