PyBullet Robotics · Phase 1

로보틱스 시뮬레이션 기본기

물리 세계 → 로봇 제어 → 센서 → Pick&Place → 자율이동 → Django 연동 · 8주 실습
8회차 × 90분 Python 3.x + PyBullet 검증된 코드 초급 → 중급
01첫 물리 세계 — 큐브가 떨어진다!기초+
PyBullet을 설치하고, 중력이 작동하는 3D 세계에 물체를 떨어뜨려 본다. "코드로 물리 법칙을 다룬다"는 감각을 잡는다.
1PyBullet이 뭔지 알아보기
PyBullet은 물리 시뮬레이션 엔진입니다. 게임처럼 "예쁘게 보여주기"가 아니라 "물리 법칙이 정확하게 작동하는 가상 세계"를 만드는 도구.
Ursina = "3D 게임을 쉽게 만드는 도구". PyBullet = "진짜 물리 법칙이 작동하는 3D 실험실". 중력, 마찰, 충돌이 현실처럼 작동해요.
로봇 회사들이 실제 로봇 제작 전에 PyBullet 같은 시뮬레이터로 먼저 테스트합니다. 실제 로봇은 비싸고 고장 나니까!
2설치
pip install pybullet
안 되면? → python -m pip install pybullet
Python 3.8 ~ 3.12 권장. numpy가 같이 설치됨.
3빈 3D 세계 띄우기
physics_world.py 파일을 만들고 타이핑:
import pybullet as p import pybullet_data import time # 1. 3D 뷰어 열기 physics_client = p.connect(p.GUI) # 2. 내장 모델 경로 등록 p.setAdditionalSearchPath(pybullet_data.getDataPath()) # 3. 중력 설정 (z축 아래로 -9.81 m/s²) p.setGravity(0, 0, -9.81) # 4. 바닥 깔기 plane = p.loadURDF("plane.urdf") # 5. 시뮬레이션 루프 for _ in range(10000): p.stepSimulation() time.sleep(1/240) p.disconnect()
p.connect(p.GUI) = "3D 창 열어!". setGravity = "중력 켜!". stepSimulation = "세계를 1/240초만큼 앞으로 돌려!"
마우스 조작: 좌클릭 드래그=회전, 우클릭 드래그=이동, 휠=확대/축소
🎓 p.GUI vs p.DIRECT 차이 (궁금한 사람만)

p.GUI = 3D 창이 뜸. 눈으로 확인할 때 사용.

p.DIRECT = 창 없이 계산만. 10~50배 빠름. 나중에 AI 학습할 때 씀.

p.connect(p.GUI) → 볼 수 있음, 느림 (실시간) p.connect(p.DIRECT) → 안 보임, 빠름 (학습용)

지금은 무조건 GUI. 나중에 강화학습(Phase 2)에서 DIRECT를 씁니다.

4큐브 떨어뜨리기
바닥 로드 다음, 시뮬 루프 전에 추가:
# 큐브 만들기 cube_shape = p.createCollisionShape(p.GEOM_BOX, halfExtents=[0.5, 0.5, 0.5]) cube_visual = p.createVisualShape(p.GEOM_BOX, halfExtents=[0.5, 0.5, 0.5], rgbaColor=[1, 0, 0, 1]) # 빨간색 cube = p.createMultiBody( baseMass=1, # 1kg baseCollisionShapeIndex=cube_shape, baseVisualShapeIndex=cube_visual, basePosition=[0, 0, 5] # 5m 높이에서 시작 )
createCollisionShape = "충돌 영역 모양". createVisualShape = "보이는 모양". createMultiBody = "이 둘을 합쳐서 물체를 세계에 놓기".
halfExtents = "반 크기". [0.5, 0.5, 0.5]면 한 변이 1m인 정육면체.
basePosition=[0, 0, 5] → 5m 높이에서 시작. 중력이 켜져있으니 떨어집니다!
🎓 왜 CollisionShape과 VisualShape이 따로인가?

CollisionShape = 물리 엔진이 충돌을 계산하는 데 쓰는 모양. 단순해야 빠름.

VisualShape = 화면에 보이는 모양. 복잡해도 됨 (성능에 덜 영향).

실제 로봇 시뮬에서는: VisualShape = 로봇의 예쁜 3D 모델 (.obj) CollisionShape = 단순한 박스/원기둥 (빠른 계산) 지금은 둘 다 같은 BOX라 차이가 없지만, 복잡한 로봇에선 반드시 분리합니다.
5구·원기둥도 떨어뜨리기
# 파란 구 sphere_s = p.createCollisionShape(p.GEOM_SPHERE, radius=0.5) sphere_v = p.createVisualShape(p.GEOM_SPHERE, radius=0.5, rgbaColor=[0, 0, 1, 1]) p.createMultiBody(baseMass=1, baseCollisionShapeIndex=sphere_s, baseVisualShapeIndex=sphere_v, basePosition=[2, 0, 5]) # 초록 원기둥 cyl_s = p.createCollisionShape(p.GEOM_CYLINDER, radius=0.3, height=1.0) cyl_v = p.createVisualShape(p.GEOM_CYLINDER, radius=0.3, length=1.0, rgbaColor=[0, 1, 0, 1]) p.createMultiBody(baseMass=1, baseCollisionShapeIndex=cyl_s, baseVisualShapeIndex=cyl_v, basePosition=[-2, 0, 5])
GEOM_SPHERE, GEOM_CYLINDER, GEOM_BOX — 세 가지 기본 도형. 이것만으로도 대부분의 실험 가능.
VisualShape에서 원기둥은 length, CollisionShape에서는 height. 이름이 다름 주의!
6질량 실험 — 무겁면 어떻게 될까?
같은 높이에서 질량만 다르게 두 큐브를 떨어뜨려봅니다.
# 가벼운 큐브 (1kg) p.createMultiBody(baseMass=1, ..., basePosition=[-1,0,3]) # 무거운 큐브 (100kg) p.createMultiBody(baseMass=100, ..., basePosition=[1,0,3])
진공에서는 같은 속도로 떨어지지만, 충돌 후 행동이 다릅니다! 무거운 큐브는 덜 튕김.
7마찰 & 반발 계수 바꿔보기
# 물체 만든 후에 물리 속성 변경 p.changeDynamics(cube, -1, lateralFriction=0.0, # 마찰 0 = 빙판 restitution=0.9) # 반발 0.9 = 탱탱볼 # 비교용: 마찰 높고 반발 없는 큐브 p.changeDynamics(cube2, -1, lateralFriction=1.0, # 마찰 1 = 고무 restitution=0.0) # 반발 0 = 찰흙
lateralFriction = 얼마나 미끄러운가 (0=빙판, 1=고무). restitution = 얼마나 튕기는가 (0=찰흙, 1=탱탱볼).
changeDynamics의 두 번째 인자 -1 = "베이스 링크(몸체 전체)"라는 뜻.
🎓 changeDynamics 파라미터 전체 목록

