| | """ |
| | Overlay Tool - Generates visual markers for biopsy sites and excision margins |
| | """ |
| |
|
| | import io |
| | import tempfile |
| | from typing import Tuple, Optional, Dict, Any |
| | from PIL import Image, ImageDraw, ImageFont |
| |
|
| |
|
| | class OverlayTool: |
| | """ |
| | Generates image overlays for clinical decision visualization: |
| | - Biopsy site markers (circles) |
| | - Excision margins (dashed outlines with margin indicators) |
| | """ |
| |
|
| | |
| | COLORS = { |
| | 'biopsy': (255, 69, 0, 200), |
| | 'excision': (220, 20, 60, 200), |
| | 'margin': (255, 215, 0, 180), |
| | 'text': (255, 255, 255, 255), |
| | 'text_bg': (0, 0, 0, 180), |
| | } |
| |
|
| | def __init__(self): |
| | self.loaded = True |
| |
|
| | def generate_biopsy_overlay( |
| | self, |
| | image: Image.Image, |
| | center_x: float, |
| | center_y: float, |
| | radius: float = 0.05, |
| | label: str = "Biopsy Site" |
| | ) -> Dict[str, Any]: |
| | """ |
| | Generate biopsy site overlay with circle marker. |
| | |
| | Args: |
| | image: PIL Image |
| | center_x: X coordinate as fraction (0-1) of image width |
| | center_y: Y coordinate as fraction (0-1) of image height |
| | radius: Radius as fraction of image width |
| | label: Text label for the marker |
| | |
| | Returns: |
| | Dict with overlay image and metadata |
| | """ |
| | |
| | img = image.convert("RGBA") |
| | width, height = img.size |
| |
|
| | |
| | overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) |
| | draw = ImageDraw.Draw(overlay) |
| |
|
| | |
| | cx = int(center_x * width) |
| | cy = int(center_y * height) |
| | r = int(radius * width) |
| |
|
| | |
| | for offset in range(3): |
| | draw.ellipse( |
| | [cx - r - offset, cy - r - offset, cx + r + offset, cy + r + offset], |
| | outline=self.COLORS['biopsy'], |
| | width=2 |
| | ) |
| |
|
| | |
| | line_len = r // 2 |
| | draw.line([(cx - line_len, cy), (cx + line_len, cy)], |
| | fill=self.COLORS['biopsy'], width=2) |
| | draw.line([(cx, cy - line_len), (cx, cy + line_len)], |
| | fill=self.COLORS['biopsy'], width=2) |
| |
|
| | |
| | try: |
| | font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 14) |
| | except: |
| | font = ImageFont.load_default() |
| |
|
| | text_bbox = draw.textbbox((0, 0), label, font=font) |
| | text_width = text_bbox[2] - text_bbox[0] |
| | text_height = text_bbox[3] - text_bbox[1] |
| |
|
| | text_x = cx - text_width // 2 |
| | text_y = cy + r + 10 |
| |
|
| | |
| | padding = 4 |
| | draw.rectangle( |
| | [text_x - padding, text_y - padding, |
| | text_x + text_width + padding, text_y + text_height + padding], |
| | fill=self.COLORS['text_bg'] |
| | ) |
| | draw.text((text_x, text_y), label, fill=self.COLORS['text'], font=font) |
| |
|
| | |
| | result = Image.alpha_composite(img, overlay) |
| |
|
| | |
| | temp_file = tempfile.NamedTemporaryFile(suffix="_biopsy_overlay.png", delete=False) |
| | result.save(temp_file.name, "PNG") |
| | temp_file.close() |
| |
|
| | return { |
| | "overlay": result, |
| | "path": temp_file.name, |
| | "type": "biopsy", |
| | "coordinates": { |
| | "center_x": center_x, |
| | "center_y": center_y, |
| | "radius": radius |
| | }, |
| | "label": label |
| | } |
| |
|
| | def generate_excision_overlay( |
| | self, |
| | image: Image.Image, |
| | center_x: float, |
| | center_y: float, |
| | lesion_radius: float, |
| | margin_mm: int = 5, |
| | pixels_per_mm: float = 10.0, |
| | label: str = "Excision Margin" |
| | ) -> Dict[str, Any]: |
| | """ |
| | Generate excision margin overlay with inner (lesion) and outer (margin) boundaries. |
| | |
| | Args: |
| | image: PIL Image |
| | center_x: X coordinate as fraction (0-1) |
| | center_y: Y coordinate as fraction (0-1) |
| | lesion_radius: Lesion radius as fraction of image width |
| | margin_mm: Excision margin in millimeters |
| | pixels_per_mm: Estimated pixels per mm (for margin calculation) |
| | label: Text label |
| | |
| | Returns: |
| | Dict with overlay image and metadata |
| | """ |
| | img = image.convert("RGBA") |
| | width, height = img.size |
| |
|
| | overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) |
| | draw = ImageDraw.Draw(overlay) |
| |
|
| | |
| | cx = int(center_x * width) |
| | cy = int(center_y * height) |
| | inner_r = int(lesion_radius * width) |
| |
|
| | |
| | margin_px = int(margin_mm * pixels_per_mm) |
| | outer_r = inner_r + margin_px |
| |
|
| | |
| | dash_length = 10 |
| | for angle in range(0, 360, dash_length * 2): |
| | draw.arc( |
| | [cx - outer_r, cy - outer_r, cx + outer_r, cy + outer_r], |
| | start=angle, |
| | end=angle + dash_length, |
| | fill=self.COLORS['margin'], |
| | width=3 |
| | ) |
| |
|
| | |
| | draw.ellipse( |
| | [cx - inner_r, cy - inner_r, cx + inner_r, cy + inner_r], |
| | outline=self.COLORS['excision'], |
| | width=2 |
| | ) |
| |
|
| | |
| | for angle in [0, 90, 180, 270]: |
| | import math |
| | rad = math.radians(angle) |
| | inner_x = cx + int(inner_r * math.cos(rad)) |
| | inner_y = cy + int(inner_r * math.sin(rad)) |
| | outer_x = cx + int(outer_r * math.cos(rad)) |
| | outer_y = cy + int(outer_r * math.sin(rad)) |
| | draw.line([(inner_x, inner_y), (outer_x, outer_y)], |
| | fill=self.COLORS['margin'], width=2) |
| |
|
| | |
| | try: |
| | font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 12) |
| | font_small = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 10) |
| | except: |
| | font = ImageFont.load_default() |
| | font_small = font |
| |
|
| | |
| | text_bbox = draw.textbbox((0, 0), label, font=font) |
| | text_width = text_bbox[2] - text_bbox[0] |
| | text_height = text_bbox[3] - text_bbox[1] |
| |
|
| | text_x = cx - text_width // 2 |
| | text_y = cy + outer_r + 15 |
| |
|
| | padding = 4 |
| | draw.rectangle( |
| | [text_x - padding, text_y - padding, |
| | text_x + text_width + padding, text_y + text_height + padding], |
| | fill=self.COLORS['text_bg'] |
| | ) |
| | draw.text((text_x, text_y), label, fill=self.COLORS['text'], font=font) |
| |
|
| | |
| | margin_label = f"{margin_mm}mm margin" |
| | margin_bbox = draw.textbbox((0, 0), margin_label, font=font_small) |
| | margin_width = margin_bbox[2] - margin_bbox[0] |
| |
|
| | margin_text_x = cx + outer_r + 5 |
| | margin_text_y = cy - 6 |
| |
|
| | draw.rectangle( |
| | [margin_text_x - 2, margin_text_y - 2, |
| | margin_text_x + margin_width + 2, margin_text_y + 12], |
| | fill=self.COLORS['text_bg'] |
| | ) |
| | draw.text((margin_text_x, margin_text_y), margin_label, |
| | fill=self.COLORS['margin'], font=font_small) |
| |
|
| | |
| | result = Image.alpha_composite(img, overlay) |
| |
|
| | temp_file = tempfile.NamedTemporaryFile(suffix="_excision_overlay.png", delete=False) |
| | result.save(temp_file.name, "PNG") |
| | temp_file.close() |
| |
|
| | return { |
| | "overlay": result, |
| | "path": temp_file.name, |
| | "type": "excision", |
| | "coordinates": { |
| | "center_x": center_x, |
| | "center_y": center_y, |
| | "lesion_radius": lesion_radius, |
| | "margin_mm": margin_mm, |
| | "total_radius": outer_r / width |
| | }, |
| | "label": label |
| | } |
| |
|
| | def generate_comparison_overlay( |
| | self, |
| | image1: Image.Image, |
| | image2: Image.Image, |
| | label1: str = "Previous", |
| | label2: str = "Current" |
| | ) -> Dict[str, Any]: |
| | """ |
| | Generate side-by-side comparison of two images for follow-up. |
| | |
| | Args: |
| | image1: First (previous) image |
| | image2: Second (current) image |
| | label1: Label for first image |
| | label2: Label for second image |
| | |
| | Returns: |
| | Dict with comparison image and metadata |
| | """ |
| | |
| | max_height = 400 |
| |
|
| | |
| | w1, h1 = image1.size |
| | w2, h2 = image2.size |
| |
|
| | ratio1 = max_height / h1 |
| | ratio2 = max_height / h2 |
| |
|
| | new_w1 = int(w1 * ratio1) |
| | new_w2 = int(w2 * ratio2) |
| |
|
| | img1 = image1.resize((new_w1, max_height), Image.Resampling.LANCZOS) |
| | img2 = image2.resize((new_w2, max_height), Image.Resampling.LANCZOS) |
| |
|
| | |
| | gap = 20 |
| | total_width = new_w1 + gap + new_w2 |
| | header_height = 30 |
| | total_height = max_height + header_height |
| |
|
| | canvas = Image.new("RGB", (total_width, total_height), (255, 255, 255)) |
| | draw = ImageDraw.Draw(canvas) |
| |
|
| | |
| | try: |
| | font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 14) |
| | except: |
| | font = ImageFont.load_default() |
| |
|
| | |
| | draw.rectangle([0, 0, new_w1, header_height], fill=(70, 130, 180)) |
| | bbox1 = draw.textbbox((0, 0), label1, font=font) |
| | text_w1 = bbox1[2] - bbox1[0] |
| | draw.text(((new_w1 - text_w1) // 2, 8), label1, fill=(255, 255, 255), font=font) |
| |
|
| | |
| | draw.rectangle([new_w1 + gap, 0, total_width, header_height], fill=(60, 179, 113)) |
| | bbox2 = draw.textbbox((0, 0), label2, font=font) |
| | text_w2 = bbox2[2] - bbox2[0] |
| | draw.text((new_w1 + gap + (new_w2 - text_w2) // 2, 8), label2, |
| | fill=(255, 255, 255), font=font) |
| |
|
| | |
| | canvas.paste(img1, (0, header_height)) |
| | canvas.paste(img2, (new_w1 + gap, header_height)) |
| |
|
| | |
| | draw.line([(new_w1 + gap // 2, header_height), (new_w1 + gap // 2, total_height)], |
| | fill=(200, 200, 200), width=2) |
| |
|
| | temp_file = tempfile.NamedTemporaryFile(suffix="_comparison.png", delete=False) |
| | canvas.save(temp_file.name, "PNG") |
| | temp_file.close() |
| |
|
| | return { |
| | "comparison": canvas, |
| | "path": temp_file.name, |
| | "type": "comparison" |
| | } |
| |
|
| |
|
| | def get_overlay_tool() -> OverlayTool: |
| | """Get overlay tool instance""" |
| | return OverlayTool() |
| |
|