Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update to v0.9.1 #162

Merged
merged 11 commits into from
Nov 14, 2024
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
[![badge-unittests](https://github.com/xaitk/xaitk-saliency/actions/workflows/ci-unittests.yml/badge.svg)](https://github.com/XAITK/xaitk-saliency/actions/workflows/ci-unittests.yml)
[![badge-notebooks](https://github.com/xaitk/xaitk-saliency/actions/workflows/ci-example-notebooks.yml/badge.svg)](https://github.com/XAITK/xaitk-saliency/actions/workflows/ci-example-notebooks.yml)
[![codecov](https://codecov.io/gh/XAITK/xaitk-saliency/branch/master/graph/badge.svg?token=VHRNXYCNCG)](https://codecov.io/gh/XAITK/xaitk-saliency)
[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/XAITK/xaitk-saliency.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/XAITK/xaitk-saliency/context:python)

# XAITK - Saliency
The `xaitk-saliency` package is an open source, Explainable AI (XAI) framework
Expand Down
1 change: 1 addition & 0 deletions docs/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ Release Notes
release_notes/v0.8.2
release_notes/v0.8.3
release_notes/v0.9.0
release_notes/v0.9.1
20 changes: 20 additions & 0 deletions docs/release_notes/v0.9.1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
v0.9.1
======

Fixed a bug where if no detections were found in an image, then the generator would fail.

Updates / New Features
----------------------

Documentation

* Removed a deprecated badge from the README.

Implementations

* Added a check to exit early if no detections were found in `PerturbationOcclusion`

* Added a check to exit early if no saliency maps were generated in `GenerateObjectDetectorBlackboxSaliency`

Fixes
-----
14 changes: 14 additions & 0 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ name = "xaitk_saliency"
# REMEMBER: `distutils.version.*Version` types can be used to compare versions
# from strings like this.
# This package prefers to use the strict numbering standard when possible.
version = "0.9.0"
version = "0.9.1"
description = """\
Visual saliency map generation interfaces and baseline implementations \
for explainable AI."""
Expand All @@ -25,7 +25,6 @@ classifiers = [
'Operating System :: MacOS :: MacOS X',
'Operating System :: Unix',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
Expand Down
113 changes: 62 additions & 51 deletions tests/impls/gen_object_detector_blackbox_sal/test_occlusion_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
from xaitk_saliency.utils.masking import occlude_image_batch


def _perturb(ref_image: np.ndarray) -> np.ndarray:
return np.ones((6, *ref_image.shape[:2]), dtype=bool)


class TestPerturbationOcclusion:

def teardown(self) -> None:
Expand Down Expand Up @@ -62,70 +66,40 @@ def test_generate_success(self) -> None:
Test successfully invoking _generate().
"""

class StubPI (PerturbImage):
"""
Stub perturber that returns masks of ones.
"""

def perturb(self, ref_image: np.ndarray) -> np.ndarray:
return np.ones((6, *ref_image.shape[:2]), dtype=bool)

get_config = None # type: ignore

class StubGen (GenerateDetectorProposalSaliency):
"""
Stub saliency generator that returns zeros with correct shape.
"""

def generate(
self,
ref_dets: np.ndarray,
pert_dets: np.ndarray,
pert_masks: np.ndarray
) -> np.ndarray:
return np.zeros((ref_dets.shape[0], *pert_masks.shape[1:]), dtype=np.float16)

get_config = None # type: ignore

class StubDetector (DetectImageObjects):
"""
Stub object detector that returns known detections.
"""

def detect_objects(
self,
img_iter: Iterable[np.ndarray]
) -> Iterable[Iterable[Tuple[AxisAlignedBoundingBox, Dict[Hashable, float]]]]:
for i, _ in enumerate(img_iter):
# Return different number of detections for each image to
# test padding functinality
yield [(
AxisAlignedBoundingBox((0, 0), (1, 1)),
{'class0': 0.0, 'class1': 0.9}
) for _ in range(i)]

get_config = None # type: ignore

test_pi = StubPI()
test_gen = StubGen()
test_detector = StubDetector()
def detect_objects(
img_iter: Iterable[np.ndarray]
) -> Iterable[Iterable[Tuple[AxisAlignedBoundingBox, Dict[Hashable, float]]]]:
for i, _ in enumerate(img_iter):
# Return different number of detections for each image to
# test padding functinality
yield [(
AxisAlignedBoundingBox((0, 0), (1, 1)),
{'class0': 0.0, 'class1': 0.9}
) for _ in range(i)]

test_image = np.ones((64, 64, 3), dtype=np.uint8)

test_bboxes = np.ones((3, 4))
test_scores = np.ones((3, 2))

m_perturb = mock.Mock(spec=PerturbImage)
m_perturb.return_value = _perturb(test_image)
m_gen = mock.Mock(spec=GenerateDetectorProposalSaliency)
m_gen.return_value = np.zeros((3, 64, 64))
m_detector = mock.Mock(spec=DetectImageObjects)
m_detector.detect_objects = detect_objects

# Call with default fill
with mock.patch(
'xaitk_saliency.impls.gen_object_detector_blackbox_sal.occlusion_based.occlude_image_batch',
wraps=occlude_image_batch
) as m_occ_img:
inst = PerturbationOcclusion(test_pi, test_gen)
inst = PerturbationOcclusion(m_perturb, m_gen)
test_result = inst._generate(
test_image,
test_bboxes,
test_scores,
test_detector
m_detector,
)

assert test_result.shape == (3, 64, 64)
Expand All @@ -143,13 +117,13 @@ def detect_objects(
'xaitk_saliency.impls.gen_object_detector_blackbox_sal.occlusion_based.occlude_image_batch',
wraps=occlude_image_batch
) as m_occ_img:
inst = PerturbationOcclusion(test_pi, test_gen)
inst = PerturbationOcclusion(m_perturb, m_gen)
inst.fill = test_fill
test_result = inst._generate(
test_image,
test_bboxes,
test_scores,
test_detector
m_detector,
)

assert test_result.shape == (3, 64, 64)
Expand All @@ -160,3 +134,40 @@ def detect_objects(
m_kwargs = m_occ_img.call_args[-1]
assert "fill" in m_kwargs
assert m_kwargs['fill'] == test_fill

def test_empty_detections(self) -> None:
"""
Test invoking _generate() with empty detections.
"""

def detect_objects(
img_iter: Iterable[np.ndarray]
) -> Iterable[Iterable[Tuple[AxisAlignedBoundingBox, Dict[Hashable, float]]]]:
for i, _ in enumerate(img_iter):
# Return 0 detections for each image
yield []

m_detector = mock.Mock(spec=DetectImageObjects)
m_detector.detect_objects = detect_objects

test_image = np.ones((64, 64, 3), dtype=np.uint8)

test_bboxes = np.ones((3, 4))
test_scores = np.ones((3, 2))

m_perturb = mock.Mock(spec=PerturbImage)
m_perturb.return_value = _perturb(test_image)
m_gen = mock.Mock(spec=GenerateDetectorProposalSaliency)
m_gen.return_value = np.zeros((3, 64, 64))
m_detector = mock.Mock(spec=DetectImageObjects)
m_detector.detect_objects = detect_objects

inst = PerturbationOcclusion(m_perturb, m_gen)
test_result = inst._generate(
test_image,
test_bboxes,
test_scores,
m_detector,
)

assert len(test_result) == 0
35 changes: 35 additions & 0 deletions tests/interfaces/test_gen_object_detector_blackbox_sal.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,38 @@ def test_call_alias() -> None:
None # no objectness passed
)
assert test_ret == expected_return


def test_return_empty_map() -> None:
"""
Test that an empty array of maps is returned properly
"""
m_impl = mock.Mock(spec=GenerateObjectDetectorBlackboxSaliency)
m_detector = mock.Mock(spec=DetectImageObjects)

# test reference detections inputs with matching lengths
test_bboxes = np.ones((5, 4), dtype=float)
test_scores = np.ones((5, 3), dtype=float)

# 2-channel image as just HxW should work
test_image = np.ones((256, 256), dtype=np.uint8)

expected_return = np.array([])
m_impl._generate.return_value = expected_return

test_ret = GenerateObjectDetectorBlackboxSaliency.generate(
m_impl,
test_image,
test_bboxes,
test_scores,
m_detector,
)

m_impl._generate.assert_called_with(
test_image,
test_bboxes,
test_scores,
m_detector,
None # no objectness passed
)
assert len(test_ret) == 0
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ def _generate(

pert_dets_mat = _dets_to_formatted_mat(pert_dets)

if pert_dets_mat.shape[1] == 0:
return np.array([])

return self._generator(
ref_dets_mat,
pert_dets_mat,
Expand Down
7 changes: 7 additions & 0 deletions xaitk_saliency/interfaces/gen_object_detector_blackbox_sal.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

import numpy as np
import abc
from typing import Optional
Expand All @@ -6,6 +8,7 @@
from smqtk_detection import DetectImageObjects

from xaitk_saliency.exceptions import ShapeMismatchError
logger = logging.getLogger(__name__)


class GenerateObjectDetectorBlackboxSaliency (Plugfigurable):
Expand Down Expand Up @@ -144,6 +147,10 @@ def generate(
objectness,
)

if len(output) == 0:
logging.info("No detections found for image. Check DetectImageObjects and saliency configuation")
return output

# Check that the saliency heatmaps' shape matches the reference image.
if output.shape[1:] != ref_image.shape[:2]:
raise ShapeMismatchError(
Expand Down
Loading