Skip to main content
Summary

바닥의 선을 따라가는 가장 로봇다운 프로젝트

라인트레이서는 바닥에 그어진 검은색 선을 따라 움직이는 로봇이다. 겉으로 보면 단순하다. 흰색 바닥 위에 검은색 테이프를 붙이고, 로봇이 그 선을 따라가게 만들면 된다. 하지만 안쪽에서는 로봇 제어의 핵심이 거의 모두 등장한다.

로봇은 센서로 현재 상태를 읽고, 목표와 얼마나 벗어났는지 계산한 뒤, 모터 출력을 바꾸어 다시 목표에 가까워진다. 이것이 앞에서 배운 피드백 제어의 기본 구조다. 라인트레이서에서는 목표가 "라인을 로봇의 정중앙에 두는 것"이고, 현재값은 "센서 배열이 읽은 라인의 위치"다. 오차는 "라인이 중심에서 얼마나 왼쪽 또는 오른쪽으로 치우쳤는가"가 된다.

이번 장에서는 여러 개의 라인 센서를 사용해 라인의 위치를 계산하고, 그 오차에 비례해 좌우 모터 속도를 다르게 주는 P 제어 라인트레이서 로봇을 만든다. 장애물 회피 자동차가 주변 물체에 반응하는 로봇이었다면, 라인트레이서는 바닥의 길을 읽고 스스로 방향을 조정하는 로봇이다.

라인트레이서 로봇

준비할 부품

라인트레이서 로봇은 2륜 구동 바퀴 로봇을 기본으로 만든다. 앞쪽에는 라인 센서 배열을 달고, 좌우 바퀴는 각각 독립적으로 제어한다. 왼쪽 모터와 오른쪽 모터의 속도를 다르게 주면 로봇은 자연스럽게 방향을 튼다.

필요한 부품은 다음과 같다.

  • 아두이노 Uno, Nano, ESP32 같은 마이크로컨트롤러 보드

  • 2륜 구동 로봇 섀시, DC 기어드 모터 2개, 바퀴 2개, 캐스터 1개

  • TB6612FNG, L298N 같은 DC 모터 드라이버

  • 5채널 또는 6채널 라인 센서 배열

  • 배터리, 전원 스위치, 점퍼선, 나사와 양면테이프

  • 검은색 전기테이프 또는 라인 트레이싱용 트랙

라인 센서는 가능하면 5채널 이상을 추천한다. 3채널 센서도 주행은 가능하지만, 라인이 중심에서 조금만 벗어나도 정보가 거칠어진다. 5채널 배열은 "라인이 왼쪽 끝에 있다", "약간 왼쪽이다", "중앙이다", "약간 오른쪽이다", "오른쪽 끝에 있다"를 더 부드럽게 구분할 수 있다.

이 장의 예제는 5채널 아날로그 라인 센서를 기준으로 설명한다. 디지털 출력만 있는 센서 모듈도 사용할 수 있지만, 아날로그 센서를 쓰면 라인의 위치를 더 세밀하게 계산할 수 있어 P 제어의 효과를 더 잘 볼 수 있다.

센서 배열은 로봇의 앞눈이다

라인 센서 배열은 보통 적외선 LED와 수광 센서로 구성된다. 바닥에 적외선을 쏘고, 반사되어 돌아오는 빛의 양을 읽는다. 흰색 바닥은 빛을 많이 반사하고, 검은색 라인은 빛을 적게 반사한다. 센서는 이 차이를 전압이나 디지털 신호로 바꾸어 보드에 전달한다.

중요한 것은 센서 하나가 아니라 "배열"이라는 점이다. 센서가 여러 개 일렬로 놓이면 로봇은 라인의 위치를 추정할 수 있다. 예를 들어 5개의 센서를 왼쪽부터 S0, S1, S2, S3, S4라고 해 보자.

라인이 정중앙에 있으면 가운데 센서 S2가 가장 강하게 반응한다. 라인이 왼쪽으로 벗어나면 S0나 S1 쪽 반응이 커진다. 반대로 라인이 오른쪽으로 벗어나면 S3나 S4 쪽 반응이 커진다.

이제 센서의 위치에 점수를 붙여 보자.

S0    S1    S2    S3    S4
-2    -1     0    +1    +2
왼쪽       중심       오른쪽

라인이 왼쪽에 있으면 오차는 음수가 되고, 오른쪽에 있으면 오차는 양수가 된다. 로봇이 잘 따라가고 있다면 오차는 0에 가까워진다. 이 숫자 하나만 얻을 수 있으면 제어는 훨씬 쉬워진다. 왼쪽으로 치우쳤을 때는 왼쪽으로 돌고, 오른쪽으로 치우쳤을 때는 오른쪽으로 돌면 되기 때문이다.

