Skip to content

Commit 6cd5ce5

Browse files
authored
feat: adjust visualization to scale with image size (#482)
* GETI-480-adjust-visualization-to-image-size * copilot comments
1 parent 9417d3c commit 6cd5ce5

15 files changed

Lines changed: 459 additions & 44 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Default visualization constants."""
2+
3+
# Copyright (C) 2026 Intel Corporation
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
# Font sizes
7+
DEFAULT_FONT_SIZE: int = 16
8+
"""Default font size used for all text (labels, bounding boxes, overlays, keypoints)."""
9+
10+
# Line / outline widths
11+
DEFAULT_OUTLINE_WIDTH: int = 2
12+
"""Default outline width for bounding boxes and polygon contours."""
13+
14+
# Opacity
15+
DEFAULT_OPACITY: float = 0.4
16+
"""Default blend opacity for overlays and polygon fills."""
17+
18+
# Keypoint drawing
19+
DEFAULT_KEYPOINT_SIZE: int = 3
20+
"""Default radius (in pixels) for keypoint dots."""
21+
22+
# Scale baseline
23+
SCALE_BASELINE: int = 1280
24+
"""Longer-edge pixel count of 720p (landscape). Used as the denominator when
25+
computing the auto-scale factor."""

src/model_api/visualizer/layout/hstack.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ def _compute_on_primitive(self, primitive: Type[Primitive], image: PIL.Image, sc
3434
for _primitive in scene.get_primitives(primitive):
3535
image_ = _primitive.compute(image.copy())
3636
if isinstance(_primitive, Overlay):
37-
image_ = Overlay.overlay_labels(image=image_, labels=_primitive.label)
37+
image_ = Overlay.overlay_labels(
38+
image=image_,
39+
labels=_primitive.label,
40+
font_size=_primitive.font_size,
41+
)
3842
images.append(image_)
3943
return self._stitch(*images)
4044
return None

src/model_api/visualizer/primitive/bounding_box.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
from __future__ import annotations
77

8-
from PIL import Image, ImageDraw
8+
from PIL import Image, ImageDraw, ImageFont
9+
10+
from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_OUTLINE_WIDTH
911

1012
from .primitive import Primitive
1113

@@ -20,6 +22,8 @@ class BoundingBox(Primitive):
2022
y2 (int): y-coordinate of the bottom-right corner of the bounding box.
2123
label (str | None): Label of the bounding box.
2224
color (str | tuple[int, int, int]): Color of the bounding box.
25+
outline_width (int): Width of the bounding box outline.
26+
font_size (int): Font size for the label text.
2327
2428
Example:
2529
>>> bounding_box = BoundingBox(x1=10, y1=10, x2=100, y2=100, label="Label Name")
@@ -34,30 +38,35 @@ def __init__(
3438
y2: int,
3539
label: str | None = None,
3640
color: str | tuple[int, int, int] = "blue",
41+
outline_width: int = DEFAULT_OUTLINE_WIDTH,
42+
font_size: int = DEFAULT_FONT_SIZE,
3743
) -> None:
3844
self.x1 = x1
3945
self.y1 = y1
4046
self.x2 = x2
4147
self.y2 = y2
4248
self.label = label
4349
self.color = color
44-
self.y_buffer = 5 # Text at the bottom of the text box is clipped. This prevents that.
50+
self.outline_width = outline_width
51+
self.font_size = font_size
52+
self.font = ImageFont.load_default(size=self.font_size)
53+
self.y_buffer = max(3, font_size // 3) # Text at the bottom of the text box is clipped. This prevents that.
4554

4655
def compute(self, image: Image) -> Image:
4756
draw = ImageDraw.Draw(image)
4857
# draw rectangle
49-
draw.rectangle((self.x1, self.y1, self.x2, self.y2), outline=self.color, width=2)
58+
draw.rectangle((self.x1, self.y1, self.x2, self.y2), outline=self.color, width=self.outline_width)
5059
# add label
5160
if self.label:
5261
# draw the background of the label
53-
textbox = draw.textbbox((0, 0), self.label)
62+
textbox = draw.textbbox((0, 0), self.label, font=self.font)
5463
label_image = Image.new(
5564
"RGB",
5665
(textbox[2] - textbox[0], textbox[3] + self.y_buffer - textbox[1]),
5766
self.color,
5867
)
5968
draw = ImageDraw.Draw(label_image)
6069
# write the label on the background
61-
draw.text((0, 0), self.label, fill="white")
70+
draw.text((0, 0), self.label, font=self.font, fill="white")
6271
image.paste(label_image, (self.x1, self.y1))
6372
return image

src/model_api/visualizer/primitive/keypoints.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import numpy as np
99
from PIL import Image, ImageDraw, ImageFont
1010

11+
from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_KEYPOINT_SIZE
12+
1113
from .primitive import Primitive
1214

1315

@@ -25,12 +27,14 @@ def __init__(
2527
keypoints: np.ndarray,
2628
scores: Union[np.ndarray, None] = None,
2729
color: Union[str, tuple[int, int, int]] = "purple",
28-
keypoint_size: int = 3,
30+
keypoint_size: int = DEFAULT_KEYPOINT_SIZE,
31+
font_size: int = DEFAULT_FONT_SIZE,
2932
) -> None:
3033
self.keypoints = self._validate_keypoints(keypoints)
3134
self.scores = scores
3235
self.color = color
3336
self.keypoint_size = keypoint_size
37+
self.font_size = font_size
3438

3539
def compute(self, image: Image) -> Image:
3640
"""Draw keypoints on the image."""
@@ -47,7 +51,7 @@ def compute(self, image: Image) -> Image:
4751
)
4852

4953
if self.scores is not None:
50-
font = ImageFont.load_default(size=18)
54+
font = ImageFont.load_default(size=self.font_size)
5155
for score, keypoint in zip(self.scores, self.keypoints):
5256
textbox = draw.textbbox((0, 0), f"{score:.2f}", font=font)
5357
draw.text(

src/model_api/visualizer/primitive/label.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from PIL import Image, ImageDraw, ImageFont
1010

11+
from model_api.visualizer.defaults import DEFAULT_FONT_SIZE
12+
1113
from .primitive import Primitive
1214

1315

@@ -46,7 +48,7 @@ def __init__(
4648
fg_color: Union[str, tuple[int, int, int]] = "black",
4749
bg_color: Union[str, tuple[int, int, int]] = "yellow",
4850
font_path: Union[str, BytesIO, None] = None,
49-
size: int = 16,
51+
size: int = DEFAULT_FONT_SIZE,
5052
) -> None:
5153
self.label = f"{label} ({score:.2f})" if score is not None else label
5254
self.fg_color = fg_color

src/model_api/visualizer/primitive/overlay.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import PIL
1212
from PIL import ImageFont
1313

14+
from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_OPACITY
15+
1416
from .primitive import Primitive
1517

1618

@@ -28,12 +30,14 @@ class Overlay(Primitive):
2830
def __init__(
2931
self,
3032
image: PIL.Image | np.ndarray,
31-
opacity: float = 0.4,
33+
opacity: float = DEFAULT_OPACITY,
3234
label: Union[str, None] = None,
35+
font_size: int = DEFAULT_FONT_SIZE,
3336
) -> None:
3437
self.image = self._to_pil(image)
3538
self.label = label
3639
self.opacity = opacity
40+
self.font_size = font_size
3741

3842
def _to_pil(self, image: PIL.Image | np.ndarray) -> PIL.Image:
3943
if isinstance(image, np.ndarray):
@@ -45,15 +49,25 @@ def compute(self, image: PIL.Image) -> PIL.Image:
4549
return PIL.Image.blend(image, image_, self.opacity)
4650

4751
@classmethod
48-
def overlay_labels(cls, image: PIL.Image, labels: Union[list[str], str, None] = None) -> PIL.Image:
52+
def overlay_labels(
53+
cls,
54+
image: PIL.Image,
55+
labels: Union[list[str], str, None] = None,
56+
font_size: int = DEFAULT_FONT_SIZE,
57+
) -> PIL.Image:
4958
"""Draw labels at the bottom center of the image.
5059
5160
This is handy when you want to add a label to the image.
61+
62+
Args:
63+
image: Image to overlay the labels on.
64+
labels: Labels to overlay.
65+
font_size: Font size for the label text.
5266
"""
5367
if labels is not None:
5468
labels = [labels] if isinstance(labels, str) else labels
55-
font = ImageFont.load_default(size=18)
56-
buffer_y = 5
69+
font = ImageFont.load_default(size=font_size)
70+
buffer_y = max(3, font_size // 3)
5771
dummy_image = PIL.Image.new("RGB", (1, 1))
5872
draw = PIL.ImageDraw.Draw(dummy_image)
5973
textbox = draw.textbbox((0, 0), ", ".join(labels), font=font)

src/model_api/visualizer/primitive/polygon.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import cv2
1212
from PIL import Image, ImageColor, ImageDraw
1313

14+
from model_api.visualizer.defaults import DEFAULT_OPACITY, DEFAULT_OUTLINE_WIDTH
15+
1416
from .primitive import Primitive
1517

1618
if TYPE_CHECKING:
@@ -41,8 +43,8 @@ def __init__(
4143
points: list[tuple[int, int]] | None = None,
4244
mask: np.ndarray | None = None,
4345
color: str | tuple[int, int, int] = "blue",
44-
opacity: float = 0.4,
45-
outline_width: int = 2,
46+
opacity: float = DEFAULT_OPACITY,
47+
outline_width: int = DEFAULT_OUTLINE_WIDTH,
4648
) -> None:
4749
self.points = self._get_points(points, mask)
4850
self.color = color

src/model_api/visualizer/scene/anomaly.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
# Copyright (C) 2024 Intel Corporation
44
# SPDX-License-Identifier: Apache-2.0
55

6-
from itertools import starmap
76
from typing import Union
87

98
import cv2
109
from PIL import Image
1110

1211
from model_api.models.result import AnomalyResult
12+
from model_api.visualizer.defaults import DEFAULT_FONT_SIZE, DEFAULT_OUTLINE_WIDTH
1313
from model_api.visualizer.layout import Flatten, Layout
1414
from model_api.visualizer.primitive import BoundingBox, Label, Overlay, Polygon
1515

@@ -19,7 +19,14 @@
1919
class AnomalyScene(Scene):
2020
"""Anomaly Scene."""
2121

22-
def __init__(self, image: Image, result: AnomalyResult, layout: Union[Layout, None] = None) -> None:
22+
def __init__(
23+
self,
24+
image: Image,
25+
result: AnomalyResult,
26+
layout: Union[Layout, None] = None,
27+
scale: float = 1.0,
28+
) -> None:
29+
self.scale = scale
2330
super().__init__(
2431
base=image,
2532
overlay=self._get_overlays(result),
@@ -32,23 +39,44 @@ def __init__(self, image: Image, result: AnomalyResult, layout: Union[Layout, No
3239
def _get_overlays(self, result: AnomalyResult) -> list[Overlay]:
3340
if result.anomaly_map is not None:
3441
anomaly_map = cv2.cvtColor(result.anomaly_map, cv2.COLOR_BGR2RGB)
35-
return [Overlay(anomaly_map)]
42+
return [Overlay(anomaly_map, font_size=int(DEFAULT_FONT_SIZE * self.scale))]
3643
return []
3744

3845
def _get_bounding_boxes(self, result: AnomalyResult) -> list[BoundingBox]:
3946
if result.pred_boxes is not None:
40-
return list(starmap(BoundingBox, result.pred_boxes))
47+
return [
48+
BoundingBox(
49+
x1=box[0],
50+
y1=box[1],
51+
x2=box[2],
52+
y2=box[3],
53+
outline_width=max(1, int(DEFAULT_OUTLINE_WIDTH * self.scale)),
54+
font_size=int(DEFAULT_FONT_SIZE * self.scale),
55+
)
56+
for box in result.pred_boxes
57+
]
4158
return []
4259

4360
def _get_labels(self, result: AnomalyResult) -> list[Label]:
4461
labels = []
4562
if result.pred_label is not None and result.pred_score is not None:
46-
labels.append(Label(label=result.pred_label, score=result.pred_score))
63+
labels.append(
64+
Label(
65+
label=result.pred_label,
66+
score=result.pred_score,
67+
size=int(DEFAULT_FONT_SIZE * self.scale),
68+
),
69+
)
4770
return labels
4871

4972
def _get_polygons(self, result: AnomalyResult) -> list[Polygon]:
5073
if result.pred_mask is not None:
51-
return [Polygon(result.pred_mask)]
74+
return [
75+
Polygon(
76+
result.pred_mask,
77+
outline_width=max(1, int(DEFAULT_OUTLINE_WIDTH * self.scale)),
78+
),
79+
]
5280
return []
5381

5482
@property

src/model_api/visualizer/scene/classification.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from PIL import Image
1010

1111
from model_api.models.result import ClassificationResult
12+
from model_api.visualizer.defaults import DEFAULT_FONT_SIZE
1213
from model_api.visualizer.layout import Flatten, Layout
1314
from model_api.visualizer.primitive import Label, Overlay
1415

@@ -18,7 +19,14 @@
1819
class ClassificationScene(Scene):
1920
"""Classification Scene."""
2021

21-
def __init__(self, image: Image, result: ClassificationResult, layout: Union[Layout, None] = None) -> None:
22+
def __init__(
23+
self,
24+
image: Image,
25+
result: ClassificationResult,
26+
layout: Union[Layout, None] = None,
27+
scale: float = 1.0,
28+
) -> None:
29+
self.scale = scale
2230
super().__init__(
2331
base=image,
2432
label=self._get_labels(result),
@@ -31,7 +39,13 @@ def _get_labels(self, result: ClassificationResult) -> list[Label]:
3139
if result.top_labels is not None and len(result.top_labels) > 0:
3240
for label in result.top_labels:
3341
if label.name is not None:
34-
labels.append(Label(label=label.name, score=label.confidence))
42+
labels.append(
43+
Label(
44+
label=label.name,
45+
score=label.confidence,
46+
size=int(DEFAULT_FONT_SIZE * self.scale),
47+
),
48+
)
3549
return labels
3650

3751
def _get_overlays(self, result: ClassificationResult) -> list[Overlay]:

0 commit comments

Comments
 (0)