이 문서는 우리가 만든 프로그램이 어떻게 작동하는지 단계별로 설명합니다. 카메라에서 들어온 단일 스테레오 이미지가 화면에 3D 점으로 찍히기까지의 전체 여정을 추적해 보겠습니다.

🏛️ 핵심 아키텍처: 두 개의 스레드

가장 먼저, 우리 프로그램이 두 개의 병렬 스레드에서 실행된다는 점을 이해하는 것이 중요합니다. 이것이 바로 프로그램을 빠르게 만드는 핵심입니다. 하나의 스레드가 무거운 수학 연산을 처리하는 동안, 다른 스레드는 그림을 그립니다.

  1. VO 스레드 (VO Thread - "공장")
    • 파일: src/main.rs
    • 함수: run_live_vo_thread
    • 임무: 모든 힘든 작업을 수행합니다. 카메라와 통신하고, 비용이 많이 드는 OpenCV 함수(이미지 보정, 스테레오 계산, 특징점 검출)를 실행하며, 3D 포인트와 카메라 자세(Pose)를 계산합니다. 이 스레드의 유일한 목표는 "결과물"을 생산하는 것입니다.
  2. 메인 스레드 (Main Thread - "전시장")
    • 파일: src/main.rs
    • 함수: main
    • 임무: 모든 그리기를 수행합니다. OpenGL 창과 OpenCV imshow 창을 생성합니다. 99%의 시간을 이전에 마지막으로 받은 결과를 다시 그리고 새로운 결과를 기다리는 데 사용합니다. 이 스레드는 절대로 무거운 계산을 해서는 안 됩니다. 그렇지 않으면 화면이 멈출 것입니다.
  3. MPSC 채널 (The "컨베이어 벨트")
    • 코드: let (tx, rx) = mpsc::channel::<(...)>();
    • 임무: 두 스레드를 연결하는 고속 "컨베이어 벨트"입니다.
    • **tx (Transmitter)**는 VO 스레드에 있습니다. 최종 결과물을 보냅니다.
    • **rx (Receiver)**는 메인 스레드에 있습니다. 결과물을 받습니다.

🗺️ 파트 1: 프레임의 여정 (VO 스레드)

run_live_vo_thread 함수 내부에서 단일 프레임이 처리되는 단계별 과정입니다.

1단계: 초기화 (최초 1회 실행)

루프가 시작되기 전에 모든 것을 준비합니다:

  1. load_calibration(): stereo_params_PC.yml 파일을 엽니다. 이 함수는 카메라를 정의하는 모든 핵심 매트릭스(K1, D1, R1, P1, Q 등)를 로드합니다. 또한 P1 매트릭스와 image_height를 기반으로 카메라의 *실제 수직 화각(FOV)*을 계산합니다 (약 30.15도임을 확인했습니다).
  2. init_rectification(): 로드한 보정 데이터를 사용하여 두 개의 "조회 테이블"(RectificationMaps)을 미리 계산합니다. 이 맵은 원본 이미지를 *왜곡(warp)*시켜 완벽하게 정렬되고 왜곡이 제거된 이미지로 만드는 데 사용됩니다.
  3. cap_combined.set(...): 카메라에게 320x240 이미지 두 개가 나란히 붙어 있는 640x240 크기의 단일 와이드 이미지를 보내도록 요청합니다.

2단계: 획득 및 분할 (루프 시작)

  1. cap_combined.read(): 카메라에서 하나의 와이드 프레임을 가져옵니다.
  2. Mat::roi(): roi_l(왼쪽 영역)과 roi_r(오른쪽 영역)을 사용하여 이 와이드 프레임을 curr_left_gray와 curr_right_gray라는 두 개의 개별 Mat 객체로 "잘라냅니다".

3단계: 이미지 보정 (첫 번째 "마법")

스테레오 비전에서 가장 중요한 단계입니다.

  • imgproc::remap(): 이 함수를 두 번 호출합니다. 1단계에서 미리 계산한 RectificationMaps를 사용하여 curr_left_gray와 curr_right_gray의 "왜곡을 풉니다".
  • 결과: rectified_left와 rectified_right 이미지를 얻습니다. 이 이미지들은 이제 완벽하게 정렬되어, 마치 "완벽한" 핀홀 카메라로 찍은 것처럼 되었습니다. 이 과정은 다음 단계가 작동하기 위해 필수적입니다.