센서 장착 위치가 주행을 바꾼다

라인 센서는 로봇 앞쪽에 달아야 한다. 바퀴 중심축 바로 아래에 달면 로봇이 라인을 발견했을 때 이미 방향을 바꾸기 늦다. 사람으로 비유하면 발끝 바로 아래만 보고 걷는 것과 같다. 조금 앞을 봐야 미리 방향을 바꿀 수 있다.

센서 배열은 보통 앞바퀴 축보다 3~7cm 정도 앞에 장착한다. 작은 미니 로봇이라면 3cm 정도도 충분하고, 속도가 빠르거나 바퀴 간격이 넓다면 더 앞쪽에 다는 것이 좋다. 센서와 바닥 사이의 거리는 센서 모듈마다 다르지만 대개 5~10mm 정도에서 안정적으로 동작한다.

센서를 달 때는 세 가지를 특히 확인하자.

  • 센서 배열이 바닥과 평행해야 한다.

  • 센서 높이가 너무 높거나 낮지 않아야 한다.

  • 센서 중심이 로봇의 정중앙과 맞아야 한다.

센서가 한쪽으로 치우쳐 있으면 로봇은 계속 한쪽으로 도는 것처럼 보일 수 있다. 코드가 틀린 것이 아니라 센서가 삐뚤게 달린 것일 수도 있다. 라인트레이서는 하드웨어 정렬이 생각보다 중요하다. 같은 코드라도 센서 높이와 각도에 따라 주행 성능이 크게 달라진다.

기본 배선

아래 배선은 아두이노 Uno와 5채널 아날로그 라인 센서, TB6612FNG 또는 비슷한 모터 드라이버를 사용하는 구성을 기준으로 한다. 모터 드라이버 핀 이름은 제품마다 조금씩 다르지만 원리는 같다. 방향 핀 2개와 PWM 핀 1개로 한쪽 모터를 제어한다.

장치핀아두이노 예시
라인 센서 S0아날로그 출력A0
라인 센서 S1아날로그 출력A1
라인 센서 S2아날로그 출력A2
라인 센서 S3아날로그 출력A3
라인 센서 S4아날로그 출력A4
왼쪽 모터 PWM속도 제어D5
왼쪽 모터 IN1방향 제어D7
왼쪽 모터 IN2방향 제어D8
오른쪽 모터 PWM속도 제어D6
오른쪽 모터 IN1방향 제어D9
오른쪽 모터 IN2방향 제어D10

ESP32를 사용한다면 아날로그 입력이 가능한 GPIO를 센서에 연결해야 한다. 예를 들어 GPIO 32, 33, 34, 35, 36 같은 핀을 사용할 수 있다. 단, ESP32의 일부 핀은 입력 전용이거나 부팅 상태에 영향을 줄 수 있으므로, 실제 보드 핀맵을 확인하고 연결해야 한다.

전원은 특히 조심해야 한다. 모터는 순간적으로 큰 전류를 요구한다. 보드의 5V 핀에서 모터 전원을 직접 가져오면 보드가 리셋되거나 센서값이 튈 수 있다. 배터리에서 모터 드라이버로 모터 전원을 공급하고, 보드는 안정적인 5V 또는 3.3V 전원을 사용하게 한다. 그리고 보드 GND, 센서 GND, 모터 드라이버 GND는 반드시 서로 연결해야 한다. 신호는 GND라는 공통 기준이 있어야 제대로 전달된다.

흰색과 검은색 기준 잡기

라인 센서값은 환경에 따라 달라진다. 같은 센서라도 방 조명, 바닥 재질, 센서 높이, 배터리 전압에 따라 값이 달라진다. 그래서 "센서값이 500보다 작으면 검은색"처럼 고정 기준만 쓰면 트랙을 바꾸는 순간 로봇이 길을 잃을 수 있다.

이 문제를 해결하는 과정이 캘리브레이션이다. 캘리브레이션은 센서가 실제 주행 환경에서 읽는 최소값과 최대값을 기록해 기준을 잡는 작업이다. 로봇을 트랙 위에서 손으로 좌우로 흔들어 주면 각 센서는 흰색 바닥과 검은색 라인을 모두 보게 된다. 이때 각 센서가 읽은 가장 작은 값과 가장 큰 값을 저장한다.

그다음 센서값을 0부터 1000 사이로 바꾼다. 예를 들어 어떤 센서가 흰색 바닥에서 850, 검은색 라인에서 250을 읽는다고 하자. 이 센서는 검은색에서 값이 낮고 흰색에서 값이 높다. 반대로 어떤 모듈은 검은색에서 값이 높게 나올 수도 있다. 중요한 것은 절대값이 아니라 "흰색과 검은색을 구분할 수 있도록 정규화하는 것"이다.

