← Quay lại Blog
aiopenarmsim-to-realdeploymentsimplevla-rl

SimpleVLA-RL (11): Sim-to-Real cho OpenArm

Deploy model SimpleVLA-RL từ simulation lên OpenArm thật — camera setup, action mapping, và tips giảm sim-to-real gap.

Nguyễn Anh Tuấn11 tháng 4, 202617 phút đọc
SimpleVLA-RL (11): Sim-to-Real cho OpenArm

Sim-to-Real Transfer cho OpenArm với SimpleVLA-RL

Bạn đã có một VLA policy được train bằng SimpleVLA-RL trong Isaac Lab — SFT tạo baseline, GRPO đẩy success rate lên cao. Giờ đến bước quan trọng nhất và cũng khó nhất trong toàn bộ pipeline: đưa model từ simulation sang robot thật. Đây là lúc mọi giả định về physics, visual, và dynamics bị thử thách bởi thực tế.

Bài này sẽ hướng dẫn chi tiết từ setup hardware OpenArm, tạo inference server, ánh xạ action từ sim sang real, đến các kỹ thuật giảm sim-to-real gap. Chúng ta cũng sẽ xem kết quả tham khảo từ paper SimpleVLA-RL trên robot Piper và phân tích cách áp dụng cho OpenArm.

Sim-to-Real Gap: Ba kẻ thù chính

Trước khi đi vào code, hãy hiểu tại sao policy train trong sim thường không hoạt động hoàn hảo trên robot thật. Có ba loại gap chính:

1. Visual Gap — Mắt sim khác mắt thật

Rendering trong Isaac Lab dù đã rất đẹp nhưng vẫn khác camera thật. Ánh sáng, shadow, texture, reflection — tất cả đều khác. Một hộp carton trong sim có bề mặt hoàn hảo, trong khi hộp thật có vết nhăn, tem nhãn, và phản chiếu ánh sáng không đều.

2. Dynamics Gap — Vật lý sim khác vật lý thật

Ma sát giữa gripper và hộp carton trong sim được mô phỏng bằng công thức toán học đơn giản. Trong thực tế, ma sát phụ thuộc vào chất liệu bề mặt, độ ẩm, lực kẹp, góc tiếp xúc — vô số yếu tố mà sim không thể capture hết.

3. Action Gap — Điều khiển sim khác điều khiển thật

Trong sim, khi bạn gửi joint_position = 1.57, khớp robot nhảy đến vị trí đó ngay lập tức (hoặc theo một dynamic model đơn giản). Trên robot thật, motor có quán tính, có backlash (khe hở cơ khí), có delay từ CAN bus communication — tất cả tạo ra sự khác biệt giữa action sim và action thật.

Sim-to-real transfer là thử thách lớn nhất trong robot learning

Bước 1: Setup Hardware OpenArm

Kết nối CAN Bus

OpenArm sử dụng giao thức CAN bus để giao tiếp với các motor servo. Có hai cách kết nối:

Cách 1: Dùng openarm_can library (không phụ thuộc LeRobot)

# Cài openarm_can — thư viện giao tiếp trực tiếp với OpenArm
pip install openarm-can

# Hoặc clone từ source
git clone https://github.com/OpenArm-org/openarm_can.git
cd openarm_can
pip install -e .
# Test kết nối
from openarm_can import OpenArmController

controller = OpenArmController(
    can_interface="can0",      # CAN bus interface
    baudrate=1000000,          # 1Mbps
)

# Kiểm tra tất cả servo đều respond
status = controller.ping_all()
print(f"Found {len(status)} servos: {status}")
# Expected: Found 8 servos: [1, 2, 3, 4, 5, 6, 7, 8]

Cách 2: Dùng lerobot CLI chỉ cho hardware setup

# CHỈ dùng cho setup CAN — training vẫn là SimpleVLA-RL
pip install lerobot

# Setup CAN interface
sudo ip link set can0 type can bitrate 1000000
sudo ip link set can0 up

# Verify
candump can0  # Phải thấy CAN frames

Lưu ý: chúng ta dùng LeRobot CLI chỉ để setup CAN hardware vì nó có sẵn scripts tiện lợi. Toàn bộ training và inference vẫn 100% SimpleVLA-RL pipeline.

