00

Engineering Objective

A digital twin of a unitree go2 quadruped is given a specific object – in this case, a “slipping cone” for testing purposes. The robot’s goal is to navigate through a warehouse environment with sharp turns and narrow corridors to find this object in simulation

This demonstrates the practical use of AI in robot guidance, navigation, and location in environments where reliable and automated object location systems are necessary

OBJECTIVE Automatically find and locate an object in simulation
01
Phase 1

YOLOv8 ROS2 Object Detection & Localization

My first objective was to develop reliable object detection and localization. I used a YOLOv8 model to detect a yellow caution sign from the Unitree Go2's RGB-D camera feed.

I created a ROS2 module to capture approximately 700 images for training, validation, and testing. The final perception node combines YOLOv8 detection, depth sampling, temporal filtering, 3D coordinate reconstruction, tf2 coordinate transformation, and RViz2 visualization.

Pipeline: Detect object → determine bounding-box center → obtain depth → reconstruct camera-frame position → transform into map coordinates → filter repeated detections → publish persistent object location.

YOLOv8 caution sign detection visualized in RViz2
YOLOv8 detection bounding box and RViz2 visualization.
YOLOv8 training snapshot
Training and validation snapshot from the custom object-detection dataset.
MODEL / 01 · CAMERA INTRINSICS

YOLOv8 returns a bounding box around the detected object. The center of this box becomes the image-space coordinate (u, v).

The RGB-D camera also provides the depth Z, while ROS2's CameraInfo topic provides the intrinsic camera parameters.

K =
[ fx 0 cx ]
[ 0 fy cy ]
[ 0 0 1 ]

fx and fy are the camera focal lengths expressed in pixels. cx and cy define the principal point of the image.

CAMERA_INTRINSICS.PY PYTHON · ROS2
def camera_info_callback(self, msg: CameraInfo):
    K = msg.k

    self.fx = K[0]
    self.fy = K[4]
    self.cx = K[2]
    self.cy = K[5]

    # Camera intrinsics remain constant,
    # so the subscription is no longer needed.
    self.destroy_subscription(self.cam_info_sub)
MODEL / 02 · DETECTION CENTER
u = (x₁ + x₂) / 2

v = (y₁ + y₂) / 2

YOLO provides the upper-left and lower-right corners of the detected bounding box. Averaging these values gives the approximate center of the object in image coordinates.

YOLO_BOUNDING_BOX.PY PYTHON · YOLOV8
x1, y1, x2, y2 = map(int, box.xyxy[0])

# Bounding-box center in pixel coordinates
u = (x1 + x2) // 2
v = (y1 + y2) // 2
MODEL / 03 · DEPTH FILTERING

Instead of trusting one depth pixel, I sample a small 5 × 5 neighborhood around the bounding-box center. Invalid and zero-valued measurements are removed, and the median remaining depth value is used as Z.

Using the median makes the localization system less sensitive to individual noisy depth pixels.

DEPTH_FILTERING.PY PYTHON · NUMPY
def get_stable_depth(self, u, v, window=5):

    h, w = self.depth_image.shape
    half = window // 2

    umin = max(0, u - half)
    umax = min(w, u + half + 1)

    vmin = max(0, v - half)
    vmax = min(h, v + half + 1)

    patch = self.depth_image[
        vmin:vmax,
        umin:umax
    ].astype(np.float32)

    # Remove invalid measurements
    patch = patch[np.isfinite(patch)]
    patch = patch[patch > 0]

    if len(patch) == 0:
        return None

    return np.median(patch)
MODEL / 04 · PIXEL → 3D CAMERA POSITION

The difference between the detection and the camera's principal point gives the object's pixel displacement from the optical center.

Δu = u - cx

Δv = v - cy

Using the pinhole-camera model and measured depth Z, the object can then be reconstructed in the camera coordinate frame.

Xc = ((u - cx) Z) / fx

Yc = ((v - cy) Z) / fy

Zc = Z

The resulting point (Xc, Yc, Zc) represents the object's three-dimensional position relative to the front camera.

