학습 목표

  • 카메라가 3차원의 무엇을 잃어버리는지 설명할 수 있다.
  • CameraInfo 의 값으로 픽셀을 광선으로 바꿀 수 있다.
  • 왜곡 보정 전후의 영상을 구분해 바르게 쓸 수 있다.
  • 깊이를 얻는 세 방법과 각각의 실패 조건을 안다.
  • 노출과 셔터 방식이 로봇에서 왜 문제가 되는지 설명할 수 있다.
  • Image Encoding·Step·CameraInfo·Optical Frame을 검증할 수 있다.
  • Camera Calibration과 Reprojection Error를 이용해 보정 품질을 평가할 수 있다.
  • ROS 2 Image Pipeline의 QoS·동기화·대역폭·지연을 측정하고 최적화할 수 있다.
  • 실제 Robot에서 조명·진동·오염·Frame Drop을 감시하고 안전하게 대응할 수 있다.

1. 카메라가 잃어버리는 것

카메라는 로봇 센서 중 정보량이 압도적으로 많습니다. 색, 무늬, 글자, 사람의 얼굴까지 봅니다. 라이다가 절대 못 보는 것들입니다.

그런데 카메라는 결정적인 것 하나를 잃어버립니다. 거리입니다.

3차원 세상을 2차원 판에 눌러 담는 과정에서 깊이가 사라집니다. 그래서 영상 속 한 점만 보고는 그것이 1 m 앞의 작은 컵인지 10 m 앞의 커다란 통인지 알 수 없습니다. 둘은 똑같이 찍힙니다.

이것이 카메라를 쓰는 모든 작업의 출발점입니다. "저기 사과가 있다"까지는 카메라가 잘합니다. "사과가 62 cm 앞에 있다"는 카메라 혼자서는 못 합니다.

그러면 픽셀에서 무엇을 얻을 수 있는가. 방향은 얻을 수 있습니다. "카메라 원점에서 이 방향으로 뻗은 광선 위 어딘가에 그 물체가 있다"까지는 말할 수 있습니다. 거리만 모르는 것입니다.

그래서 거리를 얻는 방법은 결국 셋 중 하나입니다. 두 눈으로 보거나(스테레오), 빛을 쏘아 되돌아오는 것을 재거나(구조광·ToF), 아는 크기를 이용하거나(물체의 실제 크기를 알면 화면 크기로 거리를 역산). 뒤에서 하나씩 봅니다.

앞 화면에서 배운 optical frame 이 여기서 쓰입니다. 픽셀에서 계산한 광선은 Z 가 앞, X 가 오른쪽, Y 가 아래인 좌표계 기준입니다. 로봇 좌표계로 옮기려면 그 회전을 거쳐야 합니다.

카메라라이다
색과 무늬와 글자를 본다거리를 정확히 잰다
거리를 모른다무엇인지 모른다
어두우면 못 본다어두워도 본다
해상도가 매우 높다점이 성기다
값이 싸다상대적으로 비싸다

카메라와 라이다는 잃어버리는 것이 정확히 반대입니다. 그래서 둘을 합치면 서로의 약점을 덮습니다.

주의 영상 하나로 거리를 추정하는 학습 기반 방법도 있습니다. 하지만 그것은 본 적 있는 장면과 비슷할 때만 맞습니다. 처음 보는 환경, 특이한 물체, 조명이 다른 곳에서는 크게 틀립니다. 안전이 걸린 판단에는 쓰지 마십시오.


2. 픽셀을 광선으로 — CameraInfo

"화면 좌표 (320, 240) 에 있는 물체는 어느 방향인가." 이 질문에 답하려면 카메라의 제원 네 개가 필요합니다.

초점 거리 두 개와 주점 두 개입니다. 초점 거리는 렌즈가 얼마나 좁게 보는가를 픽셀 단위로 나타낸 값이고, 주점은 광축이 화면의 어디를 지나는가입니다. 화면 정중앙에 가깝지만 정확히 중앙은 아닙니다.

이 값들이 CameraInfo 메시지에 담겨 옵니다. 영상 토픽 옆에 항상 짝으로 나오며, 이것이 없으면 픽셀로 아무 기하 계산도 할 수 없습니다.

픽셀에서 방향을 얻는 식은 간단합니다. 주점을 빼고 초점 거리로 나누면 됩니다. 그러면 Z 가 1 인 평면 위의 좌표가 나오고, 이것이 곧 광선의 방향입니다.

여기에 거리를 곱하면 3차원 위치가 나옵니다. 깊이 카메라라면 그 픽셀의 깊이값을 곱하면 되고, 바닥 위의 물체라면 카메라 높이와 각도로 거리를 계산할 수 있습니다.

초점 거리는 해상도에 따라 달라집니다. 같은 카메라라도 절반 해상도로 찍으면 초점 거리와 주점이 모두 절반이 됩니다. 해상도를 바꿔 놓고 예전 CameraInfo 를 쓰면 조용히 틀립니다. 이것이 앞 화면에서 말한 "조용히 틀림"의 전형적인 예입니다.

CameraInfo 항목 없거나 틀리면
fx, fy 초점 거리 (픽셀 단위) 거리와 각도가 모두 틀림
cx, cy 광축이 지나는 화면 위치 중심이 치우쳐 계산이 어긋남
왜곡 계수 렌즈 굽힘 정도 가장자리에서 크게 틀림
width, height 영상 크기 해상도가 다르면 전부 무효
frame_id 어느 좌표계 기준인가 결과가 엉뚱한 곳에 찍힘

화면 중앙 픽셀은 X 와 Y 가 0 이 되어 광축 위에 있음을 확인할 수 있습니다. 값이 이렇게 나오지 않으면 주점이 잘못된 것입니다.

# 픽셀을 실제 3차원 좌표로 바꾸기
#   CameraInfo 의 값과 깊이만 있으면 됩니다.

import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CameraInfo


