How to add a round border around an image?
Asked Answered
G

5

1

I have a rectangle image, and I would like to round its corners and then add a black border to it (so the border is also round).

Is there an easy way to achieve it?

That'd be the desired output:

enter image description here

Similar unanswered question

Globate answered 24/2, 2020 at 19:50 Comment(4)
Are you going to show us how it will look?Khosrow
@MarkSetchell I attached an exampleGlobate
That doesn't tell me if the black border is inside the limits of the original, or outside. I mean if the original image was 640x480 pixels, what are the dimensions of the resulting image? Also, is PIL a hard requirement? What if other libraries can do it more readily?Khosrow
@MarkSetchell the resulting image would be larger. So the original is fully contained within the frame. Anything Python is fine. I tried to simply create a full black image, which is larger. Have code that rounds the borders of both images. And pastes the original on the black. Seems too messy though.Globate
A
8

After some discussion with Mark in the comments on my first answer, I decided to make another solution using OpenCV and NumPy, which is able to easily feed some real images, e.g. photos, to the method and get the image including a border with rounded corners, and transparency outside the border!

import cv2
import numpy as np


def rect_with_rounded_corners(image, r, t, c):
    """
    :param image: image as NumPy array
    :param r: radius of rounded corners
    :param t: thickness of border
    :param c: color of border
    :return: new image as NumPy array with rounded corners
    """

    c += (255, )

    h, w = image.shape[:2]

    # Create new image (three-channel hardcoded here...)
    new_image = np.ones((h+2*t, w+2*t, 4), np.uint8) * 255
    new_image[:, :, 3] = 0

    # Draw four rounded corners
    new_image = cv2.ellipse(new_image, (int(r+t/2), int(r+t/2)), (r, r), 180, 0, 90, c, t)
    new_image = cv2.ellipse(new_image, (int(w-r+3*t/2-1), int(r+t/2)), (r, r), 270, 0, 90, c, t)
    new_image = cv2.ellipse(new_image, (int(r+t/2), int(h-r+3*t/2-1)), (r, r), 90, 0, 90, c, t)
    new_image = cv2.ellipse(new_image, (int(w-r+3*t/2-1), int(h-r+3*t/2-1)), (r, r), 0, 0, 90, c, t)

    # Draw four edges
    new_image = cv2.line(new_image, (int(r+t/2), int(t/2)), (int(w-r+3*t/2-1), int(t/2)), c, t)
    new_image = cv2.line(new_image, (int(t/2), int(r+t/2)), (int(t/2), int(h-r+3*t/2)), c, t)
    new_image = cv2.line(new_image, (int(r+t/2), int(h+3*t/2)), (int(w-r+3*t/2-1), int(h+3*t/2)), c, t)
    new_image = cv2.line(new_image, (int(w+3*t/2), int(r+t/2)), (int(w+3*t/2), int(h-r+3*t/2)), c, t)

    # Generate masks for proper blending
    mask = new_image[:, :, 3].copy()
    mask = cv2.floodFill(mask, None, (int(w/2+t), int(h/2+t)), 128)[1]
    mask[mask != 128] = 0
    mask[mask == 128] = 1
    mask = np.stack((mask, mask, mask), axis=2)

    # Blend images
    temp = np.zeros_like(new_image[:, :, :3])
    temp[(t-1):(h+t-1), (t-1):(w+t-1)] = image.copy()
    new_image[:, :, :3] = new_image[:, :, :3] * (1 - mask) + temp * mask

    # Set proper alpha channel in new image
    temp = new_image[:, :, 3].copy()
    new_image[:, :, 3] = cv2.floodFill(temp, None, (int(w/2+t), int(h/2+t)), 255)[1]

    return new_image


img = cv2.imread('path/to/your/image.png')
cv2.imshow('img', img)

new_img = rect_with_rounded_corners(img, 50, 20, (0, 0, 0))
cv2.imshow('new_img', new_img)

cv2.waitKey(0)
cv2.destroyAllWindows()