이 장의 코드는 검은색 라인을 1000에 가깝게, 흰색 바닥을 0에 가깝게 바꾸어 사용한다. 이렇게 해 두면 계산이 쉬워진다. 센서값이 클수록 "여기에 라인이 있다"는 뜻이 된다.

라인 위치를 숫자 하나로 만들기

센서 5개의 값을 각각 따로 보고 제어할 수도 있다. 예를 들어 왼쪽 센서가 검은색이면 왼쪽으로 돌고, 오른쪽 센서가 검은색이면 오른쪽으로 도는 식이다. 하지만 이런 방식은 움직임이 딱딱하다. 로봇이 지그재그로 흔들리기 쉽다.

더 부드러운 방법은 가중 평균을 사용하는 것이다. 각 센서 위치에 숫자를 붙이고, 센서가 강하게 반응한 위치에 더 큰 영향을 주는 방식이다.

5개 센서의 위치를 다음처럼 잡아 보자.

S0     S1     S2     S3     S4
-2000 -1000    0    1000   2000

센서값이 다음과 같다고 해 보자.

S0 = 0, S1 = 200, S2 = 900, S3 = 300, S4 = 0

라인은 거의 중앙에 있지만 약간 오른쪽으로 치우쳐 있다. 이때 라인 위치는 다음처럼 계산한다.

라인 위치 = (S0×-2000 + S1×-1000 + S2×0 + S3×1000 + S4×2000)
          / (S0 + S1 + S2 + S3 + S4)

계산 결과가 0이면 중앙이다. 음수면 왼쪽, 양수면 오른쪽이다. 이 값이 바로 제어에 사용할 오차가 된다. 목표 위치가 0이므로, 현재 라인 위치 자체가 오차라고 볼 수 있다.

P 제어로 좌우 모터 속도 조절하기

이제 오차를 모터 출력으로 바꿔 보자. P 제어는 오차에 비례해 보정량을 만드는 방식이다.

보정량 = Kp × 오차

여기서 Kp는 비례 게인이다. 게인은 오차에 얼마나 민감하게 반응할지 정하는 값이다. Kp가 너무 작으면 로봇이 라인을 따라가지 못하고 넓게 휘어진다. Kp가 너무 크면 로봇이 좌우로 심하게 흔들린다.

오차가 양수라는 것은 라인이 로봇의 오른쪽에 있다는 뜻이다. 그러면 로봇은 오른쪽으로 돌아야 한다. 차동 구동 로봇에서 오른쪽으로 돌려면 왼쪽 바퀴를 더 빠르게, 오른쪽 바퀴를 더 느리게 돌리면 된다.

왼쪽 모터 속도 = 기본 속도 + 보정량
오른쪽 모터 속도 = 기본 속도 - 보정량

반대로 라인이 왼쪽에 있으면 오차는 음수가 된다. 보정량도 음수가 되므로 왼쪽 모터 속도는 줄고 오른쪽 모터 속도는 증가한다. 그러면 로봇은 왼쪽으로 돈다.

이 구조가 라인트레이서의 핵심이다. 센서 배열이 오차를 만들고, P 제어가 보정량을 만들고, 좌우 모터 속도 차이가 방향 전환을 만든다.

첫 번째 주행 코드

아래 코드는 아두이노 Uno 기준 예제다. 5채널 아날로그 라인 센서를 읽고, 자동 캘리브레이션을 한 뒤, P 제어로 라인을 따라간다. 모터 드라이버는 방향 핀 2개와 PWM 핀 1개로 한쪽 모터를 제어하는 일반적인 구성을 가정한다.

센서 모듈에 따라 검은색에서 값이 낮게 나올 수도 있고 높게 나올 수도 있다. 코드의 BLACK_IS_LOW 값을 바꾸면 된다. 검은색 라인 위에 센서를 올렸을 때 아날로그 값이 흰색 바닥보다 낮으면 true, 높으면 false로 둔다.

// 5채널 라인트레이서 예제
// Arduino Uno 기준

const int NUM_SENSORS = 5;
const int sensorPins[NUM_SENSORS] = {A0, A1, A2, A3, A4};

// 센서 위치: 왼쪽은 음수, 오른쪽은 양수
const int sensorWeight[NUM_SENSORS] = {-2000, -1000, 0, 1000, 2000};

int sensorMin[NUM_SENSORS];
int sensorMax[NUM_SENSORS];
int sensorValue[NUM_SENSORS];

// 모터 핀
const int L_PWM = 5;
const int L_IN1 = 7;
const int L_IN2 = 8;

const int R_PWM = 6;
const int R_IN1 = 9;
const int R_IN2 = 10;

// 센서 특성 설정
// 검은색에서 analogRead 값이 낮게 나오면 true
// 검은색에서 analogRead 값이 높게 나오면 false
const bool BLACK_IS_LOW = true;

