mgbam commited on
Commit
9c2e629
·
verified ·
1 Parent(s): 3e72c0d

Update dicom_utils.py

Browse files
Files changed (1) hide show
  1. dicom_utils.py +158 -192
dicom_utils.py CHANGED
@@ -2,46 +2,57 @@ import pydicom
2
  import pydicom.errors
3
  from pydicom.pixel_data_handlers.util import apply_voi_lut
4
  import numpy as np
5
- from PIL import Image
6
  import io
7
  import logging
8
  import streamlit as st
9
  from typing import Optional, Tuple, Dict, Any, List, Union
10
 
11
- # Assume logger is configured elsewhere, consistent with the action handling section
12
  logger = logging.getLogger(__name__)
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  # --- DICOM Parsing ---
15
 
16
- @st.cache_data(max_entries=10, show_spinner=False) # Cache parsing, hide default spinner
17
- def parse_dicom(dicom_bytes: bytes, filename: str = "Uploaded File") -> Optional[pydicom.Dataset]:
18
  """
19
  Parses DICOM file bytes into a pydicom Dataset object.
20
-
21
  Args:
22
  dicom_bytes: The raw bytes of the DICOM file.
23
  filename: The original filename for logging/error messages.
24
-
 
25
  Returns:
26
  A pydicom Dataset object if successful, otherwise None.
27
  """
28
  logger.info(f"Attempting to parse DICOM data from '{filename}' ({len(dicom_bytes)} bytes)...")
29
  try:
30
- # Use force=True to potentially read files with minor header issues,
31
- # but be aware this might allow slightly non-compliant files.
32
- # Consider if strict compliance is necessary for your use case.
33
  ds = pydicom.dcmread(io.BytesIO(dicom_bytes), force=True)
34
  logger.info(f"Successfully parsed DICOM data from '{filename}'. SOP Class: {ds.SOPClassUID.name if 'SOPClassUID' in ds else 'Unknown'}")
35
- # Basic validation: check for essential tags like PixelData if it's an image type
36
- if 'PixelData' not in ds:
37
- logger.warning(f"DICOM file '{filename}' parsed but lacks PixelData tag. May not be an image.")
38
- # Decide if this should be an error or just a warning depending on expected file types
39
- # st.warning(f"'{filename}' does not contain image data (PixelData tag missing).")
40
- # return None # Optionally return None if PixelData is mandatory
41
-
42
  return ds
43
  except pydicom.errors.InvalidDicomError as e:
44
- logger.error(f"Invalid DICOM data encountered in '{filename}': {e}", exc_info=False) # Keep log concise
45
  st.error(f"Error parsing '{filename}': The file is not a valid DICOM file or is corrupted. Details: {e}")
46
  return None
47
  except Exception as e:
@@ -51,26 +62,21 @@ def parse_dicom(dicom_bytes: bytes, filename: str = "Uploaded File") -> Optional
51
 
52
  # --- DICOM Metadata Extraction ---
53
 
54
- @st.cache_data(max_entries=10, show_spinner=False) # Cache metadata extraction
55
- def extract_dicom_metadata(ds: pydicom.Dataset) -> Dict[str, Any]:
56
  """
57
  Extracts a predefined set of technical DICOM metadata tags.
58
-
59
  Args:
60
  ds: The pydicom Dataset object.
61
-
 
62
  Returns:
63
  A dictionary containing the values of the extracted tags.
64
- Note: This function extracts technical parameters and does NOT perform
65
- comprehensive PHI filtering. Rely on specific filtering mechanisms
66
- (like in the report generation) before displaying sensitive data.
67
  """
68
- logger.debug(f"Extracting predefined technical metadata for SOP Instance UID: {ds.SOPInstanceUID if 'SOPInstanceUID' in ds else 'Unknown'}")
69
  metadata = {}
70
- # Define technical tags typically safe and useful for display/processing
71
- # **This is NOT a PHI filter list.**
72
  tags_to_extract = {
73
- # Tag Name (for dict key) : (Group, Element)
74
  "Modality": (0x0008, 0x0060),
75
  "StudyDescription": (0x0008, 0x1030),
76
  "SeriesDescription": (0x0008, 0x103E),
@@ -89,31 +95,28 @@ def extract_dicom_metadata(ds: pydicom.Dataset) -> Dict[str, Any]:
89
  "BitsAllocated": (0x0028, 0x0100),
90
  "BitsStored": (0x0028, 0x0101),
91
  "HighBit": (0x0028, 0x0102),
92
- "PixelRepresentation": (0x0028, 0x0103), # 0=unsigned, 1=signed
93
  "SamplesPerPixel": (0x0028, 0x0002),
94
  }
