pythonpython-imaging-libraryalpha

Why does this Python Pillow script not adhere to the alpha value I've specified to fade to the background colour?


I've been trying to write a script to draw some arcs which fade out to the background colour (see sample below).

from PIL import Image, ImageDraw
import math
import random


def draw_concentric_circles(size=800, num_circles=15):
    # Create a grey background
    bg_color = (0, 0, 0, 255)  # Dark grey
    img = Image.new('RGBA', (size, size), bg_color)
    draw = ImageDraw.Draw(img)

    center = size // 2
    max_radius = (size // 2) - 40
    radii = [int(r) for r in range(50, max_radius, max_radius // num_circles)]

    colors = [
        (232, 69, 76),  # Red
        (117, 176, 217)  # Blue
    ]

    for r in radii:
        base_color = random.choice(colors)
        # Random position for the break (in radians)
        break_pos = random.uniform(0, 2 * math.pi)
        gap_half_width = 0.05  # Size of the break

        # We draw the circle by connecting tiny segments
        # 1000 steps ensures the line looks smooth
        steps = 1000
        for i in range(steps):
            # Current angle
            angle = (i / steps) * 2 * math.pi

            # Calculate distance from the break to determine alpha
            # This logic finds the shortest angular distance to the break
            diff = abs(angle - break_pos)
            if diff > math.pi:
                diff = 2 * math.pi - diff

            # If we are inside the "break" zone, don't draw
            if diff < gap_half_width:
                continue

            # Normalize distance for fading:
            # Near the break (diff approx gap_half_width) -> Alpha 255 (100%)
            # Far from break (diff approx PI) -> Alpha 127 (50%)
            # Linear interpolation:
            alpha_ratio = 1.0 - (1.5 * (diff / math.pi))
            alpha = max(min(int(255 * alpha_ratio), 255), 0)

            # Calculate coordinates
            x = center + r * math.cos(angle)
            y = center + r * math.sin(angle)

            # Draw a tiny point/rectangle to represent the segment
            # We use a small offset to give the line some thickness
            thickness = 5
            draw.ellipse([x - thickness, y - thickness, x + thickness, y + thickness],
                         fill=(base_color[0], base_color[1], base_color[2], alpha), outline=None)

    print("Showing..")
    img.show()


draw_concentric_circles()

Gives me:

enter image description here


Solution

  • Draw does not blend the new pixels with the existing pixels. It replaces the existing (background) pixel values with the new pixel values.

    One way of solving it is having two images: a solid background; and a transparent foreground and then creating an alpha composite image from the two (also see the ImageDraw module documentation which gives an example of Drawing Partial-Opacity Text, using the same technique):

    from PIL import Image, ImageDraw
    import math
    import random
    
    
    def draw_concentric_circles(size=800, num_circles=15):
        # Create a grey background
        background_color = (0, 0, 0)  # Dark grey
        background = Image.new('RGBA', (size, size), background_color + (255,))
        foreground = Image.new('RGBA', (size, size), background_color + (0,))
        draw = ImageDraw.Draw(foreground)
    
        center = size // 2
        max_radius = (size // 2) - 40
        radii = [int(r) for r in range(50, max_radius, max_radius // num_circles)]
    
        colors = [
            (232, 69, 76),  # Red
            (117, 176, 217)  # Blue
        ]
    
        for r in radii:
            base_color = random.choice(colors)
            # Random position for the break (in radians)
            break_pos = random.uniform(0, 2 * math.pi)
            gap_half_width = 0.05  # Size of the break
    
            # We draw the circle by connecting tiny segments
            # 1000 steps ensures the line looks smooth
            steps = 1000
            for i in range(steps):
                # Current angle
                angle = (i / steps) * 2 * math.pi
    
                # Calculate distance from the break to determine alpha
                # This logic finds the shortest angular distance to the break
                diff = abs(angle - break_pos)
                if diff > math.pi:
                    diff = 2 * math.pi - diff
    
                # If we are inside the "break" zone, don't draw
                if diff < gap_half_width:
                    continue
    
                # Normalize distance for fading:
                # Near the break (diff approx gap_half_width) -> Alpha 255 (100%)
                # Far from break (diff approx PI) -> Alpha 127 (50%)
                # Linear interpolation:
                alpha_ratio = 1.0 - (1.5 * (diff / math.pi))
                alpha = max(min(int(255 * alpha_ratio), 255), 0)
    
                # Calculate coordinates
                x = center + r * math.cos(angle)
                y = center + r * math.sin(angle)
    
                # Draw a tiny point/rectangle to represent the segment
                # We use a small offset to give the line some thickness
                thickness = 5
                draw.ellipse([x - thickness, y - thickness, x + thickness, y + thickness],
                             fill=(base_color[0], base_color[1], base_color[2], alpha), outline=None)
    
        print("Showing..")
        image = Image.alpha_composite(background, foreground)
        image.show()
    
    
    draw_concentric_circles()
    

    Which outputs:

    Concentric circles fading into the background