Skip to content

A semi-humanoid picks a shelf

Walks through examples/vehicles/semi_humanoid_demo.py — a humanoid upper body on a wheeled base taking the low board and the top board of one bay, a carton per hand.

A semi-humanoid is bought for one thing a cobot on a cart cannot do: it works from the floor to over its head, because its torso moves its arms' bases. To a cell it is nothing new. It is a vehicle with no body whose running gear is the robot itself, and a robot whose joints fall into groups — two arms, a torso, hands, a head. This tutorial asks the four questions a cell asks of any mobile machine — does it fit the aisle, does it reach, does it clash, how long does it take — and the one this kind adds: how low and how high does it have to work.

python examples/vehicles/semi_humanoid_demo.py
Unitree G1-D (catalog `unitree/g1/g1-d/r2`); aisle 1.40 m
cycle time: 55.39s
  to bay         0.00 –   4.75s
  look low       4.75 –   5.55s
  torso down     5.55 –   8.05s
  pick low       8.05 –  12.37s
  grasp low     12.37 –  12.77s
  lift low      12.77 –  16.98s
  straighten    16.98 –  19.48s
  torso up      19.48 –  21.98s
  look top      21.98 –  21.98s
  pick top      21.98 –  29.96s
  grasp top     29.96 –  30.36s
  lift top      30.36 –  42.62s
  to dock       42.62 –  47.37s
  torso stand   47.37 –  47.37s
  set down      47.37 –  49.29s
  release       49.29 –  49.69s
  clear         49.69 –  52.89s
  fold          52.89 –  55.39s
at the bay (4.75s) the wheels have turned: Left_Wheel_Joint +20.4 rad, Right_Wheel_Joint -27.9 rad
torso: low {'Yaw_Joint': 2.0}, top {'LZ_mt_Joint': 0.21, 'LZ_it_Joint': 0.21}, stand as it travels
carton_low ends at (-0.45, 0.20, 0.90) — on the stand
carton_top ends at (-0.45, -0.20, 0.90) — on the stand

The machine is a Unitree G1-D from the catalog — a two-stage lift column, a waist that bows and turns, two 7-axis arms with three-finger hands, a differential base; the first run fetches it into the botrail cache. --robot rby1 is a Rainbow Robotics RB-Y1 A, whose torso is a six-joint leg; --robot rby1m (the same on mecanum wheels), --robot ffw (a ROBOTIS AI Worker FFW-SG2, a lift column on swerve modules), --robot galbot (a Galbot G1, a five-joint torso on omni wheels) and --robot r1pro (a Galaxea R1 Pro, a four-joint torso on swerve modules) are machines that do not turn; --robot semi runs the same cell on the primitive machine in examples/assets/semi_humanoid_test.urdf, with no download (see the offline machine). --compare puts all of them in front of one bay (Which machine for this bay).

A G1-D at the bay: the column up, the left hand on the top board's carton, the low one already in the right hand; a timeline lane per group and the two vision lanes

The robot is its own vehicle

    # A base that turns backs out of the dock and pivots once, at the bay; one
    # that does not crabs down the aisle, nose to the racks the whole way.
    route = ({"path": [bay_xy, (CORNER_X, 0.0), (0.0, 0.0), dock_xy], "stations": {"bay": 0, "dock": 3}}
             if machine.holonomic else
             {"path": [bay_xy, (CORNER_X, 0.0), dock_xy], "stations": {"bay": 0, "dock": 2},
              "turn_speed": machine.turn, "allow_reverse": True})
    scene.add_vehicle(BASE, body=[], start="dock", speed=machine.speed, drive=wheels.vehicle_drive, **route)
    scene.mount_robot(BASE, robot=ROBOT, wheels=wheels)

The vehicle has body=[]: there is no chassis box, because the robot's own links are the chassis — they are what the aisle check drives, in the posture the machine actually travels in. mount_robot(wheels=...) is the statement that this robot is the vehicle's running gear (The robot is the vehicle): its wheel joints turn by exactly what the vehicle drives, it stands on its base frame, it is put in its travel posture, and the BOM lists one machine. The printed wheel angles are that closed form — the machine backs 2.4 m out of the dock, turns a quarter, and drives the last 0.35 m nose first, and the G1-D's left wheel counts the other way because its axis points the other way.

The dock is the path's end. A parked vehicle faces the leg it arrived by, so the machine starts nose to the hand-off stand, and with allow_reverse it arrives at the bay nose first as well — one quarter turn in the whole trip.

Everything about the wheels and the groups comes out of the package:

