element14 Community
element14 Community
    Register Log In
  • Site
  • Search
  • Log In Register
  • Community Hub
    Community Hub
    • What's New on element14
    • Feedback and Support
    • Benefits of Membership
    • Personal Blogs
    • Members Area
    • Achievement Levels
  • Learn
    Learn
    • Ask an Expert
    • eBooks
    • element14 presents
    • Learning Center
    • Tech Spotlight
    • STEM Academy
    • Webinars, Training and Events
    • Learning Groups
  • Technologies
    Technologies
    • 3D Printing
    • FPGA
    • Industrial Automation
    • Internet of Things
    • Power & Energy
    • Sensors
    • Technology Groups
  • Challenges & Projects
    Challenges & Projects
    • Design Challenges
    • element14 presents Projects
    • Project14
    • Arduino Projects
    • Raspberry Pi Projects
    • Project Groups
  • Products
    Products
    • Arduino
    • Avnet & Tria Boards Community
    • Dev Tools
    • Manufacturers
    • Multicomp Pro
    • Product Groups
    • Raspberry Pi
    • RoadTests & Reviews
  • About Us
    About the element14 Community
  • Store
    Store
    • Visit Your Store
    • Choose another store...
      • Europe
      •  Austria (German)
      •  Belgium (Dutch, French)
      •  Bulgaria (Bulgarian)
      •  Czech Republic (Czech)
      •  Denmark (Danish)
      •  Estonia (Estonian)
      •  Finland (Finnish)
      •  France (French)
      •  Germany (German)
      •  Hungary (Hungarian)
      •  Ireland
      •  Israel
      •  Italy (Italian)
      •  Latvia (Latvian)
      •  
      •  Lithuania (Lithuanian)
      •  Netherlands (Dutch)
      •  Norway (Norwegian)
      •  Poland (Polish)
      •  Portugal (Portuguese)
      •  Romania (Romanian)
      •  Russia (Russian)
      •  Slovakia (Slovak)
      •  Slovenia (Slovenian)
      •  Spain (Spanish)
      •  Sweden (Swedish)
      •  Switzerland(German, French)
      •  Turkey (Turkish)
      •  United Kingdom
      • Asia Pacific
      •  Australia
      •  China
      •  Hong Kong
      •  India
      •  Japan
      •  Korea (Korean)
      •  Malaysia
      •  New Zealand
      •  Philippines
      •  Singapore
      •  Taiwan
      •  Thailand (Thai)
      •  Vietnam
      • Americas
      •  Brazil (Portuguese)
      •  Canada
      •  Mexico (Spanish)
      •  United States
      Can't find the country/region you're looking for? Visit our export site or find a local distributor.
  • Translate
  • Profile
  • Settings
EZ-EV Challenge
  • Challenges & Projects
  • Design Challenges
  • EZ-EV Challenge
  • More
  • Cancel
EZ-EV Challenge
Forum DockBot - Part 2 - Positioning with Aruco Markers
  • News
  • Projects
  • Forum
  • DC
  • Leaderboard
  • Files
  • Members
  • More
  • Cancel
  • New
Join EZ-EV Challenge to participate - click to join for free!
Actions
  • Share
  • More
  • Cancel
Forum Thread Details
  • Replies 3 replies
  • Subscribers 55 subscribers
  • Views 168 views
  • Users 0 members are here
Related

DockBot - Part 2 - Positioning with Aruco Markers

arvindsa
arvindsa 21 days ago

Recap

I am building a robotic system that identifies the charging port on an EV and automatically moves a charger arm to plug the charger in.

Past Forum Posts:

  1. DockBot - Part 1 - The Concept

The problem in technical terms

For a given car model, the charging port is always at the same height. But the driver can park closer or farther away, or stop too far forward or back. The robot needs to figure out exactly where the port is each time. Here is an Half AI , Half Photopea Edited picture to make things easier.

image

Robot needs to know its own position. That can come from joint encoders, stepper step counts from a home position, Lidar, or vision. I chose vision because a single camera system can identify the car model, locate the charging port, and tell the robot where it is relative to the target - all at once. Aruco markers make the position-from-vision problem much more tractable.

What Are Aruco Markers

Aruco markers are square fiducial markers - basically printed QR-code-like patterns with a thick black border and a unique binary pattern inside.  OpenCV has had native Aruco support for years and it works excellently. This is good especially since when i was using this during my Bachelors in engineering, I had to compile OpenCV from scratch t have Aruco. Here is a good video demo on how they work: https://www.youtube.com/watch?v=MlOy9qt_K4Y