Calibrate Joints

Calibration đảm bảo vị trí zero của mỗi khớp trong code tương ứng với vị trí zero thật trên robot:

from openarm_can import OpenArmController, Calibrator

controller = OpenArmController(can_interface="can0")
calibrator = Calibrator(controller)

# Bước 1: Đưa robot về home position (tay duỗi thẳng đứng)
# Làm bằng tay hoặc dùng teach pendant
input("Đưa robot về home position rồi nhấn Enter...")

# Bước 2: Ghi nhận offset cho mỗi khớp
offsets = calibrator.calibrate()
print(f"Joint offsets: {offsets}")

# Bước 3: Lưu calibration
calibrator.save("openarm_calibration.json")

Mount Camera

Camera position là yếu tố cực kỳ quan trọng cho sim-to-real. Trong sim, bạn đã đặt camera ở một vị trí cụ thể — camera thật phải ở vị trí tương tự:

# Camera parameters trong Isaac Lab (từ Part 9)
SIM_CAMERA = {
    "position": [0.5, 0.0, 1.2],   # x, y, z relative to base
    "target": [0.3, 0.0, 0.75],    # Look-at point (table center)
    "fov": 69,                      # Field of view (degrees)
    "resolution": [640, 480],       # Raw resolution
}

# Trên robot thật: mount USB webcam ở cùng vị trí
# Dùng thước đo để đặt camera:
# - 50cm trước robot base
# - 120cm cao (ngang vai robot)
# - Hướng xuống bàn, FOV ~69 độ
# - Logitech C920 có FOV 78° → zoom in nhẹ hoặc crop

Tips khi mount camera:

Bước 2: Tạo Inference Server

Thay vì chạy VLA model trực tiếp trên máy tính điều khiển robot (thường yếu), chúng ta tạo một inference server trên máy có GPU mạnh:

Architecture

┌─────────────────────────────────────────────────────────┐
│                    GPU Server                           │
│  ┌─────────────────────────────────────────────────┐    │
│  │  SimpleVLA-RL Inference                         │    │
│  │  (OpenVLA-OFT model loaded in GPU memory)       │    │
│  │  POST /predict → {image} → {action_8dof}       │    │
│  └─────────────────────────────────────────────────┘    │
└────────────────────┬────────────────────────────────────┘
                     │ HTTP (LAN)
┌────────────────────┴────────────────────────────────────┐
│                Robot Controller PC                      │
│  ┌────────────┐    ┌──────────────┐    ┌────────────┐  │
│  │ USB Camera │───>│ Control Loop │───>│ OpenArm    │  │
│  │ (Logitech) │    │ (30 Hz)      │    │ CAN Bus    │  │
│  └────────────┘    └──────────────┘    └────────────┘  │
└─────────────────────────────────────────────────────────┘

Inference Server Code

# inference_server.py — Chạy trên GPU server
import torch
import numpy as np
from flask import Flask, request, jsonify
from PIL import Image
import io
import time

# Load SimpleVLA-RL checkpoint
from prismatic.models import load_vla_model

app = Flask(__name__)

# Global model — load 1 lần khi start server
MODEL = None
DEVICE = "cuda:0"

def load_model(checkpoint_path):
    """Load trained SimpleVLA-RL model"""
    global MODEL
    print(f"Loading model from {checkpoint_path}...")
    MODEL = load_vla_model(
        checkpoint_path,
        device=DEVICE,
        use_flash_attn=True,
    )
    MODEL.eval()
    print("Model loaded successfully!")

@app.route("/predict", methods=["POST"])
def predict():
    """
    Nhận image → trả về action 8-DoF
    Input: multipart form with 'image' field (JPEG)
    Output: JSON {"action": [8 floats], "inference_ms": float}
    """
    start = time.time()
    
    # Đọc image từ request
    image_file = request.files["image"]
    image = Image.open(io.BytesIO(image_file.read())).convert("RGB")
    image = image.resize((224, 224))  # Resize cho VLA input
    
    # Task description — cố định cho box grasping
    task = "Pick up the carton box from the table"
    
    # Inference
    with torch.no_grad():
        action = MODEL.predict_action(
            image=image,
            instruction=task,
            unnorm_key="openarm_box_grasp",  # Denormalize key
        )
    
    # action shape: (8,) — 7 joint positions + 1 gripper
    action_list = action.cpu().numpy().tolist()
    
    inference_ms = (time.time() - start) * 1000
    
    return jsonify({
        "action": action_list,
        "inference_ms": inference_ms,
    })

