File size: 15,671 Bytes
7ef2f88
 
 
 
9c2e629
7ef2f88
 
 
da54348
7ef2f88
9c2e629
7ef2f88
 
9c2e629
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da54348
 
9c2e629
 
da54348
 
9c2e629
da54348
 
 
9c2e629
 
da54348
 
 
 
7ef2f88
da54348
 
9c2e629
 
 
 
7ef2f88
 
9c2e629
da54348
7ef2f88
 
da54348
 
7ef2f88
 
da54348
 
9c2e629
 
da54348
 
9c2e629
da54348
 
9c2e629
 
da54348
 
 
9c2e629
7ef2f88
 
 
 
 
 
 
 
 
 
 
da54348
7ef2f88
 
da54348
 
7ef2f88
da54348
 
 
9c2e629
da54348
7ef2f88
9c2e629
da54348
7ef2f88
da54348
 
 
 
 
 
 
9c2e629
 
 
 
 
 
 
7ef2f88
9c2e629
da54348
 
 
7ef2f88
da54348
7ef2f88
 
9c2e629
7ef2f88
9c2e629
 
 
 
7ef2f88
 
da54348
7ef2f88
9c2e629
7ef2f88
 
da54348
 
7ef2f88
 
da54348
9c2e629
7ef2f88
da54348
9c2e629
 
 
7ef2f88
da54348
7ef2f88
 
9c2e629
7ef2f88
 
9c2e629
 
7ef2f88
9c2e629
da54348
 
 
9c2e629
da54348
9c2e629
 
 
 
 
 
 
 
 
 
 
 
 
 
7ef2f88
da54348
9c2e629
da54348
9c2e629
 
 
da54348
 
 
 
 
 
 
 
 
7ef2f88
da54348
9c2e629
 
da54348
 
 
 
 
 
 
 
9c2e629
7ef2f88
da54348
9c2e629
 
 
7ef2f88
9c2e629
da54348
 
9c2e629
da54348
9c2e629
da54348
 
9c2e629
 
 
 
 
da54348
 
9c2e629
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da54348
9c2e629
 
 
 
 
 
 
 
 
 
 
 
 
 
7ef2f88
 
9c2e629
7ef2f88
 
da54348
9c2e629
 
 
7ef2f88
9c2e629
 
7ef2f88
 
da54348
 
7ef2f88
da54348
9c2e629
 
da54348
 
9c2e629
da54348
9c2e629
da54348
 
 
 
 
 
 
 
 
 
7ef2f88
da54348
 
 
 
9c2e629
 
da54348
9c2e629
da54348
 
9c2e629
 
 
 
 
7ef2f88
da54348
9c2e629
 
da54348
9c2e629
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
import pydicom
import pydicom.errors
from pydicom.pixel_data_handlers.util import apply_voi_lut
import numpy as np
from PIL import Image, ImageDraw
import io
import logging
import streamlit as st
from typing import Optional, Tuple, Dict, Any, List, Union

# Configure logger (assumed to be set up globally in your app)
logger = logging.getLogger(__name__)

# --- Helper Function to Filter PHI from Metadata ---
def filter_sensitive_metadata(metadata: Dict[str, Any]) -> Dict[str, Any]:
    """
    Filters out keys known to contain Protected Health Information (PHI)
    from the metadata dictionary.
    
    Args:
        metadata: Dictionary of metadata tags.
    
    Returns:
        Filtered dictionary with PHI removed.
    """
    # List of keys that might contain PHI (adjust as needed)
    phi_keys = {"PatientName", "PatientID", "PatientBirthDate", "PatientSex", "PatientAddress"}
    return {k: v for k, v in metadata.items() if k not in phi_keys}

# --- DICOM Parsing ---

@st.cache_data(max_entries=10, show_spinner=False)
def parse_dicom(dicom_bytes: bytes, filename: str = "Uploaded File", require_pixeldata: bool = True) -> Optional[pydicom.Dataset]:
    """
    Parses DICOM file bytes into a pydicom Dataset object.
    
    Args:
        dicom_bytes: The raw bytes of the DICOM file.
        filename: The original filename for logging/error messages.
        require_pixeldata: If True, treat missing PixelData as a fatal error.
        
    Returns:
        A pydicom Dataset object if successful, otherwise None.
    """
    logger.info(f"Attempting to parse DICOM data from '{filename}' ({len(dicom_bytes)} bytes)...")
    try:
        ds = pydicom.dcmread(io.BytesIO(dicom_bytes), force=True)
        logger.info(f"Successfully parsed DICOM data from '{filename}'. SOP Class: {ds.SOPClassUID.name if 'SOPClassUID' in ds else 'Unknown'}")
        if require_pixeldata and 'PixelData' not in ds:
            logger.error(f"DICOM file '{filename}' is missing PixelData tag.")
            st.error(f"Error: '{filename}' does not contain image data (PixelData tag missing).")
            return None
        return ds
    except pydicom.errors.InvalidDicomError as e:
        logger.error(f"Invalid DICOM data encountered in '{filename}': {e}", exc_info=False)
        st.error(f"Error parsing '{filename}': The file is not a valid DICOM file or is corrupted. Details: {e}")
        return None
    except Exception as e:
        logger.error(f"An unexpected error occurred reading DICOM data from '{filename}': {e}", exc_info=True)
        st.error(f"Failed to read DICOM file '{filename}'. An unexpected error occurred.")
        return None