image

I generated my markers at chev.me/arucogen using the Aruco MIP 36h12 dictionary. I chose this dictionary because the calibration board I got from Internet (https://github.com/PlusToolkit/PlusLibData/blob/master/ConfigFiles/OpticalMarkerTracker/aruco_calibration_board_a4.pdf) also uses this dictionary. Any dictionary works - the important thing is to use the same one for both calibration and detection.

Installing on Debian / Raspberry Pi

OpenCV comes with Aruco support built in. On Debian or Raspberry Pi OS (I am running Mainsail OS for Klipper as i am repurposing an existing RPi):

pip install opencv-python-headless
sudo apt install -y python3-picamera2

image

Camera Calibration

Before you can get an accurate 3D pose from a marker, you need to calibrate the camera. Every lens introduces distortion. I am not getting into details because I forgot the theory. But what I do know is that to calibrate, you need a known target. I used an 8×5 Aruco MIP 36h12 grid board with 35 mm markers which i found online.

image

image

You don't have permission to edit metadata of this video.
Edit media
x
image
Upload Preview
image

You show the board to the camera from different angles and distances - at least 5 frames, more is better. OpenCV's calibrateCamera then solves for the camera matrix and distortion coefficients by matching the known 3D corner positions on the board to where they appear in each image.

Calibration quality is measured by RMS reprojection error - how many pixels off the estimated corner positions are from the detected ones. Below 1.0 is good. Above that and your pose estimates will drift. Calibration only needs to be done once per camera and is saved to disk. After that it loads automatically on server start.

Step 1 - Just Get Markers Detected

The first script did nothing clever. Grab a frame, run the Aruco detector, print which IDs were found:

import cv2
from picamera2 import Picamera2

picam2 = Picamera2()
picam2.configure(picam2.create_preview_configuration(main={"size": (640, 480)}))
picam2.start()

aruco_dict = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_ARUCO_MIP_36h12)
params = cv2.aruco.DetectorParameters()
detector = cv2.aruco.ArucoDetector(aruco_dict, params)

frame = picam2.capture_array()
frame = cv2.cvtColor(frame, cv2.COLOR_RGBA2BGR)

corners, ids, _ = detector.detectMarkers(frame)
if ids is not None:
    print("Detected:", ids.flatten().tolist())
else:
    print("Nothing detected")

Hold up a marker, it prints the ID. Simple sanity check before building anything more complex.

For the actual server I tuned the detector parameters to be more robust under varying lighting conditions - wider adaptive threshold window, sub-pixel corner refinement:

_params = cv2.aruco.DetectorParameters()
_params.adaptiveThreshWinSizeMin = 3
_params.adaptiveThreshWinSizeMax = 53
_params.adaptiveThreshWinSizeStep = 4
_params.adaptiveThreshConstant = 7
_params.minMarkerPerimeterRate = 0.02
_params.maxMarkerPerimeterRate = 4.0
_params.polygonalApproxAccuracyRate = 0.05
_params.cornerRefinementMethod = cv2.aruco.CORNER_REFINE_SUBPIX
detector = cv2.aruco.ArucoDetector(aruco_dict, _params)

Sub-pixel corner refinement (CORNER_REFINE_SUBPIX) is worth turning on - it measurably improves pose accuracy because solvePnP is very sensitive to where exactly the corners land.

Step 2 - Streaming Frames Over the Network

Running code on the Pi and squinting at SSH output is not great for development. I wanted to see the camera feed on my laptop. So I built a TCP server on the Pi that grabs frames, detects markers, draws them on the frame, and sends the JPEG plus a JSON payload with the marker data.

The protocol is simple: a 4-byte length prefix, followed by a JSON metadata blob, followed by the JPEG bytes. The same framing is used in both directions.

def send_msg(conn, payload: dict, image: bytes = None):
    """Length-prefixed: [4B json_len][json][image_bytes]"""
    if image:
        payload["image_len"] = len(image)
    raw = json.dumps(payload).encode()
    header = struct.pack(">I", len(raw))
    conn.sendall(header + raw)
    if image:
        conn.sendall(image)

