#pydicom_Tkinter.py
#
# Copyright (c) 2009 Daniel Nanz
# This file is released under the pydicom (http://code.google.com/p/pydicom/)
# license, see the file license.txt available at 
# (http://code.google.com/p/pydicom/)
# 
# revision history:
# Dec-08-2009: version 0.1
# 
# 0.1:   tested with pydicom version 0.9.3, Python version 2.6.2 (32-bit) 
#        under Windows XP Professional 2002, and Mac OS X 10.5.5, 
#        using numpy 1.3.0 and a small random selection of MRI and 
#        CT images.
'''
View DICOM images from pydicom

requires numpy:  http://numpy.scipy.org/ 

Usage:
------
>>> import dicom              # pydicom
>>> import dicom.contrib.pydicom_Tkinter as pydicom_Tkinter    # this module

>>> df = dicom.read_file(filename)
>>> pydicom_Tkinter.show_image(df)
''' 

from __future__ import with_statement # for python 2.5
import Tkinter
import tempfile
import os

have_numpy = True
try:
    import numpy as np
except:
    # will not work...
    have_numpy = False


def get_PGM_bytedata_string(arr):
    '''Given a 2D numpy array as input write gray-value image data in the PGM 
    format into a byte string and return it.
    
    arr: single-byte unsigned int numpy array
    note: Tkinter's PhotoImage object seems to accept only single-byte data
    '''
    
    if arr.dtype != np.uint8:
        raise ValueError
    if len(arr.shape) != 2:
        raise ValueError
    
    # array.shape is (#rows, #cols) tuple; PGM input needs this reversed
    col_row_string = ' '.join(reversed(map(str, arr.shape)))

    bytedata_string = '\n'.join(('P5',
                                 col_row_string,
                                 str(arr.max()),
                                 arr.tostring()))
    return bytedata_string


def get_PGM_from_numpy_arr(arr, window_center, window_width, 
                           lut_min=0, lut_max=255):
    '''real-valued numpy input  ->  PGM-image formatted byte string
    
    arr: real-valued numpy array to display as grayscale image
    window_center, window_width: to define max/min values to be mapped to the
                                 lookup-table range. WC/WW scaling is done
                                 according to DICOM-3 specifications.
    lut_min, lut_max: min/max values of (PGM-) grayscale table: do not change
    '''

    if np.isreal(arr).sum() != arr.size:
        raise ValueError

    # currently only support 8-bit colors
    if lut_max != 255:
        raise ValueError

    if arr.dtype != np.float64:
        arr = arr.astype(np.float64)
    
    # LUT-specific array scaling
    # width >= 1 (DICOM standard)
    window_width = max(1, window_width)
    
    wc, ww = np.float64(window_center), np.float64(window_width)
    lut_range = np.float64(lut_max) - lut_min

    minval = wc - 0.5 - (ww - 1.0) / 2.0
    maxval = wc - 0.5 + (ww - 1.0) / 2.0

    min_mask = (minval >= arr)
    to_scale = (arr > minval) & (arr < maxval)
    max_mask = (arr >= maxval)
    
    if min_mask.any(): arr[min_mask] = lut_min
    if to_scale.any(): arr[to_scale] = ((arr[to_scale] - (wc - 0.5)) /
                                        (ww - 1.0) + 0.5) * lut_range + lut_min
    if max_mask.any(): arr[max_mask] = lut_max 
    
    # round to next integer values and convert to unsigned int
    arr = np.rint(arr).astype(np.uint8)
    
    # return PGM byte-data string
    return get_PGM_bytedata_string(arr)


def get_tkinter_photoimage_from_pydicom_image(data):
    '''
    Wrap data.pixel_array in a Tkinter PhotoImage instance, 
    after conversion into a PGM grayscale image.
    
    This will fail if the "numpy" module is not installed in the attempt of 
    creating the data.pixel_array.

    data:  object returned from pydicom.read_file()
    side effect: may leave a temporary .pgm file on disk
    '''
    
    # get numpy array as representation of image data
    arr = data.pixel_array.astype(np.float64)
    
    # pixel_array seems to be the original, non-rescaled array.
    # If present, window center and width refer to rescaled array
    # -> do rescaling if possible.
    if ('RescaleIntercept' in data) and ('RescaleSlope' in data):
        intercept = data.RescaleIntercept  # single value
        slope = data.RescaleSlope          # 
        arr = slope * arr + intercept
    
    # get default window_center and window_width values
    wc = (arr.max() + arr.min()) / 2.0
    ww = arr.max() - arr.min() + 1.0
    
    # overwrite with specific values from data, if available
    if ('WindowCenter' in data) and ('WindowWidth' in data):
        wc = data.WindowCenter
        ww = data.WindowWidth
        try:
            wc = wc[0]            # can be multiple values
        except:
            pass
        try:
            ww = ww[0]
        except:
            pass

    # scale array to account for center, width and PGM grayscale range,
    # and wrap into PGM formatted ((byte-) string
    pgm = get_PGM_from_numpy_arr(arr, wc, ww)
    
    # create a PhotoImage
    # for as yet unidentified reasons the following fails for certain 
    # window center/width values:
    #         photo_image = Tkinter.PhotoImage(data=pgm, gamma=1.0)
    #    Error with Python 2.6.2 under Windows XP:
    #          (self.tk.call(('image', 'create', imgtype, name,) + options)
    #          _tkinter.TclError: truncated PPM data
    #    OsX: distorted images 
    # while all seems perfectly OK for other values of center/width or when 
    # the PGM is first written to a temporary file and read again 

    # write PGM file into temp dir
    (os_id, abs_path) = tempfile.mkstemp(suffix='.pgm')
    with open(abs_path, 'wb') as fd:
        fd.write(pgm)
        
    photo_image = Tkinter.PhotoImage(file=abs_path, gamma=1.0)
    
    # close and remove temporary file on disk
    # os.close is needed under windows for os.remove not to fail
    try:
        os.close(os_id)
        os.remove(abs_path)
    except:
        pass  # silently leave file on disk in temp-like directory
    
    return photo_image
    

def show_image(data, block=True, master=None):
    '''
    Get minimal Tkinter GUI and display a pydicom data.pixel_array
    
    data: object returned from pydicom.read_file()
    block: if True run Tk mainloop() to show the image
    master: use with block==False and an existing Tk widget as parent widget
    
    side effects: may leave a temporary .pgm file on disk
    '''
    frame = Tkinter.Frame(master=master, background='#000')
    if 'SeriesDescription' in data and 'InstanceNumber' in data:
        title = ', '.join(('Ser: ' + data.SeriesDescription,
                           'Img: ' + str(data.InstanceNumber)))
    else:
        title = 'pydicom image'
    frame.master.title(title)
    photo_image = get_tkinter_photoimage_from_pydicom_image(data)
    label = Tkinter.Label(frame, image=photo_image, background='#000')
    # keep a reference to avoid disappearance upon garbage collection
    label.photo_reference = photo_image
    label.grid()
    frame.grid()
    
    if block==True:
        frame.mainloop()