It's the same concept as used in my other answer with some more code on the correct transparency stuff.

Some exemplary input:

Input #1

The corresponding output:

Output #1

Another input and parameter set:

Input #2

new_img = rect_with_rounded_corners(img, 20, 10, (0, 0, 128))

Output:

Output #2

Hope that also helps!

----------------------------------------
System information
----------------------------------------
Platform:    Windows-10-10.0.16299-SP0
Python:      3.8.1
NumPy:       1.18.1
OpenCV:      4.2.0
----------------------------------------
Asleep answered 25/2, 2020 at 9:24 Comment(3)
Its returning rectangular image with white edges for me, Also is there any way to have no border thickness?Triplet
@Triplet I added small improvements so you can also set the border thickness to 0. CodeSoda
So if you look closely to the red border there is a thin black line at the bottom and right side of the image. What could cause this? Using this code I have the same problem with all my border rounded images.Boyt
K
4

I fancied my hand at drawing rounded rectangles with SVG for a change - not least because somebody thinks I always use ImageMagick ;-)

#!/usr/bin/env python3

from PIL import ImageOps, Image
from cairosvg import svg2png
from io import BytesIO

def frame(im, thickness=5):
    # Get input image width and height, and calculate output width and height
    iw, ih = im.size
    ow, oh = iw+2*thickness, ih+2*thickness

    # Draw outer black rounded rect into memory as PNG
    outer = f'<svg width="{ow}" height="{oh}" style="background-color:none"><rect rx="20" ry="20" width="{ow}" height="{oh}" fill="black"/></svg>'
    png   = svg2png(bytestring=outer)
    outer = Image.open(BytesIO(png))

    # Draw inner white rounded rect, offset by thickness into memory as PNG
    inner = f'<svg width="{ow}" height="{oh}"><rect x="{thickness}" y="{thickness}" rx="20" ry="20" width="{iw}" height="{ih}" fill="white"/></svg>'
    png   = svg2png(bytestring=inner)
    inner = Image.open(BytesIO(png)).convert('L')

    # Expand original canvas with black to match output size
    expanded = ImageOps.expand(im, border=thickness, fill=(0,0,0)).convert('RGB')

    # Paste expanded image onto outer black border using inner white rectangle as mask
    outer.paste(expanded, None, inner)
    return outer

# Open image, frame it and save
im = Image.open('monsters.jpg')
result = frame(im, thickness=10)
result.save('result.png')

Output Image

enter image description here

Input Image

enter image description here

You can play with rx and ry to change the radius of the corners.

Here are outer, inner and expanded - as you can see they are all the same size as each other for easy composing atop each other.

enter image description here

Other ideas:

  • You can also create a rounded corner by drawing a white rectangle in a black box and running a median filter, or some morphological erosion, over it. If you filter this:

enter image description here

with a 15x15 median filter, you get this:

enter image description here


Just in case anyone wants an ImageMagick solution:

#!/bin/bash

# Get width and height of input image
read iw ih < <(identify -format "%w %h" monsters.jpg)

# Calculate size of output image, assumes thickness=10
((ow=iw+20))
((oh=ih+20))

magick -size ${ow}x${oh} xc:none  -fill black -draw "roundrectangle 0,0 $ow,$oh 20,20" \
    \( -size ${iw}x${ih} xc:black -fill white -draw "roundrectangle 0,0,$iw,$ih 20,20" monsters.jpg -compose darken -composite \) \
       -gravity center -compose over -composite result.png

enter image description here

Keywords: Python, image processing, round corners, rounded corners, border, SVG, cairo, cairosvg, SVG to PNG, SVG as PNG, SVG to PIL, PIL, Pillow.

Khosrow answered 25/2, 2020 at 10:52 Comment(0)
A
2

Surely, Mark will provide a fancy solution using ImageMagick. But, since your question is tagged with Pillow, and other people might also looking for a solution, here's my manual implementation, because I doubt, that there's a ready in-built method for that:

