Source code for coord2region.utils.image_utils

"""Utility functions for generating simple brain images.

This module currently exposes :func:`generate_mni152_image`, which creates a
static visualization of a spherical region overlaid on the MNI152 template
using Nilearn's plotting utilities. The resulting image is returned as PNG
bytes so it can be saved or embedded by callers without touching the
filesystem.
"""

from __future__ import annotations

from collections.abc import Sequence
from io import BytesIO

import nibabel as nib
import numpy as np
from nilearn.datasets import load_mni152_template
from nilearn.plotting import plot_stat_map
from PIL import Image, ImageDraw, ImageFont


[docs] def generate_mni152_image( coord: Sequence[float], radius: int = 6, cmap: str = "autumn", ) -> bytes: """Return a PNG image of a sphere drawn on the MNI152 template. Parameters ---------- coord : sequence of float MNI coordinate (x, y, z) in millimetres. radius : int, optional Radius of the sphere in millimetres. Defaults to ``6``. cmap : str, optional Matplotlib colormap used for the overlay. Defaults to ``"autumn"``. Returns ------- bytes PNG-encoded image bytes representing the sphere on the MNI152 template. """ template = load_mni152_template() data = np.zeros(template.shape, dtype=float) affine = template.affine # Convert the coordinate from mm space to voxel indices. voxel = nib.affines.apply_affine(np.linalg.inv(affine), coord) # Create a spherical mask around the coordinate. x, y, z = np.ogrid[: data.shape[0], : data.shape[1], : data.shape[2]] voxel_sizes = nib.affines.voxel_sizes(affine) radius_vox = radius / float(np.mean(voxel_sizes)) mask = ( (x - voxel[0]) ** 2 + (y - voxel[1]) ** 2 + (z - voxel[2]) ** 2 ) <= radius_vox**2 data[mask] = 1 img = nib.Nifti1Image(data, affine) display = plot_stat_map(img, bg_img=template, cmap=cmap, display_mode="ortho") buffer = BytesIO() display.savefig(buffer, format="png", bbox_inches="tight") display.close() buffer.seek(0) return buffer.getvalue()
[docs] def add_watermark( image_bytes: bytes, text: str = "AI approximation for illustrative purposes", ) -> bytes: """Overlay a semi-transparent watermark onto image bytes. Parameters ---------- image_bytes : bytes Original image encoded as bytes. text : str, optional Watermark text to overlay. Defaults to ``"AI approximation for illustrative purposes"``. Returns ------- bytes PNG-encoded image bytes with the watermark applied. """ base = Image.open(BytesIO(image_bytes)).convert("RGBA") width, height = base.size # Create transparent overlay for the text overlay = Image.new("RGBA", base.size, (255, 255, 255, 0)) draw = ImageDraw.Draw(overlay) # Choose a font size that covers much of the image width font_size = max(12, int(width * 0.05)) try: font = ImageFont.truetype("DejaVuSans-Bold.ttf", font_size) except Exception: font = ImageFont.load_default() # If using a scalable font, adjust size so text fits within image if hasattr(font, "getbbox"): while True: bbox = draw.textbbox((0, 0), text, font=font) text_width = bbox[2] - bbox[0] if text_width <= width * 0.9 or font_size <= 10: break font_size -= 2 try: font = ImageFont.truetype("DejaVuSans-Bold.ttf", font_size) except Exception: font = ImageFont.load_default() break bbox = draw.textbbox((0, 0), text, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] position = ((width - text_width) / 2, height - text_height - height * 0.05) draw.text(position, text, font=font, fill=(255, 255, 255, 128)) watermarked = Image.alpha_composite(base, overlay) out = BytesIO() watermarked.convert("RGB").save(out, format="PNG") out.seek(0) return out.getvalue()
[docs] def build_side_by_side_panel( left_image: bytes, right_image: bytes, *, left_title: str = "AI-generated approximation", right_title: str = "Nilearn reference", background_color: tuple[int, int, int] = (20, 20, 24), padding: int = 36, ) -> bytes: """Return a labelled side-by-side comparison panel. Parameters ---------- left_image, right_image : bytes PNG-encoded images for the left and right panels respectively. left_title, right_title : str, optional Captions rendered above the corresponding image. background_color : tuple[int, int, int], optional RGB colour applied to the canvas background. Defaults to a dark grey. padding : int, optional Padding (in pixels) surrounding the images and text. """ left = Image.open(BytesIO(left_image)).convert("RGB") right = Image.open(BytesIO(right_image)).convert("RGB") font_size = max(14, int(min(left.width, right.width) * 0.035)) try: font = ImageFont.truetype("DejaVuSans-Bold.ttf", font_size) except Exception: font = ImageFont.load_default() dummy = Image.new("RGB", (10, 10)) draw = ImageDraw.Draw(dummy) left_bbox = draw.textbbox((0, 0), left_title, font=font) right_bbox = draw.textbbox((0, 0), right_title, font=font) title_height = max(left_bbox[3] - left_bbox[1], right_bbox[3] - right_bbox[1]) spacer = padding // 2 total_width = left.width + right.width + padding * 3 total_height = max(left.height, right.height) + title_height + padding * 3 canvas = Image.new("RGB", (total_width, total_height), background_color) draw = ImageDraw.Draw(canvas) left_text_width = left_bbox[2] - left_bbox[0] right_text_width = right_bbox[2] - right_bbox[0] text_y = padding left_text_x = padding + max(0, (left.width - left_text_width) // 2) right_text_x = ( padding * 2 + left.width + max(0, (right.width - right_text_width) // 2) ) draw.text((left_text_x, text_y), left_title, fill=(235, 235, 245), font=font) draw.text((right_text_x, text_y), right_title, fill=(235, 235, 245), font=font) image_y = text_y + title_height + spacer canvas.paste(left, (padding, image_y)) canvas.paste(right, (padding * 2 + left.width, image_y)) out = BytesIO() canvas.save(out, format="PNG") out.seek(0) return out.getvalue()