@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "ok", "model_loaded": MODEL is not None})

if __name__ == "__main__":
    import sys
    checkpoint = sys.argv[1] if len(sys.argv) > 1 else "./checkpoints/openarm-rl-v1/best"
    load_model(checkpoint)
    app.run(host="0.0.0.0", port=5000, threaded=False)

Chạy Server

# Trên GPU server
conda activate simplevla
python inference_server.py ./checkpoints/openarm-rl-v1/best

# Test
curl -X POST http://gpu-server:5000/predict \
    -F "image=@test_image.jpg"
# Expected: {"action": [0.12, -0.34, ...], "inference_ms": 45.2}

Inference time trên 1 GPU A100 khoảng 30-60ms — đủ nhanh cho control loop 10-30 Hz.

Bước 3: Action Mapping Sim → Real

Đây là bước mà nhiều người bỏ qua rồi tự hỏi tại sao robot hoạt động kỳ lạ trên thực tế.

Joint Limit Clipping

Joint ranges trong sim có thể khác với giới hạn vật lý thật của OpenArm:

# action_mapper.py

import numpy as np

# Giới hạn khớp THẬT của OpenArm (từ datasheet)
REAL_JOINT_LIMITS = {
    0: (-2.96, 2.96),    # Joint 1 (base) — hẹp hơn sim
    1: (-1.48, 1.48),    # Joint 2 (shoulder)
    2: (-1.48, 1.48),    # Joint 3 (elbow)
    3: (-2.96, 2.96),    # Joint 4 (wrist 1)
    4: (-1.48, 1.48),    # Joint 5 (wrist 2)
    5: (-2.96, 2.96),    # Joint 6 (wrist 3)
    6: (-1.48, 1.48),    # Joint 7 (wrist 4)
    7: (0.0, 1.0),       # Gripper
}

def clip_to_real_limits(action):
    """Clip action values to real joint limits"""
    clipped = np.copy(action)
    for i, (low, high) in REAL_JOINT_LIMITS.items():
        clipped[i] = np.clip(action[i], low, high)
    return clipped

Exponential Moving Average (EMA) Smoothing

Model VLA output có thể nhảy giữa các step — trên sim không sao vì sim chấp nhận mọi thứ. Trên robot thật, những bước nhảy đột ngột gây rung và hao mòn motor:

class ActionSmoother:
    """Smooth actions với EMA để tránh jerk trên robot thật"""
    
    def __init__(self, alpha=0.7, action_dim=8):
        """
        alpha: EMA weight (0.5-0.9)
            - 0.5: rất smooth nhưng phản ứng chậm
            - 0.9: ít smooth nhưng phản ứng nhanh
            - 0.7: khuyến nghị cho manipulation
        """
        self.alpha = alpha
        self.prev_action = np.zeros(action_dim)
        self.initialized = False
    
    def smooth(self, action):
        if not self.initialized:
            self.prev_action = action.copy()
            self.initialized = True
            return action
        
        smoothed = self.alpha * action + (1 - self.alpha) * self.prev_action
        self.prev_action = smoothed.copy()
        return smoothed
    
    def reset(self):
        self.initialized = False

Control Frequency Matching

Tần số control phải khớp với tần số trong sim:

import time