def load(machine: Machine):
    """The robot with its groups declared, and its wheels."""
    if machine.package:
        # Arms, torso, hands and the torso-with-arm composites come declared
        # in the package; so do the wheels, the base frame and the posture
        # the machine travels in.
        return bt.Robot.from_catalog(machine.package), bt.Wheels.from_catalog(machine.package)

The torso first, then the arm

An arm is planned without the torso. Its group leaves the lift and the waist out on purpose: a plan that moved them would move the other arm's base under whatever it holds. So a reach is two moves — a ramp puts the torso where the target needs it, then a planned motion takes the hand there — and the two have to agree: the arm's poses are solved with the torso where the ramp will have put it.

Which torso posture? The machine description lists candidates per task, gentlest first, and teaching keeps the first one the hand reaches all of the task's targets from, collision-free:

    def task(kind: str, heading: float, targets: dict, leaves: dict) -> None:
        """One torso posture for all of `targets` (`name -> (arm, point)`):
        the gentlest of the machine's candidates that reaches them.
        `leaves` names, per arm, the target its hand leaves for the carry from."""
        refused = []
        for torso in machine.torso[kind]:
            solved, seed = {}, poses["travel"]
            try:
                for name, (arm, point) in targets.items():
                    solved[name] = seed = solve(arm, torso, point, heading, seed)
            except LookupError as err:
                refused.append((err.short, f"{torso}: {err}"))
                continue
            poses.update(solved)
            poses[f"torso_{kind}"] = torso
            for arm, last in leaves.items():
                poses[f"{kind}_{arm}_carry"], poses[f"{kind}_{arm}_carry_kind"] = back_to_carry(
                    arm, torso, heading, solved[last])
            return
        # The first line is the verdict a table wants: how far off the best posture is.
        shorts = [short for short, _ in refused]
        verdict = ("the hand gets there only touching something" if None in shorts
                   else f"the closest is {min(shorts) * 1e3:.0f} mm short")
        raise RuntimeError(f"{machine.key}: no torso posture reaches `{kind}` — {verdict}:\n  "
                           + "\n  ".join(text for _, text in refused))

For the G1-D that prints low {'Yaw_Joint': 2.0}: Yaw_Joint is the waist's pitch (the vendor's name — the catalog's validation report says so), and the machine reaches a carton 0.30 m over the floor by bowing 115 degrees into the bay. The top board is the two column stages, 0.42 m. Ask for a posture it does not have and the refusal lists every candidate with what stopped it — the right hand cannot take (2.60, 0.66, 0.30), 60 mm short.

