인공지능 AI

젯슨 나노 피지컬 AI 2부 - 팬틸트 카메라 사람 추적

테크플렉스 2026. 9. 8. 13:59
반응형
책상 위에서 움직이는 소형 로봇 팔
사진: Sufyan / Unsplash

1부에서 만든 err_x 는 아직 숫자일 뿐입니다. 이 숫자를 서보 각도로 바꿔 카메라가 실제로 돌기 시작하는 순간이 피지컬 AI의 시작점입니다. 그리고 그 순간부터 화면 안에서는 없던 문제들이 한꺼번에 나타납니다.

40핀 하드웨어 PWM은 건너뜁니다

오린 나노의 40핀 헤더에도 하드웨어 PWM이 있습니다. 15번과 33번 핀입니다. jetson-io 로 핀 기능을 바꾸고 sysfs에 쓰면 됩니다. 그런데 학생 프로젝트에서는 권하지 않습니다. 이유가 셋입니다.

첫째, 핀이 두 개뿐입니다. 팬과 틸트 두 축이면 이미 다 씁니다. 3부에서 바퀴 모터까지 붙이면 모자랍니다.

둘째, 동작 보고가 엇갈립니다. NVIDIA 개발자 포럼에는 오린 나노에서 sysfs 쓰기는 성공하는데 핀에 신호가 안 나온다는 글이 올라와 있고, NVIDIA의 jetson-gpio 저장소에도 33번은 되는데 15번은 안 된다는 이슈가 있습니다. 오실로스코프 없이 이 문제를 디버깅하면 하루가 사라집니다.

셋째, 레벨이 3.3V 입니다. 서보 신호선은 보통 5V 로직을 기대합니다. 대개는 3.3V로도 동작하지만 개체 편차가 있습니다.

파이썬으로 GPIO를 토글해 소프트웨어 PWM을 만드는 방법도 있는데, 리눅스 스케줄러 지터가 그대로 펄스 폭에 실려서 서보가 미세하게 계속 떱니다. 이 시리즈는 PCA9685 보드 하나를 I2C로 붙이는 방식으로 갑니다. 5천 원짜리 부품이 16채널을 해결합니다.

부품과 배선

준비물은 다음과 같습니다.

  • PCA9685 16채널 PWM 드라이버 보드 1장
  • 팬틸트 브래킷 + SG90 서보 2개 (가볍고 싸서 실습에 적당합니다. MG996R 같은 금속 기어 서보는 힘은 좋지만 전류를 훨씬 많이 먹습니다)
  • 서보 전용 5V 3A 어댑터 1개. 젯슨 5V 핀에서 끌어 쓰지 마세요.
  • 점퍼선, 브레드보드
전원 분리 배선의 개념도. 실제 보드의 배치도가 아니라 설명을 위해 직접 그린 그림입니다.

 

PCA9685 쪽 연결 대상 주의할 점
VCC 젯슨 1번 핀 (3.3V) 로직 전원입니다. 5V를 물리면 I2C 풀업이 5V가 되어 젯슨 쪽에 부담이 갑니다
GND 젯슨 6번 핀 어댑터 GND와도 반드시 연결
SDA 젯슨 3번 핀 오린 나노에서 이 버스는 /dev/i2c-7
SCL 젯슨 5번 핀
V+ (초록 단자대 +) 5V 어댑터 + 서보로 흐르는 전류가 여기로만 지나갑니다
V+ (초록 단자대 −) 5V 어댑터 − 그리고 젯슨 GND와 연결
채널 0 신호 팬 서보 (주황·노랑선) 서보의 빨강은 V+, 갈색은 GND
채널 1 신호 틸트 서보

전원을 나누라는 말이 잔소리처럼 들리겠지만, 이걸 안 지키면 증상이 아주 헷갈리는 형태로 나타납니다. 서보가 급히 방향을 바꾸는 순간 순간 전류가 튀고, 젯슨의 5V 레일이 흔들려서 보드가 그냥 재부팅됩니다. 코드에는 아무 문제가 없는데 "가끔 죽는다"는 상태가 되면 제일 먼저 여기를 의심하세요.

I2C가 잡혔는지부터 확인합니다

배선을 마쳤으면 코드를 짜기 전에 주소가 보이는지 봅니다.

sudo apt install -y i2c-tools python3-smbus
sudo usermod -aG i2c $USER      # 적용하려면 로그아웃 후 다시 로그인

i2cdetect -l                     # 버스 목록. i2c-7 이 40핀 3·5번 핀이다
i2cdetect -y -r 7                # 표에 40 이 보이면 성공