class ControlLoop:
    """Main control loop — chạy trên robot controller PC"""
    
    def __init__(self, server_url, controller, target_hz=20):
        self.server_url = server_url
        self.controller = controller  # OpenArmController
        self.target_hz = target_hz
        self.dt = 1.0 / target_hz
        self.smoother = ActionSmoother(alpha=0.7)
        
    def run_episode(self, max_steps=400):
        """Chạy 1 episode trên robot thật"""
        import requests
        import cv2
        
        camera = cv2.VideoCapture(0)  # USB camera
        camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
        camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
        
        self.smoother.reset()
        
        for step in range(max_steps):
            step_start = time.time()
            
            # 1. Capture image
            ret, frame = camera.read()
            if not ret:
                print("Camera error!")
                break
            
            # 2. Encode image
            _, buffer = cv2.imencode('.jpg', frame, 
                                     [cv2.IMWRITE_JPEG_QUALITY, 85])
            
            # 3. Send to inference server
            response = requests.post(
                f"{self.server_url}/predict",
                files={"image": ("frame.jpg", buffer.tobytes(), "image/jpeg")}
            )
            action = np.array(response.json()["action"])
            
            # 4. Post-process action
            action = clip_to_real_limits(action)
            action = self.smoother.smooth(action)
            
            # 5. Send to robot
            self.controller.set_joint_positions(action[:7])  # 7 joints
            self.controller.set_gripper(action[7])           # gripper
            
            # 6. Maintain control frequency
            elapsed = time.time() - step_start
            if elapsed < self.dt:
                time.sleep(self.dt - elapsed)
            
            # Log
            if step % 20 == 0:
                hz = 1.0 / max(elapsed, 0.001)
                print(f"Step {step}/{max_steps} | "
                      f"Hz: {hz:.1f} | "
                      f"Action: [{', '.join(f'{a:.2f}' for a in action[:3])}...]")
        
        camera.release()

Bước 4: Giảm Sim-to-Real Gap

4.1 Domain Randomization (trong sim)

Nếu bạn đã dùng domain randomization khi thu thập demo trong Isaac Lab (như đã hướng dẫn ở bài 9), policy sẽ robust hơn. Nếu chưa, hãy quay lại và retrain với randomization:

# Domain randomization parameters (Isaac Lab)
randomization = {
    "lighting": {
        "intensity": (0.5, 1.5),     # 50-150% cường độ
        "color_temp": (4000, 7000),  # Kelvin
        "direction": "random",
    },
    "camera": {
        "position_noise": 0.02,      # ±2cm random offset
        "fov_noise": 2,              # ±2 degree FOV variation
    },
    "box": {
        "position_noise": 0.05,      # ±5cm vị trí
        "size_scale": (0.8, 1.2),    # 80-120% kích thước
        "texture": "random",         # Random texture mỗi episode
    },
    "table": {
        "friction": (0.3, 0.8),      # Random ma sát
        "color": "random",
    },
}

4.2 Camera Matching

# So sánh ảnh sim vs real
# 1. Chụp 1 frame từ sim (Isaac Lab)
# 2. Chụp 1 frame từ real camera
# 3. So sánh side by side

# Checklist camera matching:
# ✅ Cùng resolution (640x480 hoặc 224x224 sau resize)
# ✅ Cùng FOV (hoặc crop real camera để match)
# ✅ Cùng vị trí tương đối với robot base
# ✅ Cùng hướng nhìn (góc pitch, yaw)
# ❌ KHÔNG cần match ánh sáng chính xác — domain randomization xử lý

4.3 Background Simplification

Một trick đơn giản nhưng hiệu quả: dọn sạch background. VLA model nhìn toàn bộ frame 224x224 — nếu background lộn xộn (đồ đạc, người đi lại, màn hình), model sẽ bị confuse.

Background tốt cho sim-to-real:
✅ Tường trắng/xám đơn sắc phía sau
✅ Bàn sạch, chỉ có hộp carton target
✅ Ánh sáng đều, không có bóng mạnh
✅ Không có vật thể di chuyển trong frame

Background xấu:
❌ Monitor/TV phía sau (pixel sáng, chữ chạy)
❌ Nhiều vật thể trên bàn ngoài target
❌ Ánh sáng tự nhiên thay đổi (cửa sổ)
❌ Người đi lại trong frame

Môi trường robotics cần được kiểm soát cẩn thận cho sim-to-real transfer

4.4 Fine-tune với Real Demos (Optional nhưng rất hiệu quả)

Nếu success rate trên real thấp hơn sim nhiều (điều gần như chắc chắn xảy ra), thu thập 10-20 real demonstrations và fine-tune model:

# Quy trình fine-tune real:
# 1. Teleoperate OpenArm thủ công 10-20 lần gắp hộp thành công
# 2. Record image + joint positions mỗi step
# 3. Format thành dataset giống sim demos
# 4. SFT thêm 500-1000 steps với LR thấp (1e-6)