95
-
96
  for name, tag_address in tags_to_extract.items():
97
  try:
98
  element = ds[tag_address]
99
  value = element.value
100
- # Handle None, empty sequences, or empty strings explicitly
101
  if value is None or value == "":
102
  metadata[name] = "N/A"
103
  continue
104
 
105
- # Nicer representation for specific types
106
  if isinstance(value, pydicom.uid.UID):
107
- display_value = value.name # Use UID name representation
108
- elif isinstance(value, list): # Handle multi-valued tags
109
- # Convert elements to string, join, handle potential nested lists/objects simply
110
- display_value = ", ".join(map(str, value))
111
- elif isinstance(value, pydicom.valuerep.DSfloat): # Decimal String
112
- display_value = float(value)
113
- elif isinstance(value, pydicom.valuerep.IS): # Integer String
114
- display_value = int(value)
115
  else:
116
- display_value = value # Use the value directly for others (int, float, str)
117
 
118
  metadata[name] = display_value
119
 
@@ -121,16 +124,17 @@ def extract_dicom_metadata(ds: pydicom.Dataset) -> Dict[str, Any]:
121
  logger.debug(f"Metadata tag {name} ({tag_address}) not found in dataset.")
122
  metadata[name] = "Not Found"
123
  except Exception as e:
124
- # Log error but don't crash metadata extraction for one bad tag
125
- logger.warning(f"Could not read or process metadata tag {name} ({tag_address}): {e}", exc_info=False)
126
  metadata[name] = "Error Reading"
127
-
128
- logger.debug(f"Finished extracting {len(metadata)} technical metadata tags.")
 
 
129
  return metadata
130
 
131
  # --- DICOM Image Conversion ---
132
 