40 이 안 보이면 코드로 넘어가 봐야 소용없습니다. SDA/SCL이 바뀌었거나, GND가 안 물렸거나, 보드 VCC에 전원이 없는 경우가 대부분입니다.

파이썬 쪽 라이브러리를 깝니다.

sudo pip3 install -U adafruit-blinka adafruit-circuitpython-pca9685 \
                     adafruit-circuitpython-servokit smbus2

서보를 감싸서 안전하게 만듭니다

kit.servo[0].angle = 30 처럼 직접 쓰면 두 가지 사고가 납니다. 브래킷의 기구 한계를 넘는 각도를 주면 서보가 벽을 밀면서 계속 힘을 쓰다가 기어가 갈리고, 한 프레임에 60도씩 뛰면 관성 때문에 카메라가 출렁입니다. 그래서 각도 제한과 속도 제한을 강제하는 얇은 층을 하나 씌웁니다.

# servo.py — PCA9685 위의 서보를 안전 범위 안에서만 움직인다
import board
import busio

# 오린 나노의 3·5번 핀은 /dev/i2c-7 이고, Blinka 에서는 SCL_1/SDA_1 로 잡힌다.
# ServoKit(channels=16) 이 인자 없이 그냥 열린다면 이 줄은 필요 없다.
I2C = busio.I2C(board.SCL_1, board.SDA_1)


class Joint:
    """각도 제한과 속도 제한을 강제하는 서보 한 축."""

    def __init__(self, kit, channel, lo=30.0, hi=150.0, start=90.0,
                 max_step=3.0, pulse=(500, 2500)):
        self.kit = kit
        self.ch = channel
        self.lo = lo
        self.hi = hi
        self.max_step = max_step      # 한 프레임에 움직일 수 있는 최대 각도
        # SG90 계열은 기본값(750~2250us)으로는 가동 범위를 다 못 쓴다
        kit.servo[channel].set_pulse_width_range(*pulse)
        self.angle = start
        kit.servo[channel].angle = start

    def move_by(self, delta):
        """상대 각도로 움직인다. 범위와 속도는 여기서 잘린다."""
        step = max(-self.max_step, min(self.max_step, delta))
        target = max(self.lo, min(self.hi, self.angle + step))
        if abs(target - self.angle) < 0.05:
            return self.angle          # 미세한 명령은 버린다. 떨림의 절반이 여기서 준다
        self.angle = target
        self.kit.servo[self.ch].angle = target
        return target

    def release(self):
        """펄스를 끊어 힘을 뺀다. 종료할 때 반드시 부른다."""
        self.kit.servo[self.ch].angle = None

release() 를 빼먹으면 프로그램이 끝난 뒤에도 서보가 마지막 각도를 계속 물고 있습니다. 브래킷이 어딘가에 걸려 있으면 조용히 계속 힘을 쓰다가 뜨거워집니다.

라이브러리 없이 PCA9685를 두드려 보기

Blinka 설치가 꼬이거나, PWM이 어떻게 만들어지는지 눈으로 보고 싶을 때를 위한 30줄짜리 대안입니다. 서보 신호는 20ms 주기(50Hz) 안에서 펄스 폭이 0.5~2.5ms로 변하는 신호일 뿐입니다.

# pca9685_raw.py — 라이브러리 없이 PCA9685 를 직접 제어한다
import time

from smbus2 import SMBus

MODE1, PRESCALE, LED0_ON_L = 0x00, 0xFE, 0x06


class PCA9685:
    def __init__(self, bus=7, address=0x40, freq=50):
        self.bus = SMBus(bus)
        self.addr = address
        self.set_freq(freq)

    def set_freq(self, hz):
        # 내장 오실레이터 25MHz 를 4096 단계로 나눈다
        prescale = round(25_000_000 / (4096 * hz)) - 1     # 50Hz -> 121
        old = self.bus.read_byte_data(self.addr, MODE1)
        self.bus.write_byte_data(self.addr, MODE1, (old & 0x7F) | 0x10)  # SLEEP
        self.bus.write_byte_data(self.addr, PRESCALE, prescale)          # 자는 동안만 바꿀 수 있다
        self.bus.write_byte_data(self.addr, MODE1, old)
        time.sleep(0.005)
        self.bus.write_byte_data(self.addr, MODE1, old | 0xA1)           # RESTART + 오토인크리먼트
        self.period_us = 1_000_000 / hz

    def set_pulse_us(self, ch, us):
        off = int(round(us / self.period_us * 4096)) & 0x0FFF   # 1500us -> 307
        base = LED0_ON_L + 4 * ch
        self.bus.write_i2c_block_data(self.addr, base,
                                      [0, 0, off & 0xFF, off >> 8])

    def set_angle(self, ch, deg, lo_us=500, hi_us=2500):
        deg = max(0.0, min(180.0, deg))
        self.set_pulse_us(ch, lo_us + (hi_us - lo_us) * deg / 180.0)


