How Can I Add an Outline/Stroke/Border to a PNG Image with Pillow Library in Python?
Asked Answered
M

6

5

I am trying to use the Pillow (python-imaging-library) Python library in order to create an outline/stroke/border (with any color and width chosen) around my .png image. You can see here the original image and my wanted result (create by a phone app):

Example images

You can download the png file of the original image here: https://pixabay.com/illustrations/brain-character-organ-smart-eyes-1773885/

I have done it in the medium size(1280x1138) but maybe it is better to do it with the smallest size (640x569).

I tried to solve the problem with two methods.

METHOD ONE

The first method is to create a fully blacked image of the brain.png image, enlarge it, and paste the original colored brain image on top of it. Here is my code:

brain_black = Image.open("brain.png") #load brain image
width = brain_black.width #in order not to type a lot
height = brain_black.height #in order not to type a lot
rectangle = Image.new("RGBA", (width, height), "black") #creating a black rectangle in the size of the brain image
brain_black.paste(rectangle, mask=brain_black) #pasting on the brain image the black rectangle, and masking it with the brain picture

#now brain_black is the brain.png image, but all its pixels are black. Let's continue:

brain_black = brain_black.resize((width+180, height+180)) #resizing the brain_black by some factor
brain_regular = Image.open("brain.png") #load the brain image in order to paste later on
brain_black.paste(brain_regular,(90,90), mask=brain_regular) #paste the regular (colored) brain on top of the enlarged black brain (in x=90, y=90, the middle of the black brain)
brain_black.save("brain_method_resize.png") #saving the image

This method doesn't work, as you can see in the image link above. It might have worked for simple geometric shapes, but not for a complicated shape like this.

METHOD TWO

The second method is to load the brain image pixels data into a 2-dimensional array, and loop over all of the pixels. Check the color of every pixel, and in every pixel which is not transparent (means A(or Alpha) is not 0 in the rgbA form) to draw a black pixel in the pixel above, below, right, left, main diagonal down, main diagonal up, secondary diagonal (/) down and secondary diagonal (/) up. Then to draw a pixel in the second pixel above, the second pixel below and etc. this was done with a "for loop" where the number of repetitions is the wanted stroke width (in this example is 30). Here is my code:

brain=Image.open("brain.png") #load brain image
background=Image.new("RGBA", (brain.size[0]+400, brain.size[1]+400), (0, 0, 0, 0)) #crate a background transparent image to create the stroke in it
background.paste(brain, (200,200), brain) #paste the brain image in the middle of the background
pixelsBrain = brain.load() #load the pixels array of brain
pixelsBack=background.load() #load the pixels array of background

for i in range(brain.size[0]):
    for j in range(brain.size[1]):
        r, c = i+200, j+200 #height and width offset 
        if(pixelsBrain[i,j][3]!=0): #checking if the opacity is not 0, if the alpha is not 0.
            for k in range(30): #the loop
                pixelsBack[r, c + k] = (0, 0, 0, 255)
                pixelsBack[r, c - k] = (0, 0, 0, 255)
                pixelsBack[r + k, c] = (0, 0, 0, 255)
                pixelsBack[r - k, c] = (0, 0, 0, 255)
                pixelsBack[r + k, c + k] = (0, 0, 0, 255)
                pixelsBack[r - k, c - k] = (0, 0, 0, 255)
                pixelsBack[r + k, c - k] =(0, 0, 0, 255)
                pixelsBack[r - k, c + k] = (0, 0, 0, 255)

background.paste(brain, (200,200), brain) #pasting the colored brain onto the background, because the loop "destroyed" the picture.

background.save("brain_method_loop.png")

This method did work, but it is very time-consuming (takes about 30 seconds just for one picture and 30 pixels stroke). I want to do it for many pictures so this method is not good for me.

Is there an easier and better way to reach my wanted result using Python Pillow library. How can I do it? And also, how can I fasten my loop code (I understood something about Numpy and OpenCV, which is better for this purpose?)

I know that if a phone app could do it in a matter of milliseconds, also python can, but I didn't find any way to do it.

Thank you.

Mendelson answered 24/4, 2020 at 9:50 Comment(2)
Please share your original image rather than a screenshot of it combined with other things.Ouzel
Thank you Mark, I have just addedMendelson
H
7

I tried some solution similar with photoshop stroke effect using OpenCV (It is not perfect and I still finding better solution)

This algorithm is based on euclidean distance transform. I also tried dilation algorithm with ellipse kernel structure, it is bit different with photoshop, and there are some information that distance transform is the way that photoshop using.