133
- @st.cache_data(max_entries=20, show_spinner="Processing DICOM image...") # Cache image generation, show spinner
134
  def dicom_to_image(
135
  ds: pydicom.Dataset,
136
  window_center: Optional[Union[float, List[float]]] = None,
@@ -138,231 +142,193 @@ def dicom_to_image(
138
  ) -> Optional[Image.Image]:
139
  """
140
  Converts DICOM pixel data to a displayable PIL Image (RGB), applying VOI LUT.
141
-
142
- Handles grayscale and some basic color formats. Uses provided Window/Level
143
- values or falls back to dataset defaults or simple min/max scaling.
144
-
145
  Args:
146
  ds: The pydicom Dataset object containing PixelData.
147
- window_center: Window Center (Level) value(s) for VOI LUT. Uses first if list.
148
- window_width: Window Width value(s) for VOI LUT. Uses first if list.
149
-
150
  Returns:
151
  A PIL Image object in RGB format, or None if processing fails.
152
  """
153
  if 'PixelData' not in ds:
154
- logger.error("Cannot convert to image: DICOM dataset lacks PixelData tag.")
155
- # No st.error here as parse_dicom might have warned already, avoid duplication
156
  return None
157
 
158
- logger.debug(f"Starting DICOM to image conversion. Photometric Interpretation: {ds.get('PhotometricInterpretation', 'N/A')}")
159
-
160
  try:
161
- pixel_array = ds.pixel_array # Access the pixel data
162
 
163
- # --- Determine and Apply Window/Level ---
164
  wc_to_use: Optional[float] = None
165
  ww_to_use: Optional[float] = None
166
-
167
- # Prioritize user-provided W/L values
168
  if window_center is not None and window_width is not None:
169
- # Handle potential multi-value inputs, take the first valid one
170
- wc_in = window_center[0] if isinstance(window_center, list) and window_center else window_center
171
- ww_in = window_width[0] if isinstance(window_width, list) and window_width else window_width
172
- try:
173
- wc_to_use = float(wc_in) if wc_in is not None else None
174
- ww_to_use = float(ww_in) if ww_in is not None else None
175
- if ww_to_use is not None and ww_to_use <= 0:
176
- logger.warning(f"Provided Window Width ({ww_to_use}) is invalid, ignoring.")
177
- ww_to_use = None # Invalidate if non-positive width
178
- elif wc_to_use is not None and ww_to_use is not None:
179
- logger.info(f"Using provided WC/WW: {wc_to_use} / {ww_to_use}")
180
- except (ValueError, TypeError):
181
- logger.warning(f"Could not convert provided WC/WW ('{wc_in}', '{ww_in}') to float, ignoring.")
182
- wc_to_use = None
183
- ww_to_use = None
184
 
185
- # If user W/L not valid or not provided, try default W/L from DICOM tags
186
  if wc_to_use is None or ww_to_use is None:
187
- default_wc, default_ww = get_default_wl(ds) # Use helper function
188
  if default_wc is not None and default_ww is not None:
189
- wc_to_use = default_wc
190
- ww_to_use = default_ww
191
- logger.info(f"Using default WC/WW from DICOM tags: {wc_to_use} / {ww_to_use}")
192
 
193
- # --- Apply Transformation ---
194
  if wc_to_use is not None and ww_to_use is not None:
195
  logger.debug(f"Applying VOI LUT with WC={wc_to_use}, WW={ww_to_use}")
196
- # Ensure data type is appropriate if necessary before apply_voi_lut
197
- # pixel_array = pixel_array.astype(np.float64) # Sometimes needed depending on pydicom/numpy versions
198
  processed_array = apply_voi_lut(pixel_array, ds, window=ww_to_use, level=wc_to_use)
199
- # Scale result of VOI LUT to 0-255
200
  min_val, max_val = processed_array.min(), processed_array.max()
201
  if max_val > min_val:
202
- # Add small epsilon to prevent division by zero if max_val == min_val after LUT
203
  pixel_array_scaled = ((processed_array - min_val) / (max_val - min_val + 1e-6)) * 255.0
204
  else:
205
  pixel_array_scaled = np.zeros_like(processed_array)
206
  pixel_array_uint8 = pixel_array_scaled.astype(np.uint8)
207
  logger.debug("VOI LUT applied and scaled to uint8.")
208
-
209
- else: # Fallback to basic min/max scaling if no valid W/L found
210
- logger.info("No valid Window/Level found. Applying basic min/max scaling.")
211
- # Apply Rescale Slope/Intercept if present, as VOI LUT wasn't used
212
  if 'RescaleSlope' in ds and 'RescaleIntercept' in ds:
213
  try:
214
  slope = float(ds.RescaleSlope)
215
  intercept = float(ds.RescaleIntercept)
216
  if slope != 1.0 or intercept != 0.0:
217
  logger.debug(f"Applying Rescale Slope ({slope}) and Intercept ({intercept})")
218
- # Ensure float array for calculation
219
  pixel_array = pixel_array.astype(np.float64) * slope + intercept
220
- else:
221
- logger.debug("Rescale Slope=1, Intercept=0, no rescale needed.")
222
  except Exception as rescale_err:
223
- logger.warning(f"Could not apply Rescale Slope/Intercept: {rescale_err}")
224
-
225
-
226
  min_val, max_val = pixel_array.min(), pixel_array.max()
227
  if max_val > min_val:
228
- # Add small epsilon to prevent division by zero
229
- scaled_array = ((pixel_array - min_val) / (max_val - min_val + 1e-6) * 255.0)
230
- else: # Handle constant image
231
- scaled_array = np.zeros_like(pixel_array)
232
  pixel_array_uint8 = scaled_array.astype(np.uint8)
233
- logger.debug("Basic min/max scaling applied and converted to uint8.")
234
-
235
 
236
- # --- Convert numpy array to PIL Image (RGB) ---
237
  photometric_interpretation = ds.get("PhotometricInterpretation", "").upper()
238
- logger.debug(f"Array shape for PIL conversion: {pixel_array_uint8.shape}, dtype: {pixel_array_uint8.dtype}")
239
 
240
- if pixel_array_uint8.ndim == 2: # Grayscale image
241
- # Common grayscale types
242
  if photometric_interpretation in ("MONOCHROME1", "MONOCHROME2"):
243
  image = Image.fromarray(pixel_array_uint8, mode='L').convert("RGB")
244
- logger.debug("Converted 2D Grayscale (MONOCHROME1/2) array to RGB PIL Image.")
245
- else: # Other 2D formats, treat as grayscale
246
- logger.warning(f"Unknown 2D Photometric Interpretation '{photometric_interpretation}'. Treating as MONOCHROME2.")
247
- image = Image.fromarray(pixel_array_uint8, mode='L').convert("RGB")
248
-
249
- elif pixel_array_uint8.ndim == 3: # Potentially color or multi-frame
250
- logger.debug(f"Input array has 3 dimensions. Photometric Interpretation: {photometric_interpretation}")
251
- # Check samples per pixel
252
  samples_per_pixel = ds.get("SamplesPerPixel", 1)
253
-
254
  if samples_per_pixel == 3 and photometric_interpretation in ("RGB", "YBR_FULL", "YBR_FULL_422"):
255
- # Planar Configuration (0=Color-by-pixel, 1=Color-by-plane)
256
- planar_config = ds.get("PlanarConfiguration", 0)
257
- if planar_config == 0: # Color-by-pixel (RRR...GGG...BBB...) is unusual for numpy shape but check anyway
258
- if pixel_array_uint8.shape[-1] == 3:
259
- image = Image.fromarray(pixel_array_uint8, mode='RGB')
260
- logger.debug("Converted 3D array (SamplesPerPixel=3, PlanarConfig=0) to RGB PIL Image.")
261
- else:
262
- logger.warning(f"Expected shape[2]=3 for Planar Config 0, got {pixel_array_uint8.shape}. Attempting first channel.")
263
- image = Image.fromarray(pixel_array_uint8[:,:,0], mode='L').convert("RGB")
264
-
265
- elif planar_config == 1: # Color-by-plane (RRR... GGG... BBB...) - needs reshaping
266
- if pixel_array_uint8.shape[0] == 3: # Check if first dimension is 3 (planes)
267
- logger.debug("Reshaping 3D array (PlanarConfig=1) for RGB conversion.")
268
- # Reshape from (3, rows, cols) to (rows, cols, 3)
269
- reshaped_array = np.transpose(pixel_array_uint8, (1, 2, 0))
270
- image = Image.fromarray(reshaped_array, mode='RGB')
271
- logger.debug("Converted 3D array (SamplesPerPixel=3, PlanarConfig=1) to RGB PIL Image.")
272
- else:
273
- logger.warning(f"Expected shape[0]=3 for Planar Config 1, got {pixel_array_uint8.shape}. Attempting first plane.")
274
- image = Image.fromarray(pixel_array_uint8[0,:,:], mode='L').convert("RGB")
275
- else: # Fallback if PlanarConfiguration is invalid or missing
276
- logger.warning(f"Unexpected Planar Configuration ({planar_config}). Assuming color-by-pixel if last dim is 3.")
277
- if pixel_array_uint8.shape[-1] == 3:
278
- image = Image.fromarray(pixel_array_uint8, mode='RGB')
279
- else: # Fallback to first channel/slice
280
- logger.warning("Falling back to first channel/slice as grayscale.")
281
- image = Image.fromarray(pixel_array_uint8[:,:,0] if pixel_array_uint8.shape[-1] != 3 else pixel_array_uint8[0,:,:], mode='L').convert("RGB")
282
-
283
- elif samples_per_pixel == 1 and pixel_array_uint8.ndim == 3: # Likely multi-frame grayscale
284
- logger.info("Detected 3D array with SamplesPerPixel=1. Displaying first frame.")
285
- image = Image.fromarray(pixel_array_uint8[0,:,:], mode='L').convert("RGB") # Display first frame
286
- else: # Unknown 3D format
287
- logger.warning(f"Unsupported 3D array format (Samples={samples_per_pixel}, PI='{photometric_interpretation}', ndim={pixel_array_uint8.ndim}). Attempting first slice/channel.")
288
- # Try slicing based on likely dimension order
289
  try:
290
- if pixel_array_uint8.shape[0] > 1 and pixel_array_uint8.shape[0] < 5: # Likely planes first
291
- image = Image.fromarray(pixel_array_uint8[0,:,:], mode='L').convert("RGB")
292
- elif pixel_array_uint8.shape[-1] > 1 and pixel_array_uint8.shape[-1] < 5: # Likely channels last
293
- image = Image.fromarray(pixel_array_uint8[:,:,0], mode='L').convert("RGB")
294
- else: # Default guess: first slice/frame
295
- image = Image.fromarray(pixel_array_uint8[0,:,:], mode='L').convert("RGB")
296
- except IndexError:
297
- logger.error("Could not extract a 2D slice from the 3D array for display.")
298
- return None
299
-
300
-
301
- else: # Unsupported dimensions
302
- logger.error(f"Cannot convert to image: Unsupported pixel array dimensions ({pixel_array_uint8.ndim})")
303
- st.warning("Failed to process DICOM image data due to unsupported array dimensions.")
304
  return None
305
 
306
- logger.info(f"Successfully converted DICOM to PIL Image (RGB format, size: {image.size}).")
307
  return image
308
 
309
  except AttributeError as e:
310
- # Often happens if ds is None or essential tags missing after force=True parsing
311
- logger.error(f"AttributeError during image conversion, likely missing DICOM tag: {e}", exc_info=False)
312
- st.warning(f"Failed to process image data: Required DICOM information missing ({e}).")
313
- return None
314
  except Exception as e:
315
- logger.error(f"Unexpected error converting DICOM pixel data to image: {e}", exc_info=True)
316
- st.warning(f"An unexpected error occurred while processing DICOM image data.")
317
  return None
318
 
319
  # --- Window/Level Helper ---
320
 
321
  def get_default_wl(ds: pydicom.Dataset) -> Tuple[Optional[float], Optional[float]]:
322
  """
323
- Safely retrieves default Window Center (Level) and Width from DICOM tags.
324
-
325
- Handles missing tags, multi-value entries (takes first), and non-numeric values.
326
-
327
  Args:
328
  ds: The pydicom Dataset object.
329
-
330
  Returns:
331
- A tuple containing (WindowCenter, WindowWidth), both Optional[float].
332
- Returns (None, None) if values are not found or invalid.
333
  """
334
  wc_val = ds.get("WindowCenter", None)
335
  ww_val = ds.get("WindowWidth", None)
336
  wc: Optional[float] = None
337
  ww: Optional[float] = None
338
 
339
- # Extract first value if multi-valued
340
  if isinstance(wc_val, pydicom.multival.MultiValue):
341
  wc_val = wc_val[0] if len(wc_val) > 0 else None
342
  if isinstance(ww_val, pydicom.multival.MultiValue):
343
  ww_val = ww_val[0] if len(ww_val) > 0 else None
344
 
345
- # Convert safely to float
346
  if wc_val is not None:
347
  try:
348
  wc = float(wc_val)
349
  except (ValueError, TypeError):
350
- logger.debug(f"Could not convert default WindowCenter ('{wc_val}') to float.")
351
- wc = None # Invalid format
352
  if ww_val is not None:
353
- try:
354
  ww = float(ww_val)
355
- # Basic sanity check for width
356
  if ww <= 0:
357
- logger.debug(f"Invalid default WindowWidth found ({ww}), ignoring.")
358
- ww = None
359
- except (ValueError, TypeError):
360
- logger.debug(f"Could not convert default WindowWidth ('{ww_val}') to float.")
361
- ww = None # Invalid format
362
 
363
  if wc is not None and ww is not None:
364
- logger.debug(f"Found default WC/WW in DICOM tags: {wc} / {ww}")
365
- return wc, ww
366
  else:
367
- logger.debug("Default WC/WW not found or invalid in DICOM tags.")
368
- return None, None
 
2
  import pydicom.errors
3
  from pydicom.pixel_data_handlers.util import apply_voi_lut
4
  import numpy as np
5
+ from PIL import Image, ImageDraw
6
  import io
7
  import logging
8
  import streamlit as st
9
  from typing import Optional, Tuple, Dict, Any, List, Union
10
 
11
+ # Configure logger (assumed to be set up globally in your app)
12
  logger = logging.getLogger(__name__)
13
 
14
+ # --- Helper Function to Filter PHI from Metadata ---
15
+ def filter_sensitive_metadata(metadata: Dict[str, Any]) -> Dict[str, Any]:
16
+ """
17
+ Filters out keys known to contain Protected Health Information (PHI)
18
+ from the metadata dictionary.
19
+
20
+ Args:
21
+ metadata: Dictionary of metadata tags.
22
+
23
+ Returns:
24
+ Filtered dictionary with PHI removed.
25
+ """
26
+ # List of keys that might contain PHI (adjust as needed)
27
+ phi_keys = {"PatientName", "PatientID", "PatientBirthDate", "PatientSex", "PatientAddress"}
28
+ return {k: v for k, v in metadata.items() if k not in phi_keys}
29
+
30
  # --- DICOM Parsing ---
31
 
32
+ @st.cache_data(max_entries=10, show_spinner=False)
33
+ def parse_dicom(dicom_bytes: bytes, filename: str = "Uploaded File", require_pixeldata: bool = True) -> Optional[pydicom.Dataset]:
34
  """
35
  Parses DICOM file bytes into a pydicom Dataset object.
36
+
37
  Args:
38
  dicom_bytes: The raw bytes of the DICOM file.
39
  filename: The original filename for logging/error messages.
40
+ require_pixeldata: If True, treat missing PixelData as a fatal error.
41
+
42
  Returns:
43
  A pydicom Dataset object if successful, otherwise None.
44
  """
45
  logger.info(f"Attempting to parse DICOM data from '{filename}' ({len(dicom_bytes)} bytes)...")
46
  try:
 
 
 
47
  ds = pydicom.dcmread(io.BytesIO(dicom_bytes), force=True)
48
  logger.info(f"Successfully parsed DICOM data from '{filename}'. SOP Class: {ds.SOPClassUID.name if 'SOPClassUID' in ds else 'Unknown'}")
49
+ if require_pixeldata and 'PixelData' not in ds:
50
+ logger.error(f"DICOM file '{filename}' is missing PixelData tag.")
51
+ st.error(f"Error: '{filename}' does not contain image data (PixelData tag missing).")
52
+ return None
 
 
 
53
  return ds
54
  except pydicom.errors.InvalidDicomError as e:
55
+ logger.error(f"Invalid DICOM data encountered in '{filename}': {e}", exc_info=False)
56
  st.error(f"Error parsing '{filename}': The file is not a valid DICOM file or is corrupted. Details: {e}")
57
  return None
58
  except Exception as e:
 
62
 
63
  # --- DICOM Metadata Extraction ---
64
 
65
+ @st.cache_data(max_entries=10, show_spinner=False)
66
+ def extract_dicom_metadata(ds: pydicom.Dataset, filter_phi: bool = True) -> Dict[str, Any]:
67
  """
68
  Extracts a predefined set of technical DICOM metadata tags.
69
+
70
  Args:
71
  ds: The pydicom Dataset object.
72
+ filter_phi: If True, filter out keys that may contain PHI.
73
+
74
  Returns:
75
  A dictionary containing the values of the extracted tags.
 
 
 
76
  """
77
+ logger.debug(f"Extracting technical metadata for SOP Instance UID: {ds.SOPInstanceUID if 'SOPInstanceUID' in ds else 'Unknown'}")
78
  metadata = {}
 
 
79
  tags_to_extract = {
 
80
  "Modality": (0x0008, 0x0060),
81
  "StudyDescription": (0x0008, 0x1030),
82
  "SeriesDescription": (0x0008, 0x103E),
 
95
  "BitsAllocated": (0x0028, 0x0100),
96
  "BitsStored": (0x0028, 0x0101),
97
  "HighBit": (0x0028, 0x0102),
98
+ "PixelRepresentation": (0x0028, 0x0103),
99
  "SamplesPerPixel": (0x0028, 0x0002),
100
  }
101
+
102
  for name, tag_address in tags_to_extract.items():
103
  try:
104
  element = ds[tag_address]
105
  value = element.value
 
106
  if value is None or value == "":
107
  metadata[name] = "N/A"
108
  continue
109
 
 
110
  if isinstance(value, pydicom.uid.UID):
111
+ display_value = value.name
112
+ elif isinstance(value, list):
113
+ display_value = ", ".join(map(str, value))
114
+ elif isinstance(value, pydicom.valuerep.DSfloat):
115
+ display_value = float(value)
116
+ elif isinstance(value, pydicom.valuerep.IS):
117
+ display_value = int(value)
 
118
  else:
119
+ display_value = value
120
 
121
  metadata[name] = display_value
122
 
 
124
  logger.debug(f"Metadata tag {name} ({tag_address}) not found in dataset.")
125
  metadata[name] = "Not Found"
126
  except Exception as e:
127
+ logger.warning(f"Could not read metadata tag {name} ({tag_address}): {e}", exc_info=False)
 
128
  metadata[name] = "Error Reading"
129
+
130
+ logger.debug(f"Extracted {len(metadata)} metadata tags.")
131
+ if filter_phi:
132
+ metadata = filter_sensitive_metadata(metadata)
133
  return metadata
134
 
135
  # --- DICOM Image Conversion ---
136
 
137
+ @st.cache_data(max_entries=20, show_spinner="Processing DICOM image...")
138
  def dicom_to_image(
139
  ds: pydicom.Dataset,
140
  window_center: Optional[Union[float, List[float]]] = None,
 
142
  ) -> Optional[Image.Image]:
143
  """
144
  Converts DICOM pixel data to a displayable PIL Image (RGB), applying VOI LUT.
145
+
 
 
 
146
  Args:
147
  ds: The pydicom Dataset object containing PixelData.
148
+ window_center: Window Center value(s) for VOI LUT (first used if list).
149
+ window_width: Window Width value(s) for VOI LUT (first used if list).
150
+
151
  Returns:
152
  A PIL Image object in RGB format, or None if processing fails.
153
  """
154
  if 'PixelData' not in ds:
155
+ logger.error("Cannot convert to image: Missing PixelData tag.")
 
156
  return None
157
 
158
+ logger.debug(f"Converting DICOM to image. Photometric Interpretation: {ds.get('PhotometricInterpretation', 'N/A')}")
159
+
160
  try:
161
+ pixel_array = ds.pixel_array
162
 
 
163
  wc_to_use: Optional[float] = None
164
  ww_to_use: Optional[float] = None
165
+
 
166
  if window_center is not None and window_width is not None:
167
+ wc_in = window_center[0] if isinstance(window_center, list) and window_center else window_center
168
+ ww_in = window_width[0] if isinstance(window_width, list) and window_width else window_width
169
+ try:
170
+ wc_to_use = float(wc_in) if wc_in is not None else None
171
+ ww_to_use = float(ww_in) if ww_in is not None else None
172
+ if ww_to_use is not None and ww_to_use <= 0:
173
+ logger.warning(f"Provided Window Width ({ww_to_use}) is invalid. Ignoring.")
174
+ ww_to_use = None
175
+ else:
176
+ logger.info(f"Using provided WC/WW: {wc_to_use} / {ww_to_use}")
177
+ except (ValueError, TypeError):
178
+ logger.warning(f"Conversion error for provided WC/WW values ('{wc_in}', '{ww_in}'). Ignoring.")
179
+ wc_to_use = None
180
+ ww_to_use = None
 
181
 
 
182
  if wc_to_use is None or ww_to_use is None:
183
+ default_wc, default_ww = get_default_wl(ds)
184
  if default_wc is not None and default_ww is not None:
185
+ wc_to_use = default_wc
186
+ ww_to_use = default_ww
187
+ logger.info(f"Using default WC/WW: {wc_to_use} / {ww_to_use}")
188
 
 
189
  if wc_to_use is not None and ww_to_use is not None:
190
  logger.debug(f"Applying VOI LUT with WC={wc_to_use}, WW={ww_to_use}")
 
 
191
  processed_array = apply_voi_lut(pixel_array, ds, window=ww_to_use, level=wc_to_use)
 
192
  min_val, max_val = processed_array.min(), processed_array.max()
193
  if max_val > min_val:
 
194
  pixel_array_scaled = ((processed_array - min_val) / (max_val - min_val + 1e-6)) * 255.0
195
  else:
196
  pixel_array_scaled = np.zeros_like(processed_array)
197
  pixel_array_uint8 = pixel_array_scaled.astype(np.uint8)
198
  logger.debug("VOI LUT applied and scaled to uint8.")
199
+ else:
200
+ logger.info("No valid WC/WW provided. Applying basic min/max scaling.")
 
 
201
  if 'RescaleSlope' in ds and 'RescaleIntercept' in ds:
202
  try:
203
  slope = float(ds.RescaleSlope)
204
  intercept = float(ds.RescaleIntercept)
205
  if slope != 1.0 or intercept != 0.0:
206
  logger.debug(f"Applying Rescale Slope ({slope}) and Intercept ({intercept})")
 
207
  pixel_array = pixel_array.astype(np.float64) * slope + intercept
 
 
208
  except Exception as rescale_err:
209
+ logger.warning(f"Rescale Slope/Intercept error: {rescale_err}")
 
 
210
  min_val, max_val = pixel_array.min(), pixel_array.max()
211
  if max_val > min_val:
212
+ scaled_array = ((pixel_array - min_val) / (max_val - min_val + 1e-6)) * 255.0
213
+ else:
214
+ scaled_array = np.zeros_like(pixel_array)
 
215
  pixel_array_uint8 = scaled_array.astype(np.uint8)
216
+ logger.debug("Basic scaling applied and converted to uint8.")
 
217
 
 
218
  photometric_interpretation = ds.get("PhotometricInterpretation", "").upper()
219
+ logger.debug(f"Array shape: {pixel_array_uint8.shape}, dtype: {pixel_array_uint8.dtype}")
220
 
221
+ if pixel_array_uint8.ndim == 2:
 
222
  if photometric_interpretation in ("MONOCHROME1", "MONOCHROME2"):
223
  image = Image.fromarray(pixel_array_uint8, mode='L').convert("RGB")
224
+ logger.debug("Converted 2D grayscale array to RGB.")
225
+ else:
226
+ logger.warning(f"Unknown 2D Photometric Interpretation '{photometric_interpretation}'. Using MONOCHROME2 assumption.")
227
+ image = Image.fromarray(pixel_array_uint8, mode='L').convert("RGB")
228
+ elif pixel_array_uint8.ndim == 3:
 
 
 
229
  samples_per_pixel = ds.get("SamplesPerPixel", 1)
 
230
  if samples_per_pixel == 3 and photometric_interpretation in ("RGB", "YBR_FULL", "YBR_FULL_422"):
231
+ planar_config = ds.get("PlanarConfiguration", 0)
232
+ if planar_config == 0:
233
+ if pixel_array_uint8.shape[-1] == 3:
234
+ image = Image.fromarray(pixel_array_uint8, mode='RGB')
235
+ logger.debug("Converted 3D array (PlanarConfig=0) to RGB.")
236
+ else:
237
+ logger.warning(f"Unexpected shape for PlanarConfig=0: {pixel_array_uint8.shape}. Using first channel.")
238
+ image = Image.fromarray(pixel_array_uint8[:,:,0], mode='L').convert("RGB")
239
+ elif planar_config == 1:
240
+ if pixel_array_uint8.shape[0] == 3:
241
+ logger.debug("Reshaping 3D array (PlanarConfig=1) for RGB conversion.")
242
+ reshaped_array = np.transpose(pixel_array_uint8, (1, 2, 0))
243
+ image = Image.fromarray(reshaped_array, mode='RGB')
244
+ else:
245
+ logger.warning(f"Unexpected shape for PlanarConfig=1: {pixel_array_uint8.shape}. Using first plane.")
246
+ image = Image.fromarray(pixel_array_uint8[0,:,:], mode='L').convert("RGB")
247
+ else:
248
+ logger.warning(f"Unexpected Planar Configuration ({planar_config}). Assuming color-by-pixel.")
249
+ if pixel_array_uint8.shape[-1] == 3:
250
+ image = Image.fromarray(pixel_array_uint8, mode='RGB')
251
+ else:
252
+ image = Image.fromarray(pixel_array_uint8[:,:,0], mode='L').convert("RGB")
253
+ elif samples_per_pixel == 1:
254
+ # Multi-frame grayscale: if more than one frame, take the first one.
255
+ if pixel_array_uint8.shape[0] > 1:
256
+ logger.info("Detected multi-frame grayscale. Displaying first frame.")
257
+ image = Image.fromarray(pixel_array_uint8[0,:,:], mode='L').convert("RGB")
258
+ else:
259
+ image = Image.fromarray(pixel_array_uint8, mode='L').convert("RGB")
260
+ else:
261
+ logger.warning(f"Unsupported 3D array format: shape {pixel_array_uint8.shape}, SamplesPerPixel={samples_per_pixel}.")
 
 
 
262
  try:
263
+ if pixel_array_uint8.ndim == 3 and pixel_array_uint8.shape[0] > 1:
264
+ image = Image.fromarray(pixel_array_uint8[0,:,:], mode='L').convert("RGB")
265
+ else:
266
+ image = Image.fromarray(pixel_array_uint8[:,:,0], mode='L').convert("RGB")
267
+ except Exception as e:
268
+ logger.error("Error extracting 2D slice from 3D array.")
269
+ return None
270
+ elif pixel_array_uint8.ndim == 4:
271
+ logger.error(f"Unsupported 4D array dimensions: {pixel_array_uint8.shape}")
272
+ st.warning("Failed to process DICOM image: 4D data is not supported.")
273
+ return None
274
+ else:
275
+ logger.error(f"Unsupported array dimensions: {pixel_array_uint8.ndim}")
276
+ st.warning("Failed to process DICOM image due to unsupported array dimensions.")
277
  return None
278
 
279
+ logger.info(f"Successfully converted DICOM to PIL Image (RGB, size: {image.size}).")
280
  return image
281
 
282
  except AttributeError as e:
283
+ logger.error(f"AttributeError during image conversion: {e}", exc_info=False)
284
+ st.warning(f"Failed to process image data: Required DICOM tag missing ({e}).")
285
+ return None
 
286
  except Exception as e:
287
+ logger.error(f"Unexpected error converting DICOM to image: {e}", exc_info=True)
288
+ st.warning("An unexpected error occurred while processing DICOM image data.")
289
  return None
290
 
291
  # --- Window/Level Helper ---
292
 
293
  def get_default_wl(ds: pydicom.Dataset) -> Tuple[Optional[float], Optional[float]]:
294
  """
295
+ Retrieves default Window Center and Width from DICOM tags.
296
+
 
 
297
  Args:
298
  ds: The pydicom Dataset object.
299
+
300
  Returns:
301
+ A tuple (WindowCenter, WindowWidth) or (None, None) if not found.
 
302
  """
303
  wc_val = ds.get("WindowCenter", None)
304
  ww_val = ds.get("WindowWidth", None)
305
  wc: Optional[float] = None
306
  ww: Optional[float] = None
307
 
 
308
  if isinstance(wc_val, pydicom.multival.MultiValue):
309
  wc_val = wc_val[0] if len(wc_val) > 0 else None
310
  if isinstance(ww_val, pydicom.multival.MultiValue):
311
  ww_val = ww_val[0] if len(ww_val) > 0 else None
312
 
 
313
  if wc_val is not None:
314
  try:
315
  wc = float(wc_val)
316
  except (ValueError, TypeError):
317
+ logger.debug(f"Could not convert WindowCenter '{wc_val}' to float.")
318
+ wc = None
319
  if ww_val is not None:
320
+ try:
321
  ww = float(ww_val)
 
322
  if ww <= 0:
323
+ logger.debug(f"Invalid WindowWidth ({ww}).")
324
+ ww = None
325
+ except (ValueError, TypeError):
326
+ logger.debug(f"Could not convert WindowWidth '{ww_val}' to float.")
327
+ ww = None
328
 
329
  if wc is not None and ww is not None:
330
+ logger.debug(f"Found default WC/WW: {wc} / {ww}")
331
+ return wc, ww
332
  else:
333
+ logger.debug("Default WC/WW not found or invalid.")
334
+ return None, None