if __name__ == "__main__":
    pwm = PCA9685()
    for a in (60, 90, 120, 90):
        pwm.set_angle(0, a)
        time.sleep(0.6)
    pwm.set_pulse_us(0, 0)       # 펄스 정지 = 힘 빼기

오차를 각도로 바꾸는 P 제어

이제 본론입니다. 표적이 화면 오른쪽에 있으면 카메라를 오른쪽으로 조금 돌리고, 중앙에 가까워질수록 덜 돌립니다. 오차에 비례해서 움직이는 것, 그게 P 제어입니다.

추적 제어 루프. 개념 설명을 위해 직접 그린 그림입니다.

 

여기서 미분항(D)이나 적분항(I)을 굳이 붙이지 않는 이유가 있습니다. 검출 좌표는 프레임마다 몇 픽셀씩 튀는데, 미분항은 이 노이즈를 그대로 증폭합니다. 적분항은 정상상태 오차를 없애 주지만 표적이 화면 밖으로 나갔다가 돌아오는 동안 오차가 계속 쌓여서, 다시 잡는 순간 카메라가 크게 튑니다. 대신 데드밴드지수 평활 두 가지를 넣습니다.

  • 데드밴드: 오차가 아주 작으면 아예 움직이지 않습니다. 이게 없으면 표적이 가만히 있어도 서보가 좌우로 계속 미세하게 떱니다.
  • 지수 평활: 이번 프레임 좌표와 직전 좌표를 섞습니다. 검출이 한 프레임 튀어도 서보는 따라 튀지 않습니다.

전체 코드

# track.py — 사람을 화면 중앙에 붙잡아 두는 팬틸트 추적기
import signal
import time

from adafruit_servokit import ServoKit
from jetson_inference import detectNet
from jetson_utils import videoSource, videoOutput, cudaFont

from servo import I2C, Joint

KP_PAN = 22.0        # 오차가 1.0(화면 끝)일 때 몇 도를 움직일지
KP_TILT = 16.0
DEADBAND = 0.06      # 이 안쪽 오차는 무시한다. 없으면 계속 떤다
EMA = 0.6            # 검출 좌표 평활 계수. 1.0 이면 평활 없음
PAN_SIGN = -1.0      # 카메라가 반대로 돌면 이 부호를 뒤집는다
TILT_SIGN = +1.0
LOST_TIMEOUT = 2.0   # 이만큼 못 찾으면 천천히 중립으로 복귀

kit = ServoKit(channels=16, i2c=I2C, address=0x40)
pan = Joint(kit, channel=0, lo=20, hi=160, start=90, max_step=2.5)
tilt = Joint(kit, channel=1, lo=60, hi=120, start=90, max_step=2.0)

net = detectNet("ssd-mobilenet-v2", threshold=0.5)
camera = videoSource("csi://0")
display = videoOutput("webrtc://@:8554/output")   # 모니터가 있으면 display://0
font = cudaFont()

running = True


def stop(*_):
    global running
    running = False


signal.signal(signal.SIGINT, stop)

sx = sy = 0.0
last_seen = 0.0

try:
    while running and display.IsStreaming():
        img = camera.Capture()
        if img is None:
            continue

        people = [d for d in net.Detect(img, overlay="box,labels")
                  if net.GetClassDesc(d.ClassID) == "person"]

        if people:
            t = max(people, key=lambda d: d.Area)
            ex = (t.Center[0] - img.width / 2) / (img.width / 2)
            ey = (t.Center[1] - img.height / 2) / (img.height / 2)
            sx = EMA * ex + (1 - EMA) * sx
            sy = EMA * ey + (1 - EMA) * sy
            last_seen = time.time()

            if abs(sx) > DEADBAND:
                pan.move_by(PAN_SIGN * KP_PAN * sx)
            if abs(sy) > DEADBAND:
                tilt.move_by(TILT_SIGN * KP_TILT * sy)

        elif time.time() - last_seen > LOST_TIMEOUT:
            # 표적을 놓쳤으면 중립으로 천천히 돌아간다
            pan.move_by((90 - pan.angle) * 0.1)
            tilt.move_by((90 - tilt.angle) * 0.1)
            sx = sy = 0.0

        font.OverlayText(
            img, img.width, img.height,
            f"pan {pan.angle:5.1f}  tilt {tilt.angle:5.1f}  "
            f"err {sx:+.2f}/{sy:+.2f}  {net.GetNetworkFPS():.0f} FPS",
            10, 10, font.White, font.Gray40,
        )
        display.Render(img)

