C++ 대용량 이미지 처리: OpenCV Mat 참조 카운팅, 필터·변환, SIMD, 멀티스레드 파이프라인

들어가며: “4K 이미지 1000장 처리하면 서버가 죽는다”

일반적인 이미지 처리 코드는 전체 이미지를 메모리에 로드한 후 순차 처리합니다. 하지만 4K(3840×2160) 이미지 1000장을 처리하면 다음과 같은 일이 벌어집니다.

// ❌ 잘못된 방법: 전체 이미지를 메모리에 로드
std::vector<cv::Mat> images;
for (const auto& path : image_paths) {
    cv::Mat img = cv::imread(path);  // 4K 이미지 ≈ 24MB
    images.push_back(img);           // 💥 1000장 × 24MB = 24GB 메모리!
}
// 서버 OOM 크래시

예제는 C++17 이상과 OpenCV 4.x를 기준으로 합니다.

메모리 계산을 해 보면 문제가 명확해집니다. JPEG 파일이 디스크에서 3~5MB라도, 디코딩한 4K 이미지는 3840 × 2160 픽셀 × 3채널 = 약 24.9MB의 압축되지 않은 픽셀입니다. 1000장이면 25GB이고, 처리 중에 필터 결과 같은 중간 버퍼가 한두 장 더 생기면 그 두세 배가 됩니다. 파일 크기만 보고 메모리를 예상하면 열 배 가까이 틀린다는 점이 이 문제의 핵심이며, 그래서 “한 장씩 로드·처리·해제”하는 스트리밍 구조가 기본이 됩니다.


메모리 폭발·EXIF·투명 PNG: 실무 이미지 처리 문제

썸네일 생성 시 메모리 폭발

업로드된 원본 이미지 100장을 200×200 썸네일로 바꿀 때 100장을 전부 디코딩해 두면, 앞에서 계산한 것처럼 장당 수십 MB의 픽셀이 쌓여 수 GB가 됩니다. 한 장씩 로드 → 리사이즈 → 저장 → 해제를 반복하면 메모리는 한 장 분량으로 유지됩니다.

필터 적용 후 이미지가 깨짐

필터나 변환 뒤 가장자리가 검게 나온다면, 테두리 처리 방식이 BORDER_CONSTANT(검은색 0으로 채움)로 지정된 경우입니다. GaussianBlur·filter2D의 기본값은 가장자리를 거울처럼 반사하는 BORDER_REFLECT_101이라 검은 테두리가 생기지 않지만, warpAffine·warpPerspective의 기본값은 BORDER_CONSTANT라서 회전·변형 뒤 빈 영역이 검게 채워집니다. 커널 크기가 짝수인 것은 테두리 문제가 아니라 ksize.width % 2 == 1 assertion 에러의 원인입니다. 필터에는 기본 테두리(BORDER_REFLECT_101)를 유지하고, 변환 후 빈 영역은 borderMode와 borderValue로 원하는 색이나 BORDER_REPLICATE를 지정합니다. 커널 크기는 홀수(3, 5, 7)로 둡니다.

회전 후 이미지 잘림

45도 회전 시 모서리가 잘리는 것은 warpAffine의 출력 크기를 입력과 같게 두었기 때문입니다. 회전된 사각형의 경계 상자 크기로 출력 크기를 잡고 이동 성분을 보정하면 됩니다(아래 회전 예제).

색공간 혼동으로 색상 이상

OpenCV의 기본 채널 순서는 BGR이므로, RGB를 기대하는 라이브러리(딥러닝 프레임워크, 웹 이미지 처리 등)에 그대로 넘기면 빨간색과 파란색이 바뀝니다. 경계에서 cv::cvtColor(img, img, cv::COLOR_BGR2RGB)로 명시적으로 변환합니다. 반대로 OpenCV의 imwrite는 BGR을 기대하므로, RGB로 바꾼 이미지를 그대로 저장하면 색이 뒤바뀝니다.

대량 처리 시 처리 속도 저하

대량 리사이즈가 느리다면 단일 스레드 순차 처리가 원인인 경우가 많습니다. 이미지 단위로 cv::parallel_for_나 스레드 풀로 병렬화하되, 이때는 OpenCV 내부 병렬화와 겹치지 않도록 cv::setNumThreads를 조정합니다.

EXIF 방향 무시로 이미지가 눕거나 뒤집힘

