← Sunil Khorwal

Building Suniyo: An 8-DOF Quadruped Robot Dog with IK and Autonomous Navigation

Published on May 3, 2026  ·  Robotics, Embedded Systems, Kinematics, ESP32

A quadruped robot is one of those projects that sounds manageable until you actually start. Four legs, two joints each — eight servos total. But the moment you fire everything up, you realize the geometry is brutal. Servos are mechanically asymmetric: the right-side hip has to move in the opposite physical direction to produce the same semantic motion as the left side. Calibration drifts. IK blows up at the joint limits. And a gait that looks smooth on paper produces a robot that falls over.

This is the story of Suniyo — my 8-DOF quadruped robot dog. Two hip + knee joints per leg, a 2D inverse kinematics solver, three distinct gait patterns, dual ESP32 hardware, and a camera-based autonomous navigation system that decides whether to step over an obstacle or go around it.


The Hardware Architecture

The core issue with a quadruped is power and signal separation. I ran into this early: an ESP32-CAM cannot drive a PCA9685 I2C bus reliably while streaming video — the camera DMA and I2C transactions fight for bus time and you get stuttering servos or corrupted frames. The solution was to split the system across two boards:

System architecture
┌─────────────────┐ │ Python PC │ (IK solver, gait engine, visualizer) └────────┬────────┘ │ WiFi ─────┼────────────────────── │ │ ┌────▼─────┐ ┌──────▼──────┐ │ ESP32 #1 │ │ ESP32-CAM │ │ Servos │ │ Camera │ └────┬─────┘ └─────────────┘ │ I2C (SDA/SCL) ┌────▼─────┐ │ PCA9685 │ 16-channel PWM driver │ 8 servos │ └──────────┘ Python → ESP32 #1: UDP port 9999 (servo commands) ESP32-CAM → Python: HTTP MJPEG stream port 81

The PCA9685 PWM driver sits on GPIO 21 (SDA) and GPIO 22 (SCL) of the servo ESP32. It runs at its default I2C address (0x40). Each of the 8 servo channels maps to a leg joint:

ChannelJointDirection
0Front Left Hip+1 (normal)
1Front Left Knee+1 (normal)
2Front Right Hip-1 (mirrored)
3Front Right Knee+1 (normal)
4Back Left Hip+1 (normal)
5Back Left Knee+1 (normal)
6Back Right Hip-1 (mirrored)
7Back Right Knee+1 (normal)

Channels 2 and 6 (both right-side hips) have direction -1. This matters: "forward" for the left hip means the servo rotates in one direction, but on the right side the servo is physically mounted as a mirror image, so the same semantic "forward" requires the servo to rotate the other way. The direction table handles this without touching the IK math.


The Inverse Kinematics Solver

Each leg is a 2-link planar arm — femur (130mm) and tibia (150mm). Given a target foot position (x, y) in the leg's local 2D plane, I need to find the hip and knee angles that place the foot there. This is the classic 2-DOF IK problem, solved with the law of cosines:

kinematics.py — 2D IK solver
FEMUR = 130 # mm TIBIA = 150 # mm def ik(x, y): d = math.sqrt(x*x + y*y) # Clamp to reachable workspace d = min(max(d, abs(FEMUR - TIBIA) + 1), FEMUR + TIBIA - 1) # Law of cosines for knee angle knee_angle = math.acos( (d*d - FEMUR*FEMUR - TIBIA*TIBIA) / (2 * FEMUR * TIBIA) ) # Atan2 for hip angle, adjusted by the triangle offset hip_angle = math.atan2(y, x) - math.atan2( TIBIA * math.sin(knee_angle), FEMUR + TIBIA * math.cos(knee_angle) ) return hip_angle, knee_angle

The clamping on d is critical. If the target foot position is outside the robot's reachable workspace — d > FEMUR + TIBIA or d < |FEMUR - TIBIA| — the acos argument goes outside [-1, 1] and the function raises a math domain error. The clamp silently snaps to the nearest reachable point instead of crashing.

Once IK gives angles in radians, they're converted to degrees and then to PWM offsets using a calibrated DEGREES_PER_PWM = 0.6 ratio (180° ÷ 300 PWM units for a standard servo). Per-servo calibration offsets are added before the PWM conversion to compensate for mechanical zero-point drift.


Three Gaits — Walk, Trot, Crawl

A gait is a pattern of phase offsets that determines when each leg lifts off the ground. Each leg cycles through a sine wave parameterised by the robot's time phase. The foot's x position during the stride follows cos(θ); when sin(θ) > 0 the foot lifts by STEP_HEIGHT (40mm).