4단계: 시차(Disparity) 및 3D 복원

  1. compute_disparity_map(): 두 개의 보정된 이미지를 StereoSGBM 알고리즘에 입력합니다. 알고리즘이 두 이미지를 비교하여 disparity_map_curr(시차 맵)를 생성합니다. 이것은 각 픽셀의 색상깊이를 나타내는 2D 이미지입니다 (밝을수록 가까움).
  2. calib3d::reproject_image_to_3d(): 이것이 두 번째 "마법" 단계입니다. 2D 시차 맵과 Q 매트릭스(보정 파일에서 로드한)를 이 함수에 전달합니다. 이 정보는 2D 깊이 이미지를 완전한 3D 포인트 클라우드(point_cloud_curr)로 변환하는 데 사용됩니다. 이제 각 픽셀은 밀리미터(mm) 단위의 실제 (X, Y, Z) 좌표를 갖게 됩니다.

5단계: Visual Odometry (우리는 어디에 있는가?)

이 단계는 이전 프레임 대비 우리가 어떻게 움직였는지 파악합니다.

  1. detector.detect_and_compute(): rectified_left 이미지에서 수백 개의 "키포인트"(코너나 흥미로운 질감 등)를 찾습니다.
  2. knn_match(): 이번 프레임의 키포인트와 이전 프레임에서 저장해 둔 키포인트(prev_frame_data)를 비교합니다. 이를 통해 수백 개의 2D 매칭 쌍을 얻습니다.
  3. 3D 대응점 생성: 모든 2D 매칭 쌍을 반복하면서, 각 쌍에 대해 현재 point_cloud_curr와 이전 prev_frame_data에서 3D (X,Y,Z) 좌표를 조회합니다. 이제 (3D 공간의 A 지점, 3D 공간의 B 지점) 쌍의 목록을 갖게 됩니다. 이것이 우리의 correspondences 리스트입니다 (예: "70개의 유효한 3D 대응점").
  4. odom.update(): 이 리스트를 Odometry 클래스로 보냅니다. 이 클래스는 RANSAC 알고리즘을 사용하여 이 모든 점들의 움직임을 가장 잘 설명하는 최적의 회전(R) 및 이동(t) 값을 찾습니다. 그런 다음, 이 움직임을 global_rotation과 global_translation에 누적하여 처음 시작점으로부터의 현재 위치를 계속 추적합니다.

6단계: 렌더러를 위한 처리 (Vec<f32> 생성)

이제 최종 결과물을 만듭니다. point_cloud_curr의 모든 픽셀을 반복합니다:

  1. (X,Y,Z) 가져오기: (mm 단위의) (X, Y, Z) 값을 가져옵니다.
  2. 필터링: 유효한 값인지 확인합니다: if z_cv.is_finite() && z_cv > 300.0 && z_cv < 10000.0. 이것이 불량 데이터를 제거하는 30cm ~ 10m "깊이 필터"입니다.
  3. 좌표계 변환: OpenCV 좌표계에서 OpenGL 좌표계로 변환하기 위해 Y축과 Z축을 뒤집습니다 (y_gl = -y_cv, z_gl = -z_cv).
  4. 색상 계산: 깊이에 따라 흰색(가까움)에서 어두운 회색(멈)으로 변하는 그레이스케일 색상을 계산합니다.
  5. points_vec.push(...): 6개의 숫자(x_gl, y_gl, z_gl, r, g, b)를 최종 Vec<f32>에 추가합니다.

7단계: 텍스트 그리기 및 전송

  1. put_text(): 사용자 정의 설정(빨간색, 0.8 크기 등)을 사용하여 point_count, centroid, global_translation 값을 tracking_viz 이미지에 그립니다.
  2. data_tx.send(...): 세 가지 결과물을 하나로 묶어 "컨베이어 벨트"를 통해 메인 스레드로 전송합니다:
    • tracking_viz (텍스트가 그려진 이미지)
    • points_vec (거대한 3D 포인트 목록)
    • (global_rot, global_trans) (최종 자세)

이제 VO 스레드의 루프가 완료되었으며, 즉시 2단계로 돌아가 다음 프레임 처리를 시작합니다.


📦 파트 2: 데이터 수신 및 사용 (메인 스레드)

이 부분은 훨씬 간단하고 빠릅니다. main 함수의 이벤트 루프입니다.