스마트폰 사진이 90도 돌아간 채로 저장된다면 JPEG EXIF Orientation 메타데이터가 무시된 것입니다. cv::imread는 기본 플래그에서 EXIF 방향을 자동 적용하므로, 문제는 대개 IMREAD_UNCHANGED·IMREAD_IGNORE_ORIENTATION을 쓰거나 EXIF를 해석하지 않는 다른 디코더(stb_image 등)로 읽을 때 생깁니다. 그런 경로라면 libexif·exiv2 등으로 Orientation 값을 읽어 cv::rotate/cv::flip으로 직접 적용합니다. 저장할 때는 imwrite가 EXIF를 남기지 않으므로 회전을 픽셀에 반영한 상태로 저장해야 합니다.

투명 PNG 처리 시 검은 배경

알파 채널이 있는 PNG를 JPEG으로 저장하면 JPEG은 알파를 지원하지 않으므로 투명 영역이 검게(픽셀의 원래 색으로) 나옵니다. 흰색이나 원하는 배경색 위에 알파 합성한 뒤 저장하거나, 알파를 지원하는 WebP·PNG로 저장합니다.

실시간 스트림에서 프레임 드롭

실시간 영상에 필터를 걸어 프레임이 떨어진다면, 프레임 간격(30fps면 약 33ms) 안에 처리가 끝나지 않는 것입니다. 해상도를 줄인 뒤 처리하거나, 가우시안 대신 더 싼 박스 필터를 쓰거나, UMat으로 OpenCL 가속을 시도합니다.


vcpkg로 OpenCV 설치와 최소 예제

OpenCV 설치 (vcpkg)

# vcpkg로 OpenCV 설치
vcpkg install opencv4:x64-linux
# CMakeLists.txt
find_package(OpenCV REQUIRED)
target_link_libraries(my_app PRIVATE ${OpenCV_LIBS})

최소 동작 예제

#include <opencv2/opencv.hpp>
#include <iostream>
int main() {
    // 이미지 로드 (BGR 포맷)
    cv::Mat img = cv::imread("input.jpg");
    if (img.empty()) {
        std::cerr << "Failed to load image\n";
        return 1;
    }
    
    std::cout << "Size: " << img.cols << "x" << img.rows
              << ", Channels: " << img.channels() << "\n";
    
    // 그레이스케일 변환
    cv::Mat gray;
    cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);
    
    // 저장
    cv::imwrite("output_gray.jpg", gray);
    return 0;
}

실행: g++ -std=c++17 -o img_proc main.cpp $(pkg-config --cflags --libs opencv4)


이미지 로드·저장·포맷 변환

안전한 이미지 로드

#include <opencv2/opencv.hpp>
#include <filesystem>
#include <stdexcept>
cv::Mat load_image_safe(const std::string& path) {
    namespace fs = std::filesystem;
    
    if (!fs::exists(path)) {
        throw std::runtime_error("File not found: " + path);
    }
    
    // IMREAD_COLOR: BGR 3채널 (기본)
    // IMREAD_UNCHANGED: 알파 채널 포함
    // IMREAD_REDUCED_COLOR_2: 1/2 크기로 로드 (메모리 절약)
    cv::Mat img = cv::imread(path, cv::IMREAD_COLOR);
    
    if (img.empty()) {
        throw std::runtime_error("Failed to decode: " + path);
    }
    
    return img;
}

메모리 효율적인 대량 로드 (스트리밍)

void process_images_streaming(
    const std::vector<std::string>& paths,
    std::function<void(cv::Mat&)> processor
) {
    for (const auto& path : paths) {
        cv::Mat img = cv::imread(path);
        if (img.empty()) continue;
        
        processor(img);  // 처리 후 img는 스코프 종료 시 자동 해제
    }
    // 각 반복마다 img 메모리 해제됨
}

포맷 변환 및 저장

// JPEG 품질 설정 (0-100, 기본 95)
std::vector<int> jpeg_params = {cv::IMWRITE_JPEG_QUALITY, 85};
cv::imwrite("output.jpg", img, jpeg_params);
// PNG 압축 (0-9, 기본값은 버전마다 다르며 4.x 문서 기준 1)
std::vector<int> png_params = {cv::IMWRITE_PNG_COMPRESSION, 6};
cv::imwrite("output.png", img, png_params);
// WebP (품질 0-100)
std::vector<int> webp_params = {cv::IMWRITE_WEBP_QUALITY, 80};
cv::imwrite("output.webp", img, webp_params);

