Daniel Astudillo
Daniel Astudillo

Reputation: 13

How can I optimize drawing rings pixel by pixel in Python

I am working on an algorithm for drawing layered rings on a circle that represent different layers of the interior of a planet. My goal is that each layer will be painted pixel by pixel in a randomized distribution according to a proportion of the things that compose that layer, with each component having a different color.

I have managed to get a brute force solution for this, but I'm now trying to optimize it since it is taking a lot of time, here is the code for the first shell:

from PIL import Image, ImageDraw
import numpy as np

width = 1200
height = 800
proportion1 = (0.3,0.6,0.1) # 3 component proportions
colors = ["c1","c2","c3"] # 3 component tags
center_x = width/2
center_y = height/2
radius = 1737 # radius of the planet
core_radius = 300 # radius of the planet's core
core_pixels = round(core_radius*center_x/radius) # pixel radius of the core
first_shell = 600 # outer radius of the first ring
first_shell_pix= round(first_shell*center_x/radius) # pixel radius of first shell

im =  Image.new(mode="RGB",size=(width,height),color=(256,256,256)) # Setting up image

# Drawing first shell
for x in range(0,width):
    for y in range(0,height):
        # Check if pixel falls within the ring coordinates
        if (center_x-x)**2 + (center_y-y)**2 >core_pixels**2 and (center_x-x)**2 + (center_y-y)**2 <first_shell_pix**2:
            col=np.random.choice(colors,size=1,p=proportion1) # Get random tag
            if col =="c1":
                color = (0,100,0)
            elif col == "c2":
                color = (154,205,50)
            elif col == "c3":
                color = (255,140,0)

            im.putpixel((x,y),color) # Paint

The real pictures I want will have several layers and more pixels, and doing the double for loop through all the pixels for each shell would be incredibly inefficient. The only thing that I can think of is having a list with all the pixel coordinates and remove them as they get painted so that the next shell doesn't iterate through all of them, but there has to be a smarter way than that.

Any recommendations?

Upvotes: 1

Views: 145

Answers (3)

Mark Setchell
Mark Setchell

Reputation: 207425

I tried this by creating a complete array (height x width) of the 3 random colours in one call, then generating the mask annulus by drawing a thick white circle with OpenCV and then copying across the random colours inside the white annulus.

I got 950ms with Tim's code and 20ms with this method, so a 47x speedup. The code is a bit sloppy but you should get the idea:

#!/usr/bin/env python3

from PIL import Image
import numpy as np
import cv2

width = 1200
height = 800
proportion1 = (0.3,0.6,0.1) # 3 component proportions
colors = ["c1","c2","c3"] # 3 component tags
cmap = {'c1':(0,100,0),'c2':(154,205,50),'c3':(255,140,0)}
center_x = width//2
center_y = height//2
radius = 1737 # radius of the planet
core_radius = 300 # radius of the planet's core
core_pixels = round(core_radius*center_x/radius) # pixel radius of the core
first_shell = 600 # outer radius of the first ring
first_shell_pix= round(first_shell*center_x/radius) # pixel radius of first shell

def Tim():
    im = np.zeros((height,width,3), dtype=np.uint8)

    # Drawing first shell
    dcore = core_pixels**2
    dshell1 = first_shell_pix**2

    for x in range(-center_x,center_x):
        for y in range(-center_y,center_y):
            diam = x*x+y*y
            # Check if pixel falls within the ring coordinates
            if dcore < diam < dshell1:
                color=np.random.choice(colors,size=1,p=proportion1)[0] # Get random tag
                im[y+center_y,x+center_x] = cmap[color]
    
    im1 = Image.fromarray(im)
    #im1.save('result.png')
    return im1

def withOpenCV():
    # Don't want dictionary lookups, just palette indices
    # Generate complete random image in one go
    ncolours = 3
    random = np.random.choice(ncolours, size=(height,width), p=proportion1)
    
    # Describe properties of annulus
    centre_coordinates = (width//2, height//2)
    radius    = 200
    colour    = 255
    thickness = 100
    
    # Draw an annulus to use as mask of where we want to copy random image
    mask = np.zeros((height,width), np.uint8)
    mask = cv2.circle(mask, centre_coordinates, radius, colour, thickness)
    # DEBUG Image.fromarray(mask).show()

    # Create output image and copy from "random" where mask is white
    im = np.where(mask>0, random, 255).astype(np.uint8)
    # print(f'Mask: shape={mask.shape}, dtype={mask.dtype}')
    # print(f'im: shape={im.shape}, dtype={im.dtype}')
    res = Image.fromarray(im)
    res.putpalette([0,100,0,154,205,50,255,140,0] + [0]*253)
    return res

withOpenCV().show()

If you are not familiar with palette/index images they are very efficient when you have 255 colours or fewer and are described here.

Upvotes: 2

Tim Roberts
Tim Roberts

Reputation: 54698

Creating a numpy array and then converting it to image is somewhat faster. Maybe doing it through a palette could speed it up even more.

from PIL import Image
import numpy as np

width = 1200
height = 800
proportion1 = (0.3,0.6,0.1) # 3 component proportions
colors = ["c1","c2","c3"] # 3 component tags
cmap = {'c1':(0,100,0),'c2':(154,205,50),'c3':(255,140,0)}
center_x = width//2
center_y = height//2
radius = 1737 # radius of the planet
core_radius = 300 # radius of the planet's core
core_pixels = round(core_radius*center_x/radius) # pixel radius of the core
first_shell = 600 # outer radius of the first ring
first_shell_pix= round(first_shell*center_x/radius) # pixel radius of first shell

im = np.zeros((height,width,3), dtype=np.uint8)

# Drawing first shell
dcore = core_pixels**2
dshell1 = first_shell_pix**2

for x in range(-center_x,center_x):
    for y in range(-center_y,center_y):
        diam = x*x+y*y
        # Check if pixel falls within the ring coordinates
        if dcore < diam < dshell1:
            color=np.random.choice(colors,size=1,p=proportion1)[0] # Get random tag
            im[y+center_y,x+center_x] = cmap[color]

im1 = Image.fromarray(im)
im1.show()

Upvotes: 1

ti7
ti7

Reputation: 18792

I would be tempted to create a random array of the same dimensions (layer you're masking through to), dilate the ring you have to the desired dimensions, and then combine

Upvotes: 0

Related Questions