STRAIGHT-LINE CAMERA DISTANCE
D = √(Xc² + Yc² + Zc²)
PIXEL_TO_CAMERA_FRAME.PY PYTHON
def compute_global_position(self, u, v, Z):

    # Pixel coordinate -> camera-frame position
    Xc = (u - self.cx) * Z / self.fx
    Yc = (v - self.cy) * Z / self.fy

    point_cam = PointStamped()

    point_cam.header.frame_id = "unitree_go2/front_cam"
    point_cam.header.stamp = self.get_clock().now().to_msg()

    point_cam.point.x = Xc
    point_cam.point.y = Yc
    point_cam.point.z = float(Z)
MODEL / 05 · CAMERA FRAME → MAP FRAME

The reconstructed point initially exists relative to the robot's front camera. This coordinate moves whenever the robot moves, so it cannot be used directly as a persistent object location.

p_camera = [ Xc ]
[ Yc ]
[ Zc ]
[ 1 ]
p_map = T(map←camera) · p_camera

ROS2 tf2 provides the transformation between the front camera frame and the global map frame. Applying that transform converts the camera-relative detection into a fixed coordinate in the simulated warehouse.

TF2_GLOBAL_TRANSFORM.PY PYTHON · ROS2 TF2
transform = self.tf_buffer.lookup_transform(
    "map",
    "unitree_go2/front_cam",
    rclpy.time.Time()
)

point_map = do_transform_point(
    point_cam,
    transform
)

return point_map
IMPLEMENTATION / 06 · COMPLETE LOCALIZATION STEP

The detection, depth filtering, camera reconstruction, and tf2 transformation are combined inside the YOLO image callback.

DETECTION_TO_GLOBAL_POSITION.PY PYTHON · ROS2
# YOLO bounding box
x1, y1, x2, y2 = map(int, box.xyxy[0])

# Bounding-box center
u = (x1 + x2) // 2
v = (y1 + y2) // 2


# Obtain stable depth around the center pixel
Z = self.get_stable_depth(u, v)

if Z is None:
    continue


# Convert millimeters to meters when required
if self.depth_image.dtype == np.uint16:
    Z /= 1000.0


# Pixel + depth -> camera frame -> map frame
point_map = self.compute_global_position(
    u,
    v,
    Z
)


# Store transformed measurement
track["positions"].append(
    point_map.point
)
FILTER / 07 · TEMPORAL VALIDATION

A single YOLO detection is not immediately accepted as a real object. The same detection must appear repeatedly across frames before its global position is recorded.

hits ≥ 40

After a track reaches the required detection count, several independent global position estimates are collected and averaged.

x̄ = (1/N) Σ xi

ȳ = (1/N) Σ yi

z̄ = (1/N) Σ zi

This reduces frame-to-frame localization noise before the object is saved as a persistent map location.

TEMPORAL_FILTERING.PY PYTHON
# Require repeated detections
if track["hits"] < self.min_hits or track["saved"]:
    continue


# Store repeated transformed positions
track["positions"].append(point_map.point)


# Average several independent measurements
if len(track["positions"]) >= 5:

    avg = Point()

    avg.x = np.mean([
        p.x for p in track["positions"]
    ])

    avg.y = np.mean([
        p.y for p in track["positions"]
    ])

    avg.z = np.mean([
        p.z for p in track["positions"]
    ])


    pose = Pose()

    pose.position = avg
    pose.orientation.w = 1.0
FILTER / 08 · DUPLICATE SUPPRESSION

Before saving the position, the new detection is compared against previously stored caution-sign locations.

d = √((x₂ - x₁)² + (y₂ - y₁)²)

If another stored object is within the merge radius, the detection is treated as the same object rather than creating a duplicate map entry.

DUPLICATE_FILTER.PY PYTHON
def is_new_pose(self, x, y):

    for pose in self.saved_poses:

        dx = pose.position.x - x
        dy = pose.position.y - y

        dist = math.sqrt(
            dx * dx +
            dy * dy
        )

        if dist < self.merge_radius_m:
            return False

    return True
PHASE 1 RESULT

The resulting ROS2 module converts a raw RGB-D camera stream into stable, persistent object locations in the global map frame. Detected caution signs can then be published as a PoseArray and visualized in RViz2 for use by later navigation and autonomy systems.

02
Phase 2

Walking Policy

The next objective was to train a reinforcement-learning policy for stable quadruped walking. The Unitree Go2 requires coordinated control across 12 joints: four hip joints, four thigh joints, and four calf joints.

The policy outputs a 12-dimensional action vector that is converted into joint-position targets. I shaped the reward function to encourage forward velocity tracking, smooth body motion, balanced leg participation, stable ground contact, and symmetric motion between the left and right legs.