자주 쓰는 것만:

lateralFriction = 마찰 계수 (0~1+) restitution = 반발 계수 (0~1) mass = 질량 (kg) linearDamping = 직선 운동 감쇠 (공기저항 느낌) angularDamping = 회전 운동 감쇠 (회전 저항) rollingFriction = 굴림 마찰 (구가 계속 구를지 멈출지)

rollingFriction을 0으로 두면 구가 영원히 굴러갑니다. 현실에선 있으니까 0.01 정도 줘야 자연스러움.

8stepSimulation 이해
시뮬레이션 루프를 자세히 이해합니다.
for i in range(2400): # 2400스텝 = 10초 p.stepSimulation() time.sleep(1/240) # 실시간 재생 # 큐브 위치 확인 (매 1초마다) if i % 240 == 0: pos, orn = p.getBasePositionAndOrientation(cube) print(f"t={i//240}s z={pos[2]:.2f}m")
1 step = 1/240초. 240 steps = 1초. Ursina의 update()와 비슷하지만, 여기선 "물리 엔진이 계산한다"는 게 핵심.
🎓 Ursina의 update()와 비교
Ursina: def update(): ← 게임 엔진이 자동 호출 cube.rotation_y += time.dt * 50 PyBullet: for _ in range(N): ← 직접 루프를 돌림 p.stepSimulation() ← 물리 계산 1스텝 time.sleep(1/240) ← 실시간 맞추기

Ursina는 게임 엔진이 루프를 관리하지만, PyBullet은 우리가 직접 루프를 짭니다. 더 자유롭지만 더 책임도 큼.

time.sleep(1/240)을 빼면? → 2400스텝이 0.1초만에 끝남. 물리는 10초치를 계산하지만 화면은 순식간에 지나감.

