I have an orange ping pong and I am writing a python program that gives the distance of the orange ping pong ball from the camera. So far it is working decently well, it is able to give the distance of the orange ping pong ball to the camera within 2 cm of error. I would like to decrease the error even more. The reason this error has come up is because I think that the green ball enveloping the ping pong in the program is repeatedly getting smaller or larger. As such, I would like to improve the program so that the green circle is able to perfectly envelope the ball. This is so that I can get the error of the distance between the ball and the camera to be at a minimum, preferably down to millimeters. Additionally, when the ball gets too close to the camera, sometimes the green circle enveloping the ping pong ball goes away entirely. I would also like the noise of small green circles to go away. I currently using OpenCV 4.8 along with Python version 3.8.16. Also, the ping pong ball has a diameter of 40mm as is bright orange.
An example video on Google Drive
One frame, with the green circle is not precisely covering ping pong ball:
https://i.stack.imgur.com/GxTwZ.png
I have tried different methods of contouring. I have also tried using a linear search algorithm to find the edges of the ball to no avail. Additionally, the current iteration I am on uses subpixel refinement. I have calibrated my camera dozens of times and I think it is currently well calibrated. I have tried to remove the Gaussian blur, but that ends up creating too much noise. Increasing the blur results in the ball not being detected up close. Here is the code I have written so far.
import cv2
import numpy as np
def calculate_distance(pixel_diameter, camera_matrix, real_diameter):
# Calculate the distance using the formula: distance = (real_diameter * focal_length) / pixel_diameter
focal_length = camera_matrix[0, 0]
return round((real_diameter * focal_length) / pixel_diameter, 6)
def filter_contours(contours, min_area, circularity_threshold):
filtered_contours = []
for contour in contours:
# Calculate contour area
area = cv2.contourArea(contour)
if area > min_area:
# Calculate circularity of the contour
perimeter = cv2.arcLength(contour, True)
circularity = 4 * np.pi * area / (perimeter ** 2)
if circularity > circularity_threshold:
filtered_contours.append(contour)
return filtered_contours
def main():
# Load camera calibration parameters
calibration_file = "camera_calibration.npz"
calibration_data = np.load(calibration_file)
camera_matrix = calibration_data["camera_matrix"]
dist_coeffs = calibration_data["dist_coeffs"]
# Create a VideoCapture object for the camera
cap = cv2.VideoCapture(0)
real_diameter = 0.04 # Actual diameter of the ping pong ball in meters
# Set frame rate to 120 FPS
# cap.set(cv2.CAP_PROP_FPS, 30)
while True:
# Read a frame from the camera
ret, frame = cap.read()
if not ret:
break
# Increase exposure
cap.set(cv2.CAP_PROP_EXPOSURE, 0.5)
# Undistort the frame using the calibration parameters
undistorted_frame = cv2.undistort(frame, camera_matrix, dist_coeffs)
# Convert the frame to HSV color space
img_hsv = cv2.cvtColor(undistorted_frame, cv2.COLOR_BGR2HSV)
# Threshold the image to isolate the ball
orange_lower = np.array([0, 100, 100])
orange_upper = np.array([30, 255, 255])
mask_ball = cv2.inRange(img_hsv, orange_lower, orange_upper)
# Apply morphological operations to remove noise
kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
mask_ball = cv2.morphologyEx(mask_ball, cv2.MORPH_OPEN, kernel_open)
mask_ball = cv2.morphologyEx(mask_ball, cv2.MORPH_CLOSE, kernel_close)
# Find contours of the ball
contours, _ = cv2.findContours(mask_ball, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# Filter contours based on size and circularity
min_area = 1000 # Adjusted minimum area threshold
circularity_threshold = 0.5 # Adjusted circularity threshold
filtered_contours = filter_contours(contours, min_area, circularity_threshold)
if len(filtered_contours) > 0:
# Find the contour with the largest area (the ball)
ball_contour = max(filtered_contours, key=cv2.contourArea)
# Find the minimum enclosing circle
(x, y), radius = cv2.minEnclosingCircle(ball_contour)
if radius >= 10: # Adjusted minimum enclosing circle radius threshold
# Refine the circle center position using subpixel accuracy
center, radius = cv2.minEnclosingCircle(ball_contour)
center = np.array(center, dtype=np.float32)
# Draw a circle around the ball
cv2.circle(frame, (int(center[0]), int(center[1])), int(radius), (0, 255, 0), 2)
# Calculate and print the distance to the ball
diameter = radius * 2
distance = calculate_distance(diameter, camera_matrix, real_diameter)
print("Distance to the ball: {:.6f} meters".format(distance))
# Calculate the x and y coordinates in the camera's image plane
x_coordinate = (center[0] - camera_matrix[0, 2]) / camera_matrix[0, 0]
y_coordinate = (center[1] - camera_matrix[1, 2]) / camera_matrix[1, 1]
x_coordinate = round(x_coordinate, 5)
y_coordinate = round(y_coordinate, 5)
print("x-coordinate: {:.5f}, y-coordinate: {:.5f}".format(x_coordinate, y_coordinate))
# Display the frame
cv2.imshow("Live Feed", frame)
else:
# Display the original frame if the ball is not detected
cv2.imshow("Live Feed", frame)
# Check for key press (press 'q' to exit)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
# Release the VideoCapture object and close windows
cap.release()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
Edit After discussing in the comments, I was able to come to a solution to my problem by not using the minimum enclosing circle. Instead, I chose to create a polygon of the edges that it could detect. This resulted in my program being much more efficient at creating the polygon around the ball I wish to be detected. The code is below along with a picture.
import cv2
import numpy as np
def calculate_distance(pixel_diameter, camera_matrix, real_diameter):
# Calculate the distance using the formula: distance = (real_diameter * focal_length) / pixel_diameter
focal_length = camera_matrix[0, 0]
return round((real_diameter * focal_length) / pixel_diameter, 6)
def filter_contours(contours, min_area, circularity_threshold):
filtered_contours = []
for contour in contours:
# Calculate contour area
area = cv2.contourArea(contour)
if area > min_area:
# Calculate circularity of the contour
perimeter = cv2.arcLength(contour, True)
circularity = 4 * np.pi * area / (perimeter ** 2)
if circularity > circularity_threshold:
filtered_contours.append(contour)
return filtered_contours
def main():
# Load camera calibration parameters
calibration_file = "camera_calibration.npz"
calibration_data = np.load(calibration_file)
camera_matrix = calibration_data["camera_matrix"]
dist_coeffs = calibration_data["dist_coeffs"]
# Create a VideoCapture object for the camera
cap = cv2.VideoCapture(0)
real_diameter = 0.04 # Actual diameter of the ping pong ball in meters
# Set frame rate to 120 FPS
cap.set(cv2.CAP_PROP_FPS, 120)
while True:
# Read a frame from the camera
ret, frame = cap.read()
if not ret:
break
# Increase exposure
cap.set(cv2.CAP_PROP_EXPOSURE, 0.5)
# Undistort the frame using the calibration parameters
undistorted_frame = cv2.undistort(frame, camera_matrix, dist_coeffs)
# Convert the frame to HSV color space
img_hsv = cv2.cvtColor(undistorted_frame, cv2.COLOR_BGR2HSV)
# Threshold the image to isolate the ball
orange_lower = np.array([0, 100, 100])
orange_upper = np.array([30, 255, 255])
mask_ball = cv2.inRange(img_hsv, orange_lower, orange_upper)
# Apply morphological operations to remove noise
kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
mask_ball = cv2.morphologyEx(mask_ball, cv2.MORPH_OPEN, kernel_open)
mask_ball = cv2.morphologyEx(mask_ball, cv2.MORPH_CLOSE, kernel_close)
# Find contours of the ball
contours, _ = cv2.findContours(mask_ball, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# Filter contours based on size and circularity
min_area = 1000 # Adjusted minimum area threshold
circularity_threshold = 0.5 # Adjusted circularity threshold
filtered_contours = filter_contours(contours, min_area, circularity_threshold)
if len(filtered_contours) > 0:
# Find the contour with the largest area (the ball)
ball_contour = max(filtered_contours, key=cv2.contourArea)
# Find the precise boundary of the ball
epsilon = 0.00001 * cv2.arcLength(ball_contour, True)
ball_boundary = cv2.approxPolyDP(ball_contour, epsilon, True)
if len(ball_boundary) > 2:
# Draw the precise boundary of the ball
cv2.drawContours(frame, [ball_boundary], -1, (0, 255, 0), 2)
# Calculate the diameter of the ball
(x, y), radius = cv2.minEnclosingCircle(ball_boundary)
diameter = radius * 2
# Calculate and print the distance to the ball
distance = calculate_distance(diameter, camera_matrix, real_diameter)
print("Distance to the ball: {:.6f} meters".format(distance))
# Calculate the x and y coordinates in the camera's image plane
x_coordinate = (x - camera_matrix[0, 2]) / camera_matrix[0, 0]
y_coordinate = (y - camera_matrix[1, 2]) / camera_matrix[1, 1]
x_coordinate = round(x_coordinate, 5)
y_coordinate = round(y_coordinate, 5)
print("x-coordinate: {:.5f}, y-coordinate: {:.5f}".format(x_coordinate, y_coordinate))
# Display the frame
cv2.imshow("Live Feed", frame)
else:
# Display the original frame if the ball is not detected
cv2.imshow("Live Feed", frame)
# Check for key press (press 'q' to exit)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
# Release the VideoCapture object and close windows
cap.release()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
Image of better ball: https://i.stack.imgur.com/O3Mul.png