PID Controlled Beam and Ball balance system - problems with controlling the stepper motor

Hello, everyone.

I am working on a project where a camera tracks a ball and my program controls the position of a beam attached stepper motor on which the ball rolls to keep the ball in the middle of the beam. I am implementing a PID controller.

Details:
Using Python 3.11 - OpenCV for a red ball's detection
FHD Webcam camera position above the ball and beam stepup, with a clear view of everything needed.

Stepper Motor Nema 17 (17PM-K406-11V), 1.4A current rating, Uni-Polar
A4988 Stepper Motor Driver, powered from a 12V battery pack, Half-step enabled.

Step pin connected to pin 3 on Uno. Direction on pin 2.
Arduino Uno

I am using Python's OpenCV library to track the ball on the beam. A red coloured ball's distance from the centre of the camera frame is being calculated and stored in 'distance' variable. This effectively is the error term. This error term is manipulated to control the position of the stepper motor. A control signal generated using the error term is sent to my Arduino Uno using the Pyserial library using a Baud Rate of 115200.

The issue I am having is that the stepper motor is very slow to spin. I believe that this is because of the stepper.run() function I am using from the AccelStepper library, which moves the motor one step at a time, far to slow for my application. I have failed to find an alternative function that accelerates the motor to the required angular position. I tried using stepper.runToPosition() but it blocks the code and makes the motor spin fast round and round, to and fro, which I cannot understand.

Here is my Python code:

import numpy as np
import cv2
import serial

#Defining PID gains
kp = 0.5
kd = 0
ki  =0
last_error =0 
stepper_pos=0
integral=0
ser = serial.Serial('com11', 115200)
def measure_distance(frame, center):
  """Measures the distance between the center of the frame and the red object.

  Args:
    frame: A numpy array representing the frame.
    center: A tuple (x, y) representing the center of the frame.

  Returns:
    A float representing the distance between the center of the frame and the red object.
  """

  # Convert the frame to HSV color space.
  hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)

  # Define the HSV range for red color.
  redLower1 = (0, 100, 100)
  redUpper1 = (10, 255, 255)
  redLower2 = (160, 100, 100)
  redUpper2 = (180, 255, 255)

  # Create masks for both red color ranges.
  mask1 = cv2.inRange(hsv, redLower1, redUpper1)
  mask2 = cv2.inRange(hsv, redLower2, redUpper2)
  mask = cv2.bitwise_or(mask1, mask2)

  # Erode and dilate the mask to remove noise.
  mask = cv2.erode(mask, None, iterations=2)
  mask = cv2.dilate(mask, None, iterations=2)

  # Find the contours of the red object.
  cnts = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]

  # Find the center of the red object.
  if len(cnts) > 0:
    c = max(cnts, key=cv2.contourArea)
    ((x, y), radius) = cv2.minEnclosingCircle(c)

    # Calculate the distance between the center of the frame and the red object.
    distance = np.linalg.norm(np.array([x, y]) - center)
    distance = round(distance, 0)
    # Draw a circle around the red object.
    cv2.circle(frame, (int(x), int(y)), int(radius), (0, 0, 255), 2)
    cv2.circle(frame, (int(x), int(y)), 4, (0, 255, 255), 1)
    if y > center[1]:   #uncomment if using
        distance *= -1
    # Print the distance to the console.
    # print("Distance to red object:", distance)
    if (distance > 100):
      distance=100
    elif distance <-100:
      distance = -100
    
    return distance
  else:
    return None

# Capture the frame from the camera.
cap = cv2.VideoCapture(0)

