Skip to content

Commit

Permalink
grayscale remote phi removal implemented, easyocr models added to assets
Browse files Browse the repository at this point in the history
  • Loading branch information
mdevans committed Oct 8, 2024
1 parent b0468c7 commit d5a8ea4
Show file tree
Hide file tree
Showing 7 changed files with 125 additions and 80 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"autoReload": {
"enable": true
},
"justMyCode": false,
"justMyCode": true,
},
{
"name": "Anonymizer GUI Optimized 1",
Expand Down
Binary file added src/assets/ocr/model/craft_mlt_25k.pth
Binary file not shown.
Binary file added src/assets/ocr/model/english_g2.pth
Binary file not shown.
Binary file added src/assets/ocr/model/latin_g2.pth
Binary file not shown.
32 changes: 26 additions & 6 deletions src/controller/anonymizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@
from utils.storage import DICOM_FILE_SUFFIX
from model.project import DICOMNode, ProjectModel
from model.anonymizer import AnonymizerModel

# from .remove_pixel_phi import process_grayscale_image
import torch
from easyocr import Reader
from .remove_pixel_phi import remove_pixel_phi

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -624,6 +625,25 @@ def _anonymizer_pixel_phi_worker(self, px_Q: Queue) -> None:

logger.info(f"thread={threading.current_thread().name} start")

# Once-off initialisation of easyocr.Reader (and underlying pytorch model):
# if pytorch models not downloaded yet, they will be when Reader initializes
model_dir = Path("assets") / "ocr" / "model" # Default is: Path("~/.EasyOCR/model").expanduser()
if not model_dir.exists():
logger.warning(
f"EasyOCR model directory: {model_dir}, does not exist, EasyOCR will create it, models still to be downloaded..."
)
else:
logger.info(f"EasyOCR downloaded models: {os.listdir(model_dir)}")

# Initialize the EasyOCR reader with the desired language(s)
ocr_reader = Reader(lang_list=["en", "de", "fr", "es"], model_storage_directory=model_dir, verbose=False)

logging.info("OCR Reader initialised successfully")

# Check if GPU available
logger.info(f"Apple MPS (Metal) GPU Available: {torch.backends.mps.is_available()}")
logger.info(f"CUDA GPU Available: {torch.cuda.is_available()}")

while True:
time.sleep(self.WORKER_THREAD_SLEEP_SECS)

Expand All @@ -633,11 +653,11 @@ def _anonymizer_pixel_phi_worker(self, px_Q: Queue) -> None:
break

logger.info(f"Remove Pixel PHI from: {path}")
result = 0
for i in range(1000000):
result += i * (i % 3)

logger.info("*PHI GONE*")
try:
remove_pixel_phi(path, ocr_reader)
except Exception as e:
logger.error(repr(e))

px_Q.task_done()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# For Burnt-IN Pixel PHI Removal:
import os
import time
from pathlib import Path
import logging

import numpy as np
Expand All @@ -16,17 +17,66 @@
BORDER_CONSTANT,
INPAINT_TELEA,
)

from easyocr import Reader
from pydicom import Dataset
from pydicom import Dataset, dcmread
from pydicom.pixel_data_handlers.util import apply_voi_lut, apply_modality_lut
from pydicom.encaps import encapsulate
from pydicom.uid import JPEG2000Lossless
from openjpeg.utils import encode_array # JPG2000Lossless

grayscale_spaces = ["MONOCHROME1", "MONOCHROME2"]
color_spaces = [
"RGB",
"RGBA",
"YBR_FULL",
"YBR_FULL_422",
"YBR_ICT",
"YBR_RCT",
"PALETTE COLOR",
]
downscale_dimension_threshold = 1000
border_size = 30

logger = logging.getLogger(__name__)


def draw_text_contours_on_mask(self, image, top_left, bottom_right, mask: ndarray) -> None:
def remove_pixel_phi(dcm_path: Path, ocr_reader: Reader) -> None:
"""
Removes the PHI in the pixel data of a DICOM file with 1...N frames
If the incoming ds pixel array was compressed, burnt-in annotation is detected and pixel_array modified
then the pixel_array is re-compressed with JPG2000Lossless compression and the ds transfer syntax changed accordingly
Args:
dcm_path (Path): path to source DICOM file [*Mutable*] TODO: immutable & keep source file?
Raises:
InvalidDicomError: if dcm_path not a valid DICOM file
TypeError: if dcm_path is none or unsupported type
ValueError:
- If any essential pixel attribute is missing or invalid.
- If group 2 elements are in dataset rather than dataset.file_meta, or if a preamble is given but is not 128 bytes long,
or if Transfer Syntax is a compressed type and pixel data is not compressed.
AttributeError If either ds.is_implicit_VR or ds.is_little_endian have not been set.
"""
# Read the DICOM image file using pydicom which will perform any decompression required
ds = dcmread(dcm_path)

