cxr-report-generation / src /visualization.py
adhisetiawan's picture
Sync latest application changes
21245a4
Raw
History Blame Contribute Delete
4.5 kB
from __future__ import annotations
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from src.config.endpoints import BBOX_NORMALIZATION_SCALE
from src.schemas.detection import LocalizedFinding
def draw_view_localizations(
image: Image.Image,
image_index: int,
findings: list[LocalizedFinding],
) -> Image.Image:
annotated = image.copy()
draw = ImageDraw.Draw(annotated)
font = ImageFont.load_default()
width, height = annotated.size
for finding in findings:
for view_localization in finding.localizations:
if view_localization.image_index != image_index:
continue
for localization in view_localization.boxes:
y0, x0, y1, x1 = localization.box_2d
left = int(x0 / BBOX_NORMALIZATION_SCALE * width)
top = int(y0 / BBOX_NORMALIZATION_SCALE * height)
right = int(x1 / BBOX_NORMALIZATION_SCALE * width)
bottom = int(y1 / BBOX_NORMALIZATION_SCALE * height)
draw.rectangle(
[(left, top), (right, bottom)],
outline="red",
width=max(2, width // 256),
)
label = localization.label
text_bbox = draw.textbbox((left, top), label, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
label_top = max(0, top - text_height - 6)
draw.rectangle(
[
(left, label_top),
(left + text_width + 6, label_top + text_height + 6),
],
fill="black",
)
draw.text(
(left + 3, label_top + 3),
label,
fill="white",
font=font,
)
return annotated
def draw_segmentation_overlay(
base_images: list[Image.Image],
masks: list,
) -> list[Image.Image]:
"""Overlay polygon segmentation masks onto images, matching the MedSAM notebook style.
Draws a semi-transparent yellow polygon fill + outline for each successful
MaskResult. Falls back to a pixel-level mask blend when no polygon is available.
Also draws the bounding box that prompted the segmentation.
"""
result = [img.convert("RGBA") for img in base_images]
for mask_result in masks:
if mask_result.status != "success":
continue
idx = mask_result.image_index
if idx >= len(result):
continue
img = result[idx]
W, H = img.size
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
if mask_result.polygon and len(mask_result.polygon) >= 3:
pts = [(x, y) for x, y in mask_result.polygon]
# Yellow fill (251, 252, 30) @ ~60% opacity, bright outline
draw.polygon(pts, fill=(251, 252, 30, 153), outline=(251, 220, 0, 230))
elif mask_result.mask is not None:
# Pixel-level fallback: blend yellow where mask is 1
base_np = np.array(img, dtype=np.float32)
mask = mask_result.mask
if mask.shape != (H, W):
mask_pil = Image.fromarray((mask * 255).astype(np.uint8)).resize(
(W, H), Image.NEAREST
)
mask = (np.array(mask_pil) > 127).astype(np.uint8)
yellow = np.array([251, 252, 30, 255], dtype=np.float32)
alpha = 0.6
mask_bool = mask.astype(bool)
base_np[mask_bool, :3] = base_np[mask_bool, :3] * (1 - alpha) + yellow[:3] * alpha
img = Image.fromarray(base_np.clip(0, 255).astype(np.uint8), mode="RGBA")
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Bounding box in blue (matching the notebook)
y0_n, x0_n, y1_n, x1_n = mask_result.bbox_normalized
x0 = int(x0_n / BBOX_NORMALIZATION_SCALE * W)
y0 = int(y0_n / BBOX_NORMALIZATION_SCALE * H)
x1 = int(x1_n / BBOX_NORMALIZATION_SCALE * W)
y1 = int(y1_n / BBOX_NORMALIZATION_SCALE * H)
lw = max(2, W // 256)
draw.rectangle([(x0, y0), (x1, y1)], outline=(0, 100, 255, 255), width=lw)
result[idx] = Image.alpha_composite(img, overlay)
return [img.convert("RGB") for img in result]