# Get the center of the frame.
center = (cap.get(cv2.CAP_PROP_FRAME_WIDTH) // 2, cap.get(cv2.CAP_PROP_FRAME_HEIGHT) // 2)

while True:
  # Read the next frame from the camera.
  ret, frame = cap.read()
  
  # If the frame is not empty, measure the distance of the red object from the center of the frame.
  if ret:
    
    distance = measure_distance(frame, center)
    if (distance!=None):

#start of PID code
      error = distance
      integral+=error
      control_signal = (kp * error) - kd*(error - last_error) + (ki*integral) #PID code (integral to be added)
      
      #updating the lastdistance
      last_error = error
      # stepper_pos = stepper_pos + program_output
      stepper_mover = str(control_signal) + "\r"  #type casting
   

    #writing the position_output to the motor controller
      ser.write(stepper_mover.encode())
      ser.flush()
      print(stepper_mover)  #for testing

    
    
    # If the distance is not None, print it to the screen and draw a circle around the red object.
    if distance is not None:
      # Print the distance to the screen.
      cv2.putText(frame, "Distance to red object: {}".format(distance), (10, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2)
      center = (int(center[0]), int(center[1]))
      
      # Draw a circle around the red object.
      cv2.circle(frame, center, 5, (0, 255, 0), 2)

  # Display the frame.
  # cv2.resize("Frame", frame,200,300)
  cv2.imshow("Frame", frame)

  # If the 'q' key is pressed, break the loop.
  if cv2.waitKey(1) & 0xFF == ord("q"):
    break

# Release the camera and close all windows.
cap.release()
cv2.destroyAllWindows()

Here is the code I am running on the Arduino Uno.

#include <AccelStepper.h>
const int step_pin = 3;
const int direction_pin = 2;
int required_pos;
int checker_variable;

// Define a stepper and the pins it will use
AccelStepper stepper(1,step_pin, direction_pin); 


void setup()
{  
 //note: I have used half-microstepping, so any input we receive we must multiply by 2
 stepper.setMaxSpeed(3000);
 stepper.setAcceleration(10000);
 stepper.setSpeed(6000);
// stepper.moveTo(800);
 Serial.begin(115200);
// stepper.moveTo(200);
}

void loop()
{
 // Read new position
 if (stepper.distanceToGo()==0){
  Serial.print("Enter a position to go to: ");
  Serial.flush();
//  if (Serial.available()!=0){
  checker_variable= Serial.parseInt();
 if (checker_variable!=0){     //to prevent the Serial Monitor newline from being read, resetting the motor
  required_pos=checker_variable;
 }
 Serial.println(required_pos);}
 stepper.moveTo(required_pos);

 stepper.run();
  
}

Any advice on how to make the stepper motor snappy and responsive to the position of the ball would be greatly appreciated. I have seen many such projects but almost no project does what I'm doing with a camera. Usually, an ultrasonic sensor is used.

Best,
Shameer.

Use a motor driver appropriate to the motor you have (the A4988 is not, because it cannot handle 1.4A), use a motor power supply of 36 V (2 Amperes minimum) and be sure to set the current limit correctly, to 1.4A/winding.

This is only a problem, if you call run() not often and fast enough. In which step increments does your phyton script send commands to the stepper? And how often? Is the script you posted really the script you use together with the phyton program? Why prompting for a new position? That takes a lot of time.

 stepper.setMaxSpeed(3000);
 stepper.setAcceleration(10000);
 stepper.setSpeed(6000);

It doesn't make sense to set Speed higher than maxSpeed. And if you use the run() method it doesn't make sense to set speed at all. This is done by run() internally.

Ive limited the current to 1A from the pot on the driver. I currently dont have another driver :frowning:

The python program sends an integer value which is the position the stepper must rotate to with reference to a reference position that is by default the position of the stepper when the code is first run. It sends the integer value very quickly, as fast as the python script can run.

Yes, this is the script im using to put everything together.

Can you elaborate on prompting for a new position being slow? I'm not aware about that.

Then maybe your stepper should run to a new position while its still on the way to the previous one. If you want the stepper to react fast, you must not wait until it has reached the previous position. And this

is blocking - no steps can be generated in this time. Even with 115200 baud sending this prompt needs more than 2 ms. parseInt() also needs time while no steps can be created. And it waits if no incoming bytes are present at that moment.
You need to do receiving positions and creating steps in parallel. You must call the run() method often even while you receive a new positon.
Maybe the MoToStepper class of my MobaTools library is easier for you, because it creates steps in the background - even while the positions are received at serial.

How do I do this? Accelstepper doesnt have such a function in my knowledge.
How do I also take inputs in a non-blocking manner?

Also, can I please have some documentation for MobaTools?

Cheers

It has. You can set a new target position with moveTo() at any time - you don't have to wait until the previous target has been reached. But it is important that you call run() really often- so you must not have blocking code in your sketch.
If you use MoToStepper you don't need something like run() because the step pulses are created in timer interrupts. So the stepper turns also while blocking code.
A documentation can be found on github.. If you install the MobaTools library, the documentation pdf-file is also copied to your library folder.

In loop() check if a char has been received, and if so, store it in a char array ( one after the other). Don't wait if no char has been received, just go on. If the complete number has been received ( identified by receiving the '\r' ) you can convert the ascii number to a binary number with the atoi() function. Don't print anything in that loop(), just receive.

This topic was automatically closed 180 days after the last reply. New replies are no longer allowed.