How can I draw a bezier curve using Python's PIL? - python

I'm using Python's Imaging Library and I would like to draw some bezier curves.
I guess I could calculate pixel by pixel but I'm hoping there is something simpler.

def make_bezier(xys):
# xys should be a sequence of 2-tuples (Bezier control points)
n = len(xys)
combinations = pascal_row(n-1)
def bezier(ts):
# This uses the generalized formula for bezier curves
# http://en.wikipedia.org/wiki/B%C3%A9zier_curve#Generalization
result = []
for t in ts:
tpowers = (t**i for i in range(n))
upowers = reversed([(1-t)**i for i in range(n)])
coefs = [c*a*b for c, a, b in zip(combinations, tpowers, upowers)]
result.append(
tuple(sum([coef*p for coef, p in zip(coefs, ps)]) for ps in zip(*xys)))
return result
return bezier
def pascal_row(n, memo={}):
# This returns the nth row of Pascal's Triangle
if n in memo:
return memo[n]
result = [1]
x, numerator = 1, n
for denominator in range(1, n//2+1):
# print(numerator,denominator,x)
x *= numerator
x /= denominator
result.append(x)
numerator -= 1
if n&1 == 0:
# n is even
result.extend(reversed(result[:-1]))
else:
result.extend(reversed(result))
memo[n] = result
return result
This, for example, draws a heart:
from PIL import Image
from PIL import ImageDraw
if __name__ == '__main__':
im = Image.new('RGBA', (100, 100), (0, 0, 0, 0))
draw = ImageDraw.Draw(im)
ts = [t/100.0 for t in range(101)]
xys = [(50, 100), (80, 80), (100, 50)]
bezier = make_bezier(xys)
points = bezier(ts)
xys = [(100, 50), (100, 0), (50, 0), (50, 35)]
bezier = make_bezier(xys)
points.extend(bezier(ts))
xys = [(50, 35), (50, 0), (0, 0), (0, 50)]
bezier = make_bezier(xys)
points.extend(bezier(ts))
xys = [(0, 50), (20, 80), (50, 100)]
bezier = make_bezier(xys)
points.extend(bezier(ts))
draw.polygon(points, fill = 'red')
im.save('out.png')

A bezier curve isn't that hard to draw yourself. Given three points A, B, C you require three linear interpolations in order to draw the curve. We use the scalar t as the parameter for the linear interpolation:
P0 = A * t + (1 - t) * B
P1 = B * t + (1 - t) * C
This interpolates between two edges we've created, edge AB and edge BC. The only thing we now have to do to calculate the point we have to draw is interpolate between P0 and P1 using the same t like so:
Pfinal = P0 * t + (1 - t) * P1
There are a couple of things that need to be done before we actually draw the curve. First off we have will walk some dt (delta t) and we need to be aware that 0 <= t <= 1. As you might be able to imagine, this will not give us a smooth curve, instead it yields only a discrete set of positions at which to plot. The easiest way to solve this is to simply draw a line between the current point and the previous point.

You can use the aggdraw on top of PIL, bezier curves are supported.
EDIT:
I made an example only to discover there is a bug in the Path class regarding curveto :(
Here is the example anyway:
from PIL import Image
import aggdraw
img = Image.new("RGB", (200, 200), "white")
canvas = aggdraw.Draw(img)
pen = aggdraw.Pen("black")
path = aggdraw.Path()
path.moveto(0, 0)
path.curveto(0, 60, 40, 100, 100, 100)
canvas.path(path.coords(), path, pen)
canvas.flush()
img.save("curve.png", "PNG")
img.show()
This should fix the bug if you're up for recompiling the module...

Although bezier curveto paths don't work with Aggdraw, as mentioned by #ToniRuža, there is another way to do this in Aggdraw. The benefit of using Aggdraw instead of PIL or your own bezier functions is that Aggdraw will antialias the image making it look smoother (see pic at bottom).
Aggdraw Symbols
Instead of using the aggdraw.Path() class to draw, you can use the aggdraw.Symbol(pathstring) class which is basically the same except you write the path as a string. According to the Aggdraw docs the way to write your path as a string is to use SVG path syntax (see: http://www.w3.org/TR/SVG/paths.html). Basically, each addition (node) to the path normally starts with
a letter representing the drawing action (uppercase for absolute path, lowercase for relative path), followed by (no spaces in between)
the x coordinate (precede by a minus sign if it is a negative number or direction)
a comma
the y coordinate (precede by a minus sign if it is a negative number or direction)
In your pathstring just separate your multiple nodes with a space. Once you have created your symbol, just remember to draw it by passing it as one of the arguments to draw.symbol(args).
Bezier Curves in Aggdraw Symbols
Specifically for cubic bezier curves you write the letter "C" or "c" followed by 6 numbers (3 sets of xy coordinates x1,y1,x2,y2,x3,y3 with commas in between the numbers but not between the first number and the letter). According the docs there are also other bezier versions by using the letter "S (smooth cubic bezier), Q (quadratic bezier), T (smooth quadratic bezier)". Here is a complete example code (requires PIL and aggdraw):
print "initializing script"
# imports
from PIL import Image
import aggdraw
# setup
img = Image.new("RGBA", (1000,1000)) # last part is image dimensions
draw = aggdraw.Draw(img)
outline = aggdraw.Pen("black", 5) # 5 is the outlinewidth in pixels
fill = aggdraw.Brush("yellow")
# the pathstring:
#m for starting point
#c for bezier curves
#z for closing up the path, optional
#(all lowercase letters for relative path)
pathstring = " m0,0 c300,300,700,600,300,900 z"
# create symbol
symbol = aggdraw.Symbol(pathstring)
# draw and save it
xy = (20,20) # xy position to place symbol
draw.symbol(xy, symbol, outline, fill)
draw.flush()
img.save("testbeziercurves.png") # this image gets saved to same folder as the script
print "finished drawing and saved!"
And the output is a smooth-looking curved bezier figure:

I found a simpler way creating a bezier curve (without aggraw and without complex functions).
import math
from PIL import Image
from PIL import ImageDraw
image = Image.new('RGB',(1190,841),'white')
draw = ImageDraw.Draw(image)
curve_smoothness = 100
#First, select start and end of curve (pixels)
curve_start = [(167,688)]
curve_end = [(678,128)]
#Second, split the path into segments
curve = []
for i in range(1,curve_smoothness,1):
split = (curve_end[0][0] - curve_start[0][0])/curve_smoothness
x = curve_start[0][0] + split * i
curve.append((x, -7 * math.pow(10,-7) * math.pow(x,3) - 0.0011 * math.pow(x,2) + 0.235 * x + 682.68))
#Third, edit any other corners of polygon
other =[(1026,721), (167,688)]
#Finally, combine all parts of polygon into one list
xys = curve_start + curve + curve_end + other #putting all parts of the polygon together
draw.polygon(xys, fill = None, outline = 256)
image.show()

Related

Using bezier curves to draw a rectangle with rounded corners in PyMuPDF

I would like to use PyMuPDF to draw a rectangle with rounded corners in a pdf. Apparently, there are no particular methods for rounded rectangles. But I was wondering if Shape.draw_bezier() or Shape.draw_curve() could be used for that purpose, making a stroke that recovers the shape of the rectangle.
Interest question! This is possible with PyMuPDF, but currently still a bit clumsy to do. The basic approach I would suggest is
Define a normal rectangle within your final one should land.
At each of the four corner define 2 points ("helper points"), which will become the start and end of method draw_curve() (which you correctly identified yourself already - chapeau!).
Then start drawing. The shape will consist of 4 curves and 4 lines, every line followed by a curve, followed by a line ...
Here is the code:
import fitz
doc = fitz.open()
page = doc.new_page()
rect = fitz.Rect(100, 100, 300, 200)
d = 10 # controls how round the edges are
# make a shape to get properly connect points
shape = page.new_shape()
lp = shape.draw_line(rect.bl + (d, 0), rect.br - (d, 0))
lp = shape.draw_curve(lp, rect.br, rect.br - (0, d))
lp = shape.draw_line(lp, rect.tr + (0, d))
lp = shape.draw_curve(lp, rect.tr, rect.tr - (d, 0))
lp = shape.draw_line(lp, rect.tl + (d, 0))
lp = shape.draw_curve(lp, rect.tl, rect.tl + (0, d))
lp = shape.draw_line(lp, rect.bl - (0, d))
lp = shape.draw_curve(lp, rect.bl, rect.bl + (d, 0))
shape.finish(color=(1, 0, 0), fill=(1, 1, 0), closePath=False)
shape.commit()
doc.save(__file__.replace(".py", ".pdf"))
Gives you this:

How do I use Piecewise Affine Transformation to straighten curved text line/ contour?

Consider the following image:
and the following bounding contour( which is a smooth version of the output of a text-detection neural network of the above image ), so this contour is a given.
I need to warp both images so that I end up with a straight enough textline, so that it can be fed to a text recognition neural network:
using Piecewise Affine Transformation, or some other method. with an implementation if possible or key points of implementation in python.
I know how to find the medial axis, order its points, simplify it (e.g using Douglas-Peucker algorithm), and find the corresponding points on a straight line.
EDIT: the question can be rephrased -naively- as the following :
have you tried the "puppet warp" feature in Adobe Photoshop? you specify "joint" points on an image , and you move these points to the desired place to perform the image warping, we can calculate the source points using a simplified medial axis (e.g 20 points instead of 200 points), and calculate the corresponding target points on a straight line, how to perform Piecewise Affine Transformation using these two sets of points( source and target)?
EDIT: modified the images, my bad
Papers
Here's a paper that does the needed result:
A Novel Technique for Unwarping Curved Handwritten Texts Using Mathematical Morphology and Piecewise Affine Transformation
another paper: A novel method for straightening curved text-lines in stylistic documents
Similar questions:
Straighten B-Spline
Challenge : Curved text extraction using python
How to convert curves in images to lines in Python?
Deforming an image so that curved lines become straight lines
Straightening a curved contour
Full code also available in this notebook , runtime -> run all to reproduce the result.
import cv2
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
from scipy import interpolate
from scipy.spatial import distance
from shapely.geometry import LineString, GeometryCollection, MultiPoint
from skimage.morphology import skeletonize
from sklearn.decomposition import PCA
from warp import PiecewiseAffineTransform # https://raw.githubusercontent.com/TimSC/image-piecewise-affine/master/warp.py
# Helper functions
def extendline(line, length):
a = line[0]
b = line[1]
lenab = distance.euclidean(a, b)
cx = b[0] + ((b[0] - a[0]) / lenab * length)
cy = b[1] + ((b[1] - a[1]) / lenab * length)
return [cx, cy]
def XYclean(x, y):
xy = np.concatenate((x.reshape(-1, 1), y.reshape(-1, 1)), axis=1)
# make PCA object
pca = PCA(2)
# fit on data
pca.fit(xy)
# transform into pca space
xypca = pca.transform(xy)
newx = xypca[:, 0]
newy = xypca[:, 1]
# sort
indexSort = np.argsort(x)
newx = newx[indexSort]
newy = newy[indexSort]
# add some more points (optional)
f = interpolate.interp1d(newx, newy, kind='linear')
newX = np.linspace(np.min(newx), np.max(newx), 100)
newY = f(newX)
# #smooth with a filter (optional)
# window = 43
# newY = savgol_filter(newY, window, 2)
# return back to old coordinates
xyclean = pca.inverse_transform(np.concatenate((newX.reshape(-1, 1), newY.reshape(-1, 1)), axis=1))
xc = xyclean[:, 0]
yc = xyclean[:, 1]
return np.hstack((xc.reshape(-1, 1), yc.reshape(-1, 1))).astype(int)
def contour2skeleton(cnt):
x, y, w, h = cv2.boundingRect(cnt)
cnt_trans = cnt - [x, y]
bim = np.zeros((h, w))
bim = cv2.drawContours(bim, [cnt_trans], -1, color=255, thickness=cv2.FILLED) // 255
sk = skeletonize(bim > 0)
#####
skeleton_yx = np.argwhere(sk > 0)
skeleton_xy = np.flip(skeleton_yx, axis=None)
xx, yy = skeleton_xy[:, 0], skeleton_xy[:, 1]
skeleton_xy = XYclean(xx, yy)
skeleton_xy = skeleton_xy + [x, y]
return skeleton_xy
mm = cv2.imread('cont.png', cv2.IMREAD_GRAYSCALE)
plt.imshow(mm)
cnts, _ = cv2.findContours(mm.astype('uint8'), cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
cont = cnts[0].reshape(-1, 2)
# find skeleton
sk = contour2skeleton(cont)
mm = np.zeros_like(mm)
cv2.polylines(mm, [sk], False, 255, 2)
plt.imshow(mm)
# simplify the skeleton
ln = LineString(sk).simplify(2)
sk_simp = np.int0(ln.coords)
mm = np.zeros_like(mm)
for pt in sk_simp:
cv2.circle(mm, pt, 5, 255, -1)
plt.imshow(mm)
# extend both ends of the skeleton
print(len(sk_simp))
a, b = sk_simp[1], sk_simp[0]
c1 = np.int0(extendline([a, b], 50))
sk_simp = np.vstack([c1, sk_simp])
a, b = sk_simp[-2], sk_simp[-1]
c2 = np.int0(extendline([a, b], 50))
sk_simp = np.vstack([sk_simp, c2])
print(len(sk_simp))
cv2.circle(mm, c1, 10, 255, -1)
cv2.circle(mm, c2, 10, 255, -1)
plt.imshow(mm)
########
# find the target points
########
pts1 = sk_simp.copy()
dists = [distance.euclidean(p1, p2) for p1, p2 in zip(pts1[:-1], pts1[1:])]
zip1 = list(zip(pts1[:-1], dists))
# find the first 2 target points
a = pts1[0]
b = a - (dists[0], 0)
pts2 = [a, b, ]
for z in zip1[1:]:
lastpt = pts2[-1]
pt, dst = z
ln = [a, lastpt]
c = extendline(ln, dst)
pts2.append(c)
pts2 = np.int0(pts2)
ln1 = LineString(pts1)
ln2 = LineString(pts2)
GeometryCollection([ln1.buffer(5), ln2.buffer(5),
MultiPoint(pts2), MultiPoint(pts1)])
########
# create translated copies of source and target points
# 50 is arbitary
pts1 = np.vstack([pts1 + [0, 50], pts1 + [0, -50]])
pts2 = np.vstack([pts2 + [0, 50], pts2 + [0, -50]])
MultiPoint(pts1)
########
# performing the warping
im = Image.open('orig.png')
dstIm = Image.new(im.mode, im.size, color=(255, 255, 255))
# Perform transform
PiecewiseAffineTransform(im, pts1, dstIm, pts2)
plt.figure(figsize=(10, 10))
plt.imshow(dstIm)
1- find medial axis , e.g using skimage.morphology.skeletonize and simplify it ,e.g using shapely object.simplify , I used a tolerance of 2 , the medial axis points are in white:
2- find the corresponding points on a straight line, using the distance between each point and the next:
3 - also added extra points on the ends, colored blue, so that the points fit the entire contour length
4- create 2 copies of the source and target points, one copy translated up and the other translated down (I choose an offset of 50 here), so the source points are now like this, please note that simple upward/downward displacement may not be the best approach for all contours, e.g if the contour is curving with degrees > 45:
5- using the code here , perform PiecewiseAffineTransform using the source and target points, here's the result, it's straight enough:
If the goal is to just unshift each column, then:
import numpy as np
from PIL import Image
source_img = Image.open("73614379-input-v2.png")
contour_img = Image.open("73614379-map-v3.png").convert("L")
assert source_img.size == contour_img.size
contour_arr = np.array(contour_img) != 0 # convert to boolean array
col_offsets = np.argmax(
contour_arr, axis=0
) # find the first non-zero row for each column
assert len(col_offsets) == source_img.size[0] # sanity check
min_nonzero_col_offset = np.min(
col_offsets[col_offsets > 0]
) # find the minimum non-zero row
target_img = Image.new("RGB", source_img.size, (255, 255, 255))
for x, col_offset in enumerate(col_offsets):
offset = col_offset - min_nonzero_col_offset if col_offset > 0 else 0
target_img.paste(
source_img.crop((x, offset, x + 1, source_img.size[1])), (x, 0)
)
target_img.save("unshifted3.png")
with the new input and the new contour from OP outputs this image:

Reversing the translation of the Pillow Image Library Rotation

I'm a bit of newbie at this, trying to rotate an image in Python Pillow without changing the position of the centre of the rotated image. OR by the pillow rotate looks of things... returning the centre back to it's original spin location.
In Pillow (Image.py) there is a function that rotates an image. This function is as follows:-
def rotate(
self,
angle,
resample=NEAREST,
expand=0,
center=None,
translate=None,
fillcolor=None,
):
"""
Returns a rotated copy of this image. This method returns a
copy of this image, rotated the given number of degrees counter
clockwise around its centre.
:param angle: In degrees counter clockwise.
:param resample: An optional resampling filter. This can be
one of :py:attr:`PIL.Image.NEAREST` (use nearest neighbour),
:py:attr:`PIL.Image.BILINEAR` (linear interpolation in a 2x2
environment), or :py:attr:`PIL.Image.BICUBIC`
(cubic spline interpolation in a 4x4 environment).
If omitted, or if the image has mode "1" or "P", it is
set to :py:attr:`PIL.Image.NEAREST`. See :ref:`concept-filters`.
:param expand: Optional expansion flag. If true, expands the output
image to make it large enough to hold the entire rotated image.
If false or omitted, make the output image the same size as the
input image. Note that the expand flag assumes rotation around
the center and no translation.
:param center: Optional center of rotation (a 2-tuple). Origin is
the upper left corner. Default is the center of the image.
:param translate: An optional post-rotate translation (a 2-tuple).
:param fillcolor: An optional color for area outside the rotated image.
:returns: An :py:class:`~PIL.Image.Image` object.
"""
angle = angle % 360.0
# Fast paths regardless of filter, as long as we're not
# translating or changing the center.
if not (center or translate):
if angle == 0:
return self.copy()
if angle == 180:
return self.transpose(ROTATE_180)
if angle == 90 and expand:
return self.transpose(ROTATE_90)
if angle == 270 and expand:
return self.transpose(ROTATE_270)
# Calculate the affine matrix. Note that this is the reverse
# transformation (from destination image to source) because we
# want to interpolate the (discrete) destination pixel from
# the local area around the (floating) source pixel.
# The matrix we actually want (note that it operates from the right):
# (1, 0, tx) (1, 0, cx) ( cos a, sin a, 0) (1, 0, -cx)
# (0, 1, ty) * (0, 1, cy) * (-sin a, cos a, 0) * (0, 1, -cy)
# (0, 0, 1) (0, 0, 1) ( 0, 0, 1) (0, 0, 1)
# The reverse matrix is thus:
# (1, 0, cx) ( cos -a, sin -a, 0) (1, 0, -cx) (1, 0, -tx)
# (0, 1, cy) * (-sin -a, cos -a, 0) * (0, 1, -cy) * (0, 1, -ty)
# (0, 0, 1) ( 0, 0, 1) (0, 0, 1) (0, 0, 1)
# In any case, the final translation may be updated at the end to
# compensate for the expand flag.
w, h = self.size
if translate is None:
post_trans = (0, 0)
else:
post_trans = translate
if center is None:
# FIXME These should be rounded to ints?
rotn_center = (w / 2.0, h / 2.0)
else:
rotn_center = center
angle = -math.radians(angle)
matrix = [
round(math.cos(angle), 15),
round(math.sin(angle), 15),
0.0,
round(-math.sin(angle), 15),
round(math.cos(angle), 15),
0.0,
]
def transform(x, y, matrix):
(a, b, c, d, e, f) = matrix
return a * x + b * y + c, d * x + e * y + f
matrix[2], matrix[5] = transform(
-rotn_center[0] - post_trans[0], -rotn_center[1] - post_trans[1], matrix
)
matrix[2] += rotn_center[0]
matrix[5] += rotn_center[1]
if expand:
# calculate output size
xx = []
yy = []
for x, y in ((0, 0), (w, 0), (w, h), (0, h)):
x, y = transform(x, y, matrix)
xx.append(x)
yy.append(y)
nw = math.ceil(max(xx)) - math.floor(min(xx))
nh = math.ceil(max(yy)) - math.floor(min(yy))
# We multiply a translation matrix from the right. Because of its
# special form, this is the same as taking the image of the
# translation vector as new translation vector.
matrix[2], matrix[5] = transform(-(nw - w) / 2.0, -(nh - h) / 2.0, matrix)
w, h = nw, nh
return self.transform((w, h), AFFINE, matrix, resample, fillcolor=fillcolor)
This function also applies some translation (position shift) in order to keep the rotated images corners inside the image. The part of the code that applies the translation is this line
matrix[2], matrix[5] = transform(-(nw - w) / 2.0, -(nh - h) / 2.0, matrix)
what I would like to do is extract the values of matrix[2] and matrix[5] so that I can reverse this translation, when the rotation is called in moviepy.
To achieve something like this...
import moviepy.editor as mped
image_clip = mped.ImageClip("image.jpg", duration=3)
rotated_image = image_clip.rotate(20).set_position((pillow_rotate_x.
(-matrix[2]),pillow_rotate_y.(-matrix[5]))
So that it undoes the pillow translation, and returns the image centre to the place it was originally rotated at.
I was wondering how this could be achieved with the least code repetition ?
For example with the following code:-
import moviepy.editor as mped
import sys
import numpy as np
print("Python Version", sys.version)
baboon = mped.ImageClip("baboon.png", duration=3)
colour_clip = mped.ColorClip(size=[500, 50], color=np.array([250, 90, 0]).astype(np.uint8), duration=3) # important to use .astype(np.uint8)
cameraman = mped.ImageClip("cameraman.jpg", duration=3)
print("baboon_size", baboon.size)
print("colour_clip size", colour_clip.size)
print("cameraman size", cameraman.size)
rot_trans_col_clip = colour_clip.add_mask().rotate(20)
rot_trans_cameraman = cameraman.add_mask().rotate(20)
stacked_clips = mped.CompositeVideoClip([baboon, rot_trans_col_clip, rot_trans_cameraman])
stacked_clips.write_videofile('rotated_imagery_on_baboon.mp4', fps=5)
With the above code you can layer up some different types of content and rotate them.
The two input image files of the baboon and cameraman can be downloaded here:-
https://drive.google.com/file/d/17_s1IunwIAy1npJrsLRicieTG4NZYV4o/view?usp=sharing
https://drive.google.com/file/d/1G5YbApGX035-9mJtuz9GNgLr6jGywk-Z/view?usp=sharing
With the Translation code below (that is inside the pillow image.py file)
matrix[2], matrix[5] = transform(-(nw - w) / 2.0, -(nh - h) / 2.0, matrix)
the affect it has on the images is illustrated here:-
https://drive.google.com/file/d/1d_prYqb-fqizFcV0MD0rMXOIny2L0KW5/view?usp=sharing
You can see here that the centres of the two rotated images have been moved, so that their corners are still in view (not cropped).
Without the Pillow Translation code inside the pillow rotation function it looks like this:-
https://drive.google.com/file/d/17POoZcuk9QAxJrnwD2LFsYd--SXdR9JA/view?usp=sharing
You can see here that although the corners are a bit cropped, the centres of the images have not moved.
This is the outcome that I want. However, Pillow rotate applies a translation at the end.
Interestingly if you set expand=False on the pillow rotate:-
rot_trans_cameraman = cameraman.add_mask().rotate(20, unit='deg', expand=False)
you get this:-
https://drive.google.com/open?id=1QEzJN3NlWK_sjxPLGC_BNs2xfxxfhAIH
which has the same centre points. So it seems that without the expand flag set to false, the centre points move, however with it set to false, all the corners are cropped in symmetry.
The reason that this would be useful, is so that if you pass an angle to pillow rotate the outcome is deterministic, rather than also incorporating a translation that is dependent on the size of the images.
So my question is, how to restore the rotation centre locations ?
The answer to this question is here:-
https://github.com/python-pillow/Pillow/issues/4556
The new reference is after the expansion and centre reposition. This can then be used in the global system to reposition the element.

Given a start color and a middle color, how to get the remaining colors? (Python)

I am trying to build a color palette around 2 colors: teal and rose
I found this website: https://learnui.design/tools/data-color-picker.html#palette
Which could do half of what I was looking for so I wanted to try to do this in python using matplotlib, seaborn, palettable, and/or colorsys.
Is there a way to interpolate what the next colors would be given a series of colors in a gradient?
For example, from the website I gave the start_color and end_color. It gave me 6 colors ranging from start_color to end_color. Is there a way to do this but to make the end_color the middle_color and continue to gradient?
from palettable.cartocolors.diverging import TealRose_7
import matplotlib as mpl
import seaborn as sns
start_color = "#009392"
end_color = "#d0587e"
# https://learnui.design/tools/data-color-picker.html#palette
colors = ['#009392', '#0091b2', '#2b89c8', '#7c7ac6', '#b366ac', '#d0587e']
sns.palplot(colors)
I would like to make the teal start_color remain the first color, make the rose end_color the middle_color (in between 3 and 4), and then have the color palette finish to make 6 total colors.
I was going to try and get the RGB values and then do some type of modeling to figure out where it would go but I think there is probably an easier way to do this.
You can think of a color as a point in a color space which typically consists of three or four dimensions like RGB or HSL. To create a linear interpolation between two points in this space requires to simply follow the line created by these two points. Depending on the color space, you will get different continuation of colors.
Below, I use the matplotlib to display the palettes and colormath for the conversions which you can install by pip install colormath. This library makes this job much easier than it would be otherwise.
import colormath
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
from colormath.color_objects import sRGBColor, HSVColor, LabColor, LCHuvColor, XYZColor, LCHabColor
from colormath.color_conversions import convert_color
def hex_to_rgb_color(hex):
return sRGBColor(*[int(hex[i + 1:i + 3], 16) for i in (0, 2 ,4)], is_upscaled=True)
def plot_color_palette(colors, subplot, title, plt_count):
ax = fig.add_subplot(plt_count, 1, subplot)
for sp in ax.spines: ax.spines[sp].set_visible(False)
for x, color in enumerate(colors):
ax.add_patch(mpl.patches.Rectangle((x, 0), 0.95, 1, facecolor=color))
ax.set_xlim((0, len(colors)))
ax.set_ylim((0, 1))
ax.set_xticks([])
ax.set_yticks([])
ax.set_aspect("equal")
plt.title(title)
def create_palette(start_rgb, end_rgb, n, colorspace):
# convert start and end to a point in the given colorspace
start = convert_color(start_rgb, colorspace).get_value_tuple()
end = convert_color(end_rgb, colorspace).get_value_tuple()
# create a set of n points along start to end
points = list(zip(*[np.linspace(start[i], end[i], n) for i in range(3)]))
# create a color for each point and convert back to rgb
rgb_colors = [convert_color(colorspace(*point), sRGBColor) for point in points]
# finally convert rgb colors back to hex
return [color.get_rgb_hex() for color in rgb_colors]
start_color = "#009392"
end_color = "#d0587e"
number_of_colors = 10
colorspaces = (sRGBColor, HSVColor, LabColor, LCHuvColor, LCHabColor, XYZColor)
start_rgb = hex_to_rgb_color(start_color)
end_rgb = hex_to_rgb_color(end_color)
fig = plt.figure(figsize=(number_of_colors, len(colorspaces)), frameon=False)
for index, colorspace in enumerate(colorspaces):
palette = create_palette(start_rgb, end_rgb, number_of_colors, colorspace)
plot_color_palette(palette, index + 1, colorspace.__name__, len(colorspaces))
plt.subplots_adjust(hspace=1.5)
plt.show()
The basic idea of linear extrapolation is to simply extend the vector defined by the two colors. The biggest problem in doing that is when we hit the "walls" of the color space. For example, think of the color space RGB where Red goes from 0 to 255. What should happen after our interpolation line hits the 255 wall? A color cannot get any more red than red. One way I thought you can continue is to treat this line as a ray of light that can "bounce off" or "reflect" off the walls of the rgb space.
Interestingly, colormath doesn't seem to mind when the parameters of its color objects exceed their limits. It proceeds to create a color object with an invalid hex value. This can sometimes occur during extrapolation. To prevent this, we can either cap the value of the RGB:
rgb_colors = np.maximum(np.minimum(rgb, [1, 1, 1]), [0, 0, 0])
or have it "reflect" back off the wall so to speak.
rgb_colors = []
for color in rgb:
c = list(color)
for i in range(3):
if c[i] > 1:
c[i] = 2 - c[i]
if c[i] < 0:
c[i] *= -1
rgb_colors.append(c)
The equations above should be self explanatory. When an RGB channel drops below zero, flip its sign to "reflect" off of the zero wall, and similarly when it exceeds 1, reflect it back towards zero. Here are some extrapolation results using this method:
def create_palette(start_rgb, end_rgb, n, colorspace, extrapolation_length):
# convert start and end to a point in the given colorspace
start = np.array(convert_color(start_rgb, colorspace, observer=2).get_value_tuple())
mid = np.array(convert_color(end_rgb, colorspace, observer=2).get_value_tuple())
# extrapolate the end point
end = start + extrapolation_length * (mid - start)
# create a set of n points along start to end
points = list(zip(*[np.linspace(start[i], end[i], n) for i in range(3)]))
# create a color for each point and convert back to rgb
rgb = [convert_color(colorspace(*point), sRGBColor).get_value_tuple() for point in points]
# rgb_colors = np.maximum(np.minimum(rgb, [1, 1, 1]), [0, 0, 0])
rgb_colors = []
for color in rgb:
c = list(color)
for i in range(3):
if c[i] > 1:
c[i] = 2 - c[i]
if c[i] < 0:
c[i] *= -1
rgb_colors.append(c)
# finally convert rgb colors back to hex
return [sRGBColor(*color).get_rgb_hex() for color in rgb_colors]
start_color = "#009392"
end_color = "#d0587e"
number_of_colors = 11
colorspaces = (sRGBColor, HSVColor, LabColor, LCHuvColor, LCHabColor, XYZColor, LuvColor)
start_rgb = hex_to_rgb_color(start_color)
end_rgb = hex_to_rgb_color(end_color)
fig = plt.figure(figsize=(6, len(colorspaces)), frameon=False)
for index, colorspace in enumerate(colorspaces):
palette = create_palette(start_rgb, end_rgb, number_of_colors, colorspace, extrapolation_length=2)
plot_color_palette(palette, index + 1, colorspace.__name__, len(colorspaces))
plt.subplots_adjust(hspace=1.2)
plt.show()
Note that because Hue is a circular axis, in color spaces like HSV or HSL it wraps back around, and if you put your end-color at the middle of the palette, you are likely to return back near your start-color.
It's quiet fascinating to see the path these interpolations take in the color space. Take a look. Notice the effect bouncing off the walls creates.
I might at some point turn this into an open source project.
Here's a solution that just does a simple interpolation between colors in the RGB colorspace. There's a problem with that... euclidian distance between colors in RGB doesn't directly relate to human perception. So... if you really want to be a wonk (in a good way) about how your colors are perceived, you may want to move into Lab or HCL to do something like this.
These aren't the best refs, but they offer something about this phenomenon I think...
https://earthobservatory.nasa.gov/blogs/elegantfigures/2013/08/05/subtleties-of-color-part-1-of-6/
https://bl.ocks.org/mbostock/3014589
So... with that caveat out of the way... here's a solution in RGB, but doing it in Lab or HCL might be better. :)
Helpers/Setup
import numpy as np
# hex (string) to rgb (tuple3)
def hex2rgb(hex):
hex_cleaned = hex.lstrip('#')
return tuple(int(hex_cleaned[i:i+2], 16) for i in (0, 2 ,4))
# rgb (tuple3) to hex (string)
def rgb2hex(rgb):
return '#' + ''.join([str('0' + hex(hh)[2:])[-2:] for hh in rgb])
# weighted mix of two colors in RGB space (takes and returns hex values)
def color_mixer(hex1, hex2, wt1=0.5):
rgb1 = hex2rgb(hex1)
rgb2 = hex2rgb(hex2)
return rgb2hex(tuple([int(wt1 * tup[0] + (1.0 - wt1) * tup[1]) for tup in zip(rgb1, rgb2)]))
# create full palette
def create_palette(start_color, mid_color, end_color, num_colors):
# set up steps
# will create twice as many colors as asked for
# to allow an explicit "mid_color" with both even and odd number of colors
num_steps = num_colors
steps = np.linspace(0, 1, num_steps)[::-1]
# create two halves of color values
pt1 = [color_mixer(first_color, mid_color, wt) for wt in steps]
pt2 = [color_mixer(mid_color, last_color, wt) for wt in steps[1:]]
# combine and subsample to get back down to 'num_colors'
return (pt1 + pt2)[::2]
Create the Palette
# the 3 colors you specified
first_color = '#009392'
last_color = '#d0587e'
mid_color = color_mixer('#2b89c8', '#7c7ac6')
# create hex colors
result = create_pallette(first_color, mid_color, last_color, 5)
result
# ['#009392', '#298aac', '#5381c7', '#916ca2', '#d0587e']
which looks like this:
If you use RGB colors you can find the vector and scale it:
#009392 = (0, 147, 146)
#d0587e = (208, 88, 126)
# slope
(208, 88, 126) - (0, 147, 146) = (208, -59, -20)
k = 4
for n in range(1,k+1):
color = (0, 147, 146) + (n/k*(208, -59, -20))
eg (0, 147, 146) + (2/4*(208, -59, -20)) = (104, 117.5, 136)

Python - Find center of object in an image

I have an image file that's has a white background with a non-white object.
I want to find the center of the object using python (Pillow).
I have found a similar question in c++ but no acceptable answer - How can I find center of object?
Similar question, but with broken links in answer - What is the fastest way to find the center of an irregularly shaped polygon? (broken links in answer)
I also read this page but it doesn't give me a useful recipe - https://en.wikipedia.org/wiki/Smallest-circle_problem
Here's an example image:
Edit:
The current solution I'm using is this:
def find_center(image_file):
img = Image.open(image_file)
img_mtx = img.load()
top = bottom = 0
first_row = True
# First we find the top and bottom border of the object
for row in range(img.size[0]):
for col in range(img.size[1]):
if img_mtx[row, col][0:3] != (255, 255, 255):
bottom = row
if first_row:
top = row
first_row = False
middle_row = (top + bottom) / 2 # Calculate the middle row of the object
left = right = 0
first_col = True
# Scan through the middle row and find the left and right border
for col in range(img.size[1]):
if img_mtx[middle_row, col][0:3] != (255, 255, 255):
left = col
if first_col:
right = col
first_col = False
middle_col = (left + right) / 2 # Calculate the middle col of the object
return (middle_row, middle_col)
If you define center as Center of Mass, then it is not difficult, although the CoM can be outside of your shape. You can interpret your image as a 2D distribution, and you can find its expected value (CoM) using integration (summation).
If you have numpy it is quite simple. First create a numpy array containing 1 where your image is non-white, then to make it a probability distribution divide it by the total number of ones.
from PIL import Image
import numpy as np
im = Image.open('image.bmp')
immat = im.load()
(X, Y) = im.size
m = np.zeros((X, Y))
for x in range(X):
for y in range(Y):
m[x, y] = immat[(x, y)] != (255, 255, 255)
m = m / np.sum(np.sum(m))
From this point on it turns into basic probability theory. You find the marginal distributions, then you calculate the expected values as if it was a discrete probability distribution.
# marginal distributions
dx = np.sum(m, 1)
dy = np.sum(m, 0)
# expected values
cx = np.sum(dx * np.arange(X))
cy = np.sum(dy * np.arange(Y))
(cx, cy) is the CoM you are looking for.
Remarks:
If you do not have numpy, you can still do it. It is just a bit more tedious as you have to do the summations by loops / comprehensions.
This method can easily be extended if you want to assign a 'mass' based on color. You just have to change m[x, y] = immat[(x, y)] != (255, 255, 255) to m[x, y] = f(immat[(x, y)]) where f is an arbitary (non-negative valued) function.
If you want to avoid the double loop, you can us np.asarray(im), but then be careful with the indices
No loops:
m = np.sum(np.asarray(im), -1) < 255*3
m = m / np.sum(np.sum(m))
dx = np.sum(m, 0) # there is a 0 here instead of the 1
dy = np.sum(m, 1) # as np.asarray switches the axes, because
# in matrices the vertical axis is the main
# one, while in images the horizontal one is
# the first
I would try and find a way to draw a triangle around it, with one point of the triangle at the farthest "points" on the object, and then find the center of that triangle.

Categories