# Tại sao chỉ cần 10-20 demos?
# Vì model đã hiểu manipulation qua sim training.
# Real demos chỉ cần dạy nó "recalibrate" visual + dynamics.
# Quá nhiều real demos (>50) có thể gây overfitting.

Bước 5: Evaluate trên Robot Thật

Protocol đánh giá

# eval_real.py
import json
from datetime import datetime

class RealEvaluator:
    def __init__(self, control_loop, num_trials=10):
        self.loop = control_loop
        self.num_trials = num_trials
        self.results = []
    
    def run_evaluation(self):
        for trial in range(self.num_trials):
            print(f"\n{'='*50}")
            print(f"Trial {trial+1}/{self.num_trials}")
            print(f"{'='*50}")
            
            # 1. Reset: đặt hộp carton lên bàn ở vị trí random
            input(f"Đặt hộp carton lên bàn ở vị trí mới, nhấn Enter...")
            
            # 2. Đưa robot về home position
            self.loop.controller.go_home()
            time.sleep(2)
            
            # 3. Chạy episode
            self.loop.run_episode(max_steps=400)
            
            # 4. Ghi nhận kết quả (manual)
            success = input("Gắp thành công? (y/n): ").lower() == 'y'
            failure_mode = ""
            if not success:
                failure_mode = input(
                    "Failure mode (miss/position/timeout/collision): "
                )
            
            self.results.append({
                "trial": trial + 1,
                "success": success,
                "failure_mode": failure_mode,
                "timestamp": datetime.now().isoformat(),
            })
            
            # 5. Mở gripper, về home
            self.loop.controller.open_gripper()
            self.loop.controller.go_home()
        
        # Tổng kết
        successes = sum(1 for r in self.results if r["success"])
        rate = successes / self.num_trials * 100
        print(f"\n{'='*50}")
        print(f"RESULTS: {successes}/{self.num_trials} = {rate:.1f}%")
        print(f"{'='*50}")
        
        # Lưu kết quả
        with open("real_eval_results.json", "w") as f:
            json.dump(self.results, f, indent=2)

Kết quả tham khảo từ paper (Piper robot)

SimpleVLA-RL paper báo cáo kết quả real-world trên robot Piper (7-DoF, khác OpenArm nhưng cùng class):

┌──────────────────────────────────────────────────────┐
│  SimpleVLA-RL Real-World Results (Piper Robot)       │
├─────────────────────┬──────────┬──────────┬──────────┤
│ Task                │ SFT Only │ SFT + RL │ Change   │
├─────────────────────┼──────────┼──────────┼──────────┤
│ Average (4 tasks)   │ 17.5%    │ 38.5%    │ +120%    │
│ Stack Bowls         │ 38%      │ 70%      │ +84%     │
│ Click Bell          │ 30%      │ 60%      │ +100%    │
│ Fold Towel          │ 0%       │ 18%      │ N/A      │
│ Pour Water          │ 2%       │ 6%       │ +200%    │
├─────────────────────┴──────────┴──────────┴──────────┤
│ * OpenArm box grasping kỳ vọng tương đương hoặc cao │
│   hơn Stack Bowls vì task đơn giản hơn               │
└──────────────────────────────────────────────────────┘

Dựa trên kết quả này, với OpenArm gắp hộp carton (task đơn giản hơn Stack Bowls), chúng ta kỳ vọng:

Phân tích Failure Modes trên Robot Thật

Từ kinh nghiệm thực tế, các failure modes phổ biến nhất khi deploy VLA trên robot thật:

1. Grasp Failure (30-40% of failures)

2. Positioning Error (25-35% of failures)

3. Visual Confusion (15-25% of failures)

4. Motor Overshoot (5-10% of failures)

Tips cho Box Grasping cụ thể

Gắp hộp carton có một số đặc thù cần lưu ý:

Gripper Opening phải match kích thước hộp

# Tính gripper opening tối ưu
BOX_WIDTH = 0.12  # 12cm chiều rộng hộp
GRIPPER_MAX_OPEN = 0.08  # 8cm max opening (OpenArm gripper)

# Nếu hộp rộng hơn gripper max → phải gắp cạnh dài
# Nếu hộp vừa → gắp cạnh ngắn an toàn hơn

# Trong reward function, thêm điều kiện:
# success = box_lifted AND gripper_force > threshold
# Không chỉ check position — check cả lực kẹp

