obj_localizer / src /visualization.py
3v324v23's picture
fix: resolve all ruff lint errors
cf388f7
Raw
History Blame Contribute Delete
4.02 kB
"""Visualization utilities for drawing bounding boxes on images."""
from __future__ import annotations
from typing import TYPE_CHECKING
from PIL import Image, ImageDraw, ImageFont
if TYPE_CHECKING:
from src.parsing import BBox
BOX_COLORS = [
"#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF",
"#FF8800", "#8800FF", "#00FF88", "#FF0088", "#88FF00", "#0088FF",
]
MIN_BOX_SIZE = 4
def _get_font(size: int = 14) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""Try to load a reasonable font, fall back to default."""
try:
return ImageFont.truetype("arial.ttf", size)
except OSError:
try:
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
except OSError:
return ImageFont.load_default()
def _hex_to_rgba(hex_color: str, alpha: int = 80) -> tuple[int, int, int, int]:
"""Convert hex color to RGBA tuple."""
h = hex_color.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return (r, g, b, alpha)
def draw_boxes(
image: Image.Image,
boxes: list[BBox],
labels: list[str] | None = None,
show_confidence: bool = True,
line_width: int = 3,
font_size: int = 14,
) -> Image.Image:
"""Draw bounding boxes with labels on an image.
Args:
image: Source PIL image.
boxes: List of BBox objects in pixel coordinates.
labels: Optional per-box labels. If None, uses box.label or index.
show_confidence: Whether to show confidence score in label.
line_width: Width of bounding box outlines.
font_size: Font size for labels.
Returns:
New image with drawn overlays.
"""
if not boxes:
return image.copy()
img = image.copy().convert("RGBA")
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw_overlay = ImageDraw.Draw(overlay)
draw_text = ImageDraw.Draw(img)
font = _get_font(font_size)
img_w, img_h = img.size
for i, box in enumerate(boxes):
color_hex = BOX_COLORS[i % len(BOX_COLORS)]
fill_rgba = _hex_to_rgba(color_hex, alpha=50)
outline_rgb = color_hex
bx1, by1 = max(0, box.x1), max(0, box.y1)
bx2, by2 = min(img_w, box.x2), min(img_h, box.y2)
if (bx2 - bx1) < MIN_BOX_SIZE or (by2 - by1) < MIN_BOX_SIZE:
cx, cy = (bx1 + bx2) / 2, (by1 + by2) / 2
half = MIN_BOX_SIZE
bx1, by1 = cx - half, cy - half
bx2, by2 = cx + half, cy + half
draw_overlay.rectangle([bx1, by1, bx2, by2], fill=fill_rgba, outline=outline_rgb, width=line_width)
label = labels[i] if labels and i < len(labels) else (box.label or f"#{i+1}")
if show_confidence and box.confidence > 0:
label = f"{label} ({box.confidence:.0%})"
text_bbox = draw_text.textbbox((0, 0), label, font=font)
text_w = text_bbox[2] - text_bbox[0]
text_h = text_bbox[3] - text_bbox[1]
text_y = by1 - text_h - 4 if by1 - text_h - 4 > 0 else by1 + 4
text_x = max(0, bx1)
draw_text.rectangle(
[text_x, text_y, text_x + text_w + 6, text_y + text_h + 4],
fill=color_hex,
)
draw_text.text((text_x + 3, text_y + 2), label, fill="white", font=font)
img = Image.alpha_composite(img, overlay).convert("RGB")
return img
def create_no_detection_overlay(image: Image.Image, message: str = "No detections found") -> Image.Image:
"""Create an overlay indicating no objects were detected."""
img = image.copy()
draw = ImageDraw.Draw(img)
font = _get_font(18)
text_bbox = draw.textbbox((0, 0), message, font=font)
text_w = text_bbox[2] - text_bbox[0]
text_h = text_bbox[3] - text_bbox[1]
img_w, img_h = img.size
x = (img_w - text_w) / 2
y = img_h - text_h - 20
draw.rectangle([x - 10, y - 5, x + text_w + 10, y + text_h + 5], fill=(0, 0, 0, 180))
draw.text((x, y), message, fill="yellow", font=font)
return img