class PixelToRay(Node):
    def __init__(self):
        super().__init__('pixel_to_ray')
        self.fx = self.fy = self.cx = self.cy = None

        self.create_subscription(
            CameraInfo, 'camera/camera_info',
            self.on_info, qos_profile_sensor_data)

    def on_info(self, msg):
        # K 는 3x3 행렬이 한 줄로 펼쳐진 것이다
        #   [ fx  0  cx ]
        #   [  0 fy  cy ]
        #   [  0  0   1 ]
        self.fx, self.fy = msg.k[0], msg.k[4]
        self.cx, self.cy = msg.k[2], msg.k[5]

        self.get_logger().info(
            f'해상도 {msg.width}x{msg.height}  '
            f'fx={self.fx:.1f} fy={self.fy:.1f} '
            f'cx={self.cx:.1f} cy={self.cy:.1f}')

        self.demo()

    def pixel_to_ray(self, u, v):
        # 주점을 빼고 초점 거리로 나누면 Z=1 평면의 좌표가 된다.
        # 이것이 optical frame 기준의 광선 방향이다.
        x = (u - self.cx) / self.fx
        y = (v - self.cy) / self.fy
        return x, y, 1.0

    def pixel_to_point(self, u, v, depth_m):
        # 광선에 깊이를 곱하면 3차원 위치가 나온다
        x, y, _ = self.pixel_to_ray(u, v)
        return x * depth_m, y * depth_m, depth_m

    def demo(self):
        for u, v, d in [(320, 240, 1.50), (500, 200, 2.30), (100, 400, 0.80)]:
            X, Y, Z = self.pixel_to_point(u, v, d)
            self.get_logger().info(
                f'픽셀({u},{v}) 깊이 {d:.2f} m -> '
                f'X={X:+.3f} Y={Y:+.3f} Z={Z:.3f} (optical frame)')


def main():
    rclpy.init()
    rclpy.spin(PixelToRay())

# 실행 결과 예 (640x480, fx=fy=525, cx=320, cy=240)
#   픽셀(320,240) 깊이 1.50 m -> X=+0.000 Y=+0.000 Z=1.500
#   픽셀(500,200) 깊이 2.30 m -> X=+0.788 Y=-0.175 Z=2.300
#   픽셀(100,400) 깊이 0.80 m -> X=-0.335 Y=+0.244 Z=0.800
#
# 결과는 optical frame 기준입니다. 로봇 기준으로 쓰려면
# 앞 화면에서 만든 camera_optical_frame 회전을 거쳐야 합니다.

주의 CameraInfo 는 영상 토픽과 같은 stamp 로 짝지어 옵니다. 한쪽만 오거나 시각이 어긋나면 동기화가 깨집니다. 그리고 frame_id 는 영상과 정확히 같아야 합니다. 다르면 계산 결과가 다른 좌표계로 해석됩니다.


3. 왜곡 — 직선이 휘어 보이는 이유

렌즈는 세상을 정직하게 담지 않습니다. 특히 넓게 보는 렌즈일수록 화면 가장자리에서 직선이 바깥으로 부풀거나 안으로 오므라듭니다.

앞 절의 계산식은 왜곡이 없다고 가정한 식입니다. 왜곡된 영상에 그 식을 그대로 쓰면 가장자리에서 크게 틀립니다. 중앙은 거의 맞으므로 한동안 모르고 쓰다가 나중에 발견하게 됩니다.

그래서 두 종류의 영상이 있습니다.

원본 영상 — 센서가 준 그대로. 왜곡이 있습니다.

보정 영상 — 왜곡을 펴서 직선이 직선으로 보이게 만든 것. 앞 절의 계산식은 이쪽에 써야 맞습니다.

보정에는 대가가 있습니다. 가장자리를 펴면서 화면 일부가 잘려 나가거나 검게 남습니다. 그리고 계산 비용이 듭니다.

언제 무엇을 쓸 것인가. 사람이 보기만 할 때는 원본으로 충분합니다. 거리나 각도를 계산할 때는 반드시 보정 영상을 써야 합니다. 물체를 검출해 그 위치를 3차원으로 옮기는 일이 여기 해당합니다.

왜곡 계수는 어디서 오는가. 체커보드 무늬를 여러 각도에서 찍어 계산합니다. 렌즈를 바꾸거나 초점을 만지면 다시 해야 합니다. 그리고 같은 모델의 카메라라도 개체마다 값이 다릅니다.

상황 어느 영상을 쓸 것인가 이유
사람이 화면으로 보기 원본 보정할 이유가 없음
물체 검출만 하기 원본도 무방 위치를 안 쓴다면
검출 위치를 3차원으로 보정 영상 필수 가장자리에서 크게 틀림
거리·각도 계산 보정 영상 필수 기하 계산의 전제
영상 저장·전송 원본 용량이 작고 원본이 보존됨

왜곡 계수가 전부 0 이라면 보정을 하지 않은 상태입니다. 화면 가장자리에서 계산을 한다면 반드시 보정부터 해야 합니다.

# ── 두 영상이 모두 있는지 확인 ─────────────────────────
ros2 topic list | grep image
#   /camera/image_raw            <- 원본 (왜곡 있음)
#   /camera/image_rect_color     <- 보정 영상 (없을 수도 있음)
#   /camera/camera_info

# ── 보정 영상이 없다면 보정 노드를 띄운다 ───────────────
#   image_proc 계열 노드가 camera_info 를 읽어 만들어 줍니다.

# ── 왜곡 계수가 채워져 있는지 본다 ─────────────────────
ros2 topic echo /camera/camera_info --field d --once
#   [0.0, 0.0, 0.0, 0.0, 0.0]  <- 전부 0 이면 보정을 안 한 것
#   [-0.28, 0.09, 0.0007, -0.0002, 0.0]  <- 보정된 값

# ── 직접 눈으로 확인하는 법 ────────────────────────────
#   1) 곧은 문틀이나 책상 모서리를 화면 가장자리에 놓고 찍는다
#   2) 원본에서 그 직선이 휘어 보이는지 본다
#   3) 보정 영상에서 곧게 펴졌는지 본다
#   숫자를 읽는 것보다 이쪽이 빠르고 확실합니다.

# ── 해상도를 바꿨다면 반드시 다시 확인 ──────────────────
ros2 topic echo /camera/camera_info --field width --once
ros2 topic echo /camera/camera_info --field height --once
#   실제 영상 해상도와 다르면 모든 계산이 조용히 틀립니다.