Tiếp cận từ trên xuống (top-down) hiệu quả nhất

Với hộp carton đặt trên bàn, approach từ trên xuống (vertical) cho tỷ lệ thành công cao nhất vì:

Domain Randomization cho hộp

# Trong Isaac Lab, randomize:
box_randomization = {
    "position_x": (0.2, 0.5),    # Range trên bàn
    "position_y": (-0.15, 0.15),  # Left-right
    "rotation_z": (-45, 45),      # Xoay ±45 độ
    "size_scale": (0.8, 1.3),     # Kích thước 80-130%
    "texture": ["cardboard_plain", "cardboard_printed", 
                "cardboard_worn", "cardboard_wet"],
    "mass": (0.1, 0.5),           # 100g - 500g
}

Complete Pipeline Diagram

┌─────────────────────────────────────────────────────┐
│                COMPLETE PIPELINE                     │
│                                                      │
│  Isaac Lab Sim                                       │
│       │                                              │
│       ▼                                              │
│  RL Expert Demos (500-1000 episodes)                │
│       │                                              │
│       ▼                                              │
│  SFT Training (OpenVLA-OFT) ──── ~20% success      │
│       │                                              │
│       ▼                                              │
│  RL Training (GRPO) ──────────── ~50% success       │
│       │                                              │
│       ▼                                              │
│  Sim Evaluation (100 episodes)                      │
│       │                                              │
│       ▼                                              │
│  Sim-to-Real Transfer ◄── BẠN ĐANG Ở ĐÂY          │
│       │                                              │
│       ▼                                              │
│  Real Evaluation (10 trials)                        │
│       │                                              │
│       ▼                                              │
│  Real Fine-tune (10-20 demos) ── ~65% success       │
│       │                                              │
│       ▼                                              │
│  Iterate & Improve                                  │
└─────────────────────────────────────────────────────┘

Robot manipulation research đòi hỏi kiên nhẫn và iteration liên tục

Bước tiếp theo

Sau khi có kết quả real-world evaluation đầu tiên, vòng lặp cải thiện bắt đầu:

  1. Phân tích failure modes → Hiểu robot thất bại ở đâu
  2. Thu thập thêm real demos tập trung vào scenarios thất bại
  3. Fine-tune model trên real data
  4. Evaluate lại → Lặp lại cho đến khi đạt target success rate

SimpleVLA-RL cho chúng ta một lợi thế lớn: thay vì chỉ thu thập thêm demo (như Imitation Learning thuần), chúng ta có thể quay lại sim, chạy thêm RL epochs tập trung vào failure scenarios, rồi transfer lại sang real. Đây là closed-loop improvement mà Imitation Learning đơn thuần không có.

Con đường từ 0% đến robot gắp hộp tự động không hề dễ — nhưng với SimpleVLA-RL, mỗi lần iteration đều mang lại cải thiện đo được. Kiên nhẫn và systematic approach là chìa khóa.


Bài viết liên quan

Bài viết liên quan

TutorialSimpleVLA-RL (10): SFT & RL Training cho OpenArm
openarmsimplevla-rltraininggrporeinforcement-learningPhần 10

SimpleVLA-RL (10): SFT & RL Training cho OpenArm

Hướng dẫn chi tiết SFT fine-tuning và RL training với SimpleVLA-RL cho OpenArm — từ config environment đến chạy GRPO.

11/4/202616 phút đọc
TutorialSimpleVLA-RL (6): OpenArm — Phân tích Lộ trình
openarmvlareinforcement-learninglerobotpi0Phần 6

SimpleVLA-RL (6): OpenArm — Phân tích Lộ trình

Phân tích chi tiết cách tiếp cận training robot OpenArm 7-DoF gắp hộp carton — so sánh 2 lộ trình: LeRobot native vs SimpleVLA-RL.

11/4/202613 phút đọc
TutorialSimpleVLA-RL (7): Collect Data cho OpenArm
openarmlerobotdata-collectionteleoperationPhần 7

SimpleVLA-RL (7): Collect Data cho OpenArm

Hướng dẫn từng bước setup OpenArm, calibrate, teleoperate và thu thập 50 episodes gắp hộp carton với LeRobot.

11/4/202616 phút đọc