// 주행 파라미터
int baseSpeed = 110;      // 기본 속도, 처음에는 낮게 시작
float Kp = 0.08;          // P 제어 게인
int maxSpeed = 220;       // 모터 출력 제한
int lostLineSpeed = 80;   // 라인을 잃었을 때 회전 속도

int lastError = 0;

void setup() {
  Serial.begin(9600);

  pinMode(L_PWM, OUTPUT);
  pinMode(L_IN1, OUTPUT);
  pinMode(L_IN2, OUTPUT);
  pinMode(R_PWM, OUTPUT);
  pinMode(R_IN1, OUTPUT);
  pinMode(R_IN2, OUTPUT);

  for (int i = 0; i < NUM_SENSORS; i++) {
    sensorMin[i] = 1023;
    sensorMax[i] = 0;
  }

  stopMotors();

  Serial.println("Calibration start");
  calibrateSensors(4000);
  Serial.println("Calibration done");

  delay(1000);
}

void loop() {
  int lineStrength = 0;
  int position = readLinePosition(lineStrength);

  // 라인을 거의 감지하지 못한 경우
  if (lineStrength < 120) {
    recoverLine();
    return;
  }

  int error = position;  // 목표 위치가 0이므로 position이 곧 오차
  lastError = error;

  int correction = (int)(Kp * error);

  int leftSpeed = baseSpeed + correction;
  int rightSpeed = baseSpeed - correction;

  // 급커브에서 속도를 자동으로 조금 낮춘다
  int absError = abs(error);
  if (absError > 1200) {
    leftSpeed = leftSpeed * 0.8;
    rightSpeed = rightSpeed * 0.8;
  }

  leftSpeed = constrain(leftSpeed, -maxSpeed, maxSpeed);
  rightSpeed = constrain(rightSpeed, -maxSpeed, maxSpeed);

  setMotor(leftSpeed, rightSpeed);

  Serial.print("pos=");
  Serial.print(position);
  Serial.print(" strength=");
  Serial.print(lineStrength);
  Serial.print(" L=");
  Serial.print(leftSpeed);
  Serial.print(" R=");
  Serial.println(rightSpeed);

  delay(5);
}

void calibrateSensors(unsigned long durationMs) {
  unsigned long startTime = millis();

  while (millis() - startTime < durationMs) {
    for (int i = 0; i < NUM_SENSORS; i++) {
      int raw = analogRead(sensorPins[i]);
      if (raw < sensorMin[i]) sensorMin[i] = raw;
      if (raw > sensorMax[i]) sensorMax[i] = raw;
    }
    delay(5);
  }

  for (int i = 0; i < NUM_SENSORS; i++) {
    Serial.print("S");
    Serial.print(i);
    Serial.print(" min=");
    Serial.print(sensorMin[i]);
    Serial.print(" max=");
    Serial.println(sensorMax[i]);
  }
}

int readCalibratedSensor(int index) {
  int raw = analogRead(sensorPins[index]);

  int range = sensorMax[index] - sensorMin[index];
  if (range < 10) range = 10;  // 0으로 나누는 상황 방지

  int value = (raw - sensorMin[index]) * 1000L / range;
  value = constrain(value, 0, 1000);

  // 최종적으로 검은색 라인이 1000에 가깝게 되도록 맞춘다
  if (BLACK_IS_LOW) {
    value = 1000 - value;
  }

  return value;
}

int readLinePosition(int &lineStrength) {
  long weightedSum = 0;
  long sum = 0;

  for (int i = 0; i < NUM_SENSORS; i++) {
    sensorValue[i] = readCalibratedSensor(i);

    weightedSum += (long)sensorValue[i] * sensorWeight[i];
    sum += sensorValue[i];
  }

  lineStrength = sum / NUM_SENSORS;

  if (sum == 0) {
    return lastError;
  }

  return weightedSum / sum;
}

void recoverLine() {
  // 마지막으로 라인이 있던 방향을 기억해 그쪽으로 천천히 회전한다
  if (lastError >= 0) {
    setMotor(lostLineSpeed, -lostLineSpeed);
  } else {
    setMotor(-lostLineSpeed, lostLineSpeed);
  }
}

void setMotor(int leftSpeed, int rightSpeed) {
  setOneMotor(L_PWM, L_IN1, L_IN2, leftSpeed);
  setOneMotor(R_PWM, R_IN1, R_IN2, rightSpeed);
}

void setOneMotor(int pwmPin, int in1, int in2, int speed) {
  speed = constrain(speed, -255, 255);

  if (speed > 0) {
    digitalWrite(in1, HIGH);
    digitalWrite(in2, LOW);
    analogWrite(pwmPin, speed);
  } else if (speed < 0) {
    digitalWrite(in1, LOW);
    digitalWrite(in2, HIGH);
    analogWrite(pwmPin, -speed);
  } else {
    digitalWrite(in1, LOW);
    digitalWrite(in2, LOW);
    analogWrite(pwmPin, 0);
  }
}