Pipeline: RL action vector → joint scaling and sign correction → smoothed joint targets → contact and motion measurement → reward / penalty calculation → policy update.

Unitree Go2 reinforcement learning walking policy
Reinforcement-learning walking policy during gait development and tuning.
CONTROL / 01 · 12-JOINT ACTION VECTOR

At every policy step, the reinforcement-learning agent produces a 12-dimensional action vector:

a = [ a₁, a₂, ..., a₁₂ ]ᵀ

Each action corresponds to one leg joint. Before the command is sent to the robot, I apply joint-specific sign corrections and scaling factors. This allows the same normalized RL action range to produce appropriate angular motion for hips, thighs, and calves.

a' = S ⊙ a
qdesired = qdefault + G ⊙ a'

Here, S is the action-sign vector, G is the joint scaling vector, and ⊙ represents element-wise multiplication.

APPLY_ACTION.PY PYTHON · PYTORCH
def _build_action_sign(self):
    sign = torch.ones(12, device=self.device)

    # Hip joints
    sign[0] = -1.0
    sign[1] = -1.0
    sign[2] = -1.0
    sign[3] = -1.0

    # Thigh joints
    sign[4:8] = 1.0

    # Calf joints
    sign[8]  =  1.0
    sign[9]  =  1.0
    sign[10] = -1.0
    sign[11] = -1.0

    self.action_sign = sign.unsqueeze(0)


def _apply_action(self):

    default = self.robot.data.default_joint_pos

    if self.joint_targets is None:
        self.joint_targets = default.clone()

    # RL actions are limited to [-1, 1]
    a = torch.clamp(self.actions, -1.0, 1.0)

    if not hasattr(self, "action_sign"):
        self._build_action_sign()

    # Correct joint directions
    a = a * self.action_sign

    # Joint-specific motion scale
    scale = torch.tensor(
        [
            0.05, 0.05, 0.05, 0.05,   # hips
            0.55, 0.55, 0.55, 0.55,   # thighs
            0.70, 0.70, 0.70, 0.70,   # calves
        ],
        device=self.device,
    ).unsqueeze(0)

    desired = default + scale * a

    # Hold hips near default orientation
    desired[:, 0:4] = default[:, 0:4]
CONTROL / 02 · ACTION SMOOTHING

Directly applying each new RL output produced abrupt joint motion, so I limited how quickly the commanded joint target could change between simulation steps.

Δq = clamp( qdesired - qtarget, -Δqmax, Δqmax )
qtarget,new = qtarget,old + Δq

This effectively acts as a joint-command slew-rate limiter, reducing twitching and encouraging smoother gait transitions.

ACTION_SMOOTHING.PY PYTHON · PYTORCH
# Prevent abrupt joint-target changes
max_delta = 0.10

delta = torch.clamp(
    desired - self.joint_targets,
    -max_delta,
    max_delta
)

self.joint_targets = self.joint_targets + delta

self.robot.set_joint_position_target(
    self.joint_targets
)
SENSOR / 03 · FOOT CONTACT & AIRTIME

A contact sensor attached to the feet provides the net contact force for each leg. The magnitude of this force is compared against a threshold to determine whether each foot is touching the ground.

Fi = ||F⃗i||
ci = 1, if Fi > Fthreshold
0, otherwise

The same sensor also tracks each foot's current airtime. These values let the reward function distinguish between normal swing phases and legs that remain off the ground for too long.

CONTACT_SENSOR.PY ISAAC LAB · PYTORCH
data = self.feet_sensor.data

# Net contact force magnitude for each foot
Fmag = torch.linalg.norm(
    data.net_forces_w,
    dim=-1
)

thr = self.feet_sensor.cfg.force_threshold

# 1 = foot touching ground
# 0 = foot in the air
contacts = (Fmag > thr).float()

# Current swing duration of each foot
air_time = data.current_air_time
REWARD / 04 · GAIT CONTACT PATTERN

The reward function evaluates how many feet are in contact with the ground and how many are actively in a swing phase.

Ncontact = Σ ci

A Gaussian-shaped reward favors approximately three simultaneous ground contacts while penalizing unstable configurations such as two-contact or zero-contact states.

R3-contact = exp( -((Ncontact - 3) / σc)² )

I also rewarded a gait in which approximately one leg is in a meaningful swing phase while the robot continues making forward progress.