색공간 변환

cv::Mat bgr = cv::imread("input.jpg");
cv::Mat rgb, gray, hsv;
// BGR → RGB (웹/딥러닝 모델용)
cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB);
// BGR → 그레이스케일
cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY);
// BGR → HSV (색상 기반 처리)
cv::cvtColor(bgr, hsv, cv::COLOR_BGR2HSV);

필터 적용 (블러, 샤프닝, 엣지)

Gaussian Blur (가우시안 블러)

cv::Mat apply_gaussian_blur(const cv::Mat& src, int kernel_size = 5) {
    cv::Mat dst;
    // kernel_size는 반드시 양의 홀수 (3, 5, 7, ...)
    // 짝수면 cv::Exception(assertion 실패)이 발생
    cv::GaussianBlur(src, dst, cv::Size(kernel_size, kernel_size), 0);
    return dst;
}

Bilateral Filter (엣지 보존 블러)

// 노이즈 제거하면서 엣지 유지 (느리지만 품질 좋음)
cv::Mat apply_bilateral(const cv::Mat& src) {
    cv::Mat dst;
    cv::bilateralFilter(src, dst, 9, 75, 75);
    // d=9: 필터 직경, sigmaColor=75, sigmaSpace=75
    return dst;
}

샤프닝 (Unsharp Mask)

cv::Mat apply_sharpen(const cv::Mat& src, double strength = 1.0) {
    cv::Mat blurred;
    cv::GaussianBlur(src, blurred, cv::Size(0, 0), 3);
    
    cv::Mat dst;
    cv::addWeighted(src, 1.0 + strength, blurred, -strength, 0, dst);
    return dst;
}

엣지 검출 (Canny)

cv::Mat apply_canny(const cv::Mat& src,
                    double low_thresh = 50,
                    double high_thresh = 150) {
    cv::Mat gray, edges;
    if (src.channels() == 3) {
        cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY);
    } else {
        gray = src.clone();
    }
    
    cv::Canny(gray, edges, low_thresh, high_thresh);
    return edges;
}

Median Filter (노이즈 제거)

// 솔트 앤 페퍼 노이즈에 효과적
cv::Mat apply_median(const cv::Mat& src, int kernel_size = 5) {
    cv::Mat dst;
    cv::medianBlur(src, dst, kernel_size);  // 홀수만
    return dst;
}

Morphological 연산 (모폴로지)

// 팽창·침식으로 노이즈 제거 또는 객체 분리
cv::Mat apply_morphology(const cv::Mat& src) {
    cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
    cv::Mat dst;
    cv::morphologyEx(src, dst, cv::MORPH_CLOSE, kernel);  // 닫기: 팽창 후 침식 (작은 구멍 메우기)
    return dst;
}

커스텀 컨볼루션 필터

cv::Mat apply_convolution(const cv::Mat& src, const cv::Mat& kernel) {
    cv::Mat dst;
    cv::filter2D(src, dst, -1, kernel,
                 cv::Point(-1, -1), 0, cv::BORDER_REFLECT);
    // BORDER_REFLECT: 경계에서 미러링 (검은색 방지)
    return dst;
}
// 예: 라플라시안 (엣지 강조)
void laplacian_example() {
    cv::Mat kernel = (cv::Mat_<float>(3, 3) <<
        0, -1,  0,
       -1,  4, -1,
        0, -1,  0);
    cv::Mat img = cv::imread("input.jpg");
    // 8비트 출력(ddepth=-1)이면 음수 응답이 0으로 잘림: 엣지 크기가 필요하면 CV_16S 등으로 받음
    cv::Mat result = apply_convolution(img, kernel);
}

기하학적 변환 (리사이즈, 회전, 크롭)

리사이즈

cv::Mat resize_image(const cv::Mat& src, int width, int height) {
    cv::Mat dst;
    // INTER_LINEAR: 기본, 속도/품질 균형
    // INTER_AREA: 축소 시 권장 (앨리어싱 감소)
    // INTER_CUBIC: 확대 시 품질 좋음
    cv::resize(src, dst, cv::Size(width, height), 0, 0, cv::INTER_AREA);
    return dst;
}
// 비율 유지 리사이즈
cv::Mat resize_keep_aspect(const cv::Mat& src, int max_dim) {
    double scale = std::min(
        static_cast<double>(max_dim) / src.cols,
        static_cast<double>(max_dim) / src.rows
    );
    cv::Size new_size(static_cast<int>(src.cols * scale),
                      static_cast<int>(src.rows * scale));
    cv::Mat dst;
    cv::resize(src, dst, new_size, 0, 0, cv::INTER_AREA);
    return dst;
}

