Enable thresholding of OCR results (#1001)

This commit is contained in:
Nile Wilson
2023-01-17 14:34:30 -05:00
committed by GitHub
parent 1d07b03866
commit bce777bb72
14 changed files with 229 additions and 67 deletions

View File

@@ -7,9 +7,11 @@ All notable changes to this project will be documented in this file.
#### Image redactor
* Added evaluation code for the DICOM image redaction capabilities
* Modified `ImagePiiVerifyEngine` to allow passing of kwargs
* Updated all image redactor engines and OCR classes to allow passing of an OCR confidence threshold and other OCR parameters
#### General
* Updated documentation to include instructions on using DICOM evaluation code
* Updated documentation to mention OCR thresholding
## [2.2.31] - 14.12.2022
### Changed

View File

@@ -111,13 +111,13 @@ Let's say we ran the above code block and see the following for `ocr_results_for
// OCR Results (formatted)
[
{
"left": 25,
"top": 25,
"width": 241,
"height": 37,
"conf": 95.833916,
"label": "DAVIDSON"
},
"left": 25,
"top": 25,
"width": 241,
"height": 37,
"conf": 95.833916,
"label": "DAVIDSON"
},
{
"left": 287,
"top": 25,
@@ -194,6 +194,8 @@ instance = pydicom.dcmread(file_of_interest)
_, eval_results = dicom_engine.eval_dicom_instance(instance, ground_truth)
```
You can also set optional arguments to see the effect of padding width, ground-truth matching tolerance, and OCR confidence threshold (e.g., `ocr_kwargs={"ocr_threshold": 50}`).
For a full demonstration, please see the [evaluation notebook](../samples/python/example_dicom_redactor_evaluation.ipynb).
*Return to the [Table of Contents](#table-of-contents)*

View File

@@ -139,7 +139,8 @@ Python script example can be found under:
engine.redact_from_file(input_path, output_dir, padding_width=25, fill="contrast")
# Option 3: Redact from directory
engine.redact_from_directory("path/to/your/dicom", output_dir, padding_width=25, fill="contrast")
ocr_kwargs = {"ocr_threshold": 50}
engine.redact_from_directory("path/to/your/dicom", output_dir, fill="background", ocr_kwargs=ocr_kwargs)
```
### Evaluating de-identification performance

View File

@@ -111,13 +111,13 @@ Let's say we ran the above code block and see the following for `ocr_results_for
// OCR Results (formatted)
[
{
"left": 25,
"top": 25,
"width": 241,
"height": 37,
"conf": 95.833916,
"label": "DAVIDSON"
},
"left": 25,
"top": 25,
"width": 241,
"height": 37,
"conf": 95.833916,
"label": "DAVIDSON"
},
{
"left": 287,
"top": 25,
@@ -194,6 +194,8 @@ instance = pydicom.dcmread(file_of_interest)
_, eval_results = dicom_engine.eval_dicom_instance(instance, ground_truth)
```
You can also set optional arguments to see the effect of padding width, ground-truth matching tolerance, and OCR confidence threshold (e.g., `ocr_kwargs={"ocr_threshold": 50}`).
For a full demonstration, please see the [evaluation notebook](../docs/samples/python/example_dicom_redactor_evaluation.ipynb).
*Return to the [Table of Contents](#table-of-contents)*

View File

@@ -144,7 +144,8 @@ redacted_dicom_image = engine.redact(dicom_image, fill="contrast")
engine.redact_from_file(input_path, output_dir, padding_width=25, fill="contrast")
# Option 3: Redact from directory
engine.redact_from_directory("path/to/your/dicom", output_dir, padding_width=25, fill="contrast")
ocr_kwargs = {"ocr_threshold": 50}
engine.redact_from_directory("path/to/your/dicom", output_dir, fill="background", ocr_kwargs=ocr_kwargs)
```
See the example notebook for more details and visual confirmation of the output: [docs/samples/python/example_dicom_image_redactor.ipynb](../docs/samples/python/example_dicom_image_redactor.ipynb).

View File

@@ -44,16 +44,20 @@ class DicomImagePiiVerifyEngine(ImagePiiVerifyEngine, DicomImageRedactorEngine):
def verify_dicom_instance(
self,
instance: pydicom.dataset.FileDataset,
padding_width: Optional[int] = 25,
display_image: Optional[bool] = True,
**kwargs,
padding_width: int = 25,
display_image: bool = True,
ocr_kwargs: Optional[dict] = None,
**text_analyzer_kwargs,
) -> Tuple[Optional[PIL.Image.Image], dict, list]:
"""Verify PII on a single DICOM instance.
:param instance: Loaded DICOM instance including pixel data and metadata.
:param padding_width: Padding width to use when running OCR.
:param display_image: If the verificationimage is displayed and returned.
:param kwargs: Additional values for the analyze method in ImageAnalyzerEngine.
:param ocr_kwargs: Additional params for OCR methods.
:param text_analyzer_kwargs: Additional values for the analyze method
in ImageAnalyzerEngine.
:return: Image with boxes identifying PHI, OCR results,
and analyzer results.
"""
@@ -78,12 +82,17 @@ class DicomImagePiiVerifyEngine(ImagePiiVerifyEngine, DicomImageRedactorEngine):
)
ocr_results = self.ocr_engine.perform_ocr(image)
analyzer_results = self.image_analyzer_engine.analyze(
image, ad_hoc_recognizers=[deny_list_recognizer], **kwargs
image,
ad_hoc_recognizers=[deny_list_recognizer],
ocr_kwargs=ocr_kwargs,
**text_analyzer_kwargs,
)
# Get image with verification boxes
verify_image = (
self.verify(image, ad_hoc_recognizers=[deny_list_recognizer], **kwargs)
self.verify(
image, ad_hoc_recognizers=[deny_list_recognizer], **text_analyzer_kwargs
)
if display_image
else None
)
@@ -94,10 +103,11 @@ class DicomImagePiiVerifyEngine(ImagePiiVerifyEngine, DicomImageRedactorEngine):
self,
instance: pydicom.dataset.FileDataset,
ground_truth: dict,
padding_width: Optional[int] = 25,
tolerance: Optional[int] = 50,
display_image: Optional[bool] = False,
**kwargs,
padding_width: int = 25,
tolerance: int = 50,
display_image: bool = False,
ocr_kwargs: Optional[dict] = None,
**text_analyzer_kwargs,
) -> Tuple[Optional[PIL.Image.Image], dict]:
"""Evaluate performance for a single DICOM instance.
@@ -106,12 +116,19 @@ class DicomImagePiiVerifyEngine(ImagePiiVerifyEngine, DicomImageRedactorEngine):
:param padding_width: Padding width to use when running OCR.
:param tolerance: Pixel distance tolerance for matching to ground truth.
:param display_image: If the verificationimage is displayed and returned.
:param kwargs: Additional values for the analyze method in ImageAnalyzerEngine.
:param ocr_kwargs: Additional params for OCR methods.
:param text_analyzer_kwargs: Additional values for the analyze method
in ImageAnalyzerEngine.
:return: Evaluation comparing redactor engine results vs ground truth.
"""
# Verify detected PHI
verify_image, ocr_results, analyzer_results = self.verify_dicom_instance(
instance, padding_width, display_image, **kwargs
instance,
padding_width,
display_image,
ocr_kwargs=ocr_kwargs,
**text_analyzer_kwargs,
)
formatted_ocr_results = self._get_bboxes_from_ocr_results(ocr_results)
detected_phi = self._get_bboxes_from_analyzer_results(analyzer_results)

View File

@@ -10,7 +10,7 @@ import PIL
import png
import numpy as np
from matplotlib import pyplot as plt # necessary import for PIL typing # noqa: F401
from typing import Tuple, List, Union
from typing import Tuple, List, Union, Optional
from presidio_image_redactor import ImageRedactorEngine
from presidio_image_redactor import ImageAnalyzerEngine # noqa: F401
@@ -29,7 +29,8 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
fill: str = "contrast",
padding_width: int = 25,
crop_ratio: float = 0.75,
**kwargs,
ocr_kwargs: Optional[dict] = None,
**text_analyzer_kwargs,
):
"""Redact method to redact the given DICOM image.
@@ -41,7 +42,9 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
:param padding_width: Padding width to use when running OCR.
:param crop_ratio: Portion of image to consider when selecting
most common pixel value as the background color value.
:param kwargs: Additional values for the analyze method in AnalyzerEngine
:param ocr_kwargs: Additional params for OCR methods.
:param text_analyzer_kwargs: Additional values for the analyze method
in AnalyzerEngine.
:return: DICOM instance with redacted pixel data.
"""
@@ -65,7 +68,10 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
supported_entity="PERSON", deny_list=phi_list
)
analyzer_results = self.image_analyzer_engine.analyze(
image, ad_hoc_recognizers=[deny_list_recognizer], **kwargs
image,
ad_hoc_recognizers=[deny_list_recognizer],
ocr_kwargs=ocr_kwargs,
**text_analyzer_kwargs,
)
# Redact all bounding boxes from DICOM file
@@ -81,7 +87,8 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
padding_width: int = 25,
crop_ratio: float = 0.75,
fill: str = "contrast",
**kwargs,
ocr_kwargs: Optional[dict] = None,
**text_analyzer_kwargs,
) -> None:
"""Redact method to redact from a given file.
@@ -93,7 +100,9 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
:param padding_width : Padding width to use when running OCR.
:param fill: Color setting to use for redaction box
("contrast" or "background").
:param kwargs: Additional values for the analyze method in AnalyzerEngine
:param ocr_kwargs: Additional params for OCR methods.
:param text_analyzer_kwargs: Additional values for the analyze method
in AnalyzerEngine.
"""
# Verify the given paths
if Path(input_dicom_path).is_dir() is True:
@@ -116,7 +125,8 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
padding_width=padding_width,
overwrite=True,
dst_parent_dir=".",
**kwargs,
ocr_kwargs=ocr_kwargs,
**text_analyzer_kwargs,
)
print(f"Output written to {output_location}")
@@ -130,7 +140,8 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
padding_width: int = 25,
crop_ratio: float = 0.75,
fill: str = "contrast",
**kwargs,
ocr_kwargs: Optional[dict] = None,
**text_analyzer_kwargs,
) -> None:
"""Redact method to redact from a directory of files.
@@ -144,7 +155,9 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
most common pixel value as the background color value.
:param fill: Color setting to use for redaction box
("contrast" or "background").
:param kwargs: Additional values for the analyze method in AnalyzerEngine
:param ocr_kwargs: Additional params for OCR methods.
:param text_analyzer_kwargs: Additional values for the analyze method
in AnalyzerEngine.
"""
# Verify the given paths
if Path(input_dicom_path).is_dir() is False:
@@ -167,7 +180,8 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
padding_width=padding_width,
overwrite=True,
dst_parent_dir=".",
**kwargs,
ocr_kwargs=ocr_kwargs,
**text_analyzer_kwargs,
)
print(f"Output written to {output_location}")
@@ -758,7 +772,8 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
padding_width: int,
overwrite: bool,
dst_parent_dir: str,
**kwargs,
ocr_kwargs: Optional[dict] = None,
**text_analyzer_kwargs,
) -> str:
"""Redact text PHI present on a DICOM image.
@@ -771,7 +786,9 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
:param overwrite: Only set to True if you are providing the
duplicated DICOM path in dcm_path.
:param dst_parent_dir: String path to parent directory of where to store copies.
:param kwargs: Additional values for the analyze method in AnalyzerEngine
:param ocr_kwargs: Additional params for OCR methods.
:param text_analyzer_kwargs: Additional values for the analyze method
in AnalyzerEngine.
:return: Path to the output DICOM file.
"""
@@ -805,7 +822,10 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
supported_entity="PERSON", deny_list=phi_list
)
analyzer_results = self.image_analyzer_engine.analyze(
image, ad_hoc_recognizers=[deny_list_recognizer], **kwargs
image,
ad_hoc_recognizers=[deny_list_recognizer],
ocr_kwargs=ocr_kwargs,
**text_analyzer_kwargs,
)
# Redact all bounding boxes from DICOM file
@@ -825,7 +845,8 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
padding_width: int,
overwrite: bool,
dst_parent_dir: str,
**kwargs,
ocr_kwargs: Optional[dict] = None,
**text_analyzer_kwargs,
) -> str:
"""Redact text PHI present on all DICOM images in a directory.
@@ -838,7 +859,9 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
:param overwrite: Only set to True if you are providing
the duplicated DICOM dir in dcm_dir.
:param dst_parent_dir: String path to parent directory of where to store copies.
:param kwargs: Additional values for the analyze method in AnalyzerEngine
:param ocr_kwargs: Additional params for OCR methods.
:param text_analyzer_kwargs: Additional values for the analyze method
in AnalyzerEngine.
Return:
dst_dir (str): Path to the output DICOM directory.
@@ -865,7 +888,8 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
padding_width,
overwrite,
dst_parent_dir,
**kwargs,
ocr_kwargs=ocr_kwargs,
**text_analyzer_kwargs,
)
return dst_dir

View File

@@ -1,4 +1,4 @@
from typing import List
from typing import List, Tuple, Optional
from presidio_analyzer import AnalyzerEngine, RecognizerResult
@@ -14,7 +14,11 @@ class ImageAnalyzerEngine:
:param ocr: the OCR object to be used to detect text in images.
"""
def __init__(self, analyzer_engine: AnalyzerEngine = None, ocr: OCR = None):
def __init__(
self,
analyzer_engine: Optional[AnalyzerEngine] = None,
ocr: Optional[OCR] = None,
):
if not analyzer_engine:
analyzer_engine = AnalyzerEngine()
self.analyzer_engine = analyzer_engine
@@ -23,25 +27,62 @@ class ImageAnalyzerEngine:
ocr = TesseractOCR()
self.ocr = ocr
def analyze(self, image: object, **kwargs) -> List[ImageRecognizerResult]:
def analyze(
self, image: object, ocr_kwargs: Optional[dict] = None, **text_analyzer_kwargs
) -> List[ImageRecognizerResult]:
"""Analyse method to analyse the given image.
:param image: PIL Image/numpy array or file path(str) to be processed
:param kwargs: Additional values for the analyze method in AnalyzerEngine
:param image: PIL Image/numpy array or file path(str) to be processed.
:param ocr_kwargs: Additional params for OCR methods.
:param text_analyzer_kwargs: Additional values for the analyze method
in AnalyzerEngine.
:return: list of the extract entities with image bounding boxes
:return: List of the extract entities with image bounding boxes.
"""
ocr_result = self.ocr.perform_ocr(image)
# Perform OCR
perform_ocr_kwargs, ocr_threshold = self._parse_ocr_kwargs(ocr_kwargs)
ocr_result = self.ocr.perform_ocr(image, **perform_ocr_kwargs)
# Apply OCR confidence threshold if it is passed in
if ocr_threshold:
ocr_result = self.threshold_ocr_result(ocr_result, ocr_threshold)
# Analyze text
text = self.ocr.get_text_from_ocr_dict(ocr_result)
analyzer_result = self.analyzer_engine.analyze(
text=text, language="en", **kwargs
text=text, language="en", **text_analyzer_kwargs
)
bboxes = self.map_analyzer_results_to_bounding_boxes(
analyzer_result, ocr_result, text
)
return bboxes
@staticmethod
def threshold_ocr_result(ocr_result: dict, ocr_threshold: float) -> dict:
"""Filter out OCR results below confidence threshold.
:param ocr_result: OCR results (raw).
:param ocr_threshold: Threshold value between -1 and 100.
:return: OCR results with low confidence items removed.
"""
if ocr_threshold < -1 or ocr_threshold > 100:
raise ValueError("ocr_threshold must be between -1 and 100")
# Get indices of items above threshold
idx = list()
for i, val in enumerate(ocr_result["conf"]):
if float(val) >= ocr_threshold:
idx.append(i)
# Only retain high confidence items
filtered_ocr_result = {}
for key in list(ocr_result.keys()):
filtered_ocr_result[key] = [ocr_result[key][i] for i in idx]
return filtered_ocr_result
@staticmethod
def map_analyzer_results_to_bounding_boxes(
text_analyzer_results: List[RecognizerResult], ocr_result: dict, text: str
@@ -116,3 +157,25 @@ class ImageAnalyzerEngine:
pos += len(word) + 1
return bboxes
@staticmethod
def _parse_ocr_kwargs(ocr_kwargs: dict) -> Tuple[dict, float]:
"""Parse the OCR-related kwargs.
:param ocr_kwargs: Parameters for OCR operations.
:return: Params for ocr.perform_ocr and ocr_threshold
"""
ocr_threshold = None
if ocr_kwargs is not None:
if "ocr_threshold" in ocr_kwargs:
ocr_threshold = ocr_kwargs["ocr_threshold"]
ocr_kwargs = {
key: value
for key, value in ocr_kwargs.items()
if key != "ocr_threshold"
}
else:
ocr_kwargs = {}
return ocr_kwargs, ocr_threshold

View File

@@ -24,20 +24,27 @@ class ImagePiiVerifyEngine:
image_analyzer_engine = ImageAnalyzerEngine()
self.image_analyzer_engine = image_analyzer_engine
def verify(self, image: Image, **kwargs) -> Image:
def verify(
self, image: Image, ocr_kwargs: Optional[dict] = None, **text_analyzer_kwargs
) -> Image:
"""Annotate image with the detect PII entity.
Please notice, this method duplicates the image, creates a
new instance and manipulate it.
:param image: PIL Image to be processed
:param kwargs: Additional values for the analyze method in ImageAnalyzerEngine.
:param image: PIL Image to be processed.
:param ocr_kwargs: Additional params for OCR methods.
:param text_analyzer_kwargs: Additional values for the analyze method
in ImageAnalyzerEngine.
:return: the annotated image
"""
image = ImageChops.duplicate(image)
image_x, image_y = image.size
bboxes = self.image_analyzer_engine.analyze(image, **kwargs)
bboxes = self.image_analyzer_engine.analyze(
image, ocr_kwargs, **text_analyzer_kwargs
)
fig, ax = plt.subplots()
image_r = 70
fig.set_size_inches(image_x / image_r, image_y / image_r)

View File

@@ -1,4 +1,4 @@
from typing import Union, Tuple
from typing import Union, Tuple, Optional
from PIL import Image, ImageDraw, ImageChops
@@ -18,25 +18,31 @@ class ImageRedactorEngine:
self.image_analyzer_engine = image_analyzer_engine
def redact(
self, image: Image,
self,
image: Image,
fill: Union[int, Tuple[int, int, int]] = (0, 0, 0),
**kwargs,
ocr_kwargs: Optional[dict] = None,
**text_analyzer_kwargs,
) -> Image:
"""Redact method to redact the given image.
Please notice, this method duplicates the image, creates a new instance and
manipulate it.
:param image: PIL Image to be processed
:param image: PIL Image to be processed.
:param fill: colour to fill the shape - int (0-255) for
grayscale or Tuple(R, G, B) for RGB
:param kwargs: Additional values for the analyze method in AnalyzerEngine
grayscale or Tuple(R, G, B) for RGB.
:param ocr_kwargs: Additional params for OCR methods.
:param text_analyzer_kwargs: Additional values for the analyze method
in AnalyzerEngine.
:return: the redacted image
"""
image = ImageChops.duplicate(image)
bboxes = self.image_analyzer_engine.analyze(image, **kwargs)
bboxes = self.image_analyzer_engine.analyze(
image, ocr_kwargs, **text_analyzer_kwargs
)
draw = ImageDraw.Draw(image)
for box in bboxes:

View File

@@ -5,10 +5,11 @@ class OCR(ABC):
"""OCR class that performs OCR on a given image."""
@abstractmethod
def perform_ocr(self, image: object) -> dict:
def perform_ocr(self, image: object, **kwargs) -> dict:
"""Perform OCR on a given image.
:param image: PIL Image/numpy array or file path(str) to be processed
:param kwargs: Additional values for perform OCR method
:return: results dictionary containing bboxes and text for each detected word
"""

View File

@@ -6,12 +6,13 @@ from presidio_image_redactor import OCR
class TesseractOCR(OCR):
"""OCR class that performs OCR on a given image."""
def perform_ocr(self, image: object) -> dict:
def perform_ocr(self, image: object, **kwargs) -> dict:
"""Perform OCR on a given image.
:param image: PIL Image/numpy array or file path(str) to be processed
:param kwargs: Additional values for OCR image_to_data
:return: results dictionary containing bboxes and text for each detected word
"""
output_type = pytesseract.Output.DICT
return pytesseract.image_to_data(image, output_type=output_type)
return pytesseract.image_to_data(image, output_type=output_type, **kwargs)

View File

@@ -90,7 +90,7 @@ def test_get_all_dcm_files_happy_path(
print(expected_list)
# Assert
assert test_files == expected_list
assert set(test_files) == set(expected_list)
# ------------------------------------------------------

View File

@@ -113,3 +113,38 @@ def test_given_dif_len_entities_then_map_analyzer_returns_correct_outputand_len(
assert len(expected_result) == len(mapped_entities)
assert expected_result == mapped_entities
@pytest.mark.parametrize(
"ocr_threshold, expected_length",
[(-1, 9), (50, 7), (80, 2), (100, 0)],
)
def test_threshold_ocr_result_returns_expected_results(
image_analyzer_engine, ocr_threshold, expected_length
):
# Assign
ocr_result = {}
ocr_result["text"] = [
"",
"Homey",
"Interiors",
"was",
"created",
"by",
"Katie",
"",
"Cromley.",
]
ocr_result["left"] = [143, 143, 322, 530, 634, 827, 896, 141, 141]
ocr_result["top"] = [64, 67, 67, 76, 64, 64, 64, 134, 134]
ocr_result["width"] = [936, 160, 191, 87, 172, 51, 183, 801, 190]
ocr_result["height"] = [50, 47, 37, 28, 40, 50, 40, 50, 50]
ocr_result["conf"] = [-1, 99.5, 92.3, 42.7, 66.1, 51.2, 79.7, 64.0, 70.3]
# Act
test_filtered = image_analyzer_engine.threshold_ocr_result(
ocr_result, ocr_threshold
)
# Assert
assert len(test_filtered["conf"]) == expected_length