1단계: "컨베이어 벨트" 확인

  • rx.try_recv(): 메인 스레드에서 가장 중요한 부분입니다. VO 스레드에서 새 패키지가 도착했는지 "컨베이어 벨트"를 살짝 엿봅니다.
    • Ok(...)일 경우: 새 패키지가 도착했습니다! if 블록 안으로 들어갑니다.
    • Err(...)일 경우: 새 패키지가 없습니다. if 블록 전체를 건너뛰고 이전 데이터를 다시 그립니다. 이것이 바로 VO 스레드가 느리더라도 3D 뷰가 여전히 부드럽게 느껴지는 이유입니다.

2단계: 모든 상태 업데이트 (새 패키지 도착 시)

  1. highgui::imshow(...): tracking_img를 즉시 OpenCV 창에 표시합니다.
  2. point_cloud.update_data(points_vec): 전체 Vec<f32>를 PointCloud 객체로 보냅니다. 이 객체는 모든 새 정점 데이터를 GPU의 VBO(Vertex Buffer Object)에 업로드합니다.
  3. 자세(Pose)를 뷰 매트릭스(View Matrix)로 변환: 이것이 "1인칭" 카메라의 핵심 로직입니다:
    • vo_rot_na와 vo_trans_na를 cgmath 타입(r_gl, t_gl)으로 변환합니다.
    • 카메라의 3D 위치를 vo_camera_pos_gl에 저장합니다.
    • 이들을 4x4 vo_pose_matrix로 결합합니다.
    • 이 매트릭스를 **역(inverse)**변환하여 **vo_view_matrix**를 만듭니다. (이것은 3D 그래픽스의 핵심 개념입니다: "뷰" 매트릭스는 항상 월드 공간에서의 카메라 "자세"의 역행렬입니다.)

3단계: 장면 렌더링 (매 루프마다 실행)

  1. let view = vo_view_matrix;: 방금 계산한 (또는 새 패키지가 없으면 이전의) vo_view_matrix를 사용하도록 렌더러에 지시합니다.
  2. let proj = perspective(...): 시작할 때 계산한 올바른 FOV를 사용하여 투영 매트릭스를 생성합니다.
  3. let model_matrix = Matrix4::identity();: 포인트 클라우드를 원점(0,0,0)에 그리도록 렌더러에 지시합니다.
  4. point_cloud.draw(...): 이 함수는 GPU에게 "VBO에 있는 모든 점들을 이 model_matrix, view 매트릭스, proj 매트릭스를 사용해서 그려라"고 명령합니다.
  5. axis.draw(...): 월드 좌표축에 대해서도 동일한 작업을 수행합니다.
  6. windowed_context.swap_buffers(): 최종 이미지를 화면에 표시합니다.

🚀 파트 3: "최종 데이터" 활용하기

이제 매 프레임마다 세 가지 매우 가치 있는 데이터를 생성하는 프로그램을 갖게 되었습니다. 이 데이터는 VO 스레드의 data_tx.send(...) 호출 직전에 존재합니다.

  1. point_cloud_curr (원본 Mat): (유효하지 않은 값 포함) 320x240 = 76,800개의 포인트로 구성된 전체 3D 포인트 클라우드 (OpenCV Mat 형식).
  2. points_vec (필터링된 Vec<f32>): 깊이 값으로 필터링된 깨끗한 포인트 클라우드 (약 ~29,000개 포인트), OpenGL에서 렌더링 준비 완료된 형식.
  3. global_translation (자세): 카메라의 (X, Y, Z) 위치를 밀리미터(mm) 단위로 담고 있는 na::Vector3<f32>.

이 데이터를 활용하는 3가지 아이디어는 다음과 같습니다:

아이디어 1: 경로 저장하기 (간단한 SLAM)

카메라가 이동한 경로를 파일에 저장하여 시각화할 수 있습니다.

  • run_live_vo_thread (루프 시작 전):

      use std::io::Write; // 상단에 추가

      let mut camera_path: Vec<na::Vector3<f32>> = Vec::new();

 

 
  • run_live_vo_thread (루프 내부, odom.update 호출 후):

      let (global_rot, global_trans) = odom.get_pose();

      camera_path.push(global_trans.clone());

 

  • 맨 마지막 (루프 종료 후):

      // 프로그램이 종료될 때 경로를 파일로 저장
      let mut file = std::fs::File::create("path_output.txt")?;
      for point in camera_path {
          writeln!(file, "{} {} {}", point.x, point.y, point.z)?;
      }
      println!("Saved camera path to path_output.txt");

 