주의 보정을 했는지 안 했는지는 중앙만 보면 알 수 없습니다. 중앙은 왜곡이 거의 없기 때문입니다. 반드시 화면 가장자리에 곧은 물체를 놓고 확인하십시오. 그리고 왜곡이 큰 광각 렌즈일수록 이 차이가 커집니다.


4. 깊이를 얻는 세 가지 방법

거리를 알아야 하니 어떻게든 깊이를 만들어야 합니다. 방법마다 실패하는 조건이 완전히 다릅니다. 이것을 아는 것이 이 절의 목적입니다.

① 스테레오 — 두 눈으로 본다.

떨어져 있는 두 카메라가 같은 점을 봅니다. 가까운 물체는 두 화면에서 많이 어긋나 보이고, 먼 물체는 조금 어긋납니다. 이 어긋난 정도로 거리를 계산합니다.

실패 조건이 명확합니다. 무늬가 없는 면에서는 두 화면의 같은 점을 짝지을 수 없어 실패합니다. 하얀 벽, 매끈한 바닥이 대표적입니다. 반복되는 무늬도 위험합니다. 타일이나 격자에서 엉뚱한 점끼리 짝지어집니다.

그리고 멀수록 급격히 부정확해집니다. 두 카메라 간격이 좁으면 먼 물체는 어긋남이 거의 없어 구분이 안 됩니다.

② 빛을 쏘아 재기 — 구조광과 ToF.

카메라가 직접 적외선 무늬를 쏘거나 빛의 왕복 시간을 잽니다. 무늬가 없는 면에서도 잘 됩니다. 스테레오의 약점을 정확히 메웁니다.

대신 다른 데서 실패합니다. 햇빛 아래에서는 적외선이 묻혀 버립니다. 검은 물체는 빛을 흡수해 되돌아오지 않고, 유리와 거울과 반짝이는 금속은 빛을 엉뚱한 데로 보냅니다. 이 네 가지가 실내 로봇에서 끊임없이 문제를 일으킵니다.

③ 아는 크기로 역산 — 단안.

물체의 실제 크기를 알면 화면에서의 크기로 거리를 계산할 수 있습니다. 바닥이 평평하다는 가정을 쓰면 바닥에 놓인 물체의 거리도 계산됩니다.

가정이 깨지면 즉시 틀립니다. 경사로, 계단, 크기가 다른 같은 물체에서 무너집니다.

방법 잘하는 것 실패하는 곳 실외에서
스테레오 무늬 있는 장면, 원거리 흰 벽, 반복 무늬, 어두움 잘 됨
구조광 가까운 거리, 무늬 없는 면 햇빛, 검은 물체, 유리 거의 못 씀
ToF 빠른 갱신, 무늬 없는 면 햇빛, 반사, 다중 반사 제한적
단안 + 가정 값이 싸고 단순 가정이 깨지는 모든 곳 가정에 따라
01깊이 영상이 들어옴
원본 그대로
020 과 NaN 인 픽셀을 걸러 냄 — 측정 실패
가장 흔한 실패
03유효 거리 범위 밖을 걸러 냄
범위 경계
04주변 픽셀과 크게 다른 값을 걸러 냄 — 튀는 점
반사로 인한 오측
05남은 픽셀만 3차원으로 변환

깊이 카메라의 값을 그대로 믿으면 안 됩니다. 실패한 픽셀을 걸러 내는 단계가 반드시 있어야 합니다.

주의 깊이 카메라에서 0 은 거리가 0 이라는 뜻이 아니라 대개 측정 실패라는 뜻입니다. 이것을 그대로 3차원으로 옮기면 카메라 원점에 물체 무더기가 생깁니다. 앞 화면에서 배운 대로 무의미한 값을 먼저 걸러 내야 합니다.


5. 노출과 셔터 — 움직이는 로봇의 문제

정지 사진에서는 문제가 안 되던 것들이 로봇에서는 큰 문제가 됩니다.

① 모션 블러 — 로봇이 움직이면 흐려집니다.

어두운 곳에서 카메라는 셔터를 오래 열어 빛을 모읍니다. 그 사이에 로봇이 움직이면 영상이 흐려집니다. 검출이 실패하고, 특징점이 사라지고, 위치 추정이 무너집니다.

해결은 셔터 시간을 짧게 하는 것인데, 그러면 어두워집니다. 밝기를 올리려면 감도를 올려야 하고 그러면 잡음이 늘어납니다. 흐림과 잡음 사이의 교환이며, 로봇에서는 대개 흐림이 더 나쁩니다.

② 자동 노출 — 밝기가 제멋대로 변합니다.

카메라가 알아서 밝기를 맞춰 줍니다. 사진에는 좋지만 로봇에는 나쁩니다. 창문을 지날 때 화면 전체가 어두워지고, 같은 물체가 조금 전과 다른 색으로 보입니다. 색으로 물체를 찾는 코드는 여기서 무너집니다.

그래서 로봇에서는 노출을 고정하는 경우가 많습니다. 조명이 일정한 실내라면 특히 그렇습니다.

③ 롤링 셔터 — 화면이 기울어 보입니다.

값싼 카메라는 화면을 위에서 아래로 한 줄씩 읽습니다. 그 사이에 로봇이 회전하면 위쪽과 아래쪽이 다른 시각의 장면이 됩니다. 곧은 기둥이 비스듬하게 찍히고, 빠르게 회전하면 물체가 휘어 보입니다.

전체를 동시에 읽는 방식이 이 문제가 없습니다. 빠르게 움직이거나 회전하는 로봇, 특히 시각으로 위치를 추정하는 로봇에서는 이것이 성능을 좌우합니다.

④ 대역폭 — 영상은 어마어마하게 큽니다.

가로 1280, 세로 720, 색 3바이트, 초당 30장이면 초당 약 83 MB, 즉 660 Mbps 가 넘습니다. 일반적인 무선으로는 절대 못 보냅니다.