회전

cv::Mat rotate_image(const cv::Mat& src, double angle_deg) {
    cv::Point2f center(src.cols / 2.0f, src.rows / 2.0f);
    cv::Mat rot = cv::getRotationMatrix2D(center, angle_deg, 1.0);
    
    // 회전 후 크기 계산 (잘림 방지)
    cv::Rect2f bbox = cv::RotatedRect(
        cv::Point2f(), src.size(), angle_deg).boundingRect2f();
    rot.at<double>(0, 2) += bbox.width / 2.0 - src.cols / 2.0;
    rot.at<double>(1, 2) += bbox.height / 2.0 - src.rows / 2.0;
    
    cv::Mat dst;
    cv::warpAffine(src, dst, rot, bbox.size(), cv::INTER_LINEAR);
    return dst;
}

크롭 (Crop)

cv::Mat crop_image(const cv::Mat& src, int x, int y, int w, int h) {
    // 경계 검사
    x = std::max(0, std::min(x, src.cols - 1));
    y = std::max(0, std::min(y, src.rows - 1));
    w = std::min(w, src.cols - x);
    h = std::min(h, src.rows - y);
    
    cv::Rect roi(x, y, w, h);
    return src(roi).clone();  // clone()으로 독립 복사
}

아핀 변환 (Affine)

// 3점 대응으로 아핀 변환
cv::Mat warp_affine(const cv::Mat& src,
                    const std::vector<cv::Point2f>& src_pts,
                    const std::vector<cv::Point2f>& dst_pts) {
    cv::Mat M = cv::getAffineTransform(src_pts, dst_pts);
    cv::Mat dst;
    cv::warpAffine(src, dst, M, src.size(), cv::INTER_LINEAR);
    return dst;
}

썸네일·배치·OCR 전처리 파이프라인

파이프라인 아키텍처

flowchart LR
    A[로드] --> B[리사이즈]
    B --> C[필터]
    C --> D[포맷변환]
    D --> E[저장]
    
    style A fill:#4caf50
    style E fill:#2196f3

처리 흐름 시퀀스 다이어그램

sequenceDiagram
    participant C as Client
    participant P as Pipeline
    participant O as OpenCV
    participant D as Disk
    
    C->>P: process_batch(paths)
    loop 각 이미지
        P->>D: imread(path)
        D-->>P: Mat
        P->>O: resize/filter
        O-->>P: Mat
        P->>D: imwrite(output)
        Note over P: Mat 자동 해제
    end
    P-->>C: 완료

완전한 예제: 썸네일 생성 파이프라인

#include <opencv2/opencv.hpp>
#include <filesystem>
#include <string>
#include <iostream>
struct ThumbnailConfig {
    int width = 200;
    int height = 200;
    int jpeg_quality = 85;
    bool sharpen = false;
};
bool create_thumbnail(const std::string& input_path,
                      const std::string& output_path,
                      const ThumbnailConfig& config) {
    try {
        cv::Mat img = cv::imread(input_path);
        if (img.empty()) return false;
        
        cv::Mat thumb;
        cv::resize(img, thumb, cv::Size(config.width, config.height),
                   0, 0, cv::INTER_AREA);
        
        if (config.sharpen) {
            cv::Mat blurred;
            cv::GaussianBlur(thumb, blurred, cv::Size(0, 0), 1.0);
            cv::addWeighted(thumb, 1.5, blurred, -0.5, 0, thumb);
        }
        
        std::vector<int> params = {cv::IMWRITE_JPEG_QUALITY, config.jpeg_quality};
        return cv::imwrite(output_path, thumb, params);
    } catch (const cv::Exception& e) {
        std::cerr << "OpenCV error: " << e.what() << "\n";
        return false;
    }
}

배치 처리 파이프라인