# Process Grayscale and Color Images with different functions:
pi = ds.get("PhotometricInterpretation", None)
if not pi:
raise ValueError("PhotometricInterpretation attribute missing.")
if pi in grayscale_spaces:
_process_grayscale_image(ds, ocr_reader)
else:
if pi not in color_spaces:
raise ValueError(f"Invalid Photometric Interpretation: {pi}. Expected one of {color_spaces}.")
raise NotImplementedError(f"Remove PHI from Color Images not implemented")

ds.save_as(dcm_path)


def _draw_text_contours_on_mask(image, top_left, bottom_right, mask: ndarray) -> None:
"""
Draws text contours onto the provided mask (mutates the input mask).
Expand Down Expand Up @@ -58,7 +108,7 @@ def draw_text_contours_on_mask(self, image, top_left, bottom_right, mask: ndarra
drawContours(mask, contours, -1, (255, 255, 255), thickness=-1)


def has_voi_lut(self, ds: Dataset) -> bool:
def _has_voi_lut(ds: Dataset) -> bool:
# Check for VOILUTSequence
if "VOILUTSequence" in ds:
return True
Expand All @@ -68,25 +118,26 @@ def has_voi_lut(self, ds: Dataset) -> bool:
return False


def process_grayscale_image(self, ds: Dataset, nlp, reader: Reader):
# TODO: split this process up into sub-alogirthms for pluggable functional pipeline:
# after pixel attribute validation: [apply LUT > normalize > add border > downscaling > OCR > upscaling > inpainting > compression]
def _process_grayscale_image(ds: Dataset, ocr: Reader):
"""
Processes a grayscale image by validating pixel-related attributes, performing necessary checks, and applying OCR processing.
Args:
source_ds (Dataset): The DICOM dataset of the source image. [*Mutable*]
nlp: The Named Entity Recognition (NER) object for text analysis.
reader (Reader): The OCR Reader object for text extraction.
ds (Dataset): pydicom Dataset [*Mutable*]
Raises:
ValueError: If any essential pixel attribute is missing or invalid.
ValueError: If any essential pixel attribute in ds is missing or invalid.
Returns:
None
If successful the source_ds dataset pixel_array will be modified
If the incoming source_ds pixel array was compressed, burnt-in annotation is detected and pixel_array modified
then the pixel_array is re-compressed with JPG2000Lossless compression and the source_ds transfer syntax changed accordingly
If successful the ds dataset pixel_array will be modified via inpainting to remove all detected text
"""
logger.info(f"Processing Grayscale Image PtID/SOPInstanceUID: {ds.PatientID}/{ds.SOPInstanceUID}")
logger.info(
f"Processing Grayscale Image, Modality: {ds.Modality} PtID/SOPInstanceUID: {ds.PatientID}/{ds.SOPInstanceUID}"
)
# Extract relevant attributes for pixel data processing:
# Mandatory:
pi = ds.get("PhotometricInterpretation", None)
Expand Down Expand Up @@ -176,19 +227,19 @@ def process_grayscale_image(self, ds: Dataset, nlp, reader: Reader):
)

# DICOM grayscale image is now validated
logging.info(f"Header and pixel array valid, now processing...")
logger.info(f"Transfer Syntax: {ds.file_meta.TransferSyntaxUID}")
logger.info(f"Compressed: {ds.file_meta.TransferSyntaxUID.is_compressed}")
logger.info(f"PhotometricInterpretation: {pi}")
logger.info(f"SamplePerPixel: {samples_per_pixel}")
logger.info(f"Rows={rows} Columns={cols}")
logger.info(
logger.debug(f"Header and pixel array valid, now processing...")
logger.debug(f"Transfer Syntax: {ds.file_meta.TransferSyntaxUID}")
logger.debug(f"Compressed: {ds.file_meta.TransferSyntaxUID.is_compressed}")
logger.debug(f"PhotometricInterpretation: {pi}")
logger.debug(f"SamplePerPixel: {samples_per_pixel}")
logger.debug(f"Rows={rows} Columns={cols}")
logger.debug(
f"BitsAllocated={bits_allocated} BitStored={bits_stored} HighBit={high_bit} Signed={pixel_representation!=0}"
)
logger.info(f"pixels.shape: {pixels.shape}")
logger.info(f"pixels.value.range:[{pixels.min(), pixels.max()}]")
logger.info(f"Pixel Spacing: {pixel_spacing}")
logger.info(f"Number of Frames: {no_of_frames}")
logger.debug(f"pixels.shape: {pixels.shape}")
logger.debug(f"pixels.value.range:[{pixels.min()}..{pixels.max()}]")
logger.debug(f"Pixel Spacing: {pixel_spacing}")
logger.debug(f"Number of Frames: {no_of_frames}")

# *BEGIN PROCESSING*:
# GET REDCACTION CONTOURS TO APPLY TO SOURCE PIXELS
Expand All @@ -208,13 +259,11 @@ def process_grayscale_image(self, ds: Dataset, nlp, reader: Reader):
# To improve OCR processing speed:
# TODO: Work out more precisely using readable text size, pixel spacing (not always present), mask blur kernel size & inpainting radius
# Downscale the image if its width exceeds the width_threshold
width_threshold = 1200
scale_factor = 1
downscale = cols > width_threshold
if downscale:
scale_factor = width_threshold / cols

border_size = 40 # pixels
if cols > downscale_dimension_threshold:
scale_factor = downscale_dimension_threshold / cols
elif rows > downscale_dimension_threshold:
scale_factor = downscale_dimension_threshold / rows

source_pixels_deid_stack = []

Expand All @@ -228,24 +277,25 @@ def process_grayscale_image(self, ds: Dataset, nlp, reader: Reader):
# Apply Modality LUT if present
if "ModalityLUTSequence" in ds:
pixels = apply_modality_lut(pixels, ds)
logger.info(f"Applied Modality LUT: new pixels.value.range:[{pixels.min(), pixels.max()}]")
elif self.has_voi_lut(ds):
logger.debug(f"Applied Modality LUT: new pixels.value.range:[{int(pixels.min())}..{int(pixels.max())}]")
elif _has_voi_lut(ds):
# Apply Value of Interest Lookup (Windowing) if present:
# TODO: is this necessary?
pixels = apply_voi_lut(pixels, ds)
logger.info(f"Applied VOI LUT: new pixels.value.range:[{pixels.min(), pixels.max()}]")
logger.debug(f"Applied VOI LUT: new pixels.value.range:[{int(pixels.min())}..{int(pixels.max())}]")

# Normalize the pixel array to the range 0-255
normalize(src=pixels, dst=pixels, alpha=0, beta=255, norm_type=NORM_MINMAX, dtype=-1, mask=None)
pixels = pixels.astype(np.uint8)
logger.info(f"After normalization: pixels.value.range:[{pixels.min(), pixels.max()}]")
logger.debug(f"After normalization: pixels.value.range:[{pixels.min()}..{pixels.max()}]")

if downscale:
new_size = (width_threshold, int(rows * scale_factor))
# DOWNSCALE if necessary:
if scale_factor < 1:
new_size = (int(cols * scale_factor), int(rows * scale_factor))
pixels = resize(pixels, new_size, interpolation=INTER_LINEAR)
logger.info(f"Downscaled image, new pixels.shape: {pixels.shape}")
logger.debug(f"Downscaled image with scaling factor={scale_factor:.2f}, new pixels.shape: {pixels.shape}")
else:
logger.info(f"Image width < {width_threshold}, no downscaling required.")
logger.debug(f"Max Image Dimension < {downscale_dimension_threshold}, no downscaling required.")

# Add a border to the resized image
pixels = copyMakeBorder(
Expand All @@ -257,11 +307,13 @@ def process_grayscale_image(self, ds: Dataset, nlp, reader: Reader):
BORDER_CONSTANT,
value=[0, 0, 0], # Black border
)
logger.info(f"Black Border of {border_size}px added, new pixels.shape: {pixels.shape}")
logger.debug(f"Black Border of {border_size}px added, new pixels.shape: {pixels.shape}")

# Perform OCR on the bordered image
# for word-level detection: width_ths=0.1, paragraph=False
results = reader.readtext(pixels, add_margin=0.0, rotation_info=[90]) # , 180, 270])
results = ocr.readtext(
pixels, add_margin=0.0
) # rotation_info=[90]) # , 180, 270]) #TODO: add to global config?

if not results:
logger.info("No text found in frame")
Expand All @@ -272,9 +324,6 @@ def process_grayscale_image(self, ds: Dataset, nlp, reader: Reader):
# Create a mask for in-painting
mask = np.zeros(pixels.shape[:2], dtype=np.uint8)

# Intermediate Images for debugging/verification
# text_detect_image = pixels.copy()

# Draw bounding boxes around each detected word
for bbox, text, prob in results:

Expand All @@ -284,44 +333,19 @@ def process_grayscale_image(self, ds: Dataset, nlp, reader: Reader):
top_left = tuple(map(int, top_left))
bottom_right = tuple(map(int, bottom_right))

# If nlp absent, peform full anonymization, remove all detected text from image:
if nlp is None:
self.draw_text_contours_on_mask(pixels, top_left, bottom_right, mask)
continue

# Perform Named Entity Recognition using the supplied spacy nlp object:
# 1. Spacy
doc = nlp(text) # TODO: Fuzzy match, EntityRuler et.al.
entities = [ent.label_ for ent in doc.ents]

# Add rectangle to mask if any target entities detect in text:
if any(entity in entities for entity in ["PERSON", "DATE", "GPE", "LOC"]):
# logger.info(f"spacer ner entities {entities} detected in {text}")
self.draw_text_contours_on_mask(pixels, top_left, bottom_right, mask)
continue

# 2. MRN Check:
if self.mrn_probability(text) > 0.4:
# logger.info(f"MRN probable in {text}")
self.draw_text_contours_on_mask(pixels, top_left, bottom_right, mask)
continue

# 3. DATE Check:
if self.date_probability(text) > 0.4:
# logger.info(f"DATE probable in {text}")
self.draw_text_contours_on_mask(pixels, top_left, bottom_right, mask)
continue
# Peform full anonymization, remove all detected text from image:
_draw_text_contours_on_mask(pixels, top_left, bottom_right, mask)

# CHANGE SOURCE PIXELS:

# Remove border from mask
mask = mask[border_size:-border_size, border_size : mask.shape[1] - border_size]
logger.info(f"Remove Border from mask, new mask.shape: {mask.shape}")
logger.debug(f"Remove Border from mask, new mask.shape: {mask.shape}")

# Upscale mask if downscaling was applied to source image:
if scale_factor < 1:
mask = resize(src=mask, dsize=(cols, rows), interpolation=INTER_LINEAR)
logger.info(f"Upscale mask back to original source image size, new mask.shape: {mask.shape}")
logger.debug(f"Upscale mask back to original source image size, new mask.shape: {mask.shape}")

kernel = np.ones((3, 3), np.uint8)
dilated_mask = dilate(src=mask, kernel=kernel, iterations=1)
Expand All @@ -336,7 +360,7 @@ def process_grayscale_image(self, ds: Dataset, nlp, reader: Reader):

# if source pixels were compressed then re-compress to JPG2000Lossless:
if ds.file_meta.TransferSyntaxUID.is_compressed:
logger.info("Re-compress deidentified source frame")
logger.debug("Re-compress deidentified source frame")
source_pixels_deid = encode_array(arr=source_pixels_deid, photometric_interpretation=2, use_mct=False)

source_pixels_deid_stack.append(source_pixels_deid)
Expand Down
3 changes: 2 additions & 1 deletion src/view/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ def _wait_for_aws(self):

def update_anonymizer_queues(self, ds_Q_size: int, px_Q_size: int):
self._meta_qsize.configure(text=f"{ds_Q_size}")
self._pixel_qsize.configure(text=f"{px_Q_size}")
if hasattr(self, "_pixel_qsize"):
self._pixel_qsize.configure(text=f"{px_Q_size}")

def update_totals(self, totals: Totals):
self._patients_label.configure(text=f"{totals.patients}")
Expand Down

0 comments on commit d5a8ea4

Please sign in to comment.