def stroke(origin_image, threshold, stroke_size, colors):
    img = np.array(origin_image)
    h, w, _ = img.shape
    padding = stroke_size + 50
    alpha = img[:,:,3]
    rgb_img = img[:,:,0:3]
    bigger_img = cv2.copyMakeBorder(rgb_img, padding, padding, padding, padding, 
                                        cv2.BORDER_CONSTANT, value=(0, 0, 0, 0))
    alpha = cv2.copyMakeBorder(alpha, padding, padding, padding, padding, cv2.BORDER_CONSTANT, value=0)
    bigger_img = cv2.merge((bigger_img, alpha))
    h, w, _ = bigger_img.shape
    
    _, alpha_without_shadow = cv2.threshold(alpha, threshold, 255, cv2.THRESH_BINARY)  # threshold=0 in photoshop
    alpha_without_shadow = 255 - alpha_without_shadow
    dist = cv2.distanceTransform(alpha_without_shadow, cv2.DIST_L2, cv2.DIST_MASK_3)  # dist l1 : L1 , dist l2 : l2
    stroked = change_matrix(dist, stroke_size)
    stroke_alpha = (stroked * 255).astype(np.uint8)

    stroke_b = np.full((h, w), colors[0][2], np.uint8)
    stroke_g = np.full((h, w), colors[0][1], np.uint8)
    stroke_r = np.full((h, w), colors[0][0], np.uint8)

    stroke = cv2.merge((stroke_b, stroke_g, stroke_r, stroke_alpha))
    stroke = cv2pil(stroke)
    bigger_img = cv2pil(bigger_img)
    result = Image.alpha_composite(stroke, bigger_img)
    return result

def change_matrix(input_mat, stroke_size):
    stroke_size = stroke_size - 1
    mat = np.ones(input_mat.shape)
    check_size = stroke_size + 1.0
    mat[input_mat > check_size] = 0
    border = (input_mat > stroke_size) & (input_mat <= check_size)
    mat[border] = 1.0 - (input_mat[border] - stroke_size)
    return mat

def cv2pil(cv_img):
    cv_img = cv2.cvtColor(cv_img, cv2.COLOR_BGRA2RGBA)
    pil_img = Image.fromarray(cv_img.astype("uint8"))
    return pil_img
    
    
output = stroke(test_image, threshold=0, stroke_size=10, colors=((0,0,0),))

enter image description here

Highwrought answered 16/7, 2020 at 9:47 Comment(1)
here stroke_size argument is in pixels?Ave
O
3

I can't do a fully tested Python solution for you at the moment as I have other commitments, but I can certainly show you how to do it in a few milliseconds and give you some pointers.

I just used ImageMagick at the command line. It runs on Linux and macOS (use brew install imagemagick) and Windows. So, I extract the alpha/transparency channel and discard all the colour info. Then use a morphological "edge out" operation to generate a fat line around the edges of the shape in the alpha channel. I then invert the white edges so they become black and make all the white pixels transparent. Then overlay on top of the original image.

Here's the full command:

magick baby.png \( +clone -alpha extract -morphology edgeout octagon:9  -threshold 10% -negate -transparent white \) -flatten result.png

So that basically opens the image, messes about with a cloned copy of the alpha layer inside the parentheses and then flattens the black outline that results back onto the original image and saves it. Let's do the steps one at a time:

Extract the alpha layer as alpha.png:

magick baby.png -alpha extract alpha.png

enter image description here

Now fatten the edges, invert and make everything not black become transparent and save as overlay.png:

magick alpha.png -morphology edgeout octagon:9  -threshold 10% -negate -transparent white overlay.png

enter image description here

Here's the final result, change the octagon:9 to octagon:19 for fatter lines:

enter image description here


So, with PIL... you need to open the image and convert to RGBA, then split the channels. You don't need to touch the RGB channels just the A channel.

im = Image.open('baby.png').convert('RGBA')

R, G, B, A = im.split()

Some morphology needed here - see here.

Merge the original RGB channels with the new A channel and save:

result = Image.merge((R,G,B,modifiedA))
result.save('result.png')

Note that there are Python bindings to ImageMagick called wand and you may find it easier to translate my command-line stuff using that... wand. Also, scikit-image has an easy-to-use morphology suite too.

Ouzel answered 26/4, 2020 at 14:7 Comment(1)
This answer is the best! I needed to do it via command line, and it worked for me!Cassicassia
B
2

I've written this function which is based on morphological dilation and lets you set the stroke size and color. But it's EXTREMELY slow and it seems to not work great with small elements.

If anyone can help me speed it up it would be extremely helpful.

transparent png no stroke outline

transparent png with stroke outline