#include <opencv2/opencv.hpp>
#include <filesystem>
#include <string>
#include <vector>
#include <future>
#include <atomic>
namespace fs = std::filesystem;
void batch_process(const std::vector<std::string>& input_paths,
                   const std::string& output_dir,
                   std::function<cv::Mat(const cv::Mat&)> processor,
                   size_t num_threads = 4) {
    fs::create_directories(output_dir);
    std::atomic<size_t> processed{0};
    
    auto process_one = [&](const std::string& path) {
        cv::Mat img = cv::imread(path);
        if (img.empty()) return;
        
        cv::Mat result = processor(img);
        std::string out_path = output_dir + "/" + fs::path(path).filename().string();
        cv::imwrite(out_path, result);
        ++processed;
    };
    
    std::vector<std::future<void>> futures;
    for (size_t i = 0; i < input_paths.size(); ++i) {
        futures.push_back(std::async(std::launch::async, [&, i]() {
            process_one(input_paths[i]);
        }));
        if (futures.size() >= num_threads) {
            for (auto& f : futures) f.wait();
            futures.clear();
        }
    }
    for (auto& f : futures) f.wait();
}

OCR 전처리 파이프라인 예제

cv::Mat preprocess_for_ocr(const cv::Mat& src) {
    cv::Mat gray, binary;
    cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY);
    
    // 적응형 이진화 (조명 불균일 대응)
    cv::adaptiveThreshold(gray, binary, 255,
                          cv::ADAPTIVE_THRESH_GAUSSIAN_C,
                          cv::THRESH_BINARY, 11, 2);
    
    // 노이즈 제거
    cv::Mat denoised;
    cv::fastNlMeansDenoising(binary, denoised);
    
    return denoised;
}

얼굴 검출 전처리 파이프라인

// Haar Cascade 또는 DNN 전에 그레이스케일 + 히스토그램 평활화
cv::Mat preprocess_for_face(const cv::Mat& src) {
    cv::Mat gray;
    cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY);
    cv::equalizeHist(gray, gray);  // 대비 향상
    return gray;
}

워터마크 오버레이 예제

void add_watermark(cv::Mat& img, const cv::Mat& watermark, int x, int y) {
    cv::Rect roi(x, y, watermark.cols, watermark.rows);
    if (roi.x + roi.width > img.cols || roi.y + roi.height > img.rows) return;
    
    cv::Mat roi_img = img(roi);  // img의 일부를 가리키는 헤더 (데이터 공유)
    if (watermark.channels() == 4) {
        // 알파 블렌딩: out = bg * (1 - a) + wm * a
        std::vector<cv::Mat> ch;
        cv::split(watermark, ch);  // B, G, R, A
        cv::Mat wm_bgr, alpha;
        cv::merge(std::vector<cv::Mat>{ch[0], ch[1], ch[2]}, wm_bgr);
        cv::merge(std::vector<cv::Mat>{ch[3], ch[3], ch[3]}, alpha);
        // 8비트 곱셈은 255에서 포화되므로 float로 계산
        cv::Mat bg_f, wm_f, a_f;
        roi_img.convertTo(bg_f, CV_32FC3);
        wm_bgr.convertTo(wm_f, CV_32FC3);
        alpha.convertTo(a_f, CV_32FC3, 1.0 / 255);
        cv::Mat out = bg_f.mul(cv::Scalar::all(1.0) - a_f) + wm_f.mul(a_f);
        // roi_img = out 처럼 대입하면 헤더만 바뀌고 img에는 기록되지 않음.
        // convertTo로 기존 ROI 버퍼에 직접 써야 원본에 반영됨
        out.convertTo(roi_img, CV_8UC3);
    } else {
        watermark.copyTo(roi_img);  // 타입·크기가 같으면 ROI 버퍼에 그대로 복사
    }
}

empty Mat, 홀수 커널, 채널 불일치: OpenCV 에러 해결

”Failed to load image” 또는 empty Mat

파일 경로 오류, 지원하지 않는 포맷, 권한 문제로 imread가 빈 Mat을 돌려준 경우입니다. imread는 예외 대신 빈 Mat을 반환하므로 반드시 검사합니다.

// ❌ 잘못된 방법
cv::Mat img = cv::imread("image.jpg");
img.convertTo(...);  // img.empty()일 때 크래시!
// ✅ 올바른 방법
cv::Mat img = cv::imread("image.jpg");
if (img.empty()) {
    throw std::runtime_error("Failed to load: image.jpg");
}