아이디어 2: 간단한 로봇 내비게이션

자세(pose) 정보를 기초적인 자동화에 사용할 수 있습니다.

  • run_live_vo_thread (루프 내부, odom.update 호출 후):

      let (global_rot, global_trans) = odom.get_pose();

      // 예: 로봇이 1미터(1000mm) 이상 움직였는지 확인
      if global_trans.norm() > 1000.0 {
          println!("경고: 시작 지점에서 1미터 이상 벗어났습니다!");
          // (여기에 로봇 모터에 "후진" 또는 "회전" 같은
          //  명령을 보내는 코드를 추가할 수 있습니다)
      }

 

아이디어 3: 3D 스캔 파일(.xyz) 생성하기

3D 환경을 저장하여 MeshLab 같은 프로그램에서 볼 수 있습니다.

  • run_live_vo_thread (루프 종료 후):

    // 참고: 이것은 *마지막* 프레임만 저장합니다. 실제 3D 스캐너는
      // *모든* 프레임의 포인트들을 자세(pose)를 이용해 결합해야 합니다.

      // 이미 만들어둔 `points_vec`를 사용할 수 있습니다.
      use std::io::Write; // 상단에 추가 (이미 했다면 생략)
      let mut file = std::fs::File::create("scan.xyz")?;

      // `points_vec`는 [x,y,z,r,g,b, x,y,z,r,g,b, ...] 형식입니다.
      // 한 번에 6개 항목씩 건너뜁니다.
      for chunk in points_vec.chunks_exact(6) {
          let x = chunk[0];
          let y = chunk[1];
          let z = chunk[2];
          let r = (chunk[3] * 255.0) as u8; // 색상 값을 0-255 범위로 변환
          let g = (chunk[4] * 255.0) as u8;
          let b = (chunk[5] * 255.0) as u8;

          // "XYZRGB" 형식으로 저장
          writeln!(file, "{} {} {} {} {} {}", x, y, z, r, g, b)?;
      }
      println!("Saved last point cloud to scan.xyz");

 

 

 

 

 

 

'Depth Map with Dual CAM' 카테고리의 다른 글

초저가 USB 쌍안 캠을 사용한 Depth Tracking  (0) 2021.07.12
오늘 도착한 mini 2D lidar입니다. 가격은 $12.50 + $5 shipping 인데, 하루만에 실험에 성공했습니다.
옛날에 쓰던 라이다와 크기를 비교하니 아주 확연히 다르네요. 이 정도 크기라면 드론에 직접 올리는 것도 가능해 보입니다.
 

 

 

깃헙에 찾은 샘플 테스팅 코드 : https://github.com/halac123b/Visualize-data-from-Lidar-LD19_Matplotlib-Python

 

GitHub - halac123b/Visualize-data-from-Lidar-LD19_Matplotlib-Python

Contribute to halac123b/Visualize-data-from-Lidar-LD19_Matplotlib-Python development by creating an account on GitHub.

github.com

문제는 첫 실험 코딩이 matplotlib를 사용해서 엄청 느리기 때문에 PyQtGraph 라이브러리로 바꿔서 코딩해야 할 듯 합니다.

 


이건 나의 실험 케이스 :

   Platform : Orange Pi 5, on UART #0 ( /dev/ttyS0 ) @ 230400 bps.

     Speed control : PWM1_M2, pin # 26, which uses /sys/class/pwm/pwmchip0/

        Then use the following command to make pwm1 output a 50Hz square wave (please
        switch to the root user first, and then execute the following command)
        root@o5bot:~# echo 0 > /sys/class/pwm/pwmchip0/export
        root@o5bot:~# echo 20000000 > /sys/class/pwm/pwmchip0/pwm0/period
        root@o5bot:~# echo 1000000 > /sys/class/pwm/pwmchip0/pwm0/duty_cycle
        root@o5bot:~# echo 1 > /sys/class/pwm/pwmchip0/pwm0/enable

 


Lazarus IDE를 사용해서 Object Pascal로 프로그래밍 하기. 

(사용 라이브러리 : BGRABitmap )

 

+ Recent posts