finally:
    # 어떻게 끝나든 힘은 빼고 나간다
    pan.release()
    tilt.release()
    print("서보 해제 완료")

게인은 이 순서로 맞춥니다

숫자를 한 번에 맞추려 하지 말고 순서대로 좁히세요.

  1. KP_PAN = 0 으로 두고 실행해 err_x 출력만 봅니다. 오른쪽으로 움직였을 때 양수가 나오는지 확인합니다.
  2. KP_PAN 을 5로 놓고 켭니다. 카메라가 표적 반대 방향으로 가면 PAN_SIGN 부호를 뒤집습니다.
  3. 방향이 맞으면 KP_PAN 을 5씩 올립니다. 표적을 지나쳐 좌우로 왕복하기 시작하는 값을 찾습니다.
  4. 그 값의 절반을 씁니다. 팬 축은 대개 15~25 사이에 자리 잡습니다.
  5. 틸트 축은 브래킷 무게 때문에 관성이 다릅니다. 팬 값의 0.6~0.8배에서 시작하세요.

안 될 때 보는 표

증상 원인 조치
표적을 잡았는데 반대로 돔 서보 장착 방향 PAN_SIGN 또는 TILT_SIGN 부호 뒤집기
중앙 근처에서 계속 미세하게 떨림 데드밴드 부족, 검출 좌표 노이즈 DEADBAND 0.06 → 0.10, EMA 0.6 → 0.35
표적을 지나쳐 좌우로 왕복 게인 과다 KP_PAN 을 절반으로
너무 느리게 따라감 게인 부족 또는 슬루 제한 KP_PAN 1.5배, max_step 2.5 → 4.0
서보가 움직이는 순간 젯슨이 재부팅 서보 전류가 젯슨 5V를 끌어감 서보 전원을 별도 어댑터로 분리, GND 공통
OSError: [Errno 121] Remote I/O error I2C 배선 또는 주소 i2cdetect -y -r 7 로 0x40 확인, 점퍼선 짧게
ValueError: No I2C device at address Blinka 가 다른 버스를 잡음 busio.I2C(board.SCL_1, board.SDA_1) 로 명시
서보가 끝까지 안 돌아감 펄스 폭 기본값이 좁음 set_pulse_width_range(500, 2500) 확인
프로그램 종료 후 서보가 뜨거움 힘을 안 뺐음 finally 에서 release() 호출

여기까지 오면 무엇이 생긴 건가

지금 만든 것은 30줄짜리 장난감처럼 보이지만, 구조는 산업용 비전 트래킹과 같습니다. 센서에서 상태를 읽고, 목표와의 차이를 계산하고, 액추에이터로 그 차이를 줄이고, 그 결과가 다시 센서로 들어옵니다. 다른 점은 산업용이 여기에 안전 회로와 예외 처리를 몇 백 배 더 붙였다는 것뿐입니다.

그리고 한계도 같이 보입니다. 이 코드는 "사람의 중심이 화면 가운데 오게 하라"는 규칙을 사람이 직접 적어 넣은 것입니다. 규칙을 적을 수 없는 문제 — 이를테면 "차선을 따라 달려라" — 는 이 방식으로 못 풉니다. 3부에서는 규칙을 적는 대신 사람이 조종한 데이터를 신경망에 학습시켜 그 규칙을 만들어 냅니다.

다음 편

3부는 팬틸트 대신 바퀴입니다. 게임패드로 미니카를 몰면서 (카메라 영상, 조향값) 쌍을 모으고, ResNet-18을 그 데이터로 학습시켜 사람 없이 트랙을 돌게 만듭니다. 데이터가 왜 좌우 균형이 맞아야 하는지, 실패하는 구간을 어떻게 다시 모으는지까지 다룹니다.

지금 할 일은 게인 튜닝의 3번 단계입니다. KP_PAN 을 일부러 60까지 올려서 카메라가 좌우로 발진하는 것을 한 번 보세요. 제어에서 게인을 왜 절반으로 깎는지는 그 장면을 눈으로 본 다음에야 몸에 남습니다.

참고 자료

반응형