GaussianBlur “ksize.width and height must be odd”

커널 크기가 짝수이면 발생합니다.

// ❌ 잘못된 방법
cv::GaussianBlur(img, dst, cv::Size(4, 4), 0);  // 에러!
// ✅ 올바른 방법
cv::GaussianBlur(img, dst, cv::Size(5, 5), 0);
// 또는
int k = 4;
cv::GaussianBlur(img, dst, cv::Size(k * 2 + 1, k * 2 + 1), 0);

cvtColor “Assertion failed” (채널 불일치)

1채널 그레이스케일 이미지에 3채널용 변환 코드를 적용하면 발생합니다.

// ❌ 잘못된 방법
cv::Mat gray = cv::imread("gray.png", cv::IMREAD_GRAYSCALE);
cv::cvtColor(gray, rgb, cv::COLOR_BGR2RGB);  // gray는 1채널!
// ✅ 올바른 방법
if (gray.channels() == 1) {
    cv::cvtColor(gray, rgb, cv::COLOR_GRAY2BGR);
}

resize 시 앨리어싱 (계단 현상)

크게 축소할 때 INTER_NEAREST나 INTER_LINEAR를 쓰면 일부 픽셀만 표본으로 쓰여 계단 현상과 모아레가 생깁니다.

// ❌ 축소 시 품질 저하
cv::resize(large, small, size, 0, 0, cv::INTER_LINEAR);
// ✅ 축소 시 INTER_AREA 권장
cv::resize(large, small, size, 0, 0, cv::INTER_AREA);

불필요한 clone()

cv::Mat은 참조 카운팅이라 누수는 드물지만, 결과를 새로 만드는 함수 앞에 clone()을 붙이면 큰 이미지를 한 번 더 복사합니다. 반환은 헤더만 이동하므로 비용이 거의 없습니다.

// ❌ 비효율적
cv::Mat process(const cv::Mat& src) {
    cv::Mat result = src.clone();  // 불필요한 복사
    cv::GaussianBlur(result, result, cv::Size(5,5), 0);
    return result;  // 헤더 이동, 픽셀 복사 없음
}
// ✅ 효율적
cv::Mat process(const cv::Mat& src) {
    cv::Mat result;
    cv::GaussianBlur(src, result, cv::Size(5,5), 0);
    return result;
}

다중 스레드에서 Mat 공유

같은 Mat 버퍼를 여러 스레드가 동시에 수정하면 데이터 레이스입니다. 대입으로 만든 “복사본”도 같은 버퍼를 공유한다는 점에 주의합니다.

// ❌ 위험
cv::Mat shared;
std::thread t1([&]{ cv::resize(shared, shared, ...); });
std::thread t2([&]{ cv::blur(shared, shared, ...); });  // 데이터 레이스!
// ✅ 안전
cv::Mat shared = cv::imread("img.jpg");
cv::Mat result1, result2;
std::thread t1([&]{ cv::resize(shared, result1, ...); });
std::thread t2([&]{ cv::blur(shared, result2, ...); });

imwrite 실패 시 무시

imwrite는 디스크 부족·권한·경로 오류 시 false를 반환하므로(일부 오류는 예외) 반환값을 확인합니다.

// ❌ 잘못된 방법
cv::imwrite("output.jpg", img);  // 실패해도 모름
// ✅ 올바른 방법
if (!cv::imwrite("output.jpg", img)) {
    throw std::runtime_error("Failed to write output.jpg");
}

Rect 범위 초과

ROI가 이미지 밖으로 나가면 assertion 예외가 발생합니다. 사각형 교집합으로 잘라 냅니다.

// ❌ 잘못된 방법
cv::Rect roi(x, y, w, h);
cv::Mat crop = img(roi);  // x+w > cols 시 cv::Exception
// ✅ 올바른 방법
cv::Rect roi = cv::Rect(x, y, w, h) & cv::Rect(0, 0, img.cols, img.rows);
if (roi.area() == 0) return cv::Mat();
cv::Mat crop = img(roi).clone();

parallel_for_·UMat·조기 리사이즈로 속도 올리기

연속 메모리 활용

// Mat이 연속적이면 단일 루프로 처리 가능
void fast_process(cv::Mat& img) {
    if (img.isContinuous()) {
        // 픽셀 데이터가 연속된 메모리 블록
        size_t n = img.total() * img.elemSize();
        // SIMD 또는 병렬 처리에 유리
    }
}