Every pose is solved with the machine standing where it will work (Stand.at("bay") puts the base where the vehicle will carry it, the mount's offset and all), so the bay's boards and posts are in the collision check at teach time. What comes back is a joint vector, and a joint vector is relative to the base: it lands on the same spot in the machine's frame wherever the machine stands.

Two details of the arm's motions are worth stealing:

  • Solve the most constrained pose first, and its neighbours from it. A 7-axis arm has a posture for every target; a straight line between two taught poses only exists if they are the same posture, moved.
  • Back straight out before turning away. The top carton comes out in a Cartesian line, level, until it has cleared the board's front edge, and goes on in a line into the carry. Planned as a joint move, the same hand swings the carton through the board — and the planner's way around it costs seconds.
    def line_exists(arm: str, start: list, goal: list) -> bool:
        """Whether the hand gets from `start` to `goal` in a straight line.
        Two taught poses of a 7-axis arm may be different postures of the
        same hand, and then no line joins them — it dies part way with
        `configuration jump (IK branch change)`. The planner is asked, on a
        motion that exists only for the question."""
        scene.set_joint_positions(start, robot=ROBOT)
        scene.add_segment(PROBE, goal=goal, kind=LINE, group=arm, robot=ROBOT)
        try:
            scene.plan_motion(PROBE, broadcast=False)
            return True
        except (ValueError, RuntimeError):
            return False
        finally:
            scene.remove_motion(PROBE)

    def back_to_carry(arm: str, torso: dict, heading: float, start: list) -> tuple:
        """The carry under this torso, and how a hand gets back into it from
        `start`. A torso that only lifts leaves the carried hand level: the
        pose is solved like the others and the way in is a straight line,
        the hand level all the way (out from under a board, not through it)
        — when there is one; a planned move when there is not. A torso that
        bows takes the hand's attitude with it: the way in is planned."""
        q = joints(scene, poses["travel"], torso)
        position, attitude = scene.link_pose_at(held_at(arm), q, robot=ROBOT)
        if level_hand(attitude, heading):
            carry = solve(arm, torso, position, heading, q)
            return carry, LINE if line_exists(arm, start, carry) else "joint"
        return q, "joint"

Ramps are yours to check

A planned motion is checked along its path. A ramp is not — its path is the author's — and the rollout only checks a riding robot against the cell while its vehicle moves. So the demo samples each torso sweep at the station it happens at, before the bake:

def ramp_contacts(scene: bt.Scene, station: str, a: list, b: list, samples: int = 24,
                  holonomic: bool = False) -> list:
    """Link/obstacle pairs a ramp from `a` to `b` would touch with the
    machine parked at `station`, sampled along its cubic: a ramp is not
    planned, so its sweep is the author's to check before the bake."""
    hits = set()
    with Stand(scene, holonomic) as stand:
        stand.at(station)
        for k in range(samples + 1):
            u = k / samples
            ease = u * u * (3 - 2 * u)
            scene.set_joint_positions([x + (y - x) * ease for x, y in zip(a, b)], robot=ROBOT)
            hits.update((p[1], q[1]) for p, q in scene.check_collisions())
    return sorted(hits)

It earns its keep at once. Ramping the G1-D from its bow straight to the top posture is refused:

g1d: the `torso up` ramp sweeps through the cell: [('head_link', 'bay/board_top')]

Raised as it stands, a bowed torso brings its head up under the top board. Hence the cycle's straighten step: upright first, then up.

The head camera is an input

    scene.add_camera("head_cam", robot=ROBOT, link=link, position=offset, quaternion=orientation,
                     fov=fov, resolution=RESOLUTION, near=0.1, far=3.0, body_visible=False)
    for tag in ("low", "top"):
        scene.add_vision_sensor(f"sees_{tag}", camera="head_cam", watch=[f"carton_{tag}"])
    return scene

A vision sensor behind the head camera is a signal: ON while the watched carton is in the view frustum with a clear line of sight. Each pick waits for it:

    step("look low", ramp(look_s, look["low"]), S.signal("sees_low"))

The glance itself is taught like the torso: the machine description lists glances gentlest first, and teaching keeps the first that frames the carton — the geometric half of what the sensor judges, asked of the carton's corners — and gets there without sweeping the cell. A fixed head makes that matter: the G1-D looks down by bowing — 0.3 rad is enough, because its head camera already looks 47.6 degrees down (the package's head_camera frame: the vendor's frame of the legged G1's head camera, on the head the two machines share) — and a glance that frames nothing, or sweeps the carried hand onto a board, is refused at teach time with every candidate's reason, not found out in the bake.

So a bay that was not replenished stalls the cycle at the glance, by name (timed out after 60s waiting in step 8 (`look top`)), instead of closing a hand on air. The two lanes at the bottom of the timeline show the geometry doing the gating: the low carton comes into view with the glance down; the top one stands on a board over the machine's eyes and only appears as the torso rises past it. The G1-D's head is fixed, so its glance is a bow (look is a torso posture); the primitive machine has a pan-tilt head and a group for it.

The fold rides the drive

    # The fold rides the drive: the torso comes down while the wheels roll.
    step("to dock", [S.goto(BASE, "dock"), *ramp(torso_s, look["ahead"], machine.travel)], S.device_done(BASE))

A planned motion cannot start while the vehicle drives — a plan is baked in world coordinates — but a ramp can, and that is how the column comes down from the top board's height on the way home. The ramp's samples bake into the same joint track as the turning wheels, so the fold costs no cycle time. At the stand both arms set down at once: two motions in one step, each planned with the other arm frozen and the pair re-checked every tick.

What the cell asks of the machine

print(scene.bom().to_markdown())
print(bt.select.requirements(scene, timeline=tl).to_markdown())
| line | category | requirement | basis | provided | status |
|---|---|---|---|---|---|
| machine | vehicle.mobile_manipulator | payload_kg >= 0.35 | grasps carton_low 0.35 kg | 3 | ok |
|  |  | vertical_reach_min_mm <= 302 | lowest taught hand position 0.30 m over the floor (right, `pick_low`) | 0 | ok |
|  |  | vertical_reach_max_mm >= 1532 | highest taught hand position 1.53 m over the floor (left, `lift_top`) | 2000 | ok |
|  |  | arm_count >= 2 | arms taught: left, right | 2 | ok |
|  |  | max_speed_mps >= 0.8 | travel speed | 1.5 | ok |