from matplotlib import pyplot as plt        # Just for visualization
from PIL import Image, ImageDraw


def rect_with_rounded_corners(image, r, t, c):
    """
    :param image: PIL image, assumption: uni color filled rectangle
    :param r: radius of rounded corners
    :param t: thickness of border
    :param c: color of border
    :return: new PIL image of rectangle with rounded corners
    """

    # Some method to extract the main color of the rectangle needed here ...
    mc = img.getpixel((image.width/2, image.height/2))

    # Create new image
    new_image = Image.new(image.mode, (image.width + 2*t, image.height + 2*t), (255, 255, 255))
    draw = ImageDraw.Draw(new_image)

    # Draw four rounded corners
    draw.arc([(0, 0), (2*r-1, 2*r-1)], 180, 270, c, t)
    draw.arc([(image.width-2*r+2*t, 0), (image.width+2*t, 2*r-1)], 270, 0, c, t)
    draw.arc([(image.width-2*r+2*t, image.height-2*r+2*t), (image.width+2*t, image.height+2*t)], 0, 90, c, t)
    draw.arc([(0, image.height-2*r+2*t), (2*r-1, image.height+2*t)], 90, 180, c, t)

    # Draw four edges
    draw.line([(r-1, t/2-1), (image.width-r+2*t, t/2-1)], c, t)
    draw.line([(t/2-1, r-1), (t/2-1, image.height-r+2*t)], c, t)
    draw.line([(image.width+1.5*t, r-1), (image.width+1.5*t, image.height-r+2*t)], c, t)
    draw.line([(r-1, image.height+1.5*t), (image.width-r+2*t, image.height+1.5*t)], c, t)

    # Fill rectangle with main color
    ImageDraw.floodfill(new_image, (image.width/2+t, image.height/2+t), mc)

    return new_image


img = Image.new('RGB', (640, 480), (255, 128, 255))
plt.figure(1)
plt.imshow(img)

new_img = rect_with_rounded_corners(img, 100, 20, (0, 0, 0))
plt.figure(2)
plt.imshow(new_img)

plt.show()

Basically, it's calculating and manually drawing four arcs, four edges with the desired thickness and color of the border, and then flood filling the rectangle with the color of the initial rectangle. Put that in some method and re-use it as needed, so there's no mess in the main code.

For the stated image and parameter set, we get that output (Matplotlib figure here):

Orange

For another image and parameter set

img = Image.new('RGB', (400, 300), (0, 64, 255))
plt.figure(1)
plt.imshow(img)

new_img = rect_with_rounded_corners(img, 25, 10, (255, 0, 0))
plt.figure(2)
plt.imshow(new_img)

we get, for example:

Blue

Hope that helps!

----------------------------------------
System information
----------------------------------------
Platform:    Windows-10-10.0.16299-SP0
Python:      3.8.1
Matplotlib:  3.2.0rc3
Pillow:      7.0.0
----------------------------------------
Asleep answered 25/2, 2020 at 6:52 Comment(10)
That's pretty cool. I may indeed do an answer later, but I wonder if OP actually has a photo that he wants in the middle, rather than a solid colour. I was going to make the central rectangle white (where you have orange) and the frame black, then "compose" the original image over it with a darken blend mode so the darkest pixel is chosen at each point, i.e. the black pixels around the edge for the frame and whatever the OP's original image is in the centre because it must be darker than white. Also thinking of making rounded corners with SVG maybe. I rather like your approach though😀Khosrow
@MarkSetchell In fact, I haven't thought about photos... Then my idea would be the following: New white image, draw border as described above. Then, try to flood fill the alpha channel with 0, and do some blending with the original image (= photo). But I guess, for that, I'd rather use NumPy/OpenCV. But then everything else would be NumPy/OpenCV, too.Asleep
Exactly! It's a question with several possible tradeoffs... build as asked, or build more flexible, robust solution but introduce new dependencies and more complicated, defensive programming against something that may not be an issue😀Khosrow
Actually, OP's comment about "pasting the original on the black" makes me think it is a photo and his orange and black diagram is just a simplistic representation...Khosrow
@MarkSetchell I couldn't resist: See my other answer. ;-)Asleep
Good solution, but for this little adjustment matplotlib is too large libraryFifteenth
@RaihanKabir Please pay attention to the comment on the corresponding import command: Matplotlib is just used for visualization here! The implementation itself is solely done using Pillow.Asleep
@Asleep I am sorry! :D Thanks. I wasn't paying much attention.Fifteenth
Cool piece of code, but with this code the image gets replaced by one color. Could you make it that the image stays visible? (instead of a one coloured background)Boyt
@Asleep so I tried to figure it out, but I cannot seem to get the picture behind the rounded corners. Can you give me a help? So I do not want a one color background but the picture. Thank youBoyt
I
1