9힘 직접 가하기 — applyExternalForce
# 시뮬 루프 안에서, 2초 시점에 큐브를 옆으로 밀기 if i == 480: # 480 step = 2초 p.applyExternalForce( cube, -1, forceObj=[500, 0, 0], # x방향 500N posObj=[0, 0, 0], flags=p.WORLD_FRAME )
applyExternalForce = "손으로 밀기". [500, 0, 0] = x방향으로 500뉴턴. 500N은 성인 남성이 세게 미는 정도.
forceObj=[0, 0, 1000]으로 바꾸면? → 위로 솟구칩니다! 로켓!
10물체 여러 개 쏟기 — for문
import random for i in range(20): shape = p.createCollisionShape(p.GEOM_SPHERE, radius=0.2) vis = p.createVisualShape(p.GEOM_SPHERE, radius=0.2, rgbaColor=[random.random(), random.random(), random.random(), 1]) p.createMultiBody( baseMass=0.5, baseCollisionShapeIndex=shape, baseVisualShapeIndex=vis, basePosition=[ random.uniform(-1, 1), random.uniform(-1, 1), 3 + i*0.5 # 점점 높이 ] )
20개의 색깔 공이 쏟아지면서 서로 부딪히고 튕기는 걸 보세요. 이게 물리 엔진의 힘!
11정리 & Q&A
✅ pip install → ✅ GUI 열기 → ✅ 중력+바닥 → ✅ 도형 떨어뜨리기 → ✅ 질량/마찰/반발 → ✅ 힘 가하기 → ✅ for문 쏟기
결과물: 다양한 물체가 떨어지고 튕기는 물리 시뮬레이션 + 힘 가하기
구 10개를 높이 10m에서 떨어뜨리되, 반발계수를 0.0 ~ 0.9까지 다르게 설정해서 어떤 구가 가장 높이 튀는지 비교해 보세요.
02URDF 이해 — 로봇의 설계도기초+
URDF 파일 구조를 이해하고, 내장 로봇을 불러와서 관절 정보를 읽고, 간단한 URDF를 직접 작성한다.
1기본 뼈대 코드 (이번 회차 공통)
import pybullet as p import pybullet_data import time physics_client = p.connect(p.GUI) p.setAdditionalSearchPath(pybullet_data.getDataPath()) p.setGravity(0, 0, -9.81) plane = p.loadURDF("plane.urdf") # 여기에 로봇을 불러올 겁니다 for _ in range(10000): p.stepSimulation() time.sleep(1/240) p.disconnect()
2URDF란? — XML로 된 로봇 설계도
URDF = Unified Robot Description Format. 로봇의 몸통(Link)과 관절(Joint)을 XML로 기술합니다.
<robot name="my_robot"> <link name="base"> ← 몸통 (뼈대) <visual> ... </visual> ← 보이는 모양 <collision> ... </collision> ← 충돌 영역 <inertial> ... </inertial> ← 질량/관성 </link> <joint name="joint1" type="revolute"> ← 관절 <parent link="base"/> ← 부모 링크 <child link="arm"/> ← 자식 링크 <axis xyz="0 0 1"/> ← 회전축 </joint> <link name="arm"> ... </link> </robot>
Link = 뼈. Joint = 관절. 뼈와 뼈를 관절로 연결하면 로봇 팔! 사람 몸도 URDF로 표현할 수 있어요.
3내장 로봇 불러오기 — R2D2
r2d2 = p.loadURDF("r2d2.urdf", basePosition=[0,0,0.5])
pybullet_data 안에 여러 로봇이 들어있어요. "plane.urdf", "r2d2.urdf", "kuka_iiwa/model.urdf" 등.
4관절 정보 탐색 — getJointInfo
num_joints = p.getNumJoints(r2d2) print(f"관절 수: {num_joints}") for i in range(num_joints): info = p.getJointInfo(r2d2, i) name = info[1].decode('utf-8') joint_type = info[2] lower = info[8] # 하한 upper = info[9] # 상한 type_name = {0:'회전', 1:'직선', 4:'고정'}.get(joint_type, '기타') print(f" [{i}] {name:20s} 타입={type_name}" f" 범위=[{lower:.2f}, {upper:.2f}]")
getJointInfo가 돌려주는 정보: 이름, 타입, 회전축, 범위 등. 로봇을 움직이기 전에 "이 로봇이 어떻게 생겼는지" 먼저 파악!
🎓 Joint Type 숫자 의미
0 = REVOLUTE → 회전 관절 (문 경첩처럼) 1 = PRISMATIC → 직선 이동 관절 (서랍처럼) 2 = SPHERICAL → 볼 조인트 (어깨처럼) 3 = PLANAR → 평면 이동 4 = FIXED → 고정 (움직이지 않음)

대부분 로봇은 REVOLUTE(회전)와 FIXED(고정)로 구성됩니다.

5Kuka 로봇 팔 불러오기
kuka = p.loadURDF("kuka_iiwa/model.urdf", basePosition=[0, 0, 0], useFixedBase=True) # 바닥에 고정!
useFixedBase=True 안 쓰면 로봇 팔이 바닥에 넘어져요. 실제로 공장 로봇 팔은 바닥에 볼트로 고정돼 있으니까.
R2D2와 Kuka의 관절 수, 관절 타입을 비교해 보세요. 완전히 다른 구조!
6간단한 URDF 직접 작성 — "문"
door.urdf 파일을 새로 만듭니다 (physics_world.py와 같은 폴더):
<?xml version="1.0"?> <robot name="door"> <!-- 문틀 (고정) --> <link name="frame"> <visual> <geometry><box size="0.1 0.05 2.0"/></geometry> <material name="gray"> <color rgba="0.5 0.5 0.5 1"/> </material> </visual> <collision> <geometry><box size="0.1 0.05 2.0"/></geometry> </collision> <inertial> <mass value="0"/> <inertia ixx="0" iyy="0" izz="0" ixy="0" ixz="0" iyz="0"/> </inertial> </link> <!-- 문짝 --> <link name="door_panel"> <visual> <origin xyz="0.4 0 0"/> <geometry><box size="0.8 0.05 1.8"/></geometry> <material name="brown"> <color rgba="0.6 0.3 0.1 1"/> </material> </visual> <collision> <origin xyz="0.4 0 0"/> <geometry><box size="0.8 0.05 1.8"/></geometry> </collision> <inertial> <mass value="10"/> <origin xyz="0.4 0 0"/> <inertia ixx="1" iyy="1" izz="1" ixy="0" ixz="0" iyz="0"/> </inertial> </link> <!-- 경첩 관절 --> <joint name="hinge" type="revolute"> <parent link="frame"/> <child link="door_panel"/> <origin xyz="0 0 0"/> <axis xyz="0 0 1"/> <limit lower="0" upper="1.57" effort="100" velocity="5"/> </joint> </robot>
# 문 로드하기 door = p.loadURDF("door.urdf", basePosition=[0, 2, 1], useFixedBase=True)
문틀(frame)은 mass=0으로 고정, 문짝(door_panel)은 mass=10으로 중력 영향. 경첩(hinge)은 z축 기준 0~90도 회전!
중력이 켜져있으니 문이 자동으로 열립니다! limit upper="1.57"은 90도(라디안).
7여러 로봇 동시에 구경하기
# 여러 로봇 나란히 r2d2 = p.loadURDF("r2d2.urdf", basePosition=[-2,0,0.5]) kuka = p.loadURDF("kuka_iiwa/model.urdf", basePosition=[0,0,0], useFixedBase=True) humanoid = p.loadURDF("humanoid/humanoid.urdf", basePosition=[2,0,1], useFixedBase=True) # 각각 관절 수 출력 for name, robot in [("R2D2",r2d2),("Kuka",kuka),("Human",humanoid)]: print(f"{name}: {p.getNumJoints(robot)}개 관절")
8정리 & Q&A
✅ URDF 구조(Link+Joint) → ✅ 내장 로봇 로드 → ✅ 관절 정보 읽기 → ✅ URDF 직접 작성(문) → ✅ 여러 로봇 비교
결과물: 여러 로봇 로드 + 관절 탐색 + 직접 만든 문 URDF
2-Link 로봇 팔 URDF를 직접 작성해 보세요. (base → shoulder joint → upper_arm → elbow joint → forearm)
03관절 제어 — 로봇을 움직여라기초+
Position/Velocity/Torque 세 가지 제어 모드로 로봇 관절을 직접 움직이고, 키보드로 실시간 조작해본다.
1Kuka 로봇 팔 준비
import pybullet as p import pybullet_data import time, math physics_client = p.connect(p.GUI) p.setAdditionalSearchPath(pybullet_data.getDataPath()) p.setGravity(0, 0, -9.81) p.loadURDF("plane.urdf") kuka = p.loadURDF("kuka_iiwa/model.urdf", useFixedBase=True) num_joints = p.getNumJoints(kuka) print(f"Kuka 관절 수: {num_joints}") # 7
2Position Control — "여기로 가"
# 0번 관절을 1.57 라디안(90도)으로 보내기 p.setJointMotorControl2( bodyUniqueId=kuka, jointIndex=0, controlMode=p.POSITION_CONTROL, targetPosition=1.57, # 목표 각도 (라디안) force=200 # 최대 힘 )
POSITION_CONTROL = "이 각도로 가". 가장 직관적인 모드. 내부적으로 PID 컨트롤러가 알아서 힘을 조절해줌.
1.57 라디안 = 90도. π(3.14) 라디안 = 180도. math.radians(90)으로 변환 가능.
🎓 라디안(radian)이란?
도(degree) 라디안(radian) 0° → 0 45° → π/4 ≈ 0.785 90° → π/2 ≈ 1.571 180° → π ≈ 3.142 360° → 2π ≈ 6.283 변환: math.radians(90) → 1.5707... math.degrees(1.57) → 89.95...°

왜 라디안? 물리/수학 공식에서 삼각함수(sin, cos)가 라디안 기준이라 로봇 공학에서는 라디안이 표준.

3여러 관절 동시에 움직이기
# 7개 관절 한 번에 세팅 target_positions = [0.5, -0.3, 0.8, -1.2, 0.4, 1.0, 0.0] for j in range(7): p.setJointMotorControl2( kuka, j, controlMode=p.POSITION_CONTROL, targetPosition=target_positions[j], force=200 )
숫자들을 자유롭게 바꿔보세요! 어떤 자세가 되는지 직접 확인.
4Velocity Control — "이 속도로 돌아"
# 0번 관절을 초속 1 라디안으로 계속 회전 p.setJointMotorControl2( kuka, 0, controlMode=p.VELOCITY_CONTROL, targetVelocity=1.0, # 초속 1 라디안 force=200 )
VELOCITY_CONTROL = "이 속도로 계속 돌아". 바퀴 달린 로봇(7주차)에서 주로 씁니다. 멈추려면 targetVelocity=0.
5Torque Control — "이 힘으로 밀어"
# 먼저 기존 모터 끄기 (중요!) p.setJointMotorControl2(kuka, 0, controlMode=p.VELOCITY_CONTROL, targetVelocity=0, force=0) # 그다음 토크 직접 적용 p.setJointMotorControl2(kuka, 0, controlMode=p.TORQUE_CONTROL, force=50) # 50 N·m 토크
TORQUE_CONTROL = 직접 힘(토크) 적용. 가장 현실적이지만 제어 어려움. PID 컨트롤러를 직접 만들어야 함 (Phase 2에서!).
토크 제어 전에 반드시 기존 모터를 끄세요 (force=0). 안 끄면 내장 모터와 충돌해서 이상하게 움직임.
6관절 상태 읽기 — getJointState
# 시뮬 루프 안에서 state = p.getJointState(kuka, 0) pos = state[0] # 현재 각도 (라디안) vel = state[1] # 현재 각속도 force = state[3] # 현재 적용 토크 print(f"각도={math.degrees(pos):.1f}° 속도={vel:.2f}")
이 값들이 나중에 AI의 "관찰(observation)"이 됩니다!
7키보드로 로봇 조작하기
current_joint = 0 # 지금 제어 중인 관절 target = [0.0] * 7 # 7개 관절 목표값 for _ in range(100000): p.stepSimulation() time.sleep(1/240) keys = p.getKeyboardEvents() # 1~7 키로 관절 선택 for k in range(7): if ord(str(k+1)) in keys: current_joint = k print(f"관절 {k} 선택") # 좌/우 화살표로 각도 변경 if p.B3G_LEFT_ARROW in keys: target[current_joint] -= 0.02 if p.B3G_RIGHT_ARROW in keys: target[current_joint] += 0.02 # 모든 관절에 목표 적용 for j in range(7): p.setJointMotorControl2(kuka, j, p.POSITION_CONTROL, targetPosition=target[j], force=200)
1~7 키로 관절 선택 → 좌우 화살표로 각도 조절. RC카 리모컨처럼 로봇을 직접 조종!
p.B3G_LEFT_ARROW, p.B3G_RIGHT_ARROW는 PyBullet 내장 키코드. 일반 문자는 ord('a') 방식.
8사인파 동작 — 부드러운 움직임
t = 0 for _ in range(5000): p.stepSimulation() time.sleep(1/240) t += 1/240 # 0번 관절: 사인파로 왔다갔다 p.setJointMotorControl2(kuka, 0, p.POSITION_CONTROL, targetPosition=math.sin(t * 2) * 1.0, force=200) # 2번 관절: 코사인파 p.setJointMotorControl2(kuka, 2, p.POSITION_CONTROL, targetPosition=math.cos(t * 2) * 0.8, force=200)
sin/cos로 목표값을 시간에 따라 바꾸면 "부드럽게 왔다갔다"하는 동작. 로봇 댄스!
9정리 & 3가지 모드 비교
✅ POSITION = "여기로 가" (가장 쉬움, 기본)
✅ VELOCITY = "이 속도로 돌아" (바퀴에 적합)
✅ TORQUE = "이 힘으로 밀어" (가장 현실적, 어려움)
결과물: 키보드로 Kuka 로봇 팔을 실시간 조작 + 사인파 자동 동작
관절 7개를 각각 다른 주파수의 사인파로 움직여서 "춤추는 로봇"을 만들어 보세요.
04충돌 & 접촉 — 물체가 부딪히면?기초+
충돌 감지, 접촉력 측정, 레이캐스트로 "로봇의 감각"을 구현한다. 장애물을 감지하고 피하는 첫 자율 행동.
1충돌 감지 환경 준비
import pybullet as p import pybullet_data import time p.connect(p.GUI) p.setAdditionalSearchPath(pybullet_data.getDataPath()) p.setGravity(0,0,-9.81) plane = p.loadURDF("plane.urdf") # 빨간 큐브 (위에서 떨어짐) box_s = p.createCollisionShape(p.GEOM_BOX, halfExtents=[0.3]*3) box_v = p.createVisualShape(p.GEOM_BOX, halfExtents=[0.3]*3, rgbaColor=[1,0,0,1]) box = p.createMultiBody(baseMass=1, baseCollisionShapeIndex=box_s, baseVisualShapeIndex=box_v, basePosition=[0,0,3]) # 파란 테이블 (고정) table_s = p.createCollisionShape(p.GEOM_BOX, halfExtents=[1,1,0.1]) table_v = p.createVisualShape(p.GEOM_BOX, halfExtents=[1,1,0.1], rgbaColor=[0,0,1,1]) table = p.createMultiBody(baseMass=0, baseCollisionShapeIndex=table_s, baseVisualShapeIndex=table_v, basePosition=[0,0,0.5])
mass=0 = 움직이지 않는 고정 물체 (static body). 바닥, 테이블, 벽 등에 사용.
2getContactPoints — 뭐가 닿았나?
for i in range(5000): p.stepSimulation() time.sleep(1/240) # box와 table 사이 접촉 확인 contacts = p.getContactPoints(box, table) if contacts: for c in contacts: pos = c[5] # 접촉 위치 (B측) normal = c[7] # 접촉 법선 force = c[9] # 접촉력 크기 if i % 60 == 0: print(f"접촉! 힘={force:.2f}N")
getContactPoints(A, B) = "A와 B가 닿아있나?" 닿았으면 접촉점 리스트 반환, 안 닿았으면 빈 리스트.
접촉력이 큰 순간 = 충돌 직후. 점점 줄어들면서 안정됨. 이걸 모니터링하면 "너무 세게 잡았나?" 판단 가능!
3rayTest — 레이저 포인터 쏘기
# 시작점 → 끝점으로 직선 쏘기 start_pos = [0, 0, 2] # 위에서 end_pos = [0, 0, 0] # 아래로 result = p.rayTest(start_pos, end_pos) hit_object = result[0][0] # 맞은 물체 ID (-1=없음) hit_fraction = result[0][2] # 0~1 사이 비율 hit_pos = result[0][3] # 맞은 좌표 if hit_object >= 0: distance = hit_fraction * 2 # start→end 거리 × fraction print(f"물체 {hit_object}에 맞음! 거리={distance:.2f}m")
rayTest = "보이지 않는 레이저를 쏴서 뭐에 맞는지 확인". Ursina의 raycast와 완전 같은 개념! 거리 센서의 원리.
🎓 hit_fraction이 뭐냐?
start_pos ●━━━━━━━━━━━━━━━● end_pos |← fraction →| 0.0 0.35 1.0 fraction = 0.35면 → start에서 전체 거리의 35% 지점에서 뭔가 맞음 → 실제 거리 = (end - start) 전체 길이 × 0.35

fraction이 1.0이면 = 끝점까지 아무것도 없음 (hit_object = -1).

4레이 시각화 — 디버그 라인
# 레이를 눈에 보이게 p.addUserDebugLine( start_pos, end_pos, lineColorRGB=[1,0,0], # 빨간 선 lineWidth=2, lifeTime=0 # 0=영구 )
lifeTime=3이면 3초 후 사라짐. 0이면 영구. 디버깅할 때 매우 유용!
5부채꼴 스캔 — rayTestBatch
import math scan_origin = [0, 0, 0.5] num_rays = 36 # 360도를 36등분 = 10도 간격 ray_len = 5 starts = [] ends = [] for i in range(num_rays): angle = 2 * math.pi * i / num_rays dx = math.cos(angle) * ray_len dy = math.sin(angle) * ray_len starts.append(scan_origin) ends.append([scan_origin[0]+dx, scan_origin[1]+dy, scan_origin[2]]) results = p.rayTestBatch(starts, ends) # 시각화 for i, r in enumerate(results): color = [1,0,0] if r[0] >= 0 else [0,1,0] p.addUserDebugLine(starts[i], ends[i], lineColorRGB=color, lineWidth=1, lifeTime=0)
rayTestBatch = 여러 레이를 한 번에 쏘기. 36방향으로 쏘면 주변 360도를 한눈에! 이게 LiDAR 센서의 원리.
빨간 선 = 장애물에 맞음. 초록 선 = 맞은 게 없음 (길이 열려있음).
6장애물 놓고 감지하기
# 장애물 3개 배치 for pos in [(2,0,0.5), (-1,2,0.5), (0,-3,0.5)]: obs_s = p.createCollisionShape(p.GEOM_BOX, halfExtents=[0.5,0.5,0.5]) obs_v = p.createVisualShape(p.GEOM_BOX, halfExtents=[0.5,0.5,0.5], rgbaColor=[0.8,0.2,0.2,1]) p.createMultiBody(baseMass=0, baseCollisionShapeIndex=obs_s, baseVisualShapeIndex=obs_v, basePosition=pos)
이제 부채꼴 스캔을 실행하면 장애물 방향의 레이가 빨간색으로 나옵니다!
7접촉 이벤트로 행동 바꾸기
Kuka 로봇 팔이 물체에 닿으면 멈추는 로직:
kuka = p.loadURDF("kuka_iiwa/model.urdf", useFixedBase=True) # 앞에 큐브 하나 target_box = p.createMultiBody(baseMass=0.5, ..., basePosition=[0.5,0,0.5]) stopped = False for _ in range(10000): p.stepSimulation() time.sleep(1/240) if not stopped: # 팔을 앞으로 뻗기 p.setJointMotorControl2(kuka, 1, p.POSITION_CONTROL, targetPosition=-1.0, force=200) # 닿았나? contacts = p.getContactPoints(kuka, target_box) if contacts: print("접촉! 정지!") stopped = True # 현재 위치에서 고정 state = p.getJointState(kuka, 1) p.setJointMotorControl2(kuka, 1, p.POSITION_CONTROL, targetPosition=state[0], force=200)
"닿으면 멈춰!" — 가장 기본적인 안전 장치. 실제 로봇에서도 이 원리로 충돌 감지하고 비상 정지!
8정리 & Q&A
✅ getContactPoints → ✅ rayTest → ✅ rayTestBatch 부채꼴 → ✅ 디버그 라인 → ✅ 접촉 시 행동 변경
결과물: 360도 레이캐스트 스캔 + 접촉 감지 시 자동 정지하는 로봇 팔
부채꼴 스캔 결과에서 "가장 가까운 장애물 방향"과 "거리"를 출력하는 프로그램을 만들어 보세요.
05카메라 & 시각 — 로봇의 눈중급+
로봇에 카메라를 달아 RGB/깊이/세그멘테이션 이미지를 캡처하고, 색상으로 물체를 감지한다.
1고정 카메라로 사진 찍기
import numpy as np # 카메라 설정 width, height = 320, 240 view_matrix = p.computeViewMatrix( cameraEyePosition=[1, 1, 1], # 카메라 위치 cameraTargetPosition=[0, 0, 0.5], # 바라보는 곳 cameraUpVector=[0, 0, 1] # 위쪽 방향 ) proj_matrix = p.computeProjectionMatrixFOV( fov=60, aspect=width/height, nearVal=0.1, farVal=10 ) # 사진 찍기! img = p.getCameraImage(width, height, viewMatrix=view_matrix, projectionMatrix=proj_matrix) # img[2] = RGB 데이터 (numpy로 변환) rgb = np.array(img[2], dtype=np.uint8).reshape(height, width, 4) rgb = rgb[:, :, :3] # RGBA → RGB
computeViewMatrix = "카메라를 어디에 놓고 어디를 보게 할지". computeProjectionMatrixFOV = "렌즈 설정(시야각, 거리)". getCameraImage = "셔터 누르기!"
2이미지 시각화 — matplotlib
import matplotlib.pyplot as plt plt.imshow(rgb) plt.title("PyBullet Camera") plt.axis('off') plt.savefig("capture.png") plt.show()
matplotlib 없으면: pip install matplotlib
3깊이맵(Depth) — 거리 정보
# img[3] = depth buffer (0~1) depth_raw = np.array(img[3], dtype=np.float32).reshape(height, width) # 실제 거리(m)로 변환 near, far = 0.1, 10 depth_m = far * near / (far - (far - near) * depth_raw) plt.imshow(depth_m, cmap='gray') plt.colorbar(label='Distance (m)') plt.title("Depth Map") plt.savefig("depth.png") plt.show()
깊이맵 = 각 픽셀까지의 거리. 가까운 물체=밝게, 먼 물체=어둡게. 자율주행차의 "거리 센서"와 같은 원리!
4세그멘테이션 — 물체 구분
# img[4] = segmentation mask seg = np.array(img[4], dtype=np.int32).reshape(height, width) plt.imshow(seg, cmap='tab10') plt.title("Segmentation (물체별 색상)") plt.savefig("seg.png") plt.show()
세그멘테이션 = 각 픽셀이 "어떤 물체 ID인지". 물체가 5개면 5가지 색으로 구분됨. 로봇이 "저건 테이블, 저건 컵"을 구분하는 데 필수!
5로봇 시점 카메라 (Eye-in-Hand)
kuka = p.loadURDF("kuka_iiwa/model.urdf", useFixedBase=True) def get_robot_camera(robot, link_index=6): # 끝단(end-effector) 위치와 방향 가져오기 state = p.getLinkState(robot, link_index) pos = state[0] # 위치 orn = state[1] # 방향 (쿼터니언) # 쿼터니언 → 회전행렬 rot = p.getMatrixFromQuaternion(orn) # 카메라가 바라보는 방향 (z축 앞쪽) forward = [rot[0], rot[3], rot[6]] target = [pos[0]+forward[0], pos[1]+forward[1], pos[2]+forward[2]] view = p.computeViewMatrix(pos, target, [0,0,1]) proj = p.computeProjectionMatrixFOV(60, 1, 0.1, 5) return p.getCameraImage(320,240, view, proj)
Eye-in-Hand = 로봇 끝단에 카메라를 달아서 "로봇의 시점"으로 세상을 봄. 로봇이 움직이면 카메라도 같이!
6색상 감지 — 빨간 물체 찾기
# 빨간 큐브를 씬에 추가하고 카메라로 촬영 후 # RGB 이미지에서 빨간 픽셀 찾기 red_mask = (rgb[:,:,0] > 150) & \ (rgb[:,:,1] < 80) & \ (rgb[:,:,2] < 80) red_pixels = np.sum(red_mask) total_pixels = width * height ratio = red_pixels / total_pixels * 100 print(f"빨간 영역: {ratio:.1f}%") if red_pixels > 100: # 빨간 물체의 중심 좌표 (픽셀) ys, xs = np.where(red_mask) cx, cy = np.mean(xs), np.mean(ys) print(f"빨간 물체 중심: ({cx:.0f}, {cy:.0f})")
아주 간단한 컴퓨터 비전! "빨간색이 많은 곳 = 물체가 있는 곳". 이게 나중에 딥러닝 비전으로 발전합니다.
7정리 & Q&A
✅ 고정 카메라 → ✅ RGB/Depth/Segmentation 3종 → ✅ Eye-in-Hand → ✅ 색상 감지
결과물: 로봇 시점 카메라 + RGB/깊이맵/세그멘테이션 캡처 + 빨간 물체 감지
빨간 물체와 파란 물체를 둘 다 놓고, 각각의 중심 좌표를 출력하는 프로그램을 만들어 보세요.
06Pick & Place — 물체를 집어라!중급+
역기구학(IK)으로 로봇 팔을 목표 좌표로 보내고, 그리퍼로 물체를 집어서 다른 곳에 놓는다.
1Panda 로봇 팔 + 그리퍼 준비
import pybullet as p import pybullet_data import time, math p.connect(p.GUI) p.setAdditionalSearchPath(pybullet_data.getDataPath()) p.setGravity(0,0,-9.81) p.loadURDF("plane.urdf") # 테이블 table = p.loadURDF("table/table.urdf", basePosition=[0.5,0,-0.63]) # Panda 로봇 (그리퍼 내장) panda = p.loadURDF("franka_panda/panda.urdf", basePosition=[0,0,0], useFixedBase=True) # 잡을 물체 (빨간 큐브) obj_s = p.createCollisionShape(p.GEOM_BOX, halfExtents=[0.02]*3) obj_v = p.createVisualShape(p.GEOM_BOX, halfExtents=[0.02]*3, rgbaColor=[1,0,0,1]) obj = p.createMultiBody(baseMass=0.1, baseCollisionShapeIndex=obj_s, baseVisualShapeIndex=obj_v, basePosition=[0.5, 0, 0.05])
Panda 로봇은 7개 팔 관절 + 2개 그리퍼 관절 = 총 11개 관절 (+ 고정 관절 포함 더 많음). 그리퍼는 관절 9, 10.
2그리퍼 열기/닫기
def gripper_open(): p.setJointMotorControl2(panda, 9, p.POSITION_CONTROL, targetPosition=0.04, force=20) p.setJointMotorControl2(panda, 10, p.POSITION_CONTROL, targetPosition=0.04, force=20) def gripper_close(): p.setJointMotorControl2(panda, 9, p.POSITION_CONTROL, targetPosition=0.0, force=20) p.setJointMotorControl2(panda, 10, p.POSITION_CONTROL, targetPosition=0.0, force=20)
그리퍼 관절 9, 10을 0.04(열림)↔0.0(닫힘)으로. 간단!
3역기구학(IK) — "이 좌표로 손을 보내"
def move_to(target_pos, target_orn=None): if target_orn is None: target_orn = p.getQuaternionFromEuler( [math.pi, 0, 0]) # 아래를 바라보는 자세 joint_angles = p.calculateInverseKinematics( panda, endEffectorLinkIndex=11, # 끝단 링크 targetPosition=target_pos, targetOrientation=target_orn, maxNumIterations=100 ) # 팔 관절(0~6)에만 적용 for j in range(7): p.setJointMotorControl2(panda, j, p.POSITION_CONTROL, targetPosition=joint_angles[j], force=240) # 테스트: 물체 위로 이동 move_to([0.5, 0, 0.3])
IK = "이 3D 좌표에 손 끝을 보내려면 각 관절을 몇 도로 꺾어야 해?" 를 자동 계산. 사람이 "저기 컵 잡아"라고 생각하면 뇌가 알아서 팔 관절 각도를 계산하는 것과 같음!
🎓 IK(역기구학)가 왜 어려운 문제인가?
순기구학(FK): 쉬움 관절 각도 [30°, 45°, 60°] → 끝점 위치 (x, y, z) "팔을 이렇게 꺾으면 손은 어디에?" → 삼각함수 한 번이면 끝 역기구학(IK): 어려움 끝점 위치 (x, y, z) → 관절 각도 [?°, ?°, ?°] "손을 여기에 보내려면 팔을 어떻게?" → 해가 여러 개일 수 있음! → 해가 없을 수도 있음! (닿지 않는 곳)

PyBullet의 calculateInverseKinematics는 수치적 방법으로 근사해를 구합니다. 완벽하진 않지만 대부분 잘 작동.

4Pick 시퀀스
def wait(steps=240): for _ in range(steps): p.stepSimulation() time.sleep(1/240) obj_pos = [0.5, 0, 0.05] # 1. 그리퍼 열기 gripper_open() wait(120) # 2. 물체 위 10cm로 이동 move_to([obj_pos[0], obj_pos[1], obj_pos[2]+0.15]) wait(240) # 3. 물체 높이로 하강 move_to([obj_pos[0], obj_pos[1], obj_pos[2]+0.02]) wait(240) # 4. 그리퍼 닫기 (잡기!) gripper_close() wait(120) # 5. 들어올리기 move_to([obj_pos[0], obj_pos[1], 0.3]) wait(240)
wait() 안 넣으면 로봇이 각 단계 사이에 도착도 전에 다음 명령이 실행돼요. 도착할 시간을 줘야 함!
5Place 시퀀스
place_pos = [0.3, 0.3, 0.05] # 6. 목표 위치 위로 이동 move_to([place_pos[0], place_pos[1], 0.3]) wait(240) # 7. 내려놓기 높이로 하강 move_to([place_pos[0], place_pos[1], place_pos[2]+0.05]) wait(240) # 8. 그리퍼 열기 (놓기!) gripper_open() wait(120) # 9. 위로 후퇴 move_to([0, 0, 0.5]) wait(240)
빨간 큐브가 (0.5, 0)에서 (0.3, 0.3)으로 옮겨졌으면 성공!
6여러 물체 자동 분류 (도전)
큐브 3개를 다른 위치에 놓고, for문으로 순서대로 집어서 모아봅니다.
objects = [] positions = [(0.4,0.1), (0.5,-0.1), (0.6,0.0)] for x, y in positions: o = p.createMultiBody(baseMass=0.1, baseCollisionShapeIndex=obj_s, baseVisualShapeIndex=obj_v, basePosition=[x, y, 0.05]) objects.append((o, [x, y, 0.05])) # 자동 수거 for obj_id, obj_pos in objects: gripper_open(); wait(60) move_to([obj_pos[0], obj_pos[1], 0.2]); wait(200) move_to([obj_pos[0], obj_pos[1], obj_pos[2]+0.02]); wait(200) gripper_close(); wait(100) move_to([0.3, 0.3, 0.2]); wait(200) move_to([0.3, 0.3, 0.07]); wait(200) gripper_open(); wait(60)
7정리
✅ 그리퍼 열기/닫기 → ✅ IK → ✅ Pick 시퀀스 → ✅ Place 시퀀스 → ✅ 자동 분류
결과물: 로봇 팔이 물체를 집어서 목표 위치에 놓는 Pick & Place 자동화
빨간 큐브는 왼쪽 상자에, 파란 큐브는 오른쪽 상자에 분류하는 프로그램을 만들어 보세요. (5주차 색상 감지 활용!)
07바퀴 로봇 & 자율 이동중급+
바퀴 달린 로봇(differential drive)을 만들어 키보드 RC 조작 → 목표 추적 → 장애물 회피까지 구현한다.
1내장 레이싱카 불러오기
import pybullet as p import pybullet_data import time, math p.connect(p.GUI) p.setAdditionalSearchPath(pybullet_data.getDataPath()) p.setGravity(0,0,-9.81) p.loadURDF("plane.urdf") car = p.loadURDF("racecar/racecar.urdf") # 바퀴 관절 확인 for i in range(p.getNumJoints(car)): info = p.getJointInfo(car, i) print(f"[{i}] {info[1].decode()}")
racecar의 바퀴 관절 번호를 확인하세요. 보통 뒷바퀴 2개가 구동, 앞바퀴 2개가 조향.
2Differential Drive 개념
좌우 바퀴 속도를 다르게 해서 방향을 조종합니다.
LEFT_WHEELS = [8, 15] # 왼쪽 바퀴 관절 (차량마다 다름!) RIGHT_WHEELS = [2, 5] # 오른쪽 바퀴 관절 MAX_FORCE = 20 def drive(left_vel, right_vel): for w in LEFT_WHEELS: p.setJointMotorControl2(car, w, p.VELOCITY_CONTROL, targetVelocity=left_vel, force=MAX_FORCE) for w in RIGHT_WHEELS: p.setJointMotorControl2(car, w, p.VELOCITY_CONTROL, targetVelocity=right_vel, force=MAX_FORCE)
🎓 Differential Drive 원리
양쪽 같은 속도 → 직진 ←← [■] →→ 왼쪽만 빠르게 → 오른쪽 회전 ←←← [■] → 오른쪽만 빠르게 → 왼쪽 회전 ← [■] →→→ 양쪽 반대 방향 → 제자리 회전 →→ [■] →→ (둘 다 같은 방향이면 회전) ←← [■] →→ (반대 방향이면 제자리 회전)

탱크, 룸바 청소기, 대부분의 교육용 로봇이 이 방식!

3키보드 RC카 조작
speed = 10 turn = 5 for _ in range(100000): p.stepSimulation() time.sleep(1/240) keys = p.getKeyboardEvents() left_v, right_v = 0, 0 if p.B3G_UP_ARROW in keys: # 전진 left_v, right_v = speed, speed if p.B3G_DOWN_ARROW in keys: # 후진 left_v, right_v = -speed, -speed if p.B3G_LEFT_ARROW in keys: # 좌회전 left_v, right_v = -turn, turn if p.B3G_RIGHT_ARROW in keys: # 우회전 left_v, right_v = turn, -turn drive(left_v, right_v)
4Go-to-Goal — 목표 추적
goal = [5, 3, 0] # 목표 지점 시각화 goal_v = p.createVisualShape(p.GEOM_SPHERE, radius=0.2, rgbaColor=[0,1,0,1]) p.createMultiBody(baseMass=0, baseVisualShapeIndex=goal_v, basePosition=goal) for _ in range(50000): p.stepSimulation() time.sleep(1/240) # 현재 위치 & 방향 pos, orn = p.getBasePositionAndOrientation(car) euler = p.getEulerFromQuaternion(orn) yaw = euler[2] # z축 회전 = 차량 방향 # 목표까지 각도 dx = goal[0] - pos[0] dy = goal[1] - pos[1] target_angle = math.atan2(dy, dx) dist = math.sqrt(dx**2 + dy**2) # 각도 차이 angle_diff = target_angle - yaw # -π ~ π 범위로 정규화 angle_diff = math.atan2(math.sin(angle_diff), math.cos(angle_diff)) if dist < 0.5: drive(0, 0) # 도착! print("목표 도착!") break elif abs(angle_diff) > 0.3: # 먼저 방향 맞추기 turn_dir = 5 if angle_diff > 0 else -5 drive(-turn_dir, turn_dir) else: # 방향 맞으면 직진 drive(10, 10)
로봇 자율 이동의 가장 기본! "목표가 왼쪽이면 왼쪽으로 돌고, 앞에 있으면 직진". 이게 Go-to-Goal.
5장애물 + Go-to-Goal 결합
4회차 레이캐스트를 합쳐서 "장애물 있으면 우회, 없으면 목표로" 로직:
# 장애물 배치 for obs_pos in [(2,1,0.5), (3,2,0.5), (4,1.5,0.5)]: s = p.createCollisionShape(p.GEOM_BOX, halfExtents=[0.5]*3) v = p.createVisualShape(p.GEOM_BOX, halfExtents=[0.5]*3, rgbaColor=[0.8,0.2,0.2,1]) p.createMultiBody(baseMass=0, baseCollisionShapeIndex=s, baseVisualShapeIndex=v, basePosition=obs_pos) # 시뮬 루프 안에서: 전방 레이캐스트 pos, orn = p.getBasePositionAndOrientation(car) euler = p.getEulerFromQuaternion(orn) yaw = euler[2] # 전방 3m 레이 front = [pos[0]+math.cos(yaw)*3, pos[1]+math.sin(yaw)*3, 0.3] ray = p.rayTest([pos[0],pos[1],0.3], front) obstacle_ahead = ray[0][0] >= 0 and ray[0][2] < 0.8 if obstacle_ahead: drive(-5, 5) # 장애물 → 회전 else: # Go-to-Goal 로직 (위 코드) ...
이건 가장 원시적인 Bug Algorithm(벽 따라가기). 실전에선 A*, RRT, DWA 같은 알고리즘을 씀.
6정리
✅ Differential Drive → ✅ 키보드 RC → ✅ Go-to-Goal → ✅ 장애물 회피 결합
결과물: 장애물을 피하면서 목표 지점까지 자율 이동하는 바퀴 로봇
목표 지점을 3개(웨이포인트)로 설정해서 A→B→C→A 순서대로 순회하는 순찰 로봇을 만들어 보세요.
08종합 프로젝트 & Django 연동심화+
8주간 배운 것을 통합해 미니 프로젝트를 완성하고, 결과를 Django 서버로 전송한다.
1프로젝트 선택
두 가지 중 하나를 선택합니다:
A. 물체 분류 로봇 카메라로 색상 감지 → 빨간/파란 분류 → Pick & Place (5주차 + 6주차 통합) B. 순찰 + 수거 로봇 바퀴 로봇이 웨이포인트 순회 → 레이캐스트로 물체 발견 → 위치 기록 (4주차 + 7주차 통합)
2프로젝트 A: 물체 분류 로봇
import pybullet as p import pybullet_data import numpy as np import time, math p.connect(p.GUI) p.setAdditionalSearchPath(pybullet_data.getDataPath()) p.setGravity(0,0,-9.81) p.loadURDF("plane.urdf") p.loadURDF("table/table.urdf", [0.5,0,-0.63]) panda = p.loadURDF("franka_panda/panda.urdf", useFixedBase=True) # 빨간 큐브 2개 + 파란 큐브 2개 results = {'red': 0, 'blue': 0, 'fail': 0} objects = [] for color, rgba, x in [ ('red', [1,0,0,1], 0.4), ('blue',[0,0,1,1], 0.5), ('red', [1,0,0,1], 0.6), ('blue',[0,0,1,1], 0.45), ]: s = p.createCollisionShape(p.GEOM_BOX, halfExtents=[0.02]*3) v = p.createVisualShape(p.GEOM_BOX, halfExtents=[0.02]*3, rgbaColor=rgba) obj_id = p.createMultiBody(baseMass=0.1, baseCollisionShapeIndex=s, baseVisualShapeIndex=v, basePosition=[x, 0, 0.05]) objects.append((obj_id, color, [x, 0, 0.05])) # move_to, gripper_open, gripper_close, wait는 6주차 그대로 # ... (함수 정의 생략) # 분류 실행 RED_BOX = [0.3, 0.3, 0.05] BLUE_BOX = [0.3, -0.3, 0.05] for obj_id, color, pos in objects: target = RED_BOX if color == 'red' else BLUE_BOX gripper_open(); wait(60) move_to([pos[0], pos[1], 0.2]); wait(200) move_to([pos[0], pos[1], pos[2]+0.02]); wait(200) gripper_close(); wait(100) move_to([target[0], target[1], 0.2]); wait(200) move_to(target); wait(200) gripper_open(); wait(60) results[color] += 1 print(f"{color} 분류 완료! {results}") print(f"최종 결과: {results}")
3Django API 연동
import requests import json # 시뮬 완료 후 결과 전송 data = { 'student': '홍길동', 'project': '물체 분류', 'red_sorted': results['red'], 'blue_sorted': results['blue'], 'failures': results['fail'], 'total_time': 42.5, # 초 } try: res = requests.post( 'https://apptulz.com/api/robotics/result/', json=data, timeout=5 ) print(f"서버 응답: {res.status_code}") except: print("서버 연결 실패 (로컬 저장)") with open('result.json', 'w') as f: json.dump(data, f)
시뮬레이션 결과를 Django API로 POST! 학생들이 "내 로봇 성적이 서버에 올라간다"는 경험.
서버 없어도 result.json으로 로컬 저장하니까 오프라인에서도 작동합니다.
48주 회고 & 다음 스텝
🎓 8주간 배운 것:
물리 세계 → URDF → 관절 제어(Position/Velocity/Torque) → 충돌/레이캐스트 → 카메라(RGB/Depth/Seg) → IK + Pick&Place → 바퀴 로봇 + 자율이동 → Django 연동

🚀 Phase 2 미리보기:
→ 커스텀 URDF 설계
→ PID 제어 직접 구현
→ Gymnasium 환경 만들기
→ PPO 강화학습으로 로봇 스스로 학습
→ Django 학습 결과 대시보드
→ MuJoCo 마이그레이션
결과물: 색상별 물체 분류 로봇 + Django 서버에 결과 기록 — Phase 1 완성! 🎉