CONTACT_REWARD.PY PYTHON · PYTORCH
n_contact = contacts.sum(dim=1)

sigma_c = 0.35

# Reward approximately three feet contacting the floor
r_three = torch.exp(
    -((n_contact - 3.0) / sigma_c) ** 2
)

# Penalize two-foot and zero-foot contact states
pen_two = torch.exp(
    -((n_contact - 2.0) / sigma_c) ** 2
)

pen_air = torch.exp(
    -((n_contact - 0.0) / sigma_c) ** 2
)

# Count legs that have been airborne long enough
t_swing = 0.12

num_swing = (
    air_time > t_swing
).float().sum(dim=1)

r_one_swing = torch.exp(
    -((num_swing - 1.0) / 0.25) ** 2
)
REWARD / 05 · BODY VELOCITY

The robot's world-frame velocity is transformed into the robot's body frame so the reward can measure forward, lateral, and vertical motion relative to the direction the robot is facing.

vb = Rwbᵀ vw

The forward velocity component is rewarded when it matches the commanded speed:

Rvx = exp( -((vx - vx,cmd) / 0.15)² )

Lateral velocity is discouraged so that the robot moves in a straighter line instead of drifting sideways.

Rlateral = exp(-(vy / 0.20)²)

Vertical velocity is penalized to reduce hopping and excessive body oscillation.

Pvertical = vz²
VELOCITY_REWARD.PY PYTHON · PYTORCH
v_b = self._lin_vel_body()

vx = v_b[:, 0]
vy = v_b[:, 1]
vz = v_b[:, 2]

vx_cmd = self.cmd[:, 0]

# Forward command tracking
r_vx = torch.exp(
    -((vx - vx_cmd) / 0.15) ** 2
)

# Penalize sideways drift
r_lat = torch.exp(
    -(vy / 0.20) ** 2
)

# Penalize vertical bouncing
vz_pen = vz ** 2
REWARD / 06 · LEG PARTICIPATION

To prevent the policy from relying heavily on only a subset of the legs, I measured the total joint motion of each leg using the absolute joint velocities of its hip, thigh, and calf.

Mi = |q̇hip,i| + |q̇thigh,i| + |q̇calf,i|

The average motion across all four legs is:

M̄ = (MFL + MFR + MRL + MRR) / 4

Each leg's motion is then normalized against this mean:

ri = Mi / (M̄ + ε)
LEG_MOTION.PY PYTHON · PYTORCH
qd = self.robot.data.joint_vel

leg_groups = torch.tensor(
    [
        [0, 4, 8],    # Front Left
        [1, 5, 9],    # Front Right
        [2, 6, 10],   # Rear Left
        [3, 7, 11],   # Rear Right
    ],
    device=self.device,
)

# Total motion of each leg
leg_motion = torch.sum(
    torch.abs(qd[:, leg_groups]),
    dim=2
)

# Mean motion across all four legs
mean_motion = leg_motion.mean(
    dim=1,
    keepdim=True
)

# Motion of each leg relative to the average
rel_motion = leg_motion / (
    mean_motion + 1e-4
)
PENALTY / 07 · DEAD LEG DETECTION

A major failure mode during training was a "dead leg": one leg would contribute much less motion than the others while the remaining legs compensated for it.

I penalized legs whose motion dropped below 60% of the average motion:

Pdead = Σ max( 0, 0.6 - ri )

I also tracked persistent low-motion states. If any leg remains below a motion threshold for roughly half a second, an additional stuck-leg penalty is activated.

DEAD_LEG_DETECTION.PY PYTHON · PYTORCH
# Penalize legs moving less than
# 60% of the average leg motion
dead_leg_pen = torch.relu(
    0.6 - rel_motion
).sum(dim=1)


# Track persistent low-motion legs
self.leg_stuck_counter = torch.where(
    leg_motion < 0.05,
    self.leg_stuck_counter + 1,
    torch.zeros_like(self.leg_stuck_counter)
)

# Convert 0.5 seconds into control steps
stuck_limit = 0.5 / (
    self.cfg.sim.dt *
    self.cfg.decimation
)

stuck_leg_pen = (
    self.leg_stuck_counter > stuck_limit
).any(dim=1).float()
PENALTY / 08 · LEFT-RIGHT LEG IMBALANCE

Stable locomotion also required approximately symmetric motion between corresponding left and right legs.

