feat(visualization): add bounding box overlay for anomaly regions#3532
feat(visualization): add bounding box overlay for anomaly regions#3532Midoriya-w wants to merge 8 commits into
Conversation
Signed-off-by: dinesh <midoriya54378@gmail.com>
68ea8e9 to
14bb8c8
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces a new visualization utility to draw bounding boxes over anomaly regions derived from an anomaly heatmap, improving interpretability of pixel-level anomaly outputs.
Changes:
- Added
add_bounding_boxes_to_image()to compute a binary anomaly mask via thresholding and draw contour-based bounding boxes. - Added optional parameters for threshold, box color, and line thickness.
| def add_bounding_boxes_to_image( | ||
| image: np.ndarray, | ||
| anomaly_map: np.ndarray, | ||
| threshold: float = 0.5, | ||
| box_color: tuple = (255, 0, 0), | ||
| box_thickness: int = 2, | ||
| ) -> np.ndarray: |
There was a problem hiding this comment.
np is used in type annotations and in the function body, but this module does not import NumPy. This will raise NameError when the module is imported (annotations are evaluated eagerly without from __future__ import annotations). Add import numpy as np at module scope (or enable postponed evaluation of annotations) before using np.ndarray/np.uint8.
| """Draw bounding boxes around detected anomaly regions. | ||
|
|
||
| Args: | ||
| image: Original input image as numpy array. | ||
| anomaly_map: Anomaly heatmap from model output. | ||
| threshold: Score threshold to consider a region anomalous. | ||
| box_color: BGR color tuple for bounding boxes. | ||
| box_thickness: Thickness of bounding box lines. | ||
|
|
There was a problem hiding this comment.
The docstring says box_color is in BGR, but anomalib utilities consistently document box colors as RGB (e.g., src/anomalib/utils/post_processing.py:362-405 expects RGB even though it uses OpenCV). Please align this function’s color convention with the rest of the codebase (update docs/type hints and/or convert the image/color ordering accordingly).
| ) -> np.ndarray: | ||
| """Draw bounding boxes around detected anomaly regions. | ||
|
|
||
| Args: | ||
| image: Original input image as numpy array. | ||
| anomaly_map: Anomaly heatmap from model output. | ||
| threshold: Score threshold to consider a region anomalous. | ||
| box_color: BGR color tuple for bounding boxes. | ||
| box_thickness: Thickness of bounding box lines. | ||
|
|
||
| Returns: | ||
| Image with bounding boxes drawn around anomalous regions. | ||
| """ | ||
| import cv2 | ||
|
|
||
| # Normalize anomaly map to 0-1 | ||
| norm_map = (anomaly_map - anomaly_map.min()) / (anomaly_map.max() - anomaly_map.min() + 1e-8) | ||
|
|
||
| # Threshold to get binary mask | ||
| binary_mask = (norm_map > threshold).astype(np.uint8) * 255 |
There was a problem hiding this comment.
This function min-max normalizes anomaly_map internally, which changes the meaning of threshold (it becomes relative per-image rather than the model’s score scale). Consider either (a) assuming inputs are already normalized (consistent with compute_mask(...): src/anomalib/utils/post_processing.py:308+ / _superimpose_segmentation_mask: src/anomalib/deploy/inferencers/base_inferencer.py:147+), or (b) adding a normalize flag and documenting that threshold applies after normalization.
| ) -> np.ndarray: | |
| """Draw bounding boxes around detected anomaly regions. | |
| Args: | |
| image: Original input image as numpy array. | |
| anomaly_map: Anomaly heatmap from model output. | |
| threshold: Score threshold to consider a region anomalous. | |
| box_color: BGR color tuple for bounding boxes. | |
| box_thickness: Thickness of bounding box lines. | |
| Returns: | |
| Image with bounding boxes drawn around anomalous regions. | |
| """ | |
| import cv2 | |
| # Normalize anomaly map to 0-1 | |
| norm_map = (anomaly_map - anomaly_map.min()) / (anomaly_map.max() - anomaly_map.min() + 1e-8) | |
| # Threshold to get binary mask | |
| binary_mask = (norm_map > threshold).astype(np.uint8) * 255 | |
| normalize: bool = True, | |
| ) -> np.ndarray: | |
| """Draw bounding boxes around detected anomaly regions. | |
| Args: | |
| image: Original input image as numpy array. | |
| anomaly_map: Anomaly heatmap from model output. If ``normalize`` is ``False``, | |
| values are assumed to already be on the intended thresholding scale. | |
| threshold: Score threshold to consider a region anomalous. Applied after min-max | |
| normalization when ``normalize=True``; otherwise applied directly to | |
| ``anomaly_map`` values. | |
| box_color: BGR color tuple for bounding boxes. | |
| box_thickness: Thickness of bounding box lines. | |
| normalize: Whether to min-max normalize ``anomaly_map`` before thresholding. | |
| Defaults to ``True`` to preserve the existing visualization behavior. | |
| Returns: | |
| Image with bounding boxes drawn around anomalous regions. | |
| """ | |
| import cv2 | |
| score_map = anomaly_map | |
| if normalize: | |
| # Normalize anomaly map to 0-1 before applying the threshold. | |
| score_map = (anomaly_map - anomaly_map.min()) / (anomaly_map.max() - anomaly_map.min() + 1e-8) | |
| # Threshold to get binary mask | |
| binary_mask = (score_map > threshold).astype(np.uint8) * 255 |
| for contour in contours: | ||
| x, y, w, h = cv2.boundingRect(contour) | ||
| if w > 5 and h > 5: # filter tiny noise boxes | ||
| cv2.rectangle(result, (x, y), (x + w, y + h), box_color, box_thickness) | ||
| cv2.putText(result, "DEFECT", (x, y - 5), | ||
| cv2.FONT_HERSHEY_SIMPLEX, 0.5, box_color, box_thickness) |
There was a problem hiding this comment.
There are hard-coded values (w > 5 and h > 5 and the "DEFECT" label) that affect output but aren’t configurable. To avoid magic numbers/strings and make the utility reusable, consider parameters like min_box_size and label (or omit the text overlay by default).
| base_image = add_text_to_image(base_image, title, **text_config) | ||
| output_images.append(base_image) | ||
|
|
||
| return create_image_grid(output_images, nrow=len(output_images)) if output_images else None |
There was a problem hiding this comment.
PEP8/ruff formatting: add the required blank line(s) between the end of visualize_image_item and this new top-level function definition. Also consider reformatting the multi-line cv2.putText(...) call to match the project’s formatter output.
| return create_image_grid(output_images, nrow=len(output_images)) if output_images else None | |
| return create_image_grid(output_images, nrow=len(output_images)) if output_images else None |
ashwinvaidya17
left a comment
There was a problem hiding this comment.
Thanks for the contribution. I have added a few comments.
| output_images.append(base_image) | ||
|
|
||
| return create_image_grid(output_images, nrow=len(output_images)) if output_images else None | ||
| def add_bounding_boxes_to_image( |
There was a problem hiding this comment.
Let's move this to functional.py
|
|
||
| return create_image_grid(output_images, nrow=len(output_images)) if output_images else None | ||
| def add_bounding_boxes_to_image( | ||
| image: np.ndarray, |
There was a problem hiding this comment.
To be consistent with other utils such as add_text_to_image can we use Image.Image instead
| image: np.ndarray, | |
| image: Image.Image, |
| Returns: | ||
| Image with bounding boxes drawn around anomalous regions. | ||
| """ | ||
| import cv2 |
There was a problem hiding this comment.
Try to avoid inline imports if possible.
| cv2.putText(result, "DEFECT", (x, y - 5), | ||
| cv2.FONT_HERSHEY_SIMPLEX, 0.5, box_color, box_thickness) | ||
|
|
||
| return result No newline at end of file |
There was a problem hiding this comment.
Can you run pre-commit hooks
Signed-off-by: dinesh <midoriya54378@gmail.com>
…d to functional Signed-off-by: dinesh <midoriya54378@gmail.com>
|
Thank you for the review @ashwinvaidya17! I've addressed all the comments: Moved add bounding boxes to image to functional.py Please take another look when you get a chance! |
Signed-off-by: dinesh <midoriya54378@gmail.com>
|
Hi @ashwinvaidya17, Thanks again for your detailed review earlier I’ve addressed all the points and updated the branch. I’ve also synced with the latest main branch. Happy to make further improvements if needed! |
|
Hey @ashwinvaidya17, just wanted to share that I posted about my Anomalib contribution experience on LinkedIn including the learnings from your code review feedback. Thanks again for taking the time to review! |
📋 Description
Added
add_bounding_boxes_to_image()function to item_visualizer.py to draw bounding boxes around detected anomaly regions.✨ Changes
What does this PR do?
Motivation
Anomaly heatmaps show pixel-level intensity but bounding boxes make defect locations immediately interpretable — especially useful for real-time warehouse/industrial inspection demos and production pipelines.
Testing
Tested on MVTecAD bottle dataset with PaDiM model. Bounding boxes correctly highlight defective regions identified by the anomaly heatmap.