I am using OpenCV-Python to fit an ellipse to the shape of a droplet.
Then I choose a line, which represents the surface the droplet is resting on.
I calculate the tangents at the intersection of the surface and the ellipse to get the contact angle of the droplet.
It works most of the time, but in some cases, the tangents are flipped upside down or just wrong.
It seems that the calculation for the slope of the tangent fails.
Can someone tell me why this happens?
Here you can see how it should look like (surface at y=250):
And this is the result when I choose a surface level of y=47:
I did some research and I need to detect which of the two maj_ax, min_ax was parallel to the x-Axis before the ellipse gets rotated by phi or else the slope calculation algorithm fails.
What am I doing wrong?
Here is a minimal reproducible example:
from math import cos, sin, pi, sqrt, tan, atan2, radians
import cv2
class Droplet():
def __init__(self):
self.is_valid = False
self.angle_l = 0
self.angle_r = 0
self.center = (0,0)
self.maj = 0
self.min = 0
self.phi = 0.0
self.tilt_deg = 0
self.foc_pt1 = (0,0)
self.foc_pt2 = (0,0)
self.tan_l_m = 0
self.int_l = (0,0)
self.line_l = (0,0,0,0)
self.tan_r_m = 0
self.int_r = (0,0)
self.line_r = (0,0,0,0)
self.base_diam = 0
def evaluate_droplet(img, y_base) -> Droplet:
drplt = Droplet()
crop_img = img[:y_base,:]
shape = img.shape
height = shape[0]
width = shape[1]
# values only for 8bit images!
bw_edges = cv2.Canny(crop_img, 76, 179)
contours, hierarchy = cv2.findContours(bw_edges, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
if len(contours) == 0:
raise ValueError('No contours found!')
edge = max(contours, key=cv2.contourArea)
(x0,y0),(maj_ax,min_ax),phi_deg = cv2.fitEllipse(edge)
phi = radians(phi_deg) # to radians
a = maj_ax/2
b = min_ax/2
intersection = calc_intersection_line_ellipse((x0,y0,a,b,phi),(0,y_base))
if intersection is None:
raise ValueError('No intersections found')
# select left and right intersection points
x_int_l = min(intersection)
x_int_r = max(intersection)
foc_len = sqrt(abs(a**2 - b**2))
# calc slope and angle of tangent
m_t_l = calc_slope_of_ellipse((x0,y0,a,b,phi), x_int_l, y_base)
angle_l = pi - atan2(m_t_l,1)
m_t_r = calc_slope_of_ellipse((x0,y0,a,b,phi), x_int_r, y_base)
angle_r = atan2(m_t_r,1) + pi
drplt.angle_l = angle_l
drplt.angle_r = angle_r
drplt.maj = maj_ax
drplt.min = min_ax
drplt.center = (x0, y0)
drplt.phi = phi
drplt.tilt_deg = phi_deg
drplt.tan_l_m = m_t_l
drplt.tan_r_m = m_t_r
drplt.line_l = (int(round(x_int_l - (int(round(y_base))/m_t_l))), 0, int(round(x_int_l + ((height - int(round(y_base)))/m_t_l))), int(round(height)))
drplt.line_r = (int(round(x_int_r - (int(round(y_base))/m_t_r))), 0, int(round(x_int_r + ((height - int(round(y_base)))/m_t_r))), int(round(height)))
drplt.int_l = (x_int_l, y_base)
drplt.int_r = (x_int_r, y_base)
drplt.foc_pt1 = (x0 + foc_len*cos(phi), y0 + foc_len*sin(phi))
drplt.foc_pt2 = (x0 - foc_len*cos(phi), y0 - foc_len*sin(phi))
drplt.base_diam = x_int_r - x_int_l
drplt.is_valid = True
# draw ellipse and lines
img = cv2.drawContours(img,contours,-1,(100,100,255),2)
img = cv2.drawContours(img,edge,-1,(255,0,0),2)
img = cv2.ellipse(img, (int(round(x0)),int(round(y0))), (int(round(a)),int(round(b))), int(round(phi*180/pi)), 0, 360, (255,0,255), thickness=1, lineType=cv2.LINE_AA)
y_int = int(round(y_base))
img = cv2.line(img, (int(round(x_int_l - (y_int/m_t_l))), 0), (int(round(x_int_l + ((height - y_int)/m_t_l))), int(round(height))), (255,0,255), thickness=1, lineType=cv2.LINE_AA)
img = cv2.line(img, (int(round(x_int_r - (y_int/m_t_r))), 0), (int(round(x_int_r + ((height - y_int)/m_t_r))), int(round(height))), (255,0,255), thickness=1, lineType=cv2.LINE_AA)
img = cv2.ellipse(img, (int(round(x_int_l)),y_int), (20,20), 0, 0, -int(round(angle_l*180/pi)), (255,0,255), thickness=1, lineType=cv2.LINE_AA)
img = cv2.ellipse(img, (int(round(x_int_r)),y_int), (20,20), 0, 180, 180 + int(round(angle_r*180/pi)), (255,0,255), thickness=1, lineType=cv2.LINE_AA)
img = cv2.line(img, (0,y_int), (width, y_int), (255,0,0), thickness=2, lineType=cv2.LINE_AA)
img = cv2.putText(img, '<' + str(round(angle_l*180/pi,1)), (5,y_int-5), cv2.FONT_HERSHEY_COMPLEX, .5, (0,0,0))
img = cv2.putText(img, '<' + str(round(angle_r*180/pi,1)), (width - 80,y_int-5), cv2.FONT_HERSHEY_COMPLEX, .5, (0,0,0))
cv2.imshow('Test',img)
cv2.waitKey(0)
return drplt
def calc_intersection_line_ellipse(ellipse_pars, line_pars):
"""
calculates intersection(s) of an ellipse with a line
:param ellipse_pars: tuple of (x0,y0,a,b,phi): x0,y0 center of ellipse; a,b sem-axis of ellipse; phi tilt rel to x axis
:param line_pars: tuple of (m,t): m is the slope and t is intercept of the intersecting line
:returns: x-coordinate(s) of intesection as list or float or none if none found
"""
## -->> http://quickcalcbasic.com/ellipse%20line%20intersection.pdf
(x0, y0, h, v, phi) = ellipse_pars
(m, t) = line_pars
y = t - y0
try:
a = v**2 * cos(phi)**2 + h**2 * sin(phi)**2
b = 2*y*cos(phi)*sin(phi) * (v**2 - h**2)
c = y**2 * (v**2 * sin(phi)**2 + h**2 * cos(phi)**2) - (h**2 * v**2)
det = b**2 - 4*a*c
if det > 0:
x1 = int(round((-b - sqrt(det))/(2*a) + x0))
x2 = int(round((-b + sqrt(det))/(2*a) + x0))
return x1,x2
elif det == 0:
x = int(round(-b / (2*a)))
return x
else:
return None
except Exception as ex:
raise ex
def calc_slope_of_ellipse(ellipse_pars, x, y):
"""
calculates the slope of the tangent at point x,y, the point needs to be on the ellipse!
:param ellipse_params: tuple of (x0,y0,a,b,phi): x0,y0 center of ellipse; a,b sem-axis of ellipse; phi tilt rel to x axis
:param x: x-coord where the slope will be calculated
:returns: the slope of the tangent
"""
(x0, y0, a, b, phi) = ellipse_pars
# transform to non-rotated ellipse
x_rot = (x - x0)*cos(phi) + (y - y0)*sin(phi)
y_rot = (x - x0)*sin(phi) + (y - y0)*cos(phi)
m_rot = -(b**2 * x_rot)/(a**2 * y_rot) # slope of tangent to unrotated ellipse
#rotate tangent line back to angle of the rotated ellipse
m_tan = tan(atan2(m_rot,1) + phi)
return m_tan
if __name__ == "__main__":
im = cv2.imread('untitled1.png')
# any value below 250 is just the droplet without the substrate
drp = evaluate_droplet(im, 250)
Original image:
I made a mistake in calc_slope_ellipse:
x_rot = (x - x0)*cos(phi) + (y - y0)*sin(phi)
should be
x_rot = (x - x0)*cos(phi) - (y - y0)*sin(phi)
this fixes the wrong sign of the slope at y=47.
I replaced the atan2:
m_rot = -(b**2 * x_rot)/(a**2 * y_rot) # slope of tangent to unrotated ellipse
#rotate tangent line back to angle of the rotated ellipse
m_tan = tan(atan2(m_rot,1) + phi)
with
tan_a = x_rot/a**2
tan_b = y_rot/b**2
#rotate tangent line back to angle of the rotated ellipse
tan_a_r = tan_a*cos(phi) + tan_b*sin(phi)
tan_b_r = tan_b*cos(phi) - tan_a*sin(phi)
m_tan = - (tan_a_r / tan_b_r)
This fixes the weird behaviour for certain cases (y=62).
Complete fcn:
def calc_slope_of_ellipse(ellipse_pars, x, y):
(x0, y0, a, b, phi) = ellipse_pars
# transform to non-rotated ellipse centered to origin
x_rot = (x - x0)*cos(phi) - (y - y0)*sin(phi)
y_rot = (x - x0)*sin(phi) + (y - y0)*cos(phi)
# Ax + By = C
tan_a = x_rot/a**2
tan_b = y_rot/b**2
#rotate tangent line back to angle of the rotated ellipse
tan_a_r = tan_a*cos(phi) + tan_b*sin(phi)
tan_b_r = tan_b*cos(phi) - tan_a*sin(phi)
m_tan = - (tan_a_r / tan_b_r)
return m_tan
In python, I have written some code that generates a circle using Bresenham's Midpoint Algorithm:
from PIL import Image, ImageDraw
radius = 100 #radius of circle
xpts = [] #array to hold x pts
ypts = [] #array to hold y pts
img = Image.new('RGB', (1000, 1000))
draw = ImageDraw.Draw(img) #to use draw.line()
pixels = img.load()
d = (5/4) - radius
x = 0
y = radius
xpts.append(x) #initial x value
ypts.append(y) #initial y value
while x < y:
if d < 0:
d += (2*x + 3)
x += 1
xpts.append(x + 500) #translate points to center by 500px
ypts.append(y - 500)
else:
d += (2 * (x - y) + 5)
x += 1
y -= 1
xpts.append(x + 500) #translate points to center by 500px
ypts.append(y - 500)
for i in range(len(xpts)): #draw initial and reflected octant points
pixels[xpts[i] ,ypts[i]] = (255,255,0) #initial octant
pixels[xpts[i],-ypts[i]] = (255,255,0)
pixels[-xpts[i],ypts[i]] = (255,255,0)
pixels[-xpts[i],-ypts[i]] = (255,255,0)
pixels[ypts[i],xpts[i]] = (255,255,0)
pixels[-ypts[i],xpts[i]] = (255,255,0)
pixels[ypts[i],-xpts[i]] = (255,255,0)
pixels[-ypts[i],-xpts[i]] = (255,255,0)
img.show()
To fill it, I had planned to use ImageDraw to draw a line horizontally within the circle from each point that is generated from the initial octant using draw.line(). I have the x and y coordinates stored in arrays. However, I am stuck interpreting each point and its reflection point to draw the horizontal line using draw.line(). Could someone clarify this?
Instead of drawing individual pixels, you would just add a line that connects the pixels corresponding to each other (either -x and +x or -y and +y). For each Bresenham step, you draw four lines (each connecting two octants).
Here is your adapted sample code. I dropped the points array and instead drew the lines directly. I also added the cx and cy variables that define the circle center. In your code, you sometimes used negative indices. This only works by coincidence because the circle is in the center:
from PIL import Image, ImageDraw
radius = 100 # radius of circle
xpts = [] # array to hold x pts
ypts = [] # array to hold y pts
img = Image.new('RGB', (1000, 1000))
draw = ImageDraw.Draw(img) # to use draw.line()
pixels = img.load()
d = (5 / 4) - radius
x = 0
y = radius
cx = 500
cy = 500
def draw_scanlines(x, y):
color = (255, 255, 0)
draw.line((cx - x, cy + y, cx + x, cy + y), fill=color)
draw.line((cx - x, cy - y, cx + x, cy - y), fill=color)
draw.line((cx - y, cy + x, cx + y, cy + x), fill=color)
draw.line((cx - y, cy - x, cx + y, cy - x), fill=color)
draw_scanlines(x, y)
while x < y:
if d < 0:
d += (2 * x + 3)
x += 1
else:
d += (2 * (x - y) + 5)
x += 1
y -= 1
draw_scanlines(x, y)
img.show()
Instead of drawing lines, you can fill all points inside a circle with radius radius in O(n^2) using:
# Your code here
for x in range(radius):
for y in range(radius):
if x**2 + y**2 < radius**2:
pixels[ x + 500 , y-500] = (255,255,0)
pixels[ x + 500 , -y-500] = (255,255,0)
pixels[ -x + 500 , y-500] = (255,255,0)
pixels[ -x + 500 , -y-500] = (255,255,0)
img.show()
I would like to draw a rotated rectangle I've got the top left point and bottom right point, width and height of box. As well as the angle. But I can't seem work out how you draw the rotated rectangle using OpenCV in Python. Please note that I do not want to rotate the image.
Thanks
There are many ways to draw a rectangle in OpenCV.
From the OpenCV documentatation: Drawing Functions
rectangle
Draws a simple, thick, or filled up-right rectangle.
So this function doesn't help as you want to draw it rotated.
A rectangle is nothing but a special 4-sided polygon. So simply use the function for drawing polygons instead.
polylines
Draws several polygonal curves.
Python:
cv2.polylines(img, pts, isClosed, color[, thickness[, lineType[, shift]]]) → img
and insert the 4 vertices of your rotated rectangle
or draw the 4 sides separately using
line
Draws a line segment connecting two points.
or
drawContours
Draws contours outlines or filled contours.
The points can be obtained using simple math or for example using OpenCV's RotatedRect https://docs.opencv.org/2.4/modules/core/doc/basic_structures.html#rotatedrect
class Point:
def __init__(self, x, y):
self.x = int(x)
self.y = int(y)
class Rectangle:
def __init__(self, x, y, w, h, angle):
# Center Point
self.x = x
self.y = y
# Height and Width
self.w = w
self.h = h
self.angle = angle
def rotate_rectangle(self, theta):
pt0, pt1, pt2, pt3 = self.get_vertices_points()
# Point 0
rotated_x = math.cos(theta) * (pt0.x - self.x) - math.sin(theta) * (pt0.y - self.y) + self.x
rotated_y = math.sin(theta) * (pt0.x - self.x) + math.cos(theta) * (pt0.y - self.y) + self.y
point_0 = Point(rotated_x, rotated_y)
# Point 1
rotated_x = math.cos(theta) * (pt1.x - self.x) - math.sin(theta) * (pt1.y - self.y) + self.x
rotated_y = math.sin(theta) * (pt1.x - self.x) + math.cos(theta) * (pt1.y - self.y) + self.y
point_1 = Point(rotated_x, rotated_y)
# Point 2
rotated_x = math.cos(theta) * (pt2.x - self.x) - math.sin(theta) * (pt2.y - self.y) + self.x
rotated_y = math.sin(theta) * (pt2.x - self.x) + math.cos(theta) * (pt2.y - self.y) + self.y
point_2 = Point(rotated_x, rotated_y)
# Point 3
rotated_x = math.cos(theta) * (pt3.x - self.x) - math.sin(theta) * (pt3.y - self.y) + self.x
rotated_y = math.sin(theta) * (pt3.x - self.x) + math.cos(theta) * (pt3.y - self.y) + self.y
point_3 = Point(rotated_x, rotated_y)
return point_0, point_1, point_2, point_3
Returns four new points that have been translated by theta
https://github.com/rij12/YOPO/blob/yopo/darkflow/net/yopo/calulating_IOU.py
I am trying to make my circle bounce off of my rectangle using Zelle graphics.py. Once the circle bounces off of the rectangle I wanted it to keep moving randomly. Here is my code so far, and it's working!
Also I know that each circle graphics technically can use the points of the smallest possible square that would fit around the circle to do the collision but I'm having trouble with doing that.
from graphics import *
import random
def delay(d):
for i in range(d):
for i in range(50):
pass
#-------------------------------------------------
def main():
win=GraphWin("Moving Circle",500,400)
win.setBackground('white')
pt= Point(100,200)
cir=Circle(pt,30)
#changes the color of the circle for each game
r = random.randrange(256)
b = random.randrange(256)
g = random.randrange(256)
color = color_rgb(r, g, b)
cir.setFill(color)
cir.draw(win)
#rectangle
rec = Rectangle(Point(450,450), Point(275, 425))
rec.draw(win)
rec.setFill('black')
#-------------------------------------------------
pt5 = Point(250,30)
instruct1=Text(pt5, "click multiple times to start(rectangle can take multiple clicks to move)")
instruct1.setTextColor('black')
instruct1.draw(win)
#-------------------------------------------------
p=cir.getCenter()
p2=win.getMouse()
dx=1
dy=1
keepGoing=True
while keepGoing:
d = 100
delay(d)
cir.move(dx,dy)
p=cir.getCenter()
p2=win.checkMouse()
instruct1.setText("")
#rectanlge
isClicked= win.checkMouse()
if isClicked:
rp = isClicked
rc = rec.getCenter()
rdx = rp.getX() - rc.getX()
rdy = rp.getY() - rc.getY()
rec.move(rdx,rdy)
#circle
if((p.getX()-30)<=0.0) or ((p.getX()+30)>=500):
dx= -dx
if((p.getY()-30)<=0.0) or ((p.getY()+30)>=400):
dy=-dy
p3=win.checkMouse()
main()
I know that each circle graphics technically can use the points of the
smallest possible square that would fir around the circle to do the
collision
I'm playing with an alternate idea -- we could consider a circle around the rectangle instead of a square around the circle. The issue for me is that we not only need to detect collision, but come out with a sense of which way to move away from the other object. It's not just True and False but rather a (dx, dy) type of result.
Obviously, a circle around the rectangle is too crude, but suppose it were lots of smaller circles making up the rectangle and we measure circle center to center distance to detect a hit:
A hit on just a central (green) rectangle circle means reverse the vertical direction of the big circle. A hit on just the end (red) circle means reverse the horizontal direction of the big circle. And we can detect both kinds of hits and reverse the big circle completely.
Here's my rework of your code with the above in mind -- I also fixed your multiple clicking issue and made lots of style changes:
from random import randrange
from graphics import *
WIDTH, HEIGHT = 500, 400
RADIUS = 30
def delay(d):
for _ in range(d):
for _ in range(50):
pass
def distance(p1, p2):
return ((p2.getX() - p1.getX()) ** 2 + (p2.getY() - p1.getY()) ** 2) ** 0.5
def intersects(circle, rectangle):
dx, dy = 1, 1 # no change
center = circle.getCenter()
rectangle_radius = (rectangle.p2.getY() - rectangle.p1.getY()) / 2
rectangle_width = rectangle.p2.getX() - rectangle.p1.getX()
y = rectangle.getCenter().getY()
for x in range(int(rectangle_radius * 2), int(rectangle_width - rectangle_radius * 2) + 1, int(rectangle_radius)):
if distance(center, Point(rectangle.p1.getX() + x, y)) <= rectangle_radius + RADIUS:
dy = -dy # reverse vertical
break
if distance(center, Point(rectangle.p1.getX() + rectangle_radius, y)) <= rectangle_radius + RADIUS:
dx = -dx # reverse horizontal
elif distance(center, Point(rectangle.p2.getX() - rectangle_radius, y)) <= rectangle_radius + RADIUS:
dx = -dx # reverse horizontal
return (dx, dy)
def main():
win = GraphWin("Moving Circle", WIDTH, HEIGHT)
circle = Circle(Point(WIDTH / 5, HEIGHT / 2), RADIUS)
# change the color of the circle for each game
color = color_rgb(randrange(256), randrange(256), randrange(256))
circle.setFill(color)
circle.draw(win)
# rectangle
rectangle = Rectangle(Point(275, 425), Point(450, 450)) # off screen
rectangle.setFill('black')
rectangle.draw(win)
dx, dy = 1, 1
while True:
delay(100)
circle.move(dx, dy)
# rectangle
isClicked = win.checkMouse()
if isClicked:
point = isClicked
center = rectangle.getCenter()
rectangle.move(point.getX() - center.getX(), point.getY() - center.getY())
# circle
center = circle.getCenter()
if (center.getX() - RADIUS) <= 0.0 or (center.getX() + RADIUS) >= WIDTH:
dx = -dx
if (center.getY() - RADIUS) <= 0.0 or (center.getY() + RADIUS) >= HEIGHT:
dy = -dy
# collision bounce
x, y = intersects(circle, rectangle)
dx *= x
dy *= y
main()
Not perfect, but something to play around with, possibly plugging in a better intersects() implementation.