void stopMotors() {
  setMotor(0, 0);
}

코드를 업로드한 뒤 바로 바닥에 내려놓지 말자. 전원을 켜면 먼저 4초 동안 캘리브레이션을 한다. 이 시간 동안 로봇을 손으로 들고 센서가 흰색 바닥과 검은색 라인을 모두 보도록 좌우로 천천히 움직여 준다. 캘리브레이션이 끝난 뒤 로봇을 라인 위에 올려놓으면 주행을 시작한다.

처음에는 baseSpeed를 낮게 잡는 것이 좋다. 속도가 낮으면 로봇이 이상하게 움직여도 바로 잡을 수 있고, 제어값을 튜닝하기 쉽다. 처음부터 빠르게 달리게 만들면 로봇이 선을 벗어나거나 테이블 밖으로 뛰쳐나갈 수 있다.

코드가 하는 일 한눈에 보기

이 코드는 크게 네 단계로 움직인다.

첫째, 캘리브레이션 단계에서 각 센서의 최소값과 최대값을 저장한다. 이를 통해 현재 바닥과 라인에 맞는 기준을 자동으로 만든다.

둘째, 센서값을 0부터 1000 사이로 정규화한다. 이때 검은색 라인이 1000에 가까운 값이 되도록 방향을 맞춘다. 이렇게 하면 센서 모듈마다 출력 특성이 달라도 나머지 계산은 그대로 사용할 수 있다.

셋째, 가중 평균으로 라인의 위치를 계산한다. 왼쪽 센서가 강하게 반응하면 위치값은 음수가 되고, 오른쪽 센서가 강하게 반응하면 위치값은 양수가 된다.

넷째, P 제어로 좌우 모터 속도를 바꾼다. 라인이 오른쪽에 있으면 왼쪽 바퀴를 더 빠르게 돌려 오른쪽으로 향하게 하고, 라인이 왼쪽에 있으면 오른쪽 바퀴를 더 빠르게 돌려 왼쪽으로 향하게 한다.

이 구조를 이해하면 라인트레이서뿐 아니라 벽 따라가기, 목표물 추적, 색상 추적 주행 같은 프로젝트도 훨씬 쉽게 이해할 수 있다. 센서가 바뀌어도 "오차를 계산하고, 오차에 비례해 출력을 조정한다"는 기본 흐름은 같다.

P 게인 튜닝하기

라인트레이서에서 가장 재미있고도 까다로운 부분은 튜닝이다. 코드가 맞아도 로봇이 바로 완벽하게 달리지는 않는다. 모터 출력, 바퀴 마찰, 센서 높이, 라인 폭, 트랙 곡률이 모두 다르기 때문이다.

먼저 baseSpeed를 낮게 잡고 Kp를 조정한다. 추천 순서는 다음과 같다.

  • baseSpeed를 80~120 정도로 낮게 설정한다.

  • Kp를 작은 값에서 시작해 조금씩 올린다.

  • 로봇이 곡선을 따라가지 못하면 Kp를 올린다.

  • 로봇이 좌우로 심하게 흔들리면 Kp를 낮춘다.

  • 직선은 잘 가지만 급커브에서 벗어나면 속도를 낮추거나 최대 보정량을 키운다.

예제 코드에서는 Kp = 0.08로 시작했다. 센서 위치값이 -2000부터 2000까지 나오므로, 오차가 1000일 때 보정량은 80이 된다. 기본 속도가 110이라면 왼쪽과 오른쪽 모터 속도 차이가 꽤 커진다. 작은 로봇에서는 이 정도만으로도 방향 전환이 충분할 수 있다.

만약 로봇이 라인을 따라가긴 하지만 계속 좌우로 흔들린다면 Kp가 너무 큰 것이다. 반대로 곡선에서 바깥쪽으로 밀려나며 늦게 반응한다면 Kp가 너무 작거나 속도가 너무 빠른 것이다. 라인트레이서 튜닝은 "더 세게 조향할 것인가, 더 천천히 달릴 것인가"의 균형을 찾는 과정이다.

좌우 모터가 반대로 움직일 때

라인트레이서 조립 후 가장 흔한 문제는 모터 방향이다. 코드는 전진 명령을 내렸는데 한쪽 바퀴가 뒤로 돌 수 있다. 이 경우 로봇은 제자리에서 빙글빙글 돌거나 이상한 방향으로 튄다.

먼저 센서 코드를 보기 전에 모터만 테스트하자. setMotor(100, 100)을 실행했을 때 두 바퀴가 모두 전진해야 한다. 한쪽이 반대로 돌면 그쪽 모터의 선 두 가닥을 바꾸거나, 코드에서 방향 핀 설정을 반대로 바꾸면 된다.