# --- DICOM Metadata Extraction ---

@st.cache_data(max_entries=10, show_spinner=False)
def extract_dicom_metadata(ds: pydicom.Dataset, filter_phi: bool = True) -> Dict[str, Any]:
    """
    Extracts a predefined set of technical DICOM metadata tags.
    
    Args:
        ds: The pydicom Dataset object.
        filter_phi: If True, filter out keys that may contain PHI.
        
    Returns:
        A dictionary containing the values of the extracted tags.
    """
    logger.debug(f"Extracting technical metadata for SOP Instance UID: {ds.SOPInstanceUID if 'SOPInstanceUID' in ds else 'Unknown'}")
    metadata = {}
    tags_to_extract = {
        "Modality": (0x0008, 0x0060),
        "StudyDescription": (0x0008, 0x1030),
        "SeriesDescription": (0x0008, 0x103E),
        "PatientPosition": (0x0018, 0x5100),
        "Manufacturer": (0x0008, 0x0070),
        "ManufacturerModelName": (0x0008, 0x1090),
        "Rows": (0x0028, 0x0010),
        "Columns": (0x0028, 0x0011),
        "PixelSpacing": (0x0028, 0x0030),
        "SliceThickness": (0x0018, 0x0050),
        "WindowCenter": (0x0028, 0x1050),
        "WindowWidth": (0x0028, 0x1051),
        "RescaleIntercept": (0x0028, 0x1052),
        "RescaleSlope": (0x0028, 0x1053),
        "PhotometricInterpretation": (0x0028, 0x0004),
        "BitsAllocated": (0x0028, 0x0100),
        "BitsStored": (0x0028, 0x0101),
        "HighBit": (0x0028, 0x0102),
        "PixelRepresentation": (0x0028, 0x0103),
        "SamplesPerPixel": (0x0028, 0x0002),
    }
    
    for name, tag_address in tags_to_extract.items():
        try:
            element = ds[tag_address]
            value = element.value
            if value is None or value == "":
                metadata[name] = "N/A"
                continue

            if isinstance(value, pydicom.uid.UID):
                display_value = value.name
            elif isinstance(value, list):
                display_value = ", ".join(map(str, value))
            elif isinstance(value, pydicom.valuerep.DSfloat):
                display_value = float(value)
            elif isinstance(value, pydicom.valuerep.IS):
                display_value = int(value)
            else:
                display_value = value

            metadata[name] = display_value

        except KeyError:
            logger.debug(f"Metadata tag {name} ({tag_address}) not found in dataset.")
            metadata[name] = "Not Found"
        except Exception as e:
            logger.warning(f"Could not read metadata tag {name} ({tag_address}): {e}", exc_info=False)
            metadata[name] = "Error Reading"
    
    logger.debug(f"Extracted {len(metadata)} metadata tags.")
    if filter_phi:
        metadata = filter_sensitive_metadata(metadata)
    return metadata

# --- DICOM Image Conversion ---

