0

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

  • 1
    We can't see your video... – Mark Setchell Jul 11 '23 at 07:06
  • @MarkSetchell, Apologies, I have added my video to the question. – Banana Blitz Coding Jul 11 '23 at 16:26
  • 1
    Could you please include at least one frame from the video directly into the post? The Google Drive link will break, we need the question to remain meaningful in the future too, for other people to find and learn from. – Cris Luengo Jul 11 '23 at 19:28
  • Issues I see: `cv2.getStructuringElement(cv2.MORPH_ELLIPSE, …)` doesn’t produce very nice circles, they have a different size horizontally and vertically. The contour is a polygon that goes through the outermost pixels of the object, not around the object, and so is biased. `cv2.minEnclosingCircle()` depends on only three points of the contour, so it is sensitive to noise; it will also produce a radius that is larger than the mean radius. [I would suggest using a different library than OpenCV to measure the ball.](https://www.crisluengo.net/archives/1140/) – Cris Luengo Jul 11 '23 at 19:37
  • @CrisLuengo The problem with using another library other than opencv is that it is incredibly slow. Opencv is the only library where it can handle all of the operations I am doing. – Banana Blitz Coding Jul 11 '23 at 21:18
  • ...because you've tried all the libraries of course. I bet that if you change all the stuff from `findContours` onwards with a call to `dip.MeasurementTool.Measure()` (in DIPlib) you'd be just as fast and much more precise (and with less code to boot). You'll be able to compute the radius as the "mean" column from the "Radius" feature, and your circularity as the "Roundness" feature (though the "Circularity" feature would probably be better in your case because it's derived from the "Radius" feature). The "Center" feature you'd need for drawing the circle. – Cris Luengo Jul 11 '23 at 21:27
  • Seeing what `mask_ball` looks like on the frame you posted, I think the first thing to do is improve your definition of the color of the ball. Note that this color depends a lot on illumination, which makes the color different on different sides of the ball, but also will change it if you're holding the ball closer to your monitor or a window or whatever. You might want to do something that is color-agnostic, though that is not necessarily easy. – Cris Luengo Jul 11 '23 at 22:10
  • Thank you for your advice. I'll try converting most of the OpenCV code segments to try to use diplib instead. Additionally, how would go about approaching this problem without looking at color? – Banana Blitz Coding Jul 12 '23 at 00:16

0 Answers0