그래서 압축해서 보내는 방식을 씁니다. ros2 topic list 에 압축 토픽이 함께 보이며, 무선 너머로 볼 때는 압축된 쪽을 구독해야 합니다.

설정 올리면 내리면 로봇에서의 권장
셔터 시간 밝아짐, 흐려짐 어두워짐, 선명해짐 흐림을 피해 짧게
감도 밝아짐, 잡음 증가 어두워짐, 깨끗함 흐림보다는 잡음이 나음
자동 노출 어디서나 적당히 보임 밝기가 일정함 가능하면 고정
프레임률 빠른 변화를 봄 대역폭 절약 용도에 맞게
해상도 멀리 있는 것을 봄 계산과 대역폭 절약 검출에 필요한 최소

대역폭은 반드시 실제로 재 보십시오. 계산으로 예상한 값과 실제가 다를 때가 많고, 무선 문제의 원인이 대개 여기 있습니다.

# ── 영상 대역폭을 실제로 재 본다 ───────────────────────
ros2 topic bw /camera/image_raw
#   average: 82.94 MB/s
#   무선으로는 불가능한 값입니다.

ros2 topic bw /camera/image_raw/compressed
#   average: 1.85 MB/s
#   압축하면 이 정도로 줄어듭니다.

# ── 압축 토픽이 있는지 확인 ────────────────────────────
ros2 topic list | grep image_raw
#   /camera/image_raw
#   /camera/image_raw/compressed
#   /camera/image_raw/compressedDepth

# ── 원격에서 볼 때는 압축된 쪽을 쓴다 ───────────────────
#   RViz2 의 Image 표시에서 Transport Hint 를 compressed 로 둡니다.

# ── 모션 블러를 눈으로 확인하는 법 ─────────────────────
#   1) 로봇을 평소 속도로 주행시키며 영상을 저장한다
#   2) 정지 상태의 영상과 나란히 놓고 본다
#   3) 글자나 모서리가 뭉개져 있으면 셔터 시간이 긴 것입니다

# ── 자동 노출 때문인지 확인하는 법 ─────────────────────
#   같은 물체를 밝은 곳과 어두운 곳에서 찍어
#   화면 속 색값이 크게 달라지는지 봅니다.
#   달라진다면 색 기준 검출은 실패할 것입니다.

주의 압축 영상을 그대로 정밀 계산에 쓰지 마십시오. 압축은 눈에 잘 안 보이는 정보를 버리는 방식이라 미세한 무늬와 경계가 뭉개집니다. 사람이 보거나 대략적인 검출에는 충분하지만, 스테레오 매칭이나 정밀 계측에는 원본을 써야 합니다.


6. 카메라 문제를 좁히는 순서

카메라는 말썽이 많은 센서입니다. 하지만 원인은 몇 가지로 정해져 있습니다.

① 영상이 나오는가. hz 로 봅니다. 안 나오면 드라이버, USB 대역폭, 권한 순입니다. USB 카메라 여러 대를 같은 포트에 물리면 대역폭이 모자라 하나만 나옵니다.

② CameraInfo 가 함께 오는가. 없으면 기하 계산을 아예 할 수 없습니다.

③ 해상도가 CameraInfo 와 일치하는가. 해상도를 바꿔 놓고 보정값을 그대로 두는 것이 아주 흔한 실수입니다.

④ 좌표계가 맞는가. RViz2 에 띄워 봅니다. 90도씩 돌아가 보이면 optical frame 문제입니다.

⑤ 밝기가 일정한가. 자동 노출이 켜져 있으면 화면 밝기가 시시각각 변합니다.

⑥ 흐린가. 움직일 때만 흐리면 셔터 시간, 항상 흐리면 초점입니다.

⑦ 깊이가 있다면 유효한가. 0 과 NaN 이 얼마나 되는지 세 봅니다. 흰 벽과 유리 앞에서 특히 많이 나옵니다.

증상 원인 확인·대응
영상이 아예 없음 드라이버·USB 대역폭·권한 hz, 다른 포트에 연결
여러 대 중 하나만 나옴 USB 대역폭 부족 포트를 나누거나 해상도를 낮춤
거리 계산이 가장자리에서만 틀림 왜곡 보정 안 함 image_rect 를 쓸 것
모든 거리가 일정 비율로 틀림 해상도와 CameraInfo 불일치 width, height 대조
검출 결과가 90도 돌아감 optical frame 누락 tf2_echo 로 회전 확인
색 검출이 자꾸 실패 자동 노출 노출 고정
움직일 때만 흐림 셔터 시간이 김 셔터를 짧게, 감도를 올림
회전할 때 기둥이 기움 롤링 셔터 전역 셔터 카메라로
무선에서 영상이 끊김 대역폭 초과 압축 토픽을 구독
깊이가 0 인 곳이 많음 흰 벽·유리·검은 물체 0 을 걸러 내고 다른 센서 병용

실패 픽셀 비율은 카메라를 고를 때와 배치할 때의 가장 실용적인 지표입니다. 환경을 바꿔 가며 재 보십시오.

# ── 한 판 점검 ────────────────────────────────────────
ros2 topic hz /camera/image_raw
ros2 topic hz /camera/camera_info
ros2 topic bw /camera/image_raw

# 해상도가 서로 맞는지
ros2 topic echo /camera/camera_info --field width --once
ros2 topic echo /camera/image_raw --field width --once

# 좌표계 확인 (앞 화면 내용)
ros2 topic echo /camera/image_raw --field header.frame_id --once
ros2 run tf2_ros tf2_echo base_link camera_optical_frame

# ── 깊이 영상의 실패 픽셀 비율을 센다 ───────────────────
#   흰 벽 앞과 무늬 있는 벽 앞에서 각각 재 보면
#   이 카메라가 어디서 실패하는지 바로 드러납니다.
python3 - <<EOF
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Image
import numpy as np

class DepthStat(Node):
    def __init__(self):
        super().__init__('depth_stat')
        self.create_subscription(Image, '/camera/depth/image_raw',
                                 self.on_depth, qos_profile_sensor_data)

    def on_depth(self, msg):
        # 16비트 밀리미터 형식을 가정
        d = np.frombuffer(msg.data, dtype=np.uint16)
        total = d.size
        invalid = int((d == 0).sum())
        valid = d[d > 0]
        self.get_logger().info(
            f'실패 픽셀 {invalid*100.0/total:5.1f}%  '
            f'유효 거리 {valid.min()/1000:.2f}~{valid.max()/1000:.2f} m'
            if valid.size else '유효 픽셀이 없습니다')