그다음 조향 방향을 확인한다. 로봇을 손으로 들고 센서를 라인 오른쪽에 가져갔을 때, 왼쪽 바퀴가 더 빨라지고 오른쪽 바퀴가 느려져야 한다. 라인이 오른쪽에 있으면 로봇도 오른쪽으로 돌아야 하기 때문이다. 만약 반대로 반응한다면 leftSpeed와 rightSpeed의 보정 부호를 바꾸면 된다.

// 현재 코드
int leftSpeed = baseSpeed + correction;
int rightSpeed = baseSpeed - correction;

// 반대로 조향한다면 이렇게 바꾼다
int leftSpeed = baseSpeed - correction;
int rightSpeed = baseSpeed + correction;

라인트레이서 디버깅에서는 "센서가 맞는가"와 "모터가 맞는가"를 나누어 확인하는 것이 중요하다. 둘을 동시에 의심하면 어디가 문제인지 알기 어렵다.

급커브에서 벗어나지 않게 하기

직선과 완만한 곡선은 쉽게 따라가도, 급커브에서는 로봇이 라인을 놓치기 쉽다. 급커브에서는 라인이 센서 배열의 한쪽 끝으로 빠르게 이동한다. 이때 기본 속도가 너무 빠르면 로봇이 방향을 틀기 전에 라인이 센서 밖으로 사라진다.

가장 쉬운 해결책은 오차가 커질수록 속도를 줄이는 것이다. 예제 코드에는 이미 간단한 속도 제한이 들어 있다.

int absError = abs(error);
if (absError > 1200) {
  leftSpeed = leftSpeed * 0.8;
  rightSpeed = rightSpeed * 0.8;
}

오차가 크다는 것은 라인이 중앙에서 많이 벗어났다는 뜻이다. 이때는 빨리 달리는 것보다 라인을 다시 중앙으로 가져오는 것이 우선이다. 속도를 조금 낮추면 바퀴가 미끄러지는 것도 줄고, 센서가 다음 상태를 읽을 시간도 벌 수 있다.

더 강한 회전이 필요하다면 안쪽 바퀴를 멈추거나 반대로 돌릴 수도 있다. 예를 들어 라인이 오른쪽 끝에 있으면 오른쪽 바퀴를 거의 멈추고 왼쪽 바퀴만 돌려 작은 반경으로 회전한다. 하지만 너무 자주 역회전을 사용하면 주행이 거칠어질 수 있으므로, 먼저 속도를 낮추고 Kp를 조정한 뒤 마지막 수단으로 사용하는 것이 좋다.

교차선과 넓은 검은 영역 처리하기

트랙에 따라 교차선이나 정지선이 있을 수 있다. 이때 모든 센서가 동시에 검은색을 읽는다. 일반 라인 추종 코드에서는 이런 상황을 단순히 "라인이 아주 넓다"고 판단한다. 직진하면 되는 트랙이라면 문제없지만, 교차로에서 좌회전이나 우회전을 해야 하는 미션이라면 별도의 처리가 필요하다.

교차선을 감지하는 쉬운 방법은 검은색으로 판단된 센서 개수를 세는 것이다. 정규화된 센서값이 600 이상이면 검은색이라고 가정해 보자. 5개 센서 중 4개 이상이 동시에 검은색이면 교차선이나 정지선일 가능성이 높다.

int countBlackSensors() {
  int count = 0;

  for (int i = 0; i < NUM_SENSORS; i++) {
    if (sensorValue[i] > 600) {
      count++;
    }
  }

  return count;
}

이 함수를 loop() 안에서 라인 위치를 읽은 뒤 사용할 수 있다.

int blackCount = countBlackSensors();

if (blackCount >= 4) {
  // 교차선 또는 정지선으로 판단
  // 일단 천천히 직진하거나, 미션에 따라 정지할 수 있다.
  setMotor(80, 80);
  delay(100);
  return;
}

교차선을 만났을 때 무엇을 할지는 트랙 규칙에 따라 다르다. 단순 주행 트랙에서는 잠깐 직진해 통과하면 된다. 미션형 트랙에서는 "첫 번째 교차로에서 좌회전, 두 번째 교차로에서 우회전"처럼 상태를 기억해야 한다. 이때는 교차로 개수를 세는 변수나, 미리 정해 둔 경로 배열을 사용할 수 있다.

라인을 잃었을 때 복구하기

로봇이 라인을 완전히 놓치면 모든 센서값이 흰색에 가까워진다. 이때 무작정 직진하면 더 멀리 벗어난다. 라인을 잃었을 때는 마지막으로 라인이 있던 방향을 기억해 그쪽으로 천천히 회전하는 것이 좋다.