OpenCV parallel_for_

#include <opencv2/core/parallel.hpp>
void parallel_resize(const std::vector<std::string>& paths,
                    const std::string& out_dir, cv::Size size) {
    cv::parallel_for_(cv::Range(0, static_cast<int>(paths.size())), [&](const cv::Range& r) {
        for (int i = r.start; i < r.end; ++i) {
            cv::Mat img = cv::imread(paths[i]);
            if (img.empty()) continue;  // return하면 이 구간의 나머지 이미지를 건너뜀
            cv::Mat resized;
            cv::resize(img, resized, size, 0, 0, cv::INTER_AREA);
            cv::imwrite(out_dir + "/" + fs::path(paths[i]).filename().string(),
                        resized);
        }
    });
}

parallel_for_는 전체 범위를 여러 구간으로 나눠 각 구간을 스레드에 맡기므로, 람다 안에서 return하면 그 구간에 남은 이미지 전부가 처리되지 않습니다. 원래 코드처럼 실패한 이미지 하나 때문에 수십 장이 조용히 누락되는 버그가 생기기 쉬우므로 continue를 써야 합니다. 또 람다 안에서 예외가 나면 다른 스레드의 작업과 함께 전체가 중단될 수 있으므로, 이미지 하나의 실패는 람다 안에서 잡아 기록하는 편이 안전합니다. 이 방식은 OpenCV 내부 병렬화와 겹치므로 FAQ에서 설명한 cv::setNumThreads 조정도 함께 고려해야 합니다.

UMat (OpenCL 자동 가속)

// OpenCL 장치가 있고 cv::ocl::useOpenCL()이 true일 때 가속, 없으면 CPU로 동작
cv::UMat uimg, uresult;
cv::imread("input.jpg").copyTo(uimg);
cv::GaussianBlur(uimg, uresult, cv::Size(5, 5), 0);
cv::imwrite("output.jpg", uresult);

적절한 인터폴레이션 선택

작업권장 방법이유
축소INTER_AREA앨리어싱 감소
확대INTER_CUBIC품질
실시간INTER_LINEAR속도
최고 품질INTER_LANCZOS4느리지만 최고

조기 리사이즈

// 큰 이미지는 먼저 리사이즈 후 처리 (메모리·속도 절약)
cv::Mat img = cv::imread("4k_image.jpg");
cv::Mat small;
cv::resize(img, small, cv::Size(1920, 1080), 0, 0, cv::INTER_AREA);
// small 기준으로 필터·검출 수행

SIMD 활용 (수동 픽셀 처리 시)

// OpenCV 내부는 이미 SIMD 최적화됩니다. 커스텀 루프 시:
#include <immintrin.h>
void process_row_simd(uint8_t* row, int width) {
    int i = 0;
    for (; i + 32 <= width; i += 32) {
        __m256i v = _mm256_loadu_si256((__m256i*)(row + i));
        // SIMD 연산...
        _mm256_storeu_si256((__m256i*)(row + i), v);
    }
    for (; i < width; ++i) {
        // 남은 픽셀은 스칼라로 같은 연산 수행
    }
}

어디에 시간이 쓰이는지 먼저 측정하기

썸네일 생성처럼 가벼운 파이프라인에서는 필터보다 JPEG 디코딩과 인코딩이 전체 시간의 대부분을 차지하는 경우가 흔합니다. 각 단계 앞뒤에 std::chrono::steady_clock으로 시간을 재 보면 어느 단계를 최적화해야 할지 바로 보이며, 수치는 CPU, OpenCV 빌드 옵션(libjpeg-turbo 사용 여부, SIMD 디스패치), 이미지 내용에 따라 크게 달라지므로 자기 환경에서 측정해야 의미가 있습니다.

디코딩이 병목이라면 가장 효과가 큰 방법은 처음부터 작게 디코딩하는 것입니다. cv::imread(path, cv::IMREAD_REDUCED_COLOR_4)처럼 축소 플래그를 주면 JPEG 디코더가 DCT 단계에서 1/2·1/4·1/8 해상도로 바로 풀어 내므로, 전체 해상도로 디코딩한 뒤 resize하는 것보다 디코딩 시간과 메모리가 모두 크게 줄어듭니다. 200×200 썸네일을 만들면서 4K 전체를 디코딩하는 것은 대부분 낭비입니다. 다만 축소 디코딩 결과는 INTER_AREA 리사이즈만큼 매끄럽지 않을 수 있으므로, 목표보다 조금 큰 크기로 축소 디코딩한 뒤 마지막에 INTER_AREA로 맞추는 조합이 품질과 속도의 균형이 좋습니다.