rclpy.init(); rclpy.spin(DepthStat())
EOF

주의 카메라 문제의 상당수는 카메라가 아니라 조명 문제입니다. 역광, 그림자, 깜빡이는 형광등, 창문으로 들어오는 햇빛. 알고리즘을 고치기 전에 조명을 먼저 고치는 것이 훨씬 빠를 때가 많습니다.


7. Camera Hardware를 고르는 기준

Camera 선택은 해상도 하나로 끝나지 않습니다. Robot이 무엇을 보고 얼마나 빨리 움직이는지부터 정합니다.

항목 의미 Robot에서 묻는 질문
Resolution 가로×세로 Pixel 수 목표 물체가 최소 몇 Pixel이어야 하는가?
Frame Rate 초당 Frame 수 최고 속도에서 변화와 제어 주기를 따라가는가?
FOV 가로·세로 시야각 Blind Spot과 필요한 검출 거리를 만족하는가?
Focal Length 확대와 시야 범위 먼 물체 해상도와 주변 시야 중 무엇이 중요한가?
Pixel Size Pixel의 물리 크기 어두운 환경의 광량과 Noise 요구는?
Dynamic Range 밝고 어두운 부분 동시 표현 창문·헤드라이트·그림자를 함께 볼 수 있는가?
Shutter Global 또는 Rolling 빠른 이동·진동·회전이 있는가?
Interface USB·MIPI·GigE·GMSL 등 Cable 길이·대역폭·지연·Locking 요구는?
Trigger 외부 Hardware Trigger 지원 여러 Sensor를 정확히 같은 시각에 찍어야 하는가?
Environmental 온도·진동·방수·EMC 실제 현장 등급과 수명을 만족하는가?

렌즈 교환형 Camera는 Mount, Focus, Aperture와 Lens Distortion까지 System 일부입니다. 고해상도 Sensor에 해상력이 낮은 Lens를 달면 Pixel 수만 늘고 실제 세부 정보는 늘지 않습니다.


8. Image Message의 Encoding과 Memory Layout

sensor_msgs/msg/Image는 Pixel 배열뿐 아니라 해석에 필요한 Metadata를 전달합니다.

Field 확인 내용
height, width 실제 Frame 크기
encoding rgb8, bgr8, mono8, 16UC1, 32FC1
is_bigendian Multi-byte Pixel의 Byte Order
step 한 행의 Byte 수로, Padding을 포함할 수 있음
data 최소 step × height 크기의 Byte Buffer
header.stamp 가능한 실제 Exposure 또는 측정 기준 시각
header.frame_id Camera Optical Frame

width × channel이 항상 step과 같다고 가정하면 Padding이 있는 Driver에서 행이 틀어집니다. Depth Image도 Encoding에 따라 단위가 달라집니다. 16UC1 Driver가 mm를 쓰는 경우가 많고 32FC1은 m를 쓰는 경우가 흔하지만, Encoding 이름만으로 모든 Driver의 Invalid 값 정책까지 단정하지 말고 해당 Driver 계약을 확인합니다.

ros2 interface show sensor_msgs/msg/Image
ros2 topic echo /camera/image_raw --field encoding --once
ros2 topic echo /camera/image_raw --field step --once
ros2 topic echo /camera/image_raw --field header --once
from cv_bridge import CvBridge

self.bridge = CvBridge()

def on_image(self, msg):
    image = self.bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
    if image.shape[:2] != (msg.height, msg.width):
        self.get_logger().error('decoded image size mismatch')
        return

Zero-copy나 Loaned Message 지원은 Client Library·RMW·Message Type·Transport 조건에 따라 달라지므로 실제 Copy 횟수와 지연을 측정합니다.


9. CameraInfo의 K·D·R·P 행렬

CameraInfo에는 Calibration 결과와 영상 기하가 들어 있습니다.

  • K: Raw Camera Intrinsic Matrix
  • D: Distortion Model의 계수
  • R: Stereo Rectification Rotation
  • P: Rectified Image의 Projection Matrix
  • distortion_model: 계수를 해석할 Model 이름
  • roi, binning_x/y: Crop·Binning이 적용된 영상 정보
K = [fx  0 cx]
    [ 0 fy cy]
    [ 0  0  1]

u = fx·X/Z + cx
v = fy·Y/Z + cy

Raw Pixel에는 왜곡 Model을 먼저 적용해야 하고, Rectified Image에는 일반적으로 P를 사용합니다. Stereo 오른쪽 Camera의 P[3]에는 Baseline과 Focal Length 관계가 반영될 수 있습니다. 행렬을 눈으로 복사하지 말고 image_geometry 같은 검증된 Library를 우선합니다.

ros2 interface show sensor_msgs/msg/CameraInfo
ros2 topic echo /camera/camera_info --once

Image와 CameraInfo의 Width·Height, Stamp, frame_id, ROI와 Binning이 일치하는지 자동 검사합니다.


10. Camera Calibration 표준 절차

Calibration 품질은 사진 장수보다 Target이 시야와 자세 공간을 얼마나 잘 덮는지가 중요합니다.

  1. 정확한 크기의 Checkerboard·ChArUco 등 Target을 준비합니다.
  2. Focus·Resolution·Zoom·Binning과 Exposure를 운영 조건으로 고정합니다.
  3. Target을 중앙·가장자리·모서리, 가까이·멀리, 다양한 기울기로 촬영합니다.
  4. Blur·반사·Target 일부가 잘린 Frame을 제거합니다.
  5. Intrinsic과 Distortion을 추정합니다.
  6. Reprojection Error뿐 아니라 독립 검증 영상의 직선·거리·광선 오차를 확인합니다.
  7. Camera Serial, Lens, Focus, Resolution, 온도와 Calibration File을 함께 Version 관리합니다.