def addStroke(image,strokeSize=1,color=(0,0,0)):
    #Create a disc kernel
    kernel=[]
    kernelSize=math.ceil(strokeSize)*2+1 #Should always be odd
    kernelRadius=strokeSize+0.5
    kernelCenter=kernelSize/2-1
    pixelRadius=1/math.sqrt(math.pi)
    for x in range(kernelSize):
        kernel.append([])
        for y in range(kernelSize):
            distanceToCenter=math.sqrt((kernelCenter-x+0.5)**2+(kernelCenter-y+0.5)**2)
            if(distanceToCenter<=kernelRadius-pixelRadius):
                value=1 #This pixel is fully inside the circle
            elif(distanceToCenter<=kernelRadius):
                value=min(1,(kernelRadius-distanceToCenter+pixelRadius)/(pixelRadius*2)) #Mostly inside
            elif(distanceToCenter<=kernelRadius+pixelRadius):
                value=min(1,(pixelRadius-(distanceToCenter-kernelRadius))/(pixelRadius*2)) #Mostly outside
            else:
                value=0 #This pixel is fully outside the circle
            kernel[x].append(value)
    kernelExtent=int(len(kernel)/2)
    imageWidth,imageHeight=image.size
    outline=image.copy()
    outline.paste((0,0,0,0),[0,0,imageWidth,imageHeight])
    imagePixels=image.load()
    outlinePixels=outline.load()
    #Morphological grayscale dilation
    for x in range(imageWidth):
        for y in range(imageHeight):
            highestValue=0
            for kx in range(-kernelExtent,kernelExtent+1):
                for ky in range(-kernelExtent,kernelExtent+1):
                    kernelValue=kernel[kx+kernelExtent][ky+kernelExtent]
                    if(x+kx>=0 and y+ky>=0 and x+kx<imageWidth and y+ky<imageHeight and kernelValue>0):
                        highestValue=max(highestValue,min(255,int(round(imagePixels[x+kx,y+ky][3]*kernelValue))))
            outlinePixels[x,y]=(color[0],color[1],color[2],highestValue)
    outline.paste(image,(0,0),image)
    return outline
Bourguiba answered 8/7, 2020 at 10:22 Comment(0)
F
1

I found a way to do this using the ImageFilter module, it is much faster than any custom implementation that I've seen here and doesn't rely on resizing which doesn't work for convex hulls

from PIL import Image, ImageFilter

stroke_radius = 5
img = Image.open("img.png") # RGBA image
stroke_image = Image.new("RGBA", img.size, (255, 255, 255, 255))
img_alpha = img.getchannel(3).point(lambda x: 255 if x>0 else 0)
stroke_alpha = img_alpha.filter(ImageFilter.MaxFilter(stroke_radius))
# optionally, smooth the result
stroke_alpha = stroke_alpha.filter(ImageFilter.SMOOTH)
stroke_image.putalpha(stroke_alpha)
output = Image.alpha_composite(stroke_image, img)
output.save("output.png")
Flageolet answered 3/1, 2023 at 23:11 Comment(0)
B
0

Very simple and primitive solution: use PIL.ImageFilter.FIND_EDGES to find edge of drawing, it is about 1px thick, and draw a circle in every point of the edge. It is quite fast and require few libs, but has a disadvantage of no smoothing.

from PIL import Image, ImageFilter, ImageDraw
from pathlib import Path

def mystroke(filename: Path, size: int, color: str = 'black'):
    outf = filename.parent/'mystroke'
    if not outf.exists():
        outf.mkdir()
    img = Image.open(filename)
    X, Y = img.size
    edge = img.filter(ImageFilter.FIND_EDGES).load()
    stroke = Image.new(img.mode, img.size, (0,0,0,0))
    draw = ImageDraw.Draw(stroke)
    for x in range(X):
        for y in range(Y):
            if edge[x,y][3] > 0:
                draw.ellipse((x-size,y-size,x+size,y+size),fill=color)
    stroke.paste(img, (0, 0), img )
    # stroke.show()
    stroke.save(outf/filename.name)

if __name__ == '__main__':
    folder = Path.cwd()/'images'
    for img in folder.iterdir():
        if img.is_file(): mystroke(img, 10)
Basilicata answered 17/11, 2021 at 19:35 Comment(0)
D
0

Solution using PIL

I was facing the same need: outlining a PNG image.

Here is the input image:

Input image

I see that some solution have been found, but in case some of you want another alternative, here is mine:

Basically, my solution workflow is as follow:

  1. Read and fill the non-alpha chanel of the PNG image with the border color
  2. Resize the unicolor image to make it bigger
  3. Merge the original image to the bigger unicolor image

Here you go! You have an outlined PNG image with the width and color of your choice.


Here is the code implementing the workflow:

from PIL import Image

# Set the border and color
borderSize = 20
color = (255, 0, 0)
imgPath = "<YOUR_IMAGE_PATH>"

# Open original image and extract the alpha channel
im = Image.open(imgPath)
alpha = im.getchannel('A')

# Create red image the same size and copy alpha channel across
background = Image.new('RGBA', im.size, color=color)
background.putalpha(alpha) 

# Make the background bigger
background=background.resize((background.size[0]+borderSize, background.size[1]+borderSize))

# Merge the targeted image (foreground) with the background
foreground = Image.open(imgPath)
background.paste(foreground, (int(borderSize/2), int(borderSize/2)), foreground.convert("RGBA"))
imageWithBorder = background
imageWithBorder.show()

And here is the outputimage:

Output image

Hope it helps!

Decoupage answered 21/3, 2022 at 13:25 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.