[42호]실내 위치 인식용 마커의 무결성 검사 로봇
2016 ICT 융합 프로젝트 공모전 우수상
실내 위치 인식용 마커의 무결성 검사 로봇
글 | 한국과학기술원 김규광, 최덕규, 임휘준
심사평
뉴티씨 실내 위치 인식 등에 관한 여러가지 연구들이 그동안 많이 진척되었는데, 그 중 한가지 아이템에 집중하여 결과를 낸 부분이 인상적이다. 실제로 로봇을 제작하여, NFC와 QR 코드를 인식하고, 자신의 위치를 보정하는 등 현재 위치를 확인하고, 이를 통하여 로봇의 위치 인식으로 물류 시스템 등에서 사용할 수 있도록 하려고 노력한 점이 돋보인다. 다만, 실제 상황에서의 고려 사항인 안전, 전원문제, 기구적인 토크 문제, 거리 문제 등은 고려되지 않은 점이 아쉽다. 실질적인 연구로 이어질 수 있을 것 같아서 좋은 점수를 주었다.
칩센 이미 구현된 기술에서 새로운 실용적인 아이디어를 도출했다, 실제 완성도를 조금만 더 높일 수 있고 마커를 활용한 물류 로봇들과 운용에서 문제를 없앨 수 있다면 상품화에 도전해도 될 만큼 좋은 아이디어 같다.
위드로봇 이동 로봇의 자기 위치 추정 문제는 꽤 오랫동안 로봇공학자들이 풀려고 고생하고 있는 난제입니다. 해당 작품은 QR 코드와 RFID 및 엔코더를 누적한 DR로 제한된 환경 내에서 위치 추정 문제를 풀려고 시도한 작품입니다. 아쉬운 부분은 융합 알고리즘을 너무 단순화하여, 전체 시스템의 성능이 더 나은 결과를 낼 수 있음에도 불구하고 그만큼 나오지 않는 부분입니다. 그리고 부수적이긴 하지만 QR 코드는 코드 자체적으로 오염에 의한 손상 문제에 대해 값을 복원할 수 있는 오류 정정 코드를 삽입할 수 있는 기능이 있습니다. 이러한 부분도 같이 고려되면 완성도가 훨씬 높아질 것 같습니다. 실제 로봇을 만들어 아이디어를 확인하는 과정이 쉽지 않는데, 결과를 도출해 낸 점에 박수를 보냅니다.
작품 개요
다양한 목적에 로봇을 적용하려는 시도가 늘어나고 있으며 실제로 물류 창고 등에서 로봇이 사용되기도 하는 등 실내 환경에서의 로봇의 임무 수행을 위한 위치 인식과 추정에 대한 관심이 증가하고 있다. 간단한 Monte Carlo Localization에서부터 레이저 스캐너, 카메라 등을 이용한 SLAM 기술, 전파 신호의 삼각 측량이나 fingerprinting을 이용한 위치 측위 등 다양한 기술이 개발되었으나 센서의 가격으로 인한 다수의 로봇 제작 비용의 증가, 전파 신호 측정값의 불안전성 등 여러 이유로 인해 일정 간격으로 마커를 부착하고 이를 로봇이 인식해 엔코더 정보 등과 조합 함으로서 위치를 인식하는 방식 또한 꾸준히 사용되어 왔다. 특히 마커 설치에 대한 미관상의 부담이 없으며 다수의 로봇을 안정적이고 효율적으로 운영해야 하는 물류 창고에 보통 적용되며 아마존 사의 KIVA 로봇의 경우 이러한 부착식 마커 시스템을 사용하는 대표적인 성공 사례이다. 하지만 이러한 부착식 마커의 경우 로봇 주행 및 기타 원인에 의한 물리적인 충격이나 먼지 등의 오염물질에 의해 손상될 수 있다. 대형 물류 창고의 경우 이러한 손상을 사람이 일일이 검사한다는 것은 쉬운 일이 아니며 발생할 경우 물류 체계에 비효율성을 불러올 수 있다. 본 프로젝트에서는 차륜 형태의 실내에서 주행 가능하고 QR 코드 마커를 인식해 로봇의 엔코더/센서를 이용한 위치와 비교하여 해당 위치에 설치된 QR 코드가 정상 상태인지를 확인하는 로봇을 개발하고자 한다. 또한 RFID/NFC 태그를 QR 코드와 같이 사용하여 QR 코드 손상 시에도 위치 인식을 도와줄 수 있는 시스템을 제안하고자 한다.
작품 설명
주요 동작 및 특징
Differential Wheel 형태의 차량형 주행 로봇이며 실내 주행을 돕기 위해 Wi-Fi를 이용하여 카메라 영상과 엔코더, 나침반 센서의 데이터를 전송하며 웹기반 조종 인터페이스를 제공한다. 로봇에는 스마트폰이 장착되어 있어 이를 이용하여 QR 코드와 NFC 태그를 인식한다.
전체 시스템 구성
로봇의 주 제어장치는 라즈베리파이 보드를 사용하였으며 센서와 하드웨어 인터페이스를 위해 아두이노 보드를 부착하였다. 로봇 프레임에 장착된 서보모터를 사용해 문턱 등의 장애물을 손쉽게 넘어갈 수 있으며 모터에 부착된 엔코더와 지자기 센서를 사용해 현재 위치를 계산한다. 지자기센서는 모터와 회로 등의 자기장에 영향을 받지 않도록 앞으로 길게 빼서 설치하였으며 제작 후에는 360도 회전시켜 heading 각을 계산하기 전 보정을 해 주었다. 아두이노는 센서 데이터를 모아 라즈베리파이 보드로 전달하며 모터 제어 명령이 내려올 경우 해당하는 모터를 움직인다. (그림 1.)
그림 1 |
라즈베리파이에서는 시리얼 통신을 통해 아두이노와 통신하며 제어용 웹 서버를 구동한다. 통상적으로 블루투스나 RC 수신기를 이용한 1대 1통신을 사용하며 많은 DIY프로젝트에서는 안드로이드 어플리케이션을 조종 인터페이스로 사용하나 본 프로젝트에서는 web 인터페이스를 사용해보고자 하였다. Wi-Fi를 통해 연결하므로 영상 전송 등에 필요한 넓은 대역폭과 빠른 속도가 보장되며 소켓 핸들을 계속 관리해 줘야 하는 TCP/IP통신과는 다르게 비동기식 통신인 request 방식을 사용해 일시적인 연결 장애에도 더 강인한 특징이 있다. 또한 안드로이드에서만 구동 가능한 앱과는 달리 웹 브라우저가 구동 가능하고 같은 ip대역 내에만 들어 있으면 접속하는 장비의 기종에 상관 없이 사용이 가능하다는 장점을 갖고 있다. (그림 2.)
그림 2 |
또한 jQuery Mobile을 사용하여 웹에서 폰의 앱과 비슷한 UI를 구현하였다. 조종 버튼을 누르거나 슬라이드가 움직이면 javascript 코드에 의해 POST request가 웹서버로 보내지며 서버는 수신받은 값을 아두이노로 보내 사용자의 요청에 맞춰 구동한다. (그림 3,4)
그림 3 |
그림 4 |
통상의 리니어 5V 출력 레귤레이터로는 라즈베리파이와 서보, 구동용 모터 등에 전력을 공급하기 힘들어 직접 전원 모듈을 제작하였다. 주 전력인 DC 5V는 스위칭 레귤레이터를 사용하여 큰 발열 없이 수 암페어 단위의 출력을 낼 수 있도록 하였으며 코일이나 커패시터 등의 회로 소자도 해당 전류를 견딜 수 있는 소자를 사용하였다. 지자기센서에 사용되는 3.3V는 많은 전력을 소비하지 않아 회로 단순화를 위해 리니어 레귤레이터를 사용하였다. 11.1V 리튬 폴리머 배터리를 사용하여 시스템 구동에 필요한 충분한 전력을 넣어주었다. (그림 5.)
그림 5 |
안드로이드 스마트폰을 부착시키기 위해 홀더를 직접 설계하였으며 QR 코드를 읽을 적절한 초점거리를 벌리면서도 NFC 태그를 읽을 수 있는 거리를 맞추기 위해 높이 조절이 가능하도록 3D 프린터의 히트베드 위치를 조절하는데 쓰이는 것과 같은 구조의 스프링과 나사를 같이 사용하였다. 설계한 홀더는 3D 프린터를 사용하여 제작하였으며 로봇에 부착하였다. (그림 6, 7) QR 코드와 NFC 태그를 읽을 수 있는 앱 또한 개발하였다 (그림 8).
그림 6 |
그림 7 |
그림 8 |
위치 인식은 엔코더 데이터와 지자기센서를 사용하여 진행하였으며 QR 코드에 비해 손상되기가 힘들고 인식이 편리한 NFC 태그를 우선하여 위치 보정을 하였다. NFC 태그에는 QR 코드를 부착하였으며 QR 코드와 동일한 데이터를 NFC 태그에 입력하였다. (그림 9)
그림 9 |
꺾어진 모양의 주행 코스를 선정하고 가운데 점과 양 꼭지점에 태그를 붙여 총 9장의 태그를 설치하였다. (그림 10)
그림 10 |
주행 코스를 따라 로봇을 조종하여 이동하였으며 NFC 태그 인식 데이터와 모터 입력값, 엔코더 제어값을 모두 저장하여 off line 분석에 사용하였다. 위치 보정에는 EKF SLAM, RFID SLAM 등의 사용을 고려했으나 SLAM 특유의 연산량과 marker의 위치를 모두 연산에 포함해야 하는 문제점(EKF SLAM의 경우) 등이 있어 좀 더 간단한 방식으로 보정계산을 진행하였다. NFC 태그가 인식될 경우 로봇의 현재 좌표를 기존의 이동 궤적을 모두 무시하고 NFC 태그 정중앙으로 이동시켜 엔코더를 사용함으로써 생기는 문제 중 하나인 오차 누적에 의한 drift를 해결하고자 하였다. 아래 그림의 differential wheel 로봇 모델을 사용하여 odometry를 우선적으로 계산하였다. (그림 11)
그림 11 |
로봇이 바라보는 방향(Heading)의 경우 엔코더를 사용하지 않고 지자기센서를 사용한 자북극 기준 heading 방향을 사용하였다. 실제 이동한 위치를 좌표평면으로 표현하여 의도된 주행 궤적대로 위치 추정 결과가 나오는지를 확인하였다. 수집된 데이터는 Python 언어와 matplotlib 라이브러리를 사용하여 그림 12에 시각화하였다.
그림 12 |
파란 선이 Ground Truth, 노란 점이 NFC 태그의 위치이며 녹색 선이 엔코더로만 계산한 궤적, 붉은 선이 NFC 태그를 이용해 보정한 이동 궤적이다. 엔코더만 사용하여 이동 궤적을 계산한 경우 엔코더의 drift 오차, 좌우 엔코더의 인식률 차이, 나침반의 heading 오류 등에 의해 한번의 이동 궤적 오류가 전체적인 이동 궤적에 누적된 오차를 주는 drift 를 볼 수 있다. 반면 NFC 태그를 이용하여 NFC 태그를 밟은 경우 바로 위치를 NFC 인식 지점으로 옮겨버리는 경우 이러한 기존의 오차의 영향을 받지 않고 ground truth에 맞는 위치로 옮겨감으로서 위치가 보정되는 것을 볼 수 있다. 정확한 위치를 알아 냄으로서 QR 코드/NFC 태그의 위치 또한 알 수 있고 이를 이용하여 QR 코드나 NFC 태그가 손상되더라도 상호 검증을 통해 손상된 marker를 알려줄 수 있다.
본 프로젝트에서는 로봇의 실내 위치 인식에 사용되는 로봇의 marker의 정상 여부를 검증하기 위해 QR 코드(혹은 apriltag 등의 visual 마커) 와 NFC/RFID 태그를 동시에 사용하여 한 쪽이 손상되더라도 다른 마커를 사용하여 오류를 검사하는 방식을 제안하였으며 이 임무를 수행할 수 있는 로봇을 제작하였다. 제작된 로봇은 엔코더와 나침반을 사용해 자신의 이동 위치를 알 수 있으며 안드로이드 폰을 사용하여 NFC와 QR 코드를 인식할 수 있다. 마커 인식을 통해 자신의 위치를 보정할 수 있으며 이를 이용해 “다른 마커가 인식되어야 할 자리에 QR코드/NFC 중 하나만 인식되거나 둘 다 인식되지 않는 경우 이를 사용자에게 알려 줌으로서 대형 물류창고 등에서 로봇의 위치인식을 이용한 process가 원할하게 구동되도록 도와줄 수 있을 것이라 기대된다.
또한 실내 위치 인식의 정확도를 올리기 위해 Wi-Fi, Zigbee 등의 무선 비콘 및 여러 방식이 사용되고 fingerprinting 방식 등 모든 구역에서의 전파 신호 세기를 수집하여 기계학습을 이용해 실내 위치를 인식하려는 시도 또한 사용되고 있다. 이때 사람이 직접 이러한 신호를 수집하는 것은 매우 불편하며 이미 QR코드 등의 기존 마커가 사용될 경우 이 로봇을 이용하여 각 위치 별 전파 신호 수집 등을 수월하게 진행하는 등의 추가적인 활용 또한 기대된다.
개발 환경
아두이노와 아두이노 IDE를 사용하였으며 라즈베리파이 보드에 전용 Debian 리눅스를 올려 사용하였으며 vim 에디터 개발 환경에서 python을 사용하여 프로그램하였다. 인터페이스 웹서버는 python의 BaseHTTPServer 모듈을 사용하여 구동하였으며 HTML과 jQuery Mobile을 사용하여 웹 인터페이스를 제작하였다. 영상 전송에는 OpenCV를 사용하였다.
기타 (회로도/소스코드)
아두이노 소스코드
#include <Servo.h>
#include <Wire.h>
#include <HMC5883L.h>
#include <string.h>
#define MAX_LEN 30
char cmd_array[MAX_LEN];
Servo servo_arm;
HMC5883L compass;
int error = 0;
int rignt_motor_A = 10;
int rignt_motor_B = 11;
int rignt_motor_power = 6;
volatile int right_wheel_count = 0;
int left_motor_A = 7;
int left_motor_B = 8;
int left_motor_power = 5;
volatile int left_wheel_count = 0;
int servo_arm_port = 9;
void setup()
{
Serial.begin(9600);
Wire.begin();
compass = HMC5883L();
error = compass.SetScale(1.3); //Set compass scale
if(error != 0) { Serial.println(“Compass ERR 1″);}
error = compass.SetMeasurementMode(Measurement_Continuous); //Set cont. measure mode
if(error != 0) { Serial.println(“Compass ERR 2″);}
attachInterrupt(0, right_wheel_encoder, CHANGE); // Right Wheel Encoder
attachInterrupt(1, left_wheel_encoder, CHANGE); // Left Wheel Encoder
servo_arm.attach(servo_arm_port);
servo_arm.write(140); //180 (max down) 140 (midpoint) 0(original)
pinMode(rignt_motor_A,OUTPUT);
pinMode(rignt_motor_B,OUTPUT);
pinMode(rignt_motor_power,OUTPUT);
pinMode(left_motor_A,OUTPUT);
pinMode(left_motor_B,OUTPUT);
pinMode(left_motor_power,OUTPUT);
}
void set_servo_angle(int angle)
{
servo_arm.write(angle);
delay(15);
}
void set_motor_value(int right_dir, int left_dir, int right_speed, int left_speed, int delay_time)
{
if(right_dir == 1) //front
{
digitalWrite(rignt_motor_A,HIGH);
digitalWrite(rignt_motor_B,LOW);
}
if(right_dir == 0) //back
{
digitalWrite(rignt_motor_A,LOW);
digitalWrite(rignt_motor_B,HIGH);
}
if(left_dir == 1) //front
{
digitalWrite(left_motor_A,HIGH);
digitalWrite(left_motor_B,LOW);
}
if(left_dir == 0) //back
{
digitalWrite(left_motor_A,LOW);
digitalWrite(left_motor_B,HIGH);
}
analogWrite(rignt_motor_power,right_speed);
analogWrite(left_motor_power,left_speed);
delay(delay_time);
digitalWrite(rignt_motor_power,0);
digitalWrite(left_motor_power,0);
}
float read_compass()
{
MagnetometerScaled scaled = compass.ReadScaledAxis();
int MilliGauss_OnThe_XAxis = scaled.XAxis;
float heading = atan2(scaled.YAxis+136, scaled.XAxis+40);
//float declinationAngle = 0.0457;
//heading += declinationAngle;
if(heading < 0) {heading += 2*PI;}
if(heading > 2*PI) {heading -= 2*PI;}
float deg_heading = ((heading * 180)/PI)-9;
if(deg_heading < 0) {deg_heading += 360;}
return deg_heading;
}
void right_wheel_encoder() {right_wheel_count++;}
void left_wheel_encoder() {left_wheel_count++;}
void send_all_sensor_val()
{
float heading = read_compass();
Serial.print(“r,”);
Serial.print(heading);
Serial.print(“,”);
Serial.print(right_wheel_count);
Serial.print(“,”);
Serial.print(left_wheel_count);
Serial.print(“,”);
MagnetometerRaw raw = compass.ReadRawAxis();
Serial.print(raw.XAxis);
Serial.print(“,”);
Serial.print(raw.YAxis);
Serial.print(“,”);
Serial.print(raw.ZAxis);
Serial.print(“,”);
Serial.println(“”);
right_wheel_count = 0;
left_wheel_count = 0;
}
void loop()
{
if(Serial.available()>0)
{
String cmd = Serial.readStringUntil(‘\n’);
//s,servo_angle,\n <- servo cmd
//s,140,\n
//m,right_fb,left_fb,right_pw,left_pw,time,\n <- move cmd
//m,1,0,125,125,500,\n
//r,0\n <- request cmd
char *ptr;
cmd.toCharArray(cmd_array,MAX_LEN);
char * header = strtok(cmd_array, “,”);
if(strcmp(header,”s”) == 0) //data request
{
int servo_val = atoi(strtok(NULL, “,”));
set_servo_angle(servo_val);
}
if(strcmp(header,”m”) == 0) //move command
{
int right_direction = atoi(strtok(NULL, “,”));
int left_direction = atoi(strtok(NULL, “,”));
int right_power = atoi(strtok(NULL, “,”));
int left_power = atoi(strtok(NULL, “,”));
int power_time = atoi(strtok(NULL, “,”));
set_motor_value(right_direction, left_direction, right_power, left_power, power_time);
}
if(strcmp(header,”r”) == 0) //data request
{
send_all_sensor_val();
}
}
delay(200);
}
#!/usr/bin/python
import BaseHTTPServer
import SimpleHTTPServer
import SocketServer
import json
import serial
STOP_CODE = 0
HEADING = 0
L_ENCODER = 0
R_ENCODER = 0
#com = serial.Serial(“/dev/ttyACM0″)
com = serial.Serial(“COM11″)
#LOG_FILE = open(“rover_log.txt”,’w')
class ThreadServer(SocketServer.ThreadingMixIn, BaseHTTPServer.HTTPServer):
pass
#
class MyRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
def do_GET(self):
return SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self)
#
def do_POST(self):
global STOP_CODE
global HEADING
global L_ENCODER
global R_ENCODER
global LOG_FILE
length = int(self.headers.getheader(‘content-length’))
var = self.rfile.read(length) #code=f&data=125&delay=500, code=s&data=57
packet = var.split(“&”)
cmd = packet[0].split(“=”)[1]
if cmd == “f”: #Front
header = “m,”
right_dir = “1,”
left_dir = “1,”
right_pw = packet[1].split(“=”)[1]
left_pw = packet[1].split(“=”)[1]
time_delay = packet[2].split(“=”)[1]
ser_cmd = header + right_dir + left_dir + right_pw + “,” + left_pw + “,” + time_delay + “,\n”
print ser_cmd
com.write(ser_cmd)
#”"”
com.write(‘r,0\n’)
raw_sensor_str = com.readline() #r,heading,rencoder,lencoder,mag_x,mag_y,mag_z,\n
#”"”
sensor_data = raw_sensor_str.split(“,”)
HEADING = sensor_data[1]
L_ENCODER = sensor_data[3]
R_ENCODER = sensor_data[2]
#LOG_FILE.write(ser_cmd)
#LOG_FILE.write(raw_sensor_str)
if cmd == “b”: #Back
header = “m,”
right_dir = “0,”
left_dir = “0,”
right_pw = packet[1].split(“=”)[1]
left_pw = packet[1].split(“=”)[1]
time_delay = packet[2].split(“=”)[1]
ser_cmd = header + right_dir + left_dir + right_pw + “,” + left_pw + “,” + time_delay + “,\n”
print ser_cmd
com.write(ser_cmd)
#”"”
com.write(‘r,0\n’)
raw_sensor_str = com.readline() #r,heading,rencoder,lencoder,mag_x,mag_y,mag_z,\n
#”"”
sensor_data = raw_sensor_str.split(“,”)
HEADING = sensor_data[1]
L_ENCODER = sensor_data[3]
R_ENCODER = sensor_data[2]
#LOG_FILE.write(ser_cmd)
#LOG_FILE.write(raw_sensor_str)
if cmd == “r”: #Right
header = “m,”
right_dir = “0,”
left_dir = “1,”
right_pw = packet[1].split(“=”)[1]
left_pw = packet[1].split(“=”)[1]
time_delay = packet[2].split(“=”)[1]
ser_cmd = header + right_dir + left_dir + right_pw + “,” + left_pw + “,” + time_delay + “,\n”
print ser_cmd
com.write(ser_cmd)
#”"”
com.write(‘r,0\n’)
raw_sensor_str = com.readline() #r,heading,rencoder,lencoder,mag_x,mag_y,mag_z,\n
#”"”
sensor_data = raw_sensor_str.split(“,”)
HEADING = sensor_data[1]
L_ENCODER = sensor_data[3]
R_ENCODER = sensor_data[2]
#LOG_FILE.write(ser_cmd)
#LOG_FILE.write(raw_sensor_str)
if cmd == “l”: #Left
header = “m,”
right_dir = “1,”
left_dir = “0,”
right_pw = packet[1].split(“=”)[1]
left_pw = packet[1].split(“=”)[1]
time_delay = packet[2].split(“=”)[1]
ser_cmd = header + right_dir + left_dir + right_pw + “,” + left_pw + “,” + time_delay + “,\n”
print ser_cmd
com.write(ser_cmd)
#”"”
com.write(‘r,0\n’)
raw_sensor_str = com.readline() #r,heading,rencoder,lencoder,mag_x,mag_y,mag_z,\n
#”"”
sensor_data = raw_sensor_str.split(“,”)
HEADING = sensor_data[1]
L_ENCODER = sensor_data[3]
R_ENCODER = sensor_data[2]
#LOG_FILE.write(ser_cmd)
#LOG_FILE.write(raw_sensor_str)
if cmd == “s”: #Servo
servo_angle = packet[1].split(“=”)[1]
header = “s,”
ser_cmd = header + servo_angle + “,\n”
print ser_cmd
com.write(ser_cmd)
sensor_data_dict = {‘heading’:HEADING,’l_encoder’:L_ENCODER,’r_encoder’:R_ENCODER}
self.send_response(200)
self.send_header(“Content-type”, “text/html”)
self.end_headers()
self.wfile.write(json.dumps(sensor_data_dict))
#
#
if __name__ == “__main__”:
Handler = MyRequestHandler
server = ThreadServer((’0.0.0.0′,8080), Handler)
while not STOP_CODE == 1:
server.handle_request()
#LOG_FILE.close()
server.server_close()