Notes:
- machine: reach_mm is not asked of a machine that moves its own arms' bases — the cell asks its working heights (vertical_reach_*), and whether a machine reaches them is its teaching's and its bake's to say

One line: the vehicle is not a second purchase and there is no controller box. vertical_reach_min_mm / vertical_reach_max_mm are derived for a machine mounted as its vehicle's wheels — the lowest and the highest taught hand position over the floor it stands on — and they are what tells a lift column from a bowing waist from a fixed pedestal when shopping the catalog.

There is no reach_mm row, and the note says why. An arm on a fixed base is asked its reach — the farthest taught target from its first joint — because that distance is the cell's: the base stands where the layout put it. This machine takes its arms' bases to the work, so how far a hand is from its shoulder is its own way of standing there; another machine lifts, bows or squats differently. A requirement is not a remark: a stated figure that falls short of one is an error in scene.check() and a filter in the catalog search, and this one would have filtered out the very machine whose cycle had just baked. What the cell does ask is the working heights — and whether a machine reaches them is what teaching and the bake say, one machine at a time (Which machine for this bay).

--deliverables DIR writes the whole hand-over set from the same bake — project, generated script, BOM, I/O list, PLCopen, interlocks, layout sheet, USD and the cell report. The generated script re-authors the mount (wheels=bt.Wheels(...)) and bakes to the same cycle time; the robot's own controller script is left out, by name: a machine with several groups is one program per arm.

--aisle 0.9 answers the first question. The check is the machine's own links against the row across the aisle:

cycle failed: robot `machine` riding `base` collides with `row/uprights/c3` at t = 0.060s (`AGV_link`); widen the aisle or re-teach the path

Which machine for this bay