# 설치한 ROS 배포판과 camera_calibration의 실제 옵션을 먼저 확인
ros2 run camera_calibration cameracalibrator --help

# 예시: 8×6 내부 코너, 한 칸 25 mm
ros2 run camera_calibration cameracalibrator \
  --size 8x6 --square 0.025 \
  image:=/camera/image_raw camera:=/camera

평균 Reprojection Error 하나만 낮다고 모든 영역이 정확한 것은 아닙니다. 가장자리 Error Map, 여러 거리의 광선 오차와 재투영 잔차 분포를 봅니다. Lens Focus·Mount가 바뀌면 다시 Calibration합니다.


11. Optical Frame과 TF

ROS Camera Optical Frame은 일반 Robot Body Frame과 축 방향이 다릅니다.

Frame X Y Z
일반 Robot Frame 왼쪽
Camera Optical Frame 오른쪽 아래

URDF에서는 보통 camera_linkcamera_optical_frame을 나누고 고정 Rotation을 정의합니다. Image와 CameraInfo의 frame_id는 Optical Frame을 가리키는 것이 일반적입니다.

01Camera Body Frame
02REP-103 Optical Rotation
03Camera Optical Frame
04Pixel Ray·Depth Point
05측정 Stamp 시점의 TF2 변환
06base_link·odom·map에서 사용
ros2 topic echo /camera/image_raw --field header.frame_id --once
ros2 run tf2_ros tf2_echo base_link camera_optical_frame
ros2 run tf2_tools view_frames

카메라를 눈대중으로 장착한 Translation·Rotation은 먼 거리에서 큰 위치 오차를 만듭니다. Camera–Robot 또는 Camera–LiDAR Extrinsic을 실측하고 충돌·재장착 후 재검증합니다.


12. Exposure·Gain·White Balance를 측정한다

Auto Exposure는 장면 변화에 대응하지만 Frame마다 밝기와 Exposure Time이 달라져 Visual Odometry와 색 기반 인식에 영향을 줍니다. 고정 Exposure는 일관적이지만 환경 변화에 취약합니다.

운영 기준

  • Motion Blur 한계로 최대 Exposure Time을 먼저 정합니다.
  • 부족한 밝기는 Aperture·조명·Gain 순으로 Trade-off를 평가합니다.
  • LED·형광등 Flicker와 Frame Rate·Exposure의 Beat를 시험합니다.
  • Auto White Balance가 색 분류를 흔드는지 확인합니다.
  • HDR Mode의 합성 지연과 Moving Object Artifact를 측정합니다.
  • Parameter 변경이 실제 Driver와 Hardware에 반영됐는지 Image Histogram으로 확인합니다.
ros2 param list /camera
ros2 param describe /camera exposure
ros2 param get /camera exposure
ros2 param set /camera exposure 5000

Parameter 이름·단위·범위는 Driver마다 다릅니다. 실물에서 안전한 값과 되돌릴 이전값을 확인한 후 바꿉니다.


13. Stereo Depth의 기하와 오차

Rectified Stereo에서 수평 Disparity d와 Depth Z 관계는 다음과 같습니다.

Z = f·B / d
  • f: Pixel 단위 Focal Length
  • B: 두 Camera Optical Center 사이 Baseline(m)
  • d: 같은 점의 좌우 영상 Pixel 차이

멀어질수록 Disparity가 작아지고 1 Pixel 오차가 큰 Depth 오차가 됩니다. Baseline을 키우면 먼 거리 성능은 좋아지지만 가까운 물체의 두 시야가 겹치지 않는 영역이 커지고 장치가 커집니다.

def depth_from_disparity(f_px, baseline_m, disparity_px):
    if disparity_px <= 0.0:
        return math.nan
    return f_px * baseline_m / disparity_px

Stereo는 정확한 Intrinsic·Extrinsic·동시 Exposure·Rectification과 Texture가 필요합니다. 왼쪽과 오른쪽 Auto Exposure가 다르면 Matching 성능이 떨어질 수 있습니다.


14. Depth Camera Data를 안전하게 사용하기

Depth는 RGB와 Alignment, Encoding과 Invalid 정책을 함께 확인합니다.

def decode_depth(msg, bridge):
    depth = bridge.imgmsg_to_cv2(msg, desired_encoding='passthrough')
    if msg.encoding == '16UC1':
        depth_m = depth.astype('float32') * 0.001
        depth_m[depth == 0] = float('nan')
    elif msg.encoding == '32FC1':
        depth_m = depth.astype('float32')
    else:
        raise ValueError(f'unsupported depth encoding: {msg.encoding}')
    depth_m[~np.isfinite(depth_m)] = np.nan
    return depth_m

이 코드는 16UC1이 mm인 Driver 계약을 가정하므로 Package 문서에 가정을 명시합니다. Depth와 Color가 서로 다른 Optical Frame·해상도를 가질 수 있으며 “aligned depth” Topic은 추가 계산과 Crop을 포함할 수 있습니다.

검증 항목:

  • 최소·최대 유효 거리와 Invalid 비율
  • Edge 주변 Flying Pixel
  • 유리·거울·검은 물체·햇빛·다중 반사
  • Camera 온도 상승과 Bias 변화
  • RGB–Depth Stamp 차이와 Extrinsic
  • 원본 Depth와 PointCloud 생성 비용

15. image_transport와 대역폭 설계

Raw BGR8 대역폭의 근사값은 width × height × 3 × fps입니다. 1920×1080×3×30은 약 186.6 MB/s로 Protocol Overhead 전 값입니다.

방식 장점 손실·비용 권장 용도
Raw 정보 보존, Decode 불필요 대역폭 큼 같은 Host·정밀 Vision
JPEG 계열 RGB 영상 크게 압축 Lossy, Encode·Decode 지연 원격 Monitoring
PNG 계열 무손실 가능 Scene에 따라 크고 CPU 사용 Mask·Depth 조건 검토
H.264/H.265 연속 영상 효율 우수 GOP 지연·Frame 의존·Codec 복잡성 장거리 Video Stream
ros2 topic bw /camera/image_raw
ros2 topic hz /camera/image_raw
ros2 topic list | grep -E 'image|compressed'
ros2 run image_transport list_transports