I normalized the difference between the rear legs and between the front legs by the robot's average leg motion:

Prear imbalance = |MRL - MRR| / M̄
Pfront imbalance = |MFL - MFR| / M̄

Rear-leg airtime was also compared directly so that one hind leg could not remain airborne significantly longer than its counterpart.

Pair imbalance = |tair,RL - tair,RR|
LEG_IMBALANCE.PY PYTHON · PYTORCH
m_FL = leg_motion[:, 0]
m_FR = leg_motion[:, 1]
m_RL = leg_motion[:, 2]
m_RR = leg_motion[:, 3]

motion_scale = (
    m_FL + m_FR + m_RL + m_RR
) / 4.0 + 1e-4

# Left/right motion symmetry
hind_imbalance = torch.abs(
    m_RL - m_RR
) / motion_scale

front_imbalance = torch.abs(
    m_FL - m_FR
) / motion_scale

# Rear-leg swing symmetry
hind_air_imbalance = torch.abs(
    air_time[:, 2] -
    air_time[:, 3]
)
REWARD / 09 · WALKING OBJECTIVE

These terms were combined into one reward function. Positive terms encourage upright posture, correct body height, forward velocity, controlled yaw, and useful contact patterns. Negative terms discourage tilt, hopping, joint collapse, dead legs, and asymmetric gait behavior.

R = Rstability + Rvelocity + Rcontact - Pmotion - Pimbalance
WALKING_REWARD.PY PYTHON · ISAAC LAB
reward = (
      2.0 * upright
    + 3.0 * r_height

    # Body stability
    - 6.0 * tilt_pen
    - 3.0 * pitch_pen
    - 3.0 * vz_pen

    # Velocity tracking
    + 3.0 * r_vx
    + 3.0 * r_lat
    + 3.0 * r_yaw

    # Ground-contact pattern
    + 1.0 * r_three
    + 0.5 * r_one_swing
    - 1.0 * pen_two
    - 1.0 * pen_air

    # Leg participation and symmetry
    - 0.08 * dead_leg_pen * gate
    - 0.12 * hind_imbalance * gate
    - 0.05 * front_imbalance * gate
    - 0.08 * hind_air_imbalance * gate
)
PHASE 2 RESULT

This reward structure encouraged the policy to develop a smoother and more balanced gait instead of maximizing forward movement through unstable behaviors. Contact sensing, per-leg motion analysis, velocity tracking, and persistent dead-leg detection gave the learning system explicit feedback about both locomotion performance and gait quality.

03
LiDAR-Based Object Avoidance

LiDAR-Based Object Avoidance

After developing a stable walking policy, I added LiDAR-based obstacle avoidance. The robot was trained around randomly placed spherical obstacles and learned to detect blocked paths, turn toward open space, and continue forward after clearing an obstacle.

Pipeline: LiDAR scan → detect obstacle → compare left/right clearance → adjust yaw → clear obstacle → reward successful avoidance.

Unitree Go2 detecting obstacles with LiDAR
LiDAR-based obstacle detection during training.
SENSOR / 01 · DIRECTIONAL LIDAR

Instead of using the full LiDAR scan directly, I reduced the point cloud into three directional measurements: front, left, and right clearance.

dfront = min(rays in front sector)

dleft = min(rays in left sector)

dright = min(rays in right sector)
LIDAR_SECTORS.PY ISAAC LAB · PYTORCH
# LiDAR points expressed in the robot body frame
x = rel_b[..., 0]
y = rel_b[..., 1]
z = rel_b[..., 2]

# Ignore floor and ceiling returns
height_filter = (z > -0.1) & (z < 1.5)

near_x = (x > 0.0) & (x < 5.0)

front_mask = near_x & (y.abs() < 0.6) & height_filter
left_mask  = near_x & (y > 0.3) & height_filter
right_mask = near_x & (y < -0.3) & height_filter

def masked_min(d, mask):
    filtered = torch.where(
        mask,
        d,
        torch.full_like(d, max_range)
    )
    return filtered.min(dim=1).values

d_front = masked_min(dist, front_mask)
d_left  = masked_min(dist, left_mask)
d_right = masked_min(dist, right_mask)
CONTROL / 02 · TURN TOWARD OPEN SPACE

When an obstacle enters the safety region in front of the robot, the relative left and right clearances determine the preferred turning direction.

s = sign(dright - dleft)