Each machine's own run has its own bay — boards where that torso has to move to reach them. --compare asks the buyer's question instead: one bay (--robot's), the same aisle, the same way of teaching it, every machine.

python examples/vehicles/semi_humanoid_demo.py --compare --robot semi
bay: boards at 0.75 m and 1.50 m; aisle 1.40 m
machine  torso, low board                                     torso, top board                                       cycle   verdict
g1d      Yaw_Joint 0.4                                        LZ_mt_Joint 0.21, LZ_it_Joint 0.21                         —   step 11 (`lift top`): planning failed: segment 1: cartesian line failed at 59%: IK did not converge (unreac...
rby1     torso_1 1.2, torso_2 -2.4, torso_3 1.2               as it travels                                         71.08s   ok
rby1m    torso_1 0.9, torso_2 -1.8, torso_3 0.9               as it travels                                         66.55s   ok
ffw      lift_joint -0.2                                      as it travels                                         73.11s   ok
galbot   leg_joint1 0.8, leg_joint2 2, leg_joint3 1.2         leg_joint2 2.3, leg_joint3 1.4                        63.25s   ok
r1pro    torso_joint1 -0.7, torso_joint2 2, torso_joint3 1.3  torso_joint1 -0.6, torso_joint2 1.6, torso_joint3 1        —   step 9 (`pick top`): planning failed: segment 1: cartesian line failed at 60%: IK did not converge (unreach...
semi     lift_joint 0                                         lift_joint 0.4                                        52.85s   ok

and in front of the G1-D's bay, whose low board is 0.25 m over the floor (--compare alone):

bay: boards at 0.25 m and 1.45 m; aisle 1.40 m
machine  torso, low board                                     torso, top board                                       cycle   verdict
g1d      Yaw_Joint 2                                          LZ_mt_Joint 0.21, LZ_it_Joint 0.21                    55.39s   ok
rby1     —                                                    —                                                          —   no torso posture reaches `low` — the closest is 140 mm short
rby1m    —                                                    —                                                          —   no torso posture reaches `low` — the closest is 87 mm short
ffw      —                                                    —                                                          —   no torso posture reaches `low` — the closest is 120 mm short
galbot   —                                                    —                                                          —   no glance shows the low carton
r1pro    —                                                    —                                                          —   no torso posture reaches `low` — the closest is 196 mm short
semi     —                                                    —                                                          —   no torso posture reaches `low` — the closest is 263 mm short

Four ways of making height, and they show. The column machines have no way down to 0.25 m. The RB-Y1 squats 0.45 m and still ends 140 mm short of a level hand on that carton — the postures that put it there fold the machine into its own base. The Galbot G1 folds its leg and reaches both boards of that bay, and is stopped by its eyes instead: its head tilts 28 degrees down, and from where it travels that does not show a carton 0.30 m over the floor. (The package has no camera link; the camera is this cell's reading of the head's mount frame, so that verdict is about the reading as much as about the machine.) The G1-D bows 115 degrees and takes it; in front of the other bay it is taught both boards (a bow of 0.4 rad, the column up) and then the bake stops it at the top one: 1.58 m up, its level hand has no room to back the carton 0.17 m straight out from under the board — the line runs out of arm at 59 %. The Galaxea R1 Pro finishes in front of neither: this cell holds the torso still while an arm works and pulls a carton 0.15 m straight towards the body, and an elbow that folds 100 degrees keeps that wrist more than 0.35 m from its shoulder — what is left between too near and too far is millimetres wide. A cell that let the torso lean back with the pull would suit it; that is the cell's procedure speaking, and the table says which step. None of the verdicts is an opinion about the machine — each is this bay's requirement, met or not — and they come from three places: the package (not to be had), the teaching (no torso posture of the ones the machine description lists puts the hand on the board, with how far off the best one is; no glance that shows the carton), the bake and the checks before it (a sweep through the bay, a body that brushes the aisle).

Four of the seven do not turn. A holonomic base — the RB-Y1 M's mecanum wheels, the AI Worker's and the R1 Pro's swerve modules, the Galbot G1's omni wheels — docks facing whatever it faced when parked: in the cell above it would arrive side-on to the bay. The machine description says so (holonomic=True) and the cell is laid out by it — the stand on the bay's side of the aisle, the dock up a spur of its own so the machine parks nose to the racks, and the drive a crab along the aisle: 3.1 m in 3.9 s, against 4.75 s for the turning machines' 2.75 m and a quarter turn. The bay, the aisle and the teaching are the same, which is what makes the rows comparable.

The RB-Y1 added two lines to what a machine description has to say:

  • How a level hand gets back into the carry is asked, not assumed. A 7-axis arm's carry and its taught poses may be different postures of one hand, and no straight line joins two postures — it dies part way with configuration jump (IK branch change). Teaching asks the planner: it authors a one-segment motion from where the hand leaves to the carry, plans it, and removes it again (scene.remove_motion), so nothing of the question is left in the project. A return that has a line keeps the hand level all the way; one that has not is a planned move. Of the RB-Y1's four returns three are lines — and the straight lines that matter, out from under the boards, are not up for the question at all.
  • A carry below the board backs out over its edge first. A torso that squats carries lower than it picks: straight from over the low carton into that carry cuts the board's front edge (carton_low x bay/board_low), so teaching adds a pose that pulls the hand back over the edge — only when the carry is below the board.

It also says something about collision shapes. RB-Y1 r1 checked its wrist with the vendor's capsule — 75 mm in radius and long enough to wrap the hand and whatever it holds, which suits a self-collision check and means the hand can never come within 75 mm of a board. The cell pins …/rb-y1-a/r2, where the wrist has its own shape; what may touch is declared, not inflated.

The offline machine

--robot semi is a primitive-geometry semi-humanoid from the checkout: a lift column, a waist yaw, a pan-tilt head, two 4-axis arms with one-finger hands whose twin is a mimic. It has no package, so the script declares what a package would:

    robot = bt.Robot.from_urdf(ASSETS / "semi_humanoid_test.urdf")
    arm = ("shoulder_pitch", "shoulder_roll", "elbow", "wrist")
    for side in SIDES:
        robot = robot.define_group(side, tip=f"{side}_tcp", flange=f"{side}_flange",
                                   joints=[f"{side}_{j}" for j in arm])
    robot = robot.define_group("torso", tip="chest", joints=["lift_joint", "waist_yaw_joint"])
    robot = robot.define_group("head", tip="head_camera", joints=["head_pan_joint", "head_tilt_joint"])
    wheels = bt.Wheels({"left_wheel_joint": 0.10, "right_wheel_joint": 0.10},
                       base_frame="base_footprint", posture=machine.travel)
    return robot, wheels

Its bay is its own — boards at 0.75 m and 1.50 m, where a 0.40 m column makes the difference between reaching and not — and its cycle is what python/tests/test_semi_humanoid_demo.py pins: both cartons on the stand, each pick gated by the camera, the fold on the move, one BOM line, the working heights in the requirements, a narrow aisle refused by name.

A 4-axis arm adds one lesson of its own. Its hand can only be level in the arm's own plane, so a Cartesian line between two taught poses exists only if both have exactly the same attitude — a carry taught in joint angles that is level to a hair is not level enough, and the line dies at 91 %. The carry is therefore solved by IK with the same orientation as every other pose (back_to_carry above).