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

Make flexible naming in explanation.save #51

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
### Summary

* Support OpenVINO IR (.xml) / ONNX (.onnx) model file for `Explainer` model
* Enable AISE: Adaptive Input Sampling for Explanation of Black-box Models.
* Enable AISE: Adaptive Input Sampling for Explanation of Black-box Models
* Upgrade OpenVINO to 2024.3.0
* Add saliency map visualization with explanation.plot()
* Enable flexible naming for saved saliency maps and include confidence scores

### What's Changed

Expand All @@ -19,6 +20,7 @@
* Enable AISE: Adaptive Input Sampling for Explanation of Black-box Models by @negvet in https://github.com/openvinotoolkit/openvino_xai/pull/49
* Upgrade OpenVINO to 2024.3.0 by @goodsong81 in https://github.com/openvinotoolkit/openvino_xai/pull/52
* Add saliency map visualization with explanation.plot() by @GalyaZalesskaya in https://github.com/openvinotoolkit/openvino_xai/pull/53
* Enable flexible naming for saved saliency maps and include confidence scores by @GalyaZalesskaya in https://github.com/openvinotoolkit/openvino_xai/pull/51

### Known Issues

Expand Down
106 changes: 100 additions & 6 deletions docs/source/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Content:
- [Black-Box mode](#black-box-mode)
- [XAI insertion (white-box usage)](#xai-insertion-white-box-usage)
- [Plot saliency maps](#plot-saliency-maps)
- [Saving saliency maps](#saving-saliency-maps)
- [Example scripts](#example-scripts)


Expand Down Expand Up @@ -100,8 +101,8 @@ Here's the example how we can avoid passing `preprocess_fn` by preprocessing dat
```python
import cv2
import numpy as np
from typing import Mapping
import openvino.runtime as ov
from from typing import Mapping

import openvino_xai as xai

Expand Down Expand Up @@ -137,7 +138,7 @@ explanation = explainer(
)

# Save saliency maps
explanation.save("output_path", "name")
explanation.save("output_path", "name_")
```

### Specifying `preprocess_fn`
Expand All @@ -146,8 +147,8 @@ explanation.save("output_path", "name")
```python
import cv2
import numpy as np
import openvino.runtime as ov
from typing import Mapping
import openvino.runtime as ov

import openvino_xai as xai

Expand Down Expand Up @@ -184,7 +185,7 @@ explanation = explainer(
)

# Save saliency maps
explanation.save("output_path", "name")
explanation.save("output_path", "name_")
```


Expand Down Expand Up @@ -242,7 +243,7 @@ explanation = explainer(
)

# Save saliency maps
explanation.save("output_path", "name")
explanation.save("output_path", "name_")
```


Expand Down Expand Up @@ -298,7 +299,7 @@ explanation = explainer(
)

# Save saliency maps
explanation.save("output_path", "name")
explanation.save("output_path", "name_")

```

Expand Down Expand Up @@ -343,7 +344,9 @@ The `cv` backend is better for visualization in Python scripts, as it opens extr
import cv2
import numpy as np
import openvino.runtime as ov

import openvino_xai as xai
from openvino_xai.explainer import ExplainMode

def preprocess_fn(image: np.ndarray) -> np.ndarray:
"""Preprocess the input image."""
Expand Down Expand Up @@ -391,6 +394,97 @@ explanation.plot(targets=[7], backend="cv")
explanation.plot(targets=["cat"], backend="cv")
```

## Saving saliency maps

You can easily save saliency maps with flexible naming options by using a `prefix` and `postfix`. The `prefix` allows saliency maps from the same image to have consistent naming.

The format for naming is:

`{prefix} + target_id + {postfix}.jpg`

Additionally, you can include the confidence score for each class in the saved saliency map's name.

`{prefix} + target_id + {postfix} + confidence.jpg`

```python
import cv2
import numpy as np
import openvino.runtime as ov
from typing import Mapping

import openvino_xai as xai
from openvino_xai.explainer import ExplainMode

def preprocess_fn(image: np.ndarray) -> np.ndarray:
"""Preprocess the input image."""
x = cv2.resize(src=image, dsize=(224, 224))
x = x.transpose((2, 0, 1))
processed_image = np.expand_dims(x, 0)
return processed_image

def postprocess_fn(output: Mapping):
"""Postprocess the model output."""
output = softmax(output)
return output[0]

def softmax(x: np.ndarray) -> np.ndarray:
"""Compute softmax values of x."""
e_x = np.exp(x - np.max(x))
return e_x / e_x.sum()

# Generate and process saliency maps (as many as required, sequentially)
image = cv2.imread("path/to/image.jpg")

# Create ov.Model
MODEL_PATH = "path/to/model.xml"
model = ov.Core().read_model(MODEL_PATH) # type: ov.Model

# The Explainer object will prepare and load the model once in the beginning
explainer = xai.Explainer(
model,
task=xai.Task.CLASSIFICATION,
preprocess_fn=preprocess_fn,
)

voc_labels = [
'aeroplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus', 'car', 'cat', 'chair', 'cow', 'diningtable',
'dog', 'horse', 'motorbike', 'person', 'pottedplant', 'sheep', 'sofa', 'train', 'tvmonitor'
]

# Get predicted confidences for the image
compiled_model = core.compile_model(model=model, device_name="AUTO")
logits = compiled_model(preprocess_fn(image))[0]
result_infer = postprocess_fn(logits)

# Generate list of predicted class indices and scores
result_idxs = np.argwhere(result_infer > 0.4).flatten()
result_scores = result_infer[result_idxs]

# Generate dict {class_index: confidence} to save saliency maps
scores_dict = {i: score for i, score in zip(result_idxs, result_scores)}

# Run explanation
explanation = explainer(
image,
explain_mode=ExplainMode.WHITEBOX,
label_names=voc_labels,
target_explain_labels=result_idxs, # target classes to explain
)

# Save saliency maps flexibly
OUTPUT_PATH = "output_path"
explanation.save(OUTPUT_PATH) # aeroplane.jpg
explanation.save(OUTPUT_PATH, "image_name_target_") # image_name_target_aeroplane.jpg
explanation.save(OUTPUT_PATH, prefix="image_name_target_") # image_name_target_aeroplane.jpg
explanation.save(OUTPUT_PATH, postfix="_class_map") # aeroplane_class_map.jpg
explanation.save(OUTPUT_PATH, prefix="image_name_", postfix="_class_map") # image_name_aeroplane_class_map.jpg

# Save saliency maps with confidence scores
explanation.save(
OUTPUT_PATH, prefix="image_name_", postfix="_conf_", confidence_scores=scores_dict
) # image_name_aeroplane_conf_0.85.jpg
```

## Example scripts

More usage scenarios that can be used with your own models and images as arguments are available in [examples](../../examples).
Expand Down
61 changes: 49 additions & 12 deletions openvino_xai/explainer/explanation.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,21 +134,58 @@ def _select_target_indices(
raise ValueError("Provided targer index {targer_index} is not available among saliency maps.")
return target_indices

def save(self, dir_path: Path | str, name: str | None = None) -> None:
"""Dumps saliency map."""
def save(
self,
dir_path: Path | str,
prefix: str = "",
postfix: str = "",
confidence_scores: Dict[int, float] | None = None,
) -> None:
"""
Dumps saliency map images to the specified directory.

Allows flexibly name the files with the prefix and postfix.
{prefix} + target_id + {postfix}.jpg

Also allows to add confidence scores to the file names.
{prefix} + target_id + {postfix} + confidence.jpg

save(output_dir) -> aeroplane.jpg
save(output_dir, prefix="image_name_target_") -> image_name_target_aeroplane.jpg
save(output_dir, postfix="_class_map") -> aeroplane_class_map.jpg
save(
output_dir, prefix="image_name_", postfix="_conf_", confidence_scores=scores
) -> image_name_aeroplane_conf_0.85.jpg

Parameters:
:param dir_path: The directory path where the saliency maps will be saved.
:type dir_path: Path | str
:param prefix: Optional prefix for the saliency map names. Default is an empty string.
:type prefix: str
:param postfix: Optional postfix for the saliency map names. Default is an empty string.
:type postfix: str
:param confidence_scores: Dict with confidence scores for each class index. Default is None.
:type confidence_scores: Dict[int, float] | None

"""

os.makedirs(dir_path, exist_ok=True)
save_name = name if name else ""
for cls_idx, map_to_save in self._saliency_map.items():

template = f"{prefix}{{target_name}}{postfix}{{conf_score}}.jpg"
for target_idx, map_to_save in self._saliency_map.items():
conf_score = ""
map_to_save = cv2.cvtColor(map_to_save, code=cv2.COLOR_RGB2BGR)
if isinstance(cls_idx, str):
cv2.imwrite(os.path.join(dir_path, f"{save_name}.jpg"), img=map_to_save)
return
if isinstance(target_idx, str):
target_name = "activation_map"
elif self.label_names and isinstance(target_idx, np.int64):
target_name = self.label_names[target_idx]
else:
if self.label_names:
target_name = self.label_names[cls_idx]
else:
target_name = str(cls_idx)
image_name = f"{save_name}_target_{target_name}.jpg" if save_name else f"target_{target_name}.jpg"
target_name = str(target_idx)

if confidence_scores and target_idx in confidence_scores:
conf_score = f"{confidence_scores[int(target_idx)]:.2f}"

image_name = template.format(target_name=target_name, conf_score=conf_score)
cv2.imwrite(os.path.join(dir_path, image_name), img=map_to_save)

def plot(
Expand Down
7 changes: 3 additions & 4 deletions openvino_xai/methods/black_box/aise.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@

import collections
import math
from typing import Callable, Dict, List, Tuple
from typing import Callable, Dict, List, Mapping, Tuple

import numpy as np
import openvino.runtime as ov
from openvino.runtime.utils.data_helpers.wrappers import OVDict
from scipy.optimize import Bounds, direct

from openvino_xai.common.utils import (
Expand All @@ -28,7 +27,7 @@ class AISE(BlackBoxXAIMethod):
:param model: OpenVINO model.
:type model: ov.Model
:param postprocess_fn: Post-processing function that extract scores from IR model output.
:type postprocess_fn: Callable[[OVDict], np.ndarray]
:type postprocess_fn: Callable[[Mapping], np.ndarray]
:param preprocess_fn: Pre-processing function, identity function by default
(assume input images are already preprocessed by user).
:type preprocess_fn: Callable[[np.ndarray], np.ndarray]
Expand All @@ -41,7 +40,7 @@ class AISE(BlackBoxXAIMethod):
def __init__(
self,
model: ov.Model,
postprocess_fn: Callable[[OVDict], np.ndarray],
postprocess_fn: Callable[[Mapping], np.ndarray],
preprocess_fn: Callable[[np.ndarray], np.ndarray] = IdentityPreprocessFN(),
device_name: str = "CPU",
prepare_model: bool = True,
Expand Down
2 changes: 1 addition & 1 deletion openvino_xai/methods/black_box/rise.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class RISE(BlackBoxXAIMethod):
:param model: OpenVINO model.
:type model: ov.Model
:param postprocess_fn: Post-processing function that extract scores from IR model output.
:type postprocess_fn: Callable[[OVDict], np.ndarray]
:type postprocess_fn: Callable[[Mapping], np.ndarray]
:param preprocess_fn: Pre-processing function, identity function by default
(assume input images are already preprocessed by user).
:type preprocess_fn: Callable[[np.ndarray], np.ndarray]
Expand Down
34 changes: 24 additions & 10 deletions tests/unit/explanation/test_explanation.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,37 @@ def test_save(self, tmp_path):
save_path = tmp_path / "saliency_maps"

explanation = self._get_explanation()
explanation.save(save_path, "test_map")
assert os.path.isfile(save_path / "test_map_target_aeroplane.jpg")
assert os.path.isfile(save_path / "test_map_target_bird.jpg")
explanation.save(save_path, prefix="image_name_")
assert os.path.isfile(save_path / "image_name_aeroplane.jpg")
assert os.path.isfile(save_path / "image_name_bird.jpg")

explanation = self._get_explanation()
explanation.save(save_path)
assert os.path.isfile(save_path / "target_aeroplane.jpg")
assert os.path.isfile(save_path / "target_bird.jpg")
assert os.path.isfile(save_path / "aeroplane.jpg")
assert os.path.isfile(save_path / "bird.jpg")

explanation = self._get_explanation(label_names=None)
explanation.save(save_path, "test_map")
assert os.path.isfile(save_path / "test_map_target_0.jpg")
assert os.path.isfile(save_path / "test_map_target_2.jpg")
explanation.save(save_path, postfix="_class_map")
assert os.path.isfile(save_path / "0_class_map.jpg")
assert os.path.isfile(save_path / "2_class_map.jpg")

explanation = self._get_explanation()
explanation.save(save_path, prefix="image_name_", postfix="_map")
assert os.path.isfile(save_path / "image_name_aeroplane_map.jpg")
assert os.path.isfile(save_path / "image_name_bird_map.jpg")

explanation = self._get_explanation()
explanation.save(save_path, postfix="_conf_", confidence_scores={0: 0.92, 2: 0.85})
assert os.path.isfile(save_path / "aeroplane_conf_0.92.jpg")
assert os.path.isfile(save_path / "bird_conf_0.85.jpg")

explanation = self._get_explanation(saliency_maps=SALIENCY_MAPS_IMAGE, label_names=None)
explanation.save(save_path, prefix="test_map_")
assert os.path.isfile(save_path / "test_map_activation_map.jpg")

explanation = self._get_explanation(saliency_maps=SALIENCY_MAPS_IMAGE, label_names=None)
explanation.save(save_path, "test_map")
assert os.path.isfile(save_path / "test_map.jpg")
explanation.save(save_path, prefix="test_map_", postfix="_result")
assert os.path.isfile(save_path / "test_map_activation_map_result.jpg")

def _get_explanation(self, saliency_maps=SALIENCY_MAPS, label_names=VOC_NAMES):
explain_targets = [0, 2]
Expand Down