@st.cache_data(max_entries=20, show_spinner="Processing DICOM image...")
def dicom_to_image(
    ds: pydicom.Dataset,
    window_center: Optional[Union[float, List[float]]] = None,
    window_width: Optional[Union[float, List[float]]] = None
) -> Optional[Image.Image]:
    """
    Converts DICOM pixel data to a displayable PIL Image (RGB), applying VOI LUT.
    
    Args:
        ds: The pydicom Dataset object containing PixelData.
        window_center: Window Center value(s) for VOI LUT (first used if list).
        window_width: Window Width value(s) for VOI LUT (first used if list).
    
    Returns:
        A PIL Image object in RGB format, or None if processing fails.
    """
    if 'PixelData' not in ds:
        logger.error("Cannot convert to image: Missing PixelData tag.")
        return None

    logger.debug(f"Converting DICOM to image. Photometric Interpretation: {ds.get('PhotometricInterpretation', 'N/A')}")
    
    try:
        pixel_array = ds.pixel_array

        wc_to_use: Optional[float] = None
        ww_to_use: Optional[float] = None
        
        if window_center is not None and window_width is not None:
            wc_in = window_center[0] if isinstance(window_center, list) and window_center else window_center
            ww_in = window_width[0] if isinstance(window_width, list) and window_width else window_width
            try:
                wc_to_use = float(wc_in) if wc_in is not None else None
                ww_to_use = float(ww_in) if ww_in is not None else None
                if ww_to_use is not None and ww_to_use <= 0:
                    logger.warning(f"Provided Window Width ({ww_to_use}) is invalid. Ignoring.")
                    ww_to_use = None
                else:
                    logger.info(f"Using provided WC/WW: {wc_to_use} / {ww_to_use}")
            except (ValueError, TypeError):
                logger.warning(f"Conversion error for provided WC/WW values ('{wc_in}', '{ww_in}'). Ignoring.")
                wc_to_use = None
                ww_to_use = None

        if wc_to_use is None or ww_to_use is None:
            default_wc, default_ww = get_default_wl(ds)
            if default_wc is not None and default_ww is not None:
                wc_to_use = default_wc
                ww_to_use = default_ww
                logger.info(f"Using default WC/WW: {wc_to_use} / {ww_to_use}")

        if wc_to_use is not None and ww_to_use is not None:
            logger.debug(f"Applying VOI LUT with WC={wc_to_use}, WW={ww_to_use}")
            processed_array = apply_voi_lut(pixel_array, ds, window=ww_to_use, level=wc_to_use)
            min_val, max_val = processed_array.min(), processed_array.max()
            if max_val > min_val:
                pixel_array_scaled = ((processed_array - min_val) / (max_val - min_val + 1e-6)) * 255.0
            else:
                pixel_array_scaled = np.zeros_like(processed_array)
            pixel_array_uint8 = pixel_array_scaled.astype(np.uint8)
            logger.debug("VOI LUT applied and scaled to uint8.")
        else:
            logger.info("No valid WC/WW provided. Applying basic min/max scaling.")
            if 'RescaleSlope' in ds and 'RescaleIntercept' in ds:
                try:
                    slope = float(ds.RescaleSlope)
                    intercept = float(ds.RescaleIntercept)
                    if slope != 1.0 or intercept != 0.0:
                        logger.debug(f"Applying Rescale Slope ({slope}) and Intercept ({intercept})")
                        pixel_array = pixel_array.astype(np.float64) * slope + intercept
                except Exception as rescale_err:
                    logger.warning(f"Rescale Slope/Intercept error: {rescale_err}")
            min_val, max_val = pixel_array.min(), pixel_array.max()
            if max_val > min_val:
                scaled_array = ((pixel_array - min_val) / (max_val - min_val + 1e-6)) * 255.0
            else:
                scaled_array = np.zeros_like(pixel_array)
            pixel_array_uint8 = scaled_array.astype(np.uint8)
            logger.debug("Basic scaling applied and converted to uint8.")

        photometric_interpretation = ds.get("PhotometricInterpretation", "").upper()
        logger.debug(f"Array shape: {pixel_array_uint8.shape}, dtype: {pixel_array_uint8.dtype}")

        if pixel_array_uint8.ndim == 2:
            if photometric_interpretation in ("MONOCHROME1", "MONOCHROME2"):
                image = Image.fromarray(pixel_array_uint8, mode='L').convert("RGB")
                logger.debug("Converted 2D grayscale array to RGB.")
            else:
                logger.warning(f"Unknown 2D Photometric Interpretation '{photometric_interpretation}'. Using MONOCHROME2 assumption.")
                image = Image.fromarray(pixel_array_uint8, mode='L').convert("RGB")
        elif pixel_array_uint8.ndim == 3:
            samples_per_pixel = ds.get("SamplesPerPixel", 1)
            if samples_per_pixel == 3 and photometric_interpretation in ("RGB", "YBR_FULL", "YBR_FULL_422"):
                planar_config = ds.get("PlanarConfiguration", 0)
                if planar_config == 0:
                    if pixel_array_uint8.shape[-1] == 3:
                        image = Image.fromarray(pixel_array_uint8, mode='RGB')
                        logger.debug("Converted 3D array (PlanarConfig=0) to RGB.")
                    else:
                        logger.warning(f"Unexpected shape for PlanarConfig=0: {pixel_array_uint8.shape}. Using first channel.")
                        image = Image.fromarray(pixel_array_uint8[:,:,0], mode='L').convert("RGB")
                elif planar_config == 1:
                    if pixel_array_uint8.shape[0] == 3:
                        logger.debug("Reshaping 3D array (PlanarConfig=1) for RGB conversion.")
                        reshaped_array = np.transpose(pixel_array_uint8, (1, 2, 0))
                        image = Image.fromarray(reshaped_array, mode='RGB')
                    else:
                        logger.warning(f"Unexpected shape for PlanarConfig=1: {pixel_array_uint8.shape}. Using first plane.")
                        image = Image.fromarray(pixel_array_uint8[0,:,:], mode='L').convert("RGB")
                else:
                    logger.warning(f"Unexpected Planar Configuration ({planar_config}). Assuming color-by-pixel.")
                    if pixel_array_uint8.shape[-1] == 3:
                        image = Image.fromarray(pixel_array_uint8, mode='RGB')
                    else:
                        image = Image.fromarray(pixel_array_uint8[:,:,0], mode='L').convert("RGB")
            elif samples_per_pixel == 1:
                # Multi-frame grayscale: if more than one frame, take the first one.
                if pixel_array_uint8.shape[0] > 1:
                    logger.info("Detected multi-frame grayscale. Displaying first frame.")
                    image = Image.fromarray(pixel_array_uint8[0,:,:], mode='L').convert("RGB")
                else:
                    image = Image.fromarray(pixel_array_uint8, mode='L').convert("RGB")
            else:
                logger.warning(f"Unsupported 3D array format: shape {pixel_array_uint8.shape}, SamplesPerPixel={samples_per_pixel}.")
                try:
                    if pixel_array_uint8.ndim == 3 and pixel_array_uint8.shape[0] > 1:
                        image = Image.fromarray(pixel_array_uint8[0,:,:], mode='L').convert("RGB")
                    else:
                        image = Image.fromarray(pixel_array_uint8[:,:,0], mode='L').convert("RGB")
                except Exception as e:
                    logger.error("Error extracting 2D slice from 3D array.")
                    return None
        elif pixel_array_uint8.ndim == 4:
            logger.error(f"Unsupported 4D array dimensions: {pixel_array_uint8.shape}")
            st.warning("Failed to process DICOM image: 4D data is not supported.")
            return None
        else:
            logger.error(f"Unsupported array dimensions: {pixel_array_uint8.ndim}")
            st.warning("Failed to process DICOM image due to unsupported array dimensions.")
            return None

        logger.info(f"Successfully converted DICOM to PIL Image (RGB, size: {image.size}).")
        return image

    except AttributeError as e:
        logger.error(f"AttributeError during image conversion: {e}", exc_info=False)
        st.warning(f"Failed to process image data: Required DICOM tag missing ({e}).")
        return None
    except Exception as e:
        logger.error(f"Unexpected error converting DICOM to image: {e}", exc_info=True)
        st.warning("An unexpected error occurred while processing DICOM image data.")
        return None

