diff --git a/src/model_api/visualizer/defaults.py b/src/model_api/visualizer/defaults.py new file mode 100644 index 00000000..0411658c --- /dev/null +++ b/src/model_api/visualizer/defaults.py @@ -0,0 +1,25 @@ +"""Default visualization constants.""" + +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +# Font sizes +DEFAULT_FONT_SIZE: int = 16 +"""Default font size used for all text (labels, bounding boxes, overlays, keypoints).""" + +# Line / outline widths +DEFAULT_OUTLINE_WIDTH: int = 2 +"""Default outline width for bounding boxes and polygon contours.""" + +# Opacity +DEFAULT_OPACITY: float = 0.4 +"""Default blend opacity for overlays and polygon fills.""" + +# Keypoint drawing +DEFAULT_KEYPOINT_SIZE: int = 3 +"""Default radius (in pixels) for keypoint dots.""" + +# Scale baseline +SCALE_BASELINE: int = 1280 +"""Longer-edge pixel count of 720p (landscape). Used as the denominator when +computing the auto-scale factor.""" diff --git a/src/model_api/visualizer/layout/hstack.py b/src/model_api/visualizer/layout/hstack.py index 2cc4079a..e1c629b3 100644 --- a/src/model_api/visualizer/layout/hstack.py +++ b/src/model_api/visualizer/layout/hstack.py @@ -34,7 +34,11 @@ def _compute_on_primitive(self, primitive: Type[Primitive], image: PIL.Image, sc for _primitive in scene.get_primitives(primitive): image_ = _primitive.compute(image.copy()) if isinstance(_primitive, Overlay): - image_ = Overlay.overlay_labels(image=image_, labels=_primitive.label) + image_ = Overlay.overlay_labels( + image=image_, + labels=_primitive.label, + font_size=_primitive.font_size, + ) images.append(image_) return self._stitch(*images) return None diff --git a/src/model_api/visualizer/primitive/bounding_box.py b/src/model_api/visualizer/primitive/bounding_box.py index f9dcd534..3816f889 100644 --- a/src/model_api/visualizer/primitive/bounding_box.py +++ b/src/model_api/visualizer/primitive/bounding_box.py @@ -5,7 +5,9 @@ from __future__ import annotations -from PIL import Image, ImageDraw +from PIL import Image, ImageDraw, ImageFont + +from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_OUTLINE_WIDTH from .primitive import Primitive @@ -20,6 +22,8 @@ class BoundingBox(Primitive): y2 (int): y-coordinate of the bottom-right corner of the bounding box. label (str | None): Label of the bounding box. color (str | tuple[int, int, int]): Color of the bounding box. + outline_width (int): Width of the bounding box outline. + font_size (int): Font size for the label text. Example: >>> bounding_box = BoundingBox(x1=10, y1=10, x2=100, y2=100, label="Label Name") @@ -34,6 +38,8 @@ def __init__( y2: int, label: str | None = None, color: str | tuple[int, int, int] = "blue", + outline_width: int = DEFAULT_OUTLINE_WIDTH, + font_size: int = DEFAULT_FONT_SIZE, ) -> None: self.x1 = x1 self.y1 = y1 @@ -41,16 +47,19 @@ def __init__( self.y2 = y2 self.label = label self.color = color - self.y_buffer = 5 # Text at the bottom of the text box is clipped. This prevents that. + self.outline_width = outline_width + self.font_size = font_size + self.font = ImageFont.load_default(size=self.font_size) + self.y_buffer = max(3, font_size // 3) # Text at the bottom of the text box is clipped. This prevents that. def compute(self, image: Image) -> Image: draw = ImageDraw.Draw(image) # draw rectangle - draw.rectangle((self.x1, self.y1, self.x2, self.y2), outline=self.color, width=2) + draw.rectangle((self.x1, self.y1, self.x2, self.y2), outline=self.color, width=self.outline_width) # add label if self.label: # draw the background of the label - textbox = draw.textbbox((0, 0), self.label) + textbox = draw.textbbox((0, 0), self.label, font=self.font) label_image = Image.new( "RGB", (textbox[2] - textbox[0], textbox[3] + self.y_buffer - textbox[1]), @@ -58,6 +67,6 @@ def compute(self, image: Image) -> Image: ) draw = ImageDraw.Draw(label_image) # write the label on the background - draw.text((0, 0), self.label, fill="white") + draw.text((0, 0), self.label, font=self.font, fill="white") image.paste(label_image, (self.x1, self.y1)) return image diff --git a/src/model_api/visualizer/primitive/keypoints.py b/src/model_api/visualizer/primitive/keypoints.py index 66a2a0f4..01d07e4d 100644 --- a/src/model_api/visualizer/primitive/keypoints.py +++ b/src/model_api/visualizer/primitive/keypoints.py @@ -8,6 +8,8 @@ import numpy as np from PIL import Image, ImageDraw, ImageFont +from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_KEYPOINT_SIZE + from .primitive import Primitive @@ -25,12 +27,14 @@ def __init__( keypoints: np.ndarray, scores: Union[np.ndarray, None] = None, color: Union[str, tuple[int, int, int]] = "purple", - keypoint_size: int = 3, + keypoint_size: int = DEFAULT_KEYPOINT_SIZE, + font_size: int = DEFAULT_FONT_SIZE, ) -> None: self.keypoints = self._validate_keypoints(keypoints) self.scores = scores self.color = color self.keypoint_size = keypoint_size + self.font_size = font_size def compute(self, image: Image) -> Image: """Draw keypoints on the image.""" @@ -47,7 +51,7 @@ def compute(self, image: Image) -> Image: ) if self.scores is not None: - font = ImageFont.load_default(size=18) + font = ImageFont.load_default(size=self.font_size) for score, keypoint in zip(self.scores, self.keypoints): textbox = draw.textbbox((0, 0), f"{score:.2f}", font=font) draw.text( diff --git a/src/model_api/visualizer/primitive/label.py b/src/model_api/visualizer/primitive/label.py index 8c77ec42..3c1845ff 100644 --- a/src/model_api/visualizer/primitive/label.py +++ b/src/model_api/visualizer/primitive/label.py @@ -8,6 +8,8 @@ from PIL import Image, ImageDraw, ImageFont +from model_api.visualizer.defaults import DEFAULT_FONT_SIZE + from .primitive import Primitive @@ -46,7 +48,7 @@ def __init__( fg_color: Union[str, tuple[int, int, int]] = "black", bg_color: Union[str, tuple[int, int, int]] = "yellow", font_path: Union[str, BytesIO, None] = None, - size: int = 16, + size: int = DEFAULT_FONT_SIZE, ) -> None: self.label = f"{label} ({score:.2f})" if score is not None else label self.fg_color = fg_color diff --git a/src/model_api/visualizer/primitive/overlay.py b/src/model_api/visualizer/primitive/overlay.py index 870e14de..c596e495 100644 --- a/src/model_api/visualizer/primitive/overlay.py +++ b/src/model_api/visualizer/primitive/overlay.py @@ -11,6 +11,8 @@ import PIL from PIL import ImageFont +from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_OPACITY + from .primitive import Primitive @@ -28,12 +30,14 @@ class Overlay(Primitive): def __init__( self, image: PIL.Image | np.ndarray, - opacity: float = 0.4, + opacity: float = DEFAULT_OPACITY, label: Union[str, None] = None, + font_size: int = DEFAULT_FONT_SIZE, ) -> None: self.image = self._to_pil(image) self.label = label self.opacity = opacity + self.font_size = font_size def _to_pil(self, image: PIL.Image | np.ndarray) -> PIL.Image: if isinstance(image, np.ndarray): @@ -45,15 +49,25 @@ def compute(self, image: PIL.Image) -> PIL.Image: return PIL.Image.blend(image, image_, self.opacity) @classmethod - def overlay_labels(cls, image: PIL.Image, labels: Union[list[str], str, None] = None) -> PIL.Image: + def overlay_labels( + cls, + image: PIL.Image, + labels: Union[list[str], str, None] = None, + font_size: int = DEFAULT_FONT_SIZE, + ) -> PIL.Image: """Draw labels at the bottom center of the image. This is handy when you want to add a label to the image. + + Args: + image: Image to overlay the labels on. + labels: Labels to overlay. + font_size: Font size for the label text. """ if labels is not None: labels = [labels] if isinstance(labels, str) else labels - font = ImageFont.load_default(size=18) - buffer_y = 5 + font = ImageFont.load_default(size=font_size) + buffer_y = max(3, font_size // 3) dummy_image = PIL.Image.new("RGB", (1, 1)) draw = PIL.ImageDraw.Draw(dummy_image) textbox = draw.textbbox((0, 0), ", ".join(labels), font=font) diff --git a/src/model_api/visualizer/primitive/polygon.py b/src/model_api/visualizer/primitive/polygon.py index 2b30e21f..3708b3ca 100644 --- a/src/model_api/visualizer/primitive/polygon.py +++ b/src/model_api/visualizer/primitive/polygon.py @@ -11,6 +11,8 @@ import cv2 from PIL import Image, ImageColor, ImageDraw +from model_api.visualizer.defaults import DEFAULT_OPACITY, DEFAULT_OUTLINE_WIDTH + from .primitive import Primitive if TYPE_CHECKING: @@ -41,8 +43,8 @@ def __init__( points: list[tuple[int, int]] | None = None, mask: np.ndarray | None = None, color: str | tuple[int, int, int] = "blue", - opacity: float = 0.4, - outline_width: int = 2, + opacity: float = DEFAULT_OPACITY, + outline_width: int = DEFAULT_OUTLINE_WIDTH, ) -> None: self.points = self._get_points(points, mask) self.color = color diff --git a/src/model_api/visualizer/scene/anomaly.py b/src/model_api/visualizer/scene/anomaly.py index 8790dbd5..9c196fbc 100644 --- a/src/model_api/visualizer/scene/anomaly.py +++ b/src/model_api/visualizer/scene/anomaly.py @@ -3,13 +3,13 @@ # Copyright (C) 2024 Intel Corporation # SPDX-License-Identifier: Apache-2.0 -from itertools import starmap from typing import Union import cv2 from PIL import Image from model_api.models.result import AnomalyResult +from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_OUTLINE_WIDTH from model_api.visualizer.layout import Flatten, Layout from model_api.visualizer.primitive import BoundingBox, Label, Overlay, Polygon @@ -19,7 +19,14 @@ class AnomalyScene(Scene): """Anomaly Scene.""" - def __init__(self, image: Image, result: AnomalyResult, layout: Union[Layout, None] = None) -> None: + def __init__( + self, + image: Image, + result: AnomalyResult, + layout: Union[Layout, None] = None, + scale: float = 1.0, + ) -> None: + self.scale = scale super().__init__( base=image, overlay=self._get_overlays(result), @@ -32,23 +39,44 @@ def __init__(self, image: Image, result: AnomalyResult, layout: Union[Layout, No def _get_overlays(self, result: AnomalyResult) -> list[Overlay]: if result.anomaly_map is not None: anomaly_map = cv2.cvtColor(result.anomaly_map, cv2.COLOR_BGR2RGB) - return [Overlay(anomaly_map)] + return [Overlay(anomaly_map, font_size=int(DEFAULT_FONT_SIZE * self.scale))] return [] def _get_bounding_boxes(self, result: AnomalyResult) -> list[BoundingBox]: if result.pred_boxes is not None: - return list(starmap(BoundingBox, result.pred_boxes)) + return [ + BoundingBox( + x1=box[0], + y1=box[1], + x2=box[2], + y2=box[3], + outline_width=max(1, int(DEFAULT_OUTLINE_WIDTH * self.scale)), + font_size=int(DEFAULT_FONT_SIZE * self.scale), + ) + for box in result.pred_boxes + ] return [] def _get_labels(self, result: AnomalyResult) -> list[Label]: labels = [] if result.pred_label is not None and result.pred_score is not None: - labels.append(Label(label=result.pred_label, score=result.pred_score)) + labels.append( + Label( + label=result.pred_label, + score=result.pred_score, + size=int(DEFAULT_FONT_SIZE * self.scale), + ), + ) return labels def _get_polygons(self, result: AnomalyResult) -> list[Polygon]: if result.pred_mask is not None: - return [Polygon(result.pred_mask)] + return [ + Polygon( + result.pred_mask, + outline_width=max(1, int(DEFAULT_OUTLINE_WIDTH * self.scale)), + ), + ] return [] @property diff --git a/src/model_api/visualizer/scene/classification.py b/src/model_api/visualizer/scene/classification.py index 04d08774..94160f96 100644 --- a/src/model_api/visualizer/scene/classification.py +++ b/src/model_api/visualizer/scene/classification.py @@ -9,6 +9,7 @@ from PIL import Image from model_api.models.result import ClassificationResult +from model_api.visualizer.defaults import DEFAULT_FONT_SIZE from model_api.visualizer.layout import Flatten, Layout from model_api.visualizer.primitive import Label, Overlay @@ -18,7 +19,14 @@ class ClassificationScene(Scene): """Classification Scene.""" - def __init__(self, image: Image, result: ClassificationResult, layout: Union[Layout, None] = None) -> None: + def __init__( + self, + image: Image, + result: ClassificationResult, + layout: Union[Layout, None] = None, + scale: float = 1.0, + ) -> None: + self.scale = scale super().__init__( base=image, label=self._get_labels(result), @@ -31,7 +39,13 @@ def _get_labels(self, result: ClassificationResult) -> list[Label]: if result.top_labels is not None and len(result.top_labels) > 0: for label in result.top_labels: if label.name is not None: - labels.append(Label(label=label.name, score=label.confidence)) + labels.append( + Label( + label=label.name, + score=label.confidence, + size=int(DEFAULT_FONT_SIZE * self.scale), + ), + ) return labels def _get_overlays(self, result: ClassificationResult) -> list[Overlay]: diff --git a/src/model_api/visualizer/scene/detection.py b/src/model_api/visualizer/scene/detection.py index 5bf31847..4ec15055 100644 --- a/src/model_api/visualizer/scene/detection.py +++ b/src/model_api/visualizer/scene/detection.py @@ -9,6 +9,7 @@ from PIL import Image from model_api.models.result import DetectionResult +from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_OUTLINE_WIDTH from model_api.visualizer.layout import Flatten, HStack, Layout from model_api.visualizer.primitive import BoundingBox, Label, Overlay @@ -19,8 +20,15 @@ class DetectionScene(Scene): """Detection Scene.""" - def __init__(self, image: Image, result: DetectionResult, layout: Union[Layout, None] = None) -> None: + def __init__( + self, + image: Image, + result: DetectionResult, + layout: Union[Layout, None] = None, + scale: float = 1.0, + ) -> None: self.color_per_label = get_label_color_mapping(result.label_names) + self.scale = scale super().__init__( base=image, bounding_box=self._get_bounding_boxes(result), @@ -37,7 +45,13 @@ def _get_overlays(self, result: DetectionResult) -> list[Overlay]: if result.saliency_map is not None and result.saliency_map.size > 0: saliency_map = cv2.applyColorMap(result.saliency_map[0][label_index], cv2.COLORMAP_JET) saliency_map = cv2.cvtColor(saliency_map, cv2.COLOR_BGR2RGB) - overlays.append(Overlay(saliency_map, label=label_name.title())) + overlays.append( + Overlay( + saliency_map, + label=label_name.title(), + font_size=int(DEFAULT_FONT_SIZE * self.scale), + ), + ) return overlays def _get_bounding_boxes(self, result: DetectionResult) -> list[BoundingBox]: @@ -46,7 +60,16 @@ def _get_bounding_boxes(self, result: DetectionResult) -> list[BoundingBox]: x1, y1, x2, y2 = bbox label = f"{label_name} ({score:.2f})" bounding_boxes.append( - BoundingBox(x1=x1, y1=y1, x2=x2, y2=y2, label=label, color=self.color_per_label[label_name]), + BoundingBox( + x1=x1, + y1=y1, + x2=x2, + y2=y2, + label=label, + color=self.color_per_label[label_name], + outline_width=max(1, int(DEFAULT_OUTLINE_WIDTH * self.scale)), + font_size=int(DEFAULT_FONT_SIZE * self.scale), + ), ) return bounding_boxes diff --git a/src/model_api/visualizer/scene/keypoint.py b/src/model_api/visualizer/scene/keypoint.py index 0dd7ac99..8a9aa9aa 100644 --- a/src/model_api/visualizer/scene/keypoint.py +++ b/src/model_api/visualizer/scene/keypoint.py @@ -8,6 +8,7 @@ from PIL import Image from model_api.models.result import DetectedKeypoints +from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_KEYPOINT_SIZE from model_api.visualizer.layout import Flatten, Layout from model_api.visualizer.primitive import Keypoint @@ -17,7 +18,14 @@ class KeypointScene(Scene): """Keypoint Scene.""" - def __init__(self, image: Image, result: DetectedKeypoints, layout: Union[Layout, None] = None) -> None: + def __init__( + self, + image: Image, + result: DetectedKeypoints, + layout: Union[Layout, None] = None, + scale: float = 1.0, + ) -> None: + self.scale = scale super().__init__( base=image, keypoints=self._get_keypoints(result), @@ -25,7 +33,14 @@ def __init__(self, image: Image, result: DetectedKeypoints, layout: Union[Layout ) def _get_keypoints(self, result: DetectedKeypoints) -> list[Keypoint]: - return [Keypoint(result.keypoints, result.scores)] + return [ + Keypoint( + result.keypoints, + result.scores, + keypoint_size=max(1, int(DEFAULT_KEYPOINT_SIZE * self.scale)), + font_size=int(DEFAULT_FONT_SIZE * self.scale), + ), + ] @property def default_layout(self) -> Layout: diff --git a/src/model_api/visualizer/scene/segmentation/instance_segmentation.py b/src/model_api/visualizer/scene/segmentation/instance_segmentation.py index df3bf8f9..fdfe8a59 100644 --- a/src/model_api/visualizer/scene/segmentation/instance_segmentation.py +++ b/src/model_api/visualizer/scene/segmentation/instance_segmentation.py @@ -9,6 +9,7 @@ from PIL import Image from model_api.models.result import InstanceSegmentationResult +from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_OUTLINE_WIDTH from model_api.visualizer.layout import Flatten, HStack, Layout from model_api.visualizer.primitive import BoundingBox, Label, Overlay, Polygon from model_api.visualizer.scene import Scene @@ -18,8 +19,15 @@ class InstanceSegmentationScene(Scene): """Instance Segmentation Scene.""" - def __init__(self, image: Image, result: InstanceSegmentationResult, layout: Union[Layout, None] = None) -> None: + def __init__( + self, + image: Image, + result: InstanceSegmentationResult, + layout: Union[Layout, None] = None, + scale: float = 1.0, + ) -> None: self.color_per_label = get_label_color_mapping(result.label_names) + self.scale = scale super().__init__( base=image, label=self._get_labels(result), @@ -32,13 +40,25 @@ def _get_labels(self, result: InstanceSegmentationResult) -> list[Label]: # add only unique labels labels = [] for label_name in set(result.label_names): - labels.append(Label(label=label_name, bg_color=self.color_per_label[label_name])) + labels.append( + Label( + label=label_name, + bg_color=self.color_per_label[label_name], + size=int(DEFAULT_FONT_SIZE * self.scale), + ), + ) return labels def _get_polygons(self, result: InstanceSegmentationResult) -> list[Polygon]: polygons = [] for mask, label_name in zip(result.masks, result.label_names): - polygons.append(Polygon(mask=mask, color=self.color_per_label[label_name])) + polygons.append( + Polygon( + mask=mask, + color=self.color_per_label[label_name], + outline_width=max(1, int(DEFAULT_OUTLINE_WIDTH * self.scale)), + ), + ) return polygons def _get_bounding_boxes(self, result: InstanceSegmentationResult) -> list[BoundingBox]: @@ -47,7 +67,16 @@ def _get_bounding_boxes(self, result: InstanceSegmentationResult) -> list[Boundi x1, y1, x2, y2 = bbox label = f"{label_name} ({score:.2f})" bounding_boxes.append( - BoundingBox(x1=x1, y1=y1, x2=x2, y2=y2, label=label, color=self.color_per_label[label_name]), + BoundingBox( + x1=x1, + y1=y1, + x2=x2, + y2=y2, + label=label, + color=self.color_per_label[label_name], + outline_width=max(1, int(DEFAULT_OUTLINE_WIDTH * self.scale)), + font_size=int(DEFAULT_FONT_SIZE * self.scale), + ), ) return bounding_boxes @@ -59,7 +88,13 @@ def _get_overlays(self, result: InstanceSegmentationResult) -> list[Overlay]: saliency_map = result.saliency_map[label - 1] saliency_map = cv2.applyColorMap(saliency_map, cv2.COLORMAP_JET) saliency_map = cv2.cvtColor(saliency_map, cv2.COLOR_BGR2RGB) - overlays.append(Overlay(saliency_map, label=f"{label_name.title()} Saliency Map")) + overlays.append( + Overlay( + saliency_map, + label=f"{label_name.title()} Saliency Map", + font_size=int(DEFAULT_FONT_SIZE * self.scale), + ), + ) return overlays @property diff --git a/src/model_api/visualizer/scene/segmentation/segmentation.py b/src/model_api/visualizer/scene/segmentation/segmentation.py index 12bbaa49..c80d650a 100644 --- a/src/model_api/visualizer/scene/segmentation/segmentation.py +++ b/src/model_api/visualizer/scene/segmentation/segmentation.py @@ -10,6 +10,7 @@ from PIL import Image from model_api.models.result import ImageResultWithSoftPrediction +from model_api.visualizer.defaults import DEFAULT_FONT_SIZE from model_api.visualizer.layout import HStack, Layout from model_api.visualizer.primitive import Overlay from model_api.visualizer.scene import Scene @@ -18,7 +19,14 @@ class SegmentationScene(Scene): """Segmentation Scene.""" - def __init__(self, image: Image, result: ImageResultWithSoftPrediction, layout: Union[Layout, None] = None) -> None: + def __init__( + self, + image: Image, + result: ImageResultWithSoftPrediction, + layout: Union[Layout, None] = None, + scale: float = 1.0, + ) -> None: + self.scale = scale super().__init__( base=image, overlay=self._get_overlays(result), @@ -34,12 +42,24 @@ def _get_overlays(self, result: ImageResultWithSoftPrediction) -> list[Overlay]: class_map = (hard_prediction == i).astype(np.uint8) * 255 class_map = cv2.applyColorMap(class_map, cv2.COLORMAP_JET) class_map = cv2.cvtColor(class_map, cv2.COLOR_BGR2RGB) - overlays.append(Overlay(class_map, label=f"Class {i}")) + overlays.append( + Overlay( + class_map, + label=f"Class {i}", + font_size=int(DEFAULT_FONT_SIZE * self.scale), + ), + ) # Add saliency map if result.saliency_map is not None and result.saliency_map.size > 0: saliency_map = cv2.cvtColor(result.saliency_map, cv2.COLOR_BGR2RGB) - overlays.append(Overlay(saliency_map, label="Saliency Map")) + overlays.append( + Overlay( + saliency_map, + label="Saliency Map", + font_size=int(DEFAULT_FONT_SIZE * self.scale), + ), + ) return overlays diff --git a/src/model_api/visualizer/visualizer.py b/src/model_api/visualizer/visualizer.py index ff7622c6..f685a8b8 100644 --- a/src/model_api/visualizer/visualizer.py +++ b/src/model_api/visualizer/visualizer.py @@ -20,6 +20,7 @@ Result, ) +from .defaults import SCALE_BASELINE from .scene import ( AnomalyScene, ClassificationScene, @@ -37,10 +38,33 @@ class Visualizer: - """Utility class to automatically select the correct scene and render/show it.""" + """Utility class to automatically select the correct scene and render/show it. - def __init__(self, layout: Layout | None = None) -> None: + Args: + layout: Optional layout to use for rendering. + auto_scale: When True, drawing sizes (line widths, font sizes, etc.) are + automatically scaled relative to 720p so that annotations remain + visible on high-resolution images. Defaults to True. + """ + + def __init__(self, layout: Layout | None = None, auto_scale: bool = True) -> None: self.layout = layout + self.auto_scale = auto_scale + + @staticmethod + def compute_scale_factor(image: Image.Image) -> float: + """Compute a scale factor based on the image's longer edge relative to 720p (1280px). + + Returns 1.0 for images ≤ 720p; for larger images the factor grows proportionally. + + Args: + image: PIL Image whose dimensions determine the scale. + + Returns: + Scale factor (>= 1.0). + """ + longer_edge = max(image.width, image.height) + return max(1.0, longer_edge / SCALE_BASELINE) def show(self, image: Image.Image | np.ndarray, result: Result) -> None: if isinstance(image, np.ndarray): @@ -69,21 +93,23 @@ def render(self, image: Image.Image | np.ndarray, result: Result) -> Image.Image return result_img def _scene_from_result(self, image: Image, result: Result) -> Scene: + scale = self.compute_scale_factor(image) if self.auto_scale else 1.0 + scene: Scene if isinstance(result, AnomalyResult): - scene = AnomalyScene(image, result, self.layout) + scene = AnomalyScene(image, result, self.layout, scale=scale) elif isinstance(result, ClassificationResult): - scene = ClassificationScene(image, result, self.layout) + scene = ClassificationScene(image, result, self.layout, scale=scale) elif isinstance(result, InstanceSegmentationResult): # Note: This has to be before DetectionScene because InstanceSegmentationResult is a subclass # of DetectionResult - scene = InstanceSegmentationScene(image, result, self.layout) + scene = InstanceSegmentationScene(image, result, self.layout, scale=scale) elif isinstance(result, ImageResultWithSoftPrediction): - scene = SegmentationScene(image, result, self.layout) + scene = SegmentationScene(image, result, self.layout, scale=scale) elif isinstance(result, DetectionResult): - scene = DetectionScene(image, result, self.layout) + scene = DetectionScene(image, result, self.layout, scale=scale) elif isinstance(result, DetectedKeypoints): - scene = KeypointScene(image, result, self.layout) + scene = KeypointScene(image, result, self.layout, scale=scale) else: msg = f"Unsupported result type: {type(result)}" raise ValueError(msg) diff --git a/tests/unit/visualizer/test_auto_scale.py b/tests/unit/visualizer/test_auto_scale.py new file mode 100644 index 00000000..a821025d --- /dev/null +++ b/tests/unit/visualizer/test_auto_scale.py @@ -0,0 +1,194 @@ +"""Tests for auto_scale visualizer feature.""" + +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import numpy as np +import pytest +from PIL import Image + +from model_api.models.result import ( + ClassificationResult, + DetectionResult, + InstanceSegmentationResult, +) +from model_api.models.result.classification import Label as ResultLabel +from model_api.visualizer import Visualizer +from model_api.visualizer.defaults import SCALE_BASELINE + + +class TestComputeScaleFactor: + """Test the compute_scale_factor staticmethod.""" + + def test_below_720p_returns_1(self): + """Images at or below 720p should return scale factor 1.0.""" + image = Image.new("RGB", (640, 480)) + assert Visualizer.compute_scale_factor(image) == 1.0 + + def test_exactly_720p_returns_1(self): + image = Image.new("RGB", (1280, 720)) + assert Visualizer.compute_scale_factor(image) == 1.0 + + def test_1080p(self): + image = Image.new("RGB", (1920, 1080)) + assert Visualizer.compute_scale_factor(image) == pytest.approx(1920 / SCALE_BASELINE, abs=1e-6) + + def test_4032x2268(self): + """The original motivating use-case: a phone camera photo.""" + image = Image.new("RGB", (4032, 2268)) + assert Visualizer.compute_scale_factor(image) == pytest.approx(4032 / SCALE_BASELINE, abs=1e-6) + + def test_4k(self): + image = Image.new("RGB", (3840, 2160)) + assert Visualizer.compute_scale_factor(image) == pytest.approx(3840 / SCALE_BASELINE, abs=1e-6) + + def test_8k(self): + image = Image.new("RGB", (7680, 4320)) + assert Visualizer.compute_scale_factor(image) == pytest.approx(7680 / SCALE_BASELINE, abs=1e-6) + + def test_portrait_uses_longer_edge(self): + """Portrait orientation: height > width.""" + image = Image.new("RGB", (1080, 1920)) + assert Visualizer.compute_scale_factor(image) == pytest.approx(1920 / SCALE_BASELINE, abs=1e-6) + + def test_small_image(self): + image = Image.new("RGB", (100, 100)) + assert Visualizer.compute_scale_factor(image) == 1.0 + + +class TestVisualizerAutoScale: + """Test that auto_scale=False preserves old behaviour and auto_scale=True scales.""" + + @pytest.fixture() + def small_image(self): + return Image.new("RGB", (640, 480), color=(128, 128, 128)) + + @pytest.fixture() + def large_image(self): + return Image.new("RGB", (3840, 2160), color=(128, 128, 128)) + + @pytest.fixture() + def detection_result_small(self): + return DetectionResult( + bboxes=np.array([[10, 10, 200, 200]]), + labels=np.array([0]), + label_names=["person"], + scores=np.array([0.95]), + saliency_map=np.array([]), + ) + + @pytest.fixture() + def detection_result_large(self): + return DetectionResult( + bboxes=np.array([[100, 100, 2000, 1500]]), + labels=np.array([0]), + label_names=["person"], + scores=np.array([0.95]), + saliency_map=np.array([]), + ) + + def test_default_auto_scale_is_true(self): + vis = Visualizer() + assert vis.auto_scale is True + + def test_auto_scale_false_no_scaling(self, small_image, detection_result_small): + """auto_scale=False should work exactly like before.""" + vis = Visualizer(auto_scale=False) + rendered = vis.render(small_image, detection_result_small) + assert isinstance(rendered, Image.Image) + assert rendered.size == small_image.size + + def test_auto_scale_true_small_image_no_scaling(self, small_image, detection_result_small): + """auto_scale=True on a <=720p image should produce same results as False.""" + vis_off = Visualizer(auto_scale=False) + vis_on = Visualizer(auto_scale=True) + rendered_off = vis_off.render(small_image.copy(), detection_result_small) + rendered_on = vis_on.render(small_image.copy(), detection_result_small) + np.testing.assert_array_equal(np.array(rendered_off), np.array(rendered_on)) + + def test_auto_scale_true_large_image_differs(self, large_image, detection_result_large): + """auto_scale=True on a large image should produce visually different output.""" + vis_off = Visualizer(auto_scale=False) + vis_on = Visualizer(auto_scale=True) + rendered_off = vis_off.render(large_image.copy(), detection_result_large) + rendered_on = vis_on.render(large_image.copy(), detection_result_large) + assert not np.array_equal(np.array(rendered_off), np.array(rendered_on)) + + def test_auto_scale_render_returns_numpy_when_given_numpy(self, large_image, detection_result_large): + vis = Visualizer(auto_scale=True) + rendered = vis.render(np.array(large_image), detection_result_large) + assert isinstance(rendered, np.ndarray) + assert rendered.shape == np.array(large_image).shape + + def test_auto_scale_save(self, large_image, detection_result_large, tmp_path): + vis = Visualizer(auto_scale=True) + path = tmp_path / "test_auto_scale.jpg" + vis.save(large_image, detection_result_large, path) + assert path.exists() + + def test_auto_scale_classification(self, large_image, tmp_path): + result = ClassificationResult( + top_labels=[ + ResultLabel(name="cat", confidence=0.95), + ResultLabel(name="dog", confidence=0.90), + ], + saliency_map=np.array([]), + ) + vis = Visualizer(auto_scale=True) + path = tmp_path / "test_cls_auto_scale.jpg" + vis.save(large_image, result, path) + assert path.exists() + + def test_auto_scale_instance_segmentation(self, large_image, tmp_path): + result = InstanceSegmentationResult( + bboxes=np.array([[100, 100, 2000, 1500]]), + labels=np.array([0]), + masks=np.array([np.ones((1500, 2000), dtype=np.uint8)]), + scores=np.array([0.85]), + label_names=["person"], + saliency_map=None, + feature_vector=np.array([1, 2, 3]), + ) + vis = Visualizer(auto_scale=True) + path = tmp_path / "test_iseg_auto_scale.jpg" + vis.save(large_image, result, path) + assert path.exists() + + +class TestSceneScalePropagation: + """Verify that scale factors reach the primitives inside scenes.""" + + def test_detection_scene_bounding_box_width_scales(self): + """BoundingBox outline_width should increase with scale.""" + from model_api.visualizer.scene.detection import DetectionScene + + image = Image.new("RGB", (3840, 2160)) + result = DetectionResult( + bboxes=np.array([[100, 100, 2000, 1500]]), + labels=np.array([0]), + label_names=["person"], + scores=np.array([0.95]), + saliency_map=np.array([]), + ) + scene = DetectionScene(image, result, scale=3.0) + boxes = scene.bounding_box + assert boxes is not None + assert boxes[0].outline_width == 6 + assert boxes[0].font_size == 48 + + def test_detection_scene_scale_1_preserves_defaults(self): + from model_api.visualizer.scene.detection import DetectionScene + + image = Image.new("RGB", (1280, 720)) + result = DetectionResult( + bboxes=np.array([[10, 10, 200, 200]]), + labels=np.array([0]), + label_names=["person"], + scores=np.array([0.95]), + saliency_map=np.array([]), + ) + scene = DetectionScene(image, result, scale=1.0) + boxes = scene.bounding_box + assert boxes is not None + assert boxes[0].outline_width == 2 + assert boxes[0].font_size == 16