Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
05a4cc2
working on perturbation detectors
rabah-khalek Aug 8, 2024
cf2a8c4
Refactor HF ppl model to convert numpy array to PIL image
Inokinoki Aug 5, 2024
7ba35b6
Allow to set global mode for an HF ppl model for PIL conversion
Inokinoki Aug 5, 2024
6c3ffc9
mode switch in hf models
rabah-khalek Aug 8, 2024
7e14795
supporting gray scale
rabah-khalek Aug 8, 2024
f3d8e32
Merge branch 'main' into perturbation-detectors
rabah-khalek Aug 8, 2024
c30dade
Merge branch 'main' into perturbation-detectors
rabah-khalek Aug 10, 2024
98baa6d
added missing predict_rgb_image
rabah-khalek Aug 12, 2024
a9fa22f
ensuring backward compatibility with predict_image
rabah-khalek Aug 12, 2024
e9198ce
updating detectors
rabah-khalek Aug 12, 2024
4dd46b4
Adding noise perturbation detector with Gaussian noise (#52)
bmalezieux Aug 12, 2024
e547d4d
updating detectors
rabah-khalek Aug 12, 2024
a44399d
refactoring detectors
rabah-khalek Aug 13, 2024
fe26272
small updates
rabah-khalek Aug 13, 2024
c359c9c
refactored spec setting
rabah-khalek Aug 13, 2024
6dca401
fixed import in object_detection dataloader
rabah-khalek Aug 13, 2024
99d98dd
renaming pert detectors
rabah-khalek Aug 13, 2024
6601ecb
Merge branch 'main' into perturbation-detectors
rabah-khalek Aug 13, 2024
14de1fa
Merge branch 'perturbation-detectors' into refactoring-detectors
rabah-khalek Aug 13, 2024
182731c
fixed import
rabah-khalek Aug 13, 2024
6ba1994
fixing get_scan_results args
rabah-khalek Aug 13, 2024
ffbb425
Merge pull request #53 from Giskard-AI/refactoring-detectors
rabah-khalek Aug 13, 2024
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion giskard_vision/core/dataloaders/hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ class HFDataLoader(DataIteratorBase):
"""

def __init__(
self, hf_id: str, hf_config: Optional[str] = None, hf_split: str = "test", name: Optional[str] = None
self,
hf_id: str,
hf_config: Optional[str] = None,
hf_split: str = "test",
name: Optional[str] = None,
) -> None:
"""
Initializes the general HuggingFace Datasets instance.
Expand Down
9 changes: 9 additions & 0 deletions giskard_vision/core/detectors/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from giskard_vision.image_classification.tests.performance import Accuracy
from giskard_vision.landmark_detection.tests.performance import NMEMean
from giskard_vision.object_detection.tests.performance import IoU

detector_metrics = {
"image_classification": Accuracy,
"landmark": NMEMean,
"object_detection": IoU,
}
114 changes: 114 additions & 0 deletions giskard_vision/core/detectors/perturbation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import os
from abc import abstractmethod
from pathlib import Path
from typing import Any, Sequence

import cv2

from giskard_vision.core.dataloaders.wrappers import FilteredDataLoader
from giskard_vision.core.detectors.base import (
DetectorVisionBase,
IssueGroup,
ScanResult,
)
from giskard_vision.landmark_detection.tests.base import TestDiff
from giskard_vision.utils.errors import GiskardImportError

from .metrics import detector_metrics

Cropping = IssueGroup(
"Cropping", description="Cropping involves evaluating the landmark detection model on specific face areas."
)

Ethical = IssueGroup(
"Ethical",
description="The data are filtered by ethnicity to detect ethical biases in the landmark detection model.",
)

Pose = IssueGroup(
"Head Pose",
description="The data are filtered by head pose to detect biases in the landmark detection model.",
)

Robustness = IssueGroup(
"Robustness",
description="Images from the dataset are blurred, recolored and resized to test the robustness of the model to transformations.",
)


class PerturbationBaseDetector(DetectorVisionBase):
"""
Abstract class for Landmark Detection Detectors

Methods:
get_dataloaders(dataset: Any) -> Sequence[Any]:
Abstract method that returns a list of dataloaders corresponding to
slices or transformations

get_results(model: Any, dataset: Any) -> Sequence[ScanResult]:
Returns a list of ScanResult containing the evaluation results

get_scan_result(self, test_result) -> ScanResult:
Convert TestResult to ScanResult
"""

@abstractmethod
def get_dataloaders(self, dataset: Any) -> Sequence[Any]: ...

def get_results(self, model: Any, dataset: Any) -> Sequence[ScanResult]:
dataloaders = self.get_dataloaders(dataset)

results = []
for dl in dataloaders:
test_result = TestDiff(metric=detector_metrics[model.model_type], threshold=1).run(
model=model,
dataloader=dl,
dataloader_ref=dataset,
)

# Save example images from dataloader and dataset
current_path = str(Path())
os.makedirs(f"{current_path}/examples_images", exist_ok=True)
filename_examples = []

index_worst = 0 if test_result.indexes_examples is None else test_result.indexes_examples[0]

if isinstance(dl, FilteredDataLoader):
filename_example_dataloader_ref = str(Path() / "examples_images" / f"{dataset.name}_{index_worst}.png")
cv2.imwrite(
filename_example_dataloader_ref, cv2.resize(dataset[index_worst][0][0], (0, 0), fx=0.3, fy=0.3)
)
filename_examples.append(filename_example_dataloader_ref)

filename_example_dataloader = str(Path() / "examples_images" / f"{dl.name}_{index_worst}.png")
cv2.imwrite(filename_example_dataloader, cv2.resize(dl[index_worst][0][0], (0, 0), fx=0.3, fy=0.3))
filename_examples.append(filename_example_dataloader)
results.append(self.get_scan_result(test_result, filename_examples, dl.name, len(dl)))

return results

def get_scan_result(self, test_result, filename_examples, name, size_data) -> ScanResult:
try:
from giskard.scanner.issues import IssueLevel
except (ImportError, ModuleNotFoundError) as e:
raise GiskardImportError(["giskard"]) from e

relative_delta = (test_result.metric_value_test - test_result.metric_value_ref) / test_result.metric_value_ref
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use what we implemented in the meta data detector for this function, for instance here the metric can be absolute or relative

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

taken care of in #53


if relative_delta > self.issue_level_threshold + self.deviation_threshold:
issue_level = IssueLevel.MAJOR
elif relative_delta > self.issue_level_threshold:
issue_level = IssueLevel.MEDIUM
else:
issue_level = IssueLevel.MINOR

return ScanResult(
name=name,
metric_name=test_result.metric_name,
metric_value=test_result.metric_value_test,
metric_reference_value=test_result.metric_value_ref,
issue_level=issue_level,
slice_size=size_data,
filename_examples=filename_examples,
relative_delta=relative_delta,
)
24 changes: 24 additions & 0 deletions giskard_vision/core/detectors/transformation_blurring_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from giskard_vision.core.dataloaders.wrappers import BlurredDataLoader

from ...core.detectors.decorator import maybe_detector
from .perturbation import PerturbationBaseDetector, Robustness


@maybe_detector("blurring", tags=["vision", "robustness", "image_classification", "landmark", "object_detection"])
class TransformationBlurringDetectorLandmark(PerturbationBaseDetector):
"""
Detector that evaluates models performance on blurred images
"""

issue_group = Robustness

def __init__(self, kernel_size=(11, 11), sigma=(3, 3)):
self.kernel_size = kernel_size
self.sigma = sigma

def get_dataloaders(self, dataset):
dl = BlurredDataLoader(dataset, self.kernel_size, self.sigma)

dls = [dl]

return dls
20 changes: 20 additions & 0 deletions giskard_vision/core/detectors/transformation_color_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from giskard_vision.core.dataloaders.wrappers import ColoredDataLoader

from ...core.detectors.decorator import maybe_detector
from .perturbation import PerturbationBaseDetector, Robustness


@maybe_detector("coloring", tags=["vision", "robustness", "image_classification", "landmark", "object_detection"])
class TransformationColorDetectorLandmark(PerturbationBaseDetector):
"""
Detector that evaluates models performance depending on images in grayscale
"""

issue_group = Robustness

def get_dataloaders(self, dataset):
dl = ColoredDataLoader(dataset)

dls = [dl]

return dls
28 changes: 25 additions & 3 deletions giskard_vision/core/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,37 @@ class ModelBase(ABC):
prediction_result_cls = TypesBase.prediction_result

@abstractmethod
def predict_image(self, image: np.ndarray) -> Any:
"""abstract method that takes one image as input and outputs the prediction
def predict_rgb_image(self, image: np.ndarray) -> Any:
"""abstract method that takes one RGB image as input and outputs the prediction

Args:
image (np.ndarray): input image
"""

...

def predict_gray_image(self, image: np.ndarray) -> Any:
"""abstract method that takes one gray image as input and outputs the prediction

Args:
image (np.ndarray): input image
"""

raise NotImplementedError("predict_gray_image method is not implemented")

def predict_image(self, image: np.ndarray) -> Any:
"""abstract method that takes one image as input and outputs the prediction

Args:
image (np.ndarray): input image
"""
if image.shape[-1] == 3:
return self.predict_rgb_image(image)
elif image.shape[-1] == 1 or len(image.shape) == 2:
return self.predict_gray_image(image)
else:
raise ValueError("predict_image: image shape not supported.")

def predict_batch(self, idx: int, images: List[np.ndarray]) -> np.ndarray:
"""method that should be implemented if the passed dataloader has batch_size != 1

Expand All @@ -40,7 +62,7 @@ def predict_batch(self, idx: int, images: List[np.ndarray]) -> np.ndarray:
except Exception:
res.append(None)
logger.warning(
f"{self.__class__.__name__}: Face not detected in processed image of batch {idx} and index {i}."
f"{self.__class__.__name__}: Prediction failed in processed image of batch {idx} and index {i}."
)
# logger.warning(e) # OpenCV's exception is very misleading

Expand Down
6 changes: 3 additions & 3 deletions giskard_vision/core/models/hf_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ def __init__(
"""init method that accepts a model object, number of landmarks and dimensions

Args:
model_id (str): Hugging Face model ID
name (Optional[str]): name of the model
pipeline_task (HFPipelineTask): HuggingFace pipeline task
model_id (str): Hugging Face model ID.
name (Optional[str]): name of the model.
pipeline_task (HFPipelineTask): HuggingFace pipeline task.

Raises:
GiskardImportError: If there are missing Hugging Face dependencies.
Expand Down
6 changes: 3 additions & 3 deletions giskard_vision/image_classification/dataloaders/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def get_image(self, idx: int) -> np.ndarray:
Returns:
np.ndarray: The image data.
"""
return self.get_row(idx)["image"]
return np.array(self.get_row(idx)["image"])

def get_labels(self, idx: int) -> Optional[np.ndarray]:
"""
Expand Down Expand Up @@ -124,7 +124,7 @@ def get_image(self, idx: int) -> Any:
Returns:
np.ndarray: The image data.
"""
return self.ds[idx]["image"]
return np.array(self.ds[idx]["image"])

def get_labels(self, idx: int) -> Optional[np.ndarray]:
"""
Expand Down Expand Up @@ -201,7 +201,7 @@ def get_image(self, idx: int) -> Any:
Returns:
np.ndarray: The image data.
"""
return self.ds[idx]["img"]
return np.array(self.ds[idx]["img"])

def get_labels(self, idx: int) -> Optional[np.ndarray]:
"""
Expand Down
38 changes: 23 additions & 15 deletions giskard_vision/image_classification/models/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Optional

import numpy as np
from PIL import Image

from giskard_vision.core.models.hf_pipeline import HFPipelineModelBase, HFPipelineTask
from giskard_vision.image_classification.types import Types
Expand All @@ -10,9 +11,10 @@ class ImageClassificationHFModel(HFPipelineModelBase):
"""Hugging Face pipeline wrapper class that serves as a template for image classification predictions

Args:
model_id (str): Hugging Face model ID
name (Optional[str]): name of the model
device (str): device to run the model on
model_id (str): Hugging Face model ID.
name (Optional[str]): name of the model.
device (str): device to run the model on.
mode (str): The mode to convert the numpy image data to PIL image, defaulting to "RGB".

Attributes:
classification_labels: list of classification labels, where the position of the label corresponds to the class index
Expand All @@ -21,13 +23,14 @@ class ImageClassificationHFModel(HFPipelineModelBase):
model_type = "image_classification"
prediction_result_cls = Types.prediction_result

def __init__(self, model_id: str, name: Optional[str] = None, device: str = "cpu"):
def __init__(self, model_id: str, name: Optional[str] = None, device: str = "cpu", mode: str = "RGB"):
"""init method that accepts a model id, name and device

Args:
model_id (str): Hugging Face model ID
name (Optional[str]): name of the model
device (str): device to run the model on
model_id (str): Hugging Face model ID.
name (Optional[str]): name of the model.
device (str): device to run the model on.
mode (str): The mode to convert the numpy image data to PIL image, defaulting to "RGB".
"""

super().__init__(
Expand All @@ -38,6 +41,7 @@ def __init__(self, model_id: str, name: Optional[str] = None, device: str = "cpu
)

self._classification_labels = list(self.pipeline.model.config.id2label.values())
self._mode = mode

@property
def classification_labels(self):
Expand All @@ -57,25 +61,29 @@ class SingleLabelImageClassificationHFModelWrapper(ImageClassificationHFModel):
classification_labels: list of classification labels, where the position of the label corresponds to the class index
"""

def predict_probas(self, image: np.ndarray) -> np.ndarray:
def predict_probas(self, image: np.ndarray, mode=None) -> np.ndarray:
"""method that takes one image as input and outputs the prediction of probabilities for each class

Args:
image (np.ndarray): input image
mode (str): mode of the image
"""
m = mode or self._mode
pil_image = Image.fromarray(image, mode=m)

# Pipeline takes a PIL image as input
_raw_prediction = self.pipeline(
image,
pil_image,
top_k=len(self.classification_labels), # Get probabilities for all labels
)
_prediction = {p["label"]: p["score"] for p in _raw_prediction}

return np.array([_prediction[label] for label in self.classification_labels])

def predict_image(self, image) -> Types.label:
"""method that takes one image as input and outputs one class label
def predict_rgb_image(self, image: np.ndarray) -> Types.label:
probas = self.predict_probas(image, mode=None)
return self.classification_labels[np.argmax(probas)]

Args:
image (np.ndarray): input image
"""
probas = self.predict_probas(image)
def predict_gray_image(self, image: np.ndarray) -> Types.label:
probas = self.predict_probas(image, mode="L")
return self.classification_labels[np.argmax(probas)]
4 changes: 2 additions & 2 deletions giskard_vision/landmark_detection/models/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def __init__(self, model):
super().__init__(n_landmarks=68, n_dimensions=2, name="FaceAlignment")
self.model = model

def predict_image(self, image):
def predict_rgb_image(self, image):
"""
Predict facial landmarks for a given image using the wrapped face alignment model.

Expand Down Expand Up @@ -100,7 +100,7 @@ def __init__(self):
self.landmark_detector = cv2.face.createFacemarkLBF()
self.landmark_detector.loadModel(LBFmodel)

def predict_image(self, image):
def predict_rgb_image(self, image):
"""
Predict facial landmarks for a given image using the wrapped OpenCV face landmarks model.

Expand Down
2 changes: 1 addition & 1 deletion giskard_vision/object_detection/models/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def shape_rescale(self, image, boxes):
def positive_constraint(self, boxes):
return np.clip(boxes, 0, None)

def predict_image(self, image: np.ndarray):
def predict_rgb_image(self, image: np.ndarray):
try:
from keras.applications.mobilenet import preprocess_input
except ImportError:
Expand Down