정밀 Geometry·Stereo·Feature Tracking에는 원본 또는 검증된 무손실 경로를 사용합니다. Monitoring과 Recording은 목적에 맞는 Transport를 별도로 둡니다. 압축률만 보지 말고 End-to-end Latency, CPU, Frame Drop과 복구 시간을 측정합니다.


16. Image·CameraInfo·다중 Camera 시간 동기화

Image와 CameraInfo는 같은 Frame을 설명해야 합니다. Stereo와 RGB-D, Camera–IMU는 Hardware Trigger·Device Clock·Host Clock과 Driver Timestamp 정책이 중요합니다.

from message_filters import Subscriber, TimeSynchronizer
from sensor_msgs.msg import Image, CameraInfo

self.image_sub = Subscriber(self, Image, '/camera/image_raw', qos_profile=qos)
self.info_sub = Subscriber(self, CameraInfo, '/camera/camera_info', qos_profile=qos)
self.sync = TimeSynchronizer([self.image_sub, self.info_sub], queue_size=10)
self.sync.registerCallback(self.on_frame)

def on_frame(self, image, info):
    if image.header.frame_id != info.header.frame_id:
        self.get_logger().error('Image and CameraInfo frame mismatch')
        return

ExactTime으로 Pair가 전혀 생기지 않으면 Driver가 같은 Stamp를 주는 계약인지 먼저 확인합니다. ApproximateTime의 slop을 크게 해서 문제를 숨기지 말고 Robot 속도와 허용 Pixel·공간 오차로 허용 시간을 결정합니다.


17. ROS 2 Camera Monitor 예제

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Image, CameraInfo
from diagnostic_msgs.msg import DiagnosticArray, DiagnosticStatus, KeyValue

class CameraMonitor(Node):
    def __init__(self):
        super().__init__('camera_monitor')
        self.last_image_ns = None
        self.info = None
        self.frames = 0
        self.dropped_stamp = 0
        self.last_stamp_ns = None
        self.diag_pub = self.create_publisher(DiagnosticArray, '/diagnostics', 10)
        self.create_subscription(Image, '/camera/image_raw', self.on_image,
                                 qos_profile_sensor_data)
        self.create_subscription(CameraInfo, '/camera/camera_info', self.on_info,
                                 qos_profile_sensor_data)
        self.create_timer(1.0, self.report)

    @staticmethod
    def stamp_ns(stamp):
        return stamp.sec * 1_000_000_000 + stamp.nanosec

    def on_info(self, msg):
        self.info = msg

    def on_image(self, msg):
        now_ns = self.get_clock().now().nanoseconds
        stamp_ns = self.stamp_ns(msg.header.stamp)
        if self.last_stamp_ns is not None and stamp_ns <= self.last_stamp_ns:
            self.dropped_stamp += 1
        self.last_stamp_ns = stamp_ns
        self.last_image_ns = now_ns
        self.frames += 1

        expected_min = msg.step * msg.height
        if len(msg.data) < expected_min:
            self.get_logger().error('image buffer shorter than step × height')

    def report(self):
        status = DiagnosticStatus()
        status.name = 'front_camera_monitor'
        status.hardware_id = 'front_camera'
        age_ms = float('inf') if self.last_image_ns is None else (
            self.get_clock().now().nanoseconds - self.last_image_ns) / 1e6
        mismatch = self.info is None
        if self.info is not None and self.last_image_ns is not None:
            mismatch = self.info.width <= 0 or self.info.height <= 0
        status.level = DiagnosticStatus.ERROR if age_ms > 500.0 else (
            DiagnosticStatus.WARN if mismatch or self.dropped_stamp else DiagnosticStatus.OK)
        status.message = 'camera timeout' if age_ms > 500.0 else 'camera monitored'
        status.values = [
            KeyValue(key='frames_last_sec', value=str(self.frames)),
            KeyValue(key='age_ms', value=f'{age_ms:.1f}'),
            KeyValue(key='non_monotonic_stamps', value=str(self.dropped_stamp)),
        ]
        array = DiagnosticArray()
        array.header.stamp = self.get_clock().now().to_msg()
        array.status = [status]
        self.diag_pub.publish(array)
        self.frames = 0

def main():
    rclpy.init()
    node = CameraMonitor()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()

if __name__ == '__main__':
    main()

운영용 Monitor에는 Expected FPS 범위, Image–CameraInfo 일치, 밝기·Blur·Frozen Frame, Temperature와 Device 재연결 횟수를 추가합니다. 자동 재시작은 무한 Loop 대신 횟수 제한과 Fault 승격 정책을 둡니다.


18. 실제 Robot Camera 적용 절차

01요구 거리·물체 크기·속도·조명 정의
02Sensor·Lens·Shutter·Interface 선정
03책상에서 Resolution·FPS·Exposure·Bandwidth 측정
04Intrinsic Calibration과 독립 검증
05Robot 장착 후 Optical TF·Extrinsic 검증
06Image·CameraInfo·QoS·Timestamp 계약 확인
07Worst-case 조명·진동·오염·온도 시험
08Bag 회귀 시험·Diagnostics·Maintenance 운영

운영 전 Checklist

  • Camera Serial, Lens, Focus, Resolution과 Calibration File이 일치한다.
  • Image Encoding·Step·Depth 단위와 Invalid 정책을 문서화했다.
  • Image와 CameraInfo의 Stamp·Frame·크기가 일치한다.
  • Optical Frame과 Robot Extrinsic을 실제 Target으로 검증했다.
  • Motion Blur·Rolling Shutter·Flicker의 한계를 측정했다.
  • Raw·Compressed 경로의 Latency·CPU·대역폭을 측정했다.
  • Camera 단절·Frozen Frame·Stamp 역행·USB 재연결을 시험했다.
  • 유리·검은 물체·역광·어둠·Lens 오염을 시험했다.
  • Perception 실패 시 Robot이 안전 상태로 전환한다.
  • Calibration·Firmware·Parameter 변경과 Rollback을 Version 관리한다.

19. 확인 퀴즈 15문항