def recv_msg(sock):
    hdr = _recvall(sock, 4)
    if not hdr:
        return None, None
    (length,) = struct.unpack(">I", hdr)
    body = _recvall(sock, length)
    meta = json.loads(body.decode())
    image = None
    if meta.get("image_len", 0) > 0:
        image = _recvall(sock, meta["image_len"])
    return meta, image

The streaming runs at full camera speed for data but sends JPEG images at only 4 FPS. There is no point sending 30 JPEGs per second over a home WiFi link when the pose data is what matters. Images are just for visual feedback.

On the client side, view.py connects, starts the stream, and renders the incoming frames in an OpenCV window while printing the pose table in the terminal:

send_cmd(sock, {"cmd": "stream_start"})
while True:
    meta, image = recv_msg(sock)
    if image:
        buf = np.frombuffer(image, dtype=np.uint8)
        frame = cv2.imdecode(buf, cv2.IMREAD_COLOR)
        cv2.imshow("DockBot", frame)
        if cv2.waitKey(1) & 0xFF == ord("q"):
            break
    print_table(meta.get("markers", []))

Step 3 - Camera Calibration Workflow

I added calibration commands to the server. The workflow from the client is:

  1. Print or display the 8×5 Aruco MIP 36h12 board
  2. Send cal_add while holding the board in view at different angles - tilt it, rotate it, bring it closer and farther
  3. Collect at least 15 frames with good variation
  4. Send cal_compute
elif action == "cal_add":
    frame = grab_bgr()
    corners, ids, _ = detector.detectMarkers(frame)
    if ids is not None and len(ids) >= 4:
        obj_pts, img_pts = cal_board.matchImagePoints(corners, ids)
        if obj_pts is not None and len(obj_pts) >= 4:
            with cal_lock:
                cal_frames.append((obj_pts, img_pts))
                count = len(cal_frames)
            send_msg(conn, {"status": "ok", "message": f"frame {count} added"}, ...)

elif action == "cal_compute":
    rms, mtx, dist, _, _ = cv2.calibrateCamera(
        obj_pts, img_pts, (FRAME_W, FRAME_H), None, None)
    if rms < 1.0:
        _save_calibration(mtx, dist)
        msg = f"RMS {rms:.4f} - saved to disk"
    else:
        msg = f"RMS {rms:.4f} ! too high - tilt/rotate board more and try again"

On the server, cal_add calls cal_board.matchImagePoints() to get 3D-to-2D point correspondences for that frame. cal_compute accumulates all frames and calls cv2.calibrateCamera():

If the RMS is below 1.0 the calibration is saved to disk and loaded automatically on the next server start.

Once calibrated, refineDetectedMarkers also kicks in. It uses the board geometry to recover markers that were just below the detection threshold - useful when part of the board is in shadow.

One thing to be careful about: the calibration board markers are 35 mm, but the dock markers I printed are 80 mm (larger because the camera may be placed further away in deployment). These are separate constants in the code:

CAL_MARKER_LENGTH = 0.035   # metres - calibration board markers
MARKER_LENGTH     = 0.080   # metres - dock markers used for pose estimation

Mixing them up gives you pose distances that are off by the ratio of the two sizes. The calibration board size is only used during calibration to define 3D object points. The dock marker size is used later for pose estimation.

Step 4 - Pose Estimation

Once the camera is calibrated, getting the 3D position of each marker is straightforward. The detect() function handles detection, refinement, pose estimation, and drawing all in one pass:

MARKER_OBJ_PTS = np.array([
    [-_half,  _half, 0],
    [ _half,  _half, 0],
    [ _half, -_half, 0],
    [-_half, -_half, 0],
], dtype=np.float32)

def detect(frame):
    corners, ids, rejected = detector.detectMarkers(frame)
    if camera_matrix is not None:
        corners, ids, rejected, _ = cv2.aruco.refineDetectedMarkers(
            frame, cal_board, corners, ids, rejected,
            cameraMatrix=camera_matrix, distCoeffs=dist_coeffs
        )
    markers = []
    if ids is not None:
        cv2.aruco.drawDetectedMarkers(frame, corners, ids)
        for i, mid in enumerate(ids.flatten()):
            if int(mid) not in ALLOWED_IDS:
                continue
            entry = {"id": int(mid), "corners": corners[i][0].tolist()}
            if camera_matrix is not None:
                img_pts = corners[i][0].astype(np.float32)
                _, rvec, tvec = cv2.solvePnP(
                    MARKER_OBJ_PTS, img_pts, camera_matrix, dist_coeffs,
                    flags=cv2.SOLVEPNP_IPPE_SQUARE,
                )
                cv2.drawFrameAxes(frame, camera_matrix, dist_coeffs, rvec, tvec, _half)
                entry["tvec"] = tvec.flatten().tolist()
                entry["rvec"] = rvec.flatten().tolist()
            markers.append(entry)
    return markers