The sign of this difference is used with yaw velocity so the policy is rewarded for turning toward the more open side.

AVOIDANCE_REWARD.PY PYTHON · PYTORCH
d_safe = 0.60

# Obstacle proximity
near = torch.relu(
    d_safe - d_front
) / d_safe

near_pen = near ** 2

# Decide which side has more free space
turn_dir = torch.sign(
    d_right - d_left
)

# Reward yaw in the open direction
turn_align = torch.relu(
    turn_dir * wz
)

turn_reward = (
    turn_align * near_pen
)

# Collision penalty
collision_pen = torch.clamp(
    (0.5 - d_front) / 0.5,
    0.0,
    1.0
)
PHASE 3 RESULT

The resulting policy combined learned locomotion with directional LiDAR feedback, allowing the robot to slow or turn near obstacles, choose the more open side, and resume forward motion after clearing the obstruction.

04
Phase 4

Waypoint Navigation

The final navigation layer combined the trained walking and obstacle avoidance behaviors with waypoint-based guidance. The robot continuously calculated its position relative to the next target waypoint and generated forward-velocity and yaw commands to move toward it.

Additional logic handled narrow corridors, sharp turns, and nearby obstacles so the robot could slow down, rotate, or temporarily reverse without losing the overall navigation objective.

Pipeline: Current position → target waypoint → heading error → velocity/yaw command → obstacle correction → waypoint progress → advance to next waypoint.

Unitree Go2 navigating through warehouse waypoints
Full navigation policy operating through the simulated warehouse.
NAVIGATION / 01 · WAYPOINT TARGETING

At each control step, the robot computes the vector between its current planar position and the active waypoint.

Δp = ptarget - probot
d = √(Δx² + Δy²)

The desired heading is calculated using the direction of this vector:

ψtarget = atan2(Δy, Δx)
WAYPOINT_TARGETING.PY PYTHON · PYTORCH
current_pos = self.robot.data.root_link_pos_w[:, :2]

target = self.waypoints[
    self.current_waypoint_idx
]

to_target = target - current_pos

dist_to_target = torch.norm(
    to_target,
    dim=-1
)

# Desired robot heading
waypoint_heading = torch.atan2(
    to_target[:, 1],
    to_target[:, 0]
)

self.heading_ref = waypoint_heading
NAVIGATION / 02 · HEADING CONTROL

The current robot yaw is compared with the desired waypoint heading. The angular difference is wrapped into a continuous -π to π range.

eψ = atan2( sin(ψtarget - ψ), cos(ψtarget - ψ) )

This heading error is then converted into a commanded yaw rate.

HEADING_CONTROL.PY PYTHON
current_yaw = self._yaw_from_quat(
    self.robot.data.root_quat_w
)

heading_err = torch.atan2(
    torch.sin(
        self.heading_ref - current_yaw
    ),
    torch.cos(
        self.heading_ref - current_yaw
    )
)

# Ignore very small heading errors
significant_err = (
    heading_err.abs() > 0.1
)

yaw_correction = torch.where(
    significant_err,

    torch.clamp(
        heading_err * 1.5,
        -self.cfg.max_yaw_rate,
        self.cfg.max_yaw_rate
    ),

    torch.zeros_like(heading_err)
)
NAVIGATION / 03 · CORRIDOR CENTERING

In narrow corridors, side-wall LiDAR readings are used to keep the robot centered rather than treating the walls themselves as obstacles.

ewall = k(dleft - dright)

If the robot is closer to one wall than the other, this difference adds a small yaw correction toward the center of the corridor.

CORRIDOR_CONTROL.PY PYTHON · LIDAR
# Detect narrow corridor
in_corridor = (
    (self.lidar_left  < 2.0) &
    (self.lidar_right < 2.0) &
    (self.lidar_front > 2.0)
)

# Increase heading authority inside corridor
corridor_yaw_gain = torch.where(
    in_corridor,
    torch.full_like(heading_err, 2.5),
    torch.full_like(heading_err, 1.5)
)

yaw_correction = torch.where(
    significant_err,

    torch.clamp(
        heading_err * corridor_yaw_gain,
        -self.cfg.max_yaw_rate,
        self.cfg.max_yaw_rate
    ),

    torch.zeros_like(heading_err)
)

# Center between the two walls
wall_center_err = (
    self.lidar_left -
    self.lidar_right
) * 0.15