설정 기반 파이프라인·재시도·메모리 제한 배치

설정 기반 파이프라인

# config.yaml
image_pipeline:
  resize:
    width: 800
    height: 600
    interpolation: INTER_AREA
  filters:
    - type: gaussian_blur
      kernel_size: 3
    - type: sharpen
      strength: 0.5
  output:
    format: jpeg
    quality: 85
// 설정 로드 후 파이프라인 구성
// (YAML 파싱은 yaml-cpp 등 사용)

에러 복구 및 재시도

// 네트워크 파일 시스템처럼 일시적 읽기 오류가 있을 때만 의미가 있음.
// 손상된 파일은 몇 번을 다시 읽어도 실패하므로 재시도로 해결되지 않음
cv::Mat load_with_retry(const std::string& path, int max_retries = 3) {
    for (int i = 0; i < max_retries; ++i) {
        cv::Mat img = cv::imread(path);
        if (!img.empty()) return img;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
    throw std::runtime_error("Failed after retries: " + path);
}

처리 시간 모니터링

#include <chrono>
auto start = std::chrono::steady_clock::now();
cv::Mat result = process_image(img);
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cerr << "Processed in " << ms << " ms\n";

메모리 제한 배치 처리

void process_with_memory_limit(const std::vector<std::string>& paths,
                               size_t max_concurrent = 4) {
    std::vector<std::future<void>> tasks;
    for (const auto& p : paths) {
        tasks.push_back(std::async(std::launch::async, [p]() {
            cv::Mat img = cv::imread(p);
            process_and_save(img, p);
        }));
        if (tasks.size() >= max_concurrent) {
            for (auto& t : tasks) t.wait();
            tasks.clear();
        }
    }
    for (auto& t : tasks) t.wait();
}

이 방식은 동시 처리 수를 max_concurrent로 제한해 메모리 상한을 대략 max_concurrent × (이미지 한 장 + 중간 버퍼)로 묶어 줍니다. 다만 묶음 단위로 모두 끝날 때까지 기다리므로, 한 묶음 안에 유난히 큰 이미지가 있으면 나머지 스레드가 그동안 놀게 됩니다. 처리 시간이 들쭉날쭉하다면 고정 크기 스레드 풀과 작업 큐를 쓰거나, 세마포어로 “동시에 처리 중인 이미지 수”만 제한하는 편이 처리량이 좋습니다. std::async의 future는 소멸자에서 작업 완료를 기다리므로, tasks에 넣지 않고 반환값을 버리면 그 자리에서 동기 실행처럼 동작한다는 점도 흔한 함정입니다.

로깅 및 메트릭

struct ProcessingStats {
    size_t total = 0;
    size_t success = 0;
    size_t failed = 0;
    double total_time_ms = 0;
};
void log_stats(const ProcessingStats& stats) {
    std::cerr << "Processed: " << stats.success << "/" << stats.total
              << ", Failed: " << stats.failed
              << ", Avg: " << (stats.success ? stats.total_time_ms / stats.success : 0.0) << " ms/img\n";
}

OpenCV 대신 고려할 라이브러리

썸네일·포맷 변환처럼 “이미지 서버” 성격의 작업이라면 libvips가 자주 선택됩니다. 이미지를 작은 타일 단위로 스트리밍 처리해 큰 이미지도 적은 메모리로 다루고, EXIF·ICC 색 프로필 같은 메타데이터 처리도 충실합니다. 의존성 없이 읽기·쓰기만 필요하면 stb_image, 명령줄 일괄 변환에는 ImageMagick이 편합니다. OpenCV는 필터·특징 검출·딥러닝 전처리처럼 픽셀 연산이 중심인 작업에 강점이 있지만, 색 프로필을 무시하고 BGR 픽셀만 다루므로 사진 품질이 중요한 서비스에서는 색이 미묘하게 달라지는 문제를 겪을 수 있습니다.

다음 글: 대용량 파일 업로드 이전 글: 검색 엔진 구현


같이 보면 좋은 글