예제 코드의 recoverLine() 함수가 그 역할을 한다.

void recoverLine() {
  if (lastError >= 0) {
    setMotor(lostLineSpeed, -lostLineSpeed);
  } else {
    setMotor(-lostLineSpeed, lostLineSpeed);
  }
}

마지막 오차가 양수였다면 라인이 오른쪽에 있었다는 뜻이다. 그러면 오른쪽으로 돌며 다시 라인을 찾는다. 마지막 오차가 음수였다면 왼쪽으로 돌며 찾는다.

라인 복구 동작은 로봇을 훨씬 끈질기게 만든다. 완벽하게 주행하지 못하더라도, 잠깐 라인을 잃은 뒤 다시 찾아 돌아올 수 있다. 실제 로봇에서는 이런 예외 처리 하나가 체감 성능을 크게 바꾼다.

수동 캘리브레이션 방식

자동 캘리브레이션은 편리하지만, 매번 전원을 켤 때 로봇을 손으로 흔들어야 한다. 대회용이나 고정된 트랙에서 사용할 때는 수동 기준값을 코드에 넣는 방식도 쓸 수 있다.

먼저 센서값을 시리얼 모니터에 출력해 흰색 바닥과 검은색 라인에서의 값을 확인한다. 예를 들어 센서가 흰색에서 850 근처, 검은색에서 250 근처를 읽는다면 중간값인 550 정도를 임계값으로 사용할 수 있다.

디지털 방식으로 단순화하면 다음처럼 쓸 수 있다.

int raw = analogRead(A0);

if (raw < 550) {
  // 검은색 라인
} else {
  // 흰색 바닥
}

하지만 라인트레이서에서는 가능하면 자동 캘리브레이션과 정규화 방식을 추천한다. 센서마다 값이 다르고, 왼쪽 끝 센서와 오른쪽 끝 센서의 높이가 미세하게 달라도 출력이 달라질 수 있기 때문이다. 자동 캘리브레이션은 이런 차이를 어느 정도 보정해 준다.

디지털 라인 센서로 만들 때

일부 라인 센서 모듈은 가변저항으로 임계값을 조정하고, 출력으로 HIGH 또는 LOW만 제공한다. 이런 센서는 배선과 코드가 단순하지만, 라인의 위치를 부드럽게 계산하기는 어렵다.

그래도 5채널 디지털 센서라면 가중 평균과 비슷한 방식으로 사용할 수 있다. 검은색을 감지한 센서에는 1을 주고, 감지하지 못한 센서에는 0을 준다.

int digitalValue[5] = {0, 0, 1, 1, 0};
int weight[5] = {-2000, -1000, 0, 1000, 2000};

long sum = 0;
long weightedSum = 0;

for (int i = 0; i < 5; i++) {
  sum += digitalValue[i];
  weightedSum += (long)digitalValue[i] * weight[i];
}

int position = weightedSum / sum;

다만 디지털 센서는 검은색과 흰색의 중간 상태를 표현하지 못한다. 라인이 센서 사이에 걸쳐 있을 때 아날로그 센서는 "조금 검다"를 표현할 수 있지만, 디지털 센서는 갑자기 0에서 1로 바뀐다. 그래서 고속 주행이나 부드러운 제어에는 아날로그 센서 배열이 더 유리하다.

속도를 올리고 싶다면

처음 주행에 성공하면 자연스럽게 더 빠르게 달리고 싶어진다. 하지만 속도를 올리면 지금까지 가려져 있던 문제가 드러난다. 센서가 라인을 읽고, 보드가 계산하고, 모터가 반응하는 사이에 로봇은 이미 앞으로 이동한다. 속도가 빠를수록 제어가 늦게 반응하는 것처럼 보인다.

속도를 올릴 때는 한 번에 크게 올리지 말고 조금씩 올려야 한다. baseSpeed를 10씩 올리면서 직선, 완만한 곡선, 급커브를 차례대로 테스트한다. 어느 순간부터 흔들림이 커지거나 커브에서 벗어난다면 그 지점이 현재 하드웨어와 제어기의 한계에 가깝다.

더 빠른 주행을 원한다면 다음 요소를 함께 개선해야 한다.

  • 센서를 더 앞쪽에 달아 라인을 미리 읽는다.

  • 바퀴 접지력을 높여 미끄러짐을 줄인다.

  • 로봇 무게중심을 낮춰 급회전에서 흔들림을 줄인다.

  • Kp를 다시 조정하고 필요하면 D 제어를 추가한다.

  • 급커브에서 자동으로 속도를 낮추는 조건을 넣는다.

