Consolidating general bounding box operations (#1011)

This commit is contained in:
Nile Wilson
2023-01-24 13:56:10 -05:00
committed by GitHub
parent 11dfa64d63
commit 5512a39ce7
12 changed files with 633 additions and 577 deletions

View File

@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
* 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
* Moved general bounding box operations to new class `BboxProcessor`
#### General
* Updated documentation to include instructions on using DICOM evaluation code

View File

@@ -75,6 +75,9 @@ The `DicomImagePiiVerifyEngine` class can be used to assist in ground truth labe
import pydicom
from presidio_image_redactor import DicomImagePiiVerifyEngine
# Initialize engine
dicom_engine = DicomImagePiiVerifyEngine()
# Choose your file to create ground truth for
filename = "path/to/your/file.dcm"
instance = pydicom.dcmread(filename)
@@ -84,8 +87,8 @@ padding_width = 25
verification_image, ocr_results, analyzer_results = dicom_engine.verify_dicom_instance(instance, padding_width)
# Format results for more direct comparison
ocr_results_formatted = dicom_engine._get_bboxes_from_ocr_results(ocr_results)
analyzer_results_formatted = dicom_engine._get_bboxes_from_analyzer_results(analyzer_results)
ocr_results_formatted = dicom_engine.bbox_processor.get_bboxes_from_ocr_results(ocr_results)
analyzer_results_formatted = dicom_engine.bbox_processor.get_bboxes_from_analyzer_results(analyzer_results)
```
By looking at the output of `verify_dicom_instance`, we can create a ground truth labels json.

View File

@@ -75,6 +75,9 @@ The `DicomImagePiiVerifyEngine` class can be used to assist in ground truth labe
import pydicom
from presidio_image_redactor import DicomImagePiiVerifyEngine
# Initialize engine
dicom_engine = DicomImagePiiVerifyEngine()
# Choose your file to create ground truth for
filename = "path/to/your/file.dcm"
instance = pydicom.dcmread(filename)
@@ -84,8 +87,8 @@ padding_width = 25
verification_image, ocr_results, analyzer_results = dicom_engine.verify_dicom_instance(instance, padding_width)
# Format results for more direct comparison
ocr_results_formatted = dicom_engine._get_bboxes_from_ocr_results(ocr_results)
analyzer_results_formatted = dicom_engine._get_bboxes_from_analyzer_results(analyzer_results)
ocr_results_formatted = dicom_engine.bbox_processor.get_bboxes_from_ocr_results(ocr_results)
analyzer_results_formatted = dicom_engine.bbox_processor.get_bboxes_from_analyzer_results(analyzer_results)
```
By looking at the output of `verify_dicom_instance`, we can create a ground truth labels json.

View File

@@ -3,6 +3,7 @@ import logging
from .ocr import OCR
from .tesseract_ocr import TesseractOCR
from .bbox import BboxProcessor
from .image_analyzer_engine import ImageAnalyzerEngine
from .image_redactor_engine import ImageRedactorEngine
from .image_pii_verify_engine import ImagePiiVerifyEngine
@@ -15,6 +16,7 @@ logging.getLogger("presidio-image-redactor").addHandler(logging.NullHandler())
__all__ = [
"OCR",
"TesseractOCR",
"BboxProcessor",
"ImageAnalyzerEngine",
"ImageRedactorEngine",
"ImagePiiVerifyEngine",

View File

@@ -0,0 +1,133 @@
from presidio_image_redactor.entities import ImageRecognizerResult
from typing import List, Tuple, Dict, Union
class BboxProcessor:
"""Common module for general bounding box operators."""
def get_bboxes_from_ocr_results(
self,
ocr_results: Dict[str, List[Union[int, str]]],
) -> List[Dict[str, Union[int, float, str]]]:
"""Get bounding boxes on padded image for all detected words from ocr_results.
:param ocr_results: Raw results from OCR.
:return: Bounding box information per word.
"""
bboxes = []
for i in range(len(ocr_results["text"])):
detected_text = ocr_results["text"][i]
if detected_text:
bbox = {
"left": ocr_results["left"][i],
"top": ocr_results["top"][i],
"width": ocr_results["width"][i],
"height": ocr_results["height"][i],
"conf": float(ocr_results["conf"][i]),
"label": detected_text,
}
bboxes.append(bbox)
return bboxes
def get_bboxes_from_analyzer_results(
self,
analyzer_results: List[ImageRecognizerResult],
) -> List[Dict[str, Union[str, float, int]]]:
"""Organize bounding box info from analyzer results.
:param analyzer_results: Results from using ImageAnalyzerEngine.
:return: Bounding box info organized.
"""
bboxes = []
for i in range(len(analyzer_results)):
result = analyzer_results[i].to_dict()
bbox_item = {
"entity_type": result["entity_type"],
"score": result["score"],
"left": result["left"],
"top": result["top"],
"width": result["width"],
"height": result["height"],
}
bboxes.append(bbox_item)
return bboxes
def remove_bbox_padding(
self,
analyzer_bboxes: List[Dict[str, Union[str, float, int]]],
padding_width: int,
) -> List[Dict[str, int]]:
"""Remove added padding in bounding box coordinates.
:param analyzer_bboxes: The bounding boxes from analyzer results.
:param padding_width: Pixel width used for padding (0 if no padding).
:return: Bounding box information per word.
"""
if padding_width < 0:
raise ValueError("Padding width must be a non-negative integer.")
# Remove padding from all bounding boxes
bboxes = [
{
"top": max(0, bbox["top"] - padding_width),
"left": max(0, bbox["left"] - padding_width),
"width": bbox["width"],
"height": bbox["height"],
}
for bbox in analyzer_bboxes
]
return bboxes
def match_with_source(
self,
all_pos: List[Dict[str, Union[str, int, float]]],
pii_source_dict: List[Dict[str, Union[str, int, float]]],
detected_pii: Dict[str, Union[str, float, int]],
tolerance: int = 50,
) -> Tuple[List[Dict[str, Union[str, int, float]]], bool]:
"""Match returned redacted PII bbox data with some source of truth for PII.
:param all_pos: Dictionary storing all positives.
:param pii_source_dict: List of PII labels for this instance.
:param detected_pii: Detected PII (single entity from analyzer_results).
:param tolerance: Tolerance for exact coordinates and size data.
:return: List of all positive with PII mapped back as possible
and whether a match was found.
"""
all_pos_match = all_pos.copy()
# Get info from detected PII (positive)
results_left = detected_pii["left"]
results_top = detected_pii["top"]
results_width = detected_pii["width"]
results_height = detected_pii["height"]
results_score = detected_pii["score"]
match_found = False
# See what in the ground truth this positive matches
for label in pii_source_dict:
source_left = label["left"]
source_top = label["top"]
source_width = label["width"]
source_height = label["height"]
match_left = abs(source_left - results_left) <= tolerance
match_top = abs(source_top - results_top) <= tolerance
match_width = abs(source_width - results_width) <= tolerance
match_height = abs(source_height - results_height) <= tolerance
matching = [match_left, match_top, match_width, match_height]
if False not in matching:
# If match is found, carry over ground truth info
positive = label
positive["score"] = results_score
all_pos_match.append(positive)
match_found = True
return all_pos_match, match_found

View File

@@ -10,7 +10,7 @@ from presidio_image_redactor import (
ImageAnalyzerEngine,
DicomImageRedactorEngine,
)
from presidio_image_redactor.image_pii_verify_engine import ImagePiiVerifyEngine
from presidio_image_redactor import ImagePiiVerifyEngine, BboxProcessor
from presidio_analyzer import PatternRecognizer
from typing import Tuple, List, Optional
@@ -41,6 +41,9 @@ class DicomImagePiiVerifyEngine(ImagePiiVerifyEngine, DicomImageRedactorEngine):
else:
self.image_analyzer_engine = image_analyzer_engine
# Initialize bbox processor
self.bbox_processor = BboxProcessor()
def verify_dicom_instance(
self,
instance: pydicom.dataset.FileDataset,
@@ -130,8 +133,12 @@ class DicomImagePiiVerifyEngine(ImagePiiVerifyEngine, DicomImageRedactorEngine):
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)
formatted_ocr_results = self.bbox_processor.get_bboxes_from_ocr_results(
ocr_results
)
detected_phi = self.bbox_processor.get_bboxes_from_analyzer_results(
analyzer_results
)
# Remove duplicate entities in results
detected_phi = self._remove_duplicate_entities(detected_phi)
@@ -154,31 +161,6 @@ class DicomImagePiiVerifyEngine(ImagePiiVerifyEngine, DicomImageRedactorEngine):
return verify_image, eval_results
@staticmethod
def _get_bboxes_from_ocr_results(ocr_results: dict) -> List[dict]:
"""Get bounding boxes on padded image for all detected words from ocr_results.
:param ocr_results: Raw results from OCR.
:return: Bounding box information per word.
"""
bboxes = []
item_count = 0
for i in range(len(ocr_results["text"])):
detected_text = ocr_results["text"][i]
if detected_text:
bbox = {
"left": ocr_results["left"][i],
"top": ocr_results["top"][i],
"width": ocr_results["width"][i],
"height": ocr_results["height"][i],
"conf": float(ocr_results["conf"][i]),
"label": detected_text,
}
bboxes.append(bbox)
item_count += 1
return bboxes
@staticmethod
def _remove_duplicate_entities(
results: List[dict], dup_pix_tolerance: int = 5
@@ -216,55 +198,8 @@ class DicomImagePiiVerifyEngine(ImagePiiVerifyEngine, DicomImageRedactorEngine):
return results_no_dups
@staticmethod
def _match_with_source(
all_pos: List[dict],
phi_source_dict: List[dict],
detected_phi: dict,
tolerance: int = 50,
) -> Tuple[dict, bool]:
"""Match returned redacted PHI bbox data with some source of truth for PHI.
:param all_pos: Dictionary storing all positives.
:param phi_source_dict: List of PHI labels for this instance.
:param detected_phi: Detected PHI (single entity from analyzer_results).
:param tolerance: Tolerance for exact coordinates and size data.
:return: List of all positive with PHI mapped back as possible
and whether a match was found.
"""
# Get info from detected PHI (positive)
results_left = detected_phi["left"]
results_top = detected_phi["top"]
results_width = detected_phi["width"]
results_height = detected_phi["height"]
results_score = detected_phi["score"]
match_found = False
# See what in the ground truth this positive matches
for label in phi_source_dict:
source_left = label["left"]
source_top = label["top"]
source_width = label["width"]
source_height = label["height"]
match_left = abs(source_left - results_left) <= tolerance
match_top = abs(source_top - results_top) <= tolerance
match_width = abs(source_width - results_width) <= tolerance
match_height = abs(source_height - results_height) <= tolerance
matching = [match_left, match_top, match_width, match_height]
if False not in matching:
# If match is found, carry over ground truth info
positive = label
positive["score"] = results_score
all_pos.append(positive)
match_found = True
return all_pos, match_found
@classmethod
def _label_all_positives(
cls,
self,
gt_labels_dict: dict,
ocr_results: List[dict],
detected_phi: List[dict],
@@ -289,18 +224,18 @@ class DicomImagePiiVerifyEngine(ImagePiiVerifyEngine, DicomImageRedactorEngine):
for analyzer_result in detected_phi:
# See if there are any ground truth matches
all_pos, gt_match_found = cls._match_with_source(
all_pos, gt_match_found = self.bbox_processor.match_with_source(
all_pos, gt_labels_dict, analyzer_result, tolerance
)
# If not, check back with OCR
if not gt_match_found:
all_pos, _ = cls._match_with_source(
all_pos, _ = self.bbox_processor.match_with_source(
all_pos, ocr_results, analyzer_result, tolerance
)
# Remove any duplicates
all_pos = cls._remove_duplicate_entities(all_pos, 0)
all_pos = self._remove_duplicate_entities(all_pos, 0)
return all_pos

View File

@@ -75,7 +75,12 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
)
# Redact all bounding boxes from DICOM file
bboxes = self._format_bboxes(analyzer_results, padding_width)
analyzer_bboxes = self.bbox_processor.get_bboxes_from_analyzer_results(
analyzer_results
)
bboxes = self.bbox_processor.remove_bbox_padding(
analyzer_bboxes, padding_width
)
redacted_image = self._add_redact_box(instance, bboxes, crop_ratio, fill)
return redacted_image
@@ -634,58 +639,6 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
return phi_str_list
@staticmethod
def _get_bboxes_from_analyzer_results(analyzer_results: list) -> List[dict]:
"""Organize bounding box info from analyzer results.
:param analyzer_results: Results from using ImageAnalyzerEngine.
:return: Bounding box info organized.
"""
bboxes = []
for i in range(len(analyzer_results)):
result = analyzer_results[i].to_dict()
bbox_item = {
"entity_type": result["entity_type"],
"score": result["score"],
"left": result["left"],
"top": result["top"],
"width": result["width"],
"height": result["height"],
}
bboxes.append(bbox_item)
return bboxes
@classmethod
def _format_bboxes(cls, analyzer_results: list, padding_width: int) -> List[dict]:
"""Format the bounding boxes to write directly back to DICOM pixel data.
:param analyzer_results: The analyzer results.
:param padding_width: Pixel width used for padding (0 if no padding).
:return: Bounding box information per word.
"""
if padding_width < 0:
raise ValueError("Padding width must be a positive number.")
# Write bounding box info to json files for now
bboxes = cls._get_bboxes_from_analyzer_results(analyzer_results)
# remove padding from all bounding boxes
bboxes = [
{
"top": max(0, bbox["top"] - padding_width),
"left": max(0, bbox["left"] - padding_width),
"width": bbox["width"],
"height": bbox["height"],
}
for bbox in bboxes
]
return bboxes
@classmethod
def _set_bbox_color(
cls, instance: pydicom.dataset.FileDataset, fill: str
@@ -829,7 +782,12 @@ class DicomImageRedactorEngine(ImageRedactorEngine):
)
# Redact all bounding boxes from DICOM file
bboxes = self._format_bboxes(analyzer_results, padding_width)
analyzer_bboxes = self.bbox_processor.get_bboxes_from_analyzer_results(
analyzer_results
)
bboxes = self.bbox_processor.remove_bbox_padding(
analyzer_bboxes, padding_width
)
redacted_dicom_instance = self._add_redact_box(
instance, bboxes, crop_ratio, fill
)

View File

@@ -2,7 +2,7 @@ from typing import Union, Tuple, Optional
from PIL import Image, ImageDraw, ImageChops
from presidio_image_redactor import ImageAnalyzerEngine
from presidio_image_redactor import ImageAnalyzerEngine, BboxProcessor
class ImageRedactorEngine:
@@ -17,6 +17,8 @@ class ImageRedactorEngine:
else:
self.image_analyzer_engine = image_analyzer_engine
self.bbox_processor = BboxProcessor()
def redact(
self,
image: Image,

View File

@@ -7,9 +7,7 @@ the original parent ImagePiiVerifyEngine class.
import PIL
import pydicom
from presidio_image_redactor.dicom_image_pii_verify_engine import (
DicomImagePiiVerifyEngine,
)
from presidio_image_redactor import DicomImagePiiVerifyEngine, BboxProcessor
import pytest
PADDING_WIDTH = 25
@@ -39,6 +37,7 @@ def test_verify_correctly(
get_mock_dicom_verify_results (dict): Dictionary with loaded results.
"""
# Assign
bbox_processor = BboxProcessor()
expected_ocr_results = get_mock_dicom_verify_results["ocr_results_formatted"]
expected_analyzer_results = get_mock_dicom_verify_results["analyzer_results"]
expected_ocr_results_labels = []
@@ -51,10 +50,10 @@ def test_verify_correctly(
test_ocr_results,
test_analyzer_results,
) = mock_engine.verify_dicom_instance(get_mock_dicom_instance, PADDING_WIDTH)
test_ocr_results_formatted = mock_engine._get_bboxes_from_ocr_results(
test_ocr_results_formatted = bbox_processor.get_bboxes_from_ocr_results(
test_ocr_results
)
test_analyzer_results_formatted = mock_engine._get_bboxes_from_analyzer_results(
test_analyzer_results_formatted = bbox_processor.get_bboxes_from_analyzer_results(
test_analyzer_results
)

View File

@@ -0,0 +1,428 @@
"""Test suite for bbox.py"""
from presidio_image_redactor import BboxProcessor
from presidio_image_redactor.entities.image_recognizer_result import (
ImageRecognizerResult,
)
import pytest
from typing import List
@pytest.fixture(scope="module")
def mock_bbox_processor():
"""Instance of BboxProcessor"""
bbox_proc = BboxProcessor()
return bbox_proc
# ------------------------------------------------------
# BboxProcessor.get_bboxes_from_ocr_results()
# ------------------------------------------------------
@pytest.mark.parametrize(
"ocr_results_raw, expected_results",
[
(
{
"left": [123],
"top": [0],
"width": [100],
"height": [25],
"conf": ["1"],
"text": ["JOHN"],
},
[
{
"left": 123,
"top": 0,
"width": 100,
"height": 25,
"conf": 1,
"label": "JOHN",
}
],
),
(
{
"left": [123, 345],
"top": [0, 15],
"width": [100, 75],
"height": [25, 30],
"conf": ["1", "0.87"],
"text": ["JOHN", "DOE"],
},
[
{
"left": 123,
"top": 0,
"width": 100,
"height": 25,
"conf": 1,
"label": "JOHN",
},
{
"left": 345,
"top": 15,
"width": 75,
"height": 30,
"conf": 0.87,
"label": "DOE",
},
],
),
],
)
def test_get_bboxes_from_ocr_results_happy_path(
mock_bbox_processor: BboxProcessor,
ocr_results_raw: dict,
expected_results: list,
):
"""Test happy path for BboxProcessor.get_bboxes_from_ocr_results
Args:
mock_bbox_processor (BboxProcessor): Instantiated engine.
ocr_results_raw (dict): Raw OCR results.
expected_results (list): Formatted OCR results.
"""
# Act
test_bboxes = mock_bbox_processor.get_bboxes_from_ocr_results(ocr_results_raw)
# Assert
assert test_bboxes == expected_results
# ------------------------------------------------------
# BboxProcessor.get_bboxes_from_analyzer_results()
# ------------------------------------------------------
@pytest.mark.parametrize(
"analyzer_results, expected_bboxes",
[
(
[
ImageRecognizerResult(
entity_type="TYPE_1",
start=0,
end=0,
left=25,
top=25,
width=100,
height=100,
score=0.99,
),
ImageRecognizerResult(
entity_type="TYPE_2",
start=10,
end=10,
left=25,
top=49,
width=75,
height=51,
score=0.7,
),
ImageRecognizerResult(
entity_type="TYPE_3",
start=25,
end=35,
left=613,
top=26,
width=226,
height=35,
score=0.6,
),
],
[
{
"entity_type": "TYPE_1",
"score": 0.99,
"left": 25,
"top": 25,
"width": 100,
"height": 100,
},
{
"entity_type": "TYPE_2",
"score": 0.7,
"left": 25,
"top": 49,
"width": 75,
"height": 51,
},
{
"entity_type": "TYPE_3",
"score": 0.6,
"left": 613,
"top": 26,
"width": 226,
"height": 35,
},
],
),
],
)
def test_get_bboxes_from_analyzer_results_happy_path(
mock_bbox_processor: BboxProcessor,
analyzer_results: list,
expected_bboxes: list,
):
"""Test happy path for BboxProcessor.get_bboxes_from_analyzer_results
Args:
analyzer_results (list): Results from using ImageAnalyzerEngine.
expected_bboxes (list): Expected output bounding box list.
"""
# Arrange
# Act
test_bboxes = mock_bbox_processor.get_bboxes_from_analyzer_results(analyzer_results)
# Assert
assert test_bboxes == expected_bboxes
# ------------------------------------------------------
# BboxProcessor.remove_bbox_padding()
# ------------------------------------------------------
@pytest.mark.parametrize(
"mock_intermediate_bbox, padding_width, expected_bboxes",
[
(
[
{
"entity_type": "TYPE_1",
"score": 0.99,
"left": 10,
"top": 15,
"width": 100,
"height": 100,
},
{
"entity_type": "TYPE_2",
"score": 0.7,
"left": 25,
"top": 49,
"width": 75,
"height": 51,
},
{
"entity_type": "TYPE_3",
"score": 0.6,
"left": 613,
"top": 26,
"width": 226,
"height": 35,
},
],
25,
[
{"top": 0, "left": 0, "width": 100, "height": 100},
{"top": 24, "left": 0, "width": 75, "height": 51},
{"top": 1, "left": 588, "width": 226, "height": 35},
],
),
],
)
def test_remove_bbox_padding_happy_path(
mock_bbox_processor: BboxProcessor,
mock_intermediate_bbox: dict,
padding_width: int,
expected_bboxes: list,
):
"""Test happy path for BboxProcessor.remove_bbox_padding
Args:
mock_intermediate_bbox (dict): Value for mock of get_bboxes_from_analyzer_results.
padding_width (int): Pixel width used for padding.
expected_bboxes_dict (dict): Expected output bounding box dictionary.
"""
# Arrange
# Act
test_bboxes_dict = mock_bbox_processor.remove_bbox_padding(mock_intermediate_bbox, padding_width)
# Assert
assert test_bboxes_dict == expected_bboxes
@pytest.mark.parametrize(
"padding_width, expected_error_type",
[(-1, "ValueError"), (-200, "ValueError")],
)
def test_remove_bbox_padding_exceptions(
mock_bbox_processor: BboxProcessor, padding_width: int, expected_error_type: str
):
"""Test error handling of remove_bbox_padding
Args:
padding_width (int): Pixel width used for padding.
expected_error_type (str): Type of error we expect to be raised.
"""
with pytest.raises(Exception) as exc_info:
# Act
_ = mock_bbox_processor.remove_bbox_padding([], padding_width)
# Assert
assert expected_error_type == exc_info.typename
# ------------------------------------------------------
# DicomImagePiiVerifyEngine._match_with_source()
# ------------------------------------------------------
@pytest.mark.parametrize(
"source_labels, results, tolerance, expected_results, expected_match_found",
[
(
[
{
"label": "DAVIDSON",
"left": 25,
"top": 25,
"width": 241,
"height": 37,
},
{
"label": "DOUGLAS",
"left": 287,
"top": 25,
"width": 230,
"height": 36,
},
{
"label": "[M]",
"left": 535,
"top": 25,
"width": 60,
"height": 45,
},
],
{
"entity_type": "PERSON",
"score": 1.0,
"left": 287,
"top": 25,
"width": 230,
"height": 36,
},
50,
[
{
"label": "DOUGLAS",
"score": 1.0,
"left": 287,
"top": 25,
"width": 230,
"height": 36,
}
],
True,
),
(
[
{
"label": "DAVIDSON",
"left": 25,
"top": 25,
"width": 241,
"height": 37,
},
{
"label": "DOUGLAS",
"left": 287,
"top": 25,
"width": 230,
"height": 36,
},
{
"label": "[M]",
"left": 535,
"top": 25,
"width": 60,
"height": 45,
},
],
{
"entity_type": "PERSON",
"score": 1.0,
"left": 300,
"top": 15,
"width": 250,
"height": 40,
},
50,
[
{
"label": "DOUGLAS",
"score": 1.0,
"left": 287,
"top": 25,
"width": 230,
"height": 36,
}
],
True,
),
(
[
{
"label": "DAVIDSON",
"left": 25,
"top": 25,
"width": 241,
"height": 37,
},
{
"label": "DOUGLAS",
"left": 287,
"top": 25,
"width": 230,
"height": 36,
},
{
"label": "[M]",
"left": 535,
"top": 25,
"width": 60,
"height": 45,
},
],
{
"entity_type": "PERSON",
"score": 1.0,
"left": 99,
"top": 99,
"width": 99,
"height": 99,
},
10,
[],
False,
),
],
)
def test_match_with_source_happy_path(
mock_bbox_processor: BboxProcessor,
source_labels: List[dict],
results: dict,
tolerance: int,
expected_results: List[dict],
expected_match_found: bool,
):
"""Test happy path for BboxProcessor.match_with_source
Args:
mock_bbox_processor (BboxProcessor): Instantiated engine.
source_labels (dict): Ground truth labels for single instance.
results (dict): Detected PHI dictionary.
tolerance (int): Pixel difference tolerance for matching entities.
expected_results (dict): Expected output dictionary.
expected_match_found (bool): Expected match_found.
"""
# Assign
all_pos = []
# Act
test_all_pos, test_match_found = mock_bbox_processor.match_with_source(
all_pos, source_labels, results, tolerance
)
# Assert
assert test_all_pos == expected_results
assert test_match_found == expected_match_found

View File

@@ -2,9 +2,11 @@
"""
import pydicom
from presidio_image_redactor import TesseractOCR, ImageAnalyzerEngine
from presidio_image_redactor.dicom_image_pii_verify_engine import (
from presidio_image_redactor import (
TesseractOCR,
ImageAnalyzerEngine,
DicomImagePiiVerifyEngine,
BboxProcessor,
)
from typing import List
import pytest
@@ -145,11 +147,11 @@ def test_eval_dicom_instance_happy_path(
return_value=[None, None, None],
)
mock_get_ocr_bboxes = mocker.patch.object(
DicomImagePiiVerifyEngine, "_get_bboxes_from_ocr_results", return_value=None
BboxProcessor, "get_bboxes_from_ocr_results", return_value=None
)
mock_get_analyzer_bboxes = mocker.patch.object(
DicomImagePiiVerifyEngine,
"_get_bboxes_from_analyzer_results",
BboxProcessor,
"get_bboxes_from_analyzer_results",
return_value=None,
)
mock_remove_dups = mocker.patch.object(
@@ -181,80 +183,6 @@ def test_eval_dicom_instance_happy_path(
assert mock_recall.call_count == 1
# ------------------------------------------------------
# DicomImagePiiVerifyEngine._get_bboxes_from_ocr_results()
# ------------------------------------------------------
@pytest.mark.parametrize(
"ocr_results_raw, expected_results",
[
(
{
"left": [123],
"top": [0],
"width": [100],
"height": [25],
"conf": ["1"],
"text": ["JOHN"],
},
[
{
"left": 123,
"top": 0,
"width": 100,
"height": 25,
"conf": 1,
"label": "JOHN",
}
],
),
(
{
"left": [123, 345],
"top": [0, 15],
"width": [100, 75],
"height": [25, 30],
"conf": ["1", "0.87"],
"text": ["JOHN", "DOE"],
},
[
{
"left": 123,
"top": 0,
"width": 100,
"height": 25,
"conf": 1,
"label": "JOHN",
},
{
"left": 345,
"top": 15,
"width": 75,
"height": 30,
"conf": 0.87,
"label": "DOE",
},
],
),
],
)
def test_get_bboxes_from_ocr_results_happy_path(
mock_engine: DicomImagePiiVerifyEngine,
ocr_results_raw: dict,
expected_results: list,
):
"""Test happy path for DicomImagePiiVerifyEngine._get_bboxes_from_ocr_results
Args:
mock_engine (DicomImagePiiVerifyEngine): Instantiated engine.
ocr_results_raw (dict): Raw OCR results.
expected_results (list): Formatted OCR results.
"""
# Act
test_bboxes = mock_engine._get_bboxes_from_ocr_results(ocr_results_raw)
# Assert
assert test_bboxes == expected_results
# ------------------------------------------------------
# DicomImagePiiVerifyEngine._remove_duplicate_entities()
# ------------------------------------------------------
@@ -471,171 +399,6 @@ def test_remove_duplicate_entities_happy_path(
assert test_results_no_dups == expected_results
# ------------------------------------------------------
# DicomImagePiiVerifyEngine._match_with_source()
# ------------------------------------------------------
@pytest.mark.parametrize(
"source_labels, results, tolerance, expected_results, expected_match_found",
[
(
[
{
"label": "DAVIDSON",
"left": 25,
"top": 25,
"width": 241,
"height": 37,
},
{
"label": "DOUGLAS",
"left": 287,
"top": 25,
"width": 230,
"height": 36,
},
{
"label": "[M]",
"left": 535,
"top": 25,
"width": 60,
"height": 45,
},
],
{
"entity_type": "PERSON",
"score": 1.0,
"left": 287,
"top": 25,
"width": 230,
"height": 36,
},
50,
[
{
"label": "DOUGLAS",
"score": 1.0,
"left": 287,
"top": 25,
"width": 230,
"height": 36,
}
],
True,
),
(
[
{
"label": "DAVIDSON",
"left": 25,
"top": 25,
"width": 241,
"height": 37,
},
{
"label": "DOUGLAS",
"left": 287,
"top": 25,
"width": 230,
"height": 36,
},
{
"label": "[M]",
"left": 535,
"top": 25,
"width": 60,
"height": 45,
},
],
{
"entity_type": "PERSON",
"score": 1.0,
"left": 300,
"top": 15,
"width": 250,
"height": 40,
},
50,
[
{
"label": "DOUGLAS",
"score": 1.0,
"left": 287,
"top": 25,
"width": 230,
"height": 36,
}
],
True,
),
(
[
{
"label": "DAVIDSON",
"left": 25,
"top": 25,
"width": 241,
"height": 37,
},
{
"label": "DOUGLAS",
"left": 287,
"top": 25,
"width": 230,
"height": 36,
},
{
"label": "[M]",
"left": 535,
"top": 25,
"width": 60,
"height": 45,
},
],
{
"entity_type": "PERSON",
"score": 1.0,
"left": 99,
"top": 99,
"width": 99,
"height": 99,
},
10,
[],
False,
),
],
)
def test_match_with_source_happy_path(
mock_engine: DicomImagePiiVerifyEngine,
source_labels: List[dict],
results: dict,
tolerance: int,
expected_results: List[dict],
expected_match_found: bool,
):
"""Test happy path for DicomImagePiiVerifyEngine._match_with_source
Args:
mock_engine (DicomImagePiiVerifyEngine): Instantiated engine.
source_labels (dict): Ground truth labels for single instance.
results (dict): Detected PHI dictionary.
tolerance (int): Pixel difference tolerance for matching entities.
expected_results (dict): Expected output dictionary.
expected_match_found (bool): Expected match_found.
"""
# Assign
all_pos = []
# Act
test_all_pos, test_match_found = mock_engine._match_with_source(
all_pos, source_labels, results, tolerance
)
# Assert
assert test_all_pos == expected_results
assert test_match_found == expected_match_found
# ------------------------------------------------------
# DicomImagePiiVerifyEngine._label_all_positives()
# ------------------------------------------------------

View File

@@ -5,10 +5,7 @@ import os
import numpy as np
from PIL import Image
import pydicom
from presidio_image_redactor.dicom_image_redactor_engine import DicomImageRedactorEngine
from presidio_image_redactor.entities.image_recognizer_result import (
ImageRecognizerResult,
)
from presidio_image_redactor import DicomImageRedactorEngine
from typing import Union, Tuple
import pytest
@@ -828,185 +825,6 @@ def test_make_phi_list_happy_path(
assert set(test_phi_str_list) == set(expected_return_list)
# ------------------------------------------------------
# DicomImageRedactorEngine._get_bboxes_from_analyzer_results()
# ------------------------------------------------------
@pytest.mark.parametrize(
"analyzer_results, expected_bboxes",
[
(
[
ImageRecognizerResult(
entity_type="TYPE_1",
start=0,
end=0,
left=25,
top=25,
width=100,
height=100,
score=0.99,
),
ImageRecognizerResult(
entity_type="TYPE_2",
start=10,
end=10,
left=25,
top=49,
width=75,
height=51,
score=0.7,
),
ImageRecognizerResult(
entity_type="TYPE_3",
start=25,
end=35,
left=613,
top=26,
width=226,
height=35,
score=0.6,
),
],
[
{
"entity_type": "TYPE_1",
"score": 0.99,
"left": 25,
"top": 25,
"width": 100,
"height": 100,
},
{
"entity_type": "TYPE_2",
"score": 0.7,
"left": 25,
"top": 49,
"width": 75,
"height": 51,
},
{
"entity_type": "TYPE_3",
"score": 0.6,
"left": 613,
"top": 26,
"width": 226,
"height": 35,
},
],
),
],
)
def test_get_bboxes_from_analyzer_results_happy_path(
mock_engine: DicomImageRedactorEngine,
analyzer_results: list,
expected_bboxes: list,
):
"""Test happy path for DicomImageRedactorEngine._get_bboxes_from_analyzer_results
Args:
analyzer_results (list): Results from using ImageAnalyzerEngine.
expected_bboxes (list): Expected output bounding box list.
"""
# Arrange
# Act
test_bboxes = mock_engine._get_bboxes_from_analyzer_results(analyzer_results)
# Assert
assert test_bboxes == expected_bboxes
# ------------------------------------------------------
# DicomImageRedactorEngine._format_bboxes()
# ------------------------------------------------------
@pytest.mark.parametrize(
"mock_intermediate_bbox, padding_width, expected_bboxes",
[
(
[
{
"entity_type": "TYPE_1",
"score": 0.99,
"left": 10,
"top": 15,
"width": 100,
"height": 100,
},
{
"entity_type": "TYPE_2",
"score": 0.7,
"left": 25,
"top": 49,
"width": 75,
"height": 51,
},
{
"entity_type": "TYPE_3",
"score": 0.6,
"left": 613,
"top": 26,
"width": 226,
"height": 35,
},
],
25,
[
{"top": 0, "left": 0, "width": 100, "height": 100},
{"top": 24, "left": 0, "width": 75, "height": 51},
{"top": 1, "left": 588, "width": 226, "height": 35},
],
),
],
)
def test_format_bboxes_happy_path(
mocker,
mock_engine: DicomImageRedactorEngine,
mock_intermediate_bbox: dict,
padding_width: int,
expected_bboxes: list,
):
"""Test happy path for DicomImageRedactorEngine._format_bboxes
Args:
mock_intermediate_bbox (dict): Value for mock of _get_bboxes_from_analyzer_results.
padding_width (int): Pixel width used for padding.
expected_bboxes_dict (dict): Expected output bounding box dictionary.
"""
# Arrange
mock_get_bboxes = mocker.patch(
"presidio_image_redactor.dicom_image_redactor_engine.DicomImageRedactorEngine._get_bboxes_from_analyzer_results",
return_value=mock_intermediate_bbox,
)
# Act
test_bboxes_dict = mock_engine._format_bboxes([], padding_width)
# Assert
assert mock_get_bboxes.call_count == 1
assert test_bboxes_dict == expected_bboxes
@pytest.mark.parametrize(
"padding_width, expected_error_type",
[(-1, "ValueError"), (-200, "ValueError")],
)
def test_format_bboxes_exceptions(
mock_engine: DicomImageRedactorEngine, padding_width: int, expected_error_type: str
):
"""Test error handling of _format_bboxes
Args:
padding_width (int): Pixel width used for padding.
expected_error_type (str): Type of error we expect to be raised.
"""
with pytest.raises(Exception) as exc_info:
# Act
_ = mock_engine._format_bboxes([], padding_width)
# Assert
assert expected_error_type == exc_info.typename
# ------------------------------------------------------
# DicomImageRedactorEngine._set_bbox_color()
# ------------------------------------------------------
@@ -1258,9 +1076,13 @@ def test_DicomImageRedactorEngine_redact_happy_path(
return_value=None,
)
mock_format_bboxes = mocker.patch.object(
DicomImageRedactorEngine,
"_format_bboxes",
mock_get_analyze_bbox = mocker.patch(
"presidio_image_redactor.image_redactor_engine.BboxProcessor.get_bboxes_from_analyzer_results",
return_value=None,
)
mock_remove_bbox_padding = mocker.patch(
"presidio_image_redactor.image_redactor_engine.BboxProcessor.remove_bbox_padding",
return_value=None,
)
@@ -1285,7 +1107,8 @@ def test_DicomImageRedactorEngine_redact_happy_path(
assert mock_make_phi_list.call_count == 1
assert mock_pattern_recognizer.call_count == 1
assert mock_analyze.call_count == 1
assert mock_format_bboxes.call_count == 1
assert mock_get_analyze_bbox.call_count == 1
assert mock_remove_bbox_padding.call_count == 1
assert mock_add_redact_box.call_count == 1
@@ -1355,8 +1178,13 @@ def test_DicomImageRedactorEngine_redact_single_dicom_image_happy_path(
return_value=None,
)
mock_format_bboxes = mocker.patch(
"presidio_image_redactor.dicom_image_redactor_engine.DicomImageRedactorEngine._format_bboxes",
mock_get_analyze_bbox = mocker.patch(
"presidio_image_redactor.image_redactor_engine.BboxProcessor.get_bboxes_from_analyzer_results",
return_value=None,
)
mock_remove_bbox_padding = mocker.patch(
"presidio_image_redactor.image_redactor_engine.BboxProcessor.remove_bbox_padding",
return_value=None,
)
@@ -1386,7 +1214,8 @@ def test_DicomImageRedactorEngine_redact_single_dicom_image_happy_path(
assert mock_make_phi_list.call_count == 1
assert mock_pattern_recognizer.call_count == 1
assert mock_analyze.call_count == 1
assert mock_format_bboxes.call_count == 1
assert mock_get_analyze_bbox.call_count == 1
assert mock_remove_bbox_padding.call_count == 1
assert mock_add_redact_box.call_count == 1