HWC to CHW: OpenCV Mat 이미지 텐서 변환 방법 with C++

opencv_logo

개요

이 글에서는 HWC 형식을 사용하는 매트릭스에서 CHW 형식으로 변환하는 방법을 설명합니다.

대부분의 딥러닝 모델 추론 프레임워크는 CHW(C x H x W) 차원 순서를 갖는 텐서를 입력으로 받습니다. 그런데, 영상처리(Computer Vision) 분야에서 많이 사용하는 OpenCV 라이브러리는 HWC(H x W x C) 차원 순서의 매트릭스를 사용합니다. 따라서 모델에 입력으로 넣어주기 전에 HWC 형식의 매트릭스를 CHW 형식의 매트릭스로 변환해주는 전처리 과정이 필요합니다.

cv::dnn::blobFromImage

사실 OpenCV 함수 중에는 HWC 형식을 CHW 형식으로 변환해주는 cv::dnn::blobFromImage 함수가 존재합니다. 그러나 아래의 몇 가지 문제로 사용하지 않기로 했습니다.

  • cv::dnn::blobFromImage 함수를 사용하기 위해서 opencv_dnn<version>.lib에 대한 의존성이 추가됩니다. 즉, 이 함수 하나를 사용하기위해 배포시에 opencv_dnn<version>.dll을 같이 배포해야합니다. 큰 문제는 아니지만 의존성은 최대한 줄이는게 좋다고 생각합니다.
  • cv::dnn::blobFromImage 함수를 사용하면 일반화(Normalize) 방법에 제약 혹은 비효율성이 생깁니다. 일반화를 위한 Scale 및 Mean 파라미터가 존재 하지만, Scale 파라미터가 단일 double 값이여서 채널 별로 따로 적용할 수 가 없습니다.
    • 저는 이 문제로 Normalize 함수를 따로 구현해 사용하고 있습니다. 그런데 해당 함수가 FHD 기준 4ms 그리고 cv::dnn::blobFromImage 함수가 약 6m로 전처리에만 10ms가 소요됨으로 상당히 느리다고 할 수 있습니다.

우선 위 문제를 해결하기 전에 cv::dnn::blobFromImage 가 어떤 동작을 하는지 좀 더 자세히 살펴 보겠습니다.

  float data[6][3] = {
        {1.1f, 1.2f, 1.3f},{2.1f, 2.2f, 2.3f},
        {3.1f, 3.2f, 3.3f},{4.1f, 4.2f, 4.3f},
        {5.1f, 5.2f, 5.3f},{6.1f, 6.2f, 6.3f},
    };
    
  cv::Mat img(cv::Size(2, 3), CV_32FC3, data);
image 3
그림 1. cv::Mat img
가시화

정확히 어떻게 변환되는지 확인하기 위해 위와 같이 3x2x3(HxWxC) 이미지를 임의로 생성하였습니다. 또한 채널을 알아보기 쉽게하기위해 소숫점 첫째 자리가 1이면 B채널, 2이면 G채널, 3이면 R채널로 값을 할당 했습니다.

추상화가 잘된 프레임워크들을 사용하는 사용자는 잘 모르겠지만, CUDA 백엔드를 사용하는 프레임 워크들은 flatten한 단일 배열을 입력으로 사용합니다. 따라서 위의 “cv::Mat img”를 flatten 하면 다음과 같습니다.

auto img_flatten = img.reshape(1, 1); // 매트릭스 flatten
image 6
그림 2. cv::Mat img_flatten 가시화

위의 flatten한 매트릭스의 가시화 결과를 보면 매트릭스의 데이터가 BGRBGR…BGR 인 것을 확인 할 수 있습니다.

cv::Mat img_blob;
cv::dnn::blobFromImage(img, img_blob, 1, img.size(), cv::Scalar(0.0, 0.0, 0.0), false);
img_blob = img_blob.reshape(1, 1);
image 7
그림 3. cv::Mat img_blob 가시화

이후 위와 같이 cv::dnn::blobFromImage 적용후의 채널을 보면 BB…GGRR 인 것을 확인 할 수 있습니다. 이처럼 cv::dnn::blobFromImage는 HWC 인 이미지를 CHW 로 변환 해줍니다.

구현

std::vector<float> nhwc2nchw(
    cv::Mat src, 
    std::vector<float> mean = {0.f, 0.f, 0.f}, 
    std::vector<float> std = {1.f, 1.f, 1.f},
    bool swapRB = false)
{
    assert(src.type() == CV_32FC3 && "Input cv::Mat type should be CV_32FC3!!");

    const float* img_data = reinterpret_cast<float*>(src.data);
    const size_t nPixels = src.total();
    const size_t nCh = src.channels();

    std::vector<float> flatten(nPixels * nCh);

    int nPos = 0;
    for (int row = 0; row < src.rows; row++)
    {
        for (int col = 0; col < src.cols; col += 1)
        {
            int nIdx = 0;
            nIdx = row * src.cols * 3 + col * 3;

            std::cout << img_data[nIdx] << std::endl;
            std::cout << img_data[nIdx + 1] << std::endl;
            std::cout << img_data[nIdx + 2] << std::endl;

            flatten[nPos] = (img_data[nIdx] - mean[0]) / std[0];                     // Blue
            flatten[nPos + nPixels] = (img_data[nIdx + 1] - mean[1]) / std[1];       // Grean
            flatten[nPos + (nPixels * 2)] = (img_data[nIdx + 2] - mean[2]) / std[2]; // Red

            if(swapRB)
            {
                std::swap(flatten[nPos], flatten[nPos + (nPixels * 2)]);
            }

            nPos++;
        }
    }

    return flatten;
}

구현 방법은 간단합니다.

  • 먼저 전체 픽셀 수 만큼의 크기로 반환할(출력) std::vector를 초기화 합니다.
  • 이후 입력 이미지의 픽셀값에 하나씩 접근해 출력 벡터의 알맞은 인덱스에 넣어 주면됩니다. 즉 위처럼 Blue 채널의 시작 인덱스는 0, Green 채널의 시작 인덱스는 nPos + nPixels, Red 채널의 시작 인덱스는 nPos + (nPixels * 2)가 됩니다(그림 3. 참조). 추가로 각 픽셀의 값의 채널에 해당하는 mean 값을 빼주고 std 값으로 나누어 Normalize를 해줍니다.
  • swapRB 파라미터는 RGB 채널 순서의 데이터(예, PIL)로 학습한 모델이 많기 때문에 이를 대응하기 위해 OpenCV의 BGR 순서의 채널을 RGB 로 바꿔주기 위해 사용합니다.

결과

std::vector<float> vec_img_my_blob = nhwc2nchw(img);
cv::Mat img_my_blob(1, vec_img_my_blob.size(), CV_32FC1, vec_img_my_blob.data());

위에서 작성한 함수는 cv::Mat이 아닌 flatten된 std::vector를 반환 합니다. 그렇기 때문에 이를 가시화 하기 위해 cv::Mat 생성자를 이용해 cv::Mat 타입으로 만들어 확인해 보면 cv::dnn::blobFromImage를 사용했을 때와 결과 같은 것을 확인할 수 있었습니다.

추가로 구현 전에는 normalize + cv::dnn::blobFromImage을 수행 했을 시에 약 10ms가 소요되었지만, 새로 구현한 함수 nhwc2nchw()는 두 과정을 한번의 픽셀 접근으로 처리해 약 7ms로 줄일 수 있었습니다.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