corridor_correction = torch.where(
    in_corridor,
    yaw_correction + wall_center_err,
    yaw_correction
)
NAVIGATION / 04 · SHARP TURN HANDLING

Large heading errors indicate that the robot must perform a sharp turn. Instead of stopping completely, forward speed is reduced while the yaw command is increased.

|eψ| > 0.75  →  sharp turn

If the robot is also extremely close to an obstacle during the turn, the forward command becomes slightly negative so the robot can create room before rotating.

SHARP_TURN_CONTROL.PY PYTHON
sharp_turn_needed = (
    heading_err.abs() > 0.75
)

too_close_while_turning = (
    sharp_turn_needed &
    (self.lidar_front < 0.6)
)

SHARP_TURN_SPEED_FACTOR = 0.55

sharp_turn_speed = torch.where(

    too_close_while_turning,

    # Reverse slightly if boxed in
    torch.full_like(
        waypoint_speed_scale,
        -0.3
    ),

    torch.where(
        sharp_turn_needed,

        # Slow forward movement during turn
        waypoint_speed_scale *
        SHARP_TURN_SPEED_FACTOR,

        waypoint_speed_scale
    )
)
NAVIGATION / 05 · MOTION COMMAND

The navigation controller ultimately produces two high-level commands: forward velocity and yaw rate.

command = [vx, ωz]

These commands become observations for the reinforcement-learning locomotion policy, which determines the corresponding 12-joint gait.

NAVIGATION_COMMAND.PY PYTHON
# Forward velocity
self.cmd[go_forward, 0] = (
    self.cfg.max_lin_vel
    * corridor_speed[go_forward]
    * sharp_turn_speed[go_forward]
)

# Yaw velocity
self.cmd[go_forward, 1] = torch.where(

    sharp_turn_needed[go_forward],

    torch.sign(
        heading_err[go_forward]
    ) * sharp_turn_yaw_rate[go_forward],

    corridor_correction[go_forward]
)
REWARD / 06 · NAVIGATION PROGRESS

The policy is rewarded for reducing its distance to the current waypoint. I tracked progress independently along the global X and Y directions and added a larger bonus when the target waypoint was reached.

Rx = |Δxprevious| - |Δxcurrent|
Ry = |Δyprevious| - |Δycurrent|

Positive values mean the robot moved closer to the target.

WAYPOINT_REWARD.PY PYTHON · REINFORCEMENT LEARNING
target = self.waypoints[
    self.current_waypoint_idx
]

dx = target[:, 0] - current_pos[:, 0]
dy = target[:, 1] - current_pos[:, 1]

dist_to_waypoint = torch.sqrt(
    dx**2 + dy**2
)

# Distance from previous position
prev_dx = (
    self.waypoints[
        self.current_waypoint_idx
    ][:, 0]
    - self.prev_pos_waypoint[:, 0]
)

prev_dy = (
    self.waypoints[
        self.current_waypoint_idx
    ][:, 1]
    - self.prev_pos_waypoint[:, 1]
)

# Reward reduction in X/Y error
x_progress = (
    prev_dx.abs() -
    dx.abs()
)

y_progress = (
    prev_dy.abs() -
    dy.abs()
)

x_reward = torch.clamp(
    x_progress,
    -1.0,
    1.0
)

y_reward = torch.clamp(
    y_progress,
    -1.0,
    1.0
)
NAVIGATION / 07 · WAYPOINT COMPLETION

Each waypoint has an acceptance radius. Once the robot enters that region, the controller advances to the next waypoint in the route.

d < rwaypoint  →  waypoint reached
WAYPOINT_ADVANCE.PY PYTHON
current_radius = self.waypoint_radii[
    self.current_waypoint_idx
]

reached = (
    dist_to_waypoint <
    current_radius
).float()

# Reward successful arrival
waypoint_bonus = reached * 20.0

# Advance to next waypoint
self.current_waypoint_idx = torch.where(
    dist_to_waypoint < 1.0,

    (
        self.current_waypoint_idx + 1
    ) % len(self.waypoints),

    self.current_waypoint_idx
)
PHASE 4 RESULT

This stage combined the previous locomotion and obstacle-avoidance behaviors into a complete navigation system. The robot could follow a sequence of warehouse waypoints while correcting its heading, centering itself in narrow corridors, negotiating sharp turns, and continuing toward the final destination after avoiding obstacles.