특히 D 제어는 오차가 얼마나 빠르게 변하는지를 보고 반응하는 항이다. 라인트레이서가 좌우로 흔들릴 때 D 항을 적절히 넣으면 진동을 줄이는 데 도움이 된다. 다만 이번 장에서는 P 제어만으로 구조를 분명히 이해하는 것이 목표다. P 제어로 안정적인 저속 주행을 만든 뒤, 필요할 때 PD 또는 PID로 확장하면 된다.

흔한 문제와 해결 방법

라인트레이서가 제대로 움직이지 않을 때는 증상을 나누어 보면 해결이 쉬워진다.

증상가능한 원인확인 방법
로봇이 전혀 움직이지 않는다모터 전원, 드라이버 배선, GND 연결 문제모터 단독 테스트부터 실행한다
한쪽 바퀴만 돈다모터 선, 드라이버 채널, PWM 핀 문제좌우 모터 핀을 바꿔 보며 원인을 좁힌다
라인을 보면 반대로 돈다오차 부호 또는 모터 보정 방향이 반대leftSpeed, rightSpeed 보정 부호를 바꾼다
직선에서도 심하게 흔들린다Kp가 너무 큼, 속도가 너무 빠름Kp와 baseSpeed를 낮춘다
커브에서 라인을 놓친다Kp가 작음, 센서가 너무 뒤에 있음, 속도가 빠름센서를 앞쪽으로 옮기고 속도를 낮춘다
센서값이 계속 이상하다캘리브레이션 실패, 센서 높이 문제, 조명 간섭시리얼 모니터로 센서값을 확인한다

문제가 생기면 코드를 계속 바꾸기 전에 반드시 센서값을 시리얼 모니터로 확인하자. 검은색 라인 위에서 값이 커지는지, 흰색 바닥에서 작아지는지, 각 센서가 비슷한 범위로 반응하는지 확인해야 한다. 센서값이 제대로 들어오지 않으면 아무리 제어 코드를 고쳐도 로봇은 안정적으로 달릴 수 없다.

더 똑똑한 라인트레이서로 확장하기

이번 장의 로봇은 P 제어만 사용했지만, 이미 꽤 그럴듯하게 라인을 따라갈 수 있다. 여기서 조금씩 기능을 추가하면 대회용 라인트레이서에 가까워진다.

가장 먼저 시도할 만한 확장은 교차로 처리다. 센서 여러 개가 동시에 검은색을 읽으면 교차로로 판단하고, 미리 정한 방향으로 회전하게 만들 수 있다. 예를 들어 L, S, R 같은 문자 배열을 만들어 왼쪽, 직진, 오른쪽 순서로 움직이게 할 수 있다.

다음은 속도 프로파일이다. 직선에서는 빠르게 달리고, 커브에서는 자동으로 속도를 낮추는 방식이다. 오차가 작고 일정하면 직선으로 보고 속도를 올린다. 오차가 커지거나 빠르게 변하면 커브로 보고 속도를 낮춘다. 이렇게 하면 단순히 baseSpeed 하나로 달릴 때보다 훨씬 빠르고 안정적인 주행이 가능하다.

마지막으로 엔코더를 결합할 수 있다. 라인트레이서에서는 좌우 모터에 같은 PWM을 줘도 실제 속도는 다를 수 있다. 엔코더로 바퀴 속도를 측정해 좌우 속도를 맞추면 직선 안정성이 좋아진다. 센서 배열은 "어디로 가야 하는가"를 알려 주고, 엔코더는 "바퀴가 실제로 얼마나 돌고 있는가"를 알려 준다. 두 정보를 함께 쓰면 로봇은 훨씬 믿음직해진다.

이번 장에서 만든 것

이번 장에서는 라인 센서 배열과 좌우 모터 제어를 결합해 라인트레이서 로봇을 만들었다. 센서 하나로 검은색과 흰색을 구분하는 수준을 넘어, 여러 센서값을 가중 평균해 라인의 위치를 숫자 하나로 계산했다. 그리고 그 위치 오차에 비례해 좌우 모터 속도를 다르게 주는 P 제어 주행을 구현했다.

라인트레이서는 작지만 제어의 핵심을 품고 있다. 목표는 라인을 중앙에 두는 것이고, 센서는 현재 라인 위치를 알려 주며, 제어기는 오차를 줄이기 위해 모터 출력을 조정한다. 이것은 더 큰 로봇에서도 반복되는 기본 구조다.

다음 프로젝트에서는 로봇에 카메라를 달아 보자. 라인 센서가 바닥 바로 아래의 명암만 읽었다면, 카메라는 더 넓은 장면을 본다. 색상을 추적하고, 영상을 스트리밍하고, 라즈베리파이와 마이크로컨트롤러가 역할을 나누어 움직이는 로봇으로 확장해 나갈 수 있다.

책 목록

센서와 모터로 시작하는 실전 로봇 만들기

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기