SOLVEPNP_IPPE_SQUARE is the right solver for square planar targets - it gives two solutions and picks the better one. The translation vector tvec is the X, Y, Z position of the marker centre relative to the camera in metres. Z is depth (straight ahead), X is left/right, Y is up/down.

drawFrameAxes draws the 3D coordinate axes on each detected marker in the live stream so you can immediately see the orientation of each marker - satisfying to watch when it works.

Detection is filtered to IDs 0 through 7. Any stray detections from other sources are silently dropped.

The client prints a live-updating pose table from the stream data:

You don't have permission to edit metadata of this video.
Edit media
x
image
Upload Preview
image

If you notice closely, you'll see the estimation of pose are quite off by a large magnitude. That's when it hit me that the size of markers used to calibrate (35mm)  is not the same as the one using to estimate pose (80mm) . So I changed the code to compensate for it at this point.

The XYZ Plot

To verify the pose estimate is stable, I wrote a separate script that connects to the server and live-plots the X, Y, Z components of marker ID 0 over time using matplotlib. The network receiver runs in a background thread and pushes data into a queue; the animation callback drains it: 

def receiver(host, port, q: queue.Queue):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((host, port))
    send_cmd(sock, {"cmd": "stream_start"})
    while True:
        meta, _ = recv_msg(sock)
        if meta is None:
            break
        for m in meta.get("markers", []):
            if m["id"] == MARKER_ID and "tvec" in m:
                q.put((time.monotonic(), m["tvec"]))

def update(_frame):
    while not q.empty():
        t, (x, y, z) = q.get_nowait()
        ts.append(t - t0); xs.append(x); ys.append(y); zs.append(z)
    # drop data older than 10 seconds
    cutoff = time.monotonic() - t0 - HISTORY_S
    while ts and ts[0] < cutoff:
        ts.popleft(); xs.popleft(); ys.popleft(); zs.popleft()
    ...

ani = animation.FuncAnimation(fig, update, interval=50, blit=True)

Holding a marker steady and watching the lines tells you immediately how stable the calibration is. A wobbly line means more calibration frames or better angle variation are needed. Obviously, it's not perfect, But I will improve it after integration, as I need to hold the camera steady first. For now, it's resting over the Rpi Case. 

You don't have permission to edit metadata of this video.
Edit media
x
image
Upload Preview
image

Code

The Full code for this can be found under post2 folder at https://gitlab.com/arvindsa/dockbot-e14-challenge

Final Notes

There was a time I had to compile OpenCV from source just to get Aruco support. Now a single pip install does it. Now that I can reliably locate markers in 3D space, the next step is the physical robot - the gantry hardware.

  • Sign in to reply
  • Cancel
  • veluv01
    veluv01 21 days ago

    Interesting Approach!

    • Cancel
    • Vote Up 0 Vote Down
    • Sign in to reply
    • Cancel
  • arvindsa
    arvindsa 21 days ago in reply to veluv01

    Thank you,  i should have mentioned that having two aruco markers one on the car and one on robot will help me coordinate the movt better.

    • Cancel
    • Vote Up 0 Vote Down
    • Sign in to reply
    • Cancel
  • DAB
    DAB 21 days ago

    Very nice update.

    • Cancel
    • Vote Up 0 Vote Down
    • Sign in to reply
    • Cancel
element14 Community

element14 is the first online community specifically for engineers. Connect with your peers and get expert answers to your questions.

  • Members
  • Learn
  • Technologies
  • Challenges & Projects
  • Products
  • Store
  • About Us
  • Feedback & Support
  • FAQs
  • Terms of Use
  • Privacy Policy
  • Legal and Copyright Notices
  • Sitemap
  • Cookies

An Avnet Company © 2026 Premier Farnell Limited. All Rights Reserved.

Premier Farnell Ltd, registered in England and Wales (no 00876412), registered office: Farnell House, Forge Lane, Leeds LS12 2NE.

Follow element14

  • X
  • Facebook
  • linkedin
  • YouTube