# --- Window/Level Helper ---

def get_default_wl(ds: pydicom.Dataset) -> Tuple[Optional[float], Optional[float]]:
    """
    Retrieves default Window Center and Width from DICOM tags.
    
    Args:
        ds: The pydicom Dataset object.
    
    Returns:
        A tuple (WindowCenter, WindowWidth) or (None, None) if not found.
    """
    wc_val = ds.get("WindowCenter", None)
    ww_val = ds.get("WindowWidth", None)
    wc: Optional[float] = None
    ww: Optional[float] = None

    if isinstance(wc_val, pydicom.multival.MultiValue):
        wc_val = wc_val[0] if len(wc_val) > 0 else None
    if isinstance(ww_val, pydicom.multival.MultiValue):
        ww_val = ww_val[0] if len(ww_val) > 0 else None

    if wc_val is not None:
        try:
            wc = float(wc_val)
        except (ValueError, TypeError):
            logger.debug(f"Could not convert WindowCenter '{wc_val}' to float.")
            wc = None
    if ww_val is not None:
        try:
            ww = float(ww_val)
            if ww <= 0:
                logger.debug(f"Invalid WindowWidth ({ww}).")
                ww = None
        except (ValueError, TypeError):
            logger.debug(f"Could not convert WindowWidth '{ww_val}' to float.")
            ww = None

    if wc is not None and ww is not None:
        logger.debug(f"Found default WC/WW: {wc} / {ww}")
        return wc, ww
    else:
        logger.debug("Default WC/WW not found or invalid.")
        return None, None