Here is one more approach using Python/OpenCV. However, in this approach, the border will be inside the bounds of the input image.

  • Read the input
  • Create a white image of the size of the input
  • Pad the white image with black all around of the desired border thickness
  • Apply Gaussian blur to the padded image
  • Threshold the blurred image to form a binary image
  • Erode the thresholded image to form a second binary image
  • Get the difference between the two binary images to form the border shaped mask
  • Shave the border mask by the thickness to get it back to size of the input image
  • Create a color image the size of the input
  • Combine the input and the color image using the mask
  • Put the first thresholded image into the alpha channel of the combined image to make the outside transparent
  • Save the results

Input:

enter image description here

import cv2
import numpy as np

# set thickness, rounding and color of border
t = 21
r = 21
c = (0,0,255)

# read image
img = cv2.imread("bear.png")
hh, ww = img.shape[0:2]

# create white image of size of input
white = np.full_like(img, (255,255,255))

# add black border of thickness
border = cv2.copyMakeBorder(white, t, t, t, t, borderType=cv2.BORDER_CONSTANT, value=(0,0,0))

# blur image by rounding amount as sigma
blur = cv2.GaussianBlur(border, (0,0), r, r)

# threshold blurred image
thresh1 = cv2.threshold(blur, 128, 255, cv2.THRESH_BINARY)[1]

# create thesh2 by eroding thresh1 by 2*t
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*t,2*t))
thresh2 = cv2.morphologyEx(thresh1, cv2.MORPH_ERODE, kernel, iterations=1)

# subtract the two thresholded images to make a border mask
mask = thresh1 - thresh2

# shave border mask by t
mask = mask[t:hh+t,t:ww+t]

# create colored image the same size as input
color = np.full_like(img, c)

# combine input and color with mask
result = cv2.bitwise_and(color, mask) + cv2.bitwise_and(img, 255-mask)

# add thresh1 as alpha channel
thresh1 = thresh1[t:hh+t,t:ww+t][:,:,0]
result = np.dstack([result,thresh1])

# write 
cv2.imwrite("bear_thresh1.png", thresh1)
cv2.imwrite("bear_thresh2.png", thresh2)
cv2.imwrite("bear_mask.png", mask)
cv2.imwrite("bear_red_border.png", result)

# display it
cv2.imshow("IMAGE", img)
cv2.imshow("BORDER", border)
cv2.imshow("BLUR", blur)
cv2.imshow("THRESHOLD1", thresh1)
cv2.imshow("THRESHOLD2", thresh2)
cv2.imshow("MASK", mask)
cv2.imshow("RESULT", result)
cv2.waitKey(0)

Threshold 1 image:

enter image description here

Threshold 2 image:

enter image description here

Border Mask image:

enter image description here

Result image:

enter image description here

ADDITION

Here is a correction to the above that permits more varied thickness and radius values. Example uses thickness of 21 and radius of 81.

import cv2
import numpy as np
import skimage.exposure

# set thickness, rounding and color of border
t = 21
r = 81
c = (0,0,255)

# read image
img = cv2.imread("bear.png")
hh, ww = img.shape[0:2]