답을 선택한 뒤 정답 확인을 누르세요. 정답과 해설은 제출 후에 표시됩니다.

1. 카메라가 3차원 세상을 2차원에 담으면서 잃어버리는 것은?

2. 픽셀 하나에서 카메라가 알려 줄 수 있는 것은?

3. CameraInfo 가 없으면 할 수 없는 일은?

4. 픽셀을 광선 방향으로 바꾸는 계산은?

5. 카메라 해상도를 절반으로 낮췄는데 예전 CameraInfo 를 그대로 쓰면?

6. 왜곡 보정을 하지 않은 영상으로 기하 계산을 하면?

7. 검출한 물체의 위치를 3차원으로 옮길 때 써야 하는 영상은?

8. 스테레오 방식이 실패하는 대표적인 장면은?

9. 적외선을 쏘는 깊이 카메라가 실외에서 잘 안 되는 이유는?

10. 깊이 영상에서 값이 0 인 픽셀의 뜻은?

11. 어두운 곳에서 셔터를 오래 열면 로봇에서 생기는 문제는?

12. 자동 노출이 로봇에서 문제가 되는 이유는?

13. 롤링 셔터 카메라로 회전하는 로봇이 곧은 기둥을 찍으면?

14. 가로 1280 세로 720 컬러 영상을 초당 30장 보낼 때의 대역폭은?

15. 압축 영상을 정밀 계산에 쓰면 안 되는 이유는?


ROBOT GLOSSARY

용어 정리

전체 용어 찾아보기 →
Camera카메라
Lens를 통해 들어온 빛을 2차원 Pixel 배열의 Image Data로 변환하는 Sensor입니다.
Pixel화소
Digital Image를 구성하는 최소 표본 단위로 밝기나 색 Channel 값을 가집니다.
Focal Length초점 거리
Camera Projection에서 시야각과 확대 정도를 결정하며 CameraInfo에서는 주로 Pixel 단위 fx·fy로 표현됩니다.
Principal Point주점
Camera Optical Axis가 Image Plane과 만나는 Pixel 위치로 cx·cy로 표현됩니다.
Intrinsic카메라 내부 파라미터
Focal Length, Principal Point와 Lens Distortion처럼 Camera 내부 Projection Model을 정하는 값입니다.
Extrinsic카메라 외부 파라미터
Camera와 Robot 또는 다른 Sensor Frame 사이의 3차원 위치와 방향 변환입니다.
Distortion렌즈 왜곡
Lens 특성 때문에 이상적인 직선 Projection에서 Pixel 위치가 휘어 벗어나는 현상입니다.
Rectification영상 정류
Lens 왜곡을 펴고 Stereo의 대응점을 같은 Scanline에 놓도록 Image를 재투영하는 과정입니다.
CameraInfo카메라 기하 정보
Image 크기, Distortion Model과 K·D·R·P Calibration 값을 전달하는 ROS Message입니다.
Optical Frame카메라 광학 좌표계
X 오른쪽, Y 아래, Z 앞 방향을 사용하는 ROS Camera 좌표계입니다.
Encoding영상 인코딩 형식
rgb8, bgr8, mono8, 16UC1처럼 Pixel Channel·Bit Depth와 배열 해석 방식을 나타냅니다.
Step영상 행 바이트 간격
Padding을 포함한 Image 한 행의 Byte 수입니다.
Exposure노출
Image Sensor가 한 Frame의 빛을 모으는 시간과 밝기 조건입니다.
Gain영상 증폭도
Sensor Signal을 증폭해 밝게 만들지만 일반적으로 Noise도 함께 증가시키는 설정입니다.
Rolling Shutter순차 셔터
Image 행을 서로 다른 시각에 순서대로 읽어 빠른 움직임에서 기하 왜곡을 만드는 Shutter 방식입니다.
Global Shutter전역 셔터
모든 Pixel을 같은 Exposure 시점에 포착해 빠른 움직임의 Rolling Shutter 왜곡을 줄이는 방식입니다.
Stereo스테레오 비전
떨어진 두 Camera 영상의 대응점 Disparity와 Baseline으로 Depth를 계산하는 방식입니다.
Disparity시차
같은 3차원 점이 Rectified Stereo 좌우 Image에서 나타나는 수평 Pixel 위치 차이입니다.
Reprojection Error재투영 오차
추정한 Camera Model로 3차원 Target을 Image에 다시 투영한 위치와 실제 관측 Pixel의 차이입니다.
image_transport영상 전송 계층
ROS Image Topic을 Raw나 압축 방식으로 Publish·Subscribe하도록 Transport를 추상화하는 Package입니다.

연습 문제

  1. Robot의 목표 물체 크기·거리·속도로 필요한 해상도와 FPS 요구사항을 작성하세요.
  2. Image의 Encoding·Step·Data 길이를 검증하는 Node를 구현하세요.
  3. Image와 CameraInfo의 Stamp·Frame·Width·Height 일치 여부를 자동 검사하세요.
  4. CameraInfo의 K 값으로 다섯 Pixel의 Optical Ray를 계산하세요.
  5. Raw와 Rectified Image 가장자리의 직선 오차를 비교하세요.
  6. 독립 검증 영상으로 Reprojection Error 분포를 평가하세요.
  7. Camera Optical Frame을 base_link로 변환하고 RViz2에서 검증하세요.
  8. Exposure·Gain을 바꾸며 Blur·Noise·밝기 Trade-off를 측정하세요.
  9. Rolling Shutter와 Global Shutter의 회전 영상을 비교하세요.
  10. Stereo의 Baseline·Disparity 오차가 Depth에 미치는 영향을 계산하세요.
  11. Depth Image의 Invalid·범위 밖·Flying Pixel 비율을 측정하세요.
  12. Raw와 압축 Transport의 대역폭·CPU·End-to-end 지연을 비교하세요.
  13. Image와 IMU의 Timestamp 차이를 기록하고 허용값을 설계하세요.
  14. Camera 단절·Frozen Frame·Stamp 역행을 CameraMonitor에 주입하세요.
  15. 실제 Robot의 조명·오염·진동·온도·재Calibration 유지보수표를 작성하세요.

참고 자료