config.py — gait phase offsets (radians)
GAITS = { "walk": {"FL": 0, "FR": 1.571, "BL": 3.142, "BR": 4.712}, "trot": {"FL": 0, "BR": 0, "FR": 3.142, "BL": 3.142}, "crawl": {"FL": 0, "FR": 1.047, "BR": 2.094, "BL": 3.142}, }

The walk gait (phase offsets of π/2 between legs) keeps three feet on the ground at all times — statically stable, slow. The trot gait (diagonal pairs in sync: FL+BR, FR+BL) only has two feet down at once — faster but requires dynamic balance. The crawl gait uses π/3 spacing — the most conservative, one leg at a time.

The stride length and height are tunable constants. During obstacle avoidance, the stride is asymmetrically scaled per leg: outer legs (relative to the turn direction) get 1.5× stride, inner legs get 0.5×, producing a smooth arc rather than a pivot.


Camera-Based Obstacle Avoidance

The ESP32-CAM streams MJPEG over HTTP to the Python controller. The stream is parsed manually — scanning the raw byte buffer for JPEG SOI (0xFF 0xD8) and EOI (0xFF 0xD9) markers and decoding each frame with OpenCV. This avoids the reliability issues of OpenCV's built-in VideoCapture with HTTP streams. The system also has a Mac webcam fallback (cv2.VideoCapture(0)) for testing without the robot powered on.

Obstacle detection on each frame runs a pipeline: Gaussian blur → Canny edge detection (thresholds 50/120) → morphological close to fill edge gaps → contour extraction. Each contour is then filtered by three criteria before it counts as an obstacle:

Surviving contours are scored by proximity: 70% weight on vertical position in the frame (lower = closer) and 30% on bounding box area. The top 5 closest obstacles by score are passed to the navigation system. A contour whose centre is above 25% from the top of the frame is discarded entirely — too far to matter. Position (left/center/right) is determined by dividing the frame width into thirds.

The navigation state machine has five cases, executed with a 3-second decision cooldown to prevent oscillation:

navigation.py — decision logic
# All three zones blocked → back up if has_left and has_center and has_right: return {motion: -1, state: "surrounded_backing_up"} # Center blocked, right clear → stop and turn right if has_center and not has_right: return {motion: 0, turn: +1, turn_angle: 35} # Center blocked, left clear → stop and turn left if has_center and not has_left: return {motion: 0, turn: -1, turn_angle: 35} # Small obstacle in center → step over it if can_step_over(center_obstacle): return {motion: 1, turn: 0, speed_factor: 0.6} # Only left blocked → gentle right curve if has_left and not has_center and not has_right: return {motion: 1, turn: +1, turn_angle: 15} # Narrow passage (both sides blocked, center clear) if has_left and has_right and not has_center: return {motion: 1, turn: 0, speed_factor: 0.5}

The step-over logic checks whether an obstacle covers less than 15% of the frame height and the current gait is walk or trot (not crawl — you can't step over things slowly with a crawl). If yes, the robot proceeds forward at reduced speed rather than turning, which is much more efficient for small objects on the floor.

Speed is continuously modulated based on the closest obstacle proximity: 0.3× at proximity > 0.8 (very close), scaling up to 1.0× when nothing is near. This produces natural deceleration as the robot approaches objects rather than a hard stop.


Servo Calibration

Getting eight servos to all have the same mechanical zero is the most tedious part of any quadruped build. Servos ship with manufacturing tolerances — a servo commanded to 0° might physically sit at 7° off-center. Multiply that across eight servos and the legs look different lengths even though they're identical hardware.

The calibration tool sends each servo to its zero position one at a time. You look at the physical leg, press + or - to add/subtract degrees until it looks right, and the tool writes the offsets to calibration_results.txt. These then go into config.py as the SERVO_OFFSETS dict, applied in software before every PWM conversion.

My calibration_results.txt after physical tuning
SERVO_OFFSETS = { 'FL_HIP': 15, # Front Left Hip 'FL_KNEE': 20, # Front Left Knee 'FR_HIP': 5, # Front Right Hip 'FR_KNEE': 20, # Front Right Knee 'BL_HIP': 5, # Back Left Hip 'BL_KNEE': 20, # Back Left Knee 'BR_HIP': 5, # Back Right Hip 'BR_KNEE': 20, # Back Right Knee }

Notice all four knees are off by ~20°. That's consistent with a mounting issue — the knee servo horns were all attached one spline position clockwise of center. Correcting it in software is fine; it just means the IK is always working against a fixed offset at the hardware level.


What's Next

The trot gait works and the obstacle avoidance is functional on flat surfaces. The obvious next steps are:

73, Sunil