# create white image of size of input
white = np.full_like(img, (255,255,255))

# add black border of thickness r
border = cv2.copyMakeBorder(white, r,r,r,r, borderType=cv2.BORDER_CONSTANT, value=(0,0,0)).astype(np.float64)

# blur image by rounding amount as sigma
blur = cv2.GaussianBlur(border, (0,0), r, r)

# threshold blurred image
thresh1 = cv2.threshold(blur, 128, 255, cv2.THRESH_BINARY)[1]

# create thesh2 by eroding thresh1 by 2*t
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*t,2*t))
thresh2 = cv2.morphologyEx(thresh1, cv2.MORPH_ERODE, kernel, iterations=1)

# subtract the two thresholded images to make a border mask
mask = thresh1 - thresh2

# antialias
mask = mask.astype(np.float64)
smooth = cv2.GaussianBlur(mask, (0,0), sigmaX=3, sigmaY=3, borderType = cv2.BORDER_DEFAULT)
mask = skimage.exposure.rescale_intensity(smooth, in_range=(96,160), out_range=(0,255))

# shave border mask by r
mask = mask[r:hh+r,r:ww+r]

# create colored image the same size as input
color = np.full_like(img, c)

# combine input and color with mask
#result = cv2.bitwise_and(color, mask) + cv2.bitwise_and(img, 255-mask)
result = ((color*mask + img*(255-mask))/255).clip(0,255).astype(np.uint8)

# add thresh1 as alpha channel
thresh1 = thresh1[r:hh+r,r:ww+r][:,:,0]
result = np.dstack([result,thresh1])

# write 
cv2.imwrite("bear2_thresh1.png", thresh1)
cv2.imwrite("bear2_thresh2.png", thresh2)
cv2.imwrite("bear2_mask.png", mask)
cv2.imwrite("bear2_red_border.png", result)

# display it
cv2.imshow("IMAGE", img)
cv2.imshow("BORDER", border)
cv2.imshow("BLUR", blur)
cv2.imshow("THRESHOLD1", thresh1)
cv2.imshow("THRESHOLD2", thresh2)
cv2.imshow("MASK", mask)
cv2.imshow("RESULT", result)
cv2.waitKey(0)

Result:

enter image description here

Ibnsina answered 26/2, 2020 at 3:48 Comment(2)
this works really well. However If you change the t var and the r var to two different int's you get strange outcomes. I would like a radius of 50 or even 80 and the border not thicker than 10 or 20. But that does not work.Boyt
See my ADDITION in my answer for a correction that permits your desired values.Ibnsina
R
1

Here is a simple implementation only using pillow :

from PIL import Image, ImageDraw

def add_round_border(
    image, border_color=(232, 232, 232), border_radius=30, border_width=3
):  
    image = image.convert("RGBA")
    # Create an out mask and an in mask
    mask = Image.new("L", image.size, 0)
    draw = ImageDraw.Draw(mask)
    draw.rounded_rectangle(
        [0, 0, image.size[0], image.size[1]], radius=border_radius, fill=255
    )
    mask_in = Image.new("L", image.size, 0)
    draw = ImageDraw.Draw(mask_in)
    draw.rounded_rectangle(
        [
            border_width,
            border_width,
            image.size[0] - border_width,
            image.size[1] - border_width,
        ],
        radius=border_radius - border_width,
        fill=255,
    )

    border_image = Image.new("RGBA", image.size, color=border_color)
    new_image = Image.new("RGBA", image.size, color=0)
    # Add the border by pasting the border images onto the new image
    new_image.paste(border_image, mask=mask)
    new_image.paste(image, mask=mask_in)
    return new_image

# Driver Code
image = Image.open("YOUR_IMAGE_PATH")
image_with_border = add_round_border(image, border_color="red", border_radius=30, border_width=15)
image.save("test.png")
image_with_border.save("test_with_border.png")

Before